diff --git a/.env.production.example b/.env.production.example index caa04554..ba970531 100644 --- a/.env.production.example +++ b/.env.production.example @@ -36,11 +36,19 @@ JwtSettings__Secret= JwtSettings__Issuer=Planora.Auth JwtSettings__Audience=Planora.Clients -# Direct connection strings for non-Compose production deployments -ConnectionStrings__AuthDatabase=Host=;Port=5432;Database=planora_auth_db;Username=;Password=;SSL Mode=Require;Trust Server Certificate=false; -ConnectionStrings__TodoDatabase=Host=;Port=5432;Database=planora_todo;Username=;Password=;SSL Mode=Require;Trust Server Certificate=false; -ConnectionStrings__CategoryDatabase=Host=;Port=5432;Database=planora_category;Username=;Password=;SSL Mode=Require;Trust Server Certificate=false; -ConnectionStrings__MessagingDatabase=Host=;Port=5432;Database=planora_messaging;Username=;Password=;SSL Mode=Require;Trust Server Certificate=false; +# Direct connection strings for non-Compose production deployments. +# +# Pool sizing (T4.4): +# Maximum Pool Size=10 per service × 6 services × N replicas = total connections +# against your managed Postgres. Tune up if you scale replicas, but stay +# well below the provider's max_connections (Neon free = 100, Fly Postgres +# shared = 64) to leave headroom for the migrator and autovacuum. +# Connection Idle Lifetime=60 evicts idle connections after a minute so a +# restarted Postgres does not hand back stale sockets on the next request. +ConnectionStrings__AuthDatabase=Host=;Port=5432;Database=planora_auth_db;Username=;Password=;SSL Mode=Require;Trust Server Certificate=false;Maximum Pool Size=10;Connection Idle Lifetime=60; +ConnectionStrings__TodoDatabase=Host=;Port=5432;Database=planora_todo;Username=;Password=;SSL Mode=Require;Trust Server Certificate=false;Maximum Pool Size=10;Connection Idle Lifetime=60; +ConnectionStrings__CategoryDatabase=Host=;Port=5432;Database=planora_category;Username=;Password=;SSL Mode=Require;Trust Server Certificate=false;Maximum Pool Size=10;Connection Idle Lifetime=60; +ConnectionStrings__MessagingDatabase=Host=;Port=5432;Database=planora_messaging;Username=;Password=;SSL Mode=Require;Trust Server Certificate=false;Maximum Pool Size=10;Connection Idle Lifetime=60; ConnectionStrings__Redis=:6379,password=,ssl=True,abortConnect=false # RabbitMQ service credentials for non-Compose deployments diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 00000000..765db8a2 --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +# Planora pre-commit hook. Runs only on the files staged for the current commit. +# Activate per-clone with `scripts/install-hooks.sh` (sets core.hooksPath = .githooks). +# +# Two cheap, locally-runnable gates: +# 1. Frontend: ESLint on staged .ts/.tsx/.js/.jsx (errors only, no auto-fix). +# 2. Backend: dotnet format --verify-no-changes on the touched solution. +# +# Both gates pass quickly on a no-op commit. Skip the hook for emergency commits +# with `git commit --no-verify`. + +set -euo pipefail + +repo_root="$(git rev-parse --show-toplevel)" +cd "$repo_root" + +# Files staged for commit (added, copied, modified, renamed — not deleted). +mapfile -t staged < <(git diff --cached --name-only --diff-filter=ACMR) +if [[ ${#staged[@]} -eq 0 ]]; then + exit 0 +fi + +# --- Frontend ---------------------------------------------------------------- + +frontend_files=() +for f in "${staged[@]}"; do + case "$f" in + frontend/*.ts|frontend/*.tsx|frontend/*.js|frontend/*.jsx|\ + frontend/**/*.ts|frontend/**/*.tsx|frontend/**/*.js|frontend/**/*.jsx) + frontend_files+=("$f") + ;; + esac +done + +if [[ ${#frontend_files[@]} -gt 0 ]]; then + if [[ -x frontend/node_modules/.bin/eslint ]]; then + # ESLint accepts paths relative to its CWD; strip the "frontend/" prefix. + eslint_targets=() + for f in "${frontend_files[@]}"; do + eslint_targets+=("${f#frontend/}") + done + echo "[pre-commit] eslint ${#eslint_targets[@]} staged frontend file(s)…" + ( cd frontend && ./node_modules/.bin/eslint --max-warnings=0 "${eslint_targets[@]}" ) + else + echo "[pre-commit] frontend/node_modules/.bin/eslint missing — run 'npm --prefix frontend install' to enable the gate. Skipping for now." + fi +fi + +# --- Backend ----------------------------------------------------------------- + +backend_files=() +for f in "${staged[@]}"; do + case "$f" in + *.cs|*.csproj|*.props|*.sln) + backend_files+=("$f") + ;; + esac +done + +if [[ ${#backend_files[@]} -gt 0 ]]; then + if command -v dotnet >/dev/null 2>&1; then + echo "[pre-commit] dotnet format --verify-no-changes on Planora.sln…" + dotnet format Planora.sln --verify-no-changes --severity warn --no-restore || { + echo "[pre-commit] dotnet format reported issues. Run 'dotnet format Planora.sln' to apply, then re-stage." + exit 1 + } + else + echo "[pre-commit] dotnet CLI not on PATH — skipping the backend format check." + fi +fi + +exit 0 diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 00000000..e41c7644 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,49 @@ +# Code-ownership policy for Planora. Until additional contributors land, +# the maintainer reviews every PR; the entries below codify which surfaces are +# *especially* sensitive (security primitives, observability pipeline, schema +# migrations, deployment manifests) so a future multi-reviewer setup keeps the +# right eyes on the right diffs. + +# Default owner for everything not matched below. +* @4Keyy + +# Security primitives — auth flows, JWT, CSRF, gRPC interceptors, security stamp. +BuildingBlocks/Planora.BuildingBlocks.Infrastructure/Security/ @4Keyy +BuildingBlocks/Planora.BuildingBlocks.Infrastructure/Middleware/ @4Keyy +BuildingBlocks/Planora.BuildingBlocks.Infrastructure/Grpc/ @4Keyy +BuildingBlocks/Planora.BuildingBlocks.Infrastructure/Extensions/JwtAuthenticationExtensions.cs @4Keyy +Services/AuthApi/Planora.Auth.Infrastructure/Services/Security/ @4Keyy +Services/AuthApi/Planora.Auth.Api/Filters/ @4Keyy +SECURITY.md @4Keyy +docs/auth-security.md @4Keyy +docs/secrets-management.md @4Keyy + +# Observability pipeline — INV-OBS-* invariants enforced here. +BuildingBlocks/Planora.BuildingBlocks.Infrastructure/Logging/ @4Keyy +BuildingBlocks/Planora.BuildingBlocks.Infrastructure/Observability/ @4Keyy + +# Outbox / inbox state machines — at-least-once delivery and idempotency. +BuildingBlocks/Planora.BuildingBlocks.Infrastructure/Outbox/ @4Keyy +BuildingBlocks/Planora.BuildingBlocks.Infrastructure/Inbox/ @4Keyy +BuildingBlocks/Planora.BuildingBlocks.Application/Outbox/ @4Keyy +BuildingBlocks/Planora.BuildingBlocks.Infrastructure/IdempotentConsumer/ @4Keyy + +# Migrator + EF schema — wrong move here corrupts production __EFMigrationsHistory. +tools/Planora.Migrator/ @4Keyy +**/Migrations/ @4Keyy + +# CI/CD + deployment manifests + Dockerfiles — supply-chain blast radius. +.github/workflows/ @4Keyy +.github/dependabot.yml @4Keyy +deploy/ @4Keyy +**/Dockerfile @4Keyy +docker-compose.yml @4Keyy + +# Architectural truth. +docs/INVARIANTS.md @4Keyy +docs/DECISIONS/ @4Keyy +docs/ROADMAP.md @4Keyy +ARCHITECTURE.md @4Keyy + +# Gateway — the only public HTTP edge. +Planora.ApiGateway/ @4Keyy diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index d8c069f7..a325e2ea 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -70,7 +70,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 5 steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: ${{ inputs.ref || github.ref }} @@ -83,11 +83,7 @@ jobs: fi - name: Install flyctl - # TODO(security): pin this action to a SHA before relying on this - # workflow in production. Run: - # gh api /repos/superfly/flyctl-actions/git/refs/heads/master --jq .object.sha - # and replace `@master` with `@ # master at `. - uses: superfly/flyctl-actions/setup-flyctl@master + uses: superfly/flyctl-actions/setup-flyctl@ed8efb33836e8b2096c7fd3ba1c8afe303ebbff1 # 1.6 - name: Validate every fly.toml parses shell: bash @@ -106,12 +102,12 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 15 steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: ${{ inputs.ref || github.ref }} - name: Install flyctl - uses: superfly/flyctl-actions/setup-flyctl@master + uses: superfly/flyctl-actions/setup-flyctl@ed8efb33836e8b2096c7fd3ba1c8afe303ebbff1 # 1.6 - name: Run Planora.Migrator one-shot shell: bash @@ -159,12 +155,12 @@ jobs: config: deploy/fly/realtime.fly.toml dockerfile: Services/RealtimeApi/Planora.Realtime.Api/Dockerfile steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: ${{ inputs.ref || github.ref }} - name: Install flyctl - uses: superfly/flyctl-actions/setup-flyctl@master + uses: superfly/flyctl-actions/setup-flyctl@ed8efb33836e8b2096c7fd3ba1c8afe303ebbff1 # 1.6 - name: Deploy ${{ matrix.app }} shell: bash @@ -184,12 +180,12 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 15 steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: ${{ inputs.ref || github.ref }} - name: Install flyctl - uses: superfly/flyctl-actions/setup-flyctl@master + uses: superfly/flyctl-actions/setup-flyctl@ed8efb33836e8b2096c7fd3ba1c8afe303ebbff1 # 1.6 - name: Deploy planora-gateway shell: bash @@ -217,6 +213,27 @@ jobs: run: | echo "url=https://planora-gateway.fly.dev" >> "$GITHUB_OUTPUT" + - name: Probe /health/live (liveness) + shell: bash + # Liveness must succeed within a tight window. If the gateway process is + # alive but readiness is delayed (e.g. a slow Postgres warm-up), readiness + # polling below handles that — but if /health/live itself does not respond, + # the deploy is unhealthy and we surface immediately rather than burning + # two minutes on readiness retries. + run: | + set -e + live="${{ steps.gateway.outputs.url }}/health/live" + for attempt in {1..15}; do + if curl --fail --silent --show-error --max-time 3 "${live}" > /dev/null; then + echo "::notice::Gateway /health/live is OK" + exit 0 + fi + echo "Liveness attempt ${attempt}/15 — gateway process not responding" + sleep 2 + done + echo "::error::Gateway /health/live did not return 200 within 30 s — deploy is broken" + exit 1 + - name: Wait for /health/ready shell: bash run: | diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ebbdbc45..bc913140 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,9 +1,9 @@ name: CI on: push: - branches: [main, 'audit/**', 'fix/**', 'claude/**'] + branches: [main, develop, 'audit/**', 'fix/**'] pull_request: - branches: [main] + branches: [main, develop] # Minimum permissions. Each job only receives what it needs. permissions: @@ -19,9 +19,9 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 10 steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Lint Markdown - uses: DavidAnson/markdownlint-cli2-action@992badcdf24e3b8eb7e87ff9287fe931bcb00c6e # v20 + uses: DavidAnson/markdownlint-cli2-action@ded1f9488f68a970bc66ea5619e13e9b52e601cd # v23.2.0 - name: Check Markdown links uses: lycheeverse/lychee-action@8646ba30535128ac92d33dfc9133794bfdd9b411 # v2 with: @@ -32,14 +32,22 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 20 steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5 with: - dotnet-version: '9.0.x' + dotnet-version: '10.0.x' + # CPM: hash every input that can change the restore graph (csproj graph + # + central package versions + build props). No packages.lock.json yet, + # so the action hashes these files to derive the cache key. + cache: true + cache-dependency-path: | + **/*.csproj + Directory.Packages.props + Directory.Build.props - run: dotnet restore Planora.sln - run: dotnet build Planora.sln --no-restore --configuration Release -warnaserror - run: dotnet test Planora.sln --no-build --configuration Release --collect:"XPlat Code Coverage" --settings coverage.runsettings --results-directory ./coverage/backend - - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: backend-coverage-report path: ./coverage/backend @@ -51,7 +59,7 @@ jobs: run: working-directory: frontend steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 with: node-version: '20' @@ -62,7 +70,7 @@ jobs: - run: npm run type-check - run: npm run test:coverage - run: npm run build - - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: frontend-coverage-report path: frontend/coverage diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index b7f057b5..55739743 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -3,7 +3,7 @@ name: E2E on: workflow_dispatch: pull_request: - branches: [main] + branches: [main, develop] paths: - '.github/workflows/e2e.yml' - 'BuildingBlocks/**' @@ -26,7 +26,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 30 steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 with: @@ -83,16 +83,61 @@ jobs: run: npm ci working-directory: frontend - - name: Run Playwright e2e + # T2.6 — install Chromium for browser-rendered UI specs. The chromium + # binary is cached by setup-node along with the npm cache; first run + # downloads it (~150 MB), subsequent runs hit the cache. + - name: Install Playwright browsers (chromium) + run: npx playwright install --with-deps chromium + working-directory: frontend + + # T2.6 — build + start the Next.js frontend so the `ui` project can + # drive a real browser against it. The dev server is intentionally not + # used: dev mode has different hydration timing and Webpack overlays + # that interfere with Playwright. + - name: Build frontend + run: npm run build + working-directory: frontend + env: + NEXT_PUBLIC_API_URL: http://127.0.0.1:5132 + + - name: Start frontend (background) and wait for readiness + shell: bash + working-directory: frontend + run: | + NEXT_PUBLIC_API_URL=http://127.0.0.1:5132 \ + nohup npm run start -- -p 3000 > ../frontend-server.log 2>&1 & + echo $! > ../frontend.pid + for attempt in {1..60}; do + if curl --fail --silent --show-error "http://127.0.0.1:3000" > /dev/null; then + echo "Frontend ready after ${attempt} attempts" + exit 0 + fi + sleep 2 + done + echo "Frontend failed to start; tailing log:" + tail -200 ../frontend-server.log + exit 1 + + - name: Run Playwright e2e (api + ui projects) run: npm run e2e working-directory: frontend env: E2E_API_URL: http://127.0.0.1:5132 + E2E_FRONTEND_URL: http://127.0.0.1:3000 E2E_AUTH_LOG_CONTAINER: planora-auth-api + - name: Stop frontend + if: always() + shell: bash + run: | + if [ -f frontend.pid ]; then + pid=$(cat frontend.pid) + kill "$pid" 2>/dev/null || true + fi + - name: Upload Playwright report if: always() - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: playwright-report path: | diff --git a/.github/workflows/migrations.yml b/.github/workflows/migrations.yml index 067ce785..1ebb903c 100644 --- a/.github/workflows/migrations.yml +++ b/.github/workflows/migrations.yml @@ -9,7 +9,7 @@ name: Migration Scripts on: pull_request: - branches: [main] + branches: [main, develop] paths: - 'Services/**/Migrations/**' - 'Services/**/Persistence/**' @@ -52,11 +52,16 @@ jobs: startup: Services/MessagingApi/Planora.Messaging.Api context: MessagingDbContext steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5 with: - dotnet-version: '9.0.x' + dotnet-version: '10.0.x' + cache: true + cache-dependency-path: | + **/*.csproj + Directory.Packages.props + Directory.Build.props - name: Install dotnet-ef run: dotnet tool install --global dotnet-ef --version 9.0.15 @@ -88,7 +93,28 @@ jobs: head -50 migration-scripts/${{ matrix.name }}.sql echo "::endgroup::" - - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + - name: Validate idempotence markers are present + # `dotnet ef migrations script --idempotent` wraps every statement in an + # IF NOT EXISTS / IF EXISTS guard. If the generated SQL is non-empty but + # carries zero guards, the --idempotent flag silently failed (EF tooling + # regression) and re-running the migrator would corrupt the history. We + # only enforce this when the file actually contains migration content. + run: | + set -e + script="migration-scripts/${{ matrix.name }}.sql" + # An "empty" generated script still emits the COMMIT scaffolding; treat + # < 30 lines as no-content and skip the guard check. + lines=$(wc -l < "${script}") + if [ "${lines}" -lt 30 ]; then + echo "::notice::${{ matrix.name }}.sql appears empty (${lines} lines); skipping idempotence-marker check." + exit 0 + fi + if ! grep -q -E "IF (NOT )?EXISTS" "${script}"; then + echo "::error::${{ matrix.name }}.sql has migration content but no IF [NOT] EXISTS guards. --idempotent flag did not produce idempotent SQL." + exit 1 + fi + + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: migration-script-${{ matrix.name }} path: migration-scripts/${{ matrix.name }}.sql diff --git a/.github/workflows/nuget-vuln-pr.yml b/.github/workflows/nuget-vuln-pr.yml new file mode 100644 index 00000000..08bf5d86 --- /dev/null +++ b/.github/workflows/nuget-vuln-pr.yml @@ -0,0 +1,165 @@ +name: NuGet Vulnerability Auto-PR + +# Compensating workflow for the dependabot-disabled NuGet ecosystem. +# +# Why this exists: Dependabot cannot scope updates to the central +# Directory.Packages.props file (CPM), so we keep its NuGet ecosystem at +# open-pull-requests-limit: 0 and instead run this nightly job. If +# `dotnet list package --vulnerable` reports any high/critical advisories +# in the locked transitive graph, the workflow opens (or updates) a +# tracking PR with the report body. +# +# Authority model: +# - Branch name is stable (security/nuget-vuln-tracking) so re-runs +# update the same PR instead of fanning out duplicates. +# - PR body is the raw report — reviewers see exactly what shipped. +# - The PR carries a single label so it's easy to triage. +# - No code changes are pushed; only a marker file in the report branch +# so the PR has a delta to surface. Maintainer edits Directory.Packages.props +# themselves to apply fixes (per the dependabot-disabled rationale). + +on: + schedule: + # 03:00 UTC every day — quiet hours globally, well clear of CI peaks. + - cron: '0 3 * * *' + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + +concurrency: + group: nuget-vuln-pr + cancel-in-progress: false + +jobs: + scan-and-pr: + runs-on: ubuntu-latest + timeout-minutes: 15 + env: + REPORT_BRANCH: security/nuget-vuln-tracking + REPORT_FILE: .github/security/nuget-vuln-report.md + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + + - uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5 + with: + dotnet-version: '10.0.x' + cache: true + cache-dependency-path: | + **/*.csproj + Directory.Packages.props + Directory.Build.props + + - name: Restore solution + run: dotnet restore Planora.sln + + - name: Scan for vulnerable packages + id: scan + shell: bash + run: | + set -e + report=$(dotnet list package --vulnerable --include-transitive 2>&1 || true) + echo "$report" > /tmp/vuln-raw.txt + + # The scanner prints "has the following vulnerable packages" exactly + # when at least one advisory matches; everything else is informational. + if echo "$report" | grep -qi "has the following vulnerable packages"; then + echo "found=true" >> "$GITHUB_OUTPUT" + # Limit Body to high/critical to keep noise out of the PR. + high_only=$(echo "$report" | awk '/Severity: (High|Critical)/{p=1} /^[^ ]/{if(p&&!/^>/){p=0}} p' || true) + if [ -z "$high_only" ]; then + # Fall back to the full report if filtering produced nothing + # (parser changed) — better to over-report than miss findings. + high_only="$report" + fi + echo "$high_only" > /tmp/vuln-body.txt + else + echo "found=false" >> "$GITHUB_OUTPUT" + fi + + - name: Close any existing tracking PR when scan is clean + if: steps.scan.outputs.found == 'false' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: bash + run: | + set -e + pr_number=$(gh pr list --head "${REPORT_BRANCH}" --state open --json number --jq '.[0].number' || true) + if [ -n "${pr_number}" ]; then + gh pr close "${pr_number}" --comment "No vulnerable packages on the latest scheduled scan; closing tracking PR. The branch is preserved so the next regression reopens this same PR rather than spawning a new one." + fi + + - name: Prepare tracking branch + report file + if: steps.scan.outputs.found == 'true' + shell: bash + run: | + set -e + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + # Detach from main so we never accidentally push to it. Reset to a fresh + # tracking branch every run so the PR diff is always "current report vs main". + git fetch origin main + git checkout -B "${REPORT_BRANCH}" origin/main + + mkdir -p "$(dirname "${REPORT_FILE}")" + { + echo "# NuGet Vulnerable Packages — Tracking Report" + echo + echo "Generated: $(date -u +%Y-%m-%dT%H:%M:%SZ)" + echo "Workflow run: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" + echo + echo "Apply fixes by bumping the affected package versions in \`Directory.Packages.props\`." + echo + echo '```' + cat /tmp/vuln-body.txt + echo '```' + } > "${REPORT_FILE}" + + git add "${REPORT_FILE}" + if git diff --cached --quiet; then + echo "::notice::No content change in tracking report; PR (if open) stays as is." + else + git commit -m "security(nuget): refresh vulnerability tracking report" + git push --force-with-lease origin "${REPORT_BRANCH}" + fi + + - name: Open or update tracking PR + if: steps.scan.outputs.found == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: bash + run: | + set -e + existing=$(gh pr list --head "${REPORT_BRANCH}" --state open --json number --jq '.[0].number' || true) + body=$(cat <&1 | tee vuln-report.txt; grep -i "has the following vulnerable packages" vuln-report.txt && exit 1 || exit 0 npm-audit: @@ -45,34 +52,55 @@ jobs: run: working-directory: frontend steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 with: node-version: '20' cache: 'npm' cache-dependency-path: frontend/package-lock.json - run: npm ci - - run: npm audit --audit-level=moderate + - run: npm audit --audit-level=high codeql: name: CodeQL SAST runs-on: ubuntu-latest - timeout-minutes: 20 + timeout-minutes: 30 strategy: fail-fast: false matrix: - language: [csharp, javascript-typescript] + # build-mode is per-language: csharp uses autobuild so CodeQL can run the + # data-flow taint queries that need the compiled IL (the dataflow precision + # buildless mode cannot reach); javascript-typescript stays buildless because + # its extractor does not need a build step. + include: + - language: csharp + build-mode: autobuild + - language: javascript-typescript + build-mode: none steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + # C# autobuild needs the .NET SDK on PATH. Cache so the second build + # (during analyze) reuses the NuGet restore from the first. + - if: matrix.language == 'csharp' + uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5 + with: + dotnet-version: '10.0.x' + cache: true + cache-dependency-path: | + **/*.csproj + Directory.Packages.props + Directory.Build.props + - name: Initialize CodeQL - uses: github/codeql-action/init@f52b05f4acaaa234e44466e66d29050e135ea9ef # v4.36.0 + uses: github/codeql-action/init@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0 with: languages: ${{ matrix.language }} - # Buildless analysis — no .NET/Node build step needed, faster and robust. - build-mode: none + build-mode: ${{ matrix.build-mode }} queries: security-extended + - name: Analyze - uses: github/codeql-action/analyze@f52b05f4acaaa234e44466e66d29050e135ea9ef # v4.36.0 + uses: github/codeql-action/analyze@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0 with: category: "/language:${{ matrix.language }}" @@ -81,9 +109,9 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 15 steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - - name: Trivy misconfiguration scan - uses: aquasecurity/trivy-action@a9c7b0f06e461e9d4b4d1711f154ee024b8d7ab8 # v0.36.0 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Trivy misconfiguration scan (SARIF for Security tab) + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 with: scan-type: config scan-ref: . @@ -91,21 +119,37 @@ jobs: output: trivy-iac.sarif severity: CRITICAL,HIGH,MEDIUM - name: Upload Trivy results - uses: github/codeql-action/upload-sarif@f52b05f4acaaa234e44466e66d29050e135ea9ef # v4.36.0 + uses: github/codeql-action/upload-sarif@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0 with: sarif_file: trivy-iac.sarif category: trivy-iac + - name: Trivy fail-on-high (HIGH + CRITICAL block CI) + # Two-pass: the first run produces SARIF for the Security tab (which + # cannot fail the job), the second blocks the PR on HIGH/CRITICAL. + # MEDIUM is intentionally informational — Trivy is noisy at that level. + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 + with: + scan-type: config + scan-ref: . + format: table + severity: CRITICAL,HIGH + exit-code: 1 sbom: name: SBOM (CycloneDX) runs-on: ubuntu-latest timeout-minutes: 15 steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5 with: - dotnet-version: '9.0.x' + dotnet-version: '10.0.x' + cache: true + cache-dependency-path: | + **/*.csproj + Directory.Packages.props + Directory.Build.props - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 with: @@ -148,8 +192,38 @@ jobs: exit 1 fi - - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + id: sbom-artifact with: name: sbom path: sbom/ retention-days: 90 + + # T3.7 — Sigstore (keyless cosign) SBOM attestation. + # The GitHub OIDC token requested above signs the frontend CycloneDX + # SBOM and registers the attestation on the public Rekor transparency + # log under the commit SHA. Downstream consumers verify with: + # gh attestation verify --owner 4Keyy planora-frontend.cdx.json + # Runs only on `push` so external-fork PRs do not consume an OIDC + # token they cannot use. + # + # The backend-side SBOMs are emitted per-project by the CycloneDX .NET + # tool. Attesting each individual file would require N action + # invocations; the audit's T3.7 line was scoped to "sign SBOMs so the + # supply-chain inventory is verifiable" — the frontend bundle SBOM + # (the only public-facing artefact today) satisfies that. Per-service + # backend attestations land alongside the CD pipeline (issuing one + # attestation per built container image) in a follow-up. + # + # `subject-path` tells the action to compute the SHA-256 of the SBOM + # file itself and use that as the attestation subject. Stronger + # semantics ("attest the built artefact, SBOM as predicate") become + # available once a single bundle exists to digest — for now the SBOM + # *is* the artefact whose integrity we are recording on Rekor. + - name: Attest frontend SBOM (Sigstore keyless) + if: github.event_name == 'push' + uses: actions/attest-sbom@4651f806c01d8637787e274ac3bdf724ef169f34 # v3.0.0 + with: + subject-name: 'planora-frontend-sbom' + subject-path: 'sbom/frontend/planora-frontend.cdx.json' + sbom-path: 'sbom/frontend/planora-frontend.cdx.json' diff --git a/.gitleaksignore b/.gitleaksignore new file mode 100644 index 00000000..6e5afb4e --- /dev/null +++ b/.gitleaksignore @@ -0,0 +1,14 @@ +# gitleaks ignore list — only for fingerprints corresponding to leaks that +# were historically introduced and have since been remediated in a follow-up +# commit. Each line is a `commit:file:rule:start_line` fingerprint. +# +# Add a line here ONLY when the underlying secret has been removed at HEAD +# (so the *current* tree is clean) but the historical commit can no longer +# be rewritten safely (e.g. it has been pushed to a shared branch). + +# T2.5 — RealtimeDbContextFactory.cs originally shipped a `Password=postgres` +# design-time fallback (mirroring the grandfathered pattern in sister +# DbContextFactories). Remediated in commit a213313 by removing the literal +# fallback and throwing `InvalidOperationException` when the connection +# string is not configured. See `docs/secrets-management.md`. +6aa35a47d15c156af4d073ecc9bb5bf0e08d2960:Services/RealtimeApi/Planora.Realtime.Infrastructure/DesignTime/RealtimeDbContextFactory.cs:planora-postgres-connection-string-literal:28 diff --git a/.markdownlint-cli2.jsonc b/.markdownlint-cli2.jsonc index e4002c78..956d242b 100644 --- a/.markdownlint-cli2.jsonc +++ b/.markdownlint-cli2.jsonc @@ -7,6 +7,9 @@ "siblings_only": true }, "MD033": false, + // README opens with a centered HTML hero header (a common, attractive pattern), + // so the first line is a
rather than a top-level heading. + "MD041": false, "MD060": false }, "globs": [ diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 164f05d2..f178ad06 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -4,7 +4,7 @@ This root file is the short architecture summary. The full reference is [`docs/a ## Summary -Planora is a .NET 9 microservice backend with a Next.js 15 frontend: +Planora is a .NET 10 microservice backend with a Next.js 15 frontend: - `frontend/` - browser UI and API client. Next.js 15 App Router; global animated canvas background (`TopologyLayer`) in the root layout; Zustand auth store; Tailwind + Radix UI + Framer Motion. - `Planora.ApiGateway/` - Ocelot ingress, route map, JWT validation, health routing. diff --git a/BuildingBlocks/Planora.BuildingBlocks.Application/Messaging/Events/TaskActivityIntegrationEvent.cs b/BuildingBlocks/Planora.BuildingBlocks.Application/Messaging/Events/TaskActivityIntegrationEvent.cs new file mode 100644 index 00000000..06aca1d9 --- /dev/null +++ b/BuildingBlocks/Planora.BuildingBlocks.Application/Messaging/Events/TaskActivityIntegrationEvent.cs @@ -0,0 +1,38 @@ +namespace Planora.BuildingBlocks.Application.Messaging.Events +{ + /// + /// Stable string discriminators for the kinds of task lifecycle activity that surface as + /// system comments in the Collaboration timeline. Sent on the wire as strings (not an enum + /// ordinal) so the contract stays robust across independent service deployments. + /// + public static class TaskActivityType + { + public const string Completed = "Completed"; + public const string StartedWorking = "StartedWorking"; + public const string Left = "Left"; + } + + /// + /// Raised by TodoApi on task lifecycle transitions (owner completes/starts/stops, worker + /// joins/leaves). Consumed by the Collaboration service to append a system comment to the + /// task's activity timeline. The sentence template lives in the consumer; this event only + /// carries the structured fact plus the actor's display name (freshest at publish time). + /// + public sealed class TaskActivityIntegrationEvent : IntegrationEvent + { + public Guid TaskId { get; init; } + public Guid ActorId { get; init; } + public string ActorName { get; init; } = string.Empty; + + /// One of . + public string ActivityType { get; init; } = string.Empty; + + public TaskActivityIntegrationEvent(Guid taskId, Guid actorId, string actorName, string activityType) + { + TaskId = taskId; + ActorId = actorId; + ActorName = actorName; + ActivityType = activityType; + } + } +} diff --git a/BuildingBlocks/Planora.BuildingBlocks.Application/Messaging/Events/TaskCreatedIntegrationEvent.cs b/BuildingBlocks/Planora.BuildingBlocks.Application/Messaging/Events/TaskCreatedIntegrationEvent.cs new file mode 100644 index 00000000..ecf90dbc --- /dev/null +++ b/BuildingBlocks/Planora.BuildingBlocks.Application/Messaging/Events/TaskCreatedIntegrationEvent.cs @@ -0,0 +1,23 @@ +namespace Planora.BuildingBlocks.Application.Messaging.Events +{ + /// + /// Raised by TodoApi when a task is created. Consumed by the Collaboration service to + /// materialise the task's genesis comment (when a description was provided) and the + /// "{owner} created the task" system comment in the activity timeline ("ветка"). + /// + public sealed class TaskCreatedIntegrationEvent : IntegrationEvent + { + public Guid TaskId { get; init; } + public Guid OwnerId { get; init; } + public string OwnerName { get; init; } = string.Empty; + public string? Description { get; init; } + + public TaskCreatedIntegrationEvent(Guid taskId, Guid ownerId, string ownerName, string? description) + { + TaskId = taskId; + OwnerId = ownerId; + OwnerName = ownerName; + Description = description; + } + } +} diff --git a/BuildingBlocks/Planora.BuildingBlocks.Application/Messaging/Events/TaskDeletedIntegrationEvent.cs b/BuildingBlocks/Planora.BuildingBlocks.Application/Messaging/Events/TaskDeletedIntegrationEvent.cs new file mode 100644 index 00000000..26461d70 --- /dev/null +++ b/BuildingBlocks/Planora.BuildingBlocks.Application/Messaging/Events/TaskDeletedIntegrationEvent.cs @@ -0,0 +1,20 @@ +namespace Planora.BuildingBlocks.Application.Messaging.Events +{ + /// + /// Raised by TodoApi when a task is deleted. Replaces the former in-process cascade + /// (TodoApi used to soft-delete the comment rows in the same transaction). The + /// Collaboration service consumes this and soft-deletes every comment for the task, + /// keeping the activity timeline consistent with task lifetime via eventual consistency. + /// + public sealed class TaskDeletedIntegrationEvent : IntegrationEvent + { + public Guid TaskId { get; init; } + public Guid ActorId { get; init; } + + public TaskDeletedIntegrationEvent(Guid taskId, Guid actorId) + { + TaskId = taskId; + ActorId = actorId; + } + } +} diff --git a/BuildingBlocks/Planora.BuildingBlocks.Application/Planora.BuildingBlocks.Application.csproj b/BuildingBlocks/Planora.BuildingBlocks.Application/Planora.BuildingBlocks.Application.csproj index afec48db..dc617091 100644 --- a/BuildingBlocks/Planora.BuildingBlocks.Application/Planora.BuildingBlocks.Application.csproj +++ b/BuildingBlocks/Planora.BuildingBlocks.Application/Planora.BuildingBlocks.Application.csproj @@ -1,7 +1,7 @@ - net9.0 + net10.0 enable enable latest @@ -12,7 +12,8 @@ - + diff --git a/BuildingBlocks/Planora.BuildingBlocks.Domain/Planora.BuildingBlocks.Domain.csproj b/BuildingBlocks/Planora.BuildingBlocks.Domain/Planora.BuildingBlocks.Domain.csproj index 85011260..e06bbc0a 100644 --- a/BuildingBlocks/Planora.BuildingBlocks.Domain/Planora.BuildingBlocks.Domain.csproj +++ b/BuildingBlocks/Planora.BuildingBlocks.Domain/Planora.BuildingBlocks.Domain.csproj @@ -1,7 +1,7 @@  - net9.0 + net10.0 enable enable latest diff --git a/BuildingBlocks/Planora.BuildingBlocks.Infrastructure/Caching/CacheService.cs b/BuildingBlocks/Planora.BuildingBlocks.Infrastructure/Caching/CacheService.cs index 00a35113..b2ed5266 100644 --- a/BuildingBlocks/Planora.BuildingBlocks.Infrastructure/Caching/CacheService.cs +++ b/BuildingBlocks/Planora.BuildingBlocks.Infrastructure/Caching/CacheService.cs @@ -2,30 +2,50 @@ namespace Planora.BuildingBlocks.Infrastructure.Caching { public sealed class CacheService : ICacheService { + // Matches the StackExchangeRedisCache InstanceName set in + // BuildingBlocks DependencyInjection. The provider prepends this prefix to every + // key it writes, so SCAN must include it in the match pattern. + private const string RedisInstanceName = "planora_"; + + // Bound how many keys we delete per round-trip so a poisoned wildcard does not + // produce a single 50 000-element DEL that blocks the Redis event loop. + private const int UnlinkBatchSize = 500; + + // Defence against an unbounded callsite accidentally emitting per-id prefixes + // and exploding the planora.cache.operations cardinality budget. Anything past + // this length is collapsed to "_long_" so the metric stays useful. + private const int MaxPrefixLength = 48; + private const string PrefixFallback = "_other_"; + private readonly IDistributedCache _distributedCache; private readonly IMemoryCache _memoryCache; private readonly CacheOptions _options; + private readonly StackExchange.Redis.IConnectionMultiplexer? _redis; private readonly ILogger _logger; public CacheService( IDistributedCache distributedCache, IMemoryCache memoryCache, IOptions options, - ILogger logger) + ILogger logger, + StackExchange.Redis.IConnectionMultiplexer? redis = null) { _distributedCache = distributedCache; _memoryCache = memoryCache; _options = options.Value; + _redis = redis; _logger = logger; } public async Task GetAsync(string key, CancellationToken cancellationToken = default) { + var prefix = ExtractPrefix(key); try { if (_options.UseLocalCache && _memoryCache.TryGetValue(key, out T? cachedValue)) { _logger.LogDebug("Cache hit (L1 Memory) for key: {Key}", key); + RecordCacheOperation(prefix, "hit_l1"); return cachedValue; } @@ -33,6 +53,7 @@ public CacheService( if (string.IsNullOrEmpty(cachedData)) { _logger.LogDebug("Cache miss for key: {Key}", key); + RecordCacheOperation(prefix, "miss"); return default; } @@ -49,15 +70,38 @@ public CacheService( } _logger.LogDebug("Cache hit (L2 Redis) for key: {Key}", key); + RecordCacheOperation(prefix, "hit_l2"); return value; } catch (Exception ex) { _logger.LogError(ex, "Error getting cache for key: {Key}", key); + RecordCacheOperation(prefix, "error"); return default; } } + private static void RecordCacheOperation(string prefix, string outcome) + { + Planora.BuildingBlocks.Infrastructure.Observability.PlanoraMetrics.CacheOperations.Add( + 1, + new System.Diagnostics.TagList { { "prefix", prefix }, { "outcome", outcome } }); + } + + // Extract a low-cardinality dimension from the cache key. CacheKeyBuilder produces + // colon-delimited keys like "User:" or "Todo:list:userId:"; the first + // segment is the entity name and is the single useful dimension to partition by. + // Long or empty prefixes collapse to a fallback so the metric stays bounded even + // if a future callsite forgets the convention. + private static string ExtractPrefix(string key) + { + if (string.IsNullOrEmpty(key)) return PrefixFallback; + var colon = key.IndexOf(':'); + var first = colon >= 0 ? key[..colon] : key; + if (first.Length == 0 || first.Length > MaxPrefixLength) return PrefixFallback; + return first; + } + public async Task SetAsync( string key, T value, @@ -115,8 +159,82 @@ public async Task RemoveAsync(string key, CancellationToken cancellationToken = public async Task RemoveByPatternAsync(string pattern, CancellationToken cancellationToken = default) { - _logger.LogWarning("RemoveByPatternAsync not fully implemented - requires Redis SCAN"); - await Task.CompletedTask; + if (string.IsNullOrWhiteSpace(pattern)) + { + return; + } + + // L1 (in-process) cache cannot be enumerated; the only contract we can honour is + // L2 (Redis) invalidation. Consumers must rely on the L1 absolute-expiration window + // (currently 5 minutes) for the in-process layer. + if (_redis is null) + { + _logger.LogWarning( + "RemoveByPatternAsync called for pattern {Pattern} but no IConnectionMultiplexer is registered; skipping Redis SCAN.", + pattern); + return; + } + + var prefixed = pattern.StartsWith(RedisInstanceName, StringComparison.Ordinal) + ? pattern + : RedisInstanceName + pattern; + + try + { + var endpoints = _redis.GetEndPoints(); + var deleted = 0; + + foreach (var endpoint in endpoints) + { + cancellationToken.ThrowIfCancellationRequested(); + var server = _redis.GetServer(endpoint); + + // Only the primary handles writes; replicas refuse UNLINK. Skip non-primary + // endpoints to avoid noisy errors in clusters that expose both. + if (server.IsReplica) + { + continue; + } + + var batch = new List(UnlinkBatchSize); + var database = _redis.GetDatabase(); + + await foreach (var key in server.KeysAsync(pattern: prefixed, pageSize: UnlinkBatchSize).WithCancellation(cancellationToken)) + { + batch.Add(key); + if (batch.Count >= UnlinkBatchSize) + { + deleted += (int)await database.KeyDeleteAsync([.. batch]); + batch.Clear(); + } + } + + if (batch.Count > 0) + { + deleted += (int)await database.KeyDeleteAsync([.. batch]); + } + } + + if (_options.UseLocalCache) + { + // Best-effort: IMemoryCache does not expose its key set, so a pattern-wide + // L1 wipe is impossible. Document the contract: L1 entries naturally expire + // within 5 minutes; callers that need immediate L1 invalidation must call + // RemoveAsync with each concrete key. + } + + _logger.LogInformation( + "Cache pattern-remove for {Pattern}: {Count} keys unlinked.", + pattern, deleted); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error removing cache by pattern: {Pattern}", pattern); + } } } } \ No newline at end of file diff --git a/BuildingBlocks/Planora.BuildingBlocks.Infrastructure/Configuration/SecurityConstants.cs b/BuildingBlocks/Planora.BuildingBlocks.Infrastructure/Configuration/SecurityConstants.cs index db5e7434..1d019f5f 100644 --- a/BuildingBlocks/Planora.BuildingBlocks.Infrastructure/Configuration/SecurityConstants.cs +++ b/BuildingBlocks/Planora.BuildingBlocks.Infrastructure/Configuration/SecurityConstants.cs @@ -121,9 +121,14 @@ public static class SecurityPolicies public const int AccountLockoutMinutes = 30; /// - /// Clock skew tolerance for token validation in seconds - /// - public const int TokenClockSkewSeconds = 5; + /// Clock skew tolerance for token validation in seconds. Single source of truth + /// shared by every JWT bearer wiring point — Auth API, every consumer service, + /// the Gateway, and the TokenService's own validation path. 30 s is chosen to + /// tolerate NTP drift between Fly machines (which can spike to tens of seconds + /// under load) while still keeping the post-expiry replay window an order of + /// magnitude tighter than the JwtBearer default of 5 minutes. + /// + public const int TokenClockSkewSeconds = 30; /// /// Maximum request body size in bytes (5 MB) diff --git a/BuildingBlocks/Planora.BuildingBlocks.Infrastructure/Extensions/JwtAuthenticationExtensions.cs b/BuildingBlocks/Planora.BuildingBlocks.Infrastructure/Extensions/JwtAuthenticationExtensions.cs index 67dd2e6c..9d575c43 100644 --- a/BuildingBlocks/Planora.BuildingBlocks.Infrastructure/Extensions/JwtAuthenticationExtensions.cs +++ b/BuildingBlocks/Planora.BuildingBlocks.Infrastructure/Extensions/JwtAuthenticationExtensions.cs @@ -3,6 +3,7 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.IdentityModel.Tokens; +using Planora.BuildingBlocks.Infrastructure.Configuration; using Planora.BuildingBlocks.Infrastructure.Security; using StackExchange.Redis; @@ -47,9 +48,9 @@ public static IServiceCollection AddJwtAuthenticationForConsumer( ValidIssuer = jwtIssuer, ValidAudience = jwtAudience, IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtSecret)), - // SECURITY: Reduce clock skew to 30 seconds. A 5-minute window allows an attacker - // to replay a just-expired token for up to 5 minutes after it should be invalid. - ClockSkew = TimeSpan.FromSeconds(30) + // SECURITY: single source of truth at SecurityConstants.SecurityPolicies.TokenClockSkewSeconds. + // Tight enough to bound post-expiry replay; loose enough to tolerate NTP drift on Fly machines. + ClockSkew = TimeSpan.FromSeconds(SecurityConstants.SecurityPolicies.TokenClockSkewSeconds) }; options.Events = new JwtBearerEvents diff --git a/BuildingBlocks/Planora.BuildingBlocks.Infrastructure/IdempotentConsumer/IdempotentMessageHandler.cs b/BuildingBlocks/Planora.BuildingBlocks.Infrastructure/IdempotentConsumer/IdempotentMessageHandler.cs index f6051c8a..d1f662fa 100644 --- a/BuildingBlocks/Planora.BuildingBlocks.Infrastructure/IdempotentConsumer/IdempotentMessageHandler.cs +++ b/BuildingBlocks/Planora.BuildingBlocks.Infrastructure/IdempotentConsumer/IdempotentMessageHandler.cs @@ -65,10 +65,13 @@ private static Guid GetEventId(TEvent @event) return guidValue; } - // Fallback: generate deterministic GUID from event content + // Fallback: generate deterministic GUID from event content. SHA256 truncated to + // 16 bytes — MD5 is fast but cryptographically broken and trips static analyzers + // (CA5351); SHA256 has the same determinism property without the audit-tooling + // friction. The truncation is acceptable because the GUID is used only as an + // idempotency key in the inbox table, not as a security primitive. var json = System.Text.Json.JsonSerializer.Serialize(@event); - using var md5 = System.Security.Cryptography.MD5.Create(); - var hash = md5.ComputeHash(System.Text.Encoding.UTF8.GetBytes(json)); - return new Guid(hash); + var fullHash = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(json)); + return new Guid(fullHash.AsSpan(0, 16)); } } diff --git a/BuildingBlocks/Planora.BuildingBlocks.Infrastructure/Inbox/InboxMessage.cs b/BuildingBlocks/Planora.BuildingBlocks.Infrastructure/Inbox/InboxMessage.cs index b4afcf0f..f3015c47 100644 --- a/BuildingBlocks/Planora.BuildingBlocks.Infrastructure/Inbox/InboxMessage.cs +++ b/BuildingBlocks/Planora.BuildingBlocks.Infrastructure/Inbox/InboxMessage.cs @@ -22,6 +22,22 @@ public InboxMessage(string messageId, string type, string content, DateTime rece Status = InboxMessageStatus.Pending; } + /// + /// Creates an inbox record whose primary key IS the integration event's Id, so a duplicate + /// delivery of the same event is detected by a simple PK existence check + /// (). Used by the event bus for consumer + /// idempotency (dedup keyed on the stable, broker-propagated event id). + /// + public InboxMessage(Guid eventId, string type, string content, DateTime receivedOn) + : base(eventId) + { + MessageId = eventId.ToString(); + Type = type; + Content = content; + ReceivedOn = receivedOn; + Status = InboxMessageStatus.Pending; + } + public void MarkAsProcessing() { Status = InboxMessageStatus.Processing; diff --git a/BuildingBlocks/Planora.BuildingBlocks.Infrastructure/Logging/TelemetryConfiguration.cs b/BuildingBlocks/Planora.BuildingBlocks.Infrastructure/Logging/TelemetryConfiguration.cs index 3bfc7a7e..4c5f39d1 100644 --- a/BuildingBlocks/Planora.BuildingBlocks.Infrastructure/Logging/TelemetryConfiguration.cs +++ b/BuildingBlocks/Planora.BuildingBlocks.Infrastructure/Logging/TelemetryConfiguration.cs @@ -38,9 +38,10 @@ namespace Planora.BuildingBlocks.Infrastructure.Logging; /// same wildcard match. /// /// -/// SECURITY: SetDbStatementForText is enabled on EF Core instrumentation. SQL text in -/// span attributes may contain PII through parameter values. Restrict trace-backend access -/// accordingly, or set OpenTelemetry:Tracing:CaptureDbStatementText=false to disable. +/// SECURITY: SetDbStatementForText is DISABLED by default on EF Core instrumentation — +/// SQL text in span attributes may contain PII through parameter values. Opt in by setting +/// OpenTelemetry:Tracing:CaptureDbStatementText=true in development or staging +/// environments where trace-backend access is restricted and PII risk is acceptable. /// /// public static class TelemetryConfiguration @@ -67,7 +68,9 @@ public static IServiceCollection AddPlanoraTelemetry( var consoleEnabled = section.GetValue("ConsoleExporter:Enabled"); var tracingEnabled = section.GetValue("Tracing:Enabled") ?? true; var metricsEnabled = section.GetValue("Metrics:Enabled") ?? true; - var captureDbText = section.GetValue("Tracing:CaptureDbStatementText") ?? true; + // SECURITY: default-off. SQL text in spans leaks parameter values (potential PII). + // Opt in per-environment via OpenTelemetry:Tracing:CaptureDbStatementText=true. + var captureDbText = section.GetValue("Tracing:CaptureDbStatementText") ?? false; var environmentName = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? "Production"; var otelBuilder = services.AddOpenTelemetry() diff --git a/BuildingBlocks/Planora.BuildingBlocks.Infrastructure/Messaging/RabbitMqEventBus.cs b/BuildingBlocks/Planora.BuildingBlocks.Infrastructure/Messaging/RabbitMqEventBus.cs index 3cca34a0..3fe0e895 100644 --- a/BuildingBlocks/Planora.BuildingBlocks.Infrastructure/Messaging/RabbitMqEventBus.cs +++ b/BuildingBlocks/Planora.BuildingBlocks.Infrastructure/Messaging/RabbitMqEventBus.cs @@ -47,7 +47,27 @@ public async Task PublishAsync(TEvent @event, CancellationToken cancella { var eventName = @event.GetType().Name; var connection = await _connectionManager.GetConnectionAsync(cancellationToken); - var channel = await connection.CreateChannelAsync(); + + // SECURITY / RELIABILITY (T4.7): publisher confirms + mandatory flag. + // + // publisherConfirmationsEnabled: broker MUST ack every publish. + // publisherConfirmationTrackingEnabled: BasicPublishAsync awaits the ack + // and throws PublishException on nack. + // mandatory: true on BasicPublishAsync: an unroutable message (no queue + // bound to the matching routing key) + // returns and surfaces as a publish + // failure instead of silently + // disappearing. + // + // The combination guarantees that PublishAsync's task completion == + // broker durability commitment for the message. The outer Outbox processor + // depends on this guarantee: if PublishAsync returns success, the outbox + // row is safe to mark Processed; if it throws, the row stays Pending and + // the message is retried per the OutboxMessage state machine (INV-COMM-3a). + var channelOpts = new CreateChannelOptions( + publisherConfirmationsEnabled: true, + publisherConfirmationTrackingEnabled: true); + var channel = await connection.CreateChannelAsync(channelOpts, cancellationToken); try { @@ -65,9 +85,15 @@ public async Task PublishAsync(TEvent @event, CancellationToken cancella Type = eventName }; - await channel.BasicPublishAsync(ExchangeName, eventName, false, properties, body, cancellationToken); + await channel.BasicPublishAsync( + exchange: ExchangeName, + routingKey: eventName, + mandatory: true, + basicProperties: properties, + body: body, + cancellationToken: cancellationToken); - _logger.LogInformation("Published event {EventName} with ID {EventId}", eventName, @event.Id); + _logger.LogInformation("Published event {EventName} with ID {EventId} (broker confirmed)", eventName, @event.Id); } catch (Exception ex) { @@ -183,12 +209,57 @@ private async Task ProcessEventAsync(IChannel channel, string eventName, BasicDe var @event = JsonSerializer.Deserialize(message, eventType); - if (@event != null) + if (@event == null) + continue; + + // Consumer idempotency (INV-COMM-4). Delivery is at-least-once (we nack+requeue on + // failure), so a redelivered or restart-replayed event must not run the handler + // twice. We dedup on the stable event Id via the service's inbox. This is graceful + // and defensive: if the service registers no IInboxRepository (or it errors), we + // simply process without dedup — exactly the previous behaviour, never worse. + var inbox = scope.ServiceProvider.GetService(); + var eventId = (@event as IntegrationEvent)?.Id ?? Guid.Empty; + + if (inbox != null && eventId != Guid.Empty) + { + try + { + if (await inbox.ExistsAsync(eventId, CancellationToken.None)) + { + _logger.LogInformation( + "Skipping already-processed event {EventName} {EventId} for handler {Handler}", + eventName, eventId, handlerType.Name); + continue; + } + } + catch (Exception dedupEx) + { + _logger.LogWarning(dedupEx, + "Inbox dedup check failed for {EventName}; processing without dedup", eventName); + inbox = null; + } + } + + var handleMethod = handlerType.GetMethod("HandleAsync"); + var result = handleMethod?.Invoke(handler, new[] { @event, CancellationToken.None }); + if (result is Task t) + await t; + + if (inbox != null && eventId != Guid.Empty) { - var handleMethod = handlerType.GetMethod("HandleAsync"); - var result = handleMethod?.Invoke(handler, new[] { @event, CancellationToken.None }); - if (result is Task t) - await t; + try + { + var record = new InboxMessage(eventId, eventName, message, DateTime.UtcNow); + record.MarkAsProcessed(); + await inbox.AddAsync(record, CancellationToken.None); + } + catch (Exception recordEx) + { + // Recording is best-effort: a failure here only risks reprocessing on a + // later redelivery, never data loss or a broken consume loop. + _logger.LogWarning(recordEx, + "Failed to record processed event {EventName} {EventId} in inbox", eventName, eventId); + } } } diff --git a/BuildingBlocks/Planora.BuildingBlocks.Infrastructure/Observability/PlanoraMetrics.cs b/BuildingBlocks/Planora.BuildingBlocks.Infrastructure/Observability/PlanoraMetrics.cs index d30c17b5..94c73783 100644 --- a/BuildingBlocks/Planora.BuildingBlocks.Infrastructure/Observability/PlanoraMetrics.cs +++ b/BuildingBlocks/Planora.BuildingBlocks.Infrastructure/Observability/PlanoraMetrics.cs @@ -90,4 +90,21 @@ public static class PlanoraMetrics name: "planora.avatar.variant.bytes", unit: "By", description: "Re-encoded WebP variant size in bytes, per variant tier."); + + /// + /// Counter incremented on every cache GetAsync call. Tags: + /// prefix — first colon-delimited segment of the cache key + /// (entity name when callers use CacheKeyBuilder.ForEntity); + /// outcome ∈ {hit_l1, hit_l2, miss, error}. + /// Hit ratio is derived in the metrics back-end: + /// sum(rate(planora_cache_operations_total{outcome=~"hit_.*"}[5m])) / + /// sum(rate(planora_cache_operations_total[5m])) by (prefix). + /// Cardinality is bounded by the set of entity prefixes the codebase emits + /// (low double-digits); a cap is enforced in CacheService to defend + /// against an unbounded callsite leaking arbitrary segments. + /// + public static readonly Counter CacheOperations = Meter.CreateCounter( + name: "planora.cache.operations", + unit: "{operation}", + description: "Cache get operations, partitioned by key prefix and outcome (hit_l1 / hit_l2 / miss / error)."); } diff --git a/BuildingBlocks/Planora.BuildingBlocks.Infrastructure/Outbox/OutboxNotifyInterceptor.cs b/BuildingBlocks/Planora.BuildingBlocks.Infrastructure/Outbox/OutboxNotifyInterceptor.cs new file mode 100644 index 00000000..f40b7cc1 --- /dev/null +++ b/BuildingBlocks/Planora.BuildingBlocks.Infrastructure/Outbox/OutboxNotifyInterceptor.cs @@ -0,0 +1,73 @@ +using Microsoft.EntityFrameworkCore.Diagnostics; + +namespace Planora.BuildingBlocks.Infrastructure.Outbox +{ + /// + /// Pulses the immediately after a transaction that inserted one or + /// more rows commits, so the dispatches + /// them without waiting out the idle poll interval. + /// + /// The "did this save insert an outbox row?" decision is captured in SavingChanges (where + /// the entries are still ) and acted on in SavedChanges + /// (after the implicit transaction has committed, so the rows are visible to the processor's + /// query). Registered scoped — one instance per DbContext scope — so the captured flag is + /// never shared across concurrent requests. + /// + public sealed class OutboxNotifyInterceptor : SaveChangesInterceptor + { + private readonly OutboxSignal _signal; + private bool _pendingOutboxInsert; + + public OutboxNotifyInterceptor(OutboxSignal signal) => _signal = signal; + + public override InterceptionResult SavingChanges( + DbContextEventData eventData, InterceptionResult result) + { + _pendingOutboxInsert = HasOutboxInsert(eventData.Context); + return base.SavingChanges(eventData, result); + } + + public override ValueTask> SavingChangesAsync( + DbContextEventData eventData, InterceptionResult result, CancellationToken cancellationToken = default) + { + _pendingOutboxInsert = HasOutboxInsert(eventData.Context); + return base.SavingChangesAsync(eventData, result, cancellationToken); + } + + public override int SavedChanges(SaveChangesCompletedEventData eventData, int result) + { + FlushSignal(); + return base.SavedChanges(eventData, result); + } + + public override ValueTask SavedChangesAsync( + SaveChangesCompletedEventData eventData, int result, CancellationToken cancellationToken = default) + { + FlushSignal(); + return base.SavedChangesAsync(eventData, result, cancellationToken); + } + + public override void SaveChangesFailed(DbContextErrorEventData eventData) + { + _pendingOutboxInsert = false; + base.SaveChangesFailed(eventData); + } + + public override Task SaveChangesFailedAsync(DbContextErrorEventData eventData, CancellationToken cancellationToken = default) + { + _pendingOutboxInsert = false; + return base.SaveChangesFailedAsync(eventData, cancellationToken); + } + + private void FlushSignal() + { + if (!_pendingOutboxInsert) return; + _pendingOutboxInsert = false; + _signal.Notify(); + } + + private static bool HasOutboxInsert(DbContext? context) => + context is not null && + context.ChangeTracker.Entries().Any(e => e.State == EntityState.Added); + } +} diff --git a/BuildingBlocks/Planora.BuildingBlocks.Infrastructure/Outbox/OutboxProcessor.cs b/BuildingBlocks/Planora.BuildingBlocks.Infrastructure/Outbox/OutboxProcessor.cs index 5655d1a1..6cd148ed 100644 --- a/BuildingBlocks/Planora.BuildingBlocks.Infrastructure/Outbox/OutboxProcessor.cs +++ b/BuildingBlocks/Planora.BuildingBlocks.Infrastructure/Outbox/OutboxProcessor.cs @@ -7,7 +7,17 @@ public sealed class OutboxProcessor : BackgroundService { private readonly IServiceProvider _serviceProvider; private readonly ILogger _logger; - private readonly TimeSpan _interval = TimeSpan.FromSeconds(30); + // Idle poll cadence — the safety net. The fast path is event-driven: an OutboxSignal + // pulse (raised by OutboxNotifyInterceptor the instant an outbox row commits) wakes the + // loop in milliseconds, so a freshly produced task-lifecycle event reaches its + // Collaboration "ветка" system comment almost immediately rather than after a poll tick. + // When no signal is registered (services that opted out) the loop falls back to pure + // polling at this cadence. Kept short so the indexed Take(BatchSize) query stays cheap. + private readonly TimeSpan _interval = TimeSpan.FromSeconds(5); + private const int BatchSize = 20; + + // Optional: present in services that wired immediate dispatch. Null elsewhere → pure poll. + private readonly OutboxSignal? _signal; public OutboxProcessor( IServiceProvider serviceProvider, @@ -15,30 +25,42 @@ public OutboxProcessor( { _serviceProvider = serviceProvider; _logger = logger; + _signal = serviceProvider.GetService(); } protected override async Task ExecuteAsync(CancellationToken stoppingToken) { - _logger.LogInformation("Outbox Processor started"); + _logger.LogInformation( + "Outbox Processor started ({Mode})", + _signal is null ? "poll" : "signal + poll fallback"); while (!stoppingToken.IsCancellationRequested) { + int processed = 0; try { - await ProcessOutboxMessagesAsync(stoppingToken); + processed = await ProcessOutboxMessagesAsync(stoppingToken); } catch (Exception ex) { _logger.LogError(ex, "Error processing outbox messages"); } - await Task.Delay(_interval, stoppingToken); + // A full batch likely means more rows are waiting — drain immediately + // without idling so a burst clears in one tight loop. + if (processed >= BatchSize) + continue; + + if (_signal is not null) + await _signal.WaitAsync(_interval, stoppingToken); + else + await Task.Delay(_interval, stoppingToken); } _logger.LogInformation("Outbox Processor stopped"); } - private async Task ProcessOutboxMessagesAsync(CancellationToken cancellationToken) + private async Task ProcessOutboxMessagesAsync(CancellationToken cancellationToken) { using var scope = _serviceProvider.CreateScope(); @@ -46,7 +68,7 @@ private async Task ProcessOutboxMessagesAsync(CancellationToken cancellationToke if (dbContext == null) { _logger.LogWarning("DbContext not found in DI container"); - return; + return 0; } var eventBus = scope.ServiceProvider.GetRequiredService(); @@ -59,7 +81,7 @@ private async Task ProcessOutboxMessagesAsync(CancellationToken cancellationToke .Where(m => m.Status == OutboxMessageStatus.Pending || (m.Status == OutboxMessageStatus.Failed && m.NextRetryUtc <= DateTime.UtcNow)) .OrderBy(m => m.OccurredOnUtc) - .Take(20) + .Take(BatchSize) .ToListAsync(cancellationToken); foreach (var message in messages) @@ -141,6 +163,8 @@ private async Task ProcessOutboxMessagesAsync(CancellationToken cancellationToke batchStopwatch.Stop(); PlanoraMetrics.OutboxBatchDuration.Record(batchStopwatch.Elapsed.TotalSeconds); + + return messages.Count; } } } diff --git a/BuildingBlocks/Planora.BuildingBlocks.Infrastructure/Outbox/OutboxSignal.cs b/BuildingBlocks/Planora.BuildingBlocks.Infrastructure/Outbox/OutboxSignal.cs new file mode 100644 index 00000000..c27619c8 --- /dev/null +++ b/BuildingBlocks/Planora.BuildingBlocks.Infrastructure/Outbox/OutboxSignal.cs @@ -0,0 +1,48 @@ +namespace Planora.BuildingBlocks.Infrastructure.Outbox +{ + /// + /// In-process wake signal between the producers that write to the outbox and the + /// that dispatches it. When a command handler commits a new + /// outbox message, releases the processor from its idle wait so the + /// message is published in milliseconds instead of waiting out the poll interval. + /// + /// The periodic poll remains as a safety net (a process that produced a row then crashed + /// before signalling, or a row written by a different process/instance, is still picked up), + /// so this is a pure latency optimisation with no correctness dependency — losing a signal + /// only falls back to the existing poll cadence. + /// + /// Registered as a singleton; the producer (a SaveChanges interceptor) and the single + /// hosted processor live in the same process, so a lightweight semaphore is all that is + /// needed. Coalescing is intentional: many writes between two processor passes collapse into + /// at most one pending release, because one pass drains every pending row via its batch query. + /// + public sealed class OutboxSignal + { + // Capacity 1: at most one pending wake is buffered. Extra notifies while one is already + // pending are dropped (the next pass will see all rows anyway), preventing unbounded growth. + private readonly SemaphoreSlim _semaphore = new(0, 1); + + /// Wake the processor now (or arm the next wait if it is mid-pass). + public void Notify() + { + try { _semaphore.Release(); } + catch (SemaphoreFullException) { /* a wake is already pending — coalesce */ } + } + + /// + /// Wait until is called or elapses + /// (whichever comes first). The timeout preserves the periodic safety-net poll. + /// + public async Task WaitAsync(TimeSpan timeout, CancellationToken cancellationToken) + { + try + { + await _semaphore.WaitAsync(timeout, cancellationToken); + } + catch (OperationCanceledException) + { + // Shutdown — let the processor loop observe cancellation and exit. + } + } + } +} diff --git a/BuildingBlocks/Planora.BuildingBlocks.Infrastructure/Persistence/BaseRepository.cs b/BuildingBlocks/Planora.BuildingBlocks.Infrastructure/Persistence/BaseRepository.cs index bbc2d95c..e438f708 100644 --- a/BuildingBlocks/Planora.BuildingBlocks.Infrastructure/Persistence/BaseRepository.cs +++ b/BuildingBlocks/Planora.BuildingBlocks.Infrastructure/Persistence/BaseRepository.cs @@ -4,6 +4,21 @@ namespace Planora.BuildingBlocks.Infrastructure.Persistence { + /// + /// Canonical repository base. Single source of truth for soft-delete handling, + /// AsNoTracking discipline on read queries, pagination, and specification dispatch. + /// Service-side per-context adapters (Auth/Messaging) wrap this with a thin + /// concrete-typed shim only to expose the service's own DbContext to subclasses. + /// + /// + /// Soft-delete strategy: + /// - This base applies an explicit !IsDeleted predicate on every read. + /// - Services that ALSO configure HasQueryFilter (Auth) get redundant + /// filtering — harmless because the SQL optimiser collapses it. + /// - Services without HasQueryFilter (Todo) rely solely on this predicate. + /// - intentionally does not use AsNoTracking so + /// the returned entity is trackable for subsequent mutations. + /// public abstract class BaseRepository : IRepository where TEntity : BaseEntity where TContext : DbContext @@ -25,34 +40,37 @@ protected BaseRepository(TContext context) // FirstOrDefaultAsync always goes to the store and works correctly everywhere. // The !IsDeleted predicate keeps GetByIdAsync consistent with every other query // method on this base — a soft-deleted entity must never be returned by id. + // No AsNoTracking: callers typically chain Update on the result. var guidId = (Guid)(object)id!; return await DbSet.FirstOrDefaultAsync(e => e.Id == guidId && !e.IsDeleted, cancellationToken); } public virtual async Task> GetAllAsync(CancellationToken cancellationToken = default) { - return await DbSet.Where(e => !e.IsDeleted).ToListAsync(cancellationToken); + // INV-DATA-3: AsNoTracking on read queries. Mutation flows must use + // GetByIdAsync (tracking) or a service-side query that opts back into tracking. + return await DbSet.AsNoTracking().Where(e => !e.IsDeleted).ToListAsync(cancellationToken); } public virtual async Task> FindAsync( Expression> predicate, CancellationToken cancellationToken = default) { - return await DbSet.Where(predicate).Where(e => !e.IsDeleted).ToListAsync(cancellationToken); + return await DbSet.AsNoTracking().Where(predicate).Where(e => !e.IsDeleted).ToListAsync(cancellationToken); } public virtual async Task FindFirstAsync( Expression> predicate, CancellationToken cancellationToken = default) { - return await DbSet.Where(predicate).Where(e => !e.IsDeleted).FirstOrDefaultAsync(cancellationToken); + return await DbSet.AsNoTracking().Where(predicate).Where(e => !e.IsDeleted).FirstOrDefaultAsync(cancellationToken); } public virtual async Task ExistsAsync( Expression> predicate, CancellationToken cancellationToken = default) { - return await DbSet.Where(e => !e.IsDeleted).AnyAsync(predicate, cancellationToken); + return await DbSet.AsNoTracking().Where(e => !e.IsDeleted).AnyAsync(predicate, cancellationToken); } public virtual async Task CountAsync( @@ -60,9 +78,9 @@ public virtual async Task CountAsync( CancellationToken cancellationToken = default) { if (predicate == null) - return await DbSet.Where(e => !e.IsDeleted).CountAsync(cancellationToken); + return await DbSet.AsNoTracking().Where(e => !e.IsDeleted).CountAsync(cancellationToken); - return await DbSet.Where(e => !e.IsDeleted).CountAsync(predicate, cancellationToken); + return await DbSet.AsNoTracking().Where(e => !e.IsDeleted).CountAsync(predicate, cancellationToken); } public virtual async Task AddAsync(TEntity entity, CancellationToken cancellationToken = default) @@ -110,7 +128,7 @@ public virtual void RemoveRange(IEnumerable entities) CancellationToken cancellationToken = default) { var (safePageNumber, safePageSize) = PaginationParameters.Normalize(pageNumber, pageSize); - var query = DbSet.Where(e => !e.IsDeleted).AsQueryable(); + var query = DbSet.AsNoTracking().Where(e => !e.IsDeleted).AsQueryable(); if (predicate != null) query = query.Where(predicate); diff --git a/BuildingBlocks/Planora.BuildingBlocks.Infrastructure/Persistence/N1Sentinel.cs b/BuildingBlocks/Planora.BuildingBlocks.Infrastructure/Persistence/N1Sentinel.cs new file mode 100644 index 00000000..17102d87 --- /dev/null +++ b/BuildingBlocks/Planora.BuildingBlocks.Infrastructure/Persistence/N1Sentinel.cs @@ -0,0 +1,257 @@ +using System.Collections.Concurrent; +using System.Data.Common; +using Microsoft.EntityFrameworkCore.Diagnostics; + +namespace Planora.BuildingBlocks.Infrastructure.Persistence; + +/// +/// EF Core command interceptor that fingerprints every SQL command issued within +/// a logical scope and surfaces (or fails) when the same fingerprint executes +/// more than the configured threshold within that scope. The canonical N+1 +/// regression pattern. +/// +/// +/// Scope is the AsyncLocal "session" started by . Tests +/// wrap the integration call under test in a using-block; production code never +/// begins a scope, so the interceptor is a no-op outside the test factory and +/// has zero runtime impact when not enabled. +/// +/// Fingerprint = normalised SQL text (parameters stripped, whitespace +/// collapsed). Two reads of SELECT * FROM Users WHERE Id = $1 with +/// different Ids collapse to the same fingerprint — that's the whole +/// point: an N+1 emits the same shape N times. +/// +/// Whitelist entries are SQL substrings; if the normalised command text +/// contains any whitelisted substring, repeats of that fingerprint do not +/// count toward the threshold. Use for legitimately repeated reads (e.g. a +/// foreach loop the author knows is correct). +/// +public sealed class N1SentinelInterceptor : DbCommandInterceptor +{ + private static readonly AsyncLocal Current = new(); + + /// + /// Begin a new sentinel scope. Disposing the returned handle restores + /// the previous (usually null) scope and asserts the threshold. + /// + /// Maximum allowed repeats of the same SQL fingerprint + /// within this scope. Anything past the threshold triggers . + /// Callback invoked on Dispose if a violation was + /// observed. Throws by default; tests can + /// substitute a collector instead. + /// Case-insensitive SQL substrings that exempt + /// matching fingerprints from the count. + public static IDisposable BeginScope( + int threshold = 5, + Action>? onViolation = null, + IReadOnlyCollection? whitelist = null) + { + var previous = Current.Value; + var scope = new Scope(threshold, onViolation ?? DefaultOnViolation, whitelist ?? Array.Empty()); + Current.Value = scope; + return new ScopeHandle(scope, previous); + } + + private static void DefaultOnViolation(IReadOnlyList violations) + { + var summary = string.Join("; ", violations.Select(v => $"{v.RepeatCount}× {Trim(v.Fingerprint)}")); + throw new N1SentinelException($"N+1 query pattern detected: {summary}"); + } + + private static string Trim(string text) => text.Length <= 100 ? text : text[..100] + "…"; + + public override InterceptionResult ReaderExecuting( + DbCommand command, + CommandEventData eventData, + InterceptionResult result) + { + Record(command.CommandText); + return base.ReaderExecuting(command, eventData, result); + } + + public override ValueTask> ReaderExecutingAsync( + DbCommand command, + CommandEventData eventData, + InterceptionResult result, + CancellationToken cancellationToken = default) + { + Record(command.CommandText); + return base.ReaderExecutingAsync(command, eventData, result, cancellationToken); + } + + public override InterceptionResult NonQueryExecuting( + DbCommand command, + CommandEventData eventData, + InterceptionResult result) + { + Record(command.CommandText); + return base.NonQueryExecuting(command, eventData, result); + } + + public override ValueTask> NonQueryExecutingAsync( + DbCommand command, + CommandEventData eventData, + InterceptionResult result, + CancellationToken cancellationToken = default) + { + Record(command.CommandText); + return base.NonQueryExecutingAsync(command, eventData, result, cancellationToken); + } + + public override InterceptionResult ScalarExecuting( + DbCommand command, + CommandEventData eventData, + InterceptionResult result) + { + Record(command.CommandText); + return base.ScalarExecuting(command, eventData, result); + } + + public override ValueTask> ScalarExecutingAsync( + DbCommand command, + CommandEventData eventData, + InterceptionResult result, + CancellationToken cancellationToken = default) + { + Record(command.CommandText); + return base.ScalarExecutingAsync(command, eventData, result, cancellationToken); + } + + /// + /// Records one command for sentinel accounting. Exposed for direct testing — + /// production code goes through the EF Core interceptor entry points above, + /// which call this internally. + /// + public static void RecordCommand(string commandText) + { + var scope = Current.Value; + if (scope is null) return; + scope.Record(Fingerprint(commandText)); + } + + private static void Record(string commandText) => RecordCommand(commandText); + + /// + /// Normalise the SQL: strip $N / @p? parameter placeholders so EF Core's + /// per-row parameterisation doesn't make every call look unique, and + /// collapse whitespace runs so trivial formatting differences don't either. + /// Exposed publicly so the regression tests in `N1SentinelTests` can pin + /// the fingerprinting contract without resorting to `InternalsVisibleTo`. + /// + public static string Fingerprint(string sql) + { + if (string.IsNullOrWhiteSpace(sql)) return string.Empty; + var span = sql.AsSpan(); + var sb = new System.Text.StringBuilder(sql.Length); + var inWhitespace = false; + for (int i = 0; i < span.Length; i++) + { + var c = span[i]; + if (c == '$' || c == '@') + { + sb.Append('?'); + while (i + 1 < span.Length && (char.IsLetterOrDigit(span[i + 1]) || span[i + 1] == '_')) + { + i++; + } + inWhitespace = false; + continue; + } + if (char.IsWhiteSpace(c)) + { + if (inWhitespace) continue; + sb.Append(' '); + inWhitespace = true; + continue; + } + sb.Append(c); + inWhitespace = false; + } + return sb.ToString().Trim(); + } + + private sealed class Scope + { + private readonly int _threshold; + private readonly Action> _onViolation; + private readonly IReadOnlyCollection _whitelist; + private readonly ConcurrentDictionary _counts = new(); + + public Scope(int threshold, Action> onViolation, IReadOnlyCollection whitelist) + { + _threshold = threshold; + _onViolation = onViolation; + _whitelist = whitelist; + } + + public void Record(string fingerprint) + { + if (string.IsNullOrEmpty(fingerprint)) return; + if (IsWhitelisted(fingerprint)) return; + _counts.AddOrUpdate(fingerprint, 1, (_, c) => c + 1); + } + + public IReadOnlyList Drain() + { + return _counts + .Where(kv => kv.Value > _threshold) + .Select(kv => new N1Violation(kv.Key, kv.Value)) + .ToList(); + } + + public void RaiseIfViolated() + { + var violations = Drain(); + if (violations.Count > 0) + { + _onViolation(violations); + } + } + + private bool IsWhitelisted(string fingerprint) + { + foreach (var pattern in _whitelist) + { + if (fingerprint.Contains(pattern, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + return false; + } + } + + private sealed class ScopeHandle : IDisposable + { + private readonly Scope _scope; + private readonly Scope? _previous; + private bool _disposed; + + public ScopeHandle(Scope scope, Scope? previous) + { + _scope = scope; + _previous = previous; + } + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + try + { + _scope.RaiseIfViolated(); + } + finally + { + Current.Value = _previous; + } + } + } +} + +public sealed record N1Violation(string Fingerprint, int RepeatCount); + +public sealed class N1SentinelException : Exception +{ + public N1SentinelException(string message) : base(message) { } +} diff --git a/BuildingBlocks/Planora.BuildingBlocks.Infrastructure/Persistence/OutboxRepository.cs b/BuildingBlocks/Planora.BuildingBlocks.Infrastructure/Persistence/OutboxRepository.cs new file mode 100644 index 00000000..1ef9f7be --- /dev/null +++ b/BuildingBlocks/Planora.BuildingBlocks.Infrastructure/Persistence/OutboxRepository.cs @@ -0,0 +1,64 @@ +namespace Planora.BuildingBlocks.Infrastructure.Persistence; + +/// +/// Canonical outbox repository. Picks up +/// rows plus retry-eligible rows whose +/// NextRetryUtc has elapsed. Terminal +/// rows are never picked up — they require operator action (see INV-COMM-3a). +/// +/// +/// Service-side per-DbContext implementations are [Obsolete] adapters kept +/// for one release to ease the consolidation. New wirings should register +/// OutboxRepository<TContext> directly: +/// +/// services.AddScoped<IOutboxRepository, OutboxRepository<CategoryDbContext>>(); +/// +/// +public sealed class OutboxRepository : IOutboxRepository + where TContext : DbContext +{ + private readonly TContext _context; + + public OutboxRepository(TContext context) + { + _context = context ?? throw new ArgumentNullException(nameof(context)); + } + + public async Task AddAsync(OutboxMessage message, CancellationToken cancellationToken = default) + { + await _context.Set().AddAsync(message, cancellationToken); + await _context.SaveChangesAsync(cancellationToken); + } + + public async Task> GetPendingMessagesAsync( + int batchSize, + CancellationToken cancellationToken = default) + { + // Capture "now" once so the predicate is deterministic for the duration of + // the query — EF Core does not necessarily inline DateTime.UtcNow as a + // server-side function across providers. + var now = DateTime.UtcNow; + return await _context.Set() + .Where(m => m.Status == OutboxMessageStatus.Pending || + (m.Status == OutboxMessageStatus.Failed && m.NextRetryUtc <= now)) + .OrderBy(m => m.OccurredOnUtc) + .Take(batchSize) + .ToListAsync(cancellationToken); + } + + public async Task UpdateAsync(OutboxMessage message, CancellationToken cancellationToken = default) + { + _context.Set().Update(message); + await _context.SaveChangesAsync(cancellationToken); + } + + public async Task DeleteProcessedMessagesAsync(DateTime olderThan, CancellationToken cancellationToken = default) + { + var messagesToDelete = await _context.Set() + .Where(m => m.Status == OutboxMessageStatus.Processed && m.ProcessedOnUtc < olderThan) + .ToListAsync(cancellationToken); + + _context.Set().RemoveRange(messagesToDelete); + await _context.SaveChangesAsync(cancellationToken); + } +} diff --git a/BuildingBlocks/Planora.BuildingBlocks.Infrastructure/Planora.BuildingBlocks.Infrastructure.csproj b/BuildingBlocks/Planora.BuildingBlocks.Infrastructure/Planora.BuildingBlocks.Infrastructure.csproj index 5a850ff2..eda43eb2 100644 --- a/BuildingBlocks/Planora.BuildingBlocks.Infrastructure/Planora.BuildingBlocks.Infrastructure.csproj +++ b/BuildingBlocks/Planora.BuildingBlocks.Infrastructure/Planora.BuildingBlocks.Infrastructure.csproj @@ -1,7 +1,7 @@ - net9.0 + net10.0 enable enable latest @@ -15,7 +15,8 @@ - + diff --git a/CHANGELOG.md b/CHANGELOG.md index f9d2b45d..beb7aecc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,731 @@ All notable changes to Planora are documented here. Format follows [Keep a Chang ## [Unreleased] +### fix(ci) — green markdownlint + restore branch-coverage threshold (2026-06-02) + +- Converted every remaining `*`-style list bullet in `CHANGELOG.md` to `-` (272 MD004 violations). + The markdownlint CI job lints all root `*.md`, so the historical asterisk bullets were failing it; + the whole tree now lints with **0 errors**. +- Added `frontend/src/test/components/quick-filter-bar.test.tsx` covering the new `QuickFilterBar` + (idle vs active states, single/plural summary, the +N chip overflow, the icon/colour-fallback chip + branches, and the open/clear callbacks). The component had shipped without tests and dragged global + **branch coverage to 84.75%**, below the 85% gate; it is now **85.64%** and the suite is 374 green. + +### build — drop redundant framework packages (NU1510), clean `-warnaserror` build (2026-06-01) + +Removed three `PackageReference`s that the .NET 10 SDK flags as redundant via **NU1510** (they are +already provided by the shared framework / transitively): `Microsoft.Extensions.Caching.Abstractions` +(BuildingBlocks.Infrastructure), `Microsoft.Extensions.Logging.Abstractions` (BuildingBlocks.Application, +which already has a `Microsoft.AspNetCore.App` framework reference), and +`Microsoft.Extensions.Diagnostics.HealthChecks` (Planora.ApiGateway). The solution now builds with +**0 warnings / 0 errors** even under a strict restore + `dotnet build -warnaserror`, not only under the +CI sequence (which restores separately). The CS0105 duplicate-using and the Collaboration +`AddPlanoraSwaggerGen`/`UsePlanoraSwagger` errors from older build logs were already resolved in the +current `net10.0` code. + +### docs — comprehensive, marketing-grade README overhaul (2026-06-01) + +Rewrote `README.md` into a richer, more polished landing page that reads for both engineers and a +product audience: a centered hero, a "Why Planora" benefits section, a feature tour, and an explicit +**tech-stack table that links every major dependency** (NuGet/npm) with its pinned version (sourced +from `Directory.Packages.props` and `frontend/package.json`). Expanded the configuration reference +into Required + Common-optional tables grounded in `.env.example`, corrected the Auth service port to +`5030` (gRPC `5031`), and added the full documentation index. Allowed the centered HTML hero in the +markdownlint config (`MD041: false`); the README passes markdownlint with zero errors. + +### fix(frontend) — modal stays open on leave (dashboard), branch text wraps, greyscale event icons (2026-06-01) + +- **Leaving work no longer closes the branch modal from the dashboard.** `dashboard/page.tsx` + `handleLeave` called `setEditingTodo(null)`, which closed the modal whenever the user stopped the + in-progress status from there. It now updates the open todo in place (status/`isWorking`) without + closing — matching the header-pill and "+"-menu paths and the active-feed behaviour. +- **Long unbreakable text wraps instead of scrolling sideways.** Added `overflow-wrap: anywhere` / + `word-break: break-word` to the message body, the Author's Note, and system-event text, so a giant + word or URL with no spaces wraps onto the next line rather than producing a horizontal scrollbar. +- **System-event rail markers are now greyscale and simpler.** Replaced the coloured per-event icons + with a single calm grey marker and simpler glyphs (`getSystemEventIcon`): created = Plus, started = + Play, left = LogOut, completed = Check, other = Circle. + +### feat(frontend) — redesigned, unbroken branch activity rail with event-typed markers (2026-06-01) + +The task-branch timeline rail is now a single continuous gradient line that lives in a content-height +wrapper, so it spans the whole feed and no longer breaks/stops once there are enough messages to +scroll (the old line was anchored to the scroll viewport via `top/bottom`). Every marker is now +mathematically centred on the rail (shared `RAIL_GUTTER`/`RAIL_CENTER` geometry) instead of sitting +slightly off to the side. System-event markers are no longer a generic grey dot: `getSystemEventMeta` +maps each event to a meaningful icon + colour — created (Sparkles/violet), started working +(Zap/indigo), left (LogOut/red), completed (CheckCircle2/emerald) — rendered in a tinted ring centred +on the line. The previous `getSystemEventColor` (which only matched Russian phrases and so always fell +back to grey for the actual English event sentences) was removed. + +Docs: reviewed the last 20 commits and reconciled the documentation — updated the `docs/features.md` +branch/Frontend-Behavior section to describe the new rail + typed markers; verified the Outbox/Inbox, +signal-dispatch, LAN-sharing, and launcher docs already match the code. + +### docs — accurate launcher help + documentation refresh (2026-06-01) + +Rewrote the `Start-Planora-Local.ps1` comment-based help (`.SYNOPSIS`/`.DESCRIPTION`/`.PARAMETER`/ +`.EXAMPLE`/`.NOTES`) and the `-Help` usage so they fully and accurately describe the current +behaviour: the 10-step startup pipeline, every flag (including `-Lan`), the default ports/URLs, the +per-service schema bootstrap (the launcher runs **no** separate migration step), secret handling, and +logs/lifecycle. Corrected the README local-dev section (it previously claimed the launcher "applies +schemas through the migrator", which it does not) and added a flag table + port list. Updated +`docs/OPERATIONS.md`, `docs/codebase-map.md`, and the `docs/configuration.md` LAN section to reflect +that LAN sharing is now automatic via `-Lan` + the dev CORS/CSP allowances + runtime `getApiBaseUrl()` +(only email-link `Frontend__BaseUrl` still needs the LAN IP). + +### feat — one-command LAN sharing over Wi-Fi (VPN-safe) (2026-06-01) + +Added `-Lan` to `Start-Planora-Local.ps1` so a teammate on the same Wi-Fi can open the running app. +The launcher resolves the host's physical LAN IPv4 via `Get-NetAdapter -Physical` (which excludes VPN +virtual adapters, so a split-tunnel VPN's tunnel address is never handed out), opens a Windows Firewall +inbound rule for ports 3000 + 5132 scoped to `Profile Any` + `RemoteAddress LocalSubnet` (self-elevating +once if needed), and prints the shareable `http://:3000` URL plus VPN guidance. The frontend +already binds `0.0.0.0` and `getApiBaseUrl()` auto-targets the gateway on whatever host the page was +opened from, so peers need zero configuration. + +To make this work end-to-end in development without per-IP wiring: the API gateway's dev CORS policy now +accepts loopback **and** RFC1918 private-LAN origins (via a bounded `SetIsOriginAllowed` predicate — dev +policy only, production stays an explicit allow-list), and the frontend's dev CSP `connect-src` now +allows `http:/https:/ws:/wss:` (mirroring the existing dev `img-src`), so a browser served from a LAN IP +can reach `http://:5132`. Production CSP/CORS are unchanged. + +### fix(frontend) — leaving work keeps the branch modal open + faster status-comment catch (2026-06-01) + +The header pill's "Leave" action called `onClose()`, so stopping work closed the whole branch modal. +Removed it — leaving (by the pill or the "+" menu) now keeps the modal open, and the "left the task" +system comment appears in-place. Also tightened the post-action catch-up merge schedule +(250 ms → 5.6 s, denser early) so the status system-comment surfaces almost immediately once the +signal-driven outbox dispatch has published it. + +> Note: the near-instant dispatch requires the **Todo service to be running the rebuilt binary**. A +> still-running pre-change Todo API keeps the old 5 s poll cadence until restarted. + +### perf/feat — instant outbox dispatch, unified Quick Filter bar, non-owner date popover (2026-06-01) + +**perf(outbox): task-lifecycle system comments now appear near-instantly instead of after ~20 s.** +The `OutboxProcessor` only polled every 5 s, so a "started working / left / completed" event could wait +out a poll tick on the producer *and* be missed by the branch's early catch-up refetches — feeling like +a 20 s delay. Added signal-driven dispatch: a new `OutboxSignal` (in-process singleton) plus +`OutboxNotifyInterceptor` (an EF `SaveChangesInterceptor` on `TodoDbContext`) pulse the processor the +moment a transaction that inserted an outbox row commits, so it publishes in milliseconds; the 5 s poll +stays only as a safety net, and a full batch is drained in a tight loop. Consumption is already +push-based (RabbitMQ), so the Collaboration system comment now lands in the branch in well under a +second. The signal is optional — services that do not register it fall back to pure polling unchanged. + +**feat(frontend): the applied-filter summary now lives inside the Quick Filter bar (no layout shift).** +On /tasks and /tasks/completed the active-filter info was a separate block that pushed the page around. +Extracted a shared `QuickFilterBar` component: when a filter is applied, the category chips + count + +clear button crossfade into a fixed-height subtitle row *inside* the same plate, so toggling a filter +never grows or jolts the block. Removed the duplicated inline plates and the standalone "Filter Active" +chip / "F" hint blocks from both pages. + +**feat(frontend): non-owner date popover hides the quick-pick row.** A viewer who is not the task owner +opens the date token read-only; the Today/Tomorrow/+3 days/Next week shortcuts are now omitted entirely +rather than shown disabled, leaving just the read-only calendar. + +### fix — branch comment edit/delete 409, live branch updates, open-at-bottom, completed-page filter plate (2026-06-01) + +**fix(collaboration): comment edit/delete always failed with a spurious concurrency conflict.** +`CommentRepository` overrode the base `GetByIdAsync` purely to add `AsNoTracking()`. The `Comment` +aggregate uses PostgreSQL's `xmin` as an optimistic-concurrency token (a shadow property captured only +on a *tracked* read), so the no-tracking load dropped it and the subsequent UPDATE/soft-delete issued +`WHERE xmin = 0`, matched zero rows, and threw `DbUpdateConcurrencyException` → 409 "The record has been +modified by another user." Removed the override so mutations inherit the tracking base. The author-only +edit rule (`Comment.UpdateContent`) and the frontend gating the edit button on `isOwn` were already +correct; this only fixes the false conflict. + +**feat(frontend): the task branch now updates live without re-opening the modal.** With no realtime +socket in the app, `BranchFeed` polls the newest page every 5 s (paused while editing) and merges by +comment id, so other participants' messages/edits and the asynchronously-materialised status +system-comments appear on their own. Taking a task into work / leaving / completing additionally +schedules short catch-up merges (≈0.6 / 1.5 / 3 s) so the status event shows within a second or two +even though it is produced via Outbox→Inbox after the action returns. + +**feat(frontend): the branch opens at the newest message.** The rail pins to the bottom on first load +and after take/leave/complete; "load earlier" and description edits preserve scroll position. + +**feat(frontend): /tasks/completed gets the same Quick Filter plate as /tasks.** The active-filter chip +was rendered inside the completed-archive hero/stats card; it is now a standalone row and the page shows +the identical "Quick Filter" plate (SlidersHorizontal + "F to filter" + Open Menu button) below the +header, matching /tasks exactly. + +### feat(frontend) — sticky Author's Note + task actions in branch compose menu (2026-06-01) + +The Author's Note (task description) is now part of the scrollable branch rail instead of living +outside it. It scrolls away naturally; once it leaves view a condensed frosted-glass bar appears at +the top of the feed with the author's avatar, the truncated first line, and a gently-bouncing chevron. +Clicking the bar smooth-scrolls back to the full card and fires a violet attention-pulse animation so +the note is effortless to find. The bar enters/exits with a spring via Framer Motion `AnimatePresence`. + +The compose "+" menu now surfaces two task-action items (available to all participants, not just the +owner): **Take into work** (→ Leave task when already in progress, toggle) and **Complete task** (→ +Reopen when already completed). Both mirror the existing join/leave/complete flow precisely — no new +API or logic — and emit the same system comments and toasts the cards do. The "Description" attachment +item is now hidden for non-owners (only the author can set a task description). An optimistic +`workOverride` flag in the modal makes the in-progress pill in the header flip instantly on "Take into +work" / "Leave" before the parent refetch propagates back. + +### fix(frontend) — lock owner-only fields for viewers + fixed-size branch modal (2026-06-01) + +Two issues in the branch/edit modal. (1) When a non-owner opened a public task's branch modal, the +priority, due-date and visibility tokens were fully editable — the gate keyed off +`canManageViewerCategory`, which is `true` for shared tasks because a viewer may set their *own* +category, so it leaked write access to fields that belong to the author. These three tokens are now +rendered muted for non-owners and open a read-only (greyed, non-interactive) preview on click, while the +category token stays editable for viewers as intended; the title was already owner-gated. (2) The modal +resized to its content — short for an empty branch, tall for a full one. It is now a fixed size (90vh, +capped at 880px) with the timeline flex-filling and scrolling internally, so it is always the same +maximum size regardless of how much the branch contains. + +### feat(frontend) — category filter on the Completed Tasks page (2026-06-01) + +The `/tasks/completed` page now has the same category filter as `/tasks`: the "F" hotkey toggles the +category filter modal, an active-filter chip shows the selected categories with a one-click clear, and +the hint kbd appears until first use. The selection is persisted in the same shared store as the active +page, so the filter is consistent across both. Filtering is applied client-side to the loaded archive +page (matching how the active feed filters). + +### fix(a11y) — associate auth form labels + name the password-visibility toggles (2026-06-01) + +Audit follow-up. The auth forms (login, register, forgot-password, reset-password, verify-email) +rendered each `