Stabilize runtime auth and E2E coverage

This commit is contained in:
Yun Chan 2026-06-26 14:47:00 +09:00
parent 6a3e3b541c
commit 188e899394
133 changed files with 55987 additions and 6775 deletions

View file

@ -3,12 +3,22 @@
계약: apps/api/app/routes (auth.py, sessions.py).
- credentials:"include" (BFF __Host-vignette_sid HttpOnly ).
- ApiError .
- SSE: GET /sessions/{id}/stream token/done/ping/error .
- SSE: POST /sessions/{id}/stream token/done/ping/error .
===================================================================== */
// Vite 환경변수. 기본 "/api" (vite proxy 또는 nginx 가 백엔드로 라우팅).
const API_BASE: string =
(import.meta.env.VITE_API_BASE as string | undefined) ?? "/api";
function defaultApiBase(): string {
if (typeof window !== "undefined") {
const host = window.location.hostname;
if (host === "vignette.chanpaca.net" || host.endsWith(".pages.dev")) {
return "https://api-vignette.chanpaca.net";
}
}
return "/api";
}
const configuredApiBase = (import.meta.env.VITE_API_BASE as string | undefined)?.trim();
const API_BASE: string = configuredApiBase || defaultApiBase();
export class ApiError extends Error {
readonly status: number;
@ -43,6 +53,16 @@ function joinUrl(path: string): string {
return `${base}${p}`;
}
export function apiUrl(path: string): string {
return joinUrl(path);
}
export function apiWsUrl(path: string): string {
const url = new URL(joinUrl(path), window.location.origin);
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
return url.toString();
}
async function parseError(res: Response): Promise<ApiError> {
let detail = res.statusText || "request failed";
let body: unknown = undefined;
@ -126,19 +146,47 @@ export const api = {
/** GET /auth/me — auth.py MeResponse */
export interface MeResponse {
user_id: string;
email: string;
display_name: string;
role: string; // "learner" | "teacher" | "admin"
cohort_ids: string[];
}
export interface AuthConfigResponse {
google_oauth_configured: boolean;
allowed_email_domains: string[];
redirect_uri: string;
dev_login_enabled: boolean;
}
export const authApi = {
config: () => api.get<AuthConfigResponse>("/auth/config"),
};
export type SessionStage = "라포" | "탐색" | "개입" | "정리";
/** GET /personas — personas.py PersonaSummary */
export interface PersonaSummary {
code: string;
display_name: string;
difficulty: "easy" | "moderate" | "hard" | string;
theory_target: string[];
demographics: Record<string, unknown>;
presenting_summary: string;
voice_preset: string | null;
source: string;
degraded: boolean;
}
/** POST /sessions — sessions.py SessionStartResponse */
export interface SessionStartResponse {
session_id: string;
case_id: string;
session_no: number;
stage: SessionStage;
effective_openness: number;
recall_summary: string | null;
degraded: boolean;
}
/** POST /sessions/{id}/turn — sessions.py TurnResponse */
@ -157,17 +205,144 @@ export interface SessionEndResponse {
digest_pending: boolean;
}
export interface LearnerSessionSummary {
session_id: string;
persona_code: string;
persona_name: string;
session_no: number;
status: "active" | "ended";
stage: string;
turn_count: number;
learner_turn_count: number;
client_turn_count: number;
started_at: string;
ended_at: string | null;
review_ready: boolean;
}
export interface LearnerSessionsResponse {
source: string;
sessions: LearnerSessionSummary[];
}
export interface SessionDetailTurn {
turn_seq: number;
speaker: "learner" | "client";
stage: string;
text: string;
created_at: string;
}
export interface SessionDetailResponse {
session_id: string;
case_id: string;
persona_code: string;
persona_name: string;
theory_mode: string;
status: "active" | "ended";
stage: SessionStage;
effective_openness: number;
started_at: string;
ended_at: string | null;
turns: SessionDetailTurn[];
review_ready: boolean;
}
export interface ReviewClient {
name: string;
initial: string;
persona: string;
}
export interface ReviewTechnique {
kind: string;
label: string;
}
export interface ReviewNote {
author: "ai" | "instructor" | string;
tone: "good" | "watch";
title: string;
body: string;
quote?: string | null;
}
export interface ReviewTurn {
id: string;
ts: string;
speaker: "learner" | "client";
who: string;
text: string;
techniques: ReviewTechnique[];
note?: ReviewNote | null;
}
export interface ReviewPhaseSegment {
key: string;
label: string;
weight: number;
}
export interface ReviewValencePoint {
t: number;
v: number;
}
export interface ReviewRubricRow {
name: string;
cluster: string;
ratio: number;
quality: "good" | "watch";
freq: string;
}
export interface ReviewPoint {
title: string;
body: string;
jumpTo?: string | null;
}
export interface SessionReviewResponse {
session_id: string;
client: ReviewClient;
date: string;
durationLabel: string;
durationSeconds: number;
reachedPhase: string;
sessionSignal: string;
supervisorState: string;
supervisorName: string;
summary: string;
phases: ReviewPhaseSegment[];
phaseAxis: string[];
valenceAxis: string[];
clientValence: ReviewValencePoint[];
counselorBaseline: ReviewValencePoint[];
turns: ReviewTurn[];
rubric: ReviewRubricRow[];
goodMoments: ReviewPoint[];
growthPoints: ReviewPoint[];
nextLine?: string | null;
clientFeedback?: string | null;
audioUrl?: string | null;
pdfExportUrl?: string | null;
degraded: boolean;
reviewReady: boolean;
}
/* =====================================================================
SSE GET /sessions/{id}/stream
SSE POST /sessions/{id}/stream
(sse_starlette): "token" | "done" | "ping" | "safety" | "error"
EventSource same-origin BFF .
EventSource fetch stream .
===================================================================== */
export interface SessionStreamHandlers {
/** 서버가 요청을 수락했고 learner turn 이 저장 가능한 지점 */
onOpen?: () => void;
/** 내담자 AI 토큰 1조각 */
onToken?: (chunk: string) => void;
/** 스트림 정상 종료 */
onDone?: (data: { session_id: string }) => void;
onDone?: (data: SessionStreamDone) => void;
/** 안전(위기) 신호 */
onSafety?: (data: unknown) => void;
/** 에러 이벤트(백엔드 EngineError) 또는 연결 오류 */
@ -176,9 +351,12 @@ export interface SessionStreamHandlers {
onPing?: () => void;
}
export interface SessionStreamHandle {
/** 스트림 종료(EventSource close) */
close: () => void;
export interface SessionStreamDone {
session_id: string;
stage?: SessionStage;
effective_openness?: number;
turn_seq?: number;
safety_flagged?: boolean;
}
function safeParse(data: string): unknown {
@ -190,61 +368,285 @@ function safeParse(data: string): unknown {
}
/**
* AI SSE .
* @returns close() . close .
* AI SSE .
* learner turn token/done .
*/
export function openSessionStream(
export async function openSessionStream(
sessionId: string,
text: string,
handlers: SessionStreamHandlers,
): SessionStreamHandle {
const url = joinUrl(`/sessions/${encodeURIComponent(sessionId)}/stream`);
// withCredentials: same-origin 쿠키 전송 (BFF). cross-origin SSE 는 CORS 필요.
const es = new EventSource(url, { withCredentials: true });
): Promise<SessionStreamDone> {
const res = await fetch(joinUrl(`/sessions/${encodeURIComponent(sessionId)}/stream`), {
method: "POST",
credentials: "include",
headers: {
Accept: "text/event-stream",
"Content-Type": "application/json",
},
body: JSON.stringify({ text }),
});
// addEventListener 의 커스텀 이벤트 리스너 시그니처는 Event 를 받으므로
// MessageEvent 로 안전하게 좁힌다(.data 접근).
const dataOf = (ev: Event): string | undefined =>
(ev as MessageEvent).data as string | undefined;
if (!res.ok) {
throw await parseError(res);
}
if (!res.body) {
throw new ApiError(res.status, "스트림 응답 본문이 없습니다.");
}
es.addEventListener("token", (ev: Event) => {
const data = dataOf(ev);
if (data != null) handlers.onToken?.(data);
});
es.addEventListener("done", (ev: Event) => {
const parsed = safeParse(dataOf(ev) ?? "{}") as { session_id?: string };
handlers.onDone?.({ session_id: parsed.session_id ?? sessionId });
es.close();
});
es.addEventListener("safety", (ev: Event) => {
handlers.onSafety?.(safeParse(dataOf(ev) ?? "null"));
});
es.addEventListener("ping", () => {
handlers.onPing?.();
});
es.addEventListener("error", (ev: Event) => {
// sse_starlette 의 명시적 error 이벤트는 data 를 가짐.
// 브라우저 연결 오류 이벤트는 data 가 없음 → 일반 연결 오류로 처리.
const data = dataOf(ev);
if (data) {
const parsed = safeParse(data) as { detail?: string };
handlers.onError?.({ detail: parsed.detail ?? "stream error" });
} else if (es.readyState === EventSource.CLOSED) {
handlers.onError?.({ detail: "스트림 연결이 종료되었습니다." });
handlers.onOpen?.();
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let eventName = "message";
let dataLines: string[] = [];
let donePayload: SessionStreamDone | null = null;
let streamError: ApiError | null = null;
const dispatch = () => {
if (!eventName && dataLines.length === 0) return;
const data = dataLines.join("\n");
const event = eventName || "message";
eventName = "message";
dataLines = [];
if (event === "token") {
handlers.onToken?.(data);
return;
}
if (event === "done") {
const parsed = safeParse(data || "{}") as Partial<SessionStreamDone>;
donePayload = {
session_id: parsed.session_id ?? sessionId,
stage: parsed.stage,
effective_openness: parsed.effective_openness,
turn_seq: parsed.turn_seq,
safety_flagged: parsed.safety_flagged,
};
handlers.onDone?.(donePayload);
return;
}
if (event === "safety") {
handlers.onSafety?.(safeParse(data || "null"));
return;
}
if (event === "ping") {
handlers.onPing?.();
return;
}
if (event === "error") {
const parsed = safeParse(data || "{}") as { detail?: string };
const detail = parsed.detail ?? "stream error";
handlers.onError?.({ detail });
streamError = new ApiError(503, detail, parsed);
}
});
return {
close: () => es.close(),
};
const processLine = (line: string) => {
if (line === "") {
dispatch();
return;
}
if (line.startsWith(":")) return;
const idx = line.indexOf(":");
const field = idx === -1 ? line : line.slice(0, idx);
const rawValue = idx === -1 ? "" : line.slice(idx + 1);
const value = rawValue.startsWith(" ") ? rawValue.slice(1) : rawValue;
if (field === "event") eventName = value;
else if (field === "data") dataLines.push(value);
};
const processBuffer = (final = false) => {
const lines = buffer.split(/\r?\n/);
buffer = final ? "" : (lines.pop() ?? "");
for (const line of lines) processLine(line.endsWith("\r") ? line.slice(0, -1) : line);
if (final && buffer) processLine(buffer);
if (final && dataLines.length > 0) dispatch();
};
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
processBuffer();
if (streamError) break;
}
buffer += decoder.decode();
processBuffer(true);
if (streamError) throw streamError;
return donePayload ?? { session_id: sessionId };
}
/* === 세션 API 헬퍼 (Features 단계 Session 페이지가 사용) === */
export const personaApi = {
list: () => api.get<PersonaSummary[]>("/personas"),
};
export const sessionApi = {
list: () => api.get<LearnerSessionsResponse>("/sessions"),
get: (sessionId: string) =>
api.get<SessionDetailResponse>(`/sessions/${encodeURIComponent(sessionId)}`),
start: (persona_code: string, theory_mode: "humanistic" | "cbt" | "integrative" = "humanistic") =>
api.post<SessionStartResponse>("/sessions", { persona_code, theory_mode }),
turn: (sessionId: string, text: string) =>
api.post<TurnResponse>(`/sessions/${encodeURIComponent(sessionId)}/turn`, { text }),
end: (sessionId: string) =>
api.post<SessionEndResponse>(`/sessions/${encodeURIComponent(sessionId)}/end`),
review: (sessionId: string) =>
api.get<SessionReviewResponse>(`/sessions/${encodeURIComponent(sessionId)}/review`),
stream: openSessionStream,
};
export type AdminHealthStatus = "ok" | "degraded" | "down";
export interface AdminServiceHealth {
key: string;
name: string;
status: AdminHealthStatus;
detail: string;
metric: string;
load: number;
}
export interface AdminHealthResponse {
status: AdminHealthStatus;
environment: string;
engine_mode: string;
services: AdminServiceHealth[];
}
export const adminApi = {
health: () => api.get<AdminHealthResponse>("/admin/health"),
};
export interface AdminManagedUser {
user_id: string;
email: string;
display_name: string;
role: "learner" | "teacher" | "admin";
cohort_ids: string[];
affiliation: string;
active_sessions: number;
created_at: number;
last_seen_at: number;
source: "database" | "server_session_registry";
}
export interface AdminUsersResponse {
source: "database" | "server_session_registry";
durable: boolean;
users: AdminManagedUser[];
}
export type AdminUserCreateRequest = Pick<
AdminManagedUser,
"email" | "display_name" | "role" | "affiliation" | "cohort_ids"
>;
export const adminUsersApi = {
list: () => api.get<AdminUsersResponse>("/admin/users"),
create: (body: AdminUserCreateRequest) =>
apiFetch<AdminManagedUser>("/admin/users", { method: "POST", body }),
update: (
userId: string,
body: Partial<Pick<AdminManagedUser, "display_name" | "role" | "affiliation" | "cohort_ids">>,
) => apiFetch<AdminManagedUser>(`/admin/users/${encodeURIComponent(userId)}`, {
method: "PATCH",
body,
}),
deactivate: (userId: string) =>
apiFetch<{ ok: boolean; user_id: string }>(`/admin/users/${encodeURIComponent(userId)}`, {
method: "DELETE",
}),
};
export interface TeacherSessionSummary {
session_id: string;
learner_id: string;
learner_label: string;
persona_code: string;
persona_name: string;
session_no: number;
status: "active" | "ended" | string;
stage: string;
turn_count: number;
learner_turn_count: number;
client_turn_count: number;
started_at: string;
ended_at: string | null;
}
export interface TeacherDashboardResponse {
source: string;
cohort_label: string;
total_learners: number;
active_sessions: number;
ended_sessions: number;
pending_reviews: TeacherSessionSummary[];
recent_sessions: TeacherSessionSummary[];
message: string;
}
export const teacherApi = {
dashboard: () => api.get<TeacherDashboardResponse>("/teacher/dashboard"),
};
export interface UserProfileResponse {
user_id: string;
email: string;
display_name: string;
role: RoleString;
cohort_ids: string[];
affiliation: string;
}
export interface NotificationPreferences {
session_done: boolean;
safety_signal: boolean;
learner_progress: boolean;
product_news: boolean;
}
export interface UserPreferencesResponse {
theme: "system" | "light" | "dark" | string;
voice_preset_id: string;
voice_rate: number;
notifications: NotificationPreferences;
}
export interface VoicePresetResponse {
id: string;
voice_id: string;
name: string;
desc: string;
persona_hint: string;
}
export type RoleString = "learner" | "teacher" | "admin" | string;
export const userApi = {
me: () => api.get<UserProfileResponse>("/users/me"),
updateMe: (body: { display_name?: string; affiliation?: string }) =>
apiFetch<UserProfileResponse>("/users/me", { method: "PATCH", body }),
preferences: () => api.get<UserPreferencesResponse>("/users/me/preferences"),
updatePreferences: (body: Partial<UserPreferencesResponse>) =>
apiFetch<UserPreferencesResponse>("/users/me/preferences", { method: "PATCH", body }),
voicePresets: () => api.get<VoicePresetResponse[]>("/users/me/voice-presets"),
};
export interface AdminEngineConfigResponse {
engine_mode: string;
engine_url: string;
model: string;
updated_by: string | null;
updated_at: number | null;
durable: boolean;
source: "database" | "runtime_cache" | "runtime_default" | string;
}
export const adminEngineApi = {
get: () => api.get<AdminEngineConfigResponse>("/admin/engine-config"),
update: (body: Partial<Pick<AdminEngineConfigResponse, "engine_mode" | "engine_url" | "model">>) =>
apiFetch<AdminEngineConfigResponse>("/admin/engine-config", { method: "PATCH", body }),
};

View file

@ -1,13 +1,3 @@
/* =====================================================================
Vignette AuthContext
- user/role , login/logout.
- mock ( ) + /auth/me ( ).
- role <body data-role>·data-theme tokens.css §6.2 accent .
Role enum(deps.py): learner | teacher | admin.
accent(DESIGN_CONCEPT §6.2): learner | instructor | admin.
teacher data-role="instructor" (-).
===================================================================== */
import {
createContext,
useCallback,
@ -17,16 +7,14 @@ import {
useState,
type ReactNode,
} from "react";
import { api, ApiError, type MeResponse } from "./api";
import { api, type MeResponse } from "./api";
/** 인증·인가 도메인 역할 (백엔드 deps.py Role 미러). */
export type Role = "learner" | "teacher" | "admin";
/** tokens.css §6.2 accent 스왑용 data-role 값. */
export type DesignRole = "learner" | "instructor" | "admin";
export interface AuthUser {
userId: string;
email: string;
name: string;
role: Role;
cohortIds: string[];
@ -35,26 +23,19 @@ export interface AuthUser {
export interface AuthContextValue {
user: AuthUser | null;
role: Role | null;
/** 부트스트랩(/auth/me) 진행 여부 — 가드 라우트의 깜빡임 방지 */
loading: boolean;
/** 개발용 mock 로그인: 역할 선택으로 즉시 인증 상태 진입 */
login: (role: Role, opts?: { name?: string; userId?: string }) => void;
/** 로그아웃 — 서버 세션 무효화 시도 후 로컬 상태 클리어 */
login: (role: Role, opts?: { email?: string; displayName?: string }) => Promise<AuthUser>;
logout: () => Promise<void>;
}
const STORAGE_KEY = "vignette.dev-auth";
/** API role → 디자인 data-role 매핑. */
export function designRoleOf(role: Role): DesignRole {
return role === "teacher" ? "instructor" : role;
}
/** 역할별 한국어 컨텍스트 라벨 (톱바 좌측, §6.3). */
export function roleLabel(role: Role): string {
switch (role) {
case "learner":
return "학습 대시보드";
return "학습자 공간";
case "teacher":
return "교수 콘솔";
case "admin":
@ -62,7 +43,6 @@ export function roleLabel(role: Role): string {
}
}
/** 역할 진입 기본 경로. */
export function roleHomePath(role: Role): string {
switch (role) {
case "learner":
@ -74,50 +54,42 @@ export function roleHomePath(role: Role): string {
}
}
const ROLE_DEFAULT_NAME: Record<Role, string> = {
learner: "김수련",
teacher: "이교수",
admin: "운영자",
const DEV_EMAIL_BY_ROLE: Record<Role, string> = {
learner: "learner@hs.ac.kr",
teacher: "teacher@hs.ac.kr",
admin: "admin@twentyoz.kr",
};
const DEV_NAME_BY_ROLE: Record<Role, string> = {
learner: "학습자",
teacher: "교수자",
admin: "관리자",
};
const AuthContext = createContext<AuthContextValue | null>(null);
function loadStored(): AuthUser | null {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as AuthUser;
if (parsed && typeof parsed.role === "string") return parsed;
} catch {
/* 무시 */
}
return null;
function userFromMe(me: MeResponse): AuthUser {
return {
userId: me.user_id,
email: me.email,
name: me.display_name || me.email || me.user_id,
role: (me.role as Role) ?? "learner",
cohortIds: me.cohort_ids ?? [],
};
}
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<AuthUser | null>(() => loadStored());
const [user, setUser] = useState<AuthUser | null>(null);
const [loading, setLoading] = useState(true);
// 부트스트랩: 실서버 세션이 있으면 우선. 없으면(401/네트워크오류) 저장된 mock 유지.
useEffect(() => {
let alive = true;
(async () => {
try {
const me = await api.get<MeResponse>("/auth/me");
if (!alive) return;
const serverUser: AuthUser = {
userId: me.user_id,
name: me.user_id,
role: (me.role as Role) ?? "learner",
cohortIds: me.cohort_ids ?? [],
};
setUser(serverUser);
} catch (err) {
// 401(미인증) 또는 백엔드 미가동 → mock/로그아웃 상태 유지(에러 아님).
if (!(err instanceof ApiError) && !(err instanceof TypeError)) {
// 예기치 못한 오류는 콘솔로만 (UX 차단 안 함)
console.warn("[auth] /auth/me bootstrap failed", err);
}
if (alive) setUser(userFromMe(me));
} catch {
if (alive) setUser(null);
} finally {
if (alive) setLoading(false);
}
@ -127,42 +99,28 @@ export function AuthProvider({ children }: { children: ReactNode }) {
};
}, []);
// role → <body data-role> 반영 (accent 스왑). 미인증이면 속성 제거.
useEffect(() => {
const body = document.body;
if (user) {
body.setAttribute("data-role", designRoleOf(user.role));
} else {
body.removeAttribute("data-role");
}
if (user) body.setAttribute("data-role", designRoleOf(user.role));
else body.removeAttribute("data-role");
}, [user]);
const login = useCallback<AuthContextValue["login"]>((role, opts) => {
const next: AuthUser = {
userId: opts?.userId ?? `dev-${role}`,
name: opts?.name ?? ROLE_DEFAULT_NAME[role],
const login = useCallback<AuthContextValue["login"]>(async (role, opts) => {
const me = await api.post<MeResponse>("/auth/dev-login", {
email: opts?.email ?? DEV_EMAIL_BY_ROLE[role],
role,
cohortIds: [],
};
display_name: opts?.displayName ?? DEV_NAME_BY_ROLE[role],
});
const next = userFromMe(me);
setUser(next);
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
} catch {
/* 저장 실패 무시 */
}
return next;
}, []);
const logout = useCallback<AuthContextValue["logout"]>(async () => {
try {
await api.post("/auth/logout");
} catch {
// 서버 미가동/스텁이어도 로컬 클리어는 진행
}
setUser(null);
try {
localStorage.removeItem(STORAGE_KEY);
} catch {
/* 무시 */
} finally {
setUser(null);
}
}, []);
@ -177,7 +135,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
export function useAuth(): AuthContextValue {
const ctx = useContext(AuthContext);
if (!ctx) {
throw new Error("useAuth 는 <AuthProvider> 내부에서만 사용할 수 있습니다.");
throw new Error("useAuth must be used inside AuthProvider");
}
return ctx;
}