@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
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
// PII field encryption with per-subject DEKs (crypto-shredding, #724 phase C).
|
|
2
|
+
// Same executor hook points as `encrypted: true`, but the key belongs to the
|
|
3
|
+
// erase subject — kms.eraseKey(subject) makes every value unreadable at once.
|
|
4
|
+
// Storage format is a sniffable string that fits existing text columns and
|
|
5
|
+
// names its subject inline, so decrypt needs no schema change and no resolver:
|
|
6
|
+
// kumiko-pii:v2:<subjectKey>:<base64(iv|tag|ciphertext)>
|
|
7
|
+
// v2 GCM-binds the ciphertext to `subjectKey|field` as AAD (#1263) — subject
|
|
8
|
+
// selects the DEK, field stops a cut-and-paste between two fields of the
|
|
9
|
+
// SAME subject (same DEK, so key selection alone can't catch that) from
|
|
10
|
+
// decrypting silently. v1 (no AAD) stays decrypt-only for pre-#1263 rows;
|
|
11
|
+
// every new write emits v2.
|
|
12
|
+
|
|
13
|
+
import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
|
|
14
|
+
import type { EntityDefinition } from "../engine/types/fields";
|
|
15
|
+
import type { TenantId } from "../engine/types/identifiers";
|
|
16
|
+
import {
|
|
17
|
+
isLocalKeyKmsAdapter,
|
|
18
|
+
KeyAlreadyExistsError,
|
|
19
|
+
KeyErasedError,
|
|
20
|
+
KeyNotFoundError,
|
|
21
|
+
type KmsAdapter,
|
|
22
|
+
type KmsContext,
|
|
23
|
+
type LocalKeyKmsAdapter,
|
|
24
|
+
type SubjectDek,
|
|
25
|
+
type SubjectId,
|
|
26
|
+
subjectIdFromKey,
|
|
27
|
+
subjectIdToKey,
|
|
28
|
+
} from "./kms-adapter";
|
|
29
|
+
import { resolveSubjectForField } from "./subject-resolver";
|
|
30
|
+
|
|
31
|
+
// Spec value (crypto-shredding.md) — renderers show it verbatim.
|
|
32
|
+
export const PII_ERASED_SENTINEL = "[[erased]]";
|
|
33
|
+
|
|
34
|
+
const PII_CIPHERTEXT_PREFIX_V1 = "kumiko-pii:v1:";
|
|
35
|
+
export const PII_CIPHERTEXT_PREFIX = "kumiko-pii:v2:";
|
|
36
|
+
const IV_LENGTH = 12;
|
|
37
|
+
const AUTH_TAG_LENGTH = 16;
|
|
38
|
+
|
|
39
|
+
export function isPiiCiphertext(value: unknown): value is string {
|
|
40
|
+
return (
|
|
41
|
+
typeof value === "string" &&
|
|
42
|
+
(value.startsWith(PII_CIPHERTEXT_PREFIX) || value.startsWith(PII_CIPHERTEXT_PREFIX_V1))
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function buildAad(subject: SubjectId, field: string): Buffer {
|
|
47
|
+
return Buffer.from(`${subjectIdToKey(subject)}|${field}`, "utf8");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function encryptValue(
|
|
51
|
+
subject: SubjectId,
|
|
52
|
+
dek: SubjectDek,
|
|
53
|
+
plaintext: string,
|
|
54
|
+
field: string,
|
|
55
|
+
): string {
|
|
56
|
+
const iv = randomBytes(IV_LENGTH);
|
|
57
|
+
const cipher = createCipheriv("aes-256-gcm", dek, iv);
|
|
58
|
+
cipher.setAAD(buildAad(subject, field));
|
|
59
|
+
const ciphertext = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
|
|
60
|
+
const blob = Buffer.concat([iv, cipher.getAuthTag(), ciphertext]);
|
|
61
|
+
return `${PII_CIPHERTEXT_PREFIX}${subjectIdToKey(subject)}:${blob.toString("base64")}`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function parseCiphertext(value: string): { subject: SubjectId; blob: Buffer; hasAad: boolean } {
|
|
65
|
+
const hasAad = value.startsWith(PII_CIPHERTEXT_PREFIX);
|
|
66
|
+
const prefix = hasAad ? PII_CIPHERTEXT_PREFIX : PII_CIPHERTEXT_PREFIX_V1;
|
|
67
|
+
const rest = value.slice(prefix.length);
|
|
68
|
+
// subjectKey itself contains ":" ("user:<id>") — base64 never does, so the
|
|
69
|
+
// last ":" is always the key/blob separator.
|
|
70
|
+
const sep = rest.lastIndexOf(":");
|
|
71
|
+
if (sep === -1) throw new Error(`Malformed PII ciphertext (no subject/blob separator)`);
|
|
72
|
+
return {
|
|
73
|
+
subject: subjectIdFromKey(rest.slice(0, sep)),
|
|
74
|
+
blob: Buffer.from(rest.slice(sep + 1), "base64"),
|
|
75
|
+
hasAad,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function decryptValue(
|
|
80
|
+
dek: SubjectDek,
|
|
81
|
+
blob: Buffer,
|
|
82
|
+
subject: SubjectId,
|
|
83
|
+
field: string,
|
|
84
|
+
hasAad: boolean,
|
|
85
|
+
): string {
|
|
86
|
+
const iv = blob.subarray(0, IV_LENGTH);
|
|
87
|
+
const tag = blob.subarray(IV_LENGTH, IV_LENGTH + AUTH_TAG_LENGTH);
|
|
88
|
+
const ciphertext = blob.subarray(IV_LENGTH + AUTH_TAG_LENGTH);
|
|
89
|
+
const decipher = createDecipheriv("aes-256-gcm", dek, iv);
|
|
90
|
+
if (hasAad) decipher.setAAD(buildAad(subject, field));
|
|
91
|
+
decipher.setAuthTag(tag);
|
|
92
|
+
return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("utf8");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// First write to a PII field of a new subject creates the key implicitly
|
|
96
|
+
// (user-signup / tenant-create need no extra hook). The AlreadyExists catch
|
|
97
|
+
// covers the concurrent-first-write race; a tombstoned subject re-throws
|
|
98
|
+
// KeyErasedError from the final getKey — writes to a forgotten subject fail.
|
|
99
|
+
async function getOrCreateDek(
|
|
100
|
+
kms: LocalKeyKmsAdapter,
|
|
101
|
+
subject: SubjectId,
|
|
102
|
+
ctx: KmsContext,
|
|
103
|
+
): Promise<SubjectDek> {
|
|
104
|
+
try {
|
|
105
|
+
return await kms.getKey(subject, ctx);
|
|
106
|
+
} catch (e) {
|
|
107
|
+
if (!(e instanceof KeyNotFoundError)) throw e;
|
|
108
|
+
}
|
|
109
|
+
try {
|
|
110
|
+
await kms.createKey(subject, ctx);
|
|
111
|
+
} catch (e) {
|
|
112
|
+
if (!(e instanceof KeyAlreadyExistsError)) throw e;
|
|
113
|
+
}
|
|
114
|
+
return kms.getKey(subject, ctx);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Single-value encrypt for callers that resolve the subject themselves
|
|
118
|
+
// (event-pii catalog, backfill). Ciphertext/sentinel inputs pass through —
|
|
119
|
+
// idempotent like the field-map variant. `field` names the value's storage
|
|
120
|
+
// slot (payload field / config key) — part of the AAD, so it must match the
|
|
121
|
+
// `field` passed to decryptPiiValueForSubject for the same value.
|
|
122
|
+
export async function encryptPiiValueForSubject(
|
|
123
|
+
kms: LocalKeyKmsAdapter,
|
|
124
|
+
subject: SubjectId,
|
|
125
|
+
value: string,
|
|
126
|
+
kmsCtx: KmsContext,
|
|
127
|
+
field: string,
|
|
128
|
+
): Promise<string> {
|
|
129
|
+
if (isPiiCiphertext(value) || value === PII_ERASED_SENTINEL) return value;
|
|
130
|
+
const dek = await getOrCreateDek(kms, subject, kmsCtx);
|
|
131
|
+
return encryptValue(subject, dek, value, field);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Single-value decrypt for callers that don't have an entity/field-map to
|
|
135
|
+
// pass through decryptPiiFieldValues (config values). The subject lives
|
|
136
|
+
// inside the ciphertext itself, so no subject/scope resolution is needed
|
|
137
|
+
// on this side — only the encrypt direction has to pick one. `field` must
|
|
138
|
+
// match the one used at encrypt time (AAD) or decrypt fails loud.
|
|
139
|
+
export async function decryptPiiValueForSubject(
|
|
140
|
+
kms: LocalKeyKmsAdapter,
|
|
141
|
+
value: string,
|
|
142
|
+
kmsCtx: KmsContext,
|
|
143
|
+
field: string,
|
|
144
|
+
): Promise<string> {
|
|
145
|
+
if (!isPiiCiphertext(value)) return value;
|
|
146
|
+
const { subject, blob, hasAad } = parseCiphertext(value);
|
|
147
|
+
try {
|
|
148
|
+
return decryptValue(await kms.getKey(subject, kmsCtx), blob, subject, field, hasAad);
|
|
149
|
+
} catch (e) {
|
|
150
|
+
if (!(e instanceof KeyErasedError)) throw e;
|
|
151
|
+
return PII_ERASED_SENTINEL;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export interface EncryptPiiOptions {
|
|
156
|
+
readonly onlyKeys?: Iterable<string>;
|
|
157
|
+
// Write-time tenant for tenantOwned fields on rows without a tenantId column.
|
|
158
|
+
readonly tenantId?: TenantId;
|
|
159
|
+
// Row to resolve subjects from when `row` is a partial (update changes may
|
|
160
|
+
// carry a pii field without its ownerField — the merged row still has it).
|
|
161
|
+
readonly subjectSource?: Record<string, unknown>;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export async function encryptPiiFieldValues(
|
|
165
|
+
row: Record<string, unknown>,
|
|
166
|
+
entity: EntityDefinition,
|
|
167
|
+
piiFields: readonly string[],
|
|
168
|
+
kms: LocalKeyKmsAdapter,
|
|
169
|
+
kmsCtx: KmsContext,
|
|
170
|
+
opts: EncryptPiiOptions = {},
|
|
171
|
+
): Promise<Record<string, unknown>> {
|
|
172
|
+
if (piiFields.length === 0) return row;
|
|
173
|
+
const only = opts.onlyKeys ? new Set(opts.onlyKeys) : null;
|
|
174
|
+
const subjectSource = opts.subjectSource ?? row;
|
|
175
|
+
const out = { ...row };
|
|
176
|
+
for (const name of piiFields) {
|
|
177
|
+
if (only && !only.has(name)) continue;
|
|
178
|
+
if (!(name in out)) continue;
|
|
179
|
+
const value = out[name];
|
|
180
|
+
if (value === null || value === undefined) continue;
|
|
181
|
+
// Re-encrypt paths (update previous, detail cache) may see values that
|
|
182
|
+
// are already ciphertext or the erased sentinel — both stay as-is.
|
|
183
|
+
if (isPiiCiphertext(value) || value === PII_ERASED_SENTINEL) continue;
|
|
184
|
+
if (typeof value !== "string") {
|
|
185
|
+
throw new Error(`PII field "${name}" must be a string, got ${typeof value}`);
|
|
186
|
+
}
|
|
187
|
+
const subject = resolveSubjectForField(entity, name, subjectSource, {
|
|
188
|
+
...(opts.tenantId !== undefined && { tenantId: opts.tenantId }),
|
|
189
|
+
});
|
|
190
|
+
// skip: collectPiiSubjectFields only yields annotated fields — null is unreachable, kept as a type guard
|
|
191
|
+
if (subject === null) continue;
|
|
192
|
+
const dek = await getOrCreateDek(kms, subject, kmsCtx);
|
|
193
|
+
out[name] = encryptValue(subject, dek, value, name);
|
|
194
|
+
}
|
|
195
|
+
return out;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export async function decryptPiiFieldValues(
|
|
199
|
+
row: Record<string, unknown>,
|
|
200
|
+
piiFields: readonly string[],
|
|
201
|
+
kms: LocalKeyKmsAdapter,
|
|
202
|
+
kmsCtx: KmsContext,
|
|
203
|
+
): Promise<Record<string, unknown>> {
|
|
204
|
+
if (piiFields.length === 0) return row;
|
|
205
|
+
const out = { ...row };
|
|
206
|
+
for (const name of piiFields) {
|
|
207
|
+
const value = out[name];
|
|
208
|
+
// Pre-engine plaintext rows pass through unchanged (mixed-state reads
|
|
209
|
+
// work during rollout; backfill is tracked in kumiko-framework#799).
|
|
210
|
+
if (!isPiiCiphertext(value)) continue;
|
|
211
|
+
const { subject, blob, hasAad } = parseCiphertext(value);
|
|
212
|
+
try {
|
|
213
|
+
out[name] = decryptValue(await kms.getKey(subject, kmsCtx), blob, subject, name, hasAad);
|
|
214
|
+
} catch (e) {
|
|
215
|
+
// KeyNotFound deliberately propagates: ciphertext without a key row
|
|
216
|
+
// means the key store is wrong (not shredded) — fail loud.
|
|
217
|
+
if (!(e instanceof KeyErasedError)) throw e;
|
|
218
|
+
out[name] = PII_ERASED_SENTINEL;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return out;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Boot-injected app-wide subject KMS, mirroring configureEntityFieldEncryption:
|
|
225
|
+
// run{Prod,Dev}App call configurePiiSubjectKms once; executors resolve lazily.
|
|
226
|
+
// Absent adapter = engine off (pii fields stay plaintext, pre-phase-C
|
|
227
|
+
// behavior) — the hard boot gate arrives with the prod-grade PgKmsAdapter
|
|
228
|
+
// (phase E); until then no production deployment can satisfy it.
|
|
229
|
+
let injectedKms: LocalKeyKmsAdapter | undefined;
|
|
230
|
+
|
|
231
|
+
export function configurePiiSubjectKms(adapter: KmsAdapter | undefined): void {
|
|
232
|
+
if (adapter !== undefined && !isLocalKeyKmsAdapter(adapter)) {
|
|
233
|
+
throw new Error(
|
|
234
|
+
"PII field encryption requires a local-key KMS adapter — remote-crypto " +
|
|
235
|
+
"(Vault transit) support lands with the BYOK adapter.",
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
injectedKms = adapter;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export function configuredPiiSubjectKms(): LocalKeyKmsAdapter | undefined {
|
|
242
|
+
return injectedKms;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** @internal test-only */
|
|
246
|
+
export function resetPiiSubjectKmsForTests(): void {
|
|
247
|
+
injectedKms = undefined;
|
|
248
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
KmsContext,
|
|
3
|
+
LocalKeyKmsAdapter,
|
|
4
|
+
SubjectDek,
|
|
5
|
+
SubjectId,
|
|
6
|
+
SubjectKey,
|
|
7
|
+
} from "./kms-adapter";
|
|
8
|
+
import { subjectIdToKey } from "./kms-adapter";
|
|
9
|
+
|
|
10
|
+
// Per-request DEK cache: a list rendering 50 comments of one author does one
|
|
11
|
+
// adapter round-trip, not 50. Only meaningful for local-key adapters —
|
|
12
|
+
// remote-crypto backends never release keys, every call is a round-trip.
|
|
13
|
+
export interface RequestKmsCache {
|
|
14
|
+
getKey(subject: SubjectId, ctx: KmsContext): Promise<SubjectDek>;
|
|
15
|
+
/** Drops one subject's cached DEK — called when its key is shredded mid-request. */
|
|
16
|
+
invalidate(subject: SubjectId): void;
|
|
17
|
+
clear(): void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function createRequestKmsCache(adapter: LocalKeyKmsAdapter): RequestKmsCache {
|
|
21
|
+
const cache = new Map<SubjectKey, SubjectDek>();
|
|
22
|
+
return {
|
|
23
|
+
async getKey(subject, ctx) {
|
|
24
|
+
const subjectKey = subjectIdToKey(subject);
|
|
25
|
+
const hit = cache.get(subjectKey);
|
|
26
|
+
if (hit !== undefined) return hit;
|
|
27
|
+
const dek = await adapter.getKey(subject, ctx);
|
|
28
|
+
cache.set(subjectKey, dek);
|
|
29
|
+
return dek;
|
|
30
|
+
},
|
|
31
|
+
invalidate(subject) {
|
|
32
|
+
cache.delete(subjectIdToKey(subject));
|
|
33
|
+
},
|
|
34
|
+
clear() {
|
|
35
|
+
cache.clear();
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import type { EntityDefinition } from "../engine/types/fields";
|
|
2
|
+
import type { TenantId } from "../engine/types/identifiers";
|
|
3
|
+
import type { SubjectId } from "./kms-adapter";
|
|
4
|
+
|
|
5
|
+
// Thrown when a field IS pii-annotated but the row can't name its subject —
|
|
6
|
+
// that must surface as an error, not fall back to plaintext.
|
|
7
|
+
export class SubjectResolutionError extends Error {
|
|
8
|
+
constructor(
|
|
9
|
+
public readonly fieldName: string,
|
|
10
|
+
reason: string,
|
|
11
|
+
) {
|
|
12
|
+
super(`Cannot resolve PII subject for field "${fieldName}": ${reason}`);
|
|
13
|
+
this.name = "SubjectResolutionError";
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface ResolveSubjectOptions {
|
|
18
|
+
// Write-time tenant scope — consulted for tenantOwned fields when the row
|
|
19
|
+
// itself carries no tenantId column.
|
|
20
|
+
readonly tenantId?: TenantId;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function nonEmptyString(value: unknown): string | null {
|
|
24
|
+
return typeof value === "string" && value.length > 0 ? value : null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Maps a pii-annotated field to the subject whose key encrypts it.
|
|
29
|
+
* Returns null for fields without any PII annotation (stored plaintext).
|
|
30
|
+
*
|
|
31
|
+
* Precedence for multi-annotated fields mirrors the erase triggers:
|
|
32
|
+
* userOwned (user-forget) > tenantOwned (tenant-destroy) > pii (self).
|
|
33
|
+
*/
|
|
34
|
+
export function resolveSubjectForField(
|
|
35
|
+
entity: EntityDefinition,
|
|
36
|
+
fieldName: string,
|
|
37
|
+
row: Record<string, unknown>,
|
|
38
|
+
opts: ResolveSubjectOptions = {},
|
|
39
|
+
): SubjectId | null {
|
|
40
|
+
const field = entity.fields[fieldName];
|
|
41
|
+
if (!field) throw new SubjectResolutionError(fieldName, "field is not defined on the entity");
|
|
42
|
+
|
|
43
|
+
if ("userOwned" in field && field.userOwned !== undefined) {
|
|
44
|
+
const ownerField = field.userOwned.ownerField;
|
|
45
|
+
const userId = nonEmptyString(row[ownerField]);
|
|
46
|
+
if (userId === null) {
|
|
47
|
+
throw new SubjectResolutionError(
|
|
48
|
+
fieldName,
|
|
49
|
+
`owner field "${ownerField}" is empty on the row`,
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
return { kind: "user", userId };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if ("tenantOwned" in field && field.tenantOwned === true) {
|
|
56
|
+
const tenantId = nonEmptyString(row["tenantId"]) ?? opts.tenantId;
|
|
57
|
+
if (tenantId === undefined) {
|
|
58
|
+
throw new SubjectResolutionError(
|
|
59
|
+
fieldName,
|
|
60
|
+
"row has no tenantId column and no write-time tenantId was provided",
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
return { kind: "tenant", tenantId };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if ("pii" in field && field.pii === true) {
|
|
67
|
+
// pii: true = the entity itself is the subject (user.email belongs to
|
|
68
|
+
// that user row). Serial ids are stringified — subject keys are text.
|
|
69
|
+
const id = row["id"];
|
|
70
|
+
const userId = nonEmptyString(id) ?? (typeof id === "number" ? String(id) : null);
|
|
71
|
+
if (userId === null) {
|
|
72
|
+
throw new SubjectResolutionError(fieldName, "row has no id to use as the pii self-subject");
|
|
73
|
+
}
|
|
74
|
+
return { kind: "user", userId };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// The field names an encrypt engine must process for an entity — precomputed
|
|
81
|
+
// once at executor build time, like the sensitiveFields set.
|
|
82
|
+
export function collectPiiSubjectFields(entity: EntityDefinition): readonly string[] {
|
|
83
|
+
return Object.entries(entity.fields)
|
|
84
|
+
.filter(
|
|
85
|
+
([, field]) =>
|
|
86
|
+
("userOwned" in field && field.userOwned !== undefined) ||
|
|
87
|
+
("tenantOwned" in field && field.tenantOwned === true) ||
|
|
88
|
+
("pii" in field && field.pii === true),
|
|
89
|
+
)
|
|
90
|
+
.map(([name]) => name);
|
|
91
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
// Runtime guard #722: before the rebuild swaps the shadow over the live table,
|
|
2
|
+
// abort if a live row has NO event in the projection's source streams — a row
|
|
3
|
+
// no replay can reconstruct (#498 ghost, direct-inserted without a .created
|
|
4
|
+
// event), which the swap would silently drop.
|
|
5
|
+
//
|
|
6
|
+
// The guard is deliberately narrow (event EXISTENCE only, not a column diff):
|
|
7
|
+
// the framework legitimately makes live diverge from a fresh replay in shipped
|
|
8
|
+
// ways (blind-index erase→NULL, sensitive-strip, archived-stream wipe, the #494
|
|
9
|
+
// backfill flow). Those rows all HAVE an event, so they are not ghosts and the
|
|
10
|
+
// guard leaves them alone — proven by the "direct column-write" test below.
|
|
11
|
+
|
|
12
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
13
|
+
import { type BunTestDb, createTestDb } from "../../bun-db/__tests__/bun-test-db";
|
|
14
|
+
import { asRawClient, insertOne, selectMany } from "../../db/query";
|
|
15
|
+
import { createBooleanField, createEntity, createTextField, defineFeature } from "../../engine";
|
|
16
|
+
import { createRegistry } from "../../engine/registry";
|
|
17
|
+
import { archiveStream, createArchivedStreamsTable, createEventsTable } from "../../event-store";
|
|
18
|
+
import { rebuildProjection } from "../../pipeline";
|
|
19
|
+
import { createProjectionStateTable } from "../../pipeline/projection-state";
|
|
20
|
+
import { TestUsers, unsafeCreateEntityTable } from "../../stack";
|
|
21
|
+
import { ensureTemporalPolyfill } from "../../time/polyfill";
|
|
22
|
+
import { createEventStoreExecutor } from "../event-store-executor";
|
|
23
|
+
import { buildEntityTable } from "../table-builder";
|
|
24
|
+
import { createTenantDb, type TenantDb } from "../tenant-db";
|
|
25
|
+
|
|
26
|
+
const userEntity = createEntity({
|
|
27
|
+
table: "read_unreachable_users",
|
|
28
|
+
fields: {
|
|
29
|
+
email: createTextField({ required: true }),
|
|
30
|
+
firstName: createTextField(),
|
|
31
|
+
isEnabled: createBooleanField({ default: true }),
|
|
32
|
+
},
|
|
33
|
+
softDelete: true,
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const userFeature = defineFeature("unreachabletest", (r) => {
|
|
37
|
+
r.entity("user", userEntity);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const userTable = buildEntityTable("user", userEntity);
|
|
41
|
+
const projectionName = "unreachabletest:projection:user-entity";
|
|
42
|
+
const registry = createRegistry([userFeature]);
|
|
43
|
+
|
|
44
|
+
let testDb: BunTestDb;
|
|
45
|
+
let tdb: TenantDb;
|
|
46
|
+
const adminUser = TestUsers.admin;
|
|
47
|
+
|
|
48
|
+
beforeAll(async () => {
|
|
49
|
+
await ensureTemporalPolyfill();
|
|
50
|
+
testDb = await createTestDb();
|
|
51
|
+
await unsafeCreateEntityTable(testDb.db, userEntity, "user");
|
|
52
|
+
await createEventsTable(testDb.db);
|
|
53
|
+
await createArchivedStreamsTable(testDb.db);
|
|
54
|
+
await createProjectionStateTable(testDb.db);
|
|
55
|
+
tdb = createTenantDb(testDb.db, adminUser.tenantId);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
afterAll(async () => {
|
|
59
|
+
await testDb.cleanup();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
beforeEach(async () => {
|
|
63
|
+
await asRawClient(testDb.db).unsafe(
|
|
64
|
+
`TRUNCATE kumiko_events, kumiko_archived_streams, read_unreachable_users, kumiko_projections RESTART IDENTITY CASCADE`,
|
|
65
|
+
);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
async function snapshotTable(): Promise<readonly Record<string, unknown>[]> {
|
|
69
|
+
const rows = await selectMany(
|
|
70
|
+
testDb.db,
|
|
71
|
+
userTable,
|
|
72
|
+
{},
|
|
73
|
+
{ orderBy: { col: "id", direction: "asc" } },
|
|
74
|
+
);
|
|
75
|
+
return rows as readonly Record<string, unknown>[];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
describe("assert-no-unreachable-live-rows / #722 ghost-row guard", () => {
|
|
79
|
+
test("clean executor-only projection rebuilds without firing the guard", async () => {
|
|
80
|
+
const crud = createEventStoreExecutor(userTable, userEntity, { entityName: "user" });
|
|
81
|
+
await crud.create({ email: "a@test.de", firstName: "Alice" }, adminUser, tdb);
|
|
82
|
+
await crud.create({ email: "b@test.de", firstName: "Bob" }, adminUser, tdb);
|
|
83
|
+
const before = await snapshotTable();
|
|
84
|
+
|
|
85
|
+
const result = await rebuildProjection(projectionName, { db: testDb.db, registry });
|
|
86
|
+
|
|
87
|
+
expect(result.eventsProcessed).toBe(2);
|
|
88
|
+
expect(result.columnDriftCount).toBe(0);
|
|
89
|
+
expect(await snapshotTable()).toEqual(before);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("ghost row (no backing event) → swap aborts, live untouched", async () => {
|
|
93
|
+
const crud = createEventStoreExecutor(userTable, userEntity, { entityName: "user" });
|
|
94
|
+
const a = await crud.create({ email: "a@test.de", firstName: "Alice" }, adminUser, tdb);
|
|
95
|
+
if (!a.isSuccess) throw new Error("setup failed");
|
|
96
|
+
|
|
97
|
+
// Drift: clone the event-backed row into a second id WITHOUT appending an
|
|
98
|
+
// event — the #498 ghost. No replay can ever reconstruct it.
|
|
99
|
+
const [liveRow] = await selectMany(testDb.db, userTable, { id: a.data.id as string });
|
|
100
|
+
if (!liveRow) throw new Error("live row missing");
|
|
101
|
+
const ghostId = crypto.randomUUID();
|
|
102
|
+
// Deliberately bypass the EXECUTOR_ONLY brand: the whole point is to write
|
|
103
|
+
// the entity table WITHOUT going through the executor — the production bug
|
|
104
|
+
// the guard defends against.
|
|
105
|
+
const writable = userTable as unknown as Parameters<typeof insertOne>[1];
|
|
106
|
+
await insertOne(tdb, writable, { ...liveRow, id: ghostId, email: "ghost@test.de" });
|
|
107
|
+
expect(await snapshotTable()).toHaveLength(2);
|
|
108
|
+
|
|
109
|
+
await expect(rebuildProjection(projectionName, { db: testDb.db, registry })).rejects.toThrow(
|
|
110
|
+
// #915: exact count below the truncation limit, no misleading "+" suffix.
|
|
111
|
+
/^projection-rebuild ".*": 1 live rows in ".*" have no\s+event in the projection's source streams/,
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
// Swap never ran: both rows — including the ghost — survive.
|
|
115
|
+
const after = await snapshotTable();
|
|
116
|
+
expect(after).toHaveLength(2);
|
|
117
|
+
expect(after.map((r) => r["id"])).toContain(ghostId);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("direct column-write on an event-backed row does NOT trip the guard", async () => {
|
|
121
|
+
// The row has a .created event, so it is not a ghost. Its column state was
|
|
122
|
+
// direct-written without an event (the #494 / blind-index-erase class) — the
|
|
123
|
+
// guard is event-existence-only and leaves it alone; the replay overwrites
|
|
124
|
+
// the column from the event, which is the intended, shipped behavior.
|
|
125
|
+
const crud = createEventStoreExecutor(userTable, userEntity, { entityName: "user" });
|
|
126
|
+
const a = await crud.create({ email: "a@test.de", firstName: "Alice" }, adminUser, tdb);
|
|
127
|
+
if (!a.isSuccess) throw new Error("setup failed");
|
|
128
|
+
|
|
129
|
+
await asRawClient(testDb.db).unsafe(
|
|
130
|
+
`UPDATE read_unreachable_users SET first_name = 'DirectWrite' WHERE id = $1`,
|
|
131
|
+
[a.data.id],
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
// No throw — the row is event-backed. Rebuild replays the create event.
|
|
135
|
+
// #916: the direct write IS reported as column drift (non-blocking) —
|
|
136
|
+
// this is the exact #494 healing case the guard leaves to replay.
|
|
137
|
+
const result = await rebuildProjection(projectionName, { db: testDb.db, registry });
|
|
138
|
+
expect(result.columnDriftCount).toBe(1);
|
|
139
|
+
|
|
140
|
+
const [rebuilt] = await selectMany(testDb.db, userTable, { id: a.data.id as string });
|
|
141
|
+
expect(rebuilt?.["email"]).toBe("a@test.de");
|
|
142
|
+
// The direct write is overwritten by the replay — deliberately allowed.
|
|
143
|
+
expect(rebuilt?.["firstName"]).toBe("Alice");
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("columnDriftCount reports the TRUE total, not capped at the 20-row sample limit", async () => {
|
|
147
|
+
const crud = createEventStoreExecutor(userTable, userEntity, { entityName: "user" });
|
|
148
|
+
const created = await Promise.all(
|
|
149
|
+
Array.from({ length: 21 }, (_, i) =>
|
|
150
|
+
crud.create({ email: `u${i}@test.de`, firstName: `Name${i}` }, adminUser, tdb),
|
|
151
|
+
),
|
|
152
|
+
);
|
|
153
|
+
for (const c of created) {
|
|
154
|
+
if (!c.isSuccess) throw new Error("setup failed");
|
|
155
|
+
await asRawClient(testDb.db).unsafe(
|
|
156
|
+
`UPDATE read_unreachable_users SET first_name = 'DirectWrite' WHERE id = $1`,
|
|
157
|
+
[c.data.id],
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const result = await rebuildProjection(projectionName, { db: testDb.db, registry });
|
|
162
|
+
|
|
163
|
+
// #916: the sample query is LIMITed to 20 rows, but the count must reflect
|
|
164
|
+
// all 21 — a min(actual, 20) would silently understate severity to ops.
|
|
165
|
+
expect(result.columnDriftCount).toBe(21);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("archived stream (fw#832 tombstone) does NOT trip the guard", async () => {
|
|
169
|
+
// archiveStream never deletes the .created event — projection rebuild
|
|
170
|
+
// deliberately excludes archived streams from replay (fw#832, see
|
|
171
|
+
// projection-rebuild.ts), so the row is wiped from live on swap. The
|
|
172
|
+
// guard only checks event EXISTENCE, not replay-inclusion, so it must
|
|
173
|
+
// not fire — this is the shipped tombstone behavior, not drift.
|
|
174
|
+
const crud = createEventStoreExecutor(userTable, userEntity, { entityName: "user" });
|
|
175
|
+
const a = await crud.create({ email: "a@test.de", firstName: "Alice" }, adminUser, tdb);
|
|
176
|
+
if (!a.isSuccess) throw new Error("setup failed");
|
|
177
|
+
|
|
178
|
+
await archiveStream(testDb.db, {
|
|
179
|
+
tenantId: adminUser.tenantId,
|
|
180
|
+
aggregateId: a.data.id as string,
|
|
181
|
+
aggregateType: "user",
|
|
182
|
+
archivedBy: adminUser.id,
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
// No throw — the row is event-backed despite being archived.
|
|
186
|
+
const result = await rebuildProjection(projectionName, { db: testDb.db, registry });
|
|
187
|
+
|
|
188
|
+
expect(result.eventsProcessed).toBe(0);
|
|
189
|
+
expect(await snapshotTable()).toHaveLength(0);
|
|
190
|
+
});
|
|
191
|
+
});
|