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
This commit was merged in pull request #196.
This commit is contained in:
@@ -52,17 +52,19 @@ The structural, security, observability, and quality choices are recorded as ADR
|
||||
- **Local quality gates:** Husky + lint-staged + commitlint with Conventional Commits — see [ADR-0007](docs/decisions/0007-pre-commit-hooks-and-conventional-commits.md).
|
||||
- **Documentation site:** `docs/**/*.md` rendered as a separate static site via **VitePress** (Vite-based, Node-only toolchain, Markdown-first). Mermaid diagrams via `vitepress-plugin-mermaid`. Deployed on its own hostname behind the shared reverse-proxy; CI hook on `docs/` changes rebuilds + publishes. Decoupled from the apps — content lives in `docs/`, no in-app Markdown viewer — see [ADR-0022](docs/decisions/0022-docs-site-vitepress.md).
|
||||
- **Charts + dashboards:** `D3 + Observable Plot` wrapped in `libs/shared/charts/`, one Angular component per chart type (bar, donut, line, stacked-bar, …). A11y baked in by the lib (SVG `<title>`/`<desc>`, `<details>` tabular fallback, colour-blind-safe palettes, AA-contrast text, `prefers-reduced-motion` gate). Bundle stays under [ADR-0017](docs/decisions/0017-performance-budgets-lighthouse-ci.md)'s lazy-chunk cap via per-`d3-*` module tree-shaking. Future bespoke visualisations land in raw D3 inside the same lib — see [ADR-0023](docs/decisions/0023-charts-d3-observable-plot.md).
|
||||
- **AI service relay:** dedicated `apf-ai-service` repo (ASP.NET Core, Microsoft Agent Framework) consumed via native gRPC HTTP/2 only — proto contract vendored under `apps/portal-bff/src/grpc/proto/apf-ai/` with `ts-proto` codegen committed alongside. BFF dials with `@grpc/grpc-js` (h2c in dev, h2 + TLS in prod), bridges `ChatService.Chat` to `text/event-stream` for the SPA, exposes `RagService.Search` and `ModelsService.ListModels` as plain JSON endpoints. Identity travels as an unsigned `Principal` (subject, roles, attributes) in the proto body for the POC, hashed via the audit module's `HashUserIdService` so portal and AI service audit trails join on the same `actor_id_hash`. Production hardening (signed envelope vs mTLS) deferred — see [ADR-0024](docs/decisions/0024-ai-service-relay-grpc-sse-bridge.md).
|
||||
- **Runtime:** Node.js latest LTS major.
|
||||
|
||||
## Repository status
|
||||
|
||||
The Nx workspace is **scaffolded and operational**. The three apps (`portal-shell`, `portal-admin`, `portal-bff`) and the four lib roots (`libs/feature/`, `libs/shared/state`, `libs/shared/tokens`, `libs/shared/ui`, `libs/shared/util`) are in place; CI runs `format:check / lint / test / build` on every PR.
|
||||
|
||||
ADRs 0001 → 0023 are accepted and cover the structural, security, observability, quality, i18n, admin-app, docs-site, and charts choices. **Shipped on `main`:**
|
||||
ADRs 0001 → 0024 are accepted and cover the structural, security, observability, quality, i18n, admin-app, docs-site, charts, and AI-relay choices. **Shipped on `main`:**
|
||||
|
||||
- **Phase-1 foundation** — Nx workspace, Angular `portal-shell`, NestJS `portal-bff`, Prisma + Postgres, Pino + OpenTelemetry, Husky/lint-staged/commitlint, Gitea Actions CI.
|
||||
- **Phase-2 auth + audit + security** — OIDC Auth Code + PKCE via MSAL Node, Redis sessions with AES-256-GCM at rest, idle 30 min sliding + absolute 12 h hard ceiling, RP-initiated logout, double-submit CSRF, `audit.events` append-only schema with role-based grants, helmet + env-driven CORS allowlist + rate limiting + structured error envelope (see [ADR-0021](docs/decisions/0021-phase-2-security-baseline.md)).
|
||||
- **Phase-3a admin app skeleton** — `portal-admin` SPA exists with brand tokens and routing; business modules (CMS, menu management, user list, audit log viewer) not yet implemented.
|
||||
- **AI relay surface** — vendored protos + `AiClientModule` (gRPC clients, Principal mapper, metadata builder) + `AiBridgeController` exposing `POST /api/ai/chat` (SSE), `GET /api/ai/rag/search`, `GET /api/ai/models` (see [ADR-0024](docs/decisions/0024-ai-service-relay-grpc-sse-bridge.md)). Live consumer (chatbot widget on `portal-shell`) and the proto-drift CI gate ship next.
|
||||
|
||||
**Still on the roadmap:**
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
status: proposed
|
||||
status: accepted
|
||||
date: 2026-05-19
|
||||
decision-makers: R&D Lead
|
||||
tags: [backend, security, observability]
|
||||
|
||||
@@ -67,4 +67,4 @@ ADRs are listed in numerical order. To slice by topic, filter on the `Tags` colu
|
||||
| [0021](0021-phase-2-security-baseline.md) | Phase-2 security baseline — helmet, CORS allowlist, double-submit CSRF, rate limiting, structured error envelope | accepted | `security`, `backend` | 2026-05-13 |
|
||||
| [0022](0022-docs-site-vitepress.md) | Documentation site — VitePress + Mermaid plugin, separate static deployment | accepted | `process`, `infrastructure` | 2026-05-15 |
|
||||
| [0023](0023-charts-d3-observable-plot.md) | Charts + dashboards — D3 + Observable Plot wrapped in `libs/shared/charts` | accepted | `frontend`, `accessibility`, `performance` | 2026-05-16 |
|
||||
| [0024](0024-ai-service-relay-grpc-sse-bridge.md) | AI service relay — vendored gRPC protos, NestJS gRPC client, SSE bridge to the SPA, POC unsigned principal | proposed | `backend`, `security`, `observability` | 2026-05-19 |
|
||||
| [0024](0024-ai-service-relay-grpc-sse-bridge.md) | AI service relay — vendored gRPC protos, NestJS gRPC client, SSE bridge to the SPA, POC unsigned principal | accepted | `backend`, `security`, `observability` | 2026-05-19 |
|
||||
|
||||
Reference in New Issue
Block a user