@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,70 @@
|
|
|
1
|
+
// PII on custom-event payloads (#799). Entity CRUD events get their PII
|
|
2
|
+
// encrypted by the executor; events written via ctx.appendEvent / MSP-apply /
|
|
3
|
+
// low-level append() (delivery attempt-log, jobs run-logger) had no encrypt
|
|
4
|
+
// path at all. `r.defineEvent(name, schema, { piiFields })` declares which
|
|
5
|
+
// payload fields are PII and which payload field names the owning user;
|
|
6
|
+
// createRegistry publishes the catalog and append() — the single write funnel
|
|
7
|
+
// into kumiko_events — encrypts every catalogued field. No caller can forget.
|
|
8
|
+
|
|
9
|
+
import { requestContext } from "../api/request-context";
|
|
10
|
+
import type { EventPiiFields } from "../engine/types/handlers";
|
|
11
|
+
import { configuredPiiSubjectKms, encryptPiiValueForSubject } from "./pii-field-encryption";
|
|
12
|
+
|
|
13
|
+
export type EventPiiCatalog = ReadonlyMap<string, EventPiiFields>;
|
|
14
|
+
|
|
15
|
+
// Boot-injected like configurePiiSubjectKms — createRegistry calls this with
|
|
16
|
+
// the catalog collected from all defineEvent registrations.
|
|
17
|
+
let catalog: EventPiiCatalog = new Map();
|
|
18
|
+
|
|
19
|
+
export function configureEventPiiCatalog(next: EventPiiCatalog): void {
|
|
20
|
+
catalog = next;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function configuredEventPiiCatalog(): EventPiiCatalog {
|
|
24
|
+
return catalog;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** @internal test-only */
|
|
28
|
+
export function resetEventPiiCatalogForTests(): void {
|
|
29
|
+
catalog = new Map();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Encrypts catalogued payload fields under the owning user's DEK. No-op when
|
|
33
|
+
// the event type is uncatalogued or no subject KMS is configured (plaintext
|
|
34
|
+
// rollout mode — the hard boot gate governs whether that is acceptable).
|
|
35
|
+
// A null/absent subject field (system cron runs, recipient-less skip
|
|
36
|
+
// attempts) leaves the value plaintext: there is no user key to shred.
|
|
37
|
+
export async function encryptEventPayloadPii(
|
|
38
|
+
eventType: string,
|
|
39
|
+
payload: Record<string, unknown>,
|
|
40
|
+
): Promise<Record<string, unknown>> {
|
|
41
|
+
const piiFields = catalog.get(eventType);
|
|
42
|
+
if (!piiFields) return payload;
|
|
43
|
+
const kms = configuredPiiSubjectKms();
|
|
44
|
+
if (!kms) return payload;
|
|
45
|
+
|
|
46
|
+
let out: Record<string, unknown> | undefined;
|
|
47
|
+
for (const [field, spec] of Object.entries(piiFields)) {
|
|
48
|
+
const value = payload[field];
|
|
49
|
+
if (value === null || value === undefined) continue;
|
|
50
|
+
if (typeof value !== "string") {
|
|
51
|
+
throw new Error(
|
|
52
|
+
`Event "${eventType}" piiFields."${field}" must be a string payload field, got ${typeof value}`,
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
const subjectId = payload[spec.subjectField];
|
|
56
|
+
if (typeof subjectId !== "string" || subjectId.length === 0) continue;
|
|
57
|
+
const encrypted = await encryptPiiValueForSubject(
|
|
58
|
+
kms,
|
|
59
|
+
{ kind: "user", userId: subjectId },
|
|
60
|
+
value,
|
|
61
|
+
{ requestId: requestContext.get()?.requestId ?? "append-event" },
|
|
62
|
+
field,
|
|
63
|
+
);
|
|
64
|
+
if (encrypted !== value) {
|
|
65
|
+
out ??= { ...payload };
|
|
66
|
+
out[field] = encrypted;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return out ?? payload;
|
|
70
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
KeyAlreadyExistsError,
|
|
4
|
+
KeyErasedError,
|
|
5
|
+
KeyNotFoundError,
|
|
6
|
+
type KmsHealth,
|
|
7
|
+
type LocalKeyKmsAdapter,
|
|
8
|
+
type SubjectDek,
|
|
9
|
+
type SubjectId,
|
|
10
|
+
type SubjectKey,
|
|
11
|
+
subjectIdToKey,
|
|
12
|
+
} from "./kms-adapter";
|
|
13
|
+
|
|
14
|
+
interface KeyEntry {
|
|
15
|
+
key: Buffer | null;
|
|
16
|
+
erased: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Non-persistent adapter for tests and dev mode. Erased entries stay as
|
|
20
|
+
// tombstones so the create-after-erase contract holds within a process.
|
|
21
|
+
export class InMemoryKmsAdapter implements LocalKeyKmsAdapter {
|
|
22
|
+
readonly capabilities = { mode: "local-key" } as const;
|
|
23
|
+
|
|
24
|
+
private readonly keys = new Map<SubjectKey, KeyEntry>();
|
|
25
|
+
|
|
26
|
+
async createKey(subject: SubjectId): Promise<void> {
|
|
27
|
+
const subjectKey = subjectIdToKey(subject);
|
|
28
|
+
if (this.keys.has(subjectKey)) throw new KeyAlreadyExistsError(subject);
|
|
29
|
+
this.keys.set(subjectKey, { key: randomBytes(32), erased: false });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async getKey(subject: SubjectId): Promise<SubjectDek> {
|
|
33
|
+
const entry = this.keys.get(subjectIdToKey(subject));
|
|
34
|
+
if (!entry) throw new KeyNotFoundError(subject);
|
|
35
|
+
if (entry.erased || entry.key === null) throw new KeyErasedError(subject);
|
|
36
|
+
return entry.key;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async eraseKey(subject: SubjectId): Promise<void> {
|
|
40
|
+
const entry = this.keys.get(subjectIdToKey(subject));
|
|
41
|
+
// skip: eraseKey is contractually idempotent — unknown subject is a no-op
|
|
42
|
+
if (!entry) return;
|
|
43
|
+
entry.key = null;
|
|
44
|
+
entry.erased = true;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async health(): Promise<KmsHealth> {
|
|
48
|
+
return { ok: true, latencyMs: 0 };
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
export {
|
|
2
|
+
blindIndexFieldName,
|
|
3
|
+
collectLookupableFields,
|
|
4
|
+
computeBlindIndex,
|
|
5
|
+
computeBlindIndexValues,
|
|
6
|
+
configureBlindIndexKey,
|
|
7
|
+
configuredBlindIndexKey,
|
|
8
|
+
decodeBlindIndexKey,
|
|
9
|
+
resetBlindIndexKeyForTests,
|
|
10
|
+
} from "./blind-index";
|
|
11
|
+
export {
|
|
12
|
+
configuredEventPiiCatalog,
|
|
13
|
+
configureEventPiiCatalog,
|
|
14
|
+
type EventPiiCatalog,
|
|
15
|
+
encryptEventPayloadPii,
|
|
16
|
+
resetEventPiiCatalogForTests,
|
|
17
|
+
} from "./event-pii";
|
|
18
|
+
export { InMemoryKmsAdapter } from "./in-memory-kms-adapter";
|
|
19
|
+
export {
|
|
20
|
+
isLocalKeyKmsAdapter,
|
|
21
|
+
KeyAlreadyExistsError,
|
|
22
|
+
KeyErasedError,
|
|
23
|
+
KeyNotFoundError,
|
|
24
|
+
type KmsAdapter,
|
|
25
|
+
type KmsContext,
|
|
26
|
+
type KmsHealth,
|
|
27
|
+
type LocalKeyKmsAdapter,
|
|
28
|
+
type RemoteCryptoKmsAdapter,
|
|
29
|
+
type SubjectDek,
|
|
30
|
+
type SubjectId,
|
|
31
|
+
type SubjectKey,
|
|
32
|
+
subjectIdFromKey,
|
|
33
|
+
subjectIdToKey,
|
|
34
|
+
subjectKeyForTenant,
|
|
35
|
+
subjectKeyForUser,
|
|
36
|
+
} from "./kms-adapter";
|
|
37
|
+
export {
|
|
38
|
+
createPgKmsAdapter,
|
|
39
|
+
PgKmsAdapter,
|
|
40
|
+
type PgKmsAdapterOptions,
|
|
41
|
+
type RewrapOptions,
|
|
42
|
+
type RewrapResult,
|
|
43
|
+
rewrapSubjectKeys,
|
|
44
|
+
} from "./pg-kms-adapter";
|
|
45
|
+
export {
|
|
46
|
+
configuredPiiSubjectKms,
|
|
47
|
+
configurePiiSubjectKms,
|
|
48
|
+
decryptPiiFieldValues,
|
|
49
|
+
decryptPiiValueForSubject,
|
|
50
|
+
type EncryptPiiOptions,
|
|
51
|
+
encryptPiiFieldValues,
|
|
52
|
+
encryptPiiValueForSubject,
|
|
53
|
+
isPiiCiphertext,
|
|
54
|
+
PII_CIPHERTEXT_PREFIX,
|
|
55
|
+
PII_ERASED_SENTINEL,
|
|
56
|
+
resetPiiSubjectKmsForTests,
|
|
57
|
+
} from "./pii-field-encryption";
|
|
58
|
+
export {
|
|
59
|
+
createRequestKmsCache,
|
|
60
|
+
type RequestKmsCache,
|
|
61
|
+
} from "./request-kms-cache";
|
|
62
|
+
export {
|
|
63
|
+
collectPiiSubjectFields,
|
|
64
|
+
type ResolveSubjectOptions,
|
|
65
|
+
resolveSubjectForField,
|
|
66
|
+
SubjectResolutionError,
|
|
67
|
+
} from "./subject-resolver";
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
|
|
2
|
+
import postgres from "postgres";
|
|
3
|
+
import {
|
|
4
|
+
KeyAlreadyExistsError,
|
|
5
|
+
KeyErasedError,
|
|
6
|
+
KeyNotFoundError,
|
|
7
|
+
type KmsContext,
|
|
8
|
+
type KmsHealth,
|
|
9
|
+
type LocalKeyKmsAdapter,
|
|
10
|
+
type SubjectDek,
|
|
11
|
+
type SubjectId,
|
|
12
|
+
subjectIdToKey,
|
|
13
|
+
} from "./kms-adapter";
|
|
14
|
+
|
|
15
|
+
const IV_LENGTH = 12;
|
|
16
|
+
const TAG_LENGTH = 16;
|
|
17
|
+
|
|
18
|
+
// Envelope layout [iv:12][tag:16][ct] — the platform KEK wraps each subject
|
|
19
|
+
// DEK at rest, so a subject-keys-DB dump alone reveals no key material.
|
|
20
|
+
function wrapDek(kek: Buffer, dek: Buffer): Buffer {
|
|
21
|
+
const iv = randomBytes(IV_LENGTH);
|
|
22
|
+
const cipher = createCipheriv("aes-256-gcm", kek, iv);
|
|
23
|
+
const ciphertext = Buffer.concat([cipher.update(dek), cipher.final()]);
|
|
24
|
+
return Buffer.concat([iv, cipher.getAuthTag(), ciphertext]);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function unwrapDek(kek: Buffer, wrapped: Uint8Array): SubjectDek {
|
|
28
|
+
const envelope = Buffer.from(wrapped);
|
|
29
|
+
const iv = envelope.subarray(0, IV_LENGTH);
|
|
30
|
+
const tag = envelope.subarray(IV_LENGTH, IV_LENGTH + TAG_LENGTH);
|
|
31
|
+
const ciphertext = envelope.subarray(IV_LENGTH + TAG_LENGTH);
|
|
32
|
+
const decipher = createDecipheriv("aes-256-gcm", kek, iv);
|
|
33
|
+
decipher.setAuthTag(tag);
|
|
34
|
+
return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function decodePlatformKek(base64: string): Buffer {
|
|
38
|
+
const kek = Buffer.from(base64, "base64");
|
|
39
|
+
if (kek.length !== 32) {
|
|
40
|
+
throw new Error(
|
|
41
|
+
`PgKmsAdapter: platformKek must decode to 32 bytes, got ${kek.length} — expected a base64-encoded AES-256 key (openssl rand -base64 32)`,
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
return kek;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const PG_UNIQUE_VIOLATION = "23505";
|
|
48
|
+
|
|
49
|
+
function isPgUniqueViolation(error: unknown): boolean {
|
|
50
|
+
return (
|
|
51
|
+
typeof error === "object" &&
|
|
52
|
+
error !== null &&
|
|
53
|
+
"code" in error &&
|
|
54
|
+
error.code === PG_UNIQUE_VIOLATION
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
interface SubjectKeyRow {
|
|
59
|
+
cipher_key: Uint8Array | null;
|
|
60
|
+
kek_version: number;
|
|
61
|
+
erased: boolean;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface PgKmsAdapterOptions {
|
|
65
|
+
/** Connection string of the DEDICATED subject-keys cluster — never the app DB (its backup retention must stay shorter, see kms-adapter.md). */
|
|
66
|
+
readonly databaseUrl: string;
|
|
67
|
+
/** Base64-encoded 32-byte platform KEK (PLATFORM_KEK env var). */
|
|
68
|
+
readonly platformKek: string;
|
|
69
|
+
/** Version of `platformKek` as stored in kumiko_subject_keys.kek_version.
|
|
70
|
+
* Bump on rotation; new wraps carry this version. Default 1. */
|
|
71
|
+
readonly kekVersion?: number;
|
|
72
|
+
/** Older KEKs by version, kept ONLY during a rotation window so rows not
|
|
73
|
+
* yet re-wrapped stay readable. Drop after rewrapSubjectKeys reports the
|
|
74
|
+
* estate fully on the active version (runbook: kek-rotation.md). */
|
|
75
|
+
readonly previousKeks?: Readonly<Record<number, string>>;
|
|
76
|
+
readonly maxConnections?: number;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Default production adapter: DEKs live in a separate Postgres cluster,
|
|
80
|
+
// KEK-wrapped at rest. Schema is created lazily on first use — the
|
|
81
|
+
// subject-keys cluster has no drizzle migration pipeline of its own.
|
|
82
|
+
export class PgKmsAdapter implements LocalKeyKmsAdapter {
|
|
83
|
+
readonly capabilities = { mode: "local-key" } as const;
|
|
84
|
+
|
|
85
|
+
private readonly sql: ReturnType<typeof postgres>;
|
|
86
|
+
private readonly kek: Buffer;
|
|
87
|
+
private readonly kekVersion: number;
|
|
88
|
+
private readonly previousKeks: ReadonlyMap<number, Buffer>;
|
|
89
|
+
private schemaReady: Promise<void> | undefined;
|
|
90
|
+
|
|
91
|
+
constructor(options: PgKmsAdapterOptions) {
|
|
92
|
+
this.kek = decodePlatformKek(options.platformKek);
|
|
93
|
+
this.kekVersion = options.kekVersion ?? 1;
|
|
94
|
+
const previous = new Map<number, Buffer>();
|
|
95
|
+
for (const [version, kek] of Object.entries(options.previousKeks ?? {})) {
|
|
96
|
+
const v = Number(version);
|
|
97
|
+
if (v >= this.kekVersion) {
|
|
98
|
+
throw new Error(
|
|
99
|
+
`PgKmsAdapter: previousKeks[${v}] must be older than the active kekVersion ${this.kekVersion}`,
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
previous.set(v, decodePlatformKek(kek));
|
|
103
|
+
}
|
|
104
|
+
this.previousKeks = previous;
|
|
105
|
+
this.sql = postgres(options.databaseUrl, {
|
|
106
|
+
max: options.maxConnections ?? 4,
|
|
107
|
+
// The connection is exclusive to this adapter and its only DDL is
|
|
108
|
+
// idempotent IF NOT EXISTS — notices are pure boot-log noise.
|
|
109
|
+
onnotice: () => {},
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async createKey(subject: SubjectId, ctx: KmsContext): Promise<void> {
|
|
114
|
+
await this.ensureSchema();
|
|
115
|
+
const wrapped = wrapDek(this.kek, randomBytes(32));
|
|
116
|
+
try {
|
|
117
|
+
await this.sql`
|
|
118
|
+
INSERT INTO kumiko_subject_keys (subject_id, cipher_key, kek_version, created_by)
|
|
119
|
+
VALUES (${subjectIdToKey(subject)}, ${wrapped}, ${this.kekVersion}, ${ctx.userId ?? null})`;
|
|
120
|
+
} catch (error) {
|
|
121
|
+
if (isPgUniqueViolation(error)) throw new KeyAlreadyExistsError(subject);
|
|
122
|
+
throw error;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async getKey(subject: SubjectId, _ctx: KmsContext): Promise<SubjectDek> {
|
|
127
|
+
await this.ensureSchema();
|
|
128
|
+
const rows = await this.sql<SubjectKeyRow[]>`
|
|
129
|
+
SELECT cipher_key, kek_version, (erased_at IS NOT NULL) AS erased
|
|
130
|
+
FROM kumiko_subject_keys
|
|
131
|
+
WHERE subject_id = ${subjectIdToKey(subject)}`;
|
|
132
|
+
const row = rows[0];
|
|
133
|
+
if (!row) throw new KeyNotFoundError(subject);
|
|
134
|
+
if (row.erased || row.cipher_key === null) throw new KeyErasedError(subject);
|
|
135
|
+
return unwrapDek(this.kekFor(row.kek_version, subject), row.cipher_key);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Rows keep working mid-rotation: not-yet-rewrapped DEKs name their KEK
|
|
139
|
+
// generation and unwrap with the matching previous key. A version with no
|
|
140
|
+
// configured key is a hard config error — NOT a shredded subject.
|
|
141
|
+
private kekFor(version: number, subject: SubjectId): Buffer {
|
|
142
|
+
if (version === this.kekVersion) return this.kek;
|
|
143
|
+
const previous = this.previousKeks.get(version);
|
|
144
|
+
if (previous) return previous;
|
|
145
|
+
throw new Error(
|
|
146
|
+
`PgKmsAdapter: subject ${subjectIdToKey(subject)} is wrapped with KEK version ${version} but only version ${this.kekVersion}${this.previousKeks.size > 0 ? ` (+previous ${[...this.previousKeks.keys()].join(",")})` : ""} is configured — pass previousKeks during rotation (runbook: kek-rotation.md).`,
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async eraseKey(subject: SubjectId, ctx: KmsContext): Promise<void> {
|
|
151
|
+
await this.ensureSchema();
|
|
152
|
+
// Guard on erased_at IS NULL keeps repeat erases from overwriting the
|
|
153
|
+
// original tombstone audit fields; unknown subjects update zero rows.
|
|
154
|
+
await this.sql`
|
|
155
|
+
UPDATE kumiko_subject_keys
|
|
156
|
+
SET cipher_key = NULL,
|
|
157
|
+
erased_at = now(),
|
|
158
|
+
erased_by = ${ctx.userId ?? null},
|
|
159
|
+
erase_reason = ${ctx.eraseReason ?? null}
|
|
160
|
+
WHERE subject_id = ${subjectIdToKey(subject)} AND erased_at IS NULL`;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async health(): Promise<KmsHealth> {
|
|
164
|
+
const start = Date.now();
|
|
165
|
+
await this.ensureSchema();
|
|
166
|
+
await this.sql`SELECT 1`;
|
|
167
|
+
return { ok: true, latencyMs: Date.now() - start };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async close(): Promise<void> {
|
|
171
|
+
await this.sql.end();
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
private ensureSchema(): Promise<void> {
|
|
175
|
+
// A failed attempt resets the memo so a transient DB outage does not
|
|
176
|
+
// poison the adapter for the rest of the process lifetime.
|
|
177
|
+
this.schemaReady ??= this.createSchema().catch((error) => {
|
|
178
|
+
this.schemaReady = undefined;
|
|
179
|
+
throw error;
|
|
180
|
+
});
|
|
181
|
+
return this.schemaReady;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
private async createSchema(): Promise<void> {
|
|
185
|
+
await this.sql`
|
|
186
|
+
CREATE TABLE IF NOT EXISTS kumiko_subject_keys (
|
|
187
|
+
subject_id TEXT PRIMARY KEY,
|
|
188
|
+
cipher_key BYTEA,
|
|
189
|
+
kek_version INTEGER NOT NULL DEFAULT 1,
|
|
190
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
191
|
+
created_by TEXT,
|
|
192
|
+
erased_at TIMESTAMPTZ,
|
|
193
|
+
erased_by TEXT,
|
|
194
|
+
erase_reason TEXT
|
|
195
|
+
)`;
|
|
196
|
+
await this.sql`
|
|
197
|
+
CREATE INDEX IF NOT EXISTS kumiko_subject_keys_erased_idx
|
|
198
|
+
ON kumiko_subject_keys (erased_at) WHERE erased_at IS NOT NULL`;
|
|
199
|
+
await this.sql`
|
|
200
|
+
CREATE INDEX IF NOT EXISTS kumiko_subject_keys_audit_idx
|
|
201
|
+
ON kumiko_subject_keys (created_at, erased_at)`;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export function createPgKmsAdapter(options: PgKmsAdapterOptions): PgKmsAdapter {
|
|
206
|
+
return new PgKmsAdapter(options);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export interface RewrapOptions {
|
|
210
|
+
readonly databaseUrl: string;
|
|
211
|
+
/** Older KEKs by version — every version still present in the table must be covered. */
|
|
212
|
+
readonly fromKeks: Readonly<Record<number, string>>;
|
|
213
|
+
/** The new active KEK and its version (previous active version + 1). */
|
|
214
|
+
readonly toKek: string;
|
|
215
|
+
readonly toKekVersion: number;
|
|
216
|
+
readonly batchSize?: number;
|
|
217
|
+
readonly dryRun?: boolean;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export interface RewrapResult {
|
|
221
|
+
readonly scanned: number;
|
|
222
|
+
readonly rewrapped: number;
|
|
223
|
+
readonly skippedErased: number;
|
|
224
|
+
readonly failures: ReadonlyArray<{ readonly subjectId: string; readonly reason: string }>;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// One-time KEK rotation (runbook: kek-rotation.md). Unwraps every live DEK
|
|
228
|
+
// wrapped with an older KEK generation and re-wraps it under the new KEK,
|
|
229
|
+
// bumping kek_version. Idempotent: rows already on toKekVersion are not
|
|
230
|
+
// selected; a second run reports 0. Erased tombstones (cipher_key NULL) are
|
|
231
|
+
// never touched. The UPDATE is guarded on the row's old kek_version, so a
|
|
232
|
+
// concurrent app writing fresh keys on the new version is never clobbered.
|
|
233
|
+
export async function rewrapSubjectKeys(options: RewrapOptions): Promise<RewrapResult> {
|
|
234
|
+
const toKek = decodePlatformKek(options.toKek);
|
|
235
|
+
const fromKeks = new Map<number, Buffer>();
|
|
236
|
+
for (const [version, kek] of Object.entries(options.fromKeks)) {
|
|
237
|
+
fromKeks.set(Number(version), decodePlatformKek(kek));
|
|
238
|
+
}
|
|
239
|
+
const batchSize = options.batchSize ?? 200;
|
|
240
|
+
const sql = postgres(options.databaseUrl, { max: 2, onnotice: () => {} });
|
|
241
|
+
|
|
242
|
+
const result = {
|
|
243
|
+
scanned: 0,
|
|
244
|
+
rewrapped: 0,
|
|
245
|
+
skippedErased: 0,
|
|
246
|
+
failures: [] as Array<{ subjectId: string; reason: string }>,
|
|
247
|
+
};
|
|
248
|
+
try {
|
|
249
|
+
let cursor = "";
|
|
250
|
+
for (;;) {
|
|
251
|
+
const rows = await sql<
|
|
252
|
+
Array<{ subject_id: string; cipher_key: Uint8Array | null; kek_version: number }>
|
|
253
|
+
>`
|
|
254
|
+
SELECT subject_id, cipher_key, kek_version
|
|
255
|
+
FROM kumiko_subject_keys
|
|
256
|
+
WHERE kek_version <> ${options.toKekVersion} AND subject_id > ${cursor}
|
|
257
|
+
ORDER BY subject_id ASC
|
|
258
|
+
LIMIT ${batchSize}`;
|
|
259
|
+
if (rows.length === 0) break;
|
|
260
|
+
|
|
261
|
+
for (const row of rows) {
|
|
262
|
+
result.scanned++;
|
|
263
|
+
if (row.cipher_key === null) {
|
|
264
|
+
result.skippedErased++;
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
try {
|
|
268
|
+
const fromKek = fromKeks.get(row.kek_version);
|
|
269
|
+
if (!fromKek) {
|
|
270
|
+
throw new Error(`no fromKek configured for kek_version ${row.kek_version}`);
|
|
271
|
+
}
|
|
272
|
+
const rewrapped = wrapDek(toKek, unwrapDek(fromKek, row.cipher_key));
|
|
273
|
+
if (!options.dryRun) {
|
|
274
|
+
await sql`
|
|
275
|
+
UPDATE kumiko_subject_keys
|
|
276
|
+
SET cipher_key = ${rewrapped}, kek_version = ${options.toKekVersion}
|
|
277
|
+
WHERE subject_id = ${row.subject_id} AND kek_version = ${row.kek_version}`;
|
|
278
|
+
}
|
|
279
|
+
result.rewrapped++;
|
|
280
|
+
} catch (error) {
|
|
281
|
+
result.failures.push({
|
|
282
|
+
subjectId: row.subject_id,
|
|
283
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
const last = rows[rows.length - 1];
|
|
288
|
+
if (last === undefined) break;
|
|
289
|
+
cursor = last.subject_id;
|
|
290
|
+
}
|
|
291
|
+
} finally {
|
|
292
|
+
await sql.end();
|
|
293
|
+
}
|
|
294
|
+
return result;
|
|
295
|
+
}
|