diff --git a/apps/api/app/deps.py b/apps/api/app/deps.py index 2b9a605..d2cd7a2 100644 --- a/apps/api/app/deps.py +++ b/apps/api/app/deps.py @@ -58,6 +58,8 @@ class Principal: return True if self.super_admin: return True + if self.role == Role.ADMIN: + return True if role == Role.ADMIN: return self.admin_access return False @@ -131,9 +133,11 @@ def require_role(*allowed: Role): ) -> Principal: if principal.role in allowed: return principal - if principal.super_admin: - effective = Role.ADMIN if Role.ADMIN in allowed else allowed[0] - return principal.with_role(effective) + if principal.super_admin and Role.ADMIN in allowed: + return principal.with_role(Role.ADMIN) + for role in allowed: + if principal.can_access_role(role): + return principal.with_role(role) raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=f"role {principal.role.value} not permitted", diff --git a/apps/api/app/routes/auth.py b/apps/api/app/routes/auth.py index e36ae0f..46dbeb7 100644 --- a/apps/api/app/routes/auth.py +++ b/apps/api/app/routes/auth.py @@ -1065,7 +1065,7 @@ async def accept_consent( principal: CurrentPrincipal, ) -> ConsentResponse: """Record the current learner's practice-session consent receipt.""" - if principal.role != Role.LEARNER and principal.super_admin: + if principal.role != Role.LEARNER and principal.can_access_role(Role.LEARNER): principal = principal.with_role(Role.LEARNER) if principal.role != Role.LEARNER: raise HTTPException(status.HTTP_403_FORBIDDEN, detail="learner consent only") @@ -1081,7 +1081,7 @@ async def accept_consent( @router.delete("/consent", response_model=ConsentResponse) async def withdraw_consent(principal: CurrentPrincipal) -> ConsentResponse: """Withdraw practice-session consent until the learner accepts again.""" - if principal.role != Role.LEARNER and principal.super_admin: + if principal.role != Role.LEARNER and principal.can_access_role(Role.LEARNER): principal = principal.with_role(Role.LEARNER) if principal.role != Role.LEARNER: raise HTTPException(status.HTTP_403_FORBIDDEN, detail="learner consent only") diff --git a/apps/api/app/routes/voice.py b/apps/api/app/routes/voice.py index f15222b..b230cc0 100644 --- a/apps/api/app/routes/voice.py +++ b/apps/api/app/routes/voice.py @@ -127,7 +127,7 @@ async def voice_ws(websocket: WebSocket) -> None: await _safe_send_json(websocket, {"type": "error", "detail": "not authenticated"}) await _safe_close(websocket, WS_CLOSE_UNAUTHORIZED) return - if principal.role != Role.LEARNER and principal.super_admin: + if principal.role != Role.LEARNER and principal.can_access_role(Role.LEARNER): principal = principal.with_role(Role.LEARNER) if principal.role != Role.LEARNER: await _safe_send_json(websocket, {"type": "error", "detail": "only learners can use voice"}) diff --git a/apps/api/app/services/dataset_export.py b/apps/api/app/services/dataset_export.py index b24f543..a17c301 100644 --- a/apps/api/app/services/dataset_export.py +++ b/apps/api/app/services/dataset_export.py @@ -389,8 +389,8 @@ def validate_manifest_gate(manifest: Mapping[str, Any]) -> None: approvals = manifest.get("approvals") or {} if pii_scan.get("status") != "pass": errors.append("PII scan must pass") - if (agreement.get("kappa") or 0) < 0.60: - errors.append("kappa must be >= 0.60") + if (agreement.get("kappa") or 0) < 0.70: + errors.append("kappa must be >= 0.70") if (agreement.get("icc") or 0) < 0.75: errors.append("ICC must be >= 0.75") for key in ("data_steward", "legal_or_privacy_reviewer", "technical_operator", "approved_at"): diff --git a/apps/api/app/test_phase3_artifact_checker.py b/apps/api/app/test_phase3_artifact_checker.py index 29aa252..f8dc028 100644 --- a/apps/api/app/test_phase3_artifact_checker.py +++ b/apps/api/app/test_phase3_artifact_checker.py @@ -134,7 +134,7 @@ class Phase3ArtifactCheckerTests(unittest.TestCase): "direct_identifier_policy": "blocked", }, "pii_scan": {"status": "pass"}, - "agreement": {"kappa": 0.60, "icc": 0.75}, + "agreement": {"kappa": 0.70, "icc": 0.75}, "files": [ { "path": "03-export/anonymized_dataset.jsonl", @@ -167,7 +167,7 @@ class Phase3ArtifactCheckerTests(unittest.TestCase): manifest_path = root / "03-export" / "export_manifest.json" manifest = json.loads(manifest_path.read_text(encoding="utf-8")) manifest["pii_scan"]["status"] = "pending" - manifest["agreement"]["kappa"] = 0.59 + manifest["agreement"]["kappa"] = 0.69 manifest["agreement"]["icc"] = 0.74 manifest["selection_criteria"]["include_withdrawn"] = True manifest["consent_scope"]["allowed_uses"] = ["education_quality_review"] @@ -177,7 +177,7 @@ class Phase3ArtifactCheckerTests(unittest.TestCase): errors = "\n".join(report.errors) self.assertIn("pii_scan.status='pass'", errors) - self.assertIn("agreement.kappa >= 0.60", errors) + self.assertIn("agreement.kappa >= 0.70", errors) self.assertIn("agreement.icc >= 0.75", errors) self.assertIn("include_withdrawn=false", errors) self.assertIn("recursive_learning_seed consent scope", errors) diff --git a/apps/api/app/test_runtime_policy.py b/apps/api/app/test_runtime_policy.py index af5df54..7598335 100644 --- a/apps/api/app/test_runtime_policy.py +++ b/apps/api/app/test_runtime_policy.py @@ -146,6 +146,8 @@ class RuntimeFallbackPolicyTest(unittest.IsolatedAsyncioTestCase): "has_engine_config": False, "has_session_columns": False, "has_state_columns": False, + "has_turn_provider_events": False, + "has_session_review_worksheet_columns": False, "has_stage_defs": False, "has_admin_health_event": False, "has_admin_health_daily_rollup": False, diff --git a/apps/web/e2e/admin.spec.ts b/apps/web/e2e/admin.spec.ts index 01f9089..71e15c8 100644 --- a/apps/web/e2e/admin.spec.ts +++ b/apps/web/e2e/admin.spec.ts @@ -554,6 +554,33 @@ test.describe("admin route", () => { }); }); + test("shows every workspace entry in the admin navigation", async ({ page }) => { + test.setTimeout(60_000); + await signInAsAdmin(page); + + await page.goto("/admin"); + const nav = page.locator(".vg-nav"); + await expect(nav.getByRole("link", { name: "운영 홈" })).toBeVisible(); + await expect(nav.getByRole("link", { name: "사용자" })).toBeVisible(); + await expect(nav.getByRole("link", { name: "권한" })).toBeVisible(); + await expect(nav.getByRole("link", { name: "티켓" })).toBeVisible(); + await expect(nav.getByRole("link", { name: "교수 콘솔" })).toBeVisible(); + await expect(nav.getByRole("link", { name: "페르소나" })).toBeVisible(); + await expect(nav.getByRole("link", { name: "학습자 홈" })).toBeVisible(); + await expect(nav.getByRole("link", { name: "학습", exact: true })).toBeVisible(); + await expect(nav.getByRole("link", { name: "기록" })).toBeVisible(); + + await nav.getByRole("link", { name: "교수 콘솔" }).click(); + await expect(page).toHaveURL(/\/teach$/); + await expect(page.locator(".pf-root")).toBeVisible(); + + await page.goto("/admin"); + await page.locator(".vg-nav").getByRole("link", { name: "학습자 홈" }).click(); + await expect(page).toHaveURL(/\/learn$/); + await expect(page.getByRole("heading", { name: "오늘 이어갈 회기를 먼저 봅니다." })).toBeVisible(); + await expectNoHorizontalOverflow(page); + }); + test("allows an admin to manage real server-known users", async ({ page }, testInfo) => { test.setTimeout(60_000); await signInAsAdmin(page); diff --git a/apps/web/src/components/shell/Sidebar.tsx b/apps/web/src/components/shell/Sidebar.tsx index 2054d10..ce88d96 100644 --- a/apps/web/src/components/shell/Sidebar.tsx +++ b/apps/web/src/components/shell/Sidebar.tsx @@ -27,11 +27,15 @@ const NAV_BY_ROLE: Record = { { to: "/settings", label: "설정", icon: "settings" }, ], admin: [ - { to: "/admin", label: "운영", icon: "shield", end: true }, + { to: "/admin", label: "운영 홈", icon: "shield", end: true }, { to: "/admin/users", label: "사용자", icon: "users" }, { to: "/admin/access", label: "권한", icon: "settings" }, { to: "/admin/tickets", label: "티켓", icon: "review" }, + { to: "/teach", label: "교수 콘솔", icon: "users", end: true }, { to: "/teach/personas", label: "페르소나", icon: "review" }, + { to: "/learn", label: "학습자 홈", icon: "home", end: true }, + { to: "/learn/practice", label: "학습", icon: "session" }, + { to: "/learn/history", label: "기록", icon: "review" }, { to: "/settings", label: "설정", icon: "settings" }, ], }; diff --git a/apps/web/src/components/shell/Topbar.tsx b/apps/web/src/components/shell/Topbar.tsx index e85d9c4..20c625d 100644 --- a/apps/web/src/components/shell/Topbar.tsx +++ b/apps/web/src/components/shell/Topbar.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from "react"; import { Link, useLocation, useNavigate } from "react-router-dom"; import { Icon } from "../ui/Icon"; -import { canAccessRole, roleHomePath, roleLabel, useAuth, type Role } from "../../lib/auth"; +import { accessibleRolesFor, canAccessRole, roleHomePath, roleLabel, useAuth, type Role } from "../../lib/auth"; import { applyTheme, readInitialTheme, type AppTheme } from "../../lib/theme"; /** Vignette 워드마크 — 비네트(조리개) inline SVG. dev_dashboard 마크 계승. */ @@ -49,11 +49,7 @@ export function Topbar({ contextLabel }: TopbarProps) { const [dark, toggleTheme] = useTheme(); const label = contextLabel ?? (user ? roleLabel(user.role) : null); - const switchRoles: Role[] = user?.superAdmin - ? ["learner", "teacher", "admin"] - : user?.adminAccess - ? ["admin"] - : []; + const switchRoles: Role[] = user ? accessibleRolesFor(user) : []; const onLogout = async () => { await logout(); diff --git a/apps/web/src/lib/auth.tsx b/apps/web/src/lib/auth.tsx index ecf0170..36cd43a 100644 --- a/apps/web/src/lib/auth.tsx +++ b/apps/web/src/lib/auth.tsx @@ -70,9 +70,16 @@ export function roleHomePath(role: Role): string { export function canAccessRole(user: AuthUser, role: Role): boolean { if (user.role === role) return true; if (user.superAdmin) return true; + if (user.role === "admin") return true; return role === "admin" && user.adminAccess; } +export function accessibleRolesFor(user: AuthUser): Role[] { + if (user.superAdmin || user.role === "admin") return ["learner", "teacher", "admin"]; + if (user.adminAccess) return ["admin"]; + return []; +} + const DEV_EMAIL_BY_ROLE: Record = { learner: "learner@hs.ac.kr", teacher: "teacher@hs.ac.kr", diff --git a/docs/dev_dashboard.html b/docs/dev_dashboard.html index b337b15..7b9f51c 100644 --- a/docs/dev_dashboard.html +++ b/docs/dev_dashboard.html @@ -299,7 +299,7 @@ 완료0전체의 0% 진행 중0검증/배선 진행 계획 카드0외부 gate는 내 차례에 분리 - 내 차례0블로커 2 · 결정 7 · 외부조율 2 + 내 차례0블로커 2 · 결정 0 · 외부조율 2 @@ -323,14 +323,14 @@
🧭 소유자 결정 (DECIDE) 0
결정됨아키텍처·일정

백엔드 언어 방향

소유자 결정: 장기 교체 대상과 신규 분리 서비스는 Node.js 우선. 단, 현재 FastAPI 전면 재작성은 일정 리스크라 계약 우선 strangler 방식으로 간다.

영향: 팀 역량은 Node로 맞추되, 납품 핵심 경로는 기존 Python 동작을 보존하며 교체 가능한 경계부터 만든다.

-
결정Phase 3 측정

평가설계 (H1: 실험/통제군·3척도·κ/ICC)

doc4가 20명 실험/통제군·단회기 50분·3척도 pre-post를 명시. κ/ICC·환각률 목표는 doc4 미명시 — 확정 필요.

영향: 평가 타당도, Phase 3 측정력. 평가설계는 소유자 결정.

-
결정음성 아키텍처

s2s 2차 PoC 채택 여부

캐스케이드 1차는 확정. voice-s2s-poc.md 기준표로 비교 후 유지/폐기.

캐스케이드만 / s2s 2차 PoC 유지

-
결정페르소나 음성

서비스용 음성 모델·권리 정책

Higgs급 무참조 synthetic P1 샘플은 생성 완료, dev-only sample TTS provider 연결 완료. 실존 인물/성우 ref 사용 금지 확정. 서비스 배포 전 상업 라이선스·동의 ref·대체 provider 중 결정 필요.

Higgs 라이선스 / 동의 ref / 대체 TTS

-
결정데이터주권·레이턴시

fast-loop 외부 API 허용 여부

로컬 상주 분류기 기본 + 외부 Haiku/Solar 폴백 허용 여부.

로컬 전용 / 외부 폴백 허용

-
결정운영 감사·장애 대응

운영 보존기간·에스컬레이션 정책

헬스 raw/rollup 보존 기간, 담당 그룹 자동 배정, 우선순위 escalation, SLA 표현 여부를 정해야 한다. 현재 값은 SLA가 아니라 관측 샘플 이력이다.

정책 승인 전 자동 처리 금지

-
결정동의서·데이터셋

재귀학습 fine-tuning 범위

1차 few-shot 자동갱신만 vs fine-tuning 포함.

few-shot only / fine-tuning 포함

+
결정됨Phase 3 측정

평가설계 (H1: 실험/통제군·3척도·κ/ICC)

소유자 결정(2026-06-30): 평가자 신뢰도 목표 κ≥0.70 & ICC≥0.75, 환각률 ≤3%, 통계검정은 독립표본 t-검정(α=0.05), 20명을 실험/통제군으로 무작위 배정, 단회기 50분·3척도 pre-post는 doc4대로 유지. 설계 파라미터는 확정됐고, 실제 20명 파일럿 evidence·통계해석은 별도 GATE로 남는다.

영향: 평가 타당도, Phase 3 측정력. 설계는 확정, 효과성 판정은 파일럿 데이터 이후.

+
결정됨음성 아키텍처

s2s 2차 PoC 채택 여부

소유자 결정(2026-06-30): 캐스케이드 1차를 유지하면서 s2s 2차 PoC를 계속 연다. voice-s2s-poc.md 기준표(transcript·safety·audit·latency·failure·privacy)로 비교 리포트를 만든 뒤 최종 채택/폐기를 다시 판단한다.

방향: s2s 2차 PoC 유지 → 비교 PoC 구현·실측이 후속

+
결정됨페르소나 음성

서비스용 음성 모델·권리 정책

소유자 결정(2026-06-30): 서비스 배포 음성은 상업 라이선스가 명확한 대체 TTS provider로 간다. Higgs 무참조 synthetic 샘플과 dev-only sample provider는 개발 플래그로만 유지하고, 실존 인물/성우 ref 사용 금지를 유지한다.

방향: 대체 TTS provider 채택 → provider 선정·통합이 후속

+
결정됨데이터주권·레이턴시

fast-loop 외부 API 허용 여부

소유자 결정(2026-06-30): 로컬 상주 분류기를 기본으로 하되 외부 Haiku/Solar 폴백을 허용한다. 외부 전송 시 데이터주권·로그 보존 정책과 동의 범위를 함께 적용한다.

방향: 외부 폴백 허용 → 폴백 경로·전송 가드 구현이 후속

+
결정됨운영 감사·장애 대응

운영 보존기간·에스컬레이션 정책

소유자 결정(2026-06-30): 헬스 raw 이벤트 90일, 일별 rollup 365일 보존. 우선순위 escalation은 운영자 수동 승인, SLA는 미표기(현 값은 관측 샘플 이력으로 유지), 담당 그룹 자동 배정은 도입하지 않는다.

방향: raw 90일/rollup 365일 · 수동 escalation · SLA 미표기 → retention 기본값 적용이 후속

+
결정됨동의서·데이터셋

재귀학습 fine-tuning 범위

소유자 결정(2026-06-30): few-shot 자동갱신에 더해 fine-tuning까지 범위에 포함한다. 단 실제 fine-tuning 데이터셋은 동의 범위·축어록 수급 결정이 닫힌 뒤에 구성한다.

방향: fine-tuning 포함 → 동의/데이터셋 게이트 충족 후 파이프라인 구축

결정됨안전 정책

자유연습 기본값

결정: 기본 ON + 피드백 권장 배지 + 교수자 토글. 미동의·미성년 개인정보 gate만 차단한다.

근거: MASTERPLAN_REVISIONS F-10 승인

-
결정평가 타당도·다양성

추가 축어록 수급 규모/일정

n=1 시드 한계 해소를 위한 추가 사례 수급 규모와 일정을 정해야 한다.

영향: 평가 타당도, 페르소나 다양성, Phase 3 측정력.

+
결정됨평가 타당도·다양성

추가 축어록 수급 규모/일정

소유자 결정(2026-06-30): 추가 사례 20건을 1개월 내 수급한다. n=1 시드 한계를 해소하고 페르소나 다양성·Phase 3 측정력을 확보하기 위한 목표다.

영향: 평가 타당도, 페르소나 다양성, Phase 3 측정력. 실제 수급·동의·정제는 임상/거버넌스 게이트와 연계.

@@ -533,6 +533,9 @@

97차 적용(2026-06-29): SESSION_DIGEST_WORKER_ENABLED=false 기본값을 두고, opt-in일 때만 세션 종료 성공 뒤 digest worker를 background task로 예약한다. scheduler는 persisted fallback row를 load한 뒤 DB connection을 놓고 engine을 호출하며, accepted plan만 짧은 apply transaction으로 반영한다. 이미 compressed_by가 있는 세션은 loader에서 제외하고, apply도 compressed_by IS NULL CAS로 보호하며, fallback summary 재작성 시 compression metadata를 초기화한다. 이는 bounded/default-off scheduler 골격이며, 실 provider 장시간 운영·임상 골든셋 품질평가·재압축 정책 완료가 아니다. 검증: py_compile, runner --help, M2 focused 30 passed.

99차 적용(2026-06-29): 대시보드 SSOT drift gate를 보강했다. scripts/check-dev-dashboard-ssot.py는 현재 카드 상태(done=25, planned=0, doing=0), CHECK 잔여 0, owner board counts(block=2, decide=7, ext=2), 외부 GATE 상세 6행, 결정 탭 7행, 자유연습 F-10 결정 문구, M2 30/87 검증 수치, default-off scheduler/CAS 문구, 실 provider·임상 골든셋·재압축 GATE 보존, stale 82/25/26 passed 및 낡은 결정 8건 문구 제거를 함께 확인한다. 검증: py_compile, py -3.11 -X utf8 scripts\check-dev-dashboard-ssot.py --json PASS. 아래 적용 노트 일부는 historical snapshot이라 당시 통과 수치를 그대로 보존한다.

100차 적용(2026-06-29): 관리자·교수자 메일링 시스템 1차를 추가했다. 신규 외부 로그인 pending 계정은 account_pending_approval:{user_id}, 회기 종료 후 평가/error record 저장 완료 세션은 session_review_ready:{session_id}, 명시 테스트 메일은 admin_test_email:{uuid} idempotency key로 app.notification_event/app.notification_delivery에 큐잉된다. 테스트 메일은 대량 오발송을 피하기 위해 AUTH_SUPER_ADMIN_EMAILS 대상에게만 큐잉한다. 메일 HTML은 Vignette 톤앤매너(종이 배경, 세이지 CTA, 8px 카드)를 inline style로 유지하고, 본문에는 축어록·평가 전문을 넣지 않고 /admin/users, /teach/session/:sessionId/review, /admin 딥링크만 제공한다. NOTIFICATION_EMAIL_PROVIDER=smtpSMTP_*가 설정된 경우 실제 발송하며, GET /admin/notifications, POST /admin/notifications/process, POST /admin/notifications/test, scripts/run-notification-worker.py로 delivery 상태와 재시도를 운영한다.

+

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.tsxcanAccessRole()에 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행으로 동기화했다.

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의 역할·코호트·승인 상태를 이어받는다. 미등록 외부 도메인 로그인은 계속 차단한다.

@@ -1011,19 +1014,19 @@ $env:E2E_PUBLIC_STORAGE_STATE=".\node_modules\.tmp\public-auth.json" powershell -NoProfile -ExecutionPolicy Bypass -File scripts\start-public-runtime.ps1
닫은 항목(live 증거): ① engine config 운영값(claude_cli / 9099 / gateway-default, durable, db). ② 상주 엔진풀 probe — 단일 session_id 2회 reused stream, TTFT 1667–4199ms, cost 누적 0.068→0.123. ③ 상주 엔진풀 RSS — gateway session 1개에서 claude -p child RSS 363.3MB, gateway RSS 16.2MB. ④ Postgres RLS/audit smoke 5 checks PASS. ⑤ turn cost telemetry — app.turns client_ai 13행에 provider/model/cost_usd 실적재(합 $1.22). ⑥ 레이아웃 시각 게이트 최신 9/9 + 2026-06-28 적대적 재검수 7/7 accept.
-
환경 제약으로 이 세션에서 못 닫는 항목(정직 표기): 공개 Google OAuth 실제 /turn(OAuth start는 2026-06-29 재확인 완료, 실제 계정 storageState 필요) · 실배포 infra/.env owner-secret fill-in(APP_DB_PASSWORD, OAUTH_GOOGLE_CLIENT_ID, OAUTH_GOOGLE_CLIENT_SECRET, OPENAI_API_KEY, SESSION_SECRET) · vnet.18ka.net/api-vnet.18ka.net 공개 런타임(DNS A record 0, Cloudflare 18ka.net zone 권한 불일치, Google OAuth redirect URI 등록 필요) · 한신대 데이터/SSO 거버넌스(owner/기관 written evidence 필요) · 음성 실제 Deepgram interim/final WSS·물리 마이크·공개 WSS·50분 양방향 장시간 실측 · claude_cli↔Messages API 폴백 동일성 · 재부팅 후 watchdog smoke · Phase 3 파일럿 게이트 · decisions 7건(owner 결정). 운영 원칙상 가짜 증거로 DONE 표기하지 않는다. 전체 추적: docs/ops/backlog-2026-06-26.md(B1 구현 후속 · B2 환경제약 · B3 소유자결정 · B4 거버넌스).
+
환경 제약으로 이 세션에서 못 닫는 항목(정직 표기): 공개 Google OAuth 실제 /turn(OAuth start는 2026-06-29 재확인 완료, 실제 계정 storageState 필요) · 실배포 infra/.env owner-secret fill-in(APP_DB_PASSWORD, OAUTH_GOOGLE_CLIENT_ID, OAUTH_GOOGLE_CLIENT_SECRET, OPENAI_API_KEY, SESSION_SECRET) · vnet.18ka.net/api-vnet.18ka.net 공개 런타임(DNS A record 0, Cloudflare 18ka.net zone 권한 불일치, Google OAuth redirect URI 등록 필요) · 한신대 데이터/SSO 거버넌스(owner/기관 written evidence 필요) · 음성 실제 Deepgram interim/final WSS·물리 마이크·공개 WSS·50분 양방향 장시간 실측 · claude_cli↔Messages API 폴백 동일성 · 재부팅 후 watchdog smoke · Phase 3 파일럿 게이트. owner 결정 7건은 2026-06-30 전건 확정됨. 운영 원칙상 가짜 증거로 DONE 표기하지 않는다. 전체 추적: docs/ops/backlog-2026-06-26.md(B1 구현 후속 · B2 환경제약 · B3 소유자결정 · B4 거버넌스).