# 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](decisions/). For onboarding the local environment (terminal, Node, pnpm), see [setup/01-wsl-terminal-setup.md](setup/01-wsl-terminal-setup.md).

---

## 1. Repo layout

```
apf_portal/
├── .gitea/workflows/             # CI pipelines (ADR-0015)
│   ├── ci.yml                    # per-PR + push to main: check / scan / commits / perf / a11y
│   ├── docs-site.yml             # build docs site on PR + push (ADR-0022)
│   ├── renovate.yml              # daily Renovate run (cron 03:00 UTC)
│   └── 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 (incl. /logos)
│   │   ├── src/                  # entry, app config, routes, styles, locales
│   │   ├── .postcssrc.json       # Tailwind PostCSS plugin (required by @angular/build esbuild — postcss.config.js is ignored)
│   │   └── project.json          # Nx project config (build, serve, test, lint targets)
│   ├── portal-shell-e2e/         # Playwright e2e for portal-shell
│   ├── portal-admin/             # Angular 21 SPA (admin surface, distinct origin + session per ADR-0020)
│   │   ├── public/               # static assets (incl. /logos)
│   │   ├── src/                  # entry, app config, routes, audit + users + profile pages
│   │   └── project.json
│   ├── portal-admin-e2e/         # Playwright e2e for portal-admin
│   ├── portal-bff/               # NestJS 11 BFF (Express adapter, ValidationPipe, Jest)
│   │   ├── src/                  # main, app module, controllers, services
│   │   ├── prisma/schema.prisma  # Prisma 6.x schema (PostgreSQL) — pin documented in ADR-0006
│   │   ├── .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 (state, tokens, ui, util)
├── docs/
│   ├── README.md                 # doc index (git/IDE side — excluded from the docs site)
│   ├── index.md                  # VitePress home (Hero layout, see ADR-0022)
│   ├── architecture.md           # cross-cutting Mermaid diagrams (C4, module boundaries, CI)
│   ├── development.md            # this file
│   ├── decisions/                # ADRs (MADR 4.0.0)
│   ├── setup/                    # local-environment onboarding (Zsh, pnpm, Nx workspace)
│   └── .vitepress/               # VitePress config + build cache (gitignored)
├── 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 (incl. pnpm.overrides for transitive remediation)
├── pnpm-workspace.yaml           # apps/* + libs/**
├── renovate.json                 # Renovate config (groupings, dashboard, vuln alerts)
├── tsconfig.base.json            # shared TS strict config
└── vitest.workspace.ts           # Vitest workspace projects
```

The conventions that govern this layout are recorded in:

- [ADR-0002](decisions/0002-adopt-nx-monorepo-apps-preset.md) — Nx workspace shape
- [ADR-0003](decisions/0003-workspace-and-app-naming-convention.md) — naming convention (`portal-shell`, `portal-bff`, `feature-<name>`, `shared-<scope>`)
- [ADR-0007](decisions/0007-pre-commit-hooks-and-conventional-commits.md) — local hooks
- [ADR-0015](decisions/0015-cicd-gitea-actions.md) — 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](setup/01-wsl-terminal-setup.md)                                                     |
| **Node.js 24** (latest LTS)                        | Runtime, pinned in `.nvmrc`                                                                         | `nvm install 24 && nvm use` (see [setup/02](setup/02-dev-web-stack.md))                            |
| **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](https://trivy.dev/)                                    |
| **gitleaks** _(optional, locally)_                 | Same pattern; CI uses an action                                                                     | `apt install gitleaks` or [gitleaks install docs](https://github.com/gitleaks/gitleaks#installing) |
| **Docker**                                         | For the local-dev stack (Postgres + Redis + OTel + viewers) per [`infra/local/`](../infra/local/)   | Docker Desktop on Windows, Docker Engine on Linux                                                  |
| **`psql`** (postgresql-client)                     | Inspect the local Postgres from the host (audit roles, schemas, queries). Versions `>= 16` are fine | `apt install postgresql-client`                                                                    |
| **`redis-cli`** (redis-tools)                      | Inspect the local Redis from the host (sessions, OBO cache when they land)                          | `apt install redis-tools`                                                                          |

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

```bash
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. A thin wrapper script ([`infra/local/dev.sh`](../infra/local/dev.sh)) hides Compose-profile quirks and gives ergonomic verbs:

```bash
# 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).
./infra/local/dev.sh up

# 3. (Optional) Activate viewers when debugging:
./infra/local/dev.sh up dbtools         # adds pgweb (http://localhost:8081)
./infra/local/dev.sh up observability   # adds Jaeger UI (http://localhost:16686)
./infra/local/dev.sh up all             # core + every profile

# 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
```

`./infra/local/dev.sh help` lists the rest of the verbs (`down`, `status`, `logs`, `stop`, `restart`, `exec`). Full reference — service inventory, port table, persistence, bootstrap re-run procedure — lives in [`infra/README.md`](../infra/README.md) → "Local-dev stack".

### Topology at a glance

How the pieces fit together once the stack is up — three Node dev servers on the host, the infrastructure containers behind Docker Compose, optional viewer profiles for inspection:

```mermaid
flowchart LR
  subgraph host["Dev host (WSL2 / Linux / macOS)"]
    direction TB
    shell["portal-shell<br/><code>:4200</code>"]
    admin["portal-admin<br/><code>:4300</code>"]
    bff["portal-bff<br/><code>:3000</code>"]
    docs["docs site<br/><code>:5173</code><br/>(VitePress)"]
  end

  subgraph compose["infra/local/ (Docker Compose)"]
    direction TB
    pg[("Postgres<br/><code>:5432</code>")]
    redis[("Redis<br/><code>:6379</code>")]
    otel["OTel Collector<br/><code>:4318</code>"]
    subgraph obs["observability profile"]
      jaeger["Jaeger UI<br/><code>:16686</code>"]
    end
    subgraph dbt["dbtools profile"]
      pgweb["pgweb<br/><code>:8081</code>"]
    end
  end

  shell -. "<code>/api/*</code>" .-> bff
  admin -. "<code>/api/admin/*</code>" .-> bff
  bff --> pg
  bff --> redis
  bff -- "OTLP" --> otel
  shell -- "OTLP/HTTP<br/>spans" --> otel
  admin -- "OTLP/HTTP<br/>spans" --> otel
  otel --> jaeger
  pgweb --- pg
```

The docs site lives off to the side — it has no runtime dependency on the BFF or the infra stack and only reads files under `docs/`. Same goes for the SPAs from `portal-shell` to `portal-admin`: each runs its own dev server with its own port, talks to the same BFF, but holds a **distinct session cookie** (per [ADR-0020](decisions/0020-portal-admin-app.md) §"Sessions — distinct from `portal-shell`").

---

## 4. Daily commands

### Run the apps

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

All three can run in parallel in separate terminals; the SPAs proxy API calls to the BFF in dev. Each SPA holds its own cookie / session — signing in to one does not authenticate the other (per [ADR-0020](decisions/0020-portal-admin-app.md)).

### Test

```bash
pnpm nx test portal-shell         # Vitest (single run; --configuration=watch for watch mode)
pnpm nx test portal-admin         # Vitest
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 test file:

- **Vitest projects** (`portal-shell`, `portal-admin`, `feature-auth`, `shared-*`): use a positional path filter — `pnpm nx test portal-shell --skip-nx-cache src/app/app.spec.ts`. `--testFile` is not accepted by the Nx vitest executor (`'testFile' is not found in schema`).
- **Jest projects** (`portal-bff`): `pnpm nx test portal-bff --testPathPattern=src/auth/auth.service`.

### Lint, type-check, format

```bash
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

```bash
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

```bash
# Component (specify the SPA — `portal-shell` or `portal-admin`)
pnpm nx g @nx/angular:component <name> --project=portal-shell --standalone
pnpm nx g @nx/angular:component <name> --project=portal-admin --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 — usable by either SPA)
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.

### Documentation site

`docs/**/*.md` renders as a VitePress site per [ADR-0022](decisions/0022-docs-site-vitepress.md). No Nx project for it — pure pnpm scripts:

```bash
pnpm docs:dev      # http://localhost:5173 (HMR — edit a .md, see it live)
pnpm docs:build    # static site → docs/.vitepress/dist/
pnpm docs:preview  # serve the built site locally to smoke-test
```

Adding a new ADR is a single-file change: drop `docs/decisions/NNNN-kebab-title.md` (MADR 4.0.0 frontmatter, see [`template.md`](decisions/template.md)) and the sidebar auto-lists it. Update [`decisions/README.md`](decisions/README.md) in the same PR so the curated tag-grouped index stays in sync.

### Prisma

```bash
# 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:

```bash
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. Observability dev-loop

The BFF and the SPA are both wired with OpenTelemetry tracing and structured Pino logging (per [ADR-0012](decisions/0012-observability-pino-opentelemetry.md)). This section is the practical guide to using them while debugging — finding a trace, reading the logs that go with it, untangling a slow request.

### How a click becomes a trace

```mermaid
sequenceDiagram
  actor U as User
  participant SPA as portal-shell
  participant BFF as portal-bff
  participant Pino as BFF stdout (Pino)
  participant OC as OTel Collector
  participant J as Jaeger UI

  U->>SPA: click
  Note over SPA: span `user_interaction`<br/>(root, generates trace_id)
  SPA->>BFF: fetch /api/foo<br/>traceparent: 00-<trace_id>-<span_id>-01
  Note over SPA: span `fetch` (child)
  Note over BFF: span `HTTP GET /api/foo`<br/>(continues same trace)
  BFF->>Pino: log line<br/>`{ ...payload, trace_id, span_id, request_id }`
  BFF-->>SPA: 200 OK
  SPA-)OC: OTLP/HTTP batch (every ~5 s)
  BFF-)OC: OTLP batch
  OC-)J: forward spans
  Note over J: trace_id matches the one in Pino<br/>full nested timeline visible
```

The single point of correlation between the two pillars is **`trace_id`**: Jaeger's URL bar carries it (`/trace/<hex>`), and `@opentelemetry/instrumentation-pino` injects the same value on every BFF log line emitted inside that request's scope. Grep one, navigate the other.

### Bring up the observability stack

Traces land in the Jaeger UI bundled in `infra/local/dev.compose.yml` under the `observability` profile. The OpenTelemetry Collector runs in the core profile; without Jaeger active, traces still go to the Collector but nothing visualises them — they appear as warnings in `dev.sh logs otel-collector` and are dropped after the buffer fills.

```bash
./infra/local/dev.sh up observability    # core stack + Jaeger
pnpm nx serve portal-bff                 # http://localhost:3000
pnpm nx serve portal-shell               # http://localhost:4200
```

| Endpoint               | Purpose                                                                                 |
| ---------------------- | --------------------------------------------------------------------------------------- |
| http://localhost:16686 | Jaeger UI — search and explore traces                                                   |
| http://localhost:4318  | OTLP/HTTP receiver on the Collector — both the BFF and the SPA push spans here directly |
| (BFF stdout)           | Pino logs — JSON in prod, `pino-pretty` colourised one-liners in dev                    |
| (Browser DevTools)     | SPA spans — visible in Network as POSTs to `/v1/traces`                                 |

### Reading a trace in Jaeger

1. Open http://localhost:16686.
2. Pick the **Service** dropdown — `portal-bff` for backend traces, `portal-shell` for SPA traces. After a fetch SPA → BFF, the trace shows up under whichever service initiated the root span (`portal-shell` if the user clicked, `portal-bff` if it was a direct curl).
3. **Find Traces** lists recent traces; click one to drill in. Each row in the timeline view is a span. Spans are nested by parent-child: `document_load` → `fetch` → `HTTP GET /api/...` → `prisma.findMany` → `pg.SELECT`.
4. Click a span to see its attributes, events, and resource. Useful keys to look at:
   - `http.method`, `http.status_code`, `http.url` — request shape
   - `db.statement`, `db.system` — actual SQL run by Prisma
   - `service.name` / `service.version` — which app emitted the span
   - `trace_id` (top of the trace) — what to grep in the logs

### Correlating a trace with the BFF logs

Every Pino log line emitted by the BFF during a traced request carries a `trace_id` and `span_id` field, injected automatically by `@opentelemetry/instrumentation-pino`. Concretely:

```bash
# 1. Pick a trace_id in the Jaeger URL bar (the long hex string after /trace/).
# 2. Grep the BFF stdout — capture from the serve-portal-bff terminal,
#    or pipe the dev server output to a file.
pnpm nx serve portal-bff 2>&1 | tee /tmp/portal-bff.log
grep '"trace_id":"<the hex>"' /tmp/portal-bff.log
```

In production the same field exists on every JSON log line; the log aggregator's UI can filter by it. Same trace, same `trace_id`, all the lines.

The `request_id` field (per-request UUID stored in `nestjs-cls`) is also on every line — useful for filtering when you don't have a trace handy yet.

### Reading SPA spans from the browser

The SPA pushes its spans straight to the Collector via OTLP/HTTP. Two ways to inspect what gets sent:

- **DevTools → Network tab**: filter on `localhost:4318/v1/traces`. Each entry is a batch of spans encoded as JSON. Useful for diagnosing "why doesn't my span show up" — if the POST never happens, the span never left.
- **Jaeger UI → service `portal-shell`**: same data, after the Collector forwards it. End-to-end view.

The auto-instrumentations in v1 cover:

- `document_load` — one span per page load, with sub-spans for DNS, TCP, request/response, DOM
- `fetch` — one span per outgoing fetch, with W3C `traceparent` propagated to the BFF (so the BFF picks up the same trace id and emits child spans)
- `user_interaction` — clicks, keypresses, submits

### Common gotchas

- **No spans appear in Jaeger but the Collector logs receive them**. The `observability` profile probably wasn't active when you brought the stack up. `./infra/local/dev.sh status` should list `apf-portal-jaeger`. If not, `./infra/local/dev.sh up observability`.
- **CORS errors in DevTools when the SPA POSTs to `/v1/traces`**. The Collector's `cors.allowed_origins` in `infra/local/otel-collector.yaml` must include the SPA dev origin (`http://localhost:4200`). It does by default; if you're serving on a non-default port, add it there and re-up the Collector.
- **`document_load` span appears but `fetch` spans do not propagate to the BFF as children**. Two failure modes:
  - The BFF's CORS allowlist for `traceparent` / `tracestate` is missing — see `apps/portal-bff/src/main.ts` `enableCors`.
  - The fetch URL doesn't match `propagateTraceHeaderCorsUrls` in `apps/portal-shell/src/observability/tracing.ts`. The default regex matches `localhost:3000`; non-default BFF ports need to be added.
- **Logs come out raw JSON in dev instead of `pino-pretty`**. `NODE_ENV` is set to `production` somewhere upstream (Nx's serve target normally exports `development`). Check `process.env.NODE_ENV` from inside the BFF.
- **`nx serve portal-bff` errors with `EADDRINUSE :::3000`**. A previous BFF process didn't shut down cleanly. `pkill -f portal-bff` or `ss -ltnp | grep :3000` + `kill <PID>`.

### What's not in v1

A few things ADR-0012 sketches that aren't wired yet, in case you go looking:

- CLS keys `session_id`, `user_id_hash`, `audience` — placeholders only; populated by guards/interceptors when ADR-0009 (auth) and ADR-0010 (sessions) land.
- Pino `redact` list for PII — wired with the first DTO carrying redactable data.
- Custom domain spans (`tracer.startActiveSpan('user.create', …)`) — added per service method as the corresponding feature ships. On the SPA side, custom spans across `await` need explicit `context.with(...)` since the workspace is zoneless.
- Production OTLP backend (Tempo / Loki / similar) — phase 3b on-prem ADR. The current setup is dev-only; the Collector's `debug` exporter dumps everything to stdout, useful for local but not what gets pointed at a real backend.

---

## 6. Dependency updates (Renovate)

[Renovate](https://docs.renovatebot.com/) runs as a scheduled workflow ([`.gitea/workflows/renovate.yml`](../.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`](../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 <code v-pre>${{ github.\* }}</code> 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](decisions/0017-performance-budgets-lighthouse-ci.md) — 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.
- **Patch / pin / digest / lockfile-maintenance updates auto-merge** as soon as their CI is green. The bot opens the PR, CI runs, Renovate squashes it into `main` without waiting for a human. They appear briefly in the dashboard's "Open" section while CI runs, then disappear into the merged log. Failures stay open for triage. **Minor and major** updates remain manual.
- Don't merge minor PRs 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.

### Transitive vulnerabilities — `pnpm.overrides`

Renovate's vulnerability remediation handles **direct** `package.json` deps. For **transitive** vulnerabilities (a vulnerable package pulled in by a healthy direct dep), Renovate stays silent in the dashboard — the v40 image doesn't write `pnpm.overrides` automatically. The manual pattern lives in [`package.json`](../package.json) → `pnpm.overrides`, version-selector form:

```jsonc
"esbuild@<0.25.0": ">=0.25.0",
"vite@<6.4.2": ">=6.4.2",
```

Two properties of this form worth knowing:

1. **The override is gated on the vulnerable range** — it only kicks in when the resolved version is < the threshold, becoming a no-op the day the underlying dep's own bump ships a patched version.
2. **pnpm overrides bypass peer constraints** — if the parent dep declares `vite ^5.0.0` but no patched 5.x exists, pnpm will pick a patched 6.x/7.x. Verify the chantier still builds before merging the override.

The first instance of this pattern is preserved in [#159](https://git.unespace.com/julien/apf_portal/pulls/159) (vite + esbuild via VitePress 1.6.4). Next time `pnpm audit` fails with a transitive vuln and Renovate is silent, this is the playbook.

---

## 7. Conventional commit cycle

1. Branch from `main` with a short slug:

   ```bash
   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](decisions/0015-cicd-gitea-actions.md)).

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** — 1–3 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.

---

## 8. Where to look

| Question                                    | Doc                                                                          |
| ------------------------------------------- | ---------------------------------------------------------------------------- |
| Project rules and the why behind them       | [CLAUDE.md](../CLAUDE.md)                                                    |
| All ADRs (decisions index)                  | [docs/decisions/README.md](decisions/README.md)                              |
| Initial environment setup (Zsh, Node, pnpm) | [docs/setup/](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) |

---

## 9. Sections to come — roadmap by phase

As features for later phases land, the corresponding sections below are filled in directly. Entries are split between **code shipped — doc to write** (the work is on `main`, only the prose is missing) and **not yet** (neither code nor doc).

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](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.

### Code shipped — doc to write

| Future section                        | Code shipped in                                                                                                          | What the section will cover                                                                                                                                                                                                                |
| ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **Auth dev-loop**                     | [ADR-0009](decisions/0009-auth-flow-oidc-pkce-msal-node.md) implementation series (PRs #100s)                            | Microsoft 365 Developer tenant configuration, MSAL Node connection, OIDC code-flow walkthrough, switching between dev and prod-like tenants.                                                                                               |
| **Session inspection**                | [ADR-0010](decisions/0010-session-management-redis.md) implementation                                                    | Reading the Redis session store in dev (`redis-cli`), decrypting the AES-GCM `tokens` blob with the dev `SESSION_ENCRYPTION_KEY`, force-logout patterns.                                                                                   |
| **MFA step-up debugging**             | `@RequireMfa()` decorator + freshness guard (#128)                                                                       | Triggering claims-challenge flows when the first consumer route lands, verifying `amrAt` freshness, testing the SPA interceptor on 401 + claims challenge. _(Decorator exists; no consumer route yet — section lands together with that.)_ |
| **Admin surface workflows**           | [ADR-0020](decisions/0020-portal-admin-app.md) skeleton (#127) + audit viewer (#136) + user directory (#140–#142)        | Walking through `/audit` and `/users` viewers, role assignment in Entra (`Portal.Admin`), cross-app navigation.                                                                                                                            |
| **Audit-log inspection workflow**     | [ADR-0013](decisions/0013-audit-trail-separated-postgres-append-only.md) + admin viewer                                  | Querying `audit.events` as `audit_reader`, joining with Pino logs by `trace_id` / `actor_id_hash`, validating the append-only role grants in dev.                                                                                          |
| **Downstream API integration recipe** | [ADR-0014](decisions/0014-downstream-api-access-obo-pattern.md) strategies (OBO #137, signed-assertion + JWKS #138/#139) | Adding a new `DownstreamApiConfig`, choosing the auth strategy, wiring resilience policies, testing with a mocked downstream. _(Strategy layer ready; no v1 consumer yet — section grows with the first.)_                                 |
| **OpenAPI / Scalar dev workflow**     | [#143](https://git.unespace.com/julien/apf_portal/pulls/143)                                                             | Browsing `/api/docs`, annotating new controllers with `@ApiTags` / `@ApiOperation`, exporting the spec for Bruno / Postman, the CSRF caveat when hitting mutating routes from Scalar.                                                      |
| **Capabilities-driven SPA UX**        | `/api/me/capabilities` + cross-app menu links (#151)                                                                     | When to add a new key to `CapabilitiesView`, how the SPA's `CapabilitiesService` consumes it, the "binary boolean, never the raw role" rule.                                                                                               |

### Not yet

| Future section                                                                                                                                                                                     | Triggered by                                                                                                                  |
| -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
| **Component patterns library** — in-house spartan-style components (Angular CDK + Tailwind), a11y notes per component (keyboard model, ARIA, screen-reader expectations).                          | First non-placeholder component in `libs/shared/ui/` beyond `Icon` and `UserMenu`.                                            |
| **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.             | First Playwright e2e suite touching real screens ([ADR-0016](decisions/0016-accessibility-baseline-wcag-aa-targeted-aaa.md)). |
| **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. | First real route added to the critical-routes list ([ADR-0017](decisions/0017-performance-budgets-lighthouse-ci.md)).         |
| **Debugging tips** — Angular DevTools, NestJS inspector, Prisma query log, OTel trace navigation, common gotchas.                                                                                  | Accumulates organically as the team encounters them.                                                                          |
| **Release workflow** — tag-driven release, what `release.yml` does, version bumping, changelog generation from Conventional Commits.                                                               | 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](decisions/0015-cicd-gitea-actions.md) get superseded.      | GitLab migration ADR (6–18 months horizon).                                                                                   |
