@cosmicdrift/kumiko-framework 1.0.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -38
- package/package.json +21 -2
- package/src/__tests__/anonymous-access.integration.test.ts +185 -0
- package/src/__tests__/consumer-cli.integration.test.ts +172 -0
- package/src/__tests__/entity-permalink-open.integration.test.ts +94 -0
- package/src/__tests__/full-stack.integration.test.ts +1 -1
- package/src/__tests__/schema-cli-temporal-polyfill.test.ts +48 -0
- package/src/__tests__/schema-cli.integration.test.ts +50 -0
- package/src/__tests__/store-table.integration.test.ts +93 -0
- package/src/api/__tests__/api.test.ts +373 -0
- package/src/api/__tests__/auth-middleware-anonymous-access-boot.test.ts +40 -0
- package/src/api/__tests__/auth-routes-cookie.test.ts +17 -1
- package/src/api/__tests__/auth-routes-invalid-body-invite.test.ts +253 -0
- package/src/api/__tests__/auth-routes-mfa-preauth-confirm.test.ts +222 -0
- package/src/api/__tests__/auth-routes-mfa-preauth-enable-start.test.ts +249 -0
- package/src/api/__tests__/auth-routes-mfa-verify.test.ts +202 -0
- package/src/api/__tests__/auth-routes-trusted-proxy.test.ts +135 -0
- package/src/api/__tests__/batch.integration.test.ts +101 -11
- package/src/api/__tests__/csrf-constants-sync.test.ts +20 -0
- package/src/api/__tests__/dispatcher-live.integration.test.ts +74 -0
- package/src/api/__tests__/jwt.test.ts +200 -1
- package/src/api/__tests__/login-rate-limiter-sweep.test.ts +50 -0
- package/src/api/__tests__/pat-scope.test.ts +36 -0
- package/src/api/__tests__/pii-leak-guard.integration.test.ts +103 -0
- package/src/api/__tests__/redis-login-rate-limiter.integration.test.ts +138 -0
- package/src/api/__tests__/request-id-middleware.test.ts +51 -0
- package/src/api/__tests__/server-boot-guards.test.ts +71 -0
- package/src/api/__tests__/server-jwt-ttl.test.ts +58 -0
- package/src/api/__tests__/sse-broker.test.ts +57 -0
- package/src/api/__tests__/sse-route.test.ts +4 -0
- package/src/api/api-constants.ts +11 -0
- package/src/api/auth-middleware.ts +248 -40
- package/src/api/auth-routes.ts +576 -95
- package/src/api/index.ts +13 -4
- package/src/api/jwt.ts +170 -11
- package/src/api/pat-scope.ts +14 -0
- package/src/api/pii-leak-guard.ts +47 -0
- package/src/api/request-context.ts +3 -0
- package/src/api/request-id-middleware.ts +2 -0
- package/src/api/routes.ts +169 -2
- package/src/api/server.ts +112 -16
- package/src/api/sse-broker.ts +39 -0
- package/src/bun-db/__tests__/PATTERN.md +0 -1
- package/src/bun-db/__tests__/coerce-row-temporal.test.ts +41 -0
- package/src/bun-db/__tests__/select-many-retry.test.ts +79 -0
- package/src/bun-db/__tests__/write-brand.test.ts +21 -0
- package/src/bun-db/connection.ts +3 -3
- package/src/bun-db/index.ts +2 -0
- package/src/bun-db/query.ts +105 -32
- package/src/consumer-cli.ts +134 -0
- package/src/crypto/__tests__/blind-index.test.ts +130 -0
- package/src/crypto/__tests__/event-pii.test.ts +161 -0
- package/src/crypto/__tests__/kek-rotation.integration.test.ts +180 -0
- package/src/crypto/__tests__/kms-adapter-contract.ts +134 -0
- package/src/crypto/__tests__/kms-adapter.contract.test.ts +4 -0
- package/src/crypto/__tests__/pg-kms-adapter.integration.test.ts +117 -0
- package/src/crypto/__tests__/pii-field-encryption.test.ts +376 -0
- package/src/crypto/__tests__/request-kms-cache.test.ts +74 -0
- package/src/crypto/__tests__/subject-resolver.test.ts +108 -0
- package/src/crypto/blind-index.ts +118 -0
- package/src/crypto/event-pii.ts +70 -0
- package/src/crypto/in-memory-kms-adapter.ts +50 -0
- package/src/crypto/index.ts +67 -0
- package/src/crypto/kms-adapter.ts +2 -0
- package/src/crypto/pg-kms-adapter.ts +295 -0
- package/src/crypto/pii-field-encryption.ts +248 -0
- package/src/crypto/request-kms-cache.ts +38 -0
- package/src/crypto/subject-resolver.ts +91 -0
- package/src/db/__tests__/assert-no-unreachable-live-rows.integration.test.ts +191 -0
- package/src/db/__tests__/blind-index.integration.test.ts +248 -0
- package/src/db/__tests__/build-filter-where.test.ts +34 -0
- package/src/db/__tests__/collect-table-metas.test.ts +10 -10
- package/src/db/__tests__/config-seed.integration.test.ts +13 -5
- package/src/db/__tests__/dialect-instant.test.ts +1 -4
- package/src/db/__tests__/entity-field-encryption.test.ts +7 -7
- package/src/db/__tests__/entity-table-meta-source.test.ts +50 -0
- package/src/db/__tests__/event-store-executor-context.pii-roundtrip.test.ts +67 -0
- package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +402 -0
- package/src/db/__tests__/event-store-executor.integration.test.ts +299 -29
- package/src/db/__tests__/feature-table-sources.test.ts +34 -0
- package/src/db/__tests__/implicit-projection-equivalence.integration.test.ts +118 -39
- package/src/db/__tests__/instant-to-driver-temporal.test.ts +21 -0
- package/src/db/__tests__/located-timestamp.test.ts +19 -0
- package/src/db/__tests__/migrate-generator.test.ts +21 -0
- package/src/db/__tests__/migrate-runner.test.ts +61 -0
- package/src/db/__tests__/number-field-fractional.integration.test.ts +58 -0
- package/src/db/__tests__/rebuild-marker.test.ts +13 -3
- package/src/db/__tests__/replay-migration-sql.test.ts +384 -0
- package/src/db/__tests__/schema-inspection.test.ts +7 -0
- package/src/db/__tests__/schema-migration.integration.test.ts +1 -1
- package/src/db/__tests__/table-builder-meta-lockstep.test.ts +39 -0
- package/src/db/__tests__/tenant-db-where-merge.test.ts +49 -3
- package/src/db/__tests__/tenant-db.integration.test.ts +6 -2
- package/src/db/api.ts +2 -2
- package/src/db/apply-entity-event.ts +18 -14
- package/src/db/blind-index-cleanup.ts +55 -0
- package/src/db/bun-provider.ts +2 -2
- package/src/db/collect-table-metas.ts +6 -7
- package/src/db/config-seed.ts +9 -9
- package/src/db/connection.ts +7 -12
- package/src/db/cursor.ts +1 -18
- package/src/db/dialect.ts +20 -26
- package/src/db/entity-field-encryption.ts +81 -24
- package/src/db/entity-table-meta-types.ts +2 -0
- package/src/db/entity-table-meta.ts +63 -90
- package/src/db/event-store-executor-context.ts +404 -0
- package/src/db/event-store-executor-read.ts +275 -0
- package/src/db/event-store-executor-write.ts +644 -0
- package/src/db/event-store-executor.ts +28 -1155
- package/src/db/feature-table-sources.ts +6 -5
- package/src/db/index.ts +20 -3
- package/src/db/located-timestamp.ts +4 -0
- package/src/db/migrate-generator.ts +39 -6
- package/src/db/migrate-runner.ts +107 -11
- package/src/db/pg-error.ts +9 -1
- package/src/db/postgres-provider.ts +2 -2
- package/src/db/queries/__tests__/event-store-idempotency-index.integration.test.ts +80 -0
- package/src/db/queries/backfill-pii.ts +277 -0
- package/src/db/queries/ddl.ts +45 -0
- package/src/db/queries/event-consumer.ts +35 -2
- package/src/db/queries/event-store.ts +126 -19
- package/src/db/queries/projection-rebuild.ts +22 -8
- package/src/db/queries/shadow-swap.ts +183 -0
- package/src/db/queries/test-stack.ts +4 -30
- package/src/db/query-api.ts +1 -0
- package/src/db/query.ts +1 -0
- package/src/db/rebuild-marker.ts +18 -2
- package/src/db/reference-data.ts +2 -3
- package/src/db/replay-migration-sql.ts +257 -0
- package/src/db/schema-inspection.ts +1 -1
- package/src/db/table-builder.ts +102 -71
- package/src/db/tenant-db.ts +15 -53
- package/src/engine/__tests__/boot-validator-action-wiring.test.ts +242 -0
- package/src/engine/__tests__/boot-validator-boot-check.test.ts +99 -0
- package/src/engine/__tests__/boot-validator-dashboard.test.ts +237 -0
- package/src/engine/__tests__/boot-validator-entity-list.test.ts +104 -0
- package/src/engine/__tests__/boot-validator-gdpr-storage.test.ts +7 -69
- package/src/engine/__tests__/boot-validator-i18n-keys.test.ts +26 -5
- package/src/engine/__tests__/boot-validator-located-timestamps.test.ts +3 -19
- package/src/engine/__tests__/boot-validator-pii-retention.test.ts +246 -4
- package/src/engine/__tests__/boot-validator.test.ts +219 -15
- package/src/engine/__tests__/build-app-schema.test.ts +82 -0
- package/src/engine/__tests__/build-config-feature-schema.test.ts +125 -0
- package/src/engine/__tests__/build-target.test.ts +3 -11
- package/src/engine/__tests__/codemod-pipeline.test.ts +152 -21
- package/src/engine/__tests__/config-helpers.test.ts +16 -0
- package/src/engine/__tests__/define-feature-entity-mapping.test.ts +6 -0
- package/src/engine/__tests__/define-roles.test.ts +21 -0
- package/src/engine/__tests__/engine.test.ts +163 -1
- package/src/engine/__tests__/entity-handlers.test.ts +80 -0
- package/src/engine/__tests__/event-migration-declarative.test.ts +65 -0
- package/src/engine/__tests__/event-type-map-augmentation.test.ts +24 -0
- package/src/engine/__tests__/extend-entity-projection.test.ts +123 -0
- package/src/engine/__tests__/factories-time.test.ts +2 -66
- package/src/engine/__tests__/feature-crud-shorthand.test.ts +28 -0
- package/src/engine/__tests__/feature-manifest.test.ts +31 -1
- package/src/engine/__tests__/field-access.test.ts +23 -1
- package/src/engine/__tests__/hook-phases.test.ts +5 -5
- package/src/engine/__tests__/membership-roles.test.ts +4 -10
- package/src/engine/__tests__/nav.test.ts +86 -1
- package/src/engine/__tests__/pipeline-engine.test.ts +9 -9
- package/src/engine/__tests__/pipeline-handler.integration.test.ts +18 -18
- package/src/engine/__tests__/pipeline-observability.integration.test.ts +2 -2
- package/src/engine/__tests__/pipeline-performance.integration.test.ts +3 -3
- package/src/engine/__tests__/pipeline-sub-pipelines.test.ts +9 -9
- package/src/engine/__tests__/post-query-hook.test.ts +7 -7
- package/src/engine/__tests__/registrar-object-form.test.ts +141 -0
- package/src/engine/__tests__/registry.test.ts +144 -0
- package/src/engine/__tests__/schema-builder.test.ts +18 -0
- package/src/engine/__tests__/screen.test.ts +147 -1
- package/src/engine/__tests__/soft-delete-cleanup.test.ts +3 -0
- package/src/engine/__tests__/store-table.test.ts +229 -0
- package/src/engine/__tests__/tier-resolver-extension.test.ts +19 -1
- package/src/engine/__tests__/{visual-tree-patterns.test.ts → tree-actions-patterns.test.ts} +7 -95
- package/src/engine/__tests__/validate-projection-allowlist.test.ts +15 -15
- package/src/engine/boot-validator/__tests__/config-deps.test.ts +92 -0
- package/src/engine/boot-validator/action-wiring.ts +149 -0
- package/src/engine/boot-validator/boot-check.ts +21 -0
- package/src/engine/boot-validator/config-deps.ts +40 -4
- package/src/engine/boot-validator/entity-handler.ts +20 -21
- package/src/engine/boot-validator/entity-list-screens.ts +88 -0
- package/src/engine/boot-validator/gdpr-storage.ts +0 -20
- package/src/engine/boot-validator/i18n-keys.ts +58 -4
- package/src/engine/boot-validator/index.ts +33 -12
- package/src/engine/boot-validator/nav.ts +125 -0
- package/src/engine/boot-validator/pii-retention.ts +95 -10
- package/src/engine/boot-validator/{screens-nav.ts → screens.ts} +214 -191
- package/src/engine/boot-validator/workspaces.ts +68 -0
- package/src/engine/build-app-schema.ts +16 -0
- package/src/engine/build-config-feature-schema.ts +44 -7
- package/src/engine/codemod/pipeline-codemod.ts +5 -5
- package/src/engine/config-helpers.ts +15 -0
- package/src/engine/constants.ts +32 -6
- package/src/engine/create-app.ts +11 -0
- package/src/engine/define-feature.ts +95 -947
- package/src/engine/define-handler.ts +28 -94
- package/src/engine/define-workflow.ts +1 -1
- package/src/engine/effective-features.ts +12 -2
- package/src/engine/entity-handlers.ts +76 -11
- package/src/engine/extensions/tenant-data.ts +19 -0
- package/src/engine/extensions/user-data.ts +29 -2
- package/src/engine/factories.ts +8 -49
- package/src/engine/feature-ast/__tests__/canonical-form.test.ts +26 -29
- package/src/engine/feature-ast/__tests__/fixtures/cross-file-registrar/feature.ts +9 -0
- package/src/engine/feature-ast/__tests__/fixtures/cross-file-registrar/screens.ts +4 -0
- package/src/engine/feature-ast/__tests__/parse-happy-path.test.ts +1 -2
- package/src/engine/feature-ast/__tests__/parse-real-features.test.ts +18 -8
- package/src/engine/feature-ast/__tests__/parse.test.ts +1640 -160
- package/src/engine/feature-ast/__tests__/patch.test.ts +206 -12
- package/src/engine/feature-ast/__tests__/patcher.test.ts +20 -23
- package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +380 -5
- package/src/engine/feature-ast/extractors/events.ts +322 -0
- package/src/engine/feature-ast/extractors/handlers.ts +234 -0
- package/src/engine/feature-ast/extractors/hooks.ts +243 -0
- package/src/engine/feature-ast/extractors/index.ts +34 -31
- package/src/engine/feature-ast/extractors/jobs-routes.ts +221 -0
- package/src/engine/feature-ast/extractors/projections-screens.ts +269 -0
- package/src/engine/feature-ast/extractors/round1.ts +3 -3
- package/src/engine/feature-ast/extractors/round5.ts +3 -3
- package/src/engine/feature-ast/extractors/round6.ts +2 -36
- package/src/engine/feature-ast/extractors/shared.ts +64 -6
- package/src/engine/feature-ast/index.ts +2 -4
- package/src/engine/feature-ast/parse.ts +130 -23
- package/src/engine/feature-ast/patch.ts +39 -50
- package/src/engine/feature-ast/patcher.ts +37 -38
- package/src/engine/feature-ast/patterns.ts +50 -62
- package/src/engine/feature-ast/render.ts +67 -44
- package/src/engine/feature-builder-state.ts +168 -0
- package/src/engine/feature-config-events-jobs.ts +403 -0
- package/src/engine/feature-entity-handlers.ts +208 -0
- package/src/engine/feature-manifest.ts +2 -1
- package/src/engine/feature-ui-extensions.ts +500 -0
- package/src/engine/field-access.ts +13 -2
- package/src/engine/field-helpers.ts +31 -0
- package/src/engine/handler-helpers.ts +26 -0
- package/src/engine/hook-helpers.ts +16 -0
- package/src/engine/index.ts +23 -7
- package/src/engine/membership-roles.ts +13 -0
- package/src/engine/object-form.ts +27 -0
- package/src/engine/ownership.ts +26 -79
- package/src/engine/pattern-library/__tests__/library.test.ts +7 -20
- package/src/engine/pattern-library/library.ts +44 -1152
- package/src/engine/pattern-library/mixed-schemas.ts +450 -0
- package/src/engine/pattern-library/opaque-schemas.ts +124 -0
- package/src/engine/pattern-library/shared-fields.ts +75 -0
- package/src/engine/pattern-library/static-schemas.ts +456 -0
- package/src/engine/pipeline.ts +6 -11
- package/src/engine/registry-facade.ts +362 -0
- package/src/engine/registry-ingest.ts +478 -0
- package/src/engine/registry-state.ts +388 -0
- package/src/engine/registry-validate.ts +642 -0
- package/src/engine/registry.ts +78 -1658
- package/src/engine/run-pipeline.ts +1 -1
- package/src/engine/schema-builder.ts +1 -0
- package/src/engine/screen-helpers.ts +54 -0
- package/src/engine/soft-delete-cleanup.ts +6 -2
- package/src/engine/steps/__tests__/duration-utils.test.ts +20 -0
- package/src/engine/steps/_duration-utils.ts +2 -0
- package/src/engine/steps/unsafe-projection-upsert.ts +1 -4
- package/src/engine/tier-resolver-extension.ts +3 -2
- package/src/engine/types/config.ts +2 -482
- package/src/engine/types/define-handler.ts +2 -0
- package/src/engine/types/entity-handlers.ts +2 -0
- package/src/engine/types/event-type-map.ts +1 -37
- package/src/engine/types/feature.ts +2 -972
- package/src/engine/types/fields.ts +2 -675
- package/src/engine/types/handlers.ts +2 -774
- package/src/engine/types/hooks.ts +2 -184
- package/src/engine/types/http-route.ts +1 -54
- package/src/engine/types/identifiers.ts +1 -47
- package/src/engine/types/index.ts +86 -40
- package/src/engine/types/nav.ts +2 -63
- package/src/engine/types/ownership.ts +2 -0
- package/src/engine/types/projection.ts +2 -138
- package/src/engine/types/relations.ts +1 -51
- package/src/engine/types/screen.ts +2 -574
- package/src/engine/types/step.ts +2 -334
- package/src/engine/types/target-ref.ts +1 -21
- package/src/engine/types/tree-node.ts +1 -132
- package/src/engine/types/workspace.ts +2 -49
- package/src/engine/validate-projection-allowlist.ts +6 -6
- package/src/entrypoint/__tests__/entrypoint-job-wiring.integration.test.ts +120 -1
- package/src/entrypoint/index.ts +49 -6
- package/src/errors/classes.ts +22 -1
- package/src/errors/field-issue.ts +0 -3
- package/src/errors/index.ts +1 -1
- package/src/errors/write-error-info.ts +10 -22
- package/src/es-ops/README.md +1 -1
- package/src/es-ops/__tests__/runner.integration.test.ts +74 -0
- package/src/es-ops/context.ts +4 -2
- package/src/es-ops/types.ts +8 -1
- package/src/event-store/__tests__/admin-api.integration.test.ts +27 -1
- package/src/event-store/__tests__/backfill-pii.integration.test.ts +279 -0
- package/src/event-store/__tests__/event-store.integration.test.ts +201 -0
- package/src/event-store/__tests__/row-to-stored-event.test.ts +2 -2
- package/src/event-store/__tests__/snapshot.integration.test.ts +86 -0
- package/src/event-store/__tests__/unscoped-stream-primitives.guard.test.ts +58 -0
- package/src/event-store/__tests__/upcaster.integration.test.ts +77 -45
- package/src/event-store/admin-api.ts +11 -4
- package/src/event-store/errors.ts +2 -35
- package/src/event-store/event-store.ts +64 -78
- package/src/event-store/events-schema.ts +11 -13
- package/src/event-store/index.ts +18 -3
- package/src/event-store/rebuild-dead-letter.ts +111 -0
- package/src/event-store/snapshot.ts +63 -40
- package/src/event-store/types.ts +2 -0
- package/src/files/__tests__/build-storage-key.test.ts +28 -0
- package/src/files/__tests__/file-ref-entity.test.ts +8 -0
- package/src/files/__tests__/files.integration.test.ts +24 -0
- package/src/files/__tests__/in-memory-provider.contract.test.ts +4 -0
- package/src/files/__tests__/local-provider.test.ts +31 -0
- package/src/files/__tests__/provider-resolver.test.ts +70 -0
- package/src/files/__tests__/write-stream.test.ts +13 -0
- package/src/files/file-handle.ts +2 -19
- package/src/files/file-ref-entity.ts +2 -2
- package/src/files/file-routes.ts +13 -0
- package/src/files/index.ts +1 -1
- package/src/files/local-provider.ts +27 -8
- package/src/files/provider-resolver.ts +31 -20
- package/src/files/types.ts +13 -55
- package/src/i18n/__tests__/required-surface-keys.test.ts +17 -0
- package/src/i18n/required-surface-keys.ts +120 -1
- package/src/jobs/__tests__/job-queue-depth.integration.test.ts +82 -0
- package/src/jobs/__tests__/jobs.integration.test.ts +329 -3
- package/src/jobs/job-runner.ts +82 -12
- package/src/logging/types.ts +1 -7
- package/src/migrations/pending-rebuilds.ts +1 -1
- package/src/observability/__tests__/observability.integration.test.ts +4 -1
- package/src/observability/__tests__/recording-tracer.test.ts +6 -4
- package/src/observability/index.ts +2 -0
- package/src/observability/noop-provider.ts +0 -9
- package/src/observability/recording-tracer.ts +0 -12
- package/src/observability/standard-metrics.ts +64 -2
- package/src/observability/types/index.ts +1 -29
- package/src/observability/types/metric.ts +1 -56
- package/src/observability/types/provider.ts +1 -32
- package/src/observability/types/span.ts +1 -64
- package/src/pipeline/__tests__/ctx-bridge.integration.test.ts +2 -2
- package/src/pipeline/__tests__/dispatcher.test.ts +366 -1
- package/src/pipeline/__tests__/event-consumer-state.integration.test.ts +31 -0
- package/src/pipeline/__tests__/event-dispatcher-delivery-max-attempts.test.ts +126 -0
- package/src/pipeline/__tests__/event-dispatcher-rearm.integration.test.ts +263 -0
- package/src/pipeline/__tests__/event-dispatcher.integration.test.ts +10 -0
- package/src/pipeline/__tests__/job-trigger-consumer.integration.test.ts +106 -0
- package/src/pipeline/__tests__/lifecycle-pipeline.test.ts +308 -77
- package/src/pipeline/__tests__/load-aggregate-query.integration.test.ts +16 -8
- package/src/pipeline/__tests__/post-query-hook.integration.test.ts +3 -3
- package/src/pipeline/__tests__/projection-rebuild.integration.test.ts +80 -2
- package/src/pipeline/__tests__/rebuild-poison-quarantine.integration.test.ts +274 -0
- package/src/pipeline/__tests__/try-append-event.integration.test.ts +166 -0
- package/src/pipeline/dispatch-batch.ts +187 -0
- package/src/pipeline/dispatch-query.ts +165 -0
- package/src/pipeline/dispatch-shared.ts +826 -0
- package/src/pipeline/dispatch-stream.ts +90 -0
- package/src/pipeline/dispatch-write.ts +451 -0
- package/src/pipeline/dispatcher-utils.ts +1 -1
- package/src/pipeline/dispatcher.ts +44 -1372
- package/src/pipeline/entity-cache.ts +2 -33
- package/src/pipeline/event-consumer-state.ts +30 -2
- package/src/pipeline/event-dispatcher-admin.ts +293 -0
- package/src/pipeline/event-dispatcher-delivery.ts +305 -0
- package/src/pipeline/event-dispatcher.ts +85 -542
- package/src/pipeline/index.ts +2 -0
- package/src/pipeline/msp-rebuild.ts +42 -3
- package/src/pipeline/multi-stream-apply-context.ts +4 -42
- package/src/pipeline/projection-rebuild.ts +105 -3
- package/src/pipeline/system-hooks.ts +144 -1
- package/src/rate-limit/__tests__/resolver.integration.test.ts +18 -0
- package/src/rate-limit/resolver.ts +16 -32
- package/src/schema-cli.ts +76 -16
- package/src/search/__tests__/meilisearch-adapter.integration.test.ts +207 -185
- package/src/search/__tests__/meilisearch-ids.test.ts +30 -0
- package/src/search/__tests__/reindex-entity.integration.test.ts +144 -0
- package/src/search/index.ts +6 -0
- package/src/search/meilisearch-adapter.ts +13 -12
- package/src/search/reindex-entity.ts +177 -0
- package/src/search/types.ts +1 -39
- package/src/secrets/__tests__/contains-secret.test.ts +34 -0
- package/src/secrets/__tests__/envelope-cipher.test.ts +59 -0
- package/src/secrets/__tests__/envelope.test.ts +1 -1
- package/src/secrets/dek-cache.ts +26 -5
- package/src/secrets/envelope-cipher.ts +53 -0
- package/src/secrets/envelope.ts +5 -3
- package/src/secrets/index.ts +13 -1
- package/src/secrets/stored-envelope.ts +46 -0
- package/src/secrets/types.ts +2 -162
- package/src/stack/__tests__/event-collector.test.ts +42 -0
- package/src/stack/__tests__/setup-test-stack-jobs.integration.test.ts +125 -0
- package/src/stack/db.ts +2 -1
- package/src/stack/push-entity-projection-tables.ts +4 -3
- package/src/stack/redis.ts +8 -0
- package/src/stack/request-helper.ts +38 -2
- package/src/stack/table-helpers.ts +6 -4
- package/src/stack/test-stack.ts +249 -131
- package/src/testing/__tests__/late-bound.test.ts +32 -0
- package/src/testing/__tests__/wait-for.test.ts +59 -0
- package/src/testing/boot-validator-fixture.ts +121 -0
- package/src/testing/e2e-generator.ts +8 -0
- package/src/testing/file-provider-contract.ts +104 -0
- package/src/testing/handler-context.ts +3 -1
- package/src/testing/index.ts +5 -0
- package/src/testing/late-bound.ts +5 -3
- package/src/testing/mutable-master-key-provider.ts +29 -3
- package/src/testing/wait-for.ts +3 -0
- package/src/testing/without-ambient-temporal.ts +14 -0
- package/src/time/__tests__/tz-dateline.test.ts +8 -10
- package/src/time/geo-tz.ts +1 -32
- package/src/time/index.ts +1 -0
- package/src/time/legacy-date.ts +18 -0
- package/src/time/polyfill.ts +21 -38
- package/src/time/tz-context.ts +44 -85
- package/src/ui-types/app-schema.ts +12 -0
- package/src/ui-types/index.ts +20 -7
- package/src/utils/__tests__/safe-json-temporal.test.ts +18 -0
- package/src/utils/safe-json.ts +13 -1
- package/src/__tests__/raw-table.integration.test.ts +0 -116
- package/src/bun-db/__tests__/bun-test-stack.ts +0 -6
- package/src/db/__tests__/encryption.test.ts +0 -39
- package/src/db/encryption.ts +0 -39
- package/src/db/row-helpers.ts +0 -4
- package/src/engine/__tests__/raw-table.test.ts +0 -150
- package/src/engine/__tests__/unmanaged-table.test.ts +0 -127
- package/src/engine/feature-ast/__tests__/visual-tree-parse.test.ts +0 -184
- package/src/engine/feature-ast/extractors/round4.ts +0 -1366
package/src/api/index.ts
CHANGED
|
@@ -2,22 +2,30 @@ export type { SetTenantCookieOptions } from "./anonymous-cookie";
|
|
|
2
2
|
export { deleteTenantCookie, setTenantCookie } from "./anonymous-cookie";
|
|
3
3
|
export type {
|
|
4
4
|
AnonymousAccessConfig,
|
|
5
|
+
AnonymousAccessResolved,
|
|
5
6
|
AuthMiddlewareOptions,
|
|
6
7
|
AuthSessionChecker,
|
|
7
8
|
AuthSessionStatus,
|
|
8
9
|
TenantExists,
|
|
10
|
+
TenantLifecycleStatusResolver,
|
|
9
11
|
TenantResolver,
|
|
12
|
+
TokenVerifier,
|
|
10
13
|
} from "./auth-middleware";
|
|
11
|
-
export { authMiddleware, getUser } from "./auth-middleware";
|
|
14
|
+
export { authMiddleware, getUser, PAT_TOKEN_PREFIX } from "./auth-middleware";
|
|
12
15
|
export type {
|
|
13
16
|
AuthRoutesConfig,
|
|
14
17
|
LoginRateLimiter,
|
|
15
18
|
SessionChecker,
|
|
16
19
|
SessionCreator,
|
|
20
|
+
SessionMassRevoker,
|
|
17
21
|
SessionMetadata,
|
|
18
22
|
SessionRevoker,
|
|
19
23
|
} from "./auth-routes";
|
|
20
|
-
export {
|
|
24
|
+
export {
|
|
25
|
+
createAuthRoutes,
|
|
26
|
+
createInMemoryLoginRateLimiter,
|
|
27
|
+
createRedisLoginRateLimiter,
|
|
28
|
+
} from "./auth-routes";
|
|
21
29
|
export type { CachedResponseInit, CachePolicy } from "./http-cache";
|
|
22
30
|
export {
|
|
23
31
|
cacheControlHeader,
|
|
@@ -28,8 +36,9 @@ export {
|
|
|
28
36
|
etagMatches,
|
|
29
37
|
parseIfNoneMatch,
|
|
30
38
|
} from "./http-cache";
|
|
31
|
-
export type { JwtHelper, JwtPayload } from "./jwt";
|
|
32
|
-
export { createJwtHelper } from "./jwt";
|
|
39
|
+
export type { JwtHelper, JwtKeyring, JwtPayload } from "./jwt";
|
|
40
|
+
export { createJwtHelper, loadJwtSecretOrKeyring } from "./jwt";
|
|
41
|
+
export { patAllows, qnMatches } from "./pat-scope";
|
|
33
42
|
export { type RequestContextData, requestContext } from "./request-context";
|
|
34
43
|
export { requestIdMiddleware } from "./request-id-middleware";
|
|
35
44
|
export { createApiRoutes } from "./routes";
|
package/src/api/jwt.ts
CHANGED
|
@@ -23,10 +23,82 @@ export type JwtPayload = {
|
|
|
23
23
|
export type JwtHelper = {
|
|
24
24
|
sign(user: SessionUser): Promise<string>;
|
|
25
25
|
verify(token: string): Promise<JwtPayload>;
|
|
26
|
+
// The TTL this helper signs tokens with, in seconds — the single source for
|
|
27
|
+
// callers (e.g. the auth-cookie's maxAge) that must stay coupled to the JWT's exp.
|
|
28
|
+
readonly ttlSeconds: number;
|
|
26
29
|
};
|
|
27
30
|
|
|
28
|
-
|
|
29
|
-
|
|
31
|
+
// kid → secret. All entries verify; `signKid` picks the sign-key. Rotation:
|
|
32
|
+
// add the new kid, flip signKid, keep the old kid around until in-flight
|
|
33
|
+
// tokens expire.
|
|
34
|
+
export type JwtKeyring = {
|
|
35
|
+
readonly keys: Readonly<Record<string, string>>;
|
|
36
|
+
readonly signKid: string;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
type NormalizedKeyring = {
|
|
40
|
+
readonly verifyKeys: ReadonlyMap<string, Uint8Array>;
|
|
41
|
+
readonly signKid: string | undefined;
|
|
42
|
+
readonly signKey: Uint8Array;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
function normalizeKeyring(secretOrKeyring: string | JwtKeyring): NormalizedKeyring {
|
|
46
|
+
if (typeof secretOrKeyring === "string") {
|
|
47
|
+
const key = new TextEncoder().encode(secretOrKeyring);
|
|
48
|
+
return { verifyKeys: new Map(), signKid: undefined, signKey: key };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const verifyKeys = new Map<string, Uint8Array>();
|
|
52
|
+
for (const [kid, secret] of Object.entries(secretOrKeyring.keys)) {
|
|
53
|
+
verifyKeys.set(kid, new TextEncoder().encode(secret));
|
|
54
|
+
}
|
|
55
|
+
const signKey = verifyKeys.get(secretOrKeyring.signKid);
|
|
56
|
+
if (!signKey) {
|
|
57
|
+
throw new Error(
|
|
58
|
+
`createJwtHelper: signKid "${secretOrKeyring.signKid}" is not present in the keyring`,
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
return { verifyKeys, signKid: secretOrKeyring.signKid, signKey };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Tokens carry `kid` in the protected header when signed from a keyring — pick the
|
|
65
|
+
// matching verify-key directly. Tokens without `kid` (single-secret form, or in-flight
|
|
66
|
+
// tokens signed before a rotation) fall back to trying every verify-key.
|
|
67
|
+
async function verifyWithKeyring(token: string, keyring: NormalizedKeyring, issuer: string) {
|
|
68
|
+
const { kid } = jose.decodeProtectedHeader(token);
|
|
69
|
+
if (typeof kid === "string" && keyring.verifyKeys.size > 0) {
|
|
70
|
+
const key = keyring.verifyKeys.get(kid);
|
|
71
|
+
if (!key) {
|
|
72
|
+
throw new Error(`JWT verification failed: unknown kid "${kid}"`);
|
|
73
|
+
}
|
|
74
|
+
return jose.jwtVerify(token, key, { issuer });
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ponytail: tries every key in the ring (O(keys) per legacy-token verify) — fine for a
|
|
78
|
+
// rotation window of a handful of keys, revisit if the keyring ever grows large.
|
|
79
|
+
const candidates =
|
|
80
|
+
keyring.verifyKeys.size > 0 ? [...keyring.verifyKeys.values()] : [keyring.signKey];
|
|
81
|
+
let lastError: unknown;
|
|
82
|
+
for (const key of candidates) {
|
|
83
|
+
try {
|
|
84
|
+
return await jose.jwtVerify(token, key, { issuer });
|
|
85
|
+
} catch (err) {
|
|
86
|
+
lastError = err;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
throw lastError instanceof Error
|
|
90
|
+
? lastError
|
|
91
|
+
: new Error("JWT verification failed: no matching key");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const DEFAULT_JWT_TTL_SECONDS = 24 * 60 * 60;
|
|
95
|
+
|
|
96
|
+
export function createJwtHelper(
|
|
97
|
+
secretOrKeyring: string | JwtKeyring,
|
|
98
|
+
issuer = "kumiko",
|
|
99
|
+
ttlSeconds = DEFAULT_JWT_TTL_SECONDS,
|
|
100
|
+
): JwtHelper {
|
|
101
|
+
const keyring = normalizeKeyring(secretOrKeyring);
|
|
30
102
|
|
|
31
103
|
return {
|
|
32
104
|
async sign(user) {
|
|
@@ -36,24 +108,29 @@ export function createJwtHelper(secret: string, issuer = "kumiko"): JwtHelper {
|
|
|
36
108
|
};
|
|
37
109
|
if (user.claims) body.claims = { ...user.claims };
|
|
38
110
|
|
|
111
|
+
const header: jose.JWTHeaderParameters = keyring.signKid
|
|
112
|
+
? { alg: "HS256", kid: keyring.signKid }
|
|
113
|
+
: { alg: "HS256" };
|
|
114
|
+
|
|
115
|
+
// iat/exp share one `now` — jose's setIssuedAt()/setExpirationTime(Date)
|
|
116
|
+
// each read the clock separately, letting `exp - iat` drift by a
|
|
117
|
+
// second and making TTL-precision tests flaky.
|
|
118
|
+
const nowSec = Math.floor(Date.now() / 1000);
|
|
39
119
|
const builder = new jose.SignJWT(body)
|
|
40
|
-
.setProtectedHeader(
|
|
120
|
+
.setProtectedHeader(header)
|
|
41
121
|
.setSubject(String(user.id))
|
|
42
122
|
.setIssuer(issuer)
|
|
43
|
-
.setIssuedAt()
|
|
44
|
-
.setExpirationTime(
|
|
123
|
+
.setIssuedAt(nowSec)
|
|
124
|
+
.setExpirationTime(nowSec + ttlSeconds);
|
|
45
125
|
if (user.sid) builder.setJti(user.sid);
|
|
46
126
|
|
|
47
|
-
return builder.sign(
|
|
127
|
+
return builder.sign(keyring.signKey);
|
|
48
128
|
},
|
|
49
129
|
|
|
50
130
|
async verify(token) {
|
|
51
|
-
const { payload } = await
|
|
131
|
+
const { payload } = await verifyWithKeyring(token, keyring, issuer);
|
|
52
132
|
|
|
53
|
-
//
|
|
54
|
-
// leaked secret, key confusion, or a hand-crafted token can still carry junk —
|
|
55
|
-
// validate the claim shape and reject (verify() throws → 401 in auth-middleware)
|
|
56
|
-
// instead of casting blindly.
|
|
133
|
+
// defence-in-depth: valid sig ≠ well-formed claims; malformed payload → throw → 401
|
|
57
134
|
const tenantId = parseTenantId(payload["tenantId"]);
|
|
58
135
|
if (tenantId === null) {
|
|
59
136
|
throw new Error("JWT payload validation failed: tenantId claim is missing or malformed");
|
|
@@ -87,5 +164,87 @@ export function createJwtHelper(secret: string, issuer = "kumiko"): JwtHelper {
|
|
|
87
164
|
}
|
|
88
165
|
return result;
|
|
89
166
|
},
|
|
167
|
+
ttlSeconds,
|
|
90
168
|
};
|
|
91
169
|
}
|
|
170
|
+
|
|
171
|
+
const JWT_KEY_VAR_PATTERN = /^JWT_SECRET_V(\d+)$/;
|
|
172
|
+
const JWT_CURRENT_VERSION_VAR = "JWT_SECRET_CURRENT_VERSION";
|
|
173
|
+
// Mirrors authEmailPasswordEnvSchema's JWT_SECRET.min(32) — HS256 minimum.
|
|
174
|
+
// JWT_SECRET_V<n> bypasses that zod schema entirely (it only validates the
|
|
175
|
+
// plain JWT_SECRET name), so this loader is the only gate for the rotation path.
|
|
176
|
+
const MIN_JWT_SECRET_LENGTH = 32;
|
|
177
|
+
|
|
178
|
+
function assertMinLength(name: string, value: string): void {
|
|
179
|
+
if (value.length < MIN_JWT_SECRET_LENGTH) {
|
|
180
|
+
throw new Error(`[jwt] ${name} must be ≥${MIN_JWT_SECRET_LENGTH} chars (HS256 minimum)`);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Env-loader for createJwtHelper's secret-or-keyring param, analog to
|
|
185
|
+
// secrets' loadKeyring: JWT_SECRET_V<n> (+ JWT_SECRET_CURRENT_VERSION picking
|
|
186
|
+
// the active signKid) for rotation, falling back to plain JWT_SECRET when no
|
|
187
|
+
// JWT_SECRET_V<n> is set — so a non-rotating deployment needs no new env vars.
|
|
188
|
+
export function loadJwtSecretOrKeyring(
|
|
189
|
+
env: Readonly<Record<string, string | undefined>>,
|
|
190
|
+
): string | JwtKeyring {
|
|
191
|
+
const keys: Record<string, string> = {};
|
|
192
|
+
for (const [name, value] of Object.entries(env)) {
|
|
193
|
+
const match = name.match(JWT_KEY_VAR_PATTERN);
|
|
194
|
+
if (!match) continue;
|
|
195
|
+
if (!value) {
|
|
196
|
+
throw new Error(
|
|
197
|
+
`[jwt] ${name} is set but empty — an empty versioned secret is almost certainly a ` +
|
|
198
|
+
"deploy mistake, not an intentional skip. Unset the var entirely if it's unused.",
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
assertMinLength(name, value);
|
|
202
|
+
// biome-ignore lint/style/noNonNullAssertion: regex group 1 always present
|
|
203
|
+
keys[`v${match[1]!}`] = value;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// skip: no JWT_SECRET_V<n> found — single-secret fallback.
|
|
207
|
+
if (Object.keys(keys).length === 0) {
|
|
208
|
+
const secret = env["JWT_SECRET"];
|
|
209
|
+
if (!secret) {
|
|
210
|
+
throw new Error(
|
|
211
|
+
"[jwt] JWT_SECRET not set — set JWT_SECRET for a single key, or " +
|
|
212
|
+
"JWT_SECRET_V1 (+ JWT_SECRET_CURRENT_VERSION=1) for a rotatable keyring.",
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
assertMinLength("JWT_SECRET", secret);
|
|
216
|
+
return secret;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const currentRaw = env[JWT_CURRENT_VERSION_VAR];
|
|
220
|
+
if (!currentRaw) {
|
|
221
|
+
throw new Error(
|
|
222
|
+
`[jwt] ${JWT_CURRENT_VERSION_VAR} not set — explicit current-version required ` +
|
|
223
|
+
"so adding a new JWT_SECRET_V<n> doesn't auto-promote it to the sign key.",
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
const signKid = `v${currentRaw}`;
|
|
227
|
+
if (!keys[signKid]) {
|
|
228
|
+
throw new Error(
|
|
229
|
+
`[jwt] ${JWT_CURRENT_VERSION_VAR}="${currentRaw}" not present in the keyring ` +
|
|
230
|
+
`(have versions: ${Object.keys(keys).sort().join(", ")}). ` +
|
|
231
|
+
`Check JWT_SECRET_V${currentRaw} is set.`,
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
// Carry the pre-rotation plain JWT_SECRET into the ring as a verify-only
|
|
235
|
+
// legacy key: sessions signed before JWT_SECRET_V<n> was first set have no
|
|
236
|
+
// `kid`, so verifyWithKeyring's no-kid fallback tries every key in `keys` —
|
|
237
|
+
// if the plain secret isn't one of them, every in-flight session breaks the
|
|
238
|
+
// moment rotation is adopted, exactly the mass-invalidation this keyring
|
|
239
|
+
// exists to avoid. Never becomes signKid — only JWT_SECRET_V<n> can sign.
|
|
240
|
+
// Retirement: has no automatic expiry — verifies unbounded as long as
|
|
241
|
+
// JWT_SECRET stays set. Operators must explicitly unset JWT_SECRET once
|
|
242
|
+
// max token TTL (`ttlSeconds`) has elapsed since cutover to actually
|
|
243
|
+
// retire a rotated-out secret (see run-prod-app.ts boot warning).
|
|
244
|
+
const legacySecret = env["JWT_SECRET"];
|
|
245
|
+
if (legacySecret) {
|
|
246
|
+
assertMinLength("JWT_SECRET", legacySecret);
|
|
247
|
+
keys["legacy"] = legacySecret;
|
|
248
|
+
}
|
|
249
|
+
return { keys, signKid };
|
|
250
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// QN scope-matching for Personal Access Tokens. A token's granted scopes
|
|
2
|
+
// expand (in the PAT feature's resolver) to QN globs like "credit:write:*" or
|
|
3
|
+
// "credit:query:credit:list". A glob ending in "*" matches any dispatch type
|
|
4
|
+
// sharing the prefix; otherwise it is an exact match. Fail-closed: an empty
|
|
5
|
+
// allow-list matches nothing, so a PAT with no scopes can call nothing.
|
|
6
|
+
|
|
7
|
+
export function qnMatches(pattern: string, type: string): boolean {
|
|
8
|
+
if (pattern.endsWith("*")) return type.startsWith(pattern.slice(0, -1));
|
|
9
|
+
return pattern === type;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function patAllows(allowedQns: readonly string[], type: string): boolean {
|
|
13
|
+
return allowedQns.some((pattern) => qnMatches(pattern, type));
|
|
14
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { MiddlewareHandler } from "hono";
|
|
2
|
+
import { configuredPiiSubjectKms, PII_CIPHERTEXT_PREFIX } from "../crypto";
|
|
3
|
+
|
|
4
|
+
const isProductionEnv = () => process.env["NODE_ENV"] === "production";
|
|
5
|
+
// Version-agnostic: catches both the current PII_CIPHERTEXT_PREFIX and any
|
|
6
|
+
// older/decrypt-only format version still present in unmigrated rows.
|
|
7
|
+
const CIPHERTEXT_MARKER = "kumiko-pii:v";
|
|
8
|
+
const CIPHERTEXT_RE = /kumiko-pii:v\d+:[^"\s<>\\]*/g;
|
|
9
|
+
|
|
10
|
+
// A PII subject ciphertext never belongs in an API response — its presence
|
|
11
|
+
// means a raw DB read (fetchOne/selectMany) leaked to the surface. Dev/test
|
|
12
|
+
// fail loud (500) so a forgotten decrypt turns the first integration test
|
|
13
|
+
// red; prod redacts + logs instead of shipping the blob. Skipped entirely
|
|
14
|
+
// when no subject KMS is configured (no ciphertexts can exist).
|
|
15
|
+
export function piiCiphertextResponseGuard(): MiddlewareHandler {
|
|
16
|
+
return async (c, next) => {
|
|
17
|
+
await next();
|
|
18
|
+
// skip: no subject KMS configured — no ciphertexts can exist, nothing to scan
|
|
19
|
+
if (configuredPiiSubjectKms() === undefined) return;
|
|
20
|
+
const contentType = c.res.headers.get("content-type") ?? "";
|
|
21
|
+
// skip: only JSON bodies carry handler data — streams/zips stay untouched
|
|
22
|
+
if (!contentType.includes("application/json")) return;
|
|
23
|
+
const text = await c.res.clone().text();
|
|
24
|
+
// skip: clean response — the common case
|
|
25
|
+
if (!text.includes(CIPHERTEXT_MARKER)) return;
|
|
26
|
+
|
|
27
|
+
const detail =
|
|
28
|
+
`[api] JSON response for ${c.req.method} ${c.req.path} contains a PII ciphertext ` +
|
|
29
|
+
`("${PII_CIPHERTEXT_PREFIX}…") — a raw DB read leaked to the API surface. ` +
|
|
30
|
+
`Decrypt before returning (decryptStoredPii / executor read path).`;
|
|
31
|
+
if (!isProductionEnv()) {
|
|
32
|
+
c.res = Response.json(
|
|
33
|
+
{ error: { code: "pii_ciphertext_leak", httpStatus: 500, message: detail } },
|
|
34
|
+
{ status: 500 },
|
|
35
|
+
);
|
|
36
|
+
// skip: response replaced with the loud 500 above — nothing left to do
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
console.error(detail);
|
|
40
|
+
const headers = new Headers(c.res.headers);
|
|
41
|
+
headers.delete("content-length");
|
|
42
|
+
c.res = new Response(text.replace(CIPHERTEXT_RE, "[pii-redacted]"), {
|
|
43
|
+
status: c.res.status,
|
|
44
|
+
headers,
|
|
45
|
+
});
|
|
46
|
+
};
|
|
47
|
+
}
|
|
@@ -30,6 +30,9 @@ export type RequestContextData = {
|
|
|
30
30
|
// Populated by requestIdMiddleware from x-forwarded-for or the
|
|
31
31
|
// socket address. Undefined for non-HTTP entry points (jobs, MSP).
|
|
32
32
|
readonly ip?: string;
|
|
33
|
+
// Raw User-Agent header — audit trails (download tokens, GDPR export
|
|
34
|
+
// access) want it alongside `ip`. Undefined for non-HTTP entry points.
|
|
35
|
+
readonly userAgent?: string;
|
|
33
36
|
};
|
|
34
37
|
|
|
35
38
|
const storage = new AsyncLocalStorage<RequestContextData>();
|
|
@@ -37,12 +37,14 @@ export function requestIdMiddleware() {
|
|
|
37
37
|
// than fabricate one.
|
|
38
38
|
const xff = c.req.header("x-forwarded-for");
|
|
39
39
|
const ip = xff?.split(",")[0]?.trim();
|
|
40
|
+
const userAgent = c.req.header("user-agent");
|
|
40
41
|
await requestContext.run(
|
|
41
42
|
{
|
|
42
43
|
requestId,
|
|
43
44
|
correlationId,
|
|
44
45
|
...(signal ? { signal } : {}),
|
|
45
46
|
...(ip && ip.length > 0 ? { ip } : {}),
|
|
47
|
+
...(userAgent !== undefined ? { userAgent } : {}),
|
|
46
48
|
},
|
|
47
49
|
() => next(),
|
|
48
50
|
);
|
package/src/api/routes.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { type Context, Hono } from "hono";
|
|
2
|
+
import { streamSSE } from "hono/streaming";
|
|
2
3
|
import type { ContentfulStatusCode } from "hono/utils/http-status";
|
|
4
|
+
import type { SessionUser } from "../engine/types/handlers";
|
|
3
5
|
import {
|
|
6
|
+
AccessDeniedError,
|
|
4
7
|
type KumikoError,
|
|
5
8
|
reraiseAsKumikoError,
|
|
6
9
|
serializeError,
|
|
@@ -12,9 +15,29 @@ import type { Dispatcher } from "../pipeline/dispatcher";
|
|
|
12
15
|
import { stringifyJson } from "../utils/safe-json";
|
|
13
16
|
import { Routes } from "./api-constants";
|
|
14
17
|
import { getUser } from "./auth-middleware";
|
|
18
|
+
import { patAllows } from "./pat-scope";
|
|
15
19
|
import { requestContext } from "./request-context";
|
|
20
|
+
import { SSE_HEARTBEAT_INTERVAL_MS } from "./sse-route";
|
|
16
21
|
|
|
17
|
-
|
|
22
|
+
// SSE frame event names for POST /api/stream (framework-owned; dispatcher-live
|
|
23
|
+
// has no dependency on this package and keeps its own copy in sse-stream.ts —
|
|
24
|
+
// a drift between the two fails the real-HTTP frame assertions in api.test.ts).
|
|
25
|
+
export const StreamFrame = {
|
|
26
|
+
chunk: "chunk",
|
|
27
|
+
ping: "ping",
|
|
28
|
+
done: "done",
|
|
29
|
+
error: "error",
|
|
30
|
+
} as const;
|
|
31
|
+
|
|
32
|
+
export type ApiRoutesOptions = {
|
|
33
|
+
// Override the SSE heartbeat interval (ms). Production uses
|
|
34
|
+
// SSE_HEARTBEAT_INTERVAL_MS; tests pass a short value so the pre-pull
|
|
35
|
+
// race + ping path can be exercised without a 15s wait.
|
|
36
|
+
readonly sseHeartbeatMs?: number;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export function createApiRoutes(dispatcher: Dispatcher, options: ApiRoutesOptions = {}) {
|
|
40
|
+
const heartbeatMs = options.sseHeartbeatMs ?? SSE_HEARTBEAT_INTERVAL_MS;
|
|
18
41
|
const api = new Hono();
|
|
19
42
|
|
|
20
43
|
api.post(Routes.write, async (c) => {
|
|
@@ -22,6 +45,7 @@ export function createApiRoutes(dispatcher: Dispatcher) {
|
|
|
22
45
|
const body = await c.req.json<{ type: string; payload: unknown; requestId?: string }>();
|
|
23
46
|
|
|
24
47
|
try {
|
|
48
|
+
assertPatAllowed(user, body.type);
|
|
25
49
|
const result = await dispatcher.write(body.type, body.payload, user, body.requestId);
|
|
26
50
|
if (!result.isSuccess) {
|
|
27
51
|
return writeErrorResponse(c, reraiseAsKumikoError(result.error), body.type);
|
|
@@ -59,6 +83,9 @@ export function createApiRoutes(dispatcher: Dispatcher) {
|
|
|
59
83
|
}
|
|
60
84
|
|
|
61
85
|
try {
|
|
86
|
+
if (user.pat) {
|
|
87
|
+
for (const cmd of body.commands) assertPatAllowed(user, cmd.type);
|
|
88
|
+
}
|
|
62
89
|
const result = await dispatcher.batch(body.commands, user, body.requestId);
|
|
63
90
|
if (!result.isSuccess) {
|
|
64
91
|
const err = reraiseAsKumikoError(result.error);
|
|
@@ -83,7 +110,8 @@ export function createApiRoutes(dispatcher: Dispatcher) {
|
|
|
83
110
|
}
|
|
84
111
|
return c.json(result);
|
|
85
112
|
} catch (e) {
|
|
86
|
-
|
|
113
|
+
// single "type" doesn't apply to a batch; command types keep the fault context.
|
|
114
|
+
return writeErrorResponse(c, toKumiko(e), body.commands?.map((cmd) => cmd.type).join(","));
|
|
87
115
|
}
|
|
88
116
|
});
|
|
89
117
|
|
|
@@ -92,6 +120,7 @@ export function createApiRoutes(dispatcher: Dispatcher) {
|
|
|
92
120
|
const body = await c.req.json<{ type: string; payload: unknown }>();
|
|
93
121
|
|
|
94
122
|
try {
|
|
123
|
+
assertPatAllowed(user, body.type);
|
|
95
124
|
const result = await dispatcher.query(body.type, body.payload, user);
|
|
96
125
|
return jsonResponse(c, { data: result });
|
|
97
126
|
} catch (e) {
|
|
@@ -104,6 +133,7 @@ export function createApiRoutes(dispatcher: Dispatcher) {
|
|
|
104
133
|
const body = await c.req.json<{ type: string; payload: unknown }>();
|
|
105
134
|
|
|
106
135
|
try {
|
|
136
|
+
assertPatAllowed(user, body.type);
|
|
107
137
|
await dispatcher.command(body.type, body.payload, user);
|
|
108
138
|
return c.json({ ok: true }, 202);
|
|
109
139
|
} catch (e) {
|
|
@@ -111,15 +141,152 @@ export function createApiRoutes(dispatcher: Dispatcher) {
|
|
|
111
141
|
}
|
|
112
142
|
});
|
|
113
143
|
|
|
144
|
+
// Dispatcher-driven SSE, full auth/CSRF/rate-limit chain (unlike the
|
|
145
|
+
// broker-based /sse route). Frame contract for clients: StreamFrame.chunk
|
|
146
|
+
// (one per yielded value, JSON-encoded), .ping (heartbeat, empty data),
|
|
147
|
+
// .done (terminal, empty data), .error (terminal, JSON error envelope —
|
|
148
|
+
// only reachable once the stream is already open, i.e. failures from the
|
|
149
|
+
// second chunk onward). The generator's first `.next()` — which runs the
|
|
150
|
+
// dispatch gates (feature/rate-limit/access/validation) plus the handler's
|
|
151
|
+
// first yield — is raced against a heartbeat timeout BEFORE streamSSE, so
|
|
152
|
+
// a gate failure that settles in time maps to its real HTTP status via
|
|
153
|
+
// queryErrorResponse instead of a flushed-200 error frame (framework#1517).
|
|
154
|
+
api.post(Routes.stream, async (c) => {
|
|
155
|
+
const user = getUser(c);
|
|
156
|
+
const body = await c.req.json<{ type: string; payload: unknown }>();
|
|
157
|
+
const requestId = requestContext.get()?.requestId;
|
|
158
|
+
|
|
159
|
+
const generator = dispatcher.stream(body.type, body.payload, user);
|
|
160
|
+
try {
|
|
161
|
+
assertPatAllowed(user, body.type);
|
|
162
|
+
} catch (e) {
|
|
163
|
+
return queryErrorResponse(c, toKumiko(e), body.type);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// stream.onAbort() only exists once streamSSE opens the response below.
|
|
167
|
+
// Hook the raw request signal directly so a client disconnect during the
|
|
168
|
+
// pre-pull still reclaims the generator instead of leaking a Redis
|
|
169
|
+
// subscription/DB cursor held open inside its still-unresolved first
|
|
170
|
+
// `.next()` (framework#1528).
|
|
171
|
+
const signal = c.req.raw.signal;
|
|
172
|
+
const onPrePullAbort = () => void generator.return(undefined);
|
|
173
|
+
signal.addEventListener("abort", onPrePullAbort);
|
|
174
|
+
|
|
175
|
+
const firstPull = generator.next();
|
|
176
|
+
let prePullTimer: ReturnType<typeof setTimeout> | undefined;
|
|
177
|
+
const settledInTime = await Promise.race([
|
|
178
|
+
firstPull.then(() => true).catch(() => true),
|
|
179
|
+
new Promise<false>((resolve) => {
|
|
180
|
+
prePullTimer = setTimeout(() => resolve(false), heartbeatMs);
|
|
181
|
+
}),
|
|
182
|
+
]);
|
|
183
|
+
clearTimeout(prePullTimer);
|
|
184
|
+
signal.removeEventListener("abort", onPrePullAbort);
|
|
185
|
+
|
|
186
|
+
if (settledInTime) {
|
|
187
|
+
try {
|
|
188
|
+
await firstPull;
|
|
189
|
+
} catch (e) {
|
|
190
|
+
return queryErrorResponse(c, toKumiko(e), body.type);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (signal.aborted) {
|
|
195
|
+
// Fire-and-forget: settledInTime === false means firstPull is by
|
|
196
|
+
// definition still pending — V8 queues a .return() request behind an
|
|
197
|
+
// in-flight .next(), so awaiting here would block the response until
|
|
198
|
+
// that pending pull resolves (which may be never for an idle stream).
|
|
199
|
+
void generator.return(undefined).catch(() => {});
|
|
200
|
+
return c.body(null, 499 as ContentfulStatusCode); // @cast-boundary non-standard client-closed-request status, Hono's union doesn't include it
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return streamSSE(c, async (stream) => {
|
|
204
|
+
stream.onAbort(() => {
|
|
205
|
+
void generator.return(undefined);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
try {
|
|
209
|
+
await pumpStream(stream, generator, heartbeatMs, firstPull);
|
|
210
|
+
} catch (e) {
|
|
211
|
+
const err = toKumiko(e);
|
|
212
|
+
logServerFault(err, requestId, body.type);
|
|
213
|
+
const { error } = serializeError(err, requestId);
|
|
214
|
+
await stream.writeSSE({ event: StreamFrame.error, data: stringifyJson(error) });
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
|
|
114
219
|
return api;
|
|
115
220
|
}
|
|
116
221
|
|
|
222
|
+
export type SseWriter = {
|
|
223
|
+
readonly writeSSE: (message: { readonly event: string; readonly data: string }) => Promise<void>;
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
// Races each generator.next() against a heartbeat timer so an idle handler keeps the SSE connection alive.
|
|
227
|
+
export async function pumpStream(
|
|
228
|
+
stream: SseWriter,
|
|
229
|
+
generator: AsyncGenerator<unknown>,
|
|
230
|
+
heartbeatMs: number,
|
|
231
|
+
// Pending (or already-settled) first `.next()` — see the /api/stream route,
|
|
232
|
+
// which races this against a heartbeat timeout before opening streamSSE.
|
|
233
|
+
firstPull?: Promise<IteratorResult<unknown>>,
|
|
234
|
+
): Promise<void> {
|
|
235
|
+
let pending = firstPull ?? generator.next();
|
|
236
|
+
try {
|
|
237
|
+
while (true) {
|
|
238
|
+
let heartbeatTimer: ReturnType<typeof setTimeout> | undefined;
|
|
239
|
+
const heartbeat = new Promise<"heartbeat">((resolve) => {
|
|
240
|
+
heartbeatTimer = setTimeout(() => resolve("heartbeat"), heartbeatMs);
|
|
241
|
+
});
|
|
242
|
+
let outcome: Awaited<typeof pending> | "heartbeat";
|
|
243
|
+
try {
|
|
244
|
+
outcome = await Promise.race([pending, heartbeat]);
|
|
245
|
+
} finally {
|
|
246
|
+
clearTimeout(heartbeatTimer);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (outcome === "heartbeat") {
|
|
250
|
+
await stream.writeSSE({ event: StreamFrame.ping, data: "" });
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
if (outcome.done) break;
|
|
254
|
+
await stream.writeSSE({
|
|
255
|
+
event: StreamFrame.chunk,
|
|
256
|
+
data: stringifyJson(outcome.value ?? null),
|
|
257
|
+
});
|
|
258
|
+
pending = generator.next();
|
|
259
|
+
}
|
|
260
|
+
await stream.writeSSE({ event: StreamFrame.done, data: "" });
|
|
261
|
+
} finally {
|
|
262
|
+
// Fire-and-forget: if writeSSE threw mid-loop, `pending` (the last
|
|
263
|
+
// generator.next()) may still be unresolved — V8 queues .return()
|
|
264
|
+
// behind an in-flight .next(), so awaiting here would hang until that
|
|
265
|
+
// pull settles (which may be never for a handler stuck on a dead
|
|
266
|
+
// Redis/DB subscription the disconnect just orphaned).
|
|
267
|
+
void generator.return(undefined).catch(() => {});
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
117
271
|
function jsonResponse(c: Context, body: unknown, status: ContentfulStatusCode = 200) {
|
|
118
272
|
return c.body(stringifyJson(body), status, { "Content-Type": "application/json" });
|
|
119
273
|
}
|
|
120
274
|
|
|
121
275
|
const toKumiko = toKumikoError;
|
|
122
276
|
|
|
277
|
+
// PAT scope enforcement at the API boundary. No-op for cookie/JWT users
|
|
278
|
+
// (user.pat undefined → unrestricted). For a PAT-authenticated request the
|
|
279
|
+
// dispatch type must match one of the token's granted-scope QN globs, else
|
|
280
|
+
// 403 — fail-closed, thrown so each route's existing catch shapes the body.
|
|
281
|
+
function assertPatAllowed(user: SessionUser, type: string): void {
|
|
282
|
+
if (user.pat && !patAllows(user.pat.allowedQns, type)) {
|
|
283
|
+
throw new AccessDeniedError({
|
|
284
|
+
message: `personal access token scope does not permit ${type}`,
|
|
285
|
+
details: { handler: type, scopes: user.pat.scopes },
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
123
290
|
// Unexpected server faults (5xx) carry their diagnostic stack only on the
|
|
124
291
|
// in-process error — serializeError strips cause/details from the wire body.
|
|
125
292
|
// Without this a wrapped throw (InternalError{cause}) returns a 500 with zero
|