@milcho0604/velog-mcp 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.ko.md +366 -0
- package/README.md +381 -0
- package/dist/auth.d.ts +57 -0
- package/dist/auth.js +124 -0
- package/dist/auth.js.map +1 -0
- package/dist/capabilities.d.ts +50 -0
- package/dist/capabilities.js +60 -0
- package/dist/capabilities.js.map +1 -0
- package/dist/client.d.ts +113 -0
- package/dist/client.js +322 -0
- package/dist/client.js.map +1 -0
- package/dist/format.d.ts +31 -0
- package/dist/format.js +66 -0
- package/dist/format.js.map +1 -0
- package/dist/graphql.d.ts +29 -0
- package/dist/graphql.js +82 -0
- package/dist/graphql.js.map +1 -0
- package/dist/index.d.ts +25 -0
- package/dist/index.js +149 -0
- package/dist/index.js.map +1 -0
- package/dist/me.d.ts +23 -0
- package/dist/me.js +35 -0
- package/dist/me.js.map +1 -0
- package/dist/ownership.d.ts +42 -0
- package/dist/ownership.js +62 -0
- package/dist/ownership.js.map +1 -0
- package/dist/plugin-env.d.ts +67 -0
- package/dist/plugin-env.js +102 -0
- package/dist/plugin-env.js.map +1 -0
- package/dist/ratelimit.d.ts +48 -0
- package/dist/ratelimit.js +79 -0
- package/dist/ratelimit.js.map +1 -0
- package/dist/render/chrome.d.ts +77 -0
- package/dist/render/chrome.js +287 -0
- package/dist/render/chrome.js.map +1 -0
- package/dist/render/cover.d.ts +29 -0
- package/dist/render/cover.js +195 -0
- package/dist/render/cover.js.map +1 -0
- package/dist/render/icons.d.ts +22 -0
- package/dist/render/icons.js +158 -0
- package/dist/render/icons.js.map +1 -0
- package/dist/render/index.d.ts +32 -0
- package/dist/render/index.js +137 -0
- package/dist/render/index.js.map +1 -0
- package/dist/render/page.d.ts +89 -0
- package/dist/render/page.js +761 -0
- package/dist/render/page.js.map +1 -0
- package/dist/render/tones.d.ts +30 -0
- package/dist/render/tones.js +46 -0
- package/dist/render/tones.js.map +1 -0
- package/dist/slug.d.ts +42 -0
- package/dist/slug.js +79 -0
- package/dist/slug.js.map +1 -0
- package/dist/tools/discover.d.ts +6 -0
- package/dist/tools/discover.js +106 -0
- package/dist/tools/discover.js.map +1 -0
- package/dist/tools/drafts.d.ts +23 -0
- package/dist/tools/drafts.js +227 -0
- package/dist/tools/drafts.js.map +1 -0
- package/dist/tools/export.d.ts +21 -0
- package/dist/tools/export.js +132 -0
- package/dist/tools/export.js.map +1 -0
- package/dist/tools/images.d.ts +34 -0
- package/dist/tools/images.js +556 -0
- package/dist/tools/images.js.map +1 -0
- package/dist/tools/posts.d.ts +14 -0
- package/dist/tools/posts.js +82 -0
- package/dist/tools/posts.js.map +1 -0
- package/dist/tools/profile-edit.d.ts +15 -0
- package/dist/tools/profile-edit.js +216 -0
- package/dist/tools/profile-edit.js.map +1 -0
- package/dist/tools/profile.d.ts +9 -0
- package/dist/tools/profile.js +133 -0
- package/dist/tools/profile.js.map +1 -0
- package/dist/tools/publish.d.ts +16 -0
- package/dist/tools/publish.js +424 -0
- package/dist/tools/publish.js.map +1 -0
- package/dist/tools/stats.d.ts +32 -0
- package/dist/tools/stats.js +154 -0
- package/dist/tools/stats.js.map +1 -0
- package/dist/types.d.ts +42 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/docs/PRD.md +146 -0
- package/docs/api-reference.md +329 -0
- package/docs/architecture.md +112 -0
- package/docs/decisions/0001-why-build-our-own.md +89 -0
- package/docs/decisions/0002-draft-only-write.md +84 -0
- package/docs/decisions/0003-token-env-only.md +109 -0
- package/docs/decisions/0004-capability-model.md +123 -0
- package/docs/decisions/0005-render-in-server.md +117 -0
- package/docs/decisions/0006-ship-as-plugin.md +532 -0
- package/docs/security.md +384 -0
- package/docs/tools.md +404 -0
- package/npm-shrinkwrap.json +2345 -0
- package/package.json +61 -0
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/** 벨로그 응답 타입. docs/api-reference.md 의 실측 스키마 기준. */
|
|
2
|
+
export interface VelogUserProfile {
|
|
3
|
+
display_name?: string | null;
|
|
4
|
+
short_bio?: string | null;
|
|
5
|
+
thumbnail?: string | null;
|
|
6
|
+
}
|
|
7
|
+
export interface VelogUser {
|
|
8
|
+
id?: string;
|
|
9
|
+
username?: string;
|
|
10
|
+
email?: string | null;
|
|
11
|
+
profile?: VelogUserProfile | null;
|
|
12
|
+
}
|
|
13
|
+
export interface VelogSeries {
|
|
14
|
+
id?: string;
|
|
15
|
+
name?: string;
|
|
16
|
+
url_slug?: string;
|
|
17
|
+
}
|
|
18
|
+
export interface VelogPostSummary {
|
|
19
|
+
id: string;
|
|
20
|
+
title?: string | null;
|
|
21
|
+
url_slug?: string | null;
|
|
22
|
+
short_description?: string | null;
|
|
23
|
+
thumbnail?: string | null;
|
|
24
|
+
likes?: number | null;
|
|
25
|
+
views?: number | null;
|
|
26
|
+
comments_count?: number | null;
|
|
27
|
+
released_at?: string | null;
|
|
28
|
+
is_private?: boolean | null;
|
|
29
|
+
tags?: string[] | null;
|
|
30
|
+
user?: VelogUser | null;
|
|
31
|
+
series?: VelogSeries | null;
|
|
32
|
+
}
|
|
33
|
+
export interface VelogPostDetail extends VelogPostSummary {
|
|
34
|
+
body?: string | null;
|
|
35
|
+
is_markdown?: boolean | null;
|
|
36
|
+
is_temp?: boolean | null;
|
|
37
|
+
created_at?: string | null;
|
|
38
|
+
}
|
|
39
|
+
export interface SearchPostsResult {
|
|
40
|
+
count: number;
|
|
41
|
+
posts: VelogPostSummary[];
|
|
42
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,oDAAoD"}
|
package/docs/PRD.md
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# velog-mcp 기획서
|
|
2
|
+
|
|
3
|
+
- 작성: 2026-07-30
|
|
4
|
+
- 상태: 초안 (v0.1 구현과 함께 갱신)
|
|
5
|
+
|
|
6
|
+
## 1. 배경
|
|
7
|
+
|
|
8
|
+
Velog 에 글을 쓰는 일이 늘었는데, 초안 작성이 항상 **에디터 창에서 시작**한다.
|
|
9
|
+
자료는 터미널·레포·이슈 트래커에 있고 글은 브라우저에 있으니 왕복이 생긴다.
|
|
10
|
+
|
|
11
|
+
MCP 로 벨로그를 붙이면 이 왕복이 사라진다. 다만 붙이는 순간 **AI 가 내 블로그에
|
|
12
|
+
쓰기 권한을 갖는다**. 그래서 이 프로젝트의 설계 질문은 "무엇을 할 수 있게 할까"
|
|
13
|
+
가 아니라 **"무엇을 못 하게 못 박을까"** 였다.
|
|
14
|
+
|
|
15
|
+
## 2. 문제 정의
|
|
16
|
+
|
|
17
|
+
기존 구현 두 개를 조사한 결과 (2026-07-30 기준):
|
|
18
|
+
|
|
19
|
+
| | `velog-mcp` (stoneHee99) | `velog-mcp-claude` (seongwon030) |
|
|
20
|
+
| --- | --- | --- |
|
|
21
|
+
| ⭐ | 7 | 6 |
|
|
22
|
+
| 주간 다운로드 | 32 | 46 |
|
|
23
|
+
| 마지막 갱신 | 2026-04-24 | 2026-04-28 |
|
|
24
|
+
| 발행 | `write_post` 한 번에 **즉시 게시** | `publishPost` 별도 (양호) |
|
|
25
|
+
| 삭제 | `delete_post` 노출 | `deletePost` 노출 |
|
|
26
|
+
| 인증 | **macOS 키체인에서 Chrome 암호키 추출** 후 쿠키 DB 복호화 | 토큰 붙여넣기 |
|
|
27
|
+
| npm ↔ 소스 | 일치 | **npm 0.10.0 vs 소스 0.20.0** (10버전 뒤처짐) |
|
|
28
|
+
|
|
29
|
+
정리하면 세 가지가 걸렸다.
|
|
30
|
+
|
|
31
|
+
1. **되돌릴 수 없는 동작이 노출돼 있다.** 발행과 삭제가 도구 목록에 있으면,
|
|
32
|
+
모델이 판단을 한 번 잘못하는 것만으로 공개 게시나 글 소실이 일어난다.
|
|
33
|
+
2. **인증이 과하다.** `security find-generic-password -s "Chrome Safe Storage"` 로
|
|
34
|
+
Chrome 마스터 키를 가져오는 코드가 상주한다. 현재 SQL 은 `.velog.io` 의 토큰
|
|
35
|
+
2개로 정확히 한정돼 있어 결백하지만, 그 키를 쥔 코드가 항상 돌 이유는 없다.
|
|
36
|
+
3. **공급망이 얇다.** 개인 1인 유지보수 + 주간 40회 내외 다운로드 + 3개월 정지.
|
|
37
|
+
`npx -y` 로 최신을 끌어오는 설정이면 업데이트 한 번에 코드가 바뀐다.
|
|
38
|
+
|
|
39
|
+
## 3. 목표
|
|
40
|
+
|
|
41
|
+
**G1. 되돌릴 수 없는 동작을 코드에서 없앤다.**
|
|
42
|
+
정책이나 프롬프트가 아니라 **구현 부재**로 보장한다. 발행 mutation 을 호출하는
|
|
43
|
+
코드가 레포에 존재하지 않으면 모델이 아무리 원해도 발행할 수 없다.
|
|
44
|
+
|
|
45
|
+
**G2. 읽기는 아끼지 않는다.**
|
|
46
|
+
조회·검색·통계는 실수해도 되돌릴 게 없다. 여기서는 기능을 넓게 가져간다.
|
|
47
|
+
|
|
48
|
+
**G3. 토큰을 최소로 다룬다.**
|
|
49
|
+
환경변수로만 받고, 디스크에 쓰지 않고, 브라우저·키체인을 건드리지 않고,
|
|
50
|
+
로그·에러 메시지에 실리지 않게 한다.
|
|
51
|
+
|
|
52
|
+
**G4. 스키마 근거를 남긴다.**
|
|
53
|
+
벨로그 GraphQL 은 비공식이라 언제든 바뀐다. introspection 실측 결과를
|
|
54
|
+
`docs/api-reference.md` 에 날짜와 함께 박아두고, 깨졌을 때 diff 로 찾게 한다.
|
|
55
|
+
|
|
56
|
+
## 4. 비목표 (하지 않는 것)
|
|
57
|
+
|
|
58
|
+
- **발행**: `is_temp: false` 로 글을 공개하는 기능. 사람이 벨로그에서 누른다.
|
|
59
|
+
- **삭제**: 글·시리즈·댓글 삭제. 실수의 대가가 비대칭적으로 크다.
|
|
60
|
+
- **소셜 행위**: 좋아요·팔로우·댓글 작성. 남의 타임라인에 내 이름으로 흔적을
|
|
61
|
+
남기는 일은 자동화 대상이 아니다.
|
|
62
|
+
- **계정 조작**: `unregister`(탈퇴)·프로필/이메일 변경. introspection 에 보이지만
|
|
63
|
+
구현하지 않는다.
|
|
64
|
+
- **다중 계정**: 계정 하나만 다룬다.
|
|
65
|
+
- **오프라인 캐시·동기화**: 벨로그가 원본(single source of truth)이다.
|
|
66
|
+
|
|
67
|
+
## 5. 사용자 시나리오
|
|
68
|
+
|
|
69
|
+
**S1. 자료에서 초안으로**
|
|
70
|
+
> "이번 prom-client 기여 건으로 글 초안 잡아줘"
|
|
71
|
+
→ 대화 맥락으로 마크다운을 만들고 `velog_create_draft` 로 임시저장.
|
|
72
|
+
사용자는 벨로그에서 열어 고치고 직접 발행한다.
|
|
73
|
+
|
|
74
|
+
**S2. 내 글 되짚기**
|
|
75
|
+
> "작년에 쓴 HTTP/2 글 어디였지"
|
|
76
|
+
→ `velog_search_posts(username=…)` 로 찾아 본문까지 읽어온다.
|
|
77
|
+
|
|
78
|
+
**S3. 블로그 현황 파악**
|
|
79
|
+
> "내 글 중에 조회수 높은 순으로"
|
|
80
|
+
→ `velog_blog_stats` 가 `likes`·`views` 를 집계해 표로 준다.
|
|
81
|
+
|
|
82
|
+
**S4. 백업**
|
|
83
|
+
> "내 글 전부 마크다운으로 내려받아"
|
|
84
|
+
→ `velog_export_posts` 가 로컬 디렉터리에 파일로 쓴다.
|
|
85
|
+
|
|
86
|
+
**S5. 초안 이어쓰기**
|
|
87
|
+
> "어제 잡아둔 초안 이어서 마무리하자"
|
|
88
|
+
→ `velog_list_drafts` → `velog_update_draft`.
|
|
89
|
+
|
|
90
|
+
## 6. 성공 기준
|
|
91
|
+
|
|
92
|
+
| # | 기준 | 검증 방법 |
|
|
93
|
+
| --- | --- | --- |
|
|
94
|
+
| A1 | 발행 mutation 호출 코드가 **0곳** | `grep -r "is_temp.*false"` 결과 없음 + 테스트로 고정 |
|
|
95
|
+
| A2 | 삭제·소셜·계정 mutation 미구현 | 도구 목록 스냅샷 테스트 |
|
|
96
|
+
| A3 | 토큰이 디스크·로그에 안 남음 | 파일 쓰기 없음 검증 + 에러 마스킹 테스트 |
|
|
97
|
+
| A4 | 토큰 없이도 공개 조회 동작 | 무인증 통합 테스트 |
|
|
98
|
+
| A5 | 런타임 의존성 ≤ 2 | `package.json` 검사 |
|
|
99
|
+
| A6 | 스키마 변경 감지 | introspection diff 스크립트 |
|
|
100
|
+
|
|
101
|
+
## 7. 범위 (v0.1)
|
|
102
|
+
|
|
103
|
+
### 읽기 — 인증 불필요
|
|
104
|
+
`velog_get_post` · `velog_list_posts` · `velog_search_posts` ·
|
|
105
|
+
`velog_trending_posts` · `velog_recent_posts` · `velog_get_user` ·
|
|
106
|
+
`velog_list_series` · `velog_get_series` · `velog_get_tag` ·
|
|
107
|
+
`velog_user_tags` · `velog_trending_writers`
|
|
108
|
+
|
|
109
|
+
### 읽기 — 인증 필요
|
|
110
|
+
`velog_current_user` · `velog_list_drafts` · `velog_reading_list` ·
|
|
111
|
+
`velog_feed` · `velog_notifications`
|
|
112
|
+
|
|
113
|
+
### 쓰기 — 초안 한정
|
|
114
|
+
`velog_create_draft` · `velog_update_draft`
|
|
115
|
+
|
|
116
|
+
### 파생 기능 (API 단순 중계가 아닌 것)
|
|
117
|
+
`velog_blog_stats` — 내 글의 `views`/`likes`/`comments_count` 집계
|
|
118
|
+
`velog_export_posts` — 글을 프론트매터 붙인 마크다운 파일로 저장
|
|
119
|
+
|
|
120
|
+
## 8. 위험과 대응
|
|
121
|
+
|
|
122
|
+
| 위험 | 대응 |
|
|
123
|
+
| --- | --- |
|
|
124
|
+
| 비공식 API 라 예고 없이 깨짐 | 스키마 실측을 문서로 고정 + diff 스크립트. 깨지면 어디가 바뀌었는지 즉시 나옴 |
|
|
125
|
+
| `access_token` 1시간 만료 | 만료를 **에러로 명확히** 알린다. 자동 갱신은 refresh 토큰을 상시 쓰게 하므로 v0.1 범위 밖 |
|
|
126
|
+
| 대량 호출로 이용약관 8조(정상 운영 방해) 저촉 | 페이지네이션 상한 + `export` 는 순차 호출 |
|
|
127
|
+
| 모델이 초안을 남발 | 초안은 벨로그에서 일괄 삭제 가능. 되돌릴 수 있는 실수만 허용한다는 설계 그대로 |
|
|
128
|
+
|
|
129
|
+
## 9. 법적 검토 (2026-07-30 확인)
|
|
130
|
+
|
|
131
|
+
- **이용약관** ([velog.io/policy/terms](https://velog.io/policy/terms)):
|
|
132
|
+
자동화·크롤링·스크래핑·역설계에 대한 **명시 금지 조항 없음**. 제8조(이용제한)는
|
|
133
|
+
"정상적인 서비스 운영 방해"와 "권한을 넘어선 접근"을 다룬다. 본인 계정 토큰으로
|
|
134
|
+
본인 글을 다루는 것은 권한 내 행위.
|
|
135
|
+
- **게시물 저작권** (제5조 1항): 회원에게 귀속. 내 글은 내 것.
|
|
136
|
+
- **API 재구현**: `v3.velog.io/graphql` 스키마는 벨로그 소유이며, 본 구현은
|
|
137
|
+
벨로그의 **introspection 응답에서 직접** 도출했다. 기존 두 패키지의 소스를
|
|
138
|
+
옮겨오지 않았다. 인터페이스 재구현이 저작권 침해가 아니라는 점은
|
|
139
|
+
Google v. Oracle (2021) 로 확립.
|
|
140
|
+
|
|
141
|
+
## 10. 열린 질문
|
|
142
|
+
|
|
143
|
+
- npm 배포 여부. 패키지명 `velog-mcp` 는 선점돼 있어 스코프명
|
|
144
|
+
`@milcho0604/velog-mcp` 를 잡아둠. 공개 배포는 v0.1 안정화 후 판단.
|
|
145
|
+
- `refresh_token` 자동 갱신을 넣을 것인가. 편의 ↔ 상시 보관 위험의 교환.
|
|
146
|
+
- 이미지 업로드(`uploadImage`) 지원 여부. 초안에 이미지가 필요하면 결국 든다.
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
# 벨로그 GraphQL 스키마 실측 기록
|
|
2
|
+
|
|
3
|
+
- 측정: **2026-07-30**
|
|
4
|
+
- 방법: 엔드포인트에 introspection 질의 직접 전송 (인증 불필요)
|
|
5
|
+
- 출처: 벨로그 서버 응답. 타사 구현 소스를 참조하지 않았다
|
|
6
|
+
- **교차검증**: 벨로그 공식 오픈소스 [velog-io/velog](https://github.com/velog-io/velog)
|
|
7
|
+
(⭐207, 2025-10-24) 의 `apps/server/src/graphql/Post.gql` 및
|
|
8
|
+
`apps/server/src/services/PostService/index.ts` 와 대조 — 아래 표시된 항목 일치 확인
|
|
9
|
+
|
|
10
|
+
> 비공식 API 다. 벨로그가 예고 없이 바꿀 수 있다.
|
|
11
|
+
> 무언가 깨지면 **먼저 이 문서와 현재 스키마를 diff** 하라.
|
|
12
|
+
|
|
13
|
+
## 엔드포인트
|
|
14
|
+
|
|
15
|
+
| URL | introspection | 비고 |
|
|
16
|
+
| --- | --- | --- |
|
|
17
|
+
| `https://v3.velog.io/graphql` | **열림** | 현행. 이 프로젝트가 쓰는 곳 |
|
|
18
|
+
| `https://v2.velog.io/graphql` | 막힘 | `GRAPHQL_VALIDATION_FAILED` 반환 |
|
|
19
|
+
|
|
20
|
+
v2 응답 원문:
|
|
21
|
+
```
|
|
22
|
+
GraphQL introspection is not allowed by Apollo Server, but the query
|
|
23
|
+
contained __schema or __type.
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
재확인 명령:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
curl -s -X POST https://v3.velog.io/graphql \
|
|
30
|
+
-H 'Content-Type: application/json' \
|
|
31
|
+
-d '{"query":"{ __schema { queryType { name } mutationType { name } } }"}'
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## 인증
|
|
35
|
+
|
|
36
|
+
쿠키 헤더로 전달한다. 공개 조회는 인증 없이 된다.
|
|
37
|
+
|
|
38
|
+
```
|
|
39
|
+
Cookie: access_token=<...>; refresh_token=<...>
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
| 토큰 | 유효기간 |
|
|
43
|
+
| --- | --- |
|
|
44
|
+
| `access_token` | 1시간 |
|
|
45
|
+
| `refresh_token` | 30일 |
|
|
46
|
+
|
|
47
|
+
## Mutation — 전체 23개
|
|
48
|
+
|
|
49
|
+
`*` = 이 프로젝트가 구현하는 것. 나머지는 **의도적으로 미구현** (사유는 `security.md`)
|
|
50
|
+
|
|
51
|
+
```
|
|
52
|
+
* writePost(input: WritePostInput) 글 작성 (초안=is_temp:true / 발행=false)
|
|
53
|
+
* editPost(input: EditPostInput) 글 수정·발행·발행취소
|
|
54
|
+
* updateProfile / updateAbout 프로필·소개글 ┐ VELOG_ALLOW_PROFILE=1
|
|
55
|
+
* updateVelogTitle / updateSocialInfo 제목·SNS링크 │ 일 때만 도구로 노출
|
|
56
|
+
* updateThumbnail 프로필 사진 ┘
|
|
57
|
+
|
|
58
|
+
likePost / unlikePost ✗ 소셜 행위
|
|
59
|
+
follow / unfollow ✗ 소셜 행위
|
|
60
|
+
sendMail ✗ 메일 발송
|
|
61
|
+
createNotification ✗ 알림 생성
|
|
62
|
+
readNotification / readAllNotifications ✗ 상태 변경
|
|
63
|
+
removeAllNotifications ✗ 되돌릴 수 없음
|
|
64
|
+
updateNotNoticeNotification ✗ 상태 변경
|
|
65
|
+
updateEmailRules ✗ 계정 설정
|
|
66
|
+
initiateChangeEmail / confirmChangeEmail ✗ 계정 설정
|
|
67
|
+
acceptIntegration ✗ 계정 설정
|
|
68
|
+
logout ✗ 세션 파괴
|
|
69
|
+
unregister ✗✗ 계정 탈퇴. 절대 노출 금지
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
> `deletePost` 는 v3 mutation 목록에 **없다**. 삭제는 다른 경로인 듯하나
|
|
73
|
+
> 어차피 구현하지 않으므로 조사하지 않았다.
|
|
74
|
+
|
|
75
|
+
### 이미지 업로드는 GraphQL 이 아니다
|
|
76
|
+
|
|
77
|
+
mutation 23개 어디에도 업로드가 없다. 벨로그는 파일을 fastify REST 라우트로 받는다.
|
|
78
|
+
경로는 공식 소스에서 세 겹으로 조립된다:
|
|
79
|
+
|
|
80
|
+
```
|
|
81
|
+
routes/index.mts fastify.register(api, { prefix: '/api' })
|
|
82
|
+
routes/files/index.mts fastify.register(v3, { prefix: '/v3' }) ← files 아래
|
|
83
|
+
routes/index.mts fastify.register(filesRoute, { prefix: '/files' })
|
|
84
|
+
→ POST https://v3.velog.io/api/files/v3/upload
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
| 항목 | 값 | 근거 |
|
|
88
|
+
| --- | --- | --- |
|
|
89
|
+
| 폼 필드 | `image` (파일) · `type` · `ref_id?` | `multer.single('image')` |
|
|
90
|
+
| `type` | `post` \| `profile` (+ 컨트롤러는 `book` 도 허용) | `filesController.upload` |
|
|
91
|
+
| 응답 | `{ path: "https://velog.velcdn.com/..." }` | `B2ManagerService.upload` |
|
|
92
|
+
| 크기 상한 | **30MB** | `multer({limits:{fileSize:1024*1024*30}})` |
|
|
93
|
+
| 남용 차단 | 1시간 100건 초과 · 1분 20건 이상 → **429** | `ImageService.detectAbuse` |
|
|
94
|
+
| 인증 | 쿠키 (GraphQL 과 동일) | `authGuardPlugin` |
|
|
95
|
+
|
|
96
|
+
★ `type:'post'` + `ref_id` 를 주면 서버가 **그 글이 내 글인지 확인한다**:
|
|
97
|
+
|
|
98
|
+
```ts
|
|
99
|
+
if (type === 'post' && !!ref_id) {
|
|
100
|
+
const post = await this.postService.findById(ref_id)
|
|
101
|
+
if (post?.fk_user_id !== signedUserId) throw new ForbiddenError("Can't access the post")
|
|
102
|
+
}
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
`editPost` 에는 없는 소유권 검사가 여기엔 있다. 쓸 수 있으면 쓰는 게 낫다.
|
|
106
|
+
|
|
107
|
+
**이미지 삭제 API 는 없다.** 올린 건 못 지운다.
|
|
108
|
+
|
|
109
|
+
실측 (2026-07-31, 실계정):
|
|
110
|
+
```
|
|
111
|
+
POST /api/files/v3/upload → 200 {"path":"https://velog.velcdn.com/images/milcho0604/post/…/image.png"}
|
|
112
|
+
GET 그 주소 (무인증) → 200 image/png · 147,485 bytes · 2168×1106
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
### WritePostInput (11필드) — ✅ 공식 소스와 일치
|
|
116
|
+
|
|
117
|
+
`velog-io/velog` 의 `apps/server/src/graphql/Post.gql` 원문과 대조했고 필드·필수여부가
|
|
118
|
+
전부 같다. introspection 실측이 정확했음이 확인됐다.
|
|
119
|
+
|
|
120
|
+
```
|
|
121
|
+
* title String 필수
|
|
122
|
+
* body String 필수 — 마크다운 본문
|
|
123
|
+
* tags [String] 필수 — 빈 배열 허용. 생략하면 조용히 실패한다
|
|
124
|
+
* is_markdown Boolean 필수 — true
|
|
125
|
+
* is_temp Boolean 필수 — ★ true=임시저장, false=발행
|
|
126
|
+
* is_private Boolean 필수
|
|
127
|
+
* url_slug String 필수
|
|
128
|
+
* meta JSON 필수 — 빈 객체 허용
|
|
129
|
+
thumbnail String 선택
|
|
130
|
+
series_id ID 선택
|
|
131
|
+
token String 선택
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
### EditPostInput (12필드)
|
|
135
|
+
|
|
136
|
+
`WritePostInput` 과 동일하되 맨 앞에 `id: ID!` 가 붙는다.
|
|
137
|
+
|
|
138
|
+
## Query — 전체 24개
|
|
139
|
+
|
|
140
|
+
```
|
|
141
|
+
읽기 (무인증 가능)
|
|
142
|
+
post(input: ReadPostInput) 글 하나
|
|
143
|
+
posts(input: GetPostsInput) 글 목록
|
|
144
|
+
recentPosts(input: RecentPostsInput) 최신
|
|
145
|
+
trendingPosts(input: TrendingPostsInput) 트렌딩
|
|
146
|
+
searchPosts(input: GetSearchPostsInput) 검색
|
|
147
|
+
user(input: GetUserInput) 사용자
|
|
148
|
+
series(input: GetSeriesInput) 시리즈 하나
|
|
149
|
+
seriesList(input: GetSeriesListInput) 시리즈 목록
|
|
150
|
+
tag(name: String) 태그
|
|
151
|
+
userTags(input: UserTagsInput) 사용자 태그
|
|
152
|
+
trendingWriters(input: TrendingWritersInput)
|
|
153
|
+
velogConfig(input: GetVelogConfigInput)
|
|
154
|
+
|
|
155
|
+
읽기 (인증 필요)
|
|
156
|
+
currentUser() 로그인 확인용
|
|
157
|
+
isLogged()
|
|
158
|
+
feedPosts(input: FeedPostsInput) 구독 피드
|
|
159
|
+
readingList(input: ReadingListInput) 읽기목록
|
|
160
|
+
notifications(input: NotificationsInput)
|
|
161
|
+
notNoticeNotificationCount()
|
|
162
|
+
followers / followings(input: GetFollowInput)
|
|
163
|
+
|
|
164
|
+
미사용
|
|
165
|
+
ads / restoreToken / unregisterToken / checkEmailExists
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
### 주요 입력 타입
|
|
169
|
+
|
|
170
|
+
```
|
|
171
|
+
ReadPostInput id? / username? / url_slug?
|
|
172
|
+
GetPostsInput cursor? / username? / temp_only? / tag? / limit?
|
|
173
|
+
└ temp_only:true = 내 임시글 목록 (인증 필요)
|
|
174
|
+
GetSearchPostsInput keyword* / offset? / limit? / username?
|
|
175
|
+
TrendingPostsInput offset? / limit? / timeframe?
|
|
176
|
+
GetUserInput id? / username?
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
## Post 타입 (27필드)
|
|
180
|
+
|
|
181
|
+
```
|
|
182
|
+
id title body short_description thumbnail
|
|
183
|
+
is_markdown is_temp is_private
|
|
184
|
+
url_slug fk_user_id original_post_id
|
|
185
|
+
likes views comments_count ← 통계 도구가 쓰는 것
|
|
186
|
+
created_at updated_at released_at last_read_at
|
|
187
|
+
meta user comments tags series
|
|
188
|
+
is_liked is_followed linked_posts recommended_posts
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
`likes` / `views` / `comments_count` 가 Post 에 직접 붙어 있어 별도 통계 API 없이
|
|
192
|
+
집계할 수 있다. `velog_blog_stats` 가 이걸 쓴다.
|
|
193
|
+
|
|
194
|
+
## User 타입 (14필드)
|
|
195
|
+
|
|
196
|
+
```
|
|
197
|
+
id username email created_at updated_at
|
|
198
|
+
is_certified is_trusted is_followed
|
|
199
|
+
profile(UserProfile) velog_config(VelogConfig)
|
|
200
|
+
series_list user_meta followers_count followings_count
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
## 공식 소스로 확인한 서버 동작
|
|
204
|
+
|
|
205
|
+
`velog-io/velog` 를 읽어 확인한 것들. 우리 대응 코드의 근거다.
|
|
206
|
+
|
|
207
|
+
### `updated_at` 이 왜 응답 전체를 죽이나
|
|
208
|
+
|
|
209
|
+
```graphql
|
|
210
|
+
# apps/server/src/graphql/Post.gql
|
|
211
|
+
type Post {
|
|
212
|
+
created_at: Date! # non-null
|
|
213
|
+
updated_at: Date! # non-null ← 여기
|
|
214
|
+
released_at: Date # nullable
|
|
215
|
+
}
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
**스키마는 non-null 로 선언했는데 실제 DB 에 null 인 행이 있다.** GraphQL 규약상
|
|
219
|
+
non-null 필드에 null 이 오면 그 필드만 비우는 게 아니라 상위 객체를, 리스트 안이면
|
|
220
|
+
응답 전체를 무효화한다. 그래서 글 하나 때문에 검색 결과 전부가 날아간다.
|
|
221
|
+
`released_at` 은 nullable 이라 안전 — 우리가 이쪽만 쓰는 이유다.
|
|
222
|
+
|
|
223
|
+
### 커서가 고착되는 조건
|
|
224
|
+
|
|
225
|
+
```ts
|
|
226
|
+
// apps/server/src/services/PostService/index.ts
|
|
227
|
+
const cursorData = cursor ? await ...findUnique({ fk_post_id: cursor }) : null
|
|
228
|
+
const cursorQueryOption = cursorData
|
|
229
|
+
? { released_at: { lt: cursorData.created_at }, id: { not: cursorData.id } }
|
|
230
|
+
: {} // ★ 못 찾으면 필터 없음
|
|
231
|
+
take: limit
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
**커서 id 를 서버가 못 찾으면 필터가 통째로 빠져 1페이지를 다시 준다.**
|
|
235
|
+
그대로 두면 같은 50편을 무한히 재수집한다. `fetchAllPosts` 가 id 중복 제거와
|
|
236
|
+
커서 반복 감지를 하는 이유가 이것이다 (`src/tools/stats.ts`).
|
|
237
|
+
|
|
238
|
+
또한 서버가 `limit > 100` 을 `BadRequestError` 로 막는다. 우리 도구는 50 을
|
|
239
|
+
상한으로 두므로 걸리지 않는다.
|
|
240
|
+
|
|
241
|
+
### ★ 소유권 검사가 없는 두 곳
|
|
242
|
+
|
|
243
|
+
**① `editPost` 는 글 소유자를 확인하지 않는다.**
|
|
244
|
+
|
|
245
|
+
```ts
|
|
246
|
+
// initializePostProcess
|
|
247
|
+
if (type === 'write') { ... fk_user_id: signedUserId ... } // 생성은 내 것으로
|
|
248
|
+
if (type === 'edit') { post = findUnique({ where: { id } }) } // ← 소유자 비교 없음
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
공개 글은 누구나 id 로 조회할 수 있으므로 **남의 글을 수정·비공개화할 수 있다.**
|
|
252
|
+
|
|
253
|
+
**② 시리즈도 '처음 붙일 때'는 확인하지 않는다.**
|
|
254
|
+
|
|
255
|
+
```ts
|
|
256
|
+
if (!prevSeriesPost && series_id) {
|
|
257
|
+
await this.seriesService.appendToSeries(series_id, post.id) // ← 검사 없음
|
|
258
|
+
}
|
|
259
|
+
if (prevSeriesPost && prevSeriesPost.fk_series_id !== series_id) {
|
|
260
|
+
if (series_id) {
|
|
261
|
+
await this.checkSeriesOwnership(series_id, userId) // ← 여기만 검사
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
`velog_list_series` 로 남의 공개 시리즈 id 를 얻을 수 있으므로, **내 글을 남의
|
|
265
|
+
시리즈에 붙일 수 있다.** 글 소유권 검사로는 못 막는다 — 글은 내 것이기 때문이다.
|
|
266
|
+
|
|
267
|
+
→ 우리 쪽 대응: `src/ownership.ts` 의 `assertOwned` / `assertOwnsSeries`.
|
|
268
|
+
`safety.test.ts` 의 A8·A11 이 모든 호출 경로에 적용됐는지 소스를 읽어 강제한다.
|
|
269
|
+
|
|
270
|
+
### 발행 제한 검사는 공개 여부보다 먼저 돈다
|
|
271
|
+
|
|
272
|
+
```ts
|
|
273
|
+
const isPublish = !data.is_temp && !data.is_private
|
|
274
|
+
const isLimit = await this.isPostLimitReached(signedUserId) // ← 무조건 실행
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
`is_private:true` 글은 카운터를 **올리지 않지만**, 그 글을 만드는 요청이 이미 쌓인
|
|
278
|
+
카운트에 대한 파괴 동작(최근 5분 글 전부 비공개)을 **촉발할 수 있다.**
|
|
279
|
+
'올리지 않는다'와 '유발하지 않는다'는 다르다.
|
|
280
|
+
|
|
281
|
+
## 실계정 왕복 검증 (2026-07-31)
|
|
282
|
+
|
|
283
|
+
희생용 초안 하나로 전 경로를 돌고 필드 보존을 확인했다.
|
|
284
|
+
|
|
285
|
+
```
|
|
286
|
+
create_draft → update_draft → publish_draft(비공개) → update_post → unpublish_post
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
| 단계 | is_temp | is_private | 본문 | 태그 | 시리즈 | 판정 |
|
|
290
|
+
| --- | --- | --- | --- | --- | --- | --- |
|
|
291
|
+
| 생성 | true | true | 249자 | 2개 | **없음** | series_id 를 줬는데 안 붙음 |
|
|
292
|
+
| update_draft | true | true | 249자 | 2개 | **DataBase** | 여기서 붙음 |
|
|
293
|
+
| publish_draft | **false** | true | 249자 | 2개 | DataBase | 내용 전부 유지 |
|
|
294
|
+
| update_post | false | true | 249자 | 2개 | DataBase | title 외 전부 유지 |
|
|
295
|
+
| unpublish | **true** | **true** | 249자 | 2개 | DataBase | 내용 전부 유지 |
|
|
296
|
+
|
|
297
|
+
**1→2 행이 위 D7 함정의 실물 증거다.** `create_draft` 에 `series_id` 를 줬는데
|
|
298
|
+
붙지 않았고, 같은 값으로 `update_draft` 를 부르자 붙었다. 도구가 이 사실을 응답에
|
|
299
|
+
경고로 알리는 것도 확인했다.
|
|
300
|
+
|
|
301
|
+
`unpublish` 가 `is_private:true` 로 되돌리는 것도 확인 — 공개 상태를 유지했다면
|
|
302
|
+
그 초안이 다시 벨로그 계수 대상이 됐을 것이다.
|
|
303
|
+
|
|
304
|
+
남의 시리즈(@velopert) id 로 `update_draft` 를 부르면 거부된다:
|
|
305
|
+
```
|
|
306
|
+
series_id=96ffa520-… 는 @milcho0604 의 시리즈가 아닙니다
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
### `meta` 보존 — 실데이터로 확인
|
|
310
|
+
|
|
311
|
+
왕복 초안의 `meta` 가 `{}` 라 처음엔 검증이 약했다. 그래서 벨로그 웹이 하는 것과
|
|
312
|
+
같은 형태로 값을 직접 심고 다시 확인했다.
|
|
313
|
+
|
|
314
|
+
```
|
|
315
|
+
심기 {"cover":"none","short_description":"이 값이 보존돼야 한다"}
|
|
316
|
+
update_draft 로 제목만 변경
|
|
317
|
+
결과 {"cover":"none","short_description":"이 값이 보존돼야 한다"} ← 그대로
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
`meta: {}` 를 보내던 시절이었다면 여기서 `short_description` 이 사라졌을 것이다.
|
|
321
|
+
|
|
322
|
+
## 스키마 변경 감지
|
|
323
|
+
|
|
324
|
+
```bash
|
|
325
|
+
npm run schema:dump # 현재 스키마를 덤프
|
|
326
|
+
git diff docs/api-reference.md
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
깨졌을 때 "어디가" 바뀌었는지 이 diff 로 찾는다.
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# 구조
|
|
2
|
+
|
|
3
|
+
## 전체 흐름
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
MCP 클라이언트 (Claude 등)
|
|
7
|
+
│ stdio (JSON-RPC)
|
|
8
|
+
▼
|
|
9
|
+
index.ts ─────────── createServer(client)
|
|
10
|
+
│ 도구 등록만 한다. 로직 없음
|
|
11
|
+
├── tools/posts.ts 글 조회
|
|
12
|
+
├── tools/discover.ts 검색·트렌딩·최신
|
|
13
|
+
├── tools/profile.ts 사용자·시리즈·태그
|
|
14
|
+
├── tools/stats.ts 통계 (집계)
|
|
15
|
+
├── tools/export.ts 마크다운 백업 (파일 씀)
|
|
16
|
+
└── tools/drafts.ts ★ 유일한 쓰기 경로
|
|
17
|
+
│
|
|
18
|
+
▼
|
|
19
|
+
client.ts ─────────── VelogClient.request()
|
|
20
|
+
│ 재시도 · 마스킹 · 타임아웃
|
|
21
|
+
├── auth.ts 환경변수 → 쿠키 헤더
|
|
22
|
+
└── graphql.ts 질의문 (필드 선택)
|
|
23
|
+
│
|
|
24
|
+
▼ HTTPS POST
|
|
25
|
+
https://v3.velog.io/graphql
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## 레이어 규칙
|
|
29
|
+
|
|
30
|
+
| 레이어 | 하는 일 | 하면 안 되는 일 |
|
|
31
|
+
| --- | --- | --- |
|
|
32
|
+
| `index.ts` | 도구 등록, stdio 연결 | 비즈니스 로직 |
|
|
33
|
+
| `tools/*` | 입력 검증(zod), 질의 호출, 출력 정형화 | fetch 직접 호출 |
|
|
34
|
+
| `client.ts` | HTTP, 재시도, 오류 변환, 마스킹 | 도구별 지식 |
|
|
35
|
+
| `auth.ts` | 토큰 읽기·헤더 생성·마스킹 | **파일 접근 (금지)** |
|
|
36
|
+
| `graphql.ts` | 질의문 상수 | 실행 |
|
|
37
|
+
|
|
38
|
+
`tools/*` 가 `fetch` 를 직접 부르면 마스킹과 재시도를 우회하게 된다.
|
|
39
|
+
반드시 `client.request()` 를 통한다.
|
|
40
|
+
|
|
41
|
+
## 이 레포가 쓰지 않는 TypeScript 문법
|
|
42
|
+
|
|
43
|
+
Node 24 는 `.ts` 를 **타입 스트리핑**으로 직접 실행한다. 지우기만 하고 코드를
|
|
44
|
+
생성하지 않으므로, 변환이 필요한 문법은 `ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX` 로
|
|
45
|
+
죽는다. 실제로 겪은 것:
|
|
46
|
+
|
|
47
|
+
```ts
|
|
48
|
+
// ✗ 파라미터 프로퍼티 — 필드 대입 코드를 '생성'해야 하므로 불가
|
|
49
|
+
class E extends Error {
|
|
50
|
+
constructor(readonly detail?: Detail) { super(); }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ○ 명시 필드
|
|
54
|
+
class E extends Error {
|
|
55
|
+
readonly detail: Detail | undefined;
|
|
56
|
+
constructor(detail?: Detail) { super(); this.detail = detail; }
|
|
57
|
+
}
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
같은 이유로 **`enum`·`namespace`·데코레이터**도 쓰지 않는다.
|
|
61
|
+
(`const enum` 은 물론이고 일반 `enum` 도 런타임 객체를 생성해야 한다.)
|
|
62
|
+
|
|
63
|
+
대신 얻는 것: `tsx`·`ts-node`·`jest`·`babel` 이 전부 불필요하다.
|
|
64
|
+
테스트는 `node --test src/__tests__/*.test.ts` 로 소스에서 바로 돈다.
|
|
65
|
+
|
|
66
|
+
## 빌드
|
|
67
|
+
|
|
68
|
+
```
|
|
69
|
+
src/*.ts ──tsc──▶ dist/*.js
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
소스는 `'./auth.ts'` 로 import 하고, `rewriteRelativeImportExtensions` 가
|
|
73
|
+
빌드 시 `'./auth.js'` 로 바꾼다. 그래서 **테스트는 빌드 없이** 돌고
|
|
74
|
+
**배포본은 정상 ESM** 이 된다.
|
|
75
|
+
|
|
76
|
+
## 벨로그 API 를 다룰 때 지킬 것
|
|
77
|
+
|
|
78
|
+
실측으로 확인한 상대 쪽 특성이다. 어기면 조용히 깨진다.
|
|
79
|
+
|
|
80
|
+
**1. 한 요청에 쿼리를 묶지 않는다.**
|
|
81
|
+
Prisma 커넥션 풀이 작다 (limit 5 / timeout 10s). `searchPosts + trendingPosts`
|
|
82
|
+
동시 요청에서 재현됨.
|
|
83
|
+
|
|
84
|
+
**2. `updated_at` 을 질의하지 않는다.**
|
|
85
|
+
스키마는 non-nullable 인데 실제 null 인 글이 있다. 하나만 섞여도 응답 전체가
|
|
86
|
+
거부된다 (`Cannot return null for non-nullable field Post.updated_at`).
|
|
87
|
+
|
|
88
|
+
**3. 목록 응답은 `limit` 보다 적을 수 있다.**
|
|
89
|
+
조회 후 일부 글이 사후 필터링된다. `count` 는 필터 이전 총계다.
|
|
90
|
+
페이지네이션은 반환 건수가 아니라 `offset + limit` 로 넘긴다.
|
|
91
|
+
|
|
92
|
+
**4. 5xx·커넥션풀 오류는 재시도한다. 인증 만료·4xx 는 즉시 던진다.**
|
|
93
|
+
`isTransient()` 가 판정한다.
|
|
94
|
+
|
|
95
|
+
**5. 순차 반복 호출에는 간격을 둔다.**
|
|
96
|
+
`export` 는 글마다 250ms 쉰다.
|
|
97
|
+
|
|
98
|
+
## 테스트
|
|
99
|
+
|
|
100
|
+
```
|
|
101
|
+
src/__tests__/auth.test.ts 토큰 취급·마스킹
|
|
102
|
+
src/__tests__/client.test.ts 재시도·오류 변환 (가짜 fetch 주입)
|
|
103
|
+
src/__tests__/slug.test.ts 슬러그 생성
|
|
104
|
+
src/__tests__/safety.test.ts ★ 안전 불변식 (PRD 성공기준)
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
`safety.test.ts` 는 소스 전체를 읽어 금지 패턴 부재를 단언하고, 실제 MCP
|
|
108
|
+
세션(`InMemoryTransport`)을 띄워 도구 목록과 입력 스키마를 검사한다.
|
|
109
|
+
**이 파일이 깨지면 우회하지 말고 왜 깨졌는지부터 볼 것.**
|
|
110
|
+
|
|
111
|
+
네트워크를 타는 테스트는 두지 않았다. 벨로그가 느리거나 막히면 CI 가 흔들린다.
|
|
112
|
+
실 API 검증은 개발 중 수동으로 한다 (`npm run schema:dump` 포함).
|