@cosmicdrift/kumiko-framework 1.0.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (424) hide show
  1. package/README.md +9 -38
  2. package/package.json +21 -2
  3. package/src/__tests__/anonymous-access.integration.test.ts +185 -0
  4. package/src/__tests__/consumer-cli.integration.test.ts +172 -0
  5. package/src/__tests__/entity-permalink-open.integration.test.ts +94 -0
  6. package/src/__tests__/full-stack.integration.test.ts +1 -1
  7. package/src/__tests__/schema-cli-temporal-polyfill.test.ts +48 -0
  8. package/src/__tests__/schema-cli.integration.test.ts +50 -0
  9. package/src/__tests__/store-table.integration.test.ts +93 -0
  10. package/src/api/__tests__/api.test.ts +373 -0
  11. package/src/api/__tests__/auth-middleware-anonymous-access-boot.test.ts +40 -0
  12. package/src/api/__tests__/auth-routes-cookie.test.ts +17 -1
  13. package/src/api/__tests__/auth-routes-invalid-body-invite.test.ts +253 -0
  14. package/src/api/__tests__/auth-routes-mfa-preauth-confirm.test.ts +222 -0
  15. package/src/api/__tests__/auth-routes-mfa-preauth-enable-start.test.ts +249 -0
  16. package/src/api/__tests__/auth-routes-mfa-verify.test.ts +202 -0
  17. package/src/api/__tests__/auth-routes-trusted-proxy.test.ts +135 -0
  18. package/src/api/__tests__/batch.integration.test.ts +101 -11
  19. package/src/api/__tests__/csrf-constants-sync.test.ts +20 -0
  20. package/src/api/__tests__/dispatcher-live.integration.test.ts +74 -0
  21. package/src/api/__tests__/jwt.test.ts +200 -1
  22. package/src/api/__tests__/login-rate-limiter-sweep.test.ts +50 -0
  23. package/src/api/__tests__/pat-scope.test.ts +36 -0
  24. package/src/api/__tests__/pii-leak-guard.integration.test.ts +103 -0
  25. package/src/api/__tests__/redis-login-rate-limiter.integration.test.ts +138 -0
  26. package/src/api/__tests__/request-id-middleware.test.ts +51 -0
  27. package/src/api/__tests__/server-boot-guards.test.ts +71 -0
  28. package/src/api/__tests__/server-jwt-ttl.test.ts +58 -0
  29. package/src/api/__tests__/sse-broker.test.ts +57 -0
  30. package/src/api/__tests__/sse-route.test.ts +4 -0
  31. package/src/api/api-constants.ts +11 -0
  32. package/src/api/auth-middleware.ts +248 -40
  33. package/src/api/auth-routes.ts +576 -95
  34. package/src/api/index.ts +13 -4
  35. package/src/api/jwt.ts +170 -11
  36. package/src/api/pat-scope.ts +14 -0
  37. package/src/api/pii-leak-guard.ts +47 -0
  38. package/src/api/request-context.ts +3 -0
  39. package/src/api/request-id-middleware.ts +2 -0
  40. package/src/api/routes.ts +169 -2
  41. package/src/api/server.ts +112 -16
  42. package/src/api/sse-broker.ts +39 -0
  43. package/src/bun-db/__tests__/PATTERN.md +0 -1
  44. package/src/bun-db/__tests__/coerce-row-temporal.test.ts +41 -0
  45. package/src/bun-db/__tests__/select-many-retry.test.ts +79 -0
  46. package/src/bun-db/__tests__/write-brand.test.ts +21 -0
  47. package/src/bun-db/connection.ts +3 -3
  48. package/src/bun-db/index.ts +2 -0
  49. package/src/bun-db/query.ts +105 -32
  50. package/src/consumer-cli.ts +134 -0
  51. package/src/crypto/__tests__/blind-index.test.ts +130 -0
  52. package/src/crypto/__tests__/event-pii.test.ts +161 -0
  53. package/src/crypto/__tests__/kek-rotation.integration.test.ts +180 -0
  54. package/src/crypto/__tests__/kms-adapter-contract.ts +134 -0
  55. package/src/crypto/__tests__/kms-adapter.contract.test.ts +4 -0
  56. package/src/crypto/__tests__/pg-kms-adapter.integration.test.ts +117 -0
  57. package/src/crypto/__tests__/pii-field-encryption.test.ts +376 -0
  58. package/src/crypto/__tests__/request-kms-cache.test.ts +74 -0
  59. package/src/crypto/__tests__/subject-resolver.test.ts +108 -0
  60. package/src/crypto/blind-index.ts +118 -0
  61. package/src/crypto/event-pii.ts +70 -0
  62. package/src/crypto/in-memory-kms-adapter.ts +50 -0
  63. package/src/crypto/index.ts +67 -0
  64. package/src/crypto/kms-adapter.ts +2 -0
  65. package/src/crypto/pg-kms-adapter.ts +295 -0
  66. package/src/crypto/pii-field-encryption.ts +248 -0
  67. package/src/crypto/request-kms-cache.ts +38 -0
  68. package/src/crypto/subject-resolver.ts +91 -0
  69. package/src/db/__tests__/assert-no-unreachable-live-rows.integration.test.ts +191 -0
  70. package/src/db/__tests__/blind-index.integration.test.ts +248 -0
  71. package/src/db/__tests__/build-filter-where.test.ts +34 -0
  72. package/src/db/__tests__/collect-table-metas.test.ts +10 -10
  73. package/src/db/__tests__/config-seed.integration.test.ts +13 -5
  74. package/src/db/__tests__/dialect-instant.test.ts +1 -4
  75. package/src/db/__tests__/entity-field-encryption.test.ts +7 -7
  76. package/src/db/__tests__/entity-table-meta-source.test.ts +50 -0
  77. package/src/db/__tests__/event-store-executor-context.pii-roundtrip.test.ts +67 -0
  78. package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +402 -0
  79. package/src/db/__tests__/event-store-executor.integration.test.ts +299 -29
  80. package/src/db/__tests__/feature-table-sources.test.ts +34 -0
  81. package/src/db/__tests__/implicit-projection-equivalence.integration.test.ts +118 -39
  82. package/src/db/__tests__/instant-to-driver-temporal.test.ts +21 -0
  83. package/src/db/__tests__/located-timestamp.test.ts +19 -0
  84. package/src/db/__tests__/migrate-generator.test.ts +21 -0
  85. package/src/db/__tests__/migrate-runner.test.ts +61 -0
  86. package/src/db/__tests__/number-field-fractional.integration.test.ts +58 -0
  87. package/src/db/__tests__/rebuild-marker.test.ts +13 -3
  88. package/src/db/__tests__/replay-migration-sql.test.ts +384 -0
  89. package/src/db/__tests__/schema-inspection.test.ts +7 -0
  90. package/src/db/__tests__/schema-migration.integration.test.ts +1 -1
  91. package/src/db/__tests__/table-builder-meta-lockstep.test.ts +39 -0
  92. package/src/db/__tests__/tenant-db-where-merge.test.ts +49 -3
  93. package/src/db/__tests__/tenant-db.integration.test.ts +6 -2
  94. package/src/db/api.ts +2 -2
  95. package/src/db/apply-entity-event.ts +18 -14
  96. package/src/db/blind-index-cleanup.ts +55 -0
  97. package/src/db/bun-provider.ts +2 -2
  98. package/src/db/collect-table-metas.ts +6 -7
  99. package/src/db/config-seed.ts +9 -9
  100. package/src/db/connection.ts +7 -12
  101. package/src/db/cursor.ts +1 -18
  102. package/src/db/dialect.ts +20 -26
  103. package/src/db/entity-field-encryption.ts +81 -24
  104. package/src/db/entity-table-meta-types.ts +2 -0
  105. package/src/db/entity-table-meta.ts +63 -90
  106. package/src/db/event-store-executor-context.ts +404 -0
  107. package/src/db/event-store-executor-read.ts +275 -0
  108. package/src/db/event-store-executor-write.ts +644 -0
  109. package/src/db/event-store-executor.ts +28 -1155
  110. package/src/db/feature-table-sources.ts +6 -5
  111. package/src/db/index.ts +20 -3
  112. package/src/db/located-timestamp.ts +4 -0
  113. package/src/db/migrate-generator.ts +39 -6
  114. package/src/db/migrate-runner.ts +107 -11
  115. package/src/db/pg-error.ts +9 -1
  116. package/src/db/postgres-provider.ts +2 -2
  117. package/src/db/queries/__tests__/event-store-idempotency-index.integration.test.ts +80 -0
  118. package/src/db/queries/backfill-pii.ts +277 -0
  119. package/src/db/queries/ddl.ts +45 -0
  120. package/src/db/queries/event-consumer.ts +35 -2
  121. package/src/db/queries/event-store.ts +126 -19
  122. package/src/db/queries/projection-rebuild.ts +22 -8
  123. package/src/db/queries/shadow-swap.ts +183 -0
  124. package/src/db/queries/test-stack.ts +4 -30
  125. package/src/db/query-api.ts +1 -0
  126. package/src/db/query.ts +1 -0
  127. package/src/db/rebuild-marker.ts +18 -2
  128. package/src/db/reference-data.ts +2 -3
  129. package/src/db/replay-migration-sql.ts +257 -0
  130. package/src/db/schema-inspection.ts +1 -1
  131. package/src/db/table-builder.ts +102 -71
  132. package/src/db/tenant-db.ts +15 -53
  133. package/src/engine/__tests__/boot-validator-action-wiring.test.ts +242 -0
  134. package/src/engine/__tests__/boot-validator-boot-check.test.ts +99 -0
  135. package/src/engine/__tests__/boot-validator-dashboard.test.ts +237 -0
  136. package/src/engine/__tests__/boot-validator-entity-list.test.ts +104 -0
  137. package/src/engine/__tests__/boot-validator-gdpr-storage.test.ts +7 -69
  138. package/src/engine/__tests__/boot-validator-i18n-keys.test.ts +26 -5
  139. package/src/engine/__tests__/boot-validator-located-timestamps.test.ts +3 -19
  140. package/src/engine/__tests__/boot-validator-pii-retention.test.ts +246 -4
  141. package/src/engine/__tests__/boot-validator.test.ts +219 -15
  142. package/src/engine/__tests__/build-app-schema.test.ts +82 -0
  143. package/src/engine/__tests__/build-config-feature-schema.test.ts +125 -0
  144. package/src/engine/__tests__/build-target.test.ts +3 -11
  145. package/src/engine/__tests__/codemod-pipeline.test.ts +152 -21
  146. package/src/engine/__tests__/config-helpers.test.ts +16 -0
  147. package/src/engine/__tests__/define-feature-entity-mapping.test.ts +6 -0
  148. package/src/engine/__tests__/define-roles.test.ts +21 -0
  149. package/src/engine/__tests__/engine.test.ts +163 -1
  150. package/src/engine/__tests__/entity-handlers.test.ts +80 -0
  151. package/src/engine/__tests__/event-migration-declarative.test.ts +65 -0
  152. package/src/engine/__tests__/event-type-map-augmentation.test.ts +24 -0
  153. package/src/engine/__tests__/extend-entity-projection.test.ts +123 -0
  154. package/src/engine/__tests__/factories-time.test.ts +2 -66
  155. package/src/engine/__tests__/feature-crud-shorthand.test.ts +28 -0
  156. package/src/engine/__tests__/feature-manifest.test.ts +31 -1
  157. package/src/engine/__tests__/field-access.test.ts +23 -1
  158. package/src/engine/__tests__/hook-phases.test.ts +5 -5
  159. package/src/engine/__tests__/membership-roles.test.ts +4 -10
  160. package/src/engine/__tests__/nav.test.ts +86 -1
  161. package/src/engine/__tests__/pipeline-engine.test.ts +9 -9
  162. package/src/engine/__tests__/pipeline-handler.integration.test.ts +18 -18
  163. package/src/engine/__tests__/pipeline-observability.integration.test.ts +2 -2
  164. package/src/engine/__tests__/pipeline-performance.integration.test.ts +3 -3
  165. package/src/engine/__tests__/pipeline-sub-pipelines.test.ts +9 -9
  166. package/src/engine/__tests__/post-query-hook.test.ts +7 -7
  167. package/src/engine/__tests__/registrar-object-form.test.ts +141 -0
  168. package/src/engine/__tests__/registry.test.ts +144 -0
  169. package/src/engine/__tests__/schema-builder.test.ts +18 -0
  170. package/src/engine/__tests__/screen.test.ts +147 -1
  171. package/src/engine/__tests__/soft-delete-cleanup.test.ts +3 -0
  172. package/src/engine/__tests__/store-table.test.ts +229 -0
  173. package/src/engine/__tests__/tier-resolver-extension.test.ts +19 -1
  174. package/src/engine/__tests__/{visual-tree-patterns.test.ts → tree-actions-patterns.test.ts} +7 -95
  175. package/src/engine/__tests__/validate-projection-allowlist.test.ts +15 -15
  176. package/src/engine/boot-validator/__tests__/config-deps.test.ts +92 -0
  177. package/src/engine/boot-validator/action-wiring.ts +149 -0
  178. package/src/engine/boot-validator/boot-check.ts +21 -0
  179. package/src/engine/boot-validator/config-deps.ts +40 -4
  180. package/src/engine/boot-validator/entity-handler.ts +20 -21
  181. package/src/engine/boot-validator/entity-list-screens.ts +88 -0
  182. package/src/engine/boot-validator/gdpr-storage.ts +0 -20
  183. package/src/engine/boot-validator/i18n-keys.ts +58 -4
  184. package/src/engine/boot-validator/index.ts +33 -12
  185. package/src/engine/boot-validator/nav.ts +125 -0
  186. package/src/engine/boot-validator/pii-retention.ts +95 -10
  187. package/src/engine/boot-validator/{screens-nav.ts → screens.ts} +214 -191
  188. package/src/engine/boot-validator/workspaces.ts +68 -0
  189. package/src/engine/build-app-schema.ts +16 -0
  190. package/src/engine/build-config-feature-schema.ts +44 -7
  191. package/src/engine/codemod/pipeline-codemod.ts +5 -5
  192. package/src/engine/config-helpers.ts +15 -0
  193. package/src/engine/constants.ts +32 -6
  194. package/src/engine/create-app.ts +11 -0
  195. package/src/engine/define-feature.ts +95 -947
  196. package/src/engine/define-handler.ts +28 -94
  197. package/src/engine/define-workflow.ts +1 -1
  198. package/src/engine/effective-features.ts +12 -2
  199. package/src/engine/entity-handlers.ts +76 -11
  200. package/src/engine/extensions/tenant-data.ts +19 -0
  201. package/src/engine/extensions/user-data.ts +29 -2
  202. package/src/engine/factories.ts +8 -49
  203. package/src/engine/feature-ast/__tests__/canonical-form.test.ts +26 -29
  204. package/src/engine/feature-ast/__tests__/fixtures/cross-file-registrar/feature.ts +9 -0
  205. package/src/engine/feature-ast/__tests__/fixtures/cross-file-registrar/screens.ts +4 -0
  206. package/src/engine/feature-ast/__tests__/parse-happy-path.test.ts +1 -2
  207. package/src/engine/feature-ast/__tests__/parse-real-features.test.ts +18 -8
  208. package/src/engine/feature-ast/__tests__/parse.test.ts +1640 -160
  209. package/src/engine/feature-ast/__tests__/patch.test.ts +206 -12
  210. package/src/engine/feature-ast/__tests__/patcher.test.ts +20 -23
  211. package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +380 -5
  212. package/src/engine/feature-ast/extractors/events.ts +322 -0
  213. package/src/engine/feature-ast/extractors/handlers.ts +234 -0
  214. package/src/engine/feature-ast/extractors/hooks.ts +243 -0
  215. package/src/engine/feature-ast/extractors/index.ts +34 -31
  216. package/src/engine/feature-ast/extractors/jobs-routes.ts +221 -0
  217. package/src/engine/feature-ast/extractors/projections-screens.ts +269 -0
  218. package/src/engine/feature-ast/extractors/round1.ts +3 -3
  219. package/src/engine/feature-ast/extractors/round5.ts +3 -3
  220. package/src/engine/feature-ast/extractors/round6.ts +2 -36
  221. package/src/engine/feature-ast/extractors/shared.ts +64 -6
  222. package/src/engine/feature-ast/index.ts +2 -4
  223. package/src/engine/feature-ast/parse.ts +130 -23
  224. package/src/engine/feature-ast/patch.ts +39 -50
  225. package/src/engine/feature-ast/patcher.ts +37 -38
  226. package/src/engine/feature-ast/patterns.ts +50 -62
  227. package/src/engine/feature-ast/render.ts +67 -44
  228. package/src/engine/feature-builder-state.ts +168 -0
  229. package/src/engine/feature-config-events-jobs.ts +403 -0
  230. package/src/engine/feature-entity-handlers.ts +208 -0
  231. package/src/engine/feature-manifest.ts +2 -1
  232. package/src/engine/feature-ui-extensions.ts +500 -0
  233. package/src/engine/field-access.ts +13 -2
  234. package/src/engine/field-helpers.ts +31 -0
  235. package/src/engine/handler-helpers.ts +26 -0
  236. package/src/engine/hook-helpers.ts +16 -0
  237. package/src/engine/index.ts +23 -7
  238. package/src/engine/membership-roles.ts +13 -0
  239. package/src/engine/object-form.ts +27 -0
  240. package/src/engine/ownership.ts +26 -79
  241. package/src/engine/pattern-library/__tests__/library.test.ts +7 -20
  242. package/src/engine/pattern-library/library.ts +44 -1152
  243. package/src/engine/pattern-library/mixed-schemas.ts +450 -0
  244. package/src/engine/pattern-library/opaque-schemas.ts +124 -0
  245. package/src/engine/pattern-library/shared-fields.ts +75 -0
  246. package/src/engine/pattern-library/static-schemas.ts +456 -0
  247. package/src/engine/pipeline.ts +6 -11
  248. package/src/engine/registry-facade.ts +362 -0
  249. package/src/engine/registry-ingest.ts +478 -0
  250. package/src/engine/registry-state.ts +388 -0
  251. package/src/engine/registry-validate.ts +642 -0
  252. package/src/engine/registry.ts +78 -1658
  253. package/src/engine/run-pipeline.ts +1 -1
  254. package/src/engine/schema-builder.ts +1 -0
  255. package/src/engine/screen-helpers.ts +54 -0
  256. package/src/engine/soft-delete-cleanup.ts +6 -2
  257. package/src/engine/steps/__tests__/duration-utils.test.ts +20 -0
  258. package/src/engine/steps/_duration-utils.ts +2 -0
  259. package/src/engine/steps/unsafe-projection-upsert.ts +1 -4
  260. package/src/engine/tier-resolver-extension.ts +3 -2
  261. package/src/engine/types/config.ts +2 -482
  262. package/src/engine/types/define-handler.ts +2 -0
  263. package/src/engine/types/entity-handlers.ts +2 -0
  264. package/src/engine/types/event-type-map.ts +1 -37
  265. package/src/engine/types/feature.ts +2 -972
  266. package/src/engine/types/fields.ts +2 -675
  267. package/src/engine/types/handlers.ts +2 -774
  268. package/src/engine/types/hooks.ts +2 -184
  269. package/src/engine/types/http-route.ts +1 -54
  270. package/src/engine/types/identifiers.ts +1 -47
  271. package/src/engine/types/index.ts +86 -40
  272. package/src/engine/types/nav.ts +2 -63
  273. package/src/engine/types/ownership.ts +2 -0
  274. package/src/engine/types/projection.ts +2 -138
  275. package/src/engine/types/relations.ts +1 -51
  276. package/src/engine/types/screen.ts +2 -574
  277. package/src/engine/types/step.ts +2 -334
  278. package/src/engine/types/target-ref.ts +1 -21
  279. package/src/engine/types/tree-node.ts +1 -132
  280. package/src/engine/types/workspace.ts +2 -49
  281. package/src/engine/validate-projection-allowlist.ts +6 -6
  282. package/src/entrypoint/__tests__/entrypoint-job-wiring.integration.test.ts +120 -1
  283. package/src/entrypoint/index.ts +49 -6
  284. package/src/errors/classes.ts +22 -1
  285. package/src/errors/field-issue.ts +0 -3
  286. package/src/errors/index.ts +1 -1
  287. package/src/errors/write-error-info.ts +10 -22
  288. package/src/es-ops/README.md +1 -1
  289. package/src/es-ops/__tests__/runner.integration.test.ts +74 -0
  290. package/src/es-ops/context.ts +4 -2
  291. package/src/es-ops/types.ts +8 -1
  292. package/src/event-store/__tests__/admin-api.integration.test.ts +27 -1
  293. package/src/event-store/__tests__/backfill-pii.integration.test.ts +279 -0
  294. package/src/event-store/__tests__/event-store.integration.test.ts +201 -0
  295. package/src/event-store/__tests__/row-to-stored-event.test.ts +2 -2
  296. package/src/event-store/__tests__/snapshot.integration.test.ts +86 -0
  297. package/src/event-store/__tests__/unscoped-stream-primitives.guard.test.ts +58 -0
  298. package/src/event-store/__tests__/upcaster.integration.test.ts +77 -45
  299. package/src/event-store/admin-api.ts +11 -4
  300. package/src/event-store/errors.ts +2 -35
  301. package/src/event-store/event-store.ts +64 -78
  302. package/src/event-store/events-schema.ts +11 -13
  303. package/src/event-store/index.ts +18 -3
  304. package/src/event-store/rebuild-dead-letter.ts +111 -0
  305. package/src/event-store/snapshot.ts +63 -40
  306. package/src/event-store/types.ts +2 -0
  307. package/src/files/__tests__/build-storage-key.test.ts +28 -0
  308. package/src/files/__tests__/file-ref-entity.test.ts +8 -0
  309. package/src/files/__tests__/files.integration.test.ts +24 -0
  310. package/src/files/__tests__/in-memory-provider.contract.test.ts +4 -0
  311. package/src/files/__tests__/local-provider.test.ts +31 -0
  312. package/src/files/__tests__/provider-resolver.test.ts +70 -0
  313. package/src/files/__tests__/write-stream.test.ts +13 -0
  314. package/src/files/file-handle.ts +2 -19
  315. package/src/files/file-ref-entity.ts +2 -2
  316. package/src/files/file-routes.ts +13 -0
  317. package/src/files/index.ts +1 -1
  318. package/src/files/local-provider.ts +27 -8
  319. package/src/files/provider-resolver.ts +31 -20
  320. package/src/files/types.ts +13 -55
  321. package/src/i18n/__tests__/required-surface-keys.test.ts +17 -0
  322. package/src/i18n/required-surface-keys.ts +120 -1
  323. package/src/jobs/__tests__/job-queue-depth.integration.test.ts +82 -0
  324. package/src/jobs/__tests__/jobs.integration.test.ts +329 -3
  325. package/src/jobs/job-runner.ts +82 -12
  326. package/src/logging/types.ts +1 -7
  327. package/src/migrations/pending-rebuilds.ts +1 -1
  328. package/src/observability/__tests__/observability.integration.test.ts +4 -1
  329. package/src/observability/__tests__/recording-tracer.test.ts +6 -4
  330. package/src/observability/index.ts +2 -0
  331. package/src/observability/noop-provider.ts +0 -9
  332. package/src/observability/recording-tracer.ts +0 -12
  333. package/src/observability/standard-metrics.ts +64 -2
  334. package/src/observability/types/index.ts +1 -29
  335. package/src/observability/types/metric.ts +1 -56
  336. package/src/observability/types/provider.ts +1 -32
  337. package/src/observability/types/span.ts +1 -64
  338. package/src/pipeline/__tests__/ctx-bridge.integration.test.ts +2 -2
  339. package/src/pipeline/__tests__/dispatcher.test.ts +366 -1
  340. package/src/pipeline/__tests__/event-consumer-state.integration.test.ts +31 -0
  341. package/src/pipeline/__tests__/event-dispatcher-delivery-max-attempts.test.ts +126 -0
  342. package/src/pipeline/__tests__/event-dispatcher-rearm.integration.test.ts +263 -0
  343. package/src/pipeline/__tests__/event-dispatcher.integration.test.ts +10 -0
  344. package/src/pipeline/__tests__/job-trigger-consumer.integration.test.ts +106 -0
  345. package/src/pipeline/__tests__/lifecycle-pipeline.test.ts +308 -77
  346. package/src/pipeline/__tests__/load-aggregate-query.integration.test.ts +16 -8
  347. package/src/pipeline/__tests__/post-query-hook.integration.test.ts +3 -3
  348. package/src/pipeline/__tests__/projection-rebuild.integration.test.ts +80 -2
  349. package/src/pipeline/__tests__/rebuild-poison-quarantine.integration.test.ts +274 -0
  350. package/src/pipeline/__tests__/try-append-event.integration.test.ts +166 -0
  351. package/src/pipeline/dispatch-batch.ts +187 -0
  352. package/src/pipeline/dispatch-query.ts +165 -0
  353. package/src/pipeline/dispatch-shared.ts +826 -0
  354. package/src/pipeline/dispatch-stream.ts +90 -0
  355. package/src/pipeline/dispatch-write.ts +451 -0
  356. package/src/pipeline/dispatcher-utils.ts +1 -1
  357. package/src/pipeline/dispatcher.ts +44 -1372
  358. package/src/pipeline/entity-cache.ts +2 -33
  359. package/src/pipeline/event-consumer-state.ts +30 -2
  360. package/src/pipeline/event-dispatcher-admin.ts +293 -0
  361. package/src/pipeline/event-dispatcher-delivery.ts +305 -0
  362. package/src/pipeline/event-dispatcher.ts +85 -542
  363. package/src/pipeline/index.ts +2 -0
  364. package/src/pipeline/msp-rebuild.ts +42 -3
  365. package/src/pipeline/multi-stream-apply-context.ts +4 -42
  366. package/src/pipeline/projection-rebuild.ts +105 -3
  367. package/src/pipeline/system-hooks.ts +144 -1
  368. package/src/rate-limit/__tests__/resolver.integration.test.ts +18 -0
  369. package/src/rate-limit/resolver.ts +16 -32
  370. package/src/schema-cli.ts +76 -16
  371. package/src/search/__tests__/meilisearch-adapter.integration.test.ts +207 -185
  372. package/src/search/__tests__/meilisearch-ids.test.ts +30 -0
  373. package/src/search/__tests__/reindex-entity.integration.test.ts +144 -0
  374. package/src/search/index.ts +6 -0
  375. package/src/search/meilisearch-adapter.ts +13 -12
  376. package/src/search/reindex-entity.ts +177 -0
  377. package/src/search/types.ts +1 -39
  378. package/src/secrets/__tests__/contains-secret.test.ts +34 -0
  379. package/src/secrets/__tests__/envelope-cipher.test.ts +59 -0
  380. package/src/secrets/__tests__/envelope.test.ts +1 -1
  381. package/src/secrets/dek-cache.ts +26 -5
  382. package/src/secrets/envelope-cipher.ts +53 -0
  383. package/src/secrets/envelope.ts +5 -3
  384. package/src/secrets/index.ts +13 -1
  385. package/src/secrets/stored-envelope.ts +46 -0
  386. package/src/secrets/types.ts +2 -162
  387. package/src/stack/__tests__/event-collector.test.ts +42 -0
  388. package/src/stack/__tests__/setup-test-stack-jobs.integration.test.ts +125 -0
  389. package/src/stack/db.ts +2 -1
  390. package/src/stack/push-entity-projection-tables.ts +4 -3
  391. package/src/stack/redis.ts +8 -0
  392. package/src/stack/request-helper.ts +38 -2
  393. package/src/stack/table-helpers.ts +6 -4
  394. package/src/stack/test-stack.ts +249 -131
  395. package/src/testing/__tests__/late-bound.test.ts +32 -0
  396. package/src/testing/__tests__/wait-for.test.ts +59 -0
  397. package/src/testing/boot-validator-fixture.ts +121 -0
  398. package/src/testing/e2e-generator.ts +8 -0
  399. package/src/testing/file-provider-contract.ts +104 -0
  400. package/src/testing/handler-context.ts +3 -1
  401. package/src/testing/index.ts +5 -0
  402. package/src/testing/late-bound.ts +5 -3
  403. package/src/testing/mutable-master-key-provider.ts +29 -3
  404. package/src/testing/wait-for.ts +3 -0
  405. package/src/testing/without-ambient-temporal.ts +14 -0
  406. package/src/time/__tests__/tz-dateline.test.ts +8 -10
  407. package/src/time/geo-tz.ts +1 -32
  408. package/src/time/index.ts +1 -0
  409. package/src/time/legacy-date.ts +18 -0
  410. package/src/time/polyfill.ts +21 -38
  411. package/src/time/tz-context.ts +44 -85
  412. package/src/ui-types/app-schema.ts +12 -0
  413. package/src/ui-types/index.ts +20 -7
  414. package/src/utils/__tests__/safe-json-temporal.test.ts +18 -0
  415. package/src/utils/safe-json.ts +13 -1
  416. package/src/__tests__/raw-table.integration.test.ts +0 -116
  417. package/src/bun-db/__tests__/bun-test-stack.ts +0 -6
  418. package/src/db/__tests__/encryption.test.ts +0 -39
  419. package/src/db/encryption.ts +0 -39
  420. package/src/db/row-helpers.ts +0 -4
  421. package/src/engine/__tests__/raw-table.test.ts +0 -150
  422. package/src/engine/__tests__/unmanaged-table.test.ts +0 -127
  423. package/src/engine/feature-ast/__tests__/visual-tree-parse.test.ts +0 -184
  424. package/src/engine/feature-ast/extractors/round4.ts +0 -1366
@@ -1,6 +1,7 @@
1
1
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
2
2
  import { seedRow } from "@cosmicdrift/kumiko-framework/testing";
3
3
  import { z } from "zod";
4
+ import type { TableColumns } from "../../db/dialect";
4
5
  import { createEventStoreExecutor } from "../../db/event-store-executor";
5
6
  import { asRawClient, selectMany } from "../../db/query";
6
7
  import { buildEntityTable } from "../../db/table-builder";
@@ -14,6 +15,7 @@ import {
14
15
  type SaveContext,
15
16
  } from "../../engine";
16
17
  import { UnprocessableError, writeFailure } from "../../errors";
18
+ import { RedisKeys } from "../../pipeline/redis-keys";
17
19
  import { setupTestStack, type TestStack, TestUsers, unsafeCreateEntityTable } from "../../stack";
18
20
 
19
21
  // Entity: a simple "item" with name + counter
@@ -36,7 +38,9 @@ const auditEntity = createEntity({
36
38
  itemId: createTextField({ required: true }),
37
39
  },
38
40
  });
39
- const auditTable = buildEntityTable("audit", auditEntity);
41
+ // Brand (#742) is compile-time-only; the postSave hook writes this sink via method-form,
42
+ // so hold it at the unbranded TableColumns view (identical runtime shape).
43
+ const auditTable: TableColumns = buildEntityTable("audit", auditEntity);
40
44
 
41
45
  // Hook invocation logs — reset per test. Captures which phase each hook saw.
42
46
  const inTxHookLog: Array<{ id: EntityId; name: string }> = [];
@@ -78,9 +82,9 @@ const itemFeature = defineFeature("batch", (r) => {
78
82
  );
79
83
 
80
84
  // Entity hook: inTransaction — records in memory
81
- r.entityHook(
85
+ r.hook(
82
86
  "postSave",
83
- item,
87
+ { allOf: item },
84
88
  async (result: SaveContext) => {
85
89
  inTxHookLog.push({ id: result.id, name: (result.data["name"] as string) ?? "" });
86
90
  },
@@ -89,43 +93,43 @@ const itemFeature = defineFeature("batch", (r) => {
89
93
 
90
94
  // Entity hook: inTransaction — writes to DB via ctx.db (the tx-scoped TenantDb).
91
95
  // Proves that hook DB writes roll back with the main transaction on failure.
92
- r.entityHook(
96
+ r.hook(
93
97
  "postSave",
94
- item,
98
+ { allOf: item },
95
99
  async (result, ctx) => {
96
- if (!ctx.db) return;
100
+ if (!ctx.db || !("insertOne" in ctx.db)) return;
97
101
  await ctx.db.insertOne(auditTable, { action: "item_saved", itemId: result.id });
98
102
  },
99
103
  { phase: HookPhases.inTransaction },
100
104
  );
101
105
 
102
106
  // Entity hook: afterCommit — records in memory (default phase)
103
- r.entityHook("postSave", item, async (result: SaveContext) => {
107
+ r.hook("postSave", { allOf: item }, async (result: SaveContext) => {
104
108
  afterCommitHookLog.push({ id: result.id, name: (result.data["name"] as string) ?? "" });
105
109
  });
106
110
 
107
111
  // Entity hook: afterCommit — may throw, used to verify error isolation
108
- r.entityHook("postSave", item, async () => {
112
+ r.hook("postSave", { allOf: item }, async () => {
109
113
  if (afterCommitShouldThrow) throw new Error("afterCommit_boom");
110
114
  });
111
115
 
112
116
  // Entity hook: afterCommit — runs AFTER the throwing one. Used to prove the
113
117
  // next hooks still fire despite the earlier failure.
114
- r.entityHook("postSave", item, async (result: SaveContext) => {
118
+ r.hook("postSave", { allOf: item }, async (result: SaveContext) => {
115
119
  afterCommitThirdHookRan.push((result.data["name"] as string) ?? "");
116
120
  });
117
121
 
118
122
  // Two hooks used by the parallelism test. Each records its start+end
119
123
  // timestamps so the assertion can compare intervals rather than elapsed
120
124
  // wall-clock time (which is timing-flaky on loaded CI boxes).
121
- r.entityHook("postSave", item, async (result: SaveContext) => {
125
+ r.hook("postSave", { allOf: item }, async (result: SaveContext) => {
122
126
  const name = result.data["name"] as string;
123
127
  if (!name?.startsWith("slowness-")) return;
124
128
  parallelismWindows.push({ hook: "A", start: Date.now() });
125
129
  await new Promise((r) => setTimeout(r, 80));
126
130
  parallelismWindows.push({ hook: "A", end: Date.now() });
127
131
  });
128
- r.entityHook("postSave", item, async (result: SaveContext) => {
132
+ r.hook("postSave", { allOf: item }, async (result: SaveContext) => {
129
133
  const name = result.data["name"] as string;
130
134
  if (!name?.startsWith("slowness-")) return;
131
135
  parallelismWindows.push({ hook: "B", start: Date.now() });
@@ -367,6 +371,92 @@ describe("POST /api/batch", () => {
367
371
  expect(inTxHookLog).toHaveLength(1);
368
372
  expect(afterCommitHookLog).toHaveLength(1);
369
373
  });
374
+
375
+ test("idempotency: failed batch is cached — retry returns same error without re-exec", async () => {
376
+ const requestId = "batch-rid-fail-cache";
377
+ const commands = [
378
+ { type: "batch:write:item:create", payload: { name: "should-rollback" } },
379
+ { type: "batch:write:item:fail", payload: { name: "stop" } },
380
+ ];
381
+
382
+ const first = await stack.http.batch(commands, admin, requestId);
383
+ const firstBody = await first.json();
384
+ expect(first.status).toBe(422);
385
+ expect(firstBody.isSuccess).toBe(false);
386
+ expect(firstBody.failedIndex).toBe(1);
387
+ expect(inTxHookLog.map((h) => h.name)).toEqual(["should-rollback"]);
388
+ expect(await selectMany(stack.db, itemTable)).toHaveLength(0);
389
+
390
+ inTxHookLog.length = 0;
391
+ afterCommitHookLog.length = 0;
392
+
393
+ const second = await stack.http.batch(commands, admin, requestId);
394
+ const secondBody = await second.json();
395
+ expect(second.status).toBe(422);
396
+ expect(secondBody.isSuccess).toBe(false);
397
+ expect(secondBody.failedIndex).toBe(firstBody.failedIndex);
398
+ // HTTP re-serializes the cached failure with a fresh requestId/timestamp —
399
+ // pin the stable business fields, not the transport envelope.
400
+ expect(secondBody.error.code).toBe(firstBody.error.code);
401
+ expect(secondBody.error.details).toEqual(firstBody.error.details);
402
+ // Cached path must not re-run commands (hooks stay empty, DB empty)
403
+ expect(inTxHookLog).toHaveLength(0);
404
+ expect(afterCommitHookLog).toHaveLength(0);
405
+ expect(await selectMany(stack.db, itemTable)).toHaveLength(0);
406
+ });
407
+
408
+ test("idempotency: corrupted cache entry is treated as miss and re-runs", async () => {
409
+ const requestId = "batch-rid-corrupt";
410
+ const cacheKey = `${RedisKeys.idempotency}${requestId}`;
411
+
412
+ // Prove key-coupling first: seed a well-formed cached entry under the
413
+ // exact manually-built key and confirm the batch short-circuits on it
414
+ // (no handler run, no DB write) — if the manual key ever drifts from
415
+ // what the guard actually reads, this half fails loud instead of the
416
+ // corrupt-JSON half below silently becoming a no-op cache-miss test.
417
+ const seeded = { isSuccess: true, results: [{ seeded: true }] };
418
+ await stack.redis.redis.set(cacheKey, JSON.stringify(seeded), "EX", 60);
419
+ const cachedRes = await stack.http.batch(
420
+ [{ type: "batch:write:item:create", payload: { name: "should-not-run" } }],
421
+ admin,
422
+ requestId,
423
+ );
424
+ expect(await cachedRes.json()).toEqual(seeded);
425
+ expect(await selectMany(stack.db, itemTable)).toHaveLength(0);
426
+ expect(inTxHookLog).toHaveLength(0);
427
+
428
+ // Now corrupt the same key and confirm it's treated as a miss.
429
+ await stack.redis.redis.set(cacheKey, "{not-json", "EX", 60);
430
+
431
+ const res = await stack.http.batch(
432
+ [{ type: "batch:write:item:create", payload: { name: "after-corrupt" } }],
433
+ admin,
434
+ requestId,
435
+ );
436
+ const body = await res.json();
437
+ expect(res.status).toBe(200);
438
+ expect(body.isSuccess).toBe(true);
439
+ expect(await selectMany(stack.db, itemTable)).toHaveLength(1);
440
+ expect(inTxHookLog.map((h) => h.name)).toEqual(["after-corrupt"]);
441
+ });
442
+
443
+ test("mid-batch handler throw: rolls back prior writes, afterCommit does not fire", async () => {
444
+ const res = await stack.http.batch(
445
+ [
446
+ { type: "batch:write:item:create", payload: { name: "before-throw" } },
447
+ { type: "batch:write:item:throw", payload: { name: "boom" } },
448
+ { type: "batch:write:item:create", payload: { name: "never" } },
449
+ ],
450
+ admin,
451
+ );
452
+ const body = await res.json();
453
+ expect(body.isSuccess).toBe(false);
454
+ expect(body.failedIndex).toBe(1);
455
+ expect(inTxHookLog.map((h) => h.name)).toEqual(["before-throw"]);
456
+ expect(afterCommitHookLog).toEqual([]);
457
+ expect(await selectMany(stack.db, itemTable)).toHaveLength(0);
458
+ expect(await selectMany(stack.db, auditTable)).toHaveLength(0);
459
+ });
370
460
  });
371
461
 
372
462
  describe("POST /api/write (single write runs in its own transaction)", () => {
@@ -0,0 +1,20 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ CSRF_COOKIE_NAME as DISPATCHER_CSRF_COOKIE_NAME,
4
+ CSRF_HEADER_NAME as DISPATCHER_CSRF_HEADER_NAME,
5
+ } from "@cosmicdrift/kumiko-dispatcher-live";
6
+ import { CSRF_COOKIE_NAME, CSRF_HEADER_NAME } from "../auth-middleware";
7
+
8
+ // dispatcher-live keeps its own literal copies of these two constants
9
+ // (see packages/dispatcher-live/src/csrf.ts) because it must stay
10
+ // server-dep-free for browser/React Native bundles. This test is the
11
+ // guardrail that catches drift in CI instead of at runtime.
12
+ describe("CSRF constant sync between framework and dispatcher-live", () => {
13
+ test("cookie name matches", () => {
14
+ expect(DISPATCHER_CSRF_COOKIE_NAME).toBe(CSRF_COOKIE_NAME);
15
+ });
16
+
17
+ test("header name matches", () => {
18
+ expect(DISPATCHER_CSRF_HEADER_NAME).toBe(CSRF_HEADER_NAME);
19
+ });
20
+ });
@@ -48,6 +48,17 @@ const itemFeature = defineFeature("dlive", (r) => {
48
48
  },
49
49
  { access: { roles: ["Admin"] } },
50
50
  );
51
+
52
+ r.streamHandler(
53
+ "item:tail",
54
+ z.object({ count: z.number().int().min(0) }),
55
+ async function* (query) {
56
+ for (let i = 0; i < query.payload.count; i++) {
57
+ yield { i, name: `n-${i}` };
58
+ }
59
+ },
60
+ { access: { roles: ["Admin"] } },
61
+ );
51
62
  });
52
63
 
53
64
  let stack: TestStack;
@@ -215,4 +226,67 @@ describe("dispatcher-live (integration) — full path against Kumiko server", ()
215
226
  const rows = await selectMany(stack.db, itemTable);
216
227
  expect(rows).toHaveLength(0);
217
228
  });
229
+
230
+ test("stream: yields every chunk then completes (live dispatcher ↔ POST /api/stream)", async () => {
231
+ const { fetch, csrfToken } = await buildFetch();
232
+ const dispatcher = createLiveDispatcher({ fetch, readCsrf: () => csrfToken });
233
+
234
+ const chunks: Array<{ i: number; name: string }> = [];
235
+ for await (const chunk of dispatcher.stream<{ i: number; name: string }>(
236
+ "dlive:stream:item:tail",
237
+ { count: 3 },
238
+ )) {
239
+ chunks.push(chunk);
240
+ }
241
+
242
+ expect(chunks).toEqual([
243
+ { i: 0, name: "n-0" },
244
+ { i: 1, name: "n-1" },
245
+ { i: 2, name: "n-2" },
246
+ ]);
247
+ });
248
+
249
+ test("stream: access-denied mid-stream throws mapped DispatcherError (HTTP still 200 SSE)", async () => {
250
+ // Non-Admin JWT — stream handler requires Admin. Gate fires after SSE
251
+ // headers are flushed, so the client sees an error frame, not HTTP 403.
252
+ const csrfToken = generateToken();
253
+ const guestJwt = await stack.jwt.sign(TestUsers.user);
254
+ const cookieHeader = `kumiko_auth=${guestJwt}; kumiko_csrf=${csrfToken}`;
255
+ const fetchImpl = (async (url: unknown, init: RequestInit | undefined) => {
256
+ return stack.app.request(String(url), {
257
+ ...(init ?? {}),
258
+ headers: { ...(init?.headers ?? {}), Cookie: cookieHeader },
259
+ });
260
+ }) as unknown as typeof fetch;
261
+ const dispatcher = createLiveDispatcher({ fetch: fetchImpl, readCsrf: () => csrfToken });
262
+
263
+ let thrown: unknown;
264
+ try {
265
+ for await (const _ of dispatcher.stream("dlive:stream:item:tail", { count: 1 })) {
266
+ // should not yield
267
+ }
268
+ } catch (e) {
269
+ thrown = e;
270
+ }
271
+ expect(thrown).toMatchObject({ code: "access_denied" });
272
+ });
273
+
274
+ test("stream: validation failure throws mapped DispatcherError from error frame", async () => {
275
+ const { fetch, csrfToken } = await buildFetch();
276
+ const dispatcher = createLiveDispatcher({ fetch, readCsrf: () => csrfToken });
277
+
278
+ let thrown: unknown;
279
+ try {
280
+ for await (const _ of dispatcher.stream("dlive:stream:item:tail", { count: -1 })) {
281
+ // should not yield
282
+ }
283
+ } catch (e) {
284
+ thrown = e;
285
+ }
286
+ expect(thrown).toMatchObject({ code: "validation_error" });
287
+ });
288
+
289
+ // AbortSignal mid-stream against Hono's in-memory `app.request` is
290
+ // unreliable (body often fully buffered before abort lands). Abort
291
+ // mapping for stream is covered in dispatcher-live unit tests.
218
292
  });
@@ -1,7 +1,7 @@
1
1
  import { describe, expect, it } from "bun:test";
2
2
  import * as jose from "jose";
3
3
  import type { SessionUser } from "../../engine/types";
4
- import { createJwtHelper } from "../jwt";
4
+ import { createJwtHelper, loadJwtSecretOrKeyring } from "../jwt";
5
5
 
6
6
  const SECRET = "test-secret-at-least-32-characters-long-aa";
7
7
  const TENANT = "11111111-1111-4111-8111-111111111111";
@@ -83,3 +83,202 @@ describe("createJwtHelper.verify — payload validation (KF-2)", () => {
83
83
  await expect(jwt.verify(token)).rejects.toThrow(/sub/);
84
84
  });
85
85
  });
86
+
87
+ describe("createJwtHelper — keyring form", () => {
88
+ it("sets kid in the protected header when signing from a keyring", async () => {
89
+ const jwt = createJwtHelper({ keys: { v1: SECRET }, signKid: "v1" });
90
+ const token = await jwt.sign(user);
91
+ expect(jose.decodeProtectedHeader(token).kid).toBe("v1");
92
+ });
93
+
94
+ it("verifies a keyring-signed token against a string-form helper on the same secret", async () => {
95
+ const keyringSigner = createJwtHelper({ keys: { v1: SECRET }, signKid: "v1" });
96
+ const token = await keyringSigner.sign(user);
97
+
98
+ const stringVerifier = createJwtHelper(SECRET);
99
+ const payload = await stringVerifier.verify(token);
100
+ expect(payload.sub).toBe(user.id);
101
+ });
102
+
103
+ it("omits kid when signing from a plain secret (string form)", async () => {
104
+ const jwt = createJwtHelper(SECRET);
105
+ const token = await jwt.sign(user);
106
+ expect(jose.decodeProtectedHeader(token).kid).toBeUndefined();
107
+ });
108
+
109
+ it("verifies a token signed under an old kid after rotating signKid", async () => {
110
+ const OLD_SECRET = "old-secret-at-least-32-characters-long-aa";
111
+ const before = createJwtHelper({ keys: { v1: OLD_SECRET }, signKid: "v1" });
112
+ const token = await before.sign(user);
113
+
114
+ const after = createJwtHelper({ keys: { v1: OLD_SECRET, v2: SECRET }, signKid: "v2" });
115
+ const payload = await after.verify(token);
116
+ expect(payload.sub).toBe(user.id);
117
+ });
118
+
119
+ it("verifies a legacy no-kid token against a multi-key keyring", async () => {
120
+ const legacy = createJwtHelper(SECRET);
121
+ const token = await legacy.sign(user);
122
+
123
+ const rotated = createJwtHelper({
124
+ keys: { v1: "unrelated-secret-32-characters-longg", v2: SECRET },
125
+ signKid: "v2",
126
+ });
127
+ const payload = await rotated.verify(token);
128
+ expect(payload.sub).toBe(user.id);
129
+ });
130
+
131
+ it("rejects a token whose kid is not in the keyring", async () => {
132
+ const signer = createJwtHelper({ keys: { v1: SECRET }, signKid: "v1" });
133
+ const token = await signer.sign(user);
134
+
135
+ const verifier = createJwtHelper({ keys: { v2: SECRET }, signKid: "v2" });
136
+ await expect(verifier.verify(token)).rejects.toThrow(/kid/);
137
+ });
138
+
139
+ it("throws at creation when signKid is not present in the keyring", () => {
140
+ expect(() => createJwtHelper({ keys: { v1: SECRET }, signKid: "v2" })).toThrow(/signKid/);
141
+ });
142
+ });
143
+
144
+ describe("loadJwtSecretOrKeyring", () => {
145
+ it("no JWT_SECRET_V<n> → falls back to plain JWT_SECRET", () => {
146
+ expect(loadJwtSecretOrKeyring({ JWT_SECRET: SECRET })).toBe(SECRET);
147
+ });
148
+
149
+ it("neither JWT_SECRET_V<n> nor JWT_SECRET set → throws", () => {
150
+ expect(() => loadJwtSecretOrKeyring({})).toThrow(/JWT_SECRET not set/);
151
+ });
152
+
153
+ it("JWT_SECRET_V1 + JWT_SECRET_CURRENT_VERSION=1 → single-key keyring, signKid v1", () => {
154
+ expect(
155
+ loadJwtSecretOrKeyring({
156
+ JWT_SECRET_V1: SECRET,
157
+ JWT_SECRET_CURRENT_VERSION: "1",
158
+ }),
159
+ ).toEqual({ keys: { v1: SECRET }, signKid: "v1" });
160
+ });
161
+
162
+ it("rotation: two versions, current-version picks the sign key, both stay verifiable", () => {
163
+ const OLD_SECRET = "old-secret-at-least-32-characters-long!!";
164
+ expect(
165
+ loadJwtSecretOrKeyring({
166
+ JWT_SECRET_V1: OLD_SECRET,
167
+ JWT_SECRET_V2: SECRET,
168
+ JWT_SECRET_CURRENT_VERSION: "2",
169
+ }),
170
+ ).toEqual({ keys: { v1: OLD_SECRET, v2: SECRET }, signKid: "v2" });
171
+ });
172
+
173
+ it("JWT_SECRET_V<n> present but JWT_SECRET_CURRENT_VERSION missing → throws", () => {
174
+ expect(() => loadJwtSecretOrKeyring({ JWT_SECRET_V1: SECRET })).toThrow(
175
+ /JWT_SECRET_CURRENT_VERSION not set/,
176
+ );
177
+ });
178
+
179
+ it("JWT_SECRET_CURRENT_VERSION points at a version with no matching JWT_SECRET_V<n> → throws", () => {
180
+ expect(() =>
181
+ loadJwtSecretOrKeyring({
182
+ JWT_SECRET_V1: SECRET,
183
+ JWT_SECRET_CURRENT_VERSION: "2",
184
+ }),
185
+ ).toThrow(/not present in the keyring/);
186
+ });
187
+
188
+ it("kumiko-framework#1308: plain JWT_SECRET carries over as a verify-only legacy key once a keyring is adopted", () => {
189
+ // Adopting rotation for the first time doesn't unset JWT_SECRET (it still
190
+ // signs the separate password-reset/email-verification HMAC family) —
191
+ // and sessions signed before adoption have no `kid`, so they must keep
192
+ // verifying against this exact value, not get orphaned the moment
193
+ // JWT_SECRET_V1 shows up.
194
+ const PLAIN_SECRET = "pre-rotation-plain-secret-32-chars-long!!";
195
+ expect(
196
+ loadJwtSecretOrKeyring({
197
+ JWT_SECRET: PLAIN_SECRET,
198
+ JWT_SECRET_V1: SECRET,
199
+ JWT_SECRET_CURRENT_VERSION: "1",
200
+ }),
201
+ ).toEqual({ keys: { v1: SECRET, legacy: PLAIN_SECRET }, signKid: "v1" });
202
+ });
203
+
204
+ it("kumiko-framework#1308: legacy JWT_SECRET never becomes the sign key, only ever verify-only", () => {
205
+ const PLAIN_SECRET = "pre-rotation-plain-secret-32-chars-long!!";
206
+ const keyring = loadJwtSecretOrKeyring({
207
+ JWT_SECRET: PLAIN_SECRET,
208
+ JWT_SECRET_V1: SECRET,
209
+ JWT_SECRET_CURRENT_VERSION: "1",
210
+ });
211
+ expect(typeof keyring).not.toBe("string");
212
+ expect((keyring as { signKid: string }).signKid).toBe("v1");
213
+ });
214
+
215
+ it("kumiko-framework#1308: no plain JWT_SECRET set alongside a keyring → no legacy key added", () => {
216
+ expect(
217
+ loadJwtSecretOrKeyring({
218
+ JWT_SECRET_V1: SECRET,
219
+ JWT_SECRET_CURRENT_VERSION: "1",
220
+ }),
221
+ ).toEqual({ keys: { v1: SECRET }, signKid: "v1" });
222
+ });
223
+
224
+ it("plain JWT_SECRET under 32 chars → throws", () => {
225
+ expect(() => loadJwtSecretOrKeyring({ JWT_SECRET: "too-short" })).toThrow(
226
+ /JWT_SECRET must be ≥32 chars/,
227
+ );
228
+ });
229
+
230
+ it("JWT_SECRET_V<n> under 32 chars → throws (bypasses the env-schema check otherwise)", () => {
231
+ expect(() =>
232
+ loadJwtSecretOrKeyring({
233
+ JWT_SECRET_V1: "too-short",
234
+ JWT_SECRET_CURRENT_VERSION: "1",
235
+ }),
236
+ ).toThrow(/JWT_SECRET_V1 must be ≥32 chars/);
237
+ });
238
+
239
+ it("kumiko-framework#1526: short legacy JWT_SECRET is rejected, not silently carried into the keyring", () => {
240
+ // Auth-bypass: a short JWT_SECRET carried into keys["legacy"] without a
241
+ // length check would be offline-bruteforceable — an attacker forges
242
+ // {alg:"HS256", kid:"legacy"} with arbitrary sub/tenantId/roles and
243
+ // verifyWithKeyring looks the key up by kid straight from the header.
244
+ expect(() =>
245
+ loadJwtSecretOrKeyring({
246
+ JWT_SECRET: "short",
247
+ JWT_SECRET_V1: SECRET,
248
+ JWT_SECRET_CURRENT_VERSION: "1",
249
+ }),
250
+ ).toThrow(/JWT_SECRET must be ≥32 chars/);
251
+ });
252
+
253
+ it("kumiko-framework#1308: empty JWT_SECRET_V<n> throws instead of silently falling back to single-secret mode", () => {
254
+ expect(() =>
255
+ loadJwtSecretOrKeyring({
256
+ JWT_SECRET: SECRET,
257
+ JWT_SECRET_V1: "",
258
+ JWT_SECRET_CURRENT_VERSION: "1",
259
+ }),
260
+ ).toThrow(/JWT_SECRET_V1 is set but empty/);
261
+ });
262
+ });
263
+
264
+ describe("createJwtHelper — ttlSeconds", () => {
265
+ it("defaults to 24h", () => {
266
+ const jwt = createJwtHelper(SECRET);
267
+ expect(jwt.ttlSeconds).toBe(24 * 60 * 60);
268
+ });
269
+
270
+ it("exposes a custom ttlSeconds", () => {
271
+ const jwt = createJwtHelper(SECRET, ISSUER, 60 * 60);
272
+ expect(jwt.ttlSeconds).toBe(60 * 60);
273
+ });
274
+
275
+ it("signs a token whose exp - iat exactly equals ttlSeconds", async () => {
276
+ const ttlSeconds = 60 * 60;
277
+ const jwt = createJwtHelper(SECRET, ISSUER, ttlSeconds);
278
+ const token = await jwt.sign(user);
279
+ const claims = jose.decodeJwt(token);
280
+ expect(claims.exp).toBeDefined();
281
+ expect(claims.iat).toBeDefined();
282
+ expect(claims.exp! - claims.iat!).toBe(ttlSeconds);
283
+ });
284
+ });
@@ -0,0 +1,50 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { createInMemoryLoginRateLimiter } from "../auth-routes";
3
+
4
+ describe("createInMemoryLoginRateLimiter — sweep + cap", () => {
5
+ test("sweepExpired removes expired entries before enforceCap can evict a fresh key", async () => {
6
+ // maxAttempts=1 makes every entry a single-shot probe: a second `check`
7
+ // on the same key only returns true if the entry was actually removed
8
+ // from the map (either by expiry+sweep or by enforceCap).
9
+ const limiter = createInMemoryLoginRateLimiter(1, 50, {
10
+ maxEntries: 2,
11
+ sweepThreshold: 2,
12
+ });
13
+
14
+ expect(await limiter.check("old1")).toBe(true);
15
+ expect(await limiter.check("old2")).toBe(true);
16
+ // Let both windows expire.
17
+ await Bun.sleep(60);
18
+
19
+ // hits.size (2) >= sweepThreshold (2) → sweepExpired runs first and
20
+ // clears old1/old2 before "new1" is inserted, so enforceCap never fires.
21
+ expect(await limiter.check("new1")).toBe(true);
22
+ expect(await limiter.check("new2")).toBe(true);
23
+
24
+ // If enforceCap had run instead of (or before) the sweep, it would have
25
+ // evicted "new1" as the oldest live entry, and this check would return
26
+ // true (fresh window) rather than false (still rate-limited).
27
+ expect(await limiter.check("new1")).toBe(false);
28
+ });
29
+
30
+ test("enforceCap drops the oldest entry when the map exceeds maxEntries", async () => {
31
+ const limiter = createInMemoryLoginRateLimiter(1, 60_000, {
32
+ maxEntries: 3,
33
+ sweepThreshold: 10_000, // never sweep — only the hard cap matters
34
+ });
35
+
36
+ expect(await limiter.check("k1")).toBe(true);
37
+ expect(await limiter.check("k2")).toBe(true);
38
+ expect(await limiter.check("k3")).toBe(true);
39
+ // 4th insert trips enforceCap → drops the oldest entry (k1).
40
+ expect(await limiter.check("k4")).toBe(true);
41
+
42
+ // k1 was dropped — a fresh check starts a new window (allowed).
43
+ expect(await limiter.check("k1")).toBe(true);
44
+
45
+ // k4 must still be alive in the map: a check within its still-open
46
+ // window is rate-limited (count >= maxAttempts), proving enforceCap
47
+ // dropped k1 and not k4.
48
+ expect(await limiter.check("k4")).toBe(false);
49
+ });
50
+ });
@@ -0,0 +1,36 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { patAllows, qnMatches } from "../pat-scope";
3
+
4
+ describe("qnMatches", () => {
5
+ it("exact match", () => {
6
+ expect(qnMatches("credit:query:schedule", "credit:query:schedule")).toBe(true);
7
+ expect(qnMatches("credit:query:schedule", "credit:query:credit:list")).toBe(false);
8
+ });
9
+
10
+ it("wildcard suffix matches on prefix", () => {
11
+ expect(qnMatches("credit:write:bauspar:*", "credit:write:bauspar:create")).toBe(true);
12
+ expect(qnMatches("credit:write:bauspar:*", "credit:write:bauspar:delete")).toBe(true);
13
+ expect(qnMatches("credit:write:bauspar:*", "credit:write:create")).toBe(false);
14
+ });
15
+
16
+ it("bare wildcard matches everything", () => {
17
+ expect(qnMatches("*", "anything:at:all")).toBe(true);
18
+ });
19
+ });
20
+
21
+ describe("patAllows", () => {
22
+ const allowed = ["credit:write:create", "credit:write:bauspar:*"];
23
+
24
+ it("permits an exact or wildcard-covered type", () => {
25
+ expect(patAllows(allowed, "credit:write:create")).toBe(true);
26
+ expect(patAllows(allowed, "credit:write:bauspar:update")).toBe(true);
27
+ });
28
+
29
+ it("denies a type no scope covers", () => {
30
+ expect(patAllows(allowed, "credit:write:credit:delete")).toBe(false);
31
+ });
32
+
33
+ it("fail-closed on an empty allow-list", () => {
34
+ expect(patAllows([], "credit:write:create")).toBe(false);
35
+ });
36
+ });