diff --git a/.docker/compose/docker-compose.local.yml b/.docker/compose/docker-compose.local.yml index a7c0f73da..162cca22e 100644 --- a/.docker/compose/docker-compose.local.yml +++ b/.docker/compose/docker-compose.local.yml @@ -47,7 +47,7 @@ services: # - :/import/Caddyfile:ro # - :/import/sites:ro # If your Caddyfile imports other files healthcheck: - test: ["CMD-SHELL", "curl -fsS http://localhost:8080/api/v1/health || exit 1"] + test: ["CMD-SHELL", "wget -qO /dev/null http://localhost:8080/api/v1/health || exit 1"] interval: 30s timeout: 10s retries: 3 diff --git a/.docker/compose/docker-compose.playwright-ci.yml b/.docker/compose/docker-compose.playwright-ci.yml index 94e7d5a31..bc3f80b7e 100644 --- a/.docker/compose/docker-compose.playwright-ci.yml +++ b/.docker/compose/docker-compose.playwright-ci.yml @@ -87,7 +87,7 @@ services: - playwright_caddy_config:/config - /var/run/docker.sock:/var/run/docker.sock:ro # For container discovery in tests healthcheck: - test: ["CMD", "curl", "-sf", "http://localhost:8080/api/v1/health"] + test: ["CMD-SHELL", "wget -qO /dev/null http://localhost:8080/api/v1/health || exit 1"] interval: 5s timeout: 3s retries: 12 diff --git a/.docker/compose/docker-compose.playwright-local.yml b/.docker/compose/docker-compose.playwright-local.yml index 735fe6b6f..de98e202d 100644 --- a/.docker/compose/docker-compose.playwright-local.yml +++ b/.docker/compose/docker-compose.playwright-local.yml @@ -52,7 +52,7 @@ services: volumes: - /var/run/docker.sock:/var/run/docker.sock:ro # For container discovery in tests healthcheck: - test: ["CMD-SHELL", "curl -fsS http://localhost:8080/api/v1/health || exit 1"] + test: ["CMD-SHELL", "wget -qO /dev/null http://localhost:8080/api/v1/health || exit 1"] interval: 5s timeout: 5s retries: 10 diff --git a/.docker/compose/docker-compose.yml b/.docker/compose/docker-compose.yml index 852e83a54..e7d9d3fab 100644 --- a/.docker/compose/docker-compose.yml +++ b/.docker/compose/docker-compose.yml @@ -52,7 +52,7 @@ services: # - ./my-existing-Caddyfile:/import/Caddyfile:ro # - ./sites:/import/sites:ro # If your Caddyfile imports other files healthcheck: - test: ["CMD-SHELL", "curl -fsS http://localhost:8080/api/v1/health || exit 1"] + test: ["CMD-SHELL", "wget -qO /dev/null http://localhost:8080/api/v1/health || exit 1"] interval: 30s timeout: 10s retries: 3 diff --git a/.docker/docker-entrypoint.sh b/.docker/docker-entrypoint.sh index a5e74e7e5..cf794707e 100755 --- a/.docker/docker-entrypoint.sh +++ b/.docker/docker-entrypoint.sh @@ -365,7 +365,7 @@ echo "Caddy started (PID: $CADDY_PID)" echo "Waiting for Caddy admin API..." i=1 while [ "$i" -le 30 ]; do - if curl -sf http://127.0.0.1:2019/config/ > /dev/null 2>&1; then + if wget -qO /dev/null http://127.0.0.1:2019/config/ 2>/dev/null; then echo "Caddy is ready!" break fi diff --git a/.github/workflows/auto-changelog.yml b/.github/workflows/auto-changelog.yml index 38d215e9f..be58e6275 100644 --- a/.github/workflows/auto-changelog.yml +++ b/.github/workflows/auto-changelog.yml @@ -21,6 +21,6 @@ jobs: with: ref: ${{ github.event.workflow_run.head_sha || github.sha }} - name: Draft Release - uses: release-drafter/release-drafter@6a93d829887aa2e0748befe2e808c66c0ec6e4c7 # v6 + uses: release-drafter/release-drafter@3a7fb5c85b80b1dda66e1ccb94009adbbd32fce3 # v7 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/auto-versioning.yml b/.github/workflows/auto-versioning.yml index ba0753a03..c5a815b10 100644 --- a/.github/workflows/auto-versioning.yml +++ b/.github/workflows/auto-versioning.yml @@ -89,7 +89,7 @@ jobs: - name: Create GitHub Release (creates tag via API) if: ${{ steps.semver.outputs.changed == 'true' && steps.check_release.outputs.exists == 'false' }} - uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2 + uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2 with: tag_name: ${{ steps.determine_tag.outputs.tag }} name: Release ${{ steps.determine_tag.outputs.tag }} diff --git a/.github/workflows/cerberus-integration.yml b/.github/workflows/cerberus-integration.yml index 071d5927e..e43474a2d 100644 --- a/.github/workflows/cerberus-integration.yml +++ b/.github/workflows/cerberus-integration.yml @@ -31,7 +31,7 @@ jobs: - name: Build Docker image (Local) run: | echo "Building image locally for integration tests..." - docker build -t charon:local . + docker build -t charon:local --build-arg CI="${CI:-false}" . echo "✅ Successfully built charon:local" - name: Run Cerberus integration tests diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index fab63981d..dcb0806c1 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -52,7 +52,7 @@ jobs: run: bash scripts/ci/check-codeql-parity.sh - name: Initialize CodeQL - uses: github/codeql-action/init@0d579ffd059c29b07949a3cce3983f0780820c98 # v4 + uses: github/codeql-action/init@b1bff81932f5cdfc8695c7752dcee935dcd061c8 # v4 with: languages: ${{ matrix.language }} queries: security-and-quality @@ -92,10 +92,10 @@ jobs: run: mkdir -p sarif-results - name: Autobuild - uses: github/codeql-action/autobuild@0d579ffd059c29b07949a3cce3983f0780820c98 # v4 + uses: github/codeql-action/autobuild@b1bff81932f5cdfc8695c7752dcee935dcd061c8 # v4 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@0d579ffd059c29b07949a3cce3983f0780820c98 # v4 + uses: github/codeql-action/analyze@b1bff81932f5cdfc8695c7752dcee935dcd061c8 # v4 with: category: "/language:${{ matrix.language }}" output: sarif-results/${{ matrix.language }} diff --git a/.github/workflows/crowdsec-integration.yml b/.github/workflows/crowdsec-integration.yml index 5a2fc20cf..868d8e94e 100644 --- a/.github/workflows/crowdsec-integration.yml +++ b/.github/workflows/crowdsec-integration.yml @@ -31,7 +31,7 @@ jobs: - name: Build Docker image (Local) run: | echo "Building image locally for integration tests..." - docker build -t charon:local . + docker build -t charon:local --build-arg CI="${CI:-false}" . echo "✅ Successfully built charon:local" - name: Run CrowdSec integration tests diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index 926f621a2..fb15d1d80 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -565,7 +565,7 @@ jobs: - name: Upload Trivy results if: env.TRIGGER_EVENT != 'pull_request' && steps.skip.outputs.skip_build != 'true' && steps.trivy-check.outputs.exists == 'true' - uses: github/codeql-action/upload-sarif@0d579ffd059c29b07949a3cce3983f0780820c98 # v4.32.6 + uses: github/codeql-action/upload-sarif@b1bff81932f5cdfc8695c7752dcee935dcd061c8 # v4.33.0 with: sarif_file: 'trivy-results.sarif' category: '.github/workflows/docker-build.yml:build-and-push' @@ -724,14 +724,14 @@ jobs: - name: Upload Trivy scan results if: always() && steps.trivy-pr-check.outputs.exists == 'true' - uses: github/codeql-action/upload-sarif@0d579ffd059c29b07949a3cce3983f0780820c98 # v4.32.6 + uses: github/codeql-action/upload-sarif@b1bff81932f5cdfc8695c7752dcee935dcd061c8 # v4.33.0 with: sarif_file: 'trivy-pr-results.sarif' category: 'docker-pr-image' - name: Upload Trivy compatibility results (docker-build category) if: always() && steps.trivy-pr-check.outputs.exists == 'true' - uses: github/codeql-action/upload-sarif@0d579ffd059c29b07949a3cce3983f0780820c98 # v4.32.6 + uses: github/codeql-action/upload-sarif@b1bff81932f5cdfc8695c7752dcee935dcd061c8 # v4.33.0 with: sarif_file: 'trivy-pr-results.sarif' category: '.github/workflows/docker-build.yml:build-and-push' @@ -739,7 +739,7 @@ jobs: - name: Upload Trivy compatibility results (docker-publish alias) if: always() && steps.trivy-pr-check.outputs.exists == 'true' - uses: github/codeql-action/upload-sarif@0d579ffd059c29b07949a3cce3983f0780820c98 # v4.32.6 + uses: github/codeql-action/upload-sarif@b1bff81932f5cdfc8695c7752dcee935dcd061c8 # v4.33.0 with: sarif_file: 'trivy-pr-results.sarif' category: '.github/workflows/docker-publish.yml:build-and-push' @@ -747,7 +747,7 @@ jobs: - name: Upload Trivy compatibility results (nightly alias) if: always() && steps.trivy-pr-check.outputs.exists == 'true' - uses: github/codeql-action/upload-sarif@0d579ffd059c29b07949a3cce3983f0780820c98 # v4.32.6 + uses: github/codeql-action/upload-sarif@b1bff81932f5cdfc8695c7752dcee935dcd061c8 # v4.33.0 with: sarif_file: 'trivy-pr-results.sarif' category: 'trivy-nightly' diff --git a/.github/workflows/nightly-build.yml b/.github/workflows/nightly-build.yml index 3aff9b2ff..3979a80a2 100644 --- a/.github/workflows/nightly-build.yml +++ b/.github/workflows/nightly-build.yml @@ -451,7 +451,7 @@ jobs: trivyignores: '.trivyignore' - name: Upload Trivy results - uses: github/codeql-action/upload-sarif@0d579ffd059c29b07949a3cce3983f0780820c98 # v4.32.6 + uses: github/codeql-action/upload-sarif@b1bff81932f5cdfc8695c7752dcee935dcd061c8 # v4.33.0 with: sarif_file: 'trivy-nightly.sarif' category: 'trivy-nightly' diff --git a/.github/workflows/rate-limit-integration.yml b/.github/workflows/rate-limit-integration.yml index 8c74f3a77..868ef9cf9 100644 --- a/.github/workflows/rate-limit-integration.yml +++ b/.github/workflows/rate-limit-integration.yml @@ -31,7 +31,7 @@ jobs: - name: Build Docker image (Local) run: | echo "Building image locally for integration tests..." - docker build -t charon:local . + docker build -t charon:local --build-arg CI="${CI:-false}" . echo "✅ Successfully built charon:local" - name: Run rate limit integration tests diff --git a/.github/workflows/renovate.yml b/.github/workflows/renovate.yml index 2ffbf8732..a16114109 100644 --- a/.github/workflows/renovate.yml +++ b/.github/workflows/renovate.yml @@ -25,7 +25,7 @@ jobs: fetch-depth: 1 - name: Run Renovate - uses: renovatebot/github-action@0b17c4eb901eca44d018fb25744a50a74b2042df # v46.1.4 + uses: renovatebot/github-action@abd08c7549b2a864af5df4a2e369c43f035a6a9d # v46.1.5 with: configurationFile: .github/renovate.json token: ${{ secrets.RENOVATE_TOKEN || secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/security-pr.yml b/.github/workflows/security-pr.yml index b818cd3ea..8c5a9cf51 100644 --- a/.github/workflows/security-pr.yml +++ b/.github/workflows/security-pr.yml @@ -240,7 +240,7 @@ jobs: - name: Download PR image artifact if: github.event_name == 'workflow_run' || github.event_name == 'workflow_dispatch' # actions/download-artifact v4.1.8 - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c + uses: actions/download-artifact@484a0b528fb4d7bd804637ccb632e47a0e638317 with: name: ${{ steps.check-artifact.outputs.artifact_name }} run-id: ${{ steps.check-artifact.outputs.run_id }} @@ -385,7 +385,7 @@ jobs: - name: Upload Trivy SARIF to GitHub Security if: always() && steps.trivy-sarif-check.outputs.exists == 'true' # github/codeql-action v4 - uses: github/codeql-action/upload-sarif@1a97b0f94ec9297d6f58aefe5a6b5441c045bed4 + uses: github/codeql-action/upload-sarif@7dd76e6bf79d24133aa649887a6ee01d8b063816 with: sarif_file: 'trivy-binary-results.sarif' category: ${{ steps.pr-info.outputs.is_push == 'true' && format('security-scan-{0}', github.event_name == 'workflow_run' && github.event.workflow_run.head_branch || github.ref_name) || format('security-scan-pr-{0}', steps.pr-info.outputs.pr_number) }} diff --git a/.github/workflows/security-weekly-rebuild.yml b/.github/workflows/security-weekly-rebuild.yml index 69c2ae4c3..a76c1784e 100644 --- a/.github/workflows/security-weekly-rebuild.yml +++ b/.github/workflows/security-weekly-rebuild.yml @@ -113,7 +113,7 @@ jobs: version: 'v0.69.3' - name: Upload Trivy results to GitHub Security - uses: github/codeql-action/upload-sarif@0d579ffd059c29b07949a3cce3983f0780820c98 # v4.32.6 + uses: github/codeql-action/upload-sarif@b1bff81932f5cdfc8695c7752dcee935dcd061c8 # v4.33.0 with: sarif_file: 'trivy-weekly-results.sarif' diff --git a/.github/workflows/supply-chain-pr.yml b/.github/workflows/supply-chain-pr.yml index f4a8a3fa6..a83f5e6df 100644 --- a/.github/workflows/supply-chain-pr.yml +++ b/.github/workflows/supply-chain-pr.yml @@ -362,7 +362,7 @@ jobs: - name: Upload SARIF to GitHub Security if: steps.check-artifact.outputs.artifact_found == 'true' - uses: github/codeql-action/upload-sarif@0d579ffd059c29b07949a3cce3983f0780820c98 # v4 + uses: github/codeql-action/upload-sarif@b1bff81932f5cdfc8695c7752dcee935dcd061c8 # v4 continue-on-error: true with: sarif_file: grype-results.sarif @@ -381,9 +381,12 @@ jobs: - name: Comment on PR if: steps.set-target.outputs.image_name != '' && steps.pr-number.outputs.is_push != 'true' && steps.pr-number.outputs.pr_number != '' + continue-on-error: true env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | + set -euo pipefail + PR_NUMBER="${{ steps.pr-number.outputs.pr_number }}" COMPONENT_COUNT="${{ steps.sbom-count.outputs.component_count }}" CRITICAL_COUNT="${{ steps.vuln-summary.outputs.critical_count }}" @@ -429,29 +432,38 @@ jobs: EOF ) - # Find and update existing comment or create new one - COMMENT_ID=$(gh api \ + # Fetch existing comments — skip gracefully on 403 / permission errors + COMMENTS_JSON="" + if ! COMMENTS_JSON=$(gh api \ -H "Accept: application/vnd.github+json" \ -H "X-GitHub-Api-Version: 2022-11-28" \ - "/repos/${{ github.repository }}/issues/${PR_NUMBER}/comments" \ - --jq '.[] | select(.body | contains("Supply Chain Verification Results")) | .id' | head -1) + "/repos/${{ github.repository }}/issues/${PR_NUMBER}/comments" 2>/dev/null); then + echo "⚠️ Cannot access PR comments (likely token permissions / fork / event context). Skipping PR comment." + exit 0 + fi - if [[ -n "${COMMENT_ID}" ]]; then + COMMENT_ID=$(echo "${COMMENTS_JSON}" | jq -r '.[] | select(.body | contains("Supply Chain Verification Results")) | .id' | head -1) + + if [[ -n "${COMMENT_ID:-}" && "${COMMENT_ID}" != "null" ]]; then echo "📝 Updating existing comment..." - gh api \ - --method PATCH \ + if ! gh api --method PATCH \ -H "Accept: application/vnd.github+json" \ -H "X-GitHub-Api-Version: 2022-11-28" \ "/repos/${{ github.repository }}/issues/comments/${COMMENT_ID}" \ - -f body="${COMMENT_BODY}" + -f body="${COMMENT_BODY}"; then + echo "⚠️ Failed to update comment (permissions?). Skipping." + exit 0 + fi else echo "📝 Creating new comment..." - gh api \ - --method POST \ + if ! gh api --method POST \ -H "Accept: application/vnd.github+json" \ -H "X-GitHub-Api-Version: 2022-11-28" \ "/repos/${{ github.repository }}/issues/${PR_NUMBER}/comments" \ - -f body="${COMMENT_BODY}" + -f body="${COMMENT_BODY}"; then + echo "⚠️ Failed to create comment (permissions?). Skipping." + exit 0 + fi fi echo "✅ PR comment posted" diff --git a/.github/workflows/waf-integration.yml b/.github/workflows/waf-integration.yml index 65b6fe799..509eb5eea 100644 --- a/.github/workflows/waf-integration.yml +++ b/.github/workflows/waf-integration.yml @@ -31,7 +31,7 @@ jobs: - name: Build Docker image (Local) run: | echo "Building image locally for integration tests..." - docker build -t charon:local . + docker build -t charon:local --build-arg CI="${CI:-false}" . echo "✅ Successfully built charon:local" - name: Run WAF integration tests diff --git a/.github/workflows/weekly-nightly-promotion.yml b/.github/workflows/weekly-nightly-promotion.yml index 47ad9fd6f..1b6687d33 100644 --- a/.github/workflows/weekly-nightly-promotion.yml +++ b/.github/workflows/weekly-nightly-promotion.yml @@ -200,8 +200,8 @@ jobs: runs-on: ubuntu-latest if: needs.check-nightly-health.outputs.is_healthy == 'true' outputs: - pr_number: ${{ steps.create-pr.outputs.pr_number }} - pr_url: ${{ steps.create-pr.outputs.pr_url }} + pr_number: ${{ steps.create-pr.outputs.pr_number || steps.existing-pr.outputs.pr_number }} + pr_url: ${{ steps.create-pr.outputs.pr_url || steps.existing-pr.outputs.pr_url }} skipped: ${{ steps.check-diff.outputs.skipped }} steps: diff --git a/.grype.yaml b/.grype.yaml index 7701f01f1..a129f2ec4 100644 --- a/.grype.yaml +++ b/.grype.yaml @@ -4,61 +4,6 @@ # Documentation: https://github.com/anchore/grype#specifying-matches-to-ignore ignore: - # CVE-2026-22184: zlib Global Buffer Overflow in untgz utility - # Severity: CRITICAL - # Package: zlib 1.3.1-r2 (Alpine Linux base image) - # Status: No upstream fix available as of 2026-01-16 - # - # Vulnerability Details: - # - Global buffer overflow in TGZfname() function - # - Unbounded strcpy() allows attacker-controlled archive names - # - Can lead to memory corruption, DoS, potential RCE - # - # Risk Assessment: ACCEPTED (Low exploitability in Charon context) - # - Charon does not use untgz utility directly - # - No untrusted tar archive processing in application code - # - Attack surface limited to OS-level utilities - # - Multiple layers of containerization and isolation - # - # Mitigation: - # - Monitor Alpine Linux security feed daily for zlib patches - # - Container runs with minimal privileges (no-new-privileges) - # - Read-only filesystem where possible - # - Network isolation via Docker networks - # - # Review: - # - Daily checks for Alpine security updates - # - Automatic re-scan via CI/CD on every commit - # - Manual review scheduled for 2026-01-23 (7 days) - # - # Removal Criteria: - # - Alpine releases zlib 1.3.1-r3 or higher with CVE fix - # - OR upstream zlib project releases patched version - # - Remove this suppression immediately after fix available - # - # References: - # - CVE: https://nvd.nist.gov/vuln/detail/CVE-2026-22184 - # - Alpine Security: https://security.alpinelinux.org/ - # - GitHub Issue: https://github.com/Wikid82/Charon/issues/TBD - - vulnerability: CVE-2026-22184 - package: - name: zlib - version: "1.3.1-r2" - type: apk # Alpine package - reason: | - CRITICAL buffer overflow in untgz utility. No fix available from Alpine - as of 2026-01-16. Risk accepted: Charon does not directly use untgz or - process untrusted tar archives. Attack surface limited to base OS utilities. - Monitoring Alpine security feed for upstream patch. - expiry: "2026-03-14" # Re-evaluate in 7 days - - # Action items when this suppression expires: - # 1. Check Alpine security feed: https://security.alpinelinux.org/ - # 2. Check zlib releases: https://github.com/madler/zlib/releases - # 3. If fix available: Update Dockerfile, rebuild, remove suppression - # 4. If no fix: Extend expiry by 7 days, document justification - # 5. If extended 3+ times: Escalate to security team for review - # GHSA-69x3-g4r3-p962 / CVE-2026-25793: Nebula ECDSA Signature Malleability # Severity: HIGH (CVSS 8.1) # Package: github.com/slackhq/nebula v1.9.7 (embedded in /usr/bin/caddy) @@ -98,7 +43,8 @@ ignore: # Review: # - Reviewed 2026-02-19: smallstep/certificates latest stable remains v0.27.5; # no release requiring nebula v1.10+ has shipped. Suppression extended 14 days. - # - Next review: 2026-03-05. Remove suppression immediately once upstream fixes. + # - Reviewed 2026-03-13: smallstep/certificates stable still v0.27.5, extended 30 days. + # - Next review: 2026-04-12. Remove suppression immediately once upstream fixes. # # Removal Criteria: # - smallstep/certificates releases a stable version requiring nebula v1.10+ @@ -118,11 +64,11 @@ ignore: type: go-module reason: | HIGH — ECDSA signature malleability in nebula v1.9.7 embedded in /usr/bin/caddy. - Cannot upgrade: smallstep/certificates v0.27.5 (latest stable as of 2026-02-19) + Cannot upgrade: smallstep/certificates v0.27.5 (latest stable as of 2026-03-13) still requires nebula v1.9.x (verified across v0.27.5–v0.30.0-rc2). Charon does not use Nebula VPN PKI by default. Risk accepted pending upstream smallstep fix. - Reviewed 2026-02-19: no new smallstep release changes this assessment. - expiry: "2026-03-05" # Re-evaluate in 14 days (2026-02-19 + 14 days) + Reviewed 2026-03-13: smallstep/certificates stable still v0.27.5, extended 30 days. + expiry: "2026-04-12" # Re-evaluated 2026-03-13: smallstep/certificates stable still v0.27.5, extended 30 days. # Action items when this suppression expires: # 1. Check smallstep/certificates releases: https://github.com/smallstep/certificates/releases diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 4a5f57b85..a964e8daf 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -139,15 +139,15 @@ graph TB | Component | Technology | Version | Purpose | |-----------|-----------|---------|---------| | **Framework** | React | 19.2.3 | UI framework | -| **Language** | TypeScript | 5.x | Type-safe JavaScript | -| **Build Tool** | Vite | 6.1.9 | Fast bundler and dev server | -| **CSS Framework** | Tailwind CSS | 3.x | Utility-first CSS | +| **Language** | TypeScript | 6.x | Type-safe JavaScript | +| **Build Tool** | Vite | 8.0.0-beta.18 | Fast bundler and dev server | +| **CSS Framework** | Tailwind CSS | 4.2.1 | Utility-first CSS | | **Routing** | React Router | 7.x | Client-side routing | | **HTTP Client** | Fetch API | Native | API communication | | **State Management** | React Hooks + Context | Native | Global state | | **Internationalization** | i18next | Latest | 5 language support | -| **Unit Testing** | Vitest | 2.x | Fast unit test runner | -| **E2E Testing** | Playwright | 1.50.x | Browser automation | +| **Unit Testing** | Vitest | 4.1.0-beta.6 | Fast unit test runner | +| **E2E Testing** | Playwright | 1.58.2 | Browser automation | ### Infrastructure @@ -218,7 +218,7 @@ graph TB │ │ └── main.tsx # Application entry point │ ├── public/ # Static assets │ ├── package.json # NPM dependencies -│ └── vite.config.js # Vite configuration +│ └── vite.config.ts # Vite configuration │ ├── .docker/ # Docker configuration │ ├── compose/ # Docker Compose files diff --git a/CHANGELOG.md b/CHANGELOG.md index ea12fcb1e..237662adc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **Slack Notification Provider**: Send alerts to Slack channels via Incoming Webhooks + - Supports JSON templates (minimal, detailed, custom) with Slack's native `text` format + - Webhook URL stored securely — never exposed in API responses + - Optional channel display name for easy identification in provider list + - Feature flag: `feature.notifications.service.slack.enabled` (on by default) + - See [Notification Guide](docs/features/notifications.md) for setup instructions + ### CI/CD - **Supply Chain**: Optimized verification workflow to prevent redundant builds - Change: Removed direct Push/PR triggers; now waits for 'Docker Build' via `workflow_run` diff --git a/Dockerfile b/Dockerfile index ed72ea319..8d67aca86 100644 --- a/Dockerfile +++ b/Dockerfile @@ -23,9 +23,11 @@ ARG CROWDSEC_RELEASE_SHA256=704e37121e7ac215991441cef0d8732e33fa3b1a2b2b88b53a0b # ---- Shared Go Security Patches ---- # renovate: datasource=go depName=github.com/expr-lang/expr -ARG EXPR_LANG_VERSION=1.17.7 +ARG EXPR_LANG_VERSION=1.17.8 # renovate: datasource=go depName=golang.org/x/net -ARG XNET_VERSION=0.51.0 +ARG XNET_VERSION=0.52.0 +# renovate: datasource=npm depName=npm +ARG NPM_VERSION=11.11.1 # Allow pinning Caddy version - Renovate will update this # Build the most recent Caddy 2.x release (keeps major pinned under v3). @@ -39,7 +41,7 @@ ARG CADDY_CANDIDATE_VERSION=2.11.2 ARG CADDY_USE_CANDIDATE=0 ARG CADDY_PATCH_SCENARIO=B # renovate: datasource=go depName=github.com/greenpau/caddy-security -ARG CADDY_SECURITY_VERSION=1.1.45 +ARG CADDY_SECURITY_VERSION=1.1.48 # renovate: datasource=go depName=github.com/corazawaf/coraza-caddy ARG CORAZA_CADDY_VERSION=2.2.0 ## When an official caddy image tag isn't available on the host, use a @@ -99,9 +101,12 @@ ARG VERSION=dev # Make version available to Vite as VITE_APP_VERSION during the frontend build ENV VITE_APP_VERSION=${VERSION} -# Set environment to bypass native binary requirement for cross-arch builds -ENV npm_config_rollup_skip_nodejs_native=1 \ - ROLLUP_SKIP_NODEJS_NATIVE=1 +# Vite 8: Rolldown native bindings auto-resolved per platform via optionalDependencies +ARG NPM_VERSION +# hadolint ignore=DL3017 +RUN apk upgrade --no-cache && \ + npm install -g npm@${NPM_VERSION} --no-fund --no-audit && \ + npm cache clean --force RUN npm ci @@ -410,11 +415,11 @@ WORKDIR /app # Install runtime dependencies for Charon, including bash for maintenance scripts # Note: gosu is now built from source (see gosu-builder stage) to avoid CVEs from Debian's pre-compiled version # Explicitly upgrade packages to fix security vulnerabilities -# binutils provides objdump for debug symbol detection in docker-entrypoint.sh # hadolint ignore=DL3018 RUN apk add --no-cache \ - bash ca-certificates sqlite-libs sqlite tzdata curl gettext libcap libcap-utils \ - c-ares binutils libc-utils busybox-extras + bash ca-certificates sqlite-libs sqlite tzdata gettext libcap libcap-utils \ + c-ares busybox-extras \ + && apk upgrade --no-cache zlib # Copy gosu binary from gosu-builder (built with Go 1.26+ to avoid stdlib CVEs) COPY --from=gosu-builder /gosu-out/gosu /usr/sbin/gosu @@ -433,10 +438,11 @@ SHELL ["/bin/ash", "-o", "pipefail", "-c"] # In CI, timeout quickly rather than retrying to save build time ARG GEOLITE2_COUNTRY_SHA256=aa154fc6bcd712644de232a4abcdd07dac1f801308c0b6f93dbc2b375443da7b RUN mkdir -p /app/data/geoip && \ - if [ -n "$CI" ]; then \ + if [ "$CI" = "true" ] || [ "$CI" = "1" ]; then \ echo "⏱️ CI detected - quick download (10s timeout, no retries)"; \ - if curl -fSL -m 10 "https://github.com/P3TERX/GeoLite.mmdb/raw/download/GeoLite2-Country.mmdb" \ - -o /app/data/geoip/GeoLite2-Country.mmdb 2>/dev/null; then \ + if wget -qO /app/data/geoip/GeoLite2-Country.mmdb \ + -T 10 "https://github.com/P3TERX/GeoLite.mmdb/raw/download/GeoLite2-Country.mmdb" 2>/dev/null \ + && [ -s /app/data/geoip/GeoLite2-Country.mmdb ]; then \ echo "✅ GeoIP downloaded"; \ else \ echo "⚠️ GeoIP skipped"; \ @@ -444,9 +450,10 @@ RUN mkdir -p /app/data/geoip && \ fi; \ else \ echo "Local - full download (30s timeout, 3 retries)"; \ - if curl -fSL -m 30 --retry 3 "https://github.com/P3TERX/GeoLite.mmdb/raw/download/GeoLite2-Country.mmdb" \ - -o /app/data/geoip/GeoLite2-Country.mmdb; then \ - if echo "${GEOLITE2_COUNTRY_SHA256} /app/data/geoip/GeoLite2-Country.mmdb" | sha256sum -c -; then \ + if wget -qO /app/data/geoip/GeoLite2-Country.mmdb \ + -T 30 -t 4 "https://github.com/P3TERX/GeoLite.mmdb/raw/download/GeoLite2-Country.mmdb"; then \ + if [ -s /app/data/geoip/GeoLite2-Country.mmdb ] && \ + echo "${GEOLITE2_COUNTRY_SHA256} /app/data/geoip/GeoLite2-Country.mmdb" | sha256sum -c -; then \ echo "✅ GeoIP checksum verified"; \ else \ echo "⚠️ Checksum failed"; \ @@ -579,8 +586,8 @@ EXPOSE 80 443 443/udp 2019 8080 # Security: Add healthcheck to monitor container health # Verifies the Charon API is responding correctly -HEALTHCHECK --interval=30s --timeout=3s --start-period=40s --retries=3 \ - CMD curl -f http://localhost:8080/api/v1/health || exit 1 +HEALTHCHECK --interval=30s --timeout=10s --start-period=15s --retries=3 \ + CMD wget -q -O /dev/null http://localhost:8080/api/v1/health || exit 1 # Create CrowdSec symlink as root before switching to non-root user # This symlink allows CrowdSec to use persistent storage at /app/data/crowdsec/config diff --git a/backend/go.mod b/backend/go.mod index be19ceb11..4136f9b32 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -16,8 +16,8 @@ require ( github.com/robfig/cron/v3 v3.0.1 github.com/sirupsen/logrus v1.9.4 github.com/stretchr/testify v1.11.1 - golang.org/x/crypto v0.48.0 - golang.org/x/net v0.51.0 + golang.org/x/crypto v0.49.0 + golang.org/x/net v0.52.0 golang.org/x/text v0.35.0 golang.org/x/time v0.15.0 gopkg.in/natefinch/lumberjack.v2 v2.2.1 @@ -50,7 +50,7 @@ require ( github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.30.1 // indirect - github.com/goccy/go-json v0.10.5 // indirect + github.com/goccy/go-json v0.10.6 // indirect github.com/goccy/go-yaml v1.19.2 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect diff --git a/backend/go.sum b/backend/go.sum index 268f570d0..1b7022f47 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -62,8 +62,8 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w= github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= -github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= -github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU= +github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= @@ -202,12 +202,12 @@ go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= golang.org/x/arch v0.25.0 h1:qnk6Ksugpi5Bz32947rkUgDt9/s5qvqDPl/gBKdMJLE= golang.org/x/arch v0.25.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8= -golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= -golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= +golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= -golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= -golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= +golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= +golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/backend/internal/api/handlers/notification_coverage_test.go b/backend/internal/api/handlers/notification_coverage_test.go index 4b56cb9e4..f40ca2464 100644 --- a/backend/internal/api/handlers/notification_coverage_test.go +++ b/backend/internal/api/handlers/notification_coverage_test.go @@ -474,6 +474,61 @@ func TestClassifyProviderTestFailure_TLSHandshakeFailed(t *testing.T) { assert.Contains(t, message, "TLS handshake failed") } +func TestClassifyProviderTestFailure_SlackInvalidPayload(t *testing.T) { + code, category, message := classifyProviderTestFailure(errors.New("invalid_payload")) + + assert.Equal(t, "PROVIDER_TEST_VALIDATION_FAILED", code) + assert.Equal(t, "validation", category) + assert.Contains(t, message, "Slack rejected the payload") +} + +func TestClassifyProviderTestFailure_SlackMissingTextOrFallback(t *testing.T) { + code, category, message := classifyProviderTestFailure(errors.New("missing_text_or_fallback")) + + assert.Equal(t, "PROVIDER_TEST_VALIDATION_FAILED", code) + assert.Equal(t, "validation", category) + assert.Contains(t, message, "Slack rejected the payload") +} + +func TestClassifyProviderTestFailure_SlackNoService(t *testing.T) { + code, category, message := classifyProviderTestFailure(errors.New("no_service")) + + assert.Equal(t, "PROVIDER_TEST_AUTH_REJECTED", code) + assert.Equal(t, "dispatch", category) + assert.Contains(t, message, "Slack webhook is revoked") +} + +func TestNotificationProviderHandler_Test_RejectsSlackTokenInTestRequest(t *testing.T) { + gin.SetMode(gin.TestMode) + db := setupNotificationCoverageDB(t) + svc := services.NewNotificationService(db, nil) + h := NewNotificationProviderHandler(svc) + + payload := map[string]any{ + "type": "slack", + "url": "#alerts", + "token": "https://hooks.slack.com/services/T00/B00/secret", + } + body, _ := json.Marshal(payload) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + setAdminContext(c) + c.Set(string(trace.RequestIDKey), "req-slack-token-reject") + c.Request = httptest.NewRequest(http.MethodPost, "/providers/test", bytes.NewBuffer(body)) + c.Request.Header.Set("Content-Type", "application/json") + + h.Test(c) + + assert.Equal(t, http.StatusBadRequest, w.Code) + var resp map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + assert.Equal(t, "TOKEN_WRITE_ONLY", resp["code"]) + assert.Equal(t, "validation", resp["category"]) + assert.Equal(t, "Slack webhook URL is accepted only on provider create/update", resp["error"]) + assert.NotContains(t, w.Body.String(), "hooks.slack.com") +} + func TestNotificationProviderHandler_Templates(t *testing.T) { gin.SetMode(gin.TestMode) db := setupNotificationCoverageDB(t) @@ -948,14 +1003,14 @@ func TestNotificationProviderHandler_Update_UnsupportedType(t *testing.T) { existing := models.NotificationProvider{ ID: "unsupported-type", Name: "Custom Provider", - Type: "slack", - URL: "https://hooks.slack.com/test", + Type: "pushover", + URL: "https://pushover.example.com/test", } require.NoError(t, db.Create(&existing).Error) payload := map[string]any{ - "name": "Updated Slack Provider", - "url": "https://hooks.slack.com/updated", + "name": "Updated Pushover Provider", + "url": "https://pushover.example.com/updated", } body, _ := json.Marshal(payload) diff --git a/backend/internal/api/handlers/notification_provider_blocker3_test.go b/backend/internal/api/handlers/notification_provider_blocker3_test.go index 3d71d38eb..5cd6338e2 100644 --- a/backend/internal/api/handlers/notification_provider_blocker3_test.go +++ b/backend/internal/api/handlers/notification_provider_blocker3_test.go @@ -28,19 +28,22 @@ func TestBlocker3_CreateProviderRejectsNonDiscordWithSecurityEvents(t *testing.T assert.NoError(t, err) // Create handler - service := services.NewNotificationService(db, nil) + service := services.NewNotificationService(db, nil, + services.WithSlackURLValidator(func(string) error { return nil }), + ) handler := NewNotificationProviderHandler(service) // Test cases: provider types with security events enabled testCases := []struct { name string providerType string + token string wantStatus int }{ - {"webhook", "webhook", http.StatusCreated}, - {"gotify", "gotify", http.StatusCreated}, - {"slack", "slack", http.StatusBadRequest}, - {"email", "email", http.StatusCreated}, + {"webhook", "webhook", "", http.StatusCreated}, + {"gotify", "gotify", "", http.StatusCreated}, + {"slack", "slack", "https://hooks.slack.com/services/T1234567890/B1234567890/XXXXXXXXXXXXXXXXXXXX", http.StatusCreated}, + {"email", "email", "", http.StatusCreated}, } for _, tc := range testCases { @@ -50,6 +53,7 @@ func TestBlocker3_CreateProviderRejectsNonDiscordWithSecurityEvents(t *testing.T "name": "Test Provider", "type": tc.providerType, "url": "https://example.com/webhook", + "token": tc.token, "enabled": true, "notify_security_waf_blocks": true, // Security event enabled } diff --git a/backend/internal/api/handlers/notification_provider_discord_only_test.go b/backend/internal/api/handlers/notification_provider_discord_only_test.go index f9f67d624..4c2e503a4 100644 --- a/backend/internal/api/handlers/notification_provider_discord_only_test.go +++ b/backend/internal/api/handlers/notification_provider_discord_only_test.go @@ -24,21 +24,24 @@ func TestDiscordOnly_CreateRejectsNonDiscord(t *testing.T) { require.NoError(t, err) require.NoError(t, db.AutoMigrate(&models.NotificationProvider{}, &models.Notification{})) - service := services.NewNotificationService(db, nil) + service := services.NewNotificationService(db, nil, + services.WithSlackURLValidator(func(string) error { return nil }), + ) handler := NewNotificationProviderHandler(service) testCases := []struct { name string providerType string + token string wantStatus int wantCode string }{ - {"webhook", "webhook", http.StatusCreated, ""}, - {"gotify", "gotify", http.StatusCreated, ""}, - {"slack", "slack", http.StatusBadRequest, "UNSUPPORTED_PROVIDER_TYPE"}, - {"telegram", "telegram", http.StatusCreated, ""}, - {"generic", "generic", http.StatusBadRequest, "UNSUPPORTED_PROVIDER_TYPE"}, - {"email", "email", http.StatusCreated, ""}, + {"webhook", "webhook", "", http.StatusCreated, ""}, + {"gotify", "gotify", "", http.StatusCreated, ""}, + {"slack", "slack", "https://hooks.slack.com/services/T1234567890/B1234567890/XXXXXXXXXXXXXXXXXXXX", http.StatusCreated, ""}, + {"telegram", "telegram", "", http.StatusCreated, ""}, + {"generic", "generic", "", http.StatusBadRequest, "UNSUPPORTED_PROVIDER_TYPE"}, + {"email", "email", "", http.StatusCreated, ""}, } for _, tc := range testCases { @@ -47,6 +50,7 @@ func TestDiscordOnly_CreateRejectsNonDiscord(t *testing.T) { "name": "Test Provider", "type": tc.providerType, "url": "https://example.com/webhook", + "token": tc.token, "enabled": true, "notify_proxy_hosts": true, } @@ -363,7 +367,7 @@ func TestDiscordOnly_ErrorCodes(t *testing.T) { requestFunc: func(id string) (*http.Request, gin.Params) { payload := map[string]interface{}{ "name": "Test", - "type": "slack", + "type": "pushover", "url": "https://example.com", } body, _ := json.Marshal(payload) diff --git a/backend/internal/api/handlers/notification_provider_handler.go b/backend/internal/api/handlers/notification_provider_handler.go index e45f5b8f3..b6b286371 100644 --- a/backend/internal/api/handlers/notification_provider_handler.go +++ b/backend/internal/api/handlers/notification_provider_handler.go @@ -136,6 +136,16 @@ func classifyProviderTestFailure(err error) (code string, category string, messa return "PROVIDER_TEST_UNREACHABLE", "dispatch", "Could not reach provider endpoint. Verify URL, DNS, and network connectivity" } + if strings.Contains(errText, "invalid_payload") || + strings.Contains(errText, "missing_text_or_fallback") { + return "PROVIDER_TEST_VALIDATION_FAILED", "validation", + "Slack rejected the payload. Ensure your template includes a 'text' or 'blocks' field" + } + if strings.Contains(errText, "no_service") { + return "PROVIDER_TEST_AUTH_REJECTED", "dispatch", + "Slack webhook is revoked or the app is disabled. Create a new webhook" + } + return "PROVIDER_TEST_FAILED", "dispatch", "Provider test failed" } @@ -172,7 +182,7 @@ func (h *NotificationProviderHandler) Create(c *gin.Context) { } providerType := strings.ToLower(strings.TrimSpace(req.Type)) - if providerType != "discord" && providerType != "gotify" && providerType != "webhook" && providerType != "email" && providerType != "telegram" { + if providerType != "discord" && providerType != "gotify" && providerType != "webhook" && providerType != "email" && providerType != "telegram" && providerType != "slack" { respondSanitizedProviderError(c, http.StatusBadRequest, "UNSUPPORTED_PROVIDER_TYPE", "validation", "Unsupported notification provider type") return } @@ -232,12 +242,12 @@ func (h *NotificationProviderHandler) Update(c *gin.Context) { } providerType := strings.ToLower(strings.TrimSpace(existing.Type)) - if providerType != "discord" && providerType != "gotify" && providerType != "webhook" && providerType != "email" && providerType != "telegram" { + if providerType != "discord" && providerType != "gotify" && providerType != "webhook" && providerType != "email" && providerType != "telegram" && providerType != "slack" { respondSanitizedProviderError(c, http.StatusBadRequest, "UNSUPPORTED_PROVIDER_TYPE", "validation", "Unsupported notification provider type") return } - if (providerType == "gotify" || providerType == "telegram") && strings.TrimSpace(req.Token) == "" { + if (providerType == "gotify" || providerType == "telegram" || providerType == "slack") && strings.TrimSpace(req.Token) == "" { // Keep existing token if update payload omits token req.Token = existing.Token } @@ -278,7 +288,8 @@ func isProviderValidationError(err error) bool { strings.Contains(errMsg, "rendered template") || strings.Contains(errMsg, "failed to parse template") || strings.Contains(errMsg, "failed to render template") || - strings.Contains(errMsg, "invalid Discord webhook URL") + strings.Contains(errMsg, "invalid Discord webhook URL") || + strings.Contains(errMsg, "invalid Slack webhook URL") } func (h *NotificationProviderHandler) Delete(c *gin.Context) { @@ -310,6 +321,11 @@ func (h *NotificationProviderHandler) Test(c *gin.Context) { return } + if providerType == "slack" && strings.TrimSpace(req.Token) != "" { + respondSanitizedProviderError(c, http.StatusBadRequest, "TOKEN_WRITE_ONLY", "validation", "Slack webhook URL is accepted only on provider create/update") + return + } + // Email providers use global SMTP + recipients from the URL field; they don't require a saved provider ID. if providerType == "email" { provider := models.NotificationProvider{ @@ -343,7 +359,7 @@ func (h *NotificationProviderHandler) Test(c *gin.Context) { return } - if strings.TrimSpace(provider.URL) == "" { + if providerType != "slack" && strings.TrimSpace(provider.URL) == "" { respondSanitizedProviderError(c, http.StatusBadRequest, "PROVIDER_CONFIG_MISSING", "validation", "Trusted provider configuration is incomplete") return } diff --git a/backend/internal/notifications/feature_flags.go b/backend/internal/notifications/feature_flags.go index 7a3a3405f..7443f896b 100644 --- a/backend/internal/notifications/feature_flags.go +++ b/backend/internal/notifications/feature_flags.go @@ -7,5 +7,6 @@ const ( FlagGotifyServiceEnabled = "feature.notifications.service.gotify.enabled" FlagWebhookServiceEnabled = "feature.notifications.service.webhook.enabled" FlagTelegramServiceEnabled = "feature.notifications.service.telegram.enabled" + FlagSlackServiceEnabled = "feature.notifications.service.slack.enabled" FlagSecurityProviderEventsEnabled = "feature.notifications.security_provider_events.enabled" ) diff --git a/backend/internal/services/notification_service.go b/backend/internal/services/notification_service.go index 7d8a08c6e..be22e9db3 100644 --- a/backend/internal/services/notification_service.go +++ b/backend/internal/services/notification_service.go @@ -30,15 +30,32 @@ type NotificationService struct { httpWrapper *notifications.HTTPWrapper mailService MailServiceInterface telegramAPIBaseURL string + validateSlackURL func(string) error } -func NewNotificationService(db *gorm.DB, mailService MailServiceInterface) *NotificationService { - return &NotificationService{ +// NotificationServiceOption configures a NotificationService at construction time. +type NotificationServiceOption func(*NotificationService) + +// WithSlackURLValidator overrides the Slack webhook URL validator. Intended for use +// in tests that need to bypass real URL validation without mutating shared state. +func WithSlackURLValidator(fn func(string) error) NotificationServiceOption { + return func(s *NotificationService) { + s.validateSlackURL = fn + } +} + +func NewNotificationService(db *gorm.DB, mailService MailServiceInterface, opts ...NotificationServiceOption) *NotificationService { + s := &NotificationService{ DB: db, httpWrapper: notifications.NewNotifyHTTPWrapper(), mailService: mailService, telegramAPIBaseURL: "https://api.telegram.org", + validateSlackURL: validateSlackWebhookURL, + } + for _, opt := range opts { + opt(s) } + return s } var discordWebhookRegex = regexp.MustCompile(`^https://discord(?:app)?\.com/api/webhooks/(\d+)/([a-zA-Z0-9_-]+)`) @@ -48,6 +65,15 @@ var allowedDiscordWebhookHosts = map[string]struct{}{ "canary.discord.com": {}, } +var slackWebhookRegex = regexp.MustCompile(`^https://hooks\.slack\.com/services/T[A-Za-z0-9_-]+/B[A-Za-z0-9_-]+/[A-Za-z0-9_-]+$`) + +func validateSlackWebhookURL(rawURL string) error { + if !slackWebhookRegex.MatchString(rawURL) { + return fmt.Errorf("invalid Slack webhook URL: must match https://hooks.slack.com/services/T.../B.../xxx") + } + return nil +} + func normalizeURL(serviceType, rawURL string) string { if serviceType == "discord" { matches := discordWebhookRegex.FindStringSubmatch(rawURL) @@ -110,7 +136,7 @@ func supportsJSONTemplates(providerType string) bool { func isSupportedNotificationProviderType(providerType string) bool { switch strings.ToLower(strings.TrimSpace(providerType)) { - case "discord", "email", "gotify", "webhook", "telegram": + case "discord", "email", "gotify", "webhook", "telegram", "slack": return true default: return false @@ -129,6 +155,8 @@ func (s *NotificationService) isDispatchEnabled(providerType string) bool { return s.getFeatureFlagValue(notifications.FlagWebhookServiceEnabled, true) case "telegram": return s.getFeatureFlagValue(notifications.FlagTelegramServiceEnabled, true) + case "slack": + return s.getFeatureFlagValue(notifications.FlagSlackServiceEnabled, true) default: return false } @@ -440,10 +468,21 @@ func (s *NotificationService) sendJSONPayload(ctx context.Context, p models.Noti } } case "slack": - // Slack requires either 'text' or 'blocks' if _, hasText := jsonPayload["text"]; !hasText { if _, hasBlocks := jsonPayload["blocks"]; !hasBlocks { - return fmt.Errorf("slack payload requires 'text' or 'blocks' field") + if messageValue, hasMessage := jsonPayload["message"]; hasMessage { + jsonPayload["text"] = messageValue + normalizedBody, marshalErr := json.Marshal(jsonPayload) + if marshalErr != nil { + return fmt.Errorf("failed to normalize slack payload: %w", marshalErr) + } + body.Reset() + if _, writeErr := body.Write(normalizedBody); writeErr != nil { + return fmt.Errorf("failed to write normalized slack payload: %w", writeErr) + } + } else { + return fmt.Errorf("slack payload requires 'text' or 'blocks' field") + } } } case "gotify": @@ -470,7 +509,7 @@ func (s *NotificationService) sendJSONPayload(ctx context.Context, p models.Noti } } - if providerType == "gotify" || providerType == "webhook" || providerType == "telegram" { + if providerType == "gotify" || providerType == "webhook" || providerType == "telegram" || providerType == "slack" { headers := map[string]string{ "Content-Type": "application/json", "User-Agent": "Charon-Notify/1.0", @@ -516,6 +555,17 @@ func (s *NotificationService) sendJSONPayload(ctx context.Context, p models.Noti body.Write(updatedBody) } + if providerType == "slack" { + decryptedWebhookURL := p.Token + if strings.TrimSpace(decryptedWebhookURL) == "" { + return fmt.Errorf("slack webhook URL is not configured") + } + if validateErr := s.validateSlackURL(decryptedWebhookURL); validateErr != nil { + return validateErr + } + dispatchURL = decryptedWebhookURL + } + if _, sendErr := s.httpWrapper.Send(ctx, notifications.HTTPWrapperRequest{ URL: dispatchURL, Headers: headers, @@ -739,7 +789,17 @@ func (s *NotificationService) CreateProvider(provider *models.NotificationProvid return err } - if provider.Type != "gotify" && provider.Type != "telegram" { + if provider.Type == "slack" { + token := strings.TrimSpace(provider.Token) + if token == "" { + return fmt.Errorf("slack webhook URL is required") + } + if err := s.validateSlackURL(token); err != nil { + return err + } + } + + if provider.Type != "gotify" && provider.Type != "telegram" && provider.Type != "slack" { provider.Token = "" } @@ -775,7 +835,7 @@ func (s *NotificationService) UpdateProvider(provider *models.NotificationProvid return err } - if provider.Type == "gotify" || provider.Type == "telegram" { + if provider.Type == "gotify" || provider.Type == "telegram" || provider.Type == "slack" { if strings.TrimSpace(provider.Token) == "" { provider.Token = existing.Token } @@ -783,6 +843,12 @@ func (s *NotificationService) UpdateProvider(provider *models.NotificationProvid provider.Token = "" } + if provider.Type == "slack" && provider.Token != existing.Token { + if err := s.validateSlackURL(strings.TrimSpace(provider.Token)); err != nil { + return err + } + } + // Validate custom template before saving if strings.ToLower(strings.TrimSpace(provider.Template)) == "custom" && strings.TrimSpace(provider.Config) != "" { payload := map[string]any{"Title": "Preview", "Message": "Preview", "Time": time.Now().Format(time.RFC3339), "EventType": "preview"} diff --git a/backend/internal/services/notification_service_discord_only_test.go b/backend/internal/services/notification_service_discord_only_test.go index 9fb9b19b2..8ca4b9ff0 100644 --- a/backend/internal/services/notification_service_discord_only_test.go +++ b/backend/internal/services/notification_service_discord_only_test.go @@ -22,7 +22,7 @@ func TestDiscordOnly_CreateProviderRejectsUnsupported(t *testing.T) { service := NewNotificationService(db, nil) - testCases := []string{"slack", "generic"} + testCases := []string{"generic"} for _, providerType := range testCases { t.Run(providerType, func(t *testing.T) { diff --git a/backend/internal/services/notification_service_json_test.go b/backend/internal/services/notification_service_json_test.go index 1e3d9dc96..7c84a3e36 100644 --- a/backend/internal/services/notification_service_json_test.go +++ b/backend/internal/services/notification_service_json_test.go @@ -193,11 +193,12 @@ func TestSendJSONPayload_Slack(t *testing.T) { db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{}) require.NoError(t, err) - svc := NewNotificationService(db, nil) + svc := NewNotificationService(db, nil, WithSlackURLValidator(func(string) error { return nil })) provider := models.NotificationProvider{ Type: "slack", - URL: server.URL, + URL: "#test", + Token: server.URL, Template: "custom", Config: `{"text": {{toJSON .Message}}}`, } diff --git a/backend/internal/services/notification_service_test.go b/backend/internal/services/notification_service_test.go index d79f7b50a..35c8f293b 100644 --- a/backend/internal/services/notification_service_test.go +++ b/backend/internal/services/notification_service_test.go @@ -516,14 +516,16 @@ func TestNotificationService_TestProvider_Errors(t *testing.T) { assert.Error(t, err) }) - t.Run("slack type not supported", func(t *testing.T) { + t.Run("slack with missing webhook URL", func(t *testing.T) { provider := models.NotificationProvider{ - Type: "slack", - URL: "https://hooks.slack.com/services/INVALID/WEBHOOK/URL", + Type: "slack", + URL: "#alerts", + Token: "", + Template: "minimal", } err := svc.TestProvider(provider) assert.Error(t, err) - assert.Contains(t, err.Error(), "unsupported provider type") + assert.Contains(t, err.Error(), "slack webhook URL is not configured") }) t.Run("webhook success", func(t *testing.T) { @@ -1451,17 +1453,14 @@ func TestSendJSONPayload_ServiceSpecificValidation(t *testing.T) { }) t.Run("slack_requires_text_or_blocks", func(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - })) - defer server.Close() + subSvc := NewNotificationService(db, nil, WithSlackURLValidator(func(string) error { return nil })) - // Slack without text or blocks should fail provider := models.NotificationProvider{ Type: "slack", - URL: server.URL, + URL: "#test", + Token: "https://hooks.slack.com/services/T00/B00/xxx", Template: "custom", - Config: `{"message": {{toJSON .Message}}}`, // Missing text/blocks + Config: `{"username": "Charon"}`, } data := map[string]any{ "Title": "Test", @@ -1470,7 +1469,7 @@ func TestSendJSONPayload_ServiceSpecificValidation(t *testing.T) { "EventType": "test", } - err := svc.sendJSONPayload(context.Background(), provider, data) + err := subSvc.sendJSONPayload(context.Background(), provider, data) require.Error(t, err) assert.Contains(t, err.Error(), "slack payload requires 'text' or 'blocks' field") }) @@ -1480,10 +1479,12 @@ func TestSendJSONPayload_ServiceSpecificValidation(t *testing.T) { w.WriteHeader(http.StatusOK) })) defer server.Close() + subSvc := NewNotificationService(db, nil, WithSlackURLValidator(func(string) error { return nil })) provider := models.NotificationProvider{ Type: "slack", - URL: server.URL, + URL: "#test", + Token: server.URL, Template: "custom", Config: `{"text": {{toJSON .Message}}}`, } @@ -1494,7 +1495,7 @@ func TestSendJSONPayload_ServiceSpecificValidation(t *testing.T) { "EventType": "test", } - err := svc.sendJSONPayload(context.Background(), provider, data) + err := subSvc.sendJSONPayload(context.Background(), provider, data) require.NoError(t, err) }) @@ -1503,10 +1504,12 @@ func TestSendJSONPayload_ServiceSpecificValidation(t *testing.T) { w.WriteHeader(http.StatusOK) })) defer server.Close() + subSvc := NewNotificationService(db, nil, WithSlackURLValidator(func(string) error { return nil })) provider := models.NotificationProvider{ Type: "slack", - URL: server.URL, + URL: "#test", + Token: server.URL, Template: "custom", Config: `{"blocks": [{"type": "section", "text": {"type": "mrkdwn", "text": {{toJSON .Message}}}}]}`, } @@ -1517,7 +1520,7 @@ func TestSendJSONPayload_ServiceSpecificValidation(t *testing.T) { "EventType": "test", } - err := svc.sendJSONPayload(context.Background(), provider, data) + err := subSvc.sendJSONPayload(context.Background(), provider, data) require.NoError(t, err) }) @@ -1826,7 +1829,6 @@ func TestTestProvider_NotifyOnlyRejectsUnsupportedProvider(t *testing.T) { providerType string url string }{ - {"slack", "slack", "https://hooks.slack.com/services/T/B/X"}, {"pushover", "pushover", "pushover://token@user"}, } @@ -3169,3 +3171,444 @@ func TestIsDispatchEnabled_TelegramDisabledByFlag(t *testing.T) { db.Create(&models.Setting{Key: "feature.notifications.service.telegram.enabled", Value: "false"}) assert.False(t, svc.isDispatchEnabled("telegram")) } + +// --- Slack Notification Provider Tests --- + +func TestSlackWebhookURLValidation(t *testing.T) { + tests := []struct { + name string + url string + wantErr bool + }{ + {"valid_url", "https://hooks.slack.com/services/T00000000/B00000000/abcdefghijklmnop", false}, + {"valid_url_with_dashes", "https://hooks.slack.com/services/T0-A_z/B0-A_z/abc-def_123", false}, + {"http_scheme", "http://hooks.slack.com/services/T00000000/B00000000/abcdefghijklmnop", true}, + {"wrong_host", "https://evil.com/services/T00000000/B00000000/abcdefghijklmnop", true}, + {"ip_address", "https://192.168.1.1/services/T00000000/B00000000/abcdefghijklmnop", true}, + {"missing_T_prefix", "https://hooks.slack.com/services/X00000000/B00000000/abcdefghijklmnop", true}, + {"missing_B_prefix", "https://hooks.slack.com/services/T00000000/X00000000/abcdefghijklmnop", true}, + {"query_params", "https://hooks.slack.com/services/T00000000/B00000000/abcdefghijklmnop?token=leak", true}, + {"empty_string", "", true}, + {"just_host", "https://hooks.slack.com", true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateSlackWebhookURL(tt.url) + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestSlackWebhookURLValidation_RejectsHTTP(t *testing.T) { + err := validateSlackWebhookURL("http://hooks.slack.com/services/T00000/B00000/token123") + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid Slack webhook URL") +} + +func TestSlackWebhookURLValidation_RejectsIPAddress(t *testing.T) { + err := validateSlackWebhookURL("https://192.168.1.1/services/T00000/B00000/token123") + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid Slack webhook URL") +} + +func TestSlackWebhookURLValidation_RejectsWrongHost(t *testing.T) { + err := validateSlackWebhookURL("https://evil.com/services/T00000/B00000/token123") + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid Slack webhook URL") +} + +func TestSlackWebhookURLValidation_RejectsQueryParams(t *testing.T) { + err := validateSlackWebhookURL("https://hooks.slack.com/services/T00000/B00000/token123?token=leak") + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid Slack webhook URL") +} + +func TestNotificationService_CreateProvider_Slack(t *testing.T) { + db := setupNotificationTestDB(t) + svc := NewNotificationService(db, nil) + + provider := &models.NotificationProvider{ + Name: "Slack Alerts", + Type: "slack", + URL: "#alerts", + Token: "https://hooks.slack.com/services/T00000/B00000/xxxx", + } + err := svc.CreateProvider(provider) + require.NoError(t, err) + + var saved models.NotificationProvider + require.NoError(t, db.Where("id = ?", provider.ID).First(&saved).Error) + assert.Equal(t, "https://hooks.slack.com/services/T00000/B00000/xxxx", saved.Token) + assert.Equal(t, "#alerts", saved.URL) + assert.Equal(t, "slack", saved.Type) +} + +func TestNotificationService_CreateProvider_Slack_ClearsTokenField(t *testing.T) { + db := setupNotificationTestDB(t) + svc := NewNotificationService(db, nil) + + provider := &models.NotificationProvider{ + Name: "Webhook Test", + Type: "webhook", + URL: "https://example.com/hook", + Token: "should-be-cleared", + } + err := svc.CreateProvider(provider) + require.NoError(t, err) + + var saved models.NotificationProvider + require.NoError(t, db.Where("id = ?", provider.ID).First(&saved).Error) + assert.Empty(t, saved.Token) +} + +func TestNotificationService_UpdateProvider_Slack_PreservesToken(t *testing.T) { + db := setupNotificationTestDB(t) + svc := NewNotificationService(db, nil) + + existing := models.NotificationProvider{ + ID: "prov-slack-token", + Type: "slack", + Name: "Slack Alerts", + URL: "#alerts", + Token: "https://hooks.slack.com/services/T00000/B00000/xxxx", + } + require.NoError(t, db.Create(&existing).Error) + + update := models.NotificationProvider{ + ID: "prov-slack-token", + Type: "slack", + Name: "Slack Alerts Updated", + URL: "#general", + Token: "", + } + err := svc.UpdateProvider(&update) + require.NoError(t, err) + assert.Equal(t, "https://hooks.slack.com/services/T00000/B00000/xxxx", update.Token) +} + +func TestNotificationService_TestProvider_Slack(t *testing.T) { + db := setupNotificationTestDB(t) + + var capturedBody []byte + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedBody, _ = io.ReadAll(r.Body) + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok")) + })) + defer server.Close() + + svc := NewNotificationService(db, nil, WithSlackURLValidator(func(string) error { return nil })) + + provider := models.NotificationProvider{ + Type: "slack", + URL: "#test", + Token: server.URL, + Template: "minimal", + } + + err := svc.TestProvider(provider) + require.NoError(t, err) + + var payload map[string]any + require.NoError(t, json.Unmarshal(capturedBody, &payload)) + assert.NotEmpty(t, payload["text"]) +} + +func TestNotificationService_SendExternal_Slack(t *testing.T) { + db := setupNotificationTestDB(t) + _ = db.AutoMigrate(&models.Setting{}) + + received := make(chan []byte, 1) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + received <- body + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok")) + })) + defer server.Close() + + svc := NewNotificationService(db, nil, WithSlackURLValidator(func(string) error { return nil })) + + provider := models.NotificationProvider{ + Name: "Slack E2E", + Type: "slack", + URL: "#alerts", + Token: server.URL, + Enabled: true, + NotifyProxyHosts: true, + Template: "minimal", + } + require.NoError(t, svc.CreateProvider(&provider)) + + svc.SendExternal(context.Background(), "proxy_host", "Title", "Message", nil) + + select { + case body := <-received: + var payload map[string]any + require.NoError(t, json.Unmarshal(body, &payload)) + assert.NotEmpty(t, payload["text"]) + case <-time.After(2 * time.Second): + t.Fatal("Timed out waiting for slack webhook") + } +} + +func TestNotificationService_Slack_PayloadNormalizesMessageToText(t *testing.T) { + db := setupNotificationTestDB(t) + + var capturedBody []byte + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedBody, _ = io.ReadAll(r.Body) + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok")) + })) + defer server.Close() + + svc := NewNotificationService(db, nil, WithSlackURLValidator(func(string) error { return nil })) + + provider := models.NotificationProvider{ + Type: "slack", + URL: "#test", + Token: server.URL, + Template: "custom", + Config: `{"message": {{toJSON .Message}}}`, + } + data := map[string]any{ + "Title": "Test", + "Message": "Normalize me", + "Time": time.Now().Format(time.RFC3339), + "EventType": "test", + } + + err := svc.sendJSONPayload(context.Background(), provider, data) + require.NoError(t, err) + + var payload map[string]any + require.NoError(t, json.Unmarshal(capturedBody, &payload)) + assert.Equal(t, "Normalize me", payload["text"]) +} + +func TestNotificationService_Slack_PayloadRequiresTextOrBlocks(t *testing.T) { + db := setupNotificationTestDB(t) + + svc := NewNotificationService(db, nil, WithSlackURLValidator(func(string) error { return nil })) + + provider := models.NotificationProvider{ + Type: "slack", + URL: "#test", + Token: "https://hooks.slack.com/services/T00/B00/xxx", + Template: "custom", + Config: `{"title": {{toJSON .Title}}}`, + } + data := map[string]any{ + "Title": "Test", + "Message": "Test Message", + "Time": time.Now().Format(time.RFC3339), + "EventType": "test", + } + + err := svc.sendJSONPayload(context.Background(), provider, data) + require.Error(t, err) + assert.Contains(t, err.Error(), "slack payload requires 'text' or 'blocks' field") +} + +func TestFlagSlackServiceEnabled_ConstantValue(t *testing.T) { + assert.Equal(t, "feature.notifications.service.slack.enabled", notifications.FlagSlackServiceEnabled) +} + +func TestNotificationService_Slack_IsDispatchEnabled(t *testing.T) { + db := setupNotificationTestDB(t) + _ = db.AutoMigrate(&models.Setting{}) + svc := NewNotificationService(db, nil) + + assert.True(t, svc.isDispatchEnabled("slack")) + + db.Create(&models.Setting{Key: "feature.notifications.service.slack.enabled", Value: "false"}) + assert.False(t, svc.isDispatchEnabled("slack")) +} + +func TestNotificationService_Slack_TokenNotExposedInList(t *testing.T) { + db := setupNotificationTestDB(t) + svc := NewNotificationService(db, nil) + + provider := &models.NotificationProvider{ + Name: "Slack Secret", + Type: "slack", + URL: "#secret", + Token: "https://hooks.slack.com/services/T00000/B00000/secrettoken", + } + require.NoError(t, svc.CreateProvider(provider)) + + providers, err := svc.ListProviders() + require.NoError(t, err) + require.Len(t, providers, 1) + + providers[0].HasToken = providers[0].Token != "" + providers[0].Token = "" + assert.True(t, providers[0].HasToken) + assert.Empty(t, providers[0].Token) +} + +func TestSendJSONPayload_Slack_EmptyWebhookURLReturnsError(t *testing.T) { + db := setupNotificationTestDB(t) + svc := NewNotificationService(db, nil) + + provider := models.NotificationProvider{ + Type: "slack", + URL: "#alerts", + Token: "", + Template: "minimal", + } + data := map[string]any{ + "Title": "Test", + "Message": "Should fail before dispatch", + "Time": time.Now().Format(time.RFC3339), + "EventType": "test", + } + + err := svc.sendJSONPayload(context.Background(), provider, data) + require.Error(t, err) + assert.Contains(t, err.Error(), "slack webhook URL is not configured") +} + +func TestSendJSONPayload_Slack_WhitespaceOnlyWebhookURLReturnsError(t *testing.T) { + db := setupNotificationTestDB(t) + svc := NewNotificationService(db, nil) + + provider := models.NotificationProvider{ + Type: "slack", + URL: "#alerts", + Token: " ", + Template: "minimal", + } + data := map[string]any{ + "Title": "Test", + "Message": "Should fail before dispatch", + "Time": time.Now().Format(time.RFC3339), + "EventType": "test", + } + + err := svc.sendJSONPayload(context.Background(), provider, data) + require.Error(t, err) + assert.Contains(t, err.Error(), "slack webhook URL is not configured") +} + +func TestSendJSONPayload_Slack_InvalidWebhookURLReturnsValidationError(t *testing.T) { + db := setupNotificationTestDB(t) + svc := NewNotificationService(db, nil) + + provider := models.NotificationProvider{ + Type: "slack", + URL: "#alerts", + Token: "https://evil.com/not-a-slack-webhook", + Template: "minimal", + } + data := map[string]any{ + "Title": "Test", + "Message": "Should fail URL validation", + "Time": time.Now().Format(time.RFC3339), + "EventType": "test", + } + + err := svc.sendJSONPayload(context.Background(), provider, data) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid Slack webhook URL") +} + +func TestCreateProvider_Slack_EmptyTokenRejected(t *testing.T) { + db := setupNotificationTestDB(t) + svc := NewNotificationService(db, nil) + + provider := &models.NotificationProvider{ + Name: "Slack Missing Token", + Type: "slack", + URL: "#alerts", + Token: "", + } + err := svc.CreateProvider(provider) + require.Error(t, err) + assert.Contains(t, err.Error(), "slack webhook URL is required") +} + +func TestCreateProvider_Slack_WhitespaceOnlyTokenRejected(t *testing.T) { + db := setupNotificationTestDB(t) + svc := NewNotificationService(db, nil) + + provider := &models.NotificationProvider{ + Name: "Slack Whitespace Token", + Type: "slack", + URL: "#alerts", + Token: " ", + } + err := svc.CreateProvider(provider) + require.Error(t, err) + assert.Contains(t, err.Error(), "slack webhook URL is required") +} + +func TestCreateProvider_Slack_InvalidTokenRejected(t *testing.T) { + db := setupNotificationTestDB(t) + svc := NewNotificationService(db, nil) + + provider := &models.NotificationProvider{ + Name: "Slack Bad Token", + Type: "slack", + URL: "#alerts", + Token: "https://evil.com/not-a-slack-webhook", + } + err := svc.CreateProvider(provider) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid Slack webhook URL") +} + +func TestUpdateProvider_Slack_InvalidNewTokenRejected(t *testing.T) { + db := setupNotificationTestDB(t) + svc := NewNotificationService(db, nil) + + existing := models.NotificationProvider{ + ID: "prov-slack-update-invalid", + Type: "slack", + Name: "Slack Alerts", + URL: "#alerts", + Token: "https://hooks.slack.com/services/T00000/B00000/xxxx", + } + require.NoError(t, db.Create(&existing).Error) + + update := models.NotificationProvider{ + ID: "prov-slack-update-invalid", + Type: "slack", + Name: "Slack Alerts", + URL: "#alerts", + Token: "https://evil.com/not-a-slack-webhook", + } + err := svc.UpdateProvider(&update) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid Slack webhook URL") +} + +func TestUpdateProvider_Slack_UnchangedTokenSkipsValidation(t *testing.T) { + db := setupNotificationTestDB(t) + svc := NewNotificationService(db, nil) + + existing := models.NotificationProvider{ + ID: "prov-slack-update-unchanged", + Type: "slack", + Name: "Slack Alerts", + URL: "#alerts", + Token: "https://hooks.slack.com/services/T00000/B00000/xxxx", + } + require.NoError(t, db.Create(&existing).Error) + + // Submitting empty token causes fallback to existing — should not re-validate + update := models.NotificationProvider{ + ID: "prov-slack-update-unchanged", + Type: "slack", + Name: "Slack Alerts Renamed", + URL: "#general", + Token: "", + } + err := svc.UpdateProvider(&update) + require.NoError(t, err) +} diff --git a/docs/features/notifications.md b/docs/features/notifications.md index 6dc421dd5..c8d7dc3f3 100644 --- a/docs/features/notifications.md +++ b/docs/features/notifications.md @@ -16,6 +16,7 @@ Notifications can be triggered by various events: | Service | JSON Templates | Native API | Rich Formatting | |---------|----------------|------------|-----------------| | **Discord** | ✅ Yes | ✅ Webhooks | ✅ Embeds | +| **Slack** | ✅ Yes | ✅ Webhooks | ✅ Native Formatting | | **Gotify** | ✅ Yes | ✅ HTTP API | ✅ Priority + Extras | | **Custom Webhook** | ✅ Yes | ✅ HTTP API | ✅ Template-Controlled | | **Email** | ❌ No | ✅ SMTP | ✅ HTML Branded Templates | @@ -36,8 +37,6 @@ Email notifications send HTML-branded alerts directly to one or more email addre Email notifications use built-in HTML templates with Charon branding — no JSON template editing is required. -> **Feature Flag:** Email notifications must be enabled via `feature.notifications.service.email.enabled` in **Settings** → **Feature Flags** before the Email provider option appears. - ### Why JSON Templates? JSON templates give you complete control over notification formatting, allowing you to: @@ -60,7 +59,7 @@ JSON templates give you complete control over notification formatting, allowing ### JSON Template Support -For JSON-based services (Discord, Gotify, and Custom Webhook), you can choose from three template options. Email uses its own built-in HTML templates and does not use JSON templates. +For JSON-based services (Discord, Slack, Gotify, and Custom Webhook), you can choose from three template options. Email uses its own built-in HTML templates and does not use JSON templates. #### 1. Minimal Template (Default) @@ -174,11 +173,51 @@ Discord supports rich embeds with colors, fields, and timestamps. - `16776960` - Yellow (warning) - `3066993` - Green (success) +### Slack Webhooks + +Slack notifications send messages to a channel using an Incoming Webhook URL. + +**Setup:** + +1. In Slack, go to **[Your Apps](https://api.slack.com/apps)** → **Create New App** → **From scratch** +2. Under **Features**, select **Incoming Webhooks** and toggle it **on** +3. Click **"Add New Webhook to Workspace"** and choose the channel to post to +4. Copy the Webhook URL (it looks like `https://hooks.slack.com/services/T.../B.../...`) +5. In Charon, go to **Settings** → **Notifications** and click **"Add Provider"** +6. Select **Slack** as the service type +7. Paste your Webhook URL into the **Webhook URL** field +8. Optionally enter a channel display name (e.g., `#alerts`) for easy identification +9. Configure notification triggers and save + +> **Security:** Your Webhook URL is stored securely and is never exposed in API responses. The settings page only shows a `has_token: true` indicator, so your URL stays private even if someone gains read-only access to the API. + +#### Basic Message + +```json +{ + "text": "{{.Title}}: {{.Message}}" +} +``` + +#### Formatted Message with Context + +```json +{ + "text": "*{{.Title}}*\n{{.Message}}\n\n• *Event:* {{.EventType}}\n• *Host:* {{.HostName}}\n• *Severity:* {{.Severity}}\n• *Time:* {{.Timestamp}}" +} +``` + +**Slack formatting tips:** + +- Use `*bold*` for emphasis +- Use `\n` for line breaks +- Use `•` for bullet points +- Slack automatically linkifies URLs + ## Planned Provider Expansion -Additional providers (for example Slack and Telegram) are planned for later -staged releases. This page will be expanded as each provider is validated and -released. +Additional providers (for example Telegram) are planned for later staged +releases. This page will be expanded as each provider is validated and released. ## Template Variables @@ -341,6 +380,7 @@ Use separate Discord providers for different event types: Be mindful of service limits: - **Discord**: 5 requests per 2 seconds per webhook +- **Slack**: 1 request per second per webhook - **Email**: Subject to your SMTP server's sending limits ### 6. Keep Templates Maintainable diff --git a/docs/getting-started.md b/docs/getting-started.md index f4ac30761..baf712921 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -21,6 +21,24 @@ Imagine you have several apps running on your computer. Maybe a blog, a file sto ## Step 1: Install Charon +### Required Secrets (Generate Before Installing) + +Two secrets must be set before starting Charon. Omitting them will cause **sessions to reset on every container restart**, locking users out. + +Generate both values now and keep them somewhere safe: + +```bash +# JWT secret — signs and validates login sessions +openssl rand -hex 32 + +# Encryption key — protects stored credentials at rest +openssl rand -base64 32 +``` + +> **Why this matters:** If `CHARON_JWT_SECRET` is not set, Charon generates a random key on each boot. Any active login session becomes invalid the moment the container restarts, producing a "Session validation failed" error. + +--- + ### Option A: Docker Compose (Easiest) Create a file called `docker-compose.yml`: @@ -43,6 +61,8 @@ services: - /var/run/docker.sock:/var/run/docker.sock:ro environment: - CHARON_ENV=production + - CHARON_JWT_SECRET= + - CHARON_ENCRYPTION_KEY= ``` Then run: @@ -64,6 +84,8 @@ docker run -d \ -v ./charon-data:/app/data \ -v /var/run/docker.sock:/var/run/docker.sock:ro \ -e CHARON_ENV=production \ + -e CHARON_JWT_SECRET= \ + -e CHARON_ENCRYPTION_KEY= \ wikid82/charon:latest ``` @@ -78,6 +100,8 @@ docker run -d \ -v ./charon-data:/app/data \ -v /var/run/docker.sock:/var/run/docker.sock:ro \ -e CHARON_ENV=production \ + -e CHARON_JWT_SECRET= \ + -e CHARON_ENCRYPTION_KEY= \ ghcr.io/wikid82/charon:latest ``` diff --git a/docs/issues/slack-manual-testing.md b/docs/issues/slack-manual-testing.md new file mode 100644 index 000000000..7a2fc3735 --- /dev/null +++ b/docs/issues/slack-manual-testing.md @@ -0,0 +1,76 @@ +--- +title: "Manual Testing: Slack Notification Provider" +labels: + - testing + - feature + - frontend + - backend +priority: medium +milestone: "v0.2.0-beta.2" +assignees: [] +--- + +# Manual Testing: Slack Notification Provider + +## Description + +Manual test plan for the Slack notification provider feature. Covers scenarios that automated E2E tests cannot fully validate, such as real Slack workspace delivery, message formatting, and edge cases around webhook lifecycle. + +## Pre-requisites + +- A Slack workspace with at least one channel +- An Incoming Webhook URL created via Slack App configuration (https://api.slack.com/messaging/webhooks) +- Access to the Charon instance + +## Test Cases + +### Provider CRUD + +- [ ] **Create**: Add a Slack provider with a valid webhook URL and optional channel name (`#alerts`) +- [ ] **Edit**: Change the channel display name — verify webhook URL is preserved (not cleared) +- [ ] **Test**: Click "Send Test Notification" — verify message appears in Slack channel +- [ ] **Delete**: Remove the Slack provider — verify it no longer appears in the list +- [ ] **Re-create**: Add a new Slack provider after deletion — verify clean state + +### Security + +- [ ] Webhook URL is NOT visible in the provider list UI (only `has_token: true` indicator) +- [ ] Webhook URL is NOT returned in GET `/api/v1/notifications/providers` response body +- [ ] Editing an existing provider does NOT expose the webhook URL in any form field +- [ ] Browser DevTools Network tab shows no webhook URL in any API response + +### Message Delivery + +- [ ] Default template sends a readable notification to Slack +- [ ] Custom JSON template with `text` field renders correctly +- [ ] Custom JSON template with `blocks` renders Block Kit layout +- [ ] Notifications triggered by proxy host changes arrive in Slack +- [ ] Notifications triggered by certificate events arrive in Slack +- [ ] Notifications triggered by uptime events arrive in Slack (if enabled) + +### Error Handling + +- [ ] Invalid webhook URL (not matching `hooks.slack.com/services/` pattern) shows validation error +- [ ] Expired/revoked webhook URL returns `no_service` classification error +- [ ] Disabled feature flag (`feature.notifications.service.slack.enabled=false`) prevents Slack dispatch + +### Edge Cases + +- [ ] Creating provider with empty URL field succeeds (URL is optional channel display name) +- [ ] Very long channel name in URL field is handled gracefully +- [ ] Multiple Slack providers with different webhooks can coexist +- [ ] Switching provider type from Slack to Discord clears the token field appropriately +- [ ] Switching provider type from Discord to Slack shows the webhook URL input field + +### Cross-Browser + +- [ ] Provider CRUD works in Chrome/Chromium +- [ ] Provider CRUD works in Firefox +- [ ] Provider CRUD works in Safari/WebKit + +## Acceptance Criteria + +- [ ] All security test cases pass — webhook URL never exposed +- [ ] End-to-end message delivery confirmed in a real Slack workspace +- [ ] No console errors during any provider operations +- [ ] Feature flag correctly gates Slack functionality diff --git a/docs/issues/vite-8-beta-manual-testing.md b/docs/issues/vite-8-beta-manual-testing.md new file mode 100644 index 000000000..87bc68715 --- /dev/null +++ b/docs/issues/vite-8-beta-manual-testing.md @@ -0,0 +1,81 @@ +# Manual Testing: Vite 8.0.0-beta.18 Upgrade + +**Date:** 2026-03-12 +**Status:** Open +**Priority:** Medium +**Related Commit:** chore(frontend): upgrade to Vite 8 beta with Rolldown bundler + +--- + +## Context + +Vite 8 replaces Rollup with Rolldown (Rust-based bundler) and esbuild with Oxc for +JS transforms/minification. Lightning CSS replaces esbuild for CSS minification. These +are fundamental changes to the build pipeline that automated tests may not fully cover. + +## Manual Test Cases + +### 1. Production Build Output Verification + +- [ ] Deploy the Docker image to a staging environment +- [ ] Verify the application loads without console errors +- [ ] Verify all CSS renders correctly (Lightning CSS minification change) +- [ ] Check browser DevTools Network tab — confirm single JS bundle loads +- [ ] Verify sourcemaps work correctly in browser DevTools + +### 2. CJS Interop Regression Check + +Vite 8 changes how CommonJS default exports are handled. + +- [ ] Verify axios API calls succeed (login, proxy host CRUD, settings) +- [ ] Verify react-hot-toast notifications render on success/error actions +- [ ] Verify react-hook-form validation works on all forms +- [ ] Verify @tanstack/react-query data fetching and caching works + +### 3. Dynamic Import / Code Splitting + +The `codeSplitting: false` config replaces the old `inlineDynamicImports: true`. + +- [ ] Verify lazy-loaded routes load correctly +- [ ] Verify no "chunk load failed" errors during navigation +- [ ] Check that the React initialization issue (original reason for the workaround) does not resurface + +### 4. Development Server + +- [ ] Run `npm run dev` in frontend — verify HMR (Hot Module Replacement) works +- [ ] Make a CSS change — verify it hot-reloads without full page refresh +- [ ] Make a React component change — verify it hot-reloads preserving state +- [ ] Verify the dev server proxy to backend API still works + +### 5. Cross-Browser Verification + +Test in each browser to catch any Rolldown/Oxc output differences: + +- [ ] Chrome/Chromium — full functional test +- [ ] Firefox — full functional test +- [ ] Safari/WebKit — full functional test + +### 6. Docker Build Verification + +- [ ] Build Docker image on the target deployment architecture +- [ ] Verify the image starts and passes health checks +- [ ] Verify Rolldown native bindings resolve correctly (no missing .node errors) +- [ ] Test with `--platform=linux/amd64` explicitly + +### 7. Edge Cases + +- [ ] Test with browser cache cleared (ensure no stale Vite 7 chunks cached) +- [ ] Test login flow end-to-end +- [ ] Test certificate management flows +- [ ] Test DNS provider configuration +- [ ] Test access list creation and assignment + +## Known Issues to Monitor + +1. **Oxc Minifier assumptions** — if runtime errors occur after build but not in dev, the minifier is the likely cause. Disable with `build.minify: false` to diagnose. +2. **Lightning CSS bundle size** — may differ slightly from esbuild. Compare `dist/assets/` sizes. +3. **Beta software stability** — track Vite 8 releases for fixes to any issues found. + +## Pass Criteria + +All checkboxes above must be verified. Any failure should be filed as a separate issue with the `vite-8-beta` label. diff --git a/docs/plans/archive/cve_remediation_spec.md b/docs/plans/archive/cve_remediation_spec.md new file mode 100644 index 000000000..e05b3fa45 --- /dev/null +++ b/docs/plans/archive/cve_remediation_spec.md @@ -0,0 +1,282 @@ +# CI Supply Chain CVE Remediation Plan + +**Status:** Active +**Created:** 2026-03-13 +**Branch:** `feature/beta-release` +**Context:** Three HIGH vulnerabilities (CVE-2025-69650, CVE-2025-69649, CVE-2026-3805) in the Docker runtime image are blocking the CI supply-chain scan. Two Grype ignore-rule entries are also expired and require maintenance. + +--- + +## 1. Executive Summary + +| # | Action | Severity Reduction | Effort | +|---|--------|--------------------|--------| +| 1 | Remove `curl` from runtime image (replace with `wget`) | Eliminates 1 HIGH + ~7 MEDIUMs + 2 LOWs | ~30 min | +| 2 | Remove `binutils` + `libc-utils` from runtime image | Eliminates 2 HIGH + 3 MEDIUMs | ~5 min | +| 3 | Update expired Grype ignore rules | Prevents false scan failures at next run | ~10 min | + +**Bottom line:** All three HIGH CVEs are eliminated at root rather than suppressed. After Phase 1 and Phase 2, `fail-on-severity: high` passes cleanly. Phase 3 is maintenance-only. + +--- + +## 2. CVE Inventory + +### Blocking HIGH CVEs + +| CVE | Package | Version | CVSS | Fix State | Notes | +|-----|---------|---------|------|-----------|-------| +| CVE-2026-3805 | `curl` | 8.17.0-r1 | 7.5 | `unknown` | **New** — appeared in Grype DB 2026-03-13, published 2026-03-11. SMB protocol use-after-free. Charon uses HTTPS/HTTP only. | +| CVE-2025-69650 | `binutils` | 2.45.1-r0 | 7.5 | `` (none) | Double-free in `readelf`. Charon never invokes `readelf`. | +| CVE-2025-69649 | `binutils` | 2.45.1-r0 | 7.5 | `` (none) | Null-ptr deref in `readelf`. Charon never invokes `readelf`. | + +### Associated MEDIUM/LOW CVEs eliminated as side-effects + +| CVEs | Package | Count | Eliminated by | +|------|---------|-------|---------------| +| CVE-2025-14819, CVE-2025-15079, CVE-2025-14524, CVE-2025-13034, CVE-2025-14017 | `curl` | 5 × MEDIUM | Phase 1 | +| CVE-2025-69652, CVE-2025-69644, CVE-2025-69651 | `binutils` | 3 × MEDIUM | Phase 2 | + +### Expired Grype Ignore Rules + +| Entry | Expiry | Status | Action | +|-------|--------|--------|--------| +| `CVE-2026-22184` (zlib) | 2026-03-14 | Expires tomorrow; underlying CVE already fixed via `apk upgrade --no-cache zlib` | **Remove entirely** | +| `GHSA-69x3-g4r3-p962` (nebula) | 2026-03-05 | **Expired 8 days ago**; upstream fix still unavailable | **Extend to 2026-04-13** | + +--- + +## 3. Phase 1 — Remove `curl` from Runtime Image + +### Rationale + +`curl` is present solely for: +1. GeoLite2 DB download at build time (Dockerfile, runtime stage `RUN` block) +2. HEALTHCHECK probe (Dockerfile `HEALTHCHECK` directive) +3. Caddy admin API readiness poll (`.docker/docker-entrypoint.sh`) + +`busybox` (already installed on Alpine as a transitive dependency of `busybox-extras`, which is explicitly installed) provides `wget` with sufficient functionality for all three uses. + +### 3.1 `wget` Translation Reference + +| `curl` invocation | `wget` equivalent | Notes | +|-------------------|--------------------|-------| +| `curl -fSL -m 10 "URL" -o FILE 2>/dev/null` | `wget -qO FILE -T 10 "URL" 2>/dev/null` | `-q` = quiet; `-T` = timeout (seconds); exits nonzero on failure | +| `curl -fSL -m 30 --retry 3 "URL" -o FILE` | `wget -qO FILE -T 30 -t 4 "URL"` | `-t 4` = 4 total tries (1 initial + 3 retries); add `&& [ -s FILE ]` guard | +| `curl -f http://HOST/path \|\| exit 1` | `wget -q -O /dev/null http://HOST/path \|\| exit 1` | HEALTHCHECK; wget exits nonzero on HTTP error | +| `curl -sf http://HOST/path > /dev/null 2>&1` | `wget -qO /dev/null http://HOST/path 2>/dev/null` | Silent readiness probe | + +**busybox wget notes:** +- `-T N` is per-connection timeout in seconds (equivalent to `curl --max-time`). +- `-t N` is total number of tries, not retries; `-t 4` = 3 retries. +- On download failure, busybox wget may leave a zero-byte or partial file at the output path. The `[ -s FILE ]` guard (`-s` = non-empty) prevents a corrupted placeholder from passing the sha256 check. + +### 3.2 Dockerfile Changes + +**File:** `Dockerfile` + +**Change A — Remove `curl`, `binutils`, `libc-utils` from `apk add` (runtime stage, line ~413):** + +Current: +```dockerfile +RUN apk add --no-cache \ + bash ca-certificates sqlite-libs sqlite tzdata curl gettext libcap libcap-utils \ + c-ares binutils libc-utils busybox-extras \ + && apk upgrade --no-cache zlib +``` + +New: +```dockerfile +RUN apk add --no-cache \ + bash ca-certificates sqlite-libs sqlite tzdata gettext libcap libcap-utils \ + c-ares busybox-extras \ + && apk upgrade --no-cache zlib +``` + +*(This single edit covers both Phase 1 and Phase 2 removals.)* + +**Change B — GeoLite2 download block, CI path (line ~437):** + +Current: +```dockerfile +if curl -fSL -m 10 "https://github.com/P3TERX/GeoLite.mmdb/raw/download/GeoLite2-Country.mmdb" \ + -o /app/data/geoip/GeoLite2-Country.mmdb 2>/dev/null; then +``` + +New: +```dockerfile +if wget -qO /app/data/geoip/GeoLite2-Country.mmdb \ + -T 10 "https://github.com/P3TERX/GeoLite.mmdb/raw/download/GeoLite2-Country.mmdb" 2>/dev/null; then +``` + +**Change C — GeoLite2 download block, non-CI path (line ~445):** + +Current: +```dockerfile +if curl -fSL -m 30 --retry 3 "https://github.com/P3TERX/GeoLite.mmdb/raw/download/GeoLite2-Country.mmdb" \ + -o /app/data/geoip/GeoLite2-Country.mmdb; then + if echo "${GEOLITE2_COUNTRY_SHA256} /app/data/geoip/GeoLite2-Country.mmdb" | sha256sum -c -; then +``` + +New: +```dockerfile +if wget -qO /app/data/geoip/GeoLite2-Country.mmdb \ + -T 30 -t 4 "https://github.com/P3TERX/GeoLite.mmdb/raw/download/GeoLite2-Country.mmdb"; then + if [ -s /app/data/geoip/GeoLite2-Country.mmdb ] && \ + echo "${GEOLITE2_COUNTRY_SHA256} /app/data/geoip/GeoLite2-Country.mmdb" | sha256sum -c -; then +``` + +The `[ -s FILE ]` check is added before `sha256sum` to guard against wget leaving an empty file on partial failure. + +**Change D — HEALTHCHECK directive (line ~581):** + +Current: +```dockerfile +HEALTHCHECK --interval=30s --timeout=3s --start-period=40s --retries=3 \ + CMD curl -f http://localhost:8080/api/v1/health || exit 1 +``` + +New: +```dockerfile +HEALTHCHECK --interval=30s --timeout=3s --start-period=40s --retries=3 \ + CMD wget -q -O /dev/null http://localhost:8080/api/v1/health || exit 1 +``` + +### 3.3 Entrypoint Changes + +**File:** `.docker/docker-entrypoint.sh` + +**Change E — Caddy readiness poll (line ~368):** + +Current: +```sh +if curl -sf http://127.0.0.1:2019/config/ > /dev/null 2>&1; then +``` + +New: +```sh +if wget -qO /dev/null http://127.0.0.1:2019/config/ 2>/dev/null; then +``` + +--- + +## 4. Phase 2 — Remove `binutils` and `libc-utils` from Runtime Image + +### Rationale + +`binutils` is installed solely for `objdump`, used in `.docker/docker-entrypoint.sh` to detect DWARF debug symbols when `CHARON_DEBUG=1`. The entrypoint already has a graceful fallback (lines ~401–404): + +```sh +else + # objdump not available, try to run Delve anyway with a warning + echo "Note: Cannot verify debug symbols (objdump not found). Attempting Delve..." + run_as_charon /usr/local/bin/dlv exec "$bin_path" ... +fi +``` + +When `objdump` is absent the container functions correctly for all standard and debug-mode runs. The check is advisory. + +`libc-utils` appears **only once** across the entire codebase (confirmed by grep across `*.sh`, `Dockerfile`, `*.yml`): as a sibling entry on the same `apk add` line as `binutils`. It provides glibc-compatible headers for musl-based Alpine and has no independent consumer in this image. It is safe to remove together with `binutils`. + +### 4.1 Dockerfile Change + +Already incorporated in Phase 1 Change A — the `apk add` line removes both `binutils` and `libc-utils` in a single edit. No additional changes are required. + +### 4.2 Why Not Suppress Instead? + +Suppressing in Grype requires two new ignore entries with expiry maintenance every 30 days indefinitely (no upstream Alpine fix exists). Removing the packages eliminates the CVEs permanently. There is no functional regression given the working fallback. + +--- + +## 5. Phase 3 — Update Expired Grype Ignore Rules + +**File:** `.grype.yaml` + +### 5.1 Remove `CVE-2026-22184` (zlib) Block + +**Action:** Delete the entire `CVE-2026-22184` ignore entry. + +**Reason:** The Dockerfile runtime stage already contains `&& apk upgrade --no-cache zlib`, which upgrades zlib from 1.3.1-r2 to 1.3.2-r0, resolving CVE-2026-22184. Suppressing a resolved CVE creates false confidence and obscures scan accuracy. The entry's own removal criteria have been met: Alpine released `zlib 1.3.2-r0`. + +### 5.2 Extend `GHSA-69x3-g4r3-p962` (nebula) Expiry + +**Action:** Update the `expiry` field and review comment in the nebula block. + +Current: +```yaml + expiry: "2026-03-05" # Re-evaluate in 14 days (2026-02-19 + 14 days) +``` + +New: +```yaml + expiry: "2026-04-13" # Re-evaluated 2026-03-13: smallstep/certificates stable still v0.27.5, no nebula v1.10+ requirement. Extended 30 days. +``` + +Update the review comment line: +``` + # - Next review: 2026-04-13. + # - Reviewed 2026-03-13: smallstep stable still v0.27.5 (no nebula v1.10+ requirement). Extended 30 days. + # - Remove suppression immediately once upstream fixes. +``` + +**Reason:** As of 2026-03-13, `smallstep/certificates` has not released a stable version requiring nebula v1.10+. The constraint analysis from 2026-02-19 remains valid. Expiry extended 30 days to 2026-04-13. + +--- + +## 6. File Change Summary + +| File | Change | Scope | +|------|--------|-------| +| `Dockerfile` | Remove `curl`, `binutils`, `libc-utils` from `apk add` | Line ~413–415 | +| `Dockerfile` | Replace `curl` with `wget` in GeoLite2 CI download path | Line ~437–441 | +| `Dockerfile` | Replace `curl` with `wget` in GeoLite2 non-CI path; add `[ -s FILE ]` guard | Line ~445–452 | +| `Dockerfile` | Replace `curl` with `wget` in HEALTHCHECK | Line ~581 | +| `.docker/docker-entrypoint.sh` | Replace `curl` with `wget` in Caddy readiness poll | Line ~368 | +| `.grype.yaml` | Delete `CVE-2026-22184` (zlib) ignore block entirely | zlib block | +| `.grype.yaml` | Extend `GHSA-69x3-g4r3-p962` expiry to 2026-04-13; update review comment | nebula block | + +--- + +## 7. Commit Slicing Strategy + +**Single PR** — all changes are security-related and tightly coupled. Splitting curl removal from binutils removal would produce an intermediate commit with partially resolved HIGHs, offering no validation benefit and complicating rollback. + +Suggested commit message: +``` +fix(security): remove curl and binutils from runtime image + +Replace curl with busybox wget for GeoLite2 downloads, HEALTHCHECK, +and the Caddy readiness probe. Remove binutils and libc-utils from the +runtime image; the entrypoint objdump check has a documented fallback +for missing objdump. Eliminates CVE-2026-3805 (curl HIGH), CVE-2025-69650 +and CVE-2025-69649 (binutils HIGH), plus 8 associated MEDIUM findings. + +Remove the now-resolved CVE-2026-22184 (zlib) suppression from +.grype.yaml and extend GHSA-69x3-g4r3-p962 (nebula) expiry to +2026-04-13 pending upstream smallstep/certificates update. +``` + +--- + +## 8. Expected Scan Results After Fix + +| Metric | Before | After | Delta | +|--------|--------|-------|-------| +| HIGH count | 3 | **0** | −3 | +| MEDIUM count | ~13 | ~5 | −8 | +| LOW count | ~2 | ~0 | −2 | +| `fail-on-severity: high` | ❌ FAIL | ✅ PASS | — | +| CI supply-chain scan | ❌ BLOCKED | ✅ GREEN | — | + +Remaining MEDIUMs after fix (~5): +- `busybox` / `busybox-extras` / `ssl_client` — CVE-2025-60876 (CRLF injection in wget/ssl_client; no Alpine fix; Charon application code does not invoke `wget` directly at runtime) + +--- + +## 9. Validation Steps + +1. Rebuild Docker image: `docker build -t charon:test .` +2. Run Grype scan: `grype charon:test` — confirm zero HIGH findings +3. Confirm HEALTHCHECK probe passes: start container, check `docker inspect` for `healthy` status +4. Confirm Caddy readiness: inspect entrypoint logs for `"Caddy is ready!"` +5. Run E2E suite: `npx playwright test --project=firefox` +6. Push branch and confirm CI supply-chain workflow exits green diff --git a/docs/plans/archive/eslint-ts-vite-upgrade-spec.md b/docs/plans/archive/eslint-ts-vite-upgrade-spec.md new file mode 100644 index 000000000..b3e6d3a37 --- /dev/null +++ b/docs/plans/archive/eslint-ts-vite-upgrade-spec.md @@ -0,0 +1,1158 @@ +# Major Dependency Upgrade Plan — ESLint v10, TypeScript 6.0, Vite 8 + +**Date:** 2026-03-12 +**Author:** Planning Agent +**Status:** Ready for Review +**Confidence Score:** 82% (High for ESLint v10 + TS 6.0; Medium for Vite 8 — beta with Rolldown migration) + +--- + +## 1. Executive Summary + +This plan covers the upgrade of three major frontend toolchain dependencies in the Charon project: + +| Dependency | Current Version | Target Version | Status | Risk | +|---|---|---|---|---| +| **ESLint** | `^9.39.3 <10.0.0` | `^10.0.0` | Released | **Medium** — plugin compat gate | +| **TypeScript** | `^5.9.3` | `^6.0.0` | Beta (Feb 11) / RC (Mar 6) | **Medium** — 17+ deprecations | +| **Vite** | `^7.3.1` | `8.0.0-beta.18` | Beta (Dec 3, 2025) | **High** — beta, Rolldown replaces Rollup+esbuild | + +### Key Findings + +1. **ESLint v10** is released with a comprehensive migration guide. The primary blocker is a note in `lefthook.yml`: _"ESLint pinned at v9.x.x — do not upgrade until react-hooks plugin supports v10."_ The `eslint-plugin-react-hooks@7.0.1` must be verified for ESLint v10 compatibility before proceeding. + +2. **TypeScript 6.0** is real (Beta: Feb 11, 2026; RC: Mar 6, 2026). It is explicitly designed as a **bridge release** between TS 5.9 and the native Go-based TS 7.0. It introduces 17+ deprecations/breaking changes (new defaults for `strict`, `module`, `target`, `types`, `rootDir`; removal of `outFile`, legacy module systems; deprecated `baseUrl`, `moduleResolution: node`). Charon's current `tsconfig.json` is well-positioned — it already uses `moduleResolution: bundler`, `strict: true`, and `module: ESNext`. The **critical impact** is the `types` default changing to `[]`. + +3. **Vite 8 exists as `8.0.0-beta.18`** (announced Dec 3, 2025). The headline change is **Rolldown replaces both Rollup and esbuild**. JS transforms and minification now use Oxc; CSS minification uses Lightning CSS. The `build.rollupOptions` config key is deprecated in favor of `build.rolldownOptions`, and `output.manualChunks` (object form) is removed. Charon's `vite.config.ts` uses `rollupOptions` with `inlineDynamicImports: true` — both need migration. Ecosystem packages (`@vitejs/plugin-react`, `vitest`) require beta versions for Vite 8 compatibility. + +### Recommended Execution Order + +``` +PR-1: TypeScript 6.0 upgrade (fewer external dependencies, most self-contained) +PR-2: ESLint v10 upgrade (blocked on plugin compat verification) +PR-3: Vite 8 upgrade (beta — stacked on PR-1 + PR-2 branch) +``` + +--- + +## 2. Current Dependency Inventory + +### Root `package.json` (`/projects/Charon/package.json`) + +| Package | Current Version | Category | +|---|---|---| +| `typescript` | `^5.9.3` | devDependency | +| `vite` | `^7.3.1` | devDependency | +| `@playwright/test` | `^1.58.2` | devDependency | +| `prettier` | `^3.8.1` | devDependency | +| `markdownlint-cli2` | `^0.21.0` | devDependency | + +### Frontend `package.json` (`/projects/Charon/frontend/package.json`) + +| Package | Current Version | Category | +|---|---|---| +| `typescript` | `^5.9.3` | devDependency | +| `vite` | `^7.3.1` | devDependency | +| `vitest` | `^4.0.18` | devDependency | +| `eslint` | `^9.39.3 <10.0.0` | devDependency | +| `@eslint/js` | `^9.39.3 <10.0.0` | devDependency | +| `@eslint/css` | `^1.0.0` | devDependency | +| `@eslint/json` | `^1.1.0` | devDependency | +| `@eslint/markdown` | `^7.5.1` | devDependency | +| `typescript-eslint` | `^8.57.0` | devDependency | +| `@typescript-eslint/eslint-plugin` | `^8.57.0` | devDependency | +| `@typescript-eslint/parser` | `^8.57.0` | devDependency | +| `@vitejs/plugin-react` | `^5.1.4` | devDependency | +| `@vitest/coverage-istanbul` | `^4.0.18` | devDependency | +| `@vitest/coverage-v8` | `^4.0.18` | devDependency | +| `@vitest/eslint-plugin` | `^1.6.10` | devDependency | +| `react` | `^19.2.4` | dependency | +| `react-dom` | `^19.2.4` | dependency | +| `react-router-dom` | `^7.13.1` | dependency | +| `@tanstack/react-query` | `^5.90.21` | dependency | + +### ESLint Plugin Inventory (18 plugins) + +| Plugin | Current Version | ESLint v10 Risk | +|---|---|---| +| `eslint-plugin-react-hooks` | `^7.0.1` | **HIGH** — explicit blocker in `lefthook.yml` | +| `eslint-plugin-react-compiler` | `^19.1.0-rc.2` | Medium — RC, check compat | +| `eslint-plugin-react-refresh` | `^0.5.2` | Low | +| `eslint-plugin-import-x` | `^4.16.1` | Low — modern fork | +| `eslint-plugin-jsx-a11y` | `^6.10.2` | Medium | +| `eslint-plugin-security` | `^4.0.0` | Low | +| `eslint-plugin-sonarjs` | `^4.0.2` | Low | +| `eslint-plugin-unicorn` | `^63.0.0` | Low — actively maintained | +| `eslint-plugin-promise` | `^7.2.1` | Low | +| `eslint-plugin-unused-imports` | `^4.4.1` | Low | +| `eslint-plugin-no-unsanitized` | `^4.1.5` | Medium | +| `eslint-plugin-testing-library` | `^7.16.0` | Low | +| `typescript-eslint` | `^8.57.0` | Low — tracks ESLint closely | +| `@vitest/eslint-plugin` | `^1.6.10` | Low | +| `@eslint/css` | `^1.0.0` | Low — official ESLint | +| `@eslint/json` | `^1.1.0` | Low — official ESLint | +| `@eslint/markdown` | `^7.5.1` | Low — official ESLint | + +### Config Files Affected + +| File | Impact Area | +|---|---| +| `frontend/tsconfig.json` | TS 6.0 — `types`, `lib`, defaults | +| `frontend/tsconfig.node.json` | TS 6.0 — minor | +| `frontend/tsconfig.build.json` | TS 6.0 — extends base | +| `frontend/eslint.config.js` | ESLint v10 — plugin compat | +| `eslint.config.js` (root) | ESLint v10 — imports frontend config | +| `frontend/package.json` | All — version bumps | +| `package.json` (root) | TS + Vite version bumps | +| `lefthook.yml` | ESLint v10 — remove pin note | +| `Dockerfile` | Node.js version (already compatible) | + +### Infrastructure + +- **Node.js:** `24.14.0-alpine` (Dockerfile) — meets all upgrade requirements +- **No `.npmrc` file exists** in the project +- **Go:** `1.26.1` (not affected by frontend upgrades) + +--- + +## 3. Breaking Changes Analysis + +### 3.1 ESLint v10 Breaking Changes + +**Source:** [ESLint v10 Migration Guide](https://eslint.org/docs/latest/use/migrate-to-10.0.0) + +| # | Breaking Change | Impact on Charon | Action Required | +|---|---|---|---| +| 1 | **Node.js ≥ v20.19, v22.13, or v24** required | None — already on Node 24.14.0 | None | +| 2 | **`eslint:recommended` updated** — 3 new rules: `no-unassigned-vars`, `no-useless-assignment`, `preserve-caught-error` | May flag new violations in codebase | Fix flagged code or disable rules | +| 3 | **New config file lookup** — searches from linted file, not cwd | Flat config already used; minor risk for monorepo patterns | Verify root config is found correctly | +| 4 | **Old `.eslintrc` format completely removed** | None — already using flat config | None | +| 5 | **JSX references now tracked** — fixes `no-unused-vars` for JSX components | Positive — fewer false positives | May surface new true positives | +| 6 | **`eslint-env` comments reported as errors** | Search codebase for `/* eslint-env */` | Remove if found | +| 7 | **Jiti ≥ v2.2.0 required** | Check transitive dep version | May need explicit install | +| 8 | **Removed deprecated `context` members** — `context.getScope()`, `context.getAncestors()`, etc. | Affects **plugins**, not our config directly | All 18 plugins must be compatible | +| 9 | **Removed deprecated `SourceCode` methods** | Same — plugin concern | Plugin compat verification | +| 10 | **Program AST node range spans entire source** | Unlikely to affect us | None | + +**Critical Plugin Gate:** The `eslint-plugin-react-hooks` compatibility with ESLint v10 must be verified. The `lefthook.yml` at line ~98 explicitly states: _"NOTE: ESLint pinned at v9.x.x — do not upgrade until react-hooks plugin supports v10."_ + +### 3.2 TypeScript 6.0 Breaking Changes + +**Source:** [TypeScript 6.0 Beta Announcement](https://devblogs.microsoft.com/typescript/announcing-typescript-6-0-beta/) and [6.0 Deprecation List](https://github.com/microsoft/TypeScript/issues/54500) + +#### Default Value Changes + +| Setting | Old Default | New Default | Charon Current | Action | +|---|---|---|---|---| +| `strict` | `false` | **`true`** | `true` (explicit) | None — already set | +| `module` | `commonjs` | **`esnext`** | `ESNext` (explicit) | None — already set | +| `target` | `es5` | **`es2025`** (floating) | `ES2022` (explicit) | None — already set | +| `types` | `["*"]` (all @types) | **`[]`** (none) | **Not set** | **ACTION: Add `"types": []`** | +| `rootDir` | inferred | **`.`** (tsconfig dir) | Not set | Verify — no emit, `noEmit: true` | +| `noUncheckedSideEffectImports` | `false` | **`true`** | Not set | Verify no side-effect import issues | +| `libReplacement` | `true` | **`false`** | Not set | None — improves perf | + +#### Deprecations (with `ignoreDeprecations: "6.0"` escape hatch) + +| Deprecation | Charon Uses? | Impact | +|---|---|---| +| `target: es5` | No (`ES2022`) | None | +| `--outFile` | No | None | +| `--downlevelIteration` | No | None | +| `--moduleResolution node/node10` | No (`bundler`) | None | +| `--moduleResolution classic` | No | None | +| `--baseUrl` | No | None | +| `module: amd/umd/systemjs` | No (`ESNext`) | None | +| `esModuleInterop: false` | Not explicitly set | None | +| `allowSyntheticDefaultImports: false` | Not set (`true` in tsconfig.node) | None | +| `alwaysStrict: false` | Not set (`strict: true` covers) | None | +| Legacy `module` keyword for namespaces | No | None | +| `asserts` keyword on imports | No | None | +| `no-default-lib` directives | No | None | + +#### New Features Available + +| Feature | Relevance | +|---|---| +| `import defer` syntax | Future use — deferred module evaluation | +| `--module node20` | Not needed — using bundler | +| `es2025` target/lib | Can update `target` from `ES2022` to `ES2025` | +| Temporal types | Available via `esnext` lib | +| `dom.iterable` included in `dom` | Can simplify `lib` array | +| `--stableTypeOrdering` | Useful for TS 7.0 migration prep | +| Expandable hovers | Editor UX improvement | +| `Map.getOrInsert` / `getOrInsertComputed` | Available via `esnext` lib | +| `RegExp.escape` | Available via `es2025` lib | +| `#/` subpath imports | Available for future module aliasing | + +#### lib.d.ts Changes — ArrayBuffer/Buffer Breaking Change + +TypeScript 5.9 introduced a behavioral change where `ArrayBuffer` is no longer a supertype of several `TypedArray` types. This may cause errors like: + +``` +error TS2345: Argument of type 'ArrayBufferLike' is not assignable to parameter of type 'BufferSource'. +error TS2322: Type 'Buffer' is not assignable to type 'Uint8Array'. +``` + +**Mitigation:** Ensure `@types/node` is at latest version. This is a 5.9 → 6.0 carryover that must be verified. + +### 3.3 Vite 8 Breaking Changes + +**Source:** [Vite 8 Beta Announcement](https://vite.dev/blog/announcing-vite8-beta) and [Migration from v7 Guide](https://main.vite.dev/guide/migration) + +**Version:** `8.0.0-beta.18` (dist-tag: `beta`, announced Dec 3, 2025) + +#### Core Architecture Change: Rolldown Replaces Rollup + esbuild + +Vite 8's defining change is replacing **two bundlers** (esbuild for dev transforms, Rollup for production builds) with a single Rust-based toolchain: + +| Component | Vite 7 | Vite 8 | Impact on Charon | +|---|---|---|---| +| **Bundler** | Rollup | **Rolldown** (`1.0.0-rc.8`) | `rollupOptions` → `rolldownOptions` | +| **JS Transforms** | esbuild | **Oxc** (`@oxc-project/runtime@0.115.0`) | `esbuild` config key deprecated | +| **JS Minification** | esbuild | **Oxc Minifier** | Different minification assumptions | +| **CSS Minification** | esbuild | **Lightning CSS** (`^1.31.1`) | Slightly different output, bundle size may change | +| **Dep Optimization** | esbuild | **Rolldown** | `optimizeDeps.esbuildOptions` deprecated | + +#### Breaking Changes Impacting Charon + +| # | Breaking Change | Impact on Charon | Action Required | +|---|---|---|---| +| 1 | **Node.js `^20.19.0 \|\| >=22.12.0`** required | None — already on Node 24.14.0 | None | +| 2 | **`build.rollupOptions` deprecated** → `build.rolldownOptions` | **HIGH** — `vite.config.ts` uses `rollupOptions` | Rename config key | +| 3 | **`output.manualChunks` object form removed**, function form deprecated | **HIGH** — config sets `manualChunks: undefined` | Remove or migrate to `codeSplitting` | +| 4 | **`output.inlineDynamicImports`** — supported in Rolldown but **deprecated** in favor of `codeSplitting: false` ([rolldown docs](https://rolldown.rs/reference/OutputOptions.inlineDynamicImports)) | **HIGH** — config uses `inlineDynamicImports: true` as temporary workaround | Migrate to `codeSplitting: false`; `inlineDynamicImports` works as fallback | +| 5 | **Default browser targets updated** (Chrome 107→111, Firefox 104→114, Safari 16.0→16.4) | Low — Charon doesn't set explicit `build.target` | None — new defaults are fine | +| 6 | **esbuild no longer a direct dependency** | Low — Charon doesn't use esbuild config | None | +| 7 | **Oxc Minifier** replaces esbuild minifier | Low — different assumptions about source code | Test build output; verify no minification breakage | +| 8 | **Lightning CSS** for CSS minification | Low — may produce slightly different CSS output | Verify CSS output visually | +| 9 | **Consistent CommonJS interop** — `default` import behavior changes for CJS modules | Medium — could affect CJS dependencies (axios, etc.) | Test all runtime imports | +| 10 | **Module resolution format sniffing removed** — `browser`/`module` field heuristic gone | Low — modern packages use `exports` field | Verify no resolution regressions | +| 11 | **`@vitejs/plugin-react` 5.x does NOT support Vite 8** — requires `6.0.0-beta.0` | **HIGH** — must upgrade plugin-react | Upgrade to `@vitejs/plugin-react@6.0.0-beta.0` | +| 12 | **Plugin-react 6.0 uses `@rolldown/pluginutils`** instead of Rollup utils | Low — internal plugin change | None — handled by plugin upgrade | + +#### New Features Available + +| Feature | Relevance to Charon | +|---|---| +| Built-in tsconfig `paths` support (`resolve.tsconfigPaths: true`) | Could replace manual alias config if needed | +| `emitDecoratorMetadata` support | Not needed — Charon doesn't use decorators | +| Performance: 10–30× faster production builds | Direct benefit — faster Docker builds and CI | +| Full Bundle Mode (upcoming) | Future — 3× faster dev server startup | +| Module-level persistent cache (upcoming) | Future — faster rebuilds | + +#### Dockerfile Impact: Rollup Native Skip Flags + +The current Dockerfile sets: + +```dockerfile +ENV npm_config_rollup_skip_nodejs_native=1 \ + ROLLUP_SKIP_NODEJS_NATIVE=1 +``` + +These env vars are **Rollup-specific** for cross-platform builds. With Vite 8, Rollup is replaced by Rolldown, which uses its own native bindings (`@rolldown/binding-linux-x64-musl` for Alpine). These env vars become no-ops but do not cause harm. Rolldown's native bindings are installed per-platform by npm's `optionalDependencies` mechanism — the same mechanism that works for the `$BUILDPLATFORM` Docker flag. + +**Action:** Remove the Rollup skip flags from Dockerfile and verify cross-platform builds still work. Rolldown includes `@rolldown/binding-linux-x64-musl` which is exactly what Alpine requires. + +--- + +## 4. Compatibility Matrix + +### ESLint v10 Plugin Compatibility Verification Matrix + +Each plugin must be verified before the ESLint v10 upgrade. The agent performing PR-2 must run these checks: + +```bash +# For each plugin, check peer dependency support +npm info eslint-plugin-react-hooks peerDependencies +npm info eslint-plugin-react-compiler peerDependencies +npm info eslint-plugin-jsx-a11y peerDependencies +npm info eslint-plugin-import-x peerDependencies +npm info eslint-plugin-security peerDependencies +npm info eslint-plugin-sonarjs peerDependencies +npm info eslint-plugin-unicorn peerDependencies +npm info eslint-plugin-promise peerDependencies +npm info eslint-plugin-unused-imports peerDependencies +npm info eslint-plugin-no-unsanitized peerDependencies +npm info eslint-plugin-testing-library peerDependencies +npm info eslint-plugin-react-refresh peerDependencies +npm info @vitest/eslint-plugin peerDependencies +npm info typescript-eslint peerDependencies +npm info @eslint/css peerDependencies +npm info @eslint/json peerDependencies +npm info @eslint/markdown peerDependencies +``` + +**Decision Gate:** If `eslint-plugin-react-hooks` does NOT support ESLint v10 in its `peerDependencies`, the ESLint v10 upgrade is **BLOCKED**. Do not use `--legacy-peer-deps` or `--force` as a workaround. + +### TypeScript 6.0 Ecosystem Compatibility + +| Tool | TS 6.0 Compat | Notes | +|---|---|---| +| `typescript-eslint@8.57.0` | Likely — tracks TS closely | Verify with `npm install` | +| `vite@7.3.1` | Yes — Vite uses esbuild/swc, not tsc directly | Type-check is separate | +| `vitest@4.0.18` | Yes — same reasoning | Type-check is separate | +| `@vitejs/plugin-react@5.1.4` | Yes | No TS compiler dependency | +| `react@19.2.4` / `@types/react` | Yes | Ensure `@types/react` latest | +| `@tanstack/react-query@5.90.21` | Likely — popular library | TanStack already preparing for TS 6 | +| `knip@5.86.0` | Verify | Uses TS programmatic API | + +### Node.js Compatibility + +| Tool | Min Node.js | Charon Node.js | Status | +|---|---|---|---| +| ESLint v10 | 20.19 / 22.13 / 24+ | 24.14.0 | Compatible | +| TypeScript 6.0 | TBD (likely same as 5.9) | 24.14.0 | Compatible | +| Vite 7 | 20.19 / 22.12+ | 24.14.0 | Compatible | +| Vite 8 | 20.19 / 22.12+ | 24.14.0 | Compatible | + +### Vite 8 Ecosystem Compatibility Matrix + +All Vite-related packages must be updated together. Stable releases do **not** support Vite 8. + +| Package | Current Version | Vite 8 Compatible? | Required Version | Override Needed? | +|---|---|---|---|---| +| `vite` | `^7.3.1` | — | `8.0.0-beta.18` | No — direct install | +| `@vitejs/plugin-react` | `^5.1.4` | **No** (5.x peer: `vite: ^4.2.0 \|\| ^5.0.0 \|\| ^6.0.0 \|\| ^7.0.0`) | `6.0.0-beta.0` (peer: `vite: ^8.0.0` — verified via `npm info`) | No — direct install | +| `vitest` | `^4.0.18` | **No** (deps: `^6.0.0 \|\| ^7.0.0`) | `4.1.0-beta.6` (deps: `^6.0.0 \|\| ^7.0.0 \|\| ^8.0.0-0`) | No — 4.1.0-beta.6 dep range includes Vite 8 | +| `@vitest/coverage-istanbul` | `^4.0.18` | **No** (peer: `vitest: 4.0.18`) | `4.1.0-beta.6` | No — matches vitest beta | +| `@vitest/coverage-v8` | `^4.0.18` | **No** (peer: `vitest: 4.0.18`) | `4.1.0-beta.6` | No — matches vitest beta | +| `@vitest/ui` | `^4.0.18` | **No** (peer: `vitest: 4.0.18`) | `4.1.0-beta.6` | No — matches vitest beta | +| `@vitest/eslint-plugin` | `^1.6.10` | Yes (peer: `vitest: *`) | Keep current | No | +| `@bgotink/playwright-coverage` | `^0.3.2` | Yes (no Vite peer dep) | Keep current | No | +| `@playwright/test` | `^1.58.2` | Yes (no Vite peer dep) | Keep current | No | + +**Key constraints:** + +- `vitest@4.0.18` has `vite` in its **dependencies** (not peer deps) pinned to `^6.0.0 || ^7.0.0` — this will refuse Vite 8 unless overridden +- `vitest@4.1.0-beta.6` extends this to `^6.0.0 || ^7.0.0 || ^8.0.0-0` — supports Vite 8 beta +- `@vitejs/plugin-react@6.0.0-beta.0` peers on `vite: ^8.0.0` (verified via `npm info`). New optional peer deps: `@rolldown/plugin-babel` and `babel-plugin-react-compiler` (both optional — not required) +- All `@vitest/*` packages at `4.1.0-beta.6` must be installed together (strict peer version matching: `vitest: 4.1.0-beta.6`) +- Since `vitest@4.1.0-beta.6` already includes `^8.0.0-0` in its `vite` dependency range, and all `@vitest/*` packages peer to exact `vitest: 4.1.0-beta.6`, **no npm overrides are needed** when all packages are installed in lockstep at their beta versions + +--- + +## 5. `.npmrc` Configuration + +**No `.npmrc` file currently exists in the project.** No changes needed for these upgrades. + +If plugin compatibility issues arise during ESLint v10 upgrade, **do NOT create an `.npmrc` with `legacy-peer-deps=true`**. Instead, wait for plugin updates or use granular `overrides` in `package.json`: + +```jsonc +// package.json — ONLY if a specific plugin ships a fix before updating peerDeps +{ + "overrides": { + "eslint-plugin-EXAMPLE": { + "eslint": "^10.0.0" + } + } +} +``` + +--- + +## 6. Dockerfile Changes + +**No Dockerfile changes required** for ESLint v10 or TypeScript 6.0. + +**Vite 8 requires Dockerfile changes** — the Rollup native skip flags become irrelevant: + +```diff + # Set environment to bypass native binary requirement for cross-arch builds +- ENV npm_config_rollup_skip_nodejs_native=1 \ +- ROLLUP_SKIP_NODEJS_NATIVE=1 ++ # Vite 8 uses Rolldown (Rust native bindings, auto-resolved per platform) ++ # No skip flags needed — Rolldown's optionalDependencies handle cross-platform +``` + +Current Dockerfile state (frontend-builder stage): + +```dockerfile +FROM --platform=$BUILDPLATFORM node:24.14.0-alpine AS frontend-builder +# ... +ENV npm_config_rollup_skip_nodejs_native=1 \ + ROLLUP_SKIP_NODEJS_NATIVE=1 +RUN npm ci +COPY frontend/ ./ +RUN npm run build +``` + +- Node.js 24.14.0 meets Vite 8's requirement (`^20.19.0 || >=22.12.0`) +- `npm ci` will install Rolldown's `@rolldown/binding-linux-x64-musl` automatically on Alpine +- `--platform=$BUILDPLATFORM` ensures native bindings match the build machine architecture +- The `VITE_APP_VERSION` env var and build output (`dist/`) remain unchanged +- No new environment variables or build args needed + +**Future (Vite 8):** If Vite 8 requires a higher Node.js, upgrade the base image at that time. + +--- + +## 7. Config File Changes + +### 7.1 TypeScript 6.0 — `frontend/tsconfig.json` + +```diff + { + "compilerOptions": { + "target": "ES2022", ++ // Consider upgrading to "ES2025" (TS 6.0 new target) + "useDefineForClassFields": true, +- "lib": ["ES2022", "DOM", "DOM.Iterable"], ++ "lib": ["ES2022", "DOM"], ++ // DOM.Iterable is now included in DOM as of TS 6.0 + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, ++ ++ /* TS 6.0 — explicit types to override new default of [] */ ++ "types": [] + }, + "include": ["src"], + "references": [{ "path": "./tsconfig.node.json" }] + } +``` + +**Key changes:** + +1. **`"types": []`** — Explicitly set to `[]`. Charon uses `noEmit: true` and doesn't rely on global `@types` packages in the main tsconfig. All types come from explicit imports. +2. **`"lib"` simplification** — Remove `"DOM.Iterable"` since TS 6.0 includes it in `"DOM"` automatically. +3. **`"target"` consideration** — Can optionally upgrade from `ES2022` to `ES2025` to access `RegExp.escape` and other ES2025 types natively. Not required. + +### 7.2 TypeScript 6.0 — `frontend/tsconfig.node.json` + +```diff + { + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true, +- "strict": true ++ "strict": true, ++ "types": [] + }, + "include": ["vite.config.ts"] + } +``` + +**Note:** `allowSyntheticDefaultImports` is fine — TS 6.0 deprecates setting it to `false`, not `true`. Setting it to `true` remains valid. + +### 7.3 ESLint v10 — `frontend/package.json` Version Caps + +```diff + "devDependencies": { +- "eslint": "^9.39.3 <10.0.0", ++ "eslint": "^10.0.0", +- "@eslint/js": "^9.39.3 <10.0.0", ++ "@eslint/js": "^10.0.0", + // ... all other ESLint plugins may need version bumps + } +``` + +### 7.4 ESLint v10 — `frontend/eslint.config.js` + +Likely no structural changes needed since Charon already uses flat config. Potential changes: + +- Remove any `/* eslint-env */` comments found in source files +- Handle new `eslint:recommended` rules (`no-unassigned-vars`, `no-useless-assignment`, `preserve-caught-error`) +- Verify `tseslint.config()` wrapper compatibility + +### 7.5 ESLint v10 — `lefthook.yml` + +```diff ++ # NOTE: ESLint v10 is supported — plugin compatibility verified on [DATE] +- # NOTE: ESLint pinned at v9.x.x — do not upgrade until react-hooks plugin supports v10. +``` + +### 7.6 TypeScript 6.0 — `package.json` (Root + Frontend) + +```diff + "devDependencies": { +- "typescript": "^5.9.3", ++ "typescript": "^6.0.0", + } +``` + +--- + +## 8. Phase-by-Phase Implementation Plan + +### Phase 1: Pre-Upgrade Verification (Both PRs) + +**Owner:** Frontend_Dev agent (or whoever picks up the PR) + +1. **Snapshot current state:** + + ```bash + cd /projects/Charon && npm run lint 2>&1 | tee /tmp/eslint-v9-baseline.log + cd /projects/Charon/frontend && npx tsc --noEmit 2>&1 | tee /tmp/tsc-v5-baseline.log + ``` + +2. **Verify ESLint plugin compatibility (PR-2 gate):** + + ```bash + for plugin in eslint-plugin-react-hooks eslint-plugin-react-compiler \ + eslint-plugin-jsx-a11y eslint-plugin-import-x eslint-plugin-security \ + eslint-plugin-sonarjs eslint-plugin-unicorn eslint-plugin-promise \ + eslint-plugin-unused-imports eslint-plugin-no-unsanitized \ + eslint-plugin-testing-library eslint-plugin-react-refresh \ + @vitest/eslint-plugin typescript-eslint @eslint/css @eslint/json @eslint/markdown; do + echo "=== $plugin ===" && npm info "$plugin" peerDependencies 2>/dev/null + done + ``` + +3. **Search for `eslint-env` comments:** + + ```bash + grep -r "eslint-env" frontend/src/ --include="*.ts" --include="*.tsx" --include="*.js" + ``` + +### Phase 2: TypeScript 6.0 Upgrade (PR-1) + +**Scope:** TypeScript version bump + tsconfig adjustments + +1. Update `typescript` version in both `package.json` files: + - Root: `^5.9.3` → `^6.0.0` + - Frontend: `^5.9.3` → `^6.0.0` + +2. Apply tsconfig changes (Section 7.1 and 7.2 above): + - Add `"types": []` to `tsconfig.json` and `tsconfig.node.json` + - Remove `"DOM.Iterable"` from `lib` array (now included in `"DOM"`) + +3. Run `npm install` to update lock file + +4. Run type-check and fix any new errors: + + ```bash + cd frontend && npx tsc --noEmit + ``` + +5. Common expected issues: + - Missing types from `@types/*` packages (solved by `"types": []` since we don't use globals) + - `ArrayBuffer`/`Buffer` type narrowing (from TS 5.9 lib.d.ts changes) + - Type argument inference changes (may need explicit type annotations) + +6. Run full test suite: + + ```bash + cd frontend && npx vitest run + ``` + +7. Run Playwright E2E tests to verify build works: + + ```bash + # The Dockerfile builds with npm ci && npm run build + # Verify: cd frontend && npx vite build + ``` + +### Phase 3: ESLint v10 Upgrade (PR-2) + +**Prerequisite:** Phase 1 plugin verification passes. `eslint-plugin-react-hooks` must declare ESLint v10 support. + +1. Remove version cap and update ESLint packages: + + ```bash + cd frontend + npm install -D eslint@^10.0.0 @eslint/js@^10.0.0 + ``` + +2. Update any plugins that need version bumps for ESLint v10 compat + +3. Run ESLint and compare against baseline: + + ```bash + cd /projects/Charon && npm run lint 2>&1 | tee /tmp/eslint-v10-output.log + diff /tmp/eslint-v9-baseline.log /tmp/eslint-v10-output.log + ``` + +4. Address new violations from updated `eslint:recommended`: + - `no-unassigned-vars` — variables declared but never assigned + - `no-useless-assignment` — assignments that are immediately overwritten + - `preserve-caught-error` — catch clause variables that are declared but unused + +5. Remove any `/* eslint-env */` comments found in Phase 1 + +6. Update `lefthook.yml` — remove the ESLint v9 pin note + +7. Run full test suite to confirm no regressions + +### Phase 4: Integration Testing + +1. **Full lint + type-check:** + + ```bash + cd /projects/Charon && npm run lint && cd frontend && npx tsc --noEmit + ``` + +2. **Frontend build:** + + ```bash + cd frontend && npx vite build + ``` + +3. **Unit tests:** + + ```bash + cd frontend && npx vitest run + ``` + +4. **Playwright E2E tests (all browsers):** + + ```bash + npx playwright test --project=chromium + npx playwright test --project=firefox + npx playwright test --project=webkit + ``` + +5. **Docker build verification:** + + ```bash + docker build -t charon:upgrade-test . + ``` + +### Phase 5: Vite 8 Upgrade (PR-3 — stacked commit on same branch) + +**Prerequisites:** PR-1 (TypeScript 6.0) and PR-2 (ESLint v10) already committed on branch. + +**Scope:** Vite `^7.3.1` → `8.0.0-beta.18`, plugin-react `^5.1.4` → `6.0.0-beta.0`, vitest `^4.0.18` → `4.1.0-beta.6`, vite.config.ts migration, Dockerfile cleanup. + +#### Step 1: Install Vite 8 and ecosystem packages + +```bash +cd /projects/Charon/frontend + +# Core Vite upgrade +npm install -D vite@8.0.0-beta.18 + +# Plugin-react upgrade (6.x required for Vite 8) +npm install -D @vitejs/plugin-react@6.0.0-beta.0 + +# Vitest + coverage upgrades (4.1.0-beta.6 supports Vite 8) +npm install -D vitest@4.1.0-beta.6 \ + @vitest/coverage-istanbul@4.1.0-beta.6 \ + @vitest/coverage-v8@4.1.0-beta.6 \ + @vitest/ui@4.1.0-beta.6 +``` + +#### Step 2: Update root `package.json` (direct version bump only — no overrides) + +The root `package.json` only has `vite` as a direct devDependency (used by Playwright). It does **not** need overrides — just a version bump: + +```bash +cd /projects/Charon +npm install -D vite@8.0.0-beta.18 +``` + +#### Step 3: Verify peer dep resolution (overrides likely NOT needed) + +With all packages at their Vite 8-compatible versions, overrides should not be necessary: + +- `vitest@4.1.0-beta.6` depends on `vite: ^6.0.0 || ^7.0.0 || ^8.0.0-0` — already includes Vite 8 +- `@vitejs/plugin-react@6.0.0-beta.0` peers on `vite: ^8.0.0` — matches +- All `@vitest/*@4.1.0-beta.6` peer on `vitest: 4.1.0-beta.6` — matches when installed in lockstep + +Run `npm install` and check for peer dep warnings. **Only add overrides in `frontend/package.json`** (following the established pattern from TS 6.0 and ESLint v10 phases) if specific transitive packages fail to resolve: + +```jsonc +// frontend/package.json — ONLY if npm install reports unresolved peer deps +{ + "overrides": { + // ... existing TS and ESLint overrides ... + // Add scoped overrides ONLY for the specific package that fails, e.g.: + // "some-transitive-package": { "vite": "8.0.0-beta.18" } + } +} +``` + +**Do NOT add a top-level `"vite": "8.0.0-beta.18"` override** — this forces every transitive Vite consumer to resolve to the beta, which is overly broad. If a broad override is truly needed after testing, add it with a comment explaining which transitive package requires it. + +#### Step 4: Migrate `vite.config.ts` + +```diff + import react from '@vitejs/plugin-react' + import { defineConfig } from 'vite' + + export default defineConfig({ + plugins: [react()], + server: { + port: 5173, + proxy: { + '/api': { + target: 'http://localhost:8080', + changeOrigin: true + } + } + }, + build: { + outDir: 'dist', + sourcemap: true, +- // TEMPORARY: Disable code splitting to diagnose React initialization issue +- // If this works, the problem is module loading order in async chunks + chunkSizeWarningLimit: 2000, +- rollupOptions: { +- output: { +- // Disable code splitting - bundle everything into one file +- manualChunks: undefined, +- inlineDynamicImports: true +- } +- } ++ rolldownOptions: { ++ output: { ++ // Disable code splitting — single bundle for React init stability ++ // codeSplitting: false is the Rolldown-native approach ++ // (inlineDynamicImports is deprecated in Rolldown) ++ codeSplitting: false ++ } ++ } + } + }) +``` + +**Key changes:** +1. `rollupOptions` → `rolldownOptions` (Rollup config key deprecated) +2. `manualChunks: undefined` removed (object form no longer supported; was already a no-op since `undefined`) +3. `inlineDynamicImports: true` replaced with `codeSplitting: false` — the Rolldown-native equivalent. Rolldown supports `inlineDynamicImports` but marks it as [deprecated](https://rolldown.rs/reference/OutputOptions.inlineDynamicImports) in favor of `codeSplitting: false`. +4. The TEMPORARY comment is preserved in intent — this workaround may still be needed + +**Fallback if `codeSplitting: false` behaves differently than expected:** + +```ts +build: { + rolldownOptions: { + output: { + // Deprecated but still functional in Rolldown 1.0.0-rc.8 + inlineDynamicImports: true + } + } +} +``` + +#### Step 5: Update Dockerfile + +Remove the now-irrelevant Rollup native skip flags: + +```diff +- ENV npm_config_rollup_skip_nodejs_native=1 \ +- ROLLUP_SKIP_NODEJS_NATIVE=1 ++ # Vite 8: Rolldown native bindings auto-resolved per platform via optionalDependencies +``` + +#### Step 6: Run `npm install` to regenerate lock file + +```bash +cd /projects/Charon && npm install +cd /projects/Charon/frontend && npm install +``` + +#### Step 7: Verify builds and tests + +```bash +# 1. Frontend build (most critical — tests Rolldown bundling) +cd /projects/Charon/frontend && npx vite build + +# 2. Type-check (should be unaffected) +cd /projects/Charon/frontend && npx tsc --noEmit + +# 3. Lint (should be unaffected) +cd /projects/Charon && npm run lint + +# 4. Unit tests +cd /projects/Charon/frontend && npx vitest run + +# 5. Docker build (tests Rolldown on Alpine/musl) +docker build -t charon:vite8-test . + +# 6. Playwright E2E (tests the built app end-to-end) +cd /projects/Charon && npx playwright test --project=firefox + +# 7. CJS interop smoke test (verify axios, react-hot-toast, react-hook-form) +# Run the app and manually verify pages that use CJS dependencies render correctly +# See Step 9 for detailed CJS interop verification checklist +``` + +#### Step 8: Verify build output + +```bash +# Compare build output size and structure +ls -la frontend/dist/assets/ +# Should still produce index-*.js, index-*.css +# With codeSplitting: false, should be a single JS bundle +``` + +#### Step 9: Verify CJS interop (Vite 8 behavior change) + +Vite 8's consistent CJS interop may affect imports from CJS packages like `axios` and `react-hot-toast`. **Explicitly verify these packages work at runtime:** + +```bash +# After Docker build or vite build + preview: +# 1. Verify axios API calls work (CJS package with __esModule flag) +# - Navigate to any page that makes API calls (e.g., Dashboard) +# - Check browser console for "default is not a function" errors +# 2. Verify react-hot-toast renders (CJS package) +# - Trigger a toast notification (e.g., save settings) +# - Check browser console for import errors +# 3. Verify react-hook-form works (CJS interop) +# - Open any form page, submit a form +``` + +If any runtime errors appear (e.g., `default is not a function`), use the temporary escape hatch: + +```ts +// vite.config.ts — ONLY if CJS interop breaks +export default defineConfig({ + legacy: { + inconsistentCjsInterop: true + } +}) +``` + +#### Step 10: Update `ARCHITECTURE.md` + +Update the Frontend technology stack table and directory structure to reflect current versions: + +```diff + ### Frontend + | Component | Technology | Version | Purpose | +- | **Build Tool** | Vite | 6.1.9 | Fast bundler and dev server | ++ | **Build Tool** | Vite | 8.0.0-beta.18 | Fast bundler and dev server | +- | **CSS Framework** | Tailwind CSS | 3.x | Utility-first CSS | ++ | **CSS Framework** | Tailwind CSS | 4.2.1 | Utility-first CSS | +- | **Unit Testing** | Vitest | 2.x | Fast unit test runner | ++ | **Unit Testing** | Vitest | 4.1.0-beta.6 | Fast unit test runner | +- | **E2E Testing** | Playwright | 1.50.x | Browser automation | ++ | **E2E Testing** | Playwright | 1.58.2 | Browser automation | +``` + +Also fix the directory structure reference: + +```diff +- │ └── vite.config.js # Vite configuration ++ │ └── vite.config.ts # Vite configuration +``` + +--- + +## 9. Rollback Strategy + +### TypeScript 6.0 Rollback (PR-1) + +1. Revert `package.json` changes (both root and frontend): + + ```diff + - "typescript": "^6.0.0" + + "typescript": "^5.9.3" + ``` + +2. Revert `tsconfig.json` changes (remove `"types": []`, restore `"DOM.Iterable"`) +3. Run `npm install` to restore lock file +4. Verify: `cd frontend && npx tsc --noEmit && npx vitest run` + +**Risk:** Low — TypeScript version is a devDependency only. No runtime impact. `git revert` of the PR commit is sufficient. + +### ESLint v10 Rollback (PR-2) + +1. Revert `package.json` changes: + + ```diff + - "eslint": "^10.0.0" + + "eslint": "^9.39.3 <10.0.0" + - "@eslint/js": "^10.0.0" + + "@eslint/js": "^9.39.3 <10.0.0" + ``` + +2. Revert any plugin version bumps +3. Revert `lefthook.yml` comment change +4. Run `npm install` to restore lock file +5. Verify: `cd /projects/Charon && npm run lint` + +**Risk:** Low — ESLint is a devDependency only. Code changes (fixing new rule violations) are harmless to keep even if ESLint is rolled back. + +### Vite 8 Rollback (PR-3 commit) + +1. Revert `vite` version in both `package.json` files: + + ```diff + - "vite": "8.0.0-beta.18" + + "vite": "^7.3.1" + ``` + +2. Revert ecosystem packages in `frontend/package.json`: + + ```diff + - "@vitejs/plugin-react": "6.0.0-beta.0" + + "@vitejs/plugin-react": "^5.1.4" + - "vitest": "4.1.0-beta.6" + + "vitest": "^4.0.18" + - "@vitest/coverage-istanbul": "4.1.0-beta.6" + + "@vitest/coverage-istanbul": "^4.0.18" + - "@vitest/coverage-v8": "4.1.0-beta.6" + + "@vitest/coverage-v8": "^4.0.18" + - "@vitest/ui": "4.1.0-beta.6" + + "@vitest/ui": "^4.0.18" + ``` + +3. Revert `vite.config.ts`: `rolldownOptions` → `rollupOptions`, restore `manualChunks: undefined` + +4. Revert Dockerfile: restore `ROLLUP_SKIP_NODEJS_NATIVE=1` env vars + +5. Remove Vite 8 overrides from `frontend/package.json` + +6. Run `npm install` to restore lock file + +7. Verify: `cd frontend && npx vite build && npx vitest run` + +**Risk:** Medium — Vite 8 is a pre-release beta. More likely to need rollback than stable upgrades. Since this is a stacked commit on the same branch, `git revert HEAD` cleanly removes only the Vite 8 changes while preserving TS 6.0 and ESLint v10. + +--- + +## 10. Testing Strategy + +### Automated Test Coverage + +| Test Layer | Tool | What It Validates | +|---|---|---| +| Type checking | `tsc --noEmit` | TS 6.0 compatibility, tsconfig changes | +| Linting | `eslint` | ESLint v10 config + plugin compat | +| Unit tests | `vitest run` | No runtime regressions from TS changes | +| E2E tests | Playwright (Chromium, Firefox, WebKit) | Full app build + functionality | +| Docker build | `docker build` | Dockerfile still works with new deps | +| Pre-commit hooks | `lefthook` | All hooks pass with new versions | + +### Specific Test Scenarios for TS 6.0 + +1. **Build output verification:** + + ```bash + cd frontend && npx vite build + # Verify dist/ output is correct, no new warnings + ``` + +2. **Type-check with `--stableTypeOrdering`** (prep for TS 7.0): + + ```bash + cd frontend && npx tsc --noEmit --stableTypeOrdering + # Note any differences — these will be real in TS 7.0 + ``` + +3. **Verify no `@types` resolution issues:** + + ```bash + # With types: [], ensure no global type errors appear + cd frontend && npx tsc --noEmit 2>&1 | grep "Cannot find" + ``` + +### Specific Test Scenarios for ESLint v10 + +1. **Verify all 18 plugins load without errors:** + + ```bash + cd /projects/Charon && npx eslint --print-config frontend/src/App.tsx | head -20 + ``` + +2. **Count new violations vs baseline:** + + ```bash + npx eslint frontend/src/ --format json 2>/dev/null | jq '.[] | .errorCount' | paste -sd+ | bc + ``` + +3. **Verify config lookup works correctly in monorepo:** + + ```bash + # Lint a file from the root — should find root eslint.config.js + npx eslint frontend/src/App.tsx + ``` + +--- + +## 11. Commit Slicing Strategy + +### Decision: 3 Stacked Commits on Single Branch + +**Trigger reasons:** + +- Cross-domain changes (TS and ESLint are independent tools) +- Risk isolation (if one breaks, the other can still merge) +- Review size (each PR is focused and reviewable) +- Plugin compatibility gate (ESLint v10 may be blocked) + +### PR-1: TypeScript 6.0 Upgrade + +| Attribute | Detail | +|---|---| +| **Scope** | TypeScript ^5.9.3 → ^6.0.0, tsconfig changes, fix type errors | +| **Files** | `package.json` (root), `frontend/package.json`, `package-lock.json`, `frontend/tsconfig.json`, `frontend/tsconfig.node.json`, possibly source files with type fixes | +| **Dependencies** | None — can start immediately | +| **Validation Gate** | `tsc --noEmit` passes, `vitest run` passes, `vite build` succeeds, Docker build succeeds | +| **Estimated Complexity** | Medium — mostly defaults are already correct, `types: []` is the main change | +| **Rollback** | `git revert` + `npm install` | + +### PR-2: ESLint v10 Upgrade + +| Attribute | Detail | +|---|---| +| **Scope** | ESLint ^9.x → ^10.0.0, plugin updates, fix new violations, update lefthook | +| **Files** | `frontend/package.json`, `package-lock.json`, `frontend/eslint.config.js` (if needed), `lefthook.yml`, source files with new violations | +| **Dependencies** | **BLOCKED** until `eslint-plugin-react-hooks` declares ESLint v10 support | +| **Validation Gate** | `npm run lint` passes, all plugins load, no new unhandled violations | +| **Estimated Complexity** | Medium — depends on plugin ecosystem readiness | +| **Rollback** | `git revert` + `npm install` | + +### PR-3: Vite 8 Upgrade (stacked commit on same branch) + +| Attribute | Detail | +|---|---| +| **Scope** | Vite 7→8, plugin-react 5→6, vitest 4.0→4.1-beta, vite.config.ts migration, Dockerfile cleanup | +| **Files** | `package.json` (root), `frontend/package.json`, `package-lock.json`, `frontend/vite.config.ts`, `Dockerfile`, `ARCHITECTURE.md` | +| **Dependencies** | PR-1 (TS 6.0) and PR-2 (ESLint v10) already committed on branch | +| **Validation Gate** | `vite build` succeeds with Rolldown, `vitest run` passes, Docker build succeeds, Playwright E2E passes | +| **Estimated Complexity** | **High** — beta software, bundler engine swap (Rollup→Rolldown), multiple ecosystem packages at beta versions | +| **Rollback** | `git revert HEAD` — cleanly removes only the Vite 8 commit | + +#### npm Overrides for PR-3 + +**No overrides expected** when all packages are installed at their beta versions in lockstep: +- `vitest@4.1.0-beta.6` deps include `vite: ^8.0.0-0` — resolves Vite 8 without override +- `@vitest/*@4.1.0-beta.6` peer on `vitest: 4.1.0-beta.6` — satisfied by direct install + +If `npm install` fails, add **scoped** overrides in `frontend/package.json` only for the failing package. Do not add a broad `"vite": "8.0.0-beta.18"` override. + +### Contingency + +- If TS 6.0 stable is delayed past RC, pin to `typescript@6.0.0-rc` temporarily +- If ESLint v10 plugin compat is blocked for >30 days, consider temporarily dropping the blocker plugin or using `--rulesdir` workaround +- If a plugin is permanently abandoned, research replacement plugins +- If Vite 8 beta has blocking regressions, `git revert` the Vite 8 commit and wait for the next beta or stable release — TS 6.0 + ESLint v10 upgrades remain unaffected +- If `vitest@4.1.0-beta.6` fails tests, try pinning `vitest@4.0.18` with an `overrides` entry for its `vite` dependency (force it to accept `^8.0.0-0`) +- If Rolldown's `codeSplitting: false` behaves differently than expected, try the deprecated `inlineDynamicImports: true` as a fallback, or re-investigate the React initialization issue that motivated the workaround + +--- + +## 12. Known Issues & Gotchas + +### ESLint v10 + +1. **react-hooks plugin blocker** — `lefthook.yml` explicitly states the upgrade is blocked until `eslint-plugin-react-hooks` supports v10. This is the #1 risk. + +2. **Config file lookup change** — ESLint v10 finds config files starting from the linted file and walking up. In Charon's monorepo setup (root `eslint.config.js` imports `frontend/eslint.config.js`), verify the root config is still discovered when linting `frontend/src/**`. + +3. **Jiti dependency** — ESLint v10 requires `jiti >= v2.2.0` for loading config files. This is typically a transitive dependency but may need explicit installation if conflicts arise. + +4. **Plugin API breakage** — Plugins that use deprecated `context.getScope()`, `context.getAncestors()`, `context.parserOptions`, or `context.parserPath` will break. All 18 plugins must be verified. + +### TypeScript 6.0 + +1. **`types: []` default** — This is the highest-impact change for Charon. Without explicitly setting `"types"`, TS 6.0 will not auto-load any `@types/*` packages. Since Charon uses `noEmit: true` and explicit imports, this should be fine, but test thoroughly. + +2. **TS 6.0 is a transition release** — It is explicitly designed as a bridge to TS 7.0 (native Go port). Adopting TS 6.0 now prepares us for TS 7.0 later. The `ignoreDeprecations: "6.0"` escape hatch exists if needed. + +3. **`typescript-eslint` compatibility** — If `typescript-eslint@8.57.0` doesn't support TS 6.0, we may need to update it. Check for a release that adds TS 6.0 support. + +4. **`knip` compatibility** — `knip` (`^5.86.0`) uses TS programmatic API internally. Verify it works with TS 6.0. + +5. **ArrayBuffer/Buffer types** — TS 5.9 changes to `lib.d.ts` around `ArrayBuffer` not being a supertype of `TypedArray` may surface with TS 6.0. Ensure `@types/node` is at latest. + +6. **`ts5to6` migration tool** — The experimental [ts5to6](https://github.com/andrewbranch/ts5to6) tool can automatically adjust `baseUrl` and `rootDir`. Charon doesn't use `baseUrl`, so this is of limited value, but worth knowing about. + +### Vite 8 + +1. **Beta software** — `8.0.0-beta.18` is pre-release. Expect edge cases and undocumented behavior. File issues at `https://github.com/vitejs/rolldown-vite/issues`. + +2. **Rolldown bundler is RC, not stable** — Vite 8 depends on `rolldown@1.0.0-rc.8`. Rolldown is feature-complete but may have edge cases with complex chunk splitting configurations. + +3. **`codeSplitting: false` replaces `inlineDynamicImports: true`** — `frontend/vite.config.ts` has a `TEMPORARY` workaround for a "React init issue". Rolldown supports `inlineDynamicImports` but marks it as [deprecated](https://rolldown.rs/reference/OutputOptions.inlineDynamicImports) in favor of `codeSplitting: false`. The migration uses `codeSplitting: false` as the primary approach; `inlineDynamicImports: true` can be used as a deprecated fallback. + +4. **Oxc Minifier assumptions differ from esbuild** — The Oxc Minifier makes [different assumptions](https://oxc.rs/docs/guide/usage/minifier.html#assumptions) about source code than esbuild. If runtime errors appear after build but not in dev, the minifier is the likely culprit. Use `build.minify: false` temporarily to diagnose. + +5. **CJS interop behavior change** — Vite 8 changes how `default` imports from CommonJS modules work. Packages like `axios` (CJS) may be affected. The `legacy.inconsistentCjsInterop: true` escape hatch exists if needed. + +6. **All ecosystem packages are beta** — `@vitejs/plugin-react@6.0.0-beta.0`, `vitest@4.1.0-beta.6`, and all `@vitest/*` packages are pre-release. They are tightly version-locked (e.g., `@vitest/coverage-v8` peers to exact `vitest: 4.1.0-beta.6`). + +7. **Plugin-react 6.0 API change** — The new `@vitejs/plugin-react@6.0.0-beta.0` uses `@rolldown/pluginutils` internally instead of `@rollup/pluginutils`. The public API (`react()` call in config) appears unchanged. New optional peer deps (`@rolldown/plugin-babel`, `babel-plugin-react-compiler`) are not required for Charon's usage. + +8. **Lightning CSS may increase CSS bundle size** — Lightning CSS produces slightly different output than esbuild's CSS minifier. Verify CSS output and check for visual regressions. + +9. **Cross-platform Docker builds** — Rolldown uses native Rust bindings per platform (`@rolldown/binding-linux-x64-musl` for Alpine). The `--platform=$BUILDPLATFORM` Docker flag ensures the correct binding is installed. If cross-arch builds fail, verify the correct `@rolldown/binding-*` package is being resolved. + +--- + +## 13. Risk Assessment + +| Risk | Probability | Impact | Mitigation | +|---|---|---|---| +| `eslint-plugin-react-hooks` doesn't support ESLint v10 | **Medium** | **High** — blocks PR-2 entirely | Monitor npm for updates; check GitHub issues | +| Other ESLint plugins break on v10 | **Low** | **Medium** — individual plugins can be disabled | Verify all 18 plugins; have disable config ready | +| TS 6.0 `types: []` causes unexpected errors | **Medium** | **Low** — easy to fix by adding types | Test with `tsc --noEmit`; add specific types | +| `typescript-eslint` incompatible with TS 6.0 | **Low** | **Medium** — blocks type-aware linting | Check releases; may need to update | +| `knip` breaks with TS 6.0 | **Low** | **Low** — `knip` is optional tooling | Test separately; pin if needed | +| TS 6.0 stable delayed | **Low** | **Low** — RC already available | Use RC or pin beta | +| Vite 8 beta breaks production build | **Medium** | **High** — blocks Docker/deployment | Test `vite build` thoroughly; rollback with `git revert` | +| Rolldown CJS interop breaks runtime imports | **Medium** | **Medium** — runtime errors on CJS packages | Test all CJS deps (axios, etc.); use `legacy.inconsistentCjsInterop` escape | +| Oxc Minifier causes runtime errors | **Low** | **High** — minification bugs are subtle | Compare dev vs prod behavior; use `build.minify: false` to diagnose | +| `vitest@4.1.0-beta.6` incompatible with test suite | **Low** | **Medium** — blocks unit test validation | Pin to `4.0.18` + override vite peer if needed | +| `@vitejs/plugin-react@6.0.0-beta.0` breaks React HMR | **Low** | **Medium** — dev experience degraded | Rollback to 5.1.4 + Vite 7 if critical | +| Rolldown native binding fails on Alpine cross-build | **Low** | **High** — blocks Docker build entirely | Verify `@rolldown/binding-linux-x64-musl` resolves; fall back to non-cross-platform build | +| Lightning CSS produces visual CSS regressions | **Low** | **Low** — cosmetic issues only | Visual diff E2E screenshots | +| Docker build fails after upgrades | **Low** | **Medium** — blocks CI/deployment | Test Docker build in PR CI | +| Playwright E2E failures from TS changes | **Very Low** | **High** — blocks merge | Run full E2E suite before merge | + +### Overall Risk: **MEDIUM-HIGH** + +- TypeScript 6.0 is well-characterized and Charon's tsconfig is well-aligned with the new defaults +- ESLint v10 is dependent on ecosystem readiness (plugin compatibility) +- **Vite 8 is the highest-risk change** — beta software with a complete bundler engine swap (Rollup→Rolldown). The saving grace is that all three upgrades are separate commits on the same branch, enabling surgical rollback of just the Vite 8 commit if needed + +--- + +## Acceptance Criteria + +### PR-1 (TypeScript 6.0) + +- [ ] `typescript` upgraded to `^6.0.0` in root and frontend `package.json` +- [ ] `tsconfig.json` updated with `types: []` and simplified `lib` +- [ ] `tsc --noEmit` passes with zero errors +- [ ] `vitest run` passes all tests +- [ ] `vite build` produces correct output +- [ ] Docker build succeeds +- [ ] No new `ignoreDeprecations` usage (clean upgrade) + +### PR-2 (ESLint v10) + +- [ ] Plugin compatibility verified for all 18 plugins +- [ ] `eslint` and `@eslint/js` upgraded to `^10.0.0` +- [ ] Version cap (`<10.0.0`) removed from both packages +- [ ] `npm run lint` passes (new violations fixed) +- [ ] `lefthook.yml` pin note removed/updated +- [ ] All pre-commit hooks pass + +### PR-3 (Vite 8) + +- [ ] `vite` upgraded to `8.0.0-beta.18` in root and frontend `package.json` +- [ ] `@vitejs/plugin-react` upgraded to `6.0.0-beta.0` +- [ ] `vitest` upgraded to `4.1.0-beta.6` with matching `@vitest/*` packages +- [ ] `vite.config.ts` migrated: `rollupOptions` → `rolldownOptions`, `manualChunks` removed +- [ ] npm overrides verified: no broad overrides needed (or scoped overrides added with justification) +- [ ] Dockerfile: Rollup native skip flags removed +- [ ] `vite build` produces correct output with Rolldown bundler +- [ ] `vitest run` passes all unit tests +- [ ] `tsc --noEmit` still passes (unchanged from PR-1) +- [ ] Docker build succeeds with Rolldown on Alpine/musl +- [ ] Playwright E2E tests pass (all browsers) +- [ ] No CJS interop runtime errors (axios, react-hot-toast, etc.) +- [ ] CJS interop verified: axios API calls, react-hot-toast renders, react-hook-form submits work +- [ ] CSS output visually correct (Lightning CSS minification) +- [ ] `ARCHITECTURE.md` updated: Vite 8.0.0-beta.18, Vitest 4.1.0-beta.6, Playwright 1.58.2, Tailwind CSS 4.2.1, `vite.config.ts` filename +- [ ] Pre-commit hooks pass (`lefthook`) diff --git a/docs/plans/archive/telegram_test_remediation_spec.md b/docs/plans/archive/telegram_test_remediation_spec.md new file mode 100644 index 000000000..12f1e701d --- /dev/null +++ b/docs/plans/archive/telegram_test_remediation_spec.md @@ -0,0 +1,497 @@ +# Telegram Notification Provider — Test Failure Remediation Plan + +**Date:** 2026-03-11 +**Author:** Planning Agent +**Status:** Remediation Required — All security scans pass, test failures block merge +**Previous Plan:** Archived as `docs/plans/telegram_implementation_spec.md` + +--- + +## 1. Introduction + +The Telegram notification provider feature is functionally complete with passing security scans and coverage gates. However, **56 E2E test failures** and **2 frontend unit test failures** block the PR merge. This plan identifies root causes, categorises each failure set, and provides specific remediation steps. + +### Failure Summary + +| Spec File | Failures | Browsers | Unique Est. | Category | +|---|---|---|---|---| +| `notifications.spec.ts` | 48 | 3 | ~16 | **Our change** | +| `notifications-payload.spec.ts` | 18 | 3 | ~6 | **Our change** | +| `telegram-notification-provider.spec.ts` | 4 | 1–3 | ~2 | **Our change** | +| `encryption-management.spec.ts` | 20 | 3 | ~7 | Pre-existing | +| `auth-middleware-cascade.spec.ts` | 18 | 3 | 6 | Pre-existing | +| `Notifications.test.tsx` (unit) | 2 | — | 2 | **Our change** | + +CI retries: 2 per test (`playwright.config.js` L144). Failure counts above represent unique test failures × browser projects. + +--- + +## 2. Root Cause Analysis + +### Root Cause A: `isNew` Guard on Test Button (CRITICAL — Causes ~80% of failures) + +**What changed:** The Telegram feature added a guard in `Notifications.tsx` (L117-124) that blocks the "Test" button for new (unsaved) providers: + +```typescript +// Line 117-124: handleTest() early return guard +const handleTest = () => { + const formData = watch(); + const currentType = normalizeProviderType(formData.type); + if (!formData.id && currentType !== 'email') { + toast.error(t('notificationProviders.saveBeforeTesting')); + return; + } + testMutation.mutate({ ...formData, type: currentType } as Partial); +}; +``` + +And a `disabled` attribute on the test button at `Notifications.tsx` (L382): + +```typescript +// Line 382: Button disabled state +disabled={testMutation.isPending || (isNew && !isEmail)} +``` + +**Why it was added:** The backend `Test` handler at `notification_provider_handler.go` (L333-336) requires a saved provider ID for all non-email types. For Gotify/Telegram, the server needs the stored token. For Discord/Webhook, the server still fetches the provider from DB. Without a saved provider, the backend returns `MISSING_PROVIDER_ID`. + +**Why it breaks tests:** Many existing E2E and unit tests click the test button from a **new (unsaved) provider form** using mocked endpoints. With the new guard: +1. The `