@yoonion/mimi-seed-mcp 0.19.12 → 0.19.14

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 CHANGED
@@ -1,5 +1,25 @@
1
1
  # @yoonion/mimi-seed-mcp
2
2
 
3
+ Connected app-release operations for Claude Code, Codex and other MCP clients.
4
+
5
+ Start with a lightweight, account-free check in your app repository (Node 20+):
6
+
7
+ ```bash
8
+ npx -y mimi-seed@latest check --local
9
+ ```
10
+
11
+ Review identifiers, Target API and detectable Play Billing evidence first. Then connect stores for
12
+ metadata, screenshot and uploaded-build checks. Local results do not guarantee store approval.
13
+
14
+ [Install the plugin for Claude Code or Codex](https://github.com/jeonghwanko/mimi-seed-sdk#30-second-setup)
15
+ · [English tool catalog](https://github.com/jeonghwanko/mimi-seed-sdk/blob/main/docs/domain/tool-catalog.md)
16
+ · [Release guides](https://mimi-seed.pryzm.gg/guides/expo-release-readiness)
17
+
18
+ Use `npx mimi-seed setup` to connect only the accounts you need, then open a fresh client session.
19
+ Licensed under PolyForm Noncommercial; commercial use requires a separate license.
20
+
21
+ ## 한국어 가이드
22
+
3
23
  **Mimi Seed** — Firebase · AdMob · Google Play · App Store Connect를 AI 콘솔에서 관리. Claude Code / Codex / Cursor / 기타 MCP 클라이언트에서 한 줄 등록으로 사용.
4
24
 
5
25
  > 이 패키지는 Mimi Seed의 **로컬 MCP 서버**만 포함합니다. 웹 콘솔(Next.js 앱)은 <https://mimi-seed.pryzm.gg/tool>.
@@ -84,7 +84,7 @@ you can paste. Pick the row for the job; batching two rows in one `select:` call
84
84
  | Android signing / keystore | `select:android_signing_setup,android_generate_keystore,jenkins_upload_keystore,jenkins_upload_playstore_sa` |
85
85
  | Service account end-to-end | `select:iam_list_service_accounts,iam_create_service_account,iam_list_keys,iam_create_key,iam_add_iam_policy_binding,setup_playstore_connection,playstore_register_service_account,playstore_verify_service_account,playstore_list_service_accounts,playstore_delete_service_account` |
86
86
  | Story → researched video | `select:video_save_plan,video_plan_from_story,video_research_youtube,video_search_stock_assets,video_synthesize_research,video_download_stock_assets,video_generate_image,video_add_local_asset,video_build_timeline,video_render,video_job_status,video_validate` |
87
- | YouTube upload / publish | `select:youtube_upload_video,youtube_get_video_status,youtube_update_video_privacy,mimi_seed_auth_start` |
87
+ | YouTube upload / publish | `select:youtube_upload_video,youtube_get_video_status,youtube_update_video_privacy,mimi_seed_auth_start,mimi_seed_auth_status` |
88
88
 
89
89
  ---
90
90
 
@@ -301,3 +301,11 @@ domain, with the credential each domain needs — read it to answer "what can Mi
301
301
  ---
302
302
 
303
303
  _Keep this guide in sync with `README.md`'s tool list and the `skills/` directory._
304
+
305
+ ### YouTube account selection
306
+
307
+ For multiple accounts/channels, use `mimi_seed_auth_start` with a named `profile`, `domains:["youtube"]`,
308
+ and `expectedChannelId` from the user's target channel. Have the user select that Google/personal/Brand
309
+ channel during consent, then check `mimi_seed_auth_status` with the same profile. Pass both `profile` and
310
+ required `expectedChannelId` to `youtube_upload_video`; use that profile for status/privacy calls too.
311
+ A missing profile never falls back to the default account. A channel mismatch stops before media transfer.
package/dist/auth/cli.js CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import readline from 'node:readline';
3
- import { startAuth, getStoredTokens, ensureFreshAccessToken, } from './google-auth.js';
3
+ import { startAuth, googleProfilePath, listGoogleProfiles, getStoredTokens, ensureFreshAccessToken, } from './google-auth.js';
4
4
  import { AuthError, classifyError } from './errors.js';
5
5
  import { getMcpOAuthClient } from './constants.js';
6
6
  import { AUTH_DOMAINS, DOMAIN_IDS, parseDomainList, summarizeGrantedDomains, } from './scopes.js';
@@ -23,6 +23,8 @@ const ko = {
23
23
  옵션:
24
24
  --domains <ids> 요청할 권한 도메인 (쉼표 구분, 미지정 시 전체).
25
25
  가능한 값: ${DOMAIN_IDS.join(', ')}
26
+ --profile <id> Google login profile (isolated account/channel)
27
+ --channel <id> Verify YouTube channel before saving (requires --profile)
26
28
  --no-browser URL 자동 오픈 안 함 (직접 복붙)
27
29
  --timeout <초> 콜백 대기 시간 (기본 600)
28
30
  --force 기존 토큰 무시하고 강제 재로그인
@@ -87,6 +89,8 @@ const en = {
87
89
  Options:
88
90
  --domains <ids> Permission domains to request (comma-separated; all when omitted).
89
91
  Available: ${DOMAIN_IDS.join(', ')}
92
+ --profile <id> Google login profile (isolated account/channel)
93
+ --channel <id> Verify YouTube channel before saving (requires --profile)
90
94
  --no-browser Do not open the URL automatically (copy-paste it yourself)
91
95
  --timeout <sec> How long to wait for the callback (default 600)
92
96
  --force Ignore the existing token and force a re-login
@@ -139,6 +143,8 @@ const en = {
139
143
  };
140
144
  const M = resolveLang() === 'en' ? en : ko;
141
145
  const args = process.argv.slice(2);
146
+ let profile;
147
+ let expectedChannelId;
142
148
  const hasFlag = (name) => args.includes(`--${name}`);
143
149
  const flagValue = (name) => {
144
150
  const i = args.indexOf(`--${name}`);
@@ -174,7 +180,10 @@ function printAuthError(p) {
174
180
  }
175
181
  /** 도메인 선택형 로그인 이후 토큰은 전체 권한이 아닐 수 있다 — 부여 현황을 함께 출력. */
176
182
  function printGrantedDomains() {
177
- const summary = summarizeGrantedDomains(getStoredTokens()?.scope);
183
+ const channel = getStoredTokens(profile)?.youtubeChannel;
184
+ if (channel)
185
+ err(`YouTube: ${channel.title} (${channel.id})`);
186
+ const summary = summarizeGrantedDomains(getStoredTokens(profile)?.scope);
178
187
  if (!summary.known) {
179
188
  err(M.domainsUnknown);
180
189
  return;
@@ -186,8 +195,9 @@ function printGrantedDomains() {
186
195
  async function cmdStatus() {
187
196
  err('');
188
197
  err(M.statusTitle);
198
+ err(`Google profiles: ${JSON.stringify(listGoogleProfiles())}`);
189
199
  err('');
190
- const r = await ensureFreshAccessToken();
200
+ const r = await ensureFreshAccessToken(undefined, profile);
191
201
  switch (r.status) {
192
202
  case 'fresh':
193
203
  err(M.statusFresh(fmtRemaining(r.msUntilExpiry)));
@@ -215,7 +225,7 @@ async function cmdRefresh() {
215
225
  err('');
216
226
  err(M.refreshTrying);
217
227
  err('');
218
- const r = await ensureFreshAccessToken(0); // 무조건 갱신 시도
228
+ const r = await ensureFreshAccessToken(0, profile); // 무조건 갱신 시도
219
229
  switch (r.status) {
220
230
  case 'fresh':
221
231
  err(M.refreshNotNeeded(fmtRemaining(r.msUntilExpiry)));
@@ -241,7 +251,7 @@ async function cmdLogout() {
241
251
  const fs = await import('node:fs');
242
252
  const path = await import('node:path');
243
253
  const os = await import('node:os');
244
- const tokenPath = path.join(os.homedir(), '.mimi-seed', 'tokens.json');
254
+ const tokenPath = profile !== undefined ? googleProfilePath(profile) : path.join(os.homedir(), '.mimi-seed', 'tokens.json');
245
255
  err('');
246
256
  if (fs.existsSync(tokenPath)) {
247
257
  fs.rmSync(tokenPath, { force: true });
@@ -279,11 +289,11 @@ async function cmdLogin() {
279
289
  err('');
280
290
  // 1) 기존 토큰이 있으면 silent refresh 먼저 시도.
281
291
  // --domains 는 "권한을 추가로 부여하겠다"는 명시적 의도라 이 단축 경로를 건너뛴다.
282
- if (!force && !domains) {
283
- const existing = getStoredTokens();
292
+ if (!force && !domains && !expectedChannelId) {
293
+ const existing = getStoredTokens(profile);
284
294
  if (existing) {
285
295
  err(M.loginChecking);
286
- const r = await ensureFreshAccessToken();
296
+ const r = await ensureFreshAccessToken(undefined, profile);
287
297
  if (r.status === 'fresh' || r.status === 'refreshed') {
288
298
  const label = r.status === 'fresh' ? M.loginValid : M.loginRefreshed;
289
299
  err(M.loginAlready(label, fmtRemaining(r.msUntilExpiry)));
@@ -311,6 +321,8 @@ async function cmdLogin() {
311
321
  try {
312
322
  const { clientId, clientSecret } = await getMcpOAuthClient();
313
323
  const r = startAuth(clientId, clientSecret, {
324
+ profile,
325
+ expectedChannelId,
314
326
  timeoutMs: timeoutSec * 1000,
315
327
  domains,
316
328
  });
@@ -384,6 +396,19 @@ async function main() {
384
396
  printHelp();
385
397
  process.exit(0);
386
398
  }
399
+ const readOption = (name) => {
400
+ const eq = args.find((arg) => arg.startsWith(`--${name}=`));
401
+ const value = eq ? eq.slice(name.length + 3) : flagValue(name);
402
+ if ((hasFlag(name) || eq !== undefined) && !value)
403
+ throw new Error(`--${name} requires a value.`);
404
+ return value;
405
+ };
406
+ profile = readOption('profile');
407
+ expectedChannelId = readOption('channel');
408
+ if (profile !== undefined)
409
+ googleProfilePath(profile);
410
+ if (expectedChannelId && !profile)
411
+ throw new Error('--channel requires --profile.');
387
412
  let code;
388
413
  if (hasFlag('status'))
389
414
  code = await cmdStatus();
@@ -1,7 +1,22 @@
1
1
  import { type AuthErrorPayload } from './errors.js';
2
2
  import { type AuthDomainId } from './scopes.js';
3
3
  export type { AuthDomainId } from './scopes.js';
4
+ export declare const GOOGLE_PROFILE_ID: RegExp;
5
+ export declare function googleProfilePath(profile: string): string;
6
+ /** Only safe metadata; never return tokens or OAuth client secrets to MCP callers. */
7
+ export declare function listGoogleProfiles(): {
8
+ profile: string;
9
+ connected: boolean;
10
+ youtubeChannel: {
11
+ id: string;
12
+ title: string;
13
+ } | null;
14
+ }[];
4
15
  export interface StoredTokens {
16
+ youtubeChannel?: {
17
+ id: string;
18
+ title: string;
19
+ };
5
20
  access_token: string;
6
21
  refresh_token: string;
7
22
  token_type: string;
@@ -9,25 +24,29 @@ export interface StoredTokens {
9
24
  /** 공백 구분 부여 스코프. 신규 도구(GA4 등) pre-flight 스코프 검사에 사용. 구 토큰은 undefined. */
10
25
  scope?: string;
11
26
  }
12
- export declare function getStoredCredentials(): {
27
+ export declare function getStoredCredentials(profile?: string): {
13
28
  clientId: string;
14
29
  clientSecret: string;
15
30
  } | null;
16
31
  export declare function saveCredentials(clientId: string, clientSecret: string): void;
17
- export declare function getStoredTokens(): StoredTokens | null;
32
+ export declare function getStoredTokens(profile?: string): StoredTokens | null;
18
33
  /**
19
34
  * tokens.json mtime — 마지막 refresh 시각의 근사값.
20
35
  * (saveTokens 가 매번 writeFileSync 으로 갱신하므로 mtime ≈ 마지막 갱신/저장.)
21
36
  * Google refresh_token 은 7일(미인증 앱) ~ 6개월(인증 앱) 미사용 시 revoke 됨.
22
37
  * auth_status 응답 enrichment 에 사용.
23
38
  */
24
- export declare function getTokensLastRefreshMs(): number | null;
39
+ export declare function getTokensLastRefreshMs(profile?: string): number | null;
25
40
  export declare function createOAuth2Client(clientId: string, clientSecret: string): import("google-auth-library").OAuth2Client;
26
41
  /**
27
42
  * Get authenticated OAuth2 client.
28
43
  * Returns null if not authenticated yet.
29
44
  */
30
- export declare function getAuthenticatedClient(): ReturnType<typeof createOAuth2Client> | null;
45
+ export declare function getAuthenticatedClient(profile?: string): ReturnType<typeof createOAuth2Client> | null;
46
+ export declare function getGoogleAuthAttempt(profile?: string): {
47
+ status: "pending" | "succeeded" | "failed";
48
+ expectedChannelId?: string;
49
+ } | null;
31
50
  /**
32
51
  * OAuth 플로우 시작.
33
52
  * URL과 대기 Promise를 즉시 반환. localhost:9876 콜백 서버는 백그라운드로 실행.
@@ -42,6 +61,8 @@ export declare function getAuthenticatedClient(): ReturnType<typeof createOAuth2
42
61
  export declare function startAuth(clientId: string, clientSecret: string, options?: {
43
62
  timeoutMs?: number;
44
63
  domains?: readonly AuthDomainId[];
64
+ profile?: string;
65
+ expectedChannelId?: string;
45
66
  }): {
46
67
  url: string;
47
68
  wait: Promise<StoredTokens>;
@@ -75,4 +96,4 @@ export type RefreshStatus = {
75
96
  * 통상 lifetime 이 1h 이므로, 5분 마진으로 매 도구 호출 시 만료 임박 시 사전 갱신해
76
97
  * "토큰 만료 → 도구 fail → 재호출" 의 단절 마찰 제거. 5분 마진은 평균 도구 작업 시간을 흡수.
77
98
  */
78
- export declare function ensureFreshAccessToken(marginMs?: number): Promise<RefreshStatus>;
99
+ export declare function ensureFreshAccessToken(marginMs?: number, profile?: string): Promise<RefreshStatus>;
@@ -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,21 @@ 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 requestedScopes = scopesForDomains(expectedChannelId && options.domains ? [...new Set([...options.domains, 'youtube'])] : options.domains);
187
+ const state = randomUUID();
130
188
  const authUrl = oauth2Client.generateAuthUrl({
189
+ state,
131
190
  access_type: 'offline',
132
191
  scope: requestedScopes,
133
192
  // Private windows can still share cookies with an already-running private session.
@@ -136,8 +195,12 @@ export function startAuth(clientId, clientSecret, options = {}) {
136
195
  prompt: 'consent select_account',
137
196
  include_granted_scopes: true,
138
197
  });
198
+ let exchanging = false;
139
199
  const wait = new Promise((resolve, reject) => {
140
- const rejectAuth = (e) => reject(new AuthError(classifyError(e, { phase: 'login' })));
200
+ const rejectAuth = (e) => {
201
+ attempt.status = 'failed';
202
+ reject(new AuthError(classifyError(e, { phase: 'login' })));
203
+ };
141
204
  // 핸들러 전체가 try/catch 로 감싸여 있고 모든 경로가 응답 후 rejectAuth 로 끝난다 —
142
205
  // createServer 가 void 를 기대하지만 여기서 새어 나갈 rejection 은 없다.
143
206
  // eslint-disable-next-line @typescript-eslint/no-misused-promises
@@ -149,16 +212,20 @@ export function startAuth(clientId, clientSecret, options = {}) {
149
212
  res.end();
150
213
  return;
151
214
  }
215
+ if (url.searchParams.get('state') !== state) {
216
+ res.writeHead(400);
217
+ res.end('Invalid OAuth state');
218
+ return;
219
+ }
152
220
  // Google이 동의 거부 시 ?error=access_denied 로 콜백
153
221
  const errParam = url.searchParams.get('error');
154
222
  if (errParam) {
155
- res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
156
- res.end(`<html><body><h2>❌ 인증 거부됨 (${errParam})</h2></body></html>`);
223
+ res.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8' });
224
+ res.end('Google authentication was denied.');
157
225
  try {
158
226
  server.close();
159
227
  }
160
228
  catch { /* noop */ }
161
- activeAuthServer = null;
162
229
  rejectAuth(new Error(errParam));
163
230
  return;
164
231
  }
@@ -168,6 +235,12 @@ export function startAuth(clientId, clientSecret, options = {}) {
168
235
  res.end('No code');
169
236
  return;
170
237
  }
238
+ if (attempt.status !== 'pending' || exchanging) {
239
+ res.writeHead(409);
240
+ res.end('Login is no longer accepting callbacks.');
241
+ return;
242
+ }
243
+ exchanging = true;
171
244
  let tokenResponse;
172
245
  try {
173
246
  tokenResponse = await oauth2Client.getToken(code);
@@ -179,7 +252,6 @@ export function startAuth(clientId, clientSecret, options = {}) {
179
252
  server.close();
180
253
  }
181
254
  catch { /* noop */ }
182
- activeAuthServer = null;
183
255
  rejectAuth(e);
184
256
  return;
185
257
  }
@@ -191,7 +263,7 @@ export function startAuth(clientId, clientSecret, options = {}) {
191
263
  server.close();
192
264
  }
193
265
  catch { /* noop */ }
194
- activeAuthServer = null;
266
+ attempt.status = 'failed';
195
267
  reject(new AuthError({
196
268
  code: 'TOKEN_RESPONSE_INVALID',
197
269
  message: 'Google 응답에 access_token 또는 refresh_token이 누락되었습니다.',
@@ -202,22 +274,22 @@ export function startAuth(clientId, clientSecret, options = {}) {
202
274
  }));
203
275
  return;
204
276
  }
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;
277
+ oauth2Client.setCredentials(tokens);
278
+ const youtubeChannel = (expectedChannelId || (profile && requestedScopes.some((scope) => scope.includes('/auth/youtube'))))
279
+ ? await verifyYouTubeChannel(oauth2Client, expectedChannelId) : undefined;
280
+ // A fresh login may be another Google account. Never union its scopes with
281
+ // the previous account's grant; Google's response is authoritative.
213
282
  const stored = {
214
283
  access_token: tokens.access_token,
215
284
  refresh_token: tokens.refresh_token,
216
285
  token_type: tokens.token_type ?? 'Bearer',
217
286
  expiry_date: tokens.expiry_date ?? Date.now() + 3600_000,
218
- scope: mergeScopeStrings(priorScope, tokens.scope ?? requestedScopes.join(' ')),
287
+ scope: tokens.scope ?? requestedScopes.join(' '),
288
+ ...(youtubeChannel && { youtubeChannel }),
219
289
  };
220
- saveTokens(stored);
290
+ if (attempt.status !== 'pending')
291
+ throw new Error('Login cancelled or timed out; credentials were not saved.');
292
+ saveTokens(stored, profile, { clientId, clientSecret });
221
293
  res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
222
294
  res.end(`
223
295
  <html><body style="font-family:system-ui;display:flex;justify-content:center;align-items:center;height:100vh;margin:0">
@@ -228,28 +300,36 @@ export function startAuth(clientId, clientSecret, options = {}) {
228
300
  </body></html>
229
301
  `);
230
302
  server.close();
231
- activeAuthServer = null;
303
+ attempt.status = 'succeeded';
232
304
  resolve(stored);
233
305
  }
234
306
  catch (err) {
235
307
  try {
236
308
  res.writeHead(500);
237
- res.end('Auth error');
309
+ res.end(err instanceof Error && err.message.startsWith('YouTube') ? err.message : 'Auth error. Check the MCP/CLI login status.');
238
310
  }
239
311
  catch { /* noop — already responded */ }
240
312
  try {
241
313
  server.close();
242
314
  }
243
315
  catch { /* noop */ }
244
- activeAuthServer = null;
245
316
  rejectAuth(err);
246
317
  }
247
318
  });
319
+ cancelActiveAuth = () => {
320
+ if (attempt.status !== 'pending')
321
+ return;
322
+ try {
323
+ server.close();
324
+ }
325
+ catch { /* noop */ }
326
+ rejectAuth(new Error('Login replaced by a new authentication request.'));
327
+ };
248
328
  server.on('error', (err) => {
249
329
  rejectAuth(err);
250
330
  });
251
331
  server.listen(9876, () => {
252
- activeAuthServer = server;
332
+ // Callback server is ready.
253
333
  });
254
334
  const timeoutMs = options.timeoutMs ?? 10 * 60 * 1000;
255
335
  setTimeout(() => {
@@ -258,10 +338,9 @@ export function startAuth(clientId, clientSecret, options = {}) {
258
338
  server.close();
259
339
  }
260
340
  catch { /* noop */ }
261
- activeAuthServer = null;
262
341
  rejectAuth(new Error(`Auth timeout (${Math.round(timeoutMs / 1000)}s).`));
263
342
  }
264
- }, timeoutMs);
343
+ }, timeoutMs).unref();
265
344
  });
266
345
  return { url: authUrl, wait };
267
346
  }
@@ -278,8 +357,8 @@ export function startAuth(clientId, clientSecret, options = {}) {
278
357
  * 통상 lifetime 이 1h 이므로, 5분 마진으로 매 도구 호출 시 만료 임박 시 사전 갱신해
279
358
  * "토큰 만료 → 도구 fail → 재호출" 의 단절 마찰 제거. 5분 마진은 평균 도구 작업 시간을 흡수.
280
359
  */
281
- export async function ensureFreshAccessToken(marginMs = 300_000) {
282
- const tokens = getStoredTokens();
360
+ export async function ensureFreshAccessToken(marginMs = 300_000, profile) {
361
+ const tokens = getStoredTokens(profile);
283
362
  if (!tokens) {
284
363
  return {
285
364
  status: 'unauthenticated',
@@ -316,12 +395,14 @@ export async function ensureFreshAccessToken(marginMs = 300_000) {
316
395
  // 원격 의존을 최초 1회로 끝낸다. 조회 실패는 raw throw 가 아니라 분류된 에러로 반환.
317
396
  let clientId;
318
397
  let clientSecret;
319
- const stored = getStoredCredentials();
398
+ const stored = getStoredCredentials(profile);
320
399
  if (stored?.clientId && stored?.clientSecret) {
321
400
  ({ clientId, clientSecret } = stored);
322
401
  }
323
402
  else {
324
403
  try {
404
+ if (profile !== undefined)
405
+ throw new Error(`Missing credentials for profile ${profile}. Sign in again.`);
325
406
  ({ clientId, clientSecret } = await getMcpOAuthClient());
326
407
  saveCredentials(clientId, clientSecret);
327
408
  }
@@ -338,6 +419,7 @@ export async function ensureFreshAccessToken(marginMs = 300_000) {
338
419
  try {
339
420
  const { credentials } = await client.refreshAccessToken();
340
421
  const refreshed = {
422
+ ...tokens,
341
423
  access_token: credentials.access_token ?? tokens.access_token,
342
424
  refresh_token: credentials.refresh_token ?? tokens.refresh_token,
343
425
  token_type: credentials.token_type ?? tokens.token_type ?? 'Bearer',
@@ -345,7 +427,10 @@ export async function ensureFreshAccessToken(marginMs = 300_000) {
345
427
  // refresh 응답은 scope 를 생략할 수 있으므로 기존 값을 보존(blank 방지).
346
428
  scope: credentials.scope ?? tokens.scope,
347
429
  };
348
- saveTokens(refreshed);
430
+ if (getStoredTokens(profile)?.refresh_token !== tokens.refresh_token) {
431
+ throw new Error('Google login changed during refresh. Retry with the selected profile.');
432
+ }
433
+ saveTokens(refreshed, profile);
349
434
  return {
350
435
  status: 'refreshed',
351
436
  tokens: refreshed,
@@ -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;