feat(portal-bff): redis client foundation per ADR-0010
CI / commits (pull_request) Successful in 2m22s
CI / scan (pull_request) Successful in 2m33s
CI / check (pull_request) Successful in 4m3s
CI / a11y (pull_request) Successful in 1m50s
CI / perf (pull_request) Successful in 5m33s

First step toward Redis-backed sessions. Adds the shared `ioredis`
connection that every downstream consumer (session storage, OBO
token cache, …) will inject via the new `REDIS_CLIENT` DI token.

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 plumbing lands with the production
  infrastructure ADR.
- `.env.example` promotes `REDIS_URL` from its previous future-vars
  comment block into an active variable, with a default that
  matches `infra/local/.env` (REDIS_PASSWORD + REDIS_PORT). The
  Sentinel-style keys (`REDIS_SENTINEL_HOSTS`,
  `REDIS_SENTINEL_NAME`, `REDIS_TLS`) stay in the future-vars
  comment until the prod deploy lands.
- `apps/portal-bff/src/config/check-redis-config.ts` — boot-time
  guard mirroring the existing four:
  `assertDatabaseUrl` / `assertEntraConfig` /
  `assertSessionSecret`. Refuses to start if `REDIS_URL` is unset,
  not a valid `redis://` / `rediss://` URL, missing the password
  (the local stack requires one), or still set to the
  `redis_dev_change_me` .env.example placeholder. Returns a typed
  `RedisConfig` with parsed `host` + `port` for downstream
  observability.
- `apps/portal-bff/src/redis/redis.token.ts` — `REDIS_CLIENT`
  string token + `Redis` type alias. Same shape as
  `ENTRA_CONFIG` / `MSAL_CLIENT`.
- `apps/portal-bff/src/redis/redis.module.ts` — `RedisModule`
  exposes a factory provider for `REDIS_CLIENT`. The factory
  builds the `ioredis` client from the parsed config, caps
  `maxRetriesPerRequest` at 3 (so an unreachable Redis surfaces a
  command-time error instead of an infinite reconnect storm), and
  wires `connect` / `ready` / `error` / `close` / `reconnecting`
  events into the Pino stream with the `redis` Pino context.
  Non-global on purpose — modules import it to state "I depend on
  Redis".
- `main.ts` calls `assertRedisConfig()` alongside the other three
  validators. `AppModule` imports `RedisModule`.

Verification:

- `nx run-many -t lint test build --projects=portal-bff` — green.
- 62 / 62 specs (was 52; +10 across the config validator spec and
  the module spec — the latter exercises both the happy path
  against an unreachable URL — `ioredis` constructs lazily so no
  real socket opens — and the missing-env failure mode).
- Boot smoke (with the local Compose stack running): the `redis`
  Pino context shows `redis.connect` → `redis.ready` lines on
  startup; killing the Redis container later produces
  `redis.close` / `redis.reconnecting`.

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 + 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.
This commit is contained in:
Julien Gautier
2026-05-12 16:36:29 +02:00
parent bfa35d3283
commit 7a313f627c
10 changed files with 356 additions and 3 deletions
@@ -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<IORedis>(REDIS_CLIENT);
expect(client).toBeInstanceOf(IORedis);
} finally {
ref.get<IORedis>(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/);
});
});
+65
View File
@@ -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 {}
+13
View File
@@ -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 };