@cosmicdrift/kumiko-framework 0.1.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 (388) hide show
  1. package/README.md +159 -0
  2. package/package.json +91 -0
  3. package/src/__tests__/anonymous-access.integration.ts +325 -0
  4. package/src/__tests__/error-contract.integration.ts +435 -0
  5. package/src/__tests__/field-access.integration.ts +269 -0
  6. package/src/__tests__/full-stack.integration.ts +914 -0
  7. package/src/__tests__/ownership.integration.ts +449 -0
  8. package/src/__tests__/reference-data.integration.ts +198 -0
  9. package/src/__tests__/transition-guard.integration.ts +340 -0
  10. package/src/api/__tests__/api.test.ts +337 -0
  11. package/src/api/__tests__/auth-middleware-transport.test.ts +80 -0
  12. package/src/api/__tests__/auth-routes-cookie.test.ts +179 -0
  13. package/src/api/__tests__/batch.integration.ts +404 -0
  14. package/src/api/__tests__/body-limit.test.ts +88 -0
  15. package/src/api/__tests__/csrf-middleware.test.ts +97 -0
  16. package/src/api/__tests__/dispatcher-live.integration.ts +216 -0
  17. package/src/api/__tests__/metrics-endpoint.test.ts +126 -0
  18. package/src/api/__tests__/nested-write.integration.ts +213 -0
  19. package/src/api/__tests__/readiness.test.ts +76 -0
  20. package/src/api/__tests__/request-id-middleware.test.ts +72 -0
  21. package/src/api/__tests__/sse-broker.test.ts +58 -0
  22. package/src/api/__tests__/sse-route.test.ts +112 -0
  23. package/src/api/anonymous-cookie.ts +60 -0
  24. package/src/api/api-constants.ts +64 -0
  25. package/src/api/auth-middleware.ts +418 -0
  26. package/src/api/auth-routes.ts +982 -0
  27. package/src/api/csrf-middleware.ts +77 -0
  28. package/src/api/index.ts +31 -0
  29. package/src/api/jwt.ts +66 -0
  30. package/src/api/observability-middleware.ts +89 -0
  31. package/src/api/readiness.ts +132 -0
  32. package/src/api/request-context.ts +49 -0
  33. package/src/api/request-id-middleware.ts +50 -0
  34. package/src/api/route-registrars.ts +195 -0
  35. package/src/api/routes.ts +135 -0
  36. package/src/api/server.ts +640 -0
  37. package/src/api/sse-broker.ts +71 -0
  38. package/src/api/sse-route.ts +62 -0
  39. package/src/api/tokens.ts +16 -0
  40. package/src/db/__tests__/apply-entity-event-tenant.integration.ts +159 -0
  41. package/src/db/__tests__/compound-types.test.ts +114 -0
  42. package/src/db/__tests__/connection-options.test.ts +68 -0
  43. package/src/db/__tests__/cursor.test.ts +41 -0
  44. package/src/db/__tests__/db-helpers.test.ts +369 -0
  45. package/src/db/__tests__/dialect-instant.test.ts +50 -0
  46. package/src/db/__tests__/drizzle-helpers.integration.ts +186 -0
  47. package/src/db/__tests__/drizzle-table-types.test.ts +162 -0
  48. package/src/db/__tests__/encryption.test.ts +39 -0
  49. package/src/db/__tests__/event-store-executor-list.integration.ts +313 -0
  50. package/src/db/__tests__/event-store-executor.integration.ts +235 -0
  51. package/src/db/__tests__/implicit-projection-equivalence.integration.ts +304 -0
  52. package/src/db/__tests__/located-timestamp.test.ts +184 -0
  53. package/src/db/__tests__/money.test.ts +199 -0
  54. package/src/db/__tests__/multi-row-insert.integration.ts +76 -0
  55. package/src/db/__tests__/parse-auto-verb.test.ts +70 -0
  56. package/src/db/__tests__/required-not-null-migration-safety.integration.ts +105 -0
  57. package/src/db/__tests__/row-helpers.test.ts +59 -0
  58. package/src/db/__tests__/schema-migration.integration.ts +273 -0
  59. package/src/db/__tests__/table-builder-indexes.test.ts +153 -0
  60. package/src/db/__tests__/table-builder-required.test.ts +216 -0
  61. package/src/db/__tests__/tenant-db.integration.ts +606 -0
  62. package/src/db/__tests__/unique-violation-mapping.integration.ts +166 -0
  63. package/src/db/apply-entity-event.ts +188 -0
  64. package/src/db/assert-exists-in.ts +59 -0
  65. package/src/db/compound-types.ts +47 -0
  66. package/src/db/connection.ts +104 -0
  67. package/src/db/cursor.ts +83 -0
  68. package/src/db/dialect.ts +109 -0
  69. package/src/db/eagerload.ts +174 -0
  70. package/src/db/encryption.ts +39 -0
  71. package/src/db/event-store-executor.ts +906 -0
  72. package/src/db/index.ts +55 -0
  73. package/src/db/located-timestamp.ts +114 -0
  74. package/src/db/money.ts +120 -0
  75. package/src/db/pg-error.ts +46 -0
  76. package/src/db/reference-data.ts +77 -0
  77. package/src/db/row-helpers.ts +53 -0
  78. package/src/db/schema-inspection.ts +25 -0
  79. package/src/db/table-builder.ts +475 -0
  80. package/src/db/tenant-db.ts +434 -0
  81. package/src/engine/__tests__/auth-claims-registrar.test.ts +74 -0
  82. package/src/engine/__tests__/boot-validator-located-timestamps.test.ts +108 -0
  83. package/src/engine/__tests__/boot-validator.test.ts +1865 -0
  84. package/src/engine/__tests__/build-app-schema.test.ts +154 -0
  85. package/src/engine/__tests__/claim-keys.test.ts +274 -0
  86. package/src/engine/__tests__/config-helpers.test.ts +236 -0
  87. package/src/engine/__tests__/effective-features.test.ts +86 -0
  88. package/src/engine/__tests__/engine.test.ts +1461 -0
  89. package/src/engine/__tests__/entity-handlers.test.ts +274 -0
  90. package/src/engine/__tests__/event-helpers.test.ts +68 -0
  91. package/src/engine/__tests__/extends-registrar.test.ts +159 -0
  92. package/src/engine/__tests__/factories-long-text.test.ts +84 -0
  93. package/src/engine/__tests__/factories-time.test.ts +158 -0
  94. package/src/engine/__tests__/field-predicates.test.ts +48 -0
  95. package/src/engine/__tests__/hook-phases.test.ts +132 -0
  96. package/src/engine/__tests__/identifiers.test.ts +35 -0
  97. package/src/engine/__tests__/lifecycle-hooks.test.ts +237 -0
  98. package/src/engine/__tests__/nav.test.ts +267 -0
  99. package/src/engine/__tests__/ownership.test.ts +421 -0
  100. package/src/engine/__tests__/parse-ref-target.test.ts +43 -0
  101. package/src/engine/__tests__/projection-helpers.test.ts +62 -0
  102. package/src/engine/__tests__/projection.test.ts +191 -0
  103. package/src/engine/__tests__/qualified-name.test.ts +264 -0
  104. package/src/engine/__tests__/resolve-config-or-param.test.ts +315 -0
  105. package/src/engine/__tests__/run-in.test.ts +38 -0
  106. package/src/engine/__tests__/schema-builder.test.ts +380 -0
  107. package/src/engine/__tests__/screen.test.ts +408 -0
  108. package/src/engine/__tests__/state-machine.test.ts +148 -0
  109. package/src/engine/__tests__/system-user.test.ts +57 -0
  110. package/src/engine/__tests__/validation-hooks.test.ts +71 -0
  111. package/src/engine/access.ts +23 -0
  112. package/src/engine/boot-validator.ts +1528 -0
  113. package/src/engine/build-app-schema.ts +125 -0
  114. package/src/engine/config-helpers.ts +115 -0
  115. package/src/engine/constants.ts +85 -0
  116. package/src/engine/create-app.ts +98 -0
  117. package/src/engine/define-feature.ts +702 -0
  118. package/src/engine/define-handler.ts +78 -0
  119. package/src/engine/define-roles.ts +19 -0
  120. package/src/engine/effective-features.ts +87 -0
  121. package/src/engine/entity-handlers.ts +364 -0
  122. package/src/engine/event-helpers.ts +73 -0
  123. package/src/engine/factories.ts +328 -0
  124. package/src/engine/feature-ast/__tests__/canonical-form.test.ts +416 -0
  125. package/src/engine/feature-ast/__tests__/parse-happy-path.test.ts +197 -0
  126. package/src/engine/feature-ast/__tests__/parse-real-features.test.ts +128 -0
  127. package/src/engine/feature-ast/__tests__/parse.test.ts +888 -0
  128. package/src/engine/feature-ast/__tests__/patch.test.ts +360 -0
  129. package/src/engine/feature-ast/__tests__/patcher.test.ts +469 -0
  130. package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +287 -0
  131. package/src/engine/feature-ast/extractors.ts +2562 -0
  132. package/src/engine/feature-ast/index.ts +105 -0
  133. package/src/engine/feature-ast/parse.ts +369 -0
  134. package/src/engine/feature-ast/patch.ts +525 -0
  135. package/src/engine/feature-ast/patcher.ts +518 -0
  136. package/src/engine/feature-ast/patterns.ts +434 -0
  137. package/src/engine/feature-ast/render.ts +602 -0
  138. package/src/engine/feature-ast/source-location.ts +45 -0
  139. package/src/engine/field-access.ts +120 -0
  140. package/src/engine/index.ts +254 -0
  141. package/src/engine/ownership.ts +337 -0
  142. package/src/engine/parse-ref-target.ts +22 -0
  143. package/src/engine/pattern-library/__tests__/library.test.ts +351 -0
  144. package/src/engine/pattern-library/index.ts +24 -0
  145. package/src/engine/pattern-library/library.ts +1117 -0
  146. package/src/engine/pattern-library/types.ts +255 -0
  147. package/src/engine/projection-helpers.ts +85 -0
  148. package/src/engine/qualified-name.ts +122 -0
  149. package/src/engine/read-claim.ts +31 -0
  150. package/src/engine/registry.ts +1325 -0
  151. package/src/engine/resolve-config-or-param.ts +153 -0
  152. package/src/engine/run-in.ts +29 -0
  153. package/src/engine/schema-builder.ts +175 -0
  154. package/src/engine/screen-filter-ops.ts +51 -0
  155. package/src/engine/state-machine.ts +70 -0
  156. package/src/engine/system-user.ts +32 -0
  157. package/src/engine/types/config.ts +306 -0
  158. package/src/engine/types/event-type-map.ts +37 -0
  159. package/src/engine/types/feature.ts +574 -0
  160. package/src/engine/types/fields.ts +422 -0
  161. package/src/engine/types/handlers.ts +742 -0
  162. package/src/engine/types/hooks.ts +142 -0
  163. package/src/engine/types/http-route.ts +54 -0
  164. package/src/engine/types/identifiers.ts +47 -0
  165. package/src/engine/types/index.ts +208 -0
  166. package/src/engine/types/nav.ts +46 -0
  167. package/src/engine/types/projection.ts +132 -0
  168. package/src/engine/types/relations.ts +51 -0
  169. package/src/engine/types/screen.ts +452 -0
  170. package/src/engine/types/workspace.ts +42 -0
  171. package/src/engine/validation.ts +33 -0
  172. package/src/entrypoint/__tests__/entrypoint-job-wiring.integration.ts +173 -0
  173. package/src/entrypoint/__tests__/split-deploy.integration.ts +297 -0
  174. package/src/entrypoint/index.ts +442 -0
  175. package/src/errors/__tests__/classes.test.ts +371 -0
  176. package/src/errors/__tests__/write-failures.test.ts +109 -0
  177. package/src/errors/classes.ts +249 -0
  178. package/src/errors/i18n/de.yaml +83 -0
  179. package/src/errors/i18n/en.yaml +80 -0
  180. package/src/errors/index.ts +41 -0
  181. package/src/errors/kumiko-error.ts +67 -0
  182. package/src/errors/reasons.ts +36 -0
  183. package/src/errors/serialize.ts +136 -0
  184. package/src/errors/transition-details.ts +30 -0
  185. package/src/errors/write-error-info.ts +123 -0
  186. package/src/errors/zod-bridge.ts +49 -0
  187. package/src/event-store/__tests__/admin-api.integration.ts +361 -0
  188. package/src/event-store/__tests__/event-store.integration.ts +584 -0
  189. package/src/event-store/__tests__/get-stream-version-perf.integration.ts +83 -0
  190. package/src/event-store/__tests__/perf.integration.ts +255 -0
  191. package/src/event-store/__tests__/snapshot.integration.ts +267 -0
  192. package/src/event-store/__tests__/upcaster-dead-letter.integration.ts +204 -0
  193. package/src/event-store/__tests__/upcaster.integration.ts +460 -0
  194. package/src/event-store/admin-api.ts +257 -0
  195. package/src/event-store/archive.ts +106 -0
  196. package/src/event-store/errors.ts +35 -0
  197. package/src/event-store/event-store.ts +405 -0
  198. package/src/event-store/events-schema.ts +90 -0
  199. package/src/event-store/index.ts +50 -0
  200. package/src/event-store/snapshot.ts +210 -0
  201. package/src/event-store/upcaster-dead-letter.ts +119 -0
  202. package/src/event-store/upcaster.ts +147 -0
  203. package/src/files/__tests__/content-disposition.test.ts +123 -0
  204. package/src/files/__tests__/file-field-column.integration.ts +103 -0
  205. package/src/files/__tests__/file-field-pipeline.integration.ts +211 -0
  206. package/src/files/__tests__/file-handle.test.ts +122 -0
  207. package/src/files/__tests__/files.integration.ts +830 -0
  208. package/src/files/__tests__/storage-tracking.integration.ts +153 -0
  209. package/src/files/content-disposition.ts +55 -0
  210. package/src/files/file-handle.ts +63 -0
  211. package/src/files/file-ref-table.ts +22 -0
  212. package/src/files/file-routes.ts +353 -0
  213. package/src/files/in-memory-provider.ts +62 -0
  214. package/src/files/index.ts +29 -0
  215. package/src/files/local-provider.ts +35 -0
  216. package/src/files/storage-tracking.ts +60 -0
  217. package/src/files/types.ts +118 -0
  218. package/src/i18n/__tests__/i18n.test.ts +72 -0
  219. package/src/i18n/index.ts +29 -0
  220. package/src/jobs/__tests__/job-event-trigger.integration.ts +172 -0
  221. package/src/jobs/__tests__/job-multi-trigger.integration.ts +144 -0
  222. package/src/jobs/__tests__/jobs.integration.ts +566 -0
  223. package/src/jobs/index.ts +2 -0
  224. package/src/jobs/job-runner.ts +574 -0
  225. package/src/lifecycle/__tests__/create-test-lifecycle.ts +19 -0
  226. package/src/lifecycle/__tests__/lifecycle-server.integration.ts +108 -0
  227. package/src/lifecycle/__tests__/lifecycle.test.ts +212 -0
  228. package/src/lifecycle/__tests__/signal-handlers.test.ts +106 -0
  229. package/src/lifecycle/index.ts +13 -0
  230. package/src/lifecycle/lifecycle.ts +160 -0
  231. package/src/lifecycle/signal-handlers.ts +62 -0
  232. package/src/logging/__tests__/pino-trace-bridge.test.ts +50 -0
  233. package/src/logging/index.ts +3 -0
  234. package/src/logging/pino-logger.ts +64 -0
  235. package/src/logging/types.ts +7 -0
  236. package/src/migrations/__tests__/compare-snapshots.test.ts +150 -0
  237. package/src/migrations/__tests__/detect-drift.integration.ts +320 -0
  238. package/src/migrations/__tests__/detect-projections-to-rebuild.integration.ts +134 -0
  239. package/src/migrations/__tests__/rebuild-marker.test.ts +79 -0
  240. package/src/migrations/index.ts +28 -0
  241. package/src/migrations/projection-detection.ts +149 -0
  242. package/src/migrations/rebuild-marker.ts +64 -0
  243. package/src/migrations/schema-drift.ts +395 -0
  244. package/src/observability/__tests__/console-provider.test.ts +67 -0
  245. package/src/observability/__tests__/metric-validator.test.ts +87 -0
  246. package/src/observability/__tests__/noop-provider.test.ts +82 -0
  247. package/src/observability/__tests__/observability.integration.ts +559 -0
  248. package/src/observability/__tests__/prometheus-meter.test.ts +144 -0
  249. package/src/observability/__tests__/recording-meter.test.ts +101 -0
  250. package/src/observability/__tests__/recording-tracer.test.ts +110 -0
  251. package/src/observability/__tests__/sensitive-filter.test.ts +98 -0
  252. package/src/observability/console-provider.ts +130 -0
  253. package/src/observability/context.ts +26 -0
  254. package/src/observability/fallback.ts +34 -0
  255. package/src/observability/ids.ts +25 -0
  256. package/src/observability/index.ts +79 -0
  257. package/src/observability/metric-validator.ts +86 -0
  258. package/src/observability/metrics-handle.ts +56 -0
  259. package/src/observability/noop-provider.ts +146 -0
  260. package/src/observability/prometheus-meter.ts +284 -0
  261. package/src/observability/recording-meter.ts +156 -0
  262. package/src/observability/recording-tracer.ts +198 -0
  263. package/src/observability/redis-wrapper.ts +132 -0
  264. package/src/observability/sensitive-filter.ts +108 -0
  265. package/src/observability/standard-metrics.ts +213 -0
  266. package/src/observability/types/index.ts +29 -0
  267. package/src/observability/types/metric.ts +56 -0
  268. package/src/observability/types/provider.ts +32 -0
  269. package/src/observability/types/span.ts +64 -0
  270. package/src/pipeline/__tests__/archive-stream.integration.ts +220 -0
  271. package/src/pipeline/__tests__/auth-claims-resolver.test.ts +279 -0
  272. package/src/pipeline/__tests__/cascade-handler.integration.ts +419 -0
  273. package/src/pipeline/__tests__/cascade-handler.test.ts +52 -0
  274. package/src/pipeline/__tests__/causation-chain.integration.ts +206 -0
  275. package/src/pipeline/__tests__/ctx-bridge.integration.ts +234 -0
  276. package/src/pipeline/__tests__/dispatcher.test.ts +379 -0
  277. package/src/pipeline/__tests__/distributed-lock.integration.ts +67 -0
  278. package/src/pipeline/__tests__/domain-events-projections.integration.ts +323 -0
  279. package/src/pipeline/__tests__/event-dedup.integration.ts +153 -0
  280. package/src/pipeline/__tests__/event-define-event-strict.integration.ts +202 -0
  281. package/src/pipeline/__tests__/event-dispatcher-lifecycle.integration.ts +220 -0
  282. package/src/pipeline/__tests__/event-dispatcher-multi-instance.integration.ts +423 -0
  283. package/src/pipeline/__tests__/event-dispatcher-pg-listen.integration.ts +123 -0
  284. package/src/pipeline/__tests__/event-dispatcher-recovery.integration.ts +202 -0
  285. package/src/pipeline/__tests__/event-dispatcher-second-audit.integration.ts +290 -0
  286. package/src/pipeline/__tests__/event-dispatcher-strict.test.ts +65 -0
  287. package/src/pipeline/__tests__/event-dispatcher.integration.ts +287 -0
  288. package/src/pipeline/__tests__/event-retention.integration.ts +239 -0
  289. package/src/pipeline/__tests__/fetch-for-writing.integration.ts +281 -0
  290. package/src/pipeline/__tests__/lifecycle-pipeline.test.ts +430 -0
  291. package/src/pipeline/__tests__/load-aggregate-query.integration.ts +266 -0
  292. package/src/pipeline/__tests__/msp-error-mode.integration.ts +149 -0
  293. package/src/pipeline/__tests__/msp-multi-hop.integration.ts +228 -0
  294. package/src/pipeline/__tests__/msp-rebuild.integration.ts +368 -0
  295. package/src/pipeline/__tests__/multi-stream-projection.integration.ts +341 -0
  296. package/src/pipeline/__tests__/perf-rebuild.integration.ts +147 -0
  297. package/src/pipeline/__tests__/projection-rebuild.integration.ts +551 -0
  298. package/src/pipeline/__tests__/query-projection.integration.ts +201 -0
  299. package/src/pipeline/__tests__/redis-pipeline.integration.ts +306 -0
  300. package/src/pipeline/append-event-core.ts +117 -0
  301. package/src/pipeline/auth-claims-resolver.ts +103 -0
  302. package/src/pipeline/cascade-handler.ts +113 -0
  303. package/src/pipeline/dispatcher.ts +1585 -0
  304. package/src/pipeline/distributed-lock.ts +37 -0
  305. package/src/pipeline/entity-cache.ts +113 -0
  306. package/src/pipeline/event-consumer-state.ts +108 -0
  307. package/src/pipeline/event-dedup.ts +23 -0
  308. package/src/pipeline/event-dispatcher.ts +1016 -0
  309. package/src/pipeline/event-retention.ts +154 -0
  310. package/src/pipeline/idempotency.ts +76 -0
  311. package/src/pipeline/index.ts +66 -0
  312. package/src/pipeline/lifecycle-pipeline.ts +409 -0
  313. package/src/pipeline/msp-rebuild.ts +242 -0
  314. package/src/pipeline/multi-stream-apply-context.ts +115 -0
  315. package/src/pipeline/projection-rebuild.ts +334 -0
  316. package/src/pipeline/projection-state.ts +72 -0
  317. package/src/pipeline/projections-runner.ts +56 -0
  318. package/src/pipeline/redis-keys.ts +11 -0
  319. package/src/pipeline/system-hooks.ts +190 -0
  320. package/src/random/__tests__/generate.test.ts +149 -0
  321. package/src/random/generate.ts +141 -0
  322. package/src/random/index.ts +8 -0
  323. package/src/random/words.ts +392 -0
  324. package/src/rate-limit/__tests__/dispatcher-l3.integration.ts +111 -0
  325. package/src/rate-limit/__tests__/middleware.integration.ts +189 -0
  326. package/src/rate-limit/__tests__/resolver.integration.ts +189 -0
  327. package/src/rate-limit/bucket.ts +36 -0
  328. package/src/rate-limit/index.ts +14 -0
  329. package/src/rate-limit/middleware.ts +152 -0
  330. package/src/rate-limit/resolver.ts +267 -0
  331. package/src/redis/__tests__/redis-options.test.ts +54 -0
  332. package/src/redis/index.ts +74 -0
  333. package/src/search/__tests__/meilisearch-adapter.integration.ts +236 -0
  334. package/src/search/__tests__/search-adapter.test.ts +256 -0
  335. package/src/search/in-memory-adapter.ts +123 -0
  336. package/src/search/index.ts +12 -0
  337. package/src/search/meilisearch-adapter.ts +106 -0
  338. package/src/search/types.ts +39 -0
  339. package/src/secrets/__tests__/dek-cache.test.ts +213 -0
  340. package/src/secrets/__tests__/env-master-key-provider.test.ts +119 -0
  341. package/src/secrets/__tests__/envelope.test.ts +74 -0
  342. package/src/secrets/__tests__/leak-guard.test.ts +92 -0
  343. package/src/secrets/__tests__/rotation.test.ts +149 -0
  344. package/src/secrets/dek-cache.ts +116 -0
  345. package/src/secrets/env-master-key-provider.ts +162 -0
  346. package/src/secrets/envelope.ts +55 -0
  347. package/src/secrets/index.ts +19 -0
  348. package/src/secrets/leak-guard.ts +87 -0
  349. package/src/secrets/rotation.ts +34 -0
  350. package/src/secrets/types.ts +107 -0
  351. package/src/stack/db.ts +104 -0
  352. package/src/stack/event-collector.ts +23 -0
  353. package/src/stack/index.ts +32 -0
  354. package/src/stack/redis.ts +44 -0
  355. package/src/stack/request-helper.ts +168 -0
  356. package/src/stack/table-helpers.ts +104 -0
  357. package/src/stack/test-stack.ts +357 -0
  358. package/src/stack/test-users.ts +37 -0
  359. package/src/testing/__tests__/e2e-generator.test.ts +230 -0
  360. package/src/testing/__tests__/ensure-entity-table.integration.ts +54 -0
  361. package/src/testing/access-assertions.ts +15 -0
  362. package/src/testing/assertions.ts +35 -0
  363. package/src/testing/e2e-generator.ts +465 -0
  364. package/src/testing/expect-error.ts +25 -0
  365. package/src/testing/handler-context.ts +125 -0
  366. package/src/testing/http-cookies.ts +52 -0
  367. package/src/testing/index.ts +41 -0
  368. package/src/testing/late-bound.ts +39 -0
  369. package/src/testing/mutable-master-key-provider.ts +31 -0
  370. package/src/testing/observability-recorder.ts +54 -0
  371. package/src/testing/shared-entities.ts +49 -0
  372. package/src/testing/utils.ts +1 -0
  373. package/src/testing/wait-for.ts +31 -0
  374. package/src/time/__tests__/polyfill.test.ts +73 -0
  375. package/src/time/__tests__/tz-context.test.ts +121 -0
  376. package/src/time/index.ts +21 -0
  377. package/src/time/polyfill.ts +70 -0
  378. package/src/time/tz-context.ts +107 -0
  379. package/src/ui-types/app-schema.ts +57 -0
  380. package/src/ui-types/index.ts +65 -0
  381. package/src/utils/__tests__/assert.test.ts +17 -0
  382. package/src/utils/__tests__/env-parse.test.ts +54 -0
  383. package/src/utils/assert.ts +18 -0
  384. package/src/utils/env-parse.ts +16 -0
  385. package/src/utils/ids.ts +16 -0
  386. package/src/utils/index.ts +5 -0
  387. package/src/utils/safe-json.ts +30 -0
  388. package/src/utils/serialization.ts +7 -0
@@ -0,0 +1,123 @@
1
+ import type { EntityId, TenantId } from "@cosmicdrift/kumiko-framework/engine";
2
+ import type { SearchAdapter, SearchAdapterConfig, SearchResult } from "./types";
3
+
4
+ type StoredDoc = {
5
+ entityType: string;
6
+ entityId: EntityId;
7
+ weight: number;
8
+ text: Record<string, string>;
9
+ };
10
+
11
+ type TenantIndex = {
12
+ config: SearchAdapterConfig;
13
+ docs: Map<string, StoredDoc>;
14
+ };
15
+
16
+ function docKey(entityType: string, entityId: EntityId): string {
17
+ return `${entityType}:${entityId}`;
18
+ }
19
+
20
+ export function createInMemorySearchAdapter(): SearchAdapter {
21
+ const tenants = new Map<string, TenantIndex>();
22
+
23
+ function getTenant(tenantId: TenantId): TenantIndex {
24
+ let tenant = tenants.get(tenantId);
25
+ if (!tenant) {
26
+ tenant = { config: { searchableFields: [] }, docs: new Map() };
27
+ tenants.set(tenantId, tenant);
28
+ }
29
+ return tenant;
30
+ }
31
+
32
+ return {
33
+ async configure(tenantId, config) {
34
+ const tenant = getTenant(tenantId);
35
+ tenant.config = config;
36
+ },
37
+
38
+ async index(tenantId, doc) {
39
+ const tenant = getTenant(tenantId);
40
+ const text: Record<string, string> = {};
41
+
42
+ for (const [key, value] of Object.entries(doc.fields)) {
43
+ if (value !== null && value !== undefined) {
44
+ text[key] = String(value).toLowerCase();
45
+ }
46
+ }
47
+
48
+ tenant.docs.set(docKey(doc.entityType, doc.entityId), {
49
+ entityType: doc.entityType,
50
+ entityId: doc.entityId,
51
+ weight: doc.weight,
52
+ text,
53
+ });
54
+ },
55
+
56
+ async search(tenantId, query, options) {
57
+ const tenant = tenants.get(tenantId);
58
+ if (!tenant) return [];
59
+
60
+ const q = query.toLowerCase();
61
+ const limit = options?.limit ?? 50;
62
+ const filterType = options?.filterType;
63
+ const rankingFields = tenant.config.rankingFields ?? tenant.config.searchableFields;
64
+
65
+ const scored: Array<{ result: SearchResult; score: number }> = [];
66
+
67
+ for (const doc of tenant.docs.values()) {
68
+ if (filterType && doc.entityType !== filterType) continue;
69
+
70
+ let matchScore = 0;
71
+ const fieldsToSearch =
72
+ tenant.config.searchableFields.length > 0
73
+ ? [...tenant.config.searchableFields]
74
+ : Object.keys(doc.text);
75
+
76
+ for (const fieldName of fieldsToSearch) {
77
+ const value = doc.text[fieldName];
78
+ if (!value?.includes(q)) continue;
79
+
80
+ // Field ranking: earlier in ranking = higher weight
81
+ const rankIndex = rankingFields.indexOf(fieldName);
82
+ const fieldWeight = rankIndex >= 0 ? (rankingFields.length - rankIndex) * 100 : 1;
83
+
84
+ const exactBonus = value === q ? 50 : 0;
85
+ const prefixBonus = value.startsWith(q) ? 25 : 0;
86
+
87
+ matchScore += fieldWeight + exactBonus + prefixBonus;
88
+ }
89
+
90
+ if (matchScore > 0) {
91
+ // Entity weight multiplier from searchWeight
92
+ const totalScore = matchScore * doc.weight;
93
+ scored.push({
94
+ result: { entityType: doc.entityType, entityId: doc.entityId },
95
+ score: totalScore,
96
+ });
97
+ }
98
+ }
99
+
100
+ scored.sort((a, b) => b.score - a.score);
101
+ return scored.slice(0, limit).map((s) => s.result);
102
+ },
103
+
104
+ async remove(tenantId, entityType, entityId) {
105
+ tenants.get(tenantId)?.docs.delete(docKey(entityType, entityId));
106
+ },
107
+
108
+ async indexBatch(tenantId, docs) {
109
+ for (const doc of docs) {
110
+ await this.index(tenantId, doc);
111
+ }
112
+ },
113
+
114
+ async removeBatch(tenantId, items) {
115
+ const tenant = tenants.get(tenantId);
116
+ // skip: tenant has no in-memory index (never configured) — nothing to remove
117
+ if (!tenant) return;
118
+ for (const item of items) {
119
+ tenant.docs.delete(docKey(item.entityType, item.entityId));
120
+ }
121
+ },
122
+ };
123
+ }
@@ -0,0 +1,12 @@
1
+ // Meilisearch-Adapter lebt im Sub-Path-Export `@cosmicdrift/kumiko-framework/search/meilisearch`.
2
+ // Damit lädt der Main-Barrel keinen Meilisearch-Client beim bloßen Anfassen
3
+ // von SearchAdapter-Types. Apps die Meilisearch nicht nutzen, ziehen den
4
+ // Client-Code nicht mit rein.
5
+ export { createInMemorySearchAdapter } from "./in-memory-adapter";
6
+ export type {
7
+ SearchAdapter,
8
+ SearchAdapterConfig,
9
+ SearchDocument,
10
+ SearchOptions,
11
+ SearchResult,
12
+ } from "./types";
@@ -0,0 +1,106 @@
1
+ import type { EntityId, TenantId } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { Meilisearch } from "meilisearch";
3
+ import type { SearchAdapter, SearchResult } from "./types";
4
+
5
+ export type MeilisearchAdapterOptions = {
6
+ url: string;
7
+ apiKey: string;
8
+ indexPrefix?: string;
9
+ };
10
+
11
+ function tenantIndex(prefix: string, tenantId: TenantId): string {
12
+ return `${prefix}t${tenantId}`;
13
+ }
14
+
15
+ // Meilisearch primary-key-ids: alphanumerics, `-`, `_`. UUIDs contain `-` —
16
+ // legal. Replace anything else just in case callers pass unexpected shapes.
17
+ function docId(entityType: string, entityId: EntityId): string {
18
+ return `${entityType}_${String(entityId).replace(/[^0-9A-Za-z_-]/g, "_")}`;
19
+ }
20
+
21
+ export function createMeilisearchAdapter(options: MeilisearchAdapterOptions): SearchAdapter {
22
+ const client = new Meilisearch({ host: options.url, apiKey: options.apiKey });
23
+ const prefix = options.indexPrefix ?? "kumiko_";
24
+
25
+ return {
26
+ async configure(tenantId, config) {
27
+ const index = client.index(tenantIndex(prefix, tenantId));
28
+ const fields = config.rankingFields ?? config.searchableFields;
29
+ await index.updateSearchableAttributes([...fields]).waitTask();
30
+ await index.updateFilterableAttributes(["_type", "_weight"]).waitTask();
31
+ await index.updateSortableAttributes(["_weight"]).waitTask();
32
+ },
33
+
34
+ async index(tenantId, doc) {
35
+ const index = client.index(tenantIndex(prefix, tenantId));
36
+ await index
37
+ .addDocuments(
38
+ [
39
+ {
40
+ _id: docId(doc.entityType, doc.entityId),
41
+ _type: doc.entityType,
42
+ _weight: doc.weight,
43
+ _entityId: doc.entityId,
44
+ ...doc.fields,
45
+ },
46
+ ],
47
+ { primaryKey: "_id" },
48
+ )
49
+ .waitTask();
50
+ },
51
+
52
+ async indexBatch(tenantId, docs) {
53
+ // skip: empty batch — avoid an unnecessary Meilisearch round-trip
54
+ if (docs.length === 0) return;
55
+ const index = client.index(tenantIndex(prefix, tenantId));
56
+ const payload = docs.map((doc) => ({
57
+ _id: docId(doc.entityType, doc.entityId),
58
+ _type: doc.entityType,
59
+ _weight: doc.weight,
60
+ _entityId: doc.entityId,
61
+ ...doc.fields,
62
+ }));
63
+ // Single Meilisearch task covering all N docs. Meilisearch processes
64
+ // the payload server-side as one indexing job — waitTask blocks until
65
+ // that job is done, but it's one round-trip instead of N.
66
+ await index.addDocuments(payload, { primaryKey: "_id" }).waitTask();
67
+ },
68
+
69
+ async removeBatch(tenantId, items) {
70
+ // skip: empty batch — avoid an unnecessary Meilisearch round-trip
71
+ if (items.length === 0) return;
72
+ const index = client.index(tenantIndex(prefix, tenantId));
73
+ const ids = items.map((i) => docId(i.entityType, i.entityId));
74
+ await index.deleteDocuments(ids).waitTask();
75
+ },
76
+
77
+ async search(tenantId, query, options) {
78
+ const index = client.index(tenantIndex(prefix, tenantId));
79
+
80
+ const filter: string[] = [];
81
+ if (options?.filterType) {
82
+ filter.push(`_type = "${options.filterType}"`);
83
+ }
84
+
85
+ const searchParams: Record<string, unknown> = {
86
+ limit: options?.limit ?? 50,
87
+ sort: ["_weight:desc"],
88
+ };
89
+ if (filter.length > 0) searchParams["filter"] = filter;
90
+
91
+ const results = await index.search(query, searchParams);
92
+
93
+ return results.hits.map(
94
+ (hit: Record<string, unknown>): SearchResult => ({
95
+ entityType: hit["_type"] as string,
96
+ entityId: hit["_entityId"] as EntityId,
97
+ }),
98
+ );
99
+ },
100
+
101
+ async remove(tenantId, entityType, entityId) {
102
+ const index = client.index(tenantIndex(prefix, tenantId));
103
+ await index.deleteDocument(docId(entityType, entityId)).waitTask();
104
+ },
105
+ };
106
+ }
@@ -0,0 +1,39 @@
1
+ import type { EntityId, TenantId } from "@cosmicdrift/kumiko-framework/engine";
2
+
3
+ export type SearchAdapterConfig = {
4
+ searchableFields: readonly string[];
5
+ rankingFields?: readonly string[];
6
+ };
7
+
8
+ export type SearchDocument = {
9
+ entityType: string;
10
+ entityId: EntityId;
11
+ weight: number;
12
+ fields: Record<string, unknown>;
13
+ };
14
+
15
+ export type SearchResult = {
16
+ entityType: string;
17
+ entityId: EntityId;
18
+ };
19
+
20
+ export type SearchOptions = {
21
+ limit?: number;
22
+ filterType?: string;
23
+ };
24
+
25
+ export type SearchAdapter = {
26
+ configure(tenantId: TenantId, config: SearchAdapterConfig): Promise<void>;
27
+ index(tenantId: TenantId, doc: SearchDocument): Promise<void>;
28
+ search(tenantId: TenantId, query: string, options?: SearchOptions): Promise<SearchResult[]>;
29
+ remove(tenantId: TenantId, entityType: string, entityId: EntityId): Promise<void>;
30
+ // Bulk variants. Default implementations loop over the single-doc methods —
31
+ // adapters should override when the backend supports a real batch call
32
+ // (Meilisearch, Elasticsearch, Typesense all do). Cuts a batch-write from
33
+ // N sequential HTTP + waitTask round-trips to one.
34
+ indexBatch?(tenantId: TenantId, docs: readonly SearchDocument[]): Promise<void>;
35
+ removeBatch?(
36
+ tenantId: TenantId,
37
+ items: readonly { entityType: string; entityId: EntityId }[],
38
+ ): Promise<void>;
39
+ };
@@ -0,0 +1,213 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { describe, expect, test, vi } from "vitest";
3
+ import { createDekCache } from "../dek-cache";
4
+ import type { MasterKeyProvider } from "../types";
5
+
6
+ // Minimal stub provider — counts unwrap calls so we can observe caching.
7
+ function makeCountingProvider(): MasterKeyProvider & { unwrapCallCount: () => number } {
8
+ let calls = 0;
9
+ return {
10
+ unwrapDek: async (_encryptedDek, _version) => {
11
+ calls++;
12
+ return randomBytes(32);
13
+ },
14
+ wrapDek: async () => ({ encryptedDek: randomBytes(60), kekVersion: 1 }),
15
+ currentVersion: () => 1,
16
+ isAvailable: async () => true,
17
+ unwrapCallCount: () => calls,
18
+ };
19
+ }
20
+
21
+ describe("DekCache", () => {
22
+ test("second read within TTL is served from cache (no provider call)", async () => {
23
+ const provider = makeCountingProvider();
24
+ const cache = createDekCache({ ttlMs: 1000 });
25
+ const encryptedDek = Buffer.from("some-wrapped-dek-bytes");
26
+
27
+ await cache.unwrapDek(encryptedDek, 1, provider);
28
+ expect(provider.unwrapCallCount()).toBe(1);
29
+
30
+ await cache.unwrapDek(encryptedDek, 1, provider);
31
+ await cache.unwrapDek(encryptedDek, 1, provider);
32
+ expect(provider.unwrapCallCount()).toBe(1);
33
+ });
34
+
35
+ test("after TTL expiry, provider is called again", async () => {
36
+ let t = 1_000_000;
37
+ const provider = makeCountingProvider();
38
+ const cache = createDekCache({ ttlMs: 1000, now: () => t });
39
+ const encryptedDek = Buffer.from("wrapped");
40
+
41
+ await cache.unwrapDek(encryptedDek, 1, provider);
42
+ expect(provider.unwrapCallCount()).toBe(1);
43
+
44
+ t += 1500; // past TTL
45
+ await cache.unwrapDek(encryptedDek, 1, provider);
46
+ expect(provider.unwrapCallCount()).toBe(2);
47
+ });
48
+
49
+ test("different kekVersion entries do not collide", async () => {
50
+ const provider = makeCountingProvider();
51
+ const cache = createDekCache({ ttlMs: 60_000 });
52
+ const encryptedDek = Buffer.from("same-wrapped-payload");
53
+
54
+ // Same encryptedDek bytes at two different versions — must be two
55
+ // distinct cache entries, never confuse one for the other.
56
+ await cache.unwrapDek(encryptedDek, 1, provider);
57
+ await cache.unwrapDek(encryptedDek, 2, provider);
58
+ expect(provider.unwrapCallCount()).toBe(2);
59
+ expect(cache.size()).toBe(2);
60
+ });
61
+
62
+ test("returned DEK is a defensive copy (caller can zero without affecting cache)", async () => {
63
+ const provider = makeCountingProvider();
64
+ const cache = createDekCache({ ttlMs: 60_000 });
65
+ const encryptedDek = Buffer.from("payload");
66
+
67
+ const first = await cache.unwrapDek(encryptedDek, 1, provider);
68
+ first.fill(0);
69
+
70
+ // Second call should still produce a non-zero DEK, served from cache.
71
+ const second = await cache.unwrapDek(encryptedDek, 1, provider);
72
+ expect(second.some((b) => b !== 0)).toBe(true);
73
+ // Only one underlying provider unwrap despite the zeroing.
74
+ expect(provider.unwrapCallCount()).toBe(1);
75
+ });
76
+
77
+ test("clear() drops all entries and forces next read back through the provider", async () => {
78
+ const provider = makeCountingProvider();
79
+ const cache = createDekCache({ ttlMs: 60_000 });
80
+ const encryptedDek = Buffer.from("payload");
81
+
82
+ await cache.unwrapDek(encryptedDek, 1, provider);
83
+ expect(cache.size()).toBe(1);
84
+
85
+ cache.clear();
86
+ expect(cache.size()).toBe(0);
87
+
88
+ await cache.unwrapDek(encryptedDek, 1, provider);
89
+ expect(provider.unwrapCallCount()).toBe(2);
90
+ });
91
+
92
+ test("clear() zeros the cached DEK bytes before dropping", async () => {
93
+ const dek = randomBytes(32);
94
+ const captured = Buffer.from(dek); // original reference for comparison
95
+ const provider: MasterKeyProvider = {
96
+ unwrapDek: async () => dek,
97
+ wrapDek: async () => ({ encryptedDek: randomBytes(60), kekVersion: 1 }),
98
+ currentVersion: () => 1,
99
+ isAvailable: async () => true,
100
+ };
101
+ const cache = createDekCache({ ttlMs: 60_000 });
102
+ // Populate via the actual unwrap path so the cache takes its own copy.
103
+ const dummyReturn = await cache.unwrapDek(Buffer.from("x"), 1, provider);
104
+ // The defensive copy means dek the provider returned is distinct from
105
+ // what the cache stored; we spy on the cache side via vi.
106
+ void dummyReturn;
107
+ void captured;
108
+ cache.clear();
109
+ // Post-clear we can't easily inspect the zeroed internal buffer from
110
+ // outside — but size() going to 0 plus the no-throw behaviour confirms
111
+ // the code path runs. The real security property (no stale bytes in
112
+ // heap snapshots) can only be observed with a debugger.
113
+ expect(cache.size()).toBe(0);
114
+ });
115
+
116
+ test("expired entry is pruned on next miss to bound memory", async () => {
117
+ let t = 1_000_000;
118
+ const provider = makeCountingProvider();
119
+ const cache = createDekCache({ ttlMs: 100, now: () => t });
120
+
121
+ await cache.unwrapDek(Buffer.from("a"), 1, provider);
122
+ expect(cache.size()).toBe(1);
123
+
124
+ t += 200; // past TTL
125
+ await cache.unwrapDek(Buffer.from("a"), 1, provider);
126
+ // Still size 1, but it's the fresh entry — not a leak of the expired one.
127
+ expect(cache.size()).toBe(1);
128
+ });
129
+
130
+ test("concurrent same-key calls both go through to provider in v1 (no request coalescing)", async () => {
131
+ // Documenting current behaviour: we don't dedupe in-flight unwraps.
132
+ // Two simultaneous reads of the same encryptedDek will each hit the
133
+ // provider. Fine for v1 — real-world collision risk is low and the
134
+ // cost is "one extra provider call" per burst. v2 could add a
135
+ // promise-cache to coalesce.
136
+ const provider = makeCountingProvider();
137
+ const cache = createDekCache({ ttlMs: 60_000 });
138
+ const ed = Buffer.from("concurrent");
139
+ await Promise.all([cache.unwrapDek(ed, 1, provider), cache.unwrapDek(ed, 1, provider)]);
140
+ expect(provider.unwrapCallCount()).toBe(2);
141
+ });
142
+
143
+ test("defaults TTL is 5 minutes when not overridden", async () => {
144
+ // Sanity on the documented default — a regression where someone tweaks
145
+ // DEFAULT_TTL_MS would silently change the security posture.
146
+ const provider = makeCountingProvider();
147
+ let t = 0;
148
+ const cache = createDekCache({ now: () => t });
149
+ await cache.unwrapDek(Buffer.from("x"), 1, provider);
150
+ t = 4 * 60 * 1000; // 4 min in
151
+ await cache.unwrapDek(Buffer.from("x"), 1, provider);
152
+ expect(provider.unwrapCallCount()).toBe(1);
153
+ t = 5 * 60 * 1000 + 1; // just past 5 min
154
+ await cache.unwrapDek(Buffer.from("x"), 1, provider);
155
+ expect(provider.unwrapCallCount()).toBe(2);
156
+ // avoid vitest unused-import warning
157
+ void vi;
158
+ });
159
+
160
+ test("LRU: evicts oldest when maxEntries is reached", async () => {
161
+ const provider = makeCountingProvider();
162
+ const cache = createDekCache({ maxEntries: 3, ttlMs: 60_000 });
163
+ // Fill the cache.
164
+ await cache.unwrapDek(Buffer.from("A"), 1, provider);
165
+ await cache.unwrapDek(Buffer.from("B"), 1, provider);
166
+ await cache.unwrapDek(Buffer.from("C"), 1, provider);
167
+ expect(cache.size()).toBe(3);
168
+ expect(provider.unwrapCallCount()).toBe(3);
169
+
170
+ // Insert a fourth — A (oldest) must be evicted. Size stays at cap.
171
+ await cache.unwrapDek(Buffer.from("D"), 1, provider);
172
+ expect(cache.size()).toBe(3);
173
+ expect(provider.unwrapCallCount()).toBe(4);
174
+
175
+ // Re-request A — cache miss because it was evicted, provider is called.
176
+ await cache.unwrapDek(Buffer.from("A"), 1, provider);
177
+ expect(provider.unwrapCallCount()).toBe(5);
178
+
179
+ // Re-request D — cache hit, no new provider call.
180
+ await cache.unwrapDek(Buffer.from("D"), 1, provider);
181
+ expect(provider.unwrapCallCount()).toBe(5);
182
+ });
183
+
184
+ test("LRU: touching an entry moves it to the 'most recent' end", async () => {
185
+ const provider = makeCountingProvider();
186
+ const cache = createDekCache({ maxEntries: 3, ttlMs: 60_000 });
187
+ await cache.unwrapDek(Buffer.from("A"), 1, provider);
188
+ await cache.unwrapDek(Buffer.from("B"), 1, provider);
189
+ await cache.unwrapDek(Buffer.from("C"), 1, provider);
190
+ // Touch A — now B is the oldest.
191
+ await cache.unwrapDek(Buffer.from("A"), 1, provider);
192
+ expect(provider.unwrapCallCount()).toBe(3); // A was a cache hit
193
+
194
+ // Insert D — evicts B (oldest after the touch), not A.
195
+ await cache.unwrapDek(Buffer.from("D"), 1, provider);
196
+ // A still cached
197
+ await cache.unwrapDek(Buffer.from("A"), 1, provider);
198
+ expect(provider.unwrapCallCount()).toBe(4);
199
+ // B was evicted
200
+ await cache.unwrapDek(Buffer.from("B"), 1, provider);
201
+ expect(provider.unwrapCallCount()).toBe(5);
202
+ });
203
+
204
+ test("default maxEntries is 1000 so burst-cost is bounded", async () => {
205
+ const provider = makeCountingProvider();
206
+ const cache = createDekCache();
207
+ // Insert 1001 unique entries — size must cap at 1000.
208
+ for (let i = 0; i < 1001; i++) {
209
+ await cache.unwrapDek(Buffer.from(`key-${i}`), 1, provider);
210
+ }
211
+ expect(cache.size()).toBe(1000);
212
+ });
213
+ });
@@ -0,0 +1,119 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { describe, expect, test } from "vitest";
3
+ import { createEnvMasterKeyProvider } from "../env-master-key-provider";
4
+
5
+ function env(vars: Record<string, string>): Record<string, string> {
6
+ return vars;
7
+ }
8
+
9
+ describe("EnvMasterKeyProvider — keyring loading", () => {
10
+ test("accepts a single-version keyring", () => {
11
+ const key = randomBytes(32).toString("base64");
12
+ const provider = createEnvMasterKeyProvider({
13
+ env: env({
14
+ KUMIKO_SECRETS_MASTER_KEY_V1: key,
15
+ KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION: "1",
16
+ }),
17
+ });
18
+ expect(provider.currentVersion()).toBe(1);
19
+ });
20
+
21
+ test("accepts multi-version keyring and respects CURRENT_VERSION override", () => {
22
+ const provider = createEnvMasterKeyProvider({
23
+ env: env({
24
+ KUMIKO_SECRETS_MASTER_KEY_V1: randomBytes(32).toString("base64"),
25
+ KUMIKO_SECRETS_MASTER_KEY_V2: randomBytes(32).toString("base64"),
26
+ KUMIKO_SECRETS_MASTER_KEY_V3: randomBytes(32).toString("base64"),
27
+ // Explicit: even though V3 exists, V2 stays active until ops flips this.
28
+ KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION: "2",
29
+ }),
30
+ });
31
+ expect(provider.currentVersion()).toBe(2);
32
+ });
33
+
34
+ test("rejects boot when no KEK is set", () => {
35
+ expect(() =>
36
+ createEnvMasterKeyProvider({
37
+ env: env({ KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION: "1" }),
38
+ }),
39
+ ).toThrow(/no KEK found/);
40
+ });
41
+
42
+ test("rejects boot when CURRENT_VERSION is missing", () => {
43
+ expect(() =>
44
+ createEnvMasterKeyProvider({
45
+ env: env({ KUMIKO_SECRETS_MASTER_KEY_V1: randomBytes(32).toString("base64") }),
46
+ }),
47
+ ).toThrow(/CURRENT_VERSION not set/);
48
+ });
49
+
50
+ test("rejects boot when CURRENT_VERSION points to an absent KEK", () => {
51
+ expect(() =>
52
+ createEnvMasterKeyProvider({
53
+ env: env({
54
+ KUMIKO_SECRETS_MASTER_KEY_V1: randomBytes(32).toString("base64"),
55
+ KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION: "7",
56
+ }),
57
+ }),
58
+ ).toThrow(/currentVersion=7 not present/);
59
+ });
60
+
61
+ test("rejects KEK with wrong byte length (not AES-256)", () => {
62
+ expect(() =>
63
+ createEnvMasterKeyProvider({
64
+ env: env({
65
+ // 16 bytes = AES-128 key, not what we want
66
+ KUMIKO_SECRETS_MASTER_KEY_V1: randomBytes(16).toString("base64"),
67
+ KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION: "1",
68
+ }),
69
+ }),
70
+ ).toThrow(/exactly 32 bytes/);
71
+ });
72
+
73
+ test("rejects non-numeric CURRENT_VERSION", () => {
74
+ expect(() =>
75
+ createEnvMasterKeyProvider({
76
+ env: env({
77
+ KUMIKO_SECRETS_MASTER_KEY_V1: randomBytes(32).toString("base64"),
78
+ KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION: "latest",
79
+ }),
80
+ }),
81
+ ).toThrow(/must be a positive integer/);
82
+ });
83
+ });
84
+
85
+ describe("EnvMasterKeyProvider — wrap/unwrap", () => {
86
+ test("wrap then unwrap round-trips the DEK bytes", async () => {
87
+ const provider = createEnvMasterKeyProvider({
88
+ env: env({
89
+ KUMIKO_SECRETS_MASTER_KEY_V1: randomBytes(32).toString("base64"),
90
+ KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION: "1",
91
+ }),
92
+ });
93
+ const dek = randomBytes(32);
94
+ const wrapped = await provider.wrapDek(dek);
95
+ expect(wrapped.kekVersion).toBe(1);
96
+ const unwrapped = await provider.unwrapDek(wrapped.encryptedDek, wrapped.kekVersion);
97
+ expect(unwrapped.equals(dek)).toBe(true);
98
+ });
99
+
100
+ test("unwrap throws for an unknown kekVersion with a clear error", async () => {
101
+ const provider = createEnvMasterKeyProvider({
102
+ env: env({
103
+ KUMIKO_SECRETS_MASTER_KEY_V1: randomBytes(32).toString("base64"),
104
+ KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION: "1",
105
+ }),
106
+ });
107
+ await expect(provider.unwrapDek(Buffer.alloc(60), 99)).rejects.toThrow(/no KEK for version 99/);
108
+ });
109
+
110
+ test("isAvailable returns true after successful boot", async () => {
111
+ const provider = createEnvMasterKeyProvider({
112
+ env: env({
113
+ KUMIKO_SECRETS_MASTER_KEY_V1: randomBytes(32).toString("base64"),
114
+ KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION: "1",
115
+ }),
116
+ });
117
+ expect(await provider.isAvailable()).toBe(true);
118
+ });
119
+ });
@@ -0,0 +1,74 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { describe, expect, test } from "vitest";
3
+ import { createEnvMasterKeyProvider } from "../env-master-key-provider";
4
+ import { decryptValue, encryptValue } from "../envelope";
5
+
6
+ function makeEnv(versions: Record<number, Buffer>, currentVersion: number): Record<string, string> {
7
+ const env: Record<string, string> = {
8
+ KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION: String(currentVersion),
9
+ };
10
+ for (const [v, key] of Object.entries(versions)) {
11
+ env[`KUMIKO_SECRETS_MASTER_KEY_V${v}`] = key.toString("base64");
12
+ }
13
+ return env;
14
+ }
15
+
16
+ describe("envelope — encryptValue/decryptValue", () => {
17
+ test("round-trips plaintext correctly", async () => {
18
+ const provider = createEnvMasterKeyProvider({
19
+ env: makeEnv({ 1: randomBytes(32) }, 1),
20
+ });
21
+ const envelope = await encryptValue("hello world", provider);
22
+ const back = await decryptValue(envelope, provider);
23
+ expect(back).toBe("hello world");
24
+ });
25
+
26
+ test("every encryption produces a distinct IV and ciphertext for the same plaintext", async () => {
27
+ const provider = createEnvMasterKeyProvider({
28
+ env: makeEnv({ 1: randomBytes(32) }, 1),
29
+ });
30
+ const a = await encryptValue("same", provider);
31
+ const b = await encryptValue("same", provider);
32
+ // Different IVs → different ciphertexts, even for the same plaintext.
33
+ // This is a GCM security requirement: reusing (key, IV) breaks the cipher.
34
+ expect(a.iv.equals(b.iv)).toBe(false);
35
+ expect(a.ciphertext.equals(b.ciphertext)).toBe(false);
36
+ });
37
+
38
+ test("round-trips UTF-8 content including emoji", async () => {
39
+ const provider = createEnvMasterKeyProvider({
40
+ env: makeEnv({ 1: randomBytes(32) }, 1),
41
+ });
42
+ const envelope = await encryptValue("🔐 Geheim — mit Umlauten ä ö ü ß", provider);
43
+ expect(await decryptValue(envelope, provider)).toBe("🔐 Geheim — mit Umlauten ä ö ü ß");
44
+ });
45
+
46
+ test("tampered ciphertext fails auth-tag check", async () => {
47
+ const provider = createEnvMasterKeyProvider({
48
+ env: makeEnv({ 1: randomBytes(32) }, 1),
49
+ });
50
+ const envelope = await encryptValue("integrity matters", provider);
51
+ // Flip one byte of the ciphertext. GCM auth tag must reject.
52
+ const tampered = {
53
+ ...envelope,
54
+ ciphertext: Buffer.concat([
55
+ envelope.ciphertext.subarray(0, 1),
56
+ Buffer.from([envelope.ciphertext[0]! ^ 0xff]),
57
+ envelope.ciphertext.subarray(2),
58
+ ]),
59
+ };
60
+ await expect(decryptValue(tampered, provider)).rejects.toThrow();
61
+ });
62
+
63
+ test("tampered authTag is rejected", async () => {
64
+ const provider = createEnvMasterKeyProvider({
65
+ env: makeEnv({ 1: randomBytes(32) }, 1),
66
+ });
67
+ const envelope = await encryptValue("auth me", provider);
68
+ const tampered = {
69
+ ...envelope,
70
+ authTag: Buffer.alloc(envelope.authTag.length, 0),
71
+ };
72
+ await expect(decryptValue(tampered, provider)).rejects.toThrow();
73
+ });
74
+ });