@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
package/src/bun-db/query.ts
CHANGED
|
@@ -19,6 +19,16 @@
|
|
|
19
19
|
// drizzle's getTableName + getTableColumns (drizzle weiterhin als type-
|
|
20
20
|
// reference, NICHT als runtime-API-call)
|
|
21
21
|
|
|
22
|
+
import { KUMIKO_META_SYMBOL } from "@cosmicdrift/kumiko-types/schema-table-types";
|
|
23
|
+
import type {
|
|
24
|
+
SelectOptions,
|
|
25
|
+
WhereObject,
|
|
26
|
+
WhereOperator,
|
|
27
|
+
} from "@cosmicdrift/kumiko-types/where-clause-types";
|
|
28
|
+
// Static import, not the ambient global: Bun doesn't expose Temporal on
|
|
29
|
+
// globalThis, so instantFromDriver crashed on timestamptz reads (#1480).
|
|
30
|
+
import { Temporal } from "temporal-polyfill";
|
|
31
|
+
import { computeBlindIndex, configuredBlindIndexKey } from "../crypto/blind-index";
|
|
22
32
|
import type { EntityTableMeta } from "../db/entity-table-meta";
|
|
23
33
|
import { type NotExecutorOnly, toSnakeCase } from "../db/table-builder";
|
|
24
34
|
import { camelCase as envCamelCase } from "../env";
|
|
@@ -42,7 +52,6 @@ import type { BunDbRunner } from "./connection";
|
|
|
42
52
|
// enumerable props, so an entity field named `source`/`columns`/`tableName`/…
|
|
43
53
|
// would overwrite the matching meta key — reading the meta from the symbol is
|
|
44
54
|
// the only collision-safe path.
|
|
45
|
-
const KUMIKO_META_SYMBOL = Symbol.for("kumiko:schema:Meta");
|
|
46
55
|
|
|
47
56
|
function isEntityTableMeta(v: unknown): v is EntityTableMeta {
|
|
48
57
|
return (
|
|
@@ -109,6 +118,23 @@ export function asRawClient(db: unknown): RawClient {
|
|
|
109
118
|
);
|
|
110
119
|
}
|
|
111
120
|
|
|
121
|
+
// postgres.js / Bun.SQL reject the WHOLE begin() when any statement inside
|
|
122
|
+
// it errored — even if the JS error was caught (the driver tracks per-block
|
|
123
|
+
// query failures). Error confinement therefore must go through the driver's
|
|
124
|
+
// own savepoint(), not manual SAVEPOINT statements. The callback receives
|
|
125
|
+
// the savepoint-scoped tx; run every confined statement on THAT handle.
|
|
126
|
+
export async function runInSavepoint<T>(tx: unknown, fn: (sp: unknown) => Promise<T>): Promise<T> {
|
|
127
|
+
const raw = asRawClient(tx) as unknown as {
|
|
128
|
+
savepoint?: <TR>(cb: (sp: unknown) => Promise<TR>) => Promise<TR>;
|
|
129
|
+
};
|
|
130
|
+
if (typeof raw.savepoint !== "function") {
|
|
131
|
+
throw new Error(
|
|
132
|
+
"runInSavepoint: transaction handle has no savepoint() — pass the driver tx (postgres.js TransactionSql / Bun.SQL).",
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
return raw.savepoint(fn);
|
|
136
|
+
}
|
|
137
|
+
|
|
112
138
|
/**
|
|
113
139
|
* When handlers call `selectMany(ctx.db, …)` instead of `ctx.db.selectMany(…)`,
|
|
114
140
|
* unwrap via asRawClient would bypass TenantDb scoping. Duck-type TenantDb and
|
|
@@ -165,19 +191,23 @@ function assertNotTenantScoped(db: unknown, fnName: string): void {
|
|
|
165
191
|
|
|
166
192
|
export type AnyDb = BunDbRunner | unknown;
|
|
167
193
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
194
|
+
export type {
|
|
195
|
+
OrderByClause,
|
|
196
|
+
SelectOptions,
|
|
197
|
+
WhereObject,
|
|
198
|
+
WhereOperator,
|
|
199
|
+
WhereValue,
|
|
200
|
+
} from "@cosmicdrift/kumiko-types/where-clause-types";
|
|
201
|
+
|
|
202
|
+
const WHERE_OPERATOR_KEYS = ["gt", "gte", "lt", "lte", "ne", "in", "like"] as const;
|
|
203
|
+
// Forces a tsc error here (not just a silent runtime miss) the moment
|
|
204
|
+
// WhereOperator in @cosmicdrift/kumiko-types gains a key this array doesn't
|
|
205
|
+
// know about — the two live in different packages and can't share a value
|
|
206
|
+
// import without adding a runtime dependency edge.
|
|
207
|
+
type _WhereOperatorKeysExhaustive =
|
|
208
|
+
Exclude<keyof WhereOperator, (typeof WHERE_OPERATOR_KEYS)[number]> extends never ? true : never;
|
|
209
|
+
const _whereOperatorKeysExhaustive: _WhereOperatorKeysExhaustive = true;
|
|
210
|
+
void _whereOperatorKeysExhaustive;
|
|
181
211
|
|
|
182
212
|
function isWhereOperator(v: unknown): v is WhereOperator {
|
|
183
213
|
if (v === null || typeof v !== "object" || Array.isArray(v)) return false;
|
|
@@ -185,21 +215,8 @@ function isWhereOperator(v: unknown): v is WhereOperator {
|
|
|
185
215
|
// WhereOperator is plain object literal with at most these keys.
|
|
186
216
|
const keys = Object.keys(v);
|
|
187
217
|
if (keys.length === 0) return false;
|
|
188
|
-
|
|
189
|
-
return keys.every((k) => opKeys.includes(k));
|
|
218
|
+
return keys.every((k) => (WHERE_OPERATOR_KEYS as readonly string[]).includes(k));
|
|
190
219
|
}
|
|
191
|
-
export type OrderByClause = {
|
|
192
|
-
readonly col: string;
|
|
193
|
-
readonly direction?: "asc" | "desc";
|
|
194
|
-
};
|
|
195
|
-
|
|
196
|
-
export type SelectOptions = {
|
|
197
|
-
readonly limit?: number;
|
|
198
|
-
// Single column or array for multi-column tie-breaks (e.g.
|
|
199
|
-
// [{col: "createdAt"}, {col: "id"}] for chronological-with-stable-id).
|
|
200
|
-
readonly orderBy?: OrderByClause | readonly OrderByClause[];
|
|
201
|
-
};
|
|
202
|
-
|
|
203
220
|
// Akzeptiert EITHER. Beide haben einen tableName und field→column-mapping.
|
|
204
221
|
// biome-ignore lint/suspicious/noExplicitAny: legacy drizzle pgTable surface
|
|
205
222
|
type TableLike = EntityTableMeta | any;
|
|
@@ -338,6 +355,12 @@ export function coerceRow<T extends Record<string, unknown>>(row: T, info: Table
|
|
|
338
355
|
const out: Record<string, unknown> = {};
|
|
339
356
|
let changed = false;
|
|
340
357
|
for (const key of Object.keys(row)) {
|
|
358
|
+
// Blind-Index-Spalten (#818) sind reine Lookup-Artefakte — der
|
|
359
|
+
// deterministische HMAC darf nie an Caller/API-Responses leaken.
|
|
360
|
+
if ((key.endsWith("_bidx") || key.endsWith("Bidx")) && info.hasColumn(key)) {
|
|
361
|
+
changed = true;
|
|
362
|
+
continue;
|
|
363
|
+
}
|
|
341
364
|
const pgType = info.pgTypeOf(key);
|
|
342
365
|
const value = row[key];
|
|
343
366
|
let coerced: unknown = value;
|
|
@@ -512,14 +535,64 @@ function buildWhereClause(
|
|
|
512
535
|
if (p.kind === "literal") {
|
|
513
536
|
conditions.push(`${quoteIdent(col)} = ${p.literal}`);
|
|
514
537
|
} else {
|
|
515
|
-
|
|
516
|
-
|
|
538
|
+
// Blind-Index-OR-Rewrite (#818): existiert zur Spalte ein bidx-
|
|
539
|
+
// Pendant (Suffix-Konvention `<col>_bidx`, framework-reserviert)
|
|
540
|
+
// und ist ein Key konfiguriert, matcht Equality beide Arme —
|
|
541
|
+
// Klartext-Alt-Rows über den Plaintext-Arm, verschlüsselte Rows
|
|
542
|
+
// über den HMAC. Der `kumiko-pii:`-Prefix der Ciphertexte macht
|
|
543
|
+
// einen Zufalls-Match des Plaintext-Arms praktisch unmöglich.
|
|
544
|
+
const bidxKey = configuredBlindIndexKey();
|
|
545
|
+
const bidxCol = `${col}_bidx`;
|
|
546
|
+
if (
|
|
547
|
+
bidxKey !== undefined &&
|
|
548
|
+
typeof value === "string" &&
|
|
549
|
+
!col.endsWith("_bidx") &&
|
|
550
|
+
info.hasColumn(bidxCol)
|
|
551
|
+
) {
|
|
552
|
+
conditions.push(`(${quoteIdent(col)} = $${idx++} OR ${quoteIdent(bidxCol)} = $${idx++})`);
|
|
553
|
+
values.push(p.bound, computeBlindIndex(bidxKey, value));
|
|
554
|
+
} else {
|
|
555
|
+
conditions.push(`${quoteIdent(col)} = $${idx++}${p.sql}`);
|
|
556
|
+
values.push(p.bound);
|
|
557
|
+
}
|
|
517
558
|
}
|
|
518
559
|
}
|
|
519
560
|
}
|
|
520
561
|
return { sqlText: conditions.join(" AND "), values };
|
|
521
562
|
}
|
|
522
563
|
|
|
564
|
+
// #1163: under load the Bun.SQL pool can hand out a connection the server
|
|
565
|
+
// already closed ("The connection was closed.", AbortError code 20) — no
|
|
566
|
+
// validate-on-checkout exists. One retry re-checks out a fresh connection;
|
|
567
|
+
// safe for pure reads. Tx handles are never retried: their transaction is
|
|
568
|
+
// dead once the connection dropped, and a retry would run on the same dead
|
|
569
|
+
// handle. Detection is name+message (not name alone) so a genuine user
|
|
570
|
+
// abort (AbortSignal cancel) is NOT retried.
|
|
571
|
+
function isClosedConnectionError(err: unknown): boolean {
|
|
572
|
+
if (err === null || typeof err !== "object") return false;
|
|
573
|
+
const e = err as { name?: unknown; message?: unknown };
|
|
574
|
+
return (
|
|
575
|
+
e.name === "AbortError" &&
|
|
576
|
+
typeof e.message === "string" &&
|
|
577
|
+
/connection was closed/i.test(e.message)
|
|
578
|
+
);
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
async function unsafeRead<TRow>(
|
|
582
|
+
db: AnyDb,
|
|
583
|
+
sqlText: string,
|
|
584
|
+
params: readonly unknown[],
|
|
585
|
+
): Promise<readonly TRow[]> {
|
|
586
|
+
const raw = asRawClient(db);
|
|
587
|
+
try {
|
|
588
|
+
return (await raw.unsafe(sqlText, params)) as readonly TRow[];
|
|
589
|
+
} catch (err) {
|
|
590
|
+
// TransactionSql has savepoint(), only a top-level pool client has begin().
|
|
591
|
+
if (typeof raw.begin !== "function" || !isClosedConnectionError(err)) throw err;
|
|
592
|
+
return (await raw.unsafe(sqlText, params)) as readonly TRow[];
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
523
596
|
// biome-ignore lint/suspicious/noExplicitAny: opt-in default loosens row type for unannotated test fixtures
|
|
524
597
|
export async function selectMany<TRow = any>(
|
|
525
598
|
db: AnyDb,
|
|
@@ -556,7 +629,7 @@ export async function selectMany<TRow = any>(
|
|
|
556
629
|
}
|
|
557
630
|
sqlText += ` LIMIT ${options.limit}`;
|
|
558
631
|
}
|
|
559
|
-
const raw = (await
|
|
632
|
+
const raw = (await unsafeRead(db, sqlText, values)) as readonly Record<string, unknown>[];
|
|
560
633
|
return coerceRows(raw, info) as readonly TRow[];
|
|
561
634
|
}
|
|
562
635
|
|
|
@@ -790,7 +863,7 @@ export async function countWhere(
|
|
|
790
863
|
sqlText += ` WHERE ${w.sqlText}`;
|
|
791
864
|
values = w.values;
|
|
792
865
|
}
|
|
793
|
-
const rows = (await
|
|
866
|
+
const rows = (await unsafeRead(db, sqlText, values)) as readonly { count: number }[];
|
|
794
867
|
return rows[0]?.count ?? 0;
|
|
795
868
|
}
|
|
796
869
|
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// Shared core for the standalone consumer-ops CLI (status | restart).
|
|
2
|
+
//
|
|
3
|
+
// A dead event consumer (halt-on-poison after maxAttempts) previously had no
|
|
4
|
+
// recovery surface in the standalone prod bundle — only raw SQL against
|
|
5
|
+
// kumiko_event_consumers. Mirrors schema-cli.ts's shape (single runXCli(argv,
|
|
6
|
+
// out) entry point, own DB connection) so `kumiko-consumer` ships the same
|
|
7
|
+
// way `kumiko-schema` does.
|
|
8
|
+
|
|
9
|
+
import { createConnection } from "./db/api";
|
|
10
|
+
import { dbConnectionOptionsFromEnv } from "./db/connection";
|
|
11
|
+
import { getConsumerState, restartConsumer } from "./pipeline";
|
|
12
|
+
import { ensureTemporalPolyfill } from "./time";
|
|
13
|
+
|
|
14
|
+
export type ConsumerCliOut = {
|
|
15
|
+
readonly log: (line: string) => void;
|
|
16
|
+
readonly err: (line: string) => void;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
type ParsedConsumerCliArgs = {
|
|
20
|
+
readonly sub: string | undefined;
|
|
21
|
+
readonly name: string | undefined;
|
|
22
|
+
readonly instanceId: string | undefined;
|
|
23
|
+
readonly error: string | undefined;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
// Single pass: pulls --instance-id (both "--instance-id <id>" and
|
|
27
|
+
// "--instance-id=<id>") out of argv regardless of position, leaving the
|
|
28
|
+
// remaining positionals for sub/name — instead of two independent
|
|
29
|
+
// positional/flag reads that silently misparse "--instance-id" with no
|
|
30
|
+
// value, the "=" form, or a flag placed before the positionals (#1412).
|
|
31
|
+
function parseConsumerCliArgs(argv: readonly string[]): ParsedConsumerCliArgs {
|
|
32
|
+
const positionals: string[] = [];
|
|
33
|
+
let instanceId: string | undefined;
|
|
34
|
+
for (let i = 0; i < argv.length; i++) {
|
|
35
|
+
const arg = argv[i];
|
|
36
|
+
if (arg === undefined) continue;
|
|
37
|
+
if (arg === "--instance-id") {
|
|
38
|
+
const value = argv[i + 1];
|
|
39
|
+
if (value === undefined || value.startsWith("--")) {
|
|
40
|
+
return {
|
|
41
|
+
sub: undefined,
|
|
42
|
+
name: undefined,
|
|
43
|
+
instanceId: undefined,
|
|
44
|
+
error: "--instance-id braucht einen Wert.",
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
instanceId = value;
|
|
48
|
+
i++;
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
if (arg.startsWith("--instance-id=")) {
|
|
52
|
+
instanceId = arg.slice("--instance-id=".length);
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
positionals.push(arg);
|
|
56
|
+
}
|
|
57
|
+
return { sub: positionals[0], name: positionals[1], instanceId, error: undefined };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function runConsumerCli(
|
|
61
|
+
argv: readonly string[],
|
|
62
|
+
out: ConsumerCliOut,
|
|
63
|
+
): Promise<number> {
|
|
64
|
+
// The standalone bundle never runs runProdApp/runDevApp's boot, which is
|
|
65
|
+
// where Temporal normally gets installed — ConsumerRecoveryState.updatedAt
|
|
66
|
+
// is a Temporal.Instant, so without this every subcommand throws "Temporal
|
|
67
|
+
// is not defined" (same failure mode as schema-cli, see its polyfill test).
|
|
68
|
+
await ensureTemporalPolyfill();
|
|
69
|
+
const parsed = parseConsumerCliArgs(argv);
|
|
70
|
+
if (parsed.error) {
|
|
71
|
+
out.err(` ${parsed.error}`);
|
|
72
|
+
return 1;
|
|
73
|
+
}
|
|
74
|
+
const { sub, name, instanceId } = parsed;
|
|
75
|
+
|
|
76
|
+
if (sub !== "status" && sub !== "restart") {
|
|
77
|
+
// Unknown subcommand must be visible on stderr, not just a bare exit 1 —
|
|
78
|
+
// ops invocations piping `2>&1 >/dev/null` or log pipelines that only
|
|
79
|
+
// watch stderr would otherwise see a failure with zero explanation
|
|
80
|
+
// (#1412). A bare help call (no subcommand at all) is not an error, so
|
|
81
|
+
// it keeps exit 0 + stdout.
|
|
82
|
+
const write = sub === undefined ? out.log : out.err;
|
|
83
|
+
if (sub !== undefined) out.err(` Unknown subcommand: ${sub}`);
|
|
84
|
+
write("");
|
|
85
|
+
write(" Subcommands:");
|
|
86
|
+
write(" status <name> [--instance-id <id>] Zeigt Status + Cursor eines Consumers");
|
|
87
|
+
write(" restart <name> [--instance-id <id>] Reaktiviert einen dead-Consumer (idle)");
|
|
88
|
+
write("");
|
|
89
|
+
return sub === undefined ? 0 : 1;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (!name) {
|
|
93
|
+
out.err(` Usage: consumer ${sub} <name> [--instance-id <id>]`);
|
|
94
|
+
return 1;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const dbUrl = process.env["DATABASE_URL"];
|
|
98
|
+
if (!dbUrl) {
|
|
99
|
+
out.err(" DATABASE_URL not set.");
|
|
100
|
+
return 1;
|
|
101
|
+
}
|
|
102
|
+
const { db, close } = await createConnection(dbUrl, dbConnectionOptionsFromEnv());
|
|
103
|
+
try {
|
|
104
|
+
if (sub === "status") {
|
|
105
|
+
const state = await getConsumerState(db, name, instanceId);
|
|
106
|
+
if (!state) {
|
|
107
|
+
out.err(` Consumer "${name}" (instance_id="${instanceId ?? "__shared__"}") not found.`);
|
|
108
|
+
return 1;
|
|
109
|
+
}
|
|
110
|
+
out.log("");
|
|
111
|
+
out.log(` ${state.name} (instance_id="${state.instanceId}")`);
|
|
112
|
+
out.log(` status: ${state.status}`);
|
|
113
|
+
out.log(` cursor: ${state.lastProcessedEventId}`);
|
|
114
|
+
out.log(` attempts: ${state.attempts}`);
|
|
115
|
+
out.log(` rearmCount: ${state.rearmCount}`);
|
|
116
|
+
out.log(` lastError: ${state.lastError ?? "-"}`);
|
|
117
|
+
out.log(` updatedAt: ${state.updatedAt.toString()}`);
|
|
118
|
+
out.log("");
|
|
119
|
+
return 0;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// restart
|
|
123
|
+
const result = await restartConsumer(db, name, instanceId);
|
|
124
|
+
out.log("");
|
|
125
|
+
out.log(` ✓ ${result.name} (instance_id="${result.instanceId}") → ${result.status}`);
|
|
126
|
+
out.log("");
|
|
127
|
+
return 0;
|
|
128
|
+
} catch (e) {
|
|
129
|
+
out.err(` ✗ ${e instanceof Error ? e.message : String(e)}`);
|
|
130
|
+
return 1;
|
|
131
|
+
} finally {
|
|
132
|
+
await close();
|
|
133
|
+
}
|
|
134
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { createEntity, createTextField } from "../../engine/factories";
|
|
3
|
+
import {
|
|
4
|
+
blindIndexFieldName,
|
|
5
|
+
collectLookupableFields,
|
|
6
|
+
computeBlindIndex,
|
|
7
|
+
computeBlindIndexValues,
|
|
8
|
+
configureBlindIndexKey,
|
|
9
|
+
configuredBlindIndexKey,
|
|
10
|
+
decodeBlindIndexKey,
|
|
11
|
+
resetBlindIndexKeyForTests,
|
|
12
|
+
} from "../blind-index";
|
|
13
|
+
import { InMemoryKmsAdapter } from "../in-memory-kms-adapter";
|
|
14
|
+
import {
|
|
15
|
+
configurePiiSubjectKms,
|
|
16
|
+
encryptPiiFieldValues,
|
|
17
|
+
PII_ERASED_SENTINEL,
|
|
18
|
+
resetPiiSubjectKmsForTests,
|
|
19
|
+
} from "../pii-field-encryption";
|
|
20
|
+
|
|
21
|
+
const UUID_A = "6b2f4a0e-1c9d-4f3a-9d2e-00000000000a";
|
|
22
|
+
const TEST_KEY_B64 = Buffer.alloc(32, 7).toString("base64");
|
|
23
|
+
const TEST_KEY = decodeBlindIndexKey(TEST_KEY_B64);
|
|
24
|
+
|
|
25
|
+
const userLikeEntity = createEntity({
|
|
26
|
+
fields: {
|
|
27
|
+
email: createTextField({ required: true, pii: true, lookupable: true }),
|
|
28
|
+
role: createTextField(),
|
|
29
|
+
},
|
|
30
|
+
table: "bidx_users",
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
afterEach(() => {
|
|
34
|
+
resetBlindIndexKeyForTests();
|
|
35
|
+
resetPiiSubjectKmsForTests();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe("computeBlindIndex", () => {
|
|
39
|
+
test("deterministic, prefixed, byte-exact (no normalization)", () => {
|
|
40
|
+
const a = computeBlindIndex(TEST_KEY, "marc@example.com");
|
|
41
|
+
expect(a).toStartWith("kumiko-bidx:v1:");
|
|
42
|
+
expect(computeBlindIndex(TEST_KEY, "marc@example.com")).toBe(a);
|
|
43
|
+
expect(computeBlindIndex(TEST_KEY, "Marc@example.com")).not.toBe(a);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("different keys produce different indexes", () => {
|
|
47
|
+
const otherKey = decodeBlindIndexKey(Buffer.alloc(32, 9).toString("base64"));
|
|
48
|
+
expect(computeBlindIndex(otherKey, "x")).not.toBe(computeBlindIndex(TEST_KEY, "x"));
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe("configureBlindIndexKey", () => {
|
|
53
|
+
test("rejects keys that are not 32 bytes", () => {
|
|
54
|
+
expect(() => configureBlindIndexKey(Buffer.alloc(16, 1).toString("base64"))).toThrow(
|
|
55
|
+
/32 bytes/,
|
|
56
|
+
);
|
|
57
|
+
expect(configuredBlindIndexKey()).toBeUndefined();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("undefined clears the key", () => {
|
|
61
|
+
configureBlindIndexKey(TEST_KEY_B64);
|
|
62
|
+
expect(configuredBlindIndexKey()).toBeDefined();
|
|
63
|
+
configureBlindIndexKey(undefined);
|
|
64
|
+
expect(configuredBlindIndexKey()).toBeUndefined();
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe("collectLookupableFields", () => {
|
|
69
|
+
test("only text fields with lookupable: true", () => {
|
|
70
|
+
expect(collectLookupableFields(userLikeEntity)).toEqual(["email"]);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe("computeBlindIndexValues", () => {
|
|
75
|
+
test("no key configured → empty (blind-indexing off)", async () => {
|
|
76
|
+
const out = await computeBlindIndexValues({ email: "a@b.c" }, ["email"]);
|
|
77
|
+
expect(out).toEqual({});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("plaintext value → HMAC over the value itself", async () => {
|
|
81
|
+
configureBlindIndexKey(TEST_KEY_B64);
|
|
82
|
+
const out = await computeBlindIndexValues({ email: "a@b.c" }, ["email"]);
|
|
83
|
+
expect(out[blindIndexFieldName("email")]).toBe(computeBlindIndex(TEST_KEY, "a@b.c"));
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("absent field stays untouched, null value → NULL bidx", async () => {
|
|
87
|
+
configureBlindIndexKey(TEST_KEY_B64);
|
|
88
|
+
expect(await computeBlindIndexValues({ role: "admin" }, ["email"])).toEqual({});
|
|
89
|
+
expect(await computeBlindIndexValues({ email: null }, ["email"])).toEqual({
|
|
90
|
+
emailBidx: null,
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("ciphertext value → decrypt via configured KMS, HMAC over plaintext", async () => {
|
|
95
|
+
const kms = new InMemoryKmsAdapter();
|
|
96
|
+
configurePiiSubjectKms(kms);
|
|
97
|
+
configureBlindIndexKey(TEST_KEY_B64);
|
|
98
|
+
const stored = await encryptPiiFieldValues(
|
|
99
|
+
{ id: UUID_A, email: "marc@example.com" },
|
|
100
|
+
userLikeEntity,
|
|
101
|
+
["email"],
|
|
102
|
+
kms,
|
|
103
|
+
{ requestId: "test" },
|
|
104
|
+
);
|
|
105
|
+
const out = await computeBlindIndexValues({ email: stored["email"] }, ["email"]);
|
|
106
|
+
expect(out["emailBidx"]).toBe(computeBlindIndex(TEST_KEY, "marc@example.com"));
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("erased subject → NULL bidx (lookup stops matching)", async () => {
|
|
110
|
+
const kms = new InMemoryKmsAdapter();
|
|
111
|
+
configurePiiSubjectKms(kms);
|
|
112
|
+
configureBlindIndexKey(TEST_KEY_B64);
|
|
113
|
+
const stored = await encryptPiiFieldValues(
|
|
114
|
+
{ id: UUID_A, email: "marc@example.com" },
|
|
115
|
+
userLikeEntity,
|
|
116
|
+
["email"],
|
|
117
|
+
kms,
|
|
118
|
+
{ requestId: "test" },
|
|
119
|
+
);
|
|
120
|
+
await kms.eraseKey({ kind: "user", userId: UUID_A });
|
|
121
|
+
const out = await computeBlindIndexValues({ email: stored["email"] }, ["email"]);
|
|
122
|
+
expect(out["emailBidx"]).toBeNull();
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("erased sentinel → NULL bidx", async () => {
|
|
126
|
+
configureBlindIndexKey(TEST_KEY_B64);
|
|
127
|
+
const out = await computeBlindIndexValues({ email: PII_ERASED_SENTINEL }, ["email"]);
|
|
128
|
+
expect(out["emailBidx"]).toBeNull();
|
|
129
|
+
});
|
|
130
|
+
});
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// Event-PII catalog (#799): defineEvent({ piiFields }) → createRegistry
|
|
2
|
+
// publishes the catalog → encryptEventPayloadPii encrypts under the owning
|
|
3
|
+
// user's DEK. append() applies this on every write path; the pure pieces
|
|
4
|
+
// are testable without a database.
|
|
5
|
+
|
|
6
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
import { createRegistry, defineFeature } from "../../engine";
|
|
9
|
+
import {
|
|
10
|
+
configuredEventPiiCatalog,
|
|
11
|
+
configureEventPiiCatalog,
|
|
12
|
+
encryptEventPayloadPii,
|
|
13
|
+
resetEventPiiCatalogForTests,
|
|
14
|
+
} from "../event-pii";
|
|
15
|
+
import { InMemoryKmsAdapter } from "../in-memory-kms-adapter";
|
|
16
|
+
import {
|
|
17
|
+
configurePiiSubjectKms,
|
|
18
|
+
decryptPiiFieldValues,
|
|
19
|
+
isPiiCiphertext,
|
|
20
|
+
PII_ERASED_SENTINEL,
|
|
21
|
+
resetPiiSubjectKmsForTests,
|
|
22
|
+
} from "../pii-field-encryption";
|
|
23
|
+
|
|
24
|
+
const attemptSchema = z.object({
|
|
25
|
+
recipientId: z.string().nullable(),
|
|
26
|
+
recipientAddress: z.string().nullable(),
|
|
27
|
+
status: z.string(),
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const EVENT_TYPE = "mailer:event:attempt";
|
|
31
|
+
|
|
32
|
+
function catalogWithAttempt(): void {
|
|
33
|
+
configureEventPiiCatalog(
|
|
34
|
+
new Map([[EVENT_TYPE, { recipientAddress: { subjectField: "recipientId" } }]]),
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
afterEach(() => {
|
|
39
|
+
resetEventPiiCatalogForTests();
|
|
40
|
+
resetPiiSubjectKmsForTests();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe("defineEvent piiFields validation", () => {
|
|
44
|
+
test("valid piiFields land on the EventDef and in the registry catalog", () => {
|
|
45
|
+
const feature = defineFeature("mailer", (r) => {
|
|
46
|
+
r.defineEvent("attempt", attemptSchema, {
|
|
47
|
+
piiFields: { recipientAddress: { subjectField: "recipientId" } },
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
createRegistry([feature]);
|
|
51
|
+
expect(configuredEventPiiCatalog().get(EVENT_TYPE)).toEqual({
|
|
52
|
+
recipientAddress: { subjectField: "recipientId" },
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("pii field not on the payload schema throws at definition time", () => {
|
|
57
|
+
expect(() =>
|
|
58
|
+
defineFeature("mailer", (r) => {
|
|
59
|
+
r.defineEvent("attempt", attemptSchema, {
|
|
60
|
+
piiFields: { nope: { subjectField: "recipientId" } },
|
|
61
|
+
});
|
|
62
|
+
}),
|
|
63
|
+
).toThrow(/piiFields references "nope"/);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("unknown subjectField throws at definition time", () => {
|
|
67
|
+
expect(() =>
|
|
68
|
+
defineFeature("mailer", (r) => {
|
|
69
|
+
r.defineEvent("attempt", attemptSchema, {
|
|
70
|
+
piiFields: { recipientAddress: { subjectField: "ownerId" } },
|
|
71
|
+
});
|
|
72
|
+
}),
|
|
73
|
+
).toThrow(/piiFields references "ownerId"/);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("field cannot be its own subjectField", () => {
|
|
77
|
+
expect(() =>
|
|
78
|
+
defineFeature("mailer", (r) => {
|
|
79
|
+
r.defineEvent("attempt", attemptSchema, {
|
|
80
|
+
piiFields: { recipientAddress: { subjectField: "recipientAddress" } },
|
|
81
|
+
});
|
|
82
|
+
}),
|
|
83
|
+
).toThrow(/cannot use itself as subjectField/);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("events without piiFields do not enter the catalog", () => {
|
|
87
|
+
const feature = defineFeature("mailer", (r) => {
|
|
88
|
+
r.defineEvent("attempt", attemptSchema);
|
|
89
|
+
});
|
|
90
|
+
createRegistry([feature]);
|
|
91
|
+
expect(configuredEventPiiCatalog().size).toBe(0);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe("encryptEventPayloadPii", () => {
|
|
96
|
+
const payload = { recipientId: "u-1", recipientAddress: "u1@example.com", status: "sent" };
|
|
97
|
+
|
|
98
|
+
test("uncatalogued event type returns the payload untouched (same reference)", async () => {
|
|
99
|
+
configurePiiSubjectKms(new InMemoryKmsAdapter());
|
|
100
|
+
expect(await encryptEventPayloadPii("other:event:x", payload)).toBe(payload);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("no KMS configured → plaintext passthrough (rollout mode)", async () => {
|
|
104
|
+
catalogWithAttempt();
|
|
105
|
+
expect(await encryptEventPayloadPii(EVENT_TYPE, payload)).toBe(payload);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("encrypts under the subject's DEK; subject fk stays plaintext", async () => {
|
|
109
|
+
catalogWithAttempt();
|
|
110
|
+
const kms = new InMemoryKmsAdapter();
|
|
111
|
+
configurePiiSubjectKms(kms);
|
|
112
|
+
|
|
113
|
+
const out = await encryptEventPayloadPii(EVENT_TYPE, payload);
|
|
114
|
+
expect(isPiiCiphertext(out["recipientAddress"])).toBe(true);
|
|
115
|
+
expect(String(out["recipientAddress"])).toContain("user:u-1");
|
|
116
|
+
expect(out["recipientId"]).toBe("u-1");
|
|
117
|
+
expect(out["status"]).toBe("sent");
|
|
118
|
+
|
|
119
|
+
const back = await decryptPiiFieldValues(out, ["recipientAddress"], kms, {
|
|
120
|
+
requestId: "test",
|
|
121
|
+
});
|
|
122
|
+
expect(back["recipientAddress"]).toBe("u1@example.com");
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("null subject field → value stays plaintext (no user key to shred)", async () => {
|
|
126
|
+
catalogWithAttempt();
|
|
127
|
+
configurePiiSubjectKms(new InMemoryKmsAdapter());
|
|
128
|
+
const systemPayload = {
|
|
129
|
+
recipientId: null,
|
|
130
|
+
recipientAddress: "ops@example.com",
|
|
131
|
+
status: "sent",
|
|
132
|
+
};
|
|
133
|
+
expect(await encryptEventPayloadPii(EVENT_TYPE, systemPayload)).toBe(systemPayload);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("null pii value passes through", async () => {
|
|
137
|
+
catalogWithAttempt();
|
|
138
|
+
configurePiiSubjectKms(new InMemoryKmsAdapter());
|
|
139
|
+
const skipped = { recipientId: "u-1", recipientAddress: null, status: "skipped" };
|
|
140
|
+
expect(await encryptEventPayloadPii(EVENT_TYPE, skipped)).toBe(skipped);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("idempotent: ciphertext and erased sentinel stay as-is", async () => {
|
|
144
|
+
catalogWithAttempt();
|
|
145
|
+
configurePiiSubjectKms(new InMemoryKmsAdapter());
|
|
146
|
+
const once = await encryptEventPayloadPii(EVENT_TYPE, payload);
|
|
147
|
+
const twice = await encryptEventPayloadPii(EVENT_TYPE, once);
|
|
148
|
+
expect(twice["recipientAddress"]).toBe(once["recipientAddress"]);
|
|
149
|
+
|
|
150
|
+
const erased = { ...payload, recipientAddress: PII_ERASED_SENTINEL };
|
|
151
|
+
const out = await encryptEventPayloadPii(EVENT_TYPE, erased);
|
|
152
|
+
expect(out["recipientAddress"]).toBe(PII_ERASED_SENTINEL);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("non-string pii value is a loud error, not a silent skip", async () => {
|
|
156
|
+
catalogWithAttempt();
|
|
157
|
+
configurePiiSubjectKms(new InMemoryKmsAdapter());
|
|
158
|
+
const broken = { recipientId: "u-1", recipientAddress: 42, status: "sent" };
|
|
159
|
+
expect(encryptEventPayloadPii(EVENT_TYPE, broken)).rejects.toThrow(/must be a string/);
|
|
160
|
+
});
|
|
161
|
+
});
|