Files
apf_portal/apps/portal-bff/.env.example
T
julien 883c5151de
CI / commits (push) Has been skipped
CI / scan (push) Successful in 3m20s
CI / check (push) Successful in 4m2s
CI / a11y (push) Successful in 1m23s
CI / perf (push) Successful in 6m0s
Docs site / build (push) Successful in 2m56s
feat(portal-bff): ai-bridge controller — SSE chat + JSON rag/models (#196)
## Summary

Step 3 of the AI-relay chantier (after #194 ADR and #195 client skeleton). Wires the BFF-side **live surface** that the SPA's future chatbot widget will consume. [ADR-0024](docs/decisions/0024-ai-service-relay-grpc-sse-bridge.md) is promoted from `proposed` to `accepted` in the same change.

Three end-user routes under `/api/ai/*`, gated by the active portal session (no `@RequireAdmin` — AI is a regular-user surface):

| Route | Verb | Wire | Maps to |
|---|---|---|---|
| `/api/ai/chat` | `POST` | `text/event-stream` | `apf.ai.v1.ChatService.Chat` (server-stream) |
| `/api/ai/rag/search` | `GET` | `application/json` | `apf.ai.v1.RagService.Search` (unary) |
| `/api/ai/models` | `GET` | `application/json` | `apf.ai.v1.ModelsService.ListModels` (unary) |

CSRF and session validation are delegated to the global middleware mounted in `main.ts` (per [ADR-0009](docs/decisions/0009-auth-flow-oidc-pkce-msal-node.md) and [ADR-0021](docs/decisions/0021-phase-2-security-baseline.md)); the controller asserts `req.session.user` and emits 401 if absent.

## What lands

### `apps/portal-bff/src/grpc/ai-bridge/`

```
ai-bridge/
├── ai-bridge.module.ts         imports AiClientModule, exports the controller
├── ai-bridge.controller.ts     3 routes — POST chat (SSE), GET rag/search, GET models
├── sse.writer.ts               ChatEvent oneof → SSE frame translator
├── sse.writer.spec.ts          unit tests for the codec
├── ai-bridge.controller.spec.ts  end-to-end against an in-process fake gRPC server
└── dto/
    ├── chat-request.dto.ts     class-validator body shape (POST /chat)
    └── rag-search-query.dto.ts class-validator query shape (GET /rag/search)
```

### SSE codec (`sse.writer.ts`)

Each `ChatEvent` oneof case becomes one SSE frame with a kebab-case `event:` name and a JSON-encoded `data:` payload:

```
event: token
data: {"token":"…","value":"…"}

event: agent-step
data: {"agent":"…","step":"…","stepId":"…"}

event: tool-call
data: {"callId":"…","name":"…","args":{…}}

event: done
data: {"stats":{"tokensIn":…,"tokensOut":…,"chunksRetrieved":…}}
```

A helper `relayErrorFrame(code, message, retriable)` synthesises a relay-side `event: error` frame that matches the AI service's own `ErrorEvent` shape — the SPA's renderer needs no second code path for relay-level failures vs upstream model errors. gRPC status codes map into the `urn:apf-ai:*` namespace (`UNAVAILABLE` → `urn:apf-ai:unavailable`, `DEADLINE_EXCEEDED` → `urn:apf-ai:timeout`, `PERMISSION_DENIED` → `urn:apf-ai:permission_denied`, `RESOURCE_EXHAUSTED` → `urn:apf-ai:rate_limited`, `INVALID_ARGUMENT` → `urn:apf-ai:invalid_argument`, anything else → `urn:apf-ai:relay_error`).

The terminal `done` frame closes the stream — no `[DONE]` sentinel, per ADR-0024.

### Controller (`ai-bridge.controller.ts`)

- `POST /api/ai/chat` — builds an `apf.ai.v1.ChatRequest` from the validated DTO + session-derived Principal, calls `ChatClient.chat()`, drains the `ClientReadableStream<ChatEvent>` into SSE frames written on the raw Express `Response`. `req.on('close', …)` propagates browser disconnect through an `AbortController` into `call.cancel()` so the upstream LLM stops (per `apf-ai-service/docs/streaming.md`).
- `GET /api/ai/rag/search` — unary RAG call. `topK` defaults to 0 (server picks the default). `source` and `documentId` query params surface the same filter fields the upstream RPC accepts.
- `GET /api/ai/models` — unary lookup of the provider catalogue.

The SSE writes happen on the raw Express response (manual `setHeader` + `flushHeaders` + `write` + `end`) rather than through NestJS's `@Sse()` decorator, because `@Sse()` is GET-only and the chat endpoint is POST (the SPA carries the conversation history in the body).

### Lifecycle hooks

`AiClientModule` now implements `OnApplicationShutdown` and closes the four gRPC stubs (Chat / Rag / Ingestion / Models). The four stubs share the same HTTP/2 channel (gRPC-js dedups on `endpoint + credentials`), so the `close()` calls are cheap, but kept explicit so adding a fifth stub later is an obvious one-line addition. `main.ts` now calls `app.enableShutdownHooks()` so `SIGTERM` / `SIGINT` / `SIGHUP` actually route through the lifecycle interface.

### DTOs

`ChatRequestDto` constrains:
- `messages` — 1 to 64 entries; each has `role ∈ {user, assistant, system}` (no `tool` — tool messages are constructed BFF-side per ADR-0024 §"Tool-dispatch contract") and `content` ≤ 16 KB.
- `conversationId`, `model`, `provider` — optional, ≤ 64 / 128 chars.

`RagSearchQueryDto`:
- `query` — required, non-empty.
- `topK` — optional, integer in `[1, 50]` (the AI service has its own cap; the BFF rejects out-of-range values early).
- `source` / `documentId` — optional pass-through filters.

### Documentation

- ADR-0024 frontmatter: `status: proposed` → `accepted`.
- `docs/decisions/README.md` index reflects the new status.
- `CLAUDE.md` Architecture section grows an "AI service relay" bullet; the roll-up line moves from "ADRs 0001 → 0023" to "0001 → 0024"; the shipped-on-main list grows an "AI relay surface" entry.
- `apps/portal-bff/.env.example` documents `AI_SERVICE_GRPC_ENDPOINT` / `AI_SERVICE_CLIENT_ID` / `AI_SERVICE_GRPC_TLS` and points operators at `apf-ai-service`'s own docker-compose for the runtime dependency.

## Notes for the reviewer

- **No live AI service in this PR's local-dev stack.** `apf-ai-service` runs from its own repo (`/home/jgautier/Works/apf-ai-service`) with its own `infra/docker-compose.yml`. The BFF dials `localhost:8080` by default — the host-published port of the AI service's container. This is option (a) from ADR-0024 §"Open question — Compose orchestration": two independent stacks, dial across via host networking. Merging the compose files into one would couple two release cadences without operational payoff.
- **Tests run against an in-process fake `grpc.Server`.** All five spec cases on the controller wire it up against a fake `ChatService` + `RagService` + `ModelsService` server bound to `127.0.0.1:0` (random port). No mocks — the controller's gRPC client makes a real connection, real serialisation, real cancellation propagation. Cost: ~0.5 s overhead from the gRPC server setup.
- **CSRF + session middleware are unchanged.** The new POST endpoint is protected by the existing double-submit CSRF middleware mounted in `main.ts` (per [ADR-0021](docs/decisions/0021-phase-2-security-baseline.md)). The SPA's fetch call needs to send the `X-CSRF-Token` header matching the `__Host-portal_csrf` cookie — same protocol as every other POST in the BFF. No per-controller wiring required.
- **Manual session check rather than a guard.** Three reasons: (1) matches the existing pattern in `me.controller.ts`; (2) the session check is the only authorization gate (no roles to evaluate) — a guard would add ceremony without payoff; (3) the SSE controller already takes control of the response object (`@Res()`), which `UseGuards` interacts with awkwardly. Throwing `UnauthorizedException` lets `StructuredErrorFilter` produce the 401 envelope before any header is flushed.
- **Why the controller does NOT use `@Sse()`.** NestJS's `@Sse()` decorator is GET-only and emits frames from `Observable<MessageEvent>`. The chat endpoint is POST (the SPA sends conversation history in the body) and the source is a Node `Readable` stream from `@grpc/grpc-js`. Manual response handling is simpler than adapting to / from `Observable` for a single consumer.
- **Cancellation contract.** When the SPA aborts the fetch, the browser closes the TCP connection, Express emits `'close'` on the request, the controller's `AbortController.abort()` triggers, `ChatClient` calls `.cancel()` on the gRPC stream, the AI service's `ServerCallContext.CancellationToken` cancels the upstream LLM. The spec covers the `'close'` → server-side `cancelled` event end-to-end.
- **No ingestion route in the BFF.** Per ADR-0024 §"Out of scope", v1 admin ingestion uses the `apf-ai-service/tools/Apf.Ai.Ingest/` CLI. A future PR adds the BFF endpoint when the admin "manage AI corpus" surface ships. `IngestionClient` remains in `AiClientModule` so that future PR is one new file, not a new module plus a new client.
- **No bundle-size or perf surprise.** The BFF is a Node process, not a SPA chunk — bundle budgets don't apply. The gRPC channel is opened lazily on first call; idle BFFs incur no upstream TCP cost.

## Test plan

- [x] `pnpm nx test portal-bff` — **461 specs pass** (was 443; +13 new: 8 SSE writer cases + 5 controller end-to-end cases against the in-process fake server). Worker-exit-leak warning persists from the gRPC server's slow shutdown — pre-existing pattern from PR #195; harmless.
- [x] `pnpm nx lint portal-bff` — 6 pre-existing warnings, no new ones from the diff.
- [x] `pnpm nx build portal-bff` — clean webpack compile.
- [x] Module wiring: `AppModule` imports `AiBridgeModule`, which imports `AiClientModule`. Resolves cleanly through DI; the audit-side `HashUserIdService` is satisfied by `AiClientModule`'s local provider (per the rationale recorded in PR #195's `AiClientModule` docstring).
- [ ] **Manual smoke** — bring up `apf-ai-service` from its own repo (`cd ../apf-ai-service && docker compose -f infra/docker-compose.yml up`), set `AI_SERVICE_GRPC_ENDPOINT=localhost:8080` in `apps/portal-bff/.env`, run `pnpm nx serve portal-bff`. Sign in to `portal-shell`, then in a terminal:
  ```bash
  curl --cookie-jar /tmp/portal-session http://localhost:3000/api/auth/login    # follow Entra…
  curl -N \
       -H 'Content-Type: application/json' \
       -H 'X-CSRF-Token: <copied from cookie>' \
       --cookie /tmp/portal-session \
       -d '{"messages":[{"role":"user","content":"hello"}]}' \
       http://localhost:3000/api/ai/chat
  ```
  Expect a streamed SSE response terminated by an `event: done` frame. Verify `GET /api/ai/rag/search?query=test` returns a JSON response. Verify `GET /api/ai/models` lists the configured providers.

## What's next

1. **PR (frontend chantier)** — chatbot widget on `portal-shell` consuming the SSE endpoint. Will use `fetch` + `ReadableStream` parsing (not native `EventSource`, since POST is needed). Drag / fullscreen / suggestion UX carries forward from the stargate POC's `ChatbotWidget.tsx`.
2. **PR (post-v1)** — proto-drift CI gate that diffs `proto/apf-ai/` against an upstream tag of `apf-ai-service`.
3. **Coordinated amendment** — when the first production deployment is in scope, both repos record the same prod-hardening choice (signed `Principal` envelope vs mTLS) on the same date.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #196
2026-05-19 22:39:35 +02:00

222 lines
11 KiB
Bash
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# BFF environment template
# Copy to .env (which is gitignored) and fill in actual values for local development.
# Production values are managed by the platform's secret manager (see future infrastructure ADR).
# Postgres connection (per ADR-0006)
# Local dev default: dockerised Postgres on port 5432, schema 'public'.
# Username / password / db must match infra/local/.env (POSTGRES_USER /
# POSTGRES_PASSWORD / POSTGRES_DB) — those are the source of truth,
# this is the BFF view of the same connection.
#
# IMPORTANT — URL encoding. The password is part of the URL userinfo
# segment, so any of these characters must be URL-encoded:
# @ → %40 # → %23 : → %3A / → %2F ? → %3F
# % → %25 & → %26 = → %3D + → %2B ; → %3B
# i.e. if your POSTGRES_PASSWORD is "p@ss#1", DATABASE_URL must read
# "postgresql://portal:p%40ss%231@localhost:5432/portal_dev?schema=public"
# The BFF aborts at boot with a clear error if it detects an unencoded
# special character (see apps/portal-bff/src/config/check-database-url.ts).
DATABASE_URL="postgresql://portal:portal_dev_change_me@localhost:5432/portal_dev?schema=public"
# Observability (per ADR-0012)
# All OTEL_* keys are honoured by the OpenTelemetry SDK directly — see
# apps/portal-bff/src/observability/tracing.ts for the bootstrap.
# Pino log level: 'info' in prod, 'debug' in dev (default if unset).
LOG_LEVEL=debug
OTEL_SERVICE_NAME=portal-bff
OTEL_SERVICE_VERSION=dev
# Default endpoint targets the Collector provisioned in
# infra/local/dev.compose.yml. The /v1/traces suffix is required by
# the HTTP/Protobuf transport.
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318/v1/traces
OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
# v1 samples 100 % at the app; tail sampling is delegated to the
# Collector (per ADR-0012). Override only for spike investigations.
OTEL_TRACES_SAMPLER=always_on
# Identity / Entra ID app registration (per ADR-0008 / ADR-0009)
# Values come from the project's Entra application registration in the
# Azure Admin Center → App registrations → APF Portal. The four
# *_INSTANCE_URL / *_TENANT_ID / *_CLIENT_ID / *_CLIENT_SECRET keys
# are mandatory; the BFF refuses to boot without them (see
# apps/portal-bff/src/config/check-entra-config.ts).
#
# ENTRA_INSTANCE_URL is the Microsoft login endpoint — usually
# https://login.microsoftonline.com/. The authority used by MSAL is
# `${ENTRA_INSTANCE_URL}${ENTRA_TENANT_ID}` for single-tenant flows,
# or `${ENTRA_INSTANCE_URL}organizations` / `common` for multi-tenant
# (per ADR-0008's dual-audience design). v1 uses the tenant-scoped
# authority; the multi-tenant switch lands when External ID activation
# is needed.
#
# ENTRA_CLIENT_SECRET is the high-value secret of this set. Never
# commit a real value. Production manages it via the deploy platform's
# secret manager (future infrastructure ADR).
ENTRA_INSTANCE_URL=https://login.microsoftonline.com/
ENTRA_TENANT_ID=00000000-0000-0000-0000-000000000000
ENTRA_CLIENT_ID=00000000-0000-0000-0000-000000000000
ENTRA_CLIENT_SECRET=replace_with_real_value
# Redirect URIs registered in Entra alongside the same client id.
# User portal — `/api/auth/callback` is the OIDC return URL; the
# post-logout URL is where Entra sends the browser after RP-initiated
# logout (typically the SPA landing page).
ENTRA_REDIRECT_URI=http://localhost:3000/api/auth/callback
ENTRA_POST_LOGOUT_REDIRECT_URI=http://localhost:4200/
# Admin portal — distinct callback per ADR-0020 §"Sessions — distinct
# from `portal-shell`" so Entra routes the response to the matching
# session. Both `ENTRA_REDIRECT_URI` and `ENTRA_ADMIN_REDIRECT_URI`
# must be registered in the same Entra app registration's
# "Redirect URIs" list. Distinct post-logout URL routes admin
# sign-outs to the admin SPA's landing page (port 4300 in dev — see
# apps/portal-admin/project.json `serve.options.port`).
ENTRA_ADMIN_REDIRECT_URI=http://localhost:3000/api/admin/auth/callback
ENTRA_ADMIN_POST_LOGOUT_REDIRECT_URI=http://localhost:4300/
# Cookie signing secret (per ADR-0009 §"Cookies"). Used to sign the
# transient pre-auth cookie that carries the OIDC `state` + PKCE
# verifier between the /auth/login redirect and the /auth/callback
# round-trip, and (once ADR-0010 ships) the session cookie's
# integrity layer. Mandatory at boot — the BFF aborts if missing or
# obviously weak (less than 32 base64-decoded bytes ≈ 256 bits of
# entropy). Generate a fresh value per environment:
#
# 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
# Session payload encryption (per ADR-0010 §"At-rest encryption").
# AES-256-GCM key for encrypting the session JSON that connect-redis
# writes to Redis, so a Redis dump never carries raw user identities
# / future tokens / claims in plaintext. **Distinct** from
# SESSION_SECRET, which only signs the cookie's session-id — never
# reuse one for the other. Mandatory at boot.
#
# node -e "console.log(require('crypto').randomBytes(32).toString('base64url'))"
SESSION_ENCRYPTION_KEY=replace_with_32_random_bytes_base64url
# OBO downstream-token cache encryption (per ADR-0014 §"Token cache
# (for OBO)"). AES-256-GCM key for encrypting Entra-issued downstream
# tokens cached in Redis under `obo:{actor_id_hash}:{resource}`.
# **Dedicated key** — must differ from SESSION_ENCRYPTION_KEY so a
# leak of one does not cascade into the other. The boot validator
# refuses an identical value. Mandatory at boot.
#
# node -e "console.log(require('crypto').randomBytes(32).toString('base64url'))"
OBO_CACHE_ENCRYPTION_KEY=replace_with_32_random_bytes_base64url
# BFF JWKS signing material (per ADR-0014 §"Service strategy"). The
# BFF mints short-lived `X-User-Assertion` JWTs to propagate user
# identity to non-Entra downstreams; downstreams verify the signature
# against `/.well-known/jwks.json`. Both values are mandatory at boot.
#
# Generate an RSA private key:
# mkdir -p apps/portal-bff/.secrets && \
# openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:3072 \
# -out apps/portal-bff/.secrets/jwks.pem
#
# RSA-2048 is the minimum the validator accepts; 3072 is the
# default recommendation. EC P-256 / P-384 also accepted.
BFF_JWKS_PRIVATE_KEY_PATH=apps/portal-bff/.secrets/jwks.pem
# Stable key id published in the JWKS + emitted in the JWT `kid`
# header. URL-safe charset only ([A-Za-z0-9_-], 4128 chars). Bump
# this when rotating to a new key.
BFF_JWKS_KID=bff-2026-05
# Session timeouts (per ADR-0010). Both optional with sensible
# defaults; override only when staging / prod policy diverges.
# SESSION_IDLE_TIMEOUT_SECONDS — sliding window. Each request
# extends the cookie's `expires` by this many seconds.
# Default 1800 (30 min).
# SESSION_ABSOLUTE_TIMEOUT_SECONDS — hard ceiling. Session is
# destroyed regardless of activity at this age.
# Default 43200 (12 h).
# SESSION_IDLE_TIMEOUT_SECONDS=1800
# SESSION_ABSOLUTE_TIMEOUT_SECONDS=43200
# Per-environment salt used to pseudonymise the user id before it
# lands in audit rows (per ADR-0013 §"Schema") and in Pino app log
# lines (per ADR-0012 §"User id hashing"). Same value must be used
# on both sides so audit and app logs join on `actor_id_hash`.
#
# Rotation invalidates the join key — old rows / log lines can no
# longer be correlated with the new hash. Treat as long-lived per
# environment. Mandatory at boot.
#
# node -e "console.log(require('crypto').randomBytes(32).toString('base64url'))"
LOG_USER_ID_SALT=replace_with_32_random_bytes_base64url
# CORS allowlist (per ADR-0009 §"CORS"). Comma-separated list of
# origins (scheme://host[:port]) allowed to call the BFF with
# credentials. The BFF refuses to start without this — silently
# defaulting to localhost is the classic "works in dev, breaks in
# prod" trap.
#
# Local dev: portal-shell on :4200 and portal-admin on :4300 — both
# call the BFF with credentials. Source of truth for the ports:
# `apps/<app>/project.json` `serve.options.port`.
CORS_ALLOWED_ORIGINS=http://localhost:4200,http://localhost:4300
# Rate limiting (per ADR-0015 §"DoS mitigation"). Both optional
# with conservative defaults; override per environment when traffic
# patterns demand it. The BFF keys buckets by session id when the
# request is authenticated, by remote IP otherwise — rotating
# sessions doesn't dodge the limit.
# RATE_LIMIT_PER_MINUTE — general bucket. Default 120 (~ 2 r/s).
# RATE_LIMIT_AUTH_PER_MINUTE — stricter bucket on /auth/login and
# /auth/callback. Default 10/min, to
# slow brute-force / replay loops
# without inconveniencing legit users.
# RATE_LIMIT_PER_MINUTE=120
# RATE_LIMIT_AUTH_PER_MINUTE=10
# Future env vars introduced by upcoming phases / ADRs:
#
# Auth flow (ADR-0009) — additional keys wired as the routes land:
# ENTRA_CLIENT_CERT_PATH (alternative to ENTRA_CLIENT_SECRET)
# ENTRA_ACCEPTED_TENANT_IDS (CSV; restricts which tenants can sign in
# in the multi-tenant phase — empty means
# "only ENTRA_TENANT_ID is accepted")
#
# 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)
#
# MFA (ADR-0011):
# MFA_FRESHNESS_SECONDS (default 600)
#
# Audit trail (ADR-0013):
# AUDIT_DATABASE_URL (separate creds, role 'audit_writer')
# AUDIT_ARCHIVER_DATABASE_URL (role 'audit_archiver', for the retention purge job)
# AUDIT_RETENTION_DAYS (default 365)
#
# Downstream API access (ADR-0014):
# OBO_CACHE_ENCRYPTION_KEY — wired
# BFF_JWKS_PRIVATE_KEY_PATH — wired
# BFF_JWKS_KID — wired
# <SERVICE>_API_BASE_URL (per integrated downstream — lands with the first integration)
# <SERVICE>_TIMEOUT_MS (optional, defaults to 5000 — lands with the first integration)
# AI service relay (ADR-0024) — the BFF dials apf-ai-service over
# native gRPC HTTP/2 and bridges chat streams to SSE for the SPA.
# apf-ai-service runs from its own repo (../apf-ai-service); use
# that repo's docker-compose.yml to bring it up locally, then point
# AI_SERVICE_GRPC_ENDPOINT here at the host-published port. No
# JWT/auth on the wire in v1 — the Principal travels in the proto
# body (per ADR-0024 §"Sub-decision 4 — POC unsigned principal").
AI_SERVICE_GRPC_ENDPOINT=localhost:8080
AI_SERVICE_CLIENT_ID=apf-portal-dev
# Set to 'true' in preprod/prod (h2 + TLS via the edge proxy);
# 'false' in dev for h2c against the local apf-ai-service.
AI_SERVICE_GRPC_TLS=false