@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
@@ -0,0 +1,111 @@
1
+ // Dead-letter storage for apply-handler failures during projection rebuild.
2
+ //
3
+ // Background: rebuildProjection / rebuildMultiStreamProjection replay the
4
+ // event log through apply handlers inside ONE transaction. A single event
5
+ // whose (possibly years-old) payload makes its apply throw rolls the whole
6
+ // replay back — the projection stays permanently un-rebuildable until the
7
+ // event is repaired by hand. The upcaster dead-letter only quarantines
8
+ // upcast-TRANSFORM failures, not apply failures (#760).
9
+ //
10
+ // Quarantine mode (errorPolicy.skipApplyErrors on RebuildDeps, or
11
+ // MspErrorMode.rebuild.skipApplyErrors on the MSP definition) confines each
12
+ // apply to a savepoint: a throwing apply is rolled back, captured into
13
+ // `kumiko_rebuild_dead_letters`, and the replay continues. Replay-after-fix
14
+ // is a separate ops step — same stance as the upcaster dead-letter.
15
+
16
+ import type { DbConnection, DbRunner } from "../db/connection";
17
+ import { bigint, index, jsonb, table as pgTable, text, timestamp, uuid } from "../db/dialect";
18
+ import { tableExists } from "../db/schema-inspection";
19
+ import { unsafePushTables } from "../stack";
20
+ import type { StoredEvent } from "./event-store";
21
+
22
+ export const rebuildDeadLetterTable = pgTable(
23
+ "kumiko_rebuild_dead_letters",
24
+ {
25
+ // Surrogate PK — the same event can land here across multiple rebuild
26
+ // attempts before the fix ships, without unique-violation noise.
27
+ id: bigint("id", { mode: "bigint" }).primaryKey().generatedAlwaysAsIdentity(),
28
+ projectionName: text("projection_name").notNull(),
29
+ // StoredEvent.id is surfaced as `string` (bigint serialised for JSON
30
+ // safety) — stored as text to keep the round-trip identity.
31
+ eventId: text("event_id").notNull(),
32
+ tenantId: uuid("tenant_id").notNull(),
33
+ aggregateId: text("aggregate_id").notNull(),
34
+ aggregateType: text("aggregate_type").notNull(),
35
+ eventType: text("event_type").notNull(),
36
+ errorMessage: text("error_message").notNull(),
37
+ payload: jsonb("payload").notNull(),
38
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
39
+ },
40
+ (t) => ({
41
+ projectionIdx: index("rebuild_dead_letters_projection_idx").on(t.projectionName),
42
+ createdAtIdx: index("rebuild_dead_letters_created_at_idx").on(t.createdAt),
43
+ }),
44
+ );
45
+
46
+ // Idempotent table-create. The rebuild runners call this before the rebuild
47
+ // tx whenever quarantine mode is active, so the opt-in works on databases
48
+ // provisioned before this table existed.
49
+ export async function createRebuildDeadLetterTable(db: DbConnection): Promise<void> {
50
+ // skip: table already exists — bootstrap called from multiple paths
51
+ if (await tableExists(db, "public.kumiko_rebuild_dead_letters")) return;
52
+ await unsafePushTables(db, { kumikoRebuildDeadLetters: rebuildDeadLetterTable });
53
+ }
54
+
55
+ export type SkippedApply = {
56
+ readonly event: StoredEvent;
57
+ readonly error: unknown;
58
+ };
59
+
60
+ // Bulk-write, called ONCE at the end of a quarantining rebuild (inside its
61
+ // tx). Unqualified insert — during the rebuild the search_path points at the
62
+ // shadow schema, and this table exists only in public, so it falls through.
63
+ export async function recordRebuildDeadLetters(
64
+ db: DbRunner,
65
+ projectionName: string,
66
+ skipped: readonly SkippedApply[],
67
+ ): Promise<void> {
68
+ const { insertMany } = await import("../bun-db/query");
69
+ await insertMany(
70
+ db,
71
+ rebuildDeadLetterTable,
72
+ skipped.map(({ event, error }) => ({
73
+ projectionName,
74
+ eventId: event.id,
75
+ tenantId: event.tenantId,
76
+ aggregateId: event.aggregateId,
77
+ aggregateType: event.aggregateType,
78
+ eventType: event.type,
79
+ errorMessage: error instanceof Error ? error.message : String(error),
80
+ payload: event.payload,
81
+ })),
82
+ );
83
+ }
84
+
85
+ export type RebuildDeadLetterRow = {
86
+ readonly id: bigint;
87
+ readonly projectionName: string;
88
+ readonly eventId: string;
89
+ readonly tenantId: string;
90
+ readonly aggregateId: string;
91
+ readonly aggregateType: string;
92
+ readonly eventType: string;
93
+ readonly errorMessage: string;
94
+ readonly payload: Record<string, unknown>;
95
+ readonly createdAt: Date;
96
+ };
97
+
98
+ // Ops-side triage query, optionally scoped to one projection.
99
+ export async function listRebuildDeadLetters(
100
+ db: DbConnection,
101
+ options: { projectionName?: string; limit?: number } = {},
102
+ ): Promise<readonly RebuildDeadLetterRow[]> {
103
+ const { selectMany } = await import("../bun-db/query");
104
+ const limit = options.limit ?? 100;
105
+ const where =
106
+ options.projectionName !== undefined ? { projectionName: options.projectionName } : undefined;
107
+ return selectMany<RebuildDeadLetterRow>(db, rebuildDeadLetterTable, where, {
108
+ orderBy: { col: "createdAt", direction: "desc" },
109
+ limit,
110
+ });
111
+ }
@@ -1,5 +1,10 @@
1
1
  // sql now comes from native dialect
2
2
 
3
+ import type {
4
+ LoadAggregateWithSnapshotOptions,
5
+ LoadAggregateWithSnapshotResult,
6
+ SnapshotReducer,
7
+ } from "@cosmicdrift/kumiko-types/snapshot-types";
3
8
  import type { DbConnection, DbRunner } from "../db/connection";
4
9
  import {
5
10
  index,
@@ -12,13 +17,13 @@ import {
12
17
  text,
13
18
  uuid,
14
19
  } from "../db/dialect";
15
- import { upsertSnapshot } from "../db/queries/event-store";
20
+ import { ensureSnapshotVersionColumn, upsertSnapshot } from "../db/queries/event-store";
16
21
  import { selectMany } from "../db/query";
17
22
  import { tableExists } from "../db/schema-inspection";
18
23
  import type { TenantId } from "../engine/types";
19
24
  import { unsafePushTables } from "../stack";
20
25
  import { isStreamArchived } from "./archive";
21
- import { loadEventsAfterVersion, type StoredEvent } from "./event-store";
26
+ import { loadEventsAfterVersion } from "./event-store";
22
27
 
23
28
  // Marten-aligned snapshot store. A snapshot is a point-in-time materialised
24
29
  // state of an aggregate at a specific version, cached so rehydrating the
@@ -30,18 +35,17 @@ import { loadEventsAfterVersion, type StoredEvent } from "./event-store";
30
35
  // 3. loadEventsAfterVersion(aggregate, N) → only the delta
31
36
  // 4. reducer(snapshot, delta) → current state
32
37
  //
33
- // Write path: feature authors opt in via ctx.snapshotAggregate. Policy
34
- // (every N events, every M minutes, on-demand) is a feature-level decision
35
- // the framework only offers the storage primitive.
38
+ // Write path: manual via ctx.snapshotAggregate, or automatic via the
39
+ // { snapshotEvery: N } load option the read path persists a fresh
40
+ // snapshot whenever it folded at least N delta events.
36
41
  //
37
- // Schema-migration policy: NO built-in snapshot versioning. A snapshot stores
38
- // the aggregate state in the reducer's current shape. When the reducer's
39
- // shape changes (added field, renamed property, moved compound), invalidate
40
- // the cacheDELETE from kumiko_snapshots WHERE aggregate_type = '...'.
41
- // The read path then falls back to full replay (which runs the upcaster
42
- // chain on events) until the next snapshotAggregate call. Cheaper than a
43
- // second migration mechanism; snapshots are a perf optimisation, not a
44
- // source of truth.
42
+ // Schema-migration policy: explicit generations, no reducer hashing. A
43
+ // snapshot stores the aggregate state in the reducer's current shape plus
44
+ // a caller-declared snapshot_version. Bump { snapshotVersion } when the
45
+ // reducer's shape changes stored snapshots with another generation are
46
+ // ignored (full replay through the upcaster chain on events) and restamped
47
+ // on the next auto-save. Snapshots stay a perf optimisation, not a source
48
+ // of truth.
45
49
  //
46
50
  // Upcaster interaction: the raw API (loadAggregateWithSnapshot below) does
47
51
  // NOT apply the upcaster chain on delta events — same layering as raw
@@ -63,6 +67,9 @@ export const snapshotsTable = pgTable(
63
67
  // returns events with version > this value.
64
68
  version: integer("version").notNull(),
65
69
  state: jsonb("state").$type<Record<string, unknown>>().notNull(),
70
+ // Reducer-shape generation — snapshots from another generation are
71
+ // ignored on load. See LoadAggregateWithSnapshotOptions.snapshotVersion.
72
+ snapshotVersion: integer("snapshot_version").notNull().default(sql`1`),
66
73
  createdAt: instant("created_at", { precision: 3 }).notNull().default(sql`now()`),
67
74
  },
68
75
  (t) => ({
@@ -76,7 +83,13 @@ export const snapshotsTable = pgTable(
76
83
 
77
84
  export async function createSnapshotsTable(db: DbConnection): Promise<void> {
78
85
  // skip: table already exists — idempotent boot + test-setup call
79
- if (await tableExists(db, "public.kumiko_snapshots")) return;
86
+ if (await tableExists(db, "public.kumiko_snapshots")) {
87
+ // Installs that predate snapshot_version get healed by the same
88
+ // idempotent ensure that `kumiko schema apply` / test-setup already runs.
89
+ await ensureSnapshotVersionColumn(db);
90
+ // skip: table already ensured — only the column heal above was needed
91
+ return;
92
+ }
80
93
  await unsafePushTables(db, { kumikoSnapshots: snapshotsTable });
81
94
  }
82
95
 
@@ -86,6 +99,7 @@ export type Snapshot<TState extends Record<string, unknown> = Record<string, unk
86
99
  readonly aggregateType: string;
87
100
  readonly version: number;
88
101
  readonly state: TState;
102
+ readonly snapshotVersion: number;
89
103
  readonly createdAt: Temporal.Instant;
90
104
  };
91
105
 
@@ -95,6 +109,8 @@ export type SaveSnapshotArgs = {
95
109
  readonly aggregateType: string;
96
110
  readonly version: number;
97
111
  readonly state: Record<string, unknown>;
112
+ // Reducer-shape generation (default 1) — see LoadAggregateWithSnapshotOptions.
113
+ readonly snapshotVersion?: number;
98
114
  };
99
115
 
100
116
  // Upsert-style save so re-snapshotting the same (aggregateId, version) is
@@ -108,6 +124,7 @@ export async function saveSnapshot(db: DbRunner, args: SaveSnapshotArgs): Promis
108
124
  aggregateType: args.aggregateType,
109
125
  version: args.version,
110
126
  state: args.state,
127
+ snapshotVersion: args.snapshotVersion ?? 1,
111
128
  });
112
129
  }
113
130
 
@@ -123,6 +140,7 @@ export async function loadLatestSnapshot<
123
140
  aggregateType: string;
124
141
  version: number;
125
142
  state: unknown;
143
+ snapshotVersion: number;
126
144
  createdAt: Temporal.Instant;
127
145
  };
128
146
  const rows = await selectMany<SnapRow>(
@@ -139,35 +157,16 @@ export async function loadLatestSnapshot<
139
157
  aggregateType: row.aggregateType,
140
158
  version: row.version,
141
159
  state: row.state as TState, // @cast-boundary engine-payload
160
+ snapshotVersion: row.snapshotVersion,
142
161
  createdAt: row.createdAt,
143
162
  };
144
163
  }
145
164
 
146
- // Reducer used to fold events onto a state. Kept narrow and pure — the
147
- // caller supplies the shape and update rules. Mirrors the reducer shape
148
- // feature authors already write for r.projection.apply.
149
- export type SnapshotReducer<TState extends Record<string, unknown>> = (
150
- state: TState,
151
- event: StoredEvent,
152
- ) => TState;
153
-
154
- export type LoadAggregateWithSnapshotResult<TState extends Record<string, unknown>> = {
155
- readonly state: TState;
156
- readonly version: number;
157
- readonly snapshotHit: boolean;
158
- };
159
-
160
- export type LoadAggregateWithSnapshotOptions = {
161
- // Opt-in: include archived streams in the rehydrate. Default false — same
162
- // semantics as loadAggregate / loadAggregateAsOf. Archive check is a
163
- // single indexed lookup, so the cost stays negligible on the hot path.
164
- readonly includeArchived?: boolean;
165
- // Optional upcaster step: every delta event goes through this transform
166
- // BEFORE the reducer sees it. The dispatcher wires this up with
167
- // r.eventMigration so feature code always sees current-version payloads.
168
- // Async to support Marten-style AsyncOnlyEventUpcaster (DB lookups).
169
- readonly upcastEvent?: (event: StoredEvent) => Promise<StoredEvent>;
170
- };
165
+ export type {
166
+ LoadAggregateWithSnapshotOptions,
167
+ LoadAggregateWithSnapshotResult,
168
+ SnapshotReducer,
169
+ } from "@cosmicdrift/kumiko-types/snapshot-types";
171
170
 
172
171
  // Snapshot-aware rehydrate. Loads the latest snapshot (if any), applies
173
172
  // events strictly newer than snapshot.version, and returns the fold.
@@ -186,13 +185,23 @@ export async function loadAggregateWithSnapshot<TState extends Record<string, un
186
185
  initial: TState,
187
186
  options?: LoadAggregateWithSnapshotOptions,
188
187
  ): Promise<LoadAggregateWithSnapshotResult<TState>> {
188
+ if (
189
+ options?.snapshotEvery !== undefined &&
190
+ (!Number.isInteger(options.snapshotEvery) || options.snapshotEvery < 1)
191
+ ) {
192
+ throw new Error(
193
+ `loadAggregateWithSnapshot: snapshotEvery must be an integer >= 1, got ${String(options.snapshotEvery)}`,
194
+ );
195
+ }
189
196
  if (!options?.includeArchived) {
190
197
  const archived = await isStreamArchived(db, tenantId, aggregateId);
191
198
  if (archived) {
192
199
  return { state: initial, version: 0, snapshotHit: false };
193
200
  }
194
201
  }
195
- const snapshot = await loadLatestSnapshot<TState>(db, aggregateId, tenantId);
202
+ const shapeVersion = options?.snapshotVersion ?? 1;
203
+ const stored = await loadLatestSnapshot<TState>(db, aggregateId, tenantId);
204
+ const snapshot = stored && stored.snapshotVersion === shapeVersion ? stored : null;
196
205
  const baseState = snapshot ? snapshot.state : initial;
197
206
  const afterVersion = snapshot ? snapshot.version : 0;
198
207
  const delta = await loadEventsAfterVersion(db, aggregateId, tenantId, afterVersion);
@@ -204,6 +213,20 @@ export async function loadAggregateWithSnapshot<TState extends Record<string, un
204
213
  }
205
214
  const lastDelta = delta[delta.length - 1];
206
215
  const latestVersion = lastDelta ? lastDelta.version : afterVersion;
216
+ if (options?.snapshotEvery !== undefined && lastDelta && delta.length >= options.snapshotEvery) {
217
+ try {
218
+ await saveSnapshot(db, {
219
+ aggregateId,
220
+ tenantId,
221
+ aggregateType: lastDelta.aggregateType,
222
+ version: latestVersion,
223
+ state,
224
+ snapshotVersion: shapeVersion,
225
+ });
226
+ } catch {
227
+ // Best-effort cache write — losing it only costs the next load a replay.
228
+ }
229
+ }
207
230
  return {
208
231
  state,
209
232
  version: latestVersion,
@@ -0,0 +1,2 @@
1
+ // Legacy path — re-exported for callers still importing this module directly.
2
+ export type * from "@cosmicdrift/kumiko-types/event-store-types";
@@ -0,0 +1,28 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { buildStorageKey } from "../types";
3
+
4
+ describe("buildStorageKey", () => {
5
+ test("uses the lowercased extension for a normal filename", () => {
6
+ const key = buildStorageKey("T1" as never, "invoice", 1, "attachment", "logo.PNG", "u1");
7
+ expect(key).toBe("T1/invoice/1/attachment/u1.png");
8
+ });
9
+
10
+ test("uses the sole segment as extension when there is no dot", () => {
11
+ const key = buildStorageKey("T1" as never, "invoice", 1, "attachment", "noext", "u1");
12
+ expect(key).toBe("T1/invoice/1/attachment/u1.noext");
13
+ });
14
+
15
+ test("rejects a path-traversal filename and falls back to bin instead of leaking the payload", () => {
16
+ const key = buildStorageKey(
17
+ "T1" as never,
18
+ "unattached",
19
+ "file",
20
+ "u",
21
+ "a.b/../../../../evil",
22
+ "u1",
23
+ );
24
+ expect(key).toBe("T1/unattached/file/u/u1.bin");
25
+ expect(key).not.toContain("..");
26
+ expect(key.split("/")).toHaveLength(5);
27
+ });
28
+ });
@@ -32,3 +32,11 @@ describe("fileRefEntity base-column drift", () => {
32
32
  expect(insertedById[0]?.notNull).toBe(false);
33
33
  });
34
34
  });
35
+
36
+ describe("fileRefEntity — DDL (#1205 regression)", () => {
37
+ test("size column is bigint, not double precision", () => {
38
+ const size = col("size");
39
+ expect(size).toHaveLength(1);
40
+ expect(size[0]?.pgType).toBe("bigint");
41
+ });
42
+ });
@@ -593,6 +593,30 @@ describe("error handling", () => {
593
593
  expect(body.error).toContain("invalid_file_type");
594
594
  });
595
595
 
596
+ test("upload with path-traversal entityType is rejected before it reaches storage", async () => {
597
+ const pngContent = new Uint8Array([0x89, 0x50, 0x4e, 0x47]);
598
+ const res = await uploadFile(adminUser, "logo.png", pngContent, "image/png", {
599
+ entityType: "../../tenantB",
600
+ entityId: "1",
601
+ fieldName: "logo",
602
+ });
603
+ expect(res.status).toBe(400);
604
+ const body = await res.json();
605
+ expect(body.error).toContain("invalid_entityType");
606
+ });
607
+
608
+ test("upload with path-traversal fieldName is rejected before it reaches storage", async () => {
609
+ const pngContent = new Uint8Array([0x89, 0x50, 0x4e, 0x47]);
610
+ const res = await uploadFile(adminUser, "logo.png", pngContent, "image/png", {
611
+ entityType: "tenant",
612
+ entityId: "1",
613
+ fieldName: "../../escape",
614
+ });
615
+ expect(res.status).toBe(400);
616
+ const body = await res.json();
617
+ expect(body.error).toContain("invalid_fieldName");
618
+ });
619
+
596
620
  test("upload without auth returns 401", async () => {
597
621
  const formData = new FormData();
598
622
  formData.append("file", new File([new Uint8Array(10)], "test.png", { type: "image/png" }));
@@ -0,0 +1,4 @@
1
+ import { describeFileProviderContract } from "../../testing/file-provider-contract";
2
+ import { createInMemoryFileProvider } from "../in-memory-provider";
3
+
4
+ describeFileProviderContract("InMemoryFileProvider", () => createInMemoryFileProvider());
@@ -0,0 +1,31 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import { rm } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { createLocalProvider } from "../local-provider";
6
+
7
+ describe("createLocalProvider path-traversal guard", () => {
8
+ const basePath = join(tmpdir(), `kumiko-local-provider-test-${Date.now()}`);
9
+ const provider = createLocalProvider(basePath);
10
+
11
+ afterEach(async () => {
12
+ await rm(basePath, { recursive: true, force: true });
13
+ });
14
+
15
+ test("rejects a key with a `..` segment even when it resolves inside basePath", async () => {
16
+ await expect(
17
+ provider.write("T1/e/1/f/x.png/../../../../T2/y.png", new Uint8Array([1])),
18
+ ).rejects.toThrow(/path-traversal/);
19
+ });
20
+
21
+ test("rejects a `..` segment on read/delete too, and exists() reports false instead of throwing", async () => {
22
+ await expect(provider.read("a/../../etc/passwd")).rejects.toThrow(/path-traversal/);
23
+ await expect(provider.delete("a/../../etc/passwd")).rejects.toThrow(/path-traversal/);
24
+ expect(await provider.exists("a/../../etc/passwd")).toBe(false);
25
+ });
26
+
27
+ test("still allows a normal, contained key", async () => {
28
+ await provider.write("T1/entity/1/field/file.png", new Uint8Array([1, 2, 3]));
29
+ expect(await provider.exists("T1/entity/1/field/file.png")).toBe(true);
30
+ });
31
+ });
@@ -0,0 +1,70 @@
1
+ import { describe, expect, mock, test } from "bun:test";
2
+ import type { Registry } from "../../engine/types";
3
+ import { makeFileProviderResolver } from "../provider-resolver";
4
+ import type { FileStorageProvider } from "../types";
5
+
6
+ const fakeProvider = { name: "fake" } as unknown as FileStorageProvider;
7
+
8
+ function fakeRegistry(): Registry {
9
+ return {
10
+ getExtensionUsages: () => [
11
+ {
12
+ entityName: "fake",
13
+ options: { build: async () => fakeProvider },
14
+ },
15
+ ],
16
+ } as unknown as Registry;
17
+ }
18
+
19
+ // 698/2: resolveProvider must not re-read config + secrets on every call —
20
+ // the provider is effectively static per tenant for the process lifetime.
21
+ describe("makeFileProviderResolver — per-tenant cache", () => {
22
+ test("a second resolve for the same tenant reuses the cached build, doesn't call configAccessorFactory again", async () => {
23
+ const configAccessorFactory = mock(() => async (_key: unknown) => "fake");
24
+ const resolver = makeFileProviderResolver({
25
+ registry: fakeRegistry(),
26
+ _configAccessorFactory: configAccessorFactory,
27
+ db: {} as never,
28
+ });
29
+
30
+ const first = await resolver("tenant-a" as never);
31
+ const second = await resolver("tenant-a" as never);
32
+
33
+ expect(first).toBe(fakeProvider);
34
+ expect(second).toBe(fakeProvider);
35
+ expect(configAccessorFactory).toHaveBeenCalledTimes(1);
36
+ });
37
+
38
+ test("different tenants get independent cache entries", async () => {
39
+ const configAccessorFactory = mock(() => async (_key: unknown) => "fake");
40
+ const resolver = makeFileProviderResolver({
41
+ registry: fakeRegistry(),
42
+ _configAccessorFactory: configAccessorFactory,
43
+ db: {} as never,
44
+ });
45
+
46
+ await resolver("tenant-a" as never);
47
+ await resolver("tenant-b" as never);
48
+
49
+ expect(configAccessorFactory).toHaveBeenCalledTimes(2);
50
+ });
51
+
52
+ test("a rejected build is evicted — the next call retries instead of staying poisoned", async () => {
53
+ let calls = 0;
54
+ const resolver = makeFileProviderResolver({
55
+ registry: {
56
+ getExtensionUsages: () => {
57
+ calls++;
58
+ if (calls === 1) throw new Error("transient failure");
59
+ return [{ entityName: "fake", options: { build: async () => fakeProvider } }];
60
+ },
61
+ } as unknown as Registry,
62
+ _configAccessorFactory: () => async (_key: unknown) => "fake",
63
+ db: {} as never,
64
+ });
65
+
66
+ await expect(resolver("tenant-a" as never)).rejects.toThrow("transient failure");
67
+ const second = await resolver("tenant-a" as never);
68
+ expect(second).toBe(fakeProvider);
69
+ });
70
+ });
@@ -147,6 +147,19 @@ describe("FileStorageProvider.writeStream — local-filesystem", () => {
147
147
  const data = await provider.read("k");
148
148
  expect(Array.from(data)).toEqual([2, 2, 2]);
149
149
  });
150
+
151
+ test("write/read/delete reject a key that escapes basePath; exists reports false", async () => {
152
+ const provider = createLocalProvider(basePath);
153
+ const escapingKey = "../../etc/passwd";
154
+
155
+ await expect(provider.write(escapingKey, new Uint8Array([1]))).rejects.toThrow(
156
+ /path-traversal/,
157
+ );
158
+ await expect(provider.read(escapingKey)).rejects.toThrow(/path-traversal/);
159
+ await expect(provider.delete(escapingKey)).rejects.toThrow(/path-traversal/);
160
+ // exists() treats any stat failure (missing file, escaping key) as "not there".
161
+ expect(await provider.exists(escapingKey)).toBe(false);
162
+ });
150
163
  });
151
164
 
152
165
  describe("FileStorageProvider.writeStream — Streaming-Property", () => {
@@ -10,27 +10,10 @@
10
10
  // suffix before the file extension — `foo/bar.jpg` + `"medium"` →
11
11
  // `foo/bar.medium.jpg`. Stable, reversible, no extra lookup tables.
12
12
 
13
+ import type { FileContext, FileHandle } from "@cosmicdrift/kumiko-types/file-handle-types";
13
14
  import type { FileStorageProvider } from "./types";
14
15
 
15
- export type FileHandle = {
16
- readonly key: string;
17
- read(): Promise<Uint8Array>;
18
- write(data: Uint8Array, mimeType?: string): Promise<void>;
19
- delete(): Promise<void>;
20
- exists(): Promise<boolean>;
21
- // Produce a handle for a derived key (e.g. a thumbnail). Does not touch
22
- // storage; only computes the key. Writing to the derived handle is the
23
- // caller's job.
24
- derive(suffix: string): FileHandle;
25
- };
26
-
27
- // The `ctx.files` service — a factory that materialises a FileHandle for a
28
- // storage key. One per request/event, bound to a single tenant: the provider
29
- // is resolved per-tenant through file-foundation, so uploads, ctx.files and the
30
- // GDPR jobs all hit the same store by construction.
31
- export type FileContext = {
32
- ref(key: string): FileHandle;
33
- };
16
+ export type { FileContext, FileHandle };
34
17
 
35
18
  // `getProvider` is a lazily-resolved, memoized accessor — the provider is
36
19
  // resolved (config + s3.secretAccessKey secret read) only when a handle method
@@ -1,4 +1,4 @@
1
- import { createEntity, createNumberField, createTextField } from "../engine";
1
+ import { createBigIntField, createEntity, createTextField } from "../engine";
2
2
 
3
3
  // fileRef — das File-Metadata-Entity. Ganz normales ES-Entity: Upload/Delete
4
4
  // laufen über den Standard-Executor (file-routes.ts), die Tabelle `file_refs`
@@ -25,7 +25,7 @@ export const fileRefEntity = createEntity({
25
25
  storageKey: createTextField({ required: true }),
26
26
  fileName: createTextField({ required: true, pii: true }),
27
27
  mimeType: createTextField({ required: true }),
28
- size: createNumberField({ required: true }),
28
+ size: createBigIntField({ required: true }),
29
29
  entityType: createTextField(),
30
30
  entityId: createTextField(),
31
31
  fieldName: createTextField(),
@@ -112,6 +112,19 @@ export function createFileRoutes(options: FileRoutesOptions): Hono {
112
112
  const entityId = typeof body["entityId"] === "string" ? body["entityId"] : undefined;
113
113
  const fieldName = typeof body["fieldName"] === "string" ? body["fieldName"] : undefined;
114
114
 
115
+ // These interpolate directly into the storage key (buildStorageKey below) —
116
+ // reject anything but a safe identifier so a client can't path-traverse
117
+ // out of its tenant's storage prefix (../.. segments, absolute paths).
118
+ for (const [field, value] of [
119
+ ["entityType", entityType],
120
+ ["entityId", entityId],
121
+ ["fieldName", fieldName],
122
+ ] as const) {
123
+ if (value !== undefined && !/^[A-Za-z0-9_-]+$/.test(value)) {
124
+ return c.json({ error: `invalid_${field}: must match /^[A-Za-z0-9_-]+$/` }, 400);
125
+ }
126
+ }
127
+
115
128
  // Validate against entity field definition if available.
116
129
  let maxSize = options.maxUploadSize ?? "10mb";
117
130
  let accept: readonly string[] | undefined;
@@ -34,6 +34,6 @@ export type {
34
34
  SignedUrlOptions,
35
35
  WriteStreamOptions,
36
36
  } from "./types";
37
- export { buildStorageKey, parseMaxSize, validateFile } from "./types";
37
+ export { assertSafeStorageKey, buildStorageKey, parseMaxSize, validateFile } from "./types";
38
38
  export type { ZipEntry } from "./zip-stream";
39
39
  export { createZipStream } from "./zip-stream";