feat(portal-bff): ai-client skeleton — vendored protos + grpc client + Principal mapper #195
Reference in New Issue
Block a user
Delete Branch "feat/portal-bff-ai-client-skeleton"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
Step 2 of the AI-relay chantier (after ADR-0024 merged in #194). Lands the BFF-side skeleton that talks to
apf-ai-serviceover 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:
AiClientModuleis built but is NOT imported inAppModuleyet. 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 ofapf-ai-service/contract/proto/(common, chat, rag, ingestion, models). Both the.protofiles and the regeneratedts-protooutput undergrpc/gen/are committed for hermetic builds and reviewable diffs (per ADR-0024 §"Sub-decision 3").pnpm run grpc:codegen— regenerates the stubs viagrpc-tools' bundledprotocand thets-protoplugin (outputServices=grpc-js, esModuleInterop, forceLong=long, useOptionals=messages, exportCommonSymbols=false).pnpm run grpc:sync— copies the vendored.protofiles from the siblingapf-ai-serviceworking 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.grpc/gen/**) excluded from Prettier (.prettierignore) and ESLint (eslint.config.mjsignores). Hand-rules apply to wrappers underai-client/, not to codegen output.Dependencies
@grpc/grpc-js@^1.13.0— runtime gRPC client.@bufbuild/protobuf@^2.10.2— wire codec used byts-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, shipsprotoc+ the gRPC plugin; added topnpm.onlyBuiltDependenciesso the postinstall binary download runs.Env validator
apps/portal-bff/src/config/check-ai-service-config.tsfollows 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:AI_SERVICE_GRPC_ENDPOINThost:portreachable from the BFF —apf-ai-service:8080in dev Compose, service DNS + 443 in prodAI_SERVICE_CLIENT_IDx-client-idmetadata. Convention:apf-portal-<env>AI_SERVICE_GRPC_TLStrue)falsefor h2c in dev,truefor h2 + TLS in prod7 spec cases lock the validation contract end-to-end.
AiClientModuleapps/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 protoPrincipal.subjectis hashed viaHashUserIdService(the same salt + algorithm the audit writer uses) soPrincipal.subjectmatchesaudit.events.actor_id_hashbyte-for-byte.rolespasses 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 withx-client-id(from config) andx-correlation-id(active OTel span's trace-id when present, else explicit override, else fresh UUID).chat.client.ts— server-stream wrapper aroundChatServiceClient. Returns the rawClientReadableStream<ChatEvent>(NodeReadableis async-iterable so the SSE bridge consumes withfor await). OptionalAbortSignalpropagates browser disconnect tocall.cancel().rag.client.ts,models.client.ts,ingestion.client.ts— unary promisified wrappers.IngestionClientis 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.HashUserIdServiceis declared locally rather than imported viaAuditModule(the global module) to keep the module unit-testable in isolation; runtime equivalence is guaranteed by the service being a pure function ofLOG_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 thetenantIdreserved-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,ServiceErrorpropagation.ai-client.module.spec.ts— 4 cases: module bootstrap, all four wrappers resolved, env-drivenAI_CONFIG, shared credentials across stubs.Total BFF spec suite: 443 → 461 (after merge accounting), all passing.
Notes for the reviewer
.protoand generated.tsare 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 ofapf-ai-serviceis the post-v1 follow-up listed in ADR-0024's "What's next".HashUserIdServicedeclared locally inAiClientModule. Two instances of the service exist when bothAuditModule(global) andAiClientModuleare wired intoAppModule. The cost is one extra constructor call at bootstrap; the value is full test isolation ofAiClientModuleand 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 theLOG_USER_ID_SALTenv var.AiClientModuleis NOT imported byAppModule. Deliberately. The skeleton compiles, type-checks, and unit-tests cleanly with no runtime side effects. The next PR (SSE bridge controller) adds theimports: [AiClientModule]line + the controller, in one focused change.ts-protoflat-oneof emission.ChatEventis 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).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 intoAppModuleand adds an explicit Nest lifecycle hook in the same change (paired withapp.enableShutdownHooks()inmain.ts).Test plan
pnpm run grpc:codegen— clean regeneration. Generated tree byte-identical to what's committed.pnpm nx test portal-bff— 443 specs pass (was 425).pnpm nx lint portal-bff— clean. The eslint ignore forgrpc/gen/**covers ts-proto's relaxed style; hand-writtenai-client/files pass the project's full rule set.pnpm nx build portal-bff— clean, webpack-compiled. No bundle-size delta on the runtime artefact yet (the module is unimported).pnpm install— lockfile reconciled;grpc-toolspostinstall fetchesprotocfrom the precompiled-binaries mirror without errors on Linux x64.AI_SERVICE_GRPC_ENDPOINTat the localapf-ai-serviceCompose 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
AiClientModuleintoAppModule, addsPOST /api/ai/chat(SSE),GET /api/ai/rag/search,GET /api/ai/models. Adds theOnApplicationShutdownhook +enableShutdownHooks(). Addsapf-ai-servicetoinfra/local/dev.compose.yml. Promotes ADR-0024 fromproposedtoacceptedand updatesCLAUDE.md's ADR roll-up.portal-shellconsuming the SSE endpoint.proto/apf-ai/against the upstream tag.