@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.
- package/README.md +9 -38
- package/package.json +21 -2
- package/src/__tests__/anonymous-access.integration.test.ts +185 -0
- package/src/__tests__/consumer-cli.integration.test.ts +172 -0
- package/src/__tests__/entity-permalink-open.integration.test.ts +94 -0
- package/src/__tests__/full-stack.integration.test.ts +1 -1
- package/src/__tests__/schema-cli-temporal-polyfill.test.ts +48 -0
- package/src/__tests__/schema-cli.integration.test.ts +50 -0
- package/src/__tests__/store-table.integration.test.ts +93 -0
- package/src/api/__tests__/api.test.ts +373 -0
- package/src/api/__tests__/auth-middleware-anonymous-access-boot.test.ts +40 -0
- package/src/api/__tests__/auth-routes-cookie.test.ts +17 -1
- package/src/api/__tests__/auth-routes-invalid-body-invite.test.ts +253 -0
- package/src/api/__tests__/auth-routes-mfa-preauth-confirm.test.ts +222 -0
- package/src/api/__tests__/auth-routes-mfa-preauth-enable-start.test.ts +249 -0
- package/src/api/__tests__/auth-routes-mfa-verify.test.ts +202 -0
- package/src/api/__tests__/auth-routes-trusted-proxy.test.ts +135 -0
- package/src/api/__tests__/batch.integration.test.ts +101 -11
- package/src/api/__tests__/csrf-constants-sync.test.ts +20 -0
- package/src/api/__tests__/dispatcher-live.integration.test.ts +74 -0
- package/src/api/__tests__/jwt.test.ts +200 -1
- package/src/api/__tests__/login-rate-limiter-sweep.test.ts +50 -0
- package/src/api/__tests__/pat-scope.test.ts +36 -0
- package/src/api/__tests__/pii-leak-guard.integration.test.ts +103 -0
- package/src/api/__tests__/redis-login-rate-limiter.integration.test.ts +138 -0
- package/src/api/__tests__/request-id-middleware.test.ts +51 -0
- package/src/api/__tests__/server-boot-guards.test.ts +71 -0
- package/src/api/__tests__/server-jwt-ttl.test.ts +58 -0
- package/src/api/__tests__/sse-broker.test.ts +57 -0
- package/src/api/__tests__/sse-route.test.ts +4 -0
- package/src/api/api-constants.ts +11 -0
- package/src/api/auth-middleware.ts +248 -40
- package/src/api/auth-routes.ts +576 -95
- package/src/api/index.ts +13 -4
- package/src/api/jwt.ts +170 -11
- package/src/api/pat-scope.ts +14 -0
- package/src/api/pii-leak-guard.ts +47 -0
- package/src/api/request-context.ts +3 -0
- package/src/api/request-id-middleware.ts +2 -0
- package/src/api/routes.ts +169 -2
- package/src/api/server.ts +112 -16
- package/src/api/sse-broker.ts +39 -0
- package/src/bun-db/__tests__/PATTERN.md +0 -1
- package/src/bun-db/__tests__/coerce-row-temporal.test.ts +41 -0
- package/src/bun-db/__tests__/select-many-retry.test.ts +79 -0
- package/src/bun-db/__tests__/write-brand.test.ts +21 -0
- package/src/bun-db/connection.ts +3 -3
- package/src/bun-db/index.ts +2 -0
- package/src/bun-db/query.ts +105 -32
- package/src/consumer-cli.ts +134 -0
- package/src/crypto/__tests__/blind-index.test.ts +130 -0
- package/src/crypto/__tests__/event-pii.test.ts +161 -0
- package/src/crypto/__tests__/kek-rotation.integration.test.ts +180 -0
- package/src/crypto/__tests__/kms-adapter-contract.ts +134 -0
- package/src/crypto/__tests__/kms-adapter.contract.test.ts +4 -0
- package/src/crypto/__tests__/pg-kms-adapter.integration.test.ts +117 -0
- package/src/crypto/__tests__/pii-field-encryption.test.ts +376 -0
- package/src/crypto/__tests__/request-kms-cache.test.ts +74 -0
- package/src/crypto/__tests__/subject-resolver.test.ts +108 -0
- package/src/crypto/blind-index.ts +118 -0
- package/src/crypto/event-pii.ts +70 -0
- package/src/crypto/in-memory-kms-adapter.ts +50 -0
- package/src/crypto/index.ts +67 -0
- package/src/crypto/kms-adapter.ts +2 -0
- package/src/crypto/pg-kms-adapter.ts +295 -0
- package/src/crypto/pii-field-encryption.ts +248 -0
- package/src/crypto/request-kms-cache.ts +38 -0
- package/src/crypto/subject-resolver.ts +91 -0
- package/src/db/__tests__/assert-no-unreachable-live-rows.integration.test.ts +191 -0
- package/src/db/__tests__/blind-index.integration.test.ts +248 -0
- package/src/db/__tests__/build-filter-where.test.ts +34 -0
- package/src/db/__tests__/collect-table-metas.test.ts +10 -10
- package/src/db/__tests__/config-seed.integration.test.ts +13 -5
- package/src/db/__tests__/dialect-instant.test.ts +1 -4
- package/src/db/__tests__/entity-field-encryption.test.ts +7 -7
- package/src/db/__tests__/entity-table-meta-source.test.ts +50 -0
- package/src/db/__tests__/event-store-executor-context.pii-roundtrip.test.ts +67 -0
- package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +402 -0
- package/src/db/__tests__/event-store-executor.integration.test.ts +299 -29
- package/src/db/__tests__/feature-table-sources.test.ts +34 -0
- package/src/db/__tests__/implicit-projection-equivalence.integration.test.ts +118 -39
- package/src/db/__tests__/instant-to-driver-temporal.test.ts +21 -0
- package/src/db/__tests__/located-timestamp.test.ts +19 -0
- package/src/db/__tests__/migrate-generator.test.ts +21 -0
- package/src/db/__tests__/migrate-runner.test.ts +61 -0
- package/src/db/__tests__/number-field-fractional.integration.test.ts +58 -0
- package/src/db/__tests__/rebuild-marker.test.ts +13 -3
- package/src/db/__tests__/replay-migration-sql.test.ts +384 -0
- package/src/db/__tests__/schema-inspection.test.ts +7 -0
- package/src/db/__tests__/schema-migration.integration.test.ts +1 -1
- package/src/db/__tests__/table-builder-meta-lockstep.test.ts +39 -0
- package/src/db/__tests__/tenant-db-where-merge.test.ts +49 -3
- package/src/db/__tests__/tenant-db.integration.test.ts +6 -2
- package/src/db/api.ts +2 -2
- package/src/db/apply-entity-event.ts +18 -14
- package/src/db/blind-index-cleanup.ts +55 -0
- package/src/db/bun-provider.ts +2 -2
- package/src/db/collect-table-metas.ts +6 -7
- package/src/db/config-seed.ts +9 -9
- package/src/db/connection.ts +7 -12
- package/src/db/cursor.ts +1 -18
- package/src/db/dialect.ts +20 -26
- package/src/db/entity-field-encryption.ts +81 -24
- package/src/db/entity-table-meta-types.ts +2 -0
- package/src/db/entity-table-meta.ts +63 -90
- package/src/db/event-store-executor-context.ts +404 -0
- package/src/db/event-store-executor-read.ts +275 -0
- package/src/db/event-store-executor-write.ts +644 -0
- package/src/db/event-store-executor.ts +28 -1155
- package/src/db/feature-table-sources.ts +6 -5
- package/src/db/index.ts +20 -3
- package/src/db/located-timestamp.ts +4 -0
- package/src/db/migrate-generator.ts +39 -6
- package/src/db/migrate-runner.ts +107 -11
- package/src/db/pg-error.ts +9 -1
- package/src/db/postgres-provider.ts +2 -2
- package/src/db/queries/__tests__/event-store-idempotency-index.integration.test.ts +80 -0
- package/src/db/queries/backfill-pii.ts +277 -0
- package/src/db/queries/ddl.ts +45 -0
- package/src/db/queries/event-consumer.ts +35 -2
- package/src/db/queries/event-store.ts +126 -19
- package/src/db/queries/projection-rebuild.ts +22 -8
- package/src/db/queries/shadow-swap.ts +183 -0
- package/src/db/queries/test-stack.ts +4 -30
- package/src/db/query-api.ts +1 -0
- package/src/db/query.ts +1 -0
- package/src/db/rebuild-marker.ts +18 -2
- package/src/db/reference-data.ts +2 -3
- package/src/db/replay-migration-sql.ts +257 -0
- package/src/db/schema-inspection.ts +1 -1
- package/src/db/table-builder.ts +102 -71
- package/src/db/tenant-db.ts +15 -53
- package/src/engine/__tests__/boot-validator-action-wiring.test.ts +242 -0
- package/src/engine/__tests__/boot-validator-boot-check.test.ts +99 -0
- package/src/engine/__tests__/boot-validator-dashboard.test.ts +237 -0
- package/src/engine/__tests__/boot-validator-entity-list.test.ts +104 -0
- package/src/engine/__tests__/boot-validator-gdpr-storage.test.ts +7 -69
- package/src/engine/__tests__/boot-validator-i18n-keys.test.ts +26 -5
- package/src/engine/__tests__/boot-validator-located-timestamps.test.ts +3 -19
- package/src/engine/__tests__/boot-validator-pii-retention.test.ts +246 -4
- package/src/engine/__tests__/boot-validator.test.ts +219 -15
- package/src/engine/__tests__/build-app-schema.test.ts +82 -0
- package/src/engine/__tests__/build-config-feature-schema.test.ts +125 -0
- package/src/engine/__tests__/build-target.test.ts +3 -11
- package/src/engine/__tests__/codemod-pipeline.test.ts +152 -21
- package/src/engine/__tests__/config-helpers.test.ts +16 -0
- package/src/engine/__tests__/define-feature-entity-mapping.test.ts +6 -0
- package/src/engine/__tests__/define-roles.test.ts +21 -0
- package/src/engine/__tests__/engine.test.ts +163 -1
- package/src/engine/__tests__/entity-handlers.test.ts +80 -0
- package/src/engine/__tests__/event-migration-declarative.test.ts +65 -0
- package/src/engine/__tests__/event-type-map-augmentation.test.ts +24 -0
- package/src/engine/__tests__/extend-entity-projection.test.ts +123 -0
- package/src/engine/__tests__/factories-time.test.ts +2 -66
- package/src/engine/__tests__/feature-crud-shorthand.test.ts +28 -0
- package/src/engine/__tests__/feature-manifest.test.ts +31 -1
- package/src/engine/__tests__/field-access.test.ts +23 -1
- package/src/engine/__tests__/hook-phases.test.ts +5 -5
- package/src/engine/__tests__/membership-roles.test.ts +4 -10
- package/src/engine/__tests__/nav.test.ts +86 -1
- package/src/engine/__tests__/pipeline-engine.test.ts +9 -9
- package/src/engine/__tests__/pipeline-handler.integration.test.ts +18 -18
- package/src/engine/__tests__/pipeline-observability.integration.test.ts +2 -2
- package/src/engine/__tests__/pipeline-performance.integration.test.ts +3 -3
- package/src/engine/__tests__/pipeline-sub-pipelines.test.ts +9 -9
- package/src/engine/__tests__/post-query-hook.test.ts +7 -7
- package/src/engine/__tests__/registrar-object-form.test.ts +141 -0
- package/src/engine/__tests__/registry.test.ts +144 -0
- package/src/engine/__tests__/schema-builder.test.ts +18 -0
- package/src/engine/__tests__/screen.test.ts +147 -1
- package/src/engine/__tests__/soft-delete-cleanup.test.ts +3 -0
- package/src/engine/__tests__/store-table.test.ts +229 -0
- package/src/engine/__tests__/tier-resolver-extension.test.ts +19 -1
- package/src/engine/__tests__/{visual-tree-patterns.test.ts → tree-actions-patterns.test.ts} +7 -95
- package/src/engine/__tests__/validate-projection-allowlist.test.ts +15 -15
- package/src/engine/boot-validator/__tests__/config-deps.test.ts +92 -0
- package/src/engine/boot-validator/action-wiring.ts +149 -0
- package/src/engine/boot-validator/boot-check.ts +21 -0
- package/src/engine/boot-validator/config-deps.ts +40 -4
- package/src/engine/boot-validator/entity-handler.ts +20 -21
- package/src/engine/boot-validator/entity-list-screens.ts +88 -0
- package/src/engine/boot-validator/gdpr-storage.ts +0 -20
- package/src/engine/boot-validator/i18n-keys.ts +58 -4
- package/src/engine/boot-validator/index.ts +33 -12
- package/src/engine/boot-validator/nav.ts +125 -0
- package/src/engine/boot-validator/pii-retention.ts +95 -10
- package/src/engine/boot-validator/{screens-nav.ts → screens.ts} +214 -191
- package/src/engine/boot-validator/workspaces.ts +68 -0
- package/src/engine/build-app-schema.ts +16 -0
- package/src/engine/build-config-feature-schema.ts +44 -7
- package/src/engine/codemod/pipeline-codemod.ts +5 -5
- package/src/engine/config-helpers.ts +15 -0
- package/src/engine/constants.ts +32 -6
- package/src/engine/create-app.ts +11 -0
- package/src/engine/define-feature.ts +95 -947
- package/src/engine/define-handler.ts +28 -94
- package/src/engine/define-workflow.ts +1 -1
- package/src/engine/effective-features.ts +12 -2
- package/src/engine/entity-handlers.ts +76 -11
- package/src/engine/extensions/tenant-data.ts +19 -0
- package/src/engine/extensions/user-data.ts +29 -2
- package/src/engine/factories.ts +8 -49
- package/src/engine/feature-ast/__tests__/canonical-form.test.ts +26 -29
- package/src/engine/feature-ast/__tests__/fixtures/cross-file-registrar/feature.ts +9 -0
- package/src/engine/feature-ast/__tests__/fixtures/cross-file-registrar/screens.ts +4 -0
- package/src/engine/feature-ast/__tests__/parse-happy-path.test.ts +1 -2
- package/src/engine/feature-ast/__tests__/parse-real-features.test.ts +18 -8
- package/src/engine/feature-ast/__tests__/parse.test.ts +1640 -160
- package/src/engine/feature-ast/__tests__/patch.test.ts +206 -12
- package/src/engine/feature-ast/__tests__/patcher.test.ts +20 -23
- package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +380 -5
- package/src/engine/feature-ast/extractors/events.ts +322 -0
- package/src/engine/feature-ast/extractors/handlers.ts +234 -0
- package/src/engine/feature-ast/extractors/hooks.ts +243 -0
- package/src/engine/feature-ast/extractors/index.ts +34 -31
- package/src/engine/feature-ast/extractors/jobs-routes.ts +221 -0
- package/src/engine/feature-ast/extractors/projections-screens.ts +269 -0
- package/src/engine/feature-ast/extractors/round1.ts +3 -3
- package/src/engine/feature-ast/extractors/round5.ts +3 -3
- package/src/engine/feature-ast/extractors/round6.ts +2 -36
- package/src/engine/feature-ast/extractors/shared.ts +64 -6
- package/src/engine/feature-ast/index.ts +2 -4
- package/src/engine/feature-ast/parse.ts +130 -23
- package/src/engine/feature-ast/patch.ts +39 -50
- package/src/engine/feature-ast/patcher.ts +37 -38
- package/src/engine/feature-ast/patterns.ts +50 -62
- package/src/engine/feature-ast/render.ts +67 -44
- package/src/engine/feature-builder-state.ts +168 -0
- package/src/engine/feature-config-events-jobs.ts +403 -0
- package/src/engine/feature-entity-handlers.ts +208 -0
- package/src/engine/feature-manifest.ts +2 -1
- package/src/engine/feature-ui-extensions.ts +500 -0
- package/src/engine/field-access.ts +13 -2
- package/src/engine/field-helpers.ts +31 -0
- package/src/engine/handler-helpers.ts +26 -0
- package/src/engine/hook-helpers.ts +16 -0
- package/src/engine/index.ts +23 -7
- package/src/engine/membership-roles.ts +13 -0
- package/src/engine/object-form.ts +27 -0
- package/src/engine/ownership.ts +26 -79
- package/src/engine/pattern-library/__tests__/library.test.ts +7 -20
- package/src/engine/pattern-library/library.ts +44 -1152
- package/src/engine/pattern-library/mixed-schemas.ts +450 -0
- package/src/engine/pattern-library/opaque-schemas.ts +124 -0
- package/src/engine/pattern-library/shared-fields.ts +75 -0
- package/src/engine/pattern-library/static-schemas.ts +456 -0
- package/src/engine/pipeline.ts +6 -11
- package/src/engine/registry-facade.ts +362 -0
- package/src/engine/registry-ingest.ts +478 -0
- package/src/engine/registry-state.ts +388 -0
- package/src/engine/registry-validate.ts +642 -0
- package/src/engine/registry.ts +78 -1658
- package/src/engine/run-pipeline.ts +1 -1
- package/src/engine/schema-builder.ts +1 -0
- package/src/engine/screen-helpers.ts +54 -0
- package/src/engine/soft-delete-cleanup.ts +6 -2
- package/src/engine/steps/__tests__/duration-utils.test.ts +20 -0
- package/src/engine/steps/_duration-utils.ts +2 -0
- package/src/engine/steps/unsafe-projection-upsert.ts +1 -4
- package/src/engine/tier-resolver-extension.ts +3 -2
- package/src/engine/types/config.ts +2 -482
- package/src/engine/types/define-handler.ts +2 -0
- package/src/engine/types/entity-handlers.ts +2 -0
- package/src/engine/types/event-type-map.ts +1 -37
- package/src/engine/types/feature.ts +2 -972
- package/src/engine/types/fields.ts +2 -675
- package/src/engine/types/handlers.ts +2 -774
- package/src/engine/types/hooks.ts +2 -184
- package/src/engine/types/http-route.ts +1 -54
- package/src/engine/types/identifiers.ts +1 -47
- package/src/engine/types/index.ts +86 -40
- package/src/engine/types/nav.ts +2 -63
- package/src/engine/types/ownership.ts +2 -0
- package/src/engine/types/projection.ts +2 -138
- package/src/engine/types/relations.ts +1 -51
- package/src/engine/types/screen.ts +2 -574
- package/src/engine/types/step.ts +2 -334
- package/src/engine/types/target-ref.ts +1 -21
- package/src/engine/types/tree-node.ts +1 -132
- package/src/engine/types/workspace.ts +2 -49
- package/src/engine/validate-projection-allowlist.ts +6 -6
- package/src/entrypoint/__tests__/entrypoint-job-wiring.integration.test.ts +120 -1
- package/src/entrypoint/index.ts +49 -6
- package/src/errors/classes.ts +22 -1
- package/src/errors/field-issue.ts +0 -3
- package/src/errors/index.ts +1 -1
- package/src/errors/write-error-info.ts +10 -22
- package/src/es-ops/README.md +1 -1
- package/src/es-ops/__tests__/runner.integration.test.ts +74 -0
- package/src/es-ops/context.ts +4 -2
- package/src/es-ops/types.ts +8 -1
- package/src/event-store/__tests__/admin-api.integration.test.ts +27 -1
- package/src/event-store/__tests__/backfill-pii.integration.test.ts +279 -0
- package/src/event-store/__tests__/event-store.integration.test.ts +201 -0
- package/src/event-store/__tests__/row-to-stored-event.test.ts +2 -2
- package/src/event-store/__tests__/snapshot.integration.test.ts +86 -0
- package/src/event-store/__tests__/unscoped-stream-primitives.guard.test.ts +58 -0
- package/src/event-store/__tests__/upcaster.integration.test.ts +77 -45
- package/src/event-store/admin-api.ts +11 -4
- package/src/event-store/errors.ts +2 -35
- package/src/event-store/event-store.ts +64 -78
- package/src/event-store/events-schema.ts +11 -13
- package/src/event-store/index.ts +18 -3
- package/src/event-store/rebuild-dead-letter.ts +111 -0
- package/src/event-store/snapshot.ts +63 -40
- package/src/event-store/types.ts +2 -0
- package/src/files/__tests__/build-storage-key.test.ts +28 -0
- package/src/files/__tests__/file-ref-entity.test.ts +8 -0
- package/src/files/__tests__/files.integration.test.ts +24 -0
- package/src/files/__tests__/in-memory-provider.contract.test.ts +4 -0
- package/src/files/__tests__/local-provider.test.ts +31 -0
- package/src/files/__tests__/provider-resolver.test.ts +70 -0
- package/src/files/__tests__/write-stream.test.ts +13 -0
- package/src/files/file-handle.ts +2 -19
- package/src/files/file-ref-entity.ts +2 -2
- package/src/files/file-routes.ts +13 -0
- package/src/files/index.ts +1 -1
- package/src/files/local-provider.ts +27 -8
- package/src/files/provider-resolver.ts +31 -20
- package/src/files/types.ts +13 -55
- package/src/i18n/__tests__/required-surface-keys.test.ts +17 -0
- package/src/i18n/required-surface-keys.ts +120 -1
- package/src/jobs/__tests__/job-queue-depth.integration.test.ts +82 -0
- package/src/jobs/__tests__/jobs.integration.test.ts +329 -3
- package/src/jobs/job-runner.ts +82 -12
- package/src/logging/types.ts +1 -7
- package/src/migrations/pending-rebuilds.ts +1 -1
- package/src/observability/__tests__/observability.integration.test.ts +4 -1
- package/src/observability/__tests__/recording-tracer.test.ts +6 -4
- package/src/observability/index.ts +2 -0
- package/src/observability/noop-provider.ts +0 -9
- package/src/observability/recording-tracer.ts +0 -12
- package/src/observability/standard-metrics.ts +64 -2
- package/src/observability/types/index.ts +1 -29
- package/src/observability/types/metric.ts +1 -56
- package/src/observability/types/provider.ts +1 -32
- package/src/observability/types/span.ts +1 -64
- package/src/pipeline/__tests__/ctx-bridge.integration.test.ts +2 -2
- package/src/pipeline/__tests__/dispatcher.test.ts +366 -1
- package/src/pipeline/__tests__/event-consumer-state.integration.test.ts +31 -0
- package/src/pipeline/__tests__/event-dispatcher-delivery-max-attempts.test.ts +126 -0
- package/src/pipeline/__tests__/event-dispatcher-rearm.integration.test.ts +263 -0
- package/src/pipeline/__tests__/event-dispatcher.integration.test.ts +10 -0
- package/src/pipeline/__tests__/job-trigger-consumer.integration.test.ts +106 -0
- package/src/pipeline/__tests__/lifecycle-pipeline.test.ts +308 -77
- package/src/pipeline/__tests__/load-aggregate-query.integration.test.ts +16 -8
- package/src/pipeline/__tests__/post-query-hook.integration.test.ts +3 -3
- package/src/pipeline/__tests__/projection-rebuild.integration.test.ts +80 -2
- package/src/pipeline/__tests__/rebuild-poison-quarantine.integration.test.ts +274 -0
- package/src/pipeline/__tests__/try-append-event.integration.test.ts +166 -0
- package/src/pipeline/dispatch-batch.ts +187 -0
- package/src/pipeline/dispatch-query.ts +165 -0
- package/src/pipeline/dispatch-shared.ts +826 -0
- package/src/pipeline/dispatch-stream.ts +90 -0
- package/src/pipeline/dispatch-write.ts +451 -0
- package/src/pipeline/dispatcher-utils.ts +1 -1
- package/src/pipeline/dispatcher.ts +44 -1372
- package/src/pipeline/entity-cache.ts +2 -33
- package/src/pipeline/event-consumer-state.ts +30 -2
- package/src/pipeline/event-dispatcher-admin.ts +293 -0
- package/src/pipeline/event-dispatcher-delivery.ts +305 -0
- package/src/pipeline/event-dispatcher.ts +85 -542
- package/src/pipeline/index.ts +2 -0
- package/src/pipeline/msp-rebuild.ts +42 -3
- package/src/pipeline/multi-stream-apply-context.ts +4 -42
- package/src/pipeline/projection-rebuild.ts +105 -3
- package/src/pipeline/system-hooks.ts +144 -1
- package/src/rate-limit/__tests__/resolver.integration.test.ts +18 -0
- package/src/rate-limit/resolver.ts +16 -32
- package/src/schema-cli.ts +76 -16
- package/src/search/__tests__/meilisearch-adapter.integration.test.ts +207 -185
- package/src/search/__tests__/meilisearch-ids.test.ts +30 -0
- package/src/search/__tests__/reindex-entity.integration.test.ts +144 -0
- package/src/search/index.ts +6 -0
- package/src/search/meilisearch-adapter.ts +13 -12
- package/src/search/reindex-entity.ts +177 -0
- package/src/search/types.ts +1 -39
- package/src/secrets/__tests__/contains-secret.test.ts +34 -0
- package/src/secrets/__tests__/envelope-cipher.test.ts +59 -0
- package/src/secrets/__tests__/envelope.test.ts +1 -1
- package/src/secrets/dek-cache.ts +26 -5
- package/src/secrets/envelope-cipher.ts +53 -0
- package/src/secrets/envelope.ts +5 -3
- package/src/secrets/index.ts +13 -1
- package/src/secrets/stored-envelope.ts +46 -0
- package/src/secrets/types.ts +2 -162
- package/src/stack/__tests__/event-collector.test.ts +42 -0
- package/src/stack/__tests__/setup-test-stack-jobs.integration.test.ts +125 -0
- package/src/stack/db.ts +2 -1
- package/src/stack/push-entity-projection-tables.ts +4 -3
- package/src/stack/redis.ts +8 -0
- package/src/stack/request-helper.ts +38 -2
- package/src/stack/table-helpers.ts +6 -4
- package/src/stack/test-stack.ts +249 -131
- package/src/testing/__tests__/late-bound.test.ts +32 -0
- package/src/testing/__tests__/wait-for.test.ts +59 -0
- package/src/testing/boot-validator-fixture.ts +121 -0
- package/src/testing/e2e-generator.ts +8 -0
- package/src/testing/file-provider-contract.ts +104 -0
- package/src/testing/handler-context.ts +3 -1
- package/src/testing/index.ts +5 -0
- package/src/testing/late-bound.ts +5 -3
- package/src/testing/mutable-master-key-provider.ts +29 -3
- package/src/testing/wait-for.ts +3 -0
- package/src/testing/without-ambient-temporal.ts +14 -0
- package/src/time/__tests__/tz-dateline.test.ts +8 -10
- package/src/time/geo-tz.ts +1 -32
- package/src/time/index.ts +1 -0
- package/src/time/legacy-date.ts +18 -0
- package/src/time/polyfill.ts +21 -38
- package/src/time/tz-context.ts +44 -85
- package/src/ui-types/app-schema.ts +12 -0
- package/src/ui-types/index.ts +20 -7
- package/src/utils/__tests__/safe-json-temporal.test.ts +18 -0
- package/src/utils/safe-json.ts +13 -1
- package/src/__tests__/raw-table.integration.test.ts +0 -116
- package/src/bun-db/__tests__/bun-test-stack.ts +0 -6
- package/src/db/__tests__/encryption.test.ts +0 -39
- package/src/db/encryption.ts +0 -39
- package/src/db/row-helpers.ts +0 -4
- package/src/engine/__tests__/raw-table.test.ts +0 -150
- package/src/engine/__tests__/unmanaged-table.test.ts +0 -127
- package/src/engine/feature-ast/__tests__/visual-tree-parse.test.ts +0 -184
- package/src/engine/feature-ast/extractors/round4.ts +0 -1366
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { InMemoryKmsAdapter, PII_CIPHERTEXT_PREFIX, PII_ERASED_SENTINEL } from "../../crypto";
|
|
2
3
|
import { asRawClient } from "../../db/query";
|
|
3
4
|
import { createBooleanField, createEntity, createTextField } from "../../engine";
|
|
4
|
-
import { append, createEventsTable } from "../../event-store";
|
|
5
|
+
import { append, createEventsTable, loadEventsAfterVersion } from "../../event-store";
|
|
5
6
|
import type { EntityCache } from "../../pipeline/entity-cache";
|
|
6
7
|
import {
|
|
7
8
|
createTestDb,
|
|
@@ -10,7 +11,8 @@ import {
|
|
|
10
11
|
testTenantId,
|
|
11
12
|
unsafeCreateEntityTable,
|
|
12
13
|
} from "../../stack";
|
|
13
|
-
import {
|
|
14
|
+
import { createTestEnvelopeCipher } from "../../testing";
|
|
15
|
+
import { applyEntityEvent } from "../apply-entity-event";
|
|
14
16
|
import { resetEntityFieldEncryptionCacheForTests } from "../entity-field-encryption";
|
|
15
17
|
import { createEventStoreExecutor } from "../event-store-executor";
|
|
16
18
|
import { buildEntityTable } from "../table-builder";
|
|
@@ -122,17 +124,18 @@ describe("event-store-executor", () => {
|
|
|
122
124
|
});
|
|
123
125
|
});
|
|
124
126
|
|
|
125
|
-
// Sensitive
|
|
126
|
-
//
|
|
127
|
-
//
|
|
128
|
-
//
|
|
129
|
-
//
|
|
127
|
+
// Sensitive fields (#967): passwords/tokens/IBANs must be ciphertext-at-rest
|
|
128
|
+
// (boot validates sensitive ⇒ pii | encrypted). The event log carries the
|
|
129
|
+
// table ciphertext — replay reproduces the row byte-identically. Plaintext
|
|
130
|
+
// never lands in permanent history; the caller-facing event echo strips the
|
|
131
|
+
// field entirely (#820).
|
|
132
|
+
const SENSITIVE_TEST_KEY = Buffer.from("s3nS!t1vE.kP9xQ2@wN!vL$hR5yT8eU0").toString("base64");
|
|
130
133
|
const sensitiveEntity = createEntity({
|
|
131
134
|
table: "read_es_exec_sensitive",
|
|
132
135
|
fields: {
|
|
133
136
|
email: createTextField({ required: true }),
|
|
134
|
-
passwordHash: createTextField({ sensitive: true }),
|
|
135
|
-
apiToken: createTextField({ sensitive: true }),
|
|
137
|
+
passwordHash: createTextField({ sensitive: true, encrypted: true }),
|
|
138
|
+
apiToken: createTextField({ sensitive: true, encrypted: true }),
|
|
136
139
|
},
|
|
137
140
|
softDelete: true,
|
|
138
141
|
});
|
|
@@ -141,6 +144,7 @@ const sensitiveTable = buildEntityTable("esExecSensitive", sensitiveEntity);
|
|
|
141
144
|
describe("event-store-executor — sensitive fields", () => {
|
|
142
145
|
const crud = createEventStoreExecutor(sensitiveTable, sensitiveEntity, {
|
|
143
146
|
entityName: "esExecSensitive",
|
|
147
|
+
encryption: createTestEnvelopeCipher(SENSITIVE_TEST_KEY),
|
|
144
148
|
});
|
|
145
149
|
|
|
146
150
|
beforeAll(async () => {
|
|
@@ -170,26 +174,35 @@ describe("event-store-executor — sensitive fields", () => {
|
|
|
170
174
|
};
|
|
171
175
|
}
|
|
172
176
|
|
|
173
|
-
test("create event payload
|
|
177
|
+
test("create event payload carries sensitive fields as table ciphertext; echo strips them", async () => {
|
|
174
178
|
const result = await crud.create(
|
|
175
179
|
{ email: "s@test.de", passwordHash: "pw-hash-123", apiToken: "tok-abc" },
|
|
176
180
|
adminUser,
|
|
177
181
|
tdb,
|
|
178
182
|
);
|
|
179
183
|
if (!result.isSuccess) throw new Error("create failed");
|
|
180
|
-
//
|
|
184
|
+
// Caller-facing row: plaintext preserved.
|
|
181
185
|
expect(result.data.data["passwordHash"]).toBe("pw-hash-123");
|
|
182
186
|
expect(result.data.data["apiToken"]).toBe("tok-abc");
|
|
183
187
|
|
|
184
|
-
// Event payload:
|
|
188
|
+
// Event payload: ciphertext, byte-identical to the table column (#967).
|
|
185
189
|
const event = await lastEvent();
|
|
186
190
|
expect(event.type).toBe("esExecSensitive.created");
|
|
187
191
|
expect(event.payload["email"]).toBe("s@test.de");
|
|
188
|
-
expect(event.payload["passwordHash"]).
|
|
189
|
-
expect(event.payload["
|
|
192
|
+
expect(event.payload["passwordHash"]).toBeDefined();
|
|
193
|
+
expect(event.payload["passwordHash"]).not.toBe("pw-hash-123");
|
|
194
|
+
const rawRows = (await asRawClient(testDb.db).unsafe(
|
|
195
|
+
`SELECT password_hash FROM read_es_exec_sensitive WHERE email = 's@test.de' LIMIT 1`,
|
|
196
|
+
)) as Array<{ password_hash: string }>;
|
|
197
|
+
expect(event.payload["passwordHash"]).toBe(rawRows[0]!.password_hash);
|
|
198
|
+
|
|
199
|
+
// Caller-facing event echo stays stripped (#820).
|
|
200
|
+
if (!result.data.event) throw new Error("no event echo");
|
|
201
|
+
expect(result.data.event.payload["passwordHash"]).toBeUndefined();
|
|
202
|
+
expect(result.data.event.payload["apiToken"]).toBeUndefined();
|
|
190
203
|
});
|
|
191
204
|
|
|
192
|
-
test("update event
|
|
205
|
+
test("update event carries ciphertext in BOTH changes and previous", async () => {
|
|
193
206
|
const created = await crud.create(
|
|
194
207
|
{ email: "u@test.de", passwordHash: "old-hash", apiToken: "old-tok" },
|
|
195
208
|
adminUser,
|
|
@@ -213,16 +226,29 @@ describe("event-store-executor — sensitive fields", () => {
|
|
|
213
226
|
previous: { email?: string; passwordHash?: string; apiToken?: string };
|
|
214
227
|
}>();
|
|
215
228
|
expect(event.type).toBe("esExecSensitive.updated");
|
|
216
|
-
// Changes: email
|
|
229
|
+
// Changes: email plaintext (public), passwordHash ciphertext.
|
|
217
230
|
expect(event.payload.changes.email).toBe("u2@test.de");
|
|
218
|
-
expect(event.payload.changes.passwordHash).
|
|
219
|
-
|
|
231
|
+
expect(event.payload.changes.passwordHash).toBeDefined();
|
|
232
|
+
expect(event.payload.changes.passwordHash).not.toBe("new-hash");
|
|
233
|
+
// Previous: email plaintext, passwordHash + apiToken ciphertext.
|
|
220
234
|
expect(event.payload.previous.email).toBe("u@test.de");
|
|
221
|
-
expect(event.payload.previous.passwordHash).
|
|
222
|
-
expect(event.payload.previous.
|
|
235
|
+
expect(event.payload.previous.passwordHash).toBeDefined();
|
|
236
|
+
expect(event.payload.previous.passwordHash).not.toBe("old-hash");
|
|
237
|
+
expect(event.payload.previous.apiToken).toBeDefined();
|
|
238
|
+
expect(event.payload.previous.apiToken).not.toBe("old-tok");
|
|
239
|
+
|
|
240
|
+
// Caller-facing event echo stays stripped (#820).
|
|
241
|
+
if (!result.data.event) throw new Error("no event echo");
|
|
242
|
+
const echoPayload = result.data.event.payload as {
|
|
243
|
+
changes: Record<string, unknown>;
|
|
244
|
+
previous: Record<string, unknown>;
|
|
245
|
+
};
|
|
246
|
+
expect(echoPayload.changes["passwordHash"]).toBeUndefined();
|
|
247
|
+
expect(echoPayload.previous["passwordHash"]).toBeUndefined();
|
|
248
|
+
expect(echoPayload.previous["apiToken"]).toBeUndefined();
|
|
223
249
|
});
|
|
224
250
|
|
|
225
|
-
test("delete event
|
|
251
|
+
test("delete event carries ciphertext in previous", async () => {
|
|
226
252
|
const created = await crud.create(
|
|
227
253
|
{ email: "d@test.de", passwordHash: "pw", apiToken: "tk" },
|
|
228
254
|
adminUser,
|
|
@@ -238,11 +264,13 @@ describe("event-store-executor — sensitive fields", () => {
|
|
|
238
264
|
const event = await lastEvent<SensitivePrevious>();
|
|
239
265
|
expect(event.type).toBe("esExecSensitive.deleted");
|
|
240
266
|
expect(event.payload.previous.email).toBe("d@test.de");
|
|
241
|
-
expect(event.payload.previous.passwordHash).
|
|
242
|
-
expect(event.payload.previous.
|
|
267
|
+
expect(event.payload.previous.passwordHash).toBeDefined();
|
|
268
|
+
expect(event.payload.previous.passwordHash).not.toBe("pw");
|
|
269
|
+
expect(event.payload.previous.apiToken).toBeDefined();
|
|
270
|
+
expect(event.payload.previous.apiToken).not.toBe("tk");
|
|
243
271
|
});
|
|
244
272
|
|
|
245
|
-
test("restore event
|
|
273
|
+
test("restore event carries ciphertext in previous", async () => {
|
|
246
274
|
const created = await crud.create(
|
|
247
275
|
{ email: "r@test.de", passwordHash: "pw", apiToken: "tk" },
|
|
248
276
|
adminUser,
|
|
@@ -259,8 +287,10 @@ describe("event-store-executor — sensitive fields", () => {
|
|
|
259
287
|
const event = await lastEvent<SensitivePrevious>();
|
|
260
288
|
expect(event.type).toBe("esExecSensitive.restored");
|
|
261
289
|
expect(event.payload.previous.email).toBe("r@test.de");
|
|
262
|
-
expect(event.payload.previous.passwordHash).
|
|
263
|
-
expect(event.payload.previous.
|
|
290
|
+
expect(event.payload.previous.passwordHash).toBeDefined();
|
|
291
|
+
expect(event.payload.previous.passwordHash).not.toBe("pw");
|
|
292
|
+
expect(event.payload.previous.apiToken).toBeDefined();
|
|
293
|
+
expect(event.payload.previous.apiToken).not.toBe("tk");
|
|
264
294
|
});
|
|
265
295
|
});
|
|
266
296
|
|
|
@@ -324,7 +354,7 @@ const encryptedSoftDeleteEntity = createEntity({
|
|
|
324
354
|
const encryptedSoftDeleteTable = buildEntityTable("esExecEncSoft", encryptedSoftDeleteEntity);
|
|
325
355
|
|
|
326
356
|
describe("event-store-executor — encrypted fields", () => {
|
|
327
|
-
const encryption =
|
|
357
|
+
const encryption = createTestEnvelopeCipher(ENCRYPTION_TEST_KEY);
|
|
328
358
|
const crud = createEventStoreExecutor(encryptedTable, encryptedEntity, {
|
|
329
359
|
entityName: "esExecEncrypted",
|
|
330
360
|
encryption,
|
|
@@ -337,7 +367,6 @@ describe("event-store-executor — encrypted fields", () => {
|
|
|
337
367
|
);
|
|
338
368
|
|
|
339
369
|
beforeAll(async () => {
|
|
340
|
-
process.env["ENCRYPTION_KEY"] = ENCRYPTION_TEST_KEY;
|
|
341
370
|
resetEntityFieldEncryptionCacheForTests();
|
|
342
371
|
await unsafeCreateEntityTable(testDb.db, encryptedEntity, "esExecEncrypted");
|
|
343
372
|
await unsafeCreateEntityTable(testDb.db, encryptedSoftDeleteEntity, "esExecEncSoft");
|
|
@@ -390,6 +419,81 @@ describe("event-store-executor — encrypted fields", () => {
|
|
|
390
419
|
expect(updated.data.data["secretNote"]).toBe("new-note");
|
|
391
420
|
});
|
|
392
421
|
|
|
422
|
+
test("update WITHOUT skipUnchanged still re-encrypts a resubmitted-but-unchanged encrypted field (KEK-rotation/backfill contract)", async () => {
|
|
423
|
+
// Regression guard: direct executor.update() callers that don't opt into
|
|
424
|
+
// skipUnchanged rely on today's always-re-encrypt behavior to force a
|
|
425
|
+
// fresh ciphertext for an unchanged plaintext — e.g. auth-mfa's
|
|
426
|
+
// KEK-rotation job (reencrypt.job.ts) and the user-data-rights #494
|
|
427
|
+
// backfill both resubmit the CURRENT value on purpose to land a real
|
|
428
|
+
// event. A blanket value-diff here would silently break both.
|
|
429
|
+
const created = await crud.create(
|
|
430
|
+
{ email: "force-enc@test.de", secretNote: "same-note" },
|
|
431
|
+
adminUser,
|
|
432
|
+
tdb,
|
|
433
|
+
);
|
|
434
|
+
if (!created.isSuccess) throw new Error("create failed");
|
|
435
|
+
|
|
436
|
+
const before = (await asRawClient(testDb.db).unsafe(
|
|
437
|
+
`SELECT secret_note FROM read_es_exec_encrypted WHERE email = 'force-enc@test.de' LIMIT 1`,
|
|
438
|
+
)) as Array<{ secret_note: string }>;
|
|
439
|
+
|
|
440
|
+
const updated = await crud.update(
|
|
441
|
+
{ id: created.data.id, version: 1, changes: { secretNote: "same-note" } },
|
|
442
|
+
adminUser,
|
|
443
|
+
tdb,
|
|
444
|
+
);
|
|
445
|
+
if (!updated.isSuccess) throw new Error("update failed");
|
|
446
|
+
|
|
447
|
+
const after = (await asRawClient(testDb.db).unsafe(
|
|
448
|
+
`SELECT secret_note FROM read_es_exec_encrypted WHERE email = 'force-enc@test.de' LIMIT 1`,
|
|
449
|
+
)) as Array<{ secret_note: string }>;
|
|
450
|
+
|
|
451
|
+
// Fresh AEAD nonce per encrypt call → different ciphertext even though
|
|
452
|
+
// the plaintext is identical. Byte-identical would mean the write was
|
|
453
|
+
// skipped, which is exactly what would break KEK rotation.
|
|
454
|
+
expect(after[0]?.secret_note).toBeDefined();
|
|
455
|
+
expect(after[0]?.secret_note).not.toBe(before[0]?.secret_note);
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
test("update with skipUnchanged: true omits a resubmitted-but-unchanged encrypted field — no phantom re-encrypt (#464)", async () => {
|
|
459
|
+
const created = await crud.create(
|
|
460
|
+
{ email: "noop-enc@test.de", secretNote: "same-note" },
|
|
461
|
+
adminUser,
|
|
462
|
+
tdb,
|
|
463
|
+
);
|
|
464
|
+
if (!created.isSuccess) throw new Error("create failed");
|
|
465
|
+
|
|
466
|
+
const before = (await asRawClient(testDb.db).unsafe(
|
|
467
|
+
`SELECT secret_note FROM read_es_exec_encrypted WHERE email = 'noop-enc@test.de' LIMIT 1`,
|
|
468
|
+
)) as Array<{ secret_note: string }>;
|
|
469
|
+
|
|
470
|
+
const updated = await crud.update(
|
|
471
|
+
{
|
|
472
|
+
id: created.data.id,
|
|
473
|
+
version: 1,
|
|
474
|
+
changes: { secretNote: "same-note", email: "noop-enc-2@test.de" },
|
|
475
|
+
},
|
|
476
|
+
adminUser,
|
|
477
|
+
tdb,
|
|
478
|
+
{ skipUnchanged: true },
|
|
479
|
+
);
|
|
480
|
+
if (!updated.isSuccess) throw new Error("update failed");
|
|
481
|
+
|
|
482
|
+
const after = (await asRawClient(testDb.db).unsafe(
|
|
483
|
+
`SELECT secret_note FROM read_es_exec_encrypted WHERE email = 'noop-enc-2@test.de' LIMIT 1`,
|
|
484
|
+
)) as Array<{ secret_note: string }>;
|
|
485
|
+
|
|
486
|
+
// Re-encrypting the same plaintext produces a fresh AEAD nonce → a
|
|
487
|
+
// different ciphertext even for an unchanged value. Byte-identical
|
|
488
|
+
// ciphertext proves the column was never touched by this update.
|
|
489
|
+
expect(after[0]?.secret_note).toBe(before[0]?.secret_note);
|
|
490
|
+
|
|
491
|
+
const rows = (await asRawClient(testDb.db).unsafe(
|
|
492
|
+
`SELECT payload FROM kumiko_events WHERE type = 'esExecEncrypted.updated' ORDER BY id DESC LIMIT 1`,
|
|
493
|
+
)) as Array<{ payload: { changes?: Record<string, unknown> } }>;
|
|
494
|
+
expect(rows[0]?.payload.changes).not.toHaveProperty("secretNote");
|
|
495
|
+
});
|
|
496
|
+
|
|
393
497
|
test("update's persisted event carries ciphertext (not plaintext) for an encrypted field in `previous`", async () => {
|
|
394
498
|
// Regression: `previous` in the STORED event came from loadById(), which
|
|
395
499
|
// decrypts — appending it unchanged would put the plaintext of an
|
|
@@ -524,7 +628,7 @@ describe("event-store-executor — entity cache + encrypted fields", () => {
|
|
|
524
628
|
store.delete(`${tenantId}:${name}:${id}`);
|
|
525
629
|
},
|
|
526
630
|
};
|
|
527
|
-
const encryption =
|
|
631
|
+
const encryption = createTestEnvelopeCipher(ENCRYPTION_TEST_KEY);
|
|
528
632
|
const cachedEncryptedCrud = createEventStoreExecutor(encryptedTable, encryptedEntity, {
|
|
529
633
|
entityName: "esExecEncrypted",
|
|
530
634
|
entityCache,
|
|
@@ -554,4 +658,170 @@ describe("event-store-executor — entity cache + encrypted fields", () => {
|
|
|
554
658
|
const second = await cachedEncryptedCrud.detail({ id }, adminUser, tdb);
|
|
555
659
|
expect(second?.["secretNote"]).toBe("cache-plaintext-note");
|
|
556
660
|
});
|
|
661
|
+
|
|
662
|
+
// Regression twin pack for the list() path:
|
|
663
|
+
// 1. list decrypted BEFORE the snake→camel coercion, so any multi-word
|
|
664
|
+
// encrypted/pii field (secret_note vs secretNote) came back as raw
|
|
665
|
+
// ciphertext to the caller.
|
|
666
|
+
// 2. list's mset cached the decrypted rows — plaintext in Redis, the
|
|
667
|
+
// exact leak the detail() path re-encrypts to avoid.
|
|
668
|
+
test("list() returns plaintext for camelCase encrypted fields and caches ciphertext", async () => {
|
|
669
|
+
const created = await cachedEncryptedCrud.create(
|
|
670
|
+
{ email: "cache-list@test.de", secretNote: "list-plaintext-note" },
|
|
671
|
+
adminUser,
|
|
672
|
+
tdb,
|
|
673
|
+
);
|
|
674
|
+
if (!created.isSuccess) throw new Error("create failed");
|
|
675
|
+
const storeKey = `${adminUser.tenantId}:esExecEncrypted:${created.data.id}`;
|
|
676
|
+
store.clear();
|
|
677
|
+
|
|
678
|
+
const list = await cachedEncryptedCrud.list({}, adminUser, tdb);
|
|
679
|
+
const listed = list.rows.find((r) => r["id"] === created.data.id);
|
|
680
|
+
expect(listed?.["secretNote"]).toBe("list-plaintext-note");
|
|
681
|
+
|
|
682
|
+
const cachedRaw = store.get(storeKey);
|
|
683
|
+
expect(cachedRaw?.["secretNote"]).toBeDefined();
|
|
684
|
+
expect(cachedRaw?.["secretNote"]).not.toBe("list-plaintext-note");
|
|
685
|
+
});
|
|
686
|
+
});
|
|
687
|
+
|
|
688
|
+
// PII subject encryption (crypto-shredding, #724 phase C): pii/userOwned/
|
|
689
|
+
// tenantOwned fields are encrypted with the erase subject's DEK. Event
|
|
690
|
+
// payload AND projection row carry ciphertext (live == rebuild); erasing the
|
|
691
|
+
// subject key renders every value as the [[erased]] sentinel without
|
|
692
|
+
// touching the immutable log.
|
|
693
|
+
const piiEntity = createEntity({
|
|
694
|
+
table: "read_es_exec_pii",
|
|
695
|
+
fields: {
|
|
696
|
+
email: createTextField({ required: true, pii: true }),
|
|
697
|
+
note: createTextField({ userOwned: { ownerField: "authorId" } }),
|
|
698
|
+
authorId: createTextField(),
|
|
699
|
+
plain: createTextField(),
|
|
700
|
+
},
|
|
701
|
+
});
|
|
702
|
+
const piiTable = buildEntityTable("esExecPii", piiEntity);
|
|
703
|
+
|
|
704
|
+
describe("event-store-executor — pii subject encryption", () => {
|
|
705
|
+
const kms = new InMemoryKmsAdapter();
|
|
706
|
+
const crud = createEventStoreExecutor(piiTable, piiEntity, {
|
|
707
|
+
entityName: "esExecPii",
|
|
708
|
+
kms,
|
|
709
|
+
});
|
|
710
|
+
|
|
711
|
+
beforeAll(async () => {
|
|
712
|
+
await unsafeCreateEntityTable(testDb.db, piiEntity, "esExecPii");
|
|
713
|
+
});
|
|
714
|
+
|
|
715
|
+
beforeEach(async () => {
|
|
716
|
+
await asRawClient(testDb.db).unsafe(
|
|
717
|
+
`TRUNCATE kumiko_events, read_es_exec_pii RESTART IDENTITY CASCADE`,
|
|
718
|
+
);
|
|
719
|
+
});
|
|
720
|
+
|
|
721
|
+
test("create: projection row + event payload are ciphertext, API reads plaintext", async () => {
|
|
722
|
+
const result = await crud.create({ email: "pii@test.de", plain: "visible" }, adminUser, tdb);
|
|
723
|
+
if (!result.isSuccess) throw new Error("create failed");
|
|
724
|
+
expect(result.data.data["email"]).toBe("pii@test.de");
|
|
725
|
+
|
|
726
|
+
const rawRows = (await asRawClient(testDb.db).unsafe(
|
|
727
|
+
`SELECT email, plain FROM read_es_exec_pii LIMIT 1`,
|
|
728
|
+
)) as Array<{ email: string; plain: string }>;
|
|
729
|
+
expect(rawRows[0]!.email).toStartWith(`${PII_CIPHERTEXT_PREFIX}user:${result.data.id}:`);
|
|
730
|
+
expect(rawRows[0]!.plain).toBe("visible");
|
|
731
|
+
|
|
732
|
+
const events = (await asRawClient(testDb.db).unsafe(
|
|
733
|
+
`SELECT payload FROM kumiko_events WHERE type = 'esExecPii.created' LIMIT 1`,
|
|
734
|
+
)) as Array<{ payload: { email?: string } }>;
|
|
735
|
+
expect(events[0]?.payload.email).toStartWith(PII_CIPHERTEXT_PREFIX);
|
|
736
|
+
|
|
737
|
+
const detail = await crud.detail({ id: result.data.id }, adminUser, tdb);
|
|
738
|
+
expect(detail?.["email"]).toBe("pii@test.de");
|
|
739
|
+
const list = await crud.list({}, adminUser, tdb);
|
|
740
|
+
expect(list.rows[0]?.["email"]).toBe("pii@test.de");
|
|
741
|
+
});
|
|
742
|
+
|
|
743
|
+
test("userOwned update without ownerField in changes resolves via the merged row", async () => {
|
|
744
|
+
const created = await crud.create(
|
|
745
|
+
{ email: "owner@test.de", note: "v1", authorId: TestUsers.admin.id },
|
|
746
|
+
adminUser,
|
|
747
|
+
tdb,
|
|
748
|
+
);
|
|
749
|
+
if (!created.isSuccess) throw new Error("create failed");
|
|
750
|
+
|
|
751
|
+
const updated = await crud.update(
|
|
752
|
+
{ id: created.data.id, version: 1, changes: { note: "v2" } },
|
|
753
|
+
adminUser,
|
|
754
|
+
tdb,
|
|
755
|
+
);
|
|
756
|
+
if (!updated.isSuccess) throw new Error("update failed");
|
|
757
|
+
expect(updated.data.data["note"]).toBe("v2");
|
|
758
|
+
|
|
759
|
+
const rawRows = (await asRawClient(testDb.db).unsafe(
|
|
760
|
+
`SELECT note FROM read_es_exec_pii LIMIT 1`,
|
|
761
|
+
)) as Array<{ note: string }>;
|
|
762
|
+
expect(rawRows[0]!.note).toStartWith(`${PII_CIPHERTEXT_PREFIX}user:${TestUsers.admin.id}:`);
|
|
763
|
+
});
|
|
764
|
+
|
|
765
|
+
test("eraseKey: detail renders the sentinel, stored events stay byte-identical", async () => {
|
|
766
|
+
const created = await crud.create({ email: "forget@test.de" }, adminUser, tdb);
|
|
767
|
+
if (!created.isSuccess) throw new Error("create failed");
|
|
768
|
+
|
|
769
|
+
const eventsBefore = (await asRawClient(testDb.db).unsafe(
|
|
770
|
+
`SELECT payload FROM kumiko_events WHERE type = 'esExecPii.created' LIMIT 1`,
|
|
771
|
+
)) as Array<{ payload: { email?: string } }>;
|
|
772
|
+
|
|
773
|
+
await kms.eraseKey({ kind: "user", userId: String(created.data.id) });
|
|
774
|
+
|
|
775
|
+
const detail = await crud.detail({ id: created.data.id }, adminUser, tdb);
|
|
776
|
+
expect(detail?.["email"]).toBe(PII_ERASED_SENTINEL);
|
|
777
|
+
|
|
778
|
+
const eventsAfter = (await asRawClient(testDb.db).unsafe(
|
|
779
|
+
`SELECT payload FROM kumiko_events WHERE type = 'esExecPii.created' LIMIT 1`,
|
|
780
|
+
)) as Array<{ payload: { email?: string } }>;
|
|
781
|
+
expect(eventsAfter[0]?.payload.email).toBe(eventsBefore[0]!.payload.email!);
|
|
782
|
+
});
|
|
783
|
+
|
|
784
|
+
test("rebuild after erase resurrects no plaintext (replay writes ciphertext, reads render sentinel)", async () => {
|
|
785
|
+
const created = await crud.create({ email: "rebuild@test.de" }, adminUser, tdb);
|
|
786
|
+
if (!created.isSuccess) throw new Error("create failed");
|
|
787
|
+
await kms.eraseKey({ kind: "user", userId: String(created.data.id) });
|
|
788
|
+
|
|
789
|
+
// Same code path rebuildProjection replays: applyEntityEvent on the
|
|
790
|
+
// PERSISTED event (ciphertext payload). The response echo carries the
|
|
791
|
+
// plaintext payload since #820 and must not be used as a replay source.
|
|
792
|
+
const responseEvent = created.data.event as { payload: Record<string, unknown> } | undefined;
|
|
793
|
+
expect(responseEvent?.payload["email"]).toBe("rebuild@test.de");
|
|
794
|
+
await asRawClient(testDb.db).unsafe(`TRUNCATE read_es_exec_pii RESTART IDENTITY CASCADE`);
|
|
795
|
+
const [storedEvent] = await loadEventsAfterVersion(
|
|
796
|
+
testDb.db,
|
|
797
|
+
String(created.data.id),
|
|
798
|
+
adminUser.tenantId,
|
|
799
|
+
0,
|
|
800
|
+
);
|
|
801
|
+
if (!storedEvent) throw new Error("no persisted event for the aggregate");
|
|
802
|
+
const applied = await applyEntityEvent(storedEvent, piiTable, piiEntity, tdb.raw);
|
|
803
|
+
expect(applied.kind).toBe("applied");
|
|
804
|
+
|
|
805
|
+
const rawRows = (await asRawClient(testDb.db).unsafe(
|
|
806
|
+
`SELECT email FROM read_es_exec_pii LIMIT 1`,
|
|
807
|
+
)) as Array<{ email: string }>;
|
|
808
|
+
expect(rawRows[0]!.email).toStartWith(PII_CIPHERTEXT_PREFIX);
|
|
809
|
+
expect(rawRows[0]!.email).not.toContain("rebuild@test.de");
|
|
810
|
+
|
|
811
|
+
const detail = await crud.detail({ id: created.data.id }, adminUser, tdb);
|
|
812
|
+
expect(detail?.["email"]).toBe(PII_ERASED_SENTINEL);
|
|
813
|
+
});
|
|
814
|
+
|
|
815
|
+
test("without a kms adapter the engine is off: plaintext row (pre-#724 behavior)", async () => {
|
|
816
|
+
const plainCrud = createEventStoreExecutor(piiTable, piiEntity, {
|
|
817
|
+
entityName: "esExecPii",
|
|
818
|
+
});
|
|
819
|
+
const created = await plainCrud.create({ email: "off@test.de" }, adminUser, tdb);
|
|
820
|
+
if (!created.isSuccess) throw new Error("create failed");
|
|
821
|
+
|
|
822
|
+
const rawRows = (await asRawClient(testDb.db).unsafe(
|
|
823
|
+
`SELECT email FROM read_es_exec_pii LIMIT 1`,
|
|
824
|
+
)) as Array<{ email: string }>;
|
|
825
|
+
expect(rawRows[0]!.email).toBe("off@test.de");
|
|
826
|
+
});
|
|
557
827
|
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Regression test for #255-class drift: storeTables must appear in
|
|
2
|
+
// enumerateFeatureTableSources so setupTestStack's auto-push and
|
|
3
|
+
// collectTableMetas see the exact same table set. Before this fix,
|
|
4
|
+
// unmanaged tables were only handled by collectTableMetas directly, so any
|
|
5
|
+
// app relying on setupTestStack's ephemeral DB (Playwright/e2e) never got
|
|
6
|
+
// its store tables created — e.g. the bundled "sessions" feature's
|
|
7
|
+
// store_user_sessions, breaking every login in an ephemeral test stack.
|
|
8
|
+
|
|
9
|
+
import { describe, expect, test } from "bun:test";
|
|
10
|
+
import { defineFeature } from "../../engine/define-feature";
|
|
11
|
+
import { defineUnmanagedTable } from "../entity-table-meta";
|
|
12
|
+
import { enumerateFeatureTableSources } from "../feature-table-sources";
|
|
13
|
+
|
|
14
|
+
const probeMeta = defineUnmanagedTable({
|
|
15
|
+
tableName: "ftst_probe",
|
|
16
|
+
columns: [{ name: "id", pgType: "text", notNull: true, primaryKey: true }],
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
describe("enumerateFeatureTableSources — storeTables", () => {
|
|
20
|
+
test("includes a feature's storeTable as a table source", () => {
|
|
21
|
+
const feature = defineFeature("probe", (r) => {
|
|
22
|
+
r.storeTable(probeMeta, { reason: "direct-write store" });
|
|
23
|
+
});
|
|
24
|
+
const sources = enumerateFeatureTableSources(feature);
|
|
25
|
+
const entry = sources.find((s) => s.origin.includes("ftst_probe"));
|
|
26
|
+
expect(entry).toBeDefined();
|
|
27
|
+
expect(entry?.table).toBe(probeMeta);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("a feature with no storeTable contributes nothing extra", () => {
|
|
31
|
+
const feature = defineFeature("plain", () => {});
|
|
32
|
+
expect(enumerateFeatureTableSources(feature)).toEqual([]);
|
|
33
|
+
});
|
|
34
|
+
});
|