@yoonion/mimi-seed-mcp 0.19.13 → 0.19.17
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/README.md +2 -1
- package/assets/agent-guide.md +44 -7
- package/dist/appstore/tools.js +83 -29
- package/dist/auth/browser.d.ts +2 -0
- package/dist/auth/browser.js +4 -0
- package/dist/auth/cli.js +33 -8
- package/dist/auth/google-auth.d.ts +26 -5
- package/dist/auth/google-auth.js +133 -47
- package/dist/auth/scopes.d.ts +5 -0
- package/dist/auth/scopes.js +8 -0
- package/dist/auth/youtube-channel.d.ts +8 -0
- package/dist/auth/youtube-channel.js +26 -0
- package/dist/googleads/errors.d.ts +2 -0
- package/dist/googleads/errors.js +31 -0
- package/dist/googleads/tools.d.ts +7 -0
- package/dist/googleads/tools.js +89 -46
- package/dist/helpers.d.ts +1 -1
- package/dist/helpers.js +10 -10
- package/dist/instagram/browser-login.d.ts +13 -0
- package/dist/instagram/browser-login.js +125 -0
- package/dist/instagram/setup.d.ts +1 -1
- package/dist/instagram/setup.js +5 -2
- package/dist/lib/googleapis-lite.d.ts +2 -0
- package/dist/lib/googleapis-lite.js +3 -0
- package/dist/lib/store-create-result.d.ts +221 -0
- package/dist/lib/store-create-result.js +38 -0
- package/dist/playstore/tools.d.ts +2 -0
- package/dist/playstore/tools.js +23 -8
- package/dist/registers/appstore.js +3 -47
- package/dist/registers/auth.js +30 -6
- package/dist/registers/googleads.js +5 -2
- package/dist/registers/playstore.js +3 -16
- package/dist/registers/video.js +11 -5
- package/dist/registers/youtube.d.ts +2 -0
- package/dist/registers/youtube.js +89 -0
- package/dist/server.js +2 -0
- package/dist/social/setup-cli.js +66 -19
- package/dist/threads/browser-login.d.ts +13 -0
- package/dist/threads/browser-login.js +125 -0
- package/dist/threads/config.js +2 -4
- package/dist/threads/setup.d.ts +1 -1
- package/dist/threads/setup.js +7 -4
- package/dist/video/schemas.d.ts +12 -12
- package/dist/video/youtube-publish.d.ts +14 -0
- package/dist/video/youtube-publish.js +8 -0
- package/dist/youtube/comments.d.ts +156 -0
- package/dist/youtube/comments.js +158 -0
- package/dist/youtube/insights.d.ts +82 -0
- package/dist/youtube/insights.js +155 -0
- package/dist/youtube/tools.d.ts +174 -0
- package/dist/youtube/tools.js +292 -0
- package/package.json +5 -3
- package/scripts/googleads-report.mjs +32 -0
- package/tool-manifest.json +18 -1
package/dist/auth/google-auth.js
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import { google } from '../lib/googleapis-lite.js';
|
|
2
2
|
import http from 'node:http';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { verifyYouTubeChannel, YOUTUBE_CHANNEL_ID } from './youtube-channel.js';
|
|
3
5
|
import fs from 'node:fs';
|
|
4
6
|
import path from 'node:path';
|
|
5
7
|
import os from 'node:os';
|
|
6
8
|
import { getMcpOAuthClient } from './constants.js';
|
|
7
9
|
import { AuthError, classifyError } from './errors.js';
|
|
8
10
|
// 스코프 목록의 SSOT 는 scopes.ts (도메인 → 스코프 매핑). 여기서는 로그인 요청 조립만 한다.
|
|
9
|
-
import { scopesForDomains
|
|
11
|
+
import { scopesForDomains } from './scopes.js';
|
|
10
12
|
import { writeCredentialJson } from '../lib/atomic-write.js';
|
|
11
13
|
// Primary config dir. Legacy `~/.preseed` is read as a fallback during the
|
|
12
14
|
// rebrand so existing auth sessions don't force a re-login; new writes go to
|
|
@@ -16,7 +18,36 @@ const LEGACY_TOKEN_DIR = path.join(os.homedir(), '.preseed');
|
|
|
16
18
|
const TOKEN_PATH = path.join(TOKEN_DIR, 'tokens.json');
|
|
17
19
|
const LEGACY_TOKEN_PATH = path.join(LEGACY_TOKEN_DIR, 'tokens.json');
|
|
18
20
|
const CREDENTIALS_PATH = path.join(TOKEN_DIR, 'credentials.json');
|
|
19
|
-
export
|
|
21
|
+
export const GOOGLE_PROFILE_ID = /^[a-z0-9][a-z0-9_-]{0,63}$/;
|
|
22
|
+
export function googleProfilePath(profile) {
|
|
23
|
+
if (!GOOGLE_PROFILE_ID.test(profile))
|
|
24
|
+
throw new Error('Invalid Google profile ID: use lowercase letters, digits, _ or - (1–64 characters).');
|
|
25
|
+
return path.join(TOKEN_DIR, 'google-profiles', `${profile}.json`);
|
|
26
|
+
}
|
|
27
|
+
function readProfile(profile) {
|
|
28
|
+
const file = googleProfilePath(profile);
|
|
29
|
+
try {
|
|
30
|
+
return JSON.parse(fs.readFileSync(file, 'utf-8'));
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
/** Only safe metadata; never return tokens or OAuth client secrets to MCP callers. */
|
|
37
|
+
export function listGoogleProfiles() {
|
|
38
|
+
const dir = path.join(TOKEN_DIR, 'google-profiles');
|
|
39
|
+
if (!fs.existsSync(dir))
|
|
40
|
+
return [];
|
|
41
|
+
return fs.readdirSync(dir).filter((file) => file.endsWith('.json') && GOOGLE_PROFILE_ID.test(file.slice(0, -5)))
|
|
42
|
+
.sort().map((file) => {
|
|
43
|
+
const profile = file.slice(0, -5);
|
|
44
|
+
const tokens = readProfile(profile)?.tokens;
|
|
45
|
+
return { profile, connected: !!tokens?.refresh_token, youtubeChannel: tokens?.youtubeChannel ?? null };
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
export function getStoredCredentials(profile) {
|
|
49
|
+
if (profile !== undefined)
|
|
50
|
+
return readProfile(profile)?.credentials ?? null;
|
|
20
51
|
if (!fs.existsSync(CREDENTIALS_PATH))
|
|
21
52
|
return null;
|
|
22
53
|
try {
|
|
@@ -30,7 +61,9 @@ export function getStoredCredentials() {
|
|
|
30
61
|
export function saveCredentials(clientId, clientSecret) {
|
|
31
62
|
writeCredentialJson(CREDENTIALS_PATH, { clientId, clientSecret });
|
|
32
63
|
}
|
|
33
|
-
export function getStoredTokens() {
|
|
64
|
+
export function getStoredTokens(profile) {
|
|
65
|
+
if (profile !== undefined)
|
|
66
|
+
return readProfile(profile)?.tokens ?? null;
|
|
34
67
|
// Prefer new dir; fall back to legacy ~/.preseed during the rebrand window.
|
|
35
68
|
const pathToRead = fs.existsSync(TOKEN_PATH)
|
|
36
69
|
? TOKEN_PATH
|
|
@@ -52,7 +85,16 @@ export function getStoredTokens() {
|
|
|
52
85
|
* Google refresh_token 은 7일(미인증 앱) ~ 6개월(인증 앱) 미사용 시 revoke 됨.
|
|
53
86
|
* auth_status 응답 enrichment 에 사용.
|
|
54
87
|
*/
|
|
55
|
-
export function getTokensLastRefreshMs() {
|
|
88
|
+
export function getTokensLastRefreshMs(profile) {
|
|
89
|
+
if (profile !== undefined) {
|
|
90
|
+
const file = googleProfilePath(profile);
|
|
91
|
+
try {
|
|
92
|
+
return fs.statSync(file).mtimeMs;
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
56
98
|
const pathToRead = fs.existsSync(TOKEN_PATH)
|
|
57
99
|
? TOKEN_PATH
|
|
58
100
|
: fs.existsSync(LEGACY_TOKEN_PATH)
|
|
@@ -69,8 +111,19 @@ export function getTokensLastRefreshMs() {
|
|
|
69
111
|
}
|
|
70
112
|
// 원자적 교체가 특히 중요한 지점 — 이 함수는 access_token 만료 5분 전마다 다시 불리고,
|
|
71
113
|
// MCP 서버 인스턴스 여러 개와 CLI 가 같은 tokens.json 을 동시에 노린다.
|
|
72
|
-
function saveTokens(tokens) {
|
|
73
|
-
|
|
114
|
+
function saveTokens(tokens, profile, credentials) {
|
|
115
|
+
if (profile !== undefined) {
|
|
116
|
+
const creds = credentials ?? getStoredCredentials(profile);
|
|
117
|
+
if (!creds)
|
|
118
|
+
throw new Error(`Missing OAuth credentials for profile ${profile}. Sign in again.`);
|
|
119
|
+
// Client credentials and tokens belong to one grant and commit together.
|
|
120
|
+
writeCredentialJson(googleProfilePath(profile), { credentials: creds, tokens });
|
|
121
|
+
}
|
|
122
|
+
else {
|
|
123
|
+
if (credentials)
|
|
124
|
+
saveCredentials(credentials.clientId, credentials.clientSecret);
|
|
125
|
+
writeCredentialJson(TOKEN_PATH, tokens);
|
|
126
|
+
}
|
|
74
127
|
}
|
|
75
128
|
export function createOAuth2Client(clientId, clientSecret) {
|
|
76
129
|
return new google.auth.OAuth2(clientId, clientSecret, 'http://localhost:9876/callback');
|
|
@@ -79,32 +132,34 @@ export function createOAuth2Client(clientId, clientSecret) {
|
|
|
79
132
|
* Get authenticated OAuth2 client.
|
|
80
133
|
* Returns null if not authenticated yet.
|
|
81
134
|
*/
|
|
82
|
-
export function getAuthenticatedClient() {
|
|
83
|
-
const creds = getStoredCredentials();
|
|
135
|
+
export function getAuthenticatedClient(profile) {
|
|
136
|
+
const creds = getStoredCredentials(profile);
|
|
84
137
|
if (!creds)
|
|
85
138
|
return null;
|
|
86
|
-
const tokens = getStoredTokens();
|
|
139
|
+
const tokens = getStoredTokens(profile);
|
|
87
140
|
if (!tokens)
|
|
88
141
|
return null;
|
|
89
142
|
const client = createOAuth2Client(creds.clientId, creds.clientSecret);
|
|
90
143
|
client.setCredentials(tokens);
|
|
91
144
|
// Auto-refresh
|
|
92
145
|
client.on('tokens', (newTokens) => {
|
|
93
|
-
const stored = getStoredTokens();
|
|
94
|
-
if (stored) {
|
|
146
|
+
const stored = getStoredTokens(profile);
|
|
147
|
+
if (stored && stored.refresh_token === tokens.refresh_token) {
|
|
95
148
|
saveTokens({
|
|
96
149
|
...stored,
|
|
97
150
|
...(newTokens.access_token && { access_token: newTokens.access_token }),
|
|
98
151
|
...(newTokens.refresh_token && { refresh_token: newTokens.refresh_token }),
|
|
99
152
|
...(newTokens.expiry_date && { expiry_date: newTokens.expiry_date }),
|
|
100
153
|
...(newTokens.scope && { scope: newTokens.scope }),
|
|
101
|
-
});
|
|
154
|
+
}, profile);
|
|
102
155
|
}
|
|
103
156
|
});
|
|
104
157
|
return client;
|
|
105
158
|
}
|
|
106
159
|
// 동시 실행 방지용 — 활성 콜백 서버 참조
|
|
107
|
-
let
|
|
160
|
+
let cancelActiveAuth = null;
|
|
161
|
+
const authAttempts = new Map();
|
|
162
|
+
export function getGoogleAuthAttempt(profile) { return authAttempts.get(profile ?? '') ?? null; }
|
|
108
163
|
/**
|
|
109
164
|
* OAuth 플로우 시작.
|
|
110
165
|
* URL과 대기 Promise를 즉시 반환. localhost:9876 콜백 서버는 백그라운드로 실행.
|
|
@@ -117,17 +172,22 @@ let activeAuthServer = null;
|
|
|
117
172
|
* 얹는다(incremental authorization) — 토큰 응답의 scope 필드에도 누적 전체가 온다.
|
|
118
173
|
*/
|
|
119
174
|
export function startAuth(clientId, clientSecret, options = {}) {
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
activeAuthServer = null;
|
|
175
|
+
const { profile, expectedChannelId } = options;
|
|
176
|
+
if (profile !== undefined)
|
|
177
|
+
googleProfilePath(profile);
|
|
178
|
+
if (expectedChannelId !== undefined && (!profile || !YOUTUBE_CHANNEL_ID.test(expectedChannelId))) {
|
|
179
|
+
throw new Error('expectedChannelId requires a named profile and a valid YouTube channel ID.');
|
|
126
180
|
}
|
|
127
|
-
|
|
181
|
+
if (cancelActiveAuth)
|
|
182
|
+
cancelActiveAuth();
|
|
183
|
+
const attempt = { status: 'pending', expectedChannelId };
|
|
184
|
+
authAttempts.set(profile ?? '', attempt);
|
|
128
185
|
const oauth2Client = createOAuth2Client(clientId, clientSecret);
|
|
129
|
-
const
|
|
186
|
+
const needsYouTubeDomain = expectedChannelId && options.domains && !options.domains.some((id) => id === 'youtube' || id === 'youtube_analytics');
|
|
187
|
+
const requestedScopes = scopesForDomains(needsYouTubeDomain ? [...new Set([...options.domains, 'youtube'])] : options.domains);
|
|
188
|
+
const state = randomUUID();
|
|
130
189
|
const authUrl = oauth2Client.generateAuthUrl({
|
|
190
|
+
state,
|
|
131
191
|
access_type: 'offline',
|
|
132
192
|
scope: requestedScopes,
|
|
133
193
|
// Private windows can still share cookies with an already-running private session.
|
|
@@ -136,8 +196,12 @@ export function startAuth(clientId, clientSecret, options = {}) {
|
|
|
136
196
|
prompt: 'consent select_account',
|
|
137
197
|
include_granted_scopes: true,
|
|
138
198
|
});
|
|
199
|
+
let exchanging = false;
|
|
139
200
|
const wait = new Promise((resolve, reject) => {
|
|
140
|
-
const rejectAuth = (e) =>
|
|
201
|
+
const rejectAuth = (e) => {
|
|
202
|
+
attempt.status = 'failed';
|
|
203
|
+
reject(new AuthError(classifyError(e, { phase: 'login' })));
|
|
204
|
+
};
|
|
141
205
|
// 핸들러 전체가 try/catch 로 감싸여 있고 모든 경로가 응답 후 rejectAuth 로 끝난다 —
|
|
142
206
|
// createServer 가 void 를 기대하지만 여기서 새어 나갈 rejection 은 없다.
|
|
143
207
|
// eslint-disable-next-line @typescript-eslint/no-misused-promises
|
|
@@ -149,16 +213,20 @@ export function startAuth(clientId, clientSecret, options = {}) {
|
|
|
149
213
|
res.end();
|
|
150
214
|
return;
|
|
151
215
|
}
|
|
216
|
+
if (url.searchParams.get('state') !== state) {
|
|
217
|
+
res.writeHead(400);
|
|
218
|
+
res.end('Invalid OAuth state');
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
152
221
|
// Google이 동의 거부 시 ?error=access_denied 로 콜백
|
|
153
222
|
const errParam = url.searchParams.get('error');
|
|
154
223
|
if (errParam) {
|
|
155
|
-
res.writeHead(400, { 'Content-Type': 'text/
|
|
156
|
-
res.end(
|
|
224
|
+
res.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8' });
|
|
225
|
+
res.end('Google authentication was denied.');
|
|
157
226
|
try {
|
|
158
227
|
server.close();
|
|
159
228
|
}
|
|
160
229
|
catch { /* noop */ }
|
|
161
|
-
activeAuthServer = null;
|
|
162
230
|
rejectAuth(new Error(errParam));
|
|
163
231
|
return;
|
|
164
232
|
}
|
|
@@ -168,6 +236,12 @@ export function startAuth(clientId, clientSecret, options = {}) {
|
|
|
168
236
|
res.end('No code');
|
|
169
237
|
return;
|
|
170
238
|
}
|
|
239
|
+
if (attempt.status !== 'pending' || exchanging) {
|
|
240
|
+
res.writeHead(409);
|
|
241
|
+
res.end('Login is no longer accepting callbacks.');
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
exchanging = true;
|
|
171
245
|
let tokenResponse;
|
|
172
246
|
try {
|
|
173
247
|
tokenResponse = await oauth2Client.getToken(code);
|
|
@@ -179,7 +253,6 @@ export function startAuth(clientId, clientSecret, options = {}) {
|
|
|
179
253
|
server.close();
|
|
180
254
|
}
|
|
181
255
|
catch { /* noop */ }
|
|
182
|
-
activeAuthServer = null;
|
|
183
256
|
rejectAuth(e);
|
|
184
257
|
return;
|
|
185
258
|
}
|
|
@@ -191,7 +264,7 @@ export function startAuth(clientId, clientSecret, options = {}) {
|
|
|
191
264
|
server.close();
|
|
192
265
|
}
|
|
193
266
|
catch { /* noop */ }
|
|
194
|
-
|
|
267
|
+
attempt.status = 'failed';
|
|
195
268
|
reject(new AuthError({
|
|
196
269
|
code: 'TOKEN_RESPONSE_INVALID',
|
|
197
270
|
message: 'Google 응답에 access_token 또는 refresh_token이 누락되었습니다.',
|
|
@@ -202,22 +275,22 @@ export function startAuth(clientId, clientSecret, options = {}) {
|
|
|
202
275
|
}));
|
|
203
276
|
return;
|
|
204
277
|
}
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
//
|
|
209
|
-
//
|
|
210
|
-
// 선택형 로그인이 이를 깨서, 좁은 로그인이 legacy 로 오인돼 pre-flight 를 우회하는
|
|
211
|
-
// 것을 막는다. (기존엔 응답 scope 로 통째 덮어써서 이 두 위험에 모두 노출됐다.)
|
|
212
|
-
const priorScope = getStoredTokens()?.scope;
|
|
278
|
+
oauth2Client.setCredentials(tokens);
|
|
279
|
+
const youtubeChannel = (expectedChannelId || (profile && requestedScopes.some((scope) => scope.includes('/auth/youtube') || scope.includes('/auth/yt-analytics'))))
|
|
280
|
+
? await verifyYouTubeChannel(oauth2Client, expectedChannelId) : undefined;
|
|
281
|
+
// A fresh login may be another Google account. Never union its scopes with
|
|
282
|
+
// the previous account's grant; Google's response is authoritative.
|
|
213
283
|
const stored = {
|
|
214
284
|
access_token: tokens.access_token,
|
|
215
285
|
refresh_token: tokens.refresh_token,
|
|
216
286
|
token_type: tokens.token_type ?? 'Bearer',
|
|
217
287
|
expiry_date: tokens.expiry_date ?? Date.now() + 3600_000,
|
|
218
|
-
scope:
|
|
288
|
+
scope: tokens.scope ?? requestedScopes.join(' '),
|
|
289
|
+
...(youtubeChannel && { youtubeChannel }),
|
|
219
290
|
};
|
|
220
|
-
|
|
291
|
+
if (attempt.status !== 'pending')
|
|
292
|
+
throw new Error('Login cancelled or timed out; credentials were not saved.');
|
|
293
|
+
saveTokens(stored, profile, { clientId, clientSecret });
|
|
221
294
|
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
222
295
|
res.end(`
|
|
223
296
|
<html><body style="font-family:system-ui;display:flex;justify-content:center;align-items:center;height:100vh;margin:0">
|
|
@@ -228,28 +301,36 @@ export function startAuth(clientId, clientSecret, options = {}) {
|
|
|
228
301
|
</body></html>
|
|
229
302
|
`);
|
|
230
303
|
server.close();
|
|
231
|
-
|
|
304
|
+
attempt.status = 'succeeded';
|
|
232
305
|
resolve(stored);
|
|
233
306
|
}
|
|
234
307
|
catch (err) {
|
|
235
308
|
try {
|
|
236
309
|
res.writeHead(500);
|
|
237
|
-
res.end('Auth error');
|
|
310
|
+
res.end(err instanceof Error && err.message.startsWith('YouTube') ? err.message : 'Auth error. Check the MCP/CLI login status.');
|
|
238
311
|
}
|
|
239
312
|
catch { /* noop — already responded */ }
|
|
240
313
|
try {
|
|
241
314
|
server.close();
|
|
242
315
|
}
|
|
243
316
|
catch { /* noop */ }
|
|
244
|
-
activeAuthServer = null;
|
|
245
317
|
rejectAuth(err);
|
|
246
318
|
}
|
|
247
319
|
});
|
|
320
|
+
cancelActiveAuth = () => {
|
|
321
|
+
if (attempt.status !== 'pending')
|
|
322
|
+
return;
|
|
323
|
+
try {
|
|
324
|
+
server.close();
|
|
325
|
+
}
|
|
326
|
+
catch { /* noop */ }
|
|
327
|
+
rejectAuth(new Error('Login replaced by a new authentication request.'));
|
|
328
|
+
};
|
|
248
329
|
server.on('error', (err) => {
|
|
249
330
|
rejectAuth(err);
|
|
250
331
|
});
|
|
251
332
|
server.listen(9876, () => {
|
|
252
|
-
|
|
333
|
+
// Callback server is ready.
|
|
253
334
|
});
|
|
254
335
|
const timeoutMs = options.timeoutMs ?? 10 * 60 * 1000;
|
|
255
336
|
setTimeout(() => {
|
|
@@ -258,10 +339,9 @@ export function startAuth(clientId, clientSecret, options = {}) {
|
|
|
258
339
|
server.close();
|
|
259
340
|
}
|
|
260
341
|
catch { /* noop */ }
|
|
261
|
-
activeAuthServer = null;
|
|
262
342
|
rejectAuth(new Error(`Auth timeout (${Math.round(timeoutMs / 1000)}s).`));
|
|
263
343
|
}
|
|
264
|
-
}, timeoutMs);
|
|
344
|
+
}, timeoutMs).unref();
|
|
265
345
|
});
|
|
266
346
|
return { url: authUrl, wait };
|
|
267
347
|
}
|
|
@@ -278,8 +358,8 @@ export function startAuth(clientId, clientSecret, options = {}) {
|
|
|
278
358
|
* 통상 lifetime 이 1h 이므로, 5분 마진으로 매 도구 호출 시 만료 임박 시 사전 갱신해
|
|
279
359
|
* "토큰 만료 → 도구 fail → 재호출" 의 단절 마찰 제거. 5분 마진은 평균 도구 작업 시간을 흡수.
|
|
280
360
|
*/
|
|
281
|
-
export async function ensureFreshAccessToken(marginMs = 300_000) {
|
|
282
|
-
const tokens = getStoredTokens();
|
|
361
|
+
export async function ensureFreshAccessToken(marginMs = 300_000, profile) {
|
|
362
|
+
const tokens = getStoredTokens(profile);
|
|
283
363
|
if (!tokens) {
|
|
284
364
|
return {
|
|
285
365
|
status: 'unauthenticated',
|
|
@@ -316,12 +396,14 @@ export async function ensureFreshAccessToken(marginMs = 300_000) {
|
|
|
316
396
|
// 원격 의존을 최초 1회로 끝낸다. 조회 실패는 raw throw 가 아니라 분류된 에러로 반환.
|
|
317
397
|
let clientId;
|
|
318
398
|
let clientSecret;
|
|
319
|
-
const stored = getStoredCredentials();
|
|
399
|
+
const stored = getStoredCredentials(profile);
|
|
320
400
|
if (stored?.clientId && stored?.clientSecret) {
|
|
321
401
|
({ clientId, clientSecret } = stored);
|
|
322
402
|
}
|
|
323
403
|
else {
|
|
324
404
|
try {
|
|
405
|
+
if (profile !== undefined)
|
|
406
|
+
throw new Error(`Missing credentials for profile ${profile}. Sign in again.`);
|
|
325
407
|
({ clientId, clientSecret } = await getMcpOAuthClient());
|
|
326
408
|
saveCredentials(clientId, clientSecret);
|
|
327
409
|
}
|
|
@@ -338,6 +420,7 @@ export async function ensureFreshAccessToken(marginMs = 300_000) {
|
|
|
338
420
|
try {
|
|
339
421
|
const { credentials } = await client.refreshAccessToken();
|
|
340
422
|
const refreshed = {
|
|
423
|
+
...tokens,
|
|
341
424
|
access_token: credentials.access_token ?? tokens.access_token,
|
|
342
425
|
refresh_token: credentials.refresh_token ?? tokens.refresh_token,
|
|
343
426
|
token_type: credentials.token_type ?? tokens.token_type ?? 'Bearer',
|
|
@@ -345,7 +428,10 @@ export async function ensureFreshAccessToken(marginMs = 300_000) {
|
|
|
345
428
|
// refresh 응답은 scope 를 생략할 수 있으므로 기존 값을 보존(blank 방지).
|
|
346
429
|
scope: credentials.scope ?? tokens.scope,
|
|
347
430
|
};
|
|
348
|
-
|
|
431
|
+
if (getStoredTokens(profile)?.refresh_token !== tokens.refresh_token) {
|
|
432
|
+
throw new Error('Google login changed during refresh. Retry with the selected profile.');
|
|
433
|
+
}
|
|
434
|
+
saveTokens(refreshed, profile);
|
|
349
435
|
return {
|
|
350
436
|
status: 'refreshed',
|
|
351
437
|
tokens: refreshed,
|
package/dist/auth/scopes.d.ts
CHANGED
|
@@ -64,6 +64,11 @@ export declare const AUTH_DOMAINS: {
|
|
|
64
64
|
readonly scopes: readonly ["https://www.googleapis.com/auth/youtube.force-ssl"];
|
|
65
65
|
readonly summary: "youtube_* — 영상 업로드·처리 상태 조회·공개 상태 변경";
|
|
66
66
|
};
|
|
67
|
+
readonly youtube_analytics: {
|
|
68
|
+
readonly label: "YouTube Analytics (read-only)";
|
|
69
|
+
readonly scopes: readonly ["https://www.googleapis.com/auth/youtube.readonly", "https://www.googleapis.com/auth/yt-analytics.readonly"];
|
|
70
|
+
readonly summary: "youtube_get_channel·youtube_list_videos·youtube_get_analytics_report — 채널·영상·성과 읽기";
|
|
71
|
+
};
|
|
67
72
|
};
|
|
68
73
|
export type AuthDomainId = keyof typeof AUTH_DOMAINS;
|
|
69
74
|
/** z.enum 등 튜플이 필요한 자리에 쓰는 도메인 id 목록 (선언 순서 유지). */
|
package/dist/auth/scopes.js
CHANGED
|
@@ -72,6 +72,14 @@ export const AUTH_DOMAINS = {
|
|
|
72
72
|
scopes: ['https://www.googleapis.com/auth/youtube.force-ssl'],
|
|
73
73
|
summary: 'youtube_* — 영상 업로드·처리 상태 조회·공개 상태 변경',
|
|
74
74
|
},
|
|
75
|
+
youtube_analytics: {
|
|
76
|
+
label: 'YouTube Analytics (read-only)',
|
|
77
|
+
scopes: [
|
|
78
|
+
'https://www.googleapis.com/auth/youtube.readonly',
|
|
79
|
+
'https://www.googleapis.com/auth/yt-analytics.readonly',
|
|
80
|
+
],
|
|
81
|
+
summary: 'youtube_get_channel·youtube_list_videos·youtube_get_analytics_report — 채널·영상·성과 읽기',
|
|
82
|
+
},
|
|
75
83
|
};
|
|
76
84
|
/** z.enum 등 튜플이 필요한 자리에 쓰는 도메인 id 목록 (선언 순서 유지). */
|
|
77
85
|
export const DOMAIN_IDS = Object.keys(AUTH_DOMAINS);
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { OAuth2Client } from 'google-auth-library';
|
|
2
|
+
export declare const YOUTUBE_CHANNEL_ID: RegExp;
|
|
3
|
+
/** A channel ID is a guard, not a videos.insert routing parameter. Google selects
|
|
4
|
+
* the personal/Brand channel during OAuth; ambiguous or mismatched grants fail closed. */
|
|
5
|
+
export declare function verifyYouTubeChannel(auth: OAuth2Client, expectedChannelId?: string): Promise<{
|
|
6
|
+
id: string;
|
|
7
|
+
title: string;
|
|
8
|
+
}>;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { google } from '../lib/googleapis-lite.js';
|
|
2
|
+
import { friendlyGoogleError } from '../lib/google-errors.js';
|
|
3
|
+
export const YOUTUBE_CHANNEL_ID = /^UC[\w-]{22}$/;
|
|
4
|
+
/** A channel ID is a guard, not a videos.insert routing parameter. Google selects
|
|
5
|
+
* the personal/Brand channel during OAuth; ambiguous or mismatched grants fail closed. */
|
|
6
|
+
export async function verifyYouTubeChannel(auth, expectedChannelId) {
|
|
7
|
+
if (expectedChannelId !== undefined && !YOUTUBE_CHANNEL_ID.test(expectedChannelId)) {
|
|
8
|
+
throw new Error('Invalid YouTube channel ID (expected UC + 22 characters).');
|
|
9
|
+
}
|
|
10
|
+
let response;
|
|
11
|
+
try {
|
|
12
|
+
response = await google.youtube({ version: 'v3', auth }).channels.list({ part: ['snippet'], mine: true, maxResults: 50 });
|
|
13
|
+
}
|
|
14
|
+
catch (error) {
|
|
15
|
+
throw friendlyGoogleError(error);
|
|
16
|
+
}
|
|
17
|
+
const channels = response.data.items ?? [];
|
|
18
|
+
if (channels.length !== 1 || !channels[0].id || response.data.nextPageToken) {
|
|
19
|
+
throw new Error('YouTube login must resolve to exactly one channel. Sign in again and select the intended personal/Brand channel.');
|
|
20
|
+
}
|
|
21
|
+
const channel = { id: channels[0].id, title: channels[0].snippet?.title ?? '' };
|
|
22
|
+
if (expectedChannelId && channel.id !== expectedChannelId) {
|
|
23
|
+
throw new Error(`YouTube channel mismatch: expected ${expectedChannelId}, authenticated ${channel.id} (${channel.title}). Sign in again with the intended channel. Nothing was uploaded.`);
|
|
24
|
+
}
|
|
25
|
+
return channel;
|
|
26
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/** Preserve provider diagnostics without exposing authentication material. */
|
|
2
|
+
export function googleAdsError(status, body, secrets) {
|
|
3
|
+
let message = `Google Ads API ${status}`;
|
|
4
|
+
try {
|
|
5
|
+
const parsed = JSON.parse(body);
|
|
6
|
+
const error = (Array.isArray(parsed) ? parsed[0] : parsed)?.error;
|
|
7
|
+
if (typeof error?.message === 'string')
|
|
8
|
+
message += `: ${error.message}`;
|
|
9
|
+
for (const detail of Array.isArray(error?.details) ? error.details : []) {
|
|
10
|
+
for (const item of Array.isArray(detail.errors) ? detail.errors : []) {
|
|
11
|
+
const codes = item.errorCode && typeof item.errorCode === 'object'
|
|
12
|
+
? Object.entries(item.errorCode).map(([key, value]) => `${key}=${String(value)}`).join(', ')
|
|
13
|
+
: '';
|
|
14
|
+
message += `\n${codes}${typeof item.message === 'string' ? `: ${item.message}` : ''}`;
|
|
15
|
+
}
|
|
16
|
+
for (const item of Array.isArray(detail.fieldViolations) ? detail.fieldViolations : []) {
|
|
17
|
+
message += `\n${String(item.field ?? '')}: ${String(item.description ?? '')}`;
|
|
18
|
+
}
|
|
19
|
+
if (typeof detail.requestId === 'string')
|
|
20
|
+
message += `\nrequestId=${detail.requestId}`;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
// HTML/proxy bodies can contain arbitrary data; do not echo them.
|
|
25
|
+
message += ': non-JSON response';
|
|
26
|
+
}
|
|
27
|
+
for (const secret of secrets.filter(Boolean).sort((a, b) => b.length - a.length)) {
|
|
28
|
+
message = message.split(secret).join('[REDACTED]');
|
|
29
|
+
}
|
|
30
|
+
return new Error(message.slice(0, 4000));
|
|
31
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { OAuth2Client } from 'google-auth-library';
|
|
2
2
|
import type { GoogleAdsConfig } from './config.js';
|
|
3
|
+
export declare const REPORT_METRIC_NOTE = "cost is in account currency, not micros. conversions are Google Ads conversions, not necessarily installs. Legacy installs/cpi aliases do not establish install CPI or cohort D7 ROAS.";
|
|
3
4
|
export interface DateRange {
|
|
4
5
|
startDate: string;
|
|
5
6
|
endDate: string;
|
|
@@ -16,6 +17,8 @@ export declare function listCampaigns(auth: OAuth2Client, cfg: GoogleAdsConfig):
|
|
|
16
17
|
dailyBudget: number;
|
|
17
18
|
}[]>;
|
|
18
19
|
export declare function getCampaignReport(auth: OAuth2Client, cfg: GoogleAdsConfig, range: DateRange): Promise<{
|
|
20
|
+
currencyCode: any;
|
|
21
|
+
timeZone: any;
|
|
19
22
|
id: any;
|
|
20
23
|
name: any;
|
|
21
24
|
status: any;
|
|
@@ -26,6 +29,7 @@ export declare function getCampaignReport(auth: OAuth2Client, cfg: GoogleAdsConf
|
|
|
26
29
|
cost: number;
|
|
27
30
|
conversions: number;
|
|
28
31
|
conversionsValue: number;
|
|
32
|
+
costPerConversion: number | null;
|
|
29
33
|
cpi: number;
|
|
30
34
|
ctr: number;
|
|
31
35
|
avgCpc: number;
|
|
@@ -38,6 +42,9 @@ export declare function getUacReport(auth: OAuth2Client, cfg: GoogleAdsConfig, r
|
|
|
38
42
|
clicks: number;
|
|
39
43
|
impressions: number;
|
|
40
44
|
cost: number;
|
|
45
|
+
conversions: number;
|
|
46
|
+
conversionsValue: number;
|
|
47
|
+
costPerConversion: number | null;
|
|
41
48
|
installs: number;
|
|
42
49
|
installsValue: number;
|
|
43
50
|
cpi: number;
|