Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .notion/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,11 @@

## 1. 현재 상태

- 2026-10-04: **SSE 끊김 ERROR·docker-java 핀·App 권한 실측을 운영에 올리고, 자격 실패가 조용히 묻히던 것을 고쳤다 — main 배포 완료(#423 #425 #414 → PR #436 `90338cd`, #429 → PR #439 `cb00d87`). **미릴리스 없음**.
- 2026-10-04: **SSE 끊김 ERROR·docker-java 핀·App 권한 실측을 운영에 올리고, 자격 실패가 조용히 묻히던 것을 고쳤다 — main 배포 완료 — PR #436 `90338cd`(#423 #425 #414) · #439 `cb00d87`(#429 B안) · #444 `31d2d0f5`(#429 A안) · #447 `0ad2b0d1`(배포 경로 감지) · #449 `b221d137`(문서). **미릴리스 없음**.

**다음 세션이 볼 것**: 코드로 할 것은 비어 있다. 남은 셋은 ①계정 `unhakjace` 설치 재승인/제거(사용자) ②Cloudflare 라우팅 — 장애 아니고 품질 문제(`colo=LAX`·`loc=KR`, 사용자) ③`#344` 9-2(측정 선행, 권하지 않음). `#413` 토큰 전환은 **하지 않기로** 결론났다(`docs/pending-actions.md` §0-1).

**오늘 네 번 "전제가 틀렸다" 가 나왔다** — `#332`·`#344`·`#154` 는 끝난 일을 안 끝났다고 기록하고 있었고, `#413` 은 축소 대상이 엉뚱했다(컨테이너는 사용자 OAuth 토큰을 쓴다). 전부 **이슈·문서를 먼저 읽고 코드를 나중에 본** 탓이고, `#154` 는 그 때문에 사용자에게 불필요한 결정을 물었다. **이슈·문서보다 코드가 먼저다.**

**#423** SSE 를 보던 브라우저가 떠나면 `AsyncRequestNotUsableException` 이 catch-all 에 걸려 `ERROR Unexpected error` 로 남았다. 반환형 `void` 가 핵심이다 — 이 예외는 "응답을 더 쓸 수 없다" 는 뜻이라 `ResponseEntity` 를 돌려주면 죽은 응답에 쓰려다 2차 실패를 낸다. **결함이 로그 레벨이므로 테스트도 로그를 본다**(`ListAppender`). 그리고 단위 테스트가 증명하지 못하는 것이 하나 남아 있었다 — 죽은 응답에 정말 아무것도 쓰지 않는가. 죽은 응답은 목으로 만들 수 없어서 **실제 Tomcat 에 소켓을 붙이고 `SO_LINGER=0` 으로 RST** 를 보냈다(`ClientDisconnectRealSocketIntegrationTest`). 되돌림 세 방향 전부 확인.
**dev 에서 "ERROR 0" 은 증거가 아니었다.** 기저율이 11시간에 2건이고 둘 다 FE 가 앱을 쓰는 창 안이었다 — 끊김이 아예 없었던 것과 구별되지 않는다. dev 에 매달리다 접었고, CI 안에서 영구히 도는 쪽이 더 나은 자리였다.
Expand Down
14 changes: 14 additions & 0 deletions agent-tools/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,20 @@
API 와 함께 두고 반복하기 위해서다. 자립적인 디렉터리라 공개 저장소로 떼어내는 것은 나중에 언제든
가능하고, 그 시점은 오픈소스 공개를 결정할 때다.

## ⚠️ 아직 npm 에 배포되지 않았다

`@qeploy/client`·`@qeploy/mcp`·`@qeploy/cli` 는 **레지스트리에 없다**(위 "지금은 이 저장소 안에 있다" 의 결과다). 그래서 아래의 `npx @qeploy/...` 명령은 **지금 그대로는 npm 404 로 실패한다** — 배포하면 그대로 동작할 형태로 적어 둔 것이다(#451).

그때까지 쓰려면 소스에서 실행한다:

```bash
cd agent-tools && npm install
claude mcp add qeploy -- node "$(pwd)/packages/mcp/src/index.js"
node packages/cli/src/bin.js login
```

**스코프 없는 npm `qeploy` 는 우리 것이 아니다**(남의 패키지, 2018). 어디에도 그 이름을 안내하지 않는다.

## 시작하기 — 로그인 한 번

```bash
Expand Down
8 changes: 5 additions & 3 deletions docs/FRONTEND_API_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@

| 환경 | Base URL | 비고 |
|---|---|---|
| 로컬 백엔드 | `http://localhost:8080` | `application.yaml` 기본 포트. FE 정본(`Dvely_FE_test`)의 `VITE_API_BASE` 기본값과 동일 |
| 로컬 백엔드 | `http://localhost:8080` | `application.yaml` 기본 포트. FE 정본(`Dvely_FE`)의 `VITE_API_BASE` 기본값과 동일 |
| Swagger UI | `http://localhost:8080/swagger-ui/index.html` | 도메인별 그룹 드롭다운(auth/project/chat/agent/deployment/domainbinding/cloudconnection/preview/environment/webhook/user)으로 필터링 가능 |
| OpenAPI JSON | `http://localhost:8080/v3/api-docs` (그룹별: `/v3/api-docs/{group}`) | 코드 생성기(openapi-generator 등)에 바로 사용 가능 |

Expand Down Expand Up @@ -118,7 +118,7 @@ SSE, GitHub webhook/App 콜백, preview 프록시(`@RawApiResponse` 마킹된
- 이 문서의 각 엔드포인트 표에 적힌 "응답"은 모두 이 envelope의 `data` 내용입니다.
- **`@RawApiResponse`가 붙은 엔드포인트**(감싸지지 않고 바디를 그대로 반환): `GET /api/v1/auth/github/app/callback`(302 redirect, 바디 없음), `POST /api/v1/webhook/github`(202, 바디 없음), `GET /api/v1/agent/tasks/{taskId}/events/stream`(SSE), `GET /api/v1/previews/{sessionId}/{accessToken}/**`(프리뷰 앱의 원본 바이트 그대로 프록시).

### 3.2 FE 정본(`Dvely_FE_test`)의 unwrap 규약
### 3.2 FE 정본(`Dvely_FE`)의 unwrap 규약

`src/api/http.ts`의 `request<T>()`는 아래 규칙으로 동작합니다 — 신규 FE 프로젝트도 동일 패턴을 권장합니다.

Expand Down Expand Up @@ -594,6 +594,8 @@ POST /api/v1/api-tokens

`WRITE` 가 짧은 이유는 유출됐을 때 할 수 있는 일이 다르기 때문이다 — 배포하고 환경변수를 바꾸고 도메인을 붙인다. 범위를 벗어나면 **400** 이고 메시지에 스코프가 들어 있으니 그대로 보여주면 된다. 발급 UI 의 만료일 입력은 선택한 스코프에 따라 상한을 바꿔야 한다.

**`WRITE` 도 모든 쓰기가 아니다(#453).** PAT 로 통과하는 쓰기는 배포 생성·재시도, 환경변수 추가·수정, 도메인 추가 다섯 개뿐이고, 승인·토큰 발급/폐기·AI 키·클라우드 연결·삭제 등 나머지는 403 이다. 브라우저 로그인(JWT)은 영향이 없다. 발급 UI 에서 `WRITE` 를 설명할 때 "모든 작업" 처럼 쓰지 말 것.

### 4.12 AiCredential — `com.example.dvely.aiaccount.presentation` (3)

사용자 본인 AI API 키(BYOK) 관리. 등록한 키로 `CLAUDE_CODE` · `CODEX` 코딩 에이전트가 실행되고, 사용량은 사용자 계정으로 직접 청구된다.
Expand Down Expand Up @@ -900,5 +902,5 @@ CODE 단계는 GitHub 저장소 연결과 무관하게 동작합니다(Docker
## 6. 참고

- 이 문서의 모든 필드명·상태값은 각 컨트롤러·DTO(`src/main/java/com/example/dvely/**/presentation/**`)와 일치합니다. 스키마가 바뀌면 이 문서도 함께 갱신해야 합니다.
- 통합 테스트/수동 시나리오는 FE 정본 저장소의 `Dvely_FE_test/API_TEST_FLOW.md`(콘솔 기반 수동 QA 흐름)도 함께 참고하세요.
- 통합 테스트/수동 시나리오는 **구** FE 저장소 `Dvely_FE_test` 의 `API_TEST_FLOW.md`(2026-07-19 이후 갱신 없음 — 지금의 정본은 `Dvely_FE` 다. 그 문서는 거기에만 있어 경로는 유지한다)(콘솔 기반 수동 QA 흐름)도 함께 참고하세요.
- Swagger UI 상단 Info 블록(SwaggerConfig)에도 이 문서의 §3(공통 규약) 요약이 markdown으로 포함되어 있습니다.
13 changes: 6 additions & 7 deletions docs/pending-actions.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,18 +9,17 @@

---

## 0. 릴리스 현황 — 2026-10-04 기준
## 0. 릴리스 현황 — 2026-10-05 기준

**미릴리스 없음** — `develop == main`.

| 릴리스 | 무엇 | 검증 |
|---|---|---|
| PR #439 `cb00d87` | `#429` 자격 실패를 연결 상태로 반영 | dev 실측(`판정근거=ExpiredToken`, 매시간 WARN 0건) · 되돌림 3방향 · 헬스 200×5 · 카탈로그 200 |
| PR #436 `90338cd` | `#423` SSE 끊김을 ERROR 로 남기지 않음 · `#425` docker-java 핀 보호 · `#414` App 권한 실측 | 운영 코드 변경은 핸들러 하나(비주석 diff 직접 확인) · 되돌림 3방향 · 헬스 200×5 |
| PR #422 `340e8a7` | `#413` 컨테이너 git 자격 env 전환 · `#414` 문서 · `#415` 배포 스코프 누출 · `#421` | dev 합동 검증(FE) · 프리뷰 SHA 교체 · 감사 29→30 · 자격 파일 0건 |
| PR #408 `84444b4` | `#405` 이력의 죽은 preview 링크 제거 (**V66**) | 기준값 6개 일치 · 감소량 936 = 8×117 · 거짓 양성 79번 온전 |
| PR #404 `bdb0967` | `#392` 3단계 + `#401` | 운영 계약 4항목 일치 · ERROR 0 · Flyway 65 그대로 |
| PR #400 `3fa24a6` | `#395` 카탈로그 내구성 + 스킴 검증 (V65) | 스냅샷 행 기록 · 복원 조건 4개 통과 |
| PR #449 `b221d137` | `#413` 토큰 축소 분석(문서만) | 헬스 200 · 카탈로그 200 |
| PR #447 `0ad2b0d1` | `#429` 배포 경로 자격 실패 감지 + `INVALID_CREDENTIAL` · `#414` probe 개선 | **OpenAPI `errorCode` 에 `INVALID_CREDENTIAL` 확인**(새 jar 반영 증거) · 되돌림 2방향 |
| PR #444 `31d2d0f5` | `#429` A안 임시 자격 거부 · `#414` 축소 기록 · `#154` 정리 | **OpenAPI `sessionToken` 설명 확인** · 되돌림 2방향(셋째는 구별 불가 — 기록해 둠) |
| PR #439 `cb00d87` | `#429` B안 자격 실패를 상태로 반영 | dev 실측 `판정근거=ExpiredToken` · 매시간 WARN 0건 |
| PR #436 `90338cd` | `#423` SSE 끊김 · `#425` docker-java 핀 보호 · `#414` 권한 실측 | 운영 코드 변경은 핸들러 하나 · 되돌림 3방향 |

**확인 방법**: `git log --oneline origin/main..origin/develop` — 비어 있으면 미릴리스 없음.

Expand Down
5 changes: 3 additions & 2 deletions docs/qeploy-mcp-cli-design.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Qeploy MCP 서버 · CLI 설계 (에이전트 연동)

> 기준일: 2026-09-07 (2026-09-08 갱신) · 코드 실측 기반
> 구현 상태: 1~5단계 완료 · PR #306 · FE 6단계는 `Dvely_FE` #76
> 구현 상태: 1~5단계 완료 · PR #306 · FE 6단계는 `Dvely_FE` #76(완료) · **npm 미배포 — 실사용 불가**(#451)
> 상위 PRD: `.notion/prd.md` 부록 A-2 · 요구사항: `.notion/srs.md` §B

---
Expand Down Expand Up @@ -95,6 +95,7 @@ npx @qeploy/cli deploy --project 12

1. **기본 비활성.** `QEPLOY_ENABLE_WRITES=true` 없이는 쓰기 도구가 목록에 아예 없다. 과하게 적극적인 에이전트의 실패는 틀린 답이 아니라 진짜 배포라, 기본값은 시작할 수 없는 쪽이어야 한다.
2. **서버 강제.** `READ` 스코프 PAT 는 변경 메서드에서 403 이다. 클라이언트를 우회해도, 플래그를 켜도 막힌다. 실측으로 확인했다.
`WRITE` PAT 도 **허용 목록의 쓰기 다섯 개**(배포 생성·재시도, 환경변수 추가·수정, 도메인 추가)만 통과하고 나머지 쓰기는 403 이다(#453). 그 전에는 WRITE PAT 가 브라우저와 같은 권한이라, "승인 결정·삭제는 노출하지 않는다"가 MCP 가 도구를 안 만든 것으로만 지켜졌다 — 셸이 있는 에이전트는 저장된 토큰으로 승인을 스스로 누르거나 PAT 로 PAT 를 재발급할 수 있었다. 목록은 `JwtAuthenticationFilter.PAT_WRITABLE` 이고, `agent-tools/packages/client` 의 쓰기 호출이 목록 밖으로 나가면 `JwtAuthenticationFilterTest` 가 깨진다. 에이전트에 쓰기를 새로 열 때는 그 목록에 한 줄을 더한다.
3. **승인 게이트.** 배포·인프라 변경의 기존 승인 흐름은 이 경로에서도 그대로다. 그래서 `qeploy_deploy` 응답이 200 이어도 "배포됨"이 아니라 "승인 대기"일 수 있고, 도구 설명이 그 점을 명시해 에이전트가 단정하지 않게 한다.

여기에 MCP 표준 `destructiveHint` 를 붙여 클라이언트가 실행 전에 사용자에게 물을 수 있게 했다. 우리 산문을 에이전트가 읽어주길 바라는 대신 프로토콜 자체의 수단을 쓴다.
Expand Down Expand Up @@ -159,7 +160,7 @@ npx @qeploy/cli deploy --project 12
- **CLI 와 MCP 가 같은 파일을 읽는다.** 에이전트는 `npx @qeploy/mcp` 로 실행되어 환경변수를 받을 자리가 없고, 에이전트 설정 파일에 자격증명을 적게 하면 비밀이 있을 필요 없는 곳에 하나 더 생긴다.
- **환경변수가 파일을 이긴다.** CI 가 `QEPLOY_TOKEN` 을 설정했는데 개발자의 로그인 파일이 조용히 덮어쓰면 안 된다.
- **붙여넣은 것은 화면에 표시하지 않고, 발급한 PAT 도 출력하지 않는다.** 이미 저장했으므로 스크롤백에 90일짜리 자격증명을 남길 이유가 없다.
- **`logout` 은 로컬 파일만 지우고 그 사실을 말한다.** 서버 폐기에는 WRITE 스코프가 필요한데 기본이 READ 다. 죽은 줄 아는 자격증명이 살아 있는 것은 로그아웃이 없는 것보다 나쁘다.
- **`logout` 은 로컬 파일만 지우고 그 사실을 말한다.** 토큰 폐기는 PAT 로는 할 수 없고(#453) 웹에서만 된다. 죽은 줄 아는 자격증명이 살아 있는 것은 로그아웃이 없는 것보다 나쁘다.
- **`qp_` 로 시작하는 값을 붙여넣으면 거절한다.** 이미 발급된 PAT 를 여기 넣으면 만료될 때까지는 "되는 것처럼" 보이다가 이유를 알 수 없게 실패한다.

### 왜 브라우저 왕복이 아닌가
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@ public record IssueApiTokenRequest(
@Schema(description = "구분용 이름(선택)", example = "내 노트북 Claude Code", nullable = true)
@Size(max = 64) String label,

@Schema(description = "만료까지 일수. 생략 시 90일, 최대 365일", example = "90", nullable = true)
@Schema(description = "만료까지 일수. 스코프마다 다르다 — READ: 생략 시 90일·최대 365일, "
+ "WRITE: 생략 시 30일·최대 90일(유출 시 할 수 있는 일이 달라서). 범위를 벗어나면 400",
example = "90", nullable = true)
@Min(1) @Max(365) Integer expiresInDays
) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.regex.Pattern;

/**
* Authenticates a request from either a browser JWT or an agent/CLI personal access token.
Expand All @@ -41,6 +42,25 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
*/
private static final Set<String> WRITE_METHODS = Set.of("POST", "PUT", "PATCH", "DELETE");

/**
* The only writes a PAT may make, as {@code METHOD path} — everything else needs a browser login.
*
* <p>An allowlist rather than a denylist: before #453 a WRITE token could approve its own
* approval requests, mint fresh tokens and swap the user's AWS connection, because the agent
* tools simply did not expose those and nothing on the server said no. With a denylist the next
* such endpoint is open until someone remembers it; here it is closed until someone opens it.
*
* <p>Kept in step with what {@code agent-tools/packages/client} actually calls. Giving agents a
* new write means adding a line here, on purpose.
*/
private static final List<Pattern> PAT_WRITABLE = List.of(
Pattern.compile("POST /api/v1/projects/\\d+/deployments"),
Pattern.compile("POST /api/v1/deployments/\\d+/retry"),
Pattern.compile("POST /api/v1/projects/\\d+/environment-variables"),
Pattern.compile("PATCH /api/v1/projects/\\d+/environment-variables/\\d+"),
Pattern.compile("POST /api/v1/projects/\\d+/domains")
);

private final TokenPort tokenPort;
private final TokenBlacklistPort tokenBlacklistPort;
private final ApiTokenAuthenticator apiTokenAuthenticator;
Expand Down Expand Up @@ -81,10 +101,12 @@ private boolean authenticateWithApiToken(String token,
if (WRITE_METHODS.contains(request.getMethod()) && !apiToken.allowsWrite()) {
// 403 rather than falling through to anonymous: the caller authenticated correctly and
// simply lacks the scope, and a 401 would send them off to re-authenticate for nothing.
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write(
"{\"code\":\"FORBIDDEN\",\"message\":\"이 토큰은 읽기 전용입니다. write 스코프 토큰이 필요합니다.\"}");
reject(response, "이 토큰은 읽기 전용입니다. write 스코프 토큰이 필요합니다.");
return false;
}

if (WRITE_METHODS.contains(request.getMethod()) && !patMayWrite(request)) {
reject(response, "이 작업은 개인 액세스 토큰으로 할 수 없습니다. 웹에서 진행해주세요.");
return false;
}

Expand All @@ -97,6 +119,20 @@ private boolean authenticateWithApiToken(String token,
return true;
}

private static boolean patMayWrite(HttpServletRequest request) {
// getRequestURI is the raw path; the security firewall has already refused encoded slashes,
// "..", and ";" by the time this runs, and anything odd that slips past simply fails to
// match — which denies, the safe direction.
String call = request.getMethod() + " " + request.getRequestURI();
return PAT_WRITABLE.stream().anyMatch(p -> p.matcher(call).matches());
}

private static void reject(HttpServletResponse response, String message) throws IOException {
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{\"code\":\"FORBIDDEN\",\"message\":\"" + message + "\"}");
}

private void authenticateWithJwt(String token) {
try {
// userId 와 jti 를 한 번의 파싱으로 함께 얻는다. 따로 받으면 같은 토큰에 대해 HMAC 키
Expand Down
Loading
Loading