name: CI # Vercel variant: Vercel builds Preview deployments on every push and gates the # PR via the Vercel GitHub integration check. Running `npm run build` here too # would duplicate Vercel's work for ~3-5 minutes per PR with no added signal. # # What this CI covers (and Vercel does not): # - Lint (cheap belt-and-suspenders) # - Schema-map drift check (docs/SCHEMA_MAP.md updated when scripts/add-*.js changes) # - Unit tests (vitest) # # NOTE: tcg-vault is JavaScript (not TypeScript). No `npx tsc --noEmit` step. # Re-enable a type-check job if migrating to TypeScript. on: pull_request: branches: [main] push: branches: [main] concurrency: group: ci-${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true env: NODE_VERSION: '20' jobs: lint: name: Lint runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: ${{ env.NODE_VERSION }} cache: npm - run: npm ci # TODO(fix-lint-baseline): drop the `|| true` wrapper once .convoys/fix-lint-baseline # lands. The codebase has ~100 pre-existing ESLint errors (conditional React # hooks, unescaped entities, etc.). For now lint runs and posts output as a # warning annotation so the PR check stays green while the debt is visible. - name: Lint (non-blocking until fix-lint-baseline) run: | set +e npm run lint --if-present status=$? if [ "$status" -ne 0 ]; then echo "::warning title=Lint errors (non-blocking)::ESLint reported errors above. Tracked in .convoys/ship-readiness.md as P1 #11.5 (fix-lint-baseline). Remove the wrapper in .github/workflows/ci.yml after baseline is fixed." fi exit 0 schema-map-fresh: name: Schema map up to date runs-on: ubuntu-latest # Only run when migration scripts or the schema map itself changed. # If neither changed, nothing to verify. if: | contains(github.event.pull_request.changed_files, 'scripts/add-') || contains(github.event.pull_request.changed_files, 'scripts/fix-') || contains(github.event.pull_request.changed_files, 'scripts/setup-neon-db.js') || contains(github.event.pull_request.changed_files, 'docs/SCHEMA_MAP.md') steps: - uses: actions/checkout@v4 with: fetch-depth: 2 - name: Verify schema map updated alongside migration scripts run: | MIGRATION_CHANGED=false MAP_CHANGED=false if git diff --name-only HEAD~1 | grep -qE '^scripts/(add-|fix-|setup-neon-db\.js)'; then MIGRATION_CHANGED=true fi if git diff --name-only HEAD~1 | grep -q '^docs/SCHEMA_MAP\.md$'; then MAP_CHANGED=true fi if [ "$MIGRATION_CHANGED" = "true" ] && [ "$MAP_CHANGED" = "false" ]; then echo "::error::A migration script changed but docs/SCHEMA_MAP.md was not updated." echo "Update docs/SCHEMA_MAP.md to reflect the schema change, then re-push." exit 1 fi echo "OK: schema map and migration scripts are in sync." forbidden-endpoints: name: No dev endpoints in pages/api runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Fail if dev endpoints re-appear under pages/api/ run: | BAD_PATHS=( "pages/api/simple.js" "pages/api/test-auth.js" "pages/api/test-db.js" "pages/api/setup-database.js" ) FOUND=() for path in "${BAD_PATHS[@]}"; do if [ -f "$path" ]; then FOUND+=("$path") fi done # Also flag any new pages/api/test-*.js the explicit list missed. while IFS= read -r path; do FOUND+=("$path") done < <(find pages/api -maxdepth 4 -type f -name 'test-*.js' 2>/dev/null || true) if [ ${#FOUND[@]} -gt 0 ]; then echo "::error::Forbidden dev endpoints present in pages/api/. Delete them or move to scripts/." for path in "${FOUND[@]}"; do echo "::error file=${path}::Forbidden dev endpoint." done exit 1 fi echo "OK: no forbidden dev endpoints under pages/api/." forbidden-cors-headers: name: No wildcard CORS in pages/api runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Fail if any pages/api/ handler carries Access-Control-Allow-Origin run: | # The tcg-vault frontend and API are served from the same Vercel # deployment (same origin), so CORS headers serve no purpose and # are a documented attack surface (see .convoys/cors-tighten.md # and AGENTS.md Gotcha #5). Brief 4 of fix-auth-bypass cleaned # login.js + register.js; the cors-tighten convoy swept the # remaining 24 files. This job locks the cleanup in. # # If a future cross-origin caller is legitimately needed, design # a proper CORS layer (probably via middleware) rather than # scaffolding wildcards into individual handlers. MATCHES=$(grep -rEn 'Access-Control-Allow-(Origin|Methods|Headers)' pages/api/ 2>/dev/null || true) if [ -n "$MATCHES" ]; then echo "::error::Forbidden CORS headers present under pages/api/. Remove them — same-origin Vercel deployment does not need CORS." echo "$MATCHES" | while IFS= read -r line; do file=$(echo "$line" | cut -d: -f1) lineno=$(echo "$line" | cut -d: -f2) echo "::error file=${file},line=${lineno}::Forbidden CORS header — delete this line." done exit 1 fi echo "OK: no Access-Control-Allow-* headers under pages/api/." forbidden-client-side-llm-keys: name: No client-side LLM key leakage runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Fail on key-returning config endpoints or new browser LLM URLs run: | # Deleted by secure-scanner-gemini-key — must not return API keys to browsers. if [ -f pages/api/config/gemini.js ]; then echo "::error file=pages/api/config/gemini.js::Forbidden config endpoint — do not return API keys to browsers." exit 1 fi CONFIG_MATCHES=$(grep -rEn 'apiKey:' pages/api/config/ 2>/dev/null || true) if [ -n "$CONFIG_MATCHES" ]; then echo "::error::Forbidden apiKey response under pages/api/config/." echo "$CONFIG_MATCHES" | while IFS= read -r line; do file=$(echo "$line" | cut -d: -f1) lineno=$(echo "$line" | cut -d: -f2) echo "::error file=${file},line=${lineno}::Do not return API keys from config endpoints." done exit 1 fi AI_OCR_IMPORTS=$(grep -rEn 'from ['\''"].*ai-ocr|import.*ai-ocr' components/ 2>/dev/null || true) if [ -n "$AI_OCR_IMPORTS" ]; then echo "::error::Browser code must not import lib/ai-ocr — use POST /api/scan/identify instead." echo "$AI_OCR_IMPORTS" | while IFS= read -r line; do file=$(echo "$line" | cut -d: -f1) lineno=$(echo "$line" | cut -d: -f2) echo "::error file=${file},line=${lineno}::Remove ai-ocr import; call server-side scan API." done exit 1 fi # lib/ may hold server-only helpers (e.g. scan-gemini.js) imported only from pages/api/. SERVER_ONLY=( lib/scan-gemini.js ) LLM_PATTERN='generativelanguage\.googleapis\.com|api\.openai\.com' FOUND=() while IFS= read -r file; do skip=false for so in "${SERVER_ONLY[@]}"; do if [ "$file" = "$so" ]; then skip=true break fi done if [ "$skip" = true ]; then continue fi if grep -qE "$LLM_PATTERN" "$file" 2>/dev/null; then FOUND+=("$file") fi done < <(find components lib pages -name '*.js' ! -path 'pages/api/*' 2>/dev/null || true) if [ ${#FOUND[@]} -gt 0 ]; then echo "::error::Client-side LLM API URLs must not appear outside pages/api/." for path in "${FOUND[@]}"; do echo "::error file=${path}::Move LLM calls server-side or add to server-side-scan-pipeline removal list." done exit 1 fi echo "OK: no client-side LLM key leakage patterns detected." test: name: Unit tests (vitest) runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: ${{ env.NODE_VERSION }} cache: npm - run: npm ci - run: npm run test:run env: JWT_SECRET: ci-secret-only-for-tests-do-not-use-in-prod