+ 면접 모드와 직군을 고르면 AI가 질문을 생성하고, 실시간으로 답변을 주고받습니다. +
+diff --git a/backend/openapi.json b/backend/openapi.json new file mode 100644 index 00000000..5d9bccbd --- /dev/null +++ b/backend/openapi.json @@ -0,0 +1,3175 @@ +{ + "openapi" : "3.1.0", + "info" : { + "title" : "OpenAPI definition", + "version" : "v0" + }, + "servers" : [ { + "url" : "http://localhost", + "description" : "Generated server url" + } ], + "tags" : [ { + "name" : "Internal: Analysis Triggers", + "description" : "X-Internal-API-Key 필요. e2e 검증/디버그용 강제 트리거. 실제 사용자 흐름은 POST /api/resumes, POST /api/repositories 가 자동 트리거." + }, { + "name" : "Internal: GitHub Token Delegation", + "description" : "X-Internal-API-Key 필요. AI 서버가 레포 분석 시점에 사용자 GitHub 토큰을 짧게 위임받기 위해 호출." + }, { + "name" : "Users", + "description" : "Authenticated user APIs" + }, { + "name" : "Internal: AI Request Logs", + "description" : "X-Internal-API-Key 필요. AI 가 LLM 호출별 토큰/지연시간을 기록." + }, { + "name" : "Voice Answer", + "description" : "음성 답변 업로드 (Phase 2 - 음성 면접)" + }, { + "name" : "Auth", + "description" : "GitHub OAuth login and authentication token APIs" + }, { + "name" : "Documents", + "description" : "분석 문서(이력서/레포 공통) 조회. 상세 조회 시 분석 마크다운의 presigned S3 URL 포함." + }, { + "name" : "Users (Stats)", + "description" : "사용자 면접 통계 — 총/완료 세션 수, 평균 점수, 최근 점수 추이." + }, { + "name" : "Internal: Session Messages", + "description" : "X-Internal-API-Key 필요. RealTime WS 답변 위임." + }, { + "name" : "Sessions", + "description" : "면접 세션 생성·조회·상태 전환. 컨텍스트(이력서/레포 분석 문서) 연결 포함." + }, { + "name" : "Session Feedback", + "description" : "세션 종합 피드백 (US-24)" + }, { + "name" : "Internal: Document Embeddings", + "description" : "X-Internal-API-Key 필요. AI 서버가 분석 결과 청크/임베딩을 pgvector 에 idempotent upsert 할 때 호출." + }, { + "name" : "User Consents", + "description" : "개인정보처리/이용약관/마케팅 동의 제출·이력·철회 (append-only audit)." + }, { + "name" : "Resumes", + "description" : "이력서 업로드/관리. 업로드 시 분석 파이프라인이 자동 트리거되며 결과는 /realtime/stream/me (DOC_STATE) 로 통지됨." + }, { + "name" : "Repositories", + "description" : "GitHub 레포 등록/관리. 등록 시 분석 파이프라인이 자동 트리거되며 결과는 /realtime/stream/me (REPO_STATE) 로 통지됨." + }, { + "name" : "Session Messages", + "description" : "면접 메시지 시퀀스 — 질문/답변 트리." + }, { + "name" : "Internal: Embedding Search", + "description" : "X-Internal-API-Key 필요. AI 서버가 질문/피드백 생성 시 컨텍스트 청크 검색 (pgvector cosine)." + } ], + "paths" : { + "/api/internal/documents/{documentId}/embeddings" : { + "put" : { + "tags" : [ "Internal: Document Embeddings" ], + "summary" : "분석 문서 청크 + 임베딩 upsert", + "description" : "(document_id, chunk_index) 기준 idempotent. 벡터 차원이 dim 과 다르면 400.", + "operationId" : "internalUpsertDocumentEmbeddings", + "parameters" : [ { + "name" : "documentId", + "in" : "path", + "required" : true, + "schema" : { + "type" : "integer", + "format" : "int64" + } + } ], + "requestBody" : { + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/UpsertRequest" + } + } + }, + "required" : true + }, + "responses" : { + "200" : { + "description" : "upsert 성공 (응답에 upsert 된 개수 포함)", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/UpsertResponse" + } + } + } + }, + "400" : { + "description" : "임베딩 페이로드 검증 실패 (dim 불일치 등)", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/UpsertResponse" + } + } + } + }, + "401" : { + "description" : "X-Internal-API-Key 인증 실패", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/UpsertResponse" + } + } + } + }, + "404" : { + "description" : "documentId 에 해당하는 analyzed_documents 없음", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/UpsertResponse" + } + } + } + } + } + } + }, + "/api/users/me/consents" : { + "get" : { + "tags" : [ "User Consents" ], + "summary" : "내 동의 이력 (최신순)", + "operationId" : "listConsents", + "responses" : { + "200" : { + "description" : "이력 목록", + "content" : { + "*/*" : { + "schema" : { + "type" : "array", + "items" : { + "$ref" : "#/components/schemas/ConsentResponse" + } + } + } + } + }, + "401" : { + "description" : "인증 실패", + "content" : { + "*/*" : { + "schema" : { + "type" : "array", + "items" : { + "$ref" : "#/components/schemas/ConsentResponse" + } + } + } + } + } + } + }, + "post" : { + "tags" : [ "User Consents" ], + "summary" : "동의 제출 (새 row 생성)", + "operationId" : "submitConsent", + "requestBody" : { + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/ConsentSubmitRequest" + } + } + }, + "required" : true + }, + "responses" : { + "201" : { + "description" : "동의 row 생성", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/ConsentResponse" + } + } + } + }, + "400" : { + "description" : "요청 검증 실패", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/ConsentResponse" + } + } + } + }, + "401" : { + "description" : "인증 실패", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/ConsentResponse" + } + } + } + } + } + } + }, + "/api/sessions" : { + "get" : { + "tags" : [ "Sessions" ], + "summary" : "내 세션 히스토리 (US-15)", + "operationId" : "listSessions", + "responses" : { + "200" : { + "description" : "세션 목록", + "content" : { + "*/*" : { + "schema" : { + "type" : "array", + "items" : { + "$ref" : "#/components/schemas/SessionResponse" + } + } + } + } + }, + "401" : { + "description" : "인증 실패", + "content" : { + "*/*" : { + "schema" : { + "type" : "array", + "items" : { + "$ref" : "#/components/schemas/SessionResponse" + } + } + } + } + } + } + }, + "post" : { + "tags" : [ "Sessions" ], + "summary" : "세션 생성 (US-13/14)", + "description" : "mode/jobCategory 필수. contextDocumentIds 로 분석 문서 N개 연결 (US-14). 분석 미완료 문서는 422.", + "operationId" : "createSession", + "requestBody" : { + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/SessionCreateRequest" + } + } + }, + "required" : true + }, + "responses" : { + "201" : { + "description" : "세션 생성 완료", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/SessionResponse" + } + } + } + }, + "400" : { + "description" : "요청 검증 실패", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/SessionResponse" + } + } + } + }, + "401" : { + "description" : "인증 실패", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/SessionResponse" + } + } + } + }, + "404" : { + "description" : "지정된 문서 없음", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/SessionResponse" + } + } + } + }, + "422" : { + "description" : "분석 미완료 문서를 컨텍스트로 지정", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/SessionResponse" + } + } + } + } + } + } + }, + "/api/sessions/{sessionId}/stream-token" : { + "post" : { + "tags" : [ "Sessions" ], + "summary" : "세션 채널 SSE/WS 용 리소스 스코프 stream token", + "operationId" : "createSessionStreamToken", + "parameters" : [ { + "name" : "sessionId", + "in" : "path", + "required" : true, + "schema" : { + "type" : "integer", + "format" : "int64" + } + } ], + "responses" : { + "200" : { + "description" : "토큰 발급", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/StreamTokenResponse" + } + } + } + }, + "401" : { + "description" : "인증 실패", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/StreamTokenResponse" + } + } + } + }, + "404" : { + "description" : "세션 없음 / 소유자 아님", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/StreamTokenResponse" + } + } + } + } + } + } + }, + "/api/sessions/{sessionId}/messages" : { + "get" : { + "tags" : [ "Session Messages" ], + "summary" : "세션 메시지 시퀀스 (US-20)", + "operationId" : "listSessionMessages", + "parameters" : [ { + "name" : "sessionId", + "in" : "path", + "required" : true, + "schema" : { + "type" : "integer", + "format" : "int64" + } + } ], + "responses" : { + "200" : { + "description" : "메시지 목록", + "content" : { + "*/*" : { + "schema" : { + "type" : "array", + "items" : { + "$ref" : "#/components/schemas/MessageResponse" + } + } + } + } + }, + "401" : { + "description" : "인증 실패", + "content" : { + "*/*" : { + "schema" : { + "type" : "array", + "items" : { + "$ref" : "#/components/schemas/MessageResponse" + } + } + } + } + }, + "404" : { + "description" : "세션 없음", + "content" : { + "*/*" : { + "schema" : { + "type" : "array", + "items" : { + "$ref" : "#/components/schemas/MessageResponse" + } + } + } + } + } + } + }, + "post" : { + "tags" : [ "Session Messages" ], + "summary" : "답변 제출 (US-19) + 꼬리질문 자동 트리거", + "description" : "직전 INTERVIEWER 메시지에 대한 답변을 INTERVIEWEE 로 INSERT. commit 후 generate.followup 발행.", + "operationId" : "submitAnswer", + "parameters" : [ { + "name" : "sessionId", + "in" : "path", + "required" : true, + "schema" : { + "type" : "integer", + "format" : "int64" + } + }, { + "name" : "Idempotency-Key", + "in" : "header", + "required" : false, + "schema" : { + "type" : "string" + } + } ], + "requestBody" : { + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/MessageSubmitRequest" + } + } + }, + "required" : true + }, + "responses" : { + "201" : { + "description" : "답변 저장 + 꼬리질문 발행", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/MessageResponse" + } + } + } + }, + "400" : { + "description" : "검증 실패", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/MessageResponse" + } + } + } + }, + "401" : { + "description" : "인증 실패", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/MessageResponse" + } + } + } + }, + "404" : { + "description" : "세션 없음", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/MessageResponse" + } + } + } + }, + "422" : { + "description" : "세션이 IN_PROGRESS 아니거나 직전 메시지가 질문 아님", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/MessageResponse" + } + } + } + } + } + } + }, + "/api/sessions/{sessionId}/messages/voice" : { + "post" : { + "tags" : [ "Voice Answer" ], + "summary" : "음성 답변 업로드 + STT/분석 트리거", + "description" : "multipart/form-data 로 audio 파일 업로드. 응답은 transcribing 상태 메시지. STT 완료 후 SESSION_MESSAGE SSE 로 transcript 가 도착합니다.", + "operationId" : "submitVoiceAnswer", + "parameters" : [ { + "name" : "sessionId", + "in" : "path", + "required" : true, + "schema" : { + "type" : "integer", + "format" : "int64" + } + }, { + "name" : "Idempotency-Key", + "in" : "header", + "required" : false, + "schema" : { + "type" : "string" + } + } ], + "requestBody" : { + "content" : { + "multipart/form-data" : { + "schema" : { + "type" : "object", + "properties" : { + "audio" : { + "type" : "string", + "format" : "binary" + } + }, + "required" : [ "audio" ] + } + } + } + }, + "responses" : { + "201" : { + "description" : "메시지 INSERT + analyze.voice 발행", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/MessageResponse" + } + } + } + }, + "400" : { + "description" : "파일 형식/크기 오류", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/MessageResponse" + } + } + } + }, + "401" : { + "description" : "인증 실패", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/MessageResponse" + } + } + } + }, + "404" : { + "description" : "세션 없음", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/MessageResponse" + } + } + } + }, + "422" : { + "description" : "세션이 IN_PROGRESS 아니거나 직전 메시지가 질문 아님", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/MessageResponse" + } + } + } + } + } + } + }, + "/api/sessions/{sessionId}/messages/voice/stream-begin" : { + "post" : { + "tags" : [ "Voice Answer" ], + "summary" : "실시간 음성 답변 시작 (placeholder 생성)", + "description" : "WS 오디오 스트림 전에 호출. placeholder 메시지를 만들고 messageId 를 반환. 이 messageId 를 WS 쿼리로 넘긴다.", + "operationId" : "beginVoiceStream", + "parameters" : [ { + "name" : "sessionId", + "in" : "path", + "required" : true, + "schema" : { + "type" : "integer", + "format" : "int64" + } + }, { + "name" : "Idempotency-Key", + "in" : "header", + "required" : false, + "schema" : { + "type" : "string" + } + } ], + "responses" : { + "201" : { + "description" : "placeholder 생성", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/VoiceStreamBeginResponse" + } + } + } + }, + "404" : { + "description" : "세션 없음", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/VoiceStreamBeginResponse" + } + } + } + }, + "422" : { + "description" : "세션 상태/직전 메시지 조건 불만족", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/VoiceStreamBeginResponse" + } + } + } + } + } + } + }, + "/api/resumes" : { + "get" : { + "tags" : [ "Resumes" ], + "summary" : "내 이력서 목록", + "operationId" : "listResumes", + "responses" : { + "200" : { + "description" : "사용자 소유 이력서 목록", + "content" : { + "*/*" : { + "schema" : { + "type" : "array", + "items" : { + "$ref" : "#/components/schemas/ResumeResponse" + } + } + } + } + }, + "401" : { + "description" : "인증 실패", + "content" : { + "*/*" : { + "schema" : { + "type" : "array", + "items" : { + "$ref" : "#/components/schemas/ResumeResponse" + } + } + } + } + } + } + }, + "post" : { + "tags" : [ "Resumes" ], + "summary" : "이력서 PDF 업로드 + 분석 트리거", + "description" : "multipart/form-data 로 PDF 파일을 받아 S3 저장 → DB row 생성(status=PENDING) → AI 분석 자동 발행. 최대 20MB, application/pdf 만 허용.", + "operationId" : "uploadResume", + "requestBody" : { + "content" : { + "multipart/form-data" : { + "schema" : { + "type" : "object", + "properties" : { + "file" : { + "type" : "string", + "format" : "binary" + } + }, + "required" : [ "file" ] + } + } + } + }, + "responses" : { + "201" : { + "description" : "업로드 + 분석 트리거 성공", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/ResumeResponse" + } + } + } + }, + "400" : { + "description" : "빈 파일 / 사이즈 초과 / 비-PDF", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/ResumeResponse" + } + } + } + }, + "401" : { + "description" : "인증 실패", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/ResumeResponse" + } + } + } + } + } + } + }, + "/api/repositories" : { + "get" : { + "tags" : [ "Repositories" ], + "summary" : "등록된 레포 목록", + "operationId" : "listRegisteredRepositories", + "responses" : { + "200" : { + "description" : "사용자가 등록한 레포 목록", + "content" : { + "*/*" : { + "schema" : { + "type" : "array", + "items" : { + "$ref" : "#/components/schemas/RegisteredRepositoryResponse" + } + } + } + } + }, + "401" : { + "description" : "인증 실패", + "content" : { + "*/*" : { + "schema" : { + "type" : "array", + "items" : { + "$ref" : "#/components/schemas/RegisteredRepositoryResponse" + } + } + } + } + } + } + }, + "post" : { + "tags" : [ "Repositories" ], + "summary" : "GitHub 레포 등록 + 분석 트리거", + "description" : "사용자의 GitHub 토큰으로 메타를 재확인 후 INSERT. id/fullName 불일치(VALIDATION_ERROR), 비공개 접근 불가(REPO_PRIVATE_NO_ACCESS), 존재하지 않음(REPO_NOT_FOUND), 중복(REPO_ALREADY_REGISTERED) 분기.", + "operationId" : "registerRepository", + "requestBody" : { + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/RegisterRepositoryRequest" + } + } + }, + "required" : true + }, + "responses" : { + "201" : { + "description" : "등록 + 분석 트리거 성공", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/RegisteredRepositoryResponse" + } + } + } + }, + "400" : { + "description" : "id ↔ fullName 불일치 / 잘못된 요청", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/RegisteredRepositoryResponse" + } + } + } + }, + "401" : { + "description" : "인증 실패", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/RegisteredRepositoryResponse" + } + } + } + }, + "403" : { + "description" : "비공개 레포 접근 불가", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/RegisteredRepositoryResponse" + } + } + } + }, + "404" : { + "description" : "GitHub에 해당 레포 없음", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/RegisteredRepositoryResponse" + } + } + } + }, + "409" : { + "description" : "이미 등록된 레포", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/RegisteredRepositoryResponse" + } + } + } + } + } + } + }, + "/api/repositories/{repositoryId}/sync" : { + "post" : { + "tags" : [ "Repositories" ], + "summary" : "GitHub 메타 재동기화 (US-08)", + "description" : "GitHub API 로 default_branch/name/url 등을 다시 fetch 해 DB 갱신. last_synced_at 갱신. 분석은 재트리거하지 않음.", + "operationId" : "syncRepository", + "parameters" : [ { + "name" : "repositoryId", + "in" : "path", + "required" : true, + "schema" : { + "type" : "integer", + "format" : "int64" + } + } ], + "responses" : { + "200" : { + "description" : "메타 갱신 완료", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/RegisteredRepositoryResponse" + } + } + } + }, + "401" : { + "description" : "인증 실패", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/RegisteredRepositoryResponse" + } + } + } + }, + "404" : { + "description" : "등록된 레포 또는 GitHub 상 레포 없음", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/RegisteredRepositoryResponse" + } + } + } + }, + "403" : { + "description" : "비공개 레포 접근 불가", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/RegisteredRepositoryResponse" + } + } + } + }, + "502" : { + "description" : "GitHub API 호출 실패", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/RegisteredRepositoryResponse" + } + } + } + } + } + } + }, + "/api/internal/sessions/{sessionId}/messages" : { + "post" : { + "tags" : [ "Internal: Session Messages" ], + "summary" : "RealTime WS 답변 위임 → 답변 INSERT + 꼬리질문 발행", + "operationId" : "internalSubmitAnswer", + "parameters" : [ { + "name" : "sessionId", + "in" : "path", + "required" : true, + "schema" : { + "type" : "integer", + "format" : "int64" + } + } ], + "requestBody" : { + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/InternalAnswerSubmitRequest" + } + } + }, + "required" : true + }, + "responses" : { + "201" : { + "description" : "답변 저장", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/MessageResponse" + } + } + } + }, + "401" : { + "description" : "X-Internal-API-Key 인증 실패", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/MessageResponse" + } + } + } + }, + "404" : { + "description" : "세션 없음 / 소유자 불일치", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/MessageResponse" + } + } + } + }, + "422" : { + "description" : "세션 IN_PROGRESS 아님 또는 직전이 질문 아님", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/MessageResponse" + } + } + } + } + } + } + }, + "/api/internal/embeddings/search" : { + "post" : { + "tags" : [ "Internal: Embedding Search" ], + "summary" : "pgvector cosine topK 검색", + "description" : "queryEmbedding 으로 가장 가까운 청크 topK 반환. documentIds 가 비어 있으면 전체.", + "operationId" : "internalSearchEmbeddings", + "requestBody" : { + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/SearchRequest" + } + } + }, + "required" : true + }, + "responses" : { + "200" : { + "description" : "검색 결과", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/SearchResponse" + } + } + } + }, + "400" : { + "description" : "queryEmbedding 누락", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/SearchResponse" + } + } + } + }, + "401" : { + "description" : "X-Internal-API-Key 인증 실패", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/SearchResponse" + } + } + } + } + } + } + }, + "/api/internal/analyses/resume/{resumeId}" : { + "post" : { + "tags" : [ "Internal: Analysis Triggers" ], + "summary" : "이력서 분석 강제 트리거", + "operationId" : "internalTriggerResumeAnalysis", + "parameters" : [ { + "name" : "resumeId", + "in" : "path", + "required" : true, + "schema" : { + "type" : "integer", + "format" : "int64" + } + } ], + "requestBody" : { + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/TriggerRequest" + } + } + }, + "required" : true + }, + "responses" : { + "200" : { + "description" : "트리거 성공", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/AnalysisHandle" + } + } + } + }, + "401" : { + "description" : "X-Internal-API-Key 인증 실패", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/AnalysisHandle" + } + } + } + } + } + } + }, + "/api/internal/analyses/repository/{repositoryId}" : { + "post" : { + "tags" : [ "Internal: Analysis Triggers" ], + "summary" : "GitHub 레포 분석 강제 트리거", + "operationId" : "internalTriggerRepositoryAnalysis", + "parameters" : [ { + "name" : "repositoryId", + "in" : "path", + "required" : true, + "schema" : { + "type" : "integer", + "format" : "int64" + } + } ], + "requestBody" : { + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/TriggerRequest" + } + } + }, + "required" : true + }, + "responses" : { + "200" : { + "description" : "트리거 성공", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/AnalysisHandle" + } + } + } + }, + "401" : { + "description" : "X-Internal-API-Key 인증 실패", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/AnalysisHandle" + } + } + } + } + } + } + }, + "/api/internal/ai-logs" : { + "post" : { + "tags" : [ "Internal: AI Request Logs" ], + "summary" : "AI 요청 로그 기록", + "operationId" : "internalRecordAiRequestLog", + "requestBody" : { + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/Request" + } + } + }, + "required" : true + }, + "responses" : { + "202" : { + "description" : "기록 큐잉 (fire-and-forget)" + }, + "401" : { + "description" : "X-Internal-API-Key 인증 실패" + } + } + } + }, + "/api/auth/stream-token" : { + "post" : { + "tags" : [ "Auth" ], + "summary" : "Create SSE stream token", + "operationId" : "createStreamToken", + "responses" : { + "200" : { + "description" : "Stream token created", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/StreamTokenResponse" + } + } + } + }, + "401" : { + "description" : "Authentication is required", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/StreamTokenResponse" + } + } + } + } + } + } + }, + "/api/auth/refresh" : { + "post" : { + "tags" : [ "Auth" ], + "summary" : "Refresh StackUp access token", + "operationId" : "refreshAccessToken", + "parameters" : [ { + "name" : "refresh_token", + "in" : "cookie", + "required" : false, + "schema" : { + "type" : "string" + } + } ], + "responses" : { + "200" : { + "description" : "Access token refreshed", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/RefreshTokenResponse" + } + } + } + }, + "401" : { + "description" : "Refresh token is missing or invalid", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/RefreshTokenResponse" + } + } + } + } + } + } + }, + "/api/auth/github" : { + "post" : { + "tags" : [ "Auth" ], + "summary" : "Create GitHub OAuth authorization URL", + "operationId" : "startGithubLogin", + "responses" : { + "200" : { + "description" : "GitHub authorization URL created", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/GithubLoginResponse" + } + } + } + } + } + } + }, + "/api/sessions/{sessionId}" : { + "get" : { + "tags" : [ "Sessions" ], + "summary" : "세션 상세 (US-16)", + "operationId" : "getSession", + "parameters" : [ { + "name" : "sessionId", + "in" : "path", + "required" : true, + "schema" : { + "type" : "integer", + "format" : "int64" + } + } ], + "responses" : { + "200" : { + "description" : "세션 상세", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/SessionResponse" + } + } + } + }, + "401" : { + "description" : "인증 실패", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/SessionResponse" + } + } + } + }, + "404" : { + "description" : "세션 없음", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/SessionResponse" + } + } + } + } + } + }, + "delete" : { + "tags" : [ "Sessions" ], + "summary" : "세션 soft delete", + "operationId" : "deleteSession", + "parameters" : [ { + "name" : "sessionId", + "in" : "path", + "required" : true, + "schema" : { + "type" : "integer", + "format" : "int64" + } + } ], + "responses" : { + "204" : { + "description" : "삭제됨" + }, + "401" : { + "description" : "인증 실패" + }, + "404" : { + "description" : "세션 없음" + } + } + }, + "patch" : { + "tags" : [ "Sessions" ], + "summary" : "세션 제목/메모 수정", + "operationId" : "updateSessionMeta", + "parameters" : [ { + "name" : "sessionId", + "in" : "path", + "required" : true, + "schema" : { + "type" : "integer", + "format" : "int64" + } + } ], + "requestBody" : { + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/SessionUpdateRequest" + } + } + }, + "required" : true + }, + "responses" : { + "200" : { + "description" : "갱신 완료", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/SessionResponse" + } + } + } + }, + "401" : { + "description" : "인증 실패", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/SessionResponse" + } + } + } + }, + "404" : { + "description" : "세션 없음", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/SessionResponse" + } + } + } + } + } + } + }, + "/api/sessions/{sessionId}/start" : { + "patch" : { + "tags" : [ "Sessions" ], + "summary" : "세션 시작 (READY→IN_PROGRESS) (US-17)", + "operationId" : "startSession", + "parameters" : [ { + "name" : "sessionId", + "in" : "path", + "required" : true, + "schema" : { + "type" : "integer", + "format" : "int64" + } + } ], + "responses" : { + "200" : { + "description" : "시작됨", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/SessionResponse" + } + } + } + }, + "401" : { + "description" : "인증 실패", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/SessionResponse" + } + } + } + }, + "404" : { + "description" : "세션 없음", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/SessionResponse" + } + } + } + }, + "422" : { + "description" : "READY 아님", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/SessionResponse" + } + } + } + } + } + } + }, + "/api/sessions/{sessionId}/interrupt" : { + "patch" : { + "tags" : [ "Sessions" ], + "summary" : "세션 중단 (IN_PROGRESS→INTERRUPTED) (US-17)", + "operationId" : "interruptSession", + "parameters" : [ { + "name" : "sessionId", + "in" : "path", + "required" : true, + "schema" : { + "type" : "integer", + "format" : "int64" + } + } ], + "responses" : { + "200" : { + "description" : "중단됨", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/SessionResponse" + } + } + } + }, + "401" : { + "description" : "인증 실패", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/SessionResponse" + } + } + } + }, + "404" : { + "description" : "세션 없음", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/SessionResponse" + } + } + } + }, + "422" : { + "description" : "IN_PROGRESS 아님", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/SessionResponse" + } + } + } + } + } + } + }, + "/api/sessions/{sessionId}/end" : { + "patch" : { + "tags" : [ "Sessions" ], + "summary" : "세션 종료 (IN_PROGRESS→COMPLETED) (US-17)", + "operationId" : "endSession", + "parameters" : [ { + "name" : "sessionId", + "in" : "path", + "required" : true, + "schema" : { + "type" : "integer", + "format" : "int64" + } + } ], + "responses" : { + "200" : { + "description" : "종료됨", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/SessionResponse" + } + } + } + }, + "401" : { + "description" : "인증 실패", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/SessionResponse" + } + } + } + }, + "404" : { + "description" : "세션 없음", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/SessionResponse" + } + } + } + }, + "422" : { + "description" : "IN_PROGRESS 아님", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/SessionResponse" + } + } + } + } + } + } + }, + "/api/sessions/{sessionId}/cancel" : { + "patch" : { + "tags" : [ "Sessions" ], + "summary" : "세션 취소 (READY→CANCELLED)", + "operationId" : "cancelSession", + "parameters" : [ { + "name" : "sessionId", + "in" : "path", + "required" : true, + "schema" : { + "type" : "integer", + "format" : "int64" + } + } ], + "responses" : { + "200" : { + "description" : "취소됨", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/SessionResponse" + } + } + } + }, + "401" : { + "description" : "인증 실패", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/SessionResponse" + } + } + } + }, + "404" : { + "description" : "세션 없음", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/SessionResponse" + } + } + } + }, + "422" : { + "description" : "READY 아님", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/SessionResponse" + } + } + } + } + } + } + }, + "/api/users/me" : { + "get" : { + "tags" : [ "Users" ], + "summary" : "Get current authenticated user profile", + "operationId" : "getCurrentUser", + "responses" : { + "200" : { + "description" : "Current user profile returned", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/UserProfileResponse" + } + } + } + }, + "401" : { + "description" : "Authentication is required", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/UserProfileResponse" + } + } + } + }, + "404" : { + "description" : "User not found", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/UserProfileResponse" + } + } + } + } + } + }, + "delete" : { + "tags" : [ "Users" ], + "summary" : "회원 탈퇴 (soft delete + 모든 refresh token revoke)", + "description" : "User row 의 is_deleted=true. 모든 refresh_token revoke. GitHub access token 무효화는 사용자가 GitHub Settings 에서 별도 수행.", + "operationId" : "deleteCurrentUser", + "responses" : { + "204" : { + "description" : "탈퇴 처리됨" + }, + "401" : { + "description" : "인증 실패" + }, + "410" : { + "description" : "이미 탈퇴한 사용자" + } + } + } + }, + "/api/users/me/stats" : { + "get" : { + "tags" : [ "Users (Stats)" ], + "summary" : "내 면접 통계 (US-02)", + "description" : "신규 사용자(데이터 없음)는 카운트 0, 평균 null, recent 빈 배열.", + "operationId" : "getCurrentUserStats", + "responses" : { + "200" : { + "description" : "통계 반환", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/UserStatsResponse" + } + } + } + }, + "401" : { + "description" : "인증 실패", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/UserStatsResponse" + } + } + } + } + } + } + }, + "/api/system/ready" : { + "get" : { + "tags" : [ "system-controller" ], + "operationId" : "ready", + "responses" : { + "200" : { + "description" : "OK", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/SystemHealthResponse" + } + } + } + } + } + } + }, + "/api/system/live" : { + "get" : { + "tags" : [ "system-controller" ], + "operationId" : "live", + "responses" : { + "200" : { + "description" : "OK", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/SystemLiveResponse" + } + } + } + } + } + } + }, + "/api/system/health" : { + "get" : { + "tags" : [ "system-controller" ], + "operationId" : "health", + "responses" : { + "200" : { + "description" : "OK", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/SystemHealthResponse" + } + } + } + } + } + } + }, + "/api/sessions/{sessionId}/feedback" : { + "get" : { + "tags" : [ "Session Feedback" ], + "summary" : "세션 종합 피드백 조회", + "operationId" : "getSessionFeedback", + "parameters" : [ { + "name" : "sessionId", + "in" : "path", + "required" : true, + "schema" : { + "type" : "integer", + "format" : "int64" + } + } ], + "responses" : { + "200" : { + "description" : "피드백", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/FeedbackResponse" + } + } + } + }, + "401" : { + "description" : "인증 실패", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/FeedbackResponse" + } + } + } + }, + "404" : { + "description" : "세션 또는 피드백 없음", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/FeedbackResponse" + } + } + } + } + } + } + }, + "/api/resumes/{resumeId}" : { + "get" : { + "tags" : [ "Resumes" ], + "summary" : "이력서 단건 조회", + "operationId" : "getResume", + "parameters" : [ { + "name" : "resumeId", + "in" : "path", + "required" : true, + "schema" : { + "type" : "integer", + "format" : "int64" + } + } ], + "responses" : { + "200" : { + "description" : "이력서 메타", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/ResumeResponse" + } + } + } + }, + "401" : { + "description" : "인증 실패", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/ResumeResponse" + } + } + } + }, + "404" : { + "description" : "이력서 없음", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/ResumeResponse" + } + } + } + } + } + }, + "delete" : { + "tags" : [ "Resumes" ], + "summary" : "이력서 soft delete", + "operationId" : "deleteResume", + "parameters" : [ { + "name" : "resumeId", + "in" : "path", + "required" : true, + "schema" : { + "type" : "integer", + "format" : "int64" + } + } ], + "responses" : { + "204" : { + "description" : "삭제 완료" + }, + "401" : { + "description" : "인증 실패" + }, + "404" : { + "description" : "이력서 없음" + } + } + } + }, + "/api/repositories/{repositoryId}" : { + "get" : { + "tags" : [ "Repositories" ], + "summary" : "등록 레포 단건 조회", + "operationId" : "getRepository", + "parameters" : [ { + "name" : "repositoryId", + "in" : "path", + "required" : true, + "schema" : { + "type" : "integer", + "format" : "int64" + } + } ], + "responses" : { + "200" : { + "description" : "레포 메타", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/RegisteredRepositoryResponse" + } + } + } + }, + "401" : { + "description" : "인증 실패", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/RegisteredRepositoryResponse" + } + } + } + }, + "404" : { + "description" : "레포 없음", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/RegisteredRepositoryResponse" + } + } + } + } + } + }, + "delete" : { + "tags" : [ "Repositories" ], + "summary" : "등록 레포 soft delete", + "operationId" : "deleteRepository", + "parameters" : [ { + "name" : "repositoryId", + "in" : "path", + "required" : true, + "schema" : { + "type" : "integer", + "format" : "int64" + } + } ], + "responses" : { + "204" : { + "description" : "삭제 완료" + }, + "401" : { + "description" : "인증 실패" + }, + "404" : { + "description" : "레포 없음" + } + } + } + }, + "/api/repositories/github" : { + "get" : { + "tags" : [ "Repositories" ], + "summary" : "GitHub 후보 레포 목록 (페이지)", + "description" : "사용자의 GitHub 토큰으로 owner 레포를 updated 순으로 페이지 조회. 각 항목에 alreadyRegistered 플래그.", + "operationId" : "listCandidateRepositories", + "parameters" : [ { + "name" : "page", + "in" : "query", + "required" : false, + "schema" : { + "type" : "integer", + "format" : "int32", + "default" : 1 + } + }, { + "name" : "perPage", + "in" : "query", + "required" : false, + "schema" : { + "type" : "integer", + "format" : "int32", + "default" : 30 + } + } ], + "responses" : { + "200" : { + "description" : "GitHub 후보 페이지", + "content" : { + "*/*" : { + "schema" : { + "type" : "array", + "items" : { + "$ref" : "#/components/schemas/CandidateRepositoryResponse" + } + } + } + } + }, + "401" : { + "description" : "인증 실패", + "content" : { + "*/*" : { + "schema" : { + "type" : "array", + "items" : { + "$ref" : "#/components/schemas/CandidateRepositoryResponse" + } + } + } + } + }, + "502" : { + "description" : "GitHub API 호출 실패", + "content" : { + "*/*" : { + "schema" : { + "type" : "array", + "items" : { + "$ref" : "#/components/schemas/CandidateRepositoryResponse" + } + } + } + } + } + } + } + }, + "/api/internal/users/{userId}/github-token" : { + "get" : { + "tags" : [ "Internal: GitHub Token Delegation" ], + "summary" : "사용자 GitHub access token 평문 반환", + "description" : "GithubTokenCipher 로 복호화한 평문 토큰을 응답. 메모리/로그/이벤트 envelope 등 외부 영속/전송 금지.", + "operationId" : "internalFetchGithubToken", + "parameters" : [ { + "name" : "userId", + "in" : "path", + "required" : true, + "schema" : { + "type" : "integer", + "format" : "int64" + } + } ], + "responses" : { + "200" : { + "description" : "토큰 반환", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/GithubTokenResponse" + } + } + } + }, + "401" : { + "description" : "X-Internal-API-Key 인증 실패", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/GithubTokenResponse" + } + } + } + }, + "404" : { + "description" : "사용자 없음 또는 토큰 미존재", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/GithubTokenResponse" + } + } + } + } + } + } + }, + "/api/documents" : { + "get" : { + "tags" : [ "Documents" ], + "summary" : "내 분석 문서 목록 (filter: resumeId 또는 repositoryId)", + "description" : "filter 미지정 시 사용자 보유 전체. resumeId/repositoryId 둘 다 지정 시 resumeId 가 우선.", + "operationId" : "listAnalyzedDocuments", + "parameters" : [ { + "name" : "resumeId", + "in" : "query", + "required" : false, + "schema" : { + "type" : "integer", + "format" : "int64" + } + }, { + "name" : "repositoryId", + "in" : "query", + "required" : false, + "schema" : { + "type" : "integer", + "format" : "int64" + } + } ], + "responses" : { + "200" : { + "description" : "분석 문서 목록 (documentDownloadUrl 은 비어있음)", + "content" : { + "*/*" : { + "schema" : { + "type" : "array", + "items" : { + "$ref" : "#/components/schemas/AnalyzedDocumentResponse" + } + } + } + } + }, + "401" : { + "description" : "인증 실패", + "content" : { + "*/*" : { + "schema" : { + "type" : "array", + "items" : { + "$ref" : "#/components/schemas/AnalyzedDocumentResponse" + } + } + } + } + } + } + } + }, + "/api/documents/{documentId}" : { + "get" : { + "tags" : [ "Documents" ], + "summary" : "분석 문서 상세 + presigned MD 다운로드 URL", + "description" : "documentDownloadUrl 은 10분 TTL presigned. analysis_status 가 ANALYZED 인 경우에만 의미 있음.", + "operationId" : "getAnalyzedDocument", + "parameters" : [ { + "name" : "documentId", + "in" : "path", + "required" : true, + "schema" : { + "type" : "integer", + "format" : "int64" + } + } ], + "responses" : { + "200" : { + "description" : "분석 문서 상세", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/AnalyzedDocumentResponse" + } + } + } + }, + "401" : { + "description" : "인증 실패", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/AnalyzedDocumentResponse" + } + } + } + }, + "404" : { + "description" : "분석 문서 없음", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/AnalyzedDocumentResponse" + } + } + } + } + } + } + }, + "/api/auth/github/callback" : { + "get" : { + "tags" : [ "Auth" ], + "summary" : "Complete GitHub OAuth login", + "operationId" : "completeGithubLogin", + "parameters" : [ { + "name" : "code", + "in" : "query", + "required" : false, + "schema" : { + "type" : "string" + } + }, { + "name" : "state", + "in" : "query", + "required" : false, + "schema" : { + "type" : "string" + } + } ], + "responses" : { + "200" : { + "description" : "Login completed", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/GithubCallbackResponse" + } + } + } + }, + "401" : { + "description" : "GitHub OAuth login failed", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/GithubCallbackResponse" + } + } + } + } + } + } + }, + "/api/users/me/consents/{type}" : { + "delete" : { + "tags" : [ "User Consents" ], + "summary" : "동의 철회 (최신 활성 row 의 revoked_at 갱신)", + "operationId" : "revokeConsent", + "parameters" : [ { + "name" : "type", + "in" : "path", + "required" : true, + "schema" : { + "type" : "string", + "enum" : [ "TOS", "PRIVACY", "MARKETING" ] + } + } ], + "responses" : { + "204" : { + "description" : "철회 처리됨" + }, + "400" : { + "description" : "철회할 활성 동의 없음" + }, + "401" : { + "description" : "인증 실패" + } + } + } + }, + "/api/auth/logout" : { + "delete" : { + "tags" : [ "Auth" ], + "summary" : "Logout and revoke refresh token", + "operationId" : "logout", + "parameters" : [ { + "name" : "refresh_token", + "in" : "cookie", + "required" : false, + "schema" : { + "type" : "string" + } + } ], + "responses" : { + "204" : { + "description" : "Logged out" + } + } + } + } + }, + "components" : { + "schemas" : { + "ChunkRequest" : { + "type" : "object", + "properties" : { + "chunkIndex" : { + "type" : "integer", + "format" : "int32" + }, + "chunkText" : { + "type" : "string" + }, + "embedding" : { + "type" : "array", + "items" : { + "type" : "number", + "format" : "float" + } + } + } + }, + "UpsertRequest" : { + "type" : "object", + "properties" : { + "model" : { + "type" : "string" + }, + "dim" : { + "type" : "integer", + "format" : "int32" + }, + "chunks" : { + "type" : "array", + "items" : { + "$ref" : "#/components/schemas/ChunkRequest" + }, + "minItems" : 1 + } + }, + "required" : [ "chunks" ] + }, + "UpsertResponse" : { + "type" : "object", + "properties" : { + "upserted" : { + "type" : "integer", + "format" : "int32" + } + } + }, + "ConsentSubmitRequest" : { + "type" : "object", + "properties" : { + "consentType" : { + "type" : "string", + "enum" : [ "TOS", "PRIVACY", "MARKETING" ] + }, + "consentVersion" : { + "type" : "string", + "minLength" : 1 + } + }, + "required" : [ "consentType", "consentVersion" ] + }, + "ConsentResponse" : { + "type" : "object", + "properties" : { + "id" : { + "type" : "integer", + "format" : "int64" + }, + "consentType" : { + "type" : "string", + "enum" : [ "TOS", "PRIVACY", "MARKETING" ] + }, + "consentVersion" : { + "type" : "string" + }, + "agreed" : { + "type" : "boolean" + }, + "agreedAt" : { + "type" : "string", + "format" : "date-time" + }, + "revokedAt" : { + "type" : "string", + "format" : "date-time" + }, + "createdAt" : { + "type" : "string", + "format" : "date-time" + } + } + }, + "SessionCreateRequest" : { + "type" : "object", + "properties" : { + "title" : { + "type" : "string", + "maxLength" : 200, + "minLength" : 0 + }, + "memo" : { + "type" : "string", + "maxLength" : 4000, + "minLength" : 0 + }, + "mode" : { + "type" : "string", + "enum" : [ "TECHNICAL", "PERSONALITY", "INTEGRATED" ] + }, + "jobCategory" : { + "type" : "string", + "enum" : [ "FRONTEND", "BACKEND", "INFRA", "DBA" ] + }, + "maxQuestions" : { + "type" : "integer", + "format" : "int32", + "maximum" : 30, + "minimum" : 2 + }, + "maxDurationMinutes" : { + "type" : "integer", + "format" : "int32" + }, + "contextDocumentIds" : { + "type" : "array", + "items" : { + "type" : "integer", + "format" : "int64" + } + } + }, + "required" : [ "jobCategory", "mode" ] + }, + "SessionResponse" : { + "type" : "object", + "properties" : { + "id" : { + "type" : "integer", + "format" : "int64" + }, + "title" : { + "type" : "string" + }, + "memo" : { + "type" : "string" + }, + "mode" : { + "type" : "string", + "enum" : [ "TECHNICAL", "PERSONALITY", "INTEGRATED" ] + }, + "jobCategory" : { + "type" : "string", + "enum" : [ "FRONTEND", "BACKEND", "INFRA", "DBA" ] + }, + "maxQuestions" : { + "type" : "integer", + "format" : "int32" + }, + "maxDurationMinutes" : { + "type" : "integer", + "format" : "int32" + }, + "status" : { + "type" : "string", + "enum" : [ "READY", "IN_PROGRESS", "INTERRUPTED", "COMPLETED", "CANCELLED" ] + }, + "totalQuestionCount" : { + "type" : "integer", + "format" : "int32" + }, + "startedAt" : { + "type" : "string", + "format" : "date-time" + }, + "endedAt" : { + "type" : "string", + "format" : "date-time" + }, + "contextDocumentIds" : { + "type" : "array", + "items" : { + "type" : "integer", + "format" : "int64" + } + }, + "createdAt" : { + "type" : "string", + "format" : "date-time" + }, + "updatedAt" : { + "type" : "string", + "format" : "date-time" + } + } + }, + "StreamTokenResponse" : { + "type" : "object", + "properties" : { + "streamToken" : { + "type" : "string" + } + } + }, + "MessageSubmitRequest" : { + "type" : "object", + "properties" : { + "content" : { + "type" : "string", + "maxLength" : 8000, + "minLength" : 0 + } + }, + "required" : [ "content" ] + }, + "MessageResponse" : { + "type" : "object", + "properties" : { + "id" : { + "type" : "integer", + "format" : "int64" + }, + "sessionId" : { + "type" : "integer", + "format" : "int64" + }, + "sequenceNumber" : { + "type" : "integer", + "format" : "int32" + }, + "role" : { + "type" : "string", + "enum" : [ "INTERVIEWER", "INTERVIEWEE", "SYSTEM" ] + }, + "content" : { + "type" : "string" + }, + "audioFilePath" : { + "type" : "string" + }, + "ttsAudioPath" : { + "type" : "string" + }, + "ttsStatus" : { + "type" : "string", + "enum" : [ "NOT_REQUESTED", "PENDING", "SUCCEEDED", "FAILED" ] + }, + "ttsDurationSec" : { + "type" : "number", + "format" : "double" + }, + "parentMessageId" : { + "type" : "integer", + "format" : "int64" + }, + "status" : { + "type" : "string", + "enum" : [ "CREATED", "COMPLETED", "FAILED" ] + }, + "createdAt" : { + "type" : "string", + "format" : "date-time" + } + } + }, + "VoiceStreamBeginResponse" : { + "type" : "object", + "properties" : { + "messageId" : { + "type" : "integer", + "format" : "int64" + }, + "parentMessageId" : { + "type" : "integer", + "format" : "int64" + }, + "sequenceNumber" : { + "type" : "integer", + "format" : "int32" + } + } + }, + "ResumeResponse" : { + "type" : "object", + "properties" : { + "id" : { + "type" : "integer", + "format" : "int64" + }, + "originalFilename" : { + "type" : "string" + }, + "filePath" : { + "type" : "string" + }, + "fileType" : { + "type" : "string", + "enum" : [ "PDF" ] + }, + "fileSize" : { + "type" : "integer", + "format" : "int64" + }, + "status" : { + "type" : "string", + "enum" : [ "PENDING", "ANALYZING", "ANALYZED", "FAILED" ] + }, + "createdAt" : { + "type" : "string", + "format" : "date-time" + }, + "updatedAt" : { + "type" : "string", + "format" : "date-time" + } + } + }, + "RegisterRepositoryRequest" : { + "type" : "object", + "properties" : { + "githubRepoId" : { + "type" : "integer", + "format" : "int64" + }, + "fullName" : { + "type" : "string", + "minLength" : 1 + } + }, + "required" : [ "fullName", "githubRepoId" ] + }, + "RegisteredRepositoryResponse" : { + "type" : "object", + "properties" : { + "id" : { + "type" : "integer", + "format" : "int64" + }, + "githubRepoId" : { + "type" : "integer", + "format" : "int64" + }, + "repoName" : { + "type" : "string" + }, + "repoFullName" : { + "type" : "string" + }, + "repoUrl" : { + "type" : "string" + }, + "defaultBranch" : { + "type" : "string" + }, + "status" : { + "type" : "string", + "enum" : [ "PENDING", "ANALYZING", "ANALYZED", "FAILED" ] + }, + "lastSyncedAt" : { + "type" : "string", + "format" : "date-time" + }, + "createdAt" : { + "type" : "string", + "format" : "date-time" + }, + "updatedAt" : { + "type" : "string", + "format" : "date-time" + } + } + }, + "InternalAnswerSubmitRequest" : { + "type" : "object", + "properties" : { + "userId" : { + "type" : "integer", + "format" : "int64" + }, + "content" : { + "type" : "string", + "minLength" : 1 + }, + "idempotencyKey" : { + "type" : "string" + } + }, + "required" : [ "content", "userId" ] + }, + "SearchRequest" : { + "type" : "object", + "properties" : { + "queryEmbedding" : { + "type" : "array", + "items" : { + "type" : "number", + "format" : "float" + } + }, + "documentIds" : { + "type" : "array", + "items" : { + "type" : "integer", + "format" : "int64" + } + }, + "topK" : { + "type" : "integer", + "format" : "int32" + } + }, + "required" : [ "queryEmbedding" ] + }, + "SearchResponse" : { + "type" : "object", + "properties" : { + "hits" : { + "type" : "array", + "items" : { + "$ref" : "#/components/schemas/SearchResponseHit" + } + } + } + }, + "SearchResponseHit" : { + "type" : "object", + "properties" : { + "documentId" : { + "type" : "integer", + "format" : "int64" + }, + "chunkIndex" : { + "type" : "integer", + "format" : "int32" + }, + "chunkText" : { + "type" : "string" + }, + "distance" : { + "type" : "number", + "format" : "double" + } + } + }, + "TriggerRequest" : { + "type" : "object", + "properties" : { + "userId" : { + "type" : "integer", + "format" : "int64" + } + }, + "required" : [ "userId" ] + }, + "AnalysisHandle" : { + "type" : "object", + "properties" : { + "analyzedDocumentId" : { + "type" : "integer", + "format" : "int64" + }, + "resumeId" : { + "type" : "integer", + "format" : "int64" + }, + "repositoryId" : { + "type" : "integer", + "format" : "int64" + } + } + }, + "Request" : { + "type" : "object", + "properties" : { + "userId" : { + "type" : "integer", + "format" : "int64" + }, + "sessionId" : { + "type" : "integer", + "format" : "int64" + }, + "requestType" : { + "type" : "string", + "minLength" : 1 + }, + "modelName" : { + "type" : "string" + }, + "inputTokens" : { + "type" : "integer", + "format" : "int32" + }, + "outputTokens" : { + "type" : "integer", + "format" : "int32" + }, + "latencyMs" : { + "type" : "integer", + "format" : "int32" + }, + "status" : { + "type" : "string" + }, + "errorMessage" : { + "type" : "string" + } + }, + "required" : [ "requestType", "status" ] + }, + "RefreshTokenResponse" : { + "type" : "object", + "properties" : { + "accessToken" : { + "type" : "string", + "description" : "New StackUp JWT access token", + "example" : "new-our-jwt-access-token" + }, + "tokenType" : { + "type" : "string", + "description" : "Access token type", + "example" : "Bearer" + }, + "expiresIn" : { + "type" : "integer", + "format" : "int64", + "description" : "Access token TTL in seconds", + "example" : 900 + } + } + }, + "GithubLoginResponse" : { + "type" : "object", + "properties" : { + "authorizationUrl" : { + "type" : "string", + "description" : "GitHub OAuth authorization URL", + "example" : "https://github.com/login/oauth/authorize?client_id=...&redirect_uri=...&scope=read:user%20user:email%20repo&state=generated-state" + }, + "state" : { + "type" : "string", + "description" : "CSRF protection state value to be verified on callback", + "example" : "generated-state" + } + } + }, + "SessionUpdateRequest" : { + "type" : "object", + "properties" : { + "title" : { + "type" : "string", + "maxLength" : 200, + "minLength" : 0 + }, + "memo" : { + "type" : "string", + "maxLength" : 4000, + "minLength" : 0 + } + } + }, + "UserProfileResponse" : { + "type" : "object", + "properties" : { + "id" : { + "type" : "integer", + "format" : "int64", + "description" : "StackUp user id", + "example" : 1 + }, + "githubId" : { + "type" : "integer", + "format" : "int64", + "description" : "Stable GitHub user id", + "example" : 123456 + }, + "githubUsername" : { + "type" : "string", + "description" : "GitHub username at login time", + "example" : "octocat" + }, + "email" : { + "type" : [ "string", "null" ], + "description" : "Primary GitHub email when available", + "example" : "octocat@example.com" + }, + "avatarUrl" : { + "type" : [ "string", "null" ], + "description" : "GitHub avatar URL", + "example" : "https://avatars.githubusercontent.com/u/123456" + } + } + }, + "AverageScores" : { + "type" : "object", + "properties" : { + "overall" : { + "type" : "number", + "format" : "double" + }, + "technical" : { + "type" : "number", + "format" : "double" + }, + "logic" : { + "type" : "number", + "format" : "double" + }, + "communication" : { + "type" : "number", + "format" : "double" + } + } + }, + "RecentScore" : { + "type" : "object", + "properties" : { + "sessionId" : { + "type" : "integer", + "format" : "int64" + }, + "overall" : { + "type" : "number", + "format" : "double" + }, + "technical" : { + "type" : "number", + "format" : "double" + }, + "logic" : { + "type" : "number", + "format" : "double" + }, + "communication" : { + "type" : "number", + "format" : "double" + }, + "endedAt" : { + "type" : "string", + "format" : "date-time" + } + } + }, + "UserStatsResponse" : { + "type" : "object", + "properties" : { + "totalSessionCount" : { + "type" : "integer", + "format" : "int64" + }, + "completedSessionCount" : { + "type" : "integer", + "format" : "int64" + }, + "averages" : { + "$ref" : "#/components/schemas/AverageScores" + }, + "recent" : { + "type" : "array", + "items" : { + "$ref" : "#/components/schemas/RecentScore" + } + } + } + }, + "ComponentHealthResponse" : { + "type" : "object", + "properties" : { + "name" : { + "type" : "string" + }, + "status" : { + "type" : "string" + }, + "details" : { + "type" : "object", + "additionalProperties" : { } + } + } + }, + "SystemHealthResponse" : { + "type" : "object", + "properties" : { + "status" : { + "type" : "string" + }, + "timestamp" : { + "type" : "string", + "format" : "date-time" + }, + "components" : { + "type" : "object", + "additionalProperties" : { + "$ref" : "#/components/schemas/ComponentHealthResponse" + } + } + } + }, + "SystemLiveResponse" : { + "type" : "object", + "properties" : { + "status" : { + "type" : "string" + }, + "timestamp" : { + "type" : "string", + "format" : "date-time" + } + } + }, + "FeedbackResponse" : { + "type" : "object", + "properties" : { + "id" : { + "type" : "integer", + "format" : "int64" + }, + "sessionId" : { + "type" : "integer", + "format" : "int64" + }, + "overallScore" : { + "type" : "number", + "format" : "double" + }, + "technicalAccuracy" : { + "type" : "number", + "format" : "double" + }, + "logicScore" : { + "type" : "number", + "format" : "double" + }, + "communicationScore" : { + "type" : "number", + "format" : "double" + }, + "strengthsSummary" : { + "type" : "string" + }, + "weaknessesSummary" : { + "type" : "string" + }, + "improvementKeywords" : { + "type" : "array", + "description" : "Improvement keywords returned by AI. The current contract is a string list.", + "items" : { + "type" : "string" + } + }, + "reportFilePath" : { + "type" : "string", + "description" : "Stored report path when AI generates a detailed learning guide/report." + }, + "createdAt" : { + "type" : "string", + "format" : "date-time" + } + } + }, + "CandidateRepositoryResponse" : { + "type" : "object", + "properties" : { + "githubRepoId" : { + "type" : "integer", + "format" : "int64" + }, + "name" : { + "type" : "string" + }, + "fullName" : { + "type" : "string" + }, + "htmlUrl" : { + "type" : "string" + }, + "defaultBranch" : { + "type" : "string" + }, + "isPrivate" : { + "type" : "boolean" + }, + "alreadyRegistered" : { + "type" : "boolean" + } + } + }, + "GithubTokenResponse" : { + "type" : "object", + "properties" : { + "accessToken" : { + "type" : "string" + } + } + }, + "AnalyzedDocumentResponse" : { + "type" : "object", + "properties" : { + "id" : { + "type" : "integer", + "format" : "int64" + }, + "sourceType" : { + "type" : "string" + }, + "sourceId" : { + "type" : "integer", + "format" : "int64" + }, + "documentPath" : { + "type" : "string" + }, + "documentDownloadUrl" : { + "type" : "string", + "format" : "uri" + }, + "summary" : { + "type" : "string" + }, + "techStack" : { + "type" : "array", + "items" : { + "type" : "string" + } + }, + "embeddingChunkCount" : { + "type" : "integer", + "format" : "int32" + }, + "analysisStatus" : { + "type" : "string", + "enum" : [ "PROCESSING", "ANALYZED", "FAILED" ] + }, + "errorCode" : { + "type" : "string" + }, + "errorMessage" : { + "type" : "string" + }, + "status" : { + "type" : "string", + "enum" : [ "ACTIVE", "ARCHIVED" ] + }, + "createdAt" : { + "type" : "string", + "format" : "date-time" + }, + "updatedAt" : { + "type" : "string", + "format" : "date-time" + } + } + }, + "AuthUserResponse" : { + "type" : "object", + "properties" : { + "id" : { + "type" : "integer", + "format" : "int64", + "description" : "StackUp user id", + "example" : 1 + }, + "githubId" : { + "type" : "integer", + "format" : "int64", + "description" : "Stable GitHub user id", + "example" : 123456 + }, + "githubUsername" : { + "type" : "string", + "description" : "GitHub username at login time", + "example" : "octocat" + }, + "email" : { + "type" : [ "string", "null" ], + "description" : "Primary GitHub email when available", + "example" : "octocat@example.com" + }, + "avatarUrl" : { + "type" : [ "string", "null" ], + "description" : "GitHub avatar URL", + "example" : "https://avatars.githubusercontent.com/u/123456" + } + } + }, + "GithubCallbackResponse" : { + "type" : "object", + "properties" : { + "accessToken" : { + "type" : "string", + "description" : "StackUp JWT access token", + "example" : "our-jwt-access-token" + }, + "tokenType" : { + "type" : "string", + "description" : "Access token type", + "example" : "Bearer" + }, + "expiresIn" : { + "type" : "integer", + "format" : "int64", + "description" : "Access token TTL in seconds", + "example" : 900 + }, + "user" : { + "$ref" : "#/components/schemas/AuthUserResponse", + "description" : "Authenticated user profile" + }, + "isNewUser" : { + "type" : "boolean", + "description" : "Whether a new user was created during this login", + "example" : false + } + } + } + } + } +} diff --git a/backend/src/test/java/com/stackup/stackup/common/openapi/OpenApiSpecExportTest.java b/backend/src/test/java/com/stackup/stackup/common/openapi/OpenApiSpecExportTest.java new file mode 100644 index 00000000..4c7f1dc5 --- /dev/null +++ b/backend/src/test/java/com/stackup/stackup/common/openapi/OpenApiSpecExportTest.java @@ -0,0 +1,81 @@ +package com.stackup.stackup.common.openapi; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.stackup.stackup.auth.domain.OAuthStateRepository; +import com.stackup.stackup.auth.domain.RefreshTokenRepository; +import com.stackup.stackup.common.messaging.domain.ProcessedMessageRepository; +import com.stackup.stackup.document.domain.AnalyzedDocumentRepository; +import com.stackup.stackup.document.domain.DocumentEmbeddingRepository; +import com.stackup.stackup.github.domain.GithubRepositoryRepository; +import com.stackup.stackup.log.ai.domain.AiRequestLogRepository; +import com.stackup.stackup.resume.domain.ResumeRepository; +import com.stackup.stackup.session.domain.InterviewMessageRepository; +import com.stackup.stackup.session.domain.InterviewSessionRepository; +import com.stackup.stackup.session.domain.MessageVoiceAnalysisRepository; +import com.stackup.stackup.session.domain.SessionContextRepository; +import com.stackup.stackup.session.domain.SessionFeedbackRepository; +import com.stackup.stackup.user.domain.UserRepository; +import com.stackup.stackup.user.domain.consent.UserConsentRepository; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.context.WebApplicationContext; + +/** + * OpenAPI 스펙을 정적 파일(backend/openapi.json)로 산출하는 테스트. + * + *
프론트엔드는 이 커밋된 파일에서 openapi-typescript 로 타입을 생성하므로, 로컬 백엔드 기동 없이도 타입 안정성을 얻는다. 백엔드 + * 계약을 바꾸면 이 테스트를 다시 실행해 openapi.json 을 갱신·커밋한다. (CI 에서 "커밋된 스펙이 최신인지" 검증하는 가드로 확장 + * 가능 — 생성 결과와 커밋본을 비교.) + * + *
test 프로파일은 application-test.yml 의 더미 secret 으로 전체 컨텍스트를 로드하고, 모든 repository 는
+ * {@link StackupApplicationTests} 와 동일하게 mock 처리해 실 DB 없이 springdoc 의 매핑·스키마만으로 스펙을
+ * 렌더한다.
+ */
+@SpringBootTest
+class OpenApiSpecExportTest {
+
+ private static final Path OUTPUT = Path.of("openapi.json");
+
+ @Autowired private WebApplicationContext webApplicationContext;
+
+ @MockitoBean private OAuthStateRepository oauthStateRepository;
+ @MockitoBean private RefreshTokenRepository refreshTokenRepository;
+ @MockitoBean private UserRepository userRepository;
+ @MockitoBean private ResumeRepository resumeRepository;
+ @MockitoBean private GithubRepositoryRepository githubRepositoryRepository;
+ @MockitoBean private AnalyzedDocumentRepository analyzedDocumentRepository;
+ @MockitoBean private ProcessedMessageRepository processedMessageRepository;
+ @MockitoBean private DocumentEmbeddingRepository documentEmbeddingRepository;
+ @MockitoBean private UserConsentRepository userConsentRepository;
+ @MockitoBean private InterviewSessionRepository interviewSessionRepository;
+ @MockitoBean private InterviewMessageRepository interviewMessageRepository;
+ @MockitoBean private SessionContextRepository sessionContextRepository;
+ @MockitoBean private SessionFeedbackRepository sessionFeedbackRepository;
+ @MockitoBean private MessageVoiceAnalysisRepository messageVoiceAnalysisRepository;
+ @MockitoBean private AiRequestLogRepository aiRequestLogRepository;
+
+ @Test
+ void exportOpenApiSpec() throws Exception {
+ MockMvc mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext).build();
+ MvcResult result =
+ mockMvc.perform(get("/api/v3/api-docs")).andExpect(status().isOk()).andReturn();
+
+ byte[] raw = result.getResponse().getContentAsByteArray();
+ ObjectMapper objectMapper = new ObjectMapper();
+ Object tree = objectMapper.readValue(raw, Object.class);
+ String pretty = objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(tree);
+
+ Files.writeString(OUTPUT, pretty + System.lineSeparator(), StandardCharsets.UTF_8);
+ }
+}
diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md
index 714afe32..dae8aba2 100644
--- a/frontend/CLAUDE.md
+++ b/frontend/CLAUDE.md
@@ -15,13 +15,15 @@
| Language | TypeScript | 5.9.x |
| Lint | ESLint 9 (flat config) + Prettier | |
| Module | ESM (`"type": "module"`) | |
+| Router | React Router | 7.x (도입) |
+| Server State | TanStack Query | 5.x (도입) |
+| Styling | Tailwind CSS v4 (`@theme` 토큰) | 4.x (도입) |
+| API 타입 | openapi-typescript → `shared/api/generated.ts` | (도입, §7.1) |
+| 테스트 | Vitest + Testing Library (jsdom) | (도입) |
-### 미정 (Phase 1 도입 시점에 결정)
-- Router: 후보 — React Router v7 / TanStack Router
-- Server State: TanStack Query
-- Form: React Hook Form + Zod
-- Styling: CSS Variables 기반 + Tailwind 또는 vanilla-extract
-- 테스트: Vitest + Testing Library + Playwright(E2E)
+### 미정 (도입 시점에 결정)
+- Form: React Hook Form + Zod (현재 controlled `useState` 검증으로 충분 — 폼 복잡도 증가 시 도입)
+- E2E: Playwright
> 신규 의존성 추가 시 [`/docs/coding-conventions.md §6`](../docs/coding-conventions.md) 절차를 따르고 본 표를 갱신.
@@ -274,8 +276,9 @@ npm run lint # ESLint
---
-## 16. 현재 상태 메모 (2026-04 기준)
+## 16. 현재 상태 메모 (2026-06 기준)
-- `App.tsx`는 Vite 기본 데모 상태 → 첫 페이지(Login) 구현 시 교체 예정
-- 라이브러리 결정 전 (라우터, server state, styling) → 첫 PR 시 의사결정 + 본 문서 §1 갱신
-- 디자인 시스템 토큰 파일 미생성 → 디자인 시스템 첫 적용 PR에서 `app/styles/tokens.css` 생성
+- 라이브러리 결정 완료: React Router v7, TanStack Query v5, Tailwind v4, Vitest+Testing Library (§1).
+- 디자인 토큰 `app/styles/tokens.css` 생성됨. `shared/ui` 프리미티브: StatusBadge, Button, Spinner, TextArea, RadioCardGroup, Stepper.
+- **텍스트 면접 세션 구현됨**: `/sessions/new`(생성 설정), `/sessions/:id`(라이브 진행). `features/interview` + `domain/session`. 라이브는 WS(`useInterviewSocket`) 알림 + `GET messages` 쿼리 본문 소유 + 낙관적 답변 (`useLiveInterview`). TTS 재생·음성 답변(RT3)은 범위 밖.
+- **OpenAPI 타입 파이프라인**: 백엔드가 `OpenApiSpecExportTest`로 `backend/openapi.json` 산출·커밋 → 프론트 `npm run openapi`(= `openapi-typescript ../backend/openapi.json …`)로 `shared/api/generated.ts` 생성. 프론트는 백엔드 런타임에 무의존. 계약 변경 시 백엔드 테스트 재실행으로 갱신.
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 43742b6d..96977803 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -18,6 +18,9 @@
},
"devDependencies": {
"@eslint/js": "^9.39.4",
+ "@testing-library/jest-dom": "^6.9.1",
+ "@testing-library/react": "^16.3.2",
+ "@testing-library/user-event": "^14.6.1",
"@types/node": "^24.12.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
@@ -27,12 +30,73 @@
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.4.0",
+ "jsdom": "^29.1.1",
+ "openapi-typescript": "^7.13.0",
"prettier": "^3.8.1",
"typescript": "~5.9.3",
"typescript-eslint": "^8.57.0",
- "vite": "^8.0.1"
+ "vite": "^8.0.1",
+ "vitest": "^4.1.8"
}
},
+ "node_modules/@adobe/css-tools": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz",
+ "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@asamuzakjp/css-color": {
+ "version": "5.1.11",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz",
+ "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@asamuzakjp/generational-cache": "^1.0.1",
+ "@csstools/css-calc": "^3.2.0",
+ "@csstools/css-color-parser": "^4.1.0",
+ "@csstools/css-parser-algorithms": "^4.0.0",
+ "@csstools/css-tokenizer": "^4.0.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
+ "node_modules/@asamuzakjp/dom-selector": {
+ "version": "7.1.1",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz",
+ "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@asamuzakjp/generational-cache": "^1.0.1",
+ "@asamuzakjp/nwsapi": "^2.3.9",
+ "bidi-js": "^1.0.3",
+ "css-tree": "^3.2.1",
+ "is-potential-custom-element-name": "^1.0.1"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
+ "node_modules/@asamuzakjp/generational-cache": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz",
+ "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
+ "node_modules/@asamuzakjp/nwsapi": {
+ "version": "2.3.9",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz",
+ "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@babel/code-frame": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
@@ -225,6 +289,16 @@
"node": ">=6.0.0"
}
},
+ "node_modules/@babel/runtime": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
+ "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
"node_modules/@babel/template": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
@@ -273,6 +347,159 @@
"node": ">=6.9.0"
}
},
+ "node_modules/@bramus/specificity": {
+ "version": "2.4.2",
+ "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
+ "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "css-tree": "^3.0.0"
+ },
+ "bin": {
+ "specificity": "bin/cli.js"
+ }
+ },
+ "node_modules/@csstools/color-helpers": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz",
+ "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT-0",
+ "engines": {
+ "node": ">=20.19.0"
+ }
+ },
+ "node_modules/@csstools/css-calc": {
+ "version": "3.2.1",
+ "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz",
+ "integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "peerDependencies": {
+ "@csstools/css-parser-algorithms": "^4.0.0",
+ "@csstools/css-tokenizer": "^4.0.0"
+ }
+ },
+ "node_modules/@csstools/css-color-parser": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.1.tgz",
+ "integrity": "sha512-eZ5XOtyhK+mggRafYUWzA0tvaYOFgdY8AkgQiCJF9qNAePnUo/zmsqqYubBBb3sQ8uNUaSKTY9s9klfRaAXL0g==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "@csstools/color-helpers": "^6.0.2",
+ "@csstools/css-calc": "^3.2.1"
+ },
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "peerDependencies": {
+ "@csstools/css-parser-algorithms": "^4.0.0",
+ "@csstools/css-tokenizer": "^4.0.0"
+ }
+ },
+ "node_modules/@csstools/css-parser-algorithms": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz",
+ "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "peerDependencies": {
+ "@csstools/css-tokenizer": "^4.0.0"
+ }
+ },
+ "node_modules/@csstools/css-syntax-patches-for-csstree": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.4.tgz",
+ "integrity": "sha512-wgsqt92b7C7tQhIdPNxj0n9zuUbQlvAuI1exyzeNrOKOi62SD7ren8zqszmpVREjAOqg8cD2FqYhQfAuKjk4sw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT-0",
+ "peerDependencies": {
+ "css-tree": "^3.2.1"
+ },
+ "peerDependenciesMeta": {
+ "css-tree": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@csstools/css-tokenizer": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz",
+ "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.19.0"
+ }
+ },
"node_modules/@emnapi/core": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz",
@@ -461,6 +688,24 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
+ "node_modules/@exodus/bytes": {
+ "version": "1.15.1",
+ "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz",
+ "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ },
+ "peerDependencies": {
+ "@noble/hashes": "^1.8.0 || ^2.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@noble/hashes": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@humanfs/core": {
"version": "0.19.1",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
@@ -583,6 +828,106 @@
"url": "https://github.com/sponsors/Boshen"
}
},
+ "node_modules/@redocly/ajv": {
+ "version": "8.11.2",
+ "resolved": "https://registry.npmjs.org/@redocly/ajv/-/ajv-8.11.2.tgz",
+ "integrity": "sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fast-deep-equal": "^3.1.1",
+ "json-schema-traverse": "^1.0.0",
+ "require-from-string": "^2.0.2",
+ "uri-js-replace": "^1.0.1"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/epoberezkin"
+ }
+ },
+ "node_modules/@redocly/ajv/node_modules/json-schema-traverse": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
+ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@redocly/config": {
+ "version": "0.22.0",
+ "resolved": "https://registry.npmjs.org/@redocly/config/-/config-0.22.0.tgz",
+ "integrity": "sha512-gAy93Ddo01Z3bHuVdPWfCwzgfaYgMdaZPcfL7JZ7hWJoK9V0lXDbigTWkhiPFAaLWzbOJ+kbUQG1+XwIm0KRGQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@redocly/openapi-core": {
+ "version": "1.34.15",
+ "resolved": "https://registry.npmjs.org/@redocly/openapi-core/-/openapi-core-1.34.15.tgz",
+ "integrity": "sha512-HAwCnNyKcs5XGQqms+9t7OdAPM/5TDstmhF+0i7tdCFato2QKuYIlyWETwkXd8c5zbltr1oB+6y9NTeQLr2d6Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@redocly/ajv": "8.11.2",
+ "@redocly/config": "0.22.0",
+ "colorette": "1.4.0",
+ "https-proxy-agent": "7.0.6",
+ "js-levenshtein": "1.1.6",
+ "js-yaml": "4.1.1",
+ "minimatch": "5.1.9",
+ "pluralize": "8.0.0",
+ "yaml-ast-parser": "0.0.43"
+ },
+ "engines": {
+ "node": ">=18.17.0",
+ "npm": ">=9.5.0"
+ }
+ },
+ "node_modules/@redocly/openapi-core/node_modules/agent-base": {
+ "version": "7.1.4",
+ "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
+ "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/@redocly/openapi-core/node_modules/brace-expansion": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz",
+ "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0"
+ }
+ },
+ "node_modules/@redocly/openapi-core/node_modules/https-proxy-agent": {
+ "version": "7.0.6",
+ "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
+ "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "agent-base": "^7.1.2",
+ "debug": "4"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/@redocly/openapi-core/node_modules/minimatch": {
+ "version": "5.1.9",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz",
+ "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/@rolldown/binding-android-arm64": {
"version": "1.0.0-rc.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.12.tgz",
@@ -830,6 +1175,13 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@standard-schema/spec": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
+ "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@tailwindcss/node": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.0.tgz",
@@ -1113,6 +1465,96 @@
"react": "^18 || ^19"
}
},
+ "node_modules/@testing-library/dom": {
+ "version": "10.4.1",
+ "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
+ "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@babel/code-frame": "^7.10.4",
+ "@babel/runtime": "^7.12.5",
+ "@types/aria-query": "^5.0.1",
+ "aria-query": "5.3.0",
+ "dom-accessibility-api": "^0.5.9",
+ "lz-string": "^1.5.0",
+ "picocolors": "1.1.1",
+ "pretty-format": "^27.0.2"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@testing-library/jest-dom": {
+ "version": "6.9.1",
+ "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz",
+ "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@adobe/css-tools": "^4.4.0",
+ "aria-query": "^5.0.0",
+ "css.escape": "^1.5.1",
+ "dom-accessibility-api": "^0.6.3",
+ "picocolors": "^1.1.1",
+ "redent": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=14",
+ "npm": ">=6",
+ "yarn": ">=1"
+ }
+ },
+ "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz",
+ "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@testing-library/react": {
+ "version": "16.3.2",
+ "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz",
+ "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.12.5"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@testing-library/dom": "^10.0.0",
+ "@types/react": "^18.0.0 || ^19.0.0",
+ "@types/react-dom": "^18.0.0 || ^19.0.0",
+ "react": "^18.0.0 || ^19.0.0",
+ "react-dom": "^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@testing-library/user-event": {
+ "version": "14.6.1",
+ "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz",
+ "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12",
+ "npm": ">=6"
+ },
+ "peerDependencies": {
+ "@testing-library/dom": ">=7.21.4"
+ }
+ },
"node_modules/@tybys/wasm-util": {
"version": "0.10.1",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
@@ -1123,6 +1565,32 @@
"tslib": "^2.4.0"
}
},
+ "node_modules/@types/aria-query": {
+ "version": "5.0.4",
+ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
+ "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true
+ },
+ "node_modules/@types/chai": {
+ "version": "5.2.3",
+ "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
+ "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/deep-eql": "*",
+ "assertion-error": "^2.0.1"
+ }
+ },
+ "node_modules/@types/deep-eql": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
+ "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -1488,9 +1956,122 @@
}
}
},
- "node_modules/acorn": {
- "version": "8.16.0",
- "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
+ "node_modules/@vitest/expect": {
+ "version": "4.1.8",
+ "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.8.tgz",
+ "integrity": "sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@standard-schema/spec": "^1.1.0",
+ "@types/chai": "^5.2.2",
+ "@vitest/spy": "4.1.8",
+ "@vitest/utils": "4.1.8",
+ "chai": "^6.2.2",
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/mocker": {
+ "version": "4.1.8",
+ "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.8.tgz",
+ "integrity": "sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/spy": "4.1.8",
+ "estree-walker": "^3.0.3",
+ "magic-string": "^0.30.21"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "msw": "^2.4.9",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "msw": {
+ "optional": true
+ },
+ "vite": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vitest/pretty-format": {
+ "version": "4.1.8",
+ "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.8.tgz",
+ "integrity": "sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/runner": {
+ "version": "4.1.8",
+ "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.8.tgz",
+ "integrity": "sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/utils": "4.1.8",
+ "pathe": "^2.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/snapshot": {
+ "version": "4.1.8",
+ "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.8.tgz",
+ "integrity": "sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "4.1.8",
+ "@vitest/utils": "4.1.8",
+ "magic-string": "^0.30.21",
+ "pathe": "^2.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/spy": {
+ "version": "4.1.8",
+ "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.8.tgz",
+ "integrity": "sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/utils": {
+ "version": "4.1.8",
+ "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.8.tgz",
+ "integrity": "sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "4.1.8",
+ "convert-source-map": "^2.0.0",
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/acorn": {
+ "version": "8.16.0",
+ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"dev": true,
"license": "MIT",
@@ -1540,6 +2121,27 @@
"url": "https://github.com/sponsors/epoberezkin"
}
},
+ "node_modules/ansi-colors": {
+ "version": "4.1.3",
+ "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz",
+ "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
@@ -1563,6 +2165,26 @@
"dev": true,
"license": "Python-2.0"
},
+ "node_modules/aria-query": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
+ "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "dequal": "^2.0.3"
+ }
+ },
+ "node_modules/assertion-error": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
+ "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
@@ -1601,6 +2223,16 @@
"node": ">=6.0.0"
}
},
+ "node_modules/bidi-js": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
+ "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "require-from-string": "^2.0.2"
+ }
+ },
"node_modules/brace-expansion": {
"version": "1.1.13",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz",
@@ -1690,6 +2322,16 @@
],
"license": "CC-BY-4.0"
},
+ "node_modules/chai": {
+ "version": "6.2.2",
+ "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
+ "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@@ -1707,6 +2349,13 @@
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
+ "node_modules/change-case": {
+ "version": "5.4.4",
+ "resolved": "https://registry.npmjs.org/change-case/-/change-case-5.4.4.tgz",
+ "integrity": "sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -1727,6 +2376,13 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/colorette": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/colorette/-/colorette-1.4.0.tgz",
+ "integrity": "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
@@ -1781,6 +2437,27 @@
"node": ">= 8"
}
},
+ "node_modules/css-tree": {
+ "version": "3.2.1",
+ "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
+ "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "mdn-data": "2.27.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
+ }
+ },
+ "node_modules/css.escape": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz",
+ "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@@ -1788,6 +2465,20 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/data-urls": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
+ "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "whatwg-mimetype": "^5.0.0",
+ "whatwg-url": "^16.0.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -1805,6 +2496,13 @@
}
}
},
+ "node_modules/decimal.js": {
+ "version": "10.6.0",
+ "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
+ "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/deep-is": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@@ -1821,6 +2519,16 @@
"node": ">=0.4.0"
}
},
+ "node_modules/dequal": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
+ "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
@@ -1830,6 +2538,14 @@
"node": ">=8"
}
},
+ "node_modules/dom-accessibility-api": {
+ "version": "0.5.16",
+ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
+ "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true
+ },
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -1864,6 +2580,19 @@
"node": ">=10.13.0"
}
},
+ "node_modules/entities": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz",
+ "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
@@ -1882,6 +2611,13 @@
"node": ">= 0.4"
}
},
+ "node_modules/es-module-lexer": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz",
+ "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/es-object-atoms": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
@@ -2122,6 +2858,16 @@
"node": ">=4.0"
}
},
+ "node_modules/estree-walker": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
+ "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.0"
+ }
+ },
"node_modules/esutils": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
@@ -2132,6 +2878,16 @@
"node": ">=0.10.0"
}
},
+ "node_modules/expect-type": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
+ "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -2437,6 +3193,19 @@
"hermes-estree": "0.25.1"
}
},
+ "node_modules/html-encoding-sniffer": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz",
+ "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@exodus/bytes": "^1.6.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
"node_modules/https-proxy-agent": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
@@ -2487,6 +3256,29 @@
"node": ">=0.8.19"
}
},
+ "node_modules/indent-string": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz",
+ "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/index-to-position": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-1.2.0.tgz",
+ "integrity": "sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/is-extglob": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
@@ -2510,6 +3302,13 @@
"node": ">=0.10.0"
}
},
+ "node_modules/is-potential-custom-element-name": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
+ "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
@@ -2526,6 +3325,16 @@
"jiti": "lib/jiti-cli.mjs"
}
},
+ "node_modules/js-levenshtein": {
+ "version": "1.1.6",
+ "resolved": "https://registry.npmjs.org/js-levenshtein/-/js-levenshtein-1.1.6.tgz",
+ "integrity": "sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -2546,6 +3355,57 @@
"js-yaml": "bin/js-yaml.js"
}
},
+ "node_modules/jsdom": {
+ "version": "29.1.1",
+ "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz",
+ "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@asamuzakjp/css-color": "^5.1.11",
+ "@asamuzakjp/dom-selector": "^7.1.1",
+ "@bramus/specificity": "^2.4.2",
+ "@csstools/css-syntax-patches-for-csstree": "^1.1.3",
+ "@exodus/bytes": "^1.15.0",
+ "css-tree": "^3.2.1",
+ "data-urls": "^7.0.0",
+ "decimal.js": "^10.6.0",
+ "html-encoding-sniffer": "^6.0.0",
+ "is-potential-custom-element-name": "^1.0.1",
+ "lru-cache": "^11.3.5",
+ "parse5": "^8.0.1",
+ "saxes": "^6.0.0",
+ "symbol-tree": "^3.2.4",
+ "tough-cookie": "^6.0.1",
+ "undici": "^7.25.0",
+ "w3c-xmlserializer": "^5.0.0",
+ "webidl-conversions": "^8.0.1",
+ "whatwg-mimetype": "^5.0.0",
+ "whatwg-url": "^16.0.1",
+ "xml-name-validator": "^5.0.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24.0.0"
+ },
+ "peerDependencies": {
+ "canvas": "^3.0.0"
+ },
+ "peerDependenciesMeta": {
+ "canvas": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/jsdom/node_modules/lru-cache": {
+ "version": "11.5.1",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz",
+ "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": "20 || >=22"
+ }
+ },
"node_modules/jsesc": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
@@ -2899,6 +3759,17 @@
"yallist": "^3.0.2"
}
},
+ "node_modules/lz-string": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
+ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "bin": {
+ "lz-string": "bin/bin.js"
+ }
+ },
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@@ -2917,6 +3788,13 @@
"node": ">= 0.4"
}
},
+ "node_modules/mdn-data": {
+ "version": "2.27.1",
+ "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
+ "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==",
+ "dev": true,
+ "license": "CC0-1.0"
+ },
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
@@ -2938,6 +3816,16 @@
"node": ">= 0.6"
}
},
+ "node_modules/min-indent": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
+ "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
"node_modules/minimatch": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
@@ -2989,6 +3877,51 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/obug": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
+ "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==",
+ "dev": true,
+ "funding": [
+ "https://github.com/sponsors/sxzz",
+ "https://opencollective.com/debug"
+ ],
+ "license": "MIT"
+ },
+ "node_modules/openapi-typescript": {
+ "version": "7.13.0",
+ "resolved": "https://registry.npmjs.org/openapi-typescript/-/openapi-typescript-7.13.0.tgz",
+ "integrity": "sha512-EFP392gcqXS7ntPvbhBzbF8TyBA+baIYEm791Hy5YkjDYKTnk/Tn5OQeKm5BIZvJihpp8Zzr4hzx0Irde1LNGQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@redocly/openapi-core": "^1.34.6",
+ "ansi-colors": "^4.1.3",
+ "change-case": "^5.4.4",
+ "parse-json": "^8.3.0",
+ "supports-color": "^10.2.2",
+ "yargs-parser": "^21.1.1"
+ },
+ "bin": {
+ "openapi-typescript": "bin/cli.js"
+ },
+ "peerDependencies": {
+ "typescript": "^5.x"
+ }
+ },
+ "node_modules/openapi-typescript/node_modules/supports-color": {
+ "version": "10.2.2",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz",
+ "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/supports-color?sponsor=1"
+ }
+ },
"node_modules/optionator": {
"version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@@ -3052,6 +3985,37 @@
"node": ">=6"
}
},
+ "node_modules/parse-json": {
+ "version": "8.3.0",
+ "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.3.0.tgz",
+ "integrity": "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.26.2",
+ "index-to-position": "^1.1.0",
+ "type-fest": "^4.39.1"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/parse5": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz",
+ "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "entities": "^8.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/inikulin/parse5?sponsor=1"
+ }
+ },
"node_modules/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
@@ -3072,6 +4036,13 @@
"node": ">=8"
}
},
+ "node_modules/pathe": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
+ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -3090,6 +4061,16 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
+ "node_modules/pluralize": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz",
+ "integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
"node_modules/postcss": {
"version": "8.5.8",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
@@ -3144,6 +4125,36 @@
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
+ "node_modules/pretty-format": {
+ "version": "27.5.1",
+ "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
+ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "ansi-regex": "^5.0.1",
+ "ansi-styles": "^5.0.0",
+ "react-is": "^17.0.1"
+ },
+ "engines": {
+ "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
+ }
+ },
+ "node_modules/pretty-format/node_modules/ansi-styles": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
+ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
"node_modules/proxy-from-env": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
@@ -3184,6 +4195,14 @@
"react": "^19.2.4"
}
},
+ "node_modules/react-is": {
+ "version": "17.0.2",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
+ "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true
+ },
"node_modules/react-router": {
"version": "7.15.0",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.15.0.tgz",
@@ -3222,6 +4241,30 @@
"react-dom": ">=18"
}
},
+ "node_modules/redent": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
+ "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "indent-string": "^4.0.0",
+ "strip-indent": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/require-from-string": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
+ "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/resolve-from": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
@@ -3271,6 +4314,19 @@
"integrity": "sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw==",
"license": "MIT"
},
+ "node_modules/saxes": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
+ "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "xmlchars": "^2.2.0"
+ },
+ "engines": {
+ "node": ">=v12.22.7"
+ }
+ },
"node_modules/scheduler": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
@@ -3316,6 +4372,13 @@
"node": ">=8"
}
},
+ "node_modules/siginfo": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
+ "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
+ "dev": true,
+ "license": "ISC"
+ },
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -3325,6 +4388,33 @@
"node": ">=0.10.0"
}
},
+ "node_modules/stackback": {
+ "version": "0.0.2",
+ "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
+ "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/std-env": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz",
+ "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/strip-indent": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz",
+ "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "min-indent": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/strip-json-comments": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
@@ -3351,6 +4441,13 @@
"node": ">=8"
}
},
+ "node_modules/symbol-tree": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
+ "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/tailwindcss": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz",
@@ -3370,6 +4467,23 @@
"url": "https://opencollective.com/webpack"
}
},
+ "node_modules/tinybench": {
+ "version": "2.9.0",
+ "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
+ "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tinyexec": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz",
+ "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/tinyglobby": {
"version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
@@ -3386,6 +4500,62 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
+ "node_modules/tinyrainbow": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz",
+ "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/tldts": {
+ "version": "7.4.2",
+ "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.2.tgz",
+ "integrity": "sha512-kCwffuaH8ntKtygnWe1b4BJKWiCUH30n5KfoTr6IchcXOwR7chAOFJxFrH3vjANafUYrIA4a7SDL+nn7SiR4Sw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tldts-core": "^7.4.2"
+ },
+ "bin": {
+ "tldts": "bin/cli.js"
+ }
+ },
+ "node_modules/tldts-core": {
+ "version": "7.4.2",
+ "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.2.tgz",
+ "integrity": "sha512-nwEyF4vl4RSJjwSjBUmOSxc3BFPoIFdlRthJ6e+5v9P3bHNsoD06UjuqMUspqp7vsEZ1beaHi1km+optiE17yA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tough-cookie": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz",
+ "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "tldts": "^7.0.5"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/tr46": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz",
+ "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "punycode": "^2.3.1"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
"node_modules/ts-api-utils": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
@@ -3419,6 +4589,19 @@
"node": ">= 0.8.0"
}
},
+ "node_modules/type-fest": {
+ "version": "4.41.0",
+ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz",
+ "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==",
+ "dev": true,
+ "license": "(MIT OR CC0-1.0)",
+ "engines": {
+ "node": ">=16"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
@@ -3457,6 +4640,16 @@
"typescript": ">=4.8.4 <6.0.0"
}
},
+ "node_modules/undici": {
+ "version": "7.27.0",
+ "resolved": "https://registry.npmjs.org/undici/-/undici-7.27.0.tgz",
+ "integrity": "sha512-+t2Z/GwkZQDtu00813aP66ygViGtPHKhhoFZpQKpKrE+9jIgES+Zw+mFNaDWOVRKiuJjuqKHzD3B1sfGg8+ZOQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.18.1"
+ }
+ },
"node_modules/undici-types": {
"version": "7.16.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
@@ -3505,6 +4698,13 @@
"punycode": "^2.1.0"
}
},
+ "node_modules/uri-js-replace": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/uri-js-replace/-/uri-js-replace-1.0.1.tgz",
+ "integrity": "sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/vite": {
"version": "8.0.3",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.3.tgz",
@@ -3582,6 +4782,144 @@
}
}
},
+ "node_modules/vitest": {
+ "version": "4.1.8",
+ "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.8.tgz",
+ "integrity": "sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/expect": "4.1.8",
+ "@vitest/mocker": "4.1.8",
+ "@vitest/pretty-format": "4.1.8",
+ "@vitest/runner": "4.1.8",
+ "@vitest/snapshot": "4.1.8",
+ "@vitest/spy": "4.1.8",
+ "@vitest/utils": "4.1.8",
+ "es-module-lexer": "^2.0.0",
+ "expect-type": "^1.3.0",
+ "magic-string": "^0.30.21",
+ "obug": "^2.1.1",
+ "pathe": "^2.0.3",
+ "picomatch": "^4.0.3",
+ "std-env": "^4.0.0-rc.1",
+ "tinybench": "^2.9.0",
+ "tinyexec": "^1.0.2",
+ "tinyglobby": "^0.2.15",
+ "tinyrainbow": "^3.1.0",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
+ "why-is-node-running": "^2.3.0"
+ },
+ "bin": {
+ "vitest": "vitest.mjs"
+ },
+ "engines": {
+ "node": "^20.0.0 || ^22.0.0 || >=24.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "@edge-runtime/vm": "*",
+ "@opentelemetry/api": "^1.9.0",
+ "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
+ "@vitest/browser-playwright": "4.1.8",
+ "@vitest/browser-preview": "4.1.8",
+ "@vitest/browser-webdriverio": "4.1.8",
+ "@vitest/coverage-istanbul": "4.1.8",
+ "@vitest/coverage-v8": "4.1.8",
+ "@vitest/ui": "4.1.8",
+ "happy-dom": "*",
+ "jsdom": "*",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@edge-runtime/vm": {
+ "optional": true
+ },
+ "@opentelemetry/api": {
+ "optional": true
+ },
+ "@types/node": {
+ "optional": true
+ },
+ "@vitest/browser-playwright": {
+ "optional": true
+ },
+ "@vitest/browser-preview": {
+ "optional": true
+ },
+ "@vitest/browser-webdriverio": {
+ "optional": true
+ },
+ "@vitest/coverage-istanbul": {
+ "optional": true
+ },
+ "@vitest/coverage-v8": {
+ "optional": true
+ },
+ "@vitest/ui": {
+ "optional": true
+ },
+ "happy-dom": {
+ "optional": true
+ },
+ "jsdom": {
+ "optional": true
+ },
+ "vite": {
+ "optional": false
+ }
+ }
+ },
+ "node_modules/w3c-xmlserializer": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
+ "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "xml-name-validator": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/webidl-conversions": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
+ "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/whatwg-mimetype": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
+ "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/whatwg-url": {
+ "version": "16.0.1",
+ "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz",
+ "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@exodus/bytes": "^1.11.0",
+ "tr46": "^6.0.0",
+ "webidl-conversions": "^8.0.1"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@@ -3598,6 +4936,23 @@
"node": ">= 8"
}
},
+ "node_modules/why-is-node-running": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
+ "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "siginfo": "^2.0.0",
+ "stackback": "0.0.2"
+ },
+ "bin": {
+ "why-is-node-running": "cli.js"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/word-wrap": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
@@ -3608,6 +4963,23 @@
"node": ">=0.10.0"
}
},
+ "node_modules/xml-name-validator": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
+ "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/xmlchars": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
+ "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
@@ -3615,6 +4987,23 @@
"dev": true,
"license": "ISC"
},
+ "node_modules/yaml-ast-parser": {
+ "version": "0.0.43",
+ "resolved": "https://registry.npmjs.org/yaml-ast-parser/-/yaml-ast-parser-0.0.43.tgz",
+ "integrity": "sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==",
+ "dev": true,
+ "license": "Apache-2.0"
+ },
+ "node_modules/yargs-parser": {
+ "version": "21.1.1",
+ "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
+ "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/yocto-queue": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index 532c212b..0fd8c5c9 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -7,7 +7,10 @@
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
- "preview": "vite preview"
+ "preview": "vite preview",
+ "openapi": "openapi-typescript ../backend/openapi.json -o src/shared/api/generated.ts",
+ "test": "vitest run",
+ "test:watch": "vitest"
},
"dependencies": {
"@tailwindcss/vite": "^4.3.0",
@@ -20,6 +23,9 @@
},
"devDependencies": {
"@eslint/js": "^9.39.4",
+ "@testing-library/jest-dom": "^6.9.1",
+ "@testing-library/react": "^16.3.2",
+ "@testing-library/user-event": "^14.6.1",
"@types/node": "^24.12.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
@@ -29,9 +35,12 @@
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.4.0",
+ "jsdom": "^29.1.1",
+ "openapi-typescript": "^7.13.0",
"prettier": "^3.8.1",
"typescript": "~5.9.3",
"typescript-eslint": "^8.57.0",
- "vite": "^8.0.1"
+ "vite": "^8.0.1",
+ "vitest": "^4.1.8"
}
}
diff --git a/frontend/src/app/router/index.tsx b/frontend/src/app/router/index.tsx
index ee278444..a7008443 100644
--- a/frontend/src/app/router/index.tsx
+++ b/frontend/src/app/router/index.tsx
@@ -4,6 +4,9 @@ import HomePage from '@/pages/Home'
import LoginPage from '@/pages/Login'
import AuthCallbackPage from '@/pages/AuthCallback'
import WorkspacePage from '@/pages/Workspace'
+import InterviewSetupPage from '@/pages/InterviewSetup'
+import InterviewSessionPage from '@/pages/InterviewSession'
+import SessionFeedbackPage from '@/pages/SessionFeedback'
export const router = createBrowserRouter([
{ path: '/', element:
+ 질문 {progress.current} / {progress.max}
+
+ 총 {progress.max}개의 질문이 준비됩니다. 시작하면 첫 질문이 곧 도착합니다.
+ {messageByStatus[status] ?? '면접이 종료되었습니다.'}
+ 분석 완료된 이력서·레포지토리가 없습니다. 워크스페이스에서 먼저 분석하세요.
+
+ 세션 #{id}의 면접이 종료되어 피드백이 준비되었습니다. 상세 리포트 화면은 준비 중입니다.
+
+ 면접 모드와 직군을 고르면 AI가 질문을 생성하고, 실시간으로 답변을 주고받습니다.
+ {
+ return (await apiClient.get(`/api/sessions/${sessionId}/messages`)).data
+}
diff --git a/frontend/src/features/interview/api/sessionApi.ts b/frontend/src/features/interview/api/sessionApi.ts
new file mode 100644
index 00000000..2d405e29
--- /dev/null
+++ b/frontend/src/features/interview/api/sessionApi.ts
@@ -0,0 +1,24 @@
+import { apiClient } from '@/shared/api'
+import type { components } from '@/shared/api/generated'
+
+type S = components['schemas']
+
+export async function createSession(body: S['SessionCreateRequest']): Promise {
+ return (await apiClient.post('/api/sessions', body)).data
+}
+
+export async function getSession(id: number): Promise {
+ return (await apiClient.get(`/api/sessions/${id}`)).data
+}
+
+export async function startSession(id: number): Promise {
+ return (await apiClient.patch(`/api/sessions/${id}/start`)).data
+}
+
+export async function endSession(id: number): Promise {
+ return (await apiClient.patch(`/api/sessions/${id}/end`)).data
+}
+
+export async function interruptSession(id: number): Promise {
+ return (await apiClient.patch(`/api/sessions/${id}/interrupt`)).data
+}
diff --git a/frontend/src/features/interview/index.ts b/frontend/src/features/interview/index.ts
index e69de29b..ecc69d87 100644
--- a/frontend/src/features/interview/index.ts
+++ b/frontend/src/features/interview/index.ts
@@ -0,0 +1,5 @@
+export { LiveInterview } from './ui/live/LiveInterview'
+export { InterviewSetupForm } from './ui/setup/InterviewSetupForm'
+export type { DocOption } from './ui/setup/ContextDocumentPicker'
+export { useCreateSession } from './model/useCreateSession'
+export { useLiveInterview } from './model/useLiveInterview'
diff --git a/frontend/src/features/interview/model/interviewEvent.test.ts b/frontend/src/features/interview/model/interviewEvent.test.ts
new file mode 100644
index 00000000..e643ff7d
--- /dev/null
+++ b/frontend/src/features/interview/model/interviewEvent.test.ts
@@ -0,0 +1,17 @@
+import { describe, it, expect } from 'vitest'
+import { interviewEventAction } from './interviewEvent'
+
+describe('interviewEventAction', () => {
+ it('session.message → 메시지 refetch', () => {
+ expect(interviewEventAction('session.message')).toEqual({ kind: 'refetch-messages' })
+ })
+ it('session.state → 세션 refetch', () => {
+ expect(interviewEventAction('session.state')).toEqual({ kind: 'refetch-session' })
+ })
+ it('feedback.ready → 피드백 리다이렉트', () => {
+ expect(interviewEventAction('feedback.ready')).toEqual({ kind: 'redirect-feedback' })
+ })
+ it('알 수 없는 이벤트는 무시', () => {
+ expect(interviewEventAction('whatever')).toEqual({ kind: 'ignore' })
+ })
+})
diff --git a/frontend/src/features/interview/model/interviewEvent.ts b/frontend/src/features/interview/model/interviewEvent.ts
new file mode 100644
index 00000000..7e516e54
--- /dev/null
+++ b/frontend/src/features/interview/model/interviewEvent.ts
@@ -0,0 +1,18 @@
+export type InterviewAction =
+ | { kind: 'refetch-messages' }
+ | { kind: 'refetch-session' }
+ | { kind: 'redirect-feedback' }
+ | { kind: 'ignore' }
+
+export function interviewEventAction(event: string): InterviewAction {
+ switch (event) {
+ case 'session.message':
+ return { kind: 'refetch-messages' }
+ case 'session.state':
+ return { kind: 'refetch-session' }
+ case 'feedback.ready':
+ return { kind: 'redirect-feedback' }
+ default:
+ return { kind: 'ignore' }
+ }
+}
diff --git a/frontend/src/features/interview/model/optimistic.test.ts b/frontend/src/features/interview/model/optimistic.test.ts
new file mode 100644
index 00000000..57fdecd0
--- /dev/null
+++ b/frontend/src/features/interview/model/optimistic.test.ts
@@ -0,0 +1,35 @@
+import { describe, it, expect } from 'vitest'
+import type { Message } from '@/domain/session'
+import { pendingAnswers, toOptimisticMessage } from './optimistic'
+
+const answerMsg = (content: string): Message => ({ role: 'INTERVIEWEE', content } as Message)
+
+describe('pendingAnswers', () => {
+ it('서버에 같은 내용 답변이 없으면 유지', () => {
+ const result = pendingAnswers([{ tempId: 't1', content: '내 답변' }], [])
+ expect(result).toHaveLength(1)
+ })
+ it('서버에 같은 내용 답변이 있으면 제거', () => {
+ const result = pendingAnswers([{ tempId: 't1', content: '내 답변' }], [answerMsg('내 답변')])
+ expect(result).toHaveLength(0)
+ })
+ it('동일 내용 낙관적 2개 vs 서버 1개 → 1:1 소진으로 1개만 남는다', () => {
+ const result = pendingAnswers(
+ [
+ { tempId: 't1', content: '네' },
+ { tempId: 't2', content: '네' },
+ ],
+ [answerMsg('네')],
+ )
+ expect(result).toHaveLength(1)
+ expect(result[0].tempId).toBe('t2')
+ })
+})
+
+describe('toOptimisticMessage', () => {
+ it('INTERVIEWEE 메시지로 변환', () => {
+ const m = toOptimisticMessage({ tempId: 't1', content: '안녕' })
+ expect(m.role).toBe('INTERVIEWEE')
+ expect(m.content).toBe('안녕')
+ })
+})
diff --git a/frontend/src/features/interview/model/optimistic.ts b/frontend/src/features/interview/model/optimistic.ts
new file mode 100644
index 00000000..b102017f
--- /dev/null
+++ b/frontend/src/features/interview/model/optimistic.ts
@@ -0,0 +1,32 @@
+import type { Message } from '@/domain/session'
+
+export type OptimisticAnswer = { tempId: string; content: string }
+
+// 서버 messages 에 같은 내용의 INTERVIEWEE 답변이 반영된 만큼만 낙관적 항목을 제거한다.
+// content 가 같은 답변이 여러 번 제출돼도 서버 메시지 1개당 낙관적 1개씩만 소진(1:1)되도록
+// 카운트로 매칭한다 — 동일 내용 답변이 한꺼번에 사라지는 문제 방지.
+export function pendingAnswers(
+ optimistic: OptimisticAnswer[],
+ serverMessages: Message[],
+): OptimisticAnswer[] {
+ const remaining = new Map{session.title ?? '모의 면접'}
+ {session.title ?? '모의 면접'}
+ 피드백 준비 완료
+