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) # - Migrations apply cleanly (node-pg-migrate against ephemeral Postgres 16) # - 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 - run: npm run lint --if-present 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, 'migrations/') || 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 -qE '^migrations/'; 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 imported only from pages/api/. SERVER_ONLY=( lib/scan-vision.js ) LLM_PATTERN='generativelanguage\.googleapis\.com|api\.openai\.com|ai-gateway\.vercel\.sh' 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." forbidden-stale-strings: name: No stale ownership/collection copy runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Fail if forbidden UI strings appear in pages/ or components/ run: | # rename-collections-vocabulary convoy — ownership vs curated-list taxonomy. # API/DB literals (e.g. system collection name) live under pages/api/ only. PATTERNS=( 'Mark Owned' 'Owned Cards' 'All My Cards' ) FOUND=() for pattern in "${PATTERNS[@]}"; do while IFS= read -r line; do FOUND+=("$line") done < <(grep -rFn "$pattern" pages/ components/ \ --include='*.js' \ --exclude-dir=api 2>/dev/null || true) done if [ ${#FOUND[@]} -gt 0 ]; then echo "::error::Forbidden stale UI string(s) in pages/ or components/. Use lib/collection-vocabulary.js labels — see AGENTS.md § Product vocabulary." printf '%s\n' "${FOUND[@]}" | sort -u | while IFS= read -r line; do file=$(echo "$line" | cut -d: -f1) lineno=$(echo "$line" | cut -d: -f2) text=$(echo "$line" | cut -d: -f3-) echo "::error file=${file},line=${lineno}::${text}" done exit 1 fi echo "OK: no forbidden stale strings in pages/ or components/." migrate: name: Migrations apply (node-pg-migrate) runs-on: ubuntu-latest services: postgres: image: postgres:16 env: POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres POSTGRES_DB: deckhearth_test ports: - 5432:5432 options: >- --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 env: POSTGRES_URL: postgres://postgres:postgres@localhost:5432/deckhearth_test steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: ${{ env.NODE_VERSION }} cache: npm - run: npm ci - name: Write migrate env file run: echo "POSTGRES_URL=${POSTGRES_URL}" >> .env.local - run: npm run migrate up 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