Compare commits

...

88 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
200 changed files with 25132 additions and 3001 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/
+21 -9
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).
@@ -52,24 +52,34 @@ The structural, security, observability, and quality choices are recorded as ADR
- **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 → 0023 are accepted and cover the structural, security, observability, quality, i18n, admin-app, docs-site, and charts 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.
- **Charts lib + audit-page dashboards** ([ADR-0023](docs/decisions/0023-charts-d3-observable-plot.md)) — ADR accepted; chantier next: `libs/shared/charts/` foundations + 3 starter components (bar, donut, stacked-bar), then `/audit`-page integration with daily-volume + outcome-breakdown + event-type-over-time charts.
- `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
@@ -99,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`.
+8 -1
View File
@@ -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],
@@ -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`
@@ -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>
+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);
});
@@ -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,7 +91,83 @@ 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: { principal: principalWithPrivileges(['Portal.Auditor']) },
method: 'POST',
originalUrl: '/api/admin/cms/pages',
}),
);
await expect(guard.canActivate(ctx)).rejects.toBeInstanceOf(ForbiddenException);
expect(audit.adminAccessDenied).toHaveBeenCalledWith({
actor: { oid: 'user-oid' },
attemptedRoute: 'POST /api/admin/cms/pages',
rolesHeld: ['Portal.Auditor'],
});
});
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: { 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 privileges', async () => {
const audit = makeAuditStub();
const guard = new AdminRoleGuard(audit as unknown as AuditWriter);
const ctx = makeContext(
makeRequest({
session: {
principal: principalWithPrivileges(['Portal.Auditor', ADMIN_ROLE, 'Portal.DPO']),
},
}),
);
await expect(guard.canActivate(ctx)).resolves.toBe(true);
});
it('propagates audit write failures (no audit ⇒ no action, per ADR-0013)', async () => {
const audit = {
adminAccessDenied: jest.fn().mockRejectedValue(new Error('audit_writer denied')),
};
const guard = new AdminRoleGuard(audit as unknown as AuditWriter);
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(
@@ -97,82 +182,18 @@ describe('AdminRoleGuard', () => {
roles: ['editor', 'auditor'],
},
},
method: 'POST',
originalUrl: '/api/admin/cms/pages',
}),
);
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: 'POST /api/admin/cms/pages',
rolesHeld: ['editor', 'auditor'],
attemptedRoute: 'GET /api/admin/me',
rolesHeld: [],
});
});
it('returns true and does not audit when the user has the admin role', 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],
},
},
}),
);
await expect(guard.canActivate(ctx)).resolves.toBe(true);
expect(audit.adminAccessDenied).not.toHaveBeenCalled();
});
it('returns true when admin is one of several roles', 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'],
},
},
}),
);
await expect(guard.canActivate(ctx)).resolves.toBe(true);
});
it('propagates audit write failures (no audit ⇒ no action, per ADR-0013)', async () => {
const audit = {
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: [],
},
},
}),
);
// 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');
});
});
+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 };
}
+10 -2
View File
@@ -9,6 +9,8 @@ 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.
@@ -34,7 +36,13 @@ import { AuditStatsReader } from './audit-stats.service';
*/
@Module({
imports: [AuthModule, RedisModule],
controllers: [AdminController, AdminAuthController, AdminAuditController, AdminUsersController],
providers: [AdminRoleGuard, AuditReader, AuditStatsReader, AdminUsersReader],
controllers: [
AdminController,
AdminAuthController,
AdminAuditController,
AdminUsersController,
UserScopesController,
],
providers: [AdminRoleGuard, AuditReader, AuditStatsReader, AdminUsersReader, UserScopesService],
})
export class AdminModule {}
@@ -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'],
}),
);
});
});
});
@@ -6,8 +6,11 @@ import type {
AdminAccessDeniedInput,
AdminAuditQueryInput,
AdminAuditStatsQueryInput,
AdminScopeGrantedInput,
AdminScopeRevokedInput,
AdminUsersQueryInput,
AuditEventInput,
AuthorizationDeniedInput,
MfaRequiredInput,
SignInActor,
SignInFailedInput,
@@ -219,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`.
@@ -239,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 =
+68
View File
@@ -133,6 +133,38 @@ 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
@@ -147,6 +179,42 @@ export interface AdminAuditStatsQueryInput {
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);
});
});
}
}
@@ -0,0 +1,117 @@
import { randomBytes } from 'node:crypto';
import { describe, it, expect, beforeEach, afterAll } from '@jest/globals';
import { HashUserIdService } from '../../audit/hash-user-id.service';
import { PrincipalMapper } from './principal.mapper';
/**
* Locks the Principal-mapping contract per ADR-0024 §"Sub-decision 4":
*
* - subject = HashUserIdService(oid) — same salt, same algo as the
* portal's audit and Pino log streams. This is the
* join key between portal and apf-ai-service audit
* trails.
* - roles = pass-through (inclusive expansion lands in a future
* ADR; the wire contract on the AI side does not
* change).
* - attrs = tenantId is reserved; consumers can layer extra
* key/value pairs but tenantId wins on collision.
*/
const STRONG_SALT = randomBytes(32).toString('base64url');
const ORIGINAL_SALT = process.env['LOG_USER_ID_SALT'];
beforeEach(() => {
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;
}
});
function makeMapper(): { mapper: PrincipalMapper; hashService: HashUserIdService } {
const hashService = new HashUserIdService();
return { mapper: new PrincipalMapper(hashService), hashService };
}
describe('PrincipalMapper', () => {
it('hashes the Entra oid into the subject via HashUserIdService', () => {
const { mapper, hashService } = makeMapper();
const principal = mapper.fromInputs({
oid: 'user-oid-42',
tid: 'tenant-1',
roles: ['admin'],
});
expect(principal.subject).toBe(hashService.hash('user-oid-42'));
expect(principal.subject).toMatch(/^[0-9a-f]{16}$/);
});
it('passes roles through verbatim (no expansion in v1)', () => {
const { mapper } = makeMapper();
const principal = mapper.fromInputs({
oid: 'user-oid-42',
tid: 'tenant-1',
roles: ['directeur', 'rh'],
});
expect(principal.roles).toEqual(['directeur', 'rh']);
});
it('copies roles so caller mutations do not leak into the proto', () => {
const { mapper } = makeMapper();
const roles: string[] = ['admin'];
const principal = mapper.fromInputs({
oid: 'user-oid-42',
tid: 'tenant-1',
roles,
});
roles.push('mutated');
expect(principal.roles).toEqual(['admin']);
});
it('always emits tenantId in attributes', () => {
const { mapper } = makeMapper();
const principal = mapper.fromInputs({
oid: 'user-oid-42',
tid: 'tenant-1',
roles: [],
});
expect(principal.attributes).toEqual({ tenantId: 'tenant-1' });
});
it('merges extraAttributes into the attribute map', () => {
const { mapper } = makeMapper();
const principal = mapper.fromInputs({
oid: 'user-oid-42',
tid: 'tenant-1',
roles: [],
extraAttributes: { delegation: 'aquitaine', region: 'fr-sw' },
});
expect(principal.attributes).toEqual({
tenantId: 'tenant-1',
delegation: 'aquitaine',
region: 'fr-sw',
});
});
it('tenantId from `tid` wins over a colliding extraAttributes entry', () => {
const { mapper } = makeMapper();
const principal = mapper.fromInputs({
oid: 'user-oid-42',
tid: 'tenant-1',
roles: [],
extraAttributes: { tenantId: 'tenant-spoofed' },
});
expect(principal.attributes['tenantId']).toBe('tenant-1');
});
it('produces the same subject for the same oid + salt across instances', () => {
const { mapper: m1 } = makeMapper();
const { mapper: m2 } = makeMapper();
expect(m1.fromInputs({ oid: 'x', tid: 't', roles: [] }).subject).toBe(
m2.fromInputs({ oid: 'x', tid: 't', roles: [] }).subject,
);
});
});
@@ -0,0 +1,66 @@
import { Injectable } from '@nestjs/common';
import { HashUserIdService } from '../../audit/hash-user-id.service';
import type { Principal } from '../gen/apf-ai/common';
/**
* Read-only view of the session fields the mapper consumes. Decoupled
* from `AuthenticatedUser` so callers can build a Principal in tests
* or in non-session contexts (a future background-job consumer) by
* supplying a plain object — without dragging the full session shape
* along.
*
* Keep the shape minimal: anything beyond `oid + tid + roles` should
* land in `attributes` rather than as a new top-level field, so the
* proto contract stays stable as the BFF grows new claim sources.
*/
export interface PrincipalInputs {
/** Entra Object ID. Hashed via `HashUserIdService` into the proto `subject` field. */
readonly oid: string;
/** Entra tenant id. Surfaced as `attributes.tenantId`. */
readonly tid: string;
/** Roles already resolved upstream by the auth flow. */
readonly roles: readonly string[];
/**
* Free-form extra attributes (delegation slug, region, …) merged
* into `attributes` after `tenantId`. Caller-controlled; the
* mapper does not enrich. Reserved keys (`tenantId`) overwrite
* the caller-provided value to keep the proto contract single-
* sourced.
*/
readonly extraAttributes?: Readonly<Record<string, string>>;
}
/**
* Builds the `apf.ai.v1.Principal` proto from a portal session,
* per ADR-0024 §"Sub-decision 4 — unsigned Principal in the proto body".
*
* The `subject` field MUST be the same hash the audit module writes
* to `audit.events.actor_id_hash` (per [ADR-0013](../../docs/decisions/0013-audit-trail-separated-postgres-append-only.md))
* and that Pino's `user_id_hash` log field carries (per [ADR-0012](../../docs/decisions/0012-observability-pino-opentelemetry.md)).
* The two services' audit trails only join cleanly when both sides
* compute the same value — same Entra `oid`, same salt, same algo.
* The mapper delegates to `HashUserIdService` to make that contract
* explicit at the wire boundary.
*
* The `roles` field is passed through verbatim in v1. A future ADR
* on role hierarchy (proposed alongside the stargate migration
* analysis) will introduce inclusive expansion (`Admin` ⇒
* `[admin, directeur, rh, collaborateur]`). At that point the
* mapper grows a `RoleExpander` collaborator; the wire shape and
* the upstream contract on the AI side stay unchanged.
*/
@Injectable()
export class PrincipalMapper {
constructor(private readonly hashUserId: HashUserIdService) {}
fromInputs(inputs: PrincipalInputs): Principal {
return {
subject: this.hashUserId.hash(inputs.oid),
roles: [...inputs.roles],
attributes: {
...(inputs.extraAttributes ?? {}),
tenantId: inputs.tid,
},
};
}
}
@@ -0,0 +1,145 @@
import { describe, it, expect, beforeAll, afterAll, beforeEach } from '@jest/globals';
import {
ChannelCredentials,
Server,
ServerCredentials,
status as GrpcStatus,
type Metadata,
type sendUnaryData,
type ServerUnaryCall,
} from '@grpc/grpc-js';
import {
RagServiceClient,
RagServiceService,
type RagSearchRequest,
type RagSearchResponse,
} from '../gen/apf-ai/rag';
import { Struct } from '../gen/apf-ai/google/protobuf/struct';
import { GrpcMetadataBuilder } from './grpc-metadata.builder';
import { RagClient } from './rag.client';
import type { AiServiceConfig } from '../../config/check-ai-service-config';
/**
* Locks the unary RAG contract: the wrapper promisifies the
* callback-style stub, forwards metadata to the server, and
* surfaces `ServiceError` rejections with the original gRPC status
* code intact.
*/
const CONFIG: AiServiceConfig = {
endpoint: '',
clientId: 'apf-portal-test',
useTls: false,
};
interface ServerObservations {
request: RagSearchRequest | null;
metadata: Metadata | null;
}
let server: Server;
let port: number;
let serverObservations: ServerObservations;
let searchHandler: (
call: ServerUnaryCall<RagSearchRequest, RagSearchResponse>,
callback: sendUnaryData<RagSearchResponse>,
) => void;
beforeAll(async () => {
server = new Server();
server.addService(RagServiceService, {
search: (call, callback) => {
serverObservations.request = call.request;
serverObservations.metadata = call.metadata;
searchHandler(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();
});
});
});
beforeEach(() => {
serverObservations = { request: null, metadata: null };
});
function buildClient(): RagClient {
const config = { ...CONFIG, endpoint: `127.0.0.1:${port}` };
const grpc = new RagServiceClient(config.endpoint, ChannelCredentials.createInsecure());
return new RagClient(grpc, new GrpcMetadataBuilder(config));
}
describe('RagClient (in-process fake server)', () => {
it('resolves with the server response on a happy unary call', async () => {
searchHandler = (_call, callback) => {
callback(null, {
chunks: [
{
id: 'chunk-1',
documentId: 'doc-1',
content: 'Lorem ipsum',
source: 'rag-test',
score: 0.99,
metadata: Struct.fromPartial({}),
},
],
correlationId: 'echo-corr',
});
};
const client = buildClient();
const response = await client.search(
{
query: 'hello',
topK: 3,
principal: { subject: 'subj-1', roles: [], attributes: {} },
filters: { source: '', documentId: '' },
},
{ correlationId: 'caller-corr' },
);
expect(response.chunks).toHaveLength(1);
expect(response.chunks[0]?.id).toBe('chunk-1');
expect(serverObservations.metadata?.get('x-correlation-id')).toEqual(['caller-corr']);
});
it('rejects with the original gRPC ServiceError on server error', async () => {
searchHandler = (_call, callback) => {
callback({
code: GrpcStatus.PERMISSION_DENIED,
details: 'forbidden',
metadata: undefined as unknown as Metadata,
name: 'Error',
message: 'forbidden',
});
};
const client = buildClient();
await expect(
client.search({
query: 'q',
topK: 1,
principal: { subject: 'subj-2', roles: [], attributes: {} },
filters: { source: '', documentId: '' },
}),
).rejects.toMatchObject({ code: GrpcStatus.PERMISSION_DENIED });
});
});
@@ -0,0 +1,37 @@
import { Inject, Injectable } from '@nestjs/common';
import type { RagSearchRequest, RagSearchResponse, RagServiceClient } from '../gen/apf-ai/rag';
import { GrpcMetadataBuilder } from './grpc-metadata.builder';
import { AI_RAG_GRPC_CLIENT } from './tokens';
/**
* Wrapper around the generated `RagServiceClient` for the unary
* `Search` RPC, per ADR-0024.
*
* Promisifies the callback-style gRPC stub and injects the standard
* metadata. Errors propagate as the underlying `ServiceError` (with
* `code`, `details`, etc.) so callers can map them to portal-side
* HTTP statuses without losing the gRPC status code.
*/
@Injectable()
export class RagClient {
constructor(
@Inject(AI_RAG_GRPC_CLIENT) private readonly grpc: RagServiceClient,
private readonly metadata: GrpcMetadataBuilder,
) {}
search(
request: RagSearchRequest,
options: { correlationId?: string } = {},
): Promise<RagSearchResponse> {
const metadata = this.metadata.build({ correlationId: options.correlationId });
return new Promise<RagSearchResponse>((resolve, reject) => {
this.grpc.search(request, metadata, (err, response) => {
if (err) {
reject(err);
return;
}
resolve(response);
});
});
}
}
@@ -0,0 +1,21 @@
/**
* Dependency-injection tokens for `AiClientModule`, per ADR-0024.
*
* The module exposes three classes of provider:
*
* 1. `AI_CONFIG` — the parsed env vars from `assertAiServiceConfig`.
* 2. `AI_CREDENTIALS` — the `ChannelCredentials` (insecure in dev
* h2c, SSL in prod h2 + TLS).
* 3. Generated gRPC stub instances — one per RPC service from
* `apf.ai.v1.*` (Chat, Rag, Ingestion, Models). Wrapped by the
* `*.client.ts` services in this folder; the raw stubs stay
* behind tokens so the wrapper layer is the only place that
* knows about the codegen output.
*/
export const AI_CONFIG = Symbol('AI_CONFIG');
export const AI_CREDENTIALS = Symbol('AI_CREDENTIALS');
export const AI_CHAT_GRPC_CLIENT = Symbol('AI_CHAT_GRPC_CLIENT');
export const AI_RAG_GRPC_CLIENT = Symbol('AI_RAG_GRPC_CLIENT');
export const AI_INGESTION_GRPC_CLIENT = Symbol('AI_INGESTION_GRPC_CLIENT');
export const AI_MODELS_GRPC_CLIENT = Symbol('AI_MODELS_GRPC_CLIENT');
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,765 @@
// Code generated by protoc-gen-ts_proto. DO NOT EDIT.
// versions:
// protoc-gen-ts_proto v2.11.8
// protoc v3.19.1
// source: common.proto
/* eslint-disable */
import { BinaryReader, BinaryWriter } from "@bufbuild/protobuf/wire";
import Long from "long";
import { Struct } from "./google/protobuf/struct";
export enum ChatRole {
CHAT_ROLE_UNSPECIFIED = 0,
CHAT_ROLE_SYSTEM = 1,
CHAT_ROLE_USER = 2,
CHAT_ROLE_ASSISTANT = 3,
CHAT_ROLE_TOOL = 4,
UNRECOGNIZED = -1,
}
export function chatRoleFromJSON(object: any): ChatRole {
switch (object) {
case 0:
case "CHAT_ROLE_UNSPECIFIED":
return ChatRole.CHAT_ROLE_UNSPECIFIED;
case 1:
case "CHAT_ROLE_SYSTEM":
return ChatRole.CHAT_ROLE_SYSTEM;
case 2:
case "CHAT_ROLE_USER":
return ChatRole.CHAT_ROLE_USER;
case 3:
case "CHAT_ROLE_ASSISTANT":
return ChatRole.CHAT_ROLE_ASSISTANT;
case 4:
case "CHAT_ROLE_TOOL":
return ChatRole.CHAT_ROLE_TOOL;
case -1:
case "UNRECOGNIZED":
default:
return ChatRole.UNRECOGNIZED;
}
}
export function chatRoleToJSON(object: ChatRole): string {
switch (object) {
case ChatRole.CHAT_ROLE_UNSPECIFIED:
return "CHAT_ROLE_UNSPECIFIED";
case ChatRole.CHAT_ROLE_SYSTEM:
return "CHAT_ROLE_SYSTEM";
case ChatRole.CHAT_ROLE_USER:
return "CHAT_ROLE_USER";
case ChatRole.CHAT_ROLE_ASSISTANT:
return "CHAT_ROLE_ASSISTANT";
case ChatRole.CHAT_ROLE_TOOL:
return "CHAT_ROLE_TOOL";
case ChatRole.UNRECOGNIZED:
default:
return "UNRECOGNIZED";
}
}
/**
* Identity-bearing principal shipped with every request (carried in the JWT for
* internal callers, or in the request body for API-key callers — same shape).
*/
export interface Principal {
subject: string;
roles: string[];
attributes: { [key: string]: string };
}
export interface Principal_AttributesEntry {
key: string;
value: string;
}
/**
* Default-deny ACL attached to every ingested chunk.
* At least one of allowed_roles or allowed_subjects must be non-empty,
* enforced at the controller, the DB CHECK constraint, and the post-filter.
*/
export interface AclSpec {
allowedRoles: string[];
allowedSubjects: string[];
deniedRoles: string[];
requiredAttributes: { [key: string]: string };
}
export interface AclSpec_RequiredAttributesEntry {
key: string;
value: string;
}
export interface ChatMessage {
role: ChatRole;
content: string;
toolCallId: string;
name: string;
}
/**
* JSON-schema descriptor for caller-side tool execution. The AI service is
* tool-blind: it emits ToolCall events; the caller executes and posts back.
*/
export interface ToolDescriptor {
name: string;
description: string;
schema?: { [key: string]: any } | undefined;
}
function createBasePrincipal(): Principal {
return { subject: "", roles: [], attributes: {} };
}
export const Principal: MessageFns<Principal> = {
encode(message: Principal, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
if (message.subject !== "") {
writer.uint32(10).string(message.subject);
}
for (const v of message.roles) {
writer.uint32(18).string(v!);
}
globalThis.Object.entries(message.attributes).forEach(([key, value]: [string, string]) => {
Principal_AttributesEntry.encode({ key: key as any, value }, writer.uint32(26).fork()).join();
});
return writer;
},
decode(input: BinaryReader | Uint8Array, length?: number): Principal {
const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
const end = length === undefined ? reader.len : reader.pos + length;
const message = createBasePrincipal();
while (reader.pos < end) {
const tag = reader.uint32();
switch (tag >>> 3) {
case 1: {
if (tag !== 10) {
break;
}
message.subject = reader.string();
continue;
}
case 2: {
if (tag !== 18) {
break;
}
message.roles.push(reader.string());
continue;
}
case 3: {
if (tag !== 26) {
break;
}
const entry3 = Principal_AttributesEntry.decode(reader, reader.uint32());
if (entry3.value !== undefined) {
message.attributes[entry3.key] = entry3.value;
}
continue;
}
}
if ((tag & 7) === 4 || tag === 0) {
break;
}
reader.skip(tag & 7);
}
return message;
},
fromJSON(object: any): Principal {
return {
subject: isSet(object.subject) ? globalThis.String(object.subject) : "",
roles: globalThis.Array.isArray(object?.roles) ? object.roles.map((e: any) => globalThis.String(e)) : [],
attributes: isObject(object.attributes)
? (globalThis.Object.entries(object.attributes) as [string, any][]).reduce(
(acc: { [key: string]: string }, [key, value]: [string, any]) => {
acc[key] = globalThis.String(value);
return acc;
},
{},
)
: {},
};
},
toJSON(message: Principal): unknown {
const obj: any = {};
if (message.subject !== "") {
obj.subject = message.subject;
}
if (message.roles?.length) {
obj.roles = message.roles;
}
if (message.attributes) {
const entries = globalThis.Object.entries(message.attributes) as [string, string][];
if (entries.length > 0) {
obj.attributes = {};
entries.forEach(([k, v]) => {
obj.attributes[k] = v;
});
}
}
return obj;
},
create<I extends Exact<DeepPartial<Principal>, I>>(base?: I): Principal {
return Principal.fromPartial(base ?? ({} as any));
},
fromPartial<I extends Exact<DeepPartial<Principal>, I>>(object: I): Principal {
const message = createBasePrincipal();
message.subject = object.subject ?? "";
message.roles = object.roles?.map((e) => e) || [];
message.attributes = (globalThis.Object.entries(object.attributes ?? {}) as [string, string][]).reduce(
(acc: { [key: string]: string }, [key, value]: [string, string]) => {
if (value !== undefined) {
acc[key] = globalThis.String(value);
}
return acc;
},
{},
);
return message;
},
};
function createBasePrincipal_AttributesEntry(): Principal_AttributesEntry {
return { key: "", value: "" };
}
export const Principal_AttributesEntry: MessageFns<Principal_AttributesEntry> = {
encode(message: Principal_AttributesEntry, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
if (message.key !== "") {
writer.uint32(10).string(message.key);
}
if (message.value !== "") {
writer.uint32(18).string(message.value);
}
return writer;
},
decode(input: BinaryReader | Uint8Array, length?: number): Principal_AttributesEntry {
const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
const end = length === undefined ? reader.len : reader.pos + length;
const message = createBasePrincipal_AttributesEntry();
while (reader.pos < end) {
const tag = reader.uint32();
switch (tag >>> 3) {
case 1: {
if (tag !== 10) {
break;
}
message.key = reader.string();
continue;
}
case 2: {
if (tag !== 18) {
break;
}
message.value = reader.string();
continue;
}
}
if ((tag & 7) === 4 || tag === 0) {
break;
}
reader.skip(tag & 7);
}
return message;
},
fromJSON(object: any): Principal_AttributesEntry {
return {
key: isSet(object.key) ? globalThis.String(object.key) : "",
value: isSet(object.value) ? globalThis.String(object.value) : "",
};
},
toJSON(message: Principal_AttributesEntry): unknown {
const obj: any = {};
if (message.key !== "") {
obj.key = message.key;
}
if (message.value !== "") {
obj.value = message.value;
}
return obj;
},
create<I extends Exact<DeepPartial<Principal_AttributesEntry>, I>>(base?: I): Principal_AttributesEntry {
return Principal_AttributesEntry.fromPartial(base ?? ({} as any));
},
fromPartial<I extends Exact<DeepPartial<Principal_AttributesEntry>, I>>(object: I): Principal_AttributesEntry {
const message = createBasePrincipal_AttributesEntry();
message.key = object.key ?? "";
message.value = object.value ?? "";
return message;
},
};
function createBaseAclSpec(): AclSpec {
return { allowedRoles: [], allowedSubjects: [], deniedRoles: [], requiredAttributes: {} };
}
export const AclSpec: MessageFns<AclSpec> = {
encode(message: AclSpec, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
for (const v of message.allowedRoles) {
writer.uint32(10).string(v!);
}
for (const v of message.allowedSubjects) {
writer.uint32(18).string(v!);
}
for (const v of message.deniedRoles) {
writer.uint32(26).string(v!);
}
globalThis.Object.entries(message.requiredAttributes).forEach(([key, value]: [string, string]) => {
AclSpec_RequiredAttributesEntry.encode({ key: key as any, value }, writer.uint32(34).fork()).join();
});
return writer;
},
decode(input: BinaryReader | Uint8Array, length?: number): AclSpec {
const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
const end = length === undefined ? reader.len : reader.pos + length;
const message = createBaseAclSpec();
while (reader.pos < end) {
const tag = reader.uint32();
switch (tag >>> 3) {
case 1: {
if (tag !== 10) {
break;
}
message.allowedRoles.push(reader.string());
continue;
}
case 2: {
if (tag !== 18) {
break;
}
message.allowedSubjects.push(reader.string());
continue;
}
case 3: {
if (tag !== 26) {
break;
}
message.deniedRoles.push(reader.string());
continue;
}
case 4: {
if (tag !== 34) {
break;
}
const entry4 = AclSpec_RequiredAttributesEntry.decode(reader, reader.uint32());
if (entry4.value !== undefined) {
message.requiredAttributes[entry4.key] = entry4.value;
}
continue;
}
}
if ((tag & 7) === 4 || tag === 0) {
break;
}
reader.skip(tag & 7);
}
return message;
},
fromJSON(object: any): AclSpec {
return {
allowedRoles: globalThis.Array.isArray(object?.allowedRoles)
? object.allowedRoles.map((e: any) => globalThis.String(e))
: globalThis.Array.isArray(object?.allowed_roles)
? object.allowed_roles.map((e: any) => globalThis.String(e))
: [],
allowedSubjects: globalThis.Array.isArray(object?.allowedSubjects)
? object.allowedSubjects.map((e: any) => globalThis.String(e))
: globalThis.Array.isArray(object?.allowed_subjects)
? object.allowed_subjects.map((e: any) => globalThis.String(e))
: [],
deniedRoles: globalThis.Array.isArray(object?.deniedRoles)
? object.deniedRoles.map((e: any) => globalThis.String(e))
: globalThis.Array.isArray(object?.denied_roles)
? object.denied_roles.map((e: any) => globalThis.String(e))
: [],
requiredAttributes: isObject(object.requiredAttributes)
? (globalThis.Object.entries(object.requiredAttributes) as [string, any][]).reduce(
(acc: { [key: string]: string }, [key, value]: [string, any]) => {
acc[key] = globalThis.String(value);
return acc;
},
{},
)
: isObject(object.required_attributes)
? (globalThis.Object.entries(object.required_attributes) as [string, any][]).reduce(
(acc: { [key: string]: string }, [key, value]: [string, any]) => {
acc[key] = globalThis.String(value);
return acc;
},
{},
)
: {},
};
},
toJSON(message: AclSpec): unknown {
const obj: any = {};
if (message.allowedRoles?.length) {
obj.allowedRoles = message.allowedRoles;
}
if (message.allowedSubjects?.length) {
obj.allowedSubjects = message.allowedSubjects;
}
if (message.deniedRoles?.length) {
obj.deniedRoles = message.deniedRoles;
}
if (message.requiredAttributes) {
const entries = globalThis.Object.entries(message.requiredAttributes) as [string, string][];
if (entries.length > 0) {
obj.requiredAttributes = {};
entries.forEach(([k, v]) => {
obj.requiredAttributes[k] = v;
});
}
}
return obj;
},
create<I extends Exact<DeepPartial<AclSpec>, I>>(base?: I): AclSpec {
return AclSpec.fromPartial(base ?? ({} as any));
},
fromPartial<I extends Exact<DeepPartial<AclSpec>, I>>(object: I): AclSpec {
const message = createBaseAclSpec();
message.allowedRoles = object.allowedRoles?.map((e) => e) || [];
message.allowedSubjects = object.allowedSubjects?.map((e) => e) || [];
message.deniedRoles = object.deniedRoles?.map((e) => e) || [];
message.requiredAttributes = (globalThis.Object.entries(object.requiredAttributes ?? {}) as [string, string][])
.reduce((acc: { [key: string]: string }, [key, value]: [string, string]) => {
if (value !== undefined) {
acc[key] = globalThis.String(value);
}
return acc;
}, {});
return message;
},
};
function createBaseAclSpec_RequiredAttributesEntry(): AclSpec_RequiredAttributesEntry {
return { key: "", value: "" };
}
export const AclSpec_RequiredAttributesEntry: MessageFns<AclSpec_RequiredAttributesEntry> = {
encode(message: AclSpec_RequiredAttributesEntry, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
if (message.key !== "") {
writer.uint32(10).string(message.key);
}
if (message.value !== "") {
writer.uint32(18).string(message.value);
}
return writer;
},
decode(input: BinaryReader | Uint8Array, length?: number): AclSpec_RequiredAttributesEntry {
const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
const end = length === undefined ? reader.len : reader.pos + length;
const message = createBaseAclSpec_RequiredAttributesEntry();
while (reader.pos < end) {
const tag = reader.uint32();
switch (tag >>> 3) {
case 1: {
if (tag !== 10) {
break;
}
message.key = reader.string();
continue;
}
case 2: {
if (tag !== 18) {
break;
}
message.value = reader.string();
continue;
}
}
if ((tag & 7) === 4 || tag === 0) {
break;
}
reader.skip(tag & 7);
}
return message;
},
fromJSON(object: any): AclSpec_RequiredAttributesEntry {
return {
key: isSet(object.key) ? globalThis.String(object.key) : "",
value: isSet(object.value) ? globalThis.String(object.value) : "",
};
},
toJSON(message: AclSpec_RequiredAttributesEntry): unknown {
const obj: any = {};
if (message.key !== "") {
obj.key = message.key;
}
if (message.value !== "") {
obj.value = message.value;
}
return obj;
},
create<I extends Exact<DeepPartial<AclSpec_RequiredAttributesEntry>, I>>(base?: I): AclSpec_RequiredAttributesEntry {
return AclSpec_RequiredAttributesEntry.fromPartial(base ?? ({} as any));
},
fromPartial<I extends Exact<DeepPartial<AclSpec_RequiredAttributesEntry>, I>>(
object: I,
): AclSpec_RequiredAttributesEntry {
const message = createBaseAclSpec_RequiredAttributesEntry();
message.key = object.key ?? "";
message.value = object.value ?? "";
return message;
},
};
function createBaseChatMessage(): ChatMessage {
return { role: 0, content: "", toolCallId: "", name: "" };
}
export const ChatMessage: MessageFns<ChatMessage> = {
encode(message: ChatMessage, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
if (message.role !== 0) {
writer.uint32(8).int32(message.role);
}
if (message.content !== "") {
writer.uint32(18).string(message.content);
}
if (message.toolCallId !== "") {
writer.uint32(26).string(message.toolCallId);
}
if (message.name !== "") {
writer.uint32(34).string(message.name);
}
return writer;
},
decode(input: BinaryReader | Uint8Array, length?: number): ChatMessage {
const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
const end = length === undefined ? reader.len : reader.pos + length;
const message = createBaseChatMessage();
while (reader.pos < end) {
const tag = reader.uint32();
switch (tag >>> 3) {
case 1: {
if (tag !== 8) {
break;
}
message.role = reader.int32() as any;
continue;
}
case 2: {
if (tag !== 18) {
break;
}
message.content = reader.string();
continue;
}
case 3: {
if (tag !== 26) {
break;
}
message.toolCallId = reader.string();
continue;
}
case 4: {
if (tag !== 34) {
break;
}
message.name = reader.string();
continue;
}
}
if ((tag & 7) === 4 || tag === 0) {
break;
}
reader.skip(tag & 7);
}
return message;
},
fromJSON(object: any): ChatMessage {
return {
role: isSet(object.role) ? chatRoleFromJSON(object.role) : 0,
content: isSet(object.content) ? globalThis.String(object.content) : "",
toolCallId: isSet(object.toolCallId)
? globalThis.String(object.toolCallId)
: isSet(object.tool_call_id)
? globalThis.String(object.tool_call_id)
: "",
name: isSet(object.name) ? globalThis.String(object.name) : "",
};
},
toJSON(message: ChatMessage): unknown {
const obj: any = {};
if (message.role !== 0) {
obj.role = chatRoleToJSON(message.role);
}
if (message.content !== "") {
obj.content = message.content;
}
if (message.toolCallId !== "") {
obj.toolCallId = message.toolCallId;
}
if (message.name !== "") {
obj.name = message.name;
}
return obj;
},
create<I extends Exact<DeepPartial<ChatMessage>, I>>(base?: I): ChatMessage {
return ChatMessage.fromPartial(base ?? ({} as any));
},
fromPartial<I extends Exact<DeepPartial<ChatMessage>, I>>(object: I): ChatMessage {
const message = createBaseChatMessage();
message.role = object.role ?? 0;
message.content = object.content ?? "";
message.toolCallId = object.toolCallId ?? "";
message.name = object.name ?? "";
return message;
},
};
function createBaseToolDescriptor(): ToolDescriptor {
return { name: "", description: "", schema: undefined };
}
export const ToolDescriptor: MessageFns<ToolDescriptor> = {
encode(message: ToolDescriptor, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
if (message.name !== "") {
writer.uint32(10).string(message.name);
}
if (message.description !== "") {
writer.uint32(18).string(message.description);
}
if (message.schema !== undefined) {
Struct.encode(Struct.wrap(message.schema), writer.uint32(26).fork()).join();
}
return writer;
},
decode(input: BinaryReader | Uint8Array, length?: number): ToolDescriptor {
const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
const end = length === undefined ? reader.len : reader.pos + length;
const message = createBaseToolDescriptor();
while (reader.pos < end) {
const tag = reader.uint32();
switch (tag >>> 3) {
case 1: {
if (tag !== 10) {
break;
}
message.name = reader.string();
continue;
}
case 2: {
if (tag !== 18) {
break;
}
message.description = reader.string();
continue;
}
case 3: {
if (tag !== 26) {
break;
}
message.schema = Struct.unwrap(Struct.decode(reader, reader.uint32()));
continue;
}
}
if ((tag & 7) === 4 || tag === 0) {
break;
}
reader.skip(tag & 7);
}
return message;
},
fromJSON(object: any): ToolDescriptor {
return {
name: isSet(object.name) ? globalThis.String(object.name) : "",
description: isSet(object.description) ? globalThis.String(object.description) : "",
schema: isObject(object.schema) ? object.schema : undefined,
};
},
toJSON(message: ToolDescriptor): unknown {
const obj: any = {};
if (message.name !== "") {
obj.name = message.name;
}
if (message.description !== "") {
obj.description = message.description;
}
if (message.schema !== undefined) {
obj.schema = message.schema;
}
return obj;
},
create<I extends Exact<DeepPartial<ToolDescriptor>, I>>(base?: I): ToolDescriptor {
return ToolDescriptor.fromPartial(base ?? ({} as any));
},
fromPartial<I extends Exact<DeepPartial<ToolDescriptor>, I>>(object: I): ToolDescriptor {
const message = createBaseToolDescriptor();
message.name = object.name ?? "";
message.description = object.description ?? "";
message.schema = object.schema ?? undefined;
return message;
},
};
type Builtin = Date | Function | Uint8Array | string | number | boolean | undefined;
type DeepPartial<T> = T extends Builtin ? T
: T extends Long ? string | number | Long : T extends globalThis.Array<infer U> ? globalThis.Array<DeepPartial<U>>
: T extends ReadonlyArray<infer U> ? ReadonlyArray<DeepPartial<U>>
: T extends {} ? { [K in keyof T]?: DeepPartial<T[K]> }
: Partial<T>;
type KeysOfUnion<T> = T extends T ? keyof T : never;
type Exact<P, I extends P> = P extends Builtin ? P
: P & { [K in keyof P]: Exact<P[K], I[K]> } & { [K in Exclude<keyof I, KeysOfUnion<P>>]: never };
function isObject(value: any): boolean {
return typeof value === "object" && value !== null;
}
function isSet(value: any): boolean {
return value !== null && value !== undefined;
}
interface MessageFns<T> {
encode(message: T, writer?: BinaryWriter): BinaryWriter;
decode(input: BinaryReader | Uint8Array, length?: number): T;
fromJSON(object: any): T;
toJSON(message: T): unknown;
create<I extends Exact<DeepPartial<T>, I>>(base?: I): T;
fromPartial<I extends Exact<DeepPartial<T>, I>>(object: I): T;
}
@@ -0,0 +1,614 @@
// Code generated by protoc-gen-ts_proto. DO NOT EDIT.
// versions:
// protoc-gen-ts_proto v2.11.8
// protoc v3.19.1
// source: google/protobuf/struct.proto
/* eslint-disable */
import { BinaryReader, BinaryWriter } from "@bufbuild/protobuf/wire";
import Long from "long";
/**
* `NullValue` is a singleton enumeration to represent the null value for the
* `Value` type union.
*
* The JSON representation for `NullValue` is JSON `null`.
*/
export enum NullValue {
/** NULL_VALUE - Null value. */
NULL_VALUE = 0,
UNRECOGNIZED = -1,
}
export function nullValueFromJSON(object: any): NullValue {
switch (object) {
case 0:
case "NULL_VALUE":
return NullValue.NULL_VALUE;
case -1:
case "UNRECOGNIZED":
default:
return NullValue.UNRECOGNIZED;
}
}
export function nullValueToJSON(object: NullValue): string {
switch (object) {
case NullValue.NULL_VALUE:
return "NULL_VALUE";
case NullValue.UNRECOGNIZED:
default:
return "UNRECOGNIZED";
}
}
/**
* `Struct` represents a structured data value, consisting of fields
* which map to dynamically typed values. In some languages, `Struct`
* might be supported by a native representation. For example, in
* scripting languages like JS a struct is represented as an
* object. The details of that representation are described together
* with the proto support for the language.
*
* The JSON representation for `Struct` is JSON object.
*/
export interface Struct {
/** Unordered map of dynamically typed values. */
fields: { [key: string]: any | undefined };
}
export interface Struct_FieldsEntry {
key: string;
value?: any | undefined;
}
/**
* `Value` represents a dynamically typed value which can be either
* null, a number, a string, a boolean, a recursive struct value, or a
* list of values. A producer of value is expected to set one of these
* variants. Absence of any variant indicates an error.
*
* The JSON representation for `Value` is JSON value.
*/
export interface Value {
/** Represents a null value. */
nullValue?:
| NullValue
| undefined;
/** Represents a double value. */
numberValue?:
| number
| undefined;
/** Represents a string value. */
stringValue?:
| string
| undefined;
/** Represents a boolean value. */
boolValue?:
| boolean
| undefined;
/** Represents a structured value. */
structValue?:
| { [key: string]: any }
| undefined;
/** Represents a repeated `Value`. */
listValue?: Array<any> | undefined;
}
/**
* `ListValue` is a wrapper around a repeated field of values.
*
* The JSON representation for `ListValue` is JSON array.
*/
export interface ListValue {
/** Repeated field of dynamically typed values. */
values: any[];
}
function createBaseStruct(): Struct {
return { fields: {} };
}
export const Struct: MessageFns<Struct> & StructWrapperFns = {
encode(message: Struct, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
globalThis.Object.entries(message.fields).forEach(([key, value]: [string, any | undefined]) => {
if (value !== undefined) {
Struct_FieldsEntry.encode({ key: key as any, value }, writer.uint32(10).fork()).join();
}
});
return writer;
},
decode(input: BinaryReader | Uint8Array, length?: number): Struct {
const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
const end = length === undefined ? reader.len : reader.pos + length;
const message = createBaseStruct();
while (reader.pos < end) {
const tag = reader.uint32();
switch (tag >>> 3) {
case 1: {
if (tag !== 10) {
break;
}
const entry1 = Struct_FieldsEntry.decode(reader, reader.uint32());
if (entry1.value !== undefined) {
message.fields[entry1.key] = entry1.value;
}
continue;
}
}
if ((tag & 7) === 4 || tag === 0) {
break;
}
reader.skip(tag & 7);
}
return message;
},
fromJSON(object: any): Struct {
return {
fields: isObject(object.fields)
? (globalThis.Object.entries(object.fields) as [string, any][]).reduce(
(acc: { [key: string]: any | undefined }, [key, value]: [string, any]) => {
acc[key] = value as any | undefined;
return acc;
},
{},
)
: {},
};
},
toJSON(message: Struct): unknown {
const obj: any = {};
if (message.fields) {
const entries = globalThis.Object.entries(message.fields) as [string, any | undefined][];
if (entries.length > 0) {
obj.fields = {};
entries.forEach(([k, v]) => {
obj.fields[k] = v;
});
}
}
return obj;
},
create<I extends Exact<DeepPartial<Struct>, I>>(base?: I): Struct {
return Struct.fromPartial(base ?? ({} as any));
},
fromPartial<I extends Exact<DeepPartial<Struct>, I>>(object: I): Struct {
const message = createBaseStruct();
message.fields = (globalThis.Object.entries(object.fields ?? {}) as [string, any | undefined][]).reduce(
(acc: { [key: string]: any | undefined }, [key, value]: [string, any | undefined]) => {
if (value !== undefined) {
acc[key] = value;
}
return acc;
},
{},
);
return message;
},
wrap(object: { [key: string]: any } | undefined): Struct {
const struct = createBaseStruct();
if (object !== undefined) {
for (const key of globalThis.Object.keys(object)) {
struct.fields[key] = object[key];
}
}
return struct;
},
unwrap(message: Struct): { [key: string]: any } {
const object: { [key: string]: any } = {};
if (message.fields) {
for (const key of globalThis.Object.keys(message.fields)) {
object[key] = message.fields[key];
}
}
return object;
},
};
function createBaseStruct_FieldsEntry(): Struct_FieldsEntry {
return { key: "", value: undefined };
}
export const Struct_FieldsEntry: MessageFns<Struct_FieldsEntry> = {
encode(message: Struct_FieldsEntry, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
if (message.key !== "") {
writer.uint32(10).string(message.key);
}
if (message.value !== undefined) {
Value.encode(Value.wrap(message.value), writer.uint32(18).fork()).join();
}
return writer;
},
decode(input: BinaryReader | Uint8Array, length?: number): Struct_FieldsEntry {
const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
const end = length === undefined ? reader.len : reader.pos + length;
const message = createBaseStruct_FieldsEntry();
while (reader.pos < end) {
const tag = reader.uint32();
switch (tag >>> 3) {
case 1: {
if (tag !== 10) {
break;
}
message.key = reader.string();
continue;
}
case 2: {
if (tag !== 18) {
break;
}
message.value = Value.unwrap(Value.decode(reader, reader.uint32()));
continue;
}
}
if ((tag & 7) === 4 || tag === 0) {
break;
}
reader.skip(tag & 7);
}
return message;
},
fromJSON(object: any): Struct_FieldsEntry {
return {
key: isSet(object.key) ? globalThis.String(object.key) : "",
value: isSet(object?.value) ? object.value : undefined,
};
},
toJSON(message: Struct_FieldsEntry): unknown {
const obj: any = {};
if (message.key !== "") {
obj.key = message.key;
}
if (message.value !== undefined) {
obj.value = message.value;
}
return obj;
},
create<I extends Exact<DeepPartial<Struct_FieldsEntry>, I>>(base?: I): Struct_FieldsEntry {
return Struct_FieldsEntry.fromPartial(base ?? ({} as any));
},
fromPartial<I extends Exact<DeepPartial<Struct_FieldsEntry>, I>>(object: I): Struct_FieldsEntry {
const message = createBaseStruct_FieldsEntry();
message.key = object.key ?? "";
message.value = object.value ?? undefined;
return message;
},
};
function createBaseValue(): Value {
return {
nullValue: undefined,
numberValue: undefined,
stringValue: undefined,
boolValue: undefined,
structValue: undefined,
listValue: undefined,
};
}
export const Value: MessageFns<Value> & AnyValueWrapperFns = {
encode(message: Value, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
if (message.nullValue !== undefined) {
writer.uint32(8).int32(message.nullValue);
}
if (message.numberValue !== undefined) {
writer.uint32(17).double(message.numberValue);
}
if (message.stringValue !== undefined) {
writer.uint32(26).string(message.stringValue);
}
if (message.boolValue !== undefined) {
writer.uint32(32).bool(message.boolValue);
}
if (message.structValue !== undefined) {
Struct.encode(Struct.wrap(message.structValue), writer.uint32(42).fork()).join();
}
if (message.listValue !== undefined) {
ListValue.encode(ListValue.wrap(message.listValue), writer.uint32(50).fork()).join();
}
return writer;
},
decode(input: BinaryReader | Uint8Array, length?: number): Value {
const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
const end = length === undefined ? reader.len : reader.pos + length;
const message = createBaseValue();
while (reader.pos < end) {
const tag = reader.uint32();
switch (tag >>> 3) {
case 1: {
if (tag !== 8) {
break;
}
message.nullValue = reader.int32() as any;
continue;
}
case 2: {
if (tag !== 17) {
break;
}
message.numberValue = reader.double();
continue;
}
case 3: {
if (tag !== 26) {
break;
}
message.stringValue = reader.string();
continue;
}
case 4: {
if (tag !== 32) {
break;
}
message.boolValue = reader.bool();
continue;
}
case 5: {
if (tag !== 42) {
break;
}
message.structValue = Struct.unwrap(Struct.decode(reader, reader.uint32()));
continue;
}
case 6: {
if (tag !== 50) {
break;
}
message.listValue = ListValue.unwrap(ListValue.decode(reader, reader.uint32()));
continue;
}
}
if ((tag & 7) === 4 || tag === 0) {
break;
}
reader.skip(tag & 7);
}
return message;
},
fromJSON(object: any): Value {
return {
nullValue: isSet(object.nullValue)
? nullValueFromJSON(object.nullValue)
: isSet(object.null_value)
? nullValueFromJSON(object.null_value)
: undefined,
numberValue: isSet(object.numberValue)
? globalThis.Number(object.numberValue)
: isSet(object.number_value)
? globalThis.Number(object.number_value)
: undefined,
stringValue: isSet(object.stringValue)
? globalThis.String(object.stringValue)
: isSet(object.string_value)
? globalThis.String(object.string_value)
: undefined,
boolValue: isSet(object.boolValue)
? globalThis.Boolean(object.boolValue)
: isSet(object.bool_value)
? globalThis.Boolean(object.bool_value)
: undefined,
structValue: isObject(object.structValue)
? object.structValue
: isObject(object.struct_value)
? object.struct_value
: undefined,
listValue: globalThis.Array.isArray(object.listValue)
? [...object.listValue]
: globalThis.Array.isArray(object.list_value)
? [...object.list_value]
: undefined,
};
},
toJSON(message: Value): unknown {
const obj: any = {};
if (message.nullValue !== undefined) {
obj.nullValue = nullValueToJSON(message.nullValue);
}
if (message.numberValue !== undefined) {
obj.numberValue = message.numberValue;
}
if (message.stringValue !== undefined) {
obj.stringValue = message.stringValue;
}
if (message.boolValue !== undefined) {
obj.boolValue = message.boolValue;
}
if (message.structValue !== undefined) {
obj.structValue = message.structValue;
}
if (message.listValue !== undefined) {
obj.listValue = message.listValue;
}
return obj;
},
create<I extends Exact<DeepPartial<Value>, I>>(base?: I): Value {
return Value.fromPartial(base ?? ({} as any));
},
fromPartial<I extends Exact<DeepPartial<Value>, I>>(object: I): Value {
const message = createBaseValue();
message.nullValue = object.nullValue ?? undefined;
message.numberValue = object.numberValue ?? undefined;
message.stringValue = object.stringValue ?? undefined;
message.boolValue = object.boolValue ?? undefined;
message.structValue = object.structValue ?? undefined;
message.listValue = object.listValue ?? undefined;
return message;
},
wrap(value: any): Value {
const result = createBaseValue();
if (value === null) {
result.nullValue = NullValue.NULL_VALUE;
} else if (typeof value === "boolean") {
result.boolValue = value;
} else if (typeof value === "number") {
result.numberValue = value;
} else if (typeof value === "string") {
result.stringValue = value;
} else if (globalThis.Array.isArray(value)) {
result.listValue = value;
} else if (typeof value === "object") {
result.structValue = value;
} else if (typeof value !== "undefined") {
throw new globalThis.Error("Unsupported any value type: " + typeof value);
}
return result;
},
unwrap(message: any): string | number | boolean | Object | null | Array<any> | undefined {
if (message.stringValue !== undefined) {
return message.stringValue;
} else if (message?.numberValue !== undefined) {
return message.numberValue;
} else if (message?.boolValue !== undefined) {
return message.boolValue;
} else if (message?.structValue !== undefined) {
return message.structValue as any;
} else if (message?.listValue !== undefined) {
return message.listValue;
} else if (message?.nullValue !== undefined) {
return null;
}
return undefined;
},
};
function createBaseListValue(): ListValue {
return { values: [] };
}
export const ListValue: MessageFns<ListValue> & ListValueWrapperFns = {
encode(message: ListValue, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
for (const v of message.values) {
Value.encode(Value.wrap(v!), writer.uint32(10).fork()).join();
}
return writer;
},
decode(input: BinaryReader | Uint8Array, length?: number): ListValue {
const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
const end = length === undefined ? reader.len : reader.pos + length;
const message = createBaseListValue();
while (reader.pos < end) {
const tag = reader.uint32();
switch (tag >>> 3) {
case 1: {
if (tag !== 10) {
break;
}
message.values.push(Value.unwrap(Value.decode(reader, reader.uint32())));
continue;
}
}
if ((tag & 7) === 4 || tag === 0) {
break;
}
reader.skip(tag & 7);
}
return message;
},
fromJSON(object: any): ListValue {
return { values: globalThis.Array.isArray(object?.values) ? [...object.values] : [] };
},
toJSON(message: ListValue): unknown {
const obj: any = {};
if (message.values?.length) {
obj.values = message.values;
}
return obj;
},
create<I extends Exact<DeepPartial<ListValue>, I>>(base?: I): ListValue {
return ListValue.fromPartial(base ?? ({} as any));
},
fromPartial<I extends Exact<DeepPartial<ListValue>, I>>(object: I): ListValue {
const message = createBaseListValue();
message.values = object.values?.map((e) => e) || [];
return message;
},
wrap(array: Array<any> | undefined): ListValue {
const result = createBaseListValue();
result.values = array ?? [];
return result;
},
unwrap(message: ListValue): Array<any> {
if (message?.hasOwnProperty("values") && globalThis.Array.isArray(message.values)) {
return message.values;
} else {
return message as any;
}
},
};
type Builtin = Date | Function | Uint8Array | string | number | boolean | undefined;
type DeepPartial<T> = T extends Builtin ? T
: T extends Long ? string | number | Long : T extends globalThis.Array<infer U> ? globalThis.Array<DeepPartial<U>>
: T extends ReadonlyArray<infer U> ? ReadonlyArray<DeepPartial<U>>
: T extends {} ? { [K in keyof T]?: DeepPartial<T[K]> }
: Partial<T>;
type KeysOfUnion<T> = T extends T ? keyof T : never;
type Exact<P, I extends P> = P extends Builtin ? P
: P & { [K in keyof P]: Exact<P[K], I[K]> } & { [K in Exclude<keyof I, KeysOfUnion<P>>]: never };
function isObject(value: any): boolean {
return typeof value === "object" && value !== null;
}
function isSet(value: any): boolean {
return value !== null && value !== undefined;
}
interface MessageFns<T> {
encode(message: T, writer?: BinaryWriter): BinaryWriter;
decode(input: BinaryReader | Uint8Array, length?: number): T;
fromJSON(object: any): T;
toJSON(message: T): unknown;
create<I extends Exact<DeepPartial<T>, I>>(base?: I): T;
fromPartial<I extends Exact<DeepPartial<T>, I>>(object: I): T;
}
interface StructWrapperFns {
wrap(object: { [key: string]: any } | undefined): Struct;
unwrap(message: Struct): { [key: string]: any };
}
interface AnyValueWrapperFns {
wrap(value: any): Value;
unwrap(message: any): string | number | boolean | Object | null | Array<any> | undefined;
}
interface ListValueWrapperFns {
wrap(array: Array<any> | undefined): ListValue;
unwrap(message: ListValue): Array<any>;
}
@@ -0,0 +1,219 @@
// Code generated by protoc-gen-ts_proto. DO NOT EDIT.
// versions:
// protoc-gen-ts_proto v2.11.8
// protoc v3.19.1
// source: google/protobuf/timestamp.proto
/* eslint-disable */
import { BinaryReader, BinaryWriter } from "@bufbuild/protobuf/wire";
import Long from "long";
/**
* A Timestamp represents a point in time independent of any time zone or local
* calendar, encoded as a count of seconds and fractions of seconds at
* nanosecond resolution. The count is relative to an epoch at UTC midnight on
* January 1, 1970, in the proleptic Gregorian calendar which extends the
* Gregorian calendar backwards to year one.
*
* All minutes are 60 seconds long. Leap seconds are "smeared" so that no leap
* second table is needed for interpretation, using a [24-hour linear
* smear](https://developers.google.com/time/smear).
*
* The range is from 0001-01-01T00:00:00Z to 9999-12-31T23:59:59.999999999Z. By
* restricting to that range, we ensure that we can convert to and from [RFC
* 3339](https://www.ietf.org/rfc/rfc3339.txt) date strings.
*
* # Examples
*
* Example 1: Compute Timestamp from POSIX `time()`.
*
* Timestamp timestamp;
* timestamp.set_seconds(time(NULL));
* timestamp.set_nanos(0);
*
* Example 2: Compute Timestamp from POSIX `gettimeofday()`.
*
* struct timeval tv;
* gettimeofday(&tv, NULL);
*
* Timestamp timestamp;
* timestamp.set_seconds(tv.tv_sec);
* timestamp.set_nanos(tv.tv_usec * 1000);
*
* Example 3: Compute Timestamp from Win32 `GetSystemTimeAsFileTime()`.
*
* FILETIME ft;
* GetSystemTimeAsFileTime(&ft);
* UINT64 ticks = (((UINT64)ft.dwHighDateTime) << 32) | ft.dwLowDateTime;
*
* // A Windows tick is 100 nanoseconds. Windows epoch 1601-01-01T00:00:00Z
* // is 11644473600 seconds before Unix epoch 1970-01-01T00:00:00Z.
* Timestamp timestamp;
* timestamp.set_seconds((INT64) ((ticks / 10000000) - 11644473600LL));
* timestamp.set_nanos((INT32) ((ticks % 10000000) * 100));
*
* Example 4: Compute Timestamp from Java `System.currentTimeMillis()`.
*
* long millis = System.currentTimeMillis();
*
* Timestamp timestamp = Timestamp.newBuilder().setSeconds(millis / 1000)
* .setNanos((int) ((millis % 1000) * 1000000)).build();
*
* Example 5: Compute Timestamp from Java `Instant.now()`.
*
* Instant now = Instant.now();
*
* Timestamp timestamp =
* Timestamp.newBuilder().setSeconds(now.getEpochSecond())
* .setNanos(now.getNano()).build();
*
* Example 6: Compute Timestamp from current time in Python.
*
* timestamp = Timestamp()
* timestamp.GetCurrentTime()
*
* # JSON Mapping
*
* In JSON format, the Timestamp type is encoded as a string in the
* [RFC 3339](https://www.ietf.org/rfc/rfc3339.txt) format. That is, the
* format is "{year}-{month}-{day}T{hour}:{min}:{sec}[.{frac_sec}]Z"
* where {year} is always expressed using four digits while {month}, {day},
* {hour}, {min}, and {sec} are zero-padded to two digits each. The fractional
* seconds, which can go up to 9 digits (i.e. up to 1 nanosecond resolution),
* are optional. The "Z" suffix indicates the timezone ("UTC"); the timezone
* is required. A proto3 JSON serializer should always use UTC (as indicated by
* "Z") when printing the Timestamp type and a proto3 JSON parser should be
* able to accept both UTC and other timezones (as indicated by an offset).
*
* For example, "2017-01-15T01:30:15.01Z" encodes 15.01 seconds past
* 01:30 UTC on January 15, 2017.
*
* In JavaScript, one can convert a Date object to this format using the
* standard
* [toISOString()](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/toISOString)
* method. In Python, a standard `datetime.datetime` object can be converted
* to this format using
* [`strftime`](https://docs.python.org/2/library/time.html#time.strftime) with
* the time format spec '%Y-%m-%dT%H:%M:%S.%fZ'. Likewise, in Java, one can use
* the Joda Time's [`ISODateTimeFormat.dateTime()`](
* http://www.joda.org/joda-time/apidocs/org/joda/time/format/ISODateTimeFormat.html#dateTime%2D%2D
* ) to obtain a formatter capable of generating timestamps in this format.
*/
export interface Timestamp {
/**
* Represents seconds of UTC time since Unix epoch
* 1970-01-01T00:00:00Z. Must be from 0001-01-01T00:00:00Z to
* 9999-12-31T23:59:59Z inclusive.
*/
seconds: Long;
/**
* Non-negative fractions of a second at nanosecond resolution. Negative
* second values with fractions must still have non-negative nanos values
* that count forward in time. Must be from 0 to 999,999,999
* inclusive.
*/
nanos: number;
}
function createBaseTimestamp(): Timestamp {
return { seconds: Long.ZERO, nanos: 0 };
}
export const Timestamp: MessageFns<Timestamp> = {
encode(message: Timestamp, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
if (!message.seconds.equals(Long.ZERO)) {
writer.uint32(8).int64(message.seconds.toString());
}
if (message.nanos !== 0) {
writer.uint32(16).int32(message.nanos);
}
return writer;
},
decode(input: BinaryReader | Uint8Array, length?: number): Timestamp {
const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
const end = length === undefined ? reader.len : reader.pos + length;
const message = createBaseTimestamp();
while (reader.pos < end) {
const tag = reader.uint32();
switch (tag >>> 3) {
case 1: {
if (tag !== 8) {
break;
}
message.seconds = Long.fromString(reader.int64().toString());
continue;
}
case 2: {
if (tag !== 16) {
break;
}
message.nanos = reader.int32();
continue;
}
}
if ((tag & 7) === 4 || tag === 0) {
break;
}
reader.skip(tag & 7);
}
return message;
},
fromJSON(object: any): Timestamp {
return {
seconds: isSet(object.seconds) ? Long.fromValue(object.seconds) : Long.ZERO,
nanos: isSet(object.nanos) ? globalThis.Number(object.nanos) : 0,
};
},
toJSON(message: Timestamp): unknown {
const obj: any = {};
if (!message.seconds.equals(Long.ZERO)) {
obj.seconds = (message.seconds || Long.ZERO).toString();
}
if (message.nanos !== 0) {
obj.nanos = Math.round(message.nanos);
}
return obj;
},
create<I extends Exact<DeepPartial<Timestamp>, I>>(base?: I): Timestamp {
return Timestamp.fromPartial(base ?? ({} as any));
},
fromPartial<I extends Exact<DeepPartial<Timestamp>, I>>(object: I): Timestamp {
const message = createBaseTimestamp();
message.seconds = (object.seconds !== undefined && object.seconds !== null)
? Long.fromValue(object.seconds)
: Long.ZERO;
message.nanos = object.nanos ?? 0;
return message;
},
};
type Builtin = Date | Function | Uint8Array | string | number | boolean | undefined;
type DeepPartial<T> = T extends Builtin ? T
: T extends Long ? string | number | Long : T extends globalThis.Array<infer U> ? globalThis.Array<DeepPartial<U>>
: T extends ReadonlyArray<infer U> ? ReadonlyArray<DeepPartial<U>>
: T extends {} ? { [K in keyof T]?: DeepPartial<T[K]> }
: Partial<T>;
type KeysOfUnion<T> = T extends T ? keyof T : never;
type Exact<P, I extends P> = P extends Builtin ? P
: P & { [K in keyof P]: Exact<P[K], I[K]> } & { [K in Exclude<keyof I, KeysOfUnion<P>>]: never };
function isSet(value: any): boolean {
return value !== null && value !== undefined;
}
interface MessageFns<T> {
encode(message: T, writer?: BinaryWriter): BinaryWriter;
decode(input: BinaryReader | Uint8Array, length?: number): T;
fromJSON(object: any): T;
toJSON(message: T): unknown;
create<I extends Exact<DeepPartial<T>, I>>(base?: I): T;
fromPartial<I extends Exact<DeepPartial<T>, I>>(object: I): T;
}
@@ -0,0 +1,858 @@
// Code generated by protoc-gen-ts_proto. DO NOT EDIT.
// versions:
// protoc-gen-ts_proto v2.11.8
// protoc v3.19.1
// source: ingestion.proto
/* eslint-disable */
import { BinaryReader, BinaryWriter } from "@bufbuild/protobuf/wire";
import {
type CallOptions,
type ChannelCredentials,
Client,
type ClientOptions,
type ClientUnaryCall,
type handleUnaryCall,
makeGenericClientConstructor,
type Metadata,
type ServiceError,
type UntypedServiceImplementation,
} from "@grpc/grpc-js";
import Long from "long";
import { AclSpec, Principal } from "./common";
import { Struct } from "./google/protobuf/struct";
import { Timestamp } from "./google/protobuf/timestamp";
export interface IngestChunk {
content: string;
metadata?: { [key: string]: any } | undefined;
acl?: AclSpec | undefined;
}
export interface IngestDocumentRequest {
source: string;
externalId: string;
title: string;
chunks: IngestChunk[];
principal?: Principal | undefined;
}
export interface IngestDocumentResponse {
documentId: string;
chunksIngested: number;
}
export interface GetDocumentRequest {
id: string;
}
export interface DocumentSummary {
id: string;
sourceUri: string;
title: string;
externalId: string;
ingestedAt?: Date | undefined;
chunkCount: number;
}
export interface DeleteDocumentRequest {
id: string;
}
export interface DeleteDocumentResponse {
deleted: boolean;
}
function createBaseIngestChunk(): IngestChunk {
return { content: "", metadata: undefined, acl: undefined };
}
export const IngestChunk: MessageFns<IngestChunk> = {
encode(message: IngestChunk, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
if (message.content !== "") {
writer.uint32(10).string(message.content);
}
if (message.metadata !== undefined) {
Struct.encode(Struct.wrap(message.metadata), writer.uint32(18).fork()).join();
}
if (message.acl !== undefined) {
AclSpec.encode(message.acl, writer.uint32(26).fork()).join();
}
return writer;
},
decode(input: BinaryReader | Uint8Array, length?: number): IngestChunk {
const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
const end = length === undefined ? reader.len : reader.pos + length;
const message = createBaseIngestChunk();
while (reader.pos < end) {
const tag = reader.uint32();
switch (tag >>> 3) {
case 1: {
if (tag !== 10) {
break;
}
message.content = reader.string();
continue;
}
case 2: {
if (tag !== 18) {
break;
}
message.metadata = Struct.unwrap(Struct.decode(reader, reader.uint32()));
continue;
}
case 3: {
if (tag !== 26) {
break;
}
message.acl = AclSpec.decode(reader, reader.uint32());
continue;
}
}
if ((tag & 7) === 4 || tag === 0) {
break;
}
reader.skip(tag & 7);
}
return message;
},
fromJSON(object: any): IngestChunk {
return {
content: isSet(object.content) ? globalThis.String(object.content) : "",
metadata: isObject(object.metadata) ? object.metadata : undefined,
acl: isSet(object.acl) ? AclSpec.fromJSON(object.acl) : undefined,
};
},
toJSON(message: IngestChunk): unknown {
const obj: any = {};
if (message.content !== "") {
obj.content = message.content;
}
if (message.metadata !== undefined) {
obj.metadata = message.metadata;
}
if (message.acl !== undefined) {
obj.acl = AclSpec.toJSON(message.acl);
}
return obj;
},
create<I extends Exact<DeepPartial<IngestChunk>, I>>(base?: I): IngestChunk {
return IngestChunk.fromPartial(base ?? ({} as any));
},
fromPartial<I extends Exact<DeepPartial<IngestChunk>, I>>(object: I): IngestChunk {
const message = createBaseIngestChunk();
message.content = object.content ?? "";
message.metadata = object.metadata ?? undefined;
message.acl = (object.acl !== undefined && object.acl !== null) ? AclSpec.fromPartial(object.acl) : undefined;
return message;
},
};
function createBaseIngestDocumentRequest(): IngestDocumentRequest {
return { source: "", externalId: "", title: "", chunks: [], principal: undefined };
}
export const IngestDocumentRequest: MessageFns<IngestDocumentRequest> = {
encode(message: IngestDocumentRequest, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
if (message.source !== "") {
writer.uint32(10).string(message.source);
}
if (message.externalId !== "") {
writer.uint32(18).string(message.externalId);
}
if (message.title !== "") {
writer.uint32(26).string(message.title);
}
for (const v of message.chunks) {
IngestChunk.encode(v!, writer.uint32(34).fork()).join();
}
if (message.principal !== undefined) {
Principal.encode(message.principal, writer.uint32(42).fork()).join();
}
return writer;
},
decode(input: BinaryReader | Uint8Array, length?: number): IngestDocumentRequest {
const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
const end = length === undefined ? reader.len : reader.pos + length;
const message = createBaseIngestDocumentRequest();
while (reader.pos < end) {
const tag = reader.uint32();
switch (tag >>> 3) {
case 1: {
if (tag !== 10) {
break;
}
message.source = reader.string();
continue;
}
case 2: {
if (tag !== 18) {
break;
}
message.externalId = reader.string();
continue;
}
case 3: {
if (tag !== 26) {
break;
}
message.title = reader.string();
continue;
}
case 4: {
if (tag !== 34) {
break;
}
message.chunks.push(IngestChunk.decode(reader, reader.uint32()));
continue;
}
case 5: {
if (tag !== 42) {
break;
}
message.principal = Principal.decode(reader, reader.uint32());
continue;
}
}
if ((tag & 7) === 4 || tag === 0) {
break;
}
reader.skip(tag & 7);
}
return message;
},
fromJSON(object: any): IngestDocumentRequest {
return {
source: isSet(object.source) ? globalThis.String(object.source) : "",
externalId: isSet(object.externalId)
? globalThis.String(object.externalId)
: isSet(object.external_id)
? globalThis.String(object.external_id)
: "",
title: isSet(object.title) ? globalThis.String(object.title) : "",
chunks: globalThis.Array.isArray(object?.chunks) ? object.chunks.map((e: any) => IngestChunk.fromJSON(e)) : [],
principal: isSet(object.principal) ? Principal.fromJSON(object.principal) : undefined,
};
},
toJSON(message: IngestDocumentRequest): unknown {
const obj: any = {};
if (message.source !== "") {
obj.source = message.source;
}
if (message.externalId !== "") {
obj.externalId = message.externalId;
}
if (message.title !== "") {
obj.title = message.title;
}
if (message.chunks?.length) {
obj.chunks = message.chunks.map((e) => IngestChunk.toJSON(e));
}
if (message.principal !== undefined) {
obj.principal = Principal.toJSON(message.principal);
}
return obj;
},
create<I extends Exact<DeepPartial<IngestDocumentRequest>, I>>(base?: I): IngestDocumentRequest {
return IngestDocumentRequest.fromPartial(base ?? ({} as any));
},
fromPartial<I extends Exact<DeepPartial<IngestDocumentRequest>, I>>(object: I): IngestDocumentRequest {
const message = createBaseIngestDocumentRequest();
message.source = object.source ?? "";
message.externalId = object.externalId ?? "";
message.title = object.title ?? "";
message.chunks = object.chunks?.map((e) => IngestChunk.fromPartial(e)) || [];
message.principal = (object.principal !== undefined && object.principal !== null)
? Principal.fromPartial(object.principal)
: undefined;
return message;
},
};
function createBaseIngestDocumentResponse(): IngestDocumentResponse {
return { documentId: "", chunksIngested: 0 };
}
export const IngestDocumentResponse: MessageFns<IngestDocumentResponse> = {
encode(message: IngestDocumentResponse, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
if (message.documentId !== "") {
writer.uint32(10).string(message.documentId);
}
if (message.chunksIngested !== 0) {
writer.uint32(16).int32(message.chunksIngested);
}
return writer;
},
decode(input: BinaryReader | Uint8Array, length?: number): IngestDocumentResponse {
const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
const end = length === undefined ? reader.len : reader.pos + length;
const message = createBaseIngestDocumentResponse();
while (reader.pos < end) {
const tag = reader.uint32();
switch (tag >>> 3) {
case 1: {
if (tag !== 10) {
break;
}
message.documentId = reader.string();
continue;
}
case 2: {
if (tag !== 16) {
break;
}
message.chunksIngested = reader.int32();
continue;
}
}
if ((tag & 7) === 4 || tag === 0) {
break;
}
reader.skip(tag & 7);
}
return message;
},
fromJSON(object: any): IngestDocumentResponse {
return {
documentId: isSet(object.documentId)
? globalThis.String(object.documentId)
: isSet(object.document_id)
? globalThis.String(object.document_id)
: "",
chunksIngested: isSet(object.chunksIngested)
? globalThis.Number(object.chunksIngested)
: isSet(object.chunks_ingested)
? globalThis.Number(object.chunks_ingested)
: 0,
};
},
toJSON(message: IngestDocumentResponse): unknown {
const obj: any = {};
if (message.documentId !== "") {
obj.documentId = message.documentId;
}
if (message.chunksIngested !== 0) {
obj.chunksIngested = Math.round(message.chunksIngested);
}
return obj;
},
create<I extends Exact<DeepPartial<IngestDocumentResponse>, I>>(base?: I): IngestDocumentResponse {
return IngestDocumentResponse.fromPartial(base ?? ({} as any));
},
fromPartial<I extends Exact<DeepPartial<IngestDocumentResponse>, I>>(object: I): IngestDocumentResponse {
const message = createBaseIngestDocumentResponse();
message.documentId = object.documentId ?? "";
message.chunksIngested = object.chunksIngested ?? 0;
return message;
},
};
function createBaseGetDocumentRequest(): GetDocumentRequest {
return { id: "" };
}
export const GetDocumentRequest: MessageFns<GetDocumentRequest> = {
encode(message: GetDocumentRequest, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
if (message.id !== "") {
writer.uint32(10).string(message.id);
}
return writer;
},
decode(input: BinaryReader | Uint8Array, length?: number): GetDocumentRequest {
const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
const end = length === undefined ? reader.len : reader.pos + length;
const message = createBaseGetDocumentRequest();
while (reader.pos < end) {
const tag = reader.uint32();
switch (tag >>> 3) {
case 1: {
if (tag !== 10) {
break;
}
message.id = reader.string();
continue;
}
}
if ((tag & 7) === 4 || tag === 0) {
break;
}
reader.skip(tag & 7);
}
return message;
},
fromJSON(object: any): GetDocumentRequest {
return { id: isSet(object.id) ? globalThis.String(object.id) : "" };
},
toJSON(message: GetDocumentRequest): unknown {
const obj: any = {};
if (message.id !== "") {
obj.id = message.id;
}
return obj;
},
create<I extends Exact<DeepPartial<GetDocumentRequest>, I>>(base?: I): GetDocumentRequest {
return GetDocumentRequest.fromPartial(base ?? ({} as any));
},
fromPartial<I extends Exact<DeepPartial<GetDocumentRequest>, I>>(object: I): GetDocumentRequest {
const message = createBaseGetDocumentRequest();
message.id = object.id ?? "";
return message;
},
};
function createBaseDocumentSummary(): DocumentSummary {
return { id: "", sourceUri: "", title: "", externalId: "", ingestedAt: undefined, chunkCount: 0 };
}
export const DocumentSummary: MessageFns<DocumentSummary> = {
encode(message: DocumentSummary, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
if (message.id !== "") {
writer.uint32(10).string(message.id);
}
if (message.sourceUri !== "") {
writer.uint32(18).string(message.sourceUri);
}
if (message.title !== "") {
writer.uint32(26).string(message.title);
}
if (message.externalId !== "") {
writer.uint32(34).string(message.externalId);
}
if (message.ingestedAt !== undefined) {
Timestamp.encode(toTimestamp(message.ingestedAt), writer.uint32(42).fork()).join();
}
if (message.chunkCount !== 0) {
writer.uint32(48).int32(message.chunkCount);
}
return writer;
},
decode(input: BinaryReader | Uint8Array, length?: number): DocumentSummary {
const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
const end = length === undefined ? reader.len : reader.pos + length;
const message = createBaseDocumentSummary();
while (reader.pos < end) {
const tag = reader.uint32();
switch (tag >>> 3) {
case 1: {
if (tag !== 10) {
break;
}
message.id = reader.string();
continue;
}
case 2: {
if (tag !== 18) {
break;
}
message.sourceUri = reader.string();
continue;
}
case 3: {
if (tag !== 26) {
break;
}
message.title = reader.string();
continue;
}
case 4: {
if (tag !== 34) {
break;
}
message.externalId = reader.string();
continue;
}
case 5: {
if (tag !== 42) {
break;
}
message.ingestedAt = fromTimestamp(Timestamp.decode(reader, reader.uint32()));
continue;
}
case 6: {
if (tag !== 48) {
break;
}
message.chunkCount = reader.int32();
continue;
}
}
if ((tag & 7) === 4 || tag === 0) {
break;
}
reader.skip(tag & 7);
}
return message;
},
fromJSON(object: any): DocumentSummary {
return {
id: isSet(object.id) ? globalThis.String(object.id) : "",
sourceUri: isSet(object.sourceUri)
? globalThis.String(object.sourceUri)
: isSet(object.source_uri)
? globalThis.String(object.source_uri)
: "",
title: isSet(object.title) ? globalThis.String(object.title) : "",
externalId: isSet(object.externalId)
? globalThis.String(object.externalId)
: isSet(object.external_id)
? globalThis.String(object.external_id)
: "",
ingestedAt: isSet(object.ingestedAt)
? fromJsonTimestamp(object.ingestedAt)
: isSet(object.ingested_at)
? fromJsonTimestamp(object.ingested_at)
: undefined,
chunkCount: isSet(object.chunkCount)
? globalThis.Number(object.chunkCount)
: isSet(object.chunk_count)
? globalThis.Number(object.chunk_count)
: 0,
};
},
toJSON(message: DocumentSummary): unknown {
const obj: any = {};
if (message.id !== "") {
obj.id = message.id;
}
if (message.sourceUri !== "") {
obj.sourceUri = message.sourceUri;
}
if (message.title !== "") {
obj.title = message.title;
}
if (message.externalId !== "") {
obj.externalId = message.externalId;
}
if (message.ingestedAt !== undefined) {
obj.ingestedAt = message.ingestedAt.toISOString();
}
if (message.chunkCount !== 0) {
obj.chunkCount = Math.round(message.chunkCount);
}
return obj;
},
create<I extends Exact<DeepPartial<DocumentSummary>, I>>(base?: I): DocumentSummary {
return DocumentSummary.fromPartial(base ?? ({} as any));
},
fromPartial<I extends Exact<DeepPartial<DocumentSummary>, I>>(object: I): DocumentSummary {
const message = createBaseDocumentSummary();
message.id = object.id ?? "";
message.sourceUri = object.sourceUri ?? "";
message.title = object.title ?? "";
message.externalId = object.externalId ?? "";
message.ingestedAt = object.ingestedAt ?? undefined;
message.chunkCount = object.chunkCount ?? 0;
return message;
},
};
function createBaseDeleteDocumentRequest(): DeleteDocumentRequest {
return { id: "" };
}
export const DeleteDocumentRequest: MessageFns<DeleteDocumentRequest> = {
encode(message: DeleteDocumentRequest, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
if (message.id !== "") {
writer.uint32(10).string(message.id);
}
return writer;
},
decode(input: BinaryReader | Uint8Array, length?: number): DeleteDocumentRequest {
const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
const end = length === undefined ? reader.len : reader.pos + length;
const message = createBaseDeleteDocumentRequest();
while (reader.pos < end) {
const tag = reader.uint32();
switch (tag >>> 3) {
case 1: {
if (tag !== 10) {
break;
}
message.id = reader.string();
continue;
}
}
if ((tag & 7) === 4 || tag === 0) {
break;
}
reader.skip(tag & 7);
}
return message;
},
fromJSON(object: any): DeleteDocumentRequest {
return { id: isSet(object.id) ? globalThis.String(object.id) : "" };
},
toJSON(message: DeleteDocumentRequest): unknown {
const obj: any = {};
if (message.id !== "") {
obj.id = message.id;
}
return obj;
},
create<I extends Exact<DeepPartial<DeleteDocumentRequest>, I>>(base?: I): DeleteDocumentRequest {
return DeleteDocumentRequest.fromPartial(base ?? ({} as any));
},
fromPartial<I extends Exact<DeepPartial<DeleteDocumentRequest>, I>>(object: I): DeleteDocumentRequest {
const message = createBaseDeleteDocumentRequest();
message.id = object.id ?? "";
return message;
},
};
function createBaseDeleteDocumentResponse(): DeleteDocumentResponse {
return { deleted: false };
}
export const DeleteDocumentResponse: MessageFns<DeleteDocumentResponse> = {
encode(message: DeleteDocumentResponse, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
if (message.deleted !== false) {
writer.uint32(8).bool(message.deleted);
}
return writer;
},
decode(input: BinaryReader | Uint8Array, length?: number): DeleteDocumentResponse {
const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
const end = length === undefined ? reader.len : reader.pos + length;
const message = createBaseDeleteDocumentResponse();
while (reader.pos < end) {
const tag = reader.uint32();
switch (tag >>> 3) {
case 1: {
if (tag !== 8) {
break;
}
message.deleted = reader.bool();
continue;
}
}
if ((tag & 7) === 4 || tag === 0) {
break;
}
reader.skip(tag & 7);
}
return message;
},
fromJSON(object: any): DeleteDocumentResponse {
return { deleted: isSet(object.deleted) ? globalThis.Boolean(object.deleted) : false };
},
toJSON(message: DeleteDocumentResponse): unknown {
const obj: any = {};
if (message.deleted !== false) {
obj.deleted = message.deleted;
}
return obj;
},
create<I extends Exact<DeepPartial<DeleteDocumentResponse>, I>>(base?: I): DeleteDocumentResponse {
return DeleteDocumentResponse.fromPartial(base ?? ({} as any));
},
fromPartial<I extends Exact<DeepPartial<DeleteDocumentResponse>, I>>(object: I): DeleteDocumentResponse {
const message = createBaseDeleteDocumentResponse();
message.deleted = object.deleted ?? false;
return message;
},
};
/**
* Document ingestion. Every chunk must carry an explicit ACL: empty
* ACL is default-deny and rejected with InvalidArgument (gRPC) /
* 422 (HTTP).
*/
export type IngestionServiceService = typeof IngestionServiceService;
export const IngestionServiceService = {
ingestDocument: {
path: "/apf.ai.v1.IngestionService/IngestDocument" as const,
requestStream: false as const,
responseStream: false as const,
requestSerialize: (value: IngestDocumentRequest): Buffer =>
Buffer.from(IngestDocumentRequest.encode(value).finish()),
requestDeserialize: (value: Buffer): IngestDocumentRequest => IngestDocumentRequest.decode(value),
responseSerialize: (value: IngestDocumentResponse): Buffer =>
Buffer.from(IngestDocumentResponse.encode(value).finish()),
responseDeserialize: (value: Buffer): IngestDocumentResponse => IngestDocumentResponse.decode(value),
},
getDocument: {
path: "/apf.ai.v1.IngestionService/GetDocument" as const,
requestStream: false as const,
responseStream: false as const,
requestSerialize: (value: GetDocumentRequest): Buffer => Buffer.from(GetDocumentRequest.encode(value).finish()),
requestDeserialize: (value: Buffer): GetDocumentRequest => GetDocumentRequest.decode(value),
responseSerialize: (value: DocumentSummary): Buffer => Buffer.from(DocumentSummary.encode(value).finish()),
responseDeserialize: (value: Buffer): DocumentSummary => DocumentSummary.decode(value),
},
deleteDocument: {
path: "/apf.ai.v1.IngestionService/DeleteDocument" as const,
requestStream: false as const,
responseStream: false as const,
requestSerialize: (value: DeleteDocumentRequest): Buffer =>
Buffer.from(DeleteDocumentRequest.encode(value).finish()),
requestDeserialize: (value: Buffer): DeleteDocumentRequest => DeleteDocumentRequest.decode(value),
responseSerialize: (value: DeleteDocumentResponse): Buffer =>
Buffer.from(DeleteDocumentResponse.encode(value).finish()),
responseDeserialize: (value: Buffer): DeleteDocumentResponse => DeleteDocumentResponse.decode(value),
},
} as const;
export interface IngestionServiceServer extends UntypedServiceImplementation {
ingestDocument: handleUnaryCall<IngestDocumentRequest, IngestDocumentResponse>;
getDocument: handleUnaryCall<GetDocumentRequest, DocumentSummary>;
deleteDocument: handleUnaryCall<DeleteDocumentRequest, DeleteDocumentResponse>;
}
export interface IngestionServiceClient extends Client {
ingestDocument(
request: IngestDocumentRequest,
callback: (error: ServiceError | null, response: IngestDocumentResponse) => void,
): ClientUnaryCall;
ingestDocument(
request: IngestDocumentRequest,
metadata: Metadata,
callback: (error: ServiceError | null, response: IngestDocumentResponse) => void,
): ClientUnaryCall;
ingestDocument(
request: IngestDocumentRequest,
metadata: Metadata,
options: Partial<CallOptions>,
callback: (error: ServiceError | null, response: IngestDocumentResponse) => void,
): ClientUnaryCall;
getDocument(
request: GetDocumentRequest,
callback: (error: ServiceError | null, response: DocumentSummary) => void,
): ClientUnaryCall;
getDocument(
request: GetDocumentRequest,
metadata: Metadata,
callback: (error: ServiceError | null, response: DocumentSummary) => void,
): ClientUnaryCall;
getDocument(
request: GetDocumentRequest,
metadata: Metadata,
options: Partial<CallOptions>,
callback: (error: ServiceError | null, response: DocumentSummary) => void,
): ClientUnaryCall;
deleteDocument(
request: DeleteDocumentRequest,
callback: (error: ServiceError | null, response: DeleteDocumentResponse) => void,
): ClientUnaryCall;
deleteDocument(
request: DeleteDocumentRequest,
metadata: Metadata,
callback: (error: ServiceError | null, response: DeleteDocumentResponse) => void,
): ClientUnaryCall;
deleteDocument(
request: DeleteDocumentRequest,
metadata: Metadata,
options: Partial<CallOptions>,
callback: (error: ServiceError | null, response: DeleteDocumentResponse) => void,
): ClientUnaryCall;
}
export const IngestionServiceClient = makeGenericClientConstructor(
IngestionServiceService,
"apf.ai.v1.IngestionService",
) as unknown as {
new (address: string, credentials: ChannelCredentials, options?: Partial<ClientOptions>): IngestionServiceClient;
service: typeof IngestionServiceService;
serviceName: string;
};
type Builtin = Date | Function | Uint8Array | string | number | boolean | undefined;
type DeepPartial<T> = T extends Builtin ? T
: T extends Long ? string | number | Long : T extends globalThis.Array<infer U> ? globalThis.Array<DeepPartial<U>>
: T extends ReadonlyArray<infer U> ? ReadonlyArray<DeepPartial<U>>
: T extends {} ? { [K in keyof T]?: DeepPartial<T[K]> }
: Partial<T>;
type KeysOfUnion<T> = T extends T ? keyof T : never;
type Exact<P, I extends P> = P extends Builtin ? P
: P & { [K in keyof P]: Exact<P[K], I[K]> } & { [K in Exclude<keyof I, KeysOfUnion<P>>]: never };
function toTimestamp(date: Date): Timestamp {
const seconds = numberToLong(Math.trunc(date.getTime() / 1_000));
const nanos = (date.getTime() % 1_000) * 1_000_000;
return { seconds, nanos };
}
function fromTimestamp(t: Timestamp): Date {
let millis = (t.seconds.toNumber() || 0) * 1_000;
millis += (t.nanos || 0) / 1_000_000;
return new globalThis.Date(millis);
}
function fromJsonTimestamp(o: any): Date {
if (o instanceof globalThis.Date) {
return o;
} else if (typeof o === "string") {
return new globalThis.Date(o);
} else {
return fromTimestamp(Timestamp.fromJSON(o));
}
}
function numberToLong(number: number) {
return Long.fromNumber(number);
}
function isObject(value: any): boolean {
return typeof value === "object" && value !== null;
}
function isSet(value: any): boolean {
return value !== null && value !== undefined;
}
interface MessageFns<T> {
encode(message: T, writer?: BinaryWriter): BinaryWriter;
decode(input: BinaryReader | Uint8Array, length?: number): T;
fromJSON(object: any): T;
toJSON(message: T): unknown;
create<I extends Exact<DeepPartial<T>, I>>(base?: I): T;
fromPartial<I extends Exact<DeepPartial<T>, I>>(object: I): T;
}
@@ -0,0 +1,355 @@
// Code generated by protoc-gen-ts_proto. DO NOT EDIT.
// versions:
// protoc-gen-ts_proto v2.11.8
// protoc v3.19.1
// source: models.proto
/* eslint-disable */
import { BinaryReader, BinaryWriter } from "@bufbuild/protobuf/wire";
import {
type CallOptions,
type ChannelCredentials,
Client,
type ClientOptions,
type ClientUnaryCall,
type handleUnaryCall,
makeGenericClientConstructor,
type Metadata,
type ServiceError,
type UntypedServiceImplementation,
} from "@grpc/grpc-js";
import Long from "long";
export interface ListModelsRequest {
}
export interface ProviderInfo {
discriminator: string;
capabilities: string;
endpoint: string;
model: string;
embeddingModel: string;
}
export interface ListModelsResponse {
active: string;
providers: ProviderInfo[];
}
function createBaseListModelsRequest(): ListModelsRequest {
return {};
}
export const ListModelsRequest: MessageFns<ListModelsRequest> = {
encode(_: ListModelsRequest, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
return writer;
},
decode(input: BinaryReader | Uint8Array, length?: number): ListModelsRequest {
const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
const end = length === undefined ? reader.len : reader.pos + length;
const message = createBaseListModelsRequest();
while (reader.pos < end) {
const tag = reader.uint32();
switch (tag >>> 3) {
}
if ((tag & 7) === 4 || tag === 0) {
break;
}
reader.skip(tag & 7);
}
return message;
},
fromJSON(_: any): ListModelsRequest {
return {};
},
toJSON(_: ListModelsRequest): unknown {
const obj: any = {};
return obj;
},
create<I extends Exact<DeepPartial<ListModelsRequest>, I>>(base?: I): ListModelsRequest {
return ListModelsRequest.fromPartial(base ?? ({} as any));
},
fromPartial<I extends Exact<DeepPartial<ListModelsRequest>, I>>(_: I): ListModelsRequest {
const message = createBaseListModelsRequest();
return message;
},
};
function createBaseProviderInfo(): ProviderInfo {
return { discriminator: "", capabilities: "", endpoint: "", model: "", embeddingModel: "" };
}
export const ProviderInfo: MessageFns<ProviderInfo> = {
encode(message: ProviderInfo, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
if (message.discriminator !== "") {
writer.uint32(10).string(message.discriminator);
}
if (message.capabilities !== "") {
writer.uint32(18).string(message.capabilities);
}
if (message.endpoint !== "") {
writer.uint32(26).string(message.endpoint);
}
if (message.model !== "") {
writer.uint32(34).string(message.model);
}
if (message.embeddingModel !== "") {
writer.uint32(42).string(message.embeddingModel);
}
return writer;
},
decode(input: BinaryReader | Uint8Array, length?: number): ProviderInfo {
const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
const end = length === undefined ? reader.len : reader.pos + length;
const message = createBaseProviderInfo();
while (reader.pos < end) {
const tag = reader.uint32();
switch (tag >>> 3) {
case 1: {
if (tag !== 10) {
break;
}
message.discriminator = reader.string();
continue;
}
case 2: {
if (tag !== 18) {
break;
}
message.capabilities = reader.string();
continue;
}
case 3: {
if (tag !== 26) {
break;
}
message.endpoint = reader.string();
continue;
}
case 4: {
if (tag !== 34) {
break;
}
message.model = reader.string();
continue;
}
case 5: {
if (tag !== 42) {
break;
}
message.embeddingModel = reader.string();
continue;
}
}
if ((tag & 7) === 4 || tag === 0) {
break;
}
reader.skip(tag & 7);
}
return message;
},
fromJSON(object: any): ProviderInfo {
return {
discriminator: isSet(object.discriminator) ? globalThis.String(object.discriminator) : "",
capabilities: isSet(object.capabilities) ? globalThis.String(object.capabilities) : "",
endpoint: isSet(object.endpoint) ? globalThis.String(object.endpoint) : "",
model: isSet(object.model) ? globalThis.String(object.model) : "",
embeddingModel: isSet(object.embeddingModel)
? globalThis.String(object.embeddingModel)
: isSet(object.embedding_model)
? globalThis.String(object.embedding_model)
: "",
};
},
toJSON(message: ProviderInfo): unknown {
const obj: any = {};
if (message.discriminator !== "") {
obj.discriminator = message.discriminator;
}
if (message.capabilities !== "") {
obj.capabilities = message.capabilities;
}
if (message.endpoint !== "") {
obj.endpoint = message.endpoint;
}
if (message.model !== "") {
obj.model = message.model;
}
if (message.embeddingModel !== "") {
obj.embeddingModel = message.embeddingModel;
}
return obj;
},
create<I extends Exact<DeepPartial<ProviderInfo>, I>>(base?: I): ProviderInfo {
return ProviderInfo.fromPartial(base ?? ({} as any));
},
fromPartial<I extends Exact<DeepPartial<ProviderInfo>, I>>(object: I): ProviderInfo {
const message = createBaseProviderInfo();
message.discriminator = object.discriminator ?? "";
message.capabilities = object.capabilities ?? "";
message.endpoint = object.endpoint ?? "";
message.model = object.model ?? "";
message.embeddingModel = object.embeddingModel ?? "";
return message;
},
};
function createBaseListModelsResponse(): ListModelsResponse {
return { active: "", providers: [] };
}
export const ListModelsResponse: MessageFns<ListModelsResponse> = {
encode(message: ListModelsResponse, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
if (message.active !== "") {
writer.uint32(10).string(message.active);
}
for (const v of message.providers) {
ProviderInfo.encode(v!, writer.uint32(18).fork()).join();
}
return writer;
},
decode(input: BinaryReader | Uint8Array, length?: number): ListModelsResponse {
const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
const end = length === undefined ? reader.len : reader.pos + length;
const message = createBaseListModelsResponse();
while (reader.pos < end) {
const tag = reader.uint32();
switch (tag >>> 3) {
case 1: {
if (tag !== 10) {
break;
}
message.active = reader.string();
continue;
}
case 2: {
if (tag !== 18) {
break;
}
message.providers.push(ProviderInfo.decode(reader, reader.uint32()));
continue;
}
}
if ((tag & 7) === 4 || tag === 0) {
break;
}
reader.skip(tag & 7);
}
return message;
},
fromJSON(object: any): ListModelsResponse {
return {
active: isSet(object.active) ? globalThis.String(object.active) : "",
providers: globalThis.Array.isArray(object?.providers)
? object.providers.map((e: any) => ProviderInfo.fromJSON(e))
: [],
};
},
toJSON(message: ListModelsResponse): unknown {
const obj: any = {};
if (message.active !== "") {
obj.active = message.active;
}
if (message.providers?.length) {
obj.providers = message.providers.map((e) => ProviderInfo.toJSON(e));
}
return obj;
},
create<I extends Exact<DeepPartial<ListModelsResponse>, I>>(base?: I): ListModelsResponse {
return ListModelsResponse.fromPartial(base ?? ({} as any));
},
fromPartial<I extends Exact<DeepPartial<ListModelsResponse>, I>>(object: I): ListModelsResponse {
const message = createBaseListModelsResponse();
message.active = object.active ?? "";
message.providers = object.providers?.map((e) => ProviderInfo.fromPartial(e)) || [];
return message;
},
};
export type ModelsServiceService = typeof ModelsServiceService;
export const ModelsServiceService = {
listModels: {
path: "/apf.ai.v1.ModelsService/ListModels" as const,
requestStream: false as const,
responseStream: false as const,
requestSerialize: (value: ListModelsRequest): Buffer => Buffer.from(ListModelsRequest.encode(value).finish()),
requestDeserialize: (value: Buffer): ListModelsRequest => ListModelsRequest.decode(value),
responseSerialize: (value: ListModelsResponse): Buffer => Buffer.from(ListModelsResponse.encode(value).finish()),
responseDeserialize: (value: Buffer): ListModelsResponse => ListModelsResponse.decode(value),
},
} as const;
export interface ModelsServiceServer extends UntypedServiceImplementation {
listModels: handleUnaryCall<ListModelsRequest, ListModelsResponse>;
}
export interface ModelsServiceClient extends Client {
listModels(
request: ListModelsRequest,
callback: (error: ServiceError | null, response: ListModelsResponse) => void,
): ClientUnaryCall;
listModels(
request: ListModelsRequest,
metadata: Metadata,
callback: (error: ServiceError | null, response: ListModelsResponse) => void,
): ClientUnaryCall;
listModels(
request: ListModelsRequest,
metadata: Metadata,
options: Partial<CallOptions>,
callback: (error: ServiceError | null, response: ListModelsResponse) => void,
): ClientUnaryCall;
}
export const ModelsServiceClient = makeGenericClientConstructor(
ModelsServiceService,
"apf.ai.v1.ModelsService",
) as unknown as {
new (address: string, credentials: ChannelCredentials, options?: Partial<ClientOptions>): ModelsServiceClient;
service: typeof ModelsServiceService;
serviceName: string;
};
type Builtin = Date | Function | Uint8Array | string | number | boolean | undefined;
type DeepPartial<T> = T extends Builtin ? T
: T extends Long ? string | number | Long : T extends globalThis.Array<infer U> ? globalThis.Array<DeepPartial<U>>
: T extends ReadonlyArray<infer U> ? ReadonlyArray<DeepPartial<U>>
: T extends {} ? { [K in keyof T]?: DeepPartial<T[K]> }
: Partial<T>;
type KeysOfUnion<T> = T extends T ? keyof T : never;
type Exact<P, I extends P> = P extends Builtin ? P
: P & { [K in keyof P]: Exact<P[K], I[K]> } & { [K in Exclude<keyof I, KeysOfUnion<P>>]: never };
function isSet(value: any): boolean {
return value !== null && value !== undefined;
}
interface MessageFns<T> {
encode(message: T, writer?: BinaryWriter): BinaryWriter;
decode(input: BinaryReader | Uint8Array, length?: number): T;
fromJSON(object: any): T;
toJSON(message: T): unknown;
create<I extends Exact<DeepPartial<T>, I>>(base?: I): T;
fromPartial<I extends Exact<DeepPartial<T>, I>>(object: I): T;
}
+544
View File
@@ -0,0 +1,544 @@
// Code generated by protoc-gen-ts_proto. DO NOT EDIT.
// versions:
// protoc-gen-ts_proto v2.11.8
// protoc v3.19.1
// source: rag.proto
/* eslint-disable */
import { BinaryReader, BinaryWriter } from "@bufbuild/protobuf/wire";
import {
type CallOptions,
type ChannelCredentials,
Client,
type ClientOptions,
type ClientUnaryCall,
type handleUnaryCall,
makeGenericClientConstructor,
type Metadata,
type ServiceError,
type UntypedServiceImplementation,
} from "@grpc/grpc-js";
import Long from "long";
import { Principal } from "./common";
import { Struct } from "./google/protobuf/struct";
export interface RagSearchFilters {
source: string;
documentId: string;
}
export interface RagSearchRequest {
query: string;
topK: number;
filters?: RagSearchFilters | undefined;
principal?: Principal | undefined;
}
export interface Chunk {
id: string;
documentId: string;
content: string;
source: string;
score: number;
metadata?: { [key: string]: any } | undefined;
}
export interface RagSearchResponse {
chunks: Chunk[];
correlationId: string;
}
function createBaseRagSearchFilters(): RagSearchFilters {
return { source: "", documentId: "" };
}
export const RagSearchFilters: MessageFns<RagSearchFilters> = {
encode(message: RagSearchFilters, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
if (message.source !== "") {
writer.uint32(10).string(message.source);
}
if (message.documentId !== "") {
writer.uint32(18).string(message.documentId);
}
return writer;
},
decode(input: BinaryReader | Uint8Array, length?: number): RagSearchFilters {
const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
const end = length === undefined ? reader.len : reader.pos + length;
const message = createBaseRagSearchFilters();
while (reader.pos < end) {
const tag = reader.uint32();
switch (tag >>> 3) {
case 1: {
if (tag !== 10) {
break;
}
message.source = reader.string();
continue;
}
case 2: {
if (tag !== 18) {
break;
}
message.documentId = reader.string();
continue;
}
}
if ((tag & 7) === 4 || tag === 0) {
break;
}
reader.skip(tag & 7);
}
return message;
},
fromJSON(object: any): RagSearchFilters {
return {
source: isSet(object.source) ? globalThis.String(object.source) : "",
documentId: isSet(object.documentId)
? globalThis.String(object.documentId)
: isSet(object.document_id)
? globalThis.String(object.document_id)
: "",
};
},
toJSON(message: RagSearchFilters): unknown {
const obj: any = {};
if (message.source !== "") {
obj.source = message.source;
}
if (message.documentId !== "") {
obj.documentId = message.documentId;
}
return obj;
},
create<I extends Exact<DeepPartial<RagSearchFilters>, I>>(base?: I): RagSearchFilters {
return RagSearchFilters.fromPartial(base ?? ({} as any));
},
fromPartial<I extends Exact<DeepPartial<RagSearchFilters>, I>>(object: I): RagSearchFilters {
const message = createBaseRagSearchFilters();
message.source = object.source ?? "";
message.documentId = object.documentId ?? "";
return message;
},
};
function createBaseRagSearchRequest(): RagSearchRequest {
return { query: "", topK: 0, filters: undefined, principal: undefined };
}
export const RagSearchRequest: MessageFns<RagSearchRequest> = {
encode(message: RagSearchRequest, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
if (message.query !== "") {
writer.uint32(10).string(message.query);
}
if (message.topK !== 0) {
writer.uint32(16).int32(message.topK);
}
if (message.filters !== undefined) {
RagSearchFilters.encode(message.filters, writer.uint32(26).fork()).join();
}
if (message.principal !== undefined) {
Principal.encode(message.principal, writer.uint32(34).fork()).join();
}
return writer;
},
decode(input: BinaryReader | Uint8Array, length?: number): RagSearchRequest {
const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
const end = length === undefined ? reader.len : reader.pos + length;
const message = createBaseRagSearchRequest();
while (reader.pos < end) {
const tag = reader.uint32();
switch (tag >>> 3) {
case 1: {
if (tag !== 10) {
break;
}
message.query = reader.string();
continue;
}
case 2: {
if (tag !== 16) {
break;
}
message.topK = reader.int32();
continue;
}
case 3: {
if (tag !== 26) {
break;
}
message.filters = RagSearchFilters.decode(reader, reader.uint32());
continue;
}
case 4: {
if (tag !== 34) {
break;
}
message.principal = Principal.decode(reader, reader.uint32());
continue;
}
}
if ((tag & 7) === 4 || tag === 0) {
break;
}
reader.skip(tag & 7);
}
return message;
},
fromJSON(object: any): RagSearchRequest {
return {
query: isSet(object.query) ? globalThis.String(object.query) : "",
topK: isSet(object.topK)
? globalThis.Number(object.topK)
: isSet(object.top_k)
? globalThis.Number(object.top_k)
: 0,
filters: isSet(object.filters) ? RagSearchFilters.fromJSON(object.filters) : undefined,
principal: isSet(object.principal) ? Principal.fromJSON(object.principal) : undefined,
};
},
toJSON(message: RagSearchRequest): unknown {
const obj: any = {};
if (message.query !== "") {
obj.query = message.query;
}
if (message.topK !== 0) {
obj.topK = Math.round(message.topK);
}
if (message.filters !== undefined) {
obj.filters = RagSearchFilters.toJSON(message.filters);
}
if (message.principal !== undefined) {
obj.principal = Principal.toJSON(message.principal);
}
return obj;
},
create<I extends Exact<DeepPartial<RagSearchRequest>, I>>(base?: I): RagSearchRequest {
return RagSearchRequest.fromPartial(base ?? ({} as any));
},
fromPartial<I extends Exact<DeepPartial<RagSearchRequest>, I>>(object: I): RagSearchRequest {
const message = createBaseRagSearchRequest();
message.query = object.query ?? "";
message.topK = object.topK ?? 0;
message.filters = (object.filters !== undefined && object.filters !== null)
? RagSearchFilters.fromPartial(object.filters)
: undefined;
message.principal = (object.principal !== undefined && object.principal !== null)
? Principal.fromPartial(object.principal)
: undefined;
return message;
},
};
function createBaseChunk(): Chunk {
return { id: "", documentId: "", content: "", source: "", score: 0, metadata: undefined };
}
export const Chunk: MessageFns<Chunk> = {
encode(message: Chunk, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
if (message.id !== "") {
writer.uint32(10).string(message.id);
}
if (message.documentId !== "") {
writer.uint32(18).string(message.documentId);
}
if (message.content !== "") {
writer.uint32(26).string(message.content);
}
if (message.source !== "") {
writer.uint32(34).string(message.source);
}
if (message.score !== 0) {
writer.uint32(41).double(message.score);
}
if (message.metadata !== undefined) {
Struct.encode(Struct.wrap(message.metadata), writer.uint32(50).fork()).join();
}
return writer;
},
decode(input: BinaryReader | Uint8Array, length?: number): Chunk {
const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
const end = length === undefined ? reader.len : reader.pos + length;
const message = createBaseChunk();
while (reader.pos < end) {
const tag = reader.uint32();
switch (tag >>> 3) {
case 1: {
if (tag !== 10) {
break;
}
message.id = reader.string();
continue;
}
case 2: {
if (tag !== 18) {
break;
}
message.documentId = reader.string();
continue;
}
case 3: {
if (tag !== 26) {
break;
}
message.content = reader.string();
continue;
}
case 4: {
if (tag !== 34) {
break;
}
message.source = reader.string();
continue;
}
case 5: {
if (tag !== 41) {
break;
}
message.score = reader.double();
continue;
}
case 6: {
if (tag !== 50) {
break;
}
message.metadata = Struct.unwrap(Struct.decode(reader, reader.uint32()));
continue;
}
}
if ((tag & 7) === 4 || tag === 0) {
break;
}
reader.skip(tag & 7);
}
return message;
},
fromJSON(object: any): Chunk {
return {
id: isSet(object.id) ? globalThis.String(object.id) : "",
documentId: isSet(object.documentId)
? globalThis.String(object.documentId)
: isSet(object.document_id)
? globalThis.String(object.document_id)
: "",
content: isSet(object.content) ? globalThis.String(object.content) : "",
source: isSet(object.source) ? globalThis.String(object.source) : "",
score: isSet(object.score) ? globalThis.Number(object.score) : 0,
metadata: isObject(object.metadata) ? object.metadata : undefined,
};
},
toJSON(message: Chunk): unknown {
const obj: any = {};
if (message.id !== "") {
obj.id = message.id;
}
if (message.documentId !== "") {
obj.documentId = message.documentId;
}
if (message.content !== "") {
obj.content = message.content;
}
if (message.source !== "") {
obj.source = message.source;
}
if (message.score !== 0) {
obj.score = message.score;
}
if (message.metadata !== undefined) {
obj.metadata = message.metadata;
}
return obj;
},
create<I extends Exact<DeepPartial<Chunk>, I>>(base?: I): Chunk {
return Chunk.fromPartial(base ?? ({} as any));
},
fromPartial<I extends Exact<DeepPartial<Chunk>, I>>(object: I): Chunk {
const message = createBaseChunk();
message.id = object.id ?? "";
message.documentId = object.documentId ?? "";
message.content = object.content ?? "";
message.source = object.source ?? "";
message.score = object.score ?? 0;
message.metadata = object.metadata ?? undefined;
return message;
},
};
function createBaseRagSearchResponse(): RagSearchResponse {
return { chunks: [], correlationId: "" };
}
export const RagSearchResponse: MessageFns<RagSearchResponse> = {
encode(message: RagSearchResponse, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
for (const v of message.chunks) {
Chunk.encode(v!, writer.uint32(10).fork()).join();
}
if (message.correlationId !== "") {
writer.uint32(18).string(message.correlationId);
}
return writer;
},
decode(input: BinaryReader | Uint8Array, length?: number): RagSearchResponse {
const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
const end = length === undefined ? reader.len : reader.pos + length;
const message = createBaseRagSearchResponse();
while (reader.pos < end) {
const tag = reader.uint32();
switch (tag >>> 3) {
case 1: {
if (tag !== 10) {
break;
}
message.chunks.push(Chunk.decode(reader, reader.uint32()));
continue;
}
case 2: {
if (tag !== 18) {
break;
}
message.correlationId = reader.string();
continue;
}
}
if ((tag & 7) === 4 || tag === 0) {
break;
}
reader.skip(tag & 7);
}
return message;
},
fromJSON(object: any): RagSearchResponse {
return {
chunks: globalThis.Array.isArray(object?.chunks) ? object.chunks.map((e: any) => Chunk.fromJSON(e)) : [],
correlationId: isSet(object.correlationId)
? globalThis.String(object.correlationId)
: isSet(object.correlation_id)
? globalThis.String(object.correlation_id)
: "",
};
},
toJSON(message: RagSearchResponse): unknown {
const obj: any = {};
if (message.chunks?.length) {
obj.chunks = message.chunks.map((e) => Chunk.toJSON(e));
}
if (message.correlationId !== "") {
obj.correlationId = message.correlationId;
}
return obj;
},
create<I extends Exact<DeepPartial<RagSearchResponse>, I>>(base?: I): RagSearchResponse {
return RagSearchResponse.fromPartial(base ?? ({} as any));
},
fromPartial<I extends Exact<DeepPartial<RagSearchResponse>, I>>(object: I): RagSearchResponse {
const message = createBaseRagSearchResponse();
message.chunks = object.chunks?.map((e) => Chunk.fromPartial(e)) || [];
message.correlationId = object.correlationId ?? "";
return message;
},
};
/**
* Stateless retrieval. The server runs a single SQL query that combines
* vector similarity with the principal's ACL clause — there is no
* retrieval path that bypasses the ACL filter.
*/
export type RagServiceService = typeof RagServiceService;
export const RagServiceService = {
search: {
path: "/apf.ai.v1.RagService/Search" as const,
requestStream: false as const,
responseStream: false as const,
requestSerialize: (value: RagSearchRequest): Buffer => Buffer.from(RagSearchRequest.encode(value).finish()),
requestDeserialize: (value: Buffer): RagSearchRequest => RagSearchRequest.decode(value),
responseSerialize: (value: RagSearchResponse): Buffer => Buffer.from(RagSearchResponse.encode(value).finish()),
responseDeserialize: (value: Buffer): RagSearchResponse => RagSearchResponse.decode(value),
},
} as const;
export interface RagServiceServer extends UntypedServiceImplementation {
search: handleUnaryCall<RagSearchRequest, RagSearchResponse>;
}
export interface RagServiceClient extends Client {
search(
request: RagSearchRequest,
callback: (error: ServiceError | null, response: RagSearchResponse) => void,
): ClientUnaryCall;
search(
request: RagSearchRequest,
metadata: Metadata,
callback: (error: ServiceError | null, response: RagSearchResponse) => void,
): ClientUnaryCall;
search(
request: RagSearchRequest,
metadata: Metadata,
options: Partial<CallOptions>,
callback: (error: ServiceError | null, response: RagSearchResponse) => void,
): ClientUnaryCall;
}
export const RagServiceClient = makeGenericClientConstructor(RagServiceService, "apf.ai.v1.RagService") as unknown as {
new (address: string, credentials: ChannelCredentials, options?: Partial<ClientOptions>): RagServiceClient;
service: typeof RagServiceService;
serviceName: string;
};
type Builtin = Date | Function | Uint8Array | string | number | boolean | undefined;
type DeepPartial<T> = T extends Builtin ? T
: T extends Long ? string | number | Long : T extends globalThis.Array<infer U> ? globalThis.Array<DeepPartial<U>>
: T extends ReadonlyArray<infer U> ? ReadonlyArray<DeepPartial<U>>
: T extends {} ? { [K in keyof T]?: DeepPartial<T[K]> }
: Partial<T>;
type KeysOfUnion<T> = T extends T ? keyof T : never;
type Exact<P, I extends P> = P extends Builtin ? P
: P & { [K in keyof P]: Exact<P[K], I[K]> } & { [K in Exclude<keyof I, KeysOfUnion<P>>]: never };
function isObject(value: any): boolean {
return typeof value === "object" && value !== null;
}
function isSet(value: any): boolean {
return value !== null && value !== undefined;
}
interface MessageFns<T> {
encode(message: T, writer?: BinaryWriter): BinaryWriter;
decode(input: BinaryReader | Uint8Array, length?: number): T;
fromJSON(object: any): T;
toJSON(message: T): unknown;
create<I extends Exact<DeepPartial<T>, I>>(base?: I): T;
fromPartial<I extends Exact<DeepPartial<T>, I>>(object: I): T;
}
@@ -0,0 +1,89 @@
syntax = "proto3";
package apf.ai.v1;
import "common.proto";
import "google/protobuf/struct.proto";
option csharp_namespace = "Apf.Ai.Grpc.V1";
// Streaming chat. Server returns a stream of ChatEvent; one event per
// model delta, citation, agent step, tool-call request, error, or done.
// Conversation state is NOT persisted — the caller sends the full
// message history every call. `conversation_id` exists only for audit
// correlation.
service ChatService {
rpc Chat(ChatRequest) returns (stream ChatEvent);
}
message RagOptions {
bool enabled = 1;
int32 top_k = 2; // 0 = use server default
}
message ChatRequest {
repeated ChatMessage messages = 1;
string conversation_id = 2;
string model = 3;
string provider = 4;
repeated ToolDescriptor tools_available = 5;
RagOptions rag = 6;
// Optional for JWT auth (overridden by JWT claims). Required for
// API-key auth — but API-key callers should use the HTTP surface.
Principal principal = 7;
}
message TokenEvent {
string token = 1;
string value = 2;
}
message CitationEvent {
string chunk_id = 1;
string document_id = 2;
string source = 3;
double score = 4;
string snippet = 5;
}
message AgentStepEvent {
string agent = 1;
string step = 2;
string step_id = 3;
}
message ToolCallEvent {
string call_id = 1;
string name = 2;
google.protobuf.Struct args = 3;
}
message ErrorEvent {
string code = 1;
string message = 2;
bool retriable = 3;
}
message DoneStats {
int32 tokens_in = 1;
int32 tokens_out = 2;
int32 chunks_retrieved = 3;
}
message DoneEvent {
DoneStats stats = 1;
}
// Polymorphic stream payload. Mirrors the SSE shapes from the OpenAPI
// surface (kept side-by-side for third-party callers). gRPC's `oneof`
// gives us a tagged union without the SSE JSON discriminator.
message ChatEvent {
oneof event {
TokenEvent token = 1;
CitationEvent citation = 2;
AgentStepEvent agent_step = 3;
ToolCallEvent tool_call = 4;
ErrorEvent error = 5;
DoneEvent done = 6;
}
}
@@ -0,0 +1,48 @@
syntax = "proto3";
package apf.ai.v1;
import "google/protobuf/struct.proto";
option csharp_namespace = "Apf.Ai.Grpc.V1";
// Identity-bearing principal shipped with every request (carried in the JWT for
// internal callers, or in the request body for API-key callers — same shape).
message Principal {
string subject = 1;
repeated string roles = 2;
map<string, string> attributes = 3;
}
// Default-deny ACL attached to every ingested chunk.
// At least one of allowed_roles or allowed_subjects must be non-empty,
// enforced at the controller, the DB CHECK constraint, and the post-filter.
message AclSpec {
repeated string allowed_roles = 1;
repeated string allowed_subjects = 2;
repeated string denied_roles = 3;
map<string, string> required_attributes = 4;
}
enum ChatRole {
CHAT_ROLE_UNSPECIFIED = 0;
CHAT_ROLE_SYSTEM = 1;
CHAT_ROLE_USER = 2;
CHAT_ROLE_ASSISTANT = 3;
CHAT_ROLE_TOOL = 4;
}
message ChatMessage {
ChatRole role = 1;
string content = 2;
string tool_call_id = 3;
string name = 4;
}
// JSON-schema descriptor for caller-side tool execution. The AI service is
// tool-blind: it emits ToolCall events; the caller executes and posts back.
message ToolDescriptor {
string name = 1;
string description = 2;
google.protobuf.Struct schema = 3;
}

Some files were not shown because too many files have changed in this diff Show More