@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,982 @@
|
|
|
1
|
+
import type { Context } from "hono";
|
|
2
|
+
import { Hono } from "hono";
|
|
3
|
+
import { deleteCookie, setCookie } from "hono/cookie";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { createSystemUser } from "../engine/system-user";
|
|
6
|
+
import { type SessionUser, SYSTEM_TENANT_ID, type TenantId } from "../engine/types";
|
|
7
|
+
import { NotFoundError } from "../errors";
|
|
8
|
+
import type { Dispatcher } from "../pipeline/dispatcher";
|
|
9
|
+
import { Routes } from "./api-constants";
|
|
10
|
+
import {
|
|
11
|
+
AUTH_COOKIE_NAME,
|
|
12
|
+
type AuthSessionChecker,
|
|
13
|
+
type AuthSessionStatus,
|
|
14
|
+
CSRF_COOKIE_NAME,
|
|
15
|
+
getUser,
|
|
16
|
+
} from "./auth-middleware";
|
|
17
|
+
import type { JwtHelper } from "./jwt";
|
|
18
|
+
import { generateToken } from "./tokens";
|
|
19
|
+
|
|
20
|
+
// Cookie lifetime must track the JWT's exp claim — both are issued together,
|
|
21
|
+
// both reference the same session. jwt.ts's createJwtHelper hardcodes
|
|
22
|
+
// setExpirationTime("24h"); if that ever becomes configurable this constant
|
|
23
|
+
// follows it.
|
|
24
|
+
const JWT_TTL_SECONDS = 24 * 60 * 60;
|
|
25
|
+
|
|
26
|
+
// Resolves the Secure cookie flag. Locked off in dev/test so Playwright
|
|
27
|
+
// against http://localhost:… can actually receive the cookie. Production
|
|
28
|
+
// flips it on — browsers drop Secure cookies on http, so a misconfigured
|
|
29
|
+
// prod deploy would silently break login rather than fail loud.
|
|
30
|
+
function cookieSecure(): boolean {
|
|
31
|
+
return process.env["NODE_ENV"] === "production";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Double-entry cookie write used at login and switch-tenant. kumiko_auth is
|
|
35
|
+
// the HttpOnly carrier of the JWT; kumiko_csrf is the JS-readable token the
|
|
36
|
+
// web client echoes in X-CSRF-Token on every state-changing request. Both
|
|
37
|
+
// cookies share lifetime and SameSite so a stale auth-cookie can't outlive
|
|
38
|
+
// its csrf partner (or vice versa) and leave the client in a half-logged-in
|
|
39
|
+
// state that would trip the csrf-middleware on every retry.
|
|
40
|
+
function setAuthCookies(
|
|
41
|
+
c: Context,
|
|
42
|
+
opts: { token: string; csrfToken: string; sameSite: "lax" | "strict" },
|
|
43
|
+
): void {
|
|
44
|
+
const sameSite = opts.sameSite === "strict" ? "Strict" : "Lax";
|
|
45
|
+
const common = {
|
|
46
|
+
secure: cookieSecure(),
|
|
47
|
+
sameSite,
|
|
48
|
+
path: "/",
|
|
49
|
+
maxAge: JWT_TTL_SECONDS,
|
|
50
|
+
} as const;
|
|
51
|
+
|
|
52
|
+
setCookie(c, AUTH_COOKIE_NAME, opts.token, { ...common, httpOnly: true });
|
|
53
|
+
// Intentionally NOT HttpOnly — the web client has to read this from
|
|
54
|
+
// document.cookie to include it in the X-CSRF-Token request header.
|
|
55
|
+
setCookie(c, CSRF_COOKIE_NAME, opts.csrfToken, { ...common, httpOnly: false });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function clearAuthCookies(c: Context): void {
|
|
59
|
+
deleteCookie(c, AUTH_COOKIE_NAME, { path: "/" });
|
|
60
|
+
deleteCookie(c, CSRF_COOKIE_NAME, { path: "/" });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Body schema for POST /auth/login. Enforced BEFORE rate-limit so that a
|
|
64
|
+
// malformed body (`email: 42`, missing password, …) returns 400 instead of
|
|
65
|
+
// crashing on `.toLowerCase()` and leaking a 500 that never increments the
|
|
66
|
+
// login counter — previous wiring let attackers spam the endpoint without
|
|
67
|
+
// tripping the bucket.
|
|
68
|
+
const LoginBody = z.object({
|
|
69
|
+
email: z.string().min(1),
|
|
70
|
+
password: z.string(),
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
const ResetPasswordBody = z.object({
|
|
74
|
+
token: z.string().min(1),
|
|
75
|
+
newPassword: z.string().min(8).max(200),
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
const VerifyEmailBody = z.object({
|
|
79
|
+
token: z.string().min(1),
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const SignupConfirmBody = z.object({
|
|
83
|
+
token: z.string().min(1),
|
|
84
|
+
password: z.string().min(8).max(200),
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const InviteAcceptBody = z.object({
|
|
88
|
+
token: z.string().min(1),
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const InviteAcceptWithLoginBody = z.object({
|
|
92
|
+
token: z.string().min(1),
|
|
93
|
+
email: z.string().email(),
|
|
94
|
+
password: z.string().min(8).max(200),
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
const InviteSignupCompleteBody = z.object({
|
|
98
|
+
token: z.string().min(1),
|
|
99
|
+
password: z.string().min(8).max(200),
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// Shape guard for "handler not registered" — the only legitimate reason to
|
|
103
|
+
// fall back to a single-tenant reply on /auth/tenants or /auth/switch-tenant.
|
|
104
|
+
// Every other error (DB down, revoker throws, access denied, …) has to
|
|
105
|
+
// propagate — otherwise we'd silently paper over outages.
|
|
106
|
+
function isUnknownHandlerError(e: unknown): boolean {
|
|
107
|
+
if (!(e instanceof NotFoundError)) return false;
|
|
108
|
+
// @cast-boundary error-details — KumikoError.details shape is per-error
|
|
109
|
+
const details = e.details as { entity?: string } | undefined;
|
|
110
|
+
return details?.entity === "handler";
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
type MembershipRow = {
|
|
114
|
+
userId: string;
|
|
115
|
+
tenantId: TenantId;
|
|
116
|
+
roles: string[];
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
// Guest identity used for unauthenticated calls (e.g. login). The "all" role
|
|
120
|
+
// lets framework access checks pass for handlers declared with roles: ["all"].
|
|
121
|
+
// `id` is the zero-uuid so it flows through event-store columns cleanly.
|
|
122
|
+
const GUEST_USER: SessionUser = {
|
|
123
|
+
id: "00000000-0000-0000-0000-000000000000",
|
|
124
|
+
tenantId: SYSTEM_TENANT_ID,
|
|
125
|
+
roles: ["all"],
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
// Pluggable rate-limiter for POST /auth/login. Returning `false` blocks the
|
|
129
|
+
// request with 429 before the login handler runs — use this to slow down
|
|
130
|
+
// brute-force attempts. The framework ships a default in-memory impl; apps
|
|
131
|
+
// can swap it for a Redis-backed one for multi-node deployments.
|
|
132
|
+
export type LoginRateLimiter = {
|
|
133
|
+
check(key: string): Promise<boolean>;
|
|
134
|
+
// Called on successful login so a legitimate user's counter gets reset.
|
|
135
|
+
reset(key: string): Promise<void>;
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
// Per-session metadata forwarded to the sessionCreator. Captured at login
|
|
139
|
+
// time so the sessions feature can store IP/UA alongside each record for
|
|
140
|
+
// session-list UIs ("your devices") and security-audit flows.
|
|
141
|
+
export type SessionMetadata = {
|
|
142
|
+
readonly ip: string;
|
|
143
|
+
readonly userAgent: string;
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
// Invoked on a successful login (and on switch-tenant) so an app can persist
|
|
147
|
+
// a session record and return its ID. The returned string is embedded in the
|
|
148
|
+
// JWT's `jti` claim and echoed back as `SessionUser.sid` on every request.
|
|
149
|
+
// When the callback is not wired, JWTs are stateless — they remain valid
|
|
150
|
+
// until expiration, with no server-side revocation. The framework stays
|
|
151
|
+
// agnostic about WHERE sessions live (DB, Redis, memory); that's the
|
|
152
|
+
// sessions feature's job.
|
|
153
|
+
export type SessionCreator = (user: SessionUser, meta: SessionMetadata) => Promise<string>;
|
|
154
|
+
|
|
155
|
+
// Invoked on logout and on switch-tenant. No-op if the app hasn't wired a
|
|
156
|
+
// sessionCreator; in that case the framework never populates a `sid` and
|
|
157
|
+
// there's nothing to revoke.
|
|
158
|
+
export type SessionRevoker = (sid: string) => Promise<void>;
|
|
159
|
+
|
|
160
|
+
// Status reported by the session-store to the auth-middleware. The concrete
|
|
161
|
+
// type lives on auth-middleware to keep the tight coupling visible there;
|
|
162
|
+
// auth-routes just re-uses the alias for the AuthRoutesConfig surface.
|
|
163
|
+
// "live" → let the request through; anything else → 401 with the status as
|
|
164
|
+
// the response reason, so logs/metrics can distinguish "revoked" from
|
|
165
|
+
// "expired" from "someone forged a sid that never existed".
|
|
166
|
+
export type SessionChecker = AuthSessionChecker;
|
|
167
|
+
export type { AuthSessionStatus };
|
|
168
|
+
|
|
169
|
+
export type AuthRoutesConfig = {
|
|
170
|
+
membershipQuery: string; // qualified query handler name, e.g. config.membershipQuery
|
|
171
|
+
// Optional: qualified query handler that returns the user-row inkl.
|
|
172
|
+
// globaler Rollen (`roles` als JSON-encoded string[]). Wenn gesetzt,
|
|
173
|
+
// ruft switch-tenant diese Query und mergt die globalen Rollen mit den
|
|
174
|
+
// tenant-membership-Rollen — so überlebt SystemAdmin (oder ähnliche
|
|
175
|
+
// tenant-unabhängige Rollen) den Tenant-Switch. Erwartete Shape:
|
|
176
|
+
// `{id, roles?: string|null}`. Default nicht gesetzt = kein merge
|
|
177
|
+
// (backwards-compat für Apps ohne globale Rollen).
|
|
178
|
+
userQuery?: string;
|
|
179
|
+
// Optional: qualified write handler for login. When set, POST /auth/login
|
|
180
|
+
// dispatches to this handler with a guest identity and issues a JWT on
|
|
181
|
+
// success. Handler must return { kind: "auth-session", session: SessionUser }.
|
|
182
|
+
loginHandler?: string;
|
|
183
|
+
// Maps feature-specific login error codes to HTTP status codes. Unknown
|
|
184
|
+
// errors default to 400. Keeps the framework unaware of concrete auth codes.
|
|
185
|
+
loginErrorStatusMap?: Readonly<Record<string, number>>;
|
|
186
|
+
// Rate-limit for POST /auth/login. Defaults to in-memory 10/5min per
|
|
187
|
+
// (ip + email) bucket. Pass `null` to disable (tests, trusted networks).
|
|
188
|
+
loginRateLimit?: LoginRateLimiter | null;
|
|
189
|
+
// Session-lifecycle callbacks. When both are wired the JWT carries a `jti`
|
|
190
|
+
// (sid) and the server can revoke individual sessions (logout, compromise,
|
|
191
|
+
// password-change). When unwired the framework issues plain stateless JWTs.
|
|
192
|
+
// Mirrors the loginRateLimit pattern: feature-owned storage, framework-
|
|
193
|
+
// owned routing.
|
|
194
|
+
sessionCreator?: SessionCreator;
|
|
195
|
+
sessionRevoker?: SessionRevoker;
|
|
196
|
+
// Consulted by the auth-middleware on every authenticated request when the
|
|
197
|
+
// incoming JWT carries a `jti`. Paired with sessionCreator: create a sid
|
|
198
|
+
// at login, check it here on every request. Leaving this empty disables
|
|
199
|
+
// the revocation path — old JWTs stay valid until they expire naturally.
|
|
200
|
+
sessionChecker?: SessionChecker;
|
|
201
|
+
// When true, a JWT WITHOUT a sid is rejected. Use during deploy-rollouts
|
|
202
|
+
// once all fresh JWTs emit a sid and the legacy stateless tokens are
|
|
203
|
+
// expected to have expired. Default false keeps old tokens working.
|
|
204
|
+
sessionStrictMode?: boolean;
|
|
205
|
+
// Password-reset flow. When wired, POST /auth/request-password-reset and
|
|
206
|
+
// POST /auth/reset-password are mounted as public routes. The framework
|
|
207
|
+
// dispatches to the feature-level handlers (authoring QNs typically come
|
|
208
|
+
// from `AuthHandlers.requestPasswordReset` / `.resetPassword`) and
|
|
209
|
+
// invokes sendResetEmail with the freshly-signed token when a user was
|
|
210
|
+
// actually found. Silent-success: every response to request-reset is
|
|
211
|
+
// { isSuccess: true } regardless of whether the email existed.
|
|
212
|
+
passwordReset?: PasswordResetConfig;
|
|
213
|
+
// Email-verification flow. Symmetric to passwordReset.
|
|
214
|
+
emailVerification?: EmailVerificationConfig;
|
|
215
|
+
// Self-Signup (Magic-Link). Wenn wired, mountet POST
|
|
216
|
+
// /auth/signup-request + /auth/signup-confirm. Confirm returnt JWT-
|
|
217
|
+
// Cookie + Session-Body wie login.
|
|
218
|
+
signup?: SignupConfig;
|
|
219
|
+
// Tenant-Invite (Magic-Link). Mountet 3 accept-Routes für die 3
|
|
220
|
+
// Branches (logged-in / anon-existing-email / anon-new-email).
|
|
221
|
+
invite?: InviteConfig;
|
|
222
|
+
// SameSite flag for the HttpOnly auth cookie + JS-readable csrf cookie
|
|
223
|
+
// issued by /auth/login and /auth/switch-tenant.
|
|
224
|
+
// "lax" (default) — blocks cross-site POSTs entirely (which is what
|
|
225
|
+
// CSRF relies on) while allowing top-level GET navigation
|
|
226
|
+
// from external sites. Email deep-links (invite, magic-link,
|
|
227
|
+
// notification click) keep working.
|
|
228
|
+
// "strict" — blocks the cookie on ANY cross-site navigation including
|
|
229
|
+
// top-level GETs. Strongest CSRF control but silently breaks
|
|
230
|
+
// email deep-links — opt-in for banking / high-security apps
|
|
231
|
+
// that don't ship deep-linkable emails.
|
|
232
|
+
// The framework always pairs the cookie with a Double-Submit CSRF token
|
|
233
|
+
// (see csrf-middleware), so "lax" is defense-in-depth, not defense-alone.
|
|
234
|
+
cookieSameSite?: "lax" | "strict";
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
export type PasswordResetConfig = {
|
|
238
|
+
// Qualified name of the request handler (the one that emits either
|
|
239
|
+
// { kind: "reset-requested", ... } or { kind: "no-op" }).
|
|
240
|
+
requestHandler: string;
|
|
241
|
+
// Qualified name of the confirm handler (token + newPassword → set).
|
|
242
|
+
confirmHandler: string;
|
|
243
|
+
// Invoked only when the request handler returns kind=reset-requested.
|
|
244
|
+
// Given the signed token + target email, the callback builds the URL
|
|
245
|
+
// into the caller's app and hands it to whatever delivery channel the
|
|
246
|
+
// app wires up. Errors bubble as 5xx so silent drop-on-send can't hide
|
|
247
|
+
// an outgoing-mail outage behind a green response.
|
|
248
|
+
sendResetEmail: (args: { email: string; resetUrl: string; expiresAt: string }) => Promise<void>;
|
|
249
|
+
// Base URL of the app that hosts the reset form. The route appends
|
|
250
|
+
// `?token=…` so you should NOT include a trailing `?` or `#`. Example:
|
|
251
|
+
// "https://app.example.com/reset-password"
|
|
252
|
+
appResetUrl: string;
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
export type EmailVerificationConfig = {
|
|
256
|
+
requestHandler: string;
|
|
257
|
+
confirmHandler: string;
|
|
258
|
+
sendVerificationEmail: (args: {
|
|
259
|
+
email: string;
|
|
260
|
+
verificationUrl: string;
|
|
261
|
+
expiresAt: string;
|
|
262
|
+
}) => Promise<void>;
|
|
263
|
+
// URL of the app page that receives the `?token=…` parameter and POSTs
|
|
264
|
+
// it to /auth/verify-email on submit.
|
|
265
|
+
appVerifyUrl: string;
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
// Tenant-Invite Magic-Link. Drei Accept-Branches für klare Separation:
|
|
269
|
+
// - acceptHandler: logged-in User akzeptiert via JWT (Branch 1)
|
|
270
|
+
// - acceptWithLoginHandler: anon User mit existing email (Branch 2)
|
|
271
|
+
// - signupCompleteHandler: anon User mit neuer email (Branch 3)
|
|
272
|
+
// Branch 2+3 minten JWT analog signup-confirm.
|
|
273
|
+
export type InviteConfig = {
|
|
274
|
+
// Qualified handler names
|
|
275
|
+
readonly acceptHandler: string;
|
|
276
|
+
readonly acceptWithLoginHandler: string;
|
|
277
|
+
readonly signupCompleteHandler: string;
|
|
278
|
+
// Mail-Callback. Token-URL wird von der App-Page (z.B. /invite/accept)
|
|
279
|
+
// an den User geschickt; der Frontend leitet je nach User-State (eingeloggt
|
|
280
|
+
// / anon mit existing-email / anon mit neuer email) auf den passenden
|
|
281
|
+
// Branch-Endpoint.
|
|
282
|
+
readonly sendInviteEmail: (args: {
|
|
283
|
+
email: string;
|
|
284
|
+
inviteUrl: string;
|
|
285
|
+
expiresAt: string;
|
|
286
|
+
role: string;
|
|
287
|
+
}) => Promise<void>;
|
|
288
|
+
readonly appAcceptUrl: string;
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
// Magic-Link Self-Signup. Anders als reset/verify NICHT HMAC-signed —
|
|
292
|
+
// der Token ist opaque random, Redis ist Source of Truth. Confirm
|
|
293
|
+
// returnt `{ kind: "auth-session", session, tenantKey }` analog zu
|
|
294
|
+
// loginHandler, sodass die Route JWT minten + Cookies setzen kann
|
|
295
|
+
// (Auto-Login direkt nach Activation, kein zweiter login-Roundtrip).
|
|
296
|
+
export type SignupConfig = {
|
|
297
|
+
// Qualified name of the request handler (typisch
|
|
298
|
+
// AuthHandlers.signupRequest).
|
|
299
|
+
requestHandler: string;
|
|
300
|
+
// Qualified name of the confirm handler (typisch
|
|
301
|
+
// AuthHandlers.signupConfirm). Returnt SessionUser-Shape — die
|
|
302
|
+
// Route wickelt das wie einen erfolgreichen login.
|
|
303
|
+
confirmHandler: string;
|
|
304
|
+
// Mail-Callback. Token-URL wird als `${appActivationUrl}?token=…`
|
|
305
|
+
// an die App-Page geleitet.
|
|
306
|
+
sendActivationEmail: (args: {
|
|
307
|
+
email: string;
|
|
308
|
+
activationUrl: string;
|
|
309
|
+
expiresAt: string;
|
|
310
|
+
}) => Promise<void>;
|
|
311
|
+
// Base URL of the app page that receives the `?token=…` parameter
|
|
312
|
+
// (typisch /signup/complete). KEIN trailing `?` oder `#`.
|
|
313
|
+
appActivationUrl: string;
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
// Extract `ip` and `user-agent` for the sessionCreator.
|
|
317
|
+
// Hono's `c.req.header(...)` returns undefined for missing headers; we coerce
|
|
318
|
+
// them to "unknown" rather than throwing because auth-routes are a public
|
|
319
|
+
// surface and we don't want header-sniffing bugs to break login.
|
|
320
|
+
function requestMeta(c: { req: { header(name: string): string | undefined } }): SessionMetadata {
|
|
321
|
+
const ip =
|
|
322
|
+
c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ??
|
|
323
|
+
c.req.header("x-real-ip") ??
|
|
324
|
+
"unknown";
|
|
325
|
+
const userAgent = c.req.header("user-agent") ?? "unknown";
|
|
326
|
+
return { ip, userAgent };
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// Default: in-memory fixed window. Fine for a single Node process; for
|
|
330
|
+
// multi-process deployments, inject a Redis-backed LoginRateLimiter instead
|
|
331
|
+
// so attempts can't be spread across replicas.
|
|
332
|
+
//
|
|
333
|
+
// Memory management: entries expire after `windowMs` but the map entries
|
|
334
|
+
// themselves linger until something touches them. To stop the map from
|
|
335
|
+
// growing unbounded (a single attacker can create entries with different
|
|
336
|
+
// `ip|email` buckets at ~req rate), we opportunistically sweep expired
|
|
337
|
+
// entries when the map crosses `sweepThreshold` keys and hard-cap total
|
|
338
|
+
// entries at `maxEntries` — oldest ones get dropped first.
|
|
339
|
+
export function createInMemoryLoginRateLimiter(
|
|
340
|
+
maxAttempts = 10,
|
|
341
|
+
windowMs = 5 * 60_000,
|
|
342
|
+
{
|
|
343
|
+
maxEntries = 10_000,
|
|
344
|
+
sweepThreshold = 1_000,
|
|
345
|
+
}: { maxEntries?: number; sweepThreshold?: number } = {},
|
|
346
|
+
): LoginRateLimiter {
|
|
347
|
+
const hits = new Map<string, { count: number; resetAt: number }>();
|
|
348
|
+
|
|
349
|
+
function sweepExpired(now: number): void {
|
|
350
|
+
for (const [k, entry] of hits) {
|
|
351
|
+
if (entry.resetAt <= now) hits.delete(k);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function enforceCap(): void {
|
|
356
|
+
// Map iteration is insertion-order, so the oldest entries are first.
|
|
357
|
+
// Drop from the front until we're back under the cap.
|
|
358
|
+
// skip: under the cap, nothing to do
|
|
359
|
+
if (hits.size <= maxEntries) return;
|
|
360
|
+
const toDrop = hits.size - maxEntries;
|
|
361
|
+
let dropped = 0;
|
|
362
|
+
for (const k of hits.keys()) {
|
|
363
|
+
if (dropped >= toDrop) break;
|
|
364
|
+
hits.delete(k);
|
|
365
|
+
dropped++;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
return {
|
|
370
|
+
async check(key) {
|
|
371
|
+
const now = Date.now();
|
|
372
|
+
if (hits.size >= sweepThreshold) sweepExpired(now);
|
|
373
|
+
|
|
374
|
+
const entry = hits.get(key);
|
|
375
|
+
if (!entry || entry.resetAt <= now) {
|
|
376
|
+
hits.set(key, { count: 1, resetAt: now + windowMs });
|
|
377
|
+
enforceCap();
|
|
378
|
+
return true;
|
|
379
|
+
}
|
|
380
|
+
if (entry.count >= maxAttempts) return false;
|
|
381
|
+
entry.count++;
|
|
382
|
+
return true;
|
|
383
|
+
},
|
|
384
|
+
async reset(key) {
|
|
385
|
+
hits.delete(key);
|
|
386
|
+
},
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
export function createAuthRoutes(
|
|
391
|
+
dispatcher: Dispatcher,
|
|
392
|
+
jwt: JwtHelper,
|
|
393
|
+
config: AuthRoutesConfig,
|
|
394
|
+
): Hono {
|
|
395
|
+
const api = new Hono();
|
|
396
|
+
// Default to "lax": CSRF control comes from the double-submit token, and
|
|
397
|
+
// "lax" keeps email deep-links (invite, magic-link, notification click)
|
|
398
|
+
// working. High-security apps can opt into "strict" — see AuthRoutesConfig.
|
|
399
|
+
const cookieSameSite = config.cookieSameSite ?? "lax";
|
|
400
|
+
|
|
401
|
+
// POST /auth/login — public endpoint (bypasses auth middleware via PUBLIC_API_PATHS).
|
|
402
|
+
// The configured login handler authenticates and returns a SessionUser;
|
|
403
|
+
// the route signs the JWT and hands it back to the client.
|
|
404
|
+
if (config.loginHandler) {
|
|
405
|
+
const loginQn = config.loginHandler;
|
|
406
|
+
const statusMap = config.loginErrorStatusMap ?? {};
|
|
407
|
+
// Default to in-memory limiter unless the caller opted out via null.
|
|
408
|
+
const rateLimiter =
|
|
409
|
+
config.loginRateLimit === null
|
|
410
|
+
? null
|
|
411
|
+
: (config.loginRateLimit ?? createInMemoryLoginRateLimiter());
|
|
412
|
+
|
|
413
|
+
api.post(Routes.authLogin, async (c) => {
|
|
414
|
+
const raw = await c.req.json().catch(() => null);
|
|
415
|
+
const parsed = LoginBody.safeParse(raw);
|
|
416
|
+
if (!parsed.success) {
|
|
417
|
+
return c.json({ isSuccess: false, error: "invalid_body" }, 400);
|
|
418
|
+
}
|
|
419
|
+
const body = parsed.data;
|
|
420
|
+
|
|
421
|
+
// Client IP derivation is shared between rate-limit check and reset,
|
|
422
|
+
// so compute once. Falls back to "unknown" when no proxy header is
|
|
423
|
+
// present — consistent bucket for direct-to-server test setups.
|
|
424
|
+
const clientIp =
|
|
425
|
+
c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ??
|
|
426
|
+
c.req.header("x-real-ip") ??
|
|
427
|
+
"unknown";
|
|
428
|
+
const rateLimitKey = `${clientIp}|${body.email.toLowerCase()}`;
|
|
429
|
+
|
|
430
|
+
if (rateLimiter) {
|
|
431
|
+
// Bucket by both IP and email so a single guessed password can't
|
|
432
|
+
// block a real user from logging in, but also so one abuser can't
|
|
433
|
+
// just cycle emails.
|
|
434
|
+
const allowed = await rateLimiter.check(rateLimitKey);
|
|
435
|
+
if (!allowed) {
|
|
436
|
+
return c.json({ isSuccess: false, error: "rate_limited" }, 429);
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
const result = await dispatcher.write(loginQn, body, GUEST_USER);
|
|
441
|
+
|
|
442
|
+
if (!result.isSuccess) {
|
|
443
|
+
// Feature-specific auth reason codes arrive via UnprocessableError.details.reason
|
|
444
|
+
// (e.g. "invalid_credentials", "user_locked"). Fall back to the KumikoError code
|
|
445
|
+
// so unmapped cases still get a sensible status.
|
|
446
|
+
// @cast-boundary error-details — KumikoError.details shape is per-error
|
|
447
|
+
const reason =
|
|
448
|
+
(result.error.details as { reason?: string } | undefined)?.reason ?? result.error.code;
|
|
449
|
+
// @cast-boundary engine-payload — statusMap value union narrows to the http-status union
|
|
450
|
+
const status = (statusMap[reason] ?? result.error.httpStatus) as 400 | 401 | 403 | 500;
|
|
451
|
+
return c.json({ isSuccess: false, error: result.error }, status);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// @cast-boundary engine-payload — generic dispatcher.write result for auth-session handler
|
|
455
|
+
const data = result.data as { kind: "auth-session"; session: SessionUser };
|
|
456
|
+
|
|
457
|
+
// Session creation (optional). Creating the session BEFORE signing the
|
|
458
|
+
// JWT is load-bearing: the sid must exist on the server before the
|
|
459
|
+
// token that references it can be handed out, otherwise a fast client
|
|
460
|
+
// could arrive at an auth-middleware check before the insert commits.
|
|
461
|
+
let sessionForJwt: SessionUser = data.session;
|
|
462
|
+
if (config.sessionCreator) {
|
|
463
|
+
const sid = await config.sessionCreator(data.session, requestMeta(c));
|
|
464
|
+
sessionForJwt = { ...data.session, sid };
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
const token = await jwt.sign(sessionForJwt);
|
|
468
|
+
|
|
469
|
+
if (rateLimiter) {
|
|
470
|
+
await rateLimiter.reset(rateLimitKey);
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// Cookie transport (web): set HttpOnly auth cookie + JS-readable csrf
|
|
474
|
+
// cookie. Bearer transport (native) reads the token from the body
|
|
475
|
+
// below — the token is returned for both, so a Bearer client that
|
|
476
|
+
// ignores Set-Cookie keeps working without any server-side knowledge
|
|
477
|
+
// of which transport this client will use next.
|
|
478
|
+
const csrfToken = generateToken();
|
|
479
|
+
setAuthCookies(c, { token, csrfToken, sameSite: cookieSameSite });
|
|
480
|
+
|
|
481
|
+
return c.json({
|
|
482
|
+
isSuccess: true,
|
|
483
|
+
token,
|
|
484
|
+
user: { id: data.session.id, tenantId: data.session.tenantId, roles: data.session.roles },
|
|
485
|
+
});
|
|
486
|
+
});
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// POST /auth/request-password-reset + /auth/reset-password — public.
|
|
490
|
+
// Silent-success on request (no enumeration), typed failure on confirm.
|
|
491
|
+
// Rate-limit covered via config.rateLimit.auth (Sprint G.5 L2, /auth/*).
|
|
492
|
+
if (config.passwordReset) {
|
|
493
|
+
const pr = config.passwordReset;
|
|
494
|
+
registerTokenRequestRoute({
|
|
495
|
+
api,
|
|
496
|
+
dispatcher,
|
|
497
|
+
path: Routes.authRequestPasswordReset,
|
|
498
|
+
requestHandler: pr.requestHandler,
|
|
499
|
+
successKind: "reset-requested",
|
|
500
|
+
appBaseUrl: pr.appResetUrl,
|
|
501
|
+
sendEmail: ({ email, url, expiresAt }) =>
|
|
502
|
+
pr.sendResetEmail({ email, resetUrl: url, expiresAt }),
|
|
503
|
+
});
|
|
504
|
+
registerTokenConfirmRoute({
|
|
505
|
+
api,
|
|
506
|
+
dispatcher,
|
|
507
|
+
path: Routes.authResetPassword,
|
|
508
|
+
confirmHandler: pr.confirmHandler,
|
|
509
|
+
schema: ResetPasswordBody,
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// Email-verification mirrors password-reset.
|
|
514
|
+
if (config.emailVerification) {
|
|
515
|
+
const ev = config.emailVerification;
|
|
516
|
+
registerTokenRequestRoute({
|
|
517
|
+
api,
|
|
518
|
+
dispatcher,
|
|
519
|
+
path: Routes.authRequestEmailVerification,
|
|
520
|
+
requestHandler: ev.requestHandler,
|
|
521
|
+
successKind: "verification-requested",
|
|
522
|
+
appBaseUrl: ev.appVerifyUrl,
|
|
523
|
+
sendEmail: ({ email, url, expiresAt }) =>
|
|
524
|
+
ev.sendVerificationEmail({ email, verificationUrl: url, expiresAt }),
|
|
525
|
+
});
|
|
526
|
+
registerTokenConfirmRoute({
|
|
527
|
+
api,
|
|
528
|
+
dispatcher,
|
|
529
|
+
path: Routes.authVerifyEmail,
|
|
530
|
+
confirmHandler: ev.confirmHandler,
|
|
531
|
+
schema: VerifyEmailBody,
|
|
532
|
+
});
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// Self-Signup (Magic-Link). Request mountet wie reset/verify den
|
|
536
|
+
// silent-success-Pfad mit Token-Mail. Confirm ist anders: returnt
|
|
537
|
+
// SessionUser → die Route mintet JWT + setzt Cookies (Auto-Login
|
|
538
|
+
// direkt nach Activation, kein zweiter Login-Roundtrip nötig).
|
|
539
|
+
if (config.signup) {
|
|
540
|
+
const sg = config.signup;
|
|
541
|
+
registerTokenRequestRoute({
|
|
542
|
+
api,
|
|
543
|
+
dispatcher,
|
|
544
|
+
path: Routes.authSignupRequest,
|
|
545
|
+
requestHandler: sg.requestHandler,
|
|
546
|
+
successKind: "signup-requested",
|
|
547
|
+
appBaseUrl: sg.appActivationUrl,
|
|
548
|
+
sendEmail: ({ email, url, expiresAt }) =>
|
|
549
|
+
sg.sendActivationEmail({ email, activationUrl: url, expiresAt }),
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
api.post(Routes.authSignupConfirm, async (c) => {
|
|
553
|
+
const raw = await c.req.json().catch(() => null);
|
|
554
|
+
const parsed = SignupConfirmBody.safeParse(raw);
|
|
555
|
+
if (!parsed.success) {
|
|
556
|
+
return c.json({ isSuccess: false, error: "invalid_body" }, 400);
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
const result = await dispatcher.write(sg.confirmHandler, parsed.data, GUEST_USER);
|
|
560
|
+
|
|
561
|
+
if (!result.isSuccess) {
|
|
562
|
+
// 422 für invalid_signup_token (handler-level UnprocessableError).
|
|
563
|
+
// @cast-boundary engine-payload — KumikoError.httpStatus narrows to the http-status union
|
|
564
|
+
const status = result.error.httpStatus as 400 | 401 | 403 | 422 | 500;
|
|
565
|
+
return c.json({ isSuccess: false, error: result.error }, status);
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
// @cast-boundary engine-payload — generic dispatcher.write result for signup-confirm
|
|
569
|
+
const data = result.data as {
|
|
570
|
+
kind: "auth-session";
|
|
571
|
+
session: SessionUser;
|
|
572
|
+
tenantKey: string;
|
|
573
|
+
};
|
|
574
|
+
|
|
575
|
+
// Session-Creator analog login — wenn wired, sid wird im JWT
|
|
576
|
+
// platziert und der Server kann später den Session revoken
|
|
577
|
+
// (Logout, Compromise).
|
|
578
|
+
let sessionForJwt: SessionUser = data.session;
|
|
579
|
+
if (config.sessionCreator) {
|
|
580
|
+
const sid = await config.sessionCreator(data.session, requestMeta(c));
|
|
581
|
+
sessionForJwt = { ...data.session, sid };
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
const token = await jwt.sign(sessionForJwt);
|
|
585
|
+
const csrfToken = generateToken();
|
|
586
|
+
setAuthCookies(c, { token, csrfToken, sameSite: cookieSameSite });
|
|
587
|
+
|
|
588
|
+
return c.json({
|
|
589
|
+
isSuccess: true,
|
|
590
|
+
token,
|
|
591
|
+
user: {
|
|
592
|
+
id: data.session.id,
|
|
593
|
+
tenantId: data.session.tenantId,
|
|
594
|
+
roles: data.session.roles,
|
|
595
|
+
},
|
|
596
|
+
// tenantKey für Post-Signup-Redirect zu /<tenantKey>/.
|
|
597
|
+
// Anders als der login-response der nur `tenants[]` braucht
|
|
598
|
+
// (User wählt im Switcher), kennt der signup nur EINE
|
|
599
|
+
// membership — die Frontend-UI nimmt das direkt als Redirect-
|
|
600
|
+
// Target.
|
|
601
|
+
tenantKey: data.tenantKey,
|
|
602
|
+
});
|
|
603
|
+
});
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
// Tenant-Invite Magic-Link. 3 separate Routes für 3 Accept-Branches:
|
|
607
|
+
if (config.invite) {
|
|
608
|
+
const inv = config.invite;
|
|
609
|
+
|
|
610
|
+
// Branch 1: logged-in User klickt Invite-Link → Membership-Add im
|
|
611
|
+
// invited Tenant (NICHT Tenant-Switch — User bleibt in seiner
|
|
612
|
+
// aktuellen Session, kann später via Tenant-Switcher wechseln).
|
|
613
|
+
// Requires JWT (siehe PUBLIC_API_PATHS — invite-accept ist NICHT
|
|
614
|
+
// public, im Gegensatz zu acceptWithLogin/signupComplete).
|
|
615
|
+
api.post(Routes.authInviteAccept, async (c) => {
|
|
616
|
+
const user = getUser(c);
|
|
617
|
+
const raw = await c.req.json().catch(() => null);
|
|
618
|
+
const parsed = InviteAcceptBody.safeParse(raw);
|
|
619
|
+
if (!parsed.success) {
|
|
620
|
+
return c.json({ isSuccess: false, error: "invalid_body" }, 400);
|
|
621
|
+
}
|
|
622
|
+
const result = await dispatcher.write(inv.acceptHandler, parsed.data, user);
|
|
623
|
+
if (!result.isSuccess) {
|
|
624
|
+
// @cast-boundary engine-payload — KumikoError.httpStatus
|
|
625
|
+
const status = result.error.httpStatus as 400 | 401 | 403 | 422 | 500;
|
|
626
|
+
return c.json({ isSuccess: false, error: result.error }, status);
|
|
627
|
+
}
|
|
628
|
+
// @cast-boundary engine-payload — generic dispatcher.write result
|
|
629
|
+
const data = result.data as {
|
|
630
|
+
kind: "invite-accepted";
|
|
631
|
+
tenantId: TenantId;
|
|
632
|
+
role: string;
|
|
633
|
+
alreadyMember: boolean;
|
|
634
|
+
};
|
|
635
|
+
return c.json({
|
|
636
|
+
isSuccess: true,
|
|
637
|
+
tenantId: data.tenantId,
|
|
638
|
+
role: data.role,
|
|
639
|
+
alreadyMember: data.alreadyMember,
|
|
640
|
+
});
|
|
641
|
+
});
|
|
642
|
+
|
|
643
|
+
// Branch 2: anon User mit existing email — Login + Accept in einem
|
|
644
|
+
// Roundtrip. JWT-mint analog signup-confirm.
|
|
645
|
+
api.post(Routes.authInviteAcceptWithLogin, async (c) => {
|
|
646
|
+
const raw = await c.req.json().catch(() => null);
|
|
647
|
+
const parsed = InviteAcceptWithLoginBody.safeParse(raw);
|
|
648
|
+
if (!parsed.success) {
|
|
649
|
+
return c.json({ isSuccess: false, error: "invalid_body" }, 400);
|
|
650
|
+
}
|
|
651
|
+
const result = await dispatcher.write(inv.acceptWithLoginHandler, parsed.data, GUEST_USER);
|
|
652
|
+
if (!result.isSuccess) {
|
|
653
|
+
const status = result.error.httpStatus as 400 | 401 | 403 | 422 | 500;
|
|
654
|
+
return c.json({ isSuccess: false, error: result.error }, status);
|
|
655
|
+
}
|
|
656
|
+
const data = result.data as {
|
|
657
|
+
kind: "auth-session";
|
|
658
|
+
session: SessionUser;
|
|
659
|
+
tenantId: TenantId;
|
|
660
|
+
role: string;
|
|
661
|
+
};
|
|
662
|
+
let sessionForJwt: SessionUser = data.session;
|
|
663
|
+
if (config.sessionCreator) {
|
|
664
|
+
const sid = await config.sessionCreator(data.session, requestMeta(c));
|
|
665
|
+
sessionForJwt = { ...data.session, sid };
|
|
666
|
+
}
|
|
667
|
+
const token = await jwt.sign(sessionForJwt);
|
|
668
|
+
const csrfToken = generateToken();
|
|
669
|
+
setAuthCookies(c, { token, csrfToken, sameSite: cookieSameSite });
|
|
670
|
+
return c.json({
|
|
671
|
+
isSuccess: true,
|
|
672
|
+
token,
|
|
673
|
+
user: {
|
|
674
|
+
id: data.session.id,
|
|
675
|
+
tenantId: data.session.tenantId,
|
|
676
|
+
roles: data.session.roles,
|
|
677
|
+
},
|
|
678
|
+
tenantId: data.tenantId,
|
|
679
|
+
role: data.role,
|
|
680
|
+
});
|
|
681
|
+
});
|
|
682
|
+
|
|
683
|
+
// Branch 3: anon User mit neuer email — User+Membership entstehen,
|
|
684
|
+
// KEIN neuer Tenant. JWT-mint.
|
|
685
|
+
api.post(Routes.authInviteSignupComplete, async (c) => {
|
|
686
|
+
const raw = await c.req.json().catch(() => null);
|
|
687
|
+
const parsed = InviteSignupCompleteBody.safeParse(raw);
|
|
688
|
+
if (!parsed.success) {
|
|
689
|
+
return c.json({ isSuccess: false, error: "invalid_body" }, 400);
|
|
690
|
+
}
|
|
691
|
+
const result = await dispatcher.write(inv.signupCompleteHandler, parsed.data, GUEST_USER);
|
|
692
|
+
if (!result.isSuccess) {
|
|
693
|
+
const status = result.error.httpStatus as 400 | 401 | 403 | 422 | 500;
|
|
694
|
+
return c.json({ isSuccess: false, error: result.error }, status);
|
|
695
|
+
}
|
|
696
|
+
const data = result.data as {
|
|
697
|
+
kind: "auth-session";
|
|
698
|
+
session: SessionUser;
|
|
699
|
+
tenantId: TenantId;
|
|
700
|
+
role: string;
|
|
701
|
+
};
|
|
702
|
+
let sessionForJwt: SessionUser = data.session;
|
|
703
|
+
if (config.sessionCreator) {
|
|
704
|
+
const sid = await config.sessionCreator(data.session, requestMeta(c));
|
|
705
|
+
sessionForJwt = { ...data.session, sid };
|
|
706
|
+
}
|
|
707
|
+
const token = await jwt.sign(sessionForJwt);
|
|
708
|
+
const csrfToken = generateToken();
|
|
709
|
+
setAuthCookies(c, { token, csrfToken, sameSite: cookieSameSite });
|
|
710
|
+
return c.json({
|
|
711
|
+
isSuccess: true,
|
|
712
|
+
token,
|
|
713
|
+
user: {
|
|
714
|
+
id: data.session.id,
|
|
715
|
+
tenantId: data.session.tenantId,
|
|
716
|
+
roles: data.session.roles,
|
|
717
|
+
},
|
|
718
|
+
tenantId: data.tenantId,
|
|
719
|
+
role: data.role,
|
|
720
|
+
});
|
|
721
|
+
});
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
// POST /auth/logout — revokes the current session. Requires a valid JWT so
|
|
725
|
+
// the middleware has already populated `user.sid` from the `jti` claim. If
|
|
726
|
+
// the app hasn't wired a sessionRevoker, logout is effectively a no-op on
|
|
727
|
+
// the server — the client can just drop the token.
|
|
728
|
+
api.post(Routes.authLogout, async (c) => {
|
|
729
|
+
const user = getUser(c);
|
|
730
|
+
if (config.sessionRevoker && user.sid) {
|
|
731
|
+
await config.sessionRevoker(user.sid);
|
|
732
|
+
}
|
|
733
|
+
// Clear cookies on the cookie-transport path. Idempotent — clearing a
|
|
734
|
+
// missing cookie is a no-op, so bearer-only clients aren't affected.
|
|
735
|
+
clearAuthCookies(c);
|
|
736
|
+
return c.json({ isSuccess: true });
|
|
737
|
+
});
|
|
738
|
+
|
|
739
|
+
// GET /auth/tenants — list tenants the current user belongs to
|
|
740
|
+
api.get(Routes.authTenants, async (c) => {
|
|
741
|
+
const user = getUser(c);
|
|
742
|
+
|
|
743
|
+
try {
|
|
744
|
+
// System-scoped: membershipQuery is access-locked to system-role.
|
|
745
|
+
// @cast-boundary engine-payload — generic dispatcher.query result
|
|
746
|
+
const memberships = (await dispatcher.query(
|
|
747
|
+
config.membershipQuery,
|
|
748
|
+
{ userId: user.id },
|
|
749
|
+
createSystemUser(user.tenantId),
|
|
750
|
+
)) as MembershipRow[];
|
|
751
|
+
|
|
752
|
+
return c.json({
|
|
753
|
+
tenants: memberships.map((m) => ({
|
|
754
|
+
tenantId: m.tenantId,
|
|
755
|
+
roles: m.roles,
|
|
756
|
+
})),
|
|
757
|
+
activeTenantId: user.tenantId,
|
|
758
|
+
});
|
|
759
|
+
} catch (e) {
|
|
760
|
+
// Only legitimate fallback: the app hasn't wired membershipQuery at
|
|
761
|
+
// all. A DB fault or a permission failure has to bubble up so ops
|
|
762
|
+
// sees it — collapsing them into "just your current tenant" hides
|
|
763
|
+
// outages behind a UI that looks fine.
|
|
764
|
+
if (!isUnknownHandlerError(e)) throw e;
|
|
765
|
+
return c.json({
|
|
766
|
+
tenants: [{ tenantId: user.tenantId, roles: [...user.roles] }],
|
|
767
|
+
activeTenantId: user.tenantId,
|
|
768
|
+
});
|
|
769
|
+
}
|
|
770
|
+
});
|
|
771
|
+
|
|
772
|
+
// POST /auth/switch-tenant — switch to a different tenant
|
|
773
|
+
api.post(Routes.authSwitchTenant, async (c) => {
|
|
774
|
+
const user = getUser(c);
|
|
775
|
+
const body = await c.req.json<{ tenantId: TenantId }>();
|
|
776
|
+
const targetTenantId = body.tenantId;
|
|
777
|
+
|
|
778
|
+
if (targetTenantId === user.tenantId) {
|
|
779
|
+
return c.json({ error: "already_in_tenant" }, 400);
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
// Check membership — uses the system identity because membershipQuery is
|
|
783
|
+
// locked to the system role. The auth-route is trusted server code; it
|
|
784
|
+
// asks the question on the user's behalf, not as the user.
|
|
785
|
+
let memberships: MembershipRow[];
|
|
786
|
+
try {
|
|
787
|
+
// @cast-boundary engine-payload — generic dispatcher.query result
|
|
788
|
+
memberships = (await dispatcher.query(
|
|
789
|
+
config.membershipQuery,
|
|
790
|
+
{ userId: user.id },
|
|
791
|
+
createSystemUser(user.tenantId),
|
|
792
|
+
)) as MembershipRow[];
|
|
793
|
+
} catch (e) {
|
|
794
|
+
// No membershipQuery wired → switching tenants is just not offered in
|
|
795
|
+
// this deployment. Any other error propagates so a broken query handler
|
|
796
|
+
// surfaces as a real 5xx instead of a misleading 400.
|
|
797
|
+
if (!isUnknownHandlerError(e)) throw e;
|
|
798
|
+
return c.json({ error: "tenant_switch_not_available" }, 400);
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
const membership = memberships.find((m) => m.tenantId === targetTenantId);
|
|
802
|
+
if (!membership) {
|
|
803
|
+
return c.json({ error: "not_a_member" }, 403);
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
// Globale Rollen aus user-feature lesen wenn userQuery wired —
|
|
807
|
+
// tenant-unabhängige Rollen (SystemAdmin etc.) überleben so den
|
|
808
|
+
// tenant-switch. `parseRoles` liegt utils-side, hier inline-deserialize
|
|
809
|
+
// damit das Framework keine bundled-features-Imports kriegt.
|
|
810
|
+
let globalRoles: readonly string[] = [];
|
|
811
|
+
if (config.userQuery) {
|
|
812
|
+
try {
|
|
813
|
+
// @cast-boundary engine-payload — dispatcher.query returnt unknown,
|
|
814
|
+
// userQuery liefert per AuthUserRow-Contract eine row mit roles-spalte.
|
|
815
|
+
const userRow = (await dispatcher.query(
|
|
816
|
+
config.userQuery,
|
|
817
|
+
{ id: user.id },
|
|
818
|
+
createSystemUser(user.tenantId),
|
|
819
|
+
)) as { roles?: string | null } | null;
|
|
820
|
+
const raw = userRow?.roles;
|
|
821
|
+
if (typeof raw === "string" && raw.length > 0) {
|
|
822
|
+
// @cast-boundary db-row — userTable.roles is JSON-encoded string[] per AuthUserRow contract
|
|
823
|
+
const parsed = JSON.parse(raw) as unknown;
|
|
824
|
+
if (Array.isArray(parsed) && parsed.every((r) => typeof r === "string")) {
|
|
825
|
+
globalRoles = parsed;
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
} catch (e) {
|
|
829
|
+
// Non-fatal: globale Rollen kann nicht aufgelöst werden → switch
|
|
830
|
+
// läuft weiter mit nur tenant-rollen. Server-error mit nur dem
|
|
831
|
+
// Cause ohne Stack hochwerfen wäre für die UX schlimmer als ein
|
|
832
|
+
// Tenant-Switch ohne SystemAdmin (User merkt's und meldet's). Log
|
|
833
|
+
// it via the dispatcher so Ops sieht's.
|
|
834
|
+
if (!isUnknownHandlerError(e)) throw e;
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
// Issue new JWT with the target tenant and its roles. Claims MUST be
|
|
839
|
+
// recomputed for the new tenant — stale claims from the previous
|
|
840
|
+
// tenant would leak identity facts across tenancies (e.g. teamId from
|
|
841
|
+
// tenant A accidentally surviving into tenant B's session). The
|
|
842
|
+
// resolver runs each feature's r.authClaims() hook under the new
|
|
843
|
+
// TenantDb scope.
|
|
844
|
+
const mergedRoles = Array.from(new Set([...globalRoles, ...membership.roles]));
|
|
845
|
+
const targetSession: SessionUser = {
|
|
846
|
+
id: user.id,
|
|
847
|
+
tenantId: targetTenantId,
|
|
848
|
+
roles: mergedRoles,
|
|
849
|
+
};
|
|
850
|
+
const claims = await dispatcher.resolveAuthClaims(targetSession);
|
|
851
|
+
let sessionForJwt: SessionUser =
|
|
852
|
+
Object.keys(claims).length > 0 ? { ...targetSession, claims } : targetSession;
|
|
853
|
+
|
|
854
|
+
// Session rotation: revoke old sid BEFORE creating the new one so a
|
|
855
|
+
// creator failure leaves the user logged-out cleanly rather than with
|
|
856
|
+
// two live sessions. Client must log in again on creator-throw. A
|
|
857
|
+
// revoker/creator that actually throws (Redis down, DB deadlock) surfaces
|
|
858
|
+
// as a 5xx — swallowing it into tenant_switch_not_available was hiding
|
|
859
|
+
// real outages.
|
|
860
|
+
if (config.sessionRevoker && user.sid) {
|
|
861
|
+
await config.sessionRevoker(user.sid);
|
|
862
|
+
}
|
|
863
|
+
if (config.sessionCreator) {
|
|
864
|
+
const sid = await config.sessionCreator(sessionForJwt, requestMeta(c));
|
|
865
|
+
sessionForJwt = { ...sessionForJwt, sid };
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
const newToken = await jwt.sign(sessionForJwt);
|
|
869
|
+
|
|
870
|
+
// Rotate both cookies in lock-step with the new JWT. A fresh csrfToken
|
|
871
|
+
// is minted so a compromised csrf-value (e.g. leaked via a bug in the
|
|
872
|
+
// app's JS) can't cross a tenant boundary. Bearer-only clients get
|
|
873
|
+
// the new token in the body below — their Set-Cookie is a no-op
|
|
874
|
+
// because the browser never sent cookies.
|
|
875
|
+
const csrfToken = generateToken();
|
|
876
|
+
setAuthCookies(c, { token: newToken, csrfToken, sameSite: cookieSameSite });
|
|
877
|
+
|
|
878
|
+
return c.json({ token: newToken, tenantId: targetTenantId, roles: mergedRoles });
|
|
879
|
+
});
|
|
880
|
+
|
|
881
|
+
return api;
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
// --- shared route builders for token flows ---------------------------------
|
|
885
|
+
// Password-reset and email-verification share the exact same HTTP-shape:
|
|
886
|
+
// request-route emits a token → optional sendEmail callback → silent-success,
|
|
887
|
+
// confirm-route validates token + does the state change → typed failure or
|
|
888
|
+
// 200. Before this extraction both flows carried ~45 LOC of nearly-identical
|
|
889
|
+
// body-parse / dispatch / url-build / response plumbing. The helpers keep
|
|
890
|
+
// the public-facing silent-success invariant in one place — changing how
|
|
891
|
+
// the framework handles "invalid_body" on a public token endpoint is now
|
|
892
|
+
// one edit, not two.
|
|
893
|
+
|
|
894
|
+
type TokenRequestData = {
|
|
895
|
+
kind: string;
|
|
896
|
+
email: string;
|
|
897
|
+
token: string;
|
|
898
|
+
expiresAt: string;
|
|
899
|
+
};
|
|
900
|
+
|
|
901
|
+
type TokenNoOp = { kind: "no-op" };
|
|
902
|
+
|
|
903
|
+
function registerTokenRequestRoute(opts: {
|
|
904
|
+
api: Hono;
|
|
905
|
+
dispatcher: Dispatcher;
|
|
906
|
+
path: string;
|
|
907
|
+
requestHandler: string;
|
|
908
|
+
// Discriminator the feature handler emits when it actually minted a token
|
|
909
|
+
// (vs. the silent no-op for unknown/already-handled users).
|
|
910
|
+
successKind: string;
|
|
911
|
+
// Base URL of the receiving app page. `?token=…` is appended with proper
|
|
912
|
+
// separator handling so the caller's URL may or may not carry existing
|
|
913
|
+
// query params.
|
|
914
|
+
appBaseUrl: string;
|
|
915
|
+
sendEmail: (args: { email: string; url: string; expiresAt: string }) => Promise<void>;
|
|
916
|
+
}): void {
|
|
917
|
+
const body = RequestTokenBody;
|
|
918
|
+
opts.api.post(opts.path, async (c) => {
|
|
919
|
+
const raw = await c.req.json().catch(() => null);
|
|
920
|
+
const parsed = body.safeParse(raw);
|
|
921
|
+
// Malformed body → silent success. A probing client mustn't learn
|
|
922
|
+
// anything from the shape of their input.
|
|
923
|
+
if (!parsed.success) return c.json({ isSuccess: true });
|
|
924
|
+
|
|
925
|
+
const result = await opts.dispatcher.write(
|
|
926
|
+
opts.requestHandler,
|
|
927
|
+
{ email: parsed.data.email },
|
|
928
|
+
GUEST_USER,
|
|
929
|
+
);
|
|
930
|
+
|
|
931
|
+
// Handler-level failures (only legitimate reason: misconfiguration) are
|
|
932
|
+
// silently swallowed — observability logs capture them for ops.
|
|
933
|
+
if (result.isSuccess) {
|
|
934
|
+
// @cast-boundary engine-payload — generic dispatcher.write result narrowed by handler-emitted kind
|
|
935
|
+
const data = result.data as TokenRequestData | TokenNoOp;
|
|
936
|
+
if (data.kind === opts.successKind) {
|
|
937
|
+
// TS narrowt nicht durch generic successKind (string, kein literal) —
|
|
938
|
+
// die kind-Gleichheit garantiert den TokenRequestData-Branch hier.
|
|
939
|
+
const requested = data as TokenRequestData; // @cast-boundary engine-payload
|
|
940
|
+
const sep = opts.appBaseUrl.includes("?") ? "&" : "?";
|
|
941
|
+
const url = `${opts.appBaseUrl}${sep}token=${encodeURIComponent(requested.token)}`;
|
|
942
|
+
await opts.sendEmail({
|
|
943
|
+
email: requested.email,
|
|
944
|
+
url,
|
|
945
|
+
expiresAt: requested.expiresAt,
|
|
946
|
+
});
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
return c.json({ isSuccess: true });
|
|
951
|
+
});
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
function registerTokenConfirmRoute(opts: {
|
|
955
|
+
api: Hono;
|
|
956
|
+
dispatcher: Dispatcher;
|
|
957
|
+
path: string;
|
|
958
|
+
confirmHandler: string;
|
|
959
|
+
// Endpoint-specific body shape (reset has `newPassword`, verify doesn't).
|
|
960
|
+
schema: typeof ResetPasswordBody | typeof VerifyEmailBody;
|
|
961
|
+
}): void {
|
|
962
|
+
opts.api.post(opts.path, async (c) => {
|
|
963
|
+
const raw = await c.req.json().catch(() => null);
|
|
964
|
+
const parsed = opts.schema.safeParse(raw);
|
|
965
|
+
if (!parsed.success) {
|
|
966
|
+
return c.json({ isSuccess: false, error: "invalid_body" }, 400);
|
|
967
|
+
}
|
|
968
|
+
const result = await opts.dispatcher.write(opts.confirmHandler, parsed.data, GUEST_USER);
|
|
969
|
+
if (!result.isSuccess) {
|
|
970
|
+
const status = result.error.httpStatus as 400 | 401 | 403 | 422 | 500;
|
|
971
|
+
return c.json({ isSuccess: false, error: result.error }, status);
|
|
972
|
+
}
|
|
973
|
+
return c.json({ isSuccess: true });
|
|
974
|
+
});
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
// Shared request-body shape for request-password-reset + request-email-
|
|
978
|
+
// verification. Extracted so the two flows stay in sync when the schema
|
|
979
|
+
// gains optional fields (locale, deviceId, …).
|
|
980
|
+
const RequestTokenBody = z.object({
|
|
981
|
+
email: z.email(),
|
|
982
|
+
});
|