@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
package/src/api/index.ts CHANGED
@@ -2,22 +2,30 @@ export type { SetTenantCookieOptions } from "./anonymous-cookie";
2
2
  export { deleteTenantCookie, setTenantCookie } from "./anonymous-cookie";
3
3
  export type {
4
4
  AnonymousAccessConfig,
5
+ AnonymousAccessResolved,
5
6
  AuthMiddlewareOptions,
6
7
  AuthSessionChecker,
7
8
  AuthSessionStatus,
8
9
  TenantExists,
10
+ TenantLifecycleStatusResolver,
9
11
  TenantResolver,
12
+ TokenVerifier,
10
13
  } from "./auth-middleware";
11
- export { authMiddleware, getUser } from "./auth-middleware";
14
+ export { authMiddleware, getUser, PAT_TOKEN_PREFIX } from "./auth-middleware";
12
15
  export type {
13
16
  AuthRoutesConfig,
14
17
  LoginRateLimiter,
15
18
  SessionChecker,
16
19
  SessionCreator,
20
+ SessionMassRevoker,
17
21
  SessionMetadata,
18
22
  SessionRevoker,
19
23
  } from "./auth-routes";
20
- export { createAuthRoutes, createInMemoryLoginRateLimiter } from "./auth-routes";
24
+ export {
25
+ createAuthRoutes,
26
+ createInMemoryLoginRateLimiter,
27
+ createRedisLoginRateLimiter,
28
+ } from "./auth-routes";
21
29
  export type { CachedResponseInit, CachePolicy } from "./http-cache";
22
30
  export {
23
31
  cacheControlHeader,
@@ -28,8 +36,9 @@ export {
28
36
  etagMatches,
29
37
  parseIfNoneMatch,
30
38
  } from "./http-cache";
31
- export type { JwtHelper, JwtPayload } from "./jwt";
32
- export { createJwtHelper } from "./jwt";
39
+ export type { JwtHelper, JwtKeyring, JwtPayload } from "./jwt";
40
+ export { createJwtHelper, loadJwtSecretOrKeyring } from "./jwt";
41
+ export { patAllows, qnMatches } from "./pat-scope";
33
42
  export { type RequestContextData, requestContext } from "./request-context";
34
43
  export { requestIdMiddleware } from "./request-id-middleware";
35
44
  export { createApiRoutes } from "./routes";
package/src/api/jwt.ts CHANGED
@@ -23,10 +23,82 @@ export type JwtPayload = {
23
23
  export type JwtHelper = {
24
24
  sign(user: SessionUser): Promise<string>;
25
25
  verify(token: string): Promise<JwtPayload>;
26
+ // The TTL this helper signs tokens with, in seconds — the single source for
27
+ // callers (e.g. the auth-cookie's maxAge) that must stay coupled to the JWT's exp.
28
+ readonly ttlSeconds: number;
26
29
  };
27
30
 
28
- export function createJwtHelper(secret: string, issuer = "kumiko"): JwtHelper {
29
- const encodedSecret = new TextEncoder().encode(secret);
31
+ // kid secret. All entries verify; `signKid` picks the sign-key. Rotation:
32
+ // add the new kid, flip signKid, keep the old kid around until in-flight
33
+ // tokens expire.
34
+ export type JwtKeyring = {
35
+ readonly keys: Readonly<Record<string, string>>;
36
+ readonly signKid: string;
37
+ };
38
+
39
+ type NormalizedKeyring = {
40
+ readonly verifyKeys: ReadonlyMap<string, Uint8Array>;
41
+ readonly signKid: string | undefined;
42
+ readonly signKey: Uint8Array;
43
+ };
44
+
45
+ function normalizeKeyring(secretOrKeyring: string | JwtKeyring): NormalizedKeyring {
46
+ if (typeof secretOrKeyring === "string") {
47
+ const key = new TextEncoder().encode(secretOrKeyring);
48
+ return { verifyKeys: new Map(), signKid: undefined, signKey: key };
49
+ }
50
+
51
+ const verifyKeys = new Map<string, Uint8Array>();
52
+ for (const [kid, secret] of Object.entries(secretOrKeyring.keys)) {
53
+ verifyKeys.set(kid, new TextEncoder().encode(secret));
54
+ }
55
+ const signKey = verifyKeys.get(secretOrKeyring.signKid);
56
+ if (!signKey) {
57
+ throw new Error(
58
+ `createJwtHelper: signKid "${secretOrKeyring.signKid}" is not present in the keyring`,
59
+ );
60
+ }
61
+ return { verifyKeys, signKid: secretOrKeyring.signKid, signKey };
62
+ }
63
+
64
+ // Tokens carry `kid` in the protected header when signed from a keyring — pick the
65
+ // matching verify-key directly. Tokens without `kid` (single-secret form, or in-flight
66
+ // tokens signed before a rotation) fall back to trying every verify-key.
67
+ async function verifyWithKeyring(token: string, keyring: NormalizedKeyring, issuer: string) {
68
+ const { kid } = jose.decodeProtectedHeader(token);
69
+ if (typeof kid === "string" && keyring.verifyKeys.size > 0) {
70
+ const key = keyring.verifyKeys.get(kid);
71
+ if (!key) {
72
+ throw new Error(`JWT verification failed: unknown kid "${kid}"`);
73
+ }
74
+ return jose.jwtVerify(token, key, { issuer });
75
+ }
76
+
77
+ // ponytail: tries every key in the ring (O(keys) per legacy-token verify) — fine for a
78
+ // rotation window of a handful of keys, revisit if the keyring ever grows large.
79
+ const candidates =
80
+ keyring.verifyKeys.size > 0 ? [...keyring.verifyKeys.values()] : [keyring.signKey];
81
+ let lastError: unknown;
82
+ for (const key of candidates) {
83
+ try {
84
+ return await jose.jwtVerify(token, key, { issuer });
85
+ } catch (err) {
86
+ lastError = err;
87
+ }
88
+ }
89
+ throw lastError instanceof Error
90
+ ? lastError
91
+ : new Error("JWT verification failed: no matching key");
92
+ }
93
+
94
+ const DEFAULT_JWT_TTL_SECONDS = 24 * 60 * 60;
95
+
96
+ export function createJwtHelper(
97
+ secretOrKeyring: string | JwtKeyring,
98
+ issuer = "kumiko",
99
+ ttlSeconds = DEFAULT_JWT_TTL_SECONDS,
100
+ ): JwtHelper {
101
+ const keyring = normalizeKeyring(secretOrKeyring);
30
102
 
31
103
  return {
32
104
  async sign(user) {
@@ -36,24 +108,29 @@ export function createJwtHelper(secret: string, issuer = "kumiko"): JwtHelper {
36
108
  };
37
109
  if (user.claims) body.claims = { ...user.claims };
38
110
 
111
+ const header: jose.JWTHeaderParameters = keyring.signKid
112
+ ? { alg: "HS256", kid: keyring.signKid }
113
+ : { alg: "HS256" };
114
+
115
+ // iat/exp share one `now` — jose's setIssuedAt()/setExpirationTime(Date)
116
+ // each read the clock separately, letting `exp - iat` drift by a
117
+ // second and making TTL-precision tests flaky.
118
+ const nowSec = Math.floor(Date.now() / 1000);
39
119
  const builder = new jose.SignJWT(body)
40
- .setProtectedHeader({ alg: "HS256" })
120
+ .setProtectedHeader(header)
41
121
  .setSubject(String(user.id))
42
122
  .setIssuer(issuer)
43
- .setIssuedAt()
44
- .setExpirationTime("24h");
123
+ .setIssuedAt(nowSec)
124
+ .setExpirationTime(nowSec + ttlSeconds);
45
125
  if (user.sid) builder.setJti(user.sid);
46
126
 
47
- return builder.sign(encodedSecret);
127
+ return builder.sign(keyring.signKey);
48
128
  },
49
129
 
50
130
  async verify(token) {
51
- const { payload } = await jose.jwtVerify(token, encodedSecret, { issuer });
131
+ const { payload } = await verifyWithKeyring(token, keyring, issuer);
52
132
 
53
- // Defence in depth: a valid signature does not guarantee well-formed claims. A
54
- // leaked secret, key confusion, or a hand-crafted token can still carry junk —
55
- // validate the claim shape and reject (verify() throws → 401 in auth-middleware)
56
- // instead of casting blindly.
133
+ // defence-in-depth: valid sig well-formed claims; malformed payload → throw → 401
57
134
  const tenantId = parseTenantId(payload["tenantId"]);
58
135
  if (tenantId === null) {
59
136
  throw new Error("JWT payload validation failed: tenantId claim is missing or malformed");
@@ -87,5 +164,87 @@ export function createJwtHelper(secret: string, issuer = "kumiko"): JwtHelper {
87
164
  }
88
165
  return result;
89
166
  },
167
+ ttlSeconds,
90
168
  };
91
169
  }
170
+
171
+ const JWT_KEY_VAR_PATTERN = /^JWT_SECRET_V(\d+)$/;
172
+ const JWT_CURRENT_VERSION_VAR = "JWT_SECRET_CURRENT_VERSION";
173
+ // Mirrors authEmailPasswordEnvSchema's JWT_SECRET.min(32) — HS256 minimum.
174
+ // JWT_SECRET_V<n> bypasses that zod schema entirely (it only validates the
175
+ // plain JWT_SECRET name), so this loader is the only gate for the rotation path.
176
+ const MIN_JWT_SECRET_LENGTH = 32;
177
+
178
+ function assertMinLength(name: string, value: string): void {
179
+ if (value.length < MIN_JWT_SECRET_LENGTH) {
180
+ throw new Error(`[jwt] ${name} must be ≥${MIN_JWT_SECRET_LENGTH} chars (HS256 minimum)`);
181
+ }
182
+ }
183
+
184
+ // Env-loader for createJwtHelper's secret-or-keyring param, analog to
185
+ // secrets' loadKeyring: JWT_SECRET_V<n> (+ JWT_SECRET_CURRENT_VERSION picking
186
+ // the active signKid) for rotation, falling back to plain JWT_SECRET when no
187
+ // JWT_SECRET_V<n> is set — so a non-rotating deployment needs no new env vars.
188
+ export function loadJwtSecretOrKeyring(
189
+ env: Readonly<Record<string, string | undefined>>,
190
+ ): string | JwtKeyring {
191
+ const keys: Record<string, string> = {};
192
+ for (const [name, value] of Object.entries(env)) {
193
+ const match = name.match(JWT_KEY_VAR_PATTERN);
194
+ if (!match) continue;
195
+ if (!value) {
196
+ throw new Error(
197
+ `[jwt] ${name} is set but empty — an empty versioned secret is almost certainly a ` +
198
+ "deploy mistake, not an intentional skip. Unset the var entirely if it's unused.",
199
+ );
200
+ }
201
+ assertMinLength(name, value);
202
+ // biome-ignore lint/style/noNonNullAssertion: regex group 1 always present
203
+ keys[`v${match[1]!}`] = value;
204
+ }
205
+
206
+ // skip: no JWT_SECRET_V<n> found — single-secret fallback.
207
+ if (Object.keys(keys).length === 0) {
208
+ const secret = env["JWT_SECRET"];
209
+ if (!secret) {
210
+ throw new Error(
211
+ "[jwt] JWT_SECRET not set — set JWT_SECRET for a single key, or " +
212
+ "JWT_SECRET_V1 (+ JWT_SECRET_CURRENT_VERSION=1) for a rotatable keyring.",
213
+ );
214
+ }
215
+ assertMinLength("JWT_SECRET", secret);
216
+ return secret;
217
+ }
218
+
219
+ const currentRaw = env[JWT_CURRENT_VERSION_VAR];
220
+ if (!currentRaw) {
221
+ throw new Error(
222
+ `[jwt] ${JWT_CURRENT_VERSION_VAR} not set — explicit current-version required ` +
223
+ "so adding a new JWT_SECRET_V<n> doesn't auto-promote it to the sign key.",
224
+ );
225
+ }
226
+ const signKid = `v${currentRaw}`;
227
+ if (!keys[signKid]) {
228
+ throw new Error(
229
+ `[jwt] ${JWT_CURRENT_VERSION_VAR}="${currentRaw}" not present in the keyring ` +
230
+ `(have versions: ${Object.keys(keys).sort().join(", ")}). ` +
231
+ `Check JWT_SECRET_V${currentRaw} is set.`,
232
+ );
233
+ }
234
+ // Carry the pre-rotation plain JWT_SECRET into the ring as a verify-only
235
+ // legacy key: sessions signed before JWT_SECRET_V<n> was first set have no
236
+ // `kid`, so verifyWithKeyring's no-kid fallback tries every key in `keys` —
237
+ // if the plain secret isn't one of them, every in-flight session breaks the
238
+ // moment rotation is adopted, exactly the mass-invalidation this keyring
239
+ // exists to avoid. Never becomes signKid — only JWT_SECRET_V<n> can sign.
240
+ // Retirement: has no automatic expiry — verifies unbounded as long as
241
+ // JWT_SECRET stays set. Operators must explicitly unset JWT_SECRET once
242
+ // max token TTL (`ttlSeconds`) has elapsed since cutover to actually
243
+ // retire a rotated-out secret (see run-prod-app.ts boot warning).
244
+ const legacySecret = env["JWT_SECRET"];
245
+ if (legacySecret) {
246
+ assertMinLength("JWT_SECRET", legacySecret);
247
+ keys["legacy"] = legacySecret;
248
+ }
249
+ return { keys, signKid };
250
+ }
@@ -0,0 +1,14 @@
1
+ // QN scope-matching for Personal Access Tokens. A token's granted scopes
2
+ // expand (in the PAT feature's resolver) to QN globs like "credit:write:*" or
3
+ // "credit:query:credit:list". A glob ending in "*" matches any dispatch type
4
+ // sharing the prefix; otherwise it is an exact match. Fail-closed: an empty
5
+ // allow-list matches nothing, so a PAT with no scopes can call nothing.
6
+
7
+ export function qnMatches(pattern: string, type: string): boolean {
8
+ if (pattern.endsWith("*")) return type.startsWith(pattern.slice(0, -1));
9
+ return pattern === type;
10
+ }
11
+
12
+ export function patAllows(allowedQns: readonly string[], type: string): boolean {
13
+ return allowedQns.some((pattern) => qnMatches(pattern, type));
14
+ }
@@ -0,0 +1,47 @@
1
+ import type { MiddlewareHandler } from "hono";
2
+ import { configuredPiiSubjectKms, PII_CIPHERTEXT_PREFIX } from "../crypto";
3
+
4
+ const isProductionEnv = () => process.env["NODE_ENV"] === "production";
5
+ // Version-agnostic: catches both the current PII_CIPHERTEXT_PREFIX and any
6
+ // older/decrypt-only format version still present in unmigrated rows.
7
+ const CIPHERTEXT_MARKER = "kumiko-pii:v";
8
+ const CIPHERTEXT_RE = /kumiko-pii:v\d+:[^"\s<>\\]*/g;
9
+
10
+ // A PII subject ciphertext never belongs in an API response — its presence
11
+ // means a raw DB read (fetchOne/selectMany) leaked to the surface. Dev/test
12
+ // fail loud (500) so a forgotten decrypt turns the first integration test
13
+ // red; prod redacts + logs instead of shipping the blob. Skipped entirely
14
+ // when no subject KMS is configured (no ciphertexts can exist).
15
+ export function piiCiphertextResponseGuard(): MiddlewareHandler {
16
+ return async (c, next) => {
17
+ await next();
18
+ // skip: no subject KMS configured — no ciphertexts can exist, nothing to scan
19
+ if (configuredPiiSubjectKms() === undefined) return;
20
+ const contentType = c.res.headers.get("content-type") ?? "";
21
+ // skip: only JSON bodies carry handler data — streams/zips stay untouched
22
+ if (!contentType.includes("application/json")) return;
23
+ const text = await c.res.clone().text();
24
+ // skip: clean response — the common case
25
+ if (!text.includes(CIPHERTEXT_MARKER)) return;
26
+
27
+ const detail =
28
+ `[api] JSON response for ${c.req.method} ${c.req.path} contains a PII ciphertext ` +
29
+ `("${PII_CIPHERTEXT_PREFIX}…") — a raw DB read leaked to the API surface. ` +
30
+ `Decrypt before returning (decryptStoredPii / executor read path).`;
31
+ if (!isProductionEnv()) {
32
+ c.res = Response.json(
33
+ { error: { code: "pii_ciphertext_leak", httpStatus: 500, message: detail } },
34
+ { status: 500 },
35
+ );
36
+ // skip: response replaced with the loud 500 above — nothing left to do
37
+ return;
38
+ }
39
+ console.error(detail);
40
+ const headers = new Headers(c.res.headers);
41
+ headers.delete("content-length");
42
+ c.res = new Response(text.replace(CIPHERTEXT_RE, "[pii-redacted]"), {
43
+ status: c.res.status,
44
+ headers,
45
+ });
46
+ };
47
+ }
@@ -30,6 +30,9 @@ export type RequestContextData = {
30
30
  // Populated by requestIdMiddleware from x-forwarded-for or the
31
31
  // socket address. Undefined for non-HTTP entry points (jobs, MSP).
32
32
  readonly ip?: string;
33
+ // Raw User-Agent header — audit trails (download tokens, GDPR export
34
+ // access) want it alongside `ip`. Undefined for non-HTTP entry points.
35
+ readonly userAgent?: string;
33
36
  };
34
37
 
35
38
  const storage = new AsyncLocalStorage<RequestContextData>();
@@ -37,12 +37,14 @@ export function requestIdMiddleware() {
37
37
  // than fabricate one.
38
38
  const xff = c.req.header("x-forwarded-for");
39
39
  const ip = xff?.split(",")[0]?.trim();
40
+ const userAgent = c.req.header("user-agent");
40
41
  await requestContext.run(
41
42
  {
42
43
  requestId,
43
44
  correlationId,
44
45
  ...(signal ? { signal } : {}),
45
46
  ...(ip && ip.length > 0 ? { ip } : {}),
47
+ ...(userAgent !== undefined ? { userAgent } : {}),
46
48
  },
47
49
  () => next(),
48
50
  );
package/src/api/routes.ts CHANGED
@@ -1,6 +1,9 @@
1
1
  import { type Context, Hono } from "hono";
2
+ import { streamSSE } from "hono/streaming";
2
3
  import type { ContentfulStatusCode } from "hono/utils/http-status";
4
+ import type { SessionUser } from "../engine/types/handlers";
3
5
  import {
6
+ AccessDeniedError,
4
7
  type KumikoError,
5
8
  reraiseAsKumikoError,
6
9
  serializeError,
@@ -12,9 +15,29 @@ import type { Dispatcher } from "../pipeline/dispatcher";
12
15
  import { stringifyJson } from "../utils/safe-json";
13
16
  import { Routes } from "./api-constants";
14
17
  import { getUser } from "./auth-middleware";
18
+ import { patAllows } from "./pat-scope";
15
19
  import { requestContext } from "./request-context";
20
+ import { SSE_HEARTBEAT_INTERVAL_MS } from "./sse-route";
16
21
 
17
- export function createApiRoutes(dispatcher: Dispatcher) {
22
+ // SSE frame event names for POST /api/stream (framework-owned; dispatcher-live
23
+ // has no dependency on this package and keeps its own copy in sse-stream.ts —
24
+ // a drift between the two fails the real-HTTP frame assertions in api.test.ts).
25
+ export const StreamFrame = {
26
+ chunk: "chunk",
27
+ ping: "ping",
28
+ done: "done",
29
+ error: "error",
30
+ } as const;
31
+
32
+ export type ApiRoutesOptions = {
33
+ // Override the SSE heartbeat interval (ms). Production uses
34
+ // SSE_HEARTBEAT_INTERVAL_MS; tests pass a short value so the pre-pull
35
+ // race + ping path can be exercised without a 15s wait.
36
+ readonly sseHeartbeatMs?: number;
37
+ };
38
+
39
+ export function createApiRoutes(dispatcher: Dispatcher, options: ApiRoutesOptions = {}) {
40
+ const heartbeatMs = options.sseHeartbeatMs ?? SSE_HEARTBEAT_INTERVAL_MS;
18
41
  const api = new Hono();
19
42
 
20
43
  api.post(Routes.write, async (c) => {
@@ -22,6 +45,7 @@ export function createApiRoutes(dispatcher: Dispatcher) {
22
45
  const body = await c.req.json<{ type: string; payload: unknown; requestId?: string }>();
23
46
 
24
47
  try {
48
+ assertPatAllowed(user, body.type);
25
49
  const result = await dispatcher.write(body.type, body.payload, user, body.requestId);
26
50
  if (!result.isSuccess) {
27
51
  return writeErrorResponse(c, reraiseAsKumikoError(result.error), body.type);
@@ -59,6 +83,9 @@ export function createApiRoutes(dispatcher: Dispatcher) {
59
83
  }
60
84
 
61
85
  try {
86
+ if (user.pat) {
87
+ for (const cmd of body.commands) assertPatAllowed(user, cmd.type);
88
+ }
62
89
  const result = await dispatcher.batch(body.commands, user, body.requestId);
63
90
  if (!result.isSuccess) {
64
91
  const err = reraiseAsKumikoError(result.error);
@@ -83,7 +110,8 @@ export function createApiRoutes(dispatcher: Dispatcher) {
83
110
  }
84
111
  return c.json(result);
85
112
  } catch (e) {
86
- return writeErrorResponse(c, toKumiko(e));
113
+ // single "type" doesn't apply to a batch; command types keep the fault context.
114
+ return writeErrorResponse(c, toKumiko(e), body.commands?.map((cmd) => cmd.type).join(","));
87
115
  }
88
116
  });
89
117
 
@@ -92,6 +120,7 @@ export function createApiRoutes(dispatcher: Dispatcher) {
92
120
  const body = await c.req.json<{ type: string; payload: unknown }>();
93
121
 
94
122
  try {
123
+ assertPatAllowed(user, body.type);
95
124
  const result = await dispatcher.query(body.type, body.payload, user);
96
125
  return jsonResponse(c, { data: result });
97
126
  } catch (e) {
@@ -104,6 +133,7 @@ export function createApiRoutes(dispatcher: Dispatcher) {
104
133
  const body = await c.req.json<{ type: string; payload: unknown }>();
105
134
 
106
135
  try {
136
+ assertPatAllowed(user, body.type);
107
137
  await dispatcher.command(body.type, body.payload, user);
108
138
  return c.json({ ok: true }, 202);
109
139
  } catch (e) {
@@ -111,15 +141,152 @@ export function createApiRoutes(dispatcher: Dispatcher) {
111
141
  }
112
142
  });
113
143
 
144
+ // Dispatcher-driven SSE, full auth/CSRF/rate-limit chain (unlike the
145
+ // broker-based /sse route). Frame contract for clients: StreamFrame.chunk
146
+ // (one per yielded value, JSON-encoded), .ping (heartbeat, empty data),
147
+ // .done (terminal, empty data), .error (terminal, JSON error envelope —
148
+ // only reachable once the stream is already open, i.e. failures from the
149
+ // second chunk onward). The generator's first `.next()` — which runs the
150
+ // dispatch gates (feature/rate-limit/access/validation) plus the handler's
151
+ // first yield — is raced against a heartbeat timeout BEFORE streamSSE, so
152
+ // a gate failure that settles in time maps to its real HTTP status via
153
+ // queryErrorResponse instead of a flushed-200 error frame (framework#1517).
154
+ api.post(Routes.stream, async (c) => {
155
+ const user = getUser(c);
156
+ const body = await c.req.json<{ type: string; payload: unknown }>();
157
+ const requestId = requestContext.get()?.requestId;
158
+
159
+ const generator = dispatcher.stream(body.type, body.payload, user);
160
+ try {
161
+ assertPatAllowed(user, body.type);
162
+ } catch (e) {
163
+ return queryErrorResponse(c, toKumiko(e), body.type);
164
+ }
165
+
166
+ // stream.onAbort() only exists once streamSSE opens the response below.
167
+ // Hook the raw request signal directly so a client disconnect during the
168
+ // pre-pull still reclaims the generator instead of leaking a Redis
169
+ // subscription/DB cursor held open inside its still-unresolved first
170
+ // `.next()` (framework#1528).
171
+ const signal = c.req.raw.signal;
172
+ const onPrePullAbort = () => void generator.return(undefined);
173
+ signal.addEventListener("abort", onPrePullAbort);
174
+
175
+ const firstPull = generator.next();
176
+ let prePullTimer: ReturnType<typeof setTimeout> | undefined;
177
+ const settledInTime = await Promise.race([
178
+ firstPull.then(() => true).catch(() => true),
179
+ new Promise<false>((resolve) => {
180
+ prePullTimer = setTimeout(() => resolve(false), heartbeatMs);
181
+ }),
182
+ ]);
183
+ clearTimeout(prePullTimer);
184
+ signal.removeEventListener("abort", onPrePullAbort);
185
+
186
+ if (settledInTime) {
187
+ try {
188
+ await firstPull;
189
+ } catch (e) {
190
+ return queryErrorResponse(c, toKumiko(e), body.type);
191
+ }
192
+ }
193
+
194
+ if (signal.aborted) {
195
+ // Fire-and-forget: settledInTime === false means firstPull is by
196
+ // definition still pending — V8 queues a .return() request behind an
197
+ // in-flight .next(), so awaiting here would block the response until
198
+ // that pending pull resolves (which may be never for an idle stream).
199
+ void generator.return(undefined).catch(() => {});
200
+ return c.body(null, 499 as ContentfulStatusCode); // @cast-boundary non-standard client-closed-request status, Hono's union doesn't include it
201
+ }
202
+
203
+ return streamSSE(c, async (stream) => {
204
+ stream.onAbort(() => {
205
+ void generator.return(undefined);
206
+ });
207
+
208
+ try {
209
+ await pumpStream(stream, generator, heartbeatMs, firstPull);
210
+ } catch (e) {
211
+ const err = toKumiko(e);
212
+ logServerFault(err, requestId, body.type);
213
+ const { error } = serializeError(err, requestId);
214
+ await stream.writeSSE({ event: StreamFrame.error, data: stringifyJson(error) });
215
+ }
216
+ });
217
+ });
218
+
114
219
  return api;
115
220
  }
116
221
 
222
+ export type SseWriter = {
223
+ readonly writeSSE: (message: { readonly event: string; readonly data: string }) => Promise<void>;
224
+ };
225
+
226
+ // Races each generator.next() against a heartbeat timer so an idle handler keeps the SSE connection alive.
227
+ export async function pumpStream(
228
+ stream: SseWriter,
229
+ generator: AsyncGenerator<unknown>,
230
+ heartbeatMs: number,
231
+ // Pending (or already-settled) first `.next()` — see the /api/stream route,
232
+ // which races this against a heartbeat timeout before opening streamSSE.
233
+ firstPull?: Promise<IteratorResult<unknown>>,
234
+ ): Promise<void> {
235
+ let pending = firstPull ?? generator.next();
236
+ try {
237
+ while (true) {
238
+ let heartbeatTimer: ReturnType<typeof setTimeout> | undefined;
239
+ const heartbeat = new Promise<"heartbeat">((resolve) => {
240
+ heartbeatTimer = setTimeout(() => resolve("heartbeat"), heartbeatMs);
241
+ });
242
+ let outcome: Awaited<typeof pending> | "heartbeat";
243
+ try {
244
+ outcome = await Promise.race([pending, heartbeat]);
245
+ } finally {
246
+ clearTimeout(heartbeatTimer);
247
+ }
248
+
249
+ if (outcome === "heartbeat") {
250
+ await stream.writeSSE({ event: StreamFrame.ping, data: "" });
251
+ continue;
252
+ }
253
+ if (outcome.done) break;
254
+ await stream.writeSSE({
255
+ event: StreamFrame.chunk,
256
+ data: stringifyJson(outcome.value ?? null),
257
+ });
258
+ pending = generator.next();
259
+ }
260
+ await stream.writeSSE({ event: StreamFrame.done, data: "" });
261
+ } finally {
262
+ // Fire-and-forget: if writeSSE threw mid-loop, `pending` (the last
263
+ // generator.next()) may still be unresolved — V8 queues .return()
264
+ // behind an in-flight .next(), so awaiting here would hang until that
265
+ // pull settles (which may be never for a handler stuck on a dead
266
+ // Redis/DB subscription the disconnect just orphaned).
267
+ void generator.return(undefined).catch(() => {});
268
+ }
269
+ }
270
+
117
271
  function jsonResponse(c: Context, body: unknown, status: ContentfulStatusCode = 200) {
118
272
  return c.body(stringifyJson(body), status, { "Content-Type": "application/json" });
119
273
  }
120
274
 
121
275
  const toKumiko = toKumikoError;
122
276
 
277
+ // PAT scope enforcement at the API boundary. No-op for cookie/JWT users
278
+ // (user.pat undefined → unrestricted). For a PAT-authenticated request the
279
+ // dispatch type must match one of the token's granted-scope QN globs, else
280
+ // 403 — fail-closed, thrown so each route's existing catch shapes the body.
281
+ function assertPatAllowed(user: SessionUser, type: string): void {
282
+ if (user.pat && !patAllows(user.pat.allowedQns, type)) {
283
+ throw new AccessDeniedError({
284
+ message: `personal access token scope does not permit ${type}`,
285
+ details: { handler: type, scopes: user.pat.scopes },
286
+ });
287
+ }
288
+ }
289
+
123
290
  // Unexpected server faults (5xx) carry their diagnostic stack only on the
124
291
  // in-process error — serializeError strips cause/details from the wire body.
125
292
  // Without this a wrapped throw (InternalError{cause}) returns a 500 with zero