feat(portal-bff): ai-bridge controller — SSE chat + JSON rag/models (#196)
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

## 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
This commit was merged in pull request #196.
This commit is contained in:
2026-05-19 22:39:35 +02:00
parent 9b7d16601d
commit 883c5151de
14 changed files with 971 additions and 12 deletions
+13
View File
@@ -206,3 +206,16 @@ CORS_ALLOWED_ORIGINS=http://localhost:4200,http://localhost:4300
# 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
+2
View File
@@ -8,6 +8,7 @@ import { AdminModule } from '../admin/admin.module';
import { AuditModule } from '../audit/audit.module';
import { AuthModule } from '../auth/auth.module';
import { DownstreamModule } from '../downstream/downstream.module';
import { AiBridgeModule } from '../grpc/ai-bridge/ai-bridge.module';
import { MeModule } from '../me/me.module';
import { RedisModule } from '../redis/redis.module';
import { SecurityModule } from '../security/security.module';
@@ -25,6 +26,7 @@ import { UsersModule } from '../users/users.module';
SecurityModule,
HealthModule,
AdminModule,
AiBridgeModule,
DownstreamModule,
MeModule,
UsersModule,
@@ -0,0 +1,396 @@
import { EventEmitter } from 'node:events';
import { randomBytes } from 'node:crypto';
import { describe, it, expect, beforeAll, afterAll, beforeEach } from '@jest/globals';
import { UnauthorizedException } from '@nestjs/common';
import {
ChannelCredentials,
Server,
ServerCredentials,
status as GrpcStatus,
type sendUnaryData,
type ServerUnaryCall,
type ServerWritableStream,
} from '@grpc/grpc-js';
import { HashUserIdService } from '../../audit/hash-user-id.service';
import { ChatClient } from '../ai-client/chat.client';
import { GrpcMetadataBuilder } from '../ai-client/grpc-metadata.builder';
import { ModelsClient } from '../ai-client/models.client';
import { PrincipalMapper } from '../ai-client/principal.mapper';
import { RagClient } from '../ai-client/rag.client';
import {
ChatServiceClient,
ChatServiceService,
type ChatEvent,
type ChatRequest,
} from '../gen/apf-ai/chat';
import {
ModelsServiceClient,
ModelsServiceService,
type ListModelsRequest,
type ListModelsResponse,
} from '../gen/apf-ai/models';
import {
RagServiceClient,
RagServiceService,
type RagSearchRequest,
type RagSearchResponse,
} from '../gen/apf-ai/rag';
import { Struct } from '../gen/apf-ai/google/protobuf/struct';
import { AiBridgeController } from './ai-bridge.controller';
import type { AiServiceConfig } from '../../config/check-ai-service-config';
import type { AuthenticatedUser } from '../../auth/auth.service';
/**
* Exercises `AiBridgeController` end-to-end through the gRPC wire
* against an in-process fake of every service the controller calls.
*
* The controller is a thin translation layer (DTO → proto, gRPC →
* SSE, session → Principal). The spec confirms each direction of
* the translation lands the right bytes.
*/
const STRONG_SALT = randomBytes(32).toString('base64url');
const ORIGINAL_SALT = process.env['LOG_USER_ID_SALT'];
beforeAll(() => {
process.env['LOG_USER_ID_SALT'] = STRONG_SALT;
});
afterAll(() => {
if (ORIGINAL_SALT === undefined) {
delete process.env['LOG_USER_ID_SALT'];
} else {
process.env['LOG_USER_ID_SALT'] = ORIGINAL_SALT;
}
});
const USER: AuthenticatedUser = {
oid: 'user-oid-42',
tid: 'tenant-1',
username: 'alice@example.org',
displayName: 'Alice',
amr: ['pwd', 'mfa'],
roles: ['admin'],
};
// ---- Fake gRPC server (Chat + Rag + Models) -------------------
let server: Server;
let port: number;
let chatHandler: (call: ServerWritableStream<ChatRequest, ChatEvent>) => void;
let ragHandler: (
call: ServerUnaryCall<RagSearchRequest, RagSearchResponse>,
callback: sendUnaryData<RagSearchResponse>,
) => void;
let modelsHandler: (
call: ServerUnaryCall<ListModelsRequest, ListModelsResponse>,
callback: sendUnaryData<ListModelsResponse>,
) => void;
let observedChatRequest: ChatRequest | null = null;
beforeAll(async () => {
server = new Server();
server.addService(ChatServiceService, {
chat: (call: ServerWritableStream<ChatRequest, ChatEvent>) => {
observedChatRequest = call.request;
chatHandler(call);
},
});
server.addService(RagServiceService, {
search: (
call: ServerUnaryCall<RagSearchRequest, RagSearchResponse>,
callback: sendUnaryData<RagSearchResponse>,
) => ragHandler(call, callback),
});
server.addService(ModelsServiceService, {
listModels: (
call: ServerUnaryCall<ListModelsRequest, ListModelsResponse>,
callback: sendUnaryData<ListModelsResponse>,
) => modelsHandler(call, callback),
});
port = await new Promise<number>((resolve, reject) => {
server.bindAsync('127.0.0.1:0', ServerCredentials.createInsecure(), (err, bound) => {
if (err) {
reject(err);
return;
}
resolve(bound);
});
});
});
afterAll(async () => {
await new Promise<void>((resolve, reject) => {
server.tryShutdown((err) => {
if (err) {
reject(err);
return;
}
resolve();
});
});
});
// ---- Controller wiring --------------------------------------
function buildController(): AiBridgeController {
const config: AiServiceConfig = {
endpoint: `127.0.0.1:${port}`,
clientId: 'apf-portal-test',
useTls: false,
};
const credentials = ChannelCredentials.createInsecure();
const metadata = new GrpcMetadataBuilder(config);
const chatClient = new ChatClient(new ChatServiceClient(config.endpoint, credentials), metadata);
const ragClient = new RagClient(new RagServiceClient(config.endpoint, credentials), metadata);
const modelsClient = new ModelsClient(
new ModelsServiceClient(config.endpoint, credentials),
metadata,
);
const principalMapper = new PrincipalMapper(new HashUserIdService());
return new AiBridgeController(chatClient, ragClient, modelsClient, principalMapper);
}
// ---- Mock Express Request / Response -----------------------
interface CapturedResponse {
statusCode: number | undefined;
headers: Record<string, string>;
body: string;
ended: boolean;
writableEnded: boolean;
}
function mockReq(user: AuthenticatedUser | undefined): {
req: import('express').Request;
close: () => void;
} {
const emitter = new EventEmitter();
const req = Object.assign(emitter, {
session: { user },
}) as unknown as import('express').Request;
return {
req,
close: () => emitter.emit('close'),
};
}
function mockRes(): { res: import('express').Response; captured: CapturedResponse } {
const captured: CapturedResponse = {
statusCode: undefined,
headers: {},
body: '',
ended: false,
writableEnded: false,
};
const res = {
set statusCode(value: number) {
captured.statusCode = value;
},
get writableEnded() {
return captured.writableEnded;
},
setHeader(name: string, value: string): void {
captured.headers[name] = value;
},
flushHeaders(): void {
// no-op for the mock
},
write(chunk: string | Buffer): boolean {
captured.body += typeof chunk === 'string' ? chunk : chunk.toString();
return true;
},
end(chunk?: string | Buffer): void {
if (chunk !== undefined) {
captured.body += typeof chunk === 'string' ? chunk : chunk.toString();
}
captured.ended = true;
captured.writableEnded = true;
},
};
return { res: res as unknown as import('express').Response, captured };
}
beforeEach(() => {
observedChatRequest = null;
});
// ---------------------------------------------------------------
describe('AiBridgeController — POST /api/ai/chat (SSE bridge)', () => {
it('streams ChatEvent frames as SSE and sets the right headers', async () => {
chatHandler = (call) => {
call.write({ token: { token: 'hi', value: 'Hi' } });
call.write({ token: { token: ' there', value: ' there' } });
call.write({ done: { stats: { tokensIn: 1, tokensOut: 2, chunksRetrieved: 0 } } });
call.end();
};
const controller = buildController();
const { req } = mockReq(USER);
const { res, captured } = mockRes();
await controller.chat(
{
messages: [{ role: 'user', content: 'hello' }],
conversationId: 'c-1',
},
req,
res,
);
expect(captured.headers['Content-Type']).toBe('text/event-stream; charset=utf-8');
expect(captured.headers['Cache-Control']).toBe('no-cache, no-transform');
expect(captured.headers['X-Accel-Buffering']).toBe('no');
expect(captured.body).toContain('event: token\n');
expect(captured.body).toContain('"value":"Hi"');
expect(captured.body).toContain('event: done\n');
expect(captured.ended).toBe(true);
});
it('places a hashed subject and pass-through roles in the proto Principal', async () => {
chatHandler = (call) => {
call.write({ done: { stats: { tokensIn: 0, tokensOut: 0, chunksRetrieved: 0 } } });
call.end();
};
const controller = buildController();
const { req } = mockReq(USER);
const { res } = mockRes();
await controller.chat(
{
messages: [{ role: 'user', content: 'q' }],
},
req,
res,
);
expect(observedChatRequest?.principal?.subject).toMatch(/^[0-9a-f]{16}$/);
expect(observedChatRequest?.principal?.subject).not.toBe(USER.oid);
expect(observedChatRequest?.principal?.roles).toEqual(['admin']);
expect(observedChatRequest?.principal?.attributes).toEqual({ tenantId: 'tenant-1' });
expect(observedChatRequest?.toolsAvailable).toEqual([]);
});
it('rejects with 401 when no session.user is present', async () => {
const controller = buildController();
const { req } = mockReq(undefined);
const { res } = mockRes();
await expect(
controller.chat({ messages: [{ role: 'user', content: 'q' }] }, req, res),
).rejects.toBeInstanceOf(UnauthorizedException);
});
it('cancels the gRPC call when the request emits "close" mid-stream', async () => {
const cancellationObserved = new Promise<void>((resolve) => {
chatHandler = (call) => {
call.write({ token: { token: 't', value: 't' } });
call.on('cancelled', () => resolve());
// never end — wait for client cancellation
};
});
const controller = buildController();
const { req, close } = mockReq(USER);
const { res } = mockRes();
const completion = controller.chat({ messages: [{ role: 'user', content: 'q' }] }, req, res);
// Give the server time to emit the first frame then cut the
// request — this is the controller's documented browser-close
// pathway.
setTimeout(close, 30);
await completion;
await cancellationObserved;
});
it('writes a relay error frame on upstream failure', async () => {
chatHandler = (call) => {
call.emit('error', {
code: GrpcStatus.UNAVAILABLE,
details: 'upstream down',
message: 'upstream down',
});
};
const controller = buildController();
const { req } = mockReq(USER);
const { res, captured } = mockRes();
await controller.chat({ messages: [{ role: 'user', content: 'q' }] }, req, res);
expect(captured.body).toContain('event: error\n');
expect(captured.body).toContain('"code":"urn:apf-ai:unavailable"');
expect(captured.body).toContain('"retriable":true');
expect(captured.ended).toBe(true);
});
});
describe('AiBridgeController — GET /api/ai/rag/search', () => {
it('returns the unary response', async () => {
ragHandler = (_call, callback) => {
callback(null, {
chunks: [
{
id: 'k',
documentId: 'd',
content: 'snippet',
source: 'src',
score: 1,
metadata: Struct.fromPartial({}),
},
],
correlationId: 'corr-1',
});
};
const controller = buildController();
const { req } = mockReq(USER);
const response = await controller.ragSearch({ query: 'hello', topK: 3 }, req);
expect(response.chunks).toHaveLength(1);
expect(response.correlationId).toBe('corr-1');
});
it('rejects with 401 when no session.user', async () => {
const controller = buildController();
const { req } = mockReq(undefined);
await expect(controller.ragSearch({ query: 'x' }, req)).rejects.toBeInstanceOf(
UnauthorizedException,
);
});
});
describe('AiBridgeController — GET /api/ai/models', () => {
it('returns the model list', async () => {
modelsHandler = (_call, callback) => {
callback(null, {
active: 'openai-compatible',
providers: [
{
discriminator: 'openai-compatible',
capabilities: 'chat,embedding',
endpoint: 'http://ollama:11434/v1',
model: 'qwen2.5:3b',
embeddingModel: 'nomic-embed-text',
},
],
});
};
const controller = buildController();
const { req } = mockReq(USER);
const response = await controller.listModels(req);
expect(response.active).toBe('openai-compatible');
expect(response.providers).toHaveLength(1);
});
it('rejects with 401 when no session.user', async () => {
const controller = buildController();
const { req } = mockReq(undefined);
await expect(controller.listModels(req)).rejects.toBeInstanceOf(UnauthorizedException);
});
});
@@ -0,0 +1,232 @@
import {
Body,
Controller,
Get,
HttpStatus,
Post,
Query,
Req,
Res,
UnauthorizedException,
} from '@nestjs/common';
import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { status as GrpcStatus, type ServiceError } from '@grpc/grpc-js';
import type { Request, Response } from 'express';
import { ChatClient } from '../ai-client/chat.client';
import { ModelsClient } from '../ai-client/models.client';
import { PrincipalMapper } from '../ai-client/principal.mapper';
import { RagClient } from '../ai-client/rag.client';
import type { ChatEvent, ChatRequest } from '../gen/apf-ai/chat';
import { ChatRole } from '../gen/apf-ai/common';
import type { ListModelsResponse } from '../gen/apf-ai/models';
import type { RagSearchResponse } from '../gen/apf-ai/rag';
import { ChatRequestDto, type ChatMessageDto } from './dto/chat-request.dto';
import { RagSearchQueryDto } from './dto/rag-search-query.dto';
import { chatEventToSseFrame, relayErrorFrame } from './sse.writer';
import type { AuthenticatedUser } from '../../auth/auth.service';
/**
* BFF-facing surface of the AI relay, per
* [ADR-0024](../../../../../docs/decisions/0024-ai-service-relay-grpc-sse-bridge.md):
*
* - `POST /api/ai/chat` — streaming chat, bridged from
* `ChatService.Chat` (gRPC server-
* stream) to `text/event-stream`.
* - `GET /api/ai/rag/search` — unary RAG retrieval.
* - `GET /api/ai/models` — list configured providers.
*
* No `@RequireAdmin()` — AI features are end-user surfaces, gated
* only by the active portal session (`req.session.user`). The
* session + CSRF middleware mounted in `main.ts` covers the
* authentication + double-submit token before the controller
* runs; this class asserts presence of `session.user` for the
* routes that need it and lets the global middleware handle the
* rest.
*/
@ApiTags('ai')
@ApiCookieAuth('portal_session')
@Controller('ai')
export class AiBridgeController {
constructor(
private readonly chatClient: ChatClient,
private readonly ragClient: RagClient,
private readonly modelsClient: ModelsClient,
private readonly principalMapper: PrincipalMapper,
) {}
@ApiOperation({
summary:
'Streaming chat. Returns text/event-stream with one frame per ChatEvent (token / citation / agent-step / tool-call / error / done).',
})
@Post('chat')
async chat(
@Body() body: ChatRequestDto,
@Req() req: Request,
@Res() res: Response,
): Promise<void> {
const user = requireSessionUser(req);
const principal = this.principalMapper.fromInputs({
oid: user.oid,
tid: user.tid,
roles: user.roles,
});
const protoRequest: ChatRequest = {
messages: body.messages.map(toProtoMessage),
conversationId: body.conversationId ?? '',
model: body.model ?? '',
provider: body.provider ?? '',
toolsAvailable: [],
// v1: tool registry is empty (per ADR-0024 §"Tool-dispatch
// contract"). The AI service will never emit `tool_call` for
// requests with `toolsAvailable: []`; the SSE writer still
// covers the case in case a future tool lands.
rag: { enabled: false, topK: 0 },
principal,
};
writeSseHeaders(res);
// Browser disconnect (tab close, fetch abort, network drop) →
// AbortController → gRPC call.cancel() → upstream LLM stop.
// Per ADR-0024 §"SSE bridge between BFF and SPA".
const abort = new AbortController();
req.on('close', () => {
if (!res.writableEnded) {
abort.abort();
}
});
const stream = this.chatClient.chat(protoRequest, { signal: abort.signal });
try {
for await (const event of stream as AsyncIterable<ChatEvent>) {
const frame = chatEventToSseFrame(event);
if (frame !== null) {
res.write(frame);
}
}
} catch (err) {
// gRPC `CANCELLED` after `abort.abort()` is the expected exit
// path on browser disconnect — do not surface an error frame
// because the response is already closing. Anything else
// becomes a structured error event so the SPA's renderer
// sees the failure rather than a torn-down connection.
const code = (err as Partial<ServiceError>).code;
const cancelled = code === GrpcStatus.CANCELLED;
if (!cancelled && !res.writableEnded) {
res.write(
relayErrorFrame(
mapServiceErrorCode(code),
(err as Error).message ?? 'AI relay error',
isRetriable(code),
),
);
}
} finally {
if (!res.writableEnded) {
res.end();
}
}
}
@ApiOperation({ summary: 'Unary RAG retrieval bounded by the caller principal.' })
@Get('rag/search')
async ragSearch(
@Query() query: RagSearchQueryDto,
@Req() req: Request,
): Promise<RagSearchResponse> {
const user = requireSessionUser(req);
const principal = this.principalMapper.fromInputs({
oid: user.oid,
tid: user.tid,
roles: user.roles,
});
return this.ragClient.search({
query: query.query,
topK: query.topK ?? 0,
filters: {
source: query.source ?? '',
documentId: query.documentId ?? '',
},
principal,
});
}
@ApiOperation({ summary: 'List configured AI providers and the active provider.' })
@Get('models')
async listModels(@Req() req: Request): Promise<ListModelsResponse> {
requireSessionUser(req);
return this.modelsClient.listModels({});
}
}
function requireSessionUser(req: Request): AuthenticatedUser {
const user = req.session.user;
if (!user) {
throw new UnauthorizedException({
code: 'unauthenticated',
message: 'The AI surface requires an authenticated portal session.',
});
}
return user;
}
function writeSseHeaders(res: Response): void {
res.statusCode = HttpStatus.OK;
res.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
res.setHeader('Cache-Control', 'no-cache, no-transform');
res.setHeader('Connection', 'keep-alive');
// nginx-style buffering hint — keeps reverse-proxies from
// accumulating chunks before forwarding, which would defeat the
// streaming UX.
res.setHeader('X-Accel-Buffering', 'no');
res.flushHeaders();
}
function toProtoMessage(message: ChatMessageDto): {
role: ChatRole;
content: string;
toolCallId: string;
name: string;
} {
return {
role: ROLE_PROTO[message.role],
content: message.content,
toolCallId: '',
name: '',
};
}
const ROLE_PROTO: Record<ChatMessageDto['role'], ChatRole> = {
system: ChatRole.CHAT_ROLE_SYSTEM,
user: ChatRole.CHAT_ROLE_USER,
assistant: ChatRole.CHAT_ROLE_ASSISTANT,
};
/**
* gRPC status → SSE error code. Keeps the urn:apf-ai:* namespace
* the AI service itself uses so a relay-side error and an upstream
* error look the same to the SPA's renderer.
*/
function mapServiceErrorCode(code: number | undefined): string {
switch (code) {
case GrpcStatus.UNAVAILABLE:
return 'urn:apf-ai:unavailable';
case GrpcStatus.DEADLINE_EXCEEDED:
return 'urn:apf-ai:timeout';
case GrpcStatus.PERMISSION_DENIED:
return 'urn:apf-ai:permission_denied';
case GrpcStatus.RESOURCE_EXHAUSTED:
return 'urn:apf-ai:rate_limited';
case GrpcStatus.INVALID_ARGUMENT:
return 'urn:apf-ai:invalid_argument';
default:
return 'urn:apf-ai:relay_error';
}
}
function isRetriable(code: number | undefined): boolean {
return code === GrpcStatus.UNAVAILABLE || code === GrpcStatus.DEADLINE_EXCEEDED;
}
@@ -0,0 +1,24 @@
import { Module } from '@nestjs/common';
import { AiClientModule } from '../ai-client/ai-client.module';
import { AiBridgeController } from './ai-bridge.controller';
/**
* Hosts `AiBridgeController` and depends on `AiClientModule` for
* the four wrapper clients (`ChatClient`, `RagClient`,
* `ModelsClient`, plus `PrincipalMapper`). `AiClientModule`'s
* `OnApplicationShutdown` lifecycle closes the gRPC stubs at
* process termination — wiring this module into `AppModule`
* brings both the controller AND the shutdown contract along.
*
* Per [ADR-0024](../../../../../docs/decisions/0024-ai-service-relay-grpc-sse-bridge.md)
* §"Out of scope for this ADR" the ingestion surface is not
* exposed via the BFF in v1 — `IngestionClient` is in
* `AiClientModule` for future reuse but `AiBridgeController`
* does not surface it. The CLI under `apf-ai-service/tools/Apf.Ai.Ingest/`
* is the v1 ingestion path.
*/
@Module({
imports: [AiClientModule],
controllers: [AiBridgeController],
})
export class AiBridgeModule {}
@@ -0,0 +1,77 @@
import { Type } from 'class-transformer';
import {
ArrayMaxSize,
ArrayMinSize,
IsArray,
IsIn,
IsOptional,
IsString,
MaxLength,
MinLength,
ValidateNested,
} from 'class-validator';
/**
* Roles the SPA may attach to a message in the chat history.
*
* `tool` is intentionally absent — tool-result messages are
* constructed by the BFF itself (caller-side tool dispatch, per
* [ADR-0024](../../../../../../docs/decisions/0024-ai-service-relay-grpc-sse-bridge.md)
* §"Tool-dispatch contract — caller-side execution") and never
* provided by the SPA. v1 ships with an empty tool registry, so
* no `tool` message ever reaches this controller.
*/
const ALLOWED_ROLES = ['user', 'assistant', 'system'] as const;
type AllowedRole = (typeof ALLOWED_ROLES)[number];
export class ChatMessageDto {
@IsIn(ALLOWED_ROLES)
role!: AllowedRole;
/**
* Free-form message body. The cap is generous (16 KB) to support
* code blocks and pasted excerpts; the BFF leaves further
* pre-processing (truncation, token counting) to the AI service.
*/
@IsString()
@MinLength(0)
@MaxLength(16_384)
content!: string;
}
/**
* POST /api/ai/chat body.
*
* Stateless from the BFF's perspective — the SPA owns the
* conversation history. `conversationId` is an opaque correlation
* key used by the AI service's audit log (not a database key on
* either side); the SPA may omit it for one-off calls.
*/
export class ChatRequestDto {
@IsArray()
@ArrayMinSize(1)
@ArrayMaxSize(64)
@ValidateNested({ each: true })
@Type(() => ChatMessageDto)
messages!: ChatMessageDto[];
@IsOptional()
@IsString()
@MaxLength(128)
conversationId?: string;
/**
* Optional model + provider hints. Both default to "" on the
* wire, which the AI service interprets as "use the configured
* default". The SPA does not need to pass these in v1.
*/
@IsOptional()
@IsString()
@MaxLength(64)
model?: string;
@IsOptional()
@IsString()
@MaxLength(64)
provider?: string;
}
@@ -0,0 +1,31 @@
import { Type } from 'class-transformer';
import { IsInt, IsOptional, IsString, Max, Min, MinLength } from 'class-validator';
/**
* Query-string DTO for `GET /api/ai/rag/search`.
*
* Bounded — the upstream RAG service has its own server-side cap on
* `top_k`, but the BFF rejects obviously-out-of-range values early
* so a single SPA bug cannot induce repeated 400s round-tripped
* through the AI service.
*/
export class RagSearchQueryDto {
@IsString()
@MinLength(1)
query!: string;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(50)
topK?: number;
@IsOptional()
@IsString()
source?: string;
@IsOptional()
@IsString()
documentId?: string;
}
@@ -0,0 +1,81 @@
import { describe, it, expect } from '@jest/globals';
import { chatEventToSseFrame, relayErrorFrame } from './sse.writer';
/**
* Locks the wire shape the SPA consumes. Each frame is:
*
* event: <name>\n
* data: <json>\n
* \n
*
* The terminal blank line is the SSE message separator.
*/
describe('chatEventToSseFrame', () => {
it('maps token events with the JSON-encoded inner value', () => {
const frame = chatEventToSseFrame({ token: { token: 't1', value: 'hello' } });
expect(frame).toBe(`event: token\ndata: {"token":"t1","value":"hello"}\n\n`);
});
it('maps citation events', () => {
const frame = chatEventToSseFrame({
citation: {
chunkId: 'c-1',
documentId: 'd-1',
source: 's',
score: 0.42,
snippet: 'snip',
},
});
expect(frame?.startsWith('event: citation\n')).toBe(true);
expect(frame).toContain('"chunkId":"c-1"');
});
it('maps agent_step to kebab-case `agent-step`', () => {
const frame = chatEventToSseFrame({
agentStep: { agent: 'a', step: 's', stepId: '1' },
});
expect(frame?.startsWith('event: agent-step\n')).toBe(true);
});
it('maps tool_call to kebab-case `tool-call`', () => {
const frame = chatEventToSseFrame({
toolCall: { callId: 'c-1', name: 'echo', args: undefined },
});
expect(frame?.startsWith('event: tool-call\n')).toBe(true);
});
it('maps error events', () => {
const frame = chatEventToSseFrame({
error: { code: 'urn:apf-ai:foo', message: 'bad', retriable: false },
});
expect(frame).toBe(
`event: error\ndata: {"code":"urn:apf-ai:foo","message":"bad","retriable":false}\n\n`,
);
});
it('maps done as the terminal frame', () => {
const frame = chatEventToSseFrame({
done: { stats: { tokensIn: 1, tokensOut: 2, chunksRetrieved: 0 } },
});
expect(frame?.startsWith('event: done\n')).toBe(true);
expect(frame?.endsWith('\n\n')).toBe(true);
});
it('returns null when no oneof case is populated', () => {
expect(chatEventToSseFrame({})).toBeNull();
});
});
describe('relayErrorFrame', () => {
it('emits an error frame with the supplied code + message + retriable flag', () => {
expect(relayErrorFrame('urn:apf-ai:relay_error', 'kaboom', true)).toBe(
`event: error\ndata: {"code":"urn:apf-ai:relay_error","message":"kaboom","retriable":true}\n\n`,
);
});
it('defaults `retriable` to false when omitted', () => {
const frame = relayErrorFrame('urn:apf-ai:foo', 'msg');
expect(frame).toContain('"retriable":false');
});
});
@@ -0,0 +1,70 @@
import type { ChatEvent } from '../gen/apf-ai/chat';
/**
* Translate one `apf.ai.v1.ChatEvent` into a single SSE frame for
* the SPA, per ADR-0024 §"Sub-decision 2 — SSE bridge between BFF
* and SPA". The mapping is intentionally one-to-one with the
* proto `oneof` cases:
*
* token → `event: token` / data = TokenEvent JSON
* citation → `event: citation` / data = CitationEvent JSON
* agent_step → `event: agent-step` / data = AgentStepEvent JSON
* tool_call → `event: tool-call` / data = ToolCallEvent JSON
* error → `event: error` / data = ErrorEvent JSON
* done → `event: done` / data = DoneEvent JSON
*
* SSE event names use kebab-case so the SPA's `EventSource` /
* fetch-streaming consumer can dispatch with `.addEventListener('agent-step', …)`
* without re-mapping proto camelCase. The terminal `done` frame is
* the contract's stream-close marker — no `[DONE]` sentinel, per
* ADR-0024.
*
* Returns `null` when the event carries no populated oneof case
* (defensive — gRPC-js will not produce this in practice, but the
* caller can safely skip on `null` rather than emit an empty
* frame). The data payload is JSON.stringified; consumers parse
* with `JSON.parse(event.data)`.
*/
export function chatEventToSseFrame(event: ChatEvent): string | null {
if (event.token !== undefined) {
return formatFrame('token', event.token);
}
if (event.citation !== undefined) {
return formatFrame('citation', event.citation);
}
if (event.agentStep !== undefined) {
return formatFrame('agent-step', event.agentStep);
}
if (event.toolCall !== undefined) {
return formatFrame('tool-call', event.toolCall);
}
if (event.error !== undefined) {
return formatFrame('error', event.error);
}
if (event.done !== undefined) {
return formatFrame('done', event.done);
}
return null;
}
/**
* Convenience used by the controller's error path: synthesise a
* `urn:apf-ai:relay_error` event frame so the SPA receives a
* structured failure rather than a torn-down connection. Matches the
* shape of the AI service's own `ErrorEvent` so the SPA's renderer
* does not need a second code path for relay-level failures vs
* upstream model errors.
*/
export function relayErrorFrame(code: string, message: string, retriable = false): string {
return formatFrame('error', { code, message, retriable });
}
function formatFrame(eventName: string, data: unknown): string {
// SSE spec: every field line ends with `\n`, the frame is
// terminated by a blank line (`\n\n`). `data:` is followed by a
// single space convention. `JSON.stringify` produces no newlines
// for plain objects, so a single `data:` line is correct; a
// multi-line payload (not used by this codec) would require
// splitting on `\n` and prefixing each part with `data:`.
return `event: ${eventName}\ndata: ${JSON.stringify(data)}\n\n`;
}
@@ -1,5 +1,5 @@
import { Module, type Provider } from '@nestjs/common';
import { ChannelCredentials } from '@grpc/grpc-js';
import { Inject, Module, type OnApplicationShutdown, type Provider } from '@nestjs/common';
import { ChannelCredentials, type Client } from '@grpc/grpc-js';
import { HashUserIdService } from '../../audit/hash-user-id.service';
import { assertAiServiceConfig, type AiServiceConfig } from '../../config/check-ai-service-config';
import { ChatServiceClient } from '../gen/apf-ai/chat';
@@ -122,11 +122,32 @@ const grpcStubProviders: Provider[] = [
],
exports: [ChatClient, RagClient, IngestionClient, ModelsClient, PrincipalMapper],
})
export class AiClientModule {
// Lifecycle (channel close on shutdown) lands when the SSE-bridge
// PR wires this module into AppModule. v1 process termination
// closes the gRPC sockets via OS-level descriptor reclaim — fine
// for the dev/preprod posture; prod will add an explicit
// OnApplicationShutdown hook once Nest's shutdown hooks are
// enabled in main.ts.
export class AiClientModule implements OnApplicationShutdown {
constructor(
@Inject(AI_CHAT_GRPC_CLIENT) private readonly chatStub: Client,
@Inject(AI_RAG_GRPC_CLIENT) private readonly ragStub: Client,
@Inject(AI_INGESTION_GRPC_CLIENT) private readonly ingestionStub: Client,
@Inject(AI_MODELS_GRPC_CLIENT) private readonly modelsStub: Client,
) {}
/**
* Close every generated gRPC stub when the BFF receives `SIGTERM`
* / `SIGINT`. Each `Client.close()` flushes pending RPCs (with
* their own gRPC `CANCELLED` semantics) and tears down the
* shared HTTP/2 channel so the process can exit promptly without
* waiting for the channel's keepalive PINGs.
*
* The four stubs share the same underlying HTTP/2 channel (same
* endpoint + same credentials, gRPC-js de-duplicates), so the
* four `close()` calls are cheap but kept explicit — adding a
* fifth stub later means adding a fifth `close()` line, which is
* easier to spot than iterating an array that grew without
* review.
*/
onApplicationShutdown(): void {
this.chatStub.close();
this.ragStub.close();
this.ingestionStub.close();
this.modelsStub.close();
}
}
+10
View File
@@ -81,6 +81,16 @@ async function bootstrap() {
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,