@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
@@ -177,6 +177,27 @@ describe("runSchemaCli — validate (static CI gate, no DB)", () => {
177
177
  const code = await runSchemaCli(["validate"], appCwd, cap.out);
178
178
  expect(code).toBe(0);
179
179
  expect(cap.log.join("\n")).toContain("migrations match");
180
+ expect(cap.log.join("\n")).toContain("table/column names match .snapshot.json");
181
+ });
182
+
183
+ // Reproduces the kumiko-studio 0016 incident: a migration file gets
184
+ // hand-edited (or copy-pasted from another file) after `generate` wrote
185
+ // it — the snapshot still matches ENTITY_METAS (layer 1 stays green), but
186
+ // the .sql content no longer produces what the snapshot claims.
187
+ test("migration file edited after generate to not match its snapshot entry → drift, exit 1", async () => {
188
+ writeSchemaFile(appCwd, "read_widgets");
189
+ await runSchemaCli(["generate", "init"], appCwd, captureOut().out);
190
+ writeFileSync(
191
+ join(appCwd, "kumiko/migrations/0001_init.sql"),
192
+ `-- oops: hand-edited to the wrong table name after the snapshot was written
193
+ CREATE TABLE IF NOT EXISTS "read_widgets_v2" ("id" uuid PRIMARY KEY);
194
+ `,
195
+ );
196
+ const cap = captureOut();
197
+ const code = await runSchemaCli(["validate"], appCwd, cap.out);
198
+ expect(code).toBe(1);
199
+ expect(cap.err.join("\n")).toContain("migration-content drift");
200
+ expect(cap.err.join("\n")).toContain("read_widgets");
180
201
  });
181
202
 
182
203
  test("no FEATURES export → validateBoot skipped (drift still checked)", async () => {
@@ -204,6 +225,35 @@ export const FEATURES = [];
204
225
  expect(code).toBe(0);
205
226
  expect(cap.log.join("\n")).toContain("feature configuration is valid");
206
227
  });
228
+
229
+ test("FEATURES with a bad nav→screen ref → validateBoot fails, exit 1 (512/1)", async () => {
230
+ // Absolute file import (not the package name): appCwd is a bare tmpdir
231
+ // with no node_modules of its own — a real app resolves
232
+ // "@cosmicdrift/kumiko-framework/engine" via its own install, but this
233
+ // synthetic fixture needs a resolvable path regardless of cwd.
234
+ const engineSrc = join(import.meta.dir, "../engine/index.ts");
235
+ writeFileSync(
236
+ join(appCwd, "kumiko/schema.ts"),
237
+ `import { defineFeature } from "${engineSrc}";
238
+ export const ENTITY_METAS = [
239
+ { tableName: "read_widgets", source: "unmanaged", indexes: [],
240
+ columns: [{ name: "id", pgType: "uuid", notNull: true, primaryKey: true, defaultSql: "gen_random_uuid()" }] },
241
+ ];
242
+ export const FEATURES = [
243
+ defineFeature("probe", (r) => {
244
+ r.nav({ id: "nav-ghost", label: "Ghost", screen: "probe:screen:ghost" });
245
+ }),
246
+ ];
247
+ `,
248
+ );
249
+ await runSchemaCli(["generate", "init"], appCwd, captureOut().out);
250
+ const cap = captureOut();
251
+ const code = await runSchemaCli(["validate"], appCwd, cap.out);
252
+ expect(code).toBe(1);
253
+ expect(cap.err.join("\n")).toContain("boot:");
254
+ expect(cap.err.join("\n")).toContain("probe:screen:ghost");
255
+ expect(cap.err.join("\n")).toContain("is not registered");
256
+ });
207
257
  });
208
258
 
209
259
  describe("runSchemaCli — DB-backed paths", () => {
@@ -0,0 +1,93 @@
1
+ // Integration test for r.storeTable() — proves the full boot path:
2
+ // defineFeature declares a store table → setupTestStack auto-pushes it →
3
+ // INSERT/SELECT against the real DB roundtrip. Plan reference:
4
+ // kumiko-platform/docs/plans/architecture/table-ddl-guard.md (Stufe 3).
5
+
6
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
7
+ import { defineUnmanagedTable } from "../db/entity-table-meta";
8
+ import { asRawClient, insertOne, selectMany } from "../db/query";
9
+ import { defineFeature } from "../engine";
10
+ import { setupTestStack, type TestStack, unsafePushTables } from "../stack";
11
+
12
+ // External-system payload cache — the textbook r.storeTable() use case:
13
+ // write-only by an integration handler, read-only by a query, never
14
+ // event-sourced (the data isn't a domain fact, it's a side-effect
15
+ // snapshot).
16
+ const stripeWebhookCacheMeta = defineUnmanagedTable({
17
+ tableName: "rt_int_stripe_webhook_cache",
18
+ columns: [
19
+ { name: "event_id", pgType: "text", notNull: true, primaryKey: true },
20
+ { name: "payload", pgType: "text", notNull: true },
21
+ { name: "received_at", pgType: "timestamptz", notNull: true, defaultSql: "now()" },
22
+ ],
23
+ });
24
+
25
+ const webhookCacheFeature = defineFeature("webhook-cache", (r) => {
26
+ r.storeTable(stripeWebhookCacheMeta, {
27
+ reason: "external Stripe webhook payload cache — write-only by webhook handler",
28
+ });
29
+ });
30
+
31
+ let stack: TestStack;
32
+
33
+ beforeAll(async () => {
34
+ stack = await setupTestStack({ features: [webhookCacheFeature] });
35
+ });
36
+
37
+ afterAll(async () => {
38
+ await stack.cleanup();
39
+ });
40
+
41
+ describe("r.storeTable — DB roundtrip via setupTestStack", () => {
42
+ test("table is auto-pushed and accepts INSERT + SELECT", async () => {
43
+ const eventId = "evt_test_123";
44
+ const payload = JSON.stringify({ type: "invoice.paid", amount: 4200 });
45
+
46
+ await insertOne(stack.db, stripeWebhookCacheMeta, { eventId, payload });
47
+
48
+ const rows = await selectMany(stack.db, stripeWebhookCacheMeta, { eventId: eventId });
49
+
50
+ expect(rows).toHaveLength(1);
51
+ expect(rows[0]?.payload).toBe(payload);
52
+ expect(rows[0]?.receivedAt).toBeInstanceOf(Temporal.Instant);
53
+ });
54
+
55
+ test("registry exposes the store table with its reason and featureName", () => {
56
+ const all = stack.registry.getAllStoreTables();
57
+ const entry = all.get("rt_int_stripe_webhook_cache");
58
+ expect(entry).toBeDefined();
59
+ expect(entry?.featureName).toBe("webhook-cache");
60
+ expect(entry?.reason).toContain("Stripe webhook payload cache");
61
+ expect(entry?.meta).toBe(stripeWebhookCacheMeta);
62
+ });
63
+
64
+ test("INSERT bypasses the event store — no kumiko_events row produced", async () => {
65
+ // Proves that the storeTable lives outside the event-sourcing graph:
66
+ // a write to it shouldn't append anything to kumiko_events. If a
67
+ // future regression accidentally routed store-table writes through
68
+ // the executor, a row would show up here.
69
+ const before = await asRawClient(stack.db).unsafe<{ count: string }>(
70
+ `SELECT COUNT(*)::text AS count FROM kumiko_events`,
71
+ );
72
+ const beforeCount = Number(before[0]?.count ?? 0);
73
+
74
+ await insertOne(stack.db, stripeWebhookCacheMeta, {
75
+ eventId: "evt_no_event_emitted",
76
+ payload: "{}",
77
+ });
78
+
79
+ const after = await asRawClient(stack.db).unsafe<{ count: string }>(
80
+ `SELECT COUNT(*)::text AS count FROM kumiko_events`,
81
+ );
82
+ const afterCount = Number(after[0]?.count ?? 0);
83
+
84
+ expect(afterCount).toBe(beforeCount);
85
+ });
86
+
87
+ test("a second push on the same storeTable is idempotent — CREATE IF NOT EXISTS", async () => {
88
+ // unsafePushTables uses CREATE TABLE IF NOT EXISTS — idempotent by design.
89
+ await expect(
90
+ unsafePushTables(stack.db, { idem: stripeWebhookCacheMeta }),
91
+ ).resolves.toBeUndefined();
92
+ });
93
+ });
@@ -1,4 +1,6 @@
1
1
  import { describe, expect, test } from "bun:test";
2
+ import { parseSseFrames } from "@cosmicdrift/kumiko-dispatcher-live";
3
+ import { Hono } from "hono";
2
4
  import { z } from "zod";
3
5
  import {
4
6
  createEntity,
@@ -7,7 +9,10 @@ import {
7
9
  defineFeature,
8
10
  type TenantId,
9
11
  } from "../../engine";
12
+ import type { BatchResult, Dispatcher, WriteResult } from "../../pipeline/dispatcher";
10
13
  import { createTestUser, TestUsers } from "../../stack";
14
+ import { waitFor } from "../../testing";
15
+ import { createApiRoutes, pumpStream, StreamFrame } from "../routes";
11
16
  import { buildServer } from "../server";
12
17
 
13
18
  const JWT_SECRET = "test-secret-at-least-32-chars-long!!";
@@ -28,6 +33,38 @@ const testFeature = defineFeature("test", (r) => {
28
33
  async () => [{ id: 1, name: "Test" }],
29
34
  { access: { openToAll: true } },
30
35
  );
36
+
37
+ r.streamHandler(
38
+ "item:tail",
39
+ z.object({ count: z.number().int().min(0) }),
40
+ async function* (query) {
41
+ for (let i = 0; i < query.payload.count; i++) {
42
+ yield { i };
43
+ }
44
+ },
45
+ { access: { roles: ["Admin"] } },
46
+ );
47
+
48
+ r.streamHandler(
49
+ "item:tail-fail-mid",
50
+ z.object({}),
51
+ async function* () {
52
+ yield { i: 0 };
53
+ yield { i: 1 };
54
+ throw new Error("boom");
55
+ },
56
+ { access: { roles: ["Admin"] } },
57
+ );
58
+
59
+ r.streamHandler(
60
+ "item:tail-fail-first",
61
+ z.object({}),
62
+ // biome-ignore lint/correctness/useYield: deliberately throws before any yield — tests the pre-pull failure path
63
+ async function* () {
64
+ throw new Error("boom");
65
+ },
66
+ { access: { roles: ["Admin"] } },
67
+ );
31
68
  });
32
69
 
33
70
  const registry = createRegistry([testFeature]);
@@ -199,6 +236,138 @@ describe("POST /api/command", () => {
199
236
  });
200
237
  });
201
238
 
239
+ // --- pumpStream (SSE pull loop) ---
240
+
241
+ function fakeSseWriter() {
242
+ const frames: Array<{ event: string; data: string }> = [];
243
+ return {
244
+ frames,
245
+ async writeSSE(message: { event: string; data: string }) {
246
+ frames.push(message);
247
+ },
248
+ };
249
+ }
250
+
251
+ async function* delayedGenerator(values: readonly unknown[], delayMsByIndex: readonly number[]) {
252
+ for (let i = 0; i < values.length; i++) {
253
+ const delay = delayMsByIndex[i] ?? 0;
254
+ if (delay > 0) await Bun.sleep(delay);
255
+ yield values[i];
256
+ }
257
+ }
258
+
259
+ describe("pumpStream", () => {
260
+ test("emits a ping when the handler is slow, then still delivers the pending chunk (no loss)", async () => {
261
+ const writer = fakeSseWriter();
262
+ // heartbeatMs (10) fires before the 40ms-delayed second chunk resolves.
263
+ const gen = delayedGenerator([{ i: 0 }, { i: 1 }], [0, 40]);
264
+
265
+ await pumpStream(writer, gen, 10);
266
+
267
+ const events = writer.frames.map((f) => f.event);
268
+ expect(events[0]).toBe("chunk");
269
+ expect(events).toContain("ping");
270
+ expect(events.at(-1)).toBe("done");
271
+ // Both chunks arrive despite the ping in between — no chunk dropped.
272
+ const chunkData = writer.frames.filter((f) => f.event === "chunk").map((f) => f.data);
273
+ expect(chunkData).toEqual([JSON.stringify({ i: 0 }), JSON.stringify({ i: 1 })]);
274
+ });
275
+
276
+ test("no heartbeat fires when the handler is faster than heartbeatMs — chunks then done", async () => {
277
+ const writer = fakeSseWriter();
278
+ const gen = delayedGenerator([{ i: 0 }, { i: 1 }, { i: 2 }], [0, 0, 0]);
279
+
280
+ await pumpStream(writer, gen, 1000);
281
+
282
+ expect(writer.frames.map((f) => f.event)).toEqual(["chunk", "chunk", "chunk", "done"]);
283
+ });
284
+
285
+ test("a handler generator that throws propagates the error instead of swallowing it", async () => {
286
+ const writer = fakeSseWriter();
287
+ async function* throwing() {
288
+ yield { i: 0 };
289
+ throw new Error("handler-boom");
290
+ }
291
+
292
+ await expect(pumpStream(writer, throwing(), 1000)).rejects.toThrow("handler-boom");
293
+ // The chunk before the throw still made it out.
294
+ expect(writer.frames.map((f) => f.event)).toEqual(["chunk"]);
295
+ });
296
+
297
+ test("closes the generator when writeSSE throws mid-loop (e.g. client disconnected on a ping)", async () => {
298
+ let cleanedUp = false;
299
+ async function* gen() {
300
+ try {
301
+ await Bun.sleep(50);
302
+ yield { i: 0 };
303
+ } finally {
304
+ cleanedUp = true;
305
+ }
306
+ }
307
+ const frames: Array<{ event: string; data: string }> = [];
308
+ const writer = {
309
+ frames,
310
+ async writeSSE(message: { event: string; data: string }) {
311
+ if (message.event === StreamFrame.ping) throw new Error("client disconnected");
312
+ frames.push(message);
313
+ },
314
+ };
315
+
316
+ await expect(pumpStream(writer, gen(), 5)).rejects.toThrow("client disconnected");
317
+ // Fire-and-forget cleanup (kumiko-framework#1547): pumpStream's finally
318
+ // no longer awaits generator.return() — it can't, since a still-pending
319
+ // .next() would make that await hang — so cleanedUp flips asynchronously
320
+ // after pumpStream's own rejection, not synchronously before it.
321
+ await waitFor(() => {
322
+ expect(cleanedUp).toBe(true);
323
+ });
324
+ });
325
+
326
+ test("does not hang when writeSSE throws while a .next() pull is still in flight", async () => {
327
+ // kumiko-framework#1547: generator.return() queues behind an in-flight
328
+ // .next() (V8 semantics) — an `await generator.return(undefined)` in the
329
+ // finally block would hang for as long as the handler's .next() never
330
+ // settles, which a dead Redis/DB subscription can do indefinitely.
331
+ // `never` intentionally never resolves — the fire-and-forget fix must
332
+ // not need it to.
333
+ const never = new Promise<never>(() => {});
334
+ async function* gen() {
335
+ await never;
336
+ yield { i: 0 };
337
+ }
338
+ const writer = {
339
+ frames: [] as Array<{ event: string; data: string }>,
340
+ async writeSSE(message: { event: string; data: string }) {
341
+ if (message.event === StreamFrame.ping) throw new Error("client disconnected");
342
+ this.frames.push(message);
343
+ },
344
+ };
345
+
346
+ const TIMEOUT = Symbol("timeout");
347
+ const outcome = await Promise.race([
348
+ pumpStream(writer, gen(), 5).then(
349
+ () => "resolved",
350
+ (e) => e,
351
+ ),
352
+ Bun.sleep(500).then(() => TIMEOUT),
353
+ ]);
354
+ expect(outcome).not.toBe(TIMEOUT);
355
+ expect(outcome).toBeInstanceOf(Error);
356
+ expect((outcome as Error).message).toBe("client disconnected");
357
+ });
358
+
359
+ test("serializes an undefined yielded value as an explicit null chunk instead of an invalid frame", async () => {
360
+ const writer = fakeSseWriter();
361
+ async function* gen() {
362
+ yield undefined;
363
+ }
364
+
365
+ await pumpStream(writer, gen(), 1000);
366
+
367
+ expect(writer.frames[0]).toEqual({ event: StreamFrame.chunk, data: "null" });
368
+ });
369
+ });
370
+
202
371
  // --- SSE ---
203
372
 
204
373
  describe("GET /api/sse", () => {
@@ -215,6 +384,210 @@ describe("GET /api/sse", () => {
215
384
  });
216
385
  });
217
386
 
387
+ // --- Stream (dispatcher-driven SSE) ---
388
+
389
+ describe("POST /api/stream", () => {
390
+ test("dispatches stream handler and yields chunk frames then done", async () => {
391
+ const headers = await authHeader(adminUser);
392
+ const res = await req(
393
+ "POST",
394
+ "/api/stream",
395
+ { type: "test:stream:item:tail", payload: { count: 3 } },
396
+ headers,
397
+ );
398
+
399
+ expect(res.status).toBe(200);
400
+ expect(res.headers.get("content-type")).toContain("text/event-stream");
401
+ const frames = parseSseFrames(await res.text());
402
+ expect(frames).toEqual([
403
+ { event: "chunk", data: JSON.stringify({ i: 0 }) },
404
+ { event: "chunk", data: JSON.stringify({ i: 1 }) },
405
+ { event: "chunk", data: JSON.stringify({ i: 2 }) },
406
+ { event: "done", data: "" },
407
+ ]);
408
+ });
409
+
410
+ test("access-denied gate surfaces as a real 403, not an HTTP 200 error frame", async () => {
411
+ // Dispatch gates (feature/rate-limit/access/validation) run on the
412
+ // generator's first pull, which the route now performs BEFORE opening
413
+ // the SSE response — so a gate failure maps to its real HTTP status
414
+ // instead of a flushed-200 error frame (framework#1517).
415
+ const headers = await authHeader(guestUser);
416
+ const res = await req(
417
+ "POST",
418
+ "/api/stream",
419
+ { type: "test:stream:item:tail", payload: { count: 1 } },
420
+ headers,
421
+ );
422
+
423
+ expect(res.status).toBe(403);
424
+ expect(res.headers.get("content-type")).not.toContain("text/event-stream");
425
+ const body = await res.json();
426
+ expect(body.error).toMatchObject({ code: "access_denied" });
427
+ });
428
+
429
+ test("returns 404 for unknown stream handler", async () => {
430
+ const headers = await authHeader(adminUser);
431
+ const res = await req("POST", "/api/stream", { type: "nope", payload: {} }, headers);
432
+
433
+ expect(res.status).toBe(404);
434
+ expect(res.headers.get("content-type")).not.toContain("text/event-stream");
435
+ });
436
+
437
+ test("returns 400 for schema validation failure", async () => {
438
+ const headers = await authHeader(adminUser);
439
+ const res = await req(
440
+ "POST",
441
+ "/api/stream",
442
+ { type: "test:stream:item:tail", payload: { count: -1 } },
443
+ headers,
444
+ );
445
+
446
+ expect(res.status).toBe(400);
447
+ expect(res.headers.get("content-type")).not.toContain("text/event-stream");
448
+ });
449
+
450
+ test("handler failure after chunks already sent stays HTTP 200 with an error frame", async () => {
451
+ // Boundary between the pre-pull fix and the still-open SSE stream: a
452
+ // failure that happens AFTER the first chunk (headers already flushed)
453
+ // must keep surfacing as a 200 + "error" frame, not a real HTTP status.
454
+ const headers = await authHeader(adminUser);
455
+ const res = await req(
456
+ "POST",
457
+ "/api/stream",
458
+ { type: "test:stream:item:tail-fail-mid", payload: {} },
459
+ headers,
460
+ );
461
+
462
+ expect(res.status).toBe(200);
463
+ expect(res.headers.get("content-type")).toContain("text/event-stream");
464
+ const frames = parseSseFrames(await res.text());
465
+ expect(frames).toHaveLength(3);
466
+ expect(frames.slice(0, 2)).toEqual([
467
+ { event: "chunk", data: JSON.stringify({ i: 0 }) },
468
+ { event: "chunk", data: JSON.stringify({ i: 1 }) },
469
+ ]);
470
+ expect(frames[2]?.event).toBe("error");
471
+ expect(JSON.parse(frames[2]?.data ?? "{}")).toMatchObject({ code: "internal_error" });
472
+ });
473
+
474
+ test("handler failure before the first yield surfaces as a real HTTP 500, not an SSE error frame", async () => {
475
+ // Contract-pin for routes.ts's "error frames only reachable once the
476
+ // stream is already open" comment: a generator that throws before its
477
+ // first yield is caught by the same pre-pull gate as feature/access/
478
+ // rate-limit/validation failures — the client sees 500 + JSON, not a
479
+ // 200 with an SSE error frame.
480
+ const headers = await authHeader(adminUser);
481
+ const res = await req(
482
+ "POST",
483
+ "/api/stream",
484
+ { type: "test:stream:item:tail-fail-first", payload: {} },
485
+ headers,
486
+ );
487
+
488
+ expect(res.status).toBe(500);
489
+ expect(res.headers.get("content-type")).not.toContain("text/event-stream");
490
+ const body = await res.json();
491
+ expect(body.error).toMatchObject({ code: "internal_error" });
492
+ });
493
+ });
494
+
495
+ // --- POST /api/stream pre-pull race (framework#1547) ---
496
+ // Mount createApiRoutes with a short sseHeartbeatMs so the pre-pull heartbeat
497
+ // race is testable without waiting for the production 15s interval.
498
+
499
+ describe("POST /api/stream pre-pull race", () => {
500
+ const user = createTestUser({ roles: ["Admin"] });
501
+
502
+ function stubDispatcher(streamImpl: Dispatcher["stream"]): Dispatcher {
503
+ return {
504
+ async write(): Promise<WriteResult> {
505
+ return { isSuccess: true, data: {} };
506
+ },
507
+ async query(): Promise<unknown> {
508
+ return [];
509
+ },
510
+ stream: streamImpl,
511
+ async command(): Promise<void> {},
512
+ async batch(): Promise<BatchResult> {
513
+ return { isSuccess: true, results: [] };
514
+ },
515
+ async resolveAuthClaims(): Promise<Record<string, unknown>> {
516
+ return {};
517
+ },
518
+ };
519
+ }
520
+
521
+ function mountStreamApp(dispatcher: Dispatcher, sseHeartbeatMs: number) {
522
+ const app = new Hono<{ Variables: { pipelineUser: typeof user } }>();
523
+ app.use("/api/*", async (c, next) => {
524
+ c.set("pipelineUser", user);
525
+ await next();
526
+ });
527
+ app.route("/api", createApiRoutes(dispatcher, { sseHeartbeatMs }));
528
+ return app;
529
+ }
530
+
531
+ test("slow first next() opens 200 SSE with ping frames instead of hanging as HTTP error", async () => {
532
+ // First .next() takes longer than heartbeatMs → settledInTime=false →
533
+ // streamSSE opens immediately and pumpStream emits ping until the chunk
534
+ // arrives (framework#1547 route-level contract).
535
+ const dispatcher = stubDispatcher(async function* () {
536
+ await Bun.sleep(60);
537
+ yield { i: 0 };
538
+ });
539
+ const app = mountStreamApp(dispatcher, 20);
540
+ const res = await app.request(
541
+ new Request("http://localhost/api/stream", {
542
+ method: "POST",
543
+ headers: { "Content-Type": "application/json" },
544
+ body: JSON.stringify({ type: "any:stream:tail", payload: {} }),
545
+ }),
546
+ );
547
+ expect(res.status).toBe(200);
548
+ expect(res.headers.get("content-type")).toContain("text/event-stream");
549
+ const frames = parseSseFrames(await res.text());
550
+ const events = frames.map((f) => f.event);
551
+ expect(events).toContain("ping");
552
+ expect(events).toContain("chunk");
553
+ expect(events.at(-1)).toBe("done");
554
+ });
555
+
556
+ test("client abort during pre-pull returns 499 and runs generator cleanup", async () => {
557
+ // Finite sleep (not a never-resolving await): V8 queues .return() behind an
558
+ // in-flight .next(), so cleanup only runs once the pending pull settles.
559
+ // Abort before heartbeatMs so the route hits the 499 branch; sleep then
560
+ // completes and the queued return drains the generator's finally.
561
+ let cleanedUp = false;
562
+ const dispatcher = stubDispatcher(async function* () {
563
+ try {
564
+ await Bun.sleep(80);
565
+ yield { i: 0 };
566
+ } finally {
567
+ cleanedUp = true;
568
+ }
569
+ });
570
+ const app = mountStreamApp(dispatcher, 40);
571
+ const ac = new AbortController();
572
+ const pending = app.request(
573
+ new Request("http://localhost/api/stream", {
574
+ method: "POST",
575
+ headers: { "Content-Type": "application/json" },
576
+ body: JSON.stringify({ type: "any:stream:tail", payload: {} }),
577
+ signal: ac.signal,
578
+ }),
579
+ );
580
+ // Abort while firstPull is still racing the heartbeat timer.
581
+ await Bun.sleep(5);
582
+ ac.abort();
583
+ const res = await pending;
584
+ expect(res.status).toBe(499);
585
+ await waitFor(() => {
586
+ expect(cleanedUp).toBe(true);
587
+ });
588
+ });
589
+ });
590
+
218
591
  // --- r.httpRoute (feature-deklarierte HTTP-Routes außerhalb /api/) ---
219
592
 
220
593
  describe("feature-declared HTTP routes (r.httpRoute)", () => {
@@ -0,0 +1,40 @@
1
+ // authMiddleware boots eagerly (not per-request) so a tenantResolver
2
+ // declared without resolverTrust is an ambiguous trust decision that fails
3
+ // loud at startup, instead of silently letting a client-set tenant header
4
+ // override a host-derived resolver at request time (#1452).
5
+
6
+ import { describe, expect, test } from "bun:test";
7
+ import { authMiddleware } from "../auth-middleware";
8
+ import { createJwtHelper } from "../jwt";
9
+
10
+ const JWT_SECRET = "auth-middleware-anon-access-boot-test-secret-32ch";
11
+
12
+ describe("authMiddleware anonymousAccess boot guard", () => {
13
+ test("throws at construction when tenantResolver is set without resolverTrust", () => {
14
+ const jwt = createJwtHelper(JWT_SECRET);
15
+ expect(() =>
16
+ authMiddleware(jwt, {
17
+ anonymousAccess: {
18
+ tenantResolver: () => "t1" as never,
19
+ },
20
+ }),
21
+ ).toThrow(/resolverTrust/);
22
+ });
23
+
24
+ test("does not throw when resolverTrust is declared", () => {
25
+ const jwt = createJwtHelper(JWT_SECRET);
26
+ expect(() =>
27
+ authMiddleware(jwt, {
28
+ anonymousAccess: {
29
+ tenantResolver: () => "t1" as never,
30
+ resolverTrust: "authoritative",
31
+ },
32
+ }),
33
+ ).not.toThrow();
34
+ });
35
+
36
+ test("does not throw when no tenantResolver is configured", () => {
37
+ const jwt = createJwtHelper(JWT_SECRET);
38
+ expect(() => authMiddleware(jwt, {})).not.toThrow();
39
+ });
40
+ });
@@ -37,6 +37,7 @@ function createStubDispatcher(overrides?: Partial<Dispatcher>): Dispatcher {
37
37
  async query(): Promise<unknown> {
38
38
  return [];
39
39
  },
40
+ async *stream(): AsyncGenerator<unknown> {},
40
41
  async command(): Promise<void> {},
41
42
  async batch(): Promise<BatchResult> {
42
43
  const ok: BatchResult = { isSuccess: true, results: [] };
@@ -52,8 +53,11 @@ function createStubDispatcher(overrides?: Partial<Dispatcher>): Dispatcher {
52
53
  async function buildApp(
53
54
  overrides: Partial<AuthRoutesConfig> = {},
54
55
  dispatcher: Dispatcher = createStubDispatcher(),
56
+ ttlSeconds?: number,
55
57
  ): Promise<{ app: Hono; validToken: string }> {
56
- const jwt = createJwtHelper(JWT_SECRET);
58
+ const jwt = ttlSeconds
59
+ ? createJwtHelper(JWT_SECRET, undefined, ttlSeconds)
60
+ : createJwtHelper(JWT_SECRET);
57
61
  const validToken = await jwt.sign(TestUsers.user);
58
62
  const config: AuthRoutesConfig = {
59
63
  membershipQuery: "tenant:query:memberships",
@@ -128,6 +132,18 @@ describe("auth-routes cookie behaviour on /auth/login", () => {
128
132
  expect(typeof body.token).toBe("string");
129
133
  expect(body.token.length).toBeGreaterThan(20);
130
134
  });
135
+
136
+ test("cookie maxAge tracks the jwt helper's ttlSeconds, not a hardcoded default", async () => {
137
+ const customTtl = 60 * 60; // 1h — distinct from the 24h default
138
+ const { app } = await buildApp({}, undefined, customTtl);
139
+ const res = await app.request("/api/auth/login", {
140
+ method: "POST",
141
+ headers: { "Content-Type": "application/json" },
142
+ body: JSON.stringify({ email: "a@b.c", password: "pw" }),
143
+ });
144
+ expect(getSetCookieRaw(res, AUTH_COOKIE_NAME)).toMatch(new RegExp(`Max-Age=${customTtl}\\b`));
145
+ expect(getSetCookieRaw(res, CSRF_COOKIE_NAME)).toMatch(new RegExp(`Max-Age=${customTtl}\\b`));
146
+ });
131
147
  });
132
148
 
133
149
  describe("auth-routes cookie behaviour on /auth/logout", () => {