/** * 공개 배포 번들 검사. * * 공개 배포(webforai.kr) 빌드 결과에 실제 병원명·비밀 키·실제 병원 파일이 남아 있으면 빌드를 실패시킨다. * 빌드가 실패하면 Vercel 배포도 진행되지 않는다. 기능을 다시 켤 때(src/lib/site.ts) 실제 데이터가 딸려 나가는 * 사고를 사람이 놓쳐도 여기서 막는다. 2026-10-07 공개 전 점검에서 손으로 반복한 검사를 옮긴 것이다. * * 금지 목록은 plugins/public-bundle-denylist.json 에 있다. */ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; interface Rule { label: string; pattern: string; } interface Denylist { clinics: Rule[]; secrets: Rule[]; paths: Rule[]; } const TEXT_EXT = new Set(['.js', '.mjs', '.css', '.html', '.json', '.txt', '.xml', '.svg', '.map', '.webmanifest']); function listFiles(dir: string, base = dir): string[] { return fs.readdirSync(dir, { withFileTypes: true }).flatMap((e) => { const full = path.join(dir, e.name); return e.isDirectory() ? listFiles(full, base) : [path.relative(base, full).split(path.sep).join('/')]; }); } /** 위반 목록을 돌려준다. 비어 있으면 통과다. 비밀 키 값은 그대로 출력하지 않고 앞 6자만 보인다. */ export function findPublicBundleViolations(outDir: string, denylistPath?: string): string[] { const here = path.dirname(fileURLToPath(import.meta.url)); const list: Denylist = JSON.parse(fs.readFileSync(denylistPath ?? path.join(here, 'public-bundle-denylist.json'), 'utf8')); const files = listFiles(outDir); const out: string[] = []; for (const rule of list.paths) { const re = new RegExp(rule.pattern, 'i'); const hits = files.filter((f) => re.test(f)); if (hits.length) out.push(`[파일] ${rule.label}: ${hits.slice(0, 3).join(', ')}${hits.length > 3 ? ` 외 ${hits.length - 3}개` : ''}`); } const textRules = [ ...list.clinics.map((r) => ({ ...r, kind: '병원명', secret: false })), ...list.secrets.map((r) => ({ ...r, kind: '비밀 키', secret: true })), ]; for (const file of files) { if (!TEXT_EXT.has(path.extname(file).toLowerCase())) continue; const text = fs.readFileSync(path.join(outDir, file), 'utf8'); for (const rule of textRules) { const m = text.match(new RegExp(rule.pattern, 'i')); if (!m) continue; const shown = rule.secret ? `${m[0].slice(0, 6)}…` : m[0]; out.push(`[${rule.kind}] ${rule.label}: "${shown}" (${file})`); } } return out; } /** 위반이 있으면 빌드를 멈춘다. */ export function assertPublicBundleClean(outDir: string): void { const violations = findPublicBundleViolations(outDir); if (violations.length) { throw new Error( `공개 배포 번들 검사 실패 (${violations.length}건). 실제 병원 정보나 비밀 키가 공개 번들에 들어갔습니다.\n` + violations.map((v) => ` - ${v}`).join('\n') + '\n 목록: plugins/public-bundle-denylist.json · 스위치: src/lib/site.ts', ); } }