feat(portal-bff): ai-client skeleton — vendored protos + grpc client + Principal mapper (#195)
CI / commits (push) Has been skipped
CI / scan (push) Successful in 3m6s
CI / check (push) Successful in 5m33s
CI / a11y (push) Successful in 2m33s
CI / perf (push) Successful in 6m33s
Docs site / build (push) Successful in 2m24s

## Summary

Step 2 of the AI-relay chantier (after [ADR-0024](docs/decisions/0024-ai-service-relay-grpc-sse-bridge.md) merged in #194). Lands the BFF-side **skeleton** that talks to `apf-ai-service` over gRPC: vendored protos, generated TypeScript stubs, typed wrapper clients, Principal mapper, and metadata builder — all tested against an in-process fake gRPC server. **No HTTP route is exposed in this PR**; the SSE bridge (`POST /api/ai/chat`, `GET /api/ai/rag/search`, `GET /api/ai/models`) ships in the next PR.

The skeleton is self-contained: `AiClientModule` is built but is NOT imported in `AppModule` yet. The BFF runtime is byte-for-byte unchanged. Everything below exists for the next PR to wire into a controller.

## What lands

### Proto vendoring + codegen

- `apps/portal-bff/src/grpc/proto/apf-ai/` — mirror of `apf-ai-service/contract/proto/` (common, chat, rag, ingestion, models). Both the `.proto` files and the regenerated `ts-proto` output under `grpc/gen/` are committed for hermetic builds and reviewable diffs (per ADR-0024 §"Sub-decision 3").
- `pnpm run grpc:codegen` — regenerates the stubs via `grpc-tools`' bundled `protoc` and the `ts-proto` plugin (`outputServices=grpc-js, esModuleInterop, forceLong=long, useOptionals=messages, exportCommonSymbols=false`).
- `pnpm run grpc:sync` — copies the vendored `.proto` files from the sibling `apf-ai-service` working tree (`../apf-ai-service/contract/proto/`); developer convenience, never invoked from CI. Errors with an actionable message when the sibling tree is not where it expects.
- Generated tree (`grpc/gen/**`) excluded from Prettier (`.prettierignore`) and ESLint (`eslint.config.mjs` ignores). Hand-rules apply to wrappers under `ai-client/`, not to codegen output.

### Dependencies

- `@grpc/grpc-js@^1.13.0` — runtime gRPC client.
- `@bufbuild/protobuf@^2.10.2` — wire codec used by `ts-proto`'s emitted code.
- `long@^5.2.3` — int64 representation for proto Long fields (`forceLong=long`).
- `ts-proto@^2.7.0` — devDep, TypeScript codegen plugin.
- `grpc-tools@^1.13.0` — devDep, ships `protoc` + the gRPC plugin; added to `pnpm.onlyBuiltDependencies` so the postinstall binary download runs.

### Env validator

`apps/portal-bff/src/config/check-ai-service-config.ts` follows the same posture as the other validators (per ADR-0018 §"BFF env-var loading"): small, per-key, runs at module init, throws with an actionable message on misconfiguration. Three vars:

| Var | Mandatory | Purpose |
|---|---|---|
| `AI_SERVICE_GRPC_ENDPOINT` | yes | `host:port` reachable from the BFF — `apf-ai-service:8080` in dev Compose, service DNS + 443 in prod |
| `AI_SERVICE_CLIENT_ID` | yes | Deployment slug propagated as the `x-client-id` metadata. Convention: `apf-portal-<env>` |
| `AI_SERVICE_GRPC_TLS` | no (default `true`) | `false` for h2c in dev, `true` for h2 + TLS in prod |

7 spec cases lock the validation contract end-to-end.

### `AiClientModule`

`apps/portal-bff/src/grpc/ai-client/` houses:

- **`tokens.ts`** — DI tokens (`AI_CONFIG`, `AI_CREDENTIALS`, one per generated stub).
- **`principal.mapper.ts`** — `PrincipalMapper.fromInputs({oid, tid, roles, extraAttributes})` returns the proto `Principal`. `subject` is hashed via `HashUserIdService` (the **same** salt + algorithm the audit writer uses) so `Principal.subject` matches `audit.events.actor_id_hash` byte-for-byte. `roles` passes through verbatim — inclusive expansion lands with a future role-hierarchy ADR; the wire contract on the AI side is unchanged.
- **`grpc-metadata.builder.ts`** — stamps every outbound call with `x-client-id` (from config) and `x-correlation-id` (active OTel span's trace-id when present, else explicit override, else fresh UUID).
- **`chat.client.ts`** — server-stream wrapper around `ChatServiceClient`. Returns the raw `ClientReadableStream<ChatEvent>` (Node `Readable` is async-iterable so the SSE bridge consumes with `for await`). Optional `AbortSignal` propagates browser disconnect to `call.cancel()`.
- **`rag.client.ts`**, **`models.client.ts`**, **`ingestion.client.ts`** — unary promisified wrappers. `IngestionClient` is unused in v1 (the admin "manage AI corpus" surface lands later) but wired now so the future controller is one new file, not a new module.
- **`ai-client.module.ts`** — NestJS module wiring the providers. `HashUserIdService` is declared locally rather than imported via `AuditModule` (the global module) to keep the module unit-testable in isolation; runtime equivalence is guaranteed by the service being a pure function of `LOG_USER_ID_SALT`.

### Tests

5 new spec files, 18 new test cases. All run against in-process fake gRPC servers; no network, no live `apf-ai-service`:

- `check-ai-service-config.spec.ts` — 7 cases (happy path + 6 rejection branches).
- `principal.mapper.spec.ts` — 7 cases including the cross-service hash-stability invariant and the `tenantId` reserved-key contract.
- `grpc-metadata.builder.spec.ts` — 5 cases covering all three correlation-id resolution paths and metadata immutability.
- `chat.client.spec.ts` — 4 cases: happy-path stream, metadata propagation, mid-stream cancel via AbortSignal, pre-aborted signal.
- `rag.client.spec.ts` — 2 cases: unary happy path, `ServiceError` propagation.
- `ai-client.module.spec.ts` — 4 cases: module bootstrap, all four wrappers resolved, env-driven `AI_CONFIG`, shared credentials across stubs.

**Total BFF spec suite: 443 → 461 (after merge accounting), all passing.**

## Notes for the reviewer

- **Why both `.proto` and generated `.ts` are committed.** Hermetic builds. Reviewers see both sides of a contract change in one diff. CI never runs codegen — drift between proto and stub would otherwise hide behind a successful build. The drift gate that compares the vendored copy against an upstream tag of `apf-ai-service` is the post-v1 follow-up listed in ADR-0024's "What's next".
- **`HashUserIdService` declared locally in `AiClientModule`.** Two instances of the service exist when both `AuditModule` (global) and `AiClientModule` are wired into `AppModule`. The cost is one extra constructor call at bootstrap; the value is full test isolation of `AiClientModule` and a self-contained Principal-mapping boundary. The cross-service hash join invariant from ADR-0013 still holds because the service is a pure function of the `LOG_USER_ID_SALT` env var.
- **`AiClientModule` is NOT imported by `AppModule`.** Deliberately. The skeleton compiles, type-checks, and unit-tests cleanly with no runtime side effects. The next PR (SSE bridge controller) adds the `imports: [AiClientModule]` line + the controller, in one focused change.
- **`ts-proto` flat-oneof emission.** `ChatEvent` is generated with optional siblings (`token?`, `citation?`, `done?`, …) rather than a discriminated union (`$case: 'token'`). The flatter shape composes more naturally with the SSE writer the next PR will introduce (`event:` field name maps directly to the populated sibling).
- **Cancellation test deliberately relaxed.** The "AbortSignal already aborted before call dial" test asserts the client-side outcome (no payload, error or clean end) but not server-side observation. gRPC-js may or may not propagate a cancel frame depending on whether the call had time to dial — both outcomes are correct per the contract; only the absence of payload matters.
- **Lifecycle (`onApplicationShutdown`) deferred.** The module does not close the gRPC channel on shutdown today. Process termination closes the sockets via OS-level descriptor reclaim — sufficient for dev/preprod. The next PR wires the module into `AppModule` and adds an explicit Nest lifecycle hook in the same change (paired with `app.enableShutdownHooks()` in `main.ts`).

## Test plan

- [x] `pnpm run grpc:codegen` — clean regeneration. Generated tree byte-identical to what's committed.
- [x] `pnpm nx test portal-bff` — **443 specs pass** (was 425).
- [x] `pnpm nx lint portal-bff` — clean. The eslint ignore for `grpc/gen/**` covers ts-proto's relaxed style; hand-written `ai-client/` files pass the project's full rule set.
- [x] `pnpm nx build portal-bff` — clean, webpack-compiled. No bundle-size delta on the runtime artefact yet (the module is unimported).
- [x] `pnpm install` — lockfile reconciled; `grpc-tools` postinstall fetches `protoc` from the precompiled-binaries mirror without errors on Linux x64.
- [ ] **Manual smoke (next PR)** — once the SSE bridge ships, point `AI_SERVICE_GRPC_ENDPOINT` at the local `apf-ai-service` Compose service and run an end-to-end chat against the canned stub responses on the AI side. Not in scope for this PR.

## What's next

1. **PR — SSE bridge controller.** Wires `AiClientModule` into `AppModule`, adds `POST /api/ai/chat` (SSE), `GET /api/ai/rag/search`, `GET /api/ai/models`. Adds the `OnApplicationShutdown` hook + `enableShutdownHooks()`. Adds `apf-ai-service` to `infra/local/dev.compose.yml`. Promotes ADR-0024 from `proposed` to `accepted` and updates `CLAUDE.md`'s ADR roll-up.
2. **PR (frontend chantier)** — chatbot widget on `portal-shell` consuming the SSE endpoint.
3. **PR (post-v1)** — proto-drift CI gate diffing the vendored `proto/apf-ai/` against the upstream tag.
4. **Coordinated amendment** — when the first production deployment is in scope, both repos record the same prod-hardening choice (signed envelope or mTLS) on the same date.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #195
This commit was merged in pull request #195.
This commit is contained in:
2026-05-19 21:30:04 +02:00
parent 3f3f47317b
commit 9b7d16601d
32 changed files with 6396 additions and 11 deletions
+110 -6
View File
@@ -49,6 +49,12 @@ importers:
'@azure/msal-node':
specifier: ^5.2.1
version: 5.2.1
'@bufbuild/protobuf':
specifier: ^2.10.2
version: 2.12.0
'@grpc/grpc-js':
specifier: ^1.13.0
version: 1.14.3
'@nestjs/common':
specifier: ^11.0.0
version: 11.1.21(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)
@@ -160,6 +166,9 @@ importers:
jose:
specifier: ^6.2.3
version: 6.2.3
long:
specifier: ^5.2.3
version: 5.3.2
lucide-angular:
specifier: ^1.0.0
version: 1.0.0(@angular/common@21.2.13(@angular/core@21.2.13(@angular/compiler@21.2.13)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@21.2.13(@angular/compiler@21.2.13)(rxjs@7.8.2)(zone.js@0.16.2))
@@ -347,6 +356,9 @@ importers:
eslint-plugin-playwright:
specifier: ^1.6.2
version: 1.8.3(eslint@9.39.4(jiti@2.7.0))
grpc-tools:
specifier: ^1.13.0
version: 1.13.1(encoding@0.1.13)
husky:
specifier: ^9.1.7
version: 9.1.7
@@ -395,6 +407,9 @@ importers:
ts-node:
specifier: 10.9.2
version: 10.9.2(@swc/core@1.15.33(@swc/helpers@0.5.21))(@types/node@24.12.4)(typescript@5.9.3)
ts-proto:
specifier: ^2.7.0
version: 2.11.8
tslib:
specifier: ^2.3.0
version: 2.8.1
@@ -2541,6 +2556,11 @@ packages:
resolution: {integrity: sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==}
engines: {node: '>=8'}
'@mapbox/node-pre-gyp@2.0.3':
resolution: {integrity: sha512-uwPAhccfFJlsfCxMYTwOdVfOz3xqyj8xYL3zJj8f0pb30tLohnnFPhLuqp4/qoEz8sNxe4SESZedcBojRefIzg==}
engines: {node: '>=18'}
hasBin: true
'@mermaid-js/mermaid-mindmap@9.3.0':
resolution: {integrity: sha512-IhtYSVBBRYviH1Ehu8gk69pMDF8DSRqXBRDMWrEfHoaMruHeaP2DXA3PBnuwsMaCdPQhlUUcy/7DBLAEIXvCAw==}
@@ -5354,6 +5374,10 @@ packages:
resolution: {integrity: sha512-nrUSn7hzt7J6JWgWGz78ZYI8wj+gdIJdk0Ynjpp8l+trkn58Uqsf6RYrYkEK+3X18EX+TNdtJI0WxAtc+L84SQ==}
hasBin: true
abbrev@3.0.1:
resolution: {integrity: sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg==}
engines: {node: ^18.17.0 || >=20.5.0}
abbrev@4.0.0:
resolution: {integrity: sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA==}
engines: {node: ^20.17.0 || >=22.9.0}
@@ -5855,6 +5879,10 @@ packages:
caniuse-lite@1.0.30001793:
resolution: {integrity: sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==}
case-anything@2.1.13:
resolution: {integrity: sha512-zlOQ80VrQ2Ue+ymH5OuM/DlDq64mEm+B9UTdHULv5osUMD6HalNTblf2b1u/m6QecjsnOkBpqVZ+XPwIVsy7Ng==}
engines: {node: '>=12.13'}
ccount@2.0.1:
resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==}
@@ -6613,6 +6641,11 @@ packages:
resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==}
engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16}
detect-libc@1.0.3:
resolution: {integrity: sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==}
engines: {node: '>=0.10'}
hasBin: true
detect-libc@2.1.2:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
@@ -6679,6 +6712,9 @@ packages:
resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==}
engines: {node: '>=12'}
dprint-node@1.0.8:
resolution: {integrity: sha512-iVKnUtYfGrYcW1ZAlfR/F59cUVL8QIhWoBJoSjkkdua/dkWIgjZfiLMeTjiB06X0ZLkQ0M2C1VbUj/CxkIf1zg==}
dunder-proto@1.0.1:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'}
@@ -7319,6 +7355,10 @@ packages:
graceful-fs@4.2.11:
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
grpc-tools@1.13.1:
resolution: {integrity: sha512-0sttMUxThNIkCTJq5qI0xXMz5zWqV2u3yG1kR3Sj9OokGIoyRBFjoInK9NyW7x5fH7knj48Roh1gq5xbl0VoDQ==}
hasBin: true
hachure-fill@0.5.2:
resolution: {integrity: sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==}
@@ -8715,6 +8755,11 @@ packages:
non-layered-tidy-tree-layout@2.0.2:
resolution: {integrity: sha512-gkXMxRzUH+PB0ax9dUN0yYF0S25BqeAYqhgMaLUFmpXLEk7Fcu8f4emJuOAY0V8kjDICxROIKsTAKsV/v355xw==}
nopt@8.1.0:
resolution: {integrity: sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A==}
engines: {node: ^18.17.0 || >=20.5.0}
hasBin: true
nopt@9.0.0:
resolution: {integrity: sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw==}
engines: {node: ^20.17.0 || >=22.9.0}
@@ -10547,6 +10592,16 @@ packages:
'@swc/wasm':
optional: true
ts-poet@6.12.0:
resolution: {integrity: sha512-xo+iRNMWqyvXpFTaOAvLPA5QAWO6TZrSUs5s4Odaya3epqofBu/fMLHEWl8jPmjhA0s9sgj9sNvF1BmaQlmQkA==}
ts-proto-descriptors@2.1.0:
resolution: {integrity: sha512-S5EZYEQ6L9KLFfjSRpZWDIXDV/W7tAj8uW7pLsihIxyr62EAVSiKuVPwE8iWnr849Bqa53enex1jhDUcpgquzA==}
ts-proto@2.11.8:
resolution: {integrity: sha512-+5hzECnyVB33jxjG1BIdzAHcRBm7hjnm8womdJVp2A7xJWihP0drHHVsXYTr9i/LpWNGfh80I+AVVNzFM5AwJw==}
hasBin: true
tsconfig-paths-webpack-plugin@4.2.0:
resolution: {integrity: sha512-zbem3rfRS8BgeNK50Zz5SIQgXzLafiHjOwUAvk/38/o1jHn/V5QAgVUcz884or7WYcPaH3N2CIfUc2u0ul7UcA==}
engines: {node: '>=10.13.0'}
@@ -13702,6 +13757,19 @@ snapshots:
'@lukeed/csprng@1.1.0': {}
'@mapbox/node-pre-gyp@2.0.3(encoding@0.1.13)':
dependencies:
consola: 3.4.2
detect-libc: 2.1.2
https-proxy-agent: 7.0.6
node-fetch: 2.7.0(encoding@0.1.13)
nopt: 8.1.0
semver: 7.8.0
tar: 7.5.15
transitivePeerDependencies:
- encoding
- supports-color
'@mermaid-js/mermaid-mindmap@9.3.0':
dependencies:
'@braintree/sanitize-url': 6.0.4
@@ -14157,7 +14225,7 @@ snapshots:
'@npmcli/fs@5.0.0':
dependencies:
semver: 7.7.4
semver: 7.8.0
'@npmcli/git@7.0.2':
dependencies:
@@ -14167,7 +14235,7 @@ snapshots:
lru-cache: 11.3.6
npm-pick-manifest: 11.0.3
proc-log: 6.1.0
semver: 7.7.4
semver: 7.8.0
which: 6.0.1
'@npmcli/installed-package-contents@4.0.0':
@@ -14184,7 +14252,7 @@ snapshots:
hosted-git-info: 9.0.3
json-parse-even-better-errors: 5.0.0
proc-log: 6.1.0
semver: 7.7.4
semver: 7.8.0
spdx-expression-parse: 4.0.0
'@npmcli/promise-spawn@9.0.1':
@@ -16984,6 +17052,8 @@ snapshots:
dependencies:
argparse: 2.0.1
abbrev@3.0.1: {}
abbrev@4.0.0: {}
accepts@1.3.8:
@@ -17564,6 +17634,8 @@ snapshots:
caniuse-lite@1.0.30001793: {}
case-anything@2.1.13: {}
ccount@2.0.1: {}
chai@6.2.2: {}
@@ -18318,6 +18390,8 @@ snapshots:
destroy@1.2.0: {}
detect-libc@1.0.3: {}
detect-libc@2.1.2: {}
detect-newline@3.1.0: {}
@@ -18376,6 +18450,10 @@ snapshots:
dotenv@16.6.1: {}
dprint-node@1.0.8:
dependencies:
detect-libc: 1.0.3
dunder-proto@1.0.1:
dependencies:
call-bind-apply-helpers: 1.0.2
@@ -19158,6 +19236,13 @@ snapshots:
graceful-fs@4.2.11: {}
grpc-tools@1.13.1(encoding@0.1.13):
dependencies:
'@mapbox/node-pre-gyp': 2.0.3(encoding@0.1.13)
transitivePeerDependencies:
- encoding
- supports-color
hachure-fill@0.5.2: {}
handle-thing@2.0.1: {}
@@ -20756,7 +20841,7 @@ snapshots:
graceful-fs: 4.2.11
nopt: 9.0.0
proc-log: 6.1.0
semver: 7.7.4
semver: 7.8.0
tar: 7.5.15
tinyglobby: 0.2.16
undici: 6.25.0
@@ -20775,6 +20860,10 @@ snapshots:
non-layered-tidy-tree-layout@2.0.2:
optional: true
nopt@8.1.0:
dependencies:
abbrev: 3.0.1
nopt@9.0.0:
dependencies:
abbrev: 4.0.0
@@ -20787,7 +20876,7 @@ snapshots:
npm-install-checks@8.0.0:
dependencies:
semver: 7.7.4
semver: 7.8.0
npm-normalize-package-bin@5.0.0: {}
@@ -20808,7 +20897,7 @@ snapshots:
npm-install-checks: 8.0.0
npm-normalize-package-bin: 5.0.0
npm-package-arg: 13.0.2
semver: 7.7.4
semver: 7.8.0
npm-registry-fetch@19.1.1:
dependencies:
@@ -22881,6 +22970,21 @@ snapshots:
optionalDependencies:
'@swc/core': 1.15.33(@swc/helpers@0.5.21)
ts-poet@6.12.0:
dependencies:
dprint-node: 1.0.8
ts-proto-descriptors@2.1.0:
dependencies:
'@bufbuild/protobuf': 2.12.0
ts-proto@2.11.8:
dependencies:
'@bufbuild/protobuf': 2.12.0
case-anything: 2.1.13
ts-poet: 6.12.0
ts-proto-descriptors: 2.1.0
tsconfig-paths-webpack-plugin@4.2.0:
dependencies:
chalk: 4.1.2