883c5151de
## 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
241 lines
11 KiB
TypeScript
241 lines
11 KiB
TypeScript
// MUST be the very first import — see apps/portal-bff/src/observability/tracing.ts
|
|
// for the reasoning. Anything `import`ed above this line bypasses the
|
|
// OpenTelemetry auto-instrumentations and is silently un-traced.
|
|
import './observability/tracing';
|
|
|
|
import { ValidationPipe } from '@nestjs/common';
|
|
import { NestFactory } from '@nestjs/core';
|
|
import cookieParser from 'cookie-parser';
|
|
import helmet from 'helmet';
|
|
import { Logger } from 'nestjs-pino';
|
|
import { AppModule } from './app/app.module';
|
|
import { readCorsAllowlist } from './config/check-cors-allowlist';
|
|
import { assertDatabaseUrl } from './config/check-database-url';
|
|
import { assertEntraConfig } from './config/check-entra-config';
|
|
import { assertJwksConfig } from './config/check-jwks-config';
|
|
import { assertRedisConfig } from './config/check-redis-config';
|
|
import { assertLogUserIdSalt } from './config/check-log-user-id-salt';
|
|
import { assertOboCacheEncryptionKey } from './config/check-obo-cache-encryption-key';
|
|
import { assertSessionEncryptionKey } from './config/check-session-encryption-key';
|
|
import { assertSessionSecret } from './config/check-session-secret';
|
|
import { createRateLimitMiddleware, readRateLimitConfig } from './security/rate-limit.middleware';
|
|
import { CSRF_MIDDLEWARE } from './security/security.token';
|
|
import { StructuredErrorFilter } from './security/structured-error.filter';
|
|
import { JwksPublisher } from './downstream/jwks.publisher';
|
|
import { setupOpenApi } from './openapi/openapi';
|
|
import type { NextFunction, Request, Response } from 'express';
|
|
import {
|
|
ADMIN_SESSION_MIDDLEWARE,
|
|
SESSION_ABSOLUTE_TIMEOUT_MIDDLEWARE,
|
|
SESSION_MIDDLEWARE,
|
|
type RequestHandler,
|
|
} from './session/session.token';
|
|
|
|
// Fail fast on a malformed DATABASE_URL (most often a special char in
|
|
// the password that needs URL-encoding) rather than letting Prisma
|
|
// surface a cryptic "invalid connection string" error mid-request.
|
|
assertDatabaseUrl();
|
|
|
|
// Same family of pre-flight check for the Entra app-registration env
|
|
// vars (per ADR-0009). Missing / placeholder values fail here rather
|
|
// than deep inside the first auth request.
|
|
assertEntraConfig();
|
|
|
|
// SESSION_SECRET signs the auth-flow cookies (pre-auth state +
|
|
// 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();
|
|
|
|
// SESSION_ENCRYPTION_KEY is the AES-256-GCM key for session payload
|
|
// at-rest encryption (ADR-0010). Same fail-fast policy as the other
|
|
// pre-flight validators — a missing / weak key here would only
|
|
// surface on the first authenticated request otherwise.
|
|
assertSessionEncryptionKey();
|
|
|
|
// LOG_USER_ID_SALT — per-environment SHA-256 salt for the actor-id
|
|
// hash that joins audit rows (ADR-0013) and Pino log lines
|
|
// (ADR-0012). Mandatory at boot.
|
|
assertLogUserIdSalt();
|
|
|
|
// OBO_CACHE_ENCRYPTION_KEY — dedicated AES-256-GCM key for the OBO
|
|
// downstream-token cache (ADR-0014 §"Token cache (for OBO)"). MUST
|
|
// differ from SESSION_ENCRYPTION_KEY — the validator refuses an
|
|
// identical value as defense in depth against copy-paste accidents.
|
|
assertOboCacheEncryptionKey();
|
|
|
|
// BFF_JWKS_PRIVATE_KEY_PATH + BFF_JWKS_KID — signing material for
|
|
// the ADR-0014 signed-assertion strategy. Reads the PEM file once
|
|
// here so a missing / unreadable / weak key fails the boot rather
|
|
// than the first downstream call. The same parsed config is
|
|
// re-used by `DownstreamModule`'s factory at app construction.
|
|
assertJwksConfig();
|
|
|
|
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
|
|
// emitted before `app.useLogger()`.
|
|
const app = await NestFactory.create(AppModule, { bufferLogs: true });
|
|
app.useLogger(app.get(Logger));
|
|
|
|
// Wire `SIGTERM` / `SIGINT` / `SIGHUP` to NestJS lifecycle hooks
|
|
// (`OnApplicationShutdown`). Without this call, modules that
|
|
// hold long-lived resources (gRPC channels to `apf-ai-service`
|
|
// per [ADR-0024](../../../docs/decisions/0024-ai-service-relay-grpc-sse-bridge.md),
|
|
// Redis clients, …) would only release them via OS-level
|
|
// descriptor reclaim at process death, which delays orderly
|
|
// termination on `pnpm nx serve` reload and on prod rolling
|
|
// restarts.
|
|
app.enableShutdownHooks();
|
|
|
|
// Global exception filter — normalises every 4xx/5xx response to
|
|
// `{ error: { code, message, traceId } }`. The Nest default
|
|
// serialises HttpException's getResponse() at the top level,
|
|
// which leaks the class name on 500s and produces an
|
|
// inconsistent shape across exception types. Registering early
|
|
// (before request middleware mounts) ensures even errors thrown
|
|
// during route setup are caught.
|
|
app.useGlobalFilters(new StructuredErrorFilter(app.get(Logger)));
|
|
|
|
// Security headers (phase-2). Defaults from `helmet()` are good
|
|
// for an API server returning JSON: X-Frame-Options=SAMEORIGIN,
|
|
// X-Content-Type-Options=nosniff, Referrer-Policy=no-referrer,
|
|
// X-Powered-By removed, etc. CSP defaults apply too but the BFF
|
|
// doesn't render HTML, so they're inert here.
|
|
//
|
|
// Three overrides for our specific shape:
|
|
// - HSTS only in production (dev runs on plain HTTP).
|
|
// - crossOriginResourcePolicy: 'cross-origin' so the SPA on its
|
|
// own origin can read JSON from the BFF without being blocked
|
|
// by Spectre-class CORP protections.
|
|
// - contentSecurityPolicy: false in dev — Helmet's default CSP
|
|
// blocks `connect-src` from anything but 'self', which is
|
|
// fine for HTML pages but irrelevant for JSON responses and
|
|
// noisy in browser devtools.
|
|
app.use(
|
|
helmet({
|
|
hsts: process.env['NODE_ENV'] === 'production',
|
|
crossOriginResourcePolicy: { policy: 'cross-origin' },
|
|
contentSecurityPolicy: process.env['NODE_ENV'] === 'production',
|
|
}),
|
|
);
|
|
|
|
// CORS allowlist — env-driven via `CORS_ALLOWED_ORIGINS`, parsed
|
|
// and validated at boot. No hardcoded localhost fallback: getting
|
|
// CORS wrong silently is exactly the kind of "works in dev, breaks
|
|
// in prod" issue this validator is meant to catch.
|
|
app.enableCors({
|
|
origin: [...readCorsAllowlist()],
|
|
allowedHeaders: [
|
|
'Content-Type',
|
|
'Accept',
|
|
'Authorization',
|
|
'X-CSRF-Token',
|
|
'traceparent',
|
|
'tracestate',
|
|
],
|
|
credentials: true,
|
|
});
|
|
|
|
app.useGlobalPipes(
|
|
new ValidationPipe({
|
|
whitelist: true,
|
|
forbidNonWhitelisted: true,
|
|
transform: true,
|
|
}),
|
|
);
|
|
|
|
// Cookie parsing for the auth flow (per ADR-0009). `SESSION_SECRET`
|
|
// signs the pre-auth cookie and the post-login session id cookie;
|
|
// signed cookies are read from `req.signedCookies`, unsigned from
|
|
// `req.cookies`.
|
|
app.use(cookieParser(sessionSecret));
|
|
|
|
// Session middlewares (per ADR-0010 + ADR-0020 §"Sessions — distinct
|
|
// from `portal-shell`"). Two parallel express-session instances:
|
|
//
|
|
// - `SESSION_MIDDLEWARE` carries `portal_session` / Redis prefix
|
|
// `session:` and binds to every path EXCEPT `/api/admin/*`.
|
|
// - `ADMIN_SESSION_MIDDLEWARE` carries `portal_admin_session` /
|
|
// Redis prefix `session:admin:` and binds to `/api/admin/*` only.
|
|
//
|
|
// The dispatch is a tiny wrapper that picks one or the other per
|
|
// request — running both would have the second overwrite `req.session`
|
|
// from the first, collapsing the two surfaces. Mounted after
|
|
// `cookieParser` so the session-id cookie is parsed by the time the
|
|
// selected middleware reads it.
|
|
const userSession = app.get<RequestHandler>(SESSION_MIDDLEWARE);
|
|
const adminSession = app.get<RequestHandler>(ADMIN_SESSION_MIDDLEWARE);
|
|
app.use((req: Request, res: Response, next: NextFunction) => {
|
|
if (req.path.startsWith('/api/admin')) {
|
|
return adminSession(req, res, next);
|
|
}
|
|
return userSession(req, res, next);
|
|
});
|
|
|
|
// Absolute-timeout enforcement (ADR-0010 §"TTL policy"). Runs on
|
|
// every request that survives `express-session`; if the session
|
|
// is past its 12 h hard ceiling, destroy it + clear the cookie +
|
|
// drop the per-user index entry, then let the request continue
|
|
// anonymously (route-level guards turn it into a 401 where
|
|
// needed).
|
|
app.use(app.get<RequestHandler>(SESSION_ABSOLUTE_TIMEOUT_MIDDLEWARE));
|
|
|
|
// Rate limiting (ADR-0015 §"DoS mitigation" + phase-2 follow-up).
|
|
// Mounted after the session middleware so the bucket key falls
|
|
// back to the session id for authenticated requests (preventing
|
|
// a single attacker from rotating sessions to dodge the limit)
|
|
// and to the remote IP otherwise. Default 120/min general, 10/min
|
|
// on `/auth/login` and `/auth/callback` to slow brute-force /
|
|
// replay attempts. `/api/health` is skipped — orchestrator polls
|
|
// shouldn't burn the user quota.
|
|
app.use(createRateLimitMiddleware(readRateLimitConfig()));
|
|
|
|
// Double-submit CSRF (ADR-0009 §"CSRF defense"). Mounted after
|
|
// the session middleware so `req.session.csrfToken` is available
|
|
// for comparison with the `X-CSRF-Token` request header. Skips
|
|
// safe methods (GET / HEAD / OPTIONS), anonymous requests, and
|
|
// the auth entry routes that *mint* the token (`/auth/login`,
|
|
// `/auth/callback`).
|
|
app.use(app.get<RequestHandler>(CSRF_MIDDLEWARE));
|
|
|
|
const globalPrefix = 'api';
|
|
app.setGlobalPrefix(globalPrefix);
|
|
|
|
// JWKS endpoint at the RFC 8615 bare-root path. Wired at the
|
|
// Express layer rather than as a Nest `@Controller` because
|
|
// path-to-regexp v8 (Nest 11's router) does not cleanly route a
|
|
// leading-dot segment like `.well-known/jwks.json` to a bare-root
|
|
// URL — the previous Nest-side attempt with `setGlobalPrefix`
|
|
// `exclude` landed the route at neither `/api/.well-known/jwks.json`
|
|
// nor `/.well-known/jwks.json` (both 404'd). Express's own
|
|
// routing accepts the leading dot verbatim, and the Nest DI
|
|
// container still owns the underlying `JwksPublisher` service.
|
|
//
|
|
// Public by design (no session, no CSRF) — the JWKS is the
|
|
// downstream's verification anchor; gating it defeats the
|
|
// purpose. Mounted before `app.listen()` so the route is live
|
|
// by the time the BFF reports ready.
|
|
const jwksPublisher = app.get(JwksPublisher);
|
|
app.getHttpAdapter().get('/.well-known/jwks.json', (_req: Request, res: Response) => {
|
|
res.json(jwksPublisher.jwks());
|
|
});
|
|
|
|
// OpenAPI spec + Scalar API Reference UI, dev-only. Mounted at
|
|
// `/${globalPrefix}/openapi.json` and `/${globalPrefix}/docs`
|
|
// respectively — the function short-circuits in production.
|
|
setupOpenApi(app, globalPrefix);
|
|
|
|
const port = process.env['PORT'] ?? 3000;
|
|
await app.listen(port);
|
|
|
|
app
|
|
.get(Logger)
|
|
.log(`Application is running on: http://localhost:${port}/${globalPrefix}`, 'Bootstrap');
|
|
}
|
|
|
|
bootstrap();
|