Files
apf_portal/docs/development.md
T
julien 0f00d6d93f
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
feat(infra): add local-dev Docker Compose stack (#57)
## 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

27 KiB
Raw Blame History

Development guide

This document is the day-to-day reference for working on apf_portal. It covers the repo layout, the prerequisites, the initial setup from a fresh clone, and the commands you'll run during a typical development cycle. It is meant to grow — add sections as the team's workflow does.

For decision rationale, see the ADRs. For onboarding the local environment (terminal, Node, pnpm), see setup/.


1. Repo layout

apf_portal/
├── .gitea/workflows/             # CI pipelines (ADR-0015)
│   ├── ci.yml                    # per-PR + push to main: check / scan / commits / perf / a11y
│   └── security-scheduled.yml    # weekly full-tree scan + prod Lighthouse
├── .github/                      # Nx AI-tooling skills, prompts, agents (Nx-managed)
├── .husky/                       # local git hooks (ADR-0007)
│   ├── pre-commit                # → pnpm exec lint-staged
│   └── commit-msg                # → pnpm exec commitlint
├── apps/
│   ├── portal-shell/             # Angular 21 SPA (zoneless, standalone, Signals, Vitest, SCSS)
│   │   ├── public/               # static assets
│   │   ├── src/                  # entry, app config, routes, styles
│   │   ├── postcss.config.js     # Tailwind PostCSS plugin
│   │   └── project.json          # Nx project config (build, serve, test, lint targets)
│   ├── portal-shell-e2e/         # Playwright e2e for portal-shell
│   ├── portal-bff/               # NestJS 11 BFF (Express adapter, ValidationPipe, Jest)
│   │   ├── src/                  # main, app module, controllers, services
│   │   ├── prisma/schema.prisma  # Prisma 7 schema (postgresql)
│   │   ├── prisma.config.ts      # Prisma 7 TS config (loads DATABASE_URL from .env)
│   │   ├── .env.example          # env-vars catalog (committed); .env stays gitignored
│   │   └── project.json
│   └── portal-bff-e2e/           # Jest e2e for portal-bff
├── libs/
│   ├── feature/<name>/           # vertical feature libs (e.g. feature-auth)
│   └── shared/<scope>/           # cross-cutting libs (tokens, ui, util)
├── docs/
│   ├── README.md                 # doc index
│   ├── decisions/                # ADRs (MADR 4.0.0)
│   ├── setup/                    # local-environment onboarding (Zsh, pnpm, Nx workspace)
│   └── development.md            # this file
├── notes/                        # personal scratchpad (gitignored)
├── CLAUDE.md                     # project rules + architecture summary
├── commitlint.config.cjs         # Conventional Commits config
├── eslint.config.mjs             # workspace ESLint with module boundaries
├── lighthouserc.js               # Lighthouse CI thresholds (ADR-0017)
├── nx.json                       # Nx workspace config
├── package.json                  # workspace deps + scripts
├── pnpm-workspace.yaml           # apps/* + libs/**
├── tsconfig.base.json            # shared TS strict config
└── vitest.workspace.ts           # Vitest workspace projects

The conventions that govern this layout are recorded in:

  • ADR-0002 — Nx workspace shape
  • ADR-0003 — naming convention (portal-shell, portal-bff, feature-<name>, shared-<scope>)
  • ADR-0007 — local hooks
  • ADR-0015 — CI/CD shape

2. Prerequisites

A working dev machine for apf_portal needs:

Tool Why How
WSL 2 + Debian (Windows) or Linux/macOS native All commands assume a POSIX shell see setup/01
Node.js 24 (latest LTS) Runtime, pinned in .nvmrc nvm install 24 && nvm use (see setup/02)
pnpm 10+ Mandatory package manager (no npm/yarn lockfile) corepack enable && corepack prepare pnpm@latest --activate
Git ≥ 2.40 Husky 9 + signed commits eventually usually default
mkcert Local HTTPS for cookie-prefix __Host- (ADR-0009) apt install mkcert then mkcert -install
Trivy (optional, locally) Dep vuln scan when running ci:scan locally; CI uses an action apt install trivy or trivy install docs
gitleaks (optional, locally) Same pattern; CI uses an action apt install gitleaks or gitleaks install docs
Docker For Postgres + Redis containers in dev (until on-prem infra ADR lands) Docker Desktop on Windows, Docker Engine on Linux

Work inside the WSL filesystem (~/Works/...), never /mnt/c/... — the latter has severe I/O penalties that break Nx caching.


3. Initial setup from a fresh clone

git clone gitea@git.unespace.com:julien/apf_portal.git
cd apf_portal

# Install deps (also runs `husky` to wire git hooks)
pnpm install

# Generate the Prisma client (until you set up the DB it errors on
# missing DATABASE_URL — that's expected; the generation only reads
# the schema, not the DB).
cd apps/portal-bff && pnpm exec prisma generate && cd ../..

# Sanity check
pnpm nx run-many -t lint test build

For the BFF to actually run end-to-end, you'll also need the local infrastructure stack — Postgres, Redis, OpenTelemetry Collector — provisioned via Docker Compose:

# 1. Configure infra secrets (copy template, edit, do not commit).
cp infra/local/.env.example infra/local/.env
$EDITOR infra/local/.env
#    Set strong dev values for POSTGRES_PASSWORD and REDIS_PASSWORD.

# 2. Bring up the core stack (postgres + redis + otel-collector).
docker compose -f infra/local/dev.compose.yml up -d

# 3. (Optional) Activate viewers when debugging:
docker compose -f infra/local/dev.compose.yml --profile dbtools up -d         # pgweb
docker compose -f infra/local/dev.compose.yml --profile observability up -d   # Jaeger UI

# 4. App-side env from .env.example, then fill in DATABASE_URL pointing
# to the compose-managed Postgres (matches the values you set in
# infra/local/.env).
cp apps/portal-bff/.env.example apps/portal-bff/.env

Full reference for the local stack — service inventory, port table, persistence, bootstrap re-run procedure — lives in infra/README.md → "Local-dev stack".


4. Daily commands

Run the apps

pnpm nx serve portal-shell        # http://localhost:4200 (Angular dev server)
pnpm nx serve portal-bff          # http://localhost:3000/api (NestJS)

Both can run in parallel in two terminals; the SPA proxies API calls to the BFF in dev.

Test

pnpm nx test portal-shell         # Vitest (single run; --configuration=watch for watch mode)
pnpm nx test portal-bff           # Jest

pnpm nx run-many -t test          # all projects
pnpm nx affected -t test          # only projects affected since main

Run a single Vitest file:

pnpm nx test portal-shell --testFile=src/app/app.spec.ts

Lint, type-check, format

pnpm nx lint portal-shell             # one project
pnpm nx run-many -t lint              # all projects
pnpm nx affected -t lint              # affected only
pnpm nx affected -t lint --fix        # auto-fix where possible

pnpm nx affected -t type-check        # explicit type-check (independent of test/build)

pnpm nx format:write                  # apply Prettier
pnpm nx format:check                  # CI-style verification

Build

pnpm nx build portal-shell                                   # development build
pnpm nx build portal-shell --configuration=production        # production build
pnpm nx run-many -t build
pnpm nx affected -t build

Generate

# Component in portal-shell
pnpm nx g @nx/angular:component <name> --project=portal-shell --standalone

# Service / controller / module in portal-bff
pnpm nx g @nx/nest:service <name> --project=portal-bff
pnpm nx g @nx/nest:controller <name> --project=portal-bff

# New shared lib (TS-only, consumable by both apps)
pnpm nx g @nx/js:library --name=shared-<scope> --directory=libs/shared/<scope> \
  --bundler=tsc --unitTestRunner=vitest \
  --tags="scope:shared,type:shared" --no-interactive

# New Angular feature lib (front-only)
pnpm nx g @nx/angular:library --name=feature-<name> --directory=libs/feature/<name> \
  --standalone=true --unitTestRunner=vitest-analog \
  --tags="scope:portal-shell,type:feature" --no-interactive

Sweep generated files for process.env.X (dot notation) → process.env['X'] (bracket notation), required by the strict-TS option noPropertyAccessFromIndexSignature: true. The Nx generators don't emit bracket form.

Prisma

# Regenerate the typed client after schema changes
cd apps/portal-bff && pnpm exec prisma generate && cd ../..

# Create and apply a migration in dev
cd apps/portal-bff && pnpm exec prisma migrate dev --name <migration-name> && cd ../..

# Deploy migrations in prod (run by deploy pipeline, not locally)
cd apps/portal-bff && pnpm exec prisma migrate deploy && cd ../..

# Inspect the dev DB
cd apps/portal-bff && pnpm exec prisma studio && cd ../..

CI scripts (runnable locally)

Mirror what the CI does on every PR:

pnpm ci:check      # nx affected -t format:check lint test build
pnpm ci:audit      # pnpm audit --audit-level=moderate
pnpm ci:commits    # commitlint on the PR commit range (uses $COMMIT_LINT_FROM, defaults to origin/main)
pnpm ci:perf       # production build + Lighthouse CI against the static-served bundle

ci:scan (Trivy + gitleaks) is currently invoked from CI YAML rather than as a pnpm script — those tools are Go binaries without clean npm wrappers. Run them locally if you've installed the binaries.


5. Dependency updates (Renovate)

Renovate runs as a scheduled workflow (.gitea/workflows/renovate.yml) and opens PRs against main for dependency updates. Daily at 03:00 UTC, plus on-demand via workflow_dispatch.

Behaviour is controlled by renovate.json at the repo root: groupings (Angular, Nx, NestJS, Prisma, Vitest, TypeScript tooling, ESLint, SWC, Tailwind), Conventional-Commits-compatible commit messages (chore(deps): … / fix(deps): … for vulnerability fixes), weekly lockfile maintenance, OSV.dev as the vulnerability data source.

One-time bot onboarding

Renovate authenticates as a dedicated bot user. Setup is manual on Gitea — done once per Gitea instance, then the workflow runs unattended.

  1. Create a bot user. Site Administration → Users → Create User. Suggested name: apf-portal-bot. Strong password, mark as non-admin (least privilege).
  2. Set the bot's Full Name in its Gitea profile (User Settings → Profile → Full Name, e.g. APF Portal Bot). Without it, Renovate's git commits fail with empty ident name not allowed. The gitAuthor in renovate.json is the explicit override, but keeping the profile value consistent avoids confusion when reading commit history in Gitea's UI.
  3. Add the bot as a collaborator on this repo with Write access (Settings → Collaborators). Without write, Renovate can't push branches.
  4. Generate a PAT for the bot (RENOVATE_TOKEN). Sign in as the bot, then User Settings → Applications → Generate New Token. Scopes needed: read/write repository, read/write issue, read user. Avoid admin.
  5. Store the PAT as a repo secret. Settings → Actions → Secrets → New Secret. Name: RENOVATE_TOKEN. Value: the token from step 4.
  6. Generate a zero-scope GitHub.com PAT (GITHUBCOM_TOKEN). On github.com (any account, e.g. yours): Settings → Developer settings → Personal access tokens → Tokens (classic) → Generate new token (classic). Do not tick any scope — anonymous-equivalent rights are enough; the token only buys Renovate the higher authenticated rate limit (5 000 req/h vs 60 req/h) for resolving GitHub-hosted Action versions and containerbase/node-prebuild binaries used during lockfile maintenance.
  7. Store it as a repo secret named GITHUBCOM_TOKEN (Gitea reserves the GITHUB_* secret namespace for the built-in ${{ github.* }} context, so an underscore between GITHUB and COM is rejected).
  8. Sign out and forget both tokens locally. They are now only retrievable via the secret store.

To rotate either token: regenerate at the matching step, update the secret. The schedule keeps running unattended.

Triggering manually

Repo → Actions → "Renovate" workflow → Run workflow. Useful when you've just changed renovate.json and want the next pass to happen immediately rather than wait for the next 03:00 UTC tick.

Reviewing Renovate PRs

  • Renovate PRs run a leaner CI pipeline than human PRs — check, scan, a11y only. The perf and commits gates are skipped (per ADR-0017 — Lighthouse signal on a dep bump is essentially zero, commitlint on bot-generated messages is tautological). The full perf gate still runs on push to main post-merge, so regressions are caught seconds after merge rather than before.
  • Don't merge until the remaining gates are green.
  • Major bumps are gated behind the dependency dashboard. Renovate does not auto-create PRs for major updates; instead, they appear in the "Renovate Dependency Dashboard" issue with a checkbox. Tick the box only after reading the upstream changelog and confirming the rest of our toolchain (Nx plugins, Angular CLI, NestJS, etc.) supports the new major. This guard exists because nx affected sees a deps-only change as not affecting any project — so a major that breaks the build can pass CI silently and only surface days later. Past offenders we caught the hard way: TypeScript 5→6 (deprecated baseUrl), ESLint 9→10 (Nx eslint plugin not yet compatible), webpack-cli 5→7 (removed --node-env flag).
  • The "Renovate Dependency Dashboard" issue (auto-created on first run) lists every pending update grouped by status. Use it to triage which PRs to expedite.
  • For a major bump that introduces breaking changes, don't reflexively merge: read the changelog, then either accept the work or close the PR with a "rejected" label. Renovate respects that label and won't keep re-opening the same major.
  • Adding or removing a dependency belongs in a feature PR, not in Renovate's scope. Renovate only updates versions of existing deps.

6. Conventional commit cycle

  1. Branch from main with a short slug:

    git switch -c feat/portal-shell/auth-login   # or fix/..., chore/..., docs/...
    
  2. Commit using Conventional Commits. The local commit-msg hook (commitlint) rejects anything else.

    feat(portal-shell): add login flow stub
    fix(portal-bff): correct env var bracket access
    chore: bump @nx/* to 22.7.2
    docs(decisions): add ADR-0018 for security baseline
    

    pre-commit runs lint-staged → Prettier on staged files. Lint and tests stay in CI.

  3. Push and open a PR against main. The CI runs:

    • check (lint, type-check, test, build on affected)
    • scan (audit, Trivy, gitleaks)
    • commits (commitlint on the PR commit range)
    • perf (Lighthouse on the production bundle)
    • a11y (axe-core; placeholder until first real screens)

    All five must be green to merge. PR title must itself be a Conventional Commits message — it becomes the squash-merge subject (ADR-0015).

  4. Squash-merge into main. Branch is auto-deleted. Linear history maintained.

  5. To cut a release: tag vX.Y.Z on main. The release.yml workflow will pick it up (currently a stub; populated alongside the on-prem deploy ADR).

PR conventions

The squash-merge subject on main is the PR title, not the individual commits on the feature branch (those collapse into the squash). Two practical consequences:

  1. The PR title must itself be a valid Conventional Commits message. Same format as a commit message — <type>(<scope>): <description>, imperative mood, lowercase, no trailing period, target ≤ 70 chars. The CI commits job (commitlint on the PR commit range) catches violations.
  2. Individual commits on the feature branch can be exploratory. The local commit-msg hook still validates each commit's format, but the squash makes granular history irrelevant on main. Granular history stays available in the PR for review.

Type vocabulary

Type When
feat new user-facing feature or capability
fix bug fix
docs documentation only (no code)
style formatting / whitespace (no logic change)
refactor code change that is neither a fix nor a feature
perf performance improvement
test tests added or updated
build build system, dependencies
ci CI configuration
chore maintenance, scaffolding, project metadata
revert revert a previous commit

Scope vocabulary (optional)

Scope Examples
App portal-shell, portal-bff
Lib shared-tokens, shared-ui, shared-util, feature-auth
Cross-cutting decisions (ADR work), docs, ci, deps

Scope is optional. Omit when the change spans too many areas to scope cleanly (e.g., a workspace-level rename).

PR body template

When a PR is opened against main, Gitea pre-populates the body from .gitea/pull_request_template.md:

  • Summary — 13 bullets describing what changed.
  • Motivation — why, with ADR / issue / incident links.
  • Implementation notes — trade-offs, alternatives considered, follow-ups deferred.
  • Verification — CI gates checked, manual test description, ADR / diagram update flags.
  • Related — ADR-XXXX, related PRs, follow-up issues.

The template guides without enforcing — sections can be left blank when irrelevant. The point is to make "what does the reviewer need to know" explicit, not to add ceremony.


7. Where to look

Question Doc
Project rules and the why behind them CLAUDE.md
All ADRs (decisions index) docs/decisions/README.md
Initial environment setup (Zsh, Node, pnpm) docs/setup/
RSSI briefing for ASVS / HDS / etc. notes/asvs-level-decision-briefing-rssi.md (gitignored, personal)
The dev-team rationale for the UI stack notes/argumentaire-stack-ui-spartan-cdk-tailwind.md (gitignored, personal)

8. Sections to come — roadmap by phase

This doc starts as a phase-1 + cross-cutting reference. As features for later phases land, the corresponding sections below are filled in directly. Each entry is mapped to the ADR / implementation work that unlocks it, so a contributor can see when each section becomes real and what triggers it.

When a section grows beyond a short subsection, it is extracted to its own file under docs/development/. Per the documentation convention (see README.md), we group into a folder once we have at least three related files; this doc is then re-organised into an index pointing at the extracted files. Until then, all sections live here.

Future section Phase Triggered by
Auth dev-loop — Microsoft 365 Developer tenant configuration, MSAL Node connection, OIDC code-flow walkthrough, switching between dev and prod-like tenants. 2 Auth flow code lands (ADR-0009) once the dev tenant is provisioned by IT.
Session inspection — reading the Redis session store in dev, decrypting the AES-GCM tokens blob with the dev key, force-logout patterns. 2 Sessions module lands (ADR-0010).
MFA step-up debugging — triggering claims-challenge flows, verifying mfaVerifiedAt freshness, testing the SPA HTTP interceptor that handles 401 + claims challenge. 2 First @RequireMfa() route lands (ADR-0011).
Observability dev-loop — viewing traces in the Jaeger UI provisioned by infra/local/dev.compose.yml --profile observability, reading Pino logs with pino-pretty, correlating front spans to BFF spans via traceparent. 2 OTel SDK setup lands (ADR-0012).
Audit-log inspection workflow — querying audit.events as audit_reader, joining with app logs by trace_id, validating the append-only role grants in dev. 2 Audit module lands (ADR-0013).
Downstream API integration recipe — adding a new DownstreamApiConfig, choosing the auth strategy (OBO vs service+assertion), wiring resilience policies, testing with a mocked downstream. 2 First downstream client lands (ADR-0014).
Component patterns library — the in-house, spartan-style components (Angular CDK + Tailwind) as they ship, with a11y notes per component (keyboard model, ARIA, screen-reader expectations). 5b suite First non-placeholder component in libs/shared/ui/.
a11y testing workflow — running axe-core via Playwright locally, screen-reader testing notes (NVDA / VoiceOver / TalkBack), the APF user-panel cadence and how to triage findings. 3a First Playwright e2e suite touching real screens (ADR-0016).
Performance debugging — running Lighthouse CI locally with full config, reading the HTML reports, using source-map-explorer to investigate bundle bloat, interpreting BFF p95/p99 from OTel. 3a Lighthouse already wired in CI (ADR-0017); section grows when first real route is added to the critical-routes list.
Debugging tips — Angular DevTools, NestJS inspector, Prisma query log, OTel trace navigation, common gotchas. cross Accumulates organically as the team encounters them.
Release workflow — tag-driven release, what release.yml does, version bumping, changelog generation from Conventional Commits. 3b On-prem infrastructure ADR + populated release.yml.
GitLab migration runbook — when the org migrates Gitea → GitLab, how the workflows are ported, which level-2 sections of ADR-0015 get superseded. future GitLab migration ADR (618 months horizon).
Architecture overview diagrams — high-level component diagrams, data-flow diagrams, trust boundaries (for security review). cross First major architecture review or onboarding cohort ≥ 3 contributors.