@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,9 +1,34 @@
1
1
  import { fetchWithTimeout } from '../lib/http.js';
2
+ import { googleAdsError } from './errors.js';
2
3
  // Google Ads API는 ~13개월 주기로 sunset (항상 최신 3개 major만 유지).
3
4
  // v24 = 2026-06 기준 현행 major. 새 major 출시 시 갱신 필요.
4
5
  // https://developers.google.com/google-ads/api/docs/sunset-dates
5
6
  const API_VERSION = 'v24';
6
7
  const BASE = `https://googleads.googleapis.com/${API_VERSION}`;
8
+ export 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.';
9
+ function isRecord(value) {
10
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
11
+ }
12
+ function metricNumber(value) {
13
+ // Protobuf JSON can omit zero-valued scalars, but explicit malformed values are not zero.
14
+ if (value === undefined)
15
+ return 0;
16
+ if ((typeof value !== 'number' && typeof value !== 'string') ||
17
+ (typeof value === 'string' && !/^-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?$/.test(value)) ||
18
+ !Number.isFinite(Number(value)))
19
+ throw new Error('Google Ads returned an invalid numeric metric.');
20
+ return Number(value);
21
+ }
22
+ function validateDateRange(range) {
23
+ for (const value of [range.startDate, range.endDate]) {
24
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(value) ||
25
+ !Number.isFinite(Date.parse(value)) || new Date(value).toISOString().slice(0, 10) !== value) {
26
+ throw new Error('Google Ads dates must be valid YYYY-MM-DD calendar dates.');
27
+ }
28
+ }
29
+ if (range.startDate > range.endDate)
30
+ throw new Error('Google Ads startDate must not exceed endDate.');
31
+ }
7
32
  // ─── 내부 헬퍼 ───────────────────────────────────────────────
8
33
  async function getAccessToken(auth) {
9
34
  const token = await auth.getAccessToken();
@@ -28,8 +53,10 @@ async function search(auth, cfg, query) {
28
53
  const url = `${BASE}/customers/${cfg.customerId}/googleAds:search`;
29
54
  const all = [];
30
55
  let pageToken;
56
+ const seenTokens = new Set();
31
57
  do {
32
- const body = { query, pageSize: 1000 };
58
+ // Google Ads controls page size; sending pageSize is rejected by current APIs.
59
+ const body = { query };
33
60
  if (pageToken)
34
61
  body.pageToken = pageToken;
35
62
  const res = await fetchWithTimeout(url, {
@@ -39,27 +66,36 @@ async function search(auth, cfg, query) {
39
66
  });
40
67
  const text = await res.text();
41
68
  if (!res.ok) {
42
- let msg = `Google Ads API ${res.status}`;
43
- try {
44
- const err = JSON.parse(text);
45
- const detail = err?.error?.message ?? err?.[0]?.error?.message ?? text;
46
- msg += `: ${detail}`;
47
- }
48
- catch {
49
- msg += `: ${text.slice(0, 300)}`;
50
- }
51
- throw new Error(msg);
69
+ throw googleAdsError(res.status, text, [accessToken, cfg.developerToken]);
70
+ }
71
+ let json;
72
+ try {
73
+ json = JSON.parse(text);
74
+ }
75
+ catch {
76
+ throw new Error('Google Ads returned invalid JSON.');
77
+ }
78
+ if (!isRecord(json) || 'error' in json ||
79
+ (json.results !== undefined && !Array.isArray(json.results)) ||
80
+ (json.nextPageToken !== undefined && typeof json.nextPageToken !== 'string') ||
81
+ (json.results ?? []).some((row) => !isRecord(row) || !isRecord(row.campaign))) {
82
+ throw new Error('Google Ads returned an invalid search response.');
52
83
  }
53
- const json = JSON.parse(text);
54
84
  all.push(...(json.results ?? []));
55
85
  pageToken = json.nextPageToken ?? undefined;
86
+ if (pageToken) {
87
+ if (seenTokens.has(pageToken))
88
+ throw new Error('Google Ads repeated a page token; refusing partial totals.');
89
+ seenTokens.add(pageToken);
90
+ }
56
91
  } while (pageToken);
57
92
  return all;
58
93
  }
59
94
  function microsToCurrency(micros) {
60
- if (micros == null)
61
- return 0;
62
- return Number(micros) / 1_000_000;
95
+ const value = metricNumber(micros);
96
+ if (!Number.isSafeInteger(value))
97
+ throw new Error('Google Ads returned unsafe or fractional currency micros.');
98
+ return value / 1_000_000;
63
99
  }
64
100
  // ─── 접근 가능한 고객 목록 (API 연결 확인용) ─────────────────────
65
101
  export async function listAccessibleCustomers(auth, cfg) {
@@ -71,15 +107,7 @@ export async function listAccessibleCustomers(auth, cfg) {
71
107
  });
72
108
  const text = await res.text();
73
109
  if (!res.ok) {
74
- let msg = `Google Ads API ${res.status}`;
75
- try {
76
- const err = JSON.parse(text);
77
- msg += `: ${err?.error?.message ?? text.slice(0, 300)}`;
78
- }
79
- catch {
80
- msg += `: ${text.slice(0, 300)}`;
81
- }
82
- throw new Error(msg);
110
+ throw googleAdsError(res.status, text, [accessToken, cfg.developerToken]);
83
111
  }
84
112
  return JSON.parse(text);
85
113
  }
@@ -92,8 +120,8 @@ export async function listCampaigns(auth, cfg) {
92
120
  campaign.status,
93
121
  campaign.advertising_channel_type,
94
122
  campaign.advertising_channel_sub_type,
95
- campaign.start_date,
96
- campaign.end_date,
123
+ campaign.start_date_time,
124
+ campaign.end_date_time,
97
125
  campaign_budget.amount_micros
98
126
  FROM campaign
99
127
  WHERE campaign.status != 'REMOVED'
@@ -107,15 +135,19 @@ export async function listCampaigns(auth, cfg) {
107
135
  status: r.campaign?.status,
108
136
  channelType: r.campaign?.advertisingChannelType,
109
137
  channelSubType: r.campaign?.advertisingChannelSubType,
110
- startDate: r.campaign?.startDate,
111
- endDate: r.campaign?.endDate,
138
+ // Keep date-only response fields compatible while querying the supported schema.
139
+ startDate: r.campaign?.startDateTime?.slice(0, 10),
140
+ endDate: r.campaign?.endDateTime?.slice(0, 10),
112
141
  dailyBudget: microsToCurrency(r.campaignBudget?.amountMicros),
113
142
  }));
114
143
  }
115
144
  // ─── 캠페인 성과 리포트 ────────────────────────────────────────
116
145
  export async function getCampaignReport(auth, cfg, range) {
146
+ validateDateRange(range);
117
147
  const query = `
118
148
  SELECT
149
+ customer.currency_code,
150
+ customer.time_zone,
119
151
  campaign.id,
120
152
  campaign.name,
121
153
  campaign.status,
@@ -130,30 +162,37 @@ export async function getCampaignReport(auth, cfg, range) {
130
162
  metrics.ctr,
131
163
  metrics.average_cpc
132
164
  FROM campaign
133
- WHERE campaign.status != 'REMOVED'
134
- AND segments.date BETWEEN '${range.startDate}' AND '${range.endDate}'
165
+ WHERE segments.date BETWEEN '${range.startDate}' AND '${range.endDate}'
135
166
  ORDER BY metrics.cost_micros DESC
136
- LIMIT 500
137
167
  `;
138
168
  const rows = await search(auth, cfg, query);
169
+ for (const row of rows) {
170
+ if (!isRecord(row.metrics))
171
+ throw new Error('Google Ads report row is missing metrics.');
172
+ }
139
173
  return rows.map((r) => ({
174
+ currencyCode: r.customer?.currencyCode,
175
+ timeZone: r.customer?.timeZone,
140
176
  id: r.campaign?.id,
141
177
  name: r.campaign?.name,
142
178
  status: r.campaign?.status,
143
179
  channelType: r.campaign?.advertisingChannelType,
144
180
  channelSubType: r.campaign?.advertisingChannelSubType,
145
- clicks: Number(r.metrics?.clicks ?? 0),
146
- impressions: Number(r.metrics?.impressions ?? 0),
181
+ clicks: metricNumber(r.metrics?.clicks),
182
+ impressions: metricNumber(r.metrics?.impressions),
147
183
  cost: microsToCurrency(r.metrics?.costMicros),
148
- conversions: Number(r.metrics?.conversions ?? 0),
149
- conversionsValue: Number(r.metrics?.conversionsValue ?? 0),
150
- cpi: microsToCurrency(r.metrics?.costPerConversion),
151
- ctr: Number(r.metrics?.ctr ?? 0),
152
- avgCpc: microsToCurrency(r.metrics?.averageCpc),
184
+ conversions: metricNumber(r.metrics?.conversions),
185
+ conversionsValue: metricNumber(r.metrics?.conversionsValue),
186
+ costPerConversion: metricNumber(r.metrics?.conversions) > 0
187
+ ? metricNumber(r.metrics?.costPerConversion) / 1_000_000 : null,
188
+ cpi: metricNumber(r.metrics?.costPerConversion) / 1_000_000,
189
+ ctr: metricNumber(r.metrics?.ctr),
190
+ avgCpc: metricNumber(r.metrics?.averageCpc) / 1_000_000,
153
191
  }));
154
192
  }
155
193
  // ─── UAC(앱 캠페인) 리포트 ─────────────────────────────────────
156
194
  export async function getUacReport(auth, cfg, range) {
195
+ validateDateRange(range);
157
196
  const query = `
158
197
  SELECT
159
198
  campaign.id,
@@ -173,21 +212,22 @@ export async function getUacReport(auth, cfg, range) {
173
212
  AND campaign.advertising_channel_sub_type IN ('APP_CAMPAIGN', 'APP_CAMPAIGN_FOR_ENGAGEMENT', 'APP_CAMPAIGN_FOR_PRE_REGISTRATION')
174
213
  AND segments.date BETWEEN '${range.startDate}' AND '${range.endDate}'
175
214
  ORDER BY segments.date DESC, metrics.cost_micros DESC
176
- LIMIT 500
177
215
  `;
178
216
  const rows = await search(auth, cfg, query);
179
217
  const byId = new Map();
180
218
  for (const r of rows) {
219
+ if (!isRecord(r.metrics) || !r.campaign?.id)
220
+ throw new Error('Google Ads UAC row is missing campaign or metrics.');
181
221
  const id = String(r.campaign?.id ?? '');
182
222
  const existing = byId.get(id);
183
223
  const cost = microsToCurrency(r.metrics?.costMicros);
184
- const installs = Number(r.metrics?.conversions ?? 0);
224
+ const installs = metricNumber(r.metrics?.conversions);
185
225
  if (existing) {
186
- existing.clicks += Number(r.metrics?.clicks ?? 0);
187
- existing.impressions += Number(r.metrics?.impressions ?? 0);
226
+ existing.clicks += metricNumber(r.metrics?.clicks);
227
+ existing.impressions += metricNumber(r.metrics?.impressions);
188
228
  existing.cost += cost;
189
229
  existing.installs += installs;
190
- existing.installsValue += Number(r.metrics?.conversionsValue ?? 0);
230
+ existing.installsValue += metricNumber(r.metrics?.conversionsValue);
191
231
  if (r.segments?.date)
192
232
  existing.dates.push(r.segments.date);
193
233
  }
@@ -197,11 +237,11 @@ export async function getUacReport(auth, cfg, range) {
197
237
  name: r.campaign?.name ?? '',
198
238
  status: r.campaign?.status ?? '',
199
239
  subType: r.campaign?.advertisingChannelSubType ?? '',
200
- clicks: Number(r.metrics?.clicks ?? 0),
201
- impressions: Number(r.metrics?.impressions ?? 0),
240
+ clicks: metricNumber(r.metrics?.clicks),
241
+ impressions: metricNumber(r.metrics?.impressions),
202
242
  cost,
203
243
  installs,
204
- installsValue: Number(r.metrics?.conversionsValue ?? 0),
244
+ installsValue: metricNumber(r.metrics?.conversionsValue),
205
245
  dates: r.segments?.date ? [r.segments.date] : [],
206
246
  });
207
247
  }
@@ -214,6 +254,9 @@ export async function getUacReport(auth, cfg, range) {
214
254
  clicks: c.clicks,
215
255
  impressions: c.impressions,
216
256
  cost: c.cost,
257
+ conversions: c.installs,
258
+ conversionsValue: c.installsValue,
259
+ costPerConversion: c.installs > 0 ? c.cost / c.installs : null,
217
260
  installs: c.installs,
218
261
  installsValue: c.installsValue,
219
262
  cpi: c.installs > 0 ? c.cost / c.installs : 0,
package/dist/helpers.d.ts CHANGED
@@ -8,7 +8,7 @@ export { ensureFreshAccessToken };
8
8
  * 기존엔 만료 시 각 OAuth 도구(firebase/admob/iam/googleads/checks)가
9
9
  * googleapis GaxiosError 를 그대로 노출 → 사용자는 "재로그인하라"는 안내를 못 받았다.
10
10
  */
11
- export declare function requireAuth(requiredScope?: string): Promise<import("google-auth-library").OAuth2Client>;
11
+ export declare function requireAuth(requiredScope?: string, profile?: string): Promise<import("google-auth-library").OAuth2Client>;
12
12
  export declare const PLAY_AUTH_HINT: string;
13
13
  export declare const APPSTORE_AUTH_HINT: string;
14
14
  /**
package/dist/helpers.js CHANGED
@@ -4,13 +4,13 @@ import { getServiceAccountClient, getServiceAccountJson } from './auth/playstore
4
4
  import { getAppStoreCredentials } from './appstore/auth.js';
5
5
  export { ensureFreshAccessToken };
6
6
  const REAUTH_CMD = ' npx -y @yoonion/mimi-seed-mcp mimi-seed-auth';
7
- function formatAuthError(p) {
7
+ function formatAuthError(p, profile) {
8
8
  // 재로그인 안내는 needsReauth 인 경우에만 — CONFIG_FETCH_FAILED 처럼 재로그인이
9
9
  // 해법이 아닌 에러에 무조건 붙이면 같은 실패를 반복하게 만든다.
10
10
  return [
11
11
  `❌ [${p.code}] ${p.message}`,
12
12
  p.hint ? `→ ${p.hint}` : '',
13
- ...(p.needsReauth ? ['', '터미널에서 재로그인:', REAUTH_CMD] : []),
13
+ ...(p.needsReauth ? ['', '터미널에서 재로그인:', profile === undefined ? REAUTH_CMD : `${REAUTH_CMD} --profile ${profile}`] : []),
14
14
  ]
15
15
  .filter((l) => l !== '')
16
16
  .join('\n');
@@ -28,10 +28,10 @@ function formatAuthError(p) {
28
28
  *
29
29
  * OAuth 토큰에만 적용된다 — SA JWT 는 자체 scopes 로 토큰을 받으므로 이 검사 대상이 아니다.
30
30
  */
31
- function assertStoredScope(requiredScope) {
31
+ function assertStoredScope(requiredScope, profile) {
32
32
  if (!requiredScope)
33
33
  return;
34
- const scopeStr = getStoredTokens()?.scope;
34
+ const scopeStr = getStoredTokens(profile)?.scope;
35
35
  const missing = scopeStr === undefined
36
36
  ? !isPreTrackingScope(requiredScope)
37
37
  : !scopeStr.split(' ').filter(Boolean).includes(requiredScope);
@@ -42,7 +42,7 @@ function assertStoredScope(requiredScope) {
42
42
  code: 'INSUFFICIENT_SCOPE',
43
43
  message: `이 도구는 추가 권한이 필요해 (${requiredScope}). 현재 로그인에 그 권한이 없어.`,
44
44
  hint: domainArg
45
- ? `mimi-seed-auth --domains ${domainArg} 로 재로그인하면 기존 권한은 유지한 채 이 권한만 추가돼.`
45
+ ? `mimi-seed-auth${profile === undefined ? '' : ` --profile ${profile}`} --domains ${domainArg} 로 재로그인하면 기존 권한은 유지한 채 이 권한만 추가돼.`
46
46
  : 'mimi-seed-auth 로 재로그인하면 새 권한이 부여돼.',
47
47
  retriable: false,
48
48
  needsReauth: true,
@@ -56,12 +56,12 @@ function assertStoredScope(requiredScope) {
56
56
  * 기존엔 만료 시 각 OAuth 도구(firebase/admob/iam/googleads/checks)가
57
57
  * googleapis GaxiosError 를 그대로 노출 → 사용자는 "재로그인하라"는 안내를 못 받았다.
58
58
  */
59
- export async function requireAuth(requiredScope) {
60
- const result = await ensureFreshAccessToken();
59
+ export async function requireAuth(requiredScope, profile) {
60
+ const result = await ensureFreshAccessToken(undefined, profile);
61
61
  if (result.status === 'unauthenticated' || result.status === 'expired_refresh_failed') {
62
- throw new Error(formatAuthError(result.error));
62
+ throw new Error(formatAuthError(result.error, profile));
63
63
  }
64
- const client = getAuthenticatedClient();
64
+ const client = getAuthenticatedClient(profile);
65
65
  if (!client) {
66
66
  throw new Error(formatAuthError({
67
67
  code: 'UNAUTHENTICATED',
@@ -71,7 +71,7 @@ export async function requireAuth(requiredScope) {
71
71
  needsReauth: true,
72
72
  }));
73
73
  }
74
- assertStoredScope(requiredScope);
74
+ assertStoredScope(requiredScope, profile);
75
75
  return client;
76
76
  }
77
77
  export const PLAY_AUTH_HINT = [
@@ -0,0 +1,13 @@
1
+ import { type SocialConfigOptions } from '../social/profile-store.js';
2
+ export declare class InstagramLoginError extends Error {
3
+ readonly code: 'configuration' | 'unavailable' | 'denied' | 'timeout' | 'exchange' | 'validation';
4
+ constructor(code: 'configuration' | 'unavailable' | 'denied' | 'timeout' | 'exchange' | 'validation');
5
+ }
6
+ export interface BrowserLoginOptions extends SocialConfigOptions {
7
+ webBase?: string;
8
+ timeoutMs?: number;
9
+ openBrowser?: (url: string) => Promise<void>;
10
+ onUrl?: (url: string) => void;
11
+ }
12
+ /** The broker keeps the Meta app secret server-side; no token is passed through browser URLs. */
13
+ export declare function connectInstagramInBrowser(options?: BrowserLoginOptions): Promise<import("../facebook/setup.js").ConnectResult>;
@@ -0,0 +1,125 @@
1
+ import { createServer } from 'node:http';
2
+ import { randomBytes, createHash, timingSafeEqual } from 'node:crypto';
3
+ import { fetchWithTimeout } from '../lib/http.js';
4
+ import { openSystemBrowser } from '../auth/browser.js';
5
+ import { connectInstagram } from './setup.js';
6
+ import { resolveSocialConfigTarget } from '../social/profile-store.js';
7
+ export class InstagramLoginError extends Error {
8
+ code;
9
+ constructor(code) {
10
+ super(`Instagram browser login: ${code}`);
11
+ this.code = code;
12
+ }
13
+ }
14
+ /** The broker keeps the Meta app secret server-side; no token is passed through browser URLs. */
15
+ export async function connectInstagramInBrowser(options = {}) {
16
+ // Freeze the selected profile before any asynchronous work / directory changes.
17
+ const target = resolveSocialConfigTarget('instagram', options);
18
+ const saveOptions = { ...options, profile: target.profile ?? undefined, startDir: options.startDir ?? process.cwd() };
19
+ const base = new URL(options.webBase ?? process.env.MIMI_SEED_WEB_BASE ?? 'https://mimi-seed.pryzm.gg');
20
+ if (base.protocol !== 'https:' || base.username || base.password || base.search || base.hash || base.pathname !== '/') {
21
+ throw new InstagramLoginError('configuration');
22
+ }
23
+ const state = randomBytes(32).toString('hex');
24
+ const verifier = randomBytes(32).toString('base64url');
25
+ const codeChallenge = createHash('sha256').update(verifier).digest('base64url');
26
+ const timeoutMs = options.timeoutMs ?? 600_000;
27
+ if (!Number.isFinite(timeoutMs) || timeoutMs < 1 || timeoutMs > 600_000)
28
+ throw new InstagramLoginError('configuration');
29
+ let finish;
30
+ let fail;
31
+ let settled = false;
32
+ const callback = new Promise((resolve, reject) => { finish = resolve; fail = reject; });
33
+ // A denial/timeout can arrive while opening the browser. Attach a handler immediately.
34
+ void callback.catch(() => { });
35
+ const server = createServer((req, res) => {
36
+ res.setHeader('Cache-Control', 'no-store');
37
+ res.setHeader('Referrer-Policy', 'no-referrer');
38
+ res.setHeader('Content-Security-Policy', "default-src 'none'");
39
+ const url = new URL(req.url ?? '/', 'http://127.0.0.1');
40
+ if (req.method !== 'GET' || url.pathname !== '/instagram/callback') {
41
+ res.writeHead(404).end();
42
+ return;
43
+ }
44
+ const actual = url.searchParams.get('state') ?? '';
45
+ if (!/^[a-f0-9]{64}$/.test(actual) || !timingSafeEqual(Buffer.from(actual), Buffer.from(state))) {
46
+ res.writeHead(400).end('Invalid login state.');
47
+ return;
48
+ }
49
+ if (settled) {
50
+ res.writeHead(409).end('Login already handled.');
51
+ return;
52
+ }
53
+ if (url.searchParams.has('error')) {
54
+ settled = true;
55
+ res.writeHead(400).end('Instagram login cancelled. Return to Mimi Seed.');
56
+ fail(new InstagramLoginError('denied'));
57
+ return;
58
+ }
59
+ const code = url.searchParams.get('code');
60
+ const ticket = url.searchParams.get('ticket');
61
+ if (!code || code.length > 4096 || !ticket || ticket.length > 8192) {
62
+ res.writeHead(400).end('Invalid callback.');
63
+ return;
64
+ }
65
+ settled = true;
66
+ res.end('Instagram approval received. Return to Mimi Seed to check the connection result.');
67
+ finish({ code, ticket });
68
+ });
69
+ let timer;
70
+ try {
71
+ await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolve); });
72
+ const address = server.address();
73
+ if (!address || typeof address === 'string')
74
+ throw new InstagramLoginError('configuration');
75
+ timer = setTimeout(() => { settled = true; fail(new InstagramLoginError('timeout')); }, timeoutMs);
76
+ const request = async (path, body) => {
77
+ try {
78
+ const response = await fetchWithTimeout(new URL(path, base), {
79
+ method: 'POST', redirect: 'error', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
80
+ }, 30_000);
81
+ if (!response.ok)
82
+ throw new InstagramLoginError(response.status === 503 || response.status === 404 ? 'unavailable' : 'exchange');
83
+ return await response.json();
84
+ }
85
+ catch (error) {
86
+ if (error instanceof InstagramLoginError)
87
+ throw error;
88
+ // Network/provider exceptions may contain codes or tokens. Do not echo them.
89
+ throw new InstagramLoginError('unavailable');
90
+ }
91
+ };
92
+ const start = await request('/api/instagram-auth/start', { state, codeChallenge, callbackPort: address.port });
93
+ if (typeof start.authorizationUrl !== 'string')
94
+ throw new InstagramLoginError('configuration');
95
+ const authorize = new URL(start.authorizationUrl);
96
+ if (authorize.origin !== base.origin || authorize.pathname !== '/api/instagram-auth/authorize' || authorize.username || authorize.password) {
97
+ throw new InstagramLoginError('configuration');
98
+ }
99
+ options.onUrl?.(authorize.href);
100
+ try {
101
+ await (options.openBrowser ?? openSystemBrowser)(authorize.href);
102
+ }
103
+ catch {
104
+ if (!options.onUrl)
105
+ throw new InstagramLoginError('unavailable');
106
+ }
107
+ const result = await callback;
108
+ const token = await request('/api/instagram-auth/exchange', { ...result, codeVerifier: verifier });
109
+ if (typeof token.accessToken !== 'string' || !token.accessToken || typeof token.userId !== 'string' || !token.userId ||
110
+ typeof token.expiresInSeconds !== 'number' || !Number.isFinite(token.expiresInSeconds) || token.expiresInSeconds <= 0 || token.expiresInSeconds > 60 * 24 * 3600) {
111
+ throw new InstagramLoginError('exchange');
112
+ }
113
+ // Resolve the canonical account ID from /me: numeric OAuth user_id values can lose precision.
114
+ const connected = await connectInstagram(token.accessToken, undefined, true, saveOptions, token.expiresInSeconds);
115
+ if (!connected.ok)
116
+ throw new InstagramLoginError('validation');
117
+ return connected;
118
+ }
119
+ finally {
120
+ if (timer)
121
+ clearTimeout(timer);
122
+ server.close();
123
+ server.closeAllConnections();
124
+ }
125
+ }
@@ -2,4 +2,4 @@ import type { ConnectResult } from '../facebook/setup.js';
2
2
  import type { SocialConfigOptions } from '../social/profile-store.js';
3
3
  /** IGAA… = Instagram Login(신규) · EAA… = Facebook Login(FB Page + IG Business 연결 필요) */
4
4
  export declare function detectApiType(accessToken: string): string;
5
- export declare function connectInstagram(accessToken: string, userId?: string, assumeIssuedNow?: boolean, options?: SocialConfigOptions): Promise<ConnectResult>;
5
+ export declare function connectInstagram(accessToken: string, userId?: string, assumeIssuedNow?: boolean, options?: SocialConfigOptions, expiresInSeconds?: number): Promise<ConnectResult>;
@@ -8,7 +8,10 @@ const SIXTY_DAYS_MS = 60 * 24 * 60 * 60 * 1000;
8
8
  export function detectApiType(accessToken) {
9
9
  return accessToken.startsWith('IGAA') ? 'Instagram Login' : 'Facebook Login';
10
10
  }
11
- export async function connectInstagram(accessToken, userId, assumeIssuedNow = true, options = {}) {
11
+ export async function connectInstagram(accessToken, userId, assumeIssuedNow = true, options = {}, expiresInSeconds) {
12
+ if (expiresInSeconds !== undefined && (!Number.isFinite(expiresInSeconds) || expiresInSeconds <= 0 || expiresInSeconds > 5_184_000)) {
13
+ return { ok: false, text: 'Invalid Instagram token lifetime; nothing was saved.' };
14
+ }
12
15
  // 프로필 ID를 네트워크 호출 전에 검증하고, 성공 응답에 실제 저장 대상을 남긴다.
13
16
  const target = resolveSocialConfigTarget('instagram', options);
14
17
  const apiType = detectApiType(accessToken);
@@ -30,7 +33,7 @@ export async function connectInstagram(accessToken, userId, assumeIssuedNow = tr
30
33
  }
31
34
  }
32
35
  const expiresAt = assumeIssuedNow
33
- ? new Date(Date.now() + SIXTY_DAYS_MS).toISOString()
36
+ ? new Date(Date.now() + (expiresInSeconds === undefined ? SIXTY_DAYS_MS : expiresInSeconds * 1000)).toISOString()
34
37
  : undefined;
35
38
  try {
36
39
  const account = await api.getAccount({ accessToken, userId: resolvedUserId });
@@ -1,4 +1,5 @@
1
1
  export type { youtube_v3 } from 'googleapis/build/src/apis/youtube/index.js';
2
+ export type { youtubeAnalytics_v2 } from 'googleapis/build/src/apis/youtubeAnalytics/index.js';
2
3
  /**
3
4
  * 기존 `google.<api>(...)` 호출부와 100% 동일하게 동작하는 지연 로딩 네임스페이스.
4
5
  * 각 프로퍼티는 getter 라서, 실제로 그 API 를 쓰는 도구가 호출될 때까지 아무것도 로드하지 않는다.
@@ -19,4 +20,5 @@ export declare const google: {
19
20
  readonly searchconsole: typeof import("googleapis/build/src/apis/searchconsole/index.js").searchconsole;
20
21
  readonly serviceusage: typeof import("googleapis/build/src/apis/serviceusage/index.js").serviceusage;
21
22
  readonly youtube: typeof import("googleapis/build/src/apis/youtube/index.js").youtube;
23
+ readonly youtubeAnalytics: typeof import("googleapis/build/src/apis/youtubeAnalytics/index.js").youtubeAnalytics;
22
24
  };
@@ -91,4 +91,7 @@ export const google = {
91
91
  get youtube() {
92
92
  return sub('youtube').youtube;
93
93
  },
94
+ get youtubeAnalytics() {
95
+ return sub('youtubeAnalytics').youtubeAnalytics;
96
+ },
94
97
  };