@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,376 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { createCipheriv, randomBytes } from "node:crypto";
3
+ import { createEntity, createTextField } from "../../engine/factories";
4
+ import { InMemoryKmsAdapter } from "../in-memory-kms-adapter";
5
+ import {
6
+ KeyErasedError,
7
+ KeyNotFoundError,
8
+ type KmsContext,
9
+ subjectIdFromKey,
10
+ subjectIdToKey,
11
+ } from "../kms-adapter";
12
+ import {
13
+ decryptPiiFieldValues,
14
+ decryptPiiValueForSubject,
15
+ encryptPiiFieldValues,
16
+ encryptPiiValueForSubject,
17
+ isPiiCiphertext,
18
+ PII_CIPHERTEXT_PREFIX,
19
+ PII_ERASED_SENTINEL,
20
+ } from "../pii-field-encryption";
21
+ import { collectPiiSubjectFields } from "../subject-resolver";
22
+
23
+ const UUID_A = "6b2f4a0e-1c9d-4f3a-9d2e-00000000000a";
24
+ const UUID_B = "6b2f4a0e-1c9d-4f3a-9d2e-00000000000b";
25
+ const KMS_CTX: KmsContext = { requestId: "test" };
26
+
27
+ const userLikeEntity = createEntity({
28
+ fields: {
29
+ email: createTextField({ required: true, pii: true }),
30
+ role: createTextField(),
31
+ },
32
+ table: "pii_users",
33
+ });
34
+
35
+ const commentEntity = createEntity({
36
+ fields: {
37
+ body: createTextField({ userOwned: { ownerField: "authorId" } }),
38
+ authorId: createTextField({ required: true }),
39
+ },
40
+ table: "pii_comments",
41
+ });
42
+
43
+ const brandingEntity = createEntity({
44
+ fields: {
45
+ brandColor: createTextField({ tenantOwned: true }),
46
+ },
47
+ table: "pii_branding",
48
+ });
49
+
50
+ describe("subjectIdFromKey", () => {
51
+ test("round-trips user and tenant keys, rejects garbage", () => {
52
+ expect(subjectIdFromKey(`user:${UUID_A}`)).toEqual({ kind: "user", userId: UUID_A });
53
+ expect(subjectIdFromKey(`tenant:${UUID_B}`)).toEqual({ kind: "tenant", tenantId: UUID_B });
54
+ expect(() => subjectIdFromKey("widget:x")).toThrow(/Invalid subject key/);
55
+ });
56
+ });
57
+
58
+ describe("encryptPiiFieldValues / decryptPiiFieldValues", () => {
59
+ test("pii self-subject: DB value is sniffable ciphertext, decrypt restores plaintext", async () => {
60
+ const kms = new InMemoryKmsAdapter();
61
+ const fields = collectPiiSubjectFields(userLikeEntity);
62
+ const row = { id: UUID_A, email: "marc@example.com", role: "admin" };
63
+
64
+ const stored = await encryptPiiFieldValues(row, userLikeEntity, fields, kms, KMS_CTX);
65
+ expect(isPiiCiphertext(stored["email"])).toBe(true);
66
+ expect(String(stored["email"])).toStartWith(`${PII_CIPHERTEXT_PREFIX}user:${UUID_A}:`);
67
+ expect(stored["role"]).toBe("admin");
68
+ expect(row["email"]).toBe("marc@example.com");
69
+
70
+ const read = await decryptPiiFieldValues(stored, fields, kms, KMS_CTX);
71
+ expect(read["email"]).toBe("marc@example.com");
72
+ });
73
+
74
+ test("first write auto-creates the subject key", async () => {
75
+ const kms = new InMemoryKmsAdapter();
76
+ await expect(kms.getKey({ kind: "user", userId: UUID_A })).rejects.toBeInstanceOf(
77
+ KeyNotFoundError,
78
+ );
79
+ await encryptPiiFieldValues(
80
+ { id: UUID_A, email: "a@b.c" },
81
+ userLikeEntity,
82
+ ["email"],
83
+ kms,
84
+ KMS_CTX,
85
+ );
86
+ const dek = await kms.getKey({ kind: "user", userId: UUID_A });
87
+ expect(dek.length).toBe(32);
88
+ });
89
+
90
+ test("userOwned: ciphertext names the owner subject, not the row", async () => {
91
+ const kms = new InMemoryKmsAdapter();
92
+ const stored = await encryptPiiFieldValues(
93
+ { id: UUID_A, body: "secret note", authorId: UUID_B },
94
+ commentEntity,
95
+ ["body"],
96
+ kms,
97
+ KMS_CTX,
98
+ );
99
+ expect(String(stored["body"])).toStartWith(`${PII_CIPHERTEXT_PREFIX}user:${UUID_B}:`);
100
+ });
101
+
102
+ test("tenantOwned without tenantId column falls back to write-time tenant", async () => {
103
+ const kms = new InMemoryKmsAdapter();
104
+ const stored = await encryptPiiFieldValues(
105
+ { id: UUID_A, brandColor: "#ff0000" },
106
+ brandingEntity,
107
+ ["brandColor"],
108
+ kms,
109
+ KMS_CTX,
110
+ { tenantId: UUID_B },
111
+ );
112
+ expect(String(stored["brandColor"])).toStartWith(`${PII_CIPHERTEXT_PREFIX}tenant:${UUID_B}:`);
113
+ });
114
+
115
+ test("subjectSource resolves the owner when the partial row lacks it (update changes)", async () => {
116
+ const kms = new InMemoryKmsAdapter();
117
+ const changes = { body: "edited" };
118
+ const stored = await encryptPiiFieldValues(changes, commentEntity, ["body"], kms, KMS_CTX, {
119
+ onlyKeys: ["body"],
120
+ subjectSource: { id: UUID_A, body: "edited", authorId: UUID_B },
121
+ });
122
+ expect(String(stored["body"])).toStartWith(`${PII_CIPHERTEXT_PREFIX}user:${UUID_B}:`);
123
+ });
124
+
125
+ test("erased subject: decrypt yields the sentinel, re-encrypt passes it through", async () => {
126
+ const kms = new InMemoryKmsAdapter();
127
+ const fields = ["email"] as const;
128
+ const stored = await encryptPiiFieldValues(
129
+ { id: UUID_A, email: "gone@example.com" },
130
+ userLikeEntity,
131
+ fields,
132
+ kms,
133
+ KMS_CTX,
134
+ );
135
+ await kms.eraseKey({ kind: "user", userId: UUID_A });
136
+
137
+ const read = await decryptPiiFieldValues(stored, fields, kms, KMS_CTX);
138
+ expect(read["email"]).toBe(PII_ERASED_SENTINEL);
139
+
140
+ const reStored = await encryptPiiFieldValues(read, userLikeEntity, fields, kms, KMS_CTX);
141
+ expect(reStored["email"]).toBe(PII_ERASED_SENTINEL);
142
+ });
143
+
144
+ test("write to an erased subject fails (forget is permanent)", async () => {
145
+ const kms = new InMemoryKmsAdapter();
146
+ await kms.createKey({ kind: "user", userId: UUID_A });
147
+ await kms.eraseKey({ kind: "user", userId: UUID_A });
148
+ await expect(
149
+ encryptPiiFieldValues(
150
+ { id: UUID_A, email: "new@b.c" },
151
+ userLikeEntity,
152
+ ["email"],
153
+ kms,
154
+ KMS_CTX,
155
+ ),
156
+ ).rejects.toThrow(/erased/);
157
+ });
158
+
159
+ test("legacy plaintext rows pass through decrypt unchanged", async () => {
160
+ const kms = new InMemoryKmsAdapter();
161
+ const read = await decryptPiiFieldValues(
162
+ { id: UUID_A, email: "legacy@example.com" },
163
+ ["email"],
164
+ kms,
165
+ KMS_CTX,
166
+ );
167
+ expect(read["email"]).toBe("legacy@example.com");
168
+ });
169
+
170
+ test("already-ciphertext values are not double-encrypted (re-encrypt paths)", async () => {
171
+ const kms = new InMemoryKmsAdapter();
172
+ const once = await encryptPiiFieldValues(
173
+ { id: UUID_A, email: "a@b.c" },
174
+ userLikeEntity,
175
+ ["email"],
176
+ kms,
177
+ KMS_CTX,
178
+ );
179
+ const twice = await encryptPiiFieldValues(once, userLikeEntity, ["email"], kms, KMS_CTX);
180
+ expect(twice["email"]).toBe(once["email"]);
181
+ });
182
+
183
+ test("null values and fields outside onlyKeys are skipped", async () => {
184
+ const kms = new InMemoryKmsAdapter();
185
+ const stored = await encryptPiiFieldValues(
186
+ { id: UUID_A, email: null, role: "x" },
187
+ userLikeEntity,
188
+ ["email"],
189
+ kms,
190
+ KMS_CTX,
191
+ );
192
+ expect(stored["email"]).toBeNull();
193
+
194
+ const skipped = await encryptPiiFieldValues(
195
+ { id: UUID_A, email: "a@b.c" },
196
+ userLikeEntity,
197
+ ["email"],
198
+ kms,
199
+ KMS_CTX,
200
+ { onlyKeys: ["role"] },
201
+ );
202
+ expect(skipped["email"]).toBe("a@b.c");
203
+ });
204
+
205
+ test("non-string pii value throws", async () => {
206
+ const kms = new InMemoryKmsAdapter();
207
+ await expect(
208
+ encryptPiiFieldValues({ id: UUID_A, email: 42 }, userLikeEntity, ["email"], kms, KMS_CTX),
209
+ ).rejects.toThrow(/must be a string/);
210
+ });
211
+
212
+ test("ciphertext without a key row fails loud (wrong key store, not shredded)", async () => {
213
+ const kmsA = new InMemoryKmsAdapter();
214
+ const stored = await encryptPiiFieldValues(
215
+ { id: UUID_A, email: "a@b.c" },
216
+ userLikeEntity,
217
+ ["email"],
218
+ kmsA,
219
+ KMS_CTX,
220
+ );
221
+ const kmsB = new InMemoryKmsAdapter();
222
+ await expect(decryptPiiFieldValues(stored, ["email"], kmsB, KMS_CTX)).rejects.toBeInstanceOf(
223
+ KeyNotFoundError,
224
+ );
225
+ });
226
+ });
227
+
228
+ describe("piiEncrypted alias (kumiko-platform#457)", () => {
229
+ test("piiEncrypted + tenantOwned round-trips through the same subject-KMS pipeline", async () => {
230
+ const kms = new InMemoryKmsAdapter();
231
+ const brandingWithAccess = createEntity({
232
+ fields: {
233
+ iban: createTextField({
234
+ piiEncrypted: true,
235
+ tenantOwned: true,
236
+ access: { read: ["TenantAdmin"] },
237
+ }),
238
+ },
239
+ table: "pii_branding_iban",
240
+ });
241
+ const fields = collectPiiSubjectFields(brandingWithAccess);
242
+ expect(fields).toEqual(["iban"]);
243
+
244
+ const row = { id: UUID_A, tenantId: UUID_B, iban: "DE89370400440532013000" };
245
+ const stored = await encryptPiiFieldValues(row, brandingWithAccess, fields, kms, KMS_CTX);
246
+ expect(isPiiCiphertext(stored["iban"])).toBe(true);
247
+ expect(String(stored["iban"])).toStartWith(`${PII_CIPHERTEXT_PREFIX}tenant:${UUID_B}:`);
248
+
249
+ const read = await decryptPiiFieldValues(stored, fields, kms, KMS_CTX);
250
+ expect(read["iban"]).toBe("DE89370400440532013000");
251
+ });
252
+
253
+ test("piiEncrypted field is covered by subject erasure (Art. 17, kumiko-platform#461)", async () => {
254
+ const kms = new InMemoryKmsAdapter();
255
+ const brandingWithAccess = createEntity({
256
+ fields: {
257
+ iban: createTextField({ piiEncrypted: true, tenantOwned: true }),
258
+ },
259
+ table: "pii_branding_iban_erasure",
260
+ });
261
+ const fields = collectPiiSubjectFields(brandingWithAccess);
262
+ const row = { id: UUID_A, tenantId: UUID_B, iban: "DE89370400440532013000" };
263
+ const stored = await encryptPiiFieldValues(row, brandingWithAccess, fields, kms, KMS_CTX);
264
+
265
+ // Erasure is subject-keyed, not field-flag-keyed — kms.eraseKey doesn't
266
+ // know or care that this field is piiEncrypted vs. plain tenantOwned.
267
+ await kms.eraseKey({ kind: "tenant", tenantId: UUID_B });
268
+
269
+ const read = await decryptPiiFieldValues(stored, fields, kms, KMS_CTX);
270
+ expect(read["iban"]).toBe(PII_ERASED_SENTINEL);
271
+ });
272
+ });
273
+
274
+ describe("encryptPiiValueForSubject / decryptPiiValueForSubject (kumiko-platform#459)", () => {
275
+ test("round-trips a single value for a tenant subject", async () => {
276
+ const kms = new InMemoryKmsAdapter();
277
+ const subject = { kind: "tenant" as const, tenantId: UUID_B };
278
+ const stored = await encryptPiiValueForSubject(
279
+ kms,
280
+ subject,
281
+ "DE89370400440532013000",
282
+ KMS_CTX,
283
+ "iban",
284
+ );
285
+ expect(isPiiCiphertext(stored)).toBe(true);
286
+ expect(stored).toStartWith(`${PII_CIPHERTEXT_PREFIX}tenant:${UUID_B}:`);
287
+
288
+ const read = await decryptPiiValueForSubject(kms, stored, KMS_CTX, "iban");
289
+ expect(read).toBe("DE89370400440532013000");
290
+ });
291
+
292
+ test("erased subject: decrypt yields the sentinel", async () => {
293
+ const kms = new InMemoryKmsAdapter();
294
+ const subject = { kind: "user" as const, userId: UUID_A };
295
+ const stored = await encryptPiiValueForSubject(
296
+ kms,
297
+ subject,
298
+ "+49 151 00000000",
299
+ KMS_CTX,
300
+ "phone",
301
+ );
302
+ await kms.eraseKey(subject);
303
+
304
+ const read = await decryptPiiValueForSubject(kms, stored, KMS_CTX, "phone");
305
+ expect(read).toBe(PII_ERASED_SENTINEL);
306
+ });
307
+
308
+ test("plaintext passes through decrypt unchanged (pre-engine rows)", async () => {
309
+ const kms = new InMemoryKmsAdapter();
310
+ const read = await decryptPiiValueForSubject(kms, "plain-value", KMS_CTX, "value");
311
+ expect(read).toBe("plain-value");
312
+ });
313
+ });
314
+
315
+ describe("cross-subject decrypt leak (kumiko-framework#1190)", () => {
316
+ test("ciphertext forged to a different-but-valid subject's DEK fails GCM auth, not just KeyNotFound", async () => {
317
+ const kms = new InMemoryKmsAdapter();
318
+ const subjectA = { kind: "tenant" as const, tenantId: UUID_A };
319
+ const subjectB = { kind: "tenant" as const, tenantId: UUID_B };
320
+ // Both subjects must have a real key — otherwise this only re-proves the
321
+ // existing "ciphertext without a key row fails loud" (KeyNotFoundError) case.
322
+ await kms.createKey(subjectA);
323
+
324
+ const storedForB = await encryptPiiValueForSubject(
325
+ kms,
326
+ subjectB,
327
+ "tenant-b-secret",
328
+ KMS_CTX,
329
+ "secret",
330
+ );
331
+ const blob = storedForB.slice(storedForB.lastIndexOf(":") + 1);
332
+ const forgedForA = `${PII_CIPHERTEXT_PREFIX}${subjectIdToKey(subjectA)}:${blob}`;
333
+
334
+ const attempt = decryptPiiValueForSubject(kms, forgedForA, KMS_CTX, "secret");
335
+ await expect(attempt).rejects.not.toBeInstanceOf(KeyNotFoundError);
336
+ await expect(attempt).rejects.not.toBeInstanceOf(KeyErasedError);
337
+ await expect(attempt).rejects.toThrow();
338
+ });
339
+ });
340
+
341
+ describe("cross-field decrypt leak (kumiko-framework#1263)", () => {
342
+ test("ciphertext cut-and-pasted into a different field of the SAME subject fails GCM auth", async () => {
343
+ const kms = new InMemoryKmsAdapter();
344
+ const subject = { kind: "user" as const, userId: UUID_A };
345
+ const storedAsEmail = await encryptPiiValueForSubject(
346
+ kms,
347
+ subject,
348
+ "secret@example.com",
349
+ KMS_CTX,
350
+ "email",
351
+ );
352
+
353
+ // Same subject, same DEK — only the declared field differs. Key
354
+ // selection alone can't catch this; AAD must.
355
+ const attempt = decryptPiiValueForSubject(kms, storedAsEmail, KMS_CTX, "phone");
356
+ await expect(attempt).rejects.toThrow();
357
+ });
358
+
359
+ test("legacy v1 ciphertext (no AAD) still decrypts under the matching subject/field", async () => {
360
+ const kms = new InMemoryKmsAdapter();
361
+ const subject = { kind: "user" as const, userId: UUID_A };
362
+ await kms.createKey(subject);
363
+ const dek = await kms.getKey(subject);
364
+
365
+ // Hand-roll a pre-#1263 v1 blob (no setAAD) — real pre-migration rows
366
+ // look exactly like this.
367
+ const iv = randomBytes(12);
368
+ const cipher = createCipheriv("aes-256-gcm", dek, iv);
369
+ const ciphertext = Buffer.concat([cipher.update("legacy-value", "utf8"), cipher.final()]);
370
+ const blob = Buffer.concat([iv, cipher.getAuthTag(), ciphertext]);
371
+ const v1 = `kumiko-pii:v1:${subjectIdToKey(subject)}:${blob.toString("base64")}`;
372
+
373
+ const read = await decryptPiiValueForSubject(kms, v1, KMS_CTX, "email");
374
+ expect(read).toBe("legacy-value");
375
+ });
376
+ });
@@ -0,0 +1,74 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { InMemoryKmsAdapter } from "../in-memory-kms-adapter";
3
+ import {
4
+ KeyErasedError,
5
+ type KmsContext,
6
+ type LocalKeyKmsAdapter,
7
+ type SubjectId,
8
+ } from "../kms-adapter";
9
+ import { createRequestKmsCache } from "../request-kms-cache";
10
+
11
+ const ctx: KmsContext = { requestId: "cache-test" };
12
+ const userA: SubjectId = { kind: "user", userId: "6b2f4a0e-1c9d-4f3a-9d2e-0000000000aa" };
13
+
14
+ function countingAdapter(): { adapter: LocalKeyKmsAdapter; calls: () => number } {
15
+ const inner = new InMemoryKmsAdapter();
16
+ let getKeyCalls = 0;
17
+ const adapter: LocalKeyKmsAdapter = {
18
+ capabilities: { mode: "local-key" },
19
+ createKey: (subject) => inner.createKey(subject),
20
+ eraseKey: (subject) => inner.eraseKey(subject),
21
+ health: () => inner.health(),
22
+ getKey: (subject) => {
23
+ getKeyCalls++;
24
+ return inner.getKey(subject);
25
+ },
26
+ };
27
+ return { adapter, calls: () => getKeyCalls };
28
+ }
29
+
30
+ describe("createRequestKmsCache", () => {
31
+ test("second getKey for the same subject is served from cache", async () => {
32
+ const { adapter, calls } = countingAdapter();
33
+ await adapter.createKey(userA, ctx);
34
+ const cache = createRequestKmsCache(adapter);
35
+
36
+ const first = await cache.getKey(userA, ctx);
37
+ const second = await cache.getKey(userA, ctx);
38
+ expect(first.equals(second)).toBe(true);
39
+ expect(calls()).toBe(1);
40
+ });
41
+
42
+ test("invalidate drops the entry — next getKey hits the adapter again", async () => {
43
+ const { adapter, calls } = countingAdapter();
44
+ await adapter.createKey(userA, ctx);
45
+ const cache = createRequestKmsCache(adapter);
46
+
47
+ await cache.getKey(userA, ctx);
48
+ cache.invalidate(userA);
49
+ await cache.getKey(userA, ctx);
50
+ expect(calls()).toBe(2);
51
+ });
52
+
53
+ test("erase + invalidate surfaces KeyErasedError instead of a stale DEK", async () => {
54
+ const { adapter } = countingAdapter();
55
+ await adapter.createKey(userA, ctx);
56
+ const cache = createRequestKmsCache(adapter);
57
+
58
+ await cache.getKey(userA, ctx);
59
+ await adapter.eraseKey(userA, ctx);
60
+ cache.invalidate(userA);
61
+ await expect(cache.getKey(userA, ctx)).rejects.toBeInstanceOf(KeyErasedError);
62
+ });
63
+
64
+ test("adapter errors are not cached — a failed lookup retries", async () => {
65
+ const { adapter, calls } = countingAdapter();
66
+ const cache = createRequestKmsCache(adapter);
67
+
68
+ await expect(cache.getKey(userA, ctx)).rejects.toThrow();
69
+ await adapter.createKey(userA, ctx);
70
+ const dek = await cache.getKey(userA, ctx);
71
+ expect(dek.length).toBe(32);
72
+ expect(calls()).toBe(2);
73
+ });
74
+ });
@@ -0,0 +1,108 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { createEntity, createTextField } from "../../engine/factories";
3
+ import {
4
+ collectPiiSubjectFields,
5
+ resolveSubjectForField,
6
+ SubjectResolutionError,
7
+ } from "../subject-resolver";
8
+
9
+ const userLikeEntity = createEntity({
10
+ fields: {
11
+ email: createTextField({ required: true, pii: true }),
12
+ role: createTextField(),
13
+ },
14
+ table: "resolver_users",
15
+ idType: "uuid",
16
+ });
17
+
18
+ const commentEntity = createEntity({
19
+ fields: {
20
+ body: createTextField({ userOwned: { ownerField: "authorId" } }),
21
+ authorId: createTextField({ required: true }),
22
+ },
23
+ table: "resolver_comments",
24
+ });
25
+
26
+ const brandingEntity = createEntity({
27
+ fields: {
28
+ brandColor: createTextField({ tenantOwned: true }),
29
+ },
30
+ table: "resolver_branding",
31
+ });
32
+
33
+ const UUID_A = "6b2f4a0e-1c9d-4f3a-9d2e-00000000000a";
34
+ const UUID_B = "6b2f4a0e-1c9d-4f3a-9d2e-00000000000b";
35
+
36
+ describe("resolveSubjectForField", () => {
37
+ test("pii: true → the entity row itself is the user subject", () => {
38
+ const subject = resolveSubjectForField(userLikeEntity, "email", { id: UUID_A });
39
+ expect(subject).toEqual({ kind: "user", userId: UUID_A });
40
+ });
41
+
42
+ test("pii self-subject stringifies serial ids", () => {
43
+ const subject = resolveSubjectForField(userLikeEntity, "email", { id: 42 });
44
+ expect(subject).toEqual({ kind: "user", userId: "42" });
45
+ });
46
+
47
+ test("userOwned → subject comes from the owner reference field", () => {
48
+ const subject = resolveSubjectForField(commentEntity, "body", {
49
+ id: UUID_A,
50
+ authorId: UUID_B,
51
+ });
52
+ expect(subject).toEqual({ kind: "user", userId: UUID_B });
53
+ });
54
+
55
+ test("tenantOwned → subject from the row's tenantId column", () => {
56
+ const subject = resolveSubjectForField(brandingEntity, "brandColor", {
57
+ id: UUID_A,
58
+ tenantId: UUID_B,
59
+ });
60
+ expect(subject).toEqual({ kind: "tenant", tenantId: UUID_B });
61
+ });
62
+
63
+ test("tenantOwned falls back to the write-time tenantId option", () => {
64
+ const subject = resolveSubjectForField(
65
+ brandingEntity,
66
+ "brandColor",
67
+ { id: UUID_A },
68
+ { tenantId: UUID_B },
69
+ );
70
+ expect(subject).toEqual({ kind: "tenant", tenantId: UUID_B });
71
+ });
72
+
73
+ test("unannotated field → null (stays plaintext)", () => {
74
+ expect(resolveSubjectForField(userLikeEntity, "role", { id: UUID_A })).toBeNull();
75
+ });
76
+
77
+ test("empty owner reference throws instead of silently falling back to plaintext", () => {
78
+ expect(() => resolveSubjectForField(commentEntity, "body", { id: UUID_A })).toThrow(
79
+ SubjectResolutionError,
80
+ );
81
+ });
82
+
83
+ test("tenantOwned without any tenant scope throws", () => {
84
+ expect(() => resolveSubjectForField(brandingEntity, "brandColor", { id: UUID_A })).toThrow(
85
+ SubjectResolutionError,
86
+ );
87
+ });
88
+
89
+ test("pii row without id throws", () => {
90
+ expect(() => resolveSubjectForField(userLikeEntity, "email", {})).toThrow(
91
+ SubjectResolutionError,
92
+ );
93
+ });
94
+
95
+ test("unknown field name throws", () => {
96
+ expect(() => resolveSubjectForField(userLikeEntity, "nope", { id: UUID_A })).toThrow(
97
+ SubjectResolutionError,
98
+ );
99
+ });
100
+ });
101
+
102
+ describe("collectPiiSubjectFields", () => {
103
+ test("collects exactly the annotated fields", () => {
104
+ expect(collectPiiSubjectFields(userLikeEntity)).toEqual(["email"]);
105
+ expect(collectPiiSubjectFields(commentEntity)).toEqual(["body"]);
106
+ expect(collectPiiSubjectFields(brandingEntity)).toEqual(["brandColor"]);
107
+ });
108
+ });
@@ -0,0 +1,118 @@
1
+ // Blind-Index für PII-Equality-Lookups (kumiko-framework#818).
2
+ // `lookupable: true` auf einem Subject-annotierten text-Feld erzeugt eine
3
+ // generierte Spalte `<snake>_bidx` mit deterministischem HMAC über den
4
+ // KLARTEXT-Wert — Equality-Queries matchen `(col = $1 OR col_bidx = $2)`
5
+ // und funktionieren damit für Klartext-Alt-Rows UND verschlüsselte Rows.
6
+ //
7
+ // Wertformat: kumiko-bidx:v1:<base64url(HMAC-SHA256(key, value))>
8
+ //
9
+ // HMAC ist byte-exact ohne Normalisierung — repliziert exakt die heutige
10
+ // case-sensitive Equality-Semantik (Lowercasing wäre eine Verhaltensänderung).
11
+ //
12
+ // Der Key ist bewusst ein EIGENER 32-Byte-Key (env: KUMIKO_BLIND_INDEX_KEY),
13
+ // nicht vom PLATFORM_KEK abgeleitet und nicht Teil des KmsAdapter-Contracts —
14
+ // BYOK-Adapter dürfen ihn nie sehen. Rotation = Recompute aller bidx-Spalten
15
+ // (Rollout-Fahrplan Schritt 7).
16
+ //
17
+ // bidx-Werte leben NUR in der Projektion, nie in Event-Payloads: ein
18
+ // deterministischer HMAC im immutablen Event wäre permanente Linkage und
19
+ // bräche Forget. applyEntityEvent berechnet sie beim Apply (live + rebuild),
20
+ // nach Key-Erase recomputet der Rebuild sie zu NULL.
21
+
22
+ import { createHmac } from "node:crypto";
23
+ import { requestContext } from "../api/request-context";
24
+ import type { EntityDefinition } from "../engine/types/fields";
25
+ import {
26
+ configuredPiiSubjectKms,
27
+ decryptPiiFieldValues,
28
+ isPiiCiphertext,
29
+ PII_ERASED_SENTINEL,
30
+ } from "./pii-field-encryption";
31
+
32
+ const BLIND_INDEX_PREFIX = "kumiko-bidx:v1:";
33
+ const BLIND_INDEX_KEY_LENGTH = 32;
34
+
35
+ export function computeBlindIndex(key: Uint8Array, plaintext: string): string {
36
+ const mac = createHmac("sha256", key).update(plaintext, "utf8").digest("base64url");
37
+ return `${BLIND_INDEX_PREFIX}${mac}`;
38
+ }
39
+
40
+ export function decodeBlindIndexKey(base64Key: string): Uint8Array {
41
+ const decoded = Buffer.from(base64Key, "base64");
42
+ if (decoded.length !== BLIND_INDEX_KEY_LENGTH) {
43
+ throw new Error(
44
+ `Blind-index key must be ${BLIND_INDEX_KEY_LENGTH} bytes base64-encoded, got ${decoded.length} bytes. ` +
45
+ `Generate one: openssl rand -base64 32`,
46
+ );
47
+ }
48
+ return decoded;
49
+ }
50
+
51
+ // Boot-injected app-wide key, mirroring configurePiiSubjectKms: run{Prod,Dev}App
52
+ // call configureBlindIndexKey once; query compilers + applyEntityEvent resolve
53
+ // lazily. Absent key = blind-indexing off (bidx columns stay NULL, equality
54
+ // lookups match plaintext rows only — pre-#818 behavior).
55
+ let injectedKey: Uint8Array | undefined;
56
+
57
+ export function configureBlindIndexKey(base64Key: string | undefined): void {
58
+ injectedKey = base64Key === undefined ? undefined : decodeBlindIndexKey(base64Key);
59
+ }
60
+
61
+ export function configuredBlindIndexKey(): Uint8Array | undefined {
62
+ return injectedKey;
63
+ }
64
+
65
+ /** @internal test-only */
66
+ export function resetBlindIndexKeyForTests(): void {
67
+ injectedKey = undefined;
68
+ }
69
+
70
+ export function blindIndexFieldName(fieldName: string): string {
71
+ return `${fieldName}Bidx`;
72
+ }
73
+
74
+ // The field names that carry a blind index — precomputed per apply like
75
+ // collectPiiSubjectFields. Only text fields qualify (boot-validated).
76
+ export function collectLookupableFields(entity: EntityDefinition): readonly string[] {
77
+ return Object.entries(entity.fields)
78
+ .filter(([, field]) => field.type === "text" && field.lookupable === true)
79
+ .map(([name]) => name);
80
+ }
81
+
82
+ // bidx values for every lookupable field PRESENT in `values` (create payload
83
+ // or update changes — absent fields stay untouched). Ciphertext values are
84
+ // decrypted first so the HMAC always covers the plaintext; an erased subject
85
+ // (or the erased sentinel) yields NULL — the lookup stops matching, which is
86
+ // exactly the forget semantics.
87
+ export async function computeBlindIndexValues(
88
+ values: Record<string, unknown>,
89
+ lookupableFields: readonly string[],
90
+ ): Promise<Record<string, unknown>> {
91
+ const key = configuredBlindIndexKey();
92
+ if (key === undefined || lookupableFields.length === 0) return {};
93
+ const out: Record<string, unknown> = {};
94
+ for (const name of lookupableFields) {
95
+ if (!(name in values)) continue;
96
+ out[blindIndexFieldName(name)] = await blindIndexForValue(key, name, values[name]);
97
+ }
98
+ return out;
99
+ }
100
+
101
+ async function blindIndexForValue(
102
+ key: Uint8Array,
103
+ field: string,
104
+ value: unknown,
105
+ ): Promise<string | null> {
106
+ if (typeof value !== "string" || value === PII_ERASED_SENTINEL) return null;
107
+ if (!isPiiCiphertext(value)) return computeBlindIndex(key, value);
108
+ const kms = configuredPiiSubjectKms();
109
+ // Ciphertext without a KMS can't be decrypted here; the same read would
110
+ // also surface raw ciphertext — misconfiguration is caught at boot.
111
+ if (kms === undefined) return null;
112
+ const decrypted = await decryptPiiFieldValues({ [field]: value }, [field], kms, {
113
+ requestId: requestContext.get()?.requestId ?? "blind-index",
114
+ });
115
+ const plain = decrypted[field];
116
+ if (typeof plain !== "string" || plain === PII_ERASED_SENTINEL) return null;
117
+ return computeBlindIndex(key, plain);
118
+ }