From 287029f2a23949dbfd5b961a227860d029a32a9f Mon Sep 17 00:00:00 2001 From: Yun Chan Date: Fri, 3 Jul 2026 22:50:14 +0900 Subject: [PATCH] =?UTF-8?q?=EA=B4=80=EB=A6=AC=EC=9E=90=20=ED=9D=B0=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20=EC=A7=84=EB=8B=A8=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/e2e/admin.spec.ts | 55 ++++++++++--- apps/web/src/App.tsx | 148 ++++++++++++++++++++++++++++++++++- apps/web/src/pages/Admin.tsx | 115 +++++++++++++++++++++++++-- docs/HANDOFF.md | 2 +- docs/dev_dashboard.html | 4 +- docs/guides/architecture.md | 8 +- 6 files changed, 307 insertions(+), 25 deletions(-) diff --git a/apps/web/e2e/admin.spec.ts b/apps/web/e2e/admin.spec.ts index eea2c40..08d6d19 100644 --- a/apps/web/e2e/admin.spec.ts +++ b/apps/web/e2e/admin.spec.ts @@ -160,6 +160,7 @@ async function mockAdminSession( }> = {}, options: { adminUsers?: unknown[]; + adminTickets?: unknown; } = {}, ) { const seenAdminEndpoints = new Set(); @@ -268,18 +269,20 @@ async function mockAdminSession( if (method === "GET" && path.endsWith("/admin/tickets")) { seenAdminEndpoints.add("tickets"); - await fulfillJson({ - source: "database", - durable: true, - tickets: [], - summary: { - total: 0, - open_count: 0, - high_priority_count: 0, - stale_count: 0, - by_category: {}, + await fulfillJson( + options.adminTickets ?? { + source: "database", + durable: true, + tickets: [], + summary: { + total: 0, + open_count: 0, + high_priority_count: 0, + stale_count: 0, + by_category: {}, + }, }, - }); + ); return; } @@ -704,6 +707,36 @@ test.describe("admin route guards", () => { await expect(page.locator(".ad-diagnostic")).toContainText("active_sessions"); await expect(page.getByText("Bad User")).toBeVisible(); }); + + test("shows ticket payload diagnostics instead of a white page", async ({ page }) => { + await mockAdminSession( + page, + { + user_id: "ticket-diagnostic-admin", + email: "ticket-diagnostic-admin@twentyoz.kr", + display_name: "Ticket Diagnostic Admin", + role: "admin", + admin_access: true, + super_admin: true, + onboarding_completed_at: 1_782_900_000, + }, + { + adminTickets: { + source: "database", + durable: true, + tickets: [], + summary: null, + }, + }, + ); + + await page.goto("/admin/users"); + + await expect(page.getByRole("heading", { name: "가입 승인과 권한 관리" })).toBeVisible(); + await expect(page.locator(".ad-diagnostic")).toContainText("관리자 데이터 진단"); + await expect(page.locator(".ad-diagnostic")).toContainText("admin.tickets.summary"); + await expect(page.locator("body")).not.toHaveText(/^$/); + }); }); test.describe("admin route", () => { diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 03d0b2f..5b4e68c 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -15,7 +15,7 @@ 보호 라우트는 AuthContext 기반 간단 가드. 과설계 금지. ===================================================================== */ -import { useEffect, type ReactNode } from "react"; +import { Component, useEffect, type ErrorInfo, type ReactNode } from "react"; import { BrowserRouter, Navigate, Route, Routes, useLocation } from "react-router-dom"; import { AuthProvider, canAccessRole, initialPathForUser, useAuth, roleHomePath, type AuthUser, type Role } from "./lib/auth"; @@ -141,6 +141,150 @@ function ScrollToTopOnPathChange() { return null; } +function runtimeAssetLabel(): string { + if (typeof document === "undefined") return "unknown"; + const script = Array.from(document.scripts) + .map((item) => item.getAttribute("src") ?? "") + .find((src) => src.includes("/assets/index-") && src.endsWith(".js")); + if (!script) return "unknown"; + return script.split("/").pop() ?? script; +} + +function RouteErrorFallback({ + error, + errorInfo, +}: { + error: Error; + errorInfo: ErrorInfo | null; +}) { + return ( +
+
+
+ + 화면 진단 + +

+ 화면을 표시하지 못했습니다 +

+

+ React 라우트 렌더 중 예외가 발생했습니다. 빈 화면 대신 원인 정보를 표시합니다. +

+
+
+ {[ + ["path", typeof window === "undefined" ? "unknown" : window.location.pathname], + ["error", error.message || error.name], + ["asset", runtimeAssetLabel()], + ["componentStack", errorInfo?.componentStack?.trim() || "not captured"], + ].map(([key, value]) => ( +
+
+ {key} +
+
+ {value} +
+
+ ))} +
+
+
+ ); +} + +class RouteErrorBoundary extends Component< + { resetKey: string; children: ReactNode }, + { error: Error | null; errorInfo: ErrorInfo | null } +> { + state = { error: null, errorInfo: null }; + + static getDerivedStateFromError(error: Error) { + return { error, errorInfo: null }; + } + + componentDidCatch(error: Error, errorInfo: ErrorInfo) { + this.setState({ errorInfo }); + console.error("[route-render-error]", error, errorInfo.componentStack); + } + + componentDidUpdate(prevProps: { resetKey: string; children: ReactNode }) { + if (prevProps.resetKey !== this.props.resetKey && this.state.error) { + this.setState({ error: null, errorInfo: null }); + } + } + + render() { + if (this.state.error) { + return ; + } + return this.props.children; + } +} + +function AppRoutesWithBoundary() { + const { pathname } = useLocation(); + return ( + + + + ); +} + function AppRoutes() { return ( @@ -311,7 +455,7 @@ export default function App() { - + ); diff --git a/apps/web/src/pages/Admin.tsx b/apps/web/src/pages/Admin.tsx index bbe0d75..2b56de9 100644 --- a/apps/web/src/pages/Admin.tsx +++ b/apps/web/src/pages/Admin.tsx @@ -451,6 +451,55 @@ function cohortIdsField( return normalized; } +function numberRecordField( + source: Record, + field: string, + warnings: AdminDataWarning[], + label: string, +): Record { + const value = source[field]; + if (value == null) { + warnings.push({ + key: `${label}.${field}`, + message: `${label} 응답에서 ${field} 값이 비어 있어 빈 집계로 표시합니다.`, + detail: `${field}=null`, + }); + return {}; + } + if (typeof value !== "object" || Array.isArray(value)) { + warnings.push({ + key: `${label}.${field}`, + message: `${label} 응답에서 ${field} 타입이 잘못되어 빈 집계로 표시합니다.`, + detail: `${field}=${typeof value}`, + }); + return {}; + } + const entries = Object.entries(value as Record); + const normalized: Record = {}; + for (const [key, raw] of entries) { + if (typeof raw === "number" && Number.isFinite(raw)) { + normalized[key] = raw; + continue; + } + const parsed = typeof raw === "string" ? Number(raw) : Number.NaN; + if (Number.isFinite(parsed)) { + normalized[key] = parsed; + warnings.push({ + key: `${label}.${field}.${key}`, + message: `${label} 응답에서 ${field}.${key}가 문자열이라 숫자로 변환했습니다.`, + detail: `${field}.${key}=string`, + }); + continue; + } + warnings.push({ + key: `${label}.${field}.${key}`, + message: `${label} 응답에서 ${field}.${key} 숫자 값이 없어 제외했습니다.`, + detail: `${field}.${key}=${raw == null ? "null" : typeof raw}`, + }); + } + return normalized; +} + function roleField( source: Record, warnings: AdminDataWarning[], @@ -539,6 +588,52 @@ function normalizeAdminUsersResponse(rawResponse: AdminUsersResponse): { }; } +function normalizeAdminTicketsResponse(rawResponse: AdminTicketsResponse): { + response: AdminTicketsResponse; + warnings: AdminDataWarning[]; +} { + const warnings: AdminDataWarning[] = []; + const root = asRecord(rawResponse); + const source: AdminTicketsResponse["source"] = root.source === "database" ? "database" : "unavailable"; + const durable = typeof root.durable === "boolean" ? root.durable : source === "database"; + const rawTickets = Array.isArray(root.tickets) ? root.tickets : []; + if (!Array.isArray(root.tickets)) { + warnings.push({ + key: "admin.tickets", + message: "/admin/tickets 응답에서 tickets 배열이 없어 빈 목록으로 표시합니다.", + detail: `tickets=${root.tickets == null ? "null" : typeof root.tickets}`, + }); + } + + const summaryRoot = asRecord(root.summary); + if (!root.summary || typeof root.summary !== "object" || Array.isArray(root.summary)) { + warnings.push({ + key: "admin.tickets.summary", + message: "/admin/tickets 응답에서 summary 객체가 없어 기본 집계로 표시합니다.", + detail: `summary=${root.summary == null ? "null" : typeof root.summary}`, + }); + } + + return { + response: { + source, + durable, + generated_at: numberField(root, "generated_at", 0, warnings, "admin.tickets"), + tickets: rawTickets as AdminSupportTicket[], + summary: { + total: numberField(summaryRoot, "total", rawTickets.length, warnings, "admin.tickets.summary"), + open_count: numberField(summaryRoot, "open_count", 0, warnings, "admin.tickets.summary"), + high_priority_count: numberField(summaryRoot, "high_priority_count", 0, warnings, "admin.tickets.summary"), + stale_count: numberField(summaryRoot, "stale_count", 0, warnings, "admin.tickets.summary"), + by_category: numberRecordField(summaryRoot, "by_category", warnings, "admin.tickets.summary"), + by_priority: numberRecordField(summaryRoot, "by_priority", warnings, "admin.tickets.summary"), + by_status: numberRecordField(summaryRoot, "by_status", warnings, "admin.tickets.summary"), + }, + }, + warnings, + }; +} + function isOnline(seconds?: number | null): boolean { if (!seconds || seconds <= 0) return false; return Date.now() / 1000 - seconds < 15 * 60; @@ -774,6 +869,7 @@ export default function Admin({ section = "overview" }: AdminProps) { const [tickets, setTickets] = useState(null); const [ticketsLoading, setTicketsLoading] = useState(true); const [ticketsError, setTicketsError] = useState(null); + const [ticketsDiagnostics, setTicketsDiagnostics] = useState([]); const [updatingTicketId, setUpdatingTicketId] = useState(null); const [ticketStatusFilter, setTicketStatusFilter] = useState("all"); const [ticketCategoryFilter, setTicketCategoryFilter] = useState("all"); @@ -867,9 +963,13 @@ export default function Admin({ section = "overview" }: AdminProps) { search: ticketSearch, windowDays: 30, }; - setTickets(await adminApi.tickets(filters)); + const next = await adminApi.tickets(filters); + const { response: normalized, warnings } = normalizeAdminTicketsResponse(next); + setTickets(normalized); + setTicketsDiagnostics(warnings); } catch (err) { setTicketsError(err instanceof Error ? err.message : "운영 티켓을 불러오지 못했습니다."); + setTicketsDiagnostics([]); } finally { setTicketsLoading(false); } @@ -1199,20 +1299,23 @@ export default function Admin({ section = "overview" }: AdminProps) { const hasUserSearch = userSearch.trim().length > 0; const visibleUsers = filteredUsers.slice(0, MAX_RENDERED_USERS); const renderAdminDiagnostics = () => - usersDiagnostics.length > 0 ? ( + [...usersDiagnostics, ...ticketsDiagnostics].length > 0 ? ( [ + ...[...usersDiagnostics, ...ticketsDiagnostics].slice(0, 8).map((warning): [string, string] => [ warning.key, `${warning.message} (${warning.detail})`, ]), - ...(usersDiagnostics.length > 8 - ? [["more", `${usersDiagnostics.length - 8}개 추가 진단 생략`] as [string, string]] + ...(usersDiagnostics.length + ticketsDiagnostics.length > 8 + ? [["more", `${usersDiagnostics.length + ticketsDiagnostics.length - 8}개 추가 진단 생략`] as [ + string, + string, + ]] : []), ]} /> diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index d383c3a..675c28b 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -30,7 +30,7 @@ - M3 인증 claim 1차: Google/SAML/dev-login이 설정 기반 cohort map과 SAML cohort claim을 `cohort_ids`로 넘기고, 관리 사용자 `external_id`는 provider subject 기반(`google:`/`saml:`/`dev:`)으로 저장한다. 운영 SAML 서명검증·기관 claim schema·deprovisioning audit은 후속. - 페르소나 저작/P4~P7 적재 2차: `data/personas/P4.json`~`P7.json`을 저장소 관리 `PersonaCard`로 읽어 `materialize_seed_personas()`와 seed fallback catalog에 포함했다. 검증: `python -m pytest app/test_persona_review.py -q` 22 passed, 로컬 dev DB materialize 결과 `approved_codes=P1,P2,P3,P4,P5,P6,P7`, 인증된 로컬 `GET /personas`도 `P1..P7` 반환. - Google OAuth 진단 1차: provider callback error는 `access_denied`/`provider_error`로 분리하고, 로그인 화면은 실패 reason code를 함께 표시한다. 실제 Google 계정 완료 proof는 아직 owner 로그인/storageState가 필요하다. -- 관리자 기본 진입 경로와 blank diagnostics: `initialPathForUser()`가 pending/onboarding/admin-entitled landing을 소유한다. 관리자 콘솔 접근권이 있는 계정은 primary role이 learner/teacher여도 `/` 또는 로그인 완료 뒤 `/admin`으로 들어간다. `App.tsx`는 pathname 변경마다 document scroll을 0으로 복원해 긴 관리자 화면에서 짧은 `/admin/access`로 이동해도 sticky shell만 보이는 빈 화면을 만들지 않는다. 관리자 화면은 `/admin/users` payload 계약 위반을 안전하게 정규화하고 `관리자 데이터 진단`으로 깨진 필드/path/asset을 표시하며, route render error와 0-height main도 진단 패널로 대체한다. 검증: `npm run typecheck`, `npm run build`, `npx playwright test e2e/admin.spec.ts --project=chromium-desktop --workers=1 --grep "admin route guards"` 4 passed, custom domain 새 asset 확인. +- 관리자 기본 진입 경로와 blank diagnostics: `initialPathForUser()`가 pending/onboarding/admin-entitled landing을 소유한다. 관리자 콘솔 접근권이 있는 계정은 primary role이 learner/teacher여도 `/` 또는 로그인 완료 뒤 `/admin`으로 들어간다. `App.tsx`는 pathname 변경마다 document scroll을 0으로 복원해 긴 관리자 화면에서 짧은 `/admin/access`로 이동해도 sticky shell만 보이는 빈 화면을 만들지 않는다. 관리자 화면은 `/admin/users`와 `/admin/tickets` payload 계약 위반을 안전하게 정규화하고 `관리자 데이터 진단`으로 깨진 필드/path/asset을 표시한다. 앱 route-level error boundary도 렌더 예외를 full white page 대신 진단 화면으로 대체하며, 0-height main도 진단 패널로 노출한다. 검증: `npm run typecheck`, `npm run build`, `npx playwright test e2e/admin.spec.ts --project=chromium-desktop --workers=1 --grep "admin route guards"` 5 passed, custom domain 새 asset 확인. - `frontenddesign` 스킬은 현재 세션의 사용 가능 스킬 목록에 없었다. 대신 `docs/DESIGN_CONCEPT.md`를 SSOT로 사용했다. - 공개 API 터널은 현재 `C:\Users\encep\.cloudflared\vignette-config.yml`에서 `http://127.0.0.1:8001`을 본다. - 공개용 API 프로세스는 `127.0.0.1:8001`에서 `ENVIRONMENT=prod`로 떠 있다. 로컬 개발 API `127.0.0.1:8000`과 Tailnet 개발 API `127.0.0.1:8010`도 `ENVIRONMENT=dev`로 떠 있다. diff --git a/docs/dev_dashboard.html b/docs/dev_dashboard.html index fb31bb4..867e817 100644 --- a/docs/dev_dashboard.html +++ b/docs/dev_dashboard.html @@ -961,9 +961,9 @@ Engine session reuseengine_gateway.test_gateway_model27 tests OK; shared engine contract, JSON Schema + golden fixture validation, Node.js artifact conformance runner, GenerateResponse response validation, structured payload fallback parser, current-turn prompt split, direct evaluator/live-coach parser ownership, SSE frames/decoder, live session_id reuse, gateway-default 기본 라우팅 sentinel 정규화, missing-user 400 guard, ephemeral close fixed P1/H4 masking gateapp/test_pii_masking_eval.py app/test_orchestrator_masking.py app/test_evaluation_persistence.py app/test_session_turn_persistence.py / PLAYWRIGHT_PORT=5244 npx playwright test e2e/session-persistence.spec.ts --project=chromium-single-run --workers=1 --grep "Korean PII"47 passed; 최신 보정 회귀 59 passed + DB-backed browser PII E2E 1 passed. phone/email/RRN 및 한국어 NAME/ORG raw 값이 generate/stream/evaluator payload와 client text_masked에 남지 않음. Optional ko recognizer fake span은 [NAME]/[ORG]로 마스킹되고 같은 문장의 phone은 후단 regex가 처리하며, adapter 실패 시에도 regex fallback이 유지된다. Synthetic ko fixture 16/16 pass, 자연 발화형 이름 라벨·자기소개와 negative control 포함, input/report schema validation, summary-only report(evidence_text_included=false), entity recall 1.0, forbidden substring removal 1.0, unexpected entity violations 0. 최신 브라우저 stream E2E는 한국어 이름·기관·전화번호 발화가 DB-backed GET /sessions/{id} 상세와 /review 모두에서 raw 값 없이 [NAME]/[ORG]/[PHONE]으로 남는지 확인한다. P2a RBAC/audit/visibilityapp.test_rbac_idor11 tests OK; other learner 403, read_session audit, evaluator-only hidden, teacher session review read allowed, super_admin learner review principal is promoted to admin for evaluation/worksheet/review-status loaders, admin_access-only learner remains blocked, and learner worksheet write remains 403 - Admin access delegationC:\Users\encep\AppData\Local\Programs\Python\Python311\python.exe -X utf8 -m pytest -p no:cacheprovider app/test_admin_ops.py app/test_auth_providers.py::AuthProviderScaffoldTest::test_admin_role_can_enter_admin_console_even_with_stale_access_flag app/test_auth_providers.py::AuthProviderScaffoldTest::test_admin_role_can_enter_all_role_spaces_without_super_admin app/test_auth_providers.py::AuthProviderScaffoldTest::test_admin_access_flag_without_admin_role_does_not_enter_learner_space -q / npx playwright test e2e/admin.spec.ts --project=chromium-desktop --project=chromium-mobile --workers=1 --grep "approved admin without onboarding" / npx playwright test e2e/admin.spec.ts --project=chromium-desktop --workers=1 --grep "admin route guards" / npm run typecheck13 backend focused passed + admin onboarding guard E2E 2 passed + latest admin route guards 4 passed + web typecheck/build passed. 실제 admin role은 admin_access=false 세션이어도 can_access_role(Role.ADMIN)으로 관리자 API를 열고, 승인된 관리자 또는 admin_access 계정은 /admin*에서 학습자 온보딩으로 우회하지 않는다. 최초 진입 경로도 initialPathForUser가 소유해 primary role이 learner/teacher인 관리자 권한 계정이 / 또는 로그인 완료 후 /admin으로 들어간다. 관리자 sidebar와 기존 workspace 전환 계약은 유지된다. + Admin access delegationC:\Users\encep\AppData\Local\Programs\Python\Python311\python.exe -X utf8 -m pytest -p no:cacheprovider app/test_admin_ops.py app/test_auth_providers.py::AuthProviderScaffoldTest::test_admin_role_can_enter_admin_console_even_with_stale_access_flag app/test_auth_providers.py::AuthProviderScaffoldTest::test_admin_role_can_enter_all_role_spaces_without_super_admin app/test_auth_providers.py::AuthProviderScaffoldTest::test_admin_access_flag_without_admin_role_does_not_enter_learner_space -q / npx playwright test e2e/admin.spec.ts --project=chromium-desktop --project=chromium-mobile --workers=1 --grep "approved admin without onboarding" / npx playwright test e2e/admin.spec.ts --project=chromium-desktop --workers=1 --grep "admin route guards" / npm run typecheck13 backend focused passed + admin onboarding guard E2E 2 passed + latest admin route guards 5 passed + web typecheck/build passed. 실제 admin role은 admin_access=false 세션이어도 can_access_role(Role.ADMIN)으로 관리자 API를 열고, 승인된 관리자 또는 admin_access 계정은 /admin*에서 학습자 온보딩으로 우회하지 않는다. 최초 진입 경로도 initialPathForUser가 소유해 primary role이 learner/teacher인 관리자 권한 계정이 / 또는 로그인 완료 후 /admin으로 들어간다. 관리자 sidebar와 기존 workspace 전환 계약은 유지된다. SPA route scroll resetnpm run typecheck / npx playwright test e2e/admin.spec.ts --project=chromium-desktop --workers=1 --grep "resets scroll" / Pages deployTypecheck passed and focused E2E 1 passed. App.tsx resets document scroll to top on pathname changes, so moving from a long admin page to short /admin/access no longer preserves stale scrollY and shows only the sticky topbar/sidebar over an empty main area. Probe proof: previous scrollY=1000, after clicking 권한 scrollY=0, heading 역할, 그룹, 접근 범위 in viewport. Live custom domain now serves assets/index-DY61vYjT.js/assets/index-DzdAkRsn.css. - Admin silent-blank diagnosticsnpm run typecheck / npx playwright test e2e/admin.spec.ts --project=chromium-desktop --workers=1 --grep "admin route guards"Typecheck passed and route-guard E2E 4 passed. Admin now normalizes malformed /admin/users payload fields such as cohort_ids=null, active_sessions=null, and created_at=null instead of throwing during render. The screen shows 관리자 데이터 진단 with section/path/asset and broken field keys, while route render errors and zero-height main content fall back to a diagnostic panel instead of a blank shell. + Admin silent-blank diagnosticsnpm run typecheck / npx playwright test e2e/admin.spec.ts --project=chromium-desktop --workers=1 --grep "admin route guards"Typecheck passed and route-guard E2E 5 passed. Admin now normalizes malformed /admin/users payload fields such as cohort_ids=null, active_sessions=null, and created_at=null, plus malformed /admin/tickets payloads such as summary=null, instead of throwing during render. The screen shows 관리자 데이터 진단 with section/path/asset and broken field keys. App-level route render errors now fall back to a full-page diagnostic instead of a white screen, and zero-height main content still falls back to the admin diagnostic panel. OAuth/SAML admin landingC:\Users\encep\AppData\Local\Programs\Python\Python311\python.exe -X utf8 -m pytest -p no:cacheprovider app/test_auth_providers.py -q / public runtime restart36 passed; OAuth/SAML callback now normalizes generic saved next paths such as /learn and /teach to /admin for admin-entitled users while preserving deep links such as /learn/session/.... Live proof after commits 998acb44/6b6241f4 and latest admin diagnostic deploy 50524f14: local/public /health returned prod, db=true, engine=true; custom domain now serves assets/index-DY61vYjT.js/assets/index-DzdAkRsn.css; unauthenticated /personas remains 401. Session read-model DB readinesspython scripts\check-deploy-preflight.py --skip-db --env-file infra\.env.example --allow-placeholder-secrets / https://api-vignette.chanpaca.net/health2026-06-29 prod 503 원인은 운영 DB의 app.turns.provider_events 컬럼 누락이었다. 운영 DB hotfix 후 /teacher/dashboard code path는 source=database로 복구됐다. 현재 db.healthcheck(), runtime table readiness, deploy preflight DB mode는 app.turns 음성 메타 컬럼 5개(audio_ref, silence_ms, speech_rate, barge_in, provider_events), app.session_review_status worksheet 컬럼, app.safety_events 필수 컬럼을 함께 검증한다. 2026-06-30에는 Docker Desktop/DB 중단으로 public API 530/error code 1033이 재발했지만, Docker Desktop/DB 재기동 뒤 public health가 environment=prod, db=true, engine=true로 복구됐다. Admin usage persistencepython -m pytest app/test_admin_ops.py app/test_runtime_policy.py -q / authenticated local public-API smoke26 passed; /admin/usage returns 200 with source=database, durable=true. Cloudflare blocked raw Python public smoke with 1010, so app-level HTTP was verified against 127.0.0.1:8001 using the same prod process. diff --git a/docs/guides/architecture.md b/docs/guides/architecture.md index 06c9f3f..a2d529e 100644 --- a/docs/guides/architecture.md +++ b/docs/guides/architecture.md @@ -727,9 +727,11 @@ DB 레벨 이중강제(`04_audit_eval_rls.sql` §5, `app/db.py` `acquire()`): - SPA 라우트 전환은 `App.tsx`가 `pathname` 변경마다 문서 스크롤을 맨 위로 복원한다. 긴 관리자 목록에서 짧은 `/admin/access` 정책 화면으로 이동할 때 이전 `scrollY`가 유지되어 sticky 셸만 보이는 빈 화면을 막기 위한 계약이다. - 관리자 콘솔은 shell만 남고 본문이 비는 silent failure를 허용하지 않는다. `/admin/users` 응답의 - `cohort_ids`, `active_sessions`, `created_at` 같은 필드가 런타임 계약과 다르면 프론트가 안전한 기본값으로 - 정규화하고 `관리자 데이터 진단` 패널에 깨진 필드와 asset/path를 표시한다. 렌더 예외나 본문 0-height 상태도 - route-scoped diagnostic panel로 노출해 운영자가 원인 없이 빈칸만 보지 않게 한다. + `cohort_ids`, `active_sessions`, `created_at`와 `/admin/tickets` 응답의 `summary`, `by_status`, + `by_priority` 같은 필드가 런타임 계약과 다르면 프론트가 안전한 기본값으로 정규화하고 `관리자 데이터 진단` + 패널에 깨진 필드와 asset/path를 표시한다. 앱 route-level error boundary는 관리자 컴포넌트 바깥에서 + 터진 렌더 예외도 full white page 대신 진단 화면으로 노출하고, 본문 0-height 상태도 route-scoped + diagnostic panel로 표시해 운영자가 원인 없이 빈칸만 보지 않게 한다. - OAuth/SAML callback은 저장된 `next`가 일반 진입 경로(`/`, `/learn`, `/teach`, `/login`, `/onboarding`)이고 로그인 사용자가 관리자 콘솔 접근권을 가지면 `/admin`으로 정규화한다. 단, `/learn/session/...` 같은 깊은 링크는 사용자가 의도적으로 연 URL일 수 있으므로 보존한다.