@cosmicdrift/kumiko-framework 1.0.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -38
- package/package.json +21 -2
- package/src/__tests__/anonymous-access.integration.test.ts +185 -0
- package/src/__tests__/consumer-cli.integration.test.ts +172 -0
- package/src/__tests__/entity-permalink-open.integration.test.ts +94 -0
- package/src/__tests__/full-stack.integration.test.ts +1 -1
- package/src/__tests__/schema-cli-temporal-polyfill.test.ts +48 -0
- package/src/__tests__/schema-cli.integration.test.ts +50 -0
- package/src/__tests__/store-table.integration.test.ts +93 -0
- package/src/api/__tests__/api.test.ts +373 -0
- package/src/api/__tests__/auth-middleware-anonymous-access-boot.test.ts +40 -0
- package/src/api/__tests__/auth-routes-cookie.test.ts +17 -1
- package/src/api/__tests__/auth-routes-invalid-body-invite.test.ts +253 -0
- package/src/api/__tests__/auth-routes-mfa-preauth-confirm.test.ts +222 -0
- package/src/api/__tests__/auth-routes-mfa-preauth-enable-start.test.ts +249 -0
- package/src/api/__tests__/auth-routes-mfa-verify.test.ts +202 -0
- package/src/api/__tests__/auth-routes-trusted-proxy.test.ts +135 -0
- package/src/api/__tests__/batch.integration.test.ts +101 -11
- package/src/api/__tests__/csrf-constants-sync.test.ts +20 -0
- package/src/api/__tests__/dispatcher-live.integration.test.ts +74 -0
- package/src/api/__tests__/jwt.test.ts +200 -1
- package/src/api/__tests__/login-rate-limiter-sweep.test.ts +50 -0
- package/src/api/__tests__/pat-scope.test.ts +36 -0
- package/src/api/__tests__/pii-leak-guard.integration.test.ts +103 -0
- package/src/api/__tests__/redis-login-rate-limiter.integration.test.ts +138 -0
- package/src/api/__tests__/request-id-middleware.test.ts +51 -0
- package/src/api/__tests__/server-boot-guards.test.ts +71 -0
- package/src/api/__tests__/server-jwt-ttl.test.ts +58 -0
- package/src/api/__tests__/sse-broker.test.ts +57 -0
- package/src/api/__tests__/sse-route.test.ts +4 -0
- package/src/api/api-constants.ts +11 -0
- package/src/api/auth-middleware.ts +248 -40
- package/src/api/auth-routes.ts +576 -95
- package/src/api/index.ts +13 -4
- package/src/api/jwt.ts +170 -11
- package/src/api/pat-scope.ts +14 -0
- package/src/api/pii-leak-guard.ts +47 -0
- package/src/api/request-context.ts +3 -0
- package/src/api/request-id-middleware.ts +2 -0
- package/src/api/routes.ts +169 -2
- package/src/api/server.ts +112 -16
- package/src/api/sse-broker.ts +39 -0
- package/src/bun-db/__tests__/PATTERN.md +0 -1
- package/src/bun-db/__tests__/coerce-row-temporal.test.ts +41 -0
- package/src/bun-db/__tests__/select-many-retry.test.ts +79 -0
- package/src/bun-db/__tests__/write-brand.test.ts +21 -0
- package/src/bun-db/connection.ts +3 -3
- package/src/bun-db/index.ts +2 -0
- package/src/bun-db/query.ts +105 -32
- package/src/consumer-cli.ts +134 -0
- package/src/crypto/__tests__/blind-index.test.ts +130 -0
- package/src/crypto/__tests__/event-pii.test.ts +161 -0
- package/src/crypto/__tests__/kek-rotation.integration.test.ts +180 -0
- package/src/crypto/__tests__/kms-adapter-contract.ts +134 -0
- package/src/crypto/__tests__/kms-adapter.contract.test.ts +4 -0
- package/src/crypto/__tests__/pg-kms-adapter.integration.test.ts +117 -0
- package/src/crypto/__tests__/pii-field-encryption.test.ts +376 -0
- package/src/crypto/__tests__/request-kms-cache.test.ts +74 -0
- package/src/crypto/__tests__/subject-resolver.test.ts +108 -0
- package/src/crypto/blind-index.ts +118 -0
- package/src/crypto/event-pii.ts +70 -0
- package/src/crypto/in-memory-kms-adapter.ts +50 -0
- package/src/crypto/index.ts +67 -0
- package/src/crypto/kms-adapter.ts +2 -0
- package/src/crypto/pg-kms-adapter.ts +295 -0
- package/src/crypto/pii-field-encryption.ts +248 -0
- package/src/crypto/request-kms-cache.ts +38 -0
- package/src/crypto/subject-resolver.ts +91 -0
- package/src/db/__tests__/assert-no-unreachable-live-rows.integration.test.ts +191 -0
- package/src/db/__tests__/blind-index.integration.test.ts +248 -0
- package/src/db/__tests__/build-filter-where.test.ts +34 -0
- package/src/db/__tests__/collect-table-metas.test.ts +10 -10
- package/src/db/__tests__/config-seed.integration.test.ts +13 -5
- package/src/db/__tests__/dialect-instant.test.ts +1 -4
- package/src/db/__tests__/entity-field-encryption.test.ts +7 -7
- package/src/db/__tests__/entity-table-meta-source.test.ts +50 -0
- package/src/db/__tests__/event-store-executor-context.pii-roundtrip.test.ts +67 -0
- package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +402 -0
- package/src/db/__tests__/event-store-executor.integration.test.ts +299 -29
- package/src/db/__tests__/feature-table-sources.test.ts +34 -0
- package/src/db/__tests__/implicit-projection-equivalence.integration.test.ts +118 -39
- package/src/db/__tests__/instant-to-driver-temporal.test.ts +21 -0
- package/src/db/__tests__/located-timestamp.test.ts +19 -0
- package/src/db/__tests__/migrate-generator.test.ts +21 -0
- package/src/db/__tests__/migrate-runner.test.ts +61 -0
- package/src/db/__tests__/number-field-fractional.integration.test.ts +58 -0
- package/src/db/__tests__/rebuild-marker.test.ts +13 -3
- package/src/db/__tests__/replay-migration-sql.test.ts +384 -0
- package/src/db/__tests__/schema-inspection.test.ts +7 -0
- package/src/db/__tests__/schema-migration.integration.test.ts +1 -1
- package/src/db/__tests__/table-builder-meta-lockstep.test.ts +39 -0
- package/src/db/__tests__/tenant-db-where-merge.test.ts +49 -3
- package/src/db/__tests__/tenant-db.integration.test.ts +6 -2
- package/src/db/api.ts +2 -2
- package/src/db/apply-entity-event.ts +18 -14
- package/src/db/blind-index-cleanup.ts +55 -0
- package/src/db/bun-provider.ts +2 -2
- package/src/db/collect-table-metas.ts +6 -7
- package/src/db/config-seed.ts +9 -9
- package/src/db/connection.ts +7 -12
- package/src/db/cursor.ts +1 -18
- package/src/db/dialect.ts +20 -26
- package/src/db/entity-field-encryption.ts +81 -24
- package/src/db/entity-table-meta-types.ts +2 -0
- package/src/db/entity-table-meta.ts +63 -90
- package/src/db/event-store-executor-context.ts +404 -0
- package/src/db/event-store-executor-read.ts +275 -0
- package/src/db/event-store-executor-write.ts +644 -0
- package/src/db/event-store-executor.ts +28 -1155
- package/src/db/feature-table-sources.ts +6 -5
- package/src/db/index.ts +20 -3
- package/src/db/located-timestamp.ts +4 -0
- package/src/db/migrate-generator.ts +39 -6
- package/src/db/migrate-runner.ts +107 -11
- package/src/db/pg-error.ts +9 -1
- package/src/db/postgres-provider.ts +2 -2
- package/src/db/queries/__tests__/event-store-idempotency-index.integration.test.ts +80 -0
- package/src/db/queries/backfill-pii.ts +277 -0
- package/src/db/queries/ddl.ts +45 -0
- package/src/db/queries/event-consumer.ts +35 -2
- package/src/db/queries/event-store.ts +126 -19
- package/src/db/queries/projection-rebuild.ts +22 -8
- package/src/db/queries/shadow-swap.ts +183 -0
- package/src/db/queries/test-stack.ts +4 -30
- package/src/db/query-api.ts +1 -0
- package/src/db/query.ts +1 -0
- package/src/db/rebuild-marker.ts +18 -2
- package/src/db/reference-data.ts +2 -3
- package/src/db/replay-migration-sql.ts +257 -0
- package/src/db/schema-inspection.ts +1 -1
- package/src/db/table-builder.ts +102 -71
- package/src/db/tenant-db.ts +15 -53
- package/src/engine/__tests__/boot-validator-action-wiring.test.ts +242 -0
- package/src/engine/__tests__/boot-validator-boot-check.test.ts +99 -0
- package/src/engine/__tests__/boot-validator-dashboard.test.ts +237 -0
- package/src/engine/__tests__/boot-validator-entity-list.test.ts +104 -0
- package/src/engine/__tests__/boot-validator-gdpr-storage.test.ts +7 -69
- package/src/engine/__tests__/boot-validator-i18n-keys.test.ts +26 -5
- package/src/engine/__tests__/boot-validator-located-timestamps.test.ts +3 -19
- package/src/engine/__tests__/boot-validator-pii-retention.test.ts +246 -4
- package/src/engine/__tests__/boot-validator.test.ts +219 -15
- package/src/engine/__tests__/build-app-schema.test.ts +82 -0
- package/src/engine/__tests__/build-config-feature-schema.test.ts +125 -0
- package/src/engine/__tests__/build-target.test.ts +3 -11
- package/src/engine/__tests__/codemod-pipeline.test.ts +152 -21
- package/src/engine/__tests__/config-helpers.test.ts +16 -0
- package/src/engine/__tests__/define-feature-entity-mapping.test.ts +6 -0
- package/src/engine/__tests__/define-roles.test.ts +21 -0
- package/src/engine/__tests__/engine.test.ts +163 -1
- package/src/engine/__tests__/entity-handlers.test.ts +80 -0
- package/src/engine/__tests__/event-migration-declarative.test.ts +65 -0
- package/src/engine/__tests__/event-type-map-augmentation.test.ts +24 -0
- package/src/engine/__tests__/extend-entity-projection.test.ts +123 -0
- package/src/engine/__tests__/factories-time.test.ts +2 -66
- package/src/engine/__tests__/feature-crud-shorthand.test.ts +28 -0
- package/src/engine/__tests__/feature-manifest.test.ts +31 -1
- package/src/engine/__tests__/field-access.test.ts +23 -1
- package/src/engine/__tests__/hook-phases.test.ts +5 -5
- package/src/engine/__tests__/membership-roles.test.ts +4 -10
- package/src/engine/__tests__/nav.test.ts +86 -1
- package/src/engine/__tests__/pipeline-engine.test.ts +9 -9
- package/src/engine/__tests__/pipeline-handler.integration.test.ts +18 -18
- package/src/engine/__tests__/pipeline-observability.integration.test.ts +2 -2
- package/src/engine/__tests__/pipeline-performance.integration.test.ts +3 -3
- package/src/engine/__tests__/pipeline-sub-pipelines.test.ts +9 -9
- package/src/engine/__tests__/post-query-hook.test.ts +7 -7
- package/src/engine/__tests__/registrar-object-form.test.ts +141 -0
- package/src/engine/__tests__/registry.test.ts +144 -0
- package/src/engine/__tests__/schema-builder.test.ts +18 -0
- package/src/engine/__tests__/screen.test.ts +147 -1
- package/src/engine/__tests__/soft-delete-cleanup.test.ts +3 -0
- package/src/engine/__tests__/store-table.test.ts +229 -0
- package/src/engine/__tests__/tier-resolver-extension.test.ts +19 -1
- package/src/engine/__tests__/{visual-tree-patterns.test.ts → tree-actions-patterns.test.ts} +7 -95
- package/src/engine/__tests__/validate-projection-allowlist.test.ts +15 -15
- package/src/engine/boot-validator/__tests__/config-deps.test.ts +92 -0
- package/src/engine/boot-validator/action-wiring.ts +149 -0
- package/src/engine/boot-validator/boot-check.ts +21 -0
- package/src/engine/boot-validator/config-deps.ts +40 -4
- package/src/engine/boot-validator/entity-handler.ts +20 -21
- package/src/engine/boot-validator/entity-list-screens.ts +88 -0
- package/src/engine/boot-validator/gdpr-storage.ts +0 -20
- package/src/engine/boot-validator/i18n-keys.ts +58 -4
- package/src/engine/boot-validator/index.ts +33 -12
- package/src/engine/boot-validator/nav.ts +125 -0
- package/src/engine/boot-validator/pii-retention.ts +95 -10
- package/src/engine/boot-validator/{screens-nav.ts → screens.ts} +214 -191
- package/src/engine/boot-validator/workspaces.ts +68 -0
- package/src/engine/build-app-schema.ts +16 -0
- package/src/engine/build-config-feature-schema.ts +44 -7
- package/src/engine/codemod/pipeline-codemod.ts +5 -5
- package/src/engine/config-helpers.ts +15 -0
- package/src/engine/constants.ts +32 -6
- package/src/engine/create-app.ts +11 -0
- package/src/engine/define-feature.ts +95 -947
- package/src/engine/define-handler.ts +28 -94
- package/src/engine/define-workflow.ts +1 -1
- package/src/engine/effective-features.ts +12 -2
- package/src/engine/entity-handlers.ts +76 -11
- package/src/engine/extensions/tenant-data.ts +19 -0
- package/src/engine/extensions/user-data.ts +29 -2
- package/src/engine/factories.ts +8 -49
- package/src/engine/feature-ast/__tests__/canonical-form.test.ts +26 -29
- package/src/engine/feature-ast/__tests__/fixtures/cross-file-registrar/feature.ts +9 -0
- package/src/engine/feature-ast/__tests__/fixtures/cross-file-registrar/screens.ts +4 -0
- package/src/engine/feature-ast/__tests__/parse-happy-path.test.ts +1 -2
- package/src/engine/feature-ast/__tests__/parse-real-features.test.ts +18 -8
- package/src/engine/feature-ast/__tests__/parse.test.ts +1640 -160
- package/src/engine/feature-ast/__tests__/patch.test.ts +206 -12
- package/src/engine/feature-ast/__tests__/patcher.test.ts +20 -23
- package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +380 -5
- package/src/engine/feature-ast/extractors/events.ts +322 -0
- package/src/engine/feature-ast/extractors/handlers.ts +234 -0
- package/src/engine/feature-ast/extractors/hooks.ts +243 -0
- package/src/engine/feature-ast/extractors/index.ts +34 -31
- package/src/engine/feature-ast/extractors/jobs-routes.ts +221 -0
- package/src/engine/feature-ast/extractors/projections-screens.ts +269 -0
- package/src/engine/feature-ast/extractors/round1.ts +3 -3
- package/src/engine/feature-ast/extractors/round5.ts +3 -3
- package/src/engine/feature-ast/extractors/round6.ts +2 -36
- package/src/engine/feature-ast/extractors/shared.ts +64 -6
- package/src/engine/feature-ast/index.ts +2 -4
- package/src/engine/feature-ast/parse.ts +130 -23
- package/src/engine/feature-ast/patch.ts +39 -50
- package/src/engine/feature-ast/patcher.ts +37 -38
- package/src/engine/feature-ast/patterns.ts +50 -62
- package/src/engine/feature-ast/render.ts +67 -44
- package/src/engine/feature-builder-state.ts +168 -0
- package/src/engine/feature-config-events-jobs.ts +403 -0
- package/src/engine/feature-entity-handlers.ts +208 -0
- package/src/engine/feature-manifest.ts +2 -1
- package/src/engine/feature-ui-extensions.ts +500 -0
- package/src/engine/field-access.ts +13 -2
- package/src/engine/field-helpers.ts +31 -0
- package/src/engine/handler-helpers.ts +26 -0
- package/src/engine/hook-helpers.ts +16 -0
- package/src/engine/index.ts +23 -7
- package/src/engine/membership-roles.ts +13 -0
- package/src/engine/object-form.ts +27 -0
- package/src/engine/ownership.ts +26 -79
- package/src/engine/pattern-library/__tests__/library.test.ts +7 -20
- package/src/engine/pattern-library/library.ts +44 -1152
- package/src/engine/pattern-library/mixed-schemas.ts +450 -0
- package/src/engine/pattern-library/opaque-schemas.ts +124 -0
- package/src/engine/pattern-library/shared-fields.ts +75 -0
- package/src/engine/pattern-library/static-schemas.ts +456 -0
- package/src/engine/pipeline.ts +6 -11
- package/src/engine/registry-facade.ts +362 -0
- package/src/engine/registry-ingest.ts +478 -0
- package/src/engine/registry-state.ts +388 -0
- package/src/engine/registry-validate.ts +642 -0
- package/src/engine/registry.ts +78 -1658
- package/src/engine/run-pipeline.ts +1 -1
- package/src/engine/schema-builder.ts +1 -0
- package/src/engine/screen-helpers.ts +54 -0
- package/src/engine/soft-delete-cleanup.ts +6 -2
- package/src/engine/steps/__tests__/duration-utils.test.ts +20 -0
- package/src/engine/steps/_duration-utils.ts +2 -0
- package/src/engine/steps/unsafe-projection-upsert.ts +1 -4
- package/src/engine/tier-resolver-extension.ts +3 -2
- package/src/engine/types/config.ts +2 -482
- package/src/engine/types/define-handler.ts +2 -0
- package/src/engine/types/entity-handlers.ts +2 -0
- package/src/engine/types/event-type-map.ts +1 -37
- package/src/engine/types/feature.ts +2 -972
- package/src/engine/types/fields.ts +2 -675
- package/src/engine/types/handlers.ts +2 -774
- package/src/engine/types/hooks.ts +2 -184
- package/src/engine/types/http-route.ts +1 -54
- package/src/engine/types/identifiers.ts +1 -47
- package/src/engine/types/index.ts +86 -40
- package/src/engine/types/nav.ts +2 -63
- package/src/engine/types/ownership.ts +2 -0
- package/src/engine/types/projection.ts +2 -138
- package/src/engine/types/relations.ts +1 -51
- package/src/engine/types/screen.ts +2 -574
- package/src/engine/types/step.ts +2 -334
- package/src/engine/types/target-ref.ts +1 -21
- package/src/engine/types/tree-node.ts +1 -132
- package/src/engine/types/workspace.ts +2 -49
- package/src/engine/validate-projection-allowlist.ts +6 -6
- package/src/entrypoint/__tests__/entrypoint-job-wiring.integration.test.ts +120 -1
- package/src/entrypoint/index.ts +49 -6
- package/src/errors/classes.ts +22 -1
- package/src/errors/field-issue.ts +0 -3
- package/src/errors/index.ts +1 -1
- package/src/errors/write-error-info.ts +10 -22
- package/src/es-ops/README.md +1 -1
- package/src/es-ops/__tests__/runner.integration.test.ts +74 -0
- package/src/es-ops/context.ts +4 -2
- package/src/es-ops/types.ts +8 -1
- package/src/event-store/__tests__/admin-api.integration.test.ts +27 -1
- package/src/event-store/__tests__/backfill-pii.integration.test.ts +279 -0
- package/src/event-store/__tests__/event-store.integration.test.ts +201 -0
- package/src/event-store/__tests__/row-to-stored-event.test.ts +2 -2
- package/src/event-store/__tests__/snapshot.integration.test.ts +86 -0
- package/src/event-store/__tests__/unscoped-stream-primitives.guard.test.ts +58 -0
- package/src/event-store/__tests__/upcaster.integration.test.ts +77 -45
- package/src/event-store/admin-api.ts +11 -4
- package/src/event-store/errors.ts +2 -35
- package/src/event-store/event-store.ts +64 -78
- package/src/event-store/events-schema.ts +11 -13
- package/src/event-store/index.ts +18 -3
- package/src/event-store/rebuild-dead-letter.ts +111 -0
- package/src/event-store/snapshot.ts +63 -40
- package/src/event-store/types.ts +2 -0
- package/src/files/__tests__/build-storage-key.test.ts +28 -0
- package/src/files/__tests__/file-ref-entity.test.ts +8 -0
- package/src/files/__tests__/files.integration.test.ts +24 -0
- package/src/files/__tests__/in-memory-provider.contract.test.ts +4 -0
- package/src/files/__tests__/local-provider.test.ts +31 -0
- package/src/files/__tests__/provider-resolver.test.ts +70 -0
- package/src/files/__tests__/write-stream.test.ts +13 -0
- package/src/files/file-handle.ts +2 -19
- package/src/files/file-ref-entity.ts +2 -2
- package/src/files/file-routes.ts +13 -0
- package/src/files/index.ts +1 -1
- package/src/files/local-provider.ts +27 -8
- package/src/files/provider-resolver.ts +31 -20
- package/src/files/types.ts +13 -55
- package/src/i18n/__tests__/required-surface-keys.test.ts +17 -0
- package/src/i18n/required-surface-keys.ts +120 -1
- package/src/jobs/__tests__/job-queue-depth.integration.test.ts +82 -0
- package/src/jobs/__tests__/jobs.integration.test.ts +329 -3
- package/src/jobs/job-runner.ts +82 -12
- package/src/logging/types.ts +1 -7
- package/src/migrations/pending-rebuilds.ts +1 -1
- package/src/observability/__tests__/observability.integration.test.ts +4 -1
- package/src/observability/__tests__/recording-tracer.test.ts +6 -4
- package/src/observability/index.ts +2 -0
- package/src/observability/noop-provider.ts +0 -9
- package/src/observability/recording-tracer.ts +0 -12
- package/src/observability/standard-metrics.ts +64 -2
- package/src/observability/types/index.ts +1 -29
- package/src/observability/types/metric.ts +1 -56
- package/src/observability/types/provider.ts +1 -32
- package/src/observability/types/span.ts +1 -64
- package/src/pipeline/__tests__/ctx-bridge.integration.test.ts +2 -2
- package/src/pipeline/__tests__/dispatcher.test.ts +366 -1
- package/src/pipeline/__tests__/event-consumer-state.integration.test.ts +31 -0
- package/src/pipeline/__tests__/event-dispatcher-delivery-max-attempts.test.ts +126 -0
- package/src/pipeline/__tests__/event-dispatcher-rearm.integration.test.ts +263 -0
- package/src/pipeline/__tests__/event-dispatcher.integration.test.ts +10 -0
- package/src/pipeline/__tests__/job-trigger-consumer.integration.test.ts +106 -0
- package/src/pipeline/__tests__/lifecycle-pipeline.test.ts +308 -77
- package/src/pipeline/__tests__/load-aggregate-query.integration.test.ts +16 -8
- package/src/pipeline/__tests__/post-query-hook.integration.test.ts +3 -3
- package/src/pipeline/__tests__/projection-rebuild.integration.test.ts +80 -2
- package/src/pipeline/__tests__/rebuild-poison-quarantine.integration.test.ts +274 -0
- package/src/pipeline/__tests__/try-append-event.integration.test.ts +166 -0
- package/src/pipeline/dispatch-batch.ts +187 -0
- package/src/pipeline/dispatch-query.ts +165 -0
- package/src/pipeline/dispatch-shared.ts +826 -0
- package/src/pipeline/dispatch-stream.ts +90 -0
- package/src/pipeline/dispatch-write.ts +451 -0
- package/src/pipeline/dispatcher-utils.ts +1 -1
- package/src/pipeline/dispatcher.ts +44 -1372
- package/src/pipeline/entity-cache.ts +2 -33
- package/src/pipeline/event-consumer-state.ts +30 -2
- package/src/pipeline/event-dispatcher-admin.ts +293 -0
- package/src/pipeline/event-dispatcher-delivery.ts +305 -0
- package/src/pipeline/event-dispatcher.ts +85 -542
- package/src/pipeline/index.ts +2 -0
- package/src/pipeline/msp-rebuild.ts +42 -3
- package/src/pipeline/multi-stream-apply-context.ts +4 -42
- package/src/pipeline/projection-rebuild.ts +105 -3
- package/src/pipeline/system-hooks.ts +144 -1
- package/src/rate-limit/__tests__/resolver.integration.test.ts +18 -0
- package/src/rate-limit/resolver.ts +16 -32
- package/src/schema-cli.ts +76 -16
- package/src/search/__tests__/meilisearch-adapter.integration.test.ts +207 -185
- package/src/search/__tests__/meilisearch-ids.test.ts +30 -0
- package/src/search/__tests__/reindex-entity.integration.test.ts +144 -0
- package/src/search/index.ts +6 -0
- package/src/search/meilisearch-adapter.ts +13 -12
- package/src/search/reindex-entity.ts +177 -0
- package/src/search/types.ts +1 -39
- package/src/secrets/__tests__/contains-secret.test.ts +34 -0
- package/src/secrets/__tests__/envelope-cipher.test.ts +59 -0
- package/src/secrets/__tests__/envelope.test.ts +1 -1
- package/src/secrets/dek-cache.ts +26 -5
- package/src/secrets/envelope-cipher.ts +53 -0
- package/src/secrets/envelope.ts +5 -3
- package/src/secrets/index.ts +13 -1
- package/src/secrets/stored-envelope.ts +46 -0
- package/src/secrets/types.ts +2 -162
- package/src/stack/__tests__/event-collector.test.ts +42 -0
- package/src/stack/__tests__/setup-test-stack-jobs.integration.test.ts +125 -0
- package/src/stack/db.ts +2 -1
- package/src/stack/push-entity-projection-tables.ts +4 -3
- package/src/stack/redis.ts +8 -0
- package/src/stack/request-helper.ts +38 -2
- package/src/stack/table-helpers.ts +6 -4
- package/src/stack/test-stack.ts +249 -131
- package/src/testing/__tests__/late-bound.test.ts +32 -0
- package/src/testing/__tests__/wait-for.test.ts +59 -0
- package/src/testing/boot-validator-fixture.ts +121 -0
- package/src/testing/e2e-generator.ts +8 -0
- package/src/testing/file-provider-contract.ts +104 -0
- package/src/testing/handler-context.ts +3 -1
- package/src/testing/index.ts +5 -0
- package/src/testing/late-bound.ts +5 -3
- package/src/testing/mutable-master-key-provider.ts +29 -3
- package/src/testing/wait-for.ts +3 -0
- package/src/testing/without-ambient-temporal.ts +14 -0
- package/src/time/__tests__/tz-dateline.test.ts +8 -10
- package/src/time/geo-tz.ts +1 -32
- package/src/time/index.ts +1 -0
- package/src/time/legacy-date.ts +18 -0
- package/src/time/polyfill.ts +21 -38
- package/src/time/tz-context.ts +44 -85
- package/src/ui-types/app-schema.ts +12 -0
- package/src/ui-types/index.ts +20 -7
- package/src/utils/__tests__/safe-json-temporal.test.ts +18 -0
- package/src/utils/safe-json.ts +13 -1
- package/src/__tests__/raw-table.integration.test.ts +0 -116
- package/src/bun-db/__tests__/bun-test-stack.ts +0 -6
- package/src/db/__tests__/encryption.test.ts +0 -39
- package/src/db/encryption.ts +0 -39
- package/src/db/row-helpers.ts +0 -4
- package/src/engine/__tests__/raw-table.test.ts +0 -150
- package/src/engine/__tests__/unmanaged-table.test.ts +0 -127
- package/src/engine/feature-ast/__tests__/visual-tree-parse.test.ts +0 -184
- package/src/engine/feature-ast/extractors/round4.ts +0 -1366
package/src/api/auth-routes.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
1
2
|
import type { Context } from "hono";
|
|
2
3
|
import { Hono } from "hono";
|
|
3
4
|
import { deleteCookie, setCookie } from "hono/cookie";
|
|
5
|
+
import type Redis from "ioredis";
|
|
4
6
|
import { z } from "zod";
|
|
5
|
-
import {
|
|
7
|
+
import { buildSessionRoles } from "../engine/membership-roles";
|
|
6
8
|
import { createSystemUser } from "../engine/system-user";
|
|
7
9
|
import { type SessionUser, SYSTEM_TENANT_ID, type TenantId } from "../engine/types";
|
|
8
10
|
import { NotFoundError } from "../errors";
|
|
@@ -15,16 +17,11 @@ import {
|
|
|
15
17
|
type AuthSessionStatus,
|
|
16
18
|
CSRF_COOKIE_NAME,
|
|
17
19
|
getUser,
|
|
20
|
+
type TokenVerifier,
|
|
18
21
|
} from "./auth-middleware";
|
|
19
22
|
import type { JwtHelper } from "./jwt";
|
|
20
23
|
import { generateToken } from "./tokens";
|
|
21
24
|
|
|
22
|
-
// Cookie lifetime must track the JWT's exp claim — both are issued together,
|
|
23
|
-
// both reference the same session. jwt.ts's createJwtHelper hardcodes
|
|
24
|
-
// setExpirationTime("24h"); if that ever becomes configurable this constant
|
|
25
|
-
// follows it.
|
|
26
|
-
const JWT_TTL_SECONDS = 24 * 60 * 60;
|
|
27
|
-
|
|
28
25
|
// Resolves the Secure cookie flag. Locked off in dev/test so Playwright
|
|
29
26
|
// against http://localhost:… can actually receive the cookie. Production
|
|
30
27
|
// flips it on — browsers drop Secure cookies on http, so a misconfigured
|
|
@@ -46,6 +43,10 @@ function setAuthCookies(
|
|
|
46
43
|
csrfToken: string;
|
|
47
44
|
sameSite: "lax" | "strict";
|
|
48
45
|
domain?: string | undefined;
|
|
46
|
+
// Cookie lifetime must track the JWT's exp claim — both are issued
|
|
47
|
+
// together, both reference the same session. Callers pass jwt.ttlSeconds
|
|
48
|
+
// so the two never drift apart.
|
|
49
|
+
ttlSeconds: number;
|
|
49
50
|
},
|
|
50
51
|
): void {
|
|
51
52
|
const sameSite = opts.sameSite === "strict" ? "Strict" : "Lax";
|
|
@@ -53,7 +54,7 @@ function setAuthCookies(
|
|
|
53
54
|
secure: cookieSecure(),
|
|
54
55
|
sameSite,
|
|
55
56
|
path: "/",
|
|
56
|
-
maxAge:
|
|
57
|
+
maxAge: opts.ttlSeconds,
|
|
57
58
|
...(opts.domain !== undefined && { domain: opts.domain }),
|
|
58
59
|
} as const;
|
|
59
60
|
|
|
@@ -96,6 +97,36 @@ const LoginBody = z.object({
|
|
|
96
97
|
password: z.string(),
|
|
97
98
|
});
|
|
98
99
|
|
|
100
|
+
// Body schema for POST /auth/mfa/verify. challengeToken is opaque to the
|
|
101
|
+
// framework — it's minted and verified entirely by the mfaVerifyHandler
|
|
102
|
+
// (auth-mfa owns the token format, TOTP/recovery-code check, and the
|
|
103
|
+
// brute-force cap). code covers both 6-digit TOTP and XXXX-XXXX recovery
|
|
104
|
+
// codes (9 chars incl. the dash).
|
|
105
|
+
const MfaVerifyBody = z.object({
|
|
106
|
+
challengeToken: z.string().min(1),
|
|
107
|
+
code: z.string().min(6).max(9),
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// Body schema for POST /auth/mfa/preauth-enable-start. preauthSetupToken is
|
|
111
|
+
// opaque to the framework — minted by login.write.ts's mfaStatusChecker
|
|
112
|
+
// (auth-mfa) when enforcement policy blocks an unenrolled user, verified
|
|
113
|
+
// entirely by the mfaPreauthEnableStartHandler. accountLabel is
|
|
114
|
+
// client-supplied (the email the user just typed at login) for the
|
|
115
|
+
// otpauth:// URI, mirroring auth-mfa's own enable-start.write.ts.
|
|
116
|
+
const MfaPreauthEnableStartBody = z.object({
|
|
117
|
+
preauthSetupToken: z.string().min(1),
|
|
118
|
+
accountLabel: z.string().min(1).max(200),
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// Body schema for POST /auth/mfa/preauth-confirm. setupToken is opaque to
|
|
122
|
+
// the framework — minted by the mfaPreauthEnableStartHandler, verified
|
|
123
|
+
// entirely by the mfaPreauthConfirmHandler. code is TOTP-only (no recovery
|
|
124
|
+
// codes at enrollment time), mirroring auth-mfa's own enable-confirm.write.ts.
|
|
125
|
+
const MfaPreauthConfirmBody = z.object({
|
|
126
|
+
setupToken: z.string().min(1),
|
|
127
|
+
code: z.string().length(6),
|
|
128
|
+
});
|
|
129
|
+
|
|
99
130
|
const ResetPasswordBody = z.object({
|
|
100
131
|
token: z.string().min(1),
|
|
101
132
|
newPassword: z.string().min(8).max(200),
|
|
@@ -187,6 +218,12 @@ export type SessionCreator = (user: SessionUser, meta: SessionMetadata) => Promi
|
|
|
187
218
|
// there's nothing to revoke.
|
|
188
219
|
export type SessionRevoker = (sid: string) => Promise<void>;
|
|
189
220
|
|
|
221
|
+
// Mass-revoke every live session for a user. Used by password-change and
|
|
222
|
+
// "sign out everywhere" (sessions feature) and by auth-foundation's
|
|
223
|
+
// SessionStore extension point — canonical home so both can re-export
|
|
224
|
+
// the same type instead of restating it.
|
|
225
|
+
export type SessionMassRevoker = (userId: string) => Promise<number>;
|
|
226
|
+
|
|
190
227
|
// Status reported by the session-store to the auth-middleware. The concrete
|
|
191
228
|
// type lives on auth-middleware to keep the tight coupling visible there;
|
|
192
229
|
// auth-routes just re-uses the alias for the AuthRoutesConfig surface.
|
|
@@ -216,6 +253,70 @@ export type AuthRoutesConfig = {
|
|
|
216
253
|
// Rate-limit for POST /auth/login. Defaults to in-memory 10/5min per
|
|
217
254
|
// (ip + email) bucket. Pass `null` to disable (tests, trusted networks).
|
|
218
255
|
loginRateLimit?: LoginRateLimiter | null;
|
|
256
|
+
// Optional: qualified write handler completing a two-step (password +
|
|
257
|
+
// TOTP/recovery-code) login. When set, POST /auth/mfa/verify dispatches
|
|
258
|
+
// { challengeToken, code } to this handler with a guest identity. On
|
|
259
|
+
// success the handler must return { kind: "mfa-verify-success", session:
|
|
260
|
+
// SessionUser } and the route mints a JWT exactly like /auth/login.
|
|
261
|
+
// Everything MFA-specific (challenge-token format, TOTP/recovery check,
|
|
262
|
+
// the per-account brute-force cap) is owned by the handler — the
|
|
263
|
+
// framework stays as agnostic about it as it is about password hashing.
|
|
264
|
+
mfaVerifyHandler?: string;
|
|
265
|
+
// Maps mfaVerifyHandler error codes to HTTP status codes, same pattern as
|
|
266
|
+
// loginErrorStatusMap. Unknown errors default to the error's own httpStatus.
|
|
267
|
+
mfaVerifyErrorStatusMap?: Readonly<Record<string, number>>;
|
|
268
|
+
// Rate-limit for POST /auth/mfa/verify, keyed by client IP. Defaults to
|
|
269
|
+
// in-memory 10/5min. Pass `null` to disable. This is DELIBERATELY separate
|
|
270
|
+
// from loginRateLimit: unlike /auth/login (a dispatcher write-handler
|
|
271
|
+
// route that could inherit a handler-level rateLimit), this is a
|
|
272
|
+
// framework route with no handler.rateLimit to fall back on — and it's
|
|
273
|
+
// also separate from any per-account brute-force cap the mfaVerifyHandler
|
|
274
|
+
// enforces itself (see its own doc comment) — IP-scoped abuse protection
|
|
275
|
+
// and per-account guessing protection are different threats, neither
|
|
276
|
+
// substitutes for the other.
|
|
277
|
+
mfaVerifyRateLimit?: LoginRateLimiter | null;
|
|
278
|
+
// Optional: qualified write handler generating a TOTP secret + QR for a
|
|
279
|
+
// user blocked at login by MFA enforcement but not yet enrolled (see
|
|
280
|
+
// mfaStatusChecker's setupRequired branch, auth-mfa). When set, POST
|
|
281
|
+
// /auth/mfa/preauth-enable-start dispatches { preauthSetupToken,
|
|
282
|
+
// accountLabel } to this handler with a guest identity — no session is
|
|
283
|
+
// minted, the response is just { setupToken, otpauthUri, recoveryCodes }
|
|
284
|
+
// (same shape as auth-mfa's own enable-start.write.ts).
|
|
285
|
+
mfaPreauthEnableStartHandler?: string;
|
|
286
|
+
// Maps mfaPreauthEnableStartHandler error codes to HTTP status codes,
|
|
287
|
+
// same pattern as mfaVerifyErrorStatusMap.
|
|
288
|
+
mfaPreauthEnableStartErrorStatusMap?: Readonly<Record<string, number>>;
|
|
289
|
+
// Rate-limit for POST /auth/mfa/preauth-enable-start, keyed by client IP.
|
|
290
|
+
// Defaults to in-memory 10/5min. Pass `null` to disable. This route does
|
|
291
|
+
// NOT inherit the generic L2 authEndpointRateLimit on /api/auth/* by
|
|
292
|
+
// default (that's opt-in per-app via runProdApp's rateLimit.auth), and a
|
|
293
|
+
// preauthSetupToken is valid for the full setup-token TTL and not
|
|
294
|
+
// single-use until preauth-confirm burns it — an unrate-limited replay
|
|
295
|
+
// means every hit re-derives a fresh TOTP secret + 8 argon2id recovery-
|
|
296
|
+
// code hashes (recovery-codes.ts, Promise.all), a memory-hard CPU
|
|
297
|
+
// amplifier reachable from a single stolen/leaked token.
|
|
298
|
+
mfaPreauthEnableStartRateLimit?: LoginRateLimiter | null;
|
|
299
|
+
// Optional: qualified write handler completing the enrollment started by
|
|
300
|
+
// mfaPreauthEnableStartHandler — takes the secret-carrying setupToken
|
|
301
|
+
// from that step plus a TOTP code. When set, POST /auth/mfa/preauth-
|
|
302
|
+
// confirm dispatches { setupToken, code } to this handler with a guest
|
|
303
|
+
// identity. On success the handler must return { kind: "mfa-preauth-
|
|
304
|
+
// confirm-success", session: SessionUser } and the route mints a JWT
|
|
305
|
+
// exactly like /auth/mfa/verify — this call both finishes enrollment AND
|
|
306
|
+
// completes the login that was blocked by mfa-setup-required. Unlike
|
|
307
|
+
// mfaPreauthEnableStartHandler, THIS is the per-account TOTP-guessing
|
|
308
|
+
// surface the enable-start doc comment refers to — the handler owns its
|
|
309
|
+
// own brute-force cap (mirroring mfaVerifyHandler's), and this route adds
|
|
310
|
+
// its own IP-scoped rate limiter for the same reason mfaVerifyRateLimit
|
|
311
|
+
// exists separately from loginRateLimit.
|
|
312
|
+
mfaPreauthConfirmHandler?: string;
|
|
313
|
+
// Maps mfaPreauthConfirmHandler error codes to HTTP status codes, same
|
|
314
|
+
// pattern as mfaVerifyErrorStatusMap.
|
|
315
|
+
mfaPreauthConfirmErrorStatusMap?: Readonly<Record<string, number>>;
|
|
316
|
+
// Rate-limit for POST /auth/mfa/preauth-confirm, keyed by client IP. Same
|
|
317
|
+
// reasoning as mfaVerifyRateLimit — defaults to in-memory 10/5min, pass
|
|
318
|
+
// `null` to disable.
|
|
319
|
+
mfaPreauthConfirmRateLimit?: LoginRateLimiter | null;
|
|
219
320
|
// Session-lifecycle callbacks. When both are wired the JWT carries a `jti`
|
|
220
321
|
// (sid) and the server can revoke individual sessions (logout, compromise,
|
|
221
322
|
// password-change). When unwired the framework issues plain stateless JWTs.
|
|
@@ -228,10 +329,19 @@ export type AuthRoutesConfig = {
|
|
|
228
329
|
// at login, check it here on every request. Leaving this empty disables
|
|
229
330
|
// the revocation path — old JWTs stay valid until they expire naturally.
|
|
230
331
|
sessionChecker?: SessionChecker;
|
|
231
|
-
//
|
|
232
|
-
//
|
|
233
|
-
//
|
|
234
|
-
|
|
332
|
+
// Resolves bearer tokens (any registered auth-foundation `tokenVerifier`
|
|
333
|
+
// provider — PAT today, future JWT-provider), consulted BEFORE jwt.verify.
|
|
334
|
+
// Wired by run-prod-app/run-dev-app when at least one provider feature is
|
|
335
|
+
// mounted; unwired = no non-JWT bearer auth.
|
|
336
|
+
tokenVerifier?: TokenVerifier;
|
|
337
|
+
// Per-token request-rate limiter for PAT-authenticated requests, keyed by
|
|
338
|
+
// the token id (SessionUser.pat.tokenId). Cookie/JWT requests are unaffected.
|
|
339
|
+
// Reuses the LoginRateLimiter shape (a generic keyed check/reset limiter).
|
|
340
|
+
// Wired by run-prod-app when the PAT feature is mounted; unwired = no PAT
|
|
341
|
+
// rate limiting.
|
|
342
|
+
patRateLimiter?: LoginRateLimiter;
|
|
343
|
+
// Tenant-lifecycle 410 gate — wired by tenant-lifecycle / run-prod-app.
|
|
344
|
+
resolveTenantLifecycleStatus?: import("./auth-middleware").TenantLifecycleStatusResolver;
|
|
235
345
|
// Password-reset flow. When wired, POST /auth/request-password-reset and
|
|
236
346
|
// POST /auth/reset-password are mounted as public routes. The framework
|
|
237
347
|
// dispatches to the feature-level handlers (authoring QNs typically come
|
|
@@ -242,6 +352,12 @@ export type AuthRoutesConfig = {
|
|
|
242
352
|
passwordReset?: PasswordResetConfig;
|
|
243
353
|
// Email-verification flow. Symmetric to passwordReset.
|
|
244
354
|
emailVerification?: EmailVerificationConfig;
|
|
355
|
+
// Account-unlock flow (#1266). When wired, POST
|
|
356
|
+
// /auth/request-account-unlock + /auth/confirm-account-unlock are
|
|
357
|
+
// mounted as public routes. Confirm needs no extra body field
|
|
358
|
+
// (token-only, like email-verification) — the handler only clears the
|
|
359
|
+
// Redis lockout state, no entity write.
|
|
360
|
+
accountUnlock?: AccountUnlockConfig;
|
|
245
361
|
// Self-Signup (Magic-Link). Wenn wired, mountet POST
|
|
246
362
|
// /auth/signup-request + /auth/signup-confirm. Confirm returnt JWT-
|
|
247
363
|
// Cookie + Session-Body wie login.
|
|
@@ -295,6 +411,20 @@ export type AuthRoutesConfig = {
|
|
|
295
411
|
// subdomain can then forge authenticated state-changing requests. Prefer
|
|
296
412
|
// setting `allowedOrigins`.
|
|
297
413
|
unsafeSkipOriginCheck?: boolean;
|
|
414
|
+
// Number of trusted reverse-proxy hops between the client and this
|
|
415
|
+
// process that APPEND (not overwrite) their peer address to
|
|
416
|
+
// `x-forwarded-for` (e.g. nginx `$proxy_add_x_forwarded_for`). Used to
|
|
417
|
+
// derive the client IP for every auth rate-limiter (login, mfa-verify,
|
|
418
|
+
// preauth-enable-start, preauth-confirm) and for requestMeta's session
|
|
419
|
+
// IP — kumiko-framework#1539. Default 0 = legacy behavior, trust the
|
|
420
|
+
// first XFF entry (or x-real-ip) unconditionally; spoofable by design,
|
|
421
|
+
// kept as the default so unconfigured deployments don't regress into a
|
|
422
|
+
// shared "unknown" bucket (mfa-verify/preauth-confirm key on bare IP, no
|
|
423
|
+
// email composite — collapsing everyone into one bucket is a DoS worse
|
|
424
|
+
// than the spoofing hole). Set this to your real proxy hop count (1 for
|
|
425
|
+
// a single ingress/reverse-proxy, 2 for edge-LB + ingress, etc.) to close
|
|
426
|
+
// the hole; see clientIpOf's doc comment for the extraction algorithm.
|
|
427
|
+
trustedProxyHops?: number;
|
|
298
428
|
};
|
|
299
429
|
|
|
300
430
|
export type PasswordResetConfig = {
|
|
@@ -312,6 +442,13 @@ export type EmailVerificationConfig = {
|
|
|
312
442
|
confirmHandler: string;
|
|
313
443
|
};
|
|
314
444
|
|
|
445
|
+
export type AccountUnlockConfig = {
|
|
446
|
+
requestHandler: string;
|
|
447
|
+
// Token-only body (mirrors EmailVerificationConfig) — no entity write, so
|
|
448
|
+
// there's no newPassword-equivalent field.
|
|
449
|
+
confirmHandler: string;
|
|
450
|
+
};
|
|
451
|
+
|
|
315
452
|
// Tenant-Invite Magic-Link. Drei Accept-Branches für klare Separation:
|
|
316
453
|
// - acceptHandler: logged-in User akzeptiert via JWT (Branch 1)
|
|
317
454
|
// - acceptWithLoginHandler: anon User mit existing email (Branch 2)
|
|
@@ -342,15 +479,73 @@ export type SignupConfig = {
|
|
|
342
479
|
confirmHandler: string;
|
|
343
480
|
};
|
|
344
481
|
|
|
482
|
+
// Derives the caller IP from proxy headers, single source for the
|
|
483
|
+
// rate-limiter keys below and requestMeta. kumiko-framework#1523/#1522/#1539:
|
|
484
|
+
// `x-forwarded-for` is attacker-controlled unless we know how many trusted
|
|
485
|
+
// proxy hops sit between the client and this process — `trustedProxyHops`
|
|
486
|
+
// (AuthRoutesConfig) is that count.
|
|
487
|
+
//
|
|
488
|
+
// hops === 0 (default): legacy behavior — trust the first XFF entry (or
|
|
489
|
+
// x-real-ip) at face value. Kept as the default so unconfigured
|
|
490
|
+
// deployments don't regress: collapsing everyone into a single "unknown"
|
|
491
|
+
// bucket would turn mfa-verify/preauth-confirm's pure-IP-keyed limiter
|
|
492
|
+
// into a one-request-locks-out-everyone DoS, which is worse than the
|
|
493
|
+
// spoofing hole this issue is about. Apps behind a proxy MUST set this to
|
|
494
|
+
// close the hole — see AuthRoutesConfig.trustedProxyHops.
|
|
495
|
+
//
|
|
496
|
+
// hops >= 1: each trusted proxy appends (not overwrites) its peer address
|
|
497
|
+
// to XFF (e.g. nginx `$proxy_add_x_forwarded_for`), so the last `hops`
|
|
498
|
+
// entries are proxy-supplied and everything before them — including the
|
|
499
|
+
// real client — sits at `entries[length - hops]`. Anything the client
|
|
500
|
+
// itself prepended lands further left and is ignored. If the header has
|
|
501
|
+
// fewer entries than `hops`, the proxy chain is shorter than configured
|
|
502
|
+
// (misconfiguration or a bypassed hop) — fall back to "unknown" rather
|
|
503
|
+
// than trusting a shorter, potentially attacker-controlled chain.
|
|
504
|
+
// x-real-ip is NOT consulted in this branch: unlike XFF it has no
|
|
505
|
+
// standardized hop-count semantics, so there's no safe way to validate it
|
|
506
|
+
// against a configured hop count.
|
|
507
|
+
//
|
|
508
|
+
// Callers that need a hard boundary regardless of this config
|
|
509
|
+
// (preauth-enable-start) additionally key on something the caller can't
|
|
510
|
+
// freely choose (see preauthTokenKeyOf below).
|
|
511
|
+
function clientIpOf(
|
|
512
|
+
c: { req: { header(name: string): string | undefined } },
|
|
513
|
+
trustedProxyHops = 0,
|
|
514
|
+
): string {
|
|
515
|
+
if (trustedProxyHops <= 0) {
|
|
516
|
+
return (
|
|
517
|
+
c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ??
|
|
518
|
+
c.req.header("x-real-ip") ??
|
|
519
|
+
"unknown"
|
|
520
|
+
);
|
|
521
|
+
}
|
|
522
|
+
const entries = (c.req.header("x-forwarded-for") ?? "")
|
|
523
|
+
.split(",")
|
|
524
|
+
.map((entry) => entry.trim())
|
|
525
|
+
.filter((entry) => entry.length > 0);
|
|
526
|
+
if (entries.length < trustedProxyHops) {
|
|
527
|
+
return "unknown";
|
|
528
|
+
}
|
|
529
|
+
return entries[entries.length - trustedProxyHops] ?? "unknown";
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// Second rate-limit axis for preauth-enable-start: unlike the IP, a
|
|
533
|
+
// preauthSetupToken isn't freely choosable by the attacker (it's minted by
|
|
534
|
+
// login.write.ts), so hashing it into a bucket key still caps the replay
|
|
535
|
+
// even when the IP-based bucket is bypassed via header rotation.
|
|
536
|
+
function preauthTokenKeyOf(token: string): string {
|
|
537
|
+
return `preauth-token:${createHash("sha256").update(token).digest("hex")}`;
|
|
538
|
+
}
|
|
539
|
+
|
|
345
540
|
// Extract `ip` and `user-agent` for the sessionCreator.
|
|
346
541
|
// Hono's `c.req.header(...)` returns undefined for missing headers; we coerce
|
|
347
542
|
// them to "unknown" rather than throwing because auth-routes are a public
|
|
348
543
|
// surface and we don't want header-sniffing bugs to break login.
|
|
349
|
-
function requestMeta(
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
544
|
+
function requestMeta(
|
|
545
|
+
c: { req: { header(name: string): string | undefined } },
|
|
546
|
+
trustedProxyHops = 0,
|
|
547
|
+
): SessionMetadata {
|
|
548
|
+
const ip = clientIpOf(c, trustedProxyHops);
|
|
354
549
|
const userAgent = c.req.header("user-agent") ?? "unknown";
|
|
355
550
|
return { ip, userAgent };
|
|
356
551
|
}
|
|
@@ -416,6 +611,59 @@ export function createInMemoryLoginRateLimiter(
|
|
|
416
611
|
};
|
|
417
612
|
}
|
|
418
613
|
|
|
614
|
+
// Redis-backed sibling of createInMemoryLoginRateLimiter — same fixed-window
|
|
615
|
+
// semantics (count <= maxAttempts allows, window anchored on first hit), but
|
|
616
|
+
// shared across replicas via INCR/PEXPIRE instead of an in-process Map.
|
|
617
|
+
// runProdApp defaults to this: an in-memory limiter only rate-limits within
|
|
618
|
+
// a single instance, so a multi-replica prod deployment would silently give
|
|
619
|
+
// each replica its own bucket. namespace separates the login-key keyspace
|
|
620
|
+
// from the mfa-verify one (they share the same LoginRateLimiter shape but
|
|
621
|
+
// key on different values).
|
|
622
|
+
// Fail-closed on Redis outage, unlike the in-memory limiter (which never
|
|
623
|
+
// throws): `check`/`reset` propagate any Redis error, so callers 500
|
|
624
|
+
// instead of falling back to unlimited attempts. Deliberate — Redis is
|
|
625
|
+
// already required infra for a multi-replica deployment, and for a
|
|
626
|
+
// security-relevant limiter "briefly unavailable" should read as "briefly
|
|
627
|
+
// down", not "briefly unlimited".
|
|
628
|
+
export function createRedisLoginRateLimiter(
|
|
629
|
+
redis: Redis,
|
|
630
|
+
maxAttempts = 10,
|
|
631
|
+
windowMs = 5 * 60_000,
|
|
632
|
+
namespace = "login",
|
|
633
|
+
): LoginRateLimiter {
|
|
634
|
+
const prefix = `kumiko:auth:ratelimit:${namespace}:`;
|
|
635
|
+
|
|
636
|
+
return {
|
|
637
|
+
async check(key) {
|
|
638
|
+
const redisKey = `${prefix}${key}`;
|
|
639
|
+
// INCR then PEXPIRE was two round-trips: a crash/network blip between
|
|
640
|
+
// them (only possible right after count===1, when the key is fresh)
|
|
641
|
+
// left the key permanently without a TTL — the window never resets,
|
|
642
|
+
// the counter only grows, and the bucket is locked out until an
|
|
643
|
+
// operator manually resets/deletes the key. One atomic eval closes
|
|
644
|
+
// the gap: the TTL is set in the same script invocation that creates
|
|
645
|
+
// the key, so no observer (including a crash) can see count===1
|
|
646
|
+
// without the expiry already applied. The `PTTL < 0` branch also
|
|
647
|
+
// heals keys that already exist without a TTL from before this fix
|
|
648
|
+
// shipped (PTTL returns -1 for "no expiry set, key exists") — without
|
|
649
|
+
// it, those pre-existing keys would grow forever with no way back to
|
|
650
|
+
// a normal window.
|
|
651
|
+
const count = (await redis.eval(
|
|
652
|
+
`local c = redis.call('INCR', KEYS[1])
|
|
653
|
+
if c == 1 or redis.call('PTTL', KEYS[1]) < 0 then redis.call('PEXPIRE', KEYS[1], ARGV[1]) end
|
|
654
|
+
return c`,
|
|
655
|
+
1,
|
|
656
|
+
redisKey,
|
|
657
|
+
windowMs,
|
|
658
|
+
)) as number;
|
|
659
|
+
return count <= maxAttempts;
|
|
660
|
+
},
|
|
661
|
+
async reset(key) {
|
|
662
|
+
await redis.del(`${prefix}${key}`);
|
|
663
|
+
},
|
|
664
|
+
};
|
|
665
|
+
}
|
|
666
|
+
|
|
419
667
|
export function createAuthRoutes(
|
|
420
668
|
dispatcher: Dispatcher,
|
|
421
669
|
jwt: JwtHelper,
|
|
@@ -427,6 +675,44 @@ export function createAuthRoutes(
|
|
|
427
675
|
// working. High-security apps can opt into "strict" — see AuthRoutesConfig.
|
|
428
676
|
const cookieSameSite = config.cookieSameSite ?? "lax";
|
|
429
677
|
const cookieDomain = config.cookieDomain;
|
|
678
|
+
// Single hop-count-aware IP getter for every rate-limit call site below —
|
|
679
|
+
// see AuthRoutesConfig.trustedProxyHops / clientIpOf's doc comment. Fail
|
|
680
|
+
// loud on a non-finite/negative value rather than letting it silently
|
|
681
|
+
// reach clientIpOf, where NaN/negative behaves like "chain too short"
|
|
682
|
+
// and collapses every request into the shared "unknown" bucket.
|
|
683
|
+
if (
|
|
684
|
+
config.trustedProxyHops !== undefined &&
|
|
685
|
+
(!Number.isInteger(config.trustedProxyHops) || config.trustedProxyHops < 0)
|
|
686
|
+
) {
|
|
687
|
+
throw new Error(
|
|
688
|
+
`createAuthRoutes: trustedProxyHops must be a non-negative integer, got ${config.trustedProxyHops}.`,
|
|
689
|
+
);
|
|
690
|
+
}
|
|
691
|
+
const trustedProxyHops = config.trustedProxyHops ?? 0;
|
|
692
|
+
const getClientIp = (c: Context): string => clientIpOf(c, trustedProxyHops);
|
|
693
|
+
|
|
694
|
+
// Shared tail of every route that ends a request logged-in: create the
|
|
695
|
+
// session record (if wired), sign the JWT, set the auth+csrf cookies. Was
|
|
696
|
+
// duplicated 5x (login/signup-confirm/invite-accept-with-login/invite-
|
|
697
|
+
// signup-complete/switch-tenant) — extracted so a 6th caller (mfa-verify)
|
|
698
|
+
// doesn't grow it to 6.
|
|
699
|
+
async function mintSessionAndRespond(c: Context, session: SessionUser): Promise<string> {
|
|
700
|
+
let sessionForJwt = session;
|
|
701
|
+
if (config.sessionCreator) {
|
|
702
|
+
const sid = await config.sessionCreator(session, requestMeta(c, trustedProxyHops));
|
|
703
|
+
sessionForJwt = { ...session, sid };
|
|
704
|
+
}
|
|
705
|
+
const token = await jwt.sign(sessionForJwt);
|
|
706
|
+
const csrfToken = generateToken();
|
|
707
|
+
setAuthCookies(c, {
|
|
708
|
+
token,
|
|
709
|
+
csrfToken,
|
|
710
|
+
sameSite: cookieSameSite,
|
|
711
|
+
domain: cookieDomain,
|
|
712
|
+
ttlSeconds: jwt.ttlSeconds,
|
|
713
|
+
});
|
|
714
|
+
return token;
|
|
715
|
+
}
|
|
430
716
|
|
|
431
717
|
// POST /auth/login — public endpoint (bypasses auth middleware via PUBLIC_API_PATHS).
|
|
432
718
|
// The configured login handler authenticates and returns a SessionUser;
|
|
@@ -448,13 +734,7 @@ export function createAuthRoutes(
|
|
|
448
734
|
}
|
|
449
735
|
const body = parsed.data;
|
|
450
736
|
|
|
451
|
-
|
|
452
|
-
// so compute once. Falls back to "unknown" when no proxy header is
|
|
453
|
-
// present — consistent bucket for direct-to-server test setups.
|
|
454
|
-
const clientIp =
|
|
455
|
-
c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ??
|
|
456
|
-
c.req.header("x-real-ip") ??
|
|
457
|
-
"unknown";
|
|
737
|
+
const clientIp = getClientIp(c);
|
|
458
738
|
const rateLimitKey = `${clientIp}|${body.email.toLowerCase()}`;
|
|
459
739
|
|
|
460
740
|
if (rateLimiter) {
|
|
@@ -481,32 +761,253 @@ export function createAuthRoutes(
|
|
|
481
761
|
return c.json({ isSuccess: false, error: result.error }, status);
|
|
482
762
|
}
|
|
483
763
|
|
|
484
|
-
// @cast-boundary engine-payload — generic dispatcher.write result for
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
//
|
|
488
|
-
//
|
|
489
|
-
//
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
764
|
+
// @cast-boundary engine-payload — generic dispatcher.write result for
|
|
765
|
+
// login. Three possible shapes: a straight session, an MFA challenge
|
|
766
|
+
// when the loginHandler is wired with a second-factor gate, or a hard
|
|
767
|
+
// block when enforcement policy demands MFA but the user never
|
|
768
|
+
// enrolled (see auth-mfa's config.ts for why this has no in-band
|
|
769
|
+
// recovery yet).
|
|
770
|
+
const data = result.data as
|
|
771
|
+
| { kind: "auth-session"; session: SessionUser }
|
|
772
|
+
| { kind: "mfa-challenge"; challengeToken: string }
|
|
773
|
+
| { kind: "mfa-setup-required"; preauthSetupToken: string };
|
|
774
|
+
|
|
775
|
+
if (data.kind === "mfa-setup-required") {
|
|
776
|
+
// No session, no challenge — the client must show an
|
|
777
|
+
// enrollment-required message. No rate-limit reset (same reasoning
|
|
778
|
+
// as the mfa-challenge branch below).
|
|
779
|
+
return c.json({
|
|
780
|
+
isSuccess: true,
|
|
781
|
+
mfaSetupRequired: true,
|
|
782
|
+
preauthSetupToken: data.preauthSetupToken,
|
|
783
|
+
});
|
|
495
784
|
}
|
|
496
785
|
|
|
497
|
-
|
|
786
|
+
if (data.kind === "mfa-challenge") {
|
|
787
|
+
// No session minted yet — no cookies, no token. The client must
|
|
788
|
+
// complete /auth/mfa/verify with this token before it gets either.
|
|
789
|
+
// Rate-limit counter is NOT reset here: a "correct password, wrong/
|
|
790
|
+
// no TOTP yet" outcome hasn't proven the caller owns the account any
|
|
791
|
+
// more than a wrong password did.
|
|
792
|
+
return c.json({ isSuccess: true, mfaRequired: true, challengeToken: data.challengeToken });
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
// Session creation (optional) + JWT sign + cookies — see
|
|
796
|
+
// mintSessionAndRespond. Creating the session BEFORE signing the JWT
|
|
797
|
+
// is load-bearing: the sid must exist on the server before the token
|
|
798
|
+
// that references it can be handed out, otherwise a fast client could
|
|
799
|
+
// arrive at an auth-middleware check before the insert commits.
|
|
800
|
+
const token = await mintSessionAndRespond(c, data.session);
|
|
498
801
|
|
|
499
802
|
if (rateLimiter) {
|
|
500
803
|
await rateLimiter.reset(rateLimitKey);
|
|
501
804
|
}
|
|
502
805
|
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
806
|
+
return c.json({
|
|
807
|
+
isSuccess: true,
|
|
808
|
+
token,
|
|
809
|
+
user: { id: data.session.id, tenantId: data.session.tenantId, roles: data.session.roles },
|
|
810
|
+
});
|
|
811
|
+
});
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
// POST /auth/mfa/verify — completes a two-step login. Mirrors /auth/login
|
|
815
|
+
// structurally (public, GUEST_USER dispatch, mintSessionAndRespond on
|
|
816
|
+
// success) but with its OWN rate limiter (mfaVerifyRateLimit) — this route
|
|
817
|
+
// never goes through a dispatcher write-handler's own rateLimit config,
|
|
818
|
+
// so without this it would have NO rate limiting at all. Per-account
|
|
819
|
+
// brute-force protection (capping wrong-code guesses against one
|
|
820
|
+
// still-valid challenge token) is a separate mechanism the handler itself
|
|
821
|
+
// owns — see AuthRoutesConfig.mfaVerifyHandler's doc comment.
|
|
822
|
+
if (config.mfaVerifyHandler) {
|
|
823
|
+
const mfaVerifyQn = config.mfaVerifyHandler;
|
|
824
|
+
const statusMap = config.mfaVerifyErrorStatusMap ?? {};
|
|
825
|
+
const rateLimiter =
|
|
826
|
+
config.mfaVerifyRateLimit === null
|
|
827
|
+
? null
|
|
828
|
+
: (config.mfaVerifyRateLimit ?? createInMemoryLoginRateLimiter());
|
|
829
|
+
|
|
830
|
+
api.post(Routes.authMfaVerify, async (c) => {
|
|
831
|
+
const raw = await c.req.json().catch(() => null);
|
|
832
|
+
const parsed = MfaVerifyBody.safeParse(raw);
|
|
833
|
+
if (!parsed.success) {
|
|
834
|
+
return c.json({ isSuccess: false, error: "invalid_body" }, 400);
|
|
835
|
+
}
|
|
836
|
+
const body = parsed.data;
|
|
837
|
+
|
|
838
|
+
const clientIp = getClientIp(c);
|
|
839
|
+
|
|
840
|
+
if (rateLimiter) {
|
|
841
|
+
const allowed = await rateLimiter.check(clientIp);
|
|
842
|
+
if (!allowed) {
|
|
843
|
+
return c.json({ isSuccess: false, error: "rate_limited" }, 429);
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
const result = await dispatcher.write(mfaVerifyQn, body, GUEST_USER);
|
|
848
|
+
|
|
849
|
+
if (!result.isSuccess) {
|
|
850
|
+
// @cast-boundary error-details — KumikoError.details shape is per-error
|
|
851
|
+
const reason =
|
|
852
|
+
(result.error.details as { reason?: string } | undefined)?.reason ?? result.error.code;
|
|
853
|
+
// @cast-boundary engine-payload — statusMap value union narrows to the http-status union
|
|
854
|
+
const status = (statusMap[reason] ?? result.error.httpStatus) as
|
|
855
|
+
| 400
|
|
856
|
+
| 401
|
|
857
|
+
| 403
|
|
858
|
+
| 422
|
|
859
|
+
| 429
|
|
860
|
+
| 500;
|
|
861
|
+
return c.json({ isSuccess: false, error: result.error }, status);
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
// @cast-boundary engine-payload — generic dispatcher.write result for mfa-verify handler
|
|
865
|
+
const data = result.data as { kind: "mfa-verify-success"; session: SessionUser };
|
|
866
|
+
|
|
867
|
+
const token = await mintSessionAndRespond(c, data.session);
|
|
868
|
+
|
|
869
|
+
if (rateLimiter) {
|
|
870
|
+
await rateLimiter.reset(clientIp);
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
return c.json({
|
|
874
|
+
isSuccess: true,
|
|
875
|
+
token,
|
|
876
|
+
user: { id: data.session.id, tenantId: data.session.tenantId, roles: data.session.roles },
|
|
877
|
+
});
|
|
878
|
+
});
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
// POST /auth/mfa/preauth-enable-start — lets a user blocked at login by
|
|
882
|
+
// MFA enforcement (mfa-setup-required) generate a TOTP secret + QR
|
|
883
|
+
// without a session, using the preauthSetupToken login.write.ts issued.
|
|
884
|
+
// No JWT is minted here (unlike /auth/login and /auth/mfa/verify) — the
|
|
885
|
+
// handler's response carries a secret-bearing setupToken that a later
|
|
886
|
+
// pre-auth confirm step (the same shape auth-mfa's own enable-confirm
|
|
887
|
+
// consumes) verifies. Rate-limited like mfaVerifyRateLimit/
|
|
888
|
+
// mfaPreauthConfirmRateLimit — a preauthSetupToken is valid and
|
|
889
|
+
// not-single-use for its whole TTL, so without this cap a replay is a
|
|
890
|
+
// memory-hard CPU amplifier (see AuthRoutesConfig doc comment).
|
|
891
|
+
if (config.mfaPreauthEnableStartHandler) {
|
|
892
|
+
const mfaPreauthEnableStartQn = config.mfaPreauthEnableStartHandler;
|
|
893
|
+
const statusMap = config.mfaPreauthEnableStartErrorStatusMap ?? {};
|
|
894
|
+
const rateLimiter =
|
|
895
|
+
config.mfaPreauthEnableStartRateLimit === null
|
|
896
|
+
? null
|
|
897
|
+
: (config.mfaPreauthEnableStartRateLimit ?? createInMemoryLoginRateLimiter());
|
|
898
|
+
|
|
899
|
+
api.post(Routes.authMfaPreauthEnableStart, async (c) => {
|
|
900
|
+
const raw = await c.req.json().catch(() => null);
|
|
901
|
+
const parsed = MfaPreauthEnableStartBody.safeParse(raw);
|
|
902
|
+
if (!parsed.success) {
|
|
903
|
+
return c.json({ isSuccess: false, error: "invalid_body" }, 400);
|
|
904
|
+
}
|
|
905
|
+
const body = parsed.data;
|
|
906
|
+
|
|
907
|
+
const clientIp = getClientIp(c);
|
|
908
|
+
|
|
909
|
+
if (rateLimiter) {
|
|
910
|
+
const allowed = await rateLimiter.check(clientIp);
|
|
911
|
+
if (!allowed) {
|
|
912
|
+
return c.json({ isSuccess: false, error: "rate_limited" }, 429);
|
|
913
|
+
}
|
|
914
|
+
// Second axis, independent of the IP-derived bucket above: the
|
|
915
|
+
// preauthSetupToken is minted by login.write.ts and not freely
|
|
916
|
+
// choosable, so this cap survives an attacker rotating
|
|
917
|
+
// x-forwarded-for to bypass the IP bucket (kumiko-framework#1522).
|
|
918
|
+
const tokenAllowed = await rateLimiter.check(preauthTokenKeyOf(body.preauthSetupToken));
|
|
919
|
+
if (!tokenAllowed) {
|
|
920
|
+
return c.json({ isSuccess: false, error: "rate_limited" }, 429);
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
const result = await dispatcher.write(mfaPreauthEnableStartQn, body, GUEST_USER);
|
|
925
|
+
|
|
926
|
+
if (!result.isSuccess) {
|
|
927
|
+
// @cast-boundary error-details — KumikoError.details shape is per-error
|
|
928
|
+
const reason =
|
|
929
|
+
(result.error.details as { reason?: string } | undefined)?.reason ?? result.error.code;
|
|
930
|
+
// @cast-boundary engine-payload — statusMap value union narrows to the http-status union
|
|
931
|
+
const status = (statusMap[reason] ?? result.error.httpStatus) as
|
|
932
|
+
| 400
|
|
933
|
+
| 401
|
|
934
|
+
| 403
|
|
935
|
+
| 422
|
|
936
|
+
| 429
|
|
937
|
+
| 500;
|
|
938
|
+
return c.json({ isSuccess: false, error: result.error }, status);
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
// @cast-boundary engine-payload — generic dispatcher.write result for
|
|
942
|
+
// the preauth-enable-start handler
|
|
943
|
+
const data = result.data as {
|
|
944
|
+
setupToken: string;
|
|
945
|
+
otpauthUri: string;
|
|
946
|
+
recoveryCodes: readonly string[];
|
|
947
|
+
};
|
|
948
|
+
|
|
949
|
+
return c.json({ isSuccess: true, ...data });
|
|
950
|
+
});
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
// POST /auth/mfa/preauth-confirm — completes both the enrollment started
|
|
954
|
+
// by preauth-enable-start AND the login mfa-setup-required blocked.
|
|
955
|
+
// Mirrors /auth/mfa/verify structurally (public, GUEST_USER dispatch,
|
|
956
|
+
// mintSessionAndRespond on success) with its OWN rate limiter
|
|
957
|
+
// (mfaPreauthConfirmRateLimit) — same reasoning as mfaVerifyRateLimit.
|
|
958
|
+
// Per-account brute-force protection (capping wrong-code guesses against
|
|
959
|
+
// one still-valid setupToken) is owned by the handler itself — see
|
|
960
|
+
// AuthRoutesConfig.mfaPreauthConfirmHandler's doc comment.
|
|
961
|
+
if (config.mfaPreauthConfirmHandler) {
|
|
962
|
+
const mfaPreauthConfirmQn = config.mfaPreauthConfirmHandler;
|
|
963
|
+
const statusMap = config.mfaPreauthConfirmErrorStatusMap ?? {};
|
|
964
|
+
const rateLimiter =
|
|
965
|
+
config.mfaPreauthConfirmRateLimit === null
|
|
966
|
+
? null
|
|
967
|
+
: (config.mfaPreauthConfirmRateLimit ?? createInMemoryLoginRateLimiter());
|
|
968
|
+
|
|
969
|
+
api.post(Routes.authMfaPreauthConfirm, async (c) => {
|
|
970
|
+
const raw = await c.req.json().catch(() => null);
|
|
971
|
+
const parsed = MfaPreauthConfirmBody.safeParse(raw);
|
|
972
|
+
if (!parsed.success) {
|
|
973
|
+
return c.json({ isSuccess: false, error: "invalid_body" }, 400);
|
|
974
|
+
}
|
|
975
|
+
const body = parsed.data;
|
|
976
|
+
|
|
977
|
+
const clientIp = getClientIp(c);
|
|
978
|
+
|
|
979
|
+
if (rateLimiter) {
|
|
980
|
+
const allowed = await rateLimiter.check(clientIp);
|
|
981
|
+
if (!allowed) {
|
|
982
|
+
return c.json({ isSuccess: false, error: "rate_limited" }, 429);
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
const result = await dispatcher.write(mfaPreauthConfirmQn, body, GUEST_USER);
|
|
987
|
+
|
|
988
|
+
if (!result.isSuccess) {
|
|
989
|
+
// @cast-boundary error-details — KumikoError.details shape is per-error
|
|
990
|
+
const reason =
|
|
991
|
+
(result.error.details as { reason?: string } | undefined)?.reason ?? result.error.code;
|
|
992
|
+
// @cast-boundary engine-payload — statusMap value union narrows to the http-status union
|
|
993
|
+
const status = (statusMap[reason] ?? result.error.httpStatus) as
|
|
994
|
+
| 400
|
|
995
|
+
| 401
|
|
996
|
+
| 403
|
|
997
|
+
| 422
|
|
998
|
+
| 429
|
|
999
|
+
| 500;
|
|
1000
|
+
return c.json({ isSuccess: false, error: result.error }, status);
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
// @cast-boundary engine-payload — generic dispatcher.write result for mfa-preauth-confirm handler
|
|
1004
|
+
const data = result.data as { kind: "mfa-preauth-confirm-success"; session: SessionUser };
|
|
1005
|
+
|
|
1006
|
+
const token = await mintSessionAndRespond(c, data.session);
|
|
1007
|
+
|
|
1008
|
+
if (rateLimiter) {
|
|
1009
|
+
await rateLimiter.reset(clientIp);
|
|
1010
|
+
}
|
|
510
1011
|
|
|
511
1012
|
return c.json({
|
|
512
1013
|
isSuccess: true,
|
|
@@ -554,6 +1055,26 @@ export function createAuthRoutes(
|
|
|
554
1055
|
});
|
|
555
1056
|
}
|
|
556
1057
|
|
|
1058
|
+
// Account-unlock mirrors email-verification (token-only confirm body) —
|
|
1059
|
+
// clears the Redis lockout state instead of an entity field, see
|
|
1060
|
+
// confirm-account-unlock.write.ts.
|
|
1061
|
+
if (config.accountUnlock) {
|
|
1062
|
+
const au = config.accountUnlock;
|
|
1063
|
+
registerTokenRequestRoute({
|
|
1064
|
+
api,
|
|
1065
|
+
dispatcher,
|
|
1066
|
+
path: Routes.authRequestAccountUnlock,
|
|
1067
|
+
requestHandler: au.requestHandler,
|
|
1068
|
+
});
|
|
1069
|
+
registerTokenConfirmRoute({
|
|
1070
|
+
api,
|
|
1071
|
+
dispatcher,
|
|
1072
|
+
path: Routes.authConfirmAccountUnlock,
|
|
1073
|
+
confirmHandler: au.confirmHandler,
|
|
1074
|
+
schema: VerifyEmailBody,
|
|
1075
|
+
});
|
|
1076
|
+
}
|
|
1077
|
+
|
|
557
1078
|
// Self-Signup (Magic-Link). Request mountet wie reset/verify den
|
|
558
1079
|
// silent-success-Pfad mit Token-Mail. Confirm ist anders: returnt
|
|
559
1080
|
// SessionUser → die Route mintet JWT + setzt Cookies (Auto-Login
|
|
@@ -590,18 +1111,8 @@ export function createAuthRoutes(
|
|
|
590
1111
|
tenantKey: string;
|
|
591
1112
|
};
|
|
592
1113
|
|
|
593
|
-
// Session
|
|
594
|
-
|
|
595
|
-
// (Logout, Compromise).
|
|
596
|
-
let sessionForJwt: SessionUser = data.session;
|
|
597
|
-
if (config.sessionCreator) {
|
|
598
|
-
const sid = await config.sessionCreator(data.session, requestMeta(c));
|
|
599
|
-
sessionForJwt = { ...data.session, sid };
|
|
600
|
-
}
|
|
601
|
-
|
|
602
|
-
const token = await jwt.sign(sessionForJwt);
|
|
603
|
-
const csrfToken = generateToken();
|
|
604
|
-
setAuthCookies(c, { token, csrfToken, sameSite: cookieSameSite, domain: cookieDomain });
|
|
1114
|
+
// Session creation + JWT sign + cookies — see mintSessionAndRespond.
|
|
1115
|
+
const token = await mintSessionAndRespond(c, data.session);
|
|
605
1116
|
|
|
606
1117
|
return c.json({
|
|
607
1118
|
isSuccess: true,
|
|
@@ -677,14 +1188,7 @@ export function createAuthRoutes(
|
|
|
677
1188
|
tenantId: TenantId;
|
|
678
1189
|
role: string;
|
|
679
1190
|
}; // @cast-boundary engine-payload
|
|
680
|
-
|
|
681
|
-
if (config.sessionCreator) {
|
|
682
|
-
const sid = await config.sessionCreator(data.session, requestMeta(c));
|
|
683
|
-
sessionForJwt = { ...data.session, sid };
|
|
684
|
-
}
|
|
685
|
-
const token = await jwt.sign(sessionForJwt);
|
|
686
|
-
const csrfToken = generateToken();
|
|
687
|
-
setAuthCookies(c, { token, csrfToken, sameSite: cookieSameSite, domain: cookieDomain });
|
|
1191
|
+
const token = await mintSessionAndRespond(c, data.session);
|
|
688
1192
|
return c.json({
|
|
689
1193
|
isSuccess: true,
|
|
690
1194
|
token,
|
|
@@ -717,14 +1221,7 @@ export function createAuthRoutes(
|
|
|
717
1221
|
tenantId: TenantId;
|
|
718
1222
|
role: string;
|
|
719
1223
|
}; // @cast-boundary engine-payload
|
|
720
|
-
|
|
721
|
-
if (config.sessionCreator) {
|
|
722
|
-
const sid = await config.sessionCreator(data.session, requestMeta(c));
|
|
723
|
-
sessionForJwt = { ...data.session, sid };
|
|
724
|
-
}
|
|
725
|
-
const token = await jwt.sign(sessionForJwt);
|
|
726
|
-
const csrfToken = generateToken();
|
|
727
|
-
setAuthCookies(c, { token, csrfToken, sameSite: cookieSameSite, domain: cookieDomain });
|
|
1224
|
+
const token = await mintSessionAndRespond(c, data.session);
|
|
728
1225
|
return c.json({
|
|
729
1226
|
isSuccess: true,
|
|
730
1227
|
token,
|
|
@@ -857,20 +1354,19 @@ export function createAuthRoutes(
|
|
|
857
1354
|
// tenant A accidentally surviving into tenant B's session). The
|
|
858
1355
|
// resolver runs each feature's r.authClaims() hook under the new
|
|
859
1356
|
// TenantDb scope.
|
|
860
|
-
//
|
|
1357
|
+
// buildSessionRoles calls stripForbiddenMembershipRoles internally and
|
|
1358
|
+
// strips reserved roles from the membership side only — globalRoles
|
|
861
1359
|
// (where SystemAdmin legitimately lives) is never filtered. Backstop for a
|
|
862
1360
|
// membership role that a projection rebuild resurrected past command-time
|
|
863
1361
|
// validation (see engine/membership-roles).
|
|
864
|
-
const mergedRoles =
|
|
865
|
-
new Set([...globalRoles, ...stripForbiddenMembershipRoles(membership.roles)]),
|
|
866
|
-
);
|
|
1362
|
+
const mergedRoles = buildSessionRoles(globalRoles, membership.roles);
|
|
867
1363
|
const targetSession: SessionUser = {
|
|
868
1364
|
id: user.id,
|
|
869
1365
|
tenantId: targetTenantId,
|
|
870
1366
|
roles: mergedRoles,
|
|
871
1367
|
};
|
|
872
1368
|
const claims = await dispatcher.resolveAuthClaims(targetSession);
|
|
873
|
-
|
|
1369
|
+
const sessionForJwt: SessionUser =
|
|
874
1370
|
Object.keys(claims).length > 0 ? { ...targetSession, claims } : targetSession;
|
|
875
1371
|
|
|
876
1372
|
// Session rotation: revoke old sid BEFORE creating the new one so a
|
|
@@ -882,25 +1378,10 @@ export function createAuthRoutes(
|
|
|
882
1378
|
if (config.sessionRevoker && user.sid) {
|
|
883
1379
|
await config.sessionRevoker(user.sid);
|
|
884
1380
|
}
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
const newToken = await jwt.sign(sessionForJwt);
|
|
891
|
-
|
|
892
|
-
// Rotate both cookies in lock-step with the new JWT. A fresh csrfToken
|
|
893
|
-
// is minted so a compromised csrf-value (e.g. leaked via a bug in the
|
|
894
|
-
// app's JS) can't cross a tenant boundary. Bearer-only clients get
|
|
895
|
-
// the new token in the body below — their Set-Cookie is a no-op
|
|
896
|
-
// because the browser never sent cookies.
|
|
897
|
-
const csrfToken = generateToken();
|
|
898
|
-
setAuthCookies(c, {
|
|
899
|
-
token: newToken,
|
|
900
|
-
csrfToken,
|
|
901
|
-
sameSite: cookieSameSite,
|
|
902
|
-
domain: cookieDomain,
|
|
903
|
-
});
|
|
1381
|
+
// Session creation + JWT sign + cookies — see mintSessionAndRespond. A
|
|
1382
|
+
// fresh csrfToken is minted (inside the helper) so a compromised csrf-
|
|
1383
|
+
// value can't cross a tenant boundary.
|
|
1384
|
+
const newToken = await mintSessionAndRespond(c, sessionForJwt);
|
|
904
1385
|
|
|
905
1386
|
return c.json({ token: newToken, tenantId: targetTenantId, roles: mergedRoles });
|
|
906
1387
|
});
|