Commit Graph

22 Commits

Author SHA1 Message Date
julien 2480d0dd6d fix(portal-bff): align admin entra role name with Portal.Admin (#145)
CI / check (push) Successful in 2m47s
CI / commits (push) Has been skipped
CI / scan (push) Successful in 1m47s
CI / a11y (push) Successful in 1m30s
CI / perf (push) Successful in 3m57s
## Summary

The [`AdminRoleGuard`](apps/portal-bff/src/admin/admin-role.guard.ts) was matching on the literal `'admin'`, but the Entra app registration declares the admin app role with `value: "Portal.Admin"`. End result: an authenticated user with the role assigned in Entra still landed with `roles: []` in their session (claim simply not present in the id token), and every request to `/api/admin/audit` and `/api/admin/users` returned a **403**.

Caught manually in the portal-admin SPA: login succeeded, sidebar links to "Audit log" / "User list" returned 403. The [`/api/admin/auth/me`](apps/portal-bff/src/admin/admin-auth.controller.ts) self-test confirmed the missing claim was the cause.

## What lands

### Constant value — single source of truth

[`apps/portal-bff/src/admin/admin-role.guard.ts:18`](apps/portal-bff/src/admin/admin-role.guard.ts#L18):

```diff
-export const ADMIN_ROLE = 'admin';
+export const ADMIN_ROLE = 'Portal.Admin';
```

[`admin-role.guard.spec.ts`](apps/portal-bff/src/admin/admin-role.guard.spec.ts) already imports `ADMIN_ROLE` from the source rather than hardcoding the literal, so the guard contract spec rolls through unchanged. The fixtures elsewhere ([`auth.service.spec.ts`](apps/portal-bff/src/auth/auth.service.spec.ts), [`admin.controller.spec.ts`](apps/portal-bff/src/admin/admin.controller.spec.ts), [`admin-auth.controller.spec.ts`](apps/portal-bff/src/admin/admin-auth.controller.spec.ts), [`require-mfa.guard.spec.ts`](apps/portal-bff/src/auth/require-mfa.guard.spec.ts)) keep `roles: ['admin']` as fixture data — those tests exercise the extraction / serialization pipeline, which is role-value-agnostic; touching them would be incidental cleanup with no behaviour signal.

### Doc-comment refresh

Inline references to the role name updated so future readers don't grep `'admin'` and find a phantom value:

- [`admin-role.guard.ts`](apps/portal-bff/src/admin/admin-role.guard.ts) — class doc-block (3 mentions).
- [`admin.controller.ts`](apps/portal-bff/src/admin/admin.controller.ts) — class doc-block + inline guard-contract comment.
- [`audit.service.ts`](apps/portal-bff/src/audit/audit.service.ts) — `adminAccessDenied` doc-block (2 mentions).

### Documentation

- [`docs/decisions/0020-portal-admin-app.md`](docs/decisions/0020-portal-admin-app.md) — 5 references to the role across §"How is admin access enforced", §"Auth — same Entra ID …", and the Consequences §.
- [`docs/architecture.md`](docs/architecture.md) — note next to the C4 container diagram describing the admin entry gate.
- [`CLAUDE.md`](CLAUDE.md) — "Admin application" project rule.

## Notes for the reviewer

- **Why `Portal.Admin` rather than `admin`?** Operator's call on the Entra side. The `<Application>.<Role>` namespace is the conventional Entra App Role pattern when the directory may host roles for multiple applications, and `admin` alone is ambiguous in a directory shared across products.
- **Why no migration / backfill?** The role value lives only in two places: Entra app-registration manifest (operator-managed) and the BFF constant (this PR). Existing Redis sessions captured `roles: []` (claim absent) — they'll naturally pick up the correct value on next sign-in. No persisted data references the old value.
- **No ADR.** ADR-0020 §"How is admin access enforced" already commits to "Entra ID role claim + BFF guard"; the literal role string is an implementation detail the ADR happened to spell. Updated the ADR's prose to the new value to keep the doc honest, but the decision is unchanged.

## Test plan

- [x] `pnpm nx test portal-bff` — **396 specs pass**, unchanged from `main`. The `AdminRoleGuard` contract spec (covers 401-on-no-session, 403-on-missing-role + audit emission, pass-through-on-role-present) imports `ADMIN_ROLE` and re-exercises with the new value.
- [x] `pnpm exec nx affected -t format:check lint test build --base=origin/main` — clean.
- [ ] Manual verification — pending Entra-side App Role declaration with `value: "Portal.Admin"` + assignment to the test user. Once both exist: sign out + sign in on portal-admin, hit `/api/admin/auth/me` and confirm `roles: ["Portal.Admin"]`, then click "Audit log" + "User list" and confirm both render. An `admin.access_denied` row in `audit.events` is the negative-test signal (still emitted for any user without the role).

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #145
2026-05-15 10:33:54 +02:00
julien 6a6bf90e7f docs: refresh CLAUDE.md "Repository status" section (#125)
CI / check (push) Successful in 2m18s
CI / commits (push) Has been skipped
CI / scan (push) Successful in 2m19s
CI / a11y (push) Successful in 1m5s
CI / perf (push) Successful in 3m56s
## Summary

The "Repository status" section in [CLAUDE.md](CLAUDE.md) was frozen at the pre-bootstrap state. It claimed the Nx workspace was "not yet bootstrapped", referenced ADRs only up to 0020, told future contributors that "the next step is to scaffold the workspace", and instructed agents to verify workspace existence before running build/test/run commands — all obsolete since phase-1 was shipped.

This PR rewrites the section to match reality:

- The workspace is **operational**: three apps (`portal-shell`, `portal-admin`, `portal-bff`) and 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.
- Lists what's **shipped on `main`** — phase-1 foundation, phase-2 auth + audit + security (with the right ADR citations).
- Lists what's **still on the roadmap** — `DownstreamApiClient` + OBO (ADR-0014, no v1 consumer yet), `@RequireMfa()` / `@RequireAdmin()` guards (designed-in but no consumer).
- **Disambiguates** the two distinct security ADRs: [ADR-0021](docs/decisions/0021-phase-2-security-baseline.md) is the implementation-level baseline (helmet, CORS, CSRF, rate-limit, error envelope) — **accepted**. The strategic security baseline ADR (OWASP ASVS reference level, HDS / GDPR / NIS 2 framing, RSSI sign-off) remains **paused** — when it lands it will either confirm 0021 or supersede pieces of it.

No code or behavior changes. Documentation only.

## Why this is its own PR

The stale paragraph has been there since ADR-0021 was being written; both ADR-0021 (in its PR description) and `notes/handoff.md` flagged it for a dedicated docs PR rather than piggybacking on a code change. Keeping it isolated makes the diff trivially reviewable and avoids mixing documentation refreshes with implementation changes.

## Test plan

- [x] `pnpm exec prettier --check CLAUDE.md` — clean.
- [x] Section content cross-referenced with `ls apps/`, `ls libs/feature libs/shared`, `ls docs/decisions/` — workspace structure, lib layout, and ADR count match the rewritten claims.
- [x] All ADR links in the rewritten section resolve (0011, 0014, 0020, 0021).
- [x] Conventional Commits — `docs:` type.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #125
2026-05-14 00:12:46 +02:00
julien d962be838a feat(portal-admin): skeleton app per ADR-0020 (#100)
CI / check (push) Successful in 2m49s
CI / commits (push) Has been skipped
CI / scan (push) Successful in 2m32s
CI / a11y (push) Successful in 1m26s
CI / perf (push) Successful in 4m17s
## Summary

First step of the admin track per ADR-0020. Scaffold the second Angular SPA in the workspace alongside `portal-shell`, with the architectural decisions from the v1 ADRs already wired so subsequent feature PRs can drop straight into modules.

## What lands

- **App generated** via `nx g @nx/angular:application` (with the matching e2e project skeleton). Standalone, zoneless-ready, prefix `app`, scss component styles, css root stylesheet, no SSR.
- **Tags** `scope:portal-admin, type:app` — module-boundary lint now treats `portal-admin` distinctly from `portal-shell` and lets it depend only on `scope:portal-admin` + `scope:shared` libs.
- **Tailwind v4 + brand tokens**: `.postcssrc.json`, `@import 'tailwindcss'`, class-based dark variant, and `@import '../../../libs/shared/tokens/src/brand-tokens.css'` — identical visual baseline to portal-shell.
- **i18n config mirrors portal-shell** (per ADR-0019): sourceLocale `en`, target `fr`, baseHref `/{locale}/` per locale, `@angular/localize/init` polyfill, `localize: ["en", "fr"]` on production, `i18nMissingTranslation: "error"` so CI blocks any PR that adds a marker without translating it. Seed [`messages.fr.xlf`](apps/portal-admin/src/locale/messages.fr.xlf) carries the four marked strings used today.
- **Budgets** relaxed to **500 KB gzip initial** per ADR-0020 §"Performance budgets" (vs 300 KB for portal-shell).
- **Skeleton home page** at `/` — `<h1>` + intro paragraph + "Skeleton — coming soon" chip in the brand-accent palette. All marked for i18n.
- **Skip-link landmark** (WCAG 2.4.1) with the same component-scoped scss as portal-shell.
- **Wildcard catch-all route** → bounces unknown paths to home (same defensive pattern as PR #96 on portal-shell).
- **Dev server on port 4300** (vs 4200 for portal-shell) — both can run in parallel.
- **`serve-static` target without `spa: true`** — locale-prefixed routing in production is handled by the reverse proxy (per ADR-0019), same as portal-shell since PR #92.

## CLAUDE.md

Picked up the second app in the **Naming** entry and the **Commands** snippet (`<app>` is now one of `portal-shell`, `portal-admin`, `portal-bff`).

## Verification

- `pnpm exec nx run-many -t lint test build --projects=portal-shell,portal-admin,shared-ui,shared-state,shared-tokens` — green.
- `portal-admin` test: 1 spec (skip-link + main landmark present).
- Production build of `portal-admin`: **62 KB gzip initial per locale** (vs 500 KB budget — plenty of headroom for the upcoming modules).
- Both `dist/apps/portal-admin/browser/{en,fr}/` emit with the right `<html lang>` and `<base href>`. FR bundle contains `"Administration"` / `"Squelette"` / `"bientôt disponible"` — translations applied.
- portal-shell production build unchanged.

## Out of scope (each its own follow-up PR)

- Admin app shell (header / sidebar / footer with "Admin" badge — likely sharing graduated primitives plus admin-specific bits).
- OpenTelemetry tracing setup (`service.name=portal-admin` per ADR-0012).
- `environment.ts` wiring (per ADR-0018) for admin-specific endpoints.
- BFF `AdminModule` + `AdminRoleGuard` + smoke `/api/admin/me` (per ADR-0020 §"Auth").
- First functional module — CMS / menu / users / audit viewer.

## Test plan

- [x] Lint + test + build green across the workspace.
- [x] Per-locale production build emits both folders with correct metadata.
- [ ] Manual: `pnpm exec nx serve portal-admin` boots on `:4300`, smoke page renders.
- [ ] Manual: prod build + `pnpm exec nx run portal-admin:serve-static` → `http://localhost:4300/en/` and `/fr/` show the placeholder with the matching language.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #100
2026-05-12 01:49:19 +02:00
julien c5e91f240b docs(decisions): add ADR-0019 i18n + ADR-0020 portal-admin (#89)
CI / commits (push) Has been skipped
CI / scan (push) Successful in 2m22s
CI / check (push) Successful in 2m36s
CI / a11y (push) Successful in 53s
CI / perf (push) Successful in 3m25s
## Summary

Pure documentation PR. Two ADRs that answer the two strategic questions raised after the footer chantier:

- **[ADR-0019](docs/decisions/0019-internationalisation-angular-localize.md)** — how the portal handles multiple languages.
- **[ADR-0020](docs/decisions/0020-portal-admin-app.md)** — where portal administration lives.

Implementation will land across follow-up feature PRs, each consumable on its own.

## ADR-0019 — Internationalisation

**Decision:** `@angular/localize` in **build-time** mode, two locales (`fr` default served at `/`, `en` source). Path-based URLs always prefixed (`/fr/...`, `/en/...`); `/` smart-redirects via cookie → `Accept-Language` → `fr`. The locale switcher in the footer writes a `__Host-portal_locale` cookie and hard-refreshes to the matching bundle.

**Considered and rejected:**

- `@angular/localize` runtime mode (single bundle, higher LCP / payload cost).
- `@ngx-translate` / `transloco` (community libraries; tech-bar prefers Angular first-party for foundational primitives).
- Query-param URL strategy (fragile, weaker SEO, `<html lang>` becomes harder).
- Subdomain URL strategy (breaks `__Host-` cookie scoping from ADR-0010).

**Scope boundary:** UI strings owned by developers (templates + `$localize` in code). Editorial content (CMS-managed pages, news, etc.) is BFF-served already localised — that's the admin-app pipeline (ADR-0020), not `@angular/localize`.

**First sweep consequence:** the duplicate `/accessibility` + `/accessibilite` routes collapse to one Angular route with locale-translated paths.

## ADR-0020 — `portal-admin`

**Decision:** new Angular SPA `portal-admin` alongside `portal-shell`, sharing the existing `portal-bff` via `/api/admin/*` routes guarded by an Entra `admin` role plus `@RequireMfa({ freshness: 600 })` at the entry route. Distinct origin + cookie + session (`__Host-portal_admin_session`).

**v1 modules** (all four selected):

1. Editorial pages CMS (multilingual content, fed back to `portal-shell` via the BFF).
2. Sidebar menu management (activates the `requiredPermissions` field already on `MenuItem`).
3. User list (read-only).
4. Audit log viewer (consumes the `audit.events` table per ADR-0013, via the `audit_reader` role).

**Out of v1:** B2B invitations (stay in Entra Admin Center), feature flags (no substrate yet), CMS workflow / approval flows, theme customisation, live preview.

**Considered and rejected:**

- `/admin/*` lazy-loaded inside `portal-shell` (admin code in the same origin → weaker defense in depth, admin URL not IP-restrictable independently).
- Two SPAs **and** two BFFs (doubles infra at our scale — bricolage).
- Off-the-shelf admin tooling (Retool, etc. — escapes our security baseline).

**Performance budget for admin:** ≤ 500 KB gzip initial (vs 300 KB for `portal-shell`, per ADR-0017). Lighthouse Performance ≥ 85 on critical admin routes (vs ≥ 90 on `portal-shell`). Same a11y baseline (ADR-0016), same dark-mode support.

**Shared-libs graduation:** `Icon`, `LayoutStateService`, brand tokens, dark-mode SCSS helpers move from `portal-shell` to `libs/shared/{ui,state}` when both apps need them. Mechanical refactor; tracked as the first implementation PR.

## Implementation roadmap (out of scope of this PR)

ADR-0019:

1. Install `@angular/localize`, wire build target.
2. Mark every existing UI string in `portal-shell` with `i18n` + `@@id`; produce `messages.fr.xlf`.
3. Locale switcher in footer + `/api/preferences/locale` BFF route + smart redirect at `/`.
4. Collapse the duplicate accessibility routes into a localised single route, with 301s.
5. CI gate: `nx build portal-shell --localize` is added to `ci:check` and fails on missing translation.

ADR-0020:

1. `nx g @nx/angular:app portal-admin` skeleton.
2. Shared-libs extraction (`libs/shared/ui`, `libs/shared/state`).
3. BFF `AdminModule` + `AdminRoleGuard` + smoke `GET /api/admin/me`.
4. Admin shell (header / sidebar / footer with an "Admin" badge).
5. One PR per v1 module — suggested order: CMS pages → menu management → audit viewer → user list.

## Test plan

- [x] Both ADRs follow MADR 4.0.0 (frontmatter, sections, tags from the canonical vocabulary).
- [x] `docs/decisions/README.md` index updated in the same commit.
- [x] `CLAUDE.md` architecture summary picks up entries for both decisions and bumps the ADR coverage line to 0020.
- [ ] Read-through review — invite the project lead to push back on any decision before locking implementation.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #89
2026-05-11 12:29:54 +02:00
julien 4476cbb518 docs(decisions): add ADR-0018 — environment configuration strategy (#80)
CI / check (push) Successful in 1m36s
CI / commits (push) Has been skipped
CI / scan (push) Successful in 2m2s
CI / a11y (push) Successful in 1m36s
CI / perf (push) Successful in 3m33s
## Summary

Records the per-environment config strategy for both apps. ADR-only — no code changes; the implementation is anchored in §Confirmation against the next feature work that touches per-environment values.

## What lands

[**ADR-0018 — Environment configuration**](docs/decisions/0018-environment-configuration-strategy.md):

- **SPA**: Angular `environment.ts` + project.json `fileReplacements`. Build-time substitution; no runtime config fetch (rejected for the LCP/TTFB cost it would add and the deploy-time HTML rewrite that the alternatives need). Concretely cleans up the hard-coded URLs we left in `observability/tracing.ts` and `home-status.service.ts` ("hard-coded for v1 — env-config when it lands" comments).
- **BFF**: keep `process.env` + small per-key boot-time validators (the shape `apps/portal-bff/src/config/check-database-url.ts` already follows). `@nestjs/config` rejected as too heavy for the current key count; `zod` not justified yet.
- **Audit log connection**: formalises the `AUDIT_DATABASE_URL` split that ADR-0013 §"Wired as features land" already pointed at. When set, the `AuditModule` instantiates a second Prisma client with `audit_writer`-only credentials (defense in depth); when unset, the dev fallback (shared pool + `SET LOCAL ROLE audit_writer` per transaction) keeps working. A boot-time `UPDATE`-rejection self-test runs against the dedicated pool when configured — refuses to start if the pool can mutate the audit table.

The §Confirmation block cross-references the env-var-as-boot-gate items already pre-figured in ADR-0009 / ADR-0010 / ADR-0012 / ADR-0013 / ADR-0014, so the validator pattern is the single landing place when those features ship.

## What does NOT change in this PR

- No `apps/portal-shell/src/environments/*.ts` files yet — landed alongside the next feature that actually needs per-environment values.
- No `AUDIT_DATABASE_URL` validator in BFF — same; lands when the second pool is wired.
- No CLAUDE.md restructuring; just one extra bullet under the Architecture summary referencing ADR-0018.

## Doc updates

- `docs/decisions/README.md` index — new row for ADR-0018.
- `CLAUDE.md` Architecture summary — one-line reference to ADR-0018 between the perf-budget and local-quality-gates entries.

## Test plan

- [ ] CI green on this PR (`format:check`).
- [ ] ADR-0018 renders in the doc index with the right tags (`frontend`, `backend`, `infrastructure`, `process`) and 2026-05-10 date.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #80
2026-05-10 04:58:02 +02:00
Julien Gautier 25a89bc8b0 docs: align CLAUDE.md UI stack note with the ADR-0016 spartan-ng deferral
Follow-up to the previous commit (ADR-0016 amendment). The CLAUDE.md
'Architecture' section's Accessibility line still mentioned spartan-ng
as part of the chosen UI stack; align it with the deferral note now in
ADR-0016. Phrasing matches: 'Angular CDK + TailwindCSS' as the v1
stack, with the spartan-ng philosophy applied in-house and the lib
itself revisited at 1.0.0.
2026-04-30 18:59:49 +02:00
Julien Gautier 0e58e32d29 chore: relocate ADRs from decisions/ to docs/decisions/ to consolidate documentation
Move the ADR folder under docs/ alongside the rest of the project
documentation. Convention (flat folder, globally-sequential 4-digit
numbering, tags-based categorization, MADR 4.0.0 format) is unchanged
- only the path moved.

- git mv decisions docs/decisions preserves history for all 18 ADRs +
  README + template (19 files renamed in this commit).
- ADR-0001 amended in-place with a dated note documenting the
  relocation. Status remains 'accepted' - the location detail
  changed, the decision did not.
- All cross-references updated:
  - CLAUDE.md (~17 ADR links + 3 mentions of decisions/ in the Project
    rules section)
  - docs/README.md (now references decisions/ as a sibling under docs/)
  - docs/setup/03-angular-nx-monorepo.md (paths shortened from
    ../../decisions/ to ../decisions/, since setup/ and decisions/ are
    now both inside docs/)
  - docs/decisions/0003 ../CLAUDE.md adjusted to ../../CLAUDE.md
    (one extra level of nesting)
  - docs/decisions/template.md mention of the README path
  - notes/asvs-level-decision-briefing-rssi.md mention of the index

Sanity verified: every ADR link in CLAUDE.md, docs/setup/03, and
docs/decisions/0001 resolves to an existing file. pnpm nx run-many
-t lint passes on 8 projects.
2026-04-30 18:57:59 +02:00
Julien Gautier d797becc2b chore: track Nx 22 AI tooling artefacts injected by generators
Nx 22 generators inject AI agent tooling into the repo via marked
sections and side files. Rather than re-deleting them after every
generator run, track the workspace-level ones and document why.

- CLAUDE.md gains a 'General Guidelines for working with Nx' section
  between Nx-managed markers; future Nx versions will update this
  section automatically without touching our project rules above.
  Pre-existing prettier-formatted blank lines added before code blocks
  are kept.
- .claude/settings.json (228 bytes) enables the nx-claude-plugins
  marketplace from nrwl/nx-ai-agents-config; tracked for contributor
  consistency. The personal .claude/settings.local.json stays
  gitignored.
- .github/ is the Nx AI skills/prompts/agents catalog. Kept despite
  being GitHub-named; it does not conflict with our Gitea workflows
  which will live under .gitea/workflows/ (per ADR-0015).
- .gitignore picks up Nx-managed transient dirs (.nx/polygraph,
  .claude/worktrees) and a trailing newline fix.

AGENTS.md is removed: it duplicated only the Nx auto-injected guidance
that CLAUDE.md already carries (CLAUDE.md is the strictly broader file
- project rules + Nx section). One source of truth for AI-agent
guidance.
2026-04-30 17:46:04 +02:00
Julien Gautier a88fc1e8a1 docs: align CLAUDE.md narrative with phase-2 and phase-3a ADRs
The Architecture section title and the Repository status paragraph
still referred to 'phase-1 ADRs' as if that were the current state.
Update both to reflect that ADRs 0001-0017 (phase 1 + 2 + 3a) are
recorded, that the security baseline ADR is paused awaiting RSSI
input, and that docs/setup/03 has already been rewritten to align
with the ADRs.
2026-04-30 15:46:42 +02:00
Julien Gautier ea852b3239 docs: add ADR-0017 for performance budgets (Core Web Vitals + Lighthouse CI + Angular budgets + BFF SLOs)
Pin the perf framework. Front-end metrics tracked at Google Core Web
Vitals 'Good' thresholds (LCP <= 2.5s, INP <= 200ms, CLS <= 0.1, plus
TBT <= 200ms, TTFB <= 800ms) and Lighthouse Performance >= 90 on
critical routes. Lighthouse CI (@lhci/cli) enforces them in CI with
median-of-3 runs to mitigate runner variance, on a curated
critical-routes list (login, home, accessibility statement, flagship
features as they land). Wires into the perf gate slot from ADR-0015.

Bundle budgets enforced at nx build via Angular's project.json budgets
array, type 'error': initial <= 300 KB gzip, lazy chunks <= 100 KB
gzip, per-component CSS <= 6 KB. source-map-explorer wired as an Nx
analyze target for diagnosis on budget breaches.

Back-end SLOs documented per endpoint family (p95/p99) and observed
via the OpenTelemetry spans already shipped by ADR-0012 - advisory in
CI (load profile unrepresentative), alerting in production. Quarterly
review tightens budgets when achievable.

Scheduled weekly Lighthouse run on the prod env via the existing
security-scheduled.yml workflow extends coverage beyond PR-time.

Explicit a11y/perf trade-off rule: when they conflict, a11y wins (per
APF's mission, ADR-0016). The perf budget is then re-evaluated at the
next quarterly review.

No browser-side RUM SDK in v1 - the OTel browser tracing from ADR-0012
plus scheduled prod Lighthouse runs cover the gap. RUM revisited in
v2 if a real incident escapes the existing signal.

decisions/README.md index updated. CLAUDE.md gains an explicit
'Performance budgets' line pointing to ADR-0017.
2026-04-30 14:22:36 +02:00
Julien Gautier 98a8c78a31 docs: add ADR-0016 for accessibility baseline (WCAG 2.2 AA + targeted AAA, spartan-ng + CDK + Tailwind, APF panel)
Pin the a11y baseline given the host organisation context (APF France
Handicap, where accessibility is the core mission, not a compliance
checkbox):

- Conformance: WCAG 2.2 AA universal + AAA on criteria with high
  impact for the user base (1.4.6 Enhanced Contrast 7:1, 2.2.3 No
  Timing, 2.3.3 Animation from Interactions, 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 context.

- User-preferences panel as a first-class feature: contrast (3 tiers),
  text size (up to 200%), motion, text spacing, cognitive
  simplification, reading focus. Persisted in session (ADR-0010).

- UI stack: Angular CDK + spartan-ng + TailwindCSS (not Angular
  Material; not React libs). Components copy-pasted into
  libs/shared/ui under our control. Design tokens with
  contrast-verified colour pairs in libs/shared/tokens.

- Tooling and CI gates: @angular-eslint/template/* a11y rules
  (blocking), @axe-core/playwright e2e blocking on critical/serious
  violations, design-token contrast verifier (blocking), touch-target
  size check (blocking on 44x44 min, default 48x48). Wires into the
  a11y gate slot from ADR-0015.

- Manual testing: keyboard and screen reader required on every UI PR
  via PR-template checklist; APF user-panel session before every major
  release covering visual / motor / cognitive / hearing categories.

- Documentation: public accessibility statement at /accessibility (EN)
  and /accessibilite (FR) - EAA legal requirement, generated from
  source-of-truth in the repo. Internal patterns library in
  docs/accessibility/.

decisions/README.md index updated. CLAUDE.md gains an explicit
'Accessibility' line pointing to ADR-0016 and the CI/CD line is
adjusted to mark the a11y gate as locked-in (no longer 'future').
2026-04-30 13:36:00 +02:00
Julien Gautier 880c7ded6b chore: rename project from adastra_portal to apf_portal
The host organisation - APF France Handicap - was confirmed on
2026-04-30. Update all in-repo references to use apf_portal
(snake_case in prose) and apf-portal (kebab-case workspace name and
repo URL). Touched: CLAUDE.md, ADRs 0001/0002/0003/0015, and the Nx
bootstrap setup guide.

The historical name is preserved as a single sentence in ADR-0003 as
a self-validating example of the function-prefixed naming convention
designed exactly for this scenario - the apps (portal-shell,
portal-bff) and the lib conventions (feature-<name>, shared-<scope>)
were unaffected by the rename, which was the explicit point of
ADR-0003.

Memory state aligned out-of-band: project_adastra.md retired,
project_apf_portal.md created with the expanded APF context (host
org, health + financial data scope, ASVS L3 pending RSSI input, UI
stack decision spartan-ng + CDK + Tailwind, expanded phase-3 status).

Pending follow-ups (user-side, not in this commit):
- rename the Gitea repo julien/adastra_portal -> julien/apf_portal
- git remote set-url origin gitea@git.unespace.com:julien/apf_portal.git
- optionally rename the local working directory ~/Works/adastra_portal/
  -> ~/Works/apf_portal/
2026-04-30 13:31:38 +02:00
Julien Gautier 49712d0bbf docs: add ADR-0015 for the CI/CD pipeline (Gitea Actions, trunk-based + squash-merge, thin YAML)
Pin the CI/CD shape with an explicit two-level structure, anticipating
the GitLab migration on the 6-18-month horizon:

Level 1 - vendor-neutral, survives the migration:
- Trunk-based with short-lived feature branches; squash-merge only.
- Branch protection on main: no direct push, no force push, linear
  history, required green CI, required reviewers = 0 in v1 (raised to
  >=1 when a second contributor joins), branch deletion after merge.
- Required CI gates (all blocking): format, lint, type-check, test,
  build (nx affected), audit (pnpm + Trivy), secret-scan (gitleaks),
  commit-lint (Conventional Commits on the PR commit range). Future
  a11y and perf gates land with their respective ADRs.
- Conventional Commits validated locally (hook from ADR-0007) and in
  CI as defense in depth.
- Signed commits recommended but not required in v1; revisited at the
  GitLab migration.
- Thin YAML: all orchestration logic lives in package.json scripts
  (ci:check, ci:scan, ci:commits) and Nx targets. Workflow files only
  do checkout, setup, cache, and call one script per job. The migration
  rewrites the YAML wrappers, never the gates.
- Secrets convention SCOPE_PURPOSE; gitleaks enforces no-secrets-in-code.
- Container images / deploy explicitly out of scope (deferred to the
  on-prem infrastructure ADR).

Level 2 - Gitea Actions specific, will be superseded by a GitLab
migration ADR:
- Engine: Gitea Actions (built-in, GitHub Actions-compatible syntax,
  partial portability hedge if the org pivots to GitHub).
- Runners: >=3 self-hosted act_runner instances on-prem, Debian image
  pinned by SHA, weekly rebuild via security-scheduled.yml.
- Three workflow files: ci.yml (PR + push to main), release.yml
  (stub for tag, populated by the deploy ADR), security-scheduled.yml
  (weekly Trivy + gitleaks full-tree scan + Renovate trigger).

Migration weight estimated at 3-5 days dev/ops when GitLab arrives:
the YAML is rewritten, the gates and scripts are unchanged. A future
ADR will explicitly supersede only the level-2 sections.

decisions/README.md index updated. CLAUDE.md gains an explicit 'CI/CD'
line pointing to ADR-0015 and noting the future GitLab supersession.
2026-04-30 11:21:30 +02:00
Julien Gautier a20330a474 docs: add ADR-0014 for downstream API access (OBO pattern + DownstreamApiClient framework)
Pin the framework for calls from the BFF to integrated downstream APIs.
The concrete list of downstream services is not yet known, but the
framework must exist so that the day a developer adds an integration the
answer is 'use the standard client', not 'invent something'.

A DownstreamApisModule exposes a DownstreamApiClientFactory that produces
typed clients from per-service DownstreamApiConfig blocks. Each config
declares the auth strategy, base URL, timeout, retry, circuit breaker,
bulkhead, and audienceConstraint.

Default auth strategy for Entra-protected downstreams is On-Behalf-Of
(MSAL Node acquireTokenOnBehalfOf). Downstream-scoped tokens are cached
in Redis under obo:{user_id_hash}:{resource}, encrypted with AES-256-GCM
using a dedicated key (OBO_CACHE_ENCRYPTION_KEY) distinct from the
session-encryption key so a cache compromise doesn't cascade into a
session compromise.

Fallback strategy for non-Entra downstreams is service credential +
signed user-assertion header (X-User-Assertion JWT signed by the BFF's
private key, verified by downstreams against the BFF JWKS at
/.well-known/jwks.json). Token relay is rejected as a default;
per-user credential mapping is rejected outright.

Resilience composes via cockatiel: timeout outermost, then retry (only
on idempotent verbs and retriable error classes), circuit breaker per
service, bulkhead per service. Each call opens a downstream.<service>
OpenTelemetry span; auth failures emit audit events. Downstream errors
are translated at the client boundary - never bubbled with raw payload.

Audience pre-check is enforced at the call site (not at controller
entry) - even a missing authorization guard upstream cannot bypass the
audience constraint.

The framework is forward-looking; concrete integrations land per-service
in code config (no per-integration ADR unless the integration deviates
non-trivially from the defaults). Strategy code is exercised by
mock-driven tests until the first real integration ships.

decisions/README.md index updated. CLAUDE.md gains an explicit
'Downstream API access' line pointing to ADR-0014.
2026-04-29 23:58:49 +02:00
Julien Gautier b35bf2b3de docs: add ADR-0013 for the audit trail (dedicated append-only Postgres schema)
Pin the audit-trail architecture: a dedicated 'audit' schema in the same
Postgres instance as business data, with three Postgres roles enforcing
append-only at the database layer - audit_writer (INSERT only),
audit_reader (SELECT only), audit_archiver (DELETE only on rows past the
retention threshold). No role anywhere holds UPDATE or TRUNCATE; the BFF
verifies this at startup with a deliberate failing UPDATE probe.

The audit stream is decoupled from the application logs (different sink,
different access controls, different retention) but cross-referenced via
trace_id (ADR-0012) and actor_id_hash, which uses the same salt as the
app logs so an investigator joins the two streams without re-hashing.

Events captured in v1 cover the auth and session lifecycle: sign_in
(success/failure), sign_out, session.expired, session.revoked,
token.validation.failed, mfa.assertion.failed, authz.deny. Hooks for
admin actions and sensitive data access are designed-in but inert until
v1+ features call them - kept alive by tests so they don't drift.

Failure semantics are blocking - if the audit INSERT fails, the
in-flight operation fails with 503. Trade-off acknowledged: the audit DB
is part of the trust path. Mitigation is HA Postgres in prod, deferred
to the infrastructure ADR.

Retention defaults to 365 days, env-overridable, enforced by a daily
purge running under audit_archiver. The retention default is engineering
prudence, not legal advice - the org-side legal review of the actual
applicable retention regime is explicitly owed and noted in the ADR.

Cryptographic chaining and WORM storage are deferred unless a compliance
regime demands them.

decisions/README.md index updated. CLAUDE.md gains an explicit
'Audit trail' line pointing to ADR-0013.
2026-04-29 23:32:35 +02:00
Julien Gautier fe3bb2dd7d docs: add ADR-0012 for observability (Pino + OpenTelemetry, W3C Trace Context, stdout + collector)
Pin the observability foundation. Two signals are in scope: structured
logs and distributed traces.

Logs: pino + nestjs-pino, JSON line-delimited on stdout, with a fixed
envelope (level, time, service, version, env, trace_id, span_id,
session_id, user_id_hash, audience, msg, ...). Pino redact strips a
reviewable allowlist of sensitive paths (Authorization/Cookie headers,
*.password, *.access_token, *.refresh_token, ...). user_id_hash uses a
per-environment salt (LOG_USER_ID_SALT) so the same userId is not
correlatable across environments.

Tracing: OpenTelemetry SDK for Node + auto-instrumentations (HTTP,
Express, NestJS, pg, ioredis, Prisma). The SPA also runs OTel-Web with
an HTTP interceptor propagating traceparent on outbound calls; the same
trace_id is the correlation identifier from the user click to the DB
query. No separate X-Correlation-ID.

Request-scoped context lives in nestjs-cls; the Pino formatter and the
future audit-log writer pull from CLS - no per-call threading.

Sampling is 100% at the application; tail sampling is performed at the
local OpenTelemetry Collector (deferred to the phase-3 infrastructure
ADR, where the on-prem backend - Grafana stack, ELK, or other - will be
chosen). Output: stdout for logs, OTLP/HTTP for traces, both consumed
by the local collector. The application stays vendor-neutral.

Audit logs are explicitly out of scope of this ADR - they share the
trace_id but use a separate writer and a separate sink (next ADR).

decisions/README.md index updated. CLAUDE.md gains an explicit
'Observability' summary pointing to ADR-0012.
2026-04-29 23:25:36 +02:00
Julien Gautier 065d50247f docs: add ADR-0011 for MFA enforcement (Entra Conditional Access + step-up hooks)
Pin the MFA policy: enforcement lives in Entra ID Conditional Access at
the tenant level (org IT responsibility) - the application code does not
implement MFA mechanics. The BFF performs a defense-in-depth sanity-check
on the id_token amr claim at session creation; sessions without evidence
of multi-factor authentication are rejected. The accepted amr values are
maintained in a small in-source list and reviewed on cadence.

Step-up MFA is designed-in for v1 but dormant: a @RequireMfa() decorator
and a RequireMfaGuard ship in the codebase, the session payload carries
mfaVerifiedAt, and the SPA HTTP interceptor handles the 401 + claims
challenge round trip. No v1 route is annotated, since v1 has no admin UI
or other operations sensitive enough to require fresh MFA. The hooks are
kept alive by automated tests so they don't drift.

Authentication Context Classes (ACR-based step-up) are not used in v1;
they remain a future option if specific operations later demand them.
Service-account / app-only flows are out of scope.

decisions/README.md index updated. CLAUDE.md gains an explicit 'MFA' line
pointing to ADR-0011.
2026-04-29 23:16:51 +02:00
Julien Gautier b6e43565c3 docs: add ADR-0010 for session management (opaque ID + Redis + AES-GCM)
Pin the session-state architecture: the browser carries only an opaque
crypto-random session id in __Host-portal_session, signed with
SESSION_SECRET. The payload (userId, audience, curated claims, encrypted
tokens, timestamps) lives in self-hosted Redis, accessed via the standard
express-session + connect-redis pair under the NestJS Express adapter.

The id_token / access_token / refresh_token tuple is encrypted with
AES-256-GCM before being stored - per-record IV, GCM auth tag - using
SESSION_ENCRYPTION_KEY. A Redis snapshot or memory dump alone is not
enough to forge a working session; the encryption key must also be
compromised. Tampered or wrong-key records are rejected and audited.

TTL policy: idle 30 min sliding (TTL refreshed on each request) +
absolute 12 h (checked in a global interceptor, triggers DEL on expiry).

Topology: Redis Sentinel (3+ nodes) in prod with TLS and ACL; single node
in dev. Operational specifics deferred to a phase-3 infrastructure ADR.

Revocation is immediate (DEL session:{id}). A secondary index
user_sessions:{userId} supports per-user listing and force-logout. No
PostgreSQL mirror; historical trace lives in the future audit-log ADR.

decisions/README.md index updated. CLAUDE.md gains an explicit 'Sessions'
line pointing to ADR-0010.
2026-04-29 23:09:34 +02:00
Julien Gautier 90bca95fce docs: add ADR-0009 for the authentication flow (OIDC Auth Code + PKCE via MSAL Node)
Pin the BFF authentication mechanics: OAuth 2.0 Authorization Code Flow
with PKCE, executed server-side via @azure/msal-node's
ConfidentialClientApplication. Tokens are held in the BFF session and
never reach the browser; the SPA only ever sees the opaque
__Host-portal_session cookie.

Token validation enforces the tenant allowlist from ADR-0008 (iss check)
and maps the audience claim to our Audience enum at the validation step.
Refresh-token rotation is enabled via MSAL acquireTokenSilent. Cookies
use the __Host- prefix (forces Secure/Path=/, no Domain) with
HttpOnly/SameSite=Lax. CSRF uses the double-submit pattern with a
matching X-CSRF-Token header on every state-changing request, enforced
by a NestJS interceptor and injected client-side by an Angular HTTP
interceptor. Logout is RP-initiated against Entra's end_session_endpoint.

Routes are pinned: GET /auth/login, GET /auth/callback, POST
/auth/logout, GET /auth/me. AuthGuard is registered globally - public
routes must be explicitly opted in.

Local dev runs over HTTPS via mkcert to keep cookie behaviour identical
to prod. All Entra-specific values come from environment variables; the
BFF refuses to start without them.

decisions/README.md index updated. CLAUDE.md gains an explicit
'Authentication flow' line pointing to ADR-0009.
2026-04-29 22:59:33 +02:00
Julien Gautier f5e8e6ef61 docs: add ADR-0008 for the identity model (multi-tenant Entra workforce + dual-audience design)
Capture the v1 identity model: Microsoft Entra ID, multi-tenant app with
B2B guest invitation for partner-org employees, workforce-only
authentication in v1. Code and data are architected for dual audience
from day one (Audience enum, audience claim in sessions, audience column
+ RLS policies on shared tables, claims-based authz) so that adding Entra
External ID for customers later is a switch-flip rather than a refactor.

The dev environment uses a Microsoft 365 Developer Program tenant (free,
renewable) to unblock work while the prod tenant is being provisioned by
the org IT contact. Production requires Entra ID P1 licensing - flagged
here so it can be planned, not surprised.

decisions/README.md index updated. CLAUDE.md 'Identity' line now points
to ADR-0008.
2026-04-29 22:53:17 +02:00
Julien Gautier 084ff5c3bf docs: add ADR-0007 for pre-commit hooks and align documentation references
- decisions/0007-pre-commit-hooks-and-conventional-commits.md formalizes
  Husky + lint-staged + commitlint with Conventional Commits as the local
  quality-gate baseline.
- decisions/README.md index updated.
- docs/setup/03 section 8 rewritten to reference the ADR and document the
  full hook setup (pre-commit, commit-msg, commitlint config).
- docs/setup/03 future-work table 'ADR(s)' column removed; future ADR
  numbers are now assigned at the moment each ADR is written, not
  pre-reserved.
- CLAUDE.md aligned: pre-allocated phase-2 ADR numbers replaced by phase
  references; a pointer to ADR-0007 added under 'Local quality gates'.
2026-04-29 21:01:49 +02:00
Julien Gautier 79eee77594 chore: initialize repository with project rules, docs, and phase-1 ADRs
Set up the foundation for the adastra-portal project:

- CLAUDE.md captures durable project rules (quality bar, security/perf/a11y
  as first-class, language, commit conventions, ADR proactivity).
- docs/ and decisions/ scaffolding with maintained indexes (docs/README.md
  and decisions/README.md), MADR 4.0.0 template, and tag vocabulary.
- Phase-1 ADRs (0001-0006) lock structural choices: ADR usage, Nx monorepo
  with the apps preset, naming convention (adastra-portal / portal-shell /
  portal-bff), Angular CSR/zoneless/Signals/Vitest, NestJS over Express,
  PostgreSQL with Prisma.
- docs/setup/ guides translated to English.
- .gitignore covers Node/Nx artifacts and the personal notes/ scratchpad.

The Nx workspace itself is not yet bootstrapped; that step is gated on a
revised setup guide aligned with the ADRs.
2026-04-29 20:43:00 +02:00