From d52cfe7de230f791c327168378822b1c84844c7c Mon Sep 17 00:00:00 2001 From: Boki Date: Sat, 28 Jun 2025 11:11:34 -0400 Subject: [PATCH] initial wcag-ada --- apps/wcag-ada/.dockerignore | 70 ++++ apps/wcag-ada/.env.example | 61 ++++ apps/wcag-ada/.gitlab-ci.yml | 181 ++++++++++ apps/wcag-ada/DEPLOYMENT.md | 328 ++++++++++++++++++ apps/wcag-ada/Dockerfile | 85 +++++ apps/wcag-ada/Makefile | 85 +++++ apps/wcag-ada/README.md | 175 ++++++++++ apps/wcag-ada/api/.env.example | 40 +++ apps/wcag-ada/api/Dockerfile | 66 ++++ apps/wcag-ada/api/package.json | 34 ++ apps/wcag-ada/api/prisma/schema.prisma | 153 ++++++++ apps/wcag-ada/api/src/config.ts | 39 +++ apps/wcag-ada/api/src/index.ts | 64 ++++ apps/wcag-ada/api/src/middleware/auth.ts | 69 ++++ .../api/src/middleware/error-handler.ts | 59 ++++ .../api/src/middleware/rate-limiter.ts | 75 ++++ apps/wcag-ada/api/src/routes/auth.ts | 177 ++++++++++ apps/wcag-ada/api/src/routes/health.ts | 118 +++++++ apps/wcag-ada/api/src/routes/reports.ts | 244 +++++++++++++ apps/wcag-ada/api/src/routes/scans.ts | 283 +++++++++++++++ apps/wcag-ada/api/src/routes/websites.ts | 245 +++++++++++++ .../api/src/services/report-generator.ts | 271 +++++++++++++++ apps/wcag-ada/api/src/utils/prisma.ts | 12 + apps/wcag-ada/api/tsconfig.json | 16 + apps/wcag-ada/config/README.md | 181 ++++++++++ apps/wcag-ada/config/config/default.json | 117 +++++++ apps/wcag-ada/config/config/development.json | 51 +++ apps/wcag-ada/config/config/production.json | 64 ++++ apps/wcag-ada/config/config/test.json | 53 +++ apps/wcag-ada/config/package.json | 19 + apps/wcag-ada/config/src/config-instance.ts | 67 ++++ apps/wcag-ada/config/src/index.ts | 106 ++++++ .../config/src/schemas/features.schema.ts | 89 +++++ .../config/src/schemas/providers.schema.ts | 84 +++++ .../config/src/schemas/scanner.schema.ts | 34 ++ .../config/src/schemas/wcag-app.schema.ts | 111 ++++++ .../config/src/schemas/worker.schema.ts | 49 +++ apps/wcag-ada/config/tsconfig.json | 13 + apps/wcag-ada/dashboard/Dockerfile | 57 +++ apps/wcag-ada/dashboard/index.html | 13 + apps/wcag-ada/dashboard/nginx.conf | 79 +++++ apps/wcag-ada/dashboard/package.json | 61 ++++ apps/wcag-ada/dashboard/postcss.config.js | 6 + apps/wcag-ada/dashboard/public/icon.svg | 5 + apps/wcag-ada/dashboard/src/App.tsx | 70 ++++ .../src/components/layout/auth-layout.tsx | 26 ++ .../src/components/layout/main-layout.tsx | 189 ++++++++++ .../src/components/layout/protected-route.tsx | 21 ++ .../dashboard/src/components/ui/button.tsx | 55 +++ .../dashboard/src/components/ui/card.tsx | 78 +++++ apps/wcag-ada/dashboard/src/lib/api-client.ts | 167 +++++++++ apps/wcag-ada/dashboard/src/lib/utils.ts | 6 + apps/wcag-ada/dashboard/src/main.tsx | 27 ++ .../dashboard/src/pages/auth/login.tsx | 112 ++++++ .../dashboard/src/pages/auth/register.tsx | 141 ++++++++ .../dashboard/src/pages/dashboard/index.tsx | 243 +++++++++++++ .../dashboard/src/pages/reports/index.tsx | 8 + .../dashboard/src/pages/scans/[id].tsx | 8 + .../dashboard/src/pages/scans/index.tsx | 8 + .../dashboard/src/pages/settings/index.tsx | 8 + .../dashboard/src/pages/websites/[id].tsx | 8 + .../dashboard/src/pages/websites/index.tsx | 88 +++++ .../dashboard/src/store/auth-store.ts | 55 +++ .../wcag-ada/dashboard/src/styles/globals.css | 67 ++++ apps/wcag-ada/dashboard/tailwind.config.js | 85 +++++ apps/wcag-ada/dashboard/tsconfig.json | 34 ++ apps/wcag-ada/dashboard/vite.config.ts | 22 ++ apps/wcag-ada/docker-compose.dev.yml | 47 +++ apps/wcag-ada/docker-compose.test.yml | 28 ++ apps/wcag-ada/docker-compose.yml | 153 ++++++++ apps/wcag-ada/k8s/api.yaml | 69 ++++ apps/wcag-ada/k8s/configmap.yaml | 47 +++ apps/wcag-ada/k8s/dashboard.yaml | 52 +++ apps/wcag-ada/k8s/ingress.yaml | 38 ++ apps/wcag-ada/k8s/namespace.yaml | 6 + apps/wcag-ada/k8s/postgres.yaml | 84 +++++ apps/wcag-ada/k8s/redis.yaml | 77 ++++ apps/wcag-ada/k8s/secrets.yaml | 26 ++ apps/wcag-ada/k8s/worker.yaml | 67 ++++ apps/wcag-ada/package.json | 32 ++ apps/wcag-ada/scanner/README.md | 162 +++++++++ apps/wcag-ada/scanner/package.json | 29 ++ .../scanner/src/core/accessibility-browser.ts | 285 +++++++++++++++ apps/wcag-ada/scanner/src/core/scanner.ts | 303 ++++++++++++++++ apps/wcag-ada/scanner/src/example.ts | 79 +++++ apps/wcag-ada/scanner/src/index.ts | 14 + apps/wcag-ada/scanner/tsconfig.json | 13 + apps/wcag-ada/scripts/build-images.sh | 41 +++ apps/wcag-ada/scripts/deploy-k8s.sh | 71 ++++ apps/wcag-ada/scripts/local-dev.sh | 52 +++ apps/wcag-ada/shared/package.json | 18 + apps/wcag-ada/shared/src/constants.ts | 81 +++++ apps/wcag-ada/shared/src/index.ts | 3 + apps/wcag-ada/shared/src/types.ts | 201 +++++++++++ apps/wcag-ada/shared/src/utils.ts | 155 +++++++++ apps/wcag-ada/shared/tsconfig.json | 10 + apps/wcag-ada/tsconfig.json | 21 ++ apps/wcag-ada/turbo.json | 28 ++ apps/wcag-ada/worker/Dockerfile | 84 +++++ apps/wcag-ada/worker/package.json | 29 ++ apps/wcag-ada/worker/src/config/index.ts | 38 ++ .../handlers/accessibility-scan.handler.ts | 312 +++++++++++++++++ apps/wcag-ada/worker/src/index.ts | 47 +++ apps/wcag-ada/worker/src/services/health.ts | 90 +++++ apps/wcag-ada/worker/src/services/prisma.ts | 55 +++ .../wcag-ada/worker/src/services/scheduler.ts | 139 ++++++++ apps/wcag-ada/worker/src/utils/logger.ts | 16 + apps/wcag-ada/worker/src/utils/prisma.ts | 14 + apps/wcag-ada/worker/src/utils/redis.ts | 14 + apps/wcag-ada/worker/src/utils/shutdown.ts | 37 ++ .../worker/src/workers/scan-worker.ts | 152 ++++++++ apps/wcag-ada/worker/tsconfig.json | 15 + 112 files changed, 9069 insertions(+) create mode 100644 apps/wcag-ada/.dockerignore create mode 100644 apps/wcag-ada/.env.example create mode 100644 apps/wcag-ada/.gitlab-ci.yml create mode 100644 apps/wcag-ada/DEPLOYMENT.md create mode 100644 apps/wcag-ada/Dockerfile create mode 100644 apps/wcag-ada/Makefile create mode 100644 apps/wcag-ada/README.md create mode 100644 apps/wcag-ada/api/.env.example create mode 100644 apps/wcag-ada/api/Dockerfile create mode 100644 apps/wcag-ada/api/package.json create mode 100644 apps/wcag-ada/api/prisma/schema.prisma create mode 100644 apps/wcag-ada/api/src/config.ts create mode 100644 apps/wcag-ada/api/src/index.ts create mode 100644 apps/wcag-ada/api/src/middleware/auth.ts create mode 100644 apps/wcag-ada/api/src/middleware/error-handler.ts create mode 100644 apps/wcag-ada/api/src/middleware/rate-limiter.ts create mode 100644 apps/wcag-ada/api/src/routes/auth.ts create mode 100644 apps/wcag-ada/api/src/routes/health.ts create mode 100644 apps/wcag-ada/api/src/routes/reports.ts create mode 100644 apps/wcag-ada/api/src/routes/scans.ts create mode 100644 apps/wcag-ada/api/src/routes/websites.ts create mode 100644 apps/wcag-ada/api/src/services/report-generator.ts create mode 100644 apps/wcag-ada/api/src/utils/prisma.ts create mode 100644 apps/wcag-ada/api/tsconfig.json create mode 100644 apps/wcag-ada/config/README.md create mode 100644 apps/wcag-ada/config/config/default.json create mode 100644 apps/wcag-ada/config/config/development.json create mode 100644 apps/wcag-ada/config/config/production.json create mode 100644 apps/wcag-ada/config/config/test.json create mode 100644 apps/wcag-ada/config/package.json create mode 100644 apps/wcag-ada/config/src/config-instance.ts create mode 100644 apps/wcag-ada/config/src/index.ts create mode 100644 apps/wcag-ada/config/src/schemas/features.schema.ts create mode 100644 apps/wcag-ada/config/src/schemas/providers.schema.ts create mode 100644 apps/wcag-ada/config/src/schemas/scanner.schema.ts create mode 100644 apps/wcag-ada/config/src/schemas/wcag-app.schema.ts create mode 100644 apps/wcag-ada/config/src/schemas/worker.schema.ts create mode 100644 apps/wcag-ada/config/tsconfig.json create mode 100644 apps/wcag-ada/dashboard/Dockerfile create mode 100644 apps/wcag-ada/dashboard/index.html create mode 100644 apps/wcag-ada/dashboard/nginx.conf create mode 100644 apps/wcag-ada/dashboard/package.json create mode 100644 apps/wcag-ada/dashboard/postcss.config.js create mode 100644 apps/wcag-ada/dashboard/public/icon.svg create mode 100644 apps/wcag-ada/dashboard/src/App.tsx create mode 100644 apps/wcag-ada/dashboard/src/components/layout/auth-layout.tsx create mode 100644 apps/wcag-ada/dashboard/src/components/layout/main-layout.tsx create mode 100644 apps/wcag-ada/dashboard/src/components/layout/protected-route.tsx create mode 100644 apps/wcag-ada/dashboard/src/components/ui/button.tsx create mode 100644 apps/wcag-ada/dashboard/src/components/ui/card.tsx create mode 100644 apps/wcag-ada/dashboard/src/lib/api-client.ts create mode 100644 apps/wcag-ada/dashboard/src/lib/utils.ts create mode 100644 apps/wcag-ada/dashboard/src/main.tsx create mode 100644 apps/wcag-ada/dashboard/src/pages/auth/login.tsx create mode 100644 apps/wcag-ada/dashboard/src/pages/auth/register.tsx create mode 100644 apps/wcag-ada/dashboard/src/pages/dashboard/index.tsx create mode 100644 apps/wcag-ada/dashboard/src/pages/reports/index.tsx create mode 100644 apps/wcag-ada/dashboard/src/pages/scans/[id].tsx create mode 100644 apps/wcag-ada/dashboard/src/pages/scans/index.tsx create mode 100644 apps/wcag-ada/dashboard/src/pages/settings/index.tsx create mode 100644 apps/wcag-ada/dashboard/src/pages/websites/[id].tsx create mode 100644 apps/wcag-ada/dashboard/src/pages/websites/index.tsx create mode 100644 apps/wcag-ada/dashboard/src/store/auth-store.ts create mode 100644 apps/wcag-ada/dashboard/src/styles/globals.css create mode 100644 apps/wcag-ada/dashboard/tailwind.config.js create mode 100644 apps/wcag-ada/dashboard/tsconfig.json create mode 100644 apps/wcag-ada/dashboard/vite.config.ts create mode 100644 apps/wcag-ada/docker-compose.dev.yml create mode 100644 apps/wcag-ada/docker-compose.test.yml create mode 100644 apps/wcag-ada/docker-compose.yml create mode 100644 apps/wcag-ada/k8s/api.yaml create mode 100644 apps/wcag-ada/k8s/configmap.yaml create mode 100644 apps/wcag-ada/k8s/dashboard.yaml create mode 100644 apps/wcag-ada/k8s/ingress.yaml create mode 100644 apps/wcag-ada/k8s/namespace.yaml create mode 100644 apps/wcag-ada/k8s/postgres.yaml create mode 100644 apps/wcag-ada/k8s/redis.yaml create mode 100644 apps/wcag-ada/k8s/secrets.yaml create mode 100644 apps/wcag-ada/k8s/worker.yaml create mode 100644 apps/wcag-ada/package.json create mode 100644 apps/wcag-ada/scanner/README.md create mode 100644 apps/wcag-ada/scanner/package.json create mode 100644 apps/wcag-ada/scanner/src/core/accessibility-browser.ts create mode 100644 apps/wcag-ada/scanner/src/core/scanner.ts create mode 100644 apps/wcag-ada/scanner/src/example.ts create mode 100644 apps/wcag-ada/scanner/src/index.ts create mode 100644 apps/wcag-ada/scanner/tsconfig.json create mode 100755 apps/wcag-ada/scripts/build-images.sh create mode 100755 apps/wcag-ada/scripts/deploy-k8s.sh create mode 100755 apps/wcag-ada/scripts/local-dev.sh create mode 100644 apps/wcag-ada/shared/package.json create mode 100644 apps/wcag-ada/shared/src/constants.ts create mode 100644 apps/wcag-ada/shared/src/index.ts create mode 100644 apps/wcag-ada/shared/src/types.ts create mode 100644 apps/wcag-ada/shared/src/utils.ts create mode 100644 apps/wcag-ada/shared/tsconfig.json create mode 100644 apps/wcag-ada/tsconfig.json create mode 100644 apps/wcag-ada/turbo.json create mode 100644 apps/wcag-ada/worker/Dockerfile create mode 100644 apps/wcag-ada/worker/package.json create mode 100644 apps/wcag-ada/worker/src/config/index.ts create mode 100644 apps/wcag-ada/worker/src/handlers/accessibility-scan.handler.ts create mode 100644 apps/wcag-ada/worker/src/index.ts create mode 100644 apps/wcag-ada/worker/src/services/health.ts create mode 100644 apps/wcag-ada/worker/src/services/prisma.ts create mode 100644 apps/wcag-ada/worker/src/services/scheduler.ts create mode 100644 apps/wcag-ada/worker/src/utils/logger.ts create mode 100644 apps/wcag-ada/worker/src/utils/prisma.ts create mode 100644 apps/wcag-ada/worker/src/utils/redis.ts create mode 100644 apps/wcag-ada/worker/src/utils/shutdown.ts create mode 100644 apps/wcag-ada/worker/src/workers/scan-worker.ts create mode 100644 apps/wcag-ada/worker/tsconfig.json diff --git a/apps/wcag-ada/.dockerignore b/apps/wcag-ada/.dockerignore new file mode 100644 index 0000000..6cd65d9 --- /dev/null +++ b/apps/wcag-ada/.dockerignore @@ -0,0 +1,70 @@ +# Dependencies +**/node_modules +**/.pnp +**/.pnp.js + +# Testing +**/coverage +**/.nyc_output + +# Production builds +**/dist +**/build +**/.next +**/out + +# Logs +**/*.log +**/npm-debug.log* +**/yarn-debug.log* +**/yarn-error.log* +**/lerna-debug.log* +**/.pnpm-debug.log* + +# Environment files +**/.env +**/.env.local +**/.env.development.local +**/.env.test.local +**/.env.production.local + +# IDE +**/.idea +**/.vscode +**/*.swp +**/*.swo +**/*~ +**/.DS_Store + +# Git +**/.git +**/.gitignore + +# Docker +**/Dockerfile +**/docker-compose*.yml +**/.dockerignore + +# Documentation +**/README.md +**/CHANGELOG.md +**/docs + +# Misc +**/.eslintcache +**/.cache +**/tmp +**/.turbo + +# Database +**/prisma/migrations/migration_lock.toml +**/*.db +**/*.db-journal + +# Only for root level (not in apps/*) +/apps/stock +/apps/db-design +/apps/cline +/lib/!(service) +!/lib/service/core-config +!/lib/service/core-logger \ No newline at end of file diff --git a/apps/wcag-ada/.env.example b/apps/wcag-ada/.env.example new file mode 100644 index 0000000..3adb76d --- /dev/null +++ b/apps/wcag-ada/.env.example @@ -0,0 +1,61 @@ +# Application Environment +NODE_ENV=development +APP_NAME=wcag-ada + +# API Configuration +API_PORT=3001 +API_CORS_ORIGIN=http://localhost:5173 +API_JWT_SECRET=your-super-secret-jwt-key-change-in-production +API_JWT_EXPIRES_IN=7d +API_RATE_LIMIT=100 +API_RATE_WINDOW=60000 + +# Worker Configuration +WORKER_PORT=3002 +WORKER_CONCURRENCY=5 +WORKER_QUEUE_NAME=accessibility-scans + +# Database Configuration +DATABASE_URL=postgresql://user:password@localhost:5432/wcag_ada + +# Redis Configuration +REDIS_HOST=localhost +REDIS_PORT=6379 +REDIS_PASSWORD= +REDIS_DB=0 + +# Scanner Configuration +SCANNER_HEADLESS=true +SCANNER_TIMEOUT=30000 +SCANNER_VIEWPORT_WIDTH=1920 +SCANNER_VIEWPORT_HEIGHT=1080 +SCANNER_BLOCK_RESOURCES=true +SCANNER_BLOCK_PATTERNS=font,image + +# Features +FEATURES_SCREENSHOTS=true +FEATURES_FIX_SUGGESTIONS=true +FEATURES_CUSTOM_RULES=true +FEATURES_SCHEDULED_SCANS=true +FEATURES_BULK_SCANNING=false +FEATURES_API_ACCESS=true + +# Third-party Services (optional) +SMTP_HOST= +SMTP_PORT=587 +SMTP_USER= +SMTP_PASS= +SMTP_FROM=noreply@wcag-ada.com + +SLACK_WEBHOOK_URL= +SLACK_CHANNEL=#wcag-alerts + +# Logging +LOG_LEVEL=info +LOG_FORMAT=json + +# Scheduler +SCHEDULER_TIMEZONE=America/New_York +SCHEDULER_MAX_CONCURRENT_SCANS=3 +SCHEDULER_RETRY_ATTEMPTS=3 +SCHEDULER_RETRY_DELAY=5000 \ No newline at end of file diff --git a/apps/wcag-ada/.gitlab-ci.yml b/apps/wcag-ada/.gitlab-ci.yml new file mode 100644 index 0000000..4ce635a --- /dev/null +++ b/apps/wcag-ada/.gitlab-ci.yml @@ -0,0 +1,181 @@ +stages: + - build + - test + - deploy + +variables: + DOCKER_DRIVER: overlay2 + DOCKER_TLS_CERTDIR: "" + REGISTRY: $CI_REGISTRY + API_IMAGE: $CI_REGISTRY_IMAGE/api + WORKER_IMAGE: $CI_REGISTRY_IMAGE/worker + DASHBOARD_IMAGE: $CI_REGISTRY_IMAGE/dashboard + +# Cache node_modules between jobs +.node_cache: &node_cache + cache: + key: ${CI_COMMIT_REF_SLUG} + paths: + - node_modules/ + - apps/wcag-ada/*/node_modules/ + - .bun/ + +# Docker login before each job +before_script: + - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY + +# Build stage +build:api: + stage: build + image: docker:24-dind + services: + - docker:24-dind + script: + - docker build -f apps/wcag-ada/api/Dockerfile -t $API_IMAGE:$CI_COMMIT_SHA . + - docker tag $API_IMAGE:$CI_COMMIT_SHA $API_IMAGE:latest + - docker push $API_IMAGE:$CI_COMMIT_SHA + - docker push $API_IMAGE:latest + only: + - main + - develop + - merge_requests + tags: + - docker + +build:worker: + stage: build + image: docker:24-dind + services: + - docker:24-dind + script: + - docker build -f apps/wcag-ada/worker/Dockerfile -t $WORKER_IMAGE:$CI_COMMIT_SHA . + - docker tag $WORKER_IMAGE:$CI_COMMIT_SHA $WORKER_IMAGE:latest + - docker push $WORKER_IMAGE:$CI_COMMIT_SHA + - docker push $WORKER_IMAGE:latest + only: + - main + - develop + - merge_requests + tags: + - docker + +build:dashboard: + stage: build + image: docker:24-dind + services: + - docker:24-dind + script: + - docker build -f apps/wcag-ada/dashboard/Dockerfile -t $DASHBOARD_IMAGE:$CI_COMMIT_SHA . + - docker tag $DASHBOARD_IMAGE:$CI_COMMIT_SHA $DASHBOARD_IMAGE:latest + - docker push $DASHBOARD_IMAGE:$CI_COMMIT_SHA + - docker push $DASHBOARD_IMAGE:latest + only: + - main + - develop + - merge_requests + tags: + - docker + +# Test stage +test:unit: + stage: test + image: oven/bun:1-alpine + <<: *node_cache + before_script: + - cd apps/wcag-ada + - bun install + script: + - bun test + only: + - merge_requests + tags: + - docker + +test:lint: + stage: test + image: oven/bun:1-alpine + <<: *node_cache + before_script: + - cd apps/wcag-ada + - bun install + script: + - bun run lint + only: + - merge_requests + tags: + - docker + +test:typecheck: + stage: test + image: oven/bun:1-alpine + <<: *node_cache + before_script: + - cd apps/wcag-ada + - bun install + script: + - bun run typecheck + only: + - merge_requests + tags: + - docker + +# Deploy to staging +deploy:staging: + stage: deploy + image: bitnami/kubectl:latest + before_script: + - kubectl config set-cluster k8s --server="$KUBE_URL" --insecure-skip-tls-verify=true + - kubectl config set-credentials gitlab --token="$KUBE_TOKEN" + - kubectl config set-context default --cluster=k8s --user=gitlab + - kubectl config use-context default + script: + - kubectl apply -f apps/wcag-ada/k8s/namespace.yaml + - kubectl apply -f apps/wcag-ada/k8s/configmap.yaml + - kubectl apply -f apps/wcag-ada/k8s/secrets.yaml + - kubectl apply -f apps/wcag-ada/k8s/postgres.yaml + - kubectl apply -f apps/wcag-ada/k8s/redis.yaml + - | + kubectl set image deployment/wcag-ada-api api=$API_IMAGE:$CI_COMMIT_SHA -n wcag-ada + kubectl set image deployment/wcag-ada-worker worker=$WORKER_IMAGE:$CI_COMMIT_SHA -n wcag-ada + kubectl set image deployment/wcag-ada-dashboard dashboard=$DASHBOARD_IMAGE:$CI_COMMIT_SHA -n wcag-ada + - kubectl rollout status deployment/wcag-ada-api -n wcag-ada + - kubectl rollout status deployment/wcag-ada-worker -n wcag-ada + - kubectl rollout status deployment/wcag-ada-dashboard -n wcag-ada + environment: + name: staging + url: https://staging.wcag-ada.example.com + only: + - develop + tags: + - docker + +# Deploy to production +deploy:production: + stage: deploy + image: bitnami/kubectl:latest + before_script: + - kubectl config set-cluster k8s --server="$KUBE_URL_PROD" --insecure-skip-tls-verify=true + - kubectl config set-credentials gitlab --token="$KUBE_TOKEN_PROD" + - kubectl config set-context default --cluster=k8s --user=gitlab + - kubectl config use-context default + script: + - kubectl apply -f apps/wcag-ada/k8s/namespace.yaml + - kubectl apply -f apps/wcag-ada/k8s/configmap.yaml + - kubectl apply -f apps/wcag-ada/k8s/secrets.yaml + - kubectl apply -f apps/wcag-ada/k8s/postgres.yaml + - kubectl apply -f apps/wcag-ada/k8s/redis.yaml + - | + kubectl set image deployment/wcag-ada-api api=$API_IMAGE:$CI_COMMIT_SHA -n wcag-ada + kubectl set image deployment/wcag-ada-worker worker=$WORKER_IMAGE:$CI_COMMIT_SHA -n wcag-ada + kubectl set image deployment/wcag-ada-dashboard dashboard=$DASHBOARD_IMAGE:$CI_COMMIT_SHA -n wcag-ada + - kubectl rollout status deployment/wcag-ada-api -n wcag-ada + - kubectl rollout status deployment/wcag-ada-worker -n wcag-ada + - kubectl rollout status deployment/wcag-ada-dashboard -n wcag-ada + environment: + name: production + url: https://wcag-ada.example.com + only: + - main + when: manual + tags: + - docker \ No newline at end of file diff --git a/apps/wcag-ada/DEPLOYMENT.md b/apps/wcag-ada/DEPLOYMENT.md new file mode 100644 index 0000000..bcf0f9c --- /dev/null +++ b/apps/wcag-ada/DEPLOYMENT.md @@ -0,0 +1,328 @@ +# WCAG-ADA Deployment Guide + +## Overview + +This guide covers deployment options for the WCAG-ADA Compliance Monitoring Platform. + +## Table of Contents + +1. [Local Development](#local-development) +2. [Docker Deployment](#docker-deployment) +3. [Kubernetes Deployment](#kubernetes-deployment) +4. [GitLab CI/CD](#gitlab-cicd) +5. [Configuration Management](#configuration-management) +6. [Monitoring & Maintenance](#monitoring--maintenance) + +## Local Development + +### Quick Start + +```bash +# Run the setup script +./scripts/local-dev.sh + +# Start all services +bun run dev +``` + +### Manual Setup + +1. **Start development databases:** + ```bash + docker-compose -f docker-compose.dev.yml up -d + ``` + +2. **Configure environment:** + ```bash + cp .env.example .env + # Edit .env with your settings + ``` + +3. **Install dependencies:** + ```bash + bun install + ``` + +4. **Run migrations:** + ```bash + cd api && bunx prisma migrate dev + ``` + +5. **Start services:** + ```bash + # Terminal 1 - API + cd api && bun run dev + + # Terminal 2 - Worker + cd worker && bun run dev + + # Terminal 3 - Dashboard + cd dashboard && bun run dev + ``` + +## Docker Deployment + +### Build Images + +```bash +# Using the build script +./scripts/build-images.sh + +# Or manually with docker-compose +docker-compose build +``` + +### Run with Docker Compose + +```bash +# Start all services +docker-compose up -d + +# View logs +docker-compose logs -f + +# Stop services +docker-compose down +``` + +### Production Docker Deployment + +1. **Set environment variables:** + ```bash + export API_JWT_SECRET=your-secret-key + export DATABASE_URL=postgresql://user:pass@host:5432/db + # ... other required variables + ``` + +2. **Run with production config:** + ```bash + docker-compose -f docker-compose.yml up -d + ``` + +## Kubernetes Deployment + +### Prerequisites + +- Kubernetes cluster (1.24+) +- kubectl configured +- Ingress controller installed +- cert-manager (for TLS) + +### Deploy to Kubernetes + +```bash +# Using the deploy script +./scripts/deploy-k8s.sh + +# Or manually +kubectl apply -f k8s/ +``` + +### Configuration + +1. **Update secrets:** + ```bash + # Edit k8s/secrets.yaml with your values + # Then apply + kubectl apply -f k8s/secrets.yaml -n wcag-ada + ``` + +2. **Update ingress hosts:** + ```bash + # Edit k8s/ingress.yaml + # Replace example.com with your domain + kubectl apply -f k8s/ingress.yaml -n wcag-ada + ``` + +### Scaling + +```bash +# Scale API replicas +kubectl scale deployment wcag-ada-api --replicas=5 -n wcag-ada + +# Scale Worker replicas +kubectl scale deployment wcag-ada-worker --replicas=3 -n wcag-ada +``` + +## GitLab CI/CD + +### Setup + +1. **Configure GitLab Container Registry:** + - Enable container registry in project settings + - Note your registry URL + +2. **Add CI/CD Variables:** + ``` + CI_REGISTRY_USER - GitLab username + CI_REGISTRY_PASSWORD - GitLab access token + KUBE_URL - Kubernetes API URL (staging) + KUBE_TOKEN - Kubernetes service account token (staging) + KUBE_URL_PROD - Kubernetes API URL (production) + KUBE_TOKEN_PROD - Kubernetes service account token (production) + ``` + +3. **Configure environments:** + - Create `staging` and `production` environments in GitLab + - Set appropriate URLs + +### Deployment Flow + +1. **Development:** + - Push to `develop` branch + - Automatically builds and deploys to staging + +2. **Production:** + - Merge to `main` branch + - Builds images automatically + - Manual approval required for production deployment + +## Configuration Management + +### Environment Variables + +Key configuration variables: + +```bash +# Application +NODE_ENV=production +APP_NAME=wcag-ada + +# API +API_PORT=3001 +API_JWT_SECRET= +API_CORS_ORIGIN=https://your-domain.com + +# Database +DATABASE_URL=postgresql://user:password@host:5432/wcag_ada + +# Redis +REDIS_HOST=redis +REDIS_PORT=6379 +REDIS_PASSWORD= + +# Worker +WORKER_CONCURRENCY=5 +WORKER_QUEUE_NAME=accessibility-scans + +# Scanner +SCANNER_HEADLESS=true +SCANNER_TIMEOUT=30000 +``` + +### Configuration Priority + +1. Command-line arguments (highest) +2. Environment variables +3. Configuration files +4. Default values (lowest) + +### Secrets Management + +- **Development:** Use `.env` files +- **Docker:** Use environment variables or Docker secrets +- **Kubernetes:** Use Kubernetes secrets +- **Production:** Consider using: + - HashiCorp Vault + - AWS Secrets Manager + - Azure Key Vault + - GitLab CI/CD variables + +## Monitoring & Maintenance + +### Health Checks + +All services expose health endpoints: + +- API: `http://api:3001/health` +- Worker: `http://worker:3002/health` +- Dashboard: `http://dashboard:8080/health` + +### Monitoring Stack + +Recommended monitoring setup: + +1. **Metrics:** Prometheus + Grafana +2. **Logs:** ELK Stack or Loki +3. **Traces:** Jaeger or Zipkin +4. **Uptime:** Uptime Kuma or Pingdom + +### Database Maintenance + +```bash +# Backup PostgreSQL +kubectl exec -it postgres-pod -n wcag-ada -- pg_dump -U wcag_user wcag_ada > backup.sql + +# Backup Redis +kubectl exec -it redis-pod -n wcag-ada -- redis-cli BGSAVE +``` + +### Updates and Migrations + +1. **Database migrations:** + ```bash + # Run migrations before deployment + kubectl run migration --rm -it --image=wcag-ada/api:latest -- bunx prisma migrate deploy + ``` + +2. **Rolling updates:** + ```bash + # Update image + kubectl set image deployment/wcag-ada-api api=wcag-ada/api:new-version -n wcag-ada + + # Monitor rollout + kubectl rollout status deployment/wcag-ada-api -n wcag-ada + ``` + +### Troubleshooting + +1. **View logs:** + ```bash + # Kubernetes + kubectl logs -f deployment/wcag-ada-api -n wcag-ada + + # Docker + docker-compose logs -f api + ``` + +2. **Debug pods:** + ```bash + # Get pod details + kubectl describe pod -n wcag-ada + + # Execute commands in pod + kubectl exec -it -n wcag-ada -- /bin/sh + ``` + +3. **Common issues:** + - Database connection: Check DATABASE_URL and network connectivity + - Redis connection: Verify Redis is running and accessible + - Browser issues: Ensure Chromium dependencies are installed + - Memory issues: Increase resource limits in Kubernetes + +## Security Considerations + +1. **Network Security:** + - Use NetworkPolicies in Kubernetes + - Implement proper firewall rules + - Use TLS for all external communications + +2. **Application Security:** + - Rotate JWT secrets regularly + - Use strong passwords for databases + - Implement rate limiting + - Enable CORS appropriately + +3. **Container Security:** + - Run containers as non-root user + - Use minimal base images + - Scan images for vulnerabilities + - Keep dependencies updated + +## Support + +For issues or questions: +1. Check application logs +2. Review health check endpoints +3. Consult error messages in dashboard +4. Check GitLab CI/CD pipeline status \ No newline at end of file diff --git a/apps/wcag-ada/Dockerfile b/apps/wcag-ada/Dockerfile new file mode 100644 index 0000000..1a34788 --- /dev/null +++ b/apps/wcag-ada/Dockerfile @@ -0,0 +1,85 @@ +# This is a multi-service Dockerfile that builds all services +# Use with --target flag to build specific services + +# Base stage for all services +FROM oven/bun:1-alpine as base +RUN apk add --no-cache openssl +WORKDIR /app + +# Dependencies stage +FROM base as deps +COPY package.json bun.lockb ./ +COPY apps/wcag-ada/*/package.json ./apps/wcag-ada/ +COPY lib/service/core-config/package.json ./lib/service/core-config/ +COPY lib/service/core-logger/package.json ./lib/service/core-logger/ +RUN bun install --frozen-lockfile + +# Builder stage +FROM base as builder +COPY --from=deps /app/node_modules ./node_modules +COPY --from=deps /app/package.json ./package.json +COPY . . +WORKDIR /app/apps/wcag-ada + +# Generate Prisma clients +RUN cd api && bunx prisma generate +RUN cd worker && bunx prisma generate + +# Build all services +RUN bun run build + +# API Runtime +FROM base as api +COPY --from=builder /app/apps/wcag-ada/api/dist ./dist +COPY --from=builder /app/apps/wcag-ada/api/node_modules ./node_modules +COPY --from=builder /app/apps/wcag-ada/api/prisma ./prisma +COPY --from=builder /app/apps/wcag-ada/api/package.json ./ + +RUN addgroup -g 1001 -S nodejs && adduser -S nodejs -u 1001 +RUN chown -R nodejs:nodejs /app +USER nodejs + +EXPOSE 3001 +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://localhost:3001/health || exit 1 +CMD ["bun", "run", "start"] + +# Worker Runtime +FROM base as worker +RUN apk add --no-cache chromium nss freetype harfbuzz ca-certificates ttf-freefont + +ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true \ + PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium-browser \ + PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=true \ + PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/usr/bin/chromium-browser + +COPY --from=builder /app/apps/wcag-ada/worker/dist ./dist +COPY --from=builder /app/apps/wcag-ada/worker/node_modules ./node_modules +COPY --from=builder /app/apps/wcag-ada/worker/prisma ./prisma +COPY --from=builder /app/apps/wcag-ada/worker/package.json ./ + +RUN addgroup -g 1001 -S nodejs && adduser -S nodejs -u 1001 +RUN chown -R nodejs:nodejs /app +USER nodejs + +EXPOSE 3002 +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://localhost:3002/health || exit 1 +CMD ["bun", "run", "start"] + +# Dashboard Runtime +FROM nginx:alpine as dashboard +RUN apk add --no-cache curl + +COPY apps/wcag-ada/dashboard/nginx.conf /etc/nginx/nginx.conf +COPY --from=builder /app/apps/wcag-ada/dashboard/dist /usr/share/nginx/html + +RUN addgroup -g 1001 -S nodejs && adduser -S nodejs -u 1001 +RUN chown -R nodejs:nodejs /usr/share/nginx/html /var/cache/nginx /var/log/nginx /etc/nginx/conf.d +RUN touch /var/run/nginx.pid && chown -R nodejs:nodejs /var/run/nginx.pid +USER nodejs + +EXPOSE 8080 +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD curl -f http://localhost:8080/ || exit 1 +CMD ["nginx", "-g", "daemon off;"] \ No newline at end of file diff --git a/apps/wcag-ada/Makefile b/apps/wcag-ada/Makefile new file mode 100644 index 0000000..ef5355b --- /dev/null +++ b/apps/wcag-ada/Makefile @@ -0,0 +1,85 @@ +.PHONY: help +help: ## Show this help message + @echo 'Usage: make [target]' + @echo '' + @echo 'Targets:' + @awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf " %-20s %s\n", $$1, $$2}' $(MAKEFILE_LIST) + +.PHONY: dev-up +dev-up: ## Start development databases (PostgreSQL and Redis) + docker-compose -f docker-compose.dev.yml up -d + +.PHONY: dev-down +dev-down: ## Stop development databases + docker-compose -f docker-compose.dev.yml down + +.PHONY: dev-logs +dev-logs: ## Show development database logs + docker-compose -f docker-compose.dev.yml logs -f + +.PHONY: build +build: ## Build all Docker images + docker-compose build + +.PHONY: up +up: ## Start all services + docker-compose up -d + +.PHONY: down +down: ## Stop all services + docker-compose down + +.PHONY: logs +logs: ## Show logs for all services + docker-compose logs -f + +.PHONY: logs-api +logs-api: ## Show API service logs + docker-compose logs -f api + +.PHONY: logs-worker +logs-worker: ## Show worker service logs + docker-compose logs -f worker + +.PHONY: restart +restart: ## Restart all services + docker-compose restart + +.PHONY: restart-api +restart-api: ## Restart API service + docker-compose restart api + +.PHONY: restart-worker +restart-worker: ## Restart worker service + docker-compose restart worker + +.PHONY: ps +ps: ## Show running services + docker-compose ps + +.PHONY: exec-api +exec-api: ## Execute bash in API container + docker-compose exec api /bin/sh + +.PHONY: exec-worker +exec-worker: ## Execute bash in worker container + docker-compose exec worker /bin/sh + +.PHONY: migrate +migrate: ## Run database migrations + docker-compose run --rm migrate + +.PHONY: clean +clean: ## Remove all containers and volumes + docker-compose down -v + +.PHONY: rebuild +rebuild: clean build up ## Clean, rebuild, and start all services + +.PHONY: test-scan +test-scan: ## Run a test accessibility scan + @echo "Running test scan on example.com..." + @curl -X POST http://localhost:3001/api/scans \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer YOUR_JWT_TOKEN" \ + -d '{"websiteId": "test-website-id", "url": "https://example.com"}' \ No newline at end of file diff --git a/apps/wcag-ada/README.md b/apps/wcag-ada/README.md new file mode 100644 index 0000000..797b8bd --- /dev/null +++ b/apps/wcag-ada/README.md @@ -0,0 +1,175 @@ +# WCAG-ADA Compliance Monitoring Platform + +An automated web accessibility compliance monitoring system that continuously scans websites for WCAG and ADA compliance issues, provides detailed reports, and offers remediation suggestions. + +## Architecture + +The platform consists of four main services: + +### 1. **Scanner Service** (`/scanner`) +- Performs accessibility scans using axe-core and Playwright +- Supports WCAG 2.0, 2.1, and 2.2 compliance levels (A, AA, AAA) +- Generates detailed violation reports with fix suggestions +- Captures screenshots of accessibility issues + +### 2. **API Service** (`/api`) +- RESTful API built with Hono framework +- JWT-based authentication +- Redis-backed rate limiting +- PostgreSQL database with Prisma ORM +- BullMQ integration for job queuing + +### 3. **Worker Service** (`/worker`) +- Processes scan jobs from the queue +- Runs scheduled scans based on website configurations +- Handles scan result processing and storage +- Provides health monitoring endpoints + +### 4. **Dashboard** (`/dashboard`) +- React 18 SPA with Vite +- Real-time scan monitoring +- Compliance reporting and analytics +- Website and scan management +- Built with TailwindCSS and Radix UI + +## Configuration + +The application uses a sophisticated configuration system that loads settings from multiple sources: + +1. Default values (defined in schemas) +2. Configuration files (`.env`, `config.json`) +3. Environment variables +4. Command-line arguments + +### Key Configuration Files + +- `/config/src/schemas/` - Zod schemas defining all configuration options +- `/config/src/config-instance.ts` - Main configuration loader +- `.env` - Environment-specific settings + +## Getting Started + +### Prerequisites + +- Node.js 18+ +- PostgreSQL +- Redis +- Bun (recommended) or npm/yarn + +### Installation + +1. Install dependencies: +```bash +bun install +``` + +2. Set up the database: +```bash +cd apps/wcag-ada/api +bunx prisma migrate dev +``` + +3. Configure environment variables: +```bash +cp .env.example .env +# Edit .env with your settings +``` + +### Running Services + +Start all services concurrently: +```bash +bun run dev +``` + +Or run individual services: + +```bash +# API +cd apps/wcag-ada/api +bun run dev + +# Worker +cd apps/wcag-ada/worker +bun run dev + +# Dashboard +cd apps/wcag-ada/dashboard +bun run dev + +# Scanner (for testing) +cd apps/wcag-ada/scanner +bun run example +``` + +## API Endpoints + +### Authentication +- `POST /api/auth/register` - Register new user +- `POST /api/auth/login` - User login +- `GET /api/auth/me` - Get current user + +### Websites +- `GET /api/websites` - List user's websites +- `POST /api/websites` - Add new website +- `PUT /api/websites/:id` - Update website +- `DELETE /api/websites/:id` - Delete website + +### Scans +- `POST /api/scans` - Start new scan +- `GET /api/scans/:id` - Get scan status +- `GET /api/scans/:id/result` - Get scan results +- `GET /api/scans/:id/violations` - Get violations +- `DELETE /api/scans/:id` - Cancel scan + +### Reports +- `GET /api/reports/compliance/:websiteId` - Get compliance report +- `GET /api/reports/trends/:websiteId` - Get trend analysis + +### Health +- `GET /health` - Service health check +- `GET /health/stats` - Detailed statistics + +## Development + +### Project Structure + +``` +apps/wcag-ada/ +├── api/ # REST API service +├── config/ # Configuration management +├── dashboard/ # React web interface +├── scanner/ # Accessibility scanning engine +├── shared/ # Shared types and utilities +└── worker/ # Background job processor +``` + +### Testing + +Run tests for all services: +```bash +bun test +``` + +### Building for Production + +```bash +bun run build +``` + +## Deployment + +The application is designed to be deployed as separate microservices. Each service can be containerized and deployed independently. + +### Docker Support + +Build Docker images: +```bash +docker build -t wcag-ada-api -f apps/wcag-ada/api/Dockerfile . +docker build -t wcag-ada-worker -f apps/wcag-ada/worker/Dockerfile . +docker build -t wcag-ada-dashboard -f apps/wcag-ada/dashboard/Dockerfile . +``` + +## License + +Proprietary - All rights reserved \ No newline at end of file diff --git a/apps/wcag-ada/api/.env.example b/apps/wcag-ada/api/.env.example new file mode 100644 index 0000000..83e7f1d --- /dev/null +++ b/apps/wcag-ada/api/.env.example @@ -0,0 +1,40 @@ +# Database URL (required for Prisma) +DATABASE_URL=postgresql://user:password@localhost:5432/wcag_ada + +# Environment variables for configuration overrides +# These follow the WCAG_ prefix convention + +# Override service ports +# WCAG_SERVICES_API_PORT=3001 +# WCAG_SERVICES_DASHBOARD_PORT=3000 +# WCAG_SERVICES_WORKER_PORT=3002 + +# Override database settings +# WCAG_DATABASE_POSTGRES_HOST=localhost +# WCAG_DATABASE_POSTGRES_PORT=5432 +# WCAG_DATABASE_POSTGRES_DATABASE=wcag_ada + +# Override Redis settings +# WCAG_WORKER_REDIS_HOST=localhost +# WCAG_WORKER_REDIS_PORT=6379 +# WCAG_WORKER_REDIS_PASSWORD= +# WCAG_WORKER_REDIS_DB=2 + +# Override JWT settings +# WCAG_PROVIDERS_AUTH_JWT_SECRET=your-super-secret-key +# WCAG_PROVIDERS_AUTH_JWT_EXPIRES_IN=7d + +# Override scanner settings +# WCAG_SCANNER_CONCURRENCY=2 +# WCAG_SCANNER_TIMEOUT=120000 +# WCAG_SCANNER_HEADLESS=true + +# Override storage settings +# WCAG_PROVIDERS_STORAGE_TYPE=local +# WCAG_PROVIDERS_STORAGE_LOCAL_BASE_PATH=/tmp/wcag-ada + +# Override email settings +# WCAG_PROVIDERS_EMAIL_SMTP_HOST=localhost +# WCAG_PROVIDERS_EMAIL_SMTP_PORT=587 +# WCAG_PROVIDERS_EMAIL_SMTP_AUTH_USER= +# WCAG_PROVIDERS_EMAIL_SMTP_AUTH_PASS= \ No newline at end of file diff --git a/apps/wcag-ada/api/Dockerfile b/apps/wcag-ada/api/Dockerfile new file mode 100644 index 0000000..b4c221a --- /dev/null +++ b/apps/wcag-ada/api/Dockerfile @@ -0,0 +1,66 @@ +# Build stage +FROM oven/bun:1-alpine as builder + +# Install dependencies for Prisma +RUN apk add --no-cache openssl + +WORKDIR /app + +# Copy workspace files +COPY package.json bun.lockb ./ +COPY apps/wcag-ada/api/package.json ./apps/wcag-ada/api/ +COPY apps/wcag-ada/config/package.json ./apps/wcag-ada/config/ +COPY apps/wcag-ada/shared/package.json ./apps/wcag-ada/shared/ +COPY lib/service/core-config/package.json ./lib/service/core-config/ +COPY lib/service/core-logger/package.json ./lib/service/core-logger/ + +# Install dependencies +RUN bun install --frozen-lockfile + +# Copy source code +COPY apps/wcag-ada/api ./apps/wcag-ada/api +COPY apps/wcag-ada/config ./apps/wcag-ada/config +COPY apps/wcag-ada/shared ./apps/wcag-ada/shared +COPY lib/service/core-config ./lib/service/core-config +COPY lib/service/core-logger ./lib/service/core-logger +COPY tsconfig.json ./ + +# Generate Prisma client +WORKDIR /app/apps/wcag-ada/api +RUN bunx prisma generate + +# Build the application +RUN bun run build + +# Production stage +FROM oven/bun:1-alpine + +# Install runtime dependencies +RUN apk add --no-cache openssl + +WORKDIR /app + +# Copy built application +COPY --from=builder /app/apps/wcag-ada/api/dist ./dist +COPY --from=builder /app/apps/wcag-ada/api/node_modules ./node_modules +COPY --from=builder /app/apps/wcag-ada/api/prisma ./prisma +COPY --from=builder /app/apps/wcag-ada/api/package.json ./ + +# Create non-root user +RUN addgroup -g 1001 -S nodejs && \ + adduser -S nodejs -u 1001 + +# Set ownership +RUN chown -R nodejs:nodejs /app + +USER nodejs + +# Expose port +EXPOSE 3001 + +# Health check +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://localhost:3001/health || exit 1 + +# Start the application +CMD ["bun", "run", "start"] \ No newline at end of file diff --git a/apps/wcag-ada/api/package.json b/apps/wcag-ada/api/package.json new file mode 100644 index 0000000..208196e --- /dev/null +++ b/apps/wcag-ada/api/package.json @@ -0,0 +1,34 @@ +{ + "name": "@wcag-ada/api", + "version": "0.1.0", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "scripts": { + "dev": "bun run --hot src/index.ts", + "build": "tsc -b", + "start": "bun run dist/index.js", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "hono": "^3.11.7", + "@hono/node-server": "^1.4.0", + "@hono/zod-validator": "^0.5.0", + "zod": "^3.22.4", + "@wcag-ada/shared": "workspace:*", + "@wcag-ada/scanner": "workspace:*", + "@wcag-ada/config": "workspace:*", + "jsonwebtoken": "^9.0.2", + "bcryptjs": "^2.4.3", + "nanoid": "^5.0.4", + "@prisma/client": "^5.8.0", + "bullmq": "^5.1.1", + "ioredis": "^5.3.2" + }, + "devDependencies": { + "@types/node": "^20.11.5", + "@types/jsonwebtoken": "^9.0.5", + "@types/bcryptjs": "^2.4.6", + "prisma": "^5.8.0", + "typescript": "^5.3.3" + } +} \ No newline at end of file diff --git a/apps/wcag-ada/api/prisma/schema.prisma b/apps/wcag-ada/api/prisma/schema.prisma new file mode 100644 index 0000000..2774888 --- /dev/null +++ b/apps/wcag-ada/api/prisma/schema.prisma @@ -0,0 +1,153 @@ +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model User { + id String @id @default(cuid()) + email String @unique + passwordHash String + name String? + company String? + role UserRole @default(USER) + apiKey String? @unique + isActive Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + websites Website[] + reports Report[] + scanJobs ScanJob[] +} + +model Website { + id String @id @default(cuid()) + name String + url String + userId String + user User @relation(fields: [userId], references: [id]) + + scanSchedule Json? // ScanSchedule type + lastScanAt DateTime? + complianceScore Float? + tags String[] + active Boolean @default(true) + + authConfig Json? // AuthenticationConfig type + scanOptions Json? // Default scan options + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + scanResults ScanResult[] + scanJobs ScanJob[] + reports Report[] + + @@index([userId]) + @@index([url]) +} + +model ScanResult { + id String @id @default(cuid()) + websiteId String + website Website @relation(fields: [websiteId], references: [id]) + jobId String? @unique + job ScanJob? @relation(fields: [jobId], references: [id]) + + url String + scanDuration Int + summary Json // ScanSummary type + violations Json // AccessibilityViolation[] type + passes Json // AxeResult[] type + incomplete Json // AxeResult[] type + inapplicable Json // AxeResult[] type + pageMetadata Json // PageMetadata type + wcagCompliance Json // WCAGCompliance type + + createdAt DateTime @default(now()) + + @@index([websiteId]) + @@index([createdAt]) +} + +model ScanJob { + id String @id @default(cuid()) + websiteId String + website Website @relation(fields: [websiteId], references: [id]) + userId String + user User @relation(fields: [userId], references: [id]) + + url String + status JobStatus @default(PENDING) + options Json // AccessibilityScanOptions type + + scheduledAt DateTime @default(now()) + startedAt DateTime? + completedAt DateTime? + + error String? + retryCount Int @default(0) + maxRetries Int @default(3) + + result ScanResult? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([websiteId]) + @@index([status]) + @@index([scheduledAt]) +} + +model Report { + id String @id @default(cuid()) + websiteId String + website Website @relation(fields: [websiteId], references: [id]) + userId String + user User @relation(fields: [userId], references: [id]) + + type ReportType + format ReportFormat + period Json // ReportPeriod type + + summary Json // ReportSummary type + data Json // Report data + + fileUrl String? + + generatedAt DateTime @default(now()) + + @@index([websiteId]) + @@index([userId]) + @@index([generatedAt]) +} + +enum UserRole { + USER + ADMIN +} + +enum JobStatus { + PENDING + RUNNING + COMPLETED + FAILED +} + +enum ReportType { + COMPLIANCE + EXECUTIVE + TECHNICAL + TREND +} + +enum ReportFormat { + PDF + HTML + JSON + CSV +} \ No newline at end of file diff --git a/apps/wcag-ada/api/src/config.ts b/apps/wcag-ada/api/src/config.ts new file mode 100644 index 0000000..0a8bddd --- /dev/null +++ b/apps/wcag-ada/api/src/config.ts @@ -0,0 +1,39 @@ +import { initializeWcagConfig, getWcagConfig } from '@wcag-ada/config'; + +// Initialize configuration for API service +const appConfig = initializeWcagConfig('api'); + +// Export a config object that matches the expected interface +export const config = { + NODE_ENV: appConfig.environment as 'development' | 'production' | 'test', + PORT: appConfig.services.api.port, + + // Database + DATABASE_URL: process.env.DATABASE_URL || + `postgresql://${appConfig.database.postgres.user || 'postgres'}:${appConfig.database.postgres.password || 'postgres'}@${appConfig.database.postgres.host}:${appConfig.database.postgres.port}/${appConfig.database.postgres.database}`, + REDIS_URL: `redis://${appConfig.worker.redis.host}:${appConfig.worker.redis.port}/${appConfig.worker.redis.db}`, + + // Auth + JWT_SECRET: appConfig.providers.auth.jwt.secret, + JWT_EXPIRES_IN: appConfig.providers.auth.jwt.expiresIn, + + // Scanner + SCANNER_CONCURRENCY: appConfig.scanner.concurrency, + SCANNER_TIMEOUT: appConfig.scanner.timeout, + + // API Rate Limiting + API_RATE_LIMIT: appConfig.services.api.rateLimit.max, + API_RATE_WINDOW: appConfig.services.api.rateLimit.windowMs, + + // Storage + REPORT_STORAGE_PATH: `${appConfig.providers.storage.local.basePath}/${appConfig.providers.storage.local.reports}`, + SCREENSHOT_STORAGE_PATH: `${appConfig.providers.storage.local.basePath}/${appConfig.providers.storage.local.screenshots}`, + + // CORS + CORS_ORIGIN: appConfig.services.api.cors.origin, +}; + +export type Config = typeof config; + +// Export the full app config for advanced usage +export { appConfig }; \ No newline at end of file diff --git a/apps/wcag-ada/api/src/index.ts b/apps/wcag-ada/api/src/index.ts new file mode 100644 index 0000000..225490f --- /dev/null +++ b/apps/wcag-ada/api/src/index.ts @@ -0,0 +1,64 @@ +import { serve } from '@hono/node-server'; +import { Hono } from 'hono'; +import { cors } from 'hono/cors'; +import { logger as honoLogger } from 'hono/logger'; +import { compress } from 'hono/compress'; +import { secureHeaders } from 'hono/secure-headers'; +import { config, appConfig } from './config'; +import { logger } from './utils/logger'; + +// Import routes +import { authRoutes } from './routes/auth'; +import { websiteRoutes } from './routes/websites'; +import { scanRoutes } from './routes/scans'; +import { reportRoutes } from './routes/reports'; +import { healthRoutes } from './routes/health'; + +// Import middleware +import { errorHandler } from './middleware/error-handler'; +import { rateLimiter } from './middleware/rate-limiter'; + +const app = new Hono(); + +// Global middleware +app.use('*', honoLogger()); +app.use('*', cors({ + origin: config.CORS_ORIGIN || '*', + credentials: true, +})); +app.use('*', compress()); +app.use('*', secureHeaders()); + +// Rate limiting +app.use('/api/*', rateLimiter()); + +// Health check (no auth required) +app.route('/health', healthRoutes); + +// API routes +app.route('/api/auth', authRoutes); +app.route('/api/websites', websiteRoutes); +app.route('/api/scans', scanRoutes); +app.route('/api/reports', reportRoutes); + +// 404 handler +app.notFound((c) => { + return c.json({ error: 'Not Found' }, 404); +}); + +// Global error handler +app.onError(errorHandler); + +// Start server +const port = config.PORT || 3001; + +logger.info(`🚀 WCAG-ADA API Server starting on port ${port}...`); + +serve({ + fetch: app.fetch, + port, +}, (info) => { + logger.info(`✅ Server is running on http://localhost:${info.port}`); +}); + +export default app; \ No newline at end of file diff --git a/apps/wcag-ada/api/src/middleware/auth.ts b/apps/wcag-ada/api/src/middleware/auth.ts new file mode 100644 index 0000000..efc9318 --- /dev/null +++ b/apps/wcag-ada/api/src/middleware/auth.ts @@ -0,0 +1,69 @@ +import { Context, Next } from 'hono'; +import { verify } from 'hono/jwt'; +import { prisma } from '../utils/prisma'; +import { config } from '../config'; + +export const authenticate = async (c: Context, next: Next) => { + try { + // Check for Bearer token + const authHeader = c.req.header('Authorization'); + if (authHeader && authHeader.startsWith('Bearer ')) { + const token = authHeader.substring(7); + + try { + const payload = await verify(token, config.JWT_SECRET, 'HS256') as { + sub: string; + email: string; + role: string; + }; + + c.set('userId', payload.sub); + c.set('userRole', payload.role); + c.set('authType', 'jwt'); + + return next(); + } catch (error) { + // Invalid JWT, continue to check API key + } + } + + // Check for API key + const apiKey = c.req.header('X-API-Key'); + if (apiKey) { + const user = await prisma.user.findUnique({ + where: { + apiKey, + isActive: true, + }, + select: { + id: true, + role: true, + }, + }); + + if (user) { + c.set('userId', user.id); + c.set('userRole', user.role); + c.set('authType', 'apiKey'); + + return next(); + } + } + + return c.json({ error: 'Unauthorized' }, 401); + } catch (error) { + return c.json({ error: 'Authentication failed' }, 401); + } +}; + +export const requireRole = (role: string) => { + return async (c: Context, next: Next) => { + const userRole = c.get('userRole'); + + if (!userRole || userRole !== role) { + return c.json({ error: 'Insufficient permissions' }, 403); + } + + return next(); + }; +}; \ No newline at end of file diff --git a/apps/wcag-ada/api/src/middleware/error-handler.ts b/apps/wcag-ada/api/src/middleware/error-handler.ts new file mode 100644 index 0000000..b52c051 --- /dev/null +++ b/apps/wcag-ada/api/src/middleware/error-handler.ts @@ -0,0 +1,59 @@ +import { Context } from 'hono'; +import { HTTPException } from 'hono/http-exception'; +import { ZodError } from 'zod'; +import { appConfig } from '../config'; +import { logger } from '../utils/logger'; + +export const errorHandler = (err: Error, c: Context) => { + logger.error('Error:', err); + + // Handle Hono HTTP exceptions + if (err instanceof HTTPException) { + return c.json( + { error: err.message }, + err.status + ); + } + + // Handle Zod validation errors + if (err instanceof ZodError) { + return c.json( + { + error: 'Validation error', + details: err.errors.map(e => ({ + path: e.path.join('.'), + message: e.message, + })), + }, + 400 + ); + } + + // Handle Prisma errors + if (err.constructor.name === 'PrismaClientKnownRequestError') { + const prismaError = err as any; + + if (prismaError.code === 'P2002') { + return c.json( + { error: 'Duplicate entry' }, + 400 + ); + } + + if (prismaError.code === 'P2025') { + return c.json( + { error: 'Record not found' }, + 404 + ); + } + } + + // Default error response + return c.json( + { + error: 'Internal server error', + message: appConfig.env === 'development' ? err.message : undefined, + }, + 500 + ); +}; \ No newline at end of file diff --git a/apps/wcag-ada/api/src/middleware/rate-limiter.ts b/apps/wcag-ada/api/src/middleware/rate-limiter.ts new file mode 100644 index 0000000..0c07fab --- /dev/null +++ b/apps/wcag-ada/api/src/middleware/rate-limiter.ts @@ -0,0 +1,75 @@ +import { Context, Next } from 'hono'; +import Redis from 'ioredis'; +import { config } from '../config'; +import { logger } from '../utils/logger'; +import { getWorkerConfig } from '@wcag-ada/config'; + +const workerConfig = getWorkerConfig(); +const redis = new Redis({ + host: workerConfig.redis.host, + port: workerConfig.redis.port, + password: workerConfig.redis.password, + db: workerConfig.redis.db, +}); + +interface RateLimitOptions { + limit?: number; + window?: number; // in milliseconds + keyGenerator?: (c: Context) => string; +} + +export const rateLimiter = (options: RateLimitOptions = {}) => { + const { + limit = config.API_RATE_LIMIT, + window = config.API_RATE_WINDOW, + keyGenerator = (c) => { + const userId = c.get('userId'); + const ip = c.req.header('x-forwarded-for') || c.req.header('x-real-ip') || 'unknown'; + return userId ? `rate:user:${userId}` : `rate:ip:${ip}`; + }, + } = options; + + return async (c: Context, next: Next) => { + const key = keyGenerator(c); + + try { + // Get current count + const current = await redis.get(key); + const count = current ? parseInt(current) : 0; + + // Check if limit exceeded + if (count >= limit) { + const ttl = await redis.pttl(key); + + c.header('X-RateLimit-Limit', limit.toString()); + c.header('X-RateLimit-Remaining', '0'); + c.header('X-RateLimit-Reset', new Date(Date.now() + ttl).toISOString()); + + return c.json( + { + error: 'Rate limit exceeded', + retryAfter: Math.ceil(ttl / 1000), + }, + 429 + ); + } + + // Increment counter + const pipeline = redis.pipeline(); + pipeline.incr(key); + pipeline.pexpire(key, window); + await pipeline.exec(); + + // Set headers + c.header('X-RateLimit-Limit', limit.toString()); + c.header('X-RateLimit-Remaining', (limit - count - 1).toString()); + c.header('X-RateLimit-Reset', new Date(Date.now() + window).toISOString()); + + return next(); + } catch (error) { + logger.error('Rate limiter error:', error); + // Continue on error - don't block requests due to rate limiter failure + return next(); + } + }; +}; \ No newline at end of file diff --git a/apps/wcag-ada/api/src/routes/auth.ts b/apps/wcag-ada/api/src/routes/auth.ts new file mode 100644 index 0000000..80a186b --- /dev/null +++ b/apps/wcag-ada/api/src/routes/auth.ts @@ -0,0 +1,177 @@ +import { Hono } from 'hono'; +import { zValidator } from '@hono/zod-validator'; +import { z } from 'zod'; +import { sign } from 'hono/jwt'; +import bcrypt from 'bcryptjs'; +import { nanoid } from 'nanoid'; +import { prisma } from '../utils/prisma'; +import { config } from '../config'; + +const authRoutes = new Hono(); + +// Validation schemas +const registerSchema = z.object({ + email: z.string().email(), + password: z.string().min(8), + name: z.string().optional(), + company: z.string().optional(), +}); + +const loginSchema = z.object({ + email: z.string().email(), + password: z.string(), +}); + +// Register +authRoutes.post('/register', zValidator('json', registerSchema), async (c) => { + const { email, password, name, company } = c.req.valid('json'); + + // Check if user exists + const existingUser = await prisma.user.findUnique({ + where: { email }, + }); + + if (existingUser) { + return c.json({ error: 'User already exists' }, 400); + } + + // Hash password + const passwordHash = await bcrypt.hash(password, 10); + + // Create user with API key + const user = await prisma.user.create({ + data: { + email, + passwordHash, + name, + company, + apiKey: `wcag_${nanoid(32)}`, + }, + select: { + id: true, + email: true, + name: true, + company: true, + apiKey: true, + role: true, + }, + }); + + // Generate JWT + const token = await sign( + { + sub: user.id, + email: user.email, + role: user.role, + }, + config.JWT_SECRET, + 'HS256' + ); + + return c.json({ + user, + token, + }); +}); + +// Login +authRoutes.post('/login', zValidator('json', loginSchema), async (c) => { + const { email, password } = c.req.valid('json'); + + // Find user + const user = await prisma.user.findUnique({ + where: { email }, + select: { + id: true, + email: true, + passwordHash: true, + name: true, + company: true, + apiKey: true, + role: true, + isActive: true, + }, + }); + + if (!user || !user.isActive) { + return c.json({ error: 'Invalid credentials' }, 401); + } + + // Verify password + const validPassword = await bcrypt.compare(password, user.passwordHash); + if (!validPassword) { + return c.json({ error: 'Invalid credentials' }, 401); + } + + // Generate JWT + const token = await sign( + { + sub: user.id, + email: user.email, + role: user.role, + }, + config.JWT_SECRET, + 'HS256' + ); + + // Remove passwordHash from response + const { passwordHash, ...userWithoutPassword } = user; + + return c.json({ + user: userWithoutPassword, + token, + }); +}); + +// Refresh API key +authRoutes.post('/refresh-api-key', async (c) => { + const userId = c.get('userId'); + + if (!userId) { + return c.json({ error: 'Unauthorized' }, 401); + } + + const newApiKey = `wcag_${nanoid(32)}`; + + const user = await prisma.user.update({ + where: { id: userId }, + data: { apiKey: newApiKey }, + select: { + id: true, + email: true, + apiKey: true, + }, + }); + + return c.json({ apiKey: user.apiKey }); +}); + +// Get current user +authRoutes.get('/me', async (c) => { + const userId = c.get('userId'); + + if (!userId) { + return c.json({ error: 'Unauthorized' }, 401); + } + + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { + id: true, + email: true, + name: true, + company: true, + role: true, + apiKey: true, + createdAt: true, + }, + }); + + if (!user) { + return c.json({ error: 'User not found' }, 404); + } + + return c.json(user); +}); + +export { authRoutes }; \ No newline at end of file diff --git a/apps/wcag-ada/api/src/routes/health.ts b/apps/wcag-ada/api/src/routes/health.ts new file mode 100644 index 0000000..beb580b --- /dev/null +++ b/apps/wcag-ada/api/src/routes/health.ts @@ -0,0 +1,118 @@ +import { Hono } from 'hono'; +import { Queue } from 'bullmq'; +import { prisma } from '../utils/prisma'; +import Redis from 'ioredis'; +import { appConfig } from '../config'; +import { getWorkerConfig } from '@wcag-ada/config'; + +const healthRoutes = new Hono(); + +healthRoutes.get('/', async (c) => { + const checks = { + api: 'ok', + database: 'unknown', + redis: 'unknown', + queue: 'unknown', + }; + + // Check database + try { + await prisma.$queryRaw`SELECT 1`; + checks.database = 'ok'; + } catch (error) { + checks.database = 'error'; + } + + // Check Redis + try { + const workerConfig = getWorkerConfig(); + const redis = new Redis({ + host: workerConfig.redis.host, + port: workerConfig.redis.port, + password: workerConfig.redis.password, + db: workerConfig.redis.db, + }); + await redis.ping(); + await redis.quit(); + checks.redis = 'ok'; + } catch (error) { + checks.redis = 'error'; + } + + // Check queue + try { + const workerConfig = getWorkerConfig(); + const queue = new Queue(workerConfig.queueName, { + connection: new Redis({ + host: workerConfig.redis.host, + port: workerConfig.redis.port, + password: workerConfig.redis.password, + db: workerConfig.redis.db, + }), + }); + await queue.getJobCounts(); + await queue.close(); + checks.queue = 'ok'; + } catch (error) { + checks.queue = 'error'; + } + + const allHealthy = Object.values(checks).every(status => status === 'ok'); + + return c.json( + { + status: allHealthy ? 'healthy' : 'unhealthy', + checks, + timestamp: new Date().toISOString(), + }, + allHealthy ? 200 : 503 + ); +}); + +healthRoutes.get('/stats', async (c) => { + try { + // Get queue stats + const workerConfig = getWorkerConfig(); + const queue = new Queue(workerConfig.queueName, { + connection: new Redis({ + host: workerConfig.redis.host, + port: workerConfig.redis.port, + password: workerConfig.redis.password, + db: workerConfig.redis.db, + }), + }); + + const [waiting, active, completed, failed] = await Promise.all([ + queue.getWaitingCount(), + queue.getActiveCount(), + queue.getCompletedCount(), + queue.getFailedCount(), + ]); + + const queueStats = { waiting, active, completed, failed }; + + const dbStats = await Promise.all([ + prisma.user.count(), + prisma.website.count({ where: { active: true } }), + prisma.scanJob.count(), + prisma.scanResult.count(), + ]); + + await queue.close(); + + return c.json({ + queue: queueStats, + database: { + users: dbStats[0], + websites: dbStats[1], + scanJobs: dbStats[2], + scanResults: dbStats[3], + }, + timestamp: new Date().toISOString(), + }); + } catch (error) { + return c.json({ error: 'Failed to get stats' }, 500); + } +}); + +export { healthRoutes }; \ No newline at end of file diff --git a/apps/wcag-ada/api/src/routes/reports.ts b/apps/wcag-ada/api/src/routes/reports.ts new file mode 100644 index 0000000..6fa8783 --- /dev/null +++ b/apps/wcag-ada/api/src/routes/reports.ts @@ -0,0 +1,244 @@ +import { Hono } from 'hono'; +import { zValidator } from '@hono/zod-validator'; +import { z } from 'zod'; +import { prisma } from '../utils/prisma'; +import { authenticate } from '../middleware/auth'; +import { generateReport } from '../services/report-generator'; +import type { ReportPeriod } from '@wcag-ada/shared'; + +const reportRoutes = new Hono(); + +// Apply authentication to all routes +reportRoutes.use('*', authenticate); + +// Validation schemas +const generateReportSchema = z.object({ + websiteId: z.string(), + type: z.enum(['COMPLIANCE', 'EXECUTIVE', 'TECHNICAL', 'TREND']), + format: z.enum(['PDF', 'HTML', 'JSON', 'CSV']), + period: z.object({ + start: z.string().transform(s => new Date(s)), + end: z.string().transform(s => new Date(s)), + }), +}); + +// Generate a new report +reportRoutes.post('/generate', zValidator('json', generateReportSchema), async (c) => { + const userId = c.get('userId'); + const { websiteId, type, format, period } = c.req.valid('json'); + + // Check website ownership + const website = await prisma.website.findFirst({ + where: { + id: websiteId, + userId, + active: true, + }, + }); + + if (!website) { + return c.json({ error: 'Website not found' }, 404); + } + + // Get scan results for the period + const scanResults = await prisma.scanResult.findMany({ + where: { + websiteId, + createdAt: { + gte: period.start, + lte: period.end, + }, + }, + orderBy: { createdAt: 'asc' }, + }); + + if (scanResults.length === 0) { + return c.json({ error: 'No scan data available for the specified period' }, 400); + } + + // Generate report data + const reportData = await generateReport({ + website, + scanResults, + type, + format, + period, + }); + + // Save report + const report = await prisma.report.create({ + data: { + websiteId, + userId, + type, + format, + period: period as any, + summary: reportData.summary as any, + data: reportData.data as any, + fileUrl: reportData.fileUrl, + }, + }); + + return c.json({ + report: { + id: report.id, + type: report.type, + format: report.format, + period: report.period, + fileUrl: report.fileUrl, + generatedAt: report.generatedAt, + }, + }, 201); +}); + +// Get user's reports +reportRoutes.get('/', async (c) => { + const userId = c.get('userId'); + const { page = '1', limit = '20', websiteId, type } = c.req.query(); + + const pageNum = parseInt(page); + const limitNum = parseInt(limit); + const skip = (pageNum - 1) * limitNum; + + const where = { + userId, + ...(websiteId && { websiteId }), + ...(type && { type }), + }; + + const [reports, total] = await Promise.all([ + prisma.report.findMany({ + where, + skip, + take: limitNum, + orderBy: { generatedAt: 'desc' }, + include: { + website: { + select: { + id: true, + name: true, + url: true, + }, + }, + }, + }), + prisma.report.count({ where }), + ]); + + return c.json({ + reports, + pagination: { + page: pageNum, + limit: limitNum, + total, + totalPages: Math.ceil(total / limitNum), + }, + }); +}); + +// Get report by ID +reportRoutes.get('/:id', async (c) => { + const userId = c.get('userId'); + const reportId = c.req.param('id'); + + const report = await prisma.report.findFirst({ + where: { + id: reportId, + userId, + }, + include: { + website: { + select: { + id: true, + name: true, + url: true, + }, + }, + }, + }); + + if (!report) { + return c.json({ error: 'Report not found' }, 404); + } + + return c.json(report); +}); + +// Download report file +reportRoutes.get('/:id/download', async (c) => { + const userId = c.get('userId'); + const reportId = c.req.param('id'); + + const report = await prisma.report.findFirst({ + where: { + id: reportId, + userId, + }, + }); + + if (!report || !report.fileUrl) { + return c.json({ error: 'Report file not found' }, 404); + } + + // In production, this would redirect to a signed URL or serve from storage + // For now, we'll just return the file URL + return c.json({ downloadUrl: report.fileUrl }); +}); + +// Get compliance trends +reportRoutes.get('/trends/:websiteId', async (c) => { + const userId = c.get('userId'); + const websiteId = c.req.param('websiteId'); + const { days = '30' } = c.req.query(); + + // Check website ownership + const website = await prisma.website.findFirst({ + where: { + id: websiteId, + userId, + }, + }); + + if (!website) { + return c.json({ error: 'Website not found' }, 404); + } + + const daysNum = parseInt(days); + const startDate = new Date(); + startDate.setDate(startDate.getDate() - daysNum); + + const scanResults = await prisma.scanResult.findMany({ + where: { + websiteId, + createdAt: { + gte: startDate, + }, + }, + orderBy: { createdAt: 'asc' }, + select: { + createdAt: true, + summary: true, + wcagCompliance: true, + }, + }); + + const trends = scanResults.map(result => ({ + date: result.createdAt, + score: (result.summary as any).score, + violationCount: (result.summary as any).violationCount, + passCount: (result.summary as any).passCount, + criticalIssues: (result.summary as any).criticalIssues, + seriousIssues: (result.summary as any).seriousIssues, + })); + + return c.json({ + websiteId, + period: { + start: startDate, + end: new Date(), + }, + trends, + }); +}); + +export { reportRoutes }; \ No newline at end of file diff --git a/apps/wcag-ada/api/src/routes/scans.ts b/apps/wcag-ada/api/src/routes/scans.ts new file mode 100644 index 0000000..6880170 --- /dev/null +++ b/apps/wcag-ada/api/src/routes/scans.ts @@ -0,0 +1,283 @@ +import { Hono } from 'hono'; +import { zValidator } from '@hono/zod-validator'; +import { z } from 'zod'; +import { Queue } from 'bullmq'; +import Redis from 'ioredis'; +import { prisma } from '../utils/prisma'; +import { authenticate } from '../middleware/auth'; +import { getWorkerConfig } from '@wcag-ada/config'; +import type { AccessibilityScanOptions } from '@wcag-ada/shared'; + +const scanRoutes = new Hono(); + +// Get worker config for queue connection +const workerConfig = getWorkerConfig(); + +// Create queue connection +const scanQueue = new Queue('accessibility-scans', { + connection: new Redis({ + host: workerConfig.redis.host, + port: workerConfig.redis.port, + password: workerConfig.redis.password, + db: workerConfig.redis.db, + }), +}); + +// Apply authentication to all routes +scanRoutes.use('*', authenticate); + +// Validation schemas +const createScanSchema = z.object({ + websiteId: z.string(), + url: z.string().url().optional(), + options: z.object({ + wcagLevel: z.object({ + level: z.enum(['A', 'AA', 'AAA']), + version: z.enum(['2.0', '2.1', '2.2']), + }).optional(), + viewport: z.object({ + width: z.number(), + height: z.number(), + deviceScaleFactor: z.number().optional(), + isMobile: z.boolean().optional(), + }).optional(), + includeScreenshots: z.boolean().optional(), + excludeSelectors: z.array(z.string()).optional(), + waitForSelector: z.string().optional(), + timeout: z.number().optional(), + }).optional(), +}); + +// Start a new scan +scanRoutes.post('/', zValidator('json', createScanSchema), async (c) => { + const userId = c.get('userId'); + const { websiteId, url, options } = c.req.valid('json'); + + // Get website + const website = await prisma.website.findFirst({ + where: { + id: websiteId, + userId, + active: true, + }, + }); + + if (!website) { + return c.json({ error: 'Website not found' }, 404); + } + + // Merge options with website defaults + const scanOptions: AccessibilityScanOptions = { + url: url || website.url, + ...website.scanOptions as any, + ...options, + authenticate: website.authConfig as any, + }; + + // Create scan job + const job = await prisma.scanJob.create({ + data: { + websiteId, + userId, + url: scanOptions.url, + options: scanOptions as any, + status: 'PENDING', + }, + }); + + // Queue the scan + await scanQueue.add('scan', { + jobId: job.id, + websiteId, + userId, + options: scanOptions, + }, { + jobId: job.id, + }); + + return c.json({ + job: { + id: job.id, + status: job.status, + url: job.url, + scheduledAt: job.scheduledAt, + }, + message: 'Scan queued successfully', + }, 202); +}); + +// Get scan status +scanRoutes.get('/:id', async (c) => { + const userId = c.get('userId'); + const scanId = c.req.param('id'); + + const job = await prisma.scanJob.findFirst({ + where: { + id: scanId, + userId, + }, + include: { + result: { + select: { + id: true, + summary: true, + wcagCompliance: true, + createdAt: true, + }, + }, + }, + }); + + if (!job) { + return c.json({ error: 'Scan not found' }, 404); + } + + return c.json(job); +}); + +// Get scan result +scanRoutes.get('/:id/result', async (c) => { + const userId = c.get('userId'); + const scanId = c.req.param('id'); + + const result = await prisma.scanResult.findFirst({ + where: { + jobId: scanId, + job: { + userId, + }, + }, + }); + + if (!result) { + return c.json({ error: 'Scan result not found' }, 404); + } + + return c.json(result); +}); + +// Get scan violations +scanRoutes.get('/:id/violations', async (c) => { + const userId = c.get('userId'); + const scanId = c.req.param('id'); + const { impact, tag } = c.req.query(); + + const result = await prisma.scanResult.findFirst({ + where: { + jobId: scanId, + job: { + userId, + }, + }, + select: { + violations: true, + }, + }); + + if (!result) { + return c.json({ error: 'Scan result not found' }, 404); + } + + let violations = result.violations as any[]; + + // Filter by impact if provided + if (impact) { + violations = violations.filter(v => v.impact === impact); + } + + // Filter by tag if provided + if (tag) { + violations = violations.filter(v => v.tags.includes(tag)); + } + + return c.json({ violations }); +}); + +// Cancel a pending scan +scanRoutes.delete('/:id', async (c) => { + const userId = c.get('userId'); + const scanId = c.req.param('id'); + + const job = await prisma.scanJob.findFirst({ + where: { + id: scanId, + userId, + status: 'PENDING', + }, + }); + + if (!job) { + return c.json({ error: 'Scan not found or cannot be cancelled' }, 404); + } + + // Update job status + await prisma.scanJob.update({ + where: { id: scanId }, + data: { + status: 'FAILED', + error: 'Cancelled by user', + completedAt: new Date(), + }, + }); + + // Remove from queue if possible + const queueJob = await scanQueue.getJob(scanId); + if (queueJob) { + await queueJob.remove(); + } + + return c.json({ message: 'Scan cancelled successfully' }); +}); + +// Get user's scan history +scanRoutes.get('/', async (c) => { + const userId = c.get('userId'); + const { page = '1', limit = '20', status, websiteId } = c.req.query(); + + const pageNum = parseInt(page); + const limitNum = parseInt(limit); + const skip = (pageNum - 1) * limitNum; + + const where = { + userId, + ...(status && { status }), + ...(websiteId && { websiteId }), + }; + + const [jobs, total] = await Promise.all([ + prisma.scanJob.findMany({ + where, + skip, + take: limitNum, + orderBy: { createdAt: 'desc' }, + include: { + website: { + select: { + id: true, + name: true, + url: true, + }, + }, + result: { + select: { + id: true, + summary: true, + }, + }, + }, + }), + prisma.scanJob.count({ where }), + ]); + + return c.json({ + scans: jobs, + pagination: { + page: pageNum, + limit: limitNum, + total, + totalPages: Math.ceil(total / limitNum), + }, + }); +}); + +export { scanRoutes }; \ No newline at end of file diff --git a/apps/wcag-ada/api/src/routes/websites.ts b/apps/wcag-ada/api/src/routes/websites.ts new file mode 100644 index 0000000..b1667e9 --- /dev/null +++ b/apps/wcag-ada/api/src/routes/websites.ts @@ -0,0 +1,245 @@ +import { Hono } from 'hono'; +import { zValidator } from '@hono/zod-validator'; +import { z } from 'zod'; +import { prisma } from '../utils/prisma'; +import { authenticate } from '../middleware/auth'; +import type { Website, ScanSchedule } from '@wcag-ada/shared'; + +const websiteRoutes = new Hono(); + +// Apply authentication to all routes +websiteRoutes.use('*', authenticate); + +// Validation schemas +const createWebsiteSchema = z.object({ + name: z.string().min(1), + url: z.string().url(), + tags: z.array(z.string()).optional(), + scanSchedule: z.object({ + frequency: z.enum(['manual', 'hourly', 'daily', 'weekly', 'monthly']), + dayOfWeek: z.number().min(0).max(6).optional(), + dayOfMonth: z.number().min(1).max(31).optional(), + hour: z.number().min(0).max(23).optional(), + timezone: z.string().optional(), + }).optional(), + authConfig: z.any().optional(), + scanOptions: z.any().optional(), +}); + +const updateWebsiteSchema = createWebsiteSchema.partial(); + +// List websites +websiteRoutes.get('/', async (c) => { + const userId = c.get('userId'); + const { page = '1', limit = '20', search } = c.req.query(); + + const pageNum = parseInt(page); + const limitNum = parseInt(limit); + const skip = (pageNum - 1) * limitNum; + + const where = { + userId, + ...(search && { + OR: [ + { name: { contains: search, mode: 'insensitive' } }, + { url: { contains: search, mode: 'insensitive' } }, + ], + }), + }; + + const [websites, total] = await Promise.all([ + prisma.website.findMany({ + where, + skip, + take: limitNum, + orderBy: { createdAt: 'desc' }, + include: { + _count: { + select: { scanResults: true }, + }, + }, + }), + prisma.website.count({ where }), + ]); + + return c.json({ + websites, + pagination: { + page: pageNum, + limit: limitNum, + total, + totalPages: Math.ceil(total / limitNum), + }, + }); +}); + +// Get website by ID +websiteRoutes.get('/:id', async (c) => { + const userId = c.get('userId'); + const websiteId = c.req.param('id'); + + const website = await prisma.website.findFirst({ + where: { + id: websiteId, + userId, + }, + include: { + scanResults: { + take: 1, + orderBy: { createdAt: 'desc' }, + }, + _count: { + select: { + scanResults: true, + scanJobs: true, + }, + }, + }, + }); + + if (!website) { + return c.json({ error: 'Website not found' }, 404); + } + + return c.json(website); +}); + +// Create website +websiteRoutes.post('/', zValidator('json', createWebsiteSchema), async (c) => { + const userId = c.get('userId'); + const data = c.req.valid('json'); + + // Check if website already exists for this user + const existing = await prisma.website.findFirst({ + where: { + url: data.url, + userId, + }, + }); + + if (existing) { + return c.json({ error: 'Website already exists' }, 400); + } + + const website = await prisma.website.create({ + data: { + ...data, + userId, + scanSchedule: data.scanSchedule ? data.scanSchedule : undefined, + authConfig: data.authConfig ? data.authConfig : undefined, + scanOptions: data.scanOptions ? data.scanOptions : undefined, + }, + }); + + return c.json(website, 201); +}); + +// Update website +websiteRoutes.patch('/:id', zValidator('json', updateWebsiteSchema), async (c) => { + const userId = c.get('userId'); + const websiteId = c.req.param('id'); + const data = c.req.valid('json'); + + // Check ownership + const existing = await prisma.website.findFirst({ + where: { + id: websiteId, + userId, + }, + }); + + if (!existing) { + return c.json({ error: 'Website not found' }, 404); + } + + const website = await prisma.website.update({ + where: { id: websiteId }, + data: { + ...data, + scanSchedule: data.scanSchedule !== undefined ? data.scanSchedule : undefined, + authConfig: data.authConfig !== undefined ? data.authConfig : undefined, + scanOptions: data.scanOptions !== undefined ? data.scanOptions : undefined, + }, + }); + + return c.json(website); +}); + +// Delete website +websiteRoutes.delete('/:id', async (c) => { + const userId = c.get('userId'); + const websiteId = c.req.param('id'); + + // Check ownership + const existing = await prisma.website.findFirst({ + where: { + id: websiteId, + userId, + }, + }); + + if (!existing) { + return c.json({ error: 'Website not found' }, 404); + } + + // Soft delete by setting active to false + await prisma.website.update({ + where: { id: websiteId }, + data: { active: false }, + }); + + return c.json({ message: 'Website deleted successfully' }); +}); + +// Get website scan history +websiteRoutes.get('/:id/scans', async (c) => { + const userId = c.get('userId'); + const websiteId = c.req.param('id'); + const { page = '1', limit = '20' } = c.req.query(); + + const pageNum = parseInt(page); + const limitNum = parseInt(limit); + const skip = (pageNum - 1) * limitNum; + + // Check ownership + const website = await prisma.website.findFirst({ + where: { + id: websiteId, + userId, + }, + }); + + if (!website) { + return c.json({ error: 'Website not found' }, 404); + } + + const [scans, total] = await Promise.all([ + prisma.scanResult.findMany({ + where: { websiteId }, + skip, + take: limitNum, + orderBy: { createdAt: 'desc' }, + select: { + id: true, + url: true, + scanDuration: true, + summary: true, + wcagCompliance: true, + createdAt: true, + }, + }), + prisma.scanResult.count({ where: { websiteId } }), + ]); + + return c.json({ + scans, + pagination: { + page: pageNum, + limit: limitNum, + total, + totalPages: Math.ceil(total / limitNum), + }, + }); +}); + +export { websiteRoutes }; \ No newline at end of file diff --git a/apps/wcag-ada/api/src/services/report-generator.ts b/apps/wcag-ada/api/src/services/report-generator.ts new file mode 100644 index 0000000..7b8ba8e --- /dev/null +++ b/apps/wcag-ada/api/src/services/report-generator.ts @@ -0,0 +1,271 @@ +import type { Website, ScanResult, Report } from '@prisma/client'; +import type { ReportPeriod, ReportSummary } from '@wcag-ada/shared'; +import { writeFile, mkdir } from 'fs/promises'; +import { join } from 'path'; +import { appConfig } from '../config'; +import { getStorageConfig } from '@wcag-ada/config'; +import { nanoid } from 'nanoid'; + +interface GenerateReportOptions { + website: Website; + scanResults: ScanResult[]; + type: 'COMPLIANCE' | 'EXECUTIVE' | 'TECHNICAL' | 'TREND'; + format: 'PDF' | 'HTML' | 'JSON' | 'CSV'; + period: ReportPeriod; +} + +interface GenerateReportResult { + summary: ReportSummary; + data: any; + fileUrl?: string; +} + +export async function generateReport( + options: GenerateReportOptions +): Promise { + const { website, scanResults, type, format, period } = options; + + // Calculate summary statistics + const summary = calculateReportSummary(scanResults, period); + + // Generate report data based on type + let data: any; + switch (type) { + case 'COMPLIANCE': + data = generateComplianceData(website, scanResults, summary); + break; + case 'EXECUTIVE': + data = generateExecutiveData(website, scanResults, summary); + break; + case 'TECHNICAL': + data = generateTechnicalData(website, scanResults, summary); + break; + case 'TREND': + data = generateTrendData(website, scanResults, summary); + break; + } + + // Generate file if not JSON format + let fileUrl: string | undefined; + if (format !== 'JSON') { + fileUrl = await generateReportFile(data, format, type); + } + + return { + summary, + data, + fileUrl, + }; +} + +function calculateReportSummary( + scanResults: ScanResult[], + period: ReportPeriod +): ReportSummary { + if (scanResults.length === 0) { + return { + averageScore: 0, + totalScans: 0, + improvementRate: 0, + criticalIssuesFixed: 0, + newIssuesFound: 0, + complianceLevel: { level: 'AA', version: '2.1' }, + }; + } + + // Calculate average score + const scores = scanResults.map(r => (r.summary as any).score || 0); + const averageScore = scores.reduce((a, b) => a + b, 0) / scores.length; + + // Calculate improvement rate + const firstScore = scores[0]; + const lastScore = scores[scores.length - 1]; + const improvementRate = firstScore > 0 ? ((lastScore - firstScore) / firstScore) * 100 : 0; + + // Count issues fixed and new issues + let criticalIssuesFixed = 0; + let newIssuesFound = 0; + + if (scanResults.length >= 2) { + const firstScan = scanResults[0]; + const lastScan = scanResults[scanResults.length - 1]; + + const firstCritical = (firstScan.summary as any).criticalIssues || 0; + const lastCritical = (lastScan.summary as any).criticalIssues || 0; + + criticalIssuesFixed = Math.max(0, firstCritical - lastCritical); + newIssuesFound = Math.max(0, lastCritical - firstCritical); + } + + // Get compliance level from last scan + const lastScan = scanResults[scanResults.length - 1]; + const complianceLevel = (lastScan.wcagCompliance as any)?.level || { level: 'AA', version: '2.1' }; + + return { + averageScore: Math.round(averageScore * 100) / 100, + totalScans: scanResults.length, + improvementRate: Math.round(improvementRate * 100) / 100, + criticalIssuesFixed, + newIssuesFound, + complianceLevel, + }; +} + +function generateComplianceData( + website: Website, + scanResults: ScanResult[], + summary: ReportSummary +): any { + return { + website: { + name: website.name, + url: website.url, + }, + summary, + latestScan: scanResults[scanResults.length - 1], + complianceHistory: scanResults.map(r => ({ + date: r.createdAt, + score: (r.summary as any).score, + compliant: (r.wcagCompliance as any).isCompliant, + })), + }; +} + +function generateExecutiveData( + website: Website, + scanResults: ScanResult[], + summary: ReportSummary +): any { + return { + website: { + name: website.name, + url: website.url, + }, + summary, + keyMetrics: { + currentScore: (scanResults[scanResults.length - 1].summary as any).score, + improvement: summary.improvementRate, + totalIssues: (scanResults[scanResults.length - 1].summary as any).violationCount, + criticalIssues: (scanResults[scanResults.length - 1].summary as any).criticalIssues, + }, + recommendations: generateRecommendations(scanResults[scanResults.length - 1]), + }; +} + +function generateTechnicalData( + website: Website, + scanResults: ScanResult[], + summary: ReportSummary +): any { + const latestScan = scanResults[scanResults.length - 1]; + return { + website: { + name: website.name, + url: website.url, + }, + summary, + violations: latestScan.violations, + topIssues: getTopIssues(latestScan.violations as any[]), + fixPriority: generateFixPriority(latestScan.violations as any[]), + }; +} + +function generateTrendData( + website: Website, + scanResults: ScanResult[], + summary: ReportSummary +): any { + return { + website: { + name: website.name, + url: website.url, + }, + summary, + trends: scanResults.map(r => ({ + date: r.createdAt, + score: (r.summary as any).score, + violations: (r.summary as any).violationCount, + critical: (r.summary as any).criticalIssues, + serious: (r.summary as any).seriousIssues, + moderate: (r.summary as any).moderateIssues, + minor: (r.summary as any).minorIssues, + })), + }; +} + +function generateRecommendations(scanResult: ScanResult): string[] { + const violations = scanResult.violations as any[]; + const recommendations: string[] = []; + + // Group violations by type and generate recommendations + const criticalViolations = violations.filter(v => v.impact === 'critical'); + if (criticalViolations.length > 0) { + recommendations.push('Address critical accessibility violations immediately to reduce legal risk'); + } + + // Add more intelligent recommendations based on violation patterns + + return recommendations; +} + +function getTopIssues(violations: any[]): any[] { + // Sort by impact and number of occurrences + return violations + .sort((a, b) => { + const impactOrder = { critical: 4, serious: 3, moderate: 2, minor: 1 }; + const aScore = impactOrder[a.impact] * a.nodes.length; + const bScore = impactOrder[b.impact] * b.nodes.length; + return bScore - aScore; + }) + .slice(0, 10); +} + +function generateFixPriority(violations: any[]): any[] { + // Generate prioritized fix list + return violations + .map(v => ({ + id: v.id, + impact: v.impact, + occurrences: v.nodes.length, + estimatedTime: estimateFixTime(v), + priority: calculatePriority(v), + })) + .sort((a, b) => b.priority - a.priority); +} + +function estimateFixTime(violation: any): number { + const baseTime = { + critical: 30, + serious: 20, + moderate: 15, + minor: 10, + }; + return (baseTime[violation.impact] || 15) * violation.nodes.length; +} + +function calculatePriority(violation: any): number { + const impactScore = { critical: 100, serious: 75, moderate: 50, minor: 25 }; + return (impactScore[violation.impact] || 50) * Math.log(violation.nodes.length + 1); +} + +async function generateReportFile( + data: any, + format: 'PDF' | 'HTML' | 'CSV', + type: string +): Promise { + const storageConfig = getStorageConfig(); + const reportPath = join(storageConfig.local.basePath, storageConfig.local.reports); + + // Create report directory if it doesn't exist + await mkdir(reportPath, { recursive: true }); + + const filename = `${type.toLowerCase()}_${nanoid()}.${format.toLowerCase()}`; + const filepath = join(reportPath, filename); + + // For now, just save as JSON + // In production, you would generate actual PDF/HTML/CSV files + await writeFile(filepath, JSON.stringify(data, null, 2)); + + // Return relative URL (in production, this would be a CDN URL) + return `/reports/${filename}`; +} \ No newline at end of file diff --git a/apps/wcag-ada/api/src/utils/prisma.ts b/apps/wcag-ada/api/src/utils/prisma.ts new file mode 100644 index 0000000..26b258c --- /dev/null +++ b/apps/wcag-ada/api/src/utils/prisma.ts @@ -0,0 +1,12 @@ +import { PrismaClient } from '@prisma/client'; +import { appConfig } from '../config'; + +const globalForPrisma = global as unknown as { prisma: PrismaClient }; + +export const prisma = + globalForPrisma.prisma || + new PrismaClient({ + log: appConfig.environment === 'development' ? ['query', 'error', 'warn'] : ['error'], + }); + +if (appConfig.environment !== 'production') globalForPrisma.prisma = prisma; \ No newline at end of file diff --git a/apps/wcag-ada/api/tsconfig.json b/apps/wcag-ada/api/tsconfig.json new file mode 100644 index 0000000..6b7bac7 --- /dev/null +++ b/apps/wcag-ada/api/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "composite": true, + "lib": ["ES2022"], + "types": ["bun-types"] + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"], + "references": [ + { "path": "../shared" }, + { "path": "../scanner" } + ] +} \ No newline at end of file diff --git a/apps/wcag-ada/config/README.md b/apps/wcag-ada/config/README.md new file mode 100644 index 0000000..8b4dac0 --- /dev/null +++ b/apps/wcag-ada/config/README.md @@ -0,0 +1,181 @@ +# WCAG-ADA Configuration + +Centralized configuration management for the WCAG-ADA compliance platform, built on top of the core configuration system. + +## Overview + +This configuration system provides: +- Type-safe configuration with Zod schemas +- Multi-source configuration (JSON files + environment variables) +- Environment-specific overrides +- Service-specific configurations +- Feature flags management + +## Usage + +### Basic Usage + +```typescript +import { initializeWcagConfig, getWcagConfig } from '@wcag-ada/config'; + +// Initialize configuration (do this once at app startup) +const config = initializeWcagConfig('api'); // 'api' | 'dashboard' | 'worker' + +// Access configuration values +console.log(config.services.api.port); // 3001 +console.log(config.scanner.concurrency); // 2 +``` + +### Helper Functions + +```typescript +import { + getScannerConfig, + getWorkerConfig, + isFeatureEnabled, + getServiceConfig +} from '@wcag-ada/config'; + +// Get specific configurations +const scannerConfig = getScannerConfig(); +const workerConfig = getWorkerConfig(); + +// Check feature flags +if (isFeatureEnabled('screenshots')) { + // Screenshot feature is enabled +} + +if (isFeatureEnabled('reports.pdf')) { + // PDF reports are enabled +} + +// Get service configuration +const apiConfig = getServiceConfig('api'); +``` + +## Configuration Structure + +### Scanner Configuration +- `concurrency`: Number of concurrent scans (1-10) +- `timeout`: Maximum scan duration +- `headless`: Run browser in headless mode +- `blockResources`: Block images/fonts for faster scans +- `viewport`: Default viewport dimensions + +### Worker Configuration +- `concurrency`: Number of concurrent jobs +- `queueName`: BullMQ queue name +- `redis`: Redis connection settings +- `jobs`: Job-specific configurations +- `scheduler`: Scheduled job settings + +### Features Configuration +- `screenshots`: Screenshot capture settings +- `customRules`: Custom accessibility rules +- `multiPage`: Multi-page scanning +- `reports`: Report generation features +- `integrations`: External service integrations +- `enterprise`: Enterprise features (SSO, white-label) + +### Providers Configuration +- `storage`: File storage (local, S3, GCS, Azure) +- `email`: Email providers (SMTP, SendGrid, SES) +- `auth`: Authentication settings (JWT, OAuth) +- `analytics`: Analytics providers +- `cdn`: CDN configuration + +## Environment Variables + +The system supports environment variable overrides with the `WCAG_` prefix: + +```bash +# Scanner settings +WCAG_SCANNER_CONCURRENCY=5 +WCAG_SCANNER_TIMEOUT=180000 +WCAG_SCANNER_HEADLESS=true + +# Worker settings +WCAG_WORKER_CONCURRENCY=3 +WCAG_WORKER_REDIS_HOST=redis.example.com +WCAG_WORKER_REDIS_PORT=6379 + +# Service ports +WCAG_SERVICES_API_PORT=3001 +WCAG_SERVICES_DASHBOARD_PORT=3000 + +# Storage +WCAG_PROVIDERS_STORAGE_TYPE=s3 +WCAG_PROVIDERS_STORAGE_S3_BUCKET=wcag-ada-storage +WCAG_PROVIDERS_STORAGE_S3_REGION=us-east-1 + +# Authentication +WCAG_PROVIDERS_AUTH_JWT_SECRET=your-secret-key +``` + +## Configuration Files + +Configuration files are loaded in this order (later files override earlier ones): +1. `config/default.json` - Base configuration +2. `config/{environment}.json` - Environment-specific (development, production, test) +3. Environment variables - Highest priority + +## Service-Specific Configuration + +Each service can have its own configuration: + +```typescript +// API Service +initializeWcagConfig('api'); +// Uses services.api configuration + +// Dashboard +initializeWcagConfig('dashboard'); +// Uses services.dashboard configuration + +// Worker +initializeWcagConfig('worker'); +// Uses services.worker configuration +``` + +## Adding New Configuration + +1. Add schema definition in `src/schemas/` +2. Update `wcag-app.schema.ts` to include new schema +3. Add default values in `config/default.json` +4. Add environment-specific overrides as needed +5. Create helper functions in `src/index.ts` for easy access + +## Examples + +### Check Subscription Limits + +```typescript +import { getSubscriptionConfig } from '@wcag-ada/config'; + +const starterLimits = getSubscriptionConfig('starter'); +console.log(starterLimits.websites); // 5 +console.log(starterLimits.scansPerMonth); // 500 +``` + +### Get Compliance Settings + +```typescript +import { getComplianceConfig } from '@wcag-ada/config'; + +const compliance = getComplianceConfig(); +console.log(compliance.defaultLevel); // { standard: 'WCAG21', level: 'AA' } +console.log(compliance.criticalCriteria); // ['1.1.1', '1.3.1', ...] +``` + +### Storage Configuration + +```typescript +import { getStorageConfig } from '@wcag-ada/config'; + +const storage = getStorageConfig(); +if (storage.type === 's3') { + // Use S3 storage +} else { + // Use local storage +} +``` \ No newline at end of file diff --git a/apps/wcag-ada/config/config/default.json b/apps/wcag-ada/config/config/default.json new file mode 100644 index 0000000..7f9d4dc --- /dev/null +++ b/apps/wcag-ada/config/config/default.json @@ -0,0 +1,117 @@ +{ + "appName": "wcag-ada", + "environment": "default", + + "log": { + "level": "info", + "format": "json", + "pretty": false + }, + + "database": { + "postgres": { + "host": "localhost", + "port": 5432, + "database": "wcag_ada", + "connectionLimit": 10 + }, + "redis": { + "host": "localhost", + "port": 6379, + "db": 0 + } + }, + + "scanner": { + "concurrency": 2, + "timeout": 120000, + "pageLoadTimeout": 30000, + "headless": true, + "blockResources": true, + "viewport": { + "width": 1280, + "height": 720, + "deviceScaleFactor": 1 + } + }, + + "worker": { + "enabled": true, + "concurrency": 2, + "queueName": "accessibility-scans", + "redis": { + "host": "localhost", + "port": 6379, + "db": 2 + } + }, + + "features": { + "screenshots": { + "enabled": true, + "quality": 80 + }, + "customRules": { + "enabled": true + }, + "reports": { + "pdf": { + "enabled": true + } + } + }, + + "providers": { + "storage": { + "type": "local", + "local": { + "basePath": "/tmp/wcag-ada" + } + }, + "email": { + "enabled": true, + "provider": "smtp" + }, + "auth": { + "jwt": { + "expiresIn": "7d" + } + } + }, + + "services": { + "api": { + "name": "wcag-api", + "port": 3001, + "cors": { + "enabled": true, + "origin": "*" + }, + "rateLimit": { + "enabled": true, + "windowMs": 900000, + "max": 100 + } + }, + "dashboard": { + "name": "wcag-dashboard", + "port": 3000, + "apiUrl": "http://localhost:3001" + }, + "worker": { + "name": "wcag-worker", + "port": 3002 + } + }, + + "compliance": { + "defaultLevel": { + "standard": "WCAG21", + "level": "AA" + } + }, + + "subscriptions": { + "enabled": true + } +} \ No newline at end of file diff --git a/apps/wcag-ada/config/config/development.json b/apps/wcag-ada/config/config/development.json new file mode 100644 index 0000000..ab81f36 --- /dev/null +++ b/apps/wcag-ada/config/config/development.json @@ -0,0 +1,51 @@ +{ + "environment": "development", + + "log": { + "level": "debug", + "pretty": true + }, + + "database": { + "postgres": { + "host": "localhost", + "database": "wcag_ada_dev" + } + }, + + "scanner": { + "headless": false, + "timeout": 300000 + }, + + "worker": { + "concurrency": 1 + }, + + "features": { + "reports": { + "pdf": { + "watermark": false + } + }, + "enterprise": { + "audit": { + "enabled": false + } + } + }, + + "providers": { + "auth": { + "jwt": { + "secret": "dev-secret-change-me" + } + } + }, + + "services": { + "dashboard": { + "apiUrl": "http://localhost:3001" + } + } +} \ No newline at end of file diff --git a/apps/wcag-ada/config/config/production.json b/apps/wcag-ada/config/config/production.json new file mode 100644 index 0000000..f1b42e8 --- /dev/null +++ b/apps/wcag-ada/config/config/production.json @@ -0,0 +1,64 @@ +{ + "environment": "production", + + "log": { + "level": "warn", + "pretty": false + }, + + "scanner": { + "concurrency": 5, + "headless": true + }, + + "worker": { + "concurrency": 4, + "redis": { + "maxRetriesPerRequest": 3 + } + }, + + "features": { + "screenshots": { + "quality": 70 + }, + "reports": { + "pdf": { + "watermark": true + } + }, + "enterprise": { + "audit": { + "enabled": true, + "retention": 365 + } + } + }, + + "providers": { + "storage": { + "type": "s3" + }, + "email": { + "provider": "sendgrid" + }, + "cdn": { + "enabled": true + } + }, + + "services": { + "api": { + "cors": { + "origin": "https://app.wcag-ada.com" + }, + "rateLimit": { + "max": 1000 + } + }, + "dashboard": { + "apiUrl": "https://api.wcag-ada.com", + "publicUrl": "https://app.wcag-ada.com" + } + } +} \ No newline at end of file diff --git a/apps/wcag-ada/config/config/test.json b/apps/wcag-ada/config/config/test.json new file mode 100644 index 0000000..6897f1b --- /dev/null +++ b/apps/wcag-ada/config/config/test.json @@ -0,0 +1,53 @@ +{ + "environment": "test", + + "log": { + "level": "error", + "pretty": false + }, + + "database": { + "postgres": { + "database": "wcag_ada_test" + }, + "redis": { + "db": 15 + } + }, + + "scanner": { + "concurrency": 1, + "timeout": 10000, + "headless": true + }, + + "worker": { + "enabled": false + }, + + "features": { + "screenshots": { + "enabled": false + }, + "webhooks": { + "enabled": false + } + }, + + "providers": { + "email": { + "enabled": false + }, + "analytics": { + "enabled": false + } + }, + + "services": { + "api": { + "rateLimit": { + "enabled": false + } + } + } +} \ No newline at end of file diff --git a/apps/wcag-ada/config/package.json b/apps/wcag-ada/config/package.json new file mode 100644 index 0000000..2051344 --- /dev/null +++ b/apps/wcag-ada/config/package.json @@ -0,0 +1,19 @@ +{ + "name": "@wcag-ada/config", + "version": "0.1.0", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "scripts": { + "build": "tsc -b", + "dev": "tsc -w", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@stock-bot/core-config": "workspace:*", + "zod": "^3.22.4" + }, + "devDependencies": { + "@types/node": "^20.11.5", + "typescript": "^5.3.3" + } +} \ No newline at end of file diff --git a/apps/wcag-ada/config/src/config-instance.ts b/apps/wcag-ada/config/src/config-instance.ts new file mode 100644 index 0000000..651b6b6 --- /dev/null +++ b/apps/wcag-ada/config/src/config-instance.ts @@ -0,0 +1,67 @@ +import { ConfigManager, createAppConfig } from '@stock-bot/core-config'; +import { wcagAppConfigSchema, type WcagAppConfig } from './schemas/wcag-app.schema'; +import * as path from 'path'; + +let configInstance: ConfigManager | null = null; + +/** + * Initialize the WCAG-ADA configuration + * @param serviceName - Optional service name for service-specific overrides + */ +export function initializeWcagConfig( + serviceName?: 'api' | 'dashboard' | 'worker' +): WcagAppConfig { + if (!configInstance) { + configInstance = createAppConfig(wcagAppConfigSchema, { + configPath: path.join(__dirname, '../config'), + envPrefix: 'WCAG_', + }); + } + + const config = configInstance.initialize(wcagAppConfigSchema); + + // Apply service-specific overrides if provided + if (serviceName && config.services[serviceName]) { + const serviceConfig = config.services[serviceName]; + + // Override port if specified for the service + if (serviceConfig.port && process.env.PORT) { + serviceConfig.port = parseInt(process.env.PORT, 10); + } + } + + return config; +} + +/** + * Get the current WCAG configuration instance + * @throws Error if configuration hasn't been initialized + */ +export function getWcagConfig(): WcagAppConfig { + if (!configInstance) { + throw new Error('WCAG configuration not initialized. Call initializeWcagConfig() first.'); + } + return configInstance.get(); +} + +/** + * Get a specific configuration value by path + * @param path - Dot-notation path to the configuration value + */ +export function getConfigValue(path: string): T { + if (!configInstance) { + throw new Error('WCAG configuration not initialized. Call initializeWcagConfig() first.'); + } + return configInstance.getValue(path); +} + +/** + * Check if a configuration path exists + * @param path - Dot-notation path to check + */ +export function hasConfigValue(path: string): boolean { + if (!configInstance) { + return false; + } + return configInstance.has(path); +} \ No newline at end of file diff --git a/apps/wcag-ada/config/src/index.ts b/apps/wcag-ada/config/src/index.ts new file mode 100644 index 0000000..1d4c584 --- /dev/null +++ b/apps/wcag-ada/config/src/index.ts @@ -0,0 +1,106 @@ +// Main configuration exports +export { + initializeWcagConfig, + getWcagConfig, + getConfigValue, + hasConfigValue, +} from './config-instance'; + +// Schema exports +export { wcagAppConfigSchema, type WcagAppConfig } from './schemas/wcag-app.schema'; +export { scannerConfigSchema, type ScannerConfig } from './schemas/scanner.schema'; +export { workerConfigSchema, type WorkerConfig } from './schemas/worker.schema'; +export { featuresConfigSchema, type FeaturesConfig } from './schemas/features.schema'; +export { providersConfigSchema, type ProvidersConfig } from './schemas/providers.schema'; + +// Helper functions +import { getWcagConfig } from './config-instance'; +import type { ScannerConfig, WorkerConfig, FeaturesConfig, ProvidersConfig } from './schemas'; + +/** + * Get scanner configuration + */ +export function getScannerConfig(): ScannerConfig { + return getWcagConfig().scanner; +} + +/** + * Get worker configuration + */ +export function getWorkerConfig(): WorkerConfig { + return getWcagConfig().worker; +} + +/** + * Get features configuration + */ +export function getFeaturesConfig(): FeaturesConfig { + return getWcagConfig().features; +} + +/** + * Get providers configuration + */ +export function getProvidersConfig(): ProvidersConfig { + return getWcagConfig().providers; +} + +/** + * Get service configuration + */ +export function getServiceConfig(service: 'api' | 'dashboard' | 'worker') { + return getWcagConfig().services[service]; +} + +/** + * Check if a feature is enabled + */ +export function isFeatureEnabled(feature: string): boolean { + const features = getFeaturesConfig(); + const parts = feature.split('.'); + + let current: any = features; + for (const part of parts) { + if (typeof current !== 'object' || !(part in current)) { + return false; + } + current = current[part]; + } + + return current === true || (typeof current === 'object' && current.enabled === true); +} + +/** + * Get database configuration + */ +export function getDatabaseConfig() { + return getWcagConfig().database; +} + +/** + * Get Redis configuration for queues + */ +export function getRedisConfig() { + return getWcagConfig().worker.redis; +} + +/** + * Get storage configuration + */ +export function getStorageConfig() { + return getWcagConfig().providers.storage; +} + +/** + * Get compliance configuration + */ +export function getComplianceConfig() { + return getWcagConfig().compliance; +} + +/** + * Get subscription tier configuration + */ +export function getSubscriptionConfig(tier: 'starter' | 'professional' | 'enterprise') { + return getWcagConfig().subscriptions.tiers[tier]; +} \ No newline at end of file diff --git a/apps/wcag-ada/config/src/schemas/features.schema.ts b/apps/wcag-ada/config/src/schemas/features.schema.ts new file mode 100644 index 0000000..c83f047 --- /dev/null +++ b/apps/wcag-ada/config/src/schemas/features.schema.ts @@ -0,0 +1,89 @@ +import { z } from 'zod'; + +export const featuresConfigSchema = z.object({ + // Scanner features + screenshots: z.object({ + enabled: z.boolean().default(true), + quality: z.number().min(0).max(100).default(80), + fullPage: z.boolean().default(false), + }).default({}), + + customRules: z.object({ + enabled: z.boolean().default(true), + maxRules: z.number().default(50), + }).default({}), + + multiPage: z.object({ + enabled: z.boolean().default(true), + maxPages: z.number().default(10), + maxDepth: z.number().default(3), + }).default({}), + + // API features + apiKeys: z.object({ + enabled: z.boolean().default(true), + maxPerUser: z.number().default(5), + }).default({}), + + webhooks: z.object({ + enabled: z.boolean().default(true), + maxPerWebsite: z.number().default(3), + retryAttempts: z.number().default(3), + }).default({}), + + // Report features + reports: z.object({ + pdf: z.object({ + enabled: z.boolean().default(true), + watermark: z.boolean().default(true), + }).default({}), + excel: z.object({ + enabled: z.boolean().default(false), + }).default({}), + scheduling: z.object({ + enabled: z.boolean().default(true), + }).default({}), + }).default({}), + + // Compliance features + compliance: z.object({ + wcag20: z.boolean().default(true), + wcag21: z.boolean().default(true), + wcag22: z.boolean().default(true), + section508: z.boolean().default(false), + ada: z.boolean().default(true), + }).default({}), + + // Integration features + integrations: z.object({ + github: z.object({ + enabled: z.boolean().default(true), + }).default({}), + slack: z.object({ + enabled: z.boolean().default(true), + }).default({}), + teams: z.object({ + enabled: z.boolean().default(false), + }).default({}), + jira: z.object({ + enabled: z.boolean().default(false), + }).default({}), + }).default({}), + + // Enterprise features + enterprise: z.object({ + sso: z.object({ + enabled: z.boolean().default(false), + providers: z.array(z.enum(['saml', 'oauth', 'ldap'])).default([]), + }).default({}), + whiteLabel: z.object({ + enabled: z.boolean().default(false), + }).default({}), + audit: z.object({ + enabled: z.boolean().default(true), + retention: z.number().default(365), // days + }).default({}), + }).default({}), +}); + +export type FeaturesConfig = z.infer; \ No newline at end of file diff --git a/apps/wcag-ada/config/src/schemas/providers.schema.ts b/apps/wcag-ada/config/src/schemas/providers.schema.ts new file mode 100644 index 0000000..e11bdf8 --- /dev/null +++ b/apps/wcag-ada/config/src/schemas/providers.schema.ts @@ -0,0 +1,84 @@ +import { z } from 'zod'; + +export const providersConfigSchema = z.object({ + // Storage providers + storage: z.object({ + type: z.enum(['local', 's3', 'gcs', 'azure']).default('local'), + local: z.object({ + basePath: z.string().default('/tmp/wcag-ada'), + reports: z.string().default('reports'), + screenshots: z.string().default('screenshots'), + exports: z.string().default('exports'), + }).default({}), + s3: z.object({ + enabled: z.boolean().default(false), + bucket: z.string().optional(), + region: z.string().default('us-east-1'), + accessKeyId: z.string().optional(), + secretAccessKey: z.string().optional(), + endpoint: z.string().optional(), + }).default({}), + }).default({}), + + // Email providers + email: z.object({ + enabled: z.boolean().default(true), + provider: z.enum(['smtp', 'sendgrid', 'ses', 'postmark']).default('smtp'), + from: z.object({ + name: z.string().default('WCAG-ADA Compliance'), + email: z.string().email().default('noreply@wcag-ada.com'), + }).default({}), + smtp: z.object({ + host: z.string().default('localhost'), + port: z.number().default(587), + secure: z.boolean().default(false), + auth: z.object({ + user: z.string().optional(), + pass: z.string().optional(), + }).default({}), + }).default({}), + sendgrid: z.object({ + apiKey: z.string().optional(), + }).default({}), + }).default({}), + + // Authentication providers + auth: z.object({ + jwt: z.object({ + secret: z.string().default('change-me-in-production'), + expiresIn: z.string().default('7d'), + refreshExpiresIn: z.string().default('30d'), + }).default({}), + oauth: z.object({ + google: z.object({ + enabled: z.boolean().default(false), + clientId: z.string().optional(), + clientSecret: z.string().optional(), + }).default({}), + github: z.object({ + enabled: z.boolean().default(false), + clientId: z.string().optional(), + clientSecret: z.string().optional(), + }).default({}), + }).default({}), + }).default({}), + + // Analytics providers + analytics: z.object({ + enabled: z.boolean().default(true), + provider: z.enum(['posthog', 'mixpanel', 'amplitude', 'custom']).default('posthog'), + posthog: z.object({ + apiKey: z.string().optional(), + host: z.string().default('https://app.posthog.com'), + }).default({}), + }).default({}), + + // CDN providers + cdn: z.object({ + enabled: z.boolean().default(false), + provider: z.enum(['cloudflare', 'cloudfront', 'fastly']).default('cloudflare'), + baseUrl: z.string().optional(), + }).default({}), +}); + +export type ProvidersConfig = z.infer; \ No newline at end of file diff --git a/apps/wcag-ada/config/src/schemas/scanner.schema.ts b/apps/wcag-ada/config/src/schemas/scanner.schema.ts new file mode 100644 index 0000000..6b96626 --- /dev/null +++ b/apps/wcag-ada/config/src/schemas/scanner.schema.ts @@ -0,0 +1,34 @@ +import { z } from 'zod'; + +export const scannerConfigSchema = z.object({ + concurrency: z.number().min(1).max(10).default(2), + timeout: z.number().min(30000).default(120000), // 2 minutes default + pageLoadTimeout: z.number().min(10000).default(30000), + headless: z.boolean().default(true), + blockResources: z.boolean().default(true), + viewport: z.object({ + width: z.number().default(1280), + height: z.number().default(720), + deviceScaleFactor: z.number().default(1), + }).default({}), + browsers: z.object({ + chromium: z.object({ + enabled: z.boolean().default(true), + args: z.array(z.string()).default(['--no-sandbox', '--disable-setuid-sandbox']), + }).default({}), + }).default({}), + axe: z.object({ + tags: z.array(z.string()).default(['wcag2aa', 'wcag21aa']), + resultTypes: z.array(z.enum(['violations', 'passes', 'incomplete', 'inapplicable'])) + .default(['violations', 'passes', 'incomplete', 'inapplicable']), + }).default({}), + retries: z.object({ + maxAttempts: z.number().default(3), + backoff: z.object({ + type: z.enum(['exponential', 'fixed']).default('exponential'), + delay: z.number().default(2000), + }).default({}), + }).default({}), +}); + +export type ScannerConfig = z.infer; \ No newline at end of file diff --git a/apps/wcag-ada/config/src/schemas/wcag-app.schema.ts b/apps/wcag-ada/config/src/schemas/wcag-app.schema.ts new file mode 100644 index 0000000..d014f4a --- /dev/null +++ b/apps/wcag-ada/config/src/schemas/wcag-app.schema.ts @@ -0,0 +1,111 @@ +import { z } from 'zod'; +import { + baseAppConfigSchema, + logConfigSchema, + databaseConfigSchema, + serviceConfigSchema, +} from '@stock-bot/core-config'; +import { scannerConfigSchema } from './scanner.schema'; +import { workerConfigSchema } from './worker.schema'; +import { featuresConfigSchema } from './features.schema'; +import { providersConfigSchema } from './providers.schema'; + +// Service-specific configurations +const wcagServicesSchema = z.object({ + api: serviceConfigSchema.extend({ + port: z.number().default(3001), + cors: z.object({ + enabled: z.boolean().default(true), + origin: z.string().default('*'), + credentials: z.boolean().default(true), + }).default({}), + rateLimit: z.object({ + enabled: z.boolean().default(true), + windowMs: z.number().default(900000), // 15 minutes + max: z.number().default(100), + keyGenerator: z.enum(['ip', 'userId', 'apiKey']).default('userId'), + }).default({}), + pagination: z.object({ + defaultLimit: z.number().default(20), + maxLimit: z.number().default(100), + }).default({}), + }), + + dashboard: serviceConfigSchema.extend({ + port: z.number().default(3000), + apiUrl: z.string().default('http://localhost:3001'), + publicUrl: z.string().default('http://localhost:3000'), + }), + + worker: serviceConfigSchema.extend({ + port: z.number().default(3002), // For health checks + }), +}); + +// Main WCAG application configuration schema +export const wcagAppConfigSchema = baseAppConfigSchema.extend({ + appName: z.literal('wcag-ada').default('wcag-ada'), + + // Core configurations + log: logConfigSchema, + database: databaseConfigSchema, + + // WCAG-specific configurations + scanner: scannerConfigSchema, + worker: workerConfigSchema, + features: featuresConfigSchema, + providers: providersConfigSchema, + + // Service configurations + services: wcagServicesSchema.default({}), + + // Business logic configurations + compliance: z.object({ + defaultLevel: z.object({ + standard: z.enum(['WCAG20', 'WCAG21', 'WCAG22']).default('WCAG21'), + level: z.enum(['A', 'AA', 'AAA']).default('AA'), + }).default({}), + passingScore: z.object({ + A: z.number().min(0).max(100).default(95), + AA: z.number().min(0).max(100).default(98), + AAA: z.number().min(0).max(100).default(100), + }).default({}), + criticalCriteria: z.array(z.string()).default([ + '1.1.1', // Non-text Content + '1.3.1', // Info and Relationships + '1.4.3', // Contrast (Minimum) + '2.1.1', // Keyboard + '2.1.2', // No Keyboard Trap + '2.4.1', // Bypass Blocks + '2.4.2', // Page Titled + '4.1.2', // Name, Role, Value + ]), + }).default({}), + + // Subscription/pricing tiers + subscriptions: z.object({ + enabled: z.boolean().default(true), + tiers: z.object({ + starter: z.object({ + websites: z.number().default(5), + scansPerMonth: z.number().default(500), + users: z.number().default(1), + price: z.number().default(49), + }).default({}), + professional: z.object({ + websites: z.number().default(25), + scansPerMonth: z.number().default(5000), + users: z.number().default(5), + price: z.number().default(149), + }).default({}), + enterprise: z.object({ + websites: z.number().default(-1), // Unlimited + scansPerMonth: z.number().default(-1), + users: z.number().default(-1), + price: z.number().default(499), + }).default({}), + }).default({}), + }).default({}), +}); + +export type WcagAppConfig = z.infer; \ No newline at end of file diff --git a/apps/wcag-ada/config/src/schemas/worker.schema.ts b/apps/wcag-ada/config/src/schemas/worker.schema.ts new file mode 100644 index 0000000..a1fe468 --- /dev/null +++ b/apps/wcag-ada/config/src/schemas/worker.schema.ts @@ -0,0 +1,49 @@ +import { z } from 'zod'; + +export const workerConfigSchema = z.object({ + enabled: z.boolean().default(true), + concurrency: z.number().min(1).max(10).default(2), + queueName: z.string().default('accessibility-scans'), + redis: z.object({ + host: z.string().default('localhost'), + port: z.number().default(6379), + password: z.string().optional(), + db: z.number().default(2), // Different DB for WCAG + maxRetriesPerRequest: z.number().nullable().default(null), + }).default({}), + jobs: z.object({ + scan: z.object({ + priority: z.number().default(0), + attempts: z.number().default(3), + backoff: z.object({ + type: z.enum(['exponential', 'fixed']).default('exponential'), + delay: z.number().default(2000), + }).default({}), + timeout: z.number().default(300000), // 5 minutes + removeOnComplete: z.object({ + age: z.number().default(86400), // 24 hours + count: z.number().default(100), + }).default({}), + removeOnFail: z.object({ + age: z.number().default(604800), // 7 days + count: z.number().default(500), + }).default({}), + }).default({}), + report: z.object({ + priority: z.number().default(1), + attempts: z.number().default(2), + backoff: z.object({ + type: z.enum(['exponential', 'fixed']).default('exponential'), + delay: z.number().default(5000), + }).default({}), + timeout: z.number().default(600000), // 10 minutes + }).default({}), + }).default({}), + scheduler: z.object({ + enabled: z.boolean().default(true), + interval: z.number().default(60000), // Check every minute + timezone: z.string().default('UTC'), + }).default({}), +}); + +export type WorkerConfig = z.infer; \ No newline at end of file diff --git a/apps/wcag-ada/config/tsconfig.json b/apps/wcag-ada/config/tsconfig.json new file mode 100644 index 0000000..7536eb5 --- /dev/null +++ b/apps/wcag-ada/config/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "composite": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"], + "references": [ + { "path": "../../../libs/core/config" } + ] +} \ No newline at end of file diff --git a/apps/wcag-ada/dashboard/Dockerfile b/apps/wcag-ada/dashboard/Dockerfile new file mode 100644 index 0000000..1f0a53d --- /dev/null +++ b/apps/wcag-ada/dashboard/Dockerfile @@ -0,0 +1,57 @@ +# Build stage +FROM oven/bun:1-alpine as builder + +WORKDIR /app + +# Copy workspace files +COPY package.json bun.lockb ./ +COPY apps/wcag-ada/dashboard/package.json ./apps/wcag-ada/dashboard/ +COPY apps/wcag-ada/shared/package.json ./apps/wcag-ada/shared/ + +# Install dependencies +RUN bun install --frozen-lockfile + +# Copy source code +COPY apps/wcag-ada/dashboard ./apps/wcag-ada/dashboard +COPY apps/wcag-ada/shared ./apps/wcag-ada/shared +COPY tsconfig.json ./ + +# Build the application +WORKDIR /app/apps/wcag-ada/dashboard +RUN bun run build + +# Production stage with nginx +FROM nginx:alpine + +# Install runtime dependencies +RUN apk add --no-cache curl + +# Copy nginx configuration +COPY apps/wcag-ada/dashboard/nginx.conf /etc/nginx/nginx.conf + +# Copy built application +COPY --from=builder /app/apps/wcag-ada/dashboard/dist /usr/share/nginx/html + +# Create non-root user +RUN addgroup -g 1001 -S nodejs && \ + adduser -S nodejs -u 1001 + +# Set ownership +RUN chown -R nodejs:nodejs /usr/share/nginx/html && \ + chown -R nodejs:nodejs /var/cache/nginx && \ + chown -R nodejs:nodejs /var/log/nginx && \ + chown -R nodejs:nodejs /etc/nginx/conf.d && \ + touch /var/run/nginx.pid && \ + chown -R nodejs:nodejs /var/run/nginx.pid + +USER nodejs + +# Expose port +EXPOSE 8080 + +# Health check +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD curl -f http://localhost:8080/ || exit 1 + +# Start nginx +CMD ["nginx", "-g", "daemon off;"] \ No newline at end of file diff --git a/apps/wcag-ada/dashboard/index.html b/apps/wcag-ada/dashboard/index.html new file mode 100644 index 0000000..d096489 --- /dev/null +++ b/apps/wcag-ada/dashboard/index.html @@ -0,0 +1,13 @@ + + + + + + + WCAG-ADA Compliance Dashboard + + +
+ + + \ No newline at end of file diff --git a/apps/wcag-ada/dashboard/nginx.conf b/apps/wcag-ada/dashboard/nginx.conf new file mode 100644 index 0000000..193eb37 --- /dev/null +++ b/apps/wcag-ada/dashboard/nginx.conf @@ -0,0 +1,79 @@ +worker_processes auto; +error_log /var/log/nginx/error.log warn; +pid /var/run/nginx.pid; + +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + + access_log /var/log/nginx/access.log main; + + sendfile on; + tcp_nopush on; + tcp_nodelay on; + keepalive_timeout 65; + types_hash_max_size 2048; + + # Gzip compression + gzip on; + gzip_vary on; + gzip_proxied any; + gzip_comp_level 6; + gzip_types text/plain text/css text/xml text/javascript application/json application/javascript application/xml+rss application/rss+xml application/atom+xml image/svg+xml; + + # Security headers + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; + add_header Referrer-Policy "no-referrer-when-downgrade" always; + add_header Content-Security-Policy "default-src 'self' http: https: data: blob: 'unsafe-inline' 'unsafe-eval'" always; + + server { + listen 8080; + server_name localhost; + root /usr/share/nginx/html; + index index.html; + + # Cache static assets + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + } + + # Serve index.html for all routes (React Router) + location / { + try_files $uri $uri/ /index.html; + add_header Cache-Control "no-cache, no-store, must-revalidate"; + add_header Pragma "no-cache"; + add_header Expires "0"; + } + + # API proxy (if needed in production) + location /api { + proxy_pass http://wcag-ada-api:3001; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_cache_bypass $http_upgrade; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # Health check endpoint + location /health { + access_log off; + return 200 "healthy\n"; + add_header Content-Type text/plain; + } + } +} \ No newline at end of file diff --git a/apps/wcag-ada/dashboard/package.json b/apps/wcag-ada/dashboard/package.json new file mode 100644 index 0000000..8b04e7f --- /dev/null +++ b/apps/wcag-ada/dashboard/package.json @@ -0,0 +1,61 @@ +{ + "name": "@wcag-ada/dashboard", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview", + "typecheck": "tsc --noEmit", + "lint": "eslint src" + }, + "dependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-router-dom": "^6.21.1", + "@tanstack/react-query": "^5.17.0", + "axios": "^1.6.5", + "zustand": "^4.4.7", + "@wcag-ada/shared": "workspace:*", + "@wcag-ada/config": "workspace:*", + "recharts": "^2.10.3", + "date-fns": "^3.0.6", + "clsx": "^2.1.0", + "lucide-react": "^0.303.0", + "@radix-ui/react-alert-dialog": "^1.0.5", + "@radix-ui/react-checkbox": "^1.0.4", + "@radix-ui/react-dialog": "^1.0.5", + "@radix-ui/react-dropdown-menu": "^2.0.6", + "@radix-ui/react-label": "^2.0.2", + "@radix-ui/react-popover": "^1.0.7", + "@radix-ui/react-select": "^2.0.0", + "@radix-ui/react-separator": "^1.0.3", + "@radix-ui/react-slot": "^1.0.2", + "@radix-ui/react-switch": "^1.0.3", + "@radix-ui/react-tabs": "^1.0.4", + "@radix-ui/react-toast": "^1.1.5", + "@radix-ui/react-tooltip": "^1.0.7", + "tailwindcss-animate": "^1.0.7", + "react-hook-form": "^7.48.2", + "@hookform/resolvers": "^3.3.4", + "zod": "^3.22.4", + "class-variance-authority": "^0.7.0", + "tailwind-merge": "^2.2.0" + }, + "devDependencies": { + "@types/react": "^18.2.47", + "@types/react-dom": "^18.2.18", + "@typescript-eslint/eslint-plugin": "^6.18.1", + "@typescript-eslint/parser": "^6.18.1", + "@vitejs/plugin-react": "^4.2.1", + "autoprefixer": "^10.4.16", + "eslint": "^8.56.0", + "eslint-plugin-react-hooks": "^4.6.0", + "eslint-plugin-react-refresh": "^0.4.5", + "postcss": "^8.4.33", + "tailwindcss": "^3.4.0", + "typescript": "^5.3.3", + "vite": "^5.0.11" + } +} \ No newline at end of file diff --git a/apps/wcag-ada/dashboard/postcss.config.js b/apps/wcag-ada/dashboard/postcss.config.js new file mode 100644 index 0000000..e99ebc2 --- /dev/null +++ b/apps/wcag-ada/dashboard/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} \ No newline at end of file diff --git a/apps/wcag-ada/dashboard/public/icon.svg b/apps/wcag-ada/dashboard/public/icon.svg new file mode 100644 index 0000000..a1ca464 --- /dev/null +++ b/apps/wcag-ada/dashboard/public/icon.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/apps/wcag-ada/dashboard/src/App.tsx b/apps/wcag-ada/dashboard/src/App.tsx new file mode 100644 index 0000000..376948d --- /dev/null +++ b/apps/wcag-ada/dashboard/src/App.tsx @@ -0,0 +1,70 @@ +import { useEffect } from 'react'; +import { Routes, Route, Navigate } from 'react-router-dom'; +import { useAuthStore } from '@/store/auth-store'; +import { apiClient } from '@/lib/api-client'; +import { MainLayout } from '@/components/layout/main-layout'; +import { AuthLayout } from '@/components/layout/auth-layout'; +import { ProtectedRoute } from '@/components/layout/protected-route'; + +// Pages +import { LoginPage } from '@/pages/auth/login'; +import { RegisterPage } from '@/pages/auth/register'; +import { DashboardPage } from '@/pages/dashboard'; +import { WebsitesPage } from '@/pages/websites'; +import { WebsiteDetailPage } from '@/pages/websites/[id]'; +import { ScansPage } from '@/pages/scans'; +import { ScanDetailPage } from '@/pages/scans/[id]'; +import { ReportsPage } from '@/pages/reports'; +import { SettingsPage } from '@/pages/settings'; + +function App() { + const { setAuth, setLoading, token } = useAuthStore(); + + useEffect(() => { + const initAuth = async () => { + if (!token) { + setLoading(false); + return; + } + + try { + const user = await apiClient.getMe(); + setAuth(user, token); + } catch (error) { + console.error('Auth check failed:', error); + setLoading(false); + } + }; + + initAuth(); + }, [token, setAuth, setLoading]); + + return ( + + {/* Auth routes */} + }> + } /> + } /> + + + {/* Protected routes */} + }> + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + + {/* 404 */} + } /> + + ); +} + +export default App; \ No newline at end of file diff --git a/apps/wcag-ada/dashboard/src/components/layout/auth-layout.tsx b/apps/wcag-ada/dashboard/src/components/layout/auth-layout.tsx new file mode 100644 index 0000000..9133866 --- /dev/null +++ b/apps/wcag-ada/dashboard/src/components/layout/auth-layout.tsx @@ -0,0 +1,26 @@ +import { Navigate, Outlet } from 'react-router-dom'; +import { useAuthStore } from '@/store/auth-store'; + +export function AuthLayout() { + const { isAuthenticated } = useAuthStore(); + + if (isAuthenticated) { + return ; + } + + return ( +
+
+
+

+ WCAG-ADA Compliance +

+

+ Automated accessibility testing and compliance monitoring +

+
+ +
+
+ ); +} \ No newline at end of file diff --git a/apps/wcag-ada/dashboard/src/components/layout/main-layout.tsx b/apps/wcag-ada/dashboard/src/components/layout/main-layout.tsx new file mode 100644 index 0000000..ab5dc38 --- /dev/null +++ b/apps/wcag-ada/dashboard/src/components/layout/main-layout.tsx @@ -0,0 +1,189 @@ +import { Outlet, Link, useLocation } from 'react-router-dom'; +import { cn } from '@/lib/utils'; +import { Button } from '@/components/ui/button'; +import { useAuthStore } from '@/store/auth-store'; +import { + Home, + Globe, + Scan, + FileText, + Settings, + LogOut, + Menu, + X, + Shield, +} from 'lucide-react'; +import { useState } from 'react'; + +const navigation = [ + { name: 'Dashboard', href: '/dashboard', icon: Home }, + { name: 'Websites', href: '/websites', icon: Globe }, + { name: 'Scans', href: '/scans', icon: Scan }, + { name: 'Reports', href: '/reports', icon: FileText }, + { name: 'Settings', href: '/settings', icon: Settings }, +]; + +export function MainLayout() { + const location = useLocation(); + const { user, logout } = useAuthStore(); + const [sidebarOpen, setSidebarOpen] = useState(false); + + return ( +
+ {/* Mobile sidebar */} +
+
setSidebarOpen(false)} + /> +
+
+
+ + WCAG-ADA +
+ +
+ +
+
+
+ + {user?.name?.[0] || user?.email[0].toUpperCase()} + +
+
+

+ {user?.name || user?.email} +

+

+ {user?.role} +

+
+
+ +
+
+
+ + {/* Desktop sidebar */} +
+
+
+
+ + WCAG-ADA +
+
+ +
+
+
+ + {user?.name?.[0] || user?.email[0].toUpperCase()} + +
+
+

+ {user?.name || user?.email} +

+

+ {user?.role} +

+
+
+ +
+
+
+ + {/* Main content */} +
+
+ +

+ {navigation.find((n) => location.pathname.startsWith(n.href))?.name || 'WCAG-ADA Compliance'} +

+
+
+
+ +
+
+
+
+ ); +} \ No newline at end of file diff --git a/apps/wcag-ada/dashboard/src/components/layout/protected-route.tsx b/apps/wcag-ada/dashboard/src/components/layout/protected-route.tsx new file mode 100644 index 0000000..7bf7329 --- /dev/null +++ b/apps/wcag-ada/dashboard/src/components/layout/protected-route.tsx @@ -0,0 +1,21 @@ +import { Navigate, Outlet } from 'react-router-dom'; +import { useAuthStore } from '@/store/auth-store'; +import { Loader2 } from 'lucide-react'; + +export function ProtectedRoute() { + const { isAuthenticated, isLoading } = useAuthStore(); + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (!isAuthenticated) { + return ; + } + + return ; +} \ No newline at end of file diff --git a/apps/wcag-ada/dashboard/src/components/ui/button.tsx b/apps/wcag-ada/dashboard/src/components/ui/button.tsx new file mode 100644 index 0000000..9460cd9 --- /dev/null +++ b/apps/wcag-ada/dashboard/src/components/ui/button.tsx @@ -0,0 +1,55 @@ +import * as React from 'react'; +import { Slot } from '@radix-ui/react-slot'; +import { cva, type VariantProps } from 'class-variance-authority'; +import { cn } from '@/lib/utils'; + +const buttonVariants = cva( + 'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50', + { + variants: { + variant: { + default: 'bg-primary text-primary-foreground hover:bg-primary/90', + destructive: + 'bg-destructive text-destructive-foreground hover:bg-destructive/90', + outline: + 'border border-input bg-background hover:bg-accent hover:text-accent-foreground', + secondary: + 'bg-secondary text-secondary-foreground hover:bg-secondary/80', + ghost: 'hover:bg-accent hover:text-accent-foreground', + link: 'text-primary underline-offset-4 hover:underline', + }, + size: { + default: 'h-10 px-4 py-2', + sm: 'h-9 rounded-md px-3', + lg: 'h-11 rounded-md px-8', + icon: 'h-10 w-10', + }, + }, + defaultVariants: { + variant: 'default', + size: 'default', + }, + } +); + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps { + asChild?: boolean; +} + +const Button = React.forwardRef( + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : 'button'; + return ( + + ); + } +); +Button.displayName = 'Button'; + +export { Button, buttonVariants }; \ No newline at end of file diff --git a/apps/wcag-ada/dashboard/src/components/ui/card.tsx b/apps/wcag-ada/dashboard/src/components/ui/card.tsx new file mode 100644 index 0000000..e912382 --- /dev/null +++ b/apps/wcag-ada/dashboard/src/components/ui/card.tsx @@ -0,0 +1,78 @@ +import * as React from 'react'; +import { cn } from '@/lib/utils'; + +const Card = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +Card.displayName = 'Card'; + +const CardHeader = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +CardHeader.displayName = 'CardHeader'; + +const CardTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)); +CardTitle.displayName = 'CardTitle'; + +const CardDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)); +CardDescription.displayName = 'CardDescription'; + +const CardContent = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)); +CardContent.displayName = 'CardContent'; + +const CardFooter = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +CardFooter.displayName = 'CardFooter'; + +export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }; \ No newline at end of file diff --git a/apps/wcag-ada/dashboard/src/lib/api-client.ts b/apps/wcag-ada/dashboard/src/lib/api-client.ts new file mode 100644 index 0000000..5bc6ae9 --- /dev/null +++ b/apps/wcag-ada/dashboard/src/lib/api-client.ts @@ -0,0 +1,167 @@ +import axios, { AxiosError, AxiosInstance } from 'axios'; +import { useAuthStore } from '@/store/auth-store'; + +class ApiClient { + private client: AxiosInstance; + + constructor() { + this.client = axios.create({ + baseURL: '/api', + headers: { + 'Content-Type': 'application/json', + }, + }); + + // Request interceptor to add auth token + this.client.interceptors.request.use( + (config) => { + const token = useAuthStore.getState().token; + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + return config; + }, + (error) => Promise.reject(error) + ); + + // Response interceptor to handle errors + this.client.interceptors.response.use( + (response) => response, + async (error: AxiosError) => { + if (error.response?.status === 401) { + // Token expired or invalid + useAuthStore.getState().logout(); + window.location.href = '/login'; + } + return Promise.reject(error); + } + ); + } + + // Auth endpoints + async login(email: string, password: string) { + const response = await this.client.post('/auth/login', { email, password }); + return response.data; + } + + async register(data: { email: string; password: string; name?: string; company?: string }) { + const response = await this.client.post('/auth/register', data); + return response.data; + } + + async getMe() { + const response = await this.client.get('/auth/me'); + return response.data; + } + + async refreshApiKey() { + const response = await this.client.post('/auth/refresh-api-key'); + return response.data; + } + + // Website endpoints + async getWebsites(params?: { page?: number; limit?: number; search?: string }) { + const response = await this.client.get('/websites', { params }); + return response.data; + } + + async getWebsite(id: string) { + const response = await this.client.get(`/websites/${id}`); + return response.data; + } + + async createWebsite(data: any) { + const response = await this.client.post('/websites', data); + return response.data; + } + + async updateWebsite(id: string, data: any) { + const response = await this.client.patch(`/websites/${id}`, data); + return response.data; + } + + async deleteWebsite(id: string) { + const response = await this.client.delete(`/websites/${id}`); + return response.data; + } + + async getWebsiteScans(id: string, params?: { page?: number; limit?: number }) { + const response = await this.client.get(`/websites/${id}/scans`, { params }); + return response.data; + } + + // Scan endpoints + async createScan(data: { websiteId: string; url?: string; options?: any }) { + const response = await this.client.post('/scans', data); + return response.data; + } + + async getScan(id: string) { + const response = await this.client.get(`/scans/${id}`); + return response.data; + } + + async getScanResult(id: string) { + const response = await this.client.get(`/scans/${id}/result`); + return response.data; + } + + async getScanViolations(id: string, params?: { impact?: string; tag?: string }) { + const response = await this.client.get(`/scans/${id}/violations`, { params }); + return response.data; + } + + async cancelScan(id: string) { + const response = await this.client.delete(`/scans/${id}`); + return response.data; + } + + async getScans(params?: { page?: number; limit?: number; status?: string; websiteId?: string }) { + const response = await this.client.get('/scans', { params }); + return response.data; + } + + // Report endpoints + async generateReport(data: { + websiteId: string; + type: 'COMPLIANCE' | 'EXECUTIVE' | 'TECHNICAL' | 'TREND'; + format: 'PDF' | 'HTML' | 'JSON' | 'CSV'; + period: { start: string; end: string }; + }) { + const response = await this.client.post('/reports/generate', data); + return response.data; + } + + async getReports(params?: { page?: number; limit?: number; websiteId?: string; type?: string }) { + const response = await this.client.get('/reports', { params }); + return response.data; + } + + async getReport(id: string) { + const response = await this.client.get(`/reports/${id}`); + return response.data; + } + + async downloadReport(id: string) { + const response = await this.client.get(`/reports/${id}/download`); + return response.data; + } + + async getComplianceTrends(websiteId: string, days: number = 30) { + const response = await this.client.get(`/reports/trends/${websiteId}`, { params: { days } }); + return response.data; + } + + // Health endpoints + async getHealth() { + const response = await this.client.get('/health'); + return response.data; + } + + async getStats() { + const response = await this.client.get('/health/stats'); + return response.data; + } +} + +export const apiClient = new ApiClient(); \ No newline at end of file diff --git a/apps/wcag-ada/dashboard/src/lib/utils.ts b/apps/wcag-ada/dashboard/src/lib/utils.ts new file mode 100644 index 0000000..d34cf7e --- /dev/null +++ b/apps/wcag-ada/dashboard/src/lib/utils.ts @@ -0,0 +1,6 @@ +import { type ClassValue, clsx } from 'clsx'; +import { twMerge } from 'tailwind-merge'; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} \ No newline at end of file diff --git a/apps/wcag-ada/dashboard/src/main.tsx b/apps/wcag-ada/dashboard/src/main.tsx new file mode 100644 index 0000000..70d5629 --- /dev/null +++ b/apps/wcag-ada/dashboard/src/main.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; +import { BrowserRouter } from 'react-router-dom'; +import App from './App'; +import './styles/globals.css'; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 1000 * 60 * 5, // 5 minutes + retry: 1, + }, + }, +}); + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + + + + + + +); \ No newline at end of file diff --git a/apps/wcag-ada/dashboard/src/pages/auth/login.tsx b/apps/wcag-ada/dashboard/src/pages/auth/login.tsx new file mode 100644 index 0000000..70707c1 --- /dev/null +++ b/apps/wcag-ada/dashboard/src/pages/auth/login.tsx @@ -0,0 +1,112 @@ +import { useState } from 'react'; +import { useNavigate, Link } from 'react-router-dom'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { z } from 'zod'; +import { apiClient } from '@/lib/api-client'; +import { useAuthStore } from '@/store/auth-store'; +import { Button } from '@/components/ui/button'; +import { Loader2 } from 'lucide-react'; + +const loginSchema = z.object({ + email: z.string().email('Invalid email address'), + password: z.string().min(8, 'Password must be at least 8 characters'), +}); + +type LoginForm = z.infer; + +export function LoginPage() { + const navigate = useNavigate(); + const { setAuth } = useAuthStore(); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const { + register, + handleSubmit, + formState: { errors }, + } = useForm({ + resolver: zodResolver(loginSchema), + }); + + const onSubmit = async (data: LoginForm) => { + setIsLoading(true); + setError(null); + + try { + const response = await apiClient.login(data.email, data.password); + setAuth(response.user, response.token); + navigate('/dashboard'); + } catch (err: any) { + setError(err.response?.data?.error || 'Login failed. Please try again.'); + } finally { + setIsLoading(false); + } + }; + + return ( +
+
+
+ + + {errors.email && ( +

{errors.email.message}

+ )} +
+
+ + + {errors.password && ( +

{errors.password.message}

+ )} +
+
+ + {error && ( +
+

{error}

+
+ )} + +
+ +
+ +
+ + Don't have an account?{' '} + + Sign up + + +
+
+ ); +} \ No newline at end of file diff --git a/apps/wcag-ada/dashboard/src/pages/auth/register.tsx b/apps/wcag-ada/dashboard/src/pages/auth/register.tsx new file mode 100644 index 0000000..764352a --- /dev/null +++ b/apps/wcag-ada/dashboard/src/pages/auth/register.tsx @@ -0,0 +1,141 @@ +import { useState } from 'react'; +import { useNavigate, Link } from 'react-router-dom'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { z } from 'zod'; +import { apiClient } from '@/lib/api-client'; +import { useAuthStore } from '@/store/auth-store'; +import { Button } from '@/components/ui/button'; +import { Loader2 } from 'lucide-react'; + +const registerSchema = z.object({ + email: z.string().email('Invalid email address'), + password: z.string().min(8, 'Password must be at least 8 characters'), + name: z.string().optional(), + company: z.string().optional(), +}); + +type RegisterForm = z.infer; + +export function RegisterPage() { + const navigate = useNavigate(); + const { setAuth } = useAuthStore(); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const { + register, + handleSubmit, + formState: { errors }, + } = useForm({ + resolver: zodResolver(registerSchema), + }); + + const onSubmit = async (data: RegisterForm) => { + setIsLoading(true); + setError(null); + + try { + const response = await apiClient.register(data); + setAuth(response.user, response.token); + navigate('/dashboard'); + } catch (err: any) { + setError(err.response?.data?.error || 'Registration failed. Please try again.'); + } finally { + setIsLoading(false); + } + }; + + return ( +
+
+
+ + + {errors.email && ( +

{errors.email.message}

+ )} +
+ +
+ + + {errors.password && ( +

{errors.password.message}

+ )} +
+ +
+ + +
+ +
+ + +
+
+ + {error && ( +
+

{error}

+
+ )} + +
+ +
+ +
+ + Already have an account?{' '} + + Sign in + + +
+
+ ); +} \ No newline at end of file diff --git a/apps/wcag-ada/dashboard/src/pages/dashboard/index.tsx b/apps/wcag-ada/dashboard/src/pages/dashboard/index.tsx new file mode 100644 index 0000000..f2f7b21 --- /dev/null +++ b/apps/wcag-ada/dashboard/src/pages/dashboard/index.tsx @@ -0,0 +1,243 @@ +import { useQuery } from '@tanstack/react-query'; +import { apiClient } from '@/lib/api-client'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Loader2, Globe, Scan, AlertCircle, CheckCircle2, TrendingUp, TrendingDown } from 'lucide-react'; +import { Link } from 'react-router-dom'; +import { Button } from '@/components/ui/button'; +import { LineChart, Line, AreaChart, Area, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'; +import { format } from 'date-fns'; + +export function DashboardPage() { + const { data: stats, isLoading: statsLoading } = useQuery({ + queryKey: ['stats'], + queryFn: () => apiClient.getStats(), + }); + + const { data: recentScans } = useQuery({ + queryKey: ['recent-scans'], + queryFn: () => apiClient.getScans({ limit: 5 }), + }); + + const { data: websites } = useQuery({ + queryKey: ['websites-summary'], + queryFn: () => apiClient.getWebsites({ limit: 5 }), + }); + + if (statsLoading) { + return ( +
+ +
+ ); + } + + return ( +
+ {/* Stats Grid */} +
+ + + Total Websites + + + +
{stats?.database?.websites || 0}
+

+ Active websites being monitored +

+
+
+ + + + Total Scans + + + +
{stats?.database?.scanResults || 0}
+

+ Accessibility scans performed +

+
+
+ + + + Queue Status + + + +
{stats?.queue?.active || 0}
+

+ Active scans in progress +

+
+
+ + + + Success Rate + + + +
+ {stats?.queue?.completed && stats?.queue?.failed + ? Math.round((stats.queue.completed / (stats.queue.completed + stats.queue.failed)) * 100) + : 100}% +
+

+ Scan completion rate +

+
+
+
+ + {/* Charts Row */} +
+ + + Compliance Trends + Average compliance scores over time + + +
+ + + + + + + + + +
+
+
+ + + + Issue Distribution + Violations by severity level + + +
+ + + + + + + + + +
+
+
+
+ + {/* Recent Activity */} +
+ + +
+ Recent Scans + Latest accessibility scan results +
+ +
+ +
+ {recentScans?.scans?.map((scan: any) => ( +
+
+

{scan.website?.name}

+

+ {format(new Date(scan.createdAt), 'MMM d, h:mm a')} +

+
+
+ {scan.status === 'COMPLETED' ? ( +
+ + {scan.result?.summary?.score || 0}% + + {scan.result?.summary?.score > 90 ? ( + + ) : ( + + )} +
+ ) : ( + + {scan.status} + + )} +
+
+ ))} +
+
+
+ + + +
+ Websites + Your monitored websites +
+ +
+ +
+ {websites?.websites?.map((website: any) => ( +
+
+

{website.name}

+

{website.url}

+
+
+ {website.complianceScore !== null ? ( + + {Math.round(website.complianceScore)}% + + ) : ( + + No scans yet + + )} +
+
+ ))} +
+
+
+
+
+ ); +} \ No newline at end of file diff --git a/apps/wcag-ada/dashboard/src/pages/reports/index.tsx b/apps/wcag-ada/dashboard/src/pages/reports/index.tsx new file mode 100644 index 0000000..27978de --- /dev/null +++ b/apps/wcag-ada/dashboard/src/pages/reports/index.tsx @@ -0,0 +1,8 @@ +export function ReportsPage() { + return ( +
+

Reports

+

Compliance reports and analytics coming soon...

+
+ ); +} \ No newline at end of file diff --git a/apps/wcag-ada/dashboard/src/pages/scans/[id].tsx b/apps/wcag-ada/dashboard/src/pages/scans/[id].tsx new file mode 100644 index 0000000..f966661 --- /dev/null +++ b/apps/wcag-ada/dashboard/src/pages/scans/[id].tsx @@ -0,0 +1,8 @@ +export function ScanDetailPage() { + return ( +
+

Scan Results

+

Scan detail page coming soon...

+
+ ); +} \ No newline at end of file diff --git a/apps/wcag-ada/dashboard/src/pages/scans/index.tsx b/apps/wcag-ada/dashboard/src/pages/scans/index.tsx new file mode 100644 index 0000000..ef4bc0a --- /dev/null +++ b/apps/wcag-ada/dashboard/src/pages/scans/index.tsx @@ -0,0 +1,8 @@ +export function ScansPage() { + return ( +
+

Scans

+

Scan history and management coming soon...

+
+ ); +} \ No newline at end of file diff --git a/apps/wcag-ada/dashboard/src/pages/settings/index.tsx b/apps/wcag-ada/dashboard/src/pages/settings/index.tsx new file mode 100644 index 0000000..10689bd --- /dev/null +++ b/apps/wcag-ada/dashboard/src/pages/settings/index.tsx @@ -0,0 +1,8 @@ +export function SettingsPage() { + return ( +
+

Settings

+

Account settings and preferences coming soon...

+
+ ); +} \ No newline at end of file diff --git a/apps/wcag-ada/dashboard/src/pages/websites/[id].tsx b/apps/wcag-ada/dashboard/src/pages/websites/[id].tsx new file mode 100644 index 0000000..a1ebb34 --- /dev/null +++ b/apps/wcag-ada/dashboard/src/pages/websites/[id].tsx @@ -0,0 +1,8 @@ +export function WebsiteDetailPage() { + return ( +
+

Website Details

+

Website detail page coming soon...

+
+ ); +} \ No newline at end of file diff --git a/apps/wcag-ada/dashboard/src/pages/websites/index.tsx b/apps/wcag-ada/dashboard/src/pages/websites/index.tsx new file mode 100644 index 0000000..6922485 --- /dev/null +++ b/apps/wcag-ada/dashboard/src/pages/websites/index.tsx @@ -0,0 +1,88 @@ +import { useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { apiClient } from '@/lib/api-client'; +import { Button } from '@/components/ui/button'; +import { Card } from '@/components/ui/card'; +import { Plus, Globe, ExternalLink, MoreVertical } from 'lucide-react'; +import { Link } from 'react-router-dom'; +import { format } from 'date-fns'; + +export function WebsitesPage() { + const [page, setPage] = useState(1); + const { data, isLoading } = useQuery({ + queryKey: ['websites', page], + queryFn: () => apiClient.getWebsites({ page, limit: 12 }), + }); + + return ( +
+
+
+

Websites

+

+ Manage your monitored websites and their scan schedules +

+
+ +
+ + {isLoading ? ( +
+ {[...Array(6)].map((_, i) => ( + + ))} +
+ ) : ( +
+ {data?.websites?.map((website: any) => ( + +
+ + +
+
+

{website.name}

+ + {website.url} + + +
+
+
+ {website.complianceScore !== null ? ( +
+ + {Math.round(website.complianceScore)}% + +

Compliance

+
+ ) : ( +

No scans yet

+ )} +
+ +
+ {website.lastScanAt && ( +

+ Last scan: {format(new Date(website.lastScanAt), 'MMM d, h:mm a')} +

+ )} +
+ ))} +
+ )} +
+ ); +} \ No newline at end of file diff --git a/apps/wcag-ada/dashboard/src/store/auth-store.ts b/apps/wcag-ada/dashboard/src/store/auth-store.ts new file mode 100644 index 0000000..ac65ea2 --- /dev/null +++ b/apps/wcag-ada/dashboard/src/store/auth-store.ts @@ -0,0 +1,55 @@ +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; + +interface User { + id: string; + email: string; + name?: string; + company?: string; + role: string; + apiKey?: string; +} + +interface AuthState { + user: User | null; + token: string | null; + isAuthenticated: boolean; + isLoading: boolean; + setAuth: (user: User, token: string) => void; + setUser: (user: User) => void; + setLoading: (loading: boolean) => void; + logout: () => void; +} + +export const useAuthStore = create()( + persist( + (set) => ({ + user: null, + token: null, + isAuthenticated: false, + isLoading: true, + setAuth: (user, token) => + set({ + user, + token, + isAuthenticated: true, + isLoading: false, + }), + setUser: (user) => set({ user }), + setLoading: (loading) => set({ isLoading: loading }), + logout: () => + set({ + user: null, + token: null, + isAuthenticated: false, + isLoading: false, + }), + }), + { + name: 'wcag-auth', + partialize: (state) => ({ + token: state.token, + }), + } + ) +); \ No newline at end of file diff --git a/apps/wcag-ada/dashboard/src/styles/globals.css b/apps/wcag-ada/dashboard/src/styles/globals.css new file mode 100644 index 0000000..1695238 --- /dev/null +++ b/apps/wcag-ada/dashboard/src/styles/globals.css @@ -0,0 +1,67 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + :root { + --background: 0 0% 100%; + --foreground: 222.2 84% 4.9%; + --card: 0 0% 100%; + --card-foreground: 222.2 84% 4.9%; + --popover: 0 0% 100%; + --popover-foreground: 222.2 84% 4.9%; + --primary: 221.2 83.2% 53.3%; + --primary-foreground: 210 40% 98%; + --secondary: 210 40% 96.1%; + --secondary-foreground: 222.2 47.4% 11.2%; + --muted: 210 40% 96.1%; + --muted-foreground: 215.4 16.3% 46.9%; + --accent: 210 40% 96.1%; + --accent-foreground: 222.2 47.4% 11.2%; + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 210 40% 98%; + --success: 142.1 76.2% 36.3%; + --success-foreground: 355.7 100% 97.3%; + --warning: 47.9 95.8% 53.1%; + --warning-foreground: 26 83.3% 14.1%; + --border: 214.3 31.8% 91.4%; + --input: 214.3 31.8% 91.4%; + --ring: 221.2 83.2% 53.3%; + --radius: 0.5rem; + } + + .dark { + --background: 222.2 84% 4.9%; + --foreground: 210 40% 98%; + --card: 222.2 84% 4.9%; + --card-foreground: 210 40% 98%; + --popover: 222.2 84% 4.9%; + --popover-foreground: 210 40% 98%; + --primary: 217.2 91.2% 59.8%; + --primary-foreground: 222.2 47.4% 11.2%; + --secondary: 217.2 32.6% 17.5%; + --secondary-foreground: 210 40% 98%; + --muted: 217.2 32.6% 17.5%; + --muted-foreground: 215 20.2% 65.1%; + --accent: 217.2 32.6% 17.5%; + --accent-foreground: 210 40% 98%; + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 210 40% 98%; + --success: 142.1 70.6% 45.3%; + --success-foreground: 144.9 80.4% 10%; + --warning: 47.9 80% 60%; + --warning-foreground: 47.5 90% 10%; + --border: 217.2 32.6% 17.5%; + --input: 217.2 32.6% 17.5%; + --ring: 224.3 76.3% 48%; + } +} + +@layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + } +} \ No newline at end of file diff --git a/apps/wcag-ada/dashboard/tailwind.config.js b/apps/wcag-ada/dashboard/tailwind.config.js new file mode 100644 index 0000000..f9d4846 --- /dev/null +++ b/apps/wcag-ada/dashboard/tailwind.config.js @@ -0,0 +1,85 @@ +/** @type {import('tailwindcss').Config} */ +module.exports = { + darkMode: ["class"], + content: [ + './pages/**/*.{ts,tsx}', + './components/**/*.{ts,tsx}', + './app/**/*.{ts,tsx}', + './src/**/*.{ts,tsx}', + ], + prefix: "", + theme: { + container: { + center: true, + padding: "2rem", + screens: { + "2xl": "1400px", + }, + }, + extend: { + colors: { + border: "hsl(var(--border))", + input: "hsl(var(--input))", + ring: "hsl(var(--ring))", + background: "hsl(var(--background))", + foreground: "hsl(var(--foreground))", + primary: { + DEFAULT: "hsl(var(--primary))", + foreground: "hsl(var(--primary-foreground))", + }, + secondary: { + DEFAULT: "hsl(var(--secondary))", + foreground: "hsl(var(--secondary-foreground))", + }, + destructive: { + DEFAULT: "hsl(var(--destructive))", + foreground: "hsl(var(--destructive-foreground))", + }, + muted: { + DEFAULT: "hsl(var(--muted))", + foreground: "hsl(var(--muted-foreground))", + }, + accent: { + DEFAULT: "hsl(var(--accent))", + foreground: "hsl(var(--accent-foreground))", + }, + popover: { + DEFAULT: "hsl(var(--popover))", + foreground: "hsl(var(--popover-foreground))", + }, + card: { + DEFAULT: "hsl(var(--card))", + foreground: "hsl(var(--card-foreground))", + }, + success: { + DEFAULT: "hsl(var(--success))", + foreground: "hsl(var(--success-foreground))", + }, + warning: { + DEFAULT: "hsl(var(--warning))", + foreground: "hsl(var(--warning-foreground))", + }, + }, + borderRadius: { + lg: "var(--radius)", + md: "calc(var(--radius) - 2px)", + sm: "calc(var(--radius) - 4px)", + }, + keyframes: { + "accordion-down": { + from: { height: "0" }, + to: { height: "var(--radix-accordion-content-height)" }, + }, + "accordion-up": { + from: { height: "var(--radix-accordion-content-height)" }, + to: { height: "0" }, + }, + }, + animation: { + "accordion-down": "accordion-down 0.2s ease-out", + "accordion-up": "accordion-up 0.2s ease-out", + }, + }, + }, + plugins: [require("tailwindcss-animate")], +} \ No newline at end of file diff --git a/apps/wcag-ada/dashboard/tsconfig.json b/apps/wcag-ada/dashboard/tsconfig.json new file mode 100644 index 0000000..c77ee80 --- /dev/null +++ b/apps/wcag-ada/dashboard/tsconfig.json @@ -0,0 +1,34 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + + /* Path aliases */ + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + } + }, + "include": ["src"], + "references": [ + { "path": "../shared" }, + { "path": "../config" } + ] +} \ No newline at end of file diff --git a/apps/wcag-ada/dashboard/vite.config.ts b/apps/wcag-ada/dashboard/vite.config.ts new file mode 100644 index 0000000..a07ca7a --- /dev/null +++ b/apps/wcag-ada/dashboard/vite.config.ts @@ -0,0 +1,22 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import path from 'path'; + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [react()], + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + }, + }, + server: { + port: 3000, + proxy: { + '/api': { + target: 'http://localhost:3001', + changeOrigin: true, + }, + }, + }, +}); \ No newline at end of file diff --git a/apps/wcag-ada/docker-compose.dev.yml b/apps/wcag-ada/docker-compose.dev.yml new file mode 100644 index 0000000..955262a --- /dev/null +++ b/apps/wcag-ada/docker-compose.dev.yml @@ -0,0 +1,47 @@ +version: '3.8' + +services: + # PostgreSQL Database (for local development) + postgres: + image: postgres:16-alpine + container_name: wcag-ada-postgres-dev + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: wcag_ada_dev + volumes: + - postgres_dev_data:/var/lib/postgresql/data + ports: + - "5432:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres -d wcag_ada_dev"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - wcag-ada-dev-network + + # Redis (for local development) + redis: + image: redis:7-alpine + container_name: wcag-ada-redis-dev + command: redis-server --appendonly yes + volumes: + - redis_dev_data:/data + ports: + - "6379:6379" + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - wcag-ada-dev-network + +networks: + wcag-ada-dev-network: + driver: bridge + +volumes: + postgres_dev_data: + redis_dev_data: \ No newline at end of file diff --git a/apps/wcag-ada/docker-compose.test.yml b/apps/wcag-ada/docker-compose.test.yml new file mode 100644 index 0000000..23dafc8 --- /dev/null +++ b/apps/wcag-ada/docker-compose.test.yml @@ -0,0 +1,28 @@ +version: '3.8' + +services: + # Test databases for CI/CD + postgres-test: + image: postgres:16-alpine + environment: + POSTGRES_USER: test_user + POSTGRES_PASSWORD: test_password + POSTGRES_DB: wcag_ada_test + ports: + - "5433:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U test_user -d wcag_ada_test"] + interval: 5s + timeout: 5s + retries: 5 + + redis-test: + image: redis:7-alpine + command: redis-server --appendonly yes + ports: + - "6380:6379" + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 5s + retries: 5 \ No newline at end of file diff --git a/apps/wcag-ada/docker-compose.yml b/apps/wcag-ada/docker-compose.yml new file mode 100644 index 0000000..ff75a86 --- /dev/null +++ b/apps/wcag-ada/docker-compose.yml @@ -0,0 +1,153 @@ +version: '3.8' + +services: + # PostgreSQL Database + postgres: + image: postgres:16-alpine + container_name: wcag-ada-postgres + environment: + POSTGRES_USER: wcag_user + POSTGRES_PASSWORD: wcag_password + POSTGRES_DB: wcag_ada + volumes: + - postgres_data:/var/lib/postgresql/data + ports: + - "5432:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U wcag_user -d wcag_ada"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - wcag-ada-network + + # Redis + redis: + image: redis:7-alpine + container_name: wcag-ada-redis + command: redis-server --appendonly yes + volumes: + - redis_data:/data + ports: + - "6379:6379" + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - wcag-ada-network + + # API Service + api: + build: + context: ../.. + dockerfile: apps/wcag-ada/api/Dockerfile + container_name: wcag-ada-api + environment: + NODE_ENV: production + DATABASE_URL: postgresql://wcag_user:wcag_password@postgres:5432/wcag_ada + REDIS_HOST: redis + REDIS_PORT: 6379 + API_PORT: 3001 + API_JWT_SECRET: ${API_JWT_SECRET:-change-this-in-production} + API_CORS_ORIGIN: ${API_CORS_ORIGIN:-http://localhost:8080} + ports: + - "3001:3001" + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + healthcheck: + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3001/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + networks: + - wcag-ada-network + restart: unless-stopped + + # Worker Service + worker: + build: + context: ../.. + dockerfile: apps/wcag-ada/worker/Dockerfile + container_name: wcag-ada-worker + environment: + NODE_ENV: production + DATABASE_URL: postgresql://wcag_user:wcag_password@postgres:5432/wcag_ada + REDIS_HOST: redis + REDIS_PORT: 6379 + WORKER_PORT: 3002 + WORKER_CONCURRENCY: ${WORKER_CONCURRENCY:-5} + SCANNER_HEADLESS: "true" + ports: + - "3002:3002" + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + api: + condition: service_healthy + healthcheck: + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3002/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + networks: + - wcag-ada-network + restart: unless-stopped + # Additional security options for Chromium + security_opt: + - seccomp:unconfined + cap_add: + - SYS_ADMIN + + # Dashboard + dashboard: + build: + context: ../.. + dockerfile: apps/wcag-ada/dashboard/Dockerfile + container_name: wcag-ada-dashboard + ports: + - "8080:8080" + depends_on: + api: + condition: service_healthy + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8080/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + networks: + - wcag-ada-network + restart: unless-stopped + + # Database migrations (one-time job) + migrate: + build: + context: ../.. + dockerfile: apps/wcag-ada/api/Dockerfile + container_name: wcag-ada-migrate + command: ["bunx", "prisma", "migrate", "deploy"] + environment: + DATABASE_URL: postgresql://wcag_user:wcag_password@postgres:5432/wcag_ada + depends_on: + postgres: + condition: service_healthy + networks: + - wcag-ada-network + restart: "no" + +networks: + wcag-ada-network: + driver: bridge + +volumes: + postgres_data: + redis_data: \ No newline at end of file diff --git a/apps/wcag-ada/k8s/api.yaml b/apps/wcag-ada/k8s/api.yaml new file mode 100644 index 0000000..1f8354a --- /dev/null +++ b/apps/wcag-ada/k8s/api.yaml @@ -0,0 +1,69 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: wcag-ada-api + namespace: wcag-ada +spec: + replicas: 3 + selector: + matchLabels: + app: wcag-ada-api + template: + metadata: + labels: + app: wcag-ada-api + spec: + initContainers: + - name: wait-for-postgres + image: busybox:1.36 + command: ['sh', '-c', 'until nc -z postgres-service 5432; do echo waiting for postgres; sleep 2; done'] + - name: run-migrations + image: wcag-ada/api:latest + command: ["bunx", "prisma", "migrate", "deploy"] + envFrom: + - configMapRef: + name: wcag-ada-config + - secretRef: + name: wcag-ada-secrets + containers: + - name: api + image: wcag-ada/api:latest + ports: + - containerPort: 3001 + envFrom: + - configMapRef: + name: wcag-ada-config + - secretRef: + name: wcag-ada-secrets + livenessProbe: + httpGet: + path: /health + port: 3001 + initialDelaySeconds: 30 + periodSeconds: 10 + readinessProbe: + httpGet: + path: /health + port: 3001 + initialDelaySeconds: 5 + periodSeconds: 5 + resources: + requests: + memory: "256Mi" + cpu: "100m" + limits: + memory: "512Mi" + cpu: "500m" +--- +apiVersion: v1 +kind: Service +metadata: + name: wcag-ada-api-service + namespace: wcag-ada +spec: + selector: + app: wcag-ada-api + ports: + - port: 3001 + targetPort: 3001 + type: ClusterIP \ No newline at end of file diff --git a/apps/wcag-ada/k8s/configmap.yaml b/apps/wcag-ada/k8s/configmap.yaml new file mode 100644 index 0000000..9848851 --- /dev/null +++ b/apps/wcag-ada/k8s/configmap.yaml @@ -0,0 +1,47 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: wcag-ada-config + namespace: wcag-ada +data: + NODE_ENV: "production" + APP_NAME: "wcag-ada" + + # API Configuration + API_PORT: "3001" + API_CORS_ORIGIN: "https://wcag-ada.example.com" + API_JWT_EXPIRES_IN: "7d" + API_RATE_LIMIT: "100" + API_RATE_WINDOW: "60000" + + # Worker Configuration + WORKER_PORT: "3002" + WORKER_CONCURRENCY: "5" + WORKER_QUEUE_NAME: "accessibility-scans" + + # Redis Configuration + REDIS_HOST: "redis-service" + REDIS_PORT: "6379" + REDIS_DB: "0" + + # Scanner Configuration + SCANNER_HEADLESS: "true" + SCANNER_TIMEOUT: "30000" + SCANNER_VIEWPORT_WIDTH: "1920" + SCANNER_VIEWPORT_HEIGHT: "1080" + SCANNER_BLOCK_RESOURCES: "true" + SCANNER_BLOCK_PATTERNS: "font,image" + + # Features + FEATURES_SCREENSHOTS: "true" + FEATURES_FIX_SUGGESTIONS: "true" + FEATURES_CUSTOM_RULES: "true" + FEATURES_SCHEDULED_SCANS: "true" + FEATURES_BULK_SCANNING: "false" + FEATURES_API_ACCESS: "true" + + # Scheduler + SCHEDULER_TIMEZONE: "America/New_York" + SCHEDULER_MAX_CONCURRENT_SCANS: "3" + SCHEDULER_RETRY_ATTEMPTS: "3" + SCHEDULER_RETRY_DELAY: "5000" \ No newline at end of file diff --git a/apps/wcag-ada/k8s/dashboard.yaml b/apps/wcag-ada/k8s/dashboard.yaml new file mode 100644 index 0000000..64ddc6a --- /dev/null +++ b/apps/wcag-ada/k8s/dashboard.yaml @@ -0,0 +1,52 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: wcag-ada-dashboard + namespace: wcag-ada +spec: + replicas: 2 + selector: + matchLabels: + app: wcag-ada-dashboard + template: + metadata: + labels: + app: wcag-ada-dashboard + spec: + containers: + - name: dashboard + image: wcag-ada/dashboard:latest + ports: + - containerPort: 8080 + livenessProbe: + httpGet: + path: /health + port: 8080 + initialDelaySeconds: 10 + periodSeconds: 10 + readinessProbe: + httpGet: + path: /health + port: 8080 + initialDelaySeconds: 5 + periodSeconds: 5 + resources: + requests: + memory: "64Mi" + cpu: "50m" + limits: + memory: "128Mi" + cpu: "100m" +--- +apiVersion: v1 +kind: Service +metadata: + name: wcag-ada-dashboard-service + namespace: wcag-ada +spec: + selector: + app: wcag-ada-dashboard + ports: + - port: 80 + targetPort: 8080 + type: ClusterIP \ No newline at end of file diff --git a/apps/wcag-ada/k8s/ingress.yaml b/apps/wcag-ada/k8s/ingress.yaml new file mode 100644 index 0000000..3ac96f7 --- /dev/null +++ b/apps/wcag-ada/k8s/ingress.yaml @@ -0,0 +1,38 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: wcag-ada-ingress + namespace: wcag-ada + annotations: + nginx.ingress.kubernetes.io/rewrite-target: / + cert-manager.io/cluster-issuer: "letsencrypt-prod" + nginx.ingress.kubernetes.io/ssl-redirect: "true" + nginx.ingress.kubernetes.io/proxy-body-size: "10m" +spec: + ingressClassName: nginx + tls: + - hosts: + - wcag-ada.example.com + - api.wcag-ada.example.com + secretName: wcag-ada-tls + rules: + - host: wcag-ada.example.com + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: wcag-ada-dashboard-service + port: + number: 80 + - host: api.wcag-ada.example.com + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: wcag-ada-api-service + port: + number: 3001 \ No newline at end of file diff --git a/apps/wcag-ada/k8s/namespace.yaml b/apps/wcag-ada/k8s/namespace.yaml new file mode 100644 index 0000000..7bc3ab7 --- /dev/null +++ b/apps/wcag-ada/k8s/namespace.yaml @@ -0,0 +1,6 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: wcag-ada + labels: + name: wcag-ada \ No newline at end of file diff --git a/apps/wcag-ada/k8s/postgres.yaml b/apps/wcag-ada/k8s/postgres.yaml new file mode 100644 index 0000000..3fc2b15 --- /dev/null +++ b/apps/wcag-ada/k8s/postgres.yaml @@ -0,0 +1,84 @@ +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: postgres-pvc + namespace: wcag-ada +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 10Gi +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: postgres + namespace: wcag-ada +spec: + replicas: 1 + selector: + matchLabels: + app: postgres + template: + metadata: + labels: + app: postgres + spec: + containers: + - name: postgres + image: postgres:16-alpine + env: + - name: POSTGRES_USER + value: wcag_user + - name: POSTGRES_PASSWORD + valueFrom: + secretKeyRef: + name: wcag-ada-secrets + key: DATABASE_URL + - name: POSTGRES_DB + value: wcag_ada + - name: PGDATA + value: /var/lib/postgresql/data/pgdata + ports: + - containerPort: 5432 + volumeMounts: + - name: postgres-storage + mountPath: /var/lib/postgresql/data + livenessProbe: + exec: + command: + - pg_isready + - -U + - wcag_user + - -d + - wcag_ada + initialDelaySeconds: 30 + periodSeconds: 10 + readinessProbe: + exec: + command: + - pg_isready + - -U + - wcag_user + - -d + - wcag_ada + initialDelaySeconds: 5 + periodSeconds: 5 + volumes: + - name: postgres-storage + persistentVolumeClaim: + claimName: postgres-pvc +--- +apiVersion: v1 +kind: Service +metadata: + name: postgres-service + namespace: wcag-ada +spec: + selector: + app: postgres + ports: + - port: 5432 + targetPort: 5432 + type: ClusterIP \ No newline at end of file diff --git a/apps/wcag-ada/k8s/redis.yaml b/apps/wcag-ada/k8s/redis.yaml new file mode 100644 index 0000000..84b44ae --- /dev/null +++ b/apps/wcag-ada/k8s/redis.yaml @@ -0,0 +1,77 @@ +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: redis-pvc + namespace: wcag-ada +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 1Gi +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: redis + namespace: wcag-ada +spec: + replicas: 1 + selector: + matchLabels: + app: redis + template: + metadata: + labels: + app: redis + spec: + containers: + - name: redis + image: redis:7-alpine + command: + - redis-server + - --appendonly + - "yes" + ports: + - containerPort: 6379 + volumeMounts: + - name: redis-storage + mountPath: /data + livenessProbe: + exec: + command: + - redis-cli + - ping + initialDelaySeconds: 30 + periodSeconds: 10 + readinessProbe: + exec: + command: + - redis-cli + - ping + initialDelaySeconds: 5 + periodSeconds: 5 + resources: + requests: + memory: "256Mi" + cpu: "100m" + limits: + memory: "512Mi" + cpu: "200m" + volumes: + - name: redis-storage + persistentVolumeClaim: + claimName: redis-pvc +--- +apiVersion: v1 +kind: Service +metadata: + name: redis-service + namespace: wcag-ada +spec: + selector: + app: redis + ports: + - port: 6379 + targetPort: 6379 + type: ClusterIP \ No newline at end of file diff --git a/apps/wcag-ada/k8s/secrets.yaml b/apps/wcag-ada/k8s/secrets.yaml new file mode 100644 index 0000000..3b9ae25 --- /dev/null +++ b/apps/wcag-ada/k8s/secrets.yaml @@ -0,0 +1,26 @@ +apiVersion: v1 +kind: Secret +metadata: + name: wcag-ada-secrets + namespace: wcag-ada +type: Opaque +stringData: + # Database + DATABASE_URL: "postgresql://wcag_user:wcag_password@postgres-service:5432/wcag_ada" + + # Redis + REDIS_PASSWORD: "" + + # API + API_JWT_SECRET: "your-super-secret-jwt-key-change-in-production" + + # Email (optional) + SMTP_HOST: "" + SMTP_PORT: "587" + SMTP_USER: "" + SMTP_PASS: "" + SMTP_FROM: "noreply@wcag-ada.com" + + # Slack (optional) + SLACK_WEBHOOK_URL: "" + SLACK_CHANNEL: "#wcag-alerts" \ No newline at end of file diff --git a/apps/wcag-ada/k8s/worker.yaml b/apps/wcag-ada/k8s/worker.yaml new file mode 100644 index 0000000..8c7420f --- /dev/null +++ b/apps/wcag-ada/k8s/worker.yaml @@ -0,0 +1,67 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: wcag-ada-worker + namespace: wcag-ada +spec: + replicas: 2 + selector: + matchLabels: + app: wcag-ada-worker + template: + metadata: + labels: + app: wcag-ada-worker + spec: + initContainers: + - name: wait-for-services + image: busybox:1.36 + command: ['sh', '-c', 'until nc -z postgres-service 5432 && nc -z redis-service 6379 && nc -z wcag-ada-api-service 3001; do echo waiting for services; sleep 2; done'] + containers: + - name: worker + image: wcag-ada/worker:latest + ports: + - containerPort: 3002 + envFrom: + - configMapRef: + name: wcag-ada-config + - secretRef: + name: wcag-ada-secrets + livenessProbe: + httpGet: + path: /health + port: 3002 + initialDelaySeconds: 40 + periodSeconds: 10 + readinessProbe: + httpGet: + path: /health + port: 3002 + initialDelaySeconds: 10 + periodSeconds: 5 + resources: + requests: + memory: "512Mi" + cpu: "250m" + limits: + memory: "1Gi" + cpu: "1000m" + securityContext: + capabilities: + add: + - SYS_ADMIN + runAsUser: 1001 + runAsGroup: 1001 +--- +apiVersion: v1 +kind: Service +metadata: + name: wcag-ada-worker-service + namespace: wcag-ada +spec: + selector: + app: wcag-ada-worker + ports: + - port: 3002 + targetPort: 3002 + type: ClusterIP \ No newline at end of file diff --git a/apps/wcag-ada/package.json b/apps/wcag-ada/package.json new file mode 100644 index 0000000..f024ed1 --- /dev/null +++ b/apps/wcag-ada/package.json @@ -0,0 +1,32 @@ +{ + "name": "@wcag-ada/root", + "version": "1.0.0", + "private": true, + "scripts": { + "dev": "turbo run dev --parallel", + "build": "turbo run build", + "test": "turbo run test", + "lint": "turbo run lint", + "typecheck": "turbo run typecheck", + "clean": "turbo run clean", + "dev:api": "cd api && bun run dev", + "dev:worker": "cd worker && bun run dev", + "dev:dashboard": "cd dashboard && bun run dev", + "dev:scanner": "cd scanner && bun run example", + "migrate": "cd api && bunx prisma migrate dev", + "db:push": "cd api && bunx prisma db push", + "db:studio": "cd api && bunx prisma studio", + "db:seed": "cd api && bun run seed" + }, + "devDependencies": { + "turbo": "latest" + }, + "workspaces": [ + "api", + "config", + "dashboard", + "scanner", + "shared", + "worker" + ] +} \ No newline at end of file diff --git a/apps/wcag-ada/scanner/README.md b/apps/wcag-ada/scanner/README.md new file mode 100644 index 0000000..bc7c85e --- /dev/null +++ b/apps/wcag-ada/scanner/README.md @@ -0,0 +1,162 @@ +# WCAG-ADA Accessibility Scanner + +A high-performance accessibility scanner built with Playwright and axe-core for WCAG compliance testing. + +## Features + +- 🚀 Fast scanning with Playwright +- ♿ WCAG 2.0, 2.1, and 2.2 compliance testing +- 📊 Detailed violation reports with fix suggestions +- 📸 Screenshot capture for violations +- 🔐 Authentication support (basic, form-based, custom) +- 📱 Mobile and desktop viewport testing +- 🎯 Custom rule support +- 🚫 Resource blocking for faster scans + +## Installation + +```bash +bun install +``` + +## Usage + +### Basic Example + +```typescript +import { AccessibilityScanner } from '@wcag-ada/scanner'; + +const scanner = new AccessibilityScanner(); +await scanner.initialize(); + +const result = await scanner.scan({ + url: 'https://example.com', + wcagLevel: { + level: 'AA', + version: '2.1' + } +}); + +console.log(`Compliance Score: ${result.summary.score}%`); +console.log(`Violations Found: ${result.summary.violationCount}`); + +await scanner.close(); +``` + +### Advanced Options + +```typescript +const result = await scanner.scan({ + url: 'https://example.com', + wcagLevel: { + level: 'AA', + version: '2.1' + }, + includeScreenshots: true, + viewport: { + width: 1920, + height: 1080, + isMobile: false + }, + authenticate: { + type: 'form', + loginUrl: 'https://example.com/login', + credentials: { + username: 'user@example.com', + password: 'password' + }, + selectors: { + username: '#email', + password: '#password', + submit: 'button[type="submit"]' + } + }, + excludeSelectors: [ + '.cookie-banner', + '[aria-hidden="true"]' + ], + waitForSelector: '#main-content', + timeout: 60000 +}); +``` + +### Scan Multiple Pages + +```typescript +const urls = [ + 'https://example.com', + 'https://example.com/about', + 'https://example.com/contact' +]; + +const results = await scanner.scanMultiplePages(urls, { + wcagLevel: { level: 'AA', version: '2.1' } +}); +``` + +## Scan Results + +The scanner returns a comprehensive `AccessibilityScanResult` object containing: + +- **Summary**: Overall compliance score, violation counts by severity +- **Violations**: Detailed list of accessibility issues with: + - Impact level (critical, serious, moderate, minor) + - Affected elements with HTML snippets + - Fix suggestions + - WCAG criteria mapping + - Screenshots (if enabled) +- **Passes**: Rules that passed +- **Incomplete**: Rules that need manual review +- **Page Metadata**: Title, language, element counts, landmark presence +- **WCAG Compliance**: Level-specific compliance status + +## Performance Optimization + +The scanner is optimized for speed: +- Blocks unnecessary resources (images, fonts, analytics) +- Parallel page scanning support +- Configurable timeouts +- Headless browser mode + +## Custom Rules + +Add custom accessibility rules: + +```typescript +const customRules = [{ + id: 'custom-button-size', + selector: 'button', + tags: ['custom', 'wcag21aa'], + description: 'Buttons must have minimum touch target size', + help: 'Ensure buttons are at least 44x44 pixels', + severity: 'serious', + validator: (element) => { + const rect = element.getBoundingClientRect(); + return rect.width >= 44 && rect.height >= 44; + } +}]; + +const result = await scanner.scan({ + url: 'https://example.com', + customRules +}); +``` + +## Running the Example + +```bash +bun run src/example.ts +``` + +## Development + +```bash +# Build +bun run build + +# Watch mode +bun run dev + +# Type checking +bun run typecheck +``` \ No newline at end of file diff --git a/apps/wcag-ada/scanner/package.json b/apps/wcag-ada/scanner/package.json new file mode 100644 index 0000000..4e85265 --- /dev/null +++ b/apps/wcag-ada/scanner/package.json @@ -0,0 +1,29 @@ +{ + "name": "@wcag-ada/scanner", + "version": "0.1.0", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "scripts": { + "build": "tsc -b", + "dev": "tsc -w", + "test": "playwright test", + "lint": "eslint src", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@axe-core/playwright": "^4.8.3", + "@playwright/test": "^1.40.1", + "axe-core": "^4.8.3", + "playwright": "^1.40.1", + "pdfkit": "^0.14.0", + "handlebars": "^4.7.8", + "p-queue": "^7.4.1", + "@wcag-ada/shared": "workspace:*", + "@wcag-ada/config": "workspace:*" + }, + "devDependencies": { + "@types/node": "^20.11.5", + "@types/pdfkit": "^0.13.4", + "typescript": "^5.3.3" + } +} \ No newline at end of file diff --git a/apps/wcag-ada/scanner/src/core/accessibility-browser.ts b/apps/wcag-ada/scanner/src/core/accessibility-browser.ts new file mode 100644 index 0000000..45edc84 --- /dev/null +++ b/apps/wcag-ada/scanner/src/core/accessibility-browser.ts @@ -0,0 +1,285 @@ +import { chromium, Browser, BrowserContext, Page } from 'playwright'; +import AxeBuilder from '@axe-core/playwright'; +import type { AxeResults } from 'axe-core'; +import type { + AccessibilityScanOptions, + ViewportSize, + AuthenticationConfig +} from '@wcag-ada/shared'; +import { getWCAGTags, RESOURCE_BLOCK_PATTERNS } from '@wcag-ada/shared'; +import { getScannerConfig, type ScannerConfig } from '@wcag-ada/config'; + +export interface AccessibilityBrowserOptions { + headless?: boolean; + scanTimeout?: number; + defaultViewport?: ViewportSize; + blockResources?: boolean; +} + +export class AccessibilityBrowser { + private browser?: Browser; + private context?: BrowserContext; + private logger: any; + private config: ScannerConfig; + private options: AccessibilityBrowserOptions; + private initialized = false; + + constructor(logger?: any, options?: AccessibilityBrowserOptions) { + this.logger = logger || console; + this.config = getScannerConfig(); + + // Merge config with provided options + this.options = { + headless: options?.headless ?? this.config.headless, + scanTimeout: options?.scanTimeout ?? this.config.timeout, + defaultViewport: options?.defaultViewport ?? this.config.viewport, + blockResources: options?.blockResources ?? this.config.blockResources, + }; + } + + async initialize(): Promise { + if (this.initialized) return; + + this.browser = await chromium.launch({ + headless: this.options.headless, + args: this.config.browsers.chromium.args, + }); + + this.context = await this.browser.newContext({ + viewport: this.options.defaultViewport, + }); + + // Block resources for faster scanning if enabled + if (this.options.blockResources) { + await this.context.route('**/*', (route) => { + const url = route.request().url(); + const shouldBlock = RESOURCE_BLOCK_PATTERNS.some(pattern => { + if (pattern.includes('*')) { + const regex = new RegExp(pattern.replace('*', '.*')); + return regex.test(url); + } + return url.includes(pattern); + }); + + if (shouldBlock) { + route.abort(); + } else { + route.continue(); + } + }); + } + + this.initialized = true; + } + + async close(): Promise { + if (this.context) await this.context.close(); + if (this.browser) await this.browser.close(); + this.initialized = false; + } + + async scanPage(options: AccessibilityScanOptions): Promise<{ + page: Page; + axeResults: AxeResults; + html: string; + }> { + if (!this.context) throw new Error('Browser not initialized'); + + const { url, viewport, authenticate, wcagLevel, excludeSelectors, waitForSelector } = options; + + const page = await this.context.newPage(); + + try { + // Set viewport if different from default + if (viewport) { + await page.setViewportSize({ + width: viewport.width, + height: viewport.height, + }); + } + + // Handle authentication if needed + if (authenticate) { + await this.handleAuthentication(page, authenticate); + } + + // Navigate to URL + await page.goto(url, { + waitUntil: 'networkidle', + timeout: options.timeout || this.config.pageLoadTimeout, + }); + + // Wait for specific selector if provided + if (waitForSelector) { + await page.waitForSelector(waitForSelector, { + timeout: 10000, + }); + } + + // Additional wait for dynamic content + await page.waitForTimeout(2000); + + // Get page HTML + const html = await page.content(); + + // Configure axe + const axeBuilder = new AxeBuilder({ page }); + + // Set WCAG tags + if (wcagLevel) { + const tags = getWCAGTags(wcagLevel); + axeBuilder.withTags(tags); + } + + // Exclude selectors if provided + if (excludeSelectors && excludeSelectors.length > 0) { + excludeSelectors.forEach(selector => { + axeBuilder.exclude(selector); + }); + } + + // Set options + axeBuilder.options({ + resultTypes: this.config.axe.resultTypes, + elementRef: true, + runOnly: { + type: 'tag', + values: wcagLevel ? getWCAGTags(wcagLevel) : this.config.axe.tags, + }, + }); + + // Run accessibility scan + const axeResults = await Promise.race([ + axeBuilder.analyze(), + new Promise((_, reject) => + setTimeout(() => reject(new Error('Scan timeout')), this.options.scanTimeout!) + ), + ]); + + return { + page, + axeResults, + html, + }; + } catch (error) { + await page.close(); + throw error; + } + } + + private async handleAuthentication(page: Page, auth: AuthenticationConfig): Promise { + switch (auth.type) { + case 'basic': + if (auth.credentials?.username && auth.credentials?.password) { + await page.context().setHTTPCredentials({ + username: auth.credentials.username, + password: auth.credentials.password, + }); + } + break; + + case 'form': + if (auth.loginUrl) { + await page.goto(auth.loginUrl); + + if (auth.selectors?.username && auth.credentials?.username) { + await page.fill(auth.selectors.username, auth.credentials.username); + } + + if (auth.selectors?.password && auth.credentials?.password) { + await page.fill(auth.selectors.password, auth.credentials.password); + } + + if (auth.selectors?.submit) { + await page.click(auth.selectors.submit); + await page.waitForNavigation({ waitUntil: 'networkidle' }); + } + } + break; + + case 'custom': + if (auth.customScript) { + await page.evaluate(auth.customScript); + } + break; + } + } + + async captureViolationScreenshots( + page: Page, + violations: AxeResults['violations'] + ): Promise> { + const screenshots = new Map(); + + for (const violation of violations) { + for (let i = 0; i < violation.nodes.length; i++) { + const node = violation.nodes[i]; + if (node.target && node.target.length > 0) { + try { + const selector = node.target[0]; + const element = await page.$(selector); + + if (element) { + const screenshot = await element.screenshot({ + type: 'png', + }); + + const key = `${violation.id}_${i}`; + screenshots.set(key, screenshot.toString('base64')); + } + } catch (error) { + this.logger?.warn(`Failed to capture screenshot for ${violation.id}:`, error); + } + } + } + } + + return screenshots; + } + + async evaluateCustomRules(page: Page, customRules: any[]): Promise { + const results = []; + + for (const rule of customRules) { + try { + const elements = await page.$$(rule.selector); + const violations = []; + + for (const element of elements) { + const passes = await page.evaluate( + (el, validatorStr) => { + const validator = new Function('element', `return (${validatorStr})(element)`); + return validator(el); + }, + element, + rule.validator.toString() + ); + + if (!passes) { + const html = await element.evaluate(el => el.outerHTML); + violations.push({ + html, + target: [rule.selector], + }); + } + } + + if (violations.length > 0) { + results.push({ + id: rule.id, + description: rule.description, + help: rule.help, + helpUrl: rule.helpUrl, + impact: rule.severity, + tags: rule.tags, + nodes: violations, + }); + } + } catch (error) { + this.logger?.error(`Failed to evaluate custom rule ${rule.id}:`, error); + } + } + + return results; + } +} \ No newline at end of file diff --git a/apps/wcag-ada/scanner/src/core/scanner.ts b/apps/wcag-ada/scanner/src/core/scanner.ts new file mode 100644 index 0000000..ed50d7e --- /dev/null +++ b/apps/wcag-ada/scanner/src/core/scanner.ts @@ -0,0 +1,303 @@ +import { AccessibilityBrowser } from './accessibility-browser'; +import type { + AccessibilityScanOptions, + AccessibilityScanResult, + AccessibilityViolation, + ScanSummary, + PageMetadata, + WCAGCompliance, + WCAGLevel, + CriteriaResult +} from '@wcag-ada/shared'; +import { + calculateComplianceScore, + summarizeViolations, + generateFixSuggestion, + extractPageMetadata, + isCompliantWithLevel, + CRITICAL_WCAG_CRITERIA +} from '@wcag-ada/shared'; +import type { AxeResults, Result as AxeResult } from 'axe-core'; +import { initializeWcagConfig } from '@wcag-ada/config'; + +export class AccessibilityScanner { + private browser: AccessibilityBrowser; + private logger: any; + + constructor(logger?: any) { + this.logger = logger || console; + + // Initialize config for scanner service + initializeWcagConfig(); + + this.browser = new AccessibilityBrowser(logger); + } + + async initialize(): Promise { + await this.browser.initialize(); + } + + async close(): Promise { + await this.browser.close(); + } + + async scan(options: AccessibilityScanOptions): Promise { + const startTime = Date.now(); + + try { + // Perform the scan + const { page, axeResults, html } = await this.browser.scanPage(options); + + // Capture screenshots if requested + let screenshotMap: Map | undefined; + if (options.includeScreenshots) { + screenshotMap = await this.browser.captureViolationScreenshots(page, axeResults.violations); + } + + // Evaluate custom rules if provided + let customViolations: AxeResult[] = []; + if (options.customRules && options.customRules.length > 0) { + customViolations = await this.browser.evaluateCustomRules(page, options.customRules); + } + + // Close the page + await page.close(); + + // Process results + const violations = this.processViolations( + [...axeResults.violations, ...customViolations], + screenshotMap + ); + + // Extract metadata + const pageMetadata = this.extractPageMetadata(html, options); + + // Calculate summary + const summary = this.calculateSummary(axeResults, violations); + + // Determine WCAG compliance + const wcagCompliance = this.calculateWCAGCompliance( + violations, + axeResults.passes, + options.wcagLevel || { level: 'AA', version: '2.1' }, + summary.score + ); + + const scanDuration = Date.now() - startTime; + + return { + url: options.url, + timestamp: new Date(), + scanDuration, + summary, + violations, + passes: axeResults.passes, + incomplete: axeResults.incomplete, + inapplicable: axeResults.inapplicable, + pageMetadata, + wcagCompliance, + }; + } catch (error) { + this.logger.error('Scan failed:', error); + throw error; + } + } + + private processViolations( + axeViolations: AxeResult[], + screenshots?: Map + ): AccessibilityViolation[] { + return axeViolations.map(violation => { + const nodes = violation.nodes.map((node, index) => { + const screenshotKey = `${violation.id}_${index}`; + return { + html: node.html, + target: node.target as string[], + xpath: node.xpath?.[0], + failureSummary: node.failureSummary, + screenshot: screenshots?.get(screenshotKey), + relatedNodes: node.relatedNodes?.map(related => ({ + html: related.html, + target: related.target as string[], + })), + }; + }); + + return { + id: violation.id, + impact: violation.impact!, + description: violation.description, + help: violation.help, + helpUrl: violation.helpUrl, + tags: violation.tags, + nodes, + wcagCriteria: this.mapToWCAGCriteria(violation.tags), + fixSuggestion: generateFixSuggestion(violation.id), + }; + }); + } + + private extractPageMetadata(html: string, options: AccessibilityScanOptions): PageMetadata { + const metadata = extractPageMetadata(html); + + return { + title: metadata.title || 'Untitled Page', + url: options.url, + viewport: options.viewport || { width: 1280, height: 720 }, + language: metadata.language, + doctype: this.extractDoctype(html), + hasLandmarks: metadata.hasLandmarks || false, + hasHeadings: metadata.hasHeadings || false, + imagesCount: metadata.imagesCount || 0, + formsCount: metadata.formsCount || 0, + linksCount: metadata.linksCount || 0, + }; + } + + private extractDoctype(html: string): string { + const doctypeMatch = html.match(/]+)>/i); + return doctypeMatch ? doctypeMatch[1].trim() : 'unknown'; + } + + private calculateSummary( + axeResults: AxeResults, + violations: AccessibilityViolation[] + ): ScanSummary { + const violationSummary = summarizeViolations(violations); + const violationCount = violations.reduce((sum, v) => sum + v.nodes.length, 0); + + const result: AccessibilityScanResult = { + violations, + passes: axeResults.passes, + summary: {} as ScanSummary, + } as AccessibilityScanResult; + + const score = calculateComplianceScore(result); + + return { + violationCount, + passCount: axeResults.passes.length, + incompleteCount: axeResults.incomplete.length, + inapplicableCount: axeResults.inapplicable.length, + ...violationSummary, + score, + } as ScanSummary; + } + + private calculateWCAGCompliance( + violations: AccessibilityViolation[], + passes: AxeResult[], + wcagLevel: WCAGLevel, + score: number + ): WCAGCompliance { + const criteriaMap = new Map(); + + // Initialize critical criteria + CRITICAL_WCAG_CRITERIA.forEach(criterion => { + criteriaMap.set(criterion, { + criterion, + level: this.getCriterionLevel(criterion), + passed: true, + violations: [], + }); + }); + + // Process violations + violations.forEach(violation => { + violation.wcagCriteria?.forEach(criterion => { + if (!criteriaMap.has(criterion)) { + criteriaMap.set(criterion, { + criterion, + level: this.getCriterionLevel(criterion), + passed: false, + violations: [violation.id], + }); + } else { + const result = criteriaMap.get(criterion)!; + result.passed = false; + result.violations.push(violation.id); + } + }); + }); + + const criteriaResults = Array.from(criteriaMap.values()); + const isCompliant = isCompliantWithLevel(score, wcagLevel.level); + + return { + level: wcagLevel, + isCompliant, + criteriaResults, + overallScore: score, + }; + } + + private mapToWCAGCriteria(tags: string[]): string[] { + const criteriaMap: Record = { + 'wcag111': ['1.1.1'], + 'wcag131': ['1.3.1'], + 'wcag141': ['1.4.1'], + 'wcag143': ['1.4.3'], + 'wcag211': ['2.1.1'], + 'wcag212': ['2.1.2'], + 'wcag241': ['2.4.1'], + 'wcag242': ['2.4.2'], + 'wcag243': ['2.4.3'], + 'wcag244': ['2.4.4'], + 'wcag311': ['3.1.1'], + 'wcag331': ['3.3.1'], + 'wcag332': ['3.3.2'], + 'wcag411': ['4.1.1'], + 'wcag412': ['4.1.2'], + }; + + const criteria = new Set(); + tags.forEach(tag => { + const mapped = criteriaMap[tag]; + if (mapped) { + mapped.forEach(c => criteria.add(c)); + } + }); + + return Array.from(criteria); + } + + private getCriterionLevel(criterion: string): 'A' | 'AA' | 'AAA' { + const levelMap: Record = { + '1.1.1': 'A', + '1.3.1': 'A', + '1.4.1': 'A', + '1.4.3': 'AA', + '2.1.1': 'A', + '2.1.2': 'A', + '2.4.1': 'A', + '2.4.2': 'A', + '2.4.3': 'A', + '2.4.4': 'A', + '3.1.1': 'A', + '3.3.1': 'A', + '3.3.2': 'A', + '4.1.1': 'A', + '4.1.2': 'A', + }; + + return levelMap[criterion] || 'A'; + } + + async scanMultiplePages( + urls: string[], + baseOptions: Omit + ): Promise { + const results: AccessibilityScanResult[] = []; + + for (const url of urls) { + try { + const result = await this.scan({ ...baseOptions, url }); + results.push(result); + } catch (error) { + this.logger.error(`Failed to scan ${url}:`, error); + } + } + + return results; + } +} \ No newline at end of file diff --git a/apps/wcag-ada/scanner/src/example.ts b/apps/wcag-ada/scanner/src/example.ts new file mode 100644 index 0000000..a0d0464 --- /dev/null +++ b/apps/wcag-ada/scanner/src/example.ts @@ -0,0 +1,79 @@ +import { AccessibilityScanner } from './core/scanner'; +import type { AccessibilityScanOptions } from '@wcag-ada/shared'; +import { createLogger } from '@lib/service/core-logger'; + +const logger = createLogger('scanner-example'); + +async function runExample() { + const scanner = new AccessibilityScanner(); + + try { + // Initialize the browser + await scanner.initialize(); + + // Define scan options + const scanOptions: AccessibilityScanOptions = { + url: 'https://example.com', + wcagLevel: { + level: 'AA', + version: '2.1' + }, + includeScreenshots: true, + viewport: { + width: 1920, + height: 1080 + }, + excludeSelectors: [ + '.cookie-banner', + '[aria-hidden="true"]' + ] + }; + + logger.info('Starting accessibility scan...'); + const result = await scanner.scan(scanOptions); + + // Display summary + logger.info('\n=== SCAN SUMMARY ==='); + logger.info(`URL: ${result.url}`); + logger.info(`Compliance Score: ${result.summary.score}%`); + logger.info(`WCAG ${result.wcagCompliance.level.version} Level ${result.wcagCompliance.level.level}: ${result.wcagCompliance.isCompliant ? 'PASSED' : 'FAILED'}`); + logger.info(`\nViolations: ${result.summary.violationCount}`); + logger.info(`- Critical: ${result.summary.criticalIssues}`); + logger.info(`- Serious: ${result.summary.seriousIssues}`); + logger.info(`- Moderate: ${result.summary.moderateIssues}`); + logger.info(`- Minor: ${result.summary.minorIssues}`); + + // Display top violations + if (result.violations.length > 0) { + logger.info('\n=== TOP VIOLATIONS ==='); + result.violations.slice(0, 5).forEach((violation, index) => { + logger.info(`\n${index + 1}. ${violation.help}`); + logger.info(` Impact: ${violation.impact}`); + logger.info(` Affected elements: ${violation.nodes.length}`); + logger.info(` Fix: ${violation.fixSuggestion}`); + logger.info(` More info: ${violation.helpUrl}`); + }); + } + + // Display page metadata + logger.info('\n=== PAGE METADATA ==='); + logger.info(`Title: ${result.pageMetadata.title}`); + logger.info(`Language: ${result.pageMetadata.language || 'Not specified'}`); + logger.info(`Images: ${result.pageMetadata.imagesCount}`); + logger.info(`Forms: ${result.pageMetadata.formsCount}`); + logger.info(`Links: ${result.pageMetadata.linksCount}`); + logger.info(`Has landmarks: ${result.pageMetadata.hasLandmarks ? 'Yes' : 'No'}`); + logger.info(`Has headings: ${result.pageMetadata.hasHeadings ? 'Yes' : 'No'}`); + + } catch (error) { + logger.error('Scan failed:', error); + } finally { + // Always close the browser + await scanner.close(); + } +} + +// Run the example +if (require.main === module) { + runExample().catch((error) => logger.error('Example failed:', error)); +} \ No newline at end of file diff --git a/apps/wcag-ada/scanner/src/index.ts b/apps/wcag-ada/scanner/src/index.ts new file mode 100644 index 0000000..2aed2e6 --- /dev/null +++ b/apps/wcag-ada/scanner/src/index.ts @@ -0,0 +1,14 @@ +export { AccessibilityScanner } from './core/scanner'; +export { AccessibilityBrowser } from './core/accessibility-browser'; +export type { AccessibilityBrowserOptions } from './core/accessibility-browser'; + +// Re-export shared types for convenience +export type { + AccessibilityScanOptions, + AccessibilityScanResult, + AccessibilityViolation, + WCAGLevel, + Website, + ScanJob, + ComplianceReport, +} from '@wcag-ada/shared'; \ No newline at end of file diff --git a/apps/wcag-ada/scanner/tsconfig.json b/apps/wcag-ada/scanner/tsconfig.json new file mode 100644 index 0000000..3eb1c72 --- /dev/null +++ b/apps/wcag-ada/scanner/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "composite": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "tests"], + "references": [ + { "path": "../shared" } + ] +} \ No newline at end of file diff --git a/apps/wcag-ada/scripts/build-images.sh b/apps/wcag-ada/scripts/build-images.sh new file mode 100755 index 0000000..1a08b91 --- /dev/null +++ b/apps/wcag-ada/scripts/build-images.sh @@ -0,0 +1,41 @@ +#!/bin/bash +set -e + +# Script to build all Docker images locally + +echo "Building WCAG-ADA Docker images..." + +# Get the repository root (two levels up from scripts directory) +REPO_ROOT=$(cd "$(dirname "$0")/../../.." && pwd) +WCAG_ROOT=$(cd "$(dirname "$0")/.." && pwd) + +# Set default registry if not provided +REGISTRY=${DOCKER_REGISTRY:-"wcag-ada"} + +# Build API image +echo "Building API image..." +docker build \ + -f "$WCAG_ROOT/api/Dockerfile" \ + -t "$REGISTRY/api:latest" \ + "$REPO_ROOT" + +# Build Worker image +echo "Building Worker image..." +docker build \ + -f "$WCAG_ROOT/worker/Dockerfile" \ + -t "$REGISTRY/worker:latest" \ + "$REPO_ROOT" + +# Build Dashboard image +echo "Building Dashboard image..." +docker build \ + -f "$WCAG_ROOT/dashboard/Dockerfile" \ + -t "$REGISTRY/dashboard:latest" \ + "$REPO_ROOT" + +echo "All images built successfully!" +echo "" +echo "Images created:" +echo " - $REGISTRY/api:latest" +echo " - $REGISTRY/worker:latest" +echo " - $REGISTRY/dashboard:latest" \ No newline at end of file diff --git a/apps/wcag-ada/scripts/deploy-k8s.sh b/apps/wcag-ada/scripts/deploy-k8s.sh new file mode 100755 index 0000000..1e8f2a9 --- /dev/null +++ b/apps/wcag-ada/scripts/deploy-k8s.sh @@ -0,0 +1,71 @@ +#!/bin/bash +set -e + +# Script to deploy to Kubernetes + +NAMESPACE=${NAMESPACE:-"wcag-ada"} +ENVIRONMENT=${ENVIRONMENT:-"staging"} + +echo "Deploying WCAG-ADA to Kubernetes..." +echo "Namespace: $NAMESPACE" +echo "Environment: $ENVIRONMENT" + +# Get the script directory +SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd) +K8S_DIR="$SCRIPT_DIR/../k8s" + +# Create namespace if it doesn't exist +kubectl create namespace $NAMESPACE --dry-run=client -o yaml | kubectl apply -f - + +# Apply configurations +echo "Applying configurations..." +kubectl apply -f "$K8S_DIR/configmap.yaml" -n $NAMESPACE + +# Apply secrets (only if file exists) +if [ -f "$K8S_DIR/secrets-$ENVIRONMENT.yaml" ]; then + echo "Applying environment-specific secrets..." + kubectl apply -f "$K8S_DIR/secrets-$ENVIRONMENT.yaml" -n $NAMESPACE +else + echo "Warning: No environment-specific secrets found. Using default secrets." + kubectl apply -f "$K8S_DIR/secrets.yaml" -n $NAMESPACE +fi + +# Deploy services +echo "Deploying PostgreSQL..." +kubectl apply -f "$K8S_DIR/postgres.yaml" -n $NAMESPACE + +echo "Deploying Redis..." +kubectl apply -f "$K8S_DIR/redis.yaml" -n $NAMESPACE + +# Wait for databases to be ready +echo "Waiting for databases to be ready..." +kubectl wait --for=condition=ready pod -l app=postgres -n $NAMESPACE --timeout=300s +kubectl wait --for=condition=ready pod -l app=redis -n $NAMESPACE --timeout=300s + +# Deploy applications +echo "Deploying API..." +kubectl apply -f "$K8S_DIR/api.yaml" -n $NAMESPACE + +echo "Deploying Worker..." +kubectl apply -f "$K8S_DIR/worker.yaml" -n $NAMESPACE + +echo "Deploying Dashboard..." +kubectl apply -f "$K8S_DIR/dashboard.yaml" -n $NAMESPACE + +# Apply ingress +echo "Applying Ingress rules..." +kubectl apply -f "$K8S_DIR/ingress.yaml" -n $NAMESPACE + +# Wait for deployments +echo "Waiting for deployments to be ready..." +kubectl rollout status deployment/wcag-ada-api -n $NAMESPACE +kubectl rollout status deployment/wcag-ada-worker -n $NAMESPACE +kubectl rollout status deployment/wcag-ada-dashboard -n $NAMESPACE + +echo "Deployment complete!" +echo "" +echo "Services deployed:" +kubectl get services -n $NAMESPACE +echo "" +echo "Pods status:" +kubectl get pods -n $NAMESPACE \ No newline at end of file diff --git a/apps/wcag-ada/scripts/local-dev.sh b/apps/wcag-ada/scripts/local-dev.sh new file mode 100755 index 0000000..f0654ba --- /dev/null +++ b/apps/wcag-ada/scripts/local-dev.sh @@ -0,0 +1,52 @@ +#!/bin/bash +set -e + +# Script to set up local development environment + +echo "Setting up WCAG-ADA local development environment..." + +# Get the script directory +SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd) +WCAG_ROOT=$(cd "$SCRIPT_DIR/.." && pwd) + +# Check if .env exists +if [ ! -f "$WCAG_ROOT/.env" ]; then + echo "Creating .env file from template..." + cp "$WCAG_ROOT/.env.example" "$WCAG_ROOT/.env" + echo "Please edit .env file with your configuration values" +fi + +# Start development databases +echo "Starting development databases..." +cd "$WCAG_ROOT" +docker-compose -f docker-compose.dev.yml up -d + +# Wait for databases to be ready +echo "Waiting for databases..." +sleep 5 + +# Install dependencies +echo "Installing dependencies..." +bun install + +# Run database migrations +echo "Running database migrations..." +cd "$WCAG_ROOT/api" +bunx prisma migrate dev + +# Start services +echo "" +echo "Development environment ready!" +echo "" +echo "To start all services, run:" +echo " cd $WCAG_ROOT && bun run dev" +echo "" +echo "Or start individual services:" +echo " API: cd $WCAG_ROOT/api && bun run dev" +echo " Worker: cd $WCAG_ROOT/worker && bun run dev" +echo " Dashboard: cd $WCAG_ROOT/dashboard && bun run dev" +echo "" +echo "Database tools:" +echo " Prisma Studio: cd $WCAG_ROOT/api && bunx prisma studio" +echo " Redis CLI: docker exec -it wcag-ada-redis-dev redis-cli" +echo " PostgreSQL: docker exec -it wcag-ada-postgres-dev psql -U postgres -d wcag_ada_dev" \ No newline at end of file diff --git a/apps/wcag-ada/shared/package.json b/apps/wcag-ada/shared/package.json new file mode 100644 index 0000000..eb2b5cb --- /dev/null +++ b/apps/wcag-ada/shared/package.json @@ -0,0 +1,18 @@ +{ + "name": "@wcag-ada/shared", + "version": "0.1.0", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "scripts": { + "build": "tsc -b", + "dev": "tsc -w", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "axe-core": "^4.8.3" + }, + "devDependencies": { + "@types/node": "^20.11.5", + "typescript": "^5.3.3" + } +} \ No newline at end of file diff --git a/apps/wcag-ada/shared/src/constants.ts b/apps/wcag-ada/shared/src/constants.ts new file mode 100644 index 0000000..edfbd4c --- /dev/null +++ b/apps/wcag-ada/shared/src/constants.ts @@ -0,0 +1,81 @@ +export const WCAG_TAGS = { + WCAG_2_0_A: ['wcag2a', 'wcag20a'], + WCAG_2_0_AA: ['wcag2aa', 'wcag20aa'], + WCAG_2_0_AAA: ['wcag2aaa', 'wcag20aaa'], + WCAG_2_1_A: ['wcag21a'], + WCAG_2_1_AA: ['wcag21aa'], + WCAG_2_1_AAA: ['wcag21aaa'], + WCAG_2_2_A: ['wcag22a'], + WCAG_2_2_AA: ['wcag22aa'], + WCAG_2_2_AAA: ['wcag22aaa'], +} as const; + +export const IMPACT_SCORES = { + critical: 100, + serious: 75, + moderate: 50, + minor: 25, +} as const; + +export const COMPLIANCE_THRESHOLDS = { + A: 0.95, + AA: 0.98, + AAA: 1.0, +} as const; + +export const DEFAULT_VIEWPORT = { + width: 1280, + height: 720, + deviceScaleFactor: 1, + isMobile: false, +} as const; + +export const MOBILE_VIEWPORT = { + width: 375, + height: 667, + deviceScaleFactor: 2, + isMobile: true, +} as const; + +export const SCAN_TIMEOUTS = { + PAGE_LOAD: 30000, + SCAN_EXECUTION: 60000, + TOTAL: 120000, +} as const; + +export const RESOURCE_BLOCK_PATTERNS = [ + '*.gif', + '*.jpg', + '*.jpeg', + '*.png', + '*.svg', + '*.webp', + '*.woff', + '*.woff2', + '*.ttf', + '*.eot', + 'fonts.googleapis.com', + 'fonts.gstatic.com', + 'googletagmanager.com', + 'google-analytics.com', + 'facebook.com', + 'twitter.com', + 'doubleclick.net', +]; + +export const CRITICAL_WCAG_CRITERIA = [ + '1.1.1', // Non-text Content + '1.3.1', // Info and Relationships + '1.4.3', // Contrast (Minimum) + '2.1.1', // Keyboard + '2.1.2', // No Keyboard Trap + '2.4.1', // Bypass Blocks + '2.4.2', // Page Titled + '2.4.3', // Focus Order + '2.4.4', // Link Purpose + '3.1.1', // Language of Page + '3.3.1', // Error Identification + '3.3.2', // Labels or Instructions + '4.1.1', // Parsing + '4.1.2', // Name, Role, Value +] as const; \ No newline at end of file diff --git a/apps/wcag-ada/shared/src/index.ts b/apps/wcag-ada/shared/src/index.ts new file mode 100644 index 0000000..e8c0b0f --- /dev/null +++ b/apps/wcag-ada/shared/src/index.ts @@ -0,0 +1,3 @@ +export * from './types'; +export * from './constants'; +export * from './utils'; \ No newline at end of file diff --git a/apps/wcag-ada/shared/src/types.ts b/apps/wcag-ada/shared/src/types.ts new file mode 100644 index 0000000..fe50ee2 --- /dev/null +++ b/apps/wcag-ada/shared/src/types.ts @@ -0,0 +1,201 @@ +import type { Result as AxeResult, ImpactValue, TagValue } from 'axe-core'; + +export interface WCAGLevel { + level: 'A' | 'AA' | 'AAA'; + version: '2.0' | '2.1' | '2.2'; +} + +export interface AccessibilityScanOptions { + url: string; + wcagLevel?: WCAGLevel; + viewport?: ViewportSize; + authenticate?: AuthenticationConfig; + includeScreenshots?: boolean; + customRules?: CustomRule[]; + scanSubpages?: boolean; + maxDepth?: number; + timeout?: number; + waitForSelector?: string; + excludeSelectors?: string[]; +} + +export interface ViewportSize { + width: number; + height: number; + deviceScaleFactor?: number; + isMobile?: boolean; +} + +export interface AuthenticationConfig { + type: 'basic' | 'form' | 'oauth' | 'custom'; + credentials?: { + username?: string; + password?: string; + token?: string; + }; + loginUrl?: string; + selectors?: { + username?: string; + password?: string; + submit?: string; + }; + customScript?: string; +} + +export interface CustomRule { + id: string; + selector: string; + tags: string[]; + description: string; + help: string; + helpUrl?: string; + severity: ImpactValue; + validator: (element: Element) => boolean | Promise; +} + +export interface AccessibilityViolation { + id: string; + impact: ImpactValue; + description: string; + help: string; + helpUrl: string; + tags: TagValue[]; + nodes: ViolationNode[]; + wcagCriteria?: string[]; + fixSuggestion?: string; +} + +export interface ViolationNode { + html: string; + target: string[]; + xpath?: string; + failureSummary?: string; + screenshot?: string; + relatedNodes?: RelatedNode[]; +} + +export interface RelatedNode { + html: string; + target: string[]; +} + +export interface AccessibilityScanResult { + url: string; + timestamp: Date; + scanDuration: number; + summary: ScanSummary; + violations: AccessibilityViolation[]; + passes: AxeResult[]; + incomplete: AxeResult[]; + inapplicable: AxeResult[]; + pageMetadata: PageMetadata; + wcagCompliance: WCAGCompliance; +} + +export interface ScanSummary { + violationCount: number; + passCount: number; + incompleteCount: number; + inapplicableCount: number; + criticalIssues: number; + seriousIssues: number; + moderateIssues: number; + minorIssues: number; + score: number; +} + +export interface PageMetadata { + title: string; + url: string; + viewport: ViewportSize; + language?: string; + doctype?: string; + hasLandmarks: boolean; + hasHeadings: boolean; + imagesCount: number; + formsCount: number; + linksCount: number; +} + +export interface WCAGCompliance { + level: WCAGLevel; + isCompliant: boolean; + criteriaResults: CriteriaResult[]; + overallScore: number; +} + +export interface CriteriaResult { + criterion: string; + level: 'A' | 'AA' | 'AAA'; + passed: boolean; + violations: string[]; +} + +export interface ScanJob { + id: string; + websiteId: string; + url: string; + status: 'pending' | 'running' | 'completed' | 'failed'; + options: AccessibilityScanOptions; + scheduledAt: Date; + startedAt?: Date; + completedAt?: Date; + error?: string; + resultId?: string; + retryCount: number; +} + +export interface Website { + id: string; + name: string; + url: string; + userId: string; + scanSchedule?: ScanSchedule; + lastScanAt?: Date; + complianceScore?: number; + tags?: string[]; + active: boolean; + createdAt: Date; + updatedAt: Date; +} + +export interface ScanSchedule { + frequency: 'manual' | 'hourly' | 'daily' | 'weekly' | 'monthly'; + dayOfWeek?: number; + dayOfMonth?: number; + hour?: number; + timezone?: string; +} + +export interface ComplianceReport { + id: string; + websiteId: string; + period: ReportPeriod; + generatedAt: Date; + summary: ReportSummary; + trendsData: TrendData[]; + detailedResults: AccessibilityScanResult[]; + recommendations: string[]; + format: 'pdf' | 'html' | 'json' | 'csv'; +} + +export interface ReportPeriod { + start: Date; + end: Date; +} + +export interface ReportSummary { + averageScore: number; + totalScans: number; + improvementRate: number; + criticalIssuesFixed: number; + newIssuesFound: number; + complianceLevel: WCAGLevel; +} + +export interface TrendData { + date: Date; + score: number; + violationCount: number; + passCount: number; +} \ No newline at end of file diff --git a/apps/wcag-ada/shared/src/utils.ts b/apps/wcag-ada/shared/src/utils.ts new file mode 100644 index 0000000..8508b1f --- /dev/null +++ b/apps/wcag-ada/shared/src/utils.ts @@ -0,0 +1,155 @@ +import type { ImpactValue } from 'axe-core'; +import { IMPACT_SCORES, COMPLIANCE_THRESHOLDS, WCAG_TAGS } from './constants'; +import type { AccessibilityScanResult, ScanSummary, WCAGLevel } from './types'; + +export function calculateComplianceScore(result: AccessibilityScanResult): number { + const { violations, passes } = result; + const totalChecks = violations.length + passes.length; + + if (totalChecks === 0) return 100; + + let weightedViolations = 0; + violations.forEach(violation => { + const impactScore = IMPACT_SCORES[violation.impact as keyof typeof IMPACT_SCORES] || 50; + weightedViolations += (impactScore / 100) * violation.nodes.length; + }); + + const score = Math.max(0, 100 - (weightedViolations / totalChecks) * 100); + return Math.round(score * 100) / 100; +} + +export function isCompliantWithLevel(score: number, level: WCAGLevel['level']): boolean { + return score >= COMPLIANCE_THRESHOLDS[level] * 100; +} + +export function getWCAGTags(level: WCAGLevel): string[] { + const key = `WCAG_${level.version.replace('.', '_')}_${level.level}` as keyof typeof WCAG_TAGS; + return [...(WCAG_TAGS[key] || [])]; +} + +export function summarizeViolations(violations: AccessibilityScanResult['violations']): Partial { + const summary = { + criticalIssues: 0, + seriousIssues: 0, + moderateIssues: 0, + minorIssues: 0, + }; + + violations.forEach(violation => { + const count = violation.nodes.length; + switch (violation.impact) { + case 'critical': + summary.criticalIssues += count; + break; + case 'serious': + summary.seriousIssues += count; + break; + case 'moderate': + summary.moderateIssues += count; + break; + case 'minor': + summary.minorIssues += count; + break; + } + }); + + return summary; +} + +export function generateFixSuggestion(violationId: string): string { + const suggestions: Record = { + 'color-contrast': 'Increase the contrast ratio between text and background colors to meet WCAG standards.', + 'image-alt': 'Add descriptive alt text to images. Use alt="" for decorative images.', + 'label': 'Add labels to form controls using