@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
|
@@ -0,0 +1,640 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import type { DbConnection, PgClient } from "../db/connection";
|
|
3
|
+
import { createTenantDb } from "../db/tenant-db";
|
|
4
|
+
import { runsInLane } from "../engine/run-in";
|
|
5
|
+
import {
|
|
6
|
+
type AppContext,
|
|
7
|
+
isFileField,
|
|
8
|
+
type Registry,
|
|
9
|
+
type RunIn,
|
|
10
|
+
SYSTEM_TENANT_ID,
|
|
11
|
+
} from "../engine/types";
|
|
12
|
+
import { createFileContext } from "../files/file-handle";
|
|
13
|
+
import type { FileRoutesOptions } from "../files/file-routes";
|
|
14
|
+
import { createFileRoutes } from "../files/file-routes";
|
|
15
|
+
import type { Lifecycle } from "../lifecycle";
|
|
16
|
+
import {
|
|
17
|
+
createNoopProvider,
|
|
18
|
+
DEFAULT_SENSITIVE_CONFIG,
|
|
19
|
+
mergeSensitiveConfig,
|
|
20
|
+
type ObservabilityOptions,
|
|
21
|
+
type ObservabilityProvider,
|
|
22
|
+
registerStandardMetrics,
|
|
23
|
+
wrapRedisClient,
|
|
24
|
+
} from "../observability";
|
|
25
|
+
import type { DispatcherOptions } from "../pipeline/dispatcher";
|
|
26
|
+
import { createDispatcher } from "../pipeline/dispatcher";
|
|
27
|
+
import { SHARED_INSTANCE_SENTINEL } from "../pipeline/event-consumer-state";
|
|
28
|
+
import type { EventDedup } from "../pipeline/event-dedup";
|
|
29
|
+
import type { EventConsumer, EventDispatcher } from "../pipeline/event-dispatcher";
|
|
30
|
+
import { createEventDispatcher } from "../pipeline/event-dispatcher";
|
|
31
|
+
import { createLifecycleHooks, type SystemHooks } from "../pipeline/lifecycle-pipeline";
|
|
32
|
+
import { createMultiStreamApplyContext } from "../pipeline/multi-stream-apply-context";
|
|
33
|
+
import {
|
|
34
|
+
createSearchEventConsumer,
|
|
35
|
+
createSseBroadcastEventConsumer,
|
|
36
|
+
} from "../pipeline/system-hooks";
|
|
37
|
+
import {
|
|
38
|
+
type AuthEndpointRateLimitOptions,
|
|
39
|
+
authEndpointRateLimit,
|
|
40
|
+
createRateLimitResolver,
|
|
41
|
+
type GlobalIpRateLimitOptions,
|
|
42
|
+
globalIpRateLimit,
|
|
43
|
+
} from "../rate-limit";
|
|
44
|
+
import type { SearchAdapter } from "../search/types";
|
|
45
|
+
import { generateId } from "../utils";
|
|
46
|
+
import { PUBLIC_API_PATHS } from "./api-constants";
|
|
47
|
+
import { type AnonymousAccessConfig, authMiddleware } from "./auth-middleware";
|
|
48
|
+
import { type AuthRoutesConfig, createAuthRoutes } from "./auth-routes";
|
|
49
|
+
import { csrfMiddleware } from "./csrf-middleware";
|
|
50
|
+
import { createJwtHelper, type JwtHelper } from "./jwt";
|
|
51
|
+
import { observabilityMiddleware } from "./observability-middleware";
|
|
52
|
+
import { requestIdMiddleware } from "./request-id-middleware";
|
|
53
|
+
import {
|
|
54
|
+
DEFAULT_MAX_REQUEST_BYTES,
|
|
55
|
+
registerBodyLimit,
|
|
56
|
+
registerHealthRoutes,
|
|
57
|
+
registerMetricsRoute,
|
|
58
|
+
registerVersionRoute,
|
|
59
|
+
} from "./route-registrars";
|
|
60
|
+
import { createApiRoutes } from "./routes";
|
|
61
|
+
import { createSseBroker, type SseBroker } from "./sse-broker";
|
|
62
|
+
import { createSseRoute } from "./sse-route";
|
|
63
|
+
|
|
64
|
+
export type ServerOptions = {
|
|
65
|
+
registry: Registry;
|
|
66
|
+
context: AppContext;
|
|
67
|
+
jwtSecret: string;
|
|
68
|
+
jwtIssuer?: string;
|
|
69
|
+
dispatcherOptions?: Omit<DispatcherOptions, "lifecycle">;
|
|
70
|
+
systemHooks?: SystemHooks;
|
|
71
|
+
eventDedup?: EventDedup;
|
|
72
|
+
sseBroker?: SseBroker;
|
|
73
|
+
auth?: AuthRoutesConfig;
|
|
74
|
+
files?: Omit<FileRoutesOptions, "db"> & { db?: FileRoutesOptions["db"] };
|
|
75
|
+
// Async event-dispatcher config. The dispatcher is created automatically
|
|
76
|
+
// when (a) context.db is a DbConnection AND (b) at least one consumer is
|
|
77
|
+
// wired — SSE (iff sseBroker), Search (iff context.searchAdapter), or
|
|
78
|
+
// feature-level r.multiStreamProjection consumers.
|
|
79
|
+
//
|
|
80
|
+
// Mirrors the old outboxPoller contract: `KumikoServer.eventDispatcher` is
|
|
81
|
+
// created but NOT auto-started. Production boot must call `.start()`;
|
|
82
|
+
// shutdown must call `.stop()`. Tests prefer `.runOnce()` for determinism
|
|
83
|
+
// and skip `.start()` entirely.
|
|
84
|
+
eventDispatcher?: {
|
|
85
|
+
pollIntervalMs?: number;
|
|
86
|
+
batchSize?: number;
|
|
87
|
+
maxAttempts?: number;
|
|
88
|
+
// Opt out of building the dispatcher even if consumers exist — e.g. ops
|
|
89
|
+
// runs a dedicated dispatcher process, or a test needs to control the
|
|
90
|
+
// consumer lifecycle manually.
|
|
91
|
+
disabled?: boolean;
|
|
92
|
+
// Opt out of the auto-built system consumers (SSE, Search) while still
|
|
93
|
+
// running feature r.multiStreamProjection consumers. Useful for tests
|
|
94
|
+
// that assert only on subscriber behaviour, or for a deployment that
|
|
95
|
+
// routes SSE via a different transport. Default: both enabled when the
|
|
96
|
+
// respective dependency (sseBroker / context.searchAdapter) is available.
|
|
97
|
+
systemConsumers?: { sse?: boolean; search?: boolean };
|
|
98
|
+
// Raw postgres.js client for LISTEN/NOTIFY wake-up (Sprint E.4). When
|
|
99
|
+
// present, `.start()` subscribes to EVENTS_PUBSUB_CHANNEL — delivery
|
|
100
|
+
// latency drops from pollIntervalMs to TCP-round-trip. The poll timer
|
|
101
|
+
// stays on as a safety net. Typically wired from
|
|
102
|
+
// `createDbConnection(url).client` so both Drizzle-queries and the
|
|
103
|
+
// dispatcher share the same underlying postgres.js pool.
|
|
104
|
+
pgClient?: PgClient;
|
|
105
|
+
};
|
|
106
|
+
// Observability: tracer + meter used for auto-instrumentation across
|
|
107
|
+
// HTTP, dispatcher, pipeline, DB. Omitted => NoopProvider (zero overhead,
|
|
108
|
+
// no spans or metrics emitted). Typically set to a ConsoleProvider in dev,
|
|
109
|
+
// OTLPProvider in prod.
|
|
110
|
+
observability?: ObservabilityProvider;
|
|
111
|
+
observabilityOptions?: ObservabilityOptions;
|
|
112
|
+
// L1/L2 rate-limit middleware. Both layers share the auto-wired
|
|
113
|
+
// resolver (or `context.rateLimit` if you provided one). Layers are
|
|
114
|
+
// independent — wire only what you need:
|
|
115
|
+
// - `global`: gates every /api/* request by client IP. Use behind
|
|
116
|
+
// Cloudflare-less deployments to absorb naive floods at the edge
|
|
117
|
+
// of the app process.
|
|
118
|
+
// - `auth`: gates a single path-pattern (default `/api/auth/*`)
|
|
119
|
+
// with tighter limits. Typically `limit: 5, windowSeconds: 60`
|
|
120
|
+
// to slow brute-force without breaking real users.
|
|
121
|
+
// Both omitted → no L1/L2 wired and no resolver auto-built unless an
|
|
122
|
+
// L3 handler declared `rateLimit:`. This keeps zero-cost when unused.
|
|
123
|
+
rateLimit?: {
|
|
124
|
+
readonly global?: Omit<GlobalIpRateLimitOptions, "resolver">;
|
|
125
|
+
readonly auth?: Omit<AuthEndpointRateLimitOptions, "resolver"> & {
|
|
126
|
+
// Path-pattern the L2 middleware applies to. Default `/api/auth/*`.
|
|
127
|
+
// Override for apps with a different auth route layout.
|
|
128
|
+
readonly path?: string;
|
|
129
|
+
};
|
|
130
|
+
};
|
|
131
|
+
// Hard cap on JSON request bodies in bytes. Applied to /api/write,
|
|
132
|
+
// /api/batch, /api/query, /api/command and /api/auth/*. File uploads
|
|
133
|
+
// (/api/files) are excluded — those have their own per-field maxSize.
|
|
134
|
+
// `undefined` → 1 MB default. `0` disables the limit entirely (tests
|
|
135
|
+
// or bespoke deployments with a reverse-proxy that caps upstream).
|
|
136
|
+
maxRequestBytes?: number;
|
|
137
|
+
// Process lifecycle. When present:
|
|
138
|
+
// - GET /health/ready reflects lifecycle.state() (200 ready / 503 else)
|
|
139
|
+
// - eventDispatcher.stop() is auto-registered as a shutdown hook, so
|
|
140
|
+
// lifecycle.drain() tears the poller down without the caller wiring it
|
|
141
|
+
// Production main.ts passes `createLifecycle()`; tests that don't care
|
|
142
|
+
// about drain() orchestration omit this and /health/ready stays absent.
|
|
143
|
+
lifecycle?: Lifecycle;
|
|
144
|
+
// Prometheus-scrape endpoint. When set, `/metrics` returns the current
|
|
145
|
+
// accumulated metric state in OpenMetrics text format. Requires the
|
|
146
|
+
// configured `observability` to use a PrometheusMeter (duck-typed via
|
|
147
|
+
// the `snapshot` method) — otherwise the route returns 503 with a
|
|
148
|
+
// note about misconfiguration. The optional `token` enforces
|
|
149
|
+
// `Authorization: Bearer <token>`; without a token set the endpoint
|
|
150
|
+
// is open (fine inside a private cluster, dangerous on the public
|
|
151
|
+
// internet). Omit this option entirely to skip the route.
|
|
152
|
+
metrics?: {
|
|
153
|
+
readonly token?: string;
|
|
154
|
+
readonly path?: string; // default "/metrics"
|
|
155
|
+
};
|
|
156
|
+
// /health/ready depth. When lifecycle is wired, the readiness handler
|
|
157
|
+
// ALSO runs dependency checks before returning 200:
|
|
158
|
+
// - DB ping (auto-wired when context.db is a DbConnection)
|
|
159
|
+
// - Redis PING (auto-wired when context.redis is set)
|
|
160
|
+
// - Dispatcher consumer-lag (opt-in via maxDispatcherLag — off by default
|
|
161
|
+
// because a default threshold would false-503 small deployments that
|
|
162
|
+
// legitimately lag during bursts)
|
|
163
|
+
// Checks run in parallel with a per-check timeout; any failed check drops
|
|
164
|
+
// the probe to 503 with a JSON body listing which check failed.
|
|
165
|
+
readiness?: {
|
|
166
|
+
readonly timeoutMs?: number;
|
|
167
|
+
readonly maxDispatcherLag?: bigint;
|
|
168
|
+
};
|
|
169
|
+
// Which deploy-lane this process runs — drives MSP-consumer filtering.
|
|
170
|
+
// "api": picks up MSPs with runIn in {api, both}.
|
|
171
|
+
// "worker": picks up MSPs with runIn in {worker, both, undefined (default)}.
|
|
172
|
+
// "both": all-in-one, no filtering — every MSP runs here.
|
|
173
|
+
// When omitted, defaults to "worker" — preserves pre-Welle-2.6 behaviour
|
|
174
|
+
// (every MSP runs on the single dispatcher, wherever it lives).
|
|
175
|
+
processLane?: RunIn;
|
|
176
|
+
// Stable identifier for THIS process in the event-consumer state table.
|
|
177
|
+
// Used as the `instance_id` on every per-instance consumer's cursor row
|
|
178
|
+
// (Welle 2.7). Shared consumers ignore this and always write the reserved
|
|
179
|
+
// sentinel. Default: `process.env.KUMIKO_INSTANCE_ID ?? generateId()`
|
|
180
|
+
// — a fresh UUID at boot is fine for single-process deploys, but
|
|
181
|
+
// multi-instance deploys SHOULD set KUMIKO_INSTANCE_ID to a stable
|
|
182
|
+
// identifier (pod name, hostname) so ops can correlate lag metrics to
|
|
183
|
+
// specific instances and can DELETE stale rows on scale-down. Must never
|
|
184
|
+
// equal the sentinel; validator fails boot if it does.
|
|
185
|
+
instanceId?: string;
|
|
186
|
+
// Opt-in: serve unauthenticated requests on handlers that allow
|
|
187
|
+
// roles=["anonymous"]. When omitted, every /api/* request still requires
|
|
188
|
+
// a valid JWT (status quo). See AnonymousAccessConfig for the resolution
|
|
189
|
+
// chain (header → cookie → resolver → defaultTenantId).
|
|
190
|
+
anonymousAccess?: AnonymousAccessConfig;
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
export type KumikoServer = {
|
|
194
|
+
app: Hono;
|
|
195
|
+
jwt: JwtHelper;
|
|
196
|
+
sseBroker: SseBroker;
|
|
197
|
+
observability: ObservabilityProvider;
|
|
198
|
+
// Present when at least one consumer is wired and context.db is a
|
|
199
|
+
// DbConnection. Caller owns the lifecycle: `.start()` in boot, `.stop()`
|
|
200
|
+
// in shutdown. Tests drain via `.runOnce()` instead.
|
|
201
|
+
eventDispatcher?: EventDispatcher;
|
|
202
|
+
// Echoed back so the caller has a single handle for both the app and the
|
|
203
|
+
// lifecycle. Only set when the caller passed one in.
|
|
204
|
+
lifecycle?: Lifecycle;
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
export function buildServer(options: ServerOptions): KumikoServer {
|
|
208
|
+
// Hard-fail when the registry declares file/image fields but no storage
|
|
209
|
+
// provider is wired. Boot-validator checks the env shape; here we prove the
|
|
210
|
+
// runtime actually has somewhere to put the bytes. Without this, uploads
|
|
211
|
+
// would fail at the first request instead of at boot.
|
|
212
|
+
if (!options.files?.storageProvider && registryDeclaresFileFields(options.registry)) {
|
|
213
|
+
throw new Error(
|
|
214
|
+
"Features declare file/image fields but no storageProvider was registered — " +
|
|
215
|
+
"pass `files: { storageProvider, db }` to buildServer().",
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const jwt = createJwtHelper(options.jwtSecret, options.jwtIssuer);
|
|
220
|
+
const sseBroker = options.sseBroker ?? createSseBroker();
|
|
221
|
+
|
|
222
|
+
// Resolve the per-process instance identifier. Prefer explicit
|
|
223
|
+
// ServerOptions.instanceId (tests, deliberate wiring), fall back to the
|
|
224
|
+
// deploy-env variable, finally a boot-time UUID. Validator rejects the
|
|
225
|
+
// sentinel — a deliberate collision attempt would silently merge this
|
|
226
|
+
// instance's per-instance cursors with the shared-row cursors and
|
|
227
|
+
// deliver events twice to one shard while starving the other.
|
|
228
|
+
const resolvedInstanceId =
|
|
229
|
+
options.instanceId ?? process.env["KUMIKO_INSTANCE_ID"] ?? generateId();
|
|
230
|
+
if (resolvedInstanceId === SHARED_INSTANCE_SENTINEL) {
|
|
231
|
+
throw new Error(
|
|
232
|
+
`ServerOptions.instanceId / KUMIKO_INSTANCE_ID cannot equal the reserved sentinel "${SHARED_INSTANCE_SENTINEL}" — ` +
|
|
233
|
+
`pick any other stable string.`,
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
// Warn when we fell back to a random UUID: the default SSE system-consumer
|
|
237
|
+
// is delivery="per-instance", so every boot gets a fresh cursor-row in
|
|
238
|
+
// kumiko_event_consumers. The previous boot's row stays behind on its last
|
|
239
|
+
// cursor and pins pruneEvents (retention-guard uses MIN(lastProcessedEventId)
|
|
240
|
+
// across all shards). Without a stable KUMIKO_INSTANCE_ID this accumulates
|
|
241
|
+
// on every restart, not just scale-down. Silent when options.instanceId or
|
|
242
|
+
// KUMIKO_INSTANCE_ID is explicit — those are deliberate choices (the test
|
|
243
|
+
// suite sets KUMIKO_INSTANCE_ID="test-instance" in vitest config).
|
|
244
|
+
const instanceIdWasRandom =
|
|
245
|
+
options.instanceId === undefined && !process.env["KUMIKO_INSTANCE_ID"];
|
|
246
|
+
if (instanceIdWasRandom) {
|
|
247
|
+
console.warn(
|
|
248
|
+
`[kumiko:boot] No ServerOptions.instanceId / KUMIKO_INSTANCE_ID set — generated a random UUID (${resolvedInstanceId}). ` +
|
|
249
|
+
`Per-instance consumers (SSE by default) write one cursor-row per instance; without a stable id, each restart leaves an orphaned row behind and pins events-retention on its last cursor. ` +
|
|
250
|
+
`Set KUMIKO_INSTANCE_ID to a stable value (e.g. hostname, pod name) in production.`,
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Observability — Noop by default so no call-site needs to null-check.
|
|
255
|
+
// Every handler/middleware that reaches for ctx.tracer / ctx.metrics gets
|
|
256
|
+
// a working, zero-cost fallback when no provider is configured.
|
|
257
|
+
const observability = options.observability ?? createNoopProvider();
|
|
258
|
+
|
|
259
|
+
// Register framework + feature metrics once on this meter. Standard
|
|
260
|
+
// metrics (HTTP, dispatcher, DB) are used by Auto-Instrumentation; feature
|
|
261
|
+
// metrics come from r.metric(...) declarations collected in the registry.
|
|
262
|
+
registerStandardMetrics(observability.meter);
|
|
263
|
+
for (const [name, def] of options.registry.getAllMetrics()) {
|
|
264
|
+
if (observability.meter.definitions().has(name)) continue;
|
|
265
|
+
observability.meter.registerMetric({
|
|
266
|
+
name,
|
|
267
|
+
type: def.type,
|
|
268
|
+
description: def.description,
|
|
269
|
+
labels: def.labels,
|
|
270
|
+
buckets: def.buckets,
|
|
271
|
+
unit: def.unit,
|
|
272
|
+
tenantLabel: def.tenantLabel,
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// When a non-default provider is configured, wrap the injected Redis clients
|
|
277
|
+
// so `redis.cmd` spans attach to every command. For the default NoopProvider
|
|
278
|
+
// we skip the proxy to keep zero runtime overhead when observability is off.
|
|
279
|
+
const shouldWrapRedis = options.observability !== undefined;
|
|
280
|
+
const redisCtx = options.context.redis;
|
|
281
|
+
const wrappedRedis =
|
|
282
|
+
shouldWrapRedis && redisCtx ? wrapRedisClient(redisCtx, observability.tracer) : redisCtx;
|
|
283
|
+
|
|
284
|
+
// Inject tracer + meter into the AppContext so the dispatcher can propagate
|
|
285
|
+
// them into every HandlerContext it builds. If a file storage provider was
|
|
286
|
+
// registered, wrap it in a FileContext so handlers/hooks can resolve
|
|
287
|
+
// `ctx.files.ref(key)` without reaching for the raw provider.
|
|
288
|
+
const fileCtx = options.files?.storageProvider
|
|
289
|
+
? createFileContext(options.files.storageProvider)
|
|
290
|
+
: undefined;
|
|
291
|
+
// Auto-wire the rate-limit resolver, but ONLY when at least one
|
|
292
|
+
// handler actually declared a rateLimit option. Apps that don't use
|
|
293
|
+
// L3 pay zero cost: no resolver instance, no Lua-script registration
|
|
294
|
+
// on Redis, no AppContext field. Apps that wire L1/L2 middleware can
|
|
295
|
+
// pass `context.rateLimit` explicitly — that takes precedence over
|
|
296
|
+
// the auto-wire (e.g. middleware-only setup without any L3 handler).
|
|
297
|
+
// Auto-build the resolver when L3 handlers declared rateLimit OR when
|
|
298
|
+
// the caller asked for L1/L2 middleware. Either path needs a resolver;
|
|
299
|
+
// both share the same instance to avoid duplicate Lua-script registration.
|
|
300
|
+
const wantsL3 = options.registry.hasRateLimitedHandler();
|
|
301
|
+
const wantsL1L2 =
|
|
302
|
+
options.rateLimit?.global !== undefined || options.rateLimit?.auth !== undefined;
|
|
303
|
+
const wantsResolver = wantsL3 || wantsL1L2;
|
|
304
|
+
const rateLimitResolver =
|
|
305
|
+
options.context.rateLimit ??
|
|
306
|
+
(wrappedRedis && wantsResolver ? createRateLimitResolver({ redis: wrappedRedis }) : undefined);
|
|
307
|
+
const contextWithObservability: AppContext = {
|
|
308
|
+
...options.context,
|
|
309
|
+
...(wrappedRedis ? { redis: wrappedRedis } : {}),
|
|
310
|
+
...(fileCtx ? { files: fileCtx } : {}),
|
|
311
|
+
...(rateLimitResolver ? { rateLimit: rateLimitResolver } : {}),
|
|
312
|
+
// Propagate the feature-toggle resolver to the context so the event-
|
|
313
|
+
// dispatcher (and any future context-reading consumer) sees the same
|
|
314
|
+
// source as the command dispatcher's handler-gate. Options take
|
|
315
|
+
// precedence over whatever was already on context — the
|
|
316
|
+
// dispatcher-options arg is the authoritative wire-up point.
|
|
317
|
+
...(options.dispatcherOptions?.effectiveFeatures
|
|
318
|
+
? { effectiveFeatures: options.dispatcherOptions.effectiveFeatures }
|
|
319
|
+
: {}),
|
|
320
|
+
tracer: observability.tracer,
|
|
321
|
+
meter: observability.meter,
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
const lifecycle = createLifecycleHooks(
|
|
325
|
+
options.registry,
|
|
326
|
+
options.systemHooks,
|
|
327
|
+
options.eventDedup ? { eventDedup: options.eventDedup } : undefined,
|
|
328
|
+
);
|
|
329
|
+
|
|
330
|
+
const dispatcher = createDispatcher(options.registry, contextWithObservability, {
|
|
331
|
+
...options.dispatcherOptions,
|
|
332
|
+
lifecycle,
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
// Async event-dispatcher — the replacement for the old transactional
|
|
336
|
+
// outbox. Consumer sources:
|
|
337
|
+
// 1. System: SSE broadcast (iff sseBroker), Search index (iff
|
|
338
|
+
// context.searchAdapter).
|
|
339
|
+
// 2. Features: every r.multiStreamProjection registered in the registry
|
|
340
|
+
// becomes its own consumer row with an independent cursor. The MSP
|
|
341
|
+
// apply map is routed by event.type; apply receives the raw DbRunner
|
|
342
|
+
// of a TX-scoped, tenant-bound DB handle so per-tenant writes stay
|
|
343
|
+
// isolated.
|
|
344
|
+
//
|
|
345
|
+
// The dispatcher is built but NOT started here. Production boot code
|
|
346
|
+
// must call `.start()`; test code typically calls `.runOnce()`.
|
|
347
|
+
// @cast-boundary engine-bridge — context.db union narrows to DbConnection here
|
|
348
|
+
const baseDb = contextWithObservability.db as DbConnection | undefined;
|
|
349
|
+
// @cast-boundary engine-bridge — searchAdapter is an optional context-extension
|
|
350
|
+
const searchAdapter = (contextWithObservability as { searchAdapter?: SearchAdapter })
|
|
351
|
+
.searchAdapter;
|
|
352
|
+
|
|
353
|
+
const sseConsumerEnabled = options.eventDispatcher?.systemConsumers?.sse ?? true;
|
|
354
|
+
const searchConsumerEnabled = options.eventDispatcher?.systemConsumers?.search ?? true;
|
|
355
|
+
|
|
356
|
+
const systemConsumers: EventConsumer[] = [];
|
|
357
|
+
if (sseConsumerEnabled) {
|
|
358
|
+
systemConsumers.push(createSseBroadcastEventConsumer(sseBroker));
|
|
359
|
+
}
|
|
360
|
+
if (searchConsumerEnabled && searchAdapter) {
|
|
361
|
+
systemConsumers.push(createSearchEventConsumer(searchAdapter, options.registry));
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// MultiStreamProjections: one EventConsumer per MSP. Handler routes by
|
|
365
|
+
// event.type into the MSP's apply map. MSPs aggregate cross-aggregate but
|
|
366
|
+
// still within one tenant by default — the applier receives the
|
|
367
|
+
// tenant-scoped DbRunner; SYSTEM_TENANT_ID events pass through the raw
|
|
368
|
+
// baseDb so system-level sinks can read across tenants.
|
|
369
|
+
//
|
|
370
|
+
// Lane-filter (Welle 2.6.b): MSPs declare `runIn` to pin them to a
|
|
371
|
+
// deploy-lane. An MSP with `runIn: "api"` won't be wired into the
|
|
372
|
+
// worker-process dispatcher (and vice versa). `runIn: "both"` (or the
|
|
373
|
+
// legacy undefined default of "worker") runs wherever a dispatcher is
|
|
374
|
+
// started — SKIP LOCKED on the consumer-cursor handles the race between
|
|
375
|
+
// processes that both want the same event.
|
|
376
|
+
const processLane: RunIn = options.processLane ?? "worker";
|
|
377
|
+
const mspDefs = [...options.registry.getAllMultiStreamProjections().values()].filter((msp) =>
|
|
378
|
+
runsInLane(msp.runIn, processLane),
|
|
379
|
+
);
|
|
380
|
+
const mspConsumers: EventConsumer[] = mspDefs.map((msp) => ({
|
|
381
|
+
name: msp.name,
|
|
382
|
+
// Feature-toggle gating: carry the owning feature so the event-dispatcher
|
|
383
|
+
// can pause this consumer when the feature is globally disabled. Events
|
|
384
|
+
// queue up in the store and replay cleanly from the same cursor on resume.
|
|
385
|
+
...(options.registry.getMultiStreamProjectionFeature(msp.name) && {
|
|
386
|
+
featureName: options.registry.getMultiStreamProjectionFeature(msp.name) as string,
|
|
387
|
+
}),
|
|
388
|
+
// Copy the continuous-lifecycle error policy straight onto the consumer.
|
|
389
|
+
// Rebuild uses its own policy (rebuildProjection reads msp.errorMode.rebuild
|
|
390
|
+
// directly); steady-state delivery runs through this consumer.
|
|
391
|
+
...(msp.errorMode?.continuous && { errorPolicy: msp.errorMode.continuous }),
|
|
392
|
+
// Carry the MSP's declared delivery semantic through to the consumer.
|
|
393
|
+
// Default (shared) is applied inside event-dispatcher, so omitting when
|
|
394
|
+
// the MSP didn't declare one keeps the existing behaviour.
|
|
395
|
+
...(msp.delivery && { delivery: msp.delivery }),
|
|
396
|
+
handler: async (event, ctx) => {
|
|
397
|
+
const applyFn = msp.apply[event.type];
|
|
398
|
+
// skip: this MSP doesn't care about this event type — fast path,
|
|
399
|
+
// every event type passes through every MSP consumer exactly once.
|
|
400
|
+
if (!applyFn) return;
|
|
401
|
+
if (!baseDb) {
|
|
402
|
+
// skip: no baseDb wired — allConsumers.length > 0 + baseDb check
|
|
403
|
+
// above gates dispatcher creation, so we won't reach here in
|
|
404
|
+
// production. Defensive return for the type-narrowing path.
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
const scopedDb =
|
|
408
|
+
event.tenantId === SYSTEM_TENANT_ID ? baseDb : createTenantDb(baseDb, event.tenantId);
|
|
409
|
+
// Hand the raw DbRunner to apply(): MSPs write to their projection
|
|
410
|
+
// table directly, they don't go through the TenantDb wrapper.
|
|
411
|
+
const rawRunner =
|
|
412
|
+
event.tenantId === SYSTEM_TENANT_ID
|
|
413
|
+
? baseDb
|
|
414
|
+
: // @cast-boundary engine-bridge — TenantDb exposes its raw DbRunner via .raw
|
|
415
|
+
(scopedDb as { raw: typeof baseDb }).raw;
|
|
416
|
+
// Saga/process-manager ctx: apply can call ctx.appendEvent to cascade
|
|
417
|
+
// a follow-up event onto another aggregate. Uses the triggering event's
|
|
418
|
+
// tenantId + userId so the causal chain stays tenant-consistent.
|
|
419
|
+
// MSP qualified names are "<feature>:projection:<short>" — the
|
|
420
|
+
// prefix before the first ":" owns the MSP. Used to reject
|
|
421
|
+
// cross-feature ctx.appendEvent calls at emit-site.
|
|
422
|
+
const mspOwner = msp.name.split(":")[0];
|
|
423
|
+
const applyCtx = createMultiStreamApplyContext({
|
|
424
|
+
registry: options.registry,
|
|
425
|
+
db: rawRunner,
|
|
426
|
+
tenantId: event.tenantId,
|
|
427
|
+
userId: event.metadata.userId,
|
|
428
|
+
...(mspOwner && { callerFeature: mspOwner }),
|
|
429
|
+
...(fileCtx && { files: fileCtx }),
|
|
430
|
+
});
|
|
431
|
+
await applyFn(event, rawRunner, applyCtx);
|
|
432
|
+
// Keep ctx reachable to satisfy the EventConsumerHandler signature.
|
|
433
|
+
void ctx;
|
|
434
|
+
},
|
|
435
|
+
}));
|
|
436
|
+
|
|
437
|
+
const allConsumers = [...systemConsumers, ...mspConsumers];
|
|
438
|
+
const {
|
|
439
|
+
disabled: dispatcherDisabled,
|
|
440
|
+
systemConsumers: _systemConsumersOpt,
|
|
441
|
+
...dispatcherTunables
|
|
442
|
+
} = options.eventDispatcher ?? {};
|
|
443
|
+
let eventDispatcher: EventDispatcher | undefined;
|
|
444
|
+
if (allConsumers.length > 0 && baseDb && !dispatcherDisabled) {
|
|
445
|
+
eventDispatcher = createEventDispatcher({
|
|
446
|
+
db: baseDb,
|
|
447
|
+
consumers: allConsumers,
|
|
448
|
+
context: contextWithObservability,
|
|
449
|
+
tracer: observability.tracer,
|
|
450
|
+
meter: observability.meter,
|
|
451
|
+
instanceId: resolvedInstanceId,
|
|
452
|
+
...dispatcherTunables,
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// Wire the event-dispatcher shutdown into the lifecycle so the caller
|
|
457
|
+
// doesn't have to know the dispatcher exists. Hooks drain LIFO, so this
|
|
458
|
+
// runs before anything registered later by the caller (e.g. DB pool close).
|
|
459
|
+
if (options.lifecycle && eventDispatcher) {
|
|
460
|
+
const dispatcher = eventDispatcher;
|
|
461
|
+
options.lifecycle.registerShutdownHook("eventDispatcher", async () => {
|
|
462
|
+
await dispatcher.stop();
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
const app = new Hono();
|
|
467
|
+
|
|
468
|
+
const sensitiveConfig = mergeSensitiveConfig(
|
|
469
|
+
options.observabilityOptions?.sensitiveFilter ?? DEFAULT_SENSITIVE_CONFIG,
|
|
470
|
+
);
|
|
471
|
+
|
|
472
|
+
registerHealthRoutes(app, {
|
|
473
|
+
lifecycle: options.lifecycle,
|
|
474
|
+
readiness: {
|
|
475
|
+
db: baseDb,
|
|
476
|
+
redis: options.context.redis,
|
|
477
|
+
consumers: allConsumers,
|
|
478
|
+
...(options.readiness ?? {}),
|
|
479
|
+
},
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
if (options.metrics) {
|
|
483
|
+
registerMetricsRoute(app, observability.meter, options.metrics);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
app.use("/api/*", requestIdMiddleware());
|
|
487
|
+
|
|
488
|
+
// Cap JSON bodies before rate-limit/auth/observability even run. Header-
|
|
489
|
+
// check is O(1); oversized requests never allocate memory for a full body
|
|
490
|
+
// parse. Upload route keeps its own per-field maxSize.
|
|
491
|
+
registerBodyLimit(app, options.maxRequestBytes ?? DEFAULT_MAX_REQUEST_BYTES);
|
|
492
|
+
|
|
493
|
+
// L1/L2 rate-limit middleware run BEFORE auth so an unauthenticated
|
|
494
|
+
// flood can't even reach the JWT-verify code path. Wired only when
|
|
495
|
+
// the caller passed `rateLimit.global` or `rateLimit.auth`. The
|
|
496
|
+
// resolver is the auto-wired one (or `context.rateLimit` if set);
|
|
497
|
+
// boot-fails loudly when the caller asked for middleware without a
|
|
498
|
+
// working Redis to back it.
|
|
499
|
+
if (wantsL1L2) {
|
|
500
|
+
if (!rateLimitResolver) {
|
|
501
|
+
throw new Error(
|
|
502
|
+
"rateLimit middleware requested but no resolver available — pass `context.redis` " +
|
|
503
|
+
"or `context.rateLimit` so the resolver can be built.",
|
|
504
|
+
);
|
|
505
|
+
}
|
|
506
|
+
if (options.rateLimit?.global) {
|
|
507
|
+
app.use(
|
|
508
|
+
"/api/*",
|
|
509
|
+
globalIpRateLimit({ ...options.rateLimit.global, resolver: rateLimitResolver }),
|
|
510
|
+
);
|
|
511
|
+
}
|
|
512
|
+
if (options.rateLimit?.auth) {
|
|
513
|
+
const { path: l2Path = "/api/auth/*", ...l2Opts } = options.rateLimit.auth;
|
|
514
|
+
app.use(l2Path, authEndpointRateLimit({ ...l2Opts, resolver: rateLimitResolver }));
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
// Observability span wraps everything that follows (auth, routes).
|
|
518
|
+
// Must come AFTER request-id (so span can carry the id) and BEFORE auth
|
|
519
|
+
// (so auth-verify can be a child span once we instrument it in v2).
|
|
520
|
+
app.use(
|
|
521
|
+
"/api/*",
|
|
522
|
+
observabilityMiddleware({
|
|
523
|
+
tracer: observability.tracer,
|
|
524
|
+
meter: observability.meter,
|
|
525
|
+
sensitiveConfig,
|
|
526
|
+
}),
|
|
527
|
+
);
|
|
528
|
+
|
|
529
|
+
// Auth middleware skips public paths (login, health) — those routes need
|
|
530
|
+
// to be callable without a valid JWT. Every other /api/* request requires
|
|
531
|
+
// a token (or, when anonymousAccess is wired, falls through as anonymous).
|
|
532
|
+
// A session-checker is forwarded when the auth-config wires one, so the
|
|
533
|
+
// middleware can reject revoked sids on every request.
|
|
534
|
+
const jwtGuard = authMiddleware(jwt, {
|
|
535
|
+
...(options.auth?.sessionChecker ? { sessionChecker: options.auth.sessionChecker } : {}),
|
|
536
|
+
...(options.auth?.sessionStrictMode ? { strictMode: options.auth.sessionStrictMode } : {}),
|
|
537
|
+
...(options.anonymousAccess ? { anonymousAccess: options.anonymousAccess } : {}),
|
|
538
|
+
});
|
|
539
|
+
app.use("/api/*", async (c, next) => {
|
|
540
|
+
if (PUBLIC_API_PATHS.has(c.req.path)) return next();
|
|
541
|
+
return jwtGuard(c, next);
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
// Double-submit CSRF guard — runs only on cookie-authenticated,
|
|
545
|
+
// state-changing requests (POST/PUT/PATCH/DELETE). The guard reads the
|
|
546
|
+
// authTransport flag set by authMiddleware, so public paths (no auth)
|
|
547
|
+
// and bearer-authenticated paths (no cookie vector) fall straight
|
|
548
|
+
// through. Must be registered AFTER the auth middleware above so the
|
|
549
|
+
// flag is populated; registered for the same scope so /api/* routes
|
|
550
|
+
// are covered uniformly.
|
|
551
|
+
const csrfGuard = csrfMiddleware();
|
|
552
|
+
app.use("/api/*", async (c, next) => {
|
|
553
|
+
if (PUBLIC_API_PATHS.has(c.req.path)) return next();
|
|
554
|
+
return csrfGuard(c, next);
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
// Public auth routes (login) need to be registered BEFORE the generic
|
|
558
|
+
// api routes so Hono matches them first.
|
|
559
|
+
if (options.auth) {
|
|
560
|
+
app.route("/api", createAuthRoutes(dispatcher, jwt, options.auth));
|
|
561
|
+
}
|
|
562
|
+
app.route("/api", createApiRoutes(dispatcher));
|
|
563
|
+
app.route("/api", createSseRoute(sseBroker));
|
|
564
|
+
|
|
565
|
+
if (options.files) {
|
|
566
|
+
const fileDb = options.files.db ?? (options.context.db as FileRoutesOptions["db"]);
|
|
567
|
+
if (!fileDb) throw new Error("files option requires db in context or files.db");
|
|
568
|
+
app.route(
|
|
569
|
+
"/api",
|
|
570
|
+
createFileRoutes({
|
|
571
|
+
...options.files,
|
|
572
|
+
db: fileDb,
|
|
573
|
+
registry: options.registry,
|
|
574
|
+
}),
|
|
575
|
+
);
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// Feature-deklarierte HTTP-Routes (r.httpRoute). Mount nach /api/* damit
|
|
579
|
+
// /api/* immer Vorrang hat — feature-Routes liegen ohnehin außerhalb
|
|
580
|
+
// (Boot-Validator blockt /api-Prefix). deps.app ist die Outer-App, sodass
|
|
581
|
+
// der Handler /api/query intern via app.fetch(...) nutzen kann (gleicher
|
|
582
|
+
// Auth-Pfad wie ein echter HTTP-Call).
|
|
583
|
+
for (const feature of options.registry.features.values()) {
|
|
584
|
+
for (const route of Object.values(feature.httpRoutes)) {
|
|
585
|
+
const honoHandler = async (c: import("hono").Context): Promise<Response> =>
|
|
586
|
+
route.handler(c, { app });
|
|
587
|
+
switch (route.method) {
|
|
588
|
+
case "GET":
|
|
589
|
+
app.get(route.path, honoHandler);
|
|
590
|
+
break;
|
|
591
|
+
case "POST":
|
|
592
|
+
app.post(route.path, honoHandler);
|
|
593
|
+
break;
|
|
594
|
+
case "PUT":
|
|
595
|
+
app.put(route.path, honoHandler);
|
|
596
|
+
break;
|
|
597
|
+
case "PATCH":
|
|
598
|
+
app.patch(route.path, honoHandler);
|
|
599
|
+
break;
|
|
600
|
+
case "DELETE":
|
|
601
|
+
app.delete(route.path, honoHandler);
|
|
602
|
+
break;
|
|
603
|
+
case "OPTIONS":
|
|
604
|
+
case "HEAD":
|
|
605
|
+
// Hono-on() für die Methoden ohne Convenience-Method.
|
|
606
|
+
app.on(route.method, route.path, honoHandler);
|
|
607
|
+
break;
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
// /version-Default registriert NACH feature-routes — Hono "first match
|
|
613
|
+
// wins", also gewinnt feature-deklariertes /version (z.B. App-spezifisches
|
|
614
|
+
// version-format mit Tenant-Stats) wenn vorhanden, sonst greift der
|
|
615
|
+
// Default-Handler aus BUILD_VERSION/BUILD_TIME-env-vars.
|
|
616
|
+
registerVersionRoute(app);
|
|
617
|
+
|
|
618
|
+
return {
|
|
619
|
+
app,
|
|
620
|
+
jwt,
|
|
621
|
+
sseBroker,
|
|
622
|
+
observability,
|
|
623
|
+
...(eventDispatcher ? { eventDispatcher } : {}),
|
|
624
|
+
...(options.lifecycle ? { lifecycle: options.lifecycle } : {}),
|
|
625
|
+
};
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
// Scans every feature's entities for a file/image/files/images field. Short-
|
|
629
|
+
// circuits on the first hit — no need to build a full inventory, we only want
|
|
630
|
+
// the yes/no answer for the boot check.
|
|
631
|
+
function registryDeclaresFileFields(registry: Registry): boolean {
|
|
632
|
+
for (const feature of registry.features.values()) {
|
|
633
|
+
for (const entity of Object.values(feature.entities)) {
|
|
634
|
+
for (const field of Object.values(entity.fields)) {
|
|
635
|
+
if (isFileField(field)) return true;
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
return false;
|
|
640
|
+
}
|