@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/README.md
ADDED
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
# velog-mcp
|
|
2
|
+
|
|
3
|
+
[](https://nodejs.org)
|
|
4
|
+
[](LICENSE)
|
|
5
|
+
[](package.json)
|
|
6
|
+
|
|
7
|
+
An MCP server for [Velog](https://velog.io), the Korean developer blogging platform.
|
|
8
|
+
Read your blog, draft posts, publish them, and back everything up — from Claude or any
|
|
9
|
+
MCP client.
|
|
10
|
+
|
|
11
|
+
**[한국어 문서 →](README.ko.md)**
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## Why another one?
|
|
16
|
+
|
|
17
|
+
Two Velog MCP servers already exist. This one differs in three ways.
|
|
18
|
+
|
|
19
|
+
**1. Publishing is a permission, not a default.**
|
|
20
|
+
Out of the box the server can create drafts and publish **privately**. Public
|
|
21
|
+
publishing requires you to set an environment variable. The model cannot flip that
|
|
22
|
+
switch — only you can, in your MCP config.
|
|
23
|
+
|
|
24
|
+
**2. Every quirk is measured, not assumed.**
|
|
25
|
+
Velog's GraphQL API is undocumented. This repo records what it *actually* does,
|
|
26
|
+
verified against [velog-io/velog](https://github.com/velog-io/velog) source and live
|
|
27
|
+
calls. Six server-side quirks are written up in
|
|
28
|
+
[docs/api-reference.md](docs/api-reference.md) — including one that silently returns an
|
|
29
|
+
empty list, and one that can turn your published posts private.
|
|
30
|
+
|
|
31
|
+
**3. Two runtime dependencies.** `@modelcontextprotocol/sdk` and `zod`. HTTP, test
|
|
32
|
+
runner, and TypeScript execution all come from Node 24 itself.
|
|
33
|
+
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
## Install
|
|
37
|
+
|
|
38
|
+
Requires **Node.js 24 or newer**.
|
|
39
|
+
|
|
40
|
+
### As a Claude Code plugin (recommended)
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
/plugin marketplace add milcho0604/velog-mcp
|
|
44
|
+
/plugin install velog@milcho
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Installation asks for four values. **Leave them all blank and it still installs,
|
|
48
|
+
running read-only.**
|
|
49
|
+
|
|
50
|
+
| Prompt | If left blank |
|
|
51
|
+
| --- | --- |
|
|
52
|
+
| Velog refresh token | Read-only (browse, search, stats still work) |
|
|
53
|
+
| Allow public publishing | Drafts and private publishing only |
|
|
54
|
+
| Allow profile edits | Profile tools stay off |
|
|
55
|
+
| Chrome path | Found automatically in standard locations |
|
|
56
|
+
|
|
57
|
+
**The token goes into the macOS Keychain**, not into a settings file in plaintext.
|
|
58
|
+
Only values declared `sensitive: true` reach the Keychain, and a test enforces
|
|
59
|
+
that declaration (P7).
|
|
60
|
+
|
|
61
|
+
Change values later with `/plugin manage`.
|
|
62
|
+
|
|
63
|
+
### From source
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
git clone https://github.com/milcho0604/velog-mcp.git
|
|
67
|
+
cd velog-mcp
|
|
68
|
+
npm install && npm run build
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Configure
|
|
72
|
+
|
|
73
|
+
Add this to your MCP client config (`claude_desktop_config.json`, `.mcp.json`, …):
|
|
74
|
+
|
|
75
|
+
```json
|
|
76
|
+
{
|
|
77
|
+
"mcpServers": {
|
|
78
|
+
"velog": {
|
|
79
|
+
"command": "node",
|
|
80
|
+
"args": ["/absolute/path/to/velog-mcp/dist/index.js"],
|
|
81
|
+
"env": {
|
|
82
|
+
"VELOG_REFRESH_TOKEN": "your_refresh_token"
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
With the Claude Code CLI:
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
claude mcp add velog -- node /absolute/path/to/velog-mcp/dist/index.js
|
|
93
|
+
# then add the "env" block to the entry it created
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
### Getting your token
|
|
97
|
+
|
|
98
|
+
Velog has no public write API, so the server authenticates with your browser session
|
|
99
|
+
cookie.
|
|
100
|
+
|
|
101
|
+
1. Log in at [velog.io](https://velog.io)
|
|
102
|
+
2. Open DevTools (`F12`) → **Application** → **Cookies** → `https://velog.io`
|
|
103
|
+
3. Copy the value of **`refresh_token`**
|
|
104
|
+
|
|
105
|
+
**`VELOG_REFRESH_TOKEN` alone is enough.** Velog's server reissues the short-lived
|
|
106
|
+
`access_token` on its own ([`authPlugin.mts`](https://github.com/velog-io/velog/blob/main/apps/server/src/common/plugins/global/authPlugin.mts)),
|
|
107
|
+
and this server picks the refreshed cookie out of the response. One paste lasts
|
|
108
|
+
**30 days**.
|
|
109
|
+
|
|
110
|
+
`VELOG_ACCESS_TOKEN` also works but expires in about an hour by itself.
|
|
111
|
+
|
|
112
|
+
> Tokens are read from the environment only. They are never written to disk, and the
|
|
113
|
+
> server never reads your browser's cookie database or your OS keychain.
|
|
114
|
+
> Whatever you put in your MCP config file does live there in plain text, though —
|
|
115
|
+
> that file is yours to protect.
|
|
116
|
+
|
|
117
|
+
**Without a token the server still starts**, read-only. Public posts, search, trending,
|
|
118
|
+
and blog stats all work unauthenticated.
|
|
119
|
+
|
|
120
|
+
---
|
|
121
|
+
|
|
122
|
+
## Permissions
|
|
123
|
+
|
|
124
|
+
| Environment | What you get |
|
|
125
|
+
| --- | --- |
|
|
126
|
+
| *(nothing set)* | Read everything · create drafts · **publish privately** · draw and upload images — 21 tools |
|
|
127
|
+
| `VELOG_ALLOW_PUBLIC=1` | …plus **public publishing** (adds an `is_private` parameter) |
|
|
128
|
+
| `VELOG_ALLOW_PROFILE=1` | …plus **profile editing** (adds 5 tools) |
|
|
129
|
+
|
|
130
|
+
The two switches are independent — enable either, both, or neither.
|
|
131
|
+
|
|
132
|
+
```json
|
|
133
|
+
"env": {
|
|
134
|
+
"VELOG_REFRESH_TOKEN": "...",
|
|
135
|
+
"VELOG_ALLOW_PUBLIC": "1",
|
|
136
|
+
"VELOG_ALLOW_PROFILE": "1"
|
|
137
|
+
}
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
Accepted as "on": `1`, `true`, `yes`, `on`. Anything else is off — a typo won't quietly
|
|
141
|
+
enable it.
|
|
142
|
+
|
|
143
|
+
When public publishing is off, the `is_private` parameter **does not exist** on any
|
|
144
|
+
tool, so the model has no way to ask for it. When it's on, `is_private` appears and
|
|
145
|
+
still defaults to `true`.
|
|
146
|
+
|
|
147
|
+
### Why private-by-default
|
|
148
|
+
|
|
149
|
+
Not caution for its own sake. Velog's rate limiter counts only `is_private: false`
|
|
150
|
+
posts:
|
|
151
|
+
|
|
152
|
+
```ts
|
|
153
|
+
// apps/server/src/services/PostApiService/index.mts
|
|
154
|
+
count({ where: { fk_user_id, is_private: false, released_at: { gt: fiveMinutesAgo } } })
|
|
155
|
+
if (count >= 10) {
|
|
156
|
+
updateMany({ where: { fk_user_id, released_at: { gt: fiveMinutesAgo } },
|
|
157
|
+
data: { is_private: true } }) // flips *everything* recent to private
|
|
158
|
+
}
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
Private posts don't **increment** that count. But `isPostLimitReached()` runs
|
|
162
|
+
unconditionally, *before* privacy is examined — so if ten public posts already exist in
|
|
163
|
+
the last five minutes, even a private draft request can trigger the sweep. "Doesn't
|
|
164
|
+
increment" is not "can't trigger." That's why write retries stay disabled and the local
|
|
165
|
+
limiter stays in place.
|
|
166
|
+
|
|
167
|
+
Public posts do increment it, and once a post is public it has already gone out through
|
|
168
|
+
RSS, search indexes, and subscriber email, none of which a delete reaches. That
|
|
169
|
+
asymmetry is what deserves an explicit opt-in.
|
|
170
|
+
|
|
171
|
+
Full reasoning: [docs/security.md](docs/security.md)
|
|
172
|
+
|
|
173
|
+
---
|
|
174
|
+
|
|
175
|
+
## Tools
|
|
176
|
+
|
|
177
|
+
21 tools. Only 9 of them change anything on Velog.
|
|
178
|
+
|
|
179
|
+
### Reading — no auth required
|
|
180
|
+
|
|
181
|
+
| Tool | Purpose |
|
|
182
|
+
| --- | --- |
|
|
183
|
+
| `velog_get_post` | Read one post, body included |
|
|
184
|
+
| `velog_list_posts` | A user's posts, optionally filtered by tag |
|
|
185
|
+
| `velog_search_posts` | Keyword search; pass `username` to search inside one blog |
|
|
186
|
+
| `velog_trending_posts` | Trending by `day` / `week` / `month` / `year` |
|
|
187
|
+
| `velog_recent_posts` | Newest posts across Velog |
|
|
188
|
+
| `velog_get_user` | Profile, follower counts, bio |
|
|
189
|
+
| `velog_list_series` | A user's series, with post counts and IDs |
|
|
190
|
+
| `velog_user_tags` | Tags a user writes about, with counts |
|
|
191
|
+
|
|
192
|
+
### Reading — auth required
|
|
193
|
+
|
|
194
|
+
| Tool | Purpose |
|
|
195
|
+
| --- | --- |
|
|
196
|
+
| `velog_whoami` | Which account the token belongs to (also a token health check) |
|
|
197
|
+
| `velog_list_drafts` | Your saved drafts, with IDs |
|
|
198
|
+
|
|
199
|
+
### Derived — things Velog doesn't provide
|
|
200
|
+
|
|
201
|
+
| Tool | Purpose |
|
|
202
|
+
| --- | --- |
|
|
203
|
+
| `velog_blog_stats` | Aggregate views/likes/comments, top posts, per-year and per-tag breakdown |
|
|
204
|
+
| `velog_export_posts` | Save posts as Markdown files with YAML front matter |
|
|
205
|
+
|
|
206
|
+
### Writing
|
|
207
|
+
|
|
208
|
+
| Tool | Effect |
|
|
209
|
+
| --- | --- |
|
|
210
|
+
| `velog_create_draft` | Save a draft. Never publishes, under any configuration |
|
|
211
|
+
| `velog_update_draft` | Replace a draft **entirely** — omitted fields are reset |
|
|
212
|
+
| `velog_publish_post` | Publish a new post |
|
|
213
|
+
| `velog_publish_draft` | Publish an existing draft, reusing its stored body |
|
|
214
|
+
| `velog_unpublish_post` | Send a published post back to drafts |
|
|
215
|
+
| `velog_update_post` | Edit a published post — omitted fields are **kept** |
|
|
216
|
+
|
|
217
|
+
> `velog_update_draft` resets what you omit; `velog_update_post` preserves it.
|
|
218
|
+
> The asymmetry is deliberate — see [docs/tools.md](docs/tools.md).
|
|
219
|
+
|
|
220
|
+
Tools that take a `username` — `velog_list_drafts`, `velog_blog_stats`,
|
|
221
|
+
`velog_export_posts`, `velog_search_posts` — fall back to your own account when you
|
|
222
|
+
omit it.
|
|
223
|
+
|
|
224
|
+
### Diagrams and images
|
|
225
|
+
|
|
226
|
+
| Tool | Effect |
|
|
227
|
+
| --- | --- |
|
|
228
|
+
| `velog_render_diagram` | Draw an architecture/flow diagram and upload it |
|
|
229
|
+
| `velog_render_cover` | Draw a 1200×630 cover card for a post |
|
|
230
|
+
| `velog_upload_image` | Upload a local image file, get the Markdown back |
|
|
231
|
+
|
|
232
|
+
You describe **what exists and what flows where**; the renderer owns everything else —
|
|
233
|
+
palette, spacing, text measurement, corner rounding, canvas size. That is deliberate: a
|
|
234
|
+
diagram redrawn from scratch each time looks different each time.
|
|
235
|
+
|
|
236
|
+
Every measurement is real. Node widths and line breaks come from the browser's
|
|
237
|
+
`getBBox()`, never from a character count — with mixed Korean and English text, counting
|
|
238
|
+
characters is wrong every time. The canvas is sized *after* drawing, from the content's
|
|
239
|
+
bounding box, so a diagram cannot be clipped.
|
|
240
|
+
|
|
241
|
+
Then it audits itself and reports five classes of defect:
|
|
242
|
+
|
|
243
|
+
```
|
|
244
|
+
text spilling outside its card · letter-spacing squeezed to fit
|
|
245
|
+
a line crossing (or hiding behind) a node
|
|
246
|
+
two lines overlapping · two nodes overlapping · a label sitting on a card
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
**If the audit finds anything, nothing is uploaded — and there is no flag to turn that
|
|
250
|
+
off.** Velog has no delete-image API and every upload counts against your quota, so a
|
|
251
|
+
flawed diagram is worth redrawing rather than shipping. An override that the model can
|
|
252
|
+
set itself is not a safeguard (same reasoning as the publishing switch in
|
|
253
|
+
[ADR 0004](docs/decisions/0004-capability-model.md)). If you really want a flawed
|
|
254
|
+
diagram online, render with `upload: false`, look at the PNG, then pass its path to
|
|
255
|
+
`velog_upload_image`.
|
|
256
|
+
|
|
257
|
+
Icons are 28 built-in glyphs (`server`, `database`, `cloud`, `clock`, `alert`, …) drawn
|
|
258
|
+
from primitive shapes. Nothing is fetched — the renderer runs with DNS disabled.
|
|
259
|
+
|
|
260
|
+
**Requires Chrome** (or any Chromium-based browser: Edge, Brave, Chromium). It is found
|
|
261
|
+
automatically on macOS/Linux/Windows; set `VELOG_CHROME_PATH` if yours lives elsewhere.
|
|
262
|
+
Only `velog_render_diagram` and `velog_render_cover` need it — `velog_upload_image`
|
|
263
|
+
just reads a local file, so it and the other 18 tools work without a browser.
|
|
264
|
+
|
|
265
|
+
**Cost, measured:** one diagram is ~1 GB peak across 9–11 Chrome processes for 3–4
|
|
266
|
+
seconds, then back to zero. That's Chrome's floor, not our content. Coordinates, text
|
|
267
|
+
lengths and array sizes are all bounded, and the canvas cap (6000px / 9M px) is enforced
|
|
268
|
+
**inside the page** — a browser commits to a surface the moment it receives width and
|
|
269
|
+
height, so checking after the fact is too late. Renders are
|
|
270
|
+
**serialized** — MCP clients call tools in parallel, and without that a five-diagram
|
|
271
|
+
request would mean 45 Chrome processes and 6 GB. Serialized, four concurrent requests
|
|
272
|
+
still peak at one render's worth. Ten renders in a row show no accumulation.
|
|
273
|
+
|
|
274
|
+
### Profile editing — `VELOG_ALLOW_PROFILE=1`
|
|
275
|
+
|
|
276
|
+
Five more tools appear: `velog_update_profile` (display name, bio),
|
|
277
|
+
`velog_update_about`, `velog_update_blog_title`, `velog_update_social_links`,
|
|
278
|
+
`velog_update_profile_image`. Without the flag they aren't registered at all.
|
|
279
|
+
|
|
280
|
+
The gate isn't about danger — these are reversible, affect only your own account, and
|
|
281
|
+
aren't distributed anywhere. It's about **confusion**: a profile's `short_bio` and a
|
|
282
|
+
post's `short_description` sound alike. "Fix my description" is ambiguous, and with the
|
|
283
|
+
switch off a wrong guess can't reach your profile.
|
|
284
|
+
|
|
285
|
+
`velog_update_profile` **keeps what you omit.** Velog's `UpdateProfileInput` requires
|
|
286
|
+
both `display_name` and `short_bio`, so sending one alone would blank the other — the
|
|
287
|
+
tool reads your current values and fills them in.
|
|
288
|
+
|
|
289
|
+
---
|
|
290
|
+
|
|
291
|
+
## Usage
|
|
292
|
+
|
|
293
|
+
Once it's configured, just talk to your MCP client.
|
|
294
|
+
|
|
295
|
+
```
|
|
296
|
+
"Draft a Velog post about the bug I fixed today"
|
|
297
|
+
→ writes Markdown, saves it as a draft, hands back the edit URL
|
|
298
|
+
|
|
299
|
+
"What did I write about HTTP/2 last year?"
|
|
300
|
+
→ searches inside your own posts
|
|
301
|
+
|
|
302
|
+
"Show my top 10 posts by views, and which tags get read most"
|
|
303
|
+
→ walks your whole blog and aggregates
|
|
304
|
+
|
|
305
|
+
"Back up all my posts to ~/blog-backup"
|
|
306
|
+
→ writes .md files with front matter
|
|
307
|
+
|
|
308
|
+
"Publish that draft"
|
|
309
|
+
→ private by default; public only with VELOG_ALLOW_PUBLIC=1
|
|
310
|
+
|
|
311
|
+
"Draw how the request flows from the LB through the workers to Redis"
|
|
312
|
+
→ renders a diagram, audits it, uploads it, hands back the Markdown line
|
|
313
|
+
|
|
314
|
+
"Make a cover image for this post"
|
|
315
|
+
→ 1200×630 card; pass the URL to velog_update_post's thumbnail
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
Your MCP client asks for approval before each tool call, and irreversible tools carry
|
|
319
|
+
`destructiveHint`, so nothing gets published without you seeing it first.
|
|
320
|
+
|
|
321
|
+
### Exported file format
|
|
322
|
+
|
|
323
|
+
```yaml
|
|
324
|
+
---
|
|
325
|
+
title: "Post title"
|
|
326
|
+
date: 2022-12-31T18:32:39.790Z
|
|
327
|
+
slug: "url-slug"
|
|
328
|
+
url: "https://velog.io/@username/url-slug"
|
|
329
|
+
tags: ["tag1", "tag2"]
|
|
330
|
+
likes: 260
|
|
331
|
+
views: 16323
|
|
332
|
+
---
|
|
333
|
+
|
|
334
|
+
Post body in Markdown…
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
---
|
|
338
|
+
|
|
339
|
+
## Development
|
|
340
|
+
|
|
341
|
+
```bash
|
|
342
|
+
npm test # node:test, runs .ts directly — no jest, no ts-node
|
|
343
|
+
npm run typecheck # includes tests — they used to be excluded, which hid real errors
|
|
344
|
+
npm run lint # typescript-eslint, type-aware
|
|
345
|
+
npm run build # tsconfig.build.json (tests excluded from dist)
|
|
346
|
+
npm run schema:dump # dump Velog's current GraphQL schema
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
276 tests. `src/__tests__/safety.test.ts` pins the security invariants (A1–A11),
|
|
350
|
+
`render.test.ts` pins the drawing ones (R1–R23), and `plugin.test.ts` pins the
|
|
351
|
+
packaging ones (P1–P26) — if any fails, find out why instead of working around it.
|
|
352
|
+
|
|
353
|
+
Every guard here was checked by **breaking it on purpose**: 54 mutations against the
|
|
354
|
+
source, plus 12 against the publish gate itself (`scripts/gate-mutation.sh`), each of
|
|
355
|
+
which must make exactly one check fail. A test that still passes with the guard removed
|
|
356
|
+
is not a test. Several in this repo did pass at first, and that is how they got fixed.
|
|
357
|
+
|
|
358
|
+
## Documentation
|
|
359
|
+
|
|
360
|
+
| Document | Contents |
|
|
361
|
+
| --- | --- |
|
|
362
|
+
| [docs/PRD.md](docs/PRD.md) | Goals, non-goals, success criteria |
|
|
363
|
+
| [docs/architecture.md](docs/architecture.md) | Layering, and the TypeScript subset Node's type stripping allows |
|
|
364
|
+
| [docs/api-reference.md](docs/api-reference.md) | Measured Velog GraphQL schema and server quirks |
|
|
365
|
+
| [docs/security.md](docs/security.md) | Token handling, capability model, what's deliberately unimplemented |
|
|
366
|
+
| [docs/tools.md](docs/tools.md) | Full tool catalog with gotchas |
|
|
367
|
+
| [docs/decisions/](docs/decisions/) | Architecture decision records |
|
|
368
|
+
|
|
369
|
+
## Notes
|
|
370
|
+
|
|
371
|
+
This talks to Velog's internal GraphQL API, which is undocumented and can change
|
|
372
|
+
without warning. When something breaks, run `npm run schema:dump` and diff it against
|
|
373
|
+
`docs/api-reference.md` — that's the fastest way to find what moved.
|
|
374
|
+
|
|
375
|
+
Velog's [terms of service](https://velog.io/policy/terms) contain no clause restricting
|
|
376
|
+
automated access. Using your own token to manage your own posts stays within scope, and
|
|
377
|
+
your posts remain yours (Article 5).
|
|
378
|
+
|
|
379
|
+
## License
|
|
380
|
+
|
|
381
|
+
MIT
|
package/dist/auth.d.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 토큰 취급 — 환경변수로만 읽고, 디스크에 쓰지 않고, 로그에 싣지 않는다.
|
|
3
|
+
*
|
|
4
|
+
* 설계 근거: docs/decisions/0003-token-env-only.md
|
|
5
|
+
* 이 파일에는 fs 를 import 하지 않는다. 토큰이 파일로 나갈 경로 자체를 없앤다.
|
|
6
|
+
*
|
|
7
|
+
* ★ 벨로그 서버가 토큰을 알아서 갱신한다 (공식 소스 authPlugin.mts 확인):
|
|
8
|
+
* - access_token 수명이 30분 미만이면 refresh_token 으로 재발급
|
|
9
|
+
* - access_token 이 없거나 깨져도 refresh_token 이 있으면 복구
|
|
10
|
+
* - 새 토큰은 응답의 Set-Cookie 로 온다
|
|
11
|
+
* 그래서 (1) refresh_token 만 있어도 인증이 되고
|
|
12
|
+
* (2) Set-Cookie 를 받아 메모리에 반영하면 세션이 안 끊긴다.
|
|
13
|
+
* 여전히 디스크에는 쓰지 않는다 — 프로세스 수명만큼만 산다.
|
|
14
|
+
*/
|
|
15
|
+
export interface Credentials {
|
|
16
|
+
readonly accessToken: string | undefined;
|
|
17
|
+
readonly refreshToken: string | undefined;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* 인증 상태. 토큰이 없는 것은 에러가 아니라 상태다 —
|
|
21
|
+
* 공개 글 조회·검색·트렌딩은 인증 없이 동작한다.
|
|
22
|
+
*/
|
|
23
|
+
export type AuthState = {
|
|
24
|
+
readonly kind: 'anonymous';
|
|
25
|
+
} | {
|
|
26
|
+
readonly kind: 'authenticated';
|
|
27
|
+
readonly credentials: Credentials;
|
|
28
|
+
};
|
|
29
|
+
export declare function readAuthFromEnv(env?: NodeJS.ProcessEnv): AuthState;
|
|
30
|
+
/** 벨로그는 쿠키 헤더로 인증한다. 없는 토큰은 싣지 않는다. */
|
|
31
|
+
export declare function buildCookieHeader(credentials: Credentials): string;
|
|
32
|
+
/**
|
|
33
|
+
* 응답의 Set-Cookie 에서 갱신된 토큰을 뽑는다.
|
|
34
|
+
*
|
|
35
|
+
* 서버가 재발급하면 여기로 온다. 값이 없으면 undefined 를 돌려
|
|
36
|
+
* 호출부가 기존 토큰을 유지하게 한다.
|
|
37
|
+
*/
|
|
38
|
+
export declare function parseSetCookie(header: string | null): Partial<Credentials>;
|
|
39
|
+
/**
|
|
40
|
+
* 현재 유효한 토큰을 들고 있는 저장소. 서버가 갱신해주면 여기에 반영한다.
|
|
41
|
+
*
|
|
42
|
+
* 메모리에만 있다. 디스크·키체인·브라우저 어디에도 쓰지 않는다.
|
|
43
|
+
*/
|
|
44
|
+
export declare class TokenStore {
|
|
45
|
+
#private;
|
|
46
|
+
constructor(initial: AuthState);
|
|
47
|
+
get state(): AuthState;
|
|
48
|
+
get isAuthenticated(): boolean;
|
|
49
|
+
/** Set-Cookie 로 받은 새 토큰을 반영한다. 바뀐 게 있으면 true. */
|
|
50
|
+
update(incoming: Partial<Credentials>): boolean;
|
|
51
|
+
/** 지금까지 본 모든 토큰을 문자열에서 가린다. */
|
|
52
|
+
mask(text: string): string;
|
|
53
|
+
}
|
|
54
|
+
/** 쓰기 도구가 인증을 요구할 때 쓰는 에러. */
|
|
55
|
+
export declare class AuthRequiredError extends Error {
|
|
56
|
+
constructor(toolName: string);
|
|
57
|
+
}
|
package/dist/auth.js
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 토큰 취급 — 환경변수로만 읽고, 디스크에 쓰지 않고, 로그에 싣지 않는다.
|
|
3
|
+
*
|
|
4
|
+
* 설계 근거: docs/decisions/0003-token-env-only.md
|
|
5
|
+
* 이 파일에는 fs 를 import 하지 않는다. 토큰이 파일로 나갈 경로 자체를 없앤다.
|
|
6
|
+
*
|
|
7
|
+
* ★ 벨로그 서버가 토큰을 알아서 갱신한다 (공식 소스 authPlugin.mts 확인):
|
|
8
|
+
* - access_token 수명이 30분 미만이면 refresh_token 으로 재발급
|
|
9
|
+
* - access_token 이 없거나 깨져도 refresh_token 이 있으면 복구
|
|
10
|
+
* - 새 토큰은 응답의 Set-Cookie 로 온다
|
|
11
|
+
* 그래서 (1) refresh_token 만 있어도 인증이 되고
|
|
12
|
+
* (2) Set-Cookie 를 받아 메모리에 반영하면 세션이 안 끊긴다.
|
|
13
|
+
* 여전히 디스크에는 쓰지 않는다 — 프로세스 수명만큼만 산다.
|
|
14
|
+
*/
|
|
15
|
+
export function readAuthFromEnv(env = process.env) {
|
|
16
|
+
const accessToken = env['VELOG_ACCESS_TOKEN']?.trim() || undefined;
|
|
17
|
+
const refreshToken = env['VELOG_REFRESH_TOKEN']?.trim() || undefined;
|
|
18
|
+
// 둘 중 하나만 있어도 인증을 시도한다. 서버가 refresh_token 으로 복구해준다.
|
|
19
|
+
if (!accessToken && !refreshToken)
|
|
20
|
+
return { kind: 'anonymous' };
|
|
21
|
+
return { kind: 'authenticated', credentials: { accessToken, refreshToken } };
|
|
22
|
+
}
|
|
23
|
+
/** 벨로그는 쿠키 헤더로 인증한다. 없는 토큰은 싣지 않는다. */
|
|
24
|
+
export function buildCookieHeader(credentials) {
|
|
25
|
+
const parts = [];
|
|
26
|
+
if (credentials.accessToken)
|
|
27
|
+
parts.push(`access_token=${credentials.accessToken}`);
|
|
28
|
+
if (credentials.refreshToken)
|
|
29
|
+
parts.push(`refresh_token=${credentials.refreshToken}`);
|
|
30
|
+
return parts.join('; ');
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* 응답의 Set-Cookie 에서 갱신된 토큰을 뽑는다.
|
|
34
|
+
*
|
|
35
|
+
* 서버가 재발급하면 여기로 온다. 값이 없으면 undefined 를 돌려
|
|
36
|
+
* 호출부가 기존 토큰을 유지하게 한다.
|
|
37
|
+
*/
|
|
38
|
+
export function parseSetCookie(header) {
|
|
39
|
+
if (!header)
|
|
40
|
+
return {};
|
|
41
|
+
const out = {};
|
|
42
|
+
// fetch 는 여러 Set-Cookie 를 콤마로 합쳐 준다. 쿠키 값 자체에는 콤마가
|
|
43
|
+
// 오지 않으므로 이름=값 패턴만 훑는 편이 안전하다.
|
|
44
|
+
for (const match of header.matchAll(/(access_token|refresh_token)=([^;,\s]*)/g)) {
|
|
45
|
+
const [, name, value] = match;
|
|
46
|
+
if (!value)
|
|
47
|
+
continue; // 로그아웃 시 빈 값으로 지우는 경우는 무시한다
|
|
48
|
+
if (name === 'access_token')
|
|
49
|
+
out.accessToken = value;
|
|
50
|
+
else
|
|
51
|
+
out.refreshToken = value;
|
|
52
|
+
}
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* 현재 유효한 토큰을 들고 있는 저장소. 서버가 갱신해주면 여기에 반영한다.
|
|
57
|
+
*
|
|
58
|
+
* 메모리에만 있다. 디스크·키체인·브라우저 어디에도 쓰지 않는다.
|
|
59
|
+
*/
|
|
60
|
+
export class TokenStore {
|
|
61
|
+
#state;
|
|
62
|
+
/** 마스킹 대상. 갱신 전 토큰도 계속 가려야 해서 누적한다. */
|
|
63
|
+
#seen = new Set();
|
|
64
|
+
constructor(initial) {
|
|
65
|
+
this.#state = initial;
|
|
66
|
+
this.#remember(initial);
|
|
67
|
+
}
|
|
68
|
+
get state() {
|
|
69
|
+
return this.#state;
|
|
70
|
+
}
|
|
71
|
+
get isAuthenticated() {
|
|
72
|
+
return this.#state.kind === 'authenticated';
|
|
73
|
+
}
|
|
74
|
+
/** Set-Cookie 로 받은 새 토큰을 반영한다. 바뀐 게 있으면 true. */
|
|
75
|
+
update(incoming) {
|
|
76
|
+
if (this.#state.kind !== 'authenticated')
|
|
77
|
+
return false;
|
|
78
|
+
if (!incoming.accessToken && !incoming.refreshToken)
|
|
79
|
+
return false;
|
|
80
|
+
const current = this.#state.credentials;
|
|
81
|
+
const next = {
|
|
82
|
+
accessToken: incoming.accessToken ?? current.accessToken,
|
|
83
|
+
refreshToken: incoming.refreshToken ?? current.refreshToken,
|
|
84
|
+
};
|
|
85
|
+
if (next.accessToken === current.accessToken &&
|
|
86
|
+
next.refreshToken === current.refreshToken) {
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
this.#state = { kind: 'authenticated', credentials: next };
|
|
90
|
+
this.#remember(this.#state);
|
|
91
|
+
return true;
|
|
92
|
+
}
|
|
93
|
+
/** 지금까지 본 모든 토큰을 문자열에서 가린다. */
|
|
94
|
+
mask(text) {
|
|
95
|
+
let masked = text;
|
|
96
|
+
for (const secret of this.#seen) {
|
|
97
|
+
masked = masked.split(secret).join('***REDACTED***');
|
|
98
|
+
}
|
|
99
|
+
return masked.replace(/(access_token|refresh_token)=[^;\s"']+/g, '$1=***REDACTED***');
|
|
100
|
+
}
|
|
101
|
+
#remember(state) {
|
|
102
|
+
if (state.kind !== 'authenticated')
|
|
103
|
+
return;
|
|
104
|
+
for (const secret of [
|
|
105
|
+
state.credentials.accessToken,
|
|
106
|
+
state.credentials.refreshToken,
|
|
107
|
+
]) {
|
|
108
|
+
// 너무 짧은 값을 치환하면 본문이 훼손된다. 토큰은 항상 이보다 길다.
|
|
109
|
+
if (secret && secret.length >= 8)
|
|
110
|
+
this.#seen.add(secret);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
/** 쓰기 도구가 인증을 요구할 때 쓰는 에러. */
|
|
115
|
+
export class AuthRequiredError extends Error {
|
|
116
|
+
constructor(toolName) {
|
|
117
|
+
super(`${toolName} 은(는) 인증이 필요합니다. ` +
|
|
118
|
+
'VELOG_ACCESS_TOKEN 또는 VELOG_REFRESH_TOKEN 환경변수를 설정하세요. ' +
|
|
119
|
+
'(velog.io 로그인 → F12 → Application → Cookies) ' +
|
|
120
|
+
'refresh_token 만 넣어도 서버가 access_token 을 재발급합니다(유효기간 30일).');
|
|
121
|
+
this.name = 'AuthRequiredError';
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
//# sourceMappingURL=auth.js.map
|
package/dist/auth.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"auth.js","sourceRoot":"","sources":["../src/auth.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 기능 범위 — 사용자가 환경변수로 정한다.
|
|
3
|
+
*
|
|
4
|
+
* 설계 원칙: **모델은 이 값을 바꿀 수 없다.** MCP 설정 파일을 손대는 사람만
|
|
5
|
+
* 바꿀 수 있고, 이는 토큰을 넣는 것과 같은 신뢰 경계다. 도구 파라미터로
|
|
6
|
+
* 노출하면 모델이 스스로 권한을 올릴 수 있게 되므로 그렇게 하지 않는다.
|
|
7
|
+
*
|
|
8
|
+
* 왜 '비공개 발행'이 기본인가 — 벨로그 실측에 근거가 있다:
|
|
9
|
+
*
|
|
10
|
+
* // apps/server/src/services/PostApiService/index.mts
|
|
11
|
+
* count({ where: { fk_user_id, is_private: false, released_at: { gt: 5분전 } } })
|
|
12
|
+
*
|
|
13
|
+
* 계수 대상이 `is_private: false` 뿐이다. 즉 **비공개 글은 이 카운터를 올리지
|
|
14
|
+
* 않는다.**
|
|
15
|
+
*
|
|
16
|
+
* ⚠️ 다만 '올리지 않는다'와 '유발하지 않는다'는 다르다. 처음엔 이걸 혼동해
|
|
17
|
+
* "비공개면 위험이 원천 소멸"이라고 잘못 적었다. 실제 서버 코드는:
|
|
18
|
+
*
|
|
19
|
+
* const isPublish = !data.is_temp && !data.is_private
|
|
20
|
+
* const isLimit = await this.isPostLimitReached(signedUserId) // ← 무조건 실행
|
|
21
|
+
*
|
|
22
|
+
* 공개 여부를 보기 **전에** 검사를 돌린다. 그래서 이미 최근 5분에 공개 글이
|
|
23
|
+
* 10건 쌓여 있으면, 다음 요청이 비공개 초안 생성이어도 그 시점에 최근 글 전체가
|
|
24
|
+
* 비공개로 바뀐다. 그 10건은 사용자가 웹에서 직접 올린 것일 수도 있어 우리
|
|
25
|
+
* 카운터로는 못 본다.
|
|
26
|
+
*
|
|
27
|
+
* 정리하면 — 비공개로 두는 것은 **위험을 줄이지만 없애지는 못한다.**
|
|
28
|
+
* 그래서 쓰기 무재시도와 자체 상한을 함께 유지한다.
|
|
29
|
+
*
|
|
30
|
+
* 공개 발행만 다르다. RSS·검색·구독 메일로 나간 뒤에는 지워도 회수가 안 되고,
|
|
31
|
+
* 벨로그 계수에도 잡힌다. 그래서 이것만 명시적 opt-in 으로 둔다.
|
|
32
|
+
*/
|
|
33
|
+
export interface Capabilities {
|
|
34
|
+
/** 공개 발행 (`is_private: false`). VELOG_ALLOW_PUBLIC=1 */
|
|
35
|
+
readonly publicPublish: boolean;
|
|
36
|
+
/**
|
|
37
|
+
* 프로필 수정 (이름·한줄소개·소개글·블로그제목·SNS링크·프로필사진).
|
|
38
|
+
* VELOG_ALLOW_PROFILE=1
|
|
39
|
+
*
|
|
40
|
+
* 위험해서 막는 게 아니다 — 전부 되돌릴 수 있고 본인 계정에만 영향이며
|
|
41
|
+
* RSS·메일로 나가지도 않는다. 게이트를 둔 이유는 **혼동 위험**이다:
|
|
42
|
+
* 프로필의 `short_bio` 와 글의 `short_description` 은 이름이 비슷하다.
|
|
43
|
+
* "소개 좀 고쳐줘" 가 어느 쪽인지 모호할 때, 스위치가 꺼져 있으면
|
|
44
|
+
* 모델이 프로필을 건드릴 수 없으므로 잘못 짚어도 사고가 안 난다.
|
|
45
|
+
*/
|
|
46
|
+
readonly editProfile: boolean;
|
|
47
|
+
}
|
|
48
|
+
export declare function readCapabilities(env?: NodeJS.ProcessEnv): Capabilities;
|
|
49
|
+
/** 기동 로그용 한 줄 요약. 사용자가 지금 뭐가 열렸는지 즉시 알 수 있어야 한다. */
|
|
50
|
+
export declare function describeCapabilities(capabilities: Capabilities): string;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 기능 범위 — 사용자가 환경변수로 정한다.
|
|
3
|
+
*
|
|
4
|
+
* 설계 원칙: **모델은 이 값을 바꿀 수 없다.** MCP 설정 파일을 손대는 사람만
|
|
5
|
+
* 바꿀 수 있고, 이는 토큰을 넣는 것과 같은 신뢰 경계다. 도구 파라미터로
|
|
6
|
+
* 노출하면 모델이 스스로 권한을 올릴 수 있게 되므로 그렇게 하지 않는다.
|
|
7
|
+
*
|
|
8
|
+
* 왜 '비공개 발행'이 기본인가 — 벨로그 실측에 근거가 있다:
|
|
9
|
+
*
|
|
10
|
+
* // apps/server/src/services/PostApiService/index.mts
|
|
11
|
+
* count({ where: { fk_user_id, is_private: false, released_at: { gt: 5분전 } } })
|
|
12
|
+
*
|
|
13
|
+
* 계수 대상이 `is_private: false` 뿐이다. 즉 **비공개 글은 이 카운터를 올리지
|
|
14
|
+
* 않는다.**
|
|
15
|
+
*
|
|
16
|
+
* ⚠️ 다만 '올리지 않는다'와 '유발하지 않는다'는 다르다. 처음엔 이걸 혼동해
|
|
17
|
+
* "비공개면 위험이 원천 소멸"이라고 잘못 적었다. 실제 서버 코드는:
|
|
18
|
+
*
|
|
19
|
+
* const isPublish = !data.is_temp && !data.is_private
|
|
20
|
+
* const isLimit = await this.isPostLimitReached(signedUserId) // ← 무조건 실행
|
|
21
|
+
*
|
|
22
|
+
* 공개 여부를 보기 **전에** 검사를 돌린다. 그래서 이미 최근 5분에 공개 글이
|
|
23
|
+
* 10건 쌓여 있으면, 다음 요청이 비공개 초안 생성이어도 그 시점에 최근 글 전체가
|
|
24
|
+
* 비공개로 바뀐다. 그 10건은 사용자가 웹에서 직접 올린 것일 수도 있어 우리
|
|
25
|
+
* 카운터로는 못 본다.
|
|
26
|
+
*
|
|
27
|
+
* 정리하면 — 비공개로 두는 것은 **위험을 줄이지만 없애지는 못한다.**
|
|
28
|
+
* 그래서 쓰기 무재시도와 자체 상한을 함께 유지한다.
|
|
29
|
+
*
|
|
30
|
+
* 공개 발행만 다르다. RSS·검색·구독 메일로 나간 뒤에는 지워도 회수가 안 되고,
|
|
31
|
+
* 벨로그 계수에도 잡힌다. 그래서 이것만 명시적 opt-in 으로 둔다.
|
|
32
|
+
*/
|
|
33
|
+
/** '켠다'로 인정하는 값. 오타로 조용히 켜지지 않게 좁게 받는다. */
|
|
34
|
+
const TRUTHY = new Set(['1', 'true', 'yes', 'on']);
|
|
35
|
+
function flag(value) {
|
|
36
|
+
return TRUTHY.has((value ?? '').trim().toLowerCase());
|
|
37
|
+
}
|
|
38
|
+
export function readCapabilities(env = process.env) {
|
|
39
|
+
return {
|
|
40
|
+
publicPublish: flag(env['VELOG_ALLOW_PUBLIC']),
|
|
41
|
+
editProfile: flag(env['VELOG_ALLOW_PROFILE']),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
/** 기동 로그용 한 줄 요약. 사용자가 지금 뭐가 열렸는지 즉시 알 수 있어야 한다. */
|
|
45
|
+
export function describeCapabilities(capabilities) {
|
|
46
|
+
const parts = [
|
|
47
|
+
capabilities.publicPublish
|
|
48
|
+
? '읽기 + 초안 + 발행(공개/비공개 선택)'
|
|
49
|
+
: '읽기 + 초안 + 비공개 발행',
|
|
50
|
+
];
|
|
51
|
+
if (capabilities.editProfile)
|
|
52
|
+
parts.push('프로필 수정');
|
|
53
|
+
const off = [];
|
|
54
|
+
if (!capabilities.publicPublish)
|
|
55
|
+
off.push('공개 발행=VELOG_ALLOW_PUBLIC');
|
|
56
|
+
if (!capabilities.editProfile)
|
|
57
|
+
off.push('프로필 수정=VELOG_ALLOW_PROFILE');
|
|
58
|
+
return parts.join(' + ') + (off.length ? ` (꺼짐: ${off.join(', ')})` : '');
|
|
59
|
+
}
|
|
60
|
+
//# sourceMappingURL=capabilities.js.map
|