@cosmicdrift/kumiko-framework 0.1.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 +159 -0
- package/package.json +91 -0
- package/src/__tests__/anonymous-access.integration.ts +325 -0
- package/src/__tests__/error-contract.integration.ts +435 -0
- package/src/__tests__/field-access.integration.ts +269 -0
- package/src/__tests__/full-stack.integration.ts +914 -0
- package/src/__tests__/ownership.integration.ts +449 -0
- package/src/__tests__/reference-data.integration.ts +198 -0
- package/src/__tests__/transition-guard.integration.ts +340 -0
- package/src/api/__tests__/api.test.ts +337 -0
- package/src/api/__tests__/auth-middleware-transport.test.ts +80 -0
- package/src/api/__tests__/auth-routes-cookie.test.ts +179 -0
- package/src/api/__tests__/batch.integration.ts +404 -0
- package/src/api/__tests__/body-limit.test.ts +88 -0
- package/src/api/__tests__/csrf-middleware.test.ts +97 -0
- package/src/api/__tests__/dispatcher-live.integration.ts +216 -0
- package/src/api/__tests__/metrics-endpoint.test.ts +126 -0
- package/src/api/__tests__/nested-write.integration.ts +213 -0
- package/src/api/__tests__/readiness.test.ts +76 -0
- package/src/api/__tests__/request-id-middleware.test.ts +72 -0
- package/src/api/__tests__/sse-broker.test.ts +58 -0
- package/src/api/__tests__/sse-route.test.ts +112 -0
- package/src/api/anonymous-cookie.ts +60 -0
- package/src/api/api-constants.ts +64 -0
- package/src/api/auth-middleware.ts +418 -0
- package/src/api/auth-routes.ts +982 -0
- package/src/api/csrf-middleware.ts +77 -0
- package/src/api/index.ts +31 -0
- package/src/api/jwt.ts +66 -0
- package/src/api/observability-middleware.ts +89 -0
- package/src/api/readiness.ts +132 -0
- package/src/api/request-context.ts +49 -0
- package/src/api/request-id-middleware.ts +50 -0
- package/src/api/route-registrars.ts +195 -0
- package/src/api/routes.ts +135 -0
- package/src/api/server.ts +640 -0
- package/src/api/sse-broker.ts +71 -0
- package/src/api/sse-route.ts +62 -0
- package/src/api/tokens.ts +16 -0
- package/src/db/__tests__/apply-entity-event-tenant.integration.ts +159 -0
- package/src/db/__tests__/compound-types.test.ts +114 -0
- package/src/db/__tests__/connection-options.test.ts +68 -0
- package/src/db/__tests__/cursor.test.ts +41 -0
- package/src/db/__tests__/db-helpers.test.ts +369 -0
- package/src/db/__tests__/dialect-instant.test.ts +50 -0
- package/src/db/__tests__/drizzle-helpers.integration.ts +186 -0
- package/src/db/__tests__/drizzle-table-types.test.ts +162 -0
- package/src/db/__tests__/encryption.test.ts +39 -0
- package/src/db/__tests__/event-store-executor-list.integration.ts +313 -0
- package/src/db/__tests__/event-store-executor.integration.ts +235 -0
- package/src/db/__tests__/implicit-projection-equivalence.integration.ts +304 -0
- package/src/db/__tests__/located-timestamp.test.ts +184 -0
- package/src/db/__tests__/money.test.ts +199 -0
- package/src/db/__tests__/multi-row-insert.integration.ts +76 -0
- package/src/db/__tests__/parse-auto-verb.test.ts +70 -0
- package/src/db/__tests__/required-not-null-migration-safety.integration.ts +105 -0
- package/src/db/__tests__/row-helpers.test.ts +59 -0
- package/src/db/__tests__/schema-migration.integration.ts +273 -0
- package/src/db/__tests__/table-builder-indexes.test.ts +153 -0
- package/src/db/__tests__/table-builder-required.test.ts +216 -0
- package/src/db/__tests__/tenant-db.integration.ts +606 -0
- package/src/db/__tests__/unique-violation-mapping.integration.ts +166 -0
- package/src/db/apply-entity-event.ts +188 -0
- package/src/db/assert-exists-in.ts +59 -0
- package/src/db/compound-types.ts +47 -0
- package/src/db/connection.ts +104 -0
- package/src/db/cursor.ts +83 -0
- package/src/db/dialect.ts +109 -0
- package/src/db/eagerload.ts +174 -0
- package/src/db/encryption.ts +39 -0
- package/src/db/event-store-executor.ts +906 -0
- package/src/db/index.ts +55 -0
- package/src/db/located-timestamp.ts +114 -0
- package/src/db/money.ts +120 -0
- package/src/db/pg-error.ts +46 -0
- package/src/db/reference-data.ts +77 -0
- package/src/db/row-helpers.ts +53 -0
- package/src/db/schema-inspection.ts +25 -0
- package/src/db/table-builder.ts +475 -0
- package/src/db/tenant-db.ts +434 -0
- package/src/engine/__tests__/auth-claims-registrar.test.ts +74 -0
- package/src/engine/__tests__/boot-validator-located-timestamps.test.ts +108 -0
- package/src/engine/__tests__/boot-validator.test.ts +1865 -0
- package/src/engine/__tests__/build-app-schema.test.ts +154 -0
- package/src/engine/__tests__/claim-keys.test.ts +274 -0
- package/src/engine/__tests__/config-helpers.test.ts +236 -0
- package/src/engine/__tests__/effective-features.test.ts +86 -0
- package/src/engine/__tests__/engine.test.ts +1461 -0
- package/src/engine/__tests__/entity-handlers.test.ts +274 -0
- package/src/engine/__tests__/event-helpers.test.ts +68 -0
- package/src/engine/__tests__/extends-registrar.test.ts +159 -0
- package/src/engine/__tests__/factories-long-text.test.ts +84 -0
- package/src/engine/__tests__/factories-time.test.ts +158 -0
- package/src/engine/__tests__/field-predicates.test.ts +48 -0
- package/src/engine/__tests__/hook-phases.test.ts +132 -0
- package/src/engine/__tests__/identifiers.test.ts +35 -0
- package/src/engine/__tests__/lifecycle-hooks.test.ts +237 -0
- package/src/engine/__tests__/nav.test.ts +267 -0
- package/src/engine/__tests__/ownership.test.ts +421 -0
- package/src/engine/__tests__/parse-ref-target.test.ts +43 -0
- package/src/engine/__tests__/projection-helpers.test.ts +62 -0
- package/src/engine/__tests__/projection.test.ts +191 -0
- package/src/engine/__tests__/qualified-name.test.ts +264 -0
- package/src/engine/__tests__/resolve-config-or-param.test.ts +315 -0
- package/src/engine/__tests__/run-in.test.ts +38 -0
- package/src/engine/__tests__/schema-builder.test.ts +380 -0
- package/src/engine/__tests__/screen.test.ts +408 -0
- package/src/engine/__tests__/state-machine.test.ts +148 -0
- package/src/engine/__tests__/system-user.test.ts +57 -0
- package/src/engine/__tests__/validation-hooks.test.ts +71 -0
- package/src/engine/access.ts +23 -0
- package/src/engine/boot-validator.ts +1528 -0
- package/src/engine/build-app-schema.ts +125 -0
- package/src/engine/config-helpers.ts +115 -0
- package/src/engine/constants.ts +85 -0
- package/src/engine/create-app.ts +98 -0
- package/src/engine/define-feature.ts +702 -0
- package/src/engine/define-handler.ts +78 -0
- package/src/engine/define-roles.ts +19 -0
- package/src/engine/effective-features.ts +87 -0
- package/src/engine/entity-handlers.ts +364 -0
- package/src/engine/event-helpers.ts +73 -0
- package/src/engine/factories.ts +328 -0
- package/src/engine/feature-ast/__tests__/canonical-form.test.ts +416 -0
- package/src/engine/feature-ast/__tests__/parse-happy-path.test.ts +197 -0
- package/src/engine/feature-ast/__tests__/parse-real-features.test.ts +128 -0
- package/src/engine/feature-ast/__tests__/parse.test.ts +888 -0
- package/src/engine/feature-ast/__tests__/patch.test.ts +360 -0
- package/src/engine/feature-ast/__tests__/patcher.test.ts +469 -0
- package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +287 -0
- package/src/engine/feature-ast/extractors.ts +2562 -0
- package/src/engine/feature-ast/index.ts +105 -0
- package/src/engine/feature-ast/parse.ts +369 -0
- package/src/engine/feature-ast/patch.ts +525 -0
- package/src/engine/feature-ast/patcher.ts +518 -0
- package/src/engine/feature-ast/patterns.ts +434 -0
- package/src/engine/feature-ast/render.ts +602 -0
- package/src/engine/feature-ast/source-location.ts +45 -0
- package/src/engine/field-access.ts +120 -0
- package/src/engine/index.ts +254 -0
- package/src/engine/ownership.ts +337 -0
- package/src/engine/parse-ref-target.ts +22 -0
- package/src/engine/pattern-library/__tests__/library.test.ts +351 -0
- package/src/engine/pattern-library/index.ts +24 -0
- package/src/engine/pattern-library/library.ts +1117 -0
- package/src/engine/pattern-library/types.ts +255 -0
- package/src/engine/projection-helpers.ts +85 -0
- package/src/engine/qualified-name.ts +122 -0
- package/src/engine/read-claim.ts +31 -0
- package/src/engine/registry.ts +1325 -0
- package/src/engine/resolve-config-or-param.ts +153 -0
- package/src/engine/run-in.ts +29 -0
- package/src/engine/schema-builder.ts +175 -0
- package/src/engine/screen-filter-ops.ts +51 -0
- package/src/engine/state-machine.ts +70 -0
- package/src/engine/system-user.ts +32 -0
- package/src/engine/types/config.ts +306 -0
- package/src/engine/types/event-type-map.ts +37 -0
- package/src/engine/types/feature.ts +574 -0
- package/src/engine/types/fields.ts +422 -0
- package/src/engine/types/handlers.ts +742 -0
- package/src/engine/types/hooks.ts +142 -0
- package/src/engine/types/http-route.ts +54 -0
- package/src/engine/types/identifiers.ts +47 -0
- package/src/engine/types/index.ts +208 -0
- package/src/engine/types/nav.ts +46 -0
- package/src/engine/types/projection.ts +132 -0
- package/src/engine/types/relations.ts +51 -0
- package/src/engine/types/screen.ts +452 -0
- package/src/engine/types/workspace.ts +42 -0
- package/src/engine/validation.ts +33 -0
- package/src/entrypoint/__tests__/entrypoint-job-wiring.integration.ts +173 -0
- package/src/entrypoint/__tests__/split-deploy.integration.ts +297 -0
- package/src/entrypoint/index.ts +442 -0
- package/src/errors/__tests__/classes.test.ts +371 -0
- package/src/errors/__tests__/write-failures.test.ts +109 -0
- package/src/errors/classes.ts +249 -0
- package/src/errors/i18n/de.yaml +83 -0
- package/src/errors/i18n/en.yaml +80 -0
- package/src/errors/index.ts +41 -0
- package/src/errors/kumiko-error.ts +67 -0
- package/src/errors/reasons.ts +36 -0
- package/src/errors/serialize.ts +136 -0
- package/src/errors/transition-details.ts +30 -0
- package/src/errors/write-error-info.ts +123 -0
- package/src/errors/zod-bridge.ts +49 -0
- package/src/event-store/__tests__/admin-api.integration.ts +361 -0
- package/src/event-store/__tests__/event-store.integration.ts +584 -0
- package/src/event-store/__tests__/get-stream-version-perf.integration.ts +83 -0
- package/src/event-store/__tests__/perf.integration.ts +255 -0
- package/src/event-store/__tests__/snapshot.integration.ts +267 -0
- package/src/event-store/__tests__/upcaster-dead-letter.integration.ts +204 -0
- package/src/event-store/__tests__/upcaster.integration.ts +460 -0
- package/src/event-store/admin-api.ts +257 -0
- package/src/event-store/archive.ts +106 -0
- package/src/event-store/errors.ts +35 -0
- package/src/event-store/event-store.ts +405 -0
- package/src/event-store/events-schema.ts +90 -0
- package/src/event-store/index.ts +50 -0
- package/src/event-store/snapshot.ts +210 -0
- package/src/event-store/upcaster-dead-letter.ts +119 -0
- package/src/event-store/upcaster.ts +147 -0
- package/src/files/__tests__/content-disposition.test.ts +123 -0
- package/src/files/__tests__/file-field-column.integration.ts +103 -0
- package/src/files/__tests__/file-field-pipeline.integration.ts +211 -0
- package/src/files/__tests__/file-handle.test.ts +122 -0
- package/src/files/__tests__/files.integration.ts +830 -0
- package/src/files/__tests__/storage-tracking.integration.ts +153 -0
- package/src/files/content-disposition.ts +55 -0
- package/src/files/file-handle.ts +63 -0
- package/src/files/file-ref-table.ts +22 -0
- package/src/files/file-routes.ts +353 -0
- package/src/files/in-memory-provider.ts +62 -0
- package/src/files/index.ts +29 -0
- package/src/files/local-provider.ts +35 -0
- package/src/files/storage-tracking.ts +60 -0
- package/src/files/types.ts +118 -0
- package/src/i18n/__tests__/i18n.test.ts +72 -0
- package/src/i18n/index.ts +29 -0
- package/src/jobs/__tests__/job-event-trigger.integration.ts +172 -0
- package/src/jobs/__tests__/job-multi-trigger.integration.ts +144 -0
- package/src/jobs/__tests__/jobs.integration.ts +566 -0
- package/src/jobs/index.ts +2 -0
- package/src/jobs/job-runner.ts +574 -0
- package/src/lifecycle/__tests__/create-test-lifecycle.ts +19 -0
- package/src/lifecycle/__tests__/lifecycle-server.integration.ts +108 -0
- package/src/lifecycle/__tests__/lifecycle.test.ts +212 -0
- package/src/lifecycle/__tests__/signal-handlers.test.ts +106 -0
- package/src/lifecycle/index.ts +13 -0
- package/src/lifecycle/lifecycle.ts +160 -0
- package/src/lifecycle/signal-handlers.ts +62 -0
- package/src/logging/__tests__/pino-trace-bridge.test.ts +50 -0
- package/src/logging/index.ts +3 -0
- package/src/logging/pino-logger.ts +64 -0
- package/src/logging/types.ts +7 -0
- package/src/migrations/__tests__/compare-snapshots.test.ts +150 -0
- package/src/migrations/__tests__/detect-drift.integration.ts +320 -0
- package/src/migrations/__tests__/detect-projections-to-rebuild.integration.ts +134 -0
- package/src/migrations/__tests__/rebuild-marker.test.ts +79 -0
- package/src/migrations/index.ts +28 -0
- package/src/migrations/projection-detection.ts +149 -0
- package/src/migrations/rebuild-marker.ts +64 -0
- package/src/migrations/schema-drift.ts +395 -0
- package/src/observability/__tests__/console-provider.test.ts +67 -0
- package/src/observability/__tests__/metric-validator.test.ts +87 -0
- package/src/observability/__tests__/noop-provider.test.ts +82 -0
- package/src/observability/__tests__/observability.integration.ts +559 -0
- package/src/observability/__tests__/prometheus-meter.test.ts +144 -0
- package/src/observability/__tests__/recording-meter.test.ts +101 -0
- package/src/observability/__tests__/recording-tracer.test.ts +110 -0
- package/src/observability/__tests__/sensitive-filter.test.ts +98 -0
- package/src/observability/console-provider.ts +130 -0
- package/src/observability/context.ts +26 -0
- package/src/observability/fallback.ts +34 -0
- package/src/observability/ids.ts +25 -0
- package/src/observability/index.ts +79 -0
- package/src/observability/metric-validator.ts +86 -0
- package/src/observability/metrics-handle.ts +56 -0
- package/src/observability/noop-provider.ts +146 -0
- package/src/observability/prometheus-meter.ts +284 -0
- package/src/observability/recording-meter.ts +156 -0
- package/src/observability/recording-tracer.ts +198 -0
- package/src/observability/redis-wrapper.ts +132 -0
- package/src/observability/sensitive-filter.ts +108 -0
- package/src/observability/standard-metrics.ts +213 -0
- package/src/observability/types/index.ts +29 -0
- package/src/observability/types/metric.ts +56 -0
- package/src/observability/types/provider.ts +32 -0
- package/src/observability/types/span.ts +64 -0
- package/src/pipeline/__tests__/archive-stream.integration.ts +220 -0
- package/src/pipeline/__tests__/auth-claims-resolver.test.ts +279 -0
- package/src/pipeline/__tests__/cascade-handler.integration.ts +419 -0
- package/src/pipeline/__tests__/cascade-handler.test.ts +52 -0
- package/src/pipeline/__tests__/causation-chain.integration.ts +206 -0
- package/src/pipeline/__tests__/ctx-bridge.integration.ts +234 -0
- package/src/pipeline/__tests__/dispatcher.test.ts +379 -0
- package/src/pipeline/__tests__/distributed-lock.integration.ts +67 -0
- package/src/pipeline/__tests__/domain-events-projections.integration.ts +323 -0
- package/src/pipeline/__tests__/event-dedup.integration.ts +153 -0
- package/src/pipeline/__tests__/event-define-event-strict.integration.ts +202 -0
- package/src/pipeline/__tests__/event-dispatcher-lifecycle.integration.ts +220 -0
- package/src/pipeline/__tests__/event-dispatcher-multi-instance.integration.ts +423 -0
- package/src/pipeline/__tests__/event-dispatcher-pg-listen.integration.ts +123 -0
- package/src/pipeline/__tests__/event-dispatcher-recovery.integration.ts +202 -0
- package/src/pipeline/__tests__/event-dispatcher-second-audit.integration.ts +290 -0
- package/src/pipeline/__tests__/event-dispatcher-strict.test.ts +65 -0
- package/src/pipeline/__tests__/event-dispatcher.integration.ts +287 -0
- package/src/pipeline/__tests__/event-retention.integration.ts +239 -0
- package/src/pipeline/__tests__/fetch-for-writing.integration.ts +281 -0
- package/src/pipeline/__tests__/lifecycle-pipeline.test.ts +430 -0
- package/src/pipeline/__tests__/load-aggregate-query.integration.ts +266 -0
- package/src/pipeline/__tests__/msp-error-mode.integration.ts +149 -0
- package/src/pipeline/__tests__/msp-multi-hop.integration.ts +228 -0
- package/src/pipeline/__tests__/msp-rebuild.integration.ts +368 -0
- package/src/pipeline/__tests__/multi-stream-projection.integration.ts +341 -0
- package/src/pipeline/__tests__/perf-rebuild.integration.ts +147 -0
- package/src/pipeline/__tests__/projection-rebuild.integration.ts +551 -0
- package/src/pipeline/__tests__/query-projection.integration.ts +201 -0
- package/src/pipeline/__tests__/redis-pipeline.integration.ts +306 -0
- package/src/pipeline/append-event-core.ts +117 -0
- package/src/pipeline/auth-claims-resolver.ts +103 -0
- package/src/pipeline/cascade-handler.ts +113 -0
- package/src/pipeline/dispatcher.ts +1585 -0
- package/src/pipeline/distributed-lock.ts +37 -0
- package/src/pipeline/entity-cache.ts +113 -0
- package/src/pipeline/event-consumer-state.ts +108 -0
- package/src/pipeline/event-dedup.ts +23 -0
- package/src/pipeline/event-dispatcher.ts +1016 -0
- package/src/pipeline/event-retention.ts +154 -0
- package/src/pipeline/idempotency.ts +76 -0
- package/src/pipeline/index.ts +66 -0
- package/src/pipeline/lifecycle-pipeline.ts +409 -0
- package/src/pipeline/msp-rebuild.ts +242 -0
- package/src/pipeline/multi-stream-apply-context.ts +115 -0
- package/src/pipeline/projection-rebuild.ts +334 -0
- package/src/pipeline/projection-state.ts +72 -0
- package/src/pipeline/projections-runner.ts +56 -0
- package/src/pipeline/redis-keys.ts +11 -0
- package/src/pipeline/system-hooks.ts +190 -0
- package/src/random/__tests__/generate.test.ts +149 -0
- package/src/random/generate.ts +141 -0
- package/src/random/index.ts +8 -0
- package/src/random/words.ts +392 -0
- package/src/rate-limit/__tests__/dispatcher-l3.integration.ts +111 -0
- package/src/rate-limit/__tests__/middleware.integration.ts +189 -0
- package/src/rate-limit/__tests__/resolver.integration.ts +189 -0
- package/src/rate-limit/bucket.ts +36 -0
- package/src/rate-limit/index.ts +14 -0
- package/src/rate-limit/middleware.ts +152 -0
- package/src/rate-limit/resolver.ts +267 -0
- package/src/redis/__tests__/redis-options.test.ts +54 -0
- package/src/redis/index.ts +74 -0
- package/src/search/__tests__/meilisearch-adapter.integration.ts +236 -0
- package/src/search/__tests__/search-adapter.test.ts +256 -0
- package/src/search/in-memory-adapter.ts +123 -0
- package/src/search/index.ts +12 -0
- package/src/search/meilisearch-adapter.ts +106 -0
- package/src/search/types.ts +39 -0
- package/src/secrets/__tests__/dek-cache.test.ts +213 -0
- package/src/secrets/__tests__/env-master-key-provider.test.ts +119 -0
- package/src/secrets/__tests__/envelope.test.ts +74 -0
- package/src/secrets/__tests__/leak-guard.test.ts +92 -0
- package/src/secrets/__tests__/rotation.test.ts +149 -0
- package/src/secrets/dek-cache.ts +116 -0
- package/src/secrets/env-master-key-provider.ts +162 -0
- package/src/secrets/envelope.ts +55 -0
- package/src/secrets/index.ts +19 -0
- package/src/secrets/leak-guard.ts +87 -0
- package/src/secrets/rotation.ts +34 -0
- package/src/secrets/types.ts +107 -0
- package/src/stack/db.ts +104 -0
- package/src/stack/event-collector.ts +23 -0
- package/src/stack/index.ts +32 -0
- package/src/stack/redis.ts +44 -0
- package/src/stack/request-helper.ts +168 -0
- package/src/stack/table-helpers.ts +104 -0
- package/src/stack/test-stack.ts +357 -0
- package/src/stack/test-users.ts +37 -0
- package/src/testing/__tests__/e2e-generator.test.ts +230 -0
- package/src/testing/__tests__/ensure-entity-table.integration.ts +54 -0
- package/src/testing/access-assertions.ts +15 -0
- package/src/testing/assertions.ts +35 -0
- package/src/testing/e2e-generator.ts +465 -0
- package/src/testing/expect-error.ts +25 -0
- package/src/testing/handler-context.ts +125 -0
- package/src/testing/http-cookies.ts +52 -0
- package/src/testing/index.ts +41 -0
- package/src/testing/late-bound.ts +39 -0
- package/src/testing/mutable-master-key-provider.ts +31 -0
- package/src/testing/observability-recorder.ts +54 -0
- package/src/testing/shared-entities.ts +49 -0
- package/src/testing/utils.ts +1 -0
- package/src/testing/wait-for.ts +31 -0
- package/src/time/__tests__/polyfill.test.ts +73 -0
- package/src/time/__tests__/tz-context.test.ts +121 -0
- package/src/time/index.ts +21 -0
- package/src/time/polyfill.ts +70 -0
- package/src/time/tz-context.ts +107 -0
- package/src/ui-types/app-schema.ts +57 -0
- package/src/ui-types/index.ts +65 -0
- package/src/utils/__tests__/assert.test.ts +17 -0
- package/src/utils/__tests__/env-parse.test.ts +54 -0
- package/src/utils/assert.ts +18 -0
- package/src/utils/env-parse.ts +16 -0
- package/src/utils/ids.ts +16 -0
- package/src/utils/index.ts +5 -0
- package/src/utils/safe-json.ts +30 -0
- package/src/utils/serialization.ts +7 -0
package/README.md
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# @cosmicdrift/kumiko-framework
|
|
2
|
+
|
|
3
|
+
[](../../LICENSE)
|
|
4
|
+
[](https://www.typescriptlang.org/)
|
|
5
|
+
|
|
6
|
+
Framework core for Kumiko — engine, pipeline, API, DB, event-store, and
|
|
7
|
+
every other bit that makes Kumiko go.
|
|
8
|
+
|
|
9
|
+
> Multi-tenant, command-based, event-sourced app framework for Bun + Hono +
|
|
10
|
+
> Drizzle. Define features, register entities, write commands — the framework
|
|
11
|
+
> wires dispatch, persistence, projections, async subscribers, and realtime
|
|
12
|
+
> delivery.
|
|
13
|
+
|
|
14
|
+
See the [monorepo root README](../../README.md) for the broader pitch, the
|
|
15
|
+
[docs/plans](../../docs/plans) directory for architecture, and [samples/](../../samples)
|
|
16
|
+
for runnable examples of every feature.
|
|
17
|
+
|
|
18
|
+
## Install
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
yarn add @cosmicdrift/kumiko-framework
|
|
22
|
+
# peers you probably already have:
|
|
23
|
+
yarn add drizzle-orm hono ioredis zod
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Bun is the intended runtime. Node 20+ works for the CLI and tests.
|
|
27
|
+
|
|
28
|
+
## At-a-glance
|
|
29
|
+
|
|
30
|
+
```typescript
|
|
31
|
+
import { defineFeature, createEntity, createTextField } from "@cosmicdrift/kumiko-framework/engine";
|
|
32
|
+
|
|
33
|
+
export const taskEntity = createEntity({
|
|
34
|
+
fields: {
|
|
35
|
+
title: createTextField({ required: true, searchable: true }),
|
|
36
|
+
done: createTextField(),
|
|
37
|
+
},
|
|
38
|
+
softDelete: true,
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const taskTable = buildDrizzleTable("task", taskEntity);
|
|
42
|
+
const taskExecutor = createEventStoreExecutor(taskTable, taskEntity, { entityName: "task" });
|
|
43
|
+
|
|
44
|
+
export const taskFeature = defineFeature("tasks", (r) => {
|
|
45
|
+
r.entity("task", taskEntity);
|
|
46
|
+
|
|
47
|
+
// Write handlers go through createEventStoreExecutor — events + projection in one TX,
|
|
48
|
+
// optimistic locking, access control, all explicit.
|
|
49
|
+
r.writeHandler(
|
|
50
|
+
"task:create",
|
|
51
|
+
z.object({ title: z.string() }),
|
|
52
|
+
async (event, ctx) => taskExecutor.create(event.payload, event.user, ctx.db),
|
|
53
|
+
{ access: { roles: ["User"] } },
|
|
54
|
+
);
|
|
55
|
+
r.writeHandler(
|
|
56
|
+
"task:update",
|
|
57
|
+
z.object({ id: z.uuid(), version: z.number(), changes: z.object({ title: z.string().optional() }) }),
|
|
58
|
+
async (event, ctx) => taskExecutor.update(event.payload, event.user, ctx.db),
|
|
59
|
+
{ access: { roles: ["User"] } },
|
|
60
|
+
);
|
|
61
|
+
r.writeHandler(
|
|
62
|
+
"task:delete",
|
|
63
|
+
z.object({ id: z.uuid() }),
|
|
64
|
+
async (event, ctx) => taskExecutor.delete(event.payload, event.user, ctx.db),
|
|
65
|
+
{ access: { roles: ["Admin"] } },
|
|
66
|
+
);
|
|
67
|
+
r.queryHandler(
|
|
68
|
+
"task:list",
|
|
69
|
+
z.object({}),
|
|
70
|
+
async (query, ctx) => taskExecutor.list(query.payload, query.user, ctx.db),
|
|
71
|
+
{ access: { openToAll: true } },
|
|
72
|
+
);
|
|
73
|
+
r.queryHandler(
|
|
74
|
+
"task:detail",
|
|
75
|
+
z.object({ id: z.uuid() }),
|
|
76
|
+
async (query, ctx) => taskExecutor.detail(query.payload, query.user, ctx.db),
|
|
77
|
+
{ access: { openToAll: true } },
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
// Read-model fed from task events, rebuildable via the CLI
|
|
81
|
+
r.projection({
|
|
82
|
+
name: "tasks-per-day",
|
|
83
|
+
source: "task",
|
|
84
|
+
table: tasksPerDayTable,
|
|
85
|
+
apply: {
|
|
86
|
+
"task.created": async (event, tx) => {
|
|
87
|
+
/* count++ */
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// Async consumer — runs after commit via the event-dispatcher,
|
|
93
|
+
// cursor-based, at-least-once, per-consumer dead-letter semantics.
|
|
94
|
+
// Omit `table` for pure side-effect handlers (mail, webhooks, ...).
|
|
95
|
+
r.multiStreamProjection({
|
|
96
|
+
name: "notify-new-task",
|
|
97
|
+
apply: {
|
|
98
|
+
"task.created": async (event) => {
|
|
99
|
+
// e.g. push to an external notification service
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## Package exports
|
|
107
|
+
|
|
108
|
+
| Entry | What's in it |
|
|
109
|
+
|---|---|
|
|
110
|
+
| `@cosmicdrift/kumiko-framework/engine` | `defineFeature`, `createEntity`, field helpers, access rules, registry |
|
|
111
|
+
| `@cosmicdrift/kumiko-framework/db` | Drizzle re-exports, `createEventStoreExecutor`, table builders, tenant-db |
|
|
112
|
+
| `@cosmicdrift/kumiko-framework/event-store` | `events` table, `append`, `loadAggregate`, `loadAggregateAsOf` |
|
|
113
|
+
| `@cosmicdrift/kumiko-framework/pipeline` | Dispatcher, event-dispatcher (AsyncDaemon), projection-rebuild, SSE + search consumers |
|
|
114
|
+
| `@cosmicdrift/kumiko-framework/api` | `buildServer`, auth middleware, SSE route, error contract |
|
|
115
|
+
| `@cosmicdrift/kumiko-framework/auth` | JWT helper, password hashing, session users |
|
|
116
|
+
| `@cosmicdrift/kumiko-framework/search` | `SearchAdapter` interface, in-memory adapter, Meili wrapper |
|
|
117
|
+
| `@cosmicdrift/kumiko-framework/jobs` | BullMQ-backed job runner, cron scheduling |
|
|
118
|
+
| `@cosmicdrift/kumiko-framework/files` | Signed-URL upload/download, tenant-scoped storage |
|
|
119
|
+
| `@cosmicdrift/kumiko-framework/i18n` | i18next setup, per-feature translation registration |
|
|
120
|
+
| `@cosmicdrift/kumiko-framework/ui` | React hooks (Zustand stores, SSE subscription, optimistic mutations) |
|
|
121
|
+
| `@cosmicdrift/kumiko-framework/testing` | `setupTestStack`, `createTestDb`, request helpers |
|
|
122
|
+
| `@cosmicdrift/kumiko-framework/utils` | Safe JSON, qualified-name helpers |
|
|
123
|
+
| `@cosmicdrift/kumiko-framework/errors` | Error classes, `writeFailure`, reason contracts |
|
|
124
|
+
|
|
125
|
+
## Core concepts
|
|
126
|
+
|
|
127
|
+
- **Feature as unit of deployment.** `defineFeature` registers entities,
|
|
128
|
+
write/query handlers, projections, post-event subscribers, lifecycle hooks,
|
|
129
|
+
access rules, and translations.
|
|
130
|
+
- **Commands in, state out.** Writes are commands dispatched through HTTP;
|
|
131
|
+
the dispatcher validates, enforces access, runs lifecycle hooks, persists
|
|
132
|
+
events, and triggers projections in a single TX.
|
|
133
|
+
- **Event-sourced by default.** Every write goes through `createEventStoreExecutor`
|
|
134
|
+
and appends a domain event to the aggregate stream. Auto-generated CRUD
|
|
135
|
+
events (`<entity>.created/updated/deleted/restored`) for record writes,
|
|
136
|
+
explicit `ctx.appendEvent` for domain events with intent. Projections feed
|
|
137
|
+
off the stream for same-TX read-after-write consistency.
|
|
138
|
+
- **Async side-effects via cursor.** SSE broadcast, search indexing, and
|
|
139
|
+
feature-registered `r.multiStreamProjection` consumers run on a single
|
|
140
|
+
cursor-based dispatcher (AsyncDaemon pattern). Per-consumer checkpoints,
|
|
141
|
+
halt-on-poison, dead-letter after N retries.
|
|
142
|
+
- **Multi-tenant scoping.** Every event, entity, projection, and search
|
|
143
|
+
index carries `tenantId`. `TenantDb` is a TX-scoped wrapper that refuses
|
|
144
|
+
writes outside the current tenant.
|
|
145
|
+
- **Optimistic concurrency.** `UNIQUE(aggregate_id, version)` on events
|
|
146
|
+
gives atomic append + conflict detection; `VersionConflictError` surfaces
|
|
147
|
+
races as a first-class value.
|
|
148
|
+
- **Idempotency + dedup.** Request-id backed unique index on the events
|
|
149
|
+
table turns retries into replays. `IdempotencyGuard` caches write
|
|
150
|
+
outcomes per tenant.
|
|
151
|
+
|
|
152
|
+
## Status
|
|
153
|
+
|
|
154
|
+
This framework is pre-1.0 and evolves fast. Every feature has a runnable
|
|
155
|
+
sample under `samples/`; the roadmap lives in [docs/plans/uebersicht.md](../../docs/plans/uebersicht.md).
|
|
156
|
+
|
|
157
|
+
## License
|
|
158
|
+
|
|
159
|
+
BUSL-1.1 — see [LICENSE](../../LICENSE).
|
package/package.json
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
|
|
5
|
+
"license": "BUSL-1.1",
|
|
6
|
+
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/cosmicdriftgamestudio/kumiko-framework.git",
|
|
10
|
+
"directory": "packages/framework"
|
|
11
|
+
},
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/cosmicdriftgamestudio/kumiko-framework/issues"
|
|
14
|
+
},
|
|
15
|
+
"homepage": "https://kumiko.so",
|
|
16
|
+
"keywords": [
|
|
17
|
+
"framework",
|
|
18
|
+
"multi-tenant",
|
|
19
|
+
"realtime",
|
|
20
|
+
"command-based",
|
|
21
|
+
"typescript",
|
|
22
|
+
"hono",
|
|
23
|
+
"drizzle",
|
|
24
|
+
"zod"
|
|
25
|
+
],
|
|
26
|
+
"type": "module",
|
|
27
|
+
"kumiko": {
|
|
28
|
+
"runtime": "runtime"
|
|
29
|
+
},
|
|
30
|
+
"exports": {
|
|
31
|
+
"./engine": "./src/engine/index.ts",
|
|
32
|
+
"./engine/types": "./src/engine/types/index.ts",
|
|
33
|
+
"./errors": "./src/errors/index.ts",
|
|
34
|
+
"./db": "./src/db/index.ts",
|
|
35
|
+
"./event-store": "./src/event-store/index.ts",
|
|
36
|
+
"./event-store/admin-api": "./src/event-store/admin-api.ts",
|
|
37
|
+
"./pipeline": "./src/pipeline/index.ts",
|
|
38
|
+
"./api": "./src/api/index.ts",
|
|
39
|
+
"./i18n": "./src/i18n/index.ts",
|
|
40
|
+
"./auth": "./src/auth/index.ts",
|
|
41
|
+
"./files": "./src/files/index.ts",
|
|
42
|
+
"./jobs": "./src/jobs/index.ts",
|
|
43
|
+
"./migrations": "./src/migrations/index.ts",
|
|
44
|
+
"./entrypoint": "./src/entrypoint/index.ts",
|
|
45
|
+
"./random": "./src/random/index.ts",
|
|
46
|
+
"./redis": "./src/redis/index.ts",
|
|
47
|
+
"./search": "./src/search/index.ts",
|
|
48
|
+
"./search/meilisearch": "./src/search/meilisearch-adapter.ts",
|
|
49
|
+
"./secrets": "./src/secrets/index.ts",
|
|
50
|
+
"./stack": "./src/stack/index.ts",
|
|
51
|
+
"./testing": "./src/testing/index.ts",
|
|
52
|
+
"./testing/handler-context": "./src/testing/handler-context.ts",
|
|
53
|
+
"./testing/e2e-generator": "./src/testing/e2e-generator.ts",
|
|
54
|
+
"./time": "./src/time/index.ts",
|
|
55
|
+
"./ui-types": "./src/ui-types/index.ts",
|
|
56
|
+
"./utils": "./src/utils/index.ts"
|
|
57
|
+
},
|
|
58
|
+
"dependencies": {
|
|
59
|
+
"bullmq": "^5.73.5",
|
|
60
|
+
"bun-types": "^1.3.12",
|
|
61
|
+
"drizzle-kit": "^0.31.10",
|
|
62
|
+
"drizzle-orm": "^0.45.2",
|
|
63
|
+
"hono": "^4.12.12",
|
|
64
|
+
"i18next": "^26.0.4",
|
|
65
|
+
"ioredis": "^5.6.0",
|
|
66
|
+
"jose": "^6.0.11",
|
|
67
|
+
"meilisearch": "^0.57.0",
|
|
68
|
+
"pino": "^10.3.1",
|
|
69
|
+
"postgres": "^3.4.9",
|
|
70
|
+
"temporal-polyfill": "^0.3.2",
|
|
71
|
+
"ts-morph": "^28.0.0",
|
|
72
|
+
"uuid": "^14.0.0",
|
|
73
|
+
"zod": "^4.3.6"
|
|
74
|
+
},
|
|
75
|
+
"devDependencies": {
|
|
76
|
+
"@cosmicdrift/kumiko-dispatcher-live": "workspace:*",
|
|
77
|
+
"@types/uuid": "^11.0.0",
|
|
78
|
+
"bun-types": "^1.2.9",
|
|
79
|
+
"drizzle-kit": "^0.31.0",
|
|
80
|
+
"pino-pretty": "^13.1.3"
|
|
81
|
+
},
|
|
82
|
+
"publishConfig": {
|
|
83
|
+
"registry": "https://registry.npmjs.org",
|
|
84
|
+
"access": "public"
|
|
85
|
+
},
|
|
86
|
+
"files": [
|
|
87
|
+
"src",
|
|
88
|
+
"README.md",
|
|
89
|
+
"LICENSE"
|
|
90
|
+
]
|
|
91
|
+
}
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
// Full-stack proof for anonymousAccess: handlers that allow roles=["anonymous"]
|
|
2
|
+
// must be reachable WITHOUT a JWT, while the rest of /api/* still requires
|
|
3
|
+
// authentication. Covers the resolution chain (defaultTenantId, X-Tenant
|
|
4
|
+
// header, kumiko_tenant cookie, custom resolver) plus the rejection paths
|
|
5
|
+
// (no tenant, unknown tenant, openToAll-protected).
|
|
6
|
+
|
|
7
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "vitest";
|
|
8
|
+
import { z } from "zod";
|
|
9
|
+
import { createEventStoreExecutor } from "../db/event-store-executor";
|
|
10
|
+
import { buildDrizzleTable } from "../db/table-builder";
|
|
11
|
+
import {
|
|
12
|
+
ANONYMOUS_USER_ID,
|
|
13
|
+
createEntity,
|
|
14
|
+
createTextField,
|
|
15
|
+
defineFeature,
|
|
16
|
+
type TenantId,
|
|
17
|
+
} from "../engine";
|
|
18
|
+
import { createEntityTable, setupTestStack, type TestStack, TestUsers } from "../stack";
|
|
19
|
+
|
|
20
|
+
const TENANT_ID = "00000000-0000-4000-8000-000000000001" as TenantId;
|
|
21
|
+
const OTHER_TENANT_ID = "00000000-0000-4000-8000-000000000002" as TenantId;
|
|
22
|
+
|
|
23
|
+
// --- Feature ---
|
|
24
|
+
|
|
25
|
+
const productEntity = createEntity({
|
|
26
|
+
table: "anon_products",
|
|
27
|
+
fields: {
|
|
28
|
+
name: createTextField({ required: true }),
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
const productTable = buildDrizzleTable("product", productEntity);
|
|
32
|
+
|
|
33
|
+
const orderEntity = createEntity({
|
|
34
|
+
table: "anon_orders",
|
|
35
|
+
fields: {
|
|
36
|
+
productName: createTextField({ required: true }),
|
|
37
|
+
placedBy: createTextField({ default: "" }),
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
const orderTable = buildDrizzleTable("order", orderEntity);
|
|
41
|
+
|
|
42
|
+
const shopFeature = defineFeature("anonshop", (r) => {
|
|
43
|
+
r.entity("product", productEntity);
|
|
44
|
+
r.entity("order", orderEntity);
|
|
45
|
+
|
|
46
|
+
// Public listing — anonymous + authenticated customers see it.
|
|
47
|
+
r.queryHandler(
|
|
48
|
+
"product:list",
|
|
49
|
+
z.object({}),
|
|
50
|
+
async (_event, ctx) => {
|
|
51
|
+
const rows = await ctx.db.select().from(productTable);
|
|
52
|
+
return rows;
|
|
53
|
+
},
|
|
54
|
+
{ access: { roles: ["anonymous", "User", "Admin"] } },
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
// Authenticated-only listing — confirms openToAll still rejects anonymous.
|
|
58
|
+
r.queryHandler(
|
|
59
|
+
"product:list-auth-only",
|
|
60
|
+
z.object({}),
|
|
61
|
+
async (_event, ctx) => {
|
|
62
|
+
const rows = await ctx.db.select().from(productTable);
|
|
63
|
+
return rows;
|
|
64
|
+
},
|
|
65
|
+
{ access: { openToAll: true } },
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
// Anonymous can place a guest order.
|
|
69
|
+
r.writeHandler(
|
|
70
|
+
"order:guest-checkout",
|
|
71
|
+
z.object({ productName: z.string().min(1) }),
|
|
72
|
+
async (event, ctx) => {
|
|
73
|
+
const crud = createEventStoreExecutor(orderTable, orderEntity, { entityName: "order" });
|
|
74
|
+
return crud.create(
|
|
75
|
+
{ productName: event.payload.productName, placedBy: event.user.id },
|
|
76
|
+
event.user,
|
|
77
|
+
ctx.db,
|
|
78
|
+
);
|
|
79
|
+
},
|
|
80
|
+
{ access: { roles: ["anonymous", "User"] } },
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
// Admin-only — confirms role-gated handlers still reject anonymous.
|
|
84
|
+
r.writeHandler(
|
|
85
|
+
"product:create",
|
|
86
|
+
z.object({ name: z.string().min(1) }),
|
|
87
|
+
async (event, ctx) => {
|
|
88
|
+
const crud = createEventStoreExecutor(productTable, productEntity, {
|
|
89
|
+
entityName: "product",
|
|
90
|
+
});
|
|
91
|
+
return crud.create({ name: event.payload.name }, event.user, ctx.db);
|
|
92
|
+
},
|
|
93
|
+
{ access: { roles: ["Admin"] } },
|
|
94
|
+
);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// --- Suite ---
|
|
98
|
+
|
|
99
|
+
describe("anonymous access — single-tenant default", () => {
|
|
100
|
+
let stack: TestStack;
|
|
101
|
+
|
|
102
|
+
beforeAll(async () => {
|
|
103
|
+
stack = await setupTestStack({
|
|
104
|
+
features: [shopFeature],
|
|
105
|
+
anonymousAccess: { defaultTenantId: TENANT_ID },
|
|
106
|
+
});
|
|
107
|
+
await createEntityTable(stack.db, productEntity);
|
|
108
|
+
await createEntityTable(stack.db, orderEntity);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
afterAll(() => stack.cleanup());
|
|
112
|
+
|
|
113
|
+
beforeEach(async () => {
|
|
114
|
+
await stack.db.delete(productTable);
|
|
115
|
+
await stack.db.delete(orderTable);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("anonymous query succeeds without any auth headers", async () => {
|
|
119
|
+
// Seed a product with the admin user so the query has data to return.
|
|
120
|
+
await stack.http.writeOk(
|
|
121
|
+
"anonshop:write:product:create",
|
|
122
|
+
{ name: "Espresso Beans" },
|
|
123
|
+
TestUsers.admin,
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
const res = await stack.http.raw("POST", "/api/query", {
|
|
127
|
+
type: "anonshop:query:product:list",
|
|
128
|
+
payload: {},
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
expect(res.status).toBe(200);
|
|
132
|
+
const body = (await res.json()) as { data: Array<{ name: string }> };
|
|
133
|
+
expect(body.data).toHaveLength(1);
|
|
134
|
+
expect(body.data[0]?.name).toBe("Espresso Beans");
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("anonymous write succeeds and records actor=anonymous", async () => {
|
|
138
|
+
const res = await stack.http.raw("POST", "/api/write", {
|
|
139
|
+
type: "anonshop:write:order:guest-checkout",
|
|
140
|
+
payload: { productName: "Espresso Beans" },
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
expect(res.status).toBe(200);
|
|
144
|
+
const body = (await res.json()) as { isSuccess: boolean };
|
|
145
|
+
expect(body.isSuccess).toBe(true);
|
|
146
|
+
|
|
147
|
+
// Verify the row landed with placedBy=anonymous in the DB. Confirms the
|
|
148
|
+
// synthesised SessionUser actually flows through to the handler.
|
|
149
|
+
const rows = await stack.db.select().from(orderTable);
|
|
150
|
+
expect(rows).toHaveLength(1);
|
|
151
|
+
expect(rows[0]?.["placedBy"]).toBe(ANONYMOUS_USER_ID);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("openToAll handler rejects anonymous (regression guard)", async () => {
|
|
155
|
+
// The advisor-flagged regression: enabling anonymousAccess must NOT
|
|
156
|
+
// silently expose every existing openToAll endpoint. hasAccess refuses
|
|
157
|
+
// anonymous on openToAll, so the dispatcher returns AccessDenied.
|
|
158
|
+
const res = await stack.http.raw("POST", "/api/query", {
|
|
159
|
+
type: "anonshop:query:product:list-auth-only",
|
|
160
|
+
payload: {},
|
|
161
|
+
});
|
|
162
|
+
expect(res.status).toBe(403);
|
|
163
|
+
const body = (await res.json()) as { error: { code: string } };
|
|
164
|
+
expect(body.error.code).toBe("access_denied");
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("role-gated handler still rejects anonymous", async () => {
|
|
168
|
+
const res = await stack.http.raw("POST", "/api/write", {
|
|
169
|
+
type: "anonshop:write:product:create",
|
|
170
|
+
payload: { name: "Tea Set" },
|
|
171
|
+
});
|
|
172
|
+
expect(res.status).toBe(403);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test("authenticated user with JWT bypasses anonymous path entirely", async () => {
|
|
176
|
+
const res = await stack.http.query("anonshop:query:product:list", {}, TestUsers.admin);
|
|
177
|
+
expect(res.status).toBe(200);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("X-Tenant header that disagrees with default → 400 tenant_mismatch", async () => {
|
|
181
|
+
// Single-tenant mode is locked: a client cannot override defaultTenantId
|
|
182
|
+
// by sending a different X-Tenant. Silent acceptance would let a
|
|
183
|
+
// confused client write into the wrong tenant of a single-tenant
|
|
184
|
+
// deployment that happened to have data for OTHER_TENANT_ID.
|
|
185
|
+
const res = await stack.http.raw(
|
|
186
|
+
"POST",
|
|
187
|
+
"/api/query",
|
|
188
|
+
{ type: "anonshop:query:product:list", payload: {} },
|
|
189
|
+
{ "X-Tenant": OTHER_TENANT_ID },
|
|
190
|
+
);
|
|
191
|
+
expect(res.status).toBe(400);
|
|
192
|
+
const body = (await res.json()) as { error: { code: string } };
|
|
193
|
+
expect(body.error.code).toBe("tenant_mismatch");
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test("X-Tenant header matching default → accepted", async () => {
|
|
197
|
+
// Same default, redundant header — fine.
|
|
198
|
+
const res = await stack.http.raw(
|
|
199
|
+
"POST",
|
|
200
|
+
"/api/query",
|
|
201
|
+
{ type: "anonshop:query:product:list", payload: {} },
|
|
202
|
+
{ "X-Tenant": TENANT_ID },
|
|
203
|
+
);
|
|
204
|
+
expect(res.status).toBe(200);
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
describe("anonymous access — header-supplied tenant", () => {
|
|
209
|
+
let stack: TestStack;
|
|
210
|
+
|
|
211
|
+
beforeAll(async () => {
|
|
212
|
+
stack = await setupTestStack({
|
|
213
|
+
features: [shopFeature],
|
|
214
|
+
anonymousAccess: {
|
|
215
|
+
// No defaultTenantId — every anonymous request must declare its tenant.
|
|
216
|
+
tenantExists: async (id: TenantId) => id === TENANT_ID || id === OTHER_TENANT_ID,
|
|
217
|
+
},
|
|
218
|
+
});
|
|
219
|
+
await createEntityTable(stack.db, productEntity);
|
|
220
|
+
await createEntityTable(stack.db, orderEntity);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
afterAll(() => stack.cleanup());
|
|
224
|
+
|
|
225
|
+
test("X-Tenant header resolves the tenant", async () => {
|
|
226
|
+
const res = await stack.http.raw(
|
|
227
|
+
"POST",
|
|
228
|
+
"/api/query",
|
|
229
|
+
{ type: "anonshop:query:product:list", payload: {} },
|
|
230
|
+
{ "X-Tenant": TENANT_ID },
|
|
231
|
+
);
|
|
232
|
+
expect(res.status).toBe(200);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test("malformed X-Tenant header → 400 invalid_tenant_format", async () => {
|
|
236
|
+
// Junk strings (SQL fragments, path traversals, plain typos) must never
|
|
237
|
+
// reach the pipeline as a TenantId. parseTenantId rejects anything that
|
|
238
|
+
// isn't a UUID-shape, the middleware turns that into a 400 here.
|
|
239
|
+
const res = await stack.http.raw(
|
|
240
|
+
"POST",
|
|
241
|
+
"/api/query",
|
|
242
|
+
{ type: "anonshop:query:product:list", payload: {} },
|
|
243
|
+
{ "X-Tenant": "not-a-uuid" },
|
|
244
|
+
);
|
|
245
|
+
expect(res.status).toBe(400);
|
|
246
|
+
const body = (await res.json()) as {
|
|
247
|
+
error: { code: string; details: { source: string } };
|
|
248
|
+
};
|
|
249
|
+
expect(body.error.code).toBe("invalid_tenant_format");
|
|
250
|
+
expect(body.error.details.source).toBe("X-Tenant header");
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
test("missing tenant → 400 tenant_required", async () => {
|
|
254
|
+
const res = await stack.http.raw("POST", "/api/query", {
|
|
255
|
+
type: "anonshop:query:product:list",
|
|
256
|
+
payload: {},
|
|
257
|
+
});
|
|
258
|
+
expect(res.status).toBe(400);
|
|
259
|
+
const body = (await res.json()) as { error: { code: string; i18nKey: string } };
|
|
260
|
+
expect(body.error.code).toBe("tenant_required");
|
|
261
|
+
expect(body.error.i18nKey).toBe("auth.errors.tenantRequired");
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test("/api/auth/* without JWT → 401 missing_token (not 400 tenant_required)", async () => {
|
|
265
|
+
// Auth-routes (tenants, switch-tenant, logout) brauchen einen JWT,
|
|
266
|
+
// aber keinen Tenant-Resolve. Vor dem Fix fielen sie in handleAnonymous,
|
|
267
|
+
// das beim resolveTenant ohne X-Tenant 400 tenant_required wirft —
|
|
268
|
+
// falsche Diagnose, der Caller ist unauthenticated, nicht ohne Tenant.
|
|
269
|
+
// Login bleibt davon unberührt (in PUBLIC_API_PATHS, skipped vor auth).
|
|
270
|
+
const res = await stack.http.raw("GET", "/api/auth/tenants");
|
|
271
|
+
expect(res.status).toBe(401);
|
|
272
|
+
const body = (await res.json()) as { error: { code: string; i18nKey: string } };
|
|
273
|
+
expect(body.error.code).toBe("missing_token");
|
|
274
|
+
expect(body.error.i18nKey).toBe("auth.errors.missingToken");
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
test("authenticated request with conflicting X-Tenant header → 400 tenant_mismatch", async () => {
|
|
278
|
+
// JWT carries tenantId=TENANT_ID, but the client sends X-Tenant for a
|
|
279
|
+
// different tenant. Silent ignore would let the client think it's
|
|
280
|
+
// hitting OTHER_TENANT_ID while it's actually on TENANT_ID — defensive
|
|
281
|
+
// reject, same shape as ambiguous_auth.
|
|
282
|
+
const token = await stack.jwt.sign(TestUsers.admin);
|
|
283
|
+
const res = await stack.http.raw(
|
|
284
|
+
"POST",
|
|
285
|
+
"/api/query",
|
|
286
|
+
{ type: "anonshop:query:product:list", payload: {} },
|
|
287
|
+
{ Authorization: `Bearer ${token}`, "X-Tenant": OTHER_TENANT_ID },
|
|
288
|
+
);
|
|
289
|
+
expect(res.status).toBe(400);
|
|
290
|
+
const body = (await res.json()) as { error: { code: string } };
|
|
291
|
+
expect(body.error.code).toBe("tenant_mismatch");
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
test("unknown tenant → 404 tenant_not_found", async () => {
|
|
295
|
+
const res = await stack.http.raw(
|
|
296
|
+
"POST",
|
|
297
|
+
"/api/query",
|
|
298
|
+
{ type: "anonshop:query:product:list", payload: {} },
|
|
299
|
+
{ "X-Tenant": "00000000-0000-4000-8000-deadbeefdead" },
|
|
300
|
+
);
|
|
301
|
+
expect(res.status).toBe(404);
|
|
302
|
+
const body = (await res.json()) as { error: { code: string } };
|
|
303
|
+
expect(body.error.code).toBe("tenant_not_found");
|
|
304
|
+
});
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
describe("anonymous access — disabled by default", () => {
|
|
308
|
+
let stack: TestStack;
|
|
309
|
+
|
|
310
|
+
beforeAll(async () => {
|
|
311
|
+
stack = await setupTestStack({ features: [shopFeature] });
|
|
312
|
+
await createEntityTable(stack.db, productEntity);
|
|
313
|
+
await createEntityTable(stack.db, orderEntity);
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
afterAll(() => stack.cleanup());
|
|
317
|
+
|
|
318
|
+
test("missing JWT → 401 (no anonymous fall-through)", async () => {
|
|
319
|
+
const res = await stack.http.raw("POST", "/api/query", {
|
|
320
|
+
type: "anonshop:query:product:list",
|
|
321
|
+
payload: {},
|
|
322
|
+
});
|
|
323
|
+
expect(res.status).toBe(401);
|
|
324
|
+
});
|
|
325
|
+
});
|