@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.
Files changed (54) hide show
  1. package/README.md +2 -1
  2. package/assets/agent-guide.md +44 -7
  3. package/dist/appstore/tools.js +83 -29
  4. package/dist/auth/browser.d.ts +2 -0
  5. package/dist/auth/browser.js +4 -0
  6. package/dist/auth/cli.js +33 -8
  7. package/dist/auth/google-auth.d.ts +26 -5
  8. package/dist/auth/google-auth.js +133 -47
  9. package/dist/auth/scopes.d.ts +5 -0
  10. package/dist/auth/scopes.js +8 -0
  11. package/dist/auth/youtube-channel.d.ts +8 -0
  12. package/dist/auth/youtube-channel.js +26 -0
  13. package/dist/googleads/errors.d.ts +2 -0
  14. package/dist/googleads/errors.js +31 -0
  15. package/dist/googleads/tools.d.ts +7 -0
  16. package/dist/googleads/tools.js +89 -46
  17. package/dist/helpers.d.ts +1 -1
  18. package/dist/helpers.js +10 -10
  19. package/dist/instagram/browser-login.d.ts +13 -0
  20. package/dist/instagram/browser-login.js +125 -0
  21. package/dist/instagram/setup.d.ts +1 -1
  22. package/dist/instagram/setup.js +5 -2
  23. package/dist/lib/googleapis-lite.d.ts +2 -0
  24. package/dist/lib/googleapis-lite.js +3 -0
  25. package/dist/lib/store-create-result.d.ts +221 -0
  26. package/dist/lib/store-create-result.js +38 -0
  27. package/dist/playstore/tools.d.ts +2 -0
  28. package/dist/playstore/tools.js +23 -8
  29. package/dist/registers/appstore.js +3 -47
  30. package/dist/registers/auth.js +30 -6
  31. package/dist/registers/googleads.js +5 -2
  32. package/dist/registers/playstore.js +3 -16
  33. package/dist/registers/video.js +11 -5
  34. package/dist/registers/youtube.d.ts +2 -0
  35. package/dist/registers/youtube.js +89 -0
  36. package/dist/server.js +2 -0
  37. package/dist/social/setup-cli.js +66 -19
  38. package/dist/threads/browser-login.d.ts +13 -0
  39. package/dist/threads/browser-login.js +125 -0
  40. package/dist/threads/config.js +2 -4
  41. package/dist/threads/setup.d.ts +1 -1
  42. package/dist/threads/setup.js +7 -4
  43. package/dist/video/schemas.d.ts +12 -12
  44. package/dist/video/youtube-publish.d.ts +14 -0
  45. package/dist/video/youtube-publish.js +8 -0
  46. package/dist/youtube/comments.d.ts +156 -0
  47. package/dist/youtube/comments.js +158 -0
  48. package/dist/youtube/insights.d.ts +82 -0
  49. package/dist/youtube/insights.js +155 -0
  50. package/dist/youtube/tools.d.ts +174 -0
  51. package/dist/youtube/tools.js +292 -0
  52. package/package.json +5 -3
  53. package/scripts/googleads-report.mjs +32 -0
  54. package/tool-manifest.json +18 -1
@@ -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, mergeScopeStrings } from './scopes.js';
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 function getStoredCredentials() {
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
- writeCredentialJson(TOKEN_PATH, tokens);
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 activeAuthServer = null;
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
- if (activeAuthServer) {
121
- try {
122
- activeAuthServer.close();
123
- }
124
- catch { /* noop */ }
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
- saveCredentials(clientId, clientSecret);
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 requestedScopes = scopesForDomains(options.domains);
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) => reject(new AuthError(classifyError(e, { phase: 'login' })));
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/html; charset=utf-8' });
156
- res.end(`<html><body><h2>❌ 인증 거부됨 (${errParam})</h2></body></html>`);
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
- activeAuthServer = null;
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
- // scope 는 항상 기록한다 — 그리고 누적(monotonic)으로 저장한다.
206
- // 1) include_granted_scopes 로 Google 측 grant 는 (기존 부여분 ∪ 이번 요청분)이다.
207
- // 2) 응답의 tokens.scope 는 그 합집합이어야 하지만, 만약 Google 이 좁혀서 주거나
208
- // 생략하면(빈 값) 여기서 기존 기록 + 이번 요청 스코프로 보정한다.
209
- // 이렇게 해야 "scope 미기록 = 추적 이전 legacy 토큰" 불변식이 유지된다 — 도메인
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: mergeScopeStrings(priorScope, tokens.scope ?? requestedScopes.join(' ')),
288
+ scope: tokens.scope ?? requestedScopes.join(' '),
289
+ ...(youtubeChannel && { youtubeChannel }),
219
290
  };
220
- saveTokens(stored);
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
- activeAuthServer = null;
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
- activeAuthServer = server;
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
- saveTokens(refreshed);
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,
@@ -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 목록 (선언 순서 유지). */
@@ -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,2 @@
1
+ /** Preserve provider diagnostics without exposing authentication material. */
2
+ export declare function googleAdsError(status: number, body: string, secrets: string[]): Error;
@@ -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;