Commit Graph

22 Commits

Author SHA1 Message Date
Julien Gautier 8dd235e44f feat(spa): proxy /api in dev-server, relative bffApiBaseUrl
CI / check (pull_request) Successful in 4m8s
CI / commits (pull_request) Successful in 4m12s
CI / scan (pull_request) Successful in 5m6s
CI / a11y (pull_request) Successful in 1m47s
CI / perf (pull_request) Successful in 6m21s
Make the SPAs reach the BFF same-origin via the Angular dev-server's
proxy, so the dockerised dev mode (ADR-0030) works when the SPA is
accessed from a remote browser (e.g. http://<vm-ip>:4200/) — and CORS
is bypassed in dev altogether.

- New proxy.conf.js per SPA: /api -> ${BFF_TARGET:-http://localhost:3000}
  (JS form so the env var can swap the target at startup without a
  rebuild).
- project.json serve.options.proxyConfig wired in for both apps.
- environment.ts (shell + admin) bffApiBaseUrl: 'http://localhost:3000/api'
  -> '/api'. Production siblings can still set an absolute origin if
  the SPA and BFF live on different hosts; tracing.ts resolves either
  form against window.location.origin.
- tracing.ts: new URL(env, window.location.origin) — relative bases no
  longer throw, absolute bases keep their own origin.
- dev.compose.yml: BFF_TARGET=http://portal-bff:3000 on portal-shell
  and portal-admin so the proxy hits the BFF container by Compose DNS.
  Native nx serve leaves it unset and falls back to localhost.

OTel exporter URL and cross-SPA links remain absolute — same remote-
browser issue, but not blocking the 'Backend unreachable' path this
PR targets. Out of scope here, follow-up if needed.

Stacked on top of feat/dockerised-dev-mode.
2026-06-01 11:16:05 +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 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 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 fe180fd125 feat(infra): local serve-static profile — Caddy reverse proxy for the prod build (#97)
CI / check (push) Successful in 2m29s
CI / commits (push) Has been skipped
CI / scan (push) Successful in 2m9s
CI / a11y (push) Successful in 1m35s
CI / perf (push) Successful in 4m16s
## Summary

Add a Caddy reverse proxy behind a new `--profile serve-static` so a contributor can exercise the production build locally with the per-locale routing the on-prem reverse proxy will use (per ADR-0019). Closes the gap surfaced by PR #96: the locale switcher / accessibility route fusion / cookie plumbing all need a prod-faithful local setup, and `nx serve-static` falls short (no SPA fallback per locale, no smart `/` redirect, exposes the `http-server` directory-listing footgun we hit during the perf-gate fix in PR #92).

## What lands

- **[`infra/local/Caddyfile`](infra/local/Caddyfile)** — explicit `route` block:
  - `GET /` → 302 to `/{locale}/` based on `Accept-Language`, falling back to `/fr/` (APF audience).
  - `/fr/*` → `dist/.../browser/fr/` with SPA fallback to `fr/index.html`.
  - `/en/*` → mirror.
  - Catch-all → 302 to `/fr/`.
- **[`infra/local/dev.compose.yml`](infra/local/dev.compose.yml)** — new `serve-static` service on the `serve-static` profile. Bind-mounts the Caddyfile and `dist/apps/portal-shell/browser/` read-only. Port 4200, overridable via `SERVE_STATIC_PORT`.
- **[`infra/local/.env.example`](infra/local/.env.example)** — adds `SERVE_STATIC_PORT=4200`.
- **[`infra/local/dev.sh`](infra/local/dev.sh)** — registers `serve-static` in `ALL_PROFILES` so `dev.sh down|status|logs` catches the new container, and `dev.sh up serve-static` works.
- **[`infra/README.md`](infra/README.md)** — file row, workflow snippet, cheat-sheet row, and a service-endpoint row with the `nx build … -c=production` prerequisite called out.

## Workflow

```
pnpm exec nx build portal-shell --configuration=production
./infra/local/dev.sh up serve-static
open http://localhost:4200/   # → /fr/ or /en/ per Accept-Language
```

## Decision worth flagging

Used **Caddy** rather than nginx or Traefik. Reason: minimal Caddyfile, single binary, no daemon config drift, sensible defaults (TLS off explicitly for local-only). Same family of choice as the rest of `infra/local/` — small, single-purpose images.

`redir` in a Caddyfile is **ambiguous** when the first arg starts with `/`: Caddy reads it as a path matcher rather than a redirect target. Using `redir * /fr/ 302` (explicit `*` matcher) avoids the gotcha. Documented inline in the Caddyfile via a comment block.

## What this PR explicitly does NOT do

- Wire TLS. Local convenience only, binds to `localhost`.
- Replace `nx run portal-shell:serve-static` (still used by Lighthouse CI in `ci:perf`).
- Set the `__Host-portal_locale` cookie or honour it for the smart redirect. Cookie handling needs the BFF route (ADR-0019 future PR).
- Land an on-prem reverse-proxy ADR. The on-prem infra ADR is phase 3b.

## Verified locally

| Probe | Expected | Actual |
|---|---|---|
| `GET / -H 'Accept-Language: fr'` | 302 `/fr/` | ✓ |
| `GET / -H 'Accept-Language: en'` | 302 `/en/` | ✓ |
| `GET /unknown` | 302 `/fr/` | ✓ |
| `GET /fr/deep/route` | 200 (SPA fallback to `fr/index.html`) | ✓ |
| `GET /fr/favicons/favicon.svg` | 200 (asset under locale folder) | ✓ |
| `/fr/index.html` markup | `lang="fr"`, `<base href="/fr/">` | ✓ |
| `/en/index.html` markup | `lang="en"`, `<base href="/en/">` | ✓ |

## Test plan

- [x] `docker compose -f infra/local/dev.compose.yml --profile serve-static config` validates clean.
- [x] `dev.sh up serve-static` brings the container up; `dev.sh status` lists it; `dev.sh stop serve-static` brings it down.
- [x] Routing probes above all pass.
- [ ] Manual: build + serve-static + click the locale switcher → URL becomes `/{other-locale}/`, the matching bundle boots, no console errors. (Verifies PR #95 + #96 end-to-end against a prod-faithful proxy.)
- [ ] Manual: `/fr/accessibilite` → router-level redirect to `/fr/accessibility` (verifies PR #94 under SPA fallback).
- [ ] Manual: `Accept-Language: en` in browser settings → root URL lands on `/en/`.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #97
2026-05-12 00:06:33 +02:00
julien a463199728 feat(infra): reactivate act_runner cache by sharing the runners network (#82)
CI / commits (push) Has been skipped
CI / check (push) Successful in 2m3s
CI / scan (push) Successful in 2m18s
CI / a11y (push) Successful in 1m0s
CI / perf (push) Successful in 3m10s
## Summary

Closes the deferred-since-day-one cache-server gap (documented as "Cache server (deferred)" in `infra/README.md` and mentioned every time we hit a slow CI install).

**Root cause.** `act_runner`'s built-in cache server binds inside the runner container and advertises an IP on the compose-defined `apf-portal-act-runners` bridge — but jobs are spawned via the mounted `/var/run/docker.sock`, which puts them on Docker's anonymous default `bridge`. The advertised URL is unreachable from the job, every cache request burns a ~2 min `ETIMEDOUT` (restore + save), the hit rate is zero.

**Fix.** Tell `act_runner` to attach jobs to the same compose-defined bridge as the runners, via `container.network` in the shared `runner-config.yaml`. The advertised cache URL becomes a normal internal-network DNS hop, jobs reach the cache server, `cache: 'pnpm'` works end-to-end.

**Blast-radius trade-off** (bounded). Every container on `apf-portal-act-runners` is one of our runner containers, plus the jobs they spawn — all of which already have full docker-socket access. Sharing a network doesn't widen what a malicious workflow can already do; it just lets jobs reach the cache server.

## What lands

- `infra/runner-config.yaml` — add `container.network: apf-portal-act-runners`. Surface the `cache.enabled: true` default explicitly so the toggle is discoverable.
- `.gitea/workflows/ci.yml` — re-enable `cache: 'pnpm'` on every `actions/setup-node` step (5 jobs). Drop the now-stale block comment that explained the disablement.
- `.gitea/workflows/security-scheduled.yml` — same on the two setup-node steps.
- `infra/README.md` "Cache server" section rewritten — was `"(deferred)"`, now describes the working setup, rationale, and the disable toggle.
- `ci.yml`'s Trivy comment trimmed to drop the cross-reference to the deferred-cache-server section that no longer exists.

## Roll-out (manual, post-merge, on the runner host)

```bash
cd <repo>/infra
git pull
./ci-runners.sh rotate
```

`rotate` recreates the containers with the new `runner-config.yaml` mount intact (rolling restart, ~15 s pause between each runner so the CI pipeline stays online).

## Test plan

- [ ] CI green on this PR (the gates run on the runners as configured **before** rollout, so this PR's run is one last "uncached" cycle).
- [ ] After rollout, the next CI run's `Set up Node.js` step shows the cache restore attempt **succeed quickly** (no ETIMEDOUT). The `Run pnpm install --frozen-lockfile` step on the first post-rollout run still reports `Progress: resolved N, reused 0, downloaded N` (cold seed).
- [ ] The **second** post-rollout run reports `reused N, downloaded 0` (or a small downloaded delta if Renovate moved a dep meanwhile) — the cache hit is real.
- [ ] `Complete job` step at the end no longer shows `reserveCache failed: connect ETIMEDOUT` warnings.
- [ ] Wall-clock for a typical PR's CI drops by ~5-10 min (5 jobs × ~30-90 s saved on `pnpm install` + the 2× ~2 min ETIMEDOUTs we used to eat).

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #82
2026-05-10 19:03:58 +02:00
julien 2d676cc279 feat(infra): add ci-runners.sh wrapper for the runner stack (#81)
CI / check (push) Successful in 1m53s
CI / commits (push) Has been skipped
CI / scan (push) Successful in 1m17s
CI / a11y (push) Successful in 1m3s
CI / perf (push) Successful in 2m27s
## Summary

Mirrors the convenience-script pattern we adopted for `infra/local/dev.sh`: typing `docker compose -f infra/ci-runners.compose.yml ...` for routine ops gets old fast, the pre-pull of the catthehacker job images is documented but easy to forget, and the "rotation of one runner at a time" tip in `infra/README.md` is a sequence the contributor was supposed to hand-roll every time.

`infra/ci-runners.sh` exposes the everyday verbs and automates the rolling-restart pattern.

## What lands

| Command                                       | Effect                                                                            |
| --------------------------------------------- | --------------------------------------------------------------------------------- |
| `./ci-runners.sh up`                          | Bring the three runner containers up                                              |
| `./ci-runners.sh up --prepull`                | Pre-pull the job images (`act-22.04` + `:full-22.04`) on the host first           |
| `./ci-runners.sh down`                        | Stop and remove the containers (**preserves** `data/runner-N/.runner` credentials) |
| `./ci-runners.sh restart <runner>`            | Restart one runner                                                                |
| `./ci-runners.sh rotate`                      | Rolling restart of every runner with a 15 s pause between each — keeps at least N-1 runners online through a config refresh |
| `./ci-runners.sh status`                      | `docker compose ps` for the runner services                                       |
| `./ci-runners.sh logs [runner]`               | Follow logs (one runner or all of them)                                           |
| `./ci-runners.sh pull-images`                 | Pre-pull / refresh the job images (idempotent)                                    |
| `./ci-runners.sh <other>`                     | Pass-through to `docker compose -f ci-runners.compose.yml ...`                    |

The destructive `down -v` (wipes `data/`, forces re-registration with a fresh Gitea token) is intentionally **not** exposed as a verb — invoke `docker compose -f ci-runners.compose.yml down -v` directly so the path is explicit at the typing level.

## Doc updates (`infra/README.md`)

- Inventory table at the top picks up the script.
- "First-time registration" walkthrough swaps the explicit `docker pull` / `docker compose up` steps for `./ci-runners.sh up --prepull`.
- New "Convenience script — `ci-runners.sh`" subsection with the cheat-sheet table.
- "Operational tips" rephrased to point at the script's `rotate` / `restart` / `logs` verbs as the canonical commands; the raw-docker-compose form is kept in parentheses as the underlying mechanism.
- "Adding a fourth runner" tip now reminds to update the `RUNNERS=()` array at the top of the script.

## Trade-off

The 15 s pause in `rotate` is a conservative approximation — `act_runner` doesn't expose a Compose healthcheck, so we can't poll for ready. Adjust the constant at the top of the script if reality argues for a different value.

## Test plan

- [ ] CI green on this PR.
- [ ] On the runner host: `./infra/ci-runners.sh status` shows the three runners running.
- [ ] `./infra/ci-runners.sh logs runner-1` tails runner-1's stdout.
- [ ] `./infra/ci-runners.sh rotate` cycles through runner-1 → runner-2 → runner-3 with the 15 s pauses; `status` between rotations shows N-1 runners online at any moment (with a brief gap for the one currently restarting).
- [ ] `./infra/ci-runners.sh restart runner-99` errors out with the "unknown runner" message.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #81
2026-05-10 06:37:07 +02:00
julien 8f2cd4e068 feat(portal-shell): wire spa-side opentelemetry tracing (#72)
CI / commits (push) Has been skipped
CI / scan (push) Successful in 1m15s
CI / a11y (push) Successful in 1m6s
CI / perf (push) Successful in 2m0s
CI / check (push) Successful in 43s
## Summary

Phase 2 of ADR-0012 — closes the loop SPA → BFF → DB. After this PR, a single user action (initial page load, click, form submit) produces one trace whose root span is owned by the SPA and whose child spans cover the BFF request, Postgres queries through Prisma, and (eventually) Redis / downstream-API hops.

## What lands

**Browser-side OTel libs** (production deps):

- `@opentelemetry/sdk-trace-web` — browser tracer + provider
- `@opentelemetry/exporter-trace-otlp-http` — OTLP/HTTP+JSON exporter
- `@opentelemetry/instrumentation` — auto-instrumentation runtime
- `@opentelemetry/instrumentation-fetch` — `fetch` + W3C `traceparent` propagation
- `@opentelemetry/instrumentation-document-load` — initial-paint timings
- `@opentelemetry/instrumentation-user-interaction` — click / keypress / submit

No `@opentelemetry/context-zone`: the workspace is zoneless per ADR-0004; the default `StackContextManager` covers the auto-instrumented paths. Custom spans across `await` will need explicit `context.with(...)` plumbing — fine, encountered as code lands.

**Code**:

- [`apps/portal-shell/src/observability/tracing.ts`](apps/portal-shell/src/observability/tracing.ts) — `WebTracerProvider` bootstrap. Documents the load-order constraint inline (same pattern as the BFF: must be the very first import of `main.ts`, otherwise auto-instrumentations miss everything imported above).
- `apps/portal-shell/src/main.ts` now imports the tracing module as line 1.

**CORS plumbing** for end-to-end trace propagation:

- BFF (`apps/portal-bff/src/main.ts`) calls `enableCors` with a minimal dev allowlist (`http://localhost:4200`) and explicit permission for the W3C `traceparent` / `tracestate` headers. The full security-grade CORS (per-environment allowlists, helmet, cookie-session, CSRF) belongs to the future phase-2 security ADR — this PR adds the strict minimum for the SPA→BFF trace context to survive cross-origin pre-flight.
- OTel Collector (`infra/local/otel-collector.yaml`) gains a `cors` block on its OTLP/HTTP receiver so the browser's own OTLP POST clears its pre-flight.

**ADR-0012 §Confirmation** rewritten: a new "Wired in the SPA foundation PR (phase 2)" block enumerates what landed here; the carry-over "Wired as features land" list drops the SPA-side SDK item and adds a follow-up note about the security-grade CORS.

## Verification

```bash
pnpm exec nx run-many -t lint test build           # 8 projects green
pnpm audit                                          # 0 vulns
./infra/local/dev.sh up observability               # bring up Collector + Jaeger
./infra/local/dev.sh                                # (separately, BFF stack — your choice)
pnpm nx serve portal-bff                           # localhost:3000
pnpm nx serve portal-shell                         # localhost:4200
```

Open http://localhost:4200 → a `document_load` trace appears in http://localhost:16686 with `service.name=portal-shell`. From DevTools, run `fetch('http://localhost:3000/api/health').then(r => r.json())` → a fetch span appears with a child BFF span on the same trace.

## Test plan

- [ ] CI green on this PR.
- [ ] After local up, `document_load` span visible in Jaeger UI for the SPA.
- [ ] Cross-origin fetch from SPA carries `traceparent` (visible in Network tab) and produces a single end-to-end trace SPA → BFF in Jaeger.
- [ ] DevTools console shows no CORS warnings about `traceparent`, `tracestate`, or the `localhost:4318/v1/traces` POST.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #72
2026-05-09 23:23:18 +02:00
julien b74d3f1b9b feat(portal-bff): observability foundations (Pino + CLS + OTel) (#70)
CI / commits (push) Has been skipped
CI / scan (push) Successful in 1m33s
CI / check (push) Successful in 2m3s
CI / a11y (push) Successful in 1m11s
CI / perf (push) Successful in 2m14s
## Summary

Implements ADR-0012 phase 1, BFF side. The SPA wiring is a separate phase-2 PR.

The BFF now emits structured JSON logs to stdout, tagged with `trace_id` / `span_id` from the active OTel context, and exports OTLP traces over HTTP/Protobuf to the Collector that already runs in the local-dev compose. Anything Nest, Express, HTTP-out, Prisma (Postgres) or `ioredis` does is auto-spanned. A `GET /api/health` liveness endpoint is added to round things out.

## What lands

**Runtime libs added** (production deps):

- `nestjs-pino`, `pino`, `pino-http` — structured logging
- `nestjs-cls` — request-scoped context
- `@opentelemetry/api` / `sdk-node` / `resources` / `semantic-conventions`
- `@opentelemetry/exporter-trace-otlp-proto` (HTTP/Protobuf, port 4318)
- `@opentelemetry/instrumentation-{http,express,nestjs-core,pg,ioredis,pino}` — curated, **no** `auto-instrumentations-node` mass-import (anti-bricolage)

Dev: `pino-pretty` (gated by `NODE_ENV`).

**Code:**

- `apps/portal-bff/src/observability/tracing.ts` — OTel `NodeSDK` bootstrap. Documents the load-order constraint inline (must be the very first import of `main.ts`). Pure side-effect module.
- `apps/portal-bff/src/observability/observability.module.ts` — composes `ClsModule` (UUID per request stored as `request_id`) and `LoggerModule` (`pino-pretty` in dev, raw JSON in prod, `LOG_LEVEL` env-driven, `/health` excluded from auto-logging, `X-Request-Id` honoured if inbound).
- `apps/portal-bff/src/health/{health.controller,health.module,health.controller.spec}.ts` — `GET /api/health` returning `{status, uptimeSeconds, service, version}`. Cheap liveness only — `/readiness` lands when dependencies have a readiness story.
- `apps/portal-bff/src/config/check-database-url.{ts,spec.ts}` — fail-fast validator called from `main.ts` before NestFactory boots. Catches the same family of bug that bit pgweb in #63: a literal special character in `POSTGRES_PASSWORD` that needs URL-encoding in `DATABASE_URL`. Prisma requires a URL string (no discrete-flag escape hatch), so early validation + a clear error message is the v1 mitigation. Six unit tests cover happy path, missing URL, wrong scheme, encoded special chars, literal `@` in password, malformed URL.

**Wiring:**

- `main.ts` imports `./observability/tracing` as line 1, then uses `app.get(Logger)` from `nestjs-pino` with `bufferLogs: true` so early-bootstrap lines are not lost.
- `app.module.ts` imports `ObservabilityModule` first, then `PrismaModule`, then `HealthModule`.
- `apps/portal-bff/.env.example` promotes `LOG_LEVEL`, `OTEL_SERVICE_NAME`, `OTEL_SERVICE_VERSION`, `OTEL_EXPORTER_OTLP_ENDPOINT`, `OTEL_EXPORTER_OTLP_PROTOCOL`, `OTEL_TRACES_SAMPLER` from the "future" comment to active settings — defaults target the local-dev Collector.
- Both `apps/portal-bff/.env.example` and `infra/local/.env.example` now spell out the URL-encoding constraint on `POSTGRES_PASSWORD` with the char-by-char encoding table (`@` → `%40`, etc.).

**ADR-0012 §Confirmation** rewritten to distinguish what landed in this PR from what is wired as the corresponding feature ADRs ship (CLS keys for `session_id` / `user_id_hash` / `audience`, `LOG_USER_ID_SALT` enforcement, redact list, custom spans, SPA-side SDK, full integration tests, prod Collector config).

## Trace ↔ log correlation

Automatic via `@opentelemetry/instrumentation-pino` — every Pino record gets `trace_id` and `span_id` injected from the active OTel context. No CLS gymnastics needed for that concern.

## Verification

```bash
pnpm exec nx run-many -t lint test build       # 8 projects green
pnpm audit --audit-level=moderate              # 0 vulnerabilities
./infra/local/dev.sh up observability          # start Collector + Jaeger
cp apps/portal-bff/.env.example apps/portal-bff/.env
pnpm nx serve portal-bff
curl http://localhost:3000/api/health
# → {"status":"ok","uptimeSeconds":N,"service":"portal-bff","version":"dev"}
```

Then hit `GET http://localhost:3000/api` once or twice and open http://localhost:16686 — the corresponding spans appear in Jaeger, and Pino logs on stdout carry the matching `trace_id`.

## Test plan

- [ ] `nx run-many -t lint test build` green on this PR's CI run.
- [ ] `pnpm audit` clean.
- [ ] BFF boots, `/api/health` returns the expected JSON.
- [ ] Pino logs in dev are colourised one-liners; in prod they would be raw JSON (toggled by `NODE_ENV=production`).
- [ ] With the local-dev stack's `--profile observability` active, traces are visible in Jaeger UI.
- [ ] Each Pino log line for a request carries the same `trace_id` as the trace span in Jaeger.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #70
2026-05-09 22:28:17 +02:00
julien fc9b63f24a feat(infra): add dev.sh wrapper for the local-dev compose stack (#68)
CI / check (push) Successful in 1m36s
CI / commits (push) Has been skipped
CI / scan (push) Successful in 2m9s
CI / a11y (push) Successful in 1m9s
CI / perf (push) Successful in 3m1s
## Summary

Two recurring frictions on the local-dev stack:

1. **Compose-profile asymmetry** — `docker compose down` only operates on services whose profile is currently active. Anything brought up with `--profile X` keeps running unless the same flag is passed on `down`. pgweb and Jaeger silently survived several `down -v` invocations before we noticed (#67 documented the gotcha; this PR makes it impossible to hit if you use the wrapper).
2. **Verbose invocations** — typing `docker compose -f infra/local/dev.compose.yml --profile … <verb>` for routine ops gets old fast.

Add [`infra/local/dev.sh`](infra/local/dev.sh) as a thin wrapper. Always passes every profile in scope on teardown / status / log commands, exposes ergonomic verbs:

| Command                                       | Effect                                                          |
| --------------------------------------------- | --------------------------------------------------------------- |
| `./infra/local/dev.sh up`                     | Core only (postgres + redis + otel-collector)                   |
| `./infra/local/dev.sh up all`                 | Core + every profile                                            |
| `./infra/local/dev.sh up dbtools`             | Core + pgweb                                                    |
| `./infra/local/dev.sh up observability`       | Core + Jaeger                                                   |
| `./infra/local/dev.sh down [-v]`              | Tear down (every profile in scope, no orphaned services)        |
| `./infra/local/dev.sh stop <service>`         | Stop one service (containers stay around)                       |
| `./infra/local/dev.sh restart <service>`      | Restart one service                                             |
| `./infra/local/dev.sh status`                 | `ps` with every profile visible                                 |
| `./infra/local/dev.sh logs [service]`         | Follow logs                                                     |
| `./infra/local/dev.sh exec <service> <cmd>`   | Run a command inside a container                                |

Anything not matching one of the named verbs is passed through to `docker compose -f dev.compose.yml ...` (with every profile flagged in), so the full Compose surface remains available.

## Doc updates

- **`infra/README.md`** — new "Convenience script" subsection with the cheat-sheet table; "First-time setup" rewritten to use the script; the standalone "Profile symmetry" tip from #67 is collapsed into a one-liner since the script now handles it (the note remains as a fallback for direct `docker compose` users).
- **`docs/development.md` §3** — points at the script for the typical setup flow.

The compose file itself is unchanged.

## Test plan

- [ ] `./infra/local/dev.sh help` prints the usage block.
- [ ] `./infra/local/dev.sh up` brings up the 3 core services (no pgweb / no jaeger).
- [ ] `./infra/local/dev.sh up all` adds pgweb and Jaeger.
- [ ] `./infra/local/dev.sh down -v` stops and removes all 5 containers (incl. pgweb and Jaeger), wipes the postgres-data and redis-data volumes.
- [ ] `./infra/local/dev.sh stop pgweb` stops just pgweb.
- [ ] `./infra/local/dev.sh logs otel-collector` follows that service's logs.
- [ ] `./infra/local/dev.sh exec postgres psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c "\du"` lists the audit roles.
- [ ] `./infra/local/dev.sh stop` (no arg) errors with a clear message.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #68
2026-05-09 21:50:37 +02:00
julien 4d2d4d652a docs(infra): document Compose profile + down/up symmetry gotcha (#67)
CI / commits (push) Has been skipped
CI / scan (push) Successful in 1m37s
CI / check (push) Successful in 1m38s
CI / a11y (push) Successful in 50s
CI / perf (push) Successful in 2m35s
## Summary

`docker compose -f infra/local/dev.compose.yml down -v` left pgweb and Jaeger running, with no error and no obvious diagnostic — they kept eating memory until the next reboot. Reason: Compose only operates on services whose profile is currently active. Bringing them up with `--profile dbtools` / `--profile observability` requires the same flag(s) on `down`, otherwise they're invisible to that command.

Document the gotcha in `infra/README.md` "Local-dev stack" → "Operational tips", with the two pragmatic resolutions:

1. Pass the same flags on each command (most explicit).
2. Set `COMPOSE_PROFILES=dbtools,observability` once in the shell or `infra/local/.env`, and let it propagate to every `up` / `down` / `ps` invocation.

## Test plan

- [ ] Bring up the full stack: `docker compose -f infra/local/dev.compose.yml --profile dbtools --profile observability up -d`.
- [ ] `docker compose -f infra/local/dev.compose.yml down -v` (no flags) — confirms pgweb + jaeger keep running.
- [ ] Run the documented "complete" command — confirms everything is gone.
- [ ] Repeat with `COMPOSE_PROFILES` set; same outcome.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #67
2026-05-09 21:40:28 +02:00
julien a893f8e06b chore(infra): migrate Jaeger to v2 for built-in dark theme (#64)
CI / check (push) Successful in 1m23s
CI / commits (push) Has been skipped
CI / scan (push) Successful in 57s
CI / a11y (push) Successful in 49s
CI / perf (push) Successful in 2m5s
## Summary

Jaeger v1's web UI has no theme selector — it ships light-only. v2 (the OTel-Collector-based rewrite, image `jaegertracing/jaeger`, distinct from v1's `jaegertracing/all-in-one`) ships a **Light / Dark / Auto** switcher in the UI nav. v2 is also the actively-developed line; v1 is on the way out within ~6-12 months.

Migration is mechanical:

- Image: `jaegertracing/all-in-one:1.76.0` → `jaegertracing/jaeger:2.17.0`.
- Drop `COLLECTOR_OTLP_ENABLED: 'true'` env — v2 enables OTLP receivers by default.
- UI port (`16686`) and OTLP ports (`4317` / `4318`) are unchanged, so the collector → `jaeger:4317` forwarding pipeline keeps working without touching `otel-collector.yaml`.

## Test plan

- [ ] After merge: `docker compose -f infra/local/dev.compose.yml --profile observability up -d --force-recreate jaeger` → container stays healthy.
- [ ] http://localhost:16686 loads the v2 UI; nav shows the Light/Dark/Auto switcher.
- [ ] Send a synthetic OTLP trace to `localhost:4317` (e.g., via grpcurl or once the BFF is wired in B) → it appears in the Jaeger UI.
- [ ] OTel Collector logs no longer warn abou

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #64
2026-05-08 23:31:16 +02:00
julien 171f21b99b fix(infra): pass pgweb credentials via discrete CLI flags (#63)
CI / check (push) Successful in 1m30s
CI / commits (push) Has been skipped
CI / scan (push) Successful in 1m37s
CI / a11y (push) Successful in 42s
CI / perf (push) Successful in 2m18s
## Summary

`PGWEB_DATABASE_URL` (post-#62 rename) still fails at boot:

```
Error: Invalid URL. Valid format: postgres://user:password@host:port/db?sslmode=mode
```

Root cause: the userinfo portion of a Postgres URL must be URL-encoded — any `@`, `#`, `:`, `/`, `?`, `%`, `&`, `=`, `+`, `;` etc. in the password breaks the parser. Compose has no built-in URL encoding, so the URL we construct in YAML is fragile by design and depends on the developer happening to pick a URL-safe password.

Switch to pgweb's discrete CLI flags (`--host`, `--port`, `--user`, `--pass`, `--db`, `--ssl`). Compose interpolates each value literally — no URL encoding required, any password works.

The image's ENTRYPOINT already passes `--bind=0.0.0.0 --listen=8081`; our args are appended to those.

## Side benefit

A missing password now yields an explicit Compose error (`POSTGRES_PASSWORD must be set in infra/local/.env`) rather than an opaque pgweb crash with a vague "Invalid URL" message.

## Test plan

- [ ] After merge: `docker compose -f infra/local/dev.compose.yml --profile dbtools up -d` → pgweb stays up (no `Restarting (1)` loop).
- [ ] http://localhost:8081 loads pgweb's UI; you can navigate to the `audit` schema.
- [ ] Confirm with a password that contains a special char (e.g., `dev@pass#2026!`) — should still work.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #63
2026-05-08 23:13:17 +02:00
julien 10f8565957 fix(infra): rename pgweb DATABASE_URL to PGWEB_DATABASE_URL (#62)
CI / check (push) Successful in 1m29s
CI / commits (push) Has been skipped
CI / scan (push) Successful in 1m26s
CI / a11y (push) Successful in 48s
CI / perf (push) Successful in 2m23s
## Summary

pgweb 0.16 deprecated `DATABASE_URL` in favour of `PGWEB_DATABASE_URL`. With the old name, the container starts up, emits a `[DEPRECATION]` warning, then crashes:

```
[DEPRECATION] Usage of DATABASE_URL env var is deprecated, please use PGWEB_DATABASE_URL variable instead
Pgweb v0.16.2 ...
Error: Invalid URL. Valid format: postgres://user:password@host:port/db?sslmode=mode
```

— because the new code path reads `PGWEB_DATABASE_URL` (empty) and the URL parser rejects an empty string.

Rename the env key in the compose file. Same value, just the new name.

## Test plan

- [ ] After merge: `docker compose -f infra/local/dev.compose.yml --profile dbtools up -d` → pgweb stays running (not in `Restarting` loop).
- [ ] http://localhost:8081 loads pgweb's UI; click through to the `audit` schema and the four `audit_*` roles to confirm DB connection.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #62
2026-05-08 22:58:21 +02:00
julien 6137486d64 fix(infra): grant audit roles to current_user, not hardcoded portal (#60)
CI / check (push) Successful in 1m32s
CI / commits (push) Has been skipped
CI / scan (push) Successful in 1m6s
CI / a11y (push) Successful in 53s
CI / perf (push) Successful in 2m13s
## Summary

The bootstrap SQL ended with:

```sql
GRANT audit_owner, audit_writer, audit_reader, audit_archiver TO portal;
```

— hard-coded `portal`. The compose file and `.env.example` both document `POSTGRES_USER` as overridable; any contributor who changed it hit:

```
ERROR: role "portal" does not exist
psql: /docker-entrypoint-initdb.d/01-init.sql:48: ERROR: role "portal" does not exist
```

Replace with `current_user`, which resolves at execution time to whoever is running the init SQL — i.e. the superuser Postgres just created from `POSTGRES_USER`, whatever its name.

## Recovery for anyone hit by the bug

The half-failed init left the postgres-data volume in a partially-initialised state. To reset:

```bash
cd infra/local
docker compose -f dev.compose.yml down -v   # wipes the volume
docker compose -f dev.compose.yml up -d     # bootstrap re-runs cleanly
```

## Test plan

- [ ] After merge + recovery: `docker compose ps` shows postgres healthy.
- [ ] `psql postgres://<user>:<pwd>@localhost:5432/portal_dev -c "\du"` lists the four `audit_*` roles, and your superuser is "Member of: {audit_owner, audit_writer, audit_reader, audit_archiver}".
- [ ] `psql ... -c "\dn"` shows the `audit` schema.
- [ ] Test with a non-default `POSTGRES_USER` value (set `POSTGRES_USER=apf_portal` in `.env`, wipe volume, re-up) — init still succeeds.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #60
2026-05-08 21:44:05 +02:00
julien d5c5c45175 fix(infra): correct OTel collector image tag (#59)
CI / commits (push) Has been skipped
CI / check (push) Successful in 1m1s
CI / scan (push) Successful in 1m9s
CI / a11y (push) Successful in 39s
CI / perf (push) Successful in 1m58s
## Summary
`otel/opentelemetry-collector-contrib:0.115.0` doesn't exist — release 0.115 was published as `0.115.1` only. Same memory-not-Docker-Hub mistake as Jaeger in #58.

```
Error response from daemon: failed to resolve reference
"docker.io/otel/opentelemetry-collector-contrib:0.115.0": not found
```

Pin to `0.150.1` (latest stable). The collector's stable-feature backward-compat covers our `otel-collector.yaml` (otlp receiver, batch processor, debug exporter, otlp/jaeger exporter) — no config change needed.

Postgres `17.2-alpine`, Redis `7.4-alpine`, pgweb `0.16.2` were verified against Docker Hub at the same time; they're all correct.

## Test plan
- [ ] `cd infra/local && docker compose -f dev.compose.yml up -d` — all 3 core services pull and start healthy.
- [ ] `--profile observability up -d` adds Jaeger 1.76.0 (after #58).
- [ ] `--profile dbtools up -d` adds pgweb 0.16.2.
- [ ] `docker compose ps` shows everything healthy.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #59
2026-05-08 20:41:00 +02:00
julien f0372adaae fix(infra): correct Jaeger image tag (#58)
CI / check (push) Successful in 1m19s
CI / commits (push) Has been skipped
CI / scan (push) Successful in 1m21s
CI / a11y (push) Successful in 48s
CI / perf (push) Successful in 2m13s
## Summary
`jaegertracing/all-in-one:1.62` doesn't exist on Docker Hub — I picked it from memory in #57 without verification. Jaeger 1.x publishes only full-semver tags (1.X.Y), not rolling minor (`1.X`) tags.

Activating `--profile observability` errored at image-pull time:

```
Error response from daemon: failed to resolve reference
"docker.io/jaegertracing/all-in-one:1.62": not found
```

Pin to `1.76.0` (latest stable in the 1.x line, verified against Docker Hub).

## Test plan
- [ ] `cd infra/local && docker compose -f dev.compose.yml --profile observability up -d` → all images pull, all services healthy.
- [ ] http://localhost:16686 → Jaeger UI loads (empty until the BFF starts emitting traces, which lands in the upcoming **B — Observability foundations** PR).
- [ ] Renovate's docker-compose manager picks up future Jaeger 1.x bumps and surfaces them as PRs.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #58
2026-05-08 20:08:33 +02:00
julien 0f00d6d93f feat(infra): add local-dev Docker Compose stack (#57)
CI / commits (push) Has been skipped
CI / check (push) Successful in 1m12s
CI / scan (push) Successful in 1m20s
CI / a11y (push) Successful in 41s
CI / perf (push) Successful in 2m9s
## Summary
Bring up Postgres + Redis + OTel Collector in one command so contributors can run the BFF end-to-end without manually wiring each service. Replaces the throwaway `docker run postgres:17-alpine` one-liner that was in `docs/development.md` §3.

### What lands
- **`infra/local/dev.compose.yml`** — three core services (`postgres:17.2-alpine`, `redis:7.4-alpine`, `otel/opentelemetry-collector-contrib:0.115.0`) plus two viewers gated behind Compose profiles:
  - `--profile dbtools` → `sosedoff/pgweb:0.16.2` (Postgres GUI on port 8081)
  - `--profile observability` → `jaegertracing/all-in-one:1.62` (Jaeger UI on 16686)
  - All ports overridable via `.env`. State in named volumes. Healthchecks on data services.
- **`infra/local/.env.example`** — credentials + ports template. `POSTGRES_PASSWORD` and `REDIS_PASSWORD` are mandatory (compose refuses to boot without them); other keys default sensibly.
- **`infra/local/init/postgres/01-init.sql`** — bootstrap SQL per **ADR-0013**: `audit_owner` / `audit_writer` / `audit_reader` / `audit_archiver` roles + `audit` schema. Default privileges encode the append-only contract (INSERT to writer, SELECT to reader, DELETE to archiver, no UPDATE/TRUNCATE to anyone). Applied on first Postgres boot only; documented re-run procedure.
- **`infra/local/otel-collector.yaml`** — pipeline: OTLP gRPC/HTTP → batch → debug exporter (always) + forward to `jaeger:4317`. When the observability profile is off, the Jaeger export logs warn-level retries but doesn't block the debug pipeline.

### Surrounding doc updates
- **`infra/README.md`** — new "Local-dev stack" section: service inventory, port table, first-time setup walkthrough, persistence/bootstrap-replay tips. The previous `local/` placeholder line is removed.
- **`docs/development.md`** §3 — rewritten to walk through the compose-based setup; cross-links to `infra/README.md` for the full reference. Roadmap entry for "Local infra recipe" removed from §8 (now implemented); "Observability dev-loop" line adjusted to point at the new Jaeger profile.

### Out of scope
- **Production parity** — HA Postgres, Redis Sentinel, real OTel backend (Tempo / Loki / etc.) — defer to the on-prem infrastructure ADR (phase 3b). The dev-only nature of this stack is called out explicitly in `infra/README.md`.
- **Wiring the BFF** to actually use these endpoints (NestJS config, Prisma datasource URL, OTel SDK init) — that's the **B — Observability foundations** chantier, next up.

## Test plan
- [ ] `cd infra/local && cp .env.example .env && docker compose -f dev.compose.yml up -d` → all three core services come up healthy; verify with `docker compose ps`.
- [ ] `psql postgres://portal:<pwd>@localhost:5432/portal_dev -c "\dn"` shows the `audit` schema; `\dg` shows the four audit roles.
- [ ] `redis-cli -a <pwd> PING` → `PONG`.
- [ ] Send a fake OTLP trace via grpcurl → see it printed by `docker compose logs otel-collector`.
- [ ] `--profile dbtools up -d` → http://localhost:8081 shows pgweb UI, can navigate to the audit schema.
- [ ] `--profile observability up -d` → http://localhost:16686 shows Jaeger UI; collector logs no longer report Jaeger export retries.
- [ ] `docker compose down -v` cleanly removes everything; next `up -d` re-runs the bootstrap SQL.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #57
2026-05-08 19:23:43 +02:00
julien efa660abab chore(infra): pin act_runner image pull policy (#10)
CI / check (push) Successful in 1m39s
CI / commits (push) Has been skipped
CI / scan (push) Failing after 52s
CI / a11y (push) Successful in 51s
CI / perf (push) Successful in 1m53s
## Summary
`act_runner`'s default `container.force_pull: true` re-issues a `docker pull` at the start of every job, adding 10–30 s of registry round-trip even when every layer is already locally cached. With job images pinned to specific tags (`catthehacker/ubuntu:act-22.04` and `:full-22.04`), the implicit pull is pure overhead and contradicts the deliberate-upgrade policy ADR-0015 spells out for the runner image.

- Add `infra/runner-config.yaml` with `container.force_pull: false`.
- Mount it read-only into all three runners and point each at it via `CONFIG_FILE=/etc/runner/config.yaml`.
- Document the pre-pull procedure and image-upgrade playbook in `infra/README.md` → "Job image pinning and pre-pull".
- Fold the pre-pull into the "First-time registration" walkthrough so a fresh setup is correct end-to-end.

The trade-off: the runner host must hold the images locally before the runner is asked to use them. Documented.

## Roll-out (manual, on the runner host)
```bash
cd infra/

# 1. Pre-pull the job images (one-shot — pays the cold cost once).
docker pull catthehacker/ubuntu:act-22.04
docker pull catthehacker/ubuntu:full-22.04

# 2. Recreate the runners so the new mount + env var take effect.
docker compose -f ci-runners.compose.yml up -d --force-recreate

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #10
2026-05-04 15:55:17 +02:00
julien f2440fbf24 fix(ci): disable broken pnpm cache on actions/setup-node (#8)
CI / a11y (push) Successful in 45s
CI / perf (push) Successful in 2m40s
CI / commits (push) Failing after 12m17s
CI / scan (push) Failing after 12m18s
CI / check (push) Failing after 12m19s
## Summary
`act_runner`'s built-in GitHub-Actions-cache server binds inside the runner container on the compose-defined `apf-portal-act-runners` bridge. Jobs spawned via the mounted Docker socket land on Docker's default `bridge` network and can't reach it. Every job opting into `cache: 'pnpm'` ate ~2 min `ETIMEDOUT` on restore + another ~2 min on save — across the 5 jobs, ~20 min wasted per CI run for zero cache hits.

Drop `cache: 'pnpm'` everywhere. `pnpm install --frozen-lockfile` is fast on the warm store inside the job container, so removing the cache layer is a net gain today.

The proper fix (cross-container networking / fixed-port cache binding) is documented in `infra/README.md` → "Cache server (deferred)" so it can be picked up as an isolated infra spike later.

## Test plan
- [ ] CI run on this PR: every job's `Set up Node.js` step finishes in seconds (no ETIMEDOUT warning).
- [ ] `Complete job` step also finishes promptly (no `reserveCache failed` warning).
- [ ] Total wall-clock time for the run should drop by ~15-20 min vs. previous runs.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #8
2026-05-04 15:33:04 +02:00
julien 02bdd43fa1 fix(ci): pin act_runner job image to catthehacker/ubuntu:act-22.04 (#4)
CI / commits (push) Has been skipped
CI / check (push) Failing after 3s
CI / scan (push) Failing after 9s
CI / a11y (push) Failing after 4s
CI / perf (push) Failing after 4s
## Summary

- Pin `act_runner`'s job container image to `catthehacker/ubuntu:act-22.04` via the `<label>:docker://<image>` format on the runner registration labels.
- Update `infra/ci-runners.compose.yml`'s `GITEA_RUNNER_LABELS` for all three runner services, with an inline comment explaining the format requirement.
- Amend ADR-0015 §"Runners" to specify the chosen image and explain the docker-suffix syntax trap.

## Motivation

The first real PR test of the CI pipeline failed at the very first step:

```
Run actions/checkout@v4
0s
Cannot find: node in PATH
 Failure - Main actions/checkout@v4
```

Root cause: `act_runner` registers labels (`self-hosted`, `on-prem`) without a `docker://` image suffix. Without that suffix, act spawns jobs in a minimal default container that has no Node. Every JavaScript action (`actions/checkout`, `actions/setup-node`, `nrwl/nx-set-shas`, the Trivy/gitleaks actions) crashes during the action's launch step. None of the five CI gates can run.

`catthehacker/ubuntu:act-22.04` is the de facto image used by `act` upstream and the standard recommendation for self-hosted Gitea Actions runners. It bundles Node, Python, git, common build tools, and the Docker CLI — exactly the assumed environment for GitHub Actions-compatible workflows.

## Implementation notes

- The fix lives in `GITEA_RUNNER_LABELS` because that's what `act_runner` reads at registration time. The label-to-image mapping is then persisted in the runner's `data/runner-N/.runner` credential.
- For runners **already registered** (i.e. the three currently-running `apf-portal-runner-N`), the persisted credential ignores the new env var. Their labels must be updated through Gitea's UI (Site Administration → Actions → Runners → each runner → edit `Labels`). This is documented in the commit message and is an operational follow-up to merging this PR.
- The compose-file change applies the next time a runner is re-registered (e.g. when `data/runner-N/` is wiped or a new fourth runner is added).
- ADR-0015 amendment is in-place, status remains `accepted`. The runner-image choice is an implementation detail under the existing decision; no new ADR.

## Verification

- [ ] `pnpm ci:check` — n/a, this PR only changes infra and ADR docs, no code paths.
- [ ] **Manual:** after merge + Gitea label updates, the next PR's `actions/checkout@v4` step runs without `Cannot find: node in PATH`.

## Related

- [ADR-0015 — CI/CD pipeline](docs/decisions/0015-cicd-gitea-actions.md). Amended in §"Runners".
- [`infra/README.md`](infra/README.md) — operational doc for the runners; mentions the registration workflow but predates this format requirement. A subsequent docs touch could mirror the new label format there too; deferred to keep this PR scoped to the actual fix.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #4
2026-05-04 11:06:56 +02:00
Julien Gautier 644d3603d2 chore: add docker compose for self-hosted Gitea act_runners
CI / check (pull_request) Failing after 6s
CI / commits (pull_request) Failing after 2s
CI / scan (pull_request) Failing after 6s
CI / a11y (pull_request) Failing after 2s
CI / perf (pull_request) Failing after 3s
Set up the infrastructure-as-code recipe to bring up the three
self-hosted Gitea Actions runners required by ADR-0015. The compose
file launches three act_runner instances pinned to 0.2.13, registered
with the project's Gitea organisation, labelled self-hosted + on-prem
to match the runs-on selector in every job under .gitea/workflows/*.

Layout:
- infra/                         new top-level folder for IaC
- infra/README.md                explains the folder, registration
                                 flow, security implications, future
                                 placeholders (local/, prod/, runbooks/)
- infra/ci-runners.compose.yml   three act_runner services, networked
                                 together, persisting credentials to
                                 ./data/runner-N
- infra/.env.example             GITEA_INSTANCE_URL +
                                 GITEA_RUNNER_REGISTRATION_TOKEN; .env
                                 itself stays git-ignored (root rule)
- infra/data/.gitignore          tracks the dir, ignores runtime state

Security posture (documented in infra/README.md): mounting
/var/run/docker.sock gives the runner root-equivalent access to the
host Docker daemon. Mitigations rely on (a) repo-scope of the runner
in Gitea, (b) running the runner host outside the production trust
boundary, (c) no extra host filesystem mounts. Future hardening
(rootless Docker, DinD sidecar) is flagged as deferred.

The compose pins the runner image (0.2.13). Bumps go through a
dedicated chore(deps) PR per the convention; image upgrades roll one
runner at a time so CI is never starved (procedure documented in the
README).

Doc indexing (docs/README.md) deliberately not touched here to avoid
a conflict with the pending docs/architecture-diagrams branch which
also modifies that file. A small follow-up PR will add an index entry
once that branch is merged.
2026-05-01 00:00:28 +02:00