Compare commits

..

113 Commits

Author SHA1 Message Date
APF Portal Bot b08966ba1f chore(deps): update analog monorepo to v2.5.3 (#266)
CI / scan (push) Successful in 2m21s
CI / commits (push) Has been skipped
CI / check (push) Successful in 5m20s
CI / a11y (push) Successful in 2m2s
CI / perf (push) Successful in 3m55s
Docs site / build (push) Successful in 3m47s
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| [@analogjs/vite-plugin-angular](https://github.com/analogjs/analog) | devDependencies | patch | [`2.5.2` -> `2.5.3`](https://renovatebot.com/diffs/npm/@analogjs%2fvite-plugin-angular/2.5.2/2.5.3) |
| [@analogjs/vitest-angular](https://analogjs.org) ([source](https://github.com/analogjs/analog)) | devDependencies | patch | [`2.5.2` -> `2.5.3`](https://renovatebot.com/diffs/npm/@analogjs%2fvitest-angular/2.5.2/2.5.3) |

---

### Release Notes

<details>
<summary>analogjs/analog (@&#8203;analogjs/vite-plugin-angular)</summary>

### [`v2.5.3`](https://github.com/analogjs/analog/blob/HEAD/CHANGELOG.md#253-2026-06-01)

[Compare Source](https://github.com/analogjs/analog/compare/v2.5.2...v2.5.3)

##### Bug Fixes

- **vite-plugin-angular:** aggregate and locate template diagnostics on the default compilation path ([#&#8203;2354](https://github.com/analogjs/analog/issues/2354)) ([c7947ef](https://github.com/analogjs/analog/commit/c7947ef87723a02f80049cf30ac2829f3dce5658))
- **vite-plugin-angular:** drop transform from aot input metadata ([c5ab296](https://github.com/analogjs/analog/commit/c5ab2960833ee8a81350fa9d0465b79128d9428b))
- **vite-plugin-angular:** drop transform from input() jit metadata ([3fcc05c](https://github.com/analogjs/analog/commit/3fcc05cdc29deb450a261512033b206320c8e24e))
- **vite-plugin-angular:** emit isSignal on jit signal-query decorators ([0af2189](https://github.com/analogjs/analog/commit/0af21898ef1170168f9a77442f55534fbe0faf99)), closes [analogjs/analog#2344](https://github.com/analogjs/analog/issues/2344)
- **vite-plugin-angular:** preserve model() alias, required, and field ([88a6c75](https://github.com/analogjs/analog/commit/88a6c7504188af7cea30c3aa5026f961d33fe0a7))
- **vite-plugin-angular:** propagate required through aot model metadata ([f163239](https://github.com/analogjs/analog/commit/f163239045d38dcded27749a6efcb1dfc55f99cf))
- **vite-plugin-angular:** read outputFromObservable options from args\[1] ([f530f08](https://github.com/analogjs/analog/commit/f530f089ae0c2a8cf75a816a2543b75afd68d7ce))
- **vite-plugin-angular:** read outputFromObservable options from args\[1] in aot ([6f7ccb9](https://github.com/analogjs/analog/commit/6f7ccb90ca646247f28b16f7e75317484136f0cd))
- **vite-plugin-angular:** respect explicit declaration:false in lib-mode builds ([#&#8203;2352](https://github.com/analogjs/analog/issues/2352)) ([dfa4c95](https://github.com/analogjs/analog/commit/dfa4c9564262c64471f125d6bc7ae940a28de16d))
- **vite-plugin-angular:** skip aot signal synthesis when a matching decorator is on the field ([b823830](https://github.com/analogjs/analog/commit/b823830b4a10bb005eea5ba835d90feb4d06a3a6))
- **vite-plugin-angular:** skip signal downleveling when a matching decorator is on the field ([0221f10](https://github.com/analogjs/analog/commit/0221f1022c58ff65fa814353d038228777b24294))
- **vite-plugin-angular:** support build.lib + dedupe diagnostics on the Angular Compilation API path ([#&#8203;2353](https://github.com/analogjs/analog/issues/2353)) ([61e8a56](https://github.com/analogjs/analog/commit/61e8a56df42dd1e502fc056e810ed671e93c8e94))

</details>

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined).

🚦 **Automerge**: Enabled.

♻ **Rebasing**: Whenever PR is behind base branch, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about these updates again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0MC42Mi4xIiwidXBkYXRlZEluVmVyIjoiNDAuNjIuMSIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOlsiZGVwZW5kZW5jaWVzIl19-->

Reviewed-on: https://git.unespace.com/julien/apf_portal/pulls/266
Co-authored-by: APF Portal Bot <jgautier.webdev+apf-portal-bot@gmail.com>
Co-committed-by: APF Portal Bot <jgautier.webdev+apf-portal-bot@gmail.com>
2026-06-02 12:27:31 +02:00
julien 2a5ab4fb46 fix(gitignore): ignore infra/*-tenant.personas.json (ADR-0026) (#268)
CI / check (push) Successful in 2m8s
CI / commits (push) Has been skipped
CI / scan (push) Successful in 1m23s
CI / a11y (push) Successful in 1m32s
CI / perf (push) Successful in 6m15s
## Summary

Add `infra/*-tenant.personas.json` to `.gitignore` mirroring the existing `*-tenant.entra.json` pattern, so the per-persona Entra `oid` map consumed by `apps/portal-bff/prisma/seed.ts` (ADR-0026) cannot accidentally be committed. The schema template `infra/test-tenant.personas.example.json` was already in place; the matching ignore was missed when the personas file was introduced.

## Background

ADR-0026 §"Seed personas" introduced two parallel tenant-private files:

- **`*-tenant.entra.json`** — Entra security-group GUIDs → role-slug map (24 entries). Has been correctly ignored since shipped (`.gitignore` lines 34-36).
- **`*-tenant.personas.json`** — Per-persona Entra `oid` map (19 entries). The example template was committed correctly but the matching ignore pattern was never added.

Practical consequence: `infra/test-tenant.personas.json` has been showing up as untracked (`??`) in `git status` on the R&D Lead's machine since the seed personas work landed in late May, surviving multiple sessions and PR opens — and could have been accidentally `git add .`-ed at any point. Confirmed via `git log --all -- infra/test-tenant.personas.json` (empty) that no commit has touched it yet, so no history rewrite needed — pure forward-only fix.

## What lands

| File | Change |
| --- | --- |
| `.gitignore` | Three new lines mirroring the entra pattern: a comment, `infra/*-tenant.personas.json` (ignore), `!infra/*-tenant.personas.example.json` (keep the template tracked). |
| `infra/README.md` | New row in the top-level table for the per-persona oid map, pointing at the `.example.json` template and ADR-0026, right under the existing entra row. |

## Verification

- [x] `git check-ignore -v infra/test-tenant.personas.json` → matched by `.gitignore:40:infra/*-tenant.personas.json`.
- [x] `git check-ignore -v infra/test-tenant.personas.example.json` → not ignored (the `!` negation correctly preserves the example template as a tracked file).
- [x] `git status --short` no longer surfaces the `??` for the personas file.
- [x] `git log --all -- infra/test-tenant.personas.json` confirms zero commits ever referenced this path, so no `git rm --cached` or history rewrite is required.

## Related

- [ADR-0025](docs/decisions/0025-authorization-model-privileges-roles-scopes.md) — entra group map, the pattern this PR copies.
- [ADR-0026](docs/decisions/0026-person-user-portal-data-model.md) — Person/User/UserScope seed which consumes the personas map.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #268
2026-06-02 12:24:58 +02:00
julien c97f250836 chore(deps): bump dompurify override to >=3.4.5 (GHSA-87xg-pxx2-7hvx) (#267)
CI / commits (push) Has been skipped
CI / check (push) Successful in 4m45s
CI / scan (push) Successful in 2m31s
CI / a11y (push) Successful in 2m0s
CI / perf (push) Successful in 5m21s
Docs site / build (push) Successful in 5m1s
## Summary

Add a `pnpm.overrides` entry for `dompurify` to cover [GHSA-87xg-pxx2-7hvx](https://github.com/advisories/GHSA-87xg-pxx2-7hvx) — an XSS via `selectedcontent` re-clone in `dompurify@3.4.4`. Surfaced by `pnpm ci:audit`, same fix recipe as the prior `tmp` advisory (PR #240).

## Why this isn't an upstream upgrade

`dompurify` is a transitive of `mermaid` (which both the docs site VitePress plugin and the in-app diagrams use). The latest `mermaid@11.15.0` still declares `dompurify@^3.3.1` — that range happily includes `3.4.4`, so a `mermaid` bump (if one existed) does not move us off the vulnerable version. Pin via override, same pattern that already lives in `package.json` for `axios`, `tmp`, `esbuild`, etc.

## What lands

| File | Change |
| --- | --- |
| `package.json` | One line added to `pnpm.overrides`: `"dompurify@<3.4.5": ">=3.4.5"`. |
| `pnpm-lock.yaml` | `dompurify@3.4.4` → `dompurify@3.4.7` resolved (the current latest in the `>=3.4.5` window mermaid will accept). Net delta is minimal (+10 / -5 lines, single transitive bump). |

## Risk

Patch range bump on a tiny utility lib with a stable API. `3.4.5`, `3.4.6`, `3.4.7` are all sanitisation-only fixes per the changelog. Mermaid is the only consumer; it uses the documented `DOMPurify.sanitize` surface which has not changed.

## Test plan

- [x] `pnpm audit --audit-level=moderate` → `No known vulnerabilities found` (exit 0).
- [x] `pnpm why dompurify` shows a single resolved `dompurify@3.4.7` under both `mermaid` and `vitepress-plugin-mermaid` (no duplicate).
- [ ] CI green on Gitea (`check` + `scan` + `audit` jobs).
- [ ] Docs site (`pnpm docs:build`) builds; a page with a mermaid diagram renders correctly.
- [ ] Charts lib and any in-app mermaid surface render unchanged.

## Related

- [GHSA-87xg-pxx2-7hvx](https://github.com/advisories/GHSA-87xg-pxx2-7hvx) — the advisory.
- PR #240 (`chore(deps): bump tmp override to >=0.2.6`) — the same pattern this PR mirrors.
- [ADR-0022](docs/decisions/0022-docs-site-vitepress.md) — VitePress + mermaid pipeline, the consumer.
- [ADR-0023](docs/decisions/0023-charts-d3-observable-plot.md) — in-app charts, also goes through the same mermaid wrapper in places.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #267
2026-06-02 12:22:02 +02:00
julien b427576d5e feat(infra): single-file toggle between apps-profile dev modes (#265)
CI / check (push) Successful in 3m20s
CI / commits (push) Has been skipped
CI / scan (push) Failing after 1m45s
CI / a11y (push) Failing after 14m42s
CI / perf (push) Failing after 14m48s
## Summary

Make the toggle between the two `apps`-profile access modes — `localhost` (VSCode Remote-SSH port forwarding) and HTTPS hostname (`apf-portal.dev-XX.local` via the mkcert team CA) — a **single-file edit** in `infra/local/.env`. Today the switch needs touching two files (the BFF's own `.env` for the four `ENTRA_*_REDIRECT_URI`, plus `infra/local/.env` for `NX_SERVE_CONFIGURATION`), with the extra cost that the BFF `.env` then carries mode-specific values and can drift from the `localhost`-friendly defaults that native `nx serve` (no Docker) expects.

After this PR:

- `apps/portal-bff/.env` stays at `localhost` defaults always — native `nx serve` works untouched, no mode-aware editing of secrets-bearing files.
- `infra/local/.env` is the **only** file a developer touches to flip between modes. Mode A is the default. Mode B is a five-line block to uncomment.

## How

The four `ENTRA_*_REDIRECT_URI` values are added to the `portal-bff` service's `environment:` block in `dev.compose.yml` with Compose interpolation that defaults to `localhost` and accepts overrides from `infra/local/.env`:

```yaml
ENTRA_REDIRECT_URI: ${ENTRA_REDIRECT_URI:-http://localhost:3000/api/auth/callback}
ENTRA_POST_LOGOUT_REDIRECT_URI: ${ENTRA_POST_LOGOUT_REDIRECT_URI:-http://localhost:4200/}
ENTRA_ADMIN_REDIRECT_URI: ${ENTRA_ADMIN_REDIRECT_URI:-http://localhost:3000/api/admin/auth/callback}
ENTRA_ADMIN_POST_LOGOUT_REDIRECT_URI: ${ENTRA_ADMIN_POST_LOGOUT_REDIRECT_URI:-http://localhost:4300/}
```

Compose's `environment:` block wins over `env_file:`, so the BFF inside the container always sees these — `localhost` when nothing is set in `infra/local/.env`, the HTTPS hostname values when Mode B is enabled. The BFF's own `.env` is irrelevant to the container's redirect URIs in either mode; it remains the canonical source for `localhost`-friendly defaults that native `nx serve` (running outside Docker) reads as before.

## What lands

| File | Change |
| --- | --- |
| `infra/local/dev.compose.yml` | Four `ENTRA_*_REDIRECT_URI` lines added to the `portal-bff` service's `environment:` block with `${VAR:-localhost-default}` interpolation. Inline comment explains the mode-toggle intent and points at `infra/README.md`. |
| `infra/local/.env.example` | The end-of-file "Apps" block is restructured into two clearly labelled profiles: **Mode A — Localhost (DEFAULT)** (an empty block — nothing to set) and **Mode B — HTTPS hostname** (a commented five-line template the developer uncomments). |
| `apps/portal-bff/.env.example` | Comment block above the redirect-URI defaults updated: instead of telling the developer to override these values here, it now explains they stay at `localhost` regardless and points at the compose-level override in `infra/local/.env`. |
| `infra/README.md` | New "**Switching between dev modes — `localhost` vs hostname**" subsection between "Dockerised app dev mode" and "HTTPS dev-server setup". Comparison table + step-by-step for Mode A + pointer to the HTTPS / mkcert subsections that follow for Mode B. |

## Test plan

- [x] Compose validates with no env overrides — the four `ENTRA_*_REDIRECT_URI` resolve to their `localhost` defaults (Mode A).
- [x] Compose validates with the four `ENTRA_*` plus `NX_SERVE_CONFIGURATION=https` set in the environment — the URIs resolve to the HTTPS hostname values (Mode B).
- [ ] **On vm-jg**: starting from a fresh checkout, leave `infra/local/.env` at its `.env.example` defaults. `./infra/local/dev.sh up apps`. Open `http://localhost:4200/` via VSCode Remote-SSH port forwarding. Login succeeds — the BFF receives `ENTRA_REDIRECT_URI=http://localhost:3000/api/auth/callback`.
- [ ] **On vm-jg**: uncomment the five Mode B lines in `infra/local/.env` (replacing `dev-jg` with the actual hostname). `./infra/local/dev.sh down && up apps`. Open `https://apf-portal.dev-jg.local:4200/`. Login succeeds against the HTTPS URIs.
- [ ] **Native WSL `nx serve`** (no Docker): unchanged — keeps reading `apps/portal-bff/.env`'s `localhost` defaults; the compose override never runs in this path.

## Related

- [ADR-0030](docs/decisions/0030-dockerised-dev-mode.md) — dockerised dev mode this finishes for the toggle-between-modes case.
- PR #263 (`feat(spa): opt-in 'https' nx serve config`) — provides the SPA-side TLS plumbing the Mode B switch enables.
- PR #264 (`docs(infra): document team mkcert CA on vm-gitlab`) — documents the trust root that Mode B relies on.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #265
2026-06-02 01:32:35 +02:00
julien 2ffbfc4034 docs(infra): document team mkcert CA on vm-gitlab (cross-VM trust) (#264)
CI / check (push) Successful in 2m21s
CI / commits (push) Has been skipped
CI / scan (push) Failing after 1m27s
CI / a11y (push) Successful in 1m33s
CI / perf (push) Successful in 4m2s
## Summary

Doc-only follow-up to the just-shipped ADR-0030 dockerised dev mode + dev-server TLS (PR #263). Adds a "Team mkcert CA on `vm-gitlab`" subsection to `infra/README.md` so a teammate joining the project can browse any dev VM with a green padlock (cross-VM access) without each pair of devs having to swap their private CAs.

Hits a real need: a third developer is about to onboard, and the current single-dev mkcert procedure doesn't scale beyond one — each dev's solo CA is only trusted by their own workstation, so colleagues hitting another VM see a `NET::ERR_CERT_AUTHORITY_INVALID` warning every time.

## What lands

`infra/README.md` — one new subsection added immediately after the existing "HTTPS dev-server setup" block, under the same Local-dev-stack section. No other file touched.

The subsection documents:

1. **Initial setup on `vm-gitlab`** — install mkcert, create a root-only `CAROOT` at `/srv/apf-portal/mkcert-ca/`, generate the team CA there. Run once by the R&D Lead.
2. **Minting per-VM certs** — the canonical `mkcert -key-file … -cert-file … apf-portal.<host>.local` invocation pointed at the shared `CAROOT`, plus a sanity `openssl x509 -subject -issuer` step and the `scp` to the target VM's `~/Works/apf_portal/.secrets/dev-tls.{key,pem}`.
3. **Onboarding a new developer** — what flows where:
   - R&D Lead → new dev: `rootCA.pem` (public cert, secure channel — 1Password / Bitwarden / direct scp, never plain e-mail) + the per-VM cert pair already on their VM.
   - New dev's workstation: drop `rootCA.pem` into the local `mkcert -CAROOT`, run `mkcert -install` to push the team CA into the Windows trust store.
   - Standard `hosts` / `.env` / `NX_SERVE_CONFIGURATION=https` / `dev.sh up apps` follow.
4. **Operational notes** — departures (no CRL needed because the dev never held the private key), CA rotation, per-VM cert rotation, future migration to a corp-CA-signed cert.

## Why `vm-gitlab` as the CA host

The CA itself is just two files (`rootCA.pem` + `rootCA-key.pem`); it does not need a service running. `vm-gitlab` is the natural home for a few reasons:

- It is already a **shared, team-managed infra VM**, owned by the same person who would mint the certs anyway.
- The CA outlives any individual dev's laptop or workstation reinstall.
- Restricting the directory to `root` keeps the private key out of every developer's blast radius — only the R&D Lead with `sudo` on `vm-gitlab` can mint.

The R&D Lead becomes the steward; developers never need SSH access to `vm-gitlab`. That trade — slight bottleneck at onboarding for much smaller key-exposure surface — is the right balance at small team scale.

## Why not just distribute the CA key

Considered the alternative — every dev gets both `rootCA.pem` and `rootCA-key.pem` in their local mkcert `CAROOT` so they can mint their own certs. Pros: no bottleneck. Cons: the CA private key would live on N workstations, and anyone with it can forge a trusted cert for any hostname on any teammate's machine. Acceptable at 2 devs of complete trust; risky at 3+. The steward pattern scales without that trade.

## Test plan

- [x] Doc renders cleanly in the `infra/README.md` flow (subsection lands between "HTTPS dev-server setup" and "Service endpoints (defaults)").
- [ ] R&D Lead walks the "Initial setup on `vm-gitlab`" steps and confirms the CA files end up at `/srv/apf-portal/mkcert-ca/` with the documented permissions.
- [ ] First new-dev onboarding (the upcoming third dev) follows the section end-to-end and reaches `https://apf-portal.dev-<their>.local:4200/` with a green padlock.
- [ ] Verify the "browse from one dev's workstation to another dev's VM" promise: after the team CA is installed on at least two workstations, both can browse `https://apf-portal.dev-jg.local:4200/` and `https://apf-portal.dev-vc.local:4200/` without a cert warning.

## Related

- [ADR-0030](docs/decisions/0030-dockerised-dev-mode.md) — the dockerised dev mode this completes for team-scale operation.
- [ADR-0028](docs/decisions/0028-migrate-cicd-and-git-hosting-to-gitlab.md) — places `vm-gitlab` as shared infra; this PR uses it as the natural CA host.
- PR #263 (`feat(spa): opt-in 'https' nx serve config for dev-server TLS`) — provides the dev-server TLS plumbing this section instructs how to feed.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #264
2026-06-01 23:30:55 +02:00
APF Portal Bot b7440788d5 chore(deps): update dependency vite to v8.0.16 (#262)
CI / commits (push) Has been skipped
CI / scan (push) Failing after 2m31s
CI / a11y (push) Successful in 2m44s
CI / check (push) Successful in 5m37s
Docs site / build (push) Failing after 15m43s
CI / perf (push) Failing after 20m59s
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| [vite](https://vite.dev) ([source](https://github.com/vitejs/vite/tree/HEAD/packages/vite)) | devDependencies | patch | [`8.0.14` -> `8.0.16`](https://renovatebot.com/diffs/npm/vite/8.0.14/8.0.16) |

---

### Release Notes

<details>
<summary>vitejs/vite (vite)</summary>

### [`v8.0.16`](https://github.com/vitejs/vite/blob/HEAD/packages/vite/CHANGELOG.md#small-8016-2026-06-01-small)

[Compare Source](https://github.com/vitejs/vite/compare/v8.0.15...v8.0.16)

##### Bug Fixes

- **deps:** reject UNC paths for launch-editor-middleware ([#&#8203;22571](https://github.com/vitejs/vite/issues/22571)) ([50b9512](https://github.com/vitejs/vite/commit/50b951225bbf6151eb84a3ad5a454908ab4a76c9))
- reject windows alternate paths ([#&#8203;22572](https://github.com/vitejs/vite/issues/22572)) ([dc245c7](https://github.com/vitejs/vite/commit/dc245c71e5007ea4d891a025e2d69ac96c736546))

### [`v8.0.15`](https://github.com/vitejs/vite/blob/HEAD/packages/vite/CHANGELOG.md#small-8015-2026-06-01-small)

[Compare Source](https://github.com/vitejs/vite/compare/v8.0.14...v8.0.15)

##### Features

- send 408 on request timeout ([#&#8203;22476](https://github.com/vitejs/vite/issues/22476)) ([c85c9ee](https://github.com/vitejs/vite/commit/c85c9eeb9aaf41f477b48b057146887bd5620797))
- update rolldown to 1.0.3 ([#&#8203;22538](https://github.com/vitejs/vite/issues/22538)) ([646dbed](https://github.com/vitejs/vite/commit/646dbedd2870f8ec48df0321177d8aa64bbd1575))

##### Bug Fixes

- capitalize error messages and remove spurious space in parse error ([#&#8203;22488](https://github.com/vitejs/vite/issues/22488)) ([85a0eff](https://github.com/vitejs/vite/commit/85a0eff1c82bbb7c99a0fe8e63704316578a40d3))
- **deps:** update all non-major dependencies ([#&#8203;22511](https://github.com/vitejs/vite/issues/22511)) ([2686d7d](https://github.com/vitejs/vite/commit/2686d7d0b722402204d3bcc687a87adea1bcf9fa))
- **dev:** fix html-proxy cache key mismatch for /@&#8203;fs/ HTML paths ([#&#8203;21762](https://github.com/vitejs/vite/issues/21762)) ([47c4213](https://github.com/vitejs/vite/commit/47c4213f134f562c41ed7c031e4788510cf7e31e))
- **glob:** error on relative glob in virtual module when no files match ([#&#8203;22497](https://github.com/vitejs/vite/issues/22497)) ([5c8e98f](https://github.com/vitejs/vite/commit/5c8e98f8b584ac5d42f0f9b8580c49792213b13c))
- **optimizer:** close the rolldown bundle when write() rejects ([#&#8203;22528](https://github.com/vitejs/vite/issues/22528)) ([e3cfb9d](https://github.com/vitejs/vite/commit/e3cfb9deecff563550fa1b8abd27656b8b292815))
- **resolve:** provide onWarn for viteResolvePlugin in JS plugin containers ([#&#8203;22509](https://github.com/vitejs/vite/issues/22509)) ([40985f1](https://github.com/vitejs/vite/commit/40985f1c09b7696e594e6c5695fbc315d2da2c83))

##### Miscellaneous Chores

- **deps:** update rolldown-related dependencies ([#&#8203;22566](https://github.com/vitejs/vite/issues/22566)) ([3052a67](https://github.com/vitejs/vite/commit/3052a67d9350f4c5076ab1c222c4a21a589cbcdd))

##### Code Refactoring

- correct logic in `collectAllModules` function ([#&#8203;22562](https://github.com/vitejs/vite/issues/22562)) ([6978a9c](https://github.com/vitejs/vite/commit/6978a9ceb942c4f5e211d52b8a1e569f8a65c80c))

</details>

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined).

🚦 **Automerge**: Enabled.

♻ **Rebasing**: Whenever PR is behind base branch, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0MC42Mi4xIiwidXBkYXRlZEluVmVyIjoiNDAuNjIuMSIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOlsiZGVwZW5kZW5jaWVzIl19-->

Reviewed-on: https://git.unespace.com/julien/apf_portal/pulls/262
Co-authored-by: APF Portal Bot <jgautier.webdev+apf-portal-bot@gmail.com>
Co-committed-by: APF Portal Bot <jgautier.webdev+apf-portal-bot@gmail.com>
2026-06-01 18:17:33 +02:00
julien db7e479dde feat(spa): opt-in 'https' nx serve config for dev-server TLS (#263)
CI / commits (push) Has been skipped
CI / scan (push) Failing after 2m30s
CI / a11y (push) Successful in 1m42s
CI / check (push) Successful in 4m30s
CI / perf (push) Successful in 5m5s
## Summary

Add an opt-in `https` configuration to the SPA dev-servers so the ADR-0030 dockerised dev mode can be reached over a hostname registered in Entra. Entra refuses `http:` redirect URIs for anything other than `localhost`, which made the hostname-based access pattern (`apf-portal.dev-jg.local`, `apf-portal.dev.local`, …) — the only stable way to share a VM-based dev stack with another developer — impossible to wire to OIDC. This PR closes that gap without touching the WSL-native + localhost flow.

## What lands

| File | Change |
| --- | --- |
| `apps/portal-shell/project.json`, `apps/portal-admin/project.json` | New `https` Nx serve configuration: inherits the `development` build, sets `ssl: true` + `sslKey: .secrets/dev-tls.key` + `sslCert: .secrets/dev-tls.pem`. `defaultConfiguration` stays `development`; the `https` config is purely opt-in. |
| `infra/local/dev.compose.yml` | The `portal-shell` and `portal-admin` commands now end with `--configuration=${NX_SERVE_CONFIGURATION:-development}`. Compose interpolates the value at YAML parse time from `infra/local/.env`. Default is `development` (no SSL), so behaviour is unchanged for anyone who doesn't opt in. |
| `infra/local/.env.example` | New commented-out `NX_SERVE_CONFIGURATION=https` block with the rationale + pointer to the mkcert setup. |
| `apps/portal-bff/.env.example` | Comment block above the `ENTRA_*_REDIRECT_URI` defaults shows the HTTPS hostname-based override pattern (the four URIs that go with the `apps` profile when accessing via a hostname) and reminds that each override must be registered Entra-side. |
| `infra/README.md` | New "HTTPS dev-server setup — remote-browser access via a hostname" subsection: mkcert install / `mkcert -install`, cert generation, `.secrets/dev-tls.{key,pem}` convention, Entra registration reminder, `NX_SERVE_CONFIGURATION=https` opt-in. Notes that WSL-native is unaffected and that the cert path stays the same when the corp CA eventually replaces mkcert. |

## Design notes

- **Convention `.secrets/dev-tls.{key,pem}` at repo root.** Matches the existing `apps/portal-bff/.secrets/jwks.pem` pattern (gitignored via `*.pem` + `*.key`). Workspace-relative path means project.json can hardcode it and each dev drops their per-host cert there.
- **Hardcoded path, per-dev cert content.** Each developer generates a cert for **their own** hostname; the cert sits at the same fixed path on every machine. Nothing dev-specific in `project.json`.
- **`https` is opt-in, not default.** Native `nx serve` keeps booting on HTTP (`localhost:4200`) without SSL key files, exactly as before. Compose default is also `development` — only setting `NX_SERVE_CONFIGURATION=https` in `infra/local/.env` switches it on, gated by the dev having actually run the mkcert step.
- **BFF stays plain HTTP.** Only the SPA dev-server terminates TLS — the proxy then hits `http://portal-bff:3000` on the internal Compose network. Entra still gets HTTPS at the browser-facing origin, which is what its policy enforces.

## What this PR deliberately does NOT do

- It does **not** force-enable HTTPS. Devs who don't care about hostname access continue working as before.
- It does **not** touch the BFF code, the Entra config helpers, or the auth flow itself. The whole change is config (project.json + compose + env-examples) + docs.
- It does **not** ship the shared VM cert story. That needs a corp-CA-signed cert (or a shared mkcert CA distributed across workstations); flagged in the README section as a follow-up.

## Test plan

- [x] Both `project.json` files parse as JSON; `nx show project` exposes the new `https` configuration with the expected SSL options.
- [x] `docker compose -f dev.compose.yml --profile apps config` validates with `NX_SERVE_CONFIGURATION=https` (resolves to `--configuration=https`) and without it (resolves to `--configuration=development`).
- [ ] **On vm-jg**: `mkcert -install` on the workstation, `mkcert` against `apf-portal.dev-jg.local`, copy `.secrets/dev-tls.{key,pem}` to the VM, set `NX_SERVE_CONFIGURATION=https` in `infra/local/.env`, register the four `https://apf-portal.dev-jg.local:*` URIs in Entra, restart `dev.sh up apps`, then open `https://apf-portal.dev-jg.local:4200/` from the workstation → SPA loads, no cert warning, sign-in completes and returns to the SPA via the OIDC callback.
- [ ] Native WSL flow unchanged: `nx serve portal-shell` still boots on `http://localhost:4200/`, OIDC against the existing `http://localhost:3000/...` Entra URIs still works.

## Related

- [ADR-0030](docs/decisions/0030-dockerised-dev-mode.md) — the dockerised dev mode this completes for the hostname-access case.
- [ADR-0018](docs/decisions/0018-environment-configuration-strategy.md) — per-environment SPA config strategy; `https` is a new Nx serve _configuration_, not a new `environment.ts` sibling, so 0018's build-time replacement story is unchanged.
- [ADR-0009](docs/decisions/0009-auth-flow-oidc-pkce-msal-node.md) — OIDC flow; no behaviour change, only the redirect-URI strings.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #263
2026-06-01 16:15:36 +02:00
APF Portal Bot cca3b76771 chore(deps): update dependency lint-staged to v17.0.6 (#257)
CI / scan (push) Successful in 3m38s
CI / commits (push) Has been skipped
CI / check (push) Successful in 6m55s
CI / a11y (push) Successful in 3m49s
CI / perf (push) Successful in 6m55s
Docs site / build (push) Successful in 5m50s
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| [lint-staged](https://github.com/lint-staged/lint-staged) | devDependencies | patch | [`17.0.5` -> `17.0.6`](https://renovatebot.com/diffs/npm/lint-staged/17.0.5/17.0.6) |

---

### Release Notes

<details>
<summary>lint-staged/lint-staged (lint-staged)</summary>

### [`v17.0.6`](https://github.com/lint-staged/lint-staged/blob/HEAD/CHANGELOG.md#1706)

[Compare Source](https://github.com/lint-staged/lint-staged/compare/v17.0.5...v17.0.6)

##### Patch Changes

- [#&#8203;1803](https://github.com/lint-staged/lint-staged/pull/1803) [`bdf2770`](https://github.com/lint-staged/lint-staged/commit/bdf27700a6e25b40333672eef4d438984a2d0383) - Run all tests with [Deno](https://deno.com), in addition to Node.js and Bun.

- [#&#8203;1796](https://github.com/lint-staged/lint-staged/pull/1796) [`7508272`](https://github.com/lint-staged/lint-staged/commit/75082727cdd070adb59d62c9040515da3bbbb2f9) - Fix performance regression of *lint-staged* v17 by going back to using `git add` to stage task modifications. This was changed to `git update-index --again` in v17 for less manual work, but unfortunately the `update-index` command gets slower in very large Git repos.

- [#&#8203;1797](https://github.com/lint-staged/lint-staged/pull/1797) [`7b2505a`](https://github.com/lint-staged/lint-staged/commit/7b2505a1f8fb8735e6306c7dabdd5295632f8c1a) - This version of *lint-staged* uses the new [staged publishing for npm packages](https://docs.npmjs.com/staged-publishing) feature. Releases are already published from GitHub Actions with [trusted publishing](https://docs.npmjs.com/trusted-publishers), but now an additional approval with two-factor authentication is also required.

- [#&#8203;1802](https://github.com/lint-staged/lint-staged/pull/1802) [`321b0a9`](https://github.com/lint-staged/lint-staged/commit/321b0a972a434006f5b5fac18867974ef040d037) - Downgrade dependency `tinyexec@1.2.2` to avoid issues in version 1.2.3.

</details>

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined).

🚦 **Automerge**: Enabled.

♻ **Rebasing**: Whenever PR is behind base branch, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0MC42Mi4xIiwidXBkYXRlZEluVmVyIjoiNDAuNjIuMSIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOlsiZGVwZW5kZW5jaWVzIl19-->

Reviewed-on: #257
Co-authored-by: APF Portal Bot <jgautier.webdev+apf-portal-bot@gmail.com>
Co-committed-by: APF Portal Bot <jgautier.webdev+apf-portal-bot@gmail.com>
2026-06-01 14:27:47 +02:00
julien 045ff924a8 fix(infra): exclude serve-static from 'dev.sh up all' (port collision with apps) (#261)
CI / check (push) Successful in 4m9s
CI / commits (push) Has been skipped
CI / scan (push) Successful in 3m33s
CI / a11y (push) Successful in 1m49s
CI / perf (push) Successful in 5m30s
## Summary

Fix `./infra/local/dev.sh up all` failing on `port is already allocated` for port 4200: the `apps` profile (ADR-0030 — Angular dev-server on 4200) and the `serve-static` profile (Caddy reverse proxy for the production build, also defaulting to 4200) cannot run together. `up all` expanded to "every profile" without taking that conflict into account.

After this PR, `./infra/local/dev.sh up all` brings up the comprehensive dev stack — infra + `dbtools` + `observability` + `apps` — and `serve-static` stays available via the explicit `./infra/local/dev.sh up serve-static` invocation when a developer actually wants to test a production build.

## Root cause

`dev.compose.yml` publishes port 4200 in two services:

- `portal-shell` (profile `apps`): `${SHELL_PORT:-4200}:4200` — Angular dev-server.
- `serve-static` (profile `serve-static`): `${SERVE_STATIC_PORT:-4200}:4200` — Caddy serving the production build.

`dev.sh`'s `ALL_PROFILES` array was used both as:
1. the teardown / status / logs scope (so a manually-started profile is still reachable for `down` and friends), **and**
2. the expansion target for `up all`.

Conflating the two meant `up all` always tried to start `serve-static` even when `apps` was in scope — and the Docker port-publishing collision aborted the whole `up`.

## Fix

Split the two concerns:

```bash
# Every profile that exists — teardown / status / logs scope.
ALL_PROFILES=(dbtools observability serve-static apps)

# What `up all` expands to — serve-static excluded.
UP_ALL_PROFILES=(dbtools observability apps)
```

`serve-static` stays accessible:

- via explicit `dev.sh up serve-static` (the original way),
- via `dev.sh down` / `status` / `logs` (still in `ALL_PROFILES`).

Why exclude `serve-static` from `up all` rather than `apps`:

- `apps` is the new "no native toolchain" dev mode (ADR-0030) — it _is_ what a comprehensive dev `up` should boot.
- `serve-static` has zero value without a prior `nx build --configuration=production` (Caddy serves an empty `dist/` → 404 everywhere). Auto-starting it in `up all` would put a 404-machine in the stack by default.
- The port number can stay at the Angular convention (4200) for the dev-server, which matches the devcontainer's `forwardPorts`.

## What lands

| File | Change |
| --- | --- |
| `infra/local/dev.sh` | Split `ALL_PROFILES` (teardown scope) and `UP_ALL_PROFILES` (`up all` scope). Inline comment explains the exclusion of `serve-static`. Usage text updated. |
| `infra/README.md` | Cheat-sheet row for `up all` clarifies the new behaviour. |

## Out of scope

The script is **environment-agnostic** — it works identically when invoked on the local workstation or on `vm-dev`. Port publishing is on `0.0.0.0` by default, so services are reachable from a remote browser via the VM IP without any script change. The user-visible difference is only the URL host (`localhost:4200` locally vs `<vm-ip>:4200` from the workstation against the VM). No local-vs-vm mode in the script.

## Test plan

- [x] `bash -n infra/local/dev.sh` passes.
- [x] `./infra/local/dev.sh help` shows the updated `up all` description.
- [ ] **On vm-dev**: `./infra/local/dev.sh up all` brings up postgres / redis / otel-collector / pgweb / jaeger / apps-deps (exits 0) / portal-bff / portal-shell / portal-admin without port conflicts; `serve-static` is **not** started.
- [ ] `./infra/local/dev.sh up serve-static` (explicit) still starts Caddy on 4200, provided `apps` is **not** already up.
- [ ] `./infra/local/dev.sh down` and `status` still see serve-static if it was started manually (ALL_PROFILES retains it).

## Related

- Surfaced by the ADR-0030 dockerised dev mode VM validation — `up all` was added to the user-facing surface in that PR; this PR finishes wiring it.
- The port collision was flagged as a caveat in the ADR-0030 PR body ("don't run `apps` and `serve-static` together"). This PR turns the caveat into a script invariant instead of relying on the user to remember.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #261
2026-06-01 13:07:08 +02:00
julien f9f5f171eb fix(security): normalize IPv6 in rate-limit keyGenerator (ADR-0021) (#260)
CI / commits (push) Has been skipped
CI / scan (push) Successful in 4m45s
CI / check (push) Successful in 5m17s
CI / a11y (push) Successful in 1m48s
CI / perf (push) Successful in 6m55s
## Summary

Fix `ERR_ERL_KEY_GEN_IPV6` raised by `express-rate-limit` at BFF boot: the rate-limit middleware's custom `keyGenerator` was using `req.ip` verbatim, which the library v8 refuses because it lets an IPv6 attacker rotate through the host bits of their own subnet to escape per-IP rate-limiting. Surfaced during the ADR-0030 dockerised dev mode validation (the boot log shows the `ValidationError` immediately after the rate-limit middleware is built).

## Root cause

`apps/portal-bff/src/security/rate-limit.middleware.ts` returned:

```ts
return `ip:${req.ip ?? 'unknown'}`;
```

`req.ip` is the raw address the IP-trust chain hands Express. For IPv4 that's already the right bucket key. For IPv6, every distinct host in an attacker's allocation hashes to a different bucket — even though the same human controls all of them. An attacker on a residential IPv6 assignment (typically `/56`) thus has ~2^72 trivially-rotatable buckets per `/56`, which makes the per-IP rate limit useless against them.

`express-rate-limit` v7+ ships an `ipKeyGenerator` helper that **truncates the address to its `/56` prefix** before keying. The library v8 raises `ERR_ERL_KEY_GEN_IPV6` at boot if a custom `keyGenerator` returns `req.ip` verbatim, precisely to refuse shipping this bypass.

## Fix

| File | Change |
| --- | --- |
| `apps/portal-bff/src/security/rate-limit.middleware.ts` | Import `ipKeyGenerator` from `express-rate-limit`; wrap `req.ip` through it when keying. IPv4 addresses pass through unchanged; IPv6 addresses get truncated to their `/56`. Comment explains the rationale + that the lib's `/56` default matches a typical residential ISP customer allocation. |
| `apps/portal-bff/src/security/rate-limit.middleware.spec.ts` | New test asserting two IPv6 addresses in the same `/56` share a bucket (the bypass would have set both apart), and that distinct `/56`s remain isolated (truncation does not collapse all IPv6 traffic into one global bucket). Existing IPv4 / session / `SKIP_PATHS` tests are unchanged and still pass — `ipKeyGenerator` is a no-op for IPv4. |

The session-keyed branch (`s:${sessionID}`) is untouched: sessions key on the BFF-issued session id, not the address.

## Why the BFF kept booting despite the error

The log shows `bootstrap` reaching `AuthModule` immediately after the `ValidationError` printout. `express-rate-limit` v8's `errorHandler` defaults to logging the error and continuing rather than throwing for this specific validation, so the middleware was effectively running with the unfixed `keyGenerator` until now — i.e. the bypass was live in the dev BFF. Fixed pre-emptively, before any prod consumer.

## Related

- Per [ADR-0021](docs/decisions/0021-phase-2-security-baseline.md) — Phase-2 security baseline, rate-limit section.
- Surfaced by the ADR-0030 dockerised dev mode validation (the SPA `/api/auth/me` proxy errors visible in the same log run were a side effect of the BFF restarting on every config validator until the env was fully populated; unrelated to this fix).

## Test plan

- [x] `pnpm exec nx test portal-bff --testFile=apps/portal-bff/src/security/rate-limit.middleware.spec.ts` — 794 tests pass, including the new `/56` isolation case.
- [ ] Restart the BFF (`./infra/local/dev.sh restart portal-bff`) — `ERR_ERL_KEY_GEN_IPV6` no longer appears in the boot log.
- [ ] IPv4 traffic still rate-limits as before (existing test coverage; no behavioural change since `ipKeyGenerator` is identity for v4).

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #260
2026-06-01 12:31:54 +02:00
julien a84ea2d116 feat(spa): proxy /api in dev-server, relative bffApiBaseUrl (#259)
CI / commits (push) Has been skipped
CI / scan (push) Successful in 4m39s
CI / check (push) Successful in 7m6s
CI / a11y (push) Successful in 2m38s
CI / perf (push) Successful in 7m52s
## Summary

Make the SPAs reach the BFF as a **same-origin** call via an Angular dev-server `/api` proxy. Solves the "Backend unreachable" error surfaced during the ADR-0030 dockerised dev mode VM validation when the SPA is accessed from a remote browser (e.g. `http://<vm-ip>:4200/`), and bypasses CORS in dev altogether. Follow-up to the just-merged ADR-0030 implementation.

## Root cause it fixes

Before this PR, both SPAs hardcoded `bffApiBaseUrl: 'http://localhost:3000/api'` (per ADR-0018) and the BFF's `CORS_ALLOWED_ORIGINS` only allowed `http://localhost:4200,http://localhost:4300`. Both assumptions hold for native `nx serve` (developer on the same machine as the BFF) but break the moment the browser sits on a different host than the BFF — exactly the case for the ADR-0030 `apps` Compose profile: open `http://<vm-ip>:4200/` from your workstation and the SPA's `localhost:3000` call hits your **workstation's** loopback (nothing there), not the VM's BFF. Even if the URL were right, the origin `http://<vm-ip>:4200` is not in the CORS allowlist.

## Fix

Switch to a same-origin dev pattern: the Angular dev-server proxies `/api/*` to the BFF, and `environment.ts` uses a relative `'/api'` URL.

| File | Change |
| --- | --- |
| `apps/portal-shell/proxy.conf.js` | **New.** Maps `/api → ${BFF_TARGET:-http://localhost:3000}` (JS form so the env var can swap the target at startup). |
| `apps/portal-admin/proxy.conf.js` | **New.** Same shape. |
| `apps/portal-shell/project.json`, `apps/portal-admin/project.json` | `serve.options.proxyConfig` points at the new file. |
| `apps/portal-shell/src/environments/environment.ts`, `apps/portal-admin/src/environments/environment.ts` | `bffApiBaseUrl: 'http://localhost:3000/api'` → `'/api'`. The comment block explains the rationale + how production siblings can still use an absolute origin if SPA + BFF live on different hosts. |
| `apps/portal-shell/src/observability/tracing.ts` | `new URL(environment.bffApiBaseUrl)` would throw on a relative URL — resolved against `window.location.origin` so both relative (dev) and absolute (prod cross-origin) bases work. |
| `infra/local/dev.compose.yml` | `BFF_TARGET=http://portal-bff:3000` added to `portal-shell` and `portal-admin` so the proxy hits the BFF **container** by name (Compose DNS) when the `apps` profile is up. Native `nx serve` leaves the var unset and falls back to `localhost:3000`. |

## Why a relative URL is safe

- `${bffApiBaseUrl}/health` etc. compose to `/api/health` — relative paths work in `fetch` / `HttpClient`.
- `tracing.ts` propagates `traceparent` on requests whose origin matches the BFF origin. Resolving the relative base against `window.location.origin` gives the current page's origin, which is exactly the origin the dev-server proxy serves from — so the regex still matches the right requests in dev. In a future cross-origin production deployment, `environment.prod.ts` can set an absolute `bffApiBaseUrl`; the URL constructor's second arg is ignored when the first is absolute, so the same code path keeps working.
- Auth flow (`feature-auth` / `auth.config.ts`): consumes `bffApiBaseUrl` via DI as a string prefix — agnostic to absolute vs relative.

## Scope notes

- The OTel HTTP exporter (`environment.otlpEndpoint = 'http://localhost:4318/v1/traces'`) and the cross-SPA links (`adminAppUrl`, `shellAppUrl`) **remain absolute**. They hit the same remote-browser problem on `apps`-profile access, but neither is blocking the user-visible "Backend unreachable" path this PR targets. Out of scope here; a follow-up could either proxy them too or surface them via runtime config.
- This pattern is dev-server only — production builds do not use the proxy. Per-environment `bffApiBaseUrl` overrides remain the supported lever (ADR-0018), unchanged.

## Test plan

- [x] `docker compose -f dev.compose.yml --profile apps config` still validates.
- [ ] **On the VM**, `./infra/local/dev.sh up apps`, then in the workstation browser open `http://<vm-ip>:4200/` → SPA loads, the "Backend unreachable" message is gone, network tab shows `/api/...` calls succeeding (same-origin, no CORS preflight).
- [ ] Native `nx serve portal-shell` still works (the proxy falls back to `localhost:3000`).
- [ ] Trace headers (`traceparent`) appear on `/api/*` fetches in the browser network tab.
- [ ] `pnpm exec nx affected -t test build` green on the two SPAs.

## Related

- [ADR-0030](docs/decisions/0030-dockerised-dev-mode.md) — the dockerised dev mode this enables to actually work from a remote browser.
- [ADR-0018](docs/decisions/0018-environment-configuration-strategy.md) — the `environment.ts` per-env strategy this complements (does not supersede — production behaviour unchanged).

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #259
2026-06-01 11:39:40 +02:00
julien c080d1ad89 feat(infra): dockerised full-stack dev mode — apps compose profile (ADR-0030) (#258)
CI / scan (push) Successful in 3m8s
CI / commits (push) Has been skipped
CI / check (push) Successful in 3m58s
CI / a11y (push) Successful in 4m38s
Docs site / build (push) Successful in 7m47s
CI / perf (push) Successful in 8m53s
## Summary

Implements [ADR-0030](../docs/decisions/0030-dockerised-dev-mode.md) (now `accepted`): a Docker Compose `apps` profile that runs the three Nx dev servers (`portal-bff`, `portal-shell`, `portal-admin`) from a shared `Dockerfile.dev`, so a developer can boot the whole stack with **no native Node/pnpm**:

```bash
./infra/local/dev.sh up apps   # infra + portal-bff:3000 + portal-shell:4200 + portal-admin:4300
```

Purely additive and profile-gated — the native `nx serve` flow and the devcontainer are untouched. Dev-only; no production images (those stay with the ADR-0028 Container Registry work).

## What lands

| File | Change |
| --- | --- |
| `docs/decisions/0030-dockerised-dev-mode.md` | Status `proposed` → `accepted`. |
| `docs/decisions/README.md` | Index status → `accepted`. |
| `infra/local/Dockerfile.dev` | **New.** `node:24-bookworm` + corepack (pnpm resolved from `packageManager` at runtime — no pinned version to drift). No COPY/install at build time. `NX_DAEMON=false`, `NODE_OPTIONS=--max-old-space-size=4096`. |
| `infra/local/dev-entrypoint.sh` | **New.** Shared entrypoint: BFF (`APF_ROLE=bff`) runs `prisma generate` + `prisma migrate deploy` then serves; SPA services go straight to `nx serve`. |
| `infra/local/dev.compose.yml` | **New `apps` profile.** A one-shot `apps-deps` service installs into a shared `node_modules` volume once (the 3 servers gate on its `service_completed_successfully`, avoiding a 3-way install race); `portal-bff` / `portal-shell` / `portal-admin` services from the shared image via a `x-app-base` anchor. Repo bind-mounted; `node_modules` + `.nx` in named volumes. |
| `infra/local/dev.sh` | `apps` added to `ALL_PROFILES` (so teardown / status / logs catch it) + usage / examples. |
| `infra/README.md` | New "Dockerised app dev mode" section + cheat-sheet / file-table rows. |
| `docs/setup/01-dev-debian-vm-setup.md` | "Three dev modes — which when" table at the top of Step 5. |
| `CLAUDE.md` | Architecture roll-up bullet + ADR-count line + environment-conventions note. |

## Key design decisions

- **One image, one install.** The monorepo means a single `Dockerfile.dev` + a single `pnpm install` serves all three apps.
- **`node_modules` + `.nx` in named volumes, not bind-mounted.** The container's install (native modules — `esbuild`, `@swc/core`, Prisma engines, `lmdb`, `@parcel/watcher` — built for this image) must never be shadowed by the host's `node_modules`. The repo source is bind-mounted for hot reload; these two directories are overlaid with named volumes.
- **`apps-deps` one-shot avoids the install race.** Three services sharing one `node_modules` volume can't all run `pnpm install` concurrently. A dedicated install service runs first; the three app services `depends_on` its completion.
- **`NX_DAEMON=false`** in the containers — three containers sharing one workspace would otherwise contend on the Nx daemon.
- **Env wiring.** The BFF reuses its own `apps/portal-bff/.env` (Entra / session / jwks secrets) via `env_file: { required: false }`; the host-specific URLs (`DATABASE_URL` / `REDIS_URL` / OTel endpoint) are overridden in `environment:` — rebuilt from `infra/local/.env` creds → Compose service names. Compose `environment` wins over `env_file`, so the localhost values in the BFF `.env` don't leak into the container.
- **BFF still needs its secrets.** "No native toolchain" ≠ "no config". `apps/portal-bff/.env` must exist (same as native dev); `required: false` lets SPA-only devs `up` without it (the BFF then fails its own boot validators with a clear message).

## Validation on the VM

- [x] `docker compose -f dev.compose.yml --profile apps config` validates (YAML, anchors / merge, env interpolation).
- [x] `bash -n` clean on `dev-entrypoint.sh` and `dev.sh`.
- [x] **Full boot on vm-dev** — `./infra/local/dev.sh up apps` brings up postgres / redis / otel + `apps-deps` (one-shot, exit 0) + portal-bff / portal-shell / portal-admin, all containers report healthy or running.
- [x] `apps-deps` populates the shared `node_modules` volume; the three servers reach their `nx serve` step without re-installing.
- [x] Ports published as expected: BFF :3000, portal-shell :4200, portal-admin :4300.
- [x] `./infra/local/dev.sh up` (no `apps`) unchanged for native devs.

## Follow-ups identified during VM validation

- **SPA → BFF reachability from a remote browser.** Opening `http://<vm-ip>:4200/` from the workstation surfaces a "Backend unreachable" message: the SPA's hardcoded `bffApiBaseUrl: 'http://localhost:3000/api'` (ADR-0018 build-time env) plus the BFF's `CORS_ALLOWED_ORIGINS=http://localhost:4200,…` both assume "browser on the same machine as the BFF", which doesn't hold here. Fixed in the **stacked follow-up PR `feat/spa-dev-proxy`** (proxy `/api` in the Angular dev-server + relative `bffApiBaseUrl`), which lands right after this PR.
- The OTel HTTP exporter URL (`environment.otlpEndpoint`) and the cross-SPA links (`adminAppUrl`, `shellAppUrl`) remain absolute and hit the same remote-browser limit; not blocking for v1, can be revisited if needed.

## Related

- [ADR-0030](docs/decisions/0030-dockerised-dev-mode.md) — the decision (accepted in this PR's chain).
- [ADR-0020](docs/decisions/0020-portal-admin-app.md) — the devcontainer this complements.
- [ADR-0028](docs/decisions/0028-migrate-cicd-and-git-hosting-to-gitlab.md) — production images / Container Registry (deferred).
- Follow-up branch `feat/spa-dev-proxy` — the SPA-side proxy fix that makes the dockerised mode usable from a remote browser.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #258
2026-06-01 11:11:44 +02:00
julien 8a2a99c351 docs(decisions): add ADR-0030 dockerised full-stack dev mode (proposed) (#256)
CI / check (push) Successful in 1m48s
CI / commits (push) Has been skipped
CI / a11y (push) Successful in 4m50s
CI / scan (push) Successful in 5m24s
CI / perf (push) Successful in 7m15s
Docs site / build (push) Successful in 3m17s
## Summary

Propose **ADR-0030 — Dockerised full-stack dev mode**: an `apps` Compose profile that runs the three Nx apps (`portal-bff`, `portal-shell`, `portal-admin`) from a shared `Dockerfile.dev`, so a developer can `docker compose up` the whole stack with **no native Node/pnpm** on the host.

`proposed` status — this PR is the **decision record only**. The `Dockerfile.dev` + the `apps` profile + the BFF entrypoint land in a follow-up PR once the ADR is accepted.

## Why

Two frictions motivate it:
1. Infra is already in Docker (`infra/local/dev.compose.yml`), but the Nx apps run natively — every dev pays the nvm/corepack/pnpm setup cost. The recent fresh-VM `.zshrc` nvm gap is a concrete example of that path breaking.
2. A developer asked to run all servers with a single `docker compose up`, no toolchain, no IDE attach.

## Decision (chosen option)

An **`apps` Compose profile** backed by **one shared `Dockerfile.dev`** (node:24 + pinned pnpm), because it is purely additive:
- `./infra/local/dev.sh up` stays infra-only (native + devcontainer flows unchanged).
- `./infra/local/dev.sh up apps` brings up infra + the three dev servers with hot reload.

Key design points captured in the ADR (built in the follow-up): one image / one install for the monorepo; repo bind-mounted but `node_modules` + Nx cache in **named volumes** (native-module arch correctness); `depends_on … service_healthy`; BFF entrypoint does `prisma generate` + `migrate deploy`; services point at the existing `apf-portal-dev` Compose network.

The ADR documents a "which mode when" table so the **three** dev modes (native / devcontainer / compose-`apps`) coexist without confusion.

## Scope guardrails

- **Dev-only.** No production images here — those are tracked against the ADR-0028 Container Registry follow-up (post-cutover). The ADR is explicit that it produces no deployment artefact.
- Complements ADR-0020's devcontainer (interactive/IDE) with a non-interactive services sibling; does not replace it.

## Numbering

Takes **0030**, not 0029. `0029` is reserved for the cascade/Pléiades/Acteurs+ syncs ADR (referenced by ADR-0026/0027/0028); numbers are never reused, so the dev-mode ADR takes the next free slot. The index gap at 0029 is intentional until that ADR is written.

## What lands

| File | Change |
| --- | --- |
| `docs/decisions/0030-dockerised-dev-mode.md` | New ADR, `proposed`. |
| `docs/decisions/README.md` | Index row for 0030. |

## Test plan

- [x] MADR 4.0.0 frontmatter; tags drawn from the README vocabulary (`infrastructure`, `process`).
- [x] Index updated in the same commit (per the repo's index-maintenance rule).
- [ ] R&D Lead review → accept / revise. On acceptance: update the CLAUDE.md architecture roll-up + add the "which mode when" guidance to `docs/setup/`, then open the implementation PR.

## Related

- [ADR-0020](docs/decisions/0020-portal-admin-app.md) — VSCode devcontainer (the interactive no-toolchain path this complements).
- [ADR-0028](docs/decisions/0028-migrate-cicd-and-git-hosting-to-gitlab.md) — Container Registry / production images (the deferred prod-image work).
- [ADR-0006](docs/decisions/0006-persistence-postgresql-prisma.md) — Prisma; the entrypoint applies (never authors) migrations.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #256
2026-05-29 19:06:07 +02:00
julien 645f81a443 fix(setup): mirror nvm init into .zshrc so zsh sees node + pnpm (#255)
CI / scan (push) Successful in 3m11s
CI / commits (push) Has been skipped
CI / check (push) Successful in 3m47s
CI / a11y (push) Successful in 4m31s
CI / perf (push) Successful in 7m5s
Docs site / build (push) Successful in 6m47s
## Summary

Fix the dev-VM bootstrap so `node` and `pnpm` are on `PATH` in zsh sessions. On a fresh VM provisioned with `docs/setup/scripts/`, a freshly-cloned checkout fails to run anything (`command not found: pnpm`, and `node` too) because the nvm init never reaches the interactive shell.

## Root cause

Two setup scripts interact badly:

- `20-zsh.sh` patches `~/.bashrc` with an `exec zsh -l` hand-off on interactive shells (chsh is blocked on the corp VM, so this is how zsh becomes the effective shell), and patches `~/.zshrc` with theme/plugins/fzf only.
- `40-node.sh` runs the upstream nvm installer, which appends its init block to **`~/.bashrc`** (its default target).

Because `20-zsh.sh` runs first, the `exec zsh -l` guard sits **above** the nvm block in `~/.bashrc`. On every interactive shell, bash execs into zsh before reaching the nvm block — so it never runs — and `~/.zshrc` has no nvm init at all. Net result: zsh sessions have neither `node` nor `pnpm` on `PATH`, even though nvm + Node + corepack installed correctly.

This is a procedure bug, not a one-off: every VM built from these scripts hits it.

## Fix

`40-node.sh` now mirrors the nvm init block into `~/.zshrc` after enabling corepack — idempotent via a managed marker, same pattern `20-zsh.sh` already uses for its `~/.bashrc` patch:

```sh
# Managed by docs/setup/scripts/40-node.sh — nvm init for zsh.
export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
[ -s "$NVM_DIR/bash_completion" ] && \. "$NVM_DIR/bash_completion"
```

`docs/setup/01-dev-debian-vm-setup.md` — the `40-node.sh` row in the bootstrap table now notes the `~/.zshrc` patch and why it is needed.

## What lands

| File | Change |
| --- | --- |
| `docs/setup/scripts/40-node.sh` | Append an idempotent nvm-init block to `~/.zshrc` after corepack enable. |
| `docs/setup/01-dev-debian-vm-setup.md` | Document the `~/.zshrc` nvm patch in the `40-node.sh` table row. |

## Manual remediation for already-provisioned VMs

VMs built before this fix won't be retroactively patched (the scripts are idempotent and skip already-installed nvm). On those, run once:

```sh
cat >> ~/.zshrc <<'EOF'

# nvm
export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
[ -s "$NVM_DIR/bash_completion" ] && \. "$NVM_DIR/bash_completion"
EOF
exec zsh -l
```

(Or re-run `40-node.sh` once this lands — the marker check makes it safe; it will add the block and skip the rest.)

## Test plan

- [x] `bash -n docs/setup/scripts/40-node.sh` — syntax valid.
- [x] Manual remediation block verified on the affected VM: after appending + `exec zsh -l`, `node --version` (v24) and `pnpm --version` (10.34.1) both resolve.
- [ ] Next fresh VM bootstrap: `node` + `pnpm` resolve in the first zsh session with no manual step.
- [ ] Re-running `40-node.sh` on an already-set-up VM is a no-op on the nvm install and adds the `~/.zshrc` block exactly once (marker idempotency).

## Related

- `docs/setup/01-dev-debian-vm-setup.md` — dev-VM bootstrap procedure.
- `20-zsh.sh` — owns the `.bashrc` → zsh hand-off this fix complements.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #255
2026-05-29 18:55:30 +02:00
APF Portal Bot 741fc07d40 fix(deps): update dependency ioredis to v5.11.0 (#254)
CI / commits (push) Has been skipped
CI / check (push) Successful in 5m50s
CI / scan (push) Successful in 4m33s
CI / a11y (push) Successful in 3m1s
CI / perf (push) Successful in 7m19s
Docs site / build (push) Successful in 4m51s
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| [ioredis](https://github.com/luin/ioredis) | dependencies | minor | [`5.10.1` -> `5.11.0`](https://renovatebot.com/diffs/npm/ioredis/5.10.1/5.11.0) |

---

### Release Notes

<details>
<summary>luin/ioredis (ioredis)</summary>

### [`v5.11.0`](https://github.com/luin/ioredis/blob/HEAD/CHANGELOG.md#5110-2026-05-26)

[Compare Source](https://github.com/luin/ioredis/compare/v5.10.1...v5.11.0)

##### Bug Fixes

- prevent RangeError from string accumulation in pipeline ([#&#8203;2088](https://github.com/luin/ioredis/issues/2088)) ([defc077](https://github.com/luin/ioredis/commit/defc07716a9ef10c2077ec4e23ea48cb9ea731fc))
- replace deprecated url.parse() with WHATWG URL API ([#&#8203;2081](https://github.com/luin/ioredis/issues/2081)) ([0021a45](https://github.com/luin/ioredis/commit/0021a4590e286aabbf27f4e2fc18f9d2de829ef0)), closes [redis/ioredis#1747](https://github.com/redis/ioredis/issues/1747)

##### Features

- add array commands, typings and tests ([#&#8203;2114](https://github.com/luin/ioredis/issues/2114)) ([baf68d6](https://github.com/luin/ioredis/commit/baf68d6d89553672cfac3e08543467b910b561c5))
- add increx command ([#&#8203;2115](https://github.com/luin/ioredis/issues/2115)) ([37d0695](https://github.com/luin/ioredis/commit/37d0695b212d865ef24132acff85420ae51dde50))
- add Redis MSETEX support ([#&#8203;2111](https://github.com/luin/ioredis/issues/2111)) ([04a4615](https://github.com/luin/ioredis/commit/04a4615e8e96b9c58d017e360b5eaafede8973d0))
- add typed GCRA command support and functional tests ([#&#8203;2094](https://github.com/luin/ioredis/issues/2094)) ([468a802](https://github.com/luin/ioredis/commit/468a8023cd2c8f342ec7c55a01bf0c8d17e4b877))
- add vector set command support ([#&#8203;2116](https://github.com/luin/ioredis/issues/2116)) ([b7b3def](https://github.com/luin/ioredis/commit/b7b3defbd119d07fb86d071d5eefc255db4920c2))
- Add xnack command ([#&#8203;2103](https://github.com/luin/ioredis/issues/2103)) ([187d29b](https://github.com/luin/ioredis/commit/187d29b45000ee46a4baa8ce91eacfa04675aee8))
- Add zinter zunion count ([#&#8203;2104](https://github.com/luin/ioredis/issues/2104)) ([0d510bb](https://github.com/luin/ioredis/commit/0d510bbc1cfc8b01d862b76c408f6687f6e77809))
- Implement `TracingChannel` support ([#&#8203;2089](https://github.com/luin/ioredis/issues/2089)) ([4760e0a](https://github.com/luin/ioredis/commit/4760e0a19c194f29f4feb703003dcf046e4509cd))

#### [5.10.1](https://github.com/luin/ioredis/compare/v5.10.0...v5.10.1) (2026-03-19)

##### Bug Fixes

- **cluster:** lazily start sharded subscribers ([#&#8203;2090](https://github.com/luin/ioredis/issues/2090)) ([4f167bb](https://github.com/luin/ioredis/commit/4f167bb9f494f0e8200a20dedd8bbdf1810fcd22))

</details>

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined).

🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0MC42Mi4xIiwidXBkYXRlZEluVmVyIjoiNDAuNjIuMSIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOlsiZGVwZW5kZW5jaWVzIl19-->

Reviewed-on: https://git.unespace.com/julien/apf_portal/pulls/254
Co-authored-by: APF Portal Bot <jgautier.webdev+apf-portal-bot@gmail.com>
Co-committed-by: APF Portal Bot <jgautier.webdev+apf-portal-bot@gmail.com>
2026-05-29 14:26:41 +02:00
APF Portal Bot 3ac408aca9 fix(deps): update dependency helmet to v8.2.0 (#253)
CI / check (push) Successful in 4m51s
CI / commits (push) Has been skipped
CI / scan (push) Successful in 2m26s
CI / a11y (push) Successful in 3m32s
CI / perf (push) Successful in 6m17s
Docs site / build (push) Successful in 5m28s
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| [helmet](https://helmet.js.org/) ([source](https://github.com/helmetjs/helmet)) | dependencies | minor | [`8.1.0` -> `8.2.0`](https://renovatebot.com/diffs/npm/helmet/8.1.0/8.2.0) |

---

### Release Notes

<details>
<summary>helmetjs/helmet (helmet)</summary>

### [`v8.2.0`](https://github.com/helmetjs/helmet/blob/HEAD/CHANGELOG.md#820---2026-05-21)

[Compare Source](https://github.com/helmetjs/helmet/compare/v8.1.0...v8.2.0)

- `Cross-Origin-Opener-Policy`: support `noopener-allow-popups`. See [#&#8203;522](https://github.com/helmetjs/helmet/pull/522)
- Improve error message when passing duplicate options

</details>

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined).

🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0MC42Mi4xIiwidXBkYXRlZEluVmVyIjoiNDAuNjIuMSIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOlsiZGVwZW5kZW5jaWVzIl19-->

Reviewed-on: #253
Co-authored-by: APF Portal Bot <jgautier.webdev+apf-portal-bot@gmail.com>
Co-committed-by: APF Portal Bot <jgautier.webdev+apf-portal-bot@gmail.com>
2026-05-29 13:49:21 +02:00
APF Portal Bot 377524ce3f chore(deps): update typescript tooling to v8.60.0 (#252)
CI / check (push) Successful in 4m48s
CI / commits (push) Has been skipped
CI / a11y (push) Successful in 4m1s
CI / scan (push) Successful in 4m54s
CI / perf (push) Successful in 8m18s
Docs site / build (push) Successful in 6m48s
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| [@typescript-eslint/utils](https://typescript-eslint.io/packages/utils) ([source](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/utils)) | devDependencies | minor | [`8.59.4` -> `8.60.0`](https://renovatebot.com/diffs/npm/@typescript-eslint%2futils/8.59.4/8.60.0) |
| [typescript-eslint](https://typescript-eslint.io/packages/typescript-eslint) ([source](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint)) | devDependencies | minor | [`8.59.4` -> `8.60.0`](https://renovatebot.com/diffs/npm/typescript-eslint/8.59.4/8.60.0) |

---

### Release Notes

<details>
<summary>typescript-eslint/typescript-eslint (@&#8203;typescript-eslint/utils)</summary>

### [`v8.60.0`](https://github.com/typescript-eslint/typescript-eslint/blob/HEAD/packages/utils/CHANGELOG.md#8600-2026-05-25)

[Compare Source](https://github.com/typescript-eslint/typescript-eslint/compare/v8.59.4...v8.60.0)

This was a version bump only for utils to align it with other projects, there were no code changes.

See [GitHub Releases](https://github.com/typescript-eslint/typescript-eslint/releases/tag/v8.60.0) for more information.

You can read about our [versioning strategy](https://typescript-eslint.io/users/versioning) and [releases](https://typescript-eslint.io/users/releases) on our website.

</details>

<details>
<summary>typescript-eslint/typescript-eslint (typescript-eslint)</summary>

### [`v8.60.0`](https://github.com/typescript-eslint/typescript-eslint/blob/HEAD/packages/typescript-eslint/CHANGELOG.md#8600-2026-05-25)

[Compare Source](https://github.com/typescript-eslint/typescript-eslint/compare/v8.59.4...v8.60.0)

This was a version bump only for typescript-eslint to align it with other projects, there were no code changes.

See [GitHub Releases](https://github.com/typescript-eslint/typescript-eslint/releases/tag/v8.60.0) for more information.

You can read about our [versioning strategy](https://typescript-eslint.io/users/versioning) and [releases](https://typescript-eslint.io/users/releases) on our website.

</details>

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined).

🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about these updates again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0MC42Mi4xIiwidXBkYXRlZEluVmVyIjoiNDAuNjIuMSIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOlsiZGVwZW5kZW5jaWVzIl19-->

Reviewed-on: #252
Co-authored-by: APF Portal Bot <jgautier.webdev+apf-portal-bot@gmail.com>
Co-committed-by: APF Portal Bot <jgautier.webdev+apf-portal-bot@gmail.com>
2026-05-29 13:48:29 +02:00
APF Portal Bot 0df3ab9f03 chore(deps): update pnpm to v10.34.1 (#251)
CI / scan (push) Successful in 3m49s
CI / check (push) Successful in 4m11s
CI / commits (push) Has been skipped
CI / a11y (push) Successful in 2m21s
CI / perf (push) Successful in 6m59s
Docs site / build (push) Successful in 5m25s
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| [pnpm](https://pnpm.io) ([source](https://github.com/pnpm/pnpm/tree/HEAD/pnpm)) | packageManager | minor | [`10.33.4` -> `10.34.1`](https://renovatebot.com/diffs/npm/pnpm/10.33.4/10.34.1) |

---

### Release Notes

<details>
<summary>pnpm/pnpm (pnpm)</summary>

### [`v10.34.1`](https://github.com/pnpm/pnpm/releases/tag/v10.34.1): pnpm 10.34.1

[Compare Source](https://github.com/pnpm/pnpm/compare/v10.34.0...v10.34.1)

#### Patch Changes

- Reject `pnpm-lock.yaml` entries whose remote tarball `resolution:` block is missing the `integrity` field. Previously the worker that extracts a downloaded tarball skipped hash verification when no integrity was supplied and minted a fresh one from the unverified bytes, so an attacker who could both alter the lockfile (e.g. via a pull request that strips `integrity:`) and serve modified content at the referenced tarball URL could install a tampered package without any error — including under `--frozen-lockfile`. pnpm now fails closed at lockfile-read time with `ERR_PNPM_MISSING_TARBALL_INTEGRITY`. Git-hosted tarballs (`gitHosted: true` or a URL on codeload.github.com / bitbucket.org / gitlab.com) and `file:` tarballs are exempt — the commit SHA in a git-host URL and the user-controlled local path already anchor the bytes.

<!-- sponsors -->

#### Platinum Sponsors

<table>
  <tbody>
    <tr>
      <td align="center" valign="middle">
        <a href="https://bit.cloud/?utm_source=pnpm&utm_medium=release_notes" target="_blank"><img src="https://pnpm.io/img/users/bit.svg" width="80" alt="Bit"></a>
      </td>
    </tr>
  </tbody>
</table>

#### Gold Sponsors

<table>
  <tbody>
    <tr>
      <td align="center" valign="middle">
        <a href="https://sanity.io/?utm_source=pnpm&utm_medium=release_notes" target="_blank">
          <picture>
            <source media="(prefers-color-scheme: light)" srcset="https://pnpm.io/img/users/sanity.svg" />
            <source media="(prefers-color-scheme: dark)" srcset="https://pnpm.io/img/users/sanity_light.svg" />
            <img src="https://pnpm.io/img/users/sanity.svg" width="120" alt="Sanity" />
          </picture>
        </a>
      </td>
      <td align="center" valign="middle">
        <a href="https://discord.com/?utm_source=pnpm&utm_medium=release_notes" target="_blank">
          <picture>
            <source media="(prefers-color-scheme: light)" srcset="https://pnpm.io/img/users/discord.svg" />
            <source media="(prefers-color-scheme: dark)" srcset="https://pnpm.io/img/users/discord_light.svg" />
            <img src="https://pnpm.io/img/users/discord.svg" width="220" alt="Discord" />
          </picture>
        </a>
      </td>
      <td align="center" valign="middle">
        <a href="https://vite.dev/?utm_source=pnpm&utm_medium=release_notes" target="_blank"><img src="https://pnpm.io/img/users/vitejs.svg" width="42" alt="Vite"></a>
      </td>
    </tr>
    <tr>
      <td align="center" valign="middle">
        <a href="https://serpapi.com/?utm_source=pnpm&utm_medium=release_notes" target="_blank">
          <picture>
            <source media="(prefers-color-scheme: light)" srcset="https://pnpm.io/img/users/serpapi_dark.svg" />
            <source media="(prefers-color-scheme: dark)" srcset="https://pnpm.io/img/users/serpapi_light.svg" />
            <img src="https://pnpm.io/img/users/serpapi_dark.svg" width="160" alt="SerpApi" />
          </picture>
        </a>
      </td>
      <td align="center" valign="middle">
        <a href="https://coderabbit.ai/?utm_source=pnpm&utm_medium=release_notes" target="_blank">
          <picture>
            <source media="(prefers-color-scheme: light)" srcset="https://pnpm.io/img/users/coderabbit.svg" />
            <source media="(prefers-color-scheme: dark)" srcset="https://pnpm.io/img/users/coderabbit_light.svg" />
            <img src="https://pnpm.io/img/users/coderabbit.svg" width="220" alt="CodeRabbit" />
          </picture>
        </a>
      </td>
      <td align="center" valign="middle">
        <a href="https://stackblitz.com/?utm_source=pnpm&utm_medium=release_notes" target="_blank">
          <picture>
            <source media="(prefers-color-scheme: light)" srcset="https://pnpm.io/img/users/stackblitz.svg" />
            <source media="(prefers-color-scheme: dark)" srcset="https://pnpm.io/img/users/stackblitz_light.svg" />
            <img src="https://pnpm.io/img/users/stackblitz.svg" width="190" alt="Stackblitz" />
          </picture>
        </a>
      </td>
    </tr>
    <tr>
      <td align="center" valign="middle">
        <a href="https://workleap.com/?utm_source=pnpm&utm_medium=release_notes" target="_blank">
          <picture>
            <source media="(prefers-color-scheme: light)" srcset="https://pnpm.io/img/users/workleap.svg" />
            <source media="(prefers-color-scheme: dark)" srcset="https://pnpm.io/img/users/workleap_light.svg" />
            <img src="https://pnpm.io/img/users/workleap.svg" width="190" alt="Workleap" />
          </picture>
        </a>
      </td>
      <td align="center" valign="middle">
        <a href="https://nx.dev/?utm_source=pnpm&utm_medium=release_notes" target="_blank">
          <picture>
            <source media="(prefers-color-scheme: light)" srcset="https://pnpm.io/img/users/nx.svg" />
            <source media="(prefers-color-scheme: dark)" srcset="https://pnpm.io/img/users/nx_light.svg" />
            <img src="https://pnpm.io/img/users/nx.svg" width="50" alt="Nx" />
          </picture>
        </a>
      </td>
    </tr>
  </tbody>
</table>

<!-- sponsors end -->

### [`v10.34.0`](https://github.com/pnpm/pnpm/releases/tag/v10.34.0): pnpm 10.34

[Compare Source](https://github.com/pnpm/pnpm/compare/v10.33.4...v10.34.0)

#### Minor Changes

- Treat tarball-integrity mismatches against the lockfile as a hard failure by default. Previously, `pnpm install` (non-frozen) would log `ERR_PNPM_TARBALL_INTEGRITY`, silently re-resolve from the registry, and overwrite the locked integrity — which meant a compromised registry, proxy, or republished version could substitute attacker-controlled content on a clean machine even though the project shipped a committed lockfile.

  `pnpm install` now exits with `ERR_PNPM_TARBALL_INTEGRITY` and a hint pointing at the new opt-in flag.

  The only opt-in is **`pnpm install --update-checksums`** — narrowly scoped to refreshing the locked integrity values from what the registry currently serves. Mirrors yarn's flag of the same name. A warning still prints when the bypass takes effect so the operation is auditable.

  `--force` and `pnpm update` deliberately do **not** bypass the integrity check. They are routine refresh operations; silently overwriting a locked integrity in those flows would erase the protection a committed lockfile is supposed to provide. `--frozen-lockfile` behavior is unchanged. `--fix-lockfile` keeps its documented purpose (filling in missing lockfile entries) and is also not a bypass.

#### Patch Changes

- Pin unscoped per-registry settings (`_authToken`, `_auth`, `username`/`_password`, `tokenHelper`, inline `cert`/`key`) to the registry declared in the same config source at load time, so a later layer overriding `registry=` (workspace `.npmrc`, `pnpm-workspace.yaml`, CLI `--registry`) cannot redirect a credential or client certificate authored for a different host. A deprecation warning is emitted whenever an unscoped per-registry setting is encountered, naming the source and the URL it was pinned to. Reported by JUNYI LIU.
- Fixed `minimumReleaseAge` handling when cached metadata is abbreviated. The npm registry returns abbreviated package metadata (without the per-version `time` field) by default, which made the maturity check throw `ERR_PNPM_MISSING_TIME` whenever cached abbreviated metadata was reused. pnpm now upgrades cached abbreviated metadata to the full document via a follow-up fetch when `minimumReleaseAge` is active, persists the upgrade to the on-disk cache so subsequent installs skip the extra fetch, and lets `ERR_PNPM_MISSING_TIME` from the cache fast-path fall through to the network fetch even under strict mode.
- Reject git resolutions whose `commit` field is not a 40-character hexadecimal SHA before invoking `git`. A malicious lockfile could otherwise smuggle a value such as `--upload-pack=<command>` through `git fetch` / `git checkout`, which on SSH or local-file transports executes the supplied command.
- Reject patch files whose `diff --git` headers reference paths outside the patched package directory. Previously a malicious `.patch` file added via a pull request could write, delete, or rename arbitrary files reachable by the user running `pnpm install`.
- Fixed `--prefix=<dir>` not being honored when locating the workspace root. The `--prefix → dir` rename was applied after workspace detection, so workspace settings declared in `<dir>/pnpm-workspace.yaml` were not loaded when pnpm was invoked from outside `<dir>` [#&#8203;11535](https://github.com/pnpm/pnpm/issues/11535).
- Reject dependency aliases that contain path-traversal segments (such as `@x/../../../../../.git/hooks`) when reading them from a package manifest or symlinking them into `node_modules`. A malicious registry package could otherwise use a transitive dependency key to make `pnpm install` create symlinks at attacker-chosen paths outside the intended `node_modules` directory.

<!-- sponsors -->

#### Platinum Sponsors

<table>
  <tbody>
    <tr>
      <td align="center" valign="middle">
        <a href="https://bit.cloud/?utm_source=pnpm&utm_medium=release_notes" target="_blank"><img src="https://pnpm.io/img/users/bit.svg" width="80" alt="Bit"></a>
      </td>
    </tr>
  </tbody>
</table>

#### Gold Sponsors

<table>
  <tbody>
    <tr>
      <td align="center" valign="middle">
        <a href="https://sanity.io/?utm_source=pnpm&utm_medium=release_notes" target="_blank">
          <picture>
            <source media="(prefers-color-scheme: light)" srcset="https://pnpm.io/img/users/sanity.svg" />
            <source media="(prefers-color-scheme: dark)" srcset="https://pnpm.io/img/users/sanity_light.svg" />
            <img src="https://pnpm.io/img/users/sanity.svg" width="120" alt="Sanity" />
          </picture>
        </a>
      </td>
      <td align="center" valign="middle">
        <a href="https://discord.com/?utm_source=pnpm&utm_medium=release_notes" target="_blank">
          <picture>
            <source media="(prefers-color-scheme: light)" srcset="https://pnpm.io/img/users/discord.svg" />
            <source media="(prefers-color-scheme: dark)" srcset="https://pnpm.io/img/users/discord_light.svg" />
            <img src="https://pnpm.io/img/users/discord.svg" width="220" alt="Discord" />
          </picture>
        </a>
      </td>
      <td align="center" valign="middle">
        <a href="https://vite.dev/?utm_source=pnpm&utm_medium=release_notes" target="_blank"><img src="https://pnpm.io/img/users/vitejs.svg" width="42" alt="Vite"></a>
      </td>
    </tr>
    <tr>
      <td align="center" valign="middle">
        <a href="https://serpapi.com/?utm_source=pnpm&utm_medium=release_notes" target="_blank">
          <picture>
            <source media="(prefers-color-scheme: light)" srcset="https://pnpm.io/img/users/serpapi_dark.svg" />
            <source media="(prefers-color-scheme: dark)" srcset="https://pnpm.io/img/users/serpapi_light.svg" />
            <img src="https://pnpm.io/img/users/serpapi_dark.svg" width="160" alt="SerpApi" />
          </picture>
        </a>
      </td>
      <td align="center" valign="middle">
        <a href="https://coderabbit.ai/?utm_source=pnpm&utm_medium=release_notes" target="_blank">
          <picture>
            <source media="(prefers-color-scheme: light)" srcset="https://pnpm.io/img/users/coderabbit.svg" />
            <source media="(prefers-color-scheme: dark)" srcset="https://pnpm.io/img/users/coderabbit_light.svg" />
            <img src="https://pnpm.io/img/users/coderabbit.svg" width="220" alt="CodeRabbit" />
          </picture>
        </a>
      </td>
      <td align="center" valign="middle">
        <a href="https://stackblitz.com/?utm_source=pnpm&utm_medium=release_notes" target="_blank">
          <picture>
            <source media="(prefers-color-scheme: light)" srcset="https://pnpm.io/img/users/stackblitz.svg" />
            <source media="(prefers-color-scheme: dark)" srcset="https://pnpm.io/img/users/stackblitz_light.svg" />
            <img src="https://pnpm.io/img/users/stackblitz.svg" width="190" alt="Stackblitz" />
          </picture>
        </a>
      </td>
    </tr>
    <tr>
      <td align="center" valign="middle">
        <a href="https://workleap.com/?utm_source=pnpm&utm_medium=release_notes" target="_blank">
          <picture>
            <source media="(prefers-color-scheme: light)" srcset="https://pnpm.io/img/users/workleap.svg" />
            <source media="(prefers-color-scheme: dark)" srcset="https://pnpm.io/img/users/workleap_light.svg" />
            <img src="https://pnpm.io/img/users/workleap.svg" width="190" alt="Workleap" />
          </picture>
        </a>
      </td>
      <td align="center" valign="middle">
        <a href="https://nx.dev/?utm_source=pnpm&utm_medium=release_notes" target="_blank">
          <picture>
            <source media="(prefers-color-scheme: light)" srcset="https://pnpm.io/img/users/nx.svg" />
            <source media="(prefers-color-scheme: dark)" srcset="https://pnpm.io/img/users/nx_light.svg" />
            <img src="https://pnpm.io/img/users/nx.svg" width="50" alt="Nx" />
          </picture>
        </a>
      </td>
    </tr>
  </tbody>
</table>

<!-- sponsors end -->

</details>

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined).

🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0MC42Mi4xIiwidXBkYXRlZEluVmVyIjoiNDAuNjIuMSIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOlsiZGVwZW5kZW5jaWVzIl19-->

Reviewed-on: https://git.unespace.com/julien/apf_portal/pulls/251
Co-authored-by: APF Portal Bot <jgautier.webdev+apf-portal-bot@gmail.com>
Co-committed-by: APF Portal Bot <jgautier.webdev+apf-portal-bot@gmail.com>
2026-05-29 13:45:53 +02:00
APF Portal Bot afbad752fb chore(deps): update dependency @oxc-project/runtime to ^0.133.0 (#250)
CI / commits (push) Has been skipped
CI / scan (push) Successful in 3m36s
CI / a11y (push) Successful in 3m8s
CI / perf (push) Successful in 6m59s
CI / check (push) Successful in 7m16s
Docs site / build (push) Successful in 6m48s
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| [@oxc-project/runtime](https://oxc.rs) ([source](https://github.com/oxc-project/oxc/tree/HEAD/npm/runtime)) | devDependencies | minor | [`^0.131.0` -> `^0.133.0`](https://renovatebot.com/diffs/npm/@oxc-project%2fruntime/0.131.0/0.133.0) |

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined).

🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0MC42Mi4xIiwidXBkYXRlZEluVmVyIjoiNDAuNjIuMSIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOlsiZGVwZW5kZW5jaWVzIl19-->

Reviewed-on: #250
Co-authored-by: APF Portal Bot <jgautier.webdev+apf-portal-bot@gmail.com>
Co-committed-by: APF Portal Bot <jgautier.webdev+apf-portal-bot@gmail.com>
2026-05-29 13:44:57 +02:00
APF Portal Bot 33d2257cf5 fix(deps): update vitest to v4.1.7 (#249)
CI / commits (push) Has been skipped
CI / scan (push) Successful in 3m5s
CI / a11y (push) Successful in 2m12s
CI / check (push) Successful in 5m38s
CI / perf (push) Successful in 6m33s
Docs site / build (push) Successful in 2m58s
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| [@vitest/coverage-v8](https://vitest.dev/guide/coverage) ([source](https://github.com/vitest-dev/vitest/tree/HEAD/packages/coverage-v8)) | devDependencies | patch | [`4.1.6` -> `4.1.7`](https://renovatebot.com/diffs/npm/@vitest%2fcoverage-v8/4.1.6/4.1.7) |
| [@vitest/ui](https://vitest.dev/guide/ui) ([source](https://github.com/vitest-dev/vitest/tree/HEAD/packages/ui)) | devDependencies | patch | [`4.1.6` -> `4.1.7`](https://renovatebot.com/diffs/npm/@vitest%2fui/4.1.6/4.1.7) |
| [vitest](https://vitest.dev) ([source](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest)) | devDependencies | patch | [`4.1.6` -> `4.1.7`](https://renovatebot.com/diffs/npm/vitest/4.1.6/4.1.7) |
| [vitest](https://vitest.dev) ([source](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest)) | dependencies | patch | [`4.1.6` -> `4.1.7`](https://renovatebot.com/diffs/npm/vitest/4.1.6/4.1.7) |

---

### Release Notes

<details>
<summary>vitest-dev/vitest (@&#8203;vitest/coverage-v8)</summary>

### [`v4.1.7`](https://github.com/vitest-dev/vitest/releases/tag/v4.1.7)

[Compare Source](https://github.com/vitest-dev/vitest/compare/v4.1.6...v4.1.7)

#####    🐞 Bug Fixes

- **runner**: Limit concurrency per task branch in addition to per leaf callbacks (backport)  -  by [@&#8203;hi-ogawa](https://github.com/hi-ogawa) in https://github.com/vitest-dev/vitest/issues/10384 [<samp>(4f0f2)</samp>](https://github.com/vitest-dev/vitest/commit/4f0f2a1ee)

#####     [View changes on GitHub](https://github.com/vitest-dev/vitest/compare/v4.1.6...v4.1.7)

</details>

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined).

🚦 **Automerge**: Enabled.

♻ **Rebasing**: Whenever PR is behind base branch, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about these updates again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0MC42Mi4xIiwidXBkYXRlZEluVmVyIjoiNDAuNjIuMSIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOlsiZGVwZW5kZW5jaWVzIl19-->

Reviewed-on: https://git.unespace.com/julien/apf_portal/pulls/249
Co-authored-by: APF Portal Bot <jgautier.webdev+apf-portal-bot@gmail.com>
Co-committed-by: APF Portal Bot <jgautier.webdev+apf-portal-bot@gmail.com>
2026-05-29 12:41:59 +02:00
APF Portal Bot 17bed9de09 fix(deps): update nx to v22.7.5 (#248)
CI / commits (push) Has been skipped
CI / scan (push) Successful in 4m16s
CI / check (push) Successful in 6m44s
CI / a11y (push) Successful in 1m58s
CI / perf (push) Successful in 7m39s
Docs site / build (push) Successful in 2m35s
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| [@nx/angular](https://nx.dev) ([source](https://github.com/nrwl/nx/tree/HEAD/packages/angular)) | devDependencies | patch | [`22.7.2` -> `22.7.5`](https://renovatebot.com/diffs/npm/@nx%2fangular/22.7.2/22.7.5) |
| [@nx/devkit](https://nx.dev) ([source](https://github.com/nrwl/nx/tree/HEAD/packages/devkit)) | devDependencies | patch | [`22.7.2` -> `22.7.5`](https://renovatebot.com/diffs/npm/@nx%2fdevkit/22.7.2/22.7.5) |
| [@nx/eslint](https://nx.dev) ([source](https://github.com/nrwl/nx/tree/HEAD/packages/eslint)) | devDependencies | patch | [`22.7.2` -> `22.7.5`](https://renovatebot.com/diffs/npm/@nx%2feslint/22.7.2/22.7.5) |
| [@nx/eslint-plugin](https://nx.dev) ([source](https://github.com/nrwl/nx/tree/HEAD/packages/eslint-plugin)) | devDependencies | patch | [`22.7.2` -> `22.7.5`](https://renovatebot.com/diffs/npm/@nx%2feslint-plugin/22.7.2/22.7.5) |
| [@nx/jest](https://nx.dev) ([source](https://github.com/nrwl/nx/tree/HEAD/packages/jest)) | devDependencies | patch | [`22.7.2` -> `22.7.5`](https://renovatebot.com/diffs/npm/@nx%2fjest/22.7.2/22.7.5) |
| [@nx/js](https://nx.dev) ([source](https://github.com/nrwl/nx/tree/HEAD/packages/js)) | devDependencies | patch | [`22.7.2` -> `22.7.5`](https://renovatebot.com/diffs/npm/@nx%2fjs/22.7.2/22.7.5) |
| [@nx/nest](https://nx.dev) ([source](https://github.com/nrwl/nx/tree/HEAD/packages/nest)) | devDependencies | patch | [`22.7.2` -> `22.7.5`](https://renovatebot.com/diffs/npm/@nx%2fnest/22.7.2/22.7.5) |
| [@nx/node](https://nx.dev) ([source](https://github.com/nrwl/nx/tree/HEAD/packages/node)) | devDependencies | patch | [`22.7.2` -> `22.7.5`](https://renovatebot.com/diffs/npm/@nx%2fnode/22.7.2/22.7.5) |
| [@nx/playwright](https://nx.dev) ([source](https://github.com/nrwl/nx/tree/HEAD/packages/playwright)) | devDependencies | patch | [`22.7.2` -> `22.7.5`](https://renovatebot.com/diffs/npm/@nx%2fplaywright/22.7.2/22.7.5) |
| [@nx/vite](https://nx.dev) ([source](https://github.com/nrwl/nx/tree/HEAD/packages/vite)) | devDependencies | patch | [`22.7.2` -> `22.7.5`](https://renovatebot.com/diffs/npm/@nx%2fvite/22.7.2/22.7.5) |
| [@nx/vite](https://nx.dev) ([source](https://github.com/nrwl/nx/tree/HEAD/packages/vite)) | dependencies | patch | [`22.7.2` -> `22.7.5`](https://renovatebot.com/diffs/npm/@nx%2fvite/22.7.2/22.7.5) |
| [@nx/vitest](https://nx.dev) ([source](https://github.com/nrwl/nx/tree/HEAD/packages/vitest)) | devDependencies | patch | [`22.7.2` -> `22.7.5`](https://renovatebot.com/diffs/npm/@nx%2fvitest/22.7.2/22.7.5) |
| [@nx/web](https://nx.dev) ([source](https://github.com/nrwl/nx/tree/HEAD/packages/web)) | devDependencies | patch | [`22.7.2` -> `22.7.5`](https://renovatebot.com/diffs/npm/@nx%2fweb/22.7.2/22.7.5) |
| [@nx/webpack](https://nx.dev) ([source](https://github.com/nrwl/nx/tree/HEAD/packages/webpack)) | devDependencies | patch | [`22.7.2` -> `22.7.5`](https://renovatebot.com/diffs/npm/@nx%2fwebpack/22.7.2/22.7.5) |
| [nx](https://nx.dev) ([source](https://github.com/nrwl/nx/tree/HEAD/packages/nx)) | devDependencies | patch | [`22.7.2` -> `22.7.5`](https://renovatebot.com/diffs/npm/nx/22.7.2/22.7.5) |

---

### Release Notes

<details>
<summary>nrwl/nx (@&#8203;nx/angular)</summary>

### [`v22.7.5`](https://github.com/nrwl/nx/releases/tag/22.7.5)

[Compare Source](https://github.com/nrwl/nx/compare/22.7.4...22.7.5)

##### 22.7.5 (2026-05-27)

##### 🩹 Fixes

- **core:** update tmp to 0.2.6 due to CVE-2026-44705 ([#&#8203;35813](https://github.com/nrwl/nx/pull/35813))

##### ❤️ Thank You

- Jack Hsu [@&#8203;jaysoo](https://github.com/jaysoo)

### [`v22.7.4`](https://github.com/nrwl/nx/releases/tag/22.7.4)

[Compare Source](https://github.com/nrwl/nx/compare/22.7.3...22.7.4)

##### 22.7.4 (2026-05-25)

##### 🩹 Fixes

- **core:** update brace-expansion and yaml ([#&#8203;35790](https://github.com/nrwl/nx/pull/35790))

##### ❤️ Thank You

- Jack Hsu [@&#8203;jaysoo](https://github.com/jaysoo)

### [`v22.7.3`](https://github.com/nrwl/nx/releases/tag/22.7.3)

[Compare Source](https://github.com/nrwl/nx/compare/22.7.2...22.7.3)

##### 22.7.3 (2026-05-22)

##### 🚀 Features

- **js:** support pnpm 11.2.2 ([#&#8203;35772](https://github.com/nrwl/nx/pull/35772))

##### 🩹 Fixes

- **angular:** only add [@&#8203;oxc-project/runtime](https://github.com/oxc-project/runtime) on the vitest-analog path ([#&#8203;35734](https://github.com/nrwl/nx/pull/35734))
- **angular-rspack:** exclude eslint config from tailwind v4 source scan ([#&#8203;35663](https://github.com/nrwl/nx/pull/35663))
- **core:** warn before installing unknown npm packages as preset ([#&#8203;35644](https://github.com/nrwl/nx/pull/35644))
- **core:** preserve input order in createNodes plugin results ([#&#8203;35595](https://github.com/nrwl/nx/pull/35595))
- **core:** resolve local plugin subpath imports from source ([#&#8203;35631](https://github.com/nrwl/nx/pull/35631))
- **core:** treat undefined task parallelism as parallel when scheduling ([#&#8203;35736](https://github.com/nrwl/nx/pull/35736))
- **core:** handle object form of bin field in getPrettierPath ([#&#8203;35680](https://github.com/nrwl/nx/pull/35680))
- **core:** detect vscode copilot ai agent ([#&#8203;35757](https://github.com/nrwl/nx/pull/35757))
- **core:** allow local plugin subpath imports without custom conditions ([#&#8203;35751](https://github.com/nrwl/nx/pull/35751), [#&#8203;35631](https://github.com/nrwl/nx/issues/35631))
- **dotnet:** include Directory.*.* files in inputs ([#&#8203;35738](https://github.com/nrwl/nx/pull/35738))
- **gradle:** add transitive:true to all tasks ([#&#8203;35677](https://github.com/nrwl/nx/pull/35677))
- **gradle:** pin generated e2e project toolchain to installed JDK ([#&#8203;35703](https://github.com/nrwl/nx/pull/35703))
- **js:** fall back to npm publish when bun publish fails with auth error ([#&#8203;35756](https://github.com/nrwl/nx/pull/35756))
- **linter:** improve convert-to-flat-config output fidelity ([#&#8203;35330](https://github.com/nrwl/nx/pull/35330))
- **linter:** only rewrite workspace-package peer deps to workspace:\* ([#&#8203;35423](https://github.com/nrwl/nx/pull/35423), [#&#8203;35318](https://github.com/nrwl/nx/issues/35318), [#&#8203;33417](https://github.com/nrwl/nx/issues/33417))
- **misc:** stop inferring `projects: 'self'` in `dependsOn` entries ([#&#8203;35686](https://github.com/nrwl/nx/pull/35686))
- **misc:** skip `$` escaping in file paths on windows ([#&#8203;35692](https://github.com/nrwl/nx/pull/35692))
- **repo:** run dotnet restore before publish ([#&#8203;35771](https://github.com/nrwl/nx/pull/35771))
- **repo:** run dotnet restore before macos e2e job ([#&#8203;35774](https://github.com/nrwl/nx/pull/35774))
- **rsbuild:** infer build outputs from distPath.root directly ([#&#8203;35707](https://github.com/nrwl/nx/pull/35707))
- **rsbuild:** lazy-require [@&#8203;rsbuild/core](https://github.com/rsbuild/core) in plugin so spec mocks work after jest.resetModules ([#&#8203;35707](https://github.com/nrwl/nx/issues/35707))
- **testing:** correct yargs-parser import in getJestProjectsAsync ([#&#8203;35672](https://github.com/nrwl/nx/pull/35672), [#&#8203;35654](https://github.com/nrwl/nx/issues/35654))

##### ❤️ Thank You

- AgentEnder [@&#8203;AgentEnder](https://github.com/AgentEnder)
- Artur [@&#8203;arturovt](https://github.com/arturovt)
- Benjamin Staneck [@&#8203;Stanzilla](https://github.com/Stanzilla)
- Copilot [@&#8203;Copilot](https://github.com/Copilot)
- Craigory Coppola [@&#8203;AgentEnder](https://github.com/AgentEnder)
- FrozenPandaz [@&#8203;FrozenPandaz](https://github.com/FrozenPandaz)
- Jason Jean [@&#8203;FrozenPandaz](https://github.com/FrozenPandaz)
- Leosvel Pérez Espinosa [@&#8203;leosvelperez](https://github.com/leosvelperez)
- leosvelperez [@&#8203;leosvelperez](https://github.com/leosvelperez)
- Louie Weng [@&#8203;lourw](https://github.com/lourw)

</details>

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined).

🚦 **Automerge**: Enabled.

♻ **Rebasing**: Whenever PR is behind base branch, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about these updates again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0MC42Mi4xIiwidXBkYXRlZEluVmVyIjoiNDAuNjIuMSIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOlsiZGVwZW5kZW5jaWVzIl19-->

Reviewed-on: https://git.unespace.com/julien/apf_portal/pulls/248
Co-authored-by: APF Portal Bot <jgautier.webdev+apf-portal-bot@gmail.com>
Co-committed-by: APF Portal Bot <jgautier.webdev+apf-portal-bot@gmail.com>
2026-05-29 12:04:15 +02:00
APF Portal Bot ffed212cf9 fix(deps): update angular to v21.2.15 (#247)
CI / commits (push) Has been skipped
CI / scan (push) Successful in 3m41s
CI / check (push) Successful in 6m39s
CI / a11y (push) Successful in 2m41s
CI / perf (push) Successful in 7m33s
Docs site / build (push) Successful in 2m49s
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| [@angular/common](https://github.com/angular/angular) ([source](https://github.com/angular/angular/tree/HEAD/packages/common)) | dependencies | patch | [`21.2.14` -> `21.2.15`](https://renovatebot.com/diffs/npm/@angular%2fcommon/21.2.14/21.2.15) |
| [@angular/compiler](https://github.com/angular/angular) ([source](https://github.com/angular/angular/tree/HEAD/packages/compiler)) | dependencies | patch | [`21.2.14` -> `21.2.15`](https://renovatebot.com/diffs/npm/@angular%2fcompiler/21.2.14/21.2.15) |
| [@angular/compiler-cli](https://github.com/angular/angular/tree/main/packages/compiler-cli) ([source](https://github.com/angular/angular/tree/HEAD/packages/compiler-cli)) | devDependencies | patch | [`21.2.14` -> `21.2.15`](https://renovatebot.com/diffs/npm/@angular%2fcompiler-cli/21.2.14/21.2.15) |
| [@angular/core](https://github.com/angular/angular) ([source](https://github.com/angular/angular/tree/HEAD/packages/core)) | dependencies | patch | [`21.2.14` -> `21.2.15`](https://renovatebot.com/diffs/npm/@angular%2fcore/21.2.14/21.2.15) |
| [@angular/forms](https://github.com/angular/angular) ([source](https://github.com/angular/angular/tree/HEAD/packages/forms)) | dependencies | patch | [`21.2.14` -> `21.2.15`](https://renovatebot.com/diffs/npm/@angular%2fforms/21.2.14/21.2.15) |
| [@angular/language-service](https://github.com/angular/angular) ([source](https://github.com/angular/angular/tree/HEAD/packages/language-service)) | devDependencies | patch | [`21.2.14` -> `21.2.15`](https://renovatebot.com/diffs/npm/@angular%2flanguage-service/21.2.14/21.2.15) |
| [@angular/localize](https://github.com/angular/angular) | dependencies | patch | [`21.2.14` -> `21.2.15`](https://renovatebot.com/diffs/npm/@angular%2flocalize/21.2.14/21.2.15) |
| [@angular/platform-browser](https://github.com/angular/angular) ([source](https://github.com/angular/angular/tree/HEAD/packages/platform-browser)) | dependencies | patch | [`21.2.14` -> `21.2.15`](https://renovatebot.com/diffs/npm/@angular%2fplatform-browser/21.2.14/21.2.15) |
| [@angular/router](https://github.com/angular/angular/tree/main/packages/router) ([source](https://github.com/angular/angular/tree/HEAD/packages/router)) | dependencies | patch | [`21.2.14` -> `21.2.15`](https://renovatebot.com/diffs/npm/@angular%2frouter/21.2.14/21.2.15) |

---

### Release Notes

<details>
<summary>angular/angular (@&#8203;angular/common)</summary>

### [`v21.2.15`](https://github.com/angular/angular/blob/HEAD/CHANGELOG.md#21215-2026-05-28)

[Compare Source](https://github.com/angular/angular/compare/v21.2.14...v21.2.15)

##### common

| Commit | Type | Description |
| -- | -- | -- |
| [7f4ac78994](https://github.com/angular/angular/commit/7f4ac78994bff1576ab33f3ce48f95c17f40b4d8) | fix | add upper bounds for digitsInfo |
| [300f61feb3](https://github.com/angular/angular/commit/300f61feb3a534bfddf16fcbd240f97b32249699) | fix | sanitize placeholder |

##### compiler

| Commit | Type | Description |
| -- | -- | -- |
| [0b07f47bd6](https://github.com/angular/angular/commit/0b07f47bd6598ae6bd5b75a375e2c817a3c0f243) | fix | normalize tag names with custom namespaces in DomElementSchemaRegistry ([#&#8203;68925](https://github.com/angular/angular/pull/68925)) |
| [eb1cbbf2eb](https://github.com/angular/angular/commit/eb1cbbf2eb5833219a367a61c04eb07aaa36cc29) | fix | prevent namespaced SVG <style> elements from being stripped |
| [cc1378d54b](https://github.com/angular/angular/commit/cc1378d54bd93f3882d732261be8e66720eb71b2) | fix | sanitize dynamic href and xlink:href bindings on SVG a elements ([#&#8203;68925](https://github.com/angular/angular/pull/68925)) |
| [782e01594e](https://github.com/angular/angular/commit/782e01594e2ad9134c7385dcf3b518101b23ccab) | fix | strip namespaced SVG script elements during template compilation ([#&#8203;68925](https://github.com/angular/angular/pull/68925)) |

##### core

| Commit | Type | Description |
| -- | -- | -- |
| [ff12fe55ac](https://github.com/angular/angular/commit/ff12fe55ace5e861ba261afb4c0480ff3c40a192) | fix | normalize tag names in runtime i18n attribute security context lookup ([#&#8203;68925](https://github.com/angular/angular/pull/68925)) |
| [e6fe77cc97](https://github.com/angular/angular/commit/e6fe77cc97fd10351687416f938bf754aff4eb9f) | fix | sanitize meta selectors |
| [daaf32937f](https://github.com/angular/angular/commit/daaf32937fd5c46e411b26f7c082613716fe9550) | fix | support prefix-insensitive DOM schema lookups and compile-time i18n attribute validation ([#&#8203;68925](https://github.com/angular/angular/pull/68925)) |
| [dada86e43d](https://github.com/angular/angular/commit/dada86e43d847204f714d1a933084617ab941c0a) | fix | synchronize core sanitization schema with compiler ([#&#8203;68925](https://github.com/angular/angular/pull/68925)) |

##### http

| Commit | Type | Description |
| -- | -- | -- |
| [582a417bd2](https://github.com/angular/angular/commit/582a417bd27fdaf989e5065dbcdf1ad752faf70c) | fix | exclude withCredentials requests from transfer cache |
| [5c6d6df34b](https://github.com/angular/angular/commit/5c6d6df34bbeff3ce98f3b35875444f925cc8f51) | fix | skip TransferCache for cookie-bearing requests by default |

##### platform-server

| Commit | Type | Description |
| -- | -- | -- |
| [37e8aadf87](https://github.com/angular/angular/commit/37e8aadf87b4facfcaf002a1557f8c393a362d97) | fix | prevent SSRF bypasses via backslash URLs in HttpClient |
| [72696e244e](https://github.com/angular/angular/commit/72696e244ed7646cca9ab9afc7769a2163943bda) | fix | secure location and document initialization against SSRF and path hijack |

##### service-worker

| Commit | Type | Description |
| -- | -- | -- |
| [b8bd49341d](https://github.com/angular/angular/commit/b8bd49341ddcee10d119a9d4aa8e5736e4e5da53) | fix | Preserves explicit 'credentials: omit' in asset requests |
| [ca32fc1000](https://github.com/angular/angular/commit/ca32fc10001301e6174804f9abcfba62252334f4) | fix | Preserves HTTP cache mode in asset group requests |

<!-- CHANGELOG SPLIT MARKER -->

</details>

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined).

🚦 **Automerge**: Enabled.

♻ **Rebasing**: Whenever PR is behind base branch, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about these updates again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0MC42Mi4xIiwidXBkYXRlZEluVmVyIjoiNDAuNjIuMSIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOlsiZGVwZW5kZW5jaWVzIl19-->

Reviewed-on: #247
Co-authored-by: APF Portal Bot <jgautier.webdev+apf-portal-bot@gmail.com>
Co-committed-by: APF Portal Bot <jgautier.webdev+apf-portal-bot@gmail.com>
2026-05-29 09:43:56 +02:00
APF Portal Bot 67d04b0a78 fix(deps): update nestjs (#246)
CI / commits (push) Has been skipped
CI / scan (push) Successful in 2m11s
CI / check (push) Failing after 6m34s
CI / a11y (push) Successful in 4m10s
CI / perf (push) Successful in 6m47s
Docs site / build (push) Successful in 3m3s
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| [@nestjs/common](https://nestjs.com) ([source](https://github.com/nestjs/nest/tree/HEAD/packages/common)) | dependencies | patch | [`11.1.21` -> `11.1.24`](https://renovatebot.com/diffs/npm/@nestjs%2fcommon/11.1.21/11.1.24) |
| [@nestjs/core](https://nestjs.com) ([source](https://github.com/nestjs/nest/tree/HEAD/packages/core)) | dependencies | patch | [`11.1.21` -> `11.1.24`](https://renovatebot.com/diffs/npm/@nestjs%2fcore/11.1.21/11.1.24) |
| [@nestjs/platform-express](https://nestjs.com) ([source](https://github.com/nestjs/nest/tree/HEAD/packages/platform-express)) | dependencies | patch | [`11.1.21` -> `11.1.24`](https://renovatebot.com/diffs/npm/@nestjs%2fplatform-express/11.1.21/11.1.24) |
| [@nestjs/swagger](https://github.com/nestjs/swagger) | dependencies | patch | [`11.4.3` -> `11.4.4`](https://renovatebot.com/diffs/npm/@nestjs%2fswagger/11.4.3/11.4.4) |
| [@nestjs/testing](https://nestjs.com) ([source](https://github.com/nestjs/nest/tree/HEAD/packages/testing)) | devDependencies | patch | [`11.1.21` -> `11.1.24`](https://renovatebot.com/diffs/npm/@nestjs%2ftesting/11.1.21/11.1.24) |

---

### Release Notes

<details>
<summary>nestjs/nest (@&#8203;nestjs/common)</summary>

### [`v11.1.24`](https://github.com/nestjs/nest/releases/tag/v11.1.24)

[Compare Source](https://github.com/nestjs/nest/compare/v11.1.23...v11.1.24)

##### v11.1.24 (2026-05-25)

##### Bug fixes

- `core`
  - [#&#8203;17009](https://github.com/nestjs/nest/pull/17009) fix(core): reset dependency-tree cache on metadata changes ([@&#8203;puneetdixit200](https://github.com/puneetdixit200))

##### Enhancements

- `core`
  - [#&#8203;16997](https://github.com/nestjs/nest/pull/16997) feat(core): warn on late websocket adapter registration ([@&#8203;hbinhng](https://github.com/hbinhng))

##### Dependencies

- `platform-ws`
  - [#&#8203;17011](https://github.com/nestjs/nest/pull/17011) chore(deps): bump ws from 8.20.1 to 8.21.0 ([@&#8203;dependabot\[bot\]](https://github.com/apps/dependabot))

##### Committers: 2

- Nguyễn Hải Bình ([@&#8203;hbinhng](https://github.com/hbinhng))
- Puneet Dixit ([@&#8203;puneetdixit200](https://github.com/puneetdixit200))

### [`v11.1.23`](https://github.com/nestjs/nest/releases/tag/v11.1.23)

[Compare Source](https://github.com/nestjs/nest/compare/v11.1.22...v11.1.23)

##### v11.1.23 (2026-05-21)

##### Bug fixes

- `core`
  - https://github.com/nestjs/nest/issues/16998 fix snapshot: true eagerly instantiates Terminus transient indicators since 11.1.20

##### Committers: 1

- Kamil Mysliwiec ([@&#8203;kamilmysliwiec](https://github.com/kamilmysliwiec))

### [`v11.1.22`](https://github.com/nestjs/nest/releases/tag/v11.1.22)

[Compare Source](https://github.com/nestjs/nest/compare/v11.1.21...v11.1.22)

##### v11.1.22 (2026-05-21)

##### Bug fixes

- `core`
  - [#&#8203;16993](https://github.com/nestjs/nest/pull/16993) fix(core): inflight request injection bug [#&#8203;16989](https://github.com/nestjs/nest/issues/16989) ([@&#8203;kamilmysliwiec](https://github.com/kamilmysliwiec))

##### Enhancements

- `core`
  - [#&#8203;16967](https://github.com/nestjs/nest/pull/16967) fix(core): identify decorator type in invalid-class-module error ([@&#8203;HarrierOnChain](https://github.com/HarrierOnChain))
  -

##### Committers: 2

- Harrier ([@&#8203;HarrierOnChain](https://github.com/HarrierOnChain))
- Kamil Mysliwiec ([@&#8203;kamilmysliwiec](https://github.com/kamilmysliwiec))

</details>

<details>
<summary>nestjs/swagger (@&#8203;nestjs/swagger)</summary>

### [`v11.4.4`](https://github.com/nestjs/swagger/releases/tag/11.4.4)

[Compare Source](https://github.com/nestjs/swagger/compare/11.4.3...11.4.4)

#### 11.4.4 (2026-05-21)

##### Bug fixes

- [#&#8203;3930](https://github.com/nestjs/swagger/pull/3930) fix: top-level nullable with discriminator issue  ([@&#8203;kamilmysliwiec](https://github.com/kamilmysliwiec))

##### Enhancements

- [#&#8203;3921](https://github.com/nestjs/swagger/pull/3921) feat(swagger): add summary field to Tag Object (OpenAPI 3.2) ([@&#8203;frbuceta](https://github.com/frbuceta))
- [#&#8203;3924](https://github.com/nestjs/swagger/pull/3924) feat(swagger): warn when [@&#8203;ApiTags](https://github.com/ApiTags) receives hierarchy fields ([@&#8203;frbuceta](https://github.com/frbuceta))
- [#&#8203;3925](https://github.com/nestjs/swagger/pull/3925) fix(swagger): type Tag Object kind as a free-form string ([@&#8203;frbuceta](https://github.com/frbuceta))

##### Committers: 4

- Alexander Scholz ([@&#8203;LucidityDesign](https://github.com/LucidityDesign))
- Francisco Buceta ([@&#8203;frbuceta](https://github.com/frbuceta))
- Kamil Mysliwiec ([@&#8203;kamilmysliwiec](https://github.com/kamilmysliwiec))
- Natanael dos Santos Feitosa ([@&#8203;natanfeitosa](https://github.com/natanfeitosa))

</details>

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined).

🚦 **Automerge**: Enabled.

♻ **Rebasing**: Whenever PR is behind base branch, or you tick the rebase/retry checkbox.

👻 **Immortal**: This PR will be recreated if closed unmerged. Get [config help](https://github.com/renovatebot/renovate/discussions) if that's undesired.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0MC42Mi4xIiwidXBkYXRlZEluVmVyIjoiNDAuNjIuMSIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOlsiZGVwZW5kZW5jaWVzIl19-->

Reviewed-on: https://git.unespace.com/julien/apf_portal/pulls/246
Co-authored-by: APF Portal Bot <jgautier.webdev+apf-portal-bot@gmail.com>
Co-committed-by: APF Portal Bot <jgautier.webdev+apf-portal-bot@gmail.com>
2026-05-28 17:39:48 +02:00
APF Portal Bot 0bdc065eb4 fix(deps): update dependency nestjs-cls to v6.2.1 (#245)
CI / commits (push) Has been skipped
CI / scan (push) Successful in 3m54s
CI / a11y (push) Successful in 2m23s
CI / check (push) Successful in 6m39s
CI / perf (push) Successful in 7m6s
Docs site / build (push) Successful in 2m55s
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| [nestjs-cls](https://papooch.github.io/nestjs-cls/) ([source](https://github.com/Papooch/nestjs-cls)) | dependencies | patch | [`6.2.0` -> `6.2.1`](https://renovatebot.com/diffs/npm/nestjs-cls/6.2.0/6.2.1) |

---

### Release Notes

<details>
<summary>Papooch/nestjs-cls (nestjs-cls)</summary>

### [`v6.2.1`](https://github.com/Papooch/nestjs-cls/releases/tag/nestjs-cls%406.2.1)

[Compare Source](https://github.com/Papooch/nestjs-cls/compare/nestjs-cls@6.2.0...nestjs-cls@6.2.1)

##### Bug Fixes

- **core**: establish ALS root context at init to mitigate enterWith leak on Node < 24 ([#&#8203;577](https://github.com/Papooch/nestjs-cls/issues/577)) ([05b8fb0](https://github.com/Papooch/nestjs-cls/commits/05b8fb0))
- **core**: improve ClsPluginsHooksHost error message and document app.init() requirement ([#&#8203;578](https://github.com/Papooch/nestjs-cls/issues/578)) ([2a4e874](https://github.com/Papooch/nestjs-cls/commits/2a4e874))

</details>

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined).

🚦 **Automerge**: Enabled.

♻ **Rebasing**: Whenever PR is behind base branch, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0MC42Mi4xIiwidXBkYXRlZEluVmVyIjoiNDAuNjIuMSIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOlsiZGVwZW5kZW5jaWVzIl19-->

Reviewed-on: https://git.unespace.com/julien/apf_portal/pulls/245
Co-authored-by: APF Portal Bot <jgautier.webdev+apf-portal-bot@gmail.com>
Co-committed-by: APF Portal Bot <jgautier.webdev+apf-portal-bot@gmail.com>
2026-05-28 17:16:02 +02:00
APF Portal Bot c41f0a9af9 fix(deps): update angular to v21.2.13 (#244)
CI / commits (push) Has been skipped
CI / scan (push) Successful in 3m56s
CI / a11y (push) Successful in 2m27s
CI / check (push) Successful in 6m44s
CI / perf (push) Successful in 6m35s
Docs site / build (push) Successful in 2m36s
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| [@angular-devkit/core](https://github.com/angular/angular-cli) | devDependencies | patch | [`21.2.12` -> `21.2.13`](https://renovatebot.com/diffs/npm/@angular-devkit%2fcore/21.2.12/21.2.13) |
| [@angular-devkit/schematics](https://github.com/angular/angular-cli) | devDependencies | patch | [`21.2.12` -> `21.2.13`](https://renovatebot.com/diffs/npm/@angular-devkit%2fschematics/21.2.12/21.2.13) |
| [@angular/build](https://github.com/angular/angular-cli) | devDependencies | patch | [`21.2.12` -> `21.2.13`](https://renovatebot.com/diffs/npm/@angular%2fbuild/21.2.12/21.2.13) |
| [@angular/cdk](https://github.com/angular/components) | dependencies | patch | [`21.2.12` -> `21.2.13`](https://renovatebot.com/diffs/npm/@angular%2fcdk/21.2.12/21.2.13) |
| [@angular/cli](https://github.com/angular/angular-cli) | devDependencies | patch | [`21.2.12` -> `21.2.13`](https://renovatebot.com/diffs/npm/@angular%2fcli/21.2.12/21.2.13) |
| [@schematics/angular](https://github.com/angular/angular-cli) | devDependencies | patch | [`21.2.12` -> `21.2.13`](https://renovatebot.com/diffs/npm/@schematics%2fangular/21.2.12/21.2.13) |

---

### Release Notes

<details>
<summary>angular/angular-cli (@&#8203;angular-devkit/core)</summary>

### [`v21.2.13`](https://github.com/angular/angular-cli/blob/HEAD/CHANGELOG.md#21213-2026-05-27)

[Compare Source](https://github.com/angular/angular-cli/compare/v21.2.12...v21.2.13)

##### [@&#8203;angular-devkit/build-angular](https://github.com/angular-devkit/build-angular)

| Commit                                                                                              | Type | Description                                                |
| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------------- |
| [3c6d26a31](https://github.com/angular/angular-cli/commit/3c6d26a316cd6aea455c19b249dc6852d84a698e) | fix  | remove unconditional CORS wildcard from webpack dev-server |

##### [@&#8203;angular/build](https://github.com/angular/build)

| Commit                                                                                              | Type | Description                                             |
| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------- |
| [2b3e95517](https://github.com/angular/angular-cli/commit/2b3e95517358f8ef3482d5319d970f4774e45ad0) | fix  | assert that asset input paths are within workspace root |

<!-- CHANGELOG SPLIT MARKER -->

</details>

<details>
<summary>angular/components (@&#8203;angular/cdk)</summary>

### [`v21.2.13`](https://github.com/angular/components/blob/HEAD/CHANGELOG.md#21213-21-2-13-2026-05-27)

[Compare Source](https://github.com/angular/components/compare/v21.2.12...v21.2.13)

No user facing changes in this release

<!-- CHANGELOG SPLIT MARKER -->

</details>

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined).

🚦 **Automerge**: Enabled.

♻ **Rebasing**: Whenever PR is behind base branch, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about these updates again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0MC42Mi4xIiwidXBkYXRlZEluVmVyIjoiNDAuNjIuMSIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOlsiZGVwZW5kZW5jaWVzIl19-->

Reviewed-on: #244
Co-authored-by: APF Portal Bot <jgautier.webdev+apf-portal-bot@gmail.com>
Co-committed-by: APF Portal Bot <jgautier.webdev+apf-portal-bot@gmail.com>
2026-05-28 17:01:44 +02:00
APF Portal Bot c5704ef2af chore(deps): update dependency dayjs to v1.11.21 (#242)
CI / commits (push) Has been skipped
CI / scan (push) Successful in 3m11s
CI / a11y (push) Successful in 2m19s
CI / check (push) Successful in 5m53s
CI / perf (push) Successful in 6m4s
Docs site / build (push) Successful in 3m10s
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| [dayjs](https://day.js.org) ([source](https://github.com/iamkun/dayjs)) | devDependencies | patch | [`1.11.20` -> `1.11.21`](https://renovatebot.com/diffs/npm/dayjs/1.11.20/1.11.21) |

---

### Release Notes

<details>
<summary>iamkun/dayjs (dayjs)</summary>

### [`v1.11.21`](https://github.com/iamkun/dayjs/blob/HEAD/CHANGELOG.md#11121-2026-05-26)

[Compare Source](https://github.com/iamkun/dayjs/compare/v1.11.20...v1.11.21)

##### Bug Fixes

- preserve unsupported year tokens in format ([#&#8203;3015](https://github.com/iamkun/dayjs/issues/3015)) ([#&#8203;3016](https://github.com/iamkun/dayjs/issues/3016)) ([8fda602](https://github.com/iamkun/dayjs/commit/8fda602beac5abbc64230ddc49085aa532320f26))

</details>

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined).

🚦 **Automerge**: Enabled.

♻ **Rebasing**: Whenever PR is behind base branch, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0MC42Mi4xIiwidXBkYXRlZEluVmVyIjoiNDAuNjIuMSIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOlsiZGVwZW5kZW5jaWVzIl19-->

Reviewed-on: https://git.unespace.com/julien/apf_portal/pulls/242
Co-authored-by: APF Portal Bot <jgautier.webdev+apf-portal-bot@gmail.com>
Co-committed-by: APF Portal Bot <jgautier.webdev+apf-portal-bot@gmail.com>
2026-05-28 10:40:57 +02:00
julien e88263a985 fix(ci): give gitlab perf job a discoverable chromium (ADR-0028) (#243)
CI / scan (push) Successful in 2m31s
CI / commits (push) Has been skipped
CI / check (push) Successful in 3m54s
CI / a11y (push) Successful in 2m2s
CI / perf (push) Successful in 3m43s
## Summary

Fix the GitLab `perf` job, which failed at the Lighthouse CI healthcheck with `Chrome installation not found`. Follow-up on [ADR-0028](../docs/decisions/0028-migrate-cicd-and-git-hosting-to-gitlab.md) Phase 2 (`.gitlab-ci.yml` landed in #241).

## Root cause

The `perf` job ran on `mcr.microsoft.com/playwright:v1.55.0-jammy`. That image **does** ship Chromium, but under `/ms-playwright/chromium-<rev>/chrome-linux/chrome` — a non-standard path/name. Lighthouse CI uses `chrome-launcher`, which discovers Chrome by probing the `PATH` (`google-chrome`, `chromium`, `chromium-browser`) or the `CHROME_PATH` env var. It cannot find Playwright's bundled Chromium, so the healthcheck fails before any run starts.

The same class of failure ("absence de Chrome pour Lighthouse") was hit and solved on the Gitea side — there the `catthehacker/ubuntu:full-22.04` image happened to carry Chrome at a standard location.

## Fix

Decouple the two browser-driving tools instead of forcing one image to serve both:

| Aspect | Before | After |
| --- | --- | --- |
| `image` | `mcr.microsoft.com/playwright:v1.55.0-jammy` | `node:24-bookworm` (same as the `.node-job` base) |
| Chrome | bundled, undiscoverable | `apt-get install --no-install-recommends chromium` → `/usr/bin/chromium` |
| `CHROME_PATH` | unset | `/usr/bin/chromium` (explicit, removes any PATH ambiguity) |
| `before_script` | inherited | `!reference [.node-job, before_script]` + the apt install |

Rationale for not pinning Playwright's Chromium via `CHROME_PATH` instead: that couples the perf job to an exact match between the npm `@playwright/test` version and the Docker image tag. A Renovate bump of `@playwright/test` while the image stays at `v1.55.0` would silently break `executablePath()`. The apt-installed Debian `chromium` has no such coupling.

The future ADR-0016 axe-core e2e job — which drives **Playwright** directly — will use the Playwright image. Lighthouse wants a standard Chrome; Playwright wants its own browsers. Different tools, different images.

`lighthouserc.js` already passes `--no-sandbox` (containerised-CI requirement), so no change needed there — that flag's rationale applies identically on GitLab Runner.

## Trade-off noted

The `apt-get install chromium` re-downloads ~100 MB per perf run (the package lands in the job container's ephemeral writable layer, not a cached image layer). Acceptable for now. If perf-job duration or `vm-gitlab` bandwidth becomes a pain, the clean optimisation is a small custom image with Chromium pre-installed, pushed to GitLab's Container Registry — out of scope here, flagged for later.

## Test plan

- [ ] GitLab `perf` job reaches the Lighthouse run (no more `Chrome installation not found`) and the ADR-0017 assertions evaluate.
- [ ] `chromium` apt install completes on `node:24-bookworm` (Debian bookworm `main` carries the package).
- [ ] `.gitlab-ci.yml` passes GitLab CI Lint (Project → Build → Pipeline Editor → Validate) — confirms the `!reference` + added keys parse.
- [ ] Other jobs (`check`, `audit`, `commits`, `a11y`, `sast`, `secret_detection`) unaffected.

## Related

- [ADR-0028](../docs/decisions/0028-migrate-cicd-and-git-hosting-to-gitlab.md) Phase 2 — `.gitlab-ci.yml` (#241).
- [ADR-0017](../docs/decisions/0017-performance-budgets-lighthouse-ci.md) — Lighthouse CI perf budgets.
- [ADR-0016](../docs/decisions/0016-accessibility-baseline-wcag-aa-targeted-aaa.md) — future axe-core e2e (will own the Playwright image).

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #243
2026-05-28 10:12:05 +02:00
julien c24795f1d6 ci(gitlab): land .gitlab-ci.yml alongside gitea workflow (ADR-0028 phase 2) (#241)
CI / check (push) Successful in 2m38s
CI / commits (push) Has been skipped
CI / scan (push) Successful in 2m47s
CI / a11y (push) Successful in 1m29s
CI / perf (push) Successful in 4m41s
## Summary

Bump the existing `tmp` security override from `>=0.2.4` to `>=0.2.6` to cover [GHSA-ph9p-34f9-6g65](https://github.com/advisories/GHSA-ph9p-34f9-6g65) — a path-traversal vulnerability via unsanitized prefix/postfix in `tmp@<0.2.6`. Surfaced by `pnpm ci:audit` on the next CI run; both Gitea and the in-flight GitLab Phase 2 pipeline fail without this.

## Why this isn't an upstream upgrade

`tmp` is a transitive dependency. The two consuming paths today:

| Path | Latest available |
| --- | --- |
| `.>nx>tmp` | `nx@22.7.4` still declares `tmp@^0.2.4` |
| `.>@lhci/cli>tmp` | `@lhci/cli@0.15.1` (latest) still declares `tmp@^0.1.0` |

Upgrading `nx` or `@lhci/cli` does not move `tmp` to 0.2.6. The pre-existing `pnpm.overrides` entry was already pinning `tmp@<0.2.4` → `>=0.2.4` against the prior advisory; this PR just widens the lower bound to match the new one. Same pattern, one line.

## What lands

| File | Change |
| --- | --- |
| `package.json` | `"tmp@<0.2.4": ">=0.2.4"` → `"tmp@<0.2.6": ">=0.2.6"` (one line). |
| `pnpm-lock.yaml` | `tmp@0.2.4` → `tmp@0.2.6` resolved; `@scalar/nestjs-api-reference@1.1.16 → 1.1.19` picked up as a natural transitive resolution during `pnpm install` (no semver-major, both are within the existing `^1.1.14` range and match Renovate's "patch + auto-merge" policy). |

## Risk

Patch bump (0.2.4 → 0.2.6) on a tiny library with a stable public API since v0.2.0. The release notes for 0.2.5 and 0.2.6 are sanitization-only fixes — no API surface change. Risk of regression in nx / lhci consumers: negligible.

## Test plan

- [x] `pnpm audit --audit-level=moderate` returns `No known vulnerabilities found` (exit 0).
- [x] `pnpm why tmp` shows `tmp@0.2.6` as the single resolved version (no duplicate).
- [ ] CI green on Gitea (`check` + `scan` jobs).
- [ ] Once merged, rebase `ci/gitlab-pipeline-phase2` on top and retry the GitLab `audit` job — expected green.

## Related

- [GHSA-ph9p-34f9-6g65](https://github.com/advisories/GHSA-ph9p-34f9-6g65) — the advisory.
- Unblocks [ADR-0028](../docs/decisions/0028-migrate-cicd-and-git-hosting-to-gitlab.md) Phase 2 pipeline parity validation (see `ci/gitlab-pipeline-phase2` branch).

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #241
2026-05-27 17:53:39 +02:00
APF Portal Bot a848e0fabd chore(deps): update dependency @swc/helpers to v0.5.23 (#239)
CI / commits (push) Has been skipped
CI / check (push) Successful in 3m25s
CI / scan (push) Successful in 1m54s
CI / a11y (push) Successful in 2m55s
CI / perf (push) Successful in 5m2s
Docs site / build (push) Successful in 4m42s
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| [@swc/helpers](https://swc.rs) ([source](https://github.com/swc-project/swc/tree/HEAD/packages/helpers)) | devDependencies | patch | [`0.5.21` -> `0.5.23`](https://renovatebot.com/diffs/npm/@swc%2fhelpers/0.5.21/0.5.23) |

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined).

🚦 **Automerge**: Enabled.

♻ **Rebasing**: Whenever PR is behind base branch, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0MC42Mi4xIiwidXBkYXRlZEluVmVyIjoiNDAuNjIuMSIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOlsiZGVwZW5kZW5jaWVzIl19-->

Reviewed-on: #239
Co-authored-by: APF Portal Bot <jgautier.webdev+apf-portal-bot@gmail.com>
Co-committed-by: APF Portal Bot <jgautier.webdev+apf-portal-bot@gmail.com>
2026-05-27 17:34:46 +02:00
julien e2894afc4a chore(deps): bump tmp override to >=0.2.6 (GHSA-ph9p-34f9-6g65) (#240)
CI / commits (push) Has been skipped
CI / check (push) Successful in 3m39s
CI / scan (push) Successful in 2m21s
CI / a11y (push) Successful in 2m43s
CI / perf (push) Successful in 4m44s
Docs site / build (push) Successful in 4m25s
## Summary

Bump the existing `tmp` security override from `>=0.2.4` to `>=0.2.6` to cover [GHSA-ph9p-34f9-6g65](https://github.com/advisories/GHSA-ph9p-34f9-6g65) — a path-traversal vulnerability via unsanitized prefix/postfix in `tmp@<0.2.6`. Surfaced by `pnpm ci:audit` on the next CI run; both Gitea and the in-flight GitLab Phase 2 pipeline fail without this.

## Why this isn't an upstream upgrade

`tmp` is a transitive dependency. The two consuming paths today:

| Path | Latest available |
| --- | --- |
| `.>nx>tmp` | `nx@22.7.4` still declares `tmp@^0.2.4` |
| `.>@lhci/cli>tmp` | `@lhci/cli@0.15.1` (latest) still declares `tmp@^0.1.0` |

Upgrading `nx` or `@lhci/cli` does not move `tmp` to 0.2.6. The pre-existing `pnpm.overrides` entry was already pinning `tmp@<0.2.4` → `>=0.2.4` against the prior advisory; this PR just widens the lower bound to match the new one. Same pattern, one line.

## What lands

| File | Change |
| --- | --- |
| `package.json` | `"tmp@<0.2.4": ">=0.2.4"` → `"tmp@<0.2.6": ">=0.2.6"` (one line). |
| `pnpm-lock.yaml` | `tmp@0.2.4` → `tmp@0.2.6` resolved; `@scalar/nestjs-api-reference@1.1.16 → 1.1.19` picked up as a natural transitive resolution during `pnpm install` (no semver-major, both are within the existing `^1.1.14` range and match Renovate's "patch + auto-merge" policy). |

## Risk

Patch bump (0.2.4 → 0.2.6) on a tiny library with a stable public API since v0.2.0. The release notes for 0.2.5 and 0.2.6 are sanitization-only fixes — no API surface change. Risk of regression in nx / lhci consumers: negligible.

## Test plan

- [x] `pnpm audit --audit-level=moderate` returns `No known vulnerabilities found` (exit 0).
- [x] `pnpm why tmp` shows `tmp@0.2.6` as the single resolved version (no duplicate).
- [ ] CI green on Gitea (`check` + `scan` jobs).
- [ ] Once merged, rebase `ci/gitlab-pipeline-phase2` on top and retry the GitLab `audit` job — expected green.

## Related

- [GHSA-ph9p-34f9-6g65](https://github.com/advisories/GHSA-ph9p-34f9-6g65) — the advisory.
- Unblocks [ADR-0028](../docs/decisions/0028-migrate-cicd-and-git-hosting-to-gitlab.md) Phase 2 pipeline parity validation (see `ci/gitlab-pipeline-phase2` branch).

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #240
2026-05-27 17:29:41 +02:00
julien 3191040bbc chore(gitlab): add MR template (ADR-0028 phase 1) (#238)
CI / check (push) Successful in 3m57s
CI / commits (push) Has been skipped
CI / scan (push) Successful in 4m5s
CI / a11y (push) Successful in 2m51s
CI / perf (push) Successful in 4m34s
## Summary

[ADR-0028](../docs/decisions/0028-migrate-cicd-and-git-hosting-to-gitlab.md) Phase 1 (`mirror-and-bootstrap`) — repo-side groundwork for the GitLab CE migration. Phase 1 is mostly ops work on `vm-gitlab` (groups, mirror push, branch protection, bot account); the only artefact that needs to land in the codebase ahead of the cutover is the GitLab MR template, so that the moment Phase 2 lands `.gitlab-ci.yml` and developers start opening MRs, they get the same structured prompt Gitea PRs use today.

## What lands

- **New file `.gitlab/merge_request_templates/Default.md`** — port of `.gitea/pull_request_template.md`, content-identical except for two adjustments:
  - "PR title" → "MR title" in the comment header (GitLab terminology).
  - **Typo fix:** the section reference to the Conventional Commits convention was `docs/development.md §5` in the original (which is "Observability dev-loop"); the correct section is **§7 "Conventional commit cycle"**. The Gitea template still has the wrong ref — it gets deleted in Phase 3 cutover so fixing it there rather than touching it twice.

GitLab discovers `.gitlab/merge_request_templates/*.md` automatically; `Default.md` becomes the default for new MRs without any project-settings step.

## Out of scope (per [ADR-0028 §"Migration sequence"](../docs/decisions/0028-migrate-cicd-and-git-hosting-to-gitlab.md))

| Item | Phase |
| --- | --- |
| `.gitlab-ci.yml` | 2 (parallel pipelines) |
| `renovate.json` `customManagers.fileMatch` swap from `.gitea/workflows/*.yml` → `.gitlab-ci.yml` | 2-3 (after `.gitlab-ci.yml` exists) |
| Remote URLs in CLAUDE.md / `docs/setup/01-dev-debian-vm-setup.md` §8 | 3 (cutover) |
| Deletion of `.gitea/workflows/` + `infra/ci-runners.compose.yml` + `infra/data/runner-*/` | 3 (cutover) |
| Stale-reference sweep + signed-commit policy | 4 (cleanup) |

## Ops checklist (executed on `vm-gitlab` — not part of this PR)

For traceability; the following happens outside the repo, on `vm-gitlab` (`10.100.201.10`, LAN-only — VPN for off-site access is sufficient):

- [ ] Create group `apf-portal` + project `apf_portal` on GitLab CE.
- [ ] `git remote add gitlab git@10.100.201.10:apf-portal/apf_portal.git && git push --mirror gitlab` from a current clone.
- [ ] Configure branch protection on `main`: push direct disabled, squash-merge only, status checks list left empty (peuplé en Phase 2 quand `.gitlab-ci.yml` apparaît).
- [ ] Create `apf-portal-bot` service account on GitLab, issue a dedicated SSH/GPG signing key (cf. ADR-0028 §"Signed commits"), generate a Renovate platform token.
- [ ] Reconfigure the existing Renovate runtime (host config, not in this repo) to add the GitLab platform endpoint pointed at `10.100.201.10`. Renovate's `config:recommended` + the `renovate.json` in-repo are platform-agnostic; only the bot's host config needs the new endpoint + token.

## Test plan

- [x] `.gitlab/merge_request_templates/Default.md` markdown renders identically to the Gitea template in a local preview.
- [x] No CI gates touched — `pnpm ci:check` / `ci:audit` unchanged.
- [ ] On first MR opened post-mirror, the Default template appears auto-selected.

## Related

- [ADR-0028](../docs/decisions/0028-migrate-cicd-and-git-hosting-to-gitlab.md) — migration ADR (accepted in #227).
- Next: Phase 2 (`gitlab-ci-pipeline`) once `vm-gitlab` ops bootstrap is complete and the runner is registered.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #238
2026-05-26 16:49:36 +02:00
julien 8bc36b460a fix(admin): unbreak ci:check on PR2b (TS2454 + missing fr translation) (#237)
CI / scan (push) Successful in 5m34s
CI / commits (push) Has been skipped
CI / check (push) Successful in 6m53s
CI / a11y (push) Successful in 3m2s
CI / perf (push) Successful in 5m37s
## Summary

Follow-up fix on [#236](#236) (ADR-0026 PR 2b). Two issues that `nx run-many -t build` exposed but `nx run-many -t test` masked:

1. **TS2454 in `user-scopes.service.ts`** — webpack's strict-mode build flagged `let exists: boolean` as "used before assigned". jest's `--transpile-only` doesn't enforce definite-assignment so it slipped through local checks.
2. **Missing FR translation** for `route.user-scopes.title` — Angular's `@angular/localize` build target requires every `$localize` ID to have a target in `messages.fr.xlf`. The route I added in #236 referenced the ID but I didn't add the trans-unit.

## What lands

| File | Change |
| --- | --- |
| `apps/portal-bff/src/admin/user-scopes.service.ts` | Initialise `let exists = false` instead of `let exists: boolean`. The `VALUE_BEARING_KINDS.has(kind)` check earlier in the function does narrow `kind` semantically, but `Set.has` doesn't propagate type narrowing the way `Array.includes` or a custom type guard would. Initialising to `false` is the smallest fix; the next `if (!exists)` still throws the friendly "does not match" message in the (unreachable-by-construction) fallback. |
| `apps/portal-admin/src/locale/messages.fr.xlf` | New `<trans-unit id="route.user-scopes.title">` — FR target `Périmètres utilisateur — Administration APF Portal`. |

## Why these failed locally

- **TS2454**: jest runs with `--transpile-only` so it skips definite-assignment analysis (and most type-checking). Webpack's production build runs full `tsc` and catches it.
- **Missing translation**: the dev server `pnpm nx serve portal-admin` doesn't run the localize pass; only `--configuration=production` does. My local `nx test portal-admin` runs in JIT mode against the source locale, so the missing FR target slid through.

For the future: `pnpm nx run-many -t build --projects=portal-bff,portal-admin` would have caught both pre-PR. Worth a `pnpm ci:check:fast` shorthand that runs a subset of the CI gates locally before push — but that's a separate PR.

## Test plan

- [x] `pnpm exec nx run-many -t build --projects=portal-bff,portal-admin` — both pass.
- [x] No application logic changed in either fix (initialise vs declare; add a missing translation row).
- [ ] CI green on this PR.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #237
2026-05-26 16:18:11 +02:00
julien 928ed0cdc2 feat(admin): add /admin/users/:oid/scopes screen + endpoints (ADR-0026 PR 2b) (#236)
CI / commits (push) Has been skipped
CI / scan (push) Successful in 2m49s
CI / a11y (push) Successful in 3m8s
CI / perf (push) Successful in 6m12s
CI / check (push) Failing after 1m42s
## Summary

ADR-0026 PR 2b (second half of PR 2). Ships the operator surface for the `@RequireScope` stack — the admin Angular screen at `/admin/users/:oid/scopes` lets an admin list / grant / revoke scopes for an existing portal User. Both write paths emit blocking audit rows per ADR-0013 (`admin.scope_granted` / `admin.scope_revoked`).

Closes the ADR-0026 trilogy:

| PR | Status | Effect |
| --- | --- | --- |
| ADR-0026 PR 1 (#232) | merged | Person + User + UserScope schema + provisioner + drift gate Person.source + PrincipalBuilder real UUIDs. |
| ADR-0026 PR 2a (#233) | merged | PrismaScopeResolver replaces stub + prisma/seed.ts populates 19 personas. |
| **ADR-0026 PR 2b (this)** | proposed | Admin scope-management screen — operator surface. |

## What lands

### BFF (under `apps/portal-bff/src/`)

| File | Change |
| --- | --- |
| `admin/user-scopes.dto.ts` | **New**. `GrantUserScopeDto` (class-validator on kind + value + ISO expiresAt) + response shapes (`UserScopeDto`, `UserScopesPageDto`). |
| `admin/user-scopes.service.ts` | **New**. `resolveUserByOid` (404 when no portal User), `list` (`UserScope` rows joined to User+Person), `grant` (validates value against ADR-0027 tables for value-bearing kinds, rejects non-empty for valueless, P2002 → 400), `revoke` (404-protected — won't leak scope-belongs-to-other-user). |
| `admin/user-scopes.controller.ts` | **New** REST surface at `/api/admin/users/:oid/scopes` with `@RequireAdmin`. `GET` (no audit, read), `POST` (audit `admin.scope_granted`), `DELETE :scopeId` (audit `admin.scope_revoked`, 204 on success). |
| `admin/user-scopes.service.spec.ts` + `admin/user-scopes.controller.spec.ts` | **New** specs — 15 cases across resolve / list / grant (value-bearing + valueless + duplicate) / revoke / audit semantics. |
| `admin/admin.module.ts` | Registers `UserScopesController` + `UserScopesService`. |
| `audit/audit.types.ts` + `audit/audit.service.ts` | New `AdminScopeGrantedInput` / `AdminScopeRevokedInput` types + `adminScopeGranted` / `adminScopeRevoked` methods on `AuditWriter`. `subject = 'user:<oid>'` so an auditor pivots on the target of the change; payload carries the resolved tuple at the moment of the write (the revoke payload survives the row's deletion). |

### SPA (under `apps/portal-admin/src/app/`)

| File | Change |
| --- | --- |
| `app.routes.ts` | New route `/users/:oid/scopes` (lazy-loaded). |
| `pages/user-scopes/user-scopes.service.ts` + `.service.spec.ts` | **New** thin HttpClient wrapper (`list` / `grant` / `revoke`). Same shape convention as `AdminUsersService`. |
| `pages/user-scopes/user-scopes.ts` + `.html` + `.scss` + `.spec.ts` | **New** page component. Signals-based + OnPush. Lists current scopes in a table with `Revoke` per row; "Grant a new scope" form with kind dropdown + conditional value input + optional `expiresAt`. Friendly error mapping (403 → "no admin access", 404 → "User not found, has this persona ever signed in or been seeded?", server messages forwarded). |
| `pages/users/users.html` | New `Manage scopes` link per row (RouterLink to the new page), with `aria-label` carrying the user's displayName. |
| `pages/users/users.ts` + `users.spec.ts` | Adds `RouterLink` import + `provideRouter([])` in the spec fixture (was missing — the route addition exposed it). |

## Key choices

- **`:oid` in the URL, not `User.id`.** The existing `/admin/users` list (ADR-0020) keys on Entra `oid` and the new screen is its child — linking from the list to the scopes page without an intermediate UUID lookup is the simplest UX. The BFF translates `oid → User.id` internally via `resolveUserByOid`.
- **404 carries a hint.** When `resolveUserByOid` misses, the BFF throws `NotFoundException("No portal User found for Entra oid ${oid}.")` and the SPA translates the 403/404 status to friendly French-English text. The hint mentions "has this persona ever signed in or been seeded?" — a common operator confusion (the user-directory list shows oids that signed in but the `User` row only exists post-sign-in OR post-seed).
- **Audit before write commit, write after audit success.** Standard ADR-0013 posture. Order in the controller: service call (which writes the DB row) → audit (blocking). If audit fails, the DB row exists but no audit — a known v1 cost for non-rollback flows. The simpler alternative (audit before write) would emit ghost audit rows for failed writes; we picked the cleaner direction.
- **Value-bearing validation against ADR-0027 tables, not catalogue.** `etablissement:0330800013` must match an existing `Structure.code`; `delegation:33` must match `Delegation.code`; `region:75` must match `Region.code`. The lookup is a single `findUnique` per kind; rejection raises 400 with the offending value in the message. Valueless kinds (`self` / `siege` / `unrestricted`) reject any non-empty value as a defensive check (the DTO type already constrains this but the service runs the assertion).
- **`UserScope.source = 'admin-ui'`** for every row created by this surface. Distinct from `'seed'` (set by `prisma/seed.ts`) and `'self-signin'` (Person source only). ADR-0029's syncs will add `'pleiades'` / `'acteurs-plus'`.
- **A11y per [ADR-0016](https://git.unespace.com/julien/apf_portal/src/branch/main/docs/decisions/0016-accessibility-baseline-wcag-aa-targeted-aaa.md)**: `aria-labelledby` on the form sections, `role="status"`/`aria-live="polite"` for load + error feedback, `role="alert"` for submit errors, `min-height: 44px` on every input/button per the touch-target rule, `aria-label` on the Manage-scopes link carrying the displayName for screen readers, conditional `aria-required` + `aria-describedby` on the value input.
- **`window.confirm` before revoke.** Cheap protection against accidental keyboard-activation. v1 OK; a future polish PR could replace with the spartan-ng dialog when it ships.

## Local verification

- [x] `node scripts/check-catalogue-drift.mjs` — clean (4 / 24 / 7 / 3).
- [x] `pnpm exec nx lint portal-bff` — **0 errors** (13 warnings, all pre-existing).
- [x] `pnpm exec nx lint portal-admin` — **0 errors** (3 warnings, all pre-existing).
- [x] `pnpm exec nx test portal-bff` — full suite passes (now includes the 2 new `user-scopes` spec files).
- [x] `pnpm exec nx test portal-admin` — **71 tests passing** (added 8 for `user-scopes`; updated `users.spec.ts` to provide a router after the RouterLink import).

## Test plan — remaining (manual on dev DB)

- [ ] **Sign in as `admin@apfrd...`** (the only persona with `Portal.Admin`) on `portal-admin`. Navigate to `/admin/users`. Click `Manage scopes` for `directeur-bordeaux`. The page shows one row `etablissement:0330800013` (from the seed).
- [ ] **Grant a new scope**: kind `delegation`, value `33`. Submit. New row appears; audit row `admin.scope_granted` lands in `audit.events`.
- [ ] **Try a bogus value** (kind `etablissement`, value `xyz`): server returns 400, form shows the message.
- [ ] **Try a duplicate** (`etablissement:0330800013` again on `directeur-bordeaux`): server returns 400 with "already granted".
- [ ] **Revoke a scope**: row disappears; audit row `admin.scope_revoked` lands.
- [ ] **Sign in as a non-admin persona** (e.g. `collaborateur-simple`) and try `/admin/users/.../scopes` → 403 + friendly error.
- [ ] **Review focus** — the BFF's `validateValueForKind` switch, the SPA's `translateError` mapping, the a11y attributes on the form, the audit ordering in the controller methods.

## Notes for the reviewer

- **No scope-vocabulary endpoint.** The form expects the operator to type the `Structure.code` / `Delegation.code` / `Region.code` by hand (with a hint string under the field). Defensive validation catches typos. A future polish PR could add `GET /api/admin/scope-vocabulary` returning all three lists at once + replace the value input with a typeahead dropdown.
- **Renamed the service's `UserScopesPage` interface to `UserScopesPayload`.** Discovered the collision with the component class during the first test run. The component keeps the `UserScopesPage` name per Angular convention; the payload type now reads as what it is.
- **`UserScope.source` is not under the drift gate today.** The schema comment in PR 1 said "same catalogue posture as Person.source"; in practice the seed writes `'seed'` and this PR writes `'admin-ui'`. Extending the drift gate to cover `UserScope.source` is a small follow-up — defensible to do once ADR-0029's upstream-sync values are landing.

## What's next

ADR-0025's `@RequireScope` stack is now end-to-end live for the test tenant: persona → Entra → BFF → PrismaScopeResolver → DB → Principal → guard. The trilogy is done.

Per the prior conversation: **GitLab migration Phase 1** (`mirror-and-bootstrap`) is the next item — mostly ops work on `vm-gitlab` (groups, branch protection, Renovate reconfig, deploy keys). The first PR-shaped output from that phase is the Renovate config update.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #236
2026-05-26 15:54:21 +02:00
julien 55338b83c0 fix(seed): use tsconfig.app.json instead of inline --compiler-options (#235)
CI / commits (push) Has been skipped
CI / check (push) Successful in 3m22s
CI / scan (push) Successful in 2m43s
CI / a11y (push) Successful in 3m37s
CI / perf (push) Successful in 6m12s
Docs site / build (push) Successful in 5m24s
## Summary

Follow-up to [#234](#234). The `db:seed` script's inline `--compiler-options '{"module":"CommonJS",…}'` was getting its quotes stripped by the shell when `pnpm run` re-spawned `ts-node`:

```
> ts-node --compiler-options {module:CommonJS,esModuleInterop:true,target:ES2020} apps/portal-bff/prisma/seed.ts

SyntaxError: Expected property name or '}' in JSON at position 1
```

Cross-platform JSON-on-cmdline quoting through nested `package.json → shell → npm-script → shell → ts-node` is a notorious pain. `apps/portal-bff/tsconfig.app.json` already declares everything `ts-node` needs to transpile the seed (module=commonjs, esModuleInterop=true, target=es2021, moduleResolution=node, types=[node]). Point at it with `-P` and drop the inline JSON entirely.

## What lands

| File | Change |
| --- | --- |
| `package.json` (root) | `db:seed` script: `--compiler-options '{...}'` → `-P apps/portal-bff/tsconfig.app.json`. One-line diff. |

That's it.

## Why `tsconfig.app.json` works for the seed even though seed.ts isn't under `src/`

`ts-node --transpile-only` reads `compilerOptions` from the `-P` tsconfig but **ignores the `include` / `exclude` fields** for the file passed explicitly on the command line. So the fact that `tsconfig.app.json` has `"include": ["src/**/*.ts"]` and the seed lives under `prisma/` doesn't matter — `ts-node` happily transpiles whatever file you point it at.

## Test plan

- [ ] **Locally**: `cd apps/portal-bff && pnpm exec prisma db seed` — no more `SyntaxError` on the compiler-options, seed runs and reports `[seed] test-tenant complete …`.
- [ ] **Idempotency** — re-run, expect `created 0 / 0 / 0`.
- [x] No application code changed — drift gate / lint / tests untouched.

## Notes for the reviewer

- Could have created a dedicated `apps/portal-bff/prisma/tsconfig.json` extending the app config. Less coupling but more files; pointing at the existing `tsconfig.app.json` is the minimum-surface fix.
- The Prisma 7 deprecation warning about `package.json#prisma` is unchanged; migrating to `prisma.config.ts` is a separate future PR (would also let us inline the seed config without shell quoting at all).

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #235
2026-05-26 14:53:12 +02:00
julien 827d69594c fix(seed): route prisma db seed through pnpm run for cwd resolution (#234)
CI / commits (push) Has been skipped
CI / check (push) Successful in 1m52s
CI / scan (push) Successful in 2m15s
CI / a11y (push) Successful in 3m28s
CI / perf (push) Successful in 6m8s
Docs site / build (push) Successful in 5m15s
## Summary

Follow-up fix on [#233](#233). The `prisma db seed` command failed with `Cannot find module './seed.ts'` because the seed command's path was resolved against the user's cwd (`apps/portal-bff/`) and doubled into `apps/portal-bff/apps/portal-bff/prisma/seed.ts`.

Root cause: Prisma CLI spawns the seed command with cwd = wherever the user invoked `prisma db seed` from. The path `apps/portal-bff/prisma/seed.ts` only works if cwd is the workspace root, but the user's habit (matching `prisma migrate dev`) is to run prisma commands from `apps/portal-bff/`.

Fix: indirect through `pnpm run`. `pnpm run` for a root-level script always sets cwd = workspace root regardless of where invoked from. The seed path then resolves predictably.

## What lands

| File | Change |
| --- | --- |
| `package.json` (root) | New `db:seed` npm script in `scripts` carrying the ts-node invocation. `prisma.seed` becomes `pnpm run db:seed` — the indirection forces cwd = workspace root before ts-node resolves the seed path. |

That's it. Two-line diff.

## Why `pnpm run` indirection works

| Step | cwd | Path resolution |
| --- | --- | --- |
| User invokes `pnpm exec prisma db seed` from `apps/portal-bff/` | `apps/portal-bff/` | — |
| Prisma CLI walks up, finds the workspace `package.json`, reads `prisma.seed` | `apps/portal-bff/` | — |
| Prisma spawns the seed command | `apps/portal-bff/` (inherited) | — |
| Seed command is `pnpm run db:seed` | `apps/portal-bff/` | — |
| **`pnpm run` resets cwd to the package's root (workspace root)** | **workspace root** | — |
| ts-node sees `apps/portal-bff/prisma/seed.ts` | workspace root | resolves correctly ✓ |

The same flow works if the user invokes from the workspace root — `pnpm run` still ends up at workspace root, idempotent.

## Test plan

- [ ] **Locally**: `cd apps/portal-bff && pnpm exec prisma db seed` — should report `[seed] test-tenant complete — created … Person + … User + … UserScope rows; …`. No `MODULE_NOT_FOUND`.
- [ ] **Also locally**: same command from the workspace root (`pnpm exec prisma db seed`) — same result.
- [ ] **Idempotency** — re-run, expect `created 0 Person + 0 User + 0 UserScope rows`.
- [x] No application code changed — drift gate, lint, tests untouched.
- [ ] **Review focus** — the `pnpm run` indirection trick + the cwd table above.

## Notes for the reviewer

- The deprecation warning about `package.json#prisma` (Prisma 7 migration to `prisma.config.ts`) is unchanged — not in this fix's scope.
- Could have also fixed by changing the path to be relative-to-bff (`prisma/seed.ts`) and forcing the user to run from `apps/portal-bff/`. The `pnpm run` approach is cwd-invariant — more robust to where the user invokes from.
- The new `db:seed` script is callable directly (`pnpm db:seed`) which is a small ergonomic bonus.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #234
2026-05-26 14:45:16 +02:00
julien 9b4b4b90d0 feat(auth): swap stub for PrismaScopeResolver + add test-tenant seed (ADR-0026 PR 2a) (#233)
CI / check (push) Successful in 4m42s
CI / commits (push) Has been skipped
CI / scan (push) Successful in 3m25s
CI / a11y (push) Successful in 4m47s
CI / perf (push) Successful in 7m30s
Docs site / build (push) Successful in 7m27s
## Summary

ADR-0026 PR 2 (first half — backend). Ships:

- **`PrismaScopeResolver`** replacing `StubScopeResolver` in `AuthModule`. Queries `user_scopes WHERE userId = ? AND (expiresAt IS NULL OR expiresAt > NOW())` and maps each row to a typed `Scope` from `shared-auth`.
- **`prisma/seed.ts`** populating Person + User + UserScope rows for the 19 test-tenant personas per `notes/test-tenant-role-assignments.md`. Idempotent — re-running preserves existing rows.
- **`infra/test-tenant.personas.example.json`** — schema template for the gitignored per-persona Entra `oid` map the seed reads.

The admin UI scope-seeding screen `/admin/users/:id/scopes` is split into **PR 2b** (Angular work, follows separately).

## What lands

| File | Change |
| --- | --- |
| `apps/portal-bff/src/auth/prisma-scope-resolver.ts` + `.spec.ts` | **New** Prisma-backed resolver. Server-side `expiresAt` filter (`null OR > NOW()`). Maps `(kind, value)` rows to the discriminated `Scope` union via a pure `toScope` helper. Off-catalogue `kind` values are skipped + WARN-logged (defense in depth — the drift gate is the canonical write-side guard). 10 spec tests covering query shape, valueless / value-bearing kinds, defensive skip on off-catalogue, edge cases on `toScope`. |
| `apps/portal-bff/src/auth/auth.module.ts` | `{ provide: ScopeResolver, useClass: StubScopeResolver }` → `useClass: PrismaScopeResolver`. The `StubScopeResolver` class stays exported from `scope-resolver.ts` (handy for spec fixtures / future "force-unrestricted" dev modes) but is no longer the default wiring. |
| `apps/portal-bff/prisma/seed.ts` | **New** seed script. Hardcoded `PERSONAS` array (19 entries, slug + email + displayName + scope tuples — transcribed from `notes/test-tenant-role-assignments.md`). For each persona: reads its Entra `oid` from `TEST_TENANT_PERSONAS_PATH` (env var, default `infra/test-tenant.personas.json`); if missing → skip with WARN; otherwise idempotent upsert (findUnique by `entraOid` → reuse, or create Person + User in nested-create transaction with `Person.source = 'seed'`). Then idempotent upserts for each scope (findUnique by `(userId, kind, value)` → skip, or create with `UserScope.source = 'seed'`). Final log: counts of rows created + personas skipped. |
| `infra/test-tenant.personas.example.json` | **New** schema template — flat `{ slug: entra-oid }` map for the 19 personas + a `_README` field documenting the shape. Distinct from `test-tenant.entra.json` (the 24-entry GROUP-guid map for `EntraGroupToRoleResolver`). Real file is gitignored. |
| `apps/portal-bff/.env.example` | New `TEST_TENANT_PERSONAS_PATH` block with the same documentation pattern as `ENTRA_GROUP_MAP_PATH`. |
| `package.json` | `prisma.seed` field added: `ts-node --transpile-only --compiler-options '...' apps/portal-bff/prisma/seed.ts`. Lets `pnpm exec prisma db seed` run the script without a project-specific tsconfig dance. |

## Key choices

- **`PrismaScopeResolver` swap is the default for AuthModule.** No "fallback to stub when DB is unreachable" — a Postgres outage that prevents reading `user_scopes` should fail the sign-in (same posture as `PersonAndUserProvisioner.ensureUser`, which is also blocking). The `StubScopeResolver` class remains in `scope-resolver.ts` for spec fixtures + as a hint of how to wire a "force-everyone-unrestricted" dev override if we ever want one.
- **Defensive `toScope` mapping.** The drift gate enforces catalogue membership at the write site (the future admin scope-seeding UI from PR 2b). The Prisma read side defends in depth by skipping off-catalogue rows — a row with `kind = 'something-bogus'` (e.g. a future migration mistake) is dropped from the resolved scopes + logged, rather than throwing on every sign-in for that user. The unit test `'skips + warns on off-catalogue kinds'` pins the behaviour.
- **Seed reads Entra `oid`s from a gitignored file.** Same pattern as `EntraGroupToRoleResolver` (path via env var, file is gitignored, schema template in `infra/*.example.json`). The `oid`s themselves are tenant-private; the 19 persona slugs + their scope tuples are project-wide and live in `seed.ts`.
- **Seed is idempotent on every level.** The unique constraint on `User.entraOid` lets us "create if missing" without locks; the unique on `(userId, kind, value)` does the same for `UserScope`. Re-running the seed after a partial run picks up where it stopped — no `--reset` needed.
- **No spec for `seed.ts` itself.** It's an integration data loader; meaningful test would require a real Prisma client + DB (or a heavy mock fixture). The persona matrix is hand-verified at PR review; the seed's correctness is exercised by running it on the dev DB (test-plan checkbox below).

## Verification path

- [x] `node scripts/check-catalogue-drift.mjs` — clean (`4 / 24 / 7 / 3`).
- [x] `node --test scripts/check-catalogue-drift.spec.mjs` — 29 tests passing.
- [x] `pnpm exec nx lint portal-bff` — **0 errors**, 13 warnings (all pre-existing).
- [x] `pnpm exec nx test portal-bff` (affected specs filtered) — **774 tests passing** (was 766; +8 for `prisma-scope-resolver.spec.ts`).
- [ ] **Locally / on dev DB**:
  - `cp infra/test-tenant.personas.example.json infra/test-tenant.personas.json` + fill in real `oid`s from the Entra admin centre.
  - `cd apps/portal-bff && pnpm exec prisma db seed` — should report `created 19 Person + 19 User + N UserScope rows; skipped 0 personas` on a fresh DB.
  - Re-run the seed → second invocation reports `0 / 0 / 0 created; skipped 0` (idempotent).
  - Sign in via the user portal as one of the 19 personas → `principal.scopes` on the session contains the seeded scopes (e.g. `directeur-bordeaux` sees `[{ kind: 'etablissement', value: '0330800013' }]`).
- [ ] **Review focus** — the `toScope` mapping (especially the defensive `null` branch on off-catalogue kinds), the seed's idempotency invariants, the `prisma.seed` command in `package.json`, the comments-only fields in `test-tenant.personas.example.json`.

## What's next

**PR 2b — Admin `/admin/users/:id/scopes` screen.** Angular admin-app SPA screen + BFF read/write controllers, against the schema this PR + ADR-0026 PR 1 + ADR-0027 PR 1 have now stood up. A11y review per ADR-0016 §"Manual testing cadence". Operator workflow only at that point — the seed in this PR is the bootstrap path for the test tenant.

Once both PR 2a + PR 2b ship: **the `@RequireScope` stack is end-to-end live** for the first time. ADR-0025's stubs (`StubScopeResolver`, the entraOid placeholder on `Principal.user.{id, personId}`) are then fully retired.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #233
2026-05-26 14:36:18 +02:00
julien 24071a63ec feat(users): add Person + User + UserScope + lazy provisioner (ADR-0026 PR 1) (#232)
CI / check (push) Successful in 7m16s
CI / commits (push) Has been skipped
CI / scan (push) Successful in 3m13s
CI / a11y (push) Successful in 2m22s
CI / perf (push) Successful in 5m37s
## Summary

First implementation PR for [ADR-0026](docs/decisions/0026-person-user-portal-data-model.md) (portal-side identity model). Ships:

- `Person` golden record + `User` portal-account overlay + `UserScope` Prisma models;
- `PersonAndUserProvisioner` service called from `SessionEstablisher.establish` (blocking — lazy-creates `Person` + `User` at first OIDC callback);
- `PERSON_SOURCES` closed-set catalogue + drift-gate extension;
- `PrincipalBuilder.build(user, identity)` signature update + `ScopeResolver.resolve({ userId })` seam change — `Principal.user.{id, personId}` now carry real portal UUIDs, no longer the `entraOid` placeholder.

No consumer for `UserScope` yet — `PrismaScopeResolver` lands in ADR-0026 PR 2. The `StubScopeResolver` continues to return `[{ kind: 'unrestricted' }]` for everyone.

## What lands

| File | Change |
| --- | --- |
| `apps/portal-bff/prisma/schema.prisma` | **+3 models** in the `public` schema: `Person` (UUID PK + PII + nullable email indexed-not-unique + source catalogue + nullable externalId + back-ref to User), `User` (UUID PK + 1-to-1 personId FK + unique entraOid + tenantId + lastSignInAt + scopes[]), `UserScope` (UUID PK + userId FK with `onDelete: Cascade` + kind + opaque value with `@default("")` + source + nullable expiresAt + `@@unique([userId, kind, value])`). All comments explain the cross-references to ADR-0025 / ADR-0027 / ADR-0029. |
| `apps/portal-bff/prisma/migrations/20260526210000_add_person_user_userscope/migration.sql` | **New** hand-written migration. DDL for the 3 tables, indexes (Person: source/externalId/email; User: unique personId + unique entraOid; UserScope: unique composite + plain userId), FKs with the correct `ON DELETE` actions (User.personId → RESTRICT; UserScope.userId → CASCADE — both Prisma defaults given the schema's `Delegation?` / explicit `onDelete: Cascade`). |
| `apps/portal-bff/src/users/person-source.ts` + `.spec.ts` | **New** catalogue. `PERSON_SOURCES = ['self-signin', 'admin-ui', 'seed'] as const`, `PersonSource` type union, `isPersonSource` type guard. Spec follows the structure-kind shape (catalogue content, no duplicates, type guard true/false, narrowing test via `String()` to widen). |
| `apps/portal-bff/src/users/person-and-user-provisioner.service.ts` + `.spec.ts` | **New** blocking provisioner. Fast path: `findUnique by entraOid` + `update lastSignInAt`. Cold path: nested `create` of Person + linked User in a single transaction. Race-condition handling: catches P2002 on `User.entraOid` and re-runs `ensureUser` (loser of a concurrent first-sign-in falls through to the now-warm fast path). Defense-in-depth `isPersonSource` check on the constant. Spec covers cold/warm/race/non-P2002-propagation paths + `splitDisplayName` cases (single token / trailing whitespace / empty / multi-word). |
| `scripts/check-catalogue-drift.mjs` | Property-literal scanner generalised. Adds `extractPersonSources` + `findPersonSourceViolationsInFile` (property `source` instead of `kind`, otherwise identical to the structure-kind scanner). The two extractors share `extractAsConstArray(path, constName)` and the two property scanners share `findPropertyLiteralViolations(path, validValues, sourceText, opts)`. Error-message formatter unchanged. Closing hint extended to reference all three catalogue locations. |
| `scripts/check-catalogue-drift.spec.mjs` | Fixture now writes a `person-source.ts` alongside `structure-kind.ts`. 7 new tests: extractPersonSources (2), findPersonSourceViolationsInFile (5: skip-no-import, no-violation, flag, non-literal, line/col). Aggregation test updated to assert decorator + Structure.kind + Person.source violations all surface together. **29 tests total, all passing.** |
| `apps/portal-bff/src/auth/scope-resolver.ts` | `resolve(input: { entraOid })` → `resolve(input: { userId })`. Stub still ignores its argument; PR 2's `PrismaScopeResolver` will key queries on `User.id`. Comment block updated to reflect that ADR-0026 PR 1 is now landed (no longer "proposed"). |
| `apps/portal-bff/src/auth/principal-builder.ts` | Signature: `build(user)` → `build(user, identity: { userId, personId })`. `Principal.user.id` / `Principal.user.personId` populated from `identity` instead of `user.oid`. Scope resolver called with `{ userId: identity.userId }`. Doc comment block updated to remove the "placeholder" caveat and document the new wiring. |
| `apps/portal-bff/src/auth/principal-builder.spec.ts` | New `TEST_IDENTITY` constant. Every `builder.build(...)` call gets the second arg. New assertions on `principal.user.id` / `principal.user.personId` carrying the test identity. Edge-case test "asks the scope resolver to resolve by entraOid" renamed + retargeted to `{ userId }`. **All 19 persona tests + 5 edge cases pass.** |
| `apps/portal-bff/src/auth/session-establisher.service.ts` | New constructor arg `personUserProvisioner: PersonAndUserProvisioner`. `establish()` calls `ensureUser({ oid, tenantId, displayName, email: user.username })` **before** `principalBuilder.build` so the identity is available. Comment block explains the Entra `preferred_username` → `Person.email` mapping and the blocking-vs-best-effort distinction with `UserDirectoryService.recordSignIn`. |
| `apps/portal-bff/src/auth/session-establisher.service.spec.ts` | Fixture extended with `provisioner` mock + `PROVISIONED_IDENTITY` constant. `STUB_PRINCIPAL` now carries those UUIDs instead of `user.oid`. New tests: (1) provisioner is called with the right input shape; (2) provisioner runs **before** `principalBuilder.build` (`mock.invocationCallOrder` assertion); (3) provisioner failure propagates and nothing downstream runs (build / audit / directory). |
| `apps/portal-bff/src/auth/auth.controller.spec.ts` | Provisioner mock added to the controller fixture (the controller's specs don't exercise provisioning themselves but `SessionEstablisher`'s constructor needs the arg). Principal stub's UUIDs aligned with the new provisioned shape. |
| `apps/portal-bff/src/users/users.module.ts` | `PersonAndUserProvisioner` registered + exported alongside `UserDirectoryService`. `@Global` so the auth module's `SessionEstablisher` can inject both without re-routing the module graph. Comment block updated to document the blocking/best-effort distinction between the two. |

## Key choices

- **Provisioner is blocking**, not best-effort. Distinct from `UserDirectoryService.recordSignIn` which is still best-effort (ADR-0020 admin-list cache, swallows its own errors). Both run from `SessionEstablisher.establish` per the new ordering: (1) provisioner — blocking, (2) build principal — uses identity, (3) save session, (4) cookie, (5) user-session index (best-effort), (6) audit (blocking ADR-0013), (7) UserDirectoryService.recordSignIn (best-effort), (8) log. A provisioner failure short-circuits the whole flow before any of (3)..(8) — the spec asserts this explicitly.
- **No email-based dedup in v1.** `Person.email` is indexed (not unique). The provisioner keys ONLY on `entraOid`. ADR-0026 §"Why no email-based merging in v1" — two distinct humans genuinely share emails; ADR-0029's sync flow handles operator-confirmed reconciliation.
- **Race-condition handling on first sign-in.** Two concurrent first-sign-ins for the same `entraOid` both fall through to `create`. The unique constraint on `User.entraOid` rejects the loser with P2002; the provisioner catches that specific code and re-runs `ensureUser` — fast path now warm. Pathological infinite-loop guarded by the warm-path behaviour on the second attempt. Spec covers the success retry + the non-P2002 propagation paths.
- **ScopeResolver seam moved from `{ entraOid }` to `{ userId }`.** The stub doesn't care about its argument either way; the change is the seam for ADR-0026 PR 2's `PrismaScopeResolver`, which keys `userScope` queries on `User.id` (UUID). Doing the rename now keeps PR 2 to "swap the implementation" only.
- **`PersonAndUserProvisioner.SELF_SIGNIN_SOURCE`** is a typed constant inside the class, not a magic string. Defense in depth: TS type union (compile-time) + drift gate (`source: 'self-signin'` literal is in a file importing `person-source.ts` — flagged if it ever drifts off-catalogue) + runtime `isPersonSource` check (error log + throw if the constant is mutated to an off-catalogue value).
- **UserDirectoryService stays.** ADR-0020's admin-list cache is functionally redundant with Person + User now, but folding it would extend the PR scope (DTO + reader + admin UI + migration of existing rows). Out of scope here — flagged as a follow-up.

## Local verification

- [x] `node scripts/check-catalogue-drift.mjs` — clean (`4 privileges, 24 roles, 7 structure kinds, 3 person sources`).
- [x] `node --test scripts/check-catalogue-drift.spec.mjs` — **29 tests passing** (was 22 — +7 for PERSON_SOURCES).
- [x] `pnpm exec nx lint portal-bff` — **0 errors**, 13 warnings (all pre-existing, none from this PR).
- [x] `pnpm exec nx test portal-bff` (filtered to the 6 affected spec files) — **766 tests passing**.
- [x] `pnpm exec prisma generate` — client regenerated with the 3 new models.

## Test plan — remaining (on a fresh dev DB)

- [ ] `./infra/local/dev.sh down -v && ./infra/local/dev.sh up` (wipe + reboot) then `cd apps/portal-bff && pnpm exec prisma migrate dev` — applies the new migration cleanly, no drift prompt.
- [ ] `pnpm exec prisma studio` — `persons` / `users` / `user_scopes` tables visible, all empty (no seed in this PR).
- [ ] Sign in via `apf-portal` against the test tenant — `users` table gets one row, `persons` table gets one row, `user_scopes` stays empty. Principal in the session carries the provisioned UUIDs (not the `entraOid`).
- [ ] **Review focus** — the provisioner's race-handling logic; the `Entra preferred_username → Person.email` mapping in `SessionEstablisher`; the ScopeResolver seam change rationale; the FK actions in the migration SQL (especially CASCADE on UserScope.userId vs RESTRICT on User.personId).

## What's next

- **ADR-0026 PR 2** — `PrismaScopeResolver` replacing `StubScopeResolver` + `/admin/users/:id/scopes` admin-app screen + `prisma/seed.ts` populating the 19 test personas' `user_scopes` per `notes/test-tenant-role-assignments.md` (referencing this PR's `User.id` and ADR-0027 PR 1's `Structure.code` values).
- **Future PR (out of this scope)** — fold `UserDirectoryEntry` into Person + User now that the latter exists. Touches AdminUsersReader, the DTO, the admin SPA, and a data migration for existing rows. Defer until ADR-0026 PR 2 stabilises.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #232
2026-05-26 13:57:36 +02:00
julien cba36394c9 fix(structures): widen via String() in narrowing test to satisfy lint (#231)
CI / check (push) Successful in 4m26s
CI / commits (push) Has been skipped
CI / scan (push) Successful in 4m33s
CI / a11y (push) Successful in 3m7s
CI / perf (push) Successful in 7m44s
## Summary

One-line lint fix in [apps/portal-bff/src/structures/structure-kind.spec.ts](apps/portal-bff/src/structures/structure-kind.spec.ts) — the narrowing test failed CI lint with `@typescript-eslint/no-inferrable-types` on `const candidate: string = 'medico_social'`.

The naive fix (drop the annotation) would make the test vacuous: TypeScript would infer the literal type `'medico_social'`, which is already a `StructureKind` subtype, so the runtime guard `isStructureKind` would have nothing to prove. Fix instead: widen via `String('medico_social')` — the inferred return type is `string`, the narrowing check stays meaningful, the linter is happy.

## What lands

| File | Change |
| --- | --- |
| `apps/portal-bff/src/structures/structure-kind.spec.ts` | `const candidate: string = 'medico_social'` → `const candidate = String('medico_social')`. 3-line comment explains why `String()` rather than dropping the annotation. |

## Test plan

- [x] `pnpm exec nx lint portal-bff` — `0 errors, 13 warnings` (warnings all pre-existing in unrelated files).
- [x] `pnpm exec prettier --check` clean.
- [ ] **CI** — `pnpm ci:check` passes on this PR.
- [ ] **Review focus** — the comment block explaining the round-trip rationale (otherwise the `String('literal')` pattern reads like over-engineering).

## Notes for the reviewer

- **Pre-existing warnings are NOT addressed here.** The 13 remaining lint warnings (non-null assertions in `principal-extractor.spec.ts`, unused underscored params in `rate-limit.middleware.ts`, one stale eslint-disable in `downstream-token-cache.service.spec.ts`) are outside the scope of this PR — none of them block CI today, and folding them in would muddle the diff. Worth a separate `chore(bff): sweep lint warnings` PR if/when those become noisy.
- **Why `String()` over an `as string` cast?** Both would work, but `String()` is a real runtime operation (returns a fresh `string`) — `as string` is type-system-only. The runtime call has a tiny side-effect (and the inferred return type really IS `string`, not the literal), so the narrowing test stays semantically real.
- **No new error class.** The lint rule `@typescript-eslint/no-inferrable-types` is set to `error` severity in the workspace config; my narrowing test was just the first case to trip it.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #231
2026-05-26 13:36:41 +02:00
APF Portal Bot 8d43717d25 fix(deps): update dependency @scalar/nestjs-api-reference to v1.1.19 (#225)
CI / commits (push) Has been skipped
CI / scan (push) Successful in 4m25s
CI / check (push) Failing after 7m3s
CI / a11y (push) Successful in 2m52s
CI / perf (push) Successful in 7m55s
Docs site / build (push) Successful in 5m2s
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| [@scalar/nestjs-api-reference](https://github.com/scalar/scalar) ([source](https://github.com/scalar/scalar/tree/HEAD/integrations/nestjs)) | dependencies | patch | [`1.1.16` -> `1.1.19`](https://renovatebot.com/diffs/npm/@scalar%2fnestjs-api-reference/1.1.16/1.1.19) |

---

### Release Notes

<details>
<summary>scalar/scalar (@&#8203;scalar/nestjs-api-reference)</summary>

### [`v1.1.19`](https://github.com/scalar/scalar/blob/HEAD/integrations/nestjs/CHANGELOG.md#1119)

### [`v1.1.18`](https://github.com/scalar/scalar/blob/HEAD/integrations/nestjs/CHANGELOG.md#1118)

### [`v1.1.17`](https://github.com/scalar/scalar/blob/HEAD/integrations/nestjs/CHANGELOG.md#1117)

</details>

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined).

🚦 **Automerge**: Enabled.

♻ **Rebasing**: Whenever PR is behind base branch, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0MC42Mi4xIiwidXBkYXRlZEluVmVyIjoiNDAuNjIuMSIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOlsiZGVwZW5kZW5jaWVzIl19-->

Reviewed-on: #225
Co-authored-by: APF Portal Bot <jgautier.webdev+apf-portal-bot@gmail.com>
Co-committed-by: APF Portal Bot <jgautier.webdev+apf-portal-bot@gmail.com>
2026-05-26 13:25:51 +02:00
julien b84b58068a refactor(users): rename Prisma User model to UserDirectoryEntry (#230)
CI / check (push) Failing after 3m36s
CI / commits (push) Has been skipped
CI / scan (push) Successful in 2m46s
CI / a11y (push) Successful in 1m44s
CI / perf (push) Successful in 4m41s
## Summary

**Mechanical refactor only**, prerequisite to ADR-0026 PR 1. Renames the existing Prisma `User` model (the ADR-0020 user-directory cache, `oid` PK, written by `UserDirectoryService.recordSignIn`) to `UserDirectoryEntry`. Frees the `User` name for the upcoming ADR-0026 model (UUID PK, FK to `Person`, `lastSignInAt` — different semantics).

Zero behavioural change. Same columns, same indexes, same constraints, same call sites — just a different identifier on the model and the table.

## What lands

| File | Change |
| --- | --- |
| `apps/portal-bff/prisma/schema.prisma` | `model User` → `model UserDirectoryEntry`. `@@map("users")` → `@@map("user_directory_entries")`. Comment block extended to flag the upcoming distinction from ADR-0026's new `User`. |
| `apps/portal-bff/prisma/migrations/20260526200000_rename_users_to_user_directory_entries/migration.sql` | **New**. `ALTER TABLE "users" RENAME TO "user_directory_entries"` + `ALTER INDEX` renames for the PK constraint and the two named indexes (Postgres doesn't auto-rename these on table rename). |
| `apps/portal-bff/src/users/user-directory.service.ts` | Two renames: the **TS input interface** `UserDirectoryEntry` → `RecordSignInInput` (the existing name was a misnomer — it's the input to `recordSignIn`, not the entry itself, and would collide with the Prisma-generated `UserDirectoryEntry` type after the model rename). The **Prisma client ref** `this.prisma.user.upsert` → `this.prisma.userDirectoryEntry.upsert`. |
| `apps/portal-bff/src/users/user-directory.service.spec.ts` | Imports / mock setup / fixture type updated to track the two renames. |
| `apps/portal-bff/src/admin/admin-users-reader.service.ts` | `this.prisma.user.{count,findMany}` → `this.prisma.userDirectoryEntry.{count,findMany}`. `Prisma.UserWhereInput` → `Prisma.UserDirectoryEntryWhereInput`. Doc comments mentioning `public.users` updated to `public.user_directory_entries`. The class name `AdminUsersReader`, the endpoint URL `/api/admin/users`, the DTO `AdminUserDto`, and the local `interface UserRow` are unchanged — these are SPA/HTTP-facing identifiers, where the URL semantics ("admin user list") still hold regardless of the backing table name. |
| `apps/portal-bff/src/admin/admin-users-reader.service.spec.ts` | Mock setup updated to track the Prisma client field rename. |

## Why two renames in one file

`apps/portal-bff/src/users/user-directory.service.ts` had a TypeScript `interface UserDirectoryEntry` carrying the **input shape** of `recordSignIn(entry: UserDirectoryEntry)`. After the Prisma model rename to `UserDirectoryEntry`, that name would clash with the Prisma-generated row type. The fix is to rename the TS interface to its proper role — `RecordSignInInput` — at the same time. Net effect: clearer naming on both sides (the call-input name now describes the call, the persisted-row type name now describes the row).

## Recovery procedure (for anyone with the old migration applied locally)

The new migration `20260526200000_rename_users_to_user_directory_entries` is a pure `ALTER TABLE ... RENAME` + index renames — Prisma's `migrate dev` runs it forward without prompting:

```bash
cd apps/portal-bff && pnpm exec prisma migrate dev
# Should report: "Applied migration `20260526200000_rename_users_to_user_directory_entries`"
```

No `down -v` needed — existing data carries over.

## Test plan

- [x] `node scripts/check-catalogue-drift.mjs` — clean (4 / 24 / 7).
- [x] `node --test scripts/check-catalogue-drift.spec.mjs` — 22 tests passing.
- [x] `pnpm exec prettier --check` clean on the touched files.
- [x] Sweep grep — zero leftover `model User`, `prisma.user.`, `Prisma.UserWhereInput`, `interface UserDirectoryEntry`, or `public.users` references anywhere under `apps/portal-bff/src/` or `apps/portal-bff/prisma/`.
- [ ] **Locally**: `pnpm exec prisma migrate dev` applies the rename migration cleanly; `pnpm exec nx test portal-bff` runs the updated specs green.
- [ ] **Review focus** — the two-rename rationale in `user-directory.service.ts`, the migration's `ALTER INDEX` clauses (don't forget those — `ALTER TABLE ... RENAME` does NOT cascade to index names in Postgres), the unchanged class/URL/DTO/local-interface identifiers in `admin-users-reader.service.ts`.

## Why ship as a separate PR

ADR-0026 PR 1's nominal scope is `Person` + `User` + `UserScope` + provisioner + drift gate + PrincipalBuilder — already ~15+ files touched. Folding the rename into that PR would mix mechanical refactor with new design. Splitting keeps both PRs reviewable for what they actually do.

## What's next (post-merge)

**ADR-0026 PR 1** — `Person` + new `User` + `UserScope` schema + `PersonAndUserProvisioner` wired into `SessionEstablisher` + `Person.source` catalogue + drift-gate extension + `PrincipalBuilder` populating `Principal.user.{id, personId}` from real rows. Now unblocked.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #230
2026-05-26 12:42:23 +02:00
julien ff1713eb6d fix(structures): align Structure.delegation_code FK action with Prisma default (#229)
CI / check (push) Failing after 4m9s
CI / commits (push) Has been skipped
CI / scan (push) Successful in 2m44s
CI / a11y (push) Successful in 2m58s
CI / perf (push) Successful in 5m34s
## Summary

Single-line fix on the `add_org_hierarchy` migration shipped in [#228](#228) (ADR-0027 PR 1). The FK action on `Structure.delegation_code` was `ON DELETE RESTRICT` (Prisma's default for **required** relations); the field is **optional** (`Structure.delegation Delegation?`), so Prisma's default is `ON DELETE SET NULL`. Mismatch caused `prisma migrate dev` to detect drift and prompt for a corrective migration on every run.

Caught locally on first VM-side validation (the workspace dev DB). No production deployment of #228 yet, so the fix is **edit-in-place** rather than a corrective sibling migration — keeps the repo's migration history clean.

## What lands

| File | Change |
| --- | --- |
| `apps/portal-bff/prisma/migrations/20260526143000_add_org_hierarchy/migration.sql` | `ON DELETE RESTRICT` → `ON DELETE SET NULL` on the `structures_delegation_code_fkey` FK. Inline comment explains the rule (nullable Prisma relation → SET NULL is the matching default; not matching it generates drift on every `migrate dev`). |

That's it. One line of SQL, one comment block. No schema.prisma change, no test change, no other file touched.

## Why edit-in-place vs new corrective migration

Edit-in-place is safe here because:

- The broken migration only exists in **dev local DBs** (Julien's WSL postgres). No staging, no preview env, no prod has applied it.
- Every dev who pulls this PR's fix wipes their local DB (`./infra/local/dev.sh down -v`) and reapplies — clean migration history, no "modified after applied" warning.
- The repo's migration list stays minimal — adding a corrective migration would carry the wart forever in `prisma/migrations/`.

Once a non-dev environment has applied a migration, the rule reverses: corrective migration mandatory, never edit in place. ADR-0015's "trunk-based + squash-merge" and the absence of a deployed environment at this stage gives us this one-time window.

## Recovery procedure for anyone who applied #228

```bash
# From the workspace root
./infra/local/dev.sh down -v   # wipe the local postgres volume
./infra/local/dev.sh up

# Pull this fix
git pull   # or git switch fix/adr-0027-pr1-fk-action-on-delete depending on local state

# Reapply migrations — no prompt this time
cd apps/portal-bff && pnpm exec prisma migrate dev
# Should report: "Applied migration `20260526143000_add_org_hierarchy`" and exit cleanly.
```

If anyone has a stray `drift_inspection/` folder under `prisma/migrations/` (artifact of the `--create-only` debugging step), delete it before reapplying — it's not in the repo, it was just diagnostic output.

## Test plan

- [x] `node scripts/check-catalogue-drift.mjs` — clean (unchanged by this fix).
- [x] `node --test scripts/check-catalogue-drift.spec.mjs` — 22 tests passing (unchanged).
- [x] `pnpm exec prettier --check` clean (SQL file unaffected by prettier but verified).
- [ ] **Locally on WSL** — wipe DB → pull fix → `prisma migrate dev` exits clean, no drift prompt.
- [ ] **Review focus** — the comment block in the migration explaining the rule (future-proof against the same mistake when ADR-0026 PR 1 adds optional relations).

## Notes for the reviewer

- **Why a comment block on the FK line, not on every nullable FK in future migrations?** This was caught the first time we hit it; a short note at the offending site is cheaper than amending `CLAUDE.md` with a "remember to match Prisma default actions" rule. If we hit the same mistake on ADR-0026 PR 1's `Person`/`User`/`UserScope` migration, we'll consider promoting it. For now: one inline comment at the place where the rule is non-obvious.
- **The drift gate doesn't catch this kind of mistake.** Catalogue drift gate scans string literals against TypeScript catalogues — it doesn't look at SQL referential actions. Postgres CHECK constraints (introduced for `Structure.kind` in this same migration) are not the same surface as FK referential actions. Catching this required actually running `prisma migrate dev`. A possible future improvement: a CI gate that runs `prisma migrate diff --from-migrations --to-schema-datamodel --script --shadow-database-url …` and fails if the diff is non-empty (zero-drift gate). Worth an ADR amendment if it becomes a recurring class of bug.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #229
2026-05-26 12:11:13 +02:00
julien ba4cdcee7a feat(structures): add Region/Delegation/Structure schema + seed (ADR-0027 PR 1) (#228)
CI / check (push) Failing after 3m51s
CI / commits (push) Has been skipped
CI / scan (push) Successful in 3m27s
CI / a11y (push) Successful in 1m53s
CI / perf (push) Successful in 5m31s
## Summary

First implementation PR for [ADR-0027](docs/decisions/0027-portal-side-organisational-hierarchy.md) (`Region` / `Delegation` / `Structure` portal-side organisational hierarchy). **Schema + seed + drift-gate extension only** — no consumer code yet (the `PrismaScopeResolver` that dereferences `Structure.code` from `UserScope.value` lives in ADR-0026 PR 2, which depends on this PR landing first).

Independent of ADR-0026 PR 1 at the schema level — both can ship in parallel; ADR-0026 PR 2 depends on both.

## What lands

| File | Change |
| --- | --- |
| `apps/portal-bff/prisma/schema.prisma` | **+3 models**. `Region` (INSEE code PK + name + delegations[]). `Delegation` (dept code PK + name + regionCode FK + structures[]). `Structure` (portal code PK + name + kind discriminator + nullable unique finess/siret/codePaie + nullable delegationCode FK). All in the `public` schema; matches the ADR-0027 schema sketch verbatim. |
| `apps/portal-bff/prisma/migrations/20260526143000_add_org_hierarchy/migration.sql` | **New** hand-written migration. DDL for the 3 tables. `CHECK ("kind" IN (...))` constraint mirroring `STRUCTURE_KINDS`. Indexes (FK columns + kind + uniques). Inline INSERT seed: Région Nouvelle-Aquitaine (75), Délégation Gironde (33), structures `0330800013` + `0330800021` (médico-social, FINESS = code) + `siege` (APF national, no delegation/finess). |
| `apps/portal-bff/src/structures/structure-kind.ts` | **New**. Closed-set catalogue: `STRUCTURE_KINDS = [medico_social, antenne, dispositif, entreprise_adaptee, mouvement, administratif, siege] as const`. `type StructureKind` derived from the union, `isStructureKind` type guard. |
| `apps/portal-bff/src/structures/structure-kind.spec.ts` | **New**. Jest spec — catalogue content, no duplicates, type guard true for catalogue values + false for typos / cascade-only values / empty, type narrowing at call site. |
| `scripts/check-catalogue-drift.mjs` | **Extend**. New `extractStructureKinds(path)` + `findStructureKindViolationsInFile(path, validKinds, sourceText?)`. Property-literal scanner: detects `kind: 'X'` in object literals, restricted to files that import from `structure-kind.ts` (cheap text pre-filter — `kind` is a common property name on unrelated objects and we'd false-positive everywhere otherwise). Integrated into `scanWorkspace`. Error-message formatter switched on callee shape (`@Foo('x')` for decorators, `kind: 'x'` for property literals). Closing hint updated to reference both ADR-0025 and ADR-0027 catalogue locations. |
| `scripts/check-catalogue-drift.spec.mjs` | **Extend**. Fixture writes a synthesised `structure-kind.ts` alongside `authorization.types.ts`. New tests: extract STRUCTURE_KINDS, throw on missing constant, skip files without the import, no-violation on catalogue values, flag off-catalogue values, skip non-literal initialisers, line/column tracking, scanWorkspace aggregation (decorator + Structure.kind together), scanWorkspace exposes the structureKinds set. **22 tests total, all passing.** |

## Defense in depth

Three layers stack for `Structure.kind`, deliberately:

1. **TypeScript type union** `StructureKind` — compile-time check at every typed assignment.
2. **Postgres `CHECK` constraint** in the migration — runtime enforcement at INSERT / UPDATE, defends raw SQL / casts / untrusted API input.
3. **`scripts/check-catalogue-drift.mjs`** — CI gate asserting every `kind: 'X'` literal in structure-context files is in the catalogue.

The `Privilege` / `FunctionalRole` catalogues from ADR-0025 only have layer 1 + layer 3 (no DB enforcement — those values aren't persisted as schema-checked columns). ADR-0027's `Structure.kind` is persisted, so layer 2 was practical to add — bulletproof against any code path that bypasses the type system.

## Seed scope (and what's deliberately NOT in it)

Just what the 19 test-tenant personas reference per `notes/test-tenant-role-assignments.md`:

- `Region` 75 Nouvelle-Aquitaine (only region the personas exercise)
- `Delegation` 33 Gironde (only delegation)
- `Structure` 0330800013 (APF Bordeaux, medico-social, FINESS = code)
- `Structure` 0330800021 (Complexe Mérignac, medico-social)
- `Structure` `siege` (APF national, kind=siege, no FK to a delegation, no FINESS)

**No** placeholder `entreprise_adaptee`, `antenne`, or `dispositif` row — those kinds are valid per the catalogue but the test tenant doesn't exercise them, and adding gold-plate seed data would be misleading ("what is this row used for?"). The full APF inventory lands with [ADR-0029](#)'s cascade sync; this seed is **superseded** (not extended) by that sync.

## Notes for the reviewer

- **Naming conflict with the existing `User` model.** The current `schema.prisma` already has a `User` model — but it's the ADR-0020 user-directory cache (Entra `oid` as PK, written by `UserDirectoryService.recordSignIn`). ADR-0026 PR 1 introduces a different `User` (UUID PK, FK to `Person`, `lastSignInAt`). **Out of scope here** — ADR-0027 PR 1 doesn't touch `User`. Flagging now so ADR-0026 PR 1 can plan the migration path (likely: rename the existing `User` to `UserDirectoryEntry` or fold it into the new Person + User pair).
- **Migration is hand-written**, matching the style of the two existing migrations (`init_audit_schema`, `users_directory`). Timestamp `20260526143000` chosen so it sorts after the existing `20260514192014_users_directory`.
- **`@@schema("public")`** required on every new model because the audit log uses `multiSchema` (per ADR-0013) — the public/audit split is configured at the datasource.
- **Drift gate error-message formatter** now switches on callee shape — decorator violations still print as `@Foo('x')`, property-literal violations print as `kind: 'x'` to match the offending code shape.
- **No `pnpm ci:check` impact expected** at the bff level beyond the new spec; `pnpm ci:catalogue-drift` continues to report clean (`catalogues: 4 privileges, 24 roles, 7 structure kinds`).

## Test plan

- [x] `node scripts/check-catalogue-drift.mjs` — clean (4 / 24 / 7).
- [x] `node --test scripts/check-catalogue-drift.spec.mjs` — 22 tests passing.
- [x] `pnpm exec prettier --check` clean on the touched files.
- [ ] **On the dev VM**: `pnpm prisma migrate dev` applies the migration cleanly against a fresh `infra/local/dev.compose.yml` postgres. `pnpm prisma studio` shows the seeded Region / Delegation / Structure rows.
- [ ] **On the dev VM**: `pnpm exec nx test portal-bff` runs the new `structure-kind.spec.ts` green.
- [ ] **Review focus** — the `Structure` model shape (kind discriminator, nullable unique columns, FK to Delegation), the inline seed values vs `notes/test-tenant-role-assignments.md`, the drift-gate property-literal scanner's restriction to files importing from `structure-kind.ts`.

## What's next

Per [ADR-0027 §"Phasing"](docs/decisions/0027-portal-side-organisational-hierarchy.md):

1. **This PR** — Region / Delegation / Structure schema + seed + drift gate. 
2. **ADR-0026 PR 1** — `Person` / `User` / `UserScope` schema + `PersonAndUserProvisioner` + drift gate extension for `Person.source` + updated `PrincipalBuilder`. Independent of (1), can ship in parallel. **Needs to resolve the existing-`User`-name collision.**
3. **ADR-0026 PR 2** — `PrismaScopeResolver` replacing `StubScopeResolver` + `/admin/users/:id/scopes` admin screen + `prisma/seed.ts` populating the 19 personas' `user_scopes` rows pointing at this PR's `Structure.code` values. **Depends on both (1) and (2).**
4. **ADR-0029** (future) — Pléiades + Acteurs+ + cascade syncs + facet schemas + `Pole` / `Service` / per-source enrichment extensions to this PR's hierarchy.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #228
2026-05-26 11:15:09 +02:00
julien 670f6303fe docs(adr-0028): accept CI/CD + git hosting migration to GitLab (#227)
CI / check (push) Successful in 2m0s
CI / commits (push) Has been skipped
CI / scan (push) Successful in 2m22s
CI / a11y (push) Successful in 3m11s
CI / perf (push) Successful in 6m15s
Docs site / build (push) Successful in 5m11s
## Summary

Promotes [ADR-0028](docs/decisions/0028-migrate-cicd-and-git-hosting-to-gitlab.md) from `proposed` to `accepted`. Shipped as `proposed` in [#226](#226); no open question left on either drivers, considered options, or the 4-phase migration sequence. Same shape as [#219](#219) (ADR-0026 + ADR-0027 acceptance).

Once merged, **Phase 1** of the migration (`mirror-and-bootstrap` — repos pushed to GitLab, branch protection / MR templates / deploy keys / Renovate reconfig) is unblocked.

## What lands

| File | Change |
| --- | --- |
| `docs/decisions/0028-migrate-cicd-and-git-hosting-to-gitlab.md` | Frontmatter `status: proposed → accepted`. |
| `docs/decisions/0015-cicd-gitea-actions.md` | **Status-update note** added at the top, right under the title — calls out that the platform choice "Gitea Actions" is superseded by ADR-0028, while the rest of ADR-0015's architectural principles (trunk-based + squash, all-gates-blocking, thin YAML, on-prem runners, signed commits, Conventional Commits) carry over unchanged. ADR-0015 stays `accepted`. |
| `docs/decisions/README.md` | ADR-0028 row: `proposed → accepted`. |
| `CLAUDE.md` | Roll-up bumped to `ADRs 0001 → 0028 accepted` with a one-sentence explanation that ADR-0028 supersedes only ADR-0015's platform choice. **CI/CD architecture bullet rewritten** to reflect the accepted-but-not-yet-implemented state — "Gitea Actions today, migrating to GitLab CE on `vm-gitlab` per ADR-0028 (4-phase rollout in follow-up PRs)". The architectural detail (gates list, thin YAML, signed commits) was preserved; only the platform headline and the act_runner→GitLab Runner line are touched. Also corrected: `ci:scan` (which doesn't exist as a script) → the real script names (`ci:catalogue-drift`, `ci:audit`, `ci:perf`, `ci:gzip-budgets`). |

## Notes for the reviewer

- **No new Architecture bullet for ADR-0028 itself.** The decision is a *platform shift* for the existing CI/CD architecture, not a new architectural concern — so it folds into the existing ADR-0015 bullet rather than adding a sibling.
- **The annotation pattern on ADR-0015** (status-update blockquote at the top) is the canonical MADR way to handle partial supersession without changing the frontmatter status. The architectural principles are still accepted; only the platform implementation moves. A future reader hitting ADR-0015 first sees the redirect immediately.
- **The CLAUDE.md script-list correction** is a side-fix — `ci:scan` is not a real script name; the actual gates are `ci:check`, `ci:catalogue-drift`, `ci:audit`, `ci:commits`, `ci:perf`, `ci:gzip-budgets`. Updated in the same touch since the bullet was being rewritten anyway.
- **No code changes**, so no `pnpm ci:check` impact. `pnpm exec prettier --check` clean on the four touched files.

## Test plan

- [x] `pnpm exec prettier --check` — clean on the four touched files.
- [x] ADR-0015 → ADR-0028 cross-reference resolves (the new blockquote link).
- [x] `ADRs 0001 → 0028 accepted` matches reality (`grep '^status: ' docs/decisions/*.md` shows everything below 0029 as `accepted`).
- [ ] **Review focus** — the ADR-0015 status-update note phrasing, the CLAUDE.md CI/CD bullet rewrite (especially the "carry over" wording), the roll-up sentence about partial supersession.

## What's next (post-merge)

1. **Phase 1 — `mirror-and-bootstrap`** — `git push --mirror gitlab` for `apf_portal` and the proto vendoring in `apf-ai-service`. GitLab side: groups, projects, branch protection (mirror Gitea's), MR templates, deploy keys, Renovate reconfigured for GitLab. **No `.gitlab-ci.yml` yet** — Gitea pipelines continue to gate. Ops work primarily; the only PR-shaped output is a Renovate config update on the apf-portal repo if its host detection changes.
2. **Phase 2 — `gitlab-ci-pipeline`** — `.gitlab-ci.yml` lands alongside `.gitea/workflows/ci.yml`. Both pipelines run in parallel for ~1 calendar week. GitLab Runner registered on `vm-gitlab`.
3. **Phase 3 — `cutover`** — remotes flip in CLAUDE.md, READMEs, `docs/setup/01-dev-debian-vm-setup.md` §8.3. `.gitea/workflows/` + `infra/ci-runners.compose.yml` deleted, Gitea read-only / archive.
4. **Phase 4 — `cleanup`** — stale references sweep, required signed-commits on `main` enabled.

In parallel — once you've finished walking through the dev VM bootstrap — the paused **ADR-0027 PR 1** (Region / Delegation / Structure Prisma schema + inline seed) and **ADR-0026 PR 1** (Person / User / UserScope schema + provisioner) can ship on Gitea; they're decoupled from the migration and don't need to wait for it.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #227
2026-05-26 10:54:55 +02:00
julien 04ee535de9 docs(adr-0028): propose CI/CD + git hosting migration Gitea -> GitLab (#226)
CI / check (push) Successful in 3m28s
CI / commits (push) Has been skipped
CI / scan (push) Successful in 3m3s
CI / a11y (push) Successful in 4m21s
CI / perf (push) Successful in 6m6s
Docs site / build (push) Successful in 5m59s
## Summary

Drafts [ADR-0028](docs/decisions/0028-migrate-cicd-and-git-hosting-to-gitlab.md) as `proposed`: migrate CI/CD + git hosting from Gitea (`git.unespace.com`) to GitLab CE self-hosted on `vm-gitlab` (`10.100.201.10`). The migration was anticipated by [ADR-0015](docs/decisions/0015-cicd-gitea-actions.md) ("level-2 implementation; will be superseded by a GitLab migration ADR within 6-18 months") — that window opens now. **Decision-only PR** — the actual 4-phase migration ships across follow-up PRs after acceptance.

ADR-0028's number was previously a placeholder reference in ADR-0026 and ADR-0027 for the Pléiades + Acteurs+ sync ADR. **Renumbering**: that future sync ADR shifts to `ADR-0029`, and the placeholder links in ADR-0026, ADR-0027 and `CLAUDE.md` update to match — included in the same PR so the chain stays consistent.

## What lands

| File | Change |
| --- | --- |
| `docs/decisions/0028-migrate-cicd-and-git-hosting-to-gitlab.md` | **New.** MADR 4.0.0 ADR, `proposed`. Decision = Option B (GitLab CE on `vm-gitlab`). Considered options A (status quo Gitea), C (Forgejo), D (cloud SaaS). Documents what carries over from ADR-0015 (architectural principles unchanged — thin YAML, trunk-based, all-gates-blocking, on-prem runners), what changes (host, pipeline file, runner type, scan tooling), the 4-phase migration sequence, and the signed-commits revisit. |
| `docs/decisions/0026-person-user-portal-data-model.md` | All 11 `ADR-0028` references → `ADR-0029` (sync + facets shifts to 0029). |
| `docs/decisions/0027-portal-side-organisational-hierarchy.md` | All 14 `ADR-0028` references → `ADR-0029`. |
| `docs/decisions/README.md` | New row for ADR-0028 (`proposed`, tags `infrastructure`, `process`, 2026-05-26). |
| `CLAUDE.md` | Roll-up updated: `ADRs 0001 → 0027 accepted; ADR-0028 + ADR-0029 proposed`. ADR-0028's relationship to ADR-0015 spelled out inline ("supersedes ADR-0015's Gitea Actions platform choice — the rest of ADR-0015's architectural principles carry over unchanged"). ADR-0026 + ADR-0027 architecture bullets renumbered 0028 → 0029 to track. |

## Key choices in the ADR

- **What carries over from ADR-0015 vs what changes** — explicit table so future readers see immediately that the migration is **platform-only**, not a re-litigation of CI principles. Trunk-based + squash, all-gates-blocking, thin YAML over portable scripts (`pnpm ci:check` etc. — unchanged), on-prem runners, Conventional Commits in CI + hook (defense in depth) — all carried over. Host, pipeline-file grammar, runner type, and scan tooling are the only things that move.
- **Native security scanning replaces the manual Trivy + gitleaks setup.** GitLab CE's built-in `Dependency-Scanning.gitlab-ci.yml` + `Secret-Detection.gitlab-ci.yml` includes consolidate the ~30 lines of inline `curl + tar` install dance currently in `.gitea/workflows/ci.yml`. Same blocking thresholds (CRITICAL+HIGH dependency vulns, any secret).
- **`vm-gitlab` is already provisioned.** No infra wait — the only sequencing constraint is operator-driven, not infrastructure-driven.
- **4-phase migration, parallel pipelines for ~1 week before cutover.** Phase 1 mirrors repos and bootstraps GitLab side (no `.gitlab-ci.yml` yet — Gitea still gates). Phase 2 lands `.gitlab-ci.yml` alongside `.gitea/workflows/ci.yml` so both pipelines run per PR until parity is confirmed. Phase 3 flips the remote URLs and deletes `.gitea/workflows/` + `infra/ci-runners.compose.yml`. Phase 4 sweeps stale references.
- **Gitea moves to read-only / archive, not decommissioned** at cutover. Existing references to Gitea PRs (`#213`, `#217`, `#219`, …) in commit messages and ADR bodies stay resolvable as historical artefacts. One VM at idle is a low long-term cost.
- **Signed commits revisit.** ADR-0015 noted "signed commits recommended, revisited at GitLab migration". ADR-0028 makes the recommendation: enable required signed commits on `main` once GitLab is live, paired with GnuPG agent forwarding (already documented in `docs/setup/01-dev-debian-vm-setup.md` §8.5). `apf-portal-bot` (Renovate) gets a dedicated signing key at PR 1.

## Renumbering — what moved and why

Before this PR, ADR-0026 and ADR-0027 used `ADR-0028` as a placeholder link for the Pléiades + Acteurs+ sync ADR. That sync ADR hasn't been drafted yet — the number was reserved.

This PR claims `ADR-0028` for the GitLab migration (the immediately-actionable decision), and shifts the sync placeholder to **ADR-0029**. All 25 link references across ADR-0026 (11) and ADR-0027 (14) update in lockstep — replace_all is safe here because in those files `ADR-0028` consistently meant "the sync ADR".

Content of the sync ADR is unchanged — only the number. When that ADR is eventually drafted as `0029-…md`, it gets the existing content reserved for it in the placeholder text.

## Test plan

- [x] `pnpm exec prettier --check` clean on the touched files.
- [x] All `ADR-0028` references in `0026-…md` / `0027-…md` / `CLAUDE.md` now read `ADR-0029` (grep confirms zero remaining references to the old number in those files).
- [x] The new `0028-…md` self-references (status frontmatter, title, internal anchors) are consistent — no leftover `0029`.
- [ ] **Review focus** — drivers / consequences / migration sequence in the ADR; the "what carries over from ADR-0015" table; the renumbering rationale.

## What's next

Per ADR-0028 §"Migration sequence", post-acceptance:

1. **ADR-0028 acceptance PR** — small status-flip, same pattern as #219 (ADR-0026 + ADR-0027 acceptance).
2. **`mirror-and-bootstrap` PR** — `git push --mirror` Gitea → GitLab; GitLab side groups / projects / branch protection / MR templates / deploy keys / Renovate reconfig. No `.gitlab-ci.yml` yet, Gitea pipelines still gate.
3. **`gitlab-ci-pipeline` PR** — `.gitlab-ci.yml` alongside the existing `.gitea/workflows/ci.yml`. Parallel runs ~1 week for parity. GitLab Runner registered on `vm-gitlab`.
4. **`cutover` PR** — remotes flip across docs, `.gitea/workflows/` + `infra/ci-runners.compose.yml` deleted, Gitea read-only.
5. **`cleanup` PR** — stale references sweep, signed-commit policy finalised on `main`.

In parallel — once the dev VM (#220 / #221 / #222 / #223 / #224) is fully bootstrapped — the paused **ADR-0027 PR 1** (Region / Delegation / Structure Prisma schema + inline seed) and **ADR-0026 PR 1** (Person / User / UserScope schema + provisioner) can ship on Gitea; they have no dependency on the GitLab migration and don't need to wait.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #226
2026-05-26 10:30:19 +02:00
julien 72e1182308 docs(setup): version aliases.zsh + gitconfig.txt under docs/setup/dotfiles (#224)
CI / check (push) Successful in 2m54s
CI / commits (push) Has been skipped
CI / scan (push) Successful in 3m0s
CI / a11y (push) Successful in 3m55s
CI / perf (push) Successful in 5m43s
Docs site / build (push) Successful in 5m31s
## Summary

Follow-up on [#220](#220). [`80-dotfiles.sh`](docs/setup/scripts/80-dotfiles.sh) was reading `notes/aliases.zsh` and `notes/gitconfig.txt` — but `notes/` is the project lead's personal scratchpad and is gitignored, so a fresh clone on the dev VM has nothing to read:

```
✗ /home/APF/dev-jugautier/Works/apf_portal/notes/aliases.zsh not found.
```

Move both templates to a versioned location and update the script + docs to match.

## What lands

| File | Change |
| --- | --- |
| `docs/setup/dotfiles/aliases.zsh` | **New.** Project-wide zsh aliases (navigation / files / search / git / dev), previously living in `notes/aliases.zsh`. Header explains scope (project-wide, all devs pick it up on next zsh restart) and where per-dev customizations go (private dotfiles repo). |
| `docs/setup/dotfiles/gitconfig.txt` | **New.** Base `~/.gitconfig` template — init / core / aliases / colour. `[user]` block carries placeholder identity (`name = your name` / `email = your.email@example.com`) that the script overwrites at install via `git config --global user.{name,email}`. |
| `docs/setup/scripts/80-dotfiles.sh` | Source paths flipped from `$REPO_ROOT/notes/…` to `$REPO_ROOT/docs/setup/dotfiles/…`. Header doc-comment updated. |
| `docs/setup/01-dev-debian-vm-setup.md` | Step-3 table row and §8.4 (dotfiles repo) reference the new path. The §8.4 wording is also tightened — the script no longer "falls back" anywhere, it has one source of truth. |
| `docs/setup/README.md` | New `dotfiles/` section in the folder index. Step-7 row in the scripts table also reflects the new paths. |

## Why this lives in `docs/setup/dotfiles/` and not `infra/` or a top-level `dotfiles/`

- The directory is **consumed exclusively by [`80-dotfiles.sh`](docs/setup/scripts/80-dotfiles.sh)** — co-locating it under `docs/setup/` keeps the setup family self-contained (one folder to read, one folder to clone).
- The name `dotfiles/` reads correctly for a future per-dev dotfiles repo migration (§8.4) — the templates here become the seed of that repo, and the install script will then check `~/.dotfiles/` first and fall back to `docs/setup/dotfiles/` second.

## Unblock path (if you're stuck mid-bootstrap)

Either pull this PR, or manually create the two files at the **new** location on the VM:

```bash
mkdir -p ~/Works/apf_portal/docs/setup/dotfiles
# paste the aliases.zsh + gitconfig.txt content there
./docs/setup/scripts/80-dotfiles.sh
```

## Test plan

- [ ] On a fresh Trixie VM, `./docs/setup/scripts/80-dotfiles.sh` succeeds: aliases.zsh symlink lands at `~/.oh-my-zsh/custom/aliases.zsh` pointing at `docs/setup/dotfiles/aliases.zsh`, `~/.gitconfig` written with the prompted identity.
- [ ] Re-running the script reports `↪ skip aliases.zsh symlink already correct` (idempotency).
- [ ] `git config --global user.name` returns the prompted value (i.e. placeholder `your name` is overwritten).
- [x] `pnpm exec prettier --check` clean on the touched files.
- [x] `bash -n docs/setup/scripts/80-dotfiles.sh` clean.

## Notes

- `notes/aliases.zsh` and `notes/gitconfig.txt` on existing dev workstations are unaffected — those files live outside the repo (gitignored), nothing here touches them. They can be deleted or kept as personal scratch at the dev's discretion.
- A future PR creates the **private dotfiles repo** (`apf/dotfiles`) and teaches `80-dotfiles.sh` to prefer `~/.dotfiles/` over `docs/setup/dotfiles/` when both exist.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #224
2026-05-24 21:23:24 +02:00
julien 1d11db9f36 docs(setup): drop deprecated apt packages on Debian 13 trixie (#223)
CI / commits (push) Has been skipped
CI / scan (push) Successful in 3m41s
CI / check (push) Successful in 4m2s
CI / a11y (push) Successful in 3m51s
CI / perf (push) Successful in 5m45s
Docs site / build (push) Successful in 5m41s
## Summary

Follow-up on [#220](#220). [`10-base-packages.sh`](docs/setup/scripts/10-base-packages.sh) fails on a fresh Debian 13 Trixie VM with `E: Impossible de trouver le paquet software-properties-common`. The package is no longer shipped on Trixie — and it was never used by any of our downstream scripts in the first place.

Drop three deprecated / unused packages from the install list. Add an inline comment explaining each kept package's purpose so a drive-by addition doesn't reintroduce the dropped ones.

## What lands

| File | Change |
| --- | --- |
| `docs/setup/scripts/10-base-packages.sh` | Drop `software-properties-common`, `apt-transport-https`, `lsb-release`. Remaining minimal set: `curl wget git ca-certificates gnupg build-essential pkg-config`. Inline comment lists which downstream script consumes each kept package + which three were deliberately removed and why. |
| `docs/setup/01-dev-debian-vm-setup.md` | Step-3 table row for `10-base-packages.sh` updated to match the new package list. |

## Why each removed package wasn't needed

| Package | Why removed |
| --- | --- |
| `software-properties-common` | Drops `add-apt-repository`. We don't use it — [`50-docker.sh`](docs/setup/scripts/50-docker.sh) writes `/etc/apt/sources.list.d/docker.list` manually with the keyring-pinned `signed-by=` form. Also no longer in Trixie. |
| `apt-transport-https` | Transitional package since apt 1.5 (2018) — apt has native HTTPS. Intermittently absent on Trixie. |
| `lsb-release` | Shell scripts read `/etc/os-release` directly (see `50-docker.sh`'s `. /etc/os-release && echo "${VERSION_CODENAME}"`). |

## Unblock path (manual, if you're stuck mid-bootstrap)

The user can either pull this PR and re-run, or shortcut manually:

```bash
sudo apt-get install -y curl wget git ca-certificates gnupg build-essential pkg-config
./docs/setup/scripts/20-zsh.sh   # continue from the next step
```

Scripts are idempotent — re-running `bootstrap.sh` after pulling this PR is also safe.

## Test plan

- [ ] On a fresh Trixie VM, `./docs/setup/scripts/10-base-packages.sh` exits successfully.
- [ ] Verify each downstream script still has the binaries it expects (curl in 40-node.sh, gpg in 50-docker.sh, build-essential in pnpm install, …).
- [x] `pnpm exec prettier --check` clean on the touched files.
- [x] `bash -n docs/setup/scripts/10-base-packages.sh` clean.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #223
2026-05-24 21:02:24 +02:00
julien d99254a280 docs(setup): make fail2ban opt-in in 70-hardening.sh (#222)
CI / commits (push) Has been skipped
CI / check (push) Successful in 3m14s
CI / scan (push) Successful in 3m19s
CI / a11y (push) Successful in 3m59s
CI / perf (push) Successful in 5m48s
Docs site / build (push) Successful in 5m57s
## Summary

Follow-up on [#220](#220) / [#221](#221). Makes fail2ban **opt-in** in [`70-hardening.sh`](docs/setup/scripts/70-hardening.sh) instead of installing it unconditionally.

Reasoning: some corp environments already ship brute-force protection at the network layer (ACL / corp firewall / appliance) — fail2ban on the host then becomes redundant and can be the wrong layer to debug from when a rule misfires. The other three hardening steps (UFW enable, sshd lockdown) were already prompt-gated; fail2ban was the odd one out.

## What lands

| File | Change |
| --- | --- |
| `docs/setup/scripts/70-hardening.sh` | fail2ban block restructured into three branches: (1) already running → skip; (2) installed but stopped → prompt to enable+start; (3) not installed → prompt to install+enable+start. Each "no" path logs `↪ skip (user choice)` so re-runs don't repeatedly nag if the dev has already declined. |
| `docs/setup/01-dev-debian-vm-setup.md` | Table row for `70-hardening.sh` clarified — each sub-step's prompt posture is now visible: UFW prompts before enabling, fail2ban prompts before installing, sshd hardening prompts before applying. `unattended-upgrades` is the only one applied unconditionally. |
| `docs/setup/README.md` | Same descriptor adjustment. |

## Test plan

- [ ] On a fresh Debian VM with no fail2ban installed, run `70-hardening.sh`, decline the fail2ban prompt → script continues, fail2ban not installed, no service started.
- [ ] On the same VM, re-run `70-hardening.sh` → the fail2ban branch prompts again (the dev may have changed their mind); declining again produces the same `↪ skip (user choice)` result.
- [ ] On a VM where fail2ban is pre-installed but stopped (rare, but possible if infra rolled it back), the script offers to start it without re-installing.
- [x] `pnpm exec prettier --check` clean on the touched files.
- [x] `bash -n docs/setup/scripts/70-hardening.sh` (syntax check) clean.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #222
2026-05-24 20:04:16 +02:00
julien c77313c693 docs(setup): use ~/.bashrc hand-off instead of chsh for zsh switch (#221)
CI / commits (push) Has been skipped
CI / check (push) Successful in 1m50s
CI / scan (push) Successful in 3m11s
CI / a11y (push) Successful in 3m28s
CI / perf (push) Successful in 6m7s
Docs site / build (push) Successful in 4m29s
## Summary

Follow-up fix on [#220](#220). The corp infra locks the default shell at user-provisioning time on the dev VM (`10.100.201.21`) — `chsh` is denied at the PAM level, so the `sudo chsh -s` block in [`20-zsh.sh`](docs/setup/scripts/20-zsh.sh) fails on every fresh VM.

Switch to the `~/.bashrc` exec-zsh hand-off — the same proven pattern used in the legacy [`02-wsl-terminal-setup.md`](docs/setup/02-wsl-terminal-setup.md). UX-identical for the dev, zero infra escalation required.

## What lands

| File | Change |
| --- | --- |
| `docs/setup/scripts/20-zsh.sh` | Drop the `sudo chsh -s` block. Append a guarded `exec zsh -l` block to `~/.bashrc` instead, marked with a managed-by comment for idempotency on re-runs. |
| `docs/setup/01-dev-debian-vm-setup.md` | Table row for `20-zsh.sh` updated — "set as default shell" → "`~/.bashrc` (exec zsh on interactive shells — `chsh` is blocked on the corp VM)". |
| `docs/setup/README.md` | Same descriptor adjustment. |

## The hand-off block

```bash
# Managed by docs/setup/scripts/20-zsh.sh — apf-portal zsh hand-off.
# Hand off to zsh on interactive shells. The `case $-` guard avoids
# breaking non-interactive bash (scp, rsync, cron, …), and the
# ZSH_VERSION check prevents an infinite re-exec loop.
case $- in
  *i*)
    if command -v zsh >/dev/null 2>&1 && [ -z "${ZSH_VERSION-}" ]; then
      exec zsh -l
    fi
    ;;
esac
```

Two safety nets in the block:

- **`case $- in *i*)`** — only runs the hand-off when the shell flag set contains `i` (interactive). `scp` / `rsync` / non-interactive ssh executions go through bash and are not hijacked.
- **`[ -z "${ZSH_VERSION-}" ]`** — `ZSH_VERSION` is set by zsh itself; if it's already set we're already in zsh and a re-exec would loop.

## Test plan

- [ ] On the dev VM, run `20-zsh.sh` on a fresh state: bash, no existing `~/.bashrc` zsh hand-off → script exits without `chsh` error, `~/.bashrc` gets the block appended, `exit` + reconnect lands in zsh.
- [ ] Re-run `20-zsh.sh` → reports `↪ skip zsh hand-off already in ~/.bashrc` (idempotency).
- [ ] `scp some-file vm-dev:/tmp/` from the workstation still works (non-interactive bash not hijacked).
- [x] `pnpm exec prettier --check` clean on the touched markdown files.
- [x] `bash -n docs/setup/scripts/20-zsh.sh` (syntax check) clean.

## Why not amend #220

#220 has already merged. Squash-merge collapsed it to `8a04540` on main; a force-push to amend would rewrite that commit and break anyone who's pulled it. The fix lands as a separate commit on the same setup-doc family.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #221
2026-05-24 19:55:32 +02:00
julien 8a04540410 docs(setup): add Debian 13 dev-VM setup procedure + scripts + devcontainer (#220)
CI / check (push) Successful in 3m7s
CI / commits (push) Has been skipped
CI / scan (push) Successful in 3m3s
CI / a11y (push) Successful in 3m41s
CI / perf (push) Successful in 5m24s
Docs site / build (push) Successful in 5m8s
## Summary

Adds a full Debian 13 dev-VM setup procedure ([docs/setup/01-dev-debian-vm-setup.md](docs/setup/01-dev-debian-vm-setup.md)) + 10 modular idempotent setup scripts + a systemd template + a `.devcontainer/` spec, in preparation for the new dev VM (`10.100.201.21`) replacing the WSL-based workflow. Both IDE flows (VSCode Remote-SSH + Devcontainer) and both Node toolchains (nvm on host + devcontainer image) are available — devs pick per task.

Adjacent context (does not ship here, planned follow-up):

- **Preview infra on the GitLab VM (`10.100.201.10`)** — same `dev.compose.yml`, deployed by CI on `main`. Doc placeholder in §8.6.
- **GitLab Runner migration** (act_runner Gitea → GitLab Runner Docker executor) — bundled with the Gitea → GitLab cutover.
- **Private dotfiles repo** (`apf/dotfiles`) — `~/.zshrc`, `~/.p10k.zsh`, `~/.tmux.conf` versioned. `80-dotfiles.sh` is already structured to fall back to a `~/.dotfiles/` clone when present.

No application-code changes. No CI gate impact (doc + scripts + devcontainer spec only).

## What lands

| Path | Change |
| --- | --- |
| `docs/setup/01-dev-debian-vm-setup.md` | **New.** Step-by-step doc: workstation prep (SSH agent + VSCode Remote-SSH + fonts), bootstrap orchestrator, per-script effects, project clone, infra boot, IDE flow A / B / C, apf-ai-service .NET appendix, troubleshooting. |
| `docs/setup/02-wsl-terminal-setup.md` | Renamed from `01-wsl-terminal-setup.md`. No content change. |
| `docs/setup/03-dev-web-stack.md` | Renamed from `02-dev-web-stack.md`. No content change. |
| `docs/setup/04-angular-nx-monorepo.md` | Renamed from `03-angular-nx-monorepo.md`. No content change. |
| `docs/setup/README.md` | **New.** Index of the `docs/setup/` folder. |
| `docs/setup/scripts/lib.sh` | **New.** Shared helpers — colour-coded log/ok/warn/err/skip, `apt_install` skipping already-installed, `ensure_line` idempotent append, `confirm` prompt. |
| `docs/setup/scripts/bootstrap.sh` | **New.** Orchestrator running scripts 10..80 in order with confirmation prompts. |
| `docs/setup/scripts/10-base-packages.sh` | **New.** apt update + base packages (curl, wget, git, build-essential, …). |
| `docs/setup/scripts/20-zsh.sh` | **New.** zsh + Oh My Zsh (RUNZSH=no, no shell hijack) + Powerlevel10k + `zsh-autosuggestions` + `zsh-syntax-highlighting`. Patches `~/.zshrc` (theme, plugins, fzf hook). |
| `docs/setup/scripts/30-cli-tools.sh` | **New.** `bat eza fd-find ripgrep fzf zoxide ncdu keychain` + `jq yq httpie make tree htop tmux direnv dnsutils unzip rsync`. Symlinks Debian-renamed binaries (`batcat`→`bat`, `fdfind`→`fd`) into `~/.local/bin` so notes/aliases.zsh works as-is. |
| `docs/setup/scripts/40-node.sh` | **New.** nvm v0.40.1 + Node from `.nvmrc` (currently 24) + corepack enable + pnpm warmed from `package.json#packageManager`. |
| `docs/setup/scripts/50-docker.sh` | **New.** Docker CE + compose plugin from docker.com apt repo, user added to `docker` group, `docker.service` enabled at boot. Pinned GPG key + repo line for Debian Trixie. |
| `docs/setup/scripts/60-tuning.sh` | **New.** `fs.inotify.max_user_watches=524288` (Vite/Nx watch ceiling), optional 4 GB swapfile, optional hostname rename (only prompts on generic hostnames like `debian13`). |
| `docs/setup/scripts/70-hardening.sh` | **New.** Best-effort UFW (`allow OpenSSH` only) + unattended-upgrades on security channel + fail2ban + sshd drop-in (`PermitRootLogin no`, `PasswordAuthentication no`, `AllowAgentForwarding yes`). Probes before applying, validates `sshd -t` before reloading, skips cleanly if infra already locked the box down. |
| `docs/setup/scripts/80-dotfiles.sh` | **New.** Symlinks `notes/aliases.zsh` → `~/.oh-my-zsh/custom/aliases.zsh` (backs up an existing target). Copies `notes/gitconfig.txt` to `~/.gitconfig`, prompts for identity, applies via `git config --global user.name/email`. |
| `docs/setup/systemd/apf-portal-infra@.service` | **New.** Template systemd unit auto-starting `./infra/local/dev.sh up` at boot. Install: `enable apf-portal-infra@$USER.service`. |
| `.devcontainer/devcontainer.json` | **New.** VSCode Dev Container spec on `mcr.microsoft.com/devcontainers/typescript-node:1-24-bookworm`. `docker-outside-of-docker` feature + `--network=apf-portal-dev` so DNS to `postgres`/`redis`/`otel-collector` works. `initializeCommand` fails fast if `dev.sh up` hasn't been run yet. Forwarded ports labelled. Six dev extensions pre-installed. |
| `.devcontainer/post-create.sh` | **New.** `corepack enable && pnpm install --frozen-lockfile`. |
| `CLAUDE.md` | "Environment conventions" rewritten: documents the two envs (`local` / `development`) + hybrid sub-mode + the two IDE flows (Remote-SSH / Devcontainer), points at the new VM setup doc and the legacy WSL doc. |

## Key choices

- **Idempotent scripts, individually runnable.** Each script probes before doing anything (apt package already installed? plugin already cloned? UFW already active? sshd drop-in already present?). Bootstrap is the orchestrator; each script also runs standalone. Re-running after a partial setup is safe and reports `↪ skip` for the no-op cases.
- **No private key on the VM — SSH agent forwarding instead.** `~/.ssh/config` on the workstation carries `ForwardAgent yes`; the VM never holds long-lived secrets. Same future pattern for GPG signing (covered in §8.5 as appendix). `keychain` is installed by `30-cli-tools.sh` as a fallback for scenarios where agent forwarding is not available (CI runners, scripts).
- **Hardening is "best-effort, probe-first".** Some infra teams ship pre-hardened VMs; this script doesn't fight that. UFW already active? Print rules and skip. unattended-upgrades already on? Skip. SSH already locked down? Skip. Each section validates before reloading so a misconfig can't take SSH offline.
- **Devcontainer assumes infra-on-host.** The container runs on the VM but talks to postgres / redis / otel **on the same VM's host docker daemon** through the shared `apf-portal-dev` Compose network. `initializeCommand` fails fast with a clear message if `./infra/local/dev.sh up` hasn't been run yet — better than puzzling `ECONNREFUSED` errors at runtime.
- **`60-tuning.sh` raises inotify to 524288.** Default Debian limit is 8K; Vite/Nx in this monorepo blow past that. The setting is persisted in `/etc/sysctl.d/99-apf-portal.conf` so it survives reboot.
- **Hybrid mode (workstation IDE + VM infra) is a documented sub-mode.** SSH `LocalForward` directives on 5432/6379/4317/4318 expose the VM's infra services as `localhost:*` on the workstation. Latency cost: 5-15 ms per query, fine for daily work; for long-running flows, wrap in `tmux` or use `autossh`.

## Notes for the reviewer

- **Renaming the existing setup docs (01→02, 02→03, 03→04) is the only "destructive" change.** `git log --follow` still works because of `git mv`. Diff shows up as renames, not delete-and-add.
- **The brief asked for `bat eza fd-find ripgrep fzf zoxide docker keychain ncdu git`.** `git` is installed by `10-base-packages.sh` (every other script needs it). Everything else lives in `30-cli-tools.sh` + `50-docker.sh`. The "fullstack-dev extras" (`jq yq httpie make tree htop tmux direnv dnsutils unzip rsync`) are additions I proposed in the lock-in question and that you greenlit (full scope) — easy to trim if any of them turn out to be unwanted.
- **Both Node toolchains in parallel** — nvm on the VM (via `40-node.sh`) **and** devcontainer in the repo. Devs can use either; both read the same `.nvmrc` + `packageManager` pin so the version stays consistent.
- **The systemd unit is a TEMPLATE** (`apf-portal-infra@.service`) — install once, enable per-user (`enable apf-portal-infra@$USER.service`). This is the right shape for a shared VM with multiple devs eventually, even if today only one user uses it.
- **No PR-body Co-Authored-By trailer, no Generated-with-Claude footer**, per the project rule.

## Test plan

Manual (no automated test exists for this kind of setup work):

- [ ] On a fresh Debian 13 VM: `git clone …`, `./docs/setup/scripts/bootstrap.sh`, answer prompts, end up with zsh + Powerlevel10k + all the requested CLI tools + Node 24 + pnpm 10.33.4 + Docker on PATH.
- [ ] `./infra/local/dev.sh up` boots successfully against the VM's local docker daemon.
- [ ] `pnpm install` + `pnpm exec nx run-many -t lint test --parallel=3` passes on the VM.
- [ ] VSCode Remote-SSH from a workstation: connect, open `~/Works/apf_portal`, run `pnpm exec nx serve portal-bff`, confirm reaches `postgres:5432`.
- [ ] VSCode Dev Containers from the same workstation: `Reopen in Container`, image builds, `postCreateCommand` runs `pnpm install`, dev server reaches postgres through the `apf-portal-dev` network.
- [ ] Hybrid mode: SSH tunnel from workstation, `pnpm exec nx serve portal-bff` locally, confirm postgres reachable via `localhost:5432`.
- [x] `pnpm exec prettier --check` clean on the touched markdown files.
- [x] Scripts pass `bash -n` (syntax check) — verified during writing.

## What's next

- Validate by walking through this doc on the actual VM `10.100.201.21`. Any friction surfaced becomes a follow-up PR (`docs(setup): ...`).
- Once the dev VM is operational, return to **ADR-0027 Implementation PR 1** (Region / Delegation / Structure Prisma schema + inline reference-data migration) — paused since the start of this PR.
- Set up the **private dotfiles repo** (`apf/dotfiles`) as a small follow-up, then teach `80-dotfiles.sh` to prefer the dotfiles repo over `notes/`.
- When migrating to GitLab: PR pair — (a) `git remote set-url` doc updates here, (b) `infra/gitlab-runners/` replacing `infra/ci-runners.compose.yml`.
- Future: deploy the **shared preview infra** on `vm-gitlab` (10.100.201.10) — CI-driven, separate PR.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #220
2026-05-24 18:40:07 +02:00
julien 0d8b3712fb docs(adr): accept ADR-0026 + ADR-0027 (#219)
CI / check (push) Successful in 3m9s
CI / commits (push) Has been skipped
CI / scan (push) Successful in 2m57s
CI / a11y (push) Successful in 3m20s
CI / perf (push) Successful in 4m48s
Docs site / build (push) Successful in 5m0s
## Summary

Promotes [ADR-0026](docs/decisions/0026-person-user-portal-data-model.md) and [ADR-0027](docs/decisions/0027-portal-side-organisational-hierarchy.md) from `proposed` to `accepted`. Both shipped as `proposed` in #217 after the cascade / acteurs_plus source-of-truth audit reshaped the org-hierarchy model. No open questions left on either.

No code changes — same shape as #205 (ADR-0025 acceptance).

## What lands

| File | Change |
| --- | --- |
| `docs/decisions/0026-person-user-portal-data-model.md` | Frontmatter `status: proposed → accepted`. |
| `docs/decisions/0027-portal-side-organisational-hierarchy.md` | Frontmatter `status: proposed → accepted`. |
| `docs/decisions/README.md` | Rows for 0026 and 0027 flip to `accepted`. |
| `CLAUDE.md` | Roll-up bumped to `0001 → 0027 accepted`; two new Architecture bullets ("Portal-side identity model" and "Portal-side organisational hierarchy") added after the ADR-0025 bullet; ADR-0025's bullet adjusted to clarify the scope literal `etablissement:<structure-code>` (with a pointer to ADR-0027 for the `Structure.code` semantics); the `@RequireScope` Prisma-resolver roadmap entry now references both ADRs as accepted with the two-schema-then-resolver phasing. |

## Notes for the reviewer

- **ADR-0025's bullet got a small touch-up, not a rewrite.** The original copy listed scope kinds as `etablissement:<finess>`, which was accurate when ADR-0025 shipped but is now superseded by ADR-0027's `Structure.code` semantics (FINESS for medico-social rows, internal slugs otherwise). The change is `<finess>` → `<structure-code>` + a `see ADR-0027` parenthetical. No actual decision in ADR-0025 changes.
- **Two new Architecture bullets, mirrored on the ADR-0024 / ADR-0025 pair of bullets that precede them in tone + length.** The "Portal-side identity model" bullet calls out the `entraOid`-only v1 dedup and the non-unique `Person.email` (both decisions made during the split rework); the "Portal-side organisational hierarchy" bullet calls out the `Structure.code` round-trip semantics and the deferred-to-ADR-0028 items (`Pole`, `Service`, arbitrary nesting, per-source enrichment, full cascade sync).
- **No code changes**, so no `pnpm ci:check` impact. `pnpm exec prettier --check` clean on the four touched files.

## Test plan

- [x] `pnpm exec prettier --check` — clean on the four touched files.
- [x] Internal links resolve (ADR-0026 ↔ ADR-0027 mutual references, plus the `ADR-0028` dangling marker left intentional for the future sync ADR).
- [ ] **Review focus** — the two new Architecture bullets phrasing; the ADR-0025 bullet's small scope-literal touch-up; CLAUDE.md roll-up wording.

## What's next

Now unblocked — per ADR-0026 §"Phasing" and ADR-0027 §"Phasing":

1. **ADR-0027 Implementation PR 1** — `Region` / `Delegation` / `Structure` Prisma schema + inline reference-data migration (Région Nouvelle-Aquitaine + Délégation 33 + a handful of test-tenant structures) + `Structure.kind` catalogue + drift-gate extension. Independent of (2) at the schema level.
2. **ADR-0026 Implementation PR 1** — `Person` / `User` / `UserScope` Prisma schema + `PersonAndUserProvisioner` called from `SessionEstablisher` + `Person.source` catalogue + drift-gate extension + updated `PrincipalBuilder` populating `Principal.user.{id, personId}` from the real rows. Can ship in parallel with (1).
3. **ADR-0026 Implementation PR 2** — `PrismaScopeResolver` replacing `StubScopeResolver` + `/admin/users/:id/scopes` admin-app screen + `prisma/seed.ts` populating the 19 test personas' `user_scopes` per `notes/test-tenant-role-assignments.md`. Depends on both (1) and (2).
4. **ADR-0028 (proposed)** — Pléiades + Acteurs+ + cascade syncs + facet schemas (Salarié / Élu / Adhérent / Bénévole / Bénéficiaire / PartenaireExterne) + operator-confirmed Person-reconciliation flow + the deferred org-hierarchy extensions (`Pole`, `Service`, per-source enrichment).

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #219
2026-05-24 16:58:39 +02:00
APF Portal Bot 7d89591f9a fix(deps): update dependency @grpc/grpc-js to v1.14.4 (#218)
CI / commits (push) Has been skipped
CI / scan (push) Successful in 3m34s
CI / check (push) Successful in 5m54s
CI / a11y (push) Successful in 2m26s
CI / perf (push) Successful in 6m15s
Docs site / build (push) Successful in 2m56s
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| [@grpc/grpc-js](https://grpc.io/) ([source](https://github.com/grpc/grpc-node)) | dependencies | patch | [`1.14.3` -> `1.14.4`](https://renovatebot.com/diffs/npm/@grpc%2fgrpc-js/1.14.3/1.14.4) |

---

### Release Notes

<details>
<summary>grpc/grpc-node (@&#8203;grpc/grpc-js)</summary>

### [`v1.14.4`](https://github.com/grpc/grpc-node/releases/tag/%40grpc/grpc-js%401.14.4): @&#8203;grpc/grpc-js 1.14.4

[Compare Source](https://github.com/grpc/grpc-node/compare/@grpc/grpc-js@1.14.3...@grpc/grpc-js@1.14.4)

- Fix a bug that could cause servers to crash when handling malformed requests ([advisory GHSA-5375-pq7m-f5r2](https://github.com/grpc/grpc-node/security/advisories/GHSA-5375-pq7m-f5r2))
- Fix a bug that could cause clients and servers to crash when handling malformed compressed messages ([advisory GHSA-99f4-grh7-6pcq](https://github.com/grpc/grpc-node/security/advisories/GHSA-99f4-grh7-6pcq))

</details>

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined).

🚦 **Automerge**: Enabled.

♻ **Rebasing**: Whenever PR is behind base branch, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0MC42Mi4xIiwidXBkYXRlZEluVmVyIjoiNDAuNjIuMSIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOlsiZGVwZW5kZW5jaWVzIl19-->

Reviewed-on: #218
Co-authored-by: APF Portal Bot <jgautier.webdev+apf-portal-bot@gmail.com>
Co-committed-by: APF Portal Bot <jgautier.webdev+apf-portal-bot@gmail.com>
2026-05-24 16:24:57 +02:00
julien 30cefc4488 docs(adr): split ADR-0026 + propose ADR-0027 (Structure hierarchy) (#217)
CI / check (push) Successful in 2m53s
CI / commits (push) Has been skipped
CI / scan (push) Successful in 2m42s
CI / a11y (push) Successful in 1m51s
CI / perf (push) Successful in 3m22s
Docs site / build (push) Successful in 2m31s
## Summary

Splits [ADR-0026](docs/decisions/0026-person-user-portal-data-model.md) into two sibling ADRs after a cascade / acteurs_plus source-of-truth audit caught a design break: the first draft pinned `Etablissement.finess` as primary key, but ≥ 30 % of APF's real structure inventory has no FINESS (antennes, dispositifs, entreprises adaptées, mouvement, administratif, siège).

| ADR | Status | Scope |
| --- | --- | --- |
| [ADR-0026](docs/decisions/0026-person-user-portal-data-model.md) — narrowed | `proposed` | `Person` + `User` + `UserScope` only (identity model) |
| [ADR-0027](docs/decisions/0027-portal-side-organisational-hierarchy.md) — new | `proposed` | `Region` + `Delegation` + `Structure` (cascade-aligned: `kind` discriminator + nullable FINESS / SIRET / `codePaie`) |
| `ADR-0028` (future) | — | Pléiades + Acteurs+ + cascade syncs + facet schemas (renumbered from the old `ADR-0027` placeholder) |

No code changes — all three artefacts moving in this PR are markdown.

## What lands

| File | Change |
| --- | --- |
| `docs/decisions/0026-person-user-portal-data-model.md` | Title trimmed (drop `+ organisational hierarchy`); `Region` / `Delegation` / `Etablissement` schema removed; `Person.email` unique constraint dropped (two distinct humans can share an email — see Lifecycle); Lifecycle rewritten without email-based dedup; `UserScope.value` documented as opaque string referencing ADR-0027 codes; Confirmation drops `Etablissement.kind` bullet; ADR-0027 sync references renumbered to ADR-0028; "What ADR-0026 ships vs adjacent ADRs" rewritten to three columns. |
| `docs/decisions/0027-portal-side-organisational-hierarchy.md` | **New.** Decision = Option B (`Structure` with `kind` discriminator + nullable FINESS / SIRET / `codePaie`, internal `code` PK that doubles as FINESS for medico-social structures). Considered options A (FINESS-only — original ADR-0026 draft), B (chosen), C (full cascade replication), D (remote read against cascade). Inline-migration seed for the test tenant (Region 75 + Delegation 33 + a handful of medico-social structures + `siege`); full inventory deferred to ADR-0028's cascade sync. `Structure.kind` enum-as-string drift-gated. |
| `docs/decisions/README.md` | ADR-0026 row: title updated, status stays `proposed`. New ADR-0027 row: `proposed`, tags `data, backend`. |
| `CLAUDE.md` | Roll-up clarifies: `0001 → 0025 accepted`; `ADR-0026 + ADR-0027 proposed`. `@RequireScope` Prisma-resolver roadmap entry references both ADRs + the ADR-0028 follow-up. No new Architecture bullet (entries land when their ADRs ship). |

## Why split

The cascade audit was the trigger. Cascade — APF's medico-social structure registry + Pléiades/Talentia HR integration — models `Structure` with a **seven-value type discriminator**: `medico_social`, `antenne`, `dispositif`, `entreprise_adaptee`, `mouvement`, `administratif`, `sanitaire`. Three of those (`antenne`, `dispositif`, part of `entreprise_adaptee`) **do not have a FINESS** by construction. Cascade carries FINESS / SIRET / SIREN / Pléiades `codePaie` / Talentia `codeCompta` on **separate per-source enrichment rows** (`StructureSourceFiness`, `StructureSourceSirene`, `StructureSourcePleiades`, `StructureSourceTalentia`), nullable and many-to-one against `Structure`.

The acteurs_plus audit confirmed: acteurs_plus does not store FINESS / SIRET / SIREN on its hierarchy entities at all — it uses a portal-internal `code` (unique string) + an `externalId` pointer.

The first ADR-0026 draft's `Etablissement.finess` PK excluded all non-medico-social structures by construction. The fix is **not** to make FINESS nullable on `Etablissement` (that smuggles the discriminator into absence-of-value semantics) — it is to adopt cascade's `Structure` + `kind` discriminator directly. Doing that inside ADR-0026 would have ballooned its scope; splitting is the cleaner shape:

- **ADR-0026** keeps a tight focus on identity (`Person` + `User` + `UserScope`). The Person model is unchanged from the first draft except for the email-dedup rewrite (already discussed before the audit landed).
- **ADR-0027** owns the org hierarchy with the cascade-aligned schema, the seeding posture, and the deferred parts (`Pole`, `Service`, arbitrary nesting, per-source enrichment) called out explicitly as ADR-0028's territory.

## ADR-0027 schema highlights

```prisma
model Structure {
  // Portal-internal stable code. For medico-social structures we set
  // code = FINESS (round-trips through scope literals + URLs cleanly).
  // For non-medico-social structures: APF-internal slug ('siege',
  // 'apf-bdx-merignac', 'ea-toulouse', 'mvt-national', …).
  code            String   @id
  name            String
  // Aligned with cascade's Structure.type discriminator. Drift-gated.
  kind            String   // 'medico_social' | 'antenne' | 'dispositif'
                           // | 'entreprise_adaptee' | 'mouvement'
                           // | 'administratif' | 'siege'
  finess          String?  @unique  // 9 digits, NULL for non-medico-social
  siret           String?  @unique  // 14 chars, NULL when not SIRENE-registered
  codePaie        String?  @unique  // Pléiades 6-char, NULL in v1
  delegationCode  String?           // NULL for siège, mouvement national
  delegation      Delegation? @relation(fields: [delegationCode], references: [code])
  @@index([kind])
  @@index([delegationCode])
}
```

The vocabulary mismatch — ADR-0025's scope kind name is `etablissement` but the value is now a `Structure.code` of any kind — is documented as a known wart, with a possible ADR-0025 amendment as the rename path if a maintainer trips over it.

## Notes for the reviewer

- **`Person.email` unique constraint dropped.** Two distinct humans genuinely can share an email (shared family alias, generic `info@` mailbox, error in an upstream feed). The first draft had `email String? @unique` carried over from a "let's use it as a v1 dedup key" line of thinking that the audit reshaped. The lifecycle now treats `entraOid` as the only natural key the v1 provisioner trusts; email is an attribute, indexed for operator-driven lookup (admin UI search, ADR-0028 reconciliation flow), not a constraint.
- **`UserScope.value` has no FK to ADR-0027 tables.** Deliberate: a scope can outlive its target (a structure decommissioned mid-quarter still has historical UserScope rows pointing at its code, which the audit log needs to read). Admin UI write path validates; runtime guard tolerates stale codes (they fail the resource match, not the sign-in).
- **The old open PR `docs/adr-0026-accept-and-tighten-lifecycle` is superseded by this one.** That branch promoted ADR-0026 (full first draft) to `accepted`. The Q1 / Q2 resolutions from that PR are preserved here — Q1 (no email-dedup) is the new ADR-0026 Lifecycle section; Q2 (inline-migration seed) moves to ADR-0027's "Seeding posture" section since it is org-hierarchy-specific. The old PR can be closed without merging.
- **No code changes**, so no `pnpm ci:check` impact. `pnpm exec prettier --check` clean on the four touched files.

## Test plan

- [x] `pnpm exec prettier --check` — clean on the four touched files.
- [x] ADR cross-references resolve (every `ADR-NNNN` link in the two ADRs round-trips; the new ADR-0028 reference is a known dangling marker for the future sync ADR).
- [ ] **Review focus** — cascade / acteurs_plus audit findings as cited in ADR-0027 §"Context"; the schema choices in ADR-0027 (kind enum, nullable FINESS/SIRET, internal `code` PK); the `Person.email` non-unique change in ADR-0026; the scope-kind vocabulary mismatch documented in ADR-0027.

## What's next (post-merge)

Per ADR-0026 §"Phasing" and ADR-0027 §"Phasing" — the two ADR PRs ship in parallel once accepted:

1. **ADR-0027 Implementation PR 1** — `Region` / `Delegation` / `Structure` Prisma schema + inline reference-data migration + `Structure.kind` catalogue + drift-gate extension.
2. **ADR-0026 Implementation PR 1** — `Person` / `User` / `UserScope` Prisma schema + `PersonAndUserProvisioner` called from `SessionEstablisher` + `Person.source` catalogue + drift-gate extension + updated `PrincipalBuilder`. Independent of (1) at the schema level — can ship in parallel.
3. **ADR-0026 Implementation PR 2** — `PrismaScopeResolver` replacing `StubScopeResolver` + `/admin/users/:id/scopes` admin screen + `prisma/seed.ts` populating the 19 test personas' `user_scopes` per `notes/test-tenant-role-assignments.md`. **Depends on both (1) and (2)** — the seed references `Structure.code` values from (1) and writes `UserScope` rows from (2).
4. **ADR-0028 (proposed)** — Pléiades + Acteurs+ + cascade syncs + facet schemas (Salarié / Élu / Adhérent / Bénévole / Bénéficiaire / PartenaireExterne) + operator-confirmed Person-reconciliation flow + schema extensions (`Pole`, `Service`, per-source enrichment) the sync needs.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #217
2026-05-24 06:55:43 +02:00
APF Portal Bot abd1f91809 fix(deps): update angular (#215)
CI / commits (push) Has been skipped
CI / scan (push) Successful in 3m58s
CI / check (push) Successful in 6m27s
CI / a11y (push) Successful in 2m32s
CI / perf (push) Successful in 6m51s
Docs site / build (push) Successful in 3m5s
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| [@angular-devkit/core](https://github.com/angular/angular-cli) | devDependencies | patch | [`21.2.11` -> `21.2.12`](https://renovatebot.com/diffs/npm/@angular-devkit%2fcore/21.2.11/21.2.12) |
| [@angular-devkit/schematics](https://github.com/angular/angular-cli) | devDependencies | patch | [`21.2.11` -> `21.2.12`](https://renovatebot.com/diffs/npm/@angular-devkit%2fschematics/21.2.11/21.2.12) |
| [@angular/build](https://github.com/angular/angular-cli) | devDependencies | patch | [`21.2.11` -> `21.2.12`](https://renovatebot.com/diffs/npm/@angular%2fbuild/21.2.11/21.2.12) |
| [@angular/cdk](https://github.com/angular/components) | dependencies | patch | [`21.2.11` -> `21.2.12`](https://renovatebot.com/diffs/npm/@angular%2fcdk/21.2.11/21.2.12) |
| [@angular/cli](https://github.com/angular/angular-cli) | devDependencies | patch | [`21.2.11` -> `21.2.12`](https://renovatebot.com/diffs/npm/@angular%2fcli/21.2.11/21.2.12) |
| [@angular/common](https://github.com/angular/angular) ([source](https://github.com/angular/angular/tree/HEAD/packages/common)) | dependencies | patch | [`21.2.13` -> `21.2.14`](https://renovatebot.com/diffs/npm/@angular%2fcommon/21.2.13/21.2.14) |
| [@angular/compiler](https://github.com/angular/angular) ([source](https://github.com/angular/angular/tree/HEAD/packages/compiler)) | dependencies | patch | [`21.2.13` -> `21.2.14`](https://renovatebot.com/diffs/npm/@angular%2fcompiler/21.2.13/21.2.14) |
| [@angular/compiler-cli](https://github.com/angular/angular/tree/main/packages/compiler-cli) ([source](https://github.com/angular/angular/tree/HEAD/packages/compiler-cli)) | devDependencies | patch | [`21.2.13` -> `21.2.14`](https://renovatebot.com/diffs/npm/@angular%2fcompiler-cli/21.2.13/21.2.14) |
| [@angular/core](https://github.com/angular/angular) ([source](https://github.com/angular/angular/tree/HEAD/packages/core)) | dependencies | patch | [`21.2.13` -> `21.2.14`](https://renovatebot.com/diffs/npm/@angular%2fcore/21.2.13/21.2.14) |
| [@angular/forms](https://github.com/angular/angular) ([source](https://github.com/angular/angular/tree/HEAD/packages/forms)) | dependencies | patch | [`21.2.13` -> `21.2.14`](https://renovatebot.com/diffs/npm/@angular%2fforms/21.2.13/21.2.14) |
| [@angular/language-service](https://github.com/angular/angular) ([source](https://github.com/angular/angular/tree/HEAD/packages/language-service)) | devDependencies | patch | [`21.2.13` -> `21.2.14`](https://renovatebot.com/diffs/npm/@angular%2flanguage-service/21.2.13/21.2.14) |
| [@angular/localize](https://github.com/angular/angular) | dependencies | patch | [`21.2.13` -> `21.2.14`](https://renovatebot.com/diffs/npm/@angular%2flocalize/21.2.13/21.2.14) |
| [@angular/platform-browser](https://github.com/angular/angular) ([source](https://github.com/angular/angular/tree/HEAD/packages/platform-browser)) | dependencies | patch | [`21.2.13` -> `21.2.14`](https://renovatebot.com/diffs/npm/@angular%2fplatform-browser/21.2.13/21.2.14) |
| [@angular/router](https://github.com/angular/angular/tree/main/packages/router) ([source](https://github.com/angular/angular/tree/HEAD/packages/router)) | dependencies | patch | [`21.2.13` -> `21.2.14`](https://renovatebot.com/diffs/npm/@angular%2frouter/21.2.13/21.2.14) |
| [@schematics/angular](https://github.com/angular/angular-cli) | devDependencies | patch | [`21.2.11` -> `21.2.12`](https://renovatebot.com/diffs/npm/@schematics%2fangular/21.2.11/21.2.12) |

---

### Release Notes

<details>
<summary>angular/angular-cli (@&#8203;angular-devkit/core)</summary>

### [`v21.2.12`](https://github.com/angular/angular-cli/blob/HEAD/CHANGELOG.md#21212-2026-05-20)

[Compare Source](https://github.com/angular/angular-cli/compare/v21.2.11...v21.2.12)

##### [@&#8203;angular/build](https://github.com/angular/build)

| Commit                                                                                              | Type | Description                                   |
| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------- |
| [cbad57579](https://github.com/angular/angular-cli/commit/cbad57579adb5de7887985afbb2bf1f40adf3cb2) | fix  | ignore virtual esbuild paths with (disabled): |

<!-- CHANGELOG SPLIT MARKER -->

</details>

<details>
<summary>angular/components (@&#8203;angular/cdk)</summary>

### [`v21.2.12`](https://github.com/angular/components/blob/HEAD/CHANGELOG.md#21212-plastic-moose-2026-05-20)

[Compare Source](https://github.com/angular/components/compare/v21.2.11...v21.2.12)

##### material

| Commit | Type | Description |
| -- | -- | -- |
| [da87be7646](https://github.com/angular/components/commit/da87be76464d76ec11ae922abd5f4c72c5b4ea3e) | fix | **datepicker:** ensure dates don't overflow on a small screen ([#&#8203;33281](https://github.com/angular/components/pull/33281)) |

<!-- CHANGELOG SPLIT MARKER -->

</details>

<details>
<summary>angular/angular (@&#8203;angular/common)</summary>

### [`v21.2.14`](https://github.com/angular/angular/blob/HEAD/CHANGELOG.md#21214-2026-05-20)

[Compare Source](https://github.com/angular/angular/compare/v21.2.13...v21.2.14)

##### compiler

| Commit | Type | Description |
| -- | -- | -- |
| [68282dff9f](https://github.com/angular/angular/commit/68282dff9f9ef46540cca4bd38fc1ab739c8a783) | fix | strip namespaced SVG script elements during template compilation |

##### core

| Commit | Type | Description |
| -- | -- | -- |
| [c0f52272ed](https://github.com/angular/angular/commit/c0f52272ed337d4776bd4178cbbdc7f32037500f) | fix | do not insert todo when migrating void [@&#8203;Output](https://github.com/Output) |
| [938a7f3edd](https://github.com/angular/angular/commit/938a7f3eddda97a39edb9edcc8b4dd970858b3a2) | fix | makes resource URL sanitizer lookup case-insensitive |
| [0fb2724194](https://github.com/angular/angular/commit/0fb272419407a64a0a47096b03a911f4e7e83d79) | fix | reject script element as a dynamic component host |
| [49113ac0ef](https://github.com/angular/angular/commit/49113ac0eff852d987b5acb28a9bbda0242842cd) | fix | visit ICU expressions in signal migration schematics |

##### router

| Commit | Type | Description |
| -- | -- | -- |
| [099bf577ee](https://github.com/angular/angular/commit/099bf577ee8f0bab60593a8fd2a1de7d298e3cd6) | fix | skip scroll-to-top on initial navigation when hydrating |

<!-- CHANGELOG SPLIT MARKER -->

</details>

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined).

🚦 **Automerge**: Enabled.

♻ **Rebasing**: Whenever PR is behind base branch, or you tick the rebase/retry checkbox.

👻 **Immortal**: This PR will be recreated if closed unmerged. Get [config help](https://github.com/renovatebot/renovate/discussions) if that's undesired.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0MC42Mi4xIiwidXBkYXRlZEluVmVyIjoiNDAuNjIuMSIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOlsiZGVwZW5kZW5jaWVzIl19-->

Reviewed-on: #215
Co-authored-by: APF Portal Bot <jgautier.webdev+apf-portal-bot@gmail.com>
Co-committed-by: APF Portal Bot <jgautier.webdev+apf-portal-bot@gmail.com>
2026-05-24 05:40:35 +02:00
APF Portal Bot e8796e94f4 chore(deps): update dependency ts-jest to v29.4.11 (#214)
CI / commits (push) Has been skipped
CI / scan (push) Successful in 4m1s
CI / check (push) Successful in 5m32s
CI / a11y (push) Successful in 1m40s
CI / perf (push) Successful in 6m29s
Docs site / build (push) Successful in 2m54s
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| [ts-jest](https://kulshekhar.github.io/ts-jest) ([source](https://github.com/kulshekhar/ts-jest)) | devDependencies | patch | [`29.4.10` -> `29.4.11`](https://renovatebot.com/diffs/npm/ts-jest/29.4.10/29.4.11) |

---

### Release Notes

<details>
<summary>kulshekhar/ts-jest (ts-jest)</summary>

### [`v29.4.11`](https://github.com/kulshekhar/ts-jest/blob/HEAD/CHANGELOG.md#29411-2026-05-21)

[Compare Source](https://github.com/kulshekhar/ts-jest/compare/v29.4.10...v29.4.11)

##### Bug Fixes

- preserve Bundler on the CJS path under TypeScript >= 6 ([3941818](https://github.com/kulshekhar/ts-jest/commit/39418187515f11b6584d35a4e3ddf50231f74936)), closes [#&#8203;4198](https://github.com/kulshekhar/ts-jest/issues/4198)

</details>

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined).

🚦 **Automerge**: Enabled.

♻ **Rebasing**: Whenever PR is behind base branch, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0MC42Mi4xIiwidXBkYXRlZEluVmVyIjoiNDAuNjIuMSIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOlsiZGVwZW5kZW5jaWVzIl19-->

Reviewed-on: https://git.unespace.com/julien/apf_portal/pulls/214
Co-authored-by: APF Portal Bot <jgautier.webdev+apf-portal-bot@gmail.com>
Co-committed-by: APF Portal Bot <jgautier.webdev+apf-portal-bot@gmail.com>
2026-05-24 05:15:53 +02:00
julien 8266e5b172 docs(adr-0026): person + user portal-side data model (proposed) (#213)
CI / check (push) Successful in 2m43s
CI / commits (push) Has been skipped
CI / scan (push) Successful in 2m36s
CI / a11y (push) Successful in 3m4s
CI / perf (push) Successful in 4m54s
Docs site / build (push) Successful in 4m50s
## Summary

[ADR-0025](docs/decisions/0025-authorization-model-privileges-roles-scopes.md) shipped the authorization model with three stubs explicitly deferred to a follow-up ADR:

- `Principal.user.id` and `Principal.user.personId` carry the Entra `oid` as a placeholder — `apps/portal-bff/src/auth/principal-builder.ts` documents the seam.
- `StubScopeResolver` returns `[{ kind: 'unrestricted' }]` for every signed-in user; the per-persona scope values documented in `notes/test-tenant-role-assignments.md` have nowhere to live.
- `@RequireScope`'s `ScopableResource` shape references an `Etablissement` / `Delegation` / `Region` chain that has no Prisma table.

ADR-0026 specifies the portal-side data model that closes those stubs. **Decision-only, status `proposed`** — implementation lands in two PRs once accepted.

## What lands

| File | Change |
| --- | --- |
| `docs/decisions/0026-person-user-portal-data-model.md` | New ADR. MADR 4.0.0 format. Tags: `data`, `backend`, `security`. ~310 lines. |
| `docs/decisions/README.md` | One new index row for 0026 (status `proposed`, 2026-05-24). |

## ADR scope

The ADR commits the **schema** for:

- **`Person`** golden record — stable identity, can exist without a portal account (Pléiades pre-provisioning, dossier bénéficiaires, alumni). `source` field tracks provenance (`self-signin` in v1; `pleiades` / `acteurs-plus` join the catalogue with ADR-0027).
- **`User`** portal-account overlay — one-to-zero-or-one with Person, lazy-created on first OIDC callback. Portal-only state (`lastSignInAt`, future a11y preferences) rides here, not on the shared Person row.
- **`UserScope`** — confirms the migration whose shape ADR-0025 §"Sources of truth — apf_portal-side `user_scopes` table" already specified.
- **`Region` / `Delegation` / `Etablissement`** — organisational hierarchy with externally-meaningful codes (INSEE / French dept / FINESS) as primary keys, matching the on-the-wire shape of the scope literals (`etablissement:0330800013`, `delegation:33`).

The ADR **explicitly defers**:

- Facet schemas (`Salarie`, `Adherent`, `Benevole`, `Elu`, `Beneficiaire`, `PartenaireExterne`) — they track upstream-system shape and ride alongside their producing sync.
- Pléiades / Acteurs+ sync logic, reconciliation policy between sync-owned and admin-UI-owned fields.

Both deferrals point at **ADR-0027** (Pléiades + Acteurs+ syncs + facet schemas) as the next-up ADR.

## Notes for the reviewer

- **Why `Person` + `User` split and not a single table.** The "Considered Options" section walks through this. Option A (User-only) breaks down the moment a dossier holder who never signs in needs a stable identifier. Option D (Person with embedded facet columns) forces every Pléiades or Acteurs+ schema change through a table that both shapes share, which is exactly the kind of coupling that ADR-0027 needs the freedom to design out.

- **Why externally-meaningful primary keys for the hierarchy.** `Region.code` / `Delegation.code` / `Etablissement.finess` are INSEE / FINESS codes — stable across reorgs and already on every URL the portal will mint. Adding a separate UUID would force every consumer to indirect through it for no gain. The trade-off (no in-place rename — if a délégation merges, delete + re-insert with the new code) is bounded by how rarely INSEE rebases.

- **Lazy User creation at first sign-in.** v1 has no Pléiades data; the OIDC callback creates the Person + User pair the first time it sees a new `oid`. When Pléiades sync ships (ADR-0027), the same provisioner extends with a `Person.externalId` lookup before falling back to `email`. The schema does not change between the two regimes — only the provisioner's lookup order does.

- **`Person.source` and `Etablissement.kind` as enum-as-string + drift-gate extension.** The catalogue-drift gate ([ADR-0025 §"Confirmation"](docs/decisions/0025-authorization-model-privileges-roles-scopes.md) and `scripts/check-catalogue-drift.mjs`) was built precisely to constrain hand-edited string columns. Extending it to assert every `Person.source` write is in the closed catalogue closes one of the soft spots in the v1 schema without standing up a Postgres ENUM.

- **PII posture.** `Person.firstName` / `lastName` / `email` are flagged in the ADR's Decision Drivers. The Pino redact list ([ADR-0012](docs/decisions/0012-observability-pino-opentelemetry.md)) and the audit-log salt ([ADR-0013](docs/decisions/0013-audit-trail-separated-postgres-append-only.md)) already cover Entra `oid`; the new PII fields ride the same posture (caller-redacted at the audit module, redacted at the Pino logger before the line ships).

- **What "two PRs" means in the More Information section.** PR 1: Prisma schema migration + `PersonAndUserProvisioner` called from `SessionEstablisher` + drift-gate extension. PR 2: `PrismaScopeResolver` replacing `StubScopeResolver` + `/admin/users/:id/scopes` screen + `prisma/seed.ts` populating the 19 test-tenant personas' `user_scopes` rows.

- **Backward compatibility for in-flight Redis sessions.** Sessions minted before the schema migration carry a Principal whose `user.id` is the Entra `oid` placeholder. The legacy-session bridge in [`apps/portal-bff/src/auth/principal-extractor.ts`](apps/portal-bff/src/auth/principal-extractor.ts) (added in #208) already handles this — when the migration deploys, those sessions continue to work for the 12 h absolute-TTL window, after which every session in Redis has the real `personId`.

## Open questions to resolve before acceptance

These were flagged in the ADR text and are worth surfacing here for the review pass:

- **`Person.email` as the v1 dedup key.** The ADR's "Bad, because" item — two Pléiades records sharing an email would crash the unique constraint. ADR-0027 will add the `externalId` precedence, but in the interim the v1 lazy-creator either trusts emails-are-unique (acceptable for the test tenant where every persona has a distinct one) or skips the dedup attempt and creates a fresh Person per `oid`. The ADR currently picks the dedup-by-email path; flag if the safer choice is "always fresh, reconcile later".
- **Should `Region` / `Delegation` / `Etablissement` migrations be seeded as part of the schema PR, or kept as a separate dataset import?** The ADR is silent on this; my default is "seed the geographic codes APF actually operates in" (a one-off SQL fixture in the migration directory). Worth confirming.

## Test plan

- [x] `pnpm exec prettier --check docs/decisions/0026-person-user-portal-data-model.md docs/decisions/README.md` — clean.
- [ ] **Review focus** — the chosen Option B vs the rejected A/C/D, the deferred facets list, the `Person.source` catalogue, the `Etablissement.kind` catalogue, and the two open questions above.
- [ ] Once accepted, the implementation phasing in the ADR's `§More Information` opens (2 PRs).

## What's next

- **This PR** — ADR-0026 ships as `proposed`.
- **Acceptance PR** — review pass, address open questions, promote `proposed → accepted` (same cadence as [#201#205](docs/decisions/0025-authorization-model-privileges-roles-scopes.md) for ADR-0025).
- **Implementation PR 1** — Prisma schema + lazy provisioner.
- **Implementation PR 2** — `PrismaScopeResolver` + admin-UI scope-seeding + test-tenant seed.
- **ADR-0027** — Pléiades + Acteurs+ syncs + facet schemas (the deferred surface from this ADR).

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #213
2026-05-24 02:19:29 +02:00
julien ca3963b2e3 docs(claude.md): refresh Repository status with ADR-0022/0023/0025 shipments (#212)
CI / check (push) Successful in 3m51s
CI / commits (push) Has been skipped
CI / scan (push) Successful in 3m58s
CI / a11y (push) Successful in 1m54s
CI / perf (push) Successful in 5m16s
## Summary

`CLAUDE.md`'s **Repository status** section drifted from `main` while three ADRs landed back-to-back. The roadmap block still listed ADR-0022 (docs static site) and ADR-0023 (charts lib + audit dashboards) as upcoming chantiers even though both shipped, and the new `libs/shared/auth/` lib introduced by ADR-0025 was not in the lib-roots list.

This PR refreshes the section to reflect what `main` actually carries today. No code touched.

## What lands

| File | Change |
| --- | --- |
| `CLAUDE.md` | Repository status section rewritten: lib roots updated, three new "Shipped on `main`" bullets (ADR-0022 / 0023 / 0025), roadmap pruned and rebalanced around what is genuinely outstanding. |

## Notes for the reviewer

- **Lib roots.** Was: "four lib roots (`libs/feature/`, `libs/shared/state`, `libs/shared/tokens`, `libs/shared/ui`, `libs/shared/util`)" — five entries called "four". Now: "seven lib roots" listing `libs/feature/auth`, `libs/shared/auth`, `libs/shared/charts`, `libs/shared/state`, `libs/shared/tokens`, `libs/shared/ui`, `libs/shared/util`. The count is correct and the new libs from ADR-0023 and ADR-0025 are explicit.

- **CI line** now mentions the catalogue-drift gate alongside the standard `format:check / lint / test / build` quartet — readers grepping for "what CI runs" land on the actual gate set.

- **AI relay entry merged with its live consumer.** The previous wording said "Live consumer (chatbot widget on `portal-shell`) and the proto-drift CI gate ship next" — both half-true: chatbot widget shipped (under `apps/portal-shell/src/app/features/chatbot/`), proto-drift gate did not. Entry now says chatbot is live and moves the proto-drift gate to the roadmap.

- **Phase-3a admin app phrasing updated.** Previous wording: "business modules (CMS, menu management, user list, audit log viewer) not yet implemented". Two of those four exist on `main` today: `/admin/users` (user-list reader via `admin-users-reader.service.ts`) and `/admin/audit` (audit-log viewer + statistics + integrated charts). Entry now names them and notes that only CMS + menu management remain.

- **Roadmap entry split for the guards.** The old line bundled `@RequireMfa()` and `@RequireAdmin()` as both "designed-in, awaiting first consumer route". `@RequireAdmin` is wired into every `/api/admin/*` controller today; only `@RequireMfa` is still consumer-less. Roadmap line narrowed to mention only `@RequireMfa`.

- **ADR-0026 referenced as `(proposed)`.** The link target is a placeholder `(#)` because the ADR file does not exist yet — kept lowercase-light so the link does not render as a dead anchor in editors. Will become a real link in the same PR that drafts ADR-0026.

## Test plan

- [x] `pnpm exec prettier --check CLAUDE.md` — clean.
- [x] Manual cross-check against the workspace state:
  - `find libs/shared -maxdepth 1 -type d` matches the listed lib roots.
  - `ls docs/.vitepress/` confirms VitePress is wired.
  - `ls libs/shared/charts/src/lib/` confirms the three chart components exist.
  - `grep -l BarChart\\|DonutChart\\|StackedBarChart apps/portal-admin/src/app/pages/audit/` confirms the audit-page integration.
  - `ls scripts/check-catalogue-drift*` confirms the drift gate exists.
  - `grep -rn @RequireAdmin apps/portal-bff/src/admin/` confirms the admin gate is wired today.

No CI gates affected — this is a doc-only change.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #212
2026-05-24 01:32:20 +02:00
julien 2eb01f59b3 feat(ci): catalogue-drift gate for @RequirePrivilege/@RequireRole literals (ADR-0025) (#211)
CI / check (push) Successful in 3m4s
CI / commits (push) Has been skipped
CI / scan (push) Successful in 2m53s
CI / a11y (push) Successful in 3m50s
CI / perf (push) Successful in 5m23s
Docs site / build (push) Successful in 5m26s
## Summary

Phase 3 (and last) of [ADR-0025](docs/decisions/0025-authorization-model-privileges-roles-scopes.md)'s implementation phasing (§"More Information" / §347): a small CI gate that asserts every string literal passed to the authorization decorators belongs to the closed catalogue declared in `libs/shared/auth/src/lib/authorization.types.ts`.

The TypeScript decorator signatures (`(...privileges: [Privilege, ...Privilege[]])`) already enforce this at compile time. The gate is **defence-in-depth** against the escape hatches the type system cannot catch:

- explicit `as Privilege` / `as FunctionalRole` casts (`'Portal.Foo' as Privilege`),
- the rare case where a developer hand-edits the catalogue type union without updating the runtime constant.

Runs in `<1s`, so it rides the existing `check` CI job rather than spinning up its own.

## What lands

| File | Role |
| --- | --- |
| `scripts/check-catalogue-drift.mjs` | The gate. TypeScript compiler API. Parses `authorization.types.ts` to extract the catalogues, walks every `.ts` file under `apps/` and `libs/`, finds each `CallExpression` whose callee identifier matches `RequirePrivilege` / `RequireRole`, validates every string-literal argument. Non-literal args are skipped (the TypeScript signature catches them already). Reports grouped by file with `line:column` per violation, exits 1 on drift. |
| `scripts/check-catalogue-drift.spec.mjs` | 13 tests via `node --test` (built-in runner, no Vitest dep). Covers catalogue parsing, file-level violation detection, workspace-wide aggregation, skipped folders, gen-stub exclusion, line/column reporting. |
| `package.json` | `ci:catalogue-drift` + `ci:catalogue-drift:test` scripts. |
| `.gitea/workflows/ci.yml` | The existing `check` job now runs the gate's unit tests first (fail-fast if the gate itself is broken), then the gate against the live workspace. |

## Notes for the reviewer

- **Why not an ESLint custom rule.** ADR-0025 §347 floats either option. The ESLint route would give editor-time feedback (red squiggles) but means standing up a local ESLint plugin lib — net ~300 lines of plumbing for a gate the TypeScript signature already enforces in real time via the literal-union types. The pnpm-script route mirrors the existing `ci:gzip-budgets` pattern; ~250 lines including tests; runs in the same `check` CI job that already installs deps. Editor feedback is **already provided by `tsc`**, so the gate's job reduces to "catch escape hatches in CI", which the script does fine.

- **Why `node --test` rather than Vitest / Jest.** The script lives in `scripts/`, outside any Nx project. Wiring Vitest for one spec file would mean a vitest.config + tsconfig.spec + Nx project just to host it. Node's built-in test runner ships with the runtime we already pin (Node 24 in `.nvmrc`), no config, ~250ms wall clock for 13 tests.

- **Generated gRPC stubs are skipped.** `apps/portal-bff/src/grpc/gen/apf-ai/common.ts` defines a `roles: string[]` proto field used by the AI-bridge — entirely unrelated to the ADR-0025 functional-role catalogue. The skip list is explicit (`SKIPPED_SUBPATHS`); adding a new codegen output later is one line.

- **Spec files are NOT skipped.** `auth-guards.persona-matrix.spec.ts` references catalogue values via the decorators in its test fixtures. Those are deliberate references and must stay in sync — if a future ADR amendment removes a role, the spec catches it on the same run as the production code. Explicitly tested in `does NOT skip spec files`.

- **Non-literal arguments (variable indirection) are skipped.** A pattern like `const slug = getRole(); @RequireRole(slug)` would not be caught by string-literal inspection. This is intentional: the TypeScript decorator signature already requires `slug` to be typed `FunctionalRole`, so the type system handles it; the gate's value-add is on literal misspellings the type system cannot see past an `as Privilege` cast.

- **Self-test confirmed the gate works.** Injected `RequirePrivilege('Portal.RogueDrift')` and `RequireRole('rogue-role-x')` into an existing spec, ran `pnpm ci:catalogue-drift`, observed:

  ```
  catalogue-drift: violations found

    apps/portal-bff/src/auth/require-privilege.guard.spec.ts
      135:18  @RequirePrivilege('Portal.RogueDrift') — not in PRIVILEGES
      136:13  @RequireRole('rogue-role-x') — not in FUNCTIONAL_ROLES
  ```

  Exit code 1. Reverted the injection; gate green again.

- **Adding a third decorator** (per ADR-0025's anticipated growth) is one line in `DECORATOR_TO_CATALOGUE` plus the matching test fixture. The script does not hardcode the decorator name list beyond that map.

- **Why no scope-kind check.** `@RequireScope` takes an `(req) => ScopableResource` extractor, not literals. The scope `kind` values are constrained by the `ScopeKind` discriminated union, which TypeScript enforces at every `{ kind: ... }` construction site. No literal escape hatch worth gating in v1.

## Test plan

- [x] `pnpm ci:catalogue-drift:test` — 13/13 green via `node --test`.
- [x] `pnpm ci:catalogue-drift` — clean against the live workspace: `catalogue-drift: clean (catalogues: 4 privileges, 24 roles).`
- [x] Self-test by injection — script catches `'Portal.RogueDrift'` and `'rogue-role-x'` with file:line:column, exits 1.
- [x] `pnpm exec prettier --check` on the new files — clean.
- [ ] CI run on this PR exercises the new step in the `check` job.

## What's next

ADR-0025's phasing closes with this PR. Remaining authorization work waits on [ADR-0026](docs/decisions/) (proposed) — the `Person` + `User` schema brings the Prisma-backed `user_scopes` table; that PR replaces `StubScopeResolver` with a `PrismaScopeResolver` and unlocks the first concrete `@RequireScope` consumer surfaces.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #211
2026-05-24 01:07:13 +02:00
julien 77af6951c9 fix(deps): bump uuid to >=11.1.1 via pnpm.overrides (GHSA-w5hq-g745-h8pq) (#210)
CI / commits (push) Has been skipped
CI / check (push) Successful in 4m55s
CI / scan (push) Successful in 2m36s
CI / a11y (push) Successful in 3m54s
CI / perf (push) Successful in 5m40s
Docs site / build (push) Successful in 5m29s
## Summary

`pnpm ci:audit` was failing on `main` with one moderate-severity finding:

```
uuid: Missing buffer bounds check in v3/v5/v6 when buf is provided
GHSA-w5hq-g745-h8pq
Vulnerable: uuid < 11.1.1
Path:       . > @lhci/cli@0.15.1 > uuid@8.3.2
```

`@lhci/cli@0.15.1` is the latest release and still ships `uuid@8.3.2`; no upstream fix yet. Renovate cannot solve this on its own.

## What lands

| File | Change |
| --- | --- |
| `package.json` | `pnpm.overrides`: add `"uuid@<11.1.1": ">=11.1.1"` alongside the existing axios / ajv / brace-expansion / esbuild / follow-redirects / ip-address / protobufjs / tmp / ws / yaml entries. |
| `pnpm-lock.yaml` | Refreshed. Both `@lhci/cli` and `sockjs` (transitive via the rspack / webpack dev-servers) now resolve to `uuid@14.0.0` — the same major already in the tree via `mermaid`. Net `uuid` versions: 2 → 1. |

## Notes for the reviewer

- **Why an override and not an `auditConfig.ignoreGhsas`.** The repo already uses `pnpm.overrides` for 10 prior advisories on transitive deps with no patched release in their consumer — same pattern applied here. An exemption-document path was drafted then dropped: forcing the fix is cleaner than waving away the warning, and the upgrade actually works.

- **Why our usage was not at risk (context, not justification to skip).** The advisory affects `uuid.v3` / `uuid.v5` / `uuid.v6` when called with the optional `buf` argument. Both consumers in the tree only use `uuid.v4()` (random):
  - `@lhci/cli/src/collect/node-runner.js:65` — `\`flags-${uuid.v4()}.json\``
  - `sockjs/lib/transport.js:9` — `uuidv4 = require('uuid').v4`
  But the audit gate is set to `moderate` per [ADR-0015](docs/decisions/0015-cicd-gitea-actions.md); every flagged advisory blocks merge regardless of reachability. Forcing the fix is the documented response.

- **Why `uuid@14` works for the CJS consumers.** `uuid@14` is ESM-only (`type: module` + conditional `exports`). `@lhci/cli` does `const uuid = require('uuid')` which is a CJS-requires-ESM pattern. Node `>= 22.12` supports it natively via the stable `require(esm)` capability; the workspace pins Node 24 in [`.nvmrc`](.nvmrc), so the consumer is fine. Verified locally:

  ```
  $ node -e "require('@lhci/cli/src/collect/node-runner.js')"
  # no error
  ```

- **Side benefit.** `uuid@8.3.2` was also pulled in transitively via `sockjs`. After the override, the dep tree consolidates onto a single `uuid@14.0.0` — removes the `Found 2 versions of uuid` dedup notice and aligns with the version `mermaid` already uses.

- **Closure condition.** When `@lhci/cli` ships a release with `uuid >= 11.1.1` (or with `uuid@14` directly), Renovate will surface the bump; this override row becomes redundant and can be deleted. Same removal cadence as the other `pnpm.overrides` entries.

## Test plan

- [x] `pnpm ci:audit` — clean: `No known vulnerabilities found`.
- [x] `pnpm ci:check` — 13 projects green (no behavioural change against the previous run).
- [x] Loaded `@lhci/cli`'s node-runner module under the override to confirm `require('uuid')` resolves without error on Node 24.
- [ ] Post-merge CI on `main` runs `pnpm ci:audit` cleanly.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #210
2026-05-24 00:40:38 +02:00
julien 5f1ef2444a fix(ci): align shared-auth build outputPath with tsconfig.lib.json outDir (#209)
CI / check (push) Successful in 3m43s
CI / commits (push) Has been skipped
CI / scan (push) Failing after 3m38s
CI / a11y (push) Successful in 1m20s
CI / perf (push) Successful in 5m1s
## Summary

`pnpm ci:check` was failing on `main` after #208 merged: `portal-bff:build` raised `TS6305 Output file 'dist/out-tsc/libs/shared/auth/src/index.d.ts' has not been built from source file 'libs/shared/auth/src/index.ts'` against every file in `apps/portal-bff/src/auth/` that imports from `shared-auth`. Fresh CI runners hit it; my local runs masked it because a manual `tsc --build` during development had populated both candidate output dirs.

## Root cause

When `nx sync` was first run for #206, it added a TypeScript project reference to `apps/portal-bff/tsconfig.app.json`:

```json
"references": [{ "path": "../../libs/shared/auth" }]
```

`portal-bff`'s build runs webpack with `compiler: 'tsc'` against that tsconfig, so tsc validates the referenced project's outputs exist at the location its tsconfig.lib.json declares as `outDir` — `dist/out-tsc/libs/shared/auth/`.

But `libs/shared/auth/project.json` set `outputPath: dist/libs/shared/auth` and Nx's `@nx/js:tsc` executor honours its own `outputPath` over the tsconfig's `outDir`, emitting the declarations to `dist/libs/shared/auth/` instead. tsc looks at the empty `dist/out-tsc/libs/shared/auth/` location and fails with TS6305.

This was harmless until #208: the previous PRs only had a single `import` from `shared-auth` (in `principal-builder.ts`), and apparently tsc's incremental cache from the local dev box's pre-fix runs was reused in CI. #208 added enough new imports across enough files that the missing declarations became unavoidable.

## What lands

| File | Change |
| --- | --- |
| `libs/shared/auth/project.json` | `outputPath: dist/libs/shared/auth` → `dist/out-tsc/libs/shared/auth`. Aligns Nx's emit location with the tsconfig.lib.json's declared `outDir`, so the project-reference validation finds the declaration files. |

One-line change, no other files touched.

## Notes for the reviewer

- **Why this didn't show in the green CI on #206 / #208.** Both PRs ran the same `nx affected -t format:check lint test build` recipe and both reported green. The likely explanation is that during PR-level runs Nx's distributed cache hit on the `portal-bff:build` task (the inputs included the source files but the cache stored the *result* of the build, which was green from an earlier successful run before the project reference landed). Post-merge runs on `main` evict the cache differently (different commit SHA → cache miss → tsc actually re-runs and hits the missing declarations).

- **Why `shared-util`, `shared-tokens`, etc. don't have the same problem.** They share the exact same outputPath / outDir mismatch in their project.json + tsconfig.lib.json, but they're only consumed by the Angular SPAs (`portal-shell`, `portal-admin`). `@angular/build` does not validate TypeScript project references the same way `webpack + compiler: 'tsc'` does, so the mismatch sits silently. `shared-auth` is the first lib in the workspace that crosses into `portal-bff`, which is why the trap surfaced now.

- **Alternative considered: drop the project reference in `portal-bff/tsconfig.app.json`.** Path aliases in `tsconfig.base.json` resolve `shared-auth` to source files directly, so webpack would still find the imports. Rejected because `nx sync` adds project references back automatically — the alignment-of-paths fix is more durable than a configuration deletion.

- **Why not align the other shared libs preemptively.** They aren't broken — their project references aren't being validated. Touching them in the same PR would expand the diff for zero current benefit. The pattern documented here is what the next lib that's consumed by `portal-bff` will need; the precedent is on the books.

- **Locally reproducible.** `pnpm nx reset && rm -rf dist .nx/cache .nx/workspace-data && pnpm ci:check` reproduces the original failure on the previous commit and passes on this commit.

## Test plan

- [x] `pnpm nx reset && rm -rf dist .nx/cache .nx/workspace-data && pnpm nx affected -t format:check lint test build --base=main --skip-nx-cache` — 3 projects green from a fully cold state.
- [ ] Post-merge CI on `main` runs through `pnpm ci:check` cleanly.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #209
2026-05-23 23:33:20 +02:00
julien 7d91da691b feat(auth): @RequirePrivilege/@RequireRole/@RequireScope guards (ADR-0025) (#208)
CI / check (push) Failing after 4m35s
CI / commits (push) Has been skipped
CI / scan (push) Failing after 3m52s
CI / a11y (push) Successful in 1m59s
CI / perf (push) Successful in 5m50s
## Summary

Phase 2 of [ADR-0025](docs/decisions/0025-authorization-model-privileges-roles-scopes.md)'s implementation phasing (§"More Information"). The three new route decorators land — `@RequirePrivilege`, `@RequireRole`, `@RequireScope` — alongside the migration of the legacy `@RequireAdmin` to read from `principal.privileges`. Each guard consumes the session-resident `Principal` built by [#206](#206), evaluates its requirement, and either passes or emits a 403 + audit row.

The drift-CI gate (phase 3) is **not** in this PR — it lands next, once the catalogue is being referenced from real route handlers.

## What lands

### Shared lib (`libs/shared/auth`)

| File | Role |
| --- | --- |
| `src/lib/principal-matchers.ts` | Pure functions: `principalHasAnyPrivilege` / `principalHasAnyRole` / `principalCoversResource`. The matchers live in the shared lib so the SPA can render UI predicates with the same logic the BFF enforces server-side. |
| Same file | `ScopableResource` type — optional FINESS / delegation / region / siege parentage. Callers populate the subset their route resource exposes; the matcher iterates and returns true on the first scope that covers a present field. |
| `src/lib/principal-matchers.spec.ts` | 24 unit tests covering OR-composition, the empty-requirement degenerate case (treated as "no constraint" — pass), every scope kind, and the resource-side parentage chain. |

### BFF guards + decorators (`apps/portal-bff/src/auth`)

| File | Role |
| --- | --- |
| `require-privilege.{decorator,guard}.ts` | `@RequirePrivilege('Portal.Admin', ...)` — type-locked to the closed `Privilege` catalogue; multiple values OR-combine. |
| `require-role.{decorator,guard}.ts` | `@RequireRole('rh', ...)` — same shape, against `FunctionalRole`. |
| `require-scope.{decorator,guard}.ts` | `@RequireScope(req => extractResource(req))` — extractor can be async (Prisma lookup pattern); returning `null` is treated as denial with empty `required[]`. |
| `principal-extractor.ts` | Single read of `Principal` off the session, with a legacy-session bridge for principals minted before [#206](#206) landed — filters `user.roles` for `Portal.*` values and synthesises an `unrestricted` scope. After the 12 h absolute-TTL window post-deploy, the bridge becomes dead code. |
| `principal-extractor.spec.ts` | 7 tests covering both code paths. |
| `require-{privilege,role,scope}.guard.spec.ts` | Unit tests for each guard: 401 on anonymous, 403 + audit on denial, pass on match, generic `forbidden` response body (no role/privilege/resource hint leaks). |
| `auth-guards.persona-matrix.spec.ts` | Integration tests: each of the 19 test-tenant personas walked through 5 representative privilege guards + 6 representative role guards. The matrix proves the contracts hold against the real provisioning, not just synthesised principals. |

### Audit module

| File | Change |
| --- | --- |
| `audit.types.ts` | New `AuthorizationDeniedInput` discriminated by `kind: 'privilege' \| 'role' \| 'scope'`. Carries `required[]` and `held[]` arrays so an auditor can pivot on "tried admin while logged in as RH" patterns without joining anything. |
| `audit.service.ts` | New `authorizationDenied()` method emitting the `auth.authorization_denied` event type. Distinct from the existing `admin.access_denied` so admin-surface signals stay clean. |
| `audit.service.spec.ts` | 3 new tests covering the three `kind` values. |

### Legacy guard migration

| File | Change |
| --- | --- |
| `admin/admin-role.guard.ts` | Reads `principal.privileges` instead of `user.roles`. Public `@RequireAdmin()` API unchanged; `admin.access_denied` event type unchanged. The audit row's `rolesHeld` field now carries `Portal.*` values rather than the raw legacy `roles` claim. |
| `admin/admin-role.guard.spec.ts` | Rewritten to exercise `session.principal`-shaped sessions, with a dedicated legacy-bridge sub-suite that asserts pre-ADR-0025 sessions still work. |
| `me/me.controller.ts` | `capabilities().canAccessAdmin` reads `principal.privileges` via the same extractor. |
| `me/me.controller.spec.ts` | Rewritten against the principal shape. |

### AuthModule wiring

| File | Change |
| --- | --- |
| `auth/auth.module.ts` | Three new guards registered as providers and re-exported. |

## Notes for the reviewer

- **Composition semantics.** Within a single decorator, values OR-combine (`@RequireRole('rh', 'comptable')` matches anyone with either). Stacking decorators AND-combines at the Nest level — `@RequireRole('rh') @RequirePrivilege('Portal.Admin')` requires both, because each guard runs separately and a single denial stops the request. The empty-requirement case is rejected at the type level by the tuple `[Privilege, ...Privilege[]]` so a route can not opt out of authorization by passing zero values.

- **Why `auth.authorization_denied` and not `admin.access_denied` for the new guards.** ADR-0025 §347 originally said "writes an `admin.access_denied` row", but reusing the `admin.*` namespace would have meant the future `@RequireRole('rh')` on a non-admin HR route ships an event whose type implies an admin surface. The new event type lives in the `auth.*` namespace where the guards live; a `kind` discriminator on the payload tells privilege / role / scope denials apart. The legacy `admin.access_denied` event continues unchanged for `AdminRoleGuard`.

- **The legacy-session bridge is short-lived.** After the 12 h absolute-TTL window from this PR's deploy, every session in Redis has a `principal` field and the bridge's `else` branch becomes unreachable. The bridge keeps `@RequireAdmin` + `MeController` functional during that window without needing a forced re-auth campaign.

- **Why a `ScopableResource` shape rather than a `Resource | Resource | ...` union per kind.** Routes protect heterogeneous resource types (`Etablissement`, `Dossier`, `Person`), each exposing a different subset of the parentage chain. The optional-field shape lets each route populate what its resource carries and lets the matcher iterate over the principal's scopes once. The cost is that a route author who forgets to populate `delegationCode` on an `Etablissement` resource locks delegation-scoped users out — a typing exercise that the next round of work (concrete consumers) will surface naturally.

- **Why `principalCoversResource` deny on doubt.** When a route's extractor returns a resource that lacks the parentage `delegationCode` field, a `delegation:33` scope no longer matches — deny is the safe direction. An over-restrictive deny shows up in the audit log (operator can fix the extractor); an over-permissive pass would silently leak data. The non-transitivity of the parentage chain is an intentional contract.

- **Why migrate `MeController` in the same PR.** The `canAccessAdmin` flag reads the same axis (`Portal.Admin` privilege). Leaving it on the legacy `user.roles` shape while everything else moves to `principal.privileges` would create internal inconsistency; a future reviewer would rightfully ask "why?". The migration is three lines plus a spec rewrite.

- **The drift-CI gate is the next PR.** ADR-0025 §"More Information" step 3. ESLint custom rule (or a `pnpm run` script) grepping every `@RequirePrivilege('...')` / `@RequireRole('...')` / scope-kind literal in the codebase and asserting each one exists in the catalogue. Now there is something to grep for (the decorators and their tests reference catalogue values), so the gate is well-scoped to land standalone.

- **No real consumers of `@RequireScope` yet.** The scope guard ships with a fully exercised contract (extractor signature, async support, audit shape, generic-forbidden body) but no live route. The first consumer surface lands with the `Person` + `User` schema (proposed ADR-0026) and the resource-loading routes that follow.

## Test plan

- [x] `pnpm nx affected -t lint test build --base=main` — 3 projects green
  - `portal-bff`: 741 tests pass (was 497 in #206 — +244 new: matchers/guards/persona-matrix/audit/extractor).
  - `shared-auth`: 41 tests pass (was 17 in #206 — +24 new matcher tests).
  - `portal-bff-e2e`: lint green.
- [x] `pnpm nx format:check` — clean after `pnpm nx format:write`.
- [ ] **Review focus** — the 19-persona matrix (each persona walked through 5 privilege guards + 6 role guards); the legacy-session bridge in `principal-extractor.ts` and its tests; the `admin.access_denied` audit payload now carries `Portal.*` values rather than legacy `roles` (intentional, called out above); the `auth.authorization_denied` event-type rationale.

## What's next

Per ADR-0025 §"More Information" phasing:

1.  #206 — types + Principal builder + group-to-role mapping skeleton.
2.  **This PR** — the three new decorators + guards, legacy `@RequireAdmin` migration.
3. **Next PR** — drift CI gate. ESLint custom rule (or `pnpm run` script) that asserts every `@RequireX('...')` literal in code is in the closed catalogue.
4. **Depends on ADR-0026** — `user_scopes` Prisma table + seed + `PrismaScopeResolver` replacing `StubScopeResolver`, then the first concrete consumers of `@RequireScope`.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #208
2026-05-23 21:49:34 +02:00
julien a34709613f fix(ci): refresh lockfile for the new shared-auth lib (#207)
CI / commits (push) Has been skipped
CI / check (push) Failing after 6m39s
CI / a11y (push) Successful in 5m6s
CI / scan (push) Failing after 8m10s
CI / perf (push) Successful in 9m37s
Docs site / build (push) Successful in 4m40s
## Summary

The auth-catalogue skeleton PR (#206) added `libs/shared/auth/` with three dev dependencies (`tslib`, `vitest`, `@nx/vite`) in the new lib's `package.json` but did not refresh `pnpm-lock.yaml`. As a result the post-merge `pnpm install --frozen-lockfile` step in CI on `main` fails with:

```
ERR_PNPM_OUTDATED_LOCKFILE  Cannot install with "frozen-lockfile" because
pnpm-lock.yaml is not up to date with <ROOT>/libs/shared/auth/package.json
specifiers in the lockfile don't match specifiers in package.json:
* 3 dependencies were added: tslib@^2.3.0, vitest@^4.0.8, @nx/vite@^22.7.1
```

Running `pnpm install` locally adds the missing `importers["libs/shared/auth"]` block. No other dependency moves — every other version is pinned to what `main` already resolved.

## What lands

| File | Change |
| --- | --- |
| `pnpm-lock.yaml` | +12 lines: new `importers["libs/shared/auth"]` entry with the three deps resolved to `tslib@2.8.1`, `vitest@4.1.6`, `@nx/vite@22.7.2` — same versions as the other `libs/shared/*` libs already use. |

## Notes for the reviewer

- **Why this didn't surface during #206's CI run.** PR-level CI runs against the branch's `pnpm-lock.yaml` *after* `pnpm install` had refreshed it locally during the work. The lockfile delta should have been part of #206 but was missed at commit time. Post-merge CI on `main` is the first run that exercises a clean `--frozen-lockfile` against the merged tree, which is when the gap shows.
- **Why not amend #206.** It's already on `main`. A separate small fix-up PR is cleaner than re-opening the merged history.
- **Preventing recurrence.** Standard Nx flow: every PR that creates a new lib or moves a dep should run `pnpm install` and stage the lockfile alongside the source. A future drift-gate (mentioned in ADR-0025 §"Confirmation" for catalogue drift) could be extended to also assert lockfile-vs-package-json alignment, but that's out of scope for this fix.

## Test plan

- [x] `pnpm install --frozen-lockfile` succeeds locally on this branch.
- [ ] Post-merge CI on `main` runs through the install step cleanly.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #207
2026-05-23 21:21:59 +02:00
APF Portal Bot 273d96551f chore(deps): update dependency @swc/core to v1.15.40 (#204)
CI / commits (push) Has been skipped
CI / check (push) Failing after 16s
CI / perf (push) Failing after 32s
CI / a11y (push) Failing after 34s
CI / scan (push) Failing after 53s
Docs site / build (push) Failing after 21s
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| [@swc/core](https://swc.rs) ([source](https://github.com/swc-project/swc/tree/HEAD/packages/core)) | devDependencies | patch | [`1.15.33` -> `1.15.40`](https://renovatebot.com/diffs/npm/@swc%2fcore/1.15.33/1.15.40) |

---

### Release Notes

<details>
<summary>swc-project/swc (@&#8203;swc/core)</summary>

### [`v1.15.40`](https://github.com/swc-project/swc/blob/HEAD/CHANGELOG.md#11540---2026-05-23)

[Compare Source](https://github.com/swc-project/swc/compare/v1.15.33...v1.15.40)

##### Bug Fixes

- **(es/minifier)** Preserve args for destructured callbacks ([#&#8203;11830](https://github.com/swc-project/swc/issues/11830)) ([21873b0](https://github.com/swc-project/swc/commit/21873b06df3fd62d952a21cf879e14d11d4b39d7))

- **(es/minifier)** Avoid generating mangled property names that collide with existing properties ([#&#8203;11839](https://github.com/swc-project/swc/issues/11839)) ([9b4fab5](https://github.com/swc-project/swc/commit/9b4fab58c90256a6da688de87ea405225a5a6fdb))

- **(es/minifier)** Respect ecma for iife temp vars ([#&#8203;11873](https://github.com/swc-project/swc/issues/11873)) ([e481934](https://github.com/swc-project/swc/commit/e481934a63c0ee891e4a770c4f0cd5ec3fd8624e))

- **(es/minifier)** Preserve default parameter object props ([#&#8203;11884](https://github.com/swc-project/swc/issues/11884)) ([71ff84f](https://github.com/swc-project/swc/commit/71ff84f19762306ab9b86accb29eb6ed83c46f84))

- **(es/parser)** Reject object-rest assignment to array/object literal ([#&#8203;11875](https://github.com/swc-project/swc/issues/11875)) ([7b57d1f](https://github.com/swc-project/swc/commit/7b57d1f8717d8bf6be0b617b04bc6e219a2b3775))

- **(es/parser)** Reject object rest assignment to literals ([#&#8203;11881](https://github.com/swc-project/swc/issues/11881)) ([4ec2eaf](https://github.com/swc-project/swc/commit/4ec2eaf4d89ddd95293b8f09169a88b0434c5a13))

- **(es/react)** Exclude self-recursive hooks from refresh dependency array ([#&#8203;11838](https://github.com/swc-project/swc/issues/11838)) ([9101c71](https://github.com/swc-project/swc/commit/9101c719fa8f3f5cb410d716d4f50544650cd81e))

- **(ts/fast-dts)** Strip definite assertions in dts ([#&#8203;11858](https://github.com/swc-project/swc/issues/11858)) ([2ab1b8a](https://github.com/swc-project/swc/commit/2ab1b8a50f2af3d8b4c42d6c4dd4f2051940cae0))

- **(ts/fast-strip)** Reject unsafe assertion erasure in binary expressions ([#&#8203;11828](https://github.com/swc-project/swc/issues/11828)) ([aa5b539](https://github.com/swc-project/swc/commit/aa5b539b277dbf4c68c87380d16f4b8713145df3))

- **(typescript)** Strip parameter binding defaults in dts ([#&#8203;11857](https://github.com/swc-project/swc/issues/11857)) ([800bc17](https://github.com/swc-project/swc/commit/800bc170334a74191eb5ae21e3bfc96bf6f7fe56))

##### Documentation

- Update agent guidance ([#&#8203;11842](https://github.com/swc-project/swc/issues/11842)) ([bf2d015](https://github.com/swc-project/swc/commit/bf2d0154cf8b66fdab16085585fda0086d297a64))

- Add security policy ([#&#8203;11876](https://github.com/swc-project/swc/issues/11876)) ([6c43c2d](https://github.com/swc-project/swc/commit/6c43c2de9cb9d5516b0ac87101345940964e943e))

- Clarify security scope for npm packages ([#&#8203;11877](https://github.com/swc-project/swc/issues/11877)) ([4662db8](https://github.com/swc-project/swc/commit/4662db8fe3e503f298a285697ea63ecc1ca3b958))

- Clarify untrusted input security model ([#&#8203;11882](https://github.com/swc-project/swc/issues/11882)) ([5463777](https://github.com/swc-project/swc/commit/546377770e164aead174404fb678319c9c56a9dc))

##### Features

- **(es/minifier)** Fine grained effect analysis of class ([#&#8203;11814](https://github.com/swc-project/swc/issues/11814)) ([c9058ad](https://github.com/swc-project/swc/commit/c9058adb5bb7d6bbe354e6136685271f722354a0))

- **(swc\_cli)** Implement all features for `swc_cli` ([#&#8203;11797](https://github.com/swc-project/swc/issues/11797)) ([9300ede](https://github.com/swc-project/swc/commit/9300ede1d495463042da1db11754c76057a50954))

##### Miscellaneous Tasks

- **(es/minifier)** Fix typo in debug log ([#&#8203;11866](https://github.com/swc-project/swc/issues/11866)) ([3de0254](https://github.com/swc-project/swc/commit/3de0254db7fae5a2883af78b8b7d57af4cb94531))

- **(html)** Add webcontainer fallback for `@swc/html` ([#&#8203;11860](https://github.com/swc-project/swc/issues/11860)) ([7692eed](https://github.com/swc-project/swc/commit/7692eed981916bb01a3c4c231b3af012d4993d9e))

##### Performance

- **(ecma)** Reduce transformer compat overhead ([#&#8203;11856](https://github.com/swc-project/swc/issues/11856)) ([d03cb71](https://github.com/swc-project/swc/commit/d03cb71fb8b39f01f0ad704473109294e52e07fd))

- **(es/codegen)** Speed up JsWriter position and srcmap tracking ([#&#8203;11867](https://github.com/swc-project/swc/issues/11867)) ([dbceade](https://github.com/swc-project/swc/commit/dbceade22809ac9a9cb7548456ab335df26fb046))

- **(es/codegen)** Remove JsWriter last\_srcmap cache ([#&#8203;11869](https://github.com/swc-project/swc/issues/11869)) ([3bc1c2b](https://github.com/swc-project/swc/commit/3bc1c2b9b2594b05478dc4240977b981d6f8521b))

- **(es/minifier)** Reduce minifier profiling hotspots ([#&#8203;11853](https://github.com/swc-project/swc/issues/11853)) ([28c1091](https://github.com/swc-project/swc/commit/28c1091adb2c6f6d0e46daa5595908d1ba6fccb7))

- Optimize es parser comment finalization ([#&#8203;11852](https://github.com/swc-project/swc/issues/11852)) ([2959ddf](https://github.com/swc-project/swc/commit/2959ddf87af0ac95eb5176180782819bd1073d66))

##### Testing

- **(es/minifier)** Move issue\_11835 fixture out of terser folder ([#&#8203;11840](https://github.com/swc-project/swc/issues/11840)) ([3dd3431](https://github.com/swc-project/swc/commit/3dd34310d429baff6e8d1a6393266c648684d3c6))

##### Ci

- Update corepack in publish docker jobs ([#&#8203;11885](https://github.com/swc-project/swc/issues/11885)) ([9a7d954](https://github.com/swc-project/swc/commit/9a7d954c4939b12da4c60023eba50d6df8086fd7))

- Pass publish docker env explicitly ([#&#8203;11888](https://github.com/swc-project/swc/issues/11888)) ([c5f7547](https://github.com/swc-project/swc/commit/c5f7547cf68a4803aec3e88901e0d6b57ebbeb55))

- Lock issues closed by merged prs ([#&#8203;11887](https://github.com/swc-project/swc/issues/11887)) ([6bd74e5](https://github.com/swc-project/swc/commit/6bd74e5683ed43640db64f78dc74001a056c1bfa))

- Provide aarch64 musl linker in publish job ([#&#8203;11889](https://github.com/swc-project/swc/issues/11889)) ([20234fd](https://github.com/swc-project/swc/commit/20234fd265f8f86f0c81c31c36e467d405a04d01))

- Fix publish musl linker and windows tests ([#&#8203;11890](https://github.com/swc-project/swc/issues/11890)) ([a798a23](https://github.com/swc-project/swc/commit/a798a23e5f5018e5c01f874457aa20370c0d7058))

- Make minifier test path explicit ([#&#8203;11891](https://github.com/swc-project/swc/issues/11891)) ([e7cba97](https://github.com/swc-project/swc/commit/e7cba972ff25565208c4448accab19c259e6947c))

##### Security

- Save CI caches only on main ([#&#8203;11848](https://github.com/swc-project/swc/issues/11848)) ([7582529](https://github.com/swc-project/swc/commit/75825293150b548216bf5c08531e1850bd064fcb))

- Update rkyv and Rust dependencies ([#&#8203;11851](https://github.com/swc-project/swc/issues/11851)) ([20d92eb](https://github.com/swc-project/swc/commit/20d92eb3c8dee378f046a6bff839913600a1fbdb))

- Harden PR workflow permissions ([#&#8203;11849](https://github.com/swc-project/swc/issues/11849)) ([e199564](https://github.com/swc-project/swc/commit/e199564ebaae88ec121a777cf0ec4eec9644aed5))

</details>

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined).

🚦 **Automerge**: Enabled.

♻ **Rebasing**: Whenever PR is behind base branch, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0MC42Mi4xIiwidXBkYXRlZEluVmVyIjoiNDAuNjIuMSIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOlsiZGVwZW5kZW5jaWVzIl19-->

Reviewed-on: https://git.unespace.com/julien/apf_portal/pulls/204
Co-authored-by: APF Portal Bot <jgautier.webdev+apf-portal-bot@gmail.com>
Co-committed-by: APF Portal Bot <jgautier.webdev+apf-portal-bot@gmail.com>
2026-05-23 21:13:06 +02:00
julien 12136f7a8a feat(auth): authorization catalogues + Principal builder skeleton (ADR-0025) (#206)
CI / commits (push) Has been skipped
CI / check (push) Failing after 20s
CI / a11y (push) Failing after 20s
CI / perf (push) Failing after 44s
CI / scan (push) Failing after 55s
Docs site / build (push) Failing after 29s
## Summary

First implementation PR after [ADR-0025](docs/decisions/0025-authorization-model-privileges-roles-scopes.md) was accepted in #205. Lands the closed-set catalogues + the OIDC-callback hook that composes the session-resident `Principal`. No new guards yet — those come in the next PR per the ADR's `§More Information` phasing.

## What lands

**New shared library: `libs/shared/auth/`** (`scope:shared`, `type:shared`, framework-agnostic, vitest)

| File | Role |
| --- | --- |
| `src/lib/authorization.types.ts` | Closed catalogues: `PRIVILEGES` (4), `FUNCTIONAL_ROLES` (24), `SCOPE_KINDS` (6). `Principal`, `Scope` types. `isPrivilege` / `isFunctionalRole` / `isScopeKind` type-guards for the drift gate. |
| `src/lib/entra-group-to-role.ts` | `parseEntraGroupMap` (validates GUID + slug closedness + dedup) + `EntraGroupToRoleResolver` (translates the Entra `groups` claim into ordered `apf-role-*` slugs, reports unknown GUIDs via callback). |
| `src/lib/*.spec.ts` | 17 unit tests against the catalogue counts + resolver contracts. |

**BFF wiring** (`apps/portal-bff/src/auth/` + `src/config/`)

| File | Role |
| --- | --- |
| `auth.service.ts` | Extracts the Entra `groups` claim. `AuthenticatedUser` grows `groups: readonly string[]`. |
| `principal-builder.ts` | Composes the three axes at sign-in. Filters `roles` claim through `isPrivilege` (drops + WARNs on unknown), resolves `groups` claim through the `EntraGroupToRoleResolver` (drops + WARNs on unknown). |
| `scope-resolver.ts` | `ScopeResolver` abstract class + `StubScopeResolver` returning `[{ kind: 'unrestricted' }]` (per ADR-0025 §331 — replaced by a Prisma implementation when ADR-0026's `user_scopes` table lands). |
| `session-establisher.service.ts` | Calls `PrincipalBuilder.build()` once, persists the result as `req.session.principal`. The legacy `req.session.user` shape is untouched (audit + AI bridge keep working). |
| `session.types.ts` | Adds optional `principal?: Principal` field on the express-session augmentation. |
| `entra-group-to-role.token.ts` | DI token for the lazily-loaded resolver. |
| `config/load-entra-group-map.ts` | Reads the gitignored `infra/<env>-tenant.entra.json` at boot. Missing path → empty resolver + WARN. Malformed file → hard fail (alternative would be silently dropping a role). |

**Tests against the 19 test-tenant personas** (`principal-builder.spec.ts`)

Every persona provisioned in `apfrd.onmicrosoft.com` on 2026-05-20 is exercised end-to-end: `admin`, `directeur-bordeaux`, `directeur-complexe`, `rh-aquitaine`, `rh-siege`, `collaborateur-simple`, `tresorier-bordeaux`, `dpo`, `it`, `benevole-aquitaine`, `chef-equipe-bordeaux`, `chef-service-bordeaux`, `directeur-territorial-aquitaine`, `juriste-siege`, `rssi`, `communication-siege`, `elu-ca-national`, `president-cd-aquitaine`, `secretaire-cd-aquitaine`. A coverage assertion confirms the personas cover all 4 privileges + 23 of 24 functional roles (the `partenaire` gap is intentional per ADR-0025).

**Infra + docs**

| File | Change |
| --- | --- |
| `infra/test-tenant.entra.example.json` | Template GUID → slug map (24 entries, full v1 catalogue). Real file (`*-tenant.entra.json`) gitignored. |
| `infra/README.md` | New row + "Entra group map" section documenting the load semantics + provisioning workflow. |
| `apps/portal-bff/.env.example` | New `ENTRA_GROUP_MAP_PATH` block. |
| `.gitignore` | `infra/*-tenant.entra.json` (except `.example.json`). |
| `tsconfig.base.json` | `shared-auth` path alias. |
| `notes/entra-groups-claim-activation.md` | Operator runbook for the Entra admin-centre step (Token configuration → Add groups claim → Security groups → Group ID). |

**ADR amendment**

| File | Change |
| --- | --- |
| `docs/decisions/0025-authorization-model-privileges-roles-scopes.md` | Path references updated `libs/feature/auth/...` → `libs/shared/auth/...` (4 occurrences), and the `ENTRA_GROUP_TO_ROLE` static-record example replaced by the actual `EntraGroupToRoleResolver` + JSON shape. |

## Notes for the reviewer

- **Why `libs/shared/auth/` and not `libs/feature/auth/` (as the ADR initially said).** The existing `libs/feature/auth/` carries `tags: ['scope:portal-shell', 'type:feature']`, which the Nx `enforce-module-boundaries` rule in `eslint.config.mjs:36-46` blocks the BFF from importing. The catalogue is needed by both sides (BFF builds the `Principal`, SPA will gate UI conditionally later), so a `scope:shared` lib is the correct home. The ADR is patched in the same PR so the document and the code agree.

- **Why drop unknown privileges with a WARN instead of preserving them.** `Portal.GhostRole` in the `roles` claim is either a leftover from a different app registration or a v1+1 experiment that hasn't been added to the catalogue yet. Both paths should not silently grant access — drop with a WARN so an operator notices, and the drift CI gate (next PR) catches the source-side equivalent before merge.

- **Why the principal builder uses a `ScopeResolver` seam now.** Per ADR-0025 §331 the v1 implementation returns `unrestricted` for everyone; the real version queries the `user_scopes` Prisma table that lands with ADR-0026. The seam is here so the next PR replaces only the implementation, not the call-site in `PrincipalBuilder` or its 24 tests. Per-persona stub scope data was deliberately not embedded in this PR — no guard consumes scopes yet, so it would be write-only documentation that drifts from the eventual Prisma seed.

- **Why `user.id` and `user.personId` placeholder to `entraOid`.** Same logic: the real UUIDs land with the `Person` + `User` Prisma schema (proposed ADR-0026). Until then, populating both fields with `entraOid` lets consumers key on `principal.user.id` today and get a real value later without branching on availability. The seam is one place (in the builder), not 24 guards.

- **Why session.types.ts carries both `user` and `principal`.** Additive instead of replacement: the audit module + the AI-bridge controller key on `user.oid` / `user.tid` directly, and migrating them in the same PR would balloon the diff for zero behavioural benefit. New consumers (`@RequirePrivilege` / `@RequireRole` / `@RequireScope` decorators, next PR) reach for `principal`.

- **Map file fails soft on absence, hard on malformation.** Path unset OR file missing → empty resolver + WARN. Sign-in still succeeds, every user gets `roles: []`. This is deliberately fail-soft so a fresh dev environment isn't blocked by an Entra-side dependency. **Bad JSON / unknown slug / duplicate GUID → throws at boot.** The alternative would silently mis-resolve a role.

- **Why `boot-time` validation only (no per-request)** for the map file. Catalogue drift is detected once at boot; per-request validation would re-read the file from disk on every sign-in. The trade-off is that a hot-edit to the file requires a BFF restart — acceptable because the file changes once per tenant lifecycle, not per session.

- **Entra `groups` claim must be activated** on the BFF's app registration before this code does anything useful. The runbook in `notes/entra-groups-claim-activation.md` walks through the steps; if the claim isn't activated, every user signs in with `principal.roles = []` and a future warn for every group GUID that would have arrived (none, in that case).

- **No drift gate yet.** ADR-0025's §"Confirmation" §346 calls for an ESLint custom rule (or `pnpm run` script) that greps every `@RequireRole('...')` literal and asserts membership in the catalogue. That's the third PR in the phasing — there's no `@RequireRole` consumer in the tree yet, so the gate would have nothing to assert against today.

## Test plan

- [x] `pnpm nx affected -t lint test build --base=main` — 13 projects green
  - 497 BFF tests (was 465 — added 4 new `groups`-claim tests in `auth.service.spec.ts`, 1 new test in `session-establisher.service.spec.ts`, 6 new tests in `load-entra-group-map.spec.ts`, 24 new tests in `principal-builder.spec.ts`)
  - 17 `shared-auth` tests (catalogue counts + resolver contracts)
  - 1 `shared-util` test (unchanged)
  - full lint + full build
- [x] `pnpm nx format:check` — clean after `pnpm nx format:write`
- [x] Manual: `tsc --build libs/shared/auth/tsconfig.lib.json` emits `.d.ts` files at the path `portal-bff`'s webpack expects.
- [ ] **Review focus** — the closed catalogues (`PRIVILEGES`, `FUNCTIONAL_ROLES`, `SCOPE_KINDS`) verbatim match ADR-0025; the 19 personas verbatim match `notes/test-tenant-role-assignments.md`; the `Principal` shape matches the ADR §"Principal shape" (modulo the `user.id` / `personId` placeholder); fail-soft on missing map file, hard on malformed.
- [ ] **After merge — operator step** : activate the Entra `groups` claim per `notes/entra-groups-claim-activation.md` and drop the real GUIDs into `infra/test-tenant.entra.json` (gitignored). Then a sign-in with `admin@apfrd.onmicrosoft.com` should land `principal.privileges = ['Portal.Admin']` and `principal.roles = ['collaborateur', 'rh']` in Redis.

## What's next

Per ADR-0025 §"More Information" phasing:

1.  **This PR** — types + Principal builder + group-to-role mapping skeleton.
2. **Next PR** — `@RequirePrivilege` + `@RequireRole` + `@RequireScope` decorators + guard integration tests against the 19 personas.
3. **PR after that** — drift CI gate (ESLint custom rule asserting every `@RequireX('...')` literal is in the catalogue).
4. **Depends on ADR-0026** — `user_scopes` Prisma table + seed + `PrismaScopeResolver` replacing `StubScopeResolver`.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #206
2026-05-23 21:09:13 +02:00
julien c9a1e195fe docs(adr-0025): promote to accepted + sync persona matrix with test tenant (#205)
CI / check (push) Successful in 2m0s
CI / commits (push) Has been skipped
CI / scan (push) Failing after 1m54s
CI / a11y (push) Successful in 1m44s
CI / perf (push) Successful in 3m49s
Docs site / build (push) Successful in 2m45s
## Summary

ADR-0025 was merged as `proposed` in #201. The test tenant (`apfrd.onmicrosoft.com`) has since been provisioned with the full role / user matrix — 4 privileges + 24 security groups + 19 users with all assignments per the persona table. The ADR is now the implementation reference, so this PR:

1. Promotes the ADR from `proposed` to `accepted`.
2. Syncs the document with what is actually in place — the privilege catalogue grows from 1 to 4 entries (the previously "anticipated future" privileges that the test tenant already has), and the persona matrix grows from 10 to 19 entries (so every one of the 24 functional-role groups has at least one member, closing the gap that prompted `notes/test-tenant-role-assignments.md` and `notes/entra-group-members.md`).
3. Records the Entra app-role GUIDs in a new "Provisioned in the test tenant" subsection for traceability — the GUIDs are stable IDs the implementation will need.
4. Updates the index + the `CLAUDE.md` roll-up.

No code changes. No implementation skeleton — that lands in the next PR (proposed: `feat(libs/feature/auth): authorization types + Principal builder skeleton`).

## What lands

| File | Change |
|---|---|
| `docs/decisions/0025-authorization-model-privileges-roles-scopes.md` | Frontmatter `status: proposed → accepted`; privilege catalogue extended from 1 to 4 entries; persona matrix rewritten from 10 to 19 entries; new `Provisioned in the test tenant (2026-05-20)` subsection capturing the four app-role GUIDs. |
| `docs/decisions/README.md` | 0025 row: `proposed → accepted`. |
| `CLAUDE.md` | Roll-up `0001 → 0024 accepted` → `0001 → 0025 accepted`; Architecture list grows an "Authorization model" bullet. |

The two operator-facing notes files (`notes/test-tenant-role-assignments.md`, `notes/entra-group-members.md`) are gitignored and unchanged — they served their purpose during tenant provisioning and remain as runbooks.

## Notes for the reviewer

- **Why promote now rather than couple with the first implementation PR (the pattern from #194#195#196).** The Entra-side provisioning *is* the implementation for this ADR — the next PR is a portal-side reflection of decisions that are already concrete in the tenant. Promoting now keeps the document honest about what the test tenant runs against.
- **Why all 4 privileges enter the v1 catalogue.** The ADR originally shipped `Portal.Admin` as the sole v1 entry and listed the other three under "anticipated near-future entries". The test tenant has all four; the catalogue should match. The three new entries are explicitly marked "provisioned; consumer surface deferred" so a reader does not look for non-existent surfaces.
- **Why 19 personas, not the cleaner 24 (one per role).** Several APF jobs genuinely combine multiple roles (RH siège often handles paie + compta; DPO often wears the quality officer hat; local delegates often grow out of volunteer roles). Densifying these existing personas is more faithful to real APF org structure than inventing 14 single-role test users. Distinct personas were created where the *scenario* is distinct (scope variations, governance positions) — see the matrix in the ADR for the breakdown.
- **Why `apf-role-partenaire` stays empty in v1.** Placeholder per the original ADR; no consuming surface to test against. The group exists in Entra so the schema is locked, but a user assignment without a guard to exercise would be theatre. The first partner-facing feature adds the user.
- **GUIDs in the ADR.** The four app-role GUIDs are repo-stable identifiers; recording them in the ADR keeps the document self-sufficient when a future contributor opens it without access to the Entra portal. The 24 functional-role group GUIDs are tenant-specific and stay in a gitignored `infra/test-tenant.entra.json` once the implementation PR creates it — referenced by name only in `libs/feature/auth/src/lib/entra-group-to-role.ts`.
- **No `prettier --write` damage.** The persona matrix is a wide table; Prettier sometimes reflows wide markdown tables. The diff is clean — Prettier left the table intact on this run.

## Test plan

- [x] `prettier --check docs/decisions/0025-authorization-model-privileges-roles-scopes.md` — passes (hook ran on commit).
- [x] Markdown links inside the ADR still resolve (`0020`, references to other ADRs unchanged).
- [x] Status row in `docs/decisions/README.md` reflects `accepted`.
- [x] `CLAUDE.md` roll-up line + Architecture list updated; no other instances of "0024" needed bumping.
- [ ] **Review focus** — the expanded privilege catalogue (4 entries, one of them already had a guard, three new ones documented), the 19-entry persona matrix, the "Provisioned in the test tenant" subsection (especially the GUIDs — make sure none was mistyped from `notes/role-user.txt`).

## What's next

With ADR-0025 accepted, the implementation phasing recorded in its `§More Information` opens:

1. **PR — `libs/feature/auth` extension** : `authorization.types.ts` (catalogue constants for the 4 privileges + 24 functional roles + 6 scope kinds), `entra-group-to-role.ts` (slug map skeleton with placeholder GUIDs ; the operator drops real GUIDs into `infra/test-tenant.entra.json` separately), `Principal` builder hook on the OIDC callback, no new guards yet.
2. **PR — `@RequireRole` + `@RequireScope` decorators + guard tests** : real composition tests against the 19 personas.
3. **PR — drift CI gate** : ESLint custom rule asserting every `@RequireRole('...')` literal in code is in the catalogue.
4. **PR — `prisma/seed.ts` for `user_scopes`** : depends on the `Person` + `User` schema (proposed ADR-0026).

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #205
2026-05-23 17:42:57 +02:00
APF Portal Bot 82d4245ab7 chore(deps): update analog monorepo to v2.5.2 (#203)
CI / commits (push) Has been skipped
CI / scan (push) Failing after 2m33s
CI / a11y (push) Successful in 2m11s
CI / check (push) Successful in 5m3s
CI / perf (push) Successful in 5m58s
Docs site / build (push) Successful in 2m40s
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| [@analogjs/vite-plugin-angular](https://github.com/analogjs/analog) | devDependencies | patch | [`2.5.1` -> `2.5.2`](https://renovatebot.com/diffs/npm/@analogjs%2fvite-plugin-angular/2.5.1/2.5.2) |
| [@analogjs/vitest-angular](https://analogjs.org) ([source](https://github.com/analogjs/analog)) | devDependencies | patch | [`2.5.1` -> `2.5.2`](https://renovatebot.com/diffs/npm/@analogjs%2fvitest-angular/2.5.1/2.5.2) |

---

### Release Notes

<details>
<summary>analogjs/analog (@&#8203;analogjs/vite-plugin-angular)</summary>

### [`v2.5.2`](https://github.com/analogjs/analog/blob/HEAD/CHANGELOG.md#252-2026-05-22)

[Compare Source](https://github.com/analogjs/analog/compare/v2.5.1...v2.5.2)

##### Bug Fixes

- **storybook-angular:** support Storybook 10.4+ object-shaped core preset ([#&#8203;2337](https://github.com/analogjs/analog/issues/2337)) ([1956794](https://github.com/analogjs/analog/commit/1956794ce6bb0b15d5aacf53a81b22469bb568a7))
- **vite-plugin-angular:** compose fastCompile JIT strip map and cover esbuild fallback ([#&#8203;2341](https://github.com/analogjs/analog/issues/2341)) ([532aa3e](https://github.com/analogjs/analog/commit/532aa3e0ff239175ead36d57278a243022492f0c))
- **vite-plugin-angular:** reject [@&#8203;Service](https://github.com/Service) on Angular below 22 ([bc63520](https://github.com/analogjs/analog/commit/bc63520720dd983d6ae8e2dcd6a936efe6168625))
- **vite-plugin-angular:** skip source .d.ts files for composite TS projects ([#&#8203;2335](https://github.com/analogjs/analog/issues/2335)) ([96ec5b3](https://github.com/analogjs/analog/commit/96ec5b38c030aa36009be2c6f0d9c5be342aa351))
- **vite-plugin-angular:** strip TS in fastCompile JIT path for StackBlitz ([#&#8203;2340](https://github.com/analogjs/analog/issues/2340)) ([44e6334](https://github.com/analogjs/analog/commit/44e6334cd9add9187fab6f5007e0b43361a9d84c))
- **vite-plugin-angular:** support the [@&#8203;Service](https://github.com/Service) decorator in the fast compiler ([b284696](https://github.com/analogjs/analog/commit/b284696088faa4e65f572eb83708329b6eed3f79))

</details>

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined).

🚦 **Automerge**: Enabled.

♻ **Rebasing**: Whenever PR is behind base branch, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about these updates again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0MC42Mi4xIiwidXBkYXRlZEluVmVyIjoiNDAuNjIuMSIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOlsiZGVwZW5kZW5jaWVzIl19-->

Reviewed-on: https://git.unespace.com/julien/apf_portal/pulls/203
Co-authored-by: APF Portal Bot <jgautier.webdev+apf-portal-bot@gmail.com>
Co-committed-by: APF Portal Bot <jgautier.webdev+apf-portal-bot@gmail.com>
2026-05-22 11:35:19 +02:00
APF Portal Bot 2b93710eaa chore(deps): update dependency vite to v8.0.14 (#202)
CI / commits (push) Has been skipped
CI / scan (push) Successful in 1m50s
CI / a11y (push) Successful in 1m48s
CI / check (push) Successful in 3m58s
CI / perf (push) Successful in 4m50s
Docs site / build (push) Successful in 2m35s
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| [vite](https://vite.dev) ([source](https://github.com/vitejs/vite/tree/HEAD/packages/vite)) | devDependencies | patch | [`8.0.13` -> `8.0.14`](https://renovatebot.com/diffs/npm/vite/8.0.13/8.0.14) |

---

### Release Notes

<details>
<summary>vitejs/vite (vite)</summary>

### [`v8.0.14`](https://github.com/vitejs/vite/blob/HEAD/packages/vite/CHANGELOG.md#small-8014-2026-05-21-small)

[Compare Source](https://github.com/vitejs/vite/compare/v8.0.13...v8.0.14)

##### Features

- update rolldown to 1.0.2 ([#&#8203;22484](https://github.com/vitejs/vite/issues/22484)) ([96efc88](https://github.com/vitejs/vite/commit/96efc88570b6a6ddf1a910f106920cbac07b3cf0))

##### Bug Fixes

- **deps:** update all non-major dependencies ([#&#8203;22471](https://github.com/vitejs/vite/issues/22471)) ([98b8163](https://github.com/vitejs/vite/commit/98b81632139d51820f82036e58d6fbbf122b77b3))
- **dev:** handle errors when sending messages to vite server ([#&#8203;22450](https://github.com/vitejs/vite/issues/22450)) ([e8e9a34](https://github.com/vitejs/vite/commit/e8e9a34dcf2540139de558a10187630884d10217))
- **html:** handle trailing slash paths in transformIndexHtml ([#&#8203;22480](https://github.com/vitejs/vite/issues/22480)) ([5d94d1b](https://github.com/vitejs/vite/commit/5d94d1bffdb2a15de9341194d89baec86ce1f693))
- **optimizer:** pass oxc jsx options to transformSync in dependency scan                                                            ([#&#8203;22342](https://github.com/vitejs/vite/issues/22342)) ([b3132da](https://github.com/vitejs/vite/commit/b3132dacea9c6e0cf526cd9f0f09d850f577c262))

##### Miscellaneous Chores

- **deps:** update rolldown-related dependencies ([#&#8203;22470](https://github.com/vitejs/vite/issues/22470)) ([7cb728e](https://github.com/vitejs/vite/commit/7cb728eb629cc677661f1bc52a044ffc0b87fc7f))
- remove irrelevant commits from changelog ([2c69495](https://github.com/vitejs/vite/commit/2c69495f250edf01132d4a20128de19dbe836086))

##### Code Refactoring

- **glob:** do not rewrite import path for absolute base ([#&#8203;22310](https://github.com/vitejs/vite/issues/22310)) ([0ae2844](https://github.com/vitejs/vite/commit/0ae2844ab6d6d1ccf78a2975b8132769fc35b302))

##### Tests

- **css:** sass does not use main field ([#&#8203;22449](https://github.com/vitejs/vite/issues/22449)) ([ebf39a0](https://github.com/vitejs/vite/commit/ebf39a04329ddc6ba765e006a5d463680a952270))

</details>

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined).

🚦 **Automerge**: Enabled.

♻ **Rebasing**: Whenever PR is behind base branch, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0MC42Mi4xIiwidXBkYXRlZEluVmVyIjoiNDAuNjIuMSIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOlsiZGVwZW5kZW5jaWVzIl19-->

Reviewed-on: https://git.unespace.com/julien/apf_portal/pulls/202
Co-authored-by: APF Portal Bot <jgautier.webdev+apf-portal-bot@gmail.com>
Co-committed-by: APF Portal Bot <jgautier.webdev+apf-portal-bot@gmail.com>
2026-05-21 10:31:55 +02:00
julien ef5073de8a docs(adr-0025): authorization model — privileges × roles × scopes (#201)
CI / scan (push) Successful in 2m32s
CI / commits (push) Has been skipped
CI / check (push) Successful in 3m5s
CI / a11y (push) Successful in 8m51s
CI / perf (push) Successful in 10m47s
Docs site / build (push) Successful in 10m46s
## Summary

Proposes [ADR-0025](docs/decisions/0025-authorization-model-privileges-roles-scopes.md) — the portal's general-purpose authorization model. **Status: `proposed`.** No code in this PR; the goal is to lock the model and the v1 catalogues before the implementation chantier opens.

The model rejects stargate's linear hierarchy (`Admin ⊃ Directeur ⊃ RH ⊃ Collaborateur`) and adopts **three orthogonal axes**:

| Axis | What it carries | Source of truth |
|---|---|---|
| **Privileges** | Portal-level capabilities (`Portal.Admin`, future `Portal.Auditor`, …) | Entra app roles, `roles` claim |
| **Functional roles** | What someone does in APF (`rh`, `directeur-etablissement`, `elu-cd-tresorier`, …) | Entra security groups, `groups` claim → curated slug catalogue |
| **Scopes** | Where a role applies (`etablissement:0330800013`, `delegation:33`, `unrestricted`, …) | apf_portal-side `user_scopes` table (v1) ; future Pléiades feed |

The three axes compose at sign-in into a session-resident `Principal`. The portal's guards consume the structured shape; a deterministic projector flattens it to the `roles[]` list that `apf-ai-service` expects per [ADR-0024](docs/decisions/0024-ai-service-relay-grpc-sse-bridge.md).

## What lands

- `docs/decisions/0025-authorization-model-privileges-roles-scopes.md` — full MADR with context, decision drivers, four considered options, exhaustive v1 catalogues, Entra-side configuration, `Principal` shape, AI-service projection, guard surface, ten test-tenant personas, consequences, confirmation criteria, pros/cons per option, ABAC migration path, related ADRs, and proposed follow-up ADRs.
- `docs/decisions/README.md` — index row for ADR-0025 (`proposed`, tags `security, backend, data`, 2026-05-20).

No `CLAUDE.md` update — ADR stays in `proposed` until reviewed; the accepted-ADRs roll-up at the top of `CLAUDE.md` stays at 0001 → 0024. Promotion to `accepted` lands in the same PR that ships the implementation skeleton (`libs/feature/auth` extension with `Principal` builder + catalogues).

## Highlights worth review focus

- **Privilege catalogue is intentionally minimal**: only `Portal.Admin` in v1. Anticipated future entries (`Portal.Auditor`, `Portal.SecurityOfficer`, `Portal.DPO`) are mentioned in the ADR but not formalised — each one rides an amendment ADR.
- **Functional-role catalogue is closed-set, 22 entries** grouped into workforce (15), governance (6), volunteer (2), external (1, placeholder). Adding a new slug requires an ADR amendment. The CI drift gate (proposed in §"Confirmation") asserts no orphan `@RequireRole('x')` literal in code.
- **Scope kinds are also closed-set**: `self`, `etablissement:<finess>`, `delegation:<dept>`, `region:<insee>`, `siege`, `unrestricted`. The `value` carriers are documented (FINESS code rather than internal `etablissement.id` because FINESS is stable across reorgs).
- **`Principal` shape** is the contract for everything downstream. Documented field-by-field. Built once at sign-in, persisted in the Redis session, refreshed on every authenticated request.
- **`PrincipalProjector` for the AI service** is mechanical: union of privileges + roles + scope-strings, no inclusive expansion. The projector is the only seam that knows about the flat shape; the rest of the portal never touches it.
- **Closed-vs-open catalogue trade-off** spelled out: the friction of "every new role rides an ADR amendment" is the price of "every slug in code is one a human approved". The drift CI gate enforces the discipline.
- **ABAC migration path** documented so a future contributor does not feel they must rewrite authorization to introduce a single Cedar/OPA-shaped rule.

## Test-tenant personas

The ADR proposes ten test users covering every interesting combination of the three axes. Table in §"Test-tenant personas" of the ADR; here's the summary the user can act on:

| Login | Privileges | Functional roles | Scopes |
|---|---|---|---|
| `admin@<tenant>` | `Portal.Admin` | `collaborateur`, `rh` | `unrestricted` |
| `directeur-bordeaux@<tenant>` | — | `directeur-etablissement`, `collaborateur` | `etablissement:0330800013` |
| `directeur-complexe@<tenant>` | — | `directeur-etablissement`, `collaborateur` | two `etablissement:*` scopes |
| `rh-aquitaine@<tenant>` | — | `rh`, `collaborateur` | `delegation:33` |
| `rh-siege@<tenant>` | — | `rh`, `responsable-paie`, `collaborateur` | `unrestricted` |
| `collab-simple@<tenant>` | — | `collaborateur` | `self` |
| `tresorier-bordeaux@<tenant>` | — | `elu-cd-tresorier`, `elu-cd` | `delegation:33` |
| `dpo@<tenant>` | — | `dpo`, `collaborateur` | `unrestricted` |
| `it@<tenant>` | — | `it`, `collaborateur` | `unrestricted` |
| `benevole-aquitaine@<tenant>` | — | `benevole`, `benevole-responsable` | `delegation:33` |

**Entra test-tenant setup the user needs to provision after acceptance**:

1. One app role: `Portal.Admin` (likely already there from the existing dev tenant; document the GUID in `apps/portal-bff/.env.test`).
2. **22 security groups**, one per functional-role slug in the catalogue: `apf-role-collaborateur`, `apf-role-chef-equipe`, `apf-role-chef-service`, `apf-role-directeur-etablissement`, `apf-role-directeur-territorial`, `apf-role-rh`, `apf-role-responsable-paie`, `apf-role-comptable`, `apf-role-juriste`, `apf-role-dpo`, `apf-role-rssi`, `apf-role-it`, `apf-role-formation`, `apf-role-qualite`, `apf-role-communication`, `apf-role-elu-ca`, `apf-role-elu-cd`, `apf-role-elu-cd-president`, `apf-role-elu-cd-tresorier`, `apf-role-elu-cd-secretaire`, `apf-role-delegue`, `apf-role-benevole`, `apf-role-benevole-responsable`, `apf-role-partenaire`.
3. The ten test users with the membership matrix above.
4. App registration manifest tweak: `groupMembershipClaims: 'SecurityGroup'` + `optionalClaims.idToken: [{ name: 'groups' }]` so the BFF sees the memberships in the ID token.

GUIDs and credentials stay in the operator's hands (out of git). When the user has provisioned the tenant, drop the GUIDs into `infra/test-tenant.entra.json` (gitignored) and the implementation PR wires `libs/feature/auth/src/lib/entra-group-to-role.ts` against them.

## Notes for the reviewer

- **Why not just extend stargate's `RoleMapper`.** The mapper's inclusive expansion (`Admin → [admin, directeur, rh, collaborateur]`) bakes in the wrong assumption — that roles form a chain. Reusing it would force every new role into the chain too. The three-axis model has no such forcing function.
- **Why `Person` is conspicuously absent here.** Authorization is keyed on the portal-side `User` account (Entra OID, session). The proposed `Person` + `User` split lands in a sibling ADR (proposed: ADR-0026) because the two decisions have different audiences — auth model is a backend/security concern; golden record is a data/domain concern. They will land in coordinated PRs.
- **Why FINESS rather than internal UUID for `etablissement:*` scopes.** FINESS codes are the canonical APF identifier for an établissement, stable across the internal-database churn (etablissement merges, reorgs, system migrations). Using the FINESS as the scope value means scope strings stay readable, debuggable, and stable when an établissement gets a new internal `id` after a Prisma migration.
- **Why no time-bound roles in v1.** APF does have interim assignments (acting Directeur for two months while the permanent one is on leave). The `user_scopes` table already has `expiresAt` to lay the groundwork; extending the *role* axis with time bounds is a future ADR amendment when a concrete use case lands.
- **Coordination with apf-ai-service.** The PrincipalProjector spec here matches exactly what `apf-ai-service`'s RBAC matrix tests expect (each chunk's ACL is a string-match against `Principal.roles[]`). The ADR explicitly notes that the projector is the only place that knows about the flat shape — keeping the AI-side contract honoured without polluting the portal-side guards.

## Test plan

- [x] `prettier --check docs/decisions/0025-authorization-model-privileges-roles-scopes.md` — passes (hook ran on commit).
- [x] Markdown links inside the ADR resolve (`0008`, `0009`, `0010`, `0011`, `0013`, `0020`, `0021`, `0024`, plus the proposed ADR-0026 / ADR-0027 placeholders).
- [x] Index row in `docs/decisions/README.md` follows the table's existing format.
- [x] No tag-vocabulary additions required — `security`, `backend`, `data` are all in the existing vocab.
- [ ] **Review focus** — the v1 catalogues (privileges + 22 functional roles + 6 scope kinds), the `Principal` shape, the projection contract for the AI service, and the ten test personas. Catalogue closures are deliberate; raising the lid requires an amendment so the v1 list deserves a careful pass.

## What's next (once accepted)

The implementation phasing recorded in the ADR's §"More Information":

1. **PR — types + Principal builder + Entra mapping skeleton**. Lands `libs/feature/auth/src/lib/authorization.types.ts` (catalogue constants), `entra-group-to-role.ts` (slug map), and the OIDC callback hook that extends `req.session.user` with `privileges` / `roles` / `scopes`. No new guards yet.
2. **PR — `@RequireRole` + `@RequireScope` decorators + guard tests**. Stub principal in unit tests; real session in e2e.
3. **PR — drift CI gate**. ESLint custom rule or `pnpm run` script: every `@RequireRole('...')` / `@RequirePrivilege('...')` / scope literal must exist in the catalogue constants.
4. **PR — test-tenant seed**. `prisma/seed.ts` populating the ten personas' `user_scopes` rows. Depends on the `Person` + `User` schema PR landing first.

In parallel, the user provisions the test tenant per the §"Test-tenant personas" instructions above.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #201
2026-05-20 18:42:45 +02:00
julien e389567a3c fix(ci): move grpc-tools to optionalDependencies (revert NODE_OPTIONS attempt) (#200)
CI / commits (push) Has been skipped
CI / check (push) Successful in 5m33s
CI / scan (push) Successful in 6m39s
CI / a11y (push) Successful in 4m16s
CI / perf (push) Successful in 7m25s
Docs site / build (push) Successful in 3m29s
## Summary

The `NODE_OPTIONS=--use-system-ca` workaround merged in #199 did not clear the CI install failure — the runner still rejects the TLS chain to `node-precompiled-binaries.grpc.io`. Either the runner's OS CA bundle is missing the same intermediate Node's bundled set is missing, or `--use-system-ca` does not propagate down to the `node-fetch@2` that `node-pre-gyp` uses. Without runner shell access the exact reason is not worth chasing.

Switch angle: **the protoc binary `grpc-tools` downloads is never used in CI**. The generated TypeScript stubs in `apps/portal-bff/src/grpc/gen/` are committed per [ADR-0024](docs/decisions/0024-ai-service-relay-grpc-sse-bridge.md) §"Sub-decision 3 — vendored protos", so CI only needs to type-check them; protoc only runs when a developer regenerates from `apps/portal-bff/src/grpc/proto/apf-ai/*.proto`.

This PR moves `grpc-tools` from `devDependencies` to `optionalDependencies`. Per pnpm semantics, when an optional dep's install (including postinstall) fails, pnpm logs a warning and the overall install completes. CI's `pnpm install --frozen-lockfile` therefore succeeds even when the binary cannot be fetched; developer machines (where TLS validates) install grpc-tools normally and `pnpm grpc:codegen` works unchanged.

## What lands

- `package.json` — `grpc-tools: "^1.13.0"` moves out of `devDependencies` and into a new `optionalDependencies` block. The entry in `pnpm.onlyBuiltDependencies` stays — it controls whether pnpm *attempts* the postinstall, not whether failure is fatal. Local installs (where TLS works) still run the postinstall and download the binary.
- `pnpm-lock.yaml` — refreshed; the lockfile reflects the new optional-dep classification. No version changes to anything else.
- `.gitea/workflows/ci.yml` — revert the workflow-level `env: NODE_OPTIONS: --use-system-ca` block added in #199. Did not help; left in place it would be a misleading "this is supposed to fix CI TLS" signpost.
- `.gitea/workflows/docs-site.yml` — same revert.

## Notes for the reviewer

- **Why `optionalDependencies` is the right primitive.** pnpm 10 documents the contract: a package listed in `optionalDependencies` is *attempted*; if the install fails for any reason (platform mismatch, postinstall script error, network failure), the failure is logged and the overall command continues. That maps exactly to what this PR needs: try in CI, fail gracefully, succeed locally.
- **Why not remove `grpc-tools` from `pnpm.onlyBuiltDependencies` instead.** Removing it from the allowlist tells pnpm not to even *try* the postinstall, which would also fix CI. But it would also break the developer workflow — locally, the protoc binary would never download, and `pnpm grpc:codegen` would fail. The dev would have to manually trigger the build (`pnpm approve-builds` then `pnpm rebuild grpc-tools`), or worse, devs would commit accidental `package.json` mutations from `approve-builds`. The `optionalDependencies` route keeps the local DX identical.
- **Why revert the workflow `env:` blocks.** They do not help here, and leaving them in place implies that `--use-system-ca` is the canonical fix for this class of failure — which it is *not*, at least not for this runner / this CDN. If a future native dep hits a similar wall and `--use-system-ca` *does* fix it for that case, the env var lands in a focused PR with a real validation. Carrying it now as a "maybe useful later" workaround is noise.
- **Codegen workflow on developer machines.** Unchanged. `pnpm install` runs the postinstall (TLS validates locally), the protoc binary lands in `node_modules/`, `pnpm grpc:codegen` regenerates stubs. The only visible difference is a one-line `WARN GET_RESOLVED_FROM_REGISTRY ...` if a contributor ever encounters the same TLS failure locally (e.g., on a corporate-proxied machine) — pnpm will mark grpc-tools as failed-optional and the rest of the install proceeds; the dev can then debug their own network without blocking the whole repo.
- **Idempotence with CI's `--frozen-lockfile`.** The lockfile change is small (re-classifies grpc-tools from `dev` to `optional`) and lands in this PR. After merge, CI runs against the new lockfile; no further coordination needed.

## Test plan

- [x] `pnpm install` locally — clean, grpc-tools postinstall runs successfully (TLS validates), protoc binary present.
- [x] `pnpm grpc:codegen` — regenerates the TypeScript stubs identically (no diff in `apps/portal-bff/src/grpc/gen/`).
- [x] `pnpm nx run-many -t lint test build -p portal-shell,portal-admin,portal-bff,shared-ui,shared-charts` — all five projects green.
- [ ] **CI green on this PR's first run.** The validation that matters: `pnpm install --frozen-lockfile` completes despite grpc-tools' postinstall failure; `ci:check` runs to completion.
- [ ] Spot-check of post-merge CI runs over the next few days to confirm the warning is recurring (expected) and the install never fails (the contract).

## What's next

- If the CI behaviour is what this PR predicts (warning instead of failure), no further action required.
- If `optionalDependencies` does not work as documented (highly unlikely, but possible if the pnpm version has a regression), the fallback is to drop `grpc-tools` from `pnpm.onlyBuiltDependencies` and add a small dev-onboarding note. One-line change away.
- Long-term cleanup: if a future PR migrates the codegen step to a Docker-based tool (`buf`, system protoc) the optional-dep entry can be removed entirely. Out of scope here.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #200
2026-05-20 15:06:25 +02:00
julien 219b7a2143 fix(ci): pass NODE_OPTIONS=--use-system-ca to clear grpc-tools tls install (#199)
CI / commits (push) Has been skipped
CI / check (push) Failing after 1m41s
CI / perf (push) Failing after 10s
CI / a11y (push) Failing after 1m33s
CI / scan (push) Failing after 1m52s
Docs site / build (push) Failing after 2m5s
## Summary

The CI runner fails `pnpm install --frozen-lockfile` since the AI-relay chantier added `grpc-tools` to the dep tree. The package's postinstall downloads a precompiled `protoc` archive from `node-precompiled-binaries.grpc.io`, and Node 24's bundled CA set cannot verify the TLS chain on the runner network path.

The fix follows the Node team's own remediation, surfaced verbatim in the error message:

> unable to verify the first certificate; if the root CA is installed locally, try running Node.js with `--use-system-ca`

Setting `NODE_OPTIONS=--use-system-ca` at the workflow `env:` block makes Node consult the OS CA store in addition to its bundled set. The runner image (`catthehacker/ubuntu:act-22.04` per [ADR-0015](docs/decisions/0015-cicd-gitea-actions.md)) carries the standard Ubuntu `ca-certificates` bundle, which validates the chain.

## What lands

`.gitea/workflows/ci.yml`:

```yaml
env:
  NODE_OPTIONS: --use-system-ca
```

`.gitea/workflows/docs-site.yml`: same one-line block.

Both placed at workflow scope so every Node process (pnpm itself + every postinstall it spawns) inherits the option without per-step duplication.

No package.json change. No code change. No runner-image change.

## Notes for the reviewer

- **Why not skip the grpc-tools postinstall in CI instead.** The protoc binary downloaded here is never invoked in CI — the generated TypeScript stubs in `apps/portal-bff/src/grpc/gen/` are committed (per ADR-0024 §"Sub-decision 3 — vendored protos"), so CI only needs to type-check them. Removing `grpc-tools` from `pnpm.onlyBuiltDependencies` would also clear the failure and is arguably more minimal in CI. The trade-off: developers who update protos would have to opt back into the postinstall (`pnpm approve-builds grpc-tools && pnpm rebuild grpc-tools`) before running `pnpm grpc:codegen`. `--use-system-ca` keeps the developer workflow identical and fixes the underlying TLS-chain issue for any future native dep that hits the same wall (a real risk as the dep tree grows). The cost is a single workflow env var.
- **Why workflow-scope `env:` rather than per-step `env:`.** Five separate `pnpm install` steps run across `ci.yml`; setting it five times would invite drift. The `env:` block at workflow scope applies to every step in every job — clean, single source of truth.
- **Node 24 compatibility.** `--use-system-ca` is documented since Node 22; Node 24 (the workspace LTS per `.nvmrc`) carries it. No `.nvmrc` change required.
- **Local-dev impact.** None. Local developers' Node already validates the chain (the issue is specific to the runner's network path); the env var is a no-op locally.
- **Forward compatibility.** If a future native dep fetches binaries from a different CDN with a similar chain issue, this fix covers it without further changes.

## Test plan

This PR cannot be locally validated in a way that reproduces the failure — the CI runner's network path is the only environment where `node-precompiled-binaries.grpc.io` cannot be reached with Node's bundled CA. The validation is the next CI run.

- [ ] **CI green** on this PR's first run — `pnpm install --frozen-lockfile` completes; `pnpm ci:check` runs to completion.
- [ ] If CI still fails: the runner's Ubuntu CA bundle does not contain the missing intermediate either. Fallback path: drop `grpc-tools` from `pnpm.onlyBuiltDependencies` so the postinstall is skipped entirely in CI (and document the dev-side `pnpm approve-builds` step). Trivial follow-up if needed.

## What's next

If `--use-system-ca` fixes the CI install and no other Node TLS issues surface, no further action is required. The env var stays as a forward-looking guard.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #199
2026-05-20 10:23:38 +02:00
APF Portal Bot 0d5ce1e153 fix(deps): update dependency @azure/msal-node to v5.2.2 (#198)
CI / commits (push) Has been skipped
Docs site / build (push) Failing after 1m19s
CI / scan (push) Failing after 2m38s
CI / check (push) Failing after 2m40s
CI / perf (push) Failing after 2m47s
CI / a11y (push) Failing after 48s
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| [@azure/msal-node](https://github.com/AzureAD/microsoft-authentication-library-for-js) | dependencies | patch | [`5.2.1` -> `5.2.2`](https://renovatebot.com/diffs/npm/@azure%2fmsal-node/5.2.1/5.2.2) |

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined).

🚦 **Automerge**: Enabled.

♻ **Rebasing**: Whenever PR is behind base branch, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0MC42Mi4xIiwidXBkYXRlZEluVmVyIjoiNDAuNjIuMSIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOlsiZGVwZW5kZW5jaWVzIl19-->

Reviewed-on: #198
Co-authored-by: APF Portal Bot <jgautier.webdev+apf-portal-bot@gmail.com>
Co-committed-by: APF Portal Bot <jgautier.webdev+apf-portal-bot@gmail.com>
2026-05-20 09:31:07 +02:00
julien 2772a918c2 feat(portal-shell): chatbot widget — SSE-bridged AI assistant with full a11y uplift (#197)
CI / check (push) Successful in 4m36s
CI / commits (push) Has been skipped
CI / scan (push) Successful in 2m36s
CI / a11y (push) Successful in 3m7s
CI / perf (push) Successful in 4m41s
## Summary

Final piece of the AI relay chantier opened with [ADR-0024](docs/decisions/0024-ai-service-relay-grpc-sse-bridge.md): a chatbot widget on `portal-shell` that consumes the BFF's `POST /api/ai/chat` SSE endpoint (shipped in #196). Built fresh against the WCAG 2.2 AA + targeted AAA bar set by [ADR-0016](docs/decisions/0016-accessibility-baseline-wcag-aa-targeted-aaa.md) rather than transcribing the stargate POC's React widget — the POC's drag UX, absent `aria-live`, and minimal screen-reader contract did not meet that bar.

The widget renders as a floating launcher bottom-right; clicking opens a dialog panel that can be toggled into fullscreen; sending a message streams the assistant's reply token-by-token; citations render as inline footnote chips with an extracted side panel for the snippet/source/score detail.

## What lands

### Files

```
apps/portal-shell/src/app/features/chatbot/
├── chatbot-host.ts             Standalone component, mounted in app.html via @defer
├── chatbot-host.html           Launcher + panel + suggestions + messages + form
├── chatbot-host.scss           7.6 KB compiled — under the new 8 KB budget
├── chatbot-host.spec.ts        12 cases: launcher / panel / log / input / citations
├── chatbot-citation-panel.ts   Extracted sibling component so the host stays under budget
├── chatbot-citation-panel.html dialog with metadata + snippet, role=dialog aria-modal=false
├── chatbot-citation-panel.scss 2.6 KB
├── chatbot.service.ts          Signals-based state (view / messages / streaming / citation)
├── chatbot.service.spec.ts     10 cases: view, send/stop, citations, errors
├── chatbot-api.service.ts      fetch + ReadableStream + CSRF cookie + AbortSignal
├── chatbot-api.service.spec.ts 5 cases: request shape, parsing, errors
├── sse-parser.ts               Pure ReadableStream<Uint8Array> → AsyncIterable<{event,data}>
├── sse-parser.spec.ts          8 cases: LF / CRLF / split chunks / trailing / JSON / passthrough
└── chatbot.types.ts            UI types decoupled from the proto-derived BFF types
```

Plus the shell-side glue:

- `apps/portal-shell/src/app/app.html` — `<app-chatbot-host />` mounted as a sibling of `<app-footer />`, wrapped in `@defer (on idle)` so the widget bundle (~27 KB lazy chunk) does not weigh on the initial paint.
- `apps/portal-shell/src/app/app.ts` — `ChatbotHost` added to `imports`.
- `apps/portal-shell/src/app/app.spec.ts` — `AUTH_CSRF_COOKIE_NAME` provider added to keep the shell smoke test compiling now that the SPA renders the chatbot.
- `apps/portal-shell/src/locale/messages.fr.xlf` — 19 new `@@chatbot.*` trans-units covering the trigger / title / fullscreen toggle / suggestions / input / streaming / errors / citation panel.
- `apps/portal-shell/project.json` — `anyComponentStyle` budget raised from `5/6 KB` to `6/8 KB` to match `portal-admin`'s posture (the audit page hit the same wall).
- `libs/shared/ui/src/lib/icon/icon.ts` — 6 new icons in the registry: `maximize-2`, `message-circle`, `minimize-2`, `send`, `square`, `x`.

### Accessibility decisions (per ADR-0016)

| Decision | Why |
|---|---|
| **No drag**. Fixed bottom-right launcher + fullscreen toggle. | Stargate's mouse-only drag had no keyboard equivalent. Keyboard parity is the project's bar; drag is the wrong primitive to inherit. |
| **`role="dialog"` + `aria-modal="false"`** (non-modal). | The page chrome stays operable behind the panel; no focus trap, no `inert` toggle on `<main>`. Closing returns focus to the launcher via a `viewChild` + `effect`. |
| **`role="log"` + `aria-live="polite"` + `aria-relevant="additions"`** on the message container. | Screen readers announce the assistant's incoming tokens without re-reading the whole history. Used a `<div>` + `<article>` children — `role="log"` is not allowed on `<ol>` / `<ul>` per ARIA. |
| **Typing dots `aria-hidden="true"`**, paired with a `<span class="sr-only">{{ streamingLabel }}</span>`. | The visual signal is decorative; the SR signal is textual. Animation gated by `@media (prefers-reduced-motion: reduce)`. |
| **Stop button visible during streaming**. | Wired to `AbortController` → `ChatClient` → upstream LLM cancel (the cancel chain shipped in #195). |
| **Inline `role="alert"`** on per-message error banners. | Errors are part of the conversation log, not page-level interruptions; assertive announcement keeps them perceivable without yanking focus. |
| **44 × 44 px touch targets everywhere**. | Launcher (3.5 rem), header chrome buttons, send / stop, citation chips, suggestion buttons. ADR-0016 baseline. |
| **Citations as inline footnotes** (`[1]`, `[2]`, …) + side panel with `source` / `score` / `snippet`. | Validated in the design check before implementation. Side panel extracted into its own component so the host stays under the SCSS budget. |
| **`prefers-reduced-motion` gating** on launcher transitions, suggestion hover, action button transitions, typing-dots animation. | Standard motion-preferences contract. |
| **i18n via `$localize`** with the `@@key` catalogue convention per [ADR-0019](docs/decisions/0019-internationalisation-angular-localize.md). | FR strings tagged at source; translations added to `messages.fr.xlf` (build fails otherwise — `i18nMissingTranslation: error`). |

### Streaming + cancellation

The browser-side flow:

1. SPA submits a prompt → `ChatbotService.send(prompt)`.
2. Service appends a user message + empty assistant placeholder, sets `isStreaming = true`.
3. `ChatbotApiService.openChatStream(...)` opens a `POST` with `Accept: text/event-stream`, `credentials: 'include'`, `X-CSRF-Token` from the `__Host-portal_csrf` cookie, and an `AbortSignal`.
4. The response body's `ReadableStream<Uint8Array>` is parsed frame-by-frame by `sse-parser.ts` and yielded as `AsyncIterable<{event, data}>`.
5. The state service routes each frame: `token` appends to the assistant message, `citation` accumulates with a 1-based index, `error` marks the message as failed, `done` terminates the stream.
6. Cancellation: clicking Stop, navigating away, or any unhandled error fires `AbortController.abort()` → fetch terminates → upstream gRPC call is cancelled → AI service stops the LLM.

Persistence: **session-ephemeral** by design. A SPA reload starts a fresh conversation. Matches the AI service's v1 posture (no per-user conversation table) and avoids the GDPR question of storing free-form transcripts before a consent flow lands.

### Native `fetch` + manual CSRF

`HttpClient` buffers responses; native `fetch().body` is the only way to consume the stream incrementally. As a consequence, the project's `bffCredentialsInterceptor` + `csrfInterceptor` do not run on this call. The service handles both concerns manually:

- `credentials: 'include'` is set explicitly so `__Host-portal_session` travels.
- The `__Host-portal_csrf` cookie is read via `document.cookie` (it is intentionally not `HttpOnly` per [ADR-0009](docs/decisions/0009-auth-flow-oidc-pkce-msal-node.md) §"CSRF defense") and echoed in the `X-CSRF-Token` header.

The cookie name + BFF base URL come from the same `AUTH_*` injection tokens the interceptors use, so the wire contract stays single-sourced.

## Notes for the reviewer

- **Why ChatbotCitationPanel is its own component.** Initial draft put the side panel inside `chatbot-host.scss`, which crossed the `anyComponentStyle` 6 KB error budget. Two paths to fix: raise the budget, or extract. Did both — the panel is genuinely its own dialog with its own ARIA contract, and the host stays under budget. The budget bump (5/6 → 6/8 KB) brings portal-shell in line with portal-admin where the same wall was hit on the audit page.
- **Why `@defer (on idle)` rather than `@defer (on viewport)` or eager.** Eager pushed the initial main bundle from ~282 KB to ~315 KB — past the 300 KB budget. The widget is not critical path on first paint, so deferring is correct on its own terms. `on idle` ensures it loads as soon as the browser is idle, so the launcher is interactive within a second on a fresh load. `on viewport` would have required the widget to be in the initial viewport, which the floating launcher is not consistently.
- **Why `<article>` children rather than `<li>` inside `role="log"`.** axe-linter (and the underlying ARIA spec) reject `role="log"` on `<ol>` / `<ul>`. Switched to `<div role="log">` with `<article>` children; semantically each chat turn is a discrete piece of content, which is exactly what `<article>` is for.
- **Why ChatbotService doesn't extend / re-use the existing `AuthService` patterns more directly.** Conversation state is ephemeral and not security-sensitive; the auth service's discriminated-union state machine is overkill for the toggle + buffer pattern the chatbot needs. The two services share the same `signal` / `computed` / `effect` idiom; that's enough consistency.
- **Why hard-coded suggestions instead of pulling from a server.** v1 ships with four French suggestions tagged for translation; server-driven suggestions are a v2 step that requires a new endpoint and a personalisation question that v1 doesn't need to answer. The current shape moves to server-driven by replacing the array literal with an effect that fetches — single point of change.
- **Tool-call event handling.** The SSE writer in #196 emits `event: tool-call` frames; the SPA parses them but does not render anything. v1 ships with an empty tool registry on the BFF side, so the AI service never emits them. When the first tool lands, the rendering UI is a follow-up PR.
- **stargate-a11y-uplift memory.** The memory note `feedback_stargate_a11y_uplift.md` codifies the rule used here for future migrations from stargate: adapt, don't transcribe. The PR text under "Accessibility decisions" is the case study.

## Test plan

- [x] `pnpm nx test portal-shell` — **85 specs pass** (was 58, +27 new across SSE parser / API service / state service / host component).
- [x] `pnpm nx test shared-ui` — 10 specs pass (icon registry exhaustive-key spec picks up the 6 new icons).
- [x] `pnpm nx lint portal-shell` — 0 errors. 5 pre-existing non-null assertion warnings in the new spec files are documented limits of vitest's mock typing.
- [x] `pnpm nx build portal-shell` — clean. Main bundle 297.49 KB raw (under 300 KB budget). Chatbot lazy chunk: 27.61 KB raw / 6.49 KB transfer. SCSS: host 7.6 KB / citation panel 2.6 KB, both under the new 8 KB error budget.
- [x] `pnpm nx run-many -t lint test build -p portal-shell,portal-admin,portal-bff,shared-ui,shared-charts` — five projects all green.
- [ ] **Manual smoke** (requires the BFF wired to `apf-ai-service` per #196's plan):
  1. `cd ../apf-ai-service && docker compose -f infra/docker-compose.yml up` to bring up the AI service.
  2. `AI_SERVICE_GRPC_ENDPOINT=localhost:8080` in `apps/portal-bff/.env`, then `pnpm nx serve portal-bff` + `pnpm nx serve portal-shell`.
  3. Sign in to the SPA, click the floating launcher bottom-right → panel opens, focus lands on the close button.
  4. Pick a suggestion → user message appears right-aligned, assistant message streams in left-aligned with the typing dots animating (unless `prefers-reduced-motion` is on).
  5. Click Stop mid-stream → the AI service log shows the gRPC call cancelled.
  6. Press Escape with focus inside the panel → panel closes, focus returns to the launcher.
  7. Toggle fullscreen → panel expands to `inset: 1rem`, ARIA contract unchanged.
  8. Toggle dark mode → all themed surfaces switch via the CSS-variable swap in `chatbot-host.scss`; AA contrast still holds against the brand tokens.
  9. Hit `/fr` and `/en` builds independently; suggestion labels swap between locales.

## What's next

The AI relay chantier closes here. Pending follow-ups stay as written in #196:

1. **PR (post-v1)** — proto-drift CI gate diffing the BFF's vendored `proto/apf-ai/` against an upstream tag of `apf-ai-service`.
2. **Coordinated amendment** — when the first production deployment is in scope, both repos record the same prod-hardening choice (signed `Principal` envelope or mTLS) on the same date.
3. **Tool-call rendering** — UI surface for the `tool-call` SSE frame, once the BFF gains its first tool descriptor.
4. **Server-driven suggestions** — replace the four hard-coded prompts with an effect that fetches per-user suggestions from a future endpoint.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #197
2026-05-20 01:39:13 +02:00
julien 883c5151de feat(portal-bff): ai-bridge controller — SSE chat + JSON rag/models (#196)
CI / commits (push) Has been skipped
CI / scan (push) Successful in 3m20s
CI / check (push) Successful in 4m2s
CI / a11y (push) Successful in 1m23s
CI / perf (push) Successful in 6m0s
Docs site / build (push) Successful in 2m56s
## 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
2026-05-19 22:39:35 +02:00
julien 9b7d16601d 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
2026-05-19 21:30:04 +02:00
julien 3f3f47317b docs(adr-0024): ai service relay — gRPC dial + SSE bridge + POC principal (#194)
CI / check (push) Successful in 2m31s
CI / scan (push) Successful in 2m12s
CI / commits (push) Has been skipped
CI / a11y (push) Successful in 2m27s
CI / perf (push) Successful in 3m59s
Docs site / build (push) Successful in 3m56s
## Summary

Proposes [ADR-0024](docs/decisions/0024-ai-service-relay-grpc-sse-bridge.md) — the integration contract between `apf_portal`'s BFF and the sibling `apf-ai-service` repository. The ADR bundles four tightly-coupled sub-choices: the wire transport between BFF and AI service, the wire transport between BFF and SPA for chat streaming, how the protos reach the BFF, and how user identity travels across the boundary in v1. **Status: `proposed`.** No code lands in this PR — the goal is to lock the contract before the implementation chantier starts.

The chosen design:

| Boundary | Choice |
|---|---|
| BFF ↔ AI service | Native **gRPC HTTP/2** via `@grpc/grpc-js`, h2c in dev / h2 + TLS in prod |
| BFF ↔ SPA (chat) | **`text/event-stream`** — one SSE frame per `ChatEvent` oneof case |
| BFF ↔ SPA (unary) | Plain JSON endpoints for `RagService.Search` + `ModelsService.ListModels` |
| Proto distribution | **Vendored** into `apps/portal-bff/src/grpc/proto/apf-ai/`, `ts-proto` codegen on demand, both `.proto` + generated `.ts` committed |
| Identity (POC) | **Unsigned `Principal { subject, roles[], attributes{} }`** in the proto body — mirrors `apf-ai-service`'s ADR-0010 |
| Production hardening | Choice between signed envelope and mTLS — **explicitly deferred** until first production deployment is in scope |

## What lands

- `docs/decisions/0024-ai-service-relay-grpc-sse-bridge.md` — new MADR-formatted ADR with the four sub-choices, decision drivers, considered options, consequences, confirmation criteria, open production-hardening question, and the related-ADRs map.
- `docs/decisions/README.md` — one new index row for ADR-0024 (`proposed`, tags `backend, security, observability`, 2026-05-19).

No source-code changes. No `CLAUDE.md` update — the ADR stays in `proposed` until reviewed, so the accepted-ADRs roll-up at the top of `CLAUDE.md` stays at 0001 → 0023. Promotion to `accepted` lands in the same PR that ships the first implementation chantier (proto vendor + `AiClientModule`), at which point `CLAUDE.md` gets the "0024 accepted" line.

## Notes for the reviewer

- **Why bundle four sub-choices in one ADR rather than four.** They couple tightly: the SPA-facing transport choice depends on the BFF-facing transport choice (gRPC-Web from the browser would dissolve the bridge layer entirely); the auth posture depends on having identity travel in the proto body (vendoring a different contract would change that); the proto-distribution choice depends on the contract being stable enough to vendor (a churning OpenAPI spec would push toward an SDK package). Splitting would force cross-ADR coordination on every revision. The ADR keeps a separate "Sub-choice" section per topic so each one stays reviewable on its own.
- **Out of scope deliberately.** The chatbot UI lives in a future frontend chantier; the role mapper (Entra groups → inclusive-expanded `roles[]`) is a separate proposed ADR; the ingestion-through-BFF path waits for the admin app's "manage AI corpus" surface; tool dispatch is wired but exercised against an empty registry in v1.
- **Hash-salt coordination is the one operational gotcha.** The same `HashUserIdService` salt has to land in both repos' deployment config so `apf-ai-service.audit_log.actor_id_hash` and `apf_portal.audit.events.actor_id_hash` produce identical values. Recorded as an open item in the ADR's "More Information" section; the deployment doc that distributes the secret is a v1-launch deliverable.
- **`apf-ai-service` cross-reference**. The ADR references `apf-ai-service/docs/adr/ADR-0010` (POC unsigned principal) and `apf-ai-service/docs/adr/ADR-0011` (mono-transport gRPC) as upstream anchors. Both are already accepted on the AI side. The "production hardening" decision will be a coordinated amendment in both repos on the same date.
- **No `DownstreamApiClient` (ADR-0014) reuse.** The OBO pattern in ADR-0014 targets *Entra-protected* downstreams that validate the user's access token. `apf-ai-service` is not Entra-protected — it accepts an unsigned Principal proto. The ADR explicitly calls this out so the reader does not expect symmetry with the Entra-protected downstream path.
- **Phasing recorded in the ADR's "More Information" section.** This PR is step (1) "ADR accepted". Steps 2–5 are separate PRs in order: client skeleton → bridge controller → frontend chatbot → proto-drift CI gate.

## Test plan

- [x] `pnpm run --silent prettier --check docs/decisions/0024-ai-service-relay-grpc-sse-bridge.md` — passes (hook ran on commit).
- [x] Markdown links inside the ADR resolve to existing files (`0005`, `0009`, `0010`, `0012`, `0013`, `0014`, `0017`, plus `CLAUDE.md`).
- [x] Index row in `docs/decisions/README.md` follows the table's existing format (column count, tag vocabulary, date format).
- [x] No tag-vocabulary additions required — `backend`, `security`, `observability` are all in the existing vocab.
- [ ] **Review focus** — the four sub-choices and the production-hardening deferral. Code chantier is gated on this PR's acceptance.

## What's next (once accepted)

1. **PR — proto vendor + codegen + `AiClientModule` skeleton** — vendors the protos, wires `ts-proto` codegen, sets up the NestJS module with the metadata interceptor and the Principal mapper, all tested against an in-process fake gRPC server. No live endpoint yet.
2. **PR — `ai-bridge` controller** — `POST /api/ai/chat` (SSE), `GET /api/ai/rag/search`, `GET /api/ai/models`, live against `apf-ai-service` in the dev Compose stack.
3. **PR (frontend chantier)** — the chatbot widget on `portal-shell` consuming the SSE endpoint.
4. **PR (post-v1)** — proto-drift CI gate that diffs the vendored copy against the upstream tag.
5. **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: #194
2026-05-19 20:11:15 +02:00
APF Portal Bot 40d4f7d290 chore(deps): update dependency cytoscape to v3.33.4 (#193)
CI / commits (push) Has been skipped
CI / scan (push) Successful in 2m47s
CI / a11y (push) Successful in 2m47s
CI / check (push) Successful in 5m52s
CI / perf (push) Successful in 6m45s
Docs site / build (push) Successful in 2m27s
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| [cytoscape](http://js.cytoscape.org) ([source](https://github.com/cytoscape/cytoscape.js)) | devDependencies | patch | [`3.33.3` -> `3.33.4`](https://renovatebot.com/diffs/npm/cytoscape/3.33.3/3.33.4) |

---

### Release Notes

<details>
<summary>cytoscape/cytoscape.js (cytoscape)</summary>

### [`v3.33.4`](https://github.com/cytoscape/cytoscape.js/releases/tag/v3.33.4)

[Compare Source](https://github.com/cytoscape/cytoscape.js/compare/v3.33.3...v3.33.4)

Release version v3.33.4

</details>

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined).

🚦 **Automerge**: Enabled.

♻ **Rebasing**: Whenever PR is behind base branch, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0MC42Mi4xIiwidXBkYXRlZEluVmVyIjoiNDAuNjIuMSIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOlsiZGVwZW5kZW5jaWVzIl19-->

Reviewed-on: #193
Co-authored-by: APF Portal Bot <jgautier.webdev+apf-portal-bot@gmail.com>
Co-committed-by: APF Portal Bot <jgautier.webdev+apf-portal-bot@gmail.com>
2026-05-19 18:39:15 +02:00
APF Portal Bot 8bdfe0a273 chore(deps): update dependency ts-jest to v29.4.10 (#192)
CI / commits (push) Has been skipped
CI / scan (push) Successful in 2m34s
CI / a11y (push) Successful in 2m7s
CI / check (push) Successful in 4m44s
CI / perf (push) Successful in 5m54s
Docs site / build (push) Successful in 2m19s
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| [ts-jest](https://kulshekhar.github.io/ts-jest) ([source](https://github.com/kulshekhar/ts-jest)) | devDependencies | patch | [`29.4.9` -> `29.4.10`](https://renovatebot.com/diffs/npm/ts-jest/29.4.9/29.4.10) |

---

### Release Notes

<details>
<summary>kulshekhar/ts-jest (ts-jest)</summary>

### [`v29.4.10`](https://github.com/kulshekhar/ts-jest/blob/HEAD/CHANGELOG.md#29410-2026-05-18)

[Compare Source](https://github.com/kulshekhar/ts-jest/compare/v29.4.9...v29.4.10)

##### Bug Fixes

- pass `resolutionMode` to `ts.resolveModuleName` for hybrid module support ([b557a85](https://github.com/kulshekhar/ts-jest/commit/b557a85f85c3fd34523ec3a15293afbdc9dea83c))
- rebuild `Program` when consecutive compiles need different module kinds ([a82a2b3](https://github.com/kulshekhar/ts-jest/commit/a82a2b32c4987a5249fd5284283117dd2fa3be47)), closes [#&#8203;4774](https://github.com/kulshekhar/ts-jest/issues/4774)
- respect tsconfig `moduleResolution` instead of forcing `Node10` ([1bffffc](https://github.com/kulshekhar/ts-jest/commit/1bffffc667557c173ae0c1f93dd436920775dac4))
- **transformer:** transpile `mjs` files from `node_modules` for CJS mode ([96d025d](https://github.com/kulshekhar/ts-jest/commit/96d025dd912ea2bceb18b67d2d509ada7a756d9d))
- **transformer:** use a consistent comparator in hoist-jest sortStatements ([8a8fd2f](https://github.com/kulshekhar/ts-jest/commit/8a8fd2fb8446655bba18367db9306a1089490e62))

</details>

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined).

🚦 **Automerge**: Enabled.

♻ **Rebasing**: Whenever PR is behind base branch, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0MC42Mi4xIiwidXBkYXRlZEluVmVyIjoiNDAuNjIuMSIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOlsiZGVwZW5kZW5jaWVzIl19-->

Reviewed-on: https://git.unespace.com/julien/apf_portal/pulls/192
Co-authored-by: APF Portal Bot <jgautier.webdev+apf-portal-bot@gmail.com>
Co-committed-by: APF Portal Bot <jgautier.webdev+apf-portal-bot@gmail.com>
2026-05-19 15:12:31 +02:00
APF Portal Bot 90504dd32b chore(deps): update dependency postcss to v8.5.15 (#191)
CI / commits (push) Has been skipped
CI / scan (push) Successful in 4m5s
CI / check (push) Successful in 6m21s
CI / a11y (push) Successful in 2m21s
CI / perf (push) Successful in 7m14s
Docs site / build (push) Successful in 2m35s
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| [postcss](https://postcss.org/) ([source](https://github.com/postcss/postcss)) | devDependencies | patch | [`8.5.14` -> `8.5.15`](https://renovatebot.com/diffs/npm/postcss/8.5.14/8.5.15) |

---

### Release Notes

<details>
<summary>postcss/postcss (postcss)</summary>

### [`v8.5.15`](https://github.com/postcss/postcss/blob/HEAD/CHANGELOG.md#8515)

[Compare Source](https://github.com/postcss/postcss/compare/8.5.14...8.5.15)

- Fixed declaration parsing performance (by [@&#8203;homanp](https://github.com/homanp)).

</details>

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined).

🚦 **Automerge**: Enabled.

♻ **Rebasing**: Whenever PR is behind base branch, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0MC42Mi4xIiwidXBkYXRlZEluVmVyIjoiNDAuNjIuMSIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOlsiZGVwZW5kZW5jaWVzIl19-->

Reviewed-on: #191
Co-authored-by: APF Portal Bot <jgautier.webdev+apf-portal-bot@gmail.com>
Co-committed-by: APF Portal Bot <jgautier.webdev+apf-portal-bot@gmail.com>
2026-05-19 14:20:10 +02:00
julien 8136695fa8 chore(deps): bump brace-expansion + ws overrides to clear audit (#190)
CI / check (push) Successful in 5m10s
CI / commits (push) Has been skipped
CI / scan (push) Successful in 2m27s
CI / a11y (push) Successful in 2m11s
CI / perf (push) Successful in 5m18s
Docs site / build (push) Successful in 5m1s
## Summary

Clears the two moderate GHSA advisories that started failing `ci:audit`:

- **`brace-expansion`** `>=5.0.0 <5.0.6` — DoS via large numeric range that bypasses the documented `max` protection ([GHSA-jxxr-4gwj-5jf2](https://github.com/advisories/GHSA-jxxr-4gwj-5jf2)). The existing override floor was at `5.0.5`; bumped one patch to `5.0.6`.
- **`ws`** `>=8.0.0 <8.20.1` — uninitialized memory disclosure ([GHSA-58qx-3vcg-4xpx](https://github.com/advisories/GHSA-58qx-3vcg-4xpx)). No prior override; added one scoped to the 8.x advisory window only.

Both are reached transitively through Nx; nothing in this repo's own dependency surface is vulnerable directly.

## What lands

`package.json` (`pnpm.overrides` block):

```diff
- "brace-expansion@<5.0.5": ">=5.0.5",
+ "brace-expansion@<5.0.6": ">=5.0.6",

+ "ws@>=8.0.0 <8.20.1": ">=8.20.1",
```

`pnpm-lock.yaml` reconciled with `pnpm install`.

## Notes for the reviewer

- **Why the `ws` override is range-scoped, not a blanket `ws@<8.20.1`.** Lighthouse pulls `ws@7.5.10` via its own tree; the 7.x line sits entirely outside the advisory window (`>=8.0.0 <8.20.1`). A blanket `<8.20.1` override would force-bump that transitive across a major version boundary and risk Lighthouse breakage with no security gain. The range form `>=8.0.0 <8.20.1 → >=8.20.1` patches exactly the vulnerable consumers (`@module-federation/dts-plugin`, etc.) and leaves Lighthouse's pin untouched.
- **Why an override and not waiting for Nx to bump.** Nx 22.7.2 is already on `main` (commits `bd94bb4` / `6b20c34`) but its sub-chain still resolves `ws@8.18.0` and `brace-expansion@5.0.5`. Upstream pickup will land eventually via Renovate; in the meantime the audit gate blocks CI on every PR. Overrides are the standard pnpm escape hatch for this exact situation. The pattern is already used in this file (`axios`, `ajv`, `esbuild`, `follow-redirects`, `ip-address`, `protobufjs`, `tmp`, `yaml`).
- **Pruning policy.** Once Nx ships a release whose `pnpm-lock.yaml` resolves both packages at or above the patched versions on its own, both override entries can be removed. The convention in this file's existing entries is to leave overrides in place even when redundant (cheap insurance against silent regressions); pruning is a separate sweep, not part of this fix.
- **No application code touched.** Both packages live deep in the Nx/Module Federation build tooling — `brace-expansion` inside `minimatch`'s glob expansion, `ws` inside Module Federation's HMR dev socket. Neither surfaces in the BFF or SPA runtime bundles. Build + test + lint were re-run across `portal-shell`, `portal-admin`, `portal-bff`, `shared-charts`, `shared-ui` as a sanity check; all green.

## Test plan

- [x] `pnpm install` — lockfile reconciled, no extraneous package churn.
- [x] `pnpm audit --audit-level=moderate` — "No known vulnerabilities found" (replaces the two-row failure that started this PR).
- [x] `pnpm nx run-many -t build test lint -p portal-shell,portal-admin,portal-bff,shared-charts,shared-ui` — all green. Module Federation's HMR socket (the surface that *uses* `ws`) is exercised implicitly by every Angular build via `@nx/angular`'s webpack pipeline.
- [ ] **Manual smoke** — `pnpm nx serve portal-shell` + `pnpm nx serve portal-admin`: dev servers come up, HMR reloads on a trivial edit, no warnings or stack traces about `ws` or `brace-expansion` resolution.

## What's next

- Renovate will eventually pick up an Nx release whose own sub-chain ships `ws@>=8.20.1` and `brace-expansion@>=5.0.6` natively. At that point, the two override entries here are dead weight and can be pruned as a follow-up sweep alongside any other stale overrides.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #190
2026-05-19 12:28:13 +02:00
julien aa61ea0e02 feat(portal-shell): ghost-style Sign-in button with log-in icon (#189)
CI / commits (push) Has been skipped
CI / scan (push) Failing after 4m3s
CI / check (push) Successful in 4m31s
CI / a11y (push) Successful in 1m16s
CI / perf (push) Successful in 5m53s
## Summary

The anonymous "Sign in" CTA in `portal-shell`'s header was a filled brand-primary block sitting next to two round icon-only buttons (Notifications, Help). The contrast was off — the filled rectangle visually dominated the row even though it's the *third* action in the strip. This PR turns it into a ghost-style button (no fill, no border) with a `log-in` icon ahead of the label, matching the quiet posture of its neighbours while still reading as the primary CTA for unauthenticated users.

## What lands

`apps/portal-shell/src/app/components/header/header.html` — the anonymous-state button:

| Aspect | Before | After |
|---|---|---|
| Fill | `bg-brand-primary-500` (filled) | `bg-transparent` (ghost) |
| Text colour | `text-white` | `text-brand-primary-500` (`-300` in dark) |
| Border | none | none — pure text-only style |
| Hover | darker fill | light `bg-brand-primary-50` tint + `text-brand-primary-600` |
| Icon | (none) | `<lib-icon name="log-in" [size]="16" aria-hidden="true" />` ahead of the label |
| Padding / gap | `px-4 gap-2` | `px-3 gap-1.5` (slightly tighter, makes room for the icon without growing the chrome) |
| Height | `h-11` | `h-11` (unchanged — 44 × 44 touch target per [ADR-0016](docs/decisions/0016-accessibility-baseline-wcag-aa-targeted-aaa.md) holds) |

`libs/shared/ui/src/lib/icon/icon.ts` — adds the missing pair of the existing `log-out` icon:

- Imports `LogIn` from `lucide-angular`.
- Registers `'log-in': LogIn,` in the alphabetical registry between `'layout-dashboard'` and `'log-out'`.

## Notes for the reviewer

- **Ghost vs. outline vs. filled — why ghost.** Tried two intermediate iterations during the design pass (outline with brand border, then a more compact outline). The user preferred the ghost rendering once we removed the border — the header strip is the right surface for an "always-quiet, surfaces on hover" CTA, since the user typically scans for the search bar first, not the auth state. Filled buttons are the right call inside content where the CTA *is* the focal point (forms, modals).
- **Touch-target stays at 44 × 44.** `h-11` is kept on purpose. The CI a11y gate from ADR-0016 (`touch-target check (44×44 min)`) is non-negotiable for interactive controls; visually shrinking horizontal padding + reducing visual weight is the right way to "compact" a button without breaking the target rule.
- **`aria-hidden="true"` on the icon.** The adjacent `<span>` carries the localised label, and the screen-reader contract is "the button announces 'Sign in', not 'log-in icon Sign in'". The icon is decorative reinforcement.
- **Label still wrapped in `<span i18n="@@header.signIn">` rather than directly on the button.** Required because the button now contains both an icon child and the text — Angular's `i18n` on the button itself would extract the icon's rendered SVG into the translation unit, which is not what translators want to see. Wrapping the text isolates the translation unit cleanly.
- **`log-in` belongs in `shared-ui`, not portal-shell.** Even though portal-shell is the only consumer today, the icon registry is by contract the single point of truth for both apps — `portal-admin`'s eventual sign-in surface will use the same icon, so registering it once in the shared lib is the right boundary.

## Test plan

- [x] `pnpm nx test portal-shell` — green. The existing spec asserts `btn.textContent.trim() === 'Sign in'`; the `<lib-icon>` renders to an SVG (no text content) and the label is now inside a `<span>`, so the text-trim check still holds.
- [x] `pnpm nx test shared-ui` — green. Icon registry's exhaustive-key spec picks up the new entry automatically (it iterates `Object.keys(ICON_REGISTRY)`).
- [x] `pnpm nx build portal-shell` — clean, no bundle-size deltas worth flagging (`log-in` is tree-shaken alongside the rest of lucide-angular).
- [x] `pnpm nx lint portal-shell shared-ui` — clean.
- [ ] **Manual smoke** — `pnpm nx serve portal-shell`, signed-out, header visible:
  - Anonymous state: ghost "Sign in" button with the log-in icon. Hover surfaces a faint fill; focus shows the brand outline ring.
  - Switch to authenticated: button is replaced by `<lib-user-menu>` (unchanged).
  - `error` state: amber "Can't reach the server" badge (unchanged).
  - Toggle dark mode: text shifts to `brand-primary-300`, hover surfaces a `gray-800` fill; still readable.
  - Tab from the address bar into the header — focus order: search input → bell → help → Sign in. Focus ring on the ghost button matches the other icon buttons (`outline-brand-primary-500 outline-offset-2`).

## What's next

- `portal-admin` will get the same `log-in` icon for its own (still skeleton) sign-in surface once that wiring lands — single shared registry means no further change here.
- If the marketing folks ever ask the sign-in CTA to come back forward visually (festival days, post-incident push), the ghost class block can flip to a filled variant locally without touching the icon or i18n contract.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #189
2026-05-19 11:42:33 +02:00
APF Portal Bot 3e120da668 chore(deps): update typescript tooling to v8.59.4 (#188)
CI / commits (push) Has been skipped
CI / check (push) Successful in 5m28s
CI / a11y (push) Successful in 1m59s
CI / perf (push) Successful in 6m34s
Docs site / build (push) Successful in 2m31s
CI / scan (push) Failing after 1m6s
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| [@typescript-eslint/utils](https://typescript-eslint.io/packages/utils) ([source](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/utils)) | devDependencies | patch | [`8.59.3` -> `8.59.4`](https://renovatebot.com/diffs/npm/@typescript-eslint%2futils/8.59.3/8.59.4) |
| [typescript-eslint](https://typescript-eslint.io/packages/typescript-eslint) ([source](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint)) | devDependencies | patch | [`8.59.3` -> `8.59.4`](https://renovatebot.com/diffs/npm/typescript-eslint/8.59.3/8.59.4) |

---

### Release Notes

<details>
<summary>typescript-eslint/typescript-eslint (@&#8203;typescript-eslint/utils)</summary>

### [`v8.59.4`](https://github.com/typescript-eslint/typescript-eslint/blob/HEAD/packages/utils/CHANGELOG.md#8594-2026-05-18)

[Compare Source](https://github.com/typescript-eslint/typescript-eslint/compare/v8.59.3...v8.59.4)

This was a version bump only for utils to align it with other projects, there were no code changes.

See [GitHub Releases](https://github.com/typescript-eslint/typescript-eslint/releases/tag/v8.59.4) for more information.

You can read about our [versioning strategy](https://typescript-eslint.io/users/versioning) and [releases](https://typescript-eslint.io/users/releases) on our website.

</details>

<details>
<summary>typescript-eslint/typescript-eslint (typescript-eslint)</summary>

### [`v8.59.4`](https://github.com/typescript-eslint/typescript-eslint/blob/HEAD/packages/typescript-eslint/CHANGELOG.md#8594-2026-05-18)

[Compare Source](https://github.com/typescript-eslint/typescript-eslint/compare/v8.59.3...v8.59.4)

##### 🩹 Fixes

- **typescript-eslint:** export Compatible\* types from typescript-eslint to resolve pnpm TS error ([#&#8203;12340](https://github.com/typescript-eslint/typescript-eslint/pull/12340))

##### ❤️ Thank You

- Kirk Waiblinger [@&#8203;kirkwaiblinger](https://github.com/kirkwaiblinger)

See [GitHub Releases](https://github.com/typescript-eslint/typescript-eslint/releases/tag/v8.59.4) for more information.

You can read about our [versioning strategy](https://typescript-eslint.io/users/versioning) and [releases](https://typescript-eslint.io/users/releases) on our website.

</details>

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined).

🚦 **Automerge**: Enabled.

♻ **Rebasing**: Whenever PR is behind base branch, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about these updates again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0MC42Mi4xIiwidXBkYXRlZEluVmVyIjoiNDAuNjIuMSIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOlsiZGVwZW5kZW5jaWVzIl19-->

Reviewed-on: #188
Co-authored-by: APF Portal Bot <jgautier.webdev+apf-portal-bot@gmail.com>
Co-committed-by: APF Portal Bot <jgautier.webdev+apf-portal-bot@gmail.com>
2026-05-19 09:48:46 +02:00
APF Portal Bot 5ef1e3d9bb chore(deps): lock file maintenance (#187)
CI / commits (push) Has been skipped
CI / scan (push) Successful in 3m27s
CI / check (push) Successful in 5m46s
CI / a11y (push) Successful in 2m27s
CI / perf (push) Successful in 7m20s
Docs site / build (push) Successful in 3m0s
This PR contains the following updates:

| Update | Change |
|---|---|
| lockFileMaintenance | All locks refreshed |

🔧 This Pull Request updates lock files to use the latest dependency versions.

---

### Configuration

📅 **Schedule**: Branch creation - "before 4am on monday" (UTC), Automerge - At any time (no schedule defined).

🚦 **Automerge**: Enabled.

♻ **Rebasing**: Whenever PR is behind base branch, or you tick the rebase/retry checkbox.

👻 **Immortal**: This PR will be recreated if closed unmerged. Get [config help](https://github.com/renovatebot/renovate/discussions) if that's undesired.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0MC42Mi4xIiwidXBkYXRlZEluVmVyIjoiNDAuNjIuMSIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOlsiZGVwZW5kZW5jaWVzIl19-->

Reviewed-on: #187
Co-authored-by: APF Portal Bot <jgautier.webdev+apf-portal-bot@gmail.com>
Co-committed-by: APF Portal Bot <jgautier.webdev+apf-portal-bot@gmail.com>
2026-05-18 03:08:42 +02:00
APF Portal Bot 0e206d4f89 chore(deps): lock file maintenance (#186)
CI / commits (push) Has been skipped
CI / scan (push) Successful in 5m1s
CI / check (push) Successful in 6m25s
CI / a11y (push) Successful in 1m48s
CI / perf (push) Successful in 7m35s
Docs site / build (push) Successful in 3m28s
This PR contains the following updates:

| Update | Change |
|---|---|
| lockFileMaintenance | All locks refreshed |

🔧 This Pull Request updates lock files to use the latest dependency versions.

---

### Configuration

📅 **Schedule**: Branch creation - "before 4am on monday" (UTC), Automerge - At any time (no schedule defined).

🚦 **Automerge**: Enabled.

♻ **Rebasing**: Whenever PR is behind base branch, or you tick the rebase/retry checkbox.

👻 **Immortal**: This PR will be recreated if closed unmerged. Get [config help](https://github.com/renovatebot/renovate/discussions) if that's undesired.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0MC42Mi4xIiwidXBkYXRlZEluVmVyIjoiNDAuNjIuMSIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOlsiZGVwZW5kZW5jaWVzIl19-->

Reviewed-on: #186
Co-authored-by: APF Portal Bot <jgautier.webdev+apf-portal-bot@gmail.com>
Co-committed-by: APF Portal Bot <jgautier.webdev+apf-portal-bot@gmail.com>
2026-05-18 02:50:41 +02:00
APF Portal Bot 93cf30679e fix(deps): update opentelemetry-js-contrib monorepo (#184)
CI / scan (push) Successful in 4m43s
CI / commits (push) Has been skipped
CI / check (push) Successful in 7m47s
CI / a11y (push) Successful in 3m17s
CI / perf (push) Successful in 5m56s
Docs site / build (push) Successful in 3m15s
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| [@opentelemetry/instrumentation-document-load](https://github.com/open-telemetry/opentelemetry-js-contrib/tree/main/packages/instrumentation-document-load#readme) ([source](https://github.com/open-telemetry/opentelemetry-js-contrib/tree/HEAD/packages/instrumentation-document-load)) | dependencies | minor | [`^0.62.0` -> `^0.63.0`](https://renovatebot.com/diffs/npm/@opentelemetry%2finstrumentation-document-load/0.62.0/0.63.0) |
| [@opentelemetry/instrumentation-express](https://github.com/open-telemetry/opentelemetry-js-contrib/tree/main/packages/instrumentation-express#readme) ([source](https://github.com/open-telemetry/opentelemetry-js-contrib/tree/HEAD/packages/instrumentation-express)) | dependencies | minor | [`^0.65.0` -> `^0.66.0`](https://renovatebot.com/diffs/npm/@opentelemetry%2finstrumentation-express/0.65.0/0.66.0) |
| [@opentelemetry/instrumentation-ioredis](https://github.com/open-telemetry/opentelemetry-js-contrib/tree/main/packages/instrumentation-ioredis#readme) ([source](https://github.com/open-telemetry/opentelemetry-js-contrib/tree/HEAD/packages/instrumentation-ioredis)) | dependencies | minor | [`^0.65.0` -> `^0.66.0`](https://renovatebot.com/diffs/npm/@opentelemetry%2finstrumentation-ioredis/0.65.0/0.66.0) |
| [@opentelemetry/instrumentation-nestjs-core](https://github.com/open-telemetry/opentelemetry-js-contrib/tree/main/packages/instrumentation-nestjs-core#readme) ([source](https://github.com/open-telemetry/opentelemetry-js-contrib/tree/HEAD/packages/instrumentation-nestjs-core)) | dependencies | minor | [`^0.63.0` -> `^0.64.0`](https://renovatebot.com/diffs/npm/@opentelemetry%2finstrumentation-nestjs-core/0.63.0/0.64.0) |
| [@opentelemetry/instrumentation-pg](https://github.com/open-telemetry/opentelemetry-js-contrib/tree/main/packages/instrumentation-pg#readme) ([source](https://github.com/open-telemetry/opentelemetry-js-contrib/tree/HEAD/packages/instrumentation-pg)) | dependencies | minor | [`^0.69.0` -> `^0.70.0`](https://renovatebot.com/diffs/npm/@opentelemetry%2finstrumentation-pg/0.69.0/0.70.0) |
| [@opentelemetry/instrumentation-pino](https://github.com/open-telemetry/opentelemetry-js-contrib/tree/main/packages/instrumentation-pino#readme) ([source](https://github.com/open-telemetry/opentelemetry-js-contrib/tree/HEAD/packages/instrumentation-pino)) | dependencies | minor | [`^0.63.0` -> `^0.64.0`](https://renovatebot.com/diffs/npm/@opentelemetry%2finstrumentation-pino/0.63.0/0.64.0) |
| [@opentelemetry/instrumentation-user-interaction](https://github.com/open-telemetry/opentelemetry-js-contrib/tree/main/packages/instrumentation-user-interaction#readme) ([source](https://github.com/open-telemetry/opentelemetry-js-contrib/tree/HEAD/packages/instrumentation-user-interaction)) | dependencies | minor | [`^0.61.0` -> `^0.62.0`](https://renovatebot.com/diffs/npm/@opentelemetry%2finstrumentation-user-interaction/0.61.0/0.62.0) |

---

### Release Notes

<details>
<summary>open-telemetry/opentelemetry-js-contrib (@&#8203;opentelemetry/instrumentation-document-load)</summary>

### [`v0.63.0`](https://github.com/open-telemetry/opentelemetry-js-contrib/blob/HEAD/packages/instrumentation-document-load/CHANGELOG.md#0630-2026-05-13)

[Compare Source](https://github.com/open-telemetry/opentelemetry-js-contrib/compare/b68fb6dcc0649631ebecab7bde81879486f74f0b...15ef7506553f631ea4181391e0c5725a56f0d082)

##### Features

- **deps:** update deps matching '@&#8203;opentelemetry/\*' ([#&#8203;3523](https://github.com/open-telemetry/opentelemetry-js-contrib/issues/3523)) ([e26a90a](https://github.com/open-telemetry/opentelemetry-js-contrib/commit/e26a90af6e2fb4666b22388b770add7a60140c9b))

</details>

<details>
<summary>open-telemetry/opentelemetry-js-contrib (@&#8203;opentelemetry/instrumentation-express)</summary>

### [`v0.66.0`](https://github.com/open-telemetry/opentelemetry-js-contrib/blob/HEAD/packages/instrumentation-express/CHANGELOG.md#0660-2026-05-13)

[Compare Source](https://github.com/open-telemetry/opentelemetry-js-contrib/compare/b68fb6dcc0649631ebecab7bde81879486f74f0b...15ef7506553f631ea4181391e0c5725a56f0d082)

##### Features

- **deps:** update deps matching '@&#8203;opentelemetry/\*' ([#&#8203;3523](https://github.com/open-telemetry/opentelemetry-js-contrib/issues/3523)) ([e26a90a](https://github.com/open-telemetry/opentelemetry-js-contrib/commit/e26a90af6e2fb4666b22388b770add7a60140c9b))

##### Dependencies

- The following workspace dependencies were updated
  - devDependencies
    - [@&#8203;opentelemetry/contrib-test-utils](https://github.com/opentelemetry/contrib-test-utils) bumped from ^0.64.0 to ^0.65.0

</details>

<details>
<summary>open-telemetry/opentelemetry-js-contrib (@&#8203;opentelemetry/instrumentation-ioredis)</summary>

### [`v0.66.0`](https://github.com/open-telemetry/opentelemetry-js-contrib/blob/HEAD/packages/instrumentation-ioredis/CHANGELOG.md#0660-2026-05-13)

[Compare Source](https://github.com/open-telemetry/opentelemetry-js-contrib/compare/b68fb6dcc0649631ebecab7bde81879486f74f0b...15ef7506553f631ea4181391e0c5725a56f0d082)

##### Features

- **deps:** update deps matching '@&#8203;opentelemetry/\*' ([#&#8203;3523](https://github.com/open-telemetry/opentelemetry-js-contrib/issues/3523)) ([e26a90a](https://github.com/open-telemetry/opentelemetry-js-contrib/commit/e26a90af6e2fb4666b22388b770add7a60140c9b))

##### Dependencies

- The following workspace dependencies were updated
  - devDependencies
    - [@&#8203;opentelemetry/contrib-test-utils](https://github.com/opentelemetry/contrib-test-utils) bumped from ^0.64.0 to ^0.65.0

</details>

<details>
<summary>open-telemetry/opentelemetry-js-contrib (@&#8203;opentelemetry/instrumentation-nestjs-core)</summary>

### [`v0.64.0`](https://github.com/open-telemetry/opentelemetry-js-contrib/blob/HEAD/packages/instrumentation-nestjs-core/CHANGELOG.md#0640-2026-05-13)

[Compare Source](https://github.com/open-telemetry/opentelemetry-js-contrib/compare/b68fb6dcc0649631ebecab7bde81879486f74f0b...15ef7506553f631ea4181391e0c5725a56f0d082)

##### Features

- **deps:** update deps matching '@&#8203;opentelemetry/\*' ([#&#8203;3523](https://github.com/open-telemetry/opentelemetry-js-contrib/issues/3523)) ([e26a90a](https://github.com/open-telemetry/opentelemetry-js-contrib/commit/e26a90af6e2fb4666b22388b770add7a60140c9b))

</details>

<details>
<summary>open-telemetry/opentelemetry-js-contrib (@&#8203;opentelemetry/instrumentation-pg)</summary>

### [`v0.70.0`](https://github.com/open-telemetry/opentelemetry-js-contrib/blob/HEAD/packages/instrumentation-pg/CHANGELOG.md#0700-2026-05-13)

[Compare Source](https://github.com/open-telemetry/opentelemetry-js-contrib/compare/b68fb6dcc0649631ebecab7bde81879486f74f0b...15ef7506553f631ea4181391e0c5725a56f0d082)

##### Features

- **deps:** update deps matching '@&#8203;opentelemetry/\*' ([#&#8203;3523](https://github.com/open-telemetry/opentelemetry-js-contrib/issues/3523)) ([e26a90a](https://github.com/open-telemetry/opentelemetry-js-contrib/commit/e26a90af6e2fb4666b22388b770add7a60140c9b))

##### Dependencies

- The following workspace dependencies were updated
  - devDependencies
    - [@&#8203;opentelemetry/contrib-test-utils](https://github.com/opentelemetry/contrib-test-utils) bumped from ^0.64.0 to ^0.65.0

</details>

<details>
<summary>open-telemetry/opentelemetry-js-contrib (@&#8203;opentelemetry/instrumentation-pino)</summary>

### [`v0.64.0`](https://github.com/open-telemetry/opentelemetry-js-contrib/blob/HEAD/packages/instrumentation-pino/CHANGELOG.md#0640-2026-05-13)

[Compare Source](https://github.com/open-telemetry/opentelemetry-js-contrib/compare/b68fb6dcc0649631ebecab7bde81879486f74f0b...15ef7506553f631ea4181391e0c5725a56f0d082)

##### Features

- **deps:** update deps matching '@&#8203;opentelemetry/\*' ([#&#8203;3523](https://github.com/open-telemetry/opentelemetry-js-contrib/issues/3523)) ([e26a90a](https://github.com/open-telemetry/opentelemetry-js-contrib/commit/e26a90af6e2fb4666b22388b770add7a60140c9b))

##### Dependencies

- The following workspace dependencies were updated
  - devDependencies
    - [@&#8203;opentelemetry/contrib-test-utils](https://github.com/opentelemetry/contrib-test-utils) bumped from ^0.64.0 to ^0.65.0

</details>

<details>
<summary>open-telemetry/opentelemetry-js-contrib (@&#8203;opentelemetry/instrumentation-user-interaction)</summary>

### [`v0.62.0`](https://github.com/open-telemetry/opentelemetry-js-contrib/blob/HEAD/packages/instrumentation-user-interaction/CHANGELOG.md#0620-2026-05-13)

[Compare Source](https://github.com/open-telemetry/opentelemetry-js-contrib/compare/b68fb6dcc0649631ebecab7bde81879486f74f0b...15ef7506553f631ea4181391e0c5725a56f0d082)

##### Features

- **deps:** update deps matching '@&#8203;opentelemetry/\*' ([#&#8203;3523](https://github.com/open-telemetry/opentelemetry-js-contrib/issues/3523)) ([e26a90a](https://github.com/open-telemetry/opentelemetry-js-contrib/commit/e26a90af6e2fb4666b22388b770add7a60140c9b))

</details>

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined).

🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.

👻 **Immortal**: This PR will be recreated if closed unmerged. Get [config help](https://github.com/renovatebot/renovate/discussions) if that's undesired.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0MC42Mi4xIiwidXBkYXRlZEluVmVyIjoiNDAuNjIuMSIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOlsiZGVwZW5kZW5jaWVzIl19-->

Reviewed-on: https://git.unespace.com/julien/apf_portal/pulls/184
Co-authored-by: APF Portal Bot <jgautier.webdev+apf-portal-bot@gmail.com>
Co-committed-by: APF Portal Bot <jgautier.webdev+apf-portal-bot@gmail.com>
2026-05-18 01:22:56 +02:00
APF Portal Bot ee59fd900c fix(deps): update opentelemetry-js monorepo (#183)
CI / scan (push) Successful in 1m32s
CI / commits (push) Has been skipped
CI / a11y (push) Successful in 1m53s
CI / perf (push) Successful in 4m57s
CI / check (push) Successful in 5m52s
Docs site / build (push) Successful in 2m14s
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| [@opentelemetry/exporter-trace-otlp-http](https://github.com/open-telemetry/opentelemetry-js/tree/main/experimental/packages/exporter-trace-otlp-http) ([source](https://github.com/open-telemetry/opentelemetry-js)) | dependencies | minor | [`^0.217.0` -> `^0.218.0`](https://renovatebot.com/diffs/npm/@opentelemetry%2fexporter-trace-otlp-http/0.217.0/0.218.0) |
| [@opentelemetry/exporter-trace-otlp-proto](https://github.com/open-telemetry/opentelemetry-js/tree/main/experimental/packages/exporter-trace-otlp-proto) ([source](https://github.com/open-telemetry/opentelemetry-js)) | dependencies | minor | [`^0.217.0` -> `^0.218.0`](https://renovatebot.com/diffs/npm/@opentelemetry%2fexporter-trace-otlp-proto/0.217.0/0.218.0) |
| [@opentelemetry/instrumentation](https://github.com/open-telemetry/opentelemetry-js/tree/main/experimental/packages/opentelemetry-instrumentation) ([source](https://github.com/open-telemetry/opentelemetry-js)) | dependencies | minor | [`^0.217.0` -> `^0.218.0`](https://renovatebot.com/diffs/npm/@opentelemetry%2finstrumentation/0.217.0/0.218.0) |
| [@opentelemetry/instrumentation-fetch](https://github.com/open-telemetry/opentelemetry-js/tree/main/experimental/packages/opentelemetry-instrumentation-fetch) ([source](https://github.com/open-telemetry/opentelemetry-js)) | dependencies | minor | [`^0.217.0` -> `^0.218.0`](https://renovatebot.com/diffs/npm/@opentelemetry%2finstrumentation-fetch/0.217.0/0.218.0) |
| [@opentelemetry/instrumentation-http](https://github.com/open-telemetry/opentelemetry-js/tree/main/experimental/packages/opentelemetry-instrumentation-http) ([source](https://github.com/open-telemetry/opentelemetry-js)) | dependencies | minor | [`^0.217.0` -> `^0.218.0`](https://renovatebot.com/diffs/npm/@opentelemetry%2finstrumentation-http/0.217.0/0.218.0) |
| [@opentelemetry/sdk-node](https://github.com/open-telemetry/opentelemetry-js/tree/main/experimental/packages/opentelemetry-sdk-node) ([source](https://github.com/open-telemetry/opentelemetry-js)) | dependencies | minor | [`^0.217.0` -> `^0.218.0`](https://renovatebot.com/diffs/npm/@opentelemetry%2fsdk-node/0.217.0/0.218.0) |
| [@opentelemetry/semantic-conventions](https://github.com/open-telemetry/opentelemetry-js/tree/main/semantic-conventions) ([source](https://github.com/open-telemetry/opentelemetry-js)) | dependencies | minor | [`1.40.0` -> `1.41.1`](https://renovatebot.com/diffs/npm/@opentelemetry%2fsemantic-conventions/1.40.0/1.41.1) |

---

### Release Notes

<details>
<summary>open-telemetry/opentelemetry-js (@&#8203;opentelemetry/exporter-trace-otlp-http)</summary>

### [`v0.218.0`](https://github.com/open-telemetry/opentelemetry-js/compare/74cde1b674508ccc0ed2601ac43a80ff2d35114c...06ad0eaaecbd49f5ead871325f852cc2a3454079)

[Compare Source](https://github.com/open-telemetry/opentelemetry-js/compare/74cde1b674508ccc0ed2601ac43a80ff2d35114c...06ad0eaaecbd49f5ead871325f852cc2a3454079)

</details>

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined).

🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.

👻 **Immortal**: This PR will be recreated if closed unmerged. Get [config help](https://github.com/renovatebot/renovate/discussions) if that's undesired.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0MC42Mi4xIiwidXBkYXRlZEluVmVyIjoiNDAuNjIuMSIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOlsiZGVwZW5kZW5jaWVzIl19-->

Reviewed-on: #183
Co-authored-by: APF Portal Bot <jgautier.webdev+apf-portal-bot@gmail.com>
Co-committed-by: APF Portal Bot <jgautier.webdev+apf-portal-bot@gmail.com>
2026-05-17 23:47:04 +02:00
APF Portal Bot 868cbb6747 chore(deps): update dependency @playwright/test to v1.60.0 (#182)
CI / scan (push) Successful in 3m7s
CI / commits (push) Has been skipped
CI / check (push) Successful in 7m27s
CI / a11y (push) Successful in 2m0s
CI / perf (push) Successful in 5m29s
Docs site / build (push) Successful in 3m15s
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| [@playwright/test](https://playwright.dev) ([source](https://github.com/microsoft/playwright)) | devDependencies | minor | [`1.59.1` -> `1.60.0`](https://renovatebot.com/diffs/npm/@playwright%2ftest/1.59.1/1.60.0) |

---

### Release Notes

<details>
<summary>microsoft/playwright (@&#8203;playwright/test)</summary>

### [`v1.60.0`](https://github.com/microsoft/playwright/releases/tag/v1.60.0)

[Compare Source](https://github.com/microsoft/playwright/compare/v1.59.1...v1.60.0)

#### 🌐 HAR recording on Tracing

[tracing.startHar()](https://playwright.dev/docs/api/class-tracing#tracing-start-har) / [tracing.stopHar()](https://playwright.dev/docs/api/class-tracing#tracing-stop-har) expose HAR recording as a first-class tracing API, with the same `content`, `mode` and `urlFilter` options as `recordHar`. The returned [Disposable](https://playwright.dev/docs/api/class-disposable) makes it easy to scope a recording with `await using`:

```js
await using har = await context.tracing.startHar('trace.har');
const page = await context.newPage();
await page.goto('https://playwright.dev');
// HAR is finalized when `har` goes out of scope.
```

#### 🪝 Drop API

New [locator.drop()](https://playwright.dev/docs/api/class-locator#locator-drop) simulates an external drag-and-drop of files or clipboard-like data onto an element. Playwright dispatches `dragenter`, `dragover`, and `drop` with a synthetic \[DataTransfer] in the page context — works cross-browser and is great for testing upload zones:

```js
await page.locator('#dropzone').drop({
  files: { name: 'note.txt', mimeType: 'text/plain', buffer: Buffer.from('hello') },
});

await page.locator('#dropzone').drop({
  data: {
    'text/plain': 'hello world',
    'text/uri-list': 'https://example.com',
  },
});
```

#### 🎯 Aria snapshots

- [expect(page).toMatchAriaSnapshot()](https://playwright.dev/docs/api/class-pageassertions#page-assertions-to-match-aria-snapshot) now works on a [Page](https://playwright.dev/docs/api/class-page), in addition to a [Locator](https://playwright.dev/docs/api/class-locator) — equivalent to asserting against `page.locator('body')`.
- New `boxes` option on [locator.ariaSnapshot()](https://playwright.dev/docs/api/class-locator#locator-aria-snapshot) / [page.ariaSnapshot()](https://playwright.dev/docs/api/class-page#page-aria-snapshot) appends each element's bounding box as `[box=x,y,width,height]`, useful for AI consumption.

#### 🛑 test.abort()

New [test.abort()](https://playwright.dev/docs/api/class-test#test-abort) aborts the currently running test from a fixture, hook, or route handler with an optional message. Use it when you have detected an unrecoverable misuse and want to fail the test right away:

```js
test('does not publish to the shared page', async ({ page }) => {
  await page.route('**/publish', route => {
    test.abort('Tests must not publish to the shared page. Use the `clone` option.');
    return route.abort();
  });
  // ...
});
```

#### New APIs

##### Browser, Context and Page

- Event [browser.on('context')](https://playwright.dev/docs/api/class-browser#browser-event-context) — fired when a new context is created on the browser.
- [BrowserContext](https://playwright.dev/docs/api/class-browsercontext) now mirrors lifecycle events from its pages: [browserContext.on('download')](https://playwright.dev/docs/api/class-browsercontext#browser-context-event-download), [browserContext.on('frameattached')](https://playwright.dev/docs/api/class-browsercontext#browser-context-event-frame-attached), [browserContext.on('framedetached')](https://playwright.dev/docs/api/class-browsercontext#browser-context-event-frame-detached), [browserContext.on('framenavigated')](https://playwright.dev/docs/api/class-browsercontext#browser-context-event-frame-navigated), [browserContext.on('pageclose')](https://playwright.dev/docs/api/class-browsercontext#browser-context-event-page-close), [browserContext.on('pageload')](https://playwright.dev/docs/api/class-browsercontext#browser-context-event-page-load).

##### Locators and Assertions

- New option `description` in [page.getByRole()](https://playwright.dev/docs/api/class-page#page-get-by-role) / [locator.getByRole()](https://playwright.dev/docs/api/class-locator#locator-get-by-role) / [frame.getByRole()](https://playwright.dev/docs/api/class-frame#frame-get-by-role) / [frameLocator.getByRole()](https://playwright.dev/docs/api/class-framelocator#frame-locator-get-by-role) for matching the [accessible description](https://www.w3.org/TR/wai-aria-1.2/#dfn-accessible-description).
- New option `pseudo` in [expect(locator).toHaveCSS()](https://playwright.dev/docs/api/class-locatorassertions#locator-assertions-to-have-css) reads computed styles from `::before` or `::after`.
- New option `style` in [locator.highlight()](https://playwright.dev/docs/api/class-locator#locator-highlight) applies extra inline CSS to the highlight overlay, plus new [page.hideHighlight()](https://playwright.dev/docs/api/class-page#page-hide-highlight) to clear all highlights.

##### Network

- [webSocketRoute.protocols()](https://playwright.dev/docs/api/class-websocketroute#web-socket-route-protocols) returns the WebSocket subprotocols requested by the page.
- New option `noDefaults` in [browserType.connectOverCDP()](https://playwright.dev/docs/api/class-browsertype#browser-type-connect-over-cdp) disables Playwright's default overrides on the default context (download behavior, focus emulation, media emulation), so attaching to a user's daily-driver browser doesn't disturb its state.

##### Errors and Reporting

- New [webError.location()](https://playwright.dev/docs/api/class-weberror#web-error-location) mirrors [consoleMessage.location()](https://playwright.dev/docs/api/class-consolemessage#console-message-location).
- [consoleMessage.location()](https://playwright.dev/docs/api/class-consolemessage#console-message-location) now exposes `line` / `column` properties (`lineNumber` / `columnNumber` are deprecated).
- New [testInfoError.errorContext](https://playwright.dev/docs/api/class-testinfoerror#test-info-error-error-context) surfaces additional diagnostic context, such as the aria snapshot of the receiver at the time of an `expect(...)` matcher failure.
- [reporter.onError()](https://playwright.dev/docs/api/class-reporter#reporter-on-error) now receives a `workerInfo` argument with details about the worker for fixture teardown errors.

##### Test runner

- New `{testFileBaseName}` token in [testProject.snapshotPathTemplate](https://playwright.dev/docs/api/class-testproject#test-project-snapshot-path-template) — file name without extension.
- Test runner now errors when a config tries to override a non-option fixture, and rejects `workers: 0` or negative values.

#### 🛠️ Other improvements

- HTML reporter:
  - `npx playwright show-report` accepts `.zip` files directly — no need to unzip first.
  - Steps that contain attachments inside nested children show an indicator on the parent step.
  - The `repeatEachIndex` is shown in the test header when non-zero.
- Trace Viewer adds a pretty-print toggle for JSON / form request and response bodies in the network details panel.

#### Breaking Changes ⚠️

- Removed long-deprecated APIs:
  - `Locator.ariaRef()` — use the standard [locator.ariaSnapshot()](https://playwright.dev/docs/api/class-locator#locator-aria-snapshot) pipeline.
  - `handle` option on `BrowserContext.exposeBinding` and `Page.exposeBinding`.
  - `logger` option on `BrowserType.connect` and `BrowserType.connectOverCDP` — use [tracing](https://playwright.dev/docs/trace-viewer) instead.
  - Context options `videosPath` / `videoSize` — use `recordVideo` instead.

#### Browser Versions

- Chromium 148.0.7778.96
- Mozilla Firefox 150.0.2
- WebKit 26.4

This version was also tested against the following stable channels:

- Google Chrome 147
- Microsoft Edge 147

</details>

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined).

🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0MC42Mi4xIiwidXBkYXRlZEluVmVyIjoiNDAuNjIuMSIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOlsiZGVwZW5kZW5jaWVzIl19-->

Reviewed-on: #182
Co-authored-by: APF Portal Bot <jgautier.webdev+apf-portal-bot@gmail.com>
Co-committed-by: APF Portal Bot <jgautier.webdev+apf-portal-bot@gmail.com>
2026-05-17 23:46:36 +02:00
APF Portal Bot e376ea1295 chore(deps): update dependency @oxc-project/runtime to ^0.131.0 (#181)
CI / scan (push) Successful in 3m7s
CI / commits (push) Has been skipped
CI / check (push) Successful in 6m30s
CI / a11y (push) Successful in 2m53s
CI / perf (push) Successful in 9m49s
Docs site / build (push) Successful in 5m35s
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| [@oxc-project/runtime](https://oxc.rs) ([source](https://github.com/oxc-project/oxc/tree/HEAD/npm/runtime)) | devDependencies | minor | [`^0.129.0` -> `^0.131.0`](https://renovatebot.com/diffs/npm/@oxc-project%2fruntime/0.129.0/0.131.0) |

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined).

🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0MC42Mi4xIiwidXBkYXRlZEluVmVyIjoiNDAuNjIuMSIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOlsiZGVwZW5kZW5jaWVzIl19-->

Reviewed-on: #181
Co-authored-by: APF Portal Bot <jgautier.webdev+apf-portal-bot@gmail.com>
Co-committed-by: APF Portal Bot <jgautier.webdev+apf-portal-bot@gmail.com>
2026-05-17 23:42:58 +02:00
julien f2360b9db3 chore(shared-charts): soften the curated palette (#185)
CI / commits (push) Has been skipped
CI / check (push) Successful in 4m50s
CI / scan (push) Successful in 5m22s
CI / a11y (push) Successful in 3m56s
CI / perf (push) Successful in 9m20s
## Summary

Tune the curated chart palette to a softer, lower-saturation set. The values shipped in #175 were pulled straight from Tailwind's `-600 / -700` ramp; on real audit-log data the donut's three slices and the bar-chart's blue read as too punchy when they share a tile, especially in dark mode. Same five intents, same a11y posture — just less visual fight.

## What lands

`libs/shared/charts/src/lib/_internal/palette.ts`:

| Constant                          | Before   | After    |
| --------------------------------- | -------- | -------- |
| `DEFAULT_BAR_FILL`                | `#1d4ed8` | `#4075e7` |
| `semanticStatusColors.info`       | `#2563eb` | `#4075e7` |
| `semanticStatusColors.success`    | `#16a34a` | `#46ac6b` |
| `semanticStatusColors.warning`    | `#ea580c` | `#f38043` |
| `semanticStatusColors.error`      | `#dc2626` | `#eb5252` |
| `semanticStatusColors.neutral`    | `#6b7280` | `#6b7280` (unchanged) |

`info` and `DEFAULT_BAR_FILL` collapse to the same hex — bars and "informational" donut slices are *meant* to read as the same semantic class (no special status), so unifying them at the constant level removes a future drift hazard.

Docstrings updated alongside — the previous comments name-checked Tailwind shades (`green-600`, `tailwind blue-700`) that no longer correspond to the values; the new comments describe the palette by intent (`muted green`, `muted orange`, ...) and call out that the softening is deliberate.

## Notes for the reviewer

- **A11y posture unchanged.** The lib's contract is "AA contrast on white surfaces, deuteranopia/protanopia distinguishability via lightness deltas, not just hue". All four chromatic entries clear the same bar: each lightness sits in a distinct band (≈ 67 % for warning, ≈ 60 % for success, ≈ 60 % for error, ≈ 56 % for info), so colour-blind viewers still distinguish them by brightness even if the hue collapses.
- **Why not derive these from `libs/shared/tokens/brand-tokens.css`?** Brand-primary is the dark teal `#12546c` and brand-accent is `#f7a919`. Neither reads correctly as "success" or "neutral chart fill"; the charts need a categorical palette tuned for *legibility on dense surfaces*, not for chrome and CTAs. Keeping the chart palette in its own lib stays consistent with ADR-0023's "lib owns the palette" stance.
- **Bar fill default + `info` semantic alias to the same value on purpose.** A bar with no per-bar encoding is semantically "informational quantity over time" — the same intent as a donut slice tagged `info`. Future consumer that wants to flag a single "info" bar inside a stacked chart will read the colour as consistent.
- **No code changes outside this file.** Consumers (`<lib-bar-chart>`, `<lib-donut-chart>`, the audit page) import these constants by name; the swap is purely a value change.

## Test plan

- [x] `pnpm nx test shared-charts` — 15 specs pass (the donut `colorMap` spec asserts the *consumer-provided* hexes, not the lib defaults, so the change is transparent there; the bar single-fill spec checks uniqueness, not the specific value).
- [x] `pnpm nx test portal-admin` — 62 specs pass.
- [x] `pnpm nx run-many -t test build lint -p shared-charts,portal-admin` — clean (same three pre-existing lint warnings unrelated to this PR).
- [ ] **Manual smoke** — `pnpm nx serve portal-admin`, sign in with `Portal.Admin`, navigate to `/admin/audit`, switch to Charts:
  - Daily-volume bars render in the new muted blue.
  - Outcome donut slices: green (success), red (failure), orange (denied) — softer than before, semantic mapping intact.
  - Dark-mode toggle — palette still legible against the dark surface.
  - Side-by-side comparison vs `main` — the new shades feel calmer, especially when multiple charts share the viewport.

## What's next

Nothing pending on the palette front. If a future chart needs a sixth intent (e.g. `pending` for in-flight states), add it here with a contrast / colour-blind check and update the typed `SemanticStatus` union in the same PR.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #185
2026-05-17 23:42:07 +02:00
APF Portal Bot 8a02ca86a2 fix(deps): update vitest to v4.1.6 (#180)
CI / commits (push) Has been skipped
CI / scan (push) Successful in 4m37s
CI / check (push) Successful in 6m47s
CI / a11y (push) Successful in 4m12s
CI / perf (push) Successful in 6m36s
Docs site / build (push) Successful in 4m40s
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| [@vitest/coverage-v8](https://vitest.dev/guide/coverage) ([source](https://github.com/vitest-dev/vitest/tree/HEAD/packages/coverage-v8)) | devDependencies | patch | [`4.1.5` -> `4.1.6`](https://renovatebot.com/diffs/npm/@vitest%2fcoverage-v8/4.1.5/4.1.6) |
| [@vitest/ui](https://vitest.dev/guide/ui) ([source](https://github.com/vitest-dev/vitest/tree/HEAD/packages/ui)) | devDependencies | patch | [`4.1.5` -> `4.1.6`](https://renovatebot.com/diffs/npm/@vitest%2fui/4.1.5/4.1.6) |
| [vitest](https://vitest.dev) ([source](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest)) | devDependencies | patch | [`4.1.5` -> `4.1.6`](https://renovatebot.com/diffs/npm/vitest/4.1.5/4.1.6) |
| [vitest](https://vitest.dev) ([source](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest)) | dependencies | patch | [`4.1.5` -> `4.1.6`](https://renovatebot.com/diffs/npm/vitest/4.1.5/4.1.6) |

---

### Release Notes

<details>
<summary>vitest-dev/vitest (@&#8203;vitest/coverage-v8)</summary>

### [`v4.1.6`](https://github.com/vitest-dev/vitest/releases/tag/v4.1.6)

[Compare Source](https://github.com/vitest-dev/vitest/compare/v4.1.5...v4.1.6)

#####    🐞 Bug Fixes

- **browser**: Provide project reference in `ToMatchScreenshotResolvePath`  -  by [@&#8203;macarie](https://github.com/macarie) and [@&#8203;sheremet-va](https://github.com/sheremet-va) in https://github.com/vitest-dev/vitest/issues/10138 [<samp>(31882)</samp>](https://github.com/vitest-dev/vitest/commit/31882607c)
- Global `sequence.concurrent: true` with top-level `test(..., { concurrent: false })` + depreacte `sequential` test API and options  -  by [@&#8203;hi-ogawa](https://github.com/hi-ogawa), **Codex** and [@&#8203;sheremet-va](https://github.com/sheremet-va) in https://github.com/vitest-dev/vitest/issues/10196 [<samp>(2847d)</samp>](https://github.com/vitest-dev/vitest/commit/2847dfa2a)
- **browser**: Simplify orchestrator otel carrier  -  by [@&#8203;hi-ogawa](https://github.com/hi-ogawa) in https://github.com/vitest-dev/vitest/issues/10285 [<samp>(18af9)</samp>](https://github.com/vitest-dev/vitest/commit/18af98cee)

#####    🏎 Performance

- Stringify diff objects only once  -  by [@&#8203;sheremet-va](https://github.com/sheremet-va) in https://github.com/vitest-dev/vitest/issues/10276 [<samp>(9f7b1)</samp>](https://github.com/vitest-dev/vitest/commit/9f7b1528c)

#####     [View changes on GitHub](https://github.com/vitest-dev/vitest/compare/v4.1.5...v4.1.6)

</details>

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined).

🚦 **Automerge**: Enabled.

♻ **Rebasing**: Whenever PR is behind base branch, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about these updates again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0MC42Mi4xIiwidXBkYXRlZEluVmVyIjoiNDAuNjIuMSIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOlsiZGVwZW5kZW5jaWVzIl19-->

Reviewed-on: https://git.unespace.com/julien/apf_portal/pulls/180
Co-authored-by: APF Portal Bot <jgautier.webdev+apf-portal-bot@gmail.com>
Co-committed-by: APF Portal Bot <jgautier.webdev+apf-portal-bot@gmail.com>
2026-05-17 22:29:35 +02:00
APF Portal Bot bd94bb4ffe fix(deps): update nx to v22.7.2 (#179)
CI / commits (push) Has been skipped
CI / scan (push) Successful in 4m26s
CI / check (push) Successful in 7m6s
CI / a11y (push) Successful in 2m46s
CI / perf (push) Successful in 9m15s
Docs site / build (push) Successful in 3m56s
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| [@nx/devkit](https://nx.dev) ([source](https://github.com/nrwl/nx/tree/HEAD/packages/devkit)) | devDependencies | patch | [`22.7.1` -> `22.7.2`](https://renovatebot.com/diffs/npm/@nx%2fdevkit/22.7.1/22.7.2) |
| [@nx/eslint-plugin](https://nx.dev) ([source](https://github.com/nrwl/nx/tree/HEAD/packages/eslint-plugin)) | devDependencies | patch | [`22.7.1` -> `22.7.2`](https://renovatebot.com/diffs/npm/@nx%2feslint-plugin/22.7.1/22.7.2) |
| [@nx/jest](https://nx.dev) ([source](https://github.com/nrwl/nx/tree/HEAD/packages/jest)) | devDependencies | patch | [`22.7.1` -> `22.7.2`](https://renovatebot.com/diffs/npm/@nx%2fjest/22.7.1/22.7.2) |
| [@nx/js](https://nx.dev) ([source](https://github.com/nrwl/nx/tree/HEAD/packages/js)) | devDependencies | patch | [`22.7.1` -> `22.7.2`](https://renovatebot.com/diffs/npm/@nx%2fjs/22.7.1/22.7.2) |
| [@nx/node](https://nx.dev) ([source](https://github.com/nrwl/nx/tree/HEAD/packages/node)) | devDependencies | patch | [`22.7.1` -> `22.7.2`](https://renovatebot.com/diffs/npm/@nx%2fnode/22.7.1/22.7.2) |
| [@nx/playwright](https://nx.dev) ([source](https://github.com/nrwl/nx/tree/HEAD/packages/playwright)) | devDependencies | patch | [`22.7.1` -> `22.7.2`](https://renovatebot.com/diffs/npm/@nx%2fplaywright/22.7.1/22.7.2) |
| [@nx/vite](https://nx.dev) ([source](https://github.com/nrwl/nx/tree/HEAD/packages/vite)) | dependencies | patch | [`22.7.1` -> `22.7.2`](https://renovatebot.com/diffs/npm/@nx%2fvite/22.7.1/22.7.2) |
| [@nx/vitest](https://nx.dev) ([source](https://github.com/nrwl/nx/tree/HEAD/packages/vitest)) | devDependencies | patch | [`22.7.1` -> `22.7.2`](https://renovatebot.com/diffs/npm/@nx%2fvitest/22.7.1/22.7.2) |
| [@nx/web](https://nx.dev) ([source](https://github.com/nrwl/nx/tree/HEAD/packages/web)) | devDependencies | patch | [`22.7.1` -> `22.7.2`](https://renovatebot.com/diffs/npm/@nx%2fweb/22.7.1/22.7.2) |
| [@nx/webpack](https://nx.dev) ([source](https://github.com/nrwl/nx/tree/HEAD/packages/webpack)) | devDependencies | patch | [`22.7.1` -> `22.7.2`](https://renovatebot.com/diffs/npm/@nx%2fwebpack/22.7.1/22.7.2) |
| [nx](https://nx.dev) ([source](https://github.com/nrwl/nx/tree/HEAD/packages/nx)) | devDependencies | patch | [`22.7.1` -> `22.7.2`](https://renovatebot.com/diffs/npm/nx/22.7.1/22.7.2) |

---

### Release Notes

<details>
<summary>nrwl/nx (@&#8203;nx/devkit)</summary>

### [`v22.7.2`](https://github.com/nrwl/nx/releases/tag/22.7.2)

[Compare Source](https://github.com/nrwl/nx/compare/22.7.1...22.7.2)

##### 22.7.2 (2026-05-14)

##### 🚀 Features

- **gradle:** stream batch task results to nx as they finish ([#&#8203;35487](https://github.com/nrwl/nx/pull/35487))
- **nx-dev:** track docs analytics for code copy, LLM prompt, YouTube ([#&#8203;35526](https://github.com/nrwl/nx/pull/35526))
- **testing:** add migration for Jest 30 snapshot guide link ([#&#8203;35629](https://github.com/nrwl/nx/pull/35629))

##### 🩹 Fixes

- **angular:** disable vitest watch by default ([#&#8203;35493](https://github.com/nrwl/nx/pull/35493))
- **angular-rspack:** keep root-scoped assets out of per-locale i18n emit ([#&#8203;35621](https://github.com/nrwl/nx/pull/35621))
- **bundling:** include tsconfig solution input for rollup ([#&#8203;35476](https://github.com/nrwl/nx/pull/35476))
- **bundling:** include tsconfig solution input for webpack ([#&#8203;35477](https://github.com/nrwl/nx/pull/35477), [#&#8203;35476](https://github.com/nrwl/nx/issues/35476))
- **core:** bump axios to 1.16.0 for all packages ([#&#8203;35568](https://github.com/nrwl/nx/pull/35568))
- **core:** add provenance check in nx console status path ([#&#8203;35485](https://github.com/nrwl/nx/pull/35485))
- **core:** remove access control header from graph app ([#&#8203;35494](https://github.com/nrwl/nx/pull/35494))
- **core:** ensure verbose logs go to stderr and daemon logs are properly decorated ([#&#8203;34358](https://github.com/nrwl/nx/pull/34358))
- **core:** show flaky-task count in run summary ([#&#8203;35491](https://github.com/nrwl/nx/pull/35491))
- **core:** unique telemetry user\_id; expose workspace\_id dimension ([#&#8203;35553](https://github.com/nrwl/nx/pull/35553))
- **core:** update minimatch to 10.2.5 ([#&#8203;35569](https://github.com/nrwl/nx/pull/35569), [#&#8203;34660](https://github.com/nrwl/nx/issues/34660))
- **core:** restore use-legacy-versioning shim for [@&#8203;nx/js](https://github.com/nx/js)[@&#8203;21](https://github.com/21) ensurePackage path ([#&#8203;35574](https://github.com/nrwl/nx/pull/35574))
- **core:** isolate NX\_PARALLEL env var in parallel-related specs ([#&#8203;35579](https://github.com/nrwl/nx/pull/35579))
- **core:** skip handleimport miss path when nx key packages are absent ([#&#8203;35596](https://github.com/nrwl/nx/pull/35596))
- **core:** use gethostuuid(3) instead of ioreg on macOS ([#&#8203;35599](https://github.com/nrwl/nx/pull/35599))
- **core:** isolate cache env vars in splitArgs spec ([#&#8203;35584](https://github.com/nrwl/nx/pull/35584))
- **core:** enable node's native v8 compile cache support ([#&#8203;35415](https://github.com/nrwl/nx/pull/35415), [#&#8203;20454](https://github.com/nrwl/nx/issues/20454))
- **core:** support skipped batch tasks end-to-end and fix TUI double logs ([#&#8203;35617](https://github.com/nrwl/nx/pull/35617))
- **core:** keep TUI task selection on the in-progress section ([#&#8203;35640](https://github.com/nrwl/nx/pull/35640))
- **core:** allow `nx mcp` to run outside of an Nx workspace ([#&#8203;35655](https://github.com/nrwl/nx/pull/35655))
- **core:** cast perf entries to PerformanceMeasure for detail access ([43c0c821ba](https://github.com/nrwl/nx/commit/43c0c821ba))
- **devkit:** exclude dist from jest module path scan ([#&#8203;35615](https://github.com/nrwl/nx/pull/35615))
- **devkit:** expand @&#8203;nx/devkit/internal re-exports for cherry-picked v23 deep-import migration ([#&#8203;35541](https://github.com/nrwl/nx/issues/35541))
- **dotnet:** correct output paths for Web SDK and centralized dist setups ([#&#8203;35398](https://github.com/nrwl/nx/pull/35398))
- **gradle:** exclude batch-runner from jest haste-map crawl ([#&#8203;35501](https://github.com/nrwl/nx/pull/35501))
- **gradle:** exclude project-graph from jest module path scan ([#&#8203;35609](https://github.com/nrwl/nx/pull/35609))
- **gradle:** support Windows file paths ([#&#8203;35184](https://github.com/nrwl/nx/pull/35184), [#&#8203;34987](https://github.com/nrwl/nx/issues/34987))
- **js:** strip glob from inferred outputs before resolving as path ([#&#8203;35463](https://github.com/nrwl/nx/pull/35463), [#&#8203;35452](https://github.com/nrwl/nx/issues/35452))
- **js:** reference vitest.config in eslint dep-checks for vitest libs ([#&#8203;35460](https://github.com/nrwl/nx/pull/35460), [#&#8203;33670](https://github.com/nrwl/nx/issues/33670), [#&#8203;35450](https://github.com/nrwl/nx/issues/35450))
- **js:** include transitive workspace deps in pruned pnpm lockfile ([#&#8203;35532](https://github.com/nrwl/nx/pull/35532), [#&#8203;35347](https://github.com/nrwl/nx/issues/35347), [#&#8203;34655](https://github.com/nrwl/nx/issues/34655))
- **linter:** prevent ENOENT crash in getRelativeImportPath for unresolvable paths ([#&#8203;35007](https://github.com/nrwl/nx/pull/35007), [#&#8203;13872](https://github.com/nrwl/nx/issues/13872), [#&#8203;34066](https://github.com/nrwl/nx/issues/34066), [#&#8203;30491](https://github.com/nrwl/nx/issues/30491), [#&#8203;16716](https://github.com/nrwl/nx/issues/16716), [#&#8203;35006](https://github.com/nrwl/nx/issues/35006), [#&#8203;21889](https://github.com/nrwl/nx/issues/21889), [#&#8203;32190](https://github.com/nrwl/nx/issues/32190))
- **maven:** skip attached artifacts that fail to materialize in batch record ([#&#8203;35473](https://github.com/nrwl/nx/pull/35473))
- **maven:** serialize Maven 4 build state recording ([#&#8203;35555](https://github.com/nrwl/nx/pull/35555))
- **maven:** widen runCLI timeout for --no-batch maven.test.ts cases ([#&#8203;35589](https://github.com/nrwl/nx/pull/35589))
- **nx-dev:** document nested CLI subcommands beyond two levels ([#&#8203;35519](https://github.com/nrwl/nx/pull/35519))
- **nx-dev:** short-circuit bot probes in framer rewrite edge function ([#&#8203;35527](https://github.com/nrwl/nx/pull/35527))
- **react:** withSvgr migration preserves other properties ([#&#8203;35484](https://github.com/nrwl/nx/pull/35484))
- **repo:** clear NX\_INVOCATION\_ROOT\_PID in run-native-target to avoid recursion false-positive ([443dee0b22](https://github.com/nrwl/nx/commit/443dee0b22))
- **repo:** revert deep-import rewrites that targeted v23-only @&#8203;nx/devkit/internal entry ([ac8187963d](https://github.com/nrwl/nx/commit/ac8187963d))
- **repo:** unblock 22.7.x cargo tests and nx-build e2e ([#&#8203;34285](https://github.com/nrwl/nx/issues/34285))
- **repo:** expand "..." spread token in graph typecheck inputs ([#&#8203;34285](https://github.com/nrwl/nx/issues/34285), [#&#8203;35458](https://github.com/nrwl/nx/issues/35458))
- **testing:** pin jest to ~30.3.0 to avoid jest-runtime 30.4 RN incompat ([#&#8203;35618](https://github.com/nrwl/nx/pull/35618))
- **testing:** handle absolute cypress screenshotsFolder/videosFolder paths ([#&#8203;35624](https://github.com/nrwl/nx/pull/35624))
- **testing:** exclude dist and out-tsc from default jest module path scan ([#&#8203;35619](https://github.com/nrwl/nx/pull/35619))
- **testing:** update remaining snapshot guide links missed by migration ([cd350c1140](https://github.com/nrwl/nx/commit/cd350c1140))

##### ❤️ Thank You

- Adam Keenan [@&#8203;adamk33n3r](https://github.com/adamk33n3r)
- AgentEnder [@&#8203;AgentEnder](https://github.com/AgentEnder)
- beeman
- Claude
- Craigory Coppola [@&#8203;AgentEnder](https://github.com/AgentEnder)
- FrozenPandaz [@&#8203;FrozenPandaz](https://github.com/FrozenPandaz)
- Jack Hsu [@&#8203;jaysoo](https://github.com/jaysoo)
- Jason Jean [@&#8203;FrozenPandaz](https://github.com/FrozenPandaz)
- Leosvel Pérez Espinosa [@&#8203;leosvelperez](https://github.com/leosvelperez)
- Max Kless
- MaxKless [@&#8203;MaxKless](https://github.com/MaxKless)
- Optischa [@&#8203;Optischa](https://github.com/Optischa)
- Sharon Lougheed

</details>

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined).

🚦 **Automerge**: Disabled because a matching PR was automerged previously.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about these updates again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0MC42Mi4xIiwidXBkYXRlZEluVmVyIjoiNDAuNjIuMSIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOlsiZGVwZW5kZW5jaWVzIl19-->

Reviewed-on: https://git.unespace.com/julien/apf_portal/pulls/179
Co-authored-by: APF Portal Bot <jgautier.webdev+apf-portal-bot@gmail.com>
Co-committed-by: APF Portal Bot <jgautier.webdev+apf-portal-bot@gmail.com>
2026-05-17 22:15:42 +02:00
APF Portal Bot 6b20c3413b fix(deps): update nx to v22.7.2 (#178)
CI / commits (push) Has been skipped
CI / scan (push) Successful in 3m24s
CI / a11y (push) Successful in 2m6s
CI / check (push) Successful in 6m3s
CI / perf (push) Successful in 6m14s
Docs site / build (push) Successful in 2m49s
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| [@nx/angular](https://nx.dev) ([source](https://github.com/nrwl/nx/tree/HEAD/packages/angular)) | devDependencies | patch | [`22.7.1` -> `22.7.2`](https://renovatebot.com/diffs/npm/@nx%2fangular/22.7.1/22.7.2) |
| [@nx/devkit](https://nx.dev) ([source](https://github.com/nrwl/nx/tree/HEAD/packages/devkit)) | devDependencies | patch | [`22.7.1` -> `22.7.2`](https://renovatebot.com/diffs/npm/@nx%2fdevkit/22.7.1/22.7.2) |
| [@nx/eslint](https://nx.dev) ([source](https://github.com/nrwl/nx/tree/HEAD/packages/eslint)) | devDependencies | patch | [`22.7.1` -> `22.7.2`](https://renovatebot.com/diffs/npm/@nx%2feslint/22.7.1/22.7.2) |
| [@nx/eslint-plugin](https://nx.dev) ([source](https://github.com/nrwl/nx/tree/HEAD/packages/eslint-plugin)) | devDependencies | patch | [`22.7.1` -> `22.7.2`](https://renovatebot.com/diffs/npm/@nx%2feslint-plugin/22.7.1/22.7.2) |
| [@nx/jest](https://nx.dev) ([source](https://github.com/nrwl/nx/tree/HEAD/packages/jest)) | devDependencies | patch | [`22.7.1` -> `22.7.2`](https://renovatebot.com/diffs/npm/@nx%2fjest/22.7.1/22.7.2) |
| [@nx/js](https://nx.dev) ([source](https://github.com/nrwl/nx/tree/HEAD/packages/js)) | devDependencies | patch | [`22.7.1` -> `22.7.2`](https://renovatebot.com/diffs/npm/@nx%2fjs/22.7.1/22.7.2) |
| [@nx/nest](https://nx.dev) ([source](https://github.com/nrwl/nx/tree/HEAD/packages/nest)) | devDependencies | patch | [`22.7.1` -> `22.7.2`](https://renovatebot.com/diffs/npm/@nx%2fnest/22.7.1/22.7.2) |
| [@nx/node](https://nx.dev) ([source](https://github.com/nrwl/nx/tree/HEAD/packages/node)) | devDependencies | patch | [`22.7.1` -> `22.7.2`](https://renovatebot.com/diffs/npm/@nx%2fnode/22.7.1/22.7.2) |
| [@nx/playwright](https://nx.dev) ([source](https://github.com/nrwl/nx/tree/HEAD/packages/playwright)) | devDependencies | patch | [`22.7.1` -> `22.7.2`](https://renovatebot.com/diffs/npm/@nx%2fplaywright/22.7.1/22.7.2) |
| [@nx/vite](https://nx.dev) ([source](https://github.com/nrwl/nx/tree/HEAD/packages/vite)) | devDependencies | patch | [`22.7.1` -> `22.7.2`](https://renovatebot.com/diffs/npm/@nx%2fvite/22.7.1/22.7.2) |
| [@nx/vite](https://nx.dev) ([source](https://github.com/nrwl/nx/tree/HEAD/packages/vite)) | dependencies | patch | [`22.7.1` -> `22.7.2`](https://renovatebot.com/diffs/npm/@nx%2fvite/22.7.1/22.7.2) |
| [@nx/vitest](https://nx.dev) ([source](https://github.com/nrwl/nx/tree/HEAD/packages/vitest)) | devDependencies | patch | [`22.7.1` -> `22.7.2`](https://renovatebot.com/diffs/npm/@nx%2fvitest/22.7.1/22.7.2) |
| [@nx/web](https://nx.dev) ([source](https://github.com/nrwl/nx/tree/HEAD/packages/web)) | devDependencies | patch | [`22.7.1` -> `22.7.2`](https://renovatebot.com/diffs/npm/@nx%2fweb/22.7.1/22.7.2) |
| [@nx/webpack](https://nx.dev) ([source](https://github.com/nrwl/nx/tree/HEAD/packages/webpack)) | devDependencies | patch | [`22.7.1` -> `22.7.2`](https://renovatebot.com/diffs/npm/@nx%2fwebpack/22.7.1/22.7.2) |
| [nx](https://nx.dev) ([source](https://github.com/nrwl/nx/tree/HEAD/packages/nx)) | devDependencies | patch | [`22.7.1` -> `22.7.2`](https://renovatebot.com/diffs/npm/nx/22.7.1/22.7.2) |

---

### Release Notes

<details>
<summary>nrwl/nx (@&#8203;nx/angular)</summary>

### [`v22.7.2`](https://github.com/nrwl/nx/releases/tag/22.7.2)

[Compare Source](https://github.com/nrwl/nx/compare/22.7.1...22.7.2)

##### 22.7.2 (2026-05-14)

##### 🚀 Features

- **gradle:** stream batch task results to nx as they finish ([#&#8203;35487](https://github.com/nrwl/nx/pull/35487))
- **nx-dev:** track docs analytics for code copy, LLM prompt, YouTube ([#&#8203;35526](https://github.com/nrwl/nx/pull/35526))
- **testing:** add migration for Jest 30 snapshot guide link ([#&#8203;35629](https://github.com/nrwl/nx/pull/35629))

##### 🩹 Fixes

- **angular:** disable vitest watch by default ([#&#8203;35493](https://github.com/nrwl/nx/pull/35493))
- **angular-rspack:** keep root-scoped assets out of per-locale i18n emit ([#&#8203;35621](https://github.com/nrwl/nx/pull/35621))
- **bundling:** include tsconfig solution input for rollup ([#&#8203;35476](https://github.com/nrwl/nx/pull/35476))
- **bundling:** include tsconfig solution input for webpack ([#&#8203;35477](https://github.com/nrwl/nx/pull/35477), [#&#8203;35476](https://github.com/nrwl/nx/issues/35476))
- **core:** bump axios to 1.16.0 for all packages ([#&#8203;35568](https://github.com/nrwl/nx/pull/35568))
- **core:** add provenance check in nx console status path ([#&#8203;35485](https://github.com/nrwl/nx/pull/35485))
- **core:** remove access control header from graph app ([#&#8203;35494](https://github.com/nrwl/nx/pull/35494))
- **core:** ensure verbose logs go to stderr and daemon logs are properly decorated ([#&#8203;34358](https://github.com/nrwl/nx/pull/34358))
- **core:** show flaky-task count in run summary ([#&#8203;35491](https://github.com/nrwl/nx/pull/35491))
- **core:** unique telemetry user\_id; expose workspace\_id dimension ([#&#8203;35553](https://github.com/nrwl/nx/pull/35553))
- **core:** update minimatch to 10.2.5 ([#&#8203;35569](https://github.com/nrwl/nx/pull/35569), [#&#8203;34660](https://github.com/nrwl/nx/issues/34660))
- **core:** restore use-legacy-versioning shim for [@&#8203;nx/js](https://github.com/nx/js)[@&#8203;21](https://github.com/21) ensurePackage path ([#&#8203;35574](https://github.com/nrwl/nx/pull/35574))
- **core:** isolate NX\_PARALLEL env var in parallel-related specs ([#&#8203;35579](https://github.com/nrwl/nx/pull/35579))
- **core:** skip handleimport miss path when nx key packages are absent ([#&#8203;35596](https://github.com/nrwl/nx/pull/35596))
- **core:** use gethostuuid(3) instead of ioreg on macOS ([#&#8203;35599](https://github.com/nrwl/nx/pull/35599))
- **core:** isolate cache env vars in splitArgs spec ([#&#8203;35584](https://github.com/nrwl/nx/pull/35584))
- **core:** enable node's native v8 compile cache support ([#&#8203;35415](https://github.com/nrwl/nx/pull/35415), [#&#8203;20454](https://github.com/nrwl/nx/issues/20454))
- **core:** support skipped batch tasks end-to-end and fix TUI double logs ([#&#8203;35617](https://github.com/nrwl/nx/pull/35617))
- **core:** keep TUI task selection on the in-progress section ([#&#8203;35640](https://github.com/nrwl/nx/pull/35640))
- **core:** allow `nx mcp` to run outside of an Nx workspace ([#&#8203;35655](https://github.com/nrwl/nx/pull/35655))
- **core:** cast perf entries to PerformanceMeasure for detail access ([43c0c821ba](https://github.com/nrwl/nx/commit/43c0c821ba))
- **devkit:** exclude dist from jest module path scan ([#&#8203;35615](https://github.com/nrwl/nx/pull/35615))
- **devkit:** expand @&#8203;nx/devkit/internal re-exports for cherry-picked v23 deep-import migration ([#&#8203;35541](https://github.com/nrwl/nx/issues/35541))
- **dotnet:** correct output paths for Web SDK and centralized dist setups ([#&#8203;35398](https://github.com/nrwl/nx/pull/35398))
- **gradle:** exclude batch-runner from jest haste-map crawl ([#&#8203;35501](https://github.com/nrwl/nx/pull/35501))
- **gradle:** exclude project-graph from jest module path scan ([#&#8203;35609](https://github.com/nrwl/nx/pull/35609))
- **gradle:** support Windows file paths ([#&#8203;35184](https://github.com/nrwl/nx/pull/35184), [#&#8203;34987](https://github.com/nrwl/nx/issues/34987))
- **js:** strip glob from inferred outputs before resolving as path ([#&#8203;35463](https://github.com/nrwl/nx/pull/35463), [#&#8203;35452](https://github.com/nrwl/nx/issues/35452))
- **js:** reference vitest.config in eslint dep-checks for vitest libs ([#&#8203;35460](https://github.com/nrwl/nx/pull/35460), [#&#8203;33670](https://github.com/nrwl/nx/issues/33670), [#&#8203;35450](https://github.com/nrwl/nx/issues/35450))
- **js:** include transitive workspace deps in pruned pnpm lockfile ([#&#8203;35532](https://github.com/nrwl/nx/pull/35532), [#&#8203;35347](https://github.com/nrwl/nx/issues/35347), [#&#8203;34655](https://github.com/nrwl/nx/issues/34655))
- **linter:** prevent ENOENT crash in getRelativeImportPath for unresolvable paths ([#&#8203;35007](https://github.com/nrwl/nx/pull/35007), [#&#8203;13872](https://github.com/nrwl/nx/issues/13872), [#&#8203;34066](https://github.com/nrwl/nx/issues/34066), [#&#8203;30491](https://github.com/nrwl/nx/issues/30491), [#&#8203;16716](https://github.com/nrwl/nx/issues/16716), [#&#8203;35006](https://github.com/nrwl/nx/issues/35006), [#&#8203;21889](https://github.com/nrwl/nx/issues/21889), [#&#8203;32190](https://github.com/nrwl/nx/issues/32190))
- **maven:** skip attached artifacts that fail to materialize in batch record ([#&#8203;35473](https://github.com/nrwl/nx/pull/35473))
- **maven:** serialize Maven 4 build state recording ([#&#8203;35555](https://github.com/nrwl/nx/pull/35555))
- **maven:** widen runCLI timeout for --no-batch maven.test.ts cases ([#&#8203;35589](https://github.com/nrwl/nx/pull/35589))
- **nx-dev:** document nested CLI subcommands beyond two levels ([#&#8203;35519](https://github.com/nrwl/nx/pull/35519))
- **nx-dev:** short-circuit bot probes in framer rewrite edge function ([#&#8203;35527](https://github.com/nrwl/nx/pull/35527))
- **react:** withSvgr migration preserves other properties ([#&#8203;35484](https://github.com/nrwl/nx/pull/35484))
- **repo:** clear NX\_INVOCATION\_ROOT\_PID in run-native-target to avoid recursion false-positive ([443dee0b22](https://github.com/nrwl/nx/commit/443dee0b22))
- **repo:** revert deep-import rewrites that targeted v23-only @&#8203;nx/devkit/internal entry ([ac8187963d](https://github.com/nrwl/nx/commit/ac8187963d))
- **repo:** unblock 22.7.x cargo tests and nx-build e2e ([#&#8203;34285](https://github.com/nrwl/nx/issues/34285))
- **repo:** expand "..." spread token in graph typecheck inputs ([#&#8203;34285](https://github.com/nrwl/nx/issues/34285), [#&#8203;35458](https://github.com/nrwl/nx/issues/35458))
- **testing:** pin jest to ~30.3.0 to avoid jest-runtime 30.4 RN incompat ([#&#8203;35618](https://github.com/nrwl/nx/pull/35618))
- **testing:** handle absolute cypress screenshotsFolder/videosFolder paths ([#&#8203;35624](https://github.com/nrwl/nx/pull/35624))
- **testing:** exclude dist and out-tsc from default jest module path scan ([#&#8203;35619](https://github.com/nrwl/nx/pull/35619))
- **testing:** update remaining snapshot guide links missed by migration ([cd350c1140](https://github.com/nrwl/nx/commit/cd350c1140))

##### ❤️ Thank You

- Adam Keenan [@&#8203;adamk33n3r](https://github.com/adamk33n3r)
- AgentEnder [@&#8203;AgentEnder](https://github.com/AgentEnder)
- beeman
- Claude
- Craigory Coppola [@&#8203;AgentEnder](https://github.com/AgentEnder)
- FrozenPandaz [@&#8203;FrozenPandaz](https://github.com/FrozenPandaz)
- Jack Hsu [@&#8203;jaysoo](https://github.com/jaysoo)
- Jason Jean [@&#8203;FrozenPandaz](https://github.com/FrozenPandaz)
- Leosvel Pérez Espinosa [@&#8203;leosvelperez](https://github.com/leosvelperez)
- Max Kless
- MaxKless [@&#8203;MaxKless](https://github.com/MaxKless)
- Optischa [@&#8203;Optischa](https://github.com/Optischa)
- Sharon Lougheed

</details>

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined).

🚦 **Automerge**: Enabled.

♻ **Rebasing**: Whenever PR is behind base branch, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about these updates again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0MC42Mi4xIiwidXBkYXRlZEluVmVyIjoiNDAuNjIuMSIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOlsiZGVwZW5kZW5jaWVzIl19-->

Reviewed-on: https://git.unespace.com/julien/apf_portal/pulls/178
Co-authored-by: APF Portal Bot <jgautier.webdev+apf-portal-bot@gmail.com>
Co-committed-by: APF Portal Bot <jgautier.webdev+apf-portal-bot@gmail.com>
2026-05-17 15:51:56 +02:00
julien b9daaa5f58 fix(portal-shell): same html/body overflow-y hidden shield as admin (#177)
CI / check (push) Successful in 3m55s
CI / commits (push) Has been skipped
CI / scan (push) Successful in 3m15s
CI / a11y (push) Successful in 1m36s
CI / perf (push) Successful in 5m11s
## Summary

Mirror of #176 onto `portal-shell`. Same one-line shield, same rationale: the two apps share an identical `:host { height: 100vh }` + `<main> overflow-y: auto` layout, so the same defensive `html, body { overflow-y: hidden }` rule belongs on both surfaces. Brings the public-facing shell to the same posture as the admin one — any future layout escape stops at the shell boundary rather than producing a phantom body scrollbar plus an empty band below the footer.

## What lands

`apps/portal-shell/src/styles.css`:

```css
html,
body {
  overflow-y: hidden;
}
```

Same block as #176 with a comment that explicitly cross-references the admin shield so future contributors don't accidentally diverge the two apps.

## Notes for the reviewer

- **Why now, rather than waiting for portal-shell to demonstrate the symptom?** #176's reviewer note said this would land "if/when a layout escape shows up there." The user asked for parity immediately, on the reasoning that the shell contract is the *same* on both apps — the shield is defensive and one-line, so coupling the two posture changes is cheaper than tracking a "TODO once we see it in shell".
- **No new tests.** Same justification as #176 — the change is at the global stylesheet level, has no behavioural surface, and the manual repro path already exists (force a chart or wide element past the viewport; pre-shield → body scrollbar + footer gap; post-shield → clipped at the shell root, `<main>` still scrolls).
- **Element-level scrolling on `<main>` is unaffected.** The skip-link, sidebar, and footer all keep their pinned positions; long routes (the user list, future content pages) scroll inside `<main>` as designed.

## Test plan

- [x] `pnpm nx build portal-shell` — clean.
- [x] `pnpm nx test portal-shell` — green.
- [x] `pnpm nx lint portal-shell` — clean.
- [x] `pnpm nx run-many -t build test lint -p portal-shell,portal-admin` — green (admin and shell share the build cache; touching only one file invalidates only the shell target).
- [ ] **Manual smoke** — `pnpm nx serve portal-shell`:
  - Open `/fr` and `/en`, scroll long pages — `<main>` scrolls, body doesn't.
  - Resize across the breakpoint where the sidebar collapses — body still doesn't scroll; sidebar/footer pin correctly.
  - Toggle dark mode — no visual regression.

## What's next

Nothing pending on this shell-shield front. The two apps are now symmetric; if a third app appears (it won't in v1) the same pattern is documented in both `styles.css` headers.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #177
2026-05-17 02:31:15 +02:00
julien 67e50be1dc fix(portal-admin): html/body overflow-y hidden as a shell shield (#176)
CI / check (push) Successful in 4m43s
CI / commits (push) Has been skipped
CI / scan (push) Successful in 3m2s
CI / a11y (push) Successful in 2m10s
CI / perf (push) Successful in 3m43s
## Summary

Tiny follow-up to #175 — the bar / donut / stacked-bar charts on the audit-log Charts tab still surfaced a *phantom* body scrollbar plus an empty band below the footer in some viewport widths. The lib-side overflow constraints from #175 hold for the cases tested, but the symptom can re-appear from any future layout escape (a wider downstream component, a third-party iframe, an unforeseen flex bug).

This PR adds a `html, body { overflow-y: hidden }` shield at the global stylesheet so any vertical overflow at the document level — wherever it comes from — stops at the shell boundary instead of producing a phantom scrollbar. Element-level scrolling on `<main>` (the only surface that *should* scroll) is unaffected.

## What lands

`apps/portal-admin/src/styles.css`:

```css
html,
body {
  overflow-y: hidden;
}
```

That's the whole change. The admin shell already commits to the "fills the viewport, never scrolls the body" layout — `<app-root>` is locked at `height: 100vh` and `<main>` owns its own `overflow-y: auto`. Anything that escapes that contract is, by design, a bug to fix at the source. The shield is a safety net, not a load-bearing layout rule.

## Notes for the reviewer

- **Why only portal-admin?** `portal-shell`'s app.scss carries the exact same `height: 100vh` + `<main> overflow-y: auto` shape, so the same shield would make sense there too. Holding it back to a separate PR because portal-shell hasn't actually demonstrated the symptom and the audit-log chantier is the immediate motivation — a one-line shield to the public-facing app deserves its own minute of consideration. Trivial to extend if/when we want symmetry.
- **Why not just delete `height: 100vh` and let the document scroll naturally?** The admin shell deliberately keeps the header, sidebar, and footer pinned while only the content area scrolls — that's a deliberate UX choice for a dense admin surface (long audit-log tables, future CMS editors), not an accident. Keeping the 100vh contract and adding the shield preserves the intent.
- **Manual reproduction of the original symptom** (now fixed): pre-shield, switching to the Charts tab on a 1280×720 viewport produced a body scrollbar with ~12 px of empty space below the footer, even though every visible element was inside `<main>`. Post-shield, the body scrollbar is gone; `<main>`'s internal scrollbar still works for the table page below the fold.

## Test plan

- [x] `pnpm nx build portal-admin` — clean.
- [x] `pnpm nx test portal-admin` — 62 specs pass (no behavioural change).
- [x] `pnpm nx lint portal-admin` — same three pre-existing warnings, no new ones.
- [ ] **Manual smoke** — `pnpm nx serve portal-admin`, sign in with `Portal.Admin`:
  - Navigate to `/admin/audit`, switch to Charts — no body scrollbar, no gap under the footer, charts still render at the column width.
  - Resize the viewport across the `(max-width: 800px)` breakpoint — body still doesn't scroll; `<main>` still does where it should.
  - Open the user-list page (long table) — `<main>` scrolls internally as expected; the shield does *not* prevent legitimate content scrolling.
  - Toggle dark mode — no visual regression.

## What's next

- Mirror the same shield into `apps/portal-shell/src/styles.css` if/when a layout escape shows up there. Tracked in `docs/decisions/0020-portal-admin-app.md` follow-ups.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #176
2026-05-17 01:56:26 +02:00
julien 9e7eb4af15 fix(audit): chart colours + Charts-tab layout regressions (#175)
CI / check (push) Successful in 6m42s
CI / commits (push) Has been skipped
CI / scan (push) Successful in 3m14s
CI / a11y (push) Successful in 2m38s
CI / perf (push) Successful in 4m33s
## Summary

Follow-up polish on the audit-log Charts tab shipped in #174. Three small but visible regressions surfaced once real data was loaded:

1. **"Events per day" bar chart cycled a categorical palette across the X axis** — every day got a different colour, which read as "categorical meaning per day" when the X axis is just a time bucket. Switched to a single fixed fill (curated blue from the lib palette).
2. **"Outcome breakdown" donut painted slices in Set2 order** — `success` came out teal, `denied` came out orange. With orange = "danger" in most readers' mental model, this is the wrong way around. Added a `colorMap` input on `<lib-donut-chart>` and a curated `semanticStatusColors` export so the audit page can map `success → green`, `denied → orange`, `failure → red`.
3. **Charts tab caused a body scrollbar + empty space below the footer.** Plot renders into a hidden `[hidden]` panel on first switch, so `canvas.clientWidth` is 0 and Plot falls back to its 600/720 default — the resulting fixed-width SVG was wider than the grid column, dragged horizontal overflow up the tree, and (because the grid item defaults to `min-width: auto`) wouldn't shrink. The shell layout broke. Fixed by constraining the chart envelope and the grid items so the SVG can scale down to the actual column width.

## What lands

### Bar chart — single fill, lib-owned default

- `Plot.barY` mark no longer encodes colour from `xKey`. The `fill` parameter is now a literal-string colour, applied uniformly to every bar.
- New `fillColor` input on `<lib-bar-chart>` for the rare case a consumer needs a different shade; defaults to `DEFAULT_BAR_FILL = '#1d4ed8'` (≈ Tailwind blue-700, picked for AA contrast against both light and dark surfaces, colour-blind-safe).
- Removed the now-unused `color: { type: 'ordinal', range: palette }` scale and the `colorScheme` input on the bar chart — the categorical palette only made sense when `fill: xKey` was the default behaviour.

### Donut chart — optional semantic mapping

- New `colorMap?: Readonly<Record<string, string>>` input on `<lib-donut-chart>`. When provided, slice fill resolves to `colorMap[category]` first, falling back to the categorical palette for any unmapped category. Lib still owns the palette per ADR-0023; the map only constrains *which colour the consumer picks for which category*, not what colours exist.
- New `semanticStatusColors` export from `shared-charts` — a curated 5-entry map (`success / warning / error / info / neutral`) tuned for AA contrast and deuteranopia/protanopia distinguishability. Consumers stay inside the lib's palette without authoring their own hex codes.
- The audit page now ships `outcomeColorMap = { success: green, failure: red, denied: orange }` and passes it to the donut. The donut's `description` (screen-reader fallback) and the legend chip semantics now line up.

### Chart envelope — overflow containment

`libs/shared/charts/src/lib/_internal/chart-envelope.scss`:

- Force `display: block` + `min-width: 0` on every chart host element (`lib-bar-chart`, `lib-donut-chart`, `lib-stacked-bar-chart`). Angular custom-element hosts default to `display: inline`, which lets the inner `<figure>` escape parent sizing constraints — the root cause of the body-scrollbar symptom.
- `.chart-canvas` gets `min-width: 0` and constrains every inner `figure { max-width: 100% }` + `svg { max-width: 100%; height: auto }`. The SVG keeps its viewBox aspect ratio while scaling down to the actual column width.

`apps/portal-admin/src/app/pages/audit/audit.scss`:

- `.chart-tile { min-width: 0; }` — grid items default to `min-width: auto`, which prevents shrinking below the intrinsic content width. Without it, Plot's fixed-width SVG could still push the grid column past `1fr` even with the lib-side fixes.

## Notes for the reviewer

- **Why a hardcoded hex (`#1d4ed8`) for `DEFAULT_BAR_FILL` rather than a brand token?** The chart lib is intentionally decoupled from `libs/shared/tokens` — it has no SCSS/CSS-vars dependency and Plot writes the fill as an SVG attribute, not a CSS value, so a CSS variable wouldn't apply anyway. The hex stays inside the lib, marked as the canonical default, with the docstring promising AA contrast + colour-blind safety. If the brand wants to take it over later, swap the constant in one place.
- **`semanticStatusColors` is a curated map, not an open extension point.** Five entries (`success / warning / error / info / neutral`) covers the audit module and any future status-bearing donut (user list, integrations health, etc.). Adding a sixth entry needs a small PR + an a11y-contrast check, which is the right friction.
- **The `aria-label="bar"` selector in the new bar-chart spec.** Plot tags its bar-mark `<g>` with `aria-label="bar"` (and similarly `"rule"` for `Plot.ruleY`). Selecting on it scopes the fill-uniqueness check to actual bars, not axes / ticks / labels. If Plot changes that label upstream the spec breaks loudly — preferable to a fragile geometric selector.
- **Lazy fetch policy and the empty-state edge case** from #174 are unchanged. The donut still receives `colorMap` even when `data` is empty; the lib's `arcs.forEach` short-circuits on zero arcs so no colour lookup happens.
- **`audit.scss` is now 7.5 KB**, still over the 6 KB component-style warning (untouched from #174) and well under the 8 KB error. The new `.chart-tile { min-width: 0 }` rule is two lines.

## Test plan

- [x] `pnpm nx test shared-charts` — **15 specs pass** (was 14: +1 donut `colorMap` test, +1 bar single-fill test, -1 obsolete bar palette assumption).
- [x] `pnpm nx test portal-admin` — **62 specs pass** (unchanged from #174 — semantic mapping is a template-only change, behavioural tests still hold).
- [x] `pnpm nx run-many -t lint test build -p portal-shell,portal-admin,portal-bff,shared-charts` — clean. Same three pre-existing lint warnings, no new ones; bundle sizes unchanged within ±0.1 KB.
- [ ] **Manual smoke** — `pnpm nx serve portal-admin` + `pnpm nx serve portal-bff`, sign in with `Portal.Admin`, navigate to `/admin/audit`:
  - Switch to **Charts** tab — daily-volume bars all render the same blue.
  - Donut centre matches `s.total`, the green slice is `success`, the orange slice is `denied`, the red slice is `failure`. Hover each slice — `<title>` shows `<category>: <count>` (untouched a11y contract from the lib).
  - Body scrollbar stays absent; footer stays anchored at the viewport bottom with no white-space gap.
  - Resize the window through the `(max-width: 800px)` breakpoint — grid collapses to one column, charts re-flow without horizontal overflow.
  - Dark mode toggle — colours stay readable, no contrast regressions.

## What's next

Nothing in this chantier. The audit dashboard is now visually coherent and layout-stable. Two background items to track separately when the CMS / user-list pages land their own dashboards:

- Tab-state URL persistence (carried forward from #174's "what's next").
- Promote the WAI-ARIA tab pattern out of `audit.html` into `libs/shared/ui/tabs/` once a second consumer appears.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #175
2026-05-17 01:17:33 +02:00
julien 9f5106b805 feat(portal-admin): audit log tabs (Table / Charts) + server-side stats consumption (#174)
CI / commits (push) Has been skipped
CI / scan (push) Successful in 3m26s
CI / a11y (push) Successful in 1m44s
CI / perf (push) Successful in 3m57s
CI / check (push) Successful in 1m24s
## Summary

PR 2 of the tabs + full-result-charts chantier — closes the loop opened in #173. The audit log page now splits into a **Table** tab (existing behaviour) and a **Charts** tab fed by the server-side stats endpoint shipped in PR 1.

```
PR 1 (#173, merged) — BFF GET /api/admin/audit/stats + Redis 5min cache
                      + admin.audit.stats.query audit event + ADR-0013 amendment.
PR 2 (this one)     — SPA Tabs UX (Table / Charts) + consume the stats endpoint,
                      replacing the per-page client-side aggregations from #172.
```

## What lands

### Tabs UX — WAI-ARIA tab pattern

The two tabs sit between the filter card and the content area. Visual treatment is intentionally minimal: thin brand-coloured underline on the active tab, focus rings on `:focus-visible`, no surrounding chrome. The panel below inherits the page surface so each tab swap reads as a content-only change.

ARIA wiring:

- `<div role="tablist">` with two `<button role="tab">` children.
- `aria-selected` mirrors the active tab; `aria-controls` points each tab at its panel id; roving `tabindex` (active = `0`, inactive = `-1`) keeps Tab linear.
- Arrow-key navigation between tabs is bound on the individual buttons (not the tablist div) — focusable elements only, satisfies the `template/click-events-have-key-events` lint without an artificial `tabindex="-1"` on the container.
- Two `<section role="tabpanel">` with `[hidden]` binding, `aria-labelledby` pointing back at the tab id.

### Stats consumption — replaces the per-page computeds

`AuditEventsService` grows a `stats(filters)` method that calls `GET /api/admin/audit/stats` and returns `AdminAuditStats` (mirror of the BFF DTO). The audit page replaces the four per-page `computed()`s — `totalOnPage`, `dailyVolume`, `outcomeBreakdown`, `dailyByEventType` — with four signals:

```ts
readonly stats        = signal<AdminAuditStats | null>(null);
readonly statsLoading = signal(false);
readonly statsError   = signal<string | null>(null);
readonly hasChartData = computed(() => (this.stats()?.total ?? 0) > 0);
```

The chart-tile components on the Charts panel consume `stats()?.dailyVolume`, `stats()?.outcomeBreakdown`, `stats()?.eventTypeByDay`, and `stats()?.total` unchanged — same shape as the old per-page projections, just sourced server-side.

### Lazy fetch policy

| Interaction              | Action on `stats`                              |
| ------------------------ | ---------------------------------------------- |
| Page load (Table active) | No call — default tab is Table, stats untouched |
| Click Charts (first time) | Fetch                                          |
| Click Table → Charts     | Fetch only if cleared by a filter change       |
| Apply / clear filters    | Clear `stats`, re-fetch **only if Charts active** |
| Filter by row's actor    | Clear `stats`, re-fetch if Charts active       |
| Next / previous page     | Do **not** invalidate stats (pagination is presentation, the aggregated set is unchanged) |

The Redis cache in PR 1 absorbs repeated identical fetches at ~5 ms; the policy here just makes sure we don't fire a stats call when nobody's looking at it.

### Honest panel copy

The Charts panel's note now reads:

> Aggregations are computed across the full filtered set (server-side), not just the events on the current page. Results are cached for 5 minutes per filter combination.

Replaces the previous "this only reflects the current page" disclaimer that #172 carried as a temporary truth.

## Notes for the reviewer

- **Why a hand-coded tablist instead of a shared `libs/shared/ui/tabs` primitive?** Two tabs, one consumer, ~30 LOC of template + ~25 LOC of SCSS. The two-occurrences-is-duplication rule says wait until a second consumer appears (cf. CMS module or user-list module slated for the next chantier), then extract with the shape both consumers have actually demanded.
- **Why is `setTab()` `async`?** It triggers `fetchStats()` on the first switch to Charts, and the spec exercises that flow with `await`. Keeping it `async` lets the test sequence assertions deterministically without `tick()`/fakeAsync.
- **Why does `search()` clear `stats` *before* the fetch even though `fetchStats()` clears it again at the top?** So the empty-state never flickers through a "stale 1000-event" total while the new fetch is in flight on a slow link. The double-clear is intentional.
- **Bundle impact.** The audit chunk grew from ~82 KB to ~84.5 KB gzip (two new signals, a stats-mapping HTTP call, the tablist template + SCSS). Well under the lazy-chunk 100 KB ceiling from [ADR-0017](docs/decisions/0017-performance-budgets-lighthouse-ci.md).
- **`audit.scss` is 7.5 KB** — over the 6 KB component-style warning, under the 8 KB error. The new `.tablist` / `.tab` rules account for the bump. If a third consumer of the tab pattern lands, the SCSS extraction comes with it.

## Test plan

- [x] `pnpm nx test portal-admin` — **62 specs pass** (was 58: removed 3 obsolete "per-page chart" cases, added 7 new tabs/stats cases).
- [x] `pnpm nx run portal-admin:lint` — clean (the two pre-existing `use-lifecycle-interface` warnings on `audit.ts` / `users.ts` and the spec non-null assertion are untouched, not regressions).
- [x] `pnpm nx build portal-admin` — clean, audit chunk 84.5 KB gzip.
- [x] `pnpm nx run-many -t lint test build -p portal-shell,portal-admin,portal-bff,shared-charts` — clean.
- [ ] **Manual smoke** — `pnpm nx serve portal-admin` + `pnpm nx serve portal-bff`, sign in with `Portal.Admin`, navigate to `/admin/audit`:
  - Page loads on the Table tab — DevTools Network shows `GET /api/admin/audit` only, **no** `/audit/stats` call.
  - Click **Charts** → spinner state, then three chart tiles render with server-side totals.
  - Apply a filter (e.g. `eventType=auth.sign_in`) while on Charts → tiles re-render with the filtered aggregates; donut centre matches the server `total`.
  - Switch back to Table, page through results → no new `/audit/stats` calls (pagination doesn't invalidate).
  - Arrow-Right / Arrow-Left between the two tabs with keyboard — focus moves, ARIA selection follows, `Tab` skips past the inactive tab to the panel content.
  - Toggle dark mode → tablist + active underline + tab focus rings all read correctly in both modes.

## What's next

- Persist the active tab in the URL (`?tab=charts`) so deep-linking and Back/Forward survive the swap. Out of scope for this chantier — once the user-list module lands and we have a second tab consumer, persistence becomes a shared concern.
- Extract `libs/shared/ui/tabs` when a second consumer materialises (CMS or user-list module).
- Surface cache observability — the stats endpoint Redis cache is silent in v1. A future ADR-0024 follow-up could add a `X-Audit-Stats-Cache: hit|miss` header for ops or an OTel attribute on the BFF span. Out of scope here, but worth flagging while the design is fresh.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #174
2026-05-17 00:16:47 +02:00
julien 2cdeb74341 feat(portal-bff): audit-stats endpoint — server-side aggregations with redis cache (#173)
CI / check (push) Successful in 3m26s
CI / commits (push) Has been skipped
CI / scan (push) Successful in 3m47s
CI / a11y (push) Successful in 4m1s
CI / perf (push) Successful in 7m30s
Docs site / build (push) Successful in 6m2s
## Summary

PR 1 of the tabs + full-result-charts chantier. New BFF endpoint `GET /api/admin/audit/stats` that computes the three chart aggregations server-side over the **full filtered set** (not the paginated slice the SPA currently feeds the charts with).

```
PR 1 (this one) — BFF endpoint + Redis cache + audit event + ADR-0013 amendment.
PR 2            — SPA: Tabs UX (Table / Charts) + replace the per-page computeds
                  with calls to this endpoint.
```

## What lands

### New route — `GET /api/admin/audit/stats`

```ts
GET /api/admin/audit/stats?eventType=...&audience=...&outcome=...
                          &subjectPrefix=...&createdAtFrom=...&createdAtTo=...
                          &actorIdHash=...
→ {
    dailyVolume:       [{ day: 'YYYY-MM-DD', count }],
    outcomeBreakdown:  [{ outcome, count }],
    eventTypeByDay:    [{ day, eventType, count }],
    total              // sum of dailyVolume.count, drives the donut centre
  }
```

Same filter shape as the existing `GET /api/admin/audit` minus pagination — the stats endpoint always aggregates the whole filtered set. `@RequireAdmin` gated (per [ADR-0020](docs/decisions/0020-portal-admin-app.md)). Time bound respects the filters strictly per the chantier brief: no filter → aggregates across the full audit retention (365 days per [ADR-0013](docs/decisions/0013-audit-trail-separated-postgres-append-only.md)). The Redis cache below absorbs repeated heavy queries.

### New service — [`AuditStatsReader`](apps/portal-bff/src/admin/audit-stats.service.ts)

Mirrors `AuditReader`'s posture:

- Every query inside a transaction that opens with `SET LOCAL ROLE audit_reader`. SELECT-only on `audit.events` even if the BFF's connection is otherwise privileged.
- Parameterised SQL only. Filter values flow through positional parameters, never concatenated.
- Three `GROUP BY` queries scoped by the same `WHERE` clause:
  - `date_trunc('day', created_at)::date AS day, COUNT(*) GROUP BY day`
  - `outcome::text, COUNT(*) GROUP BY outcome`
  - `date_trunc('day', created_at)::date AS day, event_type, COUNT(*) GROUP BY day, event_type`

### Redis cache — 5-minute TTL per filter-hash

- Cache key: `audit:stats:<sha256(canonical-JSON of filters), 16 hex chars>`. Sorted-keys canonicalisation so the same filters in different argument orders map to the same key.
- TTL: 300 s. Audit rows are append-only so past aggregations are stable; new events are continuously inserted, so admins see at most 5-minute-stale aggregations — acceptable for "approximate dashboard" usage, not for "did the last event just land" debugging (use the list endpoint for that).
- Cache writes are best-effort — a Redis-write failure does not fail the response. The DB read already happened; the next call rebuilds the cache.
- The cache *write* path is covered by spec; the cache-hit shortcut path is covered too (skips the DB transaction entirely).

### New audit event — `admin.audit.stats.query`

Mirrors `admin.audit.query` in posture (every admin read is auditable per ADR-0020 §"Read actions ... to deter fishing expeditions") with two differences:

- Distinct `event_type` so an auditor can spot "scanned aggregations" vs "paged through rows" — different observation signals (the stats endpoint can sweep millions of rows in one call; the list endpoint is bounded by `MAX_LIMIT=200`).
- Payload carries `total` (size of the aggregated set) instead of `resultCount` — stats responses don't paginate, the value carries more "size of scan" signal.

### Light amendment — [ADR-0013](docs/decisions/0013-audit-trail-separated-postgres-append-only.md)

Two additions:

- New **"Reader endpoints"** subsection that enumerates the two-endpoint reader surface (list + stats), documents the Redis-cache caveat, and points at the new `admin.audit.stats.query` event family.
- The "events emitted in v1" table grows four rows it was previously missing on `main`: `admin.access_denied`, `admin.audit.query`, `admin.audit.stats.query`, `admin.users.query`.

No supersession, no new ADR. The decision shape (server-side aggregation + Redis cache + new audit event family) was settled in chat via `AskUserQuestion` before the implementation started; recording it here keeps the ADR honest without spawning a full ADR-0024 for what's essentially an extension of ADR-0013's reader surface.

## Notes for the reviewer

- **Why not factor `buildWhere` into a shared helper between `AuditReader` and `AuditStatsReader`?** Considered. The two readers' shapes diverge in non-trivial ways: `AuditReader` adds `LIMIT/OFFSET` parameters appended to the same parameter array, `AuditStatsReader` runs three queries that all share the same `WHERE` (no further params). A shared helper would have to either expose both shapes or hand back the raw clauses + params for callers to assemble — at which point the abstraction earns its weight back. Two ~50 LOC copies today, extraction when a third reader lands or when the shape diverges further.
- **Why not cap the time window when no filter is provided?** Honest disclosure beats clever defaults. The list endpoint also returns "everything matching the filters" with no protective cap; the stats endpoint follows the same posture. The Redis cache absorbs the cost when the same heavy query lands repeatedly; an admin running unfiltered queries at high rate will see flat latency after the first call. If we later observe a real perf issue, a `windowDays` parameter is a smaller change than retrofitting one across the API.
- **Why a `text` cast on `outcome` in the SQL?** Prisma's Postgres enum types come back as JS strings already, but the `outcome` column carries a Postgres enum (`audit.AuditOutcome`). The explicit `::text` is defensive — `$queryRawUnsafe`'s typing isn't enum-aware, and the cast keeps the projection unambiguous regardless of the driver's row-shape inference.
- **Why does the date round-trip through `Date.toISOString().slice(0, 10)`?** `date_trunc('day', ...)::date` returns a Postgres `date` that node-postgres surfaces as a JS `Date` at UTC midnight. The default `toJSON` serialises the full ISO timestamp with the timezone offset — which is not what the chart x-axis wants. Slicing to `YYYY-MM-DD` matches the SPA's chart bucket convention exactly.
- **No mention of the `actorIdHash` audit row for the stats endpoint?** It's the same hash flow as `adminAuditQuery` — the `actor.oid` from the session goes through `HashUserIdService` per ADR-0012's salt-based pseudonymisation. The same flow is exercised by the existing `adminAuditQuery` tests; the new `adminAuditStatsQuery` method just routes to `recordEvent` with a different `eventType`.

## Test plan

- [x] `pnpm nx test portal-bff` — **414 specs pass** (was 401; +13 new: 8 `AuditStatsReader` service + 5 controller `stats` endpoint).
- [x] `pnpm nx run portal-bff:lint` — clean.
- [x] `pnpm nx build portal-bff` — clean (webpack).
- [ ] **Manual smoke** — `pnpm nx serve portal-bff`, sign in to portal-admin with `Portal.Admin`:
  - `curl http://localhost:3000/api/admin/audit/stats --cookie-jar /tmp/admin` returns the three projections.
  - Verify the `admin.audit.stats.query` row in `audit.events` after the call (`SELECT * FROM audit.events WHERE event_type = 'admin.audit.stats.query' ORDER BY occurred_at DESC LIMIT 1`).
  - Hit the endpoint twice in quick succession with the same filters → second call shows < 5 ms latency (cache hit, no DB transaction).
  - Hit it with different filters → first call hits DB, second cache, third with same filters → cache hit.
  - Stop Redis (`./infra/local/dev.sh stop redis`), hit the endpoint → still succeeds (cache miss + write swallowed), comes back live from DB.

## What's next

PR 2 — SPA Tabs UX (Table / Charts) + replace `dailyVolume() / outcomeBreakdown() / dailyByEventType()` (currently computed from `page().items`) with calls to this endpoint. The three computeds become signals filled by the HTTP call; the chart components on the Charts tab consume them unchanged.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #173
2026-05-16 23:11:52 +02:00
julien 209f44d667 feat(portal-admin): audit dashboard — bar / donut / stacked-bar above the table (#172)
CI / check (push) Successful in 4m13s
CI / commits (push) Has been skipped
CI / scan (push) Successful in 2m26s
CI / a11y (push) Successful in 1m48s
CI / perf (push) Successful in 3m11s
## Summary

PR 3 (final) of the charts chantier — closes the loop on [ADR-0023](docs/decisions/0023-charts-d3-observable-plot.md) by wiring the three starter components shipped in #171 onto the `/audit` page.

```
PR 1  — ADR-0023 (decision + a11y contract + bundle plan).
PR 2  — libs/shared/charts/ foundations + bar / donut / stacked-bar.
PR 3 (this one) — /audit page integration: three charts above the existing table.
```

## What lands

### Three computed aggregations on `AuditPage`

```ts
dailyVolume()       // (day: 'YYYY-MM-DD', count: number)[]
outcomeBreakdown()  // (outcome: string, count: number)[]
dailyByEventType()  // (day, eventType, count)[]   — flat, the chart pre-pivots
totalOnPage()       // number for the donut centre label
hasChartData()      // gate the section out when the page is empty
```

All four derive from the **current page only** (`page()?.items`). No new BFF endpoint — server-side aggregations across the full filter set would need a `/api/admin/audit/stats` resource that's worth its own ADR + chantier when the use case appears.

### Section markup — [`audit.html`](apps/portal-admin/src/app/pages/audit/audit.html)

A new `<section class="charts">` between the status bar and the existing table, gated on `hasChartData()`:

```
[At a glance — current page]
[charts-note: "Aggregations are computed from the events currently loaded on this page only…"]

┌─ Events per day (bar) ─┬─ Outcome breakdown (donut) ─┐
├─ Events per day, by event type (stacked bar) — wide ┤
└──────────────────────────────────────────────────────┘
```

The stacked-bar tile gets `.chart-tile--wide` (spans both grid columns) since its legend benefits from the horizontal space. The two-column grid collapses to one column under 800 px viewports.

### SCSS — [`audit.scss`](apps/portal-admin/src/app/pages/audit/audit.scss)

`.charts` shares the same surface tokens as `.filters` + `.table-wrap` (white / gray-800 background, 1 px border, 0.5 rem radius) so the page reads as one stack of related blocks. `.charts-grid` is `display: grid; grid-template-columns: repeat(2, 1fr)` with a `@media (max-width: 800px)` fallback to single-column.

### Project budget — [`apps/portal-admin/project.json`](apps/portal-admin/project.json)

`anyComponentStyle` budget bumped from 5/6 KB warn/error to **6/8 KB**. `audit.scss` lands at ~6.5 KB after the charts grid additions, comfortably under the new ceiling. The old 5/6 KB threshold predated the charts row; this is a one-time accommodation, not a global relaxation.

### Tsconfig wiring — [`apps/portal-admin/tsconfig.app.json`](apps/portal-admin/tsconfig.app.json)

`nx sync` added the new project reference to `libs/shared/charts/tsconfig.lib.json` after the `import { BarChart, … } from 'shared-charts'` in `audit.ts`. Standard plumbing.

### Spec — [`audit.spec.ts`](apps/portal-admin/src/app/pages/audit/audit.spec.ts)

Three new assertions under a `charts` describe block:

1. The three `<lib-*-chart>` elements render when the page has data.
2. `<section class="charts">` is **absent** when the page is empty (no half-rendered donut on `{ total: 0, items: [] }`).
3. The donut's `.donut-center-label` reads the page's item count — verifies the `[centerLabel]` binding wired correctly.

## Notes for the reviewer

- **Why charts only on the current page, not on the full result set?** Per the "Don't add features beyond what the task requires" rule from CLAUDE.md. The user's brief was "tester les composants sur la page Audit log", not "design a full analytics dashboard". The `.charts-note` paragraph explicitly says "computed from the events currently loaded on this page only" so the limitation is **disclosed**, not hidden. A future server-side `/api/admin/audit/stats` (with `audit_reader`-scoped queries + caching) is the natural follow-up if real investigative use exposes the per-page scope as a friction point.
- **Why `<section>` with its own `<h2>` rather than just inline tiles?** A11y. Per [ADR-0016](docs/decisions/0016-accessibility-baseline-wcag-aa-targeted-aaa.md), document structure is a first-class concern. The charts are a meaningful sub-region of the page; landmark + heading help screen-reader users navigate.
- **Why no i18n marks on the captions?** Portal-admin chrome stays in source locale per ADR-0020. The audit page has zero i18n markers today (filter labels, table headers, error messages are all plain English); the chart captions land in the same posture for consistency. If portal-admin grows i18n later, the captions get marked in the same sweep as the rest of the page.
- **Bundle impact reality-check**: the chart-bearing lazy `audit` chunk grew from ~4.4 KB gzip to **84 KB gzip** with the chart deps loaded. ADR-0023 estimated ~65 KB; the actual is higher because Plot pulls in more `d3-scale-chromatic` interpolators than I projected. Still well under the 100 KB cap from [ADR-0017](docs/decisions/0017-performance-budgets-lighthouse-ci.md), but worth noting if a fourth chart type with its own d3 submodule lands (line / scatter / heatmap will push it further).
- **No anonymous-state regression on the table**: the existing trace-link + actor-pivot behaviours from #163 are unaffected — the charts section sits between the status bar and the table, doesn't share any markup.

## Test plan

- [x] `pnpm nx test portal-admin` — **57 specs pass** (was 54; +3 for the new charts assertions).
- [x] `pnpm nx run-many -t lint test build --projects=portal-shell,portal-admin,shared-charts` — 9/9 tasks green.
- [x] Audit lazy chunk: **84 KB gzip** (was 4.4 KB before the chart deps loaded); under the 100 KB lazy-chunk budget.
- [ ] **Manual visual smoke** — sign in to portal-admin with `Portal.Admin` → navigate `/audit`:
  - The three charts render above the filter form, in a 2-column grid (stacked-bar spanning both).
  - The donut centre label reads the current page's event count.
  - Apply a filter that narrows the page to ~3 events → charts re-render with the narrowed dataset.
  - Click the `<details>` "Data table" disclosure under each chart → the tabular fallback expands with the exact rows.
  - Toggle dark mode in the footer → axis text + chart envelope swap; the donut's Cividis-equivalent palette kicks in on the categorical chart too (palette already swapped by `resolveTheme()` in the lib).
  - Filter to an empty result set → the `.charts` section disappears, the "No audit events match the current filters" empty-state stays.

## What's next

Chantier closed. Three light follow-ups stay open but optional:

- **Server-side aggregations** if the per-page scope becomes a real friction point. New BFF endpoint + likely a fourth `time-range` selector on the SPA.
- **More chart types** (line / scatter / heatmap) when business modules ask for them. The lib's `_internal/` carries the a11y plumbing already; each new component is the ~50 LOC Plot wrapper + spec.
- **Promote shared chart styling** to a fourth shared concern in `libs/shared/ui/` if a third app ever joins. Not urgent.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #172
2026-05-16 22:18:22 +02:00
julien eb8b65c7bc feat(shared-charts): foundations + bar / donut / stacked-bar components (#171)
CI / commits (push) Has been skipped
CI / check (push) Successful in 5m30s
CI / scan (push) Successful in 2m44s
CI / a11y (push) Successful in 2m37s
CI / perf (push) Successful in 4m21s
Docs site / build (push) Successful in 3m1s
## 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
2026-05-16 21:56:28 +02:00
julien 7ee7b2dadf docs(adr-0023): charts and dashboards — d3 + observable plot (#170)
CI / check (push) Successful in 1m17s
CI / commits (push) Has been skipped
CI / scan (push) Successful in 2m2s
CI / a11y (push) Successful in 2m33s
CI / perf (push) Successful in 6m7s
Docs site / build (push) Successful in 3m37s
## Summary

Records the decision to use **D3 + Observable Plot**, wrapped in a new `libs/shared/charts/`, as the chart toolkit shared by `portal-shell` and `portal-admin`. ADR-only — implementation lands as the next chantier(s).

This is staged as a 3-PR chantier per the agreed plan:

| PR | Périmètre |
| --- | --- |
| **PR 1 (this one)** | ADR-0023 — decision + a11y contract + bundle plan. |
| PR 2 | `libs/shared/charts/` foundations + 3 starter components (`<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

### [`docs/decisions/0023-charts-d3-observable-plot.md`](docs/decisions/0023-charts-d3-observable-plot.md)

Full MADR 4.0.0 record. Highlights:

- **Choice**: D3 + Observable Plot, both from Mike Bostock / Observable Inc., both MIT, both past 1.0. Plot covers ~80 % of standard charts in declarative one-liners; D3 stays the escape hatch for bespoke viz (heatmap, sankey, …) inside the same lib.
- **Why not D3 alone**: ~250 LOC per chart × 4-5 types × a11y discipline = sustained code investment before the first dashboard ships.
- **Why not ECharts / Chart.js**: 600 KB minified + canvas-rendered + an `aria` plugin afterthought (ECharts), or narrower vocabulary + brittle dark-mode (Chart.js). Both furthest from the Angular-Signals-zoneless idiom the rest of the workspace runs on.
- **A11y contract** is baked into `_internal/` (palette, tabular fallback, SVG `<title>` / `<desc>` builders) so every chart inherits WCAG 2.2 AA + AAA-targeted compliance from the lib, not from contributor discipline. Six commitments, each unit-tested per chart component.
- **Bundle plan**: ~65 KB gzip added to a chart-bearing lazy chunk (d3 modules tree-shaken + Plot + thin wrapper) — well under [ADR-0017](docs/decisions/0017-performance-budgets-lighthouse-ci.md)'s 100 KB cap.
- **Component contract**: every `<lib-*-chart>` exposes the same Signal-based input shape (`[data]`, `[caption]`, `[description]`, `[ariaLabel]`, `[colorScheme]`) regardless of whether Plot or raw D3 powers the rendering.

### [`docs/decisions/README.md`](docs/decisions/README.md)

ADR-0023 added to the index table.

### [`CLAUDE.md`](CLAUDE.md)

- "Architecture (recorded in ADRs)" gains a "Charts + dashboards" bullet describing the lib + a11y baseline + bundle posture.
- "Repository status" bumps the ADR range to `0001 → 0023`.
- "Still on the roadmap" gains the charts implementation entry pointing at this ADR.

## Notes for the reviewer

- **Why honour the user's D3 preference rather than recommend pure ECharts?** D3 (and by extension Plot) is the closest match to the project's tech bar ("stable, recognized, battle-tested") for data-viz on the web; it's also the user's stated preference, and Plot's higher-level layer eliminates the "250 LOC per chart" cost that would otherwise push us toward an alternative. The ADR explicitly walks through ECharts + Chart.js as runners-up so future challengers see the trade-offs we chose against.
- **Why a single shared lib rather than per-app charts?** Both SPAs (portal-shell + portal-admin) will host dashboards. The chart vocabulary, a11y contract, palette, and theme integration are identical between the two — duplicating into app-local code would invite drift. The lib stays at `libs/shared/charts/` next to `libs/shared/ui/`.
- **Why the `_internal/` folder for cross-cutting code?** Single source of truth for the colour palette and the a11y plumbing. A lint rule (added in PR 2) will ban consumers from importing `d3-scale-chromatic` directly so the colour-blind-safe palette stays the only path.
- **Why no ADR amendment to ADR-0016 / ADR-0017?** Both are binding constraints, not superseded. The new ADR operationalises both for the chart surface; cross-references in the "Related ADRs" section make that explicit.

## Test plan

- [x] ADR validates as MADR 4.0.0 (frontmatter, section order, tag vocabulary).
- [x] No code touched — lint / test / build matrix unaffected.
- [x] `docs/decisions/README.md` index updated in the same change per the [ADR conventions](docs/decisions/README.md#conventions).
- [ ] Review for trade-off accuracy: are the bundle estimates fair? Is the "Plot covers ~80 % of standard charts" framing defensible against the user's mental model of D3?
- [ ] Implementation chantier (PR 2) lands directly behind this if accepted: `pnpm add -w d3 @observablehq/plot @types/d3`, `libs/shared/charts/` scaffold via `pnpm nx g @nx/angular:library --name=shared-charts --directory=libs/shared/charts --standalone=true --unitTestRunner=vitest-analog --tags="scope:shared,type:shared" --no-interactive`, then the 3 starter components.

## What's next

If accepted as-is, PR 2 (lib foundations + 3 starter components) follows. If a reviewer wants to push back on D3-vs-ECharts or on the a11y contract's strictness, this is the right PR to surface that — no implementation has started.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #170
2026-05-16 21:28:03 +02:00
APF Portal Bot 6c42d4c232 fix(deps): update nestjs to v11.1.21 (#169)
CI / commits (push) Has been skipped
CI / scan (push) Successful in 2m32s
CI / a11y (push) Successful in 1m52s
CI / check (push) Successful in 4m27s
CI / perf (push) Successful in 5m25s
Docs site / build (push) Successful in 2m29s
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| [@nestjs/common](https://nestjs.com) ([source](https://github.com/nestjs/nest/tree/HEAD/packages/common)) | dependencies | patch | [`11.1.19` -> `11.1.21`](https://renovatebot.com/diffs/npm/@nestjs%2fcommon/11.1.19/11.1.21) |
| [@nestjs/core](https://nestjs.com) ([source](https://github.com/nestjs/nest/tree/HEAD/packages/core)) | dependencies | patch | [`11.1.19` -> `11.1.21`](https://renovatebot.com/diffs/npm/@nestjs%2fcore/11.1.19/11.1.21) |
| [@nestjs/platform-express](https://nestjs.com) ([source](https://github.com/nestjs/nest/tree/HEAD/packages/platform-express)) | dependencies | patch | [`11.1.19` -> `11.1.21`](https://renovatebot.com/diffs/npm/@nestjs%2fplatform-express/11.1.19/11.1.21) |
| [@nestjs/testing](https://nestjs.com) ([source](https://github.com/nestjs/nest/tree/HEAD/packages/testing)) | devDependencies | patch | [`11.1.19` -> `11.1.21`](https://renovatebot.com/diffs/npm/@nestjs%2ftesting/11.1.19/11.1.21) |

---

### Release Notes

<details>
<summary>nestjs/nest (@&#8203;nestjs/common)</summary>

### [`v11.1.21`](https://github.com/nestjs/nest/releases/tag/v11.1.21)

[Compare Source](https://github.com/nestjs/nest/compare/v11.1.20...v11.1.21)

##### v11.1.21 (2026-05-14)

##### Bug fixes

- `core`
  - [#&#8203;16948](https://github.com/nestjs/nest/pull/16948) fix(core): settle skipped provider initialization ([@&#8203;yudin-s](https://github.com/yudin-s))

##### Committers: 1

- Serge Yudin ([@&#8203;yudin-s](https://github.com/yudin-s))

### [`v11.1.20`](https://github.com/nestjs/nest/releases/tag/v11.1.20)

[Compare Source](https://github.com/nestjs/nest/compare/v11.1.19...v11.1.20)

##### v11.1.20 (2026-05-13)

##### Bug fixes

- `core`, `testing`
  - [#&#8203;16939](https://github.com/nestjs/nest/pull/16939) fix(core): fix deeply nested transient providers resolution ([@&#8203;kamilmysliwiec](https://github.com/kamilmysliwiec))
- `core`
  - [#&#8203;16861](https://github.com/nestjs/nest/pull/16861) fix(core): fix [@&#8203;Sse](https://github.com/Sse) losing events on complete ([@&#8203;MatthiasBrehmer](https://github.com/MatthiasBrehmer))
  - [#&#8203;16753](https://github.com/nestjs/nest/pull/16753) fix(core): defer sse writehead until after lifecycle completes ([@&#8203;jkalberer](https://github.com/jkalberer))
  - [#&#8203;16782](https://github.com/nestjs/nest/pull/16782) fix(core): use strict null check for SSE message id ([@&#8203;burhanharoon](https://github.com/burhanharoon))
- `microservices`
  - [#&#8203;16850](https://github.com/nestjs/nest/pull/16850) fix(microservices): ServerRMQ crashes at boot when [@&#8203;MessagePattern](https://github.com/MessagePattern)(undefined) is combined with wildcards: true ([@&#8203;lavieennoir](https://github.com/lavieennoir))
- `common`
  - [#&#8203;16845](https://github.com/nestjs/nest/pull/16845) fix(common): accept zero timestamp in parse date pipe ([@&#8203;Mysh3ll](https://github.com/Mysh3ll))
- `platform-socket.io`
  - [#&#8203;16742](https://github.com/nestjs/nest/pull/16742) fix(socket.io): Deduplicate disconnect listener in bindMessageHandlers ([@&#8203;fru1tworld](https://github.com/fru1tworld))

##### Enhancements

- `microservices`
  - [#&#8203;16676](https://github.com/nestjs/nest/pull/16676) feat(microservices): add return buffers option for binary data ([@&#8203;Forceres](https://github.com/Forceres))
  - [#&#8203;16826](https://github.com/nestjs/nest/pull/16826) feat(microservices): handle rmq blocked/unblocked connection events ([@&#8203;thisalihassan](https://github.com/thisalihassan))
- `common`
  - [#&#8203;16902](https://github.com/nestjs/nest/pull/16902) fix(common): filetype validator buffer message ([@&#8203;QusaiAlbonni](https://github.com/QusaiAlbonni))
- `platform-express`
  - [#&#8203;16844](https://github.com/nestjs/nest/pull/16844) feat(platform-express): add defParamCharset to MulterOptions ([@&#8203;starnayuta](https://github.com/starnayuta))

##### Dependencies

- `platform-ws`
  - [#&#8203;16941](https://github.com/nestjs/nest/pull/16941) chore(deps): bump ws from 8.20.0 to 8.20.1 ([@&#8203;dependabot\[bot\]](https://github.com/apps/dependabot))

##### Committers: 13

- Ali Hassan ([@&#8203;thisalihassan](https://github.com/thisalihassan))
- Burhan Haroon ([@&#8203;burhanharoon](https://github.com/burhanharoon))
- Dmytro Khyzhniak ([@&#8203;lavieennoir](https://github.com/lavieennoir))
- Harsh Rathod ([@&#8203;harshrathod50](https://github.com/harshrathod50))
- IlyaCredo ([@&#8203;Forceres](https://github.com/Forceres))
- Kamil Mysliwiec ([@&#8203;kamilmysliwiec](https://github.com/kamilmysliwiec))
- Mysh3ll ([@&#8203;Mysh3ll](https://github.com/Mysh3ll))
- [@&#8203;MatthiasBrehmer](https://github.com/MatthiasBrehmer)
- [@&#8203;QusaiAlbonni](https://github.com/QusaiAlbonni)
- [@&#8203;jkalberer](https://github.com/jkalberer)
- [@&#8203;pazaderey](https://github.com/pazaderey)
- fru1tworld ([@&#8203;fru1tworld](https://github.com/fru1tworld))
- starnayuta ([@&#8203;starnayuta](https://github.com/starnayuta))

</details>

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined).

🚦 **Automerge**: Enabled.

♻ **Rebasing**: Whenever PR is behind base branch, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about these updates again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0MC42Mi4xIiwidXBkYXRlZEluVmVyIjoiNDAuNjIuMSIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOlsiZGVwZW5kZW5jaWVzIl19-->

Reviewed-on: #169
Co-authored-by: APF Portal Bot <jgautier.webdev+apf-portal-bot@gmail.com>
Co-committed-by: APF Portal Bot <jgautier.webdev+apf-portal-bot@gmail.com>
2026-05-16 20:36:45 +02:00
APF Portal Bot 1edf154b67 fix(deps): update dependency express-rate-limit to v8.5.2 (#168)
CI / commits (push) Has been skipped
CI / scan (push) Successful in 2m15s
CI / a11y (push) Successful in 1m11s
CI / check (push) Successful in 3m46s
CI / perf (push) Successful in 4m40s
Docs site / build (push) Successful in 2m15s
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| [express-rate-limit](https://github.com/express-rate-limit/express-rate-limit) | dependencies | patch | [`8.5.1` -> `8.5.2`](https://renovatebot.com/diffs/npm/express-rate-limit/8.5.1/8.5.2) |

---

### Release Notes

<details>
<summary>express-rate-limit/express-rate-limit (express-rate-limit)</summary>

### [`v8.5.2`](https://github.com/express-rate-limit/express-rate-limit/releases/tag/v8.5.2)

[Compare Source](https://github.com/express-rate-limit/express-rate-limit/compare/v8.5.1...v8.5.2)

You can view the changelog [here](https://express-rate-limit.mintlify.app/reference/changelog).

</details>

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined).

🚦 **Automerge**: Enabled.

♻ **Rebasing**: Whenever PR is behind base branch, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0MC42Mi4xIiwidXBkYXRlZEluVmVyIjoiNDAuNjIuMSIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOlsiZGVwZW5kZW5jaWVzIl19-->

Reviewed-on: #168
Co-authored-by: APF Portal Bot <jgautier.webdev+apf-portal-bot@gmail.com>
Co-committed-by: APF Portal Bot <jgautier.webdev+apf-portal-bot@gmail.com>
2026-05-16 17:32:44 +02:00
APF Portal Bot b61e550d59 chore(deps): update dependency lint-staged to v17.0.5 (#167)
CI / commits (push) Has been skipped
CI / scan (push) Successful in 2m55s
CI / a11y (push) Successful in 1m42s
CI / check (push) Successful in 4m38s
CI / perf (push) Successful in 5m29s
Docs site / build (push) Successful in 2m32s
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| [lint-staged](https://github.com/lint-staged/lint-staged) | devDependencies | patch | [`17.0.4` -> `17.0.5`](https://renovatebot.com/diffs/npm/lint-staged/17.0.4/17.0.5) |

---

### Release Notes

<details>
<summary>lint-staged/lint-staged (lint-staged)</summary>

### [`v17.0.5`](https://github.com/lint-staged/lint-staged/blob/HEAD/CHANGELOG.md#1705)

[Compare Source](https://github.com/lint-staged/lint-staged/compare/v17.0.4...v17.0.5)

##### Patch Changes

- [#&#8203;1792](https://github.com/lint-staged/lint-staged/pull/1792) [`1f67271`](https://github.com/lint-staged/lint-staged/commit/1f672718b6fa67e0f00aafe107cb9f084f4d9102) - Correctly set the `--max-arg-length` default value based on the running platform. This controls how very long lists of staged files are split into multiple chunks.

</details>

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined).

🚦 **Automerge**: Enabled.

♻ **Rebasing**: Whenever PR is behind base branch, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0MC42Mi4xIiwidXBkYXRlZEluVmVyIjoiNDAuNjIuMSIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOlsiZGVwZW5kZW5jaWVzIl19-->

Reviewed-on: #167
Co-authored-by: APF Portal Bot <jgautier.webdev+apf-portal-bot@gmail.com>
Co-committed-by: APF Portal Bot <jgautier.webdev+apf-portal-bot@gmail.com>
2026-05-16 16:18:44 +02:00
julien 0435fec10a feat(portal-admin): jaeger deep link on trace_id + actor-pivot on actor_id_hash (#166)
CI / check (push) Successful in 2m35s
CI / commits (push) Has been skipped
CI / scan (push) Successful in 1m26s
CI / a11y (push) Successful in 1m49s
CI / perf (push) Successful in 3m48s
## Summary

Turns the audit-log table's `trace_id` and `actor_id_hash` columns from inert text into the two pivots an investigator actually needs:

- **trace_id** → Jaeger deep link (opens in a new tab). Closes the "join audit + traces by trace_id" loop from [ADR-0012](docs/decisions/0012-observability-pino-opentelemetry.md) / [ADR-0013](docs/decisions/0013-audit-trail-separated-postgres-append-only.md) without any new BFF surface.
- **actor_id_hash** → click to refilter the table on that single actor. "Show me everything else this user did" stays in the page; no copy-paste loop.

## What lands

### Trace-id deep link to Jaeger

[`audit.html`](apps/portal-admin/src/app/pages/audit/audit.html#L162-L173) — the cell becomes an `<a target="_blank" rel="noopener noreferrer">` pointing at `${environment.jaegerBaseUrl}/trace/<traceId>`. Anonymous events (`traceId === null`) keep the dash placeholder.

[`environment.ts`](apps/portal-admin/src/environments/environment.ts) gains `jaegerBaseUrl`. Dev defaults to `http://localhost:16686` (matches the compose `observability` profile from `infra/local/dev.compose.yml`). Per-env replacement picks up whatever trace backend the future infrastructure ADR settles on — Tempo, Grafana Cloud, on-prem Jaeger; the SPA-side wiring doesn't care.

### Actor-pivot click

[`audit.html`](apps/portal-admin/src/app/pages/audit/audit.html#L146-L160) — non-null `actorIdHash` becomes a `<button>` styled to read inline like the hash text (`.actor-hash--clickable`: button reset + dotted-underline hover + brand-colored focus ring). Click → `filterByActor(hash)` sets the existing `actorIdHash` filter signal, resets offset to 0, and re-runs the query. Each pivot still emits its own `admin.audit.query` audit row server-side (per [ADR-0020](docs/decisions/0020-portal-admin-app.md)) so the drill is itself auditable.

Anonymous rows keep the `(anonymous)` plain-text rendering — there's no useful filter value to pivot on.

## Why not inline-expand Pino log lines under the row

Considered, deferred. The BFF's Pino output goes to **stdout only** today; standing up a queryable log aggregator (Loki, OpenSearch, …) is a separate infrastructure chantier with its own ADR. The Jaeger jump-off carries ~99 % of the investigator's needs anyway — the trace already contains span attributes (`db.statement`, `http.status_code`, exception events) for the same request scope; Pino lines on top of that would be redundant for most investigations.

When the log aggregator does land, the inline-expand model can come back as a follow-up: `GET /api/admin/logs?traceId=<id>` + an expand affordance on the same row. The current Jaeger anchor and the future inline-logs would naturally coexist (different drills, both surfaced on the same `trace_id`).

## Notes for the reviewer

- **Why a `<button>` for the actor cell rather than an `<a>`?** The action is an in-page filter change, not a navigation. Buttons keep keyboard activation (Enter / Space), don't pollute browser history, and screen readers announce "Filter the table on hash(jane), button" rather than a misleading link role.
- **Why the dotted-underline hover for the actor, but solid-underline for trace?** Different affordances. The trace anchor is a permanent link to an external resource (Jaeger UI), so the solid underline matches the universal "link" convention. The actor button is an inline pivot that *mutates state* — the dotted underline + hover-fill conveys "this does something subtle within the page" without screaming "link".
- **CSS guardrails preserved**: focus rings on both elements, brand-color tokens (light + dark), tap targets meet the AAA 44×44 minimum (the button reset preserves the line-height + the `cell-actor` padding ≥ 12 px on each side).
- **No new i18n strings.** `title` attributes are hover hints, not screen-reader-essential — the underlying hash + traceId are the actual semantic content. The "(anonymous)" string and the dash placeholder were already in the template.
- **No BFF change.** This whole PR is SPA-side only. The audit endpoint already returns `traceId` and `actorIdHash` in every row.

## Test plan

- [x] `pnpm nx run-many -t lint test build --projects=portal-admin` — green.
- [x] **54 portal-admin specs pass** (was 50; +4 for the four new behaviours).
- [x] Lazy `audit` chunk: 18.26 → ~18.5 KB raw / 4.44 KB gzip — comfortably under the per-chunk budget.
- [ ] **Manual smoke**:
  - Sign in to portal-admin with `Portal.Admin` → open `/audit`.
  - Click any trace_id → new tab opens at `http://localhost:16686/trace/<id>` (assuming `./infra/local/dev.sh up observability` is running so Jaeger is up).
  - Anonymous rows show `—` for trace, `(anonymous)` (plain text, not clickable) for actor.
  - Click any non-anonymous actor hash → the table refreshes filtered on that hash, the "Actor id hash" filter input above shows the same value, page jumps to offset 0.
  - Tab through a row: timestamp / event are plain text; outcome badge skipped (not interactive); actor button gets focus ring; trace link gets focus ring; payload `<details>` summary gets focus ring.

## Follow-ups (optional)

- When the log aggregator ADR lands, extend the trace cell to also offer an inline-expand of Pino lines for that trace. Jaeger anchor stays as the primary affordance.
- A similar treatment on the `/users` page (clicking a row's `oid` to "show me this user's audit trail") is the natural sibling. Defer until there's an investigator workflow that asks for it — premature otherwise.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #166
2026-05-16 03:36:40 +02:00
julien 6f26bcdd65 feat(shared-ui): move the role label from the portal-shell sidebar into the user menu (#165)
CI / check (push) Successful in 3m41s
CI / commits (push) Has been skipped
CI / scan (push) Successful in 1m44s
CI / a11y (push) Successful in 1m38s
CI / perf (push) Successful in 3m8s
## Summary

Moves the "Role: …" widget from the bottom of the `portal-shell` sidebar into the user-menu panel header. Result: the role chip appears only when the reader is authenticated (the user menu only renders in that state), removing the noise of "Role: Anonymous" on signed-out traffic and de-duplicating the surface that already carries displayName / username / capability-gated actions.

```
Before: sidebar bottom  →  "Role: Anonymous"  (always rendered, even signed-out)
After:  user-menu panel →  • <role chip>      (only when authenticated)
```

## What lands

### Shared component — [`UserMenu`](libs/shared/ui/src/lib/user-menu/) gets an optional `role` input

When set, a small brand-tinted pill (`data-testid="user-menu-role"`) renders inside the panel header below the username. When undefined, the entire chip is omitted — keeps backwards compat with any future caller that doesn't carry a role concept.

```ts
readonly role = input<string | undefined>(undefined);
```

Visually echoes the `.role-chip` already used on the admin `/profile` page (rounded brand-tinted pill), tightened for the menu header density and `align-self: flex-start` so the pill doesn't stretch.

### Portal-shell

- **Header** ([header.ts](apps/portal-shell/src/app/components/header/header.ts)) gains a `roleLabel` computed that derives `Administrator` / `User` from `CapabilitiesService.canAccessAdmin` — the same logic that previously lived in the sidebar. The template binds it as `[role]="roleLabel()"` on `<lib-user-menu>`.
- **Sidebar** ([sidebar.ts](apps/portal-shell/src/app/components/sidebar/sidebar.ts), [sidebar.html](apps/portal-shell/src/app/components/sidebar/sidebar.html)) loses the role widget entirely: HTML block, `roleLabel` + `roleAriaLabel` computeds, and the `AuthService` + `CapabilitiesService` injections (the sidebar no longer needs them).
- **Sidebar spec** drops its three "role label" tests, grows a guard test asserting `data-testid="sidebar-role"` no longer exists, and reverts to the pre-#151 setup (no Http testing infrastructure — the sidebar no longer fires `/auth/me` or `/me/capabilities`).
- **Header spec** gains two assertions for the role chip inside the open menu panel (`Administrator` when `canAccessAdmin: true`, `User` otherwise).

### Portal-admin

- **Header** ([header.ts](apps/portal-admin/src/app/components/header/header.ts)) passes a **hardcoded `'Administrator'`** through `[role]`. Every reader who reaches the admin app already carries `Portal.Admin` (it's the `AdminRoleGuard` precondition for `/api/admin/*`); no need to re-derive. No i18n marks per ADR-0020's source-locale-only chrome.

### i18n

Five translation units gone from [`messages.fr.xlf`](apps/portal-shell/src/locale/messages.fr.xlf):

```
sidebar.role.anonymous
sidebar.role.administrator
sidebar.role.user
sidebar.role.aria
sidebar.role.label
```

Replaced by two new units under a generic prefix (the new owners are the user menu in either app, not the sidebar):

```
common.role.administrator
common.role.user
```

The `Anonymous` string is gone too — the chip is hidden on anonymous traffic, the label served no purpose without the user menu rendering it.

## Notes for the reviewer

- **Why a generic `common.role.*` prefix rather than `userMenu.role.*`?** The strings are reusable in any context that needs a curated role display (profile page header in a future PR, an audit log "actor role" column, …). Generic prefix avoids the next rename when a second consumer lands.
- **Why hardcode "Administrator" for portal-admin rather than reading `roles`?** The admin SPA's `CurrentUser.roles` carries the raw Entra role string (`Portal.Admin`). Displaying that in the menu header would leak the internal nomenclature into the UI — fine for the `/profile` page (it's explicit context there), wrong for the casual menu glance. The hardcoded label keeps the menu consistent with portal-shell ("Administrator" in both surfaces). If a non-admin role ever reaches portal-admin (the guard rejects it today, but the surface could evolve), this is the one place to revisit.
- **No CapabilitiesService dependency on portal-admin.** The admin app doesn't import the service — its session already exposes `roles` on `/api/admin/auth/me`, and the chip is unconditional. Keeps the admin bundle untouched by the user-portal capabilities plumbing.
- **A11y check.** The role chip is plain text inside the menu panel; the menu panel itself carries `role="menu"` + `aria-label`. The chip doesn't need an extra label — it reads "Administrator" / "User" in context after the displayName and username, which conveys the meaning. No focus state needed (not interactive).

## Test plan

- [x] `pnpm nx run-many -t lint test build --projects=shared-ui,portal-shell,portal-admin` — green.
  - `shared-ui` — **10 specs pass** (was 8; +2 for the role chip).
  - `portal-shell` — **43 specs pass** (header +2, sidebar -3 + 1 guard = -2 net, balanced to +0 on the total → 43 unchanged).
  - `portal-admin` — **50 specs pass** (no spec edits; the `[role]` binding is exercised by the existing user-menu spec via the shared component).
- [x] `pnpm nx build portal-shell --configuration=production` — i18n-strict prod build passes; confirms the xlf cleanup + new `common.role.*` keys are consistent with usage.
- [ ] **Manual smoke**:
  - **portal-shell anonymous**: sidebar bottom shows only the collapse toggle (no role line). Click "Sign in" → land back signed in, sidebar bottom unchanged.
  - **portal-shell signed in (non-admin)**: avatar → open menu → header shows `Signed in as / displayName / username / [User]` chip.
  - **portal-shell signed in (admin)**: same, chip reads `Administrator`, and the menu carries the `Open Portal Admin` row above Sign out.
  - **portal-admin signed in**: avatar → open menu → `[Administrator]` chip regardless of which admin user signed in.
  - Tabbing across the sidebar bottom no longer pauses on a non-interactive `<p>` element — directly hits the collapse toggle button.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #165
2026-05-16 03:09:56 +02:00
julien 10c80f189d feat(portal-shell): align theme switcher trigger with locale switcher shape (#164)
CI / commits (push) Has been skipped
CI / scan (push) Successful in 2m50s
CI / check (push) Successful in 3m24s
CI / a11y (push) Successful in 1m9s
CI / perf (push) Successful in 4m59s
## Summary

Aligns the theme-switcher trigger on the same chip shape as the locale-switcher. Both controls live side-by-side in the footer's device-prefs cluster (#162); the visual mismatch (round icon button vs. chip) made them read as two unrelated widgets rather than one family.

```
Before:  [☀]                       (round icon button, 44×44)
After:   [☀ System ▾]              (chip — icon + label + chevron)
```

The leading icon stays driven by `currentIcon()` so a **sun / moon / monitor** glyph still flips with the selected mode — that's the visual feedback the original icon-only design existed for, and it's preserved.

## What lands

### [`theme-switcher.html`](apps/portal-shell/src/app/components/theme-switcher/theme-switcher.html)

The round Tailwind-utility icon button becomes a `.theme-switcher__trigger` chip with three children — current-mode icon (size 14), localised mode label, `chevron-down` (size 12). Same children layout as `.locale-switcher__trigger`.

### [`theme-switcher.scss`](apps/portal-shell/src/app/components/theme-switcher/theme-switcher.scss)

Adds `.theme-switcher__trigger` mirroring `.locale-switcher__trigger` rule-for-rule: same chip metrics (min-height 2.75rem for the AAA 44×44 tap target via vertical-padding overflow inside the thin footer), same hover/focus tokens, same dark-mode swap.

Two copies rather than a shared `_chip-trigger.scss` partial — two switchers in one app is below the "three similar things" threshold CLAUDE.md sets for extraction. Promotes when a third switcher lands.

## Notes for the reviewer

- **No spec changes**. The existing assertions check `button[aria-haspopup="menu"]` + the aria-label content (`"Theme: <current> (open menu)"`). Both preserved — the trigger button still has `aria-haspopup="menu"` from `cdkMenuTriggerFor`, and `triggerAriaLabel()` is unchanged.
- **No new i18n strings**. The chip text reuses `currentLabel()`, which already returns `Light` / `Dark` / `System` from the existing `theme.mode.{light,dark,system}` translation units (they were used in the dropdown menu items before this PR; now they also drive the trigger label). Prod i18n-strict build passes.
- **Why mirror, not refactor into a shared partial?** Two switchers, identical chip shape — extracting now would be premature abstraction per [CLAUDE.md](CLAUDE.md). When a third switcher lands (an accessibility-panel toggle is the most likely candidate per [ADR-0016](docs/decisions/0016-accessibility-baseline-wcag-aa-targeted-aaa.md)'s preferences-panel plan), `_chip-trigger.scss` or a `<lib-chip-trigger>` component starts to pay off. The two copies today are mechanical and live next door — easy to keep in sync.
- **No `portal-admin` impact.** No theme switcher there (admin chrome is brand-primary-600 hardcoded for now); no change needed.

## Test plan

- [x] `pnpm nx run-many -t lint test build --projects=portal-shell` — green; **43 specs pass** (unchanged from main).
- [x] `pnpm nx build portal-shell --configuration=production` — i18n-strict prod build passes.
- [ ] **Manual visual smoke** — `pnpm nx serve portal-shell`, open the home page in a browser, confirm:
  - The theme switcher in the bottom-right of the footer is now a chip with the current mode icon + label + chevron, matching the locale chip beside it.
  - Hovering both switchers shows the same brand-primary hover color (light + dark mode).
  - Clicking the theme chip still opens the menu with three options (Light / Dark / System), the current option still carries a `✓` check.
  - Selecting Dark → trigger icon flips to moon + label flips to "Dark" + page goes dark. Switching to System: trigger icon flips to monitor + label flips to "System".
  - Tabbing across the footer hits accessibility link → locale → theme in that order, focus rings are identical between the two switchers.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #164
2026-05-16 02:52:53 +02:00
julien bba1703622 fix(deps): scope the vite < 7 override to vitepress so @angular/build keeps vite 8 (#163)
CI / check (push) Successful in 3m15s
CI / commits (push) Has been skipped
CI / scan (push) Successful in 1m25s
CI / a11y (push) Successful in 2m31s
Docs site / build (push) Successful in 4m2s
CI / perf (push) Successful in 5m0s
## Summary

Hotfix on the override added in [#161](#161). The unconditional `"vite": ">=6.4.2 <7"` was too broad — it downgraded **every** vite consumer in the workspace to vite 6.4.2, including `@angular/build@21.2.11`. Angular's dev-server plugin (`angular-memory-plugin.js > loadViteClientCode`) monkey-patches Vite's client error-overlay code; the patch targets vite 8.x's internal layout, fails against vite 6, and the home page blank-screens with:

```
[vite] Internal server error: Failed to update Vite client error overlay text.
  at loadViteClientCode (angular-memory-plugin.js:140:31)
```

…on the very first request to `pnpm nx serve portal-shell`.

## Fix

One-line change in [`package.json`](package.json) → `pnpm.overrides`:

```diff
- "vite":          ">=6.4.2 <7",
+ "vitepress>vite": ">=6.4.2 <7",
```

pnpm's `parent>child` selector syntax scopes the override to vitepress's transitive resolution only. Other vite consumers (Angular, Nx, Vitest, the analog plugin) follow their own peer constraints and resolve to vite 8.0.13 again.

## Resolution after the fix

| Consumer | vite version | Why |
| --- | --- | --- |
| `vitepress 1.6.4` | **6.4.2** | Override target — keeps VitePress 1.x off rolldown-vite |
| `@angular/build 21.2.11` | **8.0.13** | Restored; its dev-server plugin needs vite 8's client layout |
| `@nx/vite`, `@analogjs/vite-plugin-angular`, Vitest | **8.0.13** | Restored |
| `@vitejs/plugin-basic-ssl` (analog transitive) | 7.3.2 | Its own peer range; HTTPS-cert helper only, not the dev-server host. Doesn't affect us. |

## Why this regression didn't surface in #161's CI

The `docs-site.yml` workflow and `pnpm exec nx run-many -t lint test build` exercise the **production build** paths. Vite 6 ↔ Angular-build 21.2's monkey-patch incompatibility is a **dev-server-only** failure (the prod Rollup pipeline doesn't go through `loadViteClientCode`). CI couldn't have caught it; manual `nx serve portal-shell` is the only repro path. Lesson logged.

## Test plan

- [x] `pnpm install` — clean. Lockfile diff confirms: vite 6.4.2 only under vitepress, vite 8.0.13 restored everywhere else.
- [x] `pnpm audit --audit-level=moderate` — no known vulnerabilities.
- [x] `pnpm docs:build` — ~9.5 s, Mermaid still renders in ADR-0009's HTML (regression fence passes).
- [x] `pnpm exec nx run-many -t lint test build --projects=portal-shell,portal-admin,portal-bff,shared-ui,shared-state,feature-auth` — green.
- [x] **`pnpm nx serve portal-shell`** — boots cleanly, `curl http://localhost:4200/` returns HTTP 200 with the SPA shell, no `loadViteClientCode` error in the dev log. (This is the path that broke.)
- [ ] **Manual smoke (the user's repro)** — `pnpm nx serve portal-shell`, open the home page in a browser, confirm:
  - No "Failed to update Vite client error overlay text" in the browser DevTools console.
  - Page renders; navigating to `/accessibility`, `/profile` works.
  - Theme switcher in the footer toggles light / dark / system; locale switcher likewise.

## Notes for the reviewer

- **Lesson on override scoping**: the version-selector form (`"package@<vuln-range>": "patched"`) is the safest default — it only kicks in for the vulnerable range. The unconditional form (`"package": "<range>"`) is a sledgehammer and should be reserved for cases where the version-selector form genuinely can't express the intent (as was the case in #161 where the resolved version was already past the vulnerable range, so the selector was a no-op). When the unconditional form is needed, **always scope to a parent** (`"parent>package": …`) to avoid the workspace-wide blast radius.
- **No documentation change in this PR**: development.md's "Transitive vulnerabilities — `pnpm.overrides`" subsection (from #160) is still accurate. A follow-up edit could add a "scope to a parent when possible" sentence; not blocking.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #163
2026-05-16 02:07:43 +02:00
julien 076cfb67a6 feat(portal-shell): move theme switcher to footer, drop redundant settings icon (#162)
CI / check (push) Successful in 2m6s
CI / commits (push) Has been skipped
CI / scan (push) Successful in 1m32s
CI / a11y (push) Successful in 1m14s
CI / perf (push) Successful in 3m9s
## Summary

Three coupled chrome adjustments in `portal-shell`'s shell layout, all motivated by the same realisation that the UserMenu introduced in #149 made the header's standalone Settings icon redundant and surfaced an inconsistency in the theme switcher's placement (header for everyone, soon to be split between header and user-menu depending on auth state).

## What lands

### 1. Drop the standalone Settings button from the header

The UserMenu already carries a "Settings (Soon)" row for authenticated users, and Settings has no meaning for anonymous traffic. The header icon pointed nowhere; keeping it doubled the surface and forced readers to guess which control to use. `header.action.settings` is removed from [`messages.fr.xlf`](apps/portal-shell/src/locale/messages.fr.xlf) in the same step — the i18n-strict prod build is now back in sync with the source.

### 2. Move `<app-theme-switcher>` from the header to the footer

The switcher now lives beside `<app-locale-switcher>` in a single **device-prefs cluster** on the right side of the footer. Two reasons:

- **Consistency across auth states.** The alternative — keep it in the header for anonymous, move it into the user-menu once authenticated — was the original temptation. Rejected: changing the location of an identical control depending on whether the user is signed in violates Jakob's law and breaks muscle memory. Especially bad for an a11y-first platform per [ADR-0016](docs/decisions/0016-accessibility-baseline-wcag-aa-targeted-aaa.md): a reader who relies on dark mode shouldn't have to expand a menu they don't yet recognise on first visit to escape a flashing white background.
- **Precedent.** [ADR-0019](docs/decisions/0019-internationalisation-angular-localize.md) already established the footer as the home for ambient device preferences (locale switcher). Theme is the obvious sibling — same family (per-device, non-identity).

### 3. Reshape the footer into two semantic clusters

```
[© APF France handicap  ·  Accessibility statement]              [locale  •  theme]
   ←——— info / legal ———→                                         ←—— device prefs ——→
```

The Accessibility statement link moves from the right cluster (where it sat alongside the locale switcher) to the left, joining the copyright with a typographic separator (`·`). Left = static info / legal anchor; right = interactive prefs the reader controls.

Keyboard order naturally follows: a Tab-traversal from the main content hits the informational anchor before the interactive controls — a small a11y win.

## What I left alone

- **`portal-admin`'s header / footer.** No theme switcher there in v1 (the admin chrome is brand-primary-600 hardcoded); no settings icon either. ADR-0020 explicitly trims that admin chrome relative to the user shell — nothing to align right now.
- **The user-menu's "Settings (Soon)" row.** That stays. PR 2 of the auth chantier ([#150](#150)) wired the row at the request of the chantier's staging; the Settings page itself lands in a future chantier and the row's pre-existing "Soon" badge keeps the affordance honest.

## Notes for the reviewer

- **Why not also a "Theme: <current>" hint inside the user-menu?** Considered, deferred. The current Hint would be ornament without a corresponding *target*; once the Settings page exists and the theme has a settings sub-section, a "Theme: System" line in the user-menu makes sense as a deep-link affordance. Not before.
- **Why a `·` text separator rather than a CSS border?** Border would force vertical alignment math (height, dark-mode color) for one line of footer text; a typographic mid-dot inherits text color, scales with the line, and is `aria-hidden` so screen readers don't read it as "middle dot". Smaller surface for one less moving piece.
- **A11y check**: the left cluster's `<nav aria-label="Legal">` is preserved verbatim (just moved into the left `<div>` rather than the right). The accessibility statement keeps the same focus styles, keyboard behavior, and routerLink target.

## Test plan

- [x] `pnpm nx test portal-shell` — **43 specs pass** (was 40; +3 net: dropped a Settings-button assertion + the "embeds theme switcher" header assertion swapped to its inverse, added two footer assertions for the theme switcher's new home + the cluster grouping).
- [x] `pnpm nx run-many -t lint test build --projects=portal-shell` — green.
- [x] `pnpm nx build portal-shell --configuration=production` — i18n-strict prod build passes, confirming the `header.action.settings` xlf removal didn't leave a dangling source-locale reference.
- [ ] **Manual visual smoke**: open `pnpm nx serve portal-shell` in a browser, confirm:
  - The Settings cog is gone from the header.
  - The theme-switcher (sun/moon chip) now lives at the bottom-right of the footer, beside the locale chip.
  - Bottom-left of the footer reads `© 2026 APF France handicap · Accessibility statement`, the dot being decorative (no link).
  - Tabbing from the main content lands first on the accessibility link, then on the locale switcher, then on the theme switcher.
  - Toggling the theme still works as before, and toggling it persists across navigations and reloads (the underlying ThemeService is untouched).

## Follow-ups (optional)

- The header still carries the global search form + notifications + help buttons. None of those are wired to live data yet — when they get real consumers, the equivalent "double-surface" check applies (is there a duplicate path in the user-menu? a redundant trigger?). For now the placeholder shapes are useful to anchor the layout.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #162
2026-05-16 01:14:36 +02:00
julien 2dbf2d8ce4 fix(docs): cap vite below 7 (rolldown-vite) and pin mermaid transitives (#161)
CI / check (push) Successful in 3m32s
CI / commits (push) Has been skipped
CI / scan (push) Successful in 1m54s
CI / a11y (push) Successful in 3m7s
CI / perf (push) Successful in 4m54s
Docs site / build (push) Successful in 4m45s
## Summary

`pnpm docs:dev` failed after #159's transitive-vuln fix landed. Two distinct symptoms in the same log :

1. **VitePress refused to boot** — `VitePress v1 is not compatible with rolldown-vite. Use VitePress v2 instead.` The override added in #159 (`vite@<6.4.2 → >=6.4.2`) had no upper bound; pnpm resolved vitepress's `vite ^5.0.0` constraint up to **vite 7.3.2**, which uses the new rolldown bundler. VitePress 1.x explicitly rejects rolldown-vite.

2. **Resolution-warning flood** — Even on a fallback port, the console showed `Failed to resolve dependency: dayjs / debug / @braintree/sanitize-url / cytoscape / cytoscape-cose-bilkent` from `optimizeDeps.include`. The vitepress-plugin-mermaid wrapper injects those into the optimizer's include list, but under pnpm's strict isolation they aren't reachable from the workspace root (transitives of mermaid, never hoisted).

## What lands

### 1. Vite override is now an **unconditional** `>=6.4.2 <7` range

[`package.json`](package.json):

```diff
- "vite@<6.4.2": ">=6.4.2",
+ "vite": ">=6.4.2 <7",
```

The selector form (`vite@<6.4.2`) was a no-op once vite had resolved into 7.x — the override only activates when the resolved version is _in_ the vulnerable range. Vite 7 is ≥ 6.4.2, so the override stayed dormant and rolldown-vite slipped through.

The unconditional form forces a downgrade across every consumer (vitepress, `@nx/vite`, `@analogjs/vite-plugin-angular`, Vitest, etc.). All six top-level projects still lint, test, and build under vite 6.4.2.

**Trade-off acknowledged**: we're now pinning the whole workspace to vite 6.x to keep VitePress 1.x happy. The day VitePress 2 ships a 1.0 (currently in beta), we revisit and let vite advance again. Tracked as a soft follow-up.

### 2. `optimizeDeps.include` simplified to `['mermaid']`

[`docs/.vitepress/config.mts`](docs/.vitepress/config.mts):

```diff
- include: ['mermaid', 'dayjs', 'debug', '@braintree/sanitize-url'],
+ include: ['mermaid'],
```

Vite 6's dep optimizer walks Mermaid's transitives automatically once Mermaid itself is in `include`. The explicit child list from #157 was carried forward in the rolldown attempt and tripped on vite's stricter resolver — collapsing it now both removes the noise and matches what the plugin's docs recommend for vite 6.

### 3. Mermaid transitives pinned as top-level devDeps

[`package.json`](package.json):

```diff
+ "@braintree/sanitize-url": "^7.1.2",
+ "cytoscape": "^3.33.3",
+ "cytoscape-cose-bilkent": "^4.1.0",
+ "dayjs": "^1.11.20",
+ "debug": "^4.4.3",
```

These are already in `node_modules` (pulled in by mermaid). Declaring them at the workspace root makes them reachable from `optimizeDeps.include` under pnpm's strict isolation, which silences the five "Failed to resolve dependency" warnings the plugin's wrapper produced.

Cost: five extra devDep lines in `package.json` whose only purpose is to make the optimizer happy. Acceptable — they don't influence the resolved tree, just the resolver's reachability rules.

## Notes for the reviewer

- **Why not bump VitePress 1 → 2?** VitePress 2 is still beta. Per [CLAUDE.md](CLAUDE.md) §"Project rules": pre-1.0 dependencies and one-maintainer projects are rejected unless an ADR justifies the exception. ADR-0022 already records VitePress 1.6.4 as the chosen baseline; switching to a beta on the very first follow-up PR would burn the rationale.
- **Why an unconditional vite range, not a tighter selector?** The selector form (`vite@vulnerable-range → patched-range`) is the standard pattern when the parent dep's own range *includes* a patched version — pnpm picks it naturally and the override never fires. Here vite's 5.x branch was never patched (5.4.21 stayed vulnerable; vite team moved on to 6.x), so we need to force the downgrade from 7.x to 6.x regardless of the previous resolution. An unconditional override is the cleanest expression of that intent.
- **Why not extract the mermaid-transitive pins into the ADR-0022 trail?** They're plumbing for the plugin wrapper, not an architectural decision worth recording. If the plugin ships a fix that removes the include list, these can be removed without consequence. Pinning them is reversible.

## Test plan

- [x] `pnpm install` clean; lockfile changes reflect vite 6.4.2 across all consumers.
- [x] `pnpm audit --audit-level=moderate` — **No known vulnerabilities found**.
- [x] `pnpm docs:dev` — server boots cleanly on `:5173`, no warnings, home + ADR-0009 page return 200.
- [x] `pnpm docs:build` — clean build in ~9 s (back to Rollup-based timings; the rolldown 3.87 s we saw briefly was the incompatible path).
- [x] `pnpm exec nx run-many -t lint test --projects=portal-shell,portal-admin,portal-bff,shared-ui,shared-state,feature-auth` — 12/12 tasks green under the vite 6 downgrade.
- [x] `pnpm exec nx build portal-shell` — clean Angular production build; no Vite 6 incompatibility surfaced.
- [ ] **Manual smoke (visual)** — `pnpm docs:dev`, open `http://localhost:5173`, navigate to `/decisions/0009-…` and `/architecture`, confirm Mermaid diagrams render inline (this is the actual UX the user opened the issue on). Dark mode toggle still flips diagrams.

## Follow-ups (optional)

- When VitePress 2 reaches 1.0 (`vue/vitepress > releases`), revisit this override and let vite resume its mainline cadence.
- If the next Renovate cycle proposes a `cytoscape` / `dayjs` / `debug` major bump that VitePress 1.x can't keep up with, the pins above act as the safety net — Renovate will open a PR rather than silently break the dev server.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #161
2026-05-15 23:57:57 +02:00
julien e3a495ab46 docs(development): refresh after phase-3a + add topology / trace diagrams (#160)
CI / check (push) Successful in 2m5s
CI / commits (push) Has been skipped
CI / scan (push) Successful in 1m50s
CI / a11y (push) Successful in 2m17s
CI / perf (push) Successful in 4m6s
Docs site / build (push) Successful in 2m23s
## Summary

`docs/development.md` drifted as `portal-admin` shipped, the docs site landed, Prisma stayed pinned at 6.x, and a fair chunk of the phase-2 "to come" roadmap quietly turned into "shipped". Surgical refresh of the affected sections + two Mermaid diagrams where prose alone wasn't carrying the cognitive load.

## What changed

### Section 1 — Repo layout

- `apps/portal-admin/` + `apps/portal-admin-e2e/` added (both exist on `main` since #134, were never reflected in the tree).
- `prisma.config.ts` removed (phantom — Prisma 6.x doesn't ship one). The "Prisma 7" tag corrected to "Prisma 6.x" with the [ADR-0006](docs/decisions/0006-persistence-postgresql-prisma.md) pin reference.
- `docs/index.md`, `docs/architecture.md`, `docs/.vitepress/` added.
- New workflows listed: `docs-site.yml`, `renovate.yml`.

### Section 3 — Initial setup, new diagram

Mermaid `flowchart` of the local-dev topology. Host-side dev servers (`portal-shell:4200`, `portal-admin:4300`, `portal-bff:3000`, `docs:5173`) ↔ Compose containers (Postgres, Redis, OTel) ↔ viewer profiles (Jaeger, pgweb). Replaces a ports/services context that was scattered across §3 and §5.

### Section 4 — Daily commands

- `portal-admin` added to serve / test / generate examples.
- Single-test-file recipe corrected: Nx's vitest executor rejects `--testFile` (we hit this empirically while debugging the sidebar spec for #151). The new wording recommends positional path for Vitest, `--testPathPattern=…` for Jest.
- **New "Documentation site" subsection** with the three commands (`docs:dev`, `docs:build`, `docs:preview`), plus the recipe for adding an ADR.

### Section 5 — Observability, new diagram

Mermaid `sequenceDiagram` showing how a click becomes a trace: browser `user_interaction` span → `traceparent` header → BFF span → Pino log line with matching `trace_id` → OTLP batch → Jaeger UI. Anchors the prose's "trace_id is the correlation point" rule with a visual.

### Section 6 — Renovate

New subsection **"Transitive vulnerabilities — `pnpm.overrides`"**. Documents the pattern from #159 so the next contributor hitting a transitive vuln (Renovate silent, dashboard empty) has the playbook on hand without re-discovering it.

### Section 9 — Sections to come

Restructured into two groups:

- **Code shipped — doc to write** (Auth dev-loop, session inspection, MFA step-up debugging, admin surface walkthroughs, audit-log workflow, downstream API recipe, OpenAPI/Scalar workflow, capabilities-driven SPA UX). The code is on `main`; only the prose is missing. Each entry now cites the PR(s) that landed the implementation.
- **Not yet** (component patterns library, a11y testing workflow, perf debugging, release workflow, GitLab migration runbook). Both code and doc still pending.

## Notes for the reviewer

- **Why diagrams now, not at the next chantier?** Two pieces of context were genuinely easier to grasp visually than as prose lists: the local-dev topology (which port goes where), and the trace-correlation flow. The other sections (Renovate, conventional commits, CI gates) already read well as tables — adding diagrams there would be ornament, not signal.
- **Why two diagrams rather than ten?** Per the user instruction "use diagrams when useful" — restraint applies. The two added are the ones that *replace* explanatory prose rather than add to it.
- **Why didn't I rewrite the "to come" roadmap from scratch?** The phase mapping was useful intent — kept the same structure, just moved entries between the two columns as code-shipping unlocked them. Future re-shuffles stay cheap.
- **The doc still has phase-1 framing in places** (e.g. the "this doc starts as a phase-1 reference" paragraph in §9). I left it — promoting the doc to "phase-3a reference" is a larger editorial pass than this refresh deserves; the new diagrams + section-9 split already do the practical work.
- **Open questions deferred to a future pass**: the §2 "Prerequisites" table doesn't mention the docs site (`pnpm docs:dev` adds nothing to the prereqs list — just Node + pnpm, both already required). Leaving the table as is.

## Test plan

- [x] `rm -rf docs/.vitepress/{cache,dist} && pnpm docs:build` — clean, 6.3 s.
- [x] Mermaid renders inside `docs/.vitepress/dist/development.html` — both new diagrams produce `class="mermaid"` markers. `grep -c 'class="mermaid"\|<svg' development.html` → **2**.
- [ ] Visual smoke: `pnpm docs:dev`, navigate to `/development`, confirm both diagrams render (topology with port labels readable, sequence diagram with the trace flow). Toggle dark mode, confirm diagrams flip theme.
- [ ] Spot-check the "Code shipped — doc to write" table — for any contributor reading this PR, do the PR-number citations match their memory of when each chantier landed? (`auth ≈ ADR-0009 series`, `admin ≈ #127, #128, #134, #136, #140–142`, `downstream ≈ #137–139`, `OpenAPI ≈ #143`, `capabilities ≈ #151`).

## What's next

The two largest "doc to write" entries (Auth dev-loop, Audit-log inspection workflow) are good candidates for the next docs chantier — both have shipped code, RSSI-relevant content, and would benefit from a guided walkthrough. Not blocking anything; pick when the team has bandwidth.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #160
2026-05-15 23:08:50 +02:00
APF Portal Bot a8f027f546 fix(deps): update dependency axios to v1.16.1 (#158)
CI / commits (push) Has been skipped
CI / scan (push) Successful in 2m43s
CI / a11y (push) Successful in 1m54s
CI / check (push) Successful in 4m39s
CI / perf (push) Successful in 5m41s
Docs site / build (push) Successful in 1m59s
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| [axios](https://axios-http.com) ([source](https://github.com/axios/axios)) | dependencies | patch | [`1.16.0` -> `1.16.1`](https://renovatebot.com/diffs/npm/axios/1.16.0/1.16.1) |

---

### Release Notes

<details>
<summary>axios/axios (axios)</summary>

### [`v1.16.1`](https://github.com/axios/axios/releases/tag/v1.16.1)

[Compare Source](https://github.com/axios/axios/compare/v1.16.0...v1.16.1)

#### v1.16.1 — May 13, 2026

This release ships a defence-in-depth fix for prototype pollution in `formDataToJSON`, hardens proxy and CI workflows, restores Webpack 4 compatibility for the fetch adapter, and includes several small bug fixes and maintenance improvements.

#### 🔒 Security Fixes

- **Prototype Pollution Defence-in-Depth:** Hardened `formDataToJSON` against already-polluted `Object.prototype` by walking own properties only, so attacker-controlled keys inherited from a poisoned prototype cannot propagate through deserialization. (**[#&#8203;7413](https://github.com/axios/axios/issues/7413)**)
- **Proxy Cleartext Leak:** Fixed an issue where HTTPS request data could be transmitted in cleartext to an HTTP proxy under certain configurations. (**[#&#8203;10858](https://github.com/axios/axios/issues/10858)**)
- **CI Cache Removal:** Removed all GitHub Actions caches as a defence-in-depth measure against cache poisoning vectors in the build pipeline. (**[#&#8203;10882](https://github.com/axios/axios/issues/10882)**)

#### 🐛 Bug Fixes

- **Data URI Parsing:** Updated the `fromDataURI` regex to match RFC 2397 more strictly, fixing edge cases in `data:` URL handling. (**[#&#8203;10829](https://github.com/axios/axios/issues/10829)**)
- **Unicode Headers:** Preserved Unicode header values when running through request interceptors, so non-ASCII header content is no longer corrupted before dispatch. (**[#&#8203;10850](https://github.com/axios/axios/issues/10850)**)
- **XHR Upload Progress:** Guarded against malformed `ProgressEvent` payloads emitted by some environments during XHR upload, preventing crashes when `loaded` / `total` are missing or invalid. (**[#&#8203;10868](https://github.com/axios/axios/issues/10868)**)
- **Webpack 4 Fetch Adapter:** Fixed an "unexpected token" error caused by syntax in the fetch adapter that Webpack 4 could not parse, restoring compatibility for legacy bundler users. (**[#&#8203;10864](https://github.com/axios/axios/issues/10864)**)
- **Type Definitions:** Made `parseReviver` `context.source` optional in the type definitions to align with the ES2023 specification. (**[#&#8203;10837](https://github.com/axios/axios/issues/10837)**)
- **URL Object Support Reverted:** Reverted the change that allowed passing a `URL` object as `config.url` (originally **[#&#8203;10866](https://github.com/axios/axios/issues/10866)**) due to regressions; this support will be reintroduced in a later release once the underlying issues are addressed. (**[#&#8203;10874](https://github.com/axios/axios/issues/10874)**)

#### 🔧 Maintenance & Chores

- **Cycle Detection Refactor:** Replaced the array-based cycle tracker in `toJSONObject` with a `WeakSet`, improving performance and memory behaviour on large nested structures. (**[#&#8203;10832](https://github.com/axios/axios/issues/10832)**)
- **composeSignals Cleanup:** Refactored `composeSignals` to use a clearer early-return structure, simplifying the cancellation/abort composition path. (**[#&#8203;10844](https://github.com/axios/axios/issues/10844)**)
- **AI Readiness & Repo Docs:** Added `AGENTS.md` and related contributor-guide updates for both human and AI agents, plus post-release documentation improvements. (**[#&#8203;10835](https://github.com/axios/axios/issues/10835)**, **[#&#8203;10841](https://github.com/axios/axios/issues/10841)**)
- **Docs Improvements:** Clarified the GET request example, fixed the interceptor `eject` example to reference the correct instance, and corrected the Buzzoid sponsor description in the README. (**[#&#8203;10836](https://github.com/axios/axios/issues/10836)**, **[#&#8203;10853](https://github.com/axios/axios/issues/10853)**, **[#&#8203;10856](https://github.com/axios/axios/issues/10856)**)
- **Sponsorship Tooling:** Fixed empty sponsor arrays in the sponsor processing script, added the ability to inject additional sponsors, updated the sponsorship link, and added a Twicsy advertisement entry. (**[#&#8203;10843](https://github.com/axios/axios/issues/10843)**, **[#&#8203;10859](https://github.com/axios/axios/issues/10859)**, **[#&#8203;10869](https://github.com/axios/axios/issues/10869)**)
- **Dependencies:** Bumped `@commitlint/cli` from 20.5.0 to 20.5.2. (**[#&#8203;10846](https://github.com/axios/axios/issues/10846)**)

#### 🌟 New Contributors

We are thrilled to welcome our new contributors. Thank you for helping improve axios:

- **[@&#8203;hpinmetaverse](https://github.com/hpinmetaverse)** (**[#&#8203;10836](https://github.com/axios/axios/issues/10836)**)
- **[@&#8203;tommyhgunz14](https://github.com/tommyhgunz14)** (**[#&#8203;7413](https://github.com/axios/axios/issues/7413)**)
- **[@&#8203;abhu85](https://github.com/abhu85)** (**[#&#8203;10829](https://github.com/axios/axios/issues/10829)**)
- **[@&#8203;divyanshuraj1095](https://github.com/divyanshuraj1095)** (**[#&#8203;10853](https://github.com/axios/axios/issues/10853)**)
- **[@&#8203;sagodi97](https://github.com/sagodi97)** (**[#&#8203;10856](https://github.com/axios/axios/issues/10856)**)
- **[@&#8203;rkdfx](https://github.com/rkdfx)** (**[#&#8203;10868](https://github.com/axios/axios/issues/10868)**)
- **[@&#8203;Liuwei1125](https://github.com/Liuwei1125)** (**[#&#8203;10866](https://github.com/axios/axios/issues/10866)**)

[Full Changelog](https://github.com/axios/axios/compare/v1.16.0...v1.16.1)

</details>

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined).

🚦 **Automerge**: Enabled.

♻ **Rebasing**: Whenever PR is behind base branch, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0MC42Mi4xIiwidXBkYXRlZEluVmVyIjoiNDAuNjIuMSIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOlsiZGVwZW5kZW5jaWVzIl19-->

Reviewed-on: https://git.unespace.com/julien/apf_portal/pulls/158
Co-authored-by: APF Portal Bot <jgautier.webdev+apf-portal-bot@gmail.com>
Co-committed-by: APF Portal Bot <jgautier.webdev+apf-portal-bot@gmail.com>
2026-05-15 22:38:43 +02:00
julien 779061660b chore(security): override vite + esbuild past vitepress's vulnerable transitives (#159)
CI / commits (push) Has been skipped
CI / check (push) Successful in 3m46s
CI / scan (push) Successful in 2m4s
CI / a11y (push) Successful in 2m3s
Docs site / build (push) Successful in 3m44s
CI / perf (push) Successful in 3m59s
## Summary

Unblocks `pnpm ci:audit`. Two moderate vulnerabilities surfaced after the docs-site chantier (#154):

| Advisory | Package | Vulnerable | Patched | Path |
| --- | --- | --- | --- | --- |
| [GHSA-67mh-4wv8-2f99](https://github.com/advisories/GHSA-67mh-4wv8-2f99) | esbuild | ≤ 0.24.2 | ≥ 0.25.0 | `. > vitepress > vite > esbuild` |
| [GHSA-4w7w-66w2-5vf9](https://github.com/advisories/GHSA-4w7w-66w2-5vf9) | vite | ≤ 6.4.1 | ≥ 6.4.2 | `. > vitepress > vite` |

Both come from VitePress 1.6.4's pinned dep tree (`vite@5.4.21 → esbuild@0.21.5`). The rest of the workspace was already on vite 8.0.13 + esbuild 0.27.3 — only the VitePress branch was stuck on the vulnerable line.

## What lands

Two new entries in `package.json`'s existing `pnpm.overrides` block:

```json
"esbuild@<0.25.0": ">=0.25.0",
"vite@<6.4.2":     ">=6.4.2",
```

Same version-selector pattern as the other overrides already in the file (axios, follow-redirects, ip-address, …). The override only kicks in when the resolved version is in the vulnerable range, so it becomes a no-op the day the underlying dep ships a clean version of its own.

After `pnpm install`, the resolver picks **vite 7.3.2** + **esbuild 0.27.3** for the VitePress branch (the workspace's other vite consumers stay on 8.0.13, deduped on esbuild).

## Why couldn't we pin a patched vite 5.x?

The vite team did **not** backport the security fix to the 5.x line. `vite@5.4.22` is not published — the latest 5.x stays at 5.4.21, which is vulnerable. The only path forward is to let pnpm pick a patched 6.x or 7.x major. Verified that VitePress 1.6.4 still:

- builds cleanly (`pnpm docs:build` succeeds in ~4 s, down from 9 s on the older vite);
- renders Mermaid (regression fence in `.gitea/workflows/docs-site.yml` still grep-matches `class="mermaid"` / `<svg>` in ADR-0009's HTML);
- runs the dev server (`pnpm docs:dev` boots, the dayjs CJS-interop fix from #156 still applies for the same reason — Mermaid's CJS deps need pre-bundling regardless of vite major).

## Why didn't Renovate propose this PR itself?

The user reported that Renovate wasn't creating PRs for these advisories, not even listing them on the Dependency Dashboard.

**Root cause: both vite and esbuild are transitive dependencies** — declared by vitepress, not by us. Renovate's `vulnerabilityAlerts` flow handles **direct** package.json deps. For pnpm transitives, the remediation would have to land in `pnpm.overrides`, which the renovatebot/renovate:40 image doesn't write automatically.

Adjacent points:

- The Renovate workflow runs on a daily 03:00 UTC cron only (plus manual dispatch). If the user wants an immediate dashboard refresh now, the workflow accepts `workflow_dispatch` — fire it once from the Gitea Actions UI.
- After this PR merges, Renovate's dashboard should also stop flagging these advisories (the overrides count as remediation).
- Renovate config itself is unchanged — no `ignorePaths`, no exclude of vite/esbuild/vitepress. The silence was purely about the transitive-remediation gap, not a config bug.

## Test plan

- [x] `pnpm install` — clean, no peer warnings beyond the pre-existing `nestjs-prisma → chokidar` one.
- [x] `pnpm audit --audit-level=moderate` — **"No known vulnerabilities found"**.
- [x] `pnpm docs:build` — clean build in ~4 s.
- [x] Mermaid regression fence — `grep 'class="mermaid"' docs/.vitepress/dist/decisions/0009-…html` matches.
- [ ] `pnpm ci:audit` on CI — should now pass (the goal of this PR).
- [ ] Manual smoke: `pnpm docs:dev`, navigate to `/decisions/0009-…`, confirm the OIDC sequence diagram renders. Dark-mode toggle still flips theme.

## Follow-ups (optional)

- Trigger the Renovate workflow manually (Gitea Actions → Renovate → Run workflow) so the Dependency Dashboard refreshes against this overridden state.
- If we hit this transitive-remediation gap again, consider raising a `renovateConfig.transitiveRemediation` story or switching the workflow to `renovate/renovate:latest` — newer point releases sometimes ship better pnpm-overrides authoring. Not urgent.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #159
2026-05-15 22:18:07 +02:00
APF Portal Bot ed477aeb26 fix(deps): update dependency @scalar/nestjs-api-reference to v1.1.16 (#156)
CI / scan (push) Failing after 1m16s
CI / commits (push) Has been skipped
CI / a11y (push) Successful in 2m19s
CI / perf (push) Successful in 3m51s
Docs site / build (push) Successful in 3m41s
CI / check (push) Failing after 13m41s
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| [@scalar/nestjs-api-reference](https://github.com/scalar/scalar) ([source](https://github.com/scalar/scalar/tree/HEAD/integrations/nestjs)) | dependencies | patch | [`1.1.14` -> `1.1.16`](https://renovatebot.com/diffs/npm/@scalar%2fnestjs-api-reference/1.1.14/1.1.16) |

---

### Release Notes

<details>
<summary>scalar/scalar (@&#8203;scalar/nestjs-api-reference)</summary>

### [`v1.1.16`](https://github.com/scalar/scalar/blob/HEAD/integrations/nestjs/CHANGELOG.md#1116)

</details>

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined).

🚦 **Automerge**: Enabled.

♻ **Rebasing**: Whenever PR is behind base branch, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0MC42Mi4xIiwidXBkYXRlZEluVmVyIjoiNDAuNjIuMSIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOlsiZGVwZW5kZW5jaWVzIl19-->

Reviewed-on: #156
Co-authored-by: APF Portal Bot <jgautier.webdev+apf-portal-bot@gmail.com>
Co-committed-by: APF Portal Bot <jgautier.webdev+apf-portal-bot@gmail.com>
2026-05-15 20:04:13 +02:00
julien d94df427a1 fix(docs): pre-bundle mermaid deps so the dev server resolves dayjs/cytoscape (#157)
CI / check (push) Successful in 1m6s
CI / commits (push) Has been skipped
CI / scan (push) Failing after 1m15s
CI / a11y (push) Successful in 2m3s
CI / perf (push) Successful in 4m45s
Docs site / build (push) Successful in 3m19s
## Summary

`pnpm docs:dev` rendered a blank page with this thrown by the browser on the first navigation:

```
Uncaught SyntaxError: The requested module '/@fs/.../node_modules/.pnpm/dayjs@1.11.20/node_modules/dayjs/dayjs.min.js?v=…'
does not provide an export named 'default'
(at chunk-AGHRB4JF.mjs?v=…:9:8)
```

Root cause: Mermaid 11 (transitive dep of `vitepress-plugin-mermaid` shipped in #154) pulls in `dayjs`, `cytoscape`, `debug`, `@braintree/sanitize-url` — each ships a CommonJS `main` field with no `default` ESM export. Vite's dev server resolves these modules eagerly as ESM and the browser blows up before the home page renders.

The **production build was unaffected** — Rollup's plugin pipeline already wraps CJS deps for ESM consumers. `docs:build` shipped clean HTML in #154 and the CI Mermaid-fence (`grep class="mermaid"` in ADR-0009's HTML) passed precisely because it inspects the prod bundle, not the dev-server output.

## What lands

[`docs/.vitepress/config.mts`](docs/.vitepress/config.mts) — adds a single `vite.optimizeDeps.include` block:

```ts
vite: {
  optimizeDeps: {
    include: ['mermaid', 'dayjs', 'debug', '@braintree/sanitize-url'],
  },
},
```

`optimizeDeps.include` tells Vite to pre-bundle those modules through esbuild at dev-server boot, applying the same CJS→ESM interop wrapper Rollup uses in prod. The dev server now serves a working `localhost:5173` and Mermaid diagrams render.

## Notes for the reviewer

- **Why these four names specifically?** They are the Mermaid deps that ship CJS-only entrypoints. Adding `'mermaid'` alone is sometimes enough because Vite walks the dep tree, but the plugin's own README + a handful of upstream issue threads recommend listing the leaf CJS modules explicitly so the optimizer doesn't miss them on a cold cache. Cheap, defensive.
- **Why didn't the CI gate catch this?** The `Assert Mermaid renders` step in `.gitea/workflows/docs-site.yml` greps `docs/.vitepress/dist/` — the production-build output. The bug only manifests on the dev server's runtime-pre-bundling path. Catching it in CI would require booting `docs:dev` headlessly and curling the page; not worth the workflow weight for a once-per-major-upgrade class of issue. Manual `pnpm docs:dev` smoke is the right gate for now.
- **Why not bump dayjs / mermaid?** Dayjs's package shape is a long-standing upstream quirk (the `main` points at the UMD/CJS bundle); fixing it upstream would be a breaking change for non-bundler consumers. Mermaid upstream is aware; their fix has historically been "tell your bundler to pre-bundle us", which is exactly what `optimizeDeps.include` does.

## Test plan

- [x] `rm -rf docs/.vitepress/cache && pnpm docs:dev` — server boots, `curl http://localhost:5173/` returns 200.
- [x] `rm -rf docs/.vitepress/{cache,dist} && pnpm docs:build` — clean prod build in ~10 s, Mermaid SVG still present in ADR-0009's HTML (regression fence passes).
- [ ] Manual smoke: with the fix applied, navigate `localhost:5173` → home → `/decisions/0009-…` → confirm the OIDC sequence diagram renders inline, no console errors. Toggle dark mode, confirm diagrams flip theme.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #157
2026-05-15 20:03:29 +02:00
APF Portal Bot abdfb8e1f4 fix(deps): update angular (#155)
CI / commits (push) Has been skipped
CI / scan (push) Failing after 2m44s
CI / check (push) Successful in 6m19s
CI / a11y (push) Successful in 3m23s
CI / perf (push) Successful in 7m22s
Docs site / build (push) Successful in 2m28s
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| [@angular-devkit/core](https://github.com/angular/angular-cli) | devDependencies | patch | [`21.2.10` -> `21.2.11`](https://renovatebot.com/diffs/npm/@angular-devkit%2fcore/21.2.10/21.2.11) |
| [@angular-devkit/schematics](https://github.com/angular/angular-cli) | devDependencies | patch | [`21.2.10` -> `21.2.11`](https://renovatebot.com/diffs/npm/@angular-devkit%2fschematics/21.2.10/21.2.11) |
| [@angular/build](https://github.com/angular/angular-cli) | devDependencies | patch | [`21.2.10` -> `21.2.11`](https://renovatebot.com/diffs/npm/@angular%2fbuild/21.2.10/21.2.11) |
| [@angular/cdk](https://github.com/angular/components) | dependencies | patch | [`21.2.10` -> `21.2.11`](https://renovatebot.com/diffs/npm/@angular%2fcdk/21.2.10/21.2.11) |
| [@angular/cli](https://github.com/angular/angular-cli) | devDependencies | patch | [`21.2.10` -> `21.2.11`](https://renovatebot.com/diffs/npm/@angular%2fcli/21.2.10/21.2.11) |
| [@angular/common](https://github.com/angular/angular) ([source](https://github.com/angular/angular/tree/HEAD/packages/common)) | dependencies | patch | [`21.2.12` -> `21.2.13`](https://renovatebot.com/diffs/npm/@angular%2fcommon/21.2.12/21.2.13) |
| [@angular/compiler](https://github.com/angular/angular) ([source](https://github.com/angular/angular/tree/HEAD/packages/compiler)) | dependencies | patch | [`21.2.12` -> `21.2.13`](https://renovatebot.com/diffs/npm/@angular%2fcompiler/21.2.12/21.2.13) |
| [@angular/compiler-cli](https://github.com/angular/angular/tree/main/packages/compiler-cli) ([source](https://github.com/angular/angular/tree/HEAD/packages/compiler-cli)) | devDependencies | patch | [`21.2.12` -> `21.2.13`](https://renovatebot.com/diffs/npm/@angular%2fcompiler-cli/21.2.12/21.2.13) |
| [@angular/core](https://github.com/angular/angular) ([source](https://github.com/angular/angular/tree/HEAD/packages/core)) | dependencies | patch | [`21.2.12` -> `21.2.13`](https://renovatebot.com/diffs/npm/@angular%2fcore/21.2.12/21.2.13) |
| [@angular/forms](https://github.com/angular/angular) ([source](https://github.com/angular/angular/tree/HEAD/packages/forms)) | dependencies | patch | [`21.2.12` -> `21.2.13`](https://renovatebot.com/diffs/npm/@angular%2fforms/21.2.12/21.2.13) |
| [@angular/language-service](https://github.com/angular/angular) ([source](https://github.com/angular/angular/tree/HEAD/packages/language-service)) | devDependencies | patch | [`21.2.12` -> `21.2.13`](https://renovatebot.com/diffs/npm/@angular%2flanguage-service/21.2.12/21.2.13) |
| [@angular/localize](https://github.com/angular/angular) | dependencies | patch | [`21.2.12` -> `21.2.13`](https://renovatebot.com/diffs/npm/@angular%2flocalize/21.2.12/21.2.13) |
| [@angular/platform-browser](https://github.com/angular/angular) ([source](https://github.com/angular/angular/tree/HEAD/packages/platform-browser)) | dependencies | patch | [`21.2.12` -> `21.2.13`](https://renovatebot.com/diffs/npm/@angular%2fplatform-browser/21.2.12/21.2.13) |
| [@angular/router](https://github.com/angular/angular/tree/main/packages/router) ([source](https://github.com/angular/angular/tree/HEAD/packages/router)) | dependencies | patch | [`21.2.12` -> `21.2.13`](https://renovatebot.com/diffs/npm/@angular%2frouter/21.2.12/21.2.13) |
| [@schematics/angular](https://github.com/angular/angular-cli) | devDependencies | patch | [`21.2.10` -> `21.2.11`](https://renovatebot.com/diffs/npm/@schematics%2fangular/21.2.10/21.2.11) |
| [angular-eslint](https://github.com/angular-eslint/angular-eslint) ([source](https://github.com/angular-eslint/angular-eslint/tree/HEAD/packages/angular-eslint)) | devDependencies | minor | [`21.3.1` -> `21.4.0`](https://renovatebot.com/diffs/npm/angular-eslint/21.3.1/21.4.0) |

---

### Release Notes

<details>
<summary>angular/angular-cli (@&#8203;angular-devkit/core)</summary>

### [`v21.2.11`](https://github.com/angular/angular-cli/blob/HEAD/CHANGELOG.md#21211-2026-05-13)

[Compare Source](https://github.com/angular/angular-cli/compare/v21.2.10...v21.2.11)

##### [@&#8203;angular/cli](https://github.com/angular/cli)

| Commit                                                                                              | Type | Description                            |
| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------- |
| [bbd63b7a5](https://github.com/angular/angular-cli/commit/bbd63b7a5a1049bc56b9ddf6edf6563a1f2d9ace) | fix  | robustly parse npm manifest from array |

##### [@&#8203;angular/ssr](https://github.com/angular/ssr)

| Commit                                                                                              | Type | Description                                                                     |
| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------------------- |
| [eafe1a719](https://github.com/angular/angular-cli/commit/eafe1a719fd3fecd5263e0a8371200b4b1ff4bb9) | fix  | allow all hosts in common engine rendering options to prevent validation errors |
| [7a116a80d](https://github.com/angular/angular-cli/commit/7a116a80d7e6db341fd003737285d1a9db10ba6c) | fix  | remove stateful flag from URL\_PARAMETER\_REGEXP                                  |

<!-- CHANGELOG SPLIT MARKER -->

</details>

<details>
<summary>angular/components (@&#8203;angular/cdk)</summary>

### [`v21.2.11`](https://github.com/angular/components/blob/HEAD/CHANGELOG.md#21211-crystal-ball-2026-05-13)

[Compare Source](https://github.com/angular/components/compare/v21.2.10...v21.2.11)

No user facing changes in this release

<!-- CHANGELOG SPLIT MARKER -->

</details>

<details>
<summary>angular/angular (@&#8203;angular/common)</summary>

### [`v21.2.13`](https://github.com/angular/angular/blob/HEAD/CHANGELOG.md#21213-2026-05-13)

[Compare Source](https://github.com/angular/angular/compare/v21.2.12...v21.2.13)

##### core

| Commit | Type | Description |
| -- | -- | -- |
| [1c6553e97d](https://github.com/angular/angular/commit/1c6553e97d9655d8c48fbf625987fae86f9cd947) | fix | disallow event attribute bindings in host bindings unconditionally |

##### platform-server

| Commit | Type | Description |
| -- | -- | -- |
| [629905d537](https://github.com/angular/angular/commit/629905d537f59dc3c264c49f6347e3599dea0215) | fix | add `allowedHosts` option to `renderModule` and `renderApplication` |
| [0b7192f441](https://github.com/angular/angular/commit/0b7192f4410d055191ac9b15bff57d1d0b9a644f) | fix | forward BEFORE\_APP\_SERIALIZED errors to ErrorHandler |

<!-- CHANGELOG SPLIT MARKER -->

</details>

<details>
<summary>angular-eslint/angular-eslint (angular-eslint)</summary>

### [`v21.4.0`](https://github.com/angular-eslint/angular-eslint/blob/HEAD/packages/angular-eslint/CHANGELOG.md#2140-2026-05-13)

[Compare Source](https://github.com/angular-eslint/angular-eslint/compare/v21.3.1...v21.4.0)

This was a version bump only for angular-eslint to align it with other projects, there were no code changes.

</details>

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined).

🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.

👻 **Immortal**: This PR will be recreated if closed unmerged. Get [config help](https://github.com/renovatebot/renovate/discussions) if that's undesired.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0MC42Mi4xIiwidXBkYXRlZEluVmVyIjoiNDAuNjIuMSIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOlsiZGVwZW5kZW5jaWVzIl19-->

Reviewed-on: #155
Co-authored-by: APF Portal Bot <jgautier.webdev+apf-portal-bot@gmail.com>
Co-committed-by: APF Portal Bot <jgautier.webdev+apf-portal-bot@gmail.com>
2026-05-15 19:38:25 +02:00
257 changed files with 29407 additions and 3787 deletions
+65
View File
@@ -0,0 +1,65 @@
// `apf-portal` devcontainer — VSCode Dev Containers spec.
//
// Pinned Node + pnpm via corepack via the official typescript-node image
// (Bookworm base). The container has access to the host docker daemon
// through the docker socket (`docker-outside-of-docker` feature), so
// `infra/local/dev.sh up` on the HOST creates the postgres/redis/otel
// containers and this container connects to them through the shared
// `apf-portal-dev` network.
//
// Workflow:
// 1. On the host: ./infra/local/dev.sh up (creates the network)
// 2. In VSCode: F1 → "Dev Containers: Reopen in Container"
// 3. Inside the container: pnpm exec nx serve portal-bff (connects to postgres:5432)
{
"name": "apf-portal",
"image": "mcr.microsoft.com/devcontainers/typescript-node:1-24-bookworm",
"features": {
"ghcr.io/devcontainers/features/docker-outside-of-docker:1": {},
"ghcr.io/devcontainers/features/common-utils:2": {
"installZsh": false,
"configureZshAsDefaultShell": false
}
},
// `initializeCommand` runs on the HOST before the container starts.
// Fail fast if the infra network isn't up — otherwise the BFF would
// get connection-refused on every Postgres query inside the container.
"initializeCommand": "docker network inspect apf-portal-dev > /dev/null 2>&1 || { echo '❌ Bring up infra first: ./infra/local/dev.sh up' >&2 ; exit 1 ; }",
// Attach to the same Compose network as postgres/redis/otel so DNS
// resolution (`postgres:5432`) works from inside the container.
"runArgs": ["--network=apf-portal-dev"],
"forwardPorts": [4200, 4300, 3000, 8081, 16686, 4317, 4318],
"portsAttributes": {
"4200": { "label": "portal-shell", "onAutoForward": "notify" },
"4300": { "label": "portal-admin", "onAutoForward": "notify" },
"3000": { "label": "portal-bff", "onAutoForward": "notify" },
"8081": { "label": "pgweb", "onAutoForward": "silent" },
"16686": { "label": "jaeger UI", "onAutoForward": "silent" },
"4317": { "label": "otel gRPC", "onAutoForward": "silent" },
"4318": { "label": "otel HTTP", "onAutoForward": "silent" }
},
"postCreateCommand": "bash .devcontainer/post-create.sh",
"remoteUser": "node",
"containerEnv": {
// Nx + Angular CLI memory ceiling — 4 GB. Bumping past the default
// 2 GB avoids OOM in `nx build` on large monorepos.
"NODE_OPTIONS": "--max-old-space-size=4096"
},
"customizations": {
"vscode": {
"extensions": [
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode",
"Angular.ng-template",
"Prisma.prisma",
"ms-azuretools.vscode-docker",
"EditorConfig.EditorConfig"
],
"settings": {
"terminal.integrated.defaultProfile.linux": "bash",
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode"
}
}
}
}
+12
View File
@@ -0,0 +1,12 @@
#!/usr/bin/env bash
# Runs once, on the container's first start. Idempotent.
set -euo pipefail
echo "▶ Enabling corepack (pnpm shim from package.json#packageManager)…"
corepack enable
echo "▶ pnpm install --frozen-lockfile…"
pnpm install --frozen-lockfile
echo "✓ Devcontainer ready."
echo " Suggested next: pnpm exec nx run-many -t lint test --parallel=3"
+9
View File
@@ -44,6 +44,15 @@ jobs:
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- run: pnpm ci:check
# Catalogue-vs-code drift gate per ADR-0025 §"Confirmation".
# Cheap (parses ~workspace .ts files via the TypeScript
# compiler API, ~1s on a warm cache) so it rides the same
# `check` job rather than spinning up a new one. The
# script's own unit tests run first — if they fail the gate
# itself is broken and the actual catalogue check would be
# noise.
- run: pnpm ci:catalogue-drift:test
- run: pnpm ci:catalogue-drift
scan:
runs-on: [self-hosted, on-prem]
+9
View File
@@ -31,6 +31,15 @@ pnpm-debug.log*
*.pem
*.key
# Tenant-private Entra group GUIDs per ADR-0025 (commit only the .example.json)
infra/*-tenant.entra.json
!infra/*-tenant.entra.example.json
# Tenant-private Entra per-persona oids consumed by prisma/seed.ts
# (ADR-0026 §"Seed personas"). Same pattern — commit only the example.
infra/*-tenant.personas.json
!infra/*-tenant.personas.example.json
# OS / editor scrap
.DS_Store
Thumbs.db
+159
View File
@@ -0,0 +1,159 @@
# Per ADR-0028 (CI/CD migration from Gitea Actions to GitLab CE).
# Thin YAML — orchestration lives in package.json scripts (ci:check,
# ci:catalogue-drift, ci:audit, ci:commits, ci:perf, ci:gzip-budgets)
# and Nx targets. Any change to gate behaviour belongs in those
# scripts, not in this file — see ADR-0015 §"Thin pipeline YAML…"
# (principle preserved by ADR-0028 at the migration boundary).
#
# Phase 2 of ADR-0028: ships alongside .gitea/workflows/ci.yml. Both
# pipelines must remain in parity until cutover (Phase 3), at which
# point the Gitea workflow gets deleted.
include:
# GitLab built-in scanners — replace the manual Trivy + gitleaks
# install in .gitea/workflows/ci.yml. Both add their own jobs to
# the pipeline; their default stage is `test`, which is mapped in
# `stages:` below.
- template: Jobs/SAST.gitlab-ci.yml
- template: Jobs/Secret-Detection.gitlab-ci.yml
stages:
- check
- test
- commits
- perf
- a11y
workflow:
rules:
# MR pipelines — covers GitLab MR review flow once it lands.
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
# Direct pushes — covers post-merge runs on main today, and Phase 2
# parity testing on feature branches mirrored from Gitea. To be
# tightened to `$CI_COMMIT_BRANCH == "main"` at the Phase 3 cutover
# once GitLab is the source of truth.
- if: $CI_PIPELINE_SOURCE == "push"
# Shared shape for jobs that need Node + pnpm. Hidden (`.` prefix) so
# GitLab does not try to execute it. Inheriting jobs `extends: .node-job`.
.node-job:
image: node:24-bookworm
cache:
key:
files:
- pnpm-lock.yaml
paths:
- .pnpm-store/
before_script:
- corepack enable
- corepack prepare pnpm@10.33.4 --activate
- pnpm config set store-dir "$CI_PROJECT_DIR/.pnpm-store"
check:
extends: .node-job
stage: check
# `nx affected` needs a base SHA to diff against. Mirrors the
# equivalent step in .gitea/workflows/ci.yml — same logic, GitLab
# variable names. Replaces nrwl/nx-set-shas (GitHub-only).
script:
- |
if [ "$CI_PIPELINE_SOURCE" = "merge_request_event" ]; then
git fetch origin "$CI_MERGE_REQUEST_TARGET_BRANCH_NAME" --depth=100
export NX_BASE=$(git merge-base HEAD "origin/$CI_MERGE_REQUEST_TARGET_BRANCH_NAME")
else
export NX_BASE="HEAD~1"
fi
export NX_HEAD="HEAD"
- pnpm install --frozen-lockfile
- pnpm ci:check
# Catalogue-vs-code drift gate per ADR-0025 §"Confirmation". The
# script's own unit tests run first — if they fail the gate itself
# is broken and the actual catalogue check would be noise.
- pnpm ci:catalogue-drift:test
- pnpm ci:catalogue-drift
audit:
extends: .node-job
stage: test
# npm-advisory check against pnpm-lock.yaml. Trivy's dependency-vuln
# role from the Gitea workflow is now covered by the SAST include
# (which includes Dependency Scanning analyzers); `pnpm audit` stays
# in-pipeline as defense in depth against the npm advisory DB
# specifically.
script:
- pnpm install --frozen-lockfile
- pnpm ci:audit
commits:
extends: .node-job
stage: commits
# MRs opened by Renovate (apf-portal-bot) carry commit messages from
# a vetted Conventional-Commits template — running commitlint on
# them is tautological. Per ADR-0017 amendment.
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event" && $GITLAB_USER_LOGIN != "apf-portal-bot"
variables:
# Default `GIT_DEPTH: 20` is too shallow to diff against
# origin/main on long-running branches. Full clone so commitlint
# can walk all the way back.
GIT_DEPTH: 0
script:
- git fetch origin main
- pnpm install --frozen-lockfile
- COMMIT_LINT_FROM=origin/main pnpm ci:commits
perf:
extends: .node-job
stage: perf
# Lighthouse CI drives a real Chrome via chrome-launcher, which
# probes the PATH / CHROME_PATH for a standard Chrome/Chromium
# binary. The Playwright image bundles Chromium under /ms-playwright
# with a non-standard name chrome-launcher cannot discover, so we
# stay on the base Node image and apt-install Debian's `chromium`
# (lands at /usr/bin/chromium). The future ADR-0016 axe-core e2e job
# — which drives Playwright directly — will use the Playwright image
# instead: the two tools want different things, so they get
# different images rather than one image that serves both poorly.
image: node:24-bookworm
variables:
CHROME_PATH: /usr/bin/chromium
before_script:
- !reference [.node-job, before_script]
- apt-get update -qq && apt-get install -y -qq --no-install-recommends chromium
# Skip on Renovate MRs (same rationale as commits + the perf signal
# on a dep bump is essentially zero). Push events on `main` still
# run perf — we catch regressions immediately post-merge, not
# pre-merge. Per ADR-0017 amendment.
rules:
- if: $CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "main"
- if: $CI_PIPELINE_SOURCE == "merge_request_event" && $GITLAB_USER_LOGIN != "apf-portal-bot"
script:
- pnpm install --frozen-lockfile
- pnpm ci:perf
artifacts:
when: always
paths:
- .lighthouseci/
expire_in: 30 days
a11y:
extends: .node-job
stage: a11y
# Placeholder until the e2e a11y suite (axe-core via Playwright, per
# ADR-0016) is wired with the first real screens. The job exists so
# branch protection can require it from day one — currently no-ops
# with a clear message.
script:
- echo "a11y gate placeholder - axe-core via Playwright wires up with the first real screens (ADR-0016)."
# Place SAST + Secret Detection in the `test` stage to mirror the
# Gitea workflow's grouping (where Trivy + gitleaks + audit all
# lived in the `scan` job). Override does not change behaviour —
# only stage placement. The included templates set sensible defaults
# (run on default branch + MRs, full repo on default branch / diff
# on MRs); revisited in Phase 3 if pipeline duration becomes a pain.
sast:
stage: test
secret_detection:
stage: test
@@ -0,0 +1,30 @@
<!--
MR title format — becomes the squash-merge subject on main, validated by commitlint.
<type>(<scope>): <short description>
Examples:
feat(portal-shell): add user-preferences panel skeleton
fix(portal-bff): correct env var bracket access
docs(decisions): add ADR-0018 for security baseline
chore(deps): bump @nx/* to 22.7.2
Imperative mood, lowercase, no trailing period, target ≤ 70 chars.
See docs/development.md §7 for the full convention (types, scopes).
-->
## Summary
## Motivation
## Implementation notes
## Verification
- [ ] `pnpm ci:check` green locally
- [ ] `pnpm ci:audit` green (or pre-existing drift acknowledged)
- [ ] Tested manually:
- [ ] Architecture diagram updated (if `docs/architecture.md` was affected)
- [ ] ADR amended or added (if a decision changed)
## Related
+5
View File
@@ -5,3 +5,8 @@
/.nx/workspace-data
.nx/self-healing
.angular
# Generated gRPC TypeScript stubs (output of `pnpm run grpc:codegen`).
# Hand-formatting is not productive — overwritten on next regen — and
# ts-proto's output is internally consistent.
apps/portal-bff/src/grpc/gen/
+22 -8
View File
@@ -43,7 +43,7 @@ The structural, security, observability, and quality choices are recorded as ADR
- **Observability:** Pino + `nestjs-pino` for structured JSON logs, OpenTelemetry SDK + auto-instrumentations for traces, W3C Trace Context propagation across SPA → BFF → DB → Redis, `nestjs-cls` for request-scoped context (`trace_id`, `session_id`, `user_id_hash`, `audience`), 100 % sampling at the app with tail sampling deferred to the OTel Collector, stdout + OTLP shipping — see [ADR-0012](docs/decisions/0012-observability-pino-opentelemetry.md).
- **Audit trail:** dedicated `audit.events` schema in the same Postgres instance, append-only by Postgres role grants (`audit_writer` INSERT, `audit_reader` SELECT, `audit_archiver` DELETE older than retention; no `UPDATE`/`TRUNCATE` to anyone); 365-day retention default; cross-referenced with app logs via `trace_id` and `actor_id_hash` (same salt); blocking writes (no audit ⇒ no action) — see [ADR-0013](docs/decisions/0013-audit-trail-separated-postgres-append-only.md).
- **Downstream API access:** unified `DownstreamApiClient` (`@nestjs/axios` + `cockatiel`), per-service `DownstreamApiConfig`; default auth strategy is **OBO via MSAL Node** for Entra-protected APIs (downstream-scoped tokens cached in Redis with AES-256-GCM under a dedicated key); fallback strategy is service credential + signed `X-User-Assertion` JWT (BFF JWKS at `/.well-known/jwks.json`); per-call audience pre-check; no `axios`/`fetch` outside `src/downstream/` — see [ADR-0014](docs/decisions/0014-downstream-api-access-obo-pattern.md).
- **CI/CD:** **Gitea Actions** (level-2 implementation; will be superseded by a GitLab migration ADR within 6-18 months). Trunk-based with squash-merge, branch protection on `main`, all CI gates blocking. Thin YAML — orchestration logic lives in `package.json` scripts (`ci:check`, `ci:scan`, `ci:commits`) and Nx targets, runnable locally. Gates: format / lint / type-check / test / build / audit / secret-scan / commit-lint, plus `a11y` (per ADR-0016) and future `perf`. Self-hosted `act_runner` on-prem. Conventional Commits validated locally (hook) and in CI (defense in depth). Required reviewer count = 0 in v1, raised to ≥1 once a second contributor joins. Signed commits recommended, revisited at GitLab migration — see [ADR-0015](docs/decisions/0015-cicd-gitea-actions.md).
- **CI/CD:** **Gitea Actions today, migrating to GitLab CE on `vm-gitlab` per [ADR-0028](docs/decisions/0028-migrate-cicd-and-git-hosting-to-gitlab.md)** (decision-only ADR accepted; 4-phase rollout in follow-up PRs — the architectural principles below carry over unchanged from [ADR-0015](docs/decisions/0015-cicd-gitea-actions.md), only the host / pipeline file / runner type / scan tooling change). Trunk-based with squash-merge, branch protection on `main`, all CI gates blocking. Thin YAML — orchestration logic lives in `package.json` scripts (`ci:check`, `ci:catalogue-drift`, `ci:audit`, `ci:commits`, `ci:perf`, `ci:gzip-budgets`) and Nx targets, runnable locally. Gates: format / lint / type-check / test / build / audit / secret-scan / commit-lint, plus `a11y` (per ADR-0016) and `perf` (per ADR-0017). Self-hosted runners on-prem (`act_runner` today, GitLab Runner with Docker executor post-migration). Conventional Commits validated locally (hook) and in CI (defense in depth). Required reviewer count = 0 in v1, raised to ≥1 once a second contributor joins. Signed commits required on `main` once the migration cutover lands (ADR-0028's revisit of ADR-0015's "recommended" stance).
- **Accessibility:** **WCAG 2.2 AA baseline + targeted AAA** on criteria with high impact for APF's user base (1.4.6 Contrast Enhanced, 2.2.3 No Timing, 2.3.3 Animation, 3.1.5 Reading Level, 1.4.8 Visual Presentation, 2.4.9 Link Purpose, 3.3.5 Help). RGAA 4.1 alignment for French audit. UI stack: **Angular CDK + TailwindCSS** (spartan-ng _library_ deferred until it reaches 1.0.0; v1 components are written in-house in `libs/shared/ui/` on Angular CDK, applying the spartan-ng _philosophy_ of headless primitives + utility CSS + copy-paste). User-preferences panel (contrast / text size / motion / spacing / cognitive simplification / reading focus) persisted in session. Tooling: `@angular-eslint/template/*` lint, `@axe-core/playwright` e2e (blocking on critical/serious), token-contrast CI check, touch-target check (44×44 min). Manual testing cadence with APF's internal user panel before each major release. Public accessibility statement page at `/accessibility` and `/accessibilite` — see [ADR-0016](docs/decisions/0016-accessibility-baseline-wcag-aa-targeted-aaa.md).
- **Performance budgets:** Core Web Vitals at Google "Good" thresholds (LCP ≤ 2.5 s, INP ≤ 200 ms, CLS ≤ 0.1, TBT ≤ 200 ms, TTFB ≤ 800 ms), Lighthouse Performance ≥ 90 on critical routes. **Lighthouse CI** (`@lhci/cli`) runs in CI with median-of-3 mitigation, blocking on threshold breach. Angular bundle `budgets` (`type: "error"`): initial ≤ 300 KB gzip, lazy chunks ≤ 100 KB gzip. BFF p95/p99 SLOs per endpoint family observed via OTel (advisory in CI, alerting in prod). Weekly scheduled Lighthouse run on prod env. **a11y wins over perf** when they conflict — see [ADR-0017](docs/decisions/0017-performance-budgets-lighthouse-ci.md).
- **Environment configuration:** SPA per-environment values via Angular `environment.ts` + `fileReplacements` at build time (no runtime config-fetch). BFF reads `process.env` directly with small per-key boot-time validators (no `@nestjs/config` overhead at this scale). The audit log uses a separate `AUDIT_DATABASE_URL` connection pool in production (`audit_writer`-only login, defense in depth) and falls back to the shared pool + `SET LOCAL ROLE` in dev — see [ADR-0018](docs/decisions/0018-environment-configuration-strategy.md).
@@ -51,23 +51,35 @@ The structural, security, observability, and quality choices are recorded as ADR
- **Admin application (`portal-admin`):** dedicated Angular SPA alongside `portal-shell`, sharing the same `portal-bff` via `/api/admin/*` routes guarded by an Entra `Portal.Admin` role + `@RequireMfa({ freshness: 600 })` at entry. Distinct origin / cookie / session from `portal-shell` (`__Host-portal_admin_session`). v1 modules: CMS for static pages (multilingual), menu management, user list (read-only), audit log viewer. Bundle budget relaxed to ≤ 500 KB gzip (vs 300 KB for `portal-shell`); same a11y + dark-mode baseline. Shared UI primitives (`Icon`, `LayoutStateService`, brand tokens) graduate to `libs/shared/*` as both apps need them — see [ADR-0020](docs/decisions/0020-portal-admin-app.md).
- **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).
- **Authorization model:** three orthogonal axes — **privileges** (Entra app roles, `Portal.*`), **functional roles** (Entra security groups → curated `apf-role-*` slug catalogue, 24 entries v1), **scopes** (portal-side `user_scopes` table, future Pléiades feed; kinds = `self / etablissement:<structure-code> / delegation:<dept> / region:<insee> / siege / unrestricted`, see [ADR-0027](docs/decisions/0027-portal-side-organisational-hierarchy.md) for the `Structure.code` semantics). Composed at sign-in into a session-resident `Principal`; portal guards consume the structured shape, a deterministic `PrincipalProjector` flattens it to the AI-service `roles[]` contract. Replaces stargate's linear hierarchy. Catalogues are closed-set, drift gated by CI — see [ADR-0025](docs/decisions/0025-authorization-model-privileges-roles-scopes.md).
- **Portal-side identity model:** `Person` golden record (stable identity, can exist without a portal account — workforce pre-provisioning, dossier bénéficiaires, alumni) + `User` overlay (one-to-zero-or-one with Person, lazy-created on first OIDC callback in v1; carries portal-only state like `lastSignInAt`). `UserScope` backs the ADR-0025 scope axis with opaque `value` strings referencing ADR-0027's `Structure.code` / `Delegation.code` / `Region.code` — no FK at the DB level so historical rows survive structure decommissioning; admin-UI write path validates. v1 dedup uses `entraOid` only; `Person.email` is an indexed attribute, not a unique key, because two distinct humans genuinely share emails (shared aliases, generic `info@`, upstream-feed errors). Facets (Salarié / Élu / Adhérent / Bénéficiaire) + Pléiades / Acteurs+ sync + operator-confirmed Person-merge flow deferred to ADR-0029 — see [ADR-0026](docs/decisions/0026-person-user-portal-data-model.md).
- **Portal-side organisational hierarchy:** `Region` (INSEE 2-digit) → `Delegation` (department 23-char) → `Structure` with `kind` discriminator (`medico_social` / `antenne` / `dispositif` / `entreprise_adaptee` / `mouvement` / `administratif` / `siege`, aligned with cascade's `Structure.type`). `Structure.code` is the portal-internal string PK, externally meaningful: for medico-social rows it equals the FINESS (9 digits) and round-trips cleanly through scope literals (`etablissement:0330800013`) and URLs; for non-FINESS rows it is an APF-internal slug (`siege`, `apf-bdx-merignac`, `ea-toulouse`, …). `finess` / `siret` / `codePaie` are nullable, unique-when-present attributes — populated where the upstream registry has the structure on file. v1 ships a small inline-migration seed (test-tenant scope: Région Nouvelle-Aquitaine, Délégation 33, a handful of médico-social + siège); the full cascade-driven inventory sync, plus `Pole` / `Service` / arbitrary nesting / per-source enrichment, land additively with ADR-0029 — see [ADR-0027](docs/decisions/0027-portal-side-organisational-hierarchy.md).
- **Runtime:** Node.js latest LTS major.
- **Local dev environment:** three coexisting run modes — native `pnpm nx serve`, the VSCode devcontainer, and a Docker Compose `apps` profile that boots all three Nx dev servers without a native Node/pnpm toolchain (`./infra/local/dev.sh up apps`). Dev-only (production images deferred to the ADR-0028 Container Registry work); shared `Dockerfile.dev`, repo bind-mounted for hot reload, `node_modules`/Nx cache in named volumes, BFF entrypoint runs `prisma generate` + `migrate deploy` — see [ADR-0030](docs/decisions/0030-dockerised-dev-mode.md).
## 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.
The Nx workspace is **scaffolded and operational**. The three apps (`portal-shell`, `portal-admin`, `portal-bff`) and the seven lib roots (`libs/feature/auth`, `libs/shared/auth`, `libs/shared/charts`, `libs/shared/state`, `libs/shared/tokens`, `libs/shared/ui`, `libs/shared/util`) are in place; CI runs `format:check / lint / test / build` plus the ADR-0025 catalogue-drift gate on every PR.
ADRs 0001 → 0022 are accepted and cover the structural, security, observability, quality, i18n, admin-app, and docs-site choices. **Shipped on `main`:**
ADRs 0001 → 0028 plus ADR-0030 are accepted and cover the structural, security, observability, quality, i18n, admin-app, docs-site, charts, AI-relay, authorization, portal-side identity + organisational hierarchy, CI/CD platform migration, and dockerised local-dev mode choices. ADR-0028 supersedes only ADR-0015's "Gitea Actions" platform choice — the rest of ADR-0015's architectural principles carry over unchanged; the 4-phase migration (mirror → parallel pipelines → cutover → cleanup) ships in follow-up PRs. ADR-0029 (cascade / Pléiades / Acteurs+ syncs + facet schemas) is the next proposed addition — its number is reserved ahead of ADR-0030, which was written first. Until ADR-0026 + ADR-0027 implementation PRs ship, ADR-0025's stubs (`Principal.user.{id, personId}` placeholders, `StubScopeResolver`'s `unrestricted` blanket return) remain in place. **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.
- **Phase-3a admin app** — `portal-admin` SPA with brand tokens, routing, user-list reader (`/admin/users`), and audit-log viewer with statistics and integrated charts (`/admin/audit`). CMS for static pages and menu management not yet implemented.
- **AI relay surface + live consumer** — 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)). Chatbot widget live in `portal-shell` at `apps/portal-shell/src/app/features/chatbot/`.
- **Docs static site** ([ADR-0022](docs/decisions/0022-docs-site-vitepress.md)) — VitePress + Mermaid renderer at `docs/.vitepress/`, dedicated `docs-site.yml` workflow that rebuilds + publishes on every `docs/**` change.
- **Charts lib + audit-page dashboards** ([ADR-0023](docs/decisions/0023-charts-d3-observable-plot.md)) — `libs/shared/charts/` with `BarChart`, `DonutChart`, `StackedBarChart` (D3 + Observable Plot, headless / a11y-baked-in), integrated into the `/admin/audit` page for daily-volume + outcome-breakdown + event-type-over-time views.
- **Authorization model + guards** ([ADR-0025](docs/decisions/0025-authorization-model-privileges-roles-scopes.md)) — `libs/shared/auth/` exporting the closed catalogues (4 privileges, 24 functional roles, 6 scope kinds), `Principal` shape, pure matchers, and `EntraGroupToRoleResolver`. BFF-side `PrincipalBuilder` composing the three axes at sign-in from Entra `roles` + `groups` claims + a stub `ScopeResolver`. `@RequirePrivilege` / `@RequireRole` / `@RequireScope` route decorators + guards with the ADR-0021 structured-error envelope on denial; `AdminRoleGuard` migrated to read `principal.privileges`. CI drift gate (`scripts/check-catalogue-drift.mjs`) asserting every decorator literal is in the catalogue.
**Still on the roadmap:**
- `DownstreamApiClient` + OBO ([ADR-0014](docs/decisions/0014-downstream-api-access-obo-pattern.md)) — no v1 consumer yet; will land when the first business route needs an Entra-protected API.
- `@RequireMfa()` / `@RequireAdmin()` guards ([ADR-0011](docs/decisions/0011-mfa-enforcement-entra-conditional-access.md), [ADR-0020](docs/decisions/0020-portal-admin-app.md)) — designed-in, awaiting first consumer route.
- **Docs static-site implementation** ([ADR-0022](docs/decisions/0022-docs-site-vitepress.md)) — ADR accepted, chantier (VitePress install + `.vitepress/config.ts` + `docs-site.yml` workflow) lands next.
- `DownstreamApiClient` + OBO ([ADR-0014](docs/decisions/0014-downstream-api-access-obo-pattern.md)) — module scaffolded (`obo.strategy`, `signed-assertion.strategy`, JWKS publisher, encrypted token cache); no v1 consumer yet. Wires in when the first business route needs an Entra-protected API.
- `@RequireMfa()` step-up consumer routes ([ADR-0011](docs/decisions/0011-mfa-enforcement-entra-conditional-access.md)) — guard + decorator shipped; awaiting first sensitive route that needs explicit freshness enforcement beyond the Conditional Access baseline.
- **`@RequireScope` Prisma-backed resolver + first consumer surface** — `StubScopeResolver` returns `unrestricted` for everyone in v1 per [ADR-0025](docs/decisions/0025-authorization-model-privileges-roles-scopes.md) §331. Implementation lands across [ADR-0026](docs/decisions/0026-person-user-portal-data-model.md) (accepted: `Person` + `User` + `UserScope`) and [ADR-0027](docs/decisions/0027-portal-side-organisational-hierarchy.md) (accepted: `Region` / `Delegation` / `Structure` with `kind` discriminator + nullable FINESS / SIRET). Sequencing: ADR-0026 PR 1 + ADR-0027 PR 1 ship schema in parallel; ADR-0026 PR 2 then lands the `PrismaScopeResolver` + admin scope-seeding UI + test-tenant seed (which references ADR-0027's `Structure.code` values). The follow-up [ADR-0029](#) covers Pléiades / Acteurs+ / cascade syncs + facet schemas.
- **Proto-drift CI gate for the AI relay** ([ADR-0024](docs/decisions/0024-ai-service-relay-grpc-sse-bridge.md)) — asserts the vendored `apf-ai-service` proto files stay in lockstep with the upstream contract.
- **Admin app — CMS & menu management** ([ADR-0020](docs/decisions/0020-portal-admin-app.md)) — multilingual static-page editor + navigation menu builder. The user-list + audit-log-viewer modules already exist; the CMS/menu pair is the remaining v1 module scope.
- **Strategic security baseline ADR** — separate from the implementation-level [ADR-0021](docs/decisions/0021-phase-2-security-baseline.md). Remains **paused** awaiting RSSI input on the OWASP ASVS reference level and adjacent frameworks (HDS, GDPR, possibly NIS 2). When it lands it will either confirm 0021 or supersede pieces of it.
## Commands once the workspace exists
@@ -97,8 +109,10 @@ pnpm nx format:check
## Environment conventions
- **Two development environments.** `local` (Windows-WSL or native macOS / Linux on the workstation) and `development` (Debian 13 VM at `10.100.201.21` — the default for new devs, replaces WSL). A `hybrid` sub-mode runs the IDE + Nx servers on the workstation while reaching the infra services (postgres / redis / otel) on the dev VM through SSH tunnels. Full procedure: [docs/setup/01-dev-debian-vm-setup.md](docs/setup/01-dev-debian-vm-setup.md). The legacy WSL flow remains documented in [docs/setup/02-wsl-terminal-setup.md](docs/setup/02-wsl-terminal-setup.md).
- **Two IDE flows on the dev VM** — VSCode Remote-SSH (default; transparent equivalent of the WSL flow) and Devcontainer (`.devcontainer/devcontainer.json` shipped, Node + pnpm pinned in the image, mounts the docker socket so the host's `apf-portal-dev` Compose network is reachable from inside). Independently of the IDE flow, the apps can run **natively** (`pnpm nx serve`) or as **Docker Compose services** (`./infra/local/dev.sh up apps`, no native toolchain — ADR-0030); the "which mode when" table is in [docs/setup/01-dev-debian-vm-setup.md](docs/setup/01-dev-debian-vm-setup.md).
- **Never install Angular globally.** Use `pnpm dlx` for one-off CLI invocations and project-local `pnpm nx ...` for everything else — versions stay pinned per project.
- **Work inside the WSL filesystem** (`~/dev/...`), never under `/mnt/c` — the latter has severe I/O penalties that break Nx caching and dev-server reload times.
- **On WSL: work inside the WSL filesystem** (`~/Works/...`), never under `/mnt/c` — the latter has severe I/O penalties that break Nx caching and dev-server reload times. On the dev VM the analogous rule is "stay on the VM disk, do not work over SSHFS / network mounts".
- **pnpm is mandatory** (activated via `corepack enable`); do not introduce npm or yarn lockfiles.
- Prettier config target: `singleQuote: true`, `semi: true`, `printWidth: 100`.
+10 -3
View File
@@ -52,8 +52,8 @@
},
{
"type": "anyComponentStyle",
"maximumWarning": "5kb",
"maximumError": "6kb"
"maximumWarning": "6kb",
"maximumError": "8kb"
},
{
"type": "bundle",
@@ -84,7 +84,8 @@
"continuous": true,
"executor": "@angular/build:dev-server",
"options": {
"port": 4300
"port": 4300,
"proxyConfig": "apps/portal-admin/proxy.conf.js"
},
"configurations": {
"production": {
@@ -92,6 +93,12 @@
},
"development": {
"buildTarget": "portal-admin:build:development"
},
"https": {
"buildTarget": "portal-admin:build:development",
"ssl": true,
"sslKey": ".secrets/dev-tls.key",
"sslCert": ".secrets/dev-tls.pem"
}
},
"defaultConfiguration": "development"
+21
View File
@@ -0,0 +1,21 @@
// Angular dev-server proxy for portal-admin.
//
// Mirrors apps/portal-shell/proxy.conf.js — same rationale, same
// `/api → ${BFF_TARGET:-http://localhost:3000}` rule. The admin app
// talks to the same BFF (ADR-0020 §"Where does the admin app live"),
// just at admin-specific paths under `/api/admin/...`; the proxy
// match on `/api` covers both surfaces.
//
// JS form deliberate — only this form can read `process.env` so the
// Docker / native target swap (BFF_TARGET in dev.compose.yml) works
// without a rebuild.
const target = process.env['BFF_TARGET'] ?? 'http://localhost:3000';
module.exports = {
'/api': {
target,
secure: false,
changeOrigin: true,
},
};
+6
View File
@@ -4,6 +4,7 @@ import { authGuard } from 'feature-auth';
const homeTitle = $localize`:@@route.home.title:APF Portal Admin`;
const auditTitle = $localize`:@@route.audit.title:Audit log — APF Portal Admin`;
const usersTitle = $localize`:@@route.users.title:Users — APF Portal Admin`;
const userScopesTitle = $localize`:@@route.user-scopes.title:User scopes — APF Portal Admin`;
const profileTitle = $localize`:@@route.profile.title:Profile — APF Portal Admin`;
export const appRoutes: Route[] = [
@@ -23,6 +24,11 @@ export const appRoutes: Route[] = [
loadComponent: () => import('./pages/users/users').then((m) => m.UsersPage),
title: usersTitle,
},
{
path: 'users/:oid/scopes',
loadComponent: () => import('./pages/user-scopes/user-scopes').then((m) => m.UserScopesPage),
title: userScopesTitle,
},
{
path: 'profile',
canActivate: [authGuard],
@@ -32,6 +32,7 @@
[username]="state.user.username"
[initials]="initials()"
[items]="userMenuItems"
[role]="roleLabel"
[signedInAsLabel]="signedInAsLabel"
[signOutLabel]="signOutLabel"
[triggerAriaLabel]="triggerAriaLabel(state.user.displayName)"
@@ -56,6 +56,13 @@ export class AdminHeader {
protected readonly signedInAsLabel = 'Signed in as';
protected readonly signOutLabel = 'Sign out';
// Static role label — every reader who reaches portal-admin holds
// the `Portal.Admin` Entra app role (that's the `AdminRoleGuard`
// precondition for `/api/admin/*`), so the chip is unconditional
// and doesn't need to read `req.session.user.roles`. No i18n marks
// either, per ADR-0020's source-locale-only chrome.
protected readonly roleLabel = 'Administrator';
protected triggerAriaLabel(displayName: string): string {
return `User menu — signed in as ${displayName}`;
}
@@ -28,6 +28,24 @@ export interface AdminAuditPage {
readonly offset: number;
}
/**
* Aggregated audit-event stats — mirrors `AdminAuditStats` on the
* BFF side. Powers the Charts tab of the audit viewer.
*/
export interface AdminAuditStats {
readonly dailyVolume: ReadonlyArray<{ day: string; count: number }>;
readonly outcomeBreakdown: ReadonlyArray<{ outcome: string; count: number }>;
readonly eventTypeByDay: ReadonlyArray<{ day: string; eventType: string; count: number }>;
readonly total: number;
}
/**
* Filter shape sent as query params on the stats endpoint. Same as
* {@link AdminAuditQuery} minus pagination — the stats endpoint
* always aggregates the whole filtered set.
*/
export type AdminAuditStatsQuery = Omit<AdminAuditQuery, 'limit' | 'offset'>;
/**
* Filter+pagination shape sent as query params. All fields are
* optional; missing values fall back to the BFF defaults (limit=50,
@@ -65,6 +83,22 @@ export class AuditEventsService {
private readonly bffBaseUrl = inject(AUTH_BFF_BASE_URL);
query(filters: AdminAuditQuery): Observable<AdminAuditPage> {
const params = this.toHttpParams({ ...filters });
return this.http.get<AdminAuditPage>(`${this.bffBaseUrl}/admin/audit`, { params });
}
/**
* Calls `GET /api/admin/audit/stats` with the same filter shape
* minus pagination. The endpoint Redis-caches results for 5
* minutes per filter-hash (see ADR-0013 §"Reader endpoints"); the
* SPA itself doesn't try to cache.
*/
stats(filters: AdminAuditStatsQuery): Observable<AdminAuditStats> {
const params = this.toHttpParams({ ...filters });
return this.http.get<AdminAuditStats>(`${this.bffBaseUrl}/admin/audit/stats`, { params });
}
private toHttpParams(filters: Record<string, unknown>): HttpParams {
let params = new HttpParams();
for (const [key, value] of Object.entries(filters)) {
// Drop empty strings — Nest's ValidationPipe treats `?foo=`
@@ -77,6 +111,6 @@ export class AuditEventsService {
}
params = params.set(key, String(value));
}
return this.http.get<AdminAuditPage>(`${this.bffBaseUrl}/admin/audit`, { params });
return params;
}
}
+197 -73
View File
@@ -103,86 +103,210 @@
</div>
</form>
<div class="tablist" role="tablist" aria-label="Audit log views">
<button
type="button"
role="tab"
id="tab-table"
class="tab"
[class.tab--active]="tab() === 'table'"
[attr.aria-selected]="tab() === 'table'"
[attr.aria-controls]="'panel-table'"
[attr.tabindex]="tab() === 'table' ? 0 : -1"
(click)="setTab('table')"
(keydown.arrowRight)="setTab('charts')"
>
Table
</button>
<button
type="button"
role="tab"
id="tab-charts"
class="tab"
[class.tab--active]="tab() === 'charts'"
[attr.aria-selected]="tab() === 'charts'"
[attr.aria-controls]="'panel-charts'"
[attr.tabindex]="tab() === 'charts' ? 0 : -1"
(click)="setTab('charts')"
(keydown.arrowLeft)="setTab('table')"
>
Charts
</button>
</div>
<div class="status-bar" role="status" aria-live="polite">
@if (loading()) {
@if (tab() === 'table') { @if (loading()) {
<span class="status-line">Loading…</span>
} @else if (error(); as msg) {
<span class="status-line status-line--error">{{ msg }}</span>
} @else if (page(); as p) {
<span class="status-line">{{ resultRange() }}</span>
} } @else { @if (statsLoading()) {
<span class="status-line">Loading statistics…</span>
} @else if (statsError(); as msg) {
<span class="status-line status-line--error">{{ msg }}</span>
} @else if (stats(); as s) {
<span class="status-line">{{ s.total }} matching event(s) aggregated</span>
} }
</div>
<section
id="panel-charts"
role="tabpanel"
aria-labelledby="tab-charts"
class="tab-panel"
[hidden]="tab() !== 'charts'"
>
@if (stats(); as s) { @if (hasChartData()) {
<p class="charts-note">
Aggregations are computed across the full filtered set (server-side), not just the events on
the current page. Results are cached for 5 minutes per filter combination.
</p>
<div class="charts-grid">
<div class="chart-tile">
<lib-bar-chart
[data]="s.dailyVolume"
xKey="day"
yKey="count"
xLabel="Date (UTC)"
yLabel="Events"
caption="Events per day"
description="Bar chart of audit event volume per calendar day across the full filtered set."
ariaLabel="Events per day — bar chart"
/>
</div>
<div class="chart-tile">
<lib-donut-chart
[data]="s.outcomeBreakdown"
categoryKey="outcome"
valueKey="count"
[centerLabel]="s.total.toString()"
[colorMap]="outcomeColorMap"
caption="Outcome breakdown"
description="Donut chart of audit event outcomes (success in green, denied in orange, failure in red) across the full filtered set."
ariaLabel="Outcome breakdown — donut chart"
/>
</div>
<div class="chart-tile chart-tile--wide">
<lib-stacked-bar-chart
[data]="s.eventTypeByDay"
xKey="day"
yKey="count"
seriesKey="eventType"
xLabel="Date (UTC)"
yLabel="Events"
caption="Events per day, by event type"
description="Stacked bar chart of audit event volume per calendar day, broken down by event_type, across the full filtered set."
ariaLabel="Events per day, by event type — stacked bar chart"
/>
</div>
</div>
} @else {
<p class="empty">No audit events match the current filters.</p>
} } @else if (!statsLoading() && !statsError()) {
<p class="charts-note">Switch to this tab to load statistics.</p>
}
</div>
</section>
@if (page(); as p) { @if (p.items.length === 0) {
<p class="empty">No audit events match the current filters.</p>
} @else {
<div class="table-wrap">
<table class="audit-table">
<thead>
<tr>
<th scope="col">Timestamp</th>
<th scope="col">Event</th>
<th scope="col">Audience / outcome</th>
<th scope="col">Actor / subject</th>
<th scope="col">Trace</th>
<th scope="col">Payload</th>
</tr>
</thead>
<tbody>
@for (event of p.items; track event.id) {
<tr>
<td class="cell-timestamp">{{ formatTimestamp(event.createdAt) }}</td>
<td class="cell-event">{{ event.eventType }}</td>
<td class="cell-aud">
<span class="aud-badge">{{ event.audience }}</span>
<span
class="outcome-badge"
[class.outcome-badge--success]="event.outcome === 'success'"
[class.outcome-badge--failure]="event.outcome === 'failure'"
[class.outcome-badge--denied]="event.outcome === 'denied'"
>{{ event.outcome }}</span
>
</td>
<td class="cell-actor">
<div class="actor-hash">{{ event.actorIdHash ?? '(anonymous)' }}</div>
@if (event.subject; as subject) {
<div class="subject">{{ subject }}</div>
}
</td>
<td class="cell-trace">{{ event.traceId ?? '—' }}</td>
<td class="cell-payload">
@if (event.payload) {
<details>
<summary>view</summary>
<pre>{{ formatPayload(event.payload) }}</pre>
</details>
} @else {
<span class="dim"></span>
}
</td>
</tr>
}
</tbody>
</table>
</div>
<section
id="panel-table"
role="tabpanel"
aria-labelledby="tab-table"
class="tab-panel"
[hidden]="tab() !== 'table'"
>
@if (page(); as p) { @if (p.items.length === 0) {
<p class="empty">No audit events match the current filters.</p>
} @else {
<div class="table-wrap">
<table class="audit-table">
<thead>
<tr>
<th scope="col">Timestamp</th>
<th scope="col">Event</th>
<th scope="col">Audience / outcome</th>
<th scope="col">Actor / subject</th>
<th scope="col">Trace</th>
<th scope="col">Payload</th>
</tr>
</thead>
<tbody>
@for (event of p.items; track event.id) {
<tr>
<td class="cell-timestamp">{{ formatTimestamp(event.createdAt) }}</td>
<td class="cell-event">{{ event.eventType }}</td>
<td class="cell-aud">
<span class="aud-badge">{{ event.audience }}</span>
<span
class="outcome-badge"
[class.outcome-badge--success]="event.outcome === 'success'"
[class.outcome-badge--failure]="event.outcome === 'failure'"
[class.outcome-badge--denied]="event.outcome === 'denied'"
>{{ event.outcome }}</span
>
</td>
<td class="cell-actor">
@if (event.actorIdHash; as hash) {
<button
type="button"
class="actor-hash actor-hash--clickable"
(click)="filterByActor(hash)"
[attr.title]="'Filter the table on ' + hash"
>
{{ hash }}
</button>
} @else {
<div class="actor-hash">(anonymous)</div>
} @if (event.subject; as subject) {
<div class="subject">{{ subject }}</div>
}
</td>
<td class="cell-trace">
@if (event.traceId; as traceId) {
<a
class="trace-link"
[href]="jaegerUrl(traceId)"
target="_blank"
rel="noopener noreferrer"
[attr.title]="'Open trace ' + traceId + ' in Jaeger'"
>{{ traceId }}</a
>
} @else { — }
</td>
<td class="cell-payload">
@if (event.payload) {
<details>
<summary>view</summary>
<pre>{{ formatPayload(event.payload) }}</pre>
</details>
} @else {
<span class="dim"></span>
}
</td>
</tr>
}
</tbody>
</table>
</div>
<nav class="pagination" aria-label="Pagination">
<button
type="button"
class="btn btn--secondary"
(click)="previous()"
[disabled]="!hasPreviousPage() || loading()"
>
Previous
</button>
<button
type="button"
class="btn btn--secondary"
(click)="next()"
[disabled]="!hasNextPage() || loading()"
>
Next
</button>
</nav>
} }
<nav class="pagination" aria-label="Pagination">
<button
type="button"
class="btn btn--secondary"
(click)="previous()"
[disabled]="!hasPreviousPage() || loading()"
>
Previous
</button>
<button
type="button"
class="btn btn--secondary"
(click)="next()"
[disabled]="!hasNextPage() || loading()"
>
Next
</button>
</nav>
} }
</section>
</section>
@@ -194,6 +194,133 @@
color: #9ca3af;
}
// Tabs above the content. WAI-ARIA pattern: `role="tablist"` on the
// container, `role="tab"` on each button, roving `tabindex` (the
// active tab is reachable via Tab, inactive ones via arrow keys
// once focus is in the list). The visual treatment is a thin
// underline on the active tab — minimal furniture, lets the panel
// content carry the eye.
.tablist {
display: flex;
gap: 0.5rem;
border-bottom: 1px solid #e5e7eb;
margin-bottom: 1rem;
}
:host-context(.dark) .tablist {
border-bottom-color: #1f2937;
}
.tab {
appearance: none;
background: transparent;
border: 0;
border-bottom: 2px solid transparent;
padding: 0.5rem 0.75rem;
margin-bottom: -1px;
font-size: 0.875rem;
font-weight: 500;
color: #6b7280;
cursor: pointer;
transition:
color 0.12s ease-out,
border-color 0.12s ease-out;
&:hover {
color: var(--color-brand-primary-600, #1d4ed8);
}
&:focus-visible {
outline: 2px solid var(--color-brand-primary-500, #2563eb);
outline-offset: 2px;
border-radius: 0.125rem;
}
}
:host-context(.dark) .tab {
color: #9ca3af;
&:hover {
color: var(--color-brand-primary-300, #93c5fd);
}
}
.tab--active {
color: var(--color-brand-primary-600, #1d4ed8);
border-bottom-color: var(--color-brand-primary-600, #1d4ed8);
font-weight: 600;
}
:host-context(.dark) .tab--active {
color: var(--color-brand-primary-300, #93c5fd);
border-bottom-color: var(--color-brand-primary-300, #93c5fd);
}
.tab-panel {
// No box-styling — the panel inherits the page's surface.
// `[hidden]` is set imperatively when the tab isn't active.
}
// Charts panel container. Same surface tokens as `.filters` so the
// chart stack reads as one related block.
.charts {
margin: 0 0 1rem;
padding: 1rem;
background-color: #ffffff;
border: 1px solid #e5e7eb;
border-radius: 0.5rem;
}
:host-context(.dark) .charts {
background-color: #111827;
border-color: #1f2937;
}
.charts-heading {
margin: 0;
font-size: 0.875rem;
font-weight: 600;
color: #1f2937;
:where(.dark) & {
color: #f3f4f6;
}
}
.charts-note {
margin: 0.25rem 0 1rem;
font-size: 0.75rem;
color: #6b7280;
:where(.dark) & {
color: #9ca3af;
}
}
.charts-grid {
display: grid;
gap: 1rem;
grid-template-columns: repeat(2, minmax(0, 1fr));
@media (max-width: 800px) {
grid-template-columns: 1fr;
}
}
// Grid items default to `min-width: auto`, which prevents the column
// from shrinking below the intrinsic width of its content. Without
// the override, Plot's fixed-width SVG could push the column wider
// than 1fr, busting the layout and forcing a horizontal scrollbar.
.chart-tile {
min-width: 0;
}
// The stacked-bar chart carries a legend and benefits from the
// full width; flag it via a modifier and span both columns.
.chart-tile--wide {
grid-column: 1 / -1;
}
.table-wrap {
overflow-x: auto;
background-color: #ffffff;
@@ -273,6 +400,65 @@
color: #9ca3af;
}
// The clickable variant of `.actor-hash` (rendered as a <button> so
// keyboard activation works) reuses the inline-hash typography but
// strips the native button chrome and surfaces a subtle hover
// affordance. Filter pivot per the same row's actor.
.actor-hash--clickable {
display: inline;
padding: 0;
margin: 0;
background: transparent;
border: 0;
text-align: left;
cursor: pointer;
text-decoration: underline dotted transparent;
text-underline-offset: 2px;
transition:
color 0.12s ease-out,
text-decoration-color 0.12s ease-out;
&:hover {
color: var(--color-brand-primary-600, #1d4ed8);
text-decoration-color: currentColor;
}
&:focus-visible {
outline: 2px solid var(--color-brand-primary-500, #2563eb);
outline-offset: 2px;
border-radius: 0.125rem;
}
}
:host-context(.dark) .actor-hash--clickable {
&:hover {
color: var(--color-brand-primary-300, #93c5fd);
}
}
// Trace-id deep-link into Jaeger. Same hash typography as the actor
// cell (already inherited via `.cell-trace`), plus a brand-coloured
// underline so investigators read it as "follow this".
.trace-link {
color: var(--color-brand-primary-600, #1d4ed8);
text-decoration: underline;
text-underline-offset: 2px;
&:hover {
text-decoration-thickness: 2px;
}
&:focus-visible {
outline: 2px solid var(--color-brand-primary-500, #2563eb);
outline-offset: 2px;
border-radius: 0.125rem;
}
}
:host-context(.dark) .trace-link {
color: var(--color-brand-primary-300, #93c5fd);
}
.aud-badge,
.outcome-badge {
display: inline-block;
@@ -5,8 +5,27 @@ import {
AuditEventsService,
type AdminAuditPage,
type AdminAuditQuery,
type AdminAuditStats,
type AdminAuditStatsQuery,
} from './audit-events.service';
const STATS: AdminAuditStats = {
dailyVolume: [
{ day: '2026-05-12', count: 5 },
{ day: '2026-05-13', count: 7 },
],
outcomeBreakdown: [
{ outcome: 'success', count: 11 },
{ outcome: 'denied', count: 1 },
],
eventTypeByDay: [
{ day: '2026-05-12', eventType: 'auth.sign_in', count: 5 },
{ day: '2026-05-13', eventType: 'auth.sign_in', count: 6 },
{ day: '2026-05-13', eventType: 'admin.access_denied', count: 1 },
],
total: 12,
};
const PAGE: AdminAuditPage = {
items: [
{
@@ -40,22 +59,34 @@ const PAGE: AdminAuditPage = {
interface Fixture {
fixture: ReturnType<typeof TestBed.createComponent<AuditPage>>;
query: ReturnType<typeof vi.fn>;
stats: ReturnType<typeof vi.fn>;
}
function setup(opts?: { initial?: AdminAuditPage | 'error'; status?: number }): Fixture {
function setup(opts?: {
initial?: AdminAuditPage | 'error';
initialStats?: AdminAuditStats | 'error';
status?: number;
}): Fixture {
const initial = opts?.initial ?? PAGE;
const initialStats = opts?.initialStats ?? STATS;
const query = vi.fn().mockImplementation(() => {
if (initial === 'error') {
return throwError(() => ({ status: opts?.status ?? 500 }));
}
return of(initial);
});
const stats = vi.fn().mockImplementation(() => {
if (initialStats === 'error') {
return throwError(() => ({ status: opts?.status ?? 500 }));
}
return of(initialStats);
});
TestBed.configureTestingModule({
imports: [AuditPage],
providers: [{ provide: AuditEventsService, useValue: { query } }],
providers: [{ provide: AuditEventsService, useValue: { query, stats } }],
});
const fixture = TestBed.createComponent(AuditPage);
return { fixture, query };
return { fixture, query, stats };
}
async function flush(fixture: Fixture['fixture']): Promise<void> {
@@ -197,4 +228,181 @@ describe('AuditPage', () => {
expect(rows[0]?.querySelector('details')).not.toBeNull();
expect(rows[1]?.querySelector('details')).toBeNull();
});
it('renders trace_id as a Jaeger deep link (anchor with target=_blank)', async () => {
const { fixture } = setup();
await flush(fixture);
const root = fixture.nativeElement as HTMLElement;
const rows = root.querySelectorAll<HTMLTableRowElement>('.audit-table tbody tr');
const link = rows[0]?.querySelector<HTMLAnchorElement>('.trace-link');
expect(link).not.toBeNull();
expect(link?.getAttribute('href')).toContain('/trace/trace-abc');
expect(link?.getAttribute('target')).toBe('_blank');
expect(link?.getAttribute('rel')).toContain('noopener');
});
it('renders the dash placeholder for rows without a trace_id', async () => {
const { fixture } = setup();
await flush(fixture);
const root = fixture.nativeElement as HTMLElement;
const rows = root.querySelectorAll<HTMLTableRowElement>('.audit-table tbody tr');
expect(rows[1]?.querySelector('.trace-link')).toBeNull();
expect(rows[1]?.querySelector('.cell-trace')?.textContent?.trim()).toBe('—');
});
it('clicking an actor hash re-runs the query filtered on that hash + resets offset', async () => {
const { fixture, query } = setup();
await flush(fixture);
query.mockClear();
const root = fixture.nativeElement as HTMLElement;
const rows = root.querySelectorAll<HTMLTableRowElement>('.audit-table tbody tr');
rows[0]?.querySelector<HTMLButtonElement>('.actor-hash--clickable')?.click();
await flush(fixture);
expect(query).toHaveBeenCalledTimes(1);
const filters = query.mock.calls[0]?.[0] as AdminAuditQuery;
expect(filters.actorIdHash).toBe('hash(jane)');
expect(filters.offset).toBe(0);
});
it('renders the anonymous actor as plain text (not a button)', async () => {
const anonymousPage: AdminAuditPage = {
...PAGE,
items: [{ ...PAGE.items[0]!, actorIdHash: null }],
};
const { fixture } = setup({ initial: anonymousPage });
await flush(fixture);
const root = fixture.nativeElement as HTMLElement;
const row = root.querySelector<HTMLTableRowElement>('.audit-table tbody tr');
expect(row?.querySelector('.actor-hash--clickable')).toBeNull();
expect(row?.querySelector('.actor-hash')?.textContent?.trim()).toBe('(anonymous)');
});
describe('tabs + charts', () => {
it('defaults to the Table tab on first render — does NOT call the stats endpoint', async () => {
const { fixture, stats } = setup();
await flush(fixture);
const root = fixture.nativeElement as HTMLElement;
const activeTab = root.querySelector('.tab--active');
expect(activeTab?.textContent?.trim()).toBe('Table');
expect(stats).not.toHaveBeenCalled();
});
it('fetches stats when the user opens the Charts tab for the first time', async () => {
const { fixture, stats } = setup();
await flush(fixture);
const root = fixture.nativeElement as HTMLElement;
const chartsTab = Array.from(root.querySelectorAll<HTMLButtonElement>('.tab')).find(
(b) => b.textContent?.trim() === 'Charts',
);
chartsTab?.click();
await flush(fixture);
expect(stats).toHaveBeenCalledTimes(1);
expect(root.querySelector('lib-bar-chart')).not.toBeNull();
expect(root.querySelector('lib-donut-chart')).not.toBeNull();
expect(root.querySelector('lib-stacked-bar-chart')).not.toBeNull();
});
it('does NOT pass pagination knobs to the stats endpoint', async () => {
const { fixture, stats } = setup();
await flush(fixture);
const root = fixture.nativeElement as HTMLElement;
Array.from(root.querySelectorAll<HTMLButtonElement>('.tab'))
.find((b) => b.textContent?.trim() === 'Charts')
?.click();
await flush(fixture);
const filters = stats.mock.calls[0]?.[0] as AdminAuditStatsQuery & {
limit?: number;
offset?: number;
};
expect(filters.limit).toBeUndefined();
expect(filters.offset).toBeUndefined();
});
it('does NOT re-fetch stats on pagination (filters unchanged)', async () => {
const { fixture, stats } = setup();
await flush(fixture);
const root = fixture.nativeElement as HTMLElement;
Array.from(root.querySelectorAll<HTMLButtonElement>('.tab'))
.find((b) => b.textContent?.trim() === 'Charts')
?.click();
await flush(fixture);
expect(stats).toHaveBeenCalledTimes(1);
// Switch back to Table and paginate.
Array.from(root.querySelectorAll<HTMLButtonElement>('.tab'))
.find((b) => b.textContent?.trim() === 'Table')
?.click();
await flush(fixture);
// Open Charts again — cache still valid, no new stats call.
Array.from(root.querySelectorAll<HTMLButtonElement>('.tab'))
.find((b) => b.textContent?.trim() === 'Charts')
?.click();
await flush(fixture);
expect(stats).toHaveBeenCalledTimes(1);
});
it('re-fetches stats when filters change and the Charts tab is active', async () => {
const { fixture, stats } = setup();
await flush(fixture);
// Open Charts to load the initial stats.
const root = fixture.nativeElement as HTMLElement;
Array.from(root.querySelectorAll<HTMLButtonElement>('.tab'))
.find((b) => b.textContent?.trim() === 'Charts')
?.click();
await flush(fixture);
expect(stats).toHaveBeenCalledTimes(1);
// Component-state mutation: tweak a filter and re-search.
const cmp = fixture.componentInstance as unknown as {
eventType: { set(v: string): void };
search(): Promise<void>;
};
cmp.eventType.set('auth.sign_in');
await cmp.search();
await flush(fixture);
expect(stats).toHaveBeenCalledTimes(2);
});
it('shows the donut centre label with the server-side total (not the per-page count)', async () => {
const { fixture } = setup();
await flush(fixture);
const root = fixture.nativeElement as HTMLElement;
Array.from(root.querySelectorAll<HTMLButtonElement>('.tab'))
.find((b) => b.textContent?.trim() === 'Charts')
?.click();
await flush(fixture);
const donutCenter = root.querySelector('lib-donut-chart .donut-center-label');
expect(donutCenter?.textContent?.trim()).toBe(String(STATS.total));
});
it('shows the empty-state message when the stats endpoint returns 0 events', async () => {
const emptyStats: AdminAuditStats = {
dailyVolume: [],
outcomeBreakdown: [],
eventTypeByDay: [],
total: 0,
};
const { fixture } = setup({ initialStats: emptyStats });
await flush(fixture);
const root = fixture.nativeElement as HTMLElement;
Array.from(root.querySelectorAll<HTMLButtonElement>('.tab'))
.find((b) => b.textContent?.trim() === 'Charts')
?.click();
await flush(fixture);
expect(root.querySelector('lib-bar-chart')).toBeNull();
expect(root.querySelector('#panel-charts .empty')).not.toBeNull();
});
it('renders a permission-aware error when the stats endpoint returns 403', async () => {
const { fixture } = setup({ initialStats: 'error', status: 403 });
await flush(fixture);
const root = fixture.nativeElement as HTMLElement;
Array.from(root.querySelectorAll<HTMLButtonElement>('.tab'))
.find((b) => b.textContent?.trim() === 'Charts')
?.click();
await flush(fixture);
const errMsg = root.querySelector('.status-line--error')?.textContent?.trim();
expect(errMsg).toContain('do not have access');
});
});
});
+129 -3
View File
@@ -1,12 +1,19 @@
import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { firstValueFrom } from 'rxjs';
import { BarChart, DonutChart, StackedBarChart, semanticStatusColors } from 'shared-charts';
import { environment } from '../../../environments/environment';
import {
AuditEventsService,
type AdminAuditPage,
type AdminAuditQuery,
type AdminAuditStats,
type AdminAuditStatsQuery,
} from './audit-events.service';
/** Tabs shown above the audit content. */
type AuditTab = 'table' | 'charts';
/** Page-size options the SPA offers. Matches the BFF's `DEFAULT_LIMIT` (50) and `MAX_LIMIT` (200). */
const PAGE_SIZES = [25, 50, 100, 200] as const;
@@ -30,7 +37,7 @@ const PAGE_SIZES = [25, 50, 100, 200] as const;
*/
@Component({
selector: 'app-audit-page',
imports: [FormsModule],
imports: [FormsModule, BarChart, DonutChart, StackedBarChart],
templateUrl: './audit.html',
styleUrl: './audit.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
@@ -40,6 +47,20 @@ export class AuditPage {
protected readonly pageSizes = PAGE_SIZES;
/**
* Outcome → fill mapping for the donut chart. Semantic colours
* resolved from the shared-charts curated set: success = green,
* denied = orange (`warning` intent), failure = red. Keeps the
* legend "obvious without reading" — admins eyeballing the donut
* see the green slice and know it's success without parsing the
* tooltip.
*/
protected readonly outcomeColorMap: Readonly<Record<string, string>> = {
success: semanticStatusColors.success,
failure: semanticStatusColors.error,
denied: semanticStatusColors.warning,
};
// Filter form state. Each field is a separate signal so a change
// to one doesn't churn the others through ngModel's reference
// equality. All start empty — the BFF returns the most recent
@@ -86,14 +107,53 @@ export class AuditPage {
return `${first}${last} of ${p.total}`;
});
// ---------------------------------------------------------------
// Tabs + server-side stats — Charts tab consumes
// `GET /api/admin/audit/stats` (full filtered set, Redis-cached
// 5 min per filter-hash per ADR-0013 §"Reader endpoints"). Fetch
// is lazy: only fires when the Charts tab is opened, then again
// when filters change with the Charts tab active.
// ---------------------------------------------------------------
protected readonly tab = signal<AuditTab>('table');
protected readonly stats = signal<AdminAuditStats | null>(null);
protected readonly statsLoading = signal(false);
protected readonly statsError = signal<string | null>(null);
/** True when the loaded stats are non-empty (total > 0). */
protected readonly hasChartData = computed(() => (this.stats()?.total ?? 0) > 0);
/**
* Run a new query with the current filter signals at offset 0.
* Called by the "Apply filters" button + the initial render via
* the template's `@defer (on viewport)` block.
*
* When the user applies new filters, the loaded `stats` becomes
* stale — clear it so the next visit to the Charts tab refetches.
* If the Charts tab is currently active, refetch immediately;
* otherwise wait for the user to open it.
*/
async search(): Promise<void> {
this.offset.set(0);
await this.fetch();
this.stats.set(null);
const tasks: Array<Promise<void>> = [this.fetch()];
if (this.tab() === 'charts') {
tasks.push(this.fetchStats());
}
await Promise.all(tasks);
}
/**
* Switch to a tab. On the first switch to `charts` (or any switch
* that follows a filter change), fire the stats fetch — pagination
* within the table doesn't invalidate stats (the filtered set is
* unchanged), so re-opening Charts after paginating is free.
*/
async setTab(tab: AuditTab): Promise<void> {
this.tab.set(tab);
if (tab === 'charts' && this.stats() === null && !this.statsLoading()) {
await this.fetchStats();
}
}
/** Page navigation — moves the offset by `limit`, then fetches. */
@@ -119,7 +179,12 @@ export class AuditPage {
this.createdAtFrom.set('');
this.createdAtTo.set('');
this.offset.set(0);
await this.fetch();
this.stats.set(null);
const tasks: Array<Promise<void>> = [this.fetch()];
if (this.tab() === 'charts') {
tasks.push(this.fetchStats());
}
await Promise.all(tasks);
}
/** Format an ISO timestamp into the user's locale for the table. */
@@ -143,6 +208,36 @@ export class AuditPage {
}
}
/**
* Build a Jaeger-UI deep link for the given trace id — the audit
* table's `trace_id` cell becomes a clickable link to the
* trace-explorer per ADR-0012's "join audit and app logs by
* trace_id" promise. Anonymous events (`traceId === null`) don't
* get a link; the cell stays a dash.
*/
protected jaegerUrl(traceId: string): string {
return `${environment.jaegerBaseUrl}/trace/${encodeURIComponent(traceId)}`;
}
/**
* Pivot the table on a single actor: set the `actorIdHash` filter
* to the clicked hash, reset paging to 0, and re-query. Lets an
* investigator click any row's actor cell to "show me everything
* else this actor did". Per-query audit row (`admin.audit.query`)
* is still emitted server-side so the pivot is itself auditable.
*/
async filterByActor(hash: string): Promise<void> {
if (!hash) return;
this.actorIdHash.set(hash);
this.offset.set(0);
this.stats.set(null);
const tasks: Array<Promise<void>> = [this.fetch()];
if (this.tab() === 'charts') {
tasks.push(this.fetchStats());
}
await Promise.all(tasks);
}
private async fetch(): Promise<void> {
this.loading.set(true);
this.error.set(null);
@@ -182,6 +277,37 @@ export class AuditPage {
};
}
/**
* Stats endpoint call. Shares the filter shape with `fetch` minus
* pagination — `buildStatsFilters` strips `limit/offset`.
*/
private async fetchStats(): Promise<void> {
this.statsLoading.set(true);
this.statsError.set(null);
try {
const filters = this.buildStatsFilters();
const stats = await firstValueFrom(this.service.stats(filters));
this.stats.set(stats);
} catch (err) {
const status = errorStatus(err);
this.statsError.set(
status === 403
? 'You do not have access to the audit log on this session.'
: 'Could not load audit statistics. Try again in a moment.',
);
this.stats.set(null);
} finally {
this.statsLoading.set(false);
}
}
private buildStatsFilters(): AdminAuditStatsQuery {
const { limit: _limit, offset: _offset, ...rest } = this.buildFilters();
void _limit;
void _offset;
return rest;
}
ngOnInit(): void {
// Fire the initial unfiltered query so the table renders with
// the most recent events on first paint. Fire-and-forget — the
@@ -0,0 +1,126 @@
<section class="user-scopes">
<header class="user-scopes-header">
<p class="crumbs">
<a routerLink="/users">← Back to users</a>
</p>
<h1 class="title">User scopes</h1>
@if (page(); as p) {
<p class="intro">
Managing scopes for <strong>{{ p.user.displayName }}</strong> ({{ p.user.email ?? '—' }},
<code>{{ p.user.oid }}</code>). Every grant emits <code>admin.scope_granted</code> and every
revoke emits <code>admin.scope_revoked</code> — scope-management is itself auditable per
ADR-0013.
</p>
}
</header>
<div class="status-bar" role="status" aria-live="polite">
@if (loading()) {
<span class="status-line">Loading…</span>
} @else if (error(); as msg) {
<span class="status-line status-line--error">{{ msg }}</span>
}
</div>
@if (page(); as p) {
<section class="scopes-section" aria-labelledby="current-scopes-h">
<h2 id="current-scopes-h" class="section-heading">Current scopes</h2>
@if (p.scopes.length === 0) {
<p class="empty">No scopes granted to this user yet.</p>
} @else {
<div class="table-wrap">
<table class="scopes-table">
<thead>
<tr>
<th scope="col">Scope</th>
<th scope="col">Source</th>
<th scope="col">Granted</th>
<th scope="col">Expires</th>
<th scope="col"><span class="sr-only">Actions</span></th>
</tr>
</thead>
<tbody>
@for (scope of p.scopes; track scope.id) {
<tr>
<td class="cell-scope">
<code>{{ formatScope(scope.kind, scope.value) }}</code>
</td>
<td class="cell-source">{{ scope.source }}</td>
<td class="cell-timestamp">{{ formatTimestamp(scope.createdAt) }}</td>
<td class="cell-timestamp">{{ formatTimestamp(scope.expiresAt) }}</td>
<td class="cell-actions">
<button
type="button"
class="btn btn--danger"
(click)="revoke(scope.id, formatScope(scope.kind, scope.value))"
>
Revoke
</button>
</td>
</tr>
}
</tbody>
</table>
</div>
}
</section>
<section class="grant-section" aria-labelledby="grant-h">
<h2 id="grant-h" class="section-heading">Grant a new scope</h2>
<form class="grant-form" (submit)="$event.preventDefault(); grant()">
<div class="grant-grid">
<label class="field">
<span class="field-label">Kind</span>
<select
name="kind"
[ngModel]="newKind()"
(ngModelChange)="newKind.set($event)"
[disabled]="submitting()"
>
@for (kind of scopeKinds; track kind) {
<option [value]="kind">{{ kind }}</option>
}
</select>
</label>
<label class="field" [class.field--hidden]="!needsValue()">
<span class="field-label">Value</span>
<input
type="text"
name="value"
[ngModel]="newValue()"
(ngModelChange)="newValue.set($event)"
[disabled]="submitting() || !needsValue()"
placeholder="0330800013 / 33 / 75"
[attr.aria-required]="needsValue() ? 'true' : 'false'"
[attr.aria-describedby]="needsValue() ? 'value-hint' : null"
/>
@if (needsValue()) {
<span id="value-hint" class="field-hint">
For etablissement: Structure.code. For delegation: dept code (33, 2A). For region: INSEE
region code (75).
</span>
}
</label>
<label class="field">
<span class="field-label">Expires at (optional)</span>
<input
type="datetime-local"
name="expiresAt"
[ngModel]="newExpiresAt()"
(ngModelChange)="newExpiresAt.set($event)"
[disabled]="submitting()"
/>
</label>
</div>
@if (submitError(); as msg) {
<p class="submit-error" role="alert">{{ msg }}</p>
}
<div class="grant-actions">
<button type="submit" class="btn btn--primary" [disabled]="submitting()">
@if (submitting()) { Granting… } @else { Grant }
</button>
</div>
</form>
</section>
}
</section>
@@ -0,0 +1,152 @@
.user-scopes {
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.user-scopes-header {
.crumbs {
margin: 0 0 0.5rem;
font-size: 0.875rem;
}
.title {
margin: 0 0 0.5rem;
}
.intro {
margin: 0;
color: var(--color-text-secondary, #555);
}
}
.status-bar {
min-height: 1.5rem;
}
.status-line--error {
color: var(--color-danger, #b00020);
}
.section-heading {
margin: 0 0 0.75rem;
font-size: 1.125rem;
}
.empty {
font-style: italic;
color: var(--color-text-secondary, #555);
}
.table-wrap {
overflow-x: auto;
}
.scopes-table {
width: 100%;
border-collapse: collapse;
th,
td {
padding: 0.5rem 0.75rem;
text-align: left;
border-bottom: 1px solid var(--color-border, #ddd);
}
.cell-scope code {
font-family: var(--font-mono, monospace);
}
.cell-actions {
text-align: right;
}
}
.grant-section {
background: var(--color-bg-surface, #fafafa);
padding: 1rem;
border: 1px solid var(--color-border, #ddd);
border-radius: 0.5rem;
}
.grant-grid {
display: grid;
gap: 1rem;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
margin-bottom: 1rem;
}
.field {
display: flex;
flex-direction: column;
gap: 0.25rem;
&.field--hidden {
visibility: hidden;
}
.field-label {
font-weight: 600;
font-size: 0.875rem;
}
.field-hint {
font-size: 0.75rem;
color: var(--color-text-secondary, #555);
}
input,
select {
padding: 0.5rem;
border: 1px solid var(--color-border, #ccc);
border-radius: 0.25rem;
min-height: 44px; /* WCAG 2.2 AA touch target (2.5.5 / 2.5.8). */
}
}
.submit-error {
color: var(--color-danger, #b00020);
margin: 0 0 0.75rem;
}
.grant-actions {
display: flex;
justify-content: flex-end;
}
.btn {
min-height: 44px;
min-width: 44px;
padding: 0.5rem 1rem;
border-radius: 0.25rem;
border: 1px solid transparent;
cursor: pointer;
font-weight: 600;
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.btn--primary {
background: var(--color-brand-accent-500, #de9415);
color: white;
}
.btn--danger {
background: transparent;
color: var(--color-danger, #b00020);
border-color: currentColor;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
@@ -0,0 +1,92 @@
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import { firstValueFrom } from 'rxjs';
import { AUTH_BFF_BASE_URL } from 'feature-auth';
import { UserScopesService, type UserScope, type UserScopesPayload } from './user-scopes.service';
const BFF_BASE = 'http://bff.test/api';
const SCOPE: UserScope = {
id: 'scope-1',
kind: 'etablissement',
value: '0330800013',
source: 'seed',
createdAt: '2026-05-26T10:00:00.000Z',
expiresAt: null,
};
const PAGE: UserScopesPayload = {
user: {
oid: 'target-oid',
userId: 'user-uuid-1',
displayName: 'Jane Doe',
email: 'jane@apf.example',
},
scopes: [SCOPE],
};
function setup() {
TestBed.configureTestingModule({
providers: [
provideHttpClient(),
provideHttpClientTesting(),
{ provide: AUTH_BFF_BASE_URL, useValue: BFF_BASE },
],
});
return {
service: TestBed.inject(UserScopesService),
http: TestBed.inject(HttpTestingController),
};
}
describe('UserScopesService.list', () => {
it('GETs /admin/users/:oid/scopes and returns the page', async () => {
const { service, http } = setup();
const promise = firstValueFrom(service.list('target-oid'));
const req = http.expectOne(`${BFF_BASE}/admin/users/target-oid/scopes`);
expect(req.request.method).toBe('GET');
req.flush(PAGE);
await expect(promise).resolves.toEqual(PAGE);
});
it('URL-encodes the oid', async () => {
const { service, http } = setup();
void firstValueFrom(service.list('weird/oid'));
const req = http.expectOne(`${BFF_BASE}/admin/users/weird%2Foid/scopes`);
req.flush(PAGE);
});
});
describe('UserScopesService.grant', () => {
it('POSTs the input body and returns the created scope', async () => {
const { service, http } = setup();
const promise = firstValueFrom(
service.grant('target-oid', {
kind: 'etablissement',
value: '0330800013',
expiresAt: '2026-12-31T23:59:59.000Z',
}),
);
const req = http.expectOne(`${BFF_BASE}/admin/users/target-oid/scopes`);
expect(req.request.method).toBe('POST');
expect(req.request.body).toEqual({
kind: 'etablissement',
value: '0330800013',
expiresAt: '2026-12-31T23:59:59.000Z',
});
req.flush(SCOPE);
await expect(promise).resolves.toEqual(SCOPE);
});
});
describe('UserScopesService.revoke', () => {
it('DELETEs the scope by id', async () => {
const { service, http } = setup();
const promise = firstValueFrom(service.revoke('target-oid', 'scope-1'));
const req = http.expectOne(`${BFF_BASE}/admin/users/target-oid/scopes/scope-1`);
expect(req.request.method).toBe('DELETE');
req.flush(null);
await expect(promise).resolves.toBeNull();
});
});
@@ -0,0 +1,65 @@
import { HttpClient } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { AUTH_BFF_BASE_URL } from 'feature-auth';
import type { Observable } from 'rxjs';
/**
* One scope row as the SPA sees it — mirror of `UserScopeDto` on
* the BFF side. ISO-string timestamps; the SPA never round-trips
* `Date` instances per the audit-page convention.
*/
export interface UserScope {
readonly id: string;
readonly kind: string;
readonly value: string;
readonly source: string;
readonly createdAt: string;
readonly expiresAt: string | null;
}
export interface UserScopesPayload {
readonly user: {
readonly oid: string;
readonly userId: string;
readonly displayName: string;
readonly email: string | null;
};
readonly scopes: ReadonlyArray<UserScope>;
}
export interface GrantUserScopeInput {
readonly kind: string;
readonly value?: string;
readonly expiresAt?: string;
}
/**
* `UserScopesService` — Thin HttpClient wrapper around
* `/api/admin/users/:oid/scopes`. Same shape convention as
* `AdminUsersService` — `providedIn: 'root'` because the single
* consumer is the `UserScopesPayload`.
*/
@Injectable({ providedIn: 'root' })
export class UserScopesService {
private readonly http = inject(HttpClient);
private readonly bffBaseUrl = inject(AUTH_BFF_BASE_URL);
list(oid: string): Observable<UserScopesPayload> {
return this.http.get<UserScopesPayload>(
`${this.bffBaseUrl}/admin/users/${encodeURIComponent(oid)}/scopes`,
);
}
grant(oid: string, input: GrantUserScopeInput): Observable<UserScope> {
return this.http.post<UserScope>(
`${this.bffBaseUrl}/admin/users/${encodeURIComponent(oid)}/scopes`,
input,
);
}
revoke(oid: string, scopeId: string): Observable<void> {
return this.http.delete<void>(
`${this.bffBaseUrl}/admin/users/${encodeURIComponent(oid)}/scopes/${encodeURIComponent(scopeId)}`,
);
}
}
@@ -0,0 +1,134 @@
import { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting } from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import { ActivatedRoute, provideRouter } from '@angular/router';
import { of, throwError } from 'rxjs';
import { AUTH_BFF_BASE_URL } from 'feature-auth';
import {
UserScopesService,
type GrantUserScopeInput,
type UserScope,
type UserScopesPayload,
} from './user-scopes.service';
import { UserScopesPage } from './user-scopes';
const SCOPE: UserScope = {
id: 'scope-1',
kind: 'etablissement',
value: '0330800013',
source: 'seed',
createdAt: '2026-05-26T10:00:00.000Z',
expiresAt: null,
};
const PAGE: UserScopesPayload = {
user: {
oid: 'target-oid',
userId: 'user-uuid-1',
displayName: 'Jane Doe',
email: 'jane@apf.example',
},
scopes: [SCOPE],
};
function setup(opts?: {
list?: ReturnType<typeof vi.fn>;
grant?: ReturnType<typeof vi.fn>;
revoke?: ReturnType<typeof vi.fn>;
}) {
const list = opts?.list ?? vi.fn().mockReturnValue(of(PAGE));
const grant = opts?.grant ?? vi.fn().mockReturnValue(of(SCOPE));
const revoke = opts?.revoke ?? vi.fn().mockReturnValue(of(undefined));
TestBed.configureTestingModule({
imports: [UserScopesPage],
providers: [
provideHttpClient(),
provideHttpClientTesting(),
provideRouter([]),
{ provide: AUTH_BFF_BASE_URL, useValue: 'http://bff.test/api' },
{
provide: UserScopesService,
useValue: { list, grant, revoke },
},
{
provide: ActivatedRoute,
useValue: { snapshot: { paramMap: new Map([['oid', 'target-oid']]) } },
},
],
});
const fixture = TestBed.createComponent(UserScopesPage);
return { fixture, list, grant, revoke };
}
describe('UserScopesPage', () => {
it('loads the scopes for the route param oid on init', async () => {
const { fixture, list } = setup();
fixture.detectChanges();
await fixture.whenStable();
expect(list).toHaveBeenCalledWith('target-oid');
});
it('surfaces a 404 with the seed/sign-in hint', async () => {
const list = vi.fn().mockReturnValue(throwError(() => ({ status: 404 })));
const { fixture } = setup({ list });
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
const html = (fixture.nativeElement as HTMLElement).innerHTML;
expect(html).toMatch(/User not found/);
});
it('forbids a 403 with a friendly error', async () => {
const list = vi.fn().mockReturnValue(throwError(() => ({ status: 403 })));
const { fixture } = setup({ list });
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
const html = (fixture.nativeElement as HTMLElement).innerHTML;
expect(html).toMatch(/admin access/);
});
});
describe('UserScopesPage.grant', () => {
it('sends value for value-bearing kinds + refreshes the list on success', async () => {
let grantPayload: GrantUserScopeInput | null = null;
const grant = vi.fn((_oid: string, payload: GrantUserScopeInput) => {
grantPayload = payload;
return of(SCOPE);
});
const list = vi.fn().mockReturnValue(of(PAGE));
const { fixture } = setup({ list, grant });
fixture.detectChanges();
await fixture.whenStable();
const component = fixture.componentInstance as unknown as {
newKind: { set: (v: string) => void };
newValue: { set: (v: string) => void };
grant: () => Promise<void>;
};
component.newKind.set('etablissement');
component.newValue.set('0330800013');
await component.grant();
expect(grant).toHaveBeenCalledTimes(1);
expect(grantPayload).toEqual({ kind: 'etablissement', value: '0330800013' });
// List re-fetched after the grant.
expect(list).toHaveBeenCalledTimes(2);
});
it('omits value for valueless kinds', async () => {
let grantPayload: GrantUserScopeInput | null = null;
const grant = vi.fn((_oid: string, payload: GrantUserScopeInput) => {
grantPayload = payload;
return of(SCOPE);
});
const { fixture } = setup({ grant });
fixture.detectChanges();
await fixture.whenStable();
const component = fixture.componentInstance as unknown as {
newKind: { set: (v: string) => void };
grant: () => Promise<void>;
};
component.newKind.set('unrestricted');
await component.grant();
expect(grantPayload).toEqual({ kind: 'unrestricted' });
});
});
@@ -0,0 +1,180 @@
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
signal,
type OnInit,
} from '@angular/core';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { firstValueFrom } from 'rxjs';
import {
UserScopesService,
type GrantUserScopeInput,
type UserScopesPayload,
} from './user-scopes.service';
/**
* The six scope kinds from ADR-0025's catalogue. Hardcoded here so
* the SPA can dropdown them without importing from `shared-auth`
* (which would couple the admin app to the BFF lib path). Drift gate
* lives BFF-side; if the catalogue grows, this list grows in lockstep.
*/
const SCOPE_KINDS = [
'self',
'etablissement',
'delegation',
'region',
'siege',
'unrestricted',
] as const;
const VALUE_BEARING = new Set<string>(['etablissement', 'delegation', 'region']);
/**
* Admin scope-management screen per [ADR-0026 PR 2b](https://git.unespace.com/julien/apf_portal/src/branch/main/docs/decisions/0026-person-user-portal-data-model.md).
* Lists every UserScope row for the target user, lets the admin grant
* a new one (kind dropdown + value + optional expiresAt), and revoke
* existing ones inline.
*
* Reaches the BFF at `/api/admin/users/:oid/scopes`. The `:oid` URL
* param is the affected user's Entra `oid` — same identifier the
* `/admin/users` list page uses, so a "Manage scopes" link from that
* list lands here without a lookup.
*
* A11y posture (per [ADR-0016](https://git.unespace.com/julien/apf_portal/src/branch/main/docs/decisions/0016-accessibility-baseline-wcag-aa-targeted-aaa.md)):
* - Form labels associated via `<label>` wrapping or `for`.
* - Live region for status/error messages (`role="status"`).
* - Touch targets respect 44×44 min (button padding in the SCSS).
* - Revoke buttons confirm via `window.confirm` to avoid accidental
* destructive action on a row's keyboard activation.
*/
@Component({
selector: 'app-user-scopes-page',
imports: [FormsModule, RouterLink],
templateUrl: './user-scopes.html',
styleUrl: './user-scopes.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class UserScopesPage implements OnInit {
private readonly service = inject(UserScopesService);
private readonly route = inject(ActivatedRoute);
protected readonly scopeKinds = SCOPE_KINDS;
protected readonly oid = signal('');
protected readonly page = signal<UserScopesPayload | null>(null);
protected readonly loading = signal(false);
protected readonly error = signal<string | null>(null);
// Form state for the "grant new scope" row.
protected readonly newKind = signal<string>('self');
protected readonly newValue = signal('');
protected readonly newExpiresAt = signal('');
protected readonly submitting = signal(false);
protected readonly submitError = signal<string | null>(null);
protected readonly needsValue = computed(() => VALUE_BEARING.has(this.newKind()));
ngOnInit(): void {
const oid = this.route.snapshot.paramMap.get('oid') ?? '';
this.oid.set(oid);
void this.fetch();
}
protected async fetch(): Promise<void> {
this.loading.set(true);
this.error.set(null);
try {
const page = await firstValueFrom(this.service.list(this.oid()));
this.page.set(page);
} catch (err) {
this.error.set(this.translateError(err, 'Could not load scopes for this user.'));
this.page.set(null);
} finally {
this.loading.set(false);
}
}
protected async grant(): Promise<void> {
if (this.submitting()) return;
this.submitting.set(true);
this.submitError.set(null);
const payload: GrantUserScopeInput = {
kind: this.newKind(),
...(this.needsValue() ? { value: this.newValue().trim() } : {}),
...(this.newExpiresAt() ? { expiresAt: toIso(this.newExpiresAt()) } : {}),
};
try {
await firstValueFrom(this.service.grant(this.oid(), payload));
// Reset the form (kind reverts to 'self' as the "least
// privileged" default), then refresh the list to show the new
// row.
this.newKind.set('self');
this.newValue.set('');
this.newExpiresAt.set('');
await this.fetch();
} catch (err) {
this.submitError.set(this.translateError(err, 'Could not grant the scope.'));
} finally {
this.submitting.set(false);
}
}
protected async revoke(scopeId: string, label: string): Promise<void> {
const ok = window.confirm(`Revoke scope "${label}"?`);
if (!ok) return;
try {
await firstValueFrom(this.service.revoke(this.oid(), scopeId));
await this.fetch();
} catch (err) {
this.error.set(this.translateError(err, 'Could not revoke the scope.'));
}
}
protected formatScope(kind: string, value: string): string {
return value === '' ? kind : `${kind}:${value}`;
}
protected formatTimestamp(iso: string | null): string {
if (iso === null) return '—';
try {
return new Date(iso).toLocaleString();
} catch {
return iso;
}
}
private translateError(err: unknown, fallback: string): string {
if (
typeof err === 'object' &&
err !== null &&
'error' in err &&
typeof (err as { error?: unknown }).error === 'object' &&
(err as { error: { message?: unknown } }).error.message !== undefined
) {
const msg = (err as { error: { message?: unknown } }).error.message;
if (typeof msg === 'string') return msg;
if (Array.isArray(msg) && msg.every((m) => typeof m === 'string')) {
return msg.join(' • ');
}
}
const status = errorStatus(err);
if (status === 403) return 'You do not have admin access on this session.';
if (status === 404) return 'User not found — has this persona ever signed in or been seeded?';
return fallback;
}
}
function toIso(localValue: string): string {
const d = new Date(localValue);
return Number.isNaN(d.getTime()) ? localValue : d.toISOString();
}
function errorStatus(err: unknown): number | null {
if (typeof err === 'object' && err !== null && 'status' in err) {
const s = (err as { status: unknown }).status;
return typeof s === 'number' ? s : null;
}
return null;
}
@@ -107,6 +107,7 @@
<th scope="col">First seen</th>
<th scope="col">Last seen</th>
<th scope="col">OID</th>
<th scope="col"><span class="sr-only">Actions</span></th>
</tr>
</thead>
<tbody>
@@ -120,6 +121,15 @@
<td class="cell-timestamp">{{ formatTimestamp(user.firstSeenAt) }}</td>
<td class="cell-timestamp">{{ formatTimestamp(user.lastSeenAt) }}</td>
<td class="cell-oid">{{ user.oid }}</td>
<td class="cell-actions">
<a
class="btn btn--secondary"
[routerLink]="['/users', user.oid, 'scopes']"
[attr.aria-label]="'Manage scopes for ' + user.displayName"
>
Manage scopes
</a>
</td>
</tr>
}
</tbody>
@@ -1,4 +1,5 @@
import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { of, throwError } from 'rxjs';
import {
AdminUsersService,
@@ -48,7 +49,7 @@ function setup(opts?: { initial?: AdminUsersPage | 'error'; status?: number }):
});
TestBed.configureTestingModule({
imports: [UsersPage],
providers: [{ provide: AdminUsersService, useValue: { query } }],
providers: [{ provide: AdminUsersService, useValue: { query } }, provideRouter([])],
});
const fixture = TestBed.createComponent(UsersPage);
return { fixture, query };
@@ -1,5 +1,6 @@
import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { RouterLink } from '@angular/router';
import { firstValueFrom } from 'rxjs';
import {
AdminUsersService,
@@ -26,7 +27,7 @@ const PAGE_SIZES = [25, 50, 100, 200] as const;
*/
@Component({
selector: 'app-users-page',
imports: [FormsModule],
imports: [FormsModule, RouterLink],
templateUrl: './users.html',
styleUrl: './users.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
@@ -13,13 +13,17 @@
*/
export const environment = {
/**
* Origin + prefix of the BFF HTTP API. Same value as portal-shell
* — both SPAs talk to the same BFF (per ADR-0020 §"Where does
* the admin app live"). The admin-specific routing happens via
* the `AUTH_PATH_PREFIX` token (`/admin/auth`) provided in
* Prefix of the BFF HTTP API. Same value as portal-shell — both
* SPAs talk to the same BFF (per ADR-0020 §"Where does the admin
* app live"). The admin-specific routing happens via the
* `AUTH_PATH_PREFIX` token (`/admin/auth`) provided in
* `app.config.ts`, not by talking to a different host.
*
* Relative path: see portal-shell `environment.ts` for the full
* rationale. Both SPAs use `proxy.conf.js` to proxy `/api/*` to
* the BFF, keeping every call same-origin in the browser.
*/
bffApiBaseUrl: 'http://localhost:3000/api',
bffApiBaseUrl: '/api',
/**
* Name of the BFF's CSRF cookie. v1 reuses `portal_csrf`
@@ -43,4 +47,16 @@ export const environment = {
* not an Angular route.
*/
shellAppUrl: 'http://localhost:4200',
/**
* Base origin of the Jaeger UI — the trace-explorer the admin
* audit-log viewer links each `trace_id` to. Dev points at the
* compose-managed Jaeger from the `observability` profile (see
* `infra/local/dev.compose.yml`). Prod will switch to whatever
* trace backend the future infrastructure ADR picks (Tempo,
* Grafana Cloud, on-prem Jaeger…) via the per-env file replacement.
*
* Trace URLs are built as `${jaegerBaseUrl}/trace/<id>`.
*/
jaegerBaseUrl: 'http://localhost:16686',
};
@@ -21,6 +21,10 @@
<source>Users — APF Portal Admin</source>
<target>Utilisateurs — Administration APF Portal</target>
</trans-unit>
<trans-unit id="route.user-scopes.title" datatype="html">
<source>User scopes — APF Portal Admin</source>
<target>Périmètres utilisateur — Administration APF Portal</target>
</trans-unit>
<trans-unit id="route.profile.title" datatype="html">
<source>Profile — APF Portal Admin</source>
<target>Profil — Administration APF Portal</target>
+15
View File
@@ -16,3 +16,18 @@
/* Brand palette tokens — shared with portal-shell. */
@import '../../../libs/shared/tokens/src/brand-tokens.css';
/*
* The admin shell is a "fills the viewport, never scrolls the body"
* layout: <app-root> is locked at height: 100vh, and <main> owns its
* own overflow-y. If anything inside the shell ever pushes content
* past the viewport (a wide chart, a flex sizing bug, a third-party
* iframe), we'd rather clip than show a phantom body scrollbar plus
* the empty space below the footer that comes with it. The element-
* level overflow on <main> still produces a real, scrollable region
* for content that overflows by design.
*/
html,
body {
overflow-y: hidden;
}
+3
View File
@@ -7,6 +7,9 @@
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"],
"references": [
{
"path": "../../libs/shared/charts/tsconfig.lib.json"
},
{
"path": "../../libs/shared/ui/tsconfig.lib.json"
},
+52
View File
@@ -60,6 +60,23 @@ ENTRA_CLIENT_SECRET=replace_with_real_value
# User portal — `/api/auth/callback` is the OIDC return URL; the
# post-logout URL is where Entra sends the browser after RP-initiated
# logout (typically the SPA landing page).
#
# The four `localhost` values below are the **WSL-native default** —
# browser and BFF on the same host, `http://localhost:*` redirect
# URIs (which Entra accepts as the only exception to its HTTPS rule).
# Leave them here in this file regardless of how the docker `apps`
# profile is being run.
#
# For the ADR-0030 dockerised `apps` profile accessed via a
# hostname (e.g. `https://apf-portal.dev-jg.local:4200/`), do NOT
# edit these values — instead override them at the compose level
# from `infra/local/.env`. Compose's `environment:` block on the
# `portal-bff` service interpolates each of these four vars at
# parse time and wins over `env_file:`, so the BFF in the container
# sees the hostname URIs while native `nx serve` keeps reading
# this file's localhost defaults. See
# `infra/README.md` → "Switching between dev modes" for the
# two-mode toggle.
ENTRA_REDIRECT_URI=http://localhost:3000/api/auth/callback
ENTRA_POST_LOGOUT_REDIRECT_URI=http://localhost:4200/
# Admin portal — distinct callback per ADR-0020 §"Sessions — distinct
@@ -72,6 +89,28 @@ ENTRA_POST_LOGOUT_REDIRECT_URI=http://localhost:4200/
ENTRA_ADMIN_REDIRECT_URI=http://localhost:3000/api/admin/auth/callback
ENTRA_ADMIN_POST_LOGOUT_REDIRECT_URI=http://localhost:4300/
# Authorization model (per ADR-0025). Points at the JSON file that
# maps tenant-private Entra security-group GUIDs to the closed
# catalogue of `apf-role-*` slugs. The BFF loads it at boot through
# `EntraGroupToRoleResolver` (libs/shared/auth). Unset means the
# resolver runs empty — sign-in still succeeds but every user gets
# zero functional roles (no `apf-role-*` UI). A WARN is logged at
# boot so an operator can spot the missing config. See
# `infra/test-tenant.entra.example.json` for the schema; copy to
# `infra/test-tenant.entra.json` (gitignored) and fill in the real
# GUIDs from the Entra admin centre.
ENTRA_GROUP_MAP_PATH=infra/test-tenant.entra.json
# Test-tenant per-persona Entra `oid` map (per ADR-0026 PR 2).
# Consumed by `apps/portal-bff/prisma/seed.ts` to upsert Person +
# User + UserScope rows for the 19 test personas. Distinct from
# `ENTRA_GROUP_MAP_PATH` which points at the 24 functional-role
# *group* guids. Unset means `prisma db seed` skips the test-tenant
# section (no Person/User/UserScope rows seeded — lazy creation at
# first sign-in still works). See
# `infra/test-tenant.personas.example.json` for the schema.
TEST_TENANT_PERSONAS_PATH=infra/test-tenant.personas.json
# Cookie signing secret (per ADR-0009 §"Cookies"). Used to sign the
# transient pre-auth cookie that carries the OIDC `state` + PKCE
# verifier between the /auth/login redirect and the /auth/callback
@@ -206,3 +245,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
@@ -0,0 +1,121 @@
-- Organisational hierarchy (per ADR-0027).
--
-- Region → Delegation → Structure. The portal's ADR-0025 scope-axis
-- dereferences these three layers — the BFF guard
-- `principalCoversResource` walks etablissement → delegation → region
-- to decide whether a scope covers a resource. The schema lives in
-- the public schema; ADR-0029 will populate the full APF inventory
-- via the cascade sync, additively into the columns defined here.
--
-- Inline seed at the bottom of this file: just the codes the
-- test tenant exercises (Région Nouvelle-Aquitaine, Délégation
-- Gironde, two médico-social structures + the APF national siège).
-- Superseded — not extended — by ADR-0029's cascade sync once it
-- ships.
-- CreateTable
CREATE TABLE "regions" (
"code" TEXT NOT NULL,
"name" TEXT NOT NULL,
CONSTRAINT "regions_pkey" PRIMARY KEY ("code")
);
-- CreateTable
CREATE TABLE "delegations" (
"code" TEXT NOT NULL,
"name" TEXT NOT NULL,
"region_code" TEXT NOT NULL,
CONSTRAINT "delegations_pkey" PRIMARY KEY ("code")
);
-- Closed-set CHECK on Structure.kind. Mirrors STRUCTURE_KINDS in
-- apps/portal-bff/src/structures/structure-kind.ts. The CI drift gate
-- (scripts/check-catalogue-drift.mjs) asserts the TS constant matches
-- the values used in code; this CHECK constraint provides the
-- equivalent guarantee at the DB layer for any code path that
-- bypasses the type system.
CREATE TABLE "structures" (
"code" TEXT NOT NULL,
"name" TEXT NOT NULL,
"kind" TEXT NOT NULL,
"finess" TEXT,
"siret" TEXT,
"code_paie" TEXT,
"delegation_code" TEXT,
CONSTRAINT "structures_pkey" PRIMARY KEY ("code"),
CONSTRAINT "structures_kind_check" CHECK (
"kind" IN (
'medico_social',
'antenne',
'dispositif',
'entreprise_adaptee',
'mouvement',
'administratif',
'siege'
)
)
);
-- CreateIndex
CREATE INDEX "delegations_region_code_idx" ON "delegations"("region_code");
-- CreateIndex
CREATE UNIQUE INDEX "structures_finess_key" ON "structures"("finess");
-- CreateIndex
CREATE UNIQUE INDEX "structures_siret_key" ON "structures"("siret");
-- CreateIndex
CREATE UNIQUE INDEX "structures_code_paie_key" ON "structures"("code_paie");
-- CreateIndex
CREATE INDEX "structures_kind_idx" ON "structures"("kind");
-- CreateIndex
CREATE INDEX "structures_delegation_code_idx" ON "structures"("delegation_code");
-- AddForeignKey
ALTER TABLE "delegations" ADD CONSTRAINT "delegations_region_code_fkey"
FOREIGN KEY ("region_code") REFERENCES "regions"("code")
ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
-- ON DELETE SET NULL because delegationCode is optional in the Prisma
-- schema (Structure.delegation Delegation?). Matches Prisma's default
-- for nullable relations; the drift gate would otherwise flag this
-- on every `prisma migrate dev`.
ALTER TABLE "structures" ADD CONSTRAINT "structures_delegation_code_fkey"
FOREIGN KEY ("delegation_code") REFERENCES "delegations"("code")
ON DELETE SET NULL ON UPDATE CASCADE;
-- ------------------------------------------------------------------
-- Test-tenant reference seed (per ADR-0027 §"Seeding posture").
--
-- Region Nouvelle-Aquitaine + Délégation Gironde + the structures
-- referenced by the 19 personas in notes/test-tenant-role-assignments.md:
-- - 0330800013 (APF Bordeaux, FINESS = code)
-- - 0330800021 (Complexe Mérignac, FINESS = code)
-- - 'siege' (APF national headquarters)
--
-- Cascade-sync (ADR-0029) supersedes this seed entirely when it
-- ships — the cleanup migration responsible for that truncates and
-- repopulates from cascade's authoritative inventory.
-- ------------------------------------------------------------------
INSERT INTO "regions" ("code", "name") VALUES
('75', 'Nouvelle-Aquitaine');
INSERT INTO "delegations" ("code", "name", "region_code") VALUES
('33', 'Gironde', '75');
INSERT INTO "structures" ("code", "name", "kind", "finess", "delegation_code") VALUES
('0330800013', 'APF Bordeaux', 'medico_social', '0330800013', '33'),
('0330800021', 'Complexe Mérignac', 'medico_social', '0330800021', '33');
-- Siège has no delegation parent and no FINESS/SIRET/codePaie (those
-- columns stay NULL — the unique indexes tolerate multiple NULLs).
INSERT INTO "structures" ("code", "name", "kind") VALUES
('siege', 'Siège APF France handicap', 'siege');
@@ -0,0 +1,19 @@
-- Rename `users` -> `user_directory_entries`.
--
-- Prepares for ADR-0026 PR 1 which introduces a NEW `User` model
-- (UUID PK + FK to `Person` + `lastSignInAt`) with materially
-- different semantics. The existing model (ADR-0020 sign-in cache,
-- Entra `oid` as PK) keeps its role but gets a more precise name
-- that won't collide.
--
-- Mechanical refactor only — same columns, same indexes, same
-- constraints. ADR-0026 PR 1 lands the new `users` table with
-- the new semantics.
ALTER TABLE "users" RENAME TO "user_directory_entries";
-- Rename the PK constraint + indexes so they track the new table name.
-- Postgres doesn't auto-rename these on ALTER TABLE RENAME.
ALTER INDEX "users_pkey" RENAME TO "user_directory_entries_pkey";
ALTER INDEX "users_last_seen_at_idx" RENAME TO "user_directory_entries_last_seen_at_idx";
ALTER INDEX "users_username_idx" RENAME TO "user_directory_entries_username_idx";
@@ -0,0 +1,84 @@
-- Identity model (per ADR-0026 PR 1).
--
-- Person golden record + User portal-account overlay (1-to-0-or-1)
-- + UserScope (the table behind the ADR-0025 scope axis). Distinct
-- from `user_directory_entries` (ADR-0020 sign-in cache, renamed in
-- the previous migration) — those keep their role; these are new.
--
-- No seed data — the test-tenant scope rows ship in ADR-0026 PR 2
-- via `prisma/seed.ts`, after this schema is in place.
-- CreateTable
CREATE TABLE "persons" (
"id" UUID NOT NULL,
"first_name" TEXT NOT NULL,
"last_name" TEXT NOT NULL,
"email" TEXT,
"source" TEXT NOT NULL,
"external_id" TEXT,
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ(6) NOT NULL,
CONSTRAINT "persons_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "users" (
"id" UUID NOT NULL,
"person_id" UUID NOT NULL,
"entra_oid" TEXT NOT NULL,
"tenant_id" TEXT NOT NULL,
"last_sign_in_at" TIMESTAMPTZ(6),
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ(6) NOT NULL,
CONSTRAINT "users_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "user_scopes" (
"id" UUID NOT NULL,
"user_id" UUID NOT NULL,
"kind" TEXT NOT NULL,
"value" TEXT NOT NULL DEFAULT '',
"source" TEXT NOT NULL,
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"expires_at" TIMESTAMPTZ(6),
CONSTRAINT "user_scopes_pkey" PRIMARY KEY ("id")
);
-- Person indexes
CREATE INDEX "persons_source_idx" ON "persons"("source");
CREATE INDEX "persons_external_id_idx" ON "persons"("external_id");
CREATE INDEX "persons_email_idx" ON "persons"("email");
-- User indexes — both unique constraints back btree indexes.
CREATE UNIQUE INDEX "users_person_id_key" ON "users"("person_id");
CREATE UNIQUE INDEX "users_entra_oid_key" ON "users"("entra_oid");
-- UserScope indexes — the unique constraint backs the composite,
-- the plain index supports the read-by-userId hot path on sign-in.
CREATE UNIQUE INDEX "user_scopes_user_id_kind_value_key" ON "user_scopes"("user_id", "kind", "value");
CREATE INDEX "user_scopes_user_id_idx" ON "user_scopes"("user_id");
-- Foreign keys.
--
-- User.person_id is REQUIRED → ON DELETE RESTRICT (Prisma default for
-- required relations). Removing a Person with a portal User must be
-- an explicit two-step in the admin path; never an accidental cascade.
ALTER TABLE "users" ADD CONSTRAINT "users_person_id_fkey"
FOREIGN KEY ("person_id") REFERENCES "persons"("id")
ON DELETE RESTRICT ON UPDATE CASCADE;
-- UserScope.user_id has explicit `onDelete: Cascade` in the Prisma
-- schema — revoking a User wipes their scope rows in one delete,
-- which is the desired admin-UI semantic (deactivating an account
-- should not leave dangling authorisation rows).
ALTER TABLE "user_scopes" ADD CONSTRAINT "user_scopes_user_id_fkey"
FOREIGN KEY ("user_id") REFERENCES "users"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
+208 -2
View File
@@ -67,6 +67,12 @@ enum AuditOutcome {
// (combined with the salted hash) — never the BFF's primary actor
// identifier elsewhere.
//
// **Distinct from the upcoming ADR-0026 `User` model.** That one
// (UUID PK + FK to `Person` + lazy-created at OIDC callback) is the
// portal-account overlay on a `Person` golden record. This
// `UserDirectoryEntry` is the ADR-0020 sign-in cache — different
// semantics, kept apart so neither concept overloads the other.
//
// **No PII redaction on read.** Per ADR-0013 the audit module
// hashes the actor id to defend against an audit-log dump leaking
// who-did-what. This table is the *deliberate* PII storage: an
@@ -74,7 +80,7 @@ enum AuditOutcome {
// usernames. The trust boundary is the admin role gate
// (ADR-0020 §"Auth — `admin` role claim").
model User {
model UserDirectoryEntry {
// Entra `oid` — stable per-user identifier inside the tenant.
// Used as the natural primary key. Per-tenant uniqueness is
// sufficient: the dual-audience design (ADR-0008) currently
@@ -94,12 +100,212 @@ model User {
// without scanning audit.events.
lastSeenAt DateTime @default(now()) @map("last_seen_at") @db.Timestamptz(6)
@@map("users")
@@map("user_directory_entries")
@@schema("public")
@@index([lastSeenAt(sort: Desc)])
@@index([username])
}
// ============================================================
// Identity model (per ADR-0026)
// ============================================================
//
// `Person` golden record + `User` portal-account overlay (one-to-zero-
// or-one). Distinct from `UserDirectoryEntry` above — that one is the
// ADR-0020 sign-in cache keyed on Entra `oid`; these are the portal's
// stable identity model keyed on UUIDs and form the basis for the
// `@RequireScope` Prisma resolver landing in ADR-0026 PR 2.
//
// `Person` exists whether or not the human ever signs in (workforce
// pre-provisioning, dossier bénéficiaires, alumni). `User` is the
// portal-access overlay, lazy-created at first OIDC callback by
// `PersonAndUserProvisioner.ensureUser`. `UserScope` backs the ADR-0025
// scope axis with opaque `value` strings referencing ADR-0027's
// `Structure.code` / `Delegation.code` / `Region.code` — no FK at the
// DB level so historical scope rows survive structure decommissioning.
model Person {
id String @id @default(uuid()) @db.Uuid
// PII — firstName + lastName are subject to ADR-0013 redaction rules
// when emitted to logs.
firstName String @map("first_name")
lastName String @map("last_name")
// Primary contact email. Indexed for operator lookup but NOT unique
// — two distinct humans genuinely can share emails (shared family
// alias, generic info@ at a small partner organisation, error in an
// upstream feed). ADR-0029's reconciliation flow surfaces candidate
// duplicates for operator confirmation rather than auto-merging.
email String?
// Closed-set catalogue, drift-gated. See
// `apps/portal-bff/src/users/person-source.ts` for the legal values.
// v1: 'self-signin' / 'admin-ui' / 'seed'. ADR-0029 adds 'pleiades'
// and 'acteurs-plus'.
source String
// Upstream-system identifier (Pléiades matricule, Acteurs+ id).
// Nullable in v1 — populated by ADR-0029's syncs.
externalId String? @map("external_id")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(6)
// Back-ref. NULL when the Person has no portal account (workforce
// pre-provisioning, dossier bénéficiaire, alumni).
user User?
@@map("persons")
@@schema("public")
@@index([source])
@@index([externalId])
@@index([email])
}
model User {
id String @id @default(uuid()) @db.Uuid
// 1-to-1 with Person. The unique constraint on personId enforces
// "at most one User per Person"; back-ref `Person.user` is the
// other half.
personId String @unique @map("person_id") @db.Uuid
person Person @relation(fields: [personId], references: [id])
// Entra object id — stable per-user identifier inside the tenant.
// Unique because two separate Person+User pairs cannot share an
// Entra identity.
entraOid String @unique @map("entra_oid")
// Entra tenant id. Stored alongside the oid so a future dual-
// audience activation (ADR-0008) can disambiguate.
tenantId String @map("tenant_id")
// Refreshed by `PersonAndUserProvisioner.ensureUser` on every
// sign-in. Surfaced in the future /admin/users/:id screen.
lastSignInAt DateTime? @map("last_sign_in_at") @db.Timestamptz(6)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(6)
scopes UserScope[]
@@map("users")
@@schema("public")
}
model UserScope {
id String @id @default(uuid()) @db.Uuid
userId String @map("user_id") @db.Uuid
// CASCADE so revoking a User wipes their scope rows in one delete.
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
// One of the six ADR-0025 scope kinds: 'self' / 'etablissement' /
// 'delegation' / 'region' / 'siege' / 'unrestricted'. Not pulled
// from a Prisma enum because the value's semantics belong to the
// `shared-auth` catalogue, not the database; the drift gate
// already enforces the closed set at the call sites.
kind String
// Per-kind payload — semantics owned by ADR-0027. For 'etablissement'
// the value is a Structure.code; for 'delegation' it is a
// Delegation.code; for 'region' it is a Region.code. Empty string
// for 'self' / 'siege' / 'unrestricted'. NO FK to ADR-0027 tables —
// historical scope rows survive structure decommissioning; the
// admin-UI write path (ADR-0026 PR 2) validates at insert time.
value String @default("")
// Provenance, same catalogue posture as Person.source. v1:
// 'admin-ui' / 'seed'. ADR-0029 adds upstream-sync values and a
// reconciliation policy.
source String
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
// When non-null and past, the row is ignored at sign-in. Supports
// interim-director-for-two-months patterns and admin UI scope
// revocations.
expiresAt DateTime? @map("expires_at") @db.Timestamptz(6)
@@map("user_scopes")
@@schema("public")
@@unique([userId, kind, value])
@@index([userId])
}
// ============================================================
// Organisational hierarchy (per ADR-0027)
// ============================================================
//
// Region → Delegation → Structure. The portal's scope-axis
// dereferences these three layers — the BFF guard
// `principalCoversResource` walks etablissement → delegation →
// region to decide whether a scope covers a resource (per
// ADR-0025).
//
// Population in v1: a small inline seed in the
// `add_org_hierarchy` migration (the codes the test tenant
// exercises). The full APF inventory ships with ADR-0029's
// cascade sync, which writes additively into these columns —
// no schema churn at sync time.
model Region {
// INSEE region code (2 digits — '75' Nouvelle-Aquitaine,
// '11' Île-de-France). Externally meaningful and stable
// across reorgs; doubles as primary key.
code String @id
name String
delegations Delegation[]
@@map("regions")
@@schema("public")
}
model Delegation {
// French department code (2-3 chars — '33' Gironde, '2A',
// '971'). Same rationale as Region.code.
code String @id
name String
regionCode String @map("region_code")
region Region @relation(fields: [regionCode], references: [code])
structures Structure[]
@@map("delegations")
@@schema("public")
@@index([regionCode])
}
model Structure {
// Portal-internal stable code, externally meaningful. For
// medico-social structures: code = FINESS (9 digits). For
// non-medico-social: APF-internal slug ('siege',
// 'apf-bdx-merignac', 'ea-toulouse', …). Opaque at the type
// level; matching is string equality, not parsing.
code String @id
name String
// Closed set, drift-gated. Legal values are tracked in
// `apps/portal-bff/src/structures/structure-kind.ts` and
// mirrored by a Postgres CHECK constraint on this column
// (see the migration). `scripts/check-catalogue-drift.mjs`
// asserts the TS constant matches the values used in code.
kind String
// FINESS (9 digits). NULL for non-medico-social structures.
// Unique when present. Cascade's StructureSourceFiness is
// the long-term authoritative carrier; the portal denormalises
// it inline here for v1 scope-axis checks. ADR-0029's sync
// owns the write path.
finess String? @unique
// SIRET (14 chars: 9 SIREN + 5 NIC). NULL when the structure
// is not SIRENE-registered (most antennes, dispositifs).
// Unique when present.
siret String? @unique
// Pléiades payroll code (6 chars). NULL in v1 — populated by
// ADR-0029's Pléiades sync once it ships.
codePaie String? @unique @map("code_paie")
// Parent delegation. NULL for structures not attached to one
// (siège, mouvement national, …).
delegationCode String? @map("delegation_code")
delegation Delegation? @relation(fields: [delegationCode], references: [code])
@@map("structures")
@@schema("public")
@@index([kind])
@@index([delegationCode])
}
model AuditEvent {
id String @id @default(uuid()) @db.Uuid
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
+291
View File
@@ -0,0 +1,291 @@
/**
* Test-tenant seed per ADR-0026 PR 2.
*
* Run via:
* pnpm exec prisma db seed
*
* Idempotent: re-running this script preserves existing rows. The
* checks for "row already there" run against the unique constraints:
* - User.entraOid (skipping the cold path of
* PersonAndUserProvisioner if the row exists),
* - UserScope.(userId, kind, value).
*
* The seed creates Person + User + UserScope rows for the 19 personas
* provisioned in the `apfrd.onmicrosoft.com` test tenant. The
* persona matrix below is transcribed from
* `notes/test-tenant-role-assignments.md` (gitignored) — that file
* remains the human-readable reference; this constant is the source
* of truth for the seed.
*
* **Test-tenant Entra `oid`s** are read from a JSON file pointed at
* by `TEST_TENANT_PERSONAS_PATH` (or `infra/test-tenant.personas.json`
* by default). The file is gitignored — copy
* `infra/test-tenant.personas.example.json` and fill in real values
* from the Entra admin centre. Missing file → seed skips the
* test-tenant section cleanly (no error, just a log line).
*
* **`Person.source = 'seed'`** for every row created here, per the
* PERSON_SOURCES catalogue. Differentiates these rows from
* 'self-signin' (lazy-created at first sign-in) and 'admin-ui' (future
* scope-seeding admin screen, ADR-0026 PR 2b).
*
* **Privileges + functional roles are NOT seeded** — those come from
* Entra at sign-in (Entra `roles` claim + `groups` claim resolved by
* `EntraGroupToRoleResolver`). Only scopes are portal-side data.
*/
import { existsSync, readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { PrismaClient } from '@prisma/client';
interface PersonaSeed {
readonly slug: string;
readonly email: string;
readonly displayName: string;
/**
* Scope tuples per `notes/test-tenant-role-assignments.md`. The
* `value` is omitted for valueless kinds (self / siege /
* unrestricted) — the seed writes an empty string to the DB
* `value` column, matching the column's default.
*/
readonly scopes: ReadonlyArray<{ readonly kind: string; readonly value?: string }>;
}
const TENANT_DOMAIN = 'apfrd.onmicrosoft.com';
const PERSONAS: ReadonlyArray<PersonaSeed> = [
{
slug: 'admin',
email: `admin@${TENANT_DOMAIN}`,
displayName: 'Admin User',
scopes: [{ kind: 'unrestricted' }],
},
{
slug: 'directeur-bordeaux',
email: `directeur-bordeaux@${TENANT_DOMAIN}`,
displayName: 'Directeur Bordeaux',
scopes: [{ kind: 'etablissement', value: '0330800013' }],
},
{
slug: 'directeur-complexe',
email: `directeur-complexe@${TENANT_DOMAIN}`,
displayName: 'Directeur Complexe',
scopes: [
{ kind: 'etablissement', value: '0330800013' },
{ kind: 'etablissement', value: '0330800021' },
],
},
{
slug: 'rh-aquitaine',
email: `rh-aquitaine@${TENANT_DOMAIN}`,
displayName: 'RH Aquitaine',
scopes: [{ kind: 'delegation', value: '33' }],
},
{
slug: 'rh-siege',
email: `rh-siege@${TENANT_DOMAIN}`,
displayName: 'RH Siège',
scopes: [{ kind: 'unrestricted' }],
},
{
slug: 'collaborateur-simple',
email: `collaborateur-simple@${TENANT_DOMAIN}`,
displayName: 'Collaborateur Simple',
scopes: [{ kind: 'self' }],
},
{
slug: 'tresorier-bordeaux',
email: `tresorier-bordeaux@${TENANT_DOMAIN}`,
displayName: 'Trésorier Bordeaux',
scopes: [{ kind: 'delegation', value: '33' }],
},
{
slug: 'dpo',
email: `dpo@${TENANT_DOMAIN}`,
displayName: 'DPO',
scopes: [{ kind: 'unrestricted' }],
},
{
slug: 'it',
email: `it@${TENANT_DOMAIN}`,
displayName: 'IT',
scopes: [{ kind: 'unrestricted' }],
},
{
slug: 'benevole-aquitaine',
email: `benevole-aquitaine@${TENANT_DOMAIN}`,
displayName: 'Bénévole Aquitaine',
scopes: [{ kind: 'delegation', value: '33' }],
},
{
slug: 'chef-equipe-bordeaux',
email: `chef-equipe-bordeaux@${TENANT_DOMAIN}`,
displayName: 'Chef Equipe Bordeaux',
scopes: [{ kind: 'etablissement', value: '0330800013' }],
},
{
slug: 'chef-service-bordeaux',
email: `chef-service-bordeaux@${TENANT_DOMAIN}`,
displayName: 'Chef Service Bordeaux',
scopes: [{ kind: 'etablissement', value: '0330800013' }],
},
{
slug: 'directeur-territorial-aquitaine',
email: `directeur-territorial-aquitaine@${TENANT_DOMAIN}`,
displayName: 'Directeur Territorial Aquitaine',
scopes: [{ kind: 'delegation', value: '33' }],
},
{
slug: 'juriste-siege',
email: `juriste-siege@${TENANT_DOMAIN}`,
displayName: 'Juriste Siège',
scopes: [{ kind: 'unrestricted' }],
},
{
slug: 'rssi',
email: `rssi@${TENANT_DOMAIN}`,
displayName: 'RSSI',
scopes: [{ kind: 'unrestricted' }],
},
{
slug: 'communication-siege',
email: `communication-siege@${TENANT_DOMAIN}`,
displayName: 'Communication Siège',
scopes: [{ kind: 'unrestricted' }],
},
{
slug: 'elu-ca-national',
email: `elu-ca-national@${TENANT_DOMAIN}`,
displayName: 'Elu CA National',
scopes: [{ kind: 'siege' }],
},
{
slug: 'president-cd-aquitaine',
email: `president-cd-aquitaine@${TENANT_DOMAIN}`,
displayName: 'Président CD Aquitaine',
scopes: [{ kind: 'delegation', value: '33' }],
},
{
slug: 'secretaire-cd-aquitaine',
email: `secretaire-cd-aquitaine@${TENANT_DOMAIN}`,
displayName: 'Secrétaire CD Aquitaine',
scopes: [{ kind: 'delegation', value: '33' }],
},
];
async function main(): Promise<void> {
const prisma = new PrismaClient();
try {
await seedTestTenant(prisma);
} finally {
await prisma.$disconnect();
}
}
async function seedTestTenant(prisma: PrismaClient): Promise<void> {
const personasPath = resolve(
process.env['TEST_TENANT_PERSONAS_PATH'] ?? 'infra/test-tenant.personas.json',
);
if (!existsSync(personasPath)) {
console.log(
`[seed] TEST_TENANT_PERSONAS_PATH (${personasPath}) not found — skipping test-tenant seed.`,
);
return;
}
let entraOidBySlug: Record<string, string>;
try {
const raw = readFileSync(personasPath, 'utf8');
entraOidBySlug = JSON.parse(raw) as Record<string, string>;
} catch (err) {
console.error(
`[seed] could not parse ${personasPath}: ${err instanceof Error ? err.message : String(err)}`,
);
process.exit(1);
}
const tenantId = process.env['ENTRA_TENANT_ID'] ?? TENANT_DOMAIN;
const counts = { personsCreated: 0, usersCreated: 0, scopesCreated: 0, skipped: 0 };
for (const persona of PERSONAS) {
const entraOid = entraOidBySlug[persona.slug];
if (entraOid === undefined || typeof entraOid !== 'string' || entraOid === '') {
console.warn(`[seed] persona "${persona.slug}" missing oid in ${personasPath} — skipping.`);
counts.skipped += 1;
continue;
}
const { userId, created } = await ensurePersonAndUser(prisma, persona, entraOid, tenantId);
if (created) {
counts.personsCreated += 1;
counts.usersCreated += 1;
}
for (const scope of persona.scopes) {
const seededNew = await ensureUserScope(prisma, userId, scope.kind, scope.value ?? '');
if (seededNew) counts.scopesCreated += 1;
}
}
console.log(
`[seed] test-tenant complete — created ${counts.personsCreated} Person + ${counts.usersCreated} User + ${counts.scopesCreated} UserScope rows; skipped ${counts.skipped} personas (missing oid).`,
);
}
async function ensurePersonAndUser(
prisma: PrismaClient,
persona: PersonaSeed,
entraOid: string,
tenantId: string,
): Promise<{ userId: string; personId: string; created: boolean }> {
const existing = await prisma.user.findUnique({
where: { entraOid },
select: { id: true, personId: true },
});
if (existing) {
return { userId: existing.id, personId: existing.personId, created: false };
}
const [firstName, lastName] = splitDisplayName(persona.displayName);
const created = await prisma.user.create({
data: {
entraOid,
tenantId,
person: {
create: {
firstName,
lastName,
email: persona.email,
source: 'seed',
},
},
},
select: { id: true, personId: true },
});
return { userId: created.id, personId: created.personId, created: true };
}
async function ensureUserScope(
prisma: PrismaClient,
userId: string,
kind: string,
value: string,
): Promise<boolean> {
const existing = await prisma.userScope.findUnique({
where: { userId_kind_value: { userId, kind, value } },
});
if (existing) return false;
await prisma.userScope.create({
data: { userId, kind, value, source: 'seed' },
});
return true;
}
function splitDisplayName(displayName: string): [string, string] {
const trimmed = displayName.trim();
const space = trimmed.indexOf(' ');
if (space < 0) return [trimmed, ''];
return [trimmed.slice(0, space), trimmed.slice(space + 1).trim()];
}
main().catch((err: unknown) => {
console.error('[seed] failed:', err);
process.exit(1);
});
@@ -2,6 +2,8 @@ import type { Request } from 'express';
import { AdminAuditController } from './admin-audit.controller';
import type { AdminAuditQueryDto } from './audit-query.dto';
import type { AuditReader, AdminAuditPage } from './audit-reader.service';
import type { AdminAuditStatsQueryDto } from './audit-stats.dto';
import type { AuditStatsReader, AdminAuditStats } from './audit-stats.service';
import type { AuditWriter } from '../audit/audit.service';
const PAGE: AdminAuditPage = {
@@ -27,18 +29,30 @@ function makeReq(user?: { oid: string }): Request {
return { session: user !== undefined ? { user } : {} } as unknown as Request;
}
const STATS: AdminAuditStats = {
dailyVolume: [{ day: '2026-05-13', count: 1 }],
outcomeBreakdown: [{ outcome: 'success', count: 1 }],
eventTypeByDay: [{ day: '2026-05-13', eventType: 'auth.sign_in', count: 1 }],
total: 1,
};
function makeFixture() {
const auditReader = {
findEvents: jest.fn().mockResolvedValue(PAGE),
};
const auditStatsReader = {
getStats: jest.fn().mockResolvedValue(STATS),
};
const auditWriter = {
adminAuditQuery: jest.fn().mockResolvedValue(undefined),
adminAuditStatsQuery: jest.fn().mockResolvedValue(undefined),
};
const controller = new AdminAuditController(
auditReader as unknown as AuditReader,
auditStatsReader as unknown as AuditStatsReader,
auditWriter as unknown as AuditWriter,
);
return { controller, auditReader, auditWriter };
return { controller, auditReader, auditStatsReader, auditWriter };
}
describe('AdminAuditController.list', () => {
@@ -98,3 +112,51 @@ describe('AdminAuditController.list', () => {
).rejects.toThrow('audit_writer denied');
});
});
describe('AdminAuditController.stats', () => {
it('returns the aggregated stats from AuditStatsReader', async () => {
const { controller } = makeFixture();
const result = await controller.stats(
makeReq({ oid: 'admin-oid' }),
{} as AdminAuditStatsQueryDto,
);
expect(result).toBe(STATS);
});
it('forwards the validated DTO to AuditStatsReader as-is', async () => {
const { controller, auditStatsReader } = makeFixture();
const filters: AdminAuditStatsQueryDto = {
eventType: 'auth.sign_in',
audience: 'workforce',
createdAtFrom: '2026-05-01T00:00:00Z',
};
await controller.stats(makeReq({ oid: 'admin-oid' }), filters);
expect(auditStatsReader.getStats).toHaveBeenCalledWith(filters);
});
it('emits admin.audit.stats.query with the filters + total as the deterrent signal', async () => {
const { controller, auditWriter } = makeFixture();
const filters: AdminAuditStatsQueryDto = { outcome: 'denied' };
await controller.stats(makeReq({ oid: 'admin-oid' }), filters);
expect(auditWriter.adminAuditStatsQuery).toHaveBeenCalledWith({
actor: { oid: 'admin-oid' },
filters: { outcome: 'denied' },
total: STATS.total,
});
});
it('still returns stats when there is no session.user (defensive)', async () => {
const { controller, auditWriter } = makeFixture();
const result = await controller.stats(makeReq(), {} as AdminAuditStatsQueryDto);
expect(result).toBe(STATS);
expect(auditWriter.adminAuditStatsQuery).not.toHaveBeenCalled();
});
it('propagates audit write failures (same blocking posture as list)', async () => {
const { controller, auditWriter } = makeFixture();
auditWriter.adminAuditStatsQuery.mockRejectedValueOnce(new Error('audit_writer denied'));
await expect(
controller.stats(makeReq({ oid: 'admin-oid' }), {} as AdminAuditStatsQueryDto),
).rejects.toThrow('audit_writer denied');
});
});
@@ -4,6 +4,8 @@ import type { Request } from 'express';
import { AuditWriter } from '../audit/audit.service';
import { AdminAuditQueryDto } from './audit-query.dto';
import { AuditReader, type AdminAuditPage } from './audit-reader.service';
import { AdminAuditStatsQueryDto } from './audit-stats.dto';
import { AuditStatsReader, type AdminAuditStats } from './audit-stats.service';
import { RequireAdmin } from './require-admin.decorator';
/**
@@ -35,6 +37,7 @@ import { RequireAdmin } from './require-admin.decorator';
export class AdminAuditController {
constructor(
private readonly auditReader: AuditReader,
private readonly auditStats: AuditStatsReader,
private readonly audit: AuditWriter,
) {}
@@ -61,4 +64,38 @@ export class AdminAuditController {
return page;
}
/**
* `GET /api/admin/audit/stats` — server-side aggregations over
* the full filtered set (no pagination). Powers the "Charts" tab
* of the audit viewer per the chantier brief.
*
* Emits `admin.audit.stats.query` per call — same fishing-
* expedition deterrent posture as `list` above. Results are
* Redis-cached for 5 minutes inside `AuditStatsReader` to absorb
* the cost of repeated identical queries (the SPA hits this on
* each tab switch + filter apply).
*/
@ApiOperation({
summary:
'Aggregated audit stats (dailyVolume, outcomeBreakdown, eventTypeByDay) for the filtered set',
})
@Get('stats')
async stats(
@Req() req: Request,
@Query() filters: AdminAuditStatsQueryDto,
): Promise<AdminAuditStats> {
const result = await this.auditStats.getStats(filters);
const actorOid = req.session.user?.oid;
if (actorOid !== undefined) {
await this.audit.adminAuditStatsQuery({
actor: { oid: actorOid },
filters: { ...filters },
total: result.total,
});
}
return result;
}
}
@@ -1,5 +1,6 @@
import { ExecutionContext, ForbiddenException, UnauthorizedException } from '@nestjs/common';
import type { Request } from 'express';
import type { Principal } from 'shared-auth';
import { AuditWriter } from '../audit/audit.service';
import { ADMIN_ROLE, AdminRoleGuard } from './admin-role.guard';
@@ -12,6 +13,7 @@ interface SessionStub {
amr: readonly string[];
roles: readonly string[];
};
principal?: Principal;
}
function makeRequest(opts: {
@@ -38,6 +40,22 @@ function makeAuditStub(): jest.Mocked<Pick<AuditWriter, 'adminAccessDenied'>> {
};
}
function principalWithPrivileges(privileges: readonly string[]): Principal {
return {
user: {
id: 'user-oid',
personId: 'user-oid',
entraOid: 'user-oid',
tenantId: 't',
displayName: 'Jane',
},
privileges: privileges as Principal['privileges'],
roles: [],
scopes: [{ kind: 'unrestricted' }],
amr: ['pwd', 'mfa'],
};
}
describe('AdminRoleGuard', () => {
it('throws 401 when the request has no session at all', async () => {
const audit = makeAuditStub();
@@ -47,7 +65,7 @@ describe('AdminRoleGuard', () => {
expect(audit.adminAccessDenied).not.toHaveBeenCalled();
});
it('throws 401 when the session exists but no user is bound to it', async () => {
it('throws 401 when the session exists but no principal nor legacy user is bound', async () => {
const audit = makeAuditStub();
const guard = new AdminRoleGuard(audit as unknown as AuditWriter);
const ctx = makeContext(makeRequest({ session: {} }));
@@ -55,21 +73,12 @@ describe('AdminRoleGuard', () => {
expect(audit.adminAccessDenied).not.toHaveBeenCalled();
});
it('throws 403 + records admin.access_denied when the user has no roles at all', async () => {
it('throws 403 + records admin.access_denied when the principal has no privileges', async () => {
const audit = makeAuditStub();
const guard = new AdminRoleGuard(audit as unknown as AuditWriter);
const ctx = makeContext(
makeRequest({
session: {
user: {
oid: 'user-oid',
tid: 't',
username: 'jane@example',
displayName: 'Jane',
amr: ['pwd'],
roles: [],
},
},
session: { principal: principalWithPrivileges([]) },
method: 'GET',
originalUrl: '/api/admin/me',
}),
@@ -82,21 +91,12 @@ describe('AdminRoleGuard', () => {
});
});
it('throws 403 + records admin.access_denied when the user has unrelated roles only', async () => {
it('throws 403 + records admin.access_denied when the principal has non-admin privileges only', async () => {
const audit = makeAuditStub();
const guard = new AdminRoleGuard(audit as unknown as AuditWriter);
const ctx = makeContext(
makeRequest({
session: {
user: {
oid: 'user-oid',
tid: 't',
username: 'jane@example',
displayName: 'Jane',
amr: ['pwd'],
roles: ['editor', 'auditor'],
},
},
session: { principal: principalWithPrivileges(['Portal.Auditor']) },
method: 'POST',
originalUrl: '/api/admin/cms/pages',
}),
@@ -105,45 +105,27 @@ describe('AdminRoleGuard', () => {
expect(audit.adminAccessDenied).toHaveBeenCalledWith({
actor: { oid: 'user-oid' },
attemptedRoute: 'POST /api/admin/cms/pages',
rolesHeld: ['editor', 'auditor'],
rolesHeld: ['Portal.Auditor'],
});
});
it('returns true and does not audit when the user has the admin role', async () => {
it('returns true and does not audit when the principal carries Portal.Admin', async () => {
const audit = makeAuditStub();
const guard = new AdminRoleGuard(audit as unknown as AuditWriter);
const ctx = makeContext(
makeRequest({
session: {
user: {
oid: 'user-oid',
tid: 't',
username: 'jane@example',
displayName: 'Jane',
amr: ['pwd', 'mfa'],
roles: [ADMIN_ROLE],
},
},
}),
makeRequest({ session: { principal: principalWithPrivileges([ADMIN_ROLE]) } }),
);
await expect(guard.canActivate(ctx)).resolves.toBe(true);
expect(audit.adminAccessDenied).not.toHaveBeenCalled();
});
it('returns true when admin is one of several roles', async () => {
it('returns true when admin is one of several privileges', async () => {
const audit = makeAuditStub();
const guard = new AdminRoleGuard(audit as unknown as AuditWriter);
const ctx = makeContext(
makeRequest({
session: {
user: {
oid: 'user-oid',
tid: 't',
username: 'jane@example',
displayName: 'Jane',
amr: ['pwd', 'mfa'],
roles: ['editor', ADMIN_ROLE, 'auditor'],
},
principal: principalWithPrivileges(['Portal.Auditor', ADMIN_ROLE, 'Portal.DPO']),
},
}),
);
@@ -155,24 +137,63 @@ describe('AdminRoleGuard', () => {
adminAccessDenied: jest.fn().mockRejectedValue(new Error('audit_writer denied')),
};
const guard = new AdminRoleGuard(audit as unknown as AuditWriter);
const ctx = makeContext(
makeRequest({
session: {
user: {
oid: 'user-oid',
tid: 't',
username: 'jane@example',
displayName: 'Jane',
amr: ['pwd'],
roles: [],
},
},
}),
);
const ctx = makeContext(makeRequest({ session: { principal: principalWithPrivileges([]) } }));
// The 403 path goes through audit first; if audit throws, the
// guard surfaces the underlying error rather than the
// ForbiddenException — the caller sees a 500 and the audit
// invariant is preserved.
await expect(guard.canActivate(ctx)).rejects.toThrow('audit_writer denied');
});
describe('legacy-session bridge (sessions persisted before ADR-0025 landed)', () => {
it('synthesises a principal from session.user when session.principal is absent', async () => {
const audit = makeAuditStub();
const guard = new AdminRoleGuard(audit as unknown as AuditWriter);
const ctx = makeContext(
makeRequest({
session: {
user: {
oid: 'user-oid',
tid: 't',
username: 'jane@example',
displayName: 'Jane',
amr: ['pwd', 'mfa'],
roles: [ADMIN_ROLE, 'Other.Role'],
},
},
}),
);
await expect(guard.canActivate(ctx)).resolves.toBe(true);
expect(audit.adminAccessDenied).not.toHaveBeenCalled();
});
it('denies a legacy session whose roles claim carries no Portal.* value', async () => {
const audit = makeAuditStub();
const guard = new AdminRoleGuard(audit as unknown as AuditWriter);
const ctx = makeContext(
makeRequest({
session: {
user: {
oid: 'user-oid',
tid: 't',
username: 'jane@example',
displayName: 'Jane',
amr: ['pwd'],
roles: ['editor', 'auditor'],
},
},
}),
);
await expect(guard.canActivate(ctx)).rejects.toBeInstanceOf(ForbiddenException);
// The legacy bridge filters the raw `roles` claim down to
// `Portal.*` values for the audit row's `rolesHeld` field —
// `editor` and `auditor` are not privileges, so the held
// list is empty.
expect(audit.adminAccessDenied).toHaveBeenCalledWith({
actor: { oid: 'user-oid' },
attemptedRoute: 'GET /api/admin/me',
rolesHeld: [],
});
});
});
});
+25 -17
View File
@@ -7,6 +7,7 @@ import {
} from '@nestjs/common';
import type { Request } from 'express';
import { AuditWriter } from '../audit/audit.service';
import { readSessionPrincipal } from '../auth/principal-extractor';
/**
* Single Entra app role that gates the entire `/api/admin/*` surface
@@ -20,6 +21,11 @@ export const ADMIN_ROLE = 'Portal.Admin';
/**
* `AdminRoleGuard` — enforces ADR-0020's role-based admin gate.
*
* Reads from `principal.privileges` per [ADR-0025 §"Guard surface"](../../../../docs/decisions/0025-authorization-model-privileges-roles-scopes.md)
* — the migration thinned the implementation but kept the
* `@RequireAdmin()` public API and the `admin.access_denied`
* audit event type unchanged.
*
* Contract
* --------
* - **No session → 401.** The user is not authenticated at all; the
@@ -28,19 +34,21 @@ export const ADMIN_ROLE = 'Portal.Admin';
* unauthenticated 401 is normal traffic the absolute-timeout
* middleware would surface anyway.
*
* - **Session but missing `Portal.Admin` role → 403 + audit.** The user
* *is* authenticated; they just are not authorised for admin.
* This is the privilege-escalation attempt audit signal — every
* denial lands in `audit.events` with `outcome=denied`, the
* attempted route as `subject`, and the roles the user did hold
* in the payload. Per ADR-0013 §"Blocking writes": no audit ⇒ no
* action — if the audit write fails, the request fails too
* (consistent with the existing audit call sites in
* `AuthController`).
* - **Session but missing `Portal.Admin` privilege → 403 + audit.**
* The user *is* authenticated; they just are not authorised
* for admin. This is the privilege-escalation attempt audit
* signal — every denial lands in `audit.events` with
* `outcome=denied`, the attempted route as `subject`, and the
* `Portal.*` privileges the principal did hold in the payload's
* `rolesHeld` field (kept named `rolesHeld` for backward
* compatibility with the existing audit shape; the values are
* privileges per ADR-0025's renaming of the axis). Per
* ADR-0013 §"Blocking writes": no audit ⇒ no action.
*
* - **Session with `Portal.Admin` role → pass through.** Downstream
* controllers see `req.session.user` populated and can rely on
* the role check having happened.
* - **Session with `Portal.Admin` privilege → pass through.**
* Downstream controllers see `req.session.user` *and*
* `req.session.principal` populated and can rely on the gate
* having happened.
*/
@Injectable()
export class AdminRoleGuard implements CanActivate {
@@ -48,17 +56,17 @@ export class AdminRoleGuard implements CanActivate {
async canActivate(context: ExecutionContext): Promise<boolean> {
const req = context.switchToHttp().getRequest<Request>();
const user = req.session?.user;
const principal = readSessionPrincipal(req);
if (!user) {
if (principal === null) {
throw new UnauthorizedException();
}
if (!user.roles.includes(ADMIN_ROLE)) {
if (!principal.privileges.includes(ADMIN_ROLE)) {
await this.audit.adminAccessDenied({
actor: { oid: user.oid },
actor: { oid: principal.user.entraOid },
attemptedRoute: `${req.method} ${req.originalUrl}`,
rolesHeld: user.roles,
rolesHeld: [...principal.privileges],
});
throw new ForbiddenException();
}
@@ -3,7 +3,7 @@ import { PrismaService } from 'nestjs-prisma';
import { AdminUsersReader } from './admin-users-reader.service';
interface MockPrisma {
user: {
userDirectoryEntry: {
count: jest.Mock;
findMany: jest.Mock;
};
@@ -14,7 +14,7 @@ function buildPrisma(opts?: { count?: number; items?: unknown[] }): MockPrisma {
const count = jest.fn().mockResolvedValue(opts?.count ?? 0);
const findMany = jest.fn().mockResolvedValue(opts?.items ?? []);
return {
user: { count, findMany },
userDirectoryEntry: { count, findMany },
// The reader calls $transaction with the promises returned by
// `count()` + `findMany()` — the operations have already fired
// by the time $transaction sees them. The mock just resolves
@@ -48,8 +48,8 @@ describe('AdminUsersReader.findUsers', () => {
const reader = await createSubject(prisma);
const page = await reader.findUsers({});
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
expect(prisma.user.count).toHaveBeenCalledTimes(1);
expect(prisma.user.findMany).toHaveBeenCalledTimes(1);
expect(prisma.userDirectoryEntry.count).toHaveBeenCalledTimes(1);
expect(prisma.userDirectoryEntry.findMany).toHaveBeenCalledTimes(1);
expect(page.total).toBe(5);
expect(page.items).toHaveLength(1);
});
@@ -73,7 +73,7 @@ describe('AdminUsersReader.findUsers', () => {
const prisma = buildPrisma();
const reader = await createSubject(prisma);
await reader.findUsers({});
const findManyArgs = prisma.user.findMany.mock.calls[0]?.[0] as {
const findManyArgs = prisma.userDirectoryEntry.findMany.mock.calls[0]?.[0] as {
orderBy: ReadonlyArray<Record<string, string>>;
};
expect(findManyArgs.orderBy).toEqual([{ lastSeenAt: 'desc' }, { oid: 'asc' }]);
@@ -83,7 +83,7 @@ describe('AdminUsersReader.findUsers', () => {
const prisma = buildPrisma();
const reader = await createSubject(prisma);
await reader.findUsers({ username: 'jane' });
const args = prisma.user.findMany.mock.calls[0]?.[0] as {
const args = prisma.userDirectoryEntry.findMany.mock.calls[0]?.[0] as {
where: { username?: { startsWith?: string } };
};
expect(args.where.username).toEqual({ startsWith: 'jane' });
@@ -93,7 +93,7 @@ describe('AdminUsersReader.findUsers', () => {
const prisma = buildPrisma();
const reader = await createSubject(prisma);
await reader.findUsers({ displayName: 'doe' });
const args = prisma.user.findMany.mock.calls[0]?.[0] as {
const args = prisma.userDirectoryEntry.findMany.mock.calls[0]?.[0] as {
where: { displayName?: { contains?: string; mode?: string } };
};
expect(args.where.displayName).toEqual({ contains: 'doe', mode: 'insensitive' });
@@ -106,7 +106,7 @@ describe('AdminUsersReader.findUsers', () => {
lastSeenAtFrom: '2026-05-01T00:00:00.000Z',
lastSeenAtTo: '2026-05-14T23:59:59.999Z',
});
const args = prisma.user.findMany.mock.calls[0]?.[0] as {
const args = prisma.userDirectoryEntry.findMany.mock.calls[0]?.[0] as {
where: { lastSeenAt?: { gte?: Date; lt?: Date } };
};
expect(args.where.lastSeenAt?.gte).toBeInstanceOf(Date);
@@ -119,7 +119,7 @@ describe('AdminUsersReader.findUsers', () => {
const prisma = buildPrisma();
const reader = await createSubject(prisma);
const page = await reader.findUsers({});
const args = prisma.user.findMany.mock.calls[0]?.[0] as {
const args = prisma.userDirectoryEntry.findMany.mock.calls[0]?.[0] as {
take: number;
skip: number;
};
@@ -133,7 +133,7 @@ describe('AdminUsersReader.findUsers', () => {
const prisma = buildPrisma();
const reader = await createSubject(prisma);
await reader.findUsers({ limit: 1000 });
const args = prisma.user.findMany.mock.calls[0]?.[0] as { take: number };
const args = prisma.userDirectoryEntry.findMany.mock.calls[0]?.[0] as { take: number };
expect(args.take).toBe(200);
});
@@ -141,7 +141,7 @@ describe('AdminUsersReader.findUsers', () => {
const prisma = buildPrisma();
const reader = await createSubject(prisma);
await reader.findUsers({});
const args = prisma.user.findMany.mock.calls[0]?.[0] as { where: object };
const args = prisma.userDirectoryEntry.findMany.mock.calls[0]?.[0] as { where: object };
expect(args.where).toEqual({});
});
});
@@ -4,9 +4,9 @@ import { PrismaService } from 'nestjs-prisma';
import { DEFAULT_LIMIT, MAX_LIMIT, type AdminUsersQueryDto } from './users-query.dto';
/**
* SPA-facing projection of a row from `public.users`. Mirrors the
* Prisma model but exposes ISO-string timestamps so the SPA never
* has to know about `Date` serialisation conventions.
* SPA-facing projection of a row from `public.user_directory_entries`.
* Mirrors the Prisma model but exposes ISO-string timestamps so the
* SPA never has to know about `Date` serialisation conventions.
*/
export interface AdminUserDto {
readonly oid: string;
@@ -26,14 +26,15 @@ export interface AdminUsersPage {
}
/**
* `AdminUsersReader` — query side of the `public.users` directory
* per ADR-0020 §"v1 scope — User list (read-only)". Drives the
* `GET /api/admin/users` admin endpoint.
* `AdminUsersReader` — query side of the
* `public.user_directory_entries` directory per ADR-0020 §"v1 scope
* — User list (read-only)". Drives the `GET /api/admin/users` admin
* endpoint.
*
* Uses Prisma's typed client directly — unlike `AuditReader`, no
* `SET LOCAL ROLE` is needed because `public.users` has no
* role-based privilege gate (it's a regular business table; the
* trust boundary is the `@RequireAdmin` guard on the controller).
* `SET LOCAL ROLE` is needed because `public.user_directory_entries`
* has no role-based privilege gate (it's a regular business table;
* the trust boundary is the `@RequireAdmin` guard on the controller).
*
* Default order: `last_seen_at DESC` — the "most recently active"
* sort the admin UI most often wants. Falls back to `oid ASC` as
@@ -55,8 +56,8 @@ export class AdminUsersReader {
// otherwise produce an off-by-one between the count and the
// items.
const [total, items] = await this.prisma.$transaction([
this.prisma.user.count({ where }),
this.prisma.user.findMany({
this.prisma.userDirectoryEntry.count({ where }),
this.prisma.userDirectoryEntry.findMany({
where,
orderBy: [{ lastSeenAt: 'desc' }, { oid: 'asc' }],
take: limit,
@@ -73,8 +74,8 @@ export class AdminUsersReader {
}
}
function buildWhere(filters: AdminUsersQueryDto): Prisma.UserWhereInput {
const where: Prisma.UserWhereInput = {};
function buildWhere(filters: AdminUsersQueryDto): Prisma.UserDirectoryEntryWhereInput {
const where: Prisma.UserDirectoryEntryWhereInput = {};
if (filters.username !== undefined) {
where.username = { startsWith: filters.username };
}
+13 -3
View File
@@ -1,5 +1,6 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { RedisModule } from '../redis/redis.module';
import { AdminAuditController } from './admin-audit.controller';
import { AdminAuthController } from './admin-auth.controller';
import { AdminController } from './admin.controller';
@@ -7,6 +8,9 @@ import { AdminRoleGuard } from './admin-role.guard';
import { AdminUsersController } from './admin-users.controller';
import { AdminUsersReader } from './admin-users-reader.service';
import { AuditReader } from './audit-reader.service';
import { AuditStatsReader } from './audit-stats.service';
import { UserScopesController } from './user-scopes.controller';
import { UserScopesService } from './user-scopes.service';
/**
* `AdminModule` — root of the `/api/admin/*` surface per ADR-0020.
@@ -31,8 +35,14 @@ import { AuditReader } from './audit-reader.service';
* by `AuditModule`, so no extra import is needed for it.
*/
@Module({
imports: [AuthModule],
controllers: [AdminController, AdminAuthController, AdminAuditController, AdminUsersController],
providers: [AdminRoleGuard, AuditReader, AdminUsersReader],
imports: [AuthModule, RedisModule],
controllers: [
AdminController,
AdminAuthController,
AdminAuditController,
AdminUsersController,
UserScopesController,
],
providers: [AdminRoleGuard, AuditReader, AuditStatsReader, AdminUsersReader, UserScopesService],
})
export class AdminModule {}
@@ -0,0 +1,57 @@
import { Type } from 'class-transformer';
import { IsEnum, IsISO8601, IsOptional, IsString, MaxLength } from 'class-validator';
/**
* Query parameters for `GET /api/admin/audit/stats`. Mirrors the
* filter shape of {@link AdminAuditQueryDto} **minus pagination**:
* the stats endpoint always aggregates the whole filtered set, so
* `limit` / `offset` carry no meaning.
*
* Per the chantier brief, the endpoint respects filters strictly —
* an unfiltered call aggregates across the full audit retention
* (365 days by default per ADR-0013). The 5-minute Redis cache
* absorbs the cost of a heavy unfiltered query if the same shape
* lands twice in quick succession.
*/
/** Subset of `AuditAudience` accepted by the filter — matches the list endpoint. */
const AUDIT_AUDIENCES = ['workforce', 'customer'] as const;
type AuditAudienceFilter = (typeof AUDIT_AUDIENCES)[number];
/** Subset of `AuditOutcome` accepted by the filter. */
const AUDIT_OUTCOMES = ['success', 'failure', 'denied'] as const;
type AuditOutcomeFilter = (typeof AUDIT_OUTCOMES)[number];
export class AdminAuditStatsQueryDto {
@IsOptional()
@IsString()
@MaxLength(128)
eventType?: string;
@IsOptional()
@IsString()
@MaxLength(128)
actorIdHash?: string;
@IsOptional()
@IsEnum(AUDIT_AUDIENCES)
audience?: AuditAudienceFilter;
@IsOptional()
@IsEnum(AUDIT_OUTCOMES)
outcome?: AuditOutcomeFilter;
@IsOptional()
@IsString()
@MaxLength(128)
subjectPrefix?: string;
@IsOptional()
@IsISO8601()
createdAtFrom?: string;
@IsOptional()
@IsISO8601()
@Type(() => String)
createdAtTo?: string;
}
@@ -0,0 +1,199 @@
import { Test } from '@nestjs/testing';
import { PrismaService } from 'nestjs-prisma';
import { REDIS_CLIENT } from '../redis/redis.token';
import type { AdminAuditStatsQueryDto } from './audit-stats.dto';
import { AuditStatsReader } from './audit-stats.service';
interface MockTx {
$executeRawUnsafe: jest.Mock;
$queryRawUnsafe: jest.Mock;
}
interface MockPrisma {
$transaction: jest.Mock;
tx: MockTx;
}
interface MockRedis {
get: jest.Mock;
set: jest.Mock;
}
function buildPrisma(opts?: {
dailyRows?: Array<{ day: Date; count: bigint }>;
outcomeRows?: Array<{ outcome: string; count: bigint }>;
eventTypeRows?: Array<{ day: Date; event_type: string; count: bigint }>;
}): MockPrisma {
const tx: MockTx = {
$executeRawUnsafe: jest.fn().mockResolvedValue(0),
$queryRawUnsafe: jest
.fn()
// Order matches the service: dailyVolume → outcomeBreakdown → eventTypeByDay.
.mockResolvedValueOnce(opts?.dailyRows ?? [])
.mockResolvedValueOnce(opts?.outcomeRows ?? [])
.mockResolvedValueOnce(opts?.eventTypeRows ?? []),
};
return {
tx,
$transaction: jest.fn(async (fn: (tx: MockTx) => Promise<unknown>) => fn(tx)),
};
}
function buildRedis(cached?: string): MockRedis {
return {
get: jest.fn().mockResolvedValue(cached ?? null),
set: jest.fn().mockResolvedValue('OK'),
};
}
async function createSubject(prisma: MockPrisma, redis: MockRedis): Promise<AuditStatsReader> {
const moduleRef = await Test.createTestingModule({
providers: [
AuditStatsReader,
{ provide: PrismaService, useValue: prisma },
{ provide: REDIS_CLIENT, useValue: redis },
],
}).compile();
return moduleRef.get(AuditStatsReader);
}
describe('AuditStatsReader', () => {
it('returns the cached payload when Redis has a hit (no DB call)', async () => {
const cached = JSON.stringify({
dailyVolume: [{ day: '2026-05-13', count: 12 }],
outcomeBreakdown: [{ outcome: 'success', count: 12 }],
eventTypeByDay: [{ day: '2026-05-13', eventType: 'auth.sign_in', count: 12 }],
total: 12,
});
const prisma = buildPrisma();
const redis = buildRedis(cached);
const reader = await createSubject(prisma, redis);
const result = await reader.getStats({});
expect(result.total).toBe(12);
expect(prisma.$transaction).not.toHaveBeenCalled();
expect(prisma.tx.$queryRawUnsafe).not.toHaveBeenCalled();
expect(redis.set).not.toHaveBeenCalled();
});
it('locks the transaction to audit_reader before running GROUP BY queries', async () => {
const prisma = buildPrisma();
const redis = buildRedis();
const reader = await createSubject(prisma, redis);
await reader.getStats({});
const setRoleCalls = prisma.tx.$executeRawUnsafe.mock.calls;
expect(setRoleCalls[0]?.[0]).toBe('SET LOCAL ROLE audit_reader');
});
it('issues exactly three GROUP BY queries (daily / outcome / by-event-type)', async () => {
const prisma = buildPrisma();
const redis = buildRedis();
const reader = await createSubject(prisma, redis);
await reader.getStats({});
const queries = prisma.tx.$queryRawUnsafe.mock.calls;
expect(queries).toHaveLength(3);
expect(queries[0]?.[0]).toMatch(
/date_trunc\('day', created_at\)::date AS day[\s\S]*GROUP BY day/,
);
expect(queries[1]?.[0]).toMatch(/outcome::text AS outcome[\s\S]*GROUP BY outcome/);
expect(queries[2]?.[0]).toMatch(/GROUP BY day, event_type/);
});
it('projects rows into the SPA-facing shape (ISO day, numeric count, computed total)', async () => {
const prisma = buildPrisma({
dailyRows: [
{ day: new Date('2026-05-13T00:00:00.000Z'), count: 8n },
{ day: new Date('2026-05-14T00:00:00.000Z'), count: 12n },
],
outcomeRows: [
{ outcome: 'success', count: 18n },
{ outcome: 'failure', count: 2n },
],
eventTypeRows: [
{ day: new Date('2026-05-13T00:00:00.000Z'), event_type: 'auth.sign_in', count: 8n },
],
});
const redis = buildRedis();
const reader = await createSubject(prisma, redis);
const result = await reader.getStats({});
expect(result.dailyVolume).toEqual([
{ day: '2026-05-13', count: 8 },
{ day: '2026-05-14', count: 12 },
]);
expect(result.outcomeBreakdown).toEqual([
{ outcome: 'success', count: 18 },
{ outcome: 'failure', count: 2 },
]);
expect(result.eventTypeByDay).toEqual([
{ day: '2026-05-13', eventType: 'auth.sign_in', count: 8 },
]);
expect(result.total).toBe(20);
});
it('writes the result to Redis with the 5-minute TTL on cache miss', async () => {
const prisma = buildPrisma();
const redis = buildRedis();
const reader = await createSubject(prisma, redis);
await reader.getStats({ outcome: 'denied' });
expect(redis.set).toHaveBeenCalledTimes(1);
const [key, payload, mode, ttl] = redis.set.mock.calls[0] as [string, string, string, number];
expect(key).toMatch(/^audit:stats:[0-9a-f]{16}$/);
expect(JSON.parse(payload)).toMatchObject({ total: 0 });
expect(mode).toBe('EX');
expect(ttl).toBe(300);
});
it('uses the same cache key for identical filter shapes regardless of key order', async () => {
const prisma1 = buildPrisma();
const redis1 = buildRedis();
const reader1 = await createSubject(prisma1, redis1);
const prisma2 = buildPrisma();
const redis2 = buildRedis();
const reader2 = await createSubject(prisma2, redis2);
const a: AdminAuditStatsQueryDto = { eventType: 'auth.sign_in', outcome: 'success' };
const b: AdminAuditStatsQueryDto = { outcome: 'success', eventType: 'auth.sign_in' };
await reader1.getStats(a);
await reader2.getStats(b);
const key1 = (redis1.get.mock.calls[0] as [string])[0];
const key2 = (redis2.get.mock.calls[0] as [string])[0];
expect(key1).toBe(key2);
});
it('passes filter values as positional parameters (no string concatenation)', async () => {
const prisma = buildPrisma();
const redis = buildRedis();
const reader = await createSubject(prisma, redis);
await reader.getStats({ eventType: 'auth.sign_in', outcome: 'denied' });
// All three queries should carry the same `params` tail —
// event_type then outcome cast to enum. Verify on the first one.
const [, ...params] = prisma.tx.$queryRawUnsafe.mock.calls[0] as [string, ...unknown[]];
expect(params).toEqual(['auth.sign_in', 'denied']);
});
it('survives a Redis-write failure on cache miss (best-effort cache, response still flows)', async () => {
const prisma = buildPrisma({
dailyRows: [{ day: new Date('2026-05-13T00:00:00.000Z'), count: 5n }],
});
const redis = buildRedis();
redis.set.mockRejectedValueOnce(new Error('redis unavailable'));
const reader = await createSubject(prisma, redis);
const result = await reader.getStats({});
expect(result.total).toBe(5);
});
});
@@ -0,0 +1,226 @@
import { createHash } from 'node:crypto';
import { Inject, Injectable } from '@nestjs/common';
import { PrismaService } from 'nestjs-prisma';
import { REDIS_CLIENT, type Redis } from '../redis/redis.token';
import type { AdminAuditStatsQueryDto } from './audit-stats.dto';
/**
* Aggregated audit-event statistics — the shape consumed by the
* portal-admin `/audit` page's "Charts" tab (PR 2 of the stats
* chantier).
*
* Each axis matches one of the three charts:
* - `dailyVolume` → bar chart of events per UTC day,
* - `outcomeBreakdown` → donut of success/failure/denied counts,
* - `eventTypeByDay` → stacked-bar of events per day, by type.
*
* Returned to the SPA as plain JSON; consumed by the shared chart
* components without further transformation.
*/
export interface AdminAuditStats {
readonly dailyVolume: ReadonlyArray<{ day: string; count: number }>;
readonly outcomeBreakdown: ReadonlyArray<{ outcome: string; count: number }>;
readonly eventTypeByDay: ReadonlyArray<{ day: string; eventType: string; count: number }>;
/** Sum of `dailyVolume.count` — drives the donut centre label. */
readonly total: number;
}
/**
* Redis cache TTL for stats queries. Audit rows are append-only so
* past aggregations are stable, but new events are continuously
* inserted — a 5-minute TTL is the chosen compromise (per the
* chantier brief): admins see at most 5-minute-stale aggregations
* which is fine for "approximate dashboard" usage; not appropriate
* for "did the last event land yet" debugging (use the list
* endpoint for that).
*/
const STATS_CACHE_TTL_SECONDS = 300;
/** Key prefix in Redis so cache keys are scoped under one root. */
const CACHE_KEY_PREFIX = 'audit:stats:';
/**
* `AuditStatsReader` — server-side audit aggregations behind a
* Redis cache.
*
* Contract mirrors `AuditReader` for consistency:
* - Every query runs in a transaction that opens with
* `SET LOCAL ROLE audit_reader`, so SELECT is the only DB
* verb that can succeed even if the BFF's connection were
* otherwise privileged.
* - Parameterised SQL only; filter values flow through positional
* parameters, never concatenated into the SQL string.
*
* Cache key is a SHA-256 of the canonical (sorted-keys) JSON
* representation of the filter object — deterministic across
* processes, immune to JSON key ordering.
*/
@Injectable()
export class AuditStatsReader {
constructor(
private readonly prisma: PrismaService,
@Inject(REDIS_CLIENT) private readonly redis: Redis,
) {}
async getStats(filters: AdminAuditStatsQueryDto): Promise<AdminAuditStats> {
const cacheKey = `${CACHE_KEY_PREFIX}${hashFilters(filters)}`;
const cached = await this.redis.get(cacheKey);
if (cached) {
return JSON.parse(cached) as AdminAuditStats;
}
const stats = await this.computeStats(filters);
// Best-effort cache write. If Redis is briefly unavailable, we
// serve the live result and the next call rebuilds the cache.
// The DB read already happened — don't fail the response on a
// cache-write blip.
try {
await this.redis.set(cacheKey, JSON.stringify(stats), 'EX', STATS_CACHE_TTL_SECONDS);
} catch {
// swallow — see comment above
}
return stats;
}
private async computeStats(filters: AdminAuditStatsQueryDto): Promise<AdminAuditStats> {
const { whereSql, params } = buildWhere(filters);
return this.prisma.$transaction(async (tx) => {
await tx.$executeRawUnsafe(`SET LOCAL ROLE audit_reader`);
// Three GROUP BY queries, each scoped by the same WHERE clause.
// `date_trunc('day', ...)::date` yields a Postgres date (no
// time, no zone) that serializes to `YYYY-MM-DD` directly —
// matches the SPA's chart bucket convention.
const dailyRows = await tx.$queryRawUnsafe<Array<{ day: Date; count: bigint }>>(
`SELECT date_trunc('day', created_at)::date AS day,
COUNT(*)::bigint AS count
FROM "audit"."events"
${whereSql}
GROUP BY day
ORDER BY day`,
...params,
);
const outcomeRows = await tx.$queryRawUnsafe<Array<{ outcome: string; count: bigint }>>(
`SELECT outcome::text AS outcome,
COUNT(*)::bigint AS count
FROM "audit"."events"
${whereSql}
GROUP BY outcome
ORDER BY outcome`,
...params,
);
const eventTypeRows = await tx.$queryRawUnsafe<
Array<{ day: Date; event_type: string; count: bigint }>
>(
`SELECT date_trunc('day', created_at)::date AS day,
event_type,
COUNT(*)::bigint AS count
FROM "audit"."events"
${whereSql}
GROUP BY day, event_type
ORDER BY day, event_type`,
...params,
);
const dailyVolume = dailyRows.map((r) => ({
day: toIsoDay(r.day),
count: Number(r.count),
}));
const total = dailyVolume.reduce((acc, r) => acc + r.count, 0);
return {
dailyVolume,
outcomeBreakdown: outcomeRows.map((r) => ({
outcome: r.outcome,
count: Number(r.count),
})),
eventTypeByDay: eventTypeRows.map((r) => ({
day: toIsoDay(r.day),
eventType: r.event_type,
count: Number(r.count),
})),
total,
};
});
}
}
/**
* Deterministic hash of the filter object. Sorted keys + JSON
* stringify so two requests with the same filters land on the same
* cache key regardless of ordering or whitespace. Truncated to
* 16 hex chars (64 bits) — collision probability negligible at
* realistic admin-side query volumes.
*/
function hashFilters(filters: AdminAuditStatsQueryDto): string {
const sortedKeys = Object.keys(filters).sort();
const canonical = sortedKeys
.map((k) => `${k}=${(filters as Record<string, unknown>)[k] ?? ''}`)
.join('&');
return createHash('sha256').update(canonical).digest('hex').slice(0, 16);
}
interface BuiltWhere {
readonly whereSql: string;
readonly params: unknown[];
}
/**
* Same shape as `AuditReader`'s `buildWhere` — kept in sync by
* convention. Diverges only in that the stats DTO doesn't carry
* `limit` / `offset`, so those clauses don't appear here.
*/
function buildWhere(filters: AdminAuditStatsQueryDto): BuiltWhere {
const clauses: string[] = [];
const params: unknown[] = [];
if (filters.eventType !== undefined) {
params.push(filters.eventType);
clauses.push(`event_type = $${params.length}`);
}
if (filters.actorIdHash !== undefined) {
params.push(filters.actorIdHash);
clauses.push(`actor_id_hash = $${params.length}`);
}
if (filters.audience !== undefined) {
params.push(filters.audience);
clauses.push(`audience = $${params.length}::"audit"."AuditAudience"`);
}
if (filters.outcome !== undefined) {
params.push(filters.outcome);
clauses.push(`outcome = $${params.length}::"audit"."AuditOutcome"`);
}
if (filters.subjectPrefix !== undefined) {
params.push(`${escapeLikeLiteral(filters.subjectPrefix)}%`);
clauses.push(`subject LIKE $${params.length} ESCAPE '\\'`);
}
if (filters.createdAtFrom !== undefined) {
params.push(new Date(filters.createdAtFrom));
clauses.push(`created_at >= $${params.length}`);
}
if (filters.createdAtTo !== undefined) {
params.push(new Date(filters.createdAtTo));
clauses.push(`created_at < $${params.length}`);
}
const whereSql = clauses.length > 0 ? `WHERE ${clauses.join(' AND ')}` : '';
return { whereSql, params };
}
function escapeLikeLiteral(raw: string): string {
return raw.replace(/\\/g, '\\\\').replace(/%/g, '\\%').replace(/_/g, '\\_');
}
/**
* `date_trunc('day', ...)::date` round-trips through node-postgres
* as a JS Date at UTC midnight. Slice the ISO string back to
* `YYYY-MM-DD` so the SPA gets a stable bucket key — `Date#toJSON`
* would otherwise serialise the full ISO timestamp with the local
* timezone offset, which is not what the chart x-axis wants.
*/
function toIsoDay(d: Date): string {
return d.toISOString().slice(0, 10);
}
@@ -0,0 +1,132 @@
import type { Request } from 'express';
import type { AuditWriter } from '../audit/audit.service';
import { UserScopesController } from './user-scopes.controller';
import type { UserScopesService } from './user-scopes.service';
function makeFixture(): {
controller: UserScopesController;
service: { list: jest.Mock; grant: jest.Mock; revoke: jest.Mock };
audit: { adminScopeGranted: jest.Mock; adminScopeRevoked: jest.Mock };
} {
const service = {
list: jest.fn(),
grant: jest.fn(),
revoke: jest.fn(),
};
const audit = {
adminScopeGranted: jest.fn().mockResolvedValue(undefined),
adminScopeRevoked: jest.fn().mockResolvedValue(undefined),
};
const controller = new UserScopesController(
service as unknown as UserScopesService,
audit as unknown as AuditWriter,
);
return { controller, service, audit };
}
function makeReq(actorOid: string | undefined): Request {
return {
session: actorOid === undefined ? {} : { user: { oid: actorOid } },
} as unknown as Request;
}
describe('UserScopesController.list', () => {
it('returns the service payload verbatim — no audit on reads', async () => {
const { controller, service, audit } = makeFixture();
service.list.mockResolvedValue({
user: { oid: 'target-oid', userId: 'u', displayName: 'Jane', email: 'j@e' },
scopes: [],
});
const page = await controller.list('target-oid');
expect(service.list).toHaveBeenCalledWith('target-oid');
expect(page.user.oid).toBe('target-oid');
expect(audit.adminScopeGranted).not.toHaveBeenCalled();
expect(audit.adminScopeRevoked).not.toHaveBeenCalled();
});
});
describe('UserScopesController.grant', () => {
it('creates the scope and emits admin.scope_granted blocking audit', async () => {
const { controller, service, audit } = makeFixture();
service.grant.mockResolvedValue({
user: { oid: 'target-oid', userId: 'u', displayName: 'Jane', email: 'j@e' },
scope: {
id: 'new-scope',
kind: 'etablissement',
value: '0330800013',
source: 'admin-ui',
createdAt: '2026-05-26T10:00:00.000Z',
expiresAt: null,
},
});
const result = await controller.grant(makeReq('admin-oid'), 'target-oid', {
kind: 'etablissement',
value: '0330800013',
});
expect(service.grant).toHaveBeenCalledWith('target-oid', {
kind: 'etablissement',
value: '0330800013',
});
expect(audit.adminScopeGranted).toHaveBeenCalledWith({
actor: { oid: 'admin-oid' },
target: { oid: 'target-oid' },
scopeId: 'new-scope',
kind: 'etablissement',
value: '0330800013',
expiresAt: null,
});
expect(result.id).toBe('new-scope');
});
it('propagates service errors without writing the audit row', async () => {
const { controller, service, audit } = makeFixture();
service.grant.mockRejectedValue(new Error('boom'));
await expect(
controller.grant(makeReq('admin-oid'), 'target-oid', { kind: 'self' }),
).rejects.toThrow('boom');
expect(audit.adminScopeGranted).not.toHaveBeenCalled();
});
it('skips audit when the session has no actor (defensive — guard guarantees it)', async () => {
const { controller, service, audit } = makeFixture();
service.grant.mockResolvedValue({
user: { oid: 'target-oid', userId: 'u', displayName: 'Jane', email: 'j@e' },
scope: {
id: 'new-scope',
kind: 'self',
value: '',
source: 'admin-ui',
createdAt: '2026-05-26T10:00:00.000Z',
expiresAt: null,
},
});
await controller.grant(makeReq(undefined), 'target-oid', { kind: 'self' });
expect(audit.adminScopeGranted).not.toHaveBeenCalled();
});
});
describe('UserScopesController.revoke', () => {
it('deletes and emits admin.scope_revoked', async () => {
const { controller, service, audit } = makeFixture();
service.revoke.mockResolvedValue({
user: { oid: 'target-oid', userId: 'u', displayName: 'Jane', email: 'j@e' },
revoked: { kind: 'delegation', value: '33' },
});
await controller.revoke(makeReq('admin-oid'), 'target-oid', 'scope-1');
expect(service.revoke).toHaveBeenCalledWith('target-oid', 'scope-1');
expect(audit.adminScopeRevoked).toHaveBeenCalledWith({
actor: { oid: 'admin-oid' },
target: { oid: 'target-oid' },
scopeId: 'scope-1',
kind: 'delegation',
value: '33',
});
});
it('propagates service NotFound without writing the audit row', async () => {
const { controller, service, audit } = makeFixture();
service.revoke.mockRejectedValue(new Error('not found'));
await expect(controller.revoke(makeReq('admin-oid'), 'target-oid', 'ghost')).rejects.toThrow();
expect(audit.adminScopeRevoked).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,93 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
Post,
Req,
} from '@nestjs/common';
import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import type { Request } from 'express';
import { AuditWriter } from '../audit/audit.service';
import { RequireAdmin } from './require-admin.decorator';
import { GrantUserScopeDto, type UserScopeDto, type UserScopesPageDto } from './user-scopes.dto';
import { UserScopesService } from './user-scopes.service';
/**
* `/api/admin/users/:oid/scopes` per ADR-0026 PR 2b — admin
* scope-management screen for an existing portal User. Each write
* emits a typed audit row (blocking per ADR-0013); reads are NOT
* audited (low signal, high noise).
*
* The `:oid` is the affected user's Entra `oid` — same identifier
* the v1 user-directory list (ADR-0020) uses, so the SPA can link
* directly from the existing `/admin/users` rows.
*/
@ApiTags('admin (user scopes)')
@ApiCookieAuth('portal_admin_session')
@Controller('admin/users/:oid/scopes')
@RequireAdmin()
export class UserScopesController {
constructor(
private readonly userScopes: UserScopesService,
private readonly audit: AuditWriter,
) {}
@ApiOperation({
summary: "List a user's scopes (no audit — read).",
})
@Get()
async list(@Param('oid') oid: string): Promise<UserScopesPageDto> {
return this.userScopes.list(oid);
}
@ApiOperation({
summary: 'Grant a scope to a user — emits `admin.scope_granted` (blocking).',
})
@Post()
async grant(
@Req() req: Request,
@Param('oid') oid: string,
@Body() body: GrantUserScopeDto,
): Promise<UserScopeDto> {
const { user, scope } = await this.userScopes.grant(oid, body);
const actorOid = req.session.user?.oid;
if (actorOid !== undefined) {
await this.audit.adminScopeGranted({
actor: { oid: actorOid },
target: { oid: user.oid },
scopeId: scope.id,
kind: scope.kind,
value: scope.value,
expiresAt: scope.expiresAt,
});
}
return scope;
}
@ApiOperation({
summary: 'Revoke a scope — emits `admin.scope_revoked` (blocking).',
})
@Delete(':scopeId')
@HttpCode(HttpStatus.NO_CONTENT)
async revoke(
@Req() req: Request,
@Param('oid') oid: string,
@Param('scopeId') scopeId: string,
): Promise<void> {
const { user, revoked } = await this.userScopes.revoke(oid, scopeId);
const actorOid = req.session.user?.oid;
if (actorOid !== undefined) {
await this.audit.adminScopeRevoked({
actor: { oid: actorOid },
target: { oid: user.oid },
scopeId,
kind: revoked.kind,
value: revoked.value,
});
}
}
}
@@ -0,0 +1,68 @@
import { IsIn, IsISO8601, IsOptional, IsString, MaxLength } from 'class-validator';
import { SCOPE_KINDS, type ScopeKind } from 'shared-auth';
/**
* Payload accepted by `POST /api/admin/users/:oid/scopes`. Kind is
* validated against the closed catalogue; value validation against
* `Structure.code` / `Delegation.code` / `Region.code` (ADR-0027 PR 1)
* happens server-side in `UserScopesService.grant`. v1 has no
* partial-update; a re-grant with a different `expiresAt` requires
* delete + add.
*/
export class GrantUserScopeDto {
@IsString()
@IsIn(SCOPE_KINDS as ReadonlyArray<string>)
kind!: ScopeKind;
/**
* Value tied to `kind`. Required and non-empty for value-bearing
* kinds (`etablissement` / `delegation` / `region`); must be empty
* (or omitted — coerced to '' in the service) for valueless kinds
* (`self` / `siege` / `unrestricted`). The shape match is handled
* by `UserScopesService.grant` so the error surface is one place.
*/
@IsString()
@MaxLength(64)
@IsOptional()
value?: string;
/**
* Optional ISO-8601 expiry. When set and past, `PrismaScopeResolver`
* filters the row out at sign-in (`expiresAt IS NULL OR > NOW()`)
* per ADR-0026 PR 2a — supports interim-director-for-two-months
* patterns without a row deletion.
*/
@IsISO8601()
@IsOptional()
expiresAt?: string;
}
/**
* Single row in `GET /api/admin/users/:oid/scopes`. Mirrors
* `UserScope` columns; timestamps as ISO strings so the SPA never
* has to round-trip `Date` instances.
*/
export interface UserScopeDto {
readonly id: string;
readonly kind: string;
readonly value: string;
readonly source: string;
readonly createdAt: string;
readonly expiresAt: string | null;
}
/**
* Response of `GET /api/admin/users/:oid/scopes`. Includes a
* `user` envelope so the SPA can confirm the right target user
* without a second round-trip; `displayName` + `email` are pulled
* from the linked `Person` row.
*/
export interface UserScopesPageDto {
readonly user: {
readonly oid: string;
readonly userId: string;
readonly displayName: string;
readonly email: string | null;
};
readonly scopes: ReadonlyArray<UserScopeDto>;
}
@@ -0,0 +1,311 @@
import { BadRequestException, NotFoundException } from '@nestjs/common';
import type { Logger } from 'nestjs-pino';
import type { PrismaService } from 'nestjs-prisma';
import { UserScopesService } from './user-scopes.service';
interface PrismaStub {
user: { findUnique: jest.Mock };
userScope: {
findMany: jest.Mock;
findUnique: jest.Mock;
create: jest.Mock;
delete: jest.Mock;
};
structure: { findUnique: jest.Mock };
delegation: { findUnique: jest.Mock };
region: { findUnique: jest.Mock };
}
function makeLogger() {
return { log: jest.fn(), warn: jest.fn(), error: jest.fn() } as unknown as Logger & {
log: jest.Mock;
warn: jest.Mock;
error: jest.Mock;
};
}
function makePrisma(overrides?: Partial<PrismaStub>): PrismaStub {
return {
user: { findUnique: jest.fn().mockResolvedValue(null), ...overrides?.user },
userScope: {
findMany: jest.fn().mockResolvedValue([]),
findUnique: jest.fn().mockResolvedValue(null),
create: jest.fn(),
delete: jest.fn().mockResolvedValue(undefined),
...overrides?.userScope,
},
structure: { findUnique: jest.fn().mockResolvedValue(null), ...overrides?.structure },
delegation: { findUnique: jest.fn().mockResolvedValue(null), ...overrides?.delegation },
region: { findUnique: jest.fn().mockResolvedValue(null), ...overrides?.region },
};
}
function makeSubject(overrides?: Partial<PrismaStub>): {
service: UserScopesService;
prisma: PrismaStub;
logger: ReturnType<typeof makeLogger>;
} {
const prisma = makePrisma(overrides);
const logger = makeLogger();
const service = new UserScopesService(prisma as unknown as PrismaService, logger);
return { service, prisma, logger };
}
const FOUND_USER = {
id: 'user-uuid-1',
person: { firstName: 'Jane', lastName: 'Doe', email: 'jane@apf.example' },
};
describe('UserScopesService.resolveUserByOid', () => {
it('returns the User + composed displayName when found', async () => {
const { service } = makeSubject({
user: { findUnique: jest.fn().mockResolvedValue(FOUND_USER) },
});
const result = await service.resolveUserByOid('entra-oid');
expect(result).toEqual({
oid: 'entra-oid',
userId: 'user-uuid-1',
displayName: 'Jane Doe',
email: 'jane@apf.example',
});
});
it('throws NotFoundException when the User does not exist', async () => {
const { service } = makeSubject();
await expect(service.resolveUserByOid('ghost-oid')).rejects.toBeInstanceOf(NotFoundException);
});
it('falls back to "(unnamed)" when both firstName and lastName are empty', async () => {
const { service } = makeSubject({
user: {
findUnique: jest.fn().mockResolvedValue({
id: 'u',
person: { firstName: '', lastName: '', email: null },
}),
},
});
const result = await service.resolveUserByOid('o');
expect(result.displayName).toBe('(unnamed)');
expect(result.email).toBeNull();
});
});
describe('UserScopesService.list', () => {
it('returns scopes ordered by kind then value with ISO timestamps', async () => {
const { service, prisma } = makeSubject({
user: { findUnique: jest.fn().mockResolvedValue(FOUND_USER) },
userScope: {
findMany: jest.fn().mockResolvedValue([
{
id: 's1',
kind: 'etablissement',
value: '0330800013',
source: 'seed',
createdAt: new Date('2026-05-26T10:00:00.000Z'),
expiresAt: null,
},
]),
findUnique: jest.fn(),
create: jest.fn(),
delete: jest.fn(),
},
});
const result = await service.list('entra-oid');
expect(prisma.userScope.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { userId: 'user-uuid-1' },
orderBy: [{ kind: 'asc' }, { value: 'asc' }],
}),
);
expect(result.scopes).toEqual([
{
id: 's1',
kind: 'etablissement',
value: '0330800013',
source: 'seed',
createdAt: '2026-05-26T10:00:00.000Z',
expiresAt: null,
},
]);
});
});
describe('UserScopesService.grant — value-bearing kinds', () => {
it('creates the row when the etablissement value matches an existing Structure', async () => {
const { service, prisma } = makeSubject({
user: { findUnique: jest.fn().mockResolvedValue(FOUND_USER) },
structure: { findUnique: jest.fn().mockResolvedValue({ code: '0330800013' }) },
userScope: {
findMany: jest.fn(),
findUnique: jest.fn(),
delete: jest.fn(),
create: jest.fn().mockResolvedValue({
id: 'new-scope',
kind: 'etablissement',
value: '0330800013',
source: 'admin-ui',
createdAt: new Date('2026-05-26T10:00:00.000Z'),
expiresAt: null,
}),
},
});
const out = await service.grant('entra-oid', {
kind: 'etablissement',
value: '0330800013',
});
expect(prisma.structure.findUnique).toHaveBeenCalledWith(
expect.objectContaining({ where: { code: '0330800013' } }),
);
expect(prisma.userScope.create).toHaveBeenCalledWith({
data: {
userId: 'user-uuid-1',
kind: 'etablissement',
value: '0330800013',
source: 'admin-ui',
expiresAt: null,
},
select: expect.any(Object),
});
expect(out.scope.kind).toBe('etablissement');
expect(out.scope.value).toBe('0330800013');
expect(out.scope.source).toBe('admin-ui');
});
it('rejects when the value does not match any Structure row', async () => {
const { service } = makeSubject({
user: { findUnique: jest.fn().mockResolvedValue(FOUND_USER) },
structure: { findUnique: jest.fn().mockResolvedValue(null) },
});
await expect(
service.grant('entra-oid', { kind: 'etablissement', value: 'bogus' }),
).rejects.toBeInstanceOf(BadRequestException);
});
it('rejects when the value is empty for a value-bearing kind', async () => {
const { service } = makeSubject({
user: { findUnique: jest.fn().mockResolvedValue(FOUND_USER) },
});
await expect(
service.grant('entra-oid', { kind: 'delegation', value: '' }),
).rejects.toBeInstanceOf(BadRequestException);
});
it('validates delegation values against the Delegation table', async () => {
const { service, prisma } = makeSubject({
user: { findUnique: jest.fn().mockResolvedValue(FOUND_USER) },
delegation: { findUnique: jest.fn().mockResolvedValue({ code: '33' }) },
userScope: {
findMany: jest.fn(),
findUnique: jest.fn(),
delete: jest.fn(),
create: jest.fn().mockResolvedValue({
id: 's',
kind: 'delegation',
value: '33',
source: 'admin-ui',
createdAt: new Date(),
expiresAt: null,
}),
},
});
await service.grant('entra-oid', { kind: 'delegation', value: '33' });
expect(prisma.delegation.findUnique).toHaveBeenCalledWith(
expect.objectContaining({ where: { code: '33' } }),
);
});
});
describe('UserScopesService.grant — valueless kinds', () => {
it('writes value="" for unrestricted and rejects non-empty values', async () => {
const { service, prisma } = makeSubject({
user: { findUnique: jest.fn().mockResolvedValue(FOUND_USER) },
userScope: {
findMany: jest.fn(),
findUnique: jest.fn(),
delete: jest.fn(),
create: jest.fn().mockResolvedValue({
id: 's',
kind: 'unrestricted',
value: '',
source: 'admin-ui',
createdAt: new Date(),
expiresAt: null,
}),
},
});
await service.grant('entra-oid', { kind: 'unrestricted' });
expect(prisma.userScope.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ value: '' }),
}),
);
await expect(
service.grant('entra-oid', { kind: 'unrestricted', value: 'something' }),
).rejects.toBeInstanceOf(BadRequestException);
});
});
describe('UserScopesService.grant — duplicate detection', () => {
it('translates Prisma P2002 into a 400 with a friendly message', async () => {
const { service } = makeSubject({
user: { findUnique: jest.fn().mockResolvedValue(FOUND_USER) },
structure: { findUnique: jest.fn().mockResolvedValue({ code: '0330800013' }) },
userScope: {
findMany: jest.fn(),
findUnique: jest.fn(),
delete: jest.fn(),
create: jest.fn().mockRejectedValue(Object.assign(new Error('unique'), { code: 'P2002' })),
},
});
await expect(
service.grant('entra-oid', { kind: 'etablissement', value: '0330800013' }),
).rejects.toBeInstanceOf(BadRequestException);
});
});
describe('UserScopesService.revoke', () => {
it('deletes the row and returns the resolved tuple for audit', async () => {
const { service, prisma } = makeSubject({
user: { findUnique: jest.fn().mockResolvedValue(FOUND_USER) },
userScope: {
findMany: jest.fn(),
findUnique: jest.fn().mockResolvedValue({
id: 's1',
userId: 'user-uuid-1',
kind: 'delegation',
value: '33',
}),
create: jest.fn(),
delete: jest.fn().mockResolvedValue(undefined),
},
});
const out = await service.revoke('entra-oid', 's1');
expect(prisma.userScope.delete).toHaveBeenCalledWith({ where: { id: 's1' } });
expect(out.revoked).toEqual({ kind: 'delegation', value: '33' });
});
it('throws NotFoundException when the scope belongs to a different user', async () => {
const { service } = makeSubject({
user: { findUnique: jest.fn().mockResolvedValue(FOUND_USER) },
userScope: {
findMany: jest.fn(),
findUnique: jest.fn().mockResolvedValue({
id: 's1',
userId: 'other-user',
kind: 'delegation',
value: '33',
}),
create: jest.fn(),
delete: jest.fn(),
},
});
await expect(service.revoke('entra-oid', 's1')).rejects.toBeInstanceOf(NotFoundException);
});
it('throws NotFoundException when no scope exists for that id', async () => {
const { service } = makeSubject({
user: { findUnique: jest.fn().mockResolvedValue(FOUND_USER) },
});
await expect(service.revoke('entra-oid', 'ghost')).rejects.toBeInstanceOf(NotFoundException);
});
});
@@ -0,0 +1,266 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { Logger } from 'nestjs-pino';
import { PrismaService } from 'nestjs-prisma';
import { isScopeKind, type ScopeKind } from 'shared-auth';
import type { UserScopeDto, UserScopesPageDto } from './user-scopes.dto';
/**
* Kinds that carry a value referencing an ADR-0027 row. The service
* resolves each to its matching table at `grant` time.
*/
const VALUE_BEARING_KINDS = new Set<ScopeKind>(['etablissement', 'delegation', 'region']);
export interface ResolvedUser {
readonly oid: string;
readonly userId: string;
readonly displayName: string;
readonly email: string | null;
}
/**
* `UserScopesService` — owns the CRUD surface behind the ADR-0026
* PR 2b admin scope-management screen.
*
* Read side reads `user_scopes` joined to `User` + `Person` (so the
* SPA gets the affected user's display info in the same round-trip).
* Write side validates the scope tuple before insert:
*
* - `kind` is enforced by the DTO's `IsIn(SCOPE_KINDS)` plus the
* type-guard `isScopeKind` defensively.
* - For value-bearing kinds (`etablissement` / `delegation` /
* `region`), `value` must match an existing row in the
* corresponding ADR-0027 table. The lookup is the only DB call
* beyond the insert itself.
* - For valueless kinds (`self` / `siege` / `unrestricted`), the
* service coerces `value` to `''` and rejects any non-empty
* attempt.
*
* `UserScope.source` is hardcoded to `'admin-ui'` per the
* PERSON_SOURCES catalogue — see ADR-0026 PR 1's `person-source.ts`
* for the shared catalogue rules. (`UserScope.source` is not in that
* catalogue at the drift-gate level today; the seed writes 'seed'
* and we write 'admin-ui'. ADR-0029 will add upstream-sync values
* and likely formalise the UserScope.source catalogue then.)
*/
@Injectable()
export class UserScopesService {
constructor(
private readonly prisma: PrismaService,
private readonly logger: Logger,
) {}
/**
* Resolves an Entra `oid` to the new ADR-0026 `User` row + its
* linked `Person`. Throws 404 when no User row exists for the oid
* — caller may need to surface "this persona has never signed in
* and is not in the seed" with a hint.
*/
async resolveUserByOid(oid: string): Promise<ResolvedUser> {
const user = await this.prisma.user.findUnique({
where: { entraOid: oid },
select: {
id: true,
person: { select: { firstName: true, lastName: true, email: true } },
},
});
if (user === null) {
throw new NotFoundException(`No portal User found for Entra oid ${oid}.`);
}
return {
oid,
userId: user.id,
displayName: composeDisplay(user.person.firstName, user.person.lastName),
email: user.person.email,
};
}
async list(oid: string): Promise<UserScopesPageDto> {
const target = await this.resolveUserByOid(oid);
const rows = await this.prisma.userScope.findMany({
where: { userId: target.userId },
orderBy: [{ kind: 'asc' }, { value: 'asc' }],
select: {
id: true,
kind: true,
value: true,
source: true,
createdAt: true,
expiresAt: true,
},
});
return {
user: target,
scopes: rows.map(toDto),
};
}
/**
* Grant a scope. Validates the tuple, then inserts. Returns the
* resolved User (so the controller can include it in the audit
* payload) plus the freshly-created `UserScope` row.
*/
async grant(
oid: string,
input: { kind: string; value?: string; expiresAt?: string },
): Promise<{ user: ResolvedUser; scope: UserScopeDto }> {
const target = await this.resolveUserByOid(oid);
if (!isScopeKind(input.kind)) {
// The DTO already enforces this — defensive belt-and-braces.
throw new BadRequestException(`Unknown scope kind: ${input.kind}.`);
}
const normalisedValue = await this.validateValueForKind(input.kind, input.value ?? '');
const expiresAt = input.expiresAt ? new Date(input.expiresAt) : null;
try {
const row = await this.prisma.userScope.create({
data: {
userId: target.userId,
kind: input.kind,
value: normalisedValue,
source: 'admin-ui',
expiresAt,
},
select: {
id: true,
kind: true,
value: true,
source: true,
createdAt: true,
expiresAt: true,
},
});
return { user: target, scope: toDto(row) };
} catch (err) {
// Prisma's unique-constraint on (userId, kind, value) raises
// P2002. Surface it as 409-ish via a BadRequest so the admin UI
// can show "already granted" without a stack trace.
if (isPrismaP2002(err)) {
throw new BadRequestException(
`Scope already granted to this user: kind=${input.kind}, value=${normalisedValue || '(empty)'}.`,
);
}
throw err;
}
}
/**
* Revoke a scope by its row id. Returns the resolved user + the
* deleted tuple (kind / value) so the audit row reflects what the
* row WAS — the row itself is gone by the time the controller
* audits.
*/
async revoke(
oid: string,
scopeId: string,
): Promise<{ user: ResolvedUser; revoked: { kind: string; value: string } }> {
const target = await this.resolveUserByOid(oid);
const existing = await this.prisma.userScope.findUnique({
where: { id: scopeId },
select: { id: true, userId: true, kind: true, value: true },
});
if (existing === null || existing.userId !== target.userId) {
// Don't leak "this scope id exists but is for someone else" —
// 404 either way.
throw new NotFoundException(`Scope ${scopeId} not found for user ${oid}.`);
}
await this.prisma.userScope.delete({ where: { id: scopeId } });
this.logger.log(
{
event: 'user_scopes.revoked',
oid,
scopeId,
kind: existing.kind,
value: existing.value,
},
'UserScopesService',
);
return {
user: target,
revoked: { kind: existing.kind, value: existing.value },
};
}
private async validateValueForKind(kind: ScopeKind, rawValue: string): Promise<string> {
if (!VALUE_BEARING_KINDS.has(kind)) {
if (rawValue !== '') {
throw new BadRequestException(`Scope kind '${kind}' is valueless — value must be empty.`);
}
return '';
}
if (rawValue === '') {
throw new BadRequestException(`Scope kind '${kind}' requires a non-empty value.`);
}
// Look up the referenced row. Single existence check per kind;
// ADR-0026 PR 1's "no FK on UserScope.value" rationale lets the
// value persist even after the target is decommissioned, so we
// validate only at the write path.
//
// Initialise `exists = false` so TypeScript sees it as definitely
// assigned regardless of how the switch lands (the type-system
// can't see that `VALUE_BEARING_KINDS.has` already narrowed
// `kind` to the three branches). If somehow a non-value-bearing
// kind reached this code, the false default makes the next `if`
// throw with the same friendly "does not match" message — safe
// by construction.
let exists = false;
switch (kind) {
case 'etablissement':
exists =
(await this.prisma.structure.findUnique({
where: { code: rawValue },
select: { code: true },
})) !== null;
break;
case 'delegation':
exists =
(await this.prisma.delegation.findUnique({
where: { code: rawValue },
select: { code: true },
})) !== null;
break;
case 'region':
exists =
(await this.prisma.region.findUnique({
where: { code: rawValue },
select: { code: true },
})) !== null;
break;
}
if (!exists) {
throw new BadRequestException(`Scope value '${rawValue}' does not match any ${kind} row.`);
}
return rawValue;
}
}
function toDto(row: {
id: string;
kind: string;
value: string;
source: string;
createdAt: Date;
expiresAt: Date | null;
}): UserScopeDto {
return {
id: row.id,
kind: row.kind,
value: row.value,
source: row.source,
createdAt: row.createdAt.toISOString(),
expiresAt: row.expiresAt === null ? null : row.expiresAt.toISOString(),
};
}
function composeDisplay(firstName: string, lastName: string): string {
const combined = `${firstName} ${lastName}`.trim();
return combined === '' ? '(unnamed)' : combined;
}
function isPrismaP2002(err: unknown): boolean {
return (
typeof err === 'object' &&
err !== null &&
'code' in err &&
(err as { code: unknown }).code === 'P2002'
);
}
+2
View File
@@ -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,
@@ -469,4 +469,67 @@ describe('AuditWriter — typed event methods', () => {
expect(extractInsertedRow(prisma).payloadJson).toBe(JSON.stringify({ rolesHeld: [] }));
});
});
describe('authorizationDenied()', () => {
it('records auth.authorization_denied with kind=privilege payload', async () => {
const { writer, prisma, hashUserId } = await createSubject();
await writer.authorizationDenied({
actor: { oid: 'user-oid' },
attemptedRoute: 'GET /api/audit',
kind: 'privilege',
required: ['Portal.Auditor'],
held: ['Portal.Admin'],
});
expect(hashUserId.hash).toHaveBeenCalledWith('user-oid');
const row = extractInsertedRow(prisma);
expect(row.eventType).toBe('auth.authorization_denied');
expect(row.outcome).toBe('denied');
expect(row.subject).toBe('GET /api/audit');
expect(row.payloadJson).toBe(
JSON.stringify({
kind: 'privilege',
required: ['Portal.Auditor'],
held: ['Portal.Admin'],
}),
);
});
it('records kind=role with the role lists', async () => {
const { writer, prisma } = await createSubject();
await writer.authorizationDenied({
actor: { oid: 'user-oid' },
attemptedRoute: 'GET /api/hr/payroll',
kind: 'role',
required: ['rh', 'responsable-paie'],
held: ['collaborateur'],
});
const row = extractInsertedRow(prisma);
expect(row.payloadJson).toBe(
JSON.stringify({
kind: 'role',
required: ['rh', 'responsable-paie'],
held: ['collaborateur'],
}),
);
});
it('records kind=scope with the scope descriptors', async () => {
const { writer, prisma } = await createSubject();
await writer.authorizationDenied({
actor: { oid: 'user-oid' },
attemptedRoute: 'GET /api/etablissement/0330800021',
kind: 'scope',
required: ['etablissement:0330800021'],
held: ['etablissement:0330800013'],
});
const row = extractInsertedRow(prisma);
expect(row.payloadJson).toBe(
JSON.stringify({
kind: 'scope',
required: ['etablissement:0330800021'],
held: ['etablissement:0330800013'],
}),
);
});
});
});
+100
View File
@@ -5,8 +5,12 @@ import { PrismaService } from 'nestjs-prisma';
import type {
AdminAccessDeniedInput,
AdminAuditQueryInput,
AdminAuditStatsQueryInput,
AdminScopeGrantedInput,
AdminScopeRevokedInput,
AdminUsersQueryInput,
AuditEventInput,
AuthorizationDeniedInput,
MfaRequiredInput,
SignInActor,
SignInFailedInput,
@@ -174,6 +178,30 @@ export class AuditWriter {
});
}
/**
* Typed event: an admin queried the audit-log STATISTICS endpoint
* (`GET /api/admin/audit/stats`). Same deterrent posture as
* {@link adminAuditQuery} but separates the event type so an
* auditor can spot "the admin scanned aggregations" vs "the admin
* paged through rows" — different observation signals.
*
* Payload carries `total` (the size of the aggregated dataset)
* rather than `resultCount` — stats responses don't paginate, and
* `total` is what tells a reviewer how wide the scan was.
*/
async adminAuditStatsQuery(input: AdminAuditStatsQueryInput): Promise<void> {
await this.recordEvent({
eventType: 'admin.audit.stats.query',
audience: 'workforce',
outcome: 'success',
actorIdHash: this.hashUserId.hash(input.actor.oid),
payload: {
filters: input.filters,
total: input.total,
},
});
}
/**
* Typed event: an admin queried the user-directory viewer. Same
* deterrent posture as {@link adminAuditQuery} per ADR-0020
@@ -194,6 +222,50 @@ export class AuditWriter {
});
}
/**
* Typed event: an admin granted a scope to a user via the ADR-0026
* PR 2b scope-management screen. Blocking per ADR-0013 — a failed
* audit write means the scope write itself is rolled back upstream.
* The `subject` is `user:<oid>` of the affected user, so an auditor
* pivots on the *target* of the change, not the actor.
*/
async adminScopeGranted(input: AdminScopeGrantedInput): Promise<void> {
await this.recordEvent({
eventType: 'admin.scope_granted',
audience: 'workforce',
outcome: 'success',
actorIdHash: this.hashUserId.hash(input.actor.oid),
subject: `user:${input.target.oid}`,
payload: {
scopeId: input.scopeId,
kind: input.kind,
value: input.value,
expiresAt: input.expiresAt,
},
});
}
/**
* Typed event: an admin revoked a scope from a user. Payload
* carries the resolved tuple at the moment of revocation so the
* deletion is reconstructable from the audit log alone — the
* `user_scopes` row itself is gone by then.
*/
async adminScopeRevoked(input: AdminScopeRevokedInput): Promise<void> {
await this.recordEvent({
eventType: 'admin.scope_revoked',
audience: 'workforce',
outcome: 'success',
actorIdHash: this.hashUserId.hash(input.actor.oid),
subject: `user:${input.target.oid}`,
payload: {
scopeId: input.scopeId,
kind: input.kind,
value: input.value,
},
});
}
/**
* Typed event: `/api/admin/*` request rejected by `AdminRoleGuard`
* because the session's `roles` claim does not include `Portal.Admin`.
@@ -214,6 +286,34 @@ export class AuditWriter {
});
}
/**
* Typed event: a request was rejected by one of the three new
* ADR-0025 guards (`RequirePrivilegeGuard`, `RequireRoleGuard`,
* `RequireScopeGuard`). Distinct from `admin.access_denied` so
* an auditor can keep the existing admin-surface signal clean
* while tracking the broader role/scope denial surface as it
* grows.
*
* `outcome=denied` matches the existing posture: the user *is*
* authenticated, the action is just refused. The `required` and
* `held` payload arrays let an auditor pivot on "tried admin
* while logged in as RH" without joining anything.
*/
async authorizationDenied(input: AuthorizationDeniedInput): Promise<void> {
await this.recordEvent({
eventType: 'auth.authorization_denied',
audience: 'workforce',
outcome: 'denied',
actorIdHash: this.hashUserId.hash(input.actor.oid),
subject: input.attemptedRoute,
payload: {
kind: input.kind,
required: input.required,
held: input.held,
},
});
}
async recordEvent(input: AuditEventInput): Promise<void> {
const traceId = trace.getActiveSpan()?.spanContext().traceId ?? null;
const actorIdHash =
+82
View File
@@ -133,6 +133,88 @@ export interface AdminUsersQueryInput {
resultCount: number;
}
/**
* Admin granted a scope to a user via the ADR-0026 PR 2b admin
* screen. The `target` field carries the affected user's Entra `oid`
* (the URL-path identifier of the scope-management screen). Stored
* as the audit row's `subject` (`user:<oid>`). Payload captures the
* scope tuple + the resulting row id + the expiry so a reviewer can
* spot operator drift without re-querying.
*/
export interface AdminScopeGrantedInput {
actor: { oid: string };
target: { oid: string };
scopeId: string;
kind: string;
value: string;
expiresAt: string | null;
}
/**
* Admin revoked (deleted) a scope row from a user. Same target
* convention as {@link AdminScopeGrantedInput}; payload carries the
* resolved tuple at the moment of revocation so the deletion is
* reconstructable from the audit log even after the row itself is
* gone.
*/
export interface AdminScopeRevokedInput {
actor: { oid: string };
target: { oid: string };
scopeId: string;
kind: string;
value: string;
}
/**
* Audit aggregations read — `GET /api/admin/audit/stats`. Same
* deterrent posture as {@link AdminAuditQueryInput} (every admin
* read is auditable per ADR-0020) but the payload captures the
* `total` event count of the aggregated set instead of the
* paginated `resultCount` — there's no pagination on stats, the
* value carries more "size of the scan" signal.
*/
export interface AdminAuditStatsQueryInput {
actor: { oid: string };
filters: Record<string, unknown>;
total: number;
}
/**
* Discriminator on `AuthorizationDeniedInput.kind` — which of the
* three new ADR-0025 guards rejected the request. Stored in the
* payload so an auditor can spot "what kind of authorization
* failed" without parsing the route. The existing
* `admin.access_denied` event keeps its own type for backwards
* compatibility with the legacy `AdminRoleGuard` audit posture.
*/
export type AuthorizationDeniedKind = 'privilege' | 'role' | 'scope';
export interface AuthorizationDeniedInput {
actor: { oid: string };
/** Canonical "{METHOD} {originalUrl}" of the rejected request. */
attemptedRoute: string;
/** Which guard fired the rejection. */
kind: AuthorizationDeniedKind;
/**
* For `kind: 'privilege' | 'role'`: the catalogue values the
* guard required. For `kind: 'scope'`: the route's resource
* descriptor serialised into a `{kind, value}` map. Stored as
* a generic `string[]` rather than the union of `Privilege` /
* `FunctionalRole` so the audit module stays decoupled from the
* shared catalogue.
*/
required: readonly string[];
/**
* What the principal actually held on the matching axis at the
* moment of denial — `privileges[]` for privilege/admin denials,
* `roles[]` for role denials, `scopes[]` (serialised as
* `kind` or `kind:value` strings) for scope denials. Lets an
* auditor spot "tried admin while logged in as RH" patterns
* without joining anything.
*/
held: readonly string[];
}
/** Reason `RequireMfaGuard` rejected a request — fed into the audit payload. */
export type MfaRequiredReason = 'no-mfa-in-amr' | 'no-mfa-verified-at' | 'mfa-stale';
@@ -0,0 +1,267 @@
/**
* Persona-matrix integration tests per ADR-0025 §"More Information"
* phasing step 2: the three new guards exercised against every
* persona provisioned in the `apfrd.onmicrosoft.com` test tenant on
* 2026-05-20. Each test instantiates the guard with the persona's
* resolved `Principal` (the shape `PrincipalBuilder` would have
* produced at sign-in) and asserts the guard either passes or
* denies — the matrix proves the contracts hold against the real
* provisioning matrix, not just synthesised toy principals.
*
* Scopes are stubbed `unrestricted` for every persona in v1 per
* ADR-0025 §331 (the per-persona scope values land with the
* Prisma `user_scopes` table in a later PR). The scope guard is
* therefore exercised here against synthesised varied principals,
* not against the persona matrix.
*/
import { ExecutionContext, ForbiddenException } from '@nestjs/common';
import type { Reflector } from '@nestjs/core';
import type { Request } from 'express';
import type { FunctionalRole, Principal, Privilege } from 'shared-auth';
import type { AuditWriter } from '../audit/audit.service';
import { RequirePrivilegeGuard } from './require-privilege.guard';
import { RequireRoleGuard } from './require-role.guard';
interface Persona {
readonly label: string;
readonly privileges: readonly Privilege[];
readonly roles: readonly FunctionalRole[];
}
// Verbatim copy of the persona matrix from
// `principal-builder.spec.ts`. Kept here as a local constant so a
// reviewer sees the same 19-row table both files assert against;
// the two specs cover different layers (builder, then guards).
const PERSONAS: readonly Persona[] = [
{ label: 'admin', privileges: ['Portal.Admin'], roles: ['collaborateur', 'rh'] },
{
label: 'directeur-bordeaux',
privileges: [],
roles: ['collaborateur', 'directeur-etablissement'],
},
{
label: 'directeur-complexe',
privileges: [],
roles: ['collaborateur', 'directeur-etablissement'],
},
{ label: 'rh-aquitaine', privileges: [], roles: ['collaborateur', 'rh', 'formation'] },
{
label: 'rh-siege',
privileges: [],
roles: ['collaborateur', 'rh', 'responsable-paie', 'comptable'],
},
{ label: 'collaborateur-simple', privileges: [], roles: ['collaborateur'] },
{ label: 'tresorier-bordeaux', privileges: [], roles: ['elu-cd', 'elu-cd-tresorier'] },
{
label: 'dpo',
privileges: ['Portal.DPO', 'Portal.Auditor'],
roles: ['collaborateur', 'dpo', 'qualite'],
},
{ label: 'it', privileges: [], roles: ['collaborateur', 'it'] },
{
label: 'benevole-aquitaine',
privileges: [],
roles: ['delegue', 'benevole', 'benevole-responsable'],
},
{ label: 'chef-equipe-bordeaux', privileges: [], roles: ['collaborateur', 'chef-equipe'] },
{ label: 'chef-service-bordeaux', privileges: [], roles: ['collaborateur', 'chef-service'] },
{
label: 'directeur-territorial-aquitaine',
privileges: [],
roles: ['collaborateur', 'directeur-territorial'],
},
{ label: 'juriste-siege', privileges: [], roles: ['collaborateur', 'juriste'] },
{ label: 'rssi', privileges: ['Portal.SecurityOfficer'], roles: ['collaborateur', 'rssi'] },
{ label: 'communication-siege', privileges: [], roles: ['collaborateur', 'communication'] },
{ label: 'elu-ca-national', privileges: [], roles: ['elu-ca'] },
{ label: 'president-cd-aquitaine', privileges: [], roles: ['elu-cd', 'elu-cd-president'] },
{ label: 'secretaire-cd-aquitaine', privileges: [], roles: ['elu-cd', 'elu-cd-secretaire'] },
];
function principalOf(persona: Persona): Principal {
return {
user: {
id: `oid-${persona.label}`,
personId: `oid-${persona.label}`,
entraOid: `oid-${persona.label}`,
tenantId: 'tenant-1',
displayName: persona.label,
},
privileges: persona.privileges,
roles: persona.roles,
scopes: [{ kind: 'unrestricted' }],
amr: ['pwd', 'mfa'],
};
}
function makeCtxFor(principal: Principal): ExecutionContext {
const req = {
session: { principal },
method: 'GET',
originalUrl: '/api/test',
} as unknown as Request;
return {
switchToHttp: () => ({ getRequest: <T>() => req as T }),
getHandler: () => 'handler',
getClass: () => 'TestController',
} as unknown as ExecutionContext;
}
function privilegeGuardFor(metadata: readonly Privilege[]) {
const reflector = {
getAllAndOverride: jest.fn().mockReturnValue(metadata),
} as unknown as Reflector;
const audit = jest.fn().mockResolvedValue(undefined);
const writer = { authorizationDenied: audit } as unknown as AuditWriter;
return { guard: new RequirePrivilegeGuard(reflector, writer), audit };
}
function roleGuardFor(metadata: readonly FunctionalRole[]) {
const reflector = {
getAllAndOverride: jest.fn().mockReturnValue(metadata),
} as unknown as Reflector;
const audit = jest.fn().mockResolvedValue(undefined);
const writer = { authorizationDenied: audit } as unknown as AuditWriter;
return { guard: new RequireRoleGuard(reflector, writer), audit };
}
/**
* Each row asserts which personas should pass a given guard. The
* complement (everyone NOT in `allowed`) must be denied — the
* `expect denied` block at the bottom of each describe catches a
* regression that would silently let extra personas through.
*/
interface Matrix {
readonly title: string;
readonly allowed: ReadonlyArray<string>;
}
const PRIVILEGE_MATRIX: ReadonlyArray<{
metadata: readonly Privilege[];
matrix: Matrix;
}> = [
{
metadata: ['Portal.Admin'],
matrix: { title: '@RequirePrivilege(Portal.Admin)', allowed: ['admin'] },
},
{
metadata: ['Portal.DPO'],
matrix: { title: '@RequirePrivilege(Portal.DPO)', allowed: ['dpo'] },
},
{
metadata: ['Portal.Auditor'],
matrix: { title: '@RequirePrivilege(Portal.Auditor)', allowed: ['dpo'] },
},
{
metadata: ['Portal.SecurityOfficer'],
matrix: { title: '@RequirePrivilege(Portal.SecurityOfficer)', allowed: ['rssi'] },
},
{
metadata: ['Portal.Admin', 'Portal.DPO'],
matrix: {
title: '@RequirePrivilege(Portal.Admin, Portal.DPO) — OR composition',
allowed: ['admin', 'dpo'],
},
},
];
const ROLE_MATRIX: ReadonlyArray<{
metadata: readonly FunctionalRole[];
matrix: Matrix;
}> = [
{
metadata: ['rh'],
matrix: { title: '@RequireRole(rh)', allowed: ['admin', 'rh-aquitaine', 'rh-siege'] },
},
{
metadata: ['directeur-etablissement'],
matrix: {
title: '@RequireRole(directeur-etablissement)',
allowed: ['directeur-bordeaux', 'directeur-complexe'],
},
},
{
metadata: ['rh', 'comptable', 'responsable-paie'],
matrix: {
title: '@RequireRole(rh, comptable, responsable-paie) — OR composition',
allowed: ['admin', 'rh-aquitaine', 'rh-siege'],
},
},
{
metadata: ['elu-cd'],
matrix: {
title: '@RequireRole(elu-cd) — every CD member',
allowed: ['tresorier-bordeaux', 'president-cd-aquitaine', 'secretaire-cd-aquitaine'],
},
},
{
metadata: ['benevole-responsable'],
matrix: {
title: '@RequireRole(benevole-responsable)',
allowed: ['benevole-aquitaine'],
},
},
{
metadata: ['collaborateur'],
matrix: {
title: '@RequireRole(collaborateur) — every workforce persona',
allowed: [
'admin',
'directeur-bordeaux',
'directeur-complexe',
'rh-aquitaine',
'rh-siege',
'collaborateur-simple',
'dpo',
'it',
'chef-equipe-bordeaux',
'chef-service-bordeaux',
'directeur-territorial-aquitaine',
'juriste-siege',
'rssi',
'communication-siege',
],
},
},
];
describe('Privilege guard — persona matrix', () => {
for (const { metadata, matrix } of PRIVILEGE_MATRIX) {
describe(matrix.title, () => {
for (const persona of PERSONAS) {
const shouldPass = matrix.allowed.includes(persona.label);
const verb = shouldPass ? 'allows' : 'denies';
it(`${verb} ${persona.label}`, async () => {
const { guard } = privilegeGuardFor(metadata);
const ctx = makeCtxFor(principalOf(persona));
if (shouldPass) {
await expect(guard.canActivate(ctx)).resolves.toBe(true);
} else {
await expect(guard.canActivate(ctx)).rejects.toBeInstanceOf(ForbiddenException);
}
});
}
});
}
});
describe('Role guard — persona matrix', () => {
for (const { metadata, matrix } of ROLE_MATRIX) {
describe(matrix.title, () => {
for (const persona of PERSONAS) {
const shouldPass = matrix.allowed.includes(persona.label);
const verb = shouldPass ? 'allows' : 'denies';
it(`${verb} ${persona.label}`, async () => {
const { guard } = roleGuardFor(metadata);
const ctx = makeCtxFor(principalOf(persona));
if (shouldPass) {
await expect(guard.canActivate(ctx)).resolves.toBe(true);
} else {
await expect(guard.canActivate(ctx)).rejects.toBeInstanceOf(ForbiddenException);
}
});
}
});
}
});
@@ -147,14 +147,44 @@ function makeController(opts?: { completeAuthCodeFlow?: jest.Mock }): Controller
// The directory service is a best-effort dependency — a noop mock
// is sufficient for the controller's behavioural assertions.
const userDirectory = { recordSignIn: jest.fn().mockResolvedValue(undefined) };
// The provisioner is blocking per ADR-0026 — the controller specs
// don't exercise the provisioning behaviour itself, so a noop mock
// returning a fixed UUID pair is enough to let SessionEstablisher
// proceed.
const personUserProvisioner = {
ensureUser: jest.fn().mockResolvedValue({
userId: '00000000-0000-0000-0000-000000000fff',
personId: '00000000-0000-0000-0000-000000000eee',
}),
};
// Real SessionEstablisher with the same mocks the legacy tests
// already wire — keeps the behavioural assertions on session
// fields / audit calls untouched after the controller refactor.
const principalBuilder = {
// The auth.controller specs do not assert on the principal
// shape; the resolved value just has to be a valid `Principal`
// so SessionEstablisher persists it without throwing.
build: jest.fn().mockResolvedValue({
user: {
id: '00000000-0000-0000-0000-000000000fff',
personId: '00000000-0000-0000-0000-000000000eee',
entraOid: 'oid',
tenantId: 'tid',
displayName: 'Jane',
},
privileges: [],
roles: [],
scopes: [{ kind: 'unrestricted' }],
amr: [],
}),
};
const sessionEstablisher = new SessionEstablisher(
logger as unknown as Logger,
userSessionIndex as unknown as UserSessionIndexService,
audit as unknown as AuditWriter,
userDirectory as unknown as UserDirectoryService,
personUserProvisioner as unknown as import('../users/person-and-user-provisioner.service').PersonAndUserProvisioner,
principalBuilder as unknown as import('./principal-builder').PrincipalBuilder,
);
return {
controller: new AuthController(
+53 -1
View File
@@ -2,12 +2,20 @@ import { ConfidentialClientApplication, LogLevel } from '@azure/msal-node';
import { Module } from '@nestjs/common';
import { Logger } from 'nestjs-pino';
import { assertEntraConfig } from '../config/check-entra-config';
import { loadEntraGroupToRoleResolver } from '../config/load-entra-group-map';
import { SessionModule } from '../session/session.module';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { ENTRA_CONFIG, type EntraConfig } from './entra-config.token';
import { ENTRA_GROUP_TO_ROLE_RESOLVER } from './entra-group-to-role.token';
import { MSAL_CLIENT } from './msal-client.token';
import { PrincipalBuilder } from './principal-builder';
import { PrismaScopeResolver } from './prisma-scope-resolver';
import { RequireMfaGuard } from './require-mfa.guard';
import { RequirePrivilegeGuard } from './require-privilege.guard';
import { RequireRoleGuard } from './require-role.guard';
import { RequireScopeGuard } from './require-scope.guard';
import { ScopeResolver } from './scope-resolver';
import { SessionEstablisher } from './session-establisher.service';
/**
@@ -46,11 +54,43 @@ import { SessionEstablisher } from './session-establisher.service';
providers: [
AuthService,
RequireMfaGuard,
RequirePrivilegeGuard,
RequireRoleGuard,
RequireScopeGuard,
SessionEstablisher,
PrincipalBuilder,
// PrismaScopeResolver replaces StubScopeResolver per ADR-0026 PR 2.
// The stub class stays exported from scope-resolver.ts as a
// fallback for spec fixtures / future "force-unrestricted" dev
// modes, but it is no longer the default wiring.
{ provide: ScopeResolver, useClass: PrismaScopeResolver },
{
provide: ENTRA_CONFIG,
useFactory: () => assertEntraConfig(),
},
{
provide: ENTRA_GROUP_TO_ROLE_RESOLVER,
inject: [Logger],
useFactory: (logger: Logger) => {
const { resolver, sourcePath } = loadEntraGroupToRoleResolver({
onWarn: (event, payload) => logger.warn({ event, ...payload }, 'AuthModule'),
});
// Log the post-load summary unconditionally so an operator
// grepping the boot log can confirm how many functional
// roles are wired without inspecting the JSON file. The
// file path (if any) helps diagnose env-var / cwd mismatches.
logger.log(
{
event: 'auth.entra_group_map_loaded',
sourcePath,
mappingCount: resolver.size,
coveredRoles: resolver.coveredRoles(),
},
'AuthModule',
);
return resolver;
},
},
{
provide: MSAL_CLIENT,
inject: [ENTRA_CONFIG, Logger],
@@ -92,6 +132,18 @@ import { SessionEstablisher } from './session-establisher.service';
}),
},
],
exports: [ENTRA_CONFIG, MSAL_CLIENT, RequireMfaGuard, AuthService, SessionEstablisher],
exports: [
ENTRA_CONFIG,
ENTRA_GROUP_TO_ROLE_RESOLVER,
MSAL_CLIENT,
RequireMfaGuard,
RequirePrivilegeGuard,
RequireRoleGuard,
RequireScopeGuard,
AuthService,
PrincipalBuilder,
ScopeResolver,
SessionEstablisher,
],
})
export class AuthModule {}
@@ -38,6 +38,7 @@ function makeAuthResult(
claims: Partial<{
amr: string[];
roles: unknown;
groups: unknown;
oid: string;
tid: string;
name: string;
@@ -58,6 +59,12 @@ function makeAuthResult(
if (Object.prototype.hasOwnProperty.call(claims, 'roles')) {
idTokenClaims['roles'] = claims.roles;
}
// Same opt-in pattern for `groups` — Entra emits it only when
// the app registration sets `groupMembershipClaims: 'SecurityGroup'`
// and the user is in at least one group.
if (Object.prototype.hasOwnProperty.call(claims, 'groups')) {
idTokenClaims['groups'] = claims.groups;
}
return {
idTokenClaims,
account: { username: 'jane.doe@apf.example', name: 'Jane Doe' },
@@ -124,6 +131,7 @@ describe('AuthService.completeAuthCodeFlow', () => {
displayName: 'Jane Doe',
amr: ['pwd', 'mfa'],
roles: [],
groups: [],
});
});
@@ -220,6 +228,80 @@ describe('AuthService.completeAuthCodeFlow', () => {
expect(user.roles).toEqual(['admin', 'editor']);
});
// `groups` claim — extracted identically to `roles`. The raw
// GUIDs are surfaced here; resolution to `apf-role-*` slugs
// happens downstream in `PrincipalBuilder` per ADR-0025.
it('surfaces the `groups` claim when Entra includes it (security-group member)', async () => {
const acquireTokenByCode = jest.fn().mockResolvedValue(
makeAuthResult({
groups: ['11111111-1111-1111-1111-111111111111', '22222222-2222-2222-2222-222222222222'],
}),
);
const { service } = makeService({ acquireTokenByCode });
const user = await service.completeAuthCodeFlow(
'code',
PRE_AUTH_OK.state,
PRE_AUTH_OK,
ENTRA.redirectUri,
PRE_AUTH_OK.createdAt,
);
expect(user.groups).toEqual([
'11111111-1111-1111-1111-111111111111',
'22222222-2222-2222-2222-222222222222',
]);
});
it('returns an empty `groups` array when the claim is absent (no group membership)', async () => {
const acquireTokenByCode = jest.fn().mockResolvedValue(makeAuthResult({}));
const { service } = makeService({ acquireTokenByCode });
const user = await service.completeAuthCodeFlow(
'code',
PRE_AUTH_OK.state,
PRE_AUTH_OK,
ENTRA.redirectUri,
PRE_AUTH_OK.createdAt,
);
expect(user.groups).toEqual([]);
});
it('returns an empty `groups` array when the claim is non-array (defensive)', async () => {
const acquireTokenByCode = jest.fn().mockResolvedValue(makeAuthResult({ groups: 'oops' }));
const { service } = makeService({ acquireTokenByCode });
const user = await service.completeAuthCodeFlow(
'code',
PRE_AUTH_OK.state,
PRE_AUTH_OK,
ENTRA.redirectUri,
PRE_AUTH_OK.createdAt,
);
expect(user.groups).toEqual([]);
});
it('drops non-string entries from `groups` (defensive)', async () => {
const acquireTokenByCode = jest.fn().mockResolvedValue(
makeAuthResult({
groups: [
'11111111-1111-1111-1111-111111111111',
42,
null,
'22222222-2222-2222-2222-222222222222',
],
}),
);
const { service } = makeService({ acquireTokenByCode });
const user = await service.completeAuthCodeFlow(
'code',
PRE_AUTH_OK.state,
PRE_AUTH_OK,
ENTRA.redirectUri,
PRE_AUTH_OK.createdAt,
);
expect(user.groups).toEqual([
'11111111-1111-1111-1111-111111111111',
'22222222-2222-2222-2222-222222222222',
]);
});
it('throws token-exchange-failed when MSAL throws', async () => {
const acquireTokenByCode = jest.fn().mockRejectedValue(new Error('AADSTS70008'));
const { service } = makeService({ acquireTokenByCode });
+31 -5
View File
@@ -38,11 +38,23 @@ export interface AuthCodeFlowStart {
* the BFF's MFA sanity-check (ADR-0011) and by `@RequireMfa`
* freshness checks once the session lands.
* - `roles`: Entra app roles assigned to this user on the BFF's
* app registration. Surfaced for the future `@RequireAdmin` /
* `@RequireRole(...)` guards (ADR-0020 admin module). Always
* present in the shape — an empty array means the user has no
* app role on this app registration, not that the claim was
* unparseable.
* app registration. These are the four `Portal.*` *privileges*
* per ADR-0025 (`Portal.Admin`, `Portal.Auditor`,
* `Portal.SecurityOfficer`, `Portal.DPO`). Surfaced for the
* existing `@RequireAdmin()` guard and the upcoming
* `@RequirePrivilege()` decorator. Always present in the shape
* — an empty array means the user has no app role on this app
* registration, not that the claim was unparseable.
* - `groups`: Entra security-group GUIDs the user belongs to.
* Emitted by the `groups` claim once the app registration sets
* `groupMembershipClaims: 'SecurityGroup'` (per ADR-0025
* §"Sources of truth — Entra-side configuration"). The
* `PrincipalBuilder` resolves these GUIDs to `apf-role-*` slugs
* via the `EntraGroupToRoleResolver`. Always present — empty
* means either the user has no group membership or the claim
* was not configured on the app registration. Unknown GUIDs
* are dropped at resolve time, not at extraction time, so an
* audit reader still sees the raw claim.
*/
export interface AuthenticatedUser {
readonly oid: string;
@@ -51,6 +63,7 @@ export interface AuthenticatedUser {
readonly displayName: string;
readonly amr: readonly string[];
readonly roles: readonly string[];
readonly groups: readonly string[];
}
/**
@@ -212,6 +225,18 @@ export class AuthService {
? (claims['roles'] as unknown[]).filter((v): v is string => typeof v === 'string')
: [];
// `groups` is an optional claim (per ADR-0025 §"Sources of truth
// — Entra-side configuration"). Present only when the app
// registration sets `groupMembershipClaims: 'SecurityGroup'`.
// Empty array if the user is in zero security groups or the
// claim is not configured — both are valid "no functional
// roles" states the rest of the BFF must tolerate. Same
// string-filter as `roles` to defend against a non-string value
// smuggled in via a malformed token.
const groups = Array.isArray(claims['groups'])
? (claims['groups'] as unknown[]).filter((v): v is string => typeof v === 'string')
: [];
return {
oid: requireString(claims['oid'], 'oid'),
tid: requireString(claims['tid'], 'tid'),
@@ -225,6 +250,7 @@ export class AuthService {
: (result.account?.name ?? ''),
amr,
roles,
groups,
};
}
}
@@ -0,0 +1,12 @@
/**
* DI token for the lazily-loaded `EntraGroupToRoleResolver`.
*
* The resolver wraps a `Map<groupGuid, FunctionalRole>` parsed
* once at boot from `infra/<env>-tenant.entra.json` (per ADR-0025
* §"Sources of truth — Entra-side configuration"). The token is
* separate from the class import so the BFF can swap the
* implementation (an empty-map stub when no file is configured,
* a real one when `ENTRA_GROUP_MAP_PATH` resolves) without the
* consumers caring.
*/
export const ENTRA_GROUP_TO_ROLE_RESOLVER = Symbol('ENTRA_GROUP_TO_ROLE_RESOLVER');
@@ -0,0 +1,359 @@
import type { Logger } from 'nestjs-pino';
import {
EntraGroupToRoleResolver,
parseEntraGroupMap,
type FunctionalRole,
type Scope,
} from 'shared-auth';
import type { AuthenticatedUser } from './auth.service';
import { PrincipalBuilder } from './principal-builder';
import type { ScopeResolver } from './scope-resolver';
/**
* Mirrors the 24-entry `apf-role-*` catalogue: one synthetic GUID
* per slug, in catalogue order. The exact GUIDs do not matter to
* any assertion below — what matters is that the test resolver
* maps them deterministically so a persona's `groups` claim can
* be assembled by slug.
*/
const ROLE_GUID: Record<FunctionalRole, string> = {
collaborateur: '00000000-0000-0000-0000-000000000101',
'chef-equipe': '00000000-0000-0000-0000-000000000102',
'chef-service': '00000000-0000-0000-0000-000000000103',
'directeur-etablissement': '00000000-0000-0000-0000-000000000104',
'directeur-territorial': '00000000-0000-0000-0000-000000000105',
rh: '00000000-0000-0000-0000-000000000106',
'responsable-paie': '00000000-0000-0000-0000-000000000107',
comptable: '00000000-0000-0000-0000-000000000108',
juriste: '00000000-0000-0000-0000-000000000109',
dpo: '00000000-0000-0000-0000-00000000010a',
rssi: '00000000-0000-0000-0000-00000000010b',
it: '00000000-0000-0000-0000-00000000010c',
formation: '00000000-0000-0000-0000-00000000010d',
qualite: '00000000-0000-0000-0000-00000000010e',
communication: '00000000-0000-0000-0000-00000000010f',
'elu-ca': '00000000-0000-0000-0000-000000000110',
'elu-cd': '00000000-0000-0000-0000-000000000111',
'elu-cd-president': '00000000-0000-0000-0000-000000000112',
'elu-cd-tresorier': '00000000-0000-0000-0000-000000000113',
'elu-cd-secretaire': '00000000-0000-0000-0000-000000000114',
delegue: '00000000-0000-0000-0000-000000000115',
benevole: '00000000-0000-0000-0000-000000000116',
'benevole-responsable': '00000000-0000-0000-0000-000000000117',
partenaire: '00000000-0000-0000-0000-000000000118',
};
/**
* The 19 personas provisioned in the `apfrd.onmicrosoft.com` test
* tenant on 2026-05-20, transcribed from
* `notes/test-tenant-role-assignments.md` (reverse view). The
* builder's contract is that these inputs round-trip to the
* expected `(privileges, roles, scopes)` triple — when the spec
* passes, every persona we documented in the ADR is exercised
* end-to-end.
*
* Scopes are stubbed to `[{ kind: 'unrestricted' }]` for every
* persona in v1 per ADR-0025 §"Sources of truth — apf_portal-side
* `user_scopes` table". The intended per-persona scopes
* (`etablissement:…`, `delegation:33`, …) land with the Prisma
* `user_scopes` table — the next PR after this skeleton.
*/
interface Persona {
readonly label: string;
readonly username: string;
readonly privileges: readonly string[]; // raw `roles` claim values
readonly roles: readonly FunctionalRole[];
}
const PERSONAS: readonly Persona[] = [
{
label: 'admin',
username: 'admin@apfrd.onmicrosoft.com',
privileges: ['Portal.Admin'],
roles: ['collaborateur', 'rh'],
},
{
label: 'directeur-bordeaux',
username: 'directeur-bordeaux@apfrd.onmicrosoft.com',
privileges: [],
roles: ['collaborateur', 'directeur-etablissement'],
},
{
label: 'directeur-complexe',
username: 'directeur-complexe@apfrd.onmicrosoft.com',
privileges: [],
roles: ['collaborateur', 'directeur-etablissement'],
},
{
label: 'rh-aquitaine',
username: 'rh-aquitaine@apfrd.onmicrosoft.com',
privileges: [],
roles: ['collaborateur', 'rh', 'formation'],
},
{
label: 'rh-siege',
username: 'rh-siege@apfrd.onmicrosoft.com',
privileges: [],
roles: ['collaborateur', 'rh', 'responsable-paie', 'comptable'],
},
{
label: 'collaborateur-simple',
username: 'collaborateur-simple@apfrd.onmicrosoft.com',
privileges: [],
roles: ['collaborateur'],
},
{
label: 'tresorier-bordeaux',
username: 'tresorier-bordeaux@apfrd.onmicrosoft.com',
privileges: [],
roles: ['elu-cd', 'elu-cd-tresorier'],
},
{
label: 'dpo',
username: 'dpo@apfrd.onmicrosoft.com',
privileges: ['Portal.DPO', 'Portal.Auditor'],
roles: ['collaborateur', 'dpo', 'qualite'],
},
{
label: 'it',
username: 'it@apfrd.onmicrosoft.com',
privileges: [],
roles: ['collaborateur', 'it'],
},
{
label: 'benevole-aquitaine',
username: 'benevole-aquitaine@apfrd.onmicrosoft.com',
privileges: [],
// Catalogue order: delegue (governance) precedes benevole +
// benevole-responsable (volunteer). The resolver re-sorts on
// catalogue order regardless of the Entra claim's order.
roles: ['delegue', 'benevole', 'benevole-responsable'],
},
{
label: 'chef-equipe-bordeaux',
username: 'chef-equipe-bordeaux@apfrd.onmicrosoft.com',
privileges: [],
roles: ['collaborateur', 'chef-equipe'],
},
{
label: 'chef-service-bordeaux',
username: 'chef-service-bordeaux@apfrd.onmicrosoft.com',
privileges: [],
roles: ['collaborateur', 'chef-service'],
},
{
label: 'directeur-territorial-aquitaine',
username: 'directeur-territorial-aquitaine@apfrd.onmicrosoft.com',
privileges: [],
roles: ['collaborateur', 'directeur-territorial'],
},
{
label: 'juriste-siege',
username: 'juriste-siege@apfrd.onmicrosoft.com',
privileges: [],
roles: ['collaborateur', 'juriste'],
},
{
label: 'rssi',
username: 'rssi@apfrd.onmicrosoft.com',
privileges: ['Portal.SecurityOfficer'],
roles: ['collaborateur', 'rssi'],
},
{
label: 'communication-siege',
username: 'communication-siege@apfrd.onmicrosoft.com',
privileges: [],
roles: ['collaborateur', 'communication'],
},
{
label: 'elu-ca-national',
username: 'elu-ca-national@apfrd.onmicrosoft.com',
privileges: [],
roles: ['elu-ca'],
},
{
label: 'president-cd-aquitaine',
username: 'president-cd-aquitaine@apfrd.onmicrosoft.com',
privileges: [],
roles: ['elu-cd', 'elu-cd-president'],
},
{
label: 'secretaire-cd-aquitaine',
username: 'secretaire-cd-aquitaine@apfrd.onmicrosoft.com',
privileges: [],
roles: ['elu-cd', 'elu-cd-secretaire'],
},
];
function makeUser(p: Persona): AuthenticatedUser {
return {
oid: `oid-${p.label}`,
tid: 'tenant-1',
username: p.username,
displayName: p.label,
amr: ['pwd', 'mfa'],
roles: [...p.privileges],
groups: p.roles.map((slug) => ROLE_GUID[slug]),
};
}
/**
* Fixed identity used by every `build()` call in the spec. The
* provisioner is mocked at the `SessionEstablisher` test level; the
* `PrincipalBuilder` spec receives the UUIDs verbatim — what matters
* here is that they round-trip onto `Principal.user.{id, personId}`.
*/
const TEST_IDENTITY = {
userId: '00000000-0000-0000-0000-000000000fff',
personId: '00000000-0000-0000-0000-000000000eee',
};
function makeBuilder(): {
builder: PrincipalBuilder;
scopeResolver: { resolve: jest.Mock };
logger: { log: jest.Mock; warn: jest.Mock; error: jest.Mock };
} {
const rawMap: Record<string, FunctionalRole> = {};
for (const [slug, guid] of Object.entries(ROLE_GUID) as Array<[FunctionalRole, string]>) {
rawMap[guid] = slug;
}
const resolver = new EntraGroupToRoleResolver(parseEntraGroupMap(rawMap));
const scopeResolver = {
resolve: jest.fn().mockResolvedValue([{ kind: 'unrestricted' } as Scope]),
};
const logger = { log: jest.fn(), warn: jest.fn(), error: jest.fn() };
const builder = new PrincipalBuilder(
resolver,
scopeResolver as unknown as ScopeResolver,
logger as unknown as Logger,
);
return { builder, scopeResolver, logger };
}
describe('PrincipalBuilder — 19 test-tenant personas', () => {
for (const persona of PERSONAS) {
it(`builds the principal for ${persona.label}`, async () => {
const { builder } = makeBuilder();
const principal = await builder.build(makeUser(persona), TEST_IDENTITY);
expect(principal.privileges).toEqual(persona.privileges);
expect(principal.roles).toEqual(persona.roles);
expect(principal.scopes).toEqual([{ kind: 'unrestricted' }]);
expect(principal.user.entraOid).toBe(`oid-${persona.label}`);
expect(principal.user.tenantId).toBe('tenant-1');
// Real UUIDs from the provisioner, per ADR-0026 PR 1 — no
// longer the entraOid placeholder.
expect(principal.user.id).toBe(TEST_IDENTITY.userId);
expect(principal.user.personId).toBe(TEST_IDENTITY.personId);
// amr passes through verbatim — MFA freshness checks read it
// off the principal per ADR-0011.
expect(principal.amr).toEqual(['pwd', 'mfa']);
});
}
it('asserts the 19 personas cover all 4 privileges + 23 of 24 functional roles', () => {
// Coverage check baked into the spec so a regression that
// drops a role from a persona is caught here, not at PR review.
// The intentional gap is `partenaire` (placeholder per ADR-0025).
const privilegesUsed = new Set(PERSONAS.flatMap((p) => p.privileges));
expect(privilegesUsed).toEqual(
new Set(['Portal.Admin', 'Portal.Auditor', 'Portal.SecurityOfficer', 'Portal.DPO']),
);
const rolesUsed = new Set(PERSONAS.flatMap((p) => p.roles));
expect(rolesUsed.size).toBe(23);
expect(rolesUsed.has('partenaire')).toBe(false);
});
});
describe('PrincipalBuilder — edge cases', () => {
it('returns an empty privileges array when the user has no app role assignment', async () => {
const { builder } = makeBuilder();
const user = makeUser({
label: 'anon',
username: 'anon@example',
privileges: [],
roles: ['collaborateur'],
});
const principal = await builder.build(user, TEST_IDENTITY);
expect(principal.privileges).toEqual([]);
});
it('drops + warns on a claim value that is not a known privilege', async () => {
const { builder, logger } = makeBuilder();
const user = makeUser({
label: 'stale',
username: 'stale@example',
privileges: [],
roles: [],
});
// Force in a non-catalogue value the way an old assignment would.
const userWithDrift: AuthenticatedUser = {
...user,
roles: ['Portal.Admin', 'Portal.GhostRole'],
};
const principal = await builder.build(userWithDrift, TEST_IDENTITY);
expect(principal.privileges).toEqual(['Portal.Admin']);
expect(logger.warn).toHaveBeenCalledWith(
expect.objectContaining({
event: 'auth.unknown_privilege_claim',
value: 'Portal.GhostRole',
}),
'PrincipalBuilder',
);
});
it('drops + warns on an unknown group GUID (tenant misconfiguration)', async () => {
const { builder, logger } = makeBuilder();
const user = makeUser({
label: 'ghost-group',
username: 'ghost-group@example',
privileges: [],
roles: ['collaborateur'],
});
const userWithUnknownGroup: AuthenticatedUser = {
...user,
groups: [...user.groups, 'ffffffff-ffff-ffff-ffff-ffffffffffff'],
};
const principal = await builder.build(userWithUnknownGroup, TEST_IDENTITY);
expect(principal.roles).toEqual(['collaborateur']);
expect(logger.warn).toHaveBeenCalledWith(
expect.objectContaining({
event: 'auth.unknown_group_claim',
groupId: 'ffffffff-ffff-ffff-ffff-ffffffffffff',
}),
'PrincipalBuilder',
);
});
it('builds an empty-permissions principal when the user has no groups or roles', async () => {
const { builder } = makeBuilder();
const user = makeUser({
label: 'empty',
username: 'empty@example',
privileges: [],
roles: [],
});
const principal = await builder.build(user, TEST_IDENTITY);
expect(principal.privileges).toEqual([]);
expect(principal.roles).toEqual([]);
// Scope resolver stub returns unrestricted — the v1 behaviour
// per ADR-0025 §331 until the user_scopes table lands.
expect(principal.scopes).toEqual([{ kind: 'unrestricted' }]);
});
it('asks the scope resolver to resolve by userId (ADR-0026 PR 1 seam change)', async () => {
const { builder, scopeResolver } = makeBuilder();
await builder.build(
makeUser({
label: 'sr',
username: 'sr@example',
privileges: [],
roles: ['collaborateur'],
}),
TEST_IDENTITY,
);
// Pre-ADR-0026 the resolver was called with { entraOid }; PR 1
// switches the seam to { userId } so PR 2's PrismaScopeResolver
// can key queries on the real User.id.
expect(scopeResolver.resolve).toHaveBeenCalledWith({ userId: TEST_IDENTITY.userId });
});
});
@@ -0,0 +1,107 @@
import { Inject, Injectable } from '@nestjs/common';
import { Logger } from 'nestjs-pino';
import { EntraGroupToRoleResolver, isPrivilege, type Principal, type Privilege } from 'shared-auth';
import { ENTRA_GROUP_TO_ROLE_RESOLVER } from './entra-group-to-role.token';
import { ScopeResolver } from './scope-resolver';
import type { AuthenticatedUser } from './auth.service';
/**
* Builds the session-resident `Principal` per
* [ADR-0025 §"Principal shape"](../../../../docs/decisions/0025-authorization-model-privileges-roles-scopes.md).
*
* Composes the three orthogonal axes once at sign-in:
* - **Privileges** — pulled from the `roles` claim, filtered to
* the closed `PRIVILEGES` catalogue. Unknown values are
* dropped (with a WARN) rather than honoured: the BFF only
* reasons about catalogue privileges, anything else is
* either a tenant misconfiguration or a leftover from a v1+1
* experiment that should not silently grant access.
* - **Functional roles** — resolved from the `groups` claim via
* `EntraGroupToRoleResolver`. Unknown GUIDs are logged at
* WARN (per ADR-0025 §"Sources of truth — Entra-side
* configuration") and ignored.
* - **Scopes** — resolved by `ScopeResolver` from the portal-side
* `user_scopes` table (schema landed in ADR-0026 PR 1; the
* `PrismaScopeResolver` consumer lands in PR 2). v1 stubs to
* `[{ kind: 'unrestricted' }]`.
*
* Built once per sign-in, not per request: the session payload
* carries the resolved `Principal` so guards can read it without
* re-doing the GUID lookup on every API call.
*
* The `identity` parameter (UUIDs from `PersonAndUserProvisioner`)
* populates `Principal.user.{id, personId}` with the real portal-
* side identifiers introduced in ADR-0026 PR 1 — pre-PR-1 the BFF
* reused the Entra `oid` as a placeholder.
*/
@Injectable()
export class PrincipalBuilder {
constructor(
@Inject(ENTRA_GROUP_TO_ROLE_RESOLVER)
private readonly groupToRole: EntraGroupToRoleResolver,
private readonly scopes: ScopeResolver,
private readonly logger: Logger,
) {}
async build(
user: AuthenticatedUser,
identity: { userId: string; personId: string },
): Promise<Principal> {
const privileges = filterPrivileges(user.roles, user.oid, this.logger);
const roles = this.groupToRole.resolve(user.groups, (groupId: string) => {
this.logger.warn(
{
event: 'auth.unknown_group_claim',
oid: user.oid,
groupId,
},
'PrincipalBuilder',
);
});
const scopes = await this.scopes.resolve({ userId: identity.userId });
return {
user: {
id: identity.userId,
personId: identity.personId,
entraOid: user.oid,
tenantId: user.tid,
displayName: user.displayName,
},
privileges,
roles,
scopes,
amr: user.amr,
};
}
}
function filterPrivileges(
rawRoles: readonly string[],
oid: string,
logger: Logger,
): ReadonlyArray<Privilege> {
const out: Privilege[] = [];
for (const value of rawRoles) {
if (isPrivilege(value)) {
out.push(value);
} else {
// A value in the `roles` claim that is not a known privilege
// is either a leftover from a different app registration, a
// typo in the Entra manifest, or a future privilege not yet
// in the catalogue. None of those should silently grant
// access — drop with a WARN so an operator can investigate.
logger.warn(
{
event: 'auth.unknown_privilege_claim',
oid,
value,
},
'PrincipalBuilder',
);
}
}
return out;
}
@@ -0,0 +1,115 @@
import type { Request } from 'express';
import type { Principal, Scope } from 'shared-auth';
import { readSessionPrincipal, scopesToAuditStrings } from './principal-extractor';
const PRINCIPAL: Principal = {
user: {
id: 'user-1',
personId: 'person-1',
entraOid: 'oid-1',
tenantId: 'tenant-1',
displayName: 'Alice',
},
privileges: ['Portal.Admin'],
roles: ['rh'],
scopes: [{ kind: 'unrestricted' }],
amr: ['pwd', 'mfa'],
};
function makeReq(session: Record<string, unknown> | undefined): Request {
return { session } as unknown as Request;
}
describe('readSessionPrincipal', () => {
it('returns null when the request has no session at all', () => {
expect(readSessionPrincipal(makeReq(undefined))).toBeNull();
});
it('returns null when the session has neither principal nor legacy user', () => {
expect(readSessionPrincipal(makeReq({}))).toBeNull();
});
it('returns the principal verbatim when session.principal is set', () => {
const out = readSessionPrincipal(makeReq({ principal: PRINCIPAL }));
expect(out).toBe(PRINCIPAL);
});
describe('legacy bridge — session.user only (pre-ADR-0025 sessions)', () => {
it('synthesises a principal carrying the Portal.* values as privileges', () => {
const req = makeReq({
user: {
oid: 'legacy-oid',
tid: 'tenant-7',
username: 'legacy@example',
displayName: 'Legacy Joe',
amr: ['pwd', 'mfa'],
roles: ['Portal.Admin', 'Portal.DPO', 'editor', 'auditor'],
groups: [],
},
});
const out = readSessionPrincipal(req);
expect(out).not.toBeNull();
expect(out!.privileges).toEqual(['Portal.Admin', 'Portal.DPO']);
expect(out!.user.entraOid).toBe('legacy-oid');
expect(out!.user.tenantId).toBe('tenant-7');
});
it('mirrors entraOid into user.id and user.personId (ADR-0026 placeholder)', () => {
const req = makeReq({
user: {
oid: 'legacy-oid',
tid: 'tenant-7',
username: 'legacy@example',
displayName: 'Legacy Joe',
amr: [],
roles: [],
groups: [],
},
});
const out = readSessionPrincipal(req);
expect(out!.user.id).toBe('legacy-oid');
expect(out!.user.personId).toBe('legacy-oid');
});
it('legacy sessions get no functional roles and a coarse unrestricted scope', () => {
const req = makeReq({
user: {
oid: 'legacy-oid',
tid: 'tenant-7',
username: 'legacy@example',
displayName: 'Legacy Joe',
amr: [],
roles: ['Portal.Admin'],
groups: [],
},
});
const out = readSessionPrincipal(req);
expect(out!.roles).toEqual([]);
expect(out!.scopes).toEqual([{ kind: 'unrestricted' }]);
});
});
});
describe('scopesToAuditStrings', () => {
it('renders implicit scopes as the kind verbatim', () => {
const scopes: Scope[] = [{ kind: 'self' }, { kind: 'siege' }, { kind: 'unrestricted' }];
expect(scopesToAuditStrings(scopes)).toEqual(['self', 'siege', 'unrestricted']);
});
it('renders value-bearing scopes as kind:value', () => {
const scopes: Scope[] = [
{ kind: 'etablissement', value: '0330800013' },
{ kind: 'delegation', value: '33' },
{ kind: 'region', value: '75' },
];
expect(scopesToAuditStrings(scopes)).toEqual([
'etablissement:0330800013',
'delegation:33',
'region:75',
]);
});
it('returns an empty array on an empty scope list', () => {
expect(scopesToAuditStrings([])).toEqual([]);
});
});
@@ -0,0 +1,74 @@
import type { Request } from 'express';
import type { Principal, Scope } from 'shared-auth';
/**
* Reads the authorization `Principal` off the express session,
* coalescing the legacy session-shape carryover so a session that
* was minted before the ADR-0025 implementation landed still works
* for the duration of its 12 h absolute TTL.
*
* Returns `null` when no session is present at all (the guard
* should answer 401 — the user is anonymous). Returns a
* synthesised `Principal` derived from `session.user` when the
* session is authenticated but predates the `principal` field —
* the synthesised principal carries the `Portal.*` privileges
* from the legacy `roles` claim, no functional roles (the
* `groups` claim was unconfigured at the time), and an
* `unrestricted` scope. After every persisted session has cycled
* through the absolute-timeout window post-deploy, this fallback
* is dead code.
*/
export function readSessionPrincipal(req: Request): Principal | null {
const session = req.session;
if (session === undefined) {
return null;
}
if (session.principal !== undefined) {
return session.principal;
}
const user = session.user;
if (user === undefined) {
return null;
}
// Legacy session bridge. Filter `user.roles` through a generic
// `Portal.*` heuristic rather than re-importing `isPrivilege`
// here — the file's whole purpose is graceful degradation; if
// the legacy claim carries a non-catalogue Portal.X the matcher
// downstream just returns false on it.
const portalPrivileges = user.roles.filter((r) => r.startsWith('Portal.'));
const fallback: Principal = {
user: {
id: user.oid,
personId: user.oid,
entraOid: user.oid,
tenantId: user.tid,
displayName: user.displayName,
},
privileges: portalPrivileges as Principal['privileges'],
roles: [],
scopes: [{ kind: 'unrestricted' } satisfies Scope],
amr: user.amr,
};
return fallback;
}
/**
* Format a scope list as the `string[]` shape the
* `AuthorizationDeniedInput.held` audit field expects:
* `unrestricted` / `self` / `siege` ride as-is;
* `etablissement:<finess>` / `delegation:<dept>` / `region:<insee>`
* concatenate kind and value. Mirrors `PrincipalProjector` in
* spirit but is independent (the audit module must not depend on
* the AI-bridge projector — different surfaces).
*/
export function scopesToAuditStrings(scopes: ReadonlyArray<Scope>): string[] {
return scopes.map((s) => {
if (s.kind === 'etablissement' || s.kind === 'delegation' || s.kind === 'region') {
return `${s.kind}:${s.value}`;
}
return s.kind;
});
}
@@ -0,0 +1,129 @@
import type { Logger } from 'nestjs-pino';
import type { PrismaService } from 'nestjs-prisma';
import { PrismaScopeResolver, toScope } from './prisma-scope-resolver';
interface PrismaStub {
userScope: {
findMany: jest.Mock;
};
}
function makeLogger() {
return { log: jest.fn(), warn: jest.fn(), error: jest.fn() } as unknown as Logger & {
log: jest.Mock;
warn: jest.Mock;
error: jest.Mock;
};
}
function makeSubject(opts?: { findMany?: jest.Mock }): {
resolver: PrismaScopeResolver;
prisma: PrismaStub;
logger: ReturnType<typeof makeLogger>;
} {
const prisma: PrismaStub = {
userScope: {
findMany: opts?.findMany ?? jest.fn().mockResolvedValue([]),
},
};
const logger = makeLogger();
const resolver = new PrismaScopeResolver(prisma as unknown as PrismaService, logger);
return { resolver, prisma, logger };
}
describe('PrismaScopeResolver.resolve — query shape', () => {
it('filters by userId and excludes rows whose expiresAt has passed', async () => {
const { resolver, prisma } = makeSubject();
await resolver.resolve({ userId: 'user-uuid-1' });
expect(prisma.userScope.findMany).toHaveBeenCalledTimes(1);
const args = prisma.userScope.findMany.mock.calls[0]?.[0] as {
where: {
userId: string;
OR: ReadonlyArray<{ expiresAt: Date | { gt: Date } | null }>;
};
select: { kind: boolean; value: boolean };
};
expect(args.where.userId).toBe('user-uuid-1');
expect(args.where.OR).toHaveLength(2);
expect(args.where.OR[0]).toEqual({ expiresAt: null });
// The second predicate carries a `gt: <Date>` matching "expiresAt > NOW()".
expect(args.where.OR[1]).toMatchObject({ expiresAt: { gt: expect.any(Date) } });
expect(args.select).toEqual({ kind: true, value: true });
});
it('returns an empty array when the user has no scope rows', async () => {
const { resolver } = makeSubject();
const result = await resolver.resolve({ userId: 'user-uuid-1' });
expect(result).toEqual([]);
});
});
describe('PrismaScopeResolver.resolve — row-to-Scope mapping', () => {
it('maps valueless kinds to `{ kind }`', async () => {
const findMany = jest.fn().mockResolvedValue([
{ kind: 'self', value: '' },
{ kind: 'siege', value: '' },
{ kind: 'unrestricted', value: '' },
]);
const { resolver } = makeSubject({ findMany });
const result = await resolver.resolve({ userId: 'u' });
expect(result).toEqual([{ kind: 'self' }, { kind: 'siege' }, { kind: 'unrestricted' }]);
});
it('maps value-bearing kinds to `{ kind, value }`', async () => {
const findMany = jest.fn().mockResolvedValue([
{ kind: 'etablissement', value: '0330800013' },
{ kind: 'delegation', value: '33' },
{ kind: 'region', value: '75' },
]);
const { resolver } = makeSubject({ findMany });
const result = await resolver.resolve({ userId: 'u' });
expect(result).toEqual([
{ kind: 'etablissement', value: '0330800013' },
{ kind: 'delegation', value: '33' },
{ kind: 'region', value: '75' },
]);
});
it('skips + warns on off-catalogue kinds (defense in depth — drift gate is the canonical guard)', async () => {
const findMany = jest.fn().mockResolvedValue([
{ kind: 'self', value: '' },
{ kind: 'phantom-kind', value: '' },
{ kind: 'unrestricted', value: '' },
]);
const { resolver, logger } = makeSubject({ findMany });
const result = await resolver.resolve({ userId: 'u' });
expect(result).toEqual([{ kind: 'self' }, { kind: 'unrestricted' }]);
expect(logger.warn).toHaveBeenCalledWith(
expect.objectContaining({
event: 'scope_resolver.unknown_kind',
userId: 'u',
kind: 'phantom-kind',
}),
'PrismaScopeResolver',
);
});
});
describe('toScope — pure mapping helper', () => {
it('returns null for off-catalogue kinds', () => {
expect(toScope('phantom-kind', '')).toBeNull();
expect(toScope('', '')).toBeNull();
expect(toScope('Etablissement', '0330800013')).toBeNull(); // case-sensitive
});
it('drops the value for valueless kinds (even if a stale value is in the row)', () => {
expect(toScope('self', 'stale')).toEqual({ kind: 'self' });
expect(toScope('siege', 'stale')).toEqual({ kind: 'siege' });
expect(toScope('unrestricted', 'stale')).toEqual({ kind: 'unrestricted' });
});
it('preserves the value verbatim for value-bearing kinds', () => {
expect(toScope('etablissement', '0330800013')).toEqual({
kind: 'etablissement',
value: '0330800013',
});
expect(toScope('delegation', '2A')).toEqual({ kind: 'delegation', value: '2A' });
expect(toScope('region', '75')).toEqual({ kind: 'region', value: '75' });
});
});
@@ -0,0 +1,91 @@
import { Injectable } from '@nestjs/common';
import { Logger } from 'nestjs-pino';
import { PrismaService } from 'nestjs-prisma';
import { isScopeKind, type Scope } from 'shared-auth';
import { ScopeResolver } from './scope-resolver';
/**
* `PrismaScopeResolver` — production implementation of
* [`ScopeResolver`](scope-resolver.ts) backed by the `user_scopes`
* table introduced in ADR-0026 PR 1.
*
* Replaces `StubScopeResolver` (which always returned
* `[{ kind: 'unrestricted' }]`) per ADR-0025 §"Sources of truth —
* apf_portal-side `user_scopes` table". Called once per sign-in by
* `PrincipalBuilder.build`; the resolved scopes ride on the
* session-resident `Principal` so guards never re-query at request
* time.
*
* **Expiry handling.** Rows with a non-null `expiresAt` in the past
* are filtered server-side (`expiresAt IS NULL OR expiresAt > NOW()`).
* The unique-tuple constraint on `(userId, kind, value)` means a
* persona with a `delegation:33` scope that just expired can be
* re-granted by inserting a new row — the resolver picks up the
* fresh one on the next sign-in. No background cleanup job in v1;
* expired rows are harmless until either a re-grant fires the
* unique constraint or an admin manually purges them.
*
* **Catalogue defense.** Rows with an unknown `kind` (off-catalogue
* — should be impossible given the drift gate, but possible if a
* future migration writes raw SQL or a future operator runs an
* unguarded INSERT) are skipped + logged. Defensive read; the write
* path is the canonical place to enforce catalogue membership.
*/
@Injectable()
export class PrismaScopeResolver extends ScopeResolver {
constructor(
private readonly prisma: PrismaService,
private readonly logger: Logger,
) {
super();
}
async resolve({ userId }: { userId: string }): Promise<ReadonlyArray<Scope>> {
const now = new Date();
const rows = await this.prisma.userScope.findMany({
where: {
userId,
OR: [{ expiresAt: null }, { expiresAt: { gt: now } }],
},
select: { kind: true, value: true },
});
const scopes: Scope[] = [];
for (const row of rows) {
const scope = toScope(row.kind, row.value);
if (scope === null) {
this.logger.warn(
{
event: 'scope_resolver.unknown_kind',
userId,
kind: row.kind,
},
'PrismaScopeResolver',
);
continue;
}
scopes.push(scope);
}
return scopes;
}
}
/**
* Map a `(kind, value)` DB row to a typed `Scope`. Returns null for
* off-catalogue kinds so the resolver can log + skip them without a
* throw. Exported for the spec — call sites should always go through
* the resolver.
*/
export function toScope(kind: string, value: string): Scope | null {
if (!isScopeKind(kind)) return null;
switch (kind) {
case 'self':
case 'siege':
case 'unrestricted':
return { kind };
case 'etablissement':
case 'delegation':
case 'region':
return { kind, value };
}
}
@@ -53,6 +53,7 @@ const USER = {
displayName: 'Jane',
amr: ['pwd', 'mfa'],
roles: ['admin'],
groups: [],
};
describe('RequireMfaGuard', () => {
@@ -0,0 +1,33 @@
import { SetMetadata, UseGuards, applyDecorators } from '@nestjs/common';
import type { Privilege } from 'shared-auth';
import { RequirePrivilegeGuard } from './require-privilege.guard';
/**
* Reflector metadata key carrying the privilege list a route
* requires. Re-exported by the guard module so the two stay in
* lockstep.
*/
export const REQUIRE_PRIVILEGE_METADATA = 'auth:require-privilege';
/**
* `@RequirePrivilege('Portal.X', ...)` — gates a route on
* `principal.privileges` containing at least one of the listed
* `Portal.*` values per [ADR-0025 §"Guard surface"](../../../../docs/decisions/0025-authorization-model-privileges-roles-scopes.md).
*
* Multiple slugs compose as **OR** within the decorator. Stacking
* `@RequirePrivilege` with another guard (`@RequireRole`,
* `@RequireMfa`) **AND**-combines at the Nest level — each guard
* runs separately and a single denial stops the request.
*
* Calling with an empty privilege list is rejected at the type
* level by the `[Privilege, ...Privilege[]]` tuple — a route can
* not opt out of authorization by passing zero values.
*/
export function RequirePrivilege(
...privileges: [Privilege, ...Privilege[]]
): ClassDecorator & MethodDecorator {
return applyDecorators(
SetMetadata(REQUIRE_PRIVILEGE_METADATA, privileges),
UseGuards(RequirePrivilegeGuard),
);
}
@@ -0,0 +1,133 @@
import { ExecutionContext, ForbiddenException, UnauthorizedException } from '@nestjs/common';
import type { Reflector } from '@nestjs/core';
import type { Request } from 'express';
import type { Principal, Privilege } from 'shared-auth';
import type { AuditWriter } from '../audit/audit.service';
import { RequirePrivilegeGuard } from './require-privilege.guard';
function principalFor(privileges: readonly Privilege[]): Principal {
return {
user: {
id: 'user-1',
personId: 'user-1',
entraOid: 'oid-1',
tenantId: 'tenant-1',
displayName: 'Alice',
},
privileges,
roles: [],
scopes: [{ kind: 'unrestricted' }],
amr: ['pwd', 'mfa'],
};
}
function makeContext(opts: {
metadata: readonly Privilege[] | undefined;
session?: { principal?: Principal };
method?: string;
url?: string;
}): { ctx: ExecutionContext; req: Request } {
const req = {
session: opts.session,
method: opts.method ?? 'GET',
originalUrl: opts.url ?? '/api/audit',
} as unknown as Request;
const ctx = {
switchToHttp: () => ({ getRequest: <T>() => req as T }),
getHandler: () => 'handler',
getClass: () => 'AuditController',
} as unknown as ExecutionContext;
return { ctx, req };
}
function makeGuard(opts: { metadata: readonly Privilege[] | undefined; audit?: jest.Mock }): {
guard: RequirePrivilegeGuard;
audit: jest.Mock;
} {
const audit = opts.audit ?? jest.fn().mockResolvedValue(undefined);
const reflector = {
getAllAndOverride: jest.fn().mockReturnValue(opts.metadata),
} as unknown as Reflector;
const writer = { authorizationDenied: audit } as unknown as AuditWriter;
return { guard: new RequirePrivilegeGuard(reflector, writer), audit };
}
describe('RequirePrivilegeGuard', () => {
it('passes when no metadata is set (decorator absent — guard wired but route unprotected)', async () => {
const { guard } = makeGuard({ metadata: undefined });
const { ctx } = makeContext({ metadata: undefined });
await expect(guard.canActivate(ctx)).resolves.toBe(true);
});
it('throws 401 when no session is present', async () => {
const { guard, audit } = makeGuard({ metadata: ['Portal.Admin'] });
const { ctx } = makeContext({ metadata: ['Portal.Admin'], session: undefined });
await expect(guard.canActivate(ctx)).rejects.toBeInstanceOf(UnauthorizedException);
expect(audit).not.toHaveBeenCalled();
});
it('passes when the principal carries the required privilege', async () => {
const { guard } = makeGuard({ metadata: ['Portal.Admin'] });
const { ctx } = makeContext({
metadata: ['Portal.Admin'],
session: { principal: principalFor(['Portal.Admin']) },
});
await expect(guard.canActivate(ctx)).resolves.toBe(true);
});
it('OR-combines multiple required privileges (matches any one)', async () => {
const { guard } = makeGuard({
metadata: ['Portal.Auditor', 'Portal.Admin'],
});
const { ctx } = makeContext({
metadata: ['Portal.Auditor', 'Portal.Admin'],
session: { principal: principalFor(['Portal.Auditor']) },
});
await expect(guard.canActivate(ctx)).resolves.toBe(true);
});
it('throws 403 + audits when the principal lacks every required privilege', async () => {
const { guard, audit } = makeGuard({ metadata: ['Portal.Admin'] });
const { ctx } = makeContext({
metadata: ['Portal.Admin'],
session: { principal: principalFor(['Portal.Auditor']) },
method: 'POST',
url: '/api/admin/cms/pages',
});
await expect(guard.canActivate(ctx)).rejects.toBeInstanceOf(ForbiddenException);
expect(audit).toHaveBeenCalledWith({
actor: { oid: 'oid-1' },
attemptedRoute: 'POST /api/admin/cms/pages',
kind: 'privilege',
required: ['Portal.Admin'],
held: ['Portal.Auditor'],
});
});
it('audits the held privileges as the principal saw them, not the legacy roles claim', async () => {
const { guard, audit } = makeGuard({ metadata: ['Portal.Admin'] });
const { ctx } = makeContext({
metadata: ['Portal.Admin'],
session: { principal: principalFor([]) },
});
await expect(guard.canActivate(ctx)).rejects.toBeInstanceOf(ForbiddenException);
expect(audit).toHaveBeenCalledWith(expect.objectContaining({ held: [] }));
});
it('returns a generic forbidden body (no privilege hint leaks)', async () => {
const { guard } = makeGuard({ metadata: ['Portal.Admin'] });
const { ctx } = makeContext({
metadata: ['Portal.Admin'],
session: { principal: principalFor([]) },
});
try {
await guard.canActivate(ctx);
throw new Error('guard did not throw');
} catch (err) {
const body = (err as ForbiddenException).getResponse() as Record<string, unknown>;
expect(body['code']).toBe('forbidden');
expect(body['message']).toBe('Forbidden');
expect(JSON.stringify(body)).not.toContain('Portal.Admin');
}
});
});
@@ -0,0 +1,81 @@
import {
CanActivate,
ExecutionContext,
ForbiddenException,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import type { Request } from 'express';
import { type Privilege, principalHasAnyPrivilege } from 'shared-auth';
import { AuditWriter } from '../audit/audit.service';
import { readSessionPrincipal } from './principal-extractor';
import { REQUIRE_PRIVILEGE_METADATA } from './require-privilege.decorator';
/**
* `RequirePrivilegeGuard` — enforces ADR-0025 privilege gates.
*
* Contract (matches `AdminRoleGuard` for symmetry):
*
* - **No session → 401 `unauthenticated`.** Anonymous traffic is
* noise; no audit row, the SPA kicks off the login flow.
*
* - **Session but missing every required privilege → 403 + audit.**
* The user *is* authenticated; they just are not authorised.
* Audit row: `auth.authorization_denied`, `kind=privilege`,
* `required` = what the route asked for, `held` = what the
* principal actually carries.
*
* - **Session with at least one required privilege → pass.**
*
* The structured error envelope per [ADR-0021](../../../../docs/decisions/0021-phase-2-security-baseline.md):
* the body is `{ error: { code: 'forbidden', message, traceId } }`
* with a generic message — no role / privilege hint, so an
* attacker cannot use 403 responses to enumerate which privilege
* a route gates.
*/
@Injectable()
export class RequirePrivilegeGuard implements CanActivate {
constructor(
private readonly reflector: Reflector,
private readonly audit: AuditWriter,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const required = this.reflector.getAllAndOverride<readonly Privilege[]>(
REQUIRE_PRIVILEGE_METADATA,
[context.getHandler(), context.getClass()],
);
if (required === undefined || required.length === 0) {
// No metadata = guard wired but route un-annotated. Treat as
// pass-through rather than guess at the intent — the decorator
// is what carries the privilege list.
return true;
}
const req = context.switchToHttp().getRequest<Request>();
const principal = readSessionPrincipal(req);
if (principal === null) {
throw new UnauthorizedException({
code: 'unauthenticated',
message: 'Unauthenticated',
});
}
if (principalHasAnyPrivilege(principal, required)) {
return true;
}
await this.audit.authorizationDenied({
actor: { oid: principal.user.entraOid },
attemptedRoute: `${req.method} ${req.originalUrl}`,
kind: 'privilege',
required,
held: [...principal.privileges],
});
throw new ForbiddenException({
code: 'forbidden',
message: 'Forbidden',
});
}
}
@@ -0,0 +1,29 @@
import { SetMetadata, UseGuards, applyDecorators } from '@nestjs/common';
import type { FunctionalRole } from 'shared-auth';
import { RequireRoleGuard } from './require-role.guard';
/**
* Reflector metadata key carrying the functional-role list a
* route requires.
*/
export const REQUIRE_ROLE_METADATA = 'auth:require-role';
/**
* `@RequireRole('rh', ...)` — gates a route on `principal.roles`
* containing at least one of the listed `apf-role-*` slugs per
* [ADR-0025 §"Guard surface"](../../../../docs/decisions/0025-authorization-model-privileges-roles-scopes.md).
*
* Multiple slugs compose as **OR** within the decorator. Stacking
* `@RequireRole` with another guard (`@RequirePrivilege`,
* `@RequireScope`, `@RequireMfa`) **AND**-combines at the Nest
* level.
*
* The empty-list call is rejected at the type level by the
* `[FunctionalRole, ...FunctionalRole[]]` tuple — a route can
* not opt out of authorization by passing zero values.
*/
export function RequireRole(
...roles: [FunctionalRole, ...FunctionalRole[]]
): ClassDecorator & MethodDecorator {
return applyDecorators(SetMetadata(REQUIRE_ROLE_METADATA, roles), UseGuards(RequireRoleGuard));
}
@@ -0,0 +1,111 @@
import { ExecutionContext, ForbiddenException, UnauthorizedException } from '@nestjs/common';
import type { Reflector } from '@nestjs/core';
import type { Request } from 'express';
import type { FunctionalRole, Principal } from 'shared-auth';
import type { AuditWriter } from '../audit/audit.service';
import { RequireRoleGuard } from './require-role.guard';
function principalFor(roles: readonly FunctionalRole[]): Principal {
return {
user: {
id: 'user-1',
personId: 'user-1',
entraOid: 'oid-1',
tenantId: 'tenant-1',
displayName: 'Alice',
},
privileges: [],
roles,
scopes: [{ kind: 'unrestricted' }],
amr: ['pwd', 'mfa'],
};
}
function makeContext(opts: {
session?: { principal?: Principal };
method?: string;
url?: string;
}): { ctx: ExecutionContext; req: Request } {
const req = {
session: opts.session,
method: opts.method ?? 'GET',
originalUrl: opts.url ?? '/api/hr/payroll',
} as unknown as Request;
const ctx = {
switchToHttp: () => ({ getRequest: <T>() => req as T }),
getHandler: () => 'handler',
getClass: () => 'HrController',
} as unknown as ExecutionContext;
return { ctx, req };
}
function makeGuard(opts: { metadata: readonly FunctionalRole[] | undefined }): {
guard: RequireRoleGuard;
audit: jest.Mock;
} {
const audit = jest.fn().mockResolvedValue(undefined);
const reflector = {
getAllAndOverride: jest.fn().mockReturnValue(opts.metadata),
} as unknown as Reflector;
const writer = { authorizationDenied: audit } as unknown as AuditWriter;
return { guard: new RequireRoleGuard(reflector, writer), audit };
}
describe('RequireRoleGuard', () => {
it('passes when no metadata is set', async () => {
const { guard } = makeGuard({ metadata: undefined });
const { ctx } = makeContext({});
await expect(guard.canActivate(ctx)).resolves.toBe(true);
});
it('throws 401 when no session is present', async () => {
const { guard, audit } = makeGuard({ metadata: ['rh'] });
const { ctx } = makeContext({ session: undefined });
await expect(guard.canActivate(ctx)).rejects.toBeInstanceOf(UnauthorizedException);
expect(audit).not.toHaveBeenCalled();
});
it('passes when the principal carries the required role', async () => {
const { guard } = makeGuard({ metadata: ['rh'] });
const { ctx } = makeContext({ session: { principal: principalFor(['rh']) } });
await expect(guard.canActivate(ctx)).resolves.toBe(true);
});
it('OR-combines multiple required roles', async () => {
const { guard } = makeGuard({ metadata: ['rh', 'responsable-paie', 'comptable'] });
const { ctx } = makeContext({
session: { principal: principalFor(['comptable']) },
});
await expect(guard.canActivate(ctx)).resolves.toBe(true);
});
it('throws 403 + audits when the principal lacks every required role', async () => {
const { guard, audit } = makeGuard({ metadata: ['rh'] });
const { ctx } = makeContext({
session: { principal: principalFor(['collaborateur']) },
method: 'GET',
url: '/api/hr/payroll',
});
await expect(guard.canActivate(ctx)).rejects.toBeInstanceOf(ForbiddenException);
expect(audit).toHaveBeenCalledWith({
actor: { oid: 'oid-1' },
attemptedRoute: 'GET /api/hr/payroll',
kind: 'role',
required: ['rh'],
held: ['collaborateur'],
});
});
it('returns a generic forbidden body (no role hint leaks)', async () => {
const { guard } = makeGuard({ metadata: ['rh'] });
const { ctx } = makeContext({ session: { principal: principalFor([]) } });
try {
await guard.canActivate(ctx);
throw new Error('guard did not throw');
} catch (err) {
const body = (err as ForbiddenException).getResponse() as Record<string, unknown>;
expect(body['code']).toBe('forbidden');
expect(JSON.stringify(body)).not.toContain('rh');
}
});
});
@@ -0,0 +1,65 @@
import {
CanActivate,
ExecutionContext,
ForbiddenException,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import type { Request } from 'express';
import { type FunctionalRole, principalHasAnyRole } from 'shared-auth';
import { AuditWriter } from '../audit/audit.service';
import { readSessionPrincipal } from './principal-extractor';
import { REQUIRE_ROLE_METADATA } from './require-role.decorator';
/**
* `RequireRoleGuard` — enforces ADR-0025 functional-role gates.
*
* Same contract as `RequirePrivilegeGuard` with the role axis as
* the source of truth: 401 if anonymous, 403 + audit if
* authenticated but missing every required role, pass otherwise.
* Multiple roles on the decorator are OR-combined; stacking with
* other decorators is AND-combined at the Nest layer.
*/
@Injectable()
export class RequireRoleGuard implements CanActivate {
constructor(
private readonly reflector: Reflector,
private readonly audit: AuditWriter,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const required = this.reflector.getAllAndOverride<readonly FunctionalRole[]>(
REQUIRE_ROLE_METADATA,
[context.getHandler(), context.getClass()],
);
if (required === undefined || required.length === 0) {
return true;
}
const req = context.switchToHttp().getRequest<Request>();
const principal = readSessionPrincipal(req);
if (principal === null) {
throw new UnauthorizedException({
code: 'unauthenticated',
message: 'Unauthenticated',
});
}
if (principalHasAnyRole(principal, required)) {
return true;
}
await this.audit.authorizationDenied({
actor: { oid: principal.user.entraOid },
attemptedRoute: `${req.method} ${req.originalUrl}`,
kind: 'role',
required,
held: [...principal.roles],
});
throw new ForbiddenException({
code: 'forbidden',
message: 'Forbidden',
});
}
}
@@ -0,0 +1,52 @@
import { SetMetadata, UseGuards, applyDecorators } from '@nestjs/common';
import type { Request } from 'express';
import type { ScopableResource } from 'shared-auth';
import { RequireScopeGuard } from './require-scope.guard';
/**
* Reflector metadata key carrying the per-route resource
* extractor. The metadata value is the extractor function
* itself — `Reflect.metadata` happily stores callables and the
* guard invokes it on every request.
*/
export const REQUIRE_SCOPE_METADATA = 'auth:require-scope';
/**
* Extractor signature consumed by `@RequireScope`. Receives the
* incoming Express request, returns the resource descriptor the
* matcher will compare against the principal's scopes. May be
* async — a typical extractor loads the resource by
* `@Param('id')` from Prisma, denormalises its parentage chain
* (`etablissementFiness` / `delegationCode` / `regionCode`), and
* returns the descriptor.
*
* Returning `null` from the extractor means "the resource the
* route protects could not be identified" (a 404 in disguise) —
* the guard treats it as a denial and emits the audit row with
* `required: []`. The route handler still runs if the guard
* lets it through; routes that want a true 404 should surface
* it from the handler.
*/
export type RequireScopeExtractor = (
req: Request,
) => ScopableResource | null | Promise<ScopableResource | null>;
/**
* `@RequireScope(req => …)` — gates a route on
* `principalCoversResource(principal, extractor(req))` per
* [ADR-0025 §"Guard surface"](../../../../docs/decisions/0025-authorization-model-privileges-roles-scopes.md).
*
* The extractor receives the Express request — typically pulls
* an id from `req.params`, loads the resource, returns its
* scope-relevant chain (FINESS / delegation / region). The guard
* does not assume the extractor is cheap — routes that load the
* resource a second time inside the handler should pass the
* Prisma instance via a request-scoped CLS or DI, not via the
* extractor.
*/
export function RequireScope(extractor: RequireScopeExtractor): ClassDecorator & MethodDecorator {
return applyDecorators(
SetMetadata(REQUIRE_SCOPE_METADATA, extractor),
UseGuards(RequireScopeGuard),
);
}
@@ -0,0 +1,156 @@
import { ExecutionContext, ForbiddenException, UnauthorizedException } from '@nestjs/common';
import type { Reflector } from '@nestjs/core';
import type { Request } from 'express';
import type { Principal, ScopableResource, Scope } from 'shared-auth';
import type { AuditWriter } from '../audit/audit.service';
import type { RequireScopeExtractor } from './require-scope.decorator';
import { RequireScopeGuard } from './require-scope.guard';
function principalFor(scopes: readonly Scope[]): Principal {
return {
user: {
id: 'user-1',
personId: 'person-1',
entraOid: 'oid-1',
tenantId: 'tenant-1',
displayName: 'Alice',
},
privileges: [],
roles: [],
scopes,
amr: ['pwd', 'mfa'],
};
}
function makeContext(opts: {
session?: { principal?: Principal };
method?: string;
url?: string;
}): { ctx: ExecutionContext; req: Request } {
const req = {
session: opts.session,
method: opts.method ?? 'GET',
originalUrl: opts.url ?? '/api/etablissements/0330800013',
} as unknown as Request;
const ctx = {
switchToHttp: () => ({ getRequest: <T>() => req as T }),
getHandler: () => 'handler',
getClass: () => 'EtablissementController',
} as unknown as ExecutionContext;
return { ctx, req };
}
function makeGuard(opts: { extractor: RequireScopeExtractor | undefined }): {
guard: RequireScopeGuard;
audit: jest.Mock;
} {
const audit = jest.fn().mockResolvedValue(undefined);
const reflector = {
getAllAndOverride: jest.fn().mockReturnValue(opts.extractor),
} as unknown as Reflector;
const writer = { authorizationDenied: audit } as unknown as AuditWriter;
return { guard: new RequireScopeGuard(reflector, writer), audit };
}
describe('RequireScopeGuard', () => {
it('passes when no extractor metadata is set', async () => {
const { guard } = makeGuard({ extractor: undefined });
const { ctx } = makeContext({});
await expect(guard.canActivate(ctx)).resolves.toBe(true);
});
it('throws 401 when no session is present', async () => {
const { guard, audit } = makeGuard({
extractor: () => ({ etablissementFiness: '0330800013' }),
});
const { ctx } = makeContext({ session: undefined });
await expect(guard.canActivate(ctx)).rejects.toBeInstanceOf(UnauthorizedException);
expect(audit).not.toHaveBeenCalled();
});
it('passes when the principal scope covers the resource', async () => {
const { guard } = makeGuard({
extractor: () => ({ etablissementFiness: '0330800013' }),
});
const { ctx } = makeContext({
session: {
principal: principalFor([{ kind: 'etablissement', value: '0330800013' }]),
},
});
await expect(guard.canActivate(ctx)).resolves.toBe(true);
});
it('honours async extractors (Prisma lookup pattern)', async () => {
const { guard } = makeGuard({
extractor: async () => Promise.resolve({ delegationCode: '33' }),
});
const { ctx } = makeContext({
session: { principal: principalFor([{ kind: 'delegation', value: '33' }]) },
});
await expect(guard.canActivate(ctx)).resolves.toBe(true);
});
it('throws 403 + audits when the resource is outside the principal scopes', async () => {
const { guard, audit } = makeGuard({
extractor: () => ({ etablissementFiness: '0330800021' }),
});
const { ctx } = makeContext({
session: {
principal: principalFor([{ kind: 'etablissement', value: '0330800013' }]),
},
});
await expect(guard.canActivate(ctx)).rejects.toBeInstanceOf(ForbiddenException);
expect(audit).toHaveBeenCalledWith({
actor: { oid: 'oid-1' },
attemptedRoute: 'GET /api/etablissements/0330800013',
kind: 'scope',
required: ['etablissement:0330800021'],
held: ['etablissement:0330800013'],
});
});
it('throws 403 + audits with empty required when the extractor returns null', async () => {
const { guard, audit } = makeGuard({ extractor: () => null });
const { ctx } = makeContext({
session: { principal: principalFor([{ kind: 'unrestricted' }]) },
});
await expect(guard.canActivate(ctx)).rejects.toBeInstanceOf(ForbiddenException);
expect(audit).toHaveBeenCalledWith(expect.objectContaining({ kind: 'scope', required: [] }));
});
it('describes the resource correctly when it carries the full parentage chain', async () => {
const resource: ScopableResource = {
etablissementFiness: '0330800021',
delegationCode: '33',
regionCode: '75',
};
const { guard, audit } = makeGuard({ extractor: () => resource });
const { ctx } = makeContext({
session: { principal: principalFor([{ kind: 'region', value: '11' }]) },
});
await expect(guard.canActivate(ctx)).rejects.toBeInstanceOf(ForbiddenException);
expect(audit).toHaveBeenCalledWith(
expect.objectContaining({
required: ['etablissement:0330800021', 'delegation:33', 'region:75'],
held: ['region:11'],
}),
);
});
it('returns a generic forbidden body (no resource fingerprint leaks)', async () => {
const { guard } = makeGuard({
extractor: () => ({ etablissementFiness: '0330800021' }),
});
const { ctx } = makeContext({
session: { principal: principalFor([]) },
});
try {
await guard.canActivate(ctx);
throw new Error('guard did not throw');
} catch (err) {
const body = (err as ForbiddenException).getResponse() as Record<string, unknown>;
expect(body['code']).toBe('forbidden');
expect(JSON.stringify(body)).not.toContain('0330800021');
}
});
});
@@ -0,0 +1,119 @@
import {
CanActivate,
ExecutionContext,
ForbiddenException,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import type { Request } from 'express';
import { principalCoversResource, type ScopableResource } from 'shared-auth';
import { AuditWriter } from '../audit/audit.service';
import { readSessionPrincipal, scopesToAuditStrings } from './principal-extractor';
import { REQUIRE_SCOPE_METADATA, type RequireScopeExtractor } from './require-scope.decorator';
/**
* `RequireScopeGuard` — enforces ADR-0025 scope gates.
*
* Contract
* --------
* - **No session → 401.** Same posture as the privilege / role
* guards.
* - **Extractor returned `null` → 403 + audit.** The route's
* "what resource am I protecting?" question has no answer for
* this request; deny is the safe direction. Audit row carries
* `required: []` so an auditor can spot the missing-resource
* pattern.
* - **No matching scope → 403 + audit.** Standard denial; audit
* captures the resource descriptor as `required` and the
* principal's actual scope list as `held`.
* - **Matching scope → pass.**
*
* Like the other two new guards, the response body is the
* ADR-0021 structured-error envelope with a generic `forbidden`
* code — no resource fingerprint leaks back to the caller.
*/
@Injectable()
export class RequireScopeGuard implements CanActivate {
constructor(
private readonly reflector: Reflector,
private readonly audit: AuditWriter,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const extractor = this.reflector.getAllAndOverride<RequireScopeExtractor>(
REQUIRE_SCOPE_METADATA,
[context.getHandler(), context.getClass()],
);
if (extractor === undefined) {
return true;
}
const req = context.switchToHttp().getRequest<Request>();
const principal = readSessionPrincipal(req);
if (principal === null) {
throw new UnauthorizedException({
code: 'unauthenticated',
message: 'Unauthenticated',
});
}
const resource = await extractor(req);
if (resource === null) {
await this.audit.authorizationDenied({
actor: { oid: principal.user.entraOid },
attemptedRoute: `${req.method} ${req.originalUrl}`,
kind: 'scope',
required: [],
held: scopesToAuditStrings(principal.scopes),
});
throw new ForbiddenException({
code: 'forbidden',
message: 'Forbidden',
});
}
if (principalCoversResource(principal, resource)) {
return true;
}
await this.audit.authorizationDenied({
actor: { oid: principal.user.entraOid },
attemptedRoute: `${req.method} ${req.originalUrl}`,
kind: 'scope',
required: describeResource(resource),
held: scopesToAuditStrings(principal.scopes),
});
throw new ForbiddenException({
code: 'forbidden',
message: 'Forbidden',
});
}
}
/**
* Serialise the resource descriptor into the `string[]` shape the
* audit row's `required` field expects. Mirrors the `scope:value`
* convention of {@link scopesToAuditStrings} so the two sides of
* the audit row read consistently — easy `requiredVs.held` diff
* in a log dashboard.
*/
function describeResource(resource: ScopableResource): string[] {
const out: string[] = [];
if (resource.personId !== undefined) {
out.push(`self:${resource.personId}`);
}
if (resource.etablissementFiness !== undefined) {
out.push(`etablissement:${resource.etablissementFiness}`);
}
if (resource.delegationCode !== undefined) {
out.push(`delegation:${resource.delegationCode}`);
}
if (resource.regionCode !== undefined) {
out.push(`region:${resource.regionCode}`);
}
if (resource.isSiege === true) {
out.push('siege');
}
return out;
}
@@ -0,0 +1,49 @@
import { Injectable } from '@nestjs/common';
import type { Scope } from 'shared-auth';
/**
* Resolves the portal-side scopes for a user being signed in.
*
* Per [ADR-0025 §"Sources of truth — apf_portal-side `user_scopes`
* table"](../../../../docs/decisions/0025-authorization-model-privileges-roles-scopes.md),
* scopes are *not* carried by Entra: they live in the portal-side
* `user_scopes` Prisma table (per ADR-0026 PR 1's schema), keyed on
* `User.id` (UUID). Each row materialises one `Scope` entry on the
* session-resident principal.
*
* The seam is here in v1 so ADR-0026 PR 2 (the `PrismaScopeResolver`
* landing the seed + the `/admin/users/:id/scopes` screen) replaces
* only the implementation, leaving the call-site in
* `PrincipalBuilder` untouched. The v1 stub (`StubScopeResolver`)
* always returns `[{ kind: 'unrestricted' }]` per ADR-0025 §331.
*
* Note: the `input` shape switched from `{ entraOid }` to `{ userId }`
* with ADR-0026 PR 1 (Person + User + UserScope schema landed). The
* stub ignores its argument either way; the change is the seam for
* PR 2's Prisma resolver, which keys queries on `User.id`.
*/
export abstract class ScopeResolver {
abstract resolve(input: { userId: string }): Promise<ReadonlyArray<Scope>>;
}
/**
* v1 stub — returns `unrestricted` for every user. Per ADR-0025
* §"Sources of truth — apf_portal-side `user_scopes` table" the
* real implementation queries `user_scopes WHERE userId = ?`; that
* lands as `PrismaScopeResolver` in ADR-0026 PR 2 alongside the
* test-tenant seed.
*
* Deliberately not an in-memory hard-coded persona-keyed map: the
* 19 test personas have *intended* scopes (see
* `notes/test-tenant-role-assignments.md`) but those scopes have
* no guard consuming them yet, so per-persona stub data would be
* write-only documentation. ADR-0026 PR 2 populates the table; this
* class is replaced by a Prisma-backed implementation in the same
* change.
*/
@Injectable()
export class StubScopeResolver extends ScopeResolver {
override resolve(): Promise<ReadonlyArray<Scope>> {
return Promise.resolve([{ kind: 'unrestricted' }]);
}
}
@@ -1,9 +1,12 @@
import type { Request, Response } from 'express';
import type { Logger } from 'nestjs-pino';
import type { Principal } from 'shared-auth';
import type { AuditWriter } from '../audit/audit.service';
import type { UserSessionIndexService } from '../session/user-session-index.service';
import type { PersonAndUserProvisioner } from '../users/person-and-user-provisioner.service';
import type { UserDirectoryService } from '../users/user-directory.service';
import type { AuthenticatedUser } from './auth.service';
import type { PrincipalBuilder } from './principal-builder';
import { SessionEstablisher } from './session-establisher.service';
const USER: AuthenticatedUser = {
@@ -13,6 +16,7 @@ const USER: AuthenticatedUser = {
displayName: 'Jane Doe',
amr: ['pwd', 'mfa'],
roles: [],
groups: [],
};
function makeReqStub(opts?: { sessionID?: string; sessionUser?: AuthenticatedUser }): Request {
@@ -52,9 +56,30 @@ interface Fixture {
index: { add: jest.Mock; remove: jest.Mock; list: jest.Mock };
audit: { signIn: jest.Mock; signOut: jest.Mock };
directory: { recordSignIn: jest.Mock };
provisioner: { ensureUser: jest.Mock };
logger: ReturnType<typeof makeLoggerStub>;
principalBuilder: { build: jest.Mock };
}
const PROVISIONED_IDENTITY = {
userId: 'user-uuid-1',
personId: 'person-uuid-1',
};
const STUB_PRINCIPAL: Principal = {
user: {
id: PROVISIONED_IDENTITY.userId,
personId: PROVISIONED_IDENTITY.personId,
entraOid: USER.oid,
tenantId: USER.tid,
displayName: USER.displayName,
},
privileges: [],
roles: [],
scopes: [{ kind: 'unrestricted' }],
amr: USER.amr,
};
function makeFixture(): Fixture {
const index = {
add: jest.fn().mockResolvedValue(undefined),
@@ -68,14 +93,22 @@ function makeFixture(): Fixture {
const directory = {
recordSignIn: jest.fn().mockResolvedValue(undefined),
};
const provisioner = {
ensureUser: jest.fn().mockResolvedValue(PROVISIONED_IDENTITY),
};
const logger = makeLoggerStub();
const principalBuilder = {
build: jest.fn().mockResolvedValue(STUB_PRINCIPAL),
};
const est = new SessionEstablisher(
logger as unknown as Logger,
index as unknown as UserSessionIndexService,
audit as unknown as AuditWriter,
directory as unknown as UserDirectoryService,
provisioner as unknown as PersonAndUserProvisioner,
principalBuilder as unknown as PrincipalBuilder,
);
return { est, index, audit, directory, logger };
return { est, index, audit, directory, provisioner, logger, principalBuilder };
}
describe('SessionEstablisher.establish', () => {
@@ -97,6 +130,56 @@ describe('SessionEstablisher.establish', () => {
expect((sess['csrfToken'] as string).length).toBeGreaterThan(20);
});
it('provisions Person + User before building the Principal (ADR-0026 §Lifecycle)', async () => {
const { est, provisioner } = makeFixture();
const req = makeReqStub();
const res = makeResStub();
await est.establish({ user: USER, req, res, surface: 'user' });
expect(provisioner.ensureUser).toHaveBeenCalledWith({
oid: USER.oid,
tenantId: USER.tid,
displayName: USER.displayName,
// Entra preferred_username (= AuthenticatedUser.username) maps
// to Person.email per ADR-0026 §"Lifecycle".
email: USER.username,
});
});
it('builds the authorization principal with the provisioned UUIDs and stamps mfaVerifiedAt (ADR-0025 + ADR-0026)', async () => {
const { est, provisioner, principalBuilder } = makeFixture();
const req = makeReqStub();
const res = makeResStub();
await est.establish({ user: USER, req, res, surface: 'user' });
expect(principalBuilder.build).toHaveBeenCalledWith(USER, PROVISIONED_IDENTITY);
// Order: provisioner MUST run before principalBuilder so the
// build call has real UUIDs to populate Principal.user.{id,personId}.
const provisionerOrder = provisioner.ensureUser.mock.invocationCallOrder[0] ?? Infinity;
const buildOrder = principalBuilder.build.mock.invocationCallOrder[0] ?? 0;
expect(provisionerOrder).toBeLessThan(buildOrder);
const sess = (req as unknown as { session: Record<string, unknown> }).session;
const principal = sess['principal'] as Principal;
expect(principal).toBeDefined();
expect(principal.user.entraOid).toBe(USER.oid);
expect(principal.user.id).toBe(PROVISIONED_IDENTITY.userId);
expect(principal.user.personId).toBe(PROVISIONED_IDENTITY.personId);
expect(principal.scopes).toEqual([{ kind: 'unrestricted' }]);
expect(principal.mfaVerifiedAt).toBe(sess['createdAt']);
});
it('propagates provisioner failures (blocking per ADR-0026)', async () => {
const { est, provisioner, audit, directory, principalBuilder } = makeFixture();
provisioner.ensureUser.mockRejectedValueOnce(new Error('postgres unreachable'));
const req = makeReqStub();
const res = makeResStub();
await expect(est.establish({ user: USER, req, res, surface: 'user' })).rejects.toThrow(
'postgres unreachable',
);
// Nothing downstream of the provisioner runs when it fails.
expect(principalBuilder.build).not.toHaveBeenCalled();
expect(audit.signIn).not.toHaveBeenCalled();
expect(directory.recordSignIn).not.toHaveBeenCalled();
});
it('saves the session before returning (no race with the controller redirect)', async () => {
const { est } = makeFixture();
const req = makeReqStub();
@@ -6,8 +6,10 @@ import { AuditWriter } from '../audit/audit.service';
import { csrfCookieName, csrfCookieOptions } from '../security/csrf-cookie';
import { readSessionTimeouts } from '../session/session-cookie';
import { UserSessionIndexService } from '../session/user-session-index.service';
import { PersonAndUserProvisioner } from '../users/person-and-user-provisioner.service';
import { UserDirectoryService } from '../users/user-directory.service';
import type { AuthenticatedUser } from './auth.service';
import { PrincipalBuilder } from './principal-builder';
export type AuthSurface = 'user' | 'admin';
@@ -47,6 +49,8 @@ export class SessionEstablisher {
private readonly userSessionIndex: UserSessionIndexService,
private readonly audit: AuditWriter,
private readonly userDirectory: UserDirectoryService,
private readonly personUserProvisioner: PersonAndUserProvisioner,
private readonly principalBuilder: PrincipalBuilder,
) {}
async establish(opts: {
@@ -79,6 +83,28 @@ export class SessionEstablisher {
// does not re-validate factors. Refreshed by future step-up
// re-auth flows.
req.session.mfaVerifiedAt = now;
// Provision Person + User (blocking per ADR-0026 §"Lifecycle"):
// the BFF cannot build a Principal without User.id / Person.id,
// so a Postgres outage here fails the sign-in. Idempotent —
// returns the existing pair's UUIDs on subsequent sign-ins.
// Note: the Entra `preferred_username` claim maps to Person.email
// (it's typically the user's UPN / email); `AuthenticatedUser.username`
// is the BFF's name for the same field.
const identity = await this.personUserProvisioner.ensureUser({
oid: user.oid,
tenantId: user.tid,
displayName: user.displayName,
email: user.username,
});
// Authorization principal per ADR-0025: composes the three
// axes (privileges / functional roles / scopes) once at
// sign-in so guards read a single coherent shape instead of
// re-parsing claims on every request. Built before the
// session is persisted so a Redis hiccup either persists
// everything or nothing.
const principal = await this.principalBuilder.build(user, identity);
req.session.principal = { ...principal, mfaVerifiedAt: now };
await saveSession(req);
@@ -0,0 +1,90 @@
import { describe, it, expect, beforeEach, afterAll } from '@jest/globals';
import { assertAiServiceConfig } from './check-ai-service-config';
/**
* Pre-flight validation contract for `AI_SERVICE_*` env vars.
* Mirrors the test posture of `check-log-user-id-salt.spec.ts` —
* each rejection path produces an actionable error message.
*/
const ORIGINAL_ENV = { ...process.env };
function setEnv(values: Record<string, string | undefined>): void {
for (const [key, value] of Object.entries(values)) {
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
}
beforeEach(() => {
delete process.env['AI_SERVICE_GRPC_ENDPOINT'];
delete process.env['AI_SERVICE_CLIENT_ID'];
delete process.env['AI_SERVICE_GRPC_TLS'];
});
afterAll(() => {
process.env = { ...ORIGINAL_ENV };
});
describe('assertAiServiceConfig', () => {
it('returns the parsed config when every required var is present', () => {
setEnv({
AI_SERVICE_GRPC_ENDPOINT: 'apf-ai-service:8080',
AI_SERVICE_CLIENT_ID: 'apf-portal-dev',
AI_SERVICE_GRPC_TLS: 'false',
});
expect(assertAiServiceConfig()).toEqual({
endpoint: 'apf-ai-service:8080',
clientId: 'apf-portal-dev',
useTls: false,
});
});
it('defaults useTls to true when AI_SERVICE_GRPC_TLS is unset', () => {
setEnv({
AI_SERVICE_GRPC_ENDPOINT: 'apf-ai.internal:443',
AI_SERVICE_CLIENT_ID: 'apf-portal-prod',
});
expect(assertAiServiceConfig().useTls).toBe(true);
});
it('throws when AI_SERVICE_GRPC_ENDPOINT is missing', () => {
setEnv({ AI_SERVICE_CLIENT_ID: 'apf-portal-dev' });
expect(() => assertAiServiceConfig()).toThrow(/AI_SERVICE_GRPC_ENDPOINT is not set/);
});
it('throws when AI_SERVICE_GRPC_ENDPOINT does not match host:port', () => {
setEnv({
AI_SERVICE_GRPC_ENDPOINT: 'no-port-here',
AI_SERVICE_CLIENT_ID: 'apf-portal-dev',
});
expect(() => assertAiServiceConfig()).toThrow(/must match "host:port"/);
});
it('throws when AI_SERVICE_CLIENT_ID is missing', () => {
setEnv({ AI_SERVICE_GRPC_ENDPOINT: 'apf-ai-service:8080' });
expect(() => assertAiServiceConfig()).toThrow(/AI_SERVICE_CLIENT_ID is not set/);
});
it('throws when AI_SERVICE_CLIENT_ID has invalid characters', () => {
setEnv({
AI_SERVICE_GRPC_ENDPOINT: 'apf-ai-service:8080',
AI_SERVICE_CLIENT_ID: 'Apf Portal Dev',
});
expect(() => assertAiServiceConfig()).toThrow(/lowercase kebab-case slug/);
});
it('throws when AI_SERVICE_GRPC_TLS is neither "true" nor "false"', () => {
setEnv({
AI_SERVICE_GRPC_ENDPOINT: 'apf-ai-service:8080',
AI_SERVICE_CLIENT_ID: 'apf-portal-dev',
AI_SERVICE_GRPC_TLS: 'yes',
});
expect(() => assertAiServiceConfig()).toThrow(/must be "true" or "false"/);
});
});
@@ -0,0 +1,79 @@
/**
* Validate the env vars that drive the BFF's gRPC client to
* `apf-ai-service`, per ADR-0024.
*
* Three knobs, all required at module-init time of `AiClientModule`:
*
* - `AI_SERVICE_GRPC_ENDPOINT` — `host:port` reachable from the
* BFF. `apf-ai-service:8080` in the dev Compose network;
* a service DNS name + 443 in prod behind an edge proxy that
* terminates the public TLS and forwards h2 internally.
* - `AI_SERVICE_CLIENT_ID` — deployment slug propagated as the
* `x-client-id` gRPC metadata on every call (per
* `apf-ai-service/docs/contract.md`). Naming convention:
* `apf-portal-<env>` (`apf-portal-dev`, `apf-portal-preprod`,
* `apf-portal-prod`).
* - `AI_SERVICE_GRPC_TLS` — `'true'` to dial with TLS credentials
* against a CA-signed cert (default for non-dev), `'false'` for
* h2c against the Compose network in dev. Any other value is
* rejected to keep the cleartext-vs-TLS toggle explicit.
*
* Following the same family as `assertLogUserIdSalt()` and the
* other config validators under `apps/portal-bff/src/config/`,
* per ADR-0018 §"BFF env-var loading" — small, per-key, run at
* bootstrap, throw with an actionable message on misconfiguration.
*/
export interface AiServiceConfig {
readonly endpoint: string;
readonly clientId: string;
readonly useTls: boolean;
}
const ENDPOINT_PATTERN = /^[a-z0-9.-]+:[0-9]{2,5}$/i;
const CLIENT_ID_PATTERN = /^[a-z0-9-]+$/;
export function assertAiServiceConfig(): AiServiceConfig {
const endpoint = process.env['AI_SERVICE_GRPC_ENDPOINT'];
if (!endpoint || endpoint === '') {
throw new Error(
`AI_SERVICE_GRPC_ENDPOINT is not set. Expected "host:port" reachable from the BFF, ` +
`e.g. "apf-ai-service:8080" in dev Compose or "apf-ai.internal:443" in prod.`,
);
}
if (!ENDPOINT_PATTERN.test(endpoint)) {
throw new Error(
`AI_SERVICE_GRPC_ENDPOINT must match "host:port" (got: ${JSON.stringify(endpoint)}). ` +
`Hostname uses a-z 0-9 . - characters; port is 2-5 digits.`,
);
}
const clientId = process.env['AI_SERVICE_CLIENT_ID'];
if (!clientId || clientId === '') {
throw new Error(
`AI_SERVICE_CLIENT_ID is not set. Use a deployment slug like ` +
`"apf-portal-dev" / "apf-portal-preprod" / "apf-portal-prod"; ` +
`propagated as the x-client-id gRPC metadata on every call.`,
);
}
if (!CLIENT_ID_PATTERN.test(clientId)) {
throw new Error(
`AI_SERVICE_CLIENT_ID must be a lowercase kebab-case slug ` +
`(got: ${JSON.stringify(clientId)}). Pattern: ^[a-z0-9-]+$.`,
);
}
const tlsRaw = process.env['AI_SERVICE_GRPC_TLS'] ?? 'true';
if (tlsRaw !== 'true' && tlsRaw !== 'false') {
throw new Error(
`AI_SERVICE_GRPC_TLS must be "true" or "false" (got: ${JSON.stringify(tlsRaw)}). ` +
`Default in prod is "true"; the dev Compose stack sets it to "false" for h2c.`,
);
}
return {
endpoint,
clientId,
useTls: tlsRaw === 'true',
};
}
@@ -0,0 +1,93 @@
import { mkdtempSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { EntraGroupMapError } from 'shared-auth';
import { loadEntraGroupToRoleResolver } from './load-entra-group-map';
function tmpFile(name: string, contents: string): string {
const dir = mkdtempSync(join(tmpdir(), 'entra-map-'));
const path = join(dir, name);
writeFileSync(path, contents, 'utf8');
return path;
}
function noopWarn(): { onWarn: jest.Mock } {
return { onWarn: jest.fn() };
}
describe('loadEntraGroupToRoleResolver', () => {
it('returns an empty resolver and warns when ENTRA_GROUP_MAP_PATH is unset', () => {
const { onWarn } = noopWarn();
const { resolver, sourcePath } = loadEntraGroupToRoleResolver({
env: {},
onWarn,
});
expect(resolver.size).toBe(0);
expect(sourcePath).toBeNull();
expect(onWarn).toHaveBeenCalledWith('auth.entra_group_map_path_unset', expect.any(Object));
});
it('returns an empty resolver and warns when the file cannot be read', () => {
const { onWarn } = noopWarn();
const { resolver, sourcePath } = loadEntraGroupToRoleResolver({
env: { ENTRA_GROUP_MAP_PATH: '/tmp/definitely-not-a-real-path-xyz.json' },
onWarn,
});
expect(resolver.size).toBe(0);
expect(sourcePath).toBe('/tmp/definitely-not-a-real-path-xyz.json');
expect(onWarn).toHaveBeenCalledWith(
'auth.entra_group_map_file_unreadable',
expect.objectContaining({ path: '/tmp/definitely-not-a-real-path-xyz.json' }),
);
});
it('throws on malformed JSON', () => {
const path = tmpFile('bad.json', '{ not json');
expect(() =>
loadEntraGroupToRoleResolver({
env: { ENTRA_GROUP_MAP_PATH: path },
onWarn: jest.fn(),
}),
).toThrow(EntraGroupMapError);
});
it('throws when the JSON is not an object', () => {
const path = tmpFile('arr.json', '["collaborateur"]');
expect(() =>
loadEntraGroupToRoleResolver({
env: { ENTRA_GROUP_MAP_PATH: path },
onWarn: jest.fn(),
}),
).toThrow(EntraGroupMapError);
});
it('throws when a value is not a string', () => {
const path = tmpFile(
'bad-val.json',
JSON.stringify({ '11111111-1111-1111-1111-111111111111': 42 }),
);
expect(() =>
loadEntraGroupToRoleResolver({
env: { ENTRA_GROUP_MAP_PATH: path },
onWarn: jest.fn(),
}),
).toThrow(EntraGroupMapError);
});
it('returns a populated resolver on a valid map', () => {
const path = tmpFile(
'good.json',
JSON.stringify({
'11111111-1111-1111-1111-111111111111': 'collaborateur',
'22222222-2222-2222-2222-222222222222': 'rh',
}),
);
const { resolver, sourcePath } = loadEntraGroupToRoleResolver({
env: { ENTRA_GROUP_MAP_PATH: path },
onWarn: jest.fn(),
});
expect(resolver.size).toBe(2);
expect(sourcePath).toBe(path);
expect(resolver.coveredRoles()).toEqual(['collaborateur', 'rh']);
});
});
@@ -0,0 +1,98 @@
import { readFileSync } from 'node:fs';
import { isAbsolute, resolve } from 'node:path';
import { EntraGroupMapError, EntraGroupToRoleResolver, parseEntraGroupMap } from 'shared-auth';
/**
* Outcome of `loadEntraGroupToRoleResolver` — paired with the
* source path (when set) so the boot log can name the file that
* was loaded, even when the resolver itself ends up empty.
*/
export interface EntraGroupResolverLoadResult {
readonly resolver: EntraGroupToRoleResolver;
/** `null` when no path was configured (env var unset). */
readonly sourcePath: string | null;
}
/**
* Loads the Entra group-GUID → role-slug map at BFF boot per
* [ADR-0025 §"Sources of truth — Entra-side configuration"](../../../../docs/decisions/0025-authorization-model-privileges-roles-scopes.md).
*
* Sourcing precedence:
* 1. `ENTRA_GROUP_MAP_PATH` env var → JSON file path (absolute,
* or relative to `cwd`).
* 2. Unset → empty resolver, WARN logged at boot. Sign-in still
* works but every user gets an empty `roles[]` until the
* operator wires up the file. Acceptable for fresh dev
* environments; production should always set the var.
*
* Failure modes:
* - File configured but unreadable / missing → empty resolver,
* WARN. Same fail-soft posture: an operator pointing at the
* wrong path should not block every sign-in.
* - File present but malformed (bad JSON, wrong shape, unknown
* slug, duplicate GUID) → throws. A misconfigured map is a
* hard-fail because the alternative is silently mis-resolving
* a role.
*
* The function never reads `process.env` outside the documented
* key and never mutates it.
*/
export function loadEntraGroupToRoleResolver(opts: {
cwd?: string;
env?: NodeJS.ProcessEnv;
onWarn: (event: string, payload: Record<string, unknown>) => void;
}): EntraGroupResolverLoadResult {
const env = opts.env ?? process.env;
const cwd = opts.cwd ?? process.cwd();
const rawPath = env['ENTRA_GROUP_MAP_PATH'];
if (typeof rawPath !== 'string' || rawPath === '') {
opts.onWarn('auth.entra_group_map_path_unset', {
hint: 'set ENTRA_GROUP_MAP_PATH to infra/<env>-tenant.entra.json — sign-in will succeed but resolve zero functional roles until configured',
});
return { resolver: new EntraGroupToRoleResolver(new Map()), sourcePath: null };
}
const absolutePath = isAbsolute(rawPath) ? rawPath : resolve(cwd, rawPath);
let raw: string;
try {
raw = readFileSync(absolutePath, 'utf8');
} catch (err) {
opts.onWarn('auth.entra_group_map_file_unreadable', {
path: absolutePath,
message: err instanceof Error ? err.message : String(err),
});
return { resolver: new EntraGroupToRoleResolver(new Map()), sourcePath: absolutePath };
}
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch (err) {
throw new EntraGroupMapError(
`Entra group map at ${absolutePath} is not valid JSON: ${
err instanceof Error ? err.message : String(err)
}`,
);
}
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new EntraGroupMapError(
`Entra group map at ${absolutePath} must be a JSON object keyed on group GUID.`,
);
}
const stringified: Record<string, string> = {};
for (const [key, value] of Object.entries(parsed as Record<string, unknown>)) {
if (typeof value !== 'string') {
throw new EntraGroupMapError(
`Entra group map entry "${key}" in ${absolutePath} must be a string (the role slug); got ${typeof value}.`,
);
}
stringified[key] = value;
}
const map = parseEntraGroupMap(stringified);
return { resolver: new EntraGroupToRoleResolver(map), sourcePath: absolutePath };
}
@@ -0,0 +1,397 @@
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'],
groups: [],
};
// ---- 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`;
}
@@ -0,0 +1,109 @@
import { randomBytes } from 'node:crypto';
import { describe, it, expect, beforeAll, afterAll, beforeEach } from '@jest/globals';
import { Test } from '@nestjs/testing';
import { AiClientModule } from './ai-client.module';
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';
/**
* Smoke-tests `AiClientModule.compile()` — every provider resolves,
* the four exported wrapper clients are constructable, and the gRPC
* stubs share the same credentials instance (i.e. the underlying
* HTTP/2 channel can be multiplexed by grpc-js).
*
* Does NOT exercise the wire — the wire is covered by the per-client
* fake-server specs.
*/
const STRONG_SALT = randomBytes(32).toString('base64url');
const ORIGINAL = {
salt: process.env['LOG_USER_ID_SALT'],
endpoint: process.env['AI_SERVICE_GRPC_ENDPOINT'],
clientId: process.env['AI_SERVICE_CLIENT_ID'],
tls: process.env['AI_SERVICE_GRPC_TLS'],
};
beforeAll(() => {
process.env['LOG_USER_ID_SALT'] = STRONG_SALT;
process.env['AI_SERVICE_GRPC_ENDPOINT'] = 'apf-ai-service:8080';
process.env['AI_SERVICE_CLIENT_ID'] = 'apf-portal-test';
process.env['AI_SERVICE_GRPC_TLS'] = 'false';
});
afterAll(() => {
if (ORIGINAL.salt === undefined) delete process.env['LOG_USER_ID_SALT'];
else process.env['LOG_USER_ID_SALT'] = ORIGINAL.salt;
if (ORIGINAL.endpoint === undefined) delete process.env['AI_SERVICE_GRPC_ENDPOINT'];
else process.env['AI_SERVICE_GRPC_ENDPOINT'] = ORIGINAL.endpoint;
if (ORIGINAL.clientId === undefined) delete process.env['AI_SERVICE_CLIENT_ID'];
else process.env['AI_SERVICE_CLIENT_ID'] = ORIGINAL.clientId;
if (ORIGINAL.tls === undefined) delete process.env['AI_SERVICE_GRPC_TLS'];
else process.env['AI_SERVICE_GRPC_TLS'] = ORIGINAL.tls;
});
describe('AiClientModule', () => {
let moduleRef: Awaited<ReturnType<ReturnType<typeof Test.createTestingModule>['compile']>>;
beforeEach(async () => {
// AiClientModule provides HashUserIdService locally — see the
// module docstring for the rationale — so the test does not
// need to bring in AuditModule (and AuditWriter's Prisma + CLS
// dependencies that would imply).
moduleRef = await Test.createTestingModule({
imports: [AiClientModule],
}).compile();
});
it('resolves all four wrapper clients', () => {
expect(moduleRef.get(ChatClient)).toBeInstanceOf(ChatClient);
expect(moduleRef.get(RagClient)).toBeInstanceOf(RagClient);
expect(moduleRef.get(IngestionClient)).toBeInstanceOf(IngestionClient);
expect(moduleRef.get(ModelsClient)).toBeInstanceOf(ModelsClient);
});
it('resolves the Principal mapper and the metadata builder', () => {
expect(moduleRef.get(PrincipalMapper)).toBeInstanceOf(PrincipalMapper);
expect(moduleRef.get(GrpcMetadataBuilder)).toBeInstanceOf(GrpcMetadataBuilder);
});
it('parses the env vars into AI_CONFIG', () => {
const config = moduleRef.get(AI_CONFIG) as {
endpoint: string;
clientId: string;
useTls: boolean;
};
expect(config).toEqual({
endpoint: 'apf-ai-service:8080',
clientId: 'apf-portal-test',
useTls: false,
});
});
it('shares one credentials instance across every generated stub', () => {
const credentials = moduleRef.get(AI_CREDENTIALS);
expect(credentials).toBeDefined();
// Each stub provider injects AI_CREDENTIALS — same reference
// means the underlying HTTP/2 connection can be multiplexed by
// grpc-js when they target the same address.
const chat = moduleRef.get(AI_CHAT_GRPC_CLIENT) as unknown;
const rag = moduleRef.get(AI_RAG_GRPC_CLIENT) as unknown;
const ingestion = moduleRef.get(AI_INGESTION_GRPC_CLIENT) as unknown;
const models = moduleRef.get(AI_MODELS_GRPC_CLIENT) as unknown;
expect(chat).toBeDefined();
expect(rag).toBeDefined();
expect(ingestion).toBeDefined();
expect(models).toBeDefined();
});
});
@@ -0,0 +1,153 @@
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';
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 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();
}
}
@@ -0,0 +1,236 @@
import { describe, it, expect, beforeAll, afterAll, beforeEach } from '@jest/globals';
import {
ChannelCredentials,
Server,
ServerCredentials,
type Metadata,
type ServerWritableStream,
} from '@grpc/grpc-js';
import {
ChatServiceClient,
ChatServiceService,
type ChatEvent,
type ChatRequest,
} from '../gen/apf-ai/chat';
import { ChatClient } from './chat.client';
import { GrpcMetadataBuilder } from './grpc-metadata.builder';
import type { AiServiceConfig } from '../../config/check-ai-service-config';
/**
* Integration test: drives `ChatClient` against an in-process fake
* gRPC ChatService that emits a canned `ChatEvent` sequence. The
* spec covers ADR-0024's confirmation criteria:
*
* - happy-path stream emits every event the server sent and ends
* after `done`;
* - metadata (`x-client-id` + `x-correlation-id`) reaches the
* server unchanged;
* - browser-close (modelled here as an `AbortController.abort()`)
* propagates as `call.cancel()` and the server's stream callback
* observes the cancellation.
*/
const CONFIG: AiServiceConfig = {
endpoint: '',
clientId: 'apf-portal-test',
useTls: false,
};
interface ServerObservations {
request: ChatRequest | null;
metadata: Metadata | null;
cancelled: boolean;
}
let server: Server;
let port: number;
let serverObservations: ServerObservations;
let chatHandler: (call: ServerWritableStream<ChatRequest, ChatEvent>) => void;
beforeAll(async () => {
server = new Server();
server.addService(ChatServiceService, {
chat: (call: ServerWritableStream<ChatRequest, ChatEvent>) => {
serverObservations.request = call.request;
serverObservations.metadata = call.metadata;
call.on('cancelled', () => {
serverObservations.cancelled = true;
});
chatHandler(call);
},
});
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();
});
});
});
beforeEach(() => {
serverObservations = { request: null, metadata: null, cancelled: false };
});
function buildClient(): ChatClient {
const config = { ...CONFIG, endpoint: `127.0.0.1:${port}` };
const grpc = new ChatServiceClient(config.endpoint, ChannelCredentials.createInsecure());
return new ChatClient(grpc, new GrpcMetadataBuilder(config));
}
async function collect(stream: AsyncIterable<ChatEvent>): Promise<ChatEvent[]> {
const events: ChatEvent[] = [];
for await (const event of stream) {
events.push(event);
}
return events;
}
describe('ChatClient (in-process fake server)', () => {
it('emits every server event then terminates on done', async () => {
chatHandler = (call) => {
call.write({ token: { token: 'hello', value: 'Hello' } });
call.write({ token: { token: ' world', value: ' world' } });
call.write({ done: { stats: { tokensIn: 1, tokensOut: 2, chunksRetrieved: 0 } } });
call.end();
};
const client = buildClient();
const stream = client.chat({
messages: [],
conversationId: 'conv-1',
model: '',
provider: '',
toolsAvailable: [],
principal: { subject: 'subj-1', roles: ['admin'], attributes: { tenantId: 't' } },
});
const events = await collect(stream);
expect(events).toHaveLength(3);
expect(events[0]?.token?.value).toBe('Hello');
expect(events[2]?.done?.stats?.tokensOut).toBe(2);
});
it('propagates x-client-id and x-correlation-id metadata to the server', async () => {
chatHandler = (call) => {
call.write({ done: { stats: { tokensIn: 0, tokensOut: 0, chunksRetrieved: 0 } } });
call.end();
};
const client = buildClient();
await collect(
client.chat(
{
messages: [],
conversationId: 'conv-2',
model: '',
provider: '',
toolsAvailable: [],
principal: { subject: 'subj-2', roles: [], attributes: {} },
},
{ correlationId: 'corr-from-test' },
),
);
expect(serverObservations.metadata?.get('x-client-id')).toEqual(['apf-portal-test']);
expect(serverObservations.metadata?.get('x-correlation-id')).toEqual(['corr-from-test']);
});
it('cancels the call when the AbortSignal aborts mid-stream', async () => {
const cancellationObserved = new Promise<void>((resolve) => {
chatHandler = (call) => {
call.write({ token: { token: 't1', value: 't1' } });
call.on('cancelled', () => resolve());
// Deliberately do not `end()` — the test cancels.
};
});
const controller = new AbortController();
const client = buildClient();
const stream = client.chat(
{
messages: [],
conversationId: 'conv-3',
model: '',
provider: '',
toolsAvailable: [],
principal: { subject: 'subj-3', roles: [], attributes: {} },
},
{ signal: controller.signal },
);
// Drain the first event then abort.
const iter = stream[Symbol.asyncIterator]();
await iter.next();
controller.abort();
await cancellationObserved;
expect(serverObservations.cancelled).toBe(true);
});
it('terminates the stream without data when the AbortSignal is already aborted', async () => {
// The signal aborts before the call dial completes. gRPC-js
// cancels locally — depending on timing the server may or may
// not observe the call, so the spec only locks the client-side
// outcome: the stream ends in error and yields no payload.
chatHandler = (call) => {
call.on('cancelled', () => {
// best-effort end on cancellation so the suite never hangs
try {
call.end();
} catch {
// already torn down
}
});
};
const controller = new AbortController();
controller.abort();
const client = buildClient();
const stream = client.chat(
{
messages: [],
conversationId: 'conv-4',
model: '',
provider: '',
toolsAvailable: [],
principal: { subject: 'subj-4', roles: [], attributes: {} },
},
{ signal: controller.signal },
);
const events: ChatEvent[] = [];
let caught: unknown;
try {
for await (const ev of stream) {
events.push(ev);
}
} catch (err) {
caught = err;
}
expect(events).toHaveLength(0);
// gRPC-js may surface the cancellation as an error or as a clean
// end-of-stream depending on whether the call had time to dial.
// Either way, no payload is emitted — that is the contract.
if (caught !== undefined) {
expect((caught as { code?: number }).code).toBeDefined();
}
});
});
@@ -0,0 +1,69 @@
import { Inject, Injectable } from '@nestjs/common';
import type { ClientReadableStream } from '@grpc/grpc-js';
import type { ChatEvent, ChatRequest, ChatServiceClient } from '../gen/apf-ai/chat';
import { GrpcMetadataBuilder } from './grpc-metadata.builder';
import { AI_CHAT_GRPC_CLIENT } from './tokens';
/**
* Wrapper around the generated `ChatServiceClient` for the
* server-streaming `Chat` RPC, per ADR-0024 §"Sub-decision 1".
*
* The wrapper does three things on top of the raw gRPC stub:
*
* 1. Injects the `x-client-id` + `x-correlation-id` metadata via
* `GrpcMetadataBuilder` so every call carries the contract
* headers from `apf-ai-service/docs/contract.md`.
* 2. Wires an optional `AbortSignal` to `call.cancel()` so the SSE
* bridge (next PR in the chantier) can propagate browser
* disconnects up to the AI service in one line.
* 3. Returns the raw `ClientReadableStream<ChatEvent>` — Node
* `Readable` streams are async-iterable by default, so the SSE
* bridge consumes the stream with `for await ... of` and no
* intermediate adapter.
*
* Cancellation flows in both directions: a `.cancel()` from inside
* the BFF (timeout, abort) reaches the AI service as a
* `grpc.status.CANCELLED`, which translates upstream to
* `ServerCallContext.CancellationToken` stopping LLM generation
* (per `apf-ai-service/docs/streaming.md`).
*/
@Injectable()
export class ChatClient {
constructor(
@Inject(AI_CHAT_GRPC_CLIENT) private readonly grpc: ChatServiceClient,
private readonly metadata: GrpcMetadataBuilder,
) {}
/**
* Start a streaming Chat call.
*
* The returned `ClientReadableStream<ChatEvent>` emits one event
* per AI service `ChatEvent` and ends after the terminal
* `ChatEvent.done` (or on cancellation / error).
*
* @param request The full proto request, already populated with
* the conversation history and a `Principal` (build the
* principal via `PrincipalMapper.fromInputs`).
* @param options.signal Optional AbortSignal. When it aborts, the
* underlying gRPC call is cancelled and the stream closes.
* @param options.correlationId Override the metadata
* correlation-id (defaults to the active OTel trace-id).
*/
chat(
request: ChatRequest,
options: { signal?: AbortSignal; correlationId?: string } = {},
): ClientReadableStream<ChatEvent> {
const metadata = this.metadata.build({ correlationId: options.correlationId });
const call = this.grpc.chat(request, metadata);
if (options.signal) {
if (options.signal.aborted) {
call.cancel();
} else {
options.signal.addEventListener('abort', () => call.cancel(), { once: true });
}
}
return call;
}
}
@@ -0,0 +1,87 @@
import { describe, it, expect, beforeEach, afterEach, jest } from '@jest/globals';
import { trace } from '@opentelemetry/api';
import { GrpcMetadataBuilder } from './grpc-metadata.builder';
import type { AiServiceConfig } from '../../config/check-ai-service-config';
/**
* Locks the metadata-injection contract per ADR-0024 §"Metadata contract":
*
* - x-client-id ← AI_SERVICE_CLIENT_ID env (via AI_CONFIG).
* - x-correlation-id ← active OTel span's trace-id when present;
* explicit `correlationId` override wins over
* the span; otherwise a fresh UUID is minted
* so the AI service can still join its audit
* row to a unique id even when the portal
* request is outside a trace.
*/
const CONFIG: AiServiceConfig = {
endpoint: 'apf-ai-service:8080',
clientId: 'apf-portal-test',
useTls: false,
};
let activeSpan: ReturnType<typeof trace.getActiveSpan> = undefined;
let getActiveSpanSpy: ReturnType<typeof jest.spyOn>;
beforeEach(() => {
activeSpan = undefined;
getActiveSpanSpy = jest.spyOn(trace, 'getActiveSpan').mockImplementation(() => activeSpan);
});
afterEach(() => {
getActiveSpanSpy.mockRestore();
});
describe('GrpcMetadataBuilder', () => {
it('always emits x-client-id from the config', () => {
const builder = new GrpcMetadataBuilder(CONFIG);
const meta = builder.build();
expect(meta.get('x-client-id')).toEqual(['apf-portal-test']);
});
it('uses the active OTel trace-id as x-correlation-id when a span is active', () => {
activeSpan = {
spanContext: () => ({
traceId: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
spanId: 'bbbbbbbbbbbbbbbb',
traceFlags: 1,
}),
} as ReturnType<typeof trace.getActiveSpan>;
const builder = new GrpcMetadataBuilder(CONFIG);
const meta = builder.build();
expect(meta.get('x-correlation-id')).toEqual(['aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa']);
});
it('prefers the explicit options.correlationId over the active span', () => {
activeSpan = {
spanContext: () => ({
traceId: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
spanId: 'bbbbbbbbbbbbbbbb',
traceFlags: 1,
}),
} as ReturnType<typeof trace.getActiveSpan>;
const builder = new GrpcMetadataBuilder(CONFIG);
const meta = builder.build({ correlationId: 'caller-supplied-id' });
expect(meta.get('x-correlation-id')).toEqual(['caller-supplied-id']);
});
it('falls back to a UUID v4 when no span is active and no override is passed', () => {
const builder = new GrpcMetadataBuilder(CONFIG);
const meta = builder.build();
const correlationIds = meta.get('x-correlation-id') as string[];
expect(correlationIds).toHaveLength(1);
expect(correlationIds[0]).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i,
);
});
it('returns a fresh Metadata instance on every call (no shared mutation)', () => {
const builder = new GrpcMetadataBuilder(CONFIG);
const a = builder.build();
const b = builder.build();
expect(a).not.toBe(b);
});
});
@@ -0,0 +1,53 @@
import { randomUUID } from 'node:crypto';
import { Inject, Injectable } from '@nestjs/common';
import { Metadata } from '@grpc/grpc-js';
import { trace } from '@opentelemetry/api';
import { AI_CONFIG } from './tokens';
import type { AiServiceConfig } from '../../config/check-ai-service-config';
/**
* Builds the gRPC `Metadata` object every outbound call to
* `apf-ai-service` must carry, per ADR-0024 §"Metadata contract".
*
* Two entries:
*
* - `x-client-id` — deployment slug from `AI_SERVICE_CLIENT_ID`.
* Lets the AI service tag its audit rows and metrics with the
* calling deployment without inspecting the proto body.
* - `x-correlation-id` — W3C `trace-id` of the active OpenTelemetry
* span when one exists (per [ADR-0012](../../docs/decisions/0012-observability-pino-opentelemetry.md)),
* falling back to an explicit value if passed, falling back to a
* freshly-minted UUID v4 as a last resort. The AI service echoes
* this id in its audit log and propagates it to Jaeger, so the
* portal and AI traces can be joined cross-service.
*
* The builder is a class (not a static helper) so it composes with
* NestJS DI and so it can be swapped for a fake in tests that need
* to assert the metadata shape without standing up the OTel SDK.
*/
@Injectable()
export class GrpcMetadataBuilder {
constructor(@Inject(AI_CONFIG) private readonly config: AiServiceConfig) {}
/**
* Construct the metadata for one outbound call.
*
* @param options.correlationId Override the auto-resolved value.
* Use when the caller already holds a stable id (for example
* the SSE bridge controller's request-scoped trace-id) and
* wants to bind multiple calls to the same correlation key.
* `undefined` is accepted (and equivalent to omitting the
* key) so callers can forward an optional value without
* a conditional spread.
*/
build(options: { correlationId?: string | undefined } = {}): Metadata {
const meta = new Metadata();
meta.set('x-client-id', this.config.clientId);
const correlationId =
options.correlationId ?? trace.getActiveSpan()?.spanContext().traceId ?? randomUUID();
meta.set('x-correlation-id', correlationId);
return meta;
}
}
@@ -0,0 +1,78 @@
import { Inject, Injectable } from '@nestjs/common';
import type {
DeleteDocumentRequest,
DeleteDocumentResponse,
DocumentSummary,
GetDocumentRequest,
IngestDocumentRequest,
IngestDocumentResponse,
IngestionServiceClient,
} from '../gen/apf-ai/ingestion';
import { GrpcMetadataBuilder } from './grpc-metadata.builder';
import { AI_INGESTION_GRPC_CLIENT } from './tokens';
/**
* Wrapper around the generated `IngestionServiceClient`.
*
* Unused by the BFF in v1 — apf-ai-service ships a dedicated CLI
* (`tools/Apf.Ai.Ingest/`) for document ingestion during the POC.
* The wrapper is in place so the future admin "manage AI corpus"
* surface lands as one new controller, not as a new module + a new
* client + a new generated stub binding. The unit tests under
* `ingestion.client.spec.ts` lock the wire contract today.
*/
@Injectable()
export class IngestionClient {
constructor(
@Inject(AI_INGESTION_GRPC_CLIENT) private readonly grpc: IngestionServiceClient,
private readonly metadata: GrpcMetadataBuilder,
) {}
ingestDocument(
request: IngestDocumentRequest,
options: { correlationId?: string } = {},
): Promise<IngestDocumentResponse> {
const metadata = this.metadata.build({ correlationId: options.correlationId });
return new Promise<IngestDocumentResponse>((resolve, reject) => {
this.grpc.ingestDocument(request, metadata, (err, response) => {
if (err) {
reject(err);
return;
}
resolve(response);
});
});
}
getDocument(
request: GetDocumentRequest,
options: { correlationId?: string } = {},
): Promise<DocumentSummary> {
const metadata = this.metadata.build({ correlationId: options.correlationId });
return new Promise<DocumentSummary>((resolve, reject) => {
this.grpc.getDocument(request, metadata, (err, response) => {
if (err) {
reject(err);
return;
}
resolve(response);
});
});
}
deleteDocument(
request: DeleteDocumentRequest,
options: { correlationId?: string } = {},
): Promise<DeleteDocumentResponse> {
const metadata = this.metadata.build({ correlationId: options.correlationId });
return new Promise<DeleteDocumentResponse>((resolve, reject) => {
this.grpc.deleteDocument(request, metadata, (err, response) => {
if (err) {
reject(err);
return;
}
resolve(response);
});
});
}
}
@@ -0,0 +1,38 @@
import { Inject, Injectable } from '@nestjs/common';
import type {
ListModelsRequest,
ListModelsResponse,
ModelsServiceClient,
} from '../gen/apf-ai/models';
import { GrpcMetadataBuilder } from './grpc-metadata.builder';
import { AI_MODELS_GRPC_CLIENT } from './tokens';
/**
* Wrapper around the generated `ModelsServiceClient` for the unary
* `ListModels` RPC. Surfaces the active provider + the configured
* provider catalogue for use by a future admin UI tile or a v1
* "which models are available" health probe.
*/
@Injectable()
export class ModelsClient {
constructor(
@Inject(AI_MODELS_GRPC_CLIENT) private readonly grpc: ModelsServiceClient,
private readonly metadata: GrpcMetadataBuilder,
) {}
listModels(
request: ListModelsRequest = {},
options: { correlationId?: string } = {},
): Promise<ListModelsResponse> {
const metadata = this.metadata.build({ correlationId: options.correlationId });
return new Promise<ListModelsResponse>((resolve, reject) => {
this.grpc.listModels(request, metadata, (err, response) => {
if (err) {
reject(err);
return;
}
resolve(response);
});
});
}
}

Some files were not shown because too many files have changed in this diff Show More