feat(portal-bff): redis client foundation per ADR-0010 (#109)
## 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:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user