@yoonion/mimi-seed-mcp 0.11.1 → 0.12.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/README.md CHANGED
@@ -74,7 +74,7 @@ export ANTHROPIC_API_KEY=sk-ant-...
74
74
 
75
75
  ---
76
76
 
77
- ## 제공 도구 (150+ 개 · 18개 영역)
77
+ ## 제공 도구 (150+ 개 · 19개 영역)
78
78
 
79
79
  | 영역 | 도구 수 | 주요 도구 |
80
80
  |------|---------|-----------|
@@ -96,6 +96,7 @@ export ANTHROPIC_API_KEY=sk-ant-...
96
96
  | Android 서명 | 3 | `android_signing_setup` / `android_generate_keystore` / `jenkins_upload_playstore_sa` |
97
97
  | 인증 | 4 | `mimi_seed_status` / `mimi_seed_auth_start` / `mimi_seed_auth_status` / `mimi_seed_remote_sync_credentials` |
98
98
  | AI (Claude) | 2 | `generate_release_notes_from_commits` / `generate_review_reply` |
99
+ | 영상 제작 | 11 | `video_plan_from_story` / `video_research_youtube` / `video_synthesize_research` / `video_generate_image` / `video_render` |
99
100
 
100
101
  > 인앱 결제(IAP·구독) 도구는 위 Play Store·App Store 카운트에 포함됩니다 — `appstore_create_inapp_purchase` · `appstore_update_product_review_note` · `appstore_upload_product_review_screenshot` 등.
101
102
  > 전체 카탈로그(항상 최신): [`docs/domain/tool-catalog.md`](../../docs/domain/tool-catalog.md)
@@ -225,6 +226,10 @@ Claude가 연쇄 호출:
225
226
  | 변수 | 설명 |
226
227
  |------|------|
227
228
  | `ANTHROPIC_API_KEY` | AI 릴리즈 노트 생성 / 리뷰 답변 활성화 (선택) |
229
+ | `YOUTUBE_API_KEY` | 참고 영상 메타데이터 조사 (선택) |
230
+ | `PEXELS_API_KEY` | Pexels 스톡 영상 검색 (선택) |
231
+ | `OPENAI_API_KEY` | 장면 이미지 생성 (선택) |
232
+ | `MIMI_SEED_FFMPEG_PATH` / `MIMI_SEED_FFPROBE_PATH` | FFmpeg/ffprobe가 PATH에 없을 때 실행 파일 경로 (선택) |
228
233
 
229
234
  ---
230
235
 
@@ -16,7 +16,7 @@ and which actions are irreversible.
16
16
 
17
17
  ## 0. The one thing that trips every agent: deferred tools
18
18
 
19
- Mimi Seed exposes **150+ MCP tools** across 18 domains (full inventory:
19
+ Mimi Seed exposes **150+ MCP tools** across 19 domains (full inventory:
20
20
  [`docs/domain/tool-catalog.md`](domain/tool-catalog.md)). Harnesses that lazy-load large tool catalogs —
21
21
  **Claude Code most notably** — register these tools as **deferred**: the tool *names*
22
22
  are visible (in a system reminder), but the **input schemas are not loaded**. If you
@@ -56,6 +56,7 @@ tools directly — but the *call order* and *safety rules* below still apply.
56
56
  | Jenkins credentials + jobs | `select:jenkins_status,jenkins_save_config,jenkins_list_credentials,jenkins_create_credential,jenkins_upload_keystore,jenkins_upload_playstore_sa,jenkins_list_jobs,jenkins_get_job_config,jenkins_create_job,jenkins_update_job` |
57
57
  | CI (GitHub/GitLab) | `select:ci_save_config,ci_list_workflows,ci_trigger_build,ci_get_build_status,ci_list_recent_builds` |
58
58
  | Service account end-to-end | `select:iam_create_service_account,iam_create_key,iam_add_iam_policy_binding,playstore_register_service_account,playstore_verify_service_account` |
59
+ | Story → researched video | `select: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` |
59
60
 
60
61
  ---
61
62
 
@@ -79,13 +80,13 @@ every credential, which also tells them where to obtain each token
79
80
 
80
81
  | Service | Fix |
81
82
  |---------|-----|
82
- | Google (Firebase/AdMob/Play/Ads/GSC/GA4/IAM/BigQuery) | tool `mimi_seed_auth_start` → give the user the OAuth URL, **or** `mimi-seed auth login` |
83
+ | Google (Firebase/AdMob/Play/Ads/GSC/GA4/IAM/BigQuery) | tool `mimi_seed_auth_start` → give the user the OAuth URL, **or** `mimi-seed auth login`. Both accept a **domain subset** (`domains=["ga4","googleads"]` / `--domains ga4,googleads`) — request only what the task needs; re-auth keeps prior grants (incremental). Omit for all domains |
83
84
  | App Store Connect | `mimi-seed auth appstore` → verify with `appstore_verify_credentials` |
84
85
  | Play service account | `mimi-seed auth playstore`, or register per-package with `playstore_register_service_account`. **Optional** — the OAuth token carries `androidpublisher`, so this is only needed for headless/CI |
85
86
  | BigQuery | `mimi-seed auth bigquery` (optional — OAuth works too) |
86
87
  | Jenkins | `mimi-seed auth jenkins` (probes the server before saving) |
87
88
  | GitHub / GitLab CI | `mimi-seed auth ci` |
88
- | Google Ads | `mimi-seed auth googleads` — needs the `adwords` OAuth scope; an old token may need `mimi-seed auth login --force` |
89
+ | Google Ads | `mimi-seed auth googleads` — needs the `adwords` OAuth scope; an old token may need `mimi-seed auth login --domains googleads` (adds the grant, keeps the rest) |
89
90
  | Facebook / Instagram / Threads | `mimi-seed auth facebook` / `mimi-seed auth instagram` / `mimi-seed auth threads` |
90
91
 
91
92
  `mimi-seed auth meta` opens the combined social setup entry point when the user wants to review or reconnect
@@ -126,6 +127,10 @@ Notes that matter in practice:
126
127
  `playstore_*` call returns `403`.
127
128
  - AI features (`generate_release_notes_from_commits`, `generate_review_reply`) need
128
129
  `ANTHROPIC_API_KEY` in the environment.
130
+ - Video production uses capability-specific environment keys: `ANTHROPIC_API_KEY` (storyboard),
131
+ `YOUTUBE_API_KEY` (reference research), `PEXELS_API_KEY` (licensed stock search), and
132
+ `OPENAI_API_KEY` (generated images). Rendering needs FFmpeg on `PATH` or
133
+ `MIMI_SEED_FFMPEG_PATH`.
129
134
 
130
135
  ---
131
136
 
@@ -148,6 +153,7 @@ per-domain inventory is [`docs/domain/tool-catalog.md`](domain/tool-catalog.md).
148
153
  | **Facebook / Instagram / Threads** | `facebook_post_photo` · `instagram_post_carousel` · `threads_post` · `threads_refresh_token` |
149
154
  | **Checks** | `playstore_check_submission_risks` · `appstore_check_submission_risks` · `screenshot_validate` · `release_status` |
150
155
  | **AI / Auth** | `generate_release_notes_from_commits` · `generate_review_reply` · `mimi_seed_status` · `mimi_seed_auth_start` · `mimi_seed_auth_status` · `mimi_seed_remote_sync_credentials` |
156
+ | **Video production** | `video_plan_from_story` · `video_research_youtube` · `video_search_stock_assets` · `video_synthesize_research` · `video_generate_image` · `video_build_timeline` · `video_render` · `video_job_status` · `video_validate` |
151
157
 
152
158
  ---
153
159
 
@@ -169,6 +175,18 @@ per-domain inventory is [`docs/domain/tool-catalog.md`](domain/tool-catalog.md).
169
175
  `generate_release_notes_from_commits` (pass commit array + locales) → review with user →
170
176
  `playstore_update_release_notes` / `appstore_update_whats_new`.
171
177
 
178
+ ### Story → researched video
179
+ 1. `video_plan_from_story` — create the project and scene plan.
180
+ 2. `video_research_youtube` — collect reference-only metadata; never treat a result as a render asset.
181
+ 3. `video_search_stock_assets` — find licensed Pexels candidates.
182
+ 4. `video_synthesize_research` — combine metadata with any direct human/agent observations. Treat its output as
183
+ metadata-bounded guidance, not proof that the source videos were watched.
184
+ 5. Preview then confirm `video_download_stock_assets`; use `video_generate_image(confirm=false)` before any paid
185
+ generation and call it again with `confirm=true` only after approval. User-owned media goes through
186
+ `video_add_local_asset` with its ownership/license basis.
187
+ 6. `video_build_timeline` — provenance-gated scene assignment.
188
+ 7. Preview then confirm `video_render`; poll `video_job_status`, then run `video_validate` on the completed MP4.
189
+
172
190
  > **Mimi Seed does not compile app binaries.** It manages metadata, store releases, and
173
191
  > CI/Jenkins *credentials and job definitions* — not Xcode/Gradle builds. To produce an
174
192
  > `.ipa`/`.aab`, use EAS, Xcode, or a CI/Jenkins job. There is **no `jenkins_trigger_build`
package/dist/auth/cli.js CHANGED
@@ -4,6 +4,7 @@ import open from 'open';
4
4
  import { startAuth, getStoredTokens, ensureFreshAccessToken, } from './google-auth.js';
5
5
  import { AuthError, classifyError } from './errors.js';
6
6
  import { getMcpOAuthClient } from './constants.js';
7
+ import { AUTH_DOMAINS, DOMAIN_IDS, parseDomainList, summarizeGrantedDomains, } from './scopes.js';
7
8
  import { resolveLang } from '../lib/lang.js';
8
9
  // ko 가 원본이고 en 은 `typeof ko` 를 만족해야 한다 — 키를 빠뜨리면 컴파일이 깨진다.
9
10
  // 여기 있는 건 전부 **터미널에 찍히는 사람용 문자열**이다. errors.ts 가 만드는
@@ -14,11 +15,14 @@ const ko = {
14
15
 
15
16
  사용법:
16
17
  mimi-seed-auth # 로그인 (이미 있으면 자동 refresh 시도)
18
+ mimi-seed-auth --domains ga4,googleads # 필요한 권한 도메인만 요청 (기존 권한 유지)
17
19
  mimi-seed-auth --refresh # refresh_token으로 갱신만 시도 (브라우저 X)
18
- mimi-seed-auth --status # 현재 토큰 상태 출력
20
+ mimi-seed-auth --status # 현재 토큰 상태 + 부여 도메인 출력
19
21
  mimi-seed-auth --logout # 토큰 삭제
20
22
 
21
23
  옵션:
24
+ --domains <ids> 요청할 권한 도메인 (쉼표 구분, 미지정 시 전체).
25
+ 가능한 값: ${DOMAIN_IDS.join(', ')}
22
26
  --no-browser URL 자동 오픈 안 함 (직접 복붙)
23
27
  --timeout <초> 콜백 대기 시간 (기본 600)
24
28
  --force 기존 토큰 무시하고 강제 재로그인
@@ -34,6 +38,11 @@ const ko = {
34
38
  statusRefreshed: (left) => ` ✅ 연결됨 — refresh_token으로 갱신 (${left})`,
35
39
  statusExpired: ' ⚠️ 토큰 만료 + 자동 갱신 실패',
36
40
  statusNone: ' ❌ 연결된 계정 없음.',
41
+ grantedDomains: (list) => ` 권한 도메인: ${list}`,
42
+ missingDomains: (list) => ` 미부여: ${list} — mimi-seed-auth --domains <id> 로 추가 (기존 권한 유지)`,
43
+ domainsUnknown: ' 권한 도메인: (구 토큰 — scope 기록 없음. 재로그인하면 기록됨)',
44
+ invalidDomains: (bad, valid) => ` ❌ 알 수 없는 도메인: ${bad}\n 가능한 값: ${valid}`,
45
+ domainsRequested: (list) => ` 🎯 요청 도메인: ${list} (기존 부여 권한은 유지)`,
37
46
  refreshTrying: ' 🔄 refresh_token으로 갱신 시도 중...',
38
47
  refreshNotNeeded: (left) => ` ✅ 토큰 유효 — 갱신 불필요 (${left})`,
39
48
  refreshDone: (left) => ` ✅ 갱신 완료 (${left})`,
@@ -70,11 +79,14 @@ const en = {
70
79
 
71
80
  Usage:
72
81
  mimi-seed-auth # log in (tries a silent refresh if a token exists)
82
+ mimi-seed-auth --domains ga4,googleads # request only the domains you need (keeps prior grants)
73
83
  mimi-seed-auth --refresh # only refresh with refresh_token (no browser)
74
- mimi-seed-auth --status # print the current token status
84
+ mimi-seed-auth --status # print the current token status + granted domains
75
85
  mimi-seed-auth --logout # delete the token
76
86
 
77
87
  Options:
88
+ --domains <ids> Permission domains to request (comma-separated; all when omitted).
89
+ Available: ${DOMAIN_IDS.join(', ')}
78
90
  --no-browser Do not open the URL automatically (copy-paste it yourself)
79
91
  --timeout <sec> How long to wait for the callback (default 600)
80
92
  --force Ignore the existing token and force a re-login
@@ -90,6 +102,11 @@ const en = {
90
102
  statusRefreshed: (left) => ` ✅ Connected — refreshed with refresh_token (${left})`,
91
103
  statusExpired: ' ⚠️ Token expired + automatic refresh failed',
92
104
  statusNone: ' ❌ No connected account.',
105
+ grantedDomains: (list) => ` Granted domains: ${list}`,
106
+ missingDomains: (list) => ` Not granted: ${list} — add with mimi-seed-auth --domains <id> (prior grants are kept)`,
107
+ domainsUnknown: ' Granted domains: (legacy token — no scope record; re-login records it)',
108
+ invalidDomains: (bad, valid) => ` ❌ Unknown domain(s): ${bad}\n Available: ${valid}`,
109
+ domainsRequested: (list) => ` 🎯 Requesting domains: ${list} (prior grants are kept)`,
93
110
  refreshTrying: ' 🔄 Trying to refresh with refresh_token...',
94
111
  refreshNotNeeded: (left) => ` ✅ Token still valid — no refresh needed (${left})`,
95
112
  refreshDone: (left) => ` ✅ Refreshed (${left})`,
@@ -155,6 +172,17 @@ function printAuthError(p) {
155
172
  if (p.cause && process.env.DEBUG)
156
173
  err(` (cause: ${p.cause})`);
157
174
  }
175
+ /** 도메인 선택형 로그인 이후 토큰은 전체 권한이 아닐 수 있다 — 부여 현황을 함께 출력. */
176
+ function printGrantedDomains() {
177
+ const summary = summarizeGrantedDomains(getStoredTokens()?.scope);
178
+ if (!summary.known) {
179
+ err(M.domainsUnknown);
180
+ return;
181
+ }
182
+ err(M.grantedDomains(summary.granted.join(', ') || '-'));
183
+ if (summary.missing.length > 0)
184
+ err(M.missingDomains(summary.missing.join(', ')));
185
+ }
158
186
  async function cmdStatus() {
159
187
  err('');
160
188
  err(M.statusTitle);
@@ -163,10 +191,12 @@ async function cmdStatus() {
163
191
  switch (r.status) {
164
192
  case 'fresh':
165
193
  err(M.statusFresh(fmtRemaining(r.msUntilExpiry)));
194
+ printGrantedDomains();
166
195
  err('');
167
196
  return 0;
168
197
  case 'refreshed':
169
198
  err(M.statusRefreshed(fmtRemaining(r.msUntilExpiry)));
199
+ printGrantedDomains();
170
200
  err('');
171
201
  return 0;
172
202
  case 'expired_refresh_failed':
@@ -227,11 +257,29 @@ async function cmdLogin() {
227
257
  const noBrowser = hasFlag('no-browser');
228
258
  const force = hasFlag('force');
229
259
  const timeoutSec = parseInt(flagValue('timeout') ?? '600', 10);
260
+ // --domains 파싱. 공백형(--domains ga4)과 equals형(--domains=ga4) 모두 지원하고,
261
+ // 플래그는 있는데 값이 없거나(--domains, --domains --force) 잘못된 id 면 조용히 전체
262
+ // 스코프로 폴백하지 않고 에러로 안내한다 — least-privilege 의도가 훼손되지 않도록.
263
+ const domainsEq = args.find((a) => a.startsWith('--domains='));
264
+ const domainsRaw = domainsEq ? domainsEq.slice('--domains='.length) : flagValue('domains');
265
+ const domainsFlagPresent = hasFlag('domains') || domainsEq !== undefined;
266
+ let domains;
267
+ if (domainsFlagPresent) {
268
+ const parsed = domainsRaw ? parseDomainList(domainsRaw) : { domains: [], invalid: [] };
269
+ if (parsed.invalid.length > 0 || parsed.domains.length === 0) {
270
+ err('');
271
+ err(M.invalidDomains(parsed.invalid.join(', ') || domainsRaw || '(값 없음)', DOMAIN_IDS.join(', ')));
272
+ err('');
273
+ return 1;
274
+ }
275
+ domains = parsed.domains;
276
+ }
230
277
  err('');
231
278
  err(M.loginTitle);
232
279
  err('');
233
- // 1) 기존 토큰이 있으면 silent refresh 먼저 시도
234
- if (!force) {
280
+ // 1) 기존 토큰이 있으면 silent refresh 먼저 시도.
281
+ // --domains "권한을 추가로 부여하겠다"는 명시적 의도라 이 단축 경로를 건너뛴다.
282
+ if (!force && !domains) {
235
283
  const existing = getStoredTokens();
236
284
  if (existing) {
237
285
  err(M.loginChecking);
@@ -264,6 +312,7 @@ async function cmdLogin() {
264
312
  const { clientId, clientSecret } = await getMcpOAuthClient();
265
313
  const r = startAuth(clientId, clientSecret, {
266
314
  timeoutMs: timeoutSec * 1000,
315
+ domains,
267
316
  });
268
317
  url = r.url;
269
318
  wait = r.wait;
@@ -274,6 +323,9 @@ async function cmdLogin() {
274
323
  err('');
275
324
  return 1;
276
325
  }
326
+ if (domains) {
327
+ err(M.domainsRequested(domains.map((d) => `${d} (${AUTH_DOMAINS[d].label})`).join(', ')));
328
+ }
277
329
  // 3) 브라우저 열기 (or URL 출력)
278
330
  if (noBrowser) {
279
331
  err('');
@@ -318,6 +370,7 @@ async function cmdLogin() {
318
370
  err('');
319
371
  err('');
320
372
  err(M.done);
373
+ printGrantedDomains();
321
374
  err('');
322
375
  err(M.nextTitle);
323
376
  err(M.nextExample1);
@@ -1,4 +1,6 @@
1
1
  import { type AuthErrorPayload } from './errors.js';
2
+ import { type AuthDomainId } from './scopes.js';
3
+ export type { AuthDomainId } from './scopes.js';
2
4
  export interface StoredTokens {
3
5
  access_token: string;
4
6
  refresh_token: string;
@@ -32,9 +34,14 @@ export declare function getAuthenticatedClient(): ReturnType<typeof createOAuth2
32
34
  * 호출자가 URL을 사용자에게 전달하거나 `open()`을 직접 호출.
33
35
  * `wait` Promise: 토큰 저장 시 resolve, 타임아웃/에러 시 reject.
34
36
  * 재호출 시 기존 세션 자동 정리.
37
+ *
38
+ * `domains` 로 권한 도메인 서브셋만 요청할 수 있다 (미지정 시 전체 — 기존 동작).
39
+ * include_granted_scopes 덕에 재로그인은 기존 부여 스코프를 유지한 채 새 스코프만
40
+ * 얹는다(incremental authorization) — 토큰 응답의 scope 필드에도 누적 전체가 온다.
35
41
  */
36
42
  export declare function startAuth(clientId: string, clientSecret: string, options?: {
37
43
  timeoutMs?: number;
44
+ domains?: readonly AuthDomainId[];
38
45
  }): {
39
46
  url: string;
40
47
  wait: Promise<StoredTokens>;
@@ -43,7 +50,9 @@ export declare function startAuth(clientId: string, clientSecret: string, option
43
50
  * Interactive login — opens browser, waits for callback.
44
51
  * startAuth() 래퍼 — CLI에서 사용.
45
52
  */
46
- export declare function login(clientId: string, clientSecret: string): Promise<StoredTokens>;
53
+ export declare function login(clientId: string, clientSecret: string, options?: {
54
+ domains?: readonly AuthDomainId[];
55
+ }): Promise<StoredTokens>;
47
56
  export type RefreshStatus = {
48
57
  status: 'fresh';
49
58
  tokens: StoredTokens;
@@ -6,20 +6,8 @@ import path from 'node:path';
6
6
  import os from 'node:os';
7
7
  import { getMcpOAuthClient } from './constants.js';
8
8
  import { AuthError, classifyError } from './errors.js';
9
- const SCOPES = [
10
- 'https://www.googleapis.com/auth/firebase',
11
- 'https://www.googleapis.com/auth/cloud-platform',
12
- 'https://www.googleapis.com/auth/admob.readonly',
13
- 'https://www.googleapis.com/auth/admob.monetization',
14
- 'https://www.googleapis.com/auth/androidpublisher',
15
- 'https://www.googleapis.com/auth/adwords', // Google Ads API (googleads_* tools)
16
- 'https://www.googleapis.com/auth/webmasters', // Search Console API (gsc_* tools, 사이트맵 제출 포함)
17
- 'https://www.googleapis.com/auth/analytics.edit', // GA4 Analytics Admin API (ga4_* tools — property/data stream 생성·조회)
18
- // GA4 **Data API**(analyticsdata — ga4_run_report)는 analytics.edit 을 받지 않는다.
19
- // Admin API 전용 스코프이기 때문. 이게 빠져 있으면 property 목록은 보이는데 리포트만
20
- // 403 으로 막혀서, "API 가 꺼졌나" 로 오진하기 쉽다. (2026-07 실사고)
21
- 'https://www.googleapis.com/auth/analytics.readonly',
22
- ];
9
+ // 스코프 목록의 SSOT 는 scopes.ts (도메인 → 스코프 매핑). 여기서는 로그인 요청 조립만 한다.
10
+ import { scopesForDomains, mergeScopeStrings } from './scopes.js';
23
11
  // Primary config dir. Legacy `~/.preseed` is read as a fallback during the
24
12
  // rebrand so existing auth sessions don't force a re-login; new writes go to
25
13
  // the new dir.
@@ -131,6 +119,10 @@ let activeAuthServer = null;
131
119
  * 호출자가 URL을 사용자에게 전달하거나 `open()`을 직접 호출.
132
120
  * `wait` Promise: 토큰 저장 시 resolve, 타임아웃/에러 시 reject.
133
121
  * 재호출 시 기존 세션 자동 정리.
122
+ *
123
+ * `domains` 로 권한 도메인 서브셋만 요청할 수 있다 (미지정 시 전체 — 기존 동작).
124
+ * include_granted_scopes 덕에 재로그인은 기존 부여 스코프를 유지한 채 새 스코프만
125
+ * 얹는다(incremental authorization) — 토큰 응답의 scope 필드에도 누적 전체가 온다.
134
126
  */
135
127
  export function startAuth(clientId, clientSecret, options = {}) {
136
128
  if (activeAuthServer) {
@@ -142,10 +134,12 @@ export function startAuth(clientId, clientSecret, options = {}) {
142
134
  }
143
135
  saveCredentials(clientId, clientSecret);
144
136
  const oauth2Client = createOAuth2Client(clientId, clientSecret);
137
+ const requestedScopes = scopesForDomains(options.domains);
145
138
  const authUrl = oauth2Client.generateAuthUrl({
146
139
  access_type: 'offline',
147
- scope: SCOPES,
140
+ scope: requestedScopes,
148
141
  prompt: 'consent',
142
+ include_granted_scopes: true,
149
143
  });
150
144
  const wait = new Promise((resolve, reject) => {
151
145
  const rejectAuth = (e) => reject(new AuthError(classifyError(e, { phase: 'login' })));
@@ -210,12 +204,20 @@ export function startAuth(clientId, clientSecret, options = {}) {
210
204
  }));
211
205
  return;
212
206
  }
207
+ // scope 는 항상 기록한다 — 그리고 누적(monotonic)으로 저장한다.
208
+ // 1) include_granted_scopes 로 Google 측 grant 는 (기존 부여분 ∪ 이번 요청분)이다.
209
+ // 2) 응답의 tokens.scope 는 그 합집합이어야 하지만, 만약 Google 이 좁혀서 주거나
210
+ // 생략하면(빈 값) 여기서 기존 기록 + 이번 요청 스코프로 보정한다.
211
+ // 이렇게 해야 "scope 미기록 = 추적 이전 legacy 토큰" 불변식이 유지된다 — 도메인
212
+ // 선택형 로그인이 이를 깨서, 좁은 로그인이 legacy 로 오인돼 pre-flight 를 우회하는
213
+ // 것을 막는다. (기존엔 응답 scope 로 통째 덮어써서 이 두 위험에 모두 노출됐다.)
214
+ const priorScope = getStoredTokens()?.scope;
213
215
  const stored = {
214
216
  access_token: tokens.access_token,
215
217
  refresh_token: tokens.refresh_token,
216
218
  token_type: tokens.token_type ?? 'Bearer',
217
219
  expiry_date: tokens.expiry_date ?? Date.now() + 3600_000,
218
- ...(tokens.scope && { scope: tokens.scope }),
220
+ scope: mergeScopeStrings(priorScope, tokens.scope ?? requestedScopes.join(' ')),
219
221
  };
220
222
  saveTokens(stored);
221
223
  res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
@@ -269,8 +271,8 @@ export function startAuth(clientId, clientSecret, options = {}) {
269
271
  * Interactive login — opens browser, waits for callback.
270
272
  * startAuth() 래퍼 — CLI에서 사용.
271
273
  */
272
- export async function login(clientId, clientSecret) {
273
- const { url, wait } = startAuth(clientId, clientSecret);
274
+ export async function login(clientId, clientSecret, options = {}) {
275
+ const { url, wait } = startAuth(clientId, clientSecret, options);
274
276
  console.log('🔐 브라우저에서 Google 로그인 중...');
275
277
  open(url);
276
278
  return wait;
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Google OAuth "권한 도메인" → 스코프 매핑의 SSOT.
3
+ *
4
+ * mimi-seed 는 통합 플랫폼이라 여러 구글 서비스 스코프를 다루는데, 예전엔 로그인 한 번에
5
+ * 전부(full-scope)를 강제로 요청했다. OAuth 앱 심사(verification)의 "Requesting Minimum
6
+ * Scopes" 요건과 least-privilege 원칙에 맞추기 위해, 사용자가 쓸 도메인만 골라 동의하는
7
+ * 선택형(incremental) 인증으로 바꿨다:
8
+ *
9
+ * - 로그인 시 도메인 서브셋만 요청 가능 (`mimi-seed-auth --domains ga4,googleads`,
10
+ * MCP `mimi_seed_auth_start` 의 `domains` 파라미터). 미지정 시 기존과 동일한 전체 요청.
11
+ * - `include_granted_scopes=true` 로 요청하므로, 나중에 도메인을 추가해도 기존 부여
12
+ * 권한은 유지된 채 새 권한만 얹힌다 (Google incremental authorization).
13
+ * - 도구 쪽은 `requireAuth(<scope>)` pre-flight 가 미부여 도메인을 결정적으로 안내한다.
14
+ *
15
+ * 도메인 경계는 "어느 도구 군이 어느 스코프를 실제 소비하는가" 기준이다. 특히
16
+ * cloud-platform 은 IAM API(서비스 계정 생성·키 발급)가 더 좁은 대안 스코프를 제공하지
17
+ * 않아서 유지하되, `gcp` 도메인으로 격리해 그 도구를 쓰는 사용자만 요청하게 한다.
18
+ */
19
+ export interface AuthDomainDef {
20
+ /** 사람이 읽을 라벨 (동의 화면 아님 — CLI/도구 안내용) */
21
+ label: string;
22
+ scopes: readonly string[];
23
+ /** 이 도메인이 여는 도구/작업 요약 — CLI·MCP 안내 문자열에 사용 */
24
+ summary: string;
25
+ }
26
+ export declare const AUTH_DOMAINS: {
27
+ readonly firebase: {
28
+ readonly label: "Firebase";
29
+ readonly scopes: readonly ["https://www.googleapis.com/auth/firebase"];
30
+ readonly summary: "firebase_* — 프로젝트/앱/설정 조회·생성 (프로젝트 신규 생성·서비스 활성화는 gcp 도 필요)";
31
+ };
32
+ readonly gcp: {
33
+ readonly label: "Google Cloud (cloud-platform)";
34
+ readonly scopes: readonly ["https://www.googleapis.com/auth/cloud-platform"];
35
+ readonly summary: "iam_* (서비스 계정·키·IAM 바인딩 — IAM API 는 이 스코프의 좁은 대안이 없음), firebase 프로젝트 생성·서비스 활성화, BigQuery OAuth fallback";
36
+ };
37
+ readonly admob: {
38
+ readonly label: "AdMob";
39
+ readonly scopes: readonly ["https://www.googleapis.com/auth/admob.readonly", "https://www.googleapis.com/auth/admob.monetization"];
40
+ readonly summary: "admob_* — 수익 리포트, 앱·광고 단위 생성";
41
+ };
42
+ readonly playstore: {
43
+ readonly label: "Play Store";
44
+ readonly scopes: readonly ["https://www.googleapis.com/auth/androidpublisher"];
45
+ readonly summary: "playstore_* — 서비스 계정 없이 OAuth 로 하는 Play Console 작업";
46
+ };
47
+ readonly googleads: {
48
+ readonly label: "Google Ads";
49
+ readonly scopes: readonly ["https://www.googleapis.com/auth/adwords"];
50
+ readonly summary: "googleads_* — 캠페인·UAC 리포트";
51
+ };
52
+ readonly gsc: {
53
+ readonly label: "Search Console";
54
+ readonly scopes: readonly ["https://www.googleapis.com/auth/webmasters"];
55
+ readonly summary: "gsc_* — 사이트·사이트맵·검색 성과 (사이트맵 제출 포함)";
56
+ };
57
+ readonly ga4: {
58
+ readonly label: "Google Analytics (GA4)";
59
+ readonly scopes: readonly ["https://www.googleapis.com/auth/analytics.edit", "https://www.googleapis.com/auth/analytics.readonly"];
60
+ readonly summary: "ga4_* — property/data stream 생성·조회 + 리포트";
61
+ };
62
+ };
63
+ export type AuthDomainId = keyof typeof AUTH_DOMAINS;
64
+ /** z.enum 등 튜플이 필요한 자리에 쓰는 도메인 id 목록 (선언 순서 유지). */
65
+ export declare const DOMAIN_IDS: [AuthDomainId, ...AuthDomainId[]];
66
+ export declare const CLOUD_PLATFORM_SCOPE: "https://www.googleapis.com/auth/cloud-platform";
67
+ /**
68
+ * 공백 구분 scope 문자열들의 합집합. tokens.json 의 scope 는 누적(monotonic)이어야 하므로
69
+ * 로그인/갱신 시 기존 기록 + 새 응답을 합쳐 저장하는 데 쓴다. undefined/빈 문자열은 무시.
70
+ */
71
+ export declare function mergeScopeStrings(...parts: Array<string | undefined>): string;
72
+ /**
73
+ * 전체 스코프 (도메인 선언 순서대로 평탄화).
74
+ * 도메인 미지정 로그인의 기본값이자, 선택형 도입 전 full-scope 목록과 동일해야 한다
75
+ * (auth-scopes.test.ts 가 고정한다 — 스코프가 소리 없이 빠지면 기존 사용자 도구가 죽는다).
76
+ */
77
+ export declare const ALL_SCOPES: readonly string[];
78
+ /** 도메인 서브셋 → 요청할 스코프 목록. 미지정/빈 배열이면 전체(기존 동작). */
79
+ export declare function scopesForDomains(domains?: readonly AuthDomainId[]): string[];
80
+ /** 스코프 하나를 요구하는 도메인들 (INSUFFICIENT_SCOPE 안내에서 "--domains X" 를 채울 때 사용). */
81
+ export declare function domainsForScope(scope: string): AuthDomainId[];
82
+ /** CLI `--domains a,b,c` 파싱. 잘못된 id 는 invalid 로 분리해 호출자가 안내한다. */
83
+ export declare function parseDomainList(raw: string): {
84
+ domains: AuthDomainId[];
85
+ invalid: string[];
86
+ };
87
+ export interface GrantedDomainSummary {
88
+ /** tokens.json 에 scope 기록이 있는지. false 면 스코프 추적 도입 전 구 토큰. */
89
+ known: boolean;
90
+ /** 도메인의 스코프가 전부 부여된 도메인들 */
91
+ granted: AuthDomainId[];
92
+ /** 하나라도 미부여인 도메인들 */
93
+ missing: AuthDomainId[];
94
+ }
95
+ /** tokens.json 의 공백 구분 scope 문자열 → 도메인 단위 부여 현황. */
96
+ export declare function summarizeGrantedDomains(scopeStr: string | undefined): GrantedDomainSummary;
97
+ /** 구 토큰(scope 미기록)이 이 스코프를 보유했다고 간주해도 되는가. */
98
+ export declare function isPreTrackingScope(scope: string): boolean;
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Google OAuth "권한 도메인" → 스코프 매핑의 SSOT.
3
+ *
4
+ * mimi-seed 는 통합 플랫폼이라 여러 구글 서비스 스코프를 다루는데, 예전엔 로그인 한 번에
5
+ * 전부(full-scope)를 강제로 요청했다. OAuth 앱 심사(verification)의 "Requesting Minimum
6
+ * Scopes" 요건과 least-privilege 원칙에 맞추기 위해, 사용자가 쓸 도메인만 골라 동의하는
7
+ * 선택형(incremental) 인증으로 바꿨다:
8
+ *
9
+ * - 로그인 시 도메인 서브셋만 요청 가능 (`mimi-seed-auth --domains ga4,googleads`,
10
+ * MCP `mimi_seed_auth_start` 의 `domains` 파라미터). 미지정 시 기존과 동일한 전체 요청.
11
+ * - `include_granted_scopes=true` 로 요청하므로, 나중에 도메인을 추가해도 기존 부여
12
+ * 권한은 유지된 채 새 권한만 얹힌다 (Google incremental authorization).
13
+ * - 도구 쪽은 `requireAuth(<scope>)` pre-flight 가 미부여 도메인을 결정적으로 안내한다.
14
+ *
15
+ * 도메인 경계는 "어느 도구 군이 어느 스코프를 실제 소비하는가" 기준이다. 특히
16
+ * cloud-platform 은 IAM API(서비스 계정 생성·키 발급)가 더 좁은 대안 스코프를 제공하지
17
+ * 않아서 유지하되, `gcp` 도메인으로 격리해 그 도구를 쓰는 사용자만 요청하게 한다.
18
+ */
19
+ export const AUTH_DOMAINS = {
20
+ firebase: {
21
+ label: 'Firebase',
22
+ scopes: ['https://www.googleapis.com/auth/firebase'],
23
+ summary: 'firebase_* — 프로젝트/앱/설정 조회·생성 (프로젝트 신규 생성·서비스 활성화는 gcp 도 필요)',
24
+ },
25
+ gcp: {
26
+ label: 'Google Cloud (cloud-platform)',
27
+ scopes: ['https://www.googleapis.com/auth/cloud-platform'],
28
+ summary: 'iam_* (서비스 계정·키·IAM 바인딩 — IAM API 는 이 스코프의 좁은 대안이 없음), firebase 프로젝트 생성·서비스 활성화, BigQuery OAuth fallback',
29
+ },
30
+ admob: {
31
+ label: 'AdMob',
32
+ scopes: [
33
+ 'https://www.googleapis.com/auth/admob.readonly',
34
+ 'https://www.googleapis.com/auth/admob.monetization',
35
+ ],
36
+ summary: 'admob_* — 수익 리포트, 앱·광고 단위 생성',
37
+ },
38
+ playstore: {
39
+ label: 'Play Store',
40
+ scopes: ['https://www.googleapis.com/auth/androidpublisher'],
41
+ summary: 'playstore_* — 서비스 계정 없이 OAuth 로 하는 Play Console 작업',
42
+ },
43
+ googleads: {
44
+ label: 'Google Ads',
45
+ scopes: ['https://www.googleapis.com/auth/adwords'],
46
+ summary: 'googleads_* — 캠페인·UAC 리포트',
47
+ },
48
+ gsc: {
49
+ label: 'Search Console',
50
+ scopes: ['https://www.googleapis.com/auth/webmasters'],
51
+ summary: 'gsc_* — 사이트·사이트맵·검색 성과 (사이트맵 제출 포함)',
52
+ },
53
+ ga4: {
54
+ label: 'Google Analytics (GA4)',
55
+ scopes: [
56
+ // Admin API(property/data stream 생성·조회) 전용.
57
+ 'https://www.googleapis.com/auth/analytics.edit',
58
+ // Data API(runReport)는 analytics.edit 을 받지 않는다 — 같은 도메인이지만 별도 스코프.
59
+ // (ga4/tools.ts 의 GA4_DATA_SCOPE 주석, 2026-07 실사고 참고.)
60
+ 'https://www.googleapis.com/auth/analytics.readonly',
61
+ ],
62
+ summary: 'ga4_* — property/data stream 생성·조회 + 리포트',
63
+ },
64
+ };
65
+ /** z.enum 등 튜플이 필요한 자리에 쓰는 도메인 id 목록 (선언 순서 유지). */
66
+ export const DOMAIN_IDS = Object.keys(AUTH_DOMAINS);
67
+ export const CLOUD_PLATFORM_SCOPE = AUTH_DOMAINS.gcp.scopes[0];
68
+ function dedupe(scopes) {
69
+ return [...new Set(scopes)];
70
+ }
71
+ /**
72
+ * 공백 구분 scope 문자열들의 합집합. tokens.json 의 scope 는 누적(monotonic)이어야 하므로
73
+ * 로그인/갱신 시 기존 기록 + 새 응답을 합쳐 저장하는 데 쓴다. undefined/빈 문자열은 무시.
74
+ */
75
+ export function mergeScopeStrings(...parts) {
76
+ const set = new Set();
77
+ for (const part of parts) {
78
+ if (!part)
79
+ continue;
80
+ for (const s of part.split(' '))
81
+ if (s)
82
+ set.add(s);
83
+ }
84
+ return [...set].join(' ');
85
+ }
86
+ /**
87
+ * 전체 스코프 (도메인 선언 순서대로 평탄화).
88
+ * 도메인 미지정 로그인의 기본값이자, 선택형 도입 전 full-scope 목록과 동일해야 한다
89
+ * (auth-scopes.test.ts 가 고정한다 — 스코프가 소리 없이 빠지면 기존 사용자 도구가 죽는다).
90
+ */
91
+ export const ALL_SCOPES = dedupe(DOMAIN_IDS.flatMap((id) => AUTH_DOMAINS[id].scopes));
92
+ /** 도메인 서브셋 → 요청할 스코프 목록. 미지정/빈 배열이면 전체(기존 동작). */
93
+ export function scopesForDomains(domains) {
94
+ if (!domains || domains.length === 0)
95
+ return [...ALL_SCOPES];
96
+ return dedupe(domains.flatMap((id) => AUTH_DOMAINS[id].scopes));
97
+ }
98
+ /** 스코프 하나를 요구하는 도메인들 (INSUFFICIENT_SCOPE 안내에서 "--domains X" 를 채울 때 사용). */
99
+ export function domainsForScope(scope) {
100
+ return DOMAIN_IDS.filter((id) => AUTH_DOMAINS[id].scopes.includes(scope));
101
+ }
102
+ /** CLI `--domains a,b,c` 파싱. 잘못된 id 는 invalid 로 분리해 호출자가 안내한다. */
103
+ export function parseDomainList(raw) {
104
+ const domains = [];
105
+ const invalid = [];
106
+ for (const part of raw.split(',').map((s) => s.trim()).filter(Boolean)) {
107
+ if (DOMAIN_IDS.includes(part)) {
108
+ const id = part;
109
+ if (!domains.includes(id))
110
+ domains.push(id);
111
+ }
112
+ else {
113
+ invalid.push(part);
114
+ }
115
+ }
116
+ return { domains, invalid };
117
+ }
118
+ /** tokens.json 의 공백 구분 scope 문자열 → 도메인 단위 부여 현황. */
119
+ export function summarizeGrantedDomains(scopeStr) {
120
+ if (scopeStr === undefined)
121
+ return { known: false, granted: [], missing: [] };
122
+ const grantedScopes = new Set(scopeStr.split(' ').filter(Boolean));
123
+ const granted = [];
124
+ const missing = [];
125
+ for (const id of DOMAIN_IDS) {
126
+ if (AUTH_DOMAINS[id].scopes.every((s) => grantedScopes.has(s)))
127
+ granted.push(id);
128
+ else
129
+ missing.push(id);
130
+ }
131
+ return { known: true, granted, missing };
132
+ }
133
+ /**
134
+ * scope 추적 도입 시점에 **이미 존재하던** 스코프들의 **동결(frozen) 스냅샷**.
135
+ *
136
+ * 구 토큰(scope === undefined)은 추적 도입 이전의 full-scope 로그인이므로, 이 시점에
137
+ * 존재하던 스코프는 보유로 간주해야 한다 — 안 그러면 pre-flight 를 새로 다는 순간
138
+ * 멀쩡히 동작하던 기존 사용자에게 불필요한 재로그인을 강제한다.
139
+ *
140
+ * 왜 "이후 추가된 것"이 아니라 "이전에 있던 것"을 나열하는가(안전한 쪽 동결):
141
+ * 추적 도입 이후 추가되는 스코프(GA4 analytics.* 를 포함해 앞으로의 모든 신규 스코프)는
142
+ * 이 집합에 없으므로 자동으로 '추적 이후'로 분류돼, 구 토큰에서 '미보유'로 안전하게
143
+ * 취급된다. 반대 방향(추가된 것을 나열)으로 두면 신규 스코프를 넣는 사람이 목록 갱신을
144
+ * 잊는 순간 구 토큰이 그 스코프를 '보유'로 오인해 pre-flight 가 무력화된다. 이 집합은
145
+ * 동결이라 **새 스코프를 여기 추가할 일은 없다**(auth-scopes.test.ts 가 고정).
146
+ */
147
+ const PRE_TRACKING_SCOPES = new Set([
148
+ 'https://www.googleapis.com/auth/firebase',
149
+ 'https://www.googleapis.com/auth/cloud-platform',
150
+ 'https://www.googleapis.com/auth/admob.readonly',
151
+ 'https://www.googleapis.com/auth/admob.monetization',
152
+ 'https://www.googleapis.com/auth/androidpublisher',
153
+ 'https://www.googleapis.com/auth/adwords',
154
+ 'https://www.googleapis.com/auth/webmasters',
155
+ ]);
156
+ /** 구 토큰(scope 미기록)이 이 스코프를 보유했다고 간주해도 되는가. */
157
+ export function isPreTrackingScope(scope) {
158
+ return PRE_TRACKING_SCOPES.has(scope);
159
+ }