feat(portal-bff): redis client foundation per ADR-0010 #109

Merged
julien merged 1 commits from feat/portal-bff/session-redis-client into main 2026-05-12 16:48:21 +02:00
Owner

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 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 — 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.tsREDIS_CLIENT string token + Redis type alias. Same shape as the existing ENTRA_CONFIG / MSAL_CLIENT.
  • redis.module.tsRedisModule 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.connectredis.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.
## 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.
julien added 1 commit 2026-05-12 16:47:40 +02:00
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
7a313f627c
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.
julien merged commit d4b5ed1c5d into main 2026-05-12 16:48:21 +02:00
julien deleted branch feat/portal-bff/session-redis-client 2026-05-12 16:48:23 +02:00
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: julien/apf_portal#109