@cosmicdrift/kumiko-framework 1.0.0 → 2.0.0

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 (424) hide show
  1. package/README.md +9 -38
  2. package/package.json +21 -2
  3. package/src/__tests__/anonymous-access.integration.test.ts +185 -0
  4. package/src/__tests__/consumer-cli.integration.test.ts +172 -0
  5. package/src/__tests__/entity-permalink-open.integration.test.ts +94 -0
  6. package/src/__tests__/full-stack.integration.test.ts +1 -1
  7. package/src/__tests__/schema-cli-temporal-polyfill.test.ts +48 -0
  8. package/src/__tests__/schema-cli.integration.test.ts +50 -0
  9. package/src/__tests__/store-table.integration.test.ts +93 -0
  10. package/src/api/__tests__/api.test.ts +373 -0
  11. package/src/api/__tests__/auth-middleware-anonymous-access-boot.test.ts +40 -0
  12. package/src/api/__tests__/auth-routes-cookie.test.ts +17 -1
  13. package/src/api/__tests__/auth-routes-invalid-body-invite.test.ts +253 -0
  14. package/src/api/__tests__/auth-routes-mfa-preauth-confirm.test.ts +222 -0
  15. package/src/api/__tests__/auth-routes-mfa-preauth-enable-start.test.ts +249 -0
  16. package/src/api/__tests__/auth-routes-mfa-verify.test.ts +202 -0
  17. package/src/api/__tests__/auth-routes-trusted-proxy.test.ts +135 -0
  18. package/src/api/__tests__/batch.integration.test.ts +101 -11
  19. package/src/api/__tests__/csrf-constants-sync.test.ts +20 -0
  20. package/src/api/__tests__/dispatcher-live.integration.test.ts +74 -0
  21. package/src/api/__tests__/jwt.test.ts +200 -1
  22. package/src/api/__tests__/login-rate-limiter-sweep.test.ts +50 -0
  23. package/src/api/__tests__/pat-scope.test.ts +36 -0
  24. package/src/api/__tests__/pii-leak-guard.integration.test.ts +103 -0
  25. package/src/api/__tests__/redis-login-rate-limiter.integration.test.ts +138 -0
  26. package/src/api/__tests__/request-id-middleware.test.ts +51 -0
  27. package/src/api/__tests__/server-boot-guards.test.ts +71 -0
  28. package/src/api/__tests__/server-jwt-ttl.test.ts +58 -0
  29. package/src/api/__tests__/sse-broker.test.ts +57 -0
  30. package/src/api/__tests__/sse-route.test.ts +4 -0
  31. package/src/api/api-constants.ts +11 -0
  32. package/src/api/auth-middleware.ts +248 -40
  33. package/src/api/auth-routes.ts +576 -95
  34. package/src/api/index.ts +13 -4
  35. package/src/api/jwt.ts +170 -11
  36. package/src/api/pat-scope.ts +14 -0
  37. package/src/api/pii-leak-guard.ts +47 -0
  38. package/src/api/request-context.ts +3 -0
  39. package/src/api/request-id-middleware.ts +2 -0
  40. package/src/api/routes.ts +169 -2
  41. package/src/api/server.ts +112 -16
  42. package/src/api/sse-broker.ts +39 -0
  43. package/src/bun-db/__tests__/PATTERN.md +0 -1
  44. package/src/bun-db/__tests__/coerce-row-temporal.test.ts +41 -0
  45. package/src/bun-db/__tests__/select-many-retry.test.ts +79 -0
  46. package/src/bun-db/__tests__/write-brand.test.ts +21 -0
  47. package/src/bun-db/connection.ts +3 -3
  48. package/src/bun-db/index.ts +2 -0
  49. package/src/bun-db/query.ts +105 -32
  50. package/src/consumer-cli.ts +134 -0
  51. package/src/crypto/__tests__/blind-index.test.ts +130 -0
  52. package/src/crypto/__tests__/event-pii.test.ts +161 -0
  53. package/src/crypto/__tests__/kek-rotation.integration.test.ts +180 -0
  54. package/src/crypto/__tests__/kms-adapter-contract.ts +134 -0
  55. package/src/crypto/__tests__/kms-adapter.contract.test.ts +4 -0
  56. package/src/crypto/__tests__/pg-kms-adapter.integration.test.ts +117 -0
  57. package/src/crypto/__tests__/pii-field-encryption.test.ts +376 -0
  58. package/src/crypto/__tests__/request-kms-cache.test.ts +74 -0
  59. package/src/crypto/__tests__/subject-resolver.test.ts +108 -0
  60. package/src/crypto/blind-index.ts +118 -0
  61. package/src/crypto/event-pii.ts +70 -0
  62. package/src/crypto/in-memory-kms-adapter.ts +50 -0
  63. package/src/crypto/index.ts +67 -0
  64. package/src/crypto/kms-adapter.ts +2 -0
  65. package/src/crypto/pg-kms-adapter.ts +295 -0
  66. package/src/crypto/pii-field-encryption.ts +248 -0
  67. package/src/crypto/request-kms-cache.ts +38 -0
  68. package/src/crypto/subject-resolver.ts +91 -0
  69. package/src/db/__tests__/assert-no-unreachable-live-rows.integration.test.ts +191 -0
  70. package/src/db/__tests__/blind-index.integration.test.ts +248 -0
  71. package/src/db/__tests__/build-filter-where.test.ts +34 -0
  72. package/src/db/__tests__/collect-table-metas.test.ts +10 -10
  73. package/src/db/__tests__/config-seed.integration.test.ts +13 -5
  74. package/src/db/__tests__/dialect-instant.test.ts +1 -4
  75. package/src/db/__tests__/entity-field-encryption.test.ts +7 -7
  76. package/src/db/__tests__/entity-table-meta-source.test.ts +50 -0
  77. package/src/db/__tests__/event-store-executor-context.pii-roundtrip.test.ts +67 -0
  78. package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +402 -0
  79. package/src/db/__tests__/event-store-executor.integration.test.ts +299 -29
  80. package/src/db/__tests__/feature-table-sources.test.ts +34 -0
  81. package/src/db/__tests__/implicit-projection-equivalence.integration.test.ts +118 -39
  82. package/src/db/__tests__/instant-to-driver-temporal.test.ts +21 -0
  83. package/src/db/__tests__/located-timestamp.test.ts +19 -0
  84. package/src/db/__tests__/migrate-generator.test.ts +21 -0
  85. package/src/db/__tests__/migrate-runner.test.ts +61 -0
  86. package/src/db/__tests__/number-field-fractional.integration.test.ts +58 -0
  87. package/src/db/__tests__/rebuild-marker.test.ts +13 -3
  88. package/src/db/__tests__/replay-migration-sql.test.ts +384 -0
  89. package/src/db/__tests__/schema-inspection.test.ts +7 -0
  90. package/src/db/__tests__/schema-migration.integration.test.ts +1 -1
  91. package/src/db/__tests__/table-builder-meta-lockstep.test.ts +39 -0
  92. package/src/db/__tests__/tenant-db-where-merge.test.ts +49 -3
  93. package/src/db/__tests__/tenant-db.integration.test.ts +6 -2
  94. package/src/db/api.ts +2 -2
  95. package/src/db/apply-entity-event.ts +18 -14
  96. package/src/db/blind-index-cleanup.ts +55 -0
  97. package/src/db/bun-provider.ts +2 -2
  98. package/src/db/collect-table-metas.ts +6 -7
  99. package/src/db/config-seed.ts +9 -9
  100. package/src/db/connection.ts +7 -12
  101. package/src/db/cursor.ts +1 -18
  102. package/src/db/dialect.ts +20 -26
  103. package/src/db/entity-field-encryption.ts +81 -24
  104. package/src/db/entity-table-meta-types.ts +2 -0
  105. package/src/db/entity-table-meta.ts +63 -90
  106. package/src/db/event-store-executor-context.ts +404 -0
  107. package/src/db/event-store-executor-read.ts +275 -0
  108. package/src/db/event-store-executor-write.ts +644 -0
  109. package/src/db/event-store-executor.ts +28 -1155
  110. package/src/db/feature-table-sources.ts +6 -5
  111. package/src/db/index.ts +20 -3
  112. package/src/db/located-timestamp.ts +4 -0
  113. package/src/db/migrate-generator.ts +39 -6
  114. package/src/db/migrate-runner.ts +107 -11
  115. package/src/db/pg-error.ts +9 -1
  116. package/src/db/postgres-provider.ts +2 -2
  117. package/src/db/queries/__tests__/event-store-idempotency-index.integration.test.ts +80 -0
  118. package/src/db/queries/backfill-pii.ts +277 -0
  119. package/src/db/queries/ddl.ts +45 -0
  120. package/src/db/queries/event-consumer.ts +35 -2
  121. package/src/db/queries/event-store.ts +126 -19
  122. package/src/db/queries/projection-rebuild.ts +22 -8
  123. package/src/db/queries/shadow-swap.ts +183 -0
  124. package/src/db/queries/test-stack.ts +4 -30
  125. package/src/db/query-api.ts +1 -0
  126. package/src/db/query.ts +1 -0
  127. package/src/db/rebuild-marker.ts +18 -2
  128. package/src/db/reference-data.ts +2 -3
  129. package/src/db/replay-migration-sql.ts +257 -0
  130. package/src/db/schema-inspection.ts +1 -1
  131. package/src/db/table-builder.ts +102 -71
  132. package/src/db/tenant-db.ts +15 -53
  133. package/src/engine/__tests__/boot-validator-action-wiring.test.ts +242 -0
  134. package/src/engine/__tests__/boot-validator-boot-check.test.ts +99 -0
  135. package/src/engine/__tests__/boot-validator-dashboard.test.ts +237 -0
  136. package/src/engine/__tests__/boot-validator-entity-list.test.ts +104 -0
  137. package/src/engine/__tests__/boot-validator-gdpr-storage.test.ts +7 -69
  138. package/src/engine/__tests__/boot-validator-i18n-keys.test.ts +26 -5
  139. package/src/engine/__tests__/boot-validator-located-timestamps.test.ts +3 -19
  140. package/src/engine/__tests__/boot-validator-pii-retention.test.ts +246 -4
  141. package/src/engine/__tests__/boot-validator.test.ts +219 -15
  142. package/src/engine/__tests__/build-app-schema.test.ts +82 -0
  143. package/src/engine/__tests__/build-config-feature-schema.test.ts +125 -0
  144. package/src/engine/__tests__/build-target.test.ts +3 -11
  145. package/src/engine/__tests__/codemod-pipeline.test.ts +152 -21
  146. package/src/engine/__tests__/config-helpers.test.ts +16 -0
  147. package/src/engine/__tests__/define-feature-entity-mapping.test.ts +6 -0
  148. package/src/engine/__tests__/define-roles.test.ts +21 -0
  149. package/src/engine/__tests__/engine.test.ts +163 -1
  150. package/src/engine/__tests__/entity-handlers.test.ts +80 -0
  151. package/src/engine/__tests__/event-migration-declarative.test.ts +65 -0
  152. package/src/engine/__tests__/event-type-map-augmentation.test.ts +24 -0
  153. package/src/engine/__tests__/extend-entity-projection.test.ts +123 -0
  154. package/src/engine/__tests__/factories-time.test.ts +2 -66
  155. package/src/engine/__tests__/feature-crud-shorthand.test.ts +28 -0
  156. package/src/engine/__tests__/feature-manifest.test.ts +31 -1
  157. package/src/engine/__tests__/field-access.test.ts +23 -1
  158. package/src/engine/__tests__/hook-phases.test.ts +5 -5
  159. package/src/engine/__tests__/membership-roles.test.ts +4 -10
  160. package/src/engine/__tests__/nav.test.ts +86 -1
  161. package/src/engine/__tests__/pipeline-engine.test.ts +9 -9
  162. package/src/engine/__tests__/pipeline-handler.integration.test.ts +18 -18
  163. package/src/engine/__tests__/pipeline-observability.integration.test.ts +2 -2
  164. package/src/engine/__tests__/pipeline-performance.integration.test.ts +3 -3
  165. package/src/engine/__tests__/pipeline-sub-pipelines.test.ts +9 -9
  166. package/src/engine/__tests__/post-query-hook.test.ts +7 -7
  167. package/src/engine/__tests__/registrar-object-form.test.ts +141 -0
  168. package/src/engine/__tests__/registry.test.ts +144 -0
  169. package/src/engine/__tests__/schema-builder.test.ts +18 -0
  170. package/src/engine/__tests__/screen.test.ts +147 -1
  171. package/src/engine/__tests__/soft-delete-cleanup.test.ts +3 -0
  172. package/src/engine/__tests__/store-table.test.ts +229 -0
  173. package/src/engine/__tests__/tier-resolver-extension.test.ts +19 -1
  174. package/src/engine/__tests__/{visual-tree-patterns.test.ts → tree-actions-patterns.test.ts} +7 -95
  175. package/src/engine/__tests__/validate-projection-allowlist.test.ts +15 -15
  176. package/src/engine/boot-validator/__tests__/config-deps.test.ts +92 -0
  177. package/src/engine/boot-validator/action-wiring.ts +149 -0
  178. package/src/engine/boot-validator/boot-check.ts +21 -0
  179. package/src/engine/boot-validator/config-deps.ts +40 -4
  180. package/src/engine/boot-validator/entity-handler.ts +20 -21
  181. package/src/engine/boot-validator/entity-list-screens.ts +88 -0
  182. package/src/engine/boot-validator/gdpr-storage.ts +0 -20
  183. package/src/engine/boot-validator/i18n-keys.ts +58 -4
  184. package/src/engine/boot-validator/index.ts +33 -12
  185. package/src/engine/boot-validator/nav.ts +125 -0
  186. package/src/engine/boot-validator/pii-retention.ts +95 -10
  187. package/src/engine/boot-validator/{screens-nav.ts → screens.ts} +214 -191
  188. package/src/engine/boot-validator/workspaces.ts +68 -0
  189. package/src/engine/build-app-schema.ts +16 -0
  190. package/src/engine/build-config-feature-schema.ts +44 -7
  191. package/src/engine/codemod/pipeline-codemod.ts +5 -5
  192. package/src/engine/config-helpers.ts +15 -0
  193. package/src/engine/constants.ts +32 -6
  194. package/src/engine/create-app.ts +11 -0
  195. package/src/engine/define-feature.ts +95 -947
  196. package/src/engine/define-handler.ts +28 -94
  197. package/src/engine/define-workflow.ts +1 -1
  198. package/src/engine/effective-features.ts +12 -2
  199. package/src/engine/entity-handlers.ts +76 -11
  200. package/src/engine/extensions/tenant-data.ts +19 -0
  201. package/src/engine/extensions/user-data.ts +29 -2
  202. package/src/engine/factories.ts +8 -49
  203. package/src/engine/feature-ast/__tests__/canonical-form.test.ts +26 -29
  204. package/src/engine/feature-ast/__tests__/fixtures/cross-file-registrar/feature.ts +9 -0
  205. package/src/engine/feature-ast/__tests__/fixtures/cross-file-registrar/screens.ts +4 -0
  206. package/src/engine/feature-ast/__tests__/parse-happy-path.test.ts +1 -2
  207. package/src/engine/feature-ast/__tests__/parse-real-features.test.ts +18 -8
  208. package/src/engine/feature-ast/__tests__/parse.test.ts +1640 -160
  209. package/src/engine/feature-ast/__tests__/patch.test.ts +206 -12
  210. package/src/engine/feature-ast/__tests__/patcher.test.ts +20 -23
  211. package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +380 -5
  212. package/src/engine/feature-ast/extractors/events.ts +322 -0
  213. package/src/engine/feature-ast/extractors/handlers.ts +234 -0
  214. package/src/engine/feature-ast/extractors/hooks.ts +243 -0
  215. package/src/engine/feature-ast/extractors/index.ts +34 -31
  216. package/src/engine/feature-ast/extractors/jobs-routes.ts +221 -0
  217. package/src/engine/feature-ast/extractors/projections-screens.ts +269 -0
  218. package/src/engine/feature-ast/extractors/round1.ts +3 -3
  219. package/src/engine/feature-ast/extractors/round5.ts +3 -3
  220. package/src/engine/feature-ast/extractors/round6.ts +2 -36
  221. package/src/engine/feature-ast/extractors/shared.ts +64 -6
  222. package/src/engine/feature-ast/index.ts +2 -4
  223. package/src/engine/feature-ast/parse.ts +130 -23
  224. package/src/engine/feature-ast/patch.ts +39 -50
  225. package/src/engine/feature-ast/patcher.ts +37 -38
  226. package/src/engine/feature-ast/patterns.ts +50 -62
  227. package/src/engine/feature-ast/render.ts +67 -44
  228. package/src/engine/feature-builder-state.ts +168 -0
  229. package/src/engine/feature-config-events-jobs.ts +403 -0
  230. package/src/engine/feature-entity-handlers.ts +208 -0
  231. package/src/engine/feature-manifest.ts +2 -1
  232. package/src/engine/feature-ui-extensions.ts +500 -0
  233. package/src/engine/field-access.ts +13 -2
  234. package/src/engine/field-helpers.ts +31 -0
  235. package/src/engine/handler-helpers.ts +26 -0
  236. package/src/engine/hook-helpers.ts +16 -0
  237. package/src/engine/index.ts +23 -7
  238. package/src/engine/membership-roles.ts +13 -0
  239. package/src/engine/object-form.ts +27 -0
  240. package/src/engine/ownership.ts +26 -79
  241. package/src/engine/pattern-library/__tests__/library.test.ts +7 -20
  242. package/src/engine/pattern-library/library.ts +44 -1152
  243. package/src/engine/pattern-library/mixed-schemas.ts +450 -0
  244. package/src/engine/pattern-library/opaque-schemas.ts +124 -0
  245. package/src/engine/pattern-library/shared-fields.ts +75 -0
  246. package/src/engine/pattern-library/static-schemas.ts +456 -0
  247. package/src/engine/pipeline.ts +6 -11
  248. package/src/engine/registry-facade.ts +362 -0
  249. package/src/engine/registry-ingest.ts +478 -0
  250. package/src/engine/registry-state.ts +388 -0
  251. package/src/engine/registry-validate.ts +642 -0
  252. package/src/engine/registry.ts +78 -1658
  253. package/src/engine/run-pipeline.ts +1 -1
  254. package/src/engine/schema-builder.ts +1 -0
  255. package/src/engine/screen-helpers.ts +54 -0
  256. package/src/engine/soft-delete-cleanup.ts +6 -2
  257. package/src/engine/steps/__tests__/duration-utils.test.ts +20 -0
  258. package/src/engine/steps/_duration-utils.ts +2 -0
  259. package/src/engine/steps/unsafe-projection-upsert.ts +1 -4
  260. package/src/engine/tier-resolver-extension.ts +3 -2
  261. package/src/engine/types/config.ts +2 -482
  262. package/src/engine/types/define-handler.ts +2 -0
  263. package/src/engine/types/entity-handlers.ts +2 -0
  264. package/src/engine/types/event-type-map.ts +1 -37
  265. package/src/engine/types/feature.ts +2 -972
  266. package/src/engine/types/fields.ts +2 -675
  267. package/src/engine/types/handlers.ts +2 -774
  268. package/src/engine/types/hooks.ts +2 -184
  269. package/src/engine/types/http-route.ts +1 -54
  270. package/src/engine/types/identifiers.ts +1 -47
  271. package/src/engine/types/index.ts +86 -40
  272. package/src/engine/types/nav.ts +2 -63
  273. package/src/engine/types/ownership.ts +2 -0
  274. package/src/engine/types/projection.ts +2 -138
  275. package/src/engine/types/relations.ts +1 -51
  276. package/src/engine/types/screen.ts +2 -574
  277. package/src/engine/types/step.ts +2 -334
  278. package/src/engine/types/target-ref.ts +1 -21
  279. package/src/engine/types/tree-node.ts +1 -132
  280. package/src/engine/types/workspace.ts +2 -49
  281. package/src/engine/validate-projection-allowlist.ts +6 -6
  282. package/src/entrypoint/__tests__/entrypoint-job-wiring.integration.test.ts +120 -1
  283. package/src/entrypoint/index.ts +49 -6
  284. package/src/errors/classes.ts +22 -1
  285. package/src/errors/field-issue.ts +0 -3
  286. package/src/errors/index.ts +1 -1
  287. package/src/errors/write-error-info.ts +10 -22
  288. package/src/es-ops/README.md +1 -1
  289. package/src/es-ops/__tests__/runner.integration.test.ts +74 -0
  290. package/src/es-ops/context.ts +4 -2
  291. package/src/es-ops/types.ts +8 -1
  292. package/src/event-store/__tests__/admin-api.integration.test.ts +27 -1
  293. package/src/event-store/__tests__/backfill-pii.integration.test.ts +279 -0
  294. package/src/event-store/__tests__/event-store.integration.test.ts +201 -0
  295. package/src/event-store/__tests__/row-to-stored-event.test.ts +2 -2
  296. package/src/event-store/__tests__/snapshot.integration.test.ts +86 -0
  297. package/src/event-store/__tests__/unscoped-stream-primitives.guard.test.ts +58 -0
  298. package/src/event-store/__tests__/upcaster.integration.test.ts +77 -45
  299. package/src/event-store/admin-api.ts +11 -4
  300. package/src/event-store/errors.ts +2 -35
  301. package/src/event-store/event-store.ts +64 -78
  302. package/src/event-store/events-schema.ts +11 -13
  303. package/src/event-store/index.ts +18 -3
  304. package/src/event-store/rebuild-dead-letter.ts +111 -0
  305. package/src/event-store/snapshot.ts +63 -40
  306. package/src/event-store/types.ts +2 -0
  307. package/src/files/__tests__/build-storage-key.test.ts +28 -0
  308. package/src/files/__tests__/file-ref-entity.test.ts +8 -0
  309. package/src/files/__tests__/files.integration.test.ts +24 -0
  310. package/src/files/__tests__/in-memory-provider.contract.test.ts +4 -0
  311. package/src/files/__tests__/local-provider.test.ts +31 -0
  312. package/src/files/__tests__/provider-resolver.test.ts +70 -0
  313. package/src/files/__tests__/write-stream.test.ts +13 -0
  314. package/src/files/file-handle.ts +2 -19
  315. package/src/files/file-ref-entity.ts +2 -2
  316. package/src/files/file-routes.ts +13 -0
  317. package/src/files/index.ts +1 -1
  318. package/src/files/local-provider.ts +27 -8
  319. package/src/files/provider-resolver.ts +31 -20
  320. package/src/files/types.ts +13 -55
  321. package/src/i18n/__tests__/required-surface-keys.test.ts +17 -0
  322. package/src/i18n/required-surface-keys.ts +120 -1
  323. package/src/jobs/__tests__/job-queue-depth.integration.test.ts +82 -0
  324. package/src/jobs/__tests__/jobs.integration.test.ts +329 -3
  325. package/src/jobs/job-runner.ts +82 -12
  326. package/src/logging/types.ts +1 -7
  327. package/src/migrations/pending-rebuilds.ts +1 -1
  328. package/src/observability/__tests__/observability.integration.test.ts +4 -1
  329. package/src/observability/__tests__/recording-tracer.test.ts +6 -4
  330. package/src/observability/index.ts +2 -0
  331. package/src/observability/noop-provider.ts +0 -9
  332. package/src/observability/recording-tracer.ts +0 -12
  333. package/src/observability/standard-metrics.ts +64 -2
  334. package/src/observability/types/index.ts +1 -29
  335. package/src/observability/types/metric.ts +1 -56
  336. package/src/observability/types/provider.ts +1 -32
  337. package/src/observability/types/span.ts +1 -64
  338. package/src/pipeline/__tests__/ctx-bridge.integration.test.ts +2 -2
  339. package/src/pipeline/__tests__/dispatcher.test.ts +366 -1
  340. package/src/pipeline/__tests__/event-consumer-state.integration.test.ts +31 -0
  341. package/src/pipeline/__tests__/event-dispatcher-delivery-max-attempts.test.ts +126 -0
  342. package/src/pipeline/__tests__/event-dispatcher-rearm.integration.test.ts +263 -0
  343. package/src/pipeline/__tests__/event-dispatcher.integration.test.ts +10 -0
  344. package/src/pipeline/__tests__/job-trigger-consumer.integration.test.ts +106 -0
  345. package/src/pipeline/__tests__/lifecycle-pipeline.test.ts +308 -77
  346. package/src/pipeline/__tests__/load-aggregate-query.integration.test.ts +16 -8
  347. package/src/pipeline/__tests__/post-query-hook.integration.test.ts +3 -3
  348. package/src/pipeline/__tests__/projection-rebuild.integration.test.ts +80 -2
  349. package/src/pipeline/__tests__/rebuild-poison-quarantine.integration.test.ts +274 -0
  350. package/src/pipeline/__tests__/try-append-event.integration.test.ts +166 -0
  351. package/src/pipeline/dispatch-batch.ts +187 -0
  352. package/src/pipeline/dispatch-query.ts +165 -0
  353. package/src/pipeline/dispatch-shared.ts +826 -0
  354. package/src/pipeline/dispatch-stream.ts +90 -0
  355. package/src/pipeline/dispatch-write.ts +451 -0
  356. package/src/pipeline/dispatcher-utils.ts +1 -1
  357. package/src/pipeline/dispatcher.ts +44 -1372
  358. package/src/pipeline/entity-cache.ts +2 -33
  359. package/src/pipeline/event-consumer-state.ts +30 -2
  360. package/src/pipeline/event-dispatcher-admin.ts +293 -0
  361. package/src/pipeline/event-dispatcher-delivery.ts +305 -0
  362. package/src/pipeline/event-dispatcher.ts +85 -542
  363. package/src/pipeline/index.ts +2 -0
  364. package/src/pipeline/msp-rebuild.ts +42 -3
  365. package/src/pipeline/multi-stream-apply-context.ts +4 -42
  366. package/src/pipeline/projection-rebuild.ts +105 -3
  367. package/src/pipeline/system-hooks.ts +144 -1
  368. package/src/rate-limit/__tests__/resolver.integration.test.ts +18 -0
  369. package/src/rate-limit/resolver.ts +16 -32
  370. package/src/schema-cli.ts +76 -16
  371. package/src/search/__tests__/meilisearch-adapter.integration.test.ts +207 -185
  372. package/src/search/__tests__/meilisearch-ids.test.ts +30 -0
  373. package/src/search/__tests__/reindex-entity.integration.test.ts +144 -0
  374. package/src/search/index.ts +6 -0
  375. package/src/search/meilisearch-adapter.ts +13 -12
  376. package/src/search/reindex-entity.ts +177 -0
  377. package/src/search/types.ts +1 -39
  378. package/src/secrets/__tests__/contains-secret.test.ts +34 -0
  379. package/src/secrets/__tests__/envelope-cipher.test.ts +59 -0
  380. package/src/secrets/__tests__/envelope.test.ts +1 -1
  381. package/src/secrets/dek-cache.ts +26 -5
  382. package/src/secrets/envelope-cipher.ts +53 -0
  383. package/src/secrets/envelope.ts +5 -3
  384. package/src/secrets/index.ts +13 -1
  385. package/src/secrets/stored-envelope.ts +46 -0
  386. package/src/secrets/types.ts +2 -162
  387. package/src/stack/__tests__/event-collector.test.ts +42 -0
  388. package/src/stack/__tests__/setup-test-stack-jobs.integration.test.ts +125 -0
  389. package/src/stack/db.ts +2 -1
  390. package/src/stack/push-entity-projection-tables.ts +4 -3
  391. package/src/stack/redis.ts +8 -0
  392. package/src/stack/request-helper.ts +38 -2
  393. package/src/stack/table-helpers.ts +6 -4
  394. package/src/stack/test-stack.ts +249 -131
  395. package/src/testing/__tests__/late-bound.test.ts +32 -0
  396. package/src/testing/__tests__/wait-for.test.ts +59 -0
  397. package/src/testing/boot-validator-fixture.ts +121 -0
  398. package/src/testing/e2e-generator.ts +8 -0
  399. package/src/testing/file-provider-contract.ts +104 -0
  400. package/src/testing/handler-context.ts +3 -1
  401. package/src/testing/index.ts +5 -0
  402. package/src/testing/late-bound.ts +5 -3
  403. package/src/testing/mutable-master-key-provider.ts +29 -3
  404. package/src/testing/wait-for.ts +3 -0
  405. package/src/testing/without-ambient-temporal.ts +14 -0
  406. package/src/time/__tests__/tz-dateline.test.ts +8 -10
  407. package/src/time/geo-tz.ts +1 -32
  408. package/src/time/index.ts +1 -0
  409. package/src/time/legacy-date.ts +18 -0
  410. package/src/time/polyfill.ts +21 -38
  411. package/src/time/tz-context.ts +44 -85
  412. package/src/ui-types/app-schema.ts +12 -0
  413. package/src/ui-types/index.ts +20 -7
  414. package/src/utils/__tests__/safe-json-temporal.test.ts +18 -0
  415. package/src/utils/safe-json.ts +13 -1
  416. package/src/__tests__/raw-table.integration.test.ts +0 -116
  417. package/src/bun-db/__tests__/bun-test-stack.ts +0 -6
  418. package/src/db/__tests__/encryption.test.ts +0 -39
  419. package/src/db/encryption.ts +0 -39
  420. package/src/db/row-helpers.ts +0 -4
  421. package/src/engine/__tests__/raw-table.test.ts +0 -150
  422. package/src/engine/__tests__/unmanaged-table.test.ts +0 -127
  423. package/src/engine/feature-ast/__tests__/visual-tree-parse.test.ts +0 -184
  424. package/src/engine/feature-ast/extractors/round4.ts +0 -1366
@@ -1,8 +1,10 @@
1
+ import { createHash } from "node:crypto";
1
2
  import type { Context } from "hono";
2
3
  import { Hono } from "hono";
3
4
  import { deleteCookie, setCookie } from "hono/cookie";
5
+ import type Redis from "ioredis";
4
6
  import { z } from "zod";
5
- import { stripForbiddenMembershipRoles } from "../engine/membership-roles";
7
+ import { buildSessionRoles } from "../engine/membership-roles";
6
8
  import { createSystemUser } from "../engine/system-user";
7
9
  import { type SessionUser, SYSTEM_TENANT_ID, type TenantId } from "../engine/types";
8
10
  import { NotFoundError } from "../errors";
@@ -15,16 +17,11 @@ import {
15
17
  type AuthSessionStatus,
16
18
  CSRF_COOKIE_NAME,
17
19
  getUser,
20
+ type TokenVerifier,
18
21
  } from "./auth-middleware";
19
22
  import type { JwtHelper } from "./jwt";
20
23
  import { generateToken } from "./tokens";
21
24
 
22
- // Cookie lifetime must track the JWT's exp claim — both are issued together,
23
- // both reference the same session. jwt.ts's createJwtHelper hardcodes
24
- // setExpirationTime("24h"); if that ever becomes configurable this constant
25
- // follows it.
26
- const JWT_TTL_SECONDS = 24 * 60 * 60;
27
-
28
25
  // Resolves the Secure cookie flag. Locked off in dev/test so Playwright
29
26
  // against http://localhost:… can actually receive the cookie. Production
30
27
  // flips it on — browsers drop Secure cookies on http, so a misconfigured
@@ -46,6 +43,10 @@ function setAuthCookies(
46
43
  csrfToken: string;
47
44
  sameSite: "lax" | "strict";
48
45
  domain?: string | undefined;
46
+ // Cookie lifetime must track the JWT's exp claim — both are issued
47
+ // together, both reference the same session. Callers pass jwt.ttlSeconds
48
+ // so the two never drift apart.
49
+ ttlSeconds: number;
49
50
  },
50
51
  ): void {
51
52
  const sameSite = opts.sameSite === "strict" ? "Strict" : "Lax";
@@ -53,7 +54,7 @@ function setAuthCookies(
53
54
  secure: cookieSecure(),
54
55
  sameSite,
55
56
  path: "/",
56
- maxAge: JWT_TTL_SECONDS,
57
+ maxAge: opts.ttlSeconds,
57
58
  ...(opts.domain !== undefined && { domain: opts.domain }),
58
59
  } as const;
59
60
 
@@ -96,6 +97,36 @@ const LoginBody = z.object({
96
97
  password: z.string(),
97
98
  });
98
99
 
100
+ // Body schema for POST /auth/mfa/verify. challengeToken is opaque to the
101
+ // framework — it's minted and verified entirely by the mfaVerifyHandler
102
+ // (auth-mfa owns the token format, TOTP/recovery-code check, and the
103
+ // brute-force cap). code covers both 6-digit TOTP and XXXX-XXXX recovery
104
+ // codes (9 chars incl. the dash).
105
+ const MfaVerifyBody = z.object({
106
+ challengeToken: z.string().min(1),
107
+ code: z.string().min(6).max(9),
108
+ });
109
+
110
+ // Body schema for POST /auth/mfa/preauth-enable-start. preauthSetupToken is
111
+ // opaque to the framework — minted by login.write.ts's mfaStatusChecker
112
+ // (auth-mfa) when enforcement policy blocks an unenrolled user, verified
113
+ // entirely by the mfaPreauthEnableStartHandler. accountLabel is
114
+ // client-supplied (the email the user just typed at login) for the
115
+ // otpauth:// URI, mirroring auth-mfa's own enable-start.write.ts.
116
+ const MfaPreauthEnableStartBody = z.object({
117
+ preauthSetupToken: z.string().min(1),
118
+ accountLabel: z.string().min(1).max(200),
119
+ });
120
+
121
+ // Body schema for POST /auth/mfa/preauth-confirm. setupToken is opaque to
122
+ // the framework — minted by the mfaPreauthEnableStartHandler, verified
123
+ // entirely by the mfaPreauthConfirmHandler. code is TOTP-only (no recovery
124
+ // codes at enrollment time), mirroring auth-mfa's own enable-confirm.write.ts.
125
+ const MfaPreauthConfirmBody = z.object({
126
+ setupToken: z.string().min(1),
127
+ code: z.string().length(6),
128
+ });
129
+
99
130
  const ResetPasswordBody = z.object({
100
131
  token: z.string().min(1),
101
132
  newPassword: z.string().min(8).max(200),
@@ -187,6 +218,12 @@ export type SessionCreator = (user: SessionUser, meta: SessionMetadata) => Promi
187
218
  // there's nothing to revoke.
188
219
  export type SessionRevoker = (sid: string) => Promise<void>;
189
220
 
221
+ // Mass-revoke every live session for a user. Used by password-change and
222
+ // "sign out everywhere" (sessions feature) and by auth-foundation's
223
+ // SessionStore extension point — canonical home so both can re-export
224
+ // the same type instead of restating it.
225
+ export type SessionMassRevoker = (userId: string) => Promise<number>;
226
+
190
227
  // Status reported by the session-store to the auth-middleware. The concrete
191
228
  // type lives on auth-middleware to keep the tight coupling visible there;
192
229
  // auth-routes just re-uses the alias for the AuthRoutesConfig surface.
@@ -216,6 +253,70 @@ export type AuthRoutesConfig = {
216
253
  // Rate-limit for POST /auth/login. Defaults to in-memory 10/5min per
217
254
  // (ip + email) bucket. Pass `null` to disable (tests, trusted networks).
218
255
  loginRateLimit?: LoginRateLimiter | null;
256
+ // Optional: qualified write handler completing a two-step (password +
257
+ // TOTP/recovery-code) login. When set, POST /auth/mfa/verify dispatches
258
+ // { challengeToken, code } to this handler with a guest identity. On
259
+ // success the handler must return { kind: "mfa-verify-success", session:
260
+ // SessionUser } and the route mints a JWT exactly like /auth/login.
261
+ // Everything MFA-specific (challenge-token format, TOTP/recovery check,
262
+ // the per-account brute-force cap) is owned by the handler — the
263
+ // framework stays as agnostic about it as it is about password hashing.
264
+ mfaVerifyHandler?: string;
265
+ // Maps mfaVerifyHandler error codes to HTTP status codes, same pattern as
266
+ // loginErrorStatusMap. Unknown errors default to the error's own httpStatus.
267
+ mfaVerifyErrorStatusMap?: Readonly<Record<string, number>>;
268
+ // Rate-limit for POST /auth/mfa/verify, keyed by client IP. Defaults to
269
+ // in-memory 10/5min. Pass `null` to disable. This is DELIBERATELY separate
270
+ // from loginRateLimit: unlike /auth/login (a dispatcher write-handler
271
+ // route that could inherit a handler-level rateLimit), this is a
272
+ // framework route with no handler.rateLimit to fall back on — and it's
273
+ // also separate from any per-account brute-force cap the mfaVerifyHandler
274
+ // enforces itself (see its own doc comment) — IP-scoped abuse protection
275
+ // and per-account guessing protection are different threats, neither
276
+ // substitutes for the other.
277
+ mfaVerifyRateLimit?: LoginRateLimiter | null;
278
+ // Optional: qualified write handler generating a TOTP secret + QR for a
279
+ // user blocked at login by MFA enforcement but not yet enrolled (see
280
+ // mfaStatusChecker's setupRequired branch, auth-mfa). When set, POST
281
+ // /auth/mfa/preauth-enable-start dispatches { preauthSetupToken,
282
+ // accountLabel } to this handler with a guest identity — no session is
283
+ // minted, the response is just { setupToken, otpauthUri, recoveryCodes }
284
+ // (same shape as auth-mfa's own enable-start.write.ts).
285
+ mfaPreauthEnableStartHandler?: string;
286
+ // Maps mfaPreauthEnableStartHandler error codes to HTTP status codes,
287
+ // same pattern as mfaVerifyErrorStatusMap.
288
+ mfaPreauthEnableStartErrorStatusMap?: Readonly<Record<string, number>>;
289
+ // Rate-limit for POST /auth/mfa/preauth-enable-start, keyed by client IP.
290
+ // Defaults to in-memory 10/5min. Pass `null` to disable. This route does
291
+ // NOT inherit the generic L2 authEndpointRateLimit on /api/auth/* by
292
+ // default (that's opt-in per-app via runProdApp's rateLimit.auth), and a
293
+ // preauthSetupToken is valid for the full setup-token TTL and not
294
+ // single-use until preauth-confirm burns it — an unrate-limited replay
295
+ // means every hit re-derives a fresh TOTP secret + 8 argon2id recovery-
296
+ // code hashes (recovery-codes.ts, Promise.all), a memory-hard CPU
297
+ // amplifier reachable from a single stolen/leaked token.
298
+ mfaPreauthEnableStartRateLimit?: LoginRateLimiter | null;
299
+ // Optional: qualified write handler completing the enrollment started by
300
+ // mfaPreauthEnableStartHandler — takes the secret-carrying setupToken
301
+ // from that step plus a TOTP code. When set, POST /auth/mfa/preauth-
302
+ // confirm dispatches { setupToken, code } to this handler with a guest
303
+ // identity. On success the handler must return { kind: "mfa-preauth-
304
+ // confirm-success", session: SessionUser } and the route mints a JWT
305
+ // exactly like /auth/mfa/verify — this call both finishes enrollment AND
306
+ // completes the login that was blocked by mfa-setup-required. Unlike
307
+ // mfaPreauthEnableStartHandler, THIS is the per-account TOTP-guessing
308
+ // surface the enable-start doc comment refers to — the handler owns its
309
+ // own brute-force cap (mirroring mfaVerifyHandler's), and this route adds
310
+ // its own IP-scoped rate limiter for the same reason mfaVerifyRateLimit
311
+ // exists separately from loginRateLimit.
312
+ mfaPreauthConfirmHandler?: string;
313
+ // Maps mfaPreauthConfirmHandler error codes to HTTP status codes, same
314
+ // pattern as mfaVerifyErrorStatusMap.
315
+ mfaPreauthConfirmErrorStatusMap?: Readonly<Record<string, number>>;
316
+ // Rate-limit for POST /auth/mfa/preauth-confirm, keyed by client IP. Same
317
+ // reasoning as mfaVerifyRateLimit — defaults to in-memory 10/5min, pass
318
+ // `null` to disable.
319
+ mfaPreauthConfirmRateLimit?: LoginRateLimiter | null;
219
320
  // Session-lifecycle callbacks. When both are wired the JWT carries a `jti`
220
321
  // (sid) and the server can revoke individual sessions (logout, compromise,
221
322
  // password-change). When unwired the framework issues plain stateless JWTs.
@@ -228,10 +329,19 @@ export type AuthRoutesConfig = {
228
329
  // at login, check it here on every request. Leaving this empty disables
229
330
  // the revocation path — old JWTs stay valid until they expire naturally.
230
331
  sessionChecker?: SessionChecker;
231
- // When true, a JWT WITHOUT a sid is rejected. Use during deploy-rollouts
232
- // once all fresh JWTs emit a sid and the legacy stateless tokens are
233
- // expected to have expired. Default false keeps old tokens working.
234
- sessionStrictMode?: boolean;
332
+ // Resolves bearer tokens (any registered auth-foundation `tokenVerifier`
333
+ // provider PAT today, future JWT-provider), consulted BEFORE jwt.verify.
334
+ // Wired by run-prod-app/run-dev-app when at least one provider feature is
335
+ // mounted; unwired = no non-JWT bearer auth.
336
+ tokenVerifier?: TokenVerifier;
337
+ // Per-token request-rate limiter for PAT-authenticated requests, keyed by
338
+ // the token id (SessionUser.pat.tokenId). Cookie/JWT requests are unaffected.
339
+ // Reuses the LoginRateLimiter shape (a generic keyed check/reset limiter).
340
+ // Wired by run-prod-app when the PAT feature is mounted; unwired = no PAT
341
+ // rate limiting.
342
+ patRateLimiter?: LoginRateLimiter;
343
+ // Tenant-lifecycle 410 gate — wired by tenant-lifecycle / run-prod-app.
344
+ resolveTenantLifecycleStatus?: import("./auth-middleware").TenantLifecycleStatusResolver;
235
345
  // Password-reset flow. When wired, POST /auth/request-password-reset and
236
346
  // POST /auth/reset-password are mounted as public routes. The framework
237
347
  // dispatches to the feature-level handlers (authoring QNs typically come
@@ -242,6 +352,12 @@ export type AuthRoutesConfig = {
242
352
  passwordReset?: PasswordResetConfig;
243
353
  // Email-verification flow. Symmetric to passwordReset.
244
354
  emailVerification?: EmailVerificationConfig;
355
+ // Account-unlock flow (#1266). When wired, POST
356
+ // /auth/request-account-unlock + /auth/confirm-account-unlock are
357
+ // mounted as public routes. Confirm needs no extra body field
358
+ // (token-only, like email-verification) — the handler only clears the
359
+ // Redis lockout state, no entity write.
360
+ accountUnlock?: AccountUnlockConfig;
245
361
  // Self-Signup (Magic-Link). Wenn wired, mountet POST
246
362
  // /auth/signup-request + /auth/signup-confirm. Confirm returnt JWT-
247
363
  // Cookie + Session-Body wie login.
@@ -295,6 +411,20 @@ export type AuthRoutesConfig = {
295
411
  // subdomain can then forge authenticated state-changing requests. Prefer
296
412
  // setting `allowedOrigins`.
297
413
  unsafeSkipOriginCheck?: boolean;
414
+ // Number of trusted reverse-proxy hops between the client and this
415
+ // process that APPEND (not overwrite) their peer address to
416
+ // `x-forwarded-for` (e.g. nginx `$proxy_add_x_forwarded_for`). Used to
417
+ // derive the client IP for every auth rate-limiter (login, mfa-verify,
418
+ // preauth-enable-start, preauth-confirm) and for requestMeta's session
419
+ // IP — kumiko-framework#1539. Default 0 = legacy behavior, trust the
420
+ // first XFF entry (or x-real-ip) unconditionally; spoofable by design,
421
+ // kept as the default so unconfigured deployments don't regress into a
422
+ // shared "unknown" bucket (mfa-verify/preauth-confirm key on bare IP, no
423
+ // email composite — collapsing everyone into one bucket is a DoS worse
424
+ // than the spoofing hole). Set this to your real proxy hop count (1 for
425
+ // a single ingress/reverse-proxy, 2 for edge-LB + ingress, etc.) to close
426
+ // the hole; see clientIpOf's doc comment for the extraction algorithm.
427
+ trustedProxyHops?: number;
298
428
  };
299
429
 
300
430
  export type PasswordResetConfig = {
@@ -312,6 +442,13 @@ export type EmailVerificationConfig = {
312
442
  confirmHandler: string;
313
443
  };
314
444
 
445
+ export type AccountUnlockConfig = {
446
+ requestHandler: string;
447
+ // Token-only body (mirrors EmailVerificationConfig) — no entity write, so
448
+ // there's no newPassword-equivalent field.
449
+ confirmHandler: string;
450
+ };
451
+
315
452
  // Tenant-Invite Magic-Link. Drei Accept-Branches für klare Separation:
316
453
  // - acceptHandler: logged-in User akzeptiert via JWT (Branch 1)
317
454
  // - acceptWithLoginHandler: anon User mit existing email (Branch 2)
@@ -342,15 +479,73 @@ export type SignupConfig = {
342
479
  confirmHandler: string;
343
480
  };
344
481
 
482
+ // Derives the caller IP from proxy headers, single source for the
483
+ // rate-limiter keys below and requestMeta. kumiko-framework#1523/#1522/#1539:
484
+ // `x-forwarded-for` is attacker-controlled unless we know how many trusted
485
+ // proxy hops sit between the client and this process — `trustedProxyHops`
486
+ // (AuthRoutesConfig) is that count.
487
+ //
488
+ // hops === 0 (default): legacy behavior — trust the first XFF entry (or
489
+ // x-real-ip) at face value. Kept as the default so unconfigured
490
+ // deployments don't regress: collapsing everyone into a single "unknown"
491
+ // bucket would turn mfa-verify/preauth-confirm's pure-IP-keyed limiter
492
+ // into a one-request-locks-out-everyone DoS, which is worse than the
493
+ // spoofing hole this issue is about. Apps behind a proxy MUST set this to
494
+ // close the hole — see AuthRoutesConfig.trustedProxyHops.
495
+ //
496
+ // hops >= 1: each trusted proxy appends (not overwrites) its peer address
497
+ // to XFF (e.g. nginx `$proxy_add_x_forwarded_for`), so the last `hops`
498
+ // entries are proxy-supplied and everything before them — including the
499
+ // real client — sits at `entries[length - hops]`. Anything the client
500
+ // itself prepended lands further left and is ignored. If the header has
501
+ // fewer entries than `hops`, the proxy chain is shorter than configured
502
+ // (misconfiguration or a bypassed hop) — fall back to "unknown" rather
503
+ // than trusting a shorter, potentially attacker-controlled chain.
504
+ // x-real-ip is NOT consulted in this branch: unlike XFF it has no
505
+ // standardized hop-count semantics, so there's no safe way to validate it
506
+ // against a configured hop count.
507
+ //
508
+ // Callers that need a hard boundary regardless of this config
509
+ // (preauth-enable-start) additionally key on something the caller can't
510
+ // freely choose (see preauthTokenKeyOf below).
511
+ function clientIpOf(
512
+ c: { req: { header(name: string): string | undefined } },
513
+ trustedProxyHops = 0,
514
+ ): string {
515
+ if (trustedProxyHops <= 0) {
516
+ return (
517
+ c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ??
518
+ c.req.header("x-real-ip") ??
519
+ "unknown"
520
+ );
521
+ }
522
+ const entries = (c.req.header("x-forwarded-for") ?? "")
523
+ .split(",")
524
+ .map((entry) => entry.trim())
525
+ .filter((entry) => entry.length > 0);
526
+ if (entries.length < trustedProxyHops) {
527
+ return "unknown";
528
+ }
529
+ return entries[entries.length - trustedProxyHops] ?? "unknown";
530
+ }
531
+
532
+ // Second rate-limit axis for preauth-enable-start: unlike the IP, a
533
+ // preauthSetupToken isn't freely choosable by the attacker (it's minted by
534
+ // login.write.ts), so hashing it into a bucket key still caps the replay
535
+ // even when the IP-based bucket is bypassed via header rotation.
536
+ function preauthTokenKeyOf(token: string): string {
537
+ return `preauth-token:${createHash("sha256").update(token).digest("hex")}`;
538
+ }
539
+
345
540
  // Extract `ip` and `user-agent` for the sessionCreator.
346
541
  // Hono's `c.req.header(...)` returns undefined for missing headers; we coerce
347
542
  // them to "unknown" rather than throwing because auth-routes are a public
348
543
  // surface and we don't want header-sniffing bugs to break login.
349
- function requestMeta(c: { req: { header(name: string): string | undefined } }): SessionMetadata {
350
- const ip =
351
- c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ??
352
- c.req.header("x-real-ip") ??
353
- "unknown";
544
+ function requestMeta(
545
+ c: { req: { header(name: string): string | undefined } },
546
+ trustedProxyHops = 0,
547
+ ): SessionMetadata {
548
+ const ip = clientIpOf(c, trustedProxyHops);
354
549
  const userAgent = c.req.header("user-agent") ?? "unknown";
355
550
  return { ip, userAgent };
356
551
  }
@@ -416,6 +611,59 @@ export function createInMemoryLoginRateLimiter(
416
611
  };
417
612
  }
418
613
 
614
+ // Redis-backed sibling of createInMemoryLoginRateLimiter — same fixed-window
615
+ // semantics (count <= maxAttempts allows, window anchored on first hit), but
616
+ // shared across replicas via INCR/PEXPIRE instead of an in-process Map.
617
+ // runProdApp defaults to this: an in-memory limiter only rate-limits within
618
+ // a single instance, so a multi-replica prod deployment would silently give
619
+ // each replica its own bucket. namespace separates the login-key keyspace
620
+ // from the mfa-verify one (they share the same LoginRateLimiter shape but
621
+ // key on different values).
622
+ // Fail-closed on Redis outage, unlike the in-memory limiter (which never
623
+ // throws): `check`/`reset` propagate any Redis error, so callers 500
624
+ // instead of falling back to unlimited attempts. Deliberate — Redis is
625
+ // already required infra for a multi-replica deployment, and for a
626
+ // security-relevant limiter "briefly unavailable" should read as "briefly
627
+ // down", not "briefly unlimited".
628
+ export function createRedisLoginRateLimiter(
629
+ redis: Redis,
630
+ maxAttempts = 10,
631
+ windowMs = 5 * 60_000,
632
+ namespace = "login",
633
+ ): LoginRateLimiter {
634
+ const prefix = `kumiko:auth:ratelimit:${namespace}:`;
635
+
636
+ return {
637
+ async check(key) {
638
+ const redisKey = `${prefix}${key}`;
639
+ // INCR then PEXPIRE was two round-trips: a crash/network blip between
640
+ // them (only possible right after count===1, when the key is fresh)
641
+ // left the key permanently without a TTL — the window never resets,
642
+ // the counter only grows, and the bucket is locked out until an
643
+ // operator manually resets/deletes the key. One atomic eval closes
644
+ // the gap: the TTL is set in the same script invocation that creates
645
+ // the key, so no observer (including a crash) can see count===1
646
+ // without the expiry already applied. The `PTTL < 0` branch also
647
+ // heals keys that already exist without a TTL from before this fix
648
+ // shipped (PTTL returns -1 for "no expiry set, key exists") — without
649
+ // it, those pre-existing keys would grow forever with no way back to
650
+ // a normal window.
651
+ const count = (await redis.eval(
652
+ `local c = redis.call('INCR', KEYS[1])
653
+ if c == 1 or redis.call('PTTL', KEYS[1]) < 0 then redis.call('PEXPIRE', KEYS[1], ARGV[1]) end
654
+ return c`,
655
+ 1,
656
+ redisKey,
657
+ windowMs,
658
+ )) as number;
659
+ return count <= maxAttempts;
660
+ },
661
+ async reset(key) {
662
+ await redis.del(`${prefix}${key}`);
663
+ },
664
+ };
665
+ }
666
+
419
667
  export function createAuthRoutes(
420
668
  dispatcher: Dispatcher,
421
669
  jwt: JwtHelper,
@@ -427,6 +675,44 @@ export function createAuthRoutes(
427
675
  // working. High-security apps can opt into "strict" — see AuthRoutesConfig.
428
676
  const cookieSameSite = config.cookieSameSite ?? "lax";
429
677
  const cookieDomain = config.cookieDomain;
678
+ // Single hop-count-aware IP getter for every rate-limit call site below —
679
+ // see AuthRoutesConfig.trustedProxyHops / clientIpOf's doc comment. Fail
680
+ // loud on a non-finite/negative value rather than letting it silently
681
+ // reach clientIpOf, where NaN/negative behaves like "chain too short"
682
+ // and collapses every request into the shared "unknown" bucket.
683
+ if (
684
+ config.trustedProxyHops !== undefined &&
685
+ (!Number.isInteger(config.trustedProxyHops) || config.trustedProxyHops < 0)
686
+ ) {
687
+ throw new Error(
688
+ `createAuthRoutes: trustedProxyHops must be a non-negative integer, got ${config.trustedProxyHops}.`,
689
+ );
690
+ }
691
+ const trustedProxyHops = config.trustedProxyHops ?? 0;
692
+ const getClientIp = (c: Context): string => clientIpOf(c, trustedProxyHops);
693
+
694
+ // Shared tail of every route that ends a request logged-in: create the
695
+ // session record (if wired), sign the JWT, set the auth+csrf cookies. Was
696
+ // duplicated 5x (login/signup-confirm/invite-accept-with-login/invite-
697
+ // signup-complete/switch-tenant) — extracted so a 6th caller (mfa-verify)
698
+ // doesn't grow it to 6.
699
+ async function mintSessionAndRespond(c: Context, session: SessionUser): Promise<string> {
700
+ let sessionForJwt = session;
701
+ if (config.sessionCreator) {
702
+ const sid = await config.sessionCreator(session, requestMeta(c, trustedProxyHops));
703
+ sessionForJwt = { ...session, sid };
704
+ }
705
+ const token = await jwt.sign(sessionForJwt);
706
+ const csrfToken = generateToken();
707
+ setAuthCookies(c, {
708
+ token,
709
+ csrfToken,
710
+ sameSite: cookieSameSite,
711
+ domain: cookieDomain,
712
+ ttlSeconds: jwt.ttlSeconds,
713
+ });
714
+ return token;
715
+ }
430
716
 
431
717
  // POST /auth/login — public endpoint (bypasses auth middleware via PUBLIC_API_PATHS).
432
718
  // The configured login handler authenticates and returns a SessionUser;
@@ -448,13 +734,7 @@ export function createAuthRoutes(
448
734
  }
449
735
  const body = parsed.data;
450
736
 
451
- // Client IP derivation is shared between rate-limit check and reset,
452
- // so compute once. Falls back to "unknown" when no proxy header is
453
- // present — consistent bucket for direct-to-server test setups.
454
- const clientIp =
455
- c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ??
456
- c.req.header("x-real-ip") ??
457
- "unknown";
737
+ const clientIp = getClientIp(c);
458
738
  const rateLimitKey = `${clientIp}|${body.email.toLowerCase()}`;
459
739
 
460
740
  if (rateLimiter) {
@@ -481,32 +761,253 @@ export function createAuthRoutes(
481
761
  return c.json({ isSuccess: false, error: result.error }, status);
482
762
  }
483
763
 
484
- // @cast-boundary engine-payload — generic dispatcher.write result for auth-session handler
485
- const data = result.data as { kind: "auth-session"; session: SessionUser };
486
-
487
- // Session creation (optional). Creating the session BEFORE signing the
488
- // JWT is load-bearing: the sid must exist on the server before the
489
- // token that references it can be handed out, otherwise a fast client
490
- // could arrive at an auth-middleware check before the insert commits.
491
- let sessionForJwt: SessionUser = data.session;
492
- if (config.sessionCreator) {
493
- const sid = await config.sessionCreator(data.session, requestMeta(c));
494
- sessionForJwt = { ...data.session, sid };
764
+ // @cast-boundary engine-payload — generic dispatcher.write result for
765
+ // login. Three possible shapes: a straight session, an MFA challenge
766
+ // when the loginHandler is wired with a second-factor gate, or a hard
767
+ // block when enforcement policy demands MFA but the user never
768
+ // enrolled (see auth-mfa's config.ts for why this has no in-band
769
+ // recovery yet).
770
+ const data = result.data as
771
+ | { kind: "auth-session"; session: SessionUser }
772
+ | { kind: "mfa-challenge"; challengeToken: string }
773
+ | { kind: "mfa-setup-required"; preauthSetupToken: string };
774
+
775
+ if (data.kind === "mfa-setup-required") {
776
+ // No session, no challenge — the client must show an
777
+ // enrollment-required message. No rate-limit reset (same reasoning
778
+ // as the mfa-challenge branch below).
779
+ return c.json({
780
+ isSuccess: true,
781
+ mfaSetupRequired: true,
782
+ preauthSetupToken: data.preauthSetupToken,
783
+ });
495
784
  }
496
785
 
497
- const token = await jwt.sign(sessionForJwt);
786
+ if (data.kind === "mfa-challenge") {
787
+ // No session minted yet — no cookies, no token. The client must
788
+ // complete /auth/mfa/verify with this token before it gets either.
789
+ // Rate-limit counter is NOT reset here: a "correct password, wrong/
790
+ // no TOTP yet" outcome hasn't proven the caller owns the account any
791
+ // more than a wrong password did.
792
+ return c.json({ isSuccess: true, mfaRequired: true, challengeToken: data.challengeToken });
793
+ }
794
+
795
+ // Session creation (optional) + JWT sign + cookies — see
796
+ // mintSessionAndRespond. Creating the session BEFORE signing the JWT
797
+ // is load-bearing: the sid must exist on the server before the token
798
+ // that references it can be handed out, otherwise a fast client could
799
+ // arrive at an auth-middleware check before the insert commits.
800
+ const token = await mintSessionAndRespond(c, data.session);
498
801
 
499
802
  if (rateLimiter) {
500
803
  await rateLimiter.reset(rateLimitKey);
501
804
  }
502
805
 
503
- // Cookie transport (web): set HttpOnly auth cookie + JS-readable csrf
504
- // cookie. Bearer transport (native) reads the token from the body
505
- // below — the token is returned for both, so a Bearer client that
506
- // ignores Set-Cookie keeps working without any server-side knowledge
507
- // of which transport this client will use next.
508
- const csrfToken = generateToken();
509
- setAuthCookies(c, { token, csrfToken, sameSite: cookieSameSite, domain: cookieDomain });
806
+ return c.json({
807
+ isSuccess: true,
808
+ token,
809
+ user: { id: data.session.id, tenantId: data.session.tenantId, roles: data.session.roles },
810
+ });
811
+ });
812
+ }
813
+
814
+ // POST /auth/mfa/verify — completes a two-step login. Mirrors /auth/login
815
+ // structurally (public, GUEST_USER dispatch, mintSessionAndRespond on
816
+ // success) but with its OWN rate limiter (mfaVerifyRateLimit) — this route
817
+ // never goes through a dispatcher write-handler's own rateLimit config,
818
+ // so without this it would have NO rate limiting at all. Per-account
819
+ // brute-force protection (capping wrong-code guesses against one
820
+ // still-valid challenge token) is a separate mechanism the handler itself
821
+ // owns — see AuthRoutesConfig.mfaVerifyHandler's doc comment.
822
+ if (config.mfaVerifyHandler) {
823
+ const mfaVerifyQn = config.mfaVerifyHandler;
824
+ const statusMap = config.mfaVerifyErrorStatusMap ?? {};
825
+ const rateLimiter =
826
+ config.mfaVerifyRateLimit === null
827
+ ? null
828
+ : (config.mfaVerifyRateLimit ?? createInMemoryLoginRateLimiter());
829
+
830
+ api.post(Routes.authMfaVerify, async (c) => {
831
+ const raw = await c.req.json().catch(() => null);
832
+ const parsed = MfaVerifyBody.safeParse(raw);
833
+ if (!parsed.success) {
834
+ return c.json({ isSuccess: false, error: "invalid_body" }, 400);
835
+ }
836
+ const body = parsed.data;
837
+
838
+ const clientIp = getClientIp(c);
839
+
840
+ if (rateLimiter) {
841
+ const allowed = await rateLimiter.check(clientIp);
842
+ if (!allowed) {
843
+ return c.json({ isSuccess: false, error: "rate_limited" }, 429);
844
+ }
845
+ }
846
+
847
+ const result = await dispatcher.write(mfaVerifyQn, body, GUEST_USER);
848
+
849
+ if (!result.isSuccess) {
850
+ // @cast-boundary error-details — KumikoError.details shape is per-error
851
+ const reason =
852
+ (result.error.details as { reason?: string } | undefined)?.reason ?? result.error.code;
853
+ // @cast-boundary engine-payload — statusMap value union narrows to the http-status union
854
+ const status = (statusMap[reason] ?? result.error.httpStatus) as
855
+ | 400
856
+ | 401
857
+ | 403
858
+ | 422
859
+ | 429
860
+ | 500;
861
+ return c.json({ isSuccess: false, error: result.error }, status);
862
+ }
863
+
864
+ // @cast-boundary engine-payload — generic dispatcher.write result for mfa-verify handler
865
+ const data = result.data as { kind: "mfa-verify-success"; session: SessionUser };
866
+
867
+ const token = await mintSessionAndRespond(c, data.session);
868
+
869
+ if (rateLimiter) {
870
+ await rateLimiter.reset(clientIp);
871
+ }
872
+
873
+ return c.json({
874
+ isSuccess: true,
875
+ token,
876
+ user: { id: data.session.id, tenantId: data.session.tenantId, roles: data.session.roles },
877
+ });
878
+ });
879
+ }
880
+
881
+ // POST /auth/mfa/preauth-enable-start — lets a user blocked at login by
882
+ // MFA enforcement (mfa-setup-required) generate a TOTP secret + QR
883
+ // without a session, using the preauthSetupToken login.write.ts issued.
884
+ // No JWT is minted here (unlike /auth/login and /auth/mfa/verify) — the
885
+ // handler's response carries a secret-bearing setupToken that a later
886
+ // pre-auth confirm step (the same shape auth-mfa's own enable-confirm
887
+ // consumes) verifies. Rate-limited like mfaVerifyRateLimit/
888
+ // mfaPreauthConfirmRateLimit — a preauthSetupToken is valid and
889
+ // not-single-use for its whole TTL, so without this cap a replay is a
890
+ // memory-hard CPU amplifier (see AuthRoutesConfig doc comment).
891
+ if (config.mfaPreauthEnableStartHandler) {
892
+ const mfaPreauthEnableStartQn = config.mfaPreauthEnableStartHandler;
893
+ const statusMap = config.mfaPreauthEnableStartErrorStatusMap ?? {};
894
+ const rateLimiter =
895
+ config.mfaPreauthEnableStartRateLimit === null
896
+ ? null
897
+ : (config.mfaPreauthEnableStartRateLimit ?? createInMemoryLoginRateLimiter());
898
+
899
+ api.post(Routes.authMfaPreauthEnableStart, async (c) => {
900
+ const raw = await c.req.json().catch(() => null);
901
+ const parsed = MfaPreauthEnableStartBody.safeParse(raw);
902
+ if (!parsed.success) {
903
+ return c.json({ isSuccess: false, error: "invalid_body" }, 400);
904
+ }
905
+ const body = parsed.data;
906
+
907
+ const clientIp = getClientIp(c);
908
+
909
+ if (rateLimiter) {
910
+ const allowed = await rateLimiter.check(clientIp);
911
+ if (!allowed) {
912
+ return c.json({ isSuccess: false, error: "rate_limited" }, 429);
913
+ }
914
+ // Second axis, independent of the IP-derived bucket above: the
915
+ // preauthSetupToken is minted by login.write.ts and not freely
916
+ // choosable, so this cap survives an attacker rotating
917
+ // x-forwarded-for to bypass the IP bucket (kumiko-framework#1522).
918
+ const tokenAllowed = await rateLimiter.check(preauthTokenKeyOf(body.preauthSetupToken));
919
+ if (!tokenAllowed) {
920
+ return c.json({ isSuccess: false, error: "rate_limited" }, 429);
921
+ }
922
+ }
923
+
924
+ const result = await dispatcher.write(mfaPreauthEnableStartQn, body, GUEST_USER);
925
+
926
+ if (!result.isSuccess) {
927
+ // @cast-boundary error-details — KumikoError.details shape is per-error
928
+ const reason =
929
+ (result.error.details as { reason?: string } | undefined)?.reason ?? result.error.code;
930
+ // @cast-boundary engine-payload — statusMap value union narrows to the http-status union
931
+ const status = (statusMap[reason] ?? result.error.httpStatus) as
932
+ | 400
933
+ | 401
934
+ | 403
935
+ | 422
936
+ | 429
937
+ | 500;
938
+ return c.json({ isSuccess: false, error: result.error }, status);
939
+ }
940
+
941
+ // @cast-boundary engine-payload — generic dispatcher.write result for
942
+ // the preauth-enable-start handler
943
+ const data = result.data as {
944
+ setupToken: string;
945
+ otpauthUri: string;
946
+ recoveryCodes: readonly string[];
947
+ };
948
+
949
+ return c.json({ isSuccess: true, ...data });
950
+ });
951
+ }
952
+
953
+ // POST /auth/mfa/preauth-confirm — completes both the enrollment started
954
+ // by preauth-enable-start AND the login mfa-setup-required blocked.
955
+ // Mirrors /auth/mfa/verify structurally (public, GUEST_USER dispatch,
956
+ // mintSessionAndRespond on success) with its OWN rate limiter
957
+ // (mfaPreauthConfirmRateLimit) — same reasoning as mfaVerifyRateLimit.
958
+ // Per-account brute-force protection (capping wrong-code guesses against
959
+ // one still-valid setupToken) is owned by the handler itself — see
960
+ // AuthRoutesConfig.mfaPreauthConfirmHandler's doc comment.
961
+ if (config.mfaPreauthConfirmHandler) {
962
+ const mfaPreauthConfirmQn = config.mfaPreauthConfirmHandler;
963
+ const statusMap = config.mfaPreauthConfirmErrorStatusMap ?? {};
964
+ const rateLimiter =
965
+ config.mfaPreauthConfirmRateLimit === null
966
+ ? null
967
+ : (config.mfaPreauthConfirmRateLimit ?? createInMemoryLoginRateLimiter());
968
+
969
+ api.post(Routes.authMfaPreauthConfirm, async (c) => {
970
+ const raw = await c.req.json().catch(() => null);
971
+ const parsed = MfaPreauthConfirmBody.safeParse(raw);
972
+ if (!parsed.success) {
973
+ return c.json({ isSuccess: false, error: "invalid_body" }, 400);
974
+ }
975
+ const body = parsed.data;
976
+
977
+ const clientIp = getClientIp(c);
978
+
979
+ if (rateLimiter) {
980
+ const allowed = await rateLimiter.check(clientIp);
981
+ if (!allowed) {
982
+ return c.json({ isSuccess: false, error: "rate_limited" }, 429);
983
+ }
984
+ }
985
+
986
+ const result = await dispatcher.write(mfaPreauthConfirmQn, body, GUEST_USER);
987
+
988
+ if (!result.isSuccess) {
989
+ // @cast-boundary error-details — KumikoError.details shape is per-error
990
+ const reason =
991
+ (result.error.details as { reason?: string } | undefined)?.reason ?? result.error.code;
992
+ // @cast-boundary engine-payload — statusMap value union narrows to the http-status union
993
+ const status = (statusMap[reason] ?? result.error.httpStatus) as
994
+ | 400
995
+ | 401
996
+ | 403
997
+ | 422
998
+ | 429
999
+ | 500;
1000
+ return c.json({ isSuccess: false, error: result.error }, status);
1001
+ }
1002
+
1003
+ // @cast-boundary engine-payload — generic dispatcher.write result for mfa-preauth-confirm handler
1004
+ const data = result.data as { kind: "mfa-preauth-confirm-success"; session: SessionUser };
1005
+
1006
+ const token = await mintSessionAndRespond(c, data.session);
1007
+
1008
+ if (rateLimiter) {
1009
+ await rateLimiter.reset(clientIp);
1010
+ }
510
1011
 
511
1012
  return c.json({
512
1013
  isSuccess: true,
@@ -554,6 +1055,26 @@ export function createAuthRoutes(
554
1055
  });
555
1056
  }
556
1057
 
1058
+ // Account-unlock mirrors email-verification (token-only confirm body) —
1059
+ // clears the Redis lockout state instead of an entity field, see
1060
+ // confirm-account-unlock.write.ts.
1061
+ if (config.accountUnlock) {
1062
+ const au = config.accountUnlock;
1063
+ registerTokenRequestRoute({
1064
+ api,
1065
+ dispatcher,
1066
+ path: Routes.authRequestAccountUnlock,
1067
+ requestHandler: au.requestHandler,
1068
+ });
1069
+ registerTokenConfirmRoute({
1070
+ api,
1071
+ dispatcher,
1072
+ path: Routes.authConfirmAccountUnlock,
1073
+ confirmHandler: au.confirmHandler,
1074
+ schema: VerifyEmailBody,
1075
+ });
1076
+ }
1077
+
557
1078
  // Self-Signup (Magic-Link). Request mountet wie reset/verify den
558
1079
  // silent-success-Pfad mit Token-Mail. Confirm ist anders: returnt
559
1080
  // SessionUser → die Route mintet JWT + setzt Cookies (Auto-Login
@@ -590,18 +1111,8 @@ export function createAuthRoutes(
590
1111
  tenantKey: string;
591
1112
  };
592
1113
 
593
- // Session-Creator analog login wenn wired, sid wird im JWT
594
- // platziert und der Server kann später den Session revoken
595
- // (Logout, Compromise).
596
- let sessionForJwt: SessionUser = data.session;
597
- if (config.sessionCreator) {
598
- const sid = await config.sessionCreator(data.session, requestMeta(c));
599
- sessionForJwt = { ...data.session, sid };
600
- }
601
-
602
- const token = await jwt.sign(sessionForJwt);
603
- const csrfToken = generateToken();
604
- setAuthCookies(c, { token, csrfToken, sameSite: cookieSameSite, domain: cookieDomain });
1114
+ // Session creation + JWT sign + cookies see mintSessionAndRespond.
1115
+ const token = await mintSessionAndRespond(c, data.session);
605
1116
 
606
1117
  return c.json({
607
1118
  isSuccess: true,
@@ -677,14 +1188,7 @@ export function createAuthRoutes(
677
1188
  tenantId: TenantId;
678
1189
  role: string;
679
1190
  }; // @cast-boundary engine-payload
680
- let sessionForJwt: SessionUser = data.session;
681
- if (config.sessionCreator) {
682
- const sid = await config.sessionCreator(data.session, requestMeta(c));
683
- sessionForJwt = { ...data.session, sid };
684
- }
685
- const token = await jwt.sign(sessionForJwt);
686
- const csrfToken = generateToken();
687
- setAuthCookies(c, { token, csrfToken, sameSite: cookieSameSite, domain: cookieDomain });
1191
+ const token = await mintSessionAndRespond(c, data.session);
688
1192
  return c.json({
689
1193
  isSuccess: true,
690
1194
  token,
@@ -717,14 +1221,7 @@ export function createAuthRoutes(
717
1221
  tenantId: TenantId;
718
1222
  role: string;
719
1223
  }; // @cast-boundary engine-payload
720
- let sessionForJwt: SessionUser = data.session;
721
- if (config.sessionCreator) {
722
- const sid = await config.sessionCreator(data.session, requestMeta(c));
723
- sessionForJwt = { ...data.session, sid };
724
- }
725
- const token = await jwt.sign(sessionForJwt);
726
- const csrfToken = generateToken();
727
- setAuthCookies(c, { token, csrfToken, sameSite: cookieSameSite, domain: cookieDomain });
1224
+ const token = await mintSessionAndRespond(c, data.session);
728
1225
  return c.json({
729
1226
  isSuccess: true,
730
1227
  token,
@@ -857,20 +1354,19 @@ export function createAuthRoutes(
857
1354
  // tenant A accidentally surviving into tenant B's session). The
858
1355
  // resolver runs each feature's r.authClaims() hook under the new
859
1356
  // TenantDb scope.
860
- // Strip reserved roles from the membership portion only — globalRoles
1357
+ // buildSessionRoles calls stripForbiddenMembershipRoles internally and
1358
+ // strips reserved roles from the membership side only — globalRoles
861
1359
  // (where SystemAdmin legitimately lives) is never filtered. Backstop for a
862
1360
  // membership role that a projection rebuild resurrected past command-time
863
1361
  // validation (see engine/membership-roles).
864
- const mergedRoles = Array.from(
865
- new Set([...globalRoles, ...stripForbiddenMembershipRoles(membership.roles)]),
866
- );
1362
+ const mergedRoles = buildSessionRoles(globalRoles, membership.roles);
867
1363
  const targetSession: SessionUser = {
868
1364
  id: user.id,
869
1365
  tenantId: targetTenantId,
870
1366
  roles: mergedRoles,
871
1367
  };
872
1368
  const claims = await dispatcher.resolveAuthClaims(targetSession);
873
- let sessionForJwt: SessionUser =
1369
+ const sessionForJwt: SessionUser =
874
1370
  Object.keys(claims).length > 0 ? { ...targetSession, claims } : targetSession;
875
1371
 
876
1372
  // Session rotation: revoke old sid BEFORE creating the new one so a
@@ -882,25 +1378,10 @@ export function createAuthRoutes(
882
1378
  if (config.sessionRevoker && user.sid) {
883
1379
  await config.sessionRevoker(user.sid);
884
1380
  }
885
- if (config.sessionCreator) {
886
- const sid = await config.sessionCreator(sessionForJwt, requestMeta(c));
887
- sessionForJwt = { ...sessionForJwt, sid };
888
- }
889
-
890
- const newToken = await jwt.sign(sessionForJwt);
891
-
892
- // Rotate both cookies in lock-step with the new JWT. A fresh csrfToken
893
- // is minted so a compromised csrf-value (e.g. leaked via a bug in the
894
- // app's JS) can't cross a tenant boundary. Bearer-only clients get
895
- // the new token in the body below — their Set-Cookie is a no-op
896
- // because the browser never sent cookies.
897
- const csrfToken = generateToken();
898
- setAuthCookies(c, {
899
- token: newToken,
900
- csrfToken,
901
- sameSite: cookieSameSite,
902
- domain: cookieDomain,
903
- });
1381
+ // Session creation + JWT sign + cookies — see mintSessionAndRespond. A
1382
+ // fresh csrfToken is minted (inside the helper) so a compromised csrf-
1383
+ // value can't cross a tenant boundary.
1384
+ const newToken = await mintSessionAndRespond(c, sessionForJwt);
904
1385
 
905
1386
  return c.json({ token: newToken, tenantId: targetTenantId, roles: mergedRoles });
906
1387
  });