741fc07d405301c1e1477e97d3f74298d1a66862
4 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
9b7d16601d |
feat(portal-bff): ai-client skeleton — vendored protos + grpc client + Principal mapper (#195)
## 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 |
||
|
|
eb8b65c7bc |
feat(shared-charts): foundations + bar / donut / stacked-bar components (#171)
## Summary Implementation of [ADR-0023](docs/decisions/0023-charts-d3-observable-plot.md) — the foundations of the workspace's chart library. PR 2 of the chantier: | PR | Périmètre | | --- | --- | | PR 1 ✅ | ADR-0023 — decision + a11y contract + bundle plan. | | **PR 2 (this one)** | `libs/shared/charts/` foundations + `<lib-bar-chart>`, `<lib-donut-chart>`, `<lib-stacked-bar-chart>`. | | PR 3 | Integration on the `/audit` page — daily-volume bar + outcome-breakdown donut + event-type-over-time stacked bar. | ## What lands ### Workspace deps ``` d3 — top-level toolkit, types via @types/d3 d3-shape — used directly by <lib-donut-chart> d3-scale-chromatic — colour-blind-safe palette source @observablehq/plot — declarative layer over D3, used by bar + stacked-bar ``` All four (+ matching `@types/*`) land in the workspace root `devDependencies`. Tree-shaken at build time per ADR-0023's bundle plan. ### New lib `libs/shared/charts/` ``` libs/shared/charts/src/lib/ ├── _internal/ ← single source of truth for the a11y contract │ ├── a11y.ts ← chartId, findChartSvg, injectSvgTitleDesc, prefersReducedMotion, resolveTheme │ ├── chart-envelope.scss ← shared figure / caption / fallback / dark-mode rules │ ├── chart-types.ts ← `ChartBaseInputs<T>` extended by each component │ └── palette.ts ← Viridis / Cividis (sequential) + ColorBrewer Set2 (categorical) ├── bar-chart/ ← Plot.barY ├── donut-chart/ ← raw d3-shape (pie + arc); Plot has no donut mark └── stacked-bar-chart/ ← Plot.barY with `fill: <seriesKey>` (auto-stacked, legend on) ``` ### A11y contract baked in for v1 Per ADR-0023's six commitments, every chart component produces (and unit-tests for): 1. `<figure role="img" aria-labelledby aria-describedby>` wrapping the SVG. 2. SVG `<title>` + `<desc>` as the **first two children** — injected post-render via `injectSvgTitleDesc` because Plot doesn't emit them itself. `findChartSvg` handles both Plot output shapes (bare SVG, or `<figure>` wrapping a legend + SVG for `legend: true` configs). 3. A `<details>` disclosure with a `<table>` rendering every data point — the keyboard-navigable / screen-reader-friendly fallback for non-visual users. 4. Palette from `_internal/palette.ts` only — Viridis / Cividis for sequential, ColorBrewer Set2 for categorical. Both colour-blind-safe. 5. AA-contrast axis text via `:where(.dark)` flips in `_internal/chart-envelope.scss`. 6. `prefers-reduced-motion` → `data-no-transitions` marker on the SVG, CSS strips animations + transitions. A custom ESLint rule in [`libs/shared/charts/eslint.config.mjs`](libs/shared/charts/eslint.config.mjs) bans direct imports of `d3-scale-chromatic` outside `_internal/palette.ts` so a future contributor can't bypass the colour-blind-safe contract. ### Component contract Every `<lib-*-chart>` exposes the same Signal-based shape per ADR-0023: ```ts [data]: readonly T[]; [caption]: string; [description]: string; [ariaLabel]: string; [colorScheme]?: 'sequential' | 'categorical'; // + chart-specific keys (xKey, yKey, categoryKey, valueKey, seriesKey, …) ``` Re-renders triggered by Angular's `effect()` on input changes; the previous SVG is `replaceChildren`-d out so there's no DOM accumulation across data updates. ## Notes for the reviewer - **Why three SCSS files importing one shared envelope?** Extracted at the third consumer per CLAUDE.md's "three similar lines is better than a premature abstraction" rule — bar + donut + stacked-bar share ~70 LOC of figure / caption / fallback / dark-mode chrome. `_internal/chart-envelope.scss` is the consolidation; each chart's `.scss` is now 4-20 LOC of chart-specific tweaks. - **Why is `<lib-donut-chart>` raw D3 rather than Plot?** Plot's design philosophy explicitly excludes pie/donut marks ("a bar chart is almost always more legible"). The audit-log outcome breakdown reads naturally as a donut (the centre carries the total). Raw `d3-shape` is the lower-level fallback ADR-0023 reserves precisely for this kind of case; the component's API is identical to the Plot-backed siblings. - **Why does the donut also stamp per-slice `<title>`?** Belt-and-suspenders. The top-level SVG `<title>` reads the caption; per-slice `<title>` reads "category: value" on hover (the SVG-native tooltip convention) for keyboard / screen-reader users who land on a specific slice. - **Why no `pnpm.overrides` adjustment for `d3-*` transitives?** None of the new deps brought a vulnerability in this install. The `pnpm audit --audit-level=moderate` gate from #161 stays green. - **What's deliberately deferred to PR 3?** The `/audit`-page integration — data aggregation from the current page (`AdminAuditPage` rows already loaded), the actual `<lib-*-chart>` placements above the existing table, the i18n strings for the captions / descriptions / aria-labels. No mock SAMPLE data shipped in this PR — every test uses local fixtures so the lib stays decoupled from any specific consumer. ## Test plan - [x] `pnpm nx test shared-charts` — **13 specs pass** across the three components. - [x] `pnpm nx lint shared-charts` — clean, including the custom `no-restricted-imports` guard on `d3-scale-chromatic`. - [x] `pnpm nx build shared-charts` — clean (TS strict + ng-packagr). - [x] `pnpm nx run-many -t lint test build --projects=shared-charts,portal-shell,portal-admin` — 12/12 tasks green. - [x] `pnpm nx build portal-shell --configuration=production` — i18n-strict prod build clean. The lib ships no i18n marks (axis labels are caller-supplied per ADR-0023's i18n posture), so no xlf entry change here. - [ ] **Visual smoke (deferred to PR 3 when the components are placed on `/audit`)** — `<figure>` + caption visible, SVG `<title>` exposed by VoiceOver / NVDA, `<details>` fallback expands to a table, dark-mode toggle flips axis text + Cividis palette, `prefers-reduced-motion` strips Plot's fade-in. ## What's next PR 3 wires the three components onto the `/audit` page: aggregates `AdminAuditPage.items` client-side into the three required shapes (daily totals, outcome counts, daily-by-event-type pivots), places them above the existing filter form, ships the i18n strings for the captions and descriptions in `messages.fr.xlf`. Bundle impact on the lazy `audit` chunk gets verified there (the ~65 KB gzip plan from the ADR). --------- Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr> Reviewed-on: #171 |
||
|
|
8de19320c5 |
chore: generate shared and feature libs with module boundaries per ADR-0003
Generate the four phase-4 libraries: - libs/shared/tokens (project name shared-tokens) - plain TS lib via @nx/js:library; will host the a11y design tokens (palette, contrast tiers, spacing, motion) once Tailwind lands in phase 5; consumable by both apps; tagged scope:shared, type:shared. - libs/shared/util (shared-util) - plain TS lib for cross-cutting utility code; tagged scope:shared, type:shared. - libs/shared/ui (shared-ui) - Angular standalone library that will host the spartan-ng components copy-pasted in phase 5; Angular-only so tagged scope:portal-shell, type:shared. unitTestRunner= vitest-analog because vitest-angular requires a buildable lib. - libs/feature/auth (feature-auth) - placeholder Angular standalone feature lib to demonstrate the type:feature pattern; tagged scope:portal-shell, type:feature. @nx/enforce-module-boundaries depConstraints replaced (root eslint.config.mjs) with the rules from ADR-0003: scope:portal-shell -> scope:portal-shell, scope:shared scope:portal-bff -> scope:portal-bff, scope:shared scope:shared -> scope:shared type:app -> type:feature, type:shared type:feature -> type:feature, type:shared type:shared -> type:shared This forbids portal-shell from importing portal-bff code (and vice versa) and prevents shared libs from depending on feature libs. Project names follow the convention of ADR-0003 (feature-<name> / shared-<scope>) by passing --name explicitly to the generator; the Nx 22 default takes only the last directory segment. Sanity check: pnpm nx run-many -t lint and -t test pass for the 8 projects (4 apps/e2e + 4 libs). Side effects from the generators: tsconfig.base.json paths populated with the lib import aliases; nx.json gains vite/playwright plugin entries; .gitignore picks up vitest.config.*.timestamp* (Vitest temp files); package.json gains @analogjs/vitest-angular and related devDeps; pnpm-lock.yaml regenerated. Two eslint-disable comments in portal-bff-e2e support files were trimmed by lint --fix - those files already lint clean without the directive. |
||
|
|
bea5e1954f |
chore: generate portal-shell and portal-bff apps per ADR-0004 / ADR-0005
Add the @nx/angular, @nx/nest, @nx/vite, @nx/eslint plugins, then generate the two apps. Adjust the empty-template tsconfig.base.json to be Angular-compatible (drop project references and customConditions that the empty-template defaults to but Angular doesn't support; keep the strict-TS extensions from ADR-0004). apps/portal-shell (Angular 21): - standalone APIs, routing, SCSS, esbuild - vitest-angular as unitTestRunner, playwright for e2e - strict mode - tags scope:portal-shell, type:app - app.config.ts wired with provideZonelessChangeDetection() per ADR-0004 (Angular 21 + Nx 22 generates without zone.js by default) apps/portal-bff (NestJS 11): - Express adapter (default per ADR-0005) - Jest as unitTestRunner - tags scope:portal-bff, type:app - main.ts wired with a global ValidationPipe configured whitelist + forbidNonWhitelisted + transform per ADR-0005 - Phase-2 security additions (helmet, CORS, sessions, CSRF, rate limit, auth guards, error filter) deferred to their respective ADRs - placeholder comment in main.ts Workspace dependencies: class-validator + class-transformer added (required by NestJS ValidationPipe at runtime). Nx-generated .gitignore additions (.angular, __screenshots__) merged into ours. .vscode/extensions.json and launch.json added by Nx are kept (do not override our existing settings.json). |