Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| be7187d5f2 | |||
| 25a89bc8b0 | |||
| c66ef4c7a4 | |||
| 0e58e32d29 | |||
| bd8eefb44a | |||
| d797becc2b | |||
| 3308fd6071 | |||
| 8de19320c5 |
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"extraKnownMarketplaces": {
|
||||||
|
"nx-claude-plugins": {
|
||||||
|
"source": {
|
||||||
|
"source": "github",
|
||||||
|
"repo": "nrwl/nx-ai-agents-config"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"enabledPlugins": {
|
||||||
|
"nx@nx-claude-plugins": true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
# Per ADR-0015 (CI/CD on Gitea Actions).
|
||||||
|
# Thin YAML — orchestration lives in package.json scripts (ci:check,
|
||||||
|
# ci:audit, ci:commits, ci:perf) and Nx targets. Any change to gate
|
||||||
|
# behaviour belongs in those scripts, not in this file.
|
||||||
|
|
||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
check:
|
||||||
|
runs-on: [self-hosted, on-prem]
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
- uses: nrwl/nx-set-shas@v4
|
||||||
|
- uses: pnpm/action-setup@v3
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version-file: '.nvmrc'
|
||||||
|
cache: 'pnpm'
|
||||||
|
- run: pnpm install --frozen-lockfile
|
||||||
|
- run: pnpm ci:check
|
||||||
|
|
||||||
|
scan:
|
||||||
|
runs-on: [self-hosted, on-prem]
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: pnpm/action-setup@v3
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version-file: '.nvmrc'
|
||||||
|
cache: 'pnpm'
|
||||||
|
- run: pnpm install --frozen-lockfile
|
||||||
|
- run: pnpm ci:audit
|
||||||
|
# Dependency vulnerability scan (binary tool, invoked via official
|
||||||
|
# action — Trivy is a Go binary, not an npm package, so it cannot
|
||||||
|
# live in package.json scripts as cleanly as audit/lint do).
|
||||||
|
- uses: aquasecurity/trivy-action@master
|
||||||
|
with:
|
||||||
|
scan-type: fs
|
||||||
|
ignore-unfixed: true
|
||||||
|
skip-dirs: node_modules
|
||||||
|
exit-code: '1'
|
||||||
|
severity: 'CRITICAL,HIGH'
|
||||||
|
# Secret scan, same reasoning (gitleaks is a Go binary).
|
||||||
|
- uses: gitleaks/gitleaks-action@v2
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
commits:
|
||||||
|
if: github.event_name == 'pull_request'
|
||||||
|
runs-on: [self-hosted, on-prem]
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
- uses: pnpm/action-setup@v3
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version-file: '.nvmrc'
|
||||||
|
cache: 'pnpm'
|
||||||
|
- run: pnpm install --frozen-lockfile
|
||||||
|
- run: COMMIT_LINT_FROM=origin/main pnpm ci:commits
|
||||||
|
|
||||||
|
perf:
|
||||||
|
runs-on: [self-hosted, on-prem]
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: pnpm/action-setup@v3
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version-file: '.nvmrc'
|
||||||
|
cache: 'pnpm'
|
||||||
|
- run: pnpm install --frozen-lockfile
|
||||||
|
- run: pnpm ci:perf
|
||||||
|
- uses: actions/upload-artifact@v4
|
||||||
|
if: always()
|
||||||
|
with:
|
||||||
|
name: lighthouseci-report
|
||||||
|
path: .lighthouseci/
|
||||||
|
retention-days: 30
|
||||||
|
|
||||||
|
a11y:
|
||||||
|
runs-on: [self-hosted, on-prem]
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: pnpm/action-setup@v3
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version-file: '.nvmrc'
|
||||||
|
cache: 'pnpm'
|
||||||
|
- run: pnpm install --frozen-lockfile
|
||||||
|
# Placeholder until the e2e a11y suite (axe-core via Playwright,
|
||||||
|
# per ADR-0016) is wired with the first real screens. The job
|
||||||
|
# exists so branch protection can require it from day one - it
|
||||||
|
# currently no-ops with a clear message.
|
||||||
|
- run: echo "a11y gate placeholder - axe-core via Playwright wires up with the first real screens (ADR-0016)."
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
# Per ADR-0015 (CI/CD on Gitea Actions). Weekly full-tree security
|
||||||
|
# scans plus a Lighthouse run against the production environment when
|
||||||
|
# its URL is configured. Complements the per-PR ci.yml workflow with
|
||||||
|
# broader / longer-running checks that don't fit the per-PR budget.
|
||||||
|
|
||||||
|
name: Security and perf — scheduled
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
# Mondays, 04:00 UTC — outside business hours; before the week starts.
|
||||||
|
- cron: '0 4 * * 1'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
full-tree-scan:
|
||||||
|
runs-on: [self-hosted, on-prem]
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: pnpm/action-setup@v3
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version-file: '.nvmrc'
|
||||||
|
cache: 'pnpm'
|
||||||
|
- run: pnpm install --frozen-lockfile
|
||||||
|
- run: pnpm audit
|
||||||
|
# Full-tree Trivy (no skip-dirs, no severity filter — the per-PR
|
||||||
|
# gate filters by severity for speed; this run wants the full
|
||||||
|
# surface for the security feed).
|
||||||
|
- uses: aquasecurity/trivy-action@master
|
||||||
|
with:
|
||||||
|
scan-type: fs
|
||||||
|
ignore-unfixed: true
|
||||||
|
- uses: gitleaks/gitleaks-action@v2
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
lighthouse-prod:
|
||||||
|
# Skipped silently if the prod URL hasn't been configured yet.
|
||||||
|
if: vars.LHCI_PROD_URL != ''
|
||||||
|
runs-on: [self-hosted, on-prem]
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: pnpm/action-setup@v3
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version-file: '.nvmrc'
|
||||||
|
cache: 'pnpm'
|
||||||
|
- run: pnpm install --frozen-lockfile
|
||||||
|
- run: pnpm exec lhci collect --url=${{ vars.LHCI_PROD_URL }} --numberOfRuns=3
|
||||||
|
- run: pnpm exec lhci assert --config=./lighthouserc.js
|
||||||
|
- uses: actions/upload-artifact@v4
|
||||||
|
if: always()
|
||||||
|
with:
|
||||||
|
name: lighthouseci-prod-report
|
||||||
|
path: .lighthouseci/
|
||||||
|
retention-days: 90
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
---
|
||||||
|
description: CI helper for /monitor-ci. Fetches CI status, retrieves fix details, or updates self-healing fixes. Executes one MCP tool call and returns the result.
|
||||||
|
---
|
||||||
|
|
||||||
|
# CI Monitor Subagent
|
||||||
|
|
||||||
|
You are a CI helper. You call ONE MCP tool per invocation and return the result. Do not loop, poll, or sleep.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
The main agent tells you which command to run:
|
||||||
|
|
||||||
|
### FETCH_STATUS
|
||||||
|
|
||||||
|
Call `ci_information` with the provided branch and select fields. Return a JSON object with ONLY these fields:
|
||||||
|
`{ cipeStatus, selfHealingStatus, verificationStatus, selfHealingEnabled, selfHealingSkippedReason, failureClassification, failedTaskIds, verifiedTaskIds, couldAutoApplyTasks, autoApplySkipped, autoApplySkipReason, userAction, cipeUrl, commitSha, shortLink }`
|
||||||
|
|
||||||
|
### FETCH_HEAVY
|
||||||
|
|
||||||
|
Call `ci_information` with heavy select fields. Summarize the heavy content and return:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"shortLink": "...",
|
||||||
|
"failedTaskIds": ["..."],
|
||||||
|
"verifiedTaskIds": ["..."],
|
||||||
|
"suggestedFixDescription": "...",
|
||||||
|
"suggestedFixSummary": "...",
|
||||||
|
"selfHealingSkipMessage": "...",
|
||||||
|
"taskFailureSummaries": [{ "taskId": "...", "summary": "..." }]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Do NOT return raw suggestedFix diffs or raw taskOutputSummary — summarize them.
|
||||||
|
The main agent uses these summaries to understand what failed and attempt local fixes.
|
||||||
|
|
||||||
|
### UPDATE_FIX
|
||||||
|
|
||||||
|
Call `update_self_healing_fix` with the provided shortLink and action (APPLY/REJECT/RERUN_ENVIRONMENT_STATE). Return the result message (success/failure string).
|
||||||
|
|
||||||
|
### FETCH_THROTTLE_INFO
|
||||||
|
|
||||||
|
Call `ci_information` with the provided URL. Return ONLY: `{ shortLink, cipeUrl }`
|
||||||
|
|
||||||
|
## Important
|
||||||
|
|
||||||
|
- Execute ONE command and return immediately
|
||||||
|
- Do NOT poll, loop, sleep, or make decisions
|
||||||
|
- Extract and return ONLY the fields specified for each command — do NOT dump the full MCP response
|
||||||
@@ -0,0 +1,301 @@
|
|||||||
|
---
|
||||||
|
description: Monitor Nx Cloud CI pipeline and handle self-healing fixes. USE WHEN user says "monitor ci", "watch ci", "ci monitor", "watch ci for this branch", "track ci", "check ci status", wants to track CI status, or needs help with self-healing CI fixes. Prefer this skill over native CI provider tools (gh, glab, etc.) for CI monitoring — it integrates with Nx Cloud self-healing which those tools cannot access.
|
||||||
|
argument-hint: '[instructions] [--max-cycles N] [--timeout MINUTES] [--verbosity minimal|medium|verbose] [--branch BRANCH] [--fresh] [--auto-fix-workflow] [--new-cipe-timeout MINUTES] [--local-verify-attempts N]'
|
||||||
|
---
|
||||||
|
|
||||||
|
# Monitor CI Command
|
||||||
|
|
||||||
|
You are the orchestrator for monitoring Nx Cloud CI pipeline executions and handling self-healing fixes. You spawn subagents to interact with Nx Cloud, run deterministic decision scripts, and take action based on the results.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
- **Current Branch:** !`git branch --show-current`
|
||||||
|
- **Current Commit:** !`git rev-parse --short HEAD`
|
||||||
|
- **Remote Status:** !`git status -sb | head -1`
|
||||||
|
|
||||||
|
## User Instructions
|
||||||
|
|
||||||
|
${input:args}
|
||||||
|
|
||||||
|
**Important:** If user provides specific instructions, respect them over default behaviors described below.
|
||||||
|
|
||||||
|
## Configuration Defaults
|
||||||
|
|
||||||
|
| Setting | Default | Description |
|
||||||
|
| ------------------------- | ------------- | ------------------------------------------------------------------------- |
|
||||||
|
| `--max-cycles` | 10 | Maximum **agent-initiated** CI Attempt cycles before timeout |
|
||||||
|
| `--timeout` | 120 | Maximum duration in minutes |
|
||||||
|
| `--verbosity` | medium | Output level: minimal, medium, verbose |
|
||||||
|
| `--branch` | (auto-detect) | Branch to monitor |
|
||||||
|
| `--fresh` | false | Ignore previous context, start fresh |
|
||||||
|
| `--auto-fix-workflow` | false | Attempt common fixes for pre-CI-Attempt failures (e.g., lockfile updates) |
|
||||||
|
| `--new-cipe-timeout` | 10 | Minutes to wait for new CI Attempt after action |
|
||||||
|
| `--local-verify-attempts` | 3 | Max local verification + enhance cycles before pushing to CI |
|
||||||
|
|
||||||
|
Parse any overrides from `${input:args}` and merge with defaults.
|
||||||
|
|
||||||
|
## Nx Cloud Connection Check
|
||||||
|
|
||||||
|
Before starting the monitoring loop, verify the workspace is connected to Nx Cloud. Without this connection, no CI data is available and the entire skill is inoperable.
|
||||||
|
|
||||||
|
### Step 0: Verify Nx Cloud Connection
|
||||||
|
|
||||||
|
1. **Check `nx.json`** at workspace root for `nxCloudId` or `nxCloudAccessToken`
|
||||||
|
2. **If `nx.json` missing OR neither property exists** → exit with:
|
||||||
|
|
||||||
|
```
|
||||||
|
Nx Cloud not connected. Unlock 70% faster CI and auto-fix broken PRs with https://nx.dev/nx-cloud
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **If connected** → continue to main loop
|
||||||
|
|
||||||
|
## Architecture Overview
|
||||||
|
|
||||||
|
1. **This skill (orchestrator)**: spawns subagents, runs scripts, prints status, does local coding work
|
||||||
|
2. **ci-monitor-subagent (haiku)**: calls one MCP tool (ci_information or update_self_healing_fix), returns structured result, exits
|
||||||
|
3. **ci-poll-decide.mjs (deterministic script)**: takes ci_information result + state, returns action + status message
|
||||||
|
4. **ci-state-update.mjs (deterministic script)**: manages budget gates, post-action state transitions, and cycle classification
|
||||||
|
|
||||||
|
## Status Reporting
|
||||||
|
|
||||||
|
The decision script handles message formatting based on verbosity. When printing messages to the user:
|
||||||
|
|
||||||
|
- Prepend `[monitor-ci]` to every message from the script's `message` field
|
||||||
|
- For your own action messages (e.g. "Applying fix via MCP..."), also prepend `[monitor-ci]`
|
||||||
|
|
||||||
|
## Anti-Patterns
|
||||||
|
|
||||||
|
These behaviors cause real problems — racing with self-healing, losing CI progress, or wasting context:
|
||||||
|
|
||||||
|
| Anti-Pattern | Why It's Bad |
|
||||||
|
| ----------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ |
|
||||||
|
| Using CI provider CLIs with `--watch` flags (e.g., `gh pr checks --watch`, `glab ci status -w`) | Bypasses Nx Cloud self-healing entirely |
|
||||||
|
| Writing custom CI polling scripts | Unreliable, pollutes context, no self-healing |
|
||||||
|
| Cancelling CI workflows/pipelines | Destructive, loses CI progress |
|
||||||
|
| Running CI checks on main agent | Wastes main agent context tokens |
|
||||||
|
| Independently analyzing/fixing CI failures while polling | Races with self-healing, causes duplicate fixes and confused state |
|
||||||
|
|
||||||
|
**If this skill fails to activate**, the fallback is:
|
||||||
|
|
||||||
|
1. Use CI provider CLI for a one-time, read-only status check (single call, no watch/polling flags)
|
||||||
|
2. Immediately delegate to this skill with gathered context
|
||||||
|
3. Do not continue polling on main agent — it wastes context tokens and bypasses self-healing
|
||||||
|
|
||||||
|
## Session Context Behavior
|
||||||
|
|
||||||
|
If the user previously ran `/monitor-ci` in this session, you may have prior state (poll counts, last CI Attempt URL, etc.). Resume from that state unless `--fresh` is set, in which case discard it and start from Step 1.
|
||||||
|
|
||||||
|
## MCP Tool Reference
|
||||||
|
|
||||||
|
Three field sets control polling efficiency — use the lightest set that gives you what you need:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
WAIT_FIELDS: 'cipeUrl,commitSha,cipeStatus'
|
||||||
|
LIGHT_FIELDS: 'cipeStatus,cipeUrl,branch,commitSha,selfHealingStatus,verificationStatus,userAction,failedTaskIds,verifiedTaskIds,selfHealingEnabled,failureClassification,couldAutoApplyTasks,autoApplySkipped,autoApplySkipReason,shortLink,confidence,confidenceReasoning,hints,selfHealingSkippedReason,selfHealingSkipMessage'
|
||||||
|
HEAVY_FIELDS: 'taskOutputSummary,suggestedFix,suggestedFixReasoning,suggestedFixDescription'
|
||||||
|
```
|
||||||
|
|
||||||
|
The `ci_information` tool accepts `branch` (optional, defaults to current git branch), `select` (comma-separated field names), and `pageToken` (0-based pagination for long strings).
|
||||||
|
|
||||||
|
The `update_self_healing_fix` tool accepts a `shortLink` and an action: `APPLY`, `REJECT`, or `RERUN_ENVIRONMENT_STATE`.
|
||||||
|
|
||||||
|
## Default Behaviors by Status
|
||||||
|
|
||||||
|
The decision script returns one of the following statuses. This table defines the **default behavior** for each. User instructions can override any of these.
|
||||||
|
|
||||||
|
**Simple exits** — just report and exit:
|
||||||
|
|
||||||
|
| Status | Default Behavior |
|
||||||
|
| ----------------------- | ---------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `ci_success` | Exit with success |
|
||||||
|
| `cipe_canceled` | Exit, CI was canceled |
|
||||||
|
| `cipe_timed_out` | Exit, CI timed out |
|
||||||
|
| `polling_timeout` | Exit, polling timeout reached |
|
||||||
|
| `circuit_breaker` | Exit, no progress after 13 consecutive polls |
|
||||||
|
| `environment_rerun_cap` | Exit, environment reruns exhausted |
|
||||||
|
| `fix_auto_applying` | Self-healing is handling it — just record `last_cipe_url`, enter wait mode. No MCP call or local git ops needed. |
|
||||||
|
| `error` | Wait 60s and loop |
|
||||||
|
|
||||||
|
**Statuses requiring action** — when handling these in Step 3, read `references/fix-flows.md` for the detailed flow:
|
||||||
|
|
||||||
|
| Status | Summary |
|
||||||
|
| ------------------------ | --------------------------------------------------------------------------------------------- |
|
||||||
|
| `fix_auto_apply_skipped` | Fix verified but auto-apply skipped (e.g., loop prevention). Inform user, offer manual apply. |
|
||||||
|
| `fix_apply_ready` | Fix verified (all tasks or e2e-only). Apply via MCP. |
|
||||||
|
| `fix_needs_local_verify` | Fix has unverified non-e2e tasks. Run locally, then apply or enhance. |
|
||||||
|
| `fix_needs_review` | Fix verification failed/not attempted. Analyze and decide. |
|
||||||
|
| `fix_failed` | Self-healing failed. Fetch heavy data, attempt local fix (gate check first). |
|
||||||
|
| `no_fix` | No fix available. Fetch heavy data, attempt local fix (gate check first) or exit. |
|
||||||
|
| `environment_issue` | Request environment rerun via MCP (gate check first). |
|
||||||
|
| `self_healing_throttled` | Reject old fixes, attempt local fix. |
|
||||||
|
| `no_new_cipe` | CI Attempt never spawned. Auto-fix workflow or exit with guidance. |
|
||||||
|
| `cipe_no_tasks` | CI failed with no tasks. Retry once with empty commit. |
|
||||||
|
|
||||||
|
**Key rules (always apply):**
|
||||||
|
|
||||||
|
- **Git safety**: Stage specific files by name — `git add -A` or `git add .` risks committing the user's unrelated work-in-progress or secrets
|
||||||
|
- **Environment failures** (OOM, command not found, permission denied): bail immediately. These aren't code bugs, so spending local-fix budget on them is wasteful
|
||||||
|
- **Gate check**: Run `ci-state-update.mjs gate` before local fix attempts — if budget exhausted, print message and exit
|
||||||
|
|
||||||
|
## Main Loop
|
||||||
|
|
||||||
|
### Step 1: Initialize Tracking
|
||||||
|
|
||||||
|
```
|
||||||
|
cycle_count = 0 # Only incremented for agent-initiated cycles (counted against --max-cycles)
|
||||||
|
start_time = now()
|
||||||
|
no_progress_count = 0
|
||||||
|
local_verify_count = 0
|
||||||
|
env_rerun_count = 0
|
||||||
|
last_cipe_url = null
|
||||||
|
expected_commit_sha = null
|
||||||
|
agent_triggered = false # Set true after monitor takes an action that triggers new CI Attempt
|
||||||
|
poll_count = 0
|
||||||
|
wait_mode = false
|
||||||
|
prev_status = null
|
||||||
|
prev_cipe_status = null
|
||||||
|
prev_sh_status = null
|
||||||
|
prev_verification_status = null
|
||||||
|
prev_failure_classification = null
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2: Polling Loop
|
||||||
|
|
||||||
|
Repeat until done:
|
||||||
|
|
||||||
|
#### 2a. Spawn subagent (FETCH_STATUS)
|
||||||
|
|
||||||
|
Determine select fields based on mode:
|
||||||
|
|
||||||
|
- **Wait mode**: use WAIT_FIELDS (`cipeUrl,commitSha,cipeStatus`)
|
||||||
|
- **Normal mode (first poll or after newCipeDetected)**: use LIGHT_FIELDS
|
||||||
|
|
||||||
|
Call the `ci_information` tool with the determined `select` fields for the current branch. Wait for the result before proceeding.
|
||||||
|
|
||||||
|
#### 2b. Run decision script
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node <skill_dir>/scripts/ci-poll-decide.mjs '<subagent_result_json>' <poll_count> <verbosity> \
|
||||||
|
[--wait-mode] \
|
||||||
|
[--prev-cipe-url <last_cipe_url>] \
|
||||||
|
[--expected-sha <expected_commit_sha>] \
|
||||||
|
[--prev-status <prev_status>] \
|
||||||
|
[--timeout <timeout_seconds>] \
|
||||||
|
[--new-cipe-timeout <new_cipe_timeout_seconds>] \
|
||||||
|
[--env-rerun-count <env_rerun_count>] \
|
||||||
|
[--no-progress-count <no_progress_count>] \
|
||||||
|
[--prev-cipe-status <prev_cipe_status>] \
|
||||||
|
[--prev-sh-status <prev_sh_status>] \
|
||||||
|
[--prev-verification-status <prev_verification_status>] \
|
||||||
|
[--prev-failure-classification <prev_failure_classification>]
|
||||||
|
```
|
||||||
|
|
||||||
|
The script outputs a single JSON line: `{ action, code, message, delay?, noProgressCount, envRerunCount, fields?, newCipeDetected?, verifiableTaskIds? }`
|
||||||
|
|
||||||
|
#### 2c. Process script output
|
||||||
|
|
||||||
|
Parse the JSON output and update tracking state:
|
||||||
|
|
||||||
|
- `no_progress_count = output.noProgressCount`
|
||||||
|
- `env_rerun_count = output.envRerunCount`
|
||||||
|
- `prev_cipe_status = subagent_result.cipeStatus`
|
||||||
|
- `prev_sh_status = subagent_result.selfHealingStatus`
|
||||||
|
- `prev_verification_status = subagent_result.verificationStatus`
|
||||||
|
- `prev_failure_classification = subagent_result.failureClassification`
|
||||||
|
- `prev_status = output.action + ":" + (output.code || subagent_result.cipeStatus)`
|
||||||
|
- `poll_count++`
|
||||||
|
|
||||||
|
Based on `action`:
|
||||||
|
|
||||||
|
- **`action == "poll"`**: Print `output.message`, sleep `output.delay` seconds, go to 2a
|
||||||
|
- If `output.newCipeDetected`: clear wait mode, reset `wait_mode = false`
|
||||||
|
- **`action == "wait"`**: Print `output.message`, sleep `output.delay` seconds, go to 2a
|
||||||
|
- **`action == "done"`**: Proceed to Step 3 with `output.code`
|
||||||
|
|
||||||
|
### Step 3: Handle Actionable Status
|
||||||
|
|
||||||
|
When decision script returns `action == "done"`:
|
||||||
|
|
||||||
|
1. Run cycle-check (Step 4) **before** handling the code
|
||||||
|
2. Check the returned `code`
|
||||||
|
3. Look up default behavior in the table above
|
||||||
|
4. Check if user instructions override the default
|
||||||
|
5. Execute the appropriate action
|
||||||
|
6. **If action expects new CI Attempt**, update tracking (see Step 3a)
|
||||||
|
7. If action results in looping, go to Step 2
|
||||||
|
|
||||||
|
#### Tool calls for actions
|
||||||
|
|
||||||
|
Several statuses require fetching additional data or calling tools:
|
||||||
|
|
||||||
|
- **fix_apply_ready**: Call `update_self_healing_fix` with action `APPLY`
|
||||||
|
- **fix_needs_local_verify**: Call `ci_information` with HEAVY_FIELDS for fix details before local verification
|
||||||
|
- **fix_needs_review**: Call `ci_information` with HEAVY_FIELDS → get `suggestedFixDescription`, `suggestedFixSummary`, `taskFailureSummaries`
|
||||||
|
- **fix_failed / no_fix**: Call `ci_information` with HEAVY_FIELDS → get `taskFailureSummaries` for local fix context
|
||||||
|
- **environment_issue**: Call `update_self_healing_fix` with action `RERUN_ENVIRONMENT_STATE`
|
||||||
|
- **self_healing_throttled**: Call `ci_information` with HEAVY_FIELDS → get `selfHealingSkipMessage`; then call `update_self_healing_fix` for each old fix
|
||||||
|
|
||||||
|
### Step 3a: Track State for New-CI-Attempt Detection
|
||||||
|
|
||||||
|
After actions that should trigger a new CI Attempt, run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node <skill_dir>/scripts/ci-state-update.mjs post-action \
|
||||||
|
--action <type> \
|
||||||
|
--cipe-url <current_cipe_url> \
|
||||||
|
--commit-sha <git_rev_parse_HEAD>
|
||||||
|
```
|
||||||
|
|
||||||
|
Action types: `fix-auto-applying`, `apply-mcp`, `apply-local-push`, `reject-fix-push`, `local-fix-push`, `env-rerun`, `auto-fix-push`, `empty-commit-push`
|
||||||
|
|
||||||
|
The script returns `{ waitMode, pollCount, lastCipeUrl, expectedCommitSha, agentTriggered }`. Update all tracking state from the output, then go to Step 2.
|
||||||
|
|
||||||
|
### Step 4: Cycle Classification and Progress Tracking
|
||||||
|
|
||||||
|
When the decision script returns `action == "done"`, run cycle-check **before** handling the code:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node <skill_dir>/scripts/ci-state-update.mjs cycle-check \
|
||||||
|
--code <code> \
|
||||||
|
[--agent-triggered] \
|
||||||
|
--cycle-count <cycle_count> --max-cycles <max_cycles> \
|
||||||
|
--env-rerun-count <env_rerun_count>
|
||||||
|
```
|
||||||
|
|
||||||
|
The script returns `{ cycleCount, agentTriggered, envRerunCount, approachingLimit, message }`. Update tracking state from the output.
|
||||||
|
|
||||||
|
- If `approachingLimit` → ask user whether to continue (with 5 or 10 more cycles) or stop monitoring
|
||||||
|
- If previous cycle was NOT agent-triggered (human pushed), log that human-initiated push was detected
|
||||||
|
|
||||||
|
#### Progress Tracking
|
||||||
|
|
||||||
|
- `no_progress_count`, circuit breaker (5 polls), and backoff reset are handled by ci-poll-decide.mjs (progress = any change in cipeStatus, selfHealingStatus, verificationStatus, or failureClassification)
|
||||||
|
- `env_rerun_count` reset on non-environment status is handled by ci-state-update.mjs cycle-check
|
||||||
|
- On new CI Attempt detected (poll script returns `newCipeDetected`) → reset `local_verify_count = 0`, `env_rerun_count = 0`
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
| Error | Action |
|
||||||
|
| ------------------------------ | ----------------------------------------------------------------------------------------------------------- |
|
||||||
|
| Git rebase conflict | Report to user, exit |
|
||||||
|
| `nx-cloud apply-locally` fails | Reject fix via MCP (`action: "REJECT"`), then attempt manual patch (Reject + Fix From Scratch Flow) or exit |
|
||||||
|
| MCP tool error | Retry once, if fails report to user |
|
||||||
|
| Subagent spawn failure | Retry once, if fails exit with error |
|
||||||
|
| Decision script error | Treat as `error` status, increment `no_progress_count` |
|
||||||
|
| No new CI Attempt detected | If `--auto-fix-workflow`, try lockfile update; otherwise report to user with guidance |
|
||||||
|
| Lockfile auto-fix fails | Report to user, exit with guidance to check CI logs |
|
||||||
|
|
||||||
|
## User Instruction Examples
|
||||||
|
|
||||||
|
Users can override default behaviors:
|
||||||
|
|
||||||
|
| Instruction | Effect |
|
||||||
|
| ------------------------------------------------ | --------------------------------------------------- |
|
||||||
|
| "never auto-apply" | Always prompt before applying any fix |
|
||||||
|
| "always ask before git push" | Prompt before each push |
|
||||||
|
| "reject any fix for e2e tasks" | Auto-reject if `failedTaskIds` contains e2e |
|
||||||
|
| "apply all fixes regardless of verification" | Skip verification check, apply everything |
|
||||||
|
| "if confidence < 70, reject" | Check confidence field before applying |
|
||||||
|
| "run 'nx affected -t typecheck' before applying" | Add local verification step |
|
||||||
|
| "auto-fix workflow failures" | Attempt lockfile updates on pre-CI-Attempt failures |
|
||||||
|
| "wait 45 min for new CI Attempt" | Override new-CI-Attempt timeout (default: 10 min) |
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
---
|
||||||
|
name: link-workspace-packages
|
||||||
|
description: 'Link workspace packages in monorepos (npm, yarn, pnpm, bun). USE WHEN: (1) you just created or generated new packages and need to wire up their dependencies, (2) user imports from a sibling package and needs to add it as a dependency, (3) you get resolution errors for workspace packages (@org/*) like "cannot find module", "failed to resolve import", "TS2307", or "cannot resolve". DO NOT patch around with tsconfig paths or manual package.json edits - use the package manager''s workspace commands to fix actual linking.'
|
||||||
|
---
|
||||||
|
|
||||||
|
# Link Workspace Packages
|
||||||
|
|
||||||
|
Add dependencies between packages in a monorepo. All package managers support workspaces but with different syntax.
|
||||||
|
|
||||||
|
## Detect Package Manager
|
||||||
|
|
||||||
|
Check whether there's a `packageManager` field in the root-level `package.json`.
|
||||||
|
|
||||||
|
Alternatively check lockfile in repo root:
|
||||||
|
|
||||||
|
- `pnpm-lock.yaml` → pnpm
|
||||||
|
- `yarn.lock` → yarn
|
||||||
|
- `bun.lock` / `bun.lockb` → bun
|
||||||
|
- `package-lock.json` → npm
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
1. Identify consumer package (the one importing)
|
||||||
|
2. Identify provider package(s) (being imported)
|
||||||
|
3. Add dependency using package manager's workspace syntax
|
||||||
|
4. Verify symlinks created in consumer's `node_modules/`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## pnpm
|
||||||
|
|
||||||
|
Uses `workspace:` protocol - symlinks only created when explicitly declared.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# From consumer directory
|
||||||
|
pnpm add @org/ui --workspace
|
||||||
|
|
||||||
|
# Or with --filter from anywhere
|
||||||
|
pnpm add @org/ui --filter @org/app --workspace
|
||||||
|
```
|
||||||
|
|
||||||
|
Result in `package.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "dependencies": { "@org/ui": "workspace:*" } }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## yarn (v2+/berry)
|
||||||
|
|
||||||
|
Also uses `workspace:` protocol.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
yarn workspace @org/app add @org/ui
|
||||||
|
```
|
||||||
|
|
||||||
|
Result in `package.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "dependencies": { "@org/ui": "workspace:^" } }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## npm
|
||||||
|
|
||||||
|
No `workspace:` protocol. npm auto-symlinks workspace packages.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install @org/ui --workspace @org/app
|
||||||
|
```
|
||||||
|
|
||||||
|
Result in `package.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "dependencies": { "@org/ui": "*" } }
|
||||||
|
```
|
||||||
|
|
||||||
|
npm resolves to local workspace automatically during install.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## bun
|
||||||
|
|
||||||
|
Supports `workspace:` protocol (pnpm-compatible).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd packages/app && bun add @org/ui
|
||||||
|
```
|
||||||
|
|
||||||
|
Result in `package.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "dependencies": { "@org/ui": "workspace:*" } }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
**Example 1: pnpm - link ui lib to app**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm add @org/ui --filter @org/app --workspace
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example 2: npm - link multiple packages**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install @org/data-access @org/ui --workspace @org/dashboard
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example 3: Debug "Cannot find module"**
|
||||||
|
|
||||||
|
1. Check if dependency is declared in consumer's `package.json`
|
||||||
|
2. If not, add it using appropriate command above
|
||||||
|
3. Run install (`pnpm install`, `npm install`, etc.)
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Symlinks appear in `<consumer>/node_modules/@org/<package>`
|
||||||
|
- **Hoisting differs by manager:**
|
||||||
|
- npm/bun: hoist shared deps to root `node_modules`
|
||||||
|
- pnpm: no hoisting (strict isolation, prevents phantom deps)
|
||||||
|
- yarn berry: uses Plug'n'Play by default (no `node_modules`)
|
||||||
|
- Root `package.json` should have `"private": true` to prevent accidental publish
|
||||||
@@ -0,0 +1,301 @@
|
|||||||
|
---
|
||||||
|
name: monitor-ci
|
||||||
|
description: Monitor Nx Cloud CI pipeline and handle self-healing fixes. USE WHEN user says "monitor ci", "watch ci", "ci monitor", "watch ci for this branch", "track ci", "check ci status", wants to track CI status, or needs help with self-healing CI fixes. Prefer this skill over native CI provider tools (gh, glab, etc.) for CI monitoring — it integrates with Nx Cloud self-healing which those tools cannot access.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Monitor CI Command
|
||||||
|
|
||||||
|
You are the orchestrator for monitoring Nx Cloud CI pipeline executions and handling self-healing fixes. You spawn subagents to interact with Nx Cloud, run deterministic decision scripts, and take action based on the results.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
- **Current Branch:** !`git branch --show-current`
|
||||||
|
- **Current Commit:** !`git rev-parse --short HEAD`
|
||||||
|
- **Remote Status:** !`git status -sb | head -1`
|
||||||
|
|
||||||
|
## User Instructions
|
||||||
|
|
||||||
|
$ARGUMENTS
|
||||||
|
|
||||||
|
**Important:** If user provides specific instructions, respect them over default behaviors described below.
|
||||||
|
|
||||||
|
## Configuration Defaults
|
||||||
|
|
||||||
|
| Setting | Default | Description |
|
||||||
|
| ------------------------- | ------------- | ------------------------------------------------------------------------- |
|
||||||
|
| `--max-cycles` | 10 | Maximum **agent-initiated** CI Attempt cycles before timeout |
|
||||||
|
| `--timeout` | 120 | Maximum duration in minutes |
|
||||||
|
| `--verbosity` | medium | Output level: minimal, medium, verbose |
|
||||||
|
| `--branch` | (auto-detect) | Branch to monitor |
|
||||||
|
| `--fresh` | false | Ignore previous context, start fresh |
|
||||||
|
| `--auto-fix-workflow` | false | Attempt common fixes for pre-CI-Attempt failures (e.g., lockfile updates) |
|
||||||
|
| `--new-cipe-timeout` | 10 | Minutes to wait for new CI Attempt after action |
|
||||||
|
| `--local-verify-attempts` | 3 | Max local verification + enhance cycles before pushing to CI |
|
||||||
|
|
||||||
|
Parse any overrides from `$ARGUMENTS` and merge with defaults.
|
||||||
|
|
||||||
|
## Nx Cloud Connection Check
|
||||||
|
|
||||||
|
Before starting the monitoring loop, verify the workspace is connected to Nx Cloud. Without this connection, no CI data is available and the entire skill is inoperable.
|
||||||
|
|
||||||
|
### Step 0: Verify Nx Cloud Connection
|
||||||
|
|
||||||
|
1. **Check `nx.json`** at workspace root for `nxCloudId` or `nxCloudAccessToken`
|
||||||
|
2. **If `nx.json` missing OR neither property exists** → exit with:
|
||||||
|
|
||||||
|
```
|
||||||
|
Nx Cloud not connected. Unlock 70% faster CI and auto-fix broken PRs with https://nx.dev/nx-cloud
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **If connected** → continue to main loop
|
||||||
|
|
||||||
|
## Architecture Overview
|
||||||
|
|
||||||
|
1. **This skill (orchestrator)**: spawns subagents, runs scripts, prints status, does local coding work
|
||||||
|
2. **ci-monitor-subagent (haiku)**: calls one MCP tool (ci_information or update_self_healing_fix), returns structured result, exits
|
||||||
|
3. **ci-poll-decide.mjs (deterministic script)**: takes ci_information result + state, returns action + status message
|
||||||
|
4. **ci-state-update.mjs (deterministic script)**: manages budget gates, post-action state transitions, and cycle classification
|
||||||
|
|
||||||
|
## Status Reporting
|
||||||
|
|
||||||
|
The decision script handles message formatting based on verbosity. When printing messages to the user:
|
||||||
|
|
||||||
|
- Prepend `[monitor-ci]` to every message from the script's `message` field
|
||||||
|
- For your own action messages (e.g. "Applying fix via MCP..."), also prepend `[monitor-ci]`
|
||||||
|
|
||||||
|
## Anti-Patterns
|
||||||
|
|
||||||
|
These behaviors cause real problems — racing with self-healing, losing CI progress, or wasting context:
|
||||||
|
|
||||||
|
| Anti-Pattern | Why It's Bad |
|
||||||
|
| ----------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ |
|
||||||
|
| Using CI provider CLIs with `--watch` flags (e.g., `gh pr checks --watch`, `glab ci status -w`) | Bypasses Nx Cloud self-healing entirely |
|
||||||
|
| Writing custom CI polling scripts | Unreliable, pollutes context, no self-healing |
|
||||||
|
| Cancelling CI workflows/pipelines | Destructive, loses CI progress |
|
||||||
|
| Running CI checks on main agent | Wastes main agent context tokens |
|
||||||
|
| Independently analyzing/fixing CI failures while polling | Races with self-healing, causes duplicate fixes and confused state |
|
||||||
|
|
||||||
|
**If this skill fails to activate**, the fallback is:
|
||||||
|
|
||||||
|
1. Use CI provider CLI for a one-time, read-only status check (single call, no watch/polling flags)
|
||||||
|
2. Immediately delegate to this skill with gathered context
|
||||||
|
3. Do not continue polling on main agent — it wastes context tokens and bypasses self-healing
|
||||||
|
|
||||||
|
## Session Context Behavior
|
||||||
|
|
||||||
|
If the user previously ran `/monitor-ci` in this session, you may have prior state (poll counts, last CI Attempt URL, etc.). Resume from that state unless `--fresh` is set, in which case discard it and start from Step 1.
|
||||||
|
|
||||||
|
## MCP Tool Reference
|
||||||
|
|
||||||
|
Three field sets control polling efficiency — use the lightest set that gives you what you need:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
WAIT_FIELDS: 'cipeUrl,commitSha,cipeStatus'
|
||||||
|
LIGHT_FIELDS: 'cipeStatus,cipeUrl,branch,commitSha,selfHealingStatus,verificationStatus,userAction,failedTaskIds,verifiedTaskIds,selfHealingEnabled,failureClassification,couldAutoApplyTasks,autoApplySkipped,autoApplySkipReason,shortLink,confidence,confidenceReasoning,hints,selfHealingSkippedReason,selfHealingSkipMessage'
|
||||||
|
HEAVY_FIELDS: 'taskOutputSummary,suggestedFix,suggestedFixReasoning,suggestedFixDescription'
|
||||||
|
```
|
||||||
|
|
||||||
|
The `ci_information` tool accepts `branch` (optional, defaults to current git branch), `select` (comma-separated field names), and `pageToken` (0-based pagination for long strings).
|
||||||
|
|
||||||
|
The `update_self_healing_fix` tool accepts a `shortLink` and an action: `APPLY`, `REJECT`, or `RERUN_ENVIRONMENT_STATE`.
|
||||||
|
|
||||||
|
## Default Behaviors by Status
|
||||||
|
|
||||||
|
The decision script returns one of the following statuses. This table defines the **default behavior** for each. User instructions can override any of these.
|
||||||
|
|
||||||
|
**Simple exits** — just report and exit:
|
||||||
|
|
||||||
|
| Status | Default Behavior |
|
||||||
|
| ----------------------- | ---------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `ci_success` | Exit with success |
|
||||||
|
| `cipe_canceled` | Exit, CI was canceled |
|
||||||
|
| `cipe_timed_out` | Exit, CI timed out |
|
||||||
|
| `polling_timeout` | Exit, polling timeout reached |
|
||||||
|
| `circuit_breaker` | Exit, no progress after 13 consecutive polls |
|
||||||
|
| `environment_rerun_cap` | Exit, environment reruns exhausted |
|
||||||
|
| `fix_auto_applying` | Self-healing is handling it — just record `last_cipe_url`, enter wait mode. No MCP call or local git ops needed. |
|
||||||
|
| `error` | Wait 60s and loop |
|
||||||
|
|
||||||
|
**Statuses requiring action** — when handling these in Step 3, read `references/fix-flows.md` for the detailed flow:
|
||||||
|
|
||||||
|
| Status | Summary |
|
||||||
|
| ------------------------ | --------------------------------------------------------------------------------------------- |
|
||||||
|
| `fix_auto_apply_skipped` | Fix verified but auto-apply skipped (e.g., loop prevention). Inform user, offer manual apply. |
|
||||||
|
| `fix_apply_ready` | Fix verified (all tasks or e2e-only). Apply via MCP. |
|
||||||
|
| `fix_needs_local_verify` | Fix has unverified non-e2e tasks. Run locally, then apply or enhance. |
|
||||||
|
| `fix_needs_review` | Fix verification failed/not attempted. Analyze and decide. |
|
||||||
|
| `fix_failed` | Self-healing failed. Fetch heavy data, attempt local fix (gate check first). |
|
||||||
|
| `no_fix` | No fix available. Fetch heavy data, attempt local fix (gate check first) or exit. |
|
||||||
|
| `environment_issue` | Request environment rerun via MCP (gate check first). |
|
||||||
|
| `self_healing_throttled` | Reject old fixes, attempt local fix. |
|
||||||
|
| `no_new_cipe` | CI Attempt never spawned. Auto-fix workflow or exit with guidance. |
|
||||||
|
| `cipe_no_tasks` | CI failed with no tasks. Retry once with empty commit. |
|
||||||
|
|
||||||
|
**Key rules (always apply):**
|
||||||
|
|
||||||
|
- **Git safety**: Stage specific files by name — `git add -A` or `git add .` risks committing the user's unrelated work-in-progress or secrets
|
||||||
|
- **Environment failures** (OOM, command not found, permission denied): bail immediately. These aren't code bugs, so spending local-fix budget on them is wasteful
|
||||||
|
- **Gate check**: Run `ci-state-update.mjs gate` before local fix attempts — if budget exhausted, print message and exit
|
||||||
|
|
||||||
|
## Main Loop
|
||||||
|
|
||||||
|
### Step 1: Initialize Tracking
|
||||||
|
|
||||||
|
```
|
||||||
|
cycle_count = 0 # Only incremented for agent-initiated cycles (counted against --max-cycles)
|
||||||
|
start_time = now()
|
||||||
|
no_progress_count = 0
|
||||||
|
local_verify_count = 0
|
||||||
|
env_rerun_count = 0
|
||||||
|
last_cipe_url = null
|
||||||
|
expected_commit_sha = null
|
||||||
|
agent_triggered = false # Set true after monitor takes an action that triggers new CI Attempt
|
||||||
|
poll_count = 0
|
||||||
|
wait_mode = false
|
||||||
|
prev_status = null
|
||||||
|
prev_cipe_status = null
|
||||||
|
prev_sh_status = null
|
||||||
|
prev_verification_status = null
|
||||||
|
prev_failure_classification = null
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2: Polling Loop
|
||||||
|
|
||||||
|
Repeat until done:
|
||||||
|
|
||||||
|
#### 2a. Spawn subagent (FETCH_STATUS)
|
||||||
|
|
||||||
|
Determine select fields based on mode:
|
||||||
|
|
||||||
|
- **Wait mode**: use WAIT_FIELDS (`cipeUrl,commitSha,cipeStatus`)
|
||||||
|
- **Normal mode (first poll or after newCipeDetected)**: use LIGHT_FIELDS
|
||||||
|
|
||||||
|
Call the `ci_information` tool with the determined `select` fields for the current branch. Wait for the result before proceeding.
|
||||||
|
|
||||||
|
#### 2b. Run decision script
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node <skill_dir>/scripts/ci-poll-decide.mjs '<subagent_result_json>' <poll_count> <verbosity> \
|
||||||
|
[--wait-mode] \
|
||||||
|
[--prev-cipe-url <last_cipe_url>] \
|
||||||
|
[--expected-sha <expected_commit_sha>] \
|
||||||
|
[--prev-status <prev_status>] \
|
||||||
|
[--timeout <timeout_seconds>] \
|
||||||
|
[--new-cipe-timeout <new_cipe_timeout_seconds>] \
|
||||||
|
[--env-rerun-count <env_rerun_count>] \
|
||||||
|
[--no-progress-count <no_progress_count>] \
|
||||||
|
[--prev-cipe-status <prev_cipe_status>] \
|
||||||
|
[--prev-sh-status <prev_sh_status>] \
|
||||||
|
[--prev-verification-status <prev_verification_status>] \
|
||||||
|
[--prev-failure-classification <prev_failure_classification>]
|
||||||
|
```
|
||||||
|
|
||||||
|
The script outputs a single JSON line: `{ action, code, message, delay?, noProgressCount, envRerunCount, fields?, newCipeDetected?, verifiableTaskIds? }`
|
||||||
|
|
||||||
|
#### 2c. Process script output
|
||||||
|
|
||||||
|
Parse the JSON output and update tracking state:
|
||||||
|
|
||||||
|
- `no_progress_count = output.noProgressCount`
|
||||||
|
- `env_rerun_count = output.envRerunCount`
|
||||||
|
- `prev_cipe_status = subagent_result.cipeStatus`
|
||||||
|
- `prev_sh_status = subagent_result.selfHealingStatus`
|
||||||
|
- `prev_verification_status = subagent_result.verificationStatus`
|
||||||
|
- `prev_failure_classification = subagent_result.failureClassification`
|
||||||
|
- `prev_status = output.action + ":" + (output.code || subagent_result.cipeStatus)`
|
||||||
|
- `poll_count++`
|
||||||
|
|
||||||
|
Based on `action`:
|
||||||
|
|
||||||
|
- **`action == "poll"`**: Print `output.message`, sleep `output.delay` seconds, go to 2a
|
||||||
|
- If `output.newCipeDetected`: clear wait mode, reset `wait_mode = false`
|
||||||
|
- **`action == "wait"`**: Print `output.message`, sleep `output.delay` seconds, go to 2a
|
||||||
|
- **`action == "done"`**: Proceed to Step 3 with `output.code`
|
||||||
|
|
||||||
|
### Step 3: Handle Actionable Status
|
||||||
|
|
||||||
|
When decision script returns `action == "done"`:
|
||||||
|
|
||||||
|
1. Run cycle-check (Step 4) **before** handling the code
|
||||||
|
2. Check the returned `code`
|
||||||
|
3. Look up default behavior in the table above
|
||||||
|
4. Check if user instructions override the default
|
||||||
|
5. Execute the appropriate action
|
||||||
|
6. **If action expects new CI Attempt**, update tracking (see Step 3a)
|
||||||
|
7. If action results in looping, go to Step 2
|
||||||
|
|
||||||
|
#### Tool calls for actions
|
||||||
|
|
||||||
|
Several statuses require fetching additional data or calling tools:
|
||||||
|
|
||||||
|
- **fix_apply_ready**: Call `update_self_healing_fix` with action `APPLY`
|
||||||
|
- **fix_needs_local_verify**: Call `ci_information` with HEAVY_FIELDS for fix details before local verification
|
||||||
|
- **fix_needs_review**: Call `ci_information` with HEAVY_FIELDS → get `suggestedFixDescription`, `suggestedFixSummary`, `taskFailureSummaries`
|
||||||
|
- **fix_failed / no_fix**: Call `ci_information` with HEAVY_FIELDS → get `taskFailureSummaries` for local fix context
|
||||||
|
- **environment_issue**: Call `update_self_healing_fix` with action `RERUN_ENVIRONMENT_STATE`
|
||||||
|
- **self_healing_throttled**: Call `ci_information` with HEAVY_FIELDS → get `selfHealingSkipMessage`; then call `update_self_healing_fix` for each old fix
|
||||||
|
|
||||||
|
### Step 3a: Track State for New-CI-Attempt Detection
|
||||||
|
|
||||||
|
After actions that should trigger a new CI Attempt, run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node <skill_dir>/scripts/ci-state-update.mjs post-action \
|
||||||
|
--action <type> \
|
||||||
|
--cipe-url <current_cipe_url> \
|
||||||
|
--commit-sha <git_rev_parse_HEAD>
|
||||||
|
```
|
||||||
|
|
||||||
|
Action types: `fix-auto-applying`, `apply-mcp`, `apply-local-push`, `reject-fix-push`, `local-fix-push`, `env-rerun`, `auto-fix-push`, `empty-commit-push`
|
||||||
|
|
||||||
|
The script returns `{ waitMode, pollCount, lastCipeUrl, expectedCommitSha, agentTriggered }`. Update all tracking state from the output, then go to Step 2.
|
||||||
|
|
||||||
|
### Step 4: Cycle Classification and Progress Tracking
|
||||||
|
|
||||||
|
When the decision script returns `action == "done"`, run cycle-check **before** handling the code:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node <skill_dir>/scripts/ci-state-update.mjs cycle-check \
|
||||||
|
--code <code> \
|
||||||
|
[--agent-triggered] \
|
||||||
|
--cycle-count <cycle_count> --max-cycles <max_cycles> \
|
||||||
|
--env-rerun-count <env_rerun_count>
|
||||||
|
```
|
||||||
|
|
||||||
|
The script returns `{ cycleCount, agentTriggered, envRerunCount, approachingLimit, message }`. Update tracking state from the output.
|
||||||
|
|
||||||
|
- If `approachingLimit` → ask user whether to continue (with 5 or 10 more cycles) or stop monitoring
|
||||||
|
- If previous cycle was NOT agent-triggered (human pushed), log that human-initiated push was detected
|
||||||
|
|
||||||
|
#### Progress Tracking
|
||||||
|
|
||||||
|
- `no_progress_count`, circuit breaker (5 polls), and backoff reset are handled by ci-poll-decide.mjs (progress = any change in cipeStatus, selfHealingStatus, verificationStatus, or failureClassification)
|
||||||
|
- `env_rerun_count` reset on non-environment status is handled by ci-state-update.mjs cycle-check
|
||||||
|
- On new CI Attempt detected (poll script returns `newCipeDetected`) → reset `local_verify_count = 0`, `env_rerun_count = 0`
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
| Error | Action |
|
||||||
|
| ------------------------------ | ----------------------------------------------------------------------------------------------------------- |
|
||||||
|
| Git rebase conflict | Report to user, exit |
|
||||||
|
| `nx-cloud apply-locally` fails | Reject fix via MCP (`action: "REJECT"`), then attempt manual patch (Reject + Fix From Scratch Flow) or exit |
|
||||||
|
| MCP tool error | Retry once, if fails report to user |
|
||||||
|
| Subagent spawn failure | Retry once, if fails exit with error |
|
||||||
|
| Decision script error | Treat as `error` status, increment `no_progress_count` |
|
||||||
|
| No new CI Attempt detected | If `--auto-fix-workflow`, try lockfile update; otherwise report to user with guidance |
|
||||||
|
| Lockfile auto-fix fails | Report to user, exit with guidance to check CI logs |
|
||||||
|
|
||||||
|
## User Instruction Examples
|
||||||
|
|
||||||
|
Users can override default behaviors:
|
||||||
|
|
||||||
|
| Instruction | Effect |
|
||||||
|
| ------------------------------------------------ | --------------------------------------------------- |
|
||||||
|
| "never auto-apply" | Always prompt before applying any fix |
|
||||||
|
| "always ask before git push" | Prompt before each push |
|
||||||
|
| "reject any fix for e2e tasks" | Auto-reject if `failedTaskIds` contains e2e |
|
||||||
|
| "apply all fixes regardless of verification" | Skip verification check, apply everything |
|
||||||
|
| "if confidence < 70, reject" | Check confidence field before applying |
|
||||||
|
| "run 'nx affected -t typecheck' before applying" | Add local verification step |
|
||||||
|
| "auto-fix workflow failures" | Attempt lockfile updates on pre-CI-Attempt failures |
|
||||||
|
| "wait 45 min for new CI Attempt" | Override new-CI-Attempt timeout (default: 10 min) |
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
# Detailed Status Handling & Fix Flows
|
||||||
|
|
||||||
|
## Status Handling by Code
|
||||||
|
|
||||||
|
### fix_auto_apply_skipped
|
||||||
|
|
||||||
|
The script returns `autoApplySkipReason` in its output.
|
||||||
|
|
||||||
|
1. Report the skip reason to the user (e.g., "Auto-apply was skipped because the previous CI pipeline execution was triggered by Nx Cloud")
|
||||||
|
2. Offer to apply the fix manually — spawn UPDATE_FIX subagent with `APPLY` if user agrees
|
||||||
|
3. Record `last_cipe_url`, enter wait mode
|
||||||
|
|
||||||
|
### fix_apply_ready
|
||||||
|
|
||||||
|
- Spawn UPDATE_FIX subagent with `APPLY`
|
||||||
|
- Record `last_cipe_url`, enter wait mode
|
||||||
|
|
||||||
|
### fix_needs_local_verify
|
||||||
|
|
||||||
|
The script returns `verifiableTaskIds` in its output.
|
||||||
|
|
||||||
|
1. **Detect package manager:** `pnpm-lock.yaml` → `pnpm nx`, `yarn.lock` → `yarn nx`, otherwise `npx nx`
|
||||||
|
2. **Run verifiable tasks in parallel** — spawn `general` subagents for each task
|
||||||
|
3. **If all pass** → spawn UPDATE_FIX subagent with `APPLY`, enter wait mode
|
||||||
|
4. **If any fail** → Apply Locally + Enhance Flow (see below)
|
||||||
|
|
||||||
|
### fix_needs_review
|
||||||
|
|
||||||
|
Spawn FETCH_HEAVY subagent, then analyze fix content (`suggestedFixDescription`, `suggestedFixSummary`, `taskFailureSummaries`):
|
||||||
|
|
||||||
|
- If fix looks correct → apply via MCP
|
||||||
|
- If fix needs enhancement → Apply Locally + Enhance Flow
|
||||||
|
- If fix is wrong → run `ci-state-update.mjs gate --gate-type local-fix`. If not allowed, print message and exit. Otherwise → Reject + Fix From Scratch Flow
|
||||||
|
|
||||||
|
### fix_failed / no_fix
|
||||||
|
|
||||||
|
Spawn FETCH_HEAVY subagent for `taskFailureSummaries`. Run `ci-state-update.mjs gate --gate-type local-fix` — if not allowed, print message and exit. Otherwise attempt local fix (counter already incremented by gate). If successful → commit, push, enter wait mode. If not → exit with failure.
|
||||||
|
|
||||||
|
### environment_issue
|
||||||
|
|
||||||
|
1. Run `ci-state-update.mjs gate --gate-type env-rerun`. If not allowed, print message and exit.
|
||||||
|
2. Spawn UPDATE_FIX subagent with `RERUN_ENVIRONMENT_STATE`
|
||||||
|
3. Enter wait mode with `last_cipe_url` set
|
||||||
|
|
||||||
|
### self_healing_throttled
|
||||||
|
|
||||||
|
Spawn FETCH_HEAVY subagent for `selfHealingSkipMessage`.
|
||||||
|
|
||||||
|
1. **Parse throttle message** for CI Attempt URLs (regex: `/cipes/{id}`)
|
||||||
|
2. **Reject previous fixes** — for each URL: spawn FETCH_THROTTLE_INFO to get `shortLink`, then UPDATE_FIX with `REJECT`
|
||||||
|
3. **Attempt local fix**: Run `ci-state-update.mjs gate --gate-type local-fix`. If not allowed → skip to step 4. Otherwise use `failedTaskIds` and `taskFailureSummaries` for context.
|
||||||
|
4. **Fallback if local fix not possible or budget exhausted**: push empty commit (`git commit --allow-empty -m "ci: rerun after rejecting throttled fixes"`), enter wait mode
|
||||||
|
|
||||||
|
### no_new_cipe
|
||||||
|
|
||||||
|
1. Report to user: no CI attempt found, suggest checking CI provider
|
||||||
|
2. If `--auto-fix-workflow`: detect package manager, run install, commit lockfile if changed, enter wait mode
|
||||||
|
3. Otherwise: exit with guidance
|
||||||
|
|
||||||
|
### cipe_no_tasks
|
||||||
|
|
||||||
|
1. Report to user: CI failed with no tasks recorded
|
||||||
|
2. Retry: `git commit --allow-empty -m "chore: retry ci [monitor-ci]"` + push, enter wait mode
|
||||||
|
3. If retry also returns `cipe_no_tasks`: exit with failure
|
||||||
|
|
||||||
|
## Fix Action Flows
|
||||||
|
|
||||||
|
### Apply via MCP
|
||||||
|
|
||||||
|
Spawn UPDATE_FIX subagent with `APPLY`. New CI Attempt spawns automatically. No local git ops.
|
||||||
|
|
||||||
|
### Apply Locally + Enhance Flow
|
||||||
|
|
||||||
|
1. `nx-cloud apply-locally <shortLink>` (sets state to `APPLIED_LOCALLY`)
|
||||||
|
2. Enhance code to fix failing tasks
|
||||||
|
3. Run failing tasks to verify
|
||||||
|
4. If still failing → run `ci-state-update.mjs gate --gate-type local-fix`. If not allowed, commit current state and push (let CI be final judge). Otherwise loop back to enhance.
|
||||||
|
5. If passing → commit and push, enter wait mode
|
||||||
|
|
||||||
|
### Reject + Fix From Scratch Flow
|
||||||
|
|
||||||
|
1. Run `ci-state-update.mjs gate --gate-type local-fix`. If not allowed, print message and exit.
|
||||||
|
2. Spawn UPDATE_FIX subagent with `REJECT`
|
||||||
|
3. Fix from scratch locally
|
||||||
|
4. Commit and push, enter wait mode
|
||||||
|
|
||||||
|
## Environment vs Code Failure Recognition
|
||||||
|
|
||||||
|
When any local fix path runs a task and it fails, assess whether the failure is a **code issue** or an **environment/tooling issue** before running the gate script.
|
||||||
|
|
||||||
|
**Indicators of environment/tooling failures** (non-exhaustive): command not found / binary missing, OOM / heap allocation failures, permission denied, network timeouts / DNS failures, missing system libraries, Docker/container issues, disk space exhaustion.
|
||||||
|
|
||||||
|
When detected → bail immediately without running gate (no budget consumed). Report that the failure is an environment/tooling issue, not a code bug.
|
||||||
|
|
||||||
|
**Code failures** (compilation errors, test assertion failures, lint violations, type errors) are genuine candidates for local fix attempts and proceed normally through the gate.
|
||||||
|
|
||||||
|
## Git Safety
|
||||||
|
|
||||||
|
- Stage specific files by name — `git add -A` or `git add .` risks committing the user's unrelated work-in-progress or secrets
|
||||||
|
|
||||||
|
## Commit Message Format
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git commit -m "fix(<projects>): <brief description>
|
||||||
|
|
||||||
|
Failed tasks: <taskId1>, <taskId2>
|
||||||
|
Local verification: passed|enhanced|failed-pushing-to-ci"
|
||||||
|
```
|
||||||
@@ -0,0 +1,399 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CI Poll Decision Script
|
||||||
|
*
|
||||||
|
* Deterministic decision engine for CI monitoring.
|
||||||
|
* Takes ci_information JSON + state args, outputs a single JSON action line.
|
||||||
|
*
|
||||||
|
* Architecture:
|
||||||
|
* classify() — pure decision tree, returns { action, code, extra? }
|
||||||
|
* buildOutput() — maps classification to full output with messages, delays, counters
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* node ci-poll-decide.mjs '<ci_info_json>' <poll_count> <verbosity> \
|
||||||
|
* [--wait-mode] [--prev-cipe-url <url>] [--expected-sha <sha>] \
|
||||||
|
* [--prev-status <status>] [--timeout <seconds>] [--new-cipe-timeout <seconds>] \
|
||||||
|
* [--env-rerun-count <n>] [--no-progress-count <n>] \
|
||||||
|
* [--prev-cipe-status <status>] [--prev-sh-status <status>] \
|
||||||
|
* [--prev-verification-status <status>] [--prev-failure-classification <status>]
|
||||||
|
*/
|
||||||
|
|
||||||
|
// --- Arg parsing ---
|
||||||
|
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const ciInfoJson = args[0];
|
||||||
|
const pollCount = parseInt(args[1], 10) || 0;
|
||||||
|
const verbosity = args[2] || 'medium';
|
||||||
|
|
||||||
|
function getFlag(name) {
|
||||||
|
return args.includes(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getArg(name) {
|
||||||
|
const idx = args.indexOf(name);
|
||||||
|
return idx !== -1 && idx + 1 < args.length ? args[idx + 1] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const waitMode = getFlag('--wait-mode');
|
||||||
|
const prevCipeUrl = getArg('--prev-cipe-url');
|
||||||
|
const expectedSha = getArg('--expected-sha');
|
||||||
|
const prevStatus = getArg('--prev-status');
|
||||||
|
const timeoutSeconds = parseInt(getArg('--timeout') || '0', 10);
|
||||||
|
const newCipeTimeoutSeconds = parseInt(getArg('--new-cipe-timeout') || '0', 10);
|
||||||
|
const envRerunCount = parseInt(getArg('--env-rerun-count') || '0', 10);
|
||||||
|
const inputNoProgressCount = parseInt(getArg('--no-progress-count') || '0', 10);
|
||||||
|
const prevCipeStatus = getArg('--prev-cipe-status');
|
||||||
|
const prevShStatus = getArg('--prev-sh-status');
|
||||||
|
const prevVerificationStatus = getArg('--prev-verification-status');
|
||||||
|
const prevFailureClassification = getArg('--prev-failure-classification');
|
||||||
|
|
||||||
|
// --- Parse CI info ---
|
||||||
|
|
||||||
|
let ci;
|
||||||
|
try {
|
||||||
|
ci = JSON.parse(ciInfoJson);
|
||||||
|
} catch {
|
||||||
|
console.log(
|
||||||
|
JSON.stringify({
|
||||||
|
action: 'done',
|
||||||
|
code: 'error',
|
||||||
|
message: 'Failed to parse ci_information JSON',
|
||||||
|
noProgressCount: inputNoProgressCount + 1,
|
||||||
|
envRerunCount,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
cipeStatus,
|
||||||
|
selfHealingStatus,
|
||||||
|
verificationStatus,
|
||||||
|
selfHealingEnabled,
|
||||||
|
selfHealingSkippedReason,
|
||||||
|
failureClassification: rawFailureClassification,
|
||||||
|
failedTaskIds = [],
|
||||||
|
verifiedTaskIds = [],
|
||||||
|
couldAutoApplyTasks,
|
||||||
|
autoApplySkipped,
|
||||||
|
autoApplySkipReason,
|
||||||
|
userAction,
|
||||||
|
cipeUrl,
|
||||||
|
commitSha,
|
||||||
|
} = ci;
|
||||||
|
|
||||||
|
const failureClassification = rawFailureClassification?.toLowerCase() ?? null;
|
||||||
|
|
||||||
|
// --- Helpers ---
|
||||||
|
|
||||||
|
function categorizeTasks() {
|
||||||
|
const verifiedSet = new Set(verifiedTaskIds);
|
||||||
|
const unverified = failedTaskIds.filter((t) => !verifiedSet.has(t));
|
||||||
|
if (unverified.length === 0) return { category: 'all_verified' };
|
||||||
|
|
||||||
|
const e2e = unverified.filter((t) => {
|
||||||
|
const parts = t.split(':');
|
||||||
|
return parts.length >= 2 && parts[1].includes('e2e');
|
||||||
|
});
|
||||||
|
if (e2e.length === unverified.length) return { category: 'e2e_only' };
|
||||||
|
|
||||||
|
const verifiable = unverified.filter((t) => {
|
||||||
|
const parts = t.split(':');
|
||||||
|
return !(parts.length >= 2 && parts[1].includes('e2e'));
|
||||||
|
});
|
||||||
|
return { category: 'needs_local_verify', verifiableTaskIds: verifiable };
|
||||||
|
}
|
||||||
|
|
||||||
|
function backoff(count) {
|
||||||
|
const delays = [60, 90, 120, 180];
|
||||||
|
return delays[Math.min(count, delays.length - 1)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasStateChanged() {
|
||||||
|
if (prevCipeStatus && cipeStatus !== prevCipeStatus) return true;
|
||||||
|
if (prevShStatus && selfHealingStatus !== prevShStatus) return true;
|
||||||
|
if (prevVerificationStatus && verificationStatus !== prevVerificationStatus) return true;
|
||||||
|
if (prevFailureClassification && failureClassification !== prevFailureClassification) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTimedOut() {
|
||||||
|
if (timeoutSeconds <= 0) return false;
|
||||||
|
const avgDelay = pollCount === 0 ? 0 : backoff(Math.floor(pollCount / 2));
|
||||||
|
return pollCount * avgDelay >= timeoutSeconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWaitTimedOut() {
|
||||||
|
if (newCipeTimeoutSeconds <= 0) return false;
|
||||||
|
return pollCount * 30 >= newCipeTimeoutSeconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isNewCipe() {
|
||||||
|
return (
|
||||||
|
(prevCipeUrl && cipeUrl && cipeUrl !== prevCipeUrl) ||
|
||||||
|
(expectedSha && commitSha && commitSha === expectedSha)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// classify() — pure decision tree
|
||||||
|
//
|
||||||
|
// Returns: { action: 'poll'|'wait'|'done', code: string, extra? }
|
||||||
|
//
|
||||||
|
// Decision priority (top wins):
|
||||||
|
// WAIT MODE:
|
||||||
|
// 1. new CI Attempt detected → poll (new_cipe_detected)
|
||||||
|
// 2. wait timed out → done (no_new_cipe)
|
||||||
|
// 3. still waiting → wait (waiting_for_cipe)
|
||||||
|
// NORMAL MODE:
|
||||||
|
// 4. polling timeout → done (polling_timeout)
|
||||||
|
// 5. circuit breaker (13 polls) → done (circuit_breaker)
|
||||||
|
// 6. CI succeeded → done (ci_success)
|
||||||
|
// 7. CI canceled → done (cipe_canceled)
|
||||||
|
// 8. CI timed out → done (cipe_timed_out)
|
||||||
|
// 9. CI failed, no tasks recorded → done (cipe_no_tasks)
|
||||||
|
// 10. environment failure → done (environment_rerun_cap | environment_issue)
|
||||||
|
// 11. self-healing throttled → done (self_healing_throttled)
|
||||||
|
// 12. CI in progress / not started → poll (ci_running)
|
||||||
|
// 13. self-healing in progress → poll (sh_running)
|
||||||
|
// 14. flaky task auto-rerun → poll (flaky_rerun)
|
||||||
|
// 15. fix auto-applied → poll (fix_auto_applied)
|
||||||
|
// 16. auto-apply: skipped → done (fix_auto_apply_skipped)
|
||||||
|
// 17. auto-apply: verification pending→ poll (verification_pending)
|
||||||
|
// 18. auto-apply: verified → done (fix_auto_applying)
|
||||||
|
// 19. fix: verification failed/none → done (fix_needs_review)
|
||||||
|
// 20. fix: all/e2e verified → done (fix_apply_ready)
|
||||||
|
// 21. fix: needs local verify → done (fix_needs_local_verify)
|
||||||
|
// 22. self-healing failed → done (fix_failed)
|
||||||
|
// 23. no fix available → done (no_fix)
|
||||||
|
// 24. fallback → poll (fallback)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
function classify() {
|
||||||
|
// --- Wait mode ---
|
||||||
|
if (waitMode) {
|
||||||
|
if (isNewCipe()) return { action: 'poll', code: 'new_cipe_detected' };
|
||||||
|
if (isWaitTimedOut()) return { action: 'done', code: 'no_new_cipe' };
|
||||||
|
return { action: 'wait', code: 'waiting_for_cipe' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Guards ---
|
||||||
|
if (isTimedOut()) return { action: 'done', code: 'polling_timeout' };
|
||||||
|
if (noProgressCount >= 13) return { action: 'done', code: 'circuit_breaker' };
|
||||||
|
|
||||||
|
// --- Terminal CI states ---
|
||||||
|
if (cipeStatus === 'SUCCEEDED') return { action: 'done', code: 'ci_success' };
|
||||||
|
if (cipeStatus === 'CANCELED') return { action: 'done', code: 'cipe_canceled' };
|
||||||
|
if (cipeStatus === 'TIMED_OUT') return { action: 'done', code: 'cipe_timed_out' };
|
||||||
|
|
||||||
|
// --- CI failed, no tasks ---
|
||||||
|
if (cipeStatus === 'FAILED' && failedTaskIds.length === 0 && selfHealingStatus == null)
|
||||||
|
return { action: 'done', code: 'cipe_no_tasks' };
|
||||||
|
|
||||||
|
// --- Environment failure ---
|
||||||
|
if (failureClassification === 'environment_state') {
|
||||||
|
if (envRerunCount >= 2) return { action: 'done', code: 'environment_rerun_cap' };
|
||||||
|
return { action: 'done', code: 'environment_issue' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Throttled ---
|
||||||
|
if (selfHealingSkippedReason === 'THROTTLED')
|
||||||
|
return { action: 'done', code: 'self_healing_throttled' };
|
||||||
|
|
||||||
|
// --- Still running: CI ---
|
||||||
|
if (cipeStatus === 'IN_PROGRESS' || cipeStatus === 'NOT_STARTED')
|
||||||
|
return { action: 'poll', code: 'ci_running' };
|
||||||
|
|
||||||
|
// --- Still running: self-healing ---
|
||||||
|
if (
|
||||||
|
(selfHealingStatus === 'IN_PROGRESS' || selfHealingStatus === 'NOT_STARTED') &&
|
||||||
|
!selfHealingSkippedReason
|
||||||
|
)
|
||||||
|
return { action: 'poll', code: 'sh_running' };
|
||||||
|
|
||||||
|
// --- Still running: flaky rerun ---
|
||||||
|
if (failureClassification === 'flaky_task') return { action: 'poll', code: 'flaky_rerun' };
|
||||||
|
|
||||||
|
// --- Fix auto-applied, waiting for new CI Attempt ---
|
||||||
|
if (userAction === 'APPLIED_AUTOMATICALLY') return { action: 'poll', code: 'fix_auto_applied' };
|
||||||
|
|
||||||
|
// --- Auto-apply path (couldAutoApplyTasks) ---
|
||||||
|
if (couldAutoApplyTasks === true) {
|
||||||
|
if (autoApplySkipped === true)
|
||||||
|
return {
|
||||||
|
action: 'done',
|
||||||
|
code: 'fix_auto_apply_skipped',
|
||||||
|
extra: { autoApplySkipReason },
|
||||||
|
};
|
||||||
|
if (verificationStatus === 'NOT_STARTED' || verificationStatus === 'IN_PROGRESS')
|
||||||
|
return { action: 'poll', code: 'verification_pending' };
|
||||||
|
if (verificationStatus === 'COMPLETED') return { action: 'done', code: 'fix_auto_applying' };
|
||||||
|
// verification FAILED or NOT_EXECUTABLE → falls through to fix_needs_review
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Fix available ---
|
||||||
|
if (selfHealingStatus === 'COMPLETED') {
|
||||||
|
if (
|
||||||
|
verificationStatus === 'FAILED' ||
|
||||||
|
verificationStatus === 'NOT_EXECUTABLE' ||
|
||||||
|
(couldAutoApplyTasks !== true && !verificationStatus)
|
||||||
|
)
|
||||||
|
return { action: 'done', code: 'fix_needs_review' };
|
||||||
|
|
||||||
|
const tasks = categorizeTasks();
|
||||||
|
if (tasks.category === 'all_verified' || tasks.category === 'e2e_only')
|
||||||
|
return { action: 'done', code: 'fix_apply_ready' };
|
||||||
|
return {
|
||||||
|
action: 'done',
|
||||||
|
code: 'fix_needs_local_verify',
|
||||||
|
extra: { verifiableTaskIds: tasks.verifiableTaskIds },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Fix failed ---
|
||||||
|
if (selfHealingStatus === 'FAILED') return { action: 'done', code: 'fix_failed' };
|
||||||
|
|
||||||
|
// --- No fix available ---
|
||||||
|
if (
|
||||||
|
cipeStatus === 'FAILED' &&
|
||||||
|
(selfHealingEnabled === false || selfHealingStatus === 'NOT_EXECUTABLE')
|
||||||
|
)
|
||||||
|
return { action: 'done', code: 'no_fix' };
|
||||||
|
|
||||||
|
// --- Fallback ---
|
||||||
|
return { action: 'poll', code: 'fallback' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// buildOutput() — maps classification to full JSON output
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
// Message templates keyed by status or key
|
||||||
|
const messages = {
|
||||||
|
// wait mode
|
||||||
|
new_cipe_detected: () => `New CI Attempt detected! CI: ${cipeStatus || 'N/A'}`,
|
||||||
|
no_new_cipe: () => 'New CI Attempt timeout exceeded. No new CI Attempt detected.',
|
||||||
|
waiting_for_cipe: () => 'Waiting for new CI Attempt...',
|
||||||
|
|
||||||
|
// guards
|
||||||
|
polling_timeout: () => 'Polling timeout exceeded.',
|
||||||
|
circuit_breaker: () => 'No progress after 13 consecutive polls. Stopping.',
|
||||||
|
|
||||||
|
// terminal
|
||||||
|
ci_success: () => 'CI passed successfully!',
|
||||||
|
cipe_canceled: () => 'CI Attempt was canceled.',
|
||||||
|
cipe_timed_out: () => 'CI Attempt timed out.',
|
||||||
|
cipe_no_tasks: () => 'CI failed but no Nx tasks were recorded.',
|
||||||
|
|
||||||
|
// environment
|
||||||
|
environment_rerun_cap: () => 'Environment rerun cap (2) exceeded. Bailing.',
|
||||||
|
environment_issue: () => 'CI: FAILED | Classification: ENVIRONMENT_STATE',
|
||||||
|
|
||||||
|
// throttled
|
||||||
|
self_healing_throttled: () => 'Self-healing throttled \u2014 too many unapplied fixes.',
|
||||||
|
|
||||||
|
// polling
|
||||||
|
ci_running: () => `CI: ${cipeStatus}`,
|
||||||
|
sh_running: () => `CI: ${cipeStatus} | Self-healing: ${selfHealingStatus}`,
|
||||||
|
flaky_rerun: () => 'CI: FAILED | Classification: FLAKY_TASK (auto-rerun in progress)',
|
||||||
|
fix_auto_applied: () => 'CI: FAILED | Fix auto-applied, new CI Attempt spawning',
|
||||||
|
verification_pending: () =>
|
||||||
|
`CI: FAILED | Self-healing: COMPLETED | Verification: ${verificationStatus}`,
|
||||||
|
|
||||||
|
// actionable
|
||||||
|
fix_auto_applying: () => 'Fix verified! Auto-applying...',
|
||||||
|
fix_auto_apply_skipped: (extra) =>
|
||||||
|
`Fix verified but auto-apply was skipped. ${
|
||||||
|
extra?.autoApplySkipReason
|
||||||
|
? `Reason: ${extra.autoApplySkipReason}`
|
||||||
|
: 'Offer to apply manually.'
|
||||||
|
}`,
|
||||||
|
fix_needs_review: () =>
|
||||||
|
`Fix available but needs review. Verification: ${verificationStatus || 'N/A'}`,
|
||||||
|
fix_apply_ready: () => 'Fix available and verified. Ready to apply.',
|
||||||
|
fix_needs_local_verify: (extra) =>
|
||||||
|
`Fix available. ${extra.verifiableTaskIds.length} task(s) need local verification.`,
|
||||||
|
fix_failed: () => 'Self-healing failed to generate a fix.',
|
||||||
|
no_fix: () => 'CI failed, no fix available.',
|
||||||
|
|
||||||
|
// fallback
|
||||||
|
fallback: () =>
|
||||||
|
`CI: ${cipeStatus || 'N/A'} | Self-healing: ${
|
||||||
|
selfHealingStatus || 'N/A'
|
||||||
|
} | Verification: ${verificationStatus || 'N/A'}`,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Codes where noProgressCount resets to 0 (genuine progress occurred)
|
||||||
|
const resetProgressCodes = new Set([
|
||||||
|
'ci_success',
|
||||||
|
'fix_auto_applying',
|
||||||
|
'fix_auto_apply_skipped',
|
||||||
|
'fix_needs_review',
|
||||||
|
'fix_apply_ready',
|
||||||
|
'fix_needs_local_verify',
|
||||||
|
]);
|
||||||
|
|
||||||
|
function formatMessage(msg) {
|
||||||
|
if (verbosity === 'minimal') {
|
||||||
|
const currentStatus = `${cipeStatus}|${selfHealingStatus}|${verificationStatus}`;
|
||||||
|
if (currentStatus === (prevStatus || '')) return null;
|
||||||
|
return msg;
|
||||||
|
}
|
||||||
|
if (verbosity === 'verbose') {
|
||||||
|
return [
|
||||||
|
`Poll #${pollCount + 1} | CI: ${cipeStatus || 'N/A'} | Self-healing: ${
|
||||||
|
selfHealingStatus || 'N/A'
|
||||||
|
} | Verification: ${verificationStatus || 'N/A'}`,
|
||||||
|
msg,
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
return `Poll #${pollCount + 1} | ${msg}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildOutput(decision) {
|
||||||
|
const { action, code, extra } = decision;
|
||||||
|
|
||||||
|
// noProgressCount is already computed before classify() was called.
|
||||||
|
// Here we only handle the reset for "genuine progress" done-codes.
|
||||||
|
|
||||||
|
const msgFn = messages[code];
|
||||||
|
const rawMsg = msgFn ? msgFn(extra) : `Unknown: ${code}`;
|
||||||
|
const message = formatMessage(rawMsg);
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
action,
|
||||||
|
code,
|
||||||
|
message,
|
||||||
|
noProgressCount: resetProgressCodes.has(code) ? 0 : noProgressCount,
|
||||||
|
envRerunCount,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add delay
|
||||||
|
if (action === 'wait') {
|
||||||
|
result.delay = 30;
|
||||||
|
} else if (action === 'poll') {
|
||||||
|
result.delay = code === 'new_cipe_detected' ? 60 : backoff(noProgressCount);
|
||||||
|
result.fields = 'light';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add extras
|
||||||
|
if (code === 'new_cipe_detected') result.newCipeDetected = true;
|
||||||
|
if (extra?.verifiableTaskIds) result.verifiableTaskIds = extra.verifiableTaskIds;
|
||||||
|
if (extra?.autoApplySkipReason) result.autoApplySkipReason = extra.autoApplySkipReason;
|
||||||
|
|
||||||
|
console.log(JSON.stringify(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Run ---
|
||||||
|
|
||||||
|
// Compute noProgressCount from input. Single assignment, no mutation.
|
||||||
|
// Wait mode: reset on new cipe, otherwise unchanged (wait doesn't count as no-progress).
|
||||||
|
// Normal mode: reset on any state change, otherwise increment.
|
||||||
|
const noProgressCount = (() => {
|
||||||
|
if (waitMode) return isNewCipe() ? 0 : inputNoProgressCount;
|
||||||
|
if (isNewCipe() || hasStateChanged()) return 0;
|
||||||
|
return inputNoProgressCount + 1;
|
||||||
|
})();
|
||||||
|
|
||||||
|
buildOutput(classify());
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CI State Update Script
|
||||||
|
*
|
||||||
|
* Deterministic state management for CI monitor actions.
|
||||||
|
* Three commands: gate, post-action, cycle-check.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* node ci-state-update.mjs gate --gate-type <local-fix|env-rerun> [counter args]
|
||||||
|
* node ci-state-update.mjs post-action --action <type> [--cipe-url <url>] [--commit-sha <sha>]
|
||||||
|
* node ci-state-update.mjs cycle-check --code <code> [--agent-triggered] [counter args]
|
||||||
|
*/
|
||||||
|
|
||||||
|
// --- Arg parsing ---
|
||||||
|
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const command = args[0];
|
||||||
|
|
||||||
|
function getFlag(name) {
|
||||||
|
return args.includes(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getArg(name) {
|
||||||
|
const idx = args.indexOf(name);
|
||||||
|
return idx !== -1 && idx + 1 < args.length ? args[idx + 1] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function output(result) {
|
||||||
|
console.log(JSON.stringify(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- gate ---
|
||||||
|
// Check if an action is allowed and return incremented counter.
|
||||||
|
// Called before any local fix attempt or environment rerun.
|
||||||
|
|
||||||
|
function gate() {
|
||||||
|
const gateType = getArg('--gate-type');
|
||||||
|
|
||||||
|
if (gateType === 'local-fix') {
|
||||||
|
const count = parseInt(getArg('--local-verify-count') || '0', 10);
|
||||||
|
const max = parseInt(getArg('--local-verify-attempts') || '3', 10);
|
||||||
|
if (count >= max) {
|
||||||
|
return output({
|
||||||
|
allowed: false,
|
||||||
|
localVerifyCount: count,
|
||||||
|
message: `Local fix budget exhausted (${count}/${max} attempts)`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return output({
|
||||||
|
allowed: true,
|
||||||
|
localVerifyCount: count + 1,
|
||||||
|
message: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (gateType === 'env-rerun') {
|
||||||
|
const count = parseInt(getArg('--env-rerun-count') || '0', 10);
|
||||||
|
if (count >= 2) {
|
||||||
|
return output({
|
||||||
|
allowed: false,
|
||||||
|
envRerunCount: count,
|
||||||
|
message: `Environment issue persists after ${count} reruns. Manual investigation needed.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return output({
|
||||||
|
allowed: true,
|
||||||
|
envRerunCount: count + 1,
|
||||||
|
message: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
output({ allowed: false, message: `Unknown gate type: ${gateType}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- post-action ---
|
||||||
|
// Compute next state after an action is taken.
|
||||||
|
// Returns wait mode params and whether the action was agent-triggered.
|
||||||
|
|
||||||
|
function postAction() {
|
||||||
|
const action = getArg('--action');
|
||||||
|
const cipeUrl = getArg('--cipe-url');
|
||||||
|
const commitSha = getArg('--commit-sha');
|
||||||
|
|
||||||
|
// MCP-triggered or auto-applied: track by cipeUrl
|
||||||
|
const cipeUrlActions = ['fix-auto-applying', 'apply-mcp', 'env-rerun'];
|
||||||
|
// Local push: track by commitSha
|
||||||
|
const commitShaActions = [
|
||||||
|
'apply-local-push',
|
||||||
|
'reject-fix-push',
|
||||||
|
'local-fix-push',
|
||||||
|
'auto-fix-push',
|
||||||
|
'empty-commit-push',
|
||||||
|
];
|
||||||
|
|
||||||
|
const trackByCipeUrl = cipeUrlActions.includes(action);
|
||||||
|
const trackByCommitSha = commitShaActions.includes(action);
|
||||||
|
|
||||||
|
if (!trackByCipeUrl && !trackByCommitSha) {
|
||||||
|
return output({ error: `Unknown action: ${action}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
// fix-auto-applying: self-healing did it, NOT the monitor
|
||||||
|
const agentTriggered = action !== 'fix-auto-applying';
|
||||||
|
|
||||||
|
output({
|
||||||
|
waitMode: true,
|
||||||
|
pollCount: 0,
|
||||||
|
lastCipeUrl: trackByCipeUrl ? cipeUrl : null,
|
||||||
|
expectedCommitSha: trackByCommitSha ? commitSha : null,
|
||||||
|
agentTriggered,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- cycle-check ---
|
||||||
|
// Cycle classification + counter resets when a new "done" code is received.
|
||||||
|
// Called at the start of handling each actionable code.
|
||||||
|
|
||||||
|
function cycleCheck() {
|
||||||
|
const status = getArg('--code');
|
||||||
|
const wasAgentTriggered = getFlag('--agent-triggered');
|
||||||
|
let cycleCount = parseInt(getArg('--cycle-count') || '0', 10);
|
||||||
|
const maxCycles = parseInt(getArg('--max-cycles') || '10', 10);
|
||||||
|
let envRerunCount = parseInt(getArg('--env-rerun-count') || '0', 10);
|
||||||
|
|
||||||
|
// Cycle classification: if previous cycle was agent-triggered, count it
|
||||||
|
if (wasAgentTriggered) cycleCount++;
|
||||||
|
|
||||||
|
// Reset env_rerun_count on non-environment status
|
||||||
|
if (status !== 'environment_issue') envRerunCount = 0;
|
||||||
|
|
||||||
|
// Approaching limit gate
|
||||||
|
const approachingLimit = cycleCount >= maxCycles - 2;
|
||||||
|
|
||||||
|
output({
|
||||||
|
cycleCount,
|
||||||
|
agentTriggered: false,
|
||||||
|
envRerunCount,
|
||||||
|
approachingLimit,
|
||||||
|
message: approachingLimit ? `Approaching cycle limit (${cycleCount}/${maxCycles})` : null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Dispatch ---
|
||||||
|
|
||||||
|
switch (command) {
|
||||||
|
case 'gate':
|
||||||
|
gate();
|
||||||
|
break;
|
||||||
|
case 'post-action':
|
||||||
|
postAction();
|
||||||
|
break;
|
||||||
|
case 'cycle-check':
|
||||||
|
cycleCheck();
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
output({ error: `Unknown command: ${command}` });
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
---
|
||||||
|
name: nx-generate
|
||||||
|
description: Generate code using nx generators. INVOKE IMMEDIATELY when user mentions scaffolding, setup, structure, creating apps/libs, or setting up project structure. Trigger words - scaffold, setup, create a ... app, create a ... lib, project structure, generate, add a new project. ALWAYS use this BEFORE calling nx_docs or exploring - this skill handles discovery internally.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Run Nx Generator
|
||||||
|
|
||||||
|
Nx generators are powerful tools that scaffold projects, make automated code migrations or automate repetitive tasks in a monorepo. They ensure consistency across the codebase and reduce boilerplate work.
|
||||||
|
|
||||||
|
This skill applies when the user wants to:
|
||||||
|
|
||||||
|
- Create new projects like libraries or applications
|
||||||
|
- Scaffold features or boilerplate code
|
||||||
|
- Run workspace-specific or custom generators
|
||||||
|
- Do anything else that an nx generator exists for
|
||||||
|
|
||||||
|
## Key Principles
|
||||||
|
|
||||||
|
1. **Always use `--no-interactive`** - Prevents prompts that would hang execution
|
||||||
|
2. **Read the generator source code** - The schema alone is not enough; understand what the generator actually does
|
||||||
|
3. **Match existing repo patterns** - Study similar artifacts in the repo and follow their conventions
|
||||||
|
4. **Verify with lint/test/build/typecheck etc.** - Generated code must pass verification. The listed targets are just an example, use what's appropriate for this workspace.
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
### 1. Discover Available Generators
|
||||||
|
|
||||||
|
Use the Nx CLI to discover available generators:
|
||||||
|
|
||||||
|
- List all generators for a plugin: `npx nx list @nx/react`
|
||||||
|
- View available plugins: `npx nx list`
|
||||||
|
|
||||||
|
This includes plugin generators (e.g., `@nx/react:library`) and local workspace generators.
|
||||||
|
|
||||||
|
### 2. Match Generator to User Request
|
||||||
|
|
||||||
|
Identify which generator(s) could fulfill the user's needs. Consider what artifact type they want, which framework is relevant, and any specific generator names mentioned.
|
||||||
|
|
||||||
|
**IMPORTANT**: When both a local workspace generator and an external plugin generator could satisfy the request, **always prefer the local workspace generator**. Local generators are customized for the specific repo's patterns.
|
||||||
|
|
||||||
|
If no suitable generator exists, you can stop using this skill. However, the burden of proof is high—carefully consider all available generators before deciding none apply.
|
||||||
|
|
||||||
|
### 3. Get Generator Options
|
||||||
|
|
||||||
|
Use the `--help` flag to understand available options:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx nx g @nx/react:library --help
|
||||||
|
```
|
||||||
|
|
||||||
|
Pay attention to required options, defaults that might need overriding, and options relevant to the user's request.
|
||||||
|
|
||||||
|
### Library Buildability
|
||||||
|
|
||||||
|
**Default to non-buildable libraries** unless there's a specific reason for buildable.
|
||||||
|
|
||||||
|
| Type | When to use | Generator flags |
|
||||||
|
| --------------------------- | ----------------------------------------------------------------- | ----------------------------------- |
|
||||||
|
| **Non-buildable** (default) | Internal monorepo libs consumed by apps | No `--bundler` flag |
|
||||||
|
| **Buildable** | Publishing to npm, cross-repo sharing, stable libs for cache hits | `--bundler=vite` or `--bundler=swc` |
|
||||||
|
|
||||||
|
Non-buildable libs:
|
||||||
|
|
||||||
|
- Export `.ts`/`.tsx` source directly
|
||||||
|
- Consumer's bundler compiles them
|
||||||
|
- Faster dev experience, less config
|
||||||
|
|
||||||
|
Buildable libs:
|
||||||
|
|
||||||
|
- Have their own build target
|
||||||
|
- Useful for stable libs that rarely change (cache hits)
|
||||||
|
- Required for npm publishing
|
||||||
|
|
||||||
|
**If unclear, ask the user:** "Should this library be buildable (own build step, better caching) or non-buildable (source consumed directly, simpler setup)?"
|
||||||
|
|
||||||
|
### 4. Read Generator Source Code
|
||||||
|
|
||||||
|
**This step is critical.** The schema alone does not tell you everything. Reading the source code helps you:
|
||||||
|
|
||||||
|
- Know exactly what files will be created/modified and where
|
||||||
|
- Understand side effects (updating configs, installing deps, etc.)
|
||||||
|
- Identify behaviors and options not obvious from the schema
|
||||||
|
- Understand how options interact with each other
|
||||||
|
|
||||||
|
To find generator source code:
|
||||||
|
|
||||||
|
- For plugin generators: Use `node -e "console.log(require.resolve('@nx/<plugin>/generators.json'));"` to find the generators.json, then locate the source from there
|
||||||
|
- If that fails, read directly from `node_modules/<plugin>/generators.json`
|
||||||
|
- For local generators: Typically in `tools/generators/` or a local plugin directory. Search the repo for the generator name.
|
||||||
|
|
||||||
|
After reading the source, reconsider: Is this the right generator? If not, go back to step 2.
|
||||||
|
|
||||||
|
> **⚠️ `--directory` flag behavior can be misleading.**
|
||||||
|
> It should specify the full path of the generated library or component, not the parent path that it will be generated in.
|
||||||
|
>
|
||||||
|
> ```bash
|
||||||
|
> # ✅ Correct - directory is the full path for the library
|
||||||
|
> nx g @nx/react:library --directory=libs/my-lib
|
||||||
|
> # generates libs/my-lib/package.json and more
|
||||||
|
>
|
||||||
|
> # ❌ Wrong - this will create files at libs and libs/src/...
|
||||||
|
> nx g @nx/react:library --name=my-lib --directory=libs
|
||||||
|
> # generates libs/package.json and more
|
||||||
|
> ```
|
||||||
|
|
||||||
|
### 5. Examine Existing Patterns
|
||||||
|
|
||||||
|
Before generating, examine the target area of the codebase:
|
||||||
|
|
||||||
|
- Look at similar existing artifacts (other libraries, applications, etc.)
|
||||||
|
- Identify naming conventions, file structures, and configuration patterns
|
||||||
|
- Note which test runners, build tools, and linters are used
|
||||||
|
- Configure the generator to match these patterns
|
||||||
|
|
||||||
|
### 6. Dry-Run to Verify File Placement
|
||||||
|
|
||||||
|
**Always run with `--dry-run` first** to verify files will be created in the correct location:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx nx g @nx/react:library --name=my-lib --dry-run --no-interactive
|
||||||
|
```
|
||||||
|
|
||||||
|
Review the output carefully. If files would be created in the wrong location, adjust your options based on what you learned from the generator source code.
|
||||||
|
|
||||||
|
Note: Some generators don't support dry-run (e.g., if they install npm packages). If dry-run fails for this reason, proceed to running the generator for real.
|
||||||
|
|
||||||
|
### 7. Run the Generator
|
||||||
|
|
||||||
|
Execute the generator:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nx generate <generator-name> <options> --no-interactive
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Tip:** New packages often need workspace dependencies wired up (e.g., importing shared types, being consumed by apps). The `link-workspace-packages` skill can help add these correctly.
|
||||||
|
|
||||||
|
### 8. Modify Generated Code (If Needed)
|
||||||
|
|
||||||
|
Generators provide a starting point. Modify the output as needed to:
|
||||||
|
|
||||||
|
- Add or modify functionality as requested
|
||||||
|
- Adjust imports, exports, or configurations
|
||||||
|
- Integrate with existing code patterns
|
||||||
|
|
||||||
|
**Important:** If you replace or delete generated test files (e.g., `*.spec.ts`), either write meaningful replacement tests or remove the `test` target from the project configuration. Empty test suites will cause `nx test` to fail.
|
||||||
|
|
||||||
|
### 9. Format and Verify
|
||||||
|
|
||||||
|
Format all generated/modified files:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nx format --fix
|
||||||
|
```
|
||||||
|
|
||||||
|
This example is for built-in nx formatting with prettier. There might be other formatting tools for this workspace, use these when appropriate.
|
||||||
|
|
||||||
|
Then verify the generated code works. Keep in mind that the changes you make with a generator or subsequent modifications might impact various projects so it's usually not enough to only run targets for the artifact you just created.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# these targets are just an example!
|
||||||
|
nx run-many -t build,lint,test,typecheck
|
||||||
|
```
|
||||||
|
|
||||||
|
These targets are common examples used across many workspaces. You should do research into other targets available for this workspace and its projects. CI configuration is usually a good guide for what the critical targets are that have to pass.
|
||||||
|
|
||||||
|
If verification fails with manageable issues (a few lint errors, minor type issues), fix them. If issues are extensive, attempt obvious fixes first, then escalate to the user with details about what was generated, what's failing, and what you've attempted.
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
---
|
||||||
|
name: nx-import
|
||||||
|
description: Import, merge, or combine repositories into an Nx workspace using nx import. USE WHEN the user asks to adopt Nx across repos, move projects into a monorepo, or bring code/history from another repository.
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
- `nx import` brings code from a source repository or folder into the current workspace, preserving commit history.
|
||||||
|
- After nx `22.6.0`, `nx import` responds with .ndjson outputs and follow-up questions. For earlier versions, always run with `--no-interactive` and specify all flags directly.
|
||||||
|
- Run `nx import --help` for available options.
|
||||||
|
- Make sure the destination directory is empty before importing.
|
||||||
|
EXAMPLE: target has `libs/utils` and `libs/models`; source has `libs/ui` and `libs/data-access` — you cannot import `libs/` into `libs/` directly. Import each source library individually.
|
||||||
|
|
||||||
|
Primary docs:
|
||||||
|
|
||||||
|
- https://nx.dev/docs/guides/adopting-nx/import-project
|
||||||
|
- https://nx.dev/docs/guides/adopting-nx/preserving-git-histories
|
||||||
|
|
||||||
|
Read the nx docs if you have the tools for it.
|
||||||
|
|
||||||
|
## Import Strategy
|
||||||
|
|
||||||
|
**Subdirectory-at-a-time** (`nx import <source> apps --source=apps`):
|
||||||
|
|
||||||
|
- **Recommended for monorepo sources** — files land at top level, no redundant config
|
||||||
|
- Caveats: multiple import commands (separate merge commits each); dest must not have conflicting directories; root configs (deps, plugins, targetDefaults) not imported
|
||||||
|
- **Directory conflicts**: Import into alternate-named dir (e.g. `imported-apps/`), then rename
|
||||||
|
|
||||||
|
**Whole repo** (`nx import <source> imported --source=.`):
|
||||||
|
|
||||||
|
- **Only for non-monorepo sources** (single-project repos)
|
||||||
|
- For monorepos, creates messy nested config (`imported/nx.json`, `imported/tsconfig.base.json`, etc.)
|
||||||
|
- If you must: keep imported `tsconfig.base.json` (projects extend it), prefix workspace globs and executor paths
|
||||||
|
|
||||||
|
### Directory Conventions
|
||||||
|
|
||||||
|
- **Always prefer the destination's existing conventions.** Source uses `libs/`but dest uses `packages/`? Import into `packages/` (`nx import <source> packages/foo --source=libs/foo`).
|
||||||
|
- If dest has no convention (empty workspace), ask the user.
|
||||||
|
|
||||||
|
### Application vs Library Detection
|
||||||
|
|
||||||
|
Before importing, identify whether the source is an **application** or a **library**:
|
||||||
|
|
||||||
|
- **Applications**: Deployable end products. Common indicators:
|
||||||
|
- _Frontend_: `next.config.*`, `vite.config.*` with a build entry point, framework-specific app scaffolding (CRA, Angular CLI app, etc.)
|
||||||
|
- _Backend (Node.js)_: Express/Fastify/NestJS server entrypoint, no `"exports"` field in `package.json`
|
||||||
|
- _JVM_: Maven `pom.xml` with `<packaging>jar</packaging>` or `<packaging>war</packaging>` and a `main` class; Gradle `application` plugin or `mainClass` setting
|
||||||
|
- _.NET_: `.csproj`/`.fsproj` with `<OutputType>Exe</OutputType>` or `<OutputType>WinExe</OutputType>`
|
||||||
|
- _General_: Dockerfile, a runnable entrypoint, no public API surface intended for import by other projects
|
||||||
|
- **Libraries**: Reusable packages consumed by other projects. Common indicators: `"main"`/`"exports"` in `package.json`, Maven/Gradle packaging as a library jar, .NET `<OutputType>Library</OutputType>`, named exports intended for import by other packages.
|
||||||
|
|
||||||
|
**Destination directory rules**:
|
||||||
|
|
||||||
|
- Applications → `apps/<name>`. Check workspace globs (e.g. `pnpm-workspace.yaml`, `workspaces` in root `package.json`) for an existing `apps/*` entry.
|
||||||
|
- If `apps/*` is **not** present, add it before importing: update the workspace glob config and commit (or stage) the change.
|
||||||
|
- Example: `nx import <source> apps/my-app --source=packages/my-app`
|
||||||
|
- Libraries → follow the dest's existing convention (`packages/`, `libs/`, etc.).
|
||||||
|
|
||||||
|
## Common Issues
|
||||||
|
|
||||||
|
### pnpm Workspace Globs (Critical)
|
||||||
|
|
||||||
|
`nx import` adds the imported directory itself (e.g. `apps`) to `pnpm-workspace.yaml`, **NOT** glob patterns for packages within it. Cross-package imports will fail with `Cannot find module`.
|
||||||
|
|
||||||
|
**Fix**: Replace with proper globs from the source config (e.g. `apps/*`, `libs/shared/*`), then `pnpm install`.
|
||||||
|
|
||||||
|
### Root Dependencies and Config Not Imported (Critical)
|
||||||
|
|
||||||
|
`nx import` does **NOT** merge from the source's root:
|
||||||
|
|
||||||
|
- `dependencies`/`devDependencies` from `package.json`
|
||||||
|
- `targetDefaults` from `nx.json` (e.g. `"@nx/esbuild:esbuild": { "dependsOn": ["^build"] }` — critical for build ordering)
|
||||||
|
- `namedInputs` from `nx.json` (e.g. `production` exclusion patterns for test files)
|
||||||
|
- Plugin configurations from `nx.json`
|
||||||
|
|
||||||
|
**Fix**: Diff source and dest `package.json` + `nx.json`. Add missing deps, merge relevant `targetDefaults` and `namedInputs`.
|
||||||
|
|
||||||
|
### TypeScript Project References
|
||||||
|
|
||||||
|
After import, run `nx sync --yes`. If it reports nothing but typecheck still fails, `nx reset` first, then `nx sync --yes` again.
|
||||||
|
|
||||||
|
### Explicit Executor Path Fixups
|
||||||
|
|
||||||
|
Inferred targets (via Nx plugins) resolve config relative to project root — no changes needed. Explicit executor targets (e.g. `@nx/esbuild:esbuild`) have workspace-root-relative paths (`main`, `outputPath`, `tsConfig`, `assets`, `sourceRoot`) that must be prefixed with the import destination directory.
|
||||||
|
|
||||||
|
### Plugin Detection
|
||||||
|
|
||||||
|
- **Whole-repo import**: `nx import` detects and offers to install plugins. Accept them.
|
||||||
|
- **Subdirectory import**: Plugins NOT auto-detected. Manually add with `npx nx add @nx/PLUGIN`. Check `include`/`exclude` patterns — defaults won't match alternate directories (e.g. `apps-beta/`).
|
||||||
|
- Run `npx nx reset` after any plugin config changes.
|
||||||
|
|
||||||
|
### Redundant Root Files (Whole-Repo Only)
|
||||||
|
|
||||||
|
Whole-repo import brings ALL source root files into the dest subdirectory. Clean up:
|
||||||
|
|
||||||
|
- `pnpm-lock.yaml` — stale; dest has its own lockfile
|
||||||
|
- `pnpm-workspace.yaml` — source workspace config; conflicts with dest
|
||||||
|
- `node_modules/` — stale symlinks pointing to source filesystem
|
||||||
|
- `.gitignore` — redundant with dest root `.gitignore`
|
||||||
|
- `nx.json` — source Nx config; dest has its own
|
||||||
|
- `README.md` — optional; keep or remove
|
||||||
|
|
||||||
|
**Don't blindly delete** `tsconfig.base.json` — imported projects may extend it via relative paths.
|
||||||
|
|
||||||
|
### Root ESLint Config Missing (Subdirectory Import)
|
||||||
|
|
||||||
|
Subdirectory import doesn't bring the source's root `eslint.config.mjs`, but project configs reference `../../eslint.config.mjs`.
|
||||||
|
|
||||||
|
**Fix order**:
|
||||||
|
|
||||||
|
1. Install ESLint deps first: `pnpm add -wD eslint@^9 @nx/eslint-plugin typescript-eslint` (plus framework-specific plugins)
|
||||||
|
2. Create root `eslint.config.mjs` (copy from source or create with `@nx/eslint-plugin` base rules)
|
||||||
|
3. Then `npx nx add @nx/eslint` to register the plugin in `nx.json`
|
||||||
|
|
||||||
|
Install `typescript-eslint` explicitly — pnpm's strict hoisting won't auto-resolve this transitive dep of `@nx/eslint-plugin`.
|
||||||
|
|
||||||
|
### ESLint Version Pinning (Critical)
|
||||||
|
|
||||||
|
**Pin ESLint to v9** (`eslint@^9.0.0`). ESLint 10 breaks `@nx/eslint` and many plugins with cryptic errors like `Cannot read properties of undefined (reading 'version')`.
|
||||||
|
|
||||||
|
`@nx/eslint` may peer-depend on ESLint 8, causing the wrong version to resolve. If lint fails with `Cannot read properties of undefined (reading 'allow')`, add `pnpm.overrides`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "pnpm": { "overrides": { "eslint": "^9.0.0" } } }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dependency Version Conflicts
|
||||||
|
|
||||||
|
After import, compare key deps (`typescript`, `eslint`, framework-specific). If dest uses newer versions, upgrade imported packages to match (usually safe). If source is newer, may need to upgrade dest first. Use `pnpm.overrides` to enforce single-version policy if desired.
|
||||||
|
|
||||||
|
### Module Boundaries
|
||||||
|
|
||||||
|
Imported projects may lack `tags`. Add tags or update `@nx/enforce-module-boundaries` rules.
|
||||||
|
|
||||||
|
### Project Name Collisions (Multi-Import)
|
||||||
|
|
||||||
|
Same `name` in `package.json` across source and dest causes `MultipleProjectsWithSameNameError`. **Fix**: Rename conflicting names (e.g. `@org/api` → `@org/teama-api`), update all dep references and import statements, `pnpm install`. The root `package.json` of each imported repo also becomes a project — rename those too.
|
||||||
|
|
||||||
|
### Workspace Dep Import Ordering
|
||||||
|
|
||||||
|
`pnpm install` fails during `nx import` if a `"workspace:*"` dependency hasn't been imported yet. File operations still succeed. **Fix**: Import all projects first, then `pnpm install --no-frozen-lockfile`.
|
||||||
|
|
||||||
|
### `.gitkeep` Blocking Subdirectory Import
|
||||||
|
|
||||||
|
The TS preset creates `packages/.gitkeep`. Remove it and commit before importing.
|
||||||
|
|
||||||
|
### Frontend tsconfig Base Settings (Critical)
|
||||||
|
|
||||||
|
The TS preset defaults (`module: "nodenext"`, `moduleResolution: "nodenext"`, `lib: ["es2022"]`) are incompatible with frontend frameworks (React, Next.js, Vue, Vite). After importing frontend projects, verify the dest root `tsconfig.base.json`:
|
||||||
|
|
||||||
|
- **`moduleResolution`**: Must be `"bundler"` (not `"nodenext"`)
|
||||||
|
- **`module`**: Must be `"esnext"` (not `"nodenext"`)
|
||||||
|
- **`lib`**: Must include `"dom"` and `"dom.iterable"` (frontend projects need these)
|
||||||
|
- **`jsx`**: `"react-jsx"` for React-only workspaces, per-project for mixed frameworks
|
||||||
|
|
||||||
|
For **subdirectory imports**, the dest root tsconfig is authoritative — update it. For **whole-repo imports**, imported projects may extend their own nested `tsconfig.base.json`, making this less critical.
|
||||||
|
|
||||||
|
If the dest also has backend projects needing `nodenext`, use per-project overrides instead of changing the root.
|
||||||
|
|
||||||
|
**Gotcha**: TypeScript does NOT merge `lib` arrays — a project-level override **replaces** the base array entirely. Always include all needed entries (e.g. `es2022`, `dom`, `dom.iterable`) in any project-level `lib`.
|
||||||
|
|
||||||
|
### `@nx/react` Typings for Libraries
|
||||||
|
|
||||||
|
React libraries generated with `@nx/react:library` reference `@nx/react/typings/cssmodule.d.ts` and `@nx/react/typings/image.d.ts` in their tsconfig `types`. These fail with `Cannot find type definition file` unless `@nx/react` is installed in the dest workspace.
|
||||||
|
|
||||||
|
**Fix**: `pnpm add -wD @nx/react`
|
||||||
|
|
||||||
|
### Jest Preset Missing (Subdirectory Import)
|
||||||
|
|
||||||
|
Nx presets create `jest.preset.js` at the workspace root, and project jest configs reference it (e.g. `../../jest.preset.js`). Subdirectory import does NOT bring this file.
|
||||||
|
|
||||||
|
**Fix**:
|
||||||
|
|
||||||
|
1. Run `npx nx add @nx/jest` — registers `@nx/jest/plugin` in `nx.json` and updates `namedInputs`
|
||||||
|
2. Create `jest.preset.js` at workspace root (see `references/JEST.md` for content) — `nx add` only creates this when a generator runs, not on bare `nx add`
|
||||||
|
3. Install test runner deps: `pnpm add -wD jest jest-environment-jsdom ts-jest @types/jest`
|
||||||
|
4. Install framework-specific test deps as needed (see `references/JEST.md`)
|
||||||
|
|
||||||
|
For deeper Jest issues (tsconfig.spec.json, Babel transforms, CI atomization, Jest vs Vitest coexistence), see `references/JEST.md`.
|
||||||
|
|
||||||
|
### Target Name Prefixing (Whole-Repo Import)
|
||||||
|
|
||||||
|
When importing a project with existing npm scripts (`build`, `dev`, `start`, `lint`), Nx plugins auto-prefix inferred target names to avoid conflicts: e.g. `next:build`, `vite:build`, `eslint:lint`.
|
||||||
|
|
||||||
|
**Fix**: Remove the Nx-rewritten npm scripts from the imported `package.json`, then either:
|
||||||
|
|
||||||
|
- Accept the prefixed names (e.g. `nx run app:next:build`)
|
||||||
|
- Rename plugin target names in `nx.json` to use unprefixed names
|
||||||
|
|
||||||
|
## Non-Nx Source Issues
|
||||||
|
|
||||||
|
When the source is a plain pnpm/npm workspace without `nx.json`.
|
||||||
|
|
||||||
|
### npm Script Rewriting (Critical)
|
||||||
|
|
||||||
|
Nx rewrites `package.json` scripts during init, creating broken commands (e.g. `vitest run` → `nx test run`). **Fix**: Remove all rewritten scripts — Nx plugins infer targets from config files.
|
||||||
|
|
||||||
|
### `noEmit` → `composite` + `emitDeclarationOnly` (Critical)
|
||||||
|
|
||||||
|
Plain TS projects use `"noEmit": true`, incompatible with Nx project references.
|
||||||
|
|
||||||
|
**Symptoms**: "typecheck target is disabled because one or more project references set 'noEmit: true'" or TS6310.
|
||||||
|
|
||||||
|
**Fix** in **all** imported tsconfigs:
|
||||||
|
|
||||||
|
1. Remove `"noEmit": true`. If inherited via extends chain, set `"noEmit": false` explicitly.
|
||||||
|
2. Add `"composite": true`, `"emitDeclarationOnly": true`, `"declarationMap": true`
|
||||||
|
3. Add `"outDir": "dist"` and `"tsBuildInfoFile": "dist/tsconfig.tsbuildinfo"`
|
||||||
|
4. Add `"extends": "../../tsconfig.base.json"` if missing. Remove settings now inherited from base.
|
||||||
|
|
||||||
|
### Stale node_modules and Lockfiles
|
||||||
|
|
||||||
|
`nx import` may bring `node_modules/` (pnpm symlinks pointing to the source filesystem) and `pnpm-lock.yaml` from the source. Both are stale.
|
||||||
|
|
||||||
|
**Fix**: `rm -rf imported/node_modules imported/pnpm-lock.yaml imported/pnpm-workspace.yaml imported/.gitignore`, then `pnpm install`.
|
||||||
|
|
||||||
|
### ESLint Config Handling
|
||||||
|
|
||||||
|
- **Legacy `.eslintrc.json` (ESLint 8)**: Delete all `.eslintrc.*`, remove v8 deps, create flat `eslint.config.mjs`.
|
||||||
|
- **Flat config (`eslint.config.js`)**: Self-contained configs can often be left as-is.
|
||||||
|
- **No ESLint**: Create both root and project-level configs from scratch.
|
||||||
|
|
||||||
|
### TypeScript `paths` Aliases
|
||||||
|
|
||||||
|
Nx uses `package.json` `"exports"` + pnpm workspace linking instead of tsconfig `"paths"`. If packages have proper `"exports"`, paths are redundant. Otherwise, update paths for the new directory structure.
|
||||||
|
|
||||||
|
## Technology-specific Guidance
|
||||||
|
|
||||||
|
Identify technologies in the source repo, then read and apply the matching reference file(s).
|
||||||
|
|
||||||
|
Available references:
|
||||||
|
|
||||||
|
- `references/ESLINT.md` — ESLint projects: duplicate `lint`/`eslint:lint` targets, legacy `.eslintrc.*` linting generated files, flat config `.cjs` self-linting, `typescript-eslint` v7/v9 peer dep conflict, mixed ESLint v8+v9 in one workspace.
|
||||||
|
- `references/GRADLE.md`
|
||||||
|
- `references/JEST.md` — Jest testing: `@nx/jest/plugin` setup, jest.preset.js, testing deps by framework, tsconfig.spec.json, Jest vs Vitest coexistence, Babel transforms, CI atomization.
|
||||||
|
- `references/NEXT.md` — Next.js projects: `@nx/next/plugin` targets, `withNx`, Next.js TS config (`noEmit`, `jsx: "preserve"`), auto-installing deps via wrong PM, non-Nx `create-next-app` imports, mixed Next.js+Vite coexistence.
|
||||||
|
- `references/TURBOREPO.md`
|
||||||
|
- `references/VITE.md` — Vite projects (React, Vue, or both): `@nx/vite/plugin` typecheck target, `resolve.alias`/`__dirname` fixes, framework deps, Vue-specific setup, mixed React+Vue coexistence.
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
## ESLint
|
||||||
|
|
||||||
|
ESLint-specific guidance for `nx import`. For generic import issues (root deps, pnpm globs, project references), see `SKILL.md`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### How `@nx/eslint/plugin` Works
|
||||||
|
|
||||||
|
`@nx/eslint/plugin` scans for ESLint config files and creates a lint target for each project. It detects **both** flat config files (`eslint.config.{js,mjs,cjs,ts,mts,cts}`) and legacy config files (`.eslintrc.{json,js,cjs,mjs,yml,yaml}`).
|
||||||
|
|
||||||
|
**Plugin options (set during `nx add @nx/eslint`):**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"plugin": "@nx/eslint/plugin",
|
||||||
|
"options": {
|
||||||
|
"targetName": "eslint:lint"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Auto-installation**: `nx import` auto-detects ESLint config files and offers to install `@nx/eslint`. Accept the offer — it registers the plugin and updates `namedInputs.production` to exclude ESLint config files.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Duplicate `lint` and `eslint:lint` Targets
|
||||||
|
|
||||||
|
After import, projects will have **two** lint-related targets if the source `package.json` has a `"lint"` npm script:
|
||||||
|
|
||||||
|
- `eslint:lint` — inferred by `@nx/eslint/plugin`; has proper caching and input/output tracking
|
||||||
|
- `lint` — created by Nx from the npm script via `nx:run-script`; no caching intelligence, just wraps `npm run lint`
|
||||||
|
|
||||||
|
**Fix**: Remove the `"lint"` script from each project's `package.json`. Keep `"lint:fix"` if present — there is no plugin-inferred equivalent for auto-fixing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Legacy `.eslintrc.*` Configs Linting Generated Files
|
||||||
|
|
||||||
|
When `@nx/eslint/plugin` runs `eslint .` on a project with a legacy `.eslintrc.*` config that uses `parserOptions.project`, it tries to lint **all** files in the project directory including:
|
||||||
|
|
||||||
|
- Generated `dist/**/*.d.ts` files (not in tsconfig `include`)
|
||||||
|
- The `.eslintrc.js` config file itself (not in tsconfig `include`)
|
||||||
|
|
||||||
|
This causes `Parsing error: ESLint was configured to run on X using parserOptions.project, however that TSConfig does not include this file`.
|
||||||
|
|
||||||
|
**Fix**: Add `ignorePatterns` to the `.eslintrc.*` config:
|
||||||
|
|
||||||
|
```json
|
||||||
|
// .eslintrc.json
|
||||||
|
{
|
||||||
|
"ignorePatterns": ["dist/**"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
// .eslintrc.js — also ignore the config file itself since module.exports isn't in tsconfig
|
||||||
|
module.exports = {
|
||||||
|
ignorePatterns: ['dist/**', '.eslintrc.js'],
|
||||||
|
// ...
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Flat Config `.cjs` Files Self-Linting
|
||||||
|
|
||||||
|
When a project uses `eslint.config.cjs` (CJS flat config), `eslint .` lints the config file itself. The `require()` call on line 1 triggers `@typescript-eslint/no-require-imports`.
|
||||||
|
|
||||||
|
**Fix**: Add the config filename to the top-level `ignores` array:
|
||||||
|
|
||||||
|
```js
|
||||||
|
module.exports = tseslint.config(
|
||||||
|
{
|
||||||
|
ignores: ['dist/**', 'node_modules/**', 'eslint.config.cjs'],
|
||||||
|
},
|
||||||
|
// ...
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
The same applies to `eslint.config.js` in a CJS project (no `"type": "module"`) if it uses `require()`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `typescript-eslint` Version Conflict With ESLint 9
|
||||||
|
|
||||||
|
`typescript-eslint@7.x` declares `peerDependencies: { "eslint": "^8.56.0" }`, but it is commonly used alongside `"eslint": "^9.0.0"`. npm treats this as a hard peer dep conflict and refuses to install.
|
||||||
|
|
||||||
|
**Root cause**: `@nx/eslint` init adds `eslint@~8.57.0` at the workspace root (for its own peer deps). Workspace packages that request `eslint@^9.0.0` + `typescript-eslint@^7.0.0` trigger the conflict when npm resolves their deps.
|
||||||
|
|
||||||
|
**Fix**: Upgrade `typescript-eslint` from `^7.0.0` to `^8.0.0` directly in the affected workspace package's `package.json`. The `tseslint.config()` API and `tseslint.configs.recommended` are identical between v7 and v8 — no config changes needed.
|
||||||
|
|
||||||
|
```json
|
||||||
|
// packages/my-package/package.json
|
||||||
|
{
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript-eslint": "^8.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note**: npm's root-level `"overrides"` field does not force versions for workspace packages' direct dependencies — update each package.json individually.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Mixed ESLint v8 and v9 in One Workspace
|
||||||
|
|
||||||
|
Legacy v8 and flat-config v9 packages can coexist in the same workspace. Each package resolves its own `eslint` version. The root `eslint@~8.57.0` (added by `@nx/eslint` init) is used by legacy v8 packages; v9 packages get their own hoisted `eslint@9`.
|
||||||
|
|
||||||
|
`@nx/eslint/plugin` infers `eslint:lint` targets for **both** config formats. Legacy packages run ESLint v8 with `.eslintrc.*`; flat-config packages run ESLint v9 with `eslint.config.*`. No special nx.json configuration is needed to support both simultaneously.
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
## Gradle
|
||||||
|
|
||||||
|
- If you import an entire Gradle repository into a subfolder, files like `gradlew`, `gradlew.bat`, and `gradle/wrapper` will end up inside that imported subfolder.
|
||||||
|
- The `@nx/gradle` plugin expects those files at the workspace root to infer Gradle projects/tasks automatically.
|
||||||
|
- If the target workspace has no Gradle setup yet, consider moving those files to the root (especially when using `@nx/gradle`).
|
||||||
|
- If the target workspace already has Gradle configured, avoid duplicate wrappers: remove imported duplicates from the subfolder or merge carefully.
|
||||||
|
- Because the import lands in a subfolder, Gradle project references can break; review settings and project path references, then fix any errors.
|
||||||
|
- If `@nx/gradle` is installed, run `nx show projects` to verify that Gradle projects are being inferred.
|
||||||
|
|
||||||
|
Helpful docs:
|
||||||
|
|
||||||
|
- https://nx.dev/docs/technologies/java/gradle/introduction
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
## Jest
|
||||||
|
|
||||||
|
Jest-specific guidance for `nx import`. For the basic "Jest Preset Missing" fix (create `jest.preset.js`, install deps), see `SKILL.md`. This file covers deeper Jest integration issues.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### How `@nx/jest` Works
|
||||||
|
|
||||||
|
`@nx/jest/plugin` scans for `jest.config.{ts,js,cjs,mjs,cts,mts}` and creates a `test` target for each project.
|
||||||
|
|
||||||
|
**Plugin options:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"plugin": "@nx/jest/plugin",
|
||||||
|
"options": {
|
||||||
|
"targetName": "test"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`npx nx add @nx/jest` does two things:
|
||||||
|
|
||||||
|
1. **Registers `@nx/jest/plugin` in `nx.json`** — without this, no `test` targets are inferred
|
||||||
|
2. Updates `namedInputs.production` to exclude test files
|
||||||
|
|
||||||
|
**Gotcha**: `nx add @nx/jest` does NOT create `jest.preset.js` — that file is only generated when you run a generator (e.g. `@nx/jest:configuration`). For imports, you must create it manually (see "Jest Preset" section below).
|
||||||
|
|
||||||
|
**Other gotcha**: If you create `jest.preset.js` manually but skip `npx nx add @nx/jest`, the plugin won't be registered and `nx run PROJECT:test` will fail with "Cannot find target 'test'". You need both.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Jest Preset
|
||||||
|
|
||||||
|
The preset provides shared Jest configuration (test patterns, ts-jest transform, resolver, jsdom environment).
|
||||||
|
|
||||||
|
**Root `jest.preset.js`:**
|
||||||
|
|
||||||
|
```js
|
||||||
|
const nxPreset = require('@nx/jest/preset').default;
|
||||||
|
module.exports = { ...nxPreset };
|
||||||
|
```
|
||||||
|
|
||||||
|
**Project `jest.config.ts`:**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export default {
|
||||||
|
displayName: 'my-lib',
|
||||||
|
preset: '../../jest.preset.js',
|
||||||
|
// project-specific overrides
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
The `preset` path is relative from the project root to the workspace root. Subdirectory imports preserve the original relative path (e.g. `../../jest.preset.js`), which resolves correctly if the import destination matches the source directory depth.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Testing Dependencies
|
||||||
|
|
||||||
|
#### Core (always needed)
|
||||||
|
|
||||||
|
```
|
||||||
|
pnpm add -wD jest ts-jest @types/jest @nx/jest
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Environment-specific
|
||||||
|
|
||||||
|
- **DOM testing** (React, Vue, browser libs): `jest-environment-jsdom`
|
||||||
|
- **Node testing** (APIs, CLIs): no extra deps (Jest defaults to `node` env, but Nx preset defaults to `jsdom`)
|
||||||
|
|
||||||
|
#### React testing
|
||||||
|
|
||||||
|
```
|
||||||
|
pnpm add -wD @testing-library/react @testing-library/jest-dom
|
||||||
|
```
|
||||||
|
|
||||||
|
#### React with Babel (non-ts-jest transform)
|
||||||
|
|
||||||
|
Some React projects use Babel instead of ts-jest for JSX transformation:
|
||||||
|
|
||||||
|
```
|
||||||
|
pnpm add -wD babel-jest @babel/core @babel/preset-env @babel/preset-react @babel/preset-typescript
|
||||||
|
```
|
||||||
|
|
||||||
|
**When**: Project `jest.config` has `transform` using `babel-jest` instead of `ts-jest`. Common in older Nx workspaces and CRA migrations.
|
||||||
|
|
||||||
|
#### Vue testing
|
||||||
|
|
||||||
|
```
|
||||||
|
pnpm add -wD @vue/test-utils
|
||||||
|
```
|
||||||
|
|
||||||
|
Vue projects typically use Vitest (not Jest) — see VITE.md.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `tsconfig.spec.json`
|
||||||
|
|
||||||
|
Jest projects need a `tsconfig.spec.json` that includes test files:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "../../dist/out-tsc",
|
||||||
|
"module": "commonjs",
|
||||||
|
"types": ["jest", "node"]
|
||||||
|
},
|
||||||
|
"include": ["jest.config.ts", "src/**/*.test.ts", "src/**/*.spec.ts", "src/**/*.d.ts"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Common issues after import:**
|
||||||
|
|
||||||
|
- Missing `"types": ["jest", "node"]` — causes `describe`/`it`/`expect` to be unrecognized
|
||||||
|
- Missing `"module": "commonjs"` — Jest doesn't support ESM by default (ts-jest transpiles to CJS)
|
||||||
|
- `include` array missing test patterns — TypeScript won't check test files
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Jest vs Vitest Coexistence
|
||||||
|
|
||||||
|
Workspaces can have both:
|
||||||
|
|
||||||
|
- **Jest**: Next.js apps, older React libs, Node libraries
|
||||||
|
- **Vitest**: Vite-based React/Vue apps and libs
|
||||||
|
|
||||||
|
Both `@nx/jest/plugin` and `@nx/vite/plugin` (which infers Vitest targets) coexist without conflicts — they detect different config files (`jest.config.*` vs `vite.config.*`).
|
||||||
|
|
||||||
|
**Target naming**: Both default to `test`. If a project somehow has both config files, rename one:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"plugin": "@nx/jest/plugin",
|
||||||
|
"options": { "targetName": "jest-test" }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `@testing-library/jest-dom` — Jest vs Vitest
|
||||||
|
|
||||||
|
Projects migrating from Jest to Vitest (or workspaces with both) need different imports:
|
||||||
|
|
||||||
|
**Jest** (in `test-setup.ts`):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import '@testing-library/jest-dom';
|
||||||
|
```
|
||||||
|
|
||||||
|
**Vitest** (in `test-setup.ts`):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import '@testing-library/jest-dom/vitest';
|
||||||
|
```
|
||||||
|
|
||||||
|
If the source used Jest but the dest workspace uses Vitest for that project type, update the import path. Also add `@testing-library/jest-dom` to tsconfig `types` array.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Non-Nx Source: Test Script Rewriting
|
||||||
|
|
||||||
|
Nx rewrites `package.json` scripts during init. Test scripts get broken:
|
||||||
|
|
||||||
|
- `"test": "jest"` → `"test": "nx test"` (circular if no executor configured)
|
||||||
|
- `"test": "vitest run"` → `"test": "nx test run"` (broken — `run` becomes an argument)
|
||||||
|
|
||||||
|
**Fix**: Remove all rewritten test scripts. `@nx/jest/plugin` and `@nx/vite/plugin` infer test targets from config files.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### CI Atomization
|
||||||
|
|
||||||
|
`@nx/jest/plugin` supports splitting tests per-file for CI parallelism:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"plugin": "@nx/jest/plugin",
|
||||||
|
"options": {
|
||||||
|
"targetName": "test",
|
||||||
|
"ciTargetName": "test-ci"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This creates `test-ci--src/lib/foo.spec.ts` targets for each test file, enabling Nx Cloud distribution. Not relevant during import, but useful for post-import CI setup.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Common Post-Import Issues
|
||||||
|
|
||||||
|
1. **"Cannot find target 'test'"**: `@nx/jest/plugin` not registered in `nx.json`. Run `npx nx add @nx/jest` or manually add the plugin entry.
|
||||||
|
|
||||||
|
2. **"Cannot find module 'jest-preset'"**: `jest.preset.js` missing at workspace root. Create it (see SKILL.md).
|
||||||
|
|
||||||
|
3. **"Cannot find type definition file for 'jest'"**: Missing `@types/jest` or `tsconfig.spec.json` doesn't have `"types": ["jest", "node"]`.
|
||||||
|
|
||||||
|
4. **Tests fail with "Cannot use import statement outside a module"**: `ts-jest` not installed or not configured as transform. Check `jest.config.ts` transform section.
|
||||||
|
|
||||||
|
5. **Snapshot path mismatches**: After import, `__snapshots__` directories may have paths baked in. Run tests once with `--updateSnapshot` to regenerate.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fix Order
|
||||||
|
|
||||||
|
### Subdirectory Import (Nx Source)
|
||||||
|
|
||||||
|
1. `npx nx add @nx/jest` — registers plugin in `nx.json` (does NOT create `jest.preset.js`)
|
||||||
|
2. Create `jest.preset.js` manually (see "Jest Preset" section above)
|
||||||
|
3. Install deps: `pnpm add -wD jest jest-environment-jsdom ts-jest @types/jest`
|
||||||
|
4. Install framework test deps: `@testing-library/react @testing-library/jest-dom` (React), `@vue/test-utils` (Vue)
|
||||||
|
5. Verify `tsconfig.spec.json` has `"types": ["jest", "node"]`
|
||||||
|
6. `nx run-many -t test`
|
||||||
|
|
||||||
|
### Whole-Repo Import (Non-Nx Source)
|
||||||
|
|
||||||
|
1. Remove rewritten test scripts from `package.json`
|
||||||
|
2. `npx nx add @nx/jest` — registers plugin (does NOT create preset)
|
||||||
|
3. Create `jest.preset.js` manually
|
||||||
|
4. Install deps (same as above)
|
||||||
|
5. Verify/fix `jest.config.*` — ensure `preset` path points to root `jest.preset.js`
|
||||||
|
6. Verify/fix `tsconfig.spec.json` — add `types`, `module`, `include` if missing
|
||||||
|
7. `nx run-many -t test`
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
## Next.js
|
||||||
|
|
||||||
|
Next.js-specific guidance for `nx import`. For generic import issues (pnpm globs, root deps, project references, name collisions, ESLint, frontend tsconfig base settings, `@nx/react` typings, Jest preset, target name prefixing, non-Nx source handling), see `SKILL.md`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `@nx/next/plugin` Inferred Targets
|
||||||
|
|
||||||
|
`@nx/next/plugin` detects `next.config.{ts,js,cjs,mjs}` and creates these targets:
|
||||||
|
|
||||||
|
- `build` → `next build` (with `dependsOn: ['^build']`)
|
||||||
|
- `dev` → `next dev`
|
||||||
|
- `start` → `next start` (depends on `build`)
|
||||||
|
- `serve-static` → same as `start`
|
||||||
|
- `build-deps` / `watch-deps` — for TS solution setup
|
||||||
|
|
||||||
|
**No separate typecheck target** — Next.js runs TypeScript checking as part of `next build`. The `@nx/js/typescript` plugin provides a standalone `typecheck` target for non-Next libraries in the workspace.
|
||||||
|
|
||||||
|
**Build target conflict**: Both `@nx/next/plugin` and `@nx/js/typescript` define a `build` target. `@nx/next/plugin` wins for Next.js projects (it detects `next.config.*`), while `@nx/js/typescript` handles libraries with `tsconfig.lib.json`. No rename needed — they coexist.
|
||||||
|
|
||||||
|
### `withNx` in `next.config.js`
|
||||||
|
|
||||||
|
Nx-generated Next.js projects use `composePlugins(withNx)` from `@nx/next`. This wrapper is optional for `next build` via the inferred plugin (which just runs `next build`), but it provides Nx-specific configuration. Keep it if present.
|
||||||
|
|
||||||
|
### Root Dependencies for Next.js
|
||||||
|
|
||||||
|
Beyond the generic root deps issue (see SKILL.md), Next.js projects typically need:
|
||||||
|
|
||||||
|
**Core**: `react`, `react-dom`, `@types/react`, `@types/react-dom`, `@types/node`, `@nx/react` (see SKILL.md for `@nx/react` typings)
|
||||||
|
**Nx plugins**: `@nx/next` (auto-installed by import), `@nx/eslint`, `@nx/jest`
|
||||||
|
**Testing**: see SKILL.md "Jest Preset Missing" section
|
||||||
|
**ESLint**: `@next/eslint-plugin-next` (in addition to generic ESLint deps from SKILL.md)
|
||||||
|
|
||||||
|
### Next.js Auto-Installing Dependencies via Wrong Package Manager
|
||||||
|
|
||||||
|
Next.js detects missing `@types/react` during `next build` and tries to install it using `yarn add` regardless of the actual package manager. In a pnpm workspace, this fails with a "nearest package directory isn't part of the project" error.
|
||||||
|
|
||||||
|
**Root cause**: `@types/react` is missing from root devDependencies.
|
||||||
|
**Fix**: Install deps at the root before building: `pnpm add -wD @types/react @types/react-dom`
|
||||||
|
|
||||||
|
### Next.js TypeScript Config Specifics
|
||||||
|
|
||||||
|
Next.js app tsconfigs have unique patterns compared to Vite:
|
||||||
|
|
||||||
|
- **`noEmit: true`** with `emitDeclarationOnly: false` — Next.js handles emit, TS just checks types. This conflicts with `composite: true` from the TS solution setup.
|
||||||
|
- **`"types": ["jest", "node"]`** — includes test types in the main tsconfig (no separate `tsconfig.app.json`)
|
||||||
|
- **`"plugins": [{ "name": "next" }]`** — for IDE integration
|
||||||
|
- **`include`** references `.next/types/**/*.ts` for Next.js auto-generated types
|
||||||
|
- **`"jsx": "preserve"`** — Next.js uses its own JSX transform, not React's
|
||||||
|
|
||||||
|
**Gotcha**: The Next.js tsconfig sets `"noEmit": true` which disables `composite` mode. This is fine because Next.js projects use `next build` for building, not `tsc`. The `@nx/js/typescript` plugin's `typecheck` target is not needed for Next.js apps.
|
||||||
|
|
||||||
|
### `next.config.js` Lint Warning
|
||||||
|
|
||||||
|
Imported Next.js configs may have `// eslint-disable-next-line @typescript-eslint/no-var-requires` but the project ESLint config enables different rule sets. This produces `Unused eslint-disable directive` warnings. Harmless — remove the comment or ignore.
|
||||||
|
|
||||||
|
### `@nx/next:init` Rewrites All npm Scripts (Whole-Repo Import)
|
||||||
|
|
||||||
|
When `@nx/next:init` runs during a whole-repo import, it rewrites the project's `package.json` scripts to prefixed `nx` calls:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"dev": "nx next:dev",
|
||||||
|
"build": "nx next:build",
|
||||||
|
"start": "nx next:start"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This is the standard "npm Script Rewriting" issue from SKILL.md, but triggered by `@nx/next:init` rather than Nx init. **Fix**: Remove all rewritten scripts from `package.json` — `@nx/next/plugin` infers all targets from `next.config.*`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Non-Nx Source (create-next-app)
|
||||||
|
|
||||||
|
### Whole-Repo Import Recommended
|
||||||
|
|
||||||
|
For single-project `create-next-app` repos, use whole-repo import into a subdirectory:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nx import /path/to/source apps/web --ref=main --source=. --no-interactive
|
||||||
|
```
|
||||||
|
|
||||||
|
### `next-env.d.ts`
|
||||||
|
|
||||||
|
`next build` auto-generates `next-env.d.ts` at the project root. Add `next-env.d.ts` to the dest root `.gitignore` — it is framework-generated and should not be committed.
|
||||||
|
|
||||||
|
### ESLint: Self-Contained `eslint-config-next`
|
||||||
|
|
||||||
|
`create-next-app` generates a flat ESLint config using `eslint-config-next` (which bundles its own plugins). This is **self-contained** — no root `eslint.config.mjs` needed, no `@nx/eslint-plugin` dependency. The `@nx/eslint/plugin` detects it and creates a lint target.
|
||||||
|
|
||||||
|
### TypeScript: No Changes Needed
|
||||||
|
|
||||||
|
Non-Nx Next.js projects have self-contained tsconfigs with `noEmit: true`, their own `lib`, `module`, `moduleResolution`, and `jsx` settings. Since `next build` handles type checking internally, no tsconfig modifications are needed. The project does NOT need to extend `tsconfig.base.json`.
|
||||||
|
|
||||||
|
**Gotcha**: The `@nx/js/typescript` plugin won't create a `typecheck` target because there's no `tsconfig.lib.json`. This is fine — use `next:build` for type checking.
|
||||||
|
|
||||||
|
### `noEmit: true` and TS Solution Setup
|
||||||
|
|
||||||
|
Non-Nx Next.js projects use `noEmit: true`, which conflicts with Nx's TS solution setup (`composite: true`). If the dest workspace uses project references and you want the Next.js app to participate:
|
||||||
|
|
||||||
|
1. Remove `noEmit: true`, add `composite: true`, `emitDeclarationOnly: true`
|
||||||
|
2. Add `extends: "../../tsconfig.base.json"`
|
||||||
|
3. Add `outDir` and `tsBuildInfoFile`
|
||||||
|
|
||||||
|
**However**, this is optional for standalone Next.js apps that don't export types consumed by other workspace projects.
|
||||||
|
|
||||||
|
### Tailwind / PostCSS
|
||||||
|
|
||||||
|
`create-next-app` with Tailwind generates `postcss.config.mjs`. This works as-is after import — no path changes needed since PostCSS resolves relative to the project root.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Mixed Next.js + Vite Coexistence
|
||||||
|
|
||||||
|
When both Next.js and Vite projects exist in the same workspace.
|
||||||
|
|
||||||
|
### Plugin Coexistence
|
||||||
|
|
||||||
|
Both `@nx/next/plugin` and `@nx/vite/plugin` can coexist in `nx.json`. They detect different config files (`next.config.*` vs `vite.config.*`) so there are no conflicts. The `@nx/js/typescript` plugin handles libraries.
|
||||||
|
|
||||||
|
### Vite Standalone Project tsconfig Fixes
|
||||||
|
|
||||||
|
Vite standalone projects (imported as whole-repo) have self-contained tsconfigs without `composite: true`. The `@nx/js/typescript` plugin's typecheck target runs `tsc --build --emitDeclarationOnly` which requires `composite`.
|
||||||
|
|
||||||
|
**Fix**:
|
||||||
|
|
||||||
|
1. Add `extends: "../../tsconfig.base.json"` to the root project tsconfig
|
||||||
|
2. Add `composite: true`, `declaration: true`, `declarationMap: true`, `tsBuildInfoFile` to `tsconfig.app.json` and `tsconfig.spec.json`
|
||||||
|
3. Set `moduleResolution: "bundler"` (replace `"node"`)
|
||||||
|
4. Add source files to `tsconfig.spec.json` `include` — specs import app code, and `composite` mode requires all files to be listed
|
||||||
|
|
||||||
|
### Typecheck Target Names
|
||||||
|
|
||||||
|
- `@nx/vite/plugin` defaults `typecheckTargetName` to `"vite:typecheck"`
|
||||||
|
- `@nx/js/typescript` uses `"typecheck"`
|
||||||
|
- Next.js projects have NO standalone typecheck target — Next.js runs type checking during `next build`
|
||||||
|
|
||||||
|
No naming conflicts between frameworks.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fix Order — Nx Source (Subdirectory Import)
|
||||||
|
|
||||||
|
1. Import Next.js apps into `apps/<name>` (see SKILL.md: "Application vs Library Detection")
|
||||||
|
2. Generic fixes from SKILL.md (pnpm globs, root deps, `.gitkeep` removal, frontend tsconfig base settings, `@nx/react` typings)
|
||||||
|
3. Install Next.js-specific deps: `pnpm add -wD @next/eslint-plugin-next`
|
||||||
|
4. ESLint setup (see SKILL.md: "Root ESLint Config Missing")
|
||||||
|
5. Jest setup (see SKILL.md: "Jest Preset Missing")
|
||||||
|
6. `nx reset && nx sync --yes && nx run-many -t typecheck,build,test,lint`
|
||||||
|
|
||||||
|
## Fix Order — Non-Nx Source (create-next-app)
|
||||||
|
|
||||||
|
1. Import into `apps/<name>` (see SKILL.md: "Application vs Library Detection")
|
||||||
|
2. Generic fixes from SKILL.md (pnpm globs, stale files cleanup, script rewriting, target name prefixing)
|
||||||
|
3. (Optional) If app needs to export types for other workspace projects: fix `noEmit` → `composite` (see SKILL.md)
|
||||||
|
4. `nx reset && nx run-many -t next:build,eslint:lint` (or unprefixed names if renamed)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Iteration Log
|
||||||
|
|
||||||
|
### Scenario 1: Basic Nx Next.js App Router + Shared Lib → TS preset (PASS)
|
||||||
|
|
||||||
|
- Source: CNW next preset (Next.js 16, App Router) + `@nx/react:library` shared-ui
|
||||||
|
- Dest: CNW ts preset (Nx 23)
|
||||||
|
- Import: subdirectory-at-a-time (apps, libs separately)
|
||||||
|
- Errors found & fixed:
|
||||||
|
1. pnpm-workspace.yaml: `apps`/`libs` → `apps/*`/`libs/*`
|
||||||
|
2. Root tsconfig: `nodenext` → `bundler`, add `dom`/`dom.iterable` to `lib`, add `jsx: react-jsx`
|
||||||
|
3. Missing `@nx/react` (for CSS module/image type defs in lib)
|
||||||
|
4. Missing `@types/react`, `@types/react-dom`, `@types/node`
|
||||||
|
5. Next.js trying `yarn add @types/react` — fixed by installing at root
|
||||||
|
6. Missing `@nx/eslint`, root `eslint.config.mjs`, ESLint plugins
|
||||||
|
7. Missing `@nx/jest`, `jest.preset.js`, `jest-environment-jsdom`, `ts-jest`
|
||||||
|
- All targets green: typecheck, build, test, lint
|
||||||
|
|
||||||
|
### Scenario 3: Non-Nx create-next-app (App Router + Tailwind) → TS preset (PASS)
|
||||||
|
|
||||||
|
- Source: `create-next-app@latest` (Next.js 16.1.6, App Router, Tailwind v4, flat ESLint config)
|
||||||
|
- Dest: CNW ts preset (Nx 23)
|
||||||
|
- Import: whole-repo into `apps/web`
|
||||||
|
- Errors found & fixed:
|
||||||
|
1. pnpm-workspace.yaml: `apps/web` → `apps/*`
|
||||||
|
2. Stale files: `node_modules/`, `pnpm-lock.yaml`, `pnpm-workspace.yaml`, `.gitignore` — deleted
|
||||||
|
3. Nx-rewritten npm scripts (`"build": "nx next:build"`, etc.) — removed
|
||||||
|
- No tsconfig changes needed — self-contained config with `noEmit: true`
|
||||||
|
- ESLint self-contained via `eslint-config-next` — no root config needed
|
||||||
|
- No test setup (create-next-app doesn't include tests)
|
||||||
|
- All targets green: next:build, eslint:lint
|
||||||
|
|
||||||
|
### Scenario 4: Non-Nx create-next-app (alongside Vite, React Router 7, TanStack, CRA) → TS preset (PASS)
|
||||||
|
|
||||||
|
- See VITE.md Scenario 6 for the full multi-import scenario
|
||||||
|
- Next.js-specific findings:
|
||||||
|
1. `@nx/next:init` rewrote all scripts to `nx next:*` format — removed all rewritten scripts
|
||||||
|
2. Stale files: `node_modules/`, `package-lock.json`, `.gitignore` — deleted (npm workspace, no pnpm files)
|
||||||
|
3. ESLint self-contained via `eslint-config-next` — no root config needed
|
||||||
|
4. No tsconfig changes needed — `noEmit: true` stays; `next build` handles type checking
|
||||||
|
- Targets: `next:build`, `next:dev`, `next:start`, `eslint:lint`
|
||||||
|
|
||||||
|
### Scenario 5: Mixed Next.js (Nx) + Vite React (standalone) → TS preset (PASS)
|
||||||
|
|
||||||
|
- Source A: CNW next preset (Next.js 16, App Router) — subdirectory import of `apps/`
|
||||||
|
- Source B: CNW react-standalone preset (Vite 7, React 19) — whole-repo import into `apps/vite-app`
|
||||||
|
- Dest: CNW ts preset (Nx 23)
|
||||||
|
- Errors found & fixed:
|
||||||
|
1. All Scenario 1 fixes for the Next.js app
|
||||||
|
2. Stale files from Vite source: `node_modules/`, `pnpm-lock.yaml`, `pnpm-workspace.yaml`, `.gitignore`, `nx.json`
|
||||||
|
3. Removed rewritten scripts from Vite app's `package.json`
|
||||||
|
4. ESLint 8 vs 9 conflict — `@nx/eslint` peer on ESLint 8 resolved wrong version. Fixed with `pnpm.overrides`
|
||||||
|
5. Vite tsconfigs missing `composite: true`, `declaration: true` — needed for `tsc --build --emitDeclarationOnly`
|
||||||
|
6. Vite `tsconfig.spec.json` `include` missing source files — specs import app code
|
||||||
|
7. Vite tsconfig `moduleResolution: "node"` → `"bundler"`, added `extends: "../../tsconfig.base.json"`
|
||||||
|
- All targets green: typecheck, build, test, lint for both projects
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
## Turborepo
|
||||||
|
|
||||||
|
- Nx replaces Turborepo task orchestration, but a clean migration requires handling Turborepo's config packages.
|
||||||
|
- Migration guide: https://nx.dev/docs/guides/adopting-nx/from-turborepo#easy-automated-migration-example
|
||||||
|
- Since Nx replaces Turborepo, all turbo config files and config packages become dead code and should be removed.
|
||||||
|
|
||||||
|
## The Config-as-Package Pattern
|
||||||
|
|
||||||
|
Turborepo monorepos ship with internal workspace packages that share configuration:
|
||||||
|
|
||||||
|
- **`@repo/typescript-config`** (or similar) — tsconfig files (`base.json`, `nextjs.json`, `react-library.json`, etc.)
|
||||||
|
- **`@repo/eslint-config`** (or similar) — ESLint config files and all ESLint plugin dependencies
|
||||||
|
|
||||||
|
These are not code libraries. They distribute config via Node module resolution (e.g., `"extends": "@repo/typescript-config/nextjs.json"`). This is the **default** Turborepo pattern — expect it in virtually every Turborepo import. Package names vary — check `package.json` files to identify the actual names.
|
||||||
|
|
||||||
|
## Check for Root Config Files First
|
||||||
|
|
||||||
|
**Before doing any config merging, check whether the destination workspace uses shared root configuration.** This decides how to handle the config packages.
|
||||||
|
|
||||||
|
- If the workspace has a root `tsconfig.base.json` and/or root `eslint.config.mjs` that projects extend, merge the config packages into these root configs (see steps below).
|
||||||
|
- If the workspace does NOT have root config files — each project manages its own configuration independently (similar to Turborepo). In this case, **do not create root config files or merge into them**. Just remove turbo-specific parts (`turbo.json`, `eslint-plugin-turbo`) and leave the config packages in place, or ask the user how they want to handle them.
|
||||||
|
|
||||||
|
If unclear, check for the presence of `tsconfig.base.json` at the root or ask the user.
|
||||||
|
|
||||||
|
## Merging TypeScript Config (Only When Root tsconfig.base.json Exists)
|
||||||
|
|
||||||
|
The config package contains a hierarchy of tsconfig files. Each project extends one via package name.
|
||||||
|
|
||||||
|
1. **Read the config package** — trace the full inheritance chain (e.g., `nextjs.json` extends `base.json`).
|
||||||
|
2. **Update root `tsconfig.base.json`** — absorb `compilerOptions` from the base config. Add Nx `paths` for cross-project imports (Turborepo doesn't use path aliases, Nx relies on them).
|
||||||
|
3. **Update each project's `tsconfig.json`**:
|
||||||
|
- Change `"extends"` from `"@repo/typescript-config/<variant>.json"` to the relative path to root `tsconfig.base.json`.
|
||||||
|
- Inline variant-specific overrides from the intermediate config (e.g., Next.js: `"module": "ESNext"`, `"moduleResolution": "Bundler"`, `"jsx": "preserve"`, `"noEmit": true`; React library: `"jsx": "react-jsx"`).
|
||||||
|
- Preserve project-specific settings (`outDir`, `include`, `exclude`, etc.).
|
||||||
|
4. **Delete the config package** and remove it from all `devDependencies`.
|
||||||
|
|
||||||
|
## Merging ESLint Config (Only When Root eslint.config Exists)
|
||||||
|
|
||||||
|
The config package centralizes ESLint plugin dependencies and exports composable flat configs.
|
||||||
|
|
||||||
|
1. **Read the config package** — identify exported configs, plugin dependencies, and inheritance.
|
||||||
|
2. **Update root `eslint.config.mjs`** — absorb base rules (JS recommended, TypeScript-ESLint, Prettier, etc.). Drop `eslint-plugin-turbo`.
|
||||||
|
3. **Update each project's `eslint.config.mjs`** — switch from importing `@repo/eslint-config/<variant>` to extending the root config, adding framework-specific plugins inline.
|
||||||
|
4. **Move ESLint plugin dependencies** from the config package to root `devDependencies`.
|
||||||
|
5. If `@nx/eslint` plugin is configured with inferred targets, remove `"lint"` scripts from project `package.json` files.
|
||||||
|
6. **Delete the config package** and remove it from all `devDependencies`.
|
||||||
|
|
||||||
|
## General Cleanup
|
||||||
|
|
||||||
|
- Remove turbo-specific dependencies: `turbo`, `eslint-plugin-turbo`.
|
||||||
|
- Delete all `turbo.json` files (root and per-package).
|
||||||
|
- Run workspace validation (`nx run-many -t build lint test typecheck`) to confirm nothing broke.
|
||||||
|
|
||||||
|
## Key Pitfalls
|
||||||
|
|
||||||
|
- **Trace the full inheritance chain** before inlining — check what each variant inherits from the base.
|
||||||
|
- **Module resolution changes** — from Node package resolution (`@repo/...`) to relative paths (`../../tsconfig.base.json`).
|
||||||
|
- **ESLint configs are JavaScript, not JSON** — handle JS imports, array spreading, and plugin objects when merging.
|
||||||
|
|
||||||
|
Helpful docs:
|
||||||
|
|
||||||
|
- https://nx.dev/docs/guides/adopting-nx/from-turborepo
|
||||||
@@ -0,0 +1,397 @@
|
|||||||
|
## Vite
|
||||||
|
|
||||||
|
Vite-specific guidance for `nx import`. For generic import issues (pnpm globs, root deps, project references, name collisions, ESLint, frontend tsconfig base settings, `@nx/react` typings, Jest preset, non-Nx source handling), see `SKILL.md`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `@nx/vite/plugin` Typecheck Target
|
||||||
|
|
||||||
|
`@nx/vite/plugin` defaults `typecheckTargetName` to `"vite:typecheck"`. If the workspace expects `"typecheck"`, set it explicitly in `nx.json`. If `@nx/js/typescript` is also registered, rename one target to avoid conflicts (e.g. `"tsc-typecheck"` for the JS plugin).
|
||||||
|
|
||||||
|
Keep both plugins only if the workspace has non-Vite pure TS libraries — `@nx/js/typescript` handles those while `@nx/vite/plugin` handles Vite projects.
|
||||||
|
|
||||||
|
### @nx/vite Plugin Install Failure
|
||||||
|
|
||||||
|
Plugin init loads `vite.config.ts` before deps are available. **Fix**: `pnpm add -wD vite @vitejs/plugin-react` (or `@vitejs/plugin-vue`) first, then `pnpm exec nx add @nx/vite`.
|
||||||
|
|
||||||
|
### Vite `resolve.alias` and `__dirname` (Non-Nx Sources)
|
||||||
|
|
||||||
|
**`__dirname` undefined** (CJS-only): Replace with `fileURLToPath(new URL('./src', import.meta.url))` from `'node:url'`.
|
||||||
|
|
||||||
|
**`@/` path alias**: Vite's `resolve.alias` works at runtime but TS needs matching `"paths"`. Set `"baseUrl": "."` in project tsconfig.
|
||||||
|
|
||||||
|
**PostCSS/Tailwind**: Verify `content` globs resolve correctly after import.
|
||||||
|
|
||||||
|
### Missing TypeScript `types` (Non-Nx Sources)
|
||||||
|
|
||||||
|
Non-Nx tsconfigs may not declare all needed types. Ensure Vite projects include `"types": ["node", "vite/client"]` in their tsconfig.
|
||||||
|
|
||||||
|
### `noEmit` Fix: Vite-Specific Notes
|
||||||
|
|
||||||
|
See SKILL.md for the generic noEmit→composite fix. Vite-specific additions:
|
||||||
|
|
||||||
|
- Non-Nx Vite projects often have **both** `tsconfig.app.json` and `tsconfig.node.json` with `noEmit` — fix both
|
||||||
|
- Solution-style tsconfigs (`"files": [], "references": [...]`) may lack `extends`. Add `extends` pointing to the dest root `tsconfig.base.json` so base settings (`moduleResolution`, `lib`) apply.
|
||||||
|
- This is safe — Vite/Vitest ignore TypeScript emit settings.
|
||||||
|
|
||||||
|
### Dependency Version Conflicts
|
||||||
|
|
||||||
|
**Shared Vite deps (both frameworks):** `vite`, `vitest`, `jsdom`, `@types/node`, `typescript` (dev)
|
||||||
|
|
||||||
|
**Vite 6→7**: Typecheck fails (`Plugin<any>` type mismatch); build/serve still works. Fix: align versions.
|
||||||
|
**Vitest 3→4**: Usually works; type conflicts may surface in shared test utils.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## React Router 7 (Vite-Based)
|
||||||
|
|
||||||
|
React Router 7 (`@react-router/dev`) uses Vite under the hood with a `vite.config.ts` and a `react-router.config.ts`. The `@nx/vite/plugin` detects `vite.config.ts` and creates inferred targets.
|
||||||
|
|
||||||
|
### Targets
|
||||||
|
|
||||||
|
`@nx/vite/plugin` creates `build`, `dev`, `serve` targets. The `build` target invokes the script defined in `package.json` (usually `react-router build`), not `vite build` directly.
|
||||||
|
|
||||||
|
**No separate typecheck target from `@nx/vite/plugin`** — React Router 7 typegen is run as part of `typecheck` (e.g. `react-router typegen && tsc`). The `typecheck` target is inferred from the tsconfig. Keep the `typecheck` script in `package.json` if present; it is not rewritten.
|
||||||
|
|
||||||
|
### tsconfig Notes
|
||||||
|
|
||||||
|
React Router 7 uses a single `tsconfig.json` (no `tsconfig.app.json`/`tsconfig.node.json` split). It includes:
|
||||||
|
|
||||||
|
- `"rootDirs": [".", "./.react-router/types"]` — for generated type files; keep as-is
|
||||||
|
- `"paths": { "~/*": ["./app/*"] }` — self-referential alias; keep as-is
|
||||||
|
- `"noEmit": true` — replace with composite settings per SKILL.md
|
||||||
|
|
||||||
|
### Build Output
|
||||||
|
|
||||||
|
React Router 7 outputs to `build/` (not `dist/`). Add `build` to the dest root `.gitignore`.
|
||||||
|
|
||||||
|
### Generated Types Directory
|
||||||
|
|
||||||
|
React Router 7 generates `.react-router/` at the project root for route type generation. Add `.react-router` to the dest root `.gitignore`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## TanStack Start (Vite-Based)
|
||||||
|
|
||||||
|
TanStack Start uses Vinxi under the hood, which wraps Vite. Projects have a standard `vite.config.ts` that `@nx/vite/plugin` detects normally.
|
||||||
|
|
||||||
|
### Targets
|
||||||
|
|
||||||
|
`@nx/vite/plugin` creates `build`, `dev`, `preview`, `serve-static`, `typecheck` targets. The `build` target runs `vite build` which invokes the TanStack Start Vinxi pipeline (produces both client and SSR bundles).
|
||||||
|
|
||||||
|
### tsconfig Notes
|
||||||
|
|
||||||
|
TanStack Start uses a single `tsconfig.json` with `"allowImportingTsExtensions": true` and `"noEmit": true`. Apply the standard noEmit → composite fix. `allowImportingTsExtensions` is compatible with `emitDeclarationOnly: true` — no change needed.
|
||||||
|
|
||||||
|
### `paths` Aliases
|
||||||
|
|
||||||
|
TanStack Start commonly uses `"#/*": ["./src/*"]` and `"@/*": ["./src/*"]`. These are self-referential — keep as-is for a single-project app.
|
||||||
|
|
||||||
|
### Uncommitted Source Repo
|
||||||
|
|
||||||
|
`create-tan-stack` initializes a git repo but does NOT make an initial commit. Before importing, commit first:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git -C /path/to/source add . && git -C /path/to/source commit -m "Initial commit"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Generated and Build Directories
|
||||||
|
|
||||||
|
TanStack Start / Vinxi / Nitro generate several directories that must be added to the dest root `.gitignore`:
|
||||||
|
|
||||||
|
- `.vinxi` — Vinxi build cache
|
||||||
|
- `.tanstack` — TanStack generated files
|
||||||
|
- `.nitro` — Nitro build artifacts
|
||||||
|
- `.output` — server-side build output (SSR/edge)
|
||||||
|
|
||||||
|
These are not covered by `dist` or `build`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## React-Specific
|
||||||
|
|
||||||
|
### React Dependencies
|
||||||
|
|
||||||
|
**Production:** `react`, `react-dom`
|
||||||
|
**Dev:** `@types/react`, `@types/react-dom`, `@vitejs/plugin-react`, `@testing-library/react`, `@testing-library/jest-dom`, `jsdom`
|
||||||
|
**ESLint (Nx sources):** `eslint-plugin-import`, `eslint-plugin-jsx-a11y`, `eslint-plugin-react`, `eslint-plugin-react-hooks`
|
||||||
|
**ESLint (`create-vite`):** `eslint-plugin-react-refresh`, `eslint-plugin-react-hooks` — self-contained flat configs can be left as-is
|
||||||
|
**Nx plugins:** `@nx/react` (generators), `@nx/vite`, `@nx/vitest`, `@nx/eslint`
|
||||||
|
|
||||||
|
### React TypeScript Configuration
|
||||||
|
|
||||||
|
Add `"jsx": "react-jsx"` — in `tsconfig.base.json` for single-framework workspaces, per-project for mixed (see Mixed section).
|
||||||
|
|
||||||
|
### React ESLint Config
|
||||||
|
|
||||||
|
```js
|
||||||
|
import nx from '@nx/eslint-plugin';
|
||||||
|
import baseConfig from '../../eslint.config.mjs';
|
||||||
|
export default [
|
||||||
|
...baseConfig,
|
||||||
|
...nx.configs['flat/react'],
|
||||||
|
{ files: ['**/*.ts', '**/*.tsx'], rules: {} },
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
### React Version Conflicts
|
||||||
|
|
||||||
|
React 18 (source) + React 19 (dest): pnpm may hoist mismatched `react-dom`, causing `TypeError: Cannot read properties of undefined (reading 'S')`. **Fix**: Align versions with `pnpm.overrides`.
|
||||||
|
|
||||||
|
### `@testing-library/jest-dom` with Vitest
|
||||||
|
|
||||||
|
If source used Jest: change import to `@testing-library/jest-dom/vitest` in test-setup.ts, add to tsconfig `types`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Vue-Specific
|
||||||
|
|
||||||
|
### Vue Dependencies
|
||||||
|
|
||||||
|
**Production:** `vue` (plus `vue-router`, `pinia` if used)
|
||||||
|
**Dev:** `@vitejs/plugin-vue`, `vue-tsc`, `@vue/test-utils`, `jsdom`
|
||||||
|
**ESLint:** `eslint-plugin-vue`, `vue-eslint-parser`, `@vue/eslint-config-typescript`, `@vue/eslint-config-prettier`
|
||||||
|
**Nx plugins:** `@nx/vue` (generators), `@nx/vite`, `@nx/vitest`, `@nx/eslint` (install AFTER deps — see below)
|
||||||
|
|
||||||
|
### Vue TypeScript Configuration
|
||||||
|
|
||||||
|
Add to `tsconfig.base.json` (single-framework) or per-project (mixed):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "jsx": "preserve", "jsxImportSource": "vue", "resolveJsonModule": true }
|
||||||
|
```
|
||||||
|
|
||||||
|
### `vue-shims.d.ts`
|
||||||
|
|
||||||
|
Vue SFC files need a type declaration. Usually exists in each project's `src/` and imports cleanly. If missing:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
declare module '*.vue' {
|
||||||
|
import { defineComponent } from 'vue';
|
||||||
|
const component: ReturnType<typeof defineComponent>;
|
||||||
|
export default component;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `vue-tsc` Auto-Detection
|
||||||
|
|
||||||
|
Both `@nx/js/typescript` and `@nx/vite/plugin` auto-detect `vue-tsc` when installed — no manual config needed. Remove source scripts like `"typecheck": "vue-tsc --noEmit"`.
|
||||||
|
|
||||||
|
### ESLint Plugin Installation Order (Critical)
|
||||||
|
|
||||||
|
`@nx/eslint` init **crashes** if Vue ESLint deps aren't installed first (it loads all config files).
|
||||||
|
|
||||||
|
**Correct order:**
|
||||||
|
|
||||||
|
1. `pnpm add -wD eslint@^9 eslint-plugin-vue vue-eslint-parser @vue/eslint-config-typescript @typescript-eslint/parser @nx/eslint-plugin typescript-eslint`
|
||||||
|
2. Create root `eslint.config.mjs`
|
||||||
|
3. Then `npx nx add @nx/eslint`
|
||||||
|
|
||||||
|
### Vue ESLint Config Pattern
|
||||||
|
|
||||||
|
```js
|
||||||
|
import vue from 'eslint-plugin-vue';
|
||||||
|
import vueParser from 'vue-eslint-parser';
|
||||||
|
import tsParser from '@typescript-eslint/parser';
|
||||||
|
import baseConfig from '../../eslint.config.mjs';
|
||||||
|
export default [
|
||||||
|
...baseConfig,
|
||||||
|
...vue.configs['flat/recommended'],
|
||||||
|
{
|
||||||
|
files: ['**/*.vue'],
|
||||||
|
languageOptions: { parser: vueParser, parserOptions: { parser: tsParser } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ['**/*.ts', '**/*.tsx', '**/*.js', '**/*.jsx', '**/*.vue'],
|
||||||
|
rules: { 'vue/multi-word-component-names': 'off' },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
**Important**: `vue-eslint-parser` override must come **AFTER** base config — `flat/typescript` sets the TS parser globally without a `files` filter, breaking `.vue` parsing.
|
||||||
|
|
||||||
|
`vue-eslint-parser` must be an explicit pnpm dependency (strict resolution prevents transitive import).
|
||||||
|
|
||||||
|
**Known issue**: Some generated Vue ESLint configs omit `vue-eslint-parser`. Use the pattern above instead.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Mixed React + Vue
|
||||||
|
|
||||||
|
When both frameworks coexist, several settings become per-project.
|
||||||
|
|
||||||
|
### tsconfig `jsx` — Per-Project Only
|
||||||
|
|
||||||
|
- React: `"jsx": "react-jsx"` in project tsconfig
|
||||||
|
- Vue: `"jsx": "preserve"`, `"jsxImportSource": "vue"` in project tsconfig
|
||||||
|
- Root: **NO** `jsx` setting
|
||||||
|
|
||||||
|
### Typecheck — Auto-Detects Framework
|
||||||
|
|
||||||
|
`@nx/vite/plugin` uses `vue-tsc` for Vue projects and `tsc` for React automatically.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"plugins": [
|
||||||
|
{ "plugin": "@nx/eslint/plugin", "options": { "targetName": "lint" } },
|
||||||
|
{
|
||||||
|
"plugin": "@nx/vite/plugin",
|
||||||
|
"options": {
|
||||||
|
"buildTargetName": "build",
|
||||||
|
"typecheckTargetName": "typecheck",
|
||||||
|
"testTargetName": "test"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Remove `@nx/js/typescript` if all projects use Vite. Keep it (renamed to `"tsc-typecheck"`) only for non-Vite pure TS libs.
|
||||||
|
|
||||||
|
### ESLint — Three-Tier Config
|
||||||
|
|
||||||
|
1. **Root**: Base rules only, no framework-specific rules
|
||||||
|
2. **React projects**: Extend root + `nx.configs['flat/react']`
|
||||||
|
3. **Vue projects**: Extend root + `vue.configs['flat/recommended']` + `vue-eslint-parser`
|
||||||
|
|
||||||
|
**Required packages**: Shared (`eslint@^9`, `@nx/eslint-plugin`, `typescript-eslint`, `@typescript-eslint/parser`), React (`eslint-plugin-import`, `eslint-plugin-jsx-a11y`, `eslint-plugin-react`, `eslint-plugin-react-hooks`), Vue (`eslint-plugin-vue`, `vue-eslint-parser`)
|
||||||
|
|
||||||
|
`@nx/react`/`@nx/vue` are for generators only — no target conflicts.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Redundant npm Scripts After Import
|
||||||
|
|
||||||
|
`nx import` copies `package.json` verbatim, so npm scripts come along. For Vite-based projects `@nx/vite/plugin` already infers the same targets from `vite.config.ts` — the npm scripts just shadow the plugin with weaker `nx:run-script` wrappers (no first-class caching inputs/outputs). Remove them after import.
|
||||||
|
|
||||||
|
### Standalone Vite App (`create-vite`)
|
||||||
|
|
||||||
|
Remove the following scripts — every one is redundant:
|
||||||
|
|
||||||
|
| Script | Plugin replacement |
|
||||||
|
| ----------------------------- | ---------------------------------------------------------------------------- |
|
||||||
|
| `dev: vite` | `@nx/vite/plugin` → `dev` |
|
||||||
|
| `build: tsc -b && vite build` | `@nx/vite/plugin` → `build`; `typecheck` via `@nx/js/typescript` handles tsc |
|
||||||
|
| `preview: vite preview` | `@nx/vite/plugin` → `preview` |
|
||||||
|
| `lint: eslint .` | `@nx/eslint/plugin` → `eslint:lint` |
|
||||||
|
|
||||||
|
### TanStack Start
|
||||||
|
|
||||||
|
Remove `build`, `dev`, `preview`, and `test` scripts, but move any hardcoded `--port` flag to `vite.config.ts` first:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// vite.config.ts
|
||||||
|
export default defineConfig({
|
||||||
|
server: { port: 3000 }, // replaces `vite dev --port 3000`
|
||||||
|
...
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### React Router 7 — Keep ALL scripts
|
||||||
|
|
||||||
|
Do **not** remove React Router 7 scripts. They use the framework CLI (`react-router build`, `react-router dev`, `react-router-serve`) which is not interchangeable with plain `vite`:
|
||||||
|
|
||||||
|
- `typecheck` runs `react-router typegen && tsc` — typegen must precede `tsc` or it fails on missing route types
|
||||||
|
- `start` serves the SSR bundle — no plugin equivalent
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fix Orders
|
||||||
|
|
||||||
|
### Nx Source
|
||||||
|
|
||||||
|
1. Generic fixes from SKILL.md (pnpm globs, root deps, executor paths, frontend tsconfig base settings, `@nx/react` typings)
|
||||||
|
2. Configure `@nx/vite/plugin` typecheck target
|
||||||
|
3. **React**: `jsx: "react-jsx"` (root or per-project)
|
||||||
|
4. **Vue**: `jsx: "preserve"` + `jsxImportSource: "vue"`; verify `vue-shims.d.ts`; install ESLint deps before `@nx/eslint`
|
||||||
|
5. **Mixed**: `jsx` per-project; remove/rename `@nx/js/typescript`
|
||||||
|
6. `nx sync --yes && nx reset && nx run-many -t typecheck,build,test,lint`
|
||||||
|
|
||||||
|
### Non-Nx Source (additional steps)
|
||||||
|
|
||||||
|
0. Import into `apps/<name>` (see SKILL.md: "Application vs Library Detection")
|
||||||
|
1. Generic fixes from SKILL.md (stale files cleanup, pnpm globs, rewritten scripts, target name prefixing, noEmit→composite, ESLint handling)
|
||||||
|
2. Fix `noEmit` in **all** tsconfigs (app, node, etc. — non-Nx projects often have multiple)
|
||||||
|
3. Add `extends` to solution-style tsconfigs so root settings apply
|
||||||
|
4. Fix `resolve.alias` / `__dirname` / `baseUrl`
|
||||||
|
5. Ensure `types` include `vite/client` and `node`
|
||||||
|
6. Install `@nx/vite` manually if it failed during import
|
||||||
|
7. Remove redundant npm scripts so `@nx/vite/plugin` infers them natively (see "Redundant npm Scripts" section)
|
||||||
|
8. **Vue**: Add `outDir` + `**/*.vue.d.ts` to ESLint ignores
|
||||||
|
9. Full verification
|
||||||
|
|
||||||
|
### Multiple-Source Imports
|
||||||
|
|
||||||
|
See SKILL.md for generic multi-import (name collisions, dep refs). Vite-specific: fix tsconfig `references` paths for alternate directories (`../../libs/` → `../../libs-beta/`).
|
||||||
|
|
||||||
|
### Non-Nx Source: React Router 7
|
||||||
|
|
||||||
|
1. Ensure source has at least one commit (see SKILL.md: "Source Repo Has No Commits")
|
||||||
|
2. `nx import` whole-repo into `apps/<name>` (see SKILL.md: "Application vs Library Detection") → auto-installs `@nx/vite`, `@nx/react`
|
||||||
|
3. Stale file cleanup: `node_modules/`, `package-lock.json`, `.gitignore`
|
||||||
|
4. Fix `tsconfig.json`: `noEmit` → `composite + emitDeclarationOnly + outDir + tsBuildInfoFile`
|
||||||
|
5. Add `build` and `.react-router` to dest root `.gitignore`
|
||||||
|
6. **Keep all npm scripts** — React Router 7 uses framework CLI (`react-router build/dev`), not plain vite (see "Redundant npm Scripts" above)
|
||||||
|
7. `npm install && nx reset && nx sync --yes`
|
||||||
|
|
||||||
|
### Non-Nx Source: TanStack Start
|
||||||
|
|
||||||
|
1. Ensure source has at least one commit — `create-tan-stack` does NOT auto-commit (see SKILL.md)
|
||||||
|
2. `nx import` whole-repo into `apps/<name>` (see SKILL.md: "Application vs Library Detection") → auto-installs `@nx/vite`, `@nx/vitest`
|
||||||
|
3. Stale file cleanup: `node_modules/`, `package-lock.json`, `.gitignore`
|
||||||
|
4. Fix `tsconfig.json`: `noEmit` → `composite + emitDeclarationOnly + outDir + tsBuildInfoFile`
|
||||||
|
5. Keep `allowImportingTsExtensions` — compatible with `emitDeclarationOnly: true`
|
||||||
|
6. Add `.vinxi`, `.tanstack`, `.nitro`, `.output` to dest root `.gitignore`
|
||||||
|
7. Move hardcoded `--port` from `dev` script into `vite.config.ts` (`server: { port: N }`)
|
||||||
|
8. Remove redundant npm scripts — `@nx/vite/plugin` infers `build`, `dev`, `preview`, `test` (see "Redundant npm Scripts" above)
|
||||||
|
9. `npm install && nx reset && nx sync --yes`
|
||||||
|
|
||||||
|
### Quick Reference: React vs Vue
|
||||||
|
|
||||||
|
| Aspect | React | Vue |
|
||||||
|
| ------------- | ------------------------ | ----------------------------------------- |
|
||||||
|
| Vite plugin | `@vitejs/plugin-react` | `@vitejs/plugin-vue` |
|
||||||
|
| Type checker | `tsc` | `vue-tsc` (auto-detected) |
|
||||||
|
| SFC support | N/A | `vue-shims.d.ts` needed |
|
||||||
|
| tsconfig jsx | `"react-jsx"` | `"preserve"` + `"jsxImportSource": "vue"` |
|
||||||
|
| ESLint parser | Standard TS | `vue-eslint-parser` + TS sub-parser |
|
||||||
|
| ESLint setup | Straightforward | Must install deps before `@nx/eslint` |
|
||||||
|
| Test utils | `@testing-library/react` | `@vue/test-utils` |
|
||||||
|
|
||||||
|
### Quick Reference: Vite-Based React Frameworks
|
||||||
|
|
||||||
|
| Aspect | Vite (standalone) | React Router 7 | TanStack Start |
|
||||||
|
| ------------------ | ----------------- | ----------------------- | ------------------------ |
|
||||||
|
| Build config | `vite.config.ts` | `vite.config.ts` | `vite.config.ts` |
|
||||||
|
| Build output | `dist/` | `build/` | `dist/` |
|
||||||
|
| SSR bundle | No | Yes (`build/server/`) | Yes (`dist/server/`) |
|
||||||
|
| tsconfig layout | app + node split | Single tsconfig | Single tsconfig |
|
||||||
|
| Auto-committed | Depends on tool | Usually yes | **No — commit first** |
|
||||||
|
| `nx import` plugin | `@nx/vite` | `@nx/vite`, `@nx/react` | `@nx/vite`, `@nx/vitest` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Iteration Log
|
||||||
|
|
||||||
|
### Scenario 6: Multiple non-Nx React apps (CRA, Next.js, React Router 7, TanStack Start, Vite) → TS preset (PASS)
|
||||||
|
|
||||||
|
- Sources: 5 standalone non-Nx repos with different build tools
|
||||||
|
- Dest: CNW ts preset (Nx 22.5.1), npm workspaces, `packages/*`
|
||||||
|
- Import: whole-repo for each, sequential into `packages/<name>`
|
||||||
|
- Pre-import fixes:
|
||||||
|
1. Removed `packages/.gitkeep` and committed
|
||||||
|
2. `git init && git add . && git commit` in Vite app (no git at all)
|
||||||
|
3. `git add . && git commit` in TanStack app (git init'd but no commits)
|
||||||
|
- Import: `npm exec nx -- import <source> packages/<name> --source=. --ref=main --no-interactive`
|
||||||
|
- Next.js import auto-installed `@nx/eslint`, `@nx/next`
|
||||||
|
- React Router 7 import auto-installed `@nx/vite`, `@nx/react`, `@nx/docker` (Dockerfile present)
|
||||||
|
- TanStack import auto-installed `@nx/vitest`
|
||||||
|
- Post-import fixes:
|
||||||
|
1. Removed stale `node_modules/`, `package-lock.json`, `.gitignore` from each package
|
||||||
|
2. Removed Nx-rewritten scripts from `board-games-nextjs/package.json` (had `"build": "nx next:build"`, etc.)
|
||||||
|
3. Updated root `tsconfig.base.json`: `nodenext` → `bundler`, added `dom`/`dom.iterable` to lib, added `jsx: react-jsx`
|
||||||
|
4. Added `build` to dest root `.gitignore` (CRA and React Router 7 output there)
|
||||||
|
5. Fixed `noEmit` → `composite + emitDeclarationOnly` in: `board-games-vite/tsconfig.app.json`, `board-games-vite/tsconfig.node.json`, `board-games-react-router/tsconfig.json`, `board-games-tanstack/tsconfig.json`
|
||||||
|
6. Fixed `tsBuildInfoFile` paths from `./node_modules/.tmp/...` to `./dist/...`
|
||||||
|
7. Installed root `@types/react`, `@types/react-dom`, `@types/node`
|
||||||
|
- All targets green: `build` for all 5 projects; `typecheck` for Vite/React Router/TanStack; `next:build` for Next.js
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
---
|
||||||
|
name: nx-plugins
|
||||||
|
description: Find and add Nx plugins. USE WHEN user wants to discover available plugins, install a new plugin, or add support for a specific framework or technology to the workspace.
|
||||||
|
---
|
||||||
|
|
||||||
|
## Finding and Installing new plugins
|
||||||
|
|
||||||
|
- List plugins: `pnpm nx list`
|
||||||
|
- Install plugins `pnpm nx add <plugin>`. Example: `pnpm nx add @nx/react`.
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
---
|
||||||
|
name: nx-run-tasks
|
||||||
|
description: Helps with running tasks in an Nx workspace. USE WHEN the user wants to execute build, test, lint, serve, or run any other tasks defined in the workspace.
|
||||||
|
---
|
||||||
|
|
||||||
|
You can run tasks with Nx in the following way.
|
||||||
|
|
||||||
|
Keep in mind that you might have to prefix things with npx/pnpx/yarn if the user doesn't have nx installed globally. Look at the package.json or lockfile to determine which package manager is in use.
|
||||||
|
|
||||||
|
For more details on any command, run it with `--help` (e.g. `nx run-many --help`, `nx affected --help`).
|
||||||
|
|
||||||
|
## Understand which tasks can be run
|
||||||
|
|
||||||
|
You can check those via `nx show project <projectname> --json`, for example `nx show project myapp --json`. It contains a `targets` section which has information about targets that can be run. You can also just look at the `package.json` scripts or `project.json` targets, but you might miss out on inferred tasks by Nx plugins.
|
||||||
|
|
||||||
|
## Run a single task
|
||||||
|
|
||||||
|
```
|
||||||
|
nx run <project>:<task>
|
||||||
|
```
|
||||||
|
|
||||||
|
where `project` is the project name defined in `package.json` or `project.json` (if present).
|
||||||
|
|
||||||
|
## Run multiple tasks
|
||||||
|
|
||||||
|
```
|
||||||
|
nx run-many -t build test lint typecheck
|
||||||
|
```
|
||||||
|
|
||||||
|
You can pass a `-p` flag to filter to specific projects, otherwise it runs on all projects. You can also use `--exclude` to exclude projects, and `--parallel` to control the number of parallel processes (default is 3).
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
- `nx run-many -t test -p proj1 proj2` — test specific projects
|
||||||
|
- `nx run-many -t test --projects=*-app --exclude=excluded-app` — test projects matching a pattern
|
||||||
|
- `nx run-many -t test --projects=tag:api-*` — test projects by tag
|
||||||
|
|
||||||
|
## Run tasks for affected projects
|
||||||
|
|
||||||
|
Use `nx affected` to only run tasks on projects that have been changed and projects that depend on changed projects. This is especially useful in CI and for large workspaces.
|
||||||
|
|
||||||
|
```
|
||||||
|
nx affected -t build test lint
|
||||||
|
```
|
||||||
|
|
||||||
|
By default it compares against the base branch. You can customize this:
|
||||||
|
|
||||||
|
- `nx affected -t test --base=main --head=HEAD` — compare against a specific base and head
|
||||||
|
- `nx affected -t test --files=libs/mylib/src/index.ts` — specify changed files directly
|
||||||
|
|
||||||
|
## Useful flags
|
||||||
|
|
||||||
|
These flags work with `run`, `run-many`, and `affected`:
|
||||||
|
|
||||||
|
- `--skipNxCache` — rerun tasks even when results are cached
|
||||||
|
- `--verbose` — print additional information such as stack traces
|
||||||
|
- `--nxBail` — stop execution after the first failed task
|
||||||
|
- `--configuration=<name>` — use a specific configuration (e.g. `production`)
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
---
|
||||||
|
name: nx-workspace
|
||||||
|
description: "Explore and understand Nx workspaces. USE WHEN answering questions about the workspace, projects, or tasks. ALSO USE WHEN an nx command fails or you need to check available targets/configuration before running a task. EXAMPLES: 'What projects are in this workspace?', 'How is project X configured?', 'What depends on library Y?', 'What targets can I run?', 'Cannot find configuration for task', 'debug nx task failure'."
|
||||||
|
---
|
||||||
|
|
||||||
|
# Nx Workspace Exploration
|
||||||
|
|
||||||
|
This skill provides read-only exploration of Nx workspaces. Use it to understand workspace structure, project configuration, available targets, and dependencies.
|
||||||
|
|
||||||
|
Keep in mind that you might have to prefix commands with `npx`/`pnpx`/`yarn` if nx isn't installed globally. Check the lockfile to determine the package manager in use.
|
||||||
|
|
||||||
|
## Listing Projects
|
||||||
|
|
||||||
|
Use `nx show projects` to list projects in the workspace.
|
||||||
|
|
||||||
|
The project filtering syntax (`-p`/`--projects`) works across many Nx commands including `nx run-many`, `nx release`, `nx show projects`, and more. Filters support explicit names, glob patterns, tag references (e.g. `tag:name`), directories, and negation (e.g. `!project-name`).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# List all projects
|
||||||
|
nx show projects
|
||||||
|
|
||||||
|
# Filter by pattern (glob)
|
||||||
|
nx show projects --projects "apps/*"
|
||||||
|
nx show projects --projects "shared-*"
|
||||||
|
|
||||||
|
# Filter by tag
|
||||||
|
nx show projects --projects "tag:publishable"
|
||||||
|
nx show projects -p 'tag:publishable,!tag:internal'
|
||||||
|
|
||||||
|
# Filter by target (projects that have a specific target)
|
||||||
|
nx show projects --withTarget build
|
||||||
|
|
||||||
|
# Combine filters
|
||||||
|
nx show projects --type lib --withTarget test
|
||||||
|
nx show projects --affected --exclude="*-e2e"
|
||||||
|
nx show projects -p "tag:scope:client,packages/*"
|
||||||
|
|
||||||
|
# Negate patterns
|
||||||
|
nx show projects -p '!tag:private'
|
||||||
|
nx show projects -p '!*-e2e'
|
||||||
|
|
||||||
|
# Output as JSON
|
||||||
|
nx show projects --json
|
||||||
|
```
|
||||||
|
|
||||||
|
## Project Configuration
|
||||||
|
|
||||||
|
Use `nx show project <name> --json` to get the full resolved configuration for a project.
|
||||||
|
|
||||||
|
**Important**: Do NOT read `project.json` directly - it only contains partial configuration. The `nx show project --json` command returns the full resolved config including inferred targets from plugins.
|
||||||
|
|
||||||
|
You can read the full project schema at `node_modules/nx/schemas/project-schema.json` to understand nx project configuration options.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Get full project configuration
|
||||||
|
nx show project my-app --json
|
||||||
|
|
||||||
|
# Extract specific parts from the JSON
|
||||||
|
nx show project my-app --json | jq '.targets'
|
||||||
|
nx show project my-app --json | jq '.targets.build'
|
||||||
|
nx show project my-app --json | jq '.targets | keys'
|
||||||
|
|
||||||
|
# Check project metadata
|
||||||
|
nx show project my-app --json | jq '{name, root, sourceRoot, projectType, tags}'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Target Information
|
||||||
|
|
||||||
|
Targets define what tasks can be run on a project.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# List all targets for a project
|
||||||
|
nx show project my-app --json | jq '.targets | keys'
|
||||||
|
|
||||||
|
# Get full target configuration
|
||||||
|
nx show project my-app --json | jq '.targets.build'
|
||||||
|
|
||||||
|
# Check target executor/command
|
||||||
|
nx show project my-app --json | jq '.targets.build.executor'
|
||||||
|
nx show project my-app --json | jq '.targets.build.command'
|
||||||
|
|
||||||
|
# View target options
|
||||||
|
nx show project my-app --json | jq '.targets.build.options'
|
||||||
|
|
||||||
|
# Check target inputs/outputs (for caching)
|
||||||
|
nx show project my-app --json | jq '.targets.build.inputs'
|
||||||
|
nx show project my-app --json | jq '.targets.build.outputs'
|
||||||
|
|
||||||
|
# Find projects with a specific target
|
||||||
|
nx show projects --withTarget serve
|
||||||
|
nx show projects --withTarget e2e
|
||||||
|
```
|
||||||
|
|
||||||
|
## Workspace Configuration
|
||||||
|
|
||||||
|
Read `nx.json` directly for workspace-level configuration.
|
||||||
|
You can read the full project schema at `node_modules/nx/schemas/nx-schema.json` to understand nx project configuration options.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Read the full nx.json
|
||||||
|
cat nx.json
|
||||||
|
|
||||||
|
# Or use jq for specific sections
|
||||||
|
cat nx.json | jq '.targetDefaults'
|
||||||
|
cat nx.json | jq '.namedInputs'
|
||||||
|
cat nx.json | jq '.plugins'
|
||||||
|
cat nx.json | jq '.generators'
|
||||||
|
```
|
||||||
|
|
||||||
|
Key nx.json sections:
|
||||||
|
|
||||||
|
- `targetDefaults` - Default configuration applied to all targets of a given name
|
||||||
|
- `namedInputs` - Reusable input definitions for caching
|
||||||
|
- `plugins` - Nx plugins and their configuration
|
||||||
|
- ...and much more, read the schema or nx.json for details
|
||||||
|
|
||||||
|
## Affected Projects
|
||||||
|
|
||||||
|
If the user is asking about affected projects, read the [affected projects reference](references/AFFECTED.md) for detailed commands and examples.
|
||||||
|
|
||||||
|
## Common Exploration Patterns
|
||||||
|
|
||||||
|
### "What's in this workspace?"
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nx show projects
|
||||||
|
nx show projects --type app
|
||||||
|
nx show projects --type lib
|
||||||
|
```
|
||||||
|
|
||||||
|
### "How do I build/test/lint project X?"
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nx show project X --json | jq '.targets | keys'
|
||||||
|
nx show project X --json | jq '.targets.build'
|
||||||
|
```
|
||||||
|
|
||||||
|
### "What depends on library Y?"
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Use the project graph to find dependents
|
||||||
|
nx graph --print | jq '.graph.dependencies | to_entries[] | select(.value[].target == "Y") | .key'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Programmatic Answers
|
||||||
|
|
||||||
|
When processing nx CLI results, use command-line tools to compute the answer programmatically rather than counting or parsing output manually. Always use `--json` flags to get structured output that can be processed with `jq`, `grep`, or other tools you have installed locally.
|
||||||
|
|
||||||
|
### Listing Projects
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nx show projects --json
|
||||||
|
```
|
||||||
|
|
||||||
|
Example output:
|
||||||
|
|
||||||
|
```json
|
||||||
|
["my-app", "my-app-e2e", "shared-ui", "shared-utils", "api"]
|
||||||
|
```
|
||||||
|
|
||||||
|
Common operations:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Count projects
|
||||||
|
nx show projects --json | jq 'length'
|
||||||
|
|
||||||
|
# Filter by pattern
|
||||||
|
nx show projects --json | jq '.[] | select(startswith("shared-"))'
|
||||||
|
|
||||||
|
# Get affected projects as array
|
||||||
|
nx show projects --affected --json | jq '.'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Project Details
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nx show project my-app --json
|
||||||
|
```
|
||||||
|
|
||||||
|
Example output:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"root": "apps/my-app",
|
||||||
|
"name": "my-app",
|
||||||
|
"sourceRoot": "apps/my-app/src",
|
||||||
|
"projectType": "application",
|
||||||
|
"tags": ["type:app", "scope:client"],
|
||||||
|
"targets": {
|
||||||
|
"build": {
|
||||||
|
"executor": "@nx/vite:build",
|
||||||
|
"options": { "outputPath": "dist/apps/my-app" }
|
||||||
|
},
|
||||||
|
"serve": {
|
||||||
|
"executor": "@nx/vite:dev-server",
|
||||||
|
"options": { "buildTarget": "my-app:build" }
|
||||||
|
},
|
||||||
|
"test": {
|
||||||
|
"executor": "@nx/vite:test",
|
||||||
|
"options": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"implicitDependencies": []
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Common operations:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Get target names
|
||||||
|
nx show project my-app --json | jq '.targets | keys'
|
||||||
|
|
||||||
|
# Get specific target config
|
||||||
|
nx show project my-app --json | jq '.targets.build'
|
||||||
|
|
||||||
|
# Get tags
|
||||||
|
nx show project my-app --json | jq '.tags'
|
||||||
|
|
||||||
|
# Get project root
|
||||||
|
nx show project my-app --json | jq -r '.root'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Project Graph
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nx graph --print
|
||||||
|
```
|
||||||
|
|
||||||
|
Example output:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"graph": {
|
||||||
|
"nodes": {
|
||||||
|
"my-app": {
|
||||||
|
"name": "my-app",
|
||||||
|
"type": "app",
|
||||||
|
"data": { "root": "apps/my-app", "tags": ["type:app"] }
|
||||||
|
},
|
||||||
|
"shared-ui": {
|
||||||
|
"name": "shared-ui",
|
||||||
|
"type": "lib",
|
||||||
|
"data": { "root": "libs/shared-ui", "tags": ["type:ui"] }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"my-app": [{ "source": "my-app", "target": "shared-ui", "type": "static" }],
|
||||||
|
"shared-ui": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Common operations:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Get all project names from graph
|
||||||
|
nx graph --print | jq '.graph.nodes | keys'
|
||||||
|
|
||||||
|
# Find dependencies of a project
|
||||||
|
nx graph --print | jq '.graph.dependencies["my-app"]'
|
||||||
|
|
||||||
|
# Find projects that depend on a library
|
||||||
|
nx graph --print | jq '.graph.dependencies | to_entries[] | select(.value[].target == "shared-ui") | .key'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### "Cannot find configuration for task X:target"
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check what targets exist on the project
|
||||||
|
nx show project X --json | jq '.targets | keys'
|
||||||
|
|
||||||
|
# Check if any projects have that target
|
||||||
|
nx show projects --withTarget target
|
||||||
|
```
|
||||||
|
|
||||||
|
### "The workspace is out of sync"
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nx sync
|
||||||
|
nx reset # if sync doesn't fix stale cache
|
||||||
|
```
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
## Affected Projects
|
||||||
|
|
||||||
|
Find projects affected by changes in the current branch.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Affected since base branch (auto-detected)
|
||||||
|
nx show projects --affected
|
||||||
|
|
||||||
|
# Affected with explicit base
|
||||||
|
nx show projects --affected --base=main
|
||||||
|
nx show projects --affected --base=origin/main
|
||||||
|
|
||||||
|
# Affected between two commits
|
||||||
|
nx show projects --affected --base=abc123 --head=def456
|
||||||
|
|
||||||
|
# Affected apps only
|
||||||
|
nx show projects --affected --type app
|
||||||
|
|
||||||
|
# Affected excluding e2e projects
|
||||||
|
nx show projects --affected --exclude="*-e2e"
|
||||||
|
|
||||||
|
# Affected by uncommitted changes
|
||||||
|
nx show projects --affected --uncommitted
|
||||||
|
|
||||||
|
# Affected by untracked files
|
||||||
|
nx show projects --affected --untracked
|
||||||
|
```
|
||||||
@@ -47,3 +47,8 @@ notes/
|
|||||||
.angular
|
.angular
|
||||||
|
|
||||||
__screenshots__/
|
__screenshots__/
|
||||||
|
|
||||||
|
vitest.config.*.timestamp*
|
||||||
|
|
||||||
|
.nx/polygraph
|
||||||
|
.claude/worktrees
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
pnpm exec commitlint --edit "$1"
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
pnpm exec lint-staged
|
||||||
@@ -21,32 +21,32 @@ These constraints were set by the project lead at kickoff. They apply to every c
|
|||||||
|
|
||||||
## Architectural Decision Records (ADRs)
|
## Architectural Decision Records (ADRs)
|
||||||
|
|
||||||
- Format: **MADR 4.0.0** (https://adr.github.io/, https://github.com/adr/madr). Template at [decisions/template.md](decisions/template.md).
|
- Format: **MADR 4.0.0** (https://adr.github.io/, https://github.com/adr/madr). Template at [docs/decisions/template.md](docs/decisions/template.md).
|
||||||
- Location: flat folder [decisions/](decisions/), indexed by [decisions/README.md](decisions/README.md).
|
- Location: flat folder [docs/decisions/](docs/decisions/), indexed by [docs/decisions/README.md](docs/decisions/README.md).
|
||||||
- Filename convention: `NNNN-kebab-title.md` with **globally sequential** 4-digit numbers. Numbers are never reset and never reused — even when an ADR is superseded or deprecated.
|
- Filename convention: `NNNN-kebab-title.md` with **globally sequential** 4-digit numbers. Numbers are never reset and never reused — even when an ADR is superseded or deprecated.
|
||||||
- Categorization: via the `tags:` array in the MADR frontmatter (e.g. `[frontend, security]`). The canonical tag vocabulary lives in `decisions/README.md`; never invent ad-hoc tags inline.
|
- Categorization: via the `tags:` array in the MADR frontmatter (e.g. `[frontend, security]`). The canonical tag vocabulary lives in `docs/decisions/README.md`; never invent ad-hoc tags inline.
|
||||||
- **Proactivity.** Any non-trivial development decision (tool/library choice, framework pattern, security control, perf budget, a11y target, naming convention, deprecation, breaking change) warrants proposing an ADR before implementation. Don't wait to be asked. Update the index in the same change.
|
- **Proactivity.** Any non-trivial development decision (tool/library choice, framework pattern, security control, perf budget, a11y target, naming convention, deprecation, breaking change) warrants proposing an ADR before implementation. Don't wait to be asked. Update the index in the same change.
|
||||||
|
|
||||||
## Architecture (recorded in ADRs)
|
## Architecture (recorded in ADRs)
|
||||||
|
|
||||||
The structural, security, observability, and quality choices are recorded as ADRs and summarized below. Any change to these requires updating the corresponding ADR.
|
The structural, security, observability, and quality choices are recorded as ADRs and summarized below. Any change to these requires updating the corresponding ADR.
|
||||||
|
|
||||||
- **Workspace:** Nx monorepo with the `apps` preset, managed by pnpm — see [ADR-0002](decisions/0002-adopt-nx-monorepo-apps-preset.md).
|
- **Workspace:** Nx monorepo with the `apps` preset, managed by pnpm — see [ADR-0002](docs/decisions/0002-adopt-nx-monorepo-apps-preset.md).
|
||||||
- **Naming:** workspace `apf-portal`; apps `portal-shell` (frontend) and `portal-bff` (backend); libs `feature-<name>` and `shared-<scope>` — see [ADR-0003](decisions/0003-workspace-and-app-naming-convention.md).
|
- **Naming:** workspace `apf-portal`; apps `portal-shell` (frontend) and `portal-bff` (backend); libs `feature-<name>` and `shared-<scope>` — see [ADR-0003](docs/decisions/0003-workspace-and-app-naming-convention.md).
|
||||||
- **Frontend (`portal-shell`):** Angular at the latest LTS major — standalone APIs, zoneless change detection, Signals, **CSR only (no SSR)**, Vitest, SCSS — see [ADR-0004](decisions/0004-frontend-stack-angular-csr-zoneless-signals.md).
|
- **Frontend (`portal-shell`):** Angular at the latest LTS major — standalone APIs, zoneless change detection, Signals, **CSR only (no SSR)**, Vitest, SCSS — see [ADR-0004](docs/decisions/0004-frontend-stack-angular-csr-zoneless-signals.md).
|
||||||
- **Backend (`portal-bff`):** NestJS at the latest stable major, mounted on the Express adapter (Fastify adapter swappable later) — see [ADR-0005](decisions/0005-backend-stack-nestjs.md).
|
- **Backend (`portal-bff`):** NestJS at the latest stable major, mounted on the Express adapter (Fastify adapter swappable later) — see [ADR-0005](docs/decisions/0005-backend-stack-nestjs.md).
|
||||||
- **Persistence:** PostgreSQL (latest stable major) via Prisma — see [ADR-0006](decisions/0006-persistence-postgresql-prisma.md).
|
- **Persistence:** PostgreSQL (latest stable major) via Prisma — see [ADR-0006](docs/decisions/0006-persistence-postgresql-prisma.md).
|
||||||
- **Sessions:** opaque session id in `__Host-portal_session`, payload in self-hosted Redis (Sentinel HA in prod, single node in dev), tokens encrypted at rest with AES-256-GCM, idle 30 min sliding + absolute 12 h — see [ADR-0010](decisions/0010-session-management-redis.md).
|
- **Sessions:** opaque session id in `__Host-portal_session`, payload in self-hosted Redis (Sentinel HA in prod, single node in dev), tokens encrypted at rest with AES-256-GCM, idle 30 min sliding + absolute 12 h — see [ADR-0010](docs/decisions/0010-session-management-redis.md).
|
||||||
- **MFA:** enforced by Entra ID Conditional Access (org-side policy, P1 licensing required); BFF sanity-checks the `amr` claim at session creation; `@RequireMfa()` decorator and freshness-based step-up are designed-in for future sensitive routes (no v1 consumer) — see [ADR-0011](decisions/0011-mfa-enforcement-entra-conditional-access.md).
|
- **MFA:** enforced by Entra ID Conditional Access (org-side policy, P1 licensing required); BFF sanity-checks the `amr` claim at session creation; `@RequireMfa()` decorator and freshness-based step-up are designed-in for future sensitive routes (no v1 consumer) — see [ADR-0011](docs/decisions/0011-mfa-enforcement-entra-conditional-access.md).
|
||||||
- **Identity:** multi-tenant Microsoft Entra ID with B2B invitation for workforce in v1, dual-audience design ready for future External ID activation — see [ADR-0008](decisions/0008-identity-model-entra-workforce-dual-audience.md).
|
- **Identity:** multi-tenant Microsoft Entra ID with B2B invitation for workforce in v1, dual-audience design ready for future External ID activation — see [ADR-0008](docs/decisions/0008-identity-model-entra-workforce-dual-audience.md).
|
||||||
- **Authentication flow:** OIDC Authorization Code + PKCE via `@azure/msal-node`, executed entirely on the BFF; SPA never holds tokens; `__Host-` prefixed cookies, double-submit CSRF, RP-initiated logout — see [ADR-0009](decisions/0009-auth-flow-oidc-pkce-msal-node.md).
|
- **Authentication flow:** OIDC Authorization Code + PKCE via `@azure/msal-node`, executed entirely on the BFF; SPA never holds tokens; `__Host-` prefixed cookies, double-submit CSRF, RP-initiated logout — see [ADR-0009](docs/decisions/0009-auth-flow-oidc-pkce-msal-node.md).
|
||||||
- **Observability:** Pino + `nestjs-pino` for structured JSON logs, OpenTelemetry SDK + auto-instrumentations for traces, W3C Trace Context propagation across SPA → BFF → DB → Redis, `nestjs-cls` for request-scoped context (`trace_id`, `session_id`, `user_id_hash`, `audience`), 100 % sampling at the app with tail sampling deferred to the OTel Collector, stdout + OTLP shipping — see [ADR-0012](decisions/0012-observability-pino-opentelemetry.md).
|
- **Observability:** Pino + `nestjs-pino` for structured JSON logs, OpenTelemetry SDK + auto-instrumentations for traces, W3C Trace Context propagation across SPA → BFF → DB → Redis, `nestjs-cls` for request-scoped context (`trace_id`, `session_id`, `user_id_hash`, `audience`), 100 % sampling at the app with tail sampling deferred to the OTel Collector, stdout + OTLP shipping — see [ADR-0012](docs/decisions/0012-observability-pino-opentelemetry.md).
|
||||||
- **Audit trail:** dedicated `audit.events` schema in the same Postgres instance, append-only by Postgres role grants (`audit_writer` INSERT, `audit_reader` SELECT, `audit_archiver` DELETE older than retention; no `UPDATE`/`TRUNCATE` to anyone); 365-day retention default; cross-referenced with app logs via `trace_id` and `actor_id_hash` (same salt); blocking writes (no audit ⇒ no action) — see [ADR-0013](decisions/0013-audit-trail-separated-postgres-append-only.md).
|
- **Audit trail:** dedicated `audit.events` schema in the same Postgres instance, append-only by Postgres role grants (`audit_writer` INSERT, `audit_reader` SELECT, `audit_archiver` DELETE older than retention; no `UPDATE`/`TRUNCATE` to anyone); 365-day retention default; cross-referenced with app logs via `trace_id` and `actor_id_hash` (same salt); blocking writes (no audit ⇒ no action) — see [ADR-0013](docs/decisions/0013-audit-trail-separated-postgres-append-only.md).
|
||||||
- **Downstream API access:** unified `DownstreamApiClient` (`@nestjs/axios` + `cockatiel`), per-service `DownstreamApiConfig`; default auth strategy is **OBO via MSAL Node** for Entra-protected APIs (downstream-scoped tokens cached in Redis with AES-256-GCM under a dedicated key); fallback strategy is service credential + signed `X-User-Assertion` JWT (BFF JWKS at `/.well-known/jwks.json`); per-call audience pre-check; no `axios`/`fetch` outside `src/downstream/` — see [ADR-0014](decisions/0014-downstream-api-access-obo-pattern.md).
|
- **Downstream API access:** unified `DownstreamApiClient` (`@nestjs/axios` + `cockatiel`), per-service `DownstreamApiConfig`; default auth strategy is **OBO via MSAL Node** for Entra-protected APIs (downstream-scoped tokens cached in Redis with AES-256-GCM under a dedicated key); fallback strategy is service credential + signed `X-User-Assertion` JWT (BFF JWKS at `/.well-known/jwks.json`); per-call audience pre-check; no `axios`/`fetch` outside `src/downstream/` — see [ADR-0014](docs/decisions/0014-downstream-api-access-obo-pattern.md).
|
||||||
- **CI/CD:** **Gitea Actions** (level-2 implementation; will be superseded by a GitLab migration ADR within 6-18 months). Trunk-based with squash-merge, branch protection on `main`, all CI gates blocking. Thin YAML — orchestration logic lives in `package.json` scripts (`ci:check`, `ci:scan`, `ci:commits`) and Nx targets, runnable locally. Gates: format / lint / type-check / test / build / audit / secret-scan / commit-lint, plus `a11y` (per ADR-0016) and future `perf`. Self-hosted `act_runner` on-prem. Conventional Commits validated locally (hook) and in CI (defense in depth). Required reviewer count = 0 in v1, raised to ≥1 once a second contributor joins. Signed commits recommended, revisited at GitLab migration — see [ADR-0015](decisions/0015-cicd-gitea-actions.md).
|
- **CI/CD:** **Gitea Actions** (level-2 implementation; will be superseded by a GitLab migration ADR within 6-18 months). Trunk-based with squash-merge, branch protection on `main`, all CI gates blocking. Thin YAML — orchestration logic lives in `package.json` scripts (`ci:check`, `ci:scan`, `ci:commits`) and Nx targets, runnable locally. Gates: format / lint / type-check / test / build / audit / secret-scan / commit-lint, plus `a11y` (per ADR-0016) and future `perf`. Self-hosted `act_runner` on-prem. Conventional Commits validated locally (hook) and in CI (defense in depth). Required reviewer count = 0 in v1, raised to ≥1 once a second contributor joins. Signed commits recommended, revisited at GitLab migration — see [ADR-0015](docs/decisions/0015-cicd-gitea-actions.md).
|
||||||
- **Accessibility:** **WCAG 2.2 AA baseline + targeted AAA** on criteria with high impact for APF's user base (1.4.6 Contrast Enhanced, 2.2.3 No Timing, 2.3.3 Animation, 3.1.5 Reading Level, 1.4.8 Visual Presentation, 2.4.9 Link Purpose, 3.3.5 Help). RGAA 4.1 alignment for French audit. UI stack: **Angular CDK + spartan-ng + Tailwind** (no Angular Material; no React libs). User-preferences panel (contrast / text size / motion / spacing / cognitive simplification / reading focus) persisted in session. Tooling: `@angular-eslint/template/*` lint, `@axe-core/playwright` e2e (blocking on critical/serious), token-contrast CI check, touch-target check (44×44 min). Manual testing cadence with APF's internal user panel before each major release. Public accessibility statement page at `/accessibility` and `/accessibilite` — see [ADR-0016](decisions/0016-accessibility-baseline-wcag-aa-targeted-aaa.md).
|
- **Accessibility:** **WCAG 2.2 AA baseline + targeted AAA** on criteria with high impact for APF's user base (1.4.6 Contrast Enhanced, 2.2.3 No Timing, 2.3.3 Animation, 3.1.5 Reading Level, 1.4.8 Visual Presentation, 2.4.9 Link Purpose, 3.3.5 Help). RGAA 4.1 alignment for French audit. UI stack: **Angular CDK + TailwindCSS** (spartan-ng _library_ deferred until it reaches 1.0.0; v1 components are written in-house in `libs/shared/ui/` on Angular CDK, applying the spartan-ng _philosophy_ of headless primitives + utility CSS + copy-paste). User-preferences panel (contrast / text size / motion / spacing / cognitive simplification / reading focus) persisted in session. Tooling: `@angular-eslint/template/*` lint, `@axe-core/playwright` e2e (blocking on critical/serious), token-contrast CI check, touch-target check (44×44 min). Manual testing cadence with APF's internal user panel before each major release. Public accessibility statement page at `/accessibility` and `/accessibilite` — see [ADR-0016](docs/decisions/0016-accessibility-baseline-wcag-aa-targeted-aaa.md).
|
||||||
- **Performance budgets:** Core Web Vitals at Google "Good" thresholds (LCP ≤ 2.5 s, INP ≤ 200 ms, CLS ≤ 0.1, TBT ≤ 200 ms, TTFB ≤ 800 ms), Lighthouse Performance ≥ 90 on critical routes. **Lighthouse CI** (`@lhci/cli`) runs in CI with median-of-3 mitigation, blocking on threshold breach. Angular bundle `budgets` (`type: "error"`): initial ≤ 300 KB gzip, lazy chunks ≤ 100 KB gzip. BFF p95/p99 SLOs per endpoint family observed via OTel (advisory in CI, alerting in prod). Weekly scheduled Lighthouse run on prod env. **a11y wins over perf** when they conflict — see [ADR-0017](decisions/0017-performance-budgets-lighthouse-ci.md).
|
- **Performance budgets:** Core Web Vitals at Google "Good" thresholds (LCP ≤ 2.5 s, INP ≤ 200 ms, CLS ≤ 0.1, TBT ≤ 200 ms, TTFB ≤ 800 ms), Lighthouse Performance ≥ 90 on critical routes. **Lighthouse CI** (`@lhci/cli`) runs in CI with median-of-3 mitigation, blocking on threshold breach. Angular bundle `budgets` (`type: "error"`): initial ≤ 300 KB gzip, lazy chunks ≤ 100 KB gzip. BFF p95/p99 SLOs per endpoint family observed via OTel (advisory in CI, alerting in prod). Weekly scheduled Lighthouse run on prod env. **a11y wins over perf** when they conflict — see [ADR-0017](docs/decisions/0017-performance-budgets-lighthouse-ci.md).
|
||||||
- **Local quality gates:** Husky + lint-staged + commitlint with Conventional Commits — see [ADR-0007](decisions/0007-pre-commit-hooks-and-conventional-commits.md).
|
- **Local quality gates:** Husky + lint-staged + commitlint with Conventional Commits — see [ADR-0007](docs/decisions/0007-pre-commit-hooks-and-conventional-commits.md).
|
||||||
- **Runtime:** Node.js latest LTS major.
|
- **Runtime:** Node.js latest LTS major.
|
||||||
|
|
||||||
## Repository status
|
## Repository status
|
||||||
@@ -58,6 +58,7 @@ If asked to "build", "test", or "run" anything, first verify whether the workspa
|
|||||||
## Commands once the workspace exists
|
## Commands once the workspace exists
|
||||||
|
|
||||||
App-scoped — `<app>` is one of `portal-shell`, `portal-bff`:
|
App-scoped — `<app>` is one of `portal-shell`, `portal-bff`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm nx serve <app> # dev server
|
pnpm nx serve <app> # dev server
|
||||||
pnpm nx build <app>
|
pnpm nx build <app>
|
||||||
@@ -66,11 +67,13 @@ pnpm nx lint <app>
|
|||||||
```
|
```
|
||||||
|
|
||||||
Run a single test file:
|
Run a single test file:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm nx test <app> --testFile=path/to/file.spec.ts
|
pnpm nx test <app> --testFile=path/to/file.spec.ts
|
||||||
```
|
```
|
||||||
|
|
||||||
Workspace-wide:
|
Workspace-wide:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm nx run-many -t lint test build
|
pnpm nx run-many -t lint test build
|
||||||
pnpm nx affected -t lint test build # only projects affected by current changes
|
pnpm nx affected -t lint test build # only projects affected by current changes
|
||||||
@@ -83,3 +86,27 @@ pnpm nx format:check
|
|||||||
- **Work inside the WSL filesystem** (`~/dev/...`), never under `/mnt/c` — the latter has severe I/O penalties that break Nx caching and dev-server reload times.
|
- **Work inside the WSL filesystem** (`~/dev/...`), never under `/mnt/c` — the latter has severe I/O penalties that break Nx caching and dev-server reload times.
|
||||||
- **pnpm is mandatory** (activated via `corepack enable`); do not introduce npm or yarn lockfiles.
|
- **pnpm is mandatory** (activated via `corepack enable`); do not introduce npm or yarn lockfiles.
|
||||||
- Prettier config target: `singleQuote: true`, `semi: true`, `printWidth: 100`.
|
- Prettier config target: `singleQuote: true`, `semi: true`, `printWidth: 100`.
|
||||||
|
|
||||||
|
<!-- nx configuration start-->
|
||||||
|
<!-- Leave the start & end comments to automatically receive updates. -->
|
||||||
|
|
||||||
|
## General Guidelines for working with Nx
|
||||||
|
|
||||||
|
- For navigating/exploring the workspace, invoke the `nx-workspace` skill first - it has patterns for querying projects, targets, and dependencies
|
||||||
|
- When running tasks (for example build, lint, test, e2e, etc.), always prefer running the task through `nx` (i.e. `nx run`, `nx run-many`, `nx affected`) instead of using the underlying tooling directly
|
||||||
|
- Prefix nx commands with the workspace's package manager (e.g., `pnpm nx build`, `npm exec nx test`) - avoids using globally installed CLI
|
||||||
|
- You have access to the Nx MCP server and its tools, use them to help the user
|
||||||
|
- For Nx plugin best practices, check `node_modules/@nx/<plugin>/PLUGIN.md`. Not all plugins have this file - proceed without it if unavailable.
|
||||||
|
- NEVER guess CLI flags - always check nx_docs or `--help` first when unsure
|
||||||
|
|
||||||
|
## Scaffolding & Generators
|
||||||
|
|
||||||
|
- For scaffolding tasks (creating apps, libs, project structure, setup), ALWAYS invoke the `nx-generate` skill FIRST before exploring or calling MCP tools
|
||||||
|
|
||||||
|
## When to use nx_docs
|
||||||
|
|
||||||
|
- USE for: advanced config options, unfamiliar flags, migration guides, plugin configuration, edge cases
|
||||||
|
- DON'T USE for: basic generator syntax (`nx g @nx/react:app`), standard commands, things you already know
|
||||||
|
- The `nx-generate` skill handles generator discovery internally - don't call nx_docs just to look up generator syntax
|
||||||
|
|
||||||
|
<!-- nx configuration end-->
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { killPort } from '@nx/node/utils';
|
import { killPort } from '@nx/node/utils';
|
||||||
/* eslint-disable */
|
|
||||||
|
|
||||||
module.exports = async function () {
|
module.exports = async function () {
|
||||||
// Put clean up logic here (e.g. stopping services, docker-compose, etc.).
|
// Put clean up logic here (e.g. stopping services, docker-compose, etc.).
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
/* eslint-disable */
|
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
|
||||||
module.exports = async function () {
|
module.exports = async function () {
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
'@tailwindcss/postcss': {},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
"input": "apps/portal-shell/public"
|
"input": "apps/portal-shell/public"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"styles": ["apps/portal-shell/src/styles.scss"]
|
"styles": ["apps/portal-shell/src/styles.css"]
|
||||||
},
|
},
|
||||||
"configurations": {
|
"configurations": {
|
||||||
"production": {
|
"production": {
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
/*
|
||||||
|
* Global styles for portal-shell.
|
||||||
|
*
|
||||||
|
* Tailwind CSS 4 (CSS-first config). Theme tokens (palette, contrast tiers,
|
||||||
|
* spacing, motion) will be defined under @theme via the libs/shared/tokens
|
||||||
|
* library. spartan-ng components copy-pasted into libs/shared/ui will read
|
||||||
|
* from these tokens.
|
||||||
|
*/
|
||||||
|
|
||||||
|
@import 'tailwindcss';
|
||||||
@@ -1 +0,0 @@
|
|||||||
/* You can add global styles to this file, and also import other style files */
|
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
/**
|
||||||
|
* Commitlint configuration — Conventional Commits.
|
||||||
|
* See ADR-0007 for rationale.
|
||||||
|
*/
|
||||||
|
module.exports = {
|
||||||
|
extends: ['@commitlint/config-conventional'],
|
||||||
|
};
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
---
|
|
||||||
status: accepted
|
|
||||||
date: 2026-04-29
|
|
||||||
decision-makers: R&D Lead
|
|
||||||
tags: [process]
|
|
||||||
---
|
|
||||||
|
|
||||||
# Use ADRs to record architectural decisions
|
|
||||||
|
|
||||||
## Context and Problem Statement
|
|
||||||
|
|
||||||
Architecturally-significant decisions will be taken throughout the lifecycle of `apf-portal`. Without a written, durable trace of *why* a decision was made — context, drivers, alternatives, trade-offs — that knowledge fades within months. New contributors re-litigate settled debates, the rationale of constraints (security, performance, accessibility) gets lost, and reversing a choice later becomes risky because nobody remembers what it was protecting against.
|
|
||||||
|
|
||||||
How do we record decisions in a way that is light enough to be sustained, durable enough to outlive contributor turnover, and integrated into the development workflow?
|
|
||||||
|
|
||||||
## Decision Drivers
|
|
||||||
|
|
||||||
* Preserve the *why* of each decision so future contributors can understand or revisit it without re-deriving it.
|
|
||||||
* Make architecture reviewable — both at the time of a decision and during retrospective audits.
|
|
||||||
* Keep the format light enough that writing an ADR isn't a bureaucratic deterrent.
|
|
||||||
* Live alongside the code in version control, so decisions evolve with what they govern.
|
|
||||||
* Match the project's stated values: high technical bar, no bricolage, security/performance/accessibility as first-class concerns whose rationale must be traceable.
|
|
||||||
|
|
||||||
## Considered Options
|
|
||||||
|
|
||||||
* No formal records — rely on commit messages and tribal knowledge.
|
|
||||||
* External wiki / Confluence — decisions live outside the codebase.
|
|
||||||
* ADRs in markdown using the [MADR 4.0.0](https://github.com/adr/madr) format, in-repo.
|
|
||||||
* ADRs in another format (Nygard, Y-Statement, Cockburn).
|
|
||||||
|
|
||||||
## Decision Outcome
|
|
||||||
|
|
||||||
Chosen option: **ADRs in markdown using MADR 4.0.0**, stored in `decisions/` at the repository root.
|
|
||||||
|
|
||||||
Conventions are defined in [README.md](README.md):
|
|
||||||
- flat folder layout (no nested category folders);
|
|
||||||
- globally sequential 4-digit numeric prefix (`NNNN-kebab-title.md`);
|
|
||||||
- categorization via the `tags:` field in the MADR frontmatter, drawn from a canonical vocabulary;
|
|
||||||
- status lifecycle: `proposed` → `accepted` → optionally `deprecated` / `superseded by ADR-NNNN`;
|
|
||||||
- index maintained in [README.md](README.md) alongside any ADR change.
|
|
||||||
|
|
||||||
### Consequences
|
|
||||||
|
|
||||||
* Good, because the *why* of every architecturally-significant decision is captured at the moment it is made and travels with the code.
|
|
||||||
* Good, because ADRs are reviewed via the normal pull-request flow — architecture becomes an explicit team conversation.
|
|
||||||
* Good, because MADR provides enough structure (drivers, options, trade-offs) without being heavy.
|
|
||||||
* Good, because flat-folder + tags scales better than nested category folders as the count grows.
|
|
||||||
* Bad, because writing an ADR adds friction; this must be balanced against the cost of *not* writing one.
|
|
||||||
* Bad, because the index is maintained manually until tooling or a CI check is added.
|
|
||||||
|
|
||||||
### Confirmation
|
|
||||||
|
|
||||||
* Every change introducing or transitioning the status of an ADR updates the index table in [README.md](README.md) in the same commit.
|
|
||||||
* PR review explicitly checks for ADR proposal whenever a non-trivial decision is being introduced.
|
|
||||||
* (Future) A CI lint job validates filename pattern, frontmatter shape, and tag vocabulary.
|
|
||||||
|
|
||||||
## More Information
|
|
||||||
|
|
||||||
* MADR 4.0.0 specification: https://github.com/adr/madr
|
|
||||||
* General ADR resources: https://adr.github.io/
|
|
||||||
* Template: [template.md](template.md)
|
|
||||||
* Tag vocabulary: see "Tag vocabulary" in [README.md](README.md).
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
---
|
|
||||||
status: accepted
|
|
||||||
date: 2026-04-29
|
|
||||||
decision-makers: R&D Lead
|
|
||||||
tags: [process]
|
|
||||||
---
|
|
||||||
|
|
||||||
# Workspace and app naming convention
|
|
||||||
|
|
||||||
## Context and Problem Statement
|
|
||||||
|
|
||||||
Default Nx scaffolding offers placeholder names like `my-workspace` and `web`, which are unacceptable for a long-lived enterprise project. Naming has to survive two foreseeable changes: the product's marketing name may evolve (the working title `apf_portal` is provisional), and the project may sit alongside other R&D projects in the same Gitea organization, where collisions on generic names like `web` or `api` would cause confusion.
|
|
||||||
|
|
||||||
What naming convention do we adopt for the workspace, the apps, and future libraries so names are explicit, function-anchored, and stable against rebranding?
|
|
||||||
|
|
||||||
## Decision Drivers
|
|
||||||
|
|
||||||
* The product's marketing name is provisional — names anchored on it become stale at rebrand.
|
|
||||||
* Names should reflect *function*, not *brand*.
|
|
||||||
* Multiple R&D projects may share the org — generic names (`web`, `api`, `frontend`) cause friction.
|
|
||||||
* Compliance with npm package naming rules (lowercase, kebab-case).
|
|
||||||
* Readability inside Nx's project graph and CLI output.
|
|
||||||
|
|
||||||
## Considered Options
|
|
||||||
|
|
||||||
* **Defaults** — `my-workspace`, `web`. (Rejected up-front.)
|
|
||||||
* **Brand-anchored** — `apf-front`, `apf-back`. Fragile to further rebranding.
|
|
||||||
* **Function-prefixed** — `portal-shell`, `portal-bff`. (Chosen.)
|
|
||||||
* **Generic** — `shell`, `bff`. Risk of org-wide collision; reduced readability.
|
|
||||||
|
|
||||||
## Decision Outcome
|
|
||||||
|
|
||||||
Chosen option: **function-prefixed naming**.
|
|
||||||
|
|
||||||
| Scope | Name | Rationale |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| Workspace / repo / npm package root | `apf-portal` | matches the Gitea repository name; the only place a brand-ish name lives, so a rebrand is a one-line change |
|
|
||||||
| Frontend app | `portal-shell` | "shell" describes the function — the Angular host that loads features |
|
|
||||||
| Backend app | `portal-bff` | explicit role: backend-for-frontend |
|
|
||||||
| Feature libraries | `feature-<name>` | e.g. `feature-auth`, `feature-billing` |
|
|
||||||
| Shared libraries | `shared-<scope>` | e.g. `shared-ui`, `shared-data-access`, `shared-util` |
|
|
||||||
|
|
||||||
The `portal-` prefix encodes the *function* (a portal). The host organisation's brand can change without invalidating "portal" — the project was originally proposed as `adastra_portal` and renamed to `apf_portal` on 2026-04-30 once the host organisation (APF France Handicap) was confirmed; the apps `portal-shell` / `portal-bff` were unaffected by the rename, validating this convention. The brand-ish token `apf` is confined to the workspace root and a tiny number of metadata files (root `package.json`, repo URL).
|
|
||||||
|
|
||||||
### Consequences
|
|
||||||
|
|
||||||
* Good, because names survive a brand change with minimal churn.
|
|
||||||
* Good, because `portal-shell` / `portal-bff` are unambiguous in CLI output and in the Nx project graph.
|
|
||||||
* Good, because the prefix scales when more apps appear (e.g. `portal-admin`, `portal-jobs`).
|
|
||||||
* Bad, because the prefix adds verbosity (`pnpm nx serve portal-shell` vs `pnpm nx serve web`). Acceptable.
|
|
||||||
* Neutral, because if "portal" itself ceases to be accurate (e.g. the system pivots to something other than a portal), the prefix would also need updating — but that would be a deeper architectural pivot anyway.
|
|
||||||
|
|
||||||
### Confirmation
|
|
||||||
|
|
||||||
* Nx project names match the convention in `nx.json` and in each project's `project.json`.
|
|
||||||
* `@nx/eslint/enforce-module-boundaries` is configured with tags aligned to the convention (`scope:portal-shell`, `scope:portal-bff`, `type:feature`, `type:shared`).
|
|
||||||
* PR review rejects new project names that don't match.
|
|
||||||
|
|
||||||
## More Information
|
|
||||||
|
|
||||||
* Related ADRs: [ADR-0002](0002-adopt-nx-monorepo-apps-preset.md), and the "Project name" rule in [CLAUDE.md](../CLAUDE.md).
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
---
|
|
||||||
status: accepted
|
|
||||||
date: 2026-04-29
|
|
||||||
decision-makers: R&D Lead
|
|
||||||
tags: [frontend]
|
|
||||||
---
|
|
||||||
|
|
||||||
# Frontend stack — Angular (latest LTS), standalone, zoneless, Signals, CSR-only, Vitest
|
|
||||||
|
|
||||||
## Context and Problem Statement
|
|
||||||
|
|
||||||
The portal's frontend (`portal-shell`) is a single-page application that aggregates access to existing applications and progressively integrates re-developed features. It sits fully behind authentication: no public content. We need to fix the framework, the rendering mode, the change-detection model, and the test runner now, so subsequent ADRs (auth flow, observability, accessibility, performance budgets) can build on a stable foundation.
|
|
||||||
|
|
||||||
What frontend stack maximizes alignment with our backend (NestJS), gives us first-class accessibility and performance hooks, and is enterprise-stable for a long-lived project?
|
|
||||||
|
|
||||||
## Decision Drivers
|
|
||||||
|
|
||||||
* Architectural alignment with NestJS (DI, decorators, modules, RxJS) — minimizes cognitive distance for the team.
|
|
||||||
* Performance and accessibility as first-class concerns from day one.
|
|
||||||
* Long-term enterprise stability (no pre-1.0, no exotic stack).
|
|
||||||
* End-to-end type safety.
|
|
||||||
* No public surface — all content gated by authentication, which removes the usual SSR drivers (SEO, anonymous LCP).
|
|
||||||
|
|
||||||
## Considered Options
|
|
||||||
|
|
||||||
* **Angular (latest LTS) — standalone, zoneless, Signals, CSR, Vitest.** (Chosen.)
|
|
||||||
* Angular with SSR (`@angular/ssr`).
|
|
||||||
* React + Next.js (or Vite + React Router).
|
|
||||||
* Vue + Nuxt.
|
|
||||||
* Svelte + SvelteKit.
|
|
||||||
|
|
||||||
## Decision Outcome
|
|
||||||
|
|
||||||
Chosen option: **Angular at the latest LTS major**, with the following modern defaults:
|
|
||||||
|
|
||||||
* **Standalone APIs** — no `NgModule`s. Configuration is composed via providers; routing is functional.
|
|
||||||
* **Zoneless change detection** — no `Zone.js`. Change detection is driven by Signals and explicit `markForCheck` where needed.
|
|
||||||
* **Signals** — primary reactive primitive for component state. RxJS retained for async streams (HTTP, events).
|
|
||||||
* **Vitest** — unit and component test runner (Karma is deprecated upstream).
|
|
||||||
* **CSR only — no SSR** for v1. Reconsider only if a measured performance metric requires it.
|
|
||||||
* **SCSS** for styles. Design-token strategy to be defined in a future ADR.
|
|
||||||
* **Strict TypeScript** across the workspace (`strict: true`, `noUncheckedIndexedAccess: true`).
|
|
||||||
|
|
||||||
### Consequences
|
|
||||||
|
|
||||||
* Good, because the team works with a single mental model across front and back (DI, decorators, RxJS).
|
|
||||||
* Good, because zoneless + Signals removes Zone.js overhead and gives finer-grained, more predictable reactivity.
|
|
||||||
* Good, because Vitest is significantly faster than Karma and shares the Vite toolchain Nx already uses.
|
|
||||||
* Good, because CSR keeps the BFF free of SSR concerns (no auth-aware rendering, no double-fetch, no hydration debugging) — simpler operational surface.
|
|
||||||
* Bad, because Angular's release cadence is brisk; the project commits to staying on LTS rather than skipping versions.
|
|
||||||
* Bad, because zoneless and Signals are recent — though stable, the broader Angular ecosystem still includes Zone-dependent libraries that must be vetted before adoption.
|
|
||||||
* Bad, because CSR yields a minimal initial HTML payload; this must be paired with strong performance budgets (covered by ADR-0015, future).
|
|
||||||
|
|
||||||
### Confirmation
|
|
||||||
|
|
||||||
* `package.json` pins Angular to the current LTS major.
|
|
||||||
* `app.config.ts` registers `provideZonelessChangeDetection()` (or its current stable equivalent) and standalone routing.
|
|
||||||
* `tsconfig.base.json` sets `strict: true` and `noUncheckedIndexedAccess: true`.
|
|
||||||
* CI runs `pnpm nx test portal-shell` (Vitest).
|
|
||||||
* No `import 'zone.js'` anywhere in the codebase.
|
|
||||||
* `@angular/ssr` is not installed.
|
|
||||||
|
|
||||||
## Pros and Cons of the Options
|
|
||||||
|
|
||||||
### Angular CSR (chosen)
|
|
||||||
|
|
||||||
* Good, because alignment with NestJS minimizes context-switching for the team.
|
|
||||||
* Good, because mature, enterprise-supported (Google + community).
|
|
||||||
* Good, because Signals + zoneless make Angular's reactivity competitive with React/Vue.
|
|
||||||
* Bad, because verbosity is higher than minimalist frameworks.
|
|
||||||
|
|
||||||
### Angular SSR
|
|
||||||
|
|
||||||
* Good, because better TTFB and accessibility on first paint for unauthenticated content.
|
|
||||||
* Bad, because we have no unauthenticated content — the gain is essentially zero.
|
|
||||||
* Bad, because SSR-with-auth introduces non-trivial complexity (cookie-aware fetch, hydration with secured data).
|
|
||||||
|
|
||||||
### React + Next.js
|
|
||||||
|
|
||||||
* Good, because large ecosystem, fast iteration.
|
|
||||||
* Bad, because no architectural alignment with NestJS — different DI/state paradigms.
|
|
||||||
* Bad, because Next.js's defaults push toward server components and edge rendering, which conflict with our on-prem, all-authenticated profile.
|
|
||||||
|
|
||||||
### Vue + Nuxt / Svelte + SvelteKit
|
|
||||||
|
|
||||||
* Good, because lighter syntax, strong DX.
|
|
||||||
* Bad, because no DI alignment with NestJS.
|
|
||||||
* Bad, because smaller enterprise-grade community than Angular or React.
|
|
||||||
|
|
||||||
## More Information
|
|
||||||
|
|
||||||
* Angular zoneless change detection: https://angular.dev/guide/experimental/zoneless
|
|
||||||
* Angular Signals: https://angular.dev/guide/signals
|
|
||||||
* Vitest with Nx: https://nx.dev/nx-api/vite
|
|
||||||
* Related ADRs: [ADR-0005](0005-backend-stack-nestjs.md), ADR-0008 (auth flow, future), ADR-0014 (accessibility, future), ADR-0015 (performance budgets, future).
|
|
||||||
@@ -1,244 +0,0 @@
|
|||||||
---
|
|
||||||
status: accepted
|
|
||||||
date: 2026-04-30
|
|
||||||
decision-makers: R&D Lead
|
|
||||||
tags: [accessibility, frontend, process]
|
|
||||||
---
|
|
||||||
|
|
||||||
# Accessibility baseline — WCAG 2.2 AA + targeted AAA, Angular CDK + spartan-ng + Tailwind, APF panel testing
|
|
||||||
|
|
||||||
## Context and Problem Statement
|
|
||||||
|
|
||||||
The host organisation is **APF France Handicap**. Accessibility is the organisation's core mission, not a compliance checkbox. A meaningful proportion of the portal's users will be in situations of disability — visual, motor, cognitive, hearing — and will rely on assistive technologies (screen readers, switch controls, eye tracking, voice control, head pointers, mouth sticks). The portal's accessibility quality is therefore a **product attribute**, not a downstream concern.
|
|
||||||
|
|
||||||
The default WCAG 2.2 AA baseline applicable to enterprise portals would *under-serve* this user base. We need to fix:
|
|
||||||
- the conformance target (WCAG level + targeted enhancements);
|
|
||||||
- the implementation stack (component library, primitives, styling);
|
|
||||||
- the user-preferences surface (contrast, text size, motion, etc.) the portal must offer;
|
|
||||||
- the testing strategy, including manual testing with users from APF's internal network;
|
|
||||||
- the CI gates that prevent regression;
|
|
||||||
- the legal-disclosure surface (the EU Accessibility Act–required accessibility statement).
|
|
||||||
|
|
||||||
This ADR fixes the framework. Concrete component-by-component implementation lands as the scaffold and features arrive.
|
|
||||||
|
|
||||||
## Decision Drivers
|
|
||||||
|
|
||||||
* APF's mission elevates accessibility from "compliance" to "product".
|
|
||||||
* European Accessibility Act (EAA, in application since June 2025) makes WCAG 2.2 AA a legal floor in the EU; the portal will publish an accessibility statement and accept regression as a defect, not as a backlog item.
|
|
||||||
* RGAA 4.1 (French national standard, derived from WCAG) is the operational reference for accessibility audits in France; alignment is necessary to be auditable by French experts (including APF's own).
|
|
||||||
* The user base extends beyond screen-reader users: switch controls, eye tracking, voice control, cognitive load tolerance — all must be considered.
|
|
||||||
* The technical stack is locked on Angular ([ADR-0004](0004-frontend-stack-angular-csr-zoneless-signals.md)). Component-library choice must therefore be Angular-native or framework-agnostic.
|
|
||||||
* The dual-audience design ([ADR-0008](0008-identity-model-entra-workforce-dual-audience.md)) means a11y must be uniform across audiences — workforce and (future) customer users alike.
|
|
||||||
* Anti-bricolage: no hand-rolled a11y where a maintained library exists.
|
|
||||||
|
|
||||||
## Considered Options
|
|
||||||
|
|
||||||
### Conformance target
|
|
||||||
* WCAG 2.2 **AA** baseline (EU EAA legal floor).
|
|
||||||
* WCAG 2.2 **AA + targeted AAA** on criteria with high impact for APF's user base. (Chosen.)
|
|
||||||
* WCAG 2.2 **AAA** uniform.
|
|
||||||
|
|
||||||
### UI component stack
|
|
||||||
* Angular Material (Angular CDK + Material visual layer).
|
|
||||||
* **Angular CDK + spartan-ng + Tailwind.** (Chosen — see also `notes/argumentaire-stack-ui-spartan-cdk-tailwind.md` for the team-internal rationale.)
|
|
||||||
* Custom-on-Angular-CDK only (no spartan-ng).
|
|
||||||
* React-ecosystem libs (shadcn/ui, dice-ui, animate-ui).
|
|
||||||
|
|
||||||
### User-preferences scope
|
|
||||||
* None (rely on browser/OS prefs).
|
|
||||||
* `prefers-reduced-motion` only.
|
|
||||||
* **Full preferences panel** — contrast modes, text size, motion, spacing, simplified UI. (Chosen.)
|
|
||||||
|
|
||||||
### Testing strategy
|
|
||||||
* Automated only (axe-core + lint).
|
|
||||||
* Automated + external annual audit.
|
|
||||||
* **Automated + manual screen-reader/keyboard/switch testing + APF user panel cadence + annual internal audit.** (Chosen.)
|
|
||||||
|
|
||||||
### Documentation surface
|
|
||||||
* Internal docs only.
|
|
||||||
* **Public accessibility statement page (legal EAA requirement) + internal patterns library.** (Chosen.)
|
|
||||||
|
|
||||||
## Decision Outcome
|
|
||||||
|
|
||||||
### Conformance target
|
|
||||||
|
|
||||||
**WCAG 2.2 AA** as the universal baseline. **AAA** applied on the criteria that materially affect APF's user base:
|
|
||||||
|
|
||||||
| AAA criterion | What it adds vs AA | Why APF |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| **1.4.6 Contrast (Enhanced)** | 7:1 normal text / 4.5:1 large text (vs 4.5:1 / 3:1 at AA) | Low-vision users are a primary audience |
|
|
||||||
| **2.2.3 No Timing** | No time limits except where essential | Cognitive load and motor speed vary widely |
|
|
||||||
| **2.3.3 Animation from Interactions** | All interaction-driven animation can be disabled | Vestibular and cognitive disorders |
|
|
||||||
| **3.1.5 Reading Level** | Content readable at lower secondary level *or* a simpler alternative is provided | Cognitive accessibility |
|
|
||||||
| **1.4.8 Visual Presentation** | User control over text formatting (line spacing, paragraph spacing, line length) | Dyslexia, low vision |
|
|
||||||
| **2.4.9 Link Purpose (Link Only)** | Each link's purpose understandable from its text alone (no surrounding context) | Screen-reader navigation by link list |
|
|
||||||
| **3.3.5 Help** | Context-sensitive help available where needed | Cognitive accessibility |
|
|
||||||
|
|
||||||
The remaining AAA criteria (e.g. 1.2.6 Sign Language, 1.2.8 Media Alternative) are **not** uniformly required — they apply where the corresponding content type appears, and are addressed case by case. **RGAA 4.1** alignment is maintained as the audit reference for French experts (APF, CNIL audits, public-sector partners).
|
|
||||||
|
|
||||||
### User-preferences panel
|
|
||||||
|
|
||||||
The portal exposes a **first-class user-preferences panel** accessible from any page (keyboard-discoverable, screen-reader-announced, persisted in the user's session per [ADR-0010](0010-session-management-redis.md)):
|
|
||||||
|
|
||||||
| Preference | Options | Default |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| Contrast mode | Standard (AA-compliant) / Enhanced (AAA, 7:1) / Maximum (high-contrast OS-style, monochrome) | Standard |
|
|
||||||
| Text size | 100 % / 125 % / 150 % / 175 % / 200 % | 100 % |
|
|
||||||
| Motion | Full / Reduced / None | inherited from `prefers-reduced-motion` |
|
|
||||||
| Text spacing | Default / Generous (line-spacing 1.8, paragraph-spacing 2×, letter-spacing 0.12em) | Default |
|
|
||||||
| Cognitive simplification | Off / On (reduces secondary chrome, larger primary actions, easier-to-read summaries when authored) | Off |
|
|
||||||
| Reading focus | Off / Line-by-line | Off |
|
|
||||||
|
|
||||||
Preferences are saved per user; an unauthenticated default is read from browser preferences (`prefers-reduced-motion`, `prefers-contrast`, etc.). Changing a preference takes effect immediately, no page reload.
|
|
||||||
|
|
||||||
### Component stack
|
|
||||||
|
|
||||||
**Angular CDK + spartan-ng + TailwindCSS**. See [`notes/argumentaire-stack-ui-spartan-cdk-tailwind.md`](../notes/argumentaire-stack-ui-spartan-cdk-tailwind.md) for the full rationale produced for the dev team. Summary:
|
|
||||||
|
|
||||||
- **Angular CDK** (`@angular/cdk`) — primitives accessibility layer maintained by Google's Angular Material team. Equivalent role to Radix UI in React. Provides `Overlay`, `FocusTrap`, `FocusMonitor`, `LiveAnnouncer`, `A11yModule`, `ListKeyManager`, `Drag and Drop`, etc. Battle-tested since 2017.
|
|
||||||
- **spartan-ng** (https://www.spartan.ng/) — copy-paste component layer built on Angular CDK, styled with Tailwind. Components are checked into our repo (`libs/shared/ui/`) and modified freely. Equivalent role to shadcn/ui in React.
|
|
||||||
- **Tailwind CSS** — utility-first styling. Tokens for colour, spacing, contrast tiers live in `libs/shared/tokens/`. The contrast tiers map directly to the user preference modes above.
|
|
||||||
|
|
||||||
Angular Material is **rejected** because its visual design system (Material Design) is opinionated in ways that clash with both APF branding and the multi-tier contrast requirements. React-ecosystem libraries (shadcn/ui, dice-ui, animate-ui) are rejected as incompatible with the Angular stack ([ADR-0004](0004-frontend-stack-angular-csr-zoneless-signals.md)).
|
|
||||||
|
|
||||||
### Concrete patterns to enforce
|
|
||||||
|
|
||||||
| Concern | Rule |
|
|
||||||
| --- | --- |
|
|
||||||
| Sémantique HTML | `<header>`, `<main>`, `<nav>`, `<aside>`, `<footer>` landmarks. One `h1` per page. `<button>` for actions, `<a>` for navigation. **No** `<div onclick>` — flagged by ESLint and rejected at PR review |
|
|
||||||
| Skip-links | "Aller au contenu principal" link as the first focusable element on every page |
|
|
||||||
| Focus | Always visible (no `outline: none` without an at-least-equivalent replacement). Trapped in modals via Angular CDK `FocusTrap`. Restored after navigation/modal close |
|
|
||||||
| Forms | Each `<input>` has an associated `<label>`. Errors associated via `aria-describedby`. `autocomplete` attribute correct. Valid HTML5 `type` |
|
|
||||||
| Touch targets | Minimum **44 × 44 CSS pixels** (WCAG 2.5.5 AAA), default 48 × 48 in spartan-ng tokens for safety |
|
|
||||||
| Animations | Respect `prefers-reduced-motion` and the user-preferences `motion` setting. No autoplay video. No carousels with auto-rotation |
|
|
||||||
| Language | `lang` attribute on `<html>` set dynamically per active locale. Inline `lang` on foreign-language fragments |
|
|
||||||
| Images | All non-decorative images carry meaningful `alt`. Decorative images carry `alt=""` and `role="presentation"` |
|
|
||||||
| ARIA | Used **only** when native HTML semantics are insufficient. Misuse of ARIA (the most common a11y bug) is treated as a code-quality defect |
|
|
||||||
| Error messaging | Programmatic association (`aria-describedby`), live regions (`aria-live="polite"`) for server errors, never just colour to convey state |
|
|
||||||
|
|
||||||
### Tooling and CI gates
|
|
||||||
|
|
||||||
| Layer | Tool | Where it runs | Blocking? |
|
|
||||||
| --- | --- | --- | --- |
|
|
||||||
| Static lint on Angular templates | `@angular-eslint/template/*` rules (no-positive-tabindex, click-events-have-key-events, label-has-associated-control, elements-content, no-autofocus, alt-text, valid-aria, button-has-type, …) | local hook + CI (`pnpm ci:check`, [ADR-0015](0015-cicd-gitea-actions.md)) | **Yes** |
|
|
||||||
| Automated runtime a11y check | `@axe-core/playwright` integrated in e2e tests | CI (`pnpm ci:quality` — the `a11y` gate of [ADR-0015](0015-cicd-gitea-actions.md)) | **Yes** on `critical` or `serious` violations; warning on `moderate`/`minor` |
|
|
||||||
| Design-token contrast verification | Custom script that walks `libs/shared/tokens/` colour pairs and verifies WCAG AA + AAA contrast ratios | CI | **Yes** |
|
|
||||||
| Touch-target check | Layout test verifying interactive element minimum size | CI (Playwright) | **Yes** on min 44 × 44 |
|
|
||||||
| Motion respect check | E2E test that turns motion off and verifies no animation occurs | CI (Playwright) | **Yes** |
|
|
||||||
| Screen-reader manual testing | NVDA (Windows), VoiceOver (macOS, iOS), TalkBack (Android) | Manual, before merge of any new component or major refactor | **Yes** — checklist in PR template |
|
|
||||||
| Keyboard-only navigation | Manual | Manual, before merge of any UI change | **Yes** — checklist in PR template |
|
|
||||||
| Switch-control / eye-tracking | Manual, on flagship features | APF user panel cadence | **Yes** as gate for major releases |
|
|
||||||
| Cognitive walkthrough | Manual, with user representatives | APF user panel cadence | **Yes** as gate for major releases |
|
|
||||||
|
|
||||||
### Manual testing — APF user panel
|
|
||||||
|
|
||||||
APF France Handicap has, by design, an active network of users in situations of disability. This is a structural advantage that no consultant can replicate.
|
|
||||||
|
|
||||||
- **Cadence** : panel-tested before each major release. Composition: at least one user per primary disability category (visual, motor, cognitive, hearing) drawn from the APF network.
|
|
||||||
- **Scope** : critical user journeys (sign-in, navigation, integrated apps, user-preferences panel, error-recovery flows) plus any new flagship feature.
|
|
||||||
- **Format** : moderated session, observation-based, recorded with consent. Findings categorised (blocker / serious / moderate / minor) and triaged like security findings — blockers and serious must be fixed before release.
|
|
||||||
- **Authority** : a finding by a user-panel session is at least as authoritative as an axe-core report. Disagreements escalate to the R&D lead.
|
|
||||||
|
|
||||||
### Documentation
|
|
||||||
|
|
||||||
- **Accessibility statement page** at `/accessibility` (English) and `/accessibilite` (French) — required by EAA. Content: conformance level claimed, scope (which routes are covered), known issues with target resolution dates, contact email for accessibility complaints, date of last review, RGAA alignment level. Generated from a maintained source-of-truth file in the repo (e.g. `apps/portal-shell/src/accessibility-statement.md`) so the statement evolves with the code, not separately.
|
|
||||||
- **Internal patterns library** in `docs/accessibility/` (created when first patterns ship) covering: focus management, ARIA usage, form patterns, error messaging, multi-step flows, modals, notifications. Examples drawn from the codebase, kept in sync via review.
|
|
||||||
- **Component library docs** : every spartan-ng component checked into `libs/shared/ui/` carries a11y notes (keyboard model, ARIA roles emitted, screen-reader expectations).
|
|
||||||
|
|
||||||
### Consequences
|
|
||||||
|
|
||||||
* Good, because a11y is structural — encoded in the lint, the gates, the components, the tokens, the user-preferences panel — rather than relying on individual discipline.
|
|
||||||
* Good, because the chosen stack (CDK + spartan-ng + Tailwind) gives full design control without forfeiting Google-tier a11y primitives, and supports the multi-tier contrast/text-size/motion modes without contortion.
|
|
||||||
* Good, because the AA + targeted AAA approach concentrates effort where APF's user base benefits most, instead of spreading thin across every AAA criterion.
|
|
||||||
* Good, because access to APF's user network is a competitive advantage — the panel testing is more rigorous than what most enterprise projects can afford.
|
|
||||||
* Good, because the accessibility statement and the patterns library make the project's a11y posture *legible* — auditable by external experts, demonstrable to APF stakeholders.
|
|
||||||
* Bad, because the upfront cost is non-trivial — design tokens with multi-tier contrast, components with theming, user-preferences panel, e2e a11y tests. Estimated +25–35 % of UI delivery time vs. an a11y-as-afterthought approach. Acknowledged and accepted given APF's mission.
|
|
||||||
* Bad, because spartan-ng is younger than Angular Material — occasional rough edges expected. Mitigated by the copy-paste model: components live in our repo, we own them, fallback is custom-on-CDK without lib dependency.
|
|
||||||
* Bad, because the user-preferences panel must be wired through every component — components that don't honour the active contrast mode or the active text size break the contract. Enforced by review and a custom lint rule per component.
|
|
||||||
* Bad, because manual testing cadence (panel sessions, screen-reader, keyboard) is real ops effort. Mitigated by APF's internal network; expense kept inside the org.
|
|
||||||
|
|
||||||
### Confirmation
|
|
||||||
|
|
||||||
* `libs/shared/tokens` exposes design tokens for colour (across at least three contrast tiers), spacing, typography, motion. Token contrast ratios are verified by a CI script.
|
|
||||||
* `libs/shared/ui` hosts spartan-ng components, copied in and themed against the tokens. Each component carries an a11y note in its docs.
|
|
||||||
* Angular CDK is the only allowed source of `Overlay`, `FocusTrap`, `FocusMonitor`, `LiveAnnouncer`, `Drag and Drop`. No equivalent rolled by hand.
|
|
||||||
* `apps/portal-shell` ships a user-preferences panel persisting to the session (Redis); changes apply without reload; default reads from `prefers-*` browser media queries.
|
|
||||||
* ESLint configuration enables every `@angular-eslint/template/*` a11y rule. CI fails on any violation.
|
|
||||||
* `@axe-core/playwright` is integrated in the e2e suite. CI's `a11y` gate fails on any `critical` or `serious` violation, warns on `moderate` or `minor`.
|
|
||||||
* Design-token contrast verifier and touch-target verifier are part of `pnpm ci:quality`.
|
|
||||||
* PR template includes a manual-checklist section: keyboard navigation tested, screen reader tested (which one), focus order verified, error states verified.
|
|
||||||
* Accessibility statement page is published, referenced from the footer, and updated at every major release.
|
|
||||||
* APF user-panel session findings are tracked as issues with a label `a11y/panel-finding` and triaged with the same gravity as security issues.
|
|
||||||
|
|
||||||
## Pros and Cons of the Options
|
|
||||||
|
|
||||||
### Conformance target
|
|
||||||
|
|
||||||
#### AA + targeted AAA (chosen)
|
|
||||||
|
|
||||||
* Good, because matches the user base where it matters and stays achievable on a uniform basis.
|
|
||||||
* Good, because the AAA criteria selected are the ones with measurable user-impact in APF's primary populations.
|
|
||||||
* Bad, because the selection is judgement-based — open to debate at audit. Mitigated by documenting the rationale per criterion (this ADR).
|
|
||||||
|
|
||||||
#### AA only
|
|
||||||
|
|
||||||
* Good, because the EU EAA legal floor.
|
|
||||||
* Bad, because clearly insufficient given APF's mission. Bare AA on contrast (4.5:1) is uncomfortable for a low-vision user; AA on motion is permissive.
|
|
||||||
|
|
||||||
#### AAA uniform
|
|
||||||
|
|
||||||
* Good, because the highest WCAG level on every criterion.
|
|
||||||
* Bad, because some AAA criteria (1.2.6 Sign Language, 1.2.8 Media Alternative, 1.4.9 Images of Text) require content production or content forms we may not have. Pretending uniform AAA when not really delivered is worse than an honest AA + targeted AAA.
|
|
||||||
|
|
||||||
### UI component stack
|
|
||||||
|
|
||||||
#### Angular CDK + spartan-ng + Tailwind (chosen)
|
|
||||||
|
|
||||||
* Good, because Google-tier a11y primitives via CDK + copy-paste components via spartan-ng + utility CSS via Tailwind = full control, no design-system lock-in, no proprietary visuals to fight.
|
|
||||||
* Good, because aligns with the team-internal preference for the shadcn-style pattern (per dev consultation), without having to break the Angular alignment with NestJS.
|
|
||||||
* Bad, because spartan-ng is younger (2024). Risk mitigated by the copy-paste model — we own the code.
|
|
||||||
|
|
||||||
#### Angular Material
|
|
||||||
|
|
||||||
* Good, because most mature, Google-maintained.
|
|
||||||
* Bad, because the Material Design visual language is opinionated and clashes with multi-tier contrast modes and APF branding. Switching themes within Material is harder than switching themes when the components are owned in source.
|
|
||||||
|
|
||||||
#### Custom-on-Angular-CDK only
|
|
||||||
|
|
||||||
* Good, because zero dependency beyond Google.
|
|
||||||
* Bad, because every component (button, dialog, tabs, menu, etc.) must be built from CDK primitives — large upfront cost.
|
|
||||||
|
|
||||||
#### React-ecosystem libs
|
|
||||||
|
|
||||||
* Bad, because incompatible with Angular ([ADR-0004](0004-frontend-stack-angular-csr-zoneless-signals.md)).
|
|
||||||
|
|
||||||
### Testing strategy
|
|
||||||
|
|
||||||
#### Automated + manual + APF user panel + annual internal audit (chosen)
|
|
||||||
|
|
||||||
* Good, because covers the spectrum from regression detection to genuine usability.
|
|
||||||
* Good, because user panel access is structurally cheaper than what a consultancy would charge.
|
|
||||||
* Bad, because requires panel scheduling and triage — operational item.
|
|
||||||
|
|
||||||
#### Automated only
|
|
||||||
|
|
||||||
* Bad, because tools detect ~30 % of real accessibility defects. The remaining 70 % are interaction patterns invisible to static and runtime checks.
|
|
||||||
|
|
||||||
#### Automated + external annual audit
|
|
||||||
|
|
||||||
* Good, because external perspective.
|
|
||||||
* Bad, because expensive (5–15 k€/year for a real audit) and arguably less informed than APF's internal expertise. Reconsidered if APF wants a third-party signature for legal disclosure.
|
|
||||||
|
|
||||||
## More Information
|
|
||||||
|
|
||||||
* WCAG 2.2 specification: https://www.w3.org/TR/WCAG22/
|
|
||||||
* WCAG 2.2 quick reference (filterable by level): https://www.w3.org/WAI/WCAG22/quickref/
|
|
||||||
* European Accessibility Act (EAA): https://ec.europa.eu/social/main.jsp?catId=1202
|
|
||||||
* RGAA 4.1: https://accessibilite.numerique.gouv.fr/
|
|
||||||
* APF France Handicap: https://www.apf-francehandicap.org/
|
|
||||||
* Angular CDK A11y: https://material.angular.io/cdk/a11y/overview
|
|
||||||
* spartan-ng: https://www.spartan.ng/
|
|
||||||
* `@angular-eslint`: https://github.com/angular-eslint/angular-eslint
|
|
||||||
* `@axe-core/playwright`: https://github.com/dequelabs/axe-core-npm/tree/develop/packages/playwright
|
|
||||||
* Inclusive Components: https://inclusive-components.design/
|
|
||||||
* Related ADRs: [ADR-0004](0004-frontend-stack-angular-csr-zoneless-signals.md) (Angular stack), [ADR-0010](0010-session-management-redis.md) (preferences persistence), [ADR-0015](0015-cicd-gitea-actions.md) (CI gates `a11y` and `perf`), and the future ADRs for performance budgets, security baseline (ASVS L3 implications cross-cut a11y in some areas — e.g. step-up MFA must remain a11y-conformant), and on-prem infrastructure.
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
# Architectural Decision Records
|
|
||||||
|
|
||||||
This project records architecturally-significant decisions as **ADRs** in the [MADR 4.0.0](https://github.com/adr/madr) format. References: [adr.github.io](https://adr.github.io/).
|
|
||||||
|
|
||||||
## Why ADRs
|
|
||||||
|
|
||||||
ADRs capture the *why* behind a decision — context, drivers, options considered, trade-offs accepted — at the moment the decision is made. They make architecture reviewable, onboarding faster, and prevent the same debate from being re-litigated later.
|
|
||||||
|
|
||||||
## Conventions
|
|
||||||
|
|
||||||
- **Format:** MADR 4.0.0. Start from [template.md](template.md).
|
|
||||||
- **Filename:** `NNNN-kebab-case-title.md`, e.g. `0007-adopt-tailwind-for-design-tokens.md`.
|
|
||||||
- **Numbering:** globally sequential 4-digit prefix. Numbers never reset, never get reused — even when an ADR is superseded or deprecated.
|
|
||||||
- **Layout:** flat folder. ADRs are not nested into category subfolders; topical organization happens via tags.
|
|
||||||
- **Tags:** every ADR carries a `tags:` array in the MADR frontmatter, drawn from the [tag vocabulary](#tag-vocabulary) below. An ADR may carry several tags. Propose new tags (or renames) in the same PR that needs them; never invent ad-hoc tags inline.
|
|
||||||
- **Status lifecycle:** `proposed` → `accepted` → optionally `deprecated` or `superseded by [ADR-NNNN](NNNN-other.md)`. Update the YAML frontmatter; never delete an ADR.
|
|
||||||
- **Index maintenance:** every ADR addition or status change must update the [Index](#index) below in the same commit.
|
|
||||||
|
|
||||||
## When to write an ADR
|
|
||||||
|
|
||||||
Write one whenever a development decision is non-trivial: tool or library choice, framework pattern, security control, perf budget, a11y target, naming convention, deprecation, breaking change, or any choice that future contributors would benefit from understanding the *why* of.
|
|
||||||
|
|
||||||
## Tag vocabulary
|
|
||||||
|
|
||||||
The vocabulary below is the source of truth. It is intentionally coarse — propose extensions only when an existing tag genuinely doesn't fit, and avoid overly narrow tags.
|
|
||||||
|
|
||||||
| Tag | Scope |
|
|
||||||
| ---------------- | ----- |
|
|
||||||
| `frontend` | UI, Angular, components, design system, client-side state |
|
|
||||||
| `backend` | API, BFF, server-side services |
|
|
||||||
| `security` | AuthN, AuthZ, sessions, CSP, dependency scanning, secret management |
|
|
||||||
| `performance` | Perf budgets, caching, bundle size, Lighthouse |
|
|
||||||
| `accessibility` | WCAG, a11y testing, keyboard, ARIA, contrast |
|
|
||||||
| `infrastructure` | CI/CD, hosting, deployment, runtime |
|
|
||||||
| `observability` | Logs, metrics, traces, correlation IDs, monitoring |
|
|
||||||
| `data` | Persistence, schemas, migrations, data flow |
|
|
||||||
| `process` | Team conventions, workflows, repo policy |
|
|
||||||
|
|
||||||
> _Status: starter vocabulary, to be refined as ADRs accumulate. Update this table whenever a tag is added, renamed, or retired._
|
|
||||||
|
|
||||||
## Index
|
|
||||||
|
|
||||||
ADRs are listed in numerical order. To slice by topic, filter on the `Tags` column.
|
|
||||||
|
|
||||||
| # | Title | Status | Tags | Date |
|
|
||||||
| ---- | ----- | ------ | ---- | ---- |
|
|
||||||
| [0001](0001-use-adrs-to-record-architectural-decisions.md) | Use ADRs to record architectural decisions | accepted | `process` | 2026-04-29 |
|
|
||||||
| [0002](0002-adopt-nx-monorepo-apps-preset.md) | Adopt Nx monorepo with the `apps` preset | accepted | `infrastructure`, `frontend`, `backend` | 2026-04-29 |
|
|
||||||
| [0003](0003-workspace-and-app-naming-convention.md) | Workspace and app naming convention | accepted | `process` | 2026-04-29 |
|
|
||||||
| [0004](0004-frontend-stack-angular-csr-zoneless-signals.md) | Frontend stack — Angular (latest LTS), standalone, zoneless, Signals, CSR-only, Vitest | accepted | `frontend` | 2026-04-29 |
|
|
||||||
| [0005](0005-backend-stack-nestjs.md) | Backend stack — NestJS over Express, Fastify, Hono | accepted | `backend` | 2026-04-29 |
|
|
||||||
| [0006](0006-persistence-postgresql-prisma.md) | Persistence — PostgreSQL with Prisma | accepted | `data`, `backend` | 2026-04-29 |
|
|
||||||
| [0007](0007-pre-commit-hooks-and-conventional-commits.md) | Pre-commit hooks and Conventional Commits | accepted | `process` | 2026-04-29 |
|
|
||||||
| [0008](0008-identity-model-entra-workforce-dual-audience.md) | Identity model — multi-tenant Entra ID for workforce, dual-audience design for future External ID | accepted | `security`, `data` | 2026-04-29 |
|
|
||||||
| [0009](0009-auth-flow-oidc-pkce-msal-node.md) | Authentication flow — OIDC Authorization Code + PKCE via MSAL Node, BFF session pattern | accepted | `security`, `backend` | 2026-04-29 |
|
|
||||||
| [0010](0010-session-management-redis.md) | Session management — opaque session IDs in cookies, payload in self-hosted Redis with AES-GCM at rest | accepted | `security`, `backend`, `infrastructure` | 2026-04-29 |
|
|
||||||
| [0011](0011-mfa-enforcement-entra-conditional-access.md) | MFA enforcement — Entra ID Conditional Access baseline, BFF claim sanity-check, step-up hooks designed-in | accepted | `security` | 2026-04-29 |
|
|
||||||
| [0012](0012-observability-pino-opentelemetry.md) | Observability — Pino structured logs + OpenTelemetry tracing, W3C Trace Context propagation, stdout + collector | accepted | `observability`, `backend`, `frontend` | 2026-04-29 |
|
|
||||||
| [0013](0013-audit-trail-separated-postgres-append-only.md) | Audit trail — separated append-only Postgres schema, decoupled from app logs | accepted | `security`, `observability`, `data` | 2026-04-29 |
|
|
||||||
| [0014](0014-downstream-api-access-obo-pattern.md) | Downstream API access — On-Behalf-Of pattern, unified `DownstreamApiClient`, audience-aware authorization | accepted | `security`, `backend` | 2026-04-29 |
|
|
||||||
| [0015](0015-cicd-gitea-actions.md) | CI/CD pipeline — Gitea Actions, trunk-based + squash-merge, thin YAML over portable scripts | accepted | `infrastructure`, `process` | 2026-04-30 |
|
|
||||||
| [0016](0016-accessibility-baseline-wcag-aa-targeted-aaa.md) | Accessibility baseline — WCAG 2.2 AA + targeted AAA, Angular CDK + spartan-ng + Tailwind, APF panel testing | accepted | `accessibility`, `frontend`, `process` | 2026-04-30 |
|
|
||||||
| [0017](0017-performance-budgets-lighthouse-ci.md) | Performance budgets — Core Web Vitals + Lighthouse CI gates, bundle budgets, BFF p95/p99 SLOs | accepted | `performance`, `frontend`, `backend`, `process` | 2026-04-30 |
|
|
||||||
+1
-1
@@ -7,7 +7,7 @@ This is the entry point to all project documentation. It is maintained automatic
|
|||||||
- Documentation is written in **English**.
|
- Documentation is written in **English**.
|
||||||
- One topic per file. Group related files into a folder when there are three or more.
|
- One topic per file. Group related files into a folder when there are three or more.
|
||||||
- Cross-reference with relative links so they keep working in GitHub, IDEs, and exported sites.
|
- Cross-reference with relative links so they keep working in GitHub, IDEs, and exported sites.
|
||||||
- For architectural decisions, do **not** add them here — they belong in [../decisions/](../decisions/) as MADR 4.0.0 ADRs.
|
- For architectural decisions, do **not** add them here — they belong in [decisions/](decisions/) as MADR 4.0.0 ADRs.
|
||||||
|
|
||||||
## Sections
|
## Sections
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
---
|
||||||
|
status: accepted
|
||||||
|
date: 2026-04-29
|
||||||
|
decision-makers: R&D Lead
|
||||||
|
tags: [process]
|
||||||
|
---
|
||||||
|
|
||||||
|
# Use ADRs to record architectural decisions
|
||||||
|
|
||||||
|
## Context and Problem Statement
|
||||||
|
|
||||||
|
Architecturally-significant decisions will be taken throughout the lifecycle of `apf-portal`. Without a written, durable trace of _why_ a decision was made — context, drivers, alternatives, trade-offs — that knowledge fades within months. New contributors re-litigate settled debates, the rationale of constraints (security, performance, accessibility) gets lost, and reversing a choice later becomes risky because nobody remembers what it was protecting against.
|
||||||
|
|
||||||
|
How do we record decisions in a way that is light enough to be sustained, durable enough to outlive contributor turnover, and integrated into the development workflow?
|
||||||
|
|
||||||
|
## Decision Drivers
|
||||||
|
|
||||||
|
- Preserve the _why_ of each decision so future contributors can understand or revisit it without re-deriving it.
|
||||||
|
- Make architecture reviewable — both at the time of a decision and during retrospective audits.
|
||||||
|
- Keep the format light enough that writing an ADR isn't a bureaucratic deterrent.
|
||||||
|
- Live alongside the code in version control, so decisions evolve with what they govern.
|
||||||
|
- Match the project's stated values: high technical bar, no bricolage, security/performance/accessibility as first-class concerns whose rationale must be traceable.
|
||||||
|
|
||||||
|
## Considered Options
|
||||||
|
|
||||||
|
- No formal records — rely on commit messages and tribal knowledge.
|
||||||
|
- External wiki / Confluence — decisions live outside the codebase.
|
||||||
|
- ADRs in markdown using the [MADR 4.0.0](https://github.com/adr/madr) format, in-repo.
|
||||||
|
- ADRs in another format (Nygard, Y-Statement, Cockburn).
|
||||||
|
|
||||||
|
## Decision Outcome
|
||||||
|
|
||||||
|
Chosen option: **ADRs in markdown using MADR 4.0.0**, stored under `docs/decisions/` so they sit with the rest of the project documentation.
|
||||||
|
|
||||||
|
> **Amended on 2026-04-30** — ADRs were originally located at `decisions/` at the repository root (this ADR was written that way). On 2026-04-30 the folder was relocated to `docs/decisions/` to consolidate documentation under `docs/`. The relocation preserves git history (`git mv`) and the flat-folder + globally-sequential-numbering + tags conventions are unchanged; only the path moved.
|
||||||
|
|
||||||
|
Conventions are defined in [README.md](README.md):
|
||||||
|
|
||||||
|
- flat folder layout (no nested category folders);
|
||||||
|
- globally sequential 4-digit numeric prefix (`NNNN-kebab-title.md`);
|
||||||
|
- categorization via the `tags:` field in the MADR frontmatter, drawn from a canonical vocabulary;
|
||||||
|
- status lifecycle: `proposed` → `accepted` → optionally `deprecated` / `superseded by ADR-NNNN`;
|
||||||
|
- index maintained in [README.md](README.md) alongside any ADR change.
|
||||||
|
|
||||||
|
### Consequences
|
||||||
|
|
||||||
|
- Good, because the _why_ of every architecturally-significant decision is captured at the moment it is made and travels with the code.
|
||||||
|
- Good, because ADRs are reviewed via the normal pull-request flow — architecture becomes an explicit team conversation.
|
||||||
|
- Good, because MADR provides enough structure (drivers, options, trade-offs) without being heavy.
|
||||||
|
- Good, because flat-folder + tags scales better than nested category folders as the count grows.
|
||||||
|
- Bad, because writing an ADR adds friction; this must be balanced against the cost of _not_ writing one.
|
||||||
|
- Bad, because the index is maintained manually until tooling or a CI check is added.
|
||||||
|
|
||||||
|
### Confirmation
|
||||||
|
|
||||||
|
- Every change introducing or transitioning the status of an ADR updates the index table in [README.md](README.md) in the same commit.
|
||||||
|
- PR review explicitly checks for ADR proposal whenever a non-trivial decision is being introduced.
|
||||||
|
- (Future) A CI lint job validates filename pattern, frontmatter shape, and tag vocabulary.
|
||||||
|
|
||||||
|
## More Information
|
||||||
|
|
||||||
|
- MADR 4.0.0 specification: https://github.com/adr/madr
|
||||||
|
- General ADR resources: https://adr.github.io/
|
||||||
|
- Template: [template.md](template.md)
|
||||||
|
- Tag vocabulary: see "Tag vocabulary" in [README.md](README.md).
|
||||||
+36
-35
@@ -15,25 +15,26 @@ How do we structure the repository so both apps coexist with shared libraries, b
|
|||||||
|
|
||||||
## Decision Drivers
|
## Decision Drivers
|
||||||
|
|
||||||
* Two cooperating runtimes (Angular + NestJS), both TypeScript, sharing types and contracts on the wire.
|
- Two cooperating runtimes (Angular + NestJS), both TypeScript, sharing types and contracts on the wire.
|
||||||
* Need for incremental, cached builds and tests on what changed (`affected`-style workflow).
|
- Need for incremental, cached builds and tests on what changed (`affected`-style workflow).
|
||||||
* Long-term enterprise track record — no exotic stack.
|
- Long-term enterprise track record — no exotic stack.
|
||||||
* Compatibility with `pnpm` (already mandated by project setup).
|
- Compatibility with `pnpm` (already mandated by project setup).
|
||||||
* Enforceable dependency boundaries between apps and libs.
|
- Enforceable dependency boundaries between apps and libs.
|
||||||
|
|
||||||
## Considered Options
|
## Considered Options
|
||||||
|
|
||||||
* **Polyrepos** — separate repository per runtime.
|
- **Polyrepos** — separate repository per runtime.
|
||||||
* **Nx with the `apps` preset** — generic multi-tech workspace, plugins added as needed (`@nx/angular`, `@nx/nest`).
|
- **Nx with the `apps` preset** — generic multi-tech workspace, plugins added as needed (`@nx/angular`, `@nx/nest`).
|
||||||
* **Nx with the `angular-monorepo` preset** — front-first preset, BFF grafted on later.
|
- **Nx with the `angular-monorepo` preset** — front-first preset, BFF grafted on later.
|
||||||
* **Turborepo + pnpm workspaces** — lighter alternative to Nx.
|
- **Turborepo + pnpm workspaces** — lighter alternative to Nx.
|
||||||
* **pnpm workspaces alone** — manual orchestration, no monorepo tool.
|
- **pnpm workspaces alone** — manual orchestration, no monorepo tool.
|
||||||
|
|
||||||
## Decision Outcome
|
## Decision Outcome
|
||||||
|
|
||||||
Chosen option: **Nx monorepo with the `apps` preset, managed by pnpm**.
|
Chosen option: **Nx monorepo with the `apps` preset, managed by pnpm**.
|
||||||
|
|
||||||
The workspace will host:
|
The workspace will host:
|
||||||
|
|
||||||
- `apps/portal-shell` — Angular SPA frontend (see [ADR-0004](0004-frontend-stack-angular-csr-zoneless-signals.md));
|
- `apps/portal-shell` — Angular SPA frontend (see [ADR-0004](0004-frontend-stack-angular-csr-zoneless-signals.md));
|
||||||
- `apps/portal-bff` — NestJS BFF backend (see [ADR-0005](0005-backend-stack-nestjs.md));
|
- `apps/portal-bff` — NestJS BFF backend (see [ADR-0005](0005-backend-stack-nestjs.md));
|
||||||
- `libs/shared/<scope>` and `libs/feature/<name>` for shared and feature code (see [ADR-0003](0003-workspace-and-app-naming-convention.md)).
|
- `libs/shared/<scope>` and `libs/feature/<name>` for shared and feature code (see [ADR-0003](0003-workspace-and-app-naming-convention.md)).
|
||||||
@@ -42,51 +43,51 @@ Plugins enabled at bootstrap: `@nx/angular`, `@nx/nest`, `@nx/eslint`, `@nx/vite
|
|||||||
|
|
||||||
### Consequences
|
### Consequences
|
||||||
|
|
||||||
* Good, because front and back share types, lint config, formatter, and test runner with no extra plumbing.
|
- Good, because front and back share types, lint config, formatter, and test runner with no extra plumbing.
|
||||||
* Good, because Nx's project graph + `affected` make CI fast and frugal as the codebase grows.
|
- Good, because Nx's project graph + `affected` make CI fast and frugal as the codebase grows.
|
||||||
* Good, because the `apps` preset stays neutral — neither runtime is privileged in workspace structure.
|
- Good, because the `apps` preset stays neutral — neither runtime is privileged in workspace structure.
|
||||||
* Good, because Nx is mainstream, used by major enterprises, and actively maintained by Nrwl.
|
- Good, because Nx is mainstream, used by major enterprises, and actively maintained by Nrwl.
|
||||||
* Bad, because Nx adds a non-trivial conceptual surface (executors, generators, project graph) — a one-time learning cost.
|
- Bad, because Nx adds a non-trivial conceptual surface (executors, generators, project graph) — a one-time learning cost.
|
||||||
* Bad, because monorepos require discipline to keep dependency boundaries clean — mitigated by Nx's `enforce-module-boundaries` lint rule.
|
- Bad, because monorepos require discipline to keep dependency boundaries clean — mitigated by Nx's `enforce-module-boundaries` lint rule.
|
||||||
|
|
||||||
### Confirmation
|
### Confirmation
|
||||||
|
|
||||||
* Workspace is bootstrapped with `pnpm dlx create-nx-workspace@latest apf-portal --preset=apps --pm=pnpm`.
|
- Workspace is bootstrapped with `pnpm dlx create-nx-workspace@latest apf-portal --preset=apps --pm=pnpm`.
|
||||||
* `nx.json` has `@nx/eslint/enforce-module-boundaries` enabled and configured against the project tags.
|
- `nx.json` has `@nx/eslint/enforce-module-boundaries` enabled and configured against the project tags.
|
||||||
* CI runs `pnpm nx affected -t lint test build` on every push.
|
- CI runs `pnpm nx affected -t lint test build` on every push.
|
||||||
|
|
||||||
## Pros and Cons of the Options
|
## Pros and Cons of the Options
|
||||||
|
|
||||||
### Polyrepos
|
### Polyrepos
|
||||||
|
|
||||||
* Good, because each runtime has independent versioning and CI scope.
|
- Good, because each runtime has independent versioning and CI scope.
|
||||||
* Bad, because sharing TypeScript types across repos requires a published package (overhead, version drift).
|
- Bad, because sharing TypeScript types across repos requires a published package (overhead, version drift).
|
||||||
* Bad, because cross-cutting changes (e.g. adjusting an auth claim shape) touch two repos and two PRs.
|
- Bad, because cross-cutting changes (e.g. adjusting an auth claim shape) touch two repos and two PRs.
|
||||||
|
|
||||||
### Nx with `apps` preset (chosen)
|
### Nx with `apps` preset (chosen)
|
||||||
|
|
||||||
* Good, because runtime-agnostic — Angular and Node coexist as peers.
|
- Good, because runtime-agnostic — Angular and Node coexist as peers.
|
||||||
* Good, because Nx provides build orchestration, caching, generators, and lint boundaries out of the box.
|
- Good, because Nx provides build orchestration, caching, generators, and lint boundaries out of the box.
|
||||||
|
|
||||||
### Nx with `angular-monorepo` preset
|
### Nx with `angular-monorepo` preset
|
||||||
|
|
||||||
* Good, because faster initial setup for a front-first project.
|
- Good, because faster initial setup for a front-first project.
|
||||||
* Bad, because the preset implies the project is "an Angular workspace" — the BFF feels grafted, the workspace `package.json` ends up biased toward Angular tooling.
|
- Bad, because the preset implies the project is "an Angular workspace" — the BFF feels grafted, the workspace `package.json` ends up biased toward Angular tooling.
|
||||||
|
|
||||||
### Turborepo
|
### Turborepo
|
||||||
|
|
||||||
* Good, because lighter, no codegen surface.
|
- Good, because lighter, no codegen surface.
|
||||||
* Neutral, because caching/affected is comparable.
|
- Neutral, because caching/affected is comparable.
|
||||||
* Bad, because no first-class Angular plugin; both runtimes would need manual integration.
|
- Bad, because no first-class Angular plugin; both runtimes would need manual integration.
|
||||||
* Bad, because smaller enterprise track record than Nx.
|
- Bad, because smaller enterprise track record than Nx.
|
||||||
|
|
||||||
### pnpm workspaces alone
|
### pnpm workspaces alone
|
||||||
|
|
||||||
* Good, because no extra tool to learn.
|
- Good, because no extra tool to learn.
|
||||||
* Bad, because no caching, no `affected`, no generators — every piece reinvented by hand (the bricolage we're forbidden).
|
- Bad, because no caching, no `affected`, no generators — every piece reinvented by hand (the bricolage we're forbidden).
|
||||||
|
|
||||||
## More Information
|
## More Information
|
||||||
|
|
||||||
* Nx documentation: https://nx.dev
|
- Nx documentation: https://nx.dev
|
||||||
* Nx `apps` preset: https://nx.dev/getting-started/intro
|
- Nx `apps` preset: https://nx.dev/getting-started/intro
|
||||||
* Related ADRs: [ADR-0003](0003-workspace-and-app-naming-convention.md), [ADR-0004](0004-frontend-stack-angular-csr-zoneless-signals.md), [ADR-0005](0005-backend-stack-nestjs.md), ADR-0017 (CI/CD, future).
|
- Related ADRs: [ADR-0003](0003-workspace-and-app-naming-convention.md), [ADR-0004](0004-frontend-stack-angular-csr-zoneless-signals.md), [ADR-0005](0005-backend-stack-nestjs.md), ADR-0017 (CI/CD, future).
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
---
|
||||||
|
status: accepted
|
||||||
|
date: 2026-04-29
|
||||||
|
decision-makers: R&D Lead
|
||||||
|
tags: [process]
|
||||||
|
---
|
||||||
|
|
||||||
|
# Workspace and app naming convention
|
||||||
|
|
||||||
|
## Context and Problem Statement
|
||||||
|
|
||||||
|
Default Nx scaffolding offers placeholder names like `my-workspace` and `web`, which are unacceptable for a long-lived enterprise project. Naming has to survive two foreseeable changes: the product's marketing name may evolve (the working title `apf_portal` is provisional), and the project may sit alongside other R&D projects in the same Gitea organization, where collisions on generic names like `web` or `api` would cause confusion.
|
||||||
|
|
||||||
|
What naming convention do we adopt for the workspace, the apps, and future libraries so names are explicit, function-anchored, and stable against rebranding?
|
||||||
|
|
||||||
|
## Decision Drivers
|
||||||
|
|
||||||
|
- The product's marketing name is provisional — names anchored on it become stale at rebrand.
|
||||||
|
- Names should reflect _function_, not _brand_.
|
||||||
|
- Multiple R&D projects may share the org — generic names (`web`, `api`, `frontend`) cause friction.
|
||||||
|
- Compliance with npm package naming rules (lowercase, kebab-case).
|
||||||
|
- Readability inside Nx's project graph and CLI output.
|
||||||
|
|
||||||
|
## Considered Options
|
||||||
|
|
||||||
|
- **Defaults** — `my-workspace`, `web`. (Rejected up-front.)
|
||||||
|
- **Brand-anchored** — `apf-front`, `apf-back`. Fragile to further rebranding.
|
||||||
|
- **Function-prefixed** — `portal-shell`, `portal-bff`. (Chosen.)
|
||||||
|
- **Generic** — `shell`, `bff`. Risk of org-wide collision; reduced readability.
|
||||||
|
|
||||||
|
## Decision Outcome
|
||||||
|
|
||||||
|
Chosen option: **function-prefixed naming**.
|
||||||
|
|
||||||
|
| Scope | Name | Rationale |
|
||||||
|
| ----------------------------------- | ---------------- | ----------------------------------------------------------------------------------------------------------- |
|
||||||
|
| Workspace / repo / npm package root | `apf-portal` | matches the Gitea repository name; the only place a brand-ish name lives, so a rebrand is a one-line change |
|
||||||
|
| Frontend app | `portal-shell` | "shell" describes the function — the Angular host that loads features |
|
||||||
|
| Backend app | `portal-bff` | explicit role: backend-for-frontend |
|
||||||
|
| Feature libraries | `feature-<name>` | e.g. `feature-auth`, `feature-billing` |
|
||||||
|
| Shared libraries | `shared-<scope>` | e.g. `shared-ui`, `shared-data-access`, `shared-util` |
|
||||||
|
|
||||||
|
The `portal-` prefix encodes the _function_ (a portal). The host organisation's brand can change without invalidating "portal" — the project was originally proposed as `adastra_portal` and renamed to `apf_portal` on 2026-04-30 once the host organisation (APF France Handicap) was confirmed; the apps `portal-shell` / `portal-bff` were unaffected by the rename, validating this convention. The brand-ish token `apf` is confined to the workspace root and a tiny number of metadata files (root `package.json`, repo URL).
|
||||||
|
|
||||||
|
### Consequences
|
||||||
|
|
||||||
|
- Good, because names survive a brand change with minimal churn.
|
||||||
|
- Good, because `portal-shell` / `portal-bff` are unambiguous in CLI output and in the Nx project graph.
|
||||||
|
- Good, because the prefix scales when more apps appear (e.g. `portal-admin`, `portal-jobs`).
|
||||||
|
- Bad, because the prefix adds verbosity (`pnpm nx serve portal-shell` vs `pnpm nx serve web`). Acceptable.
|
||||||
|
- Neutral, because if "portal" itself ceases to be accurate (e.g. the system pivots to something other than a portal), the prefix would also need updating — but that would be a deeper architectural pivot anyway.
|
||||||
|
|
||||||
|
### Confirmation
|
||||||
|
|
||||||
|
- Nx project names match the convention in `nx.json` and in each project's `project.json`.
|
||||||
|
- `@nx/eslint/enforce-module-boundaries` is configured with tags aligned to the convention (`scope:portal-shell`, `scope:portal-bff`, `type:feature`, `type:shared`).
|
||||||
|
- PR review rejects new project names that don't match.
|
||||||
|
|
||||||
|
## More Information
|
||||||
|
|
||||||
|
- Related ADRs: [ADR-0002](0002-adopt-nx-monorepo-apps-preset.md), and the "Project name" rule in [CLAUDE.md](../../CLAUDE.md).
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
---
|
||||||
|
status: accepted
|
||||||
|
date: 2026-04-29
|
||||||
|
decision-makers: R&D Lead
|
||||||
|
tags: [frontend]
|
||||||
|
---
|
||||||
|
|
||||||
|
# Frontend stack — Angular (latest LTS), standalone, zoneless, Signals, CSR-only, Vitest
|
||||||
|
|
||||||
|
## Context and Problem Statement
|
||||||
|
|
||||||
|
The portal's frontend (`portal-shell`) is a single-page application that aggregates access to existing applications and progressively integrates re-developed features. It sits fully behind authentication: no public content. We need to fix the framework, the rendering mode, the change-detection model, and the test runner now, so subsequent ADRs (auth flow, observability, accessibility, performance budgets) can build on a stable foundation.
|
||||||
|
|
||||||
|
What frontend stack maximizes alignment with our backend (NestJS), gives us first-class accessibility and performance hooks, and is enterprise-stable for a long-lived project?
|
||||||
|
|
||||||
|
## Decision Drivers
|
||||||
|
|
||||||
|
- Architectural alignment with NestJS (DI, decorators, modules, RxJS) — minimizes cognitive distance for the team.
|
||||||
|
- Performance and accessibility as first-class concerns from day one.
|
||||||
|
- Long-term enterprise stability (no pre-1.0, no exotic stack).
|
||||||
|
- End-to-end type safety.
|
||||||
|
- No public surface — all content gated by authentication, which removes the usual SSR drivers (SEO, anonymous LCP).
|
||||||
|
|
||||||
|
## Considered Options
|
||||||
|
|
||||||
|
- **Angular (latest LTS) — standalone, zoneless, Signals, CSR, Vitest.** (Chosen.)
|
||||||
|
- Angular with SSR (`@angular/ssr`).
|
||||||
|
- React + Next.js (or Vite + React Router).
|
||||||
|
- Vue + Nuxt.
|
||||||
|
- Svelte + SvelteKit.
|
||||||
|
|
||||||
|
## Decision Outcome
|
||||||
|
|
||||||
|
Chosen option: **Angular at the latest LTS major**, with the following modern defaults:
|
||||||
|
|
||||||
|
- **Standalone APIs** — no `NgModule`s. Configuration is composed via providers; routing is functional.
|
||||||
|
- **Zoneless change detection** — no `Zone.js`. Change detection is driven by Signals and explicit `markForCheck` where needed.
|
||||||
|
- **Signals** — primary reactive primitive for component state. RxJS retained for async streams (HTTP, events).
|
||||||
|
- **Vitest** — unit and component test runner (Karma is deprecated upstream).
|
||||||
|
- **CSR only — no SSR** for v1. Reconsider only if a measured performance metric requires it.
|
||||||
|
- **SCSS** for styles. Design-token strategy to be defined in a future ADR.
|
||||||
|
- **Strict TypeScript** across the workspace (`strict: true`, `noUncheckedIndexedAccess: true`).
|
||||||
|
|
||||||
|
### Consequences
|
||||||
|
|
||||||
|
- Good, because the team works with a single mental model across front and back (DI, decorators, RxJS).
|
||||||
|
- Good, because zoneless + Signals removes Zone.js overhead and gives finer-grained, more predictable reactivity.
|
||||||
|
- Good, because Vitest is significantly faster than Karma and shares the Vite toolchain Nx already uses.
|
||||||
|
- Good, because CSR keeps the BFF free of SSR concerns (no auth-aware rendering, no double-fetch, no hydration debugging) — simpler operational surface.
|
||||||
|
- Bad, because Angular's release cadence is brisk; the project commits to staying on LTS rather than skipping versions.
|
||||||
|
- Bad, because zoneless and Signals are recent — though stable, the broader Angular ecosystem still includes Zone-dependent libraries that must be vetted before adoption.
|
||||||
|
- Bad, because CSR yields a minimal initial HTML payload; this must be paired with strong performance budgets (covered by ADR-0015, future).
|
||||||
|
|
||||||
|
### Confirmation
|
||||||
|
|
||||||
|
- `package.json` pins Angular to the current LTS major.
|
||||||
|
- `app.config.ts` registers `provideZonelessChangeDetection()` (or its current stable equivalent) and standalone routing.
|
||||||
|
- `tsconfig.base.json` sets `strict: true` and `noUncheckedIndexedAccess: true`.
|
||||||
|
- CI runs `pnpm nx test portal-shell` (Vitest).
|
||||||
|
- No `import 'zone.js'` anywhere in the codebase.
|
||||||
|
- `@angular/ssr` is not installed.
|
||||||
|
|
||||||
|
## Pros and Cons of the Options
|
||||||
|
|
||||||
|
### Angular CSR (chosen)
|
||||||
|
|
||||||
|
- Good, because alignment with NestJS minimizes context-switching for the team.
|
||||||
|
- Good, because mature, enterprise-supported (Google + community).
|
||||||
|
- Good, because Signals + zoneless make Angular's reactivity competitive with React/Vue.
|
||||||
|
- Bad, because verbosity is higher than minimalist frameworks.
|
||||||
|
|
||||||
|
### Angular SSR
|
||||||
|
|
||||||
|
- Good, because better TTFB and accessibility on first paint for unauthenticated content.
|
||||||
|
- Bad, because we have no unauthenticated content — the gain is essentially zero.
|
||||||
|
- Bad, because SSR-with-auth introduces non-trivial complexity (cookie-aware fetch, hydration with secured data).
|
||||||
|
|
||||||
|
### React + Next.js
|
||||||
|
|
||||||
|
- Good, because large ecosystem, fast iteration.
|
||||||
|
- Bad, because no architectural alignment with NestJS — different DI/state paradigms.
|
||||||
|
- Bad, because Next.js's defaults push toward server components and edge rendering, which conflict with our on-prem, all-authenticated profile.
|
||||||
|
|
||||||
|
### Vue + Nuxt / Svelte + SvelteKit
|
||||||
|
|
||||||
|
- Good, because lighter syntax, strong DX.
|
||||||
|
- Bad, because no DI alignment with NestJS.
|
||||||
|
- Bad, because smaller enterprise-grade community than Angular or React.
|
||||||
|
|
||||||
|
## More Information
|
||||||
|
|
||||||
|
- Angular zoneless change detection: https://angular.dev/guide/experimental/zoneless
|
||||||
|
- Angular Signals: https://angular.dev/guide/signals
|
||||||
|
- Vitest with Nx: https://nx.dev/nx-api/vite
|
||||||
|
- Related ADRs: [ADR-0005](0005-backend-stack-nestjs.md), ADR-0008 (auth flow, future), ADR-0014 (accessibility, future), ADR-0015 (performance budgets, future).
|
||||||
+41
-41
@@ -15,19 +15,19 @@ Which Node TypeScript framework gives us the right combination of structure, eco
|
|||||||
|
|
||||||
## Decision Drivers
|
## Decision Drivers
|
||||||
|
|
||||||
* Security is a first-class concern — the framework must impose structure rather than leave it to be hand-built.
|
- Security is a first-class concern — the framework must impose structure rather than leave it to be hand-built.
|
||||||
* Architectural alignment with the Angular frontend (DI, decorators, modules, RxJS).
|
- Architectural alignment with the Angular frontend (DI, decorators, modules, RxJS).
|
||||||
* First-class ecosystem for the things we will need: Passport / MSAL, Pino logging, OpenTelemetry, Prisma, Redis, validation, health checks.
|
- First-class ecosystem for the things we will need: Passport / MSAL, Pino logging, OpenTelemetry, Prisma, Redis, validation, health checks.
|
||||||
* Long-term enterprise viability (no pre-1.0, no single-maintainer projects).
|
- Long-term enterprise viability (no pre-1.0, no single-maintainer projects).
|
||||||
* Performance acceptable for a BFF (request mediation, not raw throughput benchmarks).
|
- Performance acceptable for a BFF (request mediation, not raw throughput benchmarks).
|
||||||
|
|
||||||
## Considered Options
|
## Considered Options
|
||||||
|
|
||||||
* **NestJS** (latest stable major). (Chosen.)
|
- **NestJS** (latest stable major). (Chosen.)
|
||||||
* **Express 5** — minimal, ubiquitous.
|
- **Express 5** — minimal, ubiquitous.
|
||||||
* **Fastify 5** — performance-oriented, plugin-based.
|
- **Fastify 5** — performance-oriented, plugin-based.
|
||||||
* **Hono** — modern, multi-runtime.
|
- **Hono** — modern, multi-runtime.
|
||||||
* **tRPC** — typed RPC over HTTP.
|
- **tRPC** — typed RPC over HTTP.
|
||||||
|
|
||||||
## Decision Outcome
|
## Decision Outcome
|
||||||
|
|
||||||
@@ -35,57 +35,57 @@ Chosen option: **NestJS at the latest stable major**, mounted on the **Express a
|
|||||||
|
|
||||||
### Consequences
|
### Consequences
|
||||||
|
|
||||||
* Good, because NestJS imposes a clear, opinionated architecture (modules, providers, controllers, pipes, guards, interceptors) — minimal room for bricolage.
|
- Good, because NestJS imposes a clear, opinionated architecture (modules, providers, controllers, pipes, guards, interceptors) — minimal room for bricolage.
|
||||||
* Good, because the team lives in the same paradigm as Angular (DI, decorators, RxJS).
|
- Good, because the team lives in the same paradigm as Angular (DI, decorators, RxJS).
|
||||||
* Good, because every supporting concern has a first-class NestJS integration: `@nestjs/passport` + `@azure/msal-node` for auth, `nestjs-pino` for logs, `nestjs-cls` for AsyncLocalStorage, `@nestjs/terminus` for health checks, `nestjs-prisma` for persistence.
|
- Good, because every supporting concern has a first-class NestJS integration: `@nestjs/passport` + `@azure/msal-node` for auth, `nestjs-pino` for logs, `nestjs-cls` for AsyncLocalStorage, `@nestjs/terminus` for health checks, `nestjs-prisma` for persistence.
|
||||||
* Good, because NestJS is at v11+ with a healthy enterprise track record (Adidas, Roche, Capgemini publicly), an active core team, and commercial support options.
|
- Good, because NestJS is at v11+ with a healthy enterprise track record (Adidas, Roche, Capgemini publicly), an active core team, and commercial support options.
|
||||||
* Good, because the adapter pattern lets us swap to Fastify later without touching application code.
|
- Good, because the adapter pattern lets us swap to Fastify later without touching application code.
|
||||||
* Bad, because NestJS has a non-trivial conceptual surface (DI hierarchy, lifecycle hooks, request-scoped providers) — one-time learning cost.
|
- Bad, because NestJS has a non-trivial conceptual surface (DI hierarchy, lifecycle hooks, request-scoped providers) — one-time learning cost.
|
||||||
* Bad, because the framework is heavier than Express or Fastify alone — acceptable for our profile.
|
- Bad, because the framework is heavier than Express or Fastify alone — acceptable for our profile.
|
||||||
|
|
||||||
### Confirmation
|
### Confirmation
|
||||||
|
|
||||||
* `apps/portal-bff/package.json` depends on `@nestjs/core`, `@nestjs/common`, `@nestjs/platform-express`.
|
- `apps/portal-bff/package.json` depends on `@nestjs/core`, `@nestjs/common`, `@nestjs/platform-express`.
|
||||||
* `apps/portal-bff/src/main.ts` bootstraps via `NestFactory.create`.
|
- `apps/portal-bff/src/main.ts` bootstraps via `NestFactory.create`.
|
||||||
* All HTTP entry points are `@Controller`-based; no raw Express routes outside framework escape hatches.
|
- All HTTP entry points are `@Controller`-based; no raw Express routes outside framework escape hatches.
|
||||||
* A global `ValidationPipe` is applied with `whitelist: true, forbidNonWhitelisted: true, transform: true`.
|
- A global `ValidationPipe` is applied with `whitelist: true, forbidNonWhitelisted: true, transform: true`.
|
||||||
|
|
||||||
## Pros and Cons of the Options
|
## Pros and Cons of the Options
|
||||||
|
|
||||||
### NestJS (chosen)
|
### NestJS (chosen)
|
||||||
|
|
||||||
* Good, because opinionated structure sets a clear bar across the team.
|
- Good, because opinionated structure sets a clear bar across the team.
|
||||||
* Good, because alignment with Angular reduces cognitive distance.
|
- Good, because alignment with Angular reduces cognitive distance.
|
||||||
* Good, because the ecosystem covers every BFF concern we have.
|
- Good, because the ecosystem covers every BFF concern we have.
|
||||||
* Bad, because verbose for tiny services — acceptable here, this is a real BFF.
|
- Bad, because verbose for tiny services — acceptable here, this is a real BFF.
|
||||||
|
|
||||||
### Express 5
|
### Express 5
|
||||||
|
|
||||||
* Good, because the lowest common denominator — every Node engineer knows it.
|
- Good, because the lowest common denominator — every Node engineer knows it.
|
||||||
* Bad, because minimalist by design — every cross-cutting concern (DI, validation, structured errors, lifecycle) is built by hand. For a security-sensitive BFF in an "anti-bricolage" project, this is a poor fit.
|
- Bad, because minimalist by design — every cross-cutting concern (DI, validation, structured errors, lifecycle) is built by hand. For a security-sensitive BFF in an "anti-bricolage" project, this is a poor fit.
|
||||||
* Bad, because Express-as-foundation in 2026 is the legacy default; the modern center of gravity has moved.
|
- Bad, because Express-as-foundation in 2026 is the legacy default; the modern center of gravity has moved.
|
||||||
|
|
||||||
### Fastify 5
|
### Fastify 5
|
||||||
|
|
||||||
* Good, because excellent raw performance and a clean plugin model.
|
- Good, because excellent raw performance and a clean plugin model.
|
||||||
* Good, because schema-driven validation is built in.
|
- Good, because schema-driven validation is built in.
|
||||||
* Bad, because architecture is left to the application — we'd reinvent NestJS poorly.
|
- Bad, because architecture is left to the application — we'd reinvent NestJS poorly.
|
||||||
* Note: NestJS can run on a Fastify adapter — we can have both if a perf profile demands it.
|
- Note: NestJS can run on a Fastify adapter — we can have both if a perf profile demands it.
|
||||||
|
|
||||||
### Hono
|
### Hono
|
||||||
|
|
||||||
* Good, because modern, minimal, multi-runtime (Node, Bun, Workers).
|
- Good, because modern, minimal, multi-runtime (Node, Bun, Workers).
|
||||||
* Bad, because too young (~2023) for our longevity bar — limited enterprise track record.
|
- Bad, because too young (~2023) for our longevity bar — limited enterprise track record.
|
||||||
* Status: keep in watch list; reconsider in 12–24 months.
|
- Status: keep in watch list; reconsider in 12–24 months.
|
||||||
|
|
||||||
### tRPC
|
### tRPC
|
||||||
|
|
||||||
* Good, because end-to-end type safety from server to client without code generation.
|
- Good, because end-to-end type safety from server to client without code generation.
|
||||||
* Bad, because it tightly couples frontend and backend builds — incompatible with a BFF that may also need to serve non-Angular consumers later (downstream API gateways, mobile, partner integrations).
|
- Bad, because it tightly couples frontend and backend builds — incompatible with a BFF that may also need to serve non-Angular consumers later (downstream API gateways, mobile, partner integrations).
|
||||||
* Bad, because not a general HTTP framework — orthogonal to the choice we're making.
|
- Bad, because not a general HTTP framework — orthogonal to the choice we're making.
|
||||||
|
|
||||||
## More Information
|
## More Information
|
||||||
|
|
||||||
* NestJS documentation: https://docs.nestjs.com
|
- NestJS documentation: https://docs.nestjs.com
|
||||||
* NestJS Fastify adapter: https://docs.nestjs.com/techniques/performance
|
- NestJS Fastify adapter: https://docs.nestjs.com/techniques/performance
|
||||||
* Related ADRs: [ADR-0002](0002-adopt-nx-monorepo-apps-preset.md), [ADR-0006](0006-persistence-postgresql-prisma.md), ADR-0008 (auth flow, future), ADR-0009 (sessions, future), ADR-0012 (observability, future).
|
- Related ADRs: [ADR-0002](0002-adopt-nx-monorepo-apps-preset.md), [ADR-0006](0006-persistence-postgresql-prisma.md), ADR-0008 (auth flow, future), ADR-0009 (sessions, future), ADR-0012 (observability, future).
|
||||||
+51
-51
@@ -15,28 +15,28 @@ Which database engine and which data-access layer do we adopt?
|
|||||||
|
|
||||||
## Decision Drivers
|
## Decision Drivers
|
||||||
|
|
||||||
* On-prem deployment (no managed-service shortcut).
|
- On-prem deployment (no managed-service shortcut).
|
||||||
* Multi-audience data with strict isolation between workforce and customer scopes (Row-Level Security desirable).
|
- Multi-audience data with strict isolation between workforce and customer scopes (Row-Level Security desirable).
|
||||||
* ACID transactions and a well-understood operational story (HA, backup, point-in-time recovery).
|
- ACID transactions and a well-understood operational story (HA, backup, point-in-time recovery).
|
||||||
* TypeScript-first developer experience for the BFF.
|
- TypeScript-first developer experience for the BFF.
|
||||||
* Long-term licensing safety — open source, no recent license-change risk (e.g. SSPL).
|
- Long-term licensing safety — open source, no recent license-change risk (e.g. SSPL).
|
||||||
|
|
||||||
## Considered Options
|
## Considered Options
|
||||||
|
|
||||||
### Database engine
|
### Database engine
|
||||||
|
|
||||||
* **PostgreSQL** (latest stable major). (Chosen.)
|
- **PostgreSQL** (latest stable major). (Chosen.)
|
||||||
* MariaDB / MySQL.
|
- MariaDB / MySQL.
|
||||||
* SQL Server (would imply Microsoft licensing terms).
|
- SQL Server (would imply Microsoft licensing terms).
|
||||||
* MongoDB / DocumentDB (rejected — not a fit for relational, audit-heavy business data; SSPL concerns).
|
- MongoDB / DocumentDB (rejected — not a fit for relational, audit-heavy business data; SSPL concerns).
|
||||||
|
|
||||||
### Data-access layer
|
### Data-access layer
|
||||||
|
|
||||||
* **Prisma** (latest stable major). (Chosen.)
|
- **Prisma** (latest stable major). (Chosen.)
|
||||||
* Drizzle.
|
- Drizzle.
|
||||||
* Kysely.
|
- Kysely.
|
||||||
* TypeORM.
|
- TypeORM.
|
||||||
* Plain `pg` driver + hand-written SQL.
|
- Plain `pg` driver + hand-written SQL.
|
||||||
|
|
||||||
## Decision Outcome
|
## Decision Outcome
|
||||||
|
|
||||||
@@ -48,72 +48,72 @@ Prisma is wired into NestJS via the `nestjs-prisma` integration, with a dedicate
|
|||||||
|
|
||||||
### Consequences
|
### Consequences
|
||||||
|
|
||||||
* Good, because PostgreSQL is the de facto enterprise open-source RDBMS — mature HA, replication, RLS, JSONB, full-text search.
|
- Good, because PostgreSQL is the de facto enterprise open-source RDBMS — mature HA, replication, RLS, JSONB, full-text search.
|
||||||
* Good, because RLS gives a strong substrate for the workforce/customer isolation called for by ADR-0007 (identity model, future).
|
- Good, because RLS gives a strong substrate for the workforce/customer isolation called for by ADR-0007 (identity model, future).
|
||||||
* Good, because Prisma's schema-first model is a single source of truth, with type generation and a readable migration log.
|
- Good, because Prisma's schema-first model is a single source of truth, with type generation and a readable migration log.
|
||||||
* Good, because Prisma's NestJS integration is well-trodden territory.
|
- Good, because Prisma's NestJS integration is well-trodden territory.
|
||||||
* Bad, because Prisma adds a generated client and a query engine binary — operational surface to ship and version.
|
- Bad, because Prisma adds a generated client and a query engine binary — operational surface to ship and version.
|
||||||
* Bad, because Prisma's escape hatch for advanced PostgreSQL features (advanced CTEs, window functions, RLS policies) is `$queryRaw` — usable, but a reminder that not everything goes through the ORM. We will not fight Prisma when raw SQL is the right tool.
|
- Bad, because Prisma's escape hatch for advanced PostgreSQL features (advanced CTEs, window functions, RLS policies) is `$queryRaw` — usable, but a reminder that not everything goes through the ORM. We will not fight Prisma when raw SQL is the right tool.
|
||||||
* Neutral, because Drizzle is a credible "à la pointe" alternative; we accept the more conservative choice for now and re-evaluate at the next major architectural review.
|
- Neutral, because Drizzle is a credible "à la pointe" alternative; we accept the more conservative choice for now and re-evaluate at the next major architectural review.
|
||||||
|
|
||||||
### Confirmation
|
### Confirmation
|
||||||
|
|
||||||
* `apps/portal-bff` depends on `prisma` and `@prisma/client`.
|
- `apps/portal-bff` depends on `prisma` and `@prisma/client`.
|
||||||
* A single `schema.prisma` lives at `apps/portal-bff/prisma/schema.prisma`.
|
- A single `schema.prisma` lives at `apps/portal-bff/prisma/schema.prisma`.
|
||||||
* `nestjs-prisma`'s `PrismaModule` is imported globally in the BFF.
|
- `nestjs-prisma`'s `PrismaModule` is imported globally in the BFF.
|
||||||
* CI runs `prisma validate` and `prisma migrate diff` against the staging schema.
|
- CI runs `prisma validate` and `prisma migrate diff` against the staging schema.
|
||||||
* Database engine version is pinned in deployment manifests.
|
- Database engine version is pinned in deployment manifests.
|
||||||
|
|
||||||
## Pros and Cons of the Options
|
## Pros and Cons of the Options
|
||||||
|
|
||||||
### PostgreSQL (chosen)
|
### PostgreSQL (chosen)
|
||||||
|
|
||||||
* Good, because mature, open source under the PostgreSQL License (permissive), broad on-prem operational tooling.
|
- Good, because mature, open source under the PostgreSQL License (permissive), broad on-prem operational tooling.
|
||||||
* Good, because RLS is first-class — directly serves multi-audience isolation.
|
- Good, because RLS is first-class — directly serves multi-audience isolation.
|
||||||
* Good, because JSONB + relational hybrid suits BFF data shapes.
|
- Good, because JSONB + relational hybrid suits BFF data shapes.
|
||||||
* Bad, because operational complexity (HA, backups, tuning) must be owned — true of any on-prem RDBMS.
|
- Bad, because operational complexity (HA, backups, tuning) must be owned — true of any on-prem RDBMS.
|
||||||
|
|
||||||
### MariaDB / MySQL
|
### MariaDB / MySQL
|
||||||
|
|
||||||
* Good, because operationally well-known.
|
- Good, because operationally well-known.
|
||||||
* Bad, because feature gap vs. PostgreSQL (RLS, JSON ergonomics, CTE maturity, generated columns).
|
- Bad, because feature gap vs. PostgreSQL (RLS, JSON ergonomics, CTE maturity, generated columns).
|
||||||
|
|
||||||
### MongoDB
|
### MongoDB
|
||||||
|
|
||||||
* Good, because the document model fits some BFF caches.
|
- Good, because the document model fits some BFF caches.
|
||||||
* Bad, because business data here is relational; forcing a document store would push joins into the application — bricolage.
|
- Bad, because business data here is relational; forcing a document store would push joins into the application — bricolage.
|
||||||
* Bad, because the SSPL license raises enterprise legal complications.
|
- Bad, because the SSPL license raises enterprise legal complications.
|
||||||
|
|
||||||
### Prisma (chosen)
|
### Prisma (chosen)
|
||||||
|
|
||||||
* Good, because schema-first declarative, strong DX, type generation, mainstream in 2026.
|
- Good, because schema-first declarative, strong DX, type generation, mainstream in 2026.
|
||||||
* Good, because a mature NestJS integration exists.
|
- Good, because a mature NestJS integration exists.
|
||||||
* Bad, because the query engine binary adds a deployment artifact.
|
- Bad, because the query engine binary adds a deployment artifact.
|
||||||
* Bad, because some PostgreSQL features require `$queryRaw`.
|
- Bad, because some PostgreSQL features require `$queryRaw`.
|
||||||
|
|
||||||
### Drizzle
|
### Drizzle
|
||||||
|
|
||||||
* Good, because lighter, closer to SQL, faster, type-safe.
|
- Good, because lighter, closer to SQL, faster, type-safe.
|
||||||
* Bad, because younger (2022) — smaller enterprise track record than Prisma. Kept on the watch list for re-evaluation.
|
- Bad, because younger (2022) — smaller enterprise track record than Prisma. Kept on the watch list for re-evaluation.
|
||||||
|
|
||||||
### Kysely
|
### Kysely
|
||||||
|
|
||||||
* Good, because elegant query-builder, no ORM magic, fully type-safe.
|
- Good, because elegant query-builder, no ORM magic, fully type-safe.
|
||||||
* Bad, because no schema/migrations of its own — must be paired with another tool, increasing surface area.
|
- Bad, because no schema/migrations of its own — must be paired with another tool, increasing surface area.
|
||||||
|
|
||||||
### TypeORM
|
### TypeORM
|
||||||
|
|
||||||
* Good, because long-standing default in NestJS history.
|
- Good, because long-standing default in NestJS history.
|
||||||
* Bad, because design issues (decorator hell, runtime metadata fragility, migration story) and slowing maintenance — community momentum has shifted to Prisma and Drizzle.
|
- Bad, because design issues (decorator hell, runtime metadata fragility, migration story) and slowing maintenance — community momentum has shifted to Prisma and Drizzle.
|
||||||
|
|
||||||
### Plain `pg` driver + hand-written SQL
|
### Plain `pg` driver + hand-written SQL
|
||||||
|
|
||||||
* Good, because zero abstraction; full control.
|
- Good, because zero abstraction; full control.
|
||||||
* Bad, because every cross-cutting concern (transactions, connection pooling, type mapping, migrations) is hand-built — the very bricolage we are forbidden.
|
- Bad, because every cross-cutting concern (transactions, connection pooling, type mapping, migrations) is hand-built — the very bricolage we are forbidden.
|
||||||
|
|
||||||
## More Information
|
## More Information
|
||||||
|
|
||||||
* PostgreSQL: https://www.postgresql.org/
|
- PostgreSQL: https://www.postgresql.org/
|
||||||
* Prisma: https://www.prisma.io/
|
- Prisma: https://www.prisma.io/
|
||||||
* `nestjs-prisma`: https://nestjs-prisma.dev/
|
- `nestjs-prisma`: https://nestjs-prisma.dev/
|
||||||
* Related ADRs: [ADR-0005](0005-backend-stack-nestjs.md), ADR-0007 (identity model + RLS use, future), ADR-0009 (sessions in Redis, future), future infrastructure ADRs (Postgres HA, backup).
|
- Related ADRs: [ADR-0005](0005-backend-stack-nestjs.md), ADR-0007 (identity model + RLS use, future), ADR-0009 (sessions in Redis, future), future infrastructure ADRs (Postgres HA, backup).
|
||||||
+43
-42
@@ -15,19 +15,19 @@ Which tooling do we adopt for git pre-commit checks and commit message validatio
|
|||||||
|
|
||||||
## Decision Drivers
|
## Decision Drivers
|
||||||
|
|
||||||
* Fast feedback: catch trivial issues locally; CI is defense in depth, not the first line.
|
- Fast feedback: catch trivial issues locally; CI is defense in depth, not the first line.
|
||||||
* Consistency of code style and commit history across contributors.
|
- Consistency of code style and commit history across contributors.
|
||||||
* Conventional commit history that machines can read (later automation: changelog, release notes, semver bumps).
|
- Conventional commit history that machines can read (later automation: changelog, release notes, semver bumps).
|
||||||
* Mainstream tooling — no exotic shell hooks, no single-maintainer projects.
|
- Mainstream tooling — no exotic shell hooks, no single-maintainer projects.
|
||||||
* Easy onboarding: no extra runtime to manage beyond Node/pnpm.
|
- Easy onboarding: no extra runtime to manage beyond Node/pnpm.
|
||||||
|
|
||||||
## Considered Options
|
## Considered Options
|
||||||
|
|
||||||
* **Husky + lint-staged + commitlint with Conventional Commits.** (Chosen.)
|
- **Husky + lint-staged + commitlint with Conventional Commits.** (Chosen.)
|
||||||
* No git hooks — rely on CI.
|
- No git hooks — rely on CI.
|
||||||
* `pre-commit` (Python framework).
|
- `pre-commit` (Python framework).
|
||||||
* `lefthook` (Go single-binary hook manager).
|
- `lefthook` (Go single-binary hook manager).
|
||||||
* Custom shell scripts in `.git/hooks/`.
|
- Custom shell scripts in `.git/hooks/`.
|
||||||
|
|
||||||
## Decision Outcome
|
## Decision Outcome
|
||||||
|
|
||||||
@@ -36,11 +36,12 @@ Chosen option: **Husky + lint-staged + commitlint, with [Conventional Commits](h
|
|||||||
Hooks installed under `.husky/`:
|
Hooks installed under `.husky/`:
|
||||||
|
|
||||||
| Hook | Action |
|
| Hook | Action |
|
||||||
| --- | --- |
|
| ------------ | ---------------------------------------------------------------------------------------------- |
|
||||||
| `pre-commit` | `pnpm exec lint-staged` — runs lint and format on staged files only |
|
| `pre-commit` | `pnpm exec lint-staged` — runs lint and format on staged files only |
|
||||||
| `commit-msg` | `pnpm exec commitlint --edit "$1"` — validates the commit message against Conventional Commits |
|
| `commit-msg` | `pnpm exec commitlint --edit "$1"` — validates the commit message against Conventional Commits |
|
||||||
|
|
||||||
Configuration:
|
Configuration:
|
||||||
|
|
||||||
- `package.json`: `"prepare": "husky"` script (Husky 9+ pattern), `lint-staged` config block.
|
- `package.json`: `"prepare": "husky"` script (Husky 9+ pattern), `lint-staged` config block.
|
||||||
- `commitlint.config.cjs`: `module.exports = { extends: ['@commitlint/config-conventional'] }`.
|
- `commitlint.config.cjs`: `module.exports = { extends: ['@commitlint/config-conventional'] }`.
|
||||||
|
|
||||||
@@ -50,57 +51,57 @@ CI re-runs the same checks (defense in depth — hooks can be bypassed with `--n
|
|||||||
|
|
||||||
### Consequences
|
### Consequences
|
||||||
|
|
||||||
* Good, because trivial issues are caught locally and don't pollute PR history.
|
- Good, because trivial issues are caught locally and don't pollute PR history.
|
||||||
* Good, because Conventional Commits gives us a machine-readable history — enables automated changelogs, semver inference, and release tooling without retroactive cleanup.
|
- Good, because Conventional Commits gives us a machine-readable history — enables automated changelogs, semver inference, and release tooling without retroactive cleanup.
|
||||||
* Good, because Husky / lint-staged / commitlint are the de facto standard in JS/TS projects — wide community, abundant documentation, low surprise factor.
|
- Good, because Husky / lint-staged / commitlint are the de facto standard in JS/TS projects — wide community, abundant documentation, low surprise factor.
|
||||||
* Good, because lint-staged keeps commit time low (only changed files are checked).
|
- Good, because lint-staged keeps commit time low (only changed files are checked).
|
||||||
* Bad, because hooks can be bypassed (`git commit --no-verify`); CI must remain authoritative.
|
- Bad, because hooks can be bypassed (`git commit --no-verify`); CI must remain authoritative.
|
||||||
* Bad, because Husky 9 changed its installation pattern (no `husky install`, just `husky`); contributors with stale instructions can be confused. Mitigated by the setup guide.
|
- Bad, because Husky 9 changed its installation pattern (no `husky install`, just `husky`); contributors with stale instructions can be confused. Mitigated by the setup guide.
|
||||||
* Bad, because Conventional Commits adds a small learning curve; mitigated by IDE plugins, `commitlint` error messages, and a one-page contributor cheatsheet (future doc).
|
- Bad, because Conventional Commits adds a small learning curve; mitigated by IDE plugins, `commitlint` error messages, and a one-page contributor cheatsheet (future doc).
|
||||||
|
|
||||||
### Confirmation
|
### Confirmation
|
||||||
|
|
||||||
* `package.json` declares `husky`, `lint-staged`, `@commitlint/cli`, `@commitlint/config-conventional` as `devDependencies`.
|
- `package.json` declares `husky`, `lint-staged`, `@commitlint/cli`, `@commitlint/config-conventional` as `devDependencies`.
|
||||||
* `package.json` has a `"prepare": "husky"` script.
|
- `package.json` has a `"prepare": "husky"` script.
|
||||||
* `.husky/pre-commit` runs `pnpm exec lint-staged` and is executable.
|
- `.husky/pre-commit` runs `pnpm exec lint-staged` and is executable.
|
||||||
* `.husky/commit-msg` runs `pnpm exec commitlint --edit "$1"` and is executable.
|
- `.husky/commit-msg` runs `pnpm exec commitlint --edit "$1"` and is executable.
|
||||||
* `commitlint.config.cjs` exists at the workspace root and extends `@commitlint/config-conventional`.
|
- `commitlint.config.cjs` exists at the workspace root and extends `@commitlint/config-conventional`.
|
||||||
* CI pipeline (future ADR) re-runs lint and format checks on every push, plus a Conventional Commits validation on the PR commit range.
|
- CI pipeline (future ADR) re-runs lint and format checks on every push, plus a Conventional Commits validation on the PR commit range.
|
||||||
|
|
||||||
## Pros and Cons of the Options
|
## Pros and Cons of the Options
|
||||||
|
|
||||||
### Husky + lint-staged + commitlint (chosen)
|
### Husky + lint-staged + commitlint (chosen)
|
||||||
|
|
||||||
* Good, because the trio is the de facto standard in the JS/TS ecosystem.
|
- Good, because the trio is the de facto standard in the JS/TS ecosystem.
|
||||||
* Good, because each tool is single-purpose and composable.
|
- Good, because each tool is single-purpose and composable.
|
||||||
* Good, because the configuration lives with the repo and is versioned with the code.
|
- Good, because the configuration lives with the repo and is versioned with the code.
|
||||||
* Bad, because three packages instead of one — slightly more dependency surface.
|
- Bad, because three packages instead of one — slightly more dependency surface.
|
||||||
|
|
||||||
### No git hooks
|
### No git hooks
|
||||||
|
|
||||||
* Good, because zero local setup.
|
- Good, because zero local setup.
|
||||||
* Bad, because every trivial issue costs a CI round-trip. Wasteful and noisy.
|
- Bad, because every trivial issue costs a CI round-trip. Wasteful and noisy.
|
||||||
|
|
||||||
### `pre-commit` (Python)
|
### `pre-commit` (Python)
|
||||||
|
|
||||||
* Good, because language-agnostic, used in mixed-language repos (Python + JS + Go).
|
- Good, because language-agnostic, used in mixed-language repos (Python + JS + Go).
|
||||||
* Bad, because adds a Python runtime dependency to a Node-only project — extra setup for contributors, especially under WSL where Python toolchains are not always uniform.
|
- Bad, because adds a Python runtime dependency to a Node-only project — extra setup for contributors, especially under WSL where Python toolchains are not always uniform.
|
||||||
|
|
||||||
### lefthook
|
### lefthook
|
||||||
|
|
||||||
* Good, because single Go binary, very fast, parallel hook execution, declarative YAML config.
|
- Good, because single Go binary, very fast, parallel hook execution, declarative YAML config.
|
||||||
* Bad, because smaller community than Husky in JS/TS land; less prior art and fewer answers when something breaks.
|
- Bad, because smaller community than Husky in JS/TS land; less prior art and fewer answers when something breaks.
|
||||||
* Status: kept on the watch list for re-evaluation if commit performance becomes a pain.
|
- Status: kept on the watch list for re-evaluation if commit performance becomes a pain.
|
||||||
|
|
||||||
### Custom shell scripts in `.git/hooks/`
|
### Custom shell scripts in `.git/hooks/`
|
||||||
|
|
||||||
* Good, because zero dependency.
|
- Good, because zero dependency.
|
||||||
* Bad, because hooks aren't versioned with the repo by default; a wrapper layer is needed anyway. Bricolage.
|
- Bad, because hooks aren't versioned with the repo by default; a wrapper layer is needed anyway. Bricolage.
|
||||||
|
|
||||||
## More Information
|
## More Information
|
||||||
|
|
||||||
* Husky: https://typicode.github.io/husky/
|
- Husky: https://typicode.github.io/husky/
|
||||||
* lint-staged: https://github.com/lint-staged/lint-staged
|
- lint-staged: https://github.com/lint-staged/lint-staged
|
||||||
* commitlint: https://commitlint.js.org/
|
- commitlint: https://commitlint.js.org/
|
||||||
* Conventional Commits: https://www.conventionalcommits.org/
|
- Conventional Commits: https://www.conventionalcommits.org/
|
||||||
* Related: future quality / CI ADR will re-run these checks as defense in depth.
|
- Related: future quality / CI ADR will re-run these checks as defense in depth.
|
||||||
+60
-59
@@ -14,18 +14,18 @@ The portal exposes no public content — every interaction requires authenticati
|
|||||||
- **Workforce.** Employees of the host organization, plus employees of partner organizations who must access the portal.
|
- **Workforce.** Employees of the host organization, plus employees of partner organizations who must access the portal.
|
||||||
- **Customers.** External end-users (clients) without a corporate IdP — covered by Microsoft Entra External ID (the CIAM product, formerly Azure AD B2C).
|
- **Customers.** External end-users (clients) without a corporate IdP — covered by Microsoft Entra External ID (the CIAM product, formerly Azure AD B2C).
|
||||||
|
|
||||||
Only workforce is needed for v1. Customers are a probable future need with no current users on the horizon. We must therefore decide three intertwined questions: which identity provider, which tenant model, and how much of the customer-side architecture to put in place *before* there are customers.
|
Only workforce is needed for v1. Customers are a probable future need with no current users on the horizon. We must therefore decide three intertwined questions: which identity provider, which tenant model, and how much of the customer-side architecture to put in place _before_ there are customers.
|
||||||
|
|
||||||
A separate complication: the host organization has no Microsoft 365 or Azure subscription yet. The production Entra tenant has to be provisioned, which is org-level IT work that must not block development.
|
A separate complication: the host organization has no Microsoft 365 or Azure subscription yet. The production Entra tenant has to be provisioned, which is org-level IT work that must not block development.
|
||||||
|
|
||||||
## Decision Drivers
|
## Decision Drivers
|
||||||
|
|
||||||
* Authentication is security-critical — the technical bar is "no bricolage", not "good enough".
|
- Authentication is security-critical — the technical bar is "no bricolage", not "good enough".
|
||||||
* Workforce identity belongs in a managed, enterprise-grade IdP — the org's chosen direction is Microsoft Entra.
|
- Workforce identity belongs in a managed, enterprise-grade IdP — the org's chosen direction is Microsoft Entra.
|
||||||
* Customer (CIAM) infrastructure has a real cost (separate tenant provisioning, branding, IDPs, self-service signup, conditional access policies). Don't pay it before there are customers.
|
- Customer (CIAM) infrastructure has a real cost (separate tenant provisioning, branding, IDPs, self-service signup, conditional access policies). Don't pay it before there are customers.
|
||||||
* Adding the customer audience later must not require a security-audit refactor of authorization, sessions, or data — the structural cost of being audience-aware is much lower up-front than retrofitted.
|
- Adding the customer audience later must not require a security-audit refactor of authorization, sessions, or data — the structural cost of being audience-aware is much lower up-front than retrofitted.
|
||||||
* Development must not be blocked on org-level provisioning of the production tenant.
|
- Development must not be blocked on org-level provisioning of the production tenant.
|
||||||
* Authorization must be claims-based and audience-aware from day one — the implicit assumption "authenticated == internal employee" is exactly the failure mode that turns into a privilege-escalation incident the day external users are introduced.
|
- Authorization must be claims-based and audience-aware from day one — the implicit assumption "authenticated == internal employee" is exactly the failure mode that turns into a privilege-escalation incident the day external users are introduced.
|
||||||
|
|
||||||
## Considered Options
|
## Considered Options
|
||||||
|
|
||||||
@@ -33,21 +33,21 @@ Three independent axes, decided together because they constrain each other:
|
|||||||
|
|
||||||
### Tenant model (production)
|
### Tenant model (production)
|
||||||
|
|
||||||
* **Single-tenant strict** — only employees of our own Entra tenant can sign in.
|
- **Single-tenant strict** — only employees of our own Entra tenant can sign in.
|
||||||
* **Multi-tenant with B2B invitation.** (Chosen.) Employees of partner organizations are explicitly invited as B2B guests in our tenant; they appear in our directory and are subject to our Conditional Access policies.
|
- **Multi-tenant with B2B invitation.** (Chosen.) Employees of partner organizations are explicitly invited as B2B guests in our tenant; they appear in our directory and are subject to our Conditional Access policies.
|
||||||
* **Multi-tenant open** — any user from any Entra tenant can sign in; tenant allowlisting handled in the application.
|
- **Multi-tenant open** — any user from any Entra tenant can sign in; tenant allowlisting handled in the application.
|
||||||
* **Workforce + Entra External ID from day one** — full dual-IdP architecture provisioned now.
|
- **Workforce + Entra External ID from day one** — full dual-IdP architecture provisioned now.
|
||||||
|
|
||||||
### Code and data architecture
|
### Code and data architecture
|
||||||
|
|
||||||
* **A. Audience-aware design from v1, workforce-only implementation.** (Chosen.) The session, claims, DB schemas, RLS policies, and authorization layer all carry an `audience` value. In v1 the only valid value is `'workforce'`; `'customer'` exists in the type system and code paths but is gated off (no External ID authority registered). When External ID is later activated, no schema migration or authz refactor is needed.
|
- **A. Audience-aware design from v1, workforce-only implementation.** (Chosen.) The session, claims, DB schemas, RLS policies, and authorization layer all carry an `audience` value. In v1 the only valid value is `'workforce'`; `'customer'` exists in the type system and code paths but is gated off (no External ID authority registered). When External ID is later activated, no schema migration or authz refactor is needed.
|
||||||
* **B. No audience-awareness — build for workforce only, refactor later.**
|
- **B. No audience-awareness — build for workforce only, refactor later.**
|
||||||
|
|
||||||
### Development tenant (while prod is being provisioned)
|
### Development tenant (while prod is being provisioned)
|
||||||
|
|
||||||
* Wait for the prod tenant — accept the dev block.
|
- Wait for the prod tenant — accept the dev block.
|
||||||
* **Microsoft 365 Developer Program tenant.** (Chosen.) Free, full-featured, renewable every 3 months.
|
- **Microsoft 365 Developer Program tenant.** (Chosen.) Free, full-featured, renewable every 3 months.
|
||||||
* Self-hosted IdP for dev (e.g. Keycloak) — explicitly rejected as bricolage and as a divergence from the prod code path.
|
- Self-hosted IdP for dev (e.g. Keycloak) — explicitly rejected as bricolage and as a divergence from the prod code path.
|
||||||
|
|
||||||
## Decision Outcome
|
## Decision Outcome
|
||||||
|
|
||||||
@@ -66,6 +66,7 @@ Three independent axes, decided together because they constrain each other:
|
|||||||
**Development environment:** a **Microsoft 365 Developer Program tenant** is provisioned and used for all non-prod environments until the prod tenant is delivered. The same multi-tenant + B2B configuration is mirrored. MSAL Node config is environment-driven — `ENTRA_TENANT_ID`, `ENTRA_CLIENT_ID`, `ENTRA_CLIENT_SECRET` (or certificate), and the tenant allowlist are all read from env, never hardcoded.
|
**Development environment:** a **Microsoft 365 Developer Program tenant** is provisioned and used for all non-prod environments until the prod tenant is delivered. The same multi-tenant + B2B configuration is mirrored. MSAL Node config is environment-driven — `ENTRA_TENANT_ID`, `ENTRA_CLIENT_ID`, `ENTRA_CLIENT_SECRET` (or certificate), and the tenant allowlist are all read from env, never hardcoded.
|
||||||
|
|
||||||
**Future External ID activation** is explicitly out of scope of v1 but in scope of the design. When it is activated:
|
**Future External ID activation** is explicitly out of scope of v1 but in scope of the design. When it is activated:
|
||||||
|
|
||||||
- A second tenant (Entra External ID) is provisioned — separate product, separate tenant, separate issuer.
|
- A second tenant (Entra External ID) is provisioned — separate product, separate tenant, separate issuer.
|
||||||
- A second authority is registered with MSAL Node.
|
- A second authority is registered with MSAL Node.
|
||||||
- The session sets `audience: 'customer'` for users authenticated via that authority.
|
- The session sets `audience: 'customer'` for users authenticated via that authority.
|
||||||
@@ -74,17 +75,17 @@ Three independent axes, decided together because they constrain each other:
|
|||||||
|
|
||||||
### Consequences
|
### Consequences
|
||||||
|
|
||||||
* Good, because workforce gets the full Entra ID enterprise feature set: Conditional Access, MFA, sign-in risk, B2B governance, audit logs.
|
- Good, because workforce gets the full Entra ID enterprise feature set: Conditional Access, MFA, sign-in risk, B2B governance, audit logs.
|
||||||
* Good, because B2B invitation gives controlled access to partner-org employees — they appear in our directory and are subject to our policies, not theirs.
|
- Good, because B2B invitation gives controlled access to partner-org employees — they appear in our directory and are subject to our policies, not theirs.
|
||||||
* Good, because deferring External ID saves multiple weeks of CIAM provisioning, branding, and policy work that would deliver no v1 value.
|
- Good, because deferring External ID saves multiple weeks of CIAM provisioning, branding, and policy work that would deliver no v1 value.
|
||||||
* Good, because the dual-audience design costs almost nothing up-front (a column per relevant table, a claim in the session, a discipline in authorization) and prevents an expensive retrofit + security audit later.
|
- Good, because the dual-audience design costs almost nothing up-front (a column per relevant table, a claim in the session, a discipline in authorization) and prevents an expensive retrofit + security audit later.
|
||||||
* Good, because the dev tenant strategy unblocks development while prod provisioning happens at IT pace.
|
- Good, because the dev tenant strategy unblocks development while prod provisioning happens at IT pace.
|
||||||
* Good, because future External ID activation is a switch-flip — provision the second tenant, register a second authority, ship — not a re-architecture.
|
- Good, because future External ID activation is a switch-flip — provision the second tenant, register a second authority, ship — not a re-architecture.
|
||||||
* Bad, because the organization needs to acquire **Entra ID P1 licensing** for production — a business decision with a real cost. Mitigation: documented up-front so it can be planned, not surprised.
|
- Bad, because the organization needs to acquire **Entra ID P1 licensing** for production — a business decision with a real cost. Mitigation: documented up-front so it can be planned, not surprised.
|
||||||
* Bad, because Microsoft 365 Developer Program tenants must be **renewed every ~3 months** (Microsoft's policy). Mitigation: a recurring task on the IT side; data in dev is non-sensitive by definition.
|
- Bad, because Microsoft 365 Developer Program tenants must be **renewed every ~3 months** (Microsoft's policy). Mitigation: a recurring task on the IT side; data in dev is non-sensitive by definition.
|
||||||
* Bad, because dual-audience discipline must be applied consistently — every new table, query, and authz check must respect it. Mitigation: helpers, code review, and a future CI lint rule that flags untagged tables.
|
- Bad, because dual-audience discipline must be applied consistently — every new table, query, and authz check must respect it. Mitigation: helpers, code review, and a future CI lint rule that flags untagged tables.
|
||||||
* Bad, because multi-tenant token validation requires maintaining a tenant allowlist — operational item, not a one-shot. Mitigation: list maintained in config (versioned), reviewed during partner onboarding.
|
- Bad, because multi-tenant token validation requires maintaining a tenant allowlist — operational item, not a one-shot. Mitigation: list maintained in config (versioned), reviewed during partner onboarding.
|
||||||
* Bad, because there is no formal v1 user for the `'customer'` code path — meaning it is not exercised in production traffic. Mitigation: dedicated automated tests with a synthetic customer principal so the path stays alive and correct.
|
- Bad, because there is no formal v1 user for the `'customer'` code path — meaning it is not exercised in production traffic. Mitigation: dedicated automated tests with a synthetic customer principal so the path stays alive and correct.
|
||||||
|
|
||||||
### Confirmation
|
### Confirmation
|
||||||
|
|
||||||
@@ -103,66 +104,66 @@ Three independent axes, decided together because they constrain each other:
|
|||||||
|
|
||||||
#### Single-tenant strict
|
#### Single-tenant strict
|
||||||
|
|
||||||
* Good, because simplest issuer validation (one accepted issuer).
|
- Good, because simplest issuer validation (one accepted issuer).
|
||||||
* Good, because users are all in our directory, fully under our policies.
|
- Good, because users are all in our directory, fully under our policies.
|
||||||
* Bad, because partner-org employees can't sign in unless we provision them as guest accounts (overhead) or assign them ours (security risk).
|
- Bad, because partner-org employees can't sign in unless we provision them as guest accounts (overhead) or assign them ours (security risk).
|
||||||
|
|
||||||
#### Multi-tenant with B2B invitation (chosen)
|
#### Multi-tenant with B2B invitation (chosen)
|
||||||
|
|
||||||
* Good, because partner-org employees use their own Entra credentials but are visible in our directory and subject to our Conditional Access.
|
- Good, because partner-org employees use their own Entra credentials but are visible in our directory and subject to our Conditional Access.
|
||||||
* Good, because B2B governance (access reviews, expiry) is built-in.
|
- Good, because B2B governance (access reviews, expiry) is built-in.
|
||||||
* Good, because Microsoft's recommended pattern for inter-org workforce access.
|
- Good, because Microsoft's recommended pattern for inter-org workforce access.
|
||||||
* Bad, because requires invitation flow per guest user — operational item.
|
- Bad, because requires invitation flow per guest user — operational item.
|
||||||
|
|
||||||
#### Multi-tenant open (no invitation)
|
#### Multi-tenant open (no invitation)
|
||||||
|
|
||||||
* Good, because lowest friction for any workforce user from any Entra tenant.
|
- Good, because lowest friction for any workforce user from any Entra tenant.
|
||||||
* Bad, because no control over who signs in unless an application-level allowlist is implemented and maintained.
|
- Bad, because no control over who signs in unless an application-level allowlist is implemented and maintained.
|
||||||
* Bad, because guests aren't in our directory — limited governance, no Conditional Access from our side.
|
- Bad, because guests aren't in our directory — limited governance, no Conditional Access from our side.
|
||||||
|
|
||||||
#### Workforce + External ID from day one
|
#### Workforce + External ID from day one
|
||||||
|
|
||||||
* Good, because the full dual-audience capability is live.
|
- Good, because the full dual-audience capability is live.
|
||||||
* Bad, because the org has neither M365/Azure nor any customer user identified — provisioning a CIAM tenant now has zero v1 yield and significant ongoing cost.
|
- Bad, because the org has neither M365/Azure nor any customer user identified — provisioning a CIAM tenant now has zero v1 yield and significant ongoing cost.
|
||||||
|
|
||||||
### Code and data architecture
|
### Code and data architecture
|
||||||
|
|
||||||
#### A. Audience-aware design from v1 (chosen)
|
#### A. Audience-aware design from v1 (chosen)
|
||||||
|
|
||||||
* Good, because the cost is small upfront — one column per shared table, one claim in the session, a discipline in authz.
|
- Good, because the cost is small upfront — one column per shared table, one claim in the session, a discipline in authz.
|
||||||
* Good, because adding External ID later is a configuration change, not a refactor.
|
- Good, because adding External ID later is a configuration change, not a refactor.
|
||||||
* Good, because authorization tests can already exercise both audiences.
|
- Good, because authorization tests can already exercise both audiences.
|
||||||
* Bad, because a discipline that must be respected on every new table — mitigated by lint and review.
|
- Bad, because a discipline that must be respected on every new table — mitigated by lint and review.
|
||||||
|
|
||||||
#### B. No audience-awareness — workforce only, refactor later
|
#### B. No audience-awareness — workforce only, refactor later
|
||||||
|
|
||||||
* Good, because the v1 codebase is slightly simpler.
|
- Good, because the v1 codebase is slightly simpler.
|
||||||
* Bad, because retrofitting audience-awareness after the fact requires schema migration on every relevant table, refactoring of every authorization check, and a full data-leak audit. The cost is asymmetrically larger than option A.
|
- Bad, because retrofitting audience-awareness after the fact requires schema migration on every relevant table, refactoring of every authorization check, and a full data-leak audit. The cost is asymmetrically larger than option A.
|
||||||
|
|
||||||
### Development tenant
|
### Development tenant
|
||||||
|
|
||||||
#### Wait for the prod tenant
|
#### Wait for the prod tenant
|
||||||
|
|
||||||
* Good, because the dev environment is identical to prod.
|
- Good, because the dev environment is identical to prod.
|
||||||
* Bad, because the development of every auth-touching feature is blocked until org-level provisioning is complete.
|
- Bad, because the development of every auth-touching feature is blocked until org-level provisioning is complete.
|
||||||
|
|
||||||
#### Microsoft 365 Developer Program tenant (chosen)
|
#### Microsoft 365 Developer Program tenant (chosen)
|
||||||
|
|
||||||
* Good, because free, full-featured, mirrors the prod configuration.
|
- Good, because free, full-featured, mirrors the prod configuration.
|
||||||
* Good, because completely decoupled from the prod tenant timeline.
|
- Good, because completely decoupled from the prod tenant timeline.
|
||||||
* Bad, because requires periodic renewal (every ~3 months).
|
- Bad, because requires periodic renewal (every ~3 months).
|
||||||
|
|
||||||
#### Self-hosted IdP (Keycloak or equivalent) for dev
|
#### Self-hosted IdP (Keycloak or equivalent) for dev
|
||||||
|
|
||||||
* Good, because fully under our control, no Microsoft account dependency.
|
- Good, because fully under our control, no Microsoft account dependency.
|
||||||
* Bad, because the dev code path differs from prod (different OIDC quirks, different claim shapes) — exactly the kind of bricolage that creates "works in dev, breaks in prod" surprises.
|
- Bad, because the dev code path differs from prod (different OIDC quirks, different claim shapes) — exactly the kind of bricolage that creates "works in dev, breaks in prod" surprises.
|
||||||
* Bad, because adds a moving part to local dev (a running IdP container) for no real gain.
|
- Bad, because adds a moving part to local dev (a running IdP container) for no real gain.
|
||||||
|
|
||||||
## More Information
|
## More Information
|
||||||
|
|
||||||
* Multi-tenant Entra apps: https://learn.microsoft.com/azure/active-directory/develop/howto-convert-app-to-be-multi-tenant
|
- Multi-tenant Entra apps: https://learn.microsoft.com/azure/active-directory/develop/howto-convert-app-to-be-multi-tenant
|
||||||
* Entra B2B collaboration: https://learn.microsoft.com/entra/external-id/what-is-b2b
|
- Entra B2B collaboration: https://learn.microsoft.com/entra/external-id/what-is-b2b
|
||||||
* Entra ID licensing tiers: https://learn.microsoft.com/entra/fundamentals/licensing
|
- Entra ID licensing tiers: https://learn.microsoft.com/entra/fundamentals/licensing
|
||||||
* Microsoft 365 Developer Program: https://developer.microsoft.com/microsoft-365/dev-program
|
- Microsoft 365 Developer Program: https://developer.microsoft.com/microsoft-365/dev-program
|
||||||
* PostgreSQL Row Level Security: https://www.postgresql.org/docs/current/ddl-rowsecurity.html
|
- PostgreSQL Row Level Security: https://www.postgresql.org/docs/current/ddl-rowsecurity.html
|
||||||
* Related ADRs: [ADR-0006](0006-persistence-postgresql-prisma.md) (Postgres + Prisma — RLS used by this ADR), and the future phase-2 ADRs for auth flow (OIDC Auth Code + PKCE via MSAL Node), session management (Redis), MFA enforcement (Conditional Access claims), downstream API access (On-Behalf-Of), and audit trail.
|
- Related ADRs: [ADR-0006](0006-persistence-postgresql-prisma.md) (Postgres + Prisma — RLS used by this ADR), and the future phase-2 ADRs for auth flow (OIDC Auth Code + PKCE via MSAL Node), session management (Redis), MFA enforcement (Conditional Access claims), downstream API access (On-Behalf-Of), and audit trail.
|
||||||
+82
-75
@@ -9,48 +9,54 @@ tags: [security, backend]
|
|||||||
|
|
||||||
## Context and Problem Statement
|
## Context and Problem Statement
|
||||||
|
|
||||||
[ADR-0008](0008-identity-model-entra-workforce-dual-audience.md) fixed the identity model: multi-tenant Entra ID for workforce, dual-audience design, M365 Developer tenant for non-prod. We now need to fix the *technical flow*: which OAuth/OIDC mechanism, which library, how tokens are obtained and held, how the session cookie is shaped, how CSRF is handled, how logout works.
|
[ADR-0008](0008-identity-model-entra-workforce-dual-audience.md) fixed the identity model: multi-tenant Entra ID for workforce, dual-audience design, M365 Developer tenant for non-prod. We now need to fix the _technical flow_: which OAuth/OIDC mechanism, which library, how tokens are obtained and held, how the session cookie is shaped, how CSRF is handled, how logout works.
|
||||||
|
|
||||||
The SPA must never hold tokens — that is the BFF security pattern, recommended by Microsoft and by the OAuth 2.0 BCP for browser-based apps (`draft-ietf-oauth-browser-based-apps`). The BFF holds the tokens server-side; the browser only carries an opaque session cookie.
|
The SPA must never hold tokens — that is the BFF security pattern, recommended by Microsoft and by the OAuth 2.0 BCP for browser-based apps (`draft-ietf-oauth-browser-based-apps`). The BFF holds the tokens server-side; the browser only carries an opaque session cookie.
|
||||||
|
|
||||||
## Decision Drivers
|
## Decision Drivers
|
||||||
|
|
||||||
* Conform to the current IETF best current practice for browser-based apps (BFF pattern).
|
- Conform to the current IETF best current practice for browser-based apps (BFF pattern).
|
||||||
* Conform to Microsoft's recommended path for multi-tenant Entra workforce apps.
|
- Conform to Microsoft's recommended path for multi-tenant Entra workforce apps.
|
||||||
* Tokens never leave the server — the SPA cannot leak them via XSS.
|
- Tokens never leave the server — the SPA cannot leak them via XSS.
|
||||||
* Support a future On-Behalf-Of flow when the BFF needs to call downstream Entra-protected APIs (covered by a later ADR).
|
- Support a future On-Behalf-Of flow when the BFF needs to call downstream Entra-protected APIs (covered by a later ADR).
|
||||||
* Defense in depth: hardened cookies, CSRF protection, refresh-token rotation.
|
- Defense in depth: hardened cookies, CSRF protection, refresh-token rotation.
|
||||||
* Consistent route shape so frontend, backend, and operators speak the same vocabulary.
|
- Consistent route shape so frontend, backend, and operators speak the same vocabulary.
|
||||||
|
|
||||||
## Considered Options
|
## Considered Options
|
||||||
|
|
||||||
### Library
|
### Library
|
||||||
* **`@azure/msal-node`** (`ConfidentialClientApplication`). (Chosen.)
|
|
||||||
* `openid-client` — generic OIDC client.
|
- **`@azure/msal-node`** (`ConfidentialClientApplication`). (Chosen.)
|
||||||
* `passport-azure-ad` — deprecated, in maintenance only.
|
- `openid-client` — generic OIDC client.
|
||||||
|
- `passport-azure-ad` — deprecated, in maintenance only.
|
||||||
|
|
||||||
### OAuth flow
|
### OAuth flow
|
||||||
* **Authorization Code Flow with PKCE.** (Chosen.)
|
|
||||||
* Implicit Flow — deprecated by IETF, rejected.
|
- **Authorization Code Flow with PKCE.** (Chosen.)
|
||||||
* Hybrid Flow — legacy, rejected.
|
- Implicit Flow — deprecated by IETF, rejected.
|
||||||
* Resource Owner Password Credentials — forbidden by Microsoft for production scenarios.
|
- Hybrid Flow — legacy, rejected.
|
||||||
|
- Resource Owner Password Credentials — forbidden by Microsoft for production scenarios.
|
||||||
|
|
||||||
### Token storage
|
### Token storage
|
||||||
* **Tokens held in the BFF session, never sent to the browser.** (Chosen — the BFF pattern itself.)
|
|
||||||
* Tokens in browser memory or localStorage / sessionStorage — XSS-exfiltrable, rejected.
|
- **Tokens held in the BFF session, never sent to the browser.** (Chosen — the BFF pattern itself.)
|
||||||
|
- Tokens in browser memory or localStorage / sessionStorage — XSS-exfiltrable, rejected.
|
||||||
|
|
||||||
### Session cookie shape
|
### Session cookie shape
|
||||||
* **`__Host-portal_session`** with `HttpOnly`, `Secure`, `SameSite=Lax`. (Chosen.)
|
|
||||||
* Same attributes without the `__Host-` prefix — weaker (subdomain bleed, downgrade attacks).
|
- **`__Host-portal_session`** with `HttpOnly`, `Secure`, `SameSite=Lax`. (Chosen.)
|
||||||
|
- Same attributes without the `__Host-` prefix — weaker (subdomain bleed, downgrade attacks).
|
||||||
|
|
||||||
### CSRF
|
### CSRF
|
||||||
* **Double-submit cookie pattern.** (Chosen.) `__Host-portal_csrf` cookie (readable by the SPA) + matching `X-CSRF-Token` header on every state-changing request.
|
|
||||||
* Synchronizer token (server-side store) — heavier, requires a dedicated CSRF token store.
|
- **Double-submit cookie pattern.** (Chosen.) `__Host-portal_csrf` cookie (readable by the SPA) + matching `X-CSRF-Token` header on every state-changing request.
|
||||||
* No CSRF protection — unacceptable.
|
- Synchronizer token (server-side store) — heavier, requires a dedicated CSRF token store.
|
||||||
|
- No CSRF protection — unacceptable.
|
||||||
|
|
||||||
### Logout
|
### Logout
|
||||||
* **RP-initiated logout.** (Chosen.) BFF invalidates the session, then redirects the browser to Entra's `end_session_endpoint`, which clears the Entra SSO session and redirects back.
|
|
||||||
* Local-only logout — the Entra SSO session stays live, the user can be silently re-authenticated without intent.
|
- **RP-initiated logout.** (Chosen.) BFF invalidates the session, then redirects the browser to Entra's `end_session_endpoint`, which clears the Entra SSO session and redirects back.
|
||||||
|
- Local-only logout — the Entra SSO session stays live, the user can be silently re-authenticated without intent.
|
||||||
|
|
||||||
## Decision Outcome
|
## Decision Outcome
|
||||||
|
|
||||||
@@ -61,6 +67,7 @@ The SPA must never hold tokens — that is the BFF security pattern, recommended
|
|||||||
**Tokens.** ID/access/refresh tokens are stored in the server-side session (storage backend covered by the next ADR). The browser only holds an opaque session identifier in `__Host-portal_session`.
|
**Tokens.** ID/access/refresh tokens are stored in the server-side session (storage backend covered by the next ADR). The browser only holds an opaque session identifier in `__Host-portal_session`.
|
||||||
|
|
||||||
**Token validation.** On callback, MSAL Node validates the `id_token` (signature against JWKS, `iss`, `aud`, `exp`, `nbf`). The BFF additionally enforces:
|
**Token validation.** On callback, MSAL Node validates the `id_token` (signature against JWKS, `iss`, `aud`, `exp`, `nbf`). The BFF additionally enforces:
|
||||||
|
|
||||||
- `iss` belongs to the tenant allowlist (workforce tenants accepted by ADR-0008);
|
- `iss` belongs to the tenant allowlist (workforce tenants accepted by ADR-0008);
|
||||||
- `aud` matches our app's `client_id`;
|
- `aud` matches our app's `client_id`;
|
||||||
- the audience claim is mapped to our `Audience` enum (workforce tokens → `audience: 'workforce'`; any other classification fails).
|
- the audience claim is mapped to our `Audience` enum (workforce tokens → `audience: 'workforce'`; any other classification fails).
|
||||||
@@ -70,7 +77,7 @@ The SPA must never hold tokens — that is the BFF security pattern, recommended
|
|||||||
**Cookies.**
|
**Cookies.**
|
||||||
|
|
||||||
| Cookie | Purpose | Attributes |
|
| Cookie | Purpose | Attributes |
|
||||||
| --- | --- | --- |
|
| ----------------------- | --------------------------- | -------------------------------------------------------------------------------------------- |
|
||||||
| `__Host-portal_session` | opaque session id | `HttpOnly`, `Secure`, `SameSite=Lax`, `Path=/` (forced by the `__Host-` prefix), no `Domain` |
|
| `__Host-portal_session` | opaque session id | `HttpOnly`, `Secure`, `SameSite=Lax`, `Path=/` (forced by the `__Host-` prefix), no `Domain` |
|
||||||
| `__Host-portal_csrf` | CSRF token (readable by JS) | `Secure`, `SameSite=Lax`, `Path=/`, no `HttpOnly` (the SPA must read it) |
|
| `__Host-portal_csrf` | CSRF token (readable by JS) | `Secure`, `SameSite=Lax`, `Path=/`, no `HttpOnly` (the SPA must read it) |
|
||||||
|
|
||||||
@@ -83,7 +90,7 @@ In **local development** (HTTP `localhost`), browsers reject the `__Host-` prefi
|
|||||||
**Routes.** All authentication endpoints live under the `/auth` prefix on the BFF:
|
**Routes.** All authentication endpoints live under the `/auth` prefix on the BFF:
|
||||||
|
|
||||||
| Method | Path | Purpose |
|
| Method | Path | Purpose |
|
||||||
| --- | --- | --- |
|
| ------ | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||||
| `GET` | `/auth/login` | starts the flow; accepts an optional `returnTo` query parameter validated against an in-app path allowlist; redirects the browser to Entra's `authorize` endpoint with `state`, `nonce`, and PKCE parameters |
|
| `GET` | `/auth/login` | starts the flow; accepts an optional `returnTo` query parameter validated against an in-app path allowlist; redirects the browser to Entra's `authorize` endpoint with `state`, `nonce`, and PKCE parameters |
|
||||||
| `GET` | `/auth/callback` | receives `code` + `state` from Entra; verifies state, exchanges via MSAL Node, validates id_token, creates the session, redirects to `returnTo` (or `/`) |
|
| `GET` | `/auth/callback` | receives `code` + `state` from Entra; verifies state, exchanges via MSAL Node, validates id_token, creates the session, redirects to `returnTo` (or `/`) |
|
||||||
| `POST` | `/auth/logout` | requires `X-CSRF-Token`; invalidates the BFF session; returns 200 with the Entra `end_session_endpoint` URL in JSON; the SPA navigates the browser to that URL |
|
| `POST` | `/auth/logout` | requires `X-CSRF-Token`; invalidates the BFF session; returns 200 with the Entra `end_session_endpoint` URL in JSON; the SPA navigates the browser to that URL |
|
||||||
@@ -94,10 +101,10 @@ In **local development** (HTTP `localhost`), browsers reject the `__Host-` prefi
|
|||||||
**Configuration.** All Entra-specific values come from environment variables — no tenant ID, client ID, or secret in source. The BFF refuses to start if any required variable is missing.
|
**Configuration.** All Entra-specific values come from environment variables — no tenant ID, client ID, or secret in source. The BFF refuses to start if any required variable is missing.
|
||||||
|
|
||||||
| Variable | Purpose |
|
| Variable | Purpose |
|
||||||
| --- | --- |
|
| --------------------------------------------------- | ----------------------------------------------------------------------------------- |
|
||||||
| `ENTRA_TENANT_ID` | home tenant id of the BFF (the tenant where the app is registered as the home org) |
|
| `ENTRA_TENANT_ID` | home tenant id of the BFF (the tenant where the app is registered as the home org) |
|
||||||
| `ENTRA_CLIENT_ID` | app's client id |
|
| `ENTRA_CLIENT_ID` | app's client id |
|
||||||
| `ENTRA_CLIENT_SECRET` *or* `ENTRA_CLIENT_CERT_PATH` | confidential-client credential (certificate preferred for production) |
|
| `ENTRA_CLIENT_SECRET` _or_ `ENTRA_CLIENT_CERT_PATH` | confidential-client credential (certificate preferred for production) |
|
||||||
| `ENTRA_ACCEPTED_TENANT_IDS` | comma-separated allowlist for `iss` validation |
|
| `ENTRA_ACCEPTED_TENANT_IDS` | comma-separated allowlist for `iss` validation |
|
||||||
| `ENTRA_REDIRECT_URI` | absolute URL of the `/auth/callback` endpoint, registered in the app registration |
|
| `ENTRA_REDIRECT_URI` | absolute URL of the `/auth/callback` endpoint, registered in the app registration |
|
||||||
| `ENTRA_POST_LOGOUT_REDIRECT_URI` | absolute URL the user lands on after Entra logout |
|
| `ENTRA_POST_LOGOUT_REDIRECT_URI` | absolute URL the user lands on after Entra logout |
|
||||||
@@ -105,29 +112,29 @@ In **local development** (HTTP `localhost`), browsers reject the `__Host-` prefi
|
|||||||
|
|
||||||
### Consequences
|
### Consequences
|
||||||
|
|
||||||
* Good, because the SPA can never leak tokens — the worst an XSS can do is hijack the session cookie, and `HttpOnly` blocks even that.
|
- Good, because the SPA can never leak tokens — the worst an XSS can do is hijack the session cookie, and `HttpOnly` blocks even that.
|
||||||
* Good, because PKCE protects the code-exchange step against authorization-code interception even on the BFF side.
|
- Good, because PKCE protects the code-exchange step against authorization-code interception even on the BFF side.
|
||||||
* Good, because the route shape and configuration surface are simple, predictable, and entirely documented.
|
- Good, because the route shape and configuration surface are simple, predictable, and entirely documented.
|
||||||
* Good, because MSAL Node will let us add the On-Behalf-Of flow in the downstream-API ADR with minimal extra work.
|
- Good, because MSAL Node will let us add the On-Behalf-Of flow in the downstream-API ADR with minimal extra work.
|
||||||
* Good, because RP-initiated logout produces a clean cross-app state; users are not silently re-authenticated against their will.
|
- Good, because RP-initiated logout produces a clean cross-app state; users are not silently re-authenticated against their will.
|
||||||
* Good, because the dual-audience claim mapping is enforced at the validation step — workforce vs customer is a server-side decision, not a client-side hint.
|
- Good, because the dual-audience claim mapping is enforced at the validation step — workforce vs customer is a server-side decision, not a client-side hint.
|
||||||
* Bad, because the codebase becomes tightly coupled to MSAL Node and Entra. Switching IdP later means more than swapping a library — but the trade-off is accepted given identity is already locked to Entra.
|
- Bad, because the codebase becomes tightly coupled to MSAL Node and Entra. Switching IdP later means more than swapping a library — but the trade-off is accepted given identity is already locked to Entra.
|
||||||
* Bad, because `SameSite=Lax` (instead of `Strict`) is necessary for the callback to work; this is a known and accepted trade-off, mitigated by HTTPS, HSTS, the `__Host-` prefix, and CSRF.
|
- Bad, because `SameSite=Lax` (instead of `Strict`) is necessary for the callback to work; this is a known and accepted trade-off, mitigated by HTTPS, HSTS, the `__Host-` prefix, and CSRF.
|
||||||
* Bad, because every state-changing call from the SPA must carry `X-CSRF-Token` — small DX overhead, mitigated by a single Angular HTTP interceptor.
|
- Bad, because every state-changing call from the SPA must carry `X-CSRF-Token` — small DX overhead, mitigated by a single Angular HTTP interceptor.
|
||||||
* Bad, because mkcert is required in dev to keep cookie behaviour identical to prod — slightly higher onboarding cost; documented in the dev setup guide.
|
- Bad, because mkcert is required in dev to keep cookie behaviour identical to prod — slightly higher onboarding cost; documented in the dev setup guide.
|
||||||
* Bad, because the tenant allowlist (`ENTRA_ACCEPTED_TENANT_IDS`) must be maintained operationally as new B2B partners are onboarded — this is an operational item, not a one-shot.
|
- Bad, because the tenant allowlist (`ENTRA_ACCEPTED_TENANT_IDS`) must be maintained operationally as new B2B partners are onboarded — this is an operational item, not a one-shot.
|
||||||
|
|
||||||
### Confirmation
|
### Confirmation
|
||||||
|
|
||||||
* `apps/portal-bff/src/auth/auth.module.ts` provides a single `ConfidentialClientApplication` configured from env, with PKCE enabled.
|
- `apps/portal-bff/src/auth/auth.module.ts` provides a single `ConfidentialClientApplication` configured from env, with PKCE enabled.
|
||||||
* `apps/portal-bff/src/auth/auth.controller.ts` exposes the four routes above and no others.
|
- `apps/portal-bff/src/auth/auth.controller.ts` exposes the four routes above and no others.
|
||||||
* `AuthGuard` is registered globally; routes that must be public are explicitly marked with a `@Public()` decorator.
|
- `AuthGuard` is registered globally; routes that must be public are explicitly marked with a `@Public()` decorator.
|
||||||
* `__Host-portal_session` and `__Host-portal_csrf` are the only cookies set by the BFF (other than infrastructure cookies, none of which carry session data).
|
- `__Host-portal_session` and `__Host-portal_csrf` are the only cookies set by the BFF (other than infrastructure cookies, none of which carry session data).
|
||||||
* A NestJS interceptor enforces double-submit CSRF on every non-`GET` request.
|
- A NestJS interceptor enforces double-submit CSRF on every non-`GET` request.
|
||||||
* The Angular HTTP interceptor in `portal-shell` injects `X-CSRF-Token` on every outgoing request to the BFF.
|
- The Angular HTTP interceptor in `portal-shell` injects `X-CSRF-Token` on every outgoing request to the BFF.
|
||||||
* `iss` validation rejects any token whose issuer is not in the tenant allowlist; tested with a token from a non-allowlisted tenant.
|
- `iss` validation rejects any token whose issuer is not in the tenant allowlist; tested with a token from a non-allowlisted tenant.
|
||||||
* Refresh-token rotation is asserted via integration tests (one expired access token + a still-valid refresh token must succeed silently; an expired refresh token must produce 401 and trigger re-authentication).
|
- Refresh-token rotation is asserted via integration tests (one expired access token + a still-valid refresh token must succeed silently; an expired refresh token must produce 401 and trigger re-authentication).
|
||||||
* RP-initiated logout is asserted: `POST /auth/logout` invalidates the session, returns the `end_session_endpoint` URL, and a subsequent request without re-authentication is denied.
|
- RP-initiated logout is asserted: `POST /auth/logout` invalidates the session, returns the `end_session_endpoint` URL, and a subsequent request without re-authentication is denied.
|
||||||
|
|
||||||
## Pros and Cons of the Options
|
## Pros and Cons of the Options
|
||||||
|
|
||||||
@@ -135,81 +142,81 @@ In **local development** (HTTP `localhost`), browsers reject the `__Host-` prefi
|
|||||||
|
|
||||||
#### `@azure/msal-node` (chosen)
|
#### `@azure/msal-node` (chosen)
|
||||||
|
|
||||||
* Good, because Microsoft-official, actively maintained, aligned with Entra's quirks and roadmap (B2B, External ID later, OBO).
|
- Good, because Microsoft-official, actively maintained, aligned with Entra's quirks and roadmap (B2B, External ID later, OBO).
|
||||||
* Good, because handles refresh-token rotation, multi-tenant authority routing, and JWKS caching out of the box.
|
- Good, because handles refresh-token rotation, multi-tenant authority routing, and JWKS caching out of the box.
|
||||||
* Bad, because Microsoft-specific — couples the BFF to Entra at the protocol-handler layer.
|
- Bad, because Microsoft-specific — couples the BFF to Entra at the protocol-handler layer.
|
||||||
|
|
||||||
#### `openid-client`
|
#### `openid-client`
|
||||||
|
|
||||||
* Good, because a generic, RFC-faithful OIDC client; would work with any IdP.
|
- Good, because a generic, RFC-faithful OIDC client; would work with any IdP.
|
||||||
* Bad, because lacks Microsoft-specific helpers, especially the OBO flow we'll need in the downstream-API ADR.
|
- Bad, because lacks Microsoft-specific helpers, especially the OBO flow we'll need in the downstream-API ADR.
|
||||||
* Bad, because we'd be writing the Microsoft-specific glue ourselves — bricolage on a security-critical surface.
|
- Bad, because we'd be writing the Microsoft-specific glue ourselves — bricolage on a security-critical surface.
|
||||||
|
|
||||||
#### `passport-azure-ad`
|
#### `passport-azure-ad`
|
||||||
|
|
||||||
* Bad, because deprecated by Microsoft; no new features, only critical fixes. Rejected.
|
- Bad, because deprecated by Microsoft; no new features, only critical fixes. Rejected.
|
||||||
|
|
||||||
### OAuth flow
|
### OAuth flow
|
||||||
|
|
||||||
#### Authorization Code with PKCE (chosen)
|
#### Authorization Code with PKCE (chosen)
|
||||||
|
|
||||||
* Good, because IETF BCP for browser-based apps, recommended by Microsoft for confidential clients on the BFF.
|
- Good, because IETF BCP for browser-based apps, recommended by Microsoft for confidential clients on the BFF.
|
||||||
* Good, because PKCE adds a binding between the auth-request and the code-exchange that defeats code-interception attacks.
|
- Good, because PKCE adds a binding between the auth-request and the code-exchange that defeats code-interception attacks.
|
||||||
|
|
||||||
#### Implicit Flow
|
#### Implicit Flow
|
||||||
|
|
||||||
* Bad, because deprecated; tokens were returned in the URL fragment, exposed to browser history and the SPA. Rejected.
|
- Bad, because deprecated; tokens were returned in the URL fragment, exposed to browser history and the SPA. Rejected.
|
||||||
|
|
||||||
#### Hybrid Flow
|
#### Hybrid Flow
|
||||||
|
|
||||||
* Bad, because legacy; combines Code and Implicit. Rejected.
|
- Bad, because legacy; combines Code and Implicit. Rejected.
|
||||||
|
|
||||||
#### Resource Owner Password Credentials
|
#### Resource Owner Password Credentials
|
||||||
|
|
||||||
* Bad, because the BFF would handle the user's credentials directly — forbidden by Microsoft for production. Rejected.
|
- Bad, because the BFF would handle the user's credentials directly — forbidden by Microsoft for production. Rejected.
|
||||||
|
|
||||||
### Cookie shape
|
### Cookie shape
|
||||||
|
|
||||||
#### `__Host-` prefix (chosen)
|
#### `__Host-` prefix (chosen)
|
||||||
|
|
||||||
* Good, because browsers enforce `Secure`, `Path=/`, and absence of `Domain` — no subdomain bleed, no downgrade.
|
- Good, because browsers enforce `Secure`, `Path=/`, and absence of `Domain` — no subdomain bleed, no downgrade.
|
||||||
* Bad, because requires HTTPS in dev (mkcert).
|
- Bad, because requires HTTPS in dev (mkcert).
|
||||||
|
|
||||||
#### Without the prefix
|
#### Without the prefix
|
||||||
|
|
||||||
* Bad, because subdomain cookies and HTTP-downgrade scenarios become attack surface.
|
- Bad, because subdomain cookies and HTTP-downgrade scenarios become attack surface.
|
||||||
|
|
||||||
### CSRF
|
### CSRF
|
||||||
|
|
||||||
#### Double-submit cookie (chosen)
|
#### Double-submit cookie (chosen)
|
||||||
|
|
||||||
* Good, because stateless server-side — no token store to maintain.
|
- Good, because stateless server-side — no token store to maintain.
|
||||||
* Good, because the cookie is `SameSite=Lax`, so it isn't sent on cross-site requests; combined with the header check, an attacker on another origin cannot synthesise a valid request.
|
- Good, because the cookie is `SameSite=Lax`, so it isn't sent on cross-site requests; combined with the header check, an attacker on another origin cannot synthesise a valid request.
|
||||||
|
|
||||||
#### Synchronizer token
|
#### Synchronizer token
|
||||||
|
|
||||||
* Good, because slightly stronger isolation (the token is server-only).
|
- Good, because slightly stronger isolation (the token is server-only).
|
||||||
* Bad, because requires a server-side store and synchronisation — heavier for marginal gain in our threat model.
|
- Bad, because requires a server-side store and synchronisation — heavier for marginal gain in our threat model.
|
||||||
|
|
||||||
#### No protection
|
#### No protection
|
||||||
|
|
||||||
* Bad, because cookies are sent on cross-site form submissions; state-changing endpoints would be trivially exploitable. Rejected.
|
- Bad, because cookies are sent on cross-site form submissions; state-changing endpoints would be trivially exploitable. Rejected.
|
||||||
|
|
||||||
### Logout
|
### Logout
|
||||||
|
|
||||||
#### RP-initiated (chosen)
|
#### RP-initiated (chosen)
|
||||||
|
|
||||||
* Good, because the user's Entra SSO state is cleared too — explicit, no silent re-auth.
|
- Good, because the user's Entra SSO state is cleared too — explicit, no silent re-auth.
|
||||||
|
|
||||||
#### Local-only
|
#### Local-only
|
||||||
|
|
||||||
* Bad, because the user remains signed in to Entra; "log out" feels reversible to the attacker who got their hands on the session for a moment, not to the user.
|
- Bad, because the user remains signed in to Entra; "log out" feels reversible to the attacker who got their hands on the session for a moment, not to the user.
|
||||||
|
|
||||||
## More Information
|
## More Information
|
||||||
|
|
||||||
* IETF "OAuth 2.0 for Browser-Based Apps" BCP draft: https://datatracker.ietf.org/doc/draft-ietf-oauth-browser-based-apps/
|
- IETF "OAuth 2.0 for Browser-Based Apps" BCP draft: https://datatracker.ietf.org/doc/draft-ietf-oauth-browser-based-apps/
|
||||||
* Microsoft BFF guidance: https://learn.microsoft.com/azure/architecture/patterns/backends-for-frontends
|
- Microsoft BFF guidance: https://learn.microsoft.com/azure/architecture/patterns/backends-for-frontends
|
||||||
* MSAL Node overview: https://learn.microsoft.com/azure/active-directory/develop/msal-node-overview
|
- MSAL Node overview: https://learn.microsoft.com/azure/active-directory/develop/msal-node-overview
|
||||||
* Cookie `__Host-` prefix: https://developer.mozilla.org/docs/Web/HTTP/Cookies#cookie_prefixes
|
- Cookie `__Host-` prefix: https://developer.mozilla.org/docs/Web/HTTP/Cookies#cookie_prefixes
|
||||||
* mkcert: https://github.com/FiloSottile/mkcert
|
- mkcert: https://github.com/FiloSottile/mkcert
|
||||||
* Related ADRs: [ADR-0005](0005-backend-stack-nestjs.md) (NestJS), [ADR-0008](0008-identity-model-entra-workforce-dual-audience.md) (identity model), and the future ADRs for session storage (Redis), MFA enforcement, downstream On-Behalf-Of flow, and audit trail.
|
- Related ADRs: [ADR-0005](0005-backend-stack-nestjs.md) (NestJS), [ADR-0008](0008-identity-model-entra-workforce-dual-audience.md) (identity model), and the future ADRs for session storage (Redis), MFA enforcement, downstream On-Behalf-Of flow, and audit trail.
|
||||||
+101
-90
@@ -12,50 +12,57 @@ tags: [security, backend, infrastructure]
|
|||||||
[ADR-0009](0009-auth-flow-oidc-pkce-msal-node.md) fixed the authentication flow: the BFF holds id/access/refresh tokens server-side and the browser only carries an opaque `__Host-portal_session` cookie. This ADR pins where the session lives, in what shape, with which TTL policy, and with which security posture.
|
[ADR-0009](0009-auth-flow-oidc-pkce-msal-node.md) fixed the authentication flow: the BFF holds id/access/refresh tokens server-side and the browser only carries an opaque `__Host-portal_session` cookie. This ADR pins where the session lives, in what shape, with which TTL policy, and with which security posture.
|
||||||
|
|
||||||
Two non-negotiable constraints frame the choices:
|
Two non-negotiable constraints frame the choices:
|
||||||
|
|
||||||
- the BFF must be able to **revoke a session immediately** (logout, security incident, admin action);
|
- the BFF must be able to **revoke a session immediately** (logout, security incident, admin action);
|
||||||
- the session payload contains tokens that, if exfiltrated from Redis, would let an attacker impersonate the user — so storage must protect against snapshot or memory-dump leaks, not only against network-level theft.
|
- the session payload contains tokens that, if exfiltrated from Redis, would let an attacker impersonate the user — so storage must protect against snapshot or memory-dump leaks, not only against network-level theft.
|
||||||
|
|
||||||
## Decision Drivers
|
## Decision Drivers
|
||||||
|
|
||||||
* Immediate, server-side revocation (logout, force-logout-everywhere).
|
- Immediate, server-side revocation (logout, force-logout-everywhere).
|
||||||
* Defense in depth at the storage layer — Redis security is necessary but not sufficient.
|
- Defense in depth at the storage layer — Redis security is necessary but not sufficient.
|
||||||
* Production HA with a self-hosted Redis (per the on-prem constraint of the project).
|
- Production HA with a self-hosted Redis (per the on-prem constraint of the project).
|
||||||
* Battle-tested integration in NestJS, no bricolage on a security-critical path.
|
- Battle-tested integration in NestJS, no bricolage on a security-critical path.
|
||||||
* TTL policy aligned with enterprise norms (idle + absolute), without harming UX.
|
- TTL policy aligned with enterprise norms (idle + absolute), without harming UX.
|
||||||
* Listing and revoking active sessions for a given user must be possible without a second persistence layer.
|
- Listing and revoking active sessions for a given user must be possible without a second persistence layer.
|
||||||
* Configuration is env-driven; no host or secret in source.
|
- Configuration is env-driven; no host or secret in source.
|
||||||
|
|
||||||
## Considered Options
|
## Considered Options
|
||||||
|
|
||||||
### Storage backend
|
### Storage backend
|
||||||
* **Redis (self-hosted).** (Chosen.)
|
|
||||||
* PostgreSQL session table.
|
- **Redis (self-hosted).** (Chosen.)
|
||||||
* In-memory (single-node only).
|
- PostgreSQL session table.
|
||||||
* Cookie-session (signed payload in the cookie itself).
|
- In-memory (single-node only).
|
||||||
|
- Cookie-session (signed payload in the cookie itself).
|
||||||
|
|
||||||
### NestJS integration
|
### NestJS integration
|
||||||
* **`express-session` + `connect-redis` middleware** mounted under the Express adapter. (Chosen.)
|
|
||||||
* A custom Redis-backed session service.
|
- **`express-session` + `connect-redis` middleware** mounted under the Express adapter. (Chosen.)
|
||||||
* A NestJS-specific wrapper (`nestjs-session`, etc.).
|
- A custom Redis-backed session service.
|
||||||
|
- A NestJS-specific wrapper (`nestjs-session`, etc.).
|
||||||
|
|
||||||
### Session ID format
|
### Session ID format
|
||||||
* **Opaque crypto-random ≥ 256 bits.** (Chosen.)
|
|
||||||
* JWT-as-session-id.
|
- **Opaque crypto-random ≥ 256 bits.** (Chosen.)
|
||||||
|
- JWT-as-session-id.
|
||||||
|
|
||||||
### Encryption at rest
|
### Encryption at rest
|
||||||
* **AES-256-GCM applied by the BFF before tokens are stored.** (Chosen.)
|
|
||||||
* No application-level encryption (rely on Redis ACL + TLS only).
|
- **AES-256-GCM applied by the BFF before tokens are stored.** (Chosen.)
|
||||||
|
- No application-level encryption (rely on Redis ACL + TLS only).
|
||||||
|
|
||||||
### TTL policy
|
### TTL policy
|
||||||
* **Idle (sliding) 30 min + absolute 12 h.** (Chosen.)
|
|
||||||
* Shorter idle (e.g. 15 min) — friction.
|
- **Idle (sliding) 30 min + absolute 12 h.** (Chosen.)
|
||||||
* Longer absolute (e.g. 24 h) — risk window.
|
- Shorter idle (e.g. 15 min) — friction.
|
||||||
* No idle expiry.
|
- Longer absolute (e.g. 24 h) — risk window.
|
||||||
|
- No idle expiry.
|
||||||
|
|
||||||
### Production topology
|
### Production topology
|
||||||
* **Redis Sentinel HA (3+ nodes).** (Chosen.) Operational details deferred to a phase-3 infrastructure ADR.
|
|
||||||
* Redis Cluster.
|
- **Redis Sentinel HA (3+ nodes).** (Chosen.) Operational details deferred to a phase-3 infrastructure ADR.
|
||||||
* Single node (dev only).
|
- Redis Cluster.
|
||||||
|
- Single node (dev only).
|
||||||
|
|
||||||
## Decision Outcome
|
## Decision Outcome
|
||||||
|
|
||||||
@@ -69,7 +76,8 @@ Two non-negotiable constraints frame the choices:
|
|||||||
type SessionPayload = {
|
type SessionPayload = {
|
||||||
userId: string;
|
userId: string;
|
||||||
audience: 'workforce' | 'customer';
|
audience: 'workforce' | 'customer';
|
||||||
claims: { // curated subset of the id_token, never the full token
|
claims: {
|
||||||
|
// curated subset of the id_token, never the full token
|
||||||
sub: string;
|
sub: string;
|
||||||
oid: string;
|
oid: string;
|
||||||
tid: string; // home tenant of the user
|
tid: string; // home tenant of the user
|
||||||
@@ -91,11 +99,12 @@ type SessionPayload = {
|
|||||||
**TTL policy.**
|
**TTL policy.**
|
||||||
|
|
||||||
| Field | Default | Source of truth |
|
| Field | Default | Source of truth |
|
||||||
| --- | --- | --- |
|
| ---------------------- | ------- | ---------------------------------- |
|
||||||
| Idle (sliding) timeout | 30 min | `SESSION_IDLE_TIMEOUT_SECONDS` |
|
| Idle (sliding) timeout | 30 min | `SESSION_IDLE_TIMEOUT_SECONDS` |
|
||||||
| Absolute timeout | 12 h | `SESSION_ABSOLUTE_TIMEOUT_SECONDS` |
|
| Absolute timeout | 12 h | `SESSION_ABSOLUTE_TIMEOUT_SECONDS` |
|
||||||
|
|
||||||
Mechanics:
|
Mechanics:
|
||||||
|
|
||||||
- The Redis key carries an `EXPIRE` matching the idle timeout; every authenticated request refreshes it via `connect-redis`'s `touch`.
|
- The Redis key carries an `EXPIRE` matching the idle timeout; every authenticated request refreshes it via `connect-redis`'s `touch`.
|
||||||
- `absoluteExpiresAt` is recorded at session creation and **checked on every request**. If exceeded, the BFF deletes the session key and returns 401.
|
- `absoluteExpiresAt` is recorded at session creation and **checked on every request**. If exceeded, the BFF deletes the session key and returns 401.
|
||||||
- The two checks are independent: a session ends at whichever timeout fires first.
|
- The two checks are independent: a session ends at whichever timeout fires first.
|
||||||
@@ -105,6 +114,7 @@ Defaults are policy decisions, not technical limits — they can be tuned per en
|
|||||||
**Token refresh.** Access tokens (Entra-issued, ~1 h lifetime) are refreshed via `acquireTokenSilent` (MSAL Node) when an authenticated handler observes the access token is within ~5 min of expiry. The refresh token (Entra workforce, ~90-day sliding) is stored in the encrypted blob alongside the access token. Refresh-token rotation is enabled (cf. ADR-0009).
|
**Token refresh.** Access tokens (Entra-issued, ~1 h lifetime) are refreshed via `acquireTokenSilent` (MSAL Node) when an authenticated handler observes the access token is within ~5 min of expiry. The refresh token (Entra workforce, ~90-day sliding) is stored in the encrypted blob alongside the access token. Refresh-token rotation is enabled (cf. ADR-0009).
|
||||||
|
|
||||||
**Revocation.**
|
**Revocation.**
|
||||||
|
|
||||||
- `POST /auth/logout` deletes the session key immediately (`DEL session:{id}`).
|
- `POST /auth/logout` deletes the session key immediately (`DEL session:{id}`).
|
||||||
- An admin "log out user X everywhere" operation lists keys via a secondary index `user_sessions:{userId}` (a Redis set of session ids maintained on session create/destroy) and `DEL`-s them.
|
- An admin "log out user X everywhere" operation lists keys via a secondary index `user_sessions:{userId}` (a Redis set of session ids maintained on session create/destroy) and `DEL`-s them.
|
||||||
- An admin "log out everyone" operation is intentionally not provided as a one-shot endpoint — it would be implemented as a runbook, not a feature, to avoid creating an obvious abuse vector.
|
- An admin "log out everyone" operation is intentionally not provided as a one-shot endpoint — it would be implemented as a runbook, not a feature, to avoid creating an obvious abuse vector.
|
||||||
@@ -112,14 +122,15 @@ Defaults are policy decisions, not technical limits — they can be tuned per en
|
|||||||
**Active-sessions listing.** The optional `user_sessions:{userId}` index supports listing of active sessions per user (for an admin dashboard or a user-side "my active sessions" view). No PostgreSQL mirror — historical trace lives in the audit log (future ADR).
|
**Active-sessions listing.** The optional `user_sessions:{userId}` index supports listing of active sessions per user (for an admin dashboard or a user-side "my active sessions" view). No PostgreSQL mirror — historical trace lives in the audit log (future ADR).
|
||||||
|
|
||||||
**Topology.**
|
**Topology.**
|
||||||
|
|
||||||
- **Production:** Redis Sentinel with at least 3 nodes (1 master, 2 replicas, 3 sentinel processes for quorum), TLS in transit, ACL-restricted credentials, AOF persistence at least every second. Hosting and operational specifics (k8s operator, backup strategy, monitoring) are deferred to a phase-3 infrastructure ADR.
|
- **Production:** Redis Sentinel with at least 3 nodes (1 master, 2 replicas, 3 sentinel processes for quorum), TLS in transit, ACL-restricted credentials, AOF persistence at least every second. Hosting and operational specifics (k8s operator, backup strategy, monitoring) are deferred to a phase-3 infrastructure ADR.
|
||||||
- **Development:** a single Redis instance (Docker container or bare process), no TLS, no persistence required. Same connection-string interface (`REDIS_URL` or Sentinel-style `REDIS_SENTINEL_HOSTS` / `REDIS_SENTINEL_NAME`) so code paths are identical.
|
- **Development:** a single Redis instance (Docker container or bare process), no TLS, no persistence required. Same connection-string interface (`REDIS_URL` or Sentinel-style `REDIS_SENTINEL_HOSTS` / `REDIS_SENTINEL_NAME`) so code paths are identical.
|
||||||
|
|
||||||
**Configuration (env-driven).**
|
**Configuration (env-driven).**
|
||||||
|
|
||||||
| Variable | Purpose |
|
| Variable | Purpose |
|
||||||
| --- | --- |
|
| --------------------------------------------------------------- | ------------------------------------------------------- |
|
||||||
| `REDIS_URL` *or* `REDIS_SENTINEL_HOSTS` + `REDIS_SENTINEL_NAME` | connection target |
|
| `REDIS_URL` _or_ `REDIS_SENTINEL_HOSTS` + `REDIS_SENTINEL_NAME` | connection target |
|
||||||
| `REDIS_PASSWORD` | client-side ACL credential |
|
| `REDIS_PASSWORD` | client-side ACL credential |
|
||||||
| `REDIS_TLS` | `'true'`/`'false'` — required `true` in prod |
|
| `REDIS_TLS` | `'true'`/`'false'` — required `true` in prod |
|
||||||
| `SESSION_SECRET` | cookie-signing HMAC secret (also used by ADR-0009) |
|
| `SESSION_SECRET` | cookie-signing HMAC secret (also used by ADR-0009) |
|
||||||
@@ -131,29 +142,29 @@ The BFF refuses to start if any required variable is missing or malformed (e.g.
|
|||||||
|
|
||||||
### Consequences
|
### Consequences
|
||||||
|
|
||||||
* Good, because server-side state means a session can be invalidated by a single Redis command, instantly, with no race window.
|
- Good, because server-side state means a session can be invalidated by a single Redis command, instantly, with no race window.
|
||||||
* Good, because AES-GCM at rest defends against Redis snapshot, RDB dump, or memory-inspection scenarios — exfiltrated ciphertext is useless without the key.
|
- Good, because AES-GCM at rest defends against Redis snapshot, RDB dump, or memory-inspection scenarios — exfiltrated ciphertext is useless without the key.
|
||||||
* Good, because the TTL policy (30 min idle / 12 h absolute) is consistent with enterprise standards and well within Entra's token lifetimes.
|
- Good, because the TTL policy (30 min idle / 12 h absolute) is consistent with enterprise standards and well within Entra's token lifetimes.
|
||||||
* Good, because Sentinel gives a clean failover story for self-hosted prod — no SaaS dependency.
|
- Good, because Sentinel gives a clean failover story for self-hosted prod — no SaaS dependency.
|
||||||
* Good, because `connect-redis` is mature and used by thousands of Node services; no bricolage on a security-critical path.
|
- Good, because `connect-redis` is mature and used by thousands of Node services; no bricolage on a security-critical path.
|
||||||
* Good, because the `user_sessions:{userId}` secondary index covers "list / revoke per user" without a Postgres mirror.
|
- Good, because the `user_sessions:{userId}` secondary index covers "list / revoke per user" without a Postgres mirror.
|
||||||
* Bad, because we now manage two long-lived secrets (`SESSION_SECRET`, `SESSION_ENCRYPTION_KEY`) — both must be backed up, rotated, and distributed safely. The rotation procedure is a real operational item (future ADR).
|
- Bad, because we now manage two long-lived secrets (`SESSION_SECRET`, `SESSION_ENCRYPTION_KEY`) — both must be backed up, rotated, and distributed safely. The rotation procedure is a real operational item (future ADR).
|
||||||
* Bad, because Sentinel deployment, monitoring, and persistence are non-trivial — flagged for the phase-3 infrastructure ADR; this is real ops work, not a footnote.
|
- Bad, because Sentinel deployment, monitoring, and persistence are non-trivial — flagged for the phase-3 infrastructure ADR; this is real ops work, not a footnote.
|
||||||
* Bad, because 30-min idle is short for tasks like writing a long form. Mitigation: the SPA can fire a lightweight heartbeat to `/auth/me` (already an existing route) on user activity to keep the session warm; this remains a UX detail, not an architectural one.
|
- Bad, because 30-min idle is short for tasks like writing a long form. Mitigation: the SPA can fire a lightweight heartbeat to `/auth/me` (already an existing route) on user activity to keep the session warm; this remains a UX detail, not an architectural one.
|
||||||
* Bad, because the secondary `user_sessions:{userId}` index adds two extra Redis writes per session lifecycle event and must be kept consistent — mitigated by treating it as best-effort (orphan entries are tolerated; expired session ids are cleaned during the next list/revoke operation).
|
- Bad, because the secondary `user_sessions:{userId}` index adds two extra Redis writes per session lifecycle event and must be kept consistent — mitigated by treating it as best-effort (orphan entries are tolerated; expired session ids are cleaned during the next list/revoke operation).
|
||||||
* Neutral, because no Postgres mirror means "active sessions" exists only in Redis; if Redis is wiped, all users are logged out. That is the intended behaviour — sessions are ephemeral by design.
|
- Neutral, because no Postgres mirror means "active sessions" exists only in Redis; if Redis is wiped, all users are logged out. That is the intended behaviour — sessions are ephemeral by design.
|
||||||
|
|
||||||
### Confirmation
|
### Confirmation
|
||||||
|
|
||||||
* `apps/portal-bff/src/session/session.module.ts` configures `express-session` + `connect-redis` against the env-provided Redis target.
|
- `apps/portal-bff/src/session/session.module.ts` configures `express-session` + `connect-redis` against the env-provided Redis target.
|
||||||
* The session store is wrapped in a `RedisSessionStore` that applies AES-256-GCM encryption to the `tokens` field on `set`/`touch` and decryption on `get`. The wrapper rejects (and logs as an audit event) any record whose authentication tag fails to verify — this catches both tampering and a wrong key.
|
- The session store is wrapped in a `RedisSessionStore` that applies AES-256-GCM encryption to the `tokens` field on `set`/`touch` and decryption on `get`. The wrapper rejects (and logs as an audit event) any record whose authentication tag fails to verify — this catches both tampering and a wrong key.
|
||||||
* The session id is generated via `crypto.randomBytes(32).toString('base64url')`.
|
- The session id is generated via `crypto.randomBytes(32).toString('base64url')`.
|
||||||
* Cookie name `__Host-portal_session` (per ADR-0009); cookie attributes asserted by integration tests.
|
- Cookie name `__Host-portal_session` (per ADR-0009); cookie attributes asserted by integration tests.
|
||||||
* `absoluteExpiresAt` is checked in a global NestJS interceptor before any controller logic; expiry triggers `DEL` and 401.
|
- `absoluteExpiresAt` is checked in a global NestJS interceptor before any controller logic; expiry triggers `DEL` and 401.
|
||||||
* `user_sessions:{userId}` membership is maintained on session create / destroy; "log out everywhere" is exposed as a controller method on an admin module (future).
|
- `user_sessions:{userId}` membership is maintained on session create / destroy; "log out everywhere" is exposed as a controller method on an admin module (future).
|
||||||
* Redis client is `ioredis`; in prod, configured via Sentinel with TLS; in dev, against `REDIS_URL`.
|
- Redis client is `ioredis`; in prod, configured via Sentinel with TLS; in dev, against `REDIS_URL`.
|
||||||
* Integration tests cover: login → session created; subsequent request → idle TTL refreshed; 30 min idle → 401; 12 h elapsed → 401 even under activity; logout → key deleted; tampered token blob → reject + audit; admin force-logout → all sessions for the target user deleted.
|
- Integration tests cover: login → session created; subsequent request → idle TTL refreshed; 30 min idle → 401; 12 h elapsed → 401 even under activity; logout → key deleted; tampered token blob → reject + audit; admin force-logout → all sessions for the target user deleted.
|
||||||
* `helmet` and the BFF startup checks reject missing or malformed `SESSION_SECRET`, `SESSION_ENCRYPTION_KEY`.
|
- `helmet` and the BFF startup checks reject missing or malformed `SESSION_SECRET`, `SESSION_ENCRYPTION_KEY`.
|
||||||
|
|
||||||
## Pros and Cons of the Options
|
## Pros and Cons of the Options
|
||||||
|
|
||||||
@@ -161,102 +172,102 @@ The BFF refuses to start if any required variable is missing or malformed (e.g.
|
|||||||
|
|
||||||
#### Redis self-hosted (chosen)
|
#### Redis self-hosted (chosen)
|
||||||
|
|
||||||
* Good, because in-memory fast, native TTL, sub-ms latency on session reads on the hot path.
|
- Good, because in-memory fast, native TTL, sub-ms latency on session reads on the hot path.
|
||||||
* Good, because immediate revocation, atomic operations, mature ecosystem.
|
- Good, because immediate revocation, atomic operations, mature ecosystem.
|
||||||
* Good, because aligned with the on-prem constraint without a SaaS dependency.
|
- Good, because aligned with the on-prem constraint without a SaaS dependency.
|
||||||
* Bad, because operating a Sentinel cluster is non-trivial — flagged for the phase-3 infra ADR.
|
- Bad, because operating a Sentinel cluster is non-trivial — flagged for the phase-3 infra ADR.
|
||||||
|
|
||||||
#### PostgreSQL session table
|
#### PostgreSQL session table
|
||||||
|
|
||||||
* Good, because we already need PostgreSQL ([ADR-0006](0006-persistence-postgresql-prisma.md)) — no new component.
|
- Good, because we already need PostgreSQL ([ADR-0006](0006-persistence-postgresql-prisma.md)) — no new component.
|
||||||
* Bad, because every authenticated request would hit a relational DB for a hot, ephemeral key-value lookup. Slower, more contention, less natural TTL.
|
- Bad, because every authenticated request would hit a relational DB for a hot, ephemeral key-value lookup. Slower, more contention, less natural TTL.
|
||||||
* Bad, because mixing transient session state with durable business data conflates concerns.
|
- Bad, because mixing transient session state with durable business data conflates concerns.
|
||||||
|
|
||||||
#### In-memory
|
#### In-memory
|
||||||
|
|
||||||
* Bad, because no HA, sessions lost at every restart, scale-out impossible.
|
- Bad, because no HA, sessions lost at every restart, scale-out impossible.
|
||||||
|
|
||||||
#### Cookie-session (signed payload in cookie)
|
#### Cookie-session (signed payload in cookie)
|
||||||
|
|
||||||
* Good, because stateless server.
|
- Good, because stateless server.
|
||||||
* Bad, because revocation is impossible without an extra blacklist; the session keeps "working" until the cookie naturally expires. Unacceptable for a security-sensitive portal.
|
- Bad, because revocation is impossible without an extra blacklist; the session keeps "working" until the cookie naturally expires. Unacceptable for a security-sensitive portal.
|
||||||
* Bad, because the cookie carrying tokens (even encrypted) is sent on every request — header bloat and a more attractive target.
|
- Bad, because the cookie carrying tokens (even encrypted) is sent on every request — header bloat and a more attractive target.
|
||||||
|
|
||||||
### NestJS integration
|
### NestJS integration
|
||||||
|
|
||||||
#### `express-session` + `connect-redis` (chosen)
|
#### `express-session` + `connect-redis` (chosen)
|
||||||
|
|
||||||
* Good, because the de facto standard pair in Node, mature, well-understood.
|
- Good, because the de facto standard pair in Node, mature, well-understood.
|
||||||
* Good, because runs natively under the Express adapter NestJS already uses.
|
- Good, because runs natively under the Express adapter NestJS already uses.
|
||||||
* Bad, because two libraries to keep up to date — manageable.
|
- Bad, because two libraries to keep up to date — manageable.
|
||||||
|
|
||||||
#### Custom Redis service
|
#### Custom Redis service
|
||||||
|
|
||||||
* Bad, because reinvents `connect-redis` poorly. Bricolage on a security path.
|
- Bad, because reinvents `connect-redis` poorly. Bricolage on a security path.
|
||||||
|
|
||||||
#### NestJS-specific wrapper
|
#### NestJS-specific wrapper
|
||||||
|
|
||||||
* Neutral, because adds nothing meaningful over the `express-session` + `connect-redis` baseline.
|
- Neutral, because adds nothing meaningful over the `express-session` + `connect-redis` baseline.
|
||||||
|
|
||||||
### Session ID format
|
### Session ID format
|
||||||
|
|
||||||
#### Opaque random (chosen)
|
#### Opaque random (chosen)
|
||||||
|
|
||||||
* Good, because zero information leaked client-side; revocation is just a `DEL`.
|
- Good, because zero information leaked client-side; revocation is just a `DEL`.
|
||||||
* Good, because compatible with `__Host-portal_session` and the BFF pattern.
|
- Good, because compatible with `__Host-portal_session` and the BFF pattern.
|
||||||
|
|
||||||
#### JWT-as-session-id
|
#### JWT-as-session-id
|
||||||
|
|
||||||
* Bad, because brittle (signature, claims surface), revocation requires a blacklist, and exposes structure that opaque IDs hide.
|
- Bad, because brittle (signature, claims surface), revocation requires a blacklist, and exposes structure that opaque IDs hide.
|
||||||
|
|
||||||
### Encryption at rest
|
### Encryption at rest
|
||||||
|
|
||||||
#### AES-256-GCM (chosen)
|
#### AES-256-GCM (chosen)
|
||||||
|
|
||||||
* Good, because authenticated encryption — tamper detection comes for free.
|
- Good, because authenticated encryption — tamper detection comes for free.
|
||||||
* Good, because defends against Redis exfiltration scenarios that go beyond the network/ACL boundary.
|
- Good, because defends against Redis exfiltration scenarios that go beyond the network/ACL boundary.
|
||||||
* Bad, because adds a second secret to manage and rotate.
|
- Bad, because adds a second secret to manage and rotate.
|
||||||
|
|
||||||
#### No application-level encryption
|
#### No application-level encryption
|
||||||
|
|
||||||
* Bad, because relies entirely on Redis being uncompromisable. Anyone who reads a memory dump or RDB snapshot gets working tokens.
|
- Bad, because relies entirely on Redis being uncompromisable. Anyone who reads a memory dump or RDB snapshot gets working tokens.
|
||||||
|
|
||||||
### TTL policy
|
### TTL policy
|
||||||
|
|
||||||
#### 30 min idle / 12 h absolute (chosen)
|
#### 30 min idle / 12 h absolute (chosen)
|
||||||
|
|
||||||
* Good, because aligned with enterprise practice and well within Entra refresh-token lifetimes.
|
- Good, because aligned with enterprise practice and well within Entra refresh-token lifetimes.
|
||||||
* Good, because limits the window of damage from a leaked session id.
|
- Good, because limits the window of damage from a leaked session id.
|
||||||
* Bad, because mildly UX-disruptive on long inactive periods (mitigated by heartbeat).
|
- Bad, because mildly UX-disruptive on long inactive periods (mitigated by heartbeat).
|
||||||
|
|
||||||
#### 15 min idle / 8 h absolute
|
#### 15 min idle / 8 h absolute
|
||||||
|
|
||||||
* Good, because tighter security.
|
- Good, because tighter security.
|
||||||
* Bad, because more user friction; would require explicit UX consideration.
|
- Bad, because more user friction; would require explicit UX consideration.
|
||||||
|
|
||||||
#### 60 min idle / 24 h absolute
|
#### 60 min idle / 24 h absolute
|
||||||
|
|
||||||
* Good, because more user-friendly.
|
- Good, because more user-friendly.
|
||||||
* Bad, because doubles the risk window after credential loss / session theft.
|
- Bad, because doubles the risk window after credential loss / session theft.
|
||||||
|
|
||||||
### Production topology
|
### Production topology
|
||||||
|
|
||||||
#### Redis Sentinel HA (chosen)
|
#### Redis Sentinel HA (chosen)
|
||||||
|
|
||||||
* Good, because automatic failover, replica reads possible, simple to reason about.
|
- Good, because automatic failover, replica reads possible, simple to reason about.
|
||||||
* Good, because matches the operational profile of an on-prem deployment.
|
- Good, because matches the operational profile of an on-prem deployment.
|
||||||
|
|
||||||
#### Redis Cluster
|
#### Redis Cluster
|
||||||
|
|
||||||
* Good, because scales beyond a single master.
|
- Good, because scales beyond a single master.
|
||||||
* Bad, because more complex; we don't need cross-shard scaling for sessions at expected volume.
|
- Bad, because more complex; we don't need cross-shard scaling for sessions at expected volume.
|
||||||
|
|
||||||
## More Information
|
## More Information
|
||||||
|
|
||||||
* OWASP Session Management Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html
|
- OWASP Session Management Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html
|
||||||
* `express-session`: https://github.com/expressjs/session
|
- `express-session`: https://github.com/expressjs/session
|
||||||
* `connect-redis`: https://github.com/tj/connect-redis
|
- `connect-redis`: https://github.com/tj/connect-redis
|
||||||
* `ioredis`: https://github.com/redis/ioredis
|
- `ioredis`: https://github.com/redis/ioredis
|
||||||
* Redis Sentinel: https://redis.io/docs/management/sentinel/
|
- Redis Sentinel: https://redis.io/docs/management/sentinel/
|
||||||
* AES-GCM (Node `crypto.createCipheriv`): https://nodejs.org/api/crypto.html#class-cipheriv
|
- AES-GCM (Node `crypto.createCipheriv`): https://nodejs.org/api/crypto.html#class-cipheriv
|
||||||
* Related ADRs: [ADR-0005](0005-backend-stack-nestjs.md) (NestJS on Express adapter), [ADR-0008](0008-identity-model-entra-workforce-dual-audience.md) (identity model), [ADR-0009](0009-auth-flow-oidc-pkce-msal-node.md) (auth flow), and the future ADRs for MFA enforcement, audit trail, and on-prem infrastructure (Sentinel deployment specifics, secret rotation procedure).
|
- Related ADRs: [ADR-0005](0005-backend-stack-nestjs.md) (NestJS on Express adapter), [ADR-0008](0008-identity-model-entra-workforce-dual-audience.md) (identity model), [ADR-0009](0009-auth-flow-oidc-pkce-msal-node.md) (auth flow), and the future ADRs for MFA enforcement, audit trail, and on-prem infrastructure (Sentinel deployment specifics, secret rotation procedure).
|
||||||
+63
-60
@@ -9,47 +9,50 @@ tags: [security]
|
|||||||
|
|
||||||
## Context and Problem Statement
|
## Context and Problem Statement
|
||||||
|
|
||||||
Multi-factor authentication is mandatory for the portal's workforce audience. The question is *where* MFA logic lives, *how* the BFF gains assurance that an authenticated session has actually been multi-factored, and *how* the application is prepared for future step-up MFA on sensitive operations — without writing any of it in v1.
|
Multi-factor authentication is mandatory for the portal's workforce audience. The question is _where_ MFA logic lives, _how_ the BFF gains assurance that an authenticated session has actually been multi-factored, and _how_ the application is prepared for future step-up MFA on sensitive operations — without writing any of it in v1.
|
||||||
|
|
||||||
Identity is Microsoft Entra ID ([ADR-0008](0008-identity-model-entra-workforce-dual-audience.md)) and the auth flow is OIDC Authorization Code + PKCE via MSAL Node ([ADR-0009](0009-auth-flow-oidc-pkce-msal-node.md)). The v1 portal has no admin UI and no defined "sensitive" operations beyond authentication itself, so step-up MFA is a design concern, not a v1 implementation concern.
|
Identity is Microsoft Entra ID ([ADR-0008](0008-identity-model-entra-workforce-dual-audience.md)) and the auth flow is OIDC Authorization Code + PKCE via MSAL Node ([ADR-0009](0009-auth-flow-oidc-pkce-msal-node.md)). The v1 portal has no admin UI and no defined "sensitive" operations beyond authentication itself, so step-up MFA is a design concern, not a v1 implementation concern.
|
||||||
|
|
||||||
## Decision Drivers
|
## Decision Drivers
|
||||||
|
|
||||||
* MFA is a non-negotiable security baseline for workforce.
|
- MFA is a non-negotiable security baseline for workforce.
|
||||||
* Application code must not implement MFA mechanics — that belongs in the IdP. "Anti-bricolage" applies particularly here.
|
- Application code must not implement MFA mechanics — that belongs in the IdP. "Anti-bricolage" applies particularly here.
|
||||||
* The mechanism chosen at sign-in must be auditable in the session (was MFA actually performed?).
|
- The mechanism chosen at sign-in must be auditable in the session (was MFA actually performed?).
|
||||||
* The architecture must support future step-up MFA (a sensitive operation requires *fresh* MFA, beyond what was done at sign-in) without retrofitting authorization.
|
- The architecture must support future step-up MFA (a sensitive operation requires _fresh_ MFA, beyond what was done at sign-in) without retrofitting authorization.
|
||||||
* Configuration responsibility must be clearly separated: the IdP (org IT) owns the policy; the BFF owns the verification.
|
- Configuration responsibility must be clearly separated: the IdP (org IT) owns the policy; the BFF owns the verification.
|
||||||
|
|
||||||
## Considered Options
|
## Considered Options
|
||||||
|
|
||||||
### Where MFA is enforced
|
### Where MFA is enforced
|
||||||
* **Microsoft Entra Conditional Access policy at the tenant level.** (Chosen.)
|
|
||||||
* MFA logic in the BFF (e.g., a second-factor prompt after password).
|
- **Microsoft Entra Conditional Access policy at the tenant level.** (Chosen.)
|
||||||
|
- MFA logic in the BFF (e.g., a second-factor prompt after password).
|
||||||
|
|
||||||
### How the BFF validates that MFA happened
|
### How the BFF validates that MFA happened
|
||||||
* **Sanity-check the `amr` claim of the id_token at session creation.** (Chosen.)
|
|
||||||
* Require an explicit Authentication Context Class Reference (ACR) value via `acr_values`/`claims` request parameter.
|
- **Sanity-check the `amr` claim of the id_token at session creation.** (Chosen.)
|
||||||
* Trust Entra unconditionally without verification.
|
- Require an explicit Authentication Context Class Reference (ACR) value via `acr_values`/`claims` request parameter.
|
||||||
|
- Trust Entra unconditionally without verification.
|
||||||
|
|
||||||
### Step-up MFA in v1
|
### Step-up MFA in v1
|
||||||
* **Designed-in via a `@RequireMfa()` decorator and a claims-challenge mechanism, but not applied to any v1 route.** (Chosen.)
|
|
||||||
* Built and exercised in v1 — premature, no consumer.
|
- **Designed-in via a `@RequireMfa()` decorator and a claims-challenge mechanism, but not applied to any v1 route.** (Chosen.)
|
||||||
* Deferred entirely (no decorator, no hook) — costly to retrofit.
|
- Built and exercised in v1 — premature, no consumer.
|
||||||
|
- Deferred entirely (no decorator, no hook) — costly to retrofit.
|
||||||
|
|
||||||
## Decision Outcome
|
## Decision Outcome
|
||||||
|
|
||||||
**MFA enforcement** lives in **Microsoft Entra Conditional Access**. The org's IT/identity contact configures a Conditional Access policy at the tenant level that requires MFA for any sign-in to the BFF's app registration, for both the prod tenant and the M365 Developer tenant used for non-prod. Recommended settings (org-side, not in our code):
|
**MFA enforcement** lives in **Microsoft Entra Conditional Access**. The org's IT/identity contact configures a Conditional Access policy at the tenant level that requires MFA for any sign-in to the BFF's app registration, for both the prod tenant and the M365 Developer tenant used for non-prod. Recommended settings (org-side, not in our code):
|
||||||
|
|
||||||
- *Assignment.* Cloud apps → the BFF app. Users → All users (or "All workforce", depending on how the org structures groups). Excluded: break-glass accounts, by Microsoft's own recommendation.
|
- _Assignment._ Cloud apps → the BFF app. Users → All users (or "All workforce", depending on how the org structures groups). Excluded: break-glass accounts, by Microsoft's own recommendation.
|
||||||
- *Conditions* (recommended, not strictly required): device compliance, location, sign-in risk (Entra ID P2 for risk-based — opt-in).
|
- _Conditions_ (recommended, not strictly required): device compliance, location, sign-in risk (Entra ID P2 for risk-based — opt-in).
|
||||||
- *Grant.* Require multi-factor authentication. Prefer phishing-resistant methods (FIDO2 / passkeys / Windows Hello for Business). Fall back to authenticator-app push or TOTP. SMS/voice deprecated, to be avoided.
|
- _Grant._ Require multi-factor authentication. Prefer phishing-resistant methods (FIDO2 / passkeys / Windows Hello for Business). Fall back to authenticator-app push or TOTP. SMS/voice deprecated, to be avoided.
|
||||||
|
|
||||||
This policy is **out of scope for application code**. Our app does not implement MFA — it consumes the MFA outcome.
|
This policy is **out of scope for application code**. Our app does not implement MFA — it consumes the MFA outcome.
|
||||||
|
|
||||||
**BFF verification.** At session creation (after the OIDC callback exchange and id_token validation), the BFF performs a sanity-check on the `amr` claim of the id_token. The session is rejected with a 401 and an explicit error if the claim is absent, empty, or contains only password-class values without an MFA-class indicator. The expected normal value emitted by Entra after a CA-enforced MFA sign-in is `["pwd","mfa"]` (or equivalent depending on the factor).
|
**BFF verification.** At session creation (after the OIDC callback exchange and id_token validation), the BFF performs a sanity-check on the `amr` claim of the id_token. The session is rejected with a 401 and an explicit error if the claim is absent, empty, or contains only password-class values without an MFA-class indicator. The expected normal value emitted by Entra after a CA-enforced MFA sign-in is `["pwd","mfa"]` (or equivalent depending on the factor).
|
||||||
|
|
||||||
This is *defense in depth*, not the primary control: the primary control is the CA policy. The sanity-check exists so that a misconfigured or disabled CA policy does not silently regress the security posture without anyone noticing — the BFF will simply refuse to grant sessions if MFA assurance is missing.
|
This is _defense in depth_, not the primary control: the primary control is the CA policy. The sanity-check exists so that a misconfigured or disabled CA policy does not silently regress the security posture without anyone noticing — the BFF will simply refuse to grant sessions if MFA assurance is missing.
|
||||||
|
|
||||||
A small validation table maintained in the BFF (in source, reviewed in PRs) lists the `amr` values accepted as multi-factor:
|
A small validation table maintained in the BFF (in source, reviewed in PRs) lists the `amr` values accepted as multi-factor:
|
||||||
|
|
||||||
@@ -64,35 +67,35 @@ This list is reviewed against Microsoft's documentation as part of the security
|
|||||||
**Step-up MFA — designed-in, dormant.** A `@RequireMfa()` controller-method decorator and an associated guard are implemented in v1, but no v1 route uses them. The guard, when active, will:
|
**Step-up MFA — designed-in, dormant.** A `@RequireMfa()` controller-method decorator and an associated guard are implemented in v1, but no v1 route uses them. The guard, when active, will:
|
||||||
|
|
||||||
1. Inspect the session for a recent MFA assertion (a `mfaVerifiedAt` timestamp added to the session payload, set to `lastSeenAt` of the auth callback).
|
1. Inspect the session for a recent MFA assertion (a `mfaVerifiedAt` timestamp added to the session payload, set to `lastSeenAt` of the auth callback).
|
||||||
2. If the timestamp is older than a configurable freshness window (`MFA_FRESHNESS_SECONDS`, default 600 = 10 min), respond with 401 + a `WWW-Authenticate` header carrying a *claims challenge* (an opaque blob produced by MSAL Node).
|
2. If the timestamp is older than a configurable freshness window (`MFA_FRESHNESS_SECONDS`, default 600 = 10 min), respond with 401 + a `WWW-Authenticate` header carrying a _claims challenge_ (an opaque blob produced by MSAL Node).
|
||||||
3. The Angular SPA HTTP interceptor catches the 401 + claims challenge, redirects the browser to `/auth/login?claims=<challenge>`, which forwards the challenge to Entra. Entra re-prompts MFA, returns a fresher `amr`/`mfaVerifiedAt`, and the original request is retried.
|
3. The Angular SPA HTTP interceptor catches the 401 + claims challenge, redirects the browser to `/auth/login?claims=<challenge>`, which forwards the challenge to Entra. Entra re-prompts MFA, returns a fresher `amr`/`mfaVerifiedAt`, and the original request is retried.
|
||||||
|
|
||||||
Authentication Context Classes (`acr_values`) are *not* used in v1. The freshness-timestamp approach is sufficient for the baseline. ACR-based step-up (e.g., "this action requires CA policy `c2`") will be considered later, gated behind a follow-up ADR if specific operations demand it.
|
Authentication Context Classes (`acr_values`) are _not_ used in v1. The freshness-timestamp approach is sufficient for the baseline. ACR-based step-up (e.g., "this action requires CA policy `c2`") will be considered later, gated behind a follow-up ADR if specific operations demand it.
|
||||||
|
|
||||||
**Service accounts / app-only tokens.** Out of scope. The BFF authenticates end-users via OIDC; service-to-service tokens (if needed for downstream APIs in a future ADR) follow a different flow — On-Behalf-Of or app-only — and have their own MFA story (i.e., none, because no human is involved).
|
**Service accounts / app-only tokens.** Out of scope. The BFF authenticates end-users via OIDC; service-to-service tokens (if needed for downstream APIs in a future ADR) follow a different flow — On-Behalf-Of or app-only — and have their own MFA story (i.e., none, because no human is involved).
|
||||||
|
|
||||||
### Consequences
|
### Consequences
|
||||||
|
|
||||||
* Good, because MFA mechanics live where they belong (the IdP). The application is not in the business of implementing OTP delivery, push notifications, or hardware-key challenges.
|
- Good, because MFA mechanics live where they belong (the IdP). The application is not in the business of implementing OTP delivery, push notifications, or hardware-key challenges.
|
||||||
* Good, because the IdP configuration can evolve independently — adding device compliance, sign-in risk, location-based rules, or moving to phishing-resistant-only — without code changes.
|
- Good, because the IdP configuration can evolve independently — adding device compliance, sign-in risk, location-based rules, or moving to phishing-resistant-only — without code changes.
|
||||||
* Good, because the `amr` sanity-check in the BFF catches a category of misconfiguration that would otherwise silently regress security.
|
- Good, because the `amr` sanity-check in the BFF catches a category of misconfiguration that would otherwise silently regress security.
|
||||||
* Good, because step-up MFA is designed-in via a single decorator. When a sensitive operation appears in a future feature, marking it requires only `@RequireMfa()` — no architectural change.
|
- Good, because step-up MFA is designed-in via a single decorator. When a sensitive operation appears in a future feature, marking it requires only `@RequireMfa()` — no architectural change.
|
||||||
* Good, because separating freshness-based step-up (default) from ACR-based step-up (advanced) keeps v1 simple while leaving a clean upgrade path.
|
- Good, because separating freshness-based step-up (default) from ACR-based step-up (advanced) keeps v1 simple while leaving a clean upgrade path.
|
||||||
* Bad, because the `amr` value list is implicitly tied to Entra's emitted values; if Microsoft introduces a new factor with a new `amr` token, the BFF rejects sessions until the list is updated. Mitigated by a documented review cadence.
|
- Bad, because the `amr` value list is implicitly tied to Entra's emitted values; if Microsoft introduces a new factor with a new `amr` token, the BFF rejects sessions until the list is updated. Mitigated by a documented review cadence.
|
||||||
* Bad, because step-up MFA needs SPA cooperation (the HTTP interceptor that handles 401 + claims challenge); if a future SPA feature bypasses the interceptor, step-up will silently degrade to plain 401. Mitigated by integration tests on the interceptor.
|
- Bad, because step-up MFA needs SPA cooperation (the HTTP interceptor that handles 401 + claims challenge); if a future SPA feature bypasses the interceptor, step-up will silently degrade to plain 401. Mitigated by integration tests on the interceptor.
|
||||||
* Bad, because Conditional Access policies — especially advanced ones — require Entra ID P1 (and P2 for risk-based). Already flagged in [ADR-0008](0008-identity-model-entra-workforce-dual-audience.md), restated here.
|
- Bad, because Conditional Access policies — especially advanced ones — require Entra ID P1 (and P2 for risk-based). Already flagged in [ADR-0008](0008-identity-model-entra-workforce-dual-audience.md), restated here.
|
||||||
* Neutral, because v1 does not exercise the step-up path. Hooks must be kept alive by automated tests, not by being used in production traffic.
|
- Neutral, because v1 does not exercise the step-up path. Hooks must be kept alive by automated tests, not by being used in production traffic.
|
||||||
|
|
||||||
### Confirmation
|
### Confirmation
|
||||||
|
|
||||||
* `apps/portal-bff/src/auth/mfa.ts` exports the `MFA_AMR_VALUES` constant and a `wasMultiFactor(claims): boolean` helper.
|
- `apps/portal-bff/src/auth/mfa.ts` exports the `MFA_AMR_VALUES` constant and a `wasMultiFactor(claims): boolean` helper.
|
||||||
* The OIDC callback path rejects sessions for which `wasMultiFactor(idTokenClaims)` is false, with a structured 401 (logged as an audit event in the future audit-log ADR).
|
- The OIDC callback path rejects sessions for which `wasMultiFactor(idTokenClaims)` is false, with a structured 401 (logged as an audit event in the future audit-log ADR).
|
||||||
* Each session payload carries `mfaVerifiedAt: number` (epoch ms), set at the callback and refreshed on any subsequent step-up auth completion.
|
- Each session payload carries `mfaVerifiedAt: number` (epoch ms), set at the callback and refreshed on any subsequent step-up auth completion.
|
||||||
* `apps/portal-bff/src/auth/require-mfa.guard.ts` provides the `RequireMfaGuard` and the `@RequireMfa()` decorator (on top of the global `AuthGuard` from ADR-0009).
|
- `apps/portal-bff/src/auth/require-mfa.guard.ts` provides the `RequireMfaGuard` and the `@RequireMfa()` decorator (on top of the global `AuthGuard` from ADR-0009).
|
||||||
* `MFA_FRESHNESS_SECONDS` is read from env, defaulting to 600. The BFF refuses to start with a value below 60 (to catch misconfiguration).
|
- `MFA_FRESHNESS_SECONDS` is read from env, defaulting to 600. The BFF refuses to start with a value below 60 (to catch misconfiguration).
|
||||||
* The Angular HTTP interceptor in `portal-shell` handles `401` + `WWW-Authenticate: Bearer error="insufficient_user_authentication"` (or the claims-challenge equivalent) and redirects to `/auth/login` with the challenge propagated.
|
- The Angular HTTP interceptor in `portal-shell` handles `401` + `WWW-Authenticate: Bearer error="insufficient_user_authentication"` (or the claims-challenge equivalent) and redirects to `/auth/login` with the challenge propagated.
|
||||||
* Integration tests cover: a sign-in without MFA assertion is rejected; a sign-in with valid MFA assertion succeeds; a route protected by `@RequireMfa()` accepts a fresh session, rejects with claims challenge after `MFA_FRESHNESS_SECONDS`, and accepts again after re-auth.
|
- Integration tests cover: a sign-in without MFA assertion is rejected; a sign-in with valid MFA assertion succeeds; a route protected by `@RequireMfa()` accepts a fresh session, rejects with claims challenge after `MFA_FRESHNESS_SECONDS`, and accepts again after re-auth.
|
||||||
* The org-side Conditional Access policy is documented in an operational runbook (out of repo, owned by IT) and reviewed during onboarding of each tenant (prod + dev).
|
- The org-side Conditional Access policy is documented in an operational runbook (out of repo, owned by IT) and reviewed during onboarding of each tenant (prod + dev).
|
||||||
|
|
||||||
## Pros and Cons of the Options
|
## Pros and Cons of the Options
|
||||||
|
|
||||||
@@ -100,54 +103,54 @@ Authentication Context Classes (`acr_values`) are *not* used in v1. The freshnes
|
|||||||
|
|
||||||
#### Conditional Access (chosen)
|
#### Conditional Access (chosen)
|
||||||
|
|
||||||
* Good, because it is the Microsoft-native, audited, IdP-managed mechanism — exactly the kind of "recognised, battle-tested" choice the project values.
|
- Good, because it is the Microsoft-native, audited, IdP-managed mechanism — exactly the kind of "recognised, battle-tested" choice the project values.
|
||||||
* Good, because changes to MFA policy are a tenant-level config, not a deployment.
|
- Good, because changes to MFA policy are a tenant-level config, not a deployment.
|
||||||
* Good, because the IdP catalogue of factors evolves without us — passkeys, FIDO2, certificate-based, etc.
|
- Good, because the IdP catalogue of factors evolves without us — passkeys, FIDO2, certificate-based, etc.
|
||||||
* Bad, because it requires Entra ID P1 minimum at the tenant level (cost flagged in ADR-0008).
|
- Bad, because it requires Entra ID P1 minimum at the tenant level (cost flagged in ADR-0008).
|
||||||
|
|
||||||
#### MFA logic in the BFF
|
#### MFA logic in the BFF
|
||||||
|
|
||||||
* Bad, because it would re-implement OTP/push/key flows the IdP already provides — exactly the bricolage we are forbidden.
|
- Bad, because it would re-implement OTP/push/key flows the IdP already provides — exactly the bricolage we are forbidden.
|
||||||
* Bad, because secret material (TOTP shared secrets, etc.) ends up in our Postgres, expanding the attack surface.
|
- Bad, because secret material (TOTP shared secrets, etc.) ends up in our Postgres, expanding the attack surface.
|
||||||
|
|
||||||
### How the BFF validates MFA
|
### How the BFF validates MFA
|
||||||
|
|
||||||
#### `amr` sanity-check (chosen)
|
#### `amr` sanity-check (chosen)
|
||||||
|
|
||||||
* Good, because cheap, immediate, no extra round trip.
|
- Good, because cheap, immediate, no extra round trip.
|
||||||
* Good, because catches a real failure mode (CA policy disabled, scope misconfigured).
|
- Good, because catches a real failure mode (CA policy disabled, scope misconfigured).
|
||||||
* Bad, because dependent on Entra's `amr` emission values — list maintenance required.
|
- Bad, because dependent on Entra's `amr` emission values — list maintenance required.
|
||||||
|
|
||||||
#### Required ACR via `claims` parameter
|
#### Required ACR via `claims` parameter
|
||||||
|
|
||||||
* Good, because explicit and machine-verifiable per request.
|
- Good, because explicit and machine-verifiable per request.
|
||||||
* Bad, because requires Authentication Context Classes to be configured tenant-side and exchanged on every request — heavy for a baseline "MFA at sign-in" need. Re-evaluate when fine-grained step-up arrives.
|
- Bad, because requires Authentication Context Classes to be configured tenant-side and exchanged on every request — heavy for a baseline "MFA at sign-in" need. Re-evaluate when fine-grained step-up arrives.
|
||||||
|
|
||||||
#### No verification
|
#### No verification
|
||||||
|
|
||||||
* Bad, because a CA policy disabled by mistake would silently regress security with no audit signal until an incident.
|
- Bad, because a CA policy disabled by mistake would silently regress security with no audit signal until an incident.
|
||||||
|
|
||||||
### Step-up MFA in v1
|
### Step-up MFA in v1
|
||||||
|
|
||||||
#### Designed-in via decorator, dormant (chosen)
|
#### Designed-in via decorator, dormant (chosen)
|
||||||
|
|
||||||
* Good, because the cost is minimal: one guard, one decorator, one timestamp in the session.
|
- Good, because the cost is minimal: one guard, one decorator, one timestamp in the session.
|
||||||
* Good, because step-up is a one-line annotation on any future sensitive route.
|
- Good, because step-up is a one-line annotation on any future sensitive route.
|
||||||
* Bad, because a dormant code path drifts unless covered by tests — addressed in the Confirmation section.
|
- Bad, because a dormant code path drifts unless covered by tests — addressed in the Confirmation section.
|
||||||
|
|
||||||
#### Built and exercised in v1
|
#### Built and exercised in v1
|
||||||
|
|
||||||
* Bad, because no v1 route is sensitive enough to require it. Building a feature with no consumer is bricolage by another name.
|
- Bad, because no v1 route is sensitive enough to require it. Building a feature with no consumer is bricolage by another name.
|
||||||
|
|
||||||
#### Deferred entirely
|
#### Deferred entirely
|
||||||
|
|
||||||
* Bad, because retrofitting step-up after the fact means refactoring guards, sessions, and the SPA interceptor. Cost asymmetric vs. the dormant-hook option.
|
- Bad, because retrofitting step-up after the fact means refactoring guards, sessions, and the SPA interceptor. Cost asymmetric vs. the dormant-hook option.
|
||||||
|
|
||||||
## More Information
|
## More Information
|
||||||
|
|
||||||
* Microsoft Entra Conditional Access overview: https://learn.microsoft.com/entra/identity/conditional-access/overview
|
- Microsoft Entra Conditional Access overview: https://learn.microsoft.com/entra/identity/conditional-access/overview
|
||||||
* `amr` claim values (OpenID Connect): https://www.iana.org/assignments/authentication-method-reference-values/
|
- `amr` claim values (OpenID Connect): https://www.iana.org/assignments/authentication-method-reference-values/
|
||||||
* Microsoft `amr` documentation: https://learn.microsoft.com/azure/active-directory/develop/access-tokens
|
- Microsoft `amr` documentation: https://learn.microsoft.com/azure/active-directory/develop/access-tokens
|
||||||
* Authentication Context Classes (ACR) and claims challenges: https://learn.microsoft.com/entra/identity-platform/v2-conditional-access-dev-guide
|
- Authentication Context Classes (ACR) and claims challenges: https://learn.microsoft.com/entra/identity-platform/v2-conditional-access-dev-guide
|
||||||
* Phishing-resistant MFA (passkeys / FIDO2): https://learn.microsoft.com/entra/identity/authentication/concept-authentication-passwordless
|
- Phishing-resistant MFA (passkeys / FIDO2): https://learn.microsoft.com/entra/identity/authentication/concept-authentication-passwordless
|
||||||
* Related ADRs: [ADR-0008](0008-identity-model-entra-workforce-dual-audience.md) (identity model — P1 licensing flagged), [ADR-0009](0009-auth-flow-oidc-pkce-msal-node.md) (auth flow), [ADR-0010](0010-session-management-redis.md) (session — `mfaVerifiedAt` lives here), and the future ADRs for downstream APIs (On-Behalf-Of), audit trail (rejected-MFA logged events), and the operational runbook for tenant Conditional Access policies.
|
- Related ADRs: [ADR-0008](0008-identity-model-entra-workforce-dual-audience.md) (identity model — P1 licensing flagged), [ADR-0009](0009-auth-flow-oidc-pkce-msal-node.md) (auth flow), [ADR-0010](0010-session-management-redis.md) (session — `mfaVerifiedAt` lives here), and the future ADRs for downstream APIs (On-Behalf-Of), audit trail (rejected-MFA logged events), and the operational runbook for tenant Conditional Access policies.
|
||||||
+115
-107
@@ -11,63 +11,71 @@ tags: [observability, backend, frontend]
|
|||||||
|
|
||||||
The portal has stated security, performance, and accessibility as first-class concerns. None of the three can be operated, audited, or improved without observability built in from day one. We need a fixed answer to:
|
The portal has stated security, performance, and accessibility as first-class concerns. None of the three can be operated, audited, or improved without observability built in from day one. We need a fixed answer to:
|
||||||
|
|
||||||
- *How* application events are recorded (logs, traces);
|
- _How_ application events are recorded (logs, traces);
|
||||||
- *Which* identifier ties together what happens in the SPA, the BFF, the DB, the cache, and any downstream API call for a single user action;
|
- _Which_ identifier ties together what happens in the SPA, the BFF, the DB, the cache, and any downstream API call for a single user action;
|
||||||
- *Where* the signals are emitted, and how they reach a backend;
|
- _Where_ the signals are emitted, and how they reach a backend;
|
||||||
- *What* must never appear in a log line (PII, credentials, tokens);
|
- _What_ must never appear in a log line (PII, credentials, tokens);
|
||||||
- *How* the architecture stays vendor-neutral so the chosen on-prem backend (Grafana stack, ELK, or otherwise — phase 3) can be selected without rewriting the application.
|
- _How_ the architecture stays vendor-neutral so the chosen on-prem backend (Grafana stack, ELK, or otherwise — phase 3) can be selected without rewriting the application.
|
||||||
|
|
||||||
Two signals are in scope here: **logs** and **traces**. Metrics are deliberately out of scope; if metrics become necessary later, they fit naturally into the same OpenTelemetry pipeline and will get their own ADR.
|
Two signals are in scope here: **logs** and **traces**. Metrics are deliberately out of scope; if metrics become necessary later, they fit naturally into the same OpenTelemetry pipeline and will get their own ADR.
|
||||||
|
|
||||||
## Decision Drivers
|
## Decision Drivers
|
||||||
|
|
||||||
* A single correlation identifier across all signals — front-end click, BFF request, DB query, downstream API call, log line — debuggable in seconds, not in spelunking.
|
- A single correlation identifier across all signals — front-end click, BFF request, DB query, downstream API call, log line — debuggable in seconds, not in spelunking.
|
||||||
* Vendor-neutral standards (OpenTelemetry, W3C Trace Context) so the on-prem backend can be chosen later (phase 3) without coupling the application to it.
|
- Vendor-neutral standards (OpenTelemetry, W3C Trace Context) so the on-prem backend can be chosen later (phase 3) without coupling the application to it.
|
||||||
* Mature, enterprise-grade libraries — anti-bricolage applies particularly here.
|
- Mature, enterprise-grade libraries — anti-bricolage applies particularly here.
|
||||||
* 12-factor: the application emits to stdout / OTLP; shipping is an operational concern, not an application concern.
|
- 12-factor: the application emits to stdout / OTLP; shipping is an operational concern, not an application concern.
|
||||||
* Privacy: PII (and credentials, tokens) must never reach a log line. Defense in depth — even if a log is exfiltrated, it must contain the minimum needed for support.
|
- Privacy: PII (and credentials, tokens) must never reach a log line. Defense in depth — even if a log is exfiltrated, it must contain the minimum needed for support.
|
||||||
* The SPA must participate in the trace so the first segment (user click → first request) is not lost.
|
- The SPA must participate in the trace so the first segment (user click → first request) is not lost.
|
||||||
|
|
||||||
## Considered Options
|
## Considered Options
|
||||||
|
|
||||||
### Logging library
|
### Logging library
|
||||||
* **Pino + `nestjs-pino`.** (Chosen.)
|
|
||||||
* Winston.
|
- **Pino + `nestjs-pino`.** (Chosen.)
|
||||||
* Bunyan.
|
- Winston.
|
||||||
* Plain `console.log` / NestJS default `Logger` only.
|
- Bunyan.
|
||||||
|
- Plain `console.log` / NestJS default `Logger` only.
|
||||||
|
|
||||||
### Tracing instrumentation
|
### Tracing instrumentation
|
||||||
* **OpenTelemetry SDK + `auto-instrumentations-node`.** (Chosen.)
|
|
||||||
* Vendor SDKs (Datadog, New Relic).
|
- **OpenTelemetry SDK + `auto-instrumentations-node`.** (Chosen.)
|
||||||
* OpenTracing.
|
- Vendor SDKs (Datadog, New Relic).
|
||||||
* Custom hand-rolled tracer.
|
- OpenTracing.
|
||||||
|
- Custom hand-rolled tracer.
|
||||||
|
|
||||||
### Trace propagation header
|
### Trace propagation header
|
||||||
* **W3C Trace Context (`traceparent`, `tracestate`).** (Chosen.)
|
|
||||||
* Custom `X-Correlation-ID`.
|
- **W3C Trace Context (`traceparent`, `tracestate`).** (Chosen.)
|
||||||
* Vendor formats (B3, Datadog, Jaeger).
|
- Custom `X-Correlation-ID`.
|
||||||
|
- Vendor formats (B3, Datadog, Jaeger).
|
||||||
|
|
||||||
### Request-scoped context (correlation propagation in code)
|
### Request-scoped context (correlation propagation in code)
|
||||||
* **`nestjs-cls` over Node `AsyncLocalStorage`.** (Chosen.)
|
|
||||||
* Manual passing through every call signature.
|
- **`nestjs-cls` over Node `AsyncLocalStorage`.** (Chosen.)
|
||||||
* Direct `AsyncLocalStorage` without the NestJS wrapper.
|
- Manual passing through every call signature.
|
||||||
|
- Direct `AsyncLocalStorage` without the NestJS wrapper.
|
||||||
|
|
||||||
### Sampling
|
### Sampling
|
||||||
* **Always-on (100 %) at the application; tail sampling deferred to the collector / phase-3 ADR.** (Chosen for v1.)
|
|
||||||
* Probabilistic head sampling (e.g. 10 %).
|
- **Always-on (100 %) at the application; tail sampling deferred to the collector / phase-3 ADR.** (Chosen for v1.)
|
||||||
|
- Probabilistic head sampling (e.g. 10 %).
|
||||||
|
|
||||||
### Emission and shipping
|
### Emission and shipping
|
||||||
* **stdout (logs) + OTLP/HTTP (traces) → local OpenTelemetry Collector → on-prem backend (phase 3).** (Chosen.)
|
|
||||||
* Direct shipping from the app to the backend.
|
- **stdout (logs) + OTLP/HTTP (traces) → local OpenTelemetry Collector → on-prem backend (phase 3).** (Chosen.)
|
||||||
|
- Direct shipping from the app to the backend.
|
||||||
|
|
||||||
### Sensitive data handling in logs
|
### Sensitive data handling in logs
|
||||||
* **Pino `redact` with an explicit allowlist of paths to strip.** (Chosen.)
|
|
||||||
* Hand-rolled scrubbing per log call.
|
- **Pino `redact` with an explicit allowlist of paths to strip.** (Chosen.)
|
||||||
* No redaction.
|
- Hand-rolled scrubbing per log call.
|
||||||
|
- No redaction.
|
||||||
|
|
||||||
### Frontend tracing
|
### Frontend tracing
|
||||||
* **OpenTelemetry-Web in the Angular SPA, propagating `traceparent` on outbound HTTP.** (Chosen.)
|
|
||||||
* Front-end out of the trace.
|
- **OpenTelemetry-Web in the Angular SPA, propagating `traceparent` on outbound HTTP.** (Chosen.)
|
||||||
|
- Front-end out of the trace.
|
||||||
|
|
||||||
## Decision Outcome
|
## Decision Outcome
|
||||||
|
|
||||||
@@ -97,12 +105,12 @@ The contextual fields (`trace_id`, `span_id`, `session_id`, `user_id_hash`, `aud
|
|||||||
|
|
||||||
**Tracing.** OpenTelemetry SDK for Node (`@opentelemetry/sdk-node`) is started before NestJS bootstrap. `@opentelemetry/auto-instrumentations-node` is enabled with the modules we use: HTTP, Express, NestJS core, `pg`, `ioredis`, and Prisma (via Prisma's built-in OTel hooks). Custom spans can be created inline via `@opentelemetry/api`'s `tracer.startActiveSpan` for non-trivial business flows.
|
**Tracing.** OpenTelemetry SDK for Node (`@opentelemetry/sdk-node`) is started before NestJS bootstrap. `@opentelemetry/auto-instrumentations-node` is enabled with the modules we use: HTTP, Express, NestJS core, `pg`, `ioredis`, and Prisma (via Prisma's built-in OTel hooks). Custom spans can be created inline via `@opentelemetry/api`'s `tracer.startActiveSpan` for non-trivial business flows.
|
||||||
|
|
||||||
**Propagation.** **W3C Trace Context** is the only propagation format. The `traceparent` (and optionally `tracestate`) header carries the trace identity end to end. The `trace_id` extracted from `traceparent` *is* the correlation identifier — there is no separate `X-Correlation-ID`. If a request arrives without a `traceparent`, the SDK starts a new trace and the BFF emits the resulting `traceparent` in the response so the SPA can pick it up. Downstream HTTP calls automatically carry `traceparent` thanks to the HTTP auto-instrumentation.
|
**Propagation.** **W3C Trace Context** is the only propagation format. The `traceparent` (and optionally `tracestate`) header carries the trace identity end to end. The `trace_id` extracted from `traceparent` _is_ the correlation identifier — there is no separate `X-Correlation-ID`. If a request arrives without a `traceparent`, the SDK starts a new trace and the BFF emits the resulting `traceparent` in the response so the SPA can pick it up. Downstream HTTP calls automatically carry `traceparent` thanks to the HTTP auto-instrumentation.
|
||||||
|
|
||||||
**Request-scoped context.** `nestjs-cls` is registered globally and exposes the per-request store. At the entry of every request, an interceptor populates:
|
**Request-scoped context.** `nestjs-cls` is registered globally and exposes the per-request store. At the entry of every request, an interceptor populates:
|
||||||
|
|
||||||
| Key | Source |
|
| Key | Source |
|
||||||
| --- | --- |
|
| -------------- | ------------------------------------------------------------------------------------------------ |
|
||||||
| `trace_id` | OTel current span context |
|
| `trace_id` | OTel current span context |
|
||||||
| `span_id` | OTel current span context |
|
| `span_id` | OTel current span context |
|
||||||
| `session_id` | session lookup (ADR-0010) |
|
| `session_id` | session lookup (ADR-0010) |
|
||||||
@@ -113,11 +121,11 @@ The Pino formatter and the audit-log writer (future ADR) read from CLS — neith
|
|||||||
|
|
||||||
**`user_id_hash` salting.** A per-environment salt (`LOG_USER_ID_SALT`) is required and read from env. The hash is `sha256(userId || ":" || salt)`, base64-url-encoded. The salt is rotated as part of secret-rotation procedures (future ops ADR). Without the salt, the same `userId` always produces the same hash, which is enumerable for known userId spaces; the salt makes per-environment hashes incomparable.
|
**`user_id_hash` salting.** A per-environment salt (`LOG_USER_ID_SALT`) is required and read from env. The hash is `sha256(userId || ":" || salt)`, base64-url-encoded. The salt is rotated as part of secret-rotation procedures (future ops ADR). Without the salt, the same `userId` always produces the same hash, which is enumerable for known userId spaces; the salt makes per-environment hashes incomparable.
|
||||||
|
|
||||||
**Sampling.** v1 emits **100 % of traces** at the application. Volumes are low and end-to-end debuggability is the priority. Tail sampling (drop the boring traces, keep the slow / failed ones) is performed at the **OpenTelemetry Collector**, configured as part of the phase-3 infrastructure ADR. The application *does not* know nor care about sampling policy — moving the policy is a collector-config change.
|
**Sampling.** v1 emits **100 % of traces** at the application. Volumes are low and end-to-end debuggability is the priority. Tail sampling (drop the boring traces, keep the slow / failed ones) is performed at the **OpenTelemetry Collector**, configured as part of the phase-3 infrastructure ADR. The application _does not_ know nor care about sampling policy — moving the policy is a collector-config change.
|
||||||
|
|
||||||
**Emission and shipping.** Logs go to **stdout** as line-delimited JSON. Traces are exported via **OTLP/HTTP** to a local **OpenTelemetry Collector** instance running alongside the BFF (sidecar in container orchestration, local process in dev). The collector is the single point that knows the on-prem backend's address and credentials; the application stays decoupled. Backend choice and collector deployment topology are deferred to the phase-3 infrastructure ADR.
|
**Emission and shipping.** Logs go to **stdout** as line-delimited JSON. Traces are exported via **OTLP/HTTP** to a local **OpenTelemetry Collector** instance running alongside the BFF (sidecar in container orchestration, local process in dev). The collector is the single point that knows the on-prem backend's address and credentials; the application stays decoupled. Backend choice and collector deployment topology are deferred to the phase-3 infrastructure ADR.
|
||||||
|
|
||||||
**Redaction.** Pino's `redact` option strips a fixed list of paths from every log line *before* it is serialised:
|
**Redaction.** Pino's `redact` option strips a fixed list of paths from every log line _before_ it is serialised:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
redact: {
|
redact: {
|
||||||
@@ -146,12 +154,12 @@ The list lives in `apps/portal-bff/src/observability/log.redact.ts` and is revie
|
|||||||
|
|
||||||
The SPA does **not** ship its own logs to a backend in v1. Browser-side error reporting (uncaught exceptions, Promise rejections) is a separate concern and will get its own ADR if and when it becomes necessary; in the interim, errors are visible in the browser console for development and surface as 500-equivalents in the BFF logs once the failed request reaches the server.
|
The SPA does **not** ship its own logs to a backend in v1. Browser-side error reporting (uncaught exceptions, Promise rejections) is a separate concern and will get its own ADR if and when it becomes necessary; in the interim, errors are visible in the browser console for development and surface as 500-equivalents in the BFF logs once the failed request reaches the server.
|
||||||
|
|
||||||
**Audit logs are not application logs.** Anything that is a security-relevant audit event (sign-in, sign-out, MFA challenge, authorization deny, admin action — when one exists) is emitted via a *separate* writer and a *separate* sink, defined by the audit-trail ADR (next). The two streams may share the trace identifier and the CLS context, but they do not share the same retention, the same access controls, or the same volume profile.
|
**Audit logs are not application logs.** Anything that is a security-relevant audit event (sign-in, sign-out, MFA challenge, authorization deny, admin action — when one exists) is emitted via a _separate_ writer and a _separate_ sink, defined by the audit-trail ADR (next). The two streams may share the trace identifier and the CLS context, but they do not share the same retention, the same access controls, or the same volume profile.
|
||||||
|
|
||||||
**Configuration (env-driven).**
|
**Configuration (env-driven).**
|
||||||
|
|
||||||
| Variable | Purpose |
|
| Variable | Purpose |
|
||||||
| --- | --- |
|
| ----------------------------- | ------------------------------------------------------------------------------------- |
|
||||||
| `LOG_LEVEL` | `trace`/`debug`/`info`/`warn`/`error`/`fatal`; default `info` in prod, `debug` in dev |
|
| `LOG_LEVEL` | `trace`/`debug`/`info`/`warn`/`error`/`fatal`; default `info` in prod, `debug` in dev |
|
||||||
| `LOG_USER_ID_SALT` | per-env salt for `user_id_hash` |
|
| `LOG_USER_ID_SALT` | per-env salt for `user_id_hash` |
|
||||||
| `OTEL_SERVICE_NAME` | e.g. `portal-bff`, `portal-shell` |
|
| `OTEL_SERVICE_NAME` | e.g. `portal-bff`, `portal-shell` |
|
||||||
@@ -165,33 +173,33 @@ The BFF refuses to start if `LOG_USER_ID_SALT` or `OTEL_SERVICE_NAME` is missing
|
|||||||
|
|
||||||
### Consequences
|
### Consequences
|
||||||
|
|
||||||
* Good, because every signal carries the same `trace_id`, navigable across the SPA, the BFF, the DB, the cache, and downstream APIs once they exist. End-to-end debugging is one click in any conformant backend.
|
- Good, because every signal carries the same `trace_id`, navigable across the SPA, the BFF, the DB, the cache, and downstream APIs once they exist. End-to-end debugging is one click in any conformant backend.
|
||||||
* Good, because OpenTelemetry + W3C are vendor-neutral; the on-prem backend (Grafana / ELK / other) is chosen later without code changes.
|
- Good, because OpenTelemetry + W3C are vendor-neutral; the on-prem backend (Grafana / ELK / other) is chosen later without code changes.
|
||||||
* Good, because Pino is the fastest established Node logger; structured JSON output is directly ingestible by every backend.
|
- Good, because Pino is the fastest established Node logger; structured JSON output is directly ingestible by every backend.
|
||||||
* Good, because `nestjs-cls` removes context-threading boilerplate — the developer writes business logic, the platform handles correlation.
|
- Good, because `nestjs-cls` removes context-threading boilerplate — the developer writes business logic, the platform handles correlation.
|
||||||
* Good, because PII redaction and per-environment salting on `user_id_hash` minimise leakage if a log is exfiltrated; the redaction list is reviewable in PRs.
|
- Good, because PII redaction and per-environment salting on `user_id_hash` minimise leakage if a log is exfiltrated; the redaction list is reviewable in PRs.
|
||||||
* Good, because 100 % sampling at the application keeps the architecture simple in v1; sampling policy moves to the collector when volumes justify it.
|
- Good, because 100 % sampling at the application keeps the architecture simple in v1; sampling policy moves to the collector when volumes justify it.
|
||||||
* Bad, because the OTel Node SDK has historically had churn in API surface; the decision to use auto-instrumentations rather than hand-instrument is a calculated bet on the upstream stabilisation. Mitigated by pinning OTel package versions and lifting them deliberately.
|
- Bad, because the OTel Node SDK has historically had churn in API surface; the decision to use auto-instrumentations rather than hand-instrument is a calculated bet on the upstream stabilisation. Mitigated by pinning OTel package versions and lifting them deliberately.
|
||||||
* Bad, because emitting 100 % traces in prod at scale will eventually cost storage; the deferral to collector-side tail sampling is the proper answer, not "drop everything at the source".
|
- Bad, because emitting 100 % traces in prod at scale will eventually cost storage; the deferral to collector-side tail sampling is the proper answer, not "drop everything at the source".
|
||||||
* Bad, because the redaction list is a per-line config — adding a new sensitive shape (e.g. a future PII field) requires a PR. There is no fully automatic "redact anything that looks PII-like". This is an accepted trade-off: explicit beats clever.
|
- Bad, because the redaction list is a per-line config — adding a new sensitive shape (e.g. a future PII field) requires a PR. There is no fully automatic "redact anything that looks PII-like". This is an accepted trade-off: explicit beats clever.
|
||||||
* Bad, because the SPA's OTel-Web bundle adds a small payload (~30 KB gzip). Acceptable; we already prioritise observability over aesthetic minimalism.
|
- Bad, because the SPA's OTel-Web bundle adds a small payload (~30 KB gzip). Acceptable; we already prioritise observability over aesthetic minimalism.
|
||||||
* Bad, because no front-end log shipping in v1 means we are blind to client-only errors that don't cause a server-side request. Acceptable for v1; revisit if support volume justifies it.
|
- Bad, because no front-end log shipping in v1 means we are blind to client-only errors that don't cause a server-side request. Acceptable for v1; revisit if support volume justifies it.
|
||||||
* Neutral, because audit logs being a separate stream is enforced by writer separation, not by namespacing inside the same Pino logger — this is intentional (different retention, different access).
|
- Neutral, because audit logs being a separate stream is enforced by writer separation, not by namespacing inside the same Pino logger — this is intentional (different retention, different access).
|
||||||
|
|
||||||
### Confirmation
|
### Confirmation
|
||||||
|
|
||||||
* `apps/portal-bff/src/observability/observability.module.ts` registers `nestjs-pino`, `nestjs-cls`, the OTel SDK initialiser, and the redact-list test fixture.
|
- `apps/portal-bff/src/observability/observability.module.ts` registers `nestjs-pino`, `nestjs-cls`, the OTel SDK initialiser, and the redact-list test fixture.
|
||||||
* `apps/portal-bff/src/main.ts` calls `tracingSetup()` *before* `NestFactory.create(AppModule)` — OTel must be initialised before any auto-instrumented module is loaded.
|
- `apps/portal-bff/src/main.ts` calls `tracingSetup()` _before_ `NestFactory.create(AppModule)` — OTel must be initialised before any auto-instrumented module is loaded.
|
||||||
* The Pino transport in dev uses `pino-pretty` (human-readable); prod emits raw JSON.
|
- The Pino transport in dev uses `pino-pretty` (human-readable); prod emits raw JSON.
|
||||||
* `apps/portal-shell/src/observability/tracing.ts` initialises `@opentelemetry/sdk-trace-web` with the OTLP/HTTP exporter pointing to the BFF's `/v1/traces` ingress (or directly to the collector if exposed).
|
- `apps/portal-shell/src/observability/tracing.ts` initialises `@opentelemetry/sdk-trace-web` with the OTLP/HTTP exporter pointing to the BFF's `/v1/traces` ingress (or directly to the collector if exposed).
|
||||||
* Every controller receives `req` and `res` and the http auto-instrumentation produces a span; non-trivial service methods open custom spans via `tracer.startActiveSpan('domain.<verb>', ...)` and propagate the CLS context within them.
|
- Every controller receives `req` and `res` and the http auto-instrumentation produces a span; non-trivial service methods open custom spans via `tracer.startActiveSpan('domain.<verb>', ...)` and propagate the CLS context within them.
|
||||||
* Integration tests:
|
- Integration tests:
|
||||||
- a single user-action request emits one trace with correct parent-child structure (SPA → BFF → DB);
|
- a single user-action request emits one trace with correct parent-child structure (SPA → BFF → DB);
|
||||||
- every log line for that request carries the same `trace_id`;
|
- every log line for that request carries the same `trace_id`;
|
||||||
- the redact list strips its targets from a captured stream;
|
- the redact list strips its targets from a captured stream;
|
||||||
- a missing `LOG_USER_ID_SALT` prevents BFF startup.
|
- a missing `LOG_USER_ID_SALT` prevents BFF startup.
|
||||||
* The CLS-bound `user_id_hash` is identical for two consecutive requests of the same user, and different across environments (different salt).
|
- The CLS-bound `user_id_hash` is identical for two consecutive requests of the same user, and different across environments (different salt).
|
||||||
* The OTel Collector configuration ships in the deployment manifest (covered by the future infrastructure ADR), not in source — the application remains backend-agnostic.
|
- The OTel Collector configuration ships in the deployment manifest (covered by the future infrastructure ADR), not in source — the application remains backend-agnostic.
|
||||||
|
|
||||||
## Pros and Cons of the Options
|
## Pros and Cons of the Options
|
||||||
|
|
||||||
@@ -199,133 +207,133 @@ The BFF refuses to start if `LOG_USER_ID_SALT` or `OTEL_SERVICE_NAME` is missing
|
|||||||
|
|
||||||
#### Pino + `nestjs-pino` (chosen)
|
#### Pino + `nestjs-pino` (chosen)
|
||||||
|
|
||||||
* Good, because the fastest established Node logger; JSON structured output by default; first-class integration with NestJS (HTTP request logging, async context).
|
- Good, because the fastest established Node logger; JSON structured output by default; first-class integration with NestJS (HTTP request logging, async context).
|
||||||
* Good, because rich `redact` configuration with path globbing — exactly what we need for PII handling.
|
- Good, because rich `redact` configuration with path globbing — exactly what we need for PII handling.
|
||||||
* Bad, because `nestjs-pino` is third-party (well-maintained, but not Nest-team-owned). Acceptable.
|
- Bad, because `nestjs-pino` is third-party (well-maintained, but not Nest-team-owned). Acceptable.
|
||||||
|
|
||||||
#### Winston
|
#### Winston
|
||||||
|
|
||||||
* Good, because long-standing, large user base.
|
- Good, because long-standing, large user base.
|
||||||
* Bad, because slower than Pino in JSON mode; ergonomics around async context less polished. Less aligned with our perf and DX goals.
|
- Bad, because slower than Pino in JSON mode; ergonomics around async context less polished. Less aligned with our perf and DX goals.
|
||||||
|
|
||||||
#### Bunyan
|
#### Bunyan
|
||||||
|
|
||||||
* Bad, because slowing maintenance, smaller community in 2026.
|
- Bad, because slowing maintenance, smaller community in 2026.
|
||||||
|
|
||||||
#### `console.log` / NestJS default Logger only
|
#### `console.log` / NestJS default Logger only
|
||||||
|
|
||||||
* Bad, because no structured output, no redaction, no async context, no levels by config. Bricolage.
|
- Bad, because no structured output, no redaction, no async context, no levels by config. Bricolage.
|
||||||
|
|
||||||
### Tracing instrumentation
|
### Tracing instrumentation
|
||||||
|
|
||||||
#### OpenTelemetry (chosen)
|
#### OpenTelemetry (chosen)
|
||||||
|
|
||||||
* Good, because the CNCF graduated standard — supported by every observability vendor and every major language.
|
- Good, because the CNCF graduated standard — supported by every observability vendor and every major language.
|
||||||
* Good, because auto-instrumentation removes the bulk of glue work for HTTP, DB, Redis, Prisma.
|
- Good, because auto-instrumentation removes the bulk of glue work for HTTP, DB, Redis, Prisma.
|
||||||
* Bad, because the Node SDK has had API churn historically — managed by version pinning.
|
- Bad, because the Node SDK has had API churn historically — managed by version pinning.
|
||||||
|
|
||||||
#### Vendor SDK (Datadog, New Relic, etc.)
|
#### Vendor SDK (Datadog, New Relic, etc.)
|
||||||
|
|
||||||
* Good, because typically richer auto-instrumentation than OTel for that vendor.
|
- Good, because typically richer auto-instrumentation than OTel for that vendor.
|
||||||
* Bad, because vendor lock-in — switching backend later means rewriting instrumentation. Rejected.
|
- Bad, because vendor lock-in — switching backend later means rewriting instrumentation. Rejected.
|
||||||
|
|
||||||
#### OpenTracing
|
#### OpenTracing
|
||||||
|
|
||||||
* Bad, because deprecated; succeeded by OTel. Rejected.
|
- Bad, because deprecated; succeeded by OTel. Rejected.
|
||||||
|
|
||||||
#### Custom
|
#### Custom
|
||||||
|
|
||||||
* Bad, because bricolage on a critical surface.
|
- Bad, because bricolage on a critical surface.
|
||||||
|
|
||||||
### Propagation header
|
### Propagation header
|
||||||
|
|
||||||
#### W3C Trace Context (chosen)
|
#### W3C Trace Context (chosen)
|
||||||
|
|
||||||
* Good, because the IETF/W3C standard adopted across the industry.
|
- Good, because the IETF/W3C standard adopted across the industry.
|
||||||
* Good, because covered by every OTel SDK and by intermediate proxies (Envoy, NGINX, AWS ALB, etc.).
|
- Good, because covered by every OTel SDK and by intermediate proxies (Envoy, NGINX, AWS ALB, etc.).
|
||||||
|
|
||||||
#### Custom `X-Correlation-ID`
|
#### Custom `X-Correlation-ID`
|
||||||
|
|
||||||
* Bad, because reinvents what already exists; loses the parent-child relation that `traceparent` carries; doesn't cooperate with auto-instrumentation.
|
- Bad, because reinvents what already exists; loses the parent-child relation that `traceparent` carries; doesn't cooperate with auto-instrumentation.
|
||||||
|
|
||||||
#### Vendor formats (B3, Datadog, Jaeger)
|
#### Vendor formats (B3, Datadog, Jaeger)
|
||||||
|
|
||||||
* Bad, because vendor-specific. OTel can be configured to emit them as a fallback if a specific intermediate requires them, but they are not the default.
|
- Bad, because vendor-specific. OTel can be configured to emit them as a fallback if a specific intermediate requires them, but they are not the default.
|
||||||
|
|
||||||
### Request-scoped context
|
### Request-scoped context
|
||||||
|
|
||||||
#### `nestjs-cls` (chosen)
|
#### `nestjs-cls` (chosen)
|
||||||
|
|
||||||
* Good, because purpose-built for NestJS, integrates cleanly with interceptors, guards, and the logger.
|
- Good, because purpose-built for NestJS, integrates cleanly with interceptors, guards, and the logger.
|
||||||
* Good, because minimal boilerplate at the call sites.
|
- Good, because minimal boilerplate at the call sites.
|
||||||
|
|
||||||
#### Manual passing
|
#### Manual passing
|
||||||
|
|
||||||
* Bad, because every service signature acquires a context parameter — relentless ceremony, easy to forget.
|
- Bad, because every service signature acquires a context parameter — relentless ceremony, easy to forget.
|
||||||
|
|
||||||
#### `AsyncLocalStorage` directly
|
#### `AsyncLocalStorage` directly
|
||||||
|
|
||||||
* Good, because no extra dependency.
|
- Good, because no extra dependency.
|
||||||
* Bad, because we'd reinvent the request-bind plumbing already provided by `nestjs-cls`.
|
- Bad, because we'd reinvent the request-bind plumbing already provided by `nestjs-cls`.
|
||||||
|
|
||||||
### Sampling
|
### Sampling
|
||||||
|
|
||||||
#### Always-on (chosen, v1)
|
#### Always-on (chosen, v1)
|
||||||
|
|
||||||
* Good, because end-to-end debuggability with no surprises.
|
- Good, because end-to-end debuggability with no surprises.
|
||||||
* Bad, because storage cost grows linearly with volume.
|
- Bad, because storage cost grows linearly with volume.
|
||||||
|
|
||||||
#### Probabilistic head sampling
|
#### Probabilistic head sampling
|
||||||
|
|
||||||
* Good, because cheap.
|
- Good, because cheap.
|
||||||
* Bad, because we lose traces of rare events — exactly the ones we need for incident analysis. Tail sampling at the collector is strictly better.
|
- Bad, because we lose traces of rare events — exactly the ones we need for incident analysis. Tail sampling at the collector is strictly better.
|
||||||
|
|
||||||
### Emission and shipping
|
### Emission and shipping
|
||||||
|
|
||||||
#### stdout + collector (chosen)
|
#### stdout + collector (chosen)
|
||||||
|
|
||||||
* Good, because 12-factor; the application is decoupled from the backend; rotating backends is an ops change, not a deploy.
|
- Good, because 12-factor; the application is decoupled from the backend; rotating backends is an ops change, not a deploy.
|
||||||
* Good, because the collector handles batching, retries, sampling, and protocol translation.
|
- Good, because the collector handles batching, retries, sampling, and protocol translation.
|
||||||
|
|
||||||
#### Direct shipping
|
#### Direct shipping
|
||||||
|
|
||||||
* Bad, because backend coupled to the application; outage of the backend impacts the app's emission path; multi-tenant credentials end up in the app config.
|
- Bad, because backend coupled to the application; outage of the backend impacts the app's emission path; multi-tenant credentials end up in the app config.
|
||||||
|
|
||||||
### Sensitive data handling
|
### Sensitive data handling
|
||||||
|
|
||||||
#### Pino `redact` allowlist (chosen)
|
#### Pino `redact` allowlist (chosen)
|
||||||
|
|
||||||
* Good, because explicit, reviewable, testable.
|
- Good, because explicit, reviewable, testable.
|
||||||
* Bad, because a new sensitive shape requires a PR — accepted.
|
- Bad, because a new sensitive shape requires a PR — accepted.
|
||||||
|
|
||||||
#### Hand-rolled scrubbing
|
#### Hand-rolled scrubbing
|
||||||
|
|
||||||
* Bad, because per-call boilerplate; easy to forget; inconsistent.
|
- Bad, because per-call boilerplate; easy to forget; inconsistent.
|
||||||
|
|
||||||
#### No redaction
|
#### No redaction
|
||||||
|
|
||||||
* Bad, because a single accidental log of an `Authorization` header turns into a security incident.
|
- Bad, because a single accidental log of an `Authorization` header turns into a security incident.
|
||||||
|
|
||||||
### Frontend tracing
|
### Frontend tracing
|
||||||
|
|
||||||
#### OTel-Web in the SPA (chosen)
|
#### OTel-Web in the SPA (chosen)
|
||||||
|
|
||||||
* Good, because the trace starts where the user clicks — first segment of every story is captured.
|
- Good, because the trace starts where the user clicks — first segment of every story is captured.
|
||||||
* Good, because the same `trace_id` shown to the user is the one used by support — no translation.
|
- Good, because the same `trace_id` shown to the user is the one used by support — no translation.
|
||||||
* Bad, because adds a small bundle to the SPA; quantifiable, accepted.
|
- Bad, because adds a small bundle to the SPA; quantifiable, accepted.
|
||||||
|
|
||||||
#### Front out of the trace
|
#### Front out of the trace
|
||||||
|
|
||||||
* Bad, because every trace begins one hop too late, and "front was slow" becomes uninvestigable.
|
- Bad, because every trace begins one hop too late, and "front was slow" becomes uninvestigable.
|
||||||
|
|
||||||
## More Information
|
## More Information
|
||||||
|
|
||||||
* OpenTelemetry: https://opentelemetry.io/
|
- OpenTelemetry: https://opentelemetry.io/
|
||||||
* OpenTelemetry for Node.js: https://opentelemetry.io/docs/languages/js/
|
- OpenTelemetry for Node.js: https://opentelemetry.io/docs/languages/js/
|
||||||
* OpenTelemetry for Web: https://opentelemetry.io/docs/languages/js/getting-started/browser/
|
- OpenTelemetry for Web: https://opentelemetry.io/docs/languages/js/getting-started/browser/
|
||||||
* W3C Trace Context: https://www.w3.org/TR/trace-context/
|
- W3C Trace Context: https://www.w3.org/TR/trace-context/
|
||||||
* Pino: https://github.com/pinojs/pino
|
- Pino: https://github.com/pinojs/pino
|
||||||
* `nestjs-pino`: https://github.com/iamolegga/nestjs-pino
|
- `nestjs-pino`: https://github.com/iamolegga/nestjs-pino
|
||||||
* `nestjs-cls`: https://github.com/Papooch/nestjs-cls
|
- `nestjs-cls`: https://github.com/Papooch/nestjs-cls
|
||||||
* OWASP Logging Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Logging_Cheat_Sheet.html
|
- OWASP Logging Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Logging_Cheat_Sheet.html
|
||||||
* Related ADRs: [ADR-0005](0005-backend-stack-nestjs.md) (NestJS), [ADR-0006](0006-persistence-postgresql-prisma.md) (Postgres + Prisma — auto-instrumented), [ADR-0010](0010-session-management-redis.md) (Redis — auto-instrumented), and the future ADRs for audit trail (separate stream, same `trace_id`) and on-prem infrastructure (collector deployment, backend choice, retention, tail sampling).
|
- Related ADRs: [ADR-0005](0005-backend-stack-nestjs.md) (NestJS), [ADR-0006](0006-persistence-postgresql-prisma.md) (Postgres + Prisma — auto-instrumented), [ADR-0010](0010-session-management-redis.md) (Redis — auto-instrumented), and the future ADRs for audit trail (separate stream, same `trace_id`) and on-prem infrastructure (collector deployment, backend choice, retention, tail sampling).
|
||||||
+88
-83
@@ -11,46 +11,51 @@ tags: [security, observability, data]
|
|||||||
|
|
||||||
[ADR-0012](0012-observability-pino-opentelemetry.md) fixed the application observability stack: structured logs and traces, all carrying the same `trace_id`, optimised for debugging and operations. Application logs are noisy, broadly accessible, and have a relatively short retention window — appropriate for ops, inappropriate for security forensics or compliance evidence.
|
[ADR-0012](0012-observability-pino-opentelemetry.md) fixed the application observability stack: structured logs and traces, all carrying the same `trace_id`, optimised for debugging and operations. Application logs are noisy, broadly accessible, and have a relatively short retention window — appropriate for ops, inappropriate for security forensics or compliance evidence.
|
||||||
|
|
||||||
Audit logs serve a different purpose: they record *security-relevant* events authoritatively, must survive years, must not be alterable by anyone (including operators), and must be queryable by auditors with *different* access controls than developers. Mixing the two streams is the recurring failure mode that turns an audit log into noise — or worse, lets a developer accidentally `UPDATE` a row.
|
Audit logs serve a different purpose: they record _security-relevant_ events authoritatively, must survive years, must not be alterable by anyone (including operators), and must be queryable by auditors with _different_ access controls than developers. Mixing the two streams is the recurring failure mode that turns an audit log into noise — or worse, lets a developer accidentally `UPDATE` a row.
|
||||||
|
|
||||||
We need a fixed answer to: which events are recorded, where they are stored, who can read them, who can write them, who can never delete or modify them, how long they are kept, and how they relate to the application observability stream.
|
We need a fixed answer to: which events are recorded, where they are stored, who can read them, who can write them, who can never delete or modify them, how long they are kept, and how they relate to the application observability stream.
|
||||||
|
|
||||||
## Decision Drivers
|
## Decision Drivers
|
||||||
|
|
||||||
* Compliance posture: an audit log that can be tampered with is no audit log.
|
- Compliance posture: an audit log that can be tampered with is no audit log.
|
||||||
* Strict separation of concern from application logs — different sink, different access, different retention, different volume profile.
|
- Strict separation of concern from application logs — different sink, different access, different retention, different volume profile.
|
||||||
* Forensic queryability: an investigator must be able to reconstruct the timeline of any user's interactions with the system.
|
- Forensic queryability: an investigator must be able to reconstruct the timeline of any user's interactions with the system.
|
||||||
* Cross-reference with the app observability stream by `trace_id` so an audit event in isolation can be enriched with the surrounding app log.
|
- Cross-reference with the app observability stream by `trace_id` so an audit event in isolation can be enriched with the surrounding app log.
|
||||||
* Avoid premature complexity: full cryptographic chaining and WORM storage are not required to start, but the path to them must remain open.
|
- Avoid premature complexity: full cryptographic chaining and WORM storage are not required to start, but the path to them must remain open.
|
||||||
* Performance: per-event INSERT must not be in the critical latency path of unrelated operations.
|
- Performance: per-event INSERT must not be in the critical latency path of unrelated operations.
|
||||||
|
|
||||||
## Considered Options
|
## Considered Options
|
||||||
|
|
||||||
### Sink
|
### Sink
|
||||||
* **Dedicated `audit` schema in the same PostgreSQL instance as business data.** (Chosen.)
|
|
||||||
* Dedicated PostgreSQL instance for audit only.
|
- **Dedicated `audit` schema in the same PostgreSQL instance as business data.** (Chosen.)
|
||||||
* Same Pino logger as app logs, with a different category.
|
- Dedicated PostgreSQL instance for audit only.
|
||||||
* Push to a SIEM (Splunk, Wazuh, Graylog, etc.).
|
- Same Pino logger as app logs, with a different category.
|
||||||
* Append-only file with rotation.
|
- Push to a SIEM (Splunk, Wazuh, Graylog, etc.).
|
||||||
|
- Append-only file with rotation.
|
||||||
|
|
||||||
### Writer
|
### Writer
|
||||||
* **Direct Prisma INSERT, in the same transaction as the business action when applicable.** (Chosen.)
|
|
||||||
* Background queue (Redis Streams, RabbitMQ, etc.) decoupled from the request path.
|
- **Direct Prisma INSERT, in the same transaction as the business action when applicable.** (Chosen.)
|
||||||
* Pino transport extension.
|
- Background queue (Redis Streams, RabbitMQ, etc.) decoupled from the request path.
|
||||||
|
- Pino transport extension.
|
||||||
|
|
||||||
### Tamper evidence
|
### Tamper evidence
|
||||||
* **Append-only at the database role level (REVOKE UPDATE / DELETE / TRUNCATE on the writer and on every other role except a dedicated archiver).** (Chosen for v1.)
|
|
||||||
* Cryptographic chaining (each row carries a hash linking it to the previous row) — deferred unless compliance demands.
|
- **Append-only at the database role level (REVOKE UPDATE / DELETE / TRUNCATE on the writer and on every other role except a dedicated archiver).** (Chosen for v1.)
|
||||||
* WORM-capable storage at the infrastructure layer — deferred to future infrastructure ADR if needed.
|
- Cryptographic chaining (each row carries a hash linking it to the previous row) — deferred unless compliance demands.
|
||||||
|
- WORM-capable storage at the infrastructure layer — deferred to future infrastructure ADR if needed.
|
||||||
|
|
||||||
### Failure semantics
|
### Failure semantics
|
||||||
* **Audit failures are blocking — if the audit INSERT fails for an in-scope event, the operation fails.** (Chosen.)
|
|
||||||
* Best-effort — audit failures are logged but the operation succeeds.
|
- **Audit failures are blocking — if the audit INSERT fails for an in-scope event, the operation fails.** (Chosen.)
|
||||||
|
- Best-effort — audit failures are logged but the operation succeeds.
|
||||||
|
|
||||||
### Retention
|
### Retention
|
||||||
* **365 days default, override via env per environment.** (Chosen.)
|
|
||||||
* Indefinite (until a cleanup is requested manually).
|
- **365 days default, override via env per environment.** (Chosen.)
|
||||||
* Aligned with a specific regulation (HDS, PCI, ISO 27001 — to be revisited if and when an applicable framework is identified).
|
- Indefinite (until a cleanup is requested manually).
|
||||||
|
- Aligned with a specific regulation (HDS, PCI, ISO 27001 — to be revisited if and when an applicable framework is identified).
|
||||||
|
|
||||||
## Decision Outcome
|
## Decision Outcome
|
||||||
|
|
||||||
@@ -93,7 +98,7 @@ enum AuditOutcome {
|
|||||||
**Events emitted in v1.**
|
**Events emitted in v1.**
|
||||||
|
|
||||||
| `event_type` | When | `outcome` |
|
| `event_type` | When | `outcome` |
|
||||||
| --- | --- | --- |
|
| ------------------------------ | -------------------------------------------------------------------------------------------------------------------------- | --------- |
|
||||||
| `auth.sign_in` | OIDC callback creates a session | `success` |
|
| `auth.sign_in` | OIDC callback creates a session | `success` |
|
||||||
| `auth.sign_in.failed` | OIDC callback rejects (state mismatch, signature, `iss` not allowlisted, MFA assertion missing, audience misclassified, …) | `failure` |
|
| `auth.sign_in.failed` | OIDC callback rejects (state mismatch, signature, `iss` not allowlisted, MFA assertion missing, audience misclassified, …) | `failure` |
|
||||||
| `auth.sign_out` | `POST /auth/logout` invalidates a session | `success` |
|
| `auth.sign_out` | `POST /auth/logout` invalidates a session | `success` |
|
||||||
@@ -103,14 +108,14 @@ enum AuditOutcome {
|
|||||||
| `auth.mfa.assertion.failed` | the BFF rejects a session for missing or weak `amr` (ADR-0011) | `failure` |
|
| `auth.mfa.assertion.failed` | the BFF rejects a session for missing or weak `amr` (ADR-0011) | `failure` |
|
||||||
| `authz.deny` | a guard rejects an authenticated request because the user's `audience`/claims don't authorise the action | `failure` |
|
| `authz.deny` | a guard rejects an authenticated request because the user's `audience`/claims don't authorise the action | `failure` |
|
||||||
|
|
||||||
The `details` JSONB field carries event-specific information (e.g. expected vs received `iss`, denied route, claim names involved). Sensitive material (full tokens, claims that should never leave the BFF) is *never* placed in `details` — the same redaction posture as app logs applies, enforced by typed event payloads at the writer's boundary.
|
The `details` JSONB field carries event-specific information (e.g. expected vs received `iss`, denied route, claim names involved). Sensitive material (full tokens, claims that should never leave the BFF) is _never_ placed in `details` — the same redaction posture as app logs applies, enforced by typed event payloads at the writer's boundary.
|
||||||
|
|
||||||
Hooks for **admin actions** and **sensitive data access** are designed-in: the writer accepts those event families today, but no v1 caller emits them. They are exercised by unit tests so the path stays alive.
|
Hooks for **admin actions** and **sensitive data access** are designed-in: the writer accepts those event families today, but no v1 caller emits them. They are exercised by unit tests so the path stays alive.
|
||||||
|
|
||||||
**Decoupling and cross-reference.** Audit events do not flow through the Pino app logger. They flow through `AuditService` directly to Postgres. Each event carries the `trace_id` from the request-scoped CLS (ADR-0012); an investigator joining audit and app log streams on `trace_id` can reconstruct the full timeline of a request. The two streams have:
|
**Decoupling and cross-reference.** Audit events do not flow through the Pino app logger. They flow through `AuditService` directly to Postgres. Each event carries the `trace_id` from the request-scoped CLS (ADR-0012); an investigator joining audit and app log streams on `trace_id` can reconstruct the full timeline of a request. The two streams have:
|
||||||
|
|
||||||
| Aspect | App logs | Audit log |
|
| Aspect | App logs | Audit log |
|
||||||
| --- | --- | --- |
|
| ---------------- | ---------------------------------------------------------- | ---------------------------------------------- |
|
||||||
| Sink | stdout → collector → on-prem backend | PostgreSQL `audit.events` |
|
| Sink | stdout → collector → on-prem backend | PostgreSQL `audit.events` |
|
||||||
| Volume | high (every request, every internal step) | low (security-relevant events only) |
|
| Volume | high (every request, every internal step) | low (security-relevant events only) |
|
||||||
| Retention | short (operational, weeks–months) | long (≥ 365 days, env-driven) |
|
| Retention | short (operational, weeks–months) | long (≥ 365 days, env-driven) |
|
||||||
@@ -124,12 +129,12 @@ Hooks for **admin actions** and **sensitive data access** are designed-in: the w
|
|||||||
|
|
||||||
> **Compliance note.** The 365-day default is engineering-prudent, not legally derived. Specific regulations applicable to the host organisation (RGPD/GDPR retention principles, sectoral rules — health, finance — if any) may demand longer or shorter windows, or full archival before deletion. The retention default is a starting point. The legal review is owed by the organisation; this ADR does not pretend to settle it.
|
> **Compliance note.** The 365-day default is engineering-prudent, not legally derived. Specific regulations applicable to the host organisation (RGPD/GDPR retention principles, sectoral rules — health, finance — if any) may demand longer or shorter windows, or full archival before deletion. The retention default is a starting point. The legal review is owed by the organisation; this ADR does not pretend to settle it.
|
||||||
|
|
||||||
**GDPR / right-of-erasure** interactions: audit events are typically retained under a "legal obligation" or "legitimate interest" basis even after a user's right-of-erasure request. The userId itself is already pseudonymised (`actor_id_hash`); the join key to user identity exists in the live DB only and disappears with account deletion, leaving the audit row scientifically pseudonymous. Whether this is legally sufficient is the legal team's call. The current implementation is compatible with both "keep" and "anonymise further" policies (we can null `actor_id_hash` for archived users without violating append-only because *zeroing* counts as a sanctioned operation under a yet-to-define `audit_redactor` role — designed-in, not implemented in v1).
|
**GDPR / right-of-erasure** interactions: audit events are typically retained under a "legal obligation" or "legitimate interest" basis even after a user's right-of-erasure request. The userId itself is already pseudonymised (`actor_id_hash`); the join key to user identity exists in the live DB only and disappears with account deletion, leaving the audit row scientifically pseudonymous. Whether this is legally sufficient is the legal team's call. The current implementation is compatible with both "keep" and "anonymise further" policies (we can null `actor_id_hash` for archived users without violating append-only because _zeroing_ counts as a sanctioned operation under a yet-to-define `audit_redactor` role — designed-in, not implemented in v1).
|
||||||
|
|
||||||
**Configuration (env-driven).**
|
**Configuration (env-driven).**
|
||||||
|
|
||||||
| Variable | Purpose |
|
| Variable | Purpose |
|
||||||
| --- | --- |
|
| ----------------------------- | ------------------------------------------------------------------------------------------------------- |
|
||||||
| `AUDIT_DATABASE_URL` | Postgres connection for the BFF as `audit_writer` (separate creds, different from the business DB role) |
|
| `AUDIT_DATABASE_URL` | Postgres connection for the BFF as `audit_writer` (separate creds, different from the business DB role) |
|
||||||
| `AUDIT_RETENTION_DAYS` | default `365`; the BFF refuses to start with a value below `30` |
|
| `AUDIT_RETENTION_DAYS` | default `365`; the BFF refuses to start with a value below `30` |
|
||||||
| `AUDIT_ARCHIVER_DATABASE_URL` | connection for the archiver job as `audit_archiver` (used by the scheduled task only) |
|
| `AUDIT_ARCHIVER_DATABASE_URL` | connection for the archiver job as `audit_archiver` (used by the scheduled task only) |
|
||||||
@@ -137,28 +142,28 @@ Hooks for **admin actions** and **sensitive data access** are designed-in: the w
|
|||||||
|
|
||||||
### Consequences
|
### Consequences
|
||||||
|
|
||||||
* Good, because audit log integrity rests on Postgres role grants — operationally enforced by the database, not by application discipline.
|
- Good, because audit log integrity rests on Postgres role grants — operationally enforced by the database, not by application discipline.
|
||||||
* Good, because the `trace_id` makes audit events instantly cross-referenceable with the rich app log, without coupling the two streams.
|
- Good, because the `trace_id` makes audit events instantly cross-referenceable with the rich app log, without coupling the two streams.
|
||||||
* Good, because the dedicated schema and roles let a future SOC team be granted `audit_reader` without giving them anything else.
|
- Good, because the dedicated schema and roles let a future SOC team be granted `audit_reader` without giving them anything else.
|
||||||
* Good, because keeping the audit DB on the same instance avoids the operational doubling of running two Postgres clusters; the schema/role separation gives most of the isolation benefits for a fraction of the cost.
|
- Good, because keeping the audit DB on the same instance avoids the operational doubling of running two Postgres clusters; the schema/role separation gives most of the isolation benefits for a fraction of the cost.
|
||||||
* Good, because the retention default is conservative and overridable, so adapting to a stricter regulatory regime is a config change, not a refactor.
|
- Good, because the retention default is conservative and overridable, so adapting to a stricter regulatory regime is a config change, not a refactor.
|
||||||
* Good, because hooks for admin actions and sensitive data access are in place but inert — adding such an event family in a future module is one writer call.
|
- Good, because hooks for admin actions and sensitive data access are in place but inert — adding such an event family in a future module is one writer call.
|
||||||
* Bad, because blocking on audit writes means the audit DB is part of the trust path; an audit DB incident degrades the application. Mitigated by HA in prod, made explicit here so it is owned, not surprising.
|
- Bad, because blocking on audit writes means the audit DB is part of the trust path; an audit DB incident degrades the application. Mitigated by HA in prod, made explicit here so it is owned, not surprising.
|
||||||
* Bad, because true tamper evidence in the cryptographic sense (chained hashes, signed entries) is not in v1. A privileged Postgres operator with both credentials and intent could in principle bypass the role grants. Mitigated by access discipline; full chaining deferred unless compliance requires it.
|
- Bad, because true tamper evidence in the cryptographic sense (chained hashes, signed entries) is not in v1. A privileged Postgres operator with both credentials and intent could in principle bypass the role grants. Mitigated by access discipline; full chaining deferred unless compliance requires it.
|
||||||
* Bad, because retention purges via `pg_cron` or a Nest task are themselves an operational item — they must be monitored, alerted on failure, and audited (via a meta-event).
|
- Bad, because retention purges via `pg_cron` or a Nest task are themselves an operational item — they must be monitored, alerted on failure, and audited (via a meta-event).
|
||||||
* Bad, because GDPR right-of-erasure interactions are *acknowledged* but not *resolved* here — the legal review is owed; the architecture allows either policy.
|
- Bad, because GDPR right-of-erasure interactions are _acknowledged_ but not _resolved_ here — the legal review is owed; the architecture allows either policy.
|
||||||
* Neutral, because a dedicated audit Postgres instance can be substituted for the schema without code change (just `AUDIT_DATABASE_URL` pointed elsewhere). The trade-off can be revisited.
|
- Neutral, because a dedicated audit Postgres instance can be substituted for the schema without code change (just `AUDIT_DATABASE_URL` pointed elsewhere). The trade-off can be revisited.
|
||||||
|
|
||||||
### Confirmation
|
### Confirmation
|
||||||
|
|
||||||
* `apps/portal-bff/prisma/schema.prisma` enables `multiSchema` and declares the `audit` schema; the `AuditEvent` model and `AuditOutcome` enum live in it.
|
- `apps/portal-bff/prisma/schema.prisma` enables `multiSchema` and declares the `audit` schema; the `AuditEvent` model and `AuditOutcome` enum live in it.
|
||||||
* Three Postgres roles exist: `audit_writer` (INSERT only on `audit.events`), `audit_reader` (SELECT only), `audit_archiver` (DELETE only, on rows older than the configured retention). The schema migration includes the explicit `REVOKE UPDATE, DELETE, TRUNCATE FROM PUBLIC` on `audit.events` and the targeted grants.
|
- Three Postgres roles exist: `audit_writer` (INSERT only on `audit.events`), `audit_reader` (SELECT only), `audit_archiver` (DELETE only, on rows older than the configured retention). The schema migration includes the explicit `REVOKE UPDATE, DELETE, TRUNCATE FROM PUBLIC` on `audit.events` and the targeted grants.
|
||||||
* `apps/portal-bff/src/audit/audit.module.ts` provides an `AuditService` with one typed method per event family (`signIn`, `signInFailed`, `signOut`, `sessionExpired`, `sessionRevoked`, `tokenValidationFailed`, `mfaAssertionFailed`, `authzDeny`, plus dormant `adminAction` and `sensitiveDataAccess`).
|
- `apps/portal-bff/src/audit/audit.module.ts` provides an `AuditService` with one typed method per event family (`signIn`, `signInFailed`, `signOut`, `sessionExpired`, `sessionRevoked`, `tokenValidationFailed`, `mfaAssertionFailed`, `authzDeny`, plus dormant `adminAction` and `sensitiveDataAccess`).
|
||||||
* `AuditService` connects via `AUDIT_DATABASE_URL` as `audit_writer`; a startup probe asserts that the role can `INSERT` and cannot `UPDATE` (a deliberate failing UPDATE during boot is rejected, the BFF starts; if it succeeds, the BFF refuses to start).
|
- `AuditService` connects via `AUDIT_DATABASE_URL` as `audit_writer`; a startup probe asserts that the role can `INSERT` and cannot `UPDATE` (a deliberate failing UPDATE during boot is rejected, the BFF starts; if it succeeds, the BFF refuses to start).
|
||||||
* The auth controller, token-validation interceptor, MFA guard, sessions module, and authorization guards each call `AuditService` on the relevant outcomes. Tests assert one audit row per event.
|
- The auth controller, token-validation interceptor, MFA guard, sessions module, and authorization guards each call `AuditService` on the relevant outcomes. Tests assert one audit row per event.
|
||||||
* The retention purge runs daily; failure raises an alert and emits `audit.retention.purge` with `outcome = failure`.
|
- The retention purge runs daily; failure raises an alert and emits `audit.retention.purge` with `outcome = failure`.
|
||||||
* The same `LOG_USER_ID_SALT` is used by app logs and by audit; an integration test asserts that the same user produces the same `actor_id_hash` in both streams.
|
- The same `LOG_USER_ID_SALT` is used by app logs and by audit; an integration test asserts that the same user produces the same `actor_id_hash` in both streams.
|
||||||
* `AUDIT_RETENTION_DAYS < 30` and missing required env vars prevent BFF startup.
|
- `AUDIT_RETENTION_DAYS < 30` and missing required env vars prevent BFF startup.
|
||||||
|
|
||||||
## Pros and Cons of the Options
|
## Pros and Cons of the Options
|
||||||
|
|
||||||
@@ -166,95 +171,95 @@ Hooks for **admin actions** and **sensitive data access** are designed-in: the w
|
|||||||
|
|
||||||
#### Dedicated `audit` schema in the same Postgres (chosen)
|
#### Dedicated `audit` schema in the same Postgres (chosen)
|
||||||
|
|
||||||
* Good, because shared infrastructure with the business DB (HA, backup, monitoring) yet logically isolated by schema and role.
|
- Good, because shared infrastructure with the business DB (HA, backup, monitoring) yet logically isolated by schema and role.
|
||||||
* Good, because transactional INSERT alongside a business action is trivial.
|
- Good, because transactional INSERT alongside a business action is trivial.
|
||||||
* Bad, because not a hard physical isolation; a Postgres-level compromise reaches both schemas. Mitigated by role separation.
|
- Bad, because not a hard physical isolation; a Postgres-level compromise reaches both schemas. Mitigated by role separation.
|
||||||
|
|
||||||
#### Dedicated Postgres instance for audit only
|
#### Dedicated Postgres instance for audit only
|
||||||
|
|
||||||
* Good, because hard physical isolation, separate backups, separate access keys.
|
- Good, because hard physical isolation, separate backups, separate access keys.
|
||||||
* Bad, because doubled operational surface (HA cluster, backup, monitoring, restoration drills) for a v1 yield that does not justify it.
|
- Bad, because doubled operational surface (HA cluster, backup, monitoring, restoration drills) for a v1 yield that does not justify it.
|
||||||
|
|
||||||
#### Same Pino logger with a different category
|
#### Same Pino logger with a different category
|
||||||
|
|
||||||
* Bad, because the audit stream inherits the access controls of the app log stream — no separation. Bricolage.
|
- Bad, because the audit stream inherits the access controls of the app log stream — no separation. Bricolage.
|
||||||
|
|
||||||
#### Push to a SIEM
|
#### Push to a SIEM
|
||||||
|
|
||||||
* Good, because aligned with how mature SOC operations consume audit data.
|
- Good, because aligned with how mature SOC operations consume audit data.
|
||||||
* Bad, because we do not have a SIEM yet; a future SIEM can ingest from the audit schema (via CDC or scheduled export) without touching application code.
|
- Bad, because we do not have a SIEM yet; a future SIEM can ingest from the audit schema (via CDC or scheduled export) without touching application code.
|
||||||
|
|
||||||
#### Append-only file with rotation
|
#### Append-only file with rotation
|
||||||
|
|
||||||
* Bad, because no concurrent-safe writes, no queryability, fragile ops, hard to enforce immutability across rotations.
|
- Bad, because no concurrent-safe writes, no queryability, fragile ops, hard to enforce immutability across rotations.
|
||||||
|
|
||||||
### Writer
|
### Writer
|
||||||
|
|
||||||
#### Direct Prisma INSERT, transactional with the business action (chosen)
|
#### Direct Prisma INSERT, transactional with the business action (chosen)
|
||||||
|
|
||||||
* Good, because either both succeed or both fail — no orphan business rows missing their audit trail.
|
- Good, because either both succeed or both fail — no orphan business rows missing their audit trail.
|
||||||
* Good, because no extra moving part to operate.
|
- Good, because no extra moving part to operate.
|
||||||
* Bad, because the audit DB is in the critical path of the request. Already discussed under failure semantics.
|
- Bad, because the audit DB is in the critical path of the request. Already discussed under failure semantics.
|
||||||
|
|
||||||
#### Background queue
|
#### Background queue
|
||||||
|
|
||||||
* Good, because the request path is decoupled from the audit DB.
|
- Good, because the request path is decoupled from the audit DB.
|
||||||
* Bad, because an event sitting in a queue when the queue dies is a missing audit event. Loud failure modes are harder to design and audit.
|
- Bad, because an event sitting in a queue when the queue dies is a missing audit event. Loud failure modes are harder to design and audit.
|
||||||
* Bad, because adds a queue (Redis Streams, RabbitMQ) to the v1 surface — out of proportion.
|
- Bad, because adds a queue (Redis Streams, RabbitMQ) to the v1 surface — out of proportion.
|
||||||
|
|
||||||
#### Pino transport extension
|
#### Pino transport extension
|
||||||
|
|
||||||
* Bad, because piggybacks audit on the app-log path — exactly the coupling we are trying to prevent.
|
- Bad, because piggybacks audit on the app-log path — exactly the coupling we are trying to prevent.
|
||||||
|
|
||||||
### Tamper evidence
|
### Tamper evidence
|
||||||
|
|
||||||
#### Append-only at the role level (chosen)
|
#### Append-only at the role level (chosen)
|
||||||
|
|
||||||
* Good, because enforced by Postgres, not by application discipline.
|
- Good, because enforced by Postgres, not by application discipline.
|
||||||
* Bad, because a privileged DB operator can in principle override grants. The credentials and the intent must coincide — this is the standard enterprise trust model.
|
- Bad, because a privileged DB operator can in principle override grants. The credentials and the intent must coincide — this is the standard enterprise trust model.
|
||||||
|
|
||||||
#### Cryptographic chaining
|
#### Cryptographic chaining
|
||||||
|
|
||||||
* Good, because tamper-evident even against the DB operator.
|
- Good, because tamper-evident even against the DB operator.
|
||||||
* Bad, because adds complexity to inserts (hashing the previous row), to schema migrations, and to range queries. Re-evaluated when a compliance regime mandates it.
|
- Bad, because adds complexity to inserts (hashing the previous row), to schema migrations, and to range queries. Re-evaluated when a compliance regime mandates it.
|
||||||
|
|
||||||
#### WORM storage
|
#### WORM storage
|
||||||
|
|
||||||
* Good, because true immutability at the storage layer.
|
- Good, because true immutability at the storage layer.
|
||||||
* Bad, because infrastructure-level decision deferred to the on-prem ADR.
|
- Bad, because infrastructure-level decision deferred to the on-prem ADR.
|
||||||
|
|
||||||
### Failure semantics
|
### Failure semantics
|
||||||
|
|
||||||
#### Blocking (chosen)
|
#### Blocking (chosen)
|
||||||
|
|
||||||
* Good, because security-positive: no audit means no action.
|
- Good, because security-positive: no audit means no action.
|
||||||
* Bad, because audit DB is part of the request trust path.
|
- Bad, because audit DB is part of the request trust path.
|
||||||
|
|
||||||
#### Best-effort
|
#### Best-effort
|
||||||
|
|
||||||
* Good, because the application keeps running through audit-DB incidents.
|
- Good, because the application keeps running through audit-DB incidents.
|
||||||
* Bad, because a sufficiently long incident creates audit gaps that compliance frameworks treat as evidence of negligence.
|
- Bad, because a sufficiently long incident creates audit gaps that compliance frameworks treat as evidence of negligence.
|
||||||
|
|
||||||
### Retention
|
### Retention
|
||||||
|
|
||||||
#### 365 days, env-overridable (chosen)
|
#### 365 days, env-overridable (chosen)
|
||||||
|
|
||||||
* Good, because conservative starting point; legally adjustable later.
|
- Good, because conservative starting point; legally adjustable later.
|
||||||
* Bad, because the engineering default is not the legal default; the legal review remains owed.
|
- Bad, because the engineering default is not the legal default; the legal review remains owed.
|
||||||
|
|
||||||
#### Indefinite
|
#### Indefinite
|
||||||
|
|
||||||
* Bad, because GDPR principles favour proportionality; unbounded retention is hard to defend without a specific basis.
|
- Bad, because GDPR principles favour proportionality; unbounded retention is hard to defend without a specific basis.
|
||||||
|
|
||||||
#### Tied to a specific regulation
|
#### Tied to a specific regulation
|
||||||
|
|
||||||
* Bad, because we don't know yet which regulation applies. To be revisited when the org clarifies.
|
- Bad, because we don't know yet which regulation applies. To be revisited when the org clarifies.
|
||||||
|
|
||||||
## More Information
|
## More Information
|
||||||
|
|
||||||
* OWASP Logging Cheat Sheet — Audit logging section: https://cheatsheetseries.owasp.org/cheatsheets/Logging_Cheat_Sheet.html
|
- OWASP Logging Cheat Sheet — Audit logging section: https://cheatsheetseries.owasp.org/cheatsheets/Logging_Cheat_Sheet.html
|
||||||
* PostgreSQL roles, GRANT, REVOKE: https://www.postgresql.org/docs/current/sql-grant.html
|
- PostgreSQL roles, GRANT, REVOKE: https://www.postgresql.org/docs/current/sql-grant.html
|
||||||
* PostgreSQL multiSchema with Prisma: https://www.prisma.io/docs/orm/prisma-schema/data-model/multi-schema
|
- PostgreSQL multiSchema with Prisma: https://www.prisma.io/docs/orm/prisma-schema/data-model/multi-schema
|
||||||
* `pg_cron`: https://github.com/citusdata/pg_cron
|
- `pg_cron`: https://github.com/citusdata/pg_cron
|
||||||
* GDPR/RGPD on retention and pseudonymisation: https://gdpr-info.eu/art-5-gdpr/
|
- GDPR/RGPD on retention and pseudonymisation: https://gdpr-info.eu/art-5-gdpr/
|
||||||
* Related ADRs: [ADR-0006](0006-persistence-postgresql-prisma.md) (Postgres + Prisma), [ADR-0008](0008-identity-model-entra-workforce-dual-audience.md) (audience), [ADR-0009](0009-auth-flow-oidc-pkce-msal-node.md) (auth flow events), [ADR-0010](0010-session-management-redis.md) (session lifecycle events), [ADR-0011](0011-mfa-enforcement-entra-conditional-access.md) (MFA events), [ADR-0012](0012-observability-pino-opentelemetry.md) (`trace_id`, `LOG_USER_ID_SALT` shared); future: infrastructure ADR (HA Postgres, backup, possibly WORM), legal review of retention, optional SIEM integration ADR, optional cryptographic-chaining ADR if compliance demands it.
|
- Related ADRs: [ADR-0006](0006-persistence-postgresql-prisma.md) (Postgres + Prisma), [ADR-0008](0008-identity-model-entra-workforce-dual-audience.md) (audience), [ADR-0009](0009-auth-flow-oidc-pkce-msal-node.md) (auth flow events), [ADR-0010](0010-session-management-redis.md) (session lifecycle events), [ADR-0011](0011-mfa-enforcement-entra-conditional-access.md) (MFA events), [ADR-0012](0012-observability-pino-opentelemetry.md) (`trace_id`, `LOG_USER_ID_SALT` shared); future: infrastructure ADR (HA Postgres, backup, possibly WORM), legal review of retention, optional SIEM integration ADR, optional cryptographic-chaining ADR if compliance demands it.
|
||||||
+83
-74
@@ -15,38 +15,43 @@ The concrete list of downstream services is not known yet (the project lead defe
|
|||||||
|
|
||||||
## Decision Drivers
|
## Decision Drivers
|
||||||
|
|
||||||
* No token ever leaves the BFF except through the legitimate downstream-call path.
|
- No token ever leaves the BFF except through the legitimate downstream-call path.
|
||||||
* A single canonical place where downstream HTTP calls are made — never a `fetch`/`axios` directly in a controller or a service.
|
- A single canonical place where downstream HTTP calls are made — never a `fetch`/`axios` directly in a controller or a service.
|
||||||
* Resilience: a slow or failing downstream cannot cascade into the BFF's general latency or saturate its event loop.
|
- Resilience: a slow or failing downstream cannot cascade into the BFF's general latency or saturate its event loop.
|
||||||
* Observability: every downstream call is part of the trace started by the user click ([ADR-0012](0012-observability-pino-opentelemetry.md)).
|
- Observability: every downstream call is part of the trace started by the user click ([ADR-0012](0012-observability-pino-opentelemetry.md)).
|
||||||
* Audience-aware: a downstream that is workforce-only must not be reachable by a customer-audience session, even by mistake. The dual-audience design ([ADR-0008](0008-identity-model-entra-workforce-dual-audience.md)) is honoured at the call site.
|
- Audience-aware: a downstream that is workforce-only must not be reachable by a customer-audience session, even by mistake. The dual-audience design ([ADR-0008](0008-identity-model-entra-workforce-dual-audience.md)) is honoured at the call site.
|
||||||
* Forward-compatible: a new downstream is added by configuration, not refactor.
|
- Forward-compatible: a new downstream is added by configuration, not refactor.
|
||||||
* Trust-realistic: legacy downstreams that can't (yet) speak Entra must have a path that is not "store the user's password somewhere".
|
- Trust-realistic: legacy downstreams that can't (yet) speak Entra must have a path that is not "store the user's password somewhere".
|
||||||
|
|
||||||
## Considered Options
|
## Considered Options
|
||||||
|
|
||||||
### Authentication strategy
|
### Authentication strategy
|
||||||
* **OBO (On-Behalf-Of) for Entra-protected downstreams.** (Chosen as default.)
|
|
||||||
* **Service credential + signed user-assertion header for non-Entra downstreams.** (Chosen as fallback.)
|
- **OBO (On-Behalf-Of) for Entra-protected downstreams.** (Chosen as default.)
|
||||||
* Token relay (the BFF passes the user's access token unchanged).
|
- **Service credential + signed user-assertion header for non-Entra downstreams.** (Chosen as fallback.)
|
||||||
* Per-user credential mapping (the BFF stores each user's credentials for each legacy downstream).
|
- Token relay (the BFF passes the user's access token unchanged).
|
||||||
|
- Per-user credential mapping (the BFF stores each user's credentials for each legacy downstream).
|
||||||
|
|
||||||
### HTTP client
|
### HTTP client
|
||||||
* **`@nestjs/axios`.** (Chosen.)
|
|
||||||
* `got`, `undici`, native `fetch`.
|
- **`@nestjs/axios`.** (Chosen.)
|
||||||
|
- `got`, `undici`, native `fetch`.
|
||||||
|
|
||||||
### Resilience
|
### Resilience
|
||||||
* **`cockatiel`** — TypeScript-native composable policies (timeout, retry, circuit breaker, bulkhead). (Chosen.)
|
|
||||||
* `p-retry` / `opossum` / hand-rolled.
|
- **`cockatiel`** — TypeScript-native composable policies (timeout, retry, circuit breaker, bulkhead). (Chosen.)
|
||||||
|
- `p-retry` / `opossum` / hand-rolled.
|
||||||
|
|
||||||
### Token cache (for OBO)
|
### Token cache (for OBO)
|
||||||
* **Redis with AES-256-GCM, dedicated key.** (Chosen.)
|
|
||||||
* In-memory only.
|
- **Redis with AES-256-GCM, dedicated key.** (Chosen.)
|
||||||
* No cache.
|
- In-memory only.
|
||||||
|
- No cache.
|
||||||
|
|
||||||
### Service registration
|
### Service registration
|
||||||
* **Module-level typed config.** (Chosen.)
|
|
||||||
* Decorator-based registration.
|
- **Module-level typed config.** (Chosen.)
|
||||||
|
- Decorator-based registration.
|
||||||
|
|
||||||
## Decision Outcome
|
## Decision Outcome
|
||||||
|
|
||||||
@@ -58,7 +63,11 @@ export type DownstreamApiConfig = {
|
|||||||
baseUrl: string; // env-driven
|
baseUrl: string; // env-driven
|
||||||
auth:
|
auth:
|
||||||
| { strategy: 'obo'; resource: string; scopes: string[] }
|
| { strategy: 'obo'; resource: string; scopes: string[] }
|
||||||
| { strategy: 'service'; credential: ServiceCredential; userAssertion: SignedAssertionConfig | null }
|
| {
|
||||||
|
strategy: 'service';
|
||||||
|
credential: ServiceCredential;
|
||||||
|
userAssertion: SignedAssertionConfig | null;
|
||||||
|
}
|
||||||
| { strategy: 'none' };
|
| { strategy: 'none' };
|
||||||
timeoutMs: number;
|
timeoutMs: number;
|
||||||
retry: { attempts: number; baseDelayMs: number; maxDelayMs: number; jitter: boolean };
|
retry: { attempts: number; baseDelayMs: number; maxDelayMs: number; jitter: boolean };
|
||||||
@@ -76,7 +85,7 @@ On cache miss or imminent expiry, the BFF calls Entra's OBO endpoint and re-cach
|
|||||||
|
|
||||||
### Service strategy (non-Entra downstreams)
|
### Service strategy (non-Entra downstreams)
|
||||||
|
|
||||||
The BFF authenticates *as itself* with the configured `ServiceCredential` (API key, OAuth client-credentials grant, mTLS, …). The user identity is propagated via a **signed assertion header** — `X-User-Assertion`, a short-lived JWT signed by the BFF's private key, carrying a minimal claim set:
|
The BFF authenticates _as itself_ with the configured `ServiceCredential` (API key, OAuth client-credentials grant, mTLS, …). The user identity is propagated via a **signed assertion header** — `X-User-Assertion`, a short-lived JWT signed by the BFF's private key, carrying a minimal claim set:
|
||||||
|
|
||||||
```jsonc
|
```jsonc
|
||||||
{
|
{
|
||||||
@@ -114,7 +123,7 @@ Per-user credential mapping is **rejected for v1**. If a downstream cannot be se
|
|||||||
|
|
||||||
### Audience pre-check
|
### Audience pre-check
|
||||||
|
|
||||||
Before any call leaves the BFF, the client reads the current request's `audience` from CLS (ADR-0012) and rejects with HTTP 403 + an `authz.deny` audit event (ADR-0013) if the audience is not in the service's `audienceConstraint`. In v1 every service is `audienceConstraint: ['workforce']`; the constraint is checked anyway so the dual-audience design is honoured from the first integration. The check is at the *call site*, not at controller entry — even a missed authorization guard upstream cannot bypass it.
|
Before any call leaves the BFF, the client reads the current request's `audience` from CLS (ADR-0012) and rejects with HTTP 403 + an `authz.deny` audit event (ADR-0013) if the audience is not in the service's `audienceConstraint`. In v1 every service is `audienceConstraint: ['workforce']`; the constraint is checked anyway so the dual-audience design is honoured from the first integration. The check is at the _call site_, not at controller entry — even a missed authorization guard upstream cannot bypass it.
|
||||||
|
|
||||||
### Observability
|
### Observability
|
||||||
|
|
||||||
@@ -126,12 +135,12 @@ Downstream errors are translated to BFF errors at the client boundary — **neve
|
|||||||
|
|
||||||
### Per-integration ADRs
|
### Per-integration ADRs
|
||||||
|
|
||||||
A downstream that requires a non-default auth strategy, a custom retry policy, a different audience model, or a special trust relationship gets its own ADR. Trivial integrations (Entra OBO, default policies, workforce-only) live in code config without a dedicated ADR. The threshold is *non-trivial deviation*, not *every integration*.
|
A downstream that requires a non-default auth strategy, a custom retry policy, a different audience model, or a special trust relationship gets its own ADR. Trivial integrations (Entra OBO, default policies, workforce-only) live in code config without a dedicated ADR. The threshold is _non-trivial deviation_, not _every integration_.
|
||||||
|
|
||||||
### Configuration (env-driven)
|
### Configuration (env-driven)
|
||||||
|
|
||||||
| Variable | Purpose |
|
| Variable | Purpose |
|
||||||
| --- | --- |
|
| ---------------------------------------------- | ----------------------------------------------------------------------------------- |
|
||||||
| `OBO_CACHE_ENCRYPTION_KEY` | 32-byte base64 AES-GCM key for the OBO token cache; refused at startup if malformed |
|
| `OBO_CACHE_ENCRYPTION_KEY` | 32-byte base64 AES-GCM key for the OBO token cache; refused at startup if malformed |
|
||||||
| `BFF_JWKS_PRIVATE_KEY_PATH` | path to the BFF's private key for signing user-assertion JWTs |
|
| `BFF_JWKS_PRIVATE_KEY_PATH` | path to the BFF's private key for signing user-assertion JWTs |
|
||||||
| `BFF_JWKS_KID` | key id published in `/.well-known/jwks.json` |
|
| `BFF_JWKS_KID` | key id published in `/.well-known/jwks.json` |
|
||||||
@@ -140,27 +149,27 @@ A downstream that requires a non-default auth strategy, a custom retry policy, a
|
|||||||
|
|
||||||
### Consequences
|
### Consequences
|
||||||
|
|
||||||
* Good, because every downstream call goes through the same plumbing — same observability, same resilience, same audit posture, same audience check. Drift requires leaving the framework, which review will not accept.
|
- Good, because every downstream call goes through the same plumbing — same observability, same resilience, same audit posture, same audience check. Drift requires leaving the framework, which review will not accept.
|
||||||
* Good, because OBO + cached tokens keeps Entra rate limits low and call latency predictable.
|
- Good, because OBO + cached tokens keeps Entra rate limits low and call latency predictable.
|
||||||
* Good, because the audience constraint at the call site enforces the dual-audience design at the *narrowest* point that actually emits a request.
|
- Good, because the audience constraint at the call site enforces the dual-audience design at the _narrowest_ point that actually emits a request.
|
||||||
* Good, because the signed-assertion strategy gives a clean answer for non-Entra downstreams without storing user credentials.
|
- Good, because the signed-assertion strategy gives a clean answer for non-Entra downstreams without storing user credentials.
|
||||||
* Good, because resilience composition is explicit and testable — tail latency under partial failure is bounded.
|
- Good, because resilience composition is explicit and testable — tail latency under partial failure is bounded.
|
||||||
* Good, because a per-service translation of error codes prevents downstream details from leaking into BFF error responses.
|
- Good, because a per-service translation of error codes prevents downstream details from leaking into BFF error responses.
|
||||||
* Bad, because the framework is non-trivial — contributors must learn it before adding a downstream. Mitigated by a one-page "how to add a downstream" guide in `docs/`, written when the first integration ships.
|
- Bad, because the framework is non-trivial — contributors must learn it before adding a downstream. Mitigated by a one-page "how to add a downstream" guide in `docs/`, written when the first integration ships.
|
||||||
* Bad, because the signed-assertion strategy requires the downstream to validate the BFF's JWT — operational coordination per integration, not a magic switch.
|
- Bad, because the signed-assertion strategy requires the downstream to validate the BFF's JWT — operational coordination per integration, not a magic switch.
|
||||||
* Bad, because the OBO cache adds a second tier-1 secret to manage (`OBO_CACHE_ENCRYPTION_KEY`). Folded into the future ops/secret-rotation ADR.
|
- Bad, because the OBO cache adds a second tier-1 secret to manage (`OBO_CACHE_ENCRYPTION_KEY`). Folded into the future ops/secret-rotation ADR.
|
||||||
* Bad, because the framework is forward-looking — there is no concrete v1 caller. Risk of drift between framework and real needs. Mitigated by writing the framework code only in the same iteration as the first concrete integration; until then, this ADR plus mock-driven unit tests on the strategies (OBO, signed-assertion) keep the design honest.
|
- Bad, because the framework is forward-looking — there is no concrete v1 caller. Risk of drift between framework and real needs. Mitigated by writing the framework code only in the same iteration as the first concrete integration; until then, this ADR plus mock-driven unit tests on the strategies (OBO, signed-assertion) keep the design honest.
|
||||||
|
|
||||||
### Confirmation
|
### Confirmation
|
||||||
|
|
||||||
* `apps/portal-bff/src/downstream/downstream.module.ts` exposes `DownstreamApiClientFactory`.
|
- `apps/portal-bff/src/downstream/downstream.module.ts` exposes `DownstreamApiClientFactory`.
|
||||||
* `apps/portal-bff/src/downstream/strategies/obo.strategy.ts` and `service-with-assertion.strategy.ts` exist with unit tests against MSAL Node mocks (OBO) and JWT-verification mocks (assertion).
|
- `apps/portal-bff/src/downstream/strategies/obo.strategy.ts` and `service-with-assertion.strategy.ts` exist with unit tests against MSAL Node mocks (OBO) and JWT-verification mocks (assertion).
|
||||||
* `apps/portal-bff/src/downstream/token-cache.service.ts` reads/writes Redis with AES-256-GCM via the dedicated key; an integration test asserts the ciphertext is unrecoverable without the key and that tampering is rejected.
|
- `apps/portal-bff/src/downstream/token-cache.service.ts` reads/writes Redis with AES-256-GCM via the dedicated key; an integration test asserts the ciphertext is unrecoverable without the key and that tampering is rejected.
|
||||||
* `cockatiel` policies are composed in `apps/portal-bff/src/downstream/resilience.ts`. Tests cover: timeout firing, retry on 5xx, circuit-breaker open/half-open transitions, bulkhead saturation.
|
- `cockatiel` policies are composed in `apps/portal-bff/src/downstream/resilience.ts`. Tests cover: timeout firing, retry on 5xx, circuit-breaker open/half-open transitions, bulkhead saturation.
|
||||||
* The audience pre-check rejects with 403 and emits `authz.deny` (ADR-0013); a test exercises a customer-audience principal targeting a workforce-only service.
|
- The audience pre-check rejects with 403 and emits `authz.deny` (ADR-0013); a test exercises a customer-audience principal targeting a workforce-only service.
|
||||||
* Each call produces a `downstream.<service>.<verb>.<path>` span; an integration test asserts trace continuity from the BFF's incoming request to the downstream span.
|
- Each call produces a `downstream.<service>.<verb>.<path>` span; an integration test asserts trace continuity from the BFF's incoming request to the downstream span.
|
||||||
* The BFF refuses to start if `OBO_CACHE_ENCRYPTION_KEY`, `BFF_JWKS_PRIVATE_KEY_PATH`, or `BFF_JWKS_KID` is missing or malformed.
|
- The BFF refuses to start if `OBO_CACHE_ENCRYPTION_KEY`, `BFF_JWKS_PRIVATE_KEY_PATH`, or `BFF_JWKS_KID` is missing or malformed.
|
||||||
* No production controller imports `axios` or `fetch` directly — enforced by an ESLint rule that flags those imports outside `apps/portal-bff/src/downstream/`. (Rule lands with the future CI ADR.)
|
- No production controller imports `axios` or `fetch` directly — enforced by an ESLint rule that flags those imports outside `apps/portal-bff/src/downstream/`. (Rule lands with the future CI ADR.)
|
||||||
|
|
||||||
## Pros and Cons of the Options
|
## Pros and Cons of the Options
|
||||||
|
|
||||||
@@ -168,81 +177,81 @@ A downstream that requires a non-default auth strategy, a custom retry policy, a
|
|||||||
|
|
||||||
#### OBO for Entra (chosen default)
|
#### OBO for Entra (chosen default)
|
||||||
|
|
||||||
* Good, because it is Microsoft's recommended pattern for confidential clients calling Entra-protected APIs on behalf of a user.
|
- Good, because it is Microsoft's recommended pattern for confidential clients calling Entra-protected APIs on behalf of a user.
|
||||||
* Good, because preserves the user's audience, claims, and tenancy in the downstream token.
|
- Good, because preserves the user's audience, claims, and tenancy in the downstream token.
|
||||||
* Bad, because requires Entra-protected downstreams.
|
- Bad, because requires Entra-protected downstreams.
|
||||||
|
|
||||||
#### Service credential + signed user-assertion (chosen for non-Entra)
|
#### Service credential + signed user-assertion (chosen for non-Entra)
|
||||||
|
|
||||||
* Good, because retains user identity for downstream authorization without giving the downstream a token it could replay against the IdP.
|
- Good, because retains user identity for downstream authorization without giving the downstream a token it could replay against the IdP.
|
||||||
* Good, because the BFF acts as a credentialed gateway — the standard pattern when downstreams cannot be brought into the IdP.
|
- Good, because the BFF acts as a credentialed gateway — the standard pattern when downstreams cannot be brought into the IdP.
|
||||||
* Bad, because requires per-integration trust setup (publishing JWKS, having the downstream verify).
|
- Bad, because requires per-integration trust setup (publishing JWKS, having the downstream verify).
|
||||||
|
|
||||||
#### Token relay
|
#### Token relay
|
||||||
|
|
||||||
* Good, because zero exchange.
|
- Good, because zero exchange.
|
||||||
* Bad, because only works if the downstream and the BFF share the exact same audience/resource. Non-portable.
|
- Bad, because only works if the downstream and the BFF share the exact same audience/resource. Non-portable.
|
||||||
* Bad, because a leaky downstream that logs Authorization headers leaks the user's BFF-scoped token.
|
- Bad, because a leaky downstream that logs Authorization headers leaks the user's BFF-scoped token.
|
||||||
|
|
||||||
#### Per-user credential mapping
|
#### Per-user credential mapping
|
||||||
|
|
||||||
* Bad, because operationally toxic — storing user passwords or tokens for legacy systems, violating user secrecy. Rejected for v1.
|
- Bad, because operationally toxic — storing user passwords or tokens for legacy systems, violating user secrecy. Rejected for v1.
|
||||||
|
|
||||||
### HTTP client
|
### HTTP client
|
||||||
|
|
||||||
#### `@nestjs/axios` (chosen)
|
#### `@nestjs/axios` (chosen)
|
||||||
|
|
||||||
* Good, because mainstream, integrates with NestJS DI, easy to wrap with interceptors and OTel.
|
- Good, because mainstream, integrates with NestJS DI, easy to wrap with interceptors and OTel.
|
||||||
|
|
||||||
#### `got` / `undici` / native `fetch`
|
#### `got` / `undici` / native `fetch`
|
||||||
|
|
||||||
* Good, because lighter or more modern.
|
- Good, because lighter or more modern.
|
||||||
* Bad, because no NestJS-blessed wrapper; we'd reinvent the surface for marginal gain.
|
- Bad, because no NestJS-blessed wrapper; we'd reinvent the surface for marginal gain.
|
||||||
|
|
||||||
### Resilience
|
### Resilience
|
||||||
|
|
||||||
#### `cockatiel` (chosen)
|
#### `cockatiel` (chosen)
|
||||||
|
|
||||||
* Good, because TypeScript-native, composable policies, well-maintained.
|
- Good, because TypeScript-native, composable policies, well-maintained.
|
||||||
* Good, because exposes timeout, retry, circuit breaker, bulkhead, fallback as first-class concepts.
|
- Good, because exposes timeout, retry, circuit breaker, bulkhead, fallback as first-class concepts.
|
||||||
|
|
||||||
#### `p-retry` / `opossum`
|
#### `p-retry` / `opossum`
|
||||||
|
|
||||||
* Good, because simpler.
|
- Good, because simpler.
|
||||||
* Bad, because narrower (one concern each) — composing them into a coherent policy stack reproduces what `cockatiel` already gives.
|
- Bad, because narrower (one concern each) — composing them into a coherent policy stack reproduces what `cockatiel` already gives.
|
||||||
|
|
||||||
### Token cache
|
### Token cache
|
||||||
|
|
||||||
#### Redis encrypted (chosen)
|
#### Redis encrypted (chosen)
|
||||||
|
|
||||||
* Good, because shared across BFF replicas, survives restart, encrypted at rest.
|
- Good, because shared across BFF replicas, survives restart, encrypted at rest.
|
||||||
* Bad, because adds a Redis round-trip per OBO acquisition. Negligible vs. an Entra round-trip.
|
- Bad, because adds a Redis round-trip per OBO acquisition. Negligible vs. an Entra round-trip.
|
||||||
|
|
||||||
#### In-memory only
|
#### In-memory only
|
||||||
|
|
||||||
* Bad, because per-replica caches duplicate work and break cache hit-rate under scale-out.
|
- Bad, because per-replica caches duplicate work and break cache hit-rate under scale-out.
|
||||||
|
|
||||||
#### No cache
|
#### No cache
|
||||||
|
|
||||||
* Bad, because hits Entra rate limits and adds 100–300 ms per downstream call.
|
- Bad, because hits Entra rate limits and adds 100–300 ms per downstream call.
|
||||||
|
|
||||||
### Service registration
|
### Service registration
|
||||||
|
|
||||||
#### Typed config (chosen)
|
#### Typed config (chosen)
|
||||||
|
|
||||||
* Good, because explicit, reviewable, IDE-discoverable.
|
- Good, because explicit, reviewable, IDE-discoverable.
|
||||||
* Bad, because every new service requires a config commit. Acceptable.
|
- Bad, because every new service requires a config commit. Acceptable.
|
||||||
|
|
||||||
#### Decorator-based
|
#### Decorator-based
|
||||||
|
|
||||||
* Good, because feels Nest-native.
|
- Good, because feels Nest-native.
|
||||||
* Bad, because hides registration in source — harder to grep, harder to audit. Defer.
|
- Bad, because hides registration in source — harder to grep, harder to audit. Defer.
|
||||||
|
|
||||||
## More Information
|
## More Information
|
||||||
|
|
||||||
* Microsoft On-Behalf-Of flow: https://learn.microsoft.com/entra/identity-platform/v2-oauth2-on-behalf-of-flow
|
- Microsoft On-Behalf-Of flow: https://learn.microsoft.com/entra/identity-platform/v2-oauth2-on-behalf-of-flow
|
||||||
* MSAL Node `acquireTokenOnBehalfOf`: https://learn.microsoft.com/javascript/api/@azure/msal-node/confidentialclientapplication
|
- MSAL Node `acquireTokenOnBehalfOf`: https://learn.microsoft.com/javascript/api/@azure/msal-node/confidentialclientapplication
|
||||||
* `@nestjs/axios`: https://docs.nestjs.com/techniques/http-module
|
- `@nestjs/axios`: https://docs.nestjs.com/techniques/http-module
|
||||||
* `cockatiel`: https://github.com/connor4312/cockatiel
|
- `cockatiel`: https://github.com/connor4312/cockatiel
|
||||||
* RFC 7517 (JWKS): https://datatracker.ietf.org/doc/html/rfc7517
|
- RFC 7517 (JWKS): https://datatracker.ietf.org/doc/html/rfc7517
|
||||||
* Related ADRs: [ADR-0005](0005-backend-stack-nestjs.md), [ADR-0008](0008-identity-model-entra-workforce-dual-audience.md), [ADR-0009](0009-auth-flow-oidc-pkce-msal-node.md), [ADR-0010](0010-session-management-redis.md), [ADR-0012](0012-observability-pino-opentelemetry.md), [ADR-0013](0013-audit-trail-separated-postgres-append-only.md).
|
- Related ADRs: [ADR-0005](0005-backend-stack-nestjs.md), [ADR-0008](0008-identity-model-entra-workforce-dual-audience.md), [ADR-0009](0009-auth-flow-oidc-pkce-msal-node.md), [ADR-0010](0010-session-management-redis.md), [ADR-0012](0012-observability-pino-opentelemetry.md), [ADR-0013](0013-audit-trail-separated-postgres-append-only.md).
|
||||||
@@ -14,55 +14,63 @@ The repository currently lives on Gitea (`gitea@git.unespace.com:julien/apf_port
|
|||||||
We also need to fix the branch model, the merge strategy, the required gates, the protection rules on `main`, and the location of the orchestration logic — all of these have first-order effects on the Nx scaffold (`package.json` scripts, lint configs, branch tags, conventional-commits validation surface).
|
We also need to fix the branch model, the merge strategy, the required gates, the protection rules on `main`, and the location of the orchestration logic — all of these have first-order effects on the Nx scaffold (`package.json` scripts, lint configs, branch tags, conventional-commits validation surface).
|
||||||
|
|
||||||
This ADR is split into two levels of decision:
|
This ADR is split into two levels of decision:
|
||||||
|
|
||||||
- **Level 1 — vendor-neutral.** Survives the future GitLab migration unchanged.
|
- **Level 1 — vendor-neutral.** Survives the future GitLab migration unchanged.
|
||||||
- **Level 2 — Gitea Actions implementation.** Will be rewritten when GitLab is adopted; a future ADR will supersede the level-2 sections without affecting level-1.
|
- **Level 2 — Gitea Actions implementation.** Will be rewritten when GitLab is adopted; a future ADR will supersede the level-2 sections without affecting level-1.
|
||||||
|
|
||||||
## Decision Drivers
|
## Decision Drivers
|
||||||
|
|
||||||
* Pipeline portability across CI vendors (Gitea now, GitLab later, possibly other platforms in the future).
|
- Pipeline portability across CI vendors (Gitea now, GitLab later, possibly other platforms in the future).
|
||||||
* All gates blocking — no "warnings ignored". Either we fail or we adjust the threshold via ADR.
|
- All gates blocking — no "warnings ignored". Either we fail or we adjust the threshold via ADR.
|
||||||
* Trunk-based development for fast feedback and continuous integration discipline.
|
- Trunk-based development for fast feedback and continuous integration discipline.
|
||||||
* Compatibility with the Nx monorepo's `affected` model and remote cache.
|
- Compatibility with the Nx monorepo's `affected` model and remote cache.
|
||||||
* Self-hosted runners (on-prem context — see ADR-0008's hosting constraint).
|
- Self-hosted runners (on-prem context — see ADR-0008's hosting constraint).
|
||||||
* Mature, mainstream tooling — anti-bricolage applies particularly here.
|
- Mature, mainstream tooling — anti-bricolage applies particularly here.
|
||||||
* Local enforcement (Husky + lint-staged + commitlint, ADR-0007) plus CI defense in depth — the same checks run twice, on purpose.
|
- Local enforcement (Husky + lint-staged + commitlint, ADR-0007) plus CI defense in depth — the same checks run twice, on purpose.
|
||||||
|
|
||||||
## Considered Options
|
## Considered Options
|
||||||
|
|
||||||
### Branch / merge strategy (level 1)
|
### Branch / merge strategy (level 1)
|
||||||
* **Trunk-based + squash-merge.** (Chosen.)
|
|
||||||
* Trunk-based + rebase-merge.
|
- **Trunk-based + squash-merge.** (Chosen.)
|
||||||
* Trunk-based + merge commit.
|
- Trunk-based + rebase-merge.
|
||||||
* GitFlow.
|
- Trunk-based + merge commit.
|
||||||
|
- GitFlow.
|
||||||
|
|
||||||
### Required reviewer count on PRs to `main` (level 1)
|
### Required reviewer count on PRs to `main` (level 1)
|
||||||
* **0 in v1, ≥1 once a second active contributor exists.** (Chosen.)
|
|
||||||
* 1 always (blocks if solo).
|
- **0 in v1, ≥1 once a second active contributor exists.** (Chosen.)
|
||||||
* 2 (heavy for a small team).
|
- 1 always (blocks if solo).
|
||||||
|
- 2 (heavy for a small team).
|
||||||
|
|
||||||
### Signed commits (level 1)
|
### Signed commits (level 1)
|
||||||
* **Recommended but not required in v1; reconsidered at the GitLab migration ADR.** (Chosen.)
|
|
||||||
* Required in v1.
|
- **Recommended but not required in v1; reconsidered at the GitLab migration ADR.** (Chosen.)
|
||||||
* Never.
|
- Required in v1.
|
||||||
|
- Never.
|
||||||
|
|
||||||
### Conventional Commits validation (level 1)
|
### Conventional Commits validation (level 1)
|
||||||
* **Local `commit-msg` hook (ADR-0007) + CI defense-in-depth on the PR commit range.** (Chosen.)
|
|
||||||
* Local hook only.
|
- **Local `commit-msg` hook (ADR-0007) + CI defense-in-depth on the PR commit range.** (Chosen.)
|
||||||
* CI only.
|
- Local hook only.
|
||||||
|
- CI only.
|
||||||
|
|
||||||
### Pipeline orchestration logic location (level 1)
|
### Pipeline orchestration logic location (level 1)
|
||||||
* **Thin YAML — logic lives in `package.json` scripts and Nx targets, the workflow file orchestrates.** (Chosen.)
|
|
||||||
* Logic in YAML, scripts called step by step.
|
- **Thin YAML — logic lives in `package.json` scripts and Nx targets, the workflow file orchestrates.** (Chosen.)
|
||||||
|
- Logic in YAML, scripts called step by step.
|
||||||
|
|
||||||
### CI engine (level 2 — Gitea-specific)
|
### CI engine (level 2 — Gitea-specific)
|
||||||
* **Gitea Actions** (built-in since Gitea 1.19, GitHub Actions-compatible YAML). (Chosen.)
|
|
||||||
* Drone CI alongside Gitea.
|
- **Gitea Actions** (built-in since Gitea 1.19, GitHub Actions-compatible YAML). (Chosen.)
|
||||||
* Concourse / Tekton / Buildkite / etc.
|
- Drone CI alongside Gitea.
|
||||||
|
- Concourse / Tekton / Buildkite / etc.
|
||||||
|
|
||||||
### Runner topology (level 2)
|
### Runner topology (level 2)
|
||||||
* **≥ 3 self-hosted `act_runner` instances on-prem.** (Chosen.)
|
|
||||||
* Single runner.
|
- **≥ 3 self-hosted `act_runner` instances on-prem.** (Chosen.)
|
||||||
* Cloud-hosted (rejected — on-prem constraint).
|
- Single runner.
|
||||||
|
- Cloud-hosted (rejected — on-prem constraint).
|
||||||
|
|
||||||
## Decision Outcome
|
## Decision Outcome
|
||||||
|
|
||||||
@@ -73,6 +81,7 @@ This ADR is split into two levels of decision:
|
|||||||
**Merge strategy.** Squash-merge only. The squash subject is the PR title and must be a valid Conventional Commits message; the squash body inherits the PR body. This produces a clean linear history on `main` where each commit corresponds 1:1 to a PR. Rebase-merge and merge-commit are disabled at the platform level.
|
**Merge strategy.** Squash-merge only. The squash subject is the PR title and must be a valid Conventional Commits message; the squash body inherits the PR body. This produces a clean linear history on `main` where each commit corresponds 1:1 to a PR. Rebase-merge and merge-commit are disabled at the platform level.
|
||||||
|
|
||||||
**Branch protection on `main`:**
|
**Branch protection on `main`:**
|
||||||
|
|
||||||
- direct push: forbidden (no exceptions, including the project lead);
|
- direct push: forbidden (no exceptions, including the project lead);
|
||||||
- force push: forbidden;
|
- force push: forbidden;
|
||||||
- linear history: required (consistent with squash-merge);
|
- linear history: required (consistent with squash-merge);
|
||||||
@@ -84,7 +93,7 @@ This ADR is split into two levels of decision:
|
|||||||
**Required CI gates** (every gate is blocking; failing any blocks the merge):
|
**Required CI gates** (every gate is blocking; failing any blocks the merge):
|
||||||
|
|
||||||
| Gate | What it runs | Tooling |
|
| Gate | What it runs | Tooling |
|
||||||
| --- | --- | --- |
|
| ------------- | -------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- |
|
||||||
| `format` | Prettier check, no auto-fix | `prettier --check` via `pnpm nx format:check` |
|
| `format` | Prettier check, no auto-fix | `prettier --check` via `pnpm nx format:check` |
|
||||||
| `lint` | ESLint across affected projects, including `@nx/enforce-module-boundaries` | `pnpm nx affected -t lint` |
|
| `lint` | ESLint across affected projects, including `@nx/enforce-module-boundaries` | `pnpm nx affected -t lint` |
|
||||||
| `type-check` | TypeScript strict, no emit | `pnpm nx affected -t type-check` |
|
| `type-check` | TypeScript strict, no emit | `pnpm nx affected -t type-check` |
|
||||||
@@ -100,7 +109,7 @@ This ADR is split into two levels of decision:
|
|||||||
|
|
||||||
**Signed commits.** Recommended but not required in v1. Setup overhead for contributors (GPG or SSH signing key) is non-trivial relative to the marginal value with a single contributor and a well-controlled host. The decision is revisited as part of the GitLab migration ADR (GitLab has stronger built-in tooling for centralised signing policies than current Gitea).
|
**Signed commits.** Recommended but not required in v1. Setup overhead for contributors (GPG or SSH signing key) is non-trivial relative to the marginal value with a single contributor and a well-controlled host. The decision is revisited as part of the GitLab migration ADR (GitLab has stronger built-in tooling for centralised signing policies than current Gitea).
|
||||||
|
|
||||||
**Logic location — the "thin YAML" pattern.** All non-trivial CI logic lives in `package.json` scripts and Nx targets, *not* in the workflow YAML. The YAML's role is restricted to: checkout, runtime setup, cache restoration, and calling a single high-level script per job. Concretely:
|
**Logic location — the "thin YAML" pattern.** All non-trivial CI logic lives in `package.json` scripts and Nx targets, _not_ in the workflow YAML. The YAML's role is restricted to: checkout, runtime setup, cache restoration, and calling a single high-level script per job. Concretely:
|
||||||
|
|
||||||
```jsonc
|
```jsonc
|
||||||
// package.json (excerpt — lands with the scaffold)
|
// package.json (excerpt — lands with the scaffold)
|
||||||
@@ -114,12 +123,14 @@ This ADR is split into two levels of decision:
|
|||||||
The migration to GitLab then becomes a rewrite of the YAML wrappers (a few dozen lines) and not a re-derivation of the gates — these scripts are platform-agnostic, runnable locally, and serve as the source of truth.
|
The migration to GitLab then becomes a rewrite of the YAML wrappers (a few dozen lines) and not a re-derivation of the gates — these scripts are platform-agnostic, runnable locally, and serve as the source of truth.
|
||||||
|
|
||||||
**Caching.** Two cache surfaces, both portable:
|
**Caching.** Two cache surfaces, both portable:
|
||||||
|
|
||||||
- the `pnpm` store, keyed on `pnpm-lock.yaml`;
|
- the `pnpm` store, keyed on `pnpm-lock.yaml`;
|
||||||
- the Nx local cache (`.nx/cache`), keyed on the project graph.
|
- the Nx local cache (`.nx/cache`), keyed on the project graph.
|
||||||
|
|
||||||
The level-2 implementation wires both via the CI vendor's cache action; the level-1 contract is "these two paths must be cached".
|
The level-2 implementation wires both via the CI vendor's cache action; the level-1 contract is "these two paths must be cached".
|
||||||
|
|
||||||
**Secrets policy.**
|
**Secrets policy.**
|
||||||
|
|
||||||
- Secrets live exclusively in CI vendor variables (Gitea → GitLab later). Naming convention: `SCOPE_PURPOSE` (e.g. `BFF_DATABASE_URL`, `OBO_CACHE_ENCRYPTION_KEY`).
|
- Secrets live exclusively in CI vendor variables (Gitea → GitLab later). Naming convention: `SCOPE_PURPOSE` (e.g. `BFF_DATABASE_URL`, `OBO_CACHE_ENCRYPTION_KEY`).
|
||||||
- No secret ever in source. `gitleaks` enforces.
|
- No secret ever in source. `gitleaks` enforces.
|
||||||
- Rotation procedures, key vault, and operator runbooks belong in the future operations / secret-management ADR.
|
- Rotation procedures, key vault, and operator runbooks belong in the future operations / secret-management ADR.
|
||||||
@@ -133,6 +144,7 @@ The level-2 implementation wires both via the CI vendor's cache action; the leve
|
|||||||
**Runners.** Three self-hosted `act_runner` instances on internal infrastructure. The first runner is deployed to validate the pipeline; the second and third are added before the project hits any non-trivial PR volume. Runners are labelled `self-hosted`, `on-prem`, plus capacity labels (`size:default`) for future job differentiation. Runner image baseline: a Debian image (aligned with the WSL development environment) pinned by SHA and rebuilt on a cadence by a security-scheduled job.
|
**Runners.** Three self-hosted `act_runner` instances on internal infrastructure. The first runner is deployed to validate the pipeline; the second and third are added before the project hits any non-trivial PR volume. Runners are labelled `self-hosted`, `on-prem`, plus capacity labels (`size:default`) for future job differentiation. Runner image baseline: a Debian image (aligned with the WSL development environment) pinned by SHA and rebuilt on a cadence by a security-scheduled job.
|
||||||
|
|
||||||
**Workflow file structure.**
|
**Workflow file structure.**
|
||||||
|
|
||||||
- `.gitea/workflows/ci.yml` — runs on `pull_request` and `push` to `main`. Hosts the `check`, `scan`, and `commits` jobs.
|
- `.gitea/workflows/ci.yml` — runs on `pull_request` and `push` to `main`. Hosts the `check`, `scan`, and `commits` jobs.
|
||||||
- `.gitea/workflows/release.yml` — runs on `push` of a `vX.Y.Z` tag. Builds release artefacts. Empty stub in v1; gains content when the on-prem deploy ADR lands.
|
- `.gitea/workflows/release.yml` — runs on `push` of a `vX.Y.Z` tag. Builds release artefacts. Empty stub in v1; gains content when the on-prem deploy ADR lands.
|
||||||
- `.gitea/workflows/security-scheduled.yml` — runs weekly via `schedule:` cron. Re-runs Trivy and gitleaks on the full tree (not just affected), and triggers Renovate (configuration covered by the security baseline ADR).
|
- `.gitea/workflows/security-scheduled.yml` — runs weekly via `schedule:` cron. Re-runs Trivy and gitleaks on the full tree (not just affected), and triggers Renovate (configuration covered by the security baseline ADR).
|
||||||
@@ -187,6 +199,7 @@ jobs:
|
|||||||
This is a sketch, not the final file — the final lands with the scaffold and any iteration on it is not by itself an ADR-worthy change.
|
This is a sketch, not the final file — the final lands with the scaffold and any iteration on it is not by itself an ADR-worthy change.
|
||||||
|
|
||||||
**Branch protection (Gitea-side configuration):** `Settings → Branches → Add rule` on `main`:
|
**Branch protection (Gitea-side configuration):** `Settings → Branches → Add rule` on `main`:
|
||||||
|
|
||||||
- Disable force push: yes;
|
- Disable force push: yes;
|
||||||
- Disable direct push: yes (only PR merge);
|
- Disable direct push: yes (only PR merge);
|
||||||
- Required status checks: `check`, `scan`, `commits`, plus future `a11y`, `perf`;
|
- Required status checks: `check`, `scan`, `commits`, plus future `a11y`, `perf`;
|
||||||
@@ -198,7 +211,7 @@ This is a sketch, not the final file — the final lands with the scaffold and a
|
|||||||
|
|
||||||
When the GitLab migration happens (6–18-month horizon), a new ADR will be written that **supersedes only the level-2 sections** of this ADR. The level-1 decisions stand unchanged. Concretely the migration touches:
|
When the GitLab migration happens (6–18-month horizon), a new ADR will be written that **supersedes only the level-2 sections** of this ADR. The level-1 decisions stand unchanged. Concretely the migration touches:
|
||||||
|
|
||||||
- `.gitea/workflows/*.yml` → `.gitlab-ci.yml` (rewrite — the *gates* are the same, the *DSL* is different). Estimate: 1–2 days.
|
- `.gitea/workflows/*.yml` → `.gitlab-ci.yml` (rewrite — the _gates_ are the same, the _DSL_ is different). Estimate: 1–2 days.
|
||||||
- Self-hosted `act_runner` → GitLab Runner. Estimate: 0.5–1 day.
|
- Self-hosted `act_runner` → GitLab Runner. Estimate: 0.5–1 day.
|
||||||
- Gitea branch protection rules → GitLab merge request approval rules. Same concepts, different UI. Estimate: a few hours.
|
- Gitea branch protection rules → GitLab merge request approval rules. Same concepts, different UI. Estimate: a few hours.
|
||||||
- Secrets re-creation in GitLab CI/CD variables. Same naming convention, copy values. Estimate: a few hours.
|
- Secrets re-creation in GitLab CI/CD variables. Same naming convention, copy values. Estimate: a few hours.
|
||||||
@@ -207,27 +220,27 @@ The `package.json` scripts (`ci:check`, `ci:scan`, `ci:commits`), the Nx workspa
|
|||||||
|
|
||||||
### Consequences
|
### Consequences
|
||||||
|
|
||||||
* Good, because the gates are written once and run twice (locally via hooks, in CI as defense). Drift is impossible without breaking both layers.
|
- Good, because the gates are written once and run twice (locally via hooks, in CI as defense). Drift is impossible without breaking both layers.
|
||||||
* Good, because thin YAML keeps CI reproducible locally — anyone can run `pnpm ci:check` to mirror what the runner does.
|
- Good, because thin YAML keeps CI reproducible locally — anyone can run `pnpm ci:check` to mirror what the runner does.
|
||||||
* Good, because Gitea Actions' GHA-compatible syntax doubles as a hedge: the same YAML can land on GitHub Actions if the org's plans change again.
|
- Good, because Gitea Actions' GHA-compatible syntax doubles as a hedge: the same YAML can land on GitHub Actions if the org's plans change again.
|
||||||
* Good, because squash-merge produces a Conventional-Commits-only history on `main` — clean changelog generation, predictable release notes, smooth semver inference.
|
- Good, because squash-merge produces a Conventional-Commits-only history on `main` — clean changelog generation, predictable release notes, smooth semver inference.
|
||||||
* Good, because branch protection is enforced at the platform level, not at convention level.
|
- Good, because branch protection is enforced at the platform level, not at convention level.
|
||||||
* Good, because the migration weight is bounded and transparent: ~3–5 days of dev/ops work, no code rewrite.
|
- Good, because the migration weight is bounded and transparent: ~3–5 days of dev/ops work, no code rewrite.
|
||||||
* Bad, because the level-2 sections will need to be rewritten at the GitLab migration. This is the explicit trade-off; it is accepted because the alternatives (going GitLab now without the existing tooling, or staying CI-less until GitLab) cost more.
|
- Bad, because the level-2 sections will need to be rewritten at the GitLab migration. This is the explicit trade-off; it is accepted because the alternatives (going GitLab now without the existing tooling, or staying CI-less until GitLab) cost more.
|
||||||
* Bad, because trunk-based with required-reviewers=0 in v1 leaves the project lead as the sole gatekeeper. Mitigated by mandating green CI (which the lead cannot bypass without rewriting branch protection — a deliberate, audit-visible action).
|
- Bad, because trunk-based with required-reviewers=0 in v1 leaves the project lead as the sole gatekeeper. Mitigated by mandating green CI (which the lead cannot bypass without rewriting branch protection — a deliberate, audit-visible action).
|
||||||
* Bad, because `act_runner` is younger than GitLab Runner; expect occasional rough edges, especially around large action ecosystems. Mitigated by pinning third-party actions by SHA and cadence-rebuilding the runner image.
|
- Bad, because `act_runner` is younger than GitLab Runner; expect occasional rough edges, especially around large action ecosystems. Mitigated by pinning third-party actions by SHA and cadence-rebuilding the runner image.
|
||||||
* Bad, because deferring signed commits to v2 means the v1 history won't carry attribution-grade signatures. Reasonable for a small team; revisit at GitLab migration.
|
- Bad, because deferring signed commits to v2 means the v1 history won't carry attribution-grade signatures. Reasonable for a small team; revisit at GitLab migration.
|
||||||
|
|
||||||
### Confirmation
|
### Confirmation
|
||||||
|
|
||||||
* `.gitea/workflows/ci.yml`, `release.yml`, `security-scheduled.yml` exist with the structure above. The `release.yml` may be a stub until the deploy ADR lands.
|
- `.gitea/workflows/ci.yml`, `release.yml`, `security-scheduled.yml` exist with the structure above. The `release.yml` may be a stub until the deploy ADR lands.
|
||||||
* `package.json` exposes `ci:check`, `ci:scan`, `ci:commits` scripts. Each is runnable locally and produces the same exit code as the CI job.
|
- `package.json` exposes `ci:check`, `ci:scan`, `ci:commits` scripts. Each is runnable locally and produces the same exit code as the CI job.
|
||||||
* Gitea branch protection on `main` has the rules above; configuration is documented in `docs/operations/branch-protection.md` (created with the scaffold).
|
- Gitea branch protection on `main` has the rules above; configuration is documented in `docs/operations/branch-protection.md` (created with the scaffold).
|
||||||
* `Trivy`, `gitleaks`, and `commitlint` are in `devDependencies` (or available as actions pinned by SHA — both acceptable).
|
- `Trivy`, `gitleaks`, and `commitlint` are in `devDependencies` (or available as actions pinned by SHA — both acceptable).
|
||||||
* At least three `act_runner` instances are registered to the org; their bootstrap and update procedure live in an operations doc.
|
- At least three `act_runner` instances are registered to the org; their bootstrap and update procedure live in an operations doc.
|
||||||
* CI exits non-zero on any gate failure; no gate is `continue-on-error: true`.
|
- CI exits non-zero on any gate failure; no gate is `continue-on-error: true`.
|
||||||
* Runner images are pinned by SHA in the workflows. The `security-scheduled.yml` job rebuilds the runner image weekly and reports to the security audit feed.
|
- Runner images are pinned by SHA in the workflows. The `security-scheduled.yml` job rebuilds the runner image weekly and reports to the security audit feed.
|
||||||
* A future migration ADR (GitLab) explicitly references this ADR, supersedes only level 2, and inherits level 1.
|
- A future migration ADR (GitLab) explicitly references this ADR, supersedes only level 2, and inherits level 1.
|
||||||
|
|
||||||
## Pros and Cons of the Options
|
## Pros and Cons of the Options
|
||||||
|
|
||||||
@@ -235,78 +248,78 @@ The `package.json` scripts (`ci:check`, `ci:scan`, `ci:commits`), the Nx workspa
|
|||||||
|
|
||||||
#### Trunk-based + squash-merge (chosen)
|
#### Trunk-based + squash-merge (chosen)
|
||||||
|
|
||||||
* Good, because clean linear history on `main`, one squash commit per PR.
|
- Good, because clean linear history on `main`, one squash commit per PR.
|
||||||
* Good, because Conventional Commits + squash-merge yields a directly machine-readable changelog.
|
- Good, because Conventional Commits + squash-merge yields a directly machine-readable changelog.
|
||||||
* Good, because feature branches stay short-lived — pressure against long-lived branches becomes structural, not cultural.
|
- Good, because feature branches stay short-lived — pressure against long-lived branches becomes structural, not cultural.
|
||||||
* Bad, because contributors lose granular commit history on the merged branch (the squash collapses it). Mitigated: the PR retains the full history for review purposes.
|
- Bad, because contributors lose granular commit history on the merged branch (the squash collapses it). Mitigated: the PR retains the full history for review purposes.
|
||||||
|
|
||||||
#### Trunk-based + rebase-merge
|
#### Trunk-based + rebase-merge
|
||||||
|
|
||||||
* Good, because preserves individual commits without merge bubbles.
|
- Good, because preserves individual commits without merge bubbles.
|
||||||
* Bad, because contributors must groom every commit to be CI-clean (each commit must compile and pass tests if we want a clean bisect history). High discipline cost; squash-merge gets most of the benefit at lower cost.
|
- Bad, because contributors must groom every commit to be CI-clean (each commit must compile and pass tests if we want a clean bisect history). High discipline cost; squash-merge gets most of the benefit at lower cost.
|
||||||
|
|
||||||
#### Trunk-based + merge commit
|
#### Trunk-based + merge commit
|
||||||
|
|
||||||
* Good, because preserves the full history, including the branch topology.
|
- Good, because preserves the full history, including the branch topology.
|
||||||
* Bad, because produces messy merge bubbles on `main`; conflicts with the "linear history" branch protection.
|
- Bad, because produces messy merge bubbles on `main`; conflicts with the "linear history" branch protection.
|
||||||
|
|
||||||
#### GitFlow
|
#### GitFlow
|
||||||
|
|
||||||
* Good, because release branches isolate stabilisation.
|
- Good, because release branches isolate stabilisation.
|
||||||
* Bad, because heavy for a continuously deployable monorepo; redundant with semver tags on a trunk-based main; introduces the "long-lived `develop` branch" anti-pattern.
|
- Bad, because heavy for a continuously deployable monorepo; redundant with semver tags on a trunk-based main; introduces the "long-lived `develop` branch" anti-pattern.
|
||||||
|
|
||||||
### CI engine (level 2)
|
### CI engine (level 2)
|
||||||
|
|
||||||
#### Gitea Actions (chosen)
|
#### Gitea Actions (chosen)
|
||||||
|
|
||||||
* Good, because built-in to Gitea, no extra deployment.
|
- Good, because built-in to Gitea, no extra deployment.
|
||||||
* Good, because GitHub Actions syntax means transferable skills and a partial portability hedge.
|
- Good, because GitHub Actions syntax means transferable skills and a partial portability hedge.
|
||||||
* Good, because actively developed by the Gitea team.
|
- Good, because actively developed by the Gitea team.
|
||||||
* Bad, because younger ecosystem than GitLab CI or GitHub Actions proper — expect occasional rough edges.
|
- Bad, because younger ecosystem than GitLab CI or GitHub Actions proper — expect occasional rough edges.
|
||||||
|
|
||||||
#### Drone CI
|
#### Drone CI
|
||||||
|
|
||||||
* Good, because mature, lean, opinionated.
|
- Good, because mature, lean, opinionated.
|
||||||
* Bad, because separate deployment and operational surface; YAML is Drone-specific (less portable than GHA-compatible Gitea Actions).
|
- Bad, because separate deployment and operational surface; YAML is Drone-specific (less portable than GHA-compatible Gitea Actions).
|
||||||
|
|
||||||
#### Concourse / Tekton / Buildkite
|
#### Concourse / Tekton / Buildkite
|
||||||
|
|
||||||
* Good, because powerful for complex pipelines.
|
- Good, because powerful for complex pipelines.
|
||||||
* Bad, because over-engineered for the v1 scope, and orthogonal to the Gitea/GitLab decision axis.
|
- Bad, because over-engineered for the v1 scope, and orthogonal to the Gitea/GitLab decision axis.
|
||||||
|
|
||||||
### Required reviewer count
|
### Required reviewer count
|
||||||
|
|
||||||
#### 0 in v1, raise later (chosen)
|
#### 0 in v1, raise later (chosen)
|
||||||
|
|
||||||
* Good, because doesn't block solo development.
|
- Good, because doesn't block solo development.
|
||||||
* Good, because the green-CI requirement still prevents the project lead from merging broken code without effort.
|
- Good, because the green-CI requirement still prevents the project lead from merging broken code without effort.
|
||||||
* Bad, because relies on the project lead's discipline (and CI's correctness) for code quality.
|
- Bad, because relies on the project lead's discipline (and CI's correctness) for code quality.
|
||||||
|
|
||||||
#### 1 always
|
#### 1 always
|
||||||
|
|
||||||
* Good, because rigour.
|
- Good, because rigour.
|
||||||
* Bad, because blocks if there's only one contributor — would force the project lead to merge their own PRs by overriding protection, which is the opposite of the intended behaviour.
|
- Bad, because blocks if there's only one contributor — would force the project lead to merge their own PRs by overriding protection, which is the opposite of the intended behaviour.
|
||||||
|
|
||||||
### Signed commits
|
### Signed commits
|
||||||
|
|
||||||
#### Optional in v1, revisited at GitLab migration (chosen)
|
#### Optional in v1, revisited at GitLab migration (chosen)
|
||||||
|
|
||||||
* Good, because no setup overhead for contributors during a phase where the priority is shipping the structural ADRs and scaffolding.
|
- Good, because no setup overhead for contributors during a phase where the priority is shipping the structural ADRs and scaffolding.
|
||||||
* Bad, because v1 history carries no attribution-grade signatures. Acceptable: the host (Gitea) records the user identity on each commit.
|
- Bad, because v1 history carries no attribution-grade signatures. Acceptable: the host (Gitea) records the user identity on each commit.
|
||||||
|
|
||||||
#### Required in v1
|
#### Required in v1
|
||||||
|
|
||||||
* Good, because rigorous attribution from day one.
|
- Good, because rigorous attribution from day one.
|
||||||
* Bad, because every contributor must set up GPG or SSH signing — high friction for early stages.
|
- Bad, because every contributor must set up GPG or SSH signing — high friction for early stages.
|
||||||
|
|
||||||
## More Information
|
## More Information
|
||||||
|
|
||||||
* Gitea Actions: https://docs.gitea.com/usage/actions/overview
|
- Gitea Actions: https://docs.gitea.com/usage/actions/overview
|
||||||
* `act_runner`: https://gitea.com/gitea/act_runner
|
- `act_runner`: https://gitea.com/gitea/act_runner
|
||||||
* GitHub Actions reference (compatible with Gitea Actions): https://docs.github.com/actions
|
- GitHub Actions reference (compatible with Gitea Actions): https://docs.github.com/actions
|
||||||
* Conventional Commits: https://www.conventionalcommits.org/
|
- Conventional Commits: https://www.conventionalcommits.org/
|
||||||
* commitlint: https://commitlint.js.org/
|
- commitlint: https://commitlint.js.org/
|
||||||
* Trivy: https://github.com/aquasecurity/trivy
|
- Trivy: https://github.com/aquasecurity/trivy
|
||||||
* gitleaks: https://github.com/gitleaks/gitleaks
|
- gitleaks: https://github.com/gitleaks/gitleaks
|
||||||
* `nrwl/nx-set-shas`: https://github.com/nrwl/nx-set-shas
|
- `nrwl/nx-set-shas`: https://github.com/nrwl/nx-set-shas
|
||||||
* Related ADRs: [ADR-0002](0002-adopt-nx-monorepo-apps-preset.md) (Nx workspace + `affected`), [ADR-0007](0007-pre-commit-hooks-and-conventional-commits.md) (local hooks + commitlint config), and the future ADRs for security baseline (Trivy / gitleaks / Renovate config), accessibility baseline (`a11y` gate), performance budgets (`perf` gate), on-prem infrastructure stack (deploy pipeline, runners hosting), and GitLab migration (level-2 supersession).
|
- Related ADRs: [ADR-0002](0002-adopt-nx-monorepo-apps-preset.md) (Nx workspace + `affected`), [ADR-0007](0007-pre-commit-hooks-and-conventional-commits.md) (local hooks + commitlint config), and the future ADRs for security baseline (Trivy / gitleaks / Renovate config), accessibility baseline (`a11y` gate), performance budgets (`perf` gate), on-prem infrastructure stack (deploy pipeline, runners hosting), and GitLab migration (level-2 supersession).
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
---
|
||||||
|
status: accepted
|
||||||
|
date: 2026-04-30
|
||||||
|
decision-makers: R&D Lead
|
||||||
|
tags: [accessibility, frontend, process]
|
||||||
|
---
|
||||||
|
|
||||||
|
# Accessibility baseline — WCAG 2.2 AA + targeted AAA, Angular CDK + spartan-ng + Tailwind, APF panel testing
|
||||||
|
|
||||||
|
## Context and Problem Statement
|
||||||
|
|
||||||
|
The host organisation is **APF France Handicap**. Accessibility is the organisation's core mission, not a compliance checkbox. A meaningful proportion of the portal's users will be in situations of disability — visual, motor, cognitive, hearing — and will rely on assistive technologies (screen readers, switch controls, eye tracking, voice control, head pointers, mouth sticks). The portal's accessibility quality is therefore a **product attribute**, not a downstream concern.
|
||||||
|
|
||||||
|
The default WCAG 2.2 AA baseline applicable to enterprise portals would _under-serve_ this user base. We need to fix:
|
||||||
|
|
||||||
|
- the conformance target (WCAG level + targeted enhancements);
|
||||||
|
- the implementation stack (component library, primitives, styling);
|
||||||
|
- the user-preferences surface (contrast, text size, motion, etc.) the portal must offer;
|
||||||
|
- the testing strategy, including manual testing with users from APF's internal network;
|
||||||
|
- the CI gates that prevent regression;
|
||||||
|
- the legal-disclosure surface (the EU Accessibility Act–required accessibility statement).
|
||||||
|
|
||||||
|
This ADR fixes the framework. Concrete component-by-component implementation lands as the scaffold and features arrive.
|
||||||
|
|
||||||
|
## Decision Drivers
|
||||||
|
|
||||||
|
- APF's mission elevates accessibility from "compliance" to "product".
|
||||||
|
- European Accessibility Act (EAA, in application since June 2025) makes WCAG 2.2 AA a legal floor in the EU; the portal will publish an accessibility statement and accept regression as a defect, not as a backlog item.
|
||||||
|
- RGAA 4.1 (French national standard, derived from WCAG) is the operational reference for accessibility audits in France; alignment is necessary to be auditable by French experts (including APF's own).
|
||||||
|
- The user base extends beyond screen-reader users: switch controls, eye tracking, voice control, cognitive load tolerance — all must be considered.
|
||||||
|
- The technical stack is locked on Angular ([ADR-0004](0004-frontend-stack-angular-csr-zoneless-signals.md)). Component-library choice must therefore be Angular-native or framework-agnostic.
|
||||||
|
- The dual-audience design ([ADR-0008](0008-identity-model-entra-workforce-dual-audience.md)) means a11y must be uniform across audiences — workforce and (future) customer users alike.
|
||||||
|
- Anti-bricolage: no hand-rolled a11y where a maintained library exists.
|
||||||
|
|
||||||
|
## Considered Options
|
||||||
|
|
||||||
|
### Conformance target
|
||||||
|
|
||||||
|
- WCAG 2.2 **AA** baseline (EU EAA legal floor).
|
||||||
|
- WCAG 2.2 **AA + targeted AAA** on criteria with high impact for APF's user base. (Chosen.)
|
||||||
|
- WCAG 2.2 **AAA** uniform.
|
||||||
|
|
||||||
|
### UI component stack
|
||||||
|
|
||||||
|
- Angular Material (Angular CDK + Material visual layer).
|
||||||
|
- **Angular CDK + spartan-ng + Tailwind.** (Chosen — see also `notes/argumentaire-stack-ui-spartan-cdk-tailwind.md` for the team-internal rationale.)
|
||||||
|
- Custom-on-Angular-CDK only (no spartan-ng).
|
||||||
|
- React-ecosystem libs (shadcn/ui, dice-ui, animate-ui).
|
||||||
|
|
||||||
|
### User-preferences scope
|
||||||
|
|
||||||
|
- None (rely on browser/OS prefs).
|
||||||
|
- `prefers-reduced-motion` only.
|
||||||
|
- **Full preferences panel** — contrast modes, text size, motion, spacing, simplified UI. (Chosen.)
|
||||||
|
|
||||||
|
### Testing strategy
|
||||||
|
|
||||||
|
- Automated only (axe-core + lint).
|
||||||
|
- Automated + external annual audit.
|
||||||
|
- **Automated + manual screen-reader/keyboard/switch testing + APF user panel cadence + annual internal audit.** (Chosen.)
|
||||||
|
|
||||||
|
### Documentation surface
|
||||||
|
|
||||||
|
- Internal docs only.
|
||||||
|
- **Public accessibility statement page (legal EAA requirement) + internal patterns library.** (Chosen.)
|
||||||
|
|
||||||
|
## Decision Outcome
|
||||||
|
|
||||||
|
### Conformance target
|
||||||
|
|
||||||
|
**WCAG 2.2 AA** as the universal baseline. **AAA** applied on the criteria that materially affect APF's user base:
|
||||||
|
|
||||||
|
| AAA criterion | What it adds vs AA | Why APF |
|
||||||
|
| ------------------------------------- | -------------------------------------------------------------------------------- | ------------------------------------------ |
|
||||||
|
| **1.4.6 Contrast (Enhanced)** | 7:1 normal text / 4.5:1 large text (vs 4.5:1 / 3:1 at AA) | Low-vision users are a primary audience |
|
||||||
|
| **2.2.3 No Timing** | No time limits except where essential | Cognitive load and motor speed vary widely |
|
||||||
|
| **2.3.3 Animation from Interactions** | All interaction-driven animation can be disabled | Vestibular and cognitive disorders |
|
||||||
|
| **3.1.5 Reading Level** | Content readable at lower secondary level _or_ a simpler alternative is provided | Cognitive accessibility |
|
||||||
|
| **1.4.8 Visual Presentation** | User control over text formatting (line spacing, paragraph spacing, line length) | Dyslexia, low vision |
|
||||||
|
| **2.4.9 Link Purpose (Link Only)** | Each link's purpose understandable from its text alone (no surrounding context) | Screen-reader navigation by link list |
|
||||||
|
| **3.3.5 Help** | Context-sensitive help available where needed | Cognitive accessibility |
|
||||||
|
|
||||||
|
The remaining AAA criteria (e.g. 1.2.6 Sign Language, 1.2.8 Media Alternative) are **not** uniformly required — they apply where the corresponding content type appears, and are addressed case by case. **RGAA 4.1** alignment is maintained as the audit reference for French experts (APF, CNIL audits, public-sector partners).
|
||||||
|
|
||||||
|
### User-preferences panel
|
||||||
|
|
||||||
|
The portal exposes a **first-class user-preferences panel** accessible from any page (keyboard-discoverable, screen-reader-announced, persisted in the user's session per [ADR-0010](0010-session-management-redis.md)):
|
||||||
|
|
||||||
|
| Preference | Options | Default |
|
||||||
|
| ------------------------ | --------------------------------------------------------------------------------------------------- | --------------------------------------- |
|
||||||
|
| Contrast mode | Standard (AA-compliant) / Enhanced (AAA, 7:1) / Maximum (high-contrast OS-style, monochrome) | Standard |
|
||||||
|
| Text size | 100 % / 125 % / 150 % / 175 % / 200 % | 100 % |
|
||||||
|
| Motion | Full / Reduced / None | inherited from `prefers-reduced-motion` |
|
||||||
|
| Text spacing | Default / Generous (line-spacing 1.8, paragraph-spacing 2×, letter-spacing 0.12em) | Default |
|
||||||
|
| Cognitive simplification | Off / On (reduces secondary chrome, larger primary actions, easier-to-read summaries when authored) | Off |
|
||||||
|
| Reading focus | Off / Line-by-line | Off |
|
||||||
|
|
||||||
|
Preferences are saved per user; an unauthenticated default is read from browser preferences (`prefers-reduced-motion`, `prefers-contrast`, etc.). Changing a preference takes effect immediately, no page reload.
|
||||||
|
|
||||||
|
### Component stack
|
||||||
|
|
||||||
|
**Angular CDK + TailwindCSS, with spartan-ng-style components written in-house** (the spartan-ng _library_ is deferred — see amendment below). See [`notes/argumentaire-stack-ui-spartan-cdk-tailwind.md`](../../notes/argumentaire-stack-ui-spartan-cdk-tailwind.md) for the original rationale; the design philosophy (headless primitives + Tailwind utility CSS + copy-paste components under our own control) is unchanged. Summary:
|
||||||
|
|
||||||
|
> **Amended on 2026-04-30** — Adoption of the spartan-ng package itself is deferred. At the time of scaffolding (2026-04-30), `@spartan-ng/brain` and `@spartan-ng/cli` were still at version `0.0.1-alpha.681`, which trips the project rule against pre-1.0 dependencies. The mitigation is to apply the spartan-ng _philosophy_ (headless primitives on Angular CDK + Tailwind utility CSS + copy-paste components owned in-source) without depending on the lib. Components for v1 are written in-house, in `libs/shared/ui`, on Angular CDK directly. spartan-ng adoption is revisited when the lib reaches `1.0.0`.
|
||||||
|
|
||||||
|
- **Angular CDK** (`@angular/cdk`) — primitives accessibility layer maintained by Google's Angular Material team. Equivalent role to Radix UI in React. Provides `Overlay`, `FocusTrap`, `FocusMonitor`, `LiveAnnouncer`, `A11yModule`, `ListKeyManager`, `Drag and Drop`, etc. Battle-tested since 2017.
|
||||||
|
- ~~**spartan-ng**~~ _(deferred — see amendment above)_. Components are written in-house in `libs/shared/ui/`, on Angular CDK, copy-paste-style. The spartan-ng project is consulted for _design inspiration_ (component patterns, ARIA usage, theming approach) without taking the dependency.
|
||||||
|
- **Tailwind CSS** — utility-first styling. Tokens for colour, spacing, contrast tiers live in `libs/shared/tokens/`. The contrast tiers map directly to the user preference modes above.
|
||||||
|
|
||||||
|
Angular Material is **rejected** because its visual design system (Material Design) is opinionated in ways that clash with both APF branding and the multi-tier contrast requirements. React-ecosystem libraries (shadcn/ui, dice-ui, animate-ui) are rejected as incompatible with the Angular stack ([ADR-0004](0004-frontend-stack-angular-csr-zoneless-signals.md)).
|
||||||
|
|
||||||
|
### Concrete patterns to enforce
|
||||||
|
|
||||||
|
| Concern | Rule |
|
||||||
|
| --------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| Sémantique HTML | `<header>`, `<main>`, `<nav>`, `<aside>`, `<footer>` landmarks. One `h1` per page. `<button>` for actions, `<a>` for navigation. **No** `<div onclick>` — flagged by ESLint and rejected at PR review |
|
||||||
|
| Skip-links | "Aller au contenu principal" link as the first focusable element on every page |
|
||||||
|
| Focus | Always visible (no `outline: none` without an at-least-equivalent replacement). Trapped in modals via Angular CDK `FocusTrap`. Restored after navigation/modal close |
|
||||||
|
| Forms | Each `<input>` has an associated `<label>`. Errors associated via `aria-describedby`. `autocomplete` attribute correct. Valid HTML5 `type` |
|
||||||
|
| Touch targets | Minimum **44 × 44 CSS pixels** (WCAG 2.5.5 AAA), default 48 × 48 in spartan-ng tokens for safety |
|
||||||
|
| Animations | Respect `prefers-reduced-motion` and the user-preferences `motion` setting. No autoplay video. No carousels with auto-rotation |
|
||||||
|
| Language | `lang` attribute on `<html>` set dynamically per active locale. Inline `lang` on foreign-language fragments |
|
||||||
|
| Images | All non-decorative images carry meaningful `alt`. Decorative images carry `alt=""` and `role="presentation"` |
|
||||||
|
| ARIA | Used **only** when native HTML semantics are insufficient. Misuse of ARIA (the most common a11y bug) is treated as a code-quality defect |
|
||||||
|
| Error messaging | Programmatic association (`aria-describedby`), live regions (`aria-live="polite"`) for server errors, never just colour to convey state |
|
||||||
|
|
||||||
|
### Tooling and CI gates
|
||||||
|
|
||||||
|
| Layer | Tool | Where it runs | Blocking? |
|
||||||
|
| ---------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- |
|
||||||
|
| Static lint on Angular templates | `@angular-eslint/template/*` rules (no-positive-tabindex, click-events-have-key-events, label-has-associated-control, elements-content, no-autofocus, alt-text, valid-aria, button-has-type, …) | local hook + CI (`pnpm ci:check`, [ADR-0015](0015-cicd-gitea-actions.md)) | **Yes** |
|
||||||
|
| Automated runtime a11y check | `@axe-core/playwright` integrated in e2e tests | CI (`pnpm ci:quality` — the `a11y` gate of [ADR-0015](0015-cicd-gitea-actions.md)) | **Yes** on `critical` or `serious` violations; warning on `moderate`/`minor` |
|
||||||
|
| Design-token contrast verification | Custom script that walks `libs/shared/tokens/` colour pairs and verifies WCAG AA + AAA contrast ratios | CI | **Yes** |
|
||||||
|
| Touch-target check | Layout test verifying interactive element minimum size | CI (Playwright) | **Yes** on min 44 × 44 |
|
||||||
|
| Motion respect check | E2E test that turns motion off and verifies no animation occurs | CI (Playwright) | **Yes** |
|
||||||
|
| Screen-reader manual testing | NVDA (Windows), VoiceOver (macOS, iOS), TalkBack (Android) | Manual, before merge of any new component or major refactor | **Yes** — checklist in PR template |
|
||||||
|
| Keyboard-only navigation | Manual | Manual, before merge of any UI change | **Yes** — checklist in PR template |
|
||||||
|
| Switch-control / eye-tracking | Manual, on flagship features | APF user panel cadence | **Yes** as gate for major releases |
|
||||||
|
| Cognitive walkthrough | Manual, with user representatives | APF user panel cadence | **Yes** as gate for major releases |
|
||||||
|
|
||||||
|
### Manual testing — APF user panel
|
||||||
|
|
||||||
|
APF France Handicap has, by design, an active network of users in situations of disability. This is a structural advantage that no consultant can replicate.
|
||||||
|
|
||||||
|
- **Cadence** : panel-tested before each major release. Composition: at least one user per primary disability category (visual, motor, cognitive, hearing) drawn from the APF network.
|
||||||
|
- **Scope** : critical user journeys (sign-in, navigation, integrated apps, user-preferences panel, error-recovery flows) plus any new flagship feature.
|
||||||
|
- **Format** : moderated session, observation-based, recorded with consent. Findings categorised (blocker / serious / moderate / minor) and triaged like security findings — blockers and serious must be fixed before release.
|
||||||
|
- **Authority** : a finding by a user-panel session is at least as authoritative as an axe-core report. Disagreements escalate to the R&D lead.
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- **Accessibility statement page** at `/accessibility` (English) and `/accessibilite` (French) — required by EAA. Content: conformance level claimed, scope (which routes are covered), known issues with target resolution dates, contact email for accessibility complaints, date of last review, RGAA alignment level. Generated from a maintained source-of-truth file in the repo (e.g. `apps/portal-shell/src/accessibility-statement.md`) so the statement evolves with the code, not separately.
|
||||||
|
- **Internal patterns library** in `docs/accessibility/` (created when first patterns ship) covering: focus management, ARIA usage, form patterns, error messaging, multi-step flows, modals, notifications. Examples drawn from the codebase, kept in sync via review.
|
||||||
|
- **Component library docs** : every spartan-ng component checked into `libs/shared/ui/` carries a11y notes (keyboard model, ARIA roles emitted, screen-reader expectations).
|
||||||
|
|
||||||
|
### Consequences
|
||||||
|
|
||||||
|
- Good, because a11y is structural — encoded in the lint, the gates, the components, the tokens, the user-preferences panel — rather than relying on individual discipline.
|
||||||
|
- Good, because the chosen stack (CDK + spartan-ng + Tailwind) gives full design control without forfeiting Google-tier a11y primitives, and supports the multi-tier contrast/text-size/motion modes without contortion.
|
||||||
|
- Good, because the AA + targeted AAA approach concentrates effort where APF's user base benefits most, instead of spreading thin across every AAA criterion.
|
||||||
|
- Good, because access to APF's user network is a competitive advantage — the panel testing is more rigorous than what most enterprise projects can afford.
|
||||||
|
- Good, because the accessibility statement and the patterns library make the project's a11y posture _legible_ — auditable by external experts, demonstrable to APF stakeholders.
|
||||||
|
- Bad, because the upfront cost is non-trivial — design tokens with multi-tier contrast, components with theming, user-preferences panel, e2e a11y tests. Estimated +25–35 % of UI delivery time vs. an a11y-as-afterthought approach. Acknowledged and accepted given APF's mission.
|
||||||
|
- Bad, because spartan-ng is younger than Angular Material — occasional rough edges expected. Mitigated by the copy-paste model: components live in our repo, we own them, fallback is custom-on-CDK without lib dependency.
|
||||||
|
- Bad, because the user-preferences panel must be wired through every component — components that don't honour the active contrast mode or the active text size break the contract. Enforced by review and a custom lint rule per component.
|
||||||
|
- Bad, because manual testing cadence (panel sessions, screen-reader, keyboard) is real ops effort. Mitigated by APF's internal network; expense kept inside the org.
|
||||||
|
|
||||||
|
### Confirmation
|
||||||
|
|
||||||
|
- `libs/shared/tokens` exposes design tokens for colour (across at least three contrast tiers), spacing, typography, motion. Token contrast ratios are verified by a CI script.
|
||||||
|
- `libs/shared/ui` hosts spartan-ng components, copied in and themed against the tokens. Each component carries an a11y note in its docs.
|
||||||
|
- Angular CDK is the only allowed source of `Overlay`, `FocusTrap`, `FocusMonitor`, `LiveAnnouncer`, `Drag and Drop`. No equivalent rolled by hand.
|
||||||
|
- `apps/portal-shell` ships a user-preferences panel persisting to the session (Redis); changes apply without reload; default reads from `prefers-*` browser media queries.
|
||||||
|
- ESLint configuration enables every `@angular-eslint/template/*` a11y rule. CI fails on any violation.
|
||||||
|
- `@axe-core/playwright` is integrated in the e2e suite. CI's `a11y` gate fails on any `critical` or `serious` violation, warns on `moderate` or `minor`.
|
||||||
|
- Design-token contrast verifier and touch-target verifier are part of `pnpm ci:quality`.
|
||||||
|
- PR template includes a manual-checklist section: keyboard navigation tested, screen reader tested (which one), focus order verified, error states verified.
|
||||||
|
- Accessibility statement page is published, referenced from the footer, and updated at every major release.
|
||||||
|
- APF user-panel session findings are tracked as issues with a label `a11y/panel-finding` and triaged with the same gravity as security issues.
|
||||||
|
|
||||||
|
## Pros and Cons of the Options
|
||||||
|
|
||||||
|
### Conformance target
|
||||||
|
|
||||||
|
#### AA + targeted AAA (chosen)
|
||||||
|
|
||||||
|
- Good, because matches the user base where it matters and stays achievable on a uniform basis.
|
||||||
|
- Good, because the AAA criteria selected are the ones with measurable user-impact in APF's primary populations.
|
||||||
|
- Bad, because the selection is judgement-based — open to debate at audit. Mitigated by documenting the rationale per criterion (this ADR).
|
||||||
|
|
||||||
|
#### AA only
|
||||||
|
|
||||||
|
- Good, because the EU EAA legal floor.
|
||||||
|
- Bad, because clearly insufficient given APF's mission. Bare AA on contrast (4.5:1) is uncomfortable for a low-vision user; AA on motion is permissive.
|
||||||
|
|
||||||
|
#### AAA uniform
|
||||||
|
|
||||||
|
- Good, because the highest WCAG level on every criterion.
|
||||||
|
- Bad, because some AAA criteria (1.2.6 Sign Language, 1.2.8 Media Alternative, 1.4.9 Images of Text) require content production or content forms we may not have. Pretending uniform AAA when not really delivered is worse than an honest AA + targeted AAA.
|
||||||
|
|
||||||
|
### UI component stack
|
||||||
|
|
||||||
|
#### Angular CDK + spartan-ng + Tailwind (chosen)
|
||||||
|
|
||||||
|
- Good, because Google-tier a11y primitives via CDK + copy-paste components via spartan-ng + utility CSS via Tailwind = full control, no design-system lock-in, no proprietary visuals to fight.
|
||||||
|
- Good, because aligns with the team-internal preference for the shadcn-style pattern (per dev consultation), without having to break the Angular alignment with NestJS.
|
||||||
|
- Bad, because spartan-ng is younger (2024). Risk mitigated by the copy-paste model — we own the code.
|
||||||
|
|
||||||
|
#### Angular Material
|
||||||
|
|
||||||
|
- Good, because most mature, Google-maintained.
|
||||||
|
- Bad, because the Material Design visual language is opinionated and clashes with multi-tier contrast modes and APF branding. Switching themes within Material is harder than switching themes when the components are owned in source.
|
||||||
|
|
||||||
|
#### Custom-on-Angular-CDK only
|
||||||
|
|
||||||
|
- Good, because zero dependency beyond Google.
|
||||||
|
- Bad, because every component (button, dialog, tabs, menu, etc.) must be built from CDK primitives — large upfront cost.
|
||||||
|
|
||||||
|
#### React-ecosystem libs
|
||||||
|
|
||||||
|
- Bad, because incompatible with Angular ([ADR-0004](0004-frontend-stack-angular-csr-zoneless-signals.md)).
|
||||||
|
|
||||||
|
### Testing strategy
|
||||||
|
|
||||||
|
#### Automated + manual + APF user panel + annual internal audit (chosen)
|
||||||
|
|
||||||
|
- Good, because covers the spectrum from regression detection to genuine usability.
|
||||||
|
- Good, because user panel access is structurally cheaper than what a consultancy would charge.
|
||||||
|
- Bad, because requires panel scheduling and triage — operational item.
|
||||||
|
|
||||||
|
#### Automated only
|
||||||
|
|
||||||
|
- Bad, because tools detect ~30 % of real accessibility defects. The remaining 70 % are interaction patterns invisible to static and runtime checks.
|
||||||
|
|
||||||
|
#### Automated + external annual audit
|
||||||
|
|
||||||
|
- Good, because external perspective.
|
||||||
|
- Bad, because expensive (5–15 k€/year for a real audit) and arguably less informed than APF's internal expertise. Reconsidered if APF wants a third-party signature for legal disclosure.
|
||||||
|
|
||||||
|
## More Information
|
||||||
|
|
||||||
|
- WCAG 2.2 specification: https://www.w3.org/TR/WCAG22/
|
||||||
|
- WCAG 2.2 quick reference (filterable by level): https://www.w3.org/WAI/WCAG22/quickref/
|
||||||
|
- European Accessibility Act (EAA): https://ec.europa.eu/social/main.jsp?catId=1202
|
||||||
|
- RGAA 4.1: https://accessibilite.numerique.gouv.fr/
|
||||||
|
- APF France Handicap: https://www.apf-francehandicap.org/
|
||||||
|
- Angular CDK A11y: https://material.angular.io/cdk/a11y/overview
|
||||||
|
- spartan-ng: https://www.spartan.ng/
|
||||||
|
- `@angular-eslint`: https://github.com/angular-eslint/angular-eslint
|
||||||
|
- `@axe-core/playwright`: https://github.com/dequelabs/axe-core-npm/tree/develop/packages/playwright
|
||||||
|
- Inclusive Components: https://inclusive-components.design/
|
||||||
|
- Related ADRs: [ADR-0004](0004-frontend-stack-angular-csr-zoneless-signals.md) (Angular stack), [ADR-0010](0010-session-management-redis.md) (preferences persistence), [ADR-0015](0015-cicd-gitea-actions.md) (CI gates `a11y` and `perf`), and the future ADRs for performance budgets, security baseline (ASVS L3 implications cross-cut a11y in some areas — e.g. step-up MFA must remain a11y-conformant), and on-prem infrastructure.
|
||||||
+77
-66
@@ -12,6 +12,7 @@ tags: [performance, frontend, backend, process]
|
|||||||
The portal is a CSR Angular SPA ([ADR-0004](0004-frontend-stack-angular-csr-zoneless-signals.md)) — without SSR, perceived performance depends entirely on the JS payload, the rendering path, and the BFF response latency. The host organisation context ([ADR-0016](0016-accessibility-baseline-wcag-aa-targeted-aaa.md)) elevates performance further: users on assistive technologies (screen readers, switch controls, eye tracking) are particularly affected by slow or jumpy interfaces; cognitive-disability tolerance for delayed feedback is reduced.
|
The portal is a CSR Angular SPA ([ADR-0004](0004-frontend-stack-angular-csr-zoneless-signals.md)) — without SSR, perceived performance depends entirely on the JS payload, the rendering path, and the BFF response latency. The host organisation context ([ADR-0016](0016-accessibility-baseline-wcag-aa-targeted-aaa.md)) elevates performance further: users on assistive technologies (screen readers, switch controls, eye tracking) are particularly affected by slow or jumpy interfaces; cognitive-disability tolerance for delayed feedback is reduced.
|
||||||
|
|
||||||
We need to fix:
|
We need to fix:
|
||||||
|
|
||||||
- which metrics we track;
|
- which metrics we track;
|
||||||
- which thresholds bound them;
|
- which thresholds bound them;
|
||||||
- which tooling enforces them in CI and in production;
|
- which tooling enforces them in CI and in production;
|
||||||
@@ -22,40 +23,46 @@ This ADR fixes the framework. Concrete optimisation work happens at feature deli
|
|||||||
|
|
||||||
## Decision Drivers
|
## Decision Drivers
|
||||||
|
|
||||||
* CSR-only ([ADR-0004](0004-frontend-stack-angular-csr-zoneless-signals.md)) — perceived perf is entirely client-side load + execution.
|
- CSR-only ([ADR-0004](0004-frontend-stack-angular-csr-zoneless-signals.md)) — perceived perf is entirely client-side load + execution.
|
||||||
* a11y context ([ADR-0016](0016-accessibility-baseline-wcag-aa-targeted-aaa.md)) — perf failure modes hurt the APF user base disproportionately.
|
- a11y context ([ADR-0016](0016-accessibility-baseline-wcag-aa-targeted-aaa.md)) — perf failure modes hurt the APF user base disproportionately.
|
||||||
* Industry-standard tooling, anti-bricolage.
|
- Industry-standard tooling, anti-bricolage.
|
||||||
* Same observability stack as [ADR-0012](0012-observability-pino-opentelemetry.md) — no parallel telemetry path.
|
- Same observability stack as [ADR-0012](0012-observability-pino-opentelemetry.md) — no parallel telemetry path.
|
||||||
* Cohérence with [ADR-0015](0015-cicd-gitea-actions.md) — the `perf` gate slot was reserved for this ADR.
|
- Cohérence with [ADR-0015](0015-cicd-gitea-actions.md) — the `perf` gate slot was reserved for this ADR.
|
||||||
|
|
||||||
## Considered Options
|
## Considered Options
|
||||||
|
|
||||||
### Metrics scope
|
### Metrics scope
|
||||||
* **Google Core Web Vitals (LCP, INP, CLS) + supplementary (TBT, TTFB) + bundle size.** (Chosen.)
|
|
||||||
* Custom metrics only (rejected — premature, not benchmarkable across the industry).
|
- **Google Core Web Vitals (LCP, INP, CLS) + supplementary (TBT, TTFB) + bundle size.** (Chosen.)
|
||||||
* Lighthouse Performance score only (insufficient on its own — score-as-only-metric is gameable).
|
- Custom metrics only (rejected — premature, not benchmarkable across the industry).
|
||||||
|
- Lighthouse Performance score only (insufficient on its own — score-as-only-metric is gameable).
|
||||||
|
|
||||||
### Front-end tooling
|
### Front-end tooling
|
||||||
* **Lighthouse CI (`@lhci/cli`)** for full audit + score, plus Angular `budgets` for bundle size enforcement at build. (Chosen.)
|
|
||||||
* WebPageTest API.
|
- **Lighthouse CI (`@lhci/cli`)** for full audit + score, plus Angular `budgets` for bundle size enforcement at build. (Chosen.)
|
||||||
* Calibre, Speedlify (à la pointe but less mature for CI integration).
|
- WebPageTest API.
|
||||||
|
- Calibre, Speedlify (à la pointe but less mature for CI integration).
|
||||||
|
|
||||||
### Threshold values
|
### Threshold values
|
||||||
* **Google "Good" Core Web Vitals thresholds + Lighthouse Performance ≥ 90.** (Chosen.)
|
|
||||||
* Stricter (e.g. LCP ≤ 2 s, score ≥ 95) — rejected as too flaky in CI runners.
|
- **Google "Good" Core Web Vitals thresholds + Lighthouse Performance ≥ 90.** (Chosen.)
|
||||||
* Looser (matches "Needs Improvement") — rejected as insufficient.
|
- Stricter (e.g. LCP ≤ 2 s, score ≥ 95) — rejected as too flaky in CI runners.
|
||||||
|
- Looser (matches "Needs Improvement") — rejected as insufficient.
|
||||||
|
|
||||||
### Bundle budgets
|
### Bundle budgets
|
||||||
* **Angular `budgets` in `project.json`, type `error`, blocking the build on overshoot.** (Chosen.)
|
|
||||||
* No budget (rejected — invariably leads to silent bloat).
|
- **Angular `budgets` in `project.json`, type `error`, blocking the build on overshoot.** (Chosen.)
|
||||||
|
- No budget (rejected — invariably leads to silent bloat).
|
||||||
|
|
||||||
### Back-end SLOs
|
### Back-end SLOs
|
||||||
* **Per-endpoint-family p95/p99 budgets, observed via OTel spans (already in place by [ADR-0012](0012-observability-pino-opentelemetry.md)), enforced at review and in scheduled reports.** (Chosen.)
|
|
||||||
* No back-end perf budget.
|
- **Per-endpoint-family p95/p99 budgets, observed via OTel spans (already in place by [ADR-0012](0012-observability-pino-opentelemetry.md)), enforced at review and in scheduled reports.** (Chosen.)
|
||||||
|
- No back-end perf budget.
|
||||||
|
|
||||||
### Real-user monitoring (RUM)
|
### Real-user monitoring (RUM)
|
||||||
* **None in v1** — rely on OTel server-side spans + Lighthouse CI in CI + scheduled prod Lighthouse runs. (Chosen.)
|
|
||||||
* Front-side RUM SDK (Sentry, Datadog Browser, custom OTel-Web RUM).
|
- **None in v1** — rely on OTel server-side spans + Lighthouse CI in CI + scheduled prod Lighthouse runs. (Chosen.)
|
||||||
|
- Front-side RUM SDK (Sentry, Datadog Browser, custom OTel-Web RUM).
|
||||||
|
|
||||||
## Decision Outcome
|
## Decision Outcome
|
||||||
|
|
||||||
@@ -64,7 +71,7 @@ This ADR fixes the framework. Concrete optimisation work happens at feature deli
|
|||||||
Core Web Vitals — Google "Good" thresholds, measured by Lighthouse CI:
|
Core Web Vitals — Google "Good" thresholds, measured by Lighthouse CI:
|
||||||
|
|
||||||
| Metric | Threshold | Source |
|
| Metric | Threshold | Source |
|
||||||
| --- | --- | --- |
|
| ----------------------------------------------- | --------------------------- | ---------------------------------------------------- |
|
||||||
| **LCP** (Largest Contentful Paint) | ≤ **2.5 s** | https://web.dev/lcp/ |
|
| **LCP** (Largest Contentful Paint) | ≤ **2.5 s** | https://web.dev/lcp/ |
|
||||||
| **INP** (Interaction to Next Paint) | ≤ **200 ms** | https://web.dev/inp/ — replaces FID since March 2024 |
|
| **INP** (Interaction to Next Paint) | ≤ **200 ms** | https://web.dev/inp/ — replaces FID since March 2024 |
|
||||||
| **CLS** (Cumulative Layout Shift) | ≤ **0.1** | https://web.dev/cls/ |
|
| **CLS** (Cumulative Layout Shift) | ≤ **0.1** | https://web.dev/cls/ |
|
||||||
@@ -75,7 +82,7 @@ Core Web Vitals — Google "Good" thresholds, measured by Lighthouse CI:
|
|||||||
Bundle budgets (`apps/portal-shell/project.json`, Angular `budgets` array, `type: "error"` — blocking at `nx build`):
|
Bundle budgets (`apps/portal-shell/project.json`, Angular `budgets` array, `type: "error"` — blocking at `nx build`):
|
||||||
|
|
||||||
| Bundle | Budget |
|
| Bundle | Budget |
|
||||||
| --- | --- |
|
| --------------------- | ------------ |
|
||||||
| Initial bundle (gzip) | ≤ **300 KB** |
|
| Initial bundle (gzip) | ≤ **300 KB** |
|
||||||
| Any lazy chunk (gzip) | ≤ **100 KB** |
|
| Any lazy chunk (gzip) | ≤ **100 KB** |
|
||||||
| Per-component CSS | ≤ **6 KB** |
|
| Per-component CSS | ≤ **6 KB** |
|
||||||
@@ -97,7 +104,7 @@ Each route is scored against the thresholds above. **Failure on any threshold is
|
|||||||
### Where Lighthouse CI runs
|
### Where Lighthouse CI runs
|
||||||
|
|
||||||
| Environment | Cadence | Purpose | Blocking? |
|
| Environment | Cadence | Purpose | Blocking? |
|
||||||
| --- | --- | --- | --- |
|
| ------------------------------------- | ---------------------------------------------------------------------------------------------------- | ---------------------------------------------- | ----------------------------------------------------- |
|
||||||
| **CI on every PR** | per push | Catch regressions before merge | **Yes** |
|
| **CI on every PR** | per push | Catch regressions before merge | **Yes** |
|
||||||
| **CI scheduled (weekly)** on prod env | cron in `security-scheduled.yml` from [ADR-0015](0015-cicd-gitea-actions.md), extended to cover perf | Detect drift / regressions in real environment | Reports as alerts; doesn't block, but triggers triage |
|
| **CI scheduled (weekly)** on prod env | cron in `security-scheduled.yml` from [ADR-0015](0015-cicd-gitea-actions.md), extended to cover perf | Detect drift / regressions in real environment | Reports as alerts; doesn't block, but triggers triage |
|
||||||
| **Local dev** | manual via `pnpm nx run portal-shell:lighthouse` | Developer-side feedback | Non-blocking |
|
| **Local dev** | manual via `pnpm nx run portal-shell:lighthouse` | Developer-side feedback | Non-blocking |
|
||||||
@@ -105,6 +112,7 @@ Each route is scored against the thresholds above. **Failure on any threshold is
|
|||||||
### Variability mitigation
|
### Variability mitigation
|
||||||
|
|
||||||
Lighthouse scores fluctuate ±2–5 points run-to-run on the same code due to runner variance. The CI configuration:
|
Lighthouse scores fluctuate ±2–5 points run-to-run on the same code due to runner variance. The CI configuration:
|
||||||
|
|
||||||
- runs **3 iterations** per route and uses the **median** (configurable via `lighthouserc.js`);
|
- runs **3 iterations** per route and uses the **median** (configurable via `lighthouserc.js`);
|
||||||
- uses a fixed runtime profile (CPU throttling, network throttling matching "Slow 4G" baseline) so scores are comparable across runs and across machines;
|
- uses a fixed runtime profile (CPU throttling, network throttling matching "Slow 4G" baseline) so scores are comparable across runs and across machines;
|
||||||
- pins the Lighthouse version per release of the runner image (avoids score shifts from Lighthouse updates).
|
- pins the Lighthouse version per release of the runner image (avoids score shifts from Lighthouse updates).
|
||||||
@@ -118,7 +126,7 @@ Lighthouse scores fluctuate ±2–5 points run-to-run on the same code due to ru
|
|||||||
Per-endpoint-family p95 / p99 SLOs, observed via the OpenTelemetry span data already produced by [ADR-0012](0012-observability-pino-opentelemetry.md). Initial budgets:
|
Per-endpoint-family p95 / p99 SLOs, observed via the OpenTelemetry span data already produced by [ADR-0012](0012-observability-pino-opentelemetry.md). Initial budgets:
|
||||||
|
|
||||||
| Endpoint family | p95 | p99 |
|
| Endpoint family | p95 | p99 |
|
||||||
| --- | --- | --- |
|
| -------------------------------------- | ---------------------------------------- | ----------------------------------------- |
|
||||||
| `GET /auth/me` | 50 ms | 150 ms |
|
| `GET /auth/me` | 50 ms | 150 ms |
|
||||||
| `GET /auth/login` (redirect) | 80 ms | 200 ms |
|
| `GET /auth/login` (redirect) | 80 ms | 200 ms |
|
||||||
| `GET /auth/callback` (token exchange) | 600 ms | 1500 ms — bound by Entra |
|
| `GET /auth/callback` (token exchange) | 600 ms | 1500 ms — bound by Entra |
|
||||||
@@ -128,6 +136,7 @@ Per-endpoint-family p95 / p99 SLOs, observed via the OpenTelemetry span data alr
|
|||||||
| Downstream-API-orchestrating endpoints | bound by downstream + 50 ms BFF overhead | bound by downstream + 200 ms BFF overhead |
|
| Downstream-API-orchestrating endpoints | bound by downstream + 50 ms BFF overhead | bound by downstream + 200 ms BFF overhead |
|
||||||
|
|
||||||
These budgets are **not** enforced as hard CI gates — load profile in CI is unrepresentative. They are enforced as:
|
These budgets are **not** enforced as hard CI gates — load profile in CI is unrepresentative. They are enforced as:
|
||||||
|
|
||||||
- **alerting thresholds** in the production observability backend (chosen in the future infrastructure ADR);
|
- **alerting thresholds** in the production observability backend (chosen in the future infrastructure ADR);
|
||||||
- **review-time signals** on PRs that touch hot paths (the OTel data from staging informs the reviewer);
|
- **review-time signals** on PRs that touch hot paths (the OTel data from staging informs the reviewer);
|
||||||
- **quarterly perf review** — tightened or loosened with ADR amendment.
|
- **quarterly perf review** — tightened or loosened with ADR amendment.
|
||||||
@@ -139,6 +148,7 @@ A regression on any of the front-end gates blocks the merge. A regression on a b
|
|||||||
### a11y / perf trade-off
|
### a11y / perf trade-off
|
||||||
|
|
||||||
Performance optimisation must not hurt accessibility. Some specific patterns to avoid:
|
Performance optimisation must not hurt accessibility. Some specific patterns to avoid:
|
||||||
|
|
||||||
- skipping FOUC prevention (briefly unstyled content disorients users with cognitive disabilities);
|
- skipping FOUC prevention (briefly unstyled content disorients users with cognitive disabilities);
|
||||||
- removing focus-visible polyfills "for bundle size";
|
- removing focus-visible polyfills "for bundle size";
|
||||||
- aggressive lazy-loading that delays critical interactive elements;
|
- aggressive lazy-loading that delays critical interactive elements;
|
||||||
@@ -157,6 +167,7 @@ When the perf budget and the a11y bar conflict, **a11y wins** — the perf budge
|
|||||||
```
|
```
|
||||||
|
|
||||||
`lighthouserc.js` (illustrative, lands with the scaffold) declares:
|
`lighthouserc.js` (illustrative, lands with the scaffold) declares:
|
||||||
|
|
||||||
- the URLs to test (the critical-routes list);
|
- the URLs to test (the critical-routes list);
|
||||||
- 3 iterations, median report;
|
- 3 iterations, median report;
|
||||||
- assertions matching the thresholds in this ADR;
|
- assertions matching the thresholds in this ADR;
|
||||||
@@ -170,25 +181,25 @@ No browser-side RUM SDK in v1. The OTel browser tracing from [ADR-0012](0012-obs
|
|||||||
|
|
||||||
### Consequences
|
### Consequences
|
||||||
|
|
||||||
* Good, because performance discipline is structural — every PR is measured against the same bar, on every route that matters.
|
- Good, because performance discipline is structural — every PR is measured against the same bar, on every route that matters.
|
||||||
* Good, because Core Web Vitals + Lighthouse CI is the industry-standard combo; results are comparable to public benchmarks and easy to explain to non-developers.
|
- Good, because Core Web Vitals + Lighthouse CI is the industry-standard combo; results are comparable to public benchmarks and easy to explain to non-developers.
|
||||||
* Good, because Angular `budgets` blocks bundle bloat at build time, before it ever reaches a user.
|
- Good, because Angular `budgets` blocks bundle bloat at build time, before it ever reaches a user.
|
||||||
* Good, because the BFF SLOs reuse the OTel pipeline already shipped by [ADR-0012](0012-observability-pino-opentelemetry.md) — no parallel telemetry.
|
- Good, because the BFF SLOs reuse the OTel pipeline already shipped by [ADR-0012](0012-observability-pino-opentelemetry.md) — no parallel telemetry.
|
||||||
* Good, because the explicit a11y/perf trade-off rule prevents "performance regressions" from being introduced under the guise of optimisation.
|
- Good, because the explicit a11y/perf trade-off rule prevents "performance regressions" from being introduced under the guise of optimisation.
|
||||||
* Bad, because Lighthouse score variability (±2–5 points) is real; CI must mitigate it with median-of-3 runs and pinned tooling. Otherwise gates flake.
|
- Bad, because Lighthouse score variability (±2–5 points) is real; CI must mitigate it with median-of-3 runs and pinned tooling. Otherwise gates flake.
|
||||||
* Bad, because tightening budgets at quarterly review requires discipline — quarterly meetings to actually happen, data to be reviewed, ADRs to be amended. Mitigated by making it a recurring calendar item.
|
- Bad, because tightening budgets at quarterly review requires discipline — quarterly meetings to actually happen, data to be reviewed, ADRs to be amended. Mitigated by making it a recurring calendar item.
|
||||||
* Bad, because the back-end SLOs are advisory in CI (not enforced) — the only enforcement is in production alerting, which means a hot-path regression can ship and only fire later. Acknowledged: enforcing perf in CI requires a representative load profile we don't have.
|
- Bad, because the back-end SLOs are advisory in CI (not enforced) — the only enforcement is in production alerting, which means a hot-path regression can ship and only fire later. Acknowledged: enforcing perf in CI requires a representative load profile we don't have.
|
||||||
|
|
||||||
### Confirmation
|
### Confirmation
|
||||||
|
|
||||||
* `lighthouserc.js` exists at the repo root with the critical-routes list, the assertions matching the thresholds above, and a 3-iteration median configuration.
|
- `lighthouserc.js` exists at the repo root with the critical-routes list, the assertions matching the thresholds above, and a 3-iteration median configuration.
|
||||||
* `apps/portal-shell/project.json` declares `budgets` of type `"error"` with the values above.
|
- `apps/portal-shell/project.json` declares `budgets` of type `"error"` with the values above.
|
||||||
* `package.json` exposes `ci:perf`, runnable locally with the same exit code as CI.
|
- `package.json` exposes `ci:perf`, runnable locally with the same exit code as CI.
|
||||||
* CI's `perf` gate from [ADR-0015](0015-cicd-gitea-actions.md) calls `pnpm ci:perf` and is blocking.
|
- CI's `perf` gate from [ADR-0015](0015-cicd-gitea-actions.md) calls `pnpm ci:perf` and is blocking.
|
||||||
* `apps/portal-shell` exposes a Nx target `analyze` invoking `source-map-explorer` against the production build's source maps.
|
- `apps/portal-shell` exposes a Nx target `analyze` invoking `source-map-explorer` against the production build's source maps.
|
||||||
* The BFF p95/p99 budgets are documented in `apps/portal-bff/README.md` and translate into alert rules in the production observability backend (configured per future infrastructure ADR).
|
- The BFF p95/p99 budgets are documented in `apps/portal-bff/README.md` and translate into alert rules in the production observability backend (configured per future infrastructure ADR).
|
||||||
* The `security-scheduled.yml` workflow includes a weekly Lighthouse CI run against the prod URL set, with reports uploaded as build artefacts and an alert on any threshold breach.
|
- The `security-scheduled.yml` workflow includes a weekly Lighthouse CI run against the prod URL set, with reports uploaded as build artefacts and an alert on any threshold breach.
|
||||||
* Quarterly performance review is on the team calendar; the agenda includes (a) review of the past quarter's reports, (b) decision to tighten budgets if achievable, (c) ADR amendment if budgets are adjusted.
|
- Quarterly performance review is on the team calendar; the agenda includes (a) review of the past quarter's reports, (b) decision to tighten budgets if achievable, (c) ADR amendment if budgets are adjusted.
|
||||||
|
|
||||||
## Pros and Cons of the Options
|
## Pros and Cons of the Options
|
||||||
|
|
||||||
@@ -196,65 +207,65 @@ No browser-side RUM SDK in v1. The OTel browser tracing from [ADR-0012](0012-obs
|
|||||||
|
|
||||||
#### Google Core Web Vitals + supplementary + bundle size (chosen)
|
#### Google Core Web Vitals + supplementary + bundle size (chosen)
|
||||||
|
|
||||||
* Good, because aligned with the industry, comparable to public benchmarks, well-documented.
|
- Good, because aligned with the industry, comparable to public benchmarks, well-documented.
|
||||||
* Good, because INP captures real user interaction latency (replaces FID since March 2024).
|
- Good, because INP captures real user interaction latency (replaces FID since March 2024).
|
||||||
* Good, because bundle size is the leading indicator that catches issues before they manifest as bad CWV.
|
- Good, because bundle size is the leading indicator that catches issues before they manifest as bad CWV.
|
||||||
|
|
||||||
#### Custom metrics only
|
#### Custom metrics only
|
||||||
|
|
||||||
* Bad, because un-benchmarkable, easy to be wrong about. Premature.
|
- Bad, because un-benchmarkable, easy to be wrong about. Premature.
|
||||||
|
|
||||||
#### Lighthouse score only
|
#### Lighthouse score only
|
||||||
|
|
||||||
* Bad, because the score is a weighted aggregate; gameable, hides which metric is failing. Use it as a top-line confirmation, not as the only metric.
|
- Bad, because the score is a weighted aggregate; gameable, hides which metric is failing. Use it as a top-line confirmation, not as the only metric.
|
||||||
|
|
||||||
### Front-end tooling
|
### Front-end tooling
|
||||||
|
|
||||||
#### Lighthouse CI (chosen)
|
#### Lighthouse CI (chosen)
|
||||||
|
|
||||||
* Good, because mature, free, the standard for automated CWV in CI.
|
- Good, because mature, free, the standard for automated CWV in CI.
|
||||||
* Good, because integrates trivially with any CI runner; uploads HTML reports as build artefacts for triage.
|
- Good, because integrates trivially with any CI runner; uploads HTML reports as build artefacts for triage.
|
||||||
* Bad, because score variability needs mitigation (median of 3, pinned version) — covered.
|
- Bad, because score variability needs mitigation (median of 3, pinned version) — covered.
|
||||||
|
|
||||||
#### WebPageTest API
|
#### WebPageTest API
|
||||||
|
|
||||||
* Good, because more granular network/profile control.
|
- Good, because more granular network/profile control.
|
||||||
* Bad, because requires a paid account or self-hosted instance; less plug-and-play in CI.
|
- Bad, because requires a paid account or self-hosted instance; less plug-and-play in CI.
|
||||||
|
|
||||||
#### Calibre / Speedlify
|
#### Calibre / Speedlify
|
||||||
|
|
||||||
* Good, because modern, well-presented dashboards.
|
- Good, because modern, well-presented dashboards.
|
||||||
* Bad, because younger / less established than Lighthouse CI; reconsidered later if Lighthouse CI proves insufficient.
|
- Bad, because younger / less established than Lighthouse CI; reconsidered later if Lighthouse CI proves insufficient.
|
||||||
|
|
||||||
### Threshold values
|
### Threshold values
|
||||||
|
|
||||||
#### Google "Good" + Lighthouse ≥ 90 (chosen)
|
#### Google "Good" + Lighthouse ≥ 90 (chosen)
|
||||||
|
|
||||||
* Good, because the de facto industry baseline; exceeding it is a clear positive signal.
|
- Good, because the de facto industry baseline; exceeding it is a clear positive signal.
|
||||||
* Bad, because not the strictest possible — a brand whose mission is accessibility might warrant tighter values. Mitigated by quarterly tightening.
|
- Bad, because not the strictest possible — a brand whose mission is accessibility might warrant tighter values. Mitigated by quarterly tightening.
|
||||||
|
|
||||||
#### Stricter (LCP ≤ 2 s, score ≥ 95)
|
#### Stricter (LCP ≤ 2 s, score ≥ 95)
|
||||||
|
|
||||||
* Good, because reflects an a11y-first mission.
|
- Good, because reflects an a11y-first mission.
|
||||||
* Bad, because flaky in CI runners — false negatives cost more than the marginal user benefit, especially given runner variance.
|
- Bad, because flaky in CI runners — false negatives cost more than the marginal user benefit, especially given runner variance.
|
||||||
|
|
||||||
### RUM
|
### RUM
|
||||||
|
|
||||||
#### None in v1 (chosen)
|
#### None in v1 (chosen)
|
||||||
|
|
||||||
* Good, because zero v1 ops surface; no privacy disclosure to make about browser-side data collection.
|
- Good, because zero v1 ops surface; no privacy disclosure to make about browser-side data collection.
|
||||||
* Bad, because real-user perf data is genuinely useful. Acceptable to defer because Lighthouse CI scheduled prod runs cover the gap.
|
- Bad, because real-user perf data is genuinely useful. Acceptable to defer because Lighthouse CI scheduled prod runs cover the gap.
|
||||||
|
|
||||||
#### RUM SDK (Sentry, Datadog Browser)
|
#### RUM SDK (Sentry, Datadog Browser)
|
||||||
|
|
||||||
* Good, because real data, real users, real distribution.
|
- Good, because real data, real users, real distribution.
|
||||||
* Bad, because adds a runtime SDK to the SPA, sends data to a third party (or our own collector with cost), and requires a privacy-disclosure update to the accessibility/legal pages.
|
- Bad, because adds a runtime SDK to the SPA, sends data to a third party (or our own collector with cost), and requires a privacy-disclosure update to the accessibility/legal pages.
|
||||||
|
|
||||||
## More Information
|
## More Information
|
||||||
|
|
||||||
* Web Vitals overview: https://web.dev/vitals/
|
- Web Vitals overview: https://web.dev/vitals/
|
||||||
* Core Web Vitals (Google): https://web.dev/articles/vitals
|
- Core Web Vitals (Google): https://web.dev/articles/vitals
|
||||||
* Lighthouse CI: https://github.com/GoogleChrome/lighthouse-ci
|
- Lighthouse CI: https://github.com/GoogleChrome/lighthouse-ci
|
||||||
* Angular budgets: https://angular.dev/tools/cli/build#configuring-size-budgets
|
- Angular budgets: https://angular.dev/tools/cli/build#configuring-size-budgets
|
||||||
* `source-map-explorer`: https://github.com/danvk/source-map-explorer
|
- `source-map-explorer`: https://github.com/danvk/source-map-explorer
|
||||||
* Related ADRs: [ADR-0004](0004-frontend-stack-angular-csr-zoneless-signals.md) (Angular stack — CSR perf profile), [ADR-0012](0012-observability-pino-opentelemetry.md) (OTel — basis for BFF SLOs), [ADR-0015](0015-cicd-gitea-actions.md) (CI gates — `perf` slot now wired), [ADR-0016](0016-accessibility-baseline-wcag-aa-targeted-aaa.md) (a11y / perf trade-off rule).
|
- Related ADRs: [ADR-0004](0004-frontend-stack-angular-csr-zoneless-signals.md) (Angular stack — CSR perf profile), [ADR-0012](0012-observability-pino-opentelemetry.md) (OTel — basis for BFF SLOs), [ADR-0015](0015-cicd-gitea-actions.md) (CI gates — `perf` slot now wired), [ADR-0016](0016-accessibility-baseline-wcag-aa-targeted-aaa.md) (a11y / perf trade-off rule).
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
# Architectural Decision Records
|
||||||
|
|
||||||
|
This project records architecturally-significant decisions as **ADRs** in the [MADR 4.0.0](https://github.com/adr/madr) format. References: [adr.github.io](https://adr.github.io/).
|
||||||
|
|
||||||
|
## Why ADRs
|
||||||
|
|
||||||
|
ADRs capture the _why_ behind a decision — context, drivers, options considered, trade-offs accepted — at the moment the decision is made. They make architecture reviewable, onboarding faster, and prevent the same debate from being re-litigated later.
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
|
||||||
|
- **Format:** MADR 4.0.0. Start from [template.md](template.md).
|
||||||
|
- **Filename:** `NNNN-kebab-case-title.md`, e.g. `0007-adopt-tailwind-for-design-tokens.md`.
|
||||||
|
- **Numbering:** globally sequential 4-digit prefix. Numbers never reset, never get reused — even when an ADR is superseded or deprecated.
|
||||||
|
- **Layout:** flat folder. ADRs are not nested into category subfolders; topical organization happens via tags.
|
||||||
|
- **Tags:** every ADR carries a `tags:` array in the MADR frontmatter, drawn from the [tag vocabulary](#tag-vocabulary) below. An ADR may carry several tags. Propose new tags (or renames) in the same PR that needs them; never invent ad-hoc tags inline.
|
||||||
|
- **Status lifecycle:** `proposed` → `accepted` → optionally `deprecated` or `superseded by [ADR-NNNN](NNNN-other.md)`. Update the YAML frontmatter; never delete an ADR.
|
||||||
|
- **Index maintenance:** every ADR addition or status change must update the [Index](#index) below in the same commit.
|
||||||
|
|
||||||
|
## When to write an ADR
|
||||||
|
|
||||||
|
Write one whenever a development decision is non-trivial: tool or library choice, framework pattern, security control, perf budget, a11y target, naming convention, deprecation, breaking change, or any choice that future contributors would benefit from understanding the _why_ of.
|
||||||
|
|
||||||
|
## Tag vocabulary
|
||||||
|
|
||||||
|
The vocabulary below is the source of truth. It is intentionally coarse — propose extensions only when an existing tag genuinely doesn't fit, and avoid overly narrow tags.
|
||||||
|
|
||||||
|
| Tag | Scope |
|
||||||
|
| ---------------- | ------------------------------------------------------------------- |
|
||||||
|
| `frontend` | UI, Angular, components, design system, client-side state |
|
||||||
|
| `backend` | API, BFF, server-side services |
|
||||||
|
| `security` | AuthN, AuthZ, sessions, CSP, dependency scanning, secret management |
|
||||||
|
| `performance` | Perf budgets, caching, bundle size, Lighthouse |
|
||||||
|
| `accessibility` | WCAG, a11y testing, keyboard, ARIA, contrast |
|
||||||
|
| `infrastructure` | CI/CD, hosting, deployment, runtime |
|
||||||
|
| `observability` | Logs, metrics, traces, correlation IDs, monitoring |
|
||||||
|
| `data` | Persistence, schemas, migrations, data flow |
|
||||||
|
| `process` | Team conventions, workflows, repo policy |
|
||||||
|
|
||||||
|
> _Status: starter vocabulary, to be refined as ADRs accumulate. Update this table whenever a tag is added, renamed, or retired._
|
||||||
|
|
||||||
|
## Index
|
||||||
|
|
||||||
|
ADRs are listed in numerical order. To slice by topic, filter on the `Tags` column.
|
||||||
|
|
||||||
|
| # | Title | Status | Tags | Date |
|
||||||
|
| ------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------- | -------- | ----------------------------------------------- | ---------- |
|
||||||
|
| [0001](0001-use-adrs-to-record-architectural-decisions.md) | Use ADRs to record architectural decisions | accepted | `process` | 2026-04-29 |
|
||||||
|
| [0002](0002-adopt-nx-monorepo-apps-preset.md) | Adopt Nx monorepo with the `apps` preset | accepted | `infrastructure`, `frontend`, `backend` | 2026-04-29 |
|
||||||
|
| [0003](0003-workspace-and-app-naming-convention.md) | Workspace and app naming convention | accepted | `process` | 2026-04-29 |
|
||||||
|
| [0004](0004-frontend-stack-angular-csr-zoneless-signals.md) | Frontend stack — Angular (latest LTS), standalone, zoneless, Signals, CSR-only, Vitest | accepted | `frontend` | 2026-04-29 |
|
||||||
|
| [0005](0005-backend-stack-nestjs.md) | Backend stack — NestJS over Express, Fastify, Hono | accepted | `backend` | 2026-04-29 |
|
||||||
|
| [0006](0006-persistence-postgresql-prisma.md) | Persistence — PostgreSQL with Prisma | accepted | `data`, `backend` | 2026-04-29 |
|
||||||
|
| [0007](0007-pre-commit-hooks-and-conventional-commits.md) | Pre-commit hooks and Conventional Commits | accepted | `process` | 2026-04-29 |
|
||||||
|
| [0008](0008-identity-model-entra-workforce-dual-audience.md) | Identity model — multi-tenant Entra ID for workforce, dual-audience design for future External ID | accepted | `security`, `data` | 2026-04-29 |
|
||||||
|
| [0009](0009-auth-flow-oidc-pkce-msal-node.md) | Authentication flow — OIDC Authorization Code + PKCE via MSAL Node, BFF session pattern | accepted | `security`, `backend` | 2026-04-29 |
|
||||||
|
| [0010](0010-session-management-redis.md) | Session management — opaque session IDs in cookies, payload in self-hosted Redis with AES-GCM at rest | accepted | `security`, `backend`, `infrastructure` | 2026-04-29 |
|
||||||
|
| [0011](0011-mfa-enforcement-entra-conditional-access.md) | MFA enforcement — Entra ID Conditional Access baseline, BFF claim sanity-check, step-up hooks designed-in | accepted | `security` | 2026-04-29 |
|
||||||
|
| [0012](0012-observability-pino-opentelemetry.md) | Observability — Pino structured logs + OpenTelemetry tracing, W3C Trace Context propagation, stdout + collector | accepted | `observability`, `backend`, `frontend` | 2026-04-29 |
|
||||||
|
| [0013](0013-audit-trail-separated-postgres-append-only.md) | Audit trail — separated append-only Postgres schema, decoupled from app logs | accepted | `security`, `observability`, `data` | 2026-04-29 |
|
||||||
|
| [0014](0014-downstream-api-access-obo-pattern.md) | Downstream API access — On-Behalf-Of pattern, unified `DownstreamApiClient`, audience-aware authorization | accepted | `security`, `backend` | 2026-04-29 |
|
||||||
|
| [0015](0015-cicd-gitea-actions.md) | CI/CD pipeline — Gitea Actions, trunk-based + squash-merge, thin YAML over portable scripts | accepted | `infrastructure`, `process` | 2026-04-30 |
|
||||||
|
| [0016](0016-accessibility-baseline-wcag-aa-targeted-aaa.md) | Accessibility baseline — WCAG 2.2 AA + targeted AAA, Angular CDK + spartan-ng + Tailwind, APF panel testing | accepted | `accessibility`, `frontend`, `process` | 2026-04-30 |
|
||||||
|
| [0017](0017-performance-budgets-lighthouse-ci.md) | Performance budgets — Core Web Vitals + Lighthouse CI gates, bundle budgets, BFF p95/p99 SLOs | accepted | `performance`, `frontend`, `backend`, `process` | 2026-04-30 |
|
||||||
@@ -1,11 +1,14 @@
|
|||||||
---
|
---
|
||||||
# `tags` is REQUIRED for this project — pick from the vocabulary in decisions/README.md.
|
# `tags` is REQUIRED for this project — pick from the vocabulary in docs/decisions/README.md.
|
||||||
# The other fields are standard MADR 4.0.0 metadata; remove any that don't apply.
|
# The other fields are standard MADR 4.0.0 metadata; remove any that don't apply.
|
||||||
status: "{proposed | rejected | accepted | deprecated | … | superseded by [ADR-NNNN](NNNN-example.md)}"
|
status: '{proposed | rejected | accepted | deprecated | … | superseded by [ADR-NNNN](NNNN-example.md)}'
|
||||||
date: {YYYY-MM-DD when the decision was last updated}
|
date: { YYYY-MM-DD when the decision was last updated }
|
||||||
decision-makers: {list everyone involved in the decision}
|
decision-makers: { list everyone involved in the decision }
|
||||||
consulted: {list everyone whose opinions are sought (typically subject-matter experts); two-way communication}
|
consulted:
|
||||||
informed: {list everyone who is kept up-to-date on progress; one-way communication}
|
{
|
||||||
|
list everyone whose opinions are sought (typically subject-matter experts); two-way communication,
|
||||||
|
}
|
||||||
|
informed: { list everyone who is kept up-to-date on progress; one-way communication }
|
||||||
tags: [<tag1>, <tag2>]
|
tags: [<tag1>, <tag2>]
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -16,57 +19,62 @@ tags: [<tag1>, <tag2>]
|
|||||||
{Describe the context and problem statement, e.g., in free form using two to three sentences or in the form of an illustrative story. You may want to articulate the problem in form of a question and add links to collaboration boards or issue management systems.}
|
{Describe the context and problem statement, e.g., in free form using two to three sentences or in the form of an illustrative story. You may want to articulate the problem in form of a question and add links to collaboration boards or issue management systems.}
|
||||||
|
|
||||||
<!-- This is an optional element. Feel free to remove. -->
|
<!-- This is an optional element. Feel free to remove. -->
|
||||||
|
|
||||||
## Decision Drivers
|
## Decision Drivers
|
||||||
|
|
||||||
* {decision driver 1, e.g., a force, facing concern, …}
|
- {decision driver 1, e.g., a force, facing concern, …}
|
||||||
* {decision driver 2, e.g., a force, facing concern, …}
|
- {decision driver 2, e.g., a force, facing concern, …}
|
||||||
* …
|
- …
|
||||||
|
|
||||||
## Considered Options
|
## Considered Options
|
||||||
|
|
||||||
* {title of option 1}
|
- {title of option 1}
|
||||||
* {title of option 2}
|
- {title of option 2}
|
||||||
* {title of option 3}
|
- {title of option 3}
|
||||||
* …
|
- …
|
||||||
|
|
||||||
## Decision Outcome
|
## Decision Outcome
|
||||||
|
|
||||||
Chosen option: "{title of option 1}", because {justification, e.g., only option which meets k.o. criterion / resolves force / comes out best (see below)}.
|
Chosen option: "{title of option 1}", because {justification, e.g., only option which meets k.o. criterion / resolves force / comes out best (see below)}.
|
||||||
|
|
||||||
<!-- This is an optional element. Feel free to remove. -->
|
<!-- This is an optional element. Feel free to remove. -->
|
||||||
|
|
||||||
### Consequences
|
### Consequences
|
||||||
|
|
||||||
* Good, because {positive consequence, e.g., improvement of one or more desired qualities, …}
|
- Good, because {positive consequence, e.g., improvement of one or more desired qualities, …}
|
||||||
* Bad, because {negative consequence, e.g., compromising one or more desired qualities, …}
|
- Bad, because {negative consequence, e.g., compromising one or more desired qualities, …}
|
||||||
* …
|
- …
|
||||||
|
|
||||||
<!-- This is an optional element. Feel free to remove. -->
|
<!-- This is an optional element. Feel free to remove. -->
|
||||||
|
|
||||||
### Confirmation
|
### Confirmation
|
||||||
|
|
||||||
{Describe how the implementation of / compliance with the ADR is confirmed. E.g., by a review, an ArchUnit test, a Lighthouse CI threshold, an axe-core assertion. Although classified as optional, this element is included in most ADRs.}
|
{Describe how the implementation of / compliance with the ADR is confirmed. E.g., by a review, an ArchUnit test, a Lighthouse CI threshold, an axe-core assertion. Although classified as optional, this element is included in most ADRs.}
|
||||||
|
|
||||||
<!-- This is an optional element. Feel free to remove. -->
|
<!-- This is an optional element. Feel free to remove. -->
|
||||||
|
|
||||||
## Pros and Cons of the Options
|
## Pros and Cons of the Options
|
||||||
|
|
||||||
### {title of option 1}
|
### {title of option 1}
|
||||||
|
|
||||||
{example | description | pointer to more information | …}
|
{example | description | pointer to more information | …}
|
||||||
|
|
||||||
* Good, because {argument a}
|
- Good, because {argument a}
|
||||||
* Good, because {argument b}
|
- Good, because {argument b}
|
||||||
* Neutral, because {argument c}
|
- Neutral, because {argument c}
|
||||||
* Bad, because {argument d}
|
- Bad, because {argument d}
|
||||||
|
|
||||||
### {title of option 2}
|
### {title of option 2}
|
||||||
|
|
||||||
{example | description | pointer to more information | …}
|
{example | description | pointer to more information | …}
|
||||||
|
|
||||||
* Good, because {argument a}
|
- Good, because {argument a}
|
||||||
* Good, because {argument b}
|
- Good, because {argument b}
|
||||||
* Neutral, because {argument c}
|
- Neutral, because {argument c}
|
||||||
* Bad, because {argument d}
|
- Bad, because {argument d}
|
||||||
|
|
||||||
<!-- This is an optional element. Feel free to remove. -->
|
<!-- This is an optional element. Feel free to remove. -->
|
||||||
|
|
||||||
## More Information
|
## More Information
|
||||||
|
|
||||||
{Additional evidence/confidence for the decision; team agreement; when and how the decision should be realized; consequences for other decisions, projects, or teams; links to related ADRs.}
|
{Additional evidence/confidence for the decision; team agreement; when and how the decision should be realized; consequences for other decisions, projects, or teams; links to related ADRs.}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# 🏗️ Bootstrap the Nx monorepo for apf-portal
|
# 🏗️ Bootstrap the Nx monorepo for apf-portal
|
||||||
|
|
||||||
> **Status.** Aligned with phase-1 ADRs ([0002](../../decisions/0002-adopt-nx-monorepo-apps-preset.md), [0003](../../decisions/0003-workspace-and-app-naming-convention.md), [0004](../../decisions/0004-frontend-stack-angular-csr-zoneless-signals.md), [0005](../../decisions/0005-backend-stack-nestjs.md), [0006](../../decisions/0006-persistence-postgresql-prisma.md)). Replaces the original placeholder-based draft.
|
> **Status.** Aligned with phase-1 ADRs ([0002](../decisions/0002-adopt-nx-monorepo-apps-preset.md), [0003](../decisions/0003-workspace-and-app-naming-convention.md), [0004](../decisions/0004-frontend-stack-angular-csr-zoneless-signals.md), [0005](../decisions/0005-backend-stack-nestjs.md), [0006](../decisions/0006-persistence-postgresql-prisma.md)). Replaces the original placeholder-based draft.
|
||||||
|
|
||||||
## Goal
|
## Goal
|
||||||
|
|
||||||
@@ -25,7 +25,7 @@ Out of scope here (covered by later phases): authentication (Entra ID), sessions
|
|||||||
|
|
||||||
## 1) Initialize the Nx workspace inside the existing repository
|
## 1) Initialize the Nx workspace inside the existing repository
|
||||||
|
|
||||||
The repository already carries documentation, ADRs, `.vscode/`, and `.gitignore`. `create-nx-workspace` always generates into a *new* directory, so we generate next to the repo and copy the workspace files in.
|
The repository already carries documentation, ADRs, `.vscode/`, and `.gitignore`. `create-nx-workspace` always generates into a _new_ directory, so we generate next to the repo and copy the workspace files in.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. Generate a clean Nx workspace next to the repo
|
# 1. Generate a clean Nx workspace next to the repo
|
||||||
@@ -47,7 +47,7 @@ cp -v package.json pnpm-workspace.yaml nx.json tsconfig.base.json \
|
|||||||
cd ~ && rm -rf ~/dev/apf-portal-bootstrap
|
cd ~ && rm -rf ~/dev/apf-portal-bootstrap
|
||||||
```
|
```
|
||||||
|
|
||||||
The `apps` preset is generic — it does not privilege a runtime ([ADR-0002](../../decisions/0002-adopt-nx-monorepo-apps-preset.md)). Plugins are added in step 2.
|
The `apps` preset is generic — it does not privilege a runtime ([ADR-0002](../decisions/0002-adopt-nx-monorepo-apps-preset.md)). Plugins are added in step 2.
|
||||||
|
|
||||||
Verify:
|
Verify:
|
||||||
|
|
||||||
@@ -92,10 +92,7 @@ import { provideRouter } from '@angular/router';
|
|||||||
import { routes } from './app.routes';
|
import { routes } from './app.routes';
|
||||||
|
|
||||||
export const appConfig: ApplicationConfig = {
|
export const appConfig: ApplicationConfig = {
|
||||||
providers: [
|
providers: [provideZonelessChangeDetection(), provideRouter(routes)],
|
||||||
provideZonelessChangeDetection(),
|
|
||||||
provideRouter(routes),
|
|
||||||
],
|
|
||||||
};
|
};
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -122,8 +119,8 @@ In `tsconfig.base.json`:
|
|||||||
"noUncheckedIndexedAccess": true,
|
"noUncheckedIndexedAccess": true,
|
||||||
"exactOptionalPropertyTypes": true,
|
"exactOptionalPropertyTypes": true,
|
||||||
"noImplicitOverride": true,
|
"noImplicitOverride": true,
|
||||||
"noPropertyAccessFromIndexSignature": true
|
"noPropertyAccessFromIndexSignature": true,
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -189,9 +186,7 @@ import { Module } from '@nestjs/common';
|
|||||||
import { PrismaModule } from 'nestjs-prisma';
|
import { PrismaModule } from 'nestjs-prisma';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [PrismaModule.forRoot({ isGlobal: true })],
|
||||||
PrismaModule.forRoot({ isGlobal: true }),
|
|
||||||
],
|
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
```
|
```
|
||||||
@@ -208,7 +203,8 @@ DATABASE_URL="postgresql://portal:portal@localhost:5432/portal_dev?schema=public
|
|||||||
|
|
||||||
## 6) Generate the first libraries
|
## 6) Generate the first libraries
|
||||||
|
|
||||||
Convention ([ADR-0003](../../decisions/0003-workspace-and-app-naming-convention.md)):
|
Convention ([ADR-0003](../decisions/0003-workspace-and-app-naming-convention.md)):
|
||||||
|
|
||||||
- `libs/feature/<name>` for vertical features;
|
- `libs/feature/<name>` for vertical features;
|
||||||
- `libs/shared/<scope>` for cross-cutting concerns.
|
- `libs/shared/<scope>` for cross-cutting concerns.
|
||||||
|
|
||||||
@@ -262,7 +258,7 @@ This prevents `portal-shell` from importing `portal-bff` code (and vice versa),
|
|||||||
|
|
||||||
## 8) Prettier, git hooks, and Conventional Commits
|
## 8) Prettier, git hooks, and Conventional Commits
|
||||||
|
|
||||||
See [ADR-0007](../../decisions/0007-pre-commit-hooks-and-conventional-commits.md) for the rationale (Husky + lint-staged + commitlint with Conventional Commits).
|
See [ADR-0007](../decisions/0007-pre-commit-hooks-and-conventional-commits.md) for the rationale (Husky + lint-staged + commitlint with Conventional Commits).
|
||||||
|
|
||||||
`.prettierrc` (project-wide):
|
`.prettierrc` (project-wide):
|
||||||
|
|
||||||
@@ -344,10 +340,10 @@ pnpm nx g @nx/nest:resource <name> --project=portal-bff
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## What this guide does *not* cover (yet)
|
## What this guide does _not_ cover (yet)
|
||||||
|
|
||||||
| Concern | Phase |
|
| Concern | Phase |
|
||||||
| --- | --- |
|
| ------------------------------------------------------------------------------------------ | ----- |
|
||||||
| Authentication (Entra ID workforce + External ID) | 2 |
|
| Authentication (Entra ID workforce + External ID) | 2 |
|
||||||
| Sessions / cache (Redis self-hosted) | 2 |
|
| Sessions / cache (Redis self-hosted) | 2 |
|
||||||
| Downstream API access (existing apps) | 2 |
|
| Downstream API access (existing apps) | 2 |
|
||||||
|
|||||||
+31
-3
@@ -5,7 +5,7 @@ export default [
|
|||||||
...nx.configs['flat/typescript'],
|
...nx.configs['flat/typescript'],
|
||||||
...nx.configs['flat/javascript'],
|
...nx.configs['flat/javascript'],
|
||||||
{
|
{
|
||||||
ignores: ['**/dist', '**/out-tsc'],
|
ignores: ['**/dist', '**/out-tsc', '**/vitest.config.*.timestamp*'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
files: ['**/*.ts', '**/*.tsx', '**/*.js', '**/*.jsx'],
|
files: ['**/*.ts', '**/*.tsx', '**/*.js', '**/*.jsx'],
|
||||||
@@ -15,10 +15,38 @@ export default [
|
|||||||
{
|
{
|
||||||
enforceBuildableLibDependency: true,
|
enforceBuildableLibDependency: true,
|
||||||
allow: ['^.*/eslint(\\.base)?\\.config\\.[cm]?[jt]s$'],
|
allow: ['^.*/eslint(\\.base)?\\.config\\.[cm]?[jt]s$'],
|
||||||
|
// Module boundaries per ADR-0003.
|
||||||
|
//
|
||||||
|
// Scope tags partition the workspace into front (portal-shell) /
|
||||||
|
// back (portal-bff) / shared (consumable by both). Type tags
|
||||||
|
// partition into apps / feature libs / shared libs.
|
||||||
|
//
|
||||||
|
// Both axes apply: a lib must satisfy both scope and type rules
|
||||||
|
// to be importable.
|
||||||
depConstraints: [
|
depConstraints: [
|
||||||
{
|
{
|
||||||
sourceTag: '*',
|
sourceTag: 'scope:portal-shell',
|
||||||
onlyDependOnLibsWithTags: ['*'],
|
onlyDependOnLibsWithTags: ['scope:portal-shell', 'scope:shared'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sourceTag: 'scope:portal-bff',
|
||||||
|
onlyDependOnLibsWithTags: ['scope:portal-bff', 'scope:shared'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sourceTag: 'scope:shared',
|
||||||
|
onlyDependOnLibsWithTags: ['scope:shared'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sourceTag: 'type:app',
|
||||||
|
onlyDependOnLibsWithTags: ['type:feature', 'type:shared'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sourceTag: 'type:feature',
|
||||||
|
onlyDependOnLibsWithTags: ['type:feature', 'type:shared'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sourceTag: 'type:shared',
|
||||||
|
onlyDependOnLibsWithTags: ['type:shared'],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# feature-auth
|
||||||
|
|
||||||
|
This library was generated with [Nx](https://nx.dev).
|
||||||
|
|
||||||
|
## Running unit tests
|
||||||
|
|
||||||
|
Run `nx test feature-auth` to execute the unit tests.
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import nx from '@nx/eslint-plugin';
|
||||||
|
import baseConfig from '../../../eslint.config.mjs';
|
||||||
|
|
||||||
|
export default [
|
||||||
|
...nx.configs['flat/angular'],
|
||||||
|
...nx.configs['flat/angular-template'],
|
||||||
|
...baseConfig,
|
||||||
|
{
|
||||||
|
files: ['**/*.ts'],
|
||||||
|
rules: {
|
||||||
|
'@angular-eslint/directive-selector': [
|
||||||
|
'error',
|
||||||
|
{
|
||||||
|
type: 'attribute',
|
||||||
|
prefix: 'lib',
|
||||||
|
style: 'camelCase',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'@angular-eslint/component-selector': [
|
||||||
|
'error',
|
||||||
|
{
|
||||||
|
type: 'element',
|
||||||
|
prefix: 'lib',
|
||||||
|
style: 'kebab-case',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ['**/*.html'],
|
||||||
|
// Override or add rules here
|
||||||
|
rules: {},
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"name": "feature-auth",
|
||||||
|
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
|
||||||
|
"sourceRoot": "libs/feature/auth/src",
|
||||||
|
"prefix": "lib",
|
||||||
|
"projectType": "library",
|
||||||
|
"tags": ["scope:portal-shell", "type:feature"],
|
||||||
|
"targets": {
|
||||||
|
"lint": {
|
||||||
|
"executor": "@nx/eslint:lint"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './lib/feature-auth/feature-auth';
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<p>FeatureAuth works!</p>
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
import { FeatureAuth } from './feature-auth';
|
||||||
|
|
||||||
|
describe('FeatureAuth', () => {
|
||||||
|
let component: FeatureAuth;
|
||||||
|
let fixture: ComponentFixture<FeatureAuth>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [FeatureAuth],
|
||||||
|
}).compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(FeatureAuth);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
await fixture.whenStable();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { Component } from '@angular/core';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'lib-feature-auth',
|
||||||
|
imports: [],
|
||||||
|
templateUrl: './feature-auth.html',
|
||||||
|
styleUrl: './feature-auth.css',
|
||||||
|
})
|
||||||
|
export class FeatureAuth {}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import '@angular/compiler';
|
||||||
|
import '@analogjs/vitest-angular/setup-snapshots';
|
||||||
|
import { setupTestBed } from '@analogjs/vitest-angular/setup-testbed';
|
||||||
|
|
||||||
|
setupTestBed();
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"emitDecoratorMetadata": false,
|
||||||
|
"module": "preserve"
|
||||||
|
},
|
||||||
|
"angularCompilerOptions": {
|
||||||
|
"enableI18nLegacyMessageIdFormat": false,
|
||||||
|
"strictInjectionParameters": true,
|
||||||
|
"strictInputAccessModifiers": true,
|
||||||
|
"strictTemplates": true
|
||||||
|
},
|
||||||
|
"files": [],
|
||||||
|
"include": [],
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.lib.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.spec.json"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "../../../dist/out-tsc",
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"inlineSources": true,
|
||||||
|
"types": []
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"],
|
||||||
|
"exclude": [
|
||||||
|
"src/**/*.spec.ts",
|
||||||
|
"src/**/*.test.ts",
|
||||||
|
"vite.config.ts",
|
||||||
|
"vite.config.mts",
|
||||||
|
"vitest.config.ts",
|
||||||
|
"vitest.config.mts",
|
||||||
|
"src/**/*.test.tsx",
|
||||||
|
"src/**/*.spec.tsx",
|
||||||
|
"src/**/*.test.js",
|
||||||
|
"src/**/*.spec.js",
|
||||||
|
"src/**/*.test.jsx",
|
||||||
|
"src/**/*.spec.jsx",
|
||||||
|
"src/test-setup.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "../../../dist/out-tsc",
|
||||||
|
"types": ["vitest/globals", "vitest/importMeta", "vite/client", "node", "vitest"]
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"vite.config.ts",
|
||||||
|
"vite.config.mts",
|
||||||
|
"vitest.config.ts",
|
||||||
|
"vitest.config.mts",
|
||||||
|
"src/**/*.test.ts",
|
||||||
|
"src/**/*.spec.ts",
|
||||||
|
"src/**/*.test.tsx",
|
||||||
|
"src/**/*.spec.tsx",
|
||||||
|
"src/**/*.test.js",
|
||||||
|
"src/**/*.spec.js",
|
||||||
|
"src/**/*.test.jsx",
|
||||||
|
"src/**/*.spec.jsx",
|
||||||
|
"src/**/*.d.ts"
|
||||||
|
],
|
||||||
|
"files": ["src/test-setup.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
/// <reference types='vitest' />
|
||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import angular from '@analogjs/vite-plugin-angular';
|
||||||
|
import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin';
|
||||||
|
import { nxCopyAssetsPlugin } from '@nx/vite/plugins/nx-copy-assets.plugin';
|
||||||
|
|
||||||
|
export default defineConfig(() => ({
|
||||||
|
root: __dirname,
|
||||||
|
cacheDir: '../../../node_modules/.vite/libs/feature/auth',
|
||||||
|
plugins: [angular(), nxViteTsPaths(), nxCopyAssetsPlugin(['*.md'])],
|
||||||
|
// Uncomment this if you are using workers.
|
||||||
|
// worker: {
|
||||||
|
// plugins: () => [ nxViteTsPaths() ],
|
||||||
|
// },
|
||||||
|
test: {
|
||||||
|
name: 'feature-auth',
|
||||||
|
watch: false,
|
||||||
|
globals: true,
|
||||||
|
environment: 'jsdom',
|
||||||
|
include: ['{src,tests}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
|
||||||
|
setupFiles: ['src/test-setup.ts'],
|
||||||
|
reporters: ['default'],
|
||||||
|
coverage: {
|
||||||
|
reportsDirectory: '../../../coverage/libs/feature/auth',
|
||||||
|
provider: 'v8' as const,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# shared-tokens
|
||||||
|
|
||||||
|
This library was generated with [Nx](https://nx.dev).
|
||||||
|
|
||||||
|
## Building
|
||||||
|
|
||||||
|
Run `nx build shared-tokens` to build the library.
|
||||||
|
|
||||||
|
## Running unit tests
|
||||||
|
|
||||||
|
Run `nx test shared-tokens` to execute the unit tests via [Vitest](https://vitest.dev/).
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import baseConfig from '../../../eslint.config.mjs';
|
||||||
|
|
||||||
|
export default [
|
||||||
|
...baseConfig,
|
||||||
|
{
|
||||||
|
files: ['**/*.json'],
|
||||||
|
rules: {
|
||||||
|
'@nx/dependency-checks': [
|
||||||
|
'error',
|
||||||
|
{
|
||||||
|
ignoredFiles: [
|
||||||
|
'{projectRoot}/eslint.config.{js,cjs,mjs,ts,cts,mts}',
|
||||||
|
'{projectRoot}/vite.config.{js,ts,mjs,mts}',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
languageOptions: {
|
||||||
|
parser: await import('jsonc-eslint-parser'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"name": "shared-tokens",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"private": true,
|
||||||
|
"type": "commonjs",
|
||||||
|
"main": "./src/index.js",
|
||||||
|
"types": "./src/index.d.ts",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.3.0",
|
||||||
|
"vitest": "^4.0.8",
|
||||||
|
"@nx/vite": "^22.7.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"name": "shared-tokens",
|
||||||
|
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
|
||||||
|
"sourceRoot": "libs/shared/tokens/src",
|
||||||
|
"projectType": "library",
|
||||||
|
"tags": ["scope:shared", "type:shared"],
|
||||||
|
"targets": {
|
||||||
|
"build": {
|
||||||
|
"executor": "@nx/js:tsc",
|
||||||
|
"outputs": ["{options.outputPath}"],
|
||||||
|
"options": {
|
||||||
|
"outputPath": "dist/libs/shared/tokens",
|
||||||
|
"main": "libs/shared/tokens/src/index.ts",
|
||||||
|
"tsConfig": "libs/shared/tokens/tsconfig.lib.json",
|
||||||
|
"assets": ["libs/shared/tokens/*.md"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './lib/shared-tokens';
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { sharedTokens } from './shared-tokens';
|
||||||
|
|
||||||
|
describe('sharedTokens', () => {
|
||||||
|
it('should work', () => {
|
||||||
|
expect(sharedTokens()).toEqual('shared-tokens');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export function sharedTokens(): string {
|
||||||
|
return 'shared-tokens';
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "esnext",
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"strict": true,
|
||||||
|
"importHelpers": true,
|
||||||
|
"noImplicitOverride": true,
|
||||||
|
"noImplicitReturns": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noPropertyAccessFromIndexSignature": true
|
||||||
|
},
|
||||||
|
"files": [],
|
||||||
|
"include": [],
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.lib.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.spec.json"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "../../../dist/out-tsc",
|
||||||
|
"declaration": true,
|
||||||
|
"types": ["node"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"],
|
||||||
|
"exclude": [
|
||||||
|
"vite.config.ts",
|
||||||
|
"vite.config.mts",
|
||||||
|
"vitest.config.ts",
|
||||||
|
"vitest.config.mts",
|
||||||
|
"src/**/*.test.ts",
|
||||||
|
"src/**/*.spec.ts",
|
||||||
|
"src/**/*.test.tsx",
|
||||||
|
"src/**/*.spec.tsx",
|
||||||
|
"src/**/*.test.js",
|
||||||
|
"src/**/*.spec.js",
|
||||||
|
"src/**/*.test.jsx",
|
||||||
|
"src/**/*.spec.jsx"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "../../../dist/out-tsc",
|
||||||
|
"types": ["vitest/globals", "vitest/importMeta", "vite/client", "node", "vitest"]
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"vite.config.ts",
|
||||||
|
"vite.config.mts",
|
||||||
|
"vitest.config.ts",
|
||||||
|
"vitest.config.mts",
|
||||||
|
"src/**/*.test.ts",
|
||||||
|
"src/**/*.spec.ts",
|
||||||
|
"src/**/*.test.tsx",
|
||||||
|
"src/**/*.spec.tsx",
|
||||||
|
"src/**/*.test.js",
|
||||||
|
"src/**/*.spec.js",
|
||||||
|
"src/**/*.test.jsx",
|
||||||
|
"src/**/*.spec.jsx",
|
||||||
|
"src/**/*.d.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin';
|
||||||
|
import { nxCopyAssetsPlugin } from '@nx/vite/plugins/nx-copy-assets.plugin';
|
||||||
|
|
||||||
|
export default defineConfig(() => ({
|
||||||
|
root: __dirname,
|
||||||
|
cacheDir: '../../../node_modules/.vite/libs/shared/tokens',
|
||||||
|
plugins: [nxViteTsPaths(), nxCopyAssetsPlugin(['*.md'])],
|
||||||
|
test: {
|
||||||
|
name: 'shared-tokens',
|
||||||
|
watch: false,
|
||||||
|
globals: true,
|
||||||
|
environment: 'node',
|
||||||
|
include: ['{src,tests}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
|
||||||
|
reporters: ['default'],
|
||||||
|
coverage: {
|
||||||
|
reportsDirectory: '../../../coverage/libs/shared/tokens',
|
||||||
|
provider: 'v8' as const,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# shared-ui
|
||||||
|
|
||||||
|
This library was generated with [Nx](https://nx.dev).
|
||||||
|
|
||||||
|
## Running unit tests
|
||||||
|
|
||||||
|
Run `nx test shared-ui` to execute the unit tests.
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import nx from '@nx/eslint-plugin';
|
||||||
|
import baseConfig from '../../../eslint.config.mjs';
|
||||||
|
|
||||||
|
export default [
|
||||||
|
...nx.configs['flat/angular'],
|
||||||
|
...nx.configs['flat/angular-template'],
|
||||||
|
...baseConfig,
|
||||||
|
{
|
||||||
|
files: ['**/*.ts'],
|
||||||
|
rules: {
|
||||||
|
'@angular-eslint/directive-selector': [
|
||||||
|
'error',
|
||||||
|
{
|
||||||
|
type: 'attribute',
|
||||||
|
prefix: 'lib',
|
||||||
|
style: 'camelCase',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'@angular-eslint/component-selector': [
|
||||||
|
'error',
|
||||||
|
{
|
||||||
|
type: 'element',
|
||||||
|
prefix: 'lib',
|
||||||
|
style: 'kebab-case',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ['**/*.html'],
|
||||||
|
// Override or add rules here
|
||||||
|
rules: {},
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"name": "shared-ui",
|
||||||
|
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
|
||||||
|
"sourceRoot": "libs/shared/ui/src",
|
||||||
|
"prefix": "lib",
|
||||||
|
"projectType": "library",
|
||||||
|
"tags": ["scope:portal-shell", "type:shared"],
|
||||||
|
"targets": {
|
||||||
|
"lint": {
|
||||||
|
"executor": "@nx/eslint:lint"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './lib/shared-ui/shared-ui';
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<p>SharedUi works!</p>
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
import { SharedUi } from './shared-ui';
|
||||||
|
|
||||||
|
describe('SharedUi', () => {
|
||||||
|
let component: SharedUi;
|
||||||
|
let fixture: ComponentFixture<SharedUi>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [SharedUi],
|
||||||
|
}).compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(SharedUi);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
await fixture.whenStable();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { Component } from '@angular/core';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'lib-shared-ui',
|
||||||
|
imports: [],
|
||||||
|
templateUrl: './shared-ui.html',
|
||||||
|
styleUrl: './shared-ui.css',
|
||||||
|
})
|
||||||
|
export class SharedUi {}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import '@angular/compiler';
|
||||||
|
import '@analogjs/vitest-angular/setup-snapshots';
|
||||||
|
import { setupTestBed } from '@analogjs/vitest-angular/setup-testbed';
|
||||||
|
|
||||||
|
setupTestBed();
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"emitDecoratorMetadata": false,
|
||||||
|
"module": "preserve"
|
||||||
|
},
|
||||||
|
"angularCompilerOptions": {
|
||||||
|
"enableI18nLegacyMessageIdFormat": false,
|
||||||
|
"strictInjectionParameters": true,
|
||||||
|
"strictInputAccessModifiers": true,
|
||||||
|
"strictTemplates": true
|
||||||
|
},
|
||||||
|
"files": [],
|
||||||
|
"include": [],
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.lib.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.spec.json"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "../../../dist/out-tsc",
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"inlineSources": true,
|
||||||
|
"types": []
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"],
|
||||||
|
"exclude": [
|
||||||
|
"src/**/*.spec.ts",
|
||||||
|
"src/**/*.test.ts",
|
||||||
|
"vite.config.ts",
|
||||||
|
"vite.config.mts",
|
||||||
|
"vitest.config.ts",
|
||||||
|
"vitest.config.mts",
|
||||||
|
"src/**/*.test.tsx",
|
||||||
|
"src/**/*.spec.tsx",
|
||||||
|
"src/**/*.test.js",
|
||||||
|
"src/**/*.spec.js",
|
||||||
|
"src/**/*.test.jsx",
|
||||||
|
"src/**/*.spec.jsx",
|
||||||
|
"src/test-setup.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "../../../dist/out-tsc",
|
||||||
|
"types": ["vitest/globals", "vitest/importMeta", "vite/client", "node", "vitest"]
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"vite.config.ts",
|
||||||
|
"vite.config.mts",
|
||||||
|
"vitest.config.ts",
|
||||||
|
"vitest.config.mts",
|
||||||
|
"src/**/*.test.ts",
|
||||||
|
"src/**/*.spec.ts",
|
||||||
|
"src/**/*.test.tsx",
|
||||||
|
"src/**/*.spec.tsx",
|
||||||
|
"src/**/*.test.js",
|
||||||
|
"src/**/*.spec.js",
|
||||||
|
"src/**/*.test.jsx",
|
||||||
|
"src/**/*.spec.jsx",
|
||||||
|
"src/**/*.d.ts"
|
||||||
|
],
|
||||||
|
"files": ["src/test-setup.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
/// <reference types='vitest' />
|
||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import angular from '@analogjs/vite-plugin-angular';
|
||||||
|
import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin';
|
||||||
|
import { nxCopyAssetsPlugin } from '@nx/vite/plugins/nx-copy-assets.plugin';
|
||||||
|
|
||||||
|
export default defineConfig(() => ({
|
||||||
|
root: __dirname,
|
||||||
|
cacheDir: '../../../node_modules/.vite/libs/shared/ui',
|
||||||
|
plugins: [angular(), nxViteTsPaths(), nxCopyAssetsPlugin(['*.md'])],
|
||||||
|
// Uncomment this if you are using workers.
|
||||||
|
// worker: {
|
||||||
|
// plugins: () => [ nxViteTsPaths() ],
|
||||||
|
// },
|
||||||
|
test: {
|
||||||
|
name: 'shared-ui',
|
||||||
|
watch: false,
|
||||||
|
globals: true,
|
||||||
|
environment: 'jsdom',
|
||||||
|
include: ['{src,tests}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
|
||||||
|
setupFiles: ['src/test-setup.ts'],
|
||||||
|
reporters: ['default'],
|
||||||
|
coverage: {
|
||||||
|
reportsDirectory: '../../../coverage/libs/shared/ui',
|
||||||
|
provider: 'v8' as const,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# shared-util
|
||||||
|
|
||||||
|
This library was generated with [Nx](https://nx.dev).
|
||||||
|
|
||||||
|
## Building
|
||||||
|
|
||||||
|
Run `nx build shared-util` to build the library.
|
||||||
|
|
||||||
|
## Running unit tests
|
||||||
|
|
||||||
|
Run `nx test shared-util` to execute the unit tests via [Vitest](https://vitest.dev/).
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import baseConfig from '../../../eslint.config.mjs';
|
||||||
|
|
||||||
|
export default [
|
||||||
|
...baseConfig,
|
||||||
|
{
|
||||||
|
files: ['**/*.json'],
|
||||||
|
rules: {
|
||||||
|
'@nx/dependency-checks': [
|
||||||
|
'error',
|
||||||
|
{
|
||||||
|
ignoredFiles: [
|
||||||
|
'{projectRoot}/eslint.config.{js,cjs,mjs,ts,cts,mts}',
|
||||||
|
'{projectRoot}/vite.config.{js,ts,mjs,mts}',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
languageOptions: {
|
||||||
|
parser: await import('jsonc-eslint-parser'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user