@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
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
// auth-routes /auth/mfa/preauth-enable-start — framework-level route
|
|
2
|
+
// mechanics only: body validation, dispatch-without-minting-a-session, and
|
|
3
|
+
// error-status mapping. Uses a stub Dispatcher with a fake handler — the
|
|
4
|
+
// REAL preauthSetupToken verification / secret generation lives in
|
|
5
|
+
// auth-mfa's own handler and is covered there, not here.
|
|
6
|
+
|
|
7
|
+
import { describe, expect, test } from "bun:test";
|
|
8
|
+
import type { Hono } from "hono";
|
|
9
|
+
import { Hono as HonoCtor } from "hono";
|
|
10
|
+
import { InternalError, UnprocessableError } from "../../errors";
|
|
11
|
+
import type { BatchResult, Dispatcher, WriteResult } from "../../pipeline/dispatcher";
|
|
12
|
+
import { PUBLIC_API_PATHS } from "../api-constants";
|
|
13
|
+
import { authMiddleware } from "../auth-middleware";
|
|
14
|
+
import {
|
|
15
|
+
type AuthRoutesConfig,
|
|
16
|
+
createAuthRoutes,
|
|
17
|
+
createInMemoryLoginRateLimiter,
|
|
18
|
+
} from "../auth-routes";
|
|
19
|
+
import { createJwtHelper } from "../jwt";
|
|
20
|
+
|
|
21
|
+
const JWT_SECRET = "test-jwt-secret-at-least-32-bytes-long!!";
|
|
22
|
+
const PREAUTH_ENABLE_START_QN = "auth-mfa:write:enable-start-preauth";
|
|
23
|
+
|
|
24
|
+
function createStubDispatcher(overrides?: Partial<Dispatcher>): Dispatcher {
|
|
25
|
+
const base: Dispatcher = {
|
|
26
|
+
async write(): Promise<WriteResult> {
|
|
27
|
+
const ok: WriteResult = {
|
|
28
|
+
isSuccess: true,
|
|
29
|
+
data: {
|
|
30
|
+
setupToken: "stub-setup-token",
|
|
31
|
+
otpauthUri: "otpauth://totp/stub",
|
|
32
|
+
recoveryCodes: ["AAAA-BBBB"],
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
return ok;
|
|
36
|
+
},
|
|
37
|
+
async query(): Promise<unknown> {
|
|
38
|
+
return [];
|
|
39
|
+
},
|
|
40
|
+
async *stream(): AsyncGenerator<unknown> {},
|
|
41
|
+
async command(): Promise<void> {},
|
|
42
|
+
async batch(): Promise<BatchResult> {
|
|
43
|
+
const ok: BatchResult = { isSuccess: true, results: [] };
|
|
44
|
+
return ok;
|
|
45
|
+
},
|
|
46
|
+
async resolveAuthClaims(): Promise<Record<string, unknown>> {
|
|
47
|
+
return {};
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
return { ...base, ...overrides };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function buildApp(
|
|
54
|
+
overrides: Partial<AuthRoutesConfig> = {},
|
|
55
|
+
dispatcher: Dispatcher = createStubDispatcher(),
|
|
56
|
+
): Promise<{ app: Hono }> {
|
|
57
|
+
const jwt = createJwtHelper(JWT_SECRET);
|
|
58
|
+
const config: AuthRoutesConfig = {
|
|
59
|
+
membershipQuery: "tenant:query:memberships",
|
|
60
|
+
mfaPreauthEnableStartHandler: PREAUTH_ENABLE_START_QN,
|
|
61
|
+
...overrides,
|
|
62
|
+
};
|
|
63
|
+
const app = new HonoCtor();
|
|
64
|
+
const jwtGuard = authMiddleware(jwt);
|
|
65
|
+
app.use("/api/*", async (c, next) => {
|
|
66
|
+
if (PUBLIC_API_PATHS.has(c.req.path)) return next();
|
|
67
|
+
return jwtGuard(c, next);
|
|
68
|
+
});
|
|
69
|
+
app.route("/api", createAuthRoutes(dispatcher, jwt, config));
|
|
70
|
+
return { app };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function preauthStartRequest(body: unknown): Request {
|
|
74
|
+
return new Request("http://localhost/api/auth/mfa/preauth-enable-start", {
|
|
75
|
+
method: "POST",
|
|
76
|
+
headers: { "Content-Type": "application/json" },
|
|
77
|
+
body: JSON.stringify(body),
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
describe("POST /auth/mfa/preauth-enable-start", () => {
|
|
82
|
+
test("is public — reachable without a JWT", async () => {
|
|
83
|
+
expect(PUBLIC_API_PATHS.has("/api/auth/mfa/preauth-enable-start")).toBe(true);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("not mounted when mfaPreauthEnableStartHandler is unset", async () => {
|
|
87
|
+
const { app } = await buildApp({ mfaPreauthEnableStartHandler: undefined });
|
|
88
|
+
const res = await app.request(
|
|
89
|
+
preauthStartRequest({ preauthSetupToken: "t", accountLabel: "a@b.c" }),
|
|
90
|
+
);
|
|
91
|
+
expect(res.status).toBe(404);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("400 on a malformed body, before dispatch", async () => {
|
|
95
|
+
let dispatched = false;
|
|
96
|
+
const dispatcher = createStubDispatcher({
|
|
97
|
+
async write(): Promise<WriteResult> {
|
|
98
|
+
dispatched = true;
|
|
99
|
+
return {
|
|
100
|
+
isSuccess: true,
|
|
101
|
+
data: { setupToken: "x", otpauthUri: "otpauth://totp/x", recoveryCodes: [] },
|
|
102
|
+
};
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
const { app } = await buildApp({}, dispatcher);
|
|
106
|
+
const res = await app.request(preauthStartRequest({ preauthSetupToken: "t" }));
|
|
107
|
+
expect(res.status).toBe(400);
|
|
108
|
+
const body = (await res.json()) as { isSuccess: boolean; error: string };
|
|
109
|
+
expect(body.error).toBe("invalid_body");
|
|
110
|
+
expect(dispatched).toBe(false);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("on success: dispatches to the handler, returns setupToken/otpauthUri/recoveryCodes, mints NO session", async () => {
|
|
114
|
+
let receivedBody: unknown;
|
|
115
|
+
const dispatcher = createStubDispatcher({
|
|
116
|
+
async write(qn, payload): Promise<WriteResult> {
|
|
117
|
+
expect(qn).toBe(PREAUTH_ENABLE_START_QN);
|
|
118
|
+
receivedBody = payload;
|
|
119
|
+
return {
|
|
120
|
+
isSuccess: true,
|
|
121
|
+
data: {
|
|
122
|
+
setupToken: "secret-carrying-setup-token",
|
|
123
|
+
otpauthUri: "otpauth://totp/Kumiko:a%40b.c",
|
|
124
|
+
recoveryCodes: ["AAAA-BBBB", "CCCC-DDDD"],
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
},
|
|
128
|
+
});
|
|
129
|
+
const { app } = await buildApp({}, dispatcher);
|
|
130
|
+
const res = await app.request(
|
|
131
|
+
preauthStartRequest({ preauthSetupToken: "opaque-preauth-token", accountLabel: "a@b.c" }),
|
|
132
|
+
);
|
|
133
|
+
expect(res.status).toBe(200);
|
|
134
|
+
expect(receivedBody).toEqual({
|
|
135
|
+
preauthSetupToken: "opaque-preauth-token",
|
|
136
|
+
accountLabel: "a@b.c",
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
const body = (await res.json()) as {
|
|
140
|
+
isSuccess: boolean;
|
|
141
|
+
setupToken: string;
|
|
142
|
+
otpauthUri: string;
|
|
143
|
+
recoveryCodes: string[];
|
|
144
|
+
token?: string;
|
|
145
|
+
};
|
|
146
|
+
expect(body.isSuccess).toBe(true);
|
|
147
|
+
expect(body.setupToken).toBe("secret-carrying-setup-token");
|
|
148
|
+
expect(body.otpauthUri).toBe("otpauth://totp/Kumiko:a%40b.c");
|
|
149
|
+
expect(body.recoveryCodes).toEqual(["AAAA-BBBB", "CCCC-DDDD"]);
|
|
150
|
+
// No JWT minted — unlike /auth/login and /auth/mfa/verify, this route
|
|
151
|
+
// never establishes a session.
|
|
152
|
+
expect(body.token).toBeUndefined();
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("a handler failure maps through mfaPreauthEnableStartErrorStatusMap", async () => {
|
|
156
|
+
const dispatcher = createStubDispatcher({
|
|
157
|
+
async write(): Promise<WriteResult> {
|
|
158
|
+
return {
|
|
159
|
+
isSuccess: false,
|
|
160
|
+
error: new UnprocessableError("invalid_challenge_token", {
|
|
161
|
+
details: { reason: "invalid_challenge_token" },
|
|
162
|
+
}),
|
|
163
|
+
};
|
|
164
|
+
},
|
|
165
|
+
});
|
|
166
|
+
const { app } = await buildApp(
|
|
167
|
+
{ mfaPreauthEnableStartErrorStatusMap: { invalid_challenge_token: 422 } },
|
|
168
|
+
dispatcher,
|
|
169
|
+
);
|
|
170
|
+
const res = await app.request(
|
|
171
|
+
preauthStartRequest({ preauthSetupToken: "t", accountLabel: "a@b.c" }),
|
|
172
|
+
);
|
|
173
|
+
expect(res.status).toBe(422);
|
|
174
|
+
const body = (await res.json()) as { isSuccess: boolean };
|
|
175
|
+
expect(body.isSuccess).toBe(false);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("an unmapped handler failure falls back to the error's own httpStatus", async () => {
|
|
179
|
+
const dispatcher = createStubDispatcher({
|
|
180
|
+
async write(): Promise<WriteResult> {
|
|
181
|
+
return { isSuccess: false, error: new InternalError({ message: "boom" }) };
|
|
182
|
+
},
|
|
183
|
+
});
|
|
184
|
+
const { app } = await buildApp({}, dispatcher);
|
|
185
|
+
const res = await app.request(
|
|
186
|
+
preauthStartRequest({ preauthSetupToken: "t", accountLabel: "a@b.c" }),
|
|
187
|
+
);
|
|
188
|
+
expect(res.status).toBe(500);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
// #1466: this route inherited zero rate-limiting — a preauthSetupToken is
|
|
192
|
+
// valid (and not single-use) for its whole TTL, so an unrated-limited
|
|
193
|
+
// replay is a memory-hard CPU amplifier (8 argon2id recovery-code hashes
|
|
194
|
+
// per hit). Mirrors mfaPreauthConfirmRateLimit's own regression test.
|
|
195
|
+
test("mfaPreauthEnableStartRateLimit caps replays — 429 after its own limit", async () => {
|
|
196
|
+
const { app } = await buildApp({
|
|
197
|
+
mfaPreauthEnableStartRateLimit: createInMemoryLoginRateLimiter(2, 60_000),
|
|
198
|
+
});
|
|
199
|
+
const attempt = () =>
|
|
200
|
+
app.request(preauthStartRequest({ preauthSetupToken: "t", accountLabel: "a@b.c" }));
|
|
201
|
+
expect((await attempt()).status).toBe(200);
|
|
202
|
+
expect((await attempt()).status).toBe(200);
|
|
203
|
+
const third = await attempt();
|
|
204
|
+
expect(third.status).toBe(429);
|
|
205
|
+
const body = (await third.json()) as { isSuccess: boolean; error: string };
|
|
206
|
+
expect(body.error).toBe("rate_limited");
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
// #1522 / #1545: the IP axis alone is bypassable by rotating
|
|
210
|
+
// x-forwarded-for. The second check keys on a sha256 of the
|
|
211
|
+
// preauthSetupToken — prove rotating IPs still hit 429 on the same
|
|
212
|
+
// token, and that a different token does not share that bucket.
|
|
213
|
+
test("preauthSetupToken rate-limit axis survives x-forwarded-for rotation", async () => {
|
|
214
|
+
const { app } = await buildApp({
|
|
215
|
+
mfaPreauthEnableStartRateLimit: createInMemoryLoginRateLimiter(2, 60_000),
|
|
216
|
+
});
|
|
217
|
+
const attempt = (token: string, forwardedFor: string) =>
|
|
218
|
+
app.request(
|
|
219
|
+
new Request("http://localhost/api/auth/mfa/preauth-enable-start", {
|
|
220
|
+
method: "POST",
|
|
221
|
+
headers: {
|
|
222
|
+
"Content-Type": "application/json",
|
|
223
|
+
"x-forwarded-for": forwardedFor,
|
|
224
|
+
},
|
|
225
|
+
body: JSON.stringify({ preauthSetupToken: token, accountLabel: "a@b.c" }),
|
|
226
|
+
}),
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
expect((await attempt("shared-token", "10.0.0.1")).status).toBe(200);
|
|
230
|
+
expect((await attempt("shared-token", "10.0.0.2")).status).toBe(200);
|
|
231
|
+
const blocked = await attempt("shared-token", "10.0.0.3");
|
|
232
|
+
expect(blocked.status).toBe(429);
|
|
233
|
+
const body = (await blocked.json()) as { isSuccess: boolean; error: string };
|
|
234
|
+
expect(body.error).toBe("rate_limited");
|
|
235
|
+
|
|
236
|
+
// Distinct token → independent bucket; rotating IP must not inherit the
|
|
237
|
+
// prior token's exhaustion.
|
|
238
|
+
expect((await attempt("other-token", "10.0.0.4")).status).toBe(200);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test("mfaPreauthEnableStartRateLimit: null disables rate-limiting", async () => {
|
|
242
|
+
const { app } = await buildApp({ mfaPreauthEnableStartRateLimit: null });
|
|
243
|
+
const attempt = () =>
|
|
244
|
+
app.request(preauthStartRequest({ preauthSetupToken: "t", accountLabel: "a@b.c" }));
|
|
245
|
+
for (let i = 0; i < 15; i++) {
|
|
246
|
+
expect((await attempt()).status).toBe(200);
|
|
247
|
+
}
|
|
248
|
+
});
|
|
249
|
+
});
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
// auth-routes /auth/mfa/verify — framework-level route mechanics only:
|
|
2
|
+
// body validation, dispatch-and-mint-on-success, its OWN rate limiter
|
|
3
|
+
// (mfaVerifyRateLimit, distinct from loginRateLimit), and error-status
|
|
4
|
+
// mapping. Uses a stub Dispatcher with a fake mfaVerifyHandler — the REAL
|
|
5
|
+
// challenge-token verification / TOTP check / brute-force cap lives in
|
|
6
|
+
// auth-mfa's own handler and is covered there, not here.
|
|
7
|
+
|
|
8
|
+
import { describe, expect, test } from "bun:test";
|
|
9
|
+
import type { Hono } from "hono";
|
|
10
|
+
import { Hono as HonoCtor } from "hono";
|
|
11
|
+
import { InternalError, UnprocessableError } from "../../errors";
|
|
12
|
+
import type { BatchResult, Dispatcher, WriteResult } from "../../pipeline/dispatcher";
|
|
13
|
+
import { TestUsers } from "../../stack";
|
|
14
|
+
import { getSetCookies } from "../../testing/http-cookies";
|
|
15
|
+
import { PUBLIC_API_PATHS } from "../api-constants";
|
|
16
|
+
import { AUTH_COOKIE_NAME, authMiddleware, CSRF_COOKIE_NAME } from "../auth-middleware";
|
|
17
|
+
import {
|
|
18
|
+
type AuthRoutesConfig,
|
|
19
|
+
createAuthRoutes,
|
|
20
|
+
createInMemoryLoginRateLimiter,
|
|
21
|
+
} from "../auth-routes";
|
|
22
|
+
import { createJwtHelper } from "../jwt";
|
|
23
|
+
|
|
24
|
+
const JWT_SECRET = "auth-routes-mfa-verify-test-secret-min-32-characters";
|
|
25
|
+
const MFA_VERIFY_QN = "auth-mfa:write:verify";
|
|
26
|
+
|
|
27
|
+
function createStubDispatcher(overrides?: Partial<Dispatcher>): Dispatcher {
|
|
28
|
+
const base: Dispatcher = {
|
|
29
|
+
async write(): Promise<WriteResult> {
|
|
30
|
+
const ok: WriteResult = {
|
|
31
|
+
isSuccess: true,
|
|
32
|
+
data: { kind: "mfa-verify-success", session: TestUsers.user },
|
|
33
|
+
};
|
|
34
|
+
return ok;
|
|
35
|
+
},
|
|
36
|
+
async query(): Promise<unknown> {
|
|
37
|
+
return [];
|
|
38
|
+
},
|
|
39
|
+
async *stream(): AsyncGenerator<unknown> {},
|
|
40
|
+
async command(): Promise<void> {},
|
|
41
|
+
async batch(): Promise<BatchResult> {
|
|
42
|
+
const ok: BatchResult = { isSuccess: true, results: [] };
|
|
43
|
+
return ok;
|
|
44
|
+
},
|
|
45
|
+
async resolveAuthClaims(): Promise<Record<string, unknown>> {
|
|
46
|
+
return {};
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
return { ...base, ...overrides };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function buildApp(
|
|
53
|
+
overrides: Partial<AuthRoutesConfig> = {},
|
|
54
|
+
dispatcher: Dispatcher = createStubDispatcher(),
|
|
55
|
+
): Promise<{ app: Hono }> {
|
|
56
|
+
const jwt = createJwtHelper(JWT_SECRET);
|
|
57
|
+
const config: AuthRoutesConfig = {
|
|
58
|
+
membershipQuery: "tenant:query:memberships",
|
|
59
|
+
mfaVerifyHandler: MFA_VERIFY_QN,
|
|
60
|
+
mfaVerifyRateLimit: null,
|
|
61
|
+
...overrides,
|
|
62
|
+
};
|
|
63
|
+
const app = new HonoCtor();
|
|
64
|
+
const jwtGuard = authMiddleware(jwt);
|
|
65
|
+
app.use("/api/*", async (c, next) => {
|
|
66
|
+
if (PUBLIC_API_PATHS.has(c.req.path)) return next();
|
|
67
|
+
return jwtGuard(c, next);
|
|
68
|
+
});
|
|
69
|
+
app.route("/api", createAuthRoutes(dispatcher, jwt, config));
|
|
70
|
+
return { app };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function verifyRequest(body: unknown): Request {
|
|
74
|
+
return new Request("http://localhost/api/auth/mfa/verify", {
|
|
75
|
+
method: "POST",
|
|
76
|
+
headers: { "Content-Type": "application/json" },
|
|
77
|
+
body: JSON.stringify(body),
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
describe("POST /auth/mfa/verify", () => {
|
|
82
|
+
test("is public — reachable without a JWT", async () => {
|
|
83
|
+
expect(PUBLIC_API_PATHS.has("/api/auth/mfa/verify")).toBe(true);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("not mounted when mfaVerifyHandler is unset", async () => {
|
|
87
|
+
const { app } = await buildApp({ mfaVerifyHandler: undefined });
|
|
88
|
+
const res = await app.request(verifyRequest({ challengeToken: "t", code: "123456" }));
|
|
89
|
+
expect(res.status).toBe(404);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("400 on a malformed body, before dispatch or rate-limit", async () => {
|
|
93
|
+
let dispatched = false;
|
|
94
|
+
const dispatcher = createStubDispatcher({
|
|
95
|
+
async write(): Promise<WriteResult> {
|
|
96
|
+
dispatched = true;
|
|
97
|
+
return { isSuccess: true, data: { kind: "mfa-verify-success", session: TestUsers.user } };
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
const { app } = await buildApp({}, dispatcher);
|
|
101
|
+
const res = await app.request(verifyRequest({ challengeToken: "t" }));
|
|
102
|
+
expect(res.status).toBe(400);
|
|
103
|
+
const body = (await res.json()) as { isSuccess: boolean; error: string };
|
|
104
|
+
expect(body.error).toBe("invalid_body");
|
|
105
|
+
expect(dispatched).toBe(false);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("on success: dispatches to mfaVerifyHandler, mints a JWT + cookies", async () => {
|
|
109
|
+
let receivedBody: unknown;
|
|
110
|
+
const dispatcher = createStubDispatcher({
|
|
111
|
+
async write(qn, payload): Promise<WriteResult> {
|
|
112
|
+
expect(qn).toBe(MFA_VERIFY_QN);
|
|
113
|
+
receivedBody = payload;
|
|
114
|
+
return { isSuccess: true, data: { kind: "mfa-verify-success", session: TestUsers.user } };
|
|
115
|
+
},
|
|
116
|
+
});
|
|
117
|
+
const { app } = await buildApp({}, dispatcher);
|
|
118
|
+
const res = await app.request(
|
|
119
|
+
verifyRequest({ challengeToken: "opaque-challenge-token", code: "123456" }),
|
|
120
|
+
);
|
|
121
|
+
expect(res.status).toBe(200);
|
|
122
|
+
expect(receivedBody).toEqual({
|
|
123
|
+
challengeToken: "opaque-challenge-token",
|
|
124
|
+
code: "123456",
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
const body = (await res.json()) as { isSuccess: boolean; token: string };
|
|
128
|
+
expect(body.isSuccess).toBe(true);
|
|
129
|
+
expect(typeof body.token).toBe("string");
|
|
130
|
+
expect(body.token.length).toBeGreaterThan(20);
|
|
131
|
+
|
|
132
|
+
const cookies = getSetCookies(res);
|
|
133
|
+
expect(cookies.get(AUTH_COOKIE_NAME)).toBeDefined();
|
|
134
|
+
expect(cookies.get(CSRF_COOKIE_NAME)).toBeDefined();
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("a handler failure maps through mfaVerifyErrorStatusMap", async () => {
|
|
138
|
+
const dispatcher = createStubDispatcher({
|
|
139
|
+
async write(): Promise<WriteResult> {
|
|
140
|
+
return {
|
|
141
|
+
isSuccess: false,
|
|
142
|
+
error: new UnprocessableError("invalid_totp_code", {
|
|
143
|
+
details: { reason: "invalid_totp_code" },
|
|
144
|
+
}),
|
|
145
|
+
};
|
|
146
|
+
},
|
|
147
|
+
});
|
|
148
|
+
const { app } = await buildApp(
|
|
149
|
+
{ mfaVerifyErrorStatusMap: { invalid_totp_code: 422 } },
|
|
150
|
+
dispatcher,
|
|
151
|
+
);
|
|
152
|
+
const res = await app.request(verifyRequest({ challengeToken: "t", code: "000000" }));
|
|
153
|
+
expect(res.status).toBe(422);
|
|
154
|
+
const body = (await res.json()) as { isSuccess: boolean };
|
|
155
|
+
expect(body.isSuccess).toBe(false);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("an unmapped handler failure falls back to the error's own httpStatus", async () => {
|
|
159
|
+
const dispatcher = createStubDispatcher({
|
|
160
|
+
async write(): Promise<WriteResult> {
|
|
161
|
+
return { isSuccess: false, error: new InternalError({ message: "boom" }) };
|
|
162
|
+
},
|
|
163
|
+
});
|
|
164
|
+
const { app } = await buildApp({}, dispatcher);
|
|
165
|
+
const res = await app.request(verifyRequest({ challengeToken: "t", code: "000000" }));
|
|
166
|
+
expect(res.status).toBe(500);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("mfaVerifyRateLimit is independent from loginRateLimit — 429 after its own cap", async () => {
|
|
170
|
+
// A failing dispatcher — reset() only fires on success, so failed
|
|
171
|
+
// attempts accumulate against the cap instead of clearing it each time.
|
|
172
|
+
const dispatcher = createStubDispatcher({
|
|
173
|
+
async write(): Promise<WriteResult> {
|
|
174
|
+
return {
|
|
175
|
+
isSuccess: false,
|
|
176
|
+
error: new UnprocessableError("invalid_totp_code"),
|
|
177
|
+
};
|
|
178
|
+
},
|
|
179
|
+
});
|
|
180
|
+
const { app } = await buildApp(
|
|
181
|
+
{ mfaVerifyRateLimit: createInMemoryLoginRateLimiter(2, 60_000) },
|
|
182
|
+
dispatcher,
|
|
183
|
+
);
|
|
184
|
+
const attempt = () => app.request(verifyRequest({ challengeToken: "t", code: "000000" }));
|
|
185
|
+
expect((await attempt()).status).toBe(422);
|
|
186
|
+
expect((await attempt()).status).toBe(422);
|
|
187
|
+
const third = await attempt();
|
|
188
|
+
expect(third.status).toBe(429);
|
|
189
|
+
const body = (await third.json()) as { isSuccess: boolean; error: string };
|
|
190
|
+
expect(body.error).toBe("rate_limited");
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test("a successful verify resets the rate-limit counter for that IP", async () => {
|
|
194
|
+
const { app } = await buildApp({
|
|
195
|
+
mfaVerifyRateLimit: createInMemoryLoginRateLimiter(1, 60_000),
|
|
196
|
+
});
|
|
197
|
+
const attempt = () => app.request(verifyRequest({ challengeToken: "t", code: "000000" }));
|
|
198
|
+
expect((await attempt()).status).toBe(200);
|
|
199
|
+
// Without a reset-on-success this would be 429 (cap=1 already spent).
|
|
200
|
+
expect((await attempt()).status).toBe(200);
|
|
201
|
+
});
|
|
202
|
+
});
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// kumiko-framework#1539: `trustedProxyHops` on AuthRoutesConfig. Verifies
|
|
2
|
+
// the client-IP derivation behind /auth/mfa/verify's rate limiter — chosen
|
|
3
|
+
// because it keys on bare IP (no email composite), so a wrong default here
|
|
4
|
+
// is a shared-bucket DoS, not just a spoofing hole. Real Hono app + real
|
|
5
|
+
// in-memory rate limiter, HTTP requests with attacker-shaped headers — no
|
|
6
|
+
// fake dispatcher.
|
|
7
|
+
|
|
8
|
+
import { describe, expect, test } from "bun:test";
|
|
9
|
+
import type { Hono } from "hono";
|
|
10
|
+
import { Hono as HonoCtor } from "hono";
|
|
11
|
+
import { UnprocessableError } from "../../errors";
|
|
12
|
+
import type { BatchResult, Dispatcher, WriteResult } from "../../pipeline/dispatcher";
|
|
13
|
+
import { PUBLIC_API_PATHS } from "../api-constants";
|
|
14
|
+
import { authMiddleware } from "../auth-middleware";
|
|
15
|
+
import {
|
|
16
|
+
type AuthRoutesConfig,
|
|
17
|
+
createAuthRoutes,
|
|
18
|
+
createInMemoryLoginRateLimiter,
|
|
19
|
+
} from "../auth-routes";
|
|
20
|
+
import { createJwtHelper } from "../jwt";
|
|
21
|
+
|
|
22
|
+
const JWT_SECRET = "test-secret-at-least-32-bytes-long-for-hs256";
|
|
23
|
+
const MFA_VERIFY_QN = "auth-mfa:write:verify";
|
|
24
|
+
|
|
25
|
+
function failingDispatcher(): Dispatcher {
|
|
26
|
+
return {
|
|
27
|
+
async write(): Promise<WriteResult> {
|
|
28
|
+
return { isSuccess: false, error: new UnprocessableError("invalid_totp_code") };
|
|
29
|
+
},
|
|
30
|
+
async query(): Promise<unknown> {
|
|
31
|
+
return [];
|
|
32
|
+
},
|
|
33
|
+
async *stream(): AsyncGenerator<unknown> {},
|
|
34
|
+
async command(): Promise<void> {},
|
|
35
|
+
async batch(): Promise<BatchResult> {
|
|
36
|
+
return { isSuccess: true, results: [] };
|
|
37
|
+
},
|
|
38
|
+
async resolveAuthClaims(): Promise<Record<string, unknown>> {
|
|
39
|
+
return {};
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function buildApp(overrides: Partial<AuthRoutesConfig> = {}): Promise<Hono> {
|
|
45
|
+
const jwt = createJwtHelper(JWT_SECRET);
|
|
46
|
+
const config: AuthRoutesConfig = {
|
|
47
|
+
membershipQuery: "tenant:query:memberships",
|
|
48
|
+
mfaVerifyHandler: MFA_VERIFY_QN,
|
|
49
|
+
mfaVerifyRateLimit: createInMemoryLoginRateLimiter(2, 60_000),
|
|
50
|
+
...overrides,
|
|
51
|
+
};
|
|
52
|
+
const app = new HonoCtor();
|
|
53
|
+
const jwtGuard = authMiddleware(jwt);
|
|
54
|
+
app.use("/api/*", async (c, next) => {
|
|
55
|
+
if (PUBLIC_API_PATHS.has(c.req.path)) return next();
|
|
56
|
+
return jwtGuard(c, next);
|
|
57
|
+
});
|
|
58
|
+
app.route("/api", createAuthRoutes(failingDispatcher(), jwt, config));
|
|
59
|
+
return app;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function verifyRequest(xForwardedFor?: string): Request {
|
|
63
|
+
return new Request("http://localhost/api/auth/mfa/verify", {
|
|
64
|
+
method: "POST",
|
|
65
|
+
headers: {
|
|
66
|
+
"Content-Type": "application/json",
|
|
67
|
+
...(xForwardedFor !== undefined ? { "x-forwarded-for": xForwardedFor } : {}),
|
|
68
|
+
},
|
|
69
|
+
body: JSON.stringify({ challengeToken: "t", code: "000000" }),
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
describe("AuthRoutesConfig.trustedProxyHops", () => {
|
|
74
|
+
test("rejects a negative trustedProxyHops at construction — fails loud, not into a shared bucket", async () => {
|
|
75
|
+
await expect(buildApp({ trustedProxyHops: -1 })).rejects.toThrow(/non-negative integer/);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("rejects a non-integer trustedProxyHops at construction", async () => {
|
|
79
|
+
await expect(buildApp({ trustedProxyHops: 1.5 })).rejects.toThrow(/non-negative integer/);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("hops=1: an attacker rotating the untrusted XFF prefix still hits the real client's bucket", async () => {
|
|
83
|
+
const app = await buildApp({ trustedProxyHops: 1 });
|
|
84
|
+
// nginx-style append: "<attacker-claim>, <real-client-as-seen-by-nginx>"
|
|
85
|
+
// — attacker rotates the left entry per request, last entry stays fixed.
|
|
86
|
+
const attempt = (n: number) => app.request(verifyRequest(`1.2.3.${n}, 9.9.9.9`));
|
|
87
|
+
expect((await attempt(1)).status).toBe(422);
|
|
88
|
+
expect((await attempt(2)).status).toBe(422);
|
|
89
|
+
const third = await attempt(3);
|
|
90
|
+
expect(third.status).toBe(429);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("hops=1: two genuinely different clients (differing last entry) get independent buckets", async () => {
|
|
94
|
+
const app = await buildApp({ trustedProxyHops: 1 });
|
|
95
|
+
const clientA = () => app.request(verifyRequest("fake, 9.9.9.9"));
|
|
96
|
+
const clientB = () => app.request(verifyRequest("fake, 8.8.8.8"));
|
|
97
|
+
expect((await clientA()).status).toBe(422);
|
|
98
|
+
expect((await clientA()).status).toBe(422);
|
|
99
|
+
expect((await clientA()).status).toBe(429);
|
|
100
|
+
// client B is unaffected by client A's cap
|
|
101
|
+
expect((await clientB()).status).toBe(422);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("hops=0 (default): legacy behavior — trusts the first XFF entry unconditionally", async () => {
|
|
105
|
+
const app = await buildApp();
|
|
106
|
+
// No override: two "different" first-entries mean two different buckets,
|
|
107
|
+
// even though both requests carry the same real backend hop.
|
|
108
|
+
const clientA = () => app.request(verifyRequest("1.1.1.1, 9.9.9.9"));
|
|
109
|
+
const clientB = () => app.request(verifyRequest("2.2.2.2, 9.9.9.9"));
|
|
110
|
+
expect((await clientA()).status).toBe(422);
|
|
111
|
+
expect((await clientA()).status).toBe(422);
|
|
112
|
+
expect((await clientA()).status).toBe(429);
|
|
113
|
+
expect((await clientB()).status).toBe(422);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("hops=2 with a shorter XFF chain than configured collapses distinct clients into the unknown bucket", async () => {
|
|
117
|
+
const app = await buildApp({ trustedProxyHops: 2 });
|
|
118
|
+
// Only 1 entry present but 2 hops configured — chain shorter than
|
|
119
|
+
// expected. Two DIFFERENT single-entry values must still share the cap:
|
|
120
|
+
// proves the short chain was rejected into "unknown" rather than
|
|
121
|
+
// extracted per-request (a same-value-only assertion would also pass
|
|
122
|
+
// for a constant return).
|
|
123
|
+
const attempt = (xff: string) => app.request(verifyRequest(xff));
|
|
124
|
+
expect((await attempt("9.9.9.9")).status).toBe(422);
|
|
125
|
+
expect((await attempt("7.7.7.7")).status).toBe(422);
|
|
126
|
+
const third = await attempt("6.6.6.6");
|
|
127
|
+
expect(third.status).toBe(429);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("hops=1 with no XFF header at all falls back to unknown, not a throw", async () => {
|
|
131
|
+
const app = await buildApp({ trustedProxyHops: 1 });
|
|
132
|
+
const res = await app.request(verifyRequest(undefined));
|
|
133
|
+
expect(res.status).toBe(422);
|
|
134
|
+
});
|
|
135
|
+
});
|