diff --git a/docs/design/fit-start-guide.md b/docs/design/fit-start-guide.md index 6ba62f7..e3e3018 100644 --- a/docs/design/fit-start-guide.md +++ b/docs/design/fit-start-guide.md @@ -49,7 +49,7 @@ flowchart TD - `store:false`, 자동 재시도 없음, 공급자 대기 25초, 최대 출력 1,600토큰. 서버는 요청 본문이나 공급자 오류 메시지를 로그로 남기지 않는다. - 대화 원문을 DB나 localStorage에 저장하지 않는다. 이 탭의 React 메모리에만 유지하며 새로고침 또는 ‘대화 지우고 다시 시작’으로 지워진다. 안내 숨김 여부만 localStorage에 보관한다. AI 호출 통계에는 비용, 토큰, 시간과 성공 여부를 `guide` 작업으로 저장한다. - 창을 다시 열거나 탭으로 돌아오면 연습실을 확인한다. 확인 전에는 기존 대화를 숨기며 소유자가 바뀌면 대화, 입력과 선택을 초기화한다. POST도 현재 연습실 헤더를 검사한다. -- DB 사용량 제한: 개인 분당 3회 및 24시간 비로그인 2회/로그인 20회, 접속망 24시간 40회. 기존 서비스 공용 시간당 40회/24시간 100회 제한도 적용한다. 문제 생성, 풀이 검토의 개인 한도는 소비하지 않는다. 비로그인은 접속망에서도 24시간 2회를 적용한다. 연결 실패도 시도 횟수에는 포함한다. +- DB 사용량 제한: 개인 분당 3회 및 24시간 비로그인 2회/로그인 20회, 접속망 24시간 40회. 가이드 전용 서비스 공용 한도(시간당 40회/24시간 150회)를 적용하며, 문제 생성·풀이 검토·프로젝트 분석이 쓰는 AI 공용 한도와 개인 한도는 소비하지 않는다. 비로그인은 접속망에서도 24시간 2회를 적용한다. AI 호출이 실패해 기본 추천으로 대체되면 개인 24시간 한도(비로그인 접속망 한도 포함)를 되돌린다. 분당, 접속망과 가이드 공용 한도는 반복 실패를 제한하기 위해 그대로 센다. - 접속망 식별과 사용량 원자성은 기존 SQLite/PostgreSQL 계약을 재사용한다. 쿠키를 지워도 접속망과 전체 한도는 남지만 이것이 모든 남용을 차단한다는 의미는 아니다. - 이 기능은 기록 저장이나 문제 생성이 없는 대화이므로 별도 작업 테이블을 추가하지 않았다. 클라이언트 중복 전송을 막고 자동 재시도하지 않지만, 응답 유실 뒤 사용자가 다시 요청하면 별도 유료 호출이 될 수 있다. 중지·닫기는 늦은 응답을 UI에 적용하지 않으며 이미 발생한 공급자 비용의 취소를 보장하지 않는다. diff --git a/docs/development.md b/docs/development.md index 0e256c3..c42205a 100644 --- a/docs/development.md +++ b/docs/development.md @@ -50,7 +50,7 @@ npm start QuickJS 0.32.0의 파일명을 바꿀 때에는 복사 스크립트와 Worker 경로를 함께 갱신하세요. 브라우저 Worker가 QuickJS를 실행하며 서버에는 사용자 코드를 실행하는 API가 없습니다. -한 테스트당 600ms, 16MiB 힙, 256KiB 스택, 출력 1,600자 제한과 전체 12초 Worker 종료를 적용합니다. +한 테스트당 600ms, 16MiB 힙, 256KiB 스택, 출력 1,600자 제한과 실행 환경 준비(최대 30초) 후 12초 Worker 종료를 적용합니다. 새 런타임을 테스트마다 만들며 DOM, 네트워크, 파일 시스템, 타이머나 모듈 로더를 노출하지 않습니다. 입력은 교육용 JavaScript 함수에 한정합니다. 모바일의 메모리 부족이나 WASM 차단 시에도 초안은 보존됩니다. diff --git a/docs/engineering.md b/docs/engineering.md index c708412..48651f9 100644 --- a/docs/engineering.md +++ b/docs/engineering.md @@ -145,7 +145,7 @@ AI 호출 결과는 `ai_runs`에 모델, 프롬프트 버전, 성공 여부, 지 `generation_usage`는 계정, 요청 ID와 한국 날짜를 기록합니다. 비로그인 하루 2회, 로그인 하루 6회이며 00:00 KST에 갱신됩니다. PostgreSQL은 계정별 advisory lock, SQLite는 `BEGIN IMMEDIATE`로 동시 요청을 직렬화합니다. 먼저 자리를 예약하고 생성 문제 저장과 같은 트랜잭션에서 완료 처리합니다. 실패 예약은 반환하고 프로세스가 중단되면 150초 후 만료합니다. 완료 요청 재시도는 기존 문제를 반환하며 추가 차감하지 않습니다. 로그아웃, 쿠키 변경과 서버 재시작은 완료된 사용량을 초기화하지 않습니다. -AI 풀이 검토, 코드 AI 질문, 실습 AI 코치는 각각 비로그인 2회/로그인 30회이며 첫 사용부터 24시간 기준입니다. 비로그인은 접속망별로도 기능당 2회를 적용해 쿠키 재발급을 통한 반복 체험을 제한합니다. 접속망은 생성 30회, 검토 40회, 전체는 시간당 40회와 24시간당 100회를 공유합니다. 이 공용 보호 한도는 실패도 차감하므로 개인 생성 잔여량보다 먼저 제한될 수 있습니다. Vercel의 공식 IP 헤더를 HMAC 처리하고 다른 호스트의 임의 전달 헤더는 신뢰하지 않습니다. 계정당 제한은 한 사람이 여러 계정을 만드는 행위까지 탐지하는 장치는 아닙니다. +AI 풀이 검토, 코드 AI 질문, 실습 AI 코치는 각각 비로그인 2회/로그인 30회이며 첫 사용부터 24시간 기준입니다. 비로그인은 접속망별로도 기능당 2회를 적용해 쿠키 재발급을 통한 반복 체험을 제한합니다. 접속망은 생성 30회, 검토 40회, 전체는 시간당 40회와 24시간당 100회를 공유합니다. 이 공용 보호 한도는 실패도 차감하므로 개인 생성 잔여량보다 먼저 제한될 수 있습니다. 풀이 검토와 코드 AI 질문은 제공자 호출이나 응답 검증이 실패해 결과를 저장하지 못하면 개인(게스트는 접속망별 포함) 횟수를 돌려줍니다. Vercel의 공식 IP 헤더를 HMAC 처리하고 다른 호스트의 임의 전달 헤더는 신뢰하지 않습니다. 신뢰할 IP가 없는 자체 호스팅에서는 모든 방문자가 한 접속망으로 묶이지 않도록 AI 기능의 접속망별 한도를 생략하고 개인·전체 한도만 적용합니다. 계정당 제한은 한 사람이 여러 계정을 만드는 행위까지 탐지하는 장치는 아닙니다. 사용량에는 모델별 단가를 적용합니다. Sol은 2026-09-13 확인한 표준 문맥의 프로모션 단가(입력 $4, 캐시 입력 $0.40, 출력 $20 / 백만 토큰)를 사용하며 실제 청구액이 아닙니다. 제공자 가격 변경 시 갱신해야 합니다. 현재 웹 백업은 최대 4,000,000바이트, 문제 2,500개, 풀이 10,000개, 입문 기록 500개와 프로젝트 500개이며 복원의 신규 문제/제출 수와 횟수에도 별도 한도를 둡니다. 인증 비밀키와 세션은 사용자용 JSON 백업에 포함하지 않습니다. @@ -459,7 +459,7 @@ flowchart LR 이번 범위는 외부 의존성이 없는 교육용 JavaScript 함수 12개다. **별도 Web Worker의 QuickJS 0.32.0 WASM 런타임**을 선택했다. 메인 UI와 분리하고 브라우저/Node 호스트 기능을 VM에 바인딩하지 않는다. 한 테스트마다 새 런타임을 만들며, 외부 모듈 로더, DOM, 네트워크, 타이머, 파일 시스템을 제공하지 않는다. 비동기 검색 테스트는 타이머 대신 Promise를 지정 순서로 완료시켜 재현한다. -실행당 600ms, VM 힙 16MiB, 스택 256KiB 및 출력 1,600자 제한이 있다. 브라우저 쪽에서는 전체 12초 제한과 취소 시 Worker 강제 종료를 추가한다. 메모리 수치는 VM 힙 제한이며 브라우저 전체 메모리 상한을 뜻하지 않는다. WASM 로딩 실패, 무한 루프, 해결되지 않는 Promise, 문법 오류와 메모리 부족은 오류 결과로 처리한다. 초안 저장은 실행 성공에 의존하지 않는다. +실행당 600ms, VM 힙 16MiB, 스택 256KiB 및 출력 1,600자 제한이 있다. 브라우저 쪽에서는 실행 환경 준비(최대 30초)가 끝난 뒤 12초 제한과 취소 시 Worker 강제 종료를 추가한다. 느린 WASM 로딩은 반복문 시간 초과가 아니라 준비 실패로 안내한다. 메모리 수치는 VM 힙 제한이며 브라우저 전체 메모리 상한을 뜻하지 않는다. WASM 로딩 실패, 무한 루프, 해결되지 않는 Promise, 문법 오류와 메모리 부족은 오류 결과로 처리한다. 초안 저장은 실행 성공에 의존하지 않는다. 비용은 WASM 초기 다운로드, 런타임 차이와 지원 환경의 제한이다. DOM/프레임워크, 네트워크, 타이머나 라이브러리가 필요한 코드는 지원하지 않는다. 다른 분야의 일반 문제는 기존 정적 리뷰를 유지한다. 실행 파일과 라이선스 고지는 postinstall에서 `public/quickjs/`에 복사하며 외부 CDN을 사용하지 않는다. [QuickJS 바인딩 공식 문서](https://github.com/justjake/quickjs-emscripten)의 런타임 제한과 Promise job API를 사용한다. diff --git a/docs/learning-experiments.md b/docs/learning-experiments.md index 085e7e9..d0fa198 100644 --- a/docs/learning-experiments.md +++ b/docs/learning-experiments.md @@ -9,7 +9,7 @@ 3. ‘두 코드로 실험 실행’을 누르면 원본과 현재 수정 코드에서 같은 표현식을 각각 실행한다. 결과는 나란히 표시하며 통과/실패 점수를 붙이지 않는다. 문법 오류나 끝나지 않는 작업은 실행 오류로 표시한다. 4. 마지막 실행의 입력, 예상, 코드 식별값과 두 결과를 기존 학습 저장에 함께 보관한다. 코드나 실행 조건이 달라졌으면 이전 기록이라고 표시한다. 새 실행은 마지막 결과를 갱신한다. -실행 자체는 추가 AI 호출을 만들지 않는다. 원본·수정 코드는 각각 새 Worker와 QuickJS 런타임에서 실행되며 서로의 전역 변수나 변이된 입력을 공유하지 않는다. 한쪽 함수에 오류가 있어도 다른 쪽 결과를 볼 수 있다. 기존 VM 실행당 600ms, 힙 16MiB, 결과 1,600자 제한을 사용하고, 각 Worker는 최대 12초 후 종료된다. 네트워크, DOM, 타이머, 호스트 파일 접근은 제공하지 않는다. 사용자 코드와 AI 제안 표현식을 서버에서 실행하지 않는다. +실행 자체는 추가 AI 호출을 만들지 않는다. 원본·수정 코드는 각각 새 Worker와 QuickJS 런타임에서 실행되며 서로의 전역 변수나 변이된 입력을 공유하지 않는다. 한쪽 함수에 오류가 있어도 다른 쪽 결과를 볼 수 있다. 기존 VM 실행당 600ms, 힙 16MiB, 결과 1,600자 제한을 사용하고, 각 Worker는 실행 환경 준비 후 최대 12초가 지나면 종료된다. 네트워크, DOM, 타이머, 호스트 파일 접근은 제공하지 않는다. 사용자 코드와 AI 제안 표현식을 서버에서 실행하지 않는다. ## 저장과 비동기 작업 diff --git a/docs/project-ai-workshop.md b/docs/project-ai-workshop.md index 3a59393..e0de135 100644 --- a/docs/project-ai-workshop.md +++ b/docs/project-ai-workshop.md @@ -12,7 +12,7 @@ AI 워크숍 홈과 `/learn/ai` 상단에서 공개 소스 저장소 또는 GitH ## 이용 흐름과 저장 -새 저장소 분석 후 학습 생성으로 진행한다. 기존 분석이 있으면 바로 학습을 만들 수 있다. 새 분석 1회와 학습 생성 1회가 프로젝트 분석 한도를 공유한다. 이미 생성된 학습을 조회하거나 답변을 저장할 때는 AI를 다시 호출하지 않는다. +새 저장소는 입력 화면에서 분석 1회와 학습 생성 1회 사용을 안내하고, 제출하면 분석을 마친 뒤 학습 생성까지 이어서 진행한다. AI 워크숍 홈의 저장소 입력도 같은 안내 후 제출하면 이 화면에서 바로 분석을 시작한다. 이 자동 진행은 제출한 탭에서 한 번만 일어나며 링크나 새로고침으로는 시작하지 않는다. 기존 분석이 있으면 학습 만들기 버튼으로 생성한다. 없는 분석 ID로 열면 다른 프로젝트를 선택하거나 새로 분석하는 화면으로 안내한다. 새 분석 1회와 학습 생성 1회가 프로젝트 분석 한도를 공유한다. 이미 생성된 학습을 조회하거나 답변을 저장할 때는 AI를 다시 호출하지 않는다. 코드 근거 → 관련 개념 수업 → 내 프로젝트 실험 계획 → 확인 문제와 적용 기록 순서다. 관련 수업에서 원래 프로젝트의 주제로 돌아오는 링크를 제공한다. 기록은 저장 버튼으로 서버에 보관하며 수정할 수 있다. 다른 창에서 기록을 바꾸면 리비전 충돌을 알리고 서버 기록을 불러올 수 있다. 기본 수업의 진도는 기존처럼 브라우저에 저장한다. diff --git a/docs/project-check.md b/docs/project-check.md index e52d08a..a331e49 100644 --- a/docs/project-check.md +++ b/docs/project-check.md @@ -37,7 +37,7 @@ - 개인 한도 외 접속망당 분석 10회/평가 20회/24시간, 기존 AI 전체 40회/시간 및 100회/24시간을 함께 적용한다. - 주소 읽기는 개인 5회/분, 접속망 20회/분으로 제한한다. - 프로젝트 질문 생성과 평가에서는 페이지 읽기 실패와 근거 부족은 AI 한도를 차감하지 않는다. AI 호출 전 한도를 원자적으로 소비하며, 호출을 시작한 뒤 실패하거나 응답이 유실돼도 환급하지 않는다. 외부 호출 비용 발생 여부를 확정할 수 없기 때문이다. 자동 유료 재시도는 없다. -- 같은 요청 ID와 같은 입력의 완료 결과는 재사용한다. 진행 중 중복 요청은 409로 알리고 같은 ID를 다른 내용으로 재사용하면 거절한다. 수동 재시도는 이전 입력을 보존한다. +- 같은 요청 ID와 같은 입력의 완료 결과는 재사용한다. 진행 중 중복 요청은 409로 알리고 같은 ID를 다른 내용으로 재사용하면 거절한다. 다만 분석별로 ID가 정해진 답변 평가와 코드 대화는 이전 시도가 실패했으면 수정한 답변으로 다시 요청할 수 있다. 수동 재시도는 이전 입력을 보존한다. - `ai_runs`에 project 작업의 실제 모델, 입력/캐시/출력 토큰, 예상 달러 비용과 지연 시간을 기록한다. URL이나 답변을 로그에 출력하지 않는다. ## 데이터와 주소 수집 diff --git a/docs/project-classes.md b/docs/project-classes.md index 0e076ba..d8bdf4f 100644 --- a/docs/project-classes.md +++ b/docs/project-classes.md @@ -8,7 +8,8 @@ - 다른 탭에서 돌아오면 이미 불러온 목록을 갱신하면서 검색어와 불러온 페이지 범위를 유지한다. 검색어는 계정별 sessionStorage에 보관해 상세 화면에서 목록으로 돌아와도 복원한다. 상세 화면도 최신 진도를 반영하며 편집 중인 이름과 메모는 유지한다. 편집 시작 시점의 리비전으로 충돌을 검사한다. - 상세 화면에서 과정별 진도와 이어하기 링크를 제공한다. 준비하지 않은 과정은 생성 화면으로 이동하며 기존 사용량 정책을 따른다. - 복습은 설계 질문의 이전 답변과 생성된 확인 문제를 다시 살펴보되 기존 제출 결과는 바꾸지 않는 흐름이다. 객관식 정답과 해설은 선택 후 표시한다. 복습 위치, 문제별 메모, 선택과 해설 공개 상태는 계정과 클래스별로 구분해 이 브라우저의 localStorage에 보관한다. 화면을 나가거나 새로고침해도 이어서 복습할 수 있다. 다른 기기 동기화와 서버 백업에는 포함하지 않는다. 기존 답변과 학습 진도를 덮어쓰지 않으며 브라우저 저장 실패 시 안내한다. Web Locks로 탭 간 저장을 직렬화하고 저장 직전에 최신 기록과 병합한다. 같은 문제의 메모가 충돌하면 다른 탭의 메모도 별도로 보관해 표시한다. 잠금이나 브라우저 저장을 사용할 수 없으면 미저장 상태를 안내한다. 실패한 변경은 화면의 대기 목록에 보존하고 다음 저장에 함께 재시도한다. 저장에 성공한 변경만 대기 목록에서 제거하므로 같은 복습 화면 안에서 다른 문제로 이동해도 미저장 메모를 유지한다. 이 대기 목록은 메모리에만 있어 저장에 성공하기 전에 새로고침하거나 화면을 닫으면 미저장 변경은 복구할 수 없다. AI 호출은 없다. -- 삭제 전 클래스와 함께 지워지는 기록을 확인한다. 분석, 평가, 실습, AI 학습, 중간 생성 단계가 함께 삭제되며 실제 저장소에는 영향을 주지 않는다. +- 보완 답변은 원래 분석의 클래스에 속한다. 목록과 분석 이력에 별도 클래스로 표시하지 않고 원래 클래스 화면에서 연다. 코드 이해 훈련, 서비스 동작 실습, AI 활용 학습과 기초 개념 연습은 원래 분석의 기록을 그대로 사용해 다시 생성하지 않는다. 보완은 원래 분석당 합계 3번까지 가능하다. +- 삭제 전 클래스와 함께 지워지는 기록을 확인한다. 분석, 보완 답변, 평가, 실습, AI 학습, 중간 생성 단계가 함께 삭제되며 실제 저장소에는 영향을 주지 않는다. 어느 화면에서 삭제해도 이 브라우저에 남은 답변 초안, 복습 기록, 선택 상태와 분석 알림을 함께 정리한다. - 최신 코드 분석은 별도 클래스를 만든다. 기존 클래스는 그대로 유지한다. 같은 저장소 주소의 분석 기록을 날짜와 함께 연결하며, 주소 끝의 슬래시와 .git 표기는 같은 주소로 취급한다. 탭 복귀와 백업 가져오기 후 이력의 기존 조회 범위를 갱신한다. 다른 브랜치 경로나 별칭 주소까지 합치지는 않는다. 버전 간 차이를 자동 비교하거나 기존 진도를 새 코드에 이관하지는 않는다. ## 소유권과 데이터 diff --git a/docs/sandbox-integrity.md b/docs/sandbox-integrity.md index c95f271..c48372a 100644 --- a/docs/sandbox-integrity.md +++ b/docs/sandbox-integrity.md @@ -28,7 +28,7 @@ JSON의 변환 규칙 자체는 [ECMAScript 명세](https://tc39.es/ecma262/mult `NaN`, 무한대, undefined, 함수, Symbol, BigInt, Date, Map, 순환 참조, 배열의 빈 자리와 지원하지 않는 속성은 오류로 안내한다. 결과를 직렬화하기 위해 getter나 `toJSON`을 호출하지 않는다. 학습자 코드나 테스트 표현식 자체에서 속성을 읽는 동작까지 금지하는 것은 아니다. 예를 들어 Date가 필요하면 함수가 비교할 문자열이나 숫자를 직접 반환하면 된다. -출력은 1,600자, 방문 항목은 1,000개, 중첩은 40단계로 제한한다. 기존의 테스트별 0.6초, 메모리 16MB, 스택 256KB와 브라우저 Worker의 전체 12초 중단 제한을 유지한다. 무한 반복, 끝나지 않는 Promise와 실행 예산을 소모하는 Proxy도 오류로 종료한다. 각 테스트는 새 런타임과 컨텍스트를 사용하고 핸들을 정리한다. DOM, 네트워크, 타이머, 모듈 로더나 호스트 함수를 추가하지 않았다. [QuickJS 라이브러리의 컨텍스트·메모리 계약](https://github.com/justjake/quickjs-emscripten)을 따른다. +출력은 1,600자, 방문 항목은 1,000개, 중첩은 40단계로 제한한다. 기존의 테스트별 0.6초, 메모리 16MB, 스택 256KB와 브라우저 Worker의 12초 중단 제한(실행 환경 준비 후부터 측정)을 유지한다. 무한 반복, 끝나지 않는 Promise와 실행 예산을 소모하는 Proxy도 오류로 종료한다. 각 테스트는 새 런타임과 컨텍스트를 사용하고 핸들을 정리한다. DOM, 네트워크, 타이머, 모듈 로더나 호스트 함수를 추가하지 않았다. [QuickJS 라이브러리의 컨텍스트·메모리 계약](https://github.com/justjake/quickjs-emscripten)을 따른다. ## 기존 기록과 사용자 안내 diff --git a/docs/security-check.md b/docs/security-check.md index e04cd92..82071ba 100644 --- a/docs/security-check.md +++ b/docs/security-check.md @@ -4,13 +4,13 @@ ## 기본 공개 설정 검사 -입력한 문서를 GET으로 읽고 최종 응답의 CSP, HSTS, frame-ancestors/X-Frame-Options, nosniff, Set-Cookie의 Secure 속성을 확인한다. HTML CSP 메타 태그의 존재와 헤더의 차이를 구분한다. 적용용 헤더의 존재는 정책 강도나 브라우저 적용 성공을 뜻하지 않는다. Report-Only는 적용용 CSP로 계산하지 않는다. 쿠키 값과 이름, 원본 HTML은 결과에 포함하지 않는다. +입력한 문서를 GET으로 읽고 최종 응답의 CSP, HSTS, frame-ancestors/X-Frame-Options, nosniff, Set-Cookie의 Secure 속성을 확인한다. 최종 응답은 상태 코드와 형식에 관계없이 헤더를 확인한다(봇 확인 403, 404, JSON 등). 본문은 압축되지 않은 HTML일 때만 메타 태그 확인에 사용한다. 리디렉션이 있었으면 이동 경로와 최종 응답 코드를 결과에 표시하며, 사이트가 붙인 쿼리 문자열은 결과에서 제외한다. HSTS의 max-age는 RFC 6797의 따옴표 형식(`max-age="31536000"`)도 인정한다. HTML CSP 메타 태그의 존재와 헤더의 차이를 구분한다. 적용용 헤더의 존재는 정책 강도나 브라우저 적용 성공을 뜻하지 않는다. Report-Only는 적용용 CSP로 계산하지 않는다. 쿠키 값과 이름, 원본 HTML은 결과에 포함하지 않는다. 결과는 설정 관찰, 보완 검토, 추가 확인 필요로 구분한다. 로그인, 폼 제출, 공격 페이로드, 포트 탐색, 경로 추측, 자바스크립트 실행, 서브리소스 요청은 수행하지 않는다. 접근 권한과 데이터베이스 정책은 확인 불가로 표시하고 소유한 테스트 환경의 수동 검증 과제로 연결한다. 보완 검토 수는 취약점 수가 아니다. ## 요청 제한 -기존 공개 페이지 수집기의 DNS IPv4 검증, 확인한 주소에 고정 연결, 리디렉션 재검증(최대 2회), 12초 및 600KB 제한을 공유한다. HTTPS 공개 도메인만 허용하며 사용자 정보, IP 리터럴, 쿼리, 해시와 사설 주소를 거절한다. 세션별 시간당 5회, 네트워크별 15회, 대상 호스트별 10회, 전역 일일 150회로 제한한다. 오류 요청도 수집 시도 예산에 포함된다. 로그인 없이 실행 가능하나 소유 또는 허락받은 서비스라는 확인이 필요하다. +기존 공개 페이지 수집기의 DNS IPv4 검증, 확인한 주소에 고정 연결, 리디렉션 재검증(최대 2회), 12초 및 600KB 제한을 공유한다. HTTPS 공개 도메인만 허용하며 입력 주소의 사용자 정보, IP 리터럴, 쿼리, 해시와 사설 주소를 거절한다. 사이트가 지정한 리디렉션 대상은 쿼리 문자열을 허용하되 HTTPS, 포트, 사설 주소 검증은 동일하게 적용한다. 프로젝트 점검은 기존대로 200 HTML 응답과 쿼리 없는 주소만 사용한다. 세션별 시간당 5회, 네트워크별 15회, 대상 호스트별 10회, 전역 일일 150회로 제한한다. 대상 사이트의 접속 실패, 시간 초과, 허용되지 않는 리디렉션처럼 입력 오류가 아닌 실패는 세션별 한도만 되돌리고, 네트워크, 대상과 전역 한도에는 그대로 포함한다. 로그인 없이 실행 가능하나 소유 또는 허락받은 서비스라는 확인이 필요하다. 원본 응답과 보고서를 DB나 AI 제공자에 보내지 않으며 서버에는 사용량 제한용 식별자만 남긴다. 주소, 결과와 세 가지 수동 확인 메모는 현재 탭의 `sessionStorage`에 `codefit-security:` 키로 임시 보관한다. 새로고침과 관련 실습 왕복 후 복원하며, 다른 계정의 화면에 표시하지 않는다. 소유·허락 확인 체크는 복원하지 않는다. @@ -22,7 +22,7 @@ ## 검증 -관련 단위 테스트는 메타 CSP, Report-Only, HSTS max-age=0, 무효 XFO, 쿠키 비공개 처리, 내부 주소 거절, 수집 전 사용량 제한을 확인한다. 브라우저 테스트는 입력과 확인, 결과 다운로드, 오류 시 이전 결과 제거 및 모바일 폭을 확인한다. `e2e/security-records.spec.ts`는 복원과 계정 격리, `e2e/experience-ui.spec.ts`는 상태 필터, 전체 내보내기와 메모 작성 현황을 확인한다. 고정 응답을 사용하는 UI 검사이며 외부 서비스의 실제 취약점을 확인한 결과가 아니다. +관련 단위 테스트는 메타 CSP, Report-Only, HSTS max-age=0과 따옴표 max-age, 무효 XFO, 쿠키 비공개 처리, 내부 주소 거절, 수집 전 사용량 제한, 쿼리 리디렉션과 비HTML 최종 응답(`public-document.test.ts`), 대상 실패 시 세션 한도 복원을 확인한다. 브라우저 테스트는 입력과 확인, 결과 다운로드, 오류 시 이전 결과 제거 및 모바일 폭을 확인한다. `e2e/security-records.spec.ts`는 복원과 계정 격리, `e2e/experience-ui.spec.ts`는 상태 필터, 전체 내보내기와 메모 작성 현황을 확인한다. 고정 응답을 사용하는 UI 검사이며 외부 서비스의 실제 취약점을 확인한 결과가 아니다. 참고: [MDN CSP](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Security-Policy), [MDN HSTS](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Strict-Transport-Security). @@ -32,7 +32,7 @@ 각 연결은 공개 DNS 검사와 IP 고정을 수행한다. 리디렉션은 따라가지 않으며 쿠키, 인증 토큰과 임의 헤더를 전달하지 않는다. 각 요청은 6초, 전체 26초 제한이다. 확인 파일은 4KB까지만 읽고 검사 대상의 응답 본문은 보관하지 않는다. 반환하는 헤더는 Access-Control-Allow-Origin, Access-Control-Allow-Credentials와 Vary뿐이다. 계정과 대상은 시간당 8회, 접속망은 15회, 서비스 전체는 하루 80회로 제한한다. 파일 발급과 실패 시도도 예산에 포함한다. -임의 Origin 또는 null 반영과 credentials=true를 함께 관찰하면 우선 검토로 표시한다. 실제 인증 데이터 유출을 입증한 것은 아니다. 별표와 credentials 조합을 곧바로 취약점이라고 하지 않으며, 인증 API와 브라우저 조건은 별도 확인하도록 안내한다. 결과는 현재 화면에만 남으며 요청 조건과 응답 비교를 텍스트로 내려받는다. +임의 Origin 또는 null 반영과 credentials=true를 함께 관찰하면 우선 검토로 표시한다. 실제 인증 데이터 유출을 입증한 것은 아니다. 별표와 credentials 조합을 곧바로 취약점이라고 하지 않으며, 인증 API와 브라우저 조건은 별도 확인하도록 안내한다. 결과는 현재 화면에만 남으며 요청 조건과 응답 비교를 텍스트로 내려받는다. 발급한 확인 파일 정보는 유효 시간 동안 현재 탭의 `sessionStorage`에 계정과 Origin별로 보관해, 파일 배포 후 새로고침하거나 같은 Origin 안에서 경로를 고쳐도 같은 토큰으로 확인한다. ## 외부 ZAP 보고서 검토 diff --git a/docs/workspace-backup.md b/docs/workspace-backup.md index a90f757..3ef8d57 100644 --- a/docs/workspace-backup.md +++ b/docs/workspace-backup.md @@ -18,17 +18,17 @@ ## 일관성과 복원 정책 -SQLite는 트랜잭션, PostgreSQL은 읽기 전용 repeatable-read 트랜잭션에서 백업을 읽는다. 가져오기 전에 새 학습 기록의 문항·참조·점수 관계를 검증하며, 실제 삽입은 코딩 기록부터 프로젝트 평가까지 하나의 트랜잭션으로 수행한다. 마지막 평가 저장에서 실패를 주입해 전체 롤백을 확인했다. +SQLite는 트랜잭션, PostgreSQL은 읽기 전용 repeatable-read 트랜잭션에서 백업을 읽는다. 가져오기 전에 새 학습 기록의 문항·참조·점수 관계와 코딩 진도·제출이 가리키는 문제와 검토 기준을 검증하며, 이 검증에 실패한 파일은 가져오기 횟수를 차감하지 않는다. 저장소 오류는 파일 불일치(400)가 아닌 서버 오류(500)로 알린다. 실제 삽입은 코딩 기록부터 프로젝트 평가까지 하나의 트랜잭션으로 수행한다. 마지막 평가 저장에서 실패를 주입해 전체 롤백을 확인했다. 기존 코딩 진도, 입문 실습과 프로젝트는 덮어쓰지 않는다. 프로젝트는 분석과 평가·학습을 한 묶음으로 취급하므로 기존 분석만 있는 경우도 부분 병합하지 않는다. 반복 가져오기에서 동일 제출·프로젝트를 중복 생성하지 않는다. 다른 계정으로 가져온 프로젝트는 대상 계정과 원본 ID에서 파생한 새 UUID로 연결하며, 원본 계정의 직접 링크는 새 계정의 기록을 노출하지 않는다. 새 입문 실습과 코드는 대상 저장소의 리비전을 발급한다. -새로 가져온 문제의 계정 연결을 `restored_problems`에 보관한다. 진도 없이 보관한 문제도 재내보내기와 SQLite→PostgreSQL 이관에서 유지한다. 공개 문제의 기존 내용은 수정하지 않는다. 가져온 파일의 서술·점수가 실제 AI 생성 결과라는 진위는 보증하지 않는다. +새로 가져온 문제의 계정 연결을 `restored_problems`에 보관한다. 진도 없이 보관한 문제도 재내보내기와 SQLite→PostgreSQL 이관에서 유지한다. 서비스에 없던 문제는 가져온 계정에만 보이도록 `problems.owner`와 `problem_catalog.owner`에 소유자를 기록한다. 다른 계정의 문제 목록, 통계, 추천과 직접 링크에는 나타나지 않으며, 파일의 출처 값과 관계없이 AI 문제로 표시하고 미래의 생성 시각은 가져온 시각으로 낮춘다. 이미 있는 기본·공개 문제의 ID는 기존처럼 연결만 하며 내용은 수정하지 않는다. 이 변경 전에 가져온 문제는 소유자를 구분할 수 없어 공개 상태로 남는다. 가져온 파일의 서술·점수가 실제 AI 생성 결과라는 진위는 보증하지 않는다. ## 공통 한도 한 파일은 UTF-8 **4,000,000바이트 (4 MB)**, 문제 2,500개, 풀이 10,000개, 입문 실습 500개, 프로젝트 500개까지다. 내보내기와 브라우저 다운로드는 같은 압축되지 않은 compact JSON 직렬화를 사용한다. 개수나 바이트 한도를 넘으면 413으로 거절하며 일부 기록만 내보내지 않는다. 대용량 분할 백업은 아직 지원하지 않는다. -가져오기는 계정당 24시간 5회, 접속망당 10회, 서비스 전체 20회의 제한을 유지한다. 서비스 전체 새 문제 한도는 파일 스키마와 같은 2,500개, 풀이 한도는 10,000개다. 다른 요청이 이미 사용한 일일 한도가 있으면 유효한 백업도 일시적으로 429가 될 수 있으며, 기록 삽입 전에 거절한다. 이전의 새 문제 100개 제한 때문에 정상 백업을 가져올 수 없던 경우를 수정했다. Vercel Functions의 요청·응답 4.5 MB 제한 안에서 오류 메시지까지 전달할 수 있도록 앱 한도를 4 MB로 둔다. 이전 로컬 버전에서 만든 4 MB 초과 파일은 이 웹 가져오기로 복원할 수 없다. 대용량 파일은 별도 이관 도구가 필요하다. 가져오기 전 기존 문제 확인은 ID 500개씩 묶어서 조회하며 최대 5회다. +가져오기는 계정당 24시간 5회, 접속망당 10회로 제한한다. 신뢰할 수 있는 접속 IP가 없는 자체 호스팅(Vercel 외)에서는 모든 방문자가 한 접속망으로 묶이지 않도록 접속망 한도를 적용하지 않는다. 서비스 전체 한도(24시간 1,000회, 새 문제 250,000개, 풀이 1,000,000개)는 폭주만 막는 상한이며 한 사람이 모두 소진하기 어렵게 크게 두었다. 한도에 걸린 요청은 기록 삽입 전에 429로 거절한다. 이전의 새 문제 100개 제한 때문에 정상 백업을 가져올 수 없던 경우를 수정했다. Vercel Functions의 요청·응답 4.5 MB 제한 안에서 오류 메시지까지 전달할 수 있도록 앱 한도를 4 MB로 둔다. 이전 로컬 버전에서 만든 4 MB 초과 파일은 이 웹 가져오기로 복원할 수 없다. 대용량 파일은 별도 이관 도구가 필요하다. 가져오기 전 기존 문제 확인은 ID 500개씩 묶어서 조회하며 최대 5회다. ## 검증 diff --git a/e2e/security-records.spec.ts b/e2e/security-records.spec.ts index f5b0624..f3ebe4d 100644 --- a/e2e/security-records.spec.ts +++ b/e2e/security-records.spec.ts @@ -82,7 +82,11 @@ test("security drafts stay with their account across focus refresh and sign-out" page, }) => { let scope = "user:security-a"; - await page.route("**/api/workspace", (route) => route.fulfill({ json: { scope } })); + await page.route("**/api/guide", (route) => + route.request().method() === "GET" + ? route.fulfill({ json: { scope, aiReady: false } }) + : route.fallback(), + ); await page.goto("/security-check"); await page.locator(".security-exercises summary").first().click(); const note = page.locator(".security-exercises textarea").first(); diff --git a/e2e/workbench-upgrade.spec.ts b/e2e/workbench-upgrade.spec.ts index c8e515a..4a5a371 100644 --- a/e2e/workbench-upgrade.spec.ts +++ b/e2e/workbench-upgrade.spec.ts @@ -98,6 +98,13 @@ test("owned CORS workflow separates proof from execution and invalidates target await panel.getByRole("button", { name: "소유권 확인 파일 발급" }).click(); await expect(panel.getByText("1. 파일을 배포하세요")).toBeVisible(); expect(runs).toBe(0); + // Fixing the path or refreshing after deploying the file keeps the issued challenge. + await page.getByLabel("점검할 공개 서비스 링크").fill("https://example.com"); + await expect(panel.getByText("1. 파일을 배포하세요")).toBeVisible(); + await page.reload(); + await page.getByText("소유권 확인 후 CORS 테스트", { exact: true }).click(); + await expect(panel.getByText("1. 파일을 배포하세요")).toBeVisible(); + await panel.getByRole("checkbox").check(); await panel.getByRole("button", { name: "소유권 확인하고 CORS 테스트" }).click(); await expect(panel.getByRole("region", { name: "CORS 테스트 결과" })).toContainText( "credentials=true", diff --git a/scripts/lib/migration-data.mjs b/scripts/lib/migration-data.mjs index f1655e9..335e992 100644 --- a/scripts/lib/migration-data.mjs +++ b/scripts/lib/migration-data.mjs @@ -16,7 +16,7 @@ const tables = { "updatedAt", ], generation_usage: ["request_id", "owner", "day", "state", "expires"], - problems: ["id", "content", "created_at"], + problems: ["id", "content", "created_at", "owner"], restored_problems: ["owner", "problem_id"], progress: [ "owner", @@ -50,7 +50,9 @@ export function* readMigrationData(source) { .map((key) => key === "code_revision" && !available.has(key) ? "CASE WHEN code IS NULL THEN 0 ELSE 1 END AS code_revision" - : `"${key}"`, + : key === "owner" && !available.has(key) + ? "NULL AS owner" + : `"${key}"`, ) .join(","); const rows = source diff --git a/scripts/lib/migration-data.test.mjs b/scripts/lib/migration-data.test.mjs index 5c15405..a3683ea 100644 --- a/scripts/lib/migration-data.test.mjs +++ b/scripts/lib/migration-data.test.mjs @@ -149,9 +149,18 @@ it("preserves restored problem ownership when moving databases", () => { db.exec(storageSchema); db.prepare("INSERT INTO problems VALUES (?,?,?)").run("saved", "{}", "2026-09-19"); db.prepare("INSERT INTO restored_problems VALUES (?,?)").run("alice", "saved"); - expect( - [...readMigrationData(db)].find((entry) => entry.table === "restored_problems").rows, - ).toEqual([["alice", "saved"]]); + const data = [...readMigrationData(db)]; + expect(data.find((entry) => entry.table === "restored_problems").rows).toEqual([ + ["alice", "saved"], + ]); + expect(data.find((entry) => entry.table === "problems").rows).toEqual([ + ["saved", "{}", "2026-09-19", null], + ]); + db.exec("ALTER TABLE problems ADD COLUMN owner TEXT"); + db.prepare("UPDATE problems SET owner='alice'").run(); + expect([...readMigrationData(db)].find((entry) => entry.table === "problems").rows[0][3]).toBe( + "alice", + ); } finally { db.close(); } diff --git a/src/app/api/guide/route.test.ts b/src/app/api/guide/route.test.ts index 0060947..396a4e5 100644 --- a/src/app/api/guide/route.test.ts +++ b/src/app/api/guide/route.test.ts @@ -1,12 +1,13 @@ import { beforeEach, afterEach, expect, it, vi } from "vitest"; -const { session, consumeLimits, askGuide } = vi.hoisted(() => ({ +const { session, consumeLimits, refundLimits, askGuide } = vi.hoisted(() => ({ session: vi.fn(), consumeLimits: vi.fn(), + refundLimits: vi.fn(), askGuide: vi.fn(), })); vi.mock("@/lib/server/session", () => ({ session })); vi.mock("@/lib/server/database", () => ({ - getStore: async () => ({ consumeLimits, queries: { recordAiRun: vi.fn() } }), + getStore: async () => ({ consumeLimits, queries: { recordAiRun: vi.fn(), refundLimits } }), })); vi.mock("@/lib/server/ai-guide", () => ({ askGuide })); import { POST, GET } from "./route"; @@ -27,6 +28,7 @@ beforeEach(() => { vi.stubEnv("VERCEL", ""); session.mockReset().mockResolvedValue({ owner: "one", scope: "guest:one" }); consumeLimits.mockReset().mockResolvedValue(true); + refundLimits.mockReset().mockResolvedValue(undefined); askGuide.mockReset(); }); afterEach(() => vi.unstubAllEnvs()); @@ -58,8 +60,12 @@ it("uses separate guide allowances and shared service caps before calling AI", a (e: { key: string }) => e.key.includes("ai:review:") || e.key.includes("ai:generate:"), ), ).toBe(false); - expect(entries.some((e: { key: string }) => e.key === "ai:global:day")).toBe(true); + // The guide has its own service budget and never spends the core AI features' global windows. + expect(entries.some((e: { key: string }) => e.key.startsWith("ai:"))).toBe(false); + expect(entries.some((e: { key: string }) => e.key === "guide:global:day")).toBe(true); + expect(entries.some((e: { key: string }) => e.key === "guide:global:hour")).toBe(true); expect(askGuide).toHaveBeenCalledOnce(); + expect(refundLimits).not.toHaveBeenCalled(); }); it("does not retry or charge a provider request when rate limited, and recovers provider failures", async () => { consumeLimits.mockResolvedValue(false); @@ -73,6 +79,8 @@ it("does not retry or charge a provider request when rate limited, and recovers expect(reply.source).toBe("basic"); expect(JSON.stringify(reply)).not.toContain("private provider details"); expect(askGuide).toHaveBeenCalledOnce(); + // Only the personal daily allowances are returned; burst, network and service windows remain. + expect(refundLimits).toHaveBeenCalledWith(["guide:guest-network:local", "guide:owner:one"]); }); it("rejects oversized and malformed chat histories before any AI work", async () => { expect( diff --git a/src/app/api/guide/route.ts b/src/app/api/guide/route.ts index fdaf37e..39e977b 100644 --- a/src/app/api/guide/route.ts +++ b/src/app/api/guide/route.ts @@ -33,15 +33,20 @@ export async function POST(request: Request) { ); const store = await getStore(); const network = networkIdentity(request); - const allowed = await store.consumeLimits([ + // Personal daily allowances are refunded when the provider call fails. + const personal = [ ...(!owner.startsWith("user:") ? [{ key: `guide:guest-network:${network}`, max: 2, windowMs: 86_400_000 }] : []), - { key: `guide:burst:${owner}`, max: 3, windowMs: 60_000 }, { key: `guide:owner:${owner}`, max: allowanceFor(owner).guide, windowMs: 86_400_000 }, + ]; + const allowed = await store.consumeLimits([ + ...personal, + { key: `guide:burst:${owner}`, max: 3, windowMs: 60_000 }, { key: `guide:network:${network}`, max: 40, windowMs: 86_400_000 }, - { key: "ai:global:hour", max: 40, windowMs: 3_600_000 }, - { key: "ai:global:day", max: 100, windowMs: 86_400_000 }, + // A separate service budget: guide traffic never exhausts review, generation or analysis. + { key: "guide:global:hour", max: 40, windowMs: 3_600_000 }, + { key: "guide:global:day", max: 150, windowMs: 86_400_000 }, ]); if (!allowed) return json( @@ -56,6 +61,9 @@ export async function POST(request: Request) { ); } catch { // Do not retry paid requests automatically or expose provider errors / user text. + // Burst, network and service windows still count the attempt to bound repeated failures. + if (!request.signal.aborted) + await store.queries.refundLimits(personal.map((entry) => entry.key)).catch(() => {}); return json( basicGuide( input.profile, diff --git a/src/app/api/import/route.ts b/src/app/api/import/route.ts index 37e5a0d..74e7986 100644 --- a/src/app/api/import/route.ts +++ b/src/app/api/import/route.ts @@ -1,12 +1,21 @@ import { importLimits, networkIdentity } from "@/lib/server/usage-policy"; import { backupSchema } from "@/lib/backup"; import { BACKUP_MAX_BYTES } from "@/lib/backup-limits"; -import { prepareBackup } from "@/lib/server/workspace-backup"; +import { + BackupMismatch, + checkBackupReferences, + prepareBackup, +} from "@/lib/server/workspace-backup"; import { getStore } from "@/lib/server/database"; import { failure, HttpError, json, readBody } from "@/lib/server/http"; import { session } from "@/lib/server/session"; export const runtime = "nodejs"; export const maxDuration = 120; +const mismatch = () => + new HttpError( + 400, + "백업의 문제와 풀이 기록이 일치하지 않습니다. 원본 백업 파일을 확인해 주세요.", + ); export async function POST(request: Request) { try { const { owner } = await session(request); @@ -20,6 +29,17 @@ export async function POST(request: Request) { ); } const store = await getStore(); + // A file that cannot be restored must not spend the import allowance. + try { + await checkBackupReferences( + backup, + async (id) => store.problem(id, owner), + async (id) => Boolean(await store.problem(id)), + ); + } catch (error) { + if (error instanceof BackupMismatch) throw mismatch(); + throw error; + } const existing = await store.queries.countExistingProblems(backup.problems.map((p) => p.id)); const newProblems = backup.problems.length - existing; if ( @@ -34,11 +54,9 @@ export async function POST(request: Request) { ); try { return json(await store.importBackup(owner, backup)); - } catch { - throw new HttpError( - 400, - "백업의 문제와 풀이 기록이 일치하지 않습니다. 원본 백업 파일을 확인해 주세요.", - ); + } catch (error) { + if (error instanceof BackupMismatch) throw mismatch(); + throw error; } } catch (error) { return failure(error); diff --git a/src/app/api/problems/[id]/coach/route.ts b/src/app/api/problems/[id]/coach/route.ts index 3104571..b25e312 100644 --- a/src/app/api/problems/[id]/coach/route.ts +++ b/src/app/api/problems/[id]/coach/route.ts @@ -16,9 +16,10 @@ export const runtime = "nodejs"; export const maxDuration = 120; export async function POST(request: Request, context: { params: Promise<{ id: string }> }) { let lease: JobLease | undefined; + let refund: (() => Promise) | undefined; try { const { owner } = await session(request); - const problem = await requireProblem((await context.params).id); + const problem = await requireProblem((await context.params).id, owner); const lab = learningLab(problem); const input = await readBody( request, @@ -70,7 +71,7 @@ export async function POST(request: Request, context: { params: Promise<{ id: st if (job.state === "pending") throw new HttpError(409, "AI 질문을 준비하고 있습니다. 잠시 후 다시 확인해 주세요."); lease = job.lease; - await aiLimit(request, owner, "coach"); + refund = await aiLimit(request, owner, "coach"); const reply = await coachUnderstanding( { originalCode: problem.starterCode, @@ -82,8 +83,11 @@ export async function POST(request: Request, context: { params: Promise<{ id: st (run) => store.queries.recordAiRun(owner, run), ); await store.completeCoaching(lease, problem.id, JSON.stringify(reply)); + refund = undefined; return json(reply); } catch (error) { + // A failed provider call or rejected model output does not use today's allowance. + await refund?.(); if (lease) { try { await (await getStore()).failJob(lease); diff --git a/src/app/api/problems/[id]/lab/route.ts b/src/app/api/problems/[id]/lab/route.ts index b97cd62..50b895d 100644 --- a/src/app/api/problems/[id]/lab/route.ts +++ b/src/app/api/problems/[id]/lab/route.ts @@ -5,8 +5,8 @@ import { session } from "@/lib/server/session"; export const runtime = "nodejs"; export async function GET(request: Request, context: { params: Promise<{ id: string }> }) { try { - await session(request); - return json(learningLab(await requireProblem((await context.params).id))); + const { owner } = await session(request); + return json(learningLab(await requireProblem((await context.params).id, owner))); } catch (error) { return failure(error); } diff --git a/src/app/api/problems/[id]/reveal/route.ts b/src/app/api/problems/[id]/reveal/route.ts index 9f6700a..d22449f 100644 --- a/src/app/api/problems/[id]/reveal/route.ts +++ b/src/app/api/problems/[id]/reveal/route.ts @@ -13,7 +13,7 @@ export async function POST(request: Request, context: { params: Promise<{ id: st z.object({ kind: z.enum(["hint", "solution"]) }), 1000, ); - const problem = await requireProblem(id); + const problem = await requireProblem(id, owner); const progress = await (await getStore()).reveal(owner, problem, kind); return json({ progress, diff --git a/src/app/api/problems/[id]/review/route.ts b/src/app/api/problems/[id]/review/route.ts index d8e6b40..c395633 100644 --- a/src/app/api/problems/[id]/review/route.ts +++ b/src/app/api/problems/[id]/review/route.ts @@ -11,13 +11,21 @@ export const runtime = "nodejs"; export const maxDuration = 120; export async function POST(request: Request, context: { params: Promise<{ id: string }> }) { let lease: JobLease | undefined; + let refund: (() => Promise) | undefined; try { const { owner } = await session(request); const { id } = await context.params; - const problem = await requireProblem(id); + const problem = await requireProblem(id, owner); const input = await readBody( request, - z.object({ code: z.string().trim().min(5).max(30000), requestId: z.uuid() }), + // Store exactly the editor text so the attempt matches the current draft. + z.object({ + code: z + .string() + .max(30000) + .refine((code) => code.trim().length >= 5, "검토할 코드를 5자 이상 작성해 주세요."), + requestId: z.uuid(), + }), ); if (problem.handoff) { const missing = missingHandoffFields(readHandoffDraft(input.code).notes); @@ -40,13 +48,16 @@ export async function POST(request: Request, context: { params: Promise<{ id: st if (job.state === "pending") throw new HttpError(409, "이 풀이를 검토하고 있습니다. 잠시 후 기록을 확인해 주세요."); lease = job.lease; - await aiLimit(request, owner, "review"); + refund = await aiLimit(request, owner, "review"); const review = await reviewCode(problem, input.code, (run) => store.queries.recordAiRun(owner, run), ); const attempt = await store.saveAttempt(owner, id, input.code, review, lease); + refund = undefined; return json({ attempt, progress: await store.progressFor(owner, id) }); } catch (error) { + // A failed provider call or rejected model output does not use today's allowance. + await refund?.(); if (lease) { try { await (await getStore()).failJob(lease); diff --git a/src/app/api/progress/[id]/route.ts b/src/app/api/progress/[id]/route.ts index 4e858c9..5577791 100644 --- a/src/app/api/progress/[id]/route.ts +++ b/src/app/api/progress/[id]/route.ts @@ -8,7 +8,7 @@ export async function PUT(request: Request, context: { params: Promise<{ id: str try { const { owner } = await session(request); const { id } = await context.params; - await requireProblem(id); + await requireProblem(id, owner); const patch = await readBody( request, z diff --git a/src/app/api/project-check/[id]/practice/route.ts b/src/app/api/project-check/[id]/practice/route.ts index cb3fb11..855a248 100644 --- a/src/app/api/project-check/[id]/practice/route.ts +++ b/src/app/api/project-check/[id]/practice/route.ts @@ -10,10 +10,10 @@ export const maxDuration = 120; type Context = { params: Promise<{ id: string }> }; async function contextFor(request: Request, context: Context) { const { owner } = await projectMember(request); - const { id } = await context.params; const store = await getStore(); - if (!(await store.queries.projectChecks.get(owner, id))) - throw new HttpError(404, "점검 기록을 찾지 못했습니다."); + // A revision reuses the root analysis's saved content instead of generating it again. + const id = await store.queries.projectChecks.rootId(owner, (await context.params).id); + if (!id) throw new HttpError(404, "점검 기록을 찾지 못했습니다."); return { owner, id, store }; } export async function GET(request: Request, context: Context) { diff --git a/src/app/api/project-check/[id]/workshop/route.ts b/src/app/api/project-check/[id]/workshop/route.ts index 877e92e..fd3820e 100644 --- a/src/app/api/project-check/[id]/workshop/route.ts +++ b/src/app/api/project-check/[id]/workshop/route.ts @@ -10,10 +10,10 @@ export const maxDuration = 120; type Context = { params: Promise<{ id: string }> }; async function contextFor(request: Request, context: Context) { const { owner } = await projectMember(request); - const { id } = await context.params; const store = await getStore(); - if (!(await store.queries.projectChecks.get(owner, id))) - throw new HttpError(404, "점검 기록을 찾지 못했습니다."); + // A revision reuses the root analysis's saved content instead of generating it again. + const id = await store.queries.projectChecks.rootId(owner, (await context.params).id); + if (!id) throw new HttpError(404, "점검 기록을 찾지 못했습니다."); return { owner, id, store }; } export async function GET(request: Request, context: Context) { diff --git a/src/app/api/project-check/route.ts b/src/app/api/project-check/route.ts index 26b12a0..4669c30 100644 --- a/src/app/api/project-check/route.ts +++ b/src/app/api/project-check/route.ts @@ -51,7 +51,7 @@ export async function POST(request: Request) { try { await service.create(owner, network, input, AbortSignal.timeout(110_000), claim.lease); } catch { - // create marks the leased job failed; status polling reports it to its owner. + // create marks the lease failed with a user-safe reason; status polling reports it. } }); } @@ -84,8 +84,10 @@ export async function DELETE(request: Request) { try { const { owner } = await projectMember(request); const { id } = await readBody(request, z.object({ id: z.uuid() }).strict(), 500); - await (await getStore()).queries.projectChecks.remove(owner, id); - return json({ removed: true }); + return json({ + removed: true, + ids: await (await getStore()).queries.projectChecks.remove(owner, id), + }); } catch (error) { return failure(error); } diff --git a/src/app/api/project-check/training/route.ts b/src/app/api/project-check/training/route.ts index 4e4f9fa..15c10e6 100644 --- a/src/app/api/project-check/training/route.ts +++ b/src/app/api/project-check/training/route.ts @@ -9,7 +9,10 @@ export async function GET(request: Request) { const { owner } = await projectMember(request); const id = z.uuid().safeParse(new URL(request.url).searchParams.get("id")); if (!id.success) throw new HttpError(400, "프로젝트 기록 주소를 확인해 주세요."); - return json(await (await getStore()).queries.projectLearning.get(owner, id.data)); + const store = await getStore(); + // Revisions continue the training of their root analysis. + const root = await store.queries.projectChecks.rootId(owner, id.data); + return json(await store.queries.projectLearning.get(owner, root ?? id.data)); } catch (e) { return failure(e); } @@ -18,7 +21,11 @@ export async function POST(request: Request) { try { const { owner } = await projectMember(request); const input = await readBody(request, trainingInput, 2000); - return json(await (await getStore()).queries.projectLearning.submit(owner, input)); + const store = await getStore(); + const root = await store.queries.projectChecks.rootId(owner, input.id); + return json( + await store.queries.projectLearning.submit(owner, { ...input, id: root ?? input.id }), + ); } catch (e) { return failure(e); } diff --git a/src/app/api/projects/[id]/route.ts b/src/app/api/projects/[id]/route.ts index ea915d6..242a054 100644 --- a/src/app/api/projects/[id]/route.ts +++ b/src/app/api/projects/[id]/route.ts @@ -17,10 +17,11 @@ async function contextFor(request: Request, context: Context) { } export async function GET(request: Request, context: Context) { try { - const { owner, id, check, q } = await contextFor(request, context); + const { owner, check, q } = await contextFor(request, context); + const root = check.revisionOf ?? check.id; const [practice, workshop] = await Promise.all([ - q.generatedPractice(owner, id), - q.workshop(owner, id), + q.generatedPractice(owner, root), + q.workshop(owner, root), ]); return json({ check, practice, workshop }); } catch (e) { @@ -38,8 +39,7 @@ export async function PATCH(request: Request, context: Context) { export async function DELETE(request: Request, context: Context) { try { const { owner, id, q } = await contextFor(request, context); - await q.remove(owner, id); - return json({ removed: true }); + return json({ removed: true, ids: await q.remove(owner, id) }); } catch (e) { return failure(e); } diff --git a/src/app/api/security-check/route.ts b/src/app/api/security-check/route.ts index fbe99a6..c6053f9 100644 --- a/src/app/api/security-check/route.ts +++ b/src/app/api/security-check/route.ts @@ -18,17 +18,27 @@ export async function POST(request: Request) { ); const url = publicUrl(input.url); const target = createHash("sha256").update(url.hostname).digest("hex"); - const allowed = await ( - await getStore() - ).consumeLimits([ - { key: `security:owner:${owner}`, max: 5, windowMs: 3_600_000 }, + const store = await getStore(); + const ownerKey = `security:owner:${owner}`; + const allowed = await store.consumeLimits([ + { key: ownerKey, max: 5, windowMs: 3_600_000 }, { key: `security:network:${networkIdentity(request)}`, max: 15, windowMs: 3_600_000 }, { key: `security:target:${target}`, max: 10, windowMs: 3_600_000 }, { key: "security:global", max: 150, windowMs: 86_400_000 }, ]); if (!allowed) throw new HttpError(429, "보안 점검 요청 한도에 도달했습니다. 잠시 후 다시 이용해 주세요."); - return json(inspectSecurity(await readPublicDocument(url.href, request.signal))); + let document; + try { + document = await readPublicDocument(url.href, request.signal, { anyResponse: true }); + } catch (error) { + // 422 means the target site failed (DNS, connection, timeout, redirects), not the input. + // Return only the personal window; network, target and global windows still count it. + if (error instanceof HttpError && error.status === 422 && !request.signal.aborted) + await store.queries.refundLimits([ownerKey]).catch(() => {}); + throw error; + } + return json(inspectSecurity(document)); } catch (error) { return failure(error); } diff --git a/src/app/api/workspace/route.ts b/src/app/api/workspace/route.ts index bb5575c..3f24e91 100644 --- a/src/app/api/workspace/route.ts +++ b/src/app/api/workspace/route.ts @@ -1,6 +1,6 @@ import { problemDetail } from "@/lib/server/problem-detail"; import { getStore } from "@/lib/server/database"; -import { failure, json, readBody } from "@/lib/server/http"; +import { failure, HttpError, json, readBody } from "@/lib/server/http"; import { session } from "@/lib/server/session"; import { z } from "zod"; import { configuredProviders } from "@/lib/server/auth-config"; @@ -17,7 +17,14 @@ export async function GET(request: Request) { store.queries.workspace(owner, id), params.get("include") === "initial" ? id - ? problemDetail(store, owner, id, params.get("attempt")).then((detail) => ({ detail })) + ? problemDetail(store, owner, id, params.get("attempt")).then( + (detail) => ({ detail }), + (error) => { + // The workspace shows its own not-found state; the rest of the page still loads. + if (error instanceof HttpError && error.status === 404) return undefined; + throw error; + }, + ) : libraryHref?.startsWith("/") ? store.queries .library(owner, new URLSearchParams(libraryHref.split("?")[1] || "")) diff --git a/src/app/learn/ai/[slug]/page.tsx b/src/app/learn/ai/[slug]/page.tsx index 60112f2..0018d02 100644 --- a/src/app/learn/ai/[slug]/page.tsx +++ b/src/app/learn/ai/[slug]/page.tsx @@ -50,6 +50,7 @@ export default async function AiLessonPage({ id={lesson.id} content={AI_CONTENT[lesson.id]} returnTo={catalogReturn(query.returnTo)} + projectQuery={project.success ? `project=${project.data}${topic}` : undefined} /> {project.success && ( item.id === id)!; const category = AI_TRACKS.find((item) => item.id === lesson.track)!; const { records, save, ready, storageError } = useAiLearningProgress(); const progress = records[id] || EMPTY_AI_PROGRESS; - const { step, experiment, completed } = progress; + const { step: savedStep, experiment, completed } = progress; + // A completed lesson reopens on its summary; the step buttons still lead back to the practice. + const [summaryFirst, setSummaryFirst] = useState(true); + const step = completed && summaryFirst ? 2 : savedStep; const answer = progress.answer ?? null; const checked = progress.checked ?? false; const [retry, setRetry] = useState(false); @@ -39,6 +45,7 @@ export function AiLesson({ save(id, { ...progress, ...patch }); } function move(to: 0 | 1 | 2) { + setSummaryFirst(false); update({ step: to }); requestAnimationFrame(() => heading.current?.focus()); } @@ -298,7 +305,7 @@ export function AiLesson({ {next ? ( 다음 수업: {next.title}