@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,111 @@
|
|
|
1
|
+
// Dead-letter storage for apply-handler failures during projection rebuild.
|
|
2
|
+
//
|
|
3
|
+
// Background: rebuildProjection / rebuildMultiStreamProjection replay the
|
|
4
|
+
// event log through apply handlers inside ONE transaction. A single event
|
|
5
|
+
// whose (possibly years-old) payload makes its apply throw rolls the whole
|
|
6
|
+
// replay back — the projection stays permanently un-rebuildable until the
|
|
7
|
+
// event is repaired by hand. The upcaster dead-letter only quarantines
|
|
8
|
+
// upcast-TRANSFORM failures, not apply failures (#760).
|
|
9
|
+
//
|
|
10
|
+
// Quarantine mode (errorPolicy.skipApplyErrors on RebuildDeps, or
|
|
11
|
+
// MspErrorMode.rebuild.skipApplyErrors on the MSP definition) confines each
|
|
12
|
+
// apply to a savepoint: a throwing apply is rolled back, captured into
|
|
13
|
+
// `kumiko_rebuild_dead_letters`, and the replay continues. Replay-after-fix
|
|
14
|
+
// is a separate ops step — same stance as the upcaster dead-letter.
|
|
15
|
+
|
|
16
|
+
import type { DbConnection, DbRunner } from "../db/connection";
|
|
17
|
+
import { bigint, index, jsonb, table as pgTable, text, timestamp, uuid } from "../db/dialect";
|
|
18
|
+
import { tableExists } from "../db/schema-inspection";
|
|
19
|
+
import { unsafePushTables } from "../stack";
|
|
20
|
+
import type { StoredEvent } from "./event-store";
|
|
21
|
+
|
|
22
|
+
export const rebuildDeadLetterTable = pgTable(
|
|
23
|
+
"kumiko_rebuild_dead_letters",
|
|
24
|
+
{
|
|
25
|
+
// Surrogate PK — the same event can land here across multiple rebuild
|
|
26
|
+
// attempts before the fix ships, without unique-violation noise.
|
|
27
|
+
id: bigint("id", { mode: "bigint" }).primaryKey().generatedAlwaysAsIdentity(),
|
|
28
|
+
projectionName: text("projection_name").notNull(),
|
|
29
|
+
// StoredEvent.id is surfaced as `string` (bigint serialised for JSON
|
|
30
|
+
// safety) — stored as text to keep the round-trip identity.
|
|
31
|
+
eventId: text("event_id").notNull(),
|
|
32
|
+
tenantId: uuid("tenant_id").notNull(),
|
|
33
|
+
aggregateId: text("aggregate_id").notNull(),
|
|
34
|
+
aggregateType: text("aggregate_type").notNull(),
|
|
35
|
+
eventType: text("event_type").notNull(),
|
|
36
|
+
errorMessage: text("error_message").notNull(),
|
|
37
|
+
payload: jsonb("payload").notNull(),
|
|
38
|
+
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
|
39
|
+
},
|
|
40
|
+
(t) => ({
|
|
41
|
+
projectionIdx: index("rebuild_dead_letters_projection_idx").on(t.projectionName),
|
|
42
|
+
createdAtIdx: index("rebuild_dead_letters_created_at_idx").on(t.createdAt),
|
|
43
|
+
}),
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
// Idempotent table-create. The rebuild runners call this before the rebuild
|
|
47
|
+
// tx whenever quarantine mode is active, so the opt-in works on databases
|
|
48
|
+
// provisioned before this table existed.
|
|
49
|
+
export async function createRebuildDeadLetterTable(db: DbConnection): Promise<void> {
|
|
50
|
+
// skip: table already exists — bootstrap called from multiple paths
|
|
51
|
+
if (await tableExists(db, "public.kumiko_rebuild_dead_letters")) return;
|
|
52
|
+
await unsafePushTables(db, { kumikoRebuildDeadLetters: rebuildDeadLetterTable });
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export type SkippedApply = {
|
|
56
|
+
readonly event: StoredEvent;
|
|
57
|
+
readonly error: unknown;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
// Bulk-write, called ONCE at the end of a quarantining rebuild (inside its
|
|
61
|
+
// tx). Unqualified insert — during the rebuild the search_path points at the
|
|
62
|
+
// shadow schema, and this table exists only in public, so it falls through.
|
|
63
|
+
export async function recordRebuildDeadLetters(
|
|
64
|
+
db: DbRunner,
|
|
65
|
+
projectionName: string,
|
|
66
|
+
skipped: readonly SkippedApply[],
|
|
67
|
+
): Promise<void> {
|
|
68
|
+
const { insertMany } = await import("../bun-db/query");
|
|
69
|
+
await insertMany(
|
|
70
|
+
db,
|
|
71
|
+
rebuildDeadLetterTable,
|
|
72
|
+
skipped.map(({ event, error }) => ({
|
|
73
|
+
projectionName,
|
|
74
|
+
eventId: event.id,
|
|
75
|
+
tenantId: event.tenantId,
|
|
76
|
+
aggregateId: event.aggregateId,
|
|
77
|
+
aggregateType: event.aggregateType,
|
|
78
|
+
eventType: event.type,
|
|
79
|
+
errorMessage: error instanceof Error ? error.message : String(error),
|
|
80
|
+
payload: event.payload,
|
|
81
|
+
})),
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export type RebuildDeadLetterRow = {
|
|
86
|
+
readonly id: bigint;
|
|
87
|
+
readonly projectionName: string;
|
|
88
|
+
readonly eventId: string;
|
|
89
|
+
readonly tenantId: string;
|
|
90
|
+
readonly aggregateId: string;
|
|
91
|
+
readonly aggregateType: string;
|
|
92
|
+
readonly eventType: string;
|
|
93
|
+
readonly errorMessage: string;
|
|
94
|
+
readonly payload: Record<string, unknown>;
|
|
95
|
+
readonly createdAt: Date;
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
// Ops-side triage query, optionally scoped to one projection.
|
|
99
|
+
export async function listRebuildDeadLetters(
|
|
100
|
+
db: DbConnection,
|
|
101
|
+
options: { projectionName?: string; limit?: number } = {},
|
|
102
|
+
): Promise<readonly RebuildDeadLetterRow[]> {
|
|
103
|
+
const { selectMany } = await import("../bun-db/query");
|
|
104
|
+
const limit = options.limit ?? 100;
|
|
105
|
+
const where =
|
|
106
|
+
options.projectionName !== undefined ? { projectionName: options.projectionName } : undefined;
|
|
107
|
+
return selectMany<RebuildDeadLetterRow>(db, rebuildDeadLetterTable, where, {
|
|
108
|
+
orderBy: { col: "createdAt", direction: "desc" },
|
|
109
|
+
limit,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
// sql now comes from native dialect
|
|
2
2
|
|
|
3
|
+
import type {
|
|
4
|
+
LoadAggregateWithSnapshotOptions,
|
|
5
|
+
LoadAggregateWithSnapshotResult,
|
|
6
|
+
SnapshotReducer,
|
|
7
|
+
} from "@cosmicdrift/kumiko-types/snapshot-types";
|
|
3
8
|
import type { DbConnection, DbRunner } from "../db/connection";
|
|
4
9
|
import {
|
|
5
10
|
index,
|
|
@@ -12,13 +17,13 @@ import {
|
|
|
12
17
|
text,
|
|
13
18
|
uuid,
|
|
14
19
|
} from "../db/dialect";
|
|
15
|
-
import { upsertSnapshot } from "../db/queries/event-store";
|
|
20
|
+
import { ensureSnapshotVersionColumn, upsertSnapshot } from "../db/queries/event-store";
|
|
16
21
|
import { selectMany } from "../db/query";
|
|
17
22
|
import { tableExists } from "../db/schema-inspection";
|
|
18
23
|
import type { TenantId } from "../engine/types";
|
|
19
24
|
import { unsafePushTables } from "../stack";
|
|
20
25
|
import { isStreamArchived } from "./archive";
|
|
21
|
-
import { loadEventsAfterVersion
|
|
26
|
+
import { loadEventsAfterVersion } from "./event-store";
|
|
22
27
|
|
|
23
28
|
// Marten-aligned snapshot store. A snapshot is a point-in-time materialised
|
|
24
29
|
// state of an aggregate at a specific version, cached so rehydrating the
|
|
@@ -30,18 +35,17 @@ import { loadEventsAfterVersion, type StoredEvent } from "./event-store";
|
|
|
30
35
|
// 3. loadEventsAfterVersion(aggregate, N) → only the delta
|
|
31
36
|
// 4. reducer(snapshot, delta) → current state
|
|
32
37
|
//
|
|
33
|
-
// Write path:
|
|
34
|
-
//
|
|
35
|
-
//
|
|
38
|
+
// Write path: manual via ctx.snapshotAggregate, or automatic via the
|
|
39
|
+
// { snapshotEvery: N } load option — the read path persists a fresh
|
|
40
|
+
// snapshot whenever it folded at least N delta events.
|
|
36
41
|
//
|
|
37
|
-
// Schema-migration policy:
|
|
38
|
-
// the aggregate state in the reducer's current shape
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
// source of truth.
|
|
42
|
+
// Schema-migration policy: explicit generations, no reducer hashing. A
|
|
43
|
+
// snapshot stores the aggregate state in the reducer's current shape plus
|
|
44
|
+
// a caller-declared snapshot_version. Bump { snapshotVersion } when the
|
|
45
|
+
// reducer's shape changes — stored snapshots with another generation are
|
|
46
|
+
// ignored (full replay through the upcaster chain on events) and restamped
|
|
47
|
+
// on the next auto-save. Snapshots stay a perf optimisation, not a source
|
|
48
|
+
// of truth.
|
|
45
49
|
//
|
|
46
50
|
// Upcaster interaction: the raw API (loadAggregateWithSnapshot below) does
|
|
47
51
|
// NOT apply the upcaster chain on delta events — same layering as raw
|
|
@@ -63,6 +67,9 @@ export const snapshotsTable = pgTable(
|
|
|
63
67
|
// returns events with version > this value.
|
|
64
68
|
version: integer("version").notNull(),
|
|
65
69
|
state: jsonb("state").$type<Record<string, unknown>>().notNull(),
|
|
70
|
+
// Reducer-shape generation — snapshots from another generation are
|
|
71
|
+
// ignored on load. See LoadAggregateWithSnapshotOptions.snapshotVersion.
|
|
72
|
+
snapshotVersion: integer("snapshot_version").notNull().default(sql`1`),
|
|
66
73
|
createdAt: instant("created_at", { precision: 3 }).notNull().default(sql`now()`),
|
|
67
74
|
},
|
|
68
75
|
(t) => ({
|
|
@@ -76,7 +83,13 @@ export const snapshotsTable = pgTable(
|
|
|
76
83
|
|
|
77
84
|
export async function createSnapshotsTable(db: DbConnection): Promise<void> {
|
|
78
85
|
// skip: table already exists — idempotent boot + test-setup call
|
|
79
|
-
if (await tableExists(db, "public.kumiko_snapshots"))
|
|
86
|
+
if (await tableExists(db, "public.kumiko_snapshots")) {
|
|
87
|
+
// Installs that predate snapshot_version get healed by the same
|
|
88
|
+
// idempotent ensure that `kumiko schema apply` / test-setup already runs.
|
|
89
|
+
await ensureSnapshotVersionColumn(db);
|
|
90
|
+
// skip: table already ensured — only the column heal above was needed
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
80
93
|
await unsafePushTables(db, { kumikoSnapshots: snapshotsTable });
|
|
81
94
|
}
|
|
82
95
|
|
|
@@ -86,6 +99,7 @@ export type Snapshot<TState extends Record<string, unknown> = Record<string, unk
|
|
|
86
99
|
readonly aggregateType: string;
|
|
87
100
|
readonly version: number;
|
|
88
101
|
readonly state: TState;
|
|
102
|
+
readonly snapshotVersion: number;
|
|
89
103
|
readonly createdAt: Temporal.Instant;
|
|
90
104
|
};
|
|
91
105
|
|
|
@@ -95,6 +109,8 @@ export type SaveSnapshotArgs = {
|
|
|
95
109
|
readonly aggregateType: string;
|
|
96
110
|
readonly version: number;
|
|
97
111
|
readonly state: Record<string, unknown>;
|
|
112
|
+
// Reducer-shape generation (default 1) — see LoadAggregateWithSnapshotOptions.
|
|
113
|
+
readonly snapshotVersion?: number;
|
|
98
114
|
};
|
|
99
115
|
|
|
100
116
|
// Upsert-style save so re-snapshotting the same (aggregateId, version) is
|
|
@@ -108,6 +124,7 @@ export async function saveSnapshot(db: DbRunner, args: SaveSnapshotArgs): Promis
|
|
|
108
124
|
aggregateType: args.aggregateType,
|
|
109
125
|
version: args.version,
|
|
110
126
|
state: args.state,
|
|
127
|
+
snapshotVersion: args.snapshotVersion ?? 1,
|
|
111
128
|
});
|
|
112
129
|
}
|
|
113
130
|
|
|
@@ -123,6 +140,7 @@ export async function loadLatestSnapshot<
|
|
|
123
140
|
aggregateType: string;
|
|
124
141
|
version: number;
|
|
125
142
|
state: unknown;
|
|
143
|
+
snapshotVersion: number;
|
|
126
144
|
createdAt: Temporal.Instant;
|
|
127
145
|
};
|
|
128
146
|
const rows = await selectMany<SnapRow>(
|
|
@@ -139,35 +157,16 @@ export async function loadLatestSnapshot<
|
|
|
139
157
|
aggregateType: row.aggregateType,
|
|
140
158
|
version: row.version,
|
|
141
159
|
state: row.state as TState, // @cast-boundary engine-payload
|
|
160
|
+
snapshotVersion: row.snapshotVersion,
|
|
142
161
|
createdAt: row.createdAt,
|
|
143
162
|
};
|
|
144
163
|
}
|
|
145
164
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
event: StoredEvent,
|
|
152
|
-
) => TState;
|
|
153
|
-
|
|
154
|
-
export type LoadAggregateWithSnapshotResult<TState extends Record<string, unknown>> = {
|
|
155
|
-
readonly state: TState;
|
|
156
|
-
readonly version: number;
|
|
157
|
-
readonly snapshotHit: boolean;
|
|
158
|
-
};
|
|
159
|
-
|
|
160
|
-
export type LoadAggregateWithSnapshotOptions = {
|
|
161
|
-
// Opt-in: include archived streams in the rehydrate. Default false — same
|
|
162
|
-
// semantics as loadAggregate / loadAggregateAsOf. Archive check is a
|
|
163
|
-
// single indexed lookup, so the cost stays negligible on the hot path.
|
|
164
|
-
readonly includeArchived?: boolean;
|
|
165
|
-
// Optional upcaster step: every delta event goes through this transform
|
|
166
|
-
// BEFORE the reducer sees it. The dispatcher wires this up with
|
|
167
|
-
// r.eventMigration so feature code always sees current-version payloads.
|
|
168
|
-
// Async to support Marten-style AsyncOnlyEventUpcaster (DB lookups).
|
|
169
|
-
readonly upcastEvent?: (event: StoredEvent) => Promise<StoredEvent>;
|
|
170
|
-
};
|
|
165
|
+
export type {
|
|
166
|
+
LoadAggregateWithSnapshotOptions,
|
|
167
|
+
LoadAggregateWithSnapshotResult,
|
|
168
|
+
SnapshotReducer,
|
|
169
|
+
} from "@cosmicdrift/kumiko-types/snapshot-types";
|
|
171
170
|
|
|
172
171
|
// Snapshot-aware rehydrate. Loads the latest snapshot (if any), applies
|
|
173
172
|
// events strictly newer than snapshot.version, and returns the fold.
|
|
@@ -186,13 +185,23 @@ export async function loadAggregateWithSnapshot<TState extends Record<string, un
|
|
|
186
185
|
initial: TState,
|
|
187
186
|
options?: LoadAggregateWithSnapshotOptions,
|
|
188
187
|
): Promise<LoadAggregateWithSnapshotResult<TState>> {
|
|
188
|
+
if (
|
|
189
|
+
options?.snapshotEvery !== undefined &&
|
|
190
|
+
(!Number.isInteger(options.snapshotEvery) || options.snapshotEvery < 1)
|
|
191
|
+
) {
|
|
192
|
+
throw new Error(
|
|
193
|
+
`loadAggregateWithSnapshot: snapshotEvery must be an integer >= 1, got ${String(options.snapshotEvery)}`,
|
|
194
|
+
);
|
|
195
|
+
}
|
|
189
196
|
if (!options?.includeArchived) {
|
|
190
197
|
const archived = await isStreamArchived(db, tenantId, aggregateId);
|
|
191
198
|
if (archived) {
|
|
192
199
|
return { state: initial, version: 0, snapshotHit: false };
|
|
193
200
|
}
|
|
194
201
|
}
|
|
195
|
-
const
|
|
202
|
+
const shapeVersion = options?.snapshotVersion ?? 1;
|
|
203
|
+
const stored = await loadLatestSnapshot<TState>(db, aggregateId, tenantId);
|
|
204
|
+
const snapshot = stored && stored.snapshotVersion === shapeVersion ? stored : null;
|
|
196
205
|
const baseState = snapshot ? snapshot.state : initial;
|
|
197
206
|
const afterVersion = snapshot ? snapshot.version : 0;
|
|
198
207
|
const delta = await loadEventsAfterVersion(db, aggregateId, tenantId, afterVersion);
|
|
@@ -204,6 +213,20 @@ export async function loadAggregateWithSnapshot<TState extends Record<string, un
|
|
|
204
213
|
}
|
|
205
214
|
const lastDelta = delta[delta.length - 1];
|
|
206
215
|
const latestVersion = lastDelta ? lastDelta.version : afterVersion;
|
|
216
|
+
if (options?.snapshotEvery !== undefined && lastDelta && delta.length >= options.snapshotEvery) {
|
|
217
|
+
try {
|
|
218
|
+
await saveSnapshot(db, {
|
|
219
|
+
aggregateId,
|
|
220
|
+
tenantId,
|
|
221
|
+
aggregateType: lastDelta.aggregateType,
|
|
222
|
+
version: latestVersion,
|
|
223
|
+
state,
|
|
224
|
+
snapshotVersion: shapeVersion,
|
|
225
|
+
});
|
|
226
|
+
} catch {
|
|
227
|
+
// Best-effort cache write — losing it only costs the next load a replay.
|
|
228
|
+
}
|
|
229
|
+
}
|
|
207
230
|
return {
|
|
208
231
|
state,
|
|
209
232
|
version: latestVersion,
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { buildStorageKey } from "../types";
|
|
3
|
+
|
|
4
|
+
describe("buildStorageKey", () => {
|
|
5
|
+
test("uses the lowercased extension for a normal filename", () => {
|
|
6
|
+
const key = buildStorageKey("T1" as never, "invoice", 1, "attachment", "logo.PNG", "u1");
|
|
7
|
+
expect(key).toBe("T1/invoice/1/attachment/u1.png");
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
test("uses the sole segment as extension when there is no dot", () => {
|
|
11
|
+
const key = buildStorageKey("T1" as never, "invoice", 1, "attachment", "noext", "u1");
|
|
12
|
+
expect(key).toBe("T1/invoice/1/attachment/u1.noext");
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("rejects a path-traversal filename and falls back to bin instead of leaking the payload", () => {
|
|
16
|
+
const key = buildStorageKey(
|
|
17
|
+
"T1" as never,
|
|
18
|
+
"unattached",
|
|
19
|
+
"file",
|
|
20
|
+
"u",
|
|
21
|
+
"a.b/../../../../evil",
|
|
22
|
+
"u1",
|
|
23
|
+
);
|
|
24
|
+
expect(key).toBe("T1/unattached/file/u/u1.bin");
|
|
25
|
+
expect(key).not.toContain("..");
|
|
26
|
+
expect(key.split("/")).toHaveLength(5);
|
|
27
|
+
});
|
|
28
|
+
});
|
|
@@ -32,3 +32,11 @@ describe("fileRefEntity base-column drift", () => {
|
|
|
32
32
|
expect(insertedById[0]?.notNull).toBe(false);
|
|
33
33
|
});
|
|
34
34
|
});
|
|
35
|
+
|
|
36
|
+
describe("fileRefEntity — DDL (#1205 regression)", () => {
|
|
37
|
+
test("size column is bigint, not double precision", () => {
|
|
38
|
+
const size = col("size");
|
|
39
|
+
expect(size).toHaveLength(1);
|
|
40
|
+
expect(size[0]?.pgType).toBe("bigint");
|
|
41
|
+
});
|
|
42
|
+
});
|
|
@@ -593,6 +593,30 @@ describe("error handling", () => {
|
|
|
593
593
|
expect(body.error).toContain("invalid_file_type");
|
|
594
594
|
});
|
|
595
595
|
|
|
596
|
+
test("upload with path-traversal entityType is rejected before it reaches storage", async () => {
|
|
597
|
+
const pngContent = new Uint8Array([0x89, 0x50, 0x4e, 0x47]);
|
|
598
|
+
const res = await uploadFile(adminUser, "logo.png", pngContent, "image/png", {
|
|
599
|
+
entityType: "../../tenantB",
|
|
600
|
+
entityId: "1",
|
|
601
|
+
fieldName: "logo",
|
|
602
|
+
});
|
|
603
|
+
expect(res.status).toBe(400);
|
|
604
|
+
const body = await res.json();
|
|
605
|
+
expect(body.error).toContain("invalid_entityType");
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
test("upload with path-traversal fieldName is rejected before it reaches storage", async () => {
|
|
609
|
+
const pngContent = new Uint8Array([0x89, 0x50, 0x4e, 0x47]);
|
|
610
|
+
const res = await uploadFile(adminUser, "logo.png", pngContent, "image/png", {
|
|
611
|
+
entityType: "tenant",
|
|
612
|
+
entityId: "1",
|
|
613
|
+
fieldName: "../../escape",
|
|
614
|
+
});
|
|
615
|
+
expect(res.status).toBe(400);
|
|
616
|
+
const body = await res.json();
|
|
617
|
+
expect(body.error).toContain("invalid_fieldName");
|
|
618
|
+
});
|
|
619
|
+
|
|
596
620
|
test("upload without auth returns 401", async () => {
|
|
597
621
|
const formData = new FormData();
|
|
598
622
|
formData.append("file", new File([new Uint8Array(10)], "test.png", { type: "image/png" }));
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { rm } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { createLocalProvider } from "../local-provider";
|
|
6
|
+
|
|
7
|
+
describe("createLocalProvider path-traversal guard", () => {
|
|
8
|
+
const basePath = join(tmpdir(), `kumiko-local-provider-test-${Date.now()}`);
|
|
9
|
+
const provider = createLocalProvider(basePath);
|
|
10
|
+
|
|
11
|
+
afterEach(async () => {
|
|
12
|
+
await rm(basePath, { recursive: true, force: true });
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("rejects a key with a `..` segment even when it resolves inside basePath", async () => {
|
|
16
|
+
await expect(
|
|
17
|
+
provider.write("T1/e/1/f/x.png/../../../../T2/y.png", new Uint8Array([1])),
|
|
18
|
+
).rejects.toThrow(/path-traversal/);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("rejects a `..` segment on read/delete too, and exists() reports false instead of throwing", async () => {
|
|
22
|
+
await expect(provider.read("a/../../etc/passwd")).rejects.toThrow(/path-traversal/);
|
|
23
|
+
await expect(provider.delete("a/../../etc/passwd")).rejects.toThrow(/path-traversal/);
|
|
24
|
+
expect(await provider.exists("a/../../etc/passwd")).toBe(false);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("still allows a normal, contained key", async () => {
|
|
28
|
+
await provider.write("T1/entity/1/field/file.png", new Uint8Array([1, 2, 3]));
|
|
29
|
+
expect(await provider.exists("T1/entity/1/field/file.png")).toBe(true);
|
|
30
|
+
});
|
|
31
|
+
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { describe, expect, mock, test } from "bun:test";
|
|
2
|
+
import type { Registry } from "../../engine/types";
|
|
3
|
+
import { makeFileProviderResolver } from "../provider-resolver";
|
|
4
|
+
import type { FileStorageProvider } from "../types";
|
|
5
|
+
|
|
6
|
+
const fakeProvider = { name: "fake" } as unknown as FileStorageProvider;
|
|
7
|
+
|
|
8
|
+
function fakeRegistry(): Registry {
|
|
9
|
+
return {
|
|
10
|
+
getExtensionUsages: () => [
|
|
11
|
+
{
|
|
12
|
+
entityName: "fake",
|
|
13
|
+
options: { build: async () => fakeProvider },
|
|
14
|
+
},
|
|
15
|
+
],
|
|
16
|
+
} as unknown as Registry;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// 698/2: resolveProvider must not re-read config + secrets on every call —
|
|
20
|
+
// the provider is effectively static per tenant for the process lifetime.
|
|
21
|
+
describe("makeFileProviderResolver — per-tenant cache", () => {
|
|
22
|
+
test("a second resolve for the same tenant reuses the cached build, doesn't call configAccessorFactory again", async () => {
|
|
23
|
+
const configAccessorFactory = mock(() => async (_key: unknown) => "fake");
|
|
24
|
+
const resolver = makeFileProviderResolver({
|
|
25
|
+
registry: fakeRegistry(),
|
|
26
|
+
_configAccessorFactory: configAccessorFactory,
|
|
27
|
+
db: {} as never,
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const first = await resolver("tenant-a" as never);
|
|
31
|
+
const second = await resolver("tenant-a" as never);
|
|
32
|
+
|
|
33
|
+
expect(first).toBe(fakeProvider);
|
|
34
|
+
expect(second).toBe(fakeProvider);
|
|
35
|
+
expect(configAccessorFactory).toHaveBeenCalledTimes(1);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("different tenants get independent cache entries", async () => {
|
|
39
|
+
const configAccessorFactory = mock(() => async (_key: unknown) => "fake");
|
|
40
|
+
const resolver = makeFileProviderResolver({
|
|
41
|
+
registry: fakeRegistry(),
|
|
42
|
+
_configAccessorFactory: configAccessorFactory,
|
|
43
|
+
db: {} as never,
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
await resolver("tenant-a" as never);
|
|
47
|
+
await resolver("tenant-b" as never);
|
|
48
|
+
|
|
49
|
+
expect(configAccessorFactory).toHaveBeenCalledTimes(2);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("a rejected build is evicted — the next call retries instead of staying poisoned", async () => {
|
|
53
|
+
let calls = 0;
|
|
54
|
+
const resolver = makeFileProviderResolver({
|
|
55
|
+
registry: {
|
|
56
|
+
getExtensionUsages: () => {
|
|
57
|
+
calls++;
|
|
58
|
+
if (calls === 1) throw new Error("transient failure");
|
|
59
|
+
return [{ entityName: "fake", options: { build: async () => fakeProvider } }];
|
|
60
|
+
},
|
|
61
|
+
} as unknown as Registry,
|
|
62
|
+
_configAccessorFactory: () => async (_key: unknown) => "fake",
|
|
63
|
+
db: {} as never,
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
await expect(resolver("tenant-a" as never)).rejects.toThrow("transient failure");
|
|
67
|
+
const second = await resolver("tenant-a" as never);
|
|
68
|
+
expect(second).toBe(fakeProvider);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
@@ -147,6 +147,19 @@ describe("FileStorageProvider.writeStream — local-filesystem", () => {
|
|
|
147
147
|
const data = await provider.read("k");
|
|
148
148
|
expect(Array.from(data)).toEqual([2, 2, 2]);
|
|
149
149
|
});
|
|
150
|
+
|
|
151
|
+
test("write/read/delete reject a key that escapes basePath; exists reports false", async () => {
|
|
152
|
+
const provider = createLocalProvider(basePath);
|
|
153
|
+
const escapingKey = "../../etc/passwd";
|
|
154
|
+
|
|
155
|
+
await expect(provider.write(escapingKey, new Uint8Array([1]))).rejects.toThrow(
|
|
156
|
+
/path-traversal/,
|
|
157
|
+
);
|
|
158
|
+
await expect(provider.read(escapingKey)).rejects.toThrow(/path-traversal/);
|
|
159
|
+
await expect(provider.delete(escapingKey)).rejects.toThrow(/path-traversal/);
|
|
160
|
+
// exists() treats any stat failure (missing file, escaping key) as "not there".
|
|
161
|
+
expect(await provider.exists(escapingKey)).toBe(false);
|
|
162
|
+
});
|
|
150
163
|
});
|
|
151
164
|
|
|
152
165
|
describe("FileStorageProvider.writeStream — Streaming-Property", () => {
|
package/src/files/file-handle.ts
CHANGED
|
@@ -10,27 +10,10 @@
|
|
|
10
10
|
// suffix before the file extension — `foo/bar.jpg` + `"medium"` →
|
|
11
11
|
// `foo/bar.medium.jpg`. Stable, reversible, no extra lookup tables.
|
|
12
12
|
|
|
13
|
+
import type { FileContext, FileHandle } from "@cosmicdrift/kumiko-types/file-handle-types";
|
|
13
14
|
import type { FileStorageProvider } from "./types";
|
|
14
15
|
|
|
15
|
-
export type FileHandle
|
|
16
|
-
readonly key: string;
|
|
17
|
-
read(): Promise<Uint8Array>;
|
|
18
|
-
write(data: Uint8Array, mimeType?: string): Promise<void>;
|
|
19
|
-
delete(): Promise<void>;
|
|
20
|
-
exists(): Promise<boolean>;
|
|
21
|
-
// Produce a handle for a derived key (e.g. a thumbnail). Does not touch
|
|
22
|
-
// storage; only computes the key. Writing to the derived handle is the
|
|
23
|
-
// caller's job.
|
|
24
|
-
derive(suffix: string): FileHandle;
|
|
25
|
-
};
|
|
26
|
-
|
|
27
|
-
// The `ctx.files` service — a factory that materialises a FileHandle for a
|
|
28
|
-
// storage key. One per request/event, bound to a single tenant: the provider
|
|
29
|
-
// is resolved per-tenant through file-foundation, so uploads, ctx.files and the
|
|
30
|
-
// GDPR jobs all hit the same store by construction.
|
|
31
|
-
export type FileContext = {
|
|
32
|
-
ref(key: string): FileHandle;
|
|
33
|
-
};
|
|
16
|
+
export type { FileContext, FileHandle };
|
|
34
17
|
|
|
35
18
|
// `getProvider` is a lazily-resolved, memoized accessor — the provider is
|
|
36
19
|
// resolved (config + s3.secretAccessKey secret read) only when a handle method
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { createBigIntField, createEntity, createTextField } from "../engine";
|
|
2
2
|
|
|
3
3
|
// fileRef — das File-Metadata-Entity. Ganz normales ES-Entity: Upload/Delete
|
|
4
4
|
// laufen über den Standard-Executor (file-routes.ts), die Tabelle `file_refs`
|
|
@@ -25,7 +25,7 @@ export const fileRefEntity = createEntity({
|
|
|
25
25
|
storageKey: createTextField({ required: true }),
|
|
26
26
|
fileName: createTextField({ required: true, pii: true }),
|
|
27
27
|
mimeType: createTextField({ required: true }),
|
|
28
|
-
size:
|
|
28
|
+
size: createBigIntField({ required: true }),
|
|
29
29
|
entityType: createTextField(),
|
|
30
30
|
entityId: createTextField(),
|
|
31
31
|
fieldName: createTextField(),
|
package/src/files/file-routes.ts
CHANGED
|
@@ -112,6 +112,19 @@ export function createFileRoutes(options: FileRoutesOptions): Hono {
|
|
|
112
112
|
const entityId = typeof body["entityId"] === "string" ? body["entityId"] : undefined;
|
|
113
113
|
const fieldName = typeof body["fieldName"] === "string" ? body["fieldName"] : undefined;
|
|
114
114
|
|
|
115
|
+
// These interpolate directly into the storage key (buildStorageKey below) —
|
|
116
|
+
// reject anything but a safe identifier so a client can't path-traverse
|
|
117
|
+
// out of its tenant's storage prefix (../.. segments, absolute paths).
|
|
118
|
+
for (const [field, value] of [
|
|
119
|
+
["entityType", entityType],
|
|
120
|
+
["entityId", entityId],
|
|
121
|
+
["fieldName", fieldName],
|
|
122
|
+
] as const) {
|
|
123
|
+
if (value !== undefined && !/^[A-Za-z0-9_-]+$/.test(value)) {
|
|
124
|
+
return c.json({ error: `invalid_${field}: must match /^[A-Za-z0-9_-]+$/` }, 400);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
115
128
|
// Validate against entity field definition if available.
|
|
116
129
|
let maxSize = options.maxUploadSize ?? "10mb";
|
|
117
130
|
let accept: readonly string[] | undefined;
|
package/src/files/index.ts
CHANGED
|
@@ -34,6 +34,6 @@ export type {
|
|
|
34
34
|
SignedUrlOptions,
|
|
35
35
|
WriteStreamOptions,
|
|
36
36
|
} from "./types";
|
|
37
|
-
export { buildStorageKey, parseMaxSize, validateFile } from "./types";
|
|
37
|
+
export { assertSafeStorageKey, buildStorageKey, parseMaxSize, validateFile } from "./types";
|
|
38
38
|
export type { ZipEntry } from "./zip-stream";
|
|
39
39
|
export { createZipStream } from "./zip-stream";
|