@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
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
2
2
|
import { seedRow } from "@cosmicdrift/kumiko-framework/testing";
|
|
3
3
|
import { z } from "zod";
|
|
4
|
+
import type { TableColumns } from "../../db/dialect";
|
|
4
5
|
import { createEventStoreExecutor } from "../../db/event-store-executor";
|
|
5
6
|
import { asRawClient, selectMany } from "../../db/query";
|
|
6
7
|
import { buildEntityTable } from "../../db/table-builder";
|
|
@@ -14,6 +15,7 @@ import {
|
|
|
14
15
|
type SaveContext,
|
|
15
16
|
} from "../../engine";
|
|
16
17
|
import { UnprocessableError, writeFailure } from "../../errors";
|
|
18
|
+
import { RedisKeys } from "../../pipeline/redis-keys";
|
|
17
19
|
import { setupTestStack, type TestStack, TestUsers, unsafeCreateEntityTable } from "../../stack";
|
|
18
20
|
|
|
19
21
|
// Entity: a simple "item" with name + counter
|
|
@@ -36,7 +38,9 @@ const auditEntity = createEntity({
|
|
|
36
38
|
itemId: createTextField({ required: true }),
|
|
37
39
|
},
|
|
38
40
|
});
|
|
39
|
-
|
|
41
|
+
// Brand (#742) is compile-time-only; the postSave hook writes this sink via method-form,
|
|
42
|
+
// so hold it at the unbranded TableColumns view (identical runtime shape).
|
|
43
|
+
const auditTable: TableColumns = buildEntityTable("audit", auditEntity);
|
|
40
44
|
|
|
41
45
|
// Hook invocation logs — reset per test. Captures which phase each hook saw.
|
|
42
46
|
const inTxHookLog: Array<{ id: EntityId; name: string }> = [];
|
|
@@ -78,9 +82,9 @@ const itemFeature = defineFeature("batch", (r) => {
|
|
|
78
82
|
);
|
|
79
83
|
|
|
80
84
|
// Entity hook: inTransaction — records in memory
|
|
81
|
-
r.
|
|
85
|
+
r.hook(
|
|
82
86
|
"postSave",
|
|
83
|
-
item,
|
|
87
|
+
{ allOf: item },
|
|
84
88
|
async (result: SaveContext) => {
|
|
85
89
|
inTxHookLog.push({ id: result.id, name: (result.data["name"] as string) ?? "" });
|
|
86
90
|
},
|
|
@@ -89,43 +93,43 @@ const itemFeature = defineFeature("batch", (r) => {
|
|
|
89
93
|
|
|
90
94
|
// Entity hook: inTransaction — writes to DB via ctx.db (the tx-scoped TenantDb).
|
|
91
95
|
// Proves that hook DB writes roll back with the main transaction on failure.
|
|
92
|
-
r.
|
|
96
|
+
r.hook(
|
|
93
97
|
"postSave",
|
|
94
|
-
item,
|
|
98
|
+
{ allOf: item },
|
|
95
99
|
async (result, ctx) => {
|
|
96
|
-
if (!ctx.db) return;
|
|
100
|
+
if (!ctx.db || !("insertOne" in ctx.db)) return;
|
|
97
101
|
await ctx.db.insertOne(auditTable, { action: "item_saved", itemId: result.id });
|
|
98
102
|
},
|
|
99
103
|
{ phase: HookPhases.inTransaction },
|
|
100
104
|
);
|
|
101
105
|
|
|
102
106
|
// Entity hook: afterCommit — records in memory (default phase)
|
|
103
|
-
r.
|
|
107
|
+
r.hook("postSave", { allOf: item }, async (result: SaveContext) => {
|
|
104
108
|
afterCommitHookLog.push({ id: result.id, name: (result.data["name"] as string) ?? "" });
|
|
105
109
|
});
|
|
106
110
|
|
|
107
111
|
// Entity hook: afterCommit — may throw, used to verify error isolation
|
|
108
|
-
r.
|
|
112
|
+
r.hook("postSave", { allOf: item }, async () => {
|
|
109
113
|
if (afterCommitShouldThrow) throw new Error("afterCommit_boom");
|
|
110
114
|
});
|
|
111
115
|
|
|
112
116
|
// Entity hook: afterCommit — runs AFTER the throwing one. Used to prove the
|
|
113
117
|
// next hooks still fire despite the earlier failure.
|
|
114
|
-
r.
|
|
118
|
+
r.hook("postSave", { allOf: item }, async (result: SaveContext) => {
|
|
115
119
|
afterCommitThirdHookRan.push((result.data["name"] as string) ?? "");
|
|
116
120
|
});
|
|
117
121
|
|
|
118
122
|
// Two hooks used by the parallelism test. Each records its start+end
|
|
119
123
|
// timestamps so the assertion can compare intervals rather than elapsed
|
|
120
124
|
// wall-clock time (which is timing-flaky on loaded CI boxes).
|
|
121
|
-
r.
|
|
125
|
+
r.hook("postSave", { allOf: item }, async (result: SaveContext) => {
|
|
122
126
|
const name = result.data["name"] as string;
|
|
123
127
|
if (!name?.startsWith("slowness-")) return;
|
|
124
128
|
parallelismWindows.push({ hook: "A", start: Date.now() });
|
|
125
129
|
await new Promise((r) => setTimeout(r, 80));
|
|
126
130
|
parallelismWindows.push({ hook: "A", end: Date.now() });
|
|
127
131
|
});
|
|
128
|
-
r.
|
|
132
|
+
r.hook("postSave", { allOf: item }, async (result: SaveContext) => {
|
|
129
133
|
const name = result.data["name"] as string;
|
|
130
134
|
if (!name?.startsWith("slowness-")) return;
|
|
131
135
|
parallelismWindows.push({ hook: "B", start: Date.now() });
|
|
@@ -367,6 +371,92 @@ describe("POST /api/batch", () => {
|
|
|
367
371
|
expect(inTxHookLog).toHaveLength(1);
|
|
368
372
|
expect(afterCommitHookLog).toHaveLength(1);
|
|
369
373
|
});
|
|
374
|
+
|
|
375
|
+
test("idempotency: failed batch is cached — retry returns same error without re-exec", async () => {
|
|
376
|
+
const requestId = "batch-rid-fail-cache";
|
|
377
|
+
const commands = [
|
|
378
|
+
{ type: "batch:write:item:create", payload: { name: "should-rollback" } },
|
|
379
|
+
{ type: "batch:write:item:fail", payload: { name: "stop" } },
|
|
380
|
+
];
|
|
381
|
+
|
|
382
|
+
const first = await stack.http.batch(commands, admin, requestId);
|
|
383
|
+
const firstBody = await first.json();
|
|
384
|
+
expect(first.status).toBe(422);
|
|
385
|
+
expect(firstBody.isSuccess).toBe(false);
|
|
386
|
+
expect(firstBody.failedIndex).toBe(1);
|
|
387
|
+
expect(inTxHookLog.map((h) => h.name)).toEqual(["should-rollback"]);
|
|
388
|
+
expect(await selectMany(stack.db, itemTable)).toHaveLength(0);
|
|
389
|
+
|
|
390
|
+
inTxHookLog.length = 0;
|
|
391
|
+
afterCommitHookLog.length = 0;
|
|
392
|
+
|
|
393
|
+
const second = await stack.http.batch(commands, admin, requestId);
|
|
394
|
+
const secondBody = await second.json();
|
|
395
|
+
expect(second.status).toBe(422);
|
|
396
|
+
expect(secondBody.isSuccess).toBe(false);
|
|
397
|
+
expect(secondBody.failedIndex).toBe(firstBody.failedIndex);
|
|
398
|
+
// HTTP re-serializes the cached failure with a fresh requestId/timestamp —
|
|
399
|
+
// pin the stable business fields, not the transport envelope.
|
|
400
|
+
expect(secondBody.error.code).toBe(firstBody.error.code);
|
|
401
|
+
expect(secondBody.error.details).toEqual(firstBody.error.details);
|
|
402
|
+
// Cached path must not re-run commands (hooks stay empty, DB empty)
|
|
403
|
+
expect(inTxHookLog).toHaveLength(0);
|
|
404
|
+
expect(afterCommitHookLog).toHaveLength(0);
|
|
405
|
+
expect(await selectMany(stack.db, itemTable)).toHaveLength(0);
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
test("idempotency: corrupted cache entry is treated as miss and re-runs", async () => {
|
|
409
|
+
const requestId = "batch-rid-corrupt";
|
|
410
|
+
const cacheKey = `${RedisKeys.idempotency}${requestId}`;
|
|
411
|
+
|
|
412
|
+
// Prove key-coupling first: seed a well-formed cached entry under the
|
|
413
|
+
// exact manually-built key and confirm the batch short-circuits on it
|
|
414
|
+
// (no handler run, no DB write) — if the manual key ever drifts from
|
|
415
|
+
// what the guard actually reads, this half fails loud instead of the
|
|
416
|
+
// corrupt-JSON half below silently becoming a no-op cache-miss test.
|
|
417
|
+
const seeded = { isSuccess: true, results: [{ seeded: true }] };
|
|
418
|
+
await stack.redis.redis.set(cacheKey, JSON.stringify(seeded), "EX", 60);
|
|
419
|
+
const cachedRes = await stack.http.batch(
|
|
420
|
+
[{ type: "batch:write:item:create", payload: { name: "should-not-run" } }],
|
|
421
|
+
admin,
|
|
422
|
+
requestId,
|
|
423
|
+
);
|
|
424
|
+
expect(await cachedRes.json()).toEqual(seeded);
|
|
425
|
+
expect(await selectMany(stack.db, itemTable)).toHaveLength(0);
|
|
426
|
+
expect(inTxHookLog).toHaveLength(0);
|
|
427
|
+
|
|
428
|
+
// Now corrupt the same key and confirm it's treated as a miss.
|
|
429
|
+
await stack.redis.redis.set(cacheKey, "{not-json", "EX", 60);
|
|
430
|
+
|
|
431
|
+
const res = await stack.http.batch(
|
|
432
|
+
[{ type: "batch:write:item:create", payload: { name: "after-corrupt" } }],
|
|
433
|
+
admin,
|
|
434
|
+
requestId,
|
|
435
|
+
);
|
|
436
|
+
const body = await res.json();
|
|
437
|
+
expect(res.status).toBe(200);
|
|
438
|
+
expect(body.isSuccess).toBe(true);
|
|
439
|
+
expect(await selectMany(stack.db, itemTable)).toHaveLength(1);
|
|
440
|
+
expect(inTxHookLog.map((h) => h.name)).toEqual(["after-corrupt"]);
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
test("mid-batch handler throw: rolls back prior writes, afterCommit does not fire", async () => {
|
|
444
|
+
const res = await stack.http.batch(
|
|
445
|
+
[
|
|
446
|
+
{ type: "batch:write:item:create", payload: { name: "before-throw" } },
|
|
447
|
+
{ type: "batch:write:item:throw", payload: { name: "boom" } },
|
|
448
|
+
{ type: "batch:write:item:create", payload: { name: "never" } },
|
|
449
|
+
],
|
|
450
|
+
admin,
|
|
451
|
+
);
|
|
452
|
+
const body = await res.json();
|
|
453
|
+
expect(body.isSuccess).toBe(false);
|
|
454
|
+
expect(body.failedIndex).toBe(1);
|
|
455
|
+
expect(inTxHookLog.map((h) => h.name)).toEqual(["before-throw"]);
|
|
456
|
+
expect(afterCommitHookLog).toEqual([]);
|
|
457
|
+
expect(await selectMany(stack.db, itemTable)).toHaveLength(0);
|
|
458
|
+
expect(await selectMany(stack.db, auditTable)).toHaveLength(0);
|
|
459
|
+
});
|
|
370
460
|
});
|
|
371
461
|
|
|
372
462
|
describe("POST /api/write (single write runs in its own transaction)", () => {
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
CSRF_COOKIE_NAME as DISPATCHER_CSRF_COOKIE_NAME,
|
|
4
|
+
CSRF_HEADER_NAME as DISPATCHER_CSRF_HEADER_NAME,
|
|
5
|
+
} from "@cosmicdrift/kumiko-dispatcher-live";
|
|
6
|
+
import { CSRF_COOKIE_NAME, CSRF_HEADER_NAME } from "../auth-middleware";
|
|
7
|
+
|
|
8
|
+
// dispatcher-live keeps its own literal copies of these two constants
|
|
9
|
+
// (see packages/dispatcher-live/src/csrf.ts) because it must stay
|
|
10
|
+
// server-dep-free for browser/React Native bundles. This test is the
|
|
11
|
+
// guardrail that catches drift in CI instead of at runtime.
|
|
12
|
+
describe("CSRF constant sync between framework and dispatcher-live", () => {
|
|
13
|
+
test("cookie name matches", () => {
|
|
14
|
+
expect(DISPATCHER_CSRF_COOKIE_NAME).toBe(CSRF_COOKIE_NAME);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("header name matches", () => {
|
|
18
|
+
expect(DISPATCHER_CSRF_HEADER_NAME).toBe(CSRF_HEADER_NAME);
|
|
19
|
+
});
|
|
20
|
+
});
|
|
@@ -48,6 +48,17 @@ const itemFeature = defineFeature("dlive", (r) => {
|
|
|
48
48
|
},
|
|
49
49
|
{ access: { roles: ["Admin"] } },
|
|
50
50
|
);
|
|
51
|
+
|
|
52
|
+
r.streamHandler(
|
|
53
|
+
"item:tail",
|
|
54
|
+
z.object({ count: z.number().int().min(0) }),
|
|
55
|
+
async function* (query) {
|
|
56
|
+
for (let i = 0; i < query.payload.count; i++) {
|
|
57
|
+
yield { i, name: `n-${i}` };
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
{ access: { roles: ["Admin"] } },
|
|
61
|
+
);
|
|
51
62
|
});
|
|
52
63
|
|
|
53
64
|
let stack: TestStack;
|
|
@@ -215,4 +226,67 @@ describe("dispatcher-live (integration) — full path against Kumiko server", ()
|
|
|
215
226
|
const rows = await selectMany(stack.db, itemTable);
|
|
216
227
|
expect(rows).toHaveLength(0);
|
|
217
228
|
});
|
|
229
|
+
|
|
230
|
+
test("stream: yields every chunk then completes (live dispatcher ↔ POST /api/stream)", async () => {
|
|
231
|
+
const { fetch, csrfToken } = await buildFetch();
|
|
232
|
+
const dispatcher = createLiveDispatcher({ fetch, readCsrf: () => csrfToken });
|
|
233
|
+
|
|
234
|
+
const chunks: Array<{ i: number; name: string }> = [];
|
|
235
|
+
for await (const chunk of dispatcher.stream<{ i: number; name: string }>(
|
|
236
|
+
"dlive:stream:item:tail",
|
|
237
|
+
{ count: 3 },
|
|
238
|
+
)) {
|
|
239
|
+
chunks.push(chunk);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
expect(chunks).toEqual([
|
|
243
|
+
{ i: 0, name: "n-0" },
|
|
244
|
+
{ i: 1, name: "n-1" },
|
|
245
|
+
{ i: 2, name: "n-2" },
|
|
246
|
+
]);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test("stream: access-denied mid-stream throws mapped DispatcherError (HTTP still 200 SSE)", async () => {
|
|
250
|
+
// Non-Admin JWT — stream handler requires Admin. Gate fires after SSE
|
|
251
|
+
// headers are flushed, so the client sees an error frame, not HTTP 403.
|
|
252
|
+
const csrfToken = generateToken();
|
|
253
|
+
const guestJwt = await stack.jwt.sign(TestUsers.user);
|
|
254
|
+
const cookieHeader = `kumiko_auth=${guestJwt}; kumiko_csrf=${csrfToken}`;
|
|
255
|
+
const fetchImpl = (async (url: unknown, init: RequestInit | undefined) => {
|
|
256
|
+
return stack.app.request(String(url), {
|
|
257
|
+
...(init ?? {}),
|
|
258
|
+
headers: { ...(init?.headers ?? {}), Cookie: cookieHeader },
|
|
259
|
+
});
|
|
260
|
+
}) as unknown as typeof fetch;
|
|
261
|
+
const dispatcher = createLiveDispatcher({ fetch: fetchImpl, readCsrf: () => csrfToken });
|
|
262
|
+
|
|
263
|
+
let thrown: unknown;
|
|
264
|
+
try {
|
|
265
|
+
for await (const _ of dispatcher.stream("dlive:stream:item:tail", { count: 1 })) {
|
|
266
|
+
// should not yield
|
|
267
|
+
}
|
|
268
|
+
} catch (e) {
|
|
269
|
+
thrown = e;
|
|
270
|
+
}
|
|
271
|
+
expect(thrown).toMatchObject({ code: "access_denied" });
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
test("stream: validation failure throws mapped DispatcherError from error frame", async () => {
|
|
275
|
+
const { fetch, csrfToken } = await buildFetch();
|
|
276
|
+
const dispatcher = createLiveDispatcher({ fetch, readCsrf: () => csrfToken });
|
|
277
|
+
|
|
278
|
+
let thrown: unknown;
|
|
279
|
+
try {
|
|
280
|
+
for await (const _ of dispatcher.stream("dlive:stream:item:tail", { count: -1 })) {
|
|
281
|
+
// should not yield
|
|
282
|
+
}
|
|
283
|
+
} catch (e) {
|
|
284
|
+
thrown = e;
|
|
285
|
+
}
|
|
286
|
+
expect(thrown).toMatchObject({ code: "validation_error" });
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
// AbortSignal mid-stream against Hono's in-memory `app.request` is
|
|
290
|
+
// unreliable (body often fully buffered before abort lands). Abort
|
|
291
|
+
// mapping for stream is covered in dispatcher-live unit tests.
|
|
218
292
|
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { describe, expect, it } from "bun:test";
|
|
2
2
|
import * as jose from "jose";
|
|
3
3
|
import type { SessionUser } from "../../engine/types";
|
|
4
|
-
import { createJwtHelper } from "../jwt";
|
|
4
|
+
import { createJwtHelper, loadJwtSecretOrKeyring } from "../jwt";
|
|
5
5
|
|
|
6
6
|
const SECRET = "test-secret-at-least-32-characters-long-aa";
|
|
7
7
|
const TENANT = "11111111-1111-4111-8111-111111111111";
|
|
@@ -83,3 +83,202 @@ describe("createJwtHelper.verify — payload validation (KF-2)", () => {
|
|
|
83
83
|
await expect(jwt.verify(token)).rejects.toThrow(/sub/);
|
|
84
84
|
});
|
|
85
85
|
});
|
|
86
|
+
|
|
87
|
+
describe("createJwtHelper — keyring form", () => {
|
|
88
|
+
it("sets kid in the protected header when signing from a keyring", async () => {
|
|
89
|
+
const jwt = createJwtHelper({ keys: { v1: SECRET }, signKid: "v1" });
|
|
90
|
+
const token = await jwt.sign(user);
|
|
91
|
+
expect(jose.decodeProtectedHeader(token).kid).toBe("v1");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("verifies a keyring-signed token against a string-form helper on the same secret", async () => {
|
|
95
|
+
const keyringSigner = createJwtHelper({ keys: { v1: SECRET }, signKid: "v1" });
|
|
96
|
+
const token = await keyringSigner.sign(user);
|
|
97
|
+
|
|
98
|
+
const stringVerifier = createJwtHelper(SECRET);
|
|
99
|
+
const payload = await stringVerifier.verify(token);
|
|
100
|
+
expect(payload.sub).toBe(user.id);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("omits kid when signing from a plain secret (string form)", async () => {
|
|
104
|
+
const jwt = createJwtHelper(SECRET);
|
|
105
|
+
const token = await jwt.sign(user);
|
|
106
|
+
expect(jose.decodeProtectedHeader(token).kid).toBeUndefined();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("verifies a token signed under an old kid after rotating signKid", async () => {
|
|
110
|
+
const OLD_SECRET = "old-secret-at-least-32-characters-long-aa";
|
|
111
|
+
const before = createJwtHelper({ keys: { v1: OLD_SECRET }, signKid: "v1" });
|
|
112
|
+
const token = await before.sign(user);
|
|
113
|
+
|
|
114
|
+
const after = createJwtHelper({ keys: { v1: OLD_SECRET, v2: SECRET }, signKid: "v2" });
|
|
115
|
+
const payload = await after.verify(token);
|
|
116
|
+
expect(payload.sub).toBe(user.id);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("verifies a legacy no-kid token against a multi-key keyring", async () => {
|
|
120
|
+
const legacy = createJwtHelper(SECRET);
|
|
121
|
+
const token = await legacy.sign(user);
|
|
122
|
+
|
|
123
|
+
const rotated = createJwtHelper({
|
|
124
|
+
keys: { v1: "unrelated-secret-32-characters-longg", v2: SECRET },
|
|
125
|
+
signKid: "v2",
|
|
126
|
+
});
|
|
127
|
+
const payload = await rotated.verify(token);
|
|
128
|
+
expect(payload.sub).toBe(user.id);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("rejects a token whose kid is not in the keyring", async () => {
|
|
132
|
+
const signer = createJwtHelper({ keys: { v1: SECRET }, signKid: "v1" });
|
|
133
|
+
const token = await signer.sign(user);
|
|
134
|
+
|
|
135
|
+
const verifier = createJwtHelper({ keys: { v2: SECRET }, signKid: "v2" });
|
|
136
|
+
await expect(verifier.verify(token)).rejects.toThrow(/kid/);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("throws at creation when signKid is not present in the keyring", () => {
|
|
140
|
+
expect(() => createJwtHelper({ keys: { v1: SECRET }, signKid: "v2" })).toThrow(/signKid/);
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
describe("loadJwtSecretOrKeyring", () => {
|
|
145
|
+
it("no JWT_SECRET_V<n> → falls back to plain JWT_SECRET", () => {
|
|
146
|
+
expect(loadJwtSecretOrKeyring({ JWT_SECRET: SECRET })).toBe(SECRET);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("neither JWT_SECRET_V<n> nor JWT_SECRET set → throws", () => {
|
|
150
|
+
expect(() => loadJwtSecretOrKeyring({})).toThrow(/JWT_SECRET not set/);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("JWT_SECRET_V1 + JWT_SECRET_CURRENT_VERSION=1 → single-key keyring, signKid v1", () => {
|
|
154
|
+
expect(
|
|
155
|
+
loadJwtSecretOrKeyring({
|
|
156
|
+
JWT_SECRET_V1: SECRET,
|
|
157
|
+
JWT_SECRET_CURRENT_VERSION: "1",
|
|
158
|
+
}),
|
|
159
|
+
).toEqual({ keys: { v1: SECRET }, signKid: "v1" });
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("rotation: two versions, current-version picks the sign key, both stay verifiable", () => {
|
|
163
|
+
const OLD_SECRET = "old-secret-at-least-32-characters-long!!";
|
|
164
|
+
expect(
|
|
165
|
+
loadJwtSecretOrKeyring({
|
|
166
|
+
JWT_SECRET_V1: OLD_SECRET,
|
|
167
|
+
JWT_SECRET_V2: SECRET,
|
|
168
|
+
JWT_SECRET_CURRENT_VERSION: "2",
|
|
169
|
+
}),
|
|
170
|
+
).toEqual({ keys: { v1: OLD_SECRET, v2: SECRET }, signKid: "v2" });
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("JWT_SECRET_V<n> present but JWT_SECRET_CURRENT_VERSION missing → throws", () => {
|
|
174
|
+
expect(() => loadJwtSecretOrKeyring({ JWT_SECRET_V1: SECRET })).toThrow(
|
|
175
|
+
/JWT_SECRET_CURRENT_VERSION not set/,
|
|
176
|
+
);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("JWT_SECRET_CURRENT_VERSION points at a version with no matching JWT_SECRET_V<n> → throws", () => {
|
|
180
|
+
expect(() =>
|
|
181
|
+
loadJwtSecretOrKeyring({
|
|
182
|
+
JWT_SECRET_V1: SECRET,
|
|
183
|
+
JWT_SECRET_CURRENT_VERSION: "2",
|
|
184
|
+
}),
|
|
185
|
+
).toThrow(/not present in the keyring/);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("kumiko-framework#1308: plain JWT_SECRET carries over as a verify-only legacy key once a keyring is adopted", () => {
|
|
189
|
+
// Adopting rotation for the first time doesn't unset JWT_SECRET (it still
|
|
190
|
+
// signs the separate password-reset/email-verification HMAC family) —
|
|
191
|
+
// and sessions signed before adoption have no `kid`, so they must keep
|
|
192
|
+
// verifying against this exact value, not get orphaned the moment
|
|
193
|
+
// JWT_SECRET_V1 shows up.
|
|
194
|
+
const PLAIN_SECRET = "pre-rotation-plain-secret-32-chars-long!!";
|
|
195
|
+
expect(
|
|
196
|
+
loadJwtSecretOrKeyring({
|
|
197
|
+
JWT_SECRET: PLAIN_SECRET,
|
|
198
|
+
JWT_SECRET_V1: SECRET,
|
|
199
|
+
JWT_SECRET_CURRENT_VERSION: "1",
|
|
200
|
+
}),
|
|
201
|
+
).toEqual({ keys: { v1: SECRET, legacy: PLAIN_SECRET }, signKid: "v1" });
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("kumiko-framework#1308: legacy JWT_SECRET never becomes the sign key, only ever verify-only", () => {
|
|
205
|
+
const PLAIN_SECRET = "pre-rotation-plain-secret-32-chars-long!!";
|
|
206
|
+
const keyring = loadJwtSecretOrKeyring({
|
|
207
|
+
JWT_SECRET: PLAIN_SECRET,
|
|
208
|
+
JWT_SECRET_V1: SECRET,
|
|
209
|
+
JWT_SECRET_CURRENT_VERSION: "1",
|
|
210
|
+
});
|
|
211
|
+
expect(typeof keyring).not.toBe("string");
|
|
212
|
+
expect((keyring as { signKid: string }).signKid).toBe("v1");
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it("kumiko-framework#1308: no plain JWT_SECRET set alongside a keyring → no legacy key added", () => {
|
|
216
|
+
expect(
|
|
217
|
+
loadJwtSecretOrKeyring({
|
|
218
|
+
JWT_SECRET_V1: SECRET,
|
|
219
|
+
JWT_SECRET_CURRENT_VERSION: "1",
|
|
220
|
+
}),
|
|
221
|
+
).toEqual({ keys: { v1: SECRET }, signKid: "v1" });
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it("plain JWT_SECRET under 32 chars → throws", () => {
|
|
225
|
+
expect(() => loadJwtSecretOrKeyring({ JWT_SECRET: "too-short" })).toThrow(
|
|
226
|
+
/JWT_SECRET must be ≥32 chars/,
|
|
227
|
+
);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it("JWT_SECRET_V<n> under 32 chars → throws (bypasses the env-schema check otherwise)", () => {
|
|
231
|
+
expect(() =>
|
|
232
|
+
loadJwtSecretOrKeyring({
|
|
233
|
+
JWT_SECRET_V1: "too-short",
|
|
234
|
+
JWT_SECRET_CURRENT_VERSION: "1",
|
|
235
|
+
}),
|
|
236
|
+
).toThrow(/JWT_SECRET_V1 must be ≥32 chars/);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it("kumiko-framework#1526: short legacy JWT_SECRET is rejected, not silently carried into the keyring", () => {
|
|
240
|
+
// Auth-bypass: a short JWT_SECRET carried into keys["legacy"] without a
|
|
241
|
+
// length check would be offline-bruteforceable — an attacker forges
|
|
242
|
+
// {alg:"HS256", kid:"legacy"} with arbitrary sub/tenantId/roles and
|
|
243
|
+
// verifyWithKeyring looks the key up by kid straight from the header.
|
|
244
|
+
expect(() =>
|
|
245
|
+
loadJwtSecretOrKeyring({
|
|
246
|
+
JWT_SECRET: "short",
|
|
247
|
+
JWT_SECRET_V1: SECRET,
|
|
248
|
+
JWT_SECRET_CURRENT_VERSION: "1",
|
|
249
|
+
}),
|
|
250
|
+
).toThrow(/JWT_SECRET must be ≥32 chars/);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it("kumiko-framework#1308: empty JWT_SECRET_V<n> throws instead of silently falling back to single-secret mode", () => {
|
|
254
|
+
expect(() =>
|
|
255
|
+
loadJwtSecretOrKeyring({
|
|
256
|
+
JWT_SECRET: SECRET,
|
|
257
|
+
JWT_SECRET_V1: "",
|
|
258
|
+
JWT_SECRET_CURRENT_VERSION: "1",
|
|
259
|
+
}),
|
|
260
|
+
).toThrow(/JWT_SECRET_V1 is set but empty/);
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
describe("createJwtHelper — ttlSeconds", () => {
|
|
265
|
+
it("defaults to 24h", () => {
|
|
266
|
+
const jwt = createJwtHelper(SECRET);
|
|
267
|
+
expect(jwt.ttlSeconds).toBe(24 * 60 * 60);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it("exposes a custom ttlSeconds", () => {
|
|
271
|
+
const jwt = createJwtHelper(SECRET, ISSUER, 60 * 60);
|
|
272
|
+
expect(jwt.ttlSeconds).toBe(60 * 60);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it("signs a token whose exp - iat exactly equals ttlSeconds", async () => {
|
|
276
|
+
const ttlSeconds = 60 * 60;
|
|
277
|
+
const jwt = createJwtHelper(SECRET, ISSUER, ttlSeconds);
|
|
278
|
+
const token = await jwt.sign(user);
|
|
279
|
+
const claims = jose.decodeJwt(token);
|
|
280
|
+
expect(claims.exp).toBeDefined();
|
|
281
|
+
expect(claims.iat).toBeDefined();
|
|
282
|
+
expect(claims.exp! - claims.iat!).toBe(ttlSeconds);
|
|
283
|
+
});
|
|
284
|
+
});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { createInMemoryLoginRateLimiter } from "../auth-routes";
|
|
3
|
+
|
|
4
|
+
describe("createInMemoryLoginRateLimiter — sweep + cap", () => {
|
|
5
|
+
test("sweepExpired removes expired entries before enforceCap can evict a fresh key", async () => {
|
|
6
|
+
// maxAttempts=1 makes every entry a single-shot probe: a second `check`
|
|
7
|
+
// on the same key only returns true if the entry was actually removed
|
|
8
|
+
// from the map (either by expiry+sweep or by enforceCap).
|
|
9
|
+
const limiter = createInMemoryLoginRateLimiter(1, 50, {
|
|
10
|
+
maxEntries: 2,
|
|
11
|
+
sweepThreshold: 2,
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
expect(await limiter.check("old1")).toBe(true);
|
|
15
|
+
expect(await limiter.check("old2")).toBe(true);
|
|
16
|
+
// Let both windows expire.
|
|
17
|
+
await Bun.sleep(60);
|
|
18
|
+
|
|
19
|
+
// hits.size (2) >= sweepThreshold (2) → sweepExpired runs first and
|
|
20
|
+
// clears old1/old2 before "new1" is inserted, so enforceCap never fires.
|
|
21
|
+
expect(await limiter.check("new1")).toBe(true);
|
|
22
|
+
expect(await limiter.check("new2")).toBe(true);
|
|
23
|
+
|
|
24
|
+
// If enforceCap had run instead of (or before) the sweep, it would have
|
|
25
|
+
// evicted "new1" as the oldest live entry, and this check would return
|
|
26
|
+
// true (fresh window) rather than false (still rate-limited).
|
|
27
|
+
expect(await limiter.check("new1")).toBe(false);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("enforceCap drops the oldest entry when the map exceeds maxEntries", async () => {
|
|
31
|
+
const limiter = createInMemoryLoginRateLimiter(1, 60_000, {
|
|
32
|
+
maxEntries: 3,
|
|
33
|
+
sweepThreshold: 10_000, // never sweep — only the hard cap matters
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
expect(await limiter.check("k1")).toBe(true);
|
|
37
|
+
expect(await limiter.check("k2")).toBe(true);
|
|
38
|
+
expect(await limiter.check("k3")).toBe(true);
|
|
39
|
+
// 4th insert trips enforceCap → drops the oldest entry (k1).
|
|
40
|
+
expect(await limiter.check("k4")).toBe(true);
|
|
41
|
+
|
|
42
|
+
// k1 was dropped — a fresh check starts a new window (allowed).
|
|
43
|
+
expect(await limiter.check("k1")).toBe(true);
|
|
44
|
+
|
|
45
|
+
// k4 must still be alive in the map: a check within its still-open
|
|
46
|
+
// window is rate-limited (count >= maxAttempts), proving enforceCap
|
|
47
|
+
// dropped k1 and not k4.
|
|
48
|
+
expect(await limiter.check("k4")).toBe(false);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { patAllows, qnMatches } from "../pat-scope";
|
|
3
|
+
|
|
4
|
+
describe("qnMatches", () => {
|
|
5
|
+
it("exact match", () => {
|
|
6
|
+
expect(qnMatches("credit:query:schedule", "credit:query:schedule")).toBe(true);
|
|
7
|
+
expect(qnMatches("credit:query:schedule", "credit:query:credit:list")).toBe(false);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it("wildcard suffix matches on prefix", () => {
|
|
11
|
+
expect(qnMatches("credit:write:bauspar:*", "credit:write:bauspar:create")).toBe(true);
|
|
12
|
+
expect(qnMatches("credit:write:bauspar:*", "credit:write:bauspar:delete")).toBe(true);
|
|
13
|
+
expect(qnMatches("credit:write:bauspar:*", "credit:write:create")).toBe(false);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("bare wildcard matches everything", () => {
|
|
17
|
+
expect(qnMatches("*", "anything:at:all")).toBe(true);
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
describe("patAllows", () => {
|
|
22
|
+
const allowed = ["credit:write:create", "credit:write:bauspar:*"];
|
|
23
|
+
|
|
24
|
+
it("permits an exact or wildcard-covered type", () => {
|
|
25
|
+
expect(patAllows(allowed, "credit:write:create")).toBe(true);
|
|
26
|
+
expect(patAllows(allowed, "credit:write:bauspar:update")).toBe(true);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("denies a type no scope covers", () => {
|
|
30
|
+
expect(patAllows(allowed, "credit:write:credit:delete")).toBe(false);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("fail-closed on an empty allow-list", () => {
|
|
34
|
+
expect(patAllows([], "credit:write:create")).toBe(false);
|
|
35
|
+
});
|
|
36
|
+
});
|