feat(portal-bff): ai-bridge controller — SSE chat + JSON rag/models #196

Merged
julien merged 1 commits from feat/portal-bff-ai-bridge into main 2026-05-19 22:39:36 +02:00
Owner

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 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 and ADR-0021); 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 (UNAVAILABLEurn:apf-ai:unavailable, DEADLINE_EXCEEDEDurn:apf-ai:timeout, PERMISSION_DENIEDurn:apf-ai:permission_denied, RESOURCE_EXHAUSTEDurn:apf-ai:rate_limited, INVALID_ARGUMENTurn: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: proposedaccepted.
  • 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). 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

  • pnpm nx test portal-bff461 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.
  • pnpm nx lint portal-bff — 6 pre-existing warnings, no new ones from the diff.
  • pnpm nx build portal-bff — clean webpack compile.
  • 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:
    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.
## 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.
julien added 1 commit 2026-05-19 22:30:42 +02:00
feat(portal-bff): ai-bridge controller — SSE chat + JSON rag/models
CI / scan (pull_request) Successful in 2m59s
CI / commits (pull_request) Successful in 3m6s
CI / check (pull_request) Successful in 3m32s
CI / a11y (pull_request) Successful in 1m50s
Docs site / build (pull_request) Successful in 2m34s
CI / perf (pull_request) Successful in 4m4s
d691c1f5cf
Wires the AI relay into the BFF per ADR-0024 (now accepted):

- AiBridgeController exposes POST /api/ai/chat (text/event-stream),
  GET /api/ai/rag/search, GET /api/ai/models under /api/ai/*.
  Session-gated (req.session.user) without an admin role — AI is
  an end-user surface. CSRF and double-submit token handled by the
  global middleware mounted in main.ts.
- sse.writer translates ChatEvent oneof cases to kebab-case SSE
  event names (token / citation / agent-step / tool-call / error /
  done), JSON-encodes the inner payload, and produces a relay-side
  error frame on upstream failures (mapped from gRPC Status codes
  into the urn:apf-ai:* namespace the AI service already uses).
- DTOs (class-validator) cap message count + length, restrict the
  role enum to user/assistant/system (tool messages are constructed
  caller-side by the BFF when tool dispatch lands), and bound RAG
  top_k between 1 and 50.
- Browser disconnect (req 'close' event) → AbortController →
  call.cancel(), upstream LLM stops without leaking the stream.
- AiClientModule now implements OnApplicationShutdown to close the
  four gRPC stubs on SIGTERM/SIGINT; main.ts calls
  app.enableShutdownHooks() to make it fire.
- ADR-0024 promoted from proposed to accepted; README index + the
  CLAUDE.md roll-up updated. apps/portal-bff/.env.example documents
  AI_SERVICE_GRPC_ENDPOINT / CLIENT_ID / TLS for local boot.
julien merged commit 883c5151de into main 2026-05-19 22:39:36 +02:00
julien deleted branch feat/portal-bff-ai-bridge 2026-05-19 22:39:36 +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#196