@cosmicdrift/kumiko-framework 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +159 -0
- package/package.json +91 -0
- package/src/__tests__/anonymous-access.integration.ts +325 -0
- package/src/__tests__/error-contract.integration.ts +435 -0
- package/src/__tests__/field-access.integration.ts +269 -0
- package/src/__tests__/full-stack.integration.ts +914 -0
- package/src/__tests__/ownership.integration.ts +449 -0
- package/src/__tests__/reference-data.integration.ts +198 -0
- package/src/__tests__/transition-guard.integration.ts +340 -0
- package/src/api/__tests__/api.test.ts +337 -0
- package/src/api/__tests__/auth-middleware-transport.test.ts +80 -0
- package/src/api/__tests__/auth-routes-cookie.test.ts +179 -0
- package/src/api/__tests__/batch.integration.ts +404 -0
- package/src/api/__tests__/body-limit.test.ts +88 -0
- package/src/api/__tests__/csrf-middleware.test.ts +97 -0
- package/src/api/__tests__/dispatcher-live.integration.ts +216 -0
- package/src/api/__tests__/metrics-endpoint.test.ts +126 -0
- package/src/api/__tests__/nested-write.integration.ts +213 -0
- package/src/api/__tests__/readiness.test.ts +76 -0
- package/src/api/__tests__/request-id-middleware.test.ts +72 -0
- package/src/api/__tests__/sse-broker.test.ts +58 -0
- package/src/api/__tests__/sse-route.test.ts +112 -0
- package/src/api/anonymous-cookie.ts +60 -0
- package/src/api/api-constants.ts +64 -0
- package/src/api/auth-middleware.ts +418 -0
- package/src/api/auth-routes.ts +982 -0
- package/src/api/csrf-middleware.ts +77 -0
- package/src/api/index.ts +31 -0
- package/src/api/jwt.ts +66 -0
- package/src/api/observability-middleware.ts +89 -0
- package/src/api/readiness.ts +132 -0
- package/src/api/request-context.ts +49 -0
- package/src/api/request-id-middleware.ts +50 -0
- package/src/api/route-registrars.ts +195 -0
- package/src/api/routes.ts +135 -0
- package/src/api/server.ts +640 -0
- package/src/api/sse-broker.ts +71 -0
- package/src/api/sse-route.ts +62 -0
- package/src/api/tokens.ts +16 -0
- package/src/db/__tests__/apply-entity-event-tenant.integration.ts +159 -0
- package/src/db/__tests__/compound-types.test.ts +114 -0
- package/src/db/__tests__/connection-options.test.ts +68 -0
- package/src/db/__tests__/cursor.test.ts +41 -0
- package/src/db/__tests__/db-helpers.test.ts +369 -0
- package/src/db/__tests__/dialect-instant.test.ts +50 -0
- package/src/db/__tests__/drizzle-helpers.integration.ts +186 -0
- package/src/db/__tests__/drizzle-table-types.test.ts +162 -0
- package/src/db/__tests__/encryption.test.ts +39 -0
- package/src/db/__tests__/event-store-executor-list.integration.ts +313 -0
- package/src/db/__tests__/event-store-executor.integration.ts +235 -0
- package/src/db/__tests__/implicit-projection-equivalence.integration.ts +304 -0
- package/src/db/__tests__/located-timestamp.test.ts +184 -0
- package/src/db/__tests__/money.test.ts +199 -0
- package/src/db/__tests__/multi-row-insert.integration.ts +76 -0
- package/src/db/__tests__/parse-auto-verb.test.ts +70 -0
- package/src/db/__tests__/required-not-null-migration-safety.integration.ts +105 -0
- package/src/db/__tests__/row-helpers.test.ts +59 -0
- package/src/db/__tests__/schema-migration.integration.ts +273 -0
- package/src/db/__tests__/table-builder-indexes.test.ts +153 -0
- package/src/db/__tests__/table-builder-required.test.ts +216 -0
- package/src/db/__tests__/tenant-db.integration.ts +606 -0
- package/src/db/__tests__/unique-violation-mapping.integration.ts +166 -0
- package/src/db/apply-entity-event.ts +188 -0
- package/src/db/assert-exists-in.ts +59 -0
- package/src/db/compound-types.ts +47 -0
- package/src/db/connection.ts +104 -0
- package/src/db/cursor.ts +83 -0
- package/src/db/dialect.ts +109 -0
- package/src/db/eagerload.ts +174 -0
- package/src/db/encryption.ts +39 -0
- package/src/db/event-store-executor.ts +906 -0
- package/src/db/index.ts +55 -0
- package/src/db/located-timestamp.ts +114 -0
- package/src/db/money.ts +120 -0
- package/src/db/pg-error.ts +46 -0
- package/src/db/reference-data.ts +77 -0
- package/src/db/row-helpers.ts +53 -0
- package/src/db/schema-inspection.ts +25 -0
- package/src/db/table-builder.ts +475 -0
- package/src/db/tenant-db.ts +434 -0
- package/src/engine/__tests__/auth-claims-registrar.test.ts +74 -0
- package/src/engine/__tests__/boot-validator-located-timestamps.test.ts +108 -0
- package/src/engine/__tests__/boot-validator.test.ts +1865 -0
- package/src/engine/__tests__/build-app-schema.test.ts +154 -0
- package/src/engine/__tests__/claim-keys.test.ts +274 -0
- package/src/engine/__tests__/config-helpers.test.ts +236 -0
- package/src/engine/__tests__/effective-features.test.ts +86 -0
- package/src/engine/__tests__/engine.test.ts +1461 -0
- package/src/engine/__tests__/entity-handlers.test.ts +274 -0
- package/src/engine/__tests__/event-helpers.test.ts +68 -0
- package/src/engine/__tests__/extends-registrar.test.ts +159 -0
- package/src/engine/__tests__/factories-long-text.test.ts +84 -0
- package/src/engine/__tests__/factories-time.test.ts +158 -0
- package/src/engine/__tests__/field-predicates.test.ts +48 -0
- package/src/engine/__tests__/hook-phases.test.ts +132 -0
- package/src/engine/__tests__/identifiers.test.ts +35 -0
- package/src/engine/__tests__/lifecycle-hooks.test.ts +237 -0
- package/src/engine/__tests__/nav.test.ts +267 -0
- package/src/engine/__tests__/ownership.test.ts +421 -0
- package/src/engine/__tests__/parse-ref-target.test.ts +43 -0
- package/src/engine/__tests__/projection-helpers.test.ts +62 -0
- package/src/engine/__tests__/projection.test.ts +191 -0
- package/src/engine/__tests__/qualified-name.test.ts +264 -0
- package/src/engine/__tests__/resolve-config-or-param.test.ts +315 -0
- package/src/engine/__tests__/run-in.test.ts +38 -0
- package/src/engine/__tests__/schema-builder.test.ts +380 -0
- package/src/engine/__tests__/screen.test.ts +408 -0
- package/src/engine/__tests__/state-machine.test.ts +148 -0
- package/src/engine/__tests__/system-user.test.ts +57 -0
- package/src/engine/__tests__/validation-hooks.test.ts +71 -0
- package/src/engine/access.ts +23 -0
- package/src/engine/boot-validator.ts +1528 -0
- package/src/engine/build-app-schema.ts +125 -0
- package/src/engine/config-helpers.ts +115 -0
- package/src/engine/constants.ts +85 -0
- package/src/engine/create-app.ts +98 -0
- package/src/engine/define-feature.ts +702 -0
- package/src/engine/define-handler.ts +78 -0
- package/src/engine/define-roles.ts +19 -0
- package/src/engine/effective-features.ts +87 -0
- package/src/engine/entity-handlers.ts +364 -0
- package/src/engine/event-helpers.ts +73 -0
- package/src/engine/factories.ts +328 -0
- package/src/engine/feature-ast/__tests__/canonical-form.test.ts +416 -0
- package/src/engine/feature-ast/__tests__/parse-happy-path.test.ts +197 -0
- package/src/engine/feature-ast/__tests__/parse-real-features.test.ts +128 -0
- package/src/engine/feature-ast/__tests__/parse.test.ts +888 -0
- package/src/engine/feature-ast/__tests__/patch.test.ts +360 -0
- package/src/engine/feature-ast/__tests__/patcher.test.ts +469 -0
- package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +287 -0
- package/src/engine/feature-ast/extractors.ts +2562 -0
- package/src/engine/feature-ast/index.ts +105 -0
- package/src/engine/feature-ast/parse.ts +369 -0
- package/src/engine/feature-ast/patch.ts +525 -0
- package/src/engine/feature-ast/patcher.ts +518 -0
- package/src/engine/feature-ast/patterns.ts +434 -0
- package/src/engine/feature-ast/render.ts +602 -0
- package/src/engine/feature-ast/source-location.ts +45 -0
- package/src/engine/field-access.ts +120 -0
- package/src/engine/index.ts +254 -0
- package/src/engine/ownership.ts +337 -0
- package/src/engine/parse-ref-target.ts +22 -0
- package/src/engine/pattern-library/__tests__/library.test.ts +351 -0
- package/src/engine/pattern-library/index.ts +24 -0
- package/src/engine/pattern-library/library.ts +1117 -0
- package/src/engine/pattern-library/types.ts +255 -0
- package/src/engine/projection-helpers.ts +85 -0
- package/src/engine/qualified-name.ts +122 -0
- package/src/engine/read-claim.ts +31 -0
- package/src/engine/registry.ts +1325 -0
- package/src/engine/resolve-config-or-param.ts +153 -0
- package/src/engine/run-in.ts +29 -0
- package/src/engine/schema-builder.ts +175 -0
- package/src/engine/screen-filter-ops.ts +51 -0
- package/src/engine/state-machine.ts +70 -0
- package/src/engine/system-user.ts +32 -0
- package/src/engine/types/config.ts +306 -0
- package/src/engine/types/event-type-map.ts +37 -0
- package/src/engine/types/feature.ts +574 -0
- package/src/engine/types/fields.ts +422 -0
- package/src/engine/types/handlers.ts +742 -0
- package/src/engine/types/hooks.ts +142 -0
- package/src/engine/types/http-route.ts +54 -0
- package/src/engine/types/identifiers.ts +47 -0
- package/src/engine/types/index.ts +208 -0
- package/src/engine/types/nav.ts +46 -0
- package/src/engine/types/projection.ts +132 -0
- package/src/engine/types/relations.ts +51 -0
- package/src/engine/types/screen.ts +452 -0
- package/src/engine/types/workspace.ts +42 -0
- package/src/engine/validation.ts +33 -0
- package/src/entrypoint/__tests__/entrypoint-job-wiring.integration.ts +173 -0
- package/src/entrypoint/__tests__/split-deploy.integration.ts +297 -0
- package/src/entrypoint/index.ts +442 -0
- package/src/errors/__tests__/classes.test.ts +371 -0
- package/src/errors/__tests__/write-failures.test.ts +109 -0
- package/src/errors/classes.ts +249 -0
- package/src/errors/i18n/de.yaml +83 -0
- package/src/errors/i18n/en.yaml +80 -0
- package/src/errors/index.ts +41 -0
- package/src/errors/kumiko-error.ts +67 -0
- package/src/errors/reasons.ts +36 -0
- package/src/errors/serialize.ts +136 -0
- package/src/errors/transition-details.ts +30 -0
- package/src/errors/write-error-info.ts +123 -0
- package/src/errors/zod-bridge.ts +49 -0
- package/src/event-store/__tests__/admin-api.integration.ts +361 -0
- package/src/event-store/__tests__/event-store.integration.ts +584 -0
- package/src/event-store/__tests__/get-stream-version-perf.integration.ts +83 -0
- package/src/event-store/__tests__/perf.integration.ts +255 -0
- package/src/event-store/__tests__/snapshot.integration.ts +267 -0
- package/src/event-store/__tests__/upcaster-dead-letter.integration.ts +204 -0
- package/src/event-store/__tests__/upcaster.integration.ts +460 -0
- package/src/event-store/admin-api.ts +257 -0
- package/src/event-store/archive.ts +106 -0
- package/src/event-store/errors.ts +35 -0
- package/src/event-store/event-store.ts +405 -0
- package/src/event-store/events-schema.ts +90 -0
- package/src/event-store/index.ts +50 -0
- package/src/event-store/snapshot.ts +210 -0
- package/src/event-store/upcaster-dead-letter.ts +119 -0
- package/src/event-store/upcaster.ts +147 -0
- package/src/files/__tests__/content-disposition.test.ts +123 -0
- package/src/files/__tests__/file-field-column.integration.ts +103 -0
- package/src/files/__tests__/file-field-pipeline.integration.ts +211 -0
- package/src/files/__tests__/file-handle.test.ts +122 -0
- package/src/files/__tests__/files.integration.ts +830 -0
- package/src/files/__tests__/storage-tracking.integration.ts +153 -0
- package/src/files/content-disposition.ts +55 -0
- package/src/files/file-handle.ts +63 -0
- package/src/files/file-ref-table.ts +22 -0
- package/src/files/file-routes.ts +353 -0
- package/src/files/in-memory-provider.ts +62 -0
- package/src/files/index.ts +29 -0
- package/src/files/local-provider.ts +35 -0
- package/src/files/storage-tracking.ts +60 -0
- package/src/files/types.ts +118 -0
- package/src/i18n/__tests__/i18n.test.ts +72 -0
- package/src/i18n/index.ts +29 -0
- package/src/jobs/__tests__/job-event-trigger.integration.ts +172 -0
- package/src/jobs/__tests__/job-multi-trigger.integration.ts +144 -0
- package/src/jobs/__tests__/jobs.integration.ts +566 -0
- package/src/jobs/index.ts +2 -0
- package/src/jobs/job-runner.ts +574 -0
- package/src/lifecycle/__tests__/create-test-lifecycle.ts +19 -0
- package/src/lifecycle/__tests__/lifecycle-server.integration.ts +108 -0
- package/src/lifecycle/__tests__/lifecycle.test.ts +212 -0
- package/src/lifecycle/__tests__/signal-handlers.test.ts +106 -0
- package/src/lifecycle/index.ts +13 -0
- package/src/lifecycle/lifecycle.ts +160 -0
- package/src/lifecycle/signal-handlers.ts +62 -0
- package/src/logging/__tests__/pino-trace-bridge.test.ts +50 -0
- package/src/logging/index.ts +3 -0
- package/src/logging/pino-logger.ts +64 -0
- package/src/logging/types.ts +7 -0
- package/src/migrations/__tests__/compare-snapshots.test.ts +150 -0
- package/src/migrations/__tests__/detect-drift.integration.ts +320 -0
- package/src/migrations/__tests__/detect-projections-to-rebuild.integration.ts +134 -0
- package/src/migrations/__tests__/rebuild-marker.test.ts +79 -0
- package/src/migrations/index.ts +28 -0
- package/src/migrations/projection-detection.ts +149 -0
- package/src/migrations/rebuild-marker.ts +64 -0
- package/src/migrations/schema-drift.ts +395 -0
- package/src/observability/__tests__/console-provider.test.ts +67 -0
- package/src/observability/__tests__/metric-validator.test.ts +87 -0
- package/src/observability/__tests__/noop-provider.test.ts +82 -0
- package/src/observability/__tests__/observability.integration.ts +559 -0
- package/src/observability/__tests__/prometheus-meter.test.ts +144 -0
- package/src/observability/__tests__/recording-meter.test.ts +101 -0
- package/src/observability/__tests__/recording-tracer.test.ts +110 -0
- package/src/observability/__tests__/sensitive-filter.test.ts +98 -0
- package/src/observability/console-provider.ts +130 -0
- package/src/observability/context.ts +26 -0
- package/src/observability/fallback.ts +34 -0
- package/src/observability/ids.ts +25 -0
- package/src/observability/index.ts +79 -0
- package/src/observability/metric-validator.ts +86 -0
- package/src/observability/metrics-handle.ts +56 -0
- package/src/observability/noop-provider.ts +146 -0
- package/src/observability/prometheus-meter.ts +284 -0
- package/src/observability/recording-meter.ts +156 -0
- package/src/observability/recording-tracer.ts +198 -0
- package/src/observability/redis-wrapper.ts +132 -0
- package/src/observability/sensitive-filter.ts +108 -0
- package/src/observability/standard-metrics.ts +213 -0
- package/src/observability/types/index.ts +29 -0
- package/src/observability/types/metric.ts +56 -0
- package/src/observability/types/provider.ts +32 -0
- package/src/observability/types/span.ts +64 -0
- package/src/pipeline/__tests__/archive-stream.integration.ts +220 -0
- package/src/pipeline/__tests__/auth-claims-resolver.test.ts +279 -0
- package/src/pipeline/__tests__/cascade-handler.integration.ts +419 -0
- package/src/pipeline/__tests__/cascade-handler.test.ts +52 -0
- package/src/pipeline/__tests__/causation-chain.integration.ts +206 -0
- package/src/pipeline/__tests__/ctx-bridge.integration.ts +234 -0
- package/src/pipeline/__tests__/dispatcher.test.ts +379 -0
- package/src/pipeline/__tests__/distributed-lock.integration.ts +67 -0
- package/src/pipeline/__tests__/domain-events-projections.integration.ts +323 -0
- package/src/pipeline/__tests__/event-dedup.integration.ts +153 -0
- package/src/pipeline/__tests__/event-define-event-strict.integration.ts +202 -0
- package/src/pipeline/__tests__/event-dispatcher-lifecycle.integration.ts +220 -0
- package/src/pipeline/__tests__/event-dispatcher-multi-instance.integration.ts +423 -0
- package/src/pipeline/__tests__/event-dispatcher-pg-listen.integration.ts +123 -0
- package/src/pipeline/__tests__/event-dispatcher-recovery.integration.ts +202 -0
- package/src/pipeline/__tests__/event-dispatcher-second-audit.integration.ts +290 -0
- package/src/pipeline/__tests__/event-dispatcher-strict.test.ts +65 -0
- package/src/pipeline/__tests__/event-dispatcher.integration.ts +287 -0
- package/src/pipeline/__tests__/event-retention.integration.ts +239 -0
- package/src/pipeline/__tests__/fetch-for-writing.integration.ts +281 -0
- package/src/pipeline/__tests__/lifecycle-pipeline.test.ts +430 -0
- package/src/pipeline/__tests__/load-aggregate-query.integration.ts +266 -0
- package/src/pipeline/__tests__/msp-error-mode.integration.ts +149 -0
- package/src/pipeline/__tests__/msp-multi-hop.integration.ts +228 -0
- package/src/pipeline/__tests__/msp-rebuild.integration.ts +368 -0
- package/src/pipeline/__tests__/multi-stream-projection.integration.ts +341 -0
- package/src/pipeline/__tests__/perf-rebuild.integration.ts +147 -0
- package/src/pipeline/__tests__/projection-rebuild.integration.ts +551 -0
- package/src/pipeline/__tests__/query-projection.integration.ts +201 -0
- package/src/pipeline/__tests__/redis-pipeline.integration.ts +306 -0
- package/src/pipeline/append-event-core.ts +117 -0
- package/src/pipeline/auth-claims-resolver.ts +103 -0
- package/src/pipeline/cascade-handler.ts +113 -0
- package/src/pipeline/dispatcher.ts +1585 -0
- package/src/pipeline/distributed-lock.ts +37 -0
- package/src/pipeline/entity-cache.ts +113 -0
- package/src/pipeline/event-consumer-state.ts +108 -0
- package/src/pipeline/event-dedup.ts +23 -0
- package/src/pipeline/event-dispatcher.ts +1016 -0
- package/src/pipeline/event-retention.ts +154 -0
- package/src/pipeline/idempotency.ts +76 -0
- package/src/pipeline/index.ts +66 -0
- package/src/pipeline/lifecycle-pipeline.ts +409 -0
- package/src/pipeline/msp-rebuild.ts +242 -0
- package/src/pipeline/multi-stream-apply-context.ts +115 -0
- package/src/pipeline/projection-rebuild.ts +334 -0
- package/src/pipeline/projection-state.ts +72 -0
- package/src/pipeline/projections-runner.ts +56 -0
- package/src/pipeline/redis-keys.ts +11 -0
- package/src/pipeline/system-hooks.ts +190 -0
- package/src/random/__tests__/generate.test.ts +149 -0
- package/src/random/generate.ts +141 -0
- package/src/random/index.ts +8 -0
- package/src/random/words.ts +392 -0
- package/src/rate-limit/__tests__/dispatcher-l3.integration.ts +111 -0
- package/src/rate-limit/__tests__/middleware.integration.ts +189 -0
- package/src/rate-limit/__tests__/resolver.integration.ts +189 -0
- package/src/rate-limit/bucket.ts +36 -0
- package/src/rate-limit/index.ts +14 -0
- package/src/rate-limit/middleware.ts +152 -0
- package/src/rate-limit/resolver.ts +267 -0
- package/src/redis/__tests__/redis-options.test.ts +54 -0
- package/src/redis/index.ts +74 -0
- package/src/search/__tests__/meilisearch-adapter.integration.ts +236 -0
- package/src/search/__tests__/search-adapter.test.ts +256 -0
- package/src/search/in-memory-adapter.ts +123 -0
- package/src/search/index.ts +12 -0
- package/src/search/meilisearch-adapter.ts +106 -0
- package/src/search/types.ts +39 -0
- package/src/secrets/__tests__/dek-cache.test.ts +213 -0
- package/src/secrets/__tests__/env-master-key-provider.test.ts +119 -0
- package/src/secrets/__tests__/envelope.test.ts +74 -0
- package/src/secrets/__tests__/leak-guard.test.ts +92 -0
- package/src/secrets/__tests__/rotation.test.ts +149 -0
- package/src/secrets/dek-cache.ts +116 -0
- package/src/secrets/env-master-key-provider.ts +162 -0
- package/src/secrets/envelope.ts +55 -0
- package/src/secrets/index.ts +19 -0
- package/src/secrets/leak-guard.ts +87 -0
- package/src/secrets/rotation.ts +34 -0
- package/src/secrets/types.ts +107 -0
- package/src/stack/db.ts +104 -0
- package/src/stack/event-collector.ts +23 -0
- package/src/stack/index.ts +32 -0
- package/src/stack/redis.ts +44 -0
- package/src/stack/request-helper.ts +168 -0
- package/src/stack/table-helpers.ts +104 -0
- package/src/stack/test-stack.ts +357 -0
- package/src/stack/test-users.ts +37 -0
- package/src/testing/__tests__/e2e-generator.test.ts +230 -0
- package/src/testing/__tests__/ensure-entity-table.integration.ts +54 -0
- package/src/testing/access-assertions.ts +15 -0
- package/src/testing/assertions.ts +35 -0
- package/src/testing/e2e-generator.ts +465 -0
- package/src/testing/expect-error.ts +25 -0
- package/src/testing/handler-context.ts +125 -0
- package/src/testing/http-cookies.ts +52 -0
- package/src/testing/index.ts +41 -0
- package/src/testing/late-bound.ts +39 -0
- package/src/testing/mutable-master-key-provider.ts +31 -0
- package/src/testing/observability-recorder.ts +54 -0
- package/src/testing/shared-entities.ts +49 -0
- package/src/testing/utils.ts +1 -0
- package/src/testing/wait-for.ts +31 -0
- package/src/time/__tests__/polyfill.test.ts +73 -0
- package/src/time/__tests__/tz-context.test.ts +121 -0
- package/src/time/index.ts +21 -0
- package/src/time/polyfill.ts +70 -0
- package/src/time/tz-context.ts +107 -0
- package/src/ui-types/app-schema.ts +57 -0
- package/src/ui-types/index.ts +65 -0
- package/src/utils/__tests__/assert.test.ts +17 -0
- package/src/utils/__tests__/env-parse.test.ts +54 -0
- package/src/utils/assert.ts +18 -0
- package/src/utils/env-parse.ts +16 -0
- package/src/utils/ids.ts +16 -0
- package/src/utils/index.ts +5 -0
- package/src/utils/safe-json.ts +30 -0
- package/src/utils/serialization.ts +7 -0
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// tenant_storage_usage MSP — aggregates upload sizes per tenant.
|
|
2
|
+
//
|
|
3
|
+
// Proves:
|
|
4
|
+
// 1. Two uploads from the same tenant end up on a single row with the
|
|
5
|
+
// sums and counts incremented atomically.
|
|
6
|
+
// 2. Uploads from different tenants land on separate rows — no cross-
|
|
7
|
+
// tenant leakage through the UPSERT.
|
|
8
|
+
// 3. The table column types survive round-trip (bigint → number via
|
|
9
|
+
// Drizzle's mode:"number", so arithmetic in assertions Just Works).
|
|
10
|
+
|
|
11
|
+
import { eq, sql } from "drizzle-orm";
|
|
12
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "vitest";
|
|
13
|
+
import type { SessionUser } from "../../engine";
|
|
14
|
+
import { createTestUser, setupTestStack, type TestStack, TestUsers } from "../../stack";
|
|
15
|
+
import {
|
|
16
|
+
createInMemoryFileProvider,
|
|
17
|
+
filesStorageTrackingFeature,
|
|
18
|
+
type InMemoryFileProvider,
|
|
19
|
+
tenantStorageUsageTable,
|
|
20
|
+
} from "..";
|
|
21
|
+
|
|
22
|
+
let stack: TestStack;
|
|
23
|
+
let provider: InMemoryFileProvider;
|
|
24
|
+
|
|
25
|
+
const admin = TestUsers.admin;
|
|
26
|
+
// Second tenant — a different UUID in the valid v4 range. The MSP must
|
|
27
|
+
// key on event.tenantId, so this row should never cross over with admin's.
|
|
28
|
+
const otherAdmin = createTestUser({
|
|
29
|
+
tenantId: "00000000-0000-4000-8000-000000000042",
|
|
30
|
+
roles: ["Admin"],
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
// Two tiny payloads with distinct lengths so the sum assertion can tell
|
|
34
|
+
// them apart without relying on the underlying bytes.
|
|
35
|
+
const SMALL = new Uint8Array([0x89, 0x50, 0x4e, 0x47, ...Array(16).fill(0)]); // 20 bytes, PNG-ish
|
|
36
|
+
const LARGE = new Uint8Array([0x89, 0x50, 0x4e, 0x47, ...Array(96).fill(0)]); // 100 bytes
|
|
37
|
+
|
|
38
|
+
beforeAll(async () => {
|
|
39
|
+
provider = createInMemoryFileProvider();
|
|
40
|
+
stack = await setupTestStack({
|
|
41
|
+
features: [filesStorageTrackingFeature],
|
|
42
|
+
files: { storageProvider: provider },
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
afterAll(async () => {
|
|
47
|
+
await stack.cleanup();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
beforeEach(async () => {
|
|
51
|
+
provider.clear();
|
|
52
|
+
stack.events.reset();
|
|
53
|
+
// Reset events + storage-usage row + consumer cursors + file_refs so
|
|
54
|
+
// each test starts from zero. kumiko_event_consumers registration is
|
|
55
|
+
// re-asserted below; truncating it forces ensureRegistered to seed the
|
|
56
|
+
// cursor at event.id = 0.
|
|
57
|
+
await stack.db.execute(
|
|
58
|
+
sql`TRUNCATE kumiko_events, kumiko_event_consumers, file_refs, read_tenant_storage_usage RESTART IDENTITY CASCADE`,
|
|
59
|
+
);
|
|
60
|
+
await stack.eventDispatcher?.ensureRegistered();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
async function upload(user: SessionUser, name: string, content: Uint8Array): Promise<void> {
|
|
64
|
+
const token = await stack.jwt.sign(user);
|
|
65
|
+
const formData = new FormData();
|
|
66
|
+
formData.append("file", new File([Buffer.from(content)], name, { type: "image/png" }));
|
|
67
|
+
const res = await stack.app.request("/api/files", {
|
|
68
|
+
method: "POST",
|
|
69
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
70
|
+
body: formData,
|
|
71
|
+
});
|
|
72
|
+
expect(res.status).toBe(201);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function usageFor(tenantId: string): Promise<{ totalBytes: number; fileCount: number }> {
|
|
76
|
+
const [row] = await stack.db
|
|
77
|
+
.select({
|
|
78
|
+
totalBytes: tenantStorageUsageTable.totalBytes,
|
|
79
|
+
fileCount: tenantStorageUsageTable.fileCount,
|
|
80
|
+
})
|
|
81
|
+
.from(tenantStorageUsageTable)
|
|
82
|
+
.where(eq(tenantStorageUsageTable.tenantId, tenantId));
|
|
83
|
+
return row ?? { totalBytes: 0, fileCount: 0 };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
describe("tenant-storage-usage MSP", () => {
|
|
87
|
+
test("single upload writes a row with totalBytes = size, fileCount = 1", async () => {
|
|
88
|
+
await upload(admin, "a.png", SMALL);
|
|
89
|
+
await stack.eventDispatcher?.runOnce();
|
|
90
|
+
|
|
91
|
+
const usage = await usageFor(admin.tenantId);
|
|
92
|
+
expect(usage).toEqual({ totalBytes: SMALL.length, fileCount: 1 });
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("two uploads same tenant — row increments atomically (UPSERT)", async () => {
|
|
96
|
+
await upload(admin, "a.png", SMALL);
|
|
97
|
+
await upload(admin, "b.png", LARGE);
|
|
98
|
+
await stack.eventDispatcher?.runOnce();
|
|
99
|
+
|
|
100
|
+
const usage = await usageFor(admin.tenantId);
|
|
101
|
+
expect(usage).toEqual({
|
|
102
|
+
totalBytes: SMALL.length + LARGE.length,
|
|
103
|
+
fileCount: 2,
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// Exactly one row per tenant — the UPSERT must not insert a second
|
|
107
|
+
// row for the second upload.
|
|
108
|
+
const rows = await stack.db
|
|
109
|
+
.select()
|
|
110
|
+
.from(tenantStorageUsageTable)
|
|
111
|
+
.where(eq(tenantStorageUsageTable.tenantId, admin.tenantId));
|
|
112
|
+
expect(rows).toHaveLength(1);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("two tenants — separate rows, no cross-leakage", async () => {
|
|
116
|
+
await upload(admin, "a.png", SMALL);
|
|
117
|
+
await upload(otherAdmin, "b.png", LARGE);
|
|
118
|
+
await stack.eventDispatcher?.runOnce();
|
|
119
|
+
|
|
120
|
+
const adminUsage = await usageFor(admin.tenantId);
|
|
121
|
+
const otherUsage = await usageFor(otherAdmin.tenantId);
|
|
122
|
+
|
|
123
|
+
expect(adminUsage).toEqual({ totalBytes: SMALL.length, fileCount: 1 });
|
|
124
|
+
expect(otherUsage).toEqual({ totalBytes: LARGE.length, fileCount: 1 });
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("lastUpdatedAt is set and advances on subsequent uploads", async () => {
|
|
128
|
+
await upload(admin, "a.png", SMALL);
|
|
129
|
+
await stack.eventDispatcher?.runOnce();
|
|
130
|
+
|
|
131
|
+
const [first] = await stack.db
|
|
132
|
+
.select({ at: tenantStorageUsageTable.lastUpdatedAt })
|
|
133
|
+
.from(tenantStorageUsageTable)
|
|
134
|
+
.where(eq(tenantStorageUsageTable.tenantId, admin.tenantId));
|
|
135
|
+
expect(first?.at).toBeInstanceOf(Temporal.Instant);
|
|
136
|
+
|
|
137
|
+
// Postgres NOW() resolution is microseconds; a second upload a beat
|
|
138
|
+
// later must produce a strictly later timestamp (or at least not an
|
|
139
|
+
// older one). We assert >= rather than > to keep the test tolerant
|
|
140
|
+
// of same-clock-tick runs.
|
|
141
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
142
|
+
await upload(admin, "b.png", LARGE);
|
|
143
|
+
await stack.eventDispatcher?.runOnce();
|
|
144
|
+
|
|
145
|
+
const [second] = await stack.db
|
|
146
|
+
.select({ at: tenantStorageUsageTable.lastUpdatedAt })
|
|
147
|
+
.from(tenantStorageUsageTable)
|
|
148
|
+
.where(eq(tenantStorageUsageTable.tenantId, admin.tenantId));
|
|
149
|
+
expect(second?.at).toBeInstanceOf(Temporal.Instant);
|
|
150
|
+
if (!first?.at || !second?.at) throw new Error("missing rows");
|
|
151
|
+
expect(Temporal.Instant.compare(second.at, first.at)).toBeGreaterThanOrEqual(0);
|
|
152
|
+
});
|
|
153
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// RFC-6266 + RFC-5987 compliant Content-Disposition builder.
|
|
2
|
+
//
|
|
3
|
+
// fileName reaches this module from the client's multipart upload — it is
|
|
4
|
+
// effectively attacker-controlled. A name like
|
|
5
|
+
// `foo.pdf"; filename*=utf-8''evil.exe` would break the `filename="..."`
|
|
6
|
+
// header quoting and inject a second parameter if we interpolated the raw
|
|
7
|
+
// string. Two-step fix:
|
|
8
|
+
//
|
|
9
|
+
// 1. ASCII fallback for `filename="..."` — strip anything outside a safe
|
|
10
|
+
// token set, keeping the name readable for legacy clients that don't
|
|
11
|
+
// understand RFC 5987.
|
|
12
|
+
// 2. Percent-encoded UTF-8 for `filename*=UTF-8''...` — the modern
|
|
13
|
+
// parameter that every current browser honours. RFC 5987 requires a
|
|
14
|
+
// handful of reserved chars that encodeURIComponent leaves alone
|
|
15
|
+
// ('()*) to also be escaped.
|
|
16
|
+
//
|
|
17
|
+
// Lives in its own module so the sanitisation is unit-testable in
|
|
18
|
+
// isolation — the HTTP route exercises integration; edge cases around
|
|
19
|
+
// encoding + fallback live in content-disposition.test.ts.
|
|
20
|
+
|
|
21
|
+
const MAX_FALLBACK_LENGTH = 100;
|
|
22
|
+
const SAFE_FALLBACK_CHARS = /[^A-Za-z0-9.\-_()]/g;
|
|
23
|
+
const RFC_5987_EXTRA_ESCAPES = /['()*]/g;
|
|
24
|
+
|
|
25
|
+
export function buildContentDispositionHeader(fileName: string): string {
|
|
26
|
+
const asciiFallback = toAsciiFallback(fileName);
|
|
27
|
+
const encoded = encodeRFC5987(fileName);
|
|
28
|
+
return `attachment; filename="${asciiFallback}"; filename*=UTF-8''${encoded}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Collapse everything outside a safe token set (letters, digits, dot, dash,
|
|
32
|
+
// underscore, parens) to underscore. Bounded length so a giant client-
|
|
33
|
+
// supplied name can't balloon the header.
|
|
34
|
+
//
|
|
35
|
+
// Falls back to "download" in two cases: (1) the stripped result is empty,
|
|
36
|
+
// and (2) nothing alphanumeric survived — a filename like "_______.png" is
|
|
37
|
+
// legal but useless. "download" is readable; the original bytes still
|
|
38
|
+
// reach the browser losslessly via filename* in the surrounding header.
|
|
39
|
+
export function toAsciiFallback(name: string): string {
|
|
40
|
+
const stripped = name.replace(SAFE_FALLBACK_CHARS, "_").slice(0, MAX_FALLBACK_LENGTH);
|
|
41
|
+
if (stripped.length === 0) return "download";
|
|
42
|
+
if (!/[A-Za-z0-9]/.test(stripped)) return "download";
|
|
43
|
+
return stripped;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// encodeURIComponent handles the UTF-8 → percent-encoding step but leaves
|
|
47
|
+
// a handful of characters unescaped that RFC 5987 calls out as reserved
|
|
48
|
+
// ( ' ( ) * ). Escape those explicitly so the output is strictly
|
|
49
|
+
// conformant and safe to drop into the `ext-value` production.
|
|
50
|
+
export function encodeRFC5987(value: string): string {
|
|
51
|
+
return encodeURIComponent(value).replace(
|
|
52
|
+
RFC_5987_EXTRA_ESCAPES,
|
|
53
|
+
(c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`,
|
|
54
|
+
);
|
|
55
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// FileHandle — a thin pointer-object around a storage key.
|
|
2
|
+
//
|
|
3
|
+
// The contract the framework exposes to hook/handler code: a FileHandle lets
|
|
4
|
+
// you read/write/delete the binary behind a storage key without ever passing
|
|
5
|
+
// the binary through event payloads, job queues, or hook signatures. Events
|
|
6
|
+
// carry the key, hooks reach for `ctx.files.ref(key)` when they actually need
|
|
7
|
+
// the bytes, and big files stay in the storage layer where they belong.
|
|
8
|
+
//
|
|
9
|
+
// `derive(suffix)` is the primitive for thumbnail/variant keys: it inserts a
|
|
10
|
+
// suffix before the file extension — `foo/bar.jpg` + `"medium"` →
|
|
11
|
+
// `foo/bar.medium.jpg`. Stable, reversible, no extra lookup tables.
|
|
12
|
+
|
|
13
|
+
import type { FileStorageProvider } from "./types";
|
|
14
|
+
|
|
15
|
+
export type FileHandle = {
|
|
16
|
+
readonly key: string;
|
|
17
|
+
read(): Promise<Uint8Array>;
|
|
18
|
+
write(data: Uint8Array, mimeType?: string): Promise<void>;
|
|
19
|
+
delete(): Promise<void>;
|
|
20
|
+
exists(): Promise<boolean>;
|
|
21
|
+
// Produce a handle for a derived key (e.g. a thumbnail). Does not touch
|
|
22
|
+
// storage; only computes the key. Writing to the derived handle is the
|
|
23
|
+
// caller's job.
|
|
24
|
+
derive(suffix: string): FileHandle;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
// The `ctx.files` service — a factory that materialises a FileHandle for a
|
|
28
|
+
// storage key. One per app, wrapped around whichever FileStorageProvider the
|
|
29
|
+
// app boot registered.
|
|
30
|
+
export type FileContext = {
|
|
31
|
+
ref(key: string): FileHandle;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export function createFileHandle(key: string, provider: FileStorageProvider): FileHandle {
|
|
35
|
+
return {
|
|
36
|
+
key,
|
|
37
|
+
read: () => provider.read(key),
|
|
38
|
+
write: (data, mimeType) => provider.write(key, data, mimeType),
|
|
39
|
+
delete: () => provider.delete(key),
|
|
40
|
+
exists: () => provider.exists(key),
|
|
41
|
+
derive: (suffix) => createFileHandle(deriveKey(key, suffix), provider),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function createFileContext(provider: FileStorageProvider): FileContext {
|
|
46
|
+
return {
|
|
47
|
+
ref: (key) => createFileHandle(key, provider),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Inserts a suffix before the file extension. Keys without an extension get
|
|
52
|
+
// the suffix appended with a dot: `foo/bar` + `"small"` → `foo/bar.small`.
|
|
53
|
+
// Keys with a dot earlier in the path (e.g. `archive.v2/foo.jpg`) correctly
|
|
54
|
+
// split on the LAST segment only.
|
|
55
|
+
export function deriveKey(key: string, suffix: string): string {
|
|
56
|
+
const lastSlash = key.lastIndexOf("/");
|
|
57
|
+
const lastSegment = lastSlash === -1 ? key : key.slice(lastSlash + 1);
|
|
58
|
+
const lastDot = lastSegment.lastIndexOf(".");
|
|
59
|
+
if (lastDot === -1) return `${key}.${suffix}`;
|
|
60
|
+
const prefix = key.slice(0, key.length - lastSegment.length + lastDot);
|
|
61
|
+
const ext = lastSegment.slice(lastDot);
|
|
62
|
+
return `${prefix}.${suffix}${ext}`;
|
|
63
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { sql } from "drizzle-orm";
|
|
2
|
+
import { instant, integer, table as pgTable, text, uuid } from "../db/dialect";
|
|
3
|
+
|
|
4
|
+
// `id` is a UUID (not serial): it doubles as the aggregate-id for the
|
|
5
|
+
// `fileRef` event stream — every upload appends exactly one
|
|
6
|
+
// `files:event:uploaded` event keyed by this id. UUIDs also close the
|
|
7
|
+
// enumeration-attack vector on /files/:id URLs.
|
|
8
|
+
export const fileRefsTable = pgTable("file_refs", {
|
|
9
|
+
id: uuid("id").primaryKey(),
|
|
10
|
+
tenantId: uuid("tenant_id").notNull(),
|
|
11
|
+
storageKey: text("storage_key").notNull(),
|
|
12
|
+
fileName: text("file_name").notNull(),
|
|
13
|
+
mimeType: text("mime_type").notNull(),
|
|
14
|
+
size: integer("size").notNull(),
|
|
15
|
+
entityType: text("entity_type"),
|
|
16
|
+
// entityId references any entity (mostly UUID-keyed under ES). Text keeps
|
|
17
|
+
// the column backward-compat with older integer-keyed entities too.
|
|
18
|
+
entityId: text("entity_id"),
|
|
19
|
+
fieldName: text("field_name"),
|
|
20
|
+
insertedAt: instant("inserted_at").default(sql`now()`).notNull(),
|
|
21
|
+
insertedById: text("inserted_by_id"),
|
|
22
|
+
});
|
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
import { and, eq } from "drizzle-orm";
|
|
2
|
+
import { Hono } from "hono";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { getUser } from "../api/auth-middleware";
|
|
5
|
+
import type { DbConnection } from "../db/connection";
|
|
6
|
+
import type { EventDef } from "../engine/types";
|
|
7
|
+
import { isFileField, type Registry, type SessionUser, type TenantId } from "../engine/types";
|
|
8
|
+
import { append as appendEvent } from "../event-store/event-store";
|
|
9
|
+
import { generateId } from "../utils";
|
|
10
|
+
import { buildContentDispositionHeader } from "./content-disposition";
|
|
11
|
+
import { fileRefsTable } from "./file-ref-table";
|
|
12
|
+
import type { FileStorageProvider } from "./types";
|
|
13
|
+
import { buildStorageKey, validateFile } from "./types";
|
|
14
|
+
|
|
15
|
+
// Decision returned by a FileAccessGuard — distinct from boolean so callers
|
|
16
|
+
// can't accidentally negate or default it.
|
|
17
|
+
export type FileAccessDecision = "allow" | "deny";
|
|
18
|
+
|
|
19
|
+
export type FileRef = {
|
|
20
|
+
id: string;
|
|
21
|
+
tenantId: TenantId;
|
|
22
|
+
storageKey: string;
|
|
23
|
+
fileName: string;
|
|
24
|
+
mimeType: string;
|
|
25
|
+
size: number;
|
|
26
|
+
entityType: string | null;
|
|
27
|
+
entityId: string | null;
|
|
28
|
+
fieldName: string | null;
|
|
29
|
+
insertedById: string | null;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
// Event emitted after a successful upload. Downstream hooks / MSPs subscribe
|
|
33
|
+
// on `fileUploadedEvent.name` via an r.multiStreamProjection apply map. The
|
|
34
|
+
// payload carries metadata + the storage key — never the binary itself.
|
|
35
|
+
// Consumers that need the bytes call `ctx.files.ref(payload.storageKey).read()`.
|
|
36
|
+
//
|
|
37
|
+
// Packaged as a framework-owned EventDef so consumers can narrow the raw
|
|
38
|
+
// StoredEvent.payload via `typedPayload(event, fileUploadedEvent)` instead
|
|
39
|
+
// of hand-casting. Schema version starts at 1; bump in lockstep with an
|
|
40
|
+
// r.eventMigration when the shape breaks.
|
|
41
|
+
export const fileUploadedPayloadSchema = z.object({
|
|
42
|
+
fileRefId: z.uuid(),
|
|
43
|
+
storageKey: z.string().min(1),
|
|
44
|
+
fileName: z.string().min(1),
|
|
45
|
+
mimeType: z.string().min(1),
|
|
46
|
+
size: z.number().int().nonnegative(),
|
|
47
|
+
entityType: z.string().nullable(),
|
|
48
|
+
entityId: z.string().nullable(),
|
|
49
|
+
fieldName: z.string().nullable(),
|
|
50
|
+
insertedById: z.string().min(1),
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
export type FileUploadedPayload = z.infer<typeof fileUploadedPayloadSchema>;
|
|
54
|
+
|
|
55
|
+
export const fileUploadedEvent: EventDef<FileUploadedPayload> = {
|
|
56
|
+
name: "files:event:uploaded",
|
|
57
|
+
schema: fileUploadedPayloadSchema,
|
|
58
|
+
version: 1,
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
// Convenience re-export so apps that only reach for the event name (e.g. as
|
|
62
|
+
// an apply-map key) don't have to dereference `.name` everywhere.
|
|
63
|
+
export const FILE_UPLOADED_EVENT_TYPE = fileUploadedEvent.name;
|
|
64
|
+
|
|
65
|
+
// Checks whether `user` may read/delete the given file. The default guard
|
|
66
|
+
// (ownerOrPrivilegedGuard) approves uploaders + any role in privilegedRoles.
|
|
67
|
+
// Apps can supply a custom guard to layer entity-level access (e.g. "drivers
|
|
68
|
+
// can read files attached to orders assigned to them").
|
|
69
|
+
export type FileAccessGuard = (args: {
|
|
70
|
+
readonly fileRef: FileRef;
|
|
71
|
+
readonly user: SessionUser;
|
|
72
|
+
readonly operation: "read" | "delete";
|
|
73
|
+
}) => FileAccessDecision | Promise<FileAccessDecision>;
|
|
74
|
+
|
|
75
|
+
export type FileRoutesOptions = {
|
|
76
|
+
readonly db: DbConnection;
|
|
77
|
+
readonly storageProvider: FileStorageProvider;
|
|
78
|
+
readonly registry?: Registry;
|
|
79
|
+
readonly maxUploadSize?: string; // global default, e.g. "10mb"
|
|
80
|
+
// Roles that bypass the default owner-check on entity-attached files.
|
|
81
|
+
// Defaults to ["Admin", "SystemAdmin"]; override to match your app's roles.
|
|
82
|
+
readonly privilegedRoles?: readonly string[];
|
|
83
|
+
// Replaces the default guard entirely. When set, privilegedRoles is ignored
|
|
84
|
+
// — the app takes full responsibility for the decision.
|
|
85
|
+
readonly accessGuard?: FileAccessGuard;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const DEFAULT_PRIVILEGED_ROLES = ["Admin", "SystemAdmin"] as const;
|
|
89
|
+
|
|
90
|
+
// 15 minutes — long enough for a download to start, short enough that a
|
|
91
|
+
// leaked URL (e.g. from a browser history screenshot) isn't a long-lived
|
|
92
|
+
// credential. Matches the security-checklist in core-files.md.
|
|
93
|
+
const SIGNED_URL_DEFAULT_EXPIRY_SECONDS = 15 * 60;
|
|
94
|
+
|
|
95
|
+
// Default guard: on attached files, allow the uploader or a privileged role.
|
|
96
|
+
// Unattached files are tenant-wide (the tenant boundary is already enforced
|
|
97
|
+
// by the query).
|
|
98
|
+
function createDefaultGuard(privilegedRoles: readonly string[]): FileAccessGuard {
|
|
99
|
+
return ({ fileRef, user }) => {
|
|
100
|
+
if (fileRef.entityType === null || fileRef.entityId === null) return "allow";
|
|
101
|
+
if (fileRef.insertedById === user.id) return "allow";
|
|
102
|
+
for (const role of privilegedRoles) {
|
|
103
|
+
if (user.roles.includes(role)) return "allow";
|
|
104
|
+
}
|
|
105
|
+
return "deny";
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function createFileRoutes(options: FileRoutesOptions): Hono {
|
|
110
|
+
const { db, storageProvider } = options;
|
|
111
|
+
const privilegedRoles = options.privilegedRoles ?? DEFAULT_PRIVILEGED_ROLES;
|
|
112
|
+
const guard: FileAccessGuard = options.accessGuard ?? createDefaultGuard(privilegedRoles);
|
|
113
|
+
const api = new Hono();
|
|
114
|
+
|
|
115
|
+
// POST /files — multipart upload.
|
|
116
|
+
api.post("/files", async (c) => {
|
|
117
|
+
const user = getUser(c);
|
|
118
|
+
const body = await c.req.parseBody();
|
|
119
|
+
const file = body["file"];
|
|
120
|
+
|
|
121
|
+
if (!file || !(file instanceof File)) {
|
|
122
|
+
return c.json({ error: "missing_file: expected multipart field 'file'" }, 400);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const entityType = typeof body["entityType"] === "string" ? body["entityType"] : undefined;
|
|
126
|
+
// Post-ES migration entities use UUID ids; we accept the raw string and
|
|
127
|
+
// store it in the text entityId column.
|
|
128
|
+
const entityId = typeof body["entityId"] === "string" ? body["entityId"] : undefined;
|
|
129
|
+
const fieldName = typeof body["fieldName"] === "string" ? body["fieldName"] : undefined;
|
|
130
|
+
|
|
131
|
+
// Validate against entity field definition if available.
|
|
132
|
+
let maxSize = options.maxUploadSize ?? "10mb";
|
|
133
|
+
let accept: readonly string[] | undefined;
|
|
134
|
+
|
|
135
|
+
if (options.registry && entityType && fieldName) {
|
|
136
|
+
const entity = options.registry.getEntity(entityType);
|
|
137
|
+
if (entity) {
|
|
138
|
+
const fieldDef = entity.fields[fieldName];
|
|
139
|
+
if (isFileField(fieldDef)) {
|
|
140
|
+
if (fieldDef.maxSize) maxSize = fieldDef.maxSize;
|
|
141
|
+
if (fieldDef.accept) accept = fieldDef.accept;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const validationError = validateFile(
|
|
147
|
+
{ fileName: file.name, mimeType: file.type, size: file.size },
|
|
148
|
+
{ maxSize, accept },
|
|
149
|
+
);
|
|
150
|
+
if (validationError) {
|
|
151
|
+
return c.json({ error: validationError }, 400);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const fileRefId = generateId();
|
|
155
|
+
const storageKey = buildStorageKey(
|
|
156
|
+
user.tenantId,
|
|
157
|
+
entityType ?? "unattached",
|
|
158
|
+
entityId ?? "",
|
|
159
|
+
fieldName ?? "file",
|
|
160
|
+
file.name,
|
|
161
|
+
generateId(),
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
// Write binary FIRST (outside the tx — network/disk I/O doesn't belong
|
|
165
|
+
// inside a PG connection's tx window). On DB-tx rollback below the bytes
|
|
166
|
+
// are orphaned in the provider; cleanup-jobs sweep those later. Losing a
|
|
167
|
+
// row on append-failure is acceptable; corrupting a committed row with a
|
|
168
|
+
// missing binary is not.
|
|
169
|
+
const data = new Uint8Array(await file.arrayBuffer());
|
|
170
|
+
await storageProvider.write(storageKey, data, file.type);
|
|
171
|
+
|
|
172
|
+
// Atomic: insert FileRef + append files:event:uploaded in one tx. Either
|
|
173
|
+
// both land or neither — no dangling FileRef without event, no event
|
|
174
|
+
// referencing a row that doesn't exist.
|
|
175
|
+
await db.transaction(async (tx) => {
|
|
176
|
+
await tx.insert(fileRefsTable).values({
|
|
177
|
+
id: fileRefId,
|
|
178
|
+
tenantId: user.tenantId,
|
|
179
|
+
storageKey,
|
|
180
|
+
fileName: file.name,
|
|
181
|
+
mimeType: file.type,
|
|
182
|
+
size: file.size,
|
|
183
|
+
entityType: entityType ?? null,
|
|
184
|
+
entityId: entityId ?? null,
|
|
185
|
+
fieldName: fieldName ?? null,
|
|
186
|
+
insertedById: user.id,
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
const payload: FileUploadedPayload = {
|
|
190
|
+
fileRefId,
|
|
191
|
+
storageKey,
|
|
192
|
+
fileName: file.name,
|
|
193
|
+
mimeType: file.type,
|
|
194
|
+
size: file.size,
|
|
195
|
+
entityType: entityType ?? null,
|
|
196
|
+
entityId: entityId ?? null,
|
|
197
|
+
fieldName: fieldName ?? null,
|
|
198
|
+
insertedById: user.id,
|
|
199
|
+
};
|
|
200
|
+
await appendEvent(tx, {
|
|
201
|
+
aggregateId: fileRefId,
|
|
202
|
+
aggregateType: "fileRef",
|
|
203
|
+
tenantId: user.tenantId,
|
|
204
|
+
expectedVersion: 0,
|
|
205
|
+
type: fileUploadedEvent.name,
|
|
206
|
+
// EventToAppend wants a Record — payload is typed through the
|
|
207
|
+
// EventDef so the cast collapses to a single boundary, not a
|
|
208
|
+
// double-`as unknown as` at the call site.
|
|
209
|
+
payload: payload as Record<string, unknown>, // @cast-boundary engine-payload
|
|
210
|
+
metadata: { userId: user.id },
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
return c.json(
|
|
215
|
+
{
|
|
216
|
+
id: fileRefId,
|
|
217
|
+
fileName: file.name,
|
|
218
|
+
mimeType: file.type,
|
|
219
|
+
size: file.size,
|
|
220
|
+
storageKey,
|
|
221
|
+
},
|
|
222
|
+
201,
|
|
223
|
+
);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
// GET /files/:id — download.
|
|
227
|
+
//
|
|
228
|
+
// Authorization stack:
|
|
229
|
+
// 1. tenantId must match (hard isolation, never crossable).
|
|
230
|
+
// 2. The configured FileAccessGuard decides read access. Default:
|
|
231
|
+
// unattached → allow; attached → uploader or privileged role.
|
|
232
|
+
// Apps override via options.accessGuard to layer entity-level rules.
|
|
233
|
+
api.get("/files/:id", async (c) => {
|
|
234
|
+
const user = getUser(c);
|
|
235
|
+
const id = c.req.param("id");
|
|
236
|
+
const fileRef = await loadFileForTenant(id, user.tenantId);
|
|
237
|
+
if (!fileRef) return c.json({ error: "not_found" }, 404);
|
|
238
|
+
|
|
239
|
+
const decision = await guard({ fileRef, user, operation: "read" });
|
|
240
|
+
if (decision === "deny") {
|
|
241
|
+
// 404 rather than 403 so the existence of the file isn't confirmed to
|
|
242
|
+
// an unauthorised caller — matches the tenant-miss response.
|
|
243
|
+
return c.json({ error: "not_found" }, 404);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const data = await storageProvider.read(fileRef.storageKey);
|
|
247
|
+
return new Response(Buffer.from(data), {
|
|
248
|
+
headers: {
|
|
249
|
+
"Content-Type": fileRef.mimeType,
|
|
250
|
+
"Content-Disposition": buildContentDispositionHeader(fileRef.fileName),
|
|
251
|
+
"Content-Length": String(fileRef.size),
|
|
252
|
+
},
|
|
253
|
+
});
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
// DELETE /files/:id — same guard, "delete" operation. Apps can differentiate
|
|
257
|
+
// read vs delete in their custom guard (e.g. only uploaders delete).
|
|
258
|
+
api.delete("/files/:id", async (c) => {
|
|
259
|
+
const user = getUser(c);
|
|
260
|
+
const id = c.req.param("id");
|
|
261
|
+
const fileRef = await loadFileForTenant(id, user.tenantId);
|
|
262
|
+
if (!fileRef) return c.json({ error: "not_found" }, 404);
|
|
263
|
+
|
|
264
|
+
const decision = await guard({ fileRef, user, operation: "delete" });
|
|
265
|
+
if (decision === "deny") return c.json({ error: "not_found" }, 404);
|
|
266
|
+
|
|
267
|
+
// Tombstone-Reihenfolge: DB-Row löschen FIRST, Bytes danach. Wenn der
|
|
268
|
+
// Storage-Call hängt oder fehlschlägt, sieht der User trotzdem den
|
|
269
|
+
// konsistenten "weg"-Zustand (kein Read findet die Row mehr) und der
|
|
270
|
+
// Cleanup-Job sweept die orphan'd bytes wie beim fehlgeschlagenen
|
|
271
|
+
// Upload. Umgekehrt liesse ein storage-success + db-fail eine Row mit
|
|
272
|
+
// permanent-broken Reference zurück — aus Sicht der API "Datei
|
|
273
|
+
// existiert" aber jeder Read 404t aus dem Provider.
|
|
274
|
+
await db.delete(fileRefsTable).where(eq(fileRefsTable.id, id));
|
|
275
|
+
await storageProvider.delete(fileRef.storageKey);
|
|
276
|
+
return c.json({ ok: true });
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
// GET /files/:id/download-url — returns a short-lived provider URL so the
|
|
280
|
+
// client can download directly (offloads bandwidth from the API, enables
|
|
281
|
+
// browser caching). Same auth + tenant + guard as GET /files/:id; the
|
|
282
|
+
// signed URL is only handed out after access is approved.
|
|
283
|
+
//
|
|
284
|
+
// Shape: JSON { url, expiresAt } rather than a 302 redirect. Redirects
|
|
285
|
+
// break browser `fetch()` on cross-origin URLs (CORS preflight semantics)
|
|
286
|
+
// and hide the expiry from the caller — JSON lets SPAs cache the URL
|
|
287
|
+
// until `expiresAt` without re-hitting the API.
|
|
288
|
+
//
|
|
289
|
+
// 501 when the wired provider doesn't support signed URLs (filesystem
|
|
290
|
+
// dev providers). Clients should fall back to the streaming endpoint.
|
|
291
|
+
api.get("/files/:id/download-url", async (c) => {
|
|
292
|
+
if (!storageProvider.getSignedUrl) {
|
|
293
|
+
return c.json(
|
|
294
|
+
{
|
|
295
|
+
error:
|
|
296
|
+
"signed_urls_not_supported: this provider does not support signed URLs — use GET /files/:id to stream",
|
|
297
|
+
},
|
|
298
|
+
501,
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const user = getUser(c);
|
|
303
|
+
const id = c.req.param("id");
|
|
304
|
+
const fileRef = await loadFileForTenant(id, user.tenantId);
|
|
305
|
+
if (!fileRef) return c.json({ error: "not_found" }, 404);
|
|
306
|
+
|
|
307
|
+
const decision = await guard({ fileRef, user, operation: "read" });
|
|
308
|
+
if (decision === "deny") return c.json({ error: "not_found" }, 404);
|
|
309
|
+
|
|
310
|
+
const expiresInSeconds = SIGNED_URL_DEFAULT_EXPIRY_SECONDS;
|
|
311
|
+
const url = await storageProvider.getSignedUrl(fileRef.storageKey, expiresInSeconds, {
|
|
312
|
+
// Hint the provider to set Content-Disposition so the browser prompts
|
|
313
|
+
// with the original filename instead of the UUID-based storage key.
|
|
314
|
+
// Sanitised via buildContentDispositionHeader — the same attacker-
|
|
315
|
+
// controlled fileName reaches the provider's presigned response.
|
|
316
|
+
contentDisposition: buildContentDispositionHeader(fileRef.fileName),
|
|
317
|
+
});
|
|
318
|
+
const expiresAt = new Date(Date.now() + expiresInSeconds * 1000).toISOString();
|
|
319
|
+
return c.json({ url, expiresAt });
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
// GET /files/:id/meta — metadata without the bytes. Guarded exactly like
|
|
323
|
+
// download (meta leaks fileName/mimeType/size).
|
|
324
|
+
api.get("/files/:id/meta", async (c) => {
|
|
325
|
+
const user = getUser(c);
|
|
326
|
+
const id = c.req.param("id");
|
|
327
|
+
const fileRef = await loadFileForTenant(id, user.tenantId);
|
|
328
|
+
if (!fileRef) return c.json({ error: "not_found" }, 404);
|
|
329
|
+
|
|
330
|
+
const decision = await guard({ fileRef, user, operation: "read" });
|
|
331
|
+
if (decision === "deny") return c.json({ error: "not_found" }, 404);
|
|
332
|
+
|
|
333
|
+
return c.json({
|
|
334
|
+
id: fileRef.id,
|
|
335
|
+
fileName: fileRef.fileName,
|
|
336
|
+
mimeType: fileRef.mimeType,
|
|
337
|
+
size: fileRef.size,
|
|
338
|
+
entityType: fileRef.entityType,
|
|
339
|
+
entityId: fileRef.entityId,
|
|
340
|
+
fieldName: fileRef.fieldName,
|
|
341
|
+
});
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
async function loadFileForTenant(id: string, tenantId: TenantId): Promise<FileRef | null> {
|
|
345
|
+
const [row] = await db
|
|
346
|
+
.select()
|
|
347
|
+
.from(fileRefsTable)
|
|
348
|
+
.where(and(eq(fileRefsTable.id, id), eq(fileRefsTable.tenantId, tenantId)));
|
|
349
|
+
return (row as FileRef | undefined) ?? null;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
return api;
|
|
353
|
+
}
|