From 9a92b2e637fd53f7f16f941b31516533170b6504 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 01/12] 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 | 29 ++ 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, 2009 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 c5910cc891b..08b2d62aa82 100644 --- a/packages/services/server/src/environment.ts +++ b/packages/services/server/src/environment.ts @@ -58,6 +58,7 @@ const EnvironmentModel = zod.object({ FEATURE_FLAGS_METRIC_ALERT_RULES_ENABLED: emptyString( zod.union([zod.literal('1'), zod.literal('0')]).optional(), ), + AWS_REGION: emptyString(zod.string().optional()), }); const CommerceModel = zod.object({ @@ -112,7 +113,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({ @@ -330,6 +340,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); @@ -454,7 +479,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 11218918e24..7ba4b3cb977 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 5387bf5e994..d3ea61966b5 100644 --- a/packages/services/service-common/package.json +++ b/packages/services/service-common/package.json @@ -36,6 +36,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 fef51b42213..16567da4268 100644 --- a/packages/services/service-common/src/index.ts +++ b/packages/services/service-common/src/index.ts @@ -12,3 +12,16 @@ export { sentryInit } from './sentry'; export { scrubBasicAuth } from './scrub'; export { createMskIamTokenProvider } from './iam-msk'; export { invariant } from './helpers'; +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 4016655e46f..e80e647fc1a 100644 --- a/packages/services/usage/README.md +++ b/packages/services/usage/README.md @@ -36,8 +36,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 51990e905a1..d18449cb76f 100644 --- a/packages/services/usage/src/environment.ts +++ b/packages/services/usage/src/environment.ts @@ -89,7 +89,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({ @@ -149,6 +158,21 @@ if (configs.kafka.success && configs.kafka.data.KAFKA_AWS_IAM_AUTH_ENABLED === ' } } +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); @@ -249,7 +273,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 cc220696e7b..0562714da46 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 ClickHouseModel = zod.union([ @@ -170,6 +180,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); @@ -292,7 +317,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, }, featureFlags: { metricAlertRulesEnabled: featureFlags.FEATURE_FLAGS_METRIC_ALERT_RULES_ENABLED === '1', diff --git a/packages/services/workflows/src/index.ts b/packages/services/workflows/src/index.ts index b6795a58c85..9104703374e 100644 --- a/packages/services/workflows/src/index.ts +++ b/packages/services/workflows/src/index.ts @@ -7,8 +7,10 @@ import { createServer, registerShutdown, reportReadiness, + resolveRedisCredentials, sentryInit, startHeartbeats, + startIamTokenRefresh, startMetrics, type TracingInstance, } from '@hive/service-common'; @@ -112,13 +114,35 @@ 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' }), ), }); @@ -127,6 +151,15 @@ const clickhouse = env.clickhouse ? new ClickHouseClient(env.clickhouse, logger.child({ source: 'ClickHouse' })) : null; +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 add742d64692f13045379f6d71fcd32c39f586df Mon Sep 17 00:00:00 2001 From: Michelle Song Date: Tue, 26 May 2026 18:03:02 -0400 Subject: [PATCH 02/12] 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/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 +++++----- 5 files changed, 11 insertions(+), 11 deletions(-) diff --git a/packages/services/service-common/package.json b/packages/services/service-common/package.json index d3ea61966b5..a51d03b24df 100644 --- a/packages/services/service-common/package.json +++ b/packages/services/service-common/package.json @@ -13,6 +13,9 @@ "@trpc/server": "10.45.3" }, "devDependencies": { + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/credential-providers": "3.1035.0", + "@aws-sdk/util-format-url": "3.972.13", "@fastify/cors": "11.2.0", "@graphql-hive/logger": "1.1.0", "@graphql-hive/plugin-opentelemetry": "1.4.26", @@ -31,17 +34,14 @@ "@sentry/types": "7.120.2", "@sentry/utils": "7.120.2", "aws-msk-iam-sasl-signer-js": "1.0.3", + "@smithy/protocol-http": "5.4.3", + "@smithy/signature-v4": "5.4.3", "fastify": "5.8.5", "fastify-plugin": "5.1.0", + "ioredis": "5.8.2", "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 index f08d36e68b07d2437ce0254bb3b481bcdaa94c53..bbca5efc66d67e7d67e496dea6bd0394bb6c7e90 100644 GIT binary patch literal 22134 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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( From 73da728aaefc3d9cfeda5939eae7648dfa09f6c0 Mon Sep 17 00:00:00 2001 From: Michelle Song <268042956+mish-elle@users.noreply.github.com> Date: Thu, 4 Jun 2026 17:58:18 -0400 Subject: [PATCH 03/12] 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 | 49 +- 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 | 56 +- 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 | 49 +- packages/services/workflows/src/redis.ts | 76 -- 36 files changed, 1460 insertions(+), 967 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 405f006c384..3b53c53a737 100644 --- a/package.json +++ b/package.json @@ -165,6 +165,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.6.4", "@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 33a933a826b..f5ed2a41df4 100644 --- a/packages/services/api/package.json +++ b/packages/services/api/package.json @@ -66,7 +66,6 @@ "graphql-parse-resolve-info": "4.13.0", "graphql-scalars": "1.24.2", "graphql-yoga": "5.13.3", - "ioredis": "5.8.2", "ioredis-mock": "8.9.0", "jsonwebtoken": "9.0.3", "lodash": "4.18.1", diff --git a/packages/services/api/src/create.ts b/packages/services/api/src/create.ts index d5a812aac7d..8546fa9c023 100644 --- a/packages/services/api/src/create.ts +++ b/packages/services/api/src/create.ts @@ -1,5 +1,4 @@ import { CONTEXT, createApplication, Provider, Scope } from 'graphql-modules'; -import { Redis } from 'ioredis'; import { PostgresDatabasePool } from '@hive/postgres'; import { TaskScheduler } from '@hive/workflows/kit'; import { adminModule } from './modules/admin'; @@ -62,7 +61,7 @@ import { Logger } from './modules/shared/providers/logger'; import { Mutex } from './modules/shared/providers/mutex'; import { PrometheusConfig } from './modules/shared/providers/prometheus-config'; import { HivePubSub, PUB_SUB_CONFIG } from './modules/shared/providers/pub-sub'; -import { REDIS_INSTANCE } from './modules/shared/providers/redis'; +import { REDIS_INSTANCE, type Redis } from './modules/shared/providers/redis'; import { RedisRateLimiter } from './modules/shared/providers/redis-rate-limiter'; import { S3_CONFIG, type S3Config } from './modules/shared/providers/s3-config'; import { Storage } from './modules/shared/providers/storage'; diff --git a/packages/services/api/src/modules/auth/providers/oauth-cache.ts b/packages/services/api/src/modules/auth/providers/oauth-cache.ts index e1bb2aa0c92..f9f4e63603b 100644 --- a/packages/services/api/src/modules/auth/providers/oauth-cache.ts +++ b/packages/services/api/src/modules/auth/providers/oauth-cache.ts @@ -1,8 +1,7 @@ import { Inject, Injectable, Scope } from 'graphql-modules'; -import { type Redis } from 'ioredis'; import z from 'zod'; import { Logger } from '../../shared/providers/logger'; -import { REDIS_INSTANCE } from '../../shared/providers/redis'; +import { REDIS_INSTANCE, type Redis } from '../../shared/providers/redis'; import { sha256 } from '../lib/supertokens-at-home/crypto'; @Injectable({ diff --git a/packages/services/api/src/modules/organization/providers/organization-access-tokens-cache.ts b/packages/services/api/src/modules/organization/providers/organization-access-tokens-cache.ts index ecb2f27b312..e7de541e0ed 100644 --- a/packages/services/api/src/modules/organization/providers/organization-access-tokens-cache.ts +++ b/packages/services/api/src/modules/organization/providers/organization-access-tokens-cache.ts @@ -2,13 +2,12 @@ import { BentoCache, bentostore } from 'bentocache'; import { memoryDriver } from 'bentocache/build/src/drivers/memory'; import { redisDriver } from 'bentocache/build/src/drivers/redis'; import { Inject, Injectable, Scope } from 'graphql-modules'; -import type Redis from 'ioredis'; import { prometheusPlugin } from '@bentocache/plugin-prometheus'; import { PostgresDatabasePool } from '@hive/postgres'; import { AuthorizationPolicyStatement } from '../../auth/lib/authz'; import { Logger } from '../../shared/providers/logger'; import { PrometheusConfig } from '../../shared/providers/prometheus-config'; -import { REDIS_INSTANCE } from '../../shared/providers/redis'; +import { REDIS_INSTANCE, type Redis } from '../../shared/providers/redis'; import { findById, OrganizationAccessTokens, diff --git a/packages/services/api/src/modules/shared/__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 5cd420ce6d7..b472250ff5c 100644 --- a/packages/services/schema/package.json +++ b/packages/services/schema/package.json @@ -26,7 +26,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 08b2d62aa82..4717ad3feab 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 7ba4b3cb977..b4c3db9a4c3 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,48 +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 = await createRedisClient(env.redis, { + logger: server.log.child({ source: 'Redis' }), + iamTokenRefreshLogger: server.log.child({ source: 'RedisIamTokenRefresh' }), + }); - const redis = createRedisClient('Redis', redisConfig, server.log.child({ source: 'Redis' })); + const redisSubscriber = await createRedisClient(env.redis, { + logger: server.log.child({ source: 'RedisSubscribe' }), + iamTokenRefreshLogger: server.log.child({ source: 'RedisSubscribeIamTokenRefresh' }), + }); const pubSub = createHivePubSub({ publisher: redis, - subscriber: createRedisClient( - 'subscriber', - redisConfig, - server.log.child({ source: 'RedisSubscribe' }), - ), + subscriber: redisSubscriber, }); - 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/src/index.ts b/packages/services/service-common/src/index.ts index 16567da4268..4ddc412217a 100644 --- a/packages/services/service-common/src/index.ts +++ b/packages/services/service-common/src/index.ts @@ -25,3 +25,15 @@ export { startIamTokenRefresh, type IamRedisConfig, } from './iam-redis'; +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 d18449cb76f..7dc6824215b 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; @@ -83,19 +87,16 @@ const PostgresModel = zod.object({ POSTGRES_DB: zod.string(), POSTGRES_USER: zod.string(), POSTGRES_PASSWORD: emptyString(zod.string().optional()), -}); + let redisConfigResult = null; -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( + 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); zod.union([zod.literal('0'), zod.literal('1')]).optional(), ), REDIS_AWS_IAM_CACHE_NAME: emptyString(zod.string().optional()), @@ -158,6 +159,7 @@ if (configs.kafka.success && configs.kafka.data.KAFKA_AWS_IAM_AUTH_ENABLED === ' } } +<<<<<<< HEAD if (configs.redis.success && configs.redis.data.REDIS_AWS_IAM_AUTH_ENABLED === '1') { const missingRedisIamVars: string[] = []; if (configs.redis.data.REDIS_TLS_ENABLED !== '1') @@ -170,6 +172,18 @@ if (configs.redis.success && configs.redis.data.REDIS_AWS_IAM_AUTH_ENABLED === ' 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); +>>>>>>> 580adcc64 (feat: centralize Redis IAM auth and client creation into service-common) } } @@ -190,7 +204,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 +282,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 2a8939ea47d..19b847f8c29 100644 --- a/packages/services/workflows/package.json +++ b/packages/services/workflows/package.json @@ -29,7 +29,6 @@ "graphile-worker": "0.16.6", "graphql": "16.9.0", "graphql-yoga": "5.13.3", - "ioredis": "5.8.2", "mjml": "4.14.0", "nodemailer": "9.0.1", "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 9104703374e..e8da8b54611 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,52 +112,25 @@ 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 = await createRedisClient(env.redis, { + logger: server.log.child({ source: 'Redis' }), + iamTokenRefreshLogger: server.log.child({ source: 'RedisIamTokenRefresh' }), +}); -const redis = createRedisClient('Redis', redisConfig, server.log.child({ source: 'Redis' })); +const redisSubscriber = await createRedisClient(env.redis, { + logger: server.log.child({ source: 'RedisSubscribe' }), + iamTokenRefreshLogger: server.log.child({ source: 'RedisSubscribeIamTokenRefresh' }), +}); const pubSub = createHivePubSub({ publisher: redis, - subscriber: createRedisClient( - 'subscriber', - redisConfig, - server.log.child({ source: 'RedisSubscribe' }), - ), + subscriber: redisSubscriber, }); const clickhouse = env.clickhouse ? new ClickHouseClient(env.clickhouse, logger.child({ source: 'ClickHouse' })) : null; -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 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 5140d847d62d8447b1b7e2cb5312d9e4df9218bd 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 04/12] - 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 - 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 | 2 - 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 | 42 ++---- packages/services/usage/src/index.ts | 1 - .../services/workflows/src/environment.ts | 33 +---- packages/services/workflows/src/index.ts | 2 - 23 files changed, 268 insertions(+), 406 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/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 4717ad3feab..7277bcc0ebd 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 b4c3db9a4c3..6433d36846b 100644 --- a/packages/services/server/src/index.ts +++ b/packages/services/server/src/index.ts @@ -173,12 +173,10 @@ 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({ diff --git a/packages/services/service-common/package.json b/packages/services/service-common/package.json index a51d03b24df..b431570b67e 100644 --- a/packages/services/service-common/package.json +++ b/packages/services/service-common/package.json @@ -38,7 +38,7 @@ "@smithy/signature-v4": "5.4.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 zcmeI4TT>j#5rzBN5&jSJWJdOagk-O6*;-4h)mjQ$yAFZZesdrJ5-V=YpbJ*$uiHLf zY*D3j1Vy~OixdDRaRb3p317`zyH~4cAEWWuQ_NAn^E&Y@3m%A#~V8HNXIXl zSIrkX`&?%>!*v_k`E{M2=&FNeD_q018_j#QxuLU%;ohz3=UZJfYCh_{sq6RD^Qc*9 zI7@#M{atAO(EPmlS$LN%kM-+IUAL?Efh6Oezv+lOxAY6=*PB(f`&vi4x*A!=%?I z#z-4At&Tfn=6ZCsG#&GX9+d;1nntwDa`o7Y9)BHuB>&=n$yC#{oWQ&phMz5S4TPpq#P84|zZ~f5wwH_(KcPon?yuhkeVgBEy$u@WaIy+Go_WSzqU>$L7#_`?MNE2u^$Gl?a2y@ zI)A9wenkf{Qd|RR#JJ`kPw&Trev%hj47a_0Q@`zM#MptjBPx+?`E)jtMU8s~X)}?&xe=5jMjnKG<1t!h z$aar4&OuwoD8Lf*wjchs?U~~gtcKhKuYn!y>u7Y0xp(Cw5Tl#u z_&|&d8*ge9uqJ-_r{eR0^zd(ew$b69u6(XGmYdtnouJ)PLeC)O`7~paLxylyW67{b z;udBBJ5N~=p0y+og4ygya*J>Hg5lUKX;LpM`xqi+jVWO>(=>SbLb?VM7WBC-8Szxk z+Pq?;s!v)Vc3Im$^Qg*(eDA zGaPgToXA2u8r>-P{ZX(oQShnaS;Q_MYw3w#*F?VVGuV5w>Onx*e$jl5EuSj3oDU|P z#=qJz%$asX*Z7g;Oq)G`+v!X&=0H3;=I6fL>ACKLrLHSNY@XOP7mIWB$fiJh>J8-m zc3!ad*7Zxo{GseAj?Z#MeV*7ao8Q+MsegckC(YMO?8REnx-JWoi^yjf|6W+`a+i6# z)Z-8q(oefXxKhfwhDh=$^PxrMA8KQ7G*9VbRxwV_!i~Tu*c?%LR%TR>H)lzY75~gy zU`x~iA0Feuy6TS>XQ9t(CN87Lwjf&z~P^q3LhoMqh zTV&mowO2S1tJg2XY5_cks#1(4V(-bc7HhEvFDNs4ekOkNh^|!+uq*QpSe0>ZcdAY{I z3dTFh^5|Gk)O}Z_F<+B{tL4Z?8_c!cKCXYq+LG@LndA23gQsOIsc(*})%_u|cFpb3 zLoEvrt0U4%o9Fajr{Mm6v&F9YI4$(yJX}XKw(~H^k;jd#FRV&3qVio>W$5Q^UF+wm z_jx|g`y`uttj5_H*MJyt|BfRBd4ONQj69ZiC39EY!W7<4BcC-r>Vm^#`J`O{zP?_= zj??cy!h-YJgVbIDrfptm-fTXS&TFXiP}*hH6l_G(tVwR`llgtO)?IG&Pp)ZOk-ZU) zRW6Nx*8P^Ven;wG+;xp^*XU;6(+}M)(yJpULc;hh9^uV3oq6EF$dWUy+OCsZ$7qt< z#j)iyJCRIh*oalA**dcknI&1@=V%^#m(aZFnasB>?XXvyTx+|Ajq}g)7xdv-+bgE9 zO?Gr5<))5pPp4<@cAqd*tV+gBbNxiE?CZsNkrLK=wi@b?C^u!8I%UPV(F<*#}nJV}+id=UGpy;uQq?x6^nZ3p2N;??Z^*H^v>fx_j&R*5o$AC ztBcIqQ4k}n>?K{>GY-Iv8Aiqq?VL}4t^DYS@<0;XF79!YkHvFEH_Ft<0vgO3gZG?c z+0_`&?PF{%$MYg&Y-U$aWRaAKu=mz8ZmILx_(}G}w)Q*|oQPTpeNe%qa+TvG^%rVa z%;cO8!=;>6=k|oL9qA3DzQ)t5ji<|s{TgE;_Rr3)YKUq*)-m*Td{NzbMyr+|I8K7TMe9Ge?S^)Q8QM2y}VkefYe~XG4*hb0&WCv636p z|5?J03OKp%Ly7S@tUeA4u_tk@UP5)-Gds^$zYle^cm-(9ZWsH{1?;{dliSJ&bwqpS zb60g3dq$YQZKm`2^8aCbW>z$>UJ3TF8J#`L-FKhN)kP9wq~@K}b?J)b#co+wXPvEraQ;=6t@DO^XMf$o3k9&123Yd+ZWaoF-2r`*=bn* zcHP$_%d7dUs9R35teSgnqX8O!4J>z1Msi4qRrh&o?v8cQrR6$yH;q zc<+$hQ}=ROIll~0V)Z-_ZgpB!&g<+R%Y4qGmu?&_$8;W;lg^4GUob|Ez5Krn#u|5u zBoTFB$NUGe?avTjN;}~h@qZE4fAu=gGx<=RA+u)+JRj?`w$GCp3Tsn5MQFPi;yzpZ z-($v_Pwu~tgR%e1QH>)XUyfZ6*mnpXa(#@gd2#Xc!&2n+*F1W;XwDng5PcMUo-bCP 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 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 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 4ddc412217a..800339a7180 100644 --- a/packages/services/service-common/src/index.ts +++ b/packages/services/service-common/src/index.ts @@ -27,10 +27,11 @@ export { } from './iam-redis'; 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 7dc6824215b..865d58ab10e 100644 --- a/packages/services/usage/src/environment.ts +++ b/packages/services/usage/src/environment.ts @@ -5,33 +5,6 @@ import { parseRedisConfigFromEnvironment, resolveServerListenOptions, } from '@hive/service-common'; - -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); -}; - -function raiseInvariant(reason: string): never { - throw new Error(reason); -} - -const EnvironmentModel = zod.object({ - PORT: emptyString(NumberFromString.optional()), - SERVER_HOST: emptyString(zod.string().optional()), - SERVER_HOST_IPV6_ONLY: emptyString(zod.union([zod.literal('1'), zod.literal('0')]).optional()), - TOKENS_ENDPOINT: zod.string().url(), COMMERCE_ENDPOINT: emptyString(zod.string().url().optional()), RATE_LIMIT_TTL: emptyString(NumberFromString.optional()).default(30_000), ENVIRONMENT: emptyString(zod.string().optional()), @@ -39,7 +12,6 @@ const EnvironmentModel = zod.object({ AWS_REGION: emptyString(zod.string().optional()), }); -const SentryModel = zod.union([ zod.object({ SENTRY: emptyString(zod.literal('0').optional()), }), @@ -89,6 +61,7 @@ const PostgresModel = zod.object({ POSTGRES_PASSWORD: emptyString(zod.string().optional()), let redisConfigResult = null; +<<<<<<< HEAD if (configs.redis.success) { redisConfigResult = parseRedisConfigFromEnvironment({ redis: configs.redis.data, @@ -102,6 +75,8 @@ const PostgresModel = zod.object({ REDIS_AWS_IAM_CACHE_NAME: emptyString(zod.string().optional()), }); +======= +>>>>>>> 6a59c87d5 (- Consolidate Redis configuration to service-common module) const PrometheusModel = zod.object({ PROMETHEUS_METRICS: emptyString(zod.union([zod.literal('0'), zod.literal('1')]).optional()), PROMETHEUS_METRICS_LABEL_INSTANCE: emptyString(zod.string().optional()), @@ -132,7 +107,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), @@ -159,6 +133,7 @@ if (configs.kafka.success && configs.kafka.data.KAFKA_AWS_IAM_AUTH_ENABLED === ' } } +<<<<<<< HEAD <<<<<<< HEAD if (configs.redis.success && configs.redis.data.REDIS_AWS_IAM_AUTH_ENABLED === '1') { const missingRedisIamVars: string[] = []; @@ -185,6 +160,15 @@ if (configs.redis.success) { environmentErrors.push(...redisConfigResult.errors); >>>>>>> 580adcc64 (feat: centralize Redis IAM auth and client creation into service-common) } +======= +const redisConfigResult = parseRedisConfigFromEnvironment( + process.env, + configs.base.success ? configs.base.data.AWS_REGION : undefined, +); + +if (redisConfigResult.type === 'error') { + environmentErrors.push(...redisConfigResult.errors); +>>>>>>> 6a59c87d5 (- Consolidate Redis configuration to service-common module) } 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 e8da8b54611..66140317e48 100644 --- a/packages/services/workflows/src/index.ts +++ b/packages/services/workflows/src/index.ts @@ -114,12 +114,10 @@ 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({ From 1e086c6aa19dae8762850d66bb56e81ff45b1bdf Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Tue, 9 Jun 2026 09:50:03 +0200 Subject: [PATCH 05/12] lockfile and one version of redis --- packages/libraries/pubsub/package.json | 2 +- packages/libraries/pubsub/src/index.ts | 2 +- 2 files changed, 2 insertions(+), 2 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'; From 9d0f4430ff8811668ff80e1713292c013d8ad3d3 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 06/12] 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 b431570b67e..348f058d71f 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 feb0e73274f7d6d1aaf1941fc2f0231ef3956890 Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Wed, 10 Jun 2026 09:46:07 +0200 Subject: [PATCH 07/12] sync lockfile --- pnpm-lock.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ddc30d3eb7c..dd95e3b6030 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1766,6 +1766,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 From 53a686e0e336e7b9b53dbb0f24fcc1494abdbbd2 Mon Sep 17 00:00:00 2001 From: mish-elle <268042956+mish-elle@users.noreply.github.com> Date: Mon, 15 Jun 2026 17:09:10 -0400 Subject: [PATCH 08/12] fix: address PR comments - retry failures, abort controller, and password-before-AUTH ordering --- .../service-common/src/iam-aws.spec.ts | 56 ++++++-- .../services/service-common/src/iam-aws.ts | 35 +++-- .../service-common/src/iam-redis.spec.ts | 135 ++++++++++++++++-- .../services/service-common/src/iam-redis.ts | 26 ++-- .../service-common/src/redis-client.spec.ts | 2 +- .../service-common/src/redis-client.ts | 3 +- packages/services/workflows/src/index.ts | 2 +- 7 files changed, 209 insertions(+), 50 deletions(-) diff --git a/packages/services/service-common/src/iam-aws.spec.ts b/packages/services/service-common/src/iam-aws.spec.ts index bf56ab7d13c..28c9edf1521 100644 --- a/packages/services/service-common/src/iam-aws.spec.ts +++ b/packages/services/service-common/src/iam-aws.spec.ts @@ -178,8 +178,20 @@ describe('generatePresignedToken', () => { describe('startTokenRefreshTimer', () => { beforeEach(() => { vi.useFakeTimers(); + vi.resetModules(); // Deterministic jitter: Math.random() → 0 vi.spyOn(Math, 'random').mockReturnValue(0); + // Mock node:timers/promises so sleep uses the faked global setTimeout + vi.doMock('node:timers/promises', () => ({ + setTimeout: (ms: number, _value?: unknown, options?: { signal?: AbortSignal }) => + new Promise((resolve, reject) => { + const id = globalThis.setTimeout(resolve, ms); + options?.signal?.addEventListener('abort', () => { + globalThis.clearTimeout(id); + reject(new DOMException('The operation was aborted', 'AbortError')); + }); + }), + })); }); afterEach(() => { @@ -188,27 +200,18 @@ describe('startTokenRefreshTimer', () => { }); describe('scheduling', () => { - it('calls unref on each setTimeout handle when supported', async () => { + it('does not call unref — abort controller handles shutdown instead', async () => { const { startTokenRefreshTimer } = await import('./iam-aws'); const refreshFn = vi.fn().mockResolvedValue(undefined); - const unref = vi.fn(); - const fakeTimer = { unref } as unknown as ReturnType; - const setTimeoutSpy = vi - .spyOn(globalThis, 'setTimeout') - .mockImplementation((() => fakeTimer) as any); - const cleanup = startTokenRefreshTimer(refreshFn, { backoffRefreshSeconds: 60, jitterMs: 0, }); - expect(setTimeoutSpy).toHaveBeenCalled(); - expect(unref).toHaveBeenCalled(); expect(typeof cleanup).toBe('function'); cleanup(); - setTimeoutSpy.mockRestore(); }); it('refreshes after (TOKEN_TTL - backoff) seconds, then schedules next', async () => { @@ -322,8 +325,9 @@ describe('startTokenRefreshTimer', () => { // Trigger the interval await vi.advanceTimersByTimeAsync(840_000); - // Allow retry backoff timers to fire - await vi.advanceTimersByTimeAsync(1000); + // Allow retry backoff timers to fire (attempt 1: 100ms, attempt 2: 200ms) + await vi.advanceTimersByTimeAsync(100); + await vi.advanceTimersByTimeAsync(200); expect(refreshFn).toHaveBeenCalledTimes(3); expect(refreshFn).toHaveBeenCalledWith(1, 3); @@ -333,6 +337,31 @@ describe('startTokenRefreshTimer', () => { cleanup(); }); + it('schedules the next cycle after all retries are exhausted', async () => { + const { startTokenRefreshTimer } = await import('./iam-aws'); + const refreshFn = vi.fn().mockRejectedValue(new Error('always fails')); + + const cleanup = startTokenRefreshTimer(refreshFn, { + backoffRefreshSeconds: 60, + jitterMs: 0, + maxRetries: 2, + retryBackoffMs: 100, + }); + + // First cycle: trigger + exhaust retries + await vi.advanceTimersByTimeAsync(840_000); + await vi.advanceTimersByTimeAsync(100); // retry backoff for attempt 1 + expect(refreshFn).toHaveBeenCalledTimes(2); + + // Second cycle: timer should fire again after intervalMs + refreshFn.mockClear(); + await vi.advanceTimersByTimeAsync(840_000); + await vi.advanceTimersByTimeAsync(100); + expect(refreshFn).toHaveBeenCalledTimes(2); + + cleanup(); + }); + it('does not retry when refreshFn succeeds on the first attempt', async () => { const { startTokenRefreshTimer } = await import('./iam-aws'); const refreshFn = vi.fn().mockResolvedValue(undefined); @@ -366,7 +395,8 @@ describe('startTokenRefreshTimer', () => { }); await vi.advanceTimersByTimeAsync(840_000); - await vi.advanceTimersByTimeAsync(1000); + // Allow first retry backoff (attempt 1: 100ms) + await vi.advanceTimersByTimeAsync(100); expect(refreshFn).toHaveBeenCalledTimes(2); expect(refreshFn).toHaveBeenCalledWith(1, 3); diff --git a/packages/services/service-common/src/iam-aws.ts b/packages/services/service-common/src/iam-aws.ts index c03b0d1aaca..ce4f2cbdaf0 100644 --- a/packages/services/service-common/src/iam-aws.ts +++ b/packages/services/service-common/src/iam-aws.ts @@ -1,3 +1,4 @@ +import { setTimeout as sleep } from 'node:timers/promises'; import { Sha256 } from '@aws-crypto/sha256-js'; import { fromNodeProviderChain } from '@aws-sdk/credential-providers'; import { formatUrl } from '@aws-sdk/util-format-url'; @@ -114,45 +115,53 @@ export function startTokenRefreshTimer( const retryBackoffMs = options?.retryBackoffMs ?? 5_000; // Track shutdown state to prevent in-flight callbacks and retries during graceful shutdown - let isShuttingDown = false; + const abortController = new AbortController(); let currentTimer: ReturnType | undefined; function scheduleNext() { - if (isShuttingDown) return; - + if (abortController.signal.aborted) return; const jitterMs = Math.floor(Math.random() * maxJitterMs); + currentTimer = setTimeout(() => { - if (isShuttingDown) return; + if (abortController.signal.aborted) return; void (async () => { for (let attempt = 1; attempt <= maxRetries; attempt++) { - if (isShuttingDown) return; + if (abortController.signal.aborted) return; try { await refreshFn(attempt, maxRetries); break; } catch { - if (attempt < maxRetries) { - await new Promise(r => setTimeout(r, attempt * retryBackoffMs)); + if (attempt >= maxRetries) { + // Out of retries, but we will retry next cycle as long as the timer is running + scheduleNext(); + return; + } + } + + try { + await sleep(attempt * retryBackoffMs, undefined, { + signal: abortController.signal, + }); + } catch (err) { + if (abortController.signal.aborted) { + return; } + throw err; } } // Schedule next cycle only after current refresh (including retries) completes scheduleNext(); })(); }, intervalMs + jitterMs); - - // Allow clean process shutdown even if the refresh timer is still active. - if (typeof currentTimer.unref === 'function') { - currentTimer.unref(); - } } scheduleNext(); // Return a cleanup function that sets shutdown flag and clears the pending timer return () => { - isShuttingDown = true; + abortController.abort(); if (currentTimer !== undefined) { clearTimeout(currentTimer); } diff --git a/packages/services/service-common/src/iam-redis.spec.ts b/packages/services/service-common/src/iam-redis.spec.ts index abd0d234a1a..36a435fc8e4 100644 --- a/packages/services/service-common/src/iam-redis.spec.ts +++ b/packages/services/service-common/src/iam-redis.spec.ts @@ -19,6 +19,18 @@ function createMockLogger() { const mockGeneratePresignedToken = vi.fn(); +// Mock node:timers/promises so sleep uses the faked global setTimeout +vi.mock('node:timers/promises', () => ({ + setTimeout: (ms: number, _value?: unknown, options?: { signal?: AbortSignal }) => + new Promise((resolve, reject) => { + const id = globalThis.setTimeout(resolve, ms); + options?.signal?.addEventListener('abort', () => { + globalThis.clearTimeout(id); + reject(new DOMException('The operation was aborted', 'AbortError')); + }); + }), +})); + vi.mock('./iam-aws', async importOriginal => { const actual = await importOriginal(); return { @@ -162,9 +174,37 @@ describe('refreshIamAuth', () => { 'WRONGPASS', ); - // Password is updated before AUTH is called (same as cluster mode). + // Password IS updated before AUTH so ioredis uses the fresh token on reconnect expect(mockRedis.options.password).toBe('bad-token'); }); + + it('updates password before AUTH so reconnects use fresh token', async () => { + const { refreshIamAuth } = await import('./iam-redis'); + const logger = createMockLogger(); + + const callOrder: string[] = []; + const mockRedis = { + call: vi.fn().mockImplementation(() => { + callOrder.push('AUTH'); + return Promise.resolve('OK'); + }), + options: { + set password(val: string) { + callOrder.push('password'); + this._password = val; + }, + get password() { + return this._password; + }, + _password: 'old-token', + }, + } as any; + + await refreshIamAuth(mockRedis, 'new-token', 'default', logger); + + expect(callOrder).toEqual(['password', 'AUTH']); + expect(mockRedis.options.password).toBe('new-token'); + }); }); describe('cluster mode', () => { @@ -204,14 +244,17 @@ describe('refreshIamAuth', () => { expect(mockCluster.options.redisOptions.password).toBe('refreshed-token'); }); - it('continues re-AUTHing remaining nodes when one node fails', async () => { + it('continues re-AUTHing remaining nodes when one node fails, then throws', async () => { const { refreshIamAuth } = await import('./iam-redis'); const logger = createMockLogger(); - const healthyNode = { call: vi.fn().mockResolvedValue('OK'), options: { password: 'old' } }; + const healthyNode = { + call: vi.fn().mockResolvedValue('OK'), + options: { host: 'node-1', password: 'old' }, + }; const failingNode = { call: vi.fn().mockRejectedValue(new Error('NOAUTH')), - options: { password: 'old' }, + options: { host: 'node-2', password: 'old' }, }; const mockCluster = Object.assign(Object.create(RedisCluster.prototype), { @@ -219,20 +262,83 @@ describe('refreshIamAuth', () => { options: { redisOptions: { password: 'old' } }, }); - // Should not throw — individual node failures are caught - await refreshIamAuth(mockCluster, 'token', 'default', logger); + // Should throw after attempting all nodes + await expect(refreshIamAuth(mockCluster, 'token', 'default', logger)).rejects.toThrow( + 'Failed to re-AUTH 1/2 cluster node(s)', + ); + // Both nodes get password updated before AUTH (so reconnects use fresh token) expect(healthyNode.options.password).toBe('token'); - // Password is set before AUTH, so even failing nodes get the updated password expect(failingNode.options.password).toBe('token'); expect(logger.warn).toHaveBeenCalledWith( expect.stringContaining('Failed to re-AUTH cluster node'), expect.any(Error), ); - // Still reports overall success - expect(logger.debug).toHaveBeenCalledWith( - expect.stringContaining('IAM token refreshed successfully'), + }); + + it('updates node passwords before AUTH so reconnects use fresh token', async () => { + const { refreshIamAuth } = await import('./iam-redis'); + const logger = createMockLogger(); + + const callOrder: string[] = []; + const createNode = (name: string) => ({ + call: vi.fn().mockImplementation(() => { + callOrder.push(`${name}:AUTH`); + return Promise.resolve('OK'); + }), + options: { + set password(val: string) { + callOrder.push(`${name}:password`); + this._password = val; + }, + get password() { + return this._password; + }, + _password: 'old', + }, + }); + + const node1 = createNode('node1'); + const node2 = createNode('node2'); + + const mockCluster = Object.assign(Object.create(RedisCluster.prototype), { + nodes: vi.fn().mockReturnValue([node1, node2]), + options: { redisOptions: { password: 'old' } }, + }); + + await refreshIamAuth(mockCluster, 'new-token', 'user', logger); + + // Each node's password is set before its AUTH call + expect(callOrder).toEqual(['node1:password', 'node1:AUTH', 'node2:password', 'node2:AUTH']); + expect(node1.options.password).toBe('new-token'); + expect(node2.options.password).toBe('new-token'); + }); + + it('throws when all cluster nodes fail', async () => { + const { refreshIamAuth } = await import('./iam-redis'); + const logger = createMockLogger(); + + const node1 = { + call: vi.fn().mockRejectedValue(new Error('NOAUTH')), + options: { host: 'node-1', password: 'old' }, + }; + const node2 = { + call: vi.fn().mockRejectedValue(new Error('NOAUTH')), + options: { host: 'node-2', password: 'old' }, + }; + + const mockCluster = Object.assign(Object.create(RedisCluster.prototype), { + nodes: vi.fn().mockReturnValue([node1, node2]), + options: { redisOptions: { password: 'old' } }, + }); + + await expect(refreshIamAuth(mockCluster, 'token', 'default', logger)).rejects.toThrow( + 'Failed to re-AUTH 2/2 cluster node(s)', ); + + // Both nodes get password updated before AUTH (so reconnects use fresh token) + expect(node1.options.password).toBe('token'); + expect(node2.options.password).toBe('token'); }); it('handles missing options.redisOptions gracefully', async () => { @@ -346,8 +452,9 @@ describe('startIamTokenRefresh', () => { ); await vi.advanceTimersByTimeAsync(720_000); - // Allow retries to fire - await vi.advanceTimersByTimeAsync(60_000); + // Allow retries to fire (attempt 1: 5s, attempt 2: 10s) + await vi.advanceTimersByTimeAsync(5_000); + await vi.advanceTimersByTimeAsync(10_000); expect(logger.error).toHaveBeenCalledWith( expect.stringContaining('ElastiCache IAM token refresh failed'), @@ -379,7 +486,9 @@ describe('startIamTokenRefresh', () => { ); await vi.advanceTimersByTimeAsync(720_000); - await vi.advanceTimersByTimeAsync(60_000); + // Advance retry backoff timers individually (attempt 1: 5s, attempt 2: 10s) + await vi.advanceTimersByTimeAsync(5_000); + await vi.advanceTimersByTimeAsync(10_000); expect(logger.error).toHaveBeenCalledWith(expect.stringContaining('exhausted all retries')); diff --git a/packages/services/service-common/src/iam-redis.ts b/packages/services/service-common/src/iam-redis.ts index 78bc465c103..fb6aeb9a865 100644 --- a/packages/services/service-common/src/iam-redis.ts +++ b/packages/services/service-common/src/iam-redis.ts @@ -80,21 +80,25 @@ export async function generateIamAuthToken( /** * Re-authenticate active Redis connections with a fresh IAM token. * - * In **cluster mode**, it iterates over every node returned by - * `nodes('all')`, issues `AUTH` for each, and updates each - * node's password only after successful authentication. Individual node - * failures are logged as warnings but do not abort the loop. + * **Password is always updated before `AUTH`** so that ioredis uses the fresh + * token on subsequent reconnects — even when `AUTH` fails (e.g. on subscriber + * connections that are in pub/sub mode and cannot accept `AUTH` commands). * - * It also updates `options.redisOptions.password` when present so new - * cluster connections use the refreshed token. + * In **cluster mode**, the cluster-level `options.redisOptions.password` is + * updated first, then each node returned by `nodes('all')` gets its per-node + * password set and an `AUTH` issued. Individual node `AUTH` failures are + * collected and logged as warnings but do not abort the loop, all remaining + * nodes are still attempted. If any nodes failed, the function throws an + * aggregate error summarising how many nodes failed. * - * In **standalone mode**, it issues a single `AUTH` command, then updates - * the client's stored password when the options object is available. + * In **standalone mode**, `options.password` is updated first, then a single + * `AUTH` command is issued. * * @param redis - The active ioredis client (standalone or cluster). * @param token - The fresh SigV4 IAM auth token. * @param username - The ElastiCache IAM username. * @param logger - Logger with `debug` and `warn` methods. + * @throws When one or more cluster nodes fail `AUTH`, or when standalone `AUTH` fails. */ export async function refreshIamAuth( redis: RedisInstance, @@ -108,11 +112,14 @@ export async function refreshIamAuth( 'Refreshing IAM token (service=elasticache) — re-authenticating %s cluster node(s)', nodes.length, ); + + const errors: Array<{ node: string; error: unknown }> = []; for (const node of nodes) { node.options.password = token; try { await node.call('AUTH', username, token); } catch (err) { + errors.push({ node: node.options.host ?? 'unknown', error: err }); logger.warn('Failed to re-AUTH cluster node (service=elasticache, error=%s)', err); } } @@ -120,6 +127,9 @@ export async function refreshIamAuth( if (redis.options?.redisOptions) { redis.options.redisOptions.password = token; } + if (errors.length > 0) { + throw new Error(`Failed to re-AUTH ${errors.length}/${nodes.length} cluster node(s)`); + } } else { redis.options.password = token; await redis.call('AUTH', username, token); diff --git a/packages/services/service-common/src/redis-client.spec.ts b/packages/services/service-common/src/redis-client.spec.ts index 616ac4e1bb7..004dc01581f 100644 --- a/packages/services/service-common/src/redis-client.spec.ts +++ b/packages/services/service-common/src/redis-client.spec.ts @@ -68,7 +68,7 @@ vi.mock('ioredis', async () => { }); const resolveRedisCredentialsMock = vi.fn(); -const startIamTokenRefreshMock = vi.fn(); +const startIamTokenRefreshMock = vi.fn().mockReturnValue(() => {}); vi.mock('./iam-redis', () => { return { diff --git a/packages/services/service-common/src/redis-client.ts b/packages/services/service-common/src/redis-client.ts index 46bce572c1a..c500b23c4b7 100644 --- a/packages/services/service-common/src/redis-client.ts +++ b/packages/services/service-common/src/redis-client.ts @@ -94,7 +94,8 @@ export async function createRedisClient( // Start IAM token refresh if enabled // Each client gets its own independent token lifecycle if (redisIamConfig) { - startIamTokenRefresh(redis, redisIamConfig, options.logger); + const stopTokenRefresh = startIamTokenRefresh(redis, redisIamConfig, options.logger); + redis.on('end', stopTokenRefresh); } return redis; diff --git a/packages/services/workflows/src/index.ts b/packages/services/workflows/src/index.ts index 66140317e48..5fa39922955 100644 --- a/packages/services/workflows/src/index.ts +++ b/packages/services/workflows/src/index.ts @@ -215,7 +215,7 @@ registerShutdown({ await pg.end(); logger.info('Shutdown postgres connection successful.'); logger.info('Shutdown redis connection.'); - redis.disconnect(false); + await Promise.all([redis.quit(), redisSubscriber.quit()]); if (shutdownMetrics) { logger.info('Stopping prometheus endpoint'); await shutdownMetrics(); From d4a714ecd2e4fb2eec782b8f237503e84155ef76 Mon Sep 17 00:00:00 2001 From: mish-elle <268042956+mish-elle@users.noreply.github.com> Date: Wed, 17 Jun 2026 10:56:19 -0400 Subject: [PATCH 09/12] fix(iam-redis): update ClusterSubscriber password during IAM token refresh --- .../service-common/src/iam-redis.spec.ts | 144 ++++++++++++++++++ .../services/service-common/src/iam-redis.ts | 53 ++++++- 2 files changed, 192 insertions(+), 5 deletions(-) diff --git a/packages/services/service-common/src/iam-redis.spec.ts b/packages/services/service-common/src/iam-redis.spec.ts index 36a435fc8e4..27f057bbe5d 100644 --- a/packages/services/service-common/src/iam-redis.spec.ts +++ b/packages/services/service-common/src/iam-redis.spec.ts @@ -369,6 +369,150 @@ describe('refreshIamAuth', () => { 0, ); }); + + describe('ClusterSubscriber password update', () => { + it('updates ClusterSubscriber internal Redis password alongside pool nodes', async () => { + const { refreshIamAuth } = await import('./iam-redis'); + const logger = createMockLogger(); + + const node1 = { call: vi.fn().mockResolvedValue('OK'), options: { password: 'old' } }; + const subscriberRedis = { options: { password: 'stale-startup-token' } }; + + const mockCluster = Object.assign(Object.create(RedisCluster.prototype), { + nodes: vi.fn().mockReturnValue([node1]), + options: { redisOptions: { password: 'old' } }, + subscriber: { + getInstance: vi.fn().mockReturnValue(subscriberRedis), + }, + }); + + await refreshIamAuth(mockCluster, 'fresh-token', 'myuser', logger); + + // Pool node password and AUTH updated + expect(node1.options.password).toBe('fresh-token'); + expect(node1.call).toHaveBeenCalledWith('AUTH', 'myuser', 'fresh-token'); + + // Internal subscriber password also updated + expect(subscriberRedis.options.password).toBe('fresh-token'); + expect(logger.debug).toHaveBeenCalledWith(expect.stringContaining('ClusterSubscriber')); + }); + + it('updates ClusterSubscriber password even when pool node AUTH fails', async () => { + const { refreshIamAuth } = await import('./iam-redis'); + const logger = createMockLogger(); + + const failingNode = { + call: vi.fn().mockRejectedValue(new Error('NOAUTH')), + options: { host: 'node-1', password: 'old' }, + }; + const subscriberRedis = { options: { password: 'stale-token' } }; + + const mockCluster = Object.assign(Object.create(RedisCluster.prototype), { + nodes: vi.fn().mockReturnValue([failingNode]), + options: { redisOptions: { password: 'old' } }, + subscriber: { + getInstance: vi.fn().mockReturnValue(subscriberRedis), + }, + }); + + await expect(refreshIamAuth(mockCluster, 'new-token', 'user', logger)).rejects.toThrow( + 'Failed to re-AUTH', + ); + + // Subscriber password still updated despite pool node failure + expect(subscriberRedis.options.password).toBe('new-token'); + }); + + it('handles missing ClusterSubscriber gracefully', async () => { + const { refreshIamAuth } = await import('./iam-redis'); + const logger = createMockLogger(); + + const mockCluster = Object.assign(Object.create(RedisCluster.prototype), { + nodes: vi.fn().mockReturnValue([]), + options: { redisOptions: { password: 'old' } }, + // No subscriber property + }); + + await expect(refreshIamAuth(mockCluster, 'token', 'user', logger)).resolves.toBeUndefined(); + }); + + it('handles ClusterSubscriber getInstance returning null', async () => { + const { refreshIamAuth } = await import('./iam-redis'); + const logger = createMockLogger(); + + const mockCluster = Object.assign(Object.create(RedisCluster.prototype), { + nodes: vi.fn().mockReturnValue([]), + options: { redisOptions: { password: 'old' } }, + subscriber: { + getInstance: vi.fn().mockReturnValue(null), + }, + }); + + await expect(refreshIamAuth(mockCluster, 'token', 'user', logger)).resolves.toBeUndefined(); + }); + + it('does not log subscriber update when no subscriber is found', async () => { + const { refreshIamAuth } = await import('./iam-redis'); + const logger = createMockLogger(); + + const mockCluster = Object.assign(Object.create(RedisCluster.prototype), { + nodes: vi.fn().mockReturnValue([]), + options: { redisOptions: { password: 'old' } }, + }); + + await refreshIamAuth(mockCluster, 'token', 'user', logger); + + const subscriberLogs = logger.debug.mock.calls.filter((call: string[]) => + call[0]?.includes?.('ClusterSubscriber'), + ); + expect(subscriberLogs).toHaveLength(0); + }); + }); + }); +}); + +describe('getClusterSubscriberInstance', () => { + it('returns the subscriber Redis instance when present', async () => { + const { getClusterSubscriberInstance } = await import('./iam-redis'); + + const subscriberRedis = { options: { password: 'old-token' } }; + const mockCluster = Object.assign(Object.create(RedisCluster.prototype), { + subscriber: { + getInstance: vi.fn().mockReturnValue(subscriberRedis), + }, + }); + + expect(getClusterSubscriberInstance(mockCluster)).toBe(subscriberRedis); + }); + + it('returns null when subscriber property is missing', async () => { + const { getClusterSubscriberInstance } = await import('./iam-redis'); + + const mockCluster = Object.assign(Object.create(RedisCluster.prototype), {}); + + expect(getClusterSubscriberInstance(mockCluster)).toBeNull(); + }); + + it('returns null when getInstance returns null', async () => { + const { getClusterSubscriberInstance } = await import('./iam-redis'); + + const mockCluster = Object.assign(Object.create(RedisCluster.prototype), { + subscriber: { + getInstance: vi.fn().mockReturnValue(null), + }, + }); + + expect(getClusterSubscriberInstance(mockCluster)).toBeNull(); + }); + + it('returns null when subscriber has no getInstance method', async () => { + const { getClusterSubscriberInstance } = await import('./iam-redis'); + + const mockCluster = Object.assign(Object.create(RedisCluster.prototype), { + subscriber: { someOtherProp: true }, + }); + + expect(getClusterSubscriberInstance(mockCluster)).toBeNull(); }); }); diff --git a/packages/services/service-common/src/iam-redis.ts b/packages/services/service-common/src/iam-redis.ts index fb6aeb9a865..2d797010d9b 100644 --- a/packages/services/service-common/src/iam-redis.ts +++ b/packages/services/service-common/src/iam-redis.ts @@ -5,6 +5,16 @@ import { generatePresignedToken, startTokenRefreshTimer } from './iam-aws'; type RedisInstance = RedisStandalone | RedisCluster; +interface InternalRedisConnection { + options: { password?: string }; +} + +interface ClusterWithInternalSubscriber { + subscriber?: { + getInstance(): InternalRedisConnection | null; + }; +} + /** * ElastiCache Redis IAM authentication. * @@ -12,6 +22,37 @@ type RedisInstance = RedisStandalone | RedisCluster; * Handles ElastiCache token generation and in-place AUTH for standalone and * cluster connections. */ + +/** + * Extract the internal ClusterSubscriber's Redis instance from a Cluster. + * + * ioredis Cluster maintains a dedicated internal Redis connection for pub/sub + * (the {@link https://github.com/redis/ioredis/blob/main/lib/cluster/ClusterSubscriber.ts | ClusterSubscriber}) + * that is **not** included in `nodes('all')`. When using IAM auth, the + * subscriber copies the password by value at creation time, so it becomes + * stale unless explicitly updated during token rotation. + * + * This function safely accesses the subscriber via runtime duck-typing so + * that a change in ioredis internals degrades gracefully (returns `null`) + * rather than crashing. + * + * @param cluster - An ioredis Cluster instance. + * @returns The subscriber's internal Redis connection, or `null` if the + * subscriber is not active or the internal structure doesn't match. + */ +export function getClusterSubscriberInstance( + cluster: RedisCluster, +): InternalRedisConnection | null { + // ioredis Cluster.subscriber is TypeScript-private but public at runtime. + // We cast through `unknown` to a narrow documented interface — NOT `as any`. + const internal = cluster as unknown as ClusterWithInternalSubscriber; + + if (internal.subscriber != null && typeof internal.subscriber.getInstance === 'function') { + return internal.subscriber.getInstance(); + } + + return null; +} /** * Seconds to subtract from the 15-min SigV4 max TTL for ElastiCache refresh. @@ -80,16 +121,13 @@ export async function generateIamAuthToken( /** * Re-authenticate active Redis connections with a fresh IAM token. * - * **Password is always updated before `AUTH`** so that ioredis uses the fresh - * token on subsequent reconnects — even when `AUTH` fails (e.g. on subscriber - * connections that are in pub/sub mode and cannot accept `AUTH` commands). - * * In **cluster mode**, the cluster-level `options.redisOptions.password` is * updated first, then each node returned by `nodes('all')` gets its per-node * password set and an `AUTH` issued. Individual node `AUTH` failures are * collected and logged as warnings but do not abort the loop, all remaining * nodes are still attempted. If any nodes failed, the function throws an - * aggregate error summarising how many nodes failed. + * aggregate error summarising how many nodes failed. Also refreshes the ClusterSubscriber + * password as it isn't reachable with `nodes('all')` and must be updated separately. * * In **standalone mode**, `options.password` is updated first, then a single * `AUTH` command is issued. @@ -127,6 +165,11 @@ export async function refreshIamAuth( if (redis.options?.redisOptions) { redis.options.redisOptions.password = token; } + const subscriberInstance = getClusterSubscriberInstance(redis); + if (subscriberInstance) { + subscriberInstance.options.password = token; + logger.debug('Updated ClusterSubscriber internal connection password (service=elasticache)'); + } if (errors.length > 0) { throw new Error(`Failed to re-AUTH ${errors.length}/${nodes.length} cluster node(s)`); } From 740e9870c3cade43a21b0ff7e284383bf9309410 Mon Sep 17 00:00:00 2001 From: mish-elle <268042956+mish-elle@users.noreply.github.com> Date: Fri, 26 Jun 2026 18:34:54 -0400 Subject: [PATCH 10/12] fix: clean usage env after rebase --- packages/services/usage/src/environment.ts | 73 +++++++++------------- 1 file changed, 28 insertions(+), 45 deletions(-) diff --git a/packages/services/usage/src/environment.ts b/packages/services/usage/src/environment.ts index 865d58ab10e..a7b567aaea2 100644 --- a/packages/services/usage/src/environment.ts +++ b/packages/services/usage/src/environment.ts @@ -5,6 +5,33 @@ import { parseRedisConfigFromEnvironment, resolveServerListenOptions, } from '@hive/service-common'; + +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); +}; + +function raiseInvariant(reason: string): never { + throw new Error(reason); +} + +const EnvironmentModel = zod.object({ + PORT: emptyString(NumberFromString.optional()), + SERVER_HOST: emptyString(zod.string().optional()), + SERVER_HOST_IPV6_ONLY: emptyString(zod.union([zod.literal('1'), zod.literal('0')]).optional()), + TOKENS_ENDPOINT: zod.string().url(), COMMERCE_ENDPOINT: emptyString(zod.string().url().optional()), RATE_LIMIT_TTL: emptyString(NumberFromString.optional()).default(30_000), ENVIRONMENT: emptyString(zod.string().optional()), @@ -12,6 +39,7 @@ import { AWS_REGION: emptyString(zod.string().optional()), }); +const SentryModel = zod.union([ zod.object({ SENTRY: emptyString(zod.literal('0').optional()), }), @@ -59,24 +87,8 @@ const PostgresModel = zod.object({ POSTGRES_DB: zod.string(), POSTGRES_USER: zod.string(), POSTGRES_PASSWORD: emptyString(zod.string().optional()), - let redisConfigResult = null; - -<<<<<<< HEAD - 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); - zod.union([zod.literal('0'), zod.literal('1')]).optional(), - ), - REDIS_AWS_IAM_CACHE_NAME: emptyString(zod.string().optional()), }); -======= ->>>>>>> 6a59c87d5 (- Consolidate Redis configuration to service-common module) const PrometheusModel = zod.object({ PROMETHEUS_METRICS: emptyString(zod.union([zod.literal('0'), zod.literal('1')]).optional()), PROMETHEUS_METRICS_LABEL_INSTANCE: emptyString(zod.string().optional()), @@ -133,34 +145,6 @@ if (configs.kafka.success && configs.kafka.data.KAFKA_AWS_IAM_AUTH_ENABLED === ' } } -<<<<<<< HEAD -<<<<<<< HEAD -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); ->>>>>>> 580adcc64 (feat: centralize Redis IAM auth and client creation into service-common) - } -======= const redisConfigResult = parseRedisConfigFromEnvironment( process.env, configs.base.success ? configs.base.data.AWS_REGION : undefined, @@ -168,7 +152,6 @@ const redisConfigResult = parseRedisConfigFromEnvironment( if (redisConfigResult.type === 'error') { environmentErrors.push(...redisConfigResult.errors); ->>>>>>> 6a59c87d5 (- Consolidate Redis configuration to service-common module) } if (environmentErrors.length) { From f674b0f13e768cbc0358d94f828386f67fa49c4b Mon Sep 17 00:00:00 2001 From: Dotan Simha Date: Sun, 28 Jun 2026 10:14:10 +0300 Subject: [PATCH 11/12] update lockfile --- pnpm-lock.yaml | 1037 ++++++++++++++++-------------------------------- 1 file changed, 350 insertions(+), 687 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dd95e3b6030..60c6d03910a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -41,6 +41,7 @@ overrides: 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.6.4 '@opentelemetry/exporter-jaeger': '-' uuid@<14.x.x: ^14.0.0 @@ -387,9 +388,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 @@ -929,7 +927,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) @@ -937,8 +935,8 @@ importers: specifier: 5.13.3 version: 5.13.3(graphql@16.14.2) ioredis: - specifier: ^5.0.0 - version: 5.8.2 + specifier: 5.10.1 + version: 5.10.1 devDependencies: tslib: specifier: 2.8.1 @@ -1079,7 +1077,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 @@ -1181,7 +1179,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 @@ -1215,12 +1213,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 @@ -1540,12 +1535,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 @@ -1596,7 +1588,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 @@ -1608,7 +1600,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 @@ -1669,9 +1661,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 @@ -1697,6 +1686,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 @@ -1705,7 +1703,7 @@ importers: version: 1.1.0(pino@10.3.0) '@graphql-hive/plugin-opentelemetry': specifier: 1.4.26 - version: 1.4.26(graphql@16.14.2)(pino@10.3.0)(ws@8.18.0) + version: 1.4.26(graphql@16.14.2)(ioredis@5.10.1)(pino@10.3.0)(ws@8.18.0) '@opentelemetry/api': specifier: 1.9.1 version: 1.9.1 @@ -1748,6 +1746,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 @@ -1757,6 +1761,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) @@ -1850,9 +1857,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 @@ -1922,9 +1926,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 @@ -2008,7 +2009,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 @@ -2044,7 +2045,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 @@ -2057,9 +2058,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) @@ -2803,8 +2801,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': @@ -2831,10 +2829,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'} @@ -2899,10 +2893,6 @@ packages: 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'} @@ -2971,8 +2961,8 @@ packages: 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': @@ -3051,10 +3041,6 @@ packages: 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'} @@ -3123,6 +3109,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'} @@ -3171,10 +3161,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'} @@ -3416,7 +3402,7 @@ packages: resolution: {integrity: sha512-X7lGyI6x1ObulCmH+0vyJ3/T6RyTBlp1hunRXjsenlkafJMIvneAx8B2V7OGLgZAxdsAbKaySjqZRj6GlX84Qw==} engines: {node: '>=20.6'} peerDependencies: - ioredis: ^5.0.0 + ioredis: 5.10.1 peerDependenciesMeta: ioredis: optional: true @@ -4359,7 +4345,7 @@ packages: engines: {node: '>=20.0.0'} peerDependencies: '@nats-io/nats-core': ^3 - ioredis: ^5 + ioredis: 5.10.1 peerDependenciesMeta: '@nats-io/nats-core': optional: true @@ -5312,7 +5298,7 @@ packages: resolution: {integrity: sha512-OQcTdmRRfYiMr+RNbfmdqEFhHWB5jz5jgcJGXaOI0zR2+PLS1nm50MQGSZG+TQVo7s9eOP7aUVp/+g9qAi1eDA==} engines: {node: '>=18.0.0'} peerDependencies: - ioredis: ^5.0.6 + ioredis: 5.10.1 '@graphql-yoga/render-graphiql@5.16.2': resolution: {integrity: sha512-QTxmGZR/sZODnMZdsXpyoFEoIjzx5dRvqtW8XRiLq8TN+nCwVujmEehxPw9Fd6bu8pBbjs4dOgGCVss6OjOByw==} @@ -5833,12 +5819,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==} @@ -9156,11 +9136,6 @@ packages: 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'} @@ -9333,8 +9308,8 @@ packages: 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': @@ -9373,12 +9348,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': @@ -11318,7 +11289,7 @@ packages: resolution: {integrity: sha512-fecAAMQtXSkWgeTQ8jSG0oBcBNNXWRVB7eTwKUxG1h+GQdI3F5z/Ej/IR0FRpUPgU7VRg4kKET4z3e4AqA4RNw==} peerDependencies: '@aws-sdk/client-dynamodb': ^3.438.0 - ioredis: ^5.3.2 + ioredis: 5.10.1 knex: ^3.0.1 kysely: ^0.27.3 orchid-orm: ^1.24.0 @@ -14028,23 +13999,19 @@ packages: engines: {node: '>=12.22'} peerDependencies: '@types/ioredis-mock': ^8 - ioredis: ^5 + ioredis: 5.10.1 ioredis-mock@8.9.0: resolution: {integrity: sha512-yIglcCkI1lvhwJVoMsR51fotZVsPsSk07ecTCgRTRlicG0Vq3lke6aAaHklyjmRNRsdYAgswqC2A0bPtQK4LSw==} engines: {node: '>=12.22'} peerDependencies: '@types/ioredis-mock': ^8 - ioredis: ^5 + ioredis: 5.10.1 ioredis@5.10.1: 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'} @@ -19427,13 +19394,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': @@ -19458,13 +19425,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': @@ -19473,13 +19440,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 @@ -19510,7 +19477,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 @@ -19529,18 +19496,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: @@ -19617,7 +19615,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.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 @@ -19628,9 +19626,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.12.12 - '@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 @@ -19649,7 +19647,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 @@ -19657,12 +19655,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 @@ -19673,9 +19671,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 @@ -19694,13 +19692,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/protocol-http': 5.4.3 + '@smithy/signature-v4': 5.4.3 '@smithy/smithy-client': 4.12.12 - '@smithy/types': 4.14.1 + '@smithy/types': 4.14.2 '@smithy/util-base64': 4.3.2 '@smithy/util-middleware': 4.2.14 '@smithy/util-utf8': 4.2.2 @@ -19712,7 +19710,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 @@ -19724,8 +19722,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 @@ -19734,26 +19732,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': @@ -19769,23 +19750,23 @@ 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 + '@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.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': @@ -19803,35 +19784,35 @@ snapshots: '@smithy/fetch-http-handler': 5.3.17 '@smithy/node-http-handler': 4.6.0 '@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.32': dependencies: - '@aws-sdk/core': 3.974.4 - '@aws-sdk/types': 3.973.8 + '@aws-sdk/core': 3.974.13 + '@aws-sdk/types': 3.973.9 '@smithy/fetch-http-handler': 5.3.17 '@smithy/node-http-handler': 4.6.0 '@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 + '@aws-sdk/core': 3.974.13 + '@aws-sdk/types': 3.973.9 '@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/protocol-http': 5.4.3 '@smithy/smithy-client': 4.13.1 - '@smithy/types': 4.14.1 + '@smithy/types': 4.14.2 '@smithy/util-stream': 4.6.1 tslib: 2.8.1 @@ -19859,14 +19840,14 @@ snapshots: '@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 + '@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/core': 3.974.13 '@aws-sdk/credential-provider-env': 3.972.30 '@aws-sdk/credential-provider-http': 3.972.32 '@aws-sdk/credential-provider-login': 3.972.34 @@ -19874,33 +19855,31 @@ snapshots: '@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 + '@aws-sdk/types': 3.973.9 '@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 + '@smithy/types': 4.14.2 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: @@ -19924,35 +19903,22 @@ 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 + '@smithy/types': 4.14.2 tslib: 2.8.1 transitivePeerDependencies: - aws-crt '@aws-sdk/credential-provider-login@3.972.34': dependencies: - '@aws-sdk/core': 3.974.4 + '@aws-sdk/core': 3.974.13 '@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 + '@aws-sdk/types': 3.973.9 '@smithy/property-provider': 4.2.14 - '@smithy/protocol-http': 5.3.14 + '@smithy/protocol-http': 5.4.3 '@smithy/shared-ini-file-loader': 4.4.9 - '@smithy/types': 4.14.1 + '@smithy/types': 4.14.2 tslib: 2.8.1 transitivePeerDependencies: - aws-crt @@ -19978,7 +19944,7 @@ snapshots: '@smithy/credential-provider-imds': 4.2.8 '@smithy/property-provider': 4.2.8 '@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 @@ -20008,14 +19974,12 @@ snapshots: '@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 + '@aws-sdk/types': 3.973.9 '@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 + '@smithy/types': 4.14.2 tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt '@aws-sdk/credential-provider-node@3.972.44': dependencies: @@ -20037,25 +20001,25 @@ 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 + '@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.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': @@ -20074,36 +20038,34 @@ 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/core': 3.974.13 '@aws-sdk/nested-clients': 3.997.2 '@aws-sdk/token-providers': 3.1035.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.38': dependencies: - '@aws-sdk/core': 3.974.8 - '@aws-sdk/nested-clients': 3.997.6 + '@aws-sdk/core': 3.974.13 + '@aws-sdk/nested-clients': 3.997.11 '@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: @@ -20122,34 +20084,32 @@ 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/core': 3.974.13 '@aws-sdk/nested-clients': 3.997.2 - '@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-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: @@ -20160,9 +20120,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 @@ -20175,17 +20135,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 @@ -20193,7 +20158,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 @@ -20207,7 +20172,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 @@ -20217,14 +20182,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 @@ -20237,7 +20202,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': @@ -20250,15 +20215,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 @@ -20269,8 +20234,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 @@ -20281,15 +20246,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 @@ -20307,9 +20272,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': @@ -20318,19 +20283,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 @@ -20349,7 +20314,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.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 @@ -20360,9 +20325,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.12.12 - '@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 @@ -20394,19 +20359,19 @@ snapshots: dependencies: '@aws-crypto/sha256-browser': 5.2.0 '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/core': 3.974.4 + '@aws-sdk/core': 3.974.13 '@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/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.20 '@smithy/config-resolver': 4.4.17 - '@smithy/core': 3.23.16 + '@smithy/core': 3.24.4 '@smithy/fetch-http-handler': 5.3.17 '@smithy/hash-node': 4.2.14 '@smithy/invalid-dependency': 4.2.14 @@ -20417,9 +20382,9 @@ snapshots: '@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/protocol-http': 5.4.3 '@smithy/smithy-client': 4.12.12 - '@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 @@ -20434,56 +20399,12 @@ snapshots: 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': @@ -20509,51 +20430,49 @@ 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/core': 3.974.13 '@aws-sdk/nested-clients': 3.997.2 - '@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/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: @@ -20571,14 +20490,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': @@ -20598,7 +20517,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 @@ -20618,6 +20537,11 @@ snapshots: '@smithy/types': 4.14.1 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': dependencies: tslib: 2.8.1 @@ -20625,7 +20549,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 @@ -20641,7 +20565,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': @@ -20656,9 +20580,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 @@ -20668,20 +20592,13 @@ snapshots: '@aws-sdk/xml-builder@3.969.0': dependencies: - '@smithy/types': 4.14.1 + '@smithy/types': 4.14.2 fast-xml-parser: 5.7.2 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 + '@smithy/types': 4.14.2 fast-xml-parser: 5.7.2 tslib: 2.8.1 @@ -20969,18 +20886,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: @@ -22282,58 +22199,7 @@ snapshots: - winston - ws - '@graphql-hive/gateway-runtime@2.9.3(graphql@16.14.2)(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.14.2) - '@envelop/generic-auth': 11.0.0(@envelop/core@5.5.1)(graphql@16.14.2) - '@envelop/instrumentation': 1.0.0 - '@graphql-hive/core': 0.21.0(graphql@16.14.2)(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.14.2))(graphql@16.14.2)(pino@10.3.0) - '@graphql-mesh/cross-helpers': 0.4.14(graphql@16.14.2) - '@graphql-mesh/fusion-runtime': 1.10.3(@types/node@25.5.0)(graphql@16.14.2)(pino@10.3.0) - '@graphql-mesh/hmac-upstream-signature': 2.0.12(graphql@16.14.2) - '@graphql-mesh/plugin-response-cache': 0.104.43(graphql@16.14.2) - '@graphql-mesh/transport-common': 1.0.16(graphql@16.14.2)(pino@10.3.0) - '@graphql-mesh/types': 0.104.28(graphql@16.14.2) - '@graphql-mesh/utils': 0.104.36(graphql@16.14.2) - '@graphql-tools/batch-delegate': 10.0.22(graphql@16.14.2) - '@graphql-tools/delegate': 12.0.16(graphql@16.14.2) - '@graphql-tools/executor-common': 1.0.6(graphql@16.14.2) - '@graphql-tools/executor-http': 3.3.0(@types/node@25.5.0)(graphql@16.14.2) - '@graphql-tools/federation': 4.4.3(@types/node@25.5.0)(graphql@16.14.2) - '@graphql-tools/stitch': 10.1.22(graphql@16.14.2) - '@graphql-tools/utils': 11.1.0(graphql@16.14.2) - '@graphql-tools/wrap': 11.1.15(graphql@16.14.2) - '@graphql-yoga/plugin-apollo-usage-report': 0.16.0(@envelop/core@5.5.1)(graphql-yoga@5.21.1(graphql@16.14.2))(graphql@16.14.2) - '@graphql-yoga/plugin-csrf-prevention': 3.16.2(graphql-yoga@5.21.1(graphql@16.14.2)) - '@graphql-yoga/plugin-defer-stream': 3.16.2(graphql-yoga@5.21.1(graphql@16.14.2))(graphql@16.14.2) - '@graphql-yoga/plugin-persisted-operations': 3.16.2(graphql-yoga@5.21.1(graphql@16.14.2))(graphql@16.14.2) - '@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.14.2 - graphql-ws: 6.0.6(graphql@16.14.2)(ws@8.18.0) - graphql-yoga: 5.21.1(graphql@16.14.2) - 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) @@ -22341,16 +22207,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) @@ -22556,53 +22422,15 @@ snapshots: - winston - ws - '@graphql-hive/plugin-opentelemetry@1.4.26(graphql@16.14.2)(pino@10.3.0)(ws@8.18.0)': - dependencies: - '@graphql-hive/core': 0.21.0(graphql@16.14.2)(pino@10.3.0) - '@graphql-hive/gateway-runtime': 2.9.3(graphql@16.14.2)(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.14.2) - '@graphql-mesh/transport-common': 1.0.16(graphql@16.14.2)(pino@10.3.0) - '@graphql-mesh/types': 0.104.28(graphql@16.14.2) - '@graphql-mesh/utils': 0.104.36(graphql@16.14.2) - '@graphql-tools/utils': 11.1.0(graphql@16.14.2) - '@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.14.2 - 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 @@ -22640,14 +22468,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.14.2)(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.14.2))(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.14.2)(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.14.2))(tslib@2.8.1)(zod@4.3.6) @@ -23067,46 +22887,15 @@ snapshots: - pino - winston - '@graphql-mesh/fusion-runtime@1.10.3(@types/node@25.5.0)(graphql@16.14.2)(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.14.2) - '@graphql-mesh/transport-common': 1.0.16(graphql@16.14.2)(pino@10.3.0) - '@graphql-mesh/types': 0.104.28(graphql@16.14.2) - '@graphql-mesh/utils': 0.104.36(graphql@16.14.2) - '@graphql-tools/batch-execute': 10.0.8(graphql@16.14.2) - '@graphql-tools/delegate': 12.0.16(graphql@16.14.2) - '@graphql-tools/executor': 1.5.0(graphql@16.14.2) - '@graphql-tools/federation': 4.4.3(@types/node@25.5.0)(graphql@16.14.2) - '@graphql-tools/merge': 9.1.5(graphql@16.14.2) - '@graphql-tools/stitch': 10.1.22(graphql@16.14.2) - '@graphql-tools/stitching-directives': 4.0.21(graphql@16.14.2) - '@graphql-tools/utils': 11.1.0(graphql@16.14.2) - '@graphql-tools/wrap': 11.1.15(graphql@16.14.2) - '@whatwg-node/disposablestack': 0.0.6 - '@whatwg-node/promise-helpers': 1.3.2 - graphql: 16.14.2 - graphql-yoga: 5.21.1(graphql@16.14.2) - 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) @@ -23129,20 +22918,6 @@ snapshots: - pino - winston - '@graphql-mesh/hmac-upstream-signature@2.0.12(graphql@16.14.2)': - dependencies: - '@graphql-mesh/cross-helpers': 0.4.14(graphql@16.14.2) - '@graphql-mesh/types': 0.104.28(graphql@16.14.2) - '@graphql-mesh/utils': 0.104.36(graphql@16.14.2) - '@graphql-tools/executor-common': 1.0.6(graphql@16.14.2) - '@graphql-tools/utils': 11.1.0(graphql@16.14.2) - '@whatwg-node/promise-helpers': 1.3.2 - graphql: 16.14.2 - tslib: 2.8.1 - transitivePeerDependencies: - - '@nats-io/nats-core' - - ioredis - '@graphql-mesh/hmac-upstream-signature@2.0.12(graphql@16.14.2)(ioredis@5.10.1)': dependencies: '@graphql-mesh/cross-helpers': 0.4.14(graphql@16.14.2) @@ -23157,11 +22932,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 @@ -23250,25 +23025,6 @@ snapshots: - '@nats-io/nats-core' - ioredis - '@graphql-mesh/plugin-response-cache@0.104.43(graphql@16.14.2)': - dependencies: - '@envelop/core': 5.5.1 - '@envelop/response-cache': 9.1.1(@envelop/core@5.5.1)(graphql@16.14.2) - '@graphql-mesh/cross-helpers': 0.4.14(graphql@16.14.2) - '@graphql-mesh/string-interpolation': 0.5.16(graphql@16.14.2) - '@graphql-mesh/types': 0.104.28(graphql@16.14.2) - '@graphql-mesh/utils': 0.104.36(graphql@16.14.2) - '@graphql-tools/utils': 11.1.0(graphql@16.14.2) - '@graphql-yoga/plugin-response-cache': 3.23.0(graphql-yoga@5.21.1(graphql@16.14.2))(graphql@16.14.2) - '@whatwg-node/promise-helpers': 1.3.2 - cache-control-parser: 2.2.0 - graphql: 16.14.2 - graphql-yoga: 5.21.1(graphql@16.14.2) - tslib: 2.8.1 - transitivePeerDependencies: - - '@nats-io/nats-core' - - ioredis - '@graphql-mesh/plugin-response-cache@0.104.43(graphql@16.14.2)(ioredis@5.10.1)': dependencies: '@envelop/core': 5.5.1 @@ -23288,14 +23044,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 @@ -23356,32 +23112,13 @@ snapshots: - pino - winston - '@graphql-mesh/transport-common@1.0.16(graphql@16.14.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/signal': 2.0.0 - '@graphql-mesh/types': 0.104.28(graphql@16.14.2) - '@graphql-tools/executor': 1.5.0(graphql@16.14.2) - '@graphql-tools/executor-common': 1.0.6(graphql@16.14.2) - '@graphql-tools/utils': 11.1.0(graphql@16.14.2) - graphql: 16.14.2 - 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)': + '@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/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) @@ -23461,21 +23198,6 @@ snapshots: - utf-8-validate - winston - '@graphql-mesh/types@0.104.28(graphql@16.14.2)': - dependencies: - '@graphql-hive/pubsub': 2.1.1(ioredis@5.8.2) - '@graphql-tools/batch-delegate': 10.0.22(graphql@16.14.2) - '@graphql-tools/delegate': 12.0.16(graphql@16.14.2) - '@graphql-tools/utils': 11.1.0(graphql@16.14.2) - '@graphql-typed-document-node/core': 3.2.0(graphql@16.14.2) - '@repeaterjs/repeater': 3.0.6 - '@whatwg-node/disposablestack': 0.0.6 - graphql: 16.14.2 - tslib: 2.8.1 - transitivePeerDependencies: - - '@nats-io/nats-core' - - ioredis - '@graphql-mesh/types@0.104.28(graphql@16.14.2)(ioredis@5.10.1)': dependencies: '@graphql-hive/pubsub': 2.1.1(ioredis@5.10.1) @@ -23491,9 +23213,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) @@ -23506,30 +23228,6 @@ snapshots: - '@nats-io/nats-core' - ioredis - '@graphql-mesh/utils@0.104.36(graphql@16.14.2)': - dependencies: - '@envelop/instrumentation': 1.0.0 - '@graphql-mesh/cross-helpers': 0.4.14(graphql@16.14.2) - '@graphql-mesh/string-interpolation': 0.5.16(graphql@16.14.2) - '@graphql-mesh/types': 0.104.28(graphql@16.14.2) - '@graphql-tools/batch-delegate': 10.0.22(graphql@16.14.2) - '@graphql-tools/delegate': 12.0.16(graphql@16.14.2) - '@graphql-tools/utils': 11.1.0(graphql@16.14.2) - '@graphql-tools/wrap': 11.1.15(graphql@16.14.2) - '@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.14.2 - js-yaml: 4.2.0 - 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.14.2)(ioredis@5.10.1)': dependencies: '@envelop/instrumentation': 1.0.0 @@ -23554,12 +23252,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) @@ -25053,11 +24751,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.14.2))': dependencies: @@ -25562,10 +25260,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': @@ -29590,7 +29284,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': @@ -29614,7 +29308,7 @@ 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 @@ -29623,8 +29317,8 @@ snapshots: '@smithy/core@3.20.5': dependencies: '@smithy/middleware-serde': 4.2.19 - '@smithy/protocol-http': 5.3.14 - '@smithy/types': 4.14.1 + '@smithy/protocol-http': 5.4.3 + '@smithy/types': 4.14.2 '@smithy/util-base64': 4.3.2 '@smithy/util-body-length-browser': 4.2.2 '@smithy/util-middleware': 4.2.14 @@ -29635,7 +29329,7 @@ snapshots: '@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 @@ -29646,12 +29340,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 @@ -29662,7 +29350,7 @@ snapshots: 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 @@ -29670,7 +29358,7 @@ snapshots: 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 @@ -29683,7 +29371,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 @@ -29707,12 +29395,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 @@ -29720,9 +29408,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 @@ -29748,7 +29436,7 @@ snapshots: '@smithy/hash-node@4.2.8': dependencies: - '@smithy/types': 4.14.1 + '@smithy/types': 4.14.2 '@smithy/util-buffer-from': 4.2.0 '@smithy/util-utf8': 4.2.2 tslib: 2.8.1 @@ -29766,7 +29454,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': @@ -29785,14 +29473,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': @@ -29808,27 +29496,27 @@ snapshots: '@smithy/middleware-endpoint@4.4.6': dependencies: - '@smithy/core': 3.23.16 + '@smithy/core': 3.24.4 '@smithy/middleware-serde': 4.2.19 '@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/types': 4.14.2 '@smithy/util-middleware': 4.2.14 '@smithy/util-retry': 4.3.3 '@smithy/uuid': 1.1.0 @@ -29838,7 +29526,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 @@ -29849,25 +29537,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': @@ -29877,7 +29565,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': @@ -29891,28 +29579,28 @@ snapshots: dependencies: '@smithy/property-provider': 4.2.8 '@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': @@ -29923,12 +29611,12 @@ snapshots: '@smithy/property-provider@4.2.14': dependencies: - '@smithy/types': 4.14.1 + '@smithy/types': 4.14.2 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': @@ -29936,49 +29624,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': @@ -29991,18 +29679,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 @@ -30010,11 +29687,11 @@ snapshots: '@smithy/smithy-client@4.10.7': dependencies: - '@smithy/core': 3.23.16 + '@smithy/core': 3.24.4 '@smithy/middleware-endpoint': 4.4.31 '@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 @@ -30023,15 +29700,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': @@ -30059,7 +29736,7 @@ 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': @@ -30117,7 +29794,7 @@ snapshots: dependencies: '@smithy/property-provider': 4.2.8 '@smithy/smithy-client': 4.12.12 - '@smithy/types': 4.14.1 + '@smithy/types': 4.14.2 tslib: 2.8.1 '@smithy/util-defaults-mode-browser@4.3.48': @@ -30129,7 +29806,7 @@ 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': @@ -30139,7 +29816,7 @@ snapshots: '@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/types': 4.14.2 tslib: 2.8.1 '@smithy/util-defaults-mode-node@4.2.53': @@ -30154,13 +29831,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': @@ -30189,13 +29866,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': @@ -30206,14 +29883,14 @@ 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/types': 4.14.2 '@smithy/util-base64': 4.3.2 '@smithy/util-buffer-from': 4.2.0 '@smithy/util-hex-encoding': 4.2.2 @@ -30233,7 +29910,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': @@ -30267,7 +29944,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': @@ -31072,7 +30749,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 @@ -32187,8 +31864,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: @@ -32251,16 +31928,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: @@ -35719,21 +35396,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: @@ -35750,20 +35427,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 aeeb9ab6735e04a8aa4b0e9a2869ce19fc2f7aa4 Mon Sep 17 00:00:00 2001 From: Dotan Simha Date: Sun, 28 Jun 2026 17:06:32 +0300 Subject: [PATCH 12/12] fix prettier --- packages/services/service-common/package.json | 2 +- packages/services/service-common/src/iam-redis.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/services/service-common/package.json b/packages/services/service-common/package.json index 348f058d71f..55125b02140 100644 --- a/packages/services/service-common/package.json +++ b/packages/services/service-common/package.json @@ -33,9 +33,9 @@ "@sentry/node": "7.120.2", "@sentry/types": "7.120.2", "@sentry/utils": "7.120.2", - "aws-msk-iam-sasl-signer-js": "1.0.3", "@smithy/protocol-http": "5.4.3", "@smithy/signature-v4": "5.4.3", + "aws-msk-iam-sasl-signer-js": "1.0.3", "fastify": "5.8.5", "fastify-plugin": "5.1.0", "ioredis": "5.10.1", diff --git a/packages/services/service-common/src/iam-redis.ts b/packages/services/service-common/src/iam-redis.ts index 2d797010d9b..457af00ed98 100644 --- a/packages/services/service-common/src/iam-redis.ts +++ b/packages/services/service-common/src/iam-redis.ts @@ -22,7 +22,7 @@ interface ClusterWithInternalSubscriber { * Handles ElastiCache token generation and in-place AUTH for standalone and * cluster connections. */ - + /** * Extract the internal ClusterSubscriber's Redis instance from a Cluster. * @@ -126,7 +126,7 @@ export async function generateIamAuthToken( * password set and an `AUTH` issued. Individual node `AUTH` failures are * collected and logged as warnings but do not abort the loop, all remaining * nodes are still attempted. If any nodes failed, the function throws an - * aggregate error summarising how many nodes failed. Also refreshes the ClusterSubscriber + * aggregate error summarising how many nodes failed. Also refreshes the ClusterSubscriber * password as it isn't reachable with `nodes('all')` and must be updated separately. * * In **standalone mode**, `options.password` is updated first, then a single