diff --git a/.changeset/five-hounds-know.md b/.changeset/five-hounds-know.md new file mode 100644 index 00000000000..4a9c5777095 --- /dev/null +++ b/.changeset/five-hounds-know.md @@ -0,0 +1,30 @@ +--- +'hive': minor +--- + +Added opt-in AWS IAM authentication for ElastiCache Redis connections and Redis Cluster mode +support. When IAM is enabled, services authenticate to Redis using short-lived SigV4 pre-signed +tokens instead of static passwords, with automatic token refresh before expiry. + +### New environment variables + +| Variable | Service | Description | +| ---------------------------- | ---------------------------------------- | ----------------------------------------------------------------------------- | +| `AWS_REGION` | schema, server, tokens, usage, workflows | Default AWS region for all AWS connections. | +| `REDIS_AWS_IAM_AUTH_ENABLED` | schema, server, tokens, usage, workflows | Set to `1` to enable IAM authentication for Redis. | +| `REDIS_AWS_IAM_CACHE_NAME` | schema, server, tokens, usage, workflows | The ElastiCache Redis cache instance name. Used as the host for the signer. | +| `REDIS_AWS_REGION` | schema, server, tokens, usage, workflows | Optional override for the Redis region (defaults to `AWS_REGION`). | +| `REDIS_CLUSTER_MODE_ENABLED` | schema, server, tokens, usage, workflows | Set to `1` to connect using Redis Cluster mode. | +| `REDIS_USERNAME` | schema, server, tokens, usage, workflows | Optional Redis username for ACL-based authentication (defaults to `default`). | + +### To enable + +- `REDIS_AWS_IAM_AUTH_ENABLED=1` +- `REDIS_TLS_ENABLED=1` must be set (IAM authentication requires TLS). +- `REDIS_AWS_REGION` or `AWS_REGION` must be set. +- `REDIS_AWS_IAM_CACHE_NAME` set to the name of the cache instance in AWS. This will be used as the hostname for the signer. +- The pod/instance must have AWS credentials available (e.g. IRSA, EKS Pod Identity, instance + profile) with the appropriate ElastiCache IAM permissions. + +### Other changes +- Bumping ioredis to `5.10.1`. diff --git a/integration-tests/package.json b/integration-tests/package.json index cec26f8064b..09f1fd57b3a 100644 --- a/integration-tests/package.json +++ b/integration-tests/package.json @@ -42,7 +42,6 @@ "graphql-sse": "2.6.0", "graphql-yoga": "5.13.3", "human-id": "4.1.1", - "ioredis": "5.8.2", "set-cookie-parser": "2.7.1", "strip-ansi": "7.1.2", "tslib": "2.8.1", diff --git a/integration-tests/testkit/seed.ts b/integration-tests/testkit/seed.ts index 393bfedcfde..09dec895978 100644 --- a/integration-tests/testkit/seed.ts +++ b/integration-tests/testkit/seed.ts @@ -1,9 +1,8 @@ import { formatISO, subHours } from 'date-fns'; import { humanId } from 'human-id'; import z from 'zod'; -import { NoopLogger } from '@hive/api/modules/shared/providers/logger'; -import { createRedisClient } from '@hive/api/modules/shared/providers/redis'; import { createPostgresDatabasePool, psql } from '@hive/postgres'; +import { createRedisClient, type ServiceLogger } from '@hive/service-common'; import type { Report } from '../../packages/libraries/core/src/client/usage.js'; import { authenticate, userEmail } from './auth'; import { @@ -235,15 +234,28 @@ export function initSeed() { value: 'a894723a5d52a30d73790752b0169835e6f81dd77d2737dba809bee7fde39092', }; - const redis = createRedisClient( - '', + const noop = () => {}; + const noopLogger = { + info: noop, + warn: noop, + error: noop, + debug: noop, + child: () => noopLogger, + } as unknown as ServiceLogger; + + const redis = await createRedisClient( { host: ensureEnv('REDIS_HOST'), password: ensureEnv('REDIS_PASSWORD'), port: parseInt(ensureEnv('REDIS_PORT'), 10), + username: undefined, tlsEnabled: false, + clusterModeEnabled: false, + awsIamAuthEnabled: false, + awsRegion: undefined, + awsIamAuthCacheName: undefined, }, - new NoopLogger(), + { logger: noopLogger }, ); await redis.set(key, JSON.stringify(challenge)); diff --git a/packages/libraries/pubsub/package.json b/packages/libraries/pubsub/package.json index 5be94169dd4..61647b91a1d 100644 --- a/packages/libraries/pubsub/package.json +++ b/packages/libraries/pubsub/package.json @@ -44,7 +44,7 @@ "typecheck": "tsc --noEmit" }, "peerDependencies": { - "ioredis": "^5.0.0" + "ioredis": "5.10.1" }, "dependencies": { "@graphql-yoga/redis-event-target": "3.0.3", diff --git a/packages/libraries/pubsub/src/index.ts b/packages/libraries/pubsub/src/index.ts index f6c7a6b06b6..ff05348c75b 100644 --- a/packages/libraries/pubsub/src/index.ts +++ b/packages/libraries/pubsub/src/index.ts @@ -1,5 +1,5 @@ import { createPubSub } from 'graphql-yoga'; -import { Redis } from 'ioredis'; +import type { Redis } from 'ioredis'; import { createRedisEventTarget } from '@graphql-yoga/redis-event-target'; import { bridgeGraphileLogger, type Logger } from './logger.js'; import type { HivePubSub } from './pub-sub.js'; diff --git a/packages/services/api/package.json b/packages/services/api/package.json index 642ce39b87e..6e1cf843776 100644 --- a/packages/services/api/package.json +++ b/packages/services/api/package.json @@ -66,7 +66,6 @@ "graphql-parse-resolve-info": "4.13.0", "graphql-scalars": "1.24.2", "graphql-yoga": "5.13.3", - "ioredis": "5.8.2", "ioredis-mock": "8.9.0", "jsonwebtoken": "9.0.3", "lodash": "4.18.1", diff --git a/packages/services/api/src/create.ts b/packages/services/api/src/create.ts index d5a812aac7d..8546fa9c023 100644 --- a/packages/services/api/src/create.ts +++ b/packages/services/api/src/create.ts @@ -1,5 +1,4 @@ import { CONTEXT, createApplication, Provider, Scope } from 'graphql-modules'; -import { Redis } from 'ioredis'; import { PostgresDatabasePool } from '@hive/postgres'; import { TaskScheduler } from '@hive/workflows/kit'; import { adminModule } from './modules/admin'; @@ -62,7 +61,7 @@ import { Logger } from './modules/shared/providers/logger'; import { Mutex } from './modules/shared/providers/mutex'; import { PrometheusConfig } from './modules/shared/providers/prometheus-config'; import { HivePubSub, PUB_SUB_CONFIG } from './modules/shared/providers/pub-sub'; -import { REDIS_INSTANCE } from './modules/shared/providers/redis'; +import { REDIS_INSTANCE, type Redis } from './modules/shared/providers/redis'; import { RedisRateLimiter } from './modules/shared/providers/redis-rate-limiter'; import { S3_CONFIG, type S3Config } from './modules/shared/providers/s3-config'; import { Storage } from './modules/shared/providers/storage'; diff --git a/packages/services/api/src/modules/auth/providers/oauth-cache.ts b/packages/services/api/src/modules/auth/providers/oauth-cache.ts index e1bb2aa0c92..f9f4e63603b 100644 --- a/packages/services/api/src/modules/auth/providers/oauth-cache.ts +++ b/packages/services/api/src/modules/auth/providers/oauth-cache.ts @@ -1,8 +1,7 @@ import { Inject, Injectable, Scope } from 'graphql-modules'; -import { type Redis } from 'ioredis'; import z from 'zod'; import { Logger } from '../../shared/providers/logger'; -import { REDIS_INSTANCE } from '../../shared/providers/redis'; +import { REDIS_INSTANCE, type Redis } from '../../shared/providers/redis'; import { sha256 } from '../lib/supertokens-at-home/crypto'; @Injectable({ diff --git a/packages/services/api/src/modules/organization/providers/organization-access-tokens-cache.ts b/packages/services/api/src/modules/organization/providers/organization-access-tokens-cache.ts index ecb2f27b312..e7de541e0ed 100644 --- a/packages/services/api/src/modules/organization/providers/organization-access-tokens-cache.ts +++ b/packages/services/api/src/modules/organization/providers/organization-access-tokens-cache.ts @@ -2,13 +2,12 @@ import { BentoCache, bentostore } from 'bentocache'; import { memoryDriver } from 'bentocache/build/src/drivers/memory'; import { redisDriver } from 'bentocache/build/src/drivers/redis'; import { Inject, Injectable, Scope } from 'graphql-modules'; -import type Redis from 'ioredis'; import { prometheusPlugin } from '@bentocache/plugin-prometheus'; import { PostgresDatabasePool } from '@hive/postgres'; import { AuthorizationPolicyStatement } from '../../auth/lib/authz'; import { Logger } from '../../shared/providers/logger'; import { PrometheusConfig } from '../../shared/providers/prometheus-config'; -import { REDIS_INSTANCE } from '../../shared/providers/redis'; +import { REDIS_INSTANCE, type Redis } from '../../shared/providers/redis'; import { findById, OrganizationAccessTokens, diff --git a/packages/services/api/src/modules/shared/providers/redis-rate-limiter.ts b/packages/services/api/src/modules/shared/providers/redis-rate-limiter.ts index 3de94030e20..b5146f1e895 100644 --- a/packages/services/api/src/modules/shared/providers/redis-rate-limiter.ts +++ b/packages/services/api/src/modules/shared/providers/redis-rate-limiter.ts @@ -1,10 +1,9 @@ import { timingSafeEqual } from 'node:crypto'; import { Inject, Injectable, Scope } from 'graphql-modules'; -import { type Redis } from 'ioredis'; import { type FastifyRequest } from '@hive/service-common'; import { sha256 } from '../../auth/lib/supertokens-at-home/crypto'; import { Logger } from './logger'; -import { REDIS_INSTANCE } from './redis'; +import { REDIS_INSTANCE, type Redis } from './redis'; import { RateLimitConfig } from './tokens'; @Injectable({ diff --git a/packages/services/api/src/modules/shared/providers/redis.ts b/packages/services/api/src/modules/shared/providers/redis.ts index 2604fd49d20..bf02d0e64d0 100644 --- a/packages/services/api/src/modules/shared/providers/redis.ts +++ b/packages/services/api/src/modules/shared/providers/redis.ts @@ -1,53 +1,13 @@ import { InjectionToken } from 'graphql-modules'; -import type { Redis as RedisInstance, RedisOptions } from 'ioredis'; -import Redis from 'ioredis'; -import { Logger } from './logger'; - -export type { RedisInstance as Redis }; - -export type RedisConfig = Required> & { - tlsEnabled: boolean; -}; - -export const REDIS_INSTANCE = new InjectionToken('REDIS_INSTANCE'); - -export function createRedisClient(label: string, config: RedisConfig, logger: Logger) { - const redis = new Redis({ - host: config.host, - port: config.port, - password: config.password, - retryStrategy(times) { - return Math.min(times * 500, 2000); - }, - reconnectOnError(error) { - logger.warn('Redis reconnectOnError (error=%s)', error); - return 1; - }, - db: 0, - maxRetriesPerRequest: null, - enableReadyCheck: false, - tls: config.tlsEnabled ? {} : undefined, - }); - - redis.on('error', err => { - logger.error('Redis connection error (error=%s,label=%s)', err, label); - }); - - redis.on('connect', () => { - logger.debug('Redis connection established (label=%s)', label); - }); - - redis.on('ready', () => { - logger.info('Redis connection ready (label=%s)', label); - }); - - redis.on('close', () => { - logger.info('Redis connection closed (label=%s)', label); - }); - - redis.on('reconnecting', (timeToReconnect?: number) => { - logger.info('Redis reconnecting in %s (label=%s)', timeToReconnect, label); - }); - - return redis; -} +import type { Redis, RedisConnectionConfig } from '@hive/service-common'; + +export type { Redis }; +export { createRedisClient } from '@hive/service-common'; +export type { RedisConnectionConfig as RedisConfig }; + +/** + * Dependency Injection token for injecting the Redis client into graphql-modules providers. + * This lives here rather than in @hive/service-common because it depends on + * graphql-modules, which is specific to the API service. + */ +export const REDIS_INSTANCE = new InjectionToken('REDIS_INSTANCE'); diff --git a/packages/services/api/src/modules/target/providers/targets-by-id-cache.ts b/packages/services/api/src/modules/target/providers/targets-by-id-cache.ts index 9d9a6b271ad..8e17b96ea42 100644 --- a/packages/services/api/src/modules/target/providers/targets-by-id-cache.ts +++ b/packages/services/api/src/modules/target/providers/targets-by-id-cache.ts @@ -2,14 +2,13 @@ import { BentoCache, bentostore } from 'bentocache'; import { memoryDriver } from 'bentocache/build/src/drivers/memory'; import { redisDriver } from 'bentocache/build/src/drivers/redis'; import { Inject, Injectable, Scope } from 'graphql-modules'; -import type Redis from 'ioredis'; import { prometheusPlugin } from '@bentocache/plugin-prometheus'; import { PostgresDatabasePool } from '@hive/postgres'; import { findTargetById } from '@hive/storage'; import type { Target } from '../../../shared/entities'; import { isUUID } from '../../../shared/is-uuid'; import { PrometheusConfig } from '../../shared/providers/prometheus-config'; -import { REDIS_INSTANCE } from '../../shared/providers/redis'; +import { REDIS_INSTANCE, type Redis } from '../../shared/providers/redis'; /** * Cache for performant Target lookups. diff --git a/packages/services/api/src/modules/target/providers/targets-by-slug-cache.ts b/packages/services/api/src/modules/target/providers/targets-by-slug-cache.ts index 757816fa95d..b57788f8d5e 100644 --- a/packages/services/api/src/modules/target/providers/targets-by-slug-cache.ts +++ b/packages/services/api/src/modules/target/providers/targets-by-slug-cache.ts @@ -2,12 +2,11 @@ import { BentoCache, bentostore } from 'bentocache'; import { memoryDriver } from 'bentocache/build/src/drivers/memory'; import { redisDriver } from 'bentocache/build/src/drivers/redis'; import { Inject, Injectable, Scope } from 'graphql-modules'; -import type Redis from 'ioredis'; import { prometheusPlugin } from '@bentocache/plugin-prometheus'; import { PostgresDatabasePool } from '@hive/postgres'; import { findTargetBySlug } from '@hive/storage'; import { PrometheusConfig } from '../../shared/providers/prometheus-config'; -import { REDIS_INSTANCE } from '../../shared/providers/redis'; +import { REDIS_INSTANCE, type Redis } from '../../shared/providers/redis'; /** * Cache for performant Target lookups. diff --git a/packages/services/schema/README.md b/packages/services/schema/README.md index 7bce8e3fafa..3a5ed6c0682 100644 --- a/packages/services/schema/README.md +++ b/packages/services/schema/README.md @@ -10,8 +10,14 @@ of subschemas. Supports Federation, Schema Stitching and Monolithic Schemas. | `PORT` | **Yes** | The port on which this service runs. | `6250` | | `REDIS_HOST` | **Yes** | The host of your redis instance. | `"127.0.0.1"` | | `REDIS_PORT` | **Yes** | The port of your redis instance. | `6379` | -| `REDIS_PASSWORD` | **Yes** | The password of your redis instance. | `"apollorocks"` | +| `REDIS_PASSWORD` | No | The password of your redis instance. | `"apollorocks"` | | `REDIS_TLS_ENABLED` | **No** | Enable TLS for redis connection (rediss://). | `"0"` | +| `REDIS_USERNAME` | No | The username for Redis Access Control List authentication. | `"default"` | +| `REDIS_CLUSTER_MODE_ENABLED` | No | Enable Redis Cluster mode. | `1` (enabled) or `0` (disabled) | +| `AWS_REGION` | No | The global AWS region for the service. Used as the default region for AWS connections. | `us-east-1` | +| `REDIS_AWS_IAM_AUTH_ENABLED` | No | Enable AWS IAM authentication for Redis (requires `AWS_REGION`). | `1` (enabled) or `0` (disabled) | +| `REDIS_AWS_REGION` | No | AWS region for IAM auth. Falls back to `AWS_REGION`. | `us-east-1` | +| `REDIS_AWS_IAM_CACHE_NAME` | No | ElastiCache cache name (required if `REDIS_AWS_IAM_AUTH_ENABLED` is `1`). | `my-cache` | | `ENCRYPTION_SECRET` | **Yes** | Secret for encrypting stuff. | `8ebe95cg21c1fee355e9fa32c8c33141` | | `ENVIRONMENT` | No | The environment of your Hive app. (**Note:** This will be used for Sentry reporting.) | `staging` | | `BODY_LIMIT` | No | Maximum payload size in bytes. Defaults to 11 MB. | `11000000` | diff --git a/packages/services/schema/package.json b/packages/services/schema/package.json index ce4ed950c19..1567356d9d1 100644 --- a/packages/services/schema/package.json +++ b/packages/services/schema/package.json @@ -25,7 +25,6 @@ "fastq": "1.19.1", "got": "14.4.7", "graphql": "16.9.0", - "ioredis": "5.8.2", "ioredis-mock": "8.9.0", "p-timeout": "6.1.4", "pino-pretty": "11.3.0", diff --git a/packages/services/schema/src/cache.ts b/packages/services/schema/src/cache.ts index eef7618e858..99c30e34f75 100644 --- a/packages/services/schema/src/cache.ts +++ b/packages/services/schema/src/cache.ts @@ -1,8 +1,7 @@ import { createHash } from 'node:crypto'; import stringify from 'fast-json-stable-stringify'; -import type { Redis } from 'ioredis'; import { TimeoutError } from 'p-timeout'; -import type { ServiceLogger } from '@hive/service-common'; +import type { Redis, ServiceLogger } from '@hive/service-common'; import { compositionCacheValueSizeBytes, schemaCompositionCounter } from './metrics'; function createChecksum(input: TInput): string { diff --git a/packages/services/schema/src/environment.ts b/packages/services/schema/src/environment.ts index e723577a703..1d6ffb9a1c8 100644 --- a/packages/services/schema/src/environment.ts +++ b/packages/services/schema/src/environment.ts @@ -1,5 +1,9 @@ import zod from 'zod'; -import { OpenTelemetryConfigurationModel, resolveServerListenOptions } from '@hive/service-common'; +import { + OpenTelemetryConfigurationModel, + parseRedisConfigFromEnvironment, + resolveServerListenOptions, +} from '@hive/service-common'; const isNumberString = (input: unknown) => zod.string().regex(/^\d+$/).safeParse(input).success; @@ -19,6 +23,10 @@ const emptyString = (input: T) => { }, input); }; +function raiseInvariant(reason: string): never { + throw new Error(reason); +} + const EnvironmentModel = zod.object({ PORT: emptyString(NumberFromString().optional()), SERVER_HOST: emptyString(zod.string().optional()), @@ -27,6 +35,7 @@ const EnvironmentModel = zod.object({ ENVIRONMENT: emptyString(zod.string().optional()), RELEASE: emptyString(zod.string().optional()), ENCRYPTION_SECRET: zod.string(), + AWS_REGION: emptyString(zod.string().optional()), COMPOSITION_WORKER_COUNT: zod.number().min(1).default(4), COMPOSITION_WORKER_MAX_OLD_GENERATION_SIZE_MB: NumberFromString(1).optional().default(512), }); @@ -60,13 +69,6 @@ const SentryModel = zod.union([ }), ]); -const RedisModel = zod.object({ - REDIS_HOST: zod.string(), - REDIS_PORT: NumberFromString(), - REDIS_PASSWORD: emptyString(zod.string().optional()), - REDIS_TLS_ENABLED: emptyString(zod.union([zod.literal('1'), zod.literal('0')]).optional()), -}); - const PrometheusModel = zod.object({ PROMETHEUS_METRICS: emptyString(zod.union([zod.literal('0'), zod.literal('1')]).optional()), PROMETHEUS_METRICS_LABEL_INSTANCE: emptyString(zod.string().optional()), @@ -97,8 +99,6 @@ const configs = { sentry: SentryModel.safeParse(process.env), - redis: RedisModel.safeParse(process.env), - prometheus: PrometheusModel.safeParse(process.env), log: LogModel.safeParse(process.env), @@ -118,6 +118,15 @@ for (const config of Object.values(configs)) { } } +const redisConfigResult = parseRedisConfigFromEnvironment( + process.env, + configs.base.success ? configs.base.data.AWS_REGION : undefined, +); + +if (redisConfigResult.type === 'error') { + environmentErrors.push(...redisConfigResult.errors); +} + if (environmentErrors.length) { const fullError = environmentErrors.join(`\n`); console.error('❌ Invalid environment variables:', fullError); @@ -133,7 +142,6 @@ function extractConfig(config: zod.SafeParseReturnType { + Sentry.captureException(err); }); try { - redis.on('error', err => { - errorHandler('Redis error', err); - }); - - redis.on('connect', () => { - server.log.debug('Redis connection established'); - }); - - redis.on('ready', () => { - server.log.info('Redis connection ready'); - }); - - redis.on('close', () => { - server.log.info('Redis connection closed'); - }); - - redis.on('reconnecting', (timeToReconnect?: number) => { - server.log.info('Redis reconnecting in %s', timeToReconnect); - }); - await registerTRPC(server, { router: schemaBuilderApiRouter, createContext({ req }): Context { diff --git a/packages/services/server/README.md b/packages/services/server/README.md index 640cd6f7cf0..46f57000e57 100644 --- a/packages/services/server/README.md +++ b/packages/services/server/README.md @@ -27,8 +27,14 @@ The GraphQL API for GraphQL Hive. | `CLICKHOUSE_REQUEST_TIMEOUT` | No | Force a request timeout value for ClickHouse operations (in ms) | `30000` | | `REDIS_HOST` | **Yes** | The host of your redis instance. | `"127.0.0.1"` | | `REDIS_PORT` | **Yes** | The port of your redis instance. | `6379` | -| `REDIS_PASSWORD` | **Yes** | The password of your redis instance. | `"password"` | +| `REDIS_PASSWORD` | No | The password of your redis instance. | `"password"` | | `REDIS_TLS_ENABLED` | **No** | Enable TLS for redis connection (rediss://). | `"0"` | +| `REDIS_USERNAME` | No | The username for Redis Access Control List authentication. | `"default"` | +| `REDIS_CLUSTER_MODE_ENABLED` | No | Enable Redis Cluster mode. | `1` (enabled) or `0` (disabled) | +| `AWS_REGION` | No | The global AWS region for the service. Used as the default region for AWS connections. | `us-east-1` | +| `REDIS_AWS_IAM_AUTH_ENABLED` | No | Enable AWS IAM authentication for Redis (requires `AWS_REGION`). | `1` (enabled) or `0` (disabled) | +| `REDIS_AWS_REGION` | No | AWS region for IAM auth. Falls back to `AWS_REGION`. | `us-east-1` | +| `REDIS_AWS_IAM_CACHE_NAME` | No | ElastiCache cache name (required if `REDIS_AWS_IAM_AUTH_ENABLED` is `1`). | `my-cache` | | `S3_ENDPOINT` | **Yes** | The S3 endpoint. | `http://localhost:9000` | | `S3_ACCESS_KEY_ID` | **Yes** | The S3 access key id. | `minioadmin` | | `S3_SECRET_ACCESS_KEY` | **Yes** | The S3 secret access key. | `minioadmin` | diff --git a/packages/services/server/package.json b/packages/services/server/package.json index e3e4182fe33..6417fa0c503 100644 --- a/packages/services/server/package.json +++ b/packages/services/server/package.json @@ -48,7 +48,6 @@ "graphql": "16.9.0", "graphql-yoga": "5.13.3", "hyperid": "4.0.0", - "ioredis": "5.8.2", "openid-client": "6.8.2", "pino-pretty": "11.3.0", "prom-client": "15.1.3", diff --git a/packages/services/server/src/environment.ts b/packages/services/server/src/environment.ts index c5910cc891b..10fc04d3a68 100644 --- a/packages/services/server/src/environment.ts +++ b/packages/services/server/src/environment.ts @@ -1,5 +1,9 @@ import zod from 'zod'; -import { OpenTelemetryConfigurationModel, resolveServerListenOptions } from '@hive/service-common'; +import { + OpenTelemetryConfigurationModel, + parseRedisConfigFromEnvironment, + resolveServerListenOptions, +} from '@hive/service-common'; const isNumberString = (input: unknown) => zod.string().regex(/^\d+$/).safeParse(input).success; @@ -18,6 +22,10 @@ const emptyString = (input: T) => { }, input); }; +function raiseInvariant(reason: string): never { + throw new Error(reason); +} + const TestUtilsModel = zod.object({ EXPOSE_MEMORY_UTILS: emptyString(zod.union([zod.literal('1'), zod.literal('0')]).optional()), }); @@ -55,6 +63,7 @@ const EnvironmentModel = zod.object({ FEATURE_FLAGS_OTEL_TRACING_ENABLED: emptyString( zod.union([zod.literal('1'), zod.literal('0')]).optional(), ), + AWS_REGION: emptyString(zod.string().optional()), FEATURE_FLAGS_METRIC_ALERT_RULES_ENABLED: emptyString( zod.union([zod.literal('1'), zod.literal('0')]).optional(), ), @@ -108,13 +117,6 @@ const ClickHouseModel = zod.object({ CLICKHOUSE_REQUEST_TIMEOUT: emptyString(NumberFromString.optional()), }); -const RedisModel = zod.object({ - REDIS_HOST: zod.string(), - REDIS_PORT: NumberFromString, - REDIS_PASSWORD: emptyString(zod.string().optional()), - REDIS_TLS_ENABLED: emptyString(zod.union([zod.literal('1'), zod.literal('0')]).optional()), -}); - const SuperTokensModel = zod.object({ SUPERTOKENS_REFRESH_TOKEN_KEY: zod.string(), SUPERTOKENS_ACCESS_TOKEN_KEY: zod.string(), @@ -302,7 +304,6 @@ const configs = { sentry: SentryModel.safeParse(processEnv), postgres: PostgresModel.safeParse(processEnv), clickhouse: ClickHouseModel.safeParse(processEnv), - redis: RedisModel.safeParse(processEnv), supertokens: SuperTokensModel.safeParse(processEnv), authGithub: AuthGitHubConfigSchema.safeParse(processEnv), authGoogle: AuthGoogleConfigSchema.safeParse(processEnv), @@ -330,6 +331,15 @@ for (const config of Object.values(configs)) { } } +const redisConfigResult = parseRedisConfigFromEnvironment( + processEnv, + configs.base.success ? configs.base.data.AWS_REGION : undefined, +); + +if (redisConfigResult.type === 'error') { + environmentErrors.push(...redisConfigResult.errors); +} + if (environmentErrors.length) { const fullError = environmentErrors.join(`\n`); console.error('❌ Invalid environment variables:', fullError); @@ -348,7 +358,6 @@ const commerce = extractConfig(configs.commerce); const postgres = extractConfig(configs.postgres); const sentry = extractConfig(configs.sentry); const clickhouse = extractConfig(configs.clickhouse); -const redis = extractConfig(configs.redis); const supertokens = extractConfig(configs.supertokens); const authGithub = extractConfig(configs.authGithub); const authGoogle = extractConfig(configs.authGoogle); @@ -450,12 +459,10 @@ export const env = { password: clickhouse.CLICKHOUSE_PASSWORD, requestTimeout: clickhouse.CLICKHOUSE_REQUEST_TIMEOUT, }, - redis: { - host: redis.REDIS_HOST, - port: redis.REDIS_PORT, - password: redis.REDIS_PASSWORD ?? '', - tlsEnabled: redis.REDIS_TLS_ENABLED === '1', - }, + redis: + redisConfigResult?.type === 'ok' + ? redisConfigResult.config + : raiseInvariant('Unreachable: redis config errors are caught above via process.exit(1)'), supertokens: { secrets: { refreshTokenKey: supertokens.SUPERTOKENS_REFRESH_TOKEN_KEY, diff --git a/packages/services/server/src/index.ts b/packages/services/server/src/index.ts index 11218918e24..6433d36846b 100644 --- a/packages/services/server/src/index.ts +++ b/packages/services/server/src/index.ts @@ -17,7 +17,6 @@ import { AccessTokenKeyContainer } from '@hive/api/modules/auth/lib/supertokens- import { EmailVerification } from '@hive/api/modules/auth/providers/email-verification'; import { OAuthCache } from '@hive/api/modules/auth/providers/oauth-cache'; import { OIDCIntegrationStore } from '@hive/api/modules/oidc-integrations/providers/oidc-integration.store'; -import { createRedisClient } from '@hive/api/modules/shared/providers/redis'; import { RedisRateLimiter } from '@hive/api/modules/shared/providers/redis-rate-limiter'; import { TargetsByIdCache } from '@hive/api/modules/target/providers/targets-by-id-cache'; import { TargetsBySlugCache } from '@hive/api/modules/target/providers/targets-by-slug-cache'; @@ -30,6 +29,7 @@ import { createConnectionString } from '@hive/postgres'; import { createHivePubSub } from '@hive/pubsub'; import { configureTracing, + createRedisClient, createServer, registerShutdown, registerTRPC, @@ -171,15 +171,17 @@ export async function main() { ); const taskScheduler = new TaskScheduler(storage.pool); - const redis = createRedisClient('Redis', env.redis, server.log.child({ source: 'Redis' })); + const redis = await createRedisClient(env.redis, { + logger: server.log.child({ source: 'Redis' }), + }); + + const redisSubscriber = await createRedisClient(env.redis, { + logger: server.log.child({ source: 'RedisSubscribe' }), + }); const pubSub = createHivePubSub({ publisher: redis, - subscriber: createRedisClient( - 'subscriber', - env.redis, - server.log.child({ source: 'RedisSubscribe' }), - ), + subscriber: redisSubscriber, }); registerShutdown({ diff --git a/packages/services/service-common/package.json b/packages/services/service-common/package.json index 5387bf5e994..55125b02140 100644 --- a/packages/services/service-common/package.json +++ b/packages/services/service-common/package.json @@ -13,6 +13,9 @@ "@trpc/server": "10.45.3" }, "devDependencies": { + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/credential-providers": "3.1035.0", + "@aws-sdk/util-format-url": "3.972.13", "@fastify/cors": "11.2.0", "@graphql-hive/logger": "1.1.0", "@graphql-hive/plugin-opentelemetry": "1.4.26", @@ -30,12 +33,16 @@ "@sentry/node": "7.120.2", "@sentry/types": "7.120.2", "@sentry/utils": "7.120.2", + "@smithy/protocol-http": "5.4.3", + "@smithy/signature-v4": "5.4.3", "aws-msk-iam-sasl-signer-js": "1.0.3", "fastify": "5.8.5", "fastify-plugin": "5.1.0", + "ioredis": "5.10.1", "opentelemetry-instrumentation-fetch-node": "1.2.3", "p-retry": "6.2.1", "prom-client": "15.1.3", + "vitest": "4.1.3", "zod": "3.25.76" } } diff --git a/packages/services/service-common/src/iam-aws.spec.ts b/packages/services/service-common/src/iam-aws.spec.ts new file mode 100644 index 00000000000..bf56ab7d13c --- /dev/null +++ b/packages/services/service-common/src/iam-aws.spec.ts @@ -0,0 +1,453 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +describe('generatePresignedToken', () => { + const mockFormatUrl = vi.fn(); + const mockPresign = vi.fn(); + + beforeEach(() => { + vi.resetModules(); + mockFormatUrl.mockReset(); + mockPresign.mockReset(); + + // Mock all AWS SDK dynamic imports + vi.doMock('@aws-sdk/credential-providers', () => ({ + fromNodeProviderChain: vi.fn(() => 'mock-credential-provider'), + })); + vi.doMock('@smithy/protocol-http', () => { + const MockHttpRequest = vi.fn(function (this: any, opts: any) { + Object.assign(this, opts); + }); + return { HttpRequest: MockHttpRequest }; + }); + vi.doMock('@smithy/signature-v4', () => { + const MockSignatureV4 = vi.fn(function (this: any) { + this.presign = mockPresign; + }); + return { SignatureV4: MockSignatureV4 }; + }); + vi.doMock('@aws-crypto/sha256-js', () => ({ + Sha256: vi.fn(), + })); + vi.doMock('@aws-sdk/util-format-url', () => ({ + formatUrl: mockFormatUrl, + })); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('token output', () => { + it('strips http:// so the token is a bare host+query string', async () => { + mockPresign.mockResolvedValue({}); + mockFormatUrl.mockReturnValue( + 'http://test-host/?Action=connect&User=default&X-Amz-Signature=abc123', + ); + + const { generatePresignedToken } = await import('./iam-aws'); + + const token = await generatePresignedToken({ + service: 'elasticache', + region: 'us-east-1', + hostname: 'my-cache', + query: { Action: 'connect', User: 'default' }, + }); + + expect(token).toBe('test-host/?Action=connect&User=default&X-Amz-Signature=abc123'); + }); + + it('strips https:// the same way', async () => { + mockPresign.mockResolvedValue({}); + mockFormatUrl.mockReturnValue('https://my-cache/?signed=true'); + + const { generatePresignedToken } = await import('./iam-aws'); + + const token = await generatePresignedToken({ + service: 'elasticache', + region: 'us-east-1', + hostname: 'my-cache', + query: {}, + }); + + expect(token).toBe('my-cache/?signed=true'); + }); + }); + + describe('SigV4 request construction', () => { + it('builds an HttpRequest from the caller hostname and query', async () => { + const { HttpRequest } = await import('@smithy/protocol-http'); + mockPresign.mockResolvedValue({}); + mockFormatUrl.mockReturnValue('http://x'); + + const { generatePresignedToken } = await import('./iam-aws'); + + await generatePresignedToken({ + service: 'elasticache', + region: 'us-east-1', + hostname: 'my-cache-group', + query: { Action: 'connect', User: 'myuser' }, + }); + + expect(HttpRequest).toHaveBeenCalledWith({ + protocol: 'http:', + hostname: 'my-cache-group', + method: 'GET', + path: '/', + query: { Action: 'connect', User: 'myuser' }, + headers: { Host: 'my-cache-group' }, + }); + }); + + it('uses the caller service and region for the signer', async () => { + const { SignatureV4 } = await import('@smithy/signature-v4'); + const { Sha256 } = await import('@aws-crypto/sha256-js'); + mockPresign.mockResolvedValue({}); + mockFormatUrl.mockReturnValue('http://x'); + + const { generatePresignedToken } = await import('./iam-aws'); + + await generatePresignedToken({ + service: 'kafka', + region: 'eu-west-1', + hostname: 'broker', + query: {}, + }); + + expect(SignatureV4).toHaveBeenCalledWith({ + service: 'kafka', + region: 'eu-west-1', + credentials: 'mock-credential-provider', + sha256: Sha256, + }); + }); + + it('presigns with a 900s expiry', async () => { + mockPresign.mockResolvedValue({}); + mockFormatUrl.mockReturnValue('http://x'); + + const { generatePresignedToken } = await import('./iam-aws'); + + await generatePresignedToken({ + service: 'elasticache', + region: 'us-east-1', + hostname: 'my-cache', + query: {}, + }); + + expect(mockPresign).toHaveBeenCalledWith(expect.anything(), { expiresIn: 900 }); + }); + }); + + describe('error propagation', () => { + it('rejects when presign fails (e.g. expired credentials)', async () => { + mockPresign.mockRejectedValue(new Error('Token has expired')); + + const { generatePresignedToken } = await import('./iam-aws'); + + await expect( + generatePresignedToken({ + service: 'elasticache', + region: 'us-east-1', + hostname: 'my-cache', + query: {}, + }), + ).rejects.toThrow('Token has expired'); + }); + + it('rejects when the credential provider fails (no credentials available)', async () => { + vi.doMock('@aws-sdk/credential-providers', () => ({ + fromNodeProviderChain: vi.fn(() => { + throw new Error('Could not load credentials from any providers'); + }), + })); + + const { generatePresignedToken } = await import('./iam-aws'); + + await expect( + generatePresignedToken({ + service: 'elasticache', + region: 'us-east-1', + hostname: 'my-cache', + query: {}, + }), + ).rejects.toThrow('Could not load credentials from any providers'); + }); + }); +}); + +describe('startTokenRefreshTimer', () => { + beforeEach(() => { + vi.useFakeTimers(); + // Deterministic jitter: Math.random() → 0 + vi.spyOn(Math, 'random').mockReturnValue(0); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + describe('scheduling', () => { + it('calls unref on each setTimeout handle when supported', async () => { + const { startTokenRefreshTimer } = await import('./iam-aws'); + const refreshFn = vi.fn().mockResolvedValue(undefined); + + const unref = vi.fn(); + const fakeTimer = { unref } as unknown as ReturnType; + const setTimeoutSpy = vi + .spyOn(globalThis, 'setTimeout') + .mockImplementation((() => fakeTimer) as any); + + const cleanup = startTokenRefreshTimer(refreshFn, { + backoffRefreshSeconds: 60, + jitterMs: 0, + }); + + expect(setTimeoutSpy).toHaveBeenCalled(); + expect(unref).toHaveBeenCalled(); + expect(typeof cleanup).toBe('function'); + + cleanup(); + setTimeoutSpy.mockRestore(); + }); + + it('refreshes after (TOKEN_TTL - backoff) seconds, then schedules next', async () => { + const { startTokenRefreshTimer } = await import('./iam-aws'); + const refreshFn = vi.fn().mockResolvedValue(undefined); + + const cleanup = startTokenRefreshTimer(refreshFn, { + backoffRefreshSeconds: 60, + jitterMs: 0, + }); + + // (900 - 60) * 1000 = 840_000ms + expect(refreshFn).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(840_000); + expect(refreshFn).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(840_000); + expect(refreshFn).toHaveBeenCalledTimes(2); + + cleanup(); + }); + + it('accepts a larger backoff to shorten the refresh cycle', async () => { + const { startTokenRefreshTimer } = await import('./iam-aws'); + const refreshFn = vi.fn().mockResolvedValue(undefined); + + const cleanup = startTokenRefreshTimer(refreshFn, { + backoffRefreshSeconds: 180, + jitterMs: 0, + }); + + // (900 - 180) * 1000 = 720_000ms + await vi.advanceTimersByTimeAsync(720_000); + expect(refreshFn).toHaveBeenCalledTimes(1); + + cleanup(); + }); + + it('defaults to 60s backoff when no options are provided', async () => { + const { startTokenRefreshTimer } = await import('./iam-aws'); + const refreshFn = vi.fn().mockResolvedValue(undefined); + + const cleanup = startTokenRefreshTimer(refreshFn); + + // (900 - 60) * 1000 = 840_000ms, jitter = 0 (mocked Math.random) + await vi.advanceTimersByTimeAsync(840_000); + expect(refreshFn).toHaveBeenCalledTimes(1); + + cleanup(); + }); + + it('stops when the cleanup function is called', async () => { + const { startTokenRefreshTimer } = await import('./iam-aws'); + const refreshFn = vi.fn().mockResolvedValue(undefined); + + const cleanup = startTokenRefreshTimer(refreshFn, { + backoffRefreshSeconds: 60, + jitterMs: 0, + }); + + cleanup(); + + await vi.advanceTimersByTimeAsync(840_000); + expect(refreshFn).not.toHaveBeenCalled(); + }); + + it('cannot overlap — next cycle waits for current refresh to complete', async () => { + const { startTokenRefreshTimer } = await import('./iam-aws'); + + let resolveRefresh: () => void; + const refreshFn = vi.fn(() => new Promise(resolve => (resolveRefresh = resolve))); + + const cleanup = startTokenRefreshTimer(refreshFn, { + backoffRefreshSeconds: 60, + jitterMs: 0, + }); + + // Trigger first cycle — refreshFn blocks + await vi.advanceTimersByTimeAsync(840_000); + expect(refreshFn).toHaveBeenCalledTimes(1); + + // Advance another full interval while first refresh is still in-flight + await vi.advanceTimersByTimeAsync(840_000); + // No second call — recursive setTimeout means next is not yet scheduled + expect(refreshFn).toHaveBeenCalledTimes(1); + + // Complete the first refresh — this should schedule the next timeout + resolveRefresh!(); + await vi.advanceTimersByTimeAsync(0); // flush microtasks + + // Now advance the interval again — second refresh fires + await vi.advanceTimersByTimeAsync(840_000); + expect(refreshFn).toHaveBeenCalledTimes(2); + + cleanup(); + }); + }); + + describe('error handling', () => { + it('retries up to maxRetries, passing attempt info to refreshFn', async () => { + const { startTokenRefreshTimer } = await import('./iam-aws'); + const refreshFn = vi.fn().mockRejectedValue(new Error('STS timeout')); + + const cleanup = startTokenRefreshTimer(refreshFn, { + backoffRefreshSeconds: 60, + jitterMs: 0, + maxRetries: 3, + retryBackoffMs: 100, + }); + + // Trigger the interval + await vi.advanceTimersByTimeAsync(840_000); + // Allow retry backoff timers to fire + await vi.advanceTimersByTimeAsync(1000); + + expect(refreshFn).toHaveBeenCalledTimes(3); + expect(refreshFn).toHaveBeenCalledWith(1, 3); + expect(refreshFn).toHaveBeenCalledWith(2, 3); + expect(refreshFn).toHaveBeenCalledWith(3, 3); + + cleanup(); + }); + + it('does not retry when refreshFn succeeds on the first attempt', async () => { + const { startTokenRefreshTimer } = await import('./iam-aws'); + const refreshFn = vi.fn().mockResolvedValue(undefined); + + const cleanup = startTokenRefreshTimer(refreshFn, { + backoffRefreshSeconds: 60, + jitterMs: 0, + maxRetries: 3, + }); + + await vi.advanceTimersByTimeAsync(840_000); + + expect(refreshFn).toHaveBeenCalledTimes(1); + expect(refreshFn).toHaveBeenCalledWith(1, 3); + + cleanup(); + }); + + it('stops retrying after the first successful attempt', async () => { + const { startTokenRefreshTimer } = await import('./iam-aws'); + const refreshFn = vi + .fn() + .mockRejectedValueOnce(new Error('transient failure')) + .mockResolvedValueOnce(undefined); + + const cleanup = startTokenRefreshTimer(refreshFn, { + backoffRefreshSeconds: 60, + jitterMs: 0, + maxRetries: 3, + retryBackoffMs: 100, + }); + + await vi.advanceTimersByTimeAsync(840_000); + await vi.advanceTimersByTimeAsync(1000); + + expect(refreshFn).toHaveBeenCalledTimes(2); + expect(refreshFn).toHaveBeenCalledWith(1, 3); + expect(refreshFn).toHaveBeenCalledWith(2, 3); + + cleanup(); + }); + }); + + describe('shutdown safety', () => { + it('prevents new callbacks from executing after cleanup is called', async () => { + const { startTokenRefreshTimer } = await import('./iam-aws'); + const refreshFn = vi.fn().mockResolvedValue(undefined); + + const cleanup = startTokenRefreshTimer(refreshFn, { + backoffRefreshSeconds: 60, + jitterMs: 0, + }); + + // First refresh should execute + await vi.advanceTimersByTimeAsync(840_000); + expect(refreshFn).toHaveBeenCalledTimes(1); + + // Call cleanup + cleanup(); + + // Second refresh should not execute + await vi.advanceTimersByTimeAsync(840_000); + expect(refreshFn).toHaveBeenCalledTimes(1); + }); + + it('stops retries when cleanup is called during in-flight refresh', async () => { + const { startTokenRefreshTimer } = await import('./iam-aws'); + const refreshFn = vi.fn().mockRejectedValue(new Error('fail')); + let cleanupFn: () => void; + + // Call cleanup after the first attempt + refreshFn.mockImplementationOnce(async () => { + cleanupFn(); + throw new Error('fail'); + }); + + cleanupFn = startTokenRefreshTimer(refreshFn, { + backoffRefreshSeconds: 60, + jitterMs: 0, + maxRetries: 5, + retryBackoffMs: 100, + }); + + await vi.advanceTimersByTimeAsync(840_000); + await vi.advanceTimersByTimeAsync(10_000); + + // Should stop after the first attempt since cleanup was called + expect(refreshFn).toHaveBeenCalledTimes(1); + }); + + it('allows cleanup to be called multiple times safely', async () => { + const { startTokenRefreshTimer } = await import('./iam-aws'); + const refreshFn = vi.fn().mockResolvedValue(undefined); + + const cleanup = startTokenRefreshTimer(refreshFn, { + backoffRefreshSeconds: 60, + jitterMs: 0, + }); + + cleanup(); + cleanup(); + cleanup(); + + await vi.advanceTimersByTimeAsync(840_000); + expect(refreshFn).not.toHaveBeenCalled(); + }); + + it('returns a callable cleanup function from the start', async () => { + const { startTokenRefreshTimer } = await import('./iam-aws'); + const refreshFn = vi.fn().mockResolvedValue(undefined); + + const cleanup = startTokenRefreshTimer(refreshFn); + + expect(typeof cleanup).toBe('function'); + expect(() => cleanup()).not.toThrow(); + }); + }); +}); diff --git a/packages/services/service-common/src/iam-aws.ts b/packages/services/service-common/src/iam-aws.ts new file mode 100644 index 00000000000..c03b0d1aaca --- /dev/null +++ b/packages/services/service-common/src/iam-aws.ts @@ -0,0 +1,160 @@ +import { Sha256 } from '@aws-crypto/sha256-js'; +import { fromNodeProviderChain } from '@aws-sdk/credential-providers'; +import { formatUrl } from '@aws-sdk/util-format-url'; +import { HttpRequest } from '@smithy/protocol-http'; +import { SignatureV4 } from '@smithy/signature-v4'; + +/** + * Generic AWS IAM utilities for SigV4 pre-signed token generation. + */ + +/** + * Max TTL for SigV4 pre-signed tokens in seconds (15 minutes). + */ +const SIGV4_TOKEN_TTL_SECONDS = 900; + +/** + * Configuration for generating a SigV4 pre-signed token. + */ +export interface PresignedTokenConfig { + /** AWS service name used in the SigV4 signing scope (e.g. `'elasticache'`). */ + service: string; + /** AWS region used in the SigV4 signing scope (e.g. `'us-east-1'`). */ + region: string; + /** Hostname embedded in the presigned request (typically the cluster/broker endpoint). */ + hostname: string; + /** Query parameters appended to the presigned URL (e.g. `{ Action: 'connect', User: 'default' }`). */ + query: Record; +} + +/** + * Generate a SigV4 pre-signed token for any AWS service. + * + * Builds a synthetic HTTP GET request, signs it with SigV4 using the default + * credential chain ({@link fromNodeProviderChain}), and returns the signed URL + * with the protocol prefix stripped. The resulting token is never sent as an + * HTTP request. It is passed as a credential to the target service. + * + * @param config - Service, region, hostname, and query parameters for the presigned request. + * @returns The signed URL string with the `http(s)://` prefix removed. + */ +export async function generatePresignedToken(config: PresignedTokenConfig): Promise { + const credentialProvider = fromNodeProviderChain(); + const expiresIn = SIGV4_TOKEN_TTL_SECONDS; + + const request = new HttpRequest({ + protocol: 'http:', + hostname: config.hostname, + method: 'GET', + path: '/', + query: config.query, + headers: { + Host: config.hostname, + }, + }); + + const signer = new SignatureV4({ + service: config.service, + region: config.region, + credentials: credentialProvider, + sha256: Sha256, + }); + + const signed = await signer.presign(request, { expiresIn }); + // formatUrl encodes query values exactly once (important for X-Amz-Security-Token). + const token = formatUrl(signed).replace(/^https?:\/\//, ''); + + return token; +} + +/** + * Options for {@link startTokenRefreshTimer}. + * + * Controls the refresh interval, jitter, and retry behaviour + * for periodic credential rotation. + */ +export interface TokenRefreshTimerOptions { + /** Seconds to subtract from the 15-min SigV4 max TTL to derive the refresh interval (default: 60 -> 14 min refresh). */ + backoffRefreshSeconds?: number; + /** Max random jitter in ms added to interval (default: 30_000 = 30 seconds). */ + jitterMs?: number; + /** Max retry attempts on failure (default: 3). */ + maxRetries?: number; + /** Base backoff delay in ms, multiplied by attempt number (default: 5_000 = 5 seconds). */ + retryBackoffMs?: number; +} + +/** + * Start a periodic timer that calls `refreshFn` to rotate credentials. + * + * The interval is derived from `SIGV4_TOKEN_TTL_SECONDS` minus a configurable + * backoff (default 60 s -> ~14-minute cycle). Random jitter (0-30 s) is added + * to prevent thundering-herd refreshes across instances. + * + * On failure the timer retries up to `maxRetries` + * times with linear backoff (`attempt * retryBackoffMs`). Callers that need + * per-attempt error logging should catch inside `refreshFn`, which receives + * the current `attempt` and `maxRetries` as arguments. + * + * @param refreshFn - Async function that generates a new token and applies it + * to the target client (e.g. Redis AUTH). Receives the + * current `attempt` (1-based) and `maxRetries` for logging/alerting. + * @param options - Tuning knobs for interval, jitter, and retry behaviour. + * @returns A cleanup function that stops the refresh loop and prevents in-flight + * retries from executing (e.g. during graceful shutdown). Safe to call multiple times. + */ +export function startTokenRefreshTimer( + refreshFn: (attempt: number, maxRetries: number) => Promise, + options?: TokenRefreshTimerOptions, +): () => void { + const backoff = options?.backoffRefreshSeconds ?? 60; + const intervalMs = (SIGV4_TOKEN_TTL_SECONDS - backoff) * 1000; + const maxJitterMs = options?.jitterMs ?? 30_000; + const maxRetries = options?.maxRetries ?? 3; + const retryBackoffMs = options?.retryBackoffMs ?? 5_000; + + // Track shutdown state to prevent in-flight callbacks and retries during graceful shutdown + let isShuttingDown = false; + let currentTimer: ReturnType | undefined; + + function scheduleNext() { + if (isShuttingDown) return; + + const jitterMs = Math.floor(Math.random() * maxJitterMs); + currentTimer = setTimeout(() => { + if (isShuttingDown) return; + + void (async () => { + for (let attempt = 1; attempt <= maxRetries; attempt++) { + if (isShuttingDown) return; + + try { + await refreshFn(attempt, maxRetries); + break; + } catch { + if (attempt < maxRetries) { + await new Promise(r => setTimeout(r, attempt * retryBackoffMs)); + } + } + } + // Schedule next cycle only after current refresh (including retries) completes + scheduleNext(); + })(); + }, intervalMs + jitterMs); + + // Allow clean process shutdown even if the refresh timer is still active. + if (typeof currentTimer.unref === 'function') { + currentTimer.unref(); + } + } + + scheduleNext(); + + // Return a cleanup function that sets shutdown flag and clears the pending timer + return () => { + isShuttingDown = true; + if (currentTimer !== undefined) { + clearTimeout(currentTimer); + } + }; +} diff --git a/packages/services/service-common/src/iam-redis.spec.ts b/packages/services/service-common/src/iam-redis.spec.ts new file mode 100644 index 00000000000..abd0d234a1a --- /dev/null +++ b/packages/services/service-common/src/iam-redis.spec.ts @@ -0,0 +1,449 @@ +import { Cluster as RedisCluster } from 'ioredis'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +function createMockLogger() { + return { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + fatal: vi.fn(), + trace: vi.fn(), + child: vi.fn(function (this: any) { + return this; + }), + level: 'info', + silent: vi.fn(), + }; +} + +const mockGeneratePresignedToken = vi.fn(); + +vi.mock('./iam-aws', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + generatePresignedToken: (...args: any[]) => mockGeneratePresignedToken(...args), + }; +}); + +beforeEach(() => { + mockGeneratePresignedToken.mockReset(); +}); + +describe('generateIamAuthToken', () => { + describe('ElastiCache SigV4 params', () => { + it('delegates to generatePresignedToken with service=elasticache', async () => { + mockGeneratePresignedToken.mockResolvedValue('signed-token-123'); + + const { generateIamAuthToken } = await import('./iam-redis'); + const logger = createMockLogger(); + + const token = await generateIamAuthToken( + { + host: 'my-cluster.abc.cache.amazonaws.com', + port: 6190, + awsRegion: 'us-east-1', + username: 'default', + iamAuthCacheName: 'my-repl-group', + }, + logger, + ); + + expect(token).toBe('signed-token-123'); + expect(mockGeneratePresignedToken).toHaveBeenCalledWith({ + service: 'elasticache', + region: 'us-east-1', + hostname: 'my-repl-group', + query: { Action: 'connect', User: 'default' }, + }); + }); + + it('passes the configured username in the query User param', async () => { + mockGeneratePresignedToken.mockResolvedValue('token'); + + const { generateIamAuthToken } = await import('./iam-redis'); + const logger = createMockLogger(); + + await generateIamAuthToken( + { + host: 'host', + port: 6379, + awsRegion: 'eu-west-1', + username: 'readwrite-user', + iamAuthCacheName: 'cache-group', + }, + logger, + ); + + expect(mockGeneratePresignedToken).toHaveBeenCalledWith( + expect.objectContaining({ + query: { Action: 'connect', User: 'readwrite-user' }, + }), + ); + }); + + it('falls back to a placeholder hostname when iamAuthCacheName is empty', async () => { + mockGeneratePresignedToken.mockResolvedValue('token'); + + const { generateIamAuthToken } = await import('./iam-redis'); + const logger = createMockLogger(); + + await generateIamAuthToken( + { + host: 'host', + port: 6379, + awsRegion: 'us-east-1', + username: 'myuser', + iamAuthCacheName: '', + }, + logger, + ); + + expect(mockGeneratePresignedToken).toHaveBeenCalledWith( + expect.objectContaining({ hostname: 'i-cannot-be-empty' }), + ); + }); + }); + + describe('error propagation', () => { + it('rejects when the underlying presign call fails', async () => { + mockGeneratePresignedToken.mockRejectedValue(new Error('STS timeout')); + + const { generateIamAuthToken } = await import('./iam-redis'); + const logger = createMockLogger(); + + await expect( + generateIamAuthToken( + { + host: 'host', + port: 6379, + awsRegion: 'us-east-1', + username: 'default', + iamAuthCacheName: 'cache', + }, + logger, + ), + ).rejects.toThrow('STS timeout'); + }); + }); +}); + +describe('refreshIamAuth', () => { + describe('standalone mode', () => { + it('calls AUTH and updates options.password', async () => { + const { refreshIamAuth } = await import('./iam-redis'); + const logger = createMockLogger(); + + const mockRedis = { + call: vi.fn().mockResolvedValue('OK'), + options: { password: 'old-token' }, + } as any; + + await refreshIamAuth(mockRedis, 'new-token-xyz', 'default', logger); + + expect(mockRedis.call).toHaveBeenCalledWith('AUTH', 'default', 'new-token-xyz'); + expect(mockRedis.options.password).toBe('new-token-xyz'); + expect(logger.debug).toHaveBeenCalledWith( + expect.stringContaining('IAM token refreshed successfully'), + ); + }); + + it('rejects when AUTH fails on standalone Redis', async () => { + const { refreshIamAuth } = await import('./iam-redis'); + const logger = createMockLogger(); + + const mockRedis = { + call: vi.fn().mockRejectedValue(new Error('WRONGPASS')), + options: { password: 'old-token' }, + } as any; + + await expect(refreshIamAuth(mockRedis, 'bad-token', 'default', logger)).rejects.toThrow( + 'WRONGPASS', + ); + + // Password is updated before AUTH is called (same as cluster mode). + expect(mockRedis.options.password).toBe('bad-token'); + }); + }); + + describe('cluster mode', () => { + it('iterates all cluster nodes and re-AUTHs each one', async () => { + const { refreshIamAuth } = await import('./iam-redis'); + const logger = createMockLogger(); + + const node1 = { call: vi.fn().mockResolvedValue('OK'), options: { password: 'old' } }; + const node2 = { call: vi.fn().mockResolvedValue('OK'), options: { password: 'old' } }; + const node3 = { call: vi.fn().mockResolvedValue('OK'), options: { password: 'old' } }; + + const mockCluster = Object.assign(Object.create(RedisCluster.prototype), { + nodes: vi.fn().mockReturnValue([node1, node2, node3]), + options: { redisOptions: { password: 'old' } }, + }); + + await refreshIamAuth(mockCluster, 'new-cluster-token', 'myuser', logger); + + for (const node of [node1, node2, node3]) { + expect(node.call).toHaveBeenCalledWith('AUTH', 'myuser', 'new-cluster-token'); + expect(node.options.password).toBe('new-cluster-token'); + } + expect(mockCluster.options.redisOptions.password).toBe('new-cluster-token'); + }); + + it('updates options.redisOptions so new node connections use the fresh token', async () => { + const { refreshIamAuth } = await import('./iam-redis'); + const logger = createMockLogger(); + + const mockCluster = Object.assign(Object.create(RedisCluster.prototype), { + nodes: vi.fn().mockReturnValue([]), + options: { redisOptions: { password: 'old-password' } }, + }); + + await refreshIamAuth(mockCluster, 'refreshed-token', 'user', logger); + + expect(mockCluster.options.redisOptions.password).toBe('refreshed-token'); + }); + + it('continues re-AUTHing remaining nodes when one node fails', async () => { + const { refreshIamAuth } = await import('./iam-redis'); + const logger = createMockLogger(); + + const healthyNode = { call: vi.fn().mockResolvedValue('OK'), options: { password: 'old' } }; + const failingNode = { + call: vi.fn().mockRejectedValue(new Error('NOAUTH')), + options: { password: 'old' }, + }; + + const mockCluster = Object.assign(Object.create(RedisCluster.prototype), { + nodes: vi.fn().mockReturnValue([healthyNode, failingNode]), + options: { redisOptions: { password: 'old' } }, + }); + + // Should not throw — individual node failures are caught + await refreshIamAuth(mockCluster, 'token', 'default', logger); + + expect(healthyNode.options.password).toBe('token'); + // Password is set before AUTH, so even failing nodes get the updated password + expect(failingNode.options.password).toBe('token'); + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining('Failed to re-AUTH cluster node'), + expect.any(Error), + ); + // Still reports overall success + expect(logger.debug).toHaveBeenCalledWith( + expect.stringContaining('IAM token refreshed successfully'), + ); + }); + + it('handles missing options.redisOptions gracefully', async () => { + const { refreshIamAuth } = await import('./iam-redis'); + const logger = createMockLogger(); + + const mockCluster = Object.assign(Object.create(RedisCluster.prototype), { + nodes: vi.fn().mockReturnValue([]), + options: undefined, + }); + + await expect(refreshIamAuth(mockCluster, 'token', 'user', logger)).resolves.toBeUndefined(); + }); + + it('handles empty nodes list gracefully', async () => { + const { refreshIamAuth } = await import('./iam-redis'); + const logger = createMockLogger(); + + const mockCluster = Object.assign(Object.create(RedisCluster.prototype), { + nodes: vi.fn().mockReturnValue([]), + options: { redisOptions: { password: 'old' } }, + }); + + await expect(refreshIamAuth(mockCluster, 'token', 'user', logger)).resolves.toBeUndefined(); + + expect(logger.debug).toHaveBeenCalledWith( + expect.stringContaining('re-authenticating %s cluster node(s)'), + 0, + ); + }); + }); +}); + +describe('startIamTokenRefresh', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.spyOn(Math, 'random').mockReturnValue(0); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + describe('refresh lifecycle', () => { + it('generates a token and re-AUTHs Redis on each tick', async () => { + mockGeneratePresignedToken.mockResolvedValue('refreshed-token'); + + const { startIamTokenRefresh } = await import('./iam-redis'); + const logger = createMockLogger(); + + const mockRedis = { + call: vi.fn().mockResolvedValue('OK'), + options: { password: 'initial-token' }, + } as any; + + const config = { + host: 'host', + port: 6190, + awsRegion: 'us-east-1', + username: 'default', + iamAuthCacheName: 'cache-name', + }; + + const timer = startIamTokenRefresh(mockRedis, config, logger); + + // ElastiCache interval: (900 - 180) * 1000 = 720_000ms + await vi.advanceTimersByTimeAsync(720_000); + + expect(mockGeneratePresignedToken).toHaveBeenCalled(); + expect(mockRedis.call).toHaveBeenCalledWith('AUTH', 'default', 'refreshed-token'); + expect(mockRedis.options.password).toBe('refreshed-token'); + + timer(); + }); + + it('returns a clearable interval handle', async () => { + const { startIamTokenRefresh } = await import('./iam-redis'); + const logger = createMockLogger(); + const mockRedis = { call: vi.fn(), options: {} } as any; + + const timer = startIamTokenRefresh( + mockRedis, + { host: 'h', port: 1, awsRegion: 'r', username: 'u', iamAuthCacheName: 'c' }, + logger, + ); + + timer(); + + await vi.advanceTimersByTimeAsync(720_000); + expect(mockRedis.call).not.toHaveBeenCalled(); + }); + }); + + describe('error handling', () => { + it('logs errors when token generation fails', async () => { + mockGeneratePresignedToken.mockRejectedValue(new Error('STS unreachable')); + + const { startIamTokenRefresh } = await import('./iam-redis'); + const logger = createMockLogger(); + + const mockRedis = { + call: vi.fn(), + options: { password: 'old' }, + } as any; + + const timer = startIamTokenRefresh( + mockRedis, + { host: 'h', port: 1, awsRegion: 'r', username: 'u', iamAuthCacheName: 'c' }, + logger, + ); + + await vi.advanceTimersByTimeAsync(720_000); + // Allow retries to fire + await vi.advanceTimersByTimeAsync(60_000); + + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining('ElastiCache IAM token refresh failed'), + expect.anything(), + expect.anything(), + expect.any(Error), + ); + // Redis password should NOT change + expect(mockRedis.options.password).toBe('old'); + + timer(); + }); + + it('logs exhaustion message after all retries fail', async () => { + mockGeneratePresignedToken.mockRejectedValue(new Error('credentials expired')); + + const { startIamTokenRefresh } = await import('./iam-redis'); + const logger = createMockLogger(); + + const mockRedis = { + call: vi.fn(), + options: { password: 'old' }, + } as any; + + const timer = startIamTokenRefresh( + mockRedis, + { host: 'h', port: 1, awsRegion: 'r', username: 'u', iamAuthCacheName: 'c' }, + logger, + ); + + await vi.advanceTimersByTimeAsync(720_000); + await vi.advanceTimersByTimeAsync(60_000); + + expect(logger.error).toHaveBeenCalledWith(expect.stringContaining('exhausted all retries')); + + timer(); + }); + }); +}); + +describe('resolveRedisCredentials', () => { + describe('static password (no IAM)', () => { + it('returns the static password when iamConfig is not provided', async () => { + const { resolveRedisCredentials } = await import('./iam-redis'); + const logger = createMockLogger(); + + const result = await resolveRedisCredentials('my-static-password', logger); + + expect(result).toEqual({ password: 'my-static-password' }); + expect(mockGeneratePresignedToken).not.toHaveBeenCalled(); + }); + + it('returns the static password when iamConfig is undefined', async () => { + const { resolveRedisCredentials } = await import('./iam-redis'); + const logger = createMockLogger(); + + const result = await resolveRedisCredentials('pw', logger, undefined); + + expect(result).toEqual({ password: 'pw' }); + }); + }); + + describe('IAM token generation', () => { + it('generates a token and returns it with the IAM username', async () => { + mockGeneratePresignedToken.mockResolvedValue('iam-generated-token'); + + const { resolveRedisCredentials } = await import('./iam-redis'); + const logger = createMockLogger(); + + const result = await resolveRedisCredentials('', logger, { + host: 'my-host', + port: 6190, + awsRegion: 'us-east-1', + username: 'readwrite-user', + iamAuthCacheName: 'my-cache-group', + }); + + expect(result).toEqual({ password: 'iam-generated-token', username: 'readwrite-user' }); + expect(mockGeneratePresignedToken).toHaveBeenCalled(); + }); + + it('rejects when token generation fails', async () => { + mockGeneratePresignedToken.mockRejectedValue(new Error('no credentials')); + + const { resolveRedisCredentials } = await import('./iam-redis'); + const logger = createMockLogger(); + + await expect( + resolveRedisCredentials('', logger, { + host: 'my-host', + port: 6190, + awsRegion: 'us-east-1', + username: 'user', + iamAuthCacheName: 'cache', + }), + ).rejects.toThrow('no credentials'); + }); + }); +}); diff --git a/packages/services/service-common/src/iam-redis.ts b/packages/services/service-common/src/iam-redis.ts new file mode 100644 index 00000000000..78bc465c103 --- /dev/null +++ b/packages/services/service-common/src/iam-redis.ts @@ -0,0 +1,200 @@ +import { Cluster as RedisCluster } from 'ioredis'; +import type { Redis as RedisStandalone } from 'ioredis'; +import type { FastifyBaseLogger as ServiceLogger } from './fastify'; +import { generatePresignedToken, startTokenRefreshTimer } from './iam-aws'; + +type RedisInstance = RedisStandalone | RedisCluster; + +/** + * ElastiCache Redis IAM authentication. + * + * Redis-specific helpers built on top of the generic iam-aws SigV4 presigning. + * Handles ElastiCache token generation and in-place AUTH for standalone and + * cluster connections. + */ + +/** + * Seconds to subtract from the 15-min SigV4 max TTL for ElastiCache refresh. + * + * A 3-minute buffer ensures the token is rotated well before expiry, giving + * enough headroom for retry attempts if the first refresh fails. + */ +const ELASTICACHE_BACKOFF_REFRESH_SECONDS = 180; + +/** + * Configuration required to generate an ElastiCache IAM auth token. + * + * Passed to {@link generateIamAuthToken}, {@link startIamTokenRefresh}, and + * {@link resolveRedisCredentials} to identify the Redis endpoint and IAM + * user for SigV4 presigning. + */ +export interface IamRedisConfig { + /** Redis endpoint hostname (e.g. `'my-cluster.abc123.use1.cache.amazonaws.com'`). */ + host: string; + /** Redis endpoint port. */ + port: number; + /** AWS region of the ElastiCache cluster (e.g. `'us-east-1'`). */ + awsRegion: string; + /** ElastiCache Redis username that has IAM enabled. */ + username: string; + /** ElastiCache cache name used as the hostname in the presigned URL. */ + iamAuthCacheName?: string; +} + +/** + * Generate a SigV4 pre-signed IAM auth token for ElastiCache Redis. + * + * Delegates to the generic {@link generatePresignedToken} with ElastiCache-specific + * parameters (`service='elasticache'`, `Action='connect'`). + * + * @param config - ElastiCache endpoint and IAM user details. + * @param logger - Logger with at least a `debug` method for tracing token generation. + * @returns The signed token string (protocol prefix stripped) to pass as the Redis `AUTH` password. + */ +export async function generateIamAuthToken( + config: IamRedisConfig, + logger: ServiceLogger, +): Promise { + const cacheName = config.iamAuthCacheName || 'i-cannot-be-empty'; + + logger.debug( + 'Generating SigV4 token (service=elasticache, hostname=%s, region=%s)', + cacheName, + config.awsRegion, + ); + + const token = await generatePresignedToken({ + service: 'elasticache', + region: config.awsRegion, + hostname: cacheName, + query: { + Action: 'connect', + User: config.username, + }, + }); + + logger.debug('Generated SigV4 token (service=elasticache, length=%s)', token.length); + return token; +} + +/** + * Re-authenticate active Redis connections with a fresh IAM token. + * + * In **cluster mode**, it iterates over every node returned by + * `nodes('all')`, issues `AUTH` for each, and updates each + * node's password only after successful authentication. Individual node + * failures are logged as warnings but do not abort the loop. + * + * It also updates `options.redisOptions.password` when present so new + * cluster connections use the refreshed token. + * + * In **standalone mode**, it issues a single `AUTH` command, then updates + * the client's stored password when the options object is available. + * + * @param redis - The active ioredis client (standalone or cluster). + * @param token - The fresh SigV4 IAM auth token. + * @param username - The ElastiCache IAM username. + * @param logger - Logger with `debug` and `warn` methods. + */ +export async function refreshIamAuth( + redis: RedisInstance, + token: string, + username: string, + logger: ServiceLogger, +): Promise { + if (redis instanceof RedisCluster) { + const nodes = redis.nodes('all'); + logger.debug( + 'Refreshing IAM token (service=elasticache) — re-authenticating %s cluster node(s)', + nodes.length, + ); + for (const node of nodes) { + node.options.password = token; + try { + await node.call('AUTH', username, token); + } catch (err) { + logger.warn('Failed to re-AUTH cluster node (service=elasticache, error=%s)', err); + } + } + // Update cluster-level redisOptions for new node connections. + if (redis.options?.redisOptions) { + redis.options.redisOptions.password = token; + } + } else { + redis.options.password = token; + await redis.call('AUTH', username, token); + } + logger.debug('IAM token refreshed successfully (service=elasticache)'); +} + +/** + * Start periodic IAM token rotation for an existing Redis connection. + * + * On each tick, generates a fresh SigV4 token via {@link generateIamAuthToken} + * and re-authenticates with {@link refreshIamAuth}. The refresh interval is + * `(900 - 180) = 720 s` (~12 minutes) plus 0-30 s jitter. + * + * Errors are retried up to 3 times with linear backoff. If all retries are + * exhausted, an error is logged but the timer keeps running for the next cycle. + * + * @param redis - The active ioredis client to re-authenticate. + * @param config - ElastiCache endpoint and IAM user details. + * @param logger - Logger with `debug`, `warn`, and `error` methods. + * @returns A cleanup function - call it during graceful shutdown to stop the + * refresh loop and prevent in-flight callbacks from executing on closed connections. + */ +export function startIamTokenRefresh( + redis: RedisInstance, + config: IamRedisConfig, + logger: ServiceLogger, +): () => void { + logger.debug('Starting ElastiCache IAM token refresh timer (region=%s)', config.awsRegion); + + return startTokenRefreshTimer( + async (attempt, maxRetries) => { + try { + const token = await generateIamAuthToken(config, logger); + await refreshIamAuth(redis, token, config.username, logger); + } catch (error) { + logger.error( + 'ElastiCache IAM token refresh failed (attempt=%s/%s, error=%s)', + attempt, + maxRetries, + error, + ); + if (attempt >= maxRetries) { + logger.error('ElastiCache IAM token refresh exhausted all retries'); + } + throw error; + } + }, + { + backoffRefreshSeconds: ELASTICACHE_BACKOFF_REFRESH_SECONDS, + }, + ); +} + +/** + * Resolve Redis credentials, returns a static password or generates an IAM token. + * + * When `iamConfig` is provided, generates a SigV4 token via + * {@link generateIamAuthToken} and returns it as the password alongside the + * IAM username. Otherwise returns the static password with no username. + * + * @param staticPassword - The fallback password used when IAM is not configured. + * @param logger - Logger with at least a `debug` method. + * @param iamConfig - ElastiCache IAM config. When `undefined`, static password is used. + * @returns An object with `password` (always present) and `username` (only when IAM is active). + */ +export async function resolveRedisCredentials( + staticPassword: string, + logger: ServiceLogger, + iamConfig?: IamRedisConfig, +): Promise<{ password: string; username?: string }> { + if (!iamConfig) { + return { password: staticPassword }; + } + + const token = await generateIamAuthToken(iamConfig, logger); + return { password: token, username: iamConfig.username }; +} diff --git a/packages/services/service-common/src/index.ts b/packages/services/service-common/src/index.ts index fef51b42213..e58aca02ca0 100644 --- a/packages/services/service-common/src/index.ts +++ b/packages/services/service-common/src/index.ts @@ -10,5 +10,31 @@ export { registerShutdown } from './graceful-shutdown'; export { cleanRequestId, maskToken } from './helpers'; export { sentryInit } from './sentry'; export { scrubBasicAuth } from './scrub'; +export { + generatePresignedToken, + startTokenRefreshTimer, + type PresignedTokenConfig, + type TokenRefreshTimerOptions, +} from './iam-aws'; +export { + generateIamAuthToken, + refreshIamAuth, + resolveRedisCredentials, + startIamTokenRefresh, + type IamRedisConfig, +} from './iam-redis'; export { createMskIamTokenProvider } from './iam-msk'; export { invariant } from './helpers'; +export { + parseRedisConfigFromEnvironment, + RedisModel, + type RedisEnvironment, + type ParseRedisConfigFromEnvironmentResult, + type RedisConfig, +} from './redis-config'; +export { + createRedisClient, + type Redis, + type RedisConnectionConfig, + type RedisClientOptions, +} from './redis-client'; diff --git a/packages/services/service-common/src/redis-client.spec.ts b/packages/services/service-common/src/redis-client.spec.ts new file mode 100644 index 00000000000..616ac4e1bb7 --- /dev/null +++ b/packages/services/service-common/src/redis-client.spec.ts @@ -0,0 +1,761 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { RedisConfig } from './redis-config'; + +/** Mock logger matching ServiceLogger interface */ +type MockServiceLogger = { + info: ReturnType; + warn: ReturnType; + error: ReturnType; + debug: ReturnType; + child: (bindings?: any) => MockServiceLogger; +}; + +function createMockLogger(): MockServiceLogger { + return { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + child: vi.fn(function (this: MockServiceLogger) { + return this; + }), + }; +} + +// Prevent actual Redis connections during tests +vi.mock('ioredis', async () => { + const EventEmitter = (await import('node:events')).EventEmitter; + + class MockRedis extends EventEmitter { + options: any; + constructor(opts?: any) { + super(); + this.options = opts ?? {}; + } + connect() { + return Promise.resolve(); + } + disconnect() {} + quit() { + return Promise.resolve('OK'); + } + } + + class MockCluster extends EventEmitter { + options: any; + startupNodes: any; + isCluster = true; + constructor(startupNodes: any, opts?: any) { + super(); + this.startupNodes = startupNodes; + this.options = opts ?? {}; + } + connect() { + return Promise.resolve(); + } + disconnect() {} + quit() { + return Promise.resolve('OK'); + } + } + + (MockRedis as any).Cluster = MockCluster; + + return { + default: MockRedis, + __esModule: true, + }; +}); + +const resolveRedisCredentialsMock = vi.fn(); +const startIamTokenRefreshMock = vi.fn(); + +vi.mock('./iam-redis', () => { + return { + resolveRedisCredentials: resolveRedisCredentialsMock, + startIamTokenRefresh: startIamTokenRefreshMock, + }; +}); + +const baseEnvConfig: RedisConfig = { + host: 'localhost', + port: 6379, + password: 'test-password', + username: 'default', + tlsEnabled: false, + clusterModeEnabled: false, + awsIamAuthEnabled: false, + awsRegion: 'us-east-1', + awsIamAuthCacheName: 'test-cache', +}; + +describe('createRedisClient', () => { + let createRedisClient: (config: RedisConfig, options: any) => Promise; + + beforeEach(async () => { + vi.clearAllMocks(); + resolveRedisCredentialsMock.mockResolvedValue({ + password: 'resolved-password', + username: 'resolved-username', + }); + const mod = await import('./redis-client'); + createRedisClient = mod.createRedisClient; + }); + + describe('standalone mode with static auth', () => { + it('creates a standalone Redis instance with static credentials', async () => { + const logger = createMockLogger(); + const redis = await createRedisClient(baseEnvConfig, { + logger, + }); + + expect(resolveRedisCredentialsMock).toHaveBeenCalledWith( + 'test-password', + expect.any(Object), + undefined, + ); + expect(redis).toBeDefined(); + expect(redis.options).toMatchObject({ + host: 'localhost', + port: 6379, + password: 'resolved-password', + username: 'resolved-username', + db: 0, + maxRetriesPerRequest: null, + enableReadyCheck: false, + }); + }); + + it('sets TLS options when tlsEnabled is true', async () => { + const logger = createMockLogger(); + const config = { ...baseEnvConfig, tlsEnabled: true }; + const redis = await createRedisClient(config, { logger }); + + expect(redis.options.tls).toEqual({}); + }); + + it('does not set TLS when tlsEnabled is false', async () => { + const logger = createMockLogger(); + const redis = await createRedisClient(baseEnvConfig, { logger }); + + expect(redis.options.tls).toBeUndefined(); + }); + + it('includes retryStrategy that caps at 2000ms', async () => { + const logger = createMockLogger(); + const redis = await createRedisClient(baseEnvConfig, { logger }); + + const strategy = redis.options.retryStrategy; + expect(strategy).toBeDefined(); + expect(strategy(1)).toBe(500); + expect(strategy(3)).toBe(1500); + expect(strategy(5)).toBe(2000); + expect(strategy(100)).toBe(2000); + }); + + it('reconnectOnError logs and returns 1', async () => { + const logger = createMockLogger(); + const redis = await createRedisClient(baseEnvConfig, { logger }); + + const handler = redis.options.reconnectOnError; + expect(handler).toBeDefined(); + const result = handler(new Error('READONLY')); + expect(result).toBe(1); + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining('reconnectOnError'), + expect.any(Error), + ); + }); + + it('defaults maxRetriesPerRequest to null', async () => { + const logger = createMockLogger(); + const redis = await createRedisClient(baseEnvConfig, { + logger, + }); + + expect(redis.options.maxRetriesPerRequest).toBeNull(); + }); + + it('allows overriding maxRetriesPerRequest via options', async () => { + const logger = createMockLogger(); + const redis = await createRedisClient(baseEnvConfig, { + logger, + maxRetriesPerRequest: 20, + }); + + expect(redis.options.maxRetriesPerRequest).toBe(20); + }); + + it('attaches connection lifecycle event listeners', async () => { + const logger = createMockLogger(); + const redis = await createRedisClient(baseEnvConfig, { logger }); + + redis.emit('error', new Error('connection refused')); + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining('Redis connection error'), + expect.any(Error), + ); + + redis.emit('connect'); + expect(logger.debug).toHaveBeenCalledWith( + expect.stringContaining('Redis connection established'), + ); + + redis.emit('ready'); + expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('Redis connection ready')); + + redis.emit('close'); + expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('Redis connection closed')); + + redis.emit('reconnecting', 500); + expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('Redis reconnecting'), 500); + }); + }); + + describe('cluster mode with static auth', () => { + it('creates a Redis.Cluster instance when clusterModeEnabled is true', async () => { + const logger = createMockLogger(); + const config = { ...baseEnvConfig, clusterModeEnabled: true }; + const redis = await createRedisClient(config, { logger }); + + expect(redis).toBeDefined(); + expect((redis as any).isCluster).toBe(true); + }); + + it('passes startup nodes with host and port', async () => { + const logger = createMockLogger(); + const config = { + ...baseEnvConfig, + host: 'clustercfg.my-cache.use1.cache.amazonaws.com', + port: 6190, + clusterModeEnabled: true, + }; + const redis = await createRedisClient(config, { logger }); + + expect((redis as any).startupNodes).toEqual([ + { host: 'clustercfg.my-cache.use1.cache.amazonaws.com', port: 6190 }, + ]); + }); + + it('configures dnsLookup to bypass DNS resolution', async () => { + const logger = createMockLogger(); + const config = { ...baseEnvConfig, clusterModeEnabled: true }; + const redis = await createRedisClient(config, { logger }); + + const { dnsLookup } = (redis as any).options; + expect(dnsLookup).toBeDefined(); + + const callback = vi.fn(); + dnsLookup('10.0.0.1', callback); + expect(callback).toHaveBeenCalledWith(null, '10.0.0.1'); + }); + + it('nests password, username, and connection options inside redisOptions', async () => { + const logger = createMockLogger(); + const config = { ...baseEnvConfig, clusterModeEnabled: true }; + const redis = await createRedisClient(config, { logger }); + + const { redisOptions } = (redis as any).options; + expect(redisOptions).toMatchObject({ + username: 'resolved-username', + password: 'resolved-password', + maxRetriesPerRequest: null, + enableReadyCheck: false, + }); + }); + + it('sets TLS inside redisOptions when tlsEnabled is true', async () => { + const logger = createMockLogger(); + const config = { ...baseEnvConfig, tlsEnabled: true, clusterModeEnabled: true }; + const redis = await createRedisClient(config, { logger }); + + expect((redis as any).options.redisOptions.tls).toEqual({}); + }); + + it('omits TLS inside redisOptions when tlsEnabled is false', async () => { + const logger = createMockLogger(); + const config = { ...baseEnvConfig, clusterModeEnabled: true }; + const redis = await createRedisClient(config, { logger }); + + expect((redis as any).options.redisOptions.tls).toBeUndefined(); + }); + + it('includes clusterRetryStrategy that caps at 2000ms', async () => { + const logger = createMockLogger(); + const config = { ...baseEnvConfig, clusterModeEnabled: true }; + const redis = await createRedisClient(config, { logger }); + + const strategy = (redis as any).options.clusterRetryStrategy; + expect(strategy).toBeDefined(); + expect(strategy(1)).toBe(500); + expect(strategy(3)).toBe(1500); + expect(strategy(5)).toBe(2000); + expect(strategy(100)).toBe(2000); + }); + + it('includes reconnectOnError inside redisOptions that logs and returns 1', async () => { + const logger = createMockLogger(); + const config = { ...baseEnvConfig, clusterModeEnabled: true }; + const redis = await createRedisClient(config, { logger }); + + const handler = (redis as any).options.redisOptions.reconnectOnError; + expect(handler).toBeDefined(); + expect(handler(new Error('MOVED'))).toBe(1); + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining('reconnectOnError'), + expect.any(Error), + ); + }); + + it('defaults maxRetriesPerRequest to null in cluster redisOptions', async () => { + const logger = createMockLogger(); + const config = { ...baseEnvConfig, clusterModeEnabled: true }; + const redis = await createRedisClient(config, { logger }); + + expect((redis as any).options.redisOptions.maxRetriesPerRequest).toBeNull(); + }); + + it('allows overriding maxRetriesPerRequest in cluster redisOptions', async () => { + const logger = createMockLogger(); + const config = { ...baseEnvConfig, clusterModeEnabled: true }; + const redis = await createRedisClient(config, { + logger, + maxRetriesPerRequest: 20, + }); + + expect((redis as any).options.redisOptions.maxRetriesPerRequest).toBe(20); + }); + }); + + describe('IAM-based authentication', () => { + it('derives redisIamConfig from environment config when IAM is enabled', async () => { + const logger = createMockLogger(); + const config = { + ...baseEnvConfig, + awsIamAuthEnabled: true, + username: 'custom-user', + }; + const redis = await createRedisClient(config, { logger }); + + expect(resolveRedisCredentialsMock).toHaveBeenCalledWith( + 'test-password', + expect.any(Object), + { + host: 'localhost', + port: 6379, + awsRegion: 'us-east-1', + username: 'custom-user', + iamAuthCacheName: 'test-cache', + }, + ); + expect(redis.options.password).toBe('resolved-password'); + expect(redis.options.username).toBe('resolved-username'); + }); + + it('defaults username to "default" when not provided in IAM config', async () => { + const logger = createMockLogger(); + const config = { + ...baseEnvConfig, + awsIamAuthEnabled: true, + username: undefined, + }; + await createRedisClient(config, { logger }); + + expect(resolveRedisCredentialsMock).toHaveBeenCalledWith( + 'test-password', + expect.any(Object), + expect.objectContaining({ + username: 'default', + }), + ); + }); + + it('starts IAM token refresh when IAM is enabled', async () => { + const logger = createMockLogger(); + const config = { + ...baseEnvConfig, + awsIamAuthEnabled: true, + }; + const redis = await createRedisClient(config, { logger }); + + expect(startIamTokenRefreshMock).toHaveBeenCalledWith( + redis, + { + host: 'localhost', + port: 6379, + awsRegion: 'us-east-1', + username: 'default', + iamAuthCacheName: 'test-cache', + }, + expect.any(Object), + ); + }); + + it('does NOT start IAM token refresh when IAM is disabled', async () => { + const logger = createMockLogger(); + const config = { + ...baseEnvConfig, + awsIamAuthEnabled: false, + }; + await createRedisClient(config, { logger }); + + expect(startIamTokenRefreshMock).not.toHaveBeenCalled(); + }); + + it('respects clusterModeEnabled flag when starting IAM token refresh', async () => { + const logger = createMockLogger(); + const config = { + ...baseEnvConfig, + awsIamAuthEnabled: true, + clusterModeEnabled: true, + }; + const redis = await createRedisClient(config, { logger }); + + expect(startIamTokenRefreshMock).toHaveBeenCalledWith( + redis, + expect.any(Object), + expect.any(Object), + ); + }); + }); + + describe('two-client pattern (server/workflows use case)', () => { + it('supports calling createRedisClient twice with independent IAM token refreshes', async () => { + const logger = createMockLogger(); + const mainLogger = logger.child({ label: 'main' }); + const subscriberLogger = logger.child({ label: 'subscriber' }); + + const config = { + ...baseEnvConfig, + awsIamAuthEnabled: true, + }; + + const mainRedis = await createRedisClient(config, { + logger: mainLogger, + }); + + const subscriberRedis = await createRedisClient(config, { + logger: subscriberLogger, + }); + + // Both clients should be distinct instances + expect(mainRedis).not.toBe(subscriberRedis); + + // Both should have resolved credentials + expect(mainRedis.options.password).toBe('resolved-password'); + expect(subscriberRedis.options.password).toBe('resolved-password'); + + // resolveRedisCredentials should be called twice (once per client) + // because each client is independent + expect(resolveRedisCredentialsMock).toHaveBeenCalledTimes(2); + + // startIamTokenRefresh should be called twice (once per client) + // each client has independent token lifecycle + expect(startIamTokenRefreshMock).toHaveBeenCalledTimes(2); + + const firstCall = startIamTokenRefreshMock.mock.calls[0]; + const secondCall = startIamTokenRefreshMock.mock.calls[1]; + + expect(firstCall[0]).toBe(mainRedis); + expect(secondCall[0]).toBe(subscriberRedis); + }); + + it('each client call resolves credentials independently', async () => { + const logger = createMockLogger(); + const config = { + ...baseEnvConfig, + awsIamAuthEnabled: true, + }; + + resolveRedisCredentialsMock + .mockResolvedValueOnce({ password: 'token-1', username: 'iam-1' }) + .mockResolvedValueOnce({ password: 'token-2', username: 'iam-2' }); + + const mainRedis = await createRedisClient(config, { logger }); + const subscriberRedis = await createRedisClient(config, { logger }); + + expect(mainRedis.options.password).toBe('token-1'); + expect(mainRedis.options.username).toBe('iam-1'); + + expect(subscriberRedis.options.password).toBe('token-2'); + expect(subscriberRedis.options.username).toBe('iam-2'); + }); + }); + + describe('options handling', () => { + it('accepts logger typed as ServiceLogger', async () => { + const logger = createMockLogger(); + const redis = await createRedisClient(baseEnvConfig, { + logger, + }); + + expect(redis).toBeDefined(); + }); + + it('accepts optional maxRetriesPerRequest override', async () => { + const logger = createMockLogger(); + const redis = await createRedisClient(baseEnvConfig, { + logger, + maxRetriesPerRequest: 25, + }); + + expect(redis.options.maxRetriesPerRequest).toBe(25); + }); + + it('uses main logger for IAM refresh logs', async () => { + const logger = createMockLogger(); + const config = { + ...baseEnvConfig, + awsIamAuthEnabled: true, + }; + const redis = await createRedisClient(config, { + logger, + }); + + expect(startIamTokenRefreshMock).toHaveBeenCalledWith(redis, expect.any(Object), logger); + }); + + it('supports creating a client without a label argument', async () => { + const logger = createMockLogger(); + const redis = await createRedisClient(baseEnvConfig, { + logger, + }); + + redis.emit('ready'); + expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('Redis connection ready')); + }); + }); + + describe('unhappy paths (error handling)', () => { + it('rejects when resolveRedisCredentials throws', async () => { + const logger = createMockLogger(); + const credentialsError = new Error('Failed to fetch AWS credentials'); + resolveRedisCredentialsMock.mockRejectedValueOnce(credentialsError); + + await expect(createRedisClient(baseEnvConfig, { logger })).rejects.toThrow( + 'Failed to fetch AWS credentials', + ); + + expect(logger.error).not.toHaveBeenCalled(); + }); + + it('handles IAM auth with credential resolution failure', async () => { + const logger = createMockLogger(); + const credentialsError = new Error('Invalid IAM credentials'); + resolveRedisCredentialsMock.mockRejectedValueOnce(credentialsError); + + const config = { + ...baseEnvConfig, + awsIamAuthEnabled: true, + }; + + await expect(createRedisClient(config, { logger })).rejects.toThrow( + 'Invalid IAM credentials', + ); + }); + + it('rejects when startIamTokenRefresh throws', async () => { + const logger = createMockLogger(); + const refreshError = new Error('Failed to start IAM token refresh'); + startIamTokenRefreshMock.mockImplementationOnce(() => { + throw refreshError; + }); + + const config = { + ...baseEnvConfig, + awsIamAuthEnabled: true, + }; + + await expect(createRedisClient(config, { logger })).rejects.toThrow( + 'Failed to start IAM token refresh', + ); + }); + + it('handles null/undefined logger gracefully when logging connection events', async () => { + // This tests that the factory doesn't assume logger is present for all operations + const logger = createMockLogger(); + const redis = await createRedisClient(baseEnvConfig, { logger }); + + // Emit error event - should not crash even if logger is weak + expect(() => { + redis.emit('error', new Error('Connection timeout')); + }).not.toThrow(); + + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining('Redis connection error'), + expect.any(Error), + ); + }); + + it('handles reconnectOnError with non-retryable errors', async () => { + const logger = createMockLogger(); + const redis = await createRedisClient(baseEnvConfig, { logger }); + + const handler = redis.options.reconnectOnError; + const result = handler(new Error('NOAUTH')); + + // Should return 1 (retry) even for auth errors - decision is to retry + expect(result).toBe(1); + }); + + it('handles reconnectOnError with connection refused', async () => { + const logger = createMockLogger(); + const redis = await createRedisClient(baseEnvConfig, { logger }); + + const handler = redis.options.reconnectOnError; + const result = handler(new Error('ECONNREFUSED')); + + expect(result).toBe(1); + expect(logger.warn).toHaveBeenCalled(); + }); + + it('handles cluster mode with connection errors', async () => { + const logger = createMockLogger(); + const config = { ...baseEnvConfig, clusterModeEnabled: true }; + const redis = await createRedisClient(config, { logger }); + + const clusterHandler = (redis as any).options.redisOptions.reconnectOnError; + const result = clusterHandler(new Error('CLUSTERDOWN')); + + expect(result).toBe(1); + }); + + it('does not call startIamTokenRefresh if IAM config is invalid but awsIamAuthEnabled is false', async () => { + const logger = createMockLogger(); + const config = { + ...baseEnvConfig, + awsIamAuthEnabled: false, + awsRegion: '', // Empty region should not trigger IAM + }; + + await createRedisClient(config, { logger }); + + expect(startIamTokenRefreshMock).not.toHaveBeenCalled(); + }); + + it('handles config with empty host gracefully', async () => { + const logger = createMockLogger(); + const config = { + ...baseEnvConfig, + host: '', // Empty host - should still create client + }; + + const redis = await createRedisClient(config, { logger }); + + expect(redis).toBeDefined(); + expect(redis.options.host).toBe(''); + }); + + it('handles config with invalid port (0)', async () => { + const logger = createMockLogger(); + const config = { + ...baseEnvConfig, + port: 0, // Invalid port + }; + + const redis = await createRedisClient(config, { logger }); + + // Client still created - port validation happens at connection time + expect(redis).toBeDefined(); + expect(redis.options.port).toBe(0); + }); + + it('handles concurrent credential resolution failures independently', async () => { + const logger = createMockLogger(); + const error1 = new Error('Error 1: Credentials expired'); + const error2 = new Error('Error 2: IAM role missing'); + + resolveRedisCredentialsMock.mockRejectedValueOnce(error1).mockRejectedValueOnce(error2); + + const config = { + ...baseEnvConfig, + awsIamAuthEnabled: true, + }; + + const promise1 = createRedisClient(config, { logger: logger.child() }); + const promise2 = createRedisClient(config, { logger: logger.child() }); + + await expect(promise1).rejects.toThrow('Error 1'); + await expect(promise2).rejects.toThrow('Error 2'); + + // Each call should have tried to resolve credentials independently + expect(resolveRedisCredentialsMock).toHaveBeenCalledTimes(2); + }); + + it('handles maxRetriesPerRequest with invalid value (negative)', async () => { + const logger = createMockLogger(); + const redis = await createRedisClient(baseEnvConfig, { + logger, + maxRetriesPerRequest: -1, // Invalid but passed through + }); + + // Factory doesn't validate - that's up to ioredis + expect(redis.options.maxRetriesPerRequest).toBe(-1); + }); + + it('handles cluster mode with credential resolution failure', async () => { + const logger = createMockLogger(); + const credentialsError = new Error('Cluster credentials unavailable'); + resolveRedisCredentialsMock.mockRejectedValueOnce(credentialsError); + + const config = { + ...baseEnvConfig, + clusterModeEnabled: true, + }; + + await expect(createRedisClient(config, { logger })).rejects.toThrow( + 'Cluster credentials unavailable', + ); + }); + + it('handles IAM token refresh error with cluster mode', async () => { + const logger = createMockLogger(); + const refreshError = new Error('Cluster IAM refresh failed'); + startIamTokenRefreshMock.mockImplementationOnce(() => { + throw refreshError; + }); + + const config = { + ...baseEnvConfig, + awsIamAuthEnabled: true, + clusterModeEnabled: true, + }; + + await expect(createRedisClient(config, { logger })).rejects.toThrow( + 'Cluster IAM refresh failed', + ); + }); + + it('logs reconnecting event with delay', async () => { + const logger = createMockLogger(); + const redis = await createRedisClient(baseEnvConfig, { logger }); + + redis.emit('reconnecting', 1500); + + expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('Redis reconnecting'), 1500); + }); + + it('handles multiple error events in sequence', async () => { + const logger = createMockLogger(); + const redis = await createRedisClient(baseEnvConfig, { logger }); + + const error1 = new Error('Connection failed'); + const error2 = new Error('Timeout'); + + redis.emit('error', error1); + redis.emit('error', error2); + + expect(logger.error).toHaveBeenCalledTimes(2); + expect(logger.error).toHaveBeenNthCalledWith( + 1, + expect.stringContaining('Redis connection error'), + error1, + ); + expect(logger.error).toHaveBeenNthCalledWith( + 2, + expect.stringContaining('Redis connection error'), + error2, + ); + }); + }); +}); diff --git a/packages/services/service-common/src/redis-client.ts b/packages/services/service-common/src/redis-client.ts new file mode 100644 index 00000000000..46bce572c1a --- /dev/null +++ b/packages/services/service-common/src/redis-client.ts @@ -0,0 +1,174 @@ +import type { RedisOptions } from 'ioredis'; +import IORedis from 'ioredis'; +import type { FastifyBaseLogger as ServiceLogger } from './fastify'; +import { resolveRedisCredentials, startIamTokenRefresh, type IamRedisConfig } from './iam-redis'; +import type { RedisConfig } from './redis-config'; + +type RedisInstance = IORedis; + +/* + * Re-exported ioredis `Redis` type for consumers that need to type a Redis client instance. + */ +export type Redis = IORedis; + +/** + * Connection configuration for creating a Redis client. + */ +export type RedisConnectionConfig = Required> & { + /** Whether to enable TLS for the Redis connection. */ + tlsEnabled: boolean; + /** Optional Redis username for ACL-based authentication. */ + username?: string; + /** When `true`, creates a `Redis.Cluster` client instead of a standalone client. */ + clusterModeEnabled?: boolean; + /** + * Maximum number of times a command will be retried before throwing. + * Defaults to `null` (unlimited retries) when omitted. + * Set to a positive number (e.g. `20`) for services that prefer bounded retries. + */ + maxRetriesPerRequest?: number | null; +}; + +/** + * Options for creating a Redis client. + */ +export type RedisClientOptions = { + /** Logger instance for connection lifecycle events and errors. */ + logger: ServiceLogger; + /** + * Maximum number of times a command will be retried before throwing. + * Defaults to `null` (unlimited retries) when omitted. + */ + maxRetriesPerRequest?: number | null; +}; + +/** + * Creates and configures a Redis client (standalone or cluster) with authentication, + * retry strategies, reconnect-on-error handling, and lifecycle event logging. + * + * This function is the single public entry point for creating Redis clients across all services. + * Each call to this function is fully independent: + * - Credentials are resolved fresh via IAM or static password + * - IAM token refresh (if enabled) is started per client with its own lifecycle + * + * @param config - Redis environment configuration (host, port, credentials, TLS, IAM settings). + * @param options - Configuration options including logger and retry configuration. + * @returns A Promise that resolves to a configured, ready-to-use Redis client instance. + */ +export async function createRedisClient( + config: RedisConfig, + options: RedisClientOptions, +): Promise { + // Derive IAM configuration from environment config if IAM is enabled + const redisIamConfig: IamRedisConfig | undefined = config.awsIamAuthEnabled + ? { + host: config.host, + port: config.port, + awsRegion: config.awsRegion ?? '', + username: config.username ?? 'default', + iamAuthCacheName: config.awsIamAuthCacheName, + } + : undefined; + + // Resolve credentials (IAM tokens or static password) + const { password, username: resolvedUsername } = await resolveRedisCredentials( + config.password, + options.logger, + redisIamConfig, + ); + + // Create the Redis client instance with resolved credentials + const redis = instantiateRedisClient( + { + host: config.host, + port: config.port, + password, + username: resolvedUsername, + tlsEnabled: config.tlsEnabled, + clusterModeEnabled: config.clusterModeEnabled, + maxRetriesPerRequest: options.maxRetriesPerRequest, + }, + options.logger, + ); + + // Start IAM token refresh if enabled + // Each client gets its own independent token lifecycle + if (redisIamConfig) { + startIamTokenRefresh(redis, redisIamConfig, options.logger); + } + + return redis; +} + +/** + * Creates and configures a Redis client instance with retry strategies, + * reconnect-on-error handling, and lifecycle event logging. + * + * This is an internal helper function. Use createRedisClient() as the public entry point. + * + * @param config - Redis connection configuration. + * @param logger - Logger instance for connection lifecycle events. + * @returns A configured, ready-to-use Redis client instance. + */ +function instantiateRedisClient(config: RedisConnectionConfig, logger: ServiceLogger) { + const maxRetriesPerRequest = config.maxRetriesPerRequest ?? null; + + let redis: RedisInstance; + if (config.clusterModeEnabled) { + redis = new IORedis.Cluster([{ host: config.host, port: config.port }], { + dnsLookup: (address, callback) => callback(null, address), + redisOptions: { + username: config.username, + password: config.password, + reconnectOnError(error) { + logger.warn('Redis reconnectOnError (error=%s)', error); + return 1; + }, + maxRetriesPerRequest, + enableReadyCheck: false, + tls: config.tlsEnabled ? {} : undefined, + }, + clusterRetryStrategy: (times: number) => Math.min(times * 500, 2000), + }) as unknown as RedisInstance; + } else { + redis = new IORedis({ + host: config.host, + port: config.port, + password: config.password, + username: config.username, + retryStrategy(times) { + return Math.min(times * 500, 2000); + }, + reconnectOnError(error) { + logger.warn('Redis reconnectOnError (error=%s)', error); + return 1; + }, + db: 0, + maxRetriesPerRequest, + enableReadyCheck: false, + tls: config.tlsEnabled ? {} : undefined, + }); + } + + redis.on('error', err => { + logger.error('Redis connection error (error=%s)', err); + }); + + redis.on('connect', () => { + logger.debug('Redis connection established'); + }); + + redis.on('ready', () => { + logger.info('Redis connection ready'); + }); + + redis.on('close', () => { + logger.info('Redis connection closed'); + }); + + redis.on('reconnecting', (timeToReconnect?: number) => { + logger.info('Redis reconnecting in %s', timeToReconnect); + }); + + return redis; +} diff --git a/packages/services/service-common/src/redis-config.spec.ts b/packages/services/service-common/src/redis-config.spec.ts new file mode 100644 index 00000000000..734b44f1106 --- /dev/null +++ b/packages/services/service-common/src/redis-config.spec.ts @@ -0,0 +1,162 @@ +import { describe, expect, it } from 'vitest'; +import { parseRedisConfigFromEnvironment } from './redis-config'; + +describe('parseRedisConfigFromEnvironment', () => { + it('returns ok for static auth config', () => { + const result = parseRedisConfigFromEnvironment({ + REDIS_HOST: 'localhost', + REDIS_PORT: '6379', + REDIS_PASSWORD: 'secret', + REDIS_AWS_IAM_AUTH_ENABLED: '0', + }); + + expect(result.type).toBe('ok'); + if (result.type === 'ok') { + expect(result.config).toMatchObject({ + host: 'localhost', + port: 6379, + password: 'secret', + awsIamAuthEnabled: false, + }); + } + }); + + it('returns ok for valid iam config', () => { + const result = parseRedisConfigFromEnvironment({ + REDIS_HOST: 'cache.aws', + REDIS_PORT: '6379', + REDIS_TLS_ENABLED: '1', + REDIS_AWS_IAM_AUTH_ENABLED: '1', + REDIS_AWS_IAM_CACHE_NAME: 'my-cache', + REDIS_AWS_REGION: 'us-east-1', + }); + + expect(result.type).toBe('ok'); + if (result.type === 'ok') { + expect(result.config).toMatchObject({ + awsIamAuthEnabled: true, + tlsEnabled: true, + awsRegion: 'us-east-1', + awsIamAuthCacheName: 'my-cache', + }); + } + }); + + it('returns error when iam enabled and tls disabled', () => { + const result = parseRedisConfigFromEnvironment({ + REDIS_HOST: 'cache.aws', + REDIS_PORT: '6379', + REDIS_TLS_ENABLED: '0', + REDIS_AWS_IAM_AUTH_ENABLED: '1', + REDIS_AWS_IAM_CACHE_NAME: 'my-cache', + REDIS_AWS_REGION: 'us-east-1', + }); + + expect(result.type).toBe('error'); + if (result.type === 'error') { + expect(result.errors[0]).toContain( + 'REDIS_TLS_ENABLED must be enabled (ElastiCache IAM requires TLS)', + ); + } + }); + + it('returns error when iam enabled and cache name missing', () => { + const result = parseRedisConfigFromEnvironment({ + REDIS_HOST: 'cache.aws', + REDIS_PORT: '6379', + REDIS_TLS_ENABLED: '1', + REDIS_AWS_IAM_AUTH_ENABLED: '1', + REDIS_AWS_REGION: 'us-east-1', + }); + + expect(result.type).toBe('error'); + if (result.type === 'error') { + expect(result.errors[0]).toContain('REDIS_AWS_IAM_CACHE_NAME'); + } + }); + + it('returns error when iam enabled and both regions missing', () => { + const result = parseRedisConfigFromEnvironment({ + REDIS_HOST: 'cache.aws', + REDIS_PORT: '6379', + REDIS_TLS_ENABLED: '1', + REDIS_AWS_IAM_AUTH_ENABLED: '1', + REDIS_AWS_IAM_CACHE_NAME: 'my-cache', + }); + + expect(result.type).toBe('error'); + if (result.type === 'error') { + expect(result.errors[0]).toContain('REDIS_AWS_REGION or AWS_REGION'); + } + }); + + it('uses AWS_REGION fallback when REDIS_AWS_REGION is missing', () => { + const result = parseRedisConfigFromEnvironment( + { + REDIS_HOST: 'cache.aws', + REDIS_PORT: '6379', + REDIS_TLS_ENABLED: '1', + REDIS_AWS_IAM_AUTH_ENABLED: '1', + REDIS_AWS_IAM_CACHE_NAME: 'my-cache', + }, + 'eu-west-1', + ); + + expect(result.type).toBe('ok'); + if (result.type === 'ok') { + expect(result.config.awsRegion).toBe('eu-west-1'); + } + }); + + it('normalizes empty optional fields', () => { + const result = parseRedisConfigFromEnvironment({ + REDIS_HOST: 'localhost', + REDIS_PORT: '6379', + REDIS_PASSWORD: '', + REDIS_USERNAME: '', + REDIS_AWS_IAM_AUTH_ENABLED: '0', + }); + + expect(result.type).toBe('ok'); + if (result.type === 'ok') { + expect(result.config.password).toBe(''); + expect(result.config.username).toBeUndefined(); + } + }); + + it('returns error when required REDIS_HOST is missing', () => { + const result = parseRedisConfigFromEnvironment({ + REDIS_PORT: '6379', + }); + + expect(result.type).toBe('error'); + }); + + it('returns error when REDIS_PORT is not a number', () => { + const result = parseRedisConfigFromEnvironment({ + REDIS_HOST: 'localhost', + REDIS_PORT: 'not-a-number', + }); + + expect(result.type).toBe('error'); + }); + + it('prefers REDIS_AWS_REGION over the awsRegion param', () => { + const result = parseRedisConfigFromEnvironment( + { + REDIS_HOST: 'cache.aws', + REDIS_PORT: '6379', + REDIS_TLS_ENABLED: '1', + REDIS_AWS_IAM_AUTH_ENABLED: '1', + REDIS_AWS_IAM_CACHE_NAME: 'my-cache', + REDIS_AWS_REGION: 'us-east-1', + }, + 'eu-west-1', + ); + + expect(result.type).toBe('ok'); + if (result.type === 'ok') { + expect(result.config.awsRegion).toBe('us-east-1'); + } + }); +}); diff --git a/packages/services/service-common/src/redis-config.ts b/packages/services/service-common/src/redis-config.ts new file mode 100644 index 00000000000..d4cf05f01be --- /dev/null +++ b/packages/services/service-common/src/redis-config.ts @@ -0,0 +1,134 @@ +import * as zod from 'zod'; + +// Shared helper for optional empty strings +const isNumberString = (input: unknown) => zod.string().regex(/^\d+$/).safeParse(input).success; + +const numberFromNumberOrNumberString = (input: unknown): number | undefined => { + if (typeof input == 'number') return input; + if (isNumberString(input)) return Number(input); +}; + +const NumberFromString = zod.preprocess(numberFromNumberOrNumberString, zod.number().min(1)); + +const emptyString = (input: T) => { + return zod.preprocess((value: unknown) => { + if (value === '') return undefined; + return value; + }, input); +}; + +/** + * Centralized Zod model for Redis environment variables. + * Used by all services instead of duplicating the schema. + */ +export const RedisModel = zod.object({ + REDIS_HOST: zod.string(), + REDIS_PORT: NumberFromString, + REDIS_PASSWORD: emptyString(zod.string().optional()), + REDIS_USERNAME: emptyString(zod.string().optional()), + REDIS_TLS_ENABLED: emptyString(zod.union([zod.literal('1'), zod.literal('0')]).optional()), + REDIS_CLUSTER_MODE_ENABLED: emptyString( + zod.union([zod.literal('0'), zod.literal('1')]).optional(), + ), + REDIS_AWS_REGION: emptyString(zod.string().optional()), + REDIS_AWS_IAM_AUTH_ENABLED: emptyString( + zod.union([zod.literal('0'), zod.literal('1')]).optional(), + ), + REDIS_AWS_IAM_CACHE_NAME: emptyString(zod.string().optional()), +}); + +/** + * Parsed Redis-related environment variables. Inferred from the Zod schema. + */ +export type RedisEnvironment = zod.infer; + +/** + * Normalized Redis runtime configuration consumed by service modules. + */ +export type RedisConfig = { + host: string; + port: number; + password: string; + username: string | undefined; + tlsEnabled: boolean; + clusterModeEnabled: boolean; + awsIamAuthEnabled: boolean; + awsRegion: string | undefined; + awsIamAuthCacheName: string | undefined; +}; + +/** + * Result of building Redis runtime config from environment input. + * + * `error` is returned only for invalid Redis IAM combinations; schema validation + * errors from Zod are handled by the caller. + */ +export type ParseRedisConfigFromEnvironmentResult = + | { + type: 'error'; + errors: Array; + } + | { + type: 'ok'; + config: RedisConfig; + }; + +/** + * Parses and validates Redis environment variables from process.env, then validates + * IAM requirements. Returns a discriminated union of ok (with config) or error (with messages). + */ +export function parseRedisConfigFromEnvironment( + env: NodeJS.ProcessEnv, + awsRegion?: string, +): ParseRedisConfigFromEnvironmentResult { + const parseResult = RedisModel.safeParse(env); + + if (!parseResult.success) { + return { + type: 'error', + errors: [JSON.stringify(parseResult.error.format(), null, 4)], + }; + } + + const redis = parseResult.data; + + if (redis.REDIS_AWS_IAM_AUTH_ENABLED === '1') { + const missingRedisIamVars: string[] = []; + + if (redis.REDIS_TLS_ENABLED !== '1') { + missingRedisIamVars.push('REDIS_TLS_ENABLED must be enabled (ElastiCache IAM requires TLS)'); + } + + if (!redis.REDIS_AWS_IAM_CACHE_NAME) { + missingRedisIamVars.push('REDIS_AWS_IAM_CACHE_NAME'); + } + + if (!redis.REDIS_AWS_REGION && !awsRegion) { + missingRedisIamVars.push('REDIS_AWS_REGION or AWS_REGION'); + } + + if (missingRedisIamVars.length > 0) { + return { + type: 'error', + errors: [ + `REDIS_AWS_IAM_AUTH_ENABLED is enabled but the following required variables are missing or invalid: ${missingRedisIamVars.join(', ')}`, + ], + }; + } + } + + return { + type: 'ok', + config: { + host: redis.REDIS_HOST, + port: redis.REDIS_PORT, + password: redis.REDIS_PASSWORD ?? '', + username: redis.REDIS_USERNAME, + tlsEnabled: redis.REDIS_TLS_ENABLED === '1', + clusterModeEnabled: redis.REDIS_CLUSTER_MODE_ENABLED === '1', + awsIamAuthEnabled: redis.REDIS_AWS_IAM_AUTH_ENABLED === '1', + awsRegion: redis.REDIS_AWS_REGION ?? awsRegion, + awsIamAuthCacheName: redis.REDIS_AWS_IAM_CACHE_NAME, + }, + }; +} diff --git a/packages/services/tokens/README.md b/packages/services/tokens/README.md index c90be55cfbe..961c68fe631 100644 --- a/packages/services/tokens/README.md +++ b/packages/services/tokens/README.md @@ -16,8 +16,14 @@ APIs (usage service and GraphQL API). | `POSTGRES_SSL` | No | Whether the postgres connection should be established via SSL. | `1` (enabled) or `0` (disabled) | | `REDIS_HOST` | **Yes** | The host of your redis instance. | `"127.0.0.1"` | | `REDIS_PORT` | **Yes** | The port of your redis instance. | `6379` | -| `REDIS_PASSWORD` | **Yes** | The password of your redis instance. | `"apollorocks"` | +| `REDIS_PASSWORD` | No | The password of your redis instance. | `"apollorocks"` | | `REDIS_TLS_ENABLED` | **No** | Enable TLS for redis connection (rediss://). | `"0"` | +| `REDIS_USERNAME` | No | The username for Redis Access Control List authentication. | `"default"` | +| `REDIS_CLUSTER_MODE_ENABLED` | No | Enable Redis Cluster mode. | `1` (enabled) or `0` (disabled) | +| `AWS_REGION` | No | The global AWS region for the service. Used as the default region for AWS connections. | `us-east-1` | +| `REDIS_AWS_IAM_AUTH_ENABLED` | No | Enable AWS IAM authentication for Redis (requires `AWS_REGION`). | `1` (enabled) or `0` (disabled) | +| `REDIS_AWS_REGION` | No | AWS region for IAM auth. Falls back to `AWS_REGION`. | `us-east-1` | +| `REDIS_AWS_IAM_CACHE_NAME` | No | ElastiCache cache name (required if `REDIS_AWS_IAM_AUTH_ENABLED` is `1`). | `my-cache` | | `ENVIRONMENT` | No | The environment of your Hive app. (**Note:** This will be used for Sentry reporting.) | `staging` | | `SENTRY` | No | Whether Sentry error reporting should be enabled. | `1` (enabled) or `0` (disabled) | | `SENTRY_DSN` | No | The DSN for reporting errors to Sentry. | `https://dooobars@o557896.ingest.sentry.io/12121212` | diff --git a/packages/services/tokens/package.json b/packages/services/tokens/package.json index 45d6da7cd5a..944b74b7d2f 100644 --- a/packages/services/tokens/package.json +++ b/packages/services/tokens/package.json @@ -20,7 +20,6 @@ "@types/ms": "0.7.34", "dotenv": "16.4.7", "fastify": "5.8.5", - "ioredis": "5.8.2", "lru-cache": "11.0.2", "ms": "2.1.3", "p-timeout": "6.1.4", diff --git a/packages/services/tokens/src/environment.ts b/packages/services/tokens/src/environment.ts index 147ce2a1eee..4c677718280 100644 --- a/packages/services/tokens/src/environment.ts +++ b/packages/services/tokens/src/environment.ts @@ -1,5 +1,9 @@ import zod from 'zod'; -import { OpenTelemetryConfigurationModel, resolveServerListenOptions } from '@hive/service-common'; +import { + OpenTelemetryConfigurationModel, + parseRedisConfigFromEnvironment, + resolveServerListenOptions, +} from '@hive/service-common'; const isNumberString = (input: unknown) => zod.string().regex(/^\d+$/).safeParse(input).success; @@ -18,6 +22,10 @@ const emptyString = (input: T) => { }, input); }; +function raiseInvariant(reason: string): never { + throw new Error(reason); +} + const EnvironmentModel = zod.object({ PORT: emptyString(NumberFromString.optional()), SERVER_HOST: emptyString(zod.string().optional()), @@ -25,6 +33,7 @@ const EnvironmentModel = zod.object({ ENVIRONMENT: emptyString(zod.string().optional()), RELEASE: emptyString(zod.string().optional()), HEARTBEAT_ENDPOINT: emptyString(zod.string().url().optional()), + AWS_REGION: emptyString(zod.string().optional()), }); const SentryModel = zod.union([ @@ -46,13 +55,6 @@ const PostgresModel = zod.object({ POSTGRES_SSL: emptyString(zod.union([zod.literal('1'), zod.literal('0')]).optional()), }); -const RedisModel = zod.object({ - REDIS_HOST: zod.string(), - REDIS_PORT: NumberFromString, - REDIS_PASSWORD: emptyString(zod.string().optional()), - REDIS_TLS_ENABLED: emptyString(zod.union([zod.literal('1'), zod.literal('0')]).optional()), -}); - const PrometheusModel = zod.object({ PROMETHEUS_METRICS: emptyString(zod.union([zod.literal('0'), zod.literal('1')]).optional()), PROMETHEUS_METRICS_LABEL_INSTANCE: emptyString(zod.string().optional()), @@ -85,8 +87,6 @@ const configs = { postgres: PostgresModel.safeParse(process.env), - redis: RedisModel.safeParse(process.env), - prometheus: PrometheusModel.safeParse(process.env), log: LogModel.safeParse(process.env), @@ -107,6 +107,15 @@ for (const config of Object.values(configs)) { } } +const redisConfigResult = parseRedisConfigFromEnvironment( + process.env, + configs.base.success ? configs.base.data.AWS_REGION : undefined, +); + +if (redisConfigResult.type === 'error') { + environmentErrors.push(...redisConfigResult.errors); +} + if (environmentErrors.length) { const fullError = environmentErrors.join(`\n`); console.error('❌ Invalid environment variables:', fullError); @@ -122,7 +131,6 @@ function extractConfig(config: zod.SafeParseReturnType { - server.log.error(err, 'Redis connection error'); - }); - - redis.on('connect', () => { - server.log.info('Redis connection established'); - }); - - redis.on('ready', () => { - server.log.info('Redis connection ready... '); - }); - - redis.on('close', () => { - server.log.info('Redis connection closed'); - }); - - redis.on('reconnecting', (timeToReconnect?: number) => { - server.log.info('Redis reconnecting in %s', timeToReconnect); - }); - redis.on('end', async () => { server.log.info('Redis ended - no more reconnections will be made'); await shutdown(); diff --git a/packages/services/tokens/src/multi-tier-storage.ts b/packages/services/tokens/src/multi-tier-storage.ts index 0d9ba6e7e30..e26c60df2c0 100644 --- a/packages/services/tokens/src/multi-tier-storage.ts +++ b/packages/services/tokens/src/multi-tier-storage.ts @@ -1,8 +1,8 @@ import type { FastifyBaseLogger } from 'fastify'; -import type { Redis } from 'ioredis'; import { LRUCache } from 'lru-cache'; import ms from 'ms'; import { createConnectionString, type PostgresConnectionParamaters } from '@hive/postgres'; +import type { Redis } from '@hive/service-common'; import { createTokenStorage, type Interceptor } from '@hive/storage'; import { captureException, captureMessage } from '@sentry/node'; import { atomic, until, useActionTracker } from './helpers'; diff --git a/packages/services/usage/README.md b/packages/services/usage/README.md index 4016655e46f..c9d421ed1b4 100644 --- a/packages/services/usage/README.md +++ b/packages/services/usage/README.md @@ -36,8 +36,13 @@ The data is written to a Kafka broker, form Kafka the data is feed into clickhou | `POSTGRES_PASSWORD` | No | Password for accessing the postgres database. | `postgres` | | `REDIS_HOST` | **Yes** | The host of your redis instance. | `"127.0.0.1"` | | `REDIS_PORT` | **Yes** | The port of your redis instance. | `6379` | -| `REDIS_PASSWORD` | **Yes** | The password of your redis instance. | `"apollorocks"` | +| `REDIS_PASSWORD` | No | The password of your redis instance. | `"apollorocks"` | | `REDIS_TLS_ENABLED` | **No** | Enable TLS for redis connection (rediss://). | `"0"` | +| `REDIS_USERNAME` | No | The username for Redis Access Control List authentication. | `"default"` | +| `REDIS_CLUSTER_MODE_ENABLED` | No | Enable Redis Cluster mode. | `1` (enabled) or `0` (disabled) | +| `REDIS_AWS_IAM_AUTH_ENABLED` | No | Enable AWS IAM authentication for Redis (requires `AWS_REGION`). | `1` (enabled) or `0` (disabled) | +| `REDIS_AWS_REGION` | No | AWS region for IAM auth. Falls back to `AWS_REGION`. | `us-east-1` | +| `REDIS_AWS_IAM_CACHE_NAME` | No | ElastiCache cache name (required if `REDIS_AWS_IAM_AUTH_ENABLED` is `1`). | `my-cache` | | `ENVIRONMENT` | No | The environment of your Hive app. (**Note:** This will be used for Sentry reporting.) | `staging` | | `SENTRY_DSN` | No | The DSN for reporting errors to Sentry. | `https://dooobars@o557896.ingest.sentry.io/12121212` | | `SENTRY` | No | Whether Sentry error reporting should be enabled. | `1` (enabled) or `0` (disabled) | diff --git a/packages/services/usage/package.json b/packages/services/usage/package.json index 9f2948db923..183ba265f44 100644 --- a/packages/services/usage/package.json +++ b/packages/services/usage/package.json @@ -23,7 +23,6 @@ "dotenv": "16.4.7", "got": "14.4.7", "graphql": "16.9.0", - "ioredis": "5.8.2", "kafkajs": "2.2.4", "lru-cache": "11.0.2", "p-limit": "6.2.0", diff --git a/packages/services/usage/src/authn.ts b/packages/services/usage/src/authn.ts index 4274fce68bb..d57cc85301b 100644 --- a/packages/services/usage/src/authn.ts +++ b/packages/services/usage/src/authn.ts @@ -1,4 +1,3 @@ -import type Redis from 'ioredis'; import { AuthN } from '@hive/api/modules/auth/lib/authz'; import { OrganizationAccessTokenStrategy } from '@hive/api/modules/auth/lib/organization-access-token-strategy'; import { OrganizationAccessTokenValidationCache } from '@hive/api/modules/auth/providers/organization-access-token-validation-cache'; @@ -6,6 +5,7 @@ import { OrganizationAccessTokensCache } from '@hive/api/modules/organization/pr import { Logger } from '@hive/api/modules/shared/providers/logger'; import { PrometheusConfig } from '@hive/api/modules/shared/providers/prometheus-config'; import type { PostgresDatabasePool } from '@hive/postgres'; +import type { Redis } from '@hive/service-common'; /** * Creates an authentication provider for organization access tokens. diff --git a/packages/services/usage/src/environment.ts b/packages/services/usage/src/environment.ts index 51990e905a1..a7b567aaea2 100644 --- a/packages/services/usage/src/environment.ts +++ b/packages/services/usage/src/environment.ts @@ -1,6 +1,10 @@ import * as fs from 'fs'; import zod from 'zod'; -import { OpenTelemetryConfigurationModel, resolveServerListenOptions } from '@hive/service-common'; +import { + OpenTelemetryConfigurationModel, + parseRedisConfigFromEnvironment, + resolveServerListenOptions, +} from '@hive/service-common'; const isNumberString = (input: unknown) => zod.string().regex(/^\d+$/).safeParse(input).success; @@ -85,13 +89,6 @@ const PostgresModel = zod.object({ POSTGRES_PASSWORD: emptyString(zod.string().optional()), }); -const RedisModel = zod.object({ - REDIS_HOST: zod.string(), - REDIS_PORT: NumberFromString, - REDIS_PASSWORD: emptyString(zod.string().optional()), - REDIS_TLS_ENABLED: emptyString(zod.union([zod.literal('1'), zod.literal('0')]).optional()), -}); - const PrometheusModel = zod.object({ PROMETHEUS_METRICS: emptyString(zod.union([zod.literal('0'), zod.literal('1')]).optional()), PROMETHEUS_METRICS_LABEL_INSTANCE: emptyString(zod.string().optional()), @@ -122,7 +119,6 @@ const configs = { sentry: SentryModel.safeParse(process.env), kafka: KafkaModel.safeParse(process.env), postgres: PostgresModel.safeParse(process.env), - redis: RedisModel.safeParse(process.env), prometheus: PrometheusModel.safeParse(process.env), log: LogModel.safeParse(process.env), tracing: OpenTelemetryConfigurationModel.safeParse(process.env), @@ -149,6 +145,15 @@ if (configs.kafka.success && configs.kafka.data.KAFKA_AWS_IAM_AUTH_ENABLED === ' } } +const redisConfigResult = parseRedisConfigFromEnvironment( + process.env, + configs.base.success ? configs.base.data.AWS_REGION : undefined, +); + +if (redisConfigResult.type === 'error') { + environmentErrors.push(...redisConfigResult.errors); +} + if (environmentErrors.length) { const fullError = environmentErrors.join(`\n`); console.error('❌ Invalid environment variables:', fullError); @@ -166,7 +171,6 @@ const base = extractConfig(configs.base); const sentry = extractConfig(configs.sentry); const kafka = extractConfig(configs.kafka); const postgres = extractConfig(configs.postgres); -const redis = extractConfig(configs.redis); const prometheus = extractConfig(configs.prometheus); const log = extractConfig(configs.log); const tracing = extractConfig(configs.tracing); @@ -245,12 +249,10 @@ export const env = { password: postgres.POSTGRES_PASSWORD, ssl: postgres.POSTGRES_SSL === '1', }, - redis: { - host: redis.REDIS_HOST, - port: redis.REDIS_PORT, - password: redis.REDIS_PASSWORD ?? '', - tlsEnabled: redis.REDIS_TLS_ENABLED === '1', - }, + redis: + redisConfigResult?.type === 'ok' + ? redisConfigResult.config + : raiseInvariant('Unreachable: redis config errors are caught above via process.exit(1)'), log: { level: log.LOG_LEVEL ?? 'info', requests: log.REQUEST_LOGGING === '1', diff --git a/packages/services/usage/src/index.ts b/packages/services/usage/src/index.ts index 6dc5d21f865..c811aea3737 100644 --- a/packages/services/usage/src/index.ts +++ b/packages/services/usage/src/index.ts @@ -1,12 +1,12 @@ #!/usr/bin/env node import 'reflect-metadata'; -import Redis from 'ioredis'; import { PrometheusConfig } from '@hive/api/modules/shared/providers/prometheus-config'; import { TargetsByIdCache } from '@hive/api/modules/target/providers/targets-by-id-cache'; import { TargetsBySlugCache } from '@hive/api/modules/target/providers/targets-by-slug-cache'; import { createPostgresDatabasePool } from '@hive/postgres'; import { configureTracing, + createRedisClient, createServer, registerShutdown, reportReadiness, @@ -52,16 +52,6 @@ async function main() { }); } - const redis = new Redis({ - host: env.redis.host, - port: env.redis.port, - password: env.redis.password, - maxRetriesPerRequest: 20, - db: 0, - enableReadyCheck: false, - tls: env.redis.tlsEnabled ? {} : undefined, - }); - const server = await createServer({ name: 'usage', sentryErrorHandler: true, @@ -71,6 +61,11 @@ async function main() { }, }); + const redis = await createRedisClient(env.redis, { + logger: server.log.child({ source: 'Redis' }), + maxRetriesPerRequest: 20, + }); + const pgPool = await createPostgresDatabasePool({ connectionParameters: env.postgres, maximumPoolSize: 5, @@ -92,26 +87,6 @@ async function main() { } try { - redis.on('error', err => { - server.log.error(err, 'Redis connection error'); - }); - - redis.on('connect', () => { - server.log.info('Redis connection established'); - }); - - redis.on('ready', () => { - server.log.info('Redis connection ready... '); - }); - - redis.on('close', () => { - server.log.info('Redis connection closed'); - }); - - redis.on('reconnecting', (timeToReconnect?: number) => { - server.log.info('Redis reconnecting in %s', timeToReconnect); - }); - redis.on('end', async () => { server.log.info('Redis ended - no more reconnections will be made'); await shutdown(); diff --git a/packages/services/workflows/README.md b/packages/services/workflows/README.md index a3ae2296498..8402ab722c6 100644 --- a/packages/services/workflows/README.md +++ b/packages/services/workflows/README.md @@ -51,3 +51,13 @@ src/lib/* | `REQUEST_BROKER_ENDPOINT` | No | The address | `https://broker.worker.dev` | | `REQUEST_BROKER_SIGNATURE` | No | A secret signature needed to verify the request origin | `hbsahdbzxch123` | | `REQUEST_LOGGING` | No | Log http requests | `1` (enabled) or `0` (disabled) | +| `REDIS_HOST` | **Yes** | The host of your redis instance. | `"127.0.0.1"` | +| `REDIS_PORT` | **Yes** | The port of your redis instance. | `6379` | +| `REDIS_PASSWORD` | No | The password of your redis instance. | `"apollorocks"` | +| `REDIS_TLS_ENABLED` | No | Enable TLS for redis connection (rediss://). | `"0"` | +| `REDIS_USERNAME` | No | The username for Redis Access Control List authentication. | `"default"` | +| `REDIS_CLUSTER_MODE_ENABLED` | No | Enable Redis Cluster mode. | `1` (enabled) or `0` (disabled) | +| `AWS_REGION` | No | The global AWS region for the service. Used as the default region for AWS connections. | `us-east-1` | +| `REDIS_AWS_IAM_AUTH_ENABLED` | No | Enable AWS IAM authentication for Redis (requires `AWS_REGION`). | `1` (enabled) or `0` (disabled) | +| `REDIS_AWS_REGION` | No | AWS region for IAM auth. Falls back to `AWS_REGION`. | `us-east-1` | +| `REDIS_AWS_IAM_CACHE_NAME` | No | ElastiCache cache name (required if `REDIS_AWS_IAM_AUTH_ENABLED` is `1`). | `my-cache` | diff --git a/packages/services/workflows/package.json b/packages/services/workflows/package.json index 8f7809f4105..e442dbbb959 100644 --- a/packages/services/workflows/package.json +++ b/packages/services/workflows/package.json @@ -29,7 +29,6 @@ "graphile-worker": "0.16.6", "graphql": "16.9.0", "graphql-yoga": "5.13.3", - "ioredis": "5.8.2", "mjml": "4.14.0", "nodemailer": "8.0.5", "sendmail": "1.6.1", diff --git a/packages/services/workflows/src/environment.ts b/packages/services/workflows/src/environment.ts index cc220696e7b..acf9dd428a4 100644 --- a/packages/services/workflows/src/environment.ts +++ b/packages/services/workflows/src/environment.ts @@ -1,6 +1,10 @@ import zod from 'zod'; import { PostgresConnectionParamaters } from '@hive/postgres'; -import { OpenTelemetryConfigurationModel, resolveServerListenOptions } from '@hive/service-common'; +import { + OpenTelemetryConfigurationModel, + parseRedisConfigFromEnvironment, + resolveServerListenOptions, +} from '@hive/service-common'; import { RequestBroker } from './lib/webhooks/send-webhook.js'; const isNumberString = (input: unknown) => zod.string().regex(/^\d+$/).safeParse(input).success; @@ -20,6 +24,10 @@ const emptyString = (input: T) => { }, input); }; +function raiseInvariant(reason: string): never { + throw new Error(reason); +} + const EnvironmentModel = zod.object({ PORT: emptyString(NumberFromString.optional()).default(3014), SERVER_HOST: emptyString(zod.string().optional()), @@ -29,6 +37,7 @@ const EnvironmentModel = zod.object({ HEARTBEAT_ENDPOINT: emptyString(zod.string().url().optional()), EMAIL_FROM: zod.string().email(), SCHEMA_ENDPOINT: zod.string().url(), + AWS_REGION: emptyString(zod.string().optional()), }); const SentryModel = zod.union([ @@ -85,13 +94,6 @@ const EmailProviderModel = zod.union([ SendmailEmailModel, ]); -const RedisModel = zod.object({ - REDIS_HOST: zod.string(), - REDIS_PORT: NumberFromString, - REDIS_PASSWORD: emptyString(zod.string().optional()), - REDIS_TLS_ENABLED: emptyString(zod.union([zod.literal('1'), zod.literal('0')]).optional()), -}); - const ClickHouseModel = zod.union([ zod.object({ CLICKHOUSE: emptyString(zod.literal('0').optional()), @@ -158,7 +160,6 @@ const configs = { tracing: OpenTelemetryConfigurationModel.safeParse(process.env), clickhouse: ClickHouseModel.safeParse(process.env), requestBroker: RequestBrokerModel.safeParse(process.env), - redis: RedisModel.safeParse(process.env), featureFlags: FeatureFlagsModel.safeParse(process.env), }; @@ -170,6 +171,15 @@ for (const config of Object.values(configs)) { } } +const redisConfigResult = parseRedisConfigFromEnvironment( + process.env, + configs.base.success ? configs.base.data.AWS_REGION : undefined, +); + +if (redisConfigResult.type === 'error') { + environmentErrors.push(...redisConfigResult.errors); +} + if (environmentErrors.length) { const fullError = environmentErrors.join(`\n`); console.error('❌ Invalid environment variables:', fullError); @@ -192,7 +202,6 @@ const log = extractConfig(configs.log); const tracing = extractConfig(configs.tracing); const clickhouse = extractConfig(configs.clickhouse); const requestBroker = extractConfig(configs.requestBroker); -const redis = extractConfig(configs.redis); const featureFlags = extractConfig(configs.featureFlags); const emailProviderConfig = @@ -288,12 +297,10 @@ export const env = { } satisfies RequestBroker) : null, httpHeartbeat: base.HEARTBEAT_ENDPOINT ? { endpoint: base.HEARTBEAT_ENDPOINT } : null, - redis: { - host: redis.REDIS_HOST, - port: redis.REDIS_PORT, - password: redis.REDIS_PASSWORD ?? '', - tlsEnabled: redis.REDIS_TLS_ENABLED === '1', - }, + redis: + redisConfigResult?.type === 'ok' + ? redisConfigResult.config + : raiseInvariant('Unreachable: redis config errors are caught above via process.exit(1)'), featureFlags: { metricAlertRulesEnabled: featureFlags.FEATURE_FLAGS_METRIC_ALERT_RULES_ENABLED === '1', }, diff --git a/packages/services/workflows/src/index.ts b/packages/services/workflows/src/index.ts index b6795a58c85..66140317e48 100644 --- a/packages/services/workflows/src/index.ts +++ b/packages/services/workflows/src/index.ts @@ -4,6 +4,7 @@ import { createPostgresDatabasePool } from '@hive/postgres'; import { bridgeGraphileLogger, createHivePubSub } from '@hive/pubsub'; import { configureTracing, + createRedisClient, createServer, registerShutdown, reportReadiness, @@ -18,7 +19,6 @@ import { ClickHouseClient } from './lib/clickhouse-client.js'; import { createEmailProvider } from './lib/emails/providers.js'; import { schemaProvider } from './lib/schema/provider.js'; import { bridgeFastifyLogger } from './logger.js'; -import { createRedisClient } from './redis'; import { createTaskEventEmitter } from './task-events.js'; let tracing: TracingInstance | undefined; @@ -112,15 +112,17 @@ const server = await createServer({ log: logger, }); -const redis = createRedisClient('Redis', env.redis, server.log.child({ source: 'Redis' })); +const redis = await createRedisClient(env.redis, { + logger: server.log.child({ source: 'Redis' }), +}); + +const redisSubscriber = await createRedisClient(env.redis, { + logger: server.log.child({ source: 'RedisSubscribe' }), +}); const pubSub = createHivePubSub({ publisher: redis, - subscriber: createRedisClient( - 'subscriber', - env.redis, - server.log.child({ source: 'RedisSubscribe' }), - ), + subscriber: redisSubscriber, }); const clickhouse = env.clickhouse diff --git a/packages/services/workflows/src/redis.ts b/packages/services/workflows/src/redis.ts deleted file mode 100644 index cb1a86034e3..00000000000 --- a/packages/services/workflows/src/redis.ts +++ /dev/null @@ -1,54 +0,0 @@ -/** - * Nearly identical to @hive/api's redis definition. - * This is duplicated in case there are additional requirements for workflows. - **/ -import type { Redis as RedisInstance, RedisOptions } from 'ioredis'; -import Redis from 'ioredis'; -import type { Logger } from '@hive/pubsub'; - -export type { RedisInstance as Redis }; - -export type RedisConfig = Required> & { - tlsEnabled: boolean; -}; - -export function createRedisClient(label: string, config: RedisConfig, logger: Logger) { - const redis = new Redis({ - host: config.host, - port: config.port, - password: config.password, - retryStrategy(times) { - return Math.min(times * 500, 2000); - }, - reconnectOnError(error) { - logger.warn('Redis reconnectOnError (error=%s)', error); - return 1; - }, - db: 0, - maxRetriesPerRequest: null, - enableReadyCheck: false, - tls: config.tlsEnabled ? {} : undefined, - }); - - redis.on('error', err => { - logger.error('Redis connection error (error=%s,label=%s)', err, label); - }); - - redis.on('connect', () => { - logger.debug('Redis connection established (label=%s)', label); - }); - - redis.on('ready', () => { - logger.info('Redis connection ready (label=%s)', label); - }); - - redis.on('close', () => { - logger.info('Redis connection closed (label=%s)', label); - }); - - redis.on('reconnecting', (timeToReconnect?: number) => { - logger.info('Redis reconnecting in %s (label=%s)', timeToReconnect, label); - }); - - return redis; -} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6cacb57c171..ccd98663124 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -386,9 +386,6 @@ importers: human-id: specifier: 4.1.1 version: 4.1.1 - ioredis: - specifier: 5.8.2 - version: 5.8.2 set-cookie-parser: specifier: 2.7.1 version: 2.7.1 @@ -928,7 +925,7 @@ importers: dependencies: '@graphql-yoga/redis-event-target': specifier: 3.0.3 - version: 3.0.3(ioredis@5.8.2) + version: 3.0.3(ioredis@5.10.1) graphile-worker: specifier: ^0.16.0 version: 0.16.6(typescript@5.7.3) @@ -936,8 +933,8 @@ importers: specifier: 5.13.3 version: 5.13.3(graphql@16.12.0) ioredis: - specifier: ^5.0.0 - version: 5.8.2 + specifier: 5.10.1 + version: 5.10.1 devDependencies: tslib: specifier: 2.8.1 @@ -1078,7 +1075,7 @@ importers: version: 3.1035.0 '@bentocache/plugin-prometheus': specifier: 0.2.0 - version: 0.2.0(bentocache@1.5.1(patch_hash=98c0f93795fdd4f5eae32ee7915de8e9a346a24c3a917262b1f4551190f1a1af)(ioredis@5.8.2))(prom-client@15.1.3) + version: 0.2.0(bentocache@1.5.1(patch_hash=98c0f93795fdd4f5eae32ee7915de8e9a346a24c3a917262b1f4551190f1a1af)(ioredis@5.10.1))(prom-client@15.1.3) '@date-fns/utc': specifier: 2.1.1 version: 2.1.1 @@ -1180,7 +1177,7 @@ importers: version: 2.4.3 bentocache: specifier: 1.5.1 - version: 1.5.1(patch_hash=98c0f93795fdd4f5eae32ee7915de8e9a346a24c3a917262b1f4551190f1a1af)(ioredis@5.8.2) + version: 1.5.1(patch_hash=98c0f93795fdd4f5eae32ee7915de8e9a346a24c3a917262b1f4551190f1a1af)(ioredis@5.10.1) csv-stringify: specifier: 6.5.2 version: 6.5.2 @@ -1214,12 +1211,9 @@ importers: graphql-yoga: specifier: 5.13.3 version: 5.13.3(graphql@16.9.0) - ioredis: - specifier: 5.8.2 - version: 5.8.2 ioredis-mock: specifier: 8.9.0 - version: 8.9.0(@types/ioredis-mock@8.2.5)(ioredis@5.8.2) + version: 8.9.0(@types/ioredis-mock@8.2.5)(ioredis@5.10.1) jsonwebtoken: specifier: 9.0.3 version: 9.0.3 @@ -1536,12 +1530,9 @@ importers: graphql: specifier: 16.9.0 version: 16.9.0 - ioredis: - specifier: 5.8.2 - version: 5.8.2 ioredis-mock: specifier: 8.9.0 - version: 8.9.0(@types/ioredis-mock@8.2.5)(ioredis@5.8.2) + version: 8.9.0(@types/ioredis-mock@8.2.5)(ioredis@5.10.1) p-timeout: specifier: 6.1.4 version: 6.1.4 @@ -1592,7 +1583,7 @@ importers: version: 8.0.2 '@graphql-hive/plugin-opentelemetry': specifier: 1.4.26 - version: 1.4.26(graphql@16.9.0)(ioredis@5.8.2)(pino@10.3.0)(ws@8.18.0) + version: 1.4.26(graphql@16.9.0)(ioredis@5.10.1)(pino@10.3.0)(ws@8.18.0) '@graphql-hive/yoga': specifier: workspace:* version: link:../../libraries/yoga/dist @@ -1604,7 +1595,7 @@ importers: version: 3.15.4(graphql-yoga@5.13.3(graphql@16.9.0))(graphql@16.9.0) '@graphql-yoga/redis-event-target': specifier: 3.0.3 - version: 3.0.3(ioredis@5.8.2) + version: 3.0.3(ioredis@5.10.1) '@hive/api': specifier: workspace:* version: link:../api @@ -1665,9 +1656,6 @@ importers: hyperid: specifier: 4.0.0 version: 4.0.0 - ioredis: - specifier: 5.8.2 - version: 5.8.2 openid-client: specifier: 6.8.2 version: 6.8.2 @@ -1693,6 +1681,15 @@ importers: specifier: 10.45.3 version: 10.45.3 devDependencies: + '@aws-crypto/sha256-js': + specifier: 5.2.0 + version: 5.2.0 + '@aws-sdk/credential-providers': + specifier: 3.1035.0 + version: 3.1035.0 + '@aws-sdk/util-format-url': + specifier: 3.972.13 + version: 3.972.13 '@fastify/cors': specifier: 11.2.0 version: 11.2.0 @@ -1701,7 +1698,7 @@ importers: version: 1.1.0(pino@10.3.0) '@graphql-hive/plugin-opentelemetry': specifier: 1.4.26 - version: 1.4.26(graphql@16.12.0)(pino@10.3.0)(ws@8.18.0) + version: 1.4.26(graphql@16.12.0)(ioredis@5.10.1)(pino@10.3.0)(ws@8.18.0) '@opentelemetry/api': specifier: 1.9.1 version: 1.9.1 @@ -1744,6 +1741,12 @@ importers: '@sentry/utils': specifier: 7.120.2 version: 7.120.2 + '@smithy/protocol-http': + specifier: 5.4.3 + version: 5.4.3 + '@smithy/signature-v4': + specifier: 5.4.3 + version: 5.4.3 aws-msk-iam-sasl-signer-js: specifier: 1.0.3 version: 1.0.3 @@ -1753,6 +1756,9 @@ importers: fastify-plugin: specifier: 5.1.0 version: 5.1.0 + ioredis: + specifier: 5.10.1 + version: 5.10.1 opentelemetry-instrumentation-fetch-node: specifier: 1.2.3 version: 1.2.3(@opentelemetry/api@1.9.1) @@ -1762,6 +1768,9 @@ importers: prom-client: specifier: 15.1.3 version: 15.1.3 + vitest: + specifier: 4.1.3 + version: 4.1.3(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(msw@2.12.7(@types/node@25.5.0)(typescript@5.7.3))(vite@7.3.2(@types/node@25.5.0)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(terser@5.37.0)(tsx@4.19.2)(yaml@2.8.3)) zod: specifier: 3.25.76 version: 3.25.76 @@ -1843,9 +1852,6 @@ importers: fastify: specifier: 5.8.5 version: 5.8.5 - ioredis: - specifier: 5.8.2 - version: 5.8.2 lru-cache: specifier: 11.0.2 version: 11.0.2 @@ -1915,9 +1921,6 @@ importers: graphql: specifier: 16.9.0 version: 16.9.0 - ioredis: - specifier: 5.8.2 - version: 5.8.2 kafkajs: specifier: 2.2.4 version: 2.2.4 @@ -2001,7 +2004,7 @@ importers: version: 0.1.3(graphql@16.9.0) '@graphql-yoga/redis-event-target': specifier: 3.0.3 - version: 3.0.3(ioredis@5.8.2) + version: 3.0.3(ioredis@5.10.1) '@hive/clickhouse': specifier: workspace:* version: link:../../internal/clickhouse @@ -2037,7 +2040,7 @@ importers: version: 1.4.7 bentocache: specifier: 1.5.1 - version: 1.5.1(patch_hash=98c0f93795fdd4f5eae32ee7915de8e9a346a24c3a917262b1f4551190f1a1af)(ioredis@5.8.2) + version: 1.5.1(patch_hash=98c0f93795fdd4f5eae32ee7915de8e9a346a24c3a917262b1f4551190f1a1af)(ioredis@5.10.1) dotenv: specifier: 16.4.7 version: 16.4.7 @@ -2050,9 +2053,6 @@ importers: graphql-yoga: specifier: 5.13.3 version: 5.13.3(graphql@16.9.0) - ioredis: - specifier: 5.8.2 - version: 5.8.2 mjml: specifier: 4.14.0 version: 4.14.0(encoding@0.1.13) @@ -2800,8 +2800,8 @@ packages: resolution: {integrity: sha512-ZRTUPz930POkxrk6GMpLBgfKuV64kOfpIytGa8eCThWhoBgbtxWDYQV3rEdmIv7gHhiQ0xk/3U9jbj4w5+/esA==} engines: {node: '>=20.0.0'} - '@aws-sdk/client-cognito-identity@3.1053.0': - resolution: {integrity: sha512-fMwSPTOWcYrKsB1NG1z9uRSLE/GDJR/375tjiAyO6z2UTlpLSuWkfoYx98oMwHaJpqb1fEhtZZQ7o8czblShJQ==} + '@aws-sdk/client-cognito-identity@3.1035.0': + resolution: {integrity: sha512-sHjCtR5GdKVXZ/bEXwqUMoGzak9fnI4Ny/NyG7+gAkYC1Z+CIg5Kr4QSrqGjA+N2iwCly8f2/rGAsNo+JGeaNA==} engines: {node: '>=20.0.0'} '@aws-sdk/client-s3@3.1035.0': @@ -2828,10 +2828,6 @@ packages: resolution: {integrity: sha512-EbVgyzQ83/Lf6oh1O4vYY47tuYw3Aosthh865LNU77KyotKz+uvEBNmsl/bSVS/vG+IU39mCqcOHrnhmhF4lug==} engines: {node: '>=20.0.0'} - '@aws-sdk/core@3.974.8': - resolution: {integrity: sha512-njR2qoG6ZuB0kvAS2FyICsFZJ6gmCcf2X/7JcD14sUvGDm26wiZ5BrA6LOiUxKFEF+IVe7kdroxyE00YlkiYsw==} - engines: {node: '>=20.0.0'} - '@aws-sdk/crc64-nvme@3.972.7': resolution: {integrity: sha512-QUagVVBbC8gODCF6e1aV0mE2TXWB9Opz4k8EJFdNrujUVQm5R4AjJa1mpOqzwOuROBzqJU9zawzig7M96L8Ejg==} engines: {node: '>=20.0.0'} @@ -2848,10 +2844,6 @@ packages: resolution: {integrity: sha512-dHpeqa29a0cBYq/h59IC2EK3AphLY96nKy4F35kBtiz9GuKDc32UYRTgjZaF8uuJCnqgw9omUZKR+9myyDHC2A==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-env@3.972.34': - resolution: {integrity: sha512-XT0jtf8Fw9JE6ppsQeoNnZRiG+jqRixMT1v1ZR17G60UvVdsQmTG8nbEyHuEPfMxDXEhfdARaM/XiEhca4lGHQ==} - engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-env@3.972.39': resolution: {integrity: sha512-29wX9zpAvEt1vcj0psha+y6ygBHy2V/S72mp6e7q0KARLWXq+pwE/lR6qGkwknQvruh52lXvlqZIga8Hdxkucw==} engines: {node: '>=20.0.0'} @@ -2864,10 +2856,6 @@ packages: resolution: {integrity: sha512-A+ZTT//Mswkf9DFEM6XlngwOtYdD8X4CUcoZ2wdpgI8cCs9mcGeuhgTwbGJvealub/MeONOaUr3FbRPMKmTDjg==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-http@3.972.36': - resolution: {integrity: sha512-DPoGWfy7J7RKxvbf5kOKIGQkD2ek3dbKgzKIGrnLuvZBz5myU+Im/H6pmc14QcnFbqHMqxvtWSgRDSJW3qXLQg==} - engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-http@3.972.41': resolution: {integrity: sha512-IA3CQTjtJkb6u1H4mE4936c8OPBMa9Jggtwe8U2Mqw/vvb/tZ5Ebd0mcZcX0uKWQhOyYo/+qNIwkV5Xh+FeJJA==} engines: {node: '>=20.0.0'} @@ -2880,10 +2868,6 @@ packages: resolution: {integrity: sha512-MoRc7tLnx3JpFkV2R826enEfBUVN8o9Cc7y3hnbMwiWzL/VJhgfxRQzHkEL9vWorMWP7tibltsRcLoid9fsVdw==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-ini@3.972.38': - resolution: {integrity: sha512-oDzUBu2MGJFgoar05sPMCwSrhw44ASyccrHzj66vO69OZqi7I6hZZxXfuPLC8OCzW7C+sU+bI73XHij41yekgQ==} - engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-ini@3.972.43': resolution: {integrity: sha512-4mzII+3mZEVXXE1xzrLQrCJL7/r62A63bA6SVzZoNL5rqCJghpf+xgGltVrIBBs0n+mOZBKrQl2tRREtvZ5l6A==} engines: {node: '>=20.0.0'} @@ -2892,14 +2876,6 @@ packages: resolution: {integrity: sha512-bIRFDf54qIUFFLTZNYt40d6EseNeK9w80dHEs7BVEAWoS23c9+MSqkdg/LJBBK9Kgy01vRmjiedfBZN+jGypLw==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-login@3.972.34': - resolution: {integrity: sha512-XVSklkRRQ/CQDmv3VVFdZRl5hTFgncFhZrLyi0Ai4LZk5o3jpY5HIfuTK7ad7tixPKa+iQmL9+vg9qNyYZB+nw==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/credential-provider-login@3.972.38': - resolution: {integrity: sha512-g1NosS8qe4OF++G2UFCM5ovSkgipC7YYor5KCWatG0UoMSO5YFj9C8muePlyVmOBV/WTI16Jo3/s1NUo/o1Bww==} - engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-login@3.972.43': resolution: {integrity: sha512-HG7kQCwXtbv3oBV61Ins0oNX8KKyvrMqqRkb6ZiAfQHbMuHaiNaEb2KnpKLPkNpqImSBK82UkVE/kaY6IfWikA==} engines: {node: '>=20.0.0'} @@ -2928,10 +2904,6 @@ packages: resolution: {integrity: sha512-McJPomNTSEo+C6UA3Zq6pFrcyTUaVsoPPBOvbOHAoIFPc8Z2CMLndqFJOnB+9bVFiBTWQLutlVGmrocBbvv4MQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-process@3.972.34': - resolution: {integrity: sha512-T3IFs4EVmVi1dVN5RciFnklCANSzvrQd/VuHY9ThHSQmYkTogjcGkoJEr+oNUPQZnso52183088NqysMPji1/Q==} - engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-process@3.972.39': resolution: {integrity: sha512-2k/amBifLd75eXNwgvPw/2lKYSQ3NhvHQgkVKVjfUq13/eJ3JRtHmznuFenn74OK3sSfp4SMy1YB2w+UVXoKqA==} engines: {node: '>=20.0.0'} @@ -2944,10 +2916,6 @@ packages: resolution: {integrity: sha512-WngYb2K+/yhkDOmDfAOjoCa9Ja3he0DZiAraboKwgWoVRkajDIcDYBCVbUTxtTUldvQoe7VvHLTrBNxvftN1aQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-sso@3.972.38': - resolution: {integrity: sha512-5ZxG+t0+3Q3QPh8KEjX6syskhgNf7I0MN7oGioTf6Lm1NTjfP7sIcYGNsthXC2qR8vcD3edNZwCr2ovfSSWuRA==} - engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-sso@3.972.43': resolution: {integrity: sha512-LPc3+Y4vhH1T4x6CMqwCM6hk5+SRf/Lwmgm8INm95wxTtIRHcMwQUVkDzWu4Iw/RSncxYM2BC01OrYbxOPZvyg==} engines: {node: '>=20.0.0'} @@ -2960,16 +2928,12 @@ packages: resolution: {integrity: sha512-5KLUH+XmSNRj6amJiJSrPsCxU5l/PYDfxyqPa1MxWhHoQC3sxvGPrSib3IE+HQlfRA4e2kO0bnJy7HJdjvpuuA==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-web-identity@3.972.38': - resolution: {integrity: sha512-lYHFF30DGI20jZcYX8cm6Ns0V7f1dDN6g/MBDLTyD/5iw+bXs3yBr2iAiHDkx4RFU5JgsnZvCHYKiRVPRdmOgw==} - engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-web-identity@3.972.43': resolution: {integrity: sha512-wQtL34lUD/09VXjwAUo2T+I3aEXRDxMB3DKmTJL/Zj0Gi6sLDTrVhae1XVt01yzkquOWajI/sZW72JGDZ1ciTw==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-providers@3.1053.0': - resolution: {integrity: sha512-jMzNBhYIIzaKiVOFndhyWSvEIosiU/zSxgcOaGXrHGcwlRXcFgTgZEcOFL504hxg4BdrrAIVdGw55Zk9zMhs7g==} + '@aws-sdk/credential-providers@3.1035.0': + resolution: {integrity: sha512-HimZ+jVYJzeD6+pwXvhKX2mvx2fScLbjC4+oz1HF9Vuls/3lAWKHssLLVpCIuXL8Ov6cWe1vQIbwpFajuTAmEA==} engines: {node: '>=20.0.0'} '@aws-sdk/middleware-bucket-endpoint@3.972.10': @@ -3044,14 +3008,6 @@ packages: resolution: {integrity: sha512-nWXXJ1r/r8N2Gw1pWolRgED38/A9A8DHR2ETWIv220zh4PZHcybbR4hUVWWktmNXTRHzDJwRluapHn0rZxuoqA==} engines: {node: '>=20.0.0'} - '@aws-sdk/nested-clients@3.997.2': - resolution: {integrity: sha512-uGGQO08YetrqfInOKG5atRMrCDRQWRuZ9gGfKY6svPmuE4K7ac+XcbCkpWpjcA7yCYsBaKB/Nly4XKgPXUO1PA==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/nested-clients@3.997.6': - resolution: {integrity: sha512-WBDnqatJl+kGObpfmfSxqnXeYTu3Me8wx8WCtvoxX3pfWrrTv8I4WTMSSs7PZqcRcVh8WeUKMgGFjMG+52SR1w==} - engines: {node: '>=20.0.0'} - '@aws-sdk/region-config-resolver@3.969.0': resolution: {integrity: sha512-scj9OXqKpcjJ4jsFLtqYWz3IaNvNOQTFFvEY8XMJXTv+3qF5I7/x9SJtKzTRJEBF3spjzBUYPtGFbs9sj4fisQ==} engines: {node: '>=20.0.0'} @@ -3080,10 +3036,6 @@ packages: resolution: {integrity: sha512-E6IO3Cn+OzBe6Sb5pnubd5Y8qSUMAsVKkD5QSwFfIx5fV1g5SkYwUDRDyPlm90RuIVcCo28wpMJU6W8wXH46Aw==} engines: {node: '>=20.0.0'} - '@aws-sdk/token-providers@3.1041.0': - resolution: {integrity: sha512-Th7kPI6YPtvJUcdznooXJMy+9rQWjmEF81LxaJssngBzuysK4a/x+l8kjm1zb7nYsUPbndnBdUnwng/3PLvtGw==} - engines: {node: '>=20.0.0'} - '@aws-sdk/token-providers@3.1052.0': resolution: {integrity: sha512-QqZNB3so7UIDxZtroc85TQaLVxdZRFm0eWM1CSR2N+b06as9TOrilvrlTZuj3guYlxMs6yLOgGxnklJ5qMYtTw==} engines: {node: '>=20.0.0'} @@ -3120,6 +3072,10 @@ packages: resolution: {integrity: sha512-DEKiHNJVtNxdyTeQspzY+15Po/kHm6sF0Cs4HV9Q2+lplB63+DrvdeiSoOSdWEWAoO2RcY1veoXVDz2tWxWCgQ==} engines: {node: '>=20.0.0'} + '@aws-sdk/util-format-url@3.972.13': + resolution: {integrity: sha512-cxWxo41rEYjJ/aBP3pe7kRsxNwtNM7KQ7bQ0MndJjX9u9Aueqf0uVaP+Kv+sXTPRGcEyZIZuGtPIqj/FLVOiAw==} + engines: {node: '>=20.0.0'} + '@aws-sdk/util-locate-window@3.208.0': resolution: {integrity: sha512-iua1A2+P7JJEDHVgvXrRJSvsnzG7stYSGQnBVphIUlemwl6nN5D+QrgbjECtrbxRz8asYFHSzhdhECqN+tFiBg==} engines: {node: '>=14.0.0'} @@ -3168,10 +3124,6 @@ packages: resolution: {integrity: sha512-BMDNVG1ETXRhl1tnisQiYBef3RShJ1kfZA7x7afivTFMLirfHNTb6U71K569HNXhSXbQZsweHvSDZ6euBw8hPA==} engines: {node: '>=20.0.0'} - '@aws-sdk/xml-builder@3.972.22': - resolution: {integrity: sha512-PMYKKtJd70IsSG0yHrdAbxBr+ZWBKLvzFZfD3/urxgf6hXVMzuU5M+3MJ5G67RpOmLBu1fAUN65SbWuKUCOlAA==} - engines: {node: '>=20.0.0'} - '@aws-sdk/xml-builder@3.972.25': resolution: {integrity: sha512-GH+Kjz4nPKWKHnsiQpnhP1MJdTGIcK4rAka6tzakgjjUkVgNsmPeEbbRAf09SzS1hjGu6duGHCBsxYke0BhHjQ==} engines: {node: '>=20.0.0'} @@ -5813,12 +5765,6 @@ packages: '@ioredis/commands@1.2.0': resolution: {integrity: sha512-Sx1pU8EM64o2BrqNpEO1CNLtKQwyhuXuqyfH7oGKCk+1a33d2r5saW8zNwm3j6BTExtjrv2BxTgzzkMwts6vGg==} - '@ioredis/commands@1.4.0': - resolution: {integrity: sha512-aFT2yemJJo+TZCmieA7qnYGQooOS7QfNmYrzGtsYd3g9j5iDP8AimYYAesf79ohjbLG12XxC4nG5DyEnC88AsQ==} - - '@ioredis/commands@1.5.0': - resolution: {integrity: sha512-eUgLqrMf8nJkZxT24JvVRrQya1vZkQh8BBeYNwGDqa5I0VUi8ACx7uFvAaLxintokpTenkK6DASvo/bvNbBGow==} - '@ioredis/commands@1.5.1': resolution: {integrity: sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==} @@ -9128,19 +9074,10 @@ packages: resolution: {integrity: sha512-qJpzYC64kaj3S0fueiu3kXm8xPrR3PcXDPEgnaNMRn0EjNSZFoFjvbUp0YUDsRhN1CB90EnHJtbxWKevnH99UQ==} engines: {node: '>=18.0.0'} - '@smithy/core@3.20.5': - resolution: {integrity: sha512-0Tz77Td8ynHaowXfOdrD0F1IH4tgWGUhwmLwmpFyTbr+U9WHXNNp9u/k2VjBXGnSe7BwjBERRpXsokGTXzNjhA==} - engines: {node: '>=18.0.0'} - '@smithy/core@3.23.16': resolution: {integrity: sha512-JStomOrINQA1VqNEopLsgcdgwd42au7mykKqVr30XFw89wLt9sDxJDi4djVPRwQmmzyTGy/uOvTc2ultMpFi1w==} engines: {node: '>=18.0.0'} - '@smithy/core@3.24.1': - resolution: {integrity: sha512-3mT7o4qQyUWttYnVK3A0Z/u3Xha3E81tXn32Tz6vjZiUXhBrkEivpw1hBYfh84iFF9CSzkBU9Y1DJ3Q6RQ231g==} - engines: {node: '>=18.0.0'} - deprecated: Deprecated due to bug in browser bundling instructions https://github.com/smithy-lang/smithy-typescript/issues/2025 - '@smithy/core@3.24.4': resolution: {integrity: sha512-3UNRKEyQyAgVgM0LGlerCLm+ChZWZ1GPfde+jBEW6bm6bSBGU1p0EbblaUV3unbhwvidjLA5Zs3sOs7mnZwvAw==} engines: {node: '>=18.0.0'} @@ -9149,10 +9086,6 @@ packages: resolution: {integrity: sha512-Au28zBN48ZAoXdooGUHemuVBrkE+Ie6RPmGNIAJsFqj33Vhb6xAgRifUydZ2aY+M+KaMAETAlKk5NC5h1G7wpg==} engines: {node: '>=18.0.0'} - '@smithy/credential-provider-imds@4.2.8': - resolution: {integrity: sha512-FNT0xHS1c/CPN8upqbMFP83+ul5YgdisfCfkZ86Jh2NSmnqw/AJ6x5pEogVCTVvSm7j9MopRU89bmDelxuDMYw==} - engines: {node: '>=18.0.0'} - '@smithy/credential-provider-imds@4.3.4': resolution: {integrity: sha512-vKW0MEFRU4Y3MkVZUkpJm+g9qyPGLCXhc0YLggUdSdBB4g7IaSSsCE75P9rBXyWHrXY1UYSQUl8/DwsTR7QciA==} engines: {node: '>=18.0.0'} @@ -9305,16 +9238,12 @@ packages: resolution: {integrity: sha512-WuM31CgfsnQ/10i7NYr0PyxqknD72Y5uMfUMVSniPjbEPceiTErb4eIqJQ+pdxNEAUEWrewrGjIRjVbVHsxZiQ==} engines: {node: '>=18.0.0'} - '@smithy/property-provider@4.2.8': - resolution: {integrity: sha512-EtCTbyIveCKeOXDSWSdze3k612yCPq1YbXsbqX3UHhkOSW8zKsM9NOJG5gTIya0vbY2DIaieG8pKo1rITHYL0w==} - engines: {node: '>=18.0.0'} - '@smithy/protocol-http@5.3.14': resolution: {integrity: sha512-dN5F8kHx8RNU0r+pCwNmFZyz6ChjMkzShy/zup6MtkRmmix4vZzJdW+di7x//b1LiynIev88FM18ie+wwPcQtQ==} engines: {node: '>=18.0.0'} - '@smithy/protocol-http@5.3.8': - resolution: {integrity: sha512-QNINVDhxpZ5QnP3aviNHQFlRogQZDfYlCkQT+7tJnErPQbDhysondEjhikuANxgMsZrkGeiAxXy4jguEGsDrWQ==} + '@smithy/protocol-http@5.4.3': + resolution: {integrity: sha512-P16TBD/d8ZcD9MHQ0ubQ9BbOYSd5HZKbHOLsyFWxKk2oBEoghbRFPfGOoqToZX1yrfLITXRylL16EyPP4IzLPg==} engines: {node: '>=18.0.0'} '@smithy/querystring-builder@4.2.14': @@ -9353,12 +9282,8 @@ packages: resolution: {integrity: sha512-ui/NlpILU+6HAQBfJX8BBsDXuKSNrjTSuOYArRblcrErwKFutjrCNb/OExfVRyj9+26F9J+ZmfWT+fKWuDrH3Q==} engines: {node: '>=14.0.0'} - '@smithy/signature-v4@5.3.14': - resolution: {integrity: sha512-1D9Y/nmlVjCeSivCbhZ7hgEpmHyY1h0GvpSZt3l0xcD9JjmjVC1CHOozS6+Gh+/ldMH8JuJ6cujObQqfayAVFA==} - engines: {node: '>=18.0.0'} - - '@smithy/signature-v4@5.4.4': - resolution: {integrity: sha512-e5UtkMvsatzBfbeBZjEOt0k0Z3BEsjTFL/n6fdO5vtBLe67tdy0dX7xw2DU7uZ3acwoHyeCqpU2Fzb7pxwHb6Q==} + '@smithy/signature-v4@5.4.3': + resolution: {integrity: sha512-53+75QuPl6DL+ct6vVEB51FDO5oulXr20TPV46VvJZg76lIlXNWfxi8j+G2V/t0I2qxCBOa3vX/8bmjrpFVo9g==} engines: {node: '>=18.0.0'} '@smithy/smithy-client@4.10.7': @@ -9425,10 +9350,6 @@ packages: resolution: {integrity: sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==} engines: {node: '>=14.0.0'} - '@smithy/util-buffer-from@4.2.0': - resolution: {integrity: sha512-kAY9hTKulTNevM2nlRtxAG2FQ3B2OR6QIrPY3zE5LqJy1oxzmgBGsHLWTcNhWXKchgA0WHW+mZkQrng/pgcCew==} - engines: {node: '>=18.0.0'} - '@smithy/util-buffer-from@4.2.2': resolution: {integrity: sha512-FDXD7cvUoFWwN6vtQfEta540Y/YBe5JneK3SoZg9bThSoOAC/eGeYEua6RkBgKjGa/sz6Y+DuBZj3+YEY21y4Q==} engines: {node: '>=18.0.0'} @@ -12979,10 +12900,6 @@ packages: fast-xml-builder@1.2.0: resolution: {integrity: sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==} - fast-xml-parser@5.7.2: - resolution: {integrity: sha512-P7oW7tLbYnhOLQk/Gv7cZgzgMPP/XN03K02/Jy6Y/NHzyIAIpxuZIM/YqAkfiXFPxA2CTm7NtCijK9EDu09u2w==} - hasBin: true - fast-xml-parser@5.7.3: resolution: {integrity: sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg==} hasBin: true @@ -14013,10 +13930,6 @@ packages: resolution: {integrity: sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA==} engines: {node: '>=12.22.0'} - ioredis@5.8.2: - resolution: {integrity: sha512-C6uC+kleiIMmjViJINWk80sOQw5lEzse1ZmvD+S/s8p8CWapftSaC+kocGTx6xrbrJ4WmYQGC08ffHLr6ToR6Q==} - engines: {node: '>=12.22.0'} - ip-address@10.2.0: resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==} engines: {node: '>= 12'} @@ -19401,13 +19314,13 @@ snapshots: '@aws-crypto/crc32@5.2.0': dependencies: '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.973.8 + '@aws-sdk/types': 3.973.9 tslib: 2.8.1 '@aws-crypto/crc32c@5.2.0': dependencies: '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.973.8 + '@aws-sdk/types': 3.973.9 tslib: 2.8.1 '@aws-crypto/sha1-browser@5.2.0': @@ -19432,13 +19345,13 @@ snapshots: '@aws-crypto/sha256-js@4.0.0': dependencies: '@aws-crypto/util': 4.0.0 - '@aws-sdk/types': 3.973.8 + '@aws-sdk/types': 3.973.9 tslib: 1.14.1 '@aws-crypto/sha256-js@5.2.0': dependencies: '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.973.8 + '@aws-sdk/types': 3.973.9 tslib: 2.8.1 '@aws-crypto/supports-web-crypto@5.2.0': @@ -19447,13 +19360,13 @@ snapshots: '@aws-crypto/util@4.0.0': dependencies: - '@aws-sdk/types': 3.973.8 + '@aws-sdk/types': 3.973.9 '@aws-sdk/util-utf8-browser': 3.259.0 tslib: 1.14.1 '@aws-crypto/util@5.2.0': dependencies: - '@aws-sdk/types': 3.973.8 + '@aws-sdk/types': 3.973.9 '@smithy/util-utf8': 2.3.0 tslib: 2.8.1 @@ -19473,7 +19386,7 @@ snapshots: '@aws-sdk/util-user-agent-browser': 3.969.0 '@aws-sdk/util-user-agent-node': 3.969.0 '@smithy/config-resolver': 4.4.6 - '@smithy/core': 3.20.5 + '@smithy/core': 3.24.4 '@smithy/fetch-http-handler': 5.3.9 '@smithy/hash-node': 4.2.8 '@smithy/invalid-dependency': 4.2.8 @@ -19484,7 +19397,7 @@ snapshots: '@smithy/middleware-stack': 4.2.8 '@smithy/node-config-provider': 4.3.8 '@smithy/node-http-handler': 4.4.8 - '@smithy/protocol-http': 5.3.8 + '@smithy/protocol-http': 5.4.3 '@smithy/smithy-client': 4.10.7 '@smithy/types': 4.12.0 '@smithy/url-parser': 4.2.8 @@ -19503,18 +19416,49 @@ snapshots: transitivePeerDependencies: - aws-crt - '@aws-sdk/client-cognito-identity@3.1053.0': + '@aws-sdk/client-cognito-identity@3.1035.0': dependencies: '@aws-crypto/sha256-browser': 5.2.0 '@aws-crypto/sha256-js': 5.2.0 '@aws-sdk/core': 3.974.13 '@aws-sdk/credential-provider-node': 3.972.44 + '@aws-sdk/middleware-host-header': 3.972.10 + '@aws-sdk/middleware-logger': 3.972.10 + '@aws-sdk/middleware-recursion-detection': 3.972.11 + '@aws-sdk/middleware-user-agent': 3.972.38 + '@aws-sdk/region-config-resolver': 3.972.13 '@aws-sdk/types': 3.973.9 + '@aws-sdk/util-endpoints': 3.996.8 + '@aws-sdk/util-user-agent-browser': 3.972.10 + '@aws-sdk/util-user-agent-node': 3.973.24 + '@smithy/config-resolver': 4.4.17 '@smithy/core': 3.24.4 '@smithy/fetch-http-handler': 5.4.4 + '@smithy/hash-node': 4.2.14 + '@smithy/invalid-dependency': 4.2.14 + '@smithy/middleware-content-length': 4.2.14 + '@smithy/middleware-endpoint': 4.5.1 + '@smithy/middleware-retry': 4.6.1 + '@smithy/middleware-serde': 4.3.1 + '@smithy/middleware-stack': 4.2.14 + '@smithy/node-config-provider': 4.3.14 '@smithy/node-http-handler': 4.7.4 + '@smithy/protocol-http': 5.4.3 + '@smithy/smithy-client': 4.13.1 '@smithy/types': 4.14.2 + '@smithy/url-parser': 4.2.14 + '@smithy/util-base64': 4.3.2 + '@smithy/util-body-length-browser': 4.2.2 + '@smithy/util-body-length-node': 4.2.3 + '@smithy/util-defaults-mode-browser': 4.4.1 + '@smithy/util-defaults-mode-node': 4.3.1 + '@smithy/util-endpoints': 3.4.2 + '@smithy/util-middleware': 4.2.14 + '@smithy/util-retry': 4.4.1 + '@smithy/util-utf8': 4.2.2 tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt '@aws-sdk/client-s3@3.1035.0': dependencies: @@ -19591,29 +19535,29 @@ snapshots: '@aws-sdk/util-user-agent-browser': 3.969.0 '@aws-sdk/util-user-agent-node': 3.969.0 '@smithy/config-resolver': 4.4.17 - '@smithy/core': 3.24.1 - '@smithy/fetch-http-handler': 5.3.17 + '@smithy/core': 3.24.4 + '@smithy/fetch-http-handler': 5.4.4 '@smithy/hash-node': 4.2.14 '@smithy/invalid-dependency': 4.2.14 '@smithy/middleware-content-length': 4.2.14 - '@smithy/middleware-endpoint': 4.4.31 - '@smithy/middleware-retry': 4.5.4 - '@smithy/middleware-serde': 4.2.19 + '@smithy/middleware-endpoint': 4.5.1 + '@smithy/middleware-retry': 4.6.1 + '@smithy/middleware-serde': 4.3.1 '@smithy/middleware-stack': 4.2.14 '@smithy/node-config-provider': 4.3.14 - '@smithy/node-http-handler': 4.7.1 - '@smithy/protocol-http': 5.3.14 - '@smithy/smithy-client': 4.12.12 - '@smithy/types': 4.14.1 + '@smithy/node-http-handler': 4.7.4 + '@smithy/protocol-http': 5.4.3 + '@smithy/smithy-client': 4.13.1 + '@smithy/types': 4.14.2 '@smithy/url-parser': 4.2.14 '@smithy/util-base64': 4.3.2 '@smithy/util-body-length-browser': 4.2.2 '@smithy/util-body-length-node': 4.2.3 - '@smithy/util-defaults-mode-browser': 4.3.48 - '@smithy/util-defaults-mode-node': 4.2.53 + '@smithy/util-defaults-mode-browser': 4.4.1 + '@smithy/util-defaults-mode-node': 4.3.1 '@smithy/util-endpoints': 3.4.2 '@smithy/util-middleware': 4.2.14 - '@smithy/util-retry': 4.3.3 + '@smithy/util-retry': 4.4.1 '@smithy/util-utf8': 4.2.2 tslib: 2.8.1 transitivePeerDependencies: @@ -19623,7 +19567,7 @@ snapshots: dependencies: '@aws-crypto/sha256-browser': 5.2.0 '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/core': 3.974.8 + '@aws-sdk/core': 3.974.13 '@aws-sdk/credential-provider-node': 3.972.39 '@aws-sdk/middleware-host-header': 3.972.10 '@aws-sdk/middleware-logger': 3.972.10 @@ -19631,12 +19575,12 @@ snapshots: '@aws-sdk/middleware-user-agent': 3.972.38 '@aws-sdk/region-config-resolver': 3.972.13 '@aws-sdk/signature-v4-multi-region': 3.996.25 - '@aws-sdk/types': 3.973.8 + '@aws-sdk/types': 3.973.9 '@aws-sdk/util-endpoints': 3.996.8 '@aws-sdk/util-user-agent-browser': 3.972.10 '@aws-sdk/util-user-agent-node': 3.973.24 '@smithy/config-resolver': 4.4.17 - '@smithy/core': 3.24.1 + '@smithy/core': 3.24.4 '@smithy/fetch-http-handler': 5.3.17 '@smithy/hash-node': 4.2.14 '@smithy/invalid-dependency': 4.2.14 @@ -19647,9 +19591,9 @@ snapshots: '@smithy/middleware-stack': 4.2.14 '@smithy/node-config-provider': 4.3.14 '@smithy/node-http-handler': 4.7.1 - '@smithy/protocol-http': 5.3.14 + '@smithy/protocol-http': 5.4.3 '@smithy/smithy-client': 4.13.1 - '@smithy/types': 4.14.1 + '@smithy/types': 4.14.2 '@smithy/url-parser': 4.2.14 '@smithy/util-base64': 4.3.2 '@smithy/util-body-length-browser': 4.2.2 @@ -19668,13 +19612,13 @@ snapshots: dependencies: '@aws-sdk/types': 3.969.0 '@aws-sdk/xml-builder': 3.969.0 - '@smithy/core': 3.23.16 + '@smithy/core': 3.24.4 '@smithy/node-config-provider': 4.3.14 - '@smithy/property-provider': 4.2.8 - '@smithy/protocol-http': 5.3.14 - '@smithy/signature-v4': 5.3.14 - '@smithy/smithy-client': 4.12.12 - '@smithy/types': 4.14.1 + '@smithy/property-provider': 4.2.14 + '@smithy/protocol-http': 5.4.3 + '@smithy/signature-v4': 5.4.3 + '@smithy/smithy-client': 4.13.1 + '@smithy/types': 4.14.2 '@smithy/util-base64': 4.3.2 '@smithy/util-middleware': 4.2.14 '@smithy/util-utf8': 4.2.2 @@ -19686,7 +19630,7 @@ snapshots: '@aws-sdk/xml-builder': 3.972.25 '@aws/lambda-invoke-store': 0.2.3 '@smithy/core': 3.24.4 - '@smithy/signature-v4': 5.4.4 + '@smithy/signature-v4': 5.4.3 '@smithy/types': 4.14.2 bowser: 2.11.0 tslib: 2.8.1 @@ -19698,8 +19642,8 @@ snapshots: '@smithy/core': 3.23.16 '@smithy/node-config-provider': 4.3.14 '@smithy/property-provider': 4.2.14 - '@smithy/protocol-http': 5.3.14 - '@smithy/signature-v4': 5.3.14 + '@smithy/protocol-http': 5.4.3 + '@smithy/signature-v4': 5.4.3 '@smithy/smithy-client': 4.12.12 '@smithy/types': 4.14.1 '@smithy/util-base64': 4.3.2 @@ -19708,26 +19652,9 @@ snapshots: '@smithy/util-utf8': 4.2.2 tslib: 2.8.1 - '@aws-sdk/core@3.974.8': - dependencies: - '@aws-sdk/types': 3.973.8 - '@aws-sdk/xml-builder': 3.972.22 - '@smithy/core': 3.24.1 - '@smithy/node-config-provider': 4.3.14 - '@smithy/property-provider': 4.2.14 - '@smithy/protocol-http': 5.3.14 - '@smithy/signature-v4': 5.3.14 - '@smithy/smithy-client': 4.13.1 - '@smithy/types': 4.14.1 - '@smithy/util-base64': 4.3.2 - '@smithy/util-middleware': 4.2.14 - '@smithy/util-retry': 4.4.1 - '@smithy/util-utf8': 4.2.2 - tslib: 2.8.1 - '@aws-sdk/crc64-nvme@3.972.7': dependencies: - '@smithy/types': 4.14.1 + '@smithy/types': 4.14.2 tslib: 2.8.1 '@aws-sdk/credential-provider-cognito-identity@3.972.36': @@ -19743,23 +19670,15 @@ snapshots: '@aws-sdk/core': 3.969.0 '@aws-sdk/types': 3.969.0 '@smithy/property-provider': 4.2.14 - '@smithy/types': 4.14.1 + '@smithy/types': 4.14.2 tslib: 2.8.1 '@aws-sdk/credential-provider-env@3.972.30': dependencies: - '@aws-sdk/core': 3.974.4 - '@aws-sdk/types': 3.973.8 - '@smithy/property-provider': 4.2.14 - '@smithy/types': 4.14.1 - tslib: 2.8.1 - - '@aws-sdk/credential-provider-env@3.972.34': - dependencies: - '@aws-sdk/core': 3.974.8 - '@aws-sdk/types': 3.973.8 + '@aws-sdk/core': 3.974.13 + '@aws-sdk/types': 3.973.9 '@smithy/property-provider': 4.2.14 - '@smithy/types': 4.14.1 + '@smithy/types': 4.14.2 tslib: 2.8.1 '@aws-sdk/credential-provider-env@3.972.39': @@ -19774,41 +19693,28 @@ snapshots: dependencies: '@aws-sdk/core': 3.969.0 '@aws-sdk/types': 3.969.0 - '@smithy/fetch-http-handler': 5.3.17 - '@smithy/node-http-handler': 4.6.0 + '@smithy/fetch-http-handler': 5.4.4 + '@smithy/node-http-handler': 4.7.4 '@smithy/property-provider': 4.2.14 - '@smithy/protocol-http': 5.3.14 - '@smithy/smithy-client': 4.12.12 - '@smithy/types': 4.14.1 + '@smithy/protocol-http': 5.4.3 + '@smithy/smithy-client': 4.13.1 + '@smithy/types': 4.14.2 '@smithy/util-stream': 4.5.24 tslib: 2.8.1 '@aws-sdk/credential-provider-http@3.972.32': dependencies: - '@aws-sdk/core': 3.974.4 - '@aws-sdk/types': 3.973.8 - '@smithy/fetch-http-handler': 5.3.17 - '@smithy/node-http-handler': 4.6.0 + '@aws-sdk/core': 3.974.13 + '@aws-sdk/types': 3.973.9 + '@smithy/fetch-http-handler': 5.4.4 + '@smithy/node-http-handler': 4.7.4 '@smithy/property-provider': 4.2.14 - '@smithy/protocol-http': 5.3.14 + '@smithy/protocol-http': 5.4.3 '@smithy/smithy-client': 4.12.12 - '@smithy/types': 4.14.1 + '@smithy/types': 4.14.2 '@smithy/util-stream': 4.5.24 tslib: 2.8.1 - '@aws-sdk/credential-provider-http@3.972.36': - dependencies: - '@aws-sdk/core': 3.974.8 - '@aws-sdk/types': 3.973.8 - '@smithy/fetch-http-handler': 5.3.17 - '@smithy/node-http-handler': 4.7.1 - '@smithy/property-provider': 4.2.14 - '@smithy/protocol-http': 5.3.14 - '@smithy/smithy-client': 4.13.1 - '@smithy/types': 4.14.1 - '@smithy/util-stream': 4.6.1 - tslib: 2.8.1 - '@aws-sdk/credential-provider-http@3.972.41': dependencies: '@aws-sdk/core': 3.974.13 @@ -19830,51 +19736,30 @@ snapshots: '@aws-sdk/credential-provider-web-identity': 3.969.0 '@aws-sdk/nested-clients': 3.969.0 '@aws-sdk/types': 3.969.0 - '@smithy/credential-provider-imds': 4.2.14 + '@smithy/credential-provider-imds': 4.3.4 '@smithy/property-provider': 4.2.14 '@smithy/shared-ini-file-loader': 4.4.9 - '@smithy/types': 4.14.1 + '@smithy/types': 4.14.2 tslib: 2.8.1 transitivePeerDependencies: - aws-crt '@aws-sdk/credential-provider-ini@3.972.34': dependencies: - '@aws-sdk/core': 3.974.4 - '@aws-sdk/credential-provider-env': 3.972.30 - '@aws-sdk/credential-provider-http': 3.972.32 - '@aws-sdk/credential-provider-login': 3.972.34 - '@aws-sdk/credential-provider-process': 3.972.30 - '@aws-sdk/credential-provider-sso': 3.972.34 - '@aws-sdk/credential-provider-web-identity': 3.972.34 - '@aws-sdk/nested-clients': 3.997.2 - '@aws-sdk/types': 3.973.8 - '@smithy/credential-provider-imds': 4.2.14 - '@smithy/property-provider': 4.2.14 - '@smithy/shared-ini-file-loader': 4.4.9 - '@smithy/types': 4.14.1 - tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt - - '@aws-sdk/credential-provider-ini@3.972.38': - dependencies: - '@aws-sdk/core': 3.974.8 - '@aws-sdk/credential-provider-env': 3.972.34 - '@aws-sdk/credential-provider-http': 3.972.36 - '@aws-sdk/credential-provider-login': 3.972.38 - '@aws-sdk/credential-provider-process': 3.972.34 - '@aws-sdk/credential-provider-sso': 3.972.38 - '@aws-sdk/credential-provider-web-identity': 3.972.38 - '@aws-sdk/nested-clients': 3.997.6 - '@aws-sdk/types': 3.973.8 - '@smithy/credential-provider-imds': 4.2.14 + '@aws-sdk/core': 3.974.13 + '@aws-sdk/credential-provider-env': 3.972.39 + '@aws-sdk/credential-provider-http': 3.972.41 + '@aws-sdk/credential-provider-login': 3.972.43 + '@aws-sdk/credential-provider-process': 3.972.39 + '@aws-sdk/credential-provider-sso': 3.972.43 + '@aws-sdk/credential-provider-web-identity': 3.972.43 + '@aws-sdk/nested-clients': 3.997.11 + '@aws-sdk/types': 3.973.9 + '@smithy/credential-provider-imds': 4.3.4 '@smithy/property-provider': 4.2.14 '@smithy/shared-ini-file-loader': 4.4.9 - '@smithy/types': 4.14.1 + '@smithy/types': 4.14.2 tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt '@aws-sdk/credential-provider-ini@3.972.43': dependencies: @@ -19898,35 +19783,9 @@ snapshots: '@aws-sdk/nested-clients': 3.969.0 '@aws-sdk/types': 3.969.0 '@smithy/property-provider': 4.2.14 - '@smithy/protocol-http': 5.3.14 - '@smithy/shared-ini-file-loader': 4.4.9 - '@smithy/types': 4.14.1 - tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt - - '@aws-sdk/credential-provider-login@3.972.34': - dependencies: - '@aws-sdk/core': 3.974.4 - '@aws-sdk/nested-clients': 3.997.2 - '@aws-sdk/types': 3.973.8 - '@smithy/property-provider': 4.2.14 - '@smithy/protocol-http': 5.3.14 - '@smithy/shared-ini-file-loader': 4.4.9 - '@smithy/types': 4.14.1 - tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt - - '@aws-sdk/credential-provider-login@3.972.38': - dependencies: - '@aws-sdk/core': 3.974.8 - '@aws-sdk/nested-clients': 3.997.6 - '@aws-sdk/types': 3.973.8 - '@smithy/property-provider': 4.2.14 - '@smithy/protocol-http': 5.3.14 + '@smithy/protocol-http': 5.4.3 '@smithy/shared-ini-file-loader': 4.4.9 - '@smithy/types': 4.14.1 + '@smithy/types': 4.14.2 tslib: 2.8.1 transitivePeerDependencies: - aws-crt @@ -19949,10 +19808,10 @@ snapshots: '@aws-sdk/credential-provider-sso': 3.969.0 '@aws-sdk/credential-provider-web-identity': 3.969.0 '@aws-sdk/types': 3.969.0 - '@smithy/credential-provider-imds': 4.2.8 - '@smithy/property-provider': 4.2.8 + '@smithy/credential-provider-imds': 4.3.4 + '@smithy/property-provider': 4.2.14 '@smithy/shared-ini-file-loader': 4.4.3 - '@smithy/types': 4.14.1 + '@smithy/types': 4.14.2 tslib: 2.8.1 transitivePeerDependencies: - aws-crt @@ -19971,25 +19830,21 @@ snapshots: '@smithy/shared-ini-file-loader': 4.4.9 '@smithy/types': 4.14.1 tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt '@aws-sdk/credential-provider-node@3.972.39': dependencies: - '@aws-sdk/credential-provider-env': 3.972.34 - '@aws-sdk/credential-provider-http': 3.972.36 - '@aws-sdk/credential-provider-ini': 3.972.38 - '@aws-sdk/credential-provider-process': 3.972.34 - '@aws-sdk/credential-provider-sso': 3.972.38 - '@aws-sdk/credential-provider-web-identity': 3.972.38 - '@aws-sdk/types': 3.973.8 - '@smithy/credential-provider-imds': 4.2.14 + '@aws-sdk/credential-provider-env': 3.972.39 + '@aws-sdk/credential-provider-http': 3.972.41 + '@aws-sdk/credential-provider-ini': 3.972.43 + '@aws-sdk/credential-provider-process': 3.972.39 + '@aws-sdk/credential-provider-sso': 3.972.43 + '@aws-sdk/credential-provider-web-identity': 3.972.43 + '@aws-sdk/types': 3.973.9 + '@smithy/credential-provider-imds': 4.3.4 '@smithy/property-provider': 4.2.14 '@smithy/shared-ini-file-loader': 4.4.9 - '@smithy/types': 4.14.1 + '@smithy/types': 4.14.2 tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt '@aws-sdk/credential-provider-node@3.972.44': dependencies: @@ -20011,25 +19866,16 @@ snapshots: '@aws-sdk/types': 3.969.0 '@smithy/property-provider': 4.2.14 '@smithy/shared-ini-file-loader': 4.4.9 - '@smithy/types': 4.14.1 + '@smithy/types': 4.14.2 tslib: 2.8.1 '@aws-sdk/credential-provider-process@3.972.30': dependencies: - '@aws-sdk/core': 3.974.4 - '@aws-sdk/types': 3.973.8 - '@smithy/property-provider': 4.2.14 - '@smithy/shared-ini-file-loader': 4.4.9 - '@smithy/types': 4.14.1 - tslib: 2.8.1 - - '@aws-sdk/credential-provider-process@3.972.34': - dependencies: - '@aws-sdk/core': 3.974.8 - '@aws-sdk/types': 3.973.8 + '@aws-sdk/core': 3.974.13 + '@aws-sdk/types': 3.973.9 '@smithy/property-provider': 4.2.14 '@smithy/shared-ini-file-loader': 4.4.9 - '@smithy/types': 4.14.1 + '@smithy/types': 4.14.2 tslib: 2.8.1 '@aws-sdk/credential-provider-process@3.972.39': @@ -20048,36 +19894,21 @@ snapshots: '@aws-sdk/types': 3.969.0 '@smithy/property-provider': 4.2.14 '@smithy/shared-ini-file-loader': 4.4.9 - '@smithy/types': 4.14.1 + '@smithy/types': 4.14.2 tslib: 2.8.1 transitivePeerDependencies: - aws-crt '@aws-sdk/credential-provider-sso@3.972.34': dependencies: - '@aws-sdk/core': 3.974.4 - '@aws-sdk/nested-clients': 3.997.2 + '@aws-sdk/core': 3.974.13 + '@aws-sdk/nested-clients': 3.997.11 '@aws-sdk/token-providers': 3.1035.0 - '@aws-sdk/types': 3.973.8 - '@smithy/property-provider': 4.2.14 - '@smithy/shared-ini-file-loader': 4.4.9 - '@smithy/types': 4.14.1 - tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt - - '@aws-sdk/credential-provider-sso@3.972.38': - dependencies: - '@aws-sdk/core': 3.974.8 - '@aws-sdk/nested-clients': 3.997.6 - '@aws-sdk/token-providers': 3.1041.0 - '@aws-sdk/types': 3.973.8 + '@aws-sdk/types': 3.973.9 '@smithy/property-provider': 4.2.14 '@smithy/shared-ini-file-loader': 4.4.9 - '@smithy/types': 4.14.1 + '@smithy/types': 4.14.2 tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt '@aws-sdk/credential-provider-sso@3.972.43': dependencies: @@ -20096,34 +19927,20 @@ snapshots: '@aws-sdk/types': 3.969.0 '@smithy/property-provider': 4.2.14 '@smithy/shared-ini-file-loader': 4.4.9 - '@smithy/types': 4.14.1 + '@smithy/types': 4.14.2 tslib: 2.8.1 transitivePeerDependencies: - aws-crt '@aws-sdk/credential-provider-web-identity@3.972.34': dependencies: - '@aws-sdk/core': 3.974.4 - '@aws-sdk/nested-clients': 3.997.2 - '@aws-sdk/types': 3.973.8 - '@smithy/property-provider': 4.2.14 - '@smithy/shared-ini-file-loader': 4.4.9 - '@smithy/types': 4.14.1 - tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt - - '@aws-sdk/credential-provider-web-identity@3.972.38': - dependencies: - '@aws-sdk/core': 3.974.8 - '@aws-sdk/nested-clients': 3.997.6 - '@aws-sdk/types': 3.973.8 + '@aws-sdk/core': 3.974.13 + '@aws-sdk/nested-clients': 3.997.11 + '@aws-sdk/types': 3.973.9 '@smithy/property-provider': 4.2.14 '@smithy/shared-ini-file-loader': 4.4.9 - '@smithy/types': 4.14.1 + '@smithy/types': 4.14.2 tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt '@aws-sdk/credential-provider-web-identity@3.972.43': dependencies: @@ -20134,9 +19951,9 @@ snapshots: '@smithy/types': 4.14.2 tslib: 2.8.1 - '@aws-sdk/credential-providers@3.1053.0': + '@aws-sdk/credential-providers@3.1035.0': dependencies: - '@aws-sdk/client-cognito-identity': 3.1053.0 + '@aws-sdk/client-cognito-identity': 3.1035.0 '@aws-sdk/core': 3.974.13 '@aws-sdk/credential-provider-cognito-identity': 3.972.36 '@aws-sdk/credential-provider-env': 3.972.39 @@ -20149,17 +19966,22 @@ snapshots: '@aws-sdk/credential-provider-web-identity': 3.972.43 '@aws-sdk/nested-clients': 3.997.11 '@aws-sdk/types': 3.973.9 + '@smithy/config-resolver': 4.4.17 '@smithy/core': 3.24.4 '@smithy/credential-provider-imds': 4.3.4 + '@smithy/node-config-provider': 4.3.14 + '@smithy/property-provider': 4.2.14 '@smithy/types': 4.14.2 tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt '@aws-sdk/middleware-bucket-endpoint@3.972.10': dependencies: '@aws-sdk/types': 3.973.8 '@aws-sdk/util-arn-parser': 3.972.3 '@smithy/node-config-provider': 4.3.14 - '@smithy/protocol-http': 5.3.14 + '@smithy/protocol-http': 5.4.3 '@smithy/types': 4.14.1 '@smithy/util-config-provider': 4.2.2 tslib: 2.8.1 @@ -20167,7 +19989,7 @@ snapshots: '@aws-sdk/middleware-expect-continue@3.972.10': dependencies: '@aws-sdk/types': 3.973.8 - '@smithy/protocol-http': 5.3.14 + '@smithy/protocol-http': 5.4.3 '@smithy/types': 4.14.1 tslib: 2.8.1 @@ -20181,7 +20003,7 @@ snapshots: '@aws-sdk/types': 3.973.8 '@smithy/is-array-buffer': 4.2.2 '@smithy/node-config-provider': 4.3.14 - '@smithy/protocol-http': 5.3.14 + '@smithy/protocol-http': 5.4.3 '@smithy/types': 4.14.1 '@smithy/util-middleware': 4.2.14 '@smithy/util-stream': 4.5.24 @@ -20191,14 +20013,14 @@ snapshots: '@aws-sdk/middleware-host-header@3.969.0': dependencies: '@aws-sdk/types': 3.969.0 - '@smithy/protocol-http': 5.3.14 - '@smithy/types': 4.14.1 + '@smithy/protocol-http': 5.4.3 + '@smithy/types': 4.14.2 tslib: 2.8.1 '@aws-sdk/middleware-host-header@3.972.10': dependencies: '@aws-sdk/types': 3.973.8 - '@smithy/protocol-http': 5.3.14 + '@smithy/protocol-http': 5.4.3 '@smithy/types': 4.14.1 tslib: 2.8.1 @@ -20211,7 +20033,7 @@ snapshots: '@aws-sdk/middleware-logger@3.969.0': dependencies: '@aws-sdk/types': 3.969.0 - '@smithy/types': 4.14.1 + '@smithy/types': 4.14.2 tslib: 2.8.1 '@aws-sdk/middleware-logger@3.972.10': @@ -20224,15 +20046,15 @@ snapshots: dependencies: '@aws-sdk/types': 3.969.0 '@aws/lambda-invoke-store': 0.2.3 - '@smithy/protocol-http': 5.3.14 - '@smithy/types': 4.14.1 + '@smithy/protocol-http': 5.4.3 + '@smithy/types': 4.14.2 tslib: 2.8.1 '@aws-sdk/middleware-recursion-detection@3.972.11': dependencies: '@aws-sdk/types': 3.973.8 '@aws/lambda-invoke-store': 0.2.3 - '@smithy/protocol-http': 5.3.14 + '@smithy/protocol-http': 5.4.3 '@smithy/types': 4.14.1 tslib: 2.8.1 @@ -20243,8 +20065,8 @@ snapshots: '@aws-sdk/util-arn-parser': 3.972.3 '@smithy/core': 3.23.16 '@smithy/node-config-provider': 4.3.14 - '@smithy/protocol-http': 5.3.14 - '@smithy/signature-v4': 5.3.14 + '@smithy/protocol-http': 5.4.3 + '@smithy/signature-v4': 5.4.3 '@smithy/smithy-client': 4.12.12 '@smithy/types': 4.14.1 '@smithy/util-config-provider': 4.2.2 @@ -20255,15 +20077,15 @@ snapshots: '@aws-sdk/middleware-sdk-s3@3.972.37': dependencies: - '@aws-sdk/core': 3.974.8 - '@aws-sdk/types': 3.973.8 + '@aws-sdk/core': 3.974.13 + '@aws-sdk/types': 3.973.9 '@aws-sdk/util-arn-parser': 3.972.3 - '@smithy/core': 3.24.1 + '@smithy/core': 3.24.4 '@smithy/node-config-provider': 4.3.14 - '@smithy/protocol-http': 5.3.14 - '@smithy/signature-v4': 5.3.14 + '@smithy/protocol-http': 5.4.3 + '@smithy/signature-v4': 5.4.3 '@smithy/smithy-client': 4.13.1 - '@smithy/types': 4.14.1 + '@smithy/types': 4.14.2 '@smithy/util-config-provider': 4.2.2 '@smithy/util-middleware': 4.2.14 '@smithy/util-stream': 4.6.1 @@ -20281,9 +20103,9 @@ snapshots: '@aws-sdk/core': 3.969.0 '@aws-sdk/types': 3.969.0 '@aws-sdk/util-endpoints': 3.969.0 - '@smithy/core': 3.23.16 - '@smithy/protocol-http': 5.3.14 - '@smithy/types': 4.14.1 + '@smithy/core': 3.24.4 + '@smithy/protocol-http': 5.4.3 + '@smithy/types': 4.14.2 tslib: 2.8.1 '@aws-sdk/middleware-user-agent@3.972.34': @@ -20292,19 +20114,19 @@ snapshots: '@aws-sdk/types': 3.973.8 '@aws-sdk/util-endpoints': 3.996.8 '@smithy/core': 3.23.16 - '@smithy/protocol-http': 5.3.14 + '@smithy/protocol-http': 5.4.3 '@smithy/types': 4.14.1 '@smithy/util-retry': 4.3.3 tslib: 2.8.1 '@aws-sdk/middleware-user-agent@3.972.38': dependencies: - '@aws-sdk/core': 3.974.8 - '@aws-sdk/types': 3.973.8 + '@aws-sdk/core': 3.974.13 + '@aws-sdk/types': 3.973.9 '@aws-sdk/util-endpoints': 3.996.8 - '@smithy/core': 3.24.1 - '@smithy/protocol-http': 5.3.14 - '@smithy/types': 4.14.1 + '@smithy/core': 3.24.4 + '@smithy/protocol-http': 5.4.3 + '@smithy/types': 4.14.2 '@smithy/util-retry': 4.4.1 tslib: 2.8.1 @@ -20323,29 +20145,29 @@ snapshots: '@aws-sdk/util-user-agent-browser': 3.969.0 '@aws-sdk/util-user-agent-node': 3.969.0 '@smithy/config-resolver': 4.4.17 - '@smithy/core': 3.24.1 - '@smithy/fetch-http-handler': 5.3.17 + '@smithy/core': 3.24.4 + '@smithy/fetch-http-handler': 5.4.4 '@smithy/hash-node': 4.2.14 '@smithy/invalid-dependency': 4.2.14 '@smithy/middleware-content-length': 4.2.14 - '@smithy/middleware-endpoint': 4.4.31 - '@smithy/middleware-retry': 4.5.4 - '@smithy/middleware-serde': 4.2.19 + '@smithy/middleware-endpoint': 4.5.1 + '@smithy/middleware-retry': 4.6.1 + '@smithy/middleware-serde': 4.3.1 '@smithy/middleware-stack': 4.2.14 '@smithy/node-config-provider': 4.3.14 - '@smithy/node-http-handler': 4.7.1 - '@smithy/protocol-http': 5.3.14 - '@smithy/smithy-client': 4.12.12 - '@smithy/types': 4.14.1 + '@smithy/node-http-handler': 4.7.4 + '@smithy/protocol-http': 5.4.3 + '@smithy/smithy-client': 4.13.1 + '@smithy/types': 4.14.2 '@smithy/url-parser': 4.2.14 '@smithy/util-base64': 4.3.2 '@smithy/util-body-length-browser': 4.2.2 '@smithy/util-body-length-node': 4.2.3 - '@smithy/util-defaults-mode-browser': 4.3.48 - '@smithy/util-defaults-mode-node': 4.2.53 + '@smithy/util-defaults-mode-browser': 4.4.1 + '@smithy/util-defaults-mode-node': 4.3.1 '@smithy/util-endpoints': 3.4.2 '@smithy/util-middleware': 4.2.14 - '@smithy/util-retry': 4.3.3 + '@smithy/util-retry': 4.4.1 '@smithy/util-utf8': 4.2.2 tslib: 2.8.1 transitivePeerDependencies: @@ -20364,100 +20186,12 @@ snapshots: '@smithy/types': 4.14.2 tslib: 2.8.1 - '@aws-sdk/nested-clients@3.997.2': - dependencies: - '@aws-crypto/sha256-browser': 5.2.0 - '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/core': 3.974.4 - '@aws-sdk/middleware-host-header': 3.972.10 - '@aws-sdk/middleware-logger': 3.972.10 - '@aws-sdk/middleware-recursion-detection': 3.972.11 - '@aws-sdk/middleware-user-agent': 3.972.34 - '@aws-sdk/region-config-resolver': 3.972.13 - '@aws-sdk/signature-v4-multi-region': 3.996.21 - '@aws-sdk/types': 3.973.8 - '@aws-sdk/util-endpoints': 3.996.8 - '@aws-sdk/util-user-agent-browser': 3.972.10 - '@aws-sdk/util-user-agent-node': 3.973.20 - '@smithy/config-resolver': 4.4.17 - '@smithy/core': 3.23.16 - '@smithy/fetch-http-handler': 5.3.17 - '@smithy/hash-node': 4.2.14 - '@smithy/invalid-dependency': 4.2.14 - '@smithy/middleware-content-length': 4.2.14 - '@smithy/middleware-endpoint': 4.4.31 - '@smithy/middleware-retry': 4.5.4 - '@smithy/middleware-serde': 4.2.19 - '@smithy/middleware-stack': 4.2.14 - '@smithy/node-config-provider': 4.3.14 - '@smithy/node-http-handler': 4.6.0 - '@smithy/protocol-http': 5.3.14 - '@smithy/smithy-client': 4.12.12 - '@smithy/types': 4.14.1 - '@smithy/url-parser': 4.2.14 - '@smithy/util-base64': 4.3.2 - '@smithy/util-body-length-browser': 4.2.2 - '@smithy/util-body-length-node': 4.2.3 - '@smithy/util-defaults-mode-browser': 4.3.48 - '@smithy/util-defaults-mode-node': 4.2.53 - '@smithy/util-endpoints': 3.4.2 - '@smithy/util-middleware': 4.2.14 - '@smithy/util-retry': 4.3.3 - '@smithy/util-utf8': 4.2.2 - tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt - - '@aws-sdk/nested-clients@3.997.6': - dependencies: - '@aws-crypto/sha256-browser': 5.2.0 - '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/core': 3.974.8 - '@aws-sdk/middleware-host-header': 3.972.10 - '@aws-sdk/middleware-logger': 3.972.10 - '@aws-sdk/middleware-recursion-detection': 3.972.11 - '@aws-sdk/middleware-user-agent': 3.972.38 - '@aws-sdk/region-config-resolver': 3.972.13 - '@aws-sdk/signature-v4-multi-region': 3.996.25 - '@aws-sdk/types': 3.973.8 - '@aws-sdk/util-endpoints': 3.996.8 - '@aws-sdk/util-user-agent-browser': 3.972.10 - '@aws-sdk/util-user-agent-node': 3.973.24 - '@smithy/config-resolver': 4.4.17 - '@smithy/core': 3.24.1 - '@smithy/fetch-http-handler': 5.3.17 - '@smithy/hash-node': 4.2.14 - '@smithy/invalid-dependency': 4.2.14 - '@smithy/middleware-content-length': 4.2.14 - '@smithy/middleware-endpoint': 4.5.1 - '@smithy/middleware-retry': 4.6.1 - '@smithy/middleware-serde': 4.3.1 - '@smithy/middleware-stack': 4.2.14 - '@smithy/node-config-provider': 4.3.14 - '@smithy/node-http-handler': 4.7.1 - '@smithy/protocol-http': 5.3.14 - '@smithy/smithy-client': 4.13.1 - '@smithy/types': 4.14.1 - '@smithy/url-parser': 4.2.14 - '@smithy/util-base64': 4.3.2 - '@smithy/util-body-length-browser': 4.2.2 - '@smithy/util-body-length-node': 4.2.3 - '@smithy/util-defaults-mode-browser': 4.4.1 - '@smithy/util-defaults-mode-node': 4.3.1 - '@smithy/util-endpoints': 3.4.2 - '@smithy/util-middleware': 4.2.14 - '@smithy/util-retry': 4.4.1 - '@smithy/util-utf8': 4.2.2 - tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt - '@aws-sdk/region-config-resolver@3.969.0': dependencies: '@aws-sdk/types': 3.969.0 '@smithy/config-resolver': 4.4.17 '@smithy/node-config-provider': 4.3.14 - '@smithy/types': 4.14.1 + '@smithy/types': 4.14.2 tslib: 2.8.1 '@aws-sdk/region-config-resolver@3.972.13': @@ -20483,51 +20217,37 @@ snapshots: dependencies: '@aws-sdk/middleware-sdk-s3': 3.972.33 '@aws-sdk/types': 3.973.8 - '@smithy/protocol-http': 5.3.14 - '@smithy/signature-v4': 5.3.14 + '@smithy/protocol-http': 5.4.3 + '@smithy/signature-v4': 5.4.3 '@smithy/types': 4.14.1 tslib: 2.8.1 '@aws-sdk/signature-v4-multi-region@3.996.25': dependencies: '@aws-sdk/middleware-sdk-s3': 3.972.37 - '@aws-sdk/types': 3.973.8 - '@smithy/protocol-http': 5.3.14 - '@smithy/signature-v4': 5.3.14 - '@smithy/types': 4.14.1 + '@aws-sdk/types': 3.973.9 + '@smithy/protocol-http': 5.4.3 + '@smithy/signature-v4': 5.4.3 + '@smithy/types': 4.14.2 tslib: 2.8.1 '@aws-sdk/signature-v4-multi-region@3.996.28': dependencies: '@aws-sdk/types': 3.973.9 '@smithy/core': 3.24.4 - '@smithy/signature-v4': 5.4.4 + '@smithy/signature-v4': 5.4.3 '@smithy/types': 4.14.2 tslib: 2.8.1 '@aws-sdk/token-providers@3.1035.0': dependencies: - '@aws-sdk/core': 3.974.4 - '@aws-sdk/nested-clients': 3.997.2 - '@aws-sdk/types': 3.973.8 - '@smithy/property-provider': 4.2.14 - '@smithy/shared-ini-file-loader': 4.4.9 - '@smithy/types': 4.14.1 - tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt - - '@aws-sdk/token-providers@3.1041.0': - dependencies: - '@aws-sdk/core': 3.974.8 - '@aws-sdk/nested-clients': 3.997.6 - '@aws-sdk/types': 3.973.8 + '@aws-sdk/core': 3.974.13 + '@aws-sdk/nested-clients': 3.997.11 + '@aws-sdk/types': 3.973.9 '@smithy/property-provider': 4.2.14 '@smithy/shared-ini-file-loader': 4.4.9 - '@smithy/types': 4.14.1 + '@smithy/types': 4.14.2 tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt '@aws-sdk/token-providers@3.1052.0': dependencies: @@ -20545,14 +20265,14 @@ snapshots: '@aws-sdk/types': 3.969.0 '@smithy/property-provider': 4.2.14 '@smithy/shared-ini-file-loader': 4.4.9 - '@smithy/types': 4.14.1 + '@smithy/types': 4.14.2 tslib: 2.8.1 transitivePeerDependencies: - aws-crt '@aws-sdk/types@3.969.0': dependencies: - '@smithy/types': 4.14.1 + '@smithy/types': 4.14.2 tslib: 2.8.1 '@aws-sdk/types@3.973.8': @@ -20572,7 +20292,7 @@ snapshots: '@aws-sdk/util-endpoints@3.969.0': dependencies: '@aws-sdk/types': 3.969.0 - '@smithy/types': 4.14.1 + '@smithy/types': 4.14.2 '@smithy/url-parser': 4.2.14 '@smithy/util-endpoints': 3.4.2 tslib: 2.8.1 @@ -20587,9 +20307,14 @@ snapshots: '@aws-sdk/util-format-url@3.972.10': dependencies: - '@aws-sdk/types': 3.973.8 + '@aws-sdk/types': 3.973.9 '@smithy/querystring-builder': 4.2.14 - '@smithy/types': 4.14.1 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + + '@aws-sdk/util-format-url@3.972.13': + dependencies: + '@aws-sdk/core': 3.974.13 tslib: 2.8.1 '@aws-sdk/util-locate-window@3.208.0': @@ -20599,7 +20324,7 @@ snapshots: '@aws-sdk/util-user-agent-browser@3.969.0': dependencies: '@aws-sdk/types': 3.969.0 - '@smithy/types': 4.14.1 + '@smithy/types': 4.14.2 bowser: 2.11.0 tslib: 2.8.1 @@ -20615,7 +20340,7 @@ snapshots: '@aws-sdk/middleware-user-agent': 3.969.0 '@aws-sdk/types': 3.969.0 '@smithy/node-config-provider': 4.3.14 - '@smithy/types': 4.14.1 + '@smithy/types': 4.14.2 tslib: 2.8.1 '@aws-sdk/util-user-agent-node@3.973.20': @@ -20630,9 +20355,9 @@ snapshots: '@aws-sdk/util-user-agent-node@3.973.24': dependencies: '@aws-sdk/middleware-user-agent': 3.972.38 - '@aws-sdk/types': 3.973.8 + '@aws-sdk/types': 3.973.9 '@smithy/node-config-provider': 4.3.14 - '@smithy/types': 4.14.1 + '@smithy/types': 4.14.2 '@smithy/util-config-provider': 4.2.2 tslib: 2.8.1 @@ -20642,21 +20367,14 @@ snapshots: '@aws-sdk/xml-builder@3.969.0': dependencies: - '@smithy/types': 4.14.1 - fast-xml-parser: 5.7.2 + '@smithy/types': 4.14.2 + fast-xml-parser: 5.7.3 tslib: 2.8.1 '@aws-sdk/xml-builder@3.972.18': dependencies: - '@smithy/types': 4.14.1 - fast-xml-parser: 5.7.2 - tslib: 2.8.1 - - '@aws-sdk/xml-builder@3.972.22': - dependencies: - '@nodable/entities': 2.1.0 - '@smithy/types': 4.14.1 - fast-xml-parser: 5.7.2 + '@smithy/types': 4.14.2 + fast-xml-parser: 5.7.3 tslib: 2.8.1 '@aws-sdk/xml-builder@3.972.25': @@ -20936,18 +20654,18 @@ snapshots: optionalDependencies: '@types/react': 18.3.18 - '@bentocache/plugin-prometheus@0.2.0(bentocache@1.5.1(patch_hash=98c0f93795fdd4f5eae32ee7915de8e9a346a24c3a917262b1f4551190f1a1af)(ioredis@5.8.2))(prom-client@15.1.3)': + '@bentocache/plugin-prometheus@0.2.0(bentocache@1.5.1(patch_hash=98c0f93795fdd4f5eae32ee7915de8e9a346a24c3a917262b1f4551190f1a1af)(ioredis@5.10.1))(prom-client@15.1.3)': dependencies: - bentocache: 1.5.1(patch_hash=98c0f93795fdd4f5eae32ee7915de8e9a346a24c3a917262b1f4551190f1a1af)(ioredis@5.8.2) + bentocache: 1.5.1(patch_hash=98c0f93795fdd4f5eae32ee7915de8e9a346a24c3a917262b1f4551190f1a1af)(ioredis@5.10.1) prom-client: 15.1.3 - '@boringnode/bus@0.9.0(ioredis@5.8.2)': + '@boringnode/bus@0.9.0(ioredis@5.10.1)': dependencies: '@paralleldrive/cuid2': 3.3.0 '@poppinss/utils': 6.10.1 object-hash: 3.0.0 optionalDependencies: - ioredis: 5.8.2 + ioredis: 5.10.1 '@changesets/apply-release-plan@7.0.13': dependencies: @@ -22249,58 +21967,7 @@ snapshots: - winston - ws - '@graphql-hive/gateway-runtime@2.9.3(graphql@16.12.0)(pino@10.3.0)(ws@8.18.0)': - dependencies: - '@envelop/core': 5.5.1 - '@envelop/disable-introspection': 9.0.0(@envelop/core@5.5.1)(graphql@16.12.0) - '@envelop/generic-auth': 11.0.0(@envelop/core@5.5.1)(graphql@16.12.0) - '@envelop/instrumentation': 1.0.0 - '@graphql-hive/core': 0.21.0(graphql@16.12.0)(pino@10.3.0) - '@graphql-hive/logger': 1.1.0(pino@10.3.0) - '@graphql-hive/pubsub': 2.1.1(ioredis@5.8.2) - '@graphql-hive/signal': 2.0.0 - '@graphql-hive/yoga': 0.48.0(graphql-yoga@5.21.1(graphql@16.12.0))(graphql@16.12.0)(pino@10.3.0) - '@graphql-mesh/cross-helpers': 0.4.14(graphql@16.12.0) - '@graphql-mesh/fusion-runtime': 1.10.3(@types/node@25.5.0)(graphql@16.12.0)(pino@10.3.0) - '@graphql-mesh/hmac-upstream-signature': 2.0.12(graphql@16.12.0) - '@graphql-mesh/plugin-response-cache': 0.104.43(graphql@16.12.0) - '@graphql-mesh/transport-common': 1.0.16(graphql@16.12.0)(pino@10.3.0) - '@graphql-mesh/types': 0.104.28(graphql@16.12.0) - '@graphql-mesh/utils': 0.104.36(graphql@16.12.0) - '@graphql-tools/batch-delegate': 10.0.22(graphql@16.12.0) - '@graphql-tools/delegate': 12.0.16(graphql@16.12.0) - '@graphql-tools/executor-common': 1.0.6(graphql@16.12.0) - '@graphql-tools/executor-http': 3.3.0(@types/node@25.5.0)(graphql@16.12.0) - '@graphql-tools/federation': 4.4.3(@types/node@25.5.0)(graphql@16.12.0) - '@graphql-tools/stitch': 10.1.19(graphql@16.12.0) - '@graphql-tools/utils': 11.1.0(graphql@16.12.0) - '@graphql-tools/wrap': 11.1.15(graphql@16.12.0) - '@graphql-yoga/plugin-apollo-usage-report': 0.16.0(@envelop/core@5.5.1)(graphql-yoga@5.21.1(graphql@16.12.0))(graphql@16.12.0) - '@graphql-yoga/plugin-csrf-prevention': 3.16.2(graphql-yoga@5.21.1(graphql@16.12.0)) - '@graphql-yoga/plugin-defer-stream': 3.16.2(graphql-yoga@5.21.1(graphql@16.12.0))(graphql@16.12.0) - '@graphql-yoga/plugin-persisted-operations': 3.16.2(graphql-yoga@5.21.1(graphql@16.12.0))(graphql@16.12.0) - '@types/node': 25.5.0 - '@whatwg-node/disposablestack': 0.0.6 - '@whatwg-node/fetch': 0.10.13 - '@whatwg-node/promise-helpers': 1.3.2 - '@whatwg-node/server': 0.10.17 - '@whatwg-node/server-plugin-cookies': 1.0.5 - graphql: 16.12.0 - graphql-ws: 6.0.6(graphql@16.12.0)(ws@8.18.0) - graphql-yoga: 5.21.1(graphql@16.12.0) - tslib: 2.8.1 - transitivePeerDependencies: - - '@fastify/websocket' - - '@logtape/logtape' - - '@nats-io/nats-core' - - crossws - - ioredis - - pino - - uWebSockets.js - - winston - - ws - - '@graphql-hive/gateway-runtime@2.9.3(graphql@16.9.0)(ioredis@5.8.2)(pino@10.3.0)(ws@8.18.0)': + '@graphql-hive/gateway-runtime@2.9.3(graphql@16.9.0)(ioredis@5.10.1)(pino@10.3.0)(ws@8.18.0)': dependencies: '@envelop/core': 5.5.1 '@envelop/disable-introspection': 9.0.0(@envelop/core@5.5.1)(graphql@16.9.0) @@ -22308,16 +21975,16 @@ snapshots: '@envelop/instrumentation': 1.0.0 '@graphql-hive/core': 0.21.0(graphql@16.9.0)(pino@10.3.0) '@graphql-hive/logger': 1.1.0(pino@10.3.0) - '@graphql-hive/pubsub': 2.1.1(ioredis@5.8.2) + '@graphql-hive/pubsub': 2.1.1(ioredis@5.10.1) '@graphql-hive/signal': 2.0.0 '@graphql-hive/yoga': 0.48.0(graphql-yoga@5.21.1(graphql@16.9.0))(graphql@16.9.0)(pino@10.3.0) '@graphql-mesh/cross-helpers': 0.4.14(graphql@16.9.0) - '@graphql-mesh/fusion-runtime': 1.10.3(@types/node@25.5.0)(graphql@16.9.0)(ioredis@5.8.2)(pino@10.3.0) - '@graphql-mesh/hmac-upstream-signature': 2.0.12(graphql@16.9.0)(ioredis@5.8.2) - '@graphql-mesh/plugin-response-cache': 0.104.43(graphql@16.9.0)(ioredis@5.8.2) - '@graphql-mesh/transport-common': 1.0.16(graphql@16.9.0)(ioredis@5.8.2)(pino@10.3.0) - '@graphql-mesh/types': 0.104.28(graphql@16.9.0)(ioredis@5.8.2) - '@graphql-mesh/utils': 0.104.36(graphql@16.9.0)(ioredis@5.8.2) + '@graphql-mesh/fusion-runtime': 1.10.3(@types/node@25.5.0)(graphql@16.9.0)(ioredis@5.10.1)(pino@10.3.0) + '@graphql-mesh/hmac-upstream-signature': 2.0.12(graphql@16.9.0)(ioredis@5.10.1) + '@graphql-mesh/plugin-response-cache': 0.104.43(graphql@16.9.0)(ioredis@5.10.1) + '@graphql-mesh/transport-common': 1.0.16(graphql@16.9.0)(ioredis@5.10.1)(pino@10.3.0) + '@graphql-mesh/types': 0.104.28(graphql@16.9.0)(ioredis@5.10.1) + '@graphql-mesh/utils': 0.104.36(graphql@16.9.0)(ioredis@5.10.1) '@graphql-tools/batch-delegate': 10.0.22(graphql@16.9.0) '@graphql-tools/delegate': 12.0.16(graphql@16.9.0) '@graphql-tools/executor-common': 1.0.6(graphql@16.9.0) @@ -22523,53 +22190,15 @@ snapshots: - winston - ws - '@graphql-hive/plugin-opentelemetry@1.4.26(graphql@16.12.0)(pino@10.3.0)(ws@8.18.0)': - dependencies: - '@graphql-hive/core': 0.21.0(graphql@16.12.0)(pino@10.3.0) - '@graphql-hive/gateway-runtime': 2.9.3(graphql@16.12.0)(pino@10.3.0)(ws@8.18.0) - '@graphql-hive/logger': 1.1.0(pino@10.3.0) - '@graphql-mesh/cross-helpers': 0.4.14(graphql@16.12.0) - '@graphql-mesh/transport-common': 1.0.16(graphql@16.12.0)(pino@10.3.0) - '@graphql-mesh/types': 0.104.28(graphql@16.12.0) - '@graphql-mesh/utils': 0.104.36(graphql@16.12.0) - '@graphql-tools/utils': 11.1.0(graphql@16.12.0) - '@opentelemetry/api': 1.9.1 - '@opentelemetry/api-logs': 0.217.0 - '@opentelemetry/auto-instrumentations-node': 0.75.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.7.1(@opentelemetry/api@1.9.1)) - '@opentelemetry/context-async-hooks': 2.7.1(@opentelemetry/api@1.9.1) - '@opentelemetry/core': 2.7.1(@opentelemetry/api@1.9.1) - '@opentelemetry/exporter-trace-otlp-grpc': 0.217.0(@opentelemetry/api@1.9.1) - '@opentelemetry/exporter-trace-otlp-http': 0.217.0(@opentelemetry/api@1.9.1) - '@opentelemetry/instrumentation': 0.217.0(@opentelemetry/api@1.9.1) - '@opentelemetry/resources': 2.7.1(@opentelemetry/api@1.9.1) - '@opentelemetry/sdk-logs': 0.217.0(@opentelemetry/api@1.9.1) - '@opentelemetry/sdk-node': 0.217.0(@opentelemetry/api@1.9.1) - '@opentelemetry/sdk-trace-base': 2.7.1(@opentelemetry/api@1.9.1) - '@opentelemetry/semantic-conventions': 1.40.0 - '@whatwg-node/promise-helpers': 1.3.2 - graphql: 16.12.0 - tslib: 2.8.1 - transitivePeerDependencies: - - '@fastify/websocket' - - '@logtape/logtape' - - '@nats-io/nats-core' - - crossws - - ioredis - - pino - - supports-color - - uWebSockets.js - - winston - - ws - - '@graphql-hive/plugin-opentelemetry@1.4.26(graphql@16.9.0)(ioredis@5.8.2)(pino@10.3.0)(ws@8.18.0)': + '@graphql-hive/plugin-opentelemetry@1.4.26(graphql@16.9.0)(ioredis@5.10.1)(pino@10.3.0)(ws@8.18.0)': dependencies: '@graphql-hive/core': 0.21.0(graphql@16.9.0)(pino@10.3.0) - '@graphql-hive/gateway-runtime': 2.9.3(graphql@16.9.0)(ioredis@5.8.2)(pino@10.3.0)(ws@8.18.0) + '@graphql-hive/gateway-runtime': 2.9.3(graphql@16.9.0)(ioredis@5.10.1)(pino@10.3.0)(ws@8.18.0) '@graphql-hive/logger': 1.1.0(pino@10.3.0) '@graphql-mesh/cross-helpers': 0.4.14(graphql@16.9.0) - '@graphql-mesh/transport-common': 1.0.16(graphql@16.9.0)(ioredis@5.8.2)(pino@10.3.0) - '@graphql-mesh/types': 0.104.28(graphql@16.9.0)(ioredis@5.8.2) - '@graphql-mesh/utils': 0.104.36(graphql@16.9.0)(ioredis@5.8.2) + '@graphql-mesh/transport-common': 1.0.16(graphql@16.9.0)(ioredis@5.10.1)(pino@10.3.0) + '@graphql-mesh/types': 0.104.28(graphql@16.9.0)(ioredis@5.10.1) + '@graphql-mesh/utils': 0.104.36(graphql@16.9.0)(ioredis@5.10.1) '@graphql-tools/utils': 11.1.0(graphql@16.9.0) '@opentelemetry/api': 1.9.1 '@opentelemetry/api-logs': 0.217.0 @@ -22607,14 +22236,6 @@ snapshots: optionalDependencies: ioredis: 5.10.1 - '@graphql-hive/pubsub@2.1.1(ioredis@5.8.2)': - dependencies: - '@repeaterjs/repeater': 3.0.6 - '@whatwg-node/disposablestack': 0.0.6 - '@whatwg-node/promise-helpers': 1.3.2 - optionalDependencies: - ioredis: 5.8.2 - '@graphql-hive/render-laboratory@0.1.7(@tanstack/react-form@1.28.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@types/node@24.12.2)(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(date-fns@4.1.0)(graphql@16.12.0)(lucide-react@0.548.0(react@18.3.1))(lz-string@1.5.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(subscriptions-transport-ws@0.11.0(graphql@16.12.0))(tslib@2.8.1)(zod@4.3.6)': dependencies: '@graphql-hive/laboratory': 0.1.7(@tanstack/react-form@1.28.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@types/node@24.12.2)(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(date-fns@4.1.0)(graphql@16.12.0)(lucide-react@0.548.0(react@18.3.1))(lz-string@1.5.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(subscriptions-transport-ws@0.11.0(graphql@16.12.0))(tslib@2.8.1)(zod@4.3.6) @@ -23034,46 +22655,15 @@ snapshots: - pino - winston - '@graphql-mesh/fusion-runtime@1.10.3(@types/node@25.5.0)(graphql@16.12.0)(pino@10.3.0)': - dependencies: - '@envelop/core': 5.5.1 - '@envelop/instrumentation': 1.0.0 - '@graphql-hive/logger': 1.1.0(pino@10.3.0) - '@graphql-mesh/cross-helpers': 0.4.14(graphql@16.12.0) - '@graphql-mesh/transport-common': 1.0.16(graphql@16.12.0)(pino@10.3.0) - '@graphql-mesh/types': 0.104.28(graphql@16.12.0) - '@graphql-mesh/utils': 0.104.36(graphql@16.12.0) - '@graphql-tools/batch-execute': 10.0.8(graphql@16.12.0) - '@graphql-tools/delegate': 12.0.16(graphql@16.12.0) - '@graphql-tools/executor': 1.5.0(graphql@16.12.0) - '@graphql-tools/federation': 4.4.3(@types/node@25.5.0)(graphql@16.12.0) - '@graphql-tools/merge': 9.1.5(graphql@16.12.0) - '@graphql-tools/stitch': 10.1.19(graphql@16.12.0) - '@graphql-tools/stitching-directives': 4.0.21(graphql@16.12.0) - '@graphql-tools/utils': 11.1.0(graphql@16.12.0) - '@graphql-tools/wrap': 11.1.15(graphql@16.12.0) - '@whatwg-node/disposablestack': 0.0.6 - '@whatwg-node/promise-helpers': 1.3.2 - graphql: 16.12.0 - graphql-yoga: 5.21.1(graphql@16.12.0) - tslib: 2.8.1 - transitivePeerDependencies: - - '@logtape/logtape' - - '@nats-io/nats-core' - - '@types/node' - - ioredis - - pino - - winston - - '@graphql-mesh/fusion-runtime@1.10.3(@types/node@25.5.0)(graphql@16.9.0)(ioredis@5.8.2)(pino@10.3.0)': + '@graphql-mesh/fusion-runtime@1.10.3(@types/node@25.5.0)(graphql@16.9.0)(ioredis@5.10.1)(pino@10.3.0)': dependencies: '@envelop/core': 5.5.1 '@envelop/instrumentation': 1.0.0 '@graphql-hive/logger': 1.1.0(pino@10.3.0) '@graphql-mesh/cross-helpers': 0.4.14(graphql@16.9.0) - '@graphql-mesh/transport-common': 1.0.16(graphql@16.9.0)(ioredis@5.8.2)(pino@10.3.0) - '@graphql-mesh/types': 0.104.28(graphql@16.9.0)(ioredis@5.8.2) - '@graphql-mesh/utils': 0.104.36(graphql@16.9.0)(ioredis@5.8.2) + '@graphql-mesh/transport-common': 1.0.16(graphql@16.9.0)(ioredis@5.10.1)(pino@10.3.0) + '@graphql-mesh/types': 0.104.28(graphql@16.9.0)(ioredis@5.10.1) + '@graphql-mesh/utils': 0.104.36(graphql@16.9.0)(ioredis@5.10.1) '@graphql-tools/batch-execute': 10.0.8(graphql@16.9.0) '@graphql-tools/delegate': 12.0.16(graphql@16.9.0) '@graphql-tools/executor': 1.5.0(graphql@16.9.0) @@ -23096,20 +22686,6 @@ snapshots: - pino - winston - '@graphql-mesh/hmac-upstream-signature@2.0.12(graphql@16.12.0)': - dependencies: - '@graphql-mesh/cross-helpers': 0.4.14(graphql@16.12.0) - '@graphql-mesh/types': 0.104.28(graphql@16.12.0) - '@graphql-mesh/utils': 0.104.36(graphql@16.12.0) - '@graphql-tools/executor-common': 1.0.6(graphql@16.12.0) - '@graphql-tools/utils': 11.1.0(graphql@16.12.0) - '@whatwg-node/promise-helpers': 1.3.2 - graphql: 16.12.0 - tslib: 2.8.1 - transitivePeerDependencies: - - '@nats-io/nats-core' - - ioredis - '@graphql-mesh/hmac-upstream-signature@2.0.12(graphql@16.12.0)(ioredis@5.10.1)': dependencies: '@graphql-mesh/cross-helpers': 0.4.14(graphql@16.12.0) @@ -23124,11 +22700,11 @@ snapshots: - '@nats-io/nats-core' - ioredis - '@graphql-mesh/hmac-upstream-signature@2.0.12(graphql@16.9.0)(ioredis@5.8.2)': + '@graphql-mesh/hmac-upstream-signature@2.0.12(graphql@16.9.0)(ioredis@5.10.1)': dependencies: '@graphql-mesh/cross-helpers': 0.4.14(graphql@16.9.0) - '@graphql-mesh/types': 0.104.28(graphql@16.9.0)(ioredis@5.8.2) - '@graphql-mesh/utils': 0.104.36(graphql@16.9.0)(ioredis@5.8.2) + '@graphql-mesh/types': 0.104.28(graphql@16.9.0)(ioredis@5.10.1) + '@graphql-mesh/utils': 0.104.36(graphql@16.9.0)(ioredis@5.10.1) '@graphql-tools/executor-common': 1.0.6(graphql@16.9.0) '@graphql-tools/utils': 11.1.0(graphql@16.9.0) '@whatwg-node/promise-helpers': 1.3.2 @@ -23217,25 +22793,6 @@ snapshots: - '@nats-io/nats-core' - ioredis - '@graphql-mesh/plugin-response-cache@0.104.43(graphql@16.12.0)': - dependencies: - '@envelop/core': 5.5.1 - '@envelop/response-cache': 9.1.1(@envelop/core@5.5.1)(graphql@16.12.0) - '@graphql-mesh/cross-helpers': 0.4.14(graphql@16.12.0) - '@graphql-mesh/string-interpolation': 0.5.16(graphql@16.12.0) - '@graphql-mesh/types': 0.104.28(graphql@16.12.0) - '@graphql-mesh/utils': 0.104.36(graphql@16.12.0) - '@graphql-tools/utils': 11.1.0(graphql@16.12.0) - '@graphql-yoga/plugin-response-cache': 3.23.0(graphql-yoga@5.21.1(graphql@16.12.0))(graphql@16.12.0) - '@whatwg-node/promise-helpers': 1.3.2 - cache-control-parser: 2.2.0 - graphql: 16.12.0 - graphql-yoga: 5.21.1(graphql@16.12.0) - tslib: 2.8.1 - transitivePeerDependencies: - - '@nats-io/nats-core' - - ioredis - '@graphql-mesh/plugin-response-cache@0.104.43(graphql@16.12.0)(ioredis@5.10.1)': dependencies: '@envelop/core': 5.5.1 @@ -23255,14 +22812,14 @@ snapshots: - '@nats-io/nats-core' - ioredis - '@graphql-mesh/plugin-response-cache@0.104.43(graphql@16.9.0)(ioredis@5.8.2)': + '@graphql-mesh/plugin-response-cache@0.104.43(graphql@16.9.0)(ioredis@5.10.1)': dependencies: '@envelop/core': 5.5.1 '@envelop/response-cache': 9.1.1(@envelop/core@5.5.1)(graphql@16.9.0) '@graphql-mesh/cross-helpers': 0.4.14(graphql@16.9.0) '@graphql-mesh/string-interpolation': 0.5.16(graphql@16.9.0) - '@graphql-mesh/types': 0.104.28(graphql@16.9.0)(ioredis@5.8.2) - '@graphql-mesh/utils': 0.104.36(graphql@16.9.0)(ioredis@5.8.2) + '@graphql-mesh/types': 0.104.28(graphql@16.9.0)(ioredis@5.10.1) + '@graphql-mesh/utils': 0.104.36(graphql@16.9.0)(ioredis@5.10.1) '@graphql-tools/utils': 11.1.0(graphql@16.9.0) '@graphql-yoga/plugin-response-cache': 3.23.0(graphql-yoga@5.21.1(graphql@16.9.0))(graphql@16.9.0) '@whatwg-node/promise-helpers': 1.3.2 @@ -23323,32 +22880,13 @@ snapshots: - pino - winston - '@graphql-mesh/transport-common@1.0.16(graphql@16.12.0)(pino@10.3.0)': + '@graphql-mesh/transport-common@1.0.16(graphql@16.9.0)(ioredis@5.10.1)(pino@10.3.0)': dependencies: '@envelop/core': 5.5.1 '@graphql-hive/logger': 1.1.0(pino@10.3.0) - '@graphql-hive/pubsub': 2.1.1(ioredis@5.8.2) - '@graphql-hive/signal': 2.0.0 - '@graphql-mesh/types': 0.104.28(graphql@16.12.0) - '@graphql-tools/executor': 1.5.0(graphql@16.12.0) - '@graphql-tools/executor-common': 1.0.6(graphql@16.12.0) - '@graphql-tools/utils': 11.1.0(graphql@16.12.0) - graphql: 16.12.0 - tslib: 2.8.1 - transitivePeerDependencies: - - '@logtape/logtape' - - '@nats-io/nats-core' - - ioredis - - pino - - winston - - '@graphql-mesh/transport-common@1.0.16(graphql@16.9.0)(ioredis@5.8.2)(pino@10.3.0)': - dependencies: - '@envelop/core': 5.5.1 - '@graphql-hive/logger': 1.1.0(pino@10.3.0) - '@graphql-hive/pubsub': 2.1.1(ioredis@5.8.2) + '@graphql-hive/pubsub': 2.1.1(ioredis@5.10.1) '@graphql-hive/signal': 2.0.0 - '@graphql-mesh/types': 0.104.28(graphql@16.9.0)(ioredis@5.8.2) + '@graphql-mesh/types': 0.104.28(graphql@16.9.0)(ioredis@5.10.1) '@graphql-tools/executor': 1.5.0(graphql@16.9.0) '@graphql-tools/executor-common': 1.0.6(graphql@16.9.0) '@graphql-tools/utils': 11.1.0(graphql@16.9.0) @@ -23428,21 +22966,6 @@ snapshots: - utf-8-validate - winston - '@graphql-mesh/types@0.104.28(graphql@16.12.0)': - dependencies: - '@graphql-hive/pubsub': 2.1.1(ioredis@5.8.2) - '@graphql-tools/batch-delegate': 10.0.22(graphql@16.12.0) - '@graphql-tools/delegate': 12.0.16(graphql@16.12.0) - '@graphql-tools/utils': 11.1.0(graphql@16.12.0) - '@graphql-typed-document-node/core': 3.2.0(graphql@16.12.0) - '@repeaterjs/repeater': 3.0.6 - '@whatwg-node/disposablestack': 0.0.6 - graphql: 16.12.0 - tslib: 2.8.1 - transitivePeerDependencies: - - '@nats-io/nats-core' - - ioredis - '@graphql-mesh/types@0.104.28(graphql@16.12.0)(ioredis@5.10.1)': dependencies: '@graphql-hive/pubsub': 2.1.1(ioredis@5.10.1) @@ -23458,9 +22981,9 @@ snapshots: - '@nats-io/nats-core' - ioredis - '@graphql-mesh/types@0.104.28(graphql@16.9.0)(ioredis@5.8.2)': + '@graphql-mesh/types@0.104.28(graphql@16.9.0)(ioredis@5.10.1)': dependencies: - '@graphql-hive/pubsub': 2.1.1(ioredis@5.8.2) + '@graphql-hive/pubsub': 2.1.1(ioredis@5.10.1) '@graphql-tools/batch-delegate': 10.0.22(graphql@16.9.0) '@graphql-tools/delegate': 12.0.16(graphql@16.9.0) '@graphql-tools/utils': 11.1.0(graphql@16.9.0) @@ -23473,30 +22996,6 @@ snapshots: - '@nats-io/nats-core' - ioredis - '@graphql-mesh/utils@0.104.36(graphql@16.12.0)': - dependencies: - '@envelop/instrumentation': 1.0.0 - '@graphql-mesh/cross-helpers': 0.4.14(graphql@16.12.0) - '@graphql-mesh/string-interpolation': 0.5.16(graphql@16.12.0) - '@graphql-mesh/types': 0.104.28(graphql@16.12.0) - '@graphql-tools/batch-delegate': 10.0.22(graphql@16.12.0) - '@graphql-tools/delegate': 12.0.16(graphql@16.12.0) - '@graphql-tools/utils': 11.1.0(graphql@16.12.0) - '@graphql-tools/wrap': 11.1.15(graphql@16.12.0) - '@whatwg-node/disposablestack': 0.0.6 - '@whatwg-node/fetch': 0.10.13 - '@whatwg-node/promise-helpers': 1.3.2 - dset: 3.1.4 - graphql: 16.12.0 - js-yaml: 4.1.1 - lodash.get: 4.4.2 - lodash.topath: 4.5.2 - tiny-lru: 13.0.0 - tslib: 2.8.1 - transitivePeerDependencies: - - '@nats-io/nats-core' - - ioredis - '@graphql-mesh/utils@0.104.36(graphql@16.12.0)(ioredis@5.10.1)': dependencies: '@envelop/instrumentation': 1.0.0 @@ -23521,12 +23020,12 @@ snapshots: - '@nats-io/nats-core' - ioredis - '@graphql-mesh/utils@0.104.36(graphql@16.9.0)(ioredis@5.8.2)': + '@graphql-mesh/utils@0.104.36(graphql@16.9.0)(ioredis@5.10.1)': dependencies: '@envelop/instrumentation': 1.0.0 '@graphql-mesh/cross-helpers': 0.4.14(graphql@16.9.0) '@graphql-mesh/string-interpolation': 0.5.16(graphql@16.9.0) - '@graphql-mesh/types': 0.104.28(graphql@16.9.0)(ioredis@5.8.2) + '@graphql-mesh/types': 0.104.28(graphql@16.9.0)(ioredis@5.10.1) '@graphql-tools/batch-delegate': 10.0.22(graphql@16.9.0) '@graphql-tools/delegate': 12.0.16(graphql@16.9.0) '@graphql-tools/utils': 11.1.0(graphql@16.9.0) @@ -24992,11 +24491,11 @@ snapshots: transitivePeerDependencies: - '@envelop/core' - '@graphql-yoga/redis-event-target@3.0.3(ioredis@5.8.2)': + '@graphql-yoga/redis-event-target@3.0.3(ioredis@5.10.1)': dependencies: '@graphql-yoga/typed-event-target': 3.0.2 '@whatwg-node/events': 0.1.2 - ioredis: 5.8.2 + ioredis: 5.10.1 '@graphql-yoga/render-graphiql@5.16.2(graphql-yoga@5.21.0(graphql@16.12.0))': dependencies: @@ -25506,10 +25005,6 @@ snapshots: '@ioredis/commands@1.2.0': {} - '@ioredis/commands@1.4.0': {} - - '@ioredis/commands@1.5.0': {} - '@ioredis/commands@1.5.1': {} '@isaacs/cliui@8.0.2': @@ -29534,7 +29029,7 @@ snapshots: '@smithy/abort-controller@4.2.8': dependencies: - '@smithy/types': 4.14.1 + '@smithy/types': 4.14.2 tslib: 2.8.1 '@smithy/chunked-blob-reader-native@4.2.3': @@ -29558,28 +29053,15 @@ snapshots: '@smithy/config-resolver@4.4.6': dependencies: '@smithy/node-config-provider': 4.3.14 - '@smithy/types': 4.14.1 + '@smithy/types': 4.14.2 '@smithy/util-config-provider': 4.2.0 '@smithy/util-endpoints': 3.4.2 '@smithy/util-middleware': 4.2.14 tslib: 2.8.1 - '@smithy/core@3.20.5': - dependencies: - '@smithy/middleware-serde': 4.2.19 - '@smithy/protocol-http': 5.3.14 - '@smithy/types': 4.14.1 - '@smithy/util-base64': 4.3.2 - '@smithy/util-body-length-browser': 4.2.2 - '@smithy/util-middleware': 4.2.14 - '@smithy/util-stream': 4.5.24 - '@smithy/util-utf8': 4.2.2 - '@smithy/uuid': 1.1.0 - tslib: 2.8.1 - '@smithy/core@3.23.16': dependencies: - '@smithy/protocol-http': 5.3.14 + '@smithy/protocol-http': 5.4.3 '@smithy/types': 4.14.1 '@smithy/url-parser': 4.2.14 '@smithy/util-base64': 4.3.2 @@ -29590,12 +29072,6 @@ snapshots: '@smithy/uuid': 1.1.2 tslib: 2.8.1 - '@smithy/core@3.24.1': - dependencies: - '@aws-crypto/crc32': 5.2.0 - '@smithy/types': 4.14.1 - tslib: 2.8.1 - '@smithy/core@3.24.4': dependencies: '@aws-crypto/crc32': 5.2.0 @@ -29606,15 +29082,7 @@ snapshots: dependencies: '@smithy/node-config-provider': 4.3.14 '@smithy/property-provider': 4.2.14 - '@smithy/types': 4.14.1 - '@smithy/url-parser': 4.2.14 - tslib: 2.8.1 - - '@smithy/credential-provider-imds@4.2.8': - dependencies: - '@smithy/node-config-provider': 4.3.14 - '@smithy/property-provider': 4.2.14 - '@smithy/types': 4.14.1 + '@smithy/types': 4.14.2 '@smithy/url-parser': 4.2.14 tslib: 2.8.1 @@ -29627,7 +29095,7 @@ snapshots: '@smithy/eventstream-codec@4.2.14': dependencies: '@aws-crypto/crc32': 5.2.0 - '@smithy/types': 4.14.1 + '@smithy/types': 4.14.2 '@smithy/util-hex-encoding': 4.2.2 tslib: 2.8.1 @@ -29651,12 +29119,12 @@ snapshots: '@smithy/eventstream-serde-universal@4.2.14': dependencies: '@smithy/eventstream-codec': 4.2.14 - '@smithy/types': 4.14.1 + '@smithy/types': 4.14.2 tslib: 2.8.1 '@smithy/fetch-http-handler@5.3.17': dependencies: - '@smithy/protocol-http': 5.3.14 + '@smithy/protocol-http': 5.4.3 '@smithy/querystring-builder': 4.2.14 '@smithy/types': 4.14.1 '@smithy/util-base64': 4.3.2 @@ -29664,9 +29132,9 @@ snapshots: '@smithy/fetch-http-handler@5.3.9': dependencies: - '@smithy/protocol-http': 5.3.14 + '@smithy/protocol-http': 5.4.3 '@smithy/querystring-builder': 4.2.8 - '@smithy/types': 4.14.1 + '@smithy/types': 4.14.2 '@smithy/util-base64': 4.3.2 tslib: 2.8.1 @@ -29692,8 +29160,8 @@ snapshots: '@smithy/hash-node@4.2.8': dependencies: - '@smithy/types': 4.14.1 - '@smithy/util-buffer-from': 4.2.0 + '@smithy/types': 4.14.2 + '@smithy/util-buffer-from': 4.2.2 '@smithy/util-utf8': 4.2.2 tslib: 2.8.1 @@ -29710,7 +29178,7 @@ snapshots: '@smithy/invalid-dependency@4.2.8': dependencies: - '@smithy/types': 4.14.1 + '@smithy/types': 4.14.2 tslib: 2.8.1 '@smithy/is-array-buffer@2.2.0': @@ -29729,14 +29197,14 @@ snapshots: '@smithy/middleware-content-length@4.2.14': dependencies: - '@smithy/protocol-http': 5.3.14 + '@smithy/protocol-http': 5.4.3 '@smithy/types': 4.14.1 tslib: 2.8.1 '@smithy/middleware-content-length@4.2.8': dependencies: - '@smithy/protocol-http': 5.3.14 - '@smithy/types': 4.14.1 + '@smithy/protocol-http': 5.4.3 + '@smithy/types': 4.14.2 tslib: 2.8.1 '@smithy/middleware-endpoint@4.4.31': @@ -29752,29 +29220,29 @@ snapshots: '@smithy/middleware-endpoint@4.4.6': dependencies: - '@smithy/core': 3.23.16 - '@smithy/middleware-serde': 4.2.19 + '@smithy/core': 3.24.4 + '@smithy/middleware-serde': 4.3.1 '@smithy/node-config-provider': 4.3.14 '@smithy/shared-ini-file-loader': 4.4.3 - '@smithy/types': 4.14.1 + '@smithy/types': 4.14.2 '@smithy/url-parser': 4.2.14 '@smithy/util-middleware': 4.2.14 tslib: 2.8.1 '@smithy/middleware-endpoint@4.5.1': dependencies: - '@smithy/core': 3.24.1 + '@smithy/core': 3.24.4 tslib: 2.8.1 '@smithy/middleware-retry@4.4.22': dependencies: '@smithy/node-config-provider': 4.3.14 - '@smithy/protocol-http': 5.3.14 + '@smithy/protocol-http': 5.4.3 '@smithy/service-error-classification': 4.2.8 - '@smithy/smithy-client': 4.12.12 - '@smithy/types': 4.14.1 + '@smithy/smithy-client': 4.13.1 + '@smithy/types': 4.14.2 '@smithy/util-middleware': 4.2.14 - '@smithy/util-retry': 4.3.3 + '@smithy/util-retry': 4.4.1 '@smithy/uuid': 1.1.0 tslib: 2.8.1 @@ -29782,7 +29250,7 @@ snapshots: dependencies: '@smithy/core': 3.23.16 '@smithy/node-config-provider': 4.3.14 - '@smithy/protocol-http': 5.3.14 + '@smithy/protocol-http': 5.4.3 '@smithy/service-error-classification': 4.3.0 '@smithy/smithy-client': 4.12.12 '@smithy/types': 4.14.1 @@ -29793,25 +29261,25 @@ snapshots: '@smithy/middleware-retry@4.6.1': dependencies: - '@smithy/core': 3.24.1 + '@smithy/core': 3.24.4 tslib: 2.8.1 '@smithy/middleware-serde@4.2.19': dependencies: '@smithy/core': 3.23.16 - '@smithy/protocol-http': 5.3.14 + '@smithy/protocol-http': 5.4.3 '@smithy/types': 4.14.1 tslib: 2.8.1 '@smithy/middleware-serde@4.2.9': dependencies: - '@smithy/protocol-http': 5.3.14 - '@smithy/types': 4.14.1 + '@smithy/protocol-http': 5.4.3 + '@smithy/types': 4.14.2 tslib: 2.8.1 '@smithy/middleware-serde@4.3.1': dependencies: - '@smithy/core': 3.24.1 + '@smithy/core': 3.24.4 tslib: 2.8.1 '@smithy/middleware-stack@4.2.14': @@ -29821,7 +29289,7 @@ snapshots: '@smithy/middleware-stack@4.2.8': dependencies: - '@smithy/types': 4.14.1 + '@smithy/types': 4.14.2 tslib: 2.8.1 '@smithy/node-config-provider@4.3.14': @@ -29833,30 +29301,30 @@ snapshots: '@smithy/node-config-provider@4.3.8': dependencies: - '@smithy/property-provider': 4.2.8 + '@smithy/property-provider': 4.2.14 '@smithy/shared-ini-file-loader': 4.4.3 - '@smithy/types': 4.14.1 + '@smithy/types': 4.14.2 tslib: 2.8.1 '@smithy/node-http-handler@4.4.8': dependencies: '@smithy/abort-controller': 4.2.8 - '@smithy/protocol-http': 5.3.14 + '@smithy/protocol-http': 5.4.3 '@smithy/querystring-builder': 4.2.8 - '@smithy/types': 4.14.1 + '@smithy/types': 4.14.2 tslib: 2.8.1 '@smithy/node-http-handler@4.6.0': dependencies: - '@smithy/protocol-http': 5.3.14 + '@smithy/protocol-http': 5.4.3 '@smithy/querystring-builder': 4.2.14 '@smithy/types': 4.14.1 tslib: 2.8.1 '@smithy/node-http-handler@4.7.1': dependencies: - '@smithy/core': 3.24.1 - '@smithy/types': 4.14.1 + '@smithy/core': 3.24.4 + '@smithy/types': 4.14.2 tslib: 2.8.1 '@smithy/node-http-handler@4.7.4': @@ -29867,12 +29335,7 @@ snapshots: '@smithy/property-provider@4.2.14': dependencies: - '@smithy/types': 4.14.1 - tslib: 2.8.1 - - '@smithy/property-provider@4.2.8': - dependencies: - '@smithy/types': 4.14.1 + '@smithy/types': 4.14.2 tslib: 2.8.1 '@smithy/protocol-http@5.3.14': @@ -29880,49 +29343,49 @@ snapshots: '@smithy/types': 4.14.1 tslib: 2.8.1 - '@smithy/protocol-http@5.3.8': + '@smithy/protocol-http@5.4.3': dependencies: - '@smithy/types': 4.14.1 + '@smithy/core': 3.24.4 tslib: 2.8.1 '@smithy/querystring-builder@4.2.14': dependencies: - '@smithy/types': 4.14.1 + '@smithy/types': 4.14.2 '@smithy/util-uri-escape': 4.2.2 tslib: 2.8.1 '@smithy/querystring-builder@4.2.8': dependencies: - '@smithy/types': 4.14.1 + '@smithy/types': 4.14.2 '@smithy/util-uri-escape': 4.2.2 tslib: 2.8.1 '@smithy/querystring-parser@4.2.14': dependencies: - '@smithy/types': 4.14.1 + '@smithy/types': 4.14.2 tslib: 2.8.1 '@smithy/querystring-parser@4.2.8': dependencies: - '@smithy/types': 4.14.1 + '@smithy/types': 4.14.2 tslib: 2.8.1 '@smithy/service-error-classification@4.2.8': dependencies: - '@smithy/types': 4.14.1 + '@smithy/types': 4.14.2 '@smithy/service-error-classification@4.3.0': dependencies: - '@smithy/types': 4.14.1 + '@smithy/types': 4.14.2 '@smithy/shared-ini-file-loader@4.4.3': dependencies: - '@smithy/types': 4.14.1 + '@smithy/types': 4.14.2 tslib: 2.8.1 '@smithy/shared-ini-file-loader@4.4.9': dependencies: - '@smithy/types': 4.14.1 + '@smithy/types': 4.14.2 tslib: 2.8.1 '@smithy/signature-v4@2.3.0': @@ -29935,18 +29398,7 @@ snapshots: '@smithy/util-utf8': 2.3.0 tslib: 2.8.1 - '@smithy/signature-v4@5.3.14': - dependencies: - '@smithy/is-array-buffer': 4.2.2 - '@smithy/protocol-http': 5.3.14 - '@smithy/types': 4.14.1 - '@smithy/util-hex-encoding': 4.2.2 - '@smithy/util-middleware': 4.2.14 - '@smithy/util-uri-escape': 4.2.2 - '@smithy/util-utf8': 4.2.2 - tslib: 2.8.1 - - '@smithy/signature-v4@5.4.4': + '@smithy/signature-v4@5.4.3': dependencies: '@smithy/core': 3.24.4 '@smithy/types': 4.14.2 @@ -29954,11 +29406,11 @@ snapshots: '@smithy/smithy-client@4.10.7': dependencies: - '@smithy/core': 3.23.16 - '@smithy/middleware-endpoint': 4.4.31 + '@smithy/core': 3.24.4 + '@smithy/middleware-endpoint': 4.5.1 '@smithy/middleware-stack': 4.2.14 - '@smithy/protocol-http': 5.3.14 - '@smithy/types': 4.14.1 + '@smithy/protocol-http': 5.4.3 + '@smithy/types': 4.14.2 '@smithy/util-stream': 4.5.24 tslib: 2.8.1 @@ -29967,15 +29419,15 @@ snapshots: '@smithy/core': 3.23.16 '@smithy/middleware-endpoint': 4.4.31 '@smithy/middleware-stack': 4.2.14 - '@smithy/protocol-http': 5.3.14 + '@smithy/protocol-http': 5.4.3 '@smithy/types': 4.14.1 '@smithy/util-stream': 4.5.24 tslib: 2.8.1 '@smithy/smithy-client@4.13.1': dependencies: - '@smithy/core': 3.24.1 - '@smithy/types': 4.14.1 + '@smithy/core': 3.24.4 + '@smithy/types': 4.14.2 tslib: 2.8.1 '@smithy/types@2.12.0': @@ -30003,12 +29455,12 @@ snapshots: '@smithy/url-parser@4.2.8': dependencies: '@smithy/querystring-parser': 4.2.8 - '@smithy/types': 4.14.1 + '@smithy/types': 4.14.2 tslib: 2.8.1 '@smithy/util-base64@4.3.0': dependencies: - '@smithy/util-buffer-from': 4.2.0 + '@smithy/util-buffer-from': 4.2.2 '@smithy/util-utf8': 4.2.2 tslib: 2.8.1 @@ -30039,11 +29491,6 @@ snapshots: '@smithy/is-array-buffer': 2.2.0 tslib: 2.8.1 - '@smithy/util-buffer-from@4.2.0': - dependencies: - '@smithy/is-array-buffer': 4.2.2 - tslib: 2.8.1 - '@smithy/util-buffer-from@4.2.2': dependencies: '@smithy/is-array-buffer': 4.2.2 @@ -30059,9 +29506,9 @@ snapshots: '@smithy/util-defaults-mode-browser@4.3.21': dependencies: - '@smithy/property-provider': 4.2.8 - '@smithy/smithy-client': 4.12.12 - '@smithy/types': 4.14.1 + '@smithy/property-provider': 4.2.14 + '@smithy/smithy-client': 4.13.1 + '@smithy/types': 4.14.2 tslib: 2.8.1 '@smithy/util-defaults-mode-browser@4.3.48': @@ -30073,17 +29520,17 @@ snapshots: '@smithy/util-defaults-mode-browser@4.4.1': dependencies: - '@smithy/core': 3.24.1 + '@smithy/core': 3.24.4 tslib: 2.8.1 '@smithy/util-defaults-mode-node@4.2.24': dependencies: '@smithy/config-resolver': 4.4.17 - '@smithy/credential-provider-imds': 4.2.8 + '@smithy/credential-provider-imds': 4.3.4 '@smithy/node-config-provider': 4.3.14 - '@smithy/property-provider': 4.2.8 - '@smithy/smithy-client': 4.12.12 - '@smithy/types': 4.14.1 + '@smithy/property-provider': 4.2.14 + '@smithy/smithy-client': 4.13.1 + '@smithy/types': 4.14.2 tslib: 2.8.1 '@smithy/util-defaults-mode-node@4.2.53': @@ -30098,13 +29545,13 @@ snapshots: '@smithy/util-defaults-mode-node@4.3.1': dependencies: - '@smithy/core': 3.24.1 + '@smithy/core': 3.24.4 tslib: 2.8.1 '@smithy/util-endpoints@3.2.8': dependencies: '@smithy/node-config-provider': 4.3.14 - '@smithy/types': 4.14.1 + '@smithy/types': 4.14.2 tslib: 2.8.1 '@smithy/util-endpoints@3.4.2': @@ -30133,13 +29580,13 @@ snapshots: '@smithy/util-middleware@4.2.8': dependencies: - '@smithy/types': 4.14.1 + '@smithy/types': 4.14.2 tslib: 2.8.1 '@smithy/util-retry@4.2.8': dependencies: '@smithy/service-error-classification': 4.2.8 - '@smithy/types': 4.14.1 + '@smithy/types': 4.14.2 tslib: 2.8.1 '@smithy/util-retry@4.3.3': @@ -30150,16 +29597,16 @@ snapshots: '@smithy/util-retry@4.4.1': dependencies: - '@smithy/core': 3.24.1 + '@smithy/core': 3.24.4 tslib: 2.8.1 '@smithy/util-stream@4.5.10': dependencies: - '@smithy/fetch-http-handler': 5.3.17 - '@smithy/node-http-handler': 4.6.0 - '@smithy/types': 4.14.1 + '@smithy/fetch-http-handler': 5.4.4 + '@smithy/node-http-handler': 4.7.4 + '@smithy/types': 4.14.2 '@smithy/util-base64': 4.3.2 - '@smithy/util-buffer-from': 4.2.0 + '@smithy/util-buffer-from': 4.2.2 '@smithy/util-hex-encoding': 4.2.2 '@smithy/util-utf8': 4.2.2 tslib: 2.8.1 @@ -30177,7 +29624,7 @@ snapshots: '@smithy/util-stream@4.6.1': dependencies: - '@smithy/core': 3.24.1 + '@smithy/core': 3.24.4 tslib: 2.8.1 '@smithy/util-uri-escape@2.2.0': @@ -30195,7 +29642,7 @@ snapshots: '@smithy/util-utf8@4.2.0': dependencies: - '@smithy/util-buffer-from': 4.2.0 + '@smithy/util-buffer-from': 4.2.2 tslib: 2.8.1 '@smithy/util-utf8@4.2.2': @@ -30211,7 +29658,7 @@ snapshots: '@smithy/util-waiter@4.2.8': dependencies: '@smithy/abort-controller': 4.2.8 - '@smithy/types': 4.14.1 + '@smithy/types': 4.14.2 tslib: 2.8.1 '@smithy/uuid@1.1.0': @@ -31016,7 +30463,7 @@ snapshots: '@types/ioredis-mock@8.2.5': dependencies: '@types/node': 24.12.2 - ioredis: 5.8.2 + ioredis: 5.10.1 transitivePeerDependencies: - supports-color @@ -32131,8 +31578,8 @@ snapshots: dependencies: '@aws-crypto/sha256-js': 4.0.0 '@aws-sdk/client-sts': 3.1045.0 - '@aws-sdk/credential-providers': 3.1053.0 - '@aws-sdk/util-format-url': 3.972.10 + '@aws-sdk/credential-providers': 3.1035.0 + '@aws-sdk/util-format-url': 3.972.13 '@smithy/signature-v4': 2.3.0 '@types/buffers': 0.1.31 transitivePeerDependencies: @@ -32195,16 +31642,16 @@ snapshots: before-after-hook@3.0.2: {} - bentocache@1.5.1(patch_hash=98c0f93795fdd4f5eae32ee7915de8e9a346a24c3a917262b1f4551190f1a1af)(ioredis@5.8.2): + bentocache@1.5.1(patch_hash=98c0f93795fdd4f5eae32ee7915de8e9a346a24c3a917262b1f4551190f1a1af)(ioredis@5.10.1): dependencies: - '@boringnode/bus': 0.9.0(ioredis@5.8.2) + '@boringnode/bus': 0.9.0(ioredis@5.10.1) '@julr/utils': 1.9.0 '@poppinss/exception': 1.2.3 async-mutex: 0.5.0 lru-cache: 11.2.6 p-timeout: 7.0.1 optionalDependencies: - ioredis: 5.8.2 + ioredis: 5.10.1 better-path-resolve@1.0.0: dependencies: @@ -34224,13 +33671,6 @@ snapshots: path-expression-matcher: 1.5.0 xml-naming: 0.1.0 - fast-xml-parser@5.7.2: - dependencies: - '@nodable/entities': 2.1.0 - fast-xml-builder: 1.2.0 - path-expression-matcher: 1.5.0 - strnum: 2.2.3 - fast-xml-parser@5.7.3: dependencies: '@nodable/entities': 2.1.0 @@ -35655,21 +35095,21 @@ snapshots: ioredis-mock@8.13.1(@types/ioredis-mock@8.2.5)(ioredis@5.10.1): dependencies: '@ioredis/as-callback': 3.0.0 - '@ioredis/commands': 1.5.0 + '@ioredis/commands': 1.5.1 '@types/ioredis-mock': 8.2.5 fengari: 0.1.4 fengari-interop: 0.1.3(fengari@0.1.4) ioredis: 5.10.1 semver: 7.7.3 - ioredis-mock@8.9.0(@types/ioredis-mock@8.2.5)(ioredis@5.8.2): + ioredis-mock@8.9.0(@types/ioredis-mock@8.2.5)(ioredis@5.10.1): dependencies: '@ioredis/as-callback': 3.0.0 '@ioredis/commands': 1.2.0 '@types/ioredis-mock': 8.2.5 fengari: 0.1.4 fengari-interop: 0.1.3(fengari@0.1.4) - ioredis: 5.8.2 + ioredis: 5.10.1 semver: 7.6.2 ioredis@5.10.1: @@ -35686,20 +35126,6 @@ snapshots: transitivePeerDependencies: - supports-color - ioredis@5.8.2: - dependencies: - '@ioredis/commands': 1.4.0 - cluster-key-slot: 1.1.1 - debug: 4.4.3(supports-color@8.1.1) - denque: 2.1.0 - lodash.defaults: 4.2.0 - lodash.isarguments: 3.1.0 - redis-errors: 1.2.0 - redis-parser: 3.0.0 - standard-as-callback: 2.1.0 - transitivePeerDependencies: - - supports-color - ip-address@10.2.0: {} ipaddr.js@1.9.1: {}