From a4be6d1fbd9f237ac78efaef696d6d82f3b8df07 Mon Sep 17 00:00:00 2001 From: mish-elle <268042956+mish-elle@users.noreply.github.com> Date: Tue, 26 May 2026 10:20:39 -0400 Subject: [PATCH 1/9] feat: add AWS IAM authentication support for ElastiCache Redis Add 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: - REDIS_AWS_IAM_AUTH_ENABLED: enable IAM authentication for Redis - REDIS_AWS_IAM_CACHE_NAME: ElastiCache cache instance name for the signer - REDIS_AWS_REGION: optional override for the Redis region - REDIS_CLUSTER_MODE_ENABLED: enable Redis Cluster mode - REDIS_USERNAME: optional Redis username for ACL-based authentication --- .changeset/five-hounds-know.md | 27 ++ .../modules/shared/__tests__/redis.spec.ts | 361 ++++++++++++++ .../api/src/modules/shared/providers/redis.ts | 54 ++- packages/services/schema/README.md | 8 +- packages/services/schema/src/environment.ts | 30 ++ packages/services/schema/src/index.ts | 78 ++- packages/services/server/README.md | 8 +- packages/services/server/src/environment.ts | 30 ++ packages/services/server/src/index.ts | 37 +- packages/services/service-common/package.json | 6 + .../service-common/src/iam-aws.spec.ts | 324 +++++++++++++ .../services/service-common/src/iam-aws.ts | 130 +++++ .../service-common/src/iam-redis.spec.ts | 447 ++++++++++++++++++ .../services/service-common/src/iam-redis.ts | 202 ++++++++ packages/services/service-common/src/index.ts | 13 + packages/services/tokens/README.md | 8 +- packages/services/tokens/src/environment.ts | 32 +- packages/services/tokens/src/index.ts | 60 ++- packages/services/usage/README.md | 8 +- packages/services/usage/src/environment.ts | 30 ++ packages/services/usage/src/index.ts | 62 ++- packages/services/workflows/README.md | 10 + .../services/workflows/src/environment.ts | 30 ++ packages/services/workflows/src/index.ts | 37 +- packages/services/workflows/src/redis.ts | 54 ++- 25 files changed, 2010 insertions(+), 76 deletions(-) create mode 100644 .changeset/five-hounds-know.md create mode 100644 packages/services/api/src/modules/shared/__tests__/redis.spec.ts create mode 100644 packages/services/service-common/src/iam-aws.spec.ts create mode 100644 packages/services/service-common/src/iam-aws.ts create mode 100644 packages/services/service-common/src/iam-redis.spec.ts create mode 100644 packages/services/service-common/src/iam-redis.ts diff --git a/.changeset/five-hounds-know.md b/.changeset/five-hounds-know.md new file mode 100644 index 00000000000..525b0c25318 --- /dev/null +++ b/.changeset/five-hounds-know.md @@ -0,0 +1,27 @@ +--- +'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. diff --git a/packages/services/api/src/modules/shared/__tests__/redis.spec.ts b/packages/services/api/src/modules/shared/__tests__/redis.spec.ts new file mode 100644 index 00000000000..8906960650a --- /dev/null +++ b/packages/services/api/src/modules/shared/__tests__/redis.spec.ts @@ -0,0 +1,361 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { RedisConfig } from '../providers/redis'; + +// Create a mock logger compatible with the Logger class shape +function createMockLogger(): any { + return { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + fatal: vi.fn(), + trace: vi.fn(), + child: vi.fn().mockReturnThis(), + }; +} + +// 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 baseConfig: RedisConfig = { + host: 'localhost', + port: 6379, + password: 'test-password', + tlsEnabled: false, +}; + +describe('createRedisClient', () => { + let createRedisClient: typeof import('../providers/redis').createRedisClient; + + beforeEach(async () => { + vi.clearAllMocks(); + const mod = await import('../providers/redis'); + createRedisClient = mod.createRedisClient; + }); + + describe('standalone mode', () => { + it('creates a standalone Redis instance with correct config', () => { + const logger = createMockLogger(); + const redis = createRedisClient('test-standalone', baseConfig, logger); + + expect(redis).toBeDefined(); + expect(redis.options).toMatchObject({ + host: 'localhost', + port: 6379, + password: 'test-password', + db: 0, + maxRetriesPerRequest: null, + enableReadyCheck: false, + }); + }); + + it('sets TLS options when tlsEnabled is true', () => { + const logger = createMockLogger(); + const redis = createRedisClient('test-tls', { ...baseConfig, tlsEnabled: true }, logger); + + expect(redis.options.tls).toEqual({}); + }); + + it('does not set TLS when tlsEnabled is false', () => { + const logger = createMockLogger(); + const redis = createRedisClient('test-no-tls', baseConfig, logger); + + expect(redis.options.tls).toBeUndefined(); + }); + + it('passes username when provided', () => { + const logger = createMockLogger(); + const redis = createRedisClient('test-user', { ...baseConfig, username: 'myuser' }, logger); + + expect(redis.options.username).toBe('myuser'); + }); + + it('includes retryStrategy that caps at 2000ms', () => { + const logger = createMockLogger(); + const redis = createRedisClient('test-retry', baseConfig, 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 returns 1', () => { + const logger = createMockLogger(); + const redis = createRedisClient('test-reconnect', baseConfig, logger); + + const handler = redis.options.reconnectOnError; + expect(handler).toBeDefined(); + expect(handler!(new Error('READONLY'))).toBe(1); + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining('reconnectOnError'), + expect.any(Error), + ); + }); + }); + + describe('cluster mode', () => { + it('creates a Redis.Cluster instance when clusterModeEnabled is true', () => { + const logger = createMockLogger(); + const redis = createRedisClient( + 'test-cluster', + { ...baseConfig, clusterModeEnabled: true }, + logger, + ); + + expect(redis).toBeDefined(); + // Verify it's a cluster instance (has isCluster from our mock) + expect((redis as any).isCluster).toBe(true); + }); + + it('passes startup nodes with host and port', () => { + const logger = createMockLogger(); + const redis = createRedisClient( + 'test-cluster-nodes', + { + ...baseConfig, + host: 'clustercfg.my-cache.use1.cache.amazonaws.com', + port: 6190, + clusterModeEnabled: true, + }, + logger, + ); + + expect((redis as any).startupNodes).toEqual([ + { host: 'clustercfg.my-cache.use1.cache.amazonaws.com', port: 6190 }, + ]); + }); + + it('configures dnsLookup to bypass DNS resolution', () => { + const logger = createMockLogger(); + const redis = createRedisClient( + 'test-cluster-dns', + { ...baseConfig, clusterModeEnabled: true }, + logger, + ); + + const { dnsLookup } = (redis as any).options; + expect(dnsLookup).toBeDefined(); + + // dnsLookup should pass the address through unchanged + 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', () => { + const logger = createMockLogger(); + const redis = createRedisClient( + 'test-cluster-opts', + { ...baseConfig, username: 'iam-user', clusterModeEnabled: true }, + logger, + ); + + const { redisOptions } = (redis as any).options; + expect(redisOptions).toMatchObject({ + username: 'iam-user', + password: 'test-password', + maxRetriesPerRequest: null, + enableReadyCheck: false, + }); + }); + + it('sets TLS inside redisOptions when tlsEnabled is true', () => { + const logger = createMockLogger(); + const redis = createRedisClient( + 'test-cluster-tls', + { ...baseConfig, tlsEnabled: true, clusterModeEnabled: true }, + logger, + ); + + expect((redis as any).options.redisOptions.tls).toEqual({}); + }); + + it('omits TLS inside redisOptions when tlsEnabled is false', () => { + const logger = createMockLogger(); + const redis = createRedisClient( + 'test-cluster-no-tls', + { ...baseConfig, clusterModeEnabled: true }, + logger, + ); + + expect((redis as any).options.redisOptions.tls).toBeUndefined(); + }); + + it('includes clusterRetryStrategy that caps at 2000ms', () => { + const logger = createMockLogger(); + const redis = createRedisClient( + 'test-cluster-retry', + { ...baseConfig, clusterModeEnabled: true }, + 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', () => { + const logger = createMockLogger(); + const redis = createRedisClient( + 'test-cluster-reconnect', + { ...baseConfig, clusterModeEnabled: true }, + 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('does not create cluster when clusterModeEnabled is false/undefined', () => { + const logger = createMockLogger(); + const redis = createRedisClient('test-no-cluster', baseConfig, logger); + + expect((redis as any).isCluster).toBeUndefined(); + }); + }); + + describe('event listeners', () => { + it('attaches error listener that logs at error level', () => { + const logger = createMockLogger(); + const redis = createRedisClient('test-events', baseConfig, logger); + + redis.emit('error', new Error('connection refused')); + + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining('Redis connection error'), + expect.any(Error), + 'test-events', + ); + }); + + it('logs errors on cluster instances the same way', () => { + const logger = createMockLogger(); + const redis = createRedisClient( + 'test-cluster-err', + { ...baseConfig, clusterModeEnabled: true }, + logger, + ); + + redis.emit('error', new Error('CLUSTERDOWN')); + + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining('Redis connection error'), + expect.any(Error), + 'test-cluster-err', + ); + }); + + it('attaches connect listener that logs at debug level', () => { + const logger = createMockLogger(); + const redis = createRedisClient('test-connect', baseConfig, logger); + + redis.emit('connect'); + + expect(logger.debug).toHaveBeenCalledWith( + expect.stringContaining('Redis connection established'), + 'test-connect', + ); + }); + + it('attaches ready listener that logs at info level', () => { + const logger = createMockLogger(); + const redis = createRedisClient('test-ready', baseConfig, logger); + + redis.emit('ready'); + + expect(logger.info).toHaveBeenCalledWith( + expect.stringContaining('Redis connection ready'), + 'test-ready', + ); + }); + + it('attaches close listener that logs at info level', () => { + const logger = createMockLogger(); + const redis = createRedisClient('test-close', baseConfig, logger); + + redis.emit('close'); + + expect(logger.info).toHaveBeenCalledWith( + expect.stringContaining('Redis connection closed'), + 'test-close', + ); + }); + + it('attaches reconnecting listener', () => { + const logger = createMockLogger(); + const redis = createRedisClient('test-reconnecting', baseConfig, logger); + + redis.emit('reconnecting', 500); + + expect(logger.info).toHaveBeenCalledWith( + expect.stringContaining('Redis reconnecting'), + 500, + 'test-reconnecting', + ); + }); + }); + + describe('return value', () => { + it('returns the redis instance synchronously', () => { + const logger = createMockLogger(); + const result = createRedisClient('sync-test', baseConfig, logger); + + // Verify it's not a promise + expect(result).not.toBeInstanceOf(Promise); + expect(result).toBeDefined(); + }); + }); +}); diff --git a/packages/services/api/src/modules/shared/providers/redis.ts b/packages/services/api/src/modules/shared/providers/redis.ts index 2604fd49d20..b50d32bf492 100644 --- a/packages/services/api/src/modules/shared/providers/redis.ts +++ b/packages/services/api/src/modules/shared/providers/redis.ts @@ -7,27 +7,49 @@ export type { RedisInstance as Redis }; export type RedisConfig = Required> & { tlsEnabled: boolean; + username?: string; + clusterModeEnabled?: 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, - }); + let redis: RedisInstance; + if (config.clusterModeEnabled) { + redis = new Redis.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: null, + enableReadyCheck: false, + tls: config.tlsEnabled ? {} : undefined, + }, + clusterRetryStrategy: (times: number) => Math.min(times * 500, 2000), + }) as unknown as RedisInstance; + } else { + redis = new Redis({ + 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: null, + enableReadyCheck: false, + tls: config.tlsEnabled ? {} : undefined, + }); + } redis.on('error', err => { logger.error('Redis connection error (error=%s,label=%s)', err, label); 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/src/environment.ts b/packages/services/schema/src/environment.ts index e723577a703..6e100f7666a 100644 --- a/packages/services/schema/src/environment.ts +++ b/packages/services/schema/src/environment.ts @@ -27,6 +27,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), }); @@ -64,7 +65,16 @@ 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()), }); const PrometheusModel = zod.object({ @@ -118,6 +128,21 @@ for (const config of Object.values(configs)) { } } +if (configs.redis.success && configs.redis.data.REDIS_AWS_IAM_AUTH_ENABLED === '1') { + const missingRedisIamVars: string[] = []; + if (configs.redis.data.REDIS_TLS_ENABLED !== '1') + missingRedisIamVars.push('REDIS_TLS_ENABLED must be enabled (ElastiCache IAM requires TLS)'); + if (!configs.redis.data.REDIS_AWS_IAM_CACHE_NAME) + missingRedisIamVars.push('REDIS_AWS_IAM_CACHE_NAME'); + if (!configs.redis.data.REDIS_AWS_REGION && !configs.base.data?.AWS_REGION) + missingRedisIamVars.push('REDIS_AWS_REGION or AWS_REGION'); + if (missingRedisIamVars.length > 0) { + environmentErrors.push( + `REDIS_AWS_IAM_AUTH_ENABLED is enabled but the following required variables are missing or invalid: ${missingRedisIamVars.join(', ')}`, + ); + } +} + if (environmentErrors.length) { const fullError = environmentErrors.join(`\n`); console.error('❌ Invalid environment variables:', fullError); @@ -160,7 +185,12 @@ export const env = { 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 ?? base.AWS_REGION, + awsIamAuthCacheName: redis.REDIS_AWS_IAM_CACHE_NAME, }, sentry: sentry.SENTRY === '1' ? { dsn: sentry.SENTRY_DSN } : null, log: { diff --git a/packages/services/schema/src/index.ts b/packages/services/schema/src/index.ts index 2e1447177ca..0af717aac29 100644 --- a/packages/services/schema/src/index.ts +++ b/packages/services/schema/src/index.ts @@ -8,7 +8,9 @@ import { registerShutdown, registerTRPC, reportReadiness, + resolveRedisCredentials, sentryInit, + startIamTokenRefresh, startMetrics, TracingInstance, } from '@hive/service-common'; @@ -71,22 +73,66 @@ async function main() { const errorHandler = createErrorHandler(server); - const redis = new Redis({ - host: env.redis.host, - port: env.redis.port, - password: env.redis.password, - retryStrategy(times) { - return Math.min(times * 500, 2000); - }, - reconnectOnError(error) { - server.log.warn('Redis reconnectOnError (error=%s)', error); - return 1; - }, - db: 0, - maxRetriesPerRequest: null, - enableReadyCheck: false, - tls: env.redis.tlsEnabled ? {} : undefined, - }); + const redisIamConfig = env.redis.awsIamAuthEnabled + ? { + host: env.redis.host, + port: env.redis.port, + awsRegion: env.redis.awsRegion ?? '', + username: env.redis.username ?? 'default', + iamAuthCacheName: env.redis.awsIamAuthCacheName, + } + : undefined; + + const { password: redisPassword, username: redisUsername } = await resolveRedisCredentials( + env.redis.password, + server.log.child({ source: 'RedisCredentialResolver' }), + redisIamConfig, + ); + + let redis: Redis; + if (env.redis.clusterModeEnabled) { + redis = new Redis.Cluster([{ host: env.redis.host, port: env.redis.port }], { + dnsLookup: (address, callback) => callback(null, address), + redisOptions: { + username: redisUsername, + password: redisPassword, + maxRetriesPerRequest: null, + enableReadyCheck: false, + tls: env.redis.tlsEnabled ? {} : undefined, + reconnectOnError(error) { + server.log.warn('Redis reconnectOnError (error=%s)', error); + return 1; + }, + }, + }) as unknown as Redis; + } else { + redis = new Redis({ + host: env.redis.host, + port: env.redis.port, + username: redisUsername, + password: redisPassword, + retryStrategy(times) { + return Math.min(times * 500, 2000); + }, + reconnectOnError(error) { + server.log.warn('Redis reconnectOnError (error=%s)', error); + return 1; + }, + db: 0, + maxRetriesPerRequest: null, + enableReadyCheck: false, + tls: env.redis.tlsEnabled ? {} : undefined, + }); + } + + if (redisIamConfig) { + startIamTokenRefresh( + redis, + redisIamConfig, + env.redis.clusterModeEnabled, + server.log.child({ source: 'RedisIamTokenRefresh' }), + ); + } try { redis.on('error', err => { 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/src/environment.ts b/packages/services/server/src/environment.ts index 81ad5b34e1e..720463b961a 100644 --- a/packages/services/server/src/environment.ts +++ b/packages/services/server/src/environment.ts @@ -51,6 +51,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()), }); const CommerceModel = zod.object({ @@ -105,7 +106,16 @@ 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()), }); const SuperTokensModel = zod.object({ @@ -322,6 +332,21 @@ for (const config of Object.values(configs)) { } } +if (configs.redis.success && configs.redis.data.REDIS_AWS_IAM_AUTH_ENABLED === '1') { + const missingRedisIamVars: string[] = []; + if (configs.redis.data.REDIS_TLS_ENABLED !== '1') + missingRedisIamVars.push('REDIS_TLS_ENABLED must be enabled (ElastiCache IAM requires TLS)'); + if (!configs.redis.data.REDIS_AWS_IAM_CACHE_NAME) + missingRedisIamVars.push('REDIS_AWS_IAM_CACHE_NAME'); + if (!configs.redis.data.REDIS_AWS_REGION && !configs.base.data?.AWS_REGION) + missingRedisIamVars.push('REDIS_AWS_REGION or AWS_REGION'); + if (missingRedisIamVars.length > 0) { + environmentErrors.push( + `REDIS_AWS_IAM_AUTH_ENABLED is enabled but the following required variables are missing or invalid: ${missingRedisIamVars.join(', ')}`, + ); + } +} + if (environmentErrors.length) { const fullError = environmentErrors.join(`\n`); console.error('❌ Invalid environment variables:', fullError); @@ -444,7 +469,12 @@ export const env = { 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 ?? base.AWS_REGION, + awsIamAuthCacheName: redis.REDIS_AWS_IAM_CACHE_NAME, }, supertokens: { secrets: { diff --git a/packages/services/server/src/index.ts b/packages/services/server/src/index.ts index 24b2f82aa96..9359582460b 100644 --- a/packages/services/server/src/index.ts +++ b/packages/services/server/src/index.ts @@ -34,7 +34,9 @@ import { registerShutdown, registerTRPC, reportReadiness, + resolveRedisCredentials, sentryInit, + startIamTokenRefresh, startMetrics, TracingInstance, } from '@hive/service-common'; @@ -171,17 +173,48 @@ export async function main() { ); const taskScheduler = new TaskScheduler(storage.pool); - const redis = createRedisClient('Redis', env.redis, server.log.child({ source: 'Redis' })); + const redisIamConfig = env.redis.awsIamAuthEnabled + ? { + host: env.redis.host, + port: env.redis.port, + awsRegion: env.redis.awsRegion ?? '', + username: env.redis.username ?? 'default', + iamAuthCacheName: env.redis.awsIamAuthCacheName, + } + : undefined; + + const { password: redisPassword, username: redisUsername } = await resolveRedisCredentials( + env.redis.password, + server.log.child({ source: 'RedisCredentialResolver' }), + redisIamConfig, + ); + + const redisConfig = { + ...env.redis, + password: redisPassword, + username: redisUsername, + }; + + const redis = createRedisClient('Redis', redisConfig, server.log.child({ source: 'Redis' })); const pubSub = createHivePubSub({ publisher: redis, subscriber: createRedisClient( 'subscriber', - env.redis, + redisConfig, server.log.child({ source: 'RedisSubscribe' }), ), }); + if (redisIamConfig) { + startIamTokenRefresh( + redis, + redisIamConfig, + env.redis.clusterModeEnabled, + server.log.child({ source: 'RedisIamTokenRefresh' }), + ); + } + registerShutdown({ logger: server.log, async onShutdown() { diff --git a/packages/services/service-common/package.json b/packages/services/service-common/package.json index 0fbb0a5e82d..b8fc0fe4e24 100644 --- a/packages/services/service-common/package.json +++ b/packages/services/service-common/package.json @@ -35,6 +35,12 @@ "opentelemetry-instrumentation-fetch-node": "1.2.3", "p-retry": "6.2.1", "prom-client": "15.1.3", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/credential-providers": "3.1035.0", + "@smithy/protocol-http": "5.4.3", + "@smithy/signature-v4": "5.4.3", + "@aws-sdk/util-format-url": "3.972.13", + "ioredis": "5.8.2", "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..f08d36e68b0 --- /dev/null +++ b/packages/services/service-common/src/iam-aws.spec.ts @@ -0,0 +1,324 @@ +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('interval scheduling', () => { + it('refreshes every (TOKEN_TTL - backoff) seconds', async () => { + const { startTokenRefreshTimer } = await import('./iam-aws'); + const refreshFn = vi.fn().mockResolvedValue(undefined); + + const timer = 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); + + clearInterval(timer); + }); + + it('accepts a larger backoff to shorten the refresh cycle', async () => { + const { startTokenRefreshTimer } = await import('./iam-aws'); + const refreshFn = vi.fn().mockResolvedValue(undefined); + + const timer = startTokenRefreshTimer(refreshFn, { + backoffRefreshSeconds: 180, + jitterMs: 0, + }); + + // (900 - 180) * 1000 = 720_000ms + await vi.advanceTimersByTimeAsync(720_000); + expect(refreshFn).toHaveBeenCalledTimes(1); + + clearInterval(timer); + }); + + it('defaults to 60s backoff when no options are provided', async () => { + const { startTokenRefreshTimer } = await import('./iam-aws'); + const refreshFn = vi.fn().mockResolvedValue(undefined); + + const timer = startTokenRefreshTimer(refreshFn); + + // (900 - 60) * 1000 = 840_000ms, jitter = 0 (mocked Math.random) + await vi.advanceTimersByTimeAsync(840_000); + expect(refreshFn).toHaveBeenCalledTimes(1); + + clearInterval(timer); + }); + + it('stops when the returned handle is cleared', async () => { + const { startTokenRefreshTimer } = await import('./iam-aws'); + const refreshFn = vi.fn().mockResolvedValue(undefined); + + const timer = startTokenRefreshTimer(refreshFn, { + backoffRefreshSeconds: 60, + jitterMs: 0, + }); + + clearInterval(timer); + + await vi.advanceTimersByTimeAsync(840_000); + expect(refreshFn).not.toHaveBeenCalled(); + }); + }); + + 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 timer = 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); + + clearInterval(timer); + }); + + 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 timer = startTokenRefreshTimer(refreshFn, { + backoffRefreshSeconds: 60, + jitterMs: 0, + maxRetries: 3, + }); + + await vi.advanceTimersByTimeAsync(840_000); + + expect(refreshFn).toHaveBeenCalledTimes(1); + expect(refreshFn).toHaveBeenCalledWith(1, 3); + + clearInterval(timer); + }); + + 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 timer = 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); + + clearInterval(timer); + }); + }); +}); 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..8006fdbe71f --- /dev/null +++ b/packages/services/service-common/src/iam-aws.ts @@ -0,0 +1,130 @@ +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 {@link TokenRefreshTimerOptions.maxRetries | 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 `setInterval` handle that can be passed to `clearInterval` to stop + * the refresh loop (e.g. during graceful shutdown). + */ +export function startTokenRefreshTimer( + refreshFn: (attempt: number, maxRetries: number) => Promise, + options?: TokenRefreshTimerOptions, +): ReturnType { + const backoff = options?.backoffRefreshSeconds ?? 60; + const intervalMs = (SIGV4_TOKEN_TTL_SECONDS - backoff) * 1000; + const jitterMs = Math.floor(Math.random() * (options?.jitterMs ?? 30_000)); + const maxRetries = options?.maxRetries ?? 3; + const retryBackoffMs = options?.retryBackoffMs ?? 5_000; + + return setInterval(() => { + void (async () => { + for (let attempt = 1; attempt <= maxRetries; attempt++) { + try { + await refreshFn(attempt, maxRetries); + return; + } catch { + if (attempt < maxRetries) { + await new Promise(r => setTimeout(r, attempt * retryBackoffMs)); + } + } + } + })(); + }, intervalMs + jitterMs); +} 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..5a9a04944d8 --- /dev/null +++ b/packages/services/service-common/src/iam-redis.spec.ts @@ -0,0 +1,447 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +function createMockLogger() { + return { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: 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', false, 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', false, logger), + ).rejects.toThrow('WRONGPASS'); + + // Password should NOT be updated on failure + expect(mockRedis.options.password).toBe('old-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 = { + nodes: vi.fn().mockReturnValue([node1, node2, node3]), + connectionPool: { redisOptions: { password: 'old' } }, + } as any; + + await refreshIamAuth(mockCluster, 'new-cluster-token', 'myuser', true, 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.connectionPool.redisOptions.password).toBe('new-cluster-token'); + }); + + it('updates connectionPool.redisOptions so new node connections use the fresh token', async () => { + const { refreshIamAuth } = await import('./iam-redis'); + const logger = createMockLogger(); + + const pool = { redisOptions: { password: 'old-password' } }; + const mockCluster = { + nodes: vi.fn().mockReturnValue([]), + connectionPool: pool, + } as any; + + await refreshIamAuth(mockCluster, 'refreshed-token', 'user', true, logger); + + expect(pool.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 = { + nodes: vi.fn().mockReturnValue([healthyNode, failingNode]), + connectionPool: { redisOptions: { password: 'old' } }, + } as any; + + // Should not throw — individual node failures are caught + await refreshIamAuth(mockCluster, 'token', 'default', true, logger); + + expect(healthyNode.options.password).toBe('token'); + expect(failingNode.options.password).toBe('old'); // Not updated on failure + 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 connectionPool gracefully', async () => { + const { refreshIamAuth } = await import('./iam-redis'); + const logger = createMockLogger(); + + const mockCluster = { + nodes: vi.fn().mockReturnValue([]), + connectionPool: undefined, + } as any; + + await expect( + refreshIamAuth(mockCluster, 'token', 'user', true, logger), + ).resolves.toBeUndefined(); + }); + + it('handles missing nodes() method gracefully', async () => { + const { refreshIamAuth } = await import('./iam-redis'); + const logger = createMockLogger(); + + const mockCluster = { + connectionPool: { redisOptions: { password: 'old' } }, + } as any; + + await expect( + refreshIamAuth(mockCluster, 'token', 'user', true, 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, false, 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'); + + clearInterval(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' }, + false, + logger, + ); + + clearInterval(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' }, + false, + 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'); + + clearInterval(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' }, + false, + logger, + ); + + await vi.advanceTimersByTimeAsync(720_000); + await vi.advanceTimersByTimeAsync(60_000); + + expect(logger.error).toHaveBeenCalledWith(expect.stringContaining('exhausted all retries')); + + clearInterval(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..66ce620bea8 --- /dev/null +++ b/packages/services/service-common/src/iam-redis.ts @@ -0,0 +1,202 @@ +import type { Redis as RedisInstance } from 'ioredis'; +import { generatePresignedToken, startTokenRefreshTimer } from './iam-aws'; + +/** + * 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: { debug(...args: unknown[]): void }, +): 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**, iterates over every known node, issues `AUTH`, and + * updates both the per-node password and the cluster `connectionPool` so + * newly created connections also use the new token. Individual node failures + * are logged as warnings but do not abort the loop. + * + * In **standalone mode**, issues a single `AUTH` command and updates the + * connection's stored password. + * + * @param redis - The active ioredis client (standalone or cluster). + * @param token - The fresh SigV4 IAM auth token. + * @param username - The ElastiCache IAM username. + * @param isCluster - `true` when `redis` is a `Redis.Cluster` instance. + * @param logger - Logger with `debug` and `warn` methods. + */ +export async function refreshIamAuth( + redis: RedisInstance, + token: string, + username: string, + isCluster: boolean, + logger: { debug(...args: unknown[]): void; warn(...args: unknown[]): void }, +): Promise { + if (isCluster) { + const nodes = (redis as any).nodes?.('all') ?? []; + logger.debug( + 'Refreshing IAM token (service=elasticache) — re-authenticating %s cluster node(s)', + nodes.length, + ); + for (const node of nodes) { + try { + await node.call('AUTH', username, token); + node.options.password = token; + } catch (err) { + logger.warn('Failed to re-AUTH cluster node (service=elasticache, error=%s)', err); + } + } + // Update cluster-level redisOptions for new node connections. + const pool = (redis as any).connectionPool; + if (pool?.redisOptions) { + pool.redisOptions.password = token; + } + } else { + await (redis as any).call('AUTH', username, token); + (redis as any).options.password = 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 isCluster - `true` when `redis` is a `Redis.Cluster` instance. + * @param logger - Logger with `debug`, `warn`, and `error` methods. + * @returns A `setInterval` handle — call `clearInterval(handle)` during + * graceful shutdown to stop the refresh loop. + */ +export function startIamTokenRefresh( + redis: RedisInstance, + config: IamRedisConfig, + isCluster: boolean, + logger: { + debug(...args: unknown[]): void; + warn(...args: unknown[]): void; + error(...args: unknown[]): void; + }, +): ReturnType { + 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, isCluster, 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: { debug(...args: unknown[]): void }, + 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 b9f1753e9e5..244840ef7e1 100644 --- a/packages/services/service-common/src/index.ts +++ b/packages/services/service-common/src/index.ts @@ -10,3 +10,16 @@ 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'; 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/src/environment.ts b/packages/services/tokens/src/environment.ts index 147ce2a1eee..167d4d07145 100644 --- a/packages/services/tokens/src/environment.ts +++ b/packages/services/tokens/src/environment.ts @@ -25,6 +25,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([ @@ -50,7 +51,16 @@ 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()), }); const PrometheusModel = zod.object({ @@ -107,6 +117,21 @@ for (const config of Object.values(configs)) { } } +if (configs.redis.success && configs.redis.data.REDIS_AWS_IAM_AUTH_ENABLED === '1') { + const missingRedisIamVars: string[] = []; + if (configs.redis.data.REDIS_TLS_ENABLED !== '1') + missingRedisIamVars.push('REDIS_TLS_ENABLED must be enabled (ElastiCache IAM requires TLS)'); + if (!configs.redis.data.REDIS_AWS_IAM_CACHE_NAME) + missingRedisIamVars.push('REDIS_AWS_IAM_CACHE_NAME'); + if (!configs.redis.data.REDIS_AWS_REGION && !configs.base.data?.AWS_REGION) + missingRedisIamVars.push('REDIS_AWS_REGION or AWS_REGION'); + if (missingRedisIamVars.length > 0) { + environmentErrors.push( + `REDIS_AWS_IAM_AUTH_ENABLED is enabled but the following required variables are missing or invalid: ${missingRedisIamVars.join(', ')}`, + ); + } +} + if (environmentErrors.length) { const fullError = environmentErrors.join(`\n`); console.error('❌ Invalid environment variables:', fullError); @@ -154,8 +179,13 @@ export const env = { redis: { host: redis.REDIS_HOST, port: redis.REDIS_PORT, - password: redis.REDIS_PASSWORD, + 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 ?? base.AWS_REGION, + awsIamAuthCacheName: redis.REDIS_AWS_IAM_CACHE_NAME, }, heartbeat: base.HEARTBEAT_ENDPOINT ? { endpoint: base.HEARTBEAT_ENDPOINT } : null, sentry: sentry.SENTRY === '1' ? { dsn: sentry.SENTRY_DSN } : null, diff --git a/packages/services/tokens/src/index.ts b/packages/services/tokens/src/index.ts index 53234b875bb..b3d4244def2 100644 --- a/packages/services/tokens/src/index.ts +++ b/packages/services/tokens/src/index.ts @@ -10,9 +10,11 @@ import { registerShutdown, registerTRPC, reportReadiness, + resolveRedisCredentials, SamplingDecision, sentryInit, startHeartbeats, + startIamTokenRefresh, startMetrics, TracingInstance, } from '@hive/service-common'; @@ -70,15 +72,55 @@ export async function main() { const errorHandler = createErrorHandler(server); - 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 redisIamConfig = env.redis.awsIamAuthEnabled + ? { + host: env.redis.host, + port: env.redis.port, + awsRegion: env.redis.awsRegion ?? '', + username: env.redis.username ?? 'default', + iamAuthCacheName: env.redis.awsIamAuthCacheName, + } + : undefined; + + const { password: redisPassword, username: redisUsername } = await resolveRedisCredentials( + env.redis.password, + server.log.child({ source: 'RedisCredentialResolver' }), + redisIamConfig, + ); + + let redis: Redis; + if (env.redis.clusterModeEnabled) { + redis = new Redis.Cluster([{ host: env.redis.host, port: env.redis.port }], { + dnsLookup: (address, callback) => callback(null, address), + redisOptions: { + username: redisUsername, + password: redisPassword, + maxRetriesPerRequest: 20, + enableReadyCheck: false, + tls: env.redis.tlsEnabled ? {} : undefined, + }, + }) as unknown as Redis; + } else { + redis = new Redis({ + host: env.redis.host, + port: env.redis.port, + username: redisUsername, + password: redisPassword, + maxRetriesPerRequest: 20, + db: 0, + enableReadyCheck: false, + tls: env.redis.tlsEnabled ? {} : undefined, + }); + } + + if (redisIamConfig) { + startIamTokenRefresh( + redis, + redisIamConfig, + env.redis.clusterModeEnabled, + server.log.child({ source: 'RedisIamTokenRefresh' }), + ); + } const storage = await createStorage( env.postgres, diff --git a/packages/services/usage/README.md b/packages/services/usage/README.md index f23c087624e..be386d0024b 100644 --- a/packages/services/usage/README.md +++ b/packages/services/usage/README.md @@ -33,8 +33,14 @@ 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) | +| `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_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/src/environment.ts b/packages/services/usage/src/environment.ts index 4bb23b07053..8e0cc1a2d0a 100644 --- a/packages/services/usage/src/environment.ts +++ b/packages/services/usage/src/environment.ts @@ -28,6 +28,7 @@ const EnvironmentModel = zod.object({ RATE_LIMIT_TTL: emptyString(NumberFromString.optional()).default(30_000), ENVIRONMENT: emptyString(zod.string().optional()), RELEASE: emptyString(zod.string().optional()), + AWS_REGION: emptyString(zod.string().optional()), }); const SentryModel = zod.union([ @@ -80,7 +81,16 @@ 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()), }); const PrometheusModel = zod.object({ @@ -127,6 +137,21 @@ for (const config of Object.values(configs)) { } } +if (configs.redis.success && configs.redis.data.REDIS_AWS_IAM_AUTH_ENABLED === '1') { + const missingRedisIamVars: string[] = []; + if (configs.redis.data.REDIS_TLS_ENABLED !== '1') + missingRedisIamVars.push('REDIS_TLS_ENABLED must be enabled (ElastiCache IAM requires TLS)'); + if (!configs.redis.data.REDIS_AWS_IAM_CACHE_NAME) + missingRedisIamVars.push('REDIS_AWS_IAM_CACHE_NAME'); + if (!configs.redis.data.REDIS_AWS_REGION && !configs.base.data?.AWS_REGION) + missingRedisIamVars.push('REDIS_AWS_REGION or AWS_REGION'); + if (missingRedisIamVars.length > 0) { + environmentErrors.push( + `REDIS_AWS_IAM_AUTH_ENABLED is enabled but the following required variables are missing or invalid: ${missingRedisIamVars.join(', ')}`, + ); + } +} + if (environmentErrors.length) { const fullError = environmentErrors.join(`\n`); console.error('❌ Invalid environment variables:', fullError); @@ -217,7 +242,12 @@ export const env = { 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 ?? base.AWS_REGION, + awsIamAuthCacheName: redis.REDIS_AWS_IAM_CACHE_NAME, }, log: { level: log.LOG_LEVEL ?? 'info', diff --git a/packages/services/usage/src/index.ts b/packages/services/usage/src/index.ts index 6dc5d21f865..b78cc1105e9 100644 --- a/packages/services/usage/src/index.ts +++ b/packages/services/usage/src/index.ts @@ -10,7 +10,9 @@ import { createServer, registerShutdown, reportReadiness, + resolveRedisCredentials, sentryInit, + startIamTokenRefresh, startMetrics, TracingInstance, } from '@hive/service-common'; @@ -52,16 +54,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 +63,56 @@ async function main() { }, }); + const redisIamConfig = env.redis.awsIamAuthEnabled + ? { + host: env.redis.host, + port: env.redis.port, + awsRegion: env.redis.awsRegion ?? '', + username: env.redis.username ?? 'default', + iamAuthCacheName: env.redis.awsIamAuthCacheName, + } + : undefined; + + const { password: redisPassword, username: redisUsername } = await resolveRedisCredentials( + env.redis.password, + server.log.child({ source: 'RedisCredentialResolver' }), + redisIamConfig, + ); + + let redis: Redis; + if (env.redis.clusterModeEnabled) { + redis = new Redis.Cluster([{ host: env.redis.host, port: env.redis.port }], { + dnsLookup: (address, callback) => callback(null, address), + redisOptions: { + username: redisUsername, + password: redisPassword, + maxRetriesPerRequest: 20, + enableReadyCheck: false, + tls: env.redis.tlsEnabled ? {} : undefined, + }, + }) as unknown as Redis; + } else { + redis = new Redis({ + host: env.redis.host, + port: env.redis.port, + username: redisUsername, + password: redisPassword, + maxRetriesPerRequest: 20, + db: 0, + enableReadyCheck: false, + tls: env.redis.tlsEnabled ? {} : undefined, + }); + } + + if (redisIamConfig) { + startIamTokenRefresh( + redis, + redisIamConfig, + env.redis.clusterModeEnabled, + server.log.child({ source: 'RedisIamTokenRefresh' }), + ); + } + const pgPool = await createPostgresDatabasePool({ connectionParameters: env.postgres, maximumPoolSize: 5, 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/src/environment.ts b/packages/services/workflows/src/environment.ts index ee7aa174d7f..3850fd4a9f3 100644 --- a/packages/services/workflows/src/environment.ts +++ b/packages/services/workflows/src/environment.ts @@ -29,6 +29,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([ @@ -89,7 +90,16 @@ 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()), }); const RequestBrokerModel = zod.union([ @@ -148,6 +158,21 @@ for (const config of Object.values(configs)) { } } +if (configs.redis.success && configs.redis.data.REDIS_AWS_IAM_AUTH_ENABLED === '1') { + const missingRedisIamVars: string[] = []; + if (configs.redis.data.REDIS_TLS_ENABLED !== '1') + missingRedisIamVars.push('REDIS_TLS_ENABLED must be enabled (ElastiCache IAM requires TLS)'); + if (!configs.redis.data.REDIS_AWS_IAM_CACHE_NAME) + missingRedisIamVars.push('REDIS_AWS_IAM_CACHE_NAME'); + if (!configs.redis.data.REDIS_AWS_REGION && !configs.base.data?.AWS_REGION) + missingRedisIamVars.push('REDIS_AWS_REGION or AWS_REGION'); + if (missingRedisIamVars.length > 0) { + environmentErrors.push( + `REDIS_AWS_IAM_AUTH_ENABLED is enabled but the following required variables are missing or invalid: ${missingRedisIamVars.join(', ')}`, + ); + } +} + if (environmentErrors.length) { const fullError = environmentErrors.join(`\n`); console.error('❌ Invalid environment variables:', fullError); @@ -258,6 +283,11 @@ export const env = { 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 ?? base.AWS_REGION, + awsIamAuthCacheName: redis.REDIS_AWS_IAM_CACHE_NAME, }, } as const; diff --git a/packages/services/workflows/src/index.ts b/packages/services/workflows/src/index.ts index 1e22f26b3c6..d96d4a89b66 100644 --- a/packages/services/workflows/src/index.ts +++ b/packages/services/workflows/src/index.ts @@ -6,8 +6,10 @@ import { createServer, registerShutdown, reportReadiness, + resolveRedisCredentials, sentryInit, startHeartbeats, + startIamTokenRefresh, startMetrics, } from '@hive/service-common'; import { Context } from './context.js'; @@ -75,17 +77,48 @@ const server = await createServer({ log: logger, }); -const redis = createRedisClient('Redis', env.redis, server.log.child({ source: 'Redis' })); +const redisIamConfig = env.redis.awsIamAuthEnabled + ? { + host: env.redis.host, + port: env.redis.port, + awsRegion: env.redis.awsRegion ?? '', + username: env.redis.username ?? 'default', + iamAuthCacheName: env.redis.awsIamAuthCacheName, + } + : undefined; + +const { password: redisPassword, username: redisUsername } = await resolveRedisCredentials( + env.redis.password, + server.log.child({ source: 'RedisCredentialResolver' }), + redisIamConfig, +); + +const redisConfig = { + ...env.redis, + password: redisPassword, + username: redisUsername, +}; + +const redis = createRedisClient('Redis', redisConfig, server.log.child({ source: 'Redis' })); const pubSub = createHivePubSub({ publisher: redis, subscriber: createRedisClient( 'subscriber', - env.redis, + redisConfig, server.log.child({ source: 'RedisSubscribe' }), ), }); +if (redisIamConfig) { + startIamTokenRefresh( + redis, + redisIamConfig, + env.redis.clusterModeEnabled, + server.log.child({ source: 'RedisIamTokenRefresh' }), + ); +} + const context: Context = { logger, email: createEmailProvider(env.email.provider, env.email.emailFrom), diff --git a/packages/services/workflows/src/redis.ts b/packages/services/workflows/src/redis.ts index cb1a86034e3..e35841ea725 100644 --- a/packages/services/workflows/src/redis.ts +++ b/packages/services/workflows/src/redis.ts @@ -10,25 +10,47 @@ export type { RedisInstance as Redis }; export type RedisConfig = Required> & { tlsEnabled: boolean; + username?: string; + clusterModeEnabled?: 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, - }); + let redis: RedisInstance; + if (config.clusterModeEnabled) { + redis = new Redis.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: null, + enableReadyCheck: false, + tls: config.tlsEnabled ? {} : undefined, + }, + clusterRetryStrategy: (times: number) => Math.min(times * 500, 2000), + }) as unknown as RedisInstance; + } else { + redis = new Redis({ + 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: null, + enableReadyCheck: false, + tls: config.tlsEnabled ? {} : undefined, + }); + } redis.on('error', err => { logger.error('Redis connection error (error=%s,label=%s)', err, label); From e55ebfcb3917ad070d6f38651893c028781b914f Mon Sep 17 00:00:00 2001 From: Michelle Song Date: Tue, 26 May 2026 18:03:02 -0400 Subject: [PATCH 2/9] fix: address PR review feedback for Redis IAM authentication - Fix refreshIamAuth to set password BEFORE AUTH call (prevents auth failures) - Add timer initialization for pubsub Redis client - Enhance test coverage with unhappy paths and organized test structure - Improve JSDoc comments for AWS IAM interfaces and functions --- packages/services/server/src/index.ts | Bin 21950 -> 43754 bytes packages/services/service-common/package.json | 12 ++++++------ .../service-common/src/iam-aws.spec.ts | Bin 9985 -> 22134 bytes .../services/service-common/src/iam-aws.ts | Bin 4938 -> 10520 bytes .../service-common/src/iam-redis.spec.ts | Bin 14354 -> 29608 bytes .../services/service-common/src/iam-redis.ts | 10 +++++----- packages/services/workflows/src/index.ts | Bin 5990 -> 12884 bytes 7 files changed, 11 insertions(+), 11 deletions(-) diff --git a/packages/services/server/src/index.ts b/packages/services/server/src/index.ts index d84715083facda523429f9194a31860049dc2792..ac0dca54b6bb76dbcb592a81921d56d4e581d432 100644 GIT binary patch literal 43754 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 literal 21950 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 UUr#0HE#vvDSpmso#*Rn-1MK?mX#fBK literal 9985 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 literal 4938 zcma)9+j84R5`EWK^cz8&AgPU%s?e5fS?gG;jcl(-J9$X0IRb_xA`rmN3@9^NZtc^4 zz~&G0B{@A80J^2tFBXBBp6=6UP9J3Js#JzzT+ z@_1q2$F<74ho2c!UCMvfQk!<4UT0>t9pM;LCS@M4u&BFfnk@@qY9&7(JlLeeyLfYe zJ$s{P&-UmUy_1DhSwbg2PU-yQf;gqjWKt9IA_PRJO39eF%alxcEel%mCSkI&7$U}~ z33egA(ros_pKR3x2(?VgBGnY_AJRH2Y9n>u1;TUGJ3W8*`QXd!!{6S%|AKH|rf*L_ zy#MQzj_AAbxJ~`EEEd_ac8SZGhBMRK?t(!3{gp-q;EWF>p^{*a*}RBt0q zNE?MMFFJ=Pxdf*FclcVz5(mWlZEzRie(bYS8=JUXU&%CWlSgNLW0mJxWVRJD&hxFU zF3D>Rj7C=qxG0iET9rU8)Uxt{?;rU0TBb(sEoF`6MYU zc|@`8cAAO87vxQy<>0Cyy)Dphw3E(evyb%d?TkEVI27#%wGG|if_h5SnhC)a@ws!f zRxBv7auVIX&a>j$YSII-+4Tn`iWKMIQsV#)=KBTMIG#O^9|uj~%eu(ES{Q(NI;0ui zqRjx$=3_5#rm2t{G*m;mNT9requIiIQA0X6gmsnB%ywSW&EAwY8zaz~U$L5K0(UhueFEw%$-@c0{aU)UX2=i9wn42KI4i!^F30 z7#_ISxVx(|#U60o@2G@X;larN^jFYfGqk^aWGOH)^~B=98Js~Wo;YU5TXv|uDaSTd zp>nQf8V1+r17A&}p#EI{+npQ~(7*GHv*os(Kw|BW&=$AfS%Han3y-IkA~kU%aNk{~ zLoa7hBM7(DHjqohAQ`dBcqbr>1ja&>WBx|unDf{n5(C{W3(T3w*${#2$+zLB_+Ua!~QykI4$n{A|(p!Me@?+;n z1JDJ!u&#^-%V;689A>c_J46B)msk%U_#oCBp(Xk9fK)EFF6jZSdB#+EhH-J#oa8g< z9CGOMKfIqlxTv;o9B_AvXh->!IfzNrP{l9@(J29;g=^6?_l@|zn1j`hKeEFp9r()A zqk$KL&NV%C?3h2HxpQk+?JNxL^D382;DAWr2h&4ag8X~`fN2JsOUX9LB{06c)AMNj z`+t7pZuWWL+TL?SWDi~0@Psz28t#ehV!V=y4S^*aL_;L1)a6~n;EKwN-Bmeq%yZI zYTnj>2Y^m3>ot}&n0edM5D3oY#%axGot)C_%2kt7>~3m zD-RD^<-tb&W#yqsG2_ttCA3;D>Kr7fOL5FQj5V6c&)Qx_W2A3Y&YV5|?;NBj3NC)xmEIAHq)T-(jbR#I@ zJyEE4*sMn)Zsznm{S!ZFW9bH|QwWMKVH?=+Z4tmP zv-XO1pObYHQH~FjAS*|-|DyTv@~G4O=JE6A{l=~0>S9*9Q}`tEcCTSn;8T}J79F>m zxyQGK0dW6zUc|aFvqi&8df7>-n+NZ9R+GK3|H_EqjgJ`?c3@>~BKB2ou%m@{5p_-T fSrEkA{|kS)|95@&@v$3piT2#Pk_(O6c6)ySY_VuZ diff --git a/packages/services/service-common/src/iam-redis.spec.ts b/packages/services/service-common/src/iam-redis.spec.ts index 5a9a04944d86ea05f2d71692b3ae4ef2a58bcc50..6d1164eaf2388195387415d1831f8a76e3a48d3b 100644 GIT binary patch literal 29608 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 literal 14354 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 XY0tG@IqKgr^VOEW<~ZKJ@lXE)_#99F diff --git a/packages/services/service-common/src/iam-redis.ts b/packages/services/service-common/src/iam-redis.ts index 66ce620bea8..afef874aef3 100644 --- a/packages/services/service-common/src/iam-redis.ts +++ b/packages/services/service-common/src/iam-redis.ts @@ -100,13 +100,13 @@ export async function refreshIamAuth( if (isCluster) { const nodes = (redis as any).nodes?.('all') ?? []; logger.debug( - 'Refreshing IAM token (service=elasticache) — re-authenticating %s cluster node(s)', + '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); - node.options.password = token; } catch (err) { logger.warn('Failed to re-AUTH cluster node (service=elasticache, error=%s)', err); } @@ -117,8 +117,8 @@ export async function refreshIamAuth( pool.redisOptions.password = token; } } else { - await (redis as any).call('AUTH', username, token); (redis as any).options.password = token; + await (redis as any).call('AUTH', username, token); } logger.debug('IAM token refreshed successfully (service=elasticache)'); } @@ -128,7 +128,7 @@ export async function refreshIamAuth( * * 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. + * `(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. @@ -137,7 +137,7 @@ export async function refreshIamAuth( * @param config - ElastiCache endpoint and IAM user details. * @param isCluster - `true` when `redis` is a `Redis.Cluster` instance. * @param logger - Logger with `debug`, `warn`, and `error` methods. - * @returns A `setInterval` handle — call `clearInterval(handle)` during + * @returns A `setInterval` handle call `clearInterval(handle)` during * graceful shutdown to stop the refresh loop. */ export function startIamTokenRefresh( diff --git a/packages/services/workflows/src/index.ts b/packages/services/workflows/src/index.ts index d96d4a89b665723c308e9d051f3f0ef8eebc520d..155cef715a1b2654e1c724d8da2a0ac98319c544 100644 GIT binary patch literal 12884 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 literal 5990 zcmbVQTXWks7Jk>SKxZFBdo4sK z^Lvl_M>#9>y_%OEI7H8^l8w5lT(h&vy^@XGNQdvL>{)oyI-m@RH&*96^tnTSBLR`y=m3w&F()2>l=U9?SJJs3VCL)Q?32U0IbY(`Z?Gki&2L_}b`(5M3i}bFJ`a19cgJj$X_CyXsDvTeXGuMTh!UHCAW9 ziw6JR+rU9UovB$??e~=#s35K?)2NS4r)oOS_$2+^4e^w@AK>6i+sf=A>j56yaJ84Z zys_22&Ic|{=cV4v6~WxR4p4W%z-0w6PS7&2qdg#Hul^uiqqmO-;e@+=2py2Hxa@~g zL!E(=@QWVVfRW!xcelC+>T0hWxOza*05nqs3tCO|RwOW1%3eJapU9_xvxgJ<6av>Y zOEIOC*p}6Uo02<)7#Mj;AL-zLF>+@Z!YosKGr+1+AsZMNJ|a`ODOJ7{UwqNaMWCm% zZzoTa$^86$BF@Eah+~xt(pS7vTaA#^m2oNH^9iEFHDX`oZK<5N6!JmpM!;=*?Nlnu zGWi8eIk}T*Iw#Y(xomUY%;1Tc`bbGZ-Z-P%4-tKk%#c)it20UWoUg|26fmBwwAJ$X+por8<=84^yNmb`B5PLX;V@iWU+-qvb%ryhMo9MyG9 z!680F7G!s)X*w)vaGR+R@qcHh)=8jR6wO(w5xS5uQz$+8NsXUTJV%yE1!Li=sGa9*JR7OF2Bdy#k6bZ+v7<*CLqpVS|%?U5Ke~Be+Jy8iOL_JA#;se-E*H_qe92 zubZasnhyk`ict*DVA>alJt`D6s$ZehG`3Y-edsW+D{WBko-spxV_3um8l;rvYx8!! z6kjbqEEbChtIW#U%7P93CoaDe!3_QzcHnbTzSm%j&VzX8l0zj{J8H|1OOc!ri(b&D zGpM!{rY*}Z0LWx0_p#P-5OC}FI&%^BVqHsR$bXW1A zc=19^LvIqIg*jN8UaENJF>|$*ZP|1Ln&rKcNX`FxGxUVLIvtp0dh%{S-c$ckdFKnn zc__zA%v@-oH{tVQAeaZd4|1YSN6(>vADg}s+|`Q|`I8*%`IPc5!}OXXyj<1Vj9hs; zp5bnpx1Az4iPX!``S4=WCzywHt|ix{DYba^FwsU44pjswS~`HDk_ey9M4mr%em3GH zEc5h&g<}ye{$5E^sTaTI5jpbjr)HlEfR@@7lmcE3Z$-?{nbjLQd@dA}5-3z1PKcX< zm59e@_yU7FUlUybKTvV?vzTYsxg*SDM+Rz&h2T8Cc1*};owL%cY-#F$TjeU`<7WyB zQHFDTC$gW#xE|HyUYCp}GAs{c-gU(L23fy?izOm8e_dJ)M6jlq{RVhfcvIIQamL zDAFA}TZsD#O%dNInqt$sqWa^Km9UkfDkD|vo6`R(Y z&UtWD{5SOD=$KqOwo}1MrdwS?h@_yNU!*xkV)31yw(yLg!rfHr_zLPl^zU@w9K9vb zBc8hDikGAiS*VYVGF&aYo=nqK8e4MT#{F^Z;<~>7=4Vr4YN71(pm-Ewm!0Io8$@^$ zfXxXXg@6J&0}9t)Qkwg7l=9JvapObWD}bF?38D1lLq@=!gyJ8mL7KQ{mc-mX>W> z4mgg;qMQgle)gks6jrSP4NC#vc18V}0Dr~lp9tc|g5y$nP>Rts$e3*5{8;Ld0jP3O z^^ked`p>0}R__xHxy;K_Z+sDxd7SsX3l5yn`SJ+Y<2Hzu(93?fAO5BE;`-gY8-e!B z51B)uDNL@1OXW#{$2_oiA{JHN^NQk)V Date: Thu, 4 Jun 2026 17:58:18 -0400 Subject: [PATCH 3/9] feat: centralize Redis IAM auth and client creation into service-common --- integration-tests/testkit/seed.ts | 2 +- package.json | 1 + packages/services/api/package.json | 1 - packages/services/api/src/create.ts | 3 +- .../src/modules/auth/providers/oauth-cache.ts | 3 +- .../organization-access-tokens-cache.ts | 3 +- .../modules/shared/__tests__/redis.spec.ts | 361 -------- .../shared/providers/redis-rate-limiter.ts | 3 +- .../api/src/modules/shared/providers/redis.ts | 86 +- .../target/providers/targets-by-id-cache.ts | 3 +- .../target/providers/targets-by-slug-cache.ts | 3 +- packages/services/schema/package.json | 1 - packages/services/schema/src/cache.ts | 3 +- packages/services/schema/src/environment.ts | 48 +- packages/services/schema/src/index.ts | 94 +- packages/services/server/package.json | 1 - packages/services/server/src/environment.ts | 48 +- packages/services/server/src/index.ts | 56 +- packages/services/service-common/src/index.ts | 12 + .../service-common/src/redis-client.spec.ts | 811 ++++++++++++++++++ .../service-common/src/redis-client.ts | 180 ++++ .../src/redis-config-validation.spec.ts | 177 ++++ .../src/redis-config-validation.ts | 93 ++ packages/services/tokens/package.json | 1 - packages/services/tokens/src/environment.ts | 48 +- packages/services/tokens/src/index.ts | 78 +- .../services/tokens/src/multi-tier-storage.ts | 2 +- packages/services/usage/README.md | 1 - packages/services/usage/package.json | 1 - packages/services/usage/src/authn.ts | 2 +- packages/services/usage/src/environment.ts | 50 +- packages/services/usage/src/index.ts | 78 +- packages/services/workflows/package.json | 1 - .../services/workflows/src/environment.ts | 48 +- packages/services/workflows/src/index.ts | 56 +- packages/services/workflows/src/redis.ts | 76 -- 36 files changed, 1449 insertions(+), 986 deletions(-) delete mode 100644 packages/services/api/src/modules/shared/__tests__/redis.spec.ts create mode 100644 packages/services/service-common/src/redis-client.spec.ts create mode 100644 packages/services/service-common/src/redis-client.ts create mode 100644 packages/services/service-common/src/redis-config-validation.spec.ts create mode 100644 packages/services/service-common/src/redis-config-validation.ts delete mode 100644 packages/services/workflows/src/redis.ts diff --git a/integration-tests/testkit/seed.ts b/integration-tests/testkit/seed.ts index 393bfedcfde..c44879550e9 100644 --- a/integration-tests/testkit/seed.ts +++ b/integration-tests/testkit/seed.ts @@ -2,8 +2,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 } from '@hive/service-common'; import type { Report } from '../../packages/libraries/core/src/client/usage.js'; import { authenticate, userEmail } from './auth'; import { diff --git a/package.json b/package.json index 7c9619f4c6d..f0c586b3bc4 100644 --- a/package.json +++ b/package.json @@ -164,6 +164,7 @@ "glob@10.x.x": "^10.5.0", "path-to-regexp@0.x.x": "^0.1.13", "fast-uri@2.x.x": "3.x.x", + "ioredis": "5.10.1", "protobufjs@8.x.x": "^8.0.2", "@opentelemetry/exporter-jaeger": "-", "uuid@<14.x.x": "^14.0.0", diff --git a/packages/services/api/package.json b/packages/services/api/package.json index 556eec265d5..5f584e8aab3 100644 --- a/packages/services/api/package.json +++ b/packages/services/api/package.json @@ -65,7 +65,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/__tests__/redis.spec.ts b/packages/services/api/src/modules/shared/__tests__/redis.spec.ts deleted file mode 100644 index 8906960650a..00000000000 --- a/packages/services/api/src/modules/shared/__tests__/redis.spec.ts +++ /dev/null @@ -1,361 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import type { RedisConfig } from '../providers/redis'; - -// Create a mock logger compatible with the Logger class shape -function createMockLogger(): any { - return { - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - debug: vi.fn(), - fatal: vi.fn(), - trace: vi.fn(), - child: vi.fn().mockReturnThis(), - }; -} - -// 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 baseConfig: RedisConfig = { - host: 'localhost', - port: 6379, - password: 'test-password', - tlsEnabled: false, -}; - -describe('createRedisClient', () => { - let createRedisClient: typeof import('../providers/redis').createRedisClient; - - beforeEach(async () => { - vi.clearAllMocks(); - const mod = await import('../providers/redis'); - createRedisClient = mod.createRedisClient; - }); - - describe('standalone mode', () => { - it('creates a standalone Redis instance with correct config', () => { - const logger = createMockLogger(); - const redis = createRedisClient('test-standalone', baseConfig, logger); - - expect(redis).toBeDefined(); - expect(redis.options).toMatchObject({ - host: 'localhost', - port: 6379, - password: 'test-password', - db: 0, - maxRetriesPerRequest: null, - enableReadyCheck: false, - }); - }); - - it('sets TLS options when tlsEnabled is true', () => { - const logger = createMockLogger(); - const redis = createRedisClient('test-tls', { ...baseConfig, tlsEnabled: true }, logger); - - expect(redis.options.tls).toEqual({}); - }); - - it('does not set TLS when tlsEnabled is false', () => { - const logger = createMockLogger(); - const redis = createRedisClient('test-no-tls', baseConfig, logger); - - expect(redis.options.tls).toBeUndefined(); - }); - - it('passes username when provided', () => { - const logger = createMockLogger(); - const redis = createRedisClient('test-user', { ...baseConfig, username: 'myuser' }, logger); - - expect(redis.options.username).toBe('myuser'); - }); - - it('includes retryStrategy that caps at 2000ms', () => { - const logger = createMockLogger(); - const redis = createRedisClient('test-retry', baseConfig, 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 returns 1', () => { - const logger = createMockLogger(); - const redis = createRedisClient('test-reconnect', baseConfig, logger); - - const handler = redis.options.reconnectOnError; - expect(handler).toBeDefined(); - expect(handler!(new Error('READONLY'))).toBe(1); - expect(logger.warn).toHaveBeenCalledWith( - expect.stringContaining('reconnectOnError'), - expect.any(Error), - ); - }); - }); - - describe('cluster mode', () => { - it('creates a Redis.Cluster instance when clusterModeEnabled is true', () => { - const logger = createMockLogger(); - const redis = createRedisClient( - 'test-cluster', - { ...baseConfig, clusterModeEnabled: true }, - logger, - ); - - expect(redis).toBeDefined(); - // Verify it's a cluster instance (has isCluster from our mock) - expect((redis as any).isCluster).toBe(true); - }); - - it('passes startup nodes with host and port', () => { - const logger = createMockLogger(); - const redis = createRedisClient( - 'test-cluster-nodes', - { - ...baseConfig, - host: 'clustercfg.my-cache.use1.cache.amazonaws.com', - port: 6190, - clusterModeEnabled: true, - }, - logger, - ); - - expect((redis as any).startupNodes).toEqual([ - { host: 'clustercfg.my-cache.use1.cache.amazonaws.com', port: 6190 }, - ]); - }); - - it('configures dnsLookup to bypass DNS resolution', () => { - const logger = createMockLogger(); - const redis = createRedisClient( - 'test-cluster-dns', - { ...baseConfig, clusterModeEnabled: true }, - logger, - ); - - const { dnsLookup } = (redis as any).options; - expect(dnsLookup).toBeDefined(); - - // dnsLookup should pass the address through unchanged - 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', () => { - const logger = createMockLogger(); - const redis = createRedisClient( - 'test-cluster-opts', - { ...baseConfig, username: 'iam-user', clusterModeEnabled: true }, - logger, - ); - - const { redisOptions } = (redis as any).options; - expect(redisOptions).toMatchObject({ - username: 'iam-user', - password: 'test-password', - maxRetriesPerRequest: null, - enableReadyCheck: false, - }); - }); - - it('sets TLS inside redisOptions when tlsEnabled is true', () => { - const logger = createMockLogger(); - const redis = createRedisClient( - 'test-cluster-tls', - { ...baseConfig, tlsEnabled: true, clusterModeEnabled: true }, - logger, - ); - - expect((redis as any).options.redisOptions.tls).toEqual({}); - }); - - it('omits TLS inside redisOptions when tlsEnabled is false', () => { - const logger = createMockLogger(); - const redis = createRedisClient( - 'test-cluster-no-tls', - { ...baseConfig, clusterModeEnabled: true }, - logger, - ); - - expect((redis as any).options.redisOptions.tls).toBeUndefined(); - }); - - it('includes clusterRetryStrategy that caps at 2000ms', () => { - const logger = createMockLogger(); - const redis = createRedisClient( - 'test-cluster-retry', - { ...baseConfig, clusterModeEnabled: true }, - 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', () => { - const logger = createMockLogger(); - const redis = createRedisClient( - 'test-cluster-reconnect', - { ...baseConfig, clusterModeEnabled: true }, - 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('does not create cluster when clusterModeEnabled is false/undefined', () => { - const logger = createMockLogger(); - const redis = createRedisClient('test-no-cluster', baseConfig, logger); - - expect((redis as any).isCluster).toBeUndefined(); - }); - }); - - describe('event listeners', () => { - it('attaches error listener that logs at error level', () => { - const logger = createMockLogger(); - const redis = createRedisClient('test-events', baseConfig, logger); - - redis.emit('error', new Error('connection refused')); - - expect(logger.error).toHaveBeenCalledWith( - expect.stringContaining('Redis connection error'), - expect.any(Error), - 'test-events', - ); - }); - - it('logs errors on cluster instances the same way', () => { - const logger = createMockLogger(); - const redis = createRedisClient( - 'test-cluster-err', - { ...baseConfig, clusterModeEnabled: true }, - logger, - ); - - redis.emit('error', new Error('CLUSTERDOWN')); - - expect(logger.error).toHaveBeenCalledWith( - expect.stringContaining('Redis connection error'), - expect.any(Error), - 'test-cluster-err', - ); - }); - - it('attaches connect listener that logs at debug level', () => { - const logger = createMockLogger(); - const redis = createRedisClient('test-connect', baseConfig, logger); - - redis.emit('connect'); - - expect(logger.debug).toHaveBeenCalledWith( - expect.stringContaining('Redis connection established'), - 'test-connect', - ); - }); - - it('attaches ready listener that logs at info level', () => { - const logger = createMockLogger(); - const redis = createRedisClient('test-ready', baseConfig, logger); - - redis.emit('ready'); - - expect(logger.info).toHaveBeenCalledWith( - expect.stringContaining('Redis connection ready'), - 'test-ready', - ); - }); - - it('attaches close listener that logs at info level', () => { - const logger = createMockLogger(); - const redis = createRedisClient('test-close', baseConfig, logger); - - redis.emit('close'); - - expect(logger.info).toHaveBeenCalledWith( - expect.stringContaining('Redis connection closed'), - 'test-close', - ); - }); - - it('attaches reconnecting listener', () => { - const logger = createMockLogger(); - const redis = createRedisClient('test-reconnecting', baseConfig, logger); - - redis.emit('reconnecting', 500); - - expect(logger.info).toHaveBeenCalledWith( - expect.stringContaining('Redis reconnecting'), - 500, - 'test-reconnecting', - ); - }); - }); - - describe('return value', () => { - it('returns the redis instance synchronously', () => { - const logger = createMockLogger(); - const result = createRedisClient('sync-test', baseConfig, logger); - - // Verify it's not a promise - expect(result).not.toBeInstanceOf(Promise); - expect(result).toBeDefined(); - }); - }); -}); 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 b50d32bf492..bf02d0e64d0 100644 --- a/packages/services/api/src/modules/shared/providers/redis.ts +++ b/packages/services/api/src/modules/shared/providers/redis.ts @@ -1,75 +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; - username?: string; - clusterModeEnabled?: boolean; -}; - -export const REDIS_INSTANCE = new InjectionToken('REDIS_INSTANCE'); - -export function createRedisClient(label: string, config: RedisConfig, logger: Logger) { - let redis: RedisInstance; - if (config.clusterModeEnabled) { - redis = new Redis.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: null, - enableReadyCheck: false, - tls: config.tlsEnabled ? {} : undefined, - }, - clusterRetryStrategy: (times: number) => Math.min(times * 500, 2000), - }) as unknown as RedisInstance; - } else { - redis = new Redis({ - 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: 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/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 6e100f7666a..e6ac4f8bb1d 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()), @@ -128,18 +136,16 @@ for (const config of Object.values(configs)) { } } -if (configs.redis.success && configs.redis.data.REDIS_AWS_IAM_AUTH_ENABLED === '1') { - const missingRedisIamVars: string[] = []; - if (configs.redis.data.REDIS_TLS_ENABLED !== '1') - missingRedisIamVars.push('REDIS_TLS_ENABLED must be enabled (ElastiCache IAM requires TLS)'); - if (!configs.redis.data.REDIS_AWS_IAM_CACHE_NAME) - missingRedisIamVars.push('REDIS_AWS_IAM_CACHE_NAME'); - if (!configs.redis.data.REDIS_AWS_REGION && !configs.base.data?.AWS_REGION) - missingRedisIamVars.push('REDIS_AWS_REGION or AWS_REGION'); - if (missingRedisIamVars.length > 0) { - environmentErrors.push( - `REDIS_AWS_IAM_AUTH_ENABLED is enabled but the following required variables are missing or invalid: ${missingRedisIamVars.join(', ')}`, - ); +let redisConfigResult = null; + +if (configs.redis.success) { + redisConfigResult = parseRedisConfigFromEnvironment({ + redis: configs.redis.data, + awsRegion: configs.base.success ? configs.base.data.AWS_REGION : undefined, + }); + + if (redisConfigResult.type === 'error') { + environmentErrors.push(...redisConfigResult.errors); } } @@ -158,7 +164,6 @@ function extractConfig(config: zod.SafeParseReturnType callback(null, address), - redisOptions: { - username: redisUsername, - password: redisPassword, - maxRetriesPerRequest: null, - enableReadyCheck: false, - tls: env.redis.tlsEnabled ? {} : undefined, - reconnectOnError(error) { - server.log.warn('Redis reconnectOnError (error=%s)', error); - return 1; - }, - }, - }) as unknown as Redis; - } else { - redis = new Redis({ - host: env.redis.host, - port: env.redis.port, - username: redisUsername, - password: redisPassword, - retryStrategy(times) { - return Math.min(times * 500, 2000); - }, - reconnectOnError(error) { - server.log.warn('Redis reconnectOnError (error=%s)', error); - return 1; - }, - db: 0, - maxRetriesPerRequest: null, - enableReadyCheck: false, - tls: env.redis.tlsEnabled ? {} : undefined, - }); - } + const redis = await createRedisClient(env.redis, { + logger: server.log.child({ source: 'Redis' }), + iamTokenRefreshLogger: server.log.child({ source: 'RedisIamTokenRefresh' }), + }); - if (redisIamConfig) { - startIamTokenRefresh( - redis, - redisIamConfig, - env.redis.clusterModeEnabled, - server.log.child({ source: 'RedisIamTokenRefresh' }), - ); - } + // Capture Redis errors in Sentry in addition to the logging done by createRedisClient + redis.on('error', err => { + 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/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 51c55f48a30..8c6fec3652e 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()), }); @@ -340,18 +348,16 @@ for (const config of Object.values(configs)) { } } -if (configs.redis.success && configs.redis.data.REDIS_AWS_IAM_AUTH_ENABLED === '1') { - const missingRedisIamVars: string[] = []; - if (configs.redis.data.REDIS_TLS_ENABLED !== '1') - missingRedisIamVars.push('REDIS_TLS_ENABLED must be enabled (ElastiCache IAM requires TLS)'); - if (!configs.redis.data.REDIS_AWS_IAM_CACHE_NAME) - missingRedisIamVars.push('REDIS_AWS_IAM_CACHE_NAME'); - if (!configs.redis.data.REDIS_AWS_REGION && !configs.base.data?.AWS_REGION) - missingRedisIamVars.push('REDIS_AWS_REGION or AWS_REGION'); - if (missingRedisIamVars.length > 0) { - environmentErrors.push( - `REDIS_AWS_IAM_AUTH_ENABLED is enabled but the following required variables are missing or invalid: ${missingRedisIamVars.join(', ')}`, - ); +let redisConfigResult = null; + +if (configs.redis.success) { + redisConfigResult = parseRedisConfigFromEnvironment({ + redis: configs.redis.data, + awsRegion: configs.base.success ? configs.base.data.AWS_REGION : undefined, + }); + + if (redisConfigResult.type === 'error') { + environmentErrors.push(...redisConfigResult.errors); } } @@ -373,7 +379,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); @@ -475,17 +480,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 ?? '', - 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 ?? base.AWS_REGION, - awsIamAuthCacheName: redis.REDIS_AWS_IAM_CACHE_NAME, - }, + 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 e9c0a6f6dee..dafc4b75d90 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,13 +29,12 @@ import { createConnectionString } from '@hive/postgres'; import { createHivePubSub } from '@hive/pubsub'; import { configureTracing, + createRedisClient, createServer, registerShutdown, registerTRPC, reportReadiness, - resolveRedisCredentials, sentryInit, - startIamTokenRefresh, startMetrics, TracingInstance, } from '@hive/service-common'; @@ -173,57 +171,21 @@ export async function main() { ); const taskScheduler = new TaskScheduler(storage.pool); - const redisIamConfig = env.redis.awsIamAuthEnabled - ? { - host: env.redis.host, - port: env.redis.port, - awsRegion: env.redis.awsRegion ?? '', - username: env.redis.username ?? 'default', - iamAuthCacheName: env.redis.awsIamAuthCacheName, - } - : undefined; - - const { password: redisPassword, username: redisUsername } = await resolveRedisCredentials( - env.redis.password, - server.log.child({ source: 'RedisCredentialResolver' }), - redisIamConfig, - ); - - const redisConfig = { - ...env.redis, - password: redisPassword, - username: redisUsername, - }; - - const redis = createRedisClient('Redis', redisConfig, server.log.child({ source: 'Redis' })); + const redis = await createRedisClient(env.redis, { + logger: server.log.child({ source: 'Redis' }), + iamTokenRefreshLogger: server.log.child({ source: 'RedisIamTokenRefresh' }), + }); - const redisSubscriber = createRedisClient( - 'subscriber', - redisConfig, - server.log.child({ source: 'RedisSubscribe' }), - ); + const redisSubscriber = await createRedisClient(env.redis, { + logger: server.log.child({ source: 'RedisSubscribe' }), + iamTokenRefreshLogger: server.log.child({ source: 'RedisSubscribeIamTokenRefresh' }), + }); const pubSub = createHivePubSub({ publisher: redis as any, subscriber: redisSubscriber as any, }); - if (redisIamConfig) { - startIamTokenRefresh( - redis, - redisIamConfig, - env.redis.clusterModeEnabled, - server.log.child({ source: 'RedisIamTokenRefresh' }), - ); - - startIamTokenRefresh( - redisSubscriber, - redisIamConfig, - env.redis.clusterModeEnabled, - server.log.child({ source: 'RedisSubscribeIamTokenRefresh' }), - ); - } - registerShutdown({ logger: server.log, async onShutdown() { diff --git a/packages/services/service-common/src/index.ts b/packages/services/service-common/src/index.ts index 1a8ca3036e6..1ac5f070b67 100644 --- a/packages/services/service-common/src/index.ts +++ b/packages/services/service-common/src/index.ts @@ -25,3 +25,15 @@ export { } from './iam-redis'; export { createMskIamTokenProvider } from './iam-msk'; export { invariant } from './helpers'; +export { + parseRedisConfigFromEnvironment, + type RedisEnvironment, + type ParseRedisConfigFromEnvironmentResult, + type RedisConfig, +} from './redis-config-validation'; +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..d388153ac36 --- /dev/null +++ b/packages/services/service-common/src/redis-client.spec.ts @@ -0,0 +1,811 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { RedisConfig } from './redis-config-validation'; + +/** 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, + }; + const redis = 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', + }, + false, + expect.any(Object), + ); + }); + + it('passes iamTokenRefreshLogger from options to startIamTokenRefresh', async () => { + const logger = createMockLogger(); + const iamRefreshLogger = createMockLogger(); + const config = { + ...baseEnvConfig, + awsIamAuthEnabled: true, + }; + const redis = await createRedisClient(config, { + logger, + iamTokenRefreshLogger: iamRefreshLogger, + }); + + expect(startIamTokenRefreshMock).toHaveBeenCalledWith( + redis, + expect.any(Object), + false, + iamRefreshLogger, + ); + }); + + it('does NOT start IAM token refresh when IAM is disabled', async () => { + const logger = createMockLogger(); + const config = { + ...baseEnvConfig, + awsIamAuthEnabled: false, + }; + const redis = 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), + true, + 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 iamLogger = createMockLogger(); + + const config = { + ...baseEnvConfig, + awsIamAuthEnabled: true, + }; + + const mainRedis = await createRedisClient(config, { + logger: mainLogger, + iamTokenRefreshLogger: iamLogger, + }); + + const subscriberRedis = await createRedisClient(config, { + logger: subscriberLogger, + iamTokenRefreshLogger: iamLogger, + }); + + // 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('accepts optional iamTokenRefreshLogger', async () => { + const logger = createMockLogger(); + const iamRefreshLogger = createMockLogger(); + const config = { + ...baseEnvConfig, + awsIamAuthEnabled: true, + }; + const redis = await createRedisClient(config, { + logger, + iamTokenRefreshLogger: iamRefreshLogger, + }); + + expect(startIamTokenRefreshMock).toHaveBeenCalledWith( + redis, + expect.any(Object), + false, + iamRefreshLogger, + ); + }); + + it('uses main logger as default iamTokenRefreshLogger if not provided', async () => { + const logger = createMockLogger(); + const config = { + ...baseEnvConfig, + awsIamAuthEnabled: true, + }; + const redis = await createRedisClient(config, { + logger, + }); + + expect(startIamTokenRefreshMock).toHaveBeenCalledWith( + redis, + expect.any(Object), + false, + 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..1d8fdfdaa68 --- /dev/null +++ b/packages/services/service-common/src/redis-client.ts @@ -0,0 +1,180 @@ +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-validation'; + +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; + /** + * Optional logger for IAM token refresh events. + * If not provided, defaults to the main `logger`. + */ + iamTokenRefreshLogger?: ServiceLogger; +}; + +/** + * 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 optional IAM token refresh logger. + * @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) { + const iamLogger = options.iamTokenRefreshLogger ?? options.logger; + startIamTokenRefresh(redis, redisIamConfig, config.clusterModeEnabled ?? false, iamLogger); + } + + 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-validation.spec.ts b/packages/services/service-common/src/redis-config-validation.spec.ts new file mode 100644 index 00000000000..679fa8470a7 --- /dev/null +++ b/packages/services/service-common/src/redis-config-validation.spec.ts @@ -0,0 +1,177 @@ +import { describe, expect, it } from 'vitest'; +import zod from 'zod'; +import { parseRedisConfigFromEnvironment } from './redis-config-validation'; + +describe('parseRedisConfigFromEnvironment', () => { + 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)); + + // treat an empty string (`''`) as undefined + const emptyString = (input: T) => { + return zod.preprocess((value: unknown) => { + if (value === '') return undefined; + return value; + }, input); + }; + + const schema = 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()), + }); + + function parseRedis(raw: Record) { + const parsed = schema.safeParse(raw); + expect(parsed.success).toBe(true); + return parsed.success ? parsed.data : undefined; + } + + it('returns ok for static auth config', () => { + const redis = parseRedis({ + REDIS_HOST: 'localhost', + REDIS_PORT: '6379', + REDIS_PASSWORD: 'secret', + REDIS_AWS_IAM_AUTH_ENABLED: '0', + }); + + const result = parseRedisConfigFromEnvironment({ redis: redis!, awsRegion: undefined }); + + 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 redis = parseRedis({ + 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', + }); + + const result = parseRedisConfigFromEnvironment({ redis: redis!, awsRegion: undefined }); + + 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 redis = parseRedis({ + 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', + }); + + const result = parseRedisConfigFromEnvironment({ redis: redis!, awsRegion: undefined }); + + 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 redis = parseRedis({ + REDIS_HOST: 'cache.aws', + REDIS_PORT: '6379', + REDIS_TLS_ENABLED: '1', + REDIS_AWS_IAM_AUTH_ENABLED: '1', + REDIS_AWS_REGION: 'us-east-1', + }); + + const result = parseRedisConfigFromEnvironment({ redis: redis!, awsRegion: undefined }); + + 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 redis = parseRedis({ + REDIS_HOST: 'cache.aws', + REDIS_PORT: '6379', + REDIS_TLS_ENABLED: '1', + REDIS_AWS_IAM_AUTH_ENABLED: '1', + REDIS_AWS_IAM_CACHE_NAME: 'my-cache', + }); + + const result = parseRedisConfigFromEnvironment({ redis: redis!, awsRegion: undefined }); + + 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 redis = parseRedis({ + REDIS_HOST: 'cache.aws', + REDIS_PORT: '6379', + REDIS_TLS_ENABLED: '1', + REDIS_AWS_IAM_AUTH_ENABLED: '1', + REDIS_AWS_IAM_CACHE_NAME: 'my-cache', + }); + + const result = parseRedisConfigFromEnvironment({ redis: redis!, awsRegion: '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 redis = parseRedis({ + REDIS_HOST: 'localhost', + REDIS_PORT: '6379', + REDIS_PASSWORD: '', + REDIS_USERNAME: '', + REDIS_AWS_IAM_AUTH_ENABLED: '0', + }); + + const result = parseRedisConfigFromEnvironment({ redis: redis!, awsRegion: undefined }); + + expect(result.type).toBe('ok'); + if (result.type === 'ok') { + expect(result.config.password).toBe(''); + expect(result.config.username).toBeUndefined(); + } + }); +}); diff --git a/packages/services/service-common/src/redis-config-validation.ts b/packages/services/service-common/src/redis-config-validation.ts new file mode 100644 index 00000000000..2383360f069 --- /dev/null +++ b/packages/services/service-common/src/redis-config-validation.ts @@ -0,0 +1,93 @@ +/** + * Parsed Redis-related environment variables. Matches the shape of the Zod schema built by services. + */ +export type RedisEnvironment = { + REDIS_HOST: string; + REDIS_PORT: number; + REDIS_PASSWORD?: string; + REDIS_USERNAME?: string; + REDIS_TLS_ENABLED?: '0' | '1'; + REDIS_CLUSTER_MODE_ENABLED?: '0' | '1'; + REDIS_AWS_REGION?: string; + REDIS_AWS_IAM_AUTH_ENABLED?: '0' | '1'; + REDIS_AWS_IAM_CACHE_NAME?: string; +}; + +/** + * Normalized Redis runtime configuration consumed by service env 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; base schema + * validation errors are handled separately by each service environment module. + */ +export type ParseRedisConfigFromEnvironmentResult = + | { + type: 'error'; + errors: Array; + } + | { + type: 'ok'; + config: RedisConfig; + }; + +/** + * Validates Redis IAM requirements and returns a normalized Redis config object. + */ +export function parseRedisConfigFromEnvironment(args: { + redis: RedisEnvironment; + awsRegion: string | undefined; +}): ParseRedisConfigFromEnvironmentResult { + if (args.redis.REDIS_AWS_IAM_AUTH_ENABLED === '1') { + const missingRedisIamVars: string[] = []; + + if (args.redis.REDIS_TLS_ENABLED !== '1') { + missingRedisIamVars.push('REDIS_TLS_ENABLED must be enabled (ElastiCache IAM requires TLS)'); + } + + if (!args.redis.REDIS_AWS_IAM_CACHE_NAME) { + missingRedisIamVars.push('REDIS_AWS_IAM_CACHE_NAME'); + } + + if (!args.redis.REDIS_AWS_REGION && !args.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: args.redis.REDIS_HOST, + port: args.redis.REDIS_PORT, + password: args.redis.REDIS_PASSWORD ?? '', + username: args.redis.REDIS_USERNAME, + tlsEnabled: args.redis.REDIS_TLS_ENABLED === '1', + clusterModeEnabled: args.redis.REDIS_CLUSTER_MODE_ENABLED === '1', + awsIamAuthEnabled: args.redis.REDIS_AWS_IAM_AUTH_ENABLED === '1', + awsRegion: args.redis.REDIS_AWS_REGION ?? args.awsRegion, + awsIamAuthCacheName: args.redis.REDIS_AWS_IAM_CACHE_NAME, + }, + }; +} 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 167d4d07145..d03dce4eca1 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()), @@ -117,18 +125,16 @@ for (const config of Object.values(configs)) { } } -if (configs.redis.success && configs.redis.data.REDIS_AWS_IAM_AUTH_ENABLED === '1') { - const missingRedisIamVars: string[] = []; - if (configs.redis.data.REDIS_TLS_ENABLED !== '1') - missingRedisIamVars.push('REDIS_TLS_ENABLED must be enabled (ElastiCache IAM requires TLS)'); - if (!configs.redis.data.REDIS_AWS_IAM_CACHE_NAME) - missingRedisIamVars.push('REDIS_AWS_IAM_CACHE_NAME'); - if (!configs.redis.data.REDIS_AWS_REGION && !configs.base.data?.AWS_REGION) - missingRedisIamVars.push('REDIS_AWS_REGION or AWS_REGION'); - if (missingRedisIamVars.length > 0) { - environmentErrors.push( - `REDIS_AWS_IAM_AUTH_ENABLED is enabled but the following required variables are missing or invalid: ${missingRedisIamVars.join(', ')}`, - ); +let redisConfigResult = null; + +if (configs.redis.success) { + redisConfigResult = parseRedisConfigFromEnvironment({ + redis: configs.redis.data, + awsRegion: configs.base.success ? configs.base.data.AWS_REGION : undefined, + }); + + if (redisConfigResult.type === 'error') { + environmentErrors.push(...redisConfigResult.errors); } } @@ -147,7 +153,6 @@ function extractConfig(config: zod.SafeParseReturnType callback(null, address), - redisOptions: { - username: redisUsername, - password: redisPassword, - maxRetriesPerRequest: 20, - enableReadyCheck: false, - tls: env.redis.tlsEnabled ? {} : undefined, - }, - }) as unknown as Redis; - } else { - redis = new Redis({ - host: env.redis.host, - port: env.redis.port, - username: redisUsername, - password: redisPassword, - maxRetriesPerRequest: 20, - db: 0, - enableReadyCheck: false, - tls: env.redis.tlsEnabled ? {} : undefined, - }); - } - - if (redisIamConfig) { - startIamTokenRefresh( - redis, - redisIamConfig, - env.redis.clusterModeEnabled, - server.log.child({ source: 'RedisIamTokenRefresh' }), - ); - } + const redis = await createRedisClient(env.redis, { + logger: server.log.child({ source: 'Redis' }), + iamTokenRefreshLogger: server.log.child({ source: 'RedisIamTokenRefresh' }), + maxRetriesPerRequest: 20, + }); const storage = await createStorage( env.postgres, @@ -146,26 +100,6 @@ export 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/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 e80e647fc1a..c9d421ed1b4 100644 --- a/packages/services/usage/README.md +++ b/packages/services/usage/README.md @@ -40,7 +40,6 @@ The data is written to a Kafka broker, form Kafka the data is feed into clickhou | `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/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 507ba571bab..d3520183464 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; @@ -145,21 +149,6 @@ for (const config of Object.values(configs)) { } } -if (configs.redis.success && configs.redis.data.REDIS_AWS_IAM_AUTH_ENABLED === '1') { - const missingRedisIamVars: string[] = []; - if (configs.redis.data.REDIS_TLS_ENABLED !== '1') - missingRedisIamVars.push('REDIS_TLS_ENABLED must be enabled (ElastiCache IAM requires TLS)'); - if (!configs.redis.data.REDIS_AWS_IAM_CACHE_NAME) - missingRedisIamVars.push('REDIS_AWS_IAM_CACHE_NAME'); - if (!configs.redis.data.REDIS_AWS_REGION && !configs.base.data?.AWS_REGION) - missingRedisIamVars.push('REDIS_AWS_REGION or AWS_REGION'); - if (missingRedisIamVars.length > 0) { - environmentErrors.push( - `REDIS_AWS_IAM_AUTH_ENABLED is enabled but the following required variables are missing or invalid: ${missingRedisIamVars.join(', ')}`, - ); - } -} - if (configs.kafka.success && configs.kafka.data.KAFKA_AWS_IAM_AUTH_ENABLED === '1') { const missingKafkaIamVars: string[] = []; if (configs.kafka.data.KAFKA_SSL !== '1') @@ -173,6 +162,19 @@ if (configs.kafka.success && configs.kafka.data.KAFKA_AWS_IAM_AUTH_ENABLED === ' } } +let redisConfigResult = null; + +if (configs.redis.success) { + redisConfigResult = parseRedisConfigFromEnvironment({ + redis: configs.redis.data, + awsRegion: 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); @@ -190,7 +192,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); @@ -269,17 +270,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 ?? '', - 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 ?? base.AWS_REGION, - awsIamAuthCacheName: redis.REDIS_AWS_IAM_CACHE_NAME, - }, + 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 b78cc1105e9..16383da0e01 100644 --- a/packages/services/usage/src/index.ts +++ b/packages/services/usage/src/index.ts @@ -1,18 +1,16 @@ #!/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, - resolveRedisCredentials, sentryInit, - startIamTokenRefresh, startMetrics, TracingInstance, } from '@hive/service-common'; @@ -63,55 +61,11 @@ async function main() { }, }); - const redisIamConfig = env.redis.awsIamAuthEnabled - ? { - host: env.redis.host, - port: env.redis.port, - awsRegion: env.redis.awsRegion ?? '', - username: env.redis.username ?? 'default', - iamAuthCacheName: env.redis.awsIamAuthCacheName, - } - : undefined; - - const { password: redisPassword, username: redisUsername } = await resolveRedisCredentials( - env.redis.password, - server.log.child({ source: 'RedisCredentialResolver' }), - redisIamConfig, - ); - - let redis: Redis; - if (env.redis.clusterModeEnabled) { - redis = new Redis.Cluster([{ host: env.redis.host, port: env.redis.port }], { - dnsLookup: (address, callback) => callback(null, address), - redisOptions: { - username: redisUsername, - password: redisPassword, - maxRetriesPerRequest: 20, - enableReadyCheck: false, - tls: env.redis.tlsEnabled ? {} : undefined, - }, - }) as unknown as Redis; - } else { - redis = new Redis({ - host: env.redis.host, - port: env.redis.port, - username: redisUsername, - password: redisPassword, - maxRetriesPerRequest: 20, - db: 0, - enableReadyCheck: false, - tls: env.redis.tlsEnabled ? {} : undefined, - }); - } - - if (redisIamConfig) { - startIamTokenRefresh( - redis, - redisIamConfig, - env.redis.clusterModeEnabled, - server.log.child({ source: 'RedisIamTokenRefresh' }), - ); - } + const redis = await createRedisClient(env.redis, { + logger: server.log.child({ source: 'Redis' }), + iamTokenRefreshLogger: server.log.child({ source: 'RedisIamTokenRefresh' }), + maxRetriesPerRequest: 20, + }); const pgPool = await createPostgresDatabasePool({ connectionParameters: env.postgres, @@ -134,26 +88,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/package.json b/packages/services/workflows/package.json index e1d2b435e6f..4fceafa0514 100644 --- a/packages/services/workflows/package.json +++ b/packages/services/workflows/package.json @@ -28,7 +28,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 0562714da46..5306a5eb9a0 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()), @@ -180,18 +188,16 @@ for (const config of Object.values(configs)) { } } -if (configs.redis.success && configs.redis.data.REDIS_AWS_IAM_AUTH_ENABLED === '1') { - const missingRedisIamVars: string[] = []; - if (configs.redis.data.REDIS_TLS_ENABLED !== '1') - missingRedisIamVars.push('REDIS_TLS_ENABLED must be enabled (ElastiCache IAM requires TLS)'); - if (!configs.redis.data.REDIS_AWS_IAM_CACHE_NAME) - missingRedisIamVars.push('REDIS_AWS_IAM_CACHE_NAME'); - if (!configs.redis.data.REDIS_AWS_REGION && !configs.base.data?.AWS_REGION) - missingRedisIamVars.push('REDIS_AWS_REGION or AWS_REGION'); - if (missingRedisIamVars.length > 0) { - environmentErrors.push( - `REDIS_AWS_IAM_AUTH_ENABLED is enabled but the following required variables are missing or invalid: ${missingRedisIamVars.join(', ')}`, - ); +let redisConfigResult = null; + +if (configs.redis.success) { + redisConfigResult = parseRedisConfigFromEnvironment({ + redis: configs.redis.data, + awsRegion: configs.base.success ? configs.base.data.AWS_REGION : undefined, + }); + + if (redisConfigResult.type === 'error') { + environmentErrors.push(...redisConfigResult.errors); } } @@ -217,7 +223,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 = @@ -313,17 +318,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 ?? '', - 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 ?? base.AWS_REGION, - awsIamAuthCacheName: redis.REDIS_AWS_IAM_CACHE_NAME, - }, + 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 c2bb4d1ab9c..f1204f8c1b4 100644 --- a/packages/services/workflows/src/index.ts +++ b/packages/services/workflows/src/index.ts @@ -4,13 +4,12 @@ import { createPostgresDatabasePool } from '@hive/postgres'; import { bridgeGraphileLogger, createHivePubSub } from '@hive/pubsub'; import { configureTracing, + createRedisClient, createServer, registerShutdown, reportReadiness, - resolveRedisCredentials, sentryInit, startHeartbeats, - startIamTokenRefresh, startMetrics, type TracingInstance, } from '@hive/service-common'; @@ -20,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; @@ -114,57 +112,21 @@ const server = await createServer({ log: logger, }); -const redisIamConfig = env.redis.awsIamAuthEnabled - ? { - host: env.redis.host, - port: env.redis.port, - awsRegion: env.redis.awsRegion ?? '', - username: env.redis.username ?? 'default', - iamAuthCacheName: env.redis.awsIamAuthCacheName, - } - : undefined; - -const { password: redisPassword, username: redisUsername } = await resolveRedisCredentials( - env.redis.password, - server.log.child({ source: 'RedisCredentialResolver' }), - redisIamConfig, -); - -const redisConfig = { - ...env.redis, - password: redisPassword, - username: redisUsername, -}; - -const redis = createRedisClient('Redis', redisConfig, server.log.child({ source: 'Redis' })); +const redis = await createRedisClient(env.redis, { + logger: server.log.child({ source: 'Redis' }), + iamTokenRefreshLogger: server.log.child({ source: 'RedisIamTokenRefresh' }), +}); -const redisSubscriber = createRedisClient( - 'subscriber', - redisConfig, - server.log.child({ source: 'RedisSubscribe' }), -); +const redisSubscriber = await createRedisClient(env.redis, { + logger: server.log.child({ source: 'RedisSubscribe' }), + iamTokenRefreshLogger: server.log.child({ source: 'RedisSubscribeIamTokenRefresh' }), +}); const pubSub = createHivePubSub({ publisher: redis as any, subscriber: redisSubscriber as any, }); -if (redisIamConfig) { - startIamTokenRefresh( - redis, - redisIamConfig, - env.redis.clusterModeEnabled, - server.log.child({ source: 'RedisIamTokenRefresh' }), - ); - - startIamTokenRefresh( - redisSubscriber, - redisIamConfig, - env.redis.clusterModeEnabled, - server.log.child({ source: 'RedisSubscribeIamTokenRefresh' }), - ); -} - const clickhouse = env.clickhouse ? new ClickHouseClient(env.clickhouse, logger.child({ source: 'ClickHouse' })) : null; diff --git a/packages/services/workflows/src/redis.ts b/packages/services/workflows/src/redis.ts deleted file mode 100644 index e35841ea725..00000000000 --- a/packages/services/workflows/src/redis.ts +++ /dev/null @@ -1,76 +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; - username?: string; - clusterModeEnabled?: boolean; -}; - -export function createRedisClient(label: string, config: RedisConfig, logger: Logger) { - let redis: RedisInstance; - if (config.clusterModeEnabled) { - redis = new Redis.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: null, - enableReadyCheck: false, - tls: config.tlsEnabled ? {} : undefined, - }, - clusterRetryStrategy: (times: number) => Math.min(times * 500, 2000), - }) as unknown as RedisInstance; - } else { - redis = new Redis({ - 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: 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; -} From 6a59c87d5333f5e2df45b8b0f84d07df1f96c8b9 Mon Sep 17 00:00:00 2001 From: mish-elle <268042956+mish-elle@users.noreply.github.com> Date: Mon, 8 Jun 2026 11:04:25 -0400 Subject: [PATCH 4/9] - Consolidate Redis configuration to service-common module - Add IAM authentication support for AWS-managed Redis - Refactor redis-config-validation to redis-config with enhanced schema - Update all services to use centralized Redis config - Add ClickHouse and feature flags support to workflows - Implement tracing configuration across services --- .changeset/five-hounds-know.md | 3 + integration-tests/package.json | 1 - package.json | 1 - packages/services/schema/src/environment.ts | 34 +---- packages/services/schema/src/index.ts | 1 - packages/services/server/src/environment.ts | 33 +---- packages/services/server/src/index.ts | 6 +- packages/services/service-common/package.json | 2 +- .../service-common/src/iam-aws.spec.ts | Bin 22134 -> 14286 bytes .../services/service-common/src/iam-aws.ts | Bin 10520 -> 5811 bytes .../service-common/src/iam-redis.spec.ts | Bin 29608 -> 14675 bytes .../services/service-common/src/iam-redis.ts | 60 ++++---- packages/services/service-common/src/index.ts | 3 +- .../service-common/src/redis-client.spec.ts | 56 +------- .../service-common/src/redis-client.ts | 18 +-- .../src/redis-config-validation.ts | 93 ------------ ...alidation.spec.ts => redis-config.spec.ts} | 121 +++++++--------- .../service-common/src/redis-config.ts | 134 ++++++++++++++++++ packages/services/tokens/src/environment.ts | 34 +---- packages/services/tokens/src/index.ts | 1 - packages/services/usage/src/environment.ts | 33 +---- packages/services/usage/src/index.ts | 1 - .../services/workflows/src/environment.ts | 33 +---- packages/services/workflows/src/index.ts | 6 +- 24 files changed, 265 insertions(+), 409 deletions(-) delete mode 100644 packages/services/service-common/src/redis-config-validation.ts rename packages/services/service-common/src/{redis-config-validation.spec.ts => redis-config.spec.ts} (53%) create mode 100644 packages/services/service-common/src/redis-config.ts diff --git a/.changeset/five-hounds-know.md b/.changeset/five-hounds-know.md index 525b0c25318..4a9c5777095 100644 --- a/.changeset/five-hounds-know.md +++ b/.changeset/five-hounds-know.md @@ -25,3 +25,6 @@ tokens instead of static passwords, with automatic token refresh before expiry. - `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/package.json b/package.json index f6a26e3cea8..ff363af7b62 100644 --- a/package.json +++ b/package.json @@ -165,7 +165,6 @@ "glob@10.x.x": "^10.5.0", "path-to-regexp@0.x.x": "^0.1.13", "fast-uri@2.x.x": "3.x.x", - "ioredis": "5.10.1", "protobufjs@8.x.x": "^8.0.2", "@opentelemetry/exporter-jaeger": "-", "uuid@<14.x.x": "^14.0.0", diff --git a/packages/services/schema/src/environment.ts b/packages/services/schema/src/environment.ts index e6ac4f8bb1d..1d6ffb9a1c8 100644 --- a/packages/services/schema/src/environment.ts +++ b/packages/services/schema/src/environment.ts @@ -69,22 +69,6 @@ const SentryModel = zod.union([ }), ]); -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()), -}); - const PrometheusModel = zod.object({ PROMETHEUS_METRICS: emptyString(zod.union([zod.literal('0'), zod.literal('1')]).optional()), PROMETHEUS_METRICS_LABEL_INSTANCE: emptyString(zod.string().optional()), @@ -115,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), @@ -136,17 +118,13 @@ for (const config of Object.values(configs)) { } } -let redisConfigResult = null; - -if (configs.redis.success) { - redisConfigResult = parseRedisConfigFromEnvironment({ - redis: configs.redis.data, - awsRegion: configs.base.success ? configs.base.data.AWS_REGION : undefined, - }); +const redisConfigResult = parseRedisConfigFromEnvironment( + process.env, + configs.base.success ? configs.base.data.AWS_REGION : undefined, +); - if (redisConfigResult.type === 'error') { - environmentErrors.push(...redisConfigResult.errors); - } +if (redisConfigResult.type === 'error') { + environmentErrors.push(...redisConfigResult.errors); } if (environmentErrors.length) { diff --git a/packages/services/schema/src/index.ts b/packages/services/schema/src/index.ts index 159b95a8eb1..c434917586d 100644 --- a/packages/services/schema/src/index.ts +++ b/packages/services/schema/src/index.ts @@ -70,7 +70,6 @@ async function main() { const redis = await createRedisClient(env.redis, { logger: server.log.child({ source: 'Redis' }), - iamTokenRefreshLogger: server.log.child({ source: 'RedisIamTokenRefresh' }), }); // Capture Redis errors in Sentry in addition to the logging done by createRedisClient diff --git a/packages/services/server/src/environment.ts b/packages/services/server/src/environment.ts index 8c6fec3652e..10fc04d3a68 100644 --- a/packages/services/server/src/environment.ts +++ b/packages/services/server/src/environment.ts @@ -117,22 +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_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()), -}); - const SuperTokensModel = zod.object({ SUPERTOKENS_REFRESH_TOKEN_KEY: zod.string(), SUPERTOKENS_ACCESS_TOKEN_KEY: zod.string(), @@ -320,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), @@ -348,17 +331,13 @@ for (const config of Object.values(configs)) { } } -let redisConfigResult = null; - -if (configs.redis.success) { - redisConfigResult = parseRedisConfigFromEnvironment({ - redis: configs.redis.data, - awsRegion: configs.base.success ? configs.base.data.AWS_REGION : undefined, - }); +const redisConfigResult = parseRedisConfigFromEnvironment( + processEnv, + configs.base.success ? configs.base.data.AWS_REGION : undefined, +); - if (redisConfigResult.type === 'error') { - environmentErrors.push(...redisConfigResult.errors); - } +if (redisConfigResult.type === 'error') { + environmentErrors.push(...redisConfigResult.errors); } if (environmentErrors.length) { diff --git a/packages/services/server/src/index.ts b/packages/services/server/src/index.ts index dafc4b75d90..6433d36846b 100644 --- a/packages/services/server/src/index.ts +++ b/packages/services/server/src/index.ts @@ -173,17 +173,15 @@ export async function main() { const redis = await createRedisClient(env.redis, { logger: server.log.child({ source: 'Redis' }), - iamTokenRefreshLogger: server.log.child({ source: 'RedisIamTokenRefresh' }), }); const redisSubscriber = await createRedisClient(env.redis, { logger: server.log.child({ source: 'RedisSubscribe' }), - iamTokenRefreshLogger: server.log.child({ source: 'RedisSubscribeIamTokenRefresh' }), }); const pubSub = createHivePubSub({ - publisher: redis as any, - subscriber: redisSubscriber as any, + publisher: redis, + subscriber: redisSubscriber, }); registerShutdown({ diff --git a/packages/services/service-common/package.json b/packages/services/service-common/package.json index b132582a8ee..0b4c44bc60f 100644 --- a/packages/services/service-common/package.json +++ b/packages/services/service-common/package.json @@ -38,7 +38,7 @@ "aws-msk-iam-sasl-signer-js": "1.0.3", "fastify": "5.8.5", "fastify-plugin": "5.1.0", - "ioredis": "5.8.2", + "ioredis": "5.10.1", "opentelemetry-instrumentation-fetch-node": "1.2.3", "p-retry": "6.2.1", "prom-client": "15.1.3", diff --git a/packages/services/service-common/src/iam-aws.spec.ts b/packages/services/service-common/src/iam-aws.spec.ts index bbca5efc66d67e7d67e496dea6bd0394bb6c7e90..bf56ab7d13cfbe9fa7dd32a7859e4ad159aef9cb 100644 GIT binary patch literal 14286 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 In8cs{518ydFaQ7m literal 22134 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 UUr#0HE#vvDSpmso#*Rn-1MK?mX#fBK diff --git a/packages/services/service-common/src/iam-aws.ts b/packages/services/service-common/src/iam-aws.ts index cf42f61849fb4be264dac21dc876988565238ce9..c03b0d1aacae9eba05d569ce56cc45b8ebffc3bd 100644 GIT binary patch literal 5811 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 diff --git a/packages/services/service-common/src/iam-redis.spec.ts b/packages/services/service-common/src/iam-redis.spec.ts index 6d1164eaf2388195387415d1831f8a76e3a48d3b..abd0d234a1a0b7f38d588d2389944c3e328b765c 100644 GIT binary patch literal 14675 zcmeHO+in|25`EWK^vi<71v!@NAX(e%0J7jX#;zqpl9PuZShQxEhJgKu z{lfi{J=OQQNtA5LHsAnBq~WkH)u&FKs%j<6RbEu=13OCVvJwU3B|8;yQkvhd*sREx zY?R~$Jsv&XOUzgJ3}1M`qs4?>h#5XtKgXhsisV8}n0UVuQ8i%+{=H0?8R{~rL|I{^ zy;+?_Rgz~cDg>{@>pXh*&wM@?MKER`_82QfRTr83k+CG3seLsNVJ|sizJQBGrQiPS|sHY#dr_X16(yLkYuB4X5@s?_Km07SMaeT$${*-^a9|;Z2&itz9tho`YBbbm$EY zk0C8{DoRn&s;_7S1I%D#midY=59?|nEeq4RaowvgQeIZc5l}AJSu+3SJGSBlUy`NW z>i}mJjAD_BIZmu(RnE*5t>arK)q<6xxJ)APOvte#8rvS6KPbD#MrMC`dMV;xcv_2K zq;N8=2q@EohmS_IYl0?Y>f}GzW%)aYV>Yp1PF;CC2Jy#8rGw%dU7u~uCf8wyHU{x$ z4)n-OySSQXk(Y3d(Q-456e)%LA_}FMLcZjG<{6M4M)^`Zllm6%*c0~s!Q*`eMfDVK zo{Bjr1_RdRRNyeD2aWI5IE{=i;rBSe8L!i-@i82WAR+;9tX{*y3b9J3^CGWT-pf~R zVr3-ryE_KOh=H`sIIQyL1E;1IuiqX9ISuhMzZB1f$c}iLiuhOHD+WYS_aYwp5J0}rhn$g?C!LahAm>@}vLw%e zn_-AqGOvL?W)27BFF%s=$&r;CeiFBlr}5c*MUn8Wy@L+N!yaQA|BrrnY+y&G_3u@wz~Co|4@gqxZOhbjXizf?LDdqE`7um|y%GNIS~2iYHl;%{|;Rwgoa< z+jRgXXc{B-VRZ&qE7=8)-VxJtwo0KJ7I_*&KN+WC>jk29+iWZ;nOLr>%{Sl@I^{n% z1NTPaUT(GltOsbzMU34*)jL3`#R%9jnMORz@@je^rV1GS*t2Zul!;WjY*pkd4%LAW z2Tb|ufmpQ=dqp^#D_bWvdz3ND68RUApZORcQ&kJYOq1++TZPIQ(dYO%FUW_!Pqa346 z9(Yk}MY|8c!v9Va%Zxb|3pElT7|s_(zHXh43&;v+ExlZb8Njl5rL<53XAX~L#j}{F zc_tuRV^Xv}j0lZ{w2~dZJ^vY*6N39HmTEa)5qiszB)~c^;sI_nxM?nCSK^iYUM9j2 zuuf0W4s6!eC&5qtF|tNyOz(^YlGz*%5rIN!^pQKgD@wePPFe}AaNFE#U>7xEBRHB? z`tLV?!emapYD#k+=jlkfC1~k19ng*=I-cfmt*LZGWIDZfiwfHZX%udAsqOD8(1tQe zA6S&dyN>*2Re-^~$Uk~@_!_a85@c;Mh{v*yB2kvJI!!n3RP^)>@!6nBiBHLg43!BF z)zWrf88`>1`Pb8vrY(0FOh3g8L;$THgT?qqOPGgg8uupg@MomkM}l6Eyh4 zT@L=}Qx4u(I`Q+7E~GwVCo(??0Zi2I0{xq+ngo(d^s6B>T$NMZZ9MT}jRZm4wp0&Q z)t=wvAxxMwm-^qM-}{}ppvvuuin85UB#*%Ol%++|FjO7v@nyYi9fLGxbwtBMBgQ*n z2qj_`xHD#`GWKu6GlF(U+T>PI%T6wXR&#AO@Yv8LhaGZoidq8$Ty@Dp8^y*z-{w}? z_yMtZXls6wOKpAR56Q}$k<_LgP{D+1E+nEzRmw_<6l^Laela0S+X(KrW7oKoA54uz z`v`}y;={Rv%1k@X;Ki9+L8m=~7knfrrOrl4B*|)o5FI`!-7CaWl}RLKbV^8KT|(%( z{O^8a%R=z9T5OI9*gJ|av{{(MOrf0_Me-hXKOmrvPoz%XN2ug3 z?x>1wpMtdZ)&Aw}*;2FdbHm2A{;O0-OH87JFo0gRfZ=7omfbSaXFk0BD$Ko)bXuCOtb&bdmUSJqA8BVZRYo=hOjK38U$x`hwl{(VDVomqm!r z8#Jk2I7`rd9u$ZgpG||vqJUmG+|>GOGZ<33S5gDBl&!G0W-irdwz-sQ5SjCnRZ{JA zX@X|?w=+|`r2$O>-^cWA1^geZNZotyA~3p{0f7I+--&aSy^GS|7|+Yq<|GSV^9q+`Mg^z{ z-RrR~AiA7xU#?6i0rq;0ci+LxaSD;tnFG#L;TFzen!ZLJs%O`OxiYPGRF)<)5pANh zuTX2ss@AAQoKikX#W7iwfc4)Eq!=V`UiMxI>8qXUl?hWJf-y8|i~{XDm|b$O z+UO@Q0Ha%3B|N>k2(0#v|DqpGVpyAFs3XyL1;TFt_jMwOkq56o40Rx;^bOliL;a?4 zPSo=$u-v}Wa@FFA?(NP0&2$$pWCC?)5iqALXE{AkCD$w6>@Y0kwjMlE=GOlf;lH1o-WeR)K1Z1H2 z#Yes3b8l4=hl7H#X9R5vc@k!+joN~`#6|uM2Ec`;i&Q|OD?>9CY6pD!o_x`-2-3k= zS8Qf!6Qko0=1@s&cTv=0uLY#_DLadiI%osh)*p%9p`ZV}PPZQJNaPrn@Y=FFJCd z6t%OKa-HGgMzkQv_O+G!Sm!gw2nQxlpP39)H3L0xeBA)P4fQI(g6bhxP$B>y6$!F4 zLGr$6ic1L|rfk@*-&1Pdn zLya}+hTj~%Uu!9cznLQL?$LveEEIrI`l14rMywB|jsx_jg}PWFeeuX)vk@F!1VtP| zW@PJtEXZ;O78y5QSEUUj#;AVS{+hW)c%+g5{i=!EthT(`#R5OwZ^(t2!*;eRXNAJ+Z+wXutz~5#ReAVAugsm)!5@5`a^0JG>Q>a zHYss;oXNkbAm5<@O+Gtd`QaR|uOa~{KQ+@2S0%nsFZ0f@Q#gR*v~DwgWbL4Jm)Zrv zo@@w{_uU=0!o$d=;9B4BJDWx>)>@K9qaA)23th?Rr%N6w__u5CBmY4R@l=r?!tR~& z6~}8T&?1TcIBbCPd(mhMSzSEZyR{$l2R*9YU>ApI+x=^v_LW-i>VM}p+Nf=Rb#uw_ H8~yZu1HiF(*SI(07pbE&H4zyH|{Z^FCqHcZ1o7=;h=yB=Q2 z@s?b9AjcbFCwwMXU&@tN@xHC>`lejpm%FB6H{Qd&Tj3{Zb4#us#AkO;KHtkdqwrCF zU&;M%rRPyt3S6bXefhf-ejk1jeja~cg~PBH+t`(R_vBuB*%F$#`>|Zx2tS5D%jdTI zK<_v#%UvJh(<3?J?gMFM8lO(r`EFOPF`nPaF%(SXY%jL(+3EA#cO?Ds{H*)vpD|5m z-U-KE3RT;kcYZwK#K%*9Y)I>SkxL`toR0pXw6zz@j^7lwuU5e6Nm!o58eIs%)Y0p#Xn||T!dU!40-V1+} zzlraD1ZWnru{+T_c#SR~o%VMBAa@)`{dy}h%kOQYds!$!J9NCWbYmi8p9n39!dxfN zw!U0PZ^p4bTAT`h_M_a_rG?}0efS~jta8e@KSZi!+sh&&EQm2C9kP-0P0f{c zY#xdxr`q{E>LNCnc>nQK3eZxb2l6Yq`$+iqCfpZYIS?#L`mEZhHeb?RYxjZZB)WSb z+O-;`yCd|C!V5Xy4u1*1mg9F(M)YUeUTA9&uLU=oNET z!tHP;+?6}L211Sfcuc_ilss#bzWNamtqnIyEC3ldg~v$Ns4*(|^pw07`I%Qy-0b6f z5}LNPi(Ys%_O$mM`S{eneJ=@R{Z)>4q))USBw7+JI2JtLh_d4U>Uc5XHm5?`~zvmC!^B&-O`l5^XH)d097zy8VpK&*QDPE(g>89UpK4VMtbw&J? zW|H&y3^adTaOy~GK(o@7Q!D5iK|c?Yyz6CqkMqa((zcME_c0T}!k6K1at-`jjmUT) zyeE6)cU#b^J*G*>a~6c{mUbO)83zd4)KBxLatXkUQvaOQNKA!u8`5zjK4o>KlElUZDNharMoeIq<*z0sC^)3RFufp5~>WxNW;}UhpyFe zz(K9Ff|48&(`xlaBW$Ef)s1R!Ss9`qi-oaq^FZKYLQ;#1sX#08z81YxzofmUDl2iL zVmWnfJQ%31G+ZRI+DOXk3^~S;jPAiH-^c@ANG0usTqP?ZXC{*+4_Q8~jg%hz|0_E7 zu}Zz-a<+O6de|P{@U2D8YISzjhFCno&#+jUC)(oaVQ5WDhRYV4)brS8{~Iq@c3bwU+FP(AJU{o z>+NKL!)m8Xrxw;lZ}&KjX3yE#^yYGJ+O3VlYS9{dc-bdT&!)ihcp^Gv^XAq|ze;l{ z6-MkwIuV&32qKk$)S9b7ED^YmaZ$qvGt#Rz0&um&)<%* zytQ^GJG&d#<~98V{pX0RRmW$d{p334eL)b-)ShODYTtKdOG#^+{hwEslyg7Nlyb!M z@w{aqWnCUGG!<#~&z95&Qn#(yCAQCM&mppFtFYxh(4@b(Y^=$Pl#6w7vh=#%G-D3NVVRYW$?)}E_T5f7 zxfU^-@;RYYy}eo-muh-Gio40skd;RTs@!0$`3Q}ZYueNYZC4tVtAxLKeqPd1*LyNc}kVmbN{c=0fZmH8Ab-HjW z)fQK!-;`x)p5y1H^h&L+cs4&&*NusAZ&lCfK1(`T-ScFcx|1(+vN+K5=#kBbew zje9AmV(Rz#{8DKRx$JW(pwYVT^fKgd73R-&qNFy3lT^y7D6_tky=$yDwhW^lL9;Ur zDKN9=XZ1jfn;LMc0bjh&o!^Jr>OS_pAob0KtVzgiu(VLDevy8vZ>H4r@;2|4Vpvw< zfRN1Wd>g$rx^BE-J!PH^cV{`XtZh$v(peW>smUrt;s&nXSX<(DsX=FpaP{dr##yZd z{k*)m(noaH-dpB*PeE##A0$-smDGWylFcxo))F1M_N0T*jK zh1)TQClUgsBRHzKlHN+9`(jxKLtLd5j5R?TUCHxYMwTkCT63ze9`|$g52Q8bgWKMn z0DW3JP>d!LrN1@d8dx_KZX_$`?)1dI(sxAkNL<=yL}(8)dqlQdQY#>ueJp3}dn)HNrcDLod+}-`|2x5eNSs^$7UVn?5=h>?OXVZH7RXM{CY)Kv2zkdRq(CaHA5&fn% za$A{Fa>Vl+X{ygqZuhmlRP{=FOvdWv$2nD^VQZVUa?$oq9duAvalDT$Bhhhu7sxF+ zT9Mz|;Wy&%*`f8Tcy>$9_zoNUQr;PJEBrNHxh3P?Z)l5;5!J`CK9IilWNf*Q{Yw0S zd8qr+mcF}cT=de0dU-r%s4xVV`&3(+aR8`N?>k9yaVr+^ujoK}R|@tzjaT{0E|Ydo z0j>5ko8}zL7B~0Sv>##sBECn3HuClqvnP|g(cg?c_{JgycwsS?MF|>f&<}6Xp--)K zd;MoOm+pm0aY_GZz$3x+@Vv%tKKLKxK!+l%QjiFduY9OO) zj;QYYkZb8JHGcJ8QP-=rdB5pNoLTp6hbQ0m^zegw;(x*+dQWFI>RLwRcSa|?!543z$iAM{(t{zK zN)BvGt#OuC8j!=;4wyW1(iL85uJhB`1x+OwY}bg*a9n4y>$9I8wXO#yn`W7|`w=!# z%y7AUil@y2K;eA#L=y|RzlPo2y9wK{=?3lX?RQrn?JoweHBZrc6Z^BZf}{KWbQTai z1G9D`0+jl^2r9pD*-V@7%l(S?pA(0~eR7HPj=gd$*qZOB-_FOj9B=lFZt?`=4#Q^C znF&(Qh0m(vMm`o@t1|C%^UvPL|EvSRYOH1CpZXEnxqB}%Jr zsprYH*0mCkU)vD6n*M(*K7jaAqa|j)saDn7XJnR#L$$9o64&4-XZ4rzbglEOMt^HF zHTwQXtx7;GYjm*=Mz)x@?)2xh*5;`9dn>x?I|o#ov`(%$u%~{QuCXdX-!AQ?THB3; z&iI_5EpG5OJ&yX#dQtW{Smy8`@a|M165g-c)L3%%zEpWT^r)CqF;DchsM?v2tuMl| znkx#2#;U&Rf_#Q))QwGHQ}4w0E8o4%U_(v$oVO(@Ej3F?o-6lgrn5C~Q_Hz)HQ2sh zCVy7zA-sLq*LZc_%IYTi-;}5bV^7qD`Q)t_=An$nau#B!CCq~xdDfPEQMm?2eIsw- z&ezw>yB6lgN{4X(E3I4ThekaF>&R!u)Mwf?+A*8)UdYdzUCwF}crx^D^8IG#lmmW+ zN%wyvNmx^ID)-eY#d#uzz80k@oc~`5jXAoxRYOpuozp)LW>{o2@BEzSsE;wMW^{dL z&or~CSG&41z`VLO3_CuZ7{_M@xt@BRG0Ymqg`o&KFN*S9e}+cb%xroV&;m&%>()IpYvQBQ8-Qn7M%z9b=ucRkj{XN_&1!i7 diff --git a/packages/services/service-common/src/iam-redis.ts b/packages/services/service-common/src/iam-redis.ts index afef874aef3..78bc465c103 100644 --- a/packages/services/service-common/src/iam-redis.ts +++ b/packages/services/service-common/src/iam-redis.ts @@ -1,6 +1,10 @@ -import type { Redis as RedisInstance } from 'ioredis'; +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. * @@ -49,7 +53,7 @@ export interface IamRedisConfig { */ export async function generateIamAuthToken( config: IamRedisConfig, - logger: { debug(...args: unknown[]): void }, + logger: ServiceLogger, ): Promise { const cacheName = config.iamAuthCacheName || 'i-cannot-be-empty'; @@ -76,31 +80,32 @@ export async function generateIamAuthToken( /** * Re-authenticate active Redis connections with a fresh IAM token. * - * In **cluster mode**, iterates over every known node, issues `AUTH`, and - * updates both the per-node password and the cluster `connectionPool` so - * newly created connections also use the new token. Individual node failures - * are logged as warnings but do not abort the loop. + * 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**, issues a single `AUTH` command and updates the - * connection's stored password. + * 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 isCluster - `true` when `redis` is a `Redis.Cluster` instance. * @param logger - Logger with `debug` and `warn` methods. */ export async function refreshIamAuth( redis: RedisInstance, token: string, username: string, - isCluster: boolean, - logger: { debug(...args: unknown[]): void; warn(...args: unknown[]): void }, + logger: ServiceLogger, ): Promise { - if (isCluster) { - const nodes = (redis as any).nodes?.('all') ?? []; + if (redis instanceof RedisCluster) { + const nodes = redis.nodes('all'); logger.debug( - 'Refreshing IAM token (service=elasticache) - re-authenticating %s cluster node(s)', + 'Refreshing IAM token (service=elasticache) — re-authenticating %s cluster node(s)', nodes.length, ); for (const node of nodes) { @@ -112,13 +117,12 @@ export async function refreshIamAuth( } } // Update cluster-level redisOptions for new node connections. - const pool = (redis as any).connectionPool; - if (pool?.redisOptions) { - pool.redisOptions.password = token; + if (redis.options?.redisOptions) { + redis.options.redisOptions.password = token; } } else { - (redis as any).options.password = token; - await (redis as any).call('AUTH', username, token); + redis.options.password = token; + await redis.call('AUTH', username, token); } logger.debug('IAM token refreshed successfully (service=elasticache)'); } @@ -135,28 +139,22 @@ export async function refreshIamAuth( * * @param redis - The active ioredis client to re-authenticate. * @param config - ElastiCache endpoint and IAM user details. - * @param isCluster - `true` when `redis` is a `Redis.Cluster` instance. * @param logger - Logger with `debug`, `warn`, and `error` methods. - * @returns A `setInterval` handle call `clearInterval(handle)` during - * graceful shutdown to stop the refresh loop. + * @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, - isCluster: boolean, - logger: { - debug(...args: unknown[]): void; - warn(...args: unknown[]): void; - error(...args: unknown[]): void; - }, -): ReturnType { + 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, isCluster, logger); + await refreshIamAuth(redis, token, config.username, logger); } catch (error) { logger.error( 'ElastiCache IAM token refresh failed (attempt=%s/%s, error=%s)', @@ -190,7 +188,7 @@ export function startIamTokenRefresh( */ export async function resolveRedisCredentials( staticPassword: string, - logger: { debug(...args: unknown[]): void }, + logger: ServiceLogger, iamConfig?: IamRedisConfig, ): Promise<{ password: string; username?: string }> { if (!iamConfig) { diff --git a/packages/services/service-common/src/index.ts b/packages/services/service-common/src/index.ts index 1ac5f070b67..e58aca02ca0 100644 --- a/packages/services/service-common/src/index.ts +++ b/packages/services/service-common/src/index.ts @@ -27,10 +27,11 @@ export { createMskIamTokenProvider } from './iam-msk'; export { invariant } from './helpers'; export { parseRedisConfigFromEnvironment, + RedisModel, type RedisEnvironment, type ParseRedisConfigFromEnvironmentResult, type RedisConfig, -} from './redis-config-validation'; +} from './redis-config'; export { createRedisClient, type Redis, diff --git a/packages/services/service-common/src/redis-client.spec.ts b/packages/services/service-common/src/redis-client.spec.ts index d388153ac36..0f95cccf05e 100644 --- a/packages/services/service-common/src/redis-client.spec.ts +++ b/packages/services/service-common/src/redis-client.spec.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -import type { RedisConfig } from './redis-config-validation'; +import type { RedisConfig } from './redis-config'; /** Mock logger matching ServiceLogger interface */ type MockServiceLogger = { @@ -387,31 +387,10 @@ describe('createRedisClient', () => { username: 'default', iamAuthCacheName: 'test-cache', }, - false, expect.any(Object), ); }); - it('passes iamTokenRefreshLogger from options to startIamTokenRefresh', async () => { - const logger = createMockLogger(); - const iamRefreshLogger = createMockLogger(); - const config = { - ...baseEnvConfig, - awsIamAuthEnabled: true, - }; - const redis = await createRedisClient(config, { - logger, - iamTokenRefreshLogger: iamRefreshLogger, - }); - - expect(startIamTokenRefreshMock).toHaveBeenCalledWith( - redis, - expect.any(Object), - false, - iamRefreshLogger, - ); - }); - it('does NOT start IAM token refresh when IAM is disabled', async () => { const logger = createMockLogger(); const config = { @@ -435,7 +414,6 @@ describe('createRedisClient', () => { expect(startIamTokenRefreshMock).toHaveBeenCalledWith( redis, expect.any(Object), - true, expect.any(Object), ); }); @@ -446,7 +424,6 @@ describe('createRedisClient', () => { const logger = createMockLogger(); const mainLogger = logger.child({ label: 'main' }); const subscriberLogger = logger.child({ label: 'subscriber' }); - const iamLogger = createMockLogger(); const config = { ...baseEnvConfig, @@ -455,12 +432,10 @@ describe('createRedisClient', () => { const mainRedis = await createRedisClient(config, { logger: mainLogger, - iamTokenRefreshLogger: iamLogger, }); const subscriberRedis = await createRedisClient(config, { logger: subscriberLogger, - iamTokenRefreshLogger: iamLogger, }); // Both clients should be distinct instances @@ -527,42 +502,17 @@ describe('createRedisClient', () => { expect(redis.options.maxRetriesPerRequest).toBe(25); }); - it('accepts optional iamTokenRefreshLogger', async () => { + it('uses main logger for IAM refresh logs', async () => { const logger = createMockLogger(); - const iamRefreshLogger = createMockLogger(); const config = { ...baseEnvConfig, awsIamAuthEnabled: true, }; const redis = await createRedisClient(config, { logger, - iamTokenRefreshLogger: iamRefreshLogger, }); - expect(startIamTokenRefreshMock).toHaveBeenCalledWith( - redis, - expect.any(Object), - false, - iamRefreshLogger, - ); - }); - - it('uses main logger as default iamTokenRefreshLogger if not provided', async () => { - const logger = createMockLogger(); - const config = { - ...baseEnvConfig, - awsIamAuthEnabled: true, - }; - const redis = await createRedisClient(config, { - logger, - }); - - expect(startIamTokenRefreshMock).toHaveBeenCalledWith( - redis, - expect.any(Object), - false, - logger, - ); + expect(startIamTokenRefreshMock).toHaveBeenCalledWith(redis, expect.any(Object), logger); }); it('supports creating a client without a label argument', async () => { diff --git a/packages/services/service-common/src/redis-client.ts b/packages/services/service-common/src/redis-client.ts index 1d8fdfdaa68..46bce572c1a 100644 --- a/packages/services/service-common/src/redis-client.ts +++ b/packages/services/service-common/src/redis-client.ts @@ -2,16 +2,16 @@ 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-validation'; +import type { RedisConfig } from './redis-config'; type RedisInstance = IORedis; /* - * Re-exported ioredis `Redis` type for consumers that need to type a Redis client instance. -*/ + * 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> & { @@ -40,11 +40,6 @@ export type RedisClientOptions = { * Defaults to `null` (unlimited retries) when omitted. */ maxRetriesPerRequest?: number | null; - /** - * Optional logger for IAM token refresh events. - * If not provided, defaults to the main `logger`. - */ - iamTokenRefreshLogger?: ServiceLogger; }; /** @@ -57,7 +52,7 @@ export type RedisClientOptions = { * - 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 optional IAM token refresh logger. + * @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( @@ -99,8 +94,7 @@ export async function createRedisClient( // Start IAM token refresh if enabled // Each client gets its own independent token lifecycle if (redisIamConfig) { - const iamLogger = options.iamTokenRefreshLogger ?? options.logger; - startIamTokenRefresh(redis, redisIamConfig, config.clusterModeEnabled ?? false, iamLogger); + startIamTokenRefresh(redis, redisIamConfig, options.logger); } return redis; diff --git a/packages/services/service-common/src/redis-config-validation.ts b/packages/services/service-common/src/redis-config-validation.ts deleted file mode 100644 index 2383360f069..00000000000 --- a/packages/services/service-common/src/redis-config-validation.ts +++ /dev/null @@ -1,93 +0,0 @@ -/** - * Parsed Redis-related environment variables. Matches the shape of the Zod schema built by services. - */ -export type RedisEnvironment = { - REDIS_HOST: string; - REDIS_PORT: number; - REDIS_PASSWORD?: string; - REDIS_USERNAME?: string; - REDIS_TLS_ENABLED?: '0' | '1'; - REDIS_CLUSTER_MODE_ENABLED?: '0' | '1'; - REDIS_AWS_REGION?: string; - REDIS_AWS_IAM_AUTH_ENABLED?: '0' | '1'; - REDIS_AWS_IAM_CACHE_NAME?: string; -}; - -/** - * Normalized Redis runtime configuration consumed by service env 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; base schema - * validation errors are handled separately by each service environment module. - */ -export type ParseRedisConfigFromEnvironmentResult = - | { - type: 'error'; - errors: Array; - } - | { - type: 'ok'; - config: RedisConfig; - }; - -/** - * Validates Redis IAM requirements and returns a normalized Redis config object. - */ -export function parseRedisConfigFromEnvironment(args: { - redis: RedisEnvironment; - awsRegion: string | undefined; -}): ParseRedisConfigFromEnvironmentResult { - if (args.redis.REDIS_AWS_IAM_AUTH_ENABLED === '1') { - const missingRedisIamVars: string[] = []; - - if (args.redis.REDIS_TLS_ENABLED !== '1') { - missingRedisIamVars.push('REDIS_TLS_ENABLED must be enabled (ElastiCache IAM requires TLS)'); - } - - if (!args.redis.REDIS_AWS_IAM_CACHE_NAME) { - missingRedisIamVars.push('REDIS_AWS_IAM_CACHE_NAME'); - } - - if (!args.redis.REDIS_AWS_REGION && !args.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: args.redis.REDIS_HOST, - port: args.redis.REDIS_PORT, - password: args.redis.REDIS_PASSWORD ?? '', - username: args.redis.REDIS_USERNAME, - tlsEnabled: args.redis.REDIS_TLS_ENABLED === '1', - clusterModeEnabled: args.redis.REDIS_CLUSTER_MODE_ENABLED === '1', - awsIamAuthEnabled: args.redis.REDIS_AWS_IAM_AUTH_ENABLED === '1', - awsRegion: args.redis.REDIS_AWS_REGION ?? args.awsRegion, - awsIamAuthCacheName: args.redis.REDIS_AWS_IAM_CACHE_NAME, - }, - }; -} diff --git a/packages/services/service-common/src/redis-config-validation.spec.ts b/packages/services/service-common/src/redis-config.spec.ts similarity index 53% rename from packages/services/service-common/src/redis-config-validation.spec.ts rename to packages/services/service-common/src/redis-config.spec.ts index 679fa8470a7..734b44f1106 100644 --- a/packages/services/service-common/src/redis-config-validation.spec.ts +++ b/packages/services/service-common/src/redis-config.spec.ts @@ -1,57 +1,15 @@ import { describe, expect, it } from 'vitest'; -import zod from 'zod'; -import { parseRedisConfigFromEnvironment } from './redis-config-validation'; +import { parseRedisConfigFromEnvironment } from './redis-config'; describe('parseRedisConfigFromEnvironment', () => { - 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)); - - // treat an empty string (`''`) as undefined - const emptyString = (input: T) => { - return zod.preprocess((value: unknown) => { - if (value === '') return undefined; - return value; - }, input); - }; - - const schema = 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()), - }); - - function parseRedis(raw: Record) { - const parsed = schema.safeParse(raw); - expect(parsed.success).toBe(true); - return parsed.success ? parsed.data : undefined; - } - it('returns ok for static auth config', () => { - const redis = parseRedis({ + const result = parseRedisConfigFromEnvironment({ REDIS_HOST: 'localhost', REDIS_PORT: '6379', REDIS_PASSWORD: 'secret', REDIS_AWS_IAM_AUTH_ENABLED: '0', }); - const result = parseRedisConfigFromEnvironment({ redis: redis!, awsRegion: undefined }); - expect(result.type).toBe('ok'); if (result.type === 'ok') { expect(result.config).toMatchObject({ @@ -64,7 +22,7 @@ describe('parseRedisConfigFromEnvironment', () => { }); it('returns ok for valid iam config', () => { - const redis = parseRedis({ + const result = parseRedisConfigFromEnvironment({ REDIS_HOST: 'cache.aws', REDIS_PORT: '6379', REDIS_TLS_ENABLED: '1', @@ -73,8 +31,6 @@ describe('parseRedisConfigFromEnvironment', () => { REDIS_AWS_REGION: 'us-east-1', }); - const result = parseRedisConfigFromEnvironment({ redis: redis!, awsRegion: undefined }); - expect(result.type).toBe('ok'); if (result.type === 'ok') { expect(result.config).toMatchObject({ @@ -87,7 +43,7 @@ describe('parseRedisConfigFromEnvironment', () => { }); it('returns error when iam enabled and tls disabled', () => { - const redis = parseRedis({ + const result = parseRedisConfigFromEnvironment({ REDIS_HOST: 'cache.aws', REDIS_PORT: '6379', REDIS_TLS_ENABLED: '0', @@ -96,8 +52,6 @@ describe('parseRedisConfigFromEnvironment', () => { REDIS_AWS_REGION: 'us-east-1', }); - const result = parseRedisConfigFromEnvironment({ redis: redis!, awsRegion: undefined }); - expect(result.type).toBe('error'); if (result.type === 'error') { expect(result.errors[0]).toContain( @@ -107,7 +61,7 @@ describe('parseRedisConfigFromEnvironment', () => { }); it('returns error when iam enabled and cache name missing', () => { - const redis = parseRedis({ + const result = parseRedisConfigFromEnvironment({ REDIS_HOST: 'cache.aws', REDIS_PORT: '6379', REDIS_TLS_ENABLED: '1', @@ -115,8 +69,6 @@ describe('parseRedisConfigFromEnvironment', () => { REDIS_AWS_REGION: 'us-east-1', }); - const result = parseRedisConfigFromEnvironment({ redis: redis!, awsRegion: undefined }); - expect(result.type).toBe('error'); if (result.type === 'error') { expect(result.errors[0]).toContain('REDIS_AWS_IAM_CACHE_NAME'); @@ -124,7 +76,7 @@ describe('parseRedisConfigFromEnvironment', () => { }); it('returns error when iam enabled and both regions missing', () => { - const redis = parseRedis({ + const result = parseRedisConfigFromEnvironment({ REDIS_HOST: 'cache.aws', REDIS_PORT: '6379', REDIS_TLS_ENABLED: '1', @@ -132,8 +84,6 @@ describe('parseRedisConfigFromEnvironment', () => { REDIS_AWS_IAM_CACHE_NAME: 'my-cache', }); - const result = parseRedisConfigFromEnvironment({ redis: redis!, awsRegion: undefined }); - expect(result.type).toBe('error'); if (result.type === 'error') { expect(result.errors[0]).toContain('REDIS_AWS_REGION or AWS_REGION'); @@ -141,15 +91,16 @@ describe('parseRedisConfigFromEnvironment', () => { }); it('uses AWS_REGION fallback when REDIS_AWS_REGION is missing', () => { - const redis = parseRedis({ - REDIS_HOST: 'cache.aws', - REDIS_PORT: '6379', - REDIS_TLS_ENABLED: '1', - REDIS_AWS_IAM_AUTH_ENABLED: '1', - REDIS_AWS_IAM_CACHE_NAME: 'my-cache', - }); - - const result = parseRedisConfigFromEnvironment({ redis: redis!, awsRegion: 'eu-west-1' }); + 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') { @@ -158,7 +109,7 @@ describe('parseRedisConfigFromEnvironment', () => { }); it('normalizes empty optional fields', () => { - const redis = parseRedis({ + const result = parseRedisConfigFromEnvironment({ REDIS_HOST: 'localhost', REDIS_PORT: '6379', REDIS_PASSWORD: '', @@ -166,12 +117,46 @@ describe('parseRedisConfigFromEnvironment', () => { REDIS_AWS_IAM_AUTH_ENABLED: '0', }); - const result = parseRedisConfigFromEnvironment({ redis: redis!, awsRegion: undefined }); - 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/src/environment.ts b/packages/services/tokens/src/environment.ts index d03dce4eca1..4c677718280 100644 --- a/packages/services/tokens/src/environment.ts +++ b/packages/services/tokens/src/environment.ts @@ -55,22 +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_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()), -}); - const PrometheusModel = zod.object({ PROMETHEUS_METRICS: emptyString(zod.union([zod.literal('0'), zod.literal('1')]).optional()), PROMETHEUS_METRICS_LABEL_INSTANCE: emptyString(zod.string().optional()), @@ -103,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), @@ -125,17 +107,13 @@ for (const config of Object.values(configs)) { } } -let redisConfigResult = null; - -if (configs.redis.success) { - redisConfigResult = parseRedisConfigFromEnvironment({ - redis: configs.redis.data, - awsRegion: configs.base.success ? configs.base.data.AWS_REGION : undefined, - }); +const redisConfigResult = parseRedisConfigFromEnvironment( + process.env, + configs.base.success ? configs.base.data.AWS_REGION : undefined, +); - if (redisConfigResult.type === 'error') { - environmentErrors.push(...redisConfigResult.errors); - } +if (redisConfigResult.type === 'error') { + environmentErrors.push(...redisConfigResult.errors); } if (environmentErrors.length) { diff --git a/packages/services/tokens/src/index.ts b/packages/services/tokens/src/index.ts index 46628a03f3f..a3872d89612 100644 --- a/packages/services/tokens/src/index.ts +++ b/packages/services/tokens/src/index.ts @@ -72,7 +72,6 @@ export async function main() { const redis = await createRedisClient(env.redis, { logger: server.log.child({ source: 'Redis' }), - iamTokenRefreshLogger: server.log.child({ source: 'RedisIamTokenRefresh' }), maxRetriesPerRequest: 20, }); diff --git a/packages/services/usage/src/environment.ts b/packages/services/usage/src/environment.ts index d3520183464..a7b567aaea2 100644 --- a/packages/services/usage/src/environment.ts +++ b/packages/services/usage/src/environment.ts @@ -89,22 +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_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()), -}); - const PrometheusModel = zod.object({ PROMETHEUS_METRICS: emptyString(zod.union([zod.literal('0'), zod.literal('1')]).optional()), PROMETHEUS_METRICS_LABEL_INSTANCE: emptyString(zod.string().optional()), @@ -135,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), @@ -162,17 +145,13 @@ if (configs.kafka.success && configs.kafka.data.KAFKA_AWS_IAM_AUTH_ENABLED === ' } } -let redisConfigResult = null; +const redisConfigResult = parseRedisConfigFromEnvironment( + process.env, + configs.base.success ? configs.base.data.AWS_REGION : undefined, +); -if (configs.redis.success) { - redisConfigResult = parseRedisConfigFromEnvironment({ - redis: configs.redis.data, - awsRegion: configs.base.success ? configs.base.data.AWS_REGION : undefined, - }); - - if (redisConfigResult.type === 'error') { - environmentErrors.push(...redisConfigResult.errors); - } +if (redisConfigResult.type === 'error') { + environmentErrors.push(...redisConfigResult.errors); } if (environmentErrors.length) { diff --git a/packages/services/usage/src/index.ts b/packages/services/usage/src/index.ts index 16383da0e01..c811aea3737 100644 --- a/packages/services/usage/src/index.ts +++ b/packages/services/usage/src/index.ts @@ -63,7 +63,6 @@ async function main() { const redis = await createRedisClient(env.redis, { logger: server.log.child({ source: 'Redis' }), - iamTokenRefreshLogger: server.log.child({ source: 'RedisIamTokenRefresh' }), maxRetriesPerRequest: 20, }); diff --git a/packages/services/workflows/src/environment.ts b/packages/services/workflows/src/environment.ts index 5306a5eb9a0..acf9dd428a4 100644 --- a/packages/services/workflows/src/environment.ts +++ b/packages/services/workflows/src/environment.ts @@ -94,22 +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_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()), -}); - const ClickHouseModel = zod.union([ zod.object({ CLICKHOUSE: emptyString(zod.literal('0').optional()), @@ -176,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), }; @@ -188,17 +171,13 @@ for (const config of Object.values(configs)) { } } -let redisConfigResult = null; +const redisConfigResult = parseRedisConfigFromEnvironment( + process.env, + configs.base.success ? configs.base.data.AWS_REGION : undefined, +); -if (configs.redis.success) { - redisConfigResult = parseRedisConfigFromEnvironment({ - redis: configs.redis.data, - awsRegion: configs.base.success ? configs.base.data.AWS_REGION : undefined, - }); - - if (redisConfigResult.type === 'error') { - environmentErrors.push(...redisConfigResult.errors); - } +if (redisConfigResult.type === 'error') { + environmentErrors.push(...redisConfigResult.errors); } if (environmentErrors.length) { diff --git a/packages/services/workflows/src/index.ts b/packages/services/workflows/src/index.ts index f1204f8c1b4..a4b69edc7e7 100644 --- a/packages/services/workflows/src/index.ts +++ b/packages/services/workflows/src/index.ts @@ -114,17 +114,15 @@ const server = await createServer({ const redis = await createRedisClient(env.redis, { logger: server.log.child({ source: 'Redis' }), - iamTokenRefreshLogger: server.log.child({ source: 'RedisIamTokenRefresh' }), }); const redisSubscriber = await createRedisClient(env.redis, { logger: server.log.child({ source: 'RedisSubscribe' }), - iamTokenRefreshLogger: server.log.child({ source: 'RedisSubscribeIamTokenRefresh' }), }); const pubSub = createHivePubSub({ - publisher: redis as any, - subscriber: redisSubscriber as any, + publisher: redis, + subscriber: redisSubscriber, }); const clickhouse = env.clickhouse From 214d759540890d2b00c0a8b0095ff58523e3724d Mon Sep 17 00:00:00 2001 From: mish-elle <268042956+mish-elle@users.noreply.github.com> Date: Mon, 8 Jun 2026 11:30:45 -0400 Subject: [PATCH 5/9] Replace Unicode em dash with ASCII hyphen in ClickHouse warning message --- packages/services/workflows/src/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/services/workflows/src/index.ts b/packages/services/workflows/src/index.ts index a4b69edc7e7..66140317e48 100644 --- a/packages/services/workflows/src/index.ts +++ b/packages/services/workflows/src/index.ts @@ -86,7 +86,7 @@ if (env.clickhouse) { ); } else { logger.warn( - 'ClickHouse not configured \u2014 metric alert rules will not be evaluated. ' + + 'ClickHouse not configured — metric alert rules will not be evaluated. ' + 'Set CLICKHOUSE=1 and the CLICKHOUSE_* env vars to enable.', ); } @@ -234,4 +234,4 @@ registerShutdown({ process.exit(1); } }, -}); \ No newline at end of file +}); From 97f879bb69ae9b0d7540385bac290cff2050957c Mon Sep 17 00:00:00 2001 From: mish-elle <268042956+mish-elle@users.noreply.github.com> Date: Mon, 8 Jun 2026 11:38:44 -0400 Subject: [PATCH 6/9] Fix newline at end of service-common package.json --- packages/services/service-common/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/services/service-common/package.json b/packages/services/service-common/package.json index 0b4c44bc60f..9589fd19295 100644 --- a/packages/services/service-common/package.json +++ b/packages/services/service-common/package.json @@ -44,4 +44,4 @@ "prom-client": "15.1.3", "zod": "3.25.76" } -} \ No newline at end of file +} From 680e4dd80d85e6b4a522b1a53f59a8baa8930abc Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Tue, 9 Jun 2026 09:50:03 +0200 Subject: [PATCH 7/9] lockfile and one version of redis --- packages/libraries/pubsub/package.json | 2 +- packages/libraries/pubsub/src/index.ts | 2 +- pnpm-lock.yaml | 1305 +++++++----------------- 3 files changed, 366 insertions(+), 943 deletions(-) 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/pnpm-lock.yaml b/pnpm-lock.yaml index 6cacb57c171..337c737de1b 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) @@ -1843,9 +1849,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 +1918,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 +2001,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 +2037,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 +2050,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 +2797,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 +2825,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 +2841,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 +2853,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 +2865,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 +2873,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 +2901,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 +2913,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 +2925,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 +3005,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 +3033,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 +3069,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 +3121,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 +5762,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 +9071,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 +9083,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 +9235,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 +9279,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 +9347,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 +12897,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 +13927,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 +19311,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 +19342,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 +19357,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 +19383,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 +19394,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 +19413,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 +19532,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 +19564,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 +19572,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 +19588,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 +19609,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 +19627,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 +19639,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 +19649,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 +19667,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 +19690,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 +19733,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 +19780,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/protocol-http': 5.4.3 '@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/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 +19805,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 +19827,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 +19863,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 +19891,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 +19924,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 +19948,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 +19963,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 +19986,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 +20000,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 +20010,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 +20030,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 +20043,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 +20062,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 +20074,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 +20100,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 +20111,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 +20142,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 +20183,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 +20214,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 +20262,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 +20289,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 +20304,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 +20321,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 +20337,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 +20352,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 +20364,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 +20651,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 +21964,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 +21972,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 +22187,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 +22233,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 +22652,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 +22683,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 +22697,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 +22790,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 +22809,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 +22877,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 +22963,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 +22978,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 +22993,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 +23017,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 +24488,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 +25002,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 +29026,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 +29050,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 +29069,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 +29079,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 +29092,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 +29116,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 +29129,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 +29157,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 +29175,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 +29194,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 +29217,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 +29247,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 +29258,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 +29286,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 +29298,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 +29332,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 +29340,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 +29395,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 +29403,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 +29416,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 +29452,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 +29488,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 +29503,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 +29517,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 +29542,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 +29577,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 +29594,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 +29621,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 +29639,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 +29655,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 +30460,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 +31575,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 +31639,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 +33668,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 +35092,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 +35123,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: {} From 2088923591c342084a32da5c2d6f179ba7eba685 Mon Sep 17 00:00:00 2001 From: mish-elle <268042956+mish-elle@users.noreply.github.com> Date: Tue, 9 Jun 2026 15:00:59 -0400 Subject: [PATCH 8/9] fix: CI lint and typecheck errors for Redis IAM auth --- integration-tests/testkit/seed.ts | 22 ++++++++++++++----- packages/services/service-common/package.json | 1 + .../service-common/src/redis-client.spec.ts | 4 ++-- 3 files changed, 20 insertions(+), 7 deletions(-) diff --git a/integration-tests/testkit/seed.ts b/integration-tests/testkit/seed.ts index c44879550e9..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 { createPostgresDatabasePool, psql } from '@hive/postgres'; -import { createRedisClient } from '@hive/service-common'; +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/services/service-common/package.json b/packages/services/service-common/package.json index 9589fd19295..55125b02140 100644 --- a/packages/services/service-common/package.json +++ b/packages/services/service-common/package.json @@ -42,6 +42,7 @@ "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/redis-client.spec.ts b/packages/services/service-common/src/redis-client.spec.ts index 0f95cccf05e..616ac4e1bb7 100644 --- a/packages/services/service-common/src/redis-client.spec.ts +++ b/packages/services/service-common/src/redis-client.spec.ts @@ -359,7 +359,7 @@ describe('createRedisClient', () => { awsIamAuthEnabled: true, username: undefined, }; - const redis = await createRedisClient(config, { logger }); + await createRedisClient(config, { logger }); expect(resolveRedisCredentialsMock).toHaveBeenCalledWith( 'test-password', @@ -397,7 +397,7 @@ describe('createRedisClient', () => { ...baseEnvConfig, awsIamAuthEnabled: false, }; - const redis = await createRedisClient(config, { logger }); + await createRedisClient(config, { logger }); expect(startIamTokenRefreshMock).not.toHaveBeenCalled(); }); From 7de4c687eb0af6f691f724bbdd2a1734105ab26e Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Wed, 10 Jun 2026 09:46:07 +0200 Subject: [PATCH 9/9] sync lockfile --- pnpm-lock.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 337c737de1b..ccd98663124 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1768,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