@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,189 @@
|
|
|
1
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "vitest";
|
|
2
|
+
import { RateLimitError } from "../../errors";
|
|
3
|
+
import { createTestRedis, type TestRedis } from "../../stack";
|
|
4
|
+
import { createRateLimitResolver, type RateLimitResolver } from "../resolver";
|
|
5
|
+
|
|
6
|
+
let testRedis: TestRedis;
|
|
7
|
+
let resolver: RateLimitResolver;
|
|
8
|
+
|
|
9
|
+
// Controllable clock so tests can advance time deterministically without
|
|
10
|
+
// real waits. Production passes Date.now; tests inject this so refill
|
|
11
|
+
// behaviour is observable in milliseconds.
|
|
12
|
+
let mockNowMs: number;
|
|
13
|
+
|
|
14
|
+
beforeAll(async () => {
|
|
15
|
+
testRedis = await createTestRedis();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
afterAll(async () => {
|
|
19
|
+
await testRedis.cleanup();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
beforeEach(async () => {
|
|
23
|
+
await testRedis.flushNamespace();
|
|
24
|
+
mockNowMs = 1_700_000_000_000; // arbitrary fixed start
|
|
25
|
+
resolver = createRateLimitResolver({
|
|
26
|
+
redis: testRedis.redis,
|
|
27
|
+
keyPrefix: "test:rl:",
|
|
28
|
+
nowMs: () => mockNowMs,
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe("createRateLimitResolver — token bucket basics", () => {
|
|
33
|
+
test("first N requests within limit are allowed, N+1 is rejected", async () => {
|
|
34
|
+
const config = { limit: 5, windowSeconds: 60 };
|
|
35
|
+
const decisions = [];
|
|
36
|
+
for (let i = 0; i < 5; i++) {
|
|
37
|
+
decisions.push(await resolver.check("user:42", config));
|
|
38
|
+
}
|
|
39
|
+
expect(decisions.every((d) => d.allowed)).toBe(true);
|
|
40
|
+
expect(decisions.map((d) => d.remaining)).toEqual([4, 3, 2, 1, 0]);
|
|
41
|
+
|
|
42
|
+
const sixth = await resolver.check("user:42", config);
|
|
43
|
+
expect(sixth.allowed).toBe(false);
|
|
44
|
+
expect(sixth.remaining).toBe(0);
|
|
45
|
+
expect(sixth.retryAfterSeconds).toBeGreaterThan(0);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("buckets are isolated by key — different user has fresh bucket", async () => {
|
|
49
|
+
const config = { limit: 2, windowSeconds: 60 };
|
|
50
|
+
await resolver.check("user:a", config);
|
|
51
|
+
await resolver.check("user:a", config);
|
|
52
|
+
const aBlocked = await resolver.check("user:a", config);
|
|
53
|
+
expect(aBlocked.allowed).toBe(false);
|
|
54
|
+
|
|
55
|
+
const bFirst = await resolver.check("user:b", config);
|
|
56
|
+
expect(bFirst.allowed).toBe(true);
|
|
57
|
+
expect(bFirst.remaining).toBe(1);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("refill: after window/2 the bucket has limit/2 tokens back", async () => {
|
|
61
|
+
const config = { limit: 10, windowSeconds: 10 };
|
|
62
|
+
// Drain the bucket
|
|
63
|
+
for (let i = 0; i < 10; i++) await resolver.check("refill:user", config);
|
|
64
|
+
const drained = await resolver.check("refill:user", config);
|
|
65
|
+
expect(drained.allowed).toBe(false);
|
|
66
|
+
|
|
67
|
+
// Advance 5s = window/2 → ~5 tokens refilled
|
|
68
|
+
mockNowMs += 5000;
|
|
69
|
+
const decisions = [];
|
|
70
|
+
for (let i = 0; i < 5; i++) {
|
|
71
|
+
decisions.push(await resolver.check("refill:user", config));
|
|
72
|
+
}
|
|
73
|
+
expect(decisions.every((d) => d.allowed)).toBe(true);
|
|
74
|
+
|
|
75
|
+
// Sixth in this batch should be blocked again — only 5 tokens were refilled.
|
|
76
|
+
const overshoot = await resolver.check("refill:user", config);
|
|
77
|
+
expect(overshoot.allowed).toBe(false);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("refill caps at limit — long idle does not exceed bucket size", async () => {
|
|
81
|
+
const config = { limit: 5, windowSeconds: 10 };
|
|
82
|
+
await resolver.check("idle:user", config);
|
|
83
|
+
|
|
84
|
+
// Advance 10× the window → bucket would overflow without the cap.
|
|
85
|
+
mockNowMs += 10 * 10 * 1000;
|
|
86
|
+
|
|
87
|
+
const decisions = [];
|
|
88
|
+
for (let i = 0; i < 5; i++) {
|
|
89
|
+
decisions.push(await resolver.check("idle:user", config));
|
|
90
|
+
}
|
|
91
|
+
expect(decisions.every((d) => d.allowed)).toBe(true);
|
|
92
|
+
const blocked = await resolver.check("idle:user", config);
|
|
93
|
+
expect(blocked.allowed).toBe(false);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe("createRateLimitResolver — cost", () => {
|
|
98
|
+
test("cost: 5 deducts 5 tokens at once", async () => {
|
|
99
|
+
const config = { limit: 10, windowSeconds: 60, cost: 5 };
|
|
100
|
+
const first = await resolver.check("cost:user", config);
|
|
101
|
+
expect(first.allowed).toBe(true);
|
|
102
|
+
expect(first.remaining).toBe(5);
|
|
103
|
+
|
|
104
|
+
const second = await resolver.check("cost:user", config);
|
|
105
|
+
expect(second.allowed).toBe(true);
|
|
106
|
+
expect(second.remaining).toBe(0);
|
|
107
|
+
|
|
108
|
+
const third = await resolver.check("cost:user", config);
|
|
109
|
+
expect(third.allowed).toBe(false);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("cost > limit: never allowed", async () => {
|
|
113
|
+
const config = { limit: 5, windowSeconds: 60, cost: 10 };
|
|
114
|
+
const decision = await resolver.check("over:user", config);
|
|
115
|
+
expect(decision.allowed).toBe(false);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
describe("createRateLimitResolver — concurrency", () => {
|
|
120
|
+
test("100 parallel requests at limit=10 — exactly 10 are allowed", async () => {
|
|
121
|
+
const config = { limit: 10, windowSeconds: 60 };
|
|
122
|
+
// 100 concurrent calls. Lua atomicity → exactly `limit` of them
|
|
123
|
+
// come back allowed=true; the rest are rejected. No double-spend.
|
|
124
|
+
const results = await Promise.all(
|
|
125
|
+
Array.from({ length: 100 }, () => resolver.check("race:user", config)),
|
|
126
|
+
);
|
|
127
|
+
const allowedCount = results.filter((d) => d.allowed).length;
|
|
128
|
+
expect(allowedCount).toBe(10);
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
describe("createRateLimitResolver — peek", () => {
|
|
133
|
+
test("peek returns the same state across consecutive calls — no token deduction", async () => {
|
|
134
|
+
const config = { limit: 5, windowSeconds: 60 };
|
|
135
|
+
// Drain 2 tokens via real check() so the bucket is at remaining=3.
|
|
136
|
+
await resolver.check("peek:user", config);
|
|
137
|
+
await resolver.check("peek:user", config);
|
|
138
|
+
|
|
139
|
+
// Three back-to-back peeks at the SAME wallclock — remaining must
|
|
140
|
+
// not move. If peek mutated state, each call would deduct/refill
|
|
141
|
+
// and the numbers would drift.
|
|
142
|
+
const a = await resolver.peek("peek:user", config);
|
|
143
|
+
const b = await resolver.peek("peek:user", config);
|
|
144
|
+
const c = await resolver.peek("peek:user", config);
|
|
145
|
+
expect(a.remaining).toBe(3);
|
|
146
|
+
expect(b.remaining).toBe(3);
|
|
147
|
+
expect(c.remaining).toBe(3);
|
|
148
|
+
|
|
149
|
+
// After 100 peeks, the next real check still sees remaining=3-1=2.
|
|
150
|
+
// Proves peek doesn't shift the refill timestamp either — if it did,
|
|
151
|
+
// the next refill maths would over-credit and remaining would jump.
|
|
152
|
+
for (let i = 0; i < 100; i++) await resolver.peek("peek:user", config);
|
|
153
|
+
const next = await resolver.check("peek:user", config);
|
|
154
|
+
expect(next.remaining).toBe(2);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("peek on a fresh bucket reports the full limit available", async () => {
|
|
158
|
+
const config = { limit: 7, windowSeconds: 60 };
|
|
159
|
+
const decision = await resolver.peek("peek:fresh", config);
|
|
160
|
+
expect(decision.allowed).toBe(true);
|
|
161
|
+
expect(decision.remaining).toBe(7);
|
|
162
|
+
expect(decision.limit).toBe(7);
|
|
163
|
+
// Fresh bucket → no need to wait for a refill.
|
|
164
|
+
expect(decision.retryAfterSeconds).toBe(0);
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
describe("createRateLimitResolver — enforce", () => {
|
|
169
|
+
test("enforce throws RateLimitError with the bucket details when blocked", async () => {
|
|
170
|
+
const config = { limit: 1, windowSeconds: 60 };
|
|
171
|
+
await resolver.enforce("enf:user", config);
|
|
172
|
+
|
|
173
|
+
let thrown: unknown;
|
|
174
|
+
try {
|
|
175
|
+
await resolver.enforce("enf:user", config);
|
|
176
|
+
} catch (e) {
|
|
177
|
+
thrown = e;
|
|
178
|
+
}
|
|
179
|
+
expect(thrown).toBeInstanceOf(RateLimitError);
|
|
180
|
+
const err = thrown as RateLimitError;
|
|
181
|
+
expect(err.httpStatus).toBe(429);
|
|
182
|
+
expect(err.code).toBe("rate_limited");
|
|
183
|
+
expect(err.details.bucket).toBe("enf:user");
|
|
184
|
+
expect(err.details.limit).toBe(1);
|
|
185
|
+
expect(err.details.windowSeconds).toBe(60);
|
|
186
|
+
expect(err.details.retryAfterSeconds).toBeGreaterThan(0);
|
|
187
|
+
expect(err.details.resetAt).toMatch(/^\d{4}-\d{2}-\d{2}T/);
|
|
188
|
+
});
|
|
189
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { RateLimitOption, SessionUser } from "../engine/types";
|
|
2
|
+
|
|
3
|
+
// Build the Redis bucket key for a handler-level rate limit. Format:
|
|
4
|
+
// <handler>:<dimension-tag>:<dimension-value>
|
|
5
|
+
// Dimension-tag keeps buckets disjoint when the same tenant/user shows up
|
|
6
|
+
// in multiple bucket strategies — `user+handler` and `user` for the same
|
|
7
|
+
// user are independent buckets.
|
|
8
|
+
|
|
9
|
+
export type BucketContext = {
|
|
10
|
+
readonly handlerName: string;
|
|
11
|
+
readonly user: SessionUser;
|
|
12
|
+
readonly ip: string | undefined;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type BucketResult =
|
|
16
|
+
| { readonly kind: "key"; readonly key: string }
|
|
17
|
+
| { readonly kind: "skip"; readonly reason: string };
|
|
18
|
+
|
|
19
|
+
export function buildBucketKey(option: RateLimitOption, ctx: BucketContext): BucketResult {
|
|
20
|
+
switch (option.per) {
|
|
21
|
+
case "user":
|
|
22
|
+
return { kind: "key", key: `user:${ctx.user.id}` };
|
|
23
|
+
case "tenant":
|
|
24
|
+
return { kind: "key", key: `tenant:${ctx.user.tenantId}` };
|
|
25
|
+
case "ip":
|
|
26
|
+
if (!ctx.ip) return { kind: "skip", reason: "no_ip" };
|
|
27
|
+
return { kind: "key", key: `ip:${ctx.ip}` };
|
|
28
|
+
case "user+handler":
|
|
29
|
+
return { kind: "key", key: `user+handler:${ctx.user.id}:${ctx.handlerName}` };
|
|
30
|
+
case "tenant+handler":
|
|
31
|
+
return { kind: "key", key: `tenant+handler:${ctx.user.tenantId}:${ctx.handlerName}` };
|
|
32
|
+
case "ip+handler":
|
|
33
|
+
if (!ctx.ip) return { kind: "skip", reason: "no_ip" };
|
|
34
|
+
return { kind: "key", key: `ip+handler:${ctx.ip}:${ctx.handlerName}` };
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export { type BucketContext, type BucketResult, buildBucketKey } from "./bucket";
|
|
2
|
+
export {
|
|
3
|
+
type AuthEndpointRateLimitOptions,
|
|
4
|
+
authEndpointRateLimit,
|
|
5
|
+
type GlobalIpRateLimitOptions,
|
|
6
|
+
globalIpRateLimit,
|
|
7
|
+
} from "./middleware";
|
|
8
|
+
export {
|
|
9
|
+
createRateLimitResolver,
|
|
10
|
+
type RateLimitConfig,
|
|
11
|
+
type RateLimitDecision,
|
|
12
|
+
type RateLimitResolver,
|
|
13
|
+
type RateLimitResolverOptions,
|
|
14
|
+
} from "./resolver";
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import type { Context, MiddlewareHandler } from "hono";
|
|
2
|
+
import { requestContext } from "../api/request-context";
|
|
3
|
+
import { RateLimitError, serializeError } from "../errors";
|
|
4
|
+
import type { RateLimitDecision, RateLimitResolver } from "./resolver";
|
|
5
|
+
|
|
6
|
+
// Hono middleware factories for L1 (Global-IP) and L2 (Auth-Endpoints).
|
|
7
|
+
//
|
|
8
|
+
// Both share the same response shape on 429 — RFC 6585 status, the
|
|
9
|
+
// X-RateLimit-* headers IETF draft uses, and a structured JSON body
|
|
10
|
+
// produced by the central serializeError() so L1/L2/L3 share the
|
|
11
|
+
// `error.code`, `i18nKey`, `details`, `requestId`, `timestamp` envelope.
|
|
12
|
+
//
|
|
13
|
+
// Fail-mode policy (from docs/plans/features/core-rate-limiting.md):
|
|
14
|
+
// L1/L2 — **fail-closed** when Redis is down. The caller is most
|
|
15
|
+
// likely an attacker; refusing service is safer than letting
|
|
16
|
+
// an unbounded flood through.
|
|
17
|
+
// L3 — fail-open (handled in dispatcher path). App availability
|
|
18
|
+
// wins for known heavy handlers when Redis blips.
|
|
19
|
+
|
|
20
|
+
export type GlobalIpRateLimitOptions = {
|
|
21
|
+
readonly resolver: RateLimitResolver;
|
|
22
|
+
readonly limit?: number;
|
|
23
|
+
readonly windowSeconds?: number;
|
|
24
|
+
// Override IP extraction — useful when behind a non-standard proxy.
|
|
25
|
+
// Default: x-forwarded-for first hop.
|
|
26
|
+
readonly extractIp?: (c: Context) => string | undefined;
|
|
27
|
+
// Hook for ops logging when fail-closed fires (Redis down). Default:
|
|
28
|
+
// emits to console.error so the misbehaviour is loud at minimum.
|
|
29
|
+
readonly onFailClosed?: (err: unknown) => void;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export function globalIpRateLimit(opts: GlobalIpRateLimitOptions): MiddlewareHandler {
|
|
33
|
+
const limit = opts.limit ?? 1000;
|
|
34
|
+
const windowSeconds = opts.windowSeconds ?? 60;
|
|
35
|
+
const extractIp = opts.extractIp ?? defaultExtractIp;
|
|
36
|
+
const onFailClosed = opts.onFailClosed ?? defaultOnFailClosed("l1-global-ip");
|
|
37
|
+
|
|
38
|
+
return async (c, next) => {
|
|
39
|
+
const ip = extractIp(c);
|
|
40
|
+
if (!ip) {
|
|
41
|
+
// No IP and no override → can't bucket. Pass-through; deployments
|
|
42
|
+
// that care about that hardening should pin extractIp explicitly.
|
|
43
|
+
return next();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
const decision = await opts.resolver.check(`l1:${ip}`, { limit, windowSeconds });
|
|
48
|
+
if (!decision.allowed) {
|
|
49
|
+
return respondRateLimited(c, decision, `l1:${ip}`);
|
|
50
|
+
}
|
|
51
|
+
setRateLimitHeaders(c, decision);
|
|
52
|
+
} catch (e) {
|
|
53
|
+
// Fail-closed: refuse rather than let a flood through with no cap.
|
|
54
|
+
// resolver.check never throws RateLimitError (only enforce does),
|
|
55
|
+
// so any throw here is an infrastructure failure (Redis down).
|
|
56
|
+
onFailClosed(e);
|
|
57
|
+
return c.json(
|
|
58
|
+
{ error: { code: "rate_limit_unavailable", message: "Rate limiter unavailable" } },
|
|
59
|
+
503,
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
await next();
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export type AuthEndpointRateLimitOptions = {
|
|
67
|
+
readonly resolver: RateLimitResolver;
|
|
68
|
+
readonly limit?: number;
|
|
69
|
+
readonly windowSeconds?: number;
|
|
70
|
+
// Optional target extractor for account-aware bucketing. When set,
|
|
71
|
+
// the bucket key is `l2:${ip}:${target}` — adds account isolation on
|
|
72
|
+
// top of IP. Default: bucket on `l2:${ip}:${path}` (IP + route),
|
|
73
|
+
// which catches naive IP-flood without consuming the request body.
|
|
74
|
+
readonly extractTarget?: (c: Context) => string | undefined | Promise<string | undefined>;
|
|
75
|
+
readonly extractIp?: (c: Context) => string | undefined;
|
|
76
|
+
readonly onFailClosed?: (err: unknown) => void;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
export function authEndpointRateLimit(opts: AuthEndpointRateLimitOptions): MiddlewareHandler {
|
|
80
|
+
const limit = opts.limit ?? 5;
|
|
81
|
+
const windowSeconds = opts.windowSeconds ?? 60;
|
|
82
|
+
const extractIp = opts.extractIp ?? defaultExtractIp;
|
|
83
|
+
const extractTarget = opts.extractTarget;
|
|
84
|
+
const onFailClosed = opts.onFailClosed ?? defaultOnFailClosed("l2-auth-endpoints");
|
|
85
|
+
|
|
86
|
+
return async (c, next) => {
|
|
87
|
+
const ip = extractIp(c);
|
|
88
|
+
if (!ip) return next();
|
|
89
|
+
|
|
90
|
+
const target = (await extractTarget?.(c)) ?? c.req.path;
|
|
91
|
+
const bucket = `l2:${ip}:${target}`;
|
|
92
|
+
|
|
93
|
+
try {
|
|
94
|
+
const decision = await opts.resolver.check(bucket, { limit, windowSeconds });
|
|
95
|
+
if (!decision.allowed) {
|
|
96
|
+
return respondRateLimited(c, decision, bucket);
|
|
97
|
+
}
|
|
98
|
+
setRateLimitHeaders(c, decision);
|
|
99
|
+
} catch (e) {
|
|
100
|
+
onFailClosed(e);
|
|
101
|
+
return c.json(
|
|
102
|
+
{ error: { code: "rate_limit_unavailable", message: "Rate limiter unavailable" } },
|
|
103
|
+
503,
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
await next();
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function defaultExtractIp(c: Context): string | undefined {
|
|
111
|
+
const xff = c.req.header("x-forwarded-for");
|
|
112
|
+
return xff?.split(",")[0]?.trim() || undefined;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function defaultOnFailClosed(label: string): (err: unknown) => void {
|
|
116
|
+
return (err) => {
|
|
117
|
+
// Loud by default — fail-closed for an unknown reason is an ops
|
|
118
|
+
// signal, not something to swallow silently. Production deploys
|
|
119
|
+
// override this with a structured logger; the default keeps the
|
|
120
|
+
// noise visible if no logger is wired.
|
|
121
|
+
// biome-ignore lint/suspicious/noConsole: ops-visible fallback when no logger is wired
|
|
122
|
+
console.error(`[rate-limit ${label}] fail-closed (refusing request):`, err);
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function setRateLimitHeaders(c: Context, decision: RateLimitDecision): void {
|
|
127
|
+
c.header("X-RateLimit-Limit", String(decision.limit));
|
|
128
|
+
c.header("X-RateLimit-Remaining", String(decision.remaining));
|
|
129
|
+
// Unix-epoch seconds — matches the de-facto industry standard (GitHub,
|
|
130
|
+
// Twitter, AWS) and stays consistent with Retry-After, which is also
|
|
131
|
+
// seconds. Previously this emitted an ISO-Instant string; proxies and
|
|
132
|
+
// client libs that parse as integer would silently see NaN.
|
|
133
|
+
c.header("X-RateLimit-Reset", String(Math.floor(decision.resetAt.epochMilliseconds / 1000)));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function respondRateLimited(c: Context, decision: RateLimitDecision, bucket: string): Response {
|
|
137
|
+
// Build a RateLimitError so the wire shape is identical to the L3
|
|
138
|
+
// dispatcher path. serializeError adds i18nKey, requestId, timestamp —
|
|
139
|
+
// fields a hand-rolled body would silently miss.
|
|
140
|
+
const err = new RateLimitError({
|
|
141
|
+
bucket,
|
|
142
|
+
limit: decision.limit,
|
|
143
|
+
windowSeconds: decision.windowSeconds,
|
|
144
|
+
remaining: decision.remaining,
|
|
145
|
+
retryAfterSeconds: decision.retryAfterSeconds,
|
|
146
|
+
resetAt: decision.resetAt.toString(),
|
|
147
|
+
});
|
|
148
|
+
c.header("Retry-After", String(Math.max(1, decision.retryAfterSeconds)));
|
|
149
|
+
setRateLimitHeaders(c, decision);
|
|
150
|
+
const reqId = requestContext.get()?.requestId;
|
|
151
|
+
return c.json(serializeError(err, reqId), 429);
|
|
152
|
+
}
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import type Redis from "ioredis";
|
|
2
|
+
import { RateLimitError } from "../errors";
|
|
3
|
+
import { RedisKeys } from "../pipeline/redis-keys";
|
|
4
|
+
|
|
5
|
+
// Token-Bucket rate limiter, atomic via Redis Lua. One round-trip per
|
|
6
|
+
// check — the script computes the bucket state inline and either deducts
|
|
7
|
+
// or rejects.
|
|
8
|
+
//
|
|
9
|
+
// Why Token Bucket: bursts are allowed up to `limit`, refill is steady
|
|
10
|
+
// at `limit / windowSeconds` per second. Sliding window would be more
|
|
11
|
+
// fair but needs more Redis ops; fixed window is simpler but gives 2x
|
|
12
|
+
// burst at boundaries. Token Bucket sits in the right trade-off zone.
|
|
13
|
+
//
|
|
14
|
+
// Storage layout per bucket:
|
|
15
|
+
// <key> hash with two fields:
|
|
16
|
+
// tokens — float, current tokens in the bucket
|
|
17
|
+
// ts — last refill timestamp (ms since epoch)
|
|
18
|
+
// TTL = 2 × windowSeconds (long enough to not lose state across refill,
|
|
19
|
+
// short enough to clean up dead buckets)
|
|
20
|
+
//
|
|
21
|
+
// The Lua script does: load → refill based on elapsed time → check cost
|
|
22
|
+
// → deduct or reject. Returns [allowed, remaining, resetAfterMs].
|
|
23
|
+
|
|
24
|
+
// KEYS[1] — bucket key
|
|
25
|
+
// ARGV[1] — limit (max tokens)
|
|
26
|
+
// ARGV[2] — refillRatePerMs (limit / (windowSeconds * 1000))
|
|
27
|
+
// ARGV[3] — cost (tokens to deduct, default 1)
|
|
28
|
+
// ARGV[4] — nowMs (server time, passed in for testability + drift safety)
|
|
29
|
+
// ARGV[5] — ttlSeconds (key TTL)
|
|
30
|
+
//
|
|
31
|
+
// Returns: { allowed (1|0), remainingTokens (int floor), retryAfterMs (int) }
|
|
32
|
+
//
|
|
33
|
+
// The peek script (TOKEN_BUCKET_PEEK_LUA) below is the same logic minus
|
|
34
|
+
// the HMSET — used by ops queries to inspect a bucket without nudging
|
|
35
|
+
// the refill timestamp. Two scripts so neither has to grow a "writeback"
|
|
36
|
+
// flag and a state-machine.
|
|
37
|
+
const TOKEN_BUCKET_LUA = `
|
|
38
|
+
local key = KEYS[1]
|
|
39
|
+
local limit = tonumber(ARGV[1])
|
|
40
|
+
local refillRatePerMs = tonumber(ARGV[2])
|
|
41
|
+
local cost = tonumber(ARGV[3])
|
|
42
|
+
local nowMs = tonumber(ARGV[4])
|
|
43
|
+
local ttl = tonumber(ARGV[5])
|
|
44
|
+
|
|
45
|
+
local data = redis.call('HMGET', key, 'tokens', 'ts')
|
|
46
|
+
local tokens = tonumber(data[1])
|
|
47
|
+
local ts = tonumber(data[2])
|
|
48
|
+
|
|
49
|
+
if tokens == nil then
|
|
50
|
+
tokens = limit
|
|
51
|
+
ts = nowMs
|
|
52
|
+
else
|
|
53
|
+
local elapsed = math.max(0, nowMs - ts)
|
|
54
|
+
tokens = math.min(limit, tokens + elapsed * refillRatePerMs)
|
|
55
|
+
ts = nowMs
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
local allowed = 0
|
|
59
|
+
local retryAfterMs = 0
|
|
60
|
+
if tokens >= cost then
|
|
61
|
+
tokens = tokens - cost
|
|
62
|
+
allowed = 1
|
|
63
|
+
else
|
|
64
|
+
-- Time until enough tokens accumulate to satisfy this request.
|
|
65
|
+
local deficit = cost - tokens
|
|
66
|
+
retryAfterMs = math.ceil(deficit / refillRatePerMs)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
redis.call('HMSET', key, 'tokens', tokens, 'ts', ts)
|
|
70
|
+
redis.call('EXPIRE', key, ttl)
|
|
71
|
+
|
|
72
|
+
return { allowed, math.floor(tokens), retryAfterMs }
|
|
73
|
+
`;
|
|
74
|
+
|
|
75
|
+
// Read-only peek. Same refill maths, but writes nothing back — the
|
|
76
|
+
// bucket state at peek time stays identical to the state the next real
|
|
77
|
+
// check() would see. Used by ops/status queries that must not deduct
|
|
78
|
+
// tokens or shift the refill timestamp.
|
|
79
|
+
//
|
|
80
|
+
// KEYS[1] — bucket key
|
|
81
|
+
// ARGV[1] — limit
|
|
82
|
+
// ARGV[2] — refillRatePerMs
|
|
83
|
+
// ARGV[3] — nowMs
|
|
84
|
+
//
|
|
85
|
+
// Returns: { remainingTokens (int floor), retryAfterMs (int — until 1 token available) }
|
|
86
|
+
const TOKEN_BUCKET_PEEK_LUA = `
|
|
87
|
+
local key = KEYS[1]
|
|
88
|
+
local limit = tonumber(ARGV[1])
|
|
89
|
+
local refillRatePerMs = tonumber(ARGV[2])
|
|
90
|
+
local nowMs = tonumber(ARGV[3])
|
|
91
|
+
|
|
92
|
+
local data = redis.call('HMGET', key, 'tokens', 'ts')
|
|
93
|
+
local tokens = tonumber(data[1])
|
|
94
|
+
local ts = tonumber(data[2])
|
|
95
|
+
|
|
96
|
+
if tokens == nil then
|
|
97
|
+
tokens = limit
|
|
98
|
+
else
|
|
99
|
+
local elapsed = math.max(0, nowMs - ts)
|
|
100
|
+
tokens = math.min(limit, tokens + elapsed * refillRatePerMs)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
local retryAfterMs = 0
|
|
104
|
+
if tokens < 1 then
|
|
105
|
+
retryAfterMs = math.ceil((1 - tokens) / refillRatePerMs)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
return { math.floor(tokens), retryAfterMs }
|
|
109
|
+
`;
|
|
110
|
+
|
|
111
|
+
export type RateLimitDecision = {
|
|
112
|
+
readonly allowed: boolean;
|
|
113
|
+
readonly limit: number;
|
|
114
|
+
readonly remaining: number;
|
|
115
|
+
readonly retryAfterSeconds: number;
|
|
116
|
+
readonly windowSeconds: number;
|
|
117
|
+
readonly resetAt: Temporal.Instant;
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
export type RateLimitConfig = {
|
|
121
|
+
readonly limit: number;
|
|
122
|
+
readonly windowSeconds: number;
|
|
123
|
+
readonly cost?: number;
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
export type RateLimitResolver = {
|
|
127
|
+
// Atomic check + deduct. Returns the decision and current bucket state
|
|
128
|
+
// — caller decides whether to throw RateLimitError or proceed.
|
|
129
|
+
check(bucket: string, config: RateLimitConfig): Promise<RateLimitDecision>;
|
|
130
|
+
|
|
131
|
+
// Convenience: throws RateLimitError when blocked. Useful inside the
|
|
132
|
+
// dispatcher / middleware code-paths where the failure shape is fixed.
|
|
133
|
+
enforce(bucket: string, config: RateLimitConfig): Promise<RateLimitDecision>;
|
|
134
|
+
|
|
135
|
+
// Read-only inspection: returns the same shape as check() but never
|
|
136
|
+
// mutates the bucket — no token deduction, no refill-timestamp update.
|
|
137
|
+
// Use for ops/status queries (e.g. "kumiko rl status user:42") that
|
|
138
|
+
// must observe the bucket without disturbing it.
|
|
139
|
+
peek(bucket: string, config: Omit<RateLimitConfig, "cost">): Promise<RateLimitDecision>;
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
export type RateLimitResolverOptions = {
|
|
143
|
+
readonly redis: Redis;
|
|
144
|
+
// Override the prefix for tests. Production uses RedisKeys.rateLimit.
|
|
145
|
+
readonly keyPrefix?: string;
|
|
146
|
+
// Override the time source for tests. Production uses Date.now().
|
|
147
|
+
readonly nowMs?: () => number;
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
type LuaResult = readonly [number, number, number];
|
|
151
|
+
|
|
152
|
+
// We register the script once per Redis client via defineCommand so each
|
|
153
|
+
// check is a single round-trip with the script already cached on the
|
|
154
|
+
// server (ioredis falls back to LOADing the script if the SHA is
|
|
155
|
+
// missing). Using defineCommand also keeps the call-site idiomatic
|
|
156
|
+
// (`redis.kumikoRateLimit(...)`) instead of building raw protocol calls.
|
|
157
|
+
type CommandClient = Redis & {
|
|
158
|
+
kumikoRateLimit(
|
|
159
|
+
key: string,
|
|
160
|
+
limit: string,
|
|
161
|
+
refillRatePerMs: string,
|
|
162
|
+
cost: string,
|
|
163
|
+
nowMs: string,
|
|
164
|
+
ttlSeconds: string,
|
|
165
|
+
): Promise<LuaResult>;
|
|
166
|
+
kumikoRateLimitPeek(
|
|
167
|
+
key: string,
|
|
168
|
+
limit: string,
|
|
169
|
+
refillRatePerMs: string,
|
|
170
|
+
nowMs: string,
|
|
171
|
+
): Promise<readonly [number, number]>;
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
const REGISTERED = new WeakSet<Redis>();
|
|
175
|
+
|
|
176
|
+
function ensureCommand(redis: Redis): CommandClient {
|
|
177
|
+
if (!REGISTERED.has(redis)) {
|
|
178
|
+
redis.defineCommand("kumikoRateLimit", {
|
|
179
|
+
numberOfKeys: 1,
|
|
180
|
+
lua: TOKEN_BUCKET_LUA,
|
|
181
|
+
});
|
|
182
|
+
redis.defineCommand("kumikoRateLimitPeek", {
|
|
183
|
+
numberOfKeys: 1,
|
|
184
|
+
lua: TOKEN_BUCKET_PEEK_LUA,
|
|
185
|
+
});
|
|
186
|
+
REGISTERED.add(redis);
|
|
187
|
+
}
|
|
188
|
+
return redis as CommandClient;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export function createRateLimitResolver(opts: RateLimitResolverOptions): RateLimitResolver {
|
|
192
|
+
const client = ensureCommand(opts.redis);
|
|
193
|
+
const prefix = opts.keyPrefix ?? RedisKeys.rateLimit;
|
|
194
|
+
const now = opts.nowMs ?? (() => Date.now());
|
|
195
|
+
|
|
196
|
+
async function check(bucket: string, config: RateLimitConfig): Promise<RateLimitDecision> {
|
|
197
|
+
const cost = config.cost ?? 1;
|
|
198
|
+
const refillRatePerMs = config.limit / (config.windowSeconds * 1000);
|
|
199
|
+
const ttlSeconds = Math.max(1, config.windowSeconds * 2);
|
|
200
|
+
const nowMs = now();
|
|
201
|
+
|
|
202
|
+
const [allowedFlag, remaining, retryAfterMs] = await client.kumikoRateLimit(
|
|
203
|
+
`${prefix}${bucket}`,
|
|
204
|
+
String(config.limit),
|
|
205
|
+
String(refillRatePerMs),
|
|
206
|
+
String(cost),
|
|
207
|
+
String(nowMs),
|
|
208
|
+
String(ttlSeconds),
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
const retryAfterSeconds = Math.ceil(retryAfterMs / 1000);
|
|
212
|
+
const resetAt = Temporal.Instant.fromEpochMilliseconds(nowMs + retryAfterMs);
|
|
213
|
+
|
|
214
|
+
return {
|
|
215
|
+
allowed: allowedFlag === 1,
|
|
216
|
+
limit: config.limit,
|
|
217
|
+
remaining,
|
|
218
|
+
retryAfterSeconds,
|
|
219
|
+
windowSeconds: config.windowSeconds,
|
|
220
|
+
resetAt,
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async function enforce(bucket: string, config: RateLimitConfig): Promise<RateLimitDecision> {
|
|
225
|
+
const decision = await check(bucket, config);
|
|
226
|
+
if (decision.allowed) return decision;
|
|
227
|
+
throw new RateLimitError({
|
|
228
|
+
bucket,
|
|
229
|
+
limit: decision.limit,
|
|
230
|
+
windowSeconds: decision.windowSeconds,
|
|
231
|
+
remaining: decision.remaining,
|
|
232
|
+
retryAfterSeconds: decision.retryAfterSeconds,
|
|
233
|
+
resetAt: decision.resetAt.toString(),
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async function peek(
|
|
238
|
+
bucket: string,
|
|
239
|
+
config: Omit<RateLimitConfig, "cost">,
|
|
240
|
+
): Promise<RateLimitDecision> {
|
|
241
|
+
const refillRatePerMs = config.limit / (config.windowSeconds * 1000);
|
|
242
|
+
const nowMs = now();
|
|
243
|
+
|
|
244
|
+
const [remaining, retryAfterMs] = await client.kumikoRateLimitPeek(
|
|
245
|
+
`${prefix}${bucket}`,
|
|
246
|
+
String(config.limit),
|
|
247
|
+
String(refillRatePerMs),
|
|
248
|
+
String(nowMs),
|
|
249
|
+
);
|
|
250
|
+
|
|
251
|
+
const retryAfterSeconds = Math.ceil(retryAfterMs / 1000);
|
|
252
|
+
const resetAt = Temporal.Instant.fromEpochMilliseconds(nowMs + retryAfterMs);
|
|
253
|
+
|
|
254
|
+
return {
|
|
255
|
+
// peek doesn't deduct, so a "would-be" allowed flag is meaningful:
|
|
256
|
+
// true iff at least one token is available right now.
|
|
257
|
+
allowed: remaining >= 1,
|
|
258
|
+
limit: config.limit,
|
|
259
|
+
remaining,
|
|
260
|
+
retryAfterSeconds,
|
|
261
|
+
windowSeconds: config.windowSeconds,
|
|
262
|
+
resetAt,
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
return { check, enforce, peek };
|
|
267
|
+
}
|