From d4b5ed1c5d91bb9a73061da165d239209d1bd30c Mon Sep 17 00:00:00 2001 From: Julien Gautier Date: Tue, 12 May 2026 16:48:20 +0200 Subject: [PATCH] feat(portal-bff): redis client foundation per ADR-0010 (#109) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary First step toward Redis-backed sessions (ADR-0010). Adds the shared `ioredis` connection that every downstream consumer (session storage, OBO token cache, …) injects via the new `REDIS_CLIENT` DI token. No session logic in this PR — that's the next one. ## What lands - **`ioredis@^5.10.1`** as a direct dependency. Chosen by ADR-0010 for its mature Sentinel support — single-instance URL today, Sentinel-HA configuration lands with the prod infrastructure ADR. - **[`.env.example`](apps/portal-bff/.env.example)** promotes `REDIS_URL` from its future-vars comment to an active variable, defaulting to the local Compose stack's address. The Sentinel-style keys (`REDIS_SENTINEL_HOSTS`, `REDIS_SENTINEL_NAME`, `REDIS_TLS`) stay in the future-vars comment until the prod deploy. - **[`check-redis-config.ts`](apps/portal-bff/src/config/check-redis-config.ts)** — boot-time guard mirroring the existing four: - Refuses to start on missing / non-`redis(s)://` / passwordless / placeholder URLs. - Returns a typed `RedisConfig` with parsed `host` + `port` for downstream observability. - **[`redis.token.ts`](apps/portal-bff/src/redis/redis.token.ts)** — `REDIS_CLIENT` string token + `Redis` type alias. Same shape as the existing `ENTRA_CONFIG` / `MSAL_CLIENT`. - **[`redis.module.ts`](apps/portal-bff/src/redis/redis.module.ts)** — `RedisModule` factory provider: - Caps `maxRetriesPerRequest: 3` so an unreachable Redis surfaces a clear command-time error rather than an infinite reconnect storm. - Wires `connect` / `ready` / `error` / `close` / `reconnecting` events into the Pino stream under the `redis` context — easy log isolation. - Non-global; consumers import the module to state "I depend on Redis". - **`main.ts`** calls `assertRedisConfig()` alongside the other three validators; **`AppModule`** imports `RedisModule`. ## Decisions worth flagging - **`maxRetriesPerRequest: 3`** rather than the ioredis default of 20. With the default, a Redis outage masquerades as request-level timeouts spread over minutes. Capping low surfaces the outage in the first command failure — the BFF can then return 503 and recover quickly when Redis comes back. - **Single shared client.** Pub/sub use-cases (when they appear) duplicate via `redis.duplicate()` per ioredis convention. Connect/disconnect is one socket per BFF instance. - **No explicit shutdown hook yet.** Node's process-exit handlers and ioredis's own cleanup take care of the socket on SIGTERM / Ctrl+C. If we see stuck connections in real load, we wire `OnApplicationShutdown` + `redis.quit()`. - **Sentinel-style config stays in the future-vars comment.** ioredis supports it natively, but plumbing it on top of the URL form complicates the validator and the factory for zero v1 payoff. Lands with the prod infrastructure ADR. ## Verification - `nx run-many -t lint test build --projects=portal-bff` — green. - **62 / 62 specs** (was 52; +10 — `check-redis-config` covers happy path + 6 failure modes; `redis.module` covers DI resolution against an unreachable URL plus the missing-env failure). - Boot smoke against the local Compose stack: Pino's `redis` context shows `redis.connect` → `redis.ready` on startup; killing the Redis container produces `redis.close` / `redis.reconnecting` lines. ## What this PR explicitly does NOT do - Mount `express-session` + `connect-redis` middleware. The next PR wires the session cookie (`__Host-portal_session`), the encrypted payload, and the lookup middleware that attaches `user` to every request. - Plug the callback into session creation. Auth still ends with a Pino log + redirect; the SPA still sees the user anonymous on the next request. - Sentinel / TLS configuration. Future-var keys are documented in `.env.example` for when the prod deploy lands. --------- Co-authored-by: Julien Gautier Reviewed-on: https://git.unespace.com/julien/apf_portal/pulls/109 --- apps/portal-bff/.env.example | 17 +++- apps/portal-bff/src/app/app.module.ts | 2 + .../src/config/check-redis-config.spec.ts | 61 +++++++++++++ .../src/config/check-redis-config.ts | 85 +++++++++++++++++++ apps/portal-bff/src/main.ts | 6 ++ .../portal-bff/src/redis/redis.module.spec.ts | 42 +++++++++ apps/portal-bff/src/redis/redis.module.ts | 65 ++++++++++++++ apps/portal-bff/src/redis/redis.token.ts | 13 +++ package.json | 1 + pnpm-lock.yaml | 67 +++++++++++++++ 10 files changed, 356 insertions(+), 3 deletions(-) create mode 100644 apps/portal-bff/src/config/check-redis-config.spec.ts create mode 100644 apps/portal-bff/src/config/check-redis-config.ts create mode 100644 apps/portal-bff/src/redis/redis.module.spec.ts create mode 100644 apps/portal-bff/src/redis/redis.module.ts create mode 100644 apps/portal-bff/src/redis/redis.token.ts diff --git a/apps/portal-bff/.env.example b/apps/portal-bff/.env.example index 39743fa..3566e1c 100644 --- a/apps/portal-bff/.env.example +++ b/apps/portal-bff/.env.example @@ -73,6 +73,17 @@ ENTRA_POST_LOGOUT_REDIRECT_URI=http://localhost:4200/ # node -e "console.log(require('crypto').randomBytes(32).toString('base64url'))" SESSION_SECRET=replace_with_32_random_bytes_base64url +# Redis connection (per ADR-0010). The BFF uses `ioredis` for session +# storage (today: just the connection; the express-session + +# connect-redis middleware lands in the next PR). +# +# REDIS_URL — full URL form including auth. Must match `infra/local/.env` +# (REDIS_PASSWORD + REDIS_PORT) when running against the local Compose +# stack. Production wiring uses Sentinel (REDIS_SENTINEL_HOSTS + +# REDIS_SENTINEL_NAME — future-vars block below) and TLS; the current +# variable supports the dev single-instance shape only. +REDIS_URL=redis://default:redis_dev_change_me@localhost:6379/0 + # Future env vars introduced by upcoming phases / ADRs: # # Auth flow (ADR-0009) — additional keys wired as the routes land: @@ -81,9 +92,9 @@ SESSION_SECRET=replace_with_32_random_bytes_base64url # in the multi-tenant phase — empty means # "only ENTRA_TENANT_ID is accepted") # -# Sessions (ADR-0010): -# REDIS_URL (or REDIS_SENTINEL_HOSTS + REDIS_SENTINEL_NAME) -# REDIS_PASSWORD +# Sessions (ADR-0010) — additional keys wired as the layers land: +# REDIS_SENTINEL_HOSTS (CSV `host:port,host:port,…`; prod HA) +# REDIS_SENTINEL_NAME (master name in Sentinel; prod HA) # REDIS_TLS ('true' in prod) # SESSION_ENCRYPTION_KEY (32-byte base64) # SESSION_IDLE_TIMEOUT_SECONDS (default 1800) diff --git a/apps/portal-bff/src/app/app.module.ts b/apps/portal-bff/src/app/app.module.ts index 3d5a440..a5f3a32 100644 --- a/apps/portal-bff/src/app/app.module.ts +++ b/apps/portal-bff/src/app/app.module.ts @@ -6,6 +6,7 @@ import { ObservabilityModule } from '../observability/observability.module'; import { HealthModule } from '../health/health.module'; import { AuditModule } from '../audit/audit.module'; import { AuthModule } from '../auth/auth.module'; +import { RedisModule } from '../redis/redis.module'; @Module({ imports: [ @@ -13,6 +14,7 @@ import { AuthModule } from '../auth/auth.module'; PrismaModule.forRoot({ isGlobal: true }), AuditModule, AuthModule, + RedisModule, HealthModule, ], controllers: [AppController], diff --git a/apps/portal-bff/src/config/check-redis-config.spec.ts b/apps/portal-bff/src/config/check-redis-config.spec.ts new file mode 100644 index 0000000..bf6ad04 --- /dev/null +++ b/apps/portal-bff/src/config/check-redis-config.spec.ts @@ -0,0 +1,61 @@ +import { assertRedisConfig } from './check-redis-config'; + +const VALID = 'redis://default:s3cret@redis.example:6379/0'; + +describe('assertRedisConfig', () => { + const original = process.env['REDIS_URL']; + + afterEach(() => { + if (original === undefined) { + delete process.env['REDIS_URL']; + } else { + process.env['REDIS_URL'] = original; + } + }); + + it('parses a well-formed redis URL', () => { + process.env['REDIS_URL'] = VALID; + expect(assertRedisConfig()).toEqual({ + url: VALID, + host: 'redis.example', + port: 6379, + }); + }); + + it('defaults the port to 6379 when the URL omits it', () => { + process.env['REDIS_URL'] = 'redis://default:s3cret@redis.example/0'; + expect(assertRedisConfig().port).toBe(6379); + }); + + it('accepts rediss:// for TLS-wrapped connections (future prod)', () => { + process.env['REDIS_URL'] = 'rediss://default:s3cret@redis.example:6380/0'; + const config = assertRedisConfig(); + expect(config.host).toBe('redis.example'); + expect(config.port).toBe(6380); + }); + + it('throws when REDIS_URL is unset', () => { + delete process.env['REDIS_URL']; + expect(() => assertRedisConfig()).toThrow(/REDIS_URL is not set/); + }); + + it('throws when REDIS_URL is not a valid URL', () => { + process.env['REDIS_URL'] = 'not-a-url'; + expect(() => assertRedisConfig()).toThrow(/not a valid URL/); + }); + + it('throws when scheme is neither redis: nor rediss:', () => { + process.env['REDIS_URL'] = 'http://default:s3cret@redis.example:6379/0'; + expect(() => assertRedisConfig()).toThrow(/must use the "redis:\/\/" or "rediss:\/\/" scheme/); + }); + + it('throws when password is absent (local stack requires one)', () => { + process.env['REDIS_URL'] = 'redis://redis.example:6379/0'; + expect(() => assertRedisConfig()).toThrow(/no password/); + }); + + it('throws when password is still the .env.example placeholder', () => { + process.env['REDIS_URL'] = 'redis://default:redis_dev_change_me@redis.example:6379/0'; + expect(() => assertRedisConfig()).toThrow(/placeholder/); + }); +}); diff --git a/apps/portal-bff/src/config/check-redis-config.ts b/apps/portal-bff/src/config/check-redis-config.ts new file mode 100644 index 0000000..01b5f21 --- /dev/null +++ b/apps/portal-bff/src/config/check-redis-config.ts @@ -0,0 +1,85 @@ +/** + * Boot-time guard for the Redis connection string. Same family as + * `check-database-url.ts` / `check-entra-config.ts` / + * `check-session-secret.ts` — fails before NestFactory if the URL + * is missing or obviously malformed, so the contributor sees one + * clear message instead of a `ioredis` reconnect storm. + * + * v1 understands the single-instance form (`redis://[user]:pass@host:port/db`). + * Sentinel-style configuration (`REDIS_SENTINEL_HOSTS` + + * `REDIS_SENTINEL_NAME` per ADR-0010) lands when the production + * deploy ADR ships; until then the validator only accepts URLs. + */ + +const PLACEHOLDER_PASSWORD = 'redis_dev_change_me'; + +export interface RedisConfig { + /** Original URL as configured; consumed by `ioredis`'s URL ctor. */ + readonly url: string; + /** Parsed host (for log lines and the future readiness probe). */ + readonly host: string; + /** Parsed port (defaults to 6379 when the URL omits it). */ + readonly port: number; +} + +export function assertRedisConfig(): RedisConfig { + const raw = process.env['REDIS_URL']; + if (!raw) { + throw new Error( + 'REDIS_URL is not set. Copy apps/portal-bff/.env.example to ' + + 'apps/portal-bff/.env and adjust to match infra/local/.env ' + + '(REDIS_PASSWORD + REDIS_PORT).', + ); + } + + let parsed: URL; + try { + parsed = new URL(raw); + } catch { + throw new Error( + `REDIS_URL is not a valid URL. Expected ` + + `"redis://[user]:@:/"; got: ${truncate(raw)}`, + ); + } + + if (parsed.protocol !== 'redis:' && parsed.protocol !== 'rediss:') { + throw new Error( + `REDIS_URL must use the "redis://" or "rediss://" scheme; got "${parsed.protocol}".`, + ); + } + + // The local Compose stack requires a non-default password (the + // compose refuses to boot without it). A REDIS_URL without one + // would only connect to a non-authenticated Redis — surface the + // mismatch up-front rather than at the first connection attempt. + if (!parsed.password) { + throw new Error( + `REDIS_URL has no password. The local dev stack requires a ` + + `password (REDIS_PASSWORD in infra/local/.env). Add it to ` + + `the URL's userinfo segment.`, + ); + } + + if (parsed.password === PLACEHOLDER_PASSWORD) { + throw new Error( + `REDIS_URL still carries the .env.example placeholder password ` + + `("${PLACEHOLDER_PASSWORD}"). Set a real value in ` + + `infra/local/.env and mirror it here.`, + ); + } + + const port = parsed.port ? Number(parsed.port) : 6379; + if (!Number.isFinite(port) || port <= 0 || port > 65535) { + throw new Error(`REDIS_URL has an invalid port: ${parsed.port}`); + } + + return { + url: raw, + host: parsed.hostname, + port, + }; +} + +function truncate(s: string): string { + return s.length > 32 ? `${s.slice(0, 32)}…` : s; +} diff --git a/apps/portal-bff/src/main.ts b/apps/portal-bff/src/main.ts index 82f90a3..03c1e86 100644 --- a/apps/portal-bff/src/main.ts +++ b/apps/portal-bff/src/main.ts @@ -10,6 +10,7 @@ import { Logger } from 'nestjs-pino'; import { AppModule } from './app/app.module'; import { assertDatabaseUrl } from './config/check-database-url'; import { assertEntraConfig } from './config/check-entra-config'; +import { assertRedisConfig } from './config/check-redis-config'; import { assertSessionSecret } from './config/check-session-secret'; // Fail fast on a malformed DATABASE_URL (most often a special char in @@ -26,6 +27,11 @@ assertEntraConfig(); // PKCE verifier today, session cookie next). const sessionSecret = assertSessionSecret(); +// REDIS_URL is the shared session / cache backend (ADR-0010). Boot- +// time guard so a malformed URL fails before `ioredis` enters its +// reconnect loop. +assertRedisConfig(); + async function bootstrap() { // `bufferLogs: true` holds early-bootstrap log lines until the // Pino-based Logger is wired in below, so we don't lose anything diff --git a/apps/portal-bff/src/redis/redis.module.spec.ts b/apps/portal-bff/src/redis/redis.module.spec.ts new file mode 100644 index 0000000..4ecbfd9 --- /dev/null +++ b/apps/portal-bff/src/redis/redis.module.spec.ts @@ -0,0 +1,42 @@ +import { Test } from '@nestjs/testing'; +import IORedis from 'ioredis'; +import { LoggerModule } from 'nestjs-pino'; +import { RedisModule } from './redis.module'; +import { REDIS_CLIENT } from './redis.token'; + +const ORIGINAL_REDIS_URL = process.env['REDIS_URL']; + +async function compile() { + return Test.createTestingModule({ + imports: [LoggerModule.forRoot({ pinoHttp: { level: 'silent' } }), RedisModule], + }).compile(); +} + +describe('RedisModule', () => { + afterEach(async () => { + if (ORIGINAL_REDIS_URL === undefined) { + delete process.env['REDIS_URL']; + } else { + process.env['REDIS_URL'] = ORIGINAL_REDIS_URL; + } + }); + + it('provides an ioredis client via the REDIS_CLIENT token', async () => { + // A well-formed but unreachable URL — `ioredis` constructs the + // client lazily so the test never opens a real socket. + process.env['REDIS_URL'] = 'redis://default:test-pass@127.0.0.1:65535/0'; + const ref = await compile(); + try { + const client = ref.get(REDIS_CLIENT); + expect(client).toBeInstanceOf(IORedis); + } finally { + ref.get(REDIS_CLIENT).disconnect(); + await ref.close(); + } + }); + + it('fails to compile when REDIS_URL is missing', async () => { + delete process.env['REDIS_URL']; + await expect(compile()).rejects.toThrow(/REDIS_URL is not set/); + }); +}); diff --git a/apps/portal-bff/src/redis/redis.module.ts b/apps/portal-bff/src/redis/redis.module.ts new file mode 100644 index 0000000..6868be6 --- /dev/null +++ b/apps/portal-bff/src/redis/redis.module.ts @@ -0,0 +1,65 @@ +import { Module } from '@nestjs/common'; +import IORedis from 'ioredis'; +import { Logger } from 'nestjs-pino'; +import { assertRedisConfig } from '../config/check-redis-config'; +import { REDIS_CLIENT, type Redis } from './redis.token'; + +/** + * Redis module — owns the single shared `ioredis` connection that + * downstream consumers (session store, OBO token cache, …) inject + * via the `REDIS_CLIENT` token. + * + * Per ADR-0010: + * - Single instance for dev (one `REDIS_URL`); Sentinel-HA for + * prod (designed-in, plumbing lands with the production + * infrastructure ADR). + * - Connection is eager — ioredis opens the socket lazily but the + * factory builds the client at module init so DI consumers + * always receive an instance. + * - Connect / error / reconnect events route into Pino with the + * `redis` context so log search isolates them from app logs. + * - Single shared client. Pub/sub use-cases (when they appear) + * duplicate the connection via `redis.duplicate()` per ioredis + * convention. + * + * Module stays non-global; consumers state "I depend on Redis" by + * importing it explicitly. + */ +@Module({ + providers: [ + { + provide: REDIS_CLIENT, + inject: [Logger], + useFactory: (logger: Logger): Redis => { + const config = assertRedisConfig(); + const client = new IORedis(config.url, { + // Cap reconnect storms during bootstrap: if Redis is + // unreachable we want the BFF to surface a clear error on + // the first command path, not a never-ending reconnect + // loop hiding the issue. + maxRetriesPerRequest: 3, + }); + + client.on('connect', () => { + logger.log({ event: 'redis.connect', host: config.host, port: config.port }, 'redis'); + }); + client.on('ready', () => { + logger.log({ event: 'redis.ready' }, 'redis'); + }); + client.on('error', (err) => { + logger.error({ event: 'redis.error', message: err.message }, 'redis'); + }); + client.on('close', () => { + logger.warn({ event: 'redis.close' }, 'redis'); + }); + client.on('reconnecting', (delayMs: number) => { + logger.warn({ event: 'redis.reconnecting', delayMs }, 'redis'); + }); + + return client; + }, + }, + ], + exports: [REDIS_CLIENT], +}) +export class RedisModule {} diff --git a/apps/portal-bff/src/redis/redis.token.ts b/apps/portal-bff/src/redis/redis.token.ts new file mode 100644 index 0000000..38183a6 --- /dev/null +++ b/apps/portal-bff/src/redis/redis.token.ts @@ -0,0 +1,13 @@ +import type Redis from 'ioredis'; + +/** + * DI token for the shared `ioredis` connection used across the + * BFF: today the session store (`connect-redis` lands in the next + * PR), tomorrow the OBO token cache (ADR-0014). + * + * Usage: + * @Inject(REDIS_CLIENT) private readonly redis: Redis + */ +export const REDIS_CLIENT = 'REDIS_CLIENT'; + +export type { Redis }; diff --git a/package.json b/package.json index f01eb70..d6a6d1a 100644 --- a/package.json +++ b/package.json @@ -147,6 +147,7 @@ "class-transformer": "^0.5.1", "class-validator": "^0.15.1", "cookie-parser": "^1.4.7", + "ioredis": "^5.10.1", "lucide-angular": "^1.0.0", "nestjs-cls": "^6.2.0", "nestjs-pino": "^4.6.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 923563f..bfaf158 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -119,6 +119,9 @@ importers: cookie-parser: specifier: ^1.4.7 version: 1.4.7 + ioredis: + specifier: ^5.10.1 + version: 5.10.1 lucide-angular: specifier: ^1.0.0 version: 1.0.0(@angular/common@21.2.12(@angular/core@21.2.12(@angular/compiler@21.2.12)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@21.2.12(@angular/compiler@21.2.12)(rxjs@7.8.2)(zone.js@0.16.2)) @@ -1882,6 +1885,9 @@ packages: '@types/node': optional: true + '@ioredis/commands@1.5.1': + resolution: {integrity: sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==} + '@isaacs/cliui@8.0.2': resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} @@ -5326,6 +5332,10 @@ packages: resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} engines: {node: '>=0.8'} + cluster-key-slot@1.1.2: + resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==} + engines: {node: '>=0.10.0'} + co@4.6.0: resolution: {integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==} engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'} @@ -5719,6 +5729,10 @@ packages: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} + denque@2.1.0: + resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} + engines: {node: '>=0.10'} + depd@1.1.2: resolution: {integrity: sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==} engines: {node: '>= 0.6'} @@ -6658,6 +6672,10 @@ packages: intl-messageformat@10.7.18: resolution: {integrity: sha512-m3Ofv/X/tV8Y3tHXLohcuVuhWKo7BBq62cqY15etqmLxg2DZ34AGGgQDeR+SCta2+zICb1NX83af0GJmbQ1++g==} + ioredis@5.10.1: + resolution: {integrity: sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA==} + engines: {node: '>=12.22.0'} + ip-address@10.2.0: resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==} engines: {node: '>= 12'} @@ -7395,9 +7413,15 @@ packages: lodash.debounce@4.0.8: resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==} + lodash.defaults@4.2.0: + resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==} + lodash.includes@4.3.0: resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} + lodash.isarguments@3.1.0: + resolution: {integrity: sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==} + lodash.isboolean@3.0.3: resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} @@ -8594,6 +8618,14 @@ packages: resolution: {integrity: sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ==} engines: {node: '>= 10.13.0'} + redis-errors@1.2.0: + resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==} + engines: {node: '>=4'} + + redis-parser@3.0.0: + resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==} + engines: {node: '>=4'} + reflect-metadata@0.2.2: resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==} @@ -9159,6 +9191,9 @@ packages: stackframe@1.3.4: resolution: {integrity: sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw==} + standard-as-callback@2.1.0: + resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==} + statuses@1.5.0: resolution: {integrity: sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==} engines: {node: '>= 0.6'} @@ -11916,6 +11951,8 @@ snapshots: optionalDependencies: '@types/node': 24.12.4 + '@ioredis/commands@1.5.1': {} + '@isaacs/cliui@8.0.2': dependencies: string-width: 5.1.2 @@ -16022,6 +16059,8 @@ snapshots: clone@1.0.4: {} + cluster-key-slot@1.1.2: {} + co@4.6.0: {} code-block-writer@12.0.0: {} @@ -16397,6 +16436,8 @@ snapshots: delayed-stream@1.0.0: {} + denque@2.1.0: {} + depd@1.1.2: {} depd@2.0.0: {} @@ -17467,6 +17508,20 @@ snapshots: '@formatjs/icu-messageformat-parser': 2.11.4 tslib: 2.8.1 + ioredis@5.10.1: + dependencies: + '@ioredis/commands': 1.5.1 + cluster-key-slot: 1.1.2 + debug: 4.4.3 + 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: {} ip-regex@4.3.0: {} @@ -18562,8 +18617,12 @@ snapshots: lodash.debounce@4.0.8: {} + lodash.defaults@4.2.0: {} + lodash.includes@4.3.0: {} + lodash.isarguments@3.1.0: {} + lodash.isboolean@3.0.3: {} lodash.isinteger@4.0.4: {} @@ -19980,6 +20039,12 @@ snapshots: dependencies: resolve: 1.22.12 + redis-errors@1.2.0: {} + + redis-parser@3.0.0: + dependencies: + redis-errors: 1.2.0 + reflect-metadata@0.2.2: {} regenerate-unicode-properties@10.2.2: @@ -20612,6 +20677,8 @@ snapshots: stackframe@1.3.4: {} + standard-as-callback@2.1.0: {} + statuses@1.5.0: {} statuses@2.0.2: {}