diff --git a/apps/web/package.json b/apps/web/package.json
index a1028be..bb8fa7d 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -9,6 +9,7 @@
"build": "tsc -b && vite build",
"generate:api-types": "node scripts/generate-api-types.mjs",
"check:api-types": "node scripts/generate-api-types.mjs --check",
+ "check:preview-hosts": "node scripts/check-preview-hosts.mjs",
"generate:live2d-assets": "node scripts/generate-live2d-assets.mjs",
"preview": "vite preview",
"typecheck": "tsc -b",
diff --git a/apps/web/scripts/check-preview-hosts.mjs b/apps/web/scripts/check-preview-hosts.mjs
new file mode 100644
index 0000000..502ac7c
--- /dev/null
+++ b/apps/web/scripts/check-preview-hosts.mjs
@@ -0,0 +1,97 @@
+#!/usr/bin/env node
+/**
+ * check-preview-hosts — vite preview 서버의 host 허용 회귀 가드.
+ *
+ * 배포는 `npm run preview`를 cloudflared 터널로 공개 도메인에 연결한다.
+ * vite 6 preview는 Host 헤더를 `preview.allowedHosts`로 검증하므로, 공개 호스트가
+ * 목록에 없으면 "Blocked request. This host is not allowed." 403만 반환되어
+ * 배포 도메인 전체가 블랭크가 된다(2026-06-30 회귀). 이 스크립트는 dist를 preview로
+ * 띄워, 공개 Host는 통과(200, app HTML)하고 임의 Host는 차단되는지 확인한다.
+ *
+ * Node의 fetch는 Host 헤더 오버라이드를 무시하므로 http.request로 직접 Host를 보낸다.
+ *
+ * 사용: node scripts/check-preview-hosts.mjs (dist 빌드가 선행되어야 한다)
+ */
+import { spawn } from "node:child_process";
+import http from "node:http";
+import { setTimeout as sleep } from "node:timers/promises";
+
+const PORT = Number(process.env.PREVIEW_CHECK_PORT ?? 5191);
+const PUBLIC_HOSTS = ["vnet.18ka.net", "api-vnet.18ka.net", "vignette.chanpaca.net"];
+const DISALLOWED_HOST = "evil.example.com";
+const BLOCK_MARKER = "Blocked request";
+
+const isWin = process.platform === "win32";
+const viteBin = isWin ? "node_modules\\.bin\\vite.cmd" : "node_modules/.bin/vite";
+
+function startPreview() {
+ return spawn(viteBin, ["preview", "--host", "127.0.0.1", "--port", String(PORT)], {
+ stdio: ["ignore", "pipe", "pipe"],
+ shell: isWin,
+ });
+}
+
+/** http.request로 명시 Host 헤더를 보낸다(fetch는 Host 오버라이드를 무시함). */
+function getWithHost(host) {
+ return new Promise((resolve, reject) => {
+ const req = http.request(
+ { hostname: "127.0.0.1", port: PORT, path: "/", method: "GET", headers: { Host: host } },
+ (res) => {
+ let body = "";
+ res.on("data", (c) => (body += c));
+ res.on("end", () => resolve({ status: res.statusCode, text: body }));
+ },
+ );
+ req.on("error", reject);
+ req.end();
+ });
+}
+
+async function waitForUp(timeoutMs = 20000) {
+ const deadline = Date.now() + timeoutMs;
+ while (Date.now() < deadline) {
+ try {
+ const { status } = await getWithHost("127.0.0.1");
+ if (status === 200) return true;
+ } catch {
+ // not up yet
+ }
+ await sleep(300);
+ }
+ return false;
+}
+
+function killTree(child) {
+ if (!child || child.killed) return;
+ if (isWin && child.pid) {
+ spawn("taskkill", ["/pid", String(child.pid), "/T", "/F"], { stdio: "ignore", shell: true });
+ } else {
+ child.kill("SIGTERM");
+ }
+}
+
+let preview;
+let failed = false;
+try {
+ preview = startPreview();
+ const up = await waitForUp();
+ if (!up) {
+ console.error("FAIL preview server did not come up; build dist first (npm run build)");
+ failed = true;
+ } else {
+ for (const host of PUBLIC_HOSTS) {
+ const { status, text } = await getWithHost(host);
+ const ok = status === 200 && !text.includes(BLOCK_MARKER) && text.includes("101차 적용(2026-06-30): 관리자(role=admin)가 학습자·교수자·관리자 워크스페이스를 모두 직접 오갈 수 있도록 권한 경계를 정리했다. 백엔드는 Principal.can_access_role()이 role=admin이면 모든 역할 접근을 허용하고, require_role()은 super_admin뿐 아니라 admin도 허용 역할로 승격한다. 학습자 전용 경로(/consent accept/withdraw, /voice/ws)의 super_admin-only 승격 분기도 can_access_role(Role.LEARNER) 기준으로 바꿔 admin이 동일하게 통과한다. 프론트는 auth.tsx의 canAccessRole()에 admin 전체 접근을 반영하고, 역할 전환 메뉴는 accessibleRolesFor() 단일 헬퍼가 소유하며, Sidebar admin 내비에 운영 홈·교수 콘솔·학습자 홈·학습·기록 진입을 추가했다. 외부 API 계약·DB schema·승인 게이트(account_status)·super_admin 권한 부여/회수 동작은 바꾸지 않았다. 검증: npm run typecheck, npm run check:api-types, py -3.11 -X utf8 -B -m pytest app/ -q 290 passed(RBAC/runtime 30 포함), admin 워크스페이스 내비 E2E 1 passed, layout 시각게이트 9/9, session-layout 4 passed.
102차 적용(2026-06-30): 소유자(윤찬) 결정 4건이 확정되어 owner 보드 결정 카드 7→3으로 줄었다. (1) s2s 2차 PoC 유지 — 캐스케이드 1차를 운영 기준으로 두면서 voice-s2s-poc.md 비교 PoC를 계속 연다(최종 채택은 비교 리포트 이후). (2) fast-loop 외부 폴백 허용 — 로컬 상주 분류기 기본 + 외부 Haiku/Solar 폴백 허용, 전송 시 데이터주권·보존·동의 가드 적용. (3) 재귀학습 fine-tuning 포함 — few-shot 자동갱신에 더해 fine-tuning까지 범위에 포함하되, 데이터셋은 동의·추가 축어록 수급 게이트가 닫힌 뒤 구성. (4) 서비스 음성 = 대체 TTS provider — 상업 라이선스가 명확한 대체 provider 채택, Higgs synthetic·dev sample provider는 개발 플래그로만 유지, 실존 인물/성우 ref 금지 유지. 네 결정 모두 방향 확정이며 구현/실측은 후속 build 항목으로 남는다. 남은 owner 결정은 평가설계(H1 κ/ICC), 운영 보존·에스컬레이션, 추가 축어록 수급 3건이다. SSOT drift 게이트(check-dev-dashboard-ssot.py)의 기대 카운트도 결정 3·decision panel 3행으로 동기화했다.
103차 적용(2026-06-30): 남은 소유자 결정 3건이 확정되어 owner 결정 7건이 전건 확정(결정 필요 3→0)됐다. (1) 평가설계 H1 — 평가자 신뢰도 κ≥0.70 & ICC≥0.75, 환각률 ≤3%, 독립표본 t-검정(α=0.05), 20명 실험/통제군 무작위 배정, 단회기 50분·3척도 pre-post 유지. (2) 운영 보존·에스컬레이션 — 헬스 raw 90일·rollup 365일, escalation 운영자 수동 승인, SLA 미표기(관측 샘플 이력 유지), 담당 그룹 자동 배정 미도입. (3) 추가 축어록 — 20건을 1개월 내 수급. 이 결정들은 설계·정책 확정이며, KPI threshold 반영·retention 기본값 적용·실제 수급/파일럿 evidence·통계해석은 후속 build/GATE로 남는다. 즉 owner 보드의 남은 항목은 모두 외부 증거·기관 의존(공개 OAuth 실 /turn, vnet DNS, 한신대 거버넌스, 임상팀 콘텐츠, 20명 파일럿·법무·물리 실측)이며, 운영 원칙상 E2E/외부 증거 없이는 DONE으로 체크하지 않는다. SSOT drift 게이트의 기대 카운트도 결정 0·decision panel 0행으로 동기화했다.
104차 적용(2026-06-30): 공개 배포 도메인(vnet.18ka.net)에서 슈퍼관리자가 관리자 페이지 전체를 블랭크로 보던 문제를 고쳤다. 원인은 권한이 아니라 vite preview 서버의 host 검증이었다. 배포는 npm run preview를 cloudflared 터널로 공개 도메인에 연결하는데, vite.config.ts에 preview.allowedHosts가 없어 공개 Host 요청이 Blocked request. This host is not allowed. 403 텍스트만 받아 SPA가 통째로 로드되지 못했다(에러 바운더리 부재로 전체 흰 화면). server.allowedHosts와 동일 목록을 preview에도 적용하고 기본 허용에 vnet.18ka.net/api-vnet.18ka.net/vignette.chanpaca.net를 추가했다. 검증: 재현(curl -H "Host: vnet.18ka.net" 127.0.0.1:5174 → 수정 전 Blocked, 수정 후 200+정상 index), 로컬 dev·preview에서 일반/슈퍼 관리자 전 admin·teach·learn 페이지 정상 렌더(pageerror 0), npm run typecheck. 공개 OAuth 실 /turn 증명은 여전히 별도 BLOCK이다.
104차 적용(2026-06-30): 공개 배포 도메인(vnet.18ka.net)에서 슈퍼관리자가 관리자 페이지 전체를 블랭크로 보던 문제를 고쳤다. 원인은 권한이 아니라 vite preview 서버의 host 검증이었다. 배포는 npm run preview를 cloudflared 터널로 공개 도메인에 연결하는데, vite.config.ts에 preview.allowedHosts가 없어 공개 Host 요청이 Blocked request. This host is not allowed. 403 텍스트만 받아 SPA가 통째로 로드되지 못했다(에러 바운더리 부재로 전체 흰 화면). server.allowedHosts와 동일 목록을 preview에도 적용하고 기본 허용에 vnet.18ka.net/api-vnet.18ka.net/vignette.chanpaca.net를 추가했다. 검증: 재현(curl -H "Host: vnet.18ka.net" 127.0.0.1:5174 → 수정 전 Blocked, 수정 후 200+정상 index), 로컬 dev·preview에서 일반/슈퍼 관리자 전 admin·teach·learn 페이지 정상 렌더(pageerror 0), npm run typecheck. 회귀 방지로 npm run check:preview-hosts(scripts/check-preview-hosts.mjs)를 추가해 preview를 띄워 공개 Host 3종 200 허용·임의 Host 403 차단을 행동 검증한다(공개 3 allowed·unlisted blocked 통과). 공개 OAuth 실 /turn 증명은 여전히 별도 BLOCK이다.
20차 적용(2026-06-28): 신규 Google/SAML 사용자는 account_status=pending으로 시작하고 승인 전에는 /pending 안내 화면만 본다. yunchan@twentyoz.kr는 슈퍼 관리자 allowlist로 admin+approved를 받으며, /admin/users는 가입 승인 탭에서 pending 계정을 승인 또는 보류 처리한다.
21차 적용(2026-06-28): 관리자 페이지 진입권을 기본 역할과 분리해 app_user.admin_access로 저장한다. AUTH_SUPER_ADMIN_EMAILS 기본값은 yunchan@twentyoz.kr, hoonjungkoo@hs.ac.kr이며, 슈퍼 관리자는 학습자·교수자·관리자 공간 전환과 관리자 권한 부여/회수를 할 수 있다. 학생·교수 계정도 admin_access=true면 우측 상단 관리자 진입이 노출된다. 구성 슈퍼 관리자의 권한 회수와 계정 비활성화는 차단한다.
25차 적용(2026-06-28): /admin/users에서 허용 도메인 밖 이메일도 정확한 계정 단위로 강제 등록할 수 있다. Google/SAML/dev-login은 미리 등록된 이메일만 도메인 게이트 예외로 통과시키고, provider 로그인 시 기존 관리 row의 역할·코호트·승인 상태를 이어받는다. 미등록 외부 도메인 로그인은 계속 차단한다.