@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,56 @@
|
|
|
1
|
+
// Metric + Meter contract. Feature code interacts through ctx.metrics
|
|
2
|
+
// (MetricsHandle); the framework wires Counter/Histogram/Gauge behind that.
|
|
3
|
+
|
|
4
|
+
export type MetricLabels = Record<string, string | number | boolean>;
|
|
5
|
+
|
|
6
|
+
export type MetricType = "counter" | "histogram" | "gauge";
|
|
7
|
+
|
|
8
|
+
export type MetricDefinition = {
|
|
9
|
+
// Fully-qualified name (with kumiko_<feature>_ prefix already applied).
|
|
10
|
+
readonly name: string;
|
|
11
|
+
readonly type: MetricType;
|
|
12
|
+
readonly description?: string;
|
|
13
|
+
// Declared label keys. Inc/observe calls with unknown label keys throw.
|
|
14
|
+
readonly labels?: readonly string[];
|
|
15
|
+
// Buckets only for histogram. If omitted, provider-default is used.
|
|
16
|
+
readonly buckets?: readonly number[];
|
|
17
|
+
readonly unit?: string;
|
|
18
|
+
// If true, the framework auto-injects tenant_id from the active ctx.
|
|
19
|
+
// Default false — adding tenant_id multiplies cardinality by tenant count.
|
|
20
|
+
readonly tenantLabel?: boolean;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export interface Counter {
|
|
24
|
+
inc(value?: number, labels?: MetricLabels): void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface Histogram {
|
|
28
|
+
observe(value: number, labels?: MetricLabels): void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface Gauge {
|
|
32
|
+
set(value: number, labels?: MetricLabels): void;
|
|
33
|
+
inc(value?: number, labels?: MetricLabels): void;
|
|
34
|
+
dec(value?: number, labels?: MetricLabels): void;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface Meter {
|
|
38
|
+
// Called once per metric during boot. Duplicate names throw.
|
|
39
|
+
registerMetric(def: MetricDefinition): void;
|
|
40
|
+
// Lookup by fully-qualified name. Unknown names throw — typed access only.
|
|
41
|
+
counter(name: string): Counter;
|
|
42
|
+
histogram(name: string): Histogram;
|
|
43
|
+
gauge(name: string): Gauge;
|
|
44
|
+
// List of registered definitions — used by ctx.metrics to validate labels.
|
|
45
|
+
definitions(): ReadonlyMap<string, MetricDefinition>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Public handle for feature code — ctx.metrics.
|
|
49
|
+
// Name resolution uses the fully-qualified name (kumiko_<feature>_<short>).
|
|
50
|
+
// The registrar resolves the short name from the calling feature context
|
|
51
|
+
// at boot time; at handler time the map is ready.
|
|
52
|
+
export interface MetricsHandle {
|
|
53
|
+
inc(name: string, labels?: MetricLabels, value?: number): void;
|
|
54
|
+
observe(name: string, value: number, labels?: MetricLabels): void;
|
|
55
|
+
set(name: string, value: number, labels?: MetricLabels): void;
|
|
56
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// Observability provider contract — the "plug" that implementations (noop,
|
|
2
|
+
// console, otlp, prometheus) fulfil. Configuration types that consumers
|
|
3
|
+
// (buildServer, setupTestStack) hand in also live here.
|
|
4
|
+
|
|
5
|
+
import type { Meter } from "./metric";
|
|
6
|
+
import type { Tracer } from "./span";
|
|
7
|
+
|
|
8
|
+
export type SamplingConfig = {
|
|
9
|
+
// Base sampling rate 0..1. Default 1 in v1 (sample everything).
|
|
10
|
+
readonly tracing?: number;
|
|
11
|
+
readonly alwaysOnError?: boolean;
|
|
12
|
+
readonly alwaysOnSlow?: { readonly thresholdMs: number };
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type SensitiveFilterConfig = {
|
|
16
|
+
readonly redactedHeaders: readonly string[];
|
|
17
|
+
readonly redactedQueryParams: readonly string[];
|
|
18
|
+
readonly redactedAttributeKeyPatterns: readonly RegExp[];
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export type ObservabilityOptions = {
|
|
22
|
+
readonly sampling?: SamplingConfig;
|
|
23
|
+
readonly sensitiveFilter?: Partial<SensitiveFilterConfig>;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export interface ObservabilityProvider {
|
|
27
|
+
readonly name: string;
|
|
28
|
+
readonly tracer: Tracer;
|
|
29
|
+
readonly meter: Meter;
|
|
30
|
+
// Graceful flush. Called from framework lifecycle shutdown.
|
|
31
|
+
shutdown(): Promise<void>;
|
|
32
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// Span + Tracer contract. Provider implementations (noop, recording → console/otlp)
|
|
2
|
+
// all speak this — everything else (middleware, dispatcher, db, redis, jobs)
|
|
3
|
+
// is provider-agnostic.
|
|
4
|
+
|
|
5
|
+
export type SpanAttributeValue = string | number | boolean;
|
|
6
|
+
export type SpanAttributes = Record<string, SpanAttributeValue>;
|
|
7
|
+
|
|
8
|
+
export type SpanStatus = "unset" | "ok" | "error";
|
|
9
|
+
|
|
10
|
+
export type SpanKind = "internal" | "server" | "client" | "producer" | "consumer";
|
|
11
|
+
|
|
12
|
+
// Serialized form of a trace context — what we pass across process boundaries
|
|
13
|
+
// (outbox row, BullMQ job payload). Matches W3C trace-context spec loosely,
|
|
14
|
+
// but staying minimal — a full W3C parser is v2.
|
|
15
|
+
export type SerializedTraceContext = {
|
|
16
|
+
readonly traceId: string;
|
|
17
|
+
readonly spanId: string;
|
|
18
|
+
readonly baggage?: Readonly<Record<string, string>>;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export type StartSpanOptions = {
|
|
22
|
+
// Either a live Span (normal in-process parent) or a serialized trace
|
|
23
|
+
// context (cross-process — outbox row, job payload). Both carry traceId
|
|
24
|
+
// and spanId, so the tracer reads them uniformly. Omitted → fall back to
|
|
25
|
+
// the AsyncLocalStorage active span.
|
|
26
|
+
readonly parent?: Span | SerializedTraceContext;
|
|
27
|
+
readonly attributes?: SpanAttributes;
|
|
28
|
+
readonly kind?: SpanKind;
|
|
29
|
+
readonly startTime?: number;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export interface Span {
|
|
33
|
+
readonly traceId: string;
|
|
34
|
+
readonly spanId: string;
|
|
35
|
+
readonly parentSpanId: string | undefined;
|
|
36
|
+
readonly name: string;
|
|
37
|
+
setAttribute(key: string, value: SpanAttributeValue): void;
|
|
38
|
+
setAttributes(attrs: SpanAttributes): void;
|
|
39
|
+
setStatus(status: SpanStatus, message?: string): void;
|
|
40
|
+
recordException(error: Error): void;
|
|
41
|
+
end(endTime?: number): void;
|
|
42
|
+
// Whether end() has been called. Idempotency guard for auto-wrappers.
|
|
43
|
+
readonly ended: boolean;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface Tracer {
|
|
47
|
+
startSpan(name: string, options?: StartSpanOptions): Span;
|
|
48
|
+
// Runs fn inside the span context (AsyncLocalStorage), ends the span
|
|
49
|
+
// automatically — including on thrown errors, where the error is recorded
|
|
50
|
+
// and status set to "error" before re-throwing.
|
|
51
|
+
withSpan<T>(
|
|
52
|
+
name: string,
|
|
53
|
+
optionsOrFn: StartSpanOptions | ((span: Span) => Promise<T>),
|
|
54
|
+
fn?: (span: Span) => Promise<T>,
|
|
55
|
+
): Promise<T>;
|
|
56
|
+
// Current active span from AsyncLocalStorage, or undefined.
|
|
57
|
+
getActiveSpan(): Span | undefined;
|
|
58
|
+
// @deprecated Prefer `startSpan(name, { parent: context })`.
|
|
59
|
+
startSpanFromContext(
|
|
60
|
+
name: string,
|
|
61
|
+
context: SerializedTraceContext,
|
|
62
|
+
options?: StartSpanOptions,
|
|
63
|
+
): Span;
|
|
64
|
+
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
// C2 — archiveStream (Marten-aligned).
|
|
2
|
+
//
|
|
3
|
+
// Marten's session.Events.ArchiveStream(id): the stream becomes read-only,
|
|
4
|
+
// loads return an empty slice, further appends throw. Restoring un-archives.
|
|
5
|
+
// Kumiko carries this as a sparse kumiko_archived_streams table so active
|
|
6
|
+
// streams never pay for extra metadata writes.
|
|
7
|
+
|
|
8
|
+
import { afterAll, afterEach, beforeAll, describe, expect, test } from "vitest";
|
|
9
|
+
import { z } from "zod";
|
|
10
|
+
import { createEventStoreExecutor } from "../../db/event-store-executor";
|
|
11
|
+
import { buildDrizzleTable } from "../../db/table-builder";
|
|
12
|
+
import { createEntity, createTextField, defineFeature } from "../../engine";
|
|
13
|
+
import {
|
|
14
|
+
ArchivedStreamError,
|
|
15
|
+
isStreamArchived,
|
|
16
|
+
loadAggregate as loadAggregateRaw,
|
|
17
|
+
} from "../../event-store";
|
|
18
|
+
import {
|
|
19
|
+
createEntityTable,
|
|
20
|
+
resetEventStore,
|
|
21
|
+
setupTestStack,
|
|
22
|
+
type TestStack,
|
|
23
|
+
TestUsers,
|
|
24
|
+
} from "../../stack";
|
|
25
|
+
|
|
26
|
+
const itemEntity = createEntity({
|
|
27
|
+
table: "read_arch_items",
|
|
28
|
+
fields: { label: createTextField({ required: true }) },
|
|
29
|
+
});
|
|
30
|
+
const itemTable = buildDrizzleTable("arch-item", itemEntity);
|
|
31
|
+
|
|
32
|
+
const archFeature = defineFeature("archtest", (r) => {
|
|
33
|
+
r.entity("arch-item", itemEntity);
|
|
34
|
+
|
|
35
|
+
const labelChanged = r.defineEvent("label-changed", z.object({ label: z.string() }));
|
|
36
|
+
|
|
37
|
+
const executor = createEventStoreExecutor(itemTable, itemEntity, {
|
|
38
|
+
entityName: "arch-item",
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
r.writeHandler(
|
|
42
|
+
"item:create",
|
|
43
|
+
z.object({ label: z.string() }),
|
|
44
|
+
async (event, ctx) => executor.create(event.payload, event.user, ctx.db),
|
|
45
|
+
{ access: { roles: ["Admin"] } },
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
r.writeHandler(
|
|
49
|
+
"item:relabel",
|
|
50
|
+
z.object({ id: z.uuid(), label: z.string() }),
|
|
51
|
+
async (event, ctx) => {
|
|
52
|
+
await ctx.appendEventUnsafe({
|
|
53
|
+
aggregateId: event.payload.id,
|
|
54
|
+
aggregateType: "arch-item",
|
|
55
|
+
type: labelChanged.name,
|
|
56
|
+
payload: { label: event.payload.label },
|
|
57
|
+
});
|
|
58
|
+
return { isSuccess: true as const, data: { id: event.payload.id } };
|
|
59
|
+
},
|
|
60
|
+
{ access: { roles: ["Admin"] } },
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
r.writeHandler(
|
|
64
|
+
"item:archive",
|
|
65
|
+
z.object({ id: z.uuid(), reason: z.string().optional() }),
|
|
66
|
+
async (event, ctx) => {
|
|
67
|
+
await ctx.archiveStream(event.payload.id, {
|
|
68
|
+
aggregateType: "arch-item",
|
|
69
|
+
reason: event.payload.reason,
|
|
70
|
+
});
|
|
71
|
+
return { isSuccess: true as const, data: { id: event.payload.id } };
|
|
72
|
+
},
|
|
73
|
+
{ access: { roles: ["Admin"] } },
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
r.writeHandler(
|
|
77
|
+
"item:restore",
|
|
78
|
+
z.object({ id: z.uuid() }),
|
|
79
|
+
async (event, ctx) => {
|
|
80
|
+
await ctx.restoreStream(event.payload.id);
|
|
81
|
+
return { isSuccess: true as const, data: { id: event.payload.id } };
|
|
82
|
+
},
|
|
83
|
+
{ access: { roles: ["Admin"] } },
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
r.queryHandler(
|
|
87
|
+
"item:events",
|
|
88
|
+
z.object({ id: z.uuid() }),
|
|
89
|
+
async (query, ctx) => ctx.loadAggregate(query.payload.id),
|
|
90
|
+
{ access: { openToAll: true } },
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
r.queryHandler(
|
|
94
|
+
"item:is-archived",
|
|
95
|
+
z.object({ id: z.uuid() }),
|
|
96
|
+
async (query, ctx) => ctx.isStreamArchived(query.payload.id),
|
|
97
|
+
{ access: { openToAll: true } },
|
|
98
|
+
);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
let stack: TestStack;
|
|
102
|
+
const admin = TestUsers.admin;
|
|
103
|
+
|
|
104
|
+
beforeAll(async () => {
|
|
105
|
+
stack = await setupTestStack({ features: [archFeature], systemHooks: [] });
|
|
106
|
+
await createEntityTable(stack.db, itemEntity, "arch-item");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
afterAll(async () => {
|
|
110
|
+
await stack.cleanup();
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
afterEach(async () => {
|
|
114
|
+
await resetEventStore(stack, ["read_arch_items"]);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
describe("archiveStream — Marten ArchiveStream equivalent", () => {
|
|
118
|
+
test("archived stream returns empty from ctx.loadAggregate", async () => {
|
|
119
|
+
const { id } = await stack.http.writeOk<{ id: string }>(
|
|
120
|
+
"archtest:write:item:create",
|
|
121
|
+
{ label: "alpha" },
|
|
122
|
+
admin,
|
|
123
|
+
);
|
|
124
|
+
await stack.http.writeOk("archtest:write:item:relabel", { id, label: "beta" }, admin);
|
|
125
|
+
|
|
126
|
+
// Pre-archive: two events visible.
|
|
127
|
+
const before = await stack.http.queryOk<unknown[]>("archtest:query:item:events", { id }, admin);
|
|
128
|
+
expect(before.length).toBe(2);
|
|
129
|
+
|
|
130
|
+
await stack.http.writeOk("archtest:write:item:archive", { id, reason: "cleanup" }, admin);
|
|
131
|
+
|
|
132
|
+
// Post-archive: empty slice by default.
|
|
133
|
+
const after = await stack.http.queryOk<unknown[]>("archtest:query:item:events", { id }, admin);
|
|
134
|
+
expect(after).toEqual([]);
|
|
135
|
+
|
|
136
|
+
// Archive flag is visible.
|
|
137
|
+
const archived = await stack.http.queryOk<boolean>(
|
|
138
|
+
"archtest:query:item:is-archived",
|
|
139
|
+
{ id },
|
|
140
|
+
admin,
|
|
141
|
+
);
|
|
142
|
+
expect(archived).toBe(true);
|
|
143
|
+
|
|
144
|
+
// Low-level loader with includeArchived surfaces the events for ops.
|
|
145
|
+
const raw = await loadAggregateRaw(stack.db, id, admin.tenantId, {
|
|
146
|
+
includeArchived: true,
|
|
147
|
+
});
|
|
148
|
+
expect(raw).toHaveLength(2);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("appendEvent on an archived stream is rejected with ArchivedStreamError", async () => {
|
|
152
|
+
const { id } = await stack.http.writeOk<{ id: string }>(
|
|
153
|
+
"archtest:write:item:create",
|
|
154
|
+
{ label: "before-archive" },
|
|
155
|
+
admin,
|
|
156
|
+
);
|
|
157
|
+
await stack.http.writeOk("archtest:write:item:archive", { id }, admin);
|
|
158
|
+
|
|
159
|
+
// Writing through the handler surfaces the ArchivedStreamError as a
|
|
160
|
+
// 500 (InternalError path) — the framework treats archive violations
|
|
161
|
+
// as logic errors, not user-input errors. The detail message is
|
|
162
|
+
// masked in the HTTP response (don't leak internals), so the proof
|
|
163
|
+
// is structural: 500 status + no event landed on disk.
|
|
164
|
+
const res = await stack.http.write(
|
|
165
|
+
"archtest:write:item:relabel",
|
|
166
|
+
{ id, label: "too-late" },
|
|
167
|
+
admin,
|
|
168
|
+
);
|
|
169
|
+
expect(res.status).toBe(500);
|
|
170
|
+
|
|
171
|
+
// Sanity: the failed write did not land on disk.
|
|
172
|
+
const raw = await loadAggregateRaw(stack.db, id, admin.tenantId, {
|
|
173
|
+
includeArchived: true,
|
|
174
|
+
});
|
|
175
|
+
const types = raw.map((e) => e.type);
|
|
176
|
+
expect(types).toContain("arch-item.created");
|
|
177
|
+
expect(types).not.toContain("archtest:event:label-changed");
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("restoreStream reopens the stream for writes and reads", async () => {
|
|
181
|
+
const { id } = await stack.http.writeOk<{ id: string }>(
|
|
182
|
+
"archtest:write:item:create",
|
|
183
|
+
{ label: "phoenix" },
|
|
184
|
+
admin,
|
|
185
|
+
);
|
|
186
|
+
await stack.http.writeOk("archtest:write:item:archive", { id }, admin);
|
|
187
|
+
expect(await isStreamArchived(stack.db, admin.tenantId, id)).toBe(true);
|
|
188
|
+
|
|
189
|
+
await stack.http.writeOk("archtest:write:item:restore", { id }, admin);
|
|
190
|
+
expect(await isStreamArchived(stack.db, admin.tenantId, id)).toBe(false);
|
|
191
|
+
|
|
192
|
+
// Writes go through again AND keep the correct version lineage —
|
|
193
|
+
// the original "created" event is at version 1, so the post-restore
|
|
194
|
+
// relabel lands at version 2.
|
|
195
|
+
await stack.http.writeOk("archtest:write:item:relabel", { id, label: "reborn" }, admin);
|
|
196
|
+
const events = await loadAggregateRaw(stack.db, id, admin.tenantId);
|
|
197
|
+
expect(events.map((e) => e.version)).toEqual([1, 2]);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test("archive is idempotent — repeated archive calls do not throw", async () => {
|
|
201
|
+
const { id } = await stack.http.writeOk<{ id: string }>(
|
|
202
|
+
"archtest:write:item:create",
|
|
203
|
+
{ label: "repeat" },
|
|
204
|
+
admin,
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
await stack.http.writeOk("archtest:write:item:archive", { id, reason: "first" }, admin);
|
|
208
|
+
await stack.http.writeOk("archtest:write:item:archive", { id, reason: "second" }, admin);
|
|
209
|
+
|
|
210
|
+
const archived = await isStreamArchived(stack.db, admin.tenantId, id);
|
|
211
|
+
expect(archived).toBe(true);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test("ArchivedStreamError carries aggregateId + tenantId", () => {
|
|
215
|
+
const err = new ArchivedStreamError(admin.tenantId, "agg-1");
|
|
216
|
+
expect(err.aggregateId).toBe("agg-1");
|
|
217
|
+
expect(err.tenantId).toBe(admin.tenantId);
|
|
218
|
+
expect(err.name).toBe("ArchivedStreamError");
|
|
219
|
+
});
|
|
220
|
+
});
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
import { describe, expect, test, vi } from "vitest";
|
|
2
|
+
import type { AuthClaimsContext, AuthClaimsHookDef, SessionUser } from "../../engine/types";
|
|
3
|
+
import type { Logger } from "../../logging/types";
|
|
4
|
+
import { resolveAuthClaims } from "../auth-claims-resolver";
|
|
5
|
+
|
|
6
|
+
type TestLogger = {
|
|
7
|
+
readonly log: Logger;
|
|
8
|
+
readonly warn: ReturnType<typeof vi.fn>;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
function makeTestLogger(): TestLogger {
|
|
12
|
+
const warn = vi.fn();
|
|
13
|
+
const info = vi.fn();
|
|
14
|
+
const error = vi.fn();
|
|
15
|
+
const debug = vi.fn();
|
|
16
|
+
const logger: Logger = {
|
|
17
|
+
warn,
|
|
18
|
+
info,
|
|
19
|
+
error,
|
|
20
|
+
debug,
|
|
21
|
+
child: () => logger,
|
|
22
|
+
};
|
|
23
|
+
return { log: logger, warn };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const testUser: SessionUser = {
|
|
27
|
+
id: "11111111-0000-4000-8000-000000000001",
|
|
28
|
+
tenantId: "22222222-0000-4000-8000-000000000001",
|
|
29
|
+
roles: ["User"],
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
// The resolver doesn't actually USE the AuthClaimsContext — it passes it
|
|
33
|
+
// through to each hook. For unit tests we only need the shape; we don't
|
|
34
|
+
// construct a real TenantDb.
|
|
35
|
+
const stubContext: AuthClaimsContext = {
|
|
36
|
+
db: {} as AuthClaimsContext["db"],
|
|
37
|
+
queryAs: async () => {
|
|
38
|
+
throw new Error("queryAs not implemented in stub");
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
function hooks(...entries: AuthClaimsHookDef[]): readonly AuthClaimsHookDef[] {
|
|
43
|
+
return entries;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
describe("resolveAuthClaims — empty", () => {
|
|
47
|
+
test("zero hooks registered → empty record, contextFactory not called", async () => {
|
|
48
|
+
const factory = vi.fn();
|
|
49
|
+
const result = await resolveAuthClaims({
|
|
50
|
+
user: testUser,
|
|
51
|
+
hooks: [],
|
|
52
|
+
contextFactory: factory,
|
|
53
|
+
});
|
|
54
|
+
expect(result).toEqual({});
|
|
55
|
+
expect(factory).not.toHaveBeenCalled();
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe("resolveAuthClaims — single hook", () => {
|
|
60
|
+
test("feature's keys get prefixed with <featureName>:", async () => {
|
|
61
|
+
const result = await resolveAuthClaims({
|
|
62
|
+
user: testUser,
|
|
63
|
+
hooks: hooks({
|
|
64
|
+
featureName: "drivers",
|
|
65
|
+
fn: async () => ({ teamId: "t-1", regionId: "r-7" }),
|
|
66
|
+
}),
|
|
67
|
+
contextFactory: () => stubContext,
|
|
68
|
+
});
|
|
69
|
+
expect(result).toEqual({
|
|
70
|
+
"drivers:teamId": "t-1",
|
|
71
|
+
"drivers:regionId": "r-7",
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("receives the user and context handed in", async () => {
|
|
76
|
+
const fn = vi.fn(async () => ({}));
|
|
77
|
+
const factory = vi.fn(() => stubContext);
|
|
78
|
+
await resolveAuthClaims({
|
|
79
|
+
user: testUser,
|
|
80
|
+
hooks: hooks({ featureName: "any", fn }),
|
|
81
|
+
contextFactory: factory,
|
|
82
|
+
});
|
|
83
|
+
expect(fn).toHaveBeenCalledWith(testUser, stubContext);
|
|
84
|
+
expect(factory).toHaveBeenCalledWith(testUser);
|
|
85
|
+
expect(factory).toHaveBeenCalledTimes(1);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe("resolveAuthClaims — multiple hooks", () => {
|
|
90
|
+
test("two features run in parallel and both contribute claims", async () => {
|
|
91
|
+
const ordering: string[] = [];
|
|
92
|
+
const result = await resolveAuthClaims({
|
|
93
|
+
user: testUser,
|
|
94
|
+
hooks: hooks(
|
|
95
|
+
{
|
|
96
|
+
featureName: "drivers",
|
|
97
|
+
fn: async () => {
|
|
98
|
+
ordering.push("drivers");
|
|
99
|
+
return { teamId: "t-1" };
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
featureName: "billing",
|
|
104
|
+
fn: async () => {
|
|
105
|
+
ordering.push("billing");
|
|
106
|
+
return { plan: "pro" };
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
),
|
|
110
|
+
contextFactory: () => stubContext,
|
|
111
|
+
});
|
|
112
|
+
expect(result).toEqual({
|
|
113
|
+
"drivers:teamId": "t-1",
|
|
114
|
+
"billing:plan": "pro",
|
|
115
|
+
});
|
|
116
|
+
// Promise.allSettled fires both; order of push() can vary but both must run.
|
|
117
|
+
expect(ordering.sort()).toEqual(["billing", "drivers"]);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("auto-prefix eliminates cross-feature collisions on the SAME inner key", async () => {
|
|
121
|
+
const result = await resolveAuthClaims({
|
|
122
|
+
user: testUser,
|
|
123
|
+
hooks: hooks(
|
|
124
|
+
{ featureName: "drivers", fn: async () => ({ teamId: "t-drivers" }) },
|
|
125
|
+
{ featureName: "billing", fn: async () => ({ teamId: "t-billing" }) },
|
|
126
|
+
),
|
|
127
|
+
contextFactory: () => stubContext,
|
|
128
|
+
});
|
|
129
|
+
expect(result).toEqual({
|
|
130
|
+
"drivers:teamId": "t-drivers",
|
|
131
|
+
"billing:teamId": "t-billing",
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
describe("resolveAuthClaims — same-feature duplicate hooks", () => {
|
|
137
|
+
test("last-wins within one feature", async () => {
|
|
138
|
+
// Two r.authClaims() calls in the same feature both return `plan`.
|
|
139
|
+
// The second registration wins (matches the JWT-layer spread semantics).
|
|
140
|
+
const result = await resolveAuthClaims({
|
|
141
|
+
user: testUser,
|
|
142
|
+
hooks: hooks(
|
|
143
|
+
{ featureName: "billing", fn: async () => ({ plan: "free" }) },
|
|
144
|
+
{ featureName: "billing", fn: async () => ({ plan: "enterprise" }) },
|
|
145
|
+
),
|
|
146
|
+
contextFactory: () => stubContext,
|
|
147
|
+
});
|
|
148
|
+
expect(result["billing:plan"]).toBe("enterprise");
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
describe("resolveAuthClaims — error policy (best-effort)", () => {
|
|
153
|
+
test("one hook throws → its feature contributes nothing, others unaffected", async () => {
|
|
154
|
+
const { log, warn } = makeTestLogger();
|
|
155
|
+
const result = await resolveAuthClaims({
|
|
156
|
+
user: testUser,
|
|
157
|
+
hooks: hooks(
|
|
158
|
+
{
|
|
159
|
+
featureName: "broken",
|
|
160
|
+
fn: async () => {
|
|
161
|
+
throw new Error("db blew up");
|
|
162
|
+
},
|
|
163
|
+
},
|
|
164
|
+
{
|
|
165
|
+
featureName: "healthy",
|
|
166
|
+
fn: async () => ({ teamId: "t-1" }),
|
|
167
|
+
},
|
|
168
|
+
),
|
|
169
|
+
contextFactory: () => stubContext,
|
|
170
|
+
log,
|
|
171
|
+
});
|
|
172
|
+
// healthy feature's claims make it into the record; broken feature's do not.
|
|
173
|
+
expect(result).toEqual({ "healthy:teamId": "t-1" });
|
|
174
|
+
// The warn should mention the feature name so ops can pinpoint the hook.
|
|
175
|
+
expect(warn).toHaveBeenCalledTimes(1);
|
|
176
|
+
const [warnMsg, warnData] = warn.mock.calls[0] ?? [];
|
|
177
|
+
expect(String(warnMsg)).toContain("authClaims");
|
|
178
|
+
expect(warnData).toMatchObject({ featureName: "broken" });
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("every hook throws → result is still an object (login does not fail)", async () => {
|
|
182
|
+
const { log } = makeTestLogger();
|
|
183
|
+
const result = await resolveAuthClaims({
|
|
184
|
+
user: testUser,
|
|
185
|
+
hooks: hooks(
|
|
186
|
+
{
|
|
187
|
+
featureName: "a",
|
|
188
|
+
fn: async () => {
|
|
189
|
+
throw new Error("x");
|
|
190
|
+
},
|
|
191
|
+
},
|
|
192
|
+
{
|
|
193
|
+
featureName: "b",
|
|
194
|
+
fn: async () => {
|
|
195
|
+
throw new Error("y");
|
|
196
|
+
},
|
|
197
|
+
},
|
|
198
|
+
),
|
|
199
|
+
contextFactory: () => stubContext,
|
|
200
|
+
log,
|
|
201
|
+
});
|
|
202
|
+
expect(result).toEqual({});
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
describe("resolveAuthClaims — reserved separator guard", () => {
|
|
207
|
+
test("a key containing ':' is dropped with a warning (keeps prefix owned by framework)", async () => {
|
|
208
|
+
const { log, warn } = makeTestLogger();
|
|
209
|
+
const result = await resolveAuthClaims({
|
|
210
|
+
user: testUser,
|
|
211
|
+
hooks: hooks({
|
|
212
|
+
featureName: "smart",
|
|
213
|
+
// A feature that tries to sneak in its own prefix would bypass
|
|
214
|
+
// auto-prefix intent — so we reject such keys rather than double-prefix.
|
|
215
|
+
fn: async () => ({ "evil:teamId": "nope", okKey: "yes" }),
|
|
216
|
+
}),
|
|
217
|
+
contextFactory: () => stubContext,
|
|
218
|
+
log,
|
|
219
|
+
});
|
|
220
|
+
expect(result).toEqual({ "smart:okKey": "yes" });
|
|
221
|
+
expect(warn).toHaveBeenCalledTimes(1);
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
describe("resolveAuthClaims — declaredKeys drift warning", () => {
|
|
226
|
+
test("hook returns a key NOT in declaredKeys → warn, but claim still lands in JWT", async () => {
|
|
227
|
+
const { log, warn } = makeTestLogger();
|
|
228
|
+
const result = await resolveAuthClaims({
|
|
229
|
+
user: testUser,
|
|
230
|
+
hooks: hooks({
|
|
231
|
+
featureName: "drivers",
|
|
232
|
+
// Feature declared `teamId` but the hook also returns `rouge` — the
|
|
233
|
+
// resolver flags it (typo/rename protection) but still merges it in,
|
|
234
|
+
// honoring best-effort.
|
|
235
|
+
declaredKeys: new Set(["teamId"]),
|
|
236
|
+
fn: async () => ({ teamId: "t-1", rouge: "x" }),
|
|
237
|
+
}),
|
|
238
|
+
contextFactory: () => stubContext,
|
|
239
|
+
log,
|
|
240
|
+
});
|
|
241
|
+
expect(result).toEqual({
|
|
242
|
+
"drivers:teamId": "t-1",
|
|
243
|
+
"drivers:rouge": "x",
|
|
244
|
+
});
|
|
245
|
+
expect(warn).toHaveBeenCalledTimes(1);
|
|
246
|
+
const [, data] = warn.mock.calls[0] ?? [];
|
|
247
|
+
expect(data).toMatchObject({ featureName: "drivers", undeclaredKey: "rouge" });
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
test("declaredKeys undefined → never warn, backwards-compat for ad-hoc hooks", async () => {
|
|
251
|
+
const { log, warn } = makeTestLogger();
|
|
252
|
+
await resolveAuthClaims({
|
|
253
|
+
user: testUser,
|
|
254
|
+
hooks: hooks({
|
|
255
|
+
featureName: "legacy",
|
|
256
|
+
// No declaredKeys on this hook — legacy hooks don't opt in.
|
|
257
|
+
fn: async () => ({ anything: 1, else: 2 }),
|
|
258
|
+
}),
|
|
259
|
+
contextFactory: () => stubContext,
|
|
260
|
+
log,
|
|
261
|
+
});
|
|
262
|
+
expect(warn).not.toHaveBeenCalled();
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
test("all returned keys declared → silent", async () => {
|
|
266
|
+
const { log, warn } = makeTestLogger();
|
|
267
|
+
await resolveAuthClaims({
|
|
268
|
+
user: testUser,
|
|
269
|
+
hooks: hooks({
|
|
270
|
+
featureName: "drivers",
|
|
271
|
+
declaredKeys: new Set(["teamId", "regionId"]),
|
|
272
|
+
fn: async () => ({ teamId: "t-1", regionId: 7 }),
|
|
273
|
+
}),
|
|
274
|
+
contextFactory: () => stubContext,
|
|
275
|
+
log,
|
|
276
|
+
});
|
|
277
|
+
expect(warn).not.toHaveBeenCalled();
|
|
278
|
+
});
|
|
279
|
+
});
|