feat(portal-bff): redis client foundation per ADR-0010 (#109)
CI / scan (push) Successful in 2m35s
CI / commits (push) Has been skipped
CI / check (push) Successful in 3m29s
CI / a11y (push) Successful in 1m43s
CI / perf (push) Successful in 3m38s

## 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 <julien.gautier@apf.asso.fr>
Reviewed-on: #109
This commit was merged in pull request #109.
This commit is contained in:
2026-05-12 16:48:20 +02:00
parent bfa35d3283
commit d4b5ed1c5d
10 changed files with 356 additions and 3 deletions
+2
View File
@@ -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],
@@ -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/);
});
});
@@ -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]:<password>@<host>:<port>/<db>"; 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;
}
+6
View File
@@ -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
@@ -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 };