import { Module, type Provider } from '@nestjs/common'; import { ChannelCredentials } 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'; import { IngestionServiceClient } from '../gen/apf-ai/ingestion'; import { ModelsServiceClient } from '../gen/apf-ai/models'; import { RagServiceClient } from '../gen/apf-ai/rag'; import { ChatClient } from './chat.client'; import { GrpcMetadataBuilder } from './grpc-metadata.builder'; import { IngestionClient } from './ingestion.client'; import { ModelsClient } from './models.client'; import { PrincipalMapper } from './principal.mapper'; import { RagClient } from './rag.client'; import { AI_CHAT_GRPC_CLIENT, AI_CONFIG, AI_CREDENTIALS, AI_INGESTION_GRPC_CLIENT, AI_MODELS_GRPC_CLIENT, AI_RAG_GRPC_CLIENT, } from './tokens'; /** * Wires the BFF's integration surface to `apf-ai-service`, per * [ADR-0024](../../../../../docs/decisions/0024-ai-service-relay-grpc-sse-bridge.md). * * The module is intentionally NOT imported in `AppModule` yet — the * SSE-bridge controller that consumes these clients ships in the * next chantier. Until then the module compiles, type-checks, and * unit-tests against an in-process fake gRPC server, but it does not * register any HTTP route. * * Provider layout: * * 1. `AI_CONFIG` factory reads + validates `AI_SERVICE_*` env vars. * 2. `AI_CREDENTIALS` factory builds the gRPC channel credentials * (insecure h2c in dev, system-CA-trusted SSL in prod). * 3. One token per generated stub — `AI_CHAT_GRPC_CLIENT` etc. — * backed by a factory that opens a `Client` against the * configured endpoint. gRPC-js multiplexes the underlying * HTTP/2 connection across stubs sharing the same address + * credentials, so the four stubs share one TCP+TLS channel. * 4. Hand-written wrapper services (`ChatClient`, `RagClient`, * `IngestionClient`, `ModelsClient`) add metadata injection * and promisification on top of the generated stubs. * 5. `PrincipalMapper` + `GrpcMetadataBuilder` collaborators. * * `PrincipalMapper` depends on `HashUserIdService` — the same hash * service the audit writer uses — so the `Principal.subject` field * matches `audit.events.actor_id_hash` exactly (per ADR-0024 * §"Audit cross-referencing"). `HashUserIdService` is declared as a * local provider here (rather than relying on `AuditModule`'s * `@Global()` export) for two reasons: * * 1. **Test isolation.** The module compiles standalone with no * other portal module in scope; no need to pull `AuditWriter` * and its Prisma + CLS deps into a unit test that only exercises * the gRPC surface. * 2. **Self-containment of the wire boundary.** The Principal-side * contract is a property of `AiClientModule`, not of the audit * module. Co-locating the hash provider keeps the boundary * readable from one file. * * Functional identity with the audit-side instance is guaranteed by * `HashUserIdService` being purely a function of * `LOG_USER_ID_SALT` — same env value ⇒ same hash output, regardless * of how many instances exist. The cross-service join key invariant * from ADR-0013 still holds. */ function buildCredentials(config: AiServiceConfig): ChannelCredentials { return config.useTls ? ChannelCredentials.createSsl() : ChannelCredentials.createInsecure(); } const grpcStubProviders: Provider[] = [ { provide: AI_CHAT_GRPC_CLIENT, inject: [AI_CONFIG, AI_CREDENTIALS], useFactory: (config: AiServiceConfig, credentials: ChannelCredentials) => new ChatServiceClient(config.endpoint, credentials), }, { provide: AI_RAG_GRPC_CLIENT, inject: [AI_CONFIG, AI_CREDENTIALS], useFactory: (config: AiServiceConfig, credentials: ChannelCredentials) => new RagServiceClient(config.endpoint, credentials), }, { provide: AI_INGESTION_GRPC_CLIENT, inject: [AI_CONFIG, AI_CREDENTIALS], useFactory: (config: AiServiceConfig, credentials: ChannelCredentials) => new IngestionServiceClient(config.endpoint, credentials), }, { provide: AI_MODELS_GRPC_CLIENT, inject: [AI_CONFIG, AI_CREDENTIALS], useFactory: (config: AiServiceConfig, credentials: ChannelCredentials) => new ModelsServiceClient(config.endpoint, credentials), }, ]; @Module({ providers: [ { provide: AI_CONFIG, useFactory: () => assertAiServiceConfig(), }, { provide: AI_CREDENTIALS, inject: [AI_CONFIG], useFactory: buildCredentials, }, ...grpcStubProviders, HashUserIdService, GrpcMetadataBuilder, PrincipalMapper, ChatClient, RagClient, IngestionClient, ModelsClient, ], 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. }