@ait-co/devtools 0.1.60 → 0.1.62
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/dist/in-app/index.js +4 -0
- package/dist/in-app/index.js.map +1 -1
- package/dist/mcp/cli.js +81 -30
- package/dist/mcp/cli.js.map +1 -1
- package/dist/mcp/server.js +1 -1
- package/dist/panel/index.js +10 -4
- package/dist/panel/index.js.map +1 -1
- package/dist/{qr-http-server-Byk0Yjk_.cjs → qr-http-server-C34O140J.cjs} +26 -6
- package/dist/qr-http-server-C34O140J.cjs.map +1 -0
- package/dist/{qr-http-server-D_Aj5Vq6.cjs → qr-http-server-CR4p9Y2d.cjs} +26 -6
- package/dist/qr-http-server-CR4p9Y2d.cjs.map +1 -0
- package/dist/{qr-http-server-N4mX8GaC.js → qr-http-server-DBgh4rxe.js} +26 -6
- package/dist/qr-http-server-DBgh4rxe.js.map +1 -0
- package/dist/{qr-http-server-kYvmlXlg.js → qr-http-server-Dp3a1AMl.js} +26 -6
- package/dist/qr-http-server-Dp3a1AMl.js.map +1 -0
- package/dist/{relay-secret-store-5A7_7zOp.js → relay-secret-store-DBcKWUl9.js} +2 -2
- package/dist/{relay-secret-store-5A7_7zOp.js.map → relay-secret-store-DBcKWUl9.js.map} +1 -1
- package/dist/{relay-url-store-qaoe0zOD.js → relay-url-store-Dq3vpd95.js} +2 -2
- package/dist/{relay-url-store-qaoe0zOD.js.map → relay-url-store-Dq3vpd95.js.map} +1 -1
- package/dist/totp-CQFmgOhM.js +3 -0
- package/dist/{totp-BIrJHsQn.js → totp-D0a8VwoR.js} +1 -1
- package/dist/{totp-BIrJHsQn.js.map → totp-D0a8VwoR.js.map} +1 -1
- package/dist/{tunnel-JuZ5_Pci.js → tunnel-EVTPTwlb.js} +2 -2
- package/dist/{tunnel-JuZ5_Pci.js.map → tunnel-EVTPTwlb.js.map} +1 -1
- package/dist/{tunnel-GieyWa22.cjs → tunnel-g6assoEq.cjs} +2 -2
- package/dist/{tunnel-GieyWa22.cjs.map → tunnel-g6assoEq.cjs.map} +1 -1
- package/dist/unplugin/index.cjs +1 -1
- package/dist/unplugin/index.js +1 -1
- package/dist/unplugin/tunnel.cjs +1 -1
- package/dist/unplugin/tunnel.js +1 -1
- package/package.json +1 -1
- package/dist/qr-http-server-Byk0Yjk_.cjs.map +0 -1
- package/dist/qr-http-server-D_Aj5Vq6.cjs.map +0 -1
- package/dist/qr-http-server-N4mX8GaC.js.map +0 -1
- package/dist/qr-http-server-kYvmlXlg.js.map +0 -1
- package/dist/totp-86i_CNqh.js +0 -3
|
@@ -200,7 +200,10 @@ const en = {
|
|
|
200
200
|
"launcher.noCamera": "No camera available — paste the URL instead.",
|
|
201
201
|
"launcher.cameraError": "Could not access the camera — paste the URL instead.",
|
|
202
202
|
"launcher.invalidUrlHttps": "Enter a valid https:// URL (the tunnel URL from your terminal).",
|
|
203
|
-
"launcher.invalidUrl": "Enter a valid http(s):// URL."
|
|
203
|
+
"launcher.invalidUrl": "Enter a valid http(s):// URL.",
|
|
204
|
+
"launcher.debugAuthFailed": "Debug connection authentication failed",
|
|
205
|
+
"launcher.debugAuthFailedHint": "The QR code may have expired. Scan a fresh QR code.",
|
|
206
|
+
"launcher.debugAuthRescanCta": "Scan a new QR"
|
|
204
207
|
};
|
|
205
208
|
//#endregion
|
|
206
209
|
//#region src/i18n/index.ts
|
|
@@ -420,7 +423,10 @@ const tables = {
|
|
|
420
423
|
"launcher.noCamera": "카메라를 사용할 수 없습니다 — URL을 직접 붙여넣으세요.",
|
|
421
424
|
"launcher.cameraError": "카메라에 접근할 수 없습니다 — URL을 직접 붙여넣으세요.",
|
|
422
425
|
"launcher.invalidUrlHttps": "올바른 https:// URL을 입력하세요 (터미널의 터널 URL).",
|
|
423
|
-
"launcher.invalidUrl": "올바른 http(s):// URL을 입력하세요."
|
|
426
|
+
"launcher.invalidUrl": "올바른 http(s):// URL을 입력하세요.",
|
|
427
|
+
"launcher.debugAuthFailed": "디버그 연결 인증 실패",
|
|
428
|
+
"launcher.debugAuthFailedHint": "QR 코드가 만료되었을 수 있어요. 새 QR을 다시 스캔하세요.",
|
|
429
|
+
"launcher.debugAuthRescanCta": "새 QR 스캔하기"
|
|
424
430
|
},
|
|
425
431
|
en
|
|
426
432
|
};
|
|
@@ -519,6 +525,7 @@ img.qr {
|
|
|
519
525
|
width: min(90vw, 360px); height: auto;
|
|
520
526
|
image-rendering: pixelated;
|
|
521
527
|
background: #fff; padding: 1rem; border-radius: 12px;
|
|
528
|
+
display: block; margin: 0 auto;
|
|
522
529
|
}
|
|
523
530
|
section { width: 100%; max-width: 480px; }
|
|
524
531
|
h2 { font-size: 1rem; font-weight: 600; color: #e6edf3; margin: 0 0 0.5rem; }
|
|
@@ -531,7 +538,7 @@ li { margin-bottom: 0.4rem; font-size: 0.9rem; line-height: 1.5; }
|
|
|
531
538
|
border-radius: 6px; border: 1px solid #30363d;
|
|
532
539
|
}
|
|
533
540
|
hr { border: none; border-top: 1px solid #21262d; width: 100%; margin: 0.5rem 0; }
|
|
534
|
-
</style></head><body><h1>AIT 디버그 세션 — QR 스캔</h1><p class="label">deployment: __SAFE_LABEL__</p><img class="qr" src="__QR_DATA_URL__" alt="attach QR"
|
|
541
|
+
</style></head><body><h1>AIT 디버그 세션 — QR 스캔</h1><p class="label">deployment: __SAFE_LABEL__</p><div id="attach-section"><img class="qr" src="__QR_DATA_URL__" alt="attach QR"/></div><section><h2>스캔 절차</h2><ol><li>토스 앱을 실행하세요.</li><li>폰 카메라 앱으로 QR 코드를 스캔하세요.</li><li>팝업이 뜨면 <strong>"토스로 열기"</strong>를 탭하세요.</li><li>미니앱이 열리고 디버그 세션이 자동으로 attach됩니다.</li></ol></section><hr/><section><h2>진단 체크리스트</h2><ul><li><strong>토스 앱이 안 열리는 경우</strong> — 앱 버전 확인, 카메라 앱으로 스캔 (토스 앱 내 QR 리더 X)</li><li><strong>미니앱이 PREPARE 상태에서 멈추는 경우</strong> — deep-link에 <code>_deploymentId</code> 파라미터가 있는지 확인</li><li><strong>Chii 주입 실패 / 콘솔이 비어 있는 경우</strong> — 미니앱 번들에 <code>in-app</code> debug import가 있는지 확인</li><li><strong>TOTP gate Layer C가 비활성인 경우</strong> — relay 서버에 <code>AIT_DEBUG_TOTP_SECRET</code>이 설정돼 있는지 확인</li></ul></section><hr/><section><h2>URL (fallback)</h2><p class="url-box">__SAFE_ATTACH_URL__</p></section></body></html>`;
|
|
535
542
|
const dashboardChromeHtmlEn = `<!DOCTYPE html>
|
|
536
543
|
<html lang="en"><head><meta charSet="utf-8"/><meta name="viewport" content="width=device-width, initial-scale=1"/><title>AIT Debug Dashboard</title><style>
|
|
537
544
|
*, *::before, *::after { box-sizing: border-box; }
|
|
@@ -585,6 +592,7 @@ img.qr {
|
|
|
585
592
|
width: min(90vw, 360px); height: auto;
|
|
586
593
|
image-rendering: pixelated;
|
|
587
594
|
background: #fff; padding: 1rem; border-radius: 12px;
|
|
595
|
+
display: block; margin: 0 auto;
|
|
588
596
|
}
|
|
589
597
|
section { width: 100%; max-width: 480px; }
|
|
590
598
|
h2 { font-size: 1rem; font-weight: 600; color: #e6edf3; margin: 0 0 0.5rem; }
|
|
@@ -597,7 +605,7 @@ li { margin-bottom: 0.4rem; font-size: 0.9rem; line-height: 1.5; }
|
|
|
597
605
|
border-radius: 6px; border: 1px solid #30363d;
|
|
598
606
|
}
|
|
599
607
|
hr { border: none; border-top: 1px solid #21262d; width: 100%; margin: 0.5rem 0; }
|
|
600
|
-
</style></head><body><h1>AIT Debug Session — QR Scan</h1><p class="label">deployment: __SAFE_LABEL__</p><img class="qr" src="__QR_DATA_URL__" alt="attach QR"
|
|
608
|
+
</style></head><body><h1>AIT Debug Session — QR Scan</h1><p class="label">deployment: __SAFE_LABEL__</p><div id="attach-section"><img class="qr" src="__QR_DATA_URL__" alt="attach QR"/></div><section><h2>How to scan</h2><ol><li>Open the Toss app.</li><li>Scan the QR code with your phone camera app.</li><li>Tap <strong>"Open in Toss"</strong> when the popup appears.</li><li>The mini-app opens and the debug session attaches automatically.</li></ol></section><hr/><section><h2>Troubleshooting checklist</h2><ul><li><strong>Toss app does not open</strong> — check app version; scan with the system camera app (not the Toss in-app QR reader)</li><li><strong>Mini-app stuck in PREPARE state</strong> — verify the deep-link has a <code>_deploymentId</code> parameter</li><li><strong>Chii injection failure / console is empty</strong> — verify the mini-app bundle has an <code>in-app</code> debug import</li><li><strong>TOTP gate Layer C is inactive</strong> — check that <code>AIT_DEBUG_TOTP_SECRET</code> is set on the relay server</li></ul></section><hr/><section><h2>URL (fallback)</h2><p class="url-box">__SAFE_ATTACH_URL__</p></section></body></html>`;
|
|
601
609
|
/** Map from Locale to the precompiled dashboard chrome string. */
|
|
602
610
|
const dashboardChromeByLocale = {
|
|
603
611
|
ko: dashboardChromeHtmlKo,
|
|
@@ -734,10 +742,22 @@ function buildSseScript(strings) {
|
|
|
734
742
|
* - __SAFE_LABEL__ : HTML-escaped deploymentId label
|
|
735
743
|
* - __SAFE_ATTACH_URL__ : HTML-escaped attach URL (TOTP at= 코드 포함 — 의도된 전달)
|
|
736
744
|
*
|
|
745
|
+
* SSE 스크립트도 주입 — `#attach-section` hook이 있으면 `/events` push 때 QR이
|
|
746
|
+
* `/qr.png?u=<fresh attachUrl>`로 자동 갱신된다. `#tunnel-status`·`#pages-list` 등
|
|
747
|
+
* 나머지 selector는 /attach 페이지에 없으므로 null-guard로 no-op.
|
|
748
|
+
*
|
|
737
749
|
* SECRET-HANDLING: TOTP at= 코드는 attachUrl 캡슐 안에서만 노출 — 의도된 transport.
|
|
738
750
|
*/
|
|
739
751
|
function buildAttachHtml(qrDataUrl, safeLabel, safeAttachUrl, locale) {
|
|
740
|
-
|
|
752
|
+
const s = resolveLocaleStrings(locale);
|
|
753
|
+
const filled = attachChromeByLocale[locale].replaceAll("__QR_DATA_URL__", qrDataUrl).replaceAll("__SAFE_LABEL__", safeLabel).replaceAll("__SAFE_ATTACH_URL__", safeAttachUrl);
|
|
754
|
+
const sseScript = buildSseScript({
|
|
755
|
+
tunnelUp: JSON.stringify(s("dashboard.tunnel.up")),
|
|
756
|
+
tunnelDown: JSON.stringify(s("dashboard.tunnel.down")),
|
|
757
|
+
pagesEmpty: JSON.stringify(s("dashboard.pages.empty")),
|
|
758
|
+
attachHint: JSON.stringify(s("dashboard.attach.hint"))
|
|
759
|
+
});
|
|
760
|
+
return filled.replace("</body>", `${sseScript}\n</body>`);
|
|
741
761
|
}
|
|
742
762
|
/**
|
|
743
763
|
* 로컬 HTTP 서버를 127.0.0.1 random port(또는 `AIT_DEBUG_HTTP_PORT` env)로 시작한다.
|
|
@@ -898,4 +918,4 @@ async function startQrHttpServer(getDashboardState) {
|
|
|
898
918
|
//#endregion
|
|
899
919
|
export { startQrHttpServer };
|
|
900
920
|
|
|
901
|
-
//# sourceMappingURL=qr-http-server-
|
|
921
|
+
//# sourceMappingURL=qr-http-server-Dp3a1AMl.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"qr-http-server-Dp3a1AMl.js","names":[],"sources":["../src/i18n/en.ts","../src/i18n/ko.ts","../src/i18n/index.ts","../src/mcp/dashboard.generated.ts","../src/mcp/qr-http-server.ts"],"sourcesContent":["import type { StringKey } from './ko.js';\n\n// English translations. Mirrors every key in `ko.ts`; missing keys fall back to\n// the key string at runtime (see `t()` in index.ts), but the `Record<StringKey,\n// string>` type below means a missing key will typecheck-fail.\n\nexport const en: Record<StringKey, string> = {\n // Panel chrome\n 'panel.title': 'AIT DevTools',\n 'panel.toggle.title': 'AIT DevTools',\n 'panel.close': 'Close',\n 'panel.editMode.on': 'EDIT',\n 'panel.editMode.off': 'READ-ONLY',\n 'panel.editMode.toggleTitle': 'Toggle panel edit mode',\n 'panel.tabError': 'Error rendering \"{tab}\" tab.',\n\n // Tab names\n 'panel.tab.env': 'Environment',\n 'panel.tab.presets': 'Presets',\n 'panel.tab.viewport': 'Viewport',\n 'panel.tab.permissions': 'Permissions',\n 'panel.tab.notifications': 'Notifications',\n 'panel.tab.location': 'Location',\n 'panel.tab.device': 'Device',\n 'panel.tab.iap': 'IAP',\n 'panel.tab.ads': 'Ads',\n 'panel.tab.events': 'Events',\n 'panel.tab.analytics': 'Analytics',\n 'panel.tab.storage': 'Storage',\n\n // Common\n 'common.readOnly': 'Read-only — mock responses are controlled at build time.',\n\n // Consent toast\n 'toast.consent.title': 'Send anonymous usage stats?',\n 'toast.consent.body':\n 'We collect anonymous events only, to improve the tool. You can turn this off anytime in the Environment tab.',\n 'toast.consent.learnMore': 'Learn more',\n 'toast.consent.accept': 'Yes, send',\n 'toast.consent.deny': 'No, thanks',\n\n // Environment tab\n 'env.section.platform': 'Platform',\n 'env.row.os': 'OS',\n 'env.row.appVersion': 'App Version',\n 'env.row.environment': 'Environment',\n 'env.row.locale': 'Locale',\n 'env.section.network': 'Network',\n 'env.row.networkStatus': 'Status',\n 'env.section.safeArea': 'Safe Area Insets',\n 'env.row.safeArea.top': 'Top',\n 'env.row.safeArea.bottom': 'Bottom',\n 'env.section.navigation': 'Navigation',\n 'env.row.iosSwipeGesture': 'iOS swipe-back',\n 'env.value.iosSwipeGesture.unset': 'not called',\n 'env.value.iosSwipeGesture.enabled': 'enabled',\n 'env.value.iosSwipeGesture.disabled': 'disabled',\n 'env.hint.iosSwipeGesture':\n 'Last value passed to setIosSwipeGestureEnabled. Switching Environment to toss lets a toss-gated guard toggle this.',\n\n // Environment > Telemetry section\n 'env.telemetry.section': 'Telemetry',\n // Tier 0 — opt-out anonymous signal\n 'env.telemetry.t0Row': 'Anonymous usage signal (Tier 0)',\n 'env.telemetry.t0On': 'On',\n 'env.telemetry.t0Off': 'Off',\n 'env.telemetry.t0TurnOn': 'Turn on',\n 'env.telemetry.t0TurnOff': 'Turn off',\n 'env.telemetry.t0Desc': 'Version + date only, no PII. Once per day. Helps improve the package.',\n // Tier 1 — opt-in extended telemetry\n 'env.telemetry.row': 'Extended telemetry (Tier 1)',\n 'env.telemetry.on': 'On',\n 'env.telemetry.off': 'Off',\n 'env.telemetry.turnOn': 'Turn on',\n 'env.telemetry.turnOff': 'Turn off',\n 'env.telemetry.anonIdLabel': 'anon_id: {value}',\n 'env.telemetry.anonIdNotSet': '(not yet set)',\n 'env.telemetry.anonIdCopyTitle': 'Click to copy full anon_id',\n 'env.telemetry.deleteBtn': 'Delete my data',\n 'env.telemetry.deleting': 'Deleting…',\n 'env.telemetry.deleted': 'Deleted',\n 'env.telemetry.deleteFailedRetry': 'Delete failed (please retry)',\n 'env.telemetry.deleteFailed': 'Delete failed',\n 'env.telemetry.privacyLink': 'Privacy policy →',\n\n // Environment > Language toggle (new)\n 'env.section.language': 'Language',\n 'env.language.row': 'Language',\n 'env.language.ko': '한국어',\n 'env.language.en': 'English',\n\n // Permissions tab\n 'permissions.section.device': 'Device Permissions',\n\n // Location tab\n 'location.section.current': 'Current Location',\n 'location.row.latitude': 'Latitude',\n 'location.row.longitude': 'Longitude',\n 'location.row.accuracy': 'Accuracy',\n\n // Device tab\n 'device.section.modes': 'Device API Modes',\n 'device.row.camera': 'Camera',\n 'device.row.photos': 'Photos',\n 'device.row.location': 'Location',\n 'device.row.network': 'Network',\n 'device.row.clipboard': 'Clipboard',\n 'device.section.mockImages': 'Mock Images ({count})',\n 'device.btn.add': '+ Add',\n 'device.btn.useDefaults': 'Use defaults',\n 'device.btn.clear': 'Clear',\n 'device.prompt.camera.title': 'Camera Prompt — Select an image',\n 'device.prompt.photos.title': 'Photos Prompt — Select images',\n 'device.prompt.location.title': 'Location Prompt — Enter coordinates',\n 'device.prompt.locationUpdate.title': 'Location Update — Send coordinates',\n 'device.prompt.fallbackTitle': 'Prompt: {type}',\n 'device.prompt.label.lat': 'Lat',\n 'device.prompt.label.lng': 'Lng',\n 'device.prompt.send': 'Send',\n 'device.prompt.cancel': 'Cancel',\n\n // Device tab — Haptic section\n 'device.section.haptic': 'Haptic',\n 'device.haptic.lastCall': 'Last haptic',\n 'device.haptic.noneYet': '(none yet)',\n 'device.haptic.trigger': 'Trigger haptic',\n\n // Viewport tab\n 'viewport.section.device': 'Device',\n 'viewport.row.preset': 'Preset',\n 'viewport.row.orientation': 'Orientation',\n 'viewport.row.notchSide': 'Notch side',\n 'viewport.section.custom': 'Custom size',\n 'viewport.row.width': 'Width (px)',\n 'viewport.row.height': 'Height (px)',\n 'viewport.section.appearance': 'Appearance',\n 'viewport.row.showFrame': 'Show frame',\n 'viewport.row.showAitNavBar': 'Show Apps in Toss nav bar',\n 'viewport.row.navBarType': 'Nav bar type',\n 'viewport.status.noConstraint': 'No viewport constraint — body fills the window.',\n 'viewport.status.cssPhysical': 'CSS / physical',\n 'viewport.status.safeArea': 'Safe area',\n 'viewport.status.aitNavBar': 'AIT nav bar',\n 'viewport.status.aitNavBarValue': '{height}px → SafeArea top · {type}',\n 'viewport.orientation.autoSuffix': '{orient} (auto)',\n\n // IAP tab\n 'iap.section.simulator': 'IAP Simulator',\n 'iap.row.nextResult': 'Next Purchase Result',\n 'iap.section.tossPay': 'TossPay',\n 'iap.row.tossPayResult': 'Next Payment Result',\n 'iap.section.pending': 'Pending Orders ({count})',\n 'iap.empty.pending': '(no pending orders)',\n 'iap.section.completed': 'Completed Orders ({count})',\n 'iap.empty.completed': '(no completed orders)',\n 'iap.btn.complete': 'Complete',\n 'iap.label.pending': 'PENDING',\n\n // Events tab\n 'events.section.navigation': 'Navigation Events',\n 'events.btn.triggerBack': 'Trigger Back Event',\n 'events.btn.triggerHome': 'Trigger Home Event',\n 'events.section.login': 'Login',\n 'events.row.loggedIn': 'Logged In',\n 'events.row.tossLoginIntegrated': 'Toss Login Integrated',\n\n // Analytics tab\n 'analytics.section.log': 'Analytics Log ({count})',\n 'analytics.btn.clear': 'Clear',\n 'analytics.calls.section': 'SDK Calls ({count})',\n 'analytics.calls.btn.clear': 'Clear',\n 'analytics.calls.empty': '(no SDK calls yet)',\n\n // Storage tab\n 'storage.section.title': 'Storage ({count} items)',\n 'storage.btn.clearAll': 'Clear All',\n 'storage.empty': 'No items in storage',\n\n // Presets tab\n 'presets.section.builtIn': 'Built-in scenarios',\n 'presets.section.saved': 'Saved presets ({count})',\n 'presets.section.save': 'Save',\n 'presets.save.description': 'Capture network / permissions / auth / IAP / ads / payment slices.',\n 'presets.btn.saveCurrent': 'Save current as preset',\n 'presets.btn.apply': 'Apply',\n 'presets.btn.reApply': 'Re-apply',\n 'presets.btn.delete': 'Delete',\n 'presets.empty.saved': 'No saved presets yet.',\n 'presets.empty.builtIn': 'No built-in presets.',\n 'presets.prompt.label': 'Preset label?',\n 'presets.confirm.delete': 'Delete preset \"{label}\"?',\n\n // Ads tab\n 'ads.section.state': 'Ads State',\n 'ads.row.isLoaded': 'isLoaded',\n 'ads.row.forceNoFill': 'Force \"no fill\"',\n 'ads.empty.events': 'No events yet',\n 'ads.section.googleAdMob': 'GoogleAdMob',\n 'ads.section.tossAds': 'TossAds',\n 'ads.section.fullScreenAd': 'FullScreenAd',\n 'ads.btn.load': 'Load',\n 'ads.btn.show': 'Show',\n 'ads.section.tossAdsBanner': 'TossAds Banner',\n 'ads.row.rewardUnitType': 'Reward unit type',\n 'ads.row.rewardAmount': 'Reward amount',\n 'ads.btn.render': 'Render',\n 'ads.btn.noFill': 'No-fill',\n 'ads.btn.click': 'Click',\n 'ads.btn.destroy': 'Destroy',\n\n // Notifications tab\n 'notifications.section.title': 'requestNotificationAgreement',\n 'notifications.option.newAgreement': 'newAgreement (first-time agree)',\n 'notifications.option.alreadyAgreed': 'alreadyAgreed (already opted-in)',\n 'notifications.option.agreementRejected': 'agreementRejected (user declined)',\n\n // qr-http-server — dashboard page (server-side, Node, per-request)\n 'dashboard.title': 'AIT Debug Dashboard',\n 'dashboard.updated': 'Last updated: {ts}',\n 'dashboard.tunnel.section': 'Tunnel status',\n 'dashboard.tunnel.up': 'Connected',\n 'dashboard.tunnel.down': 'Disconnected',\n 'dashboard.attach.section': 'Attach QR',\n 'dashboard.attach.hint': 'Call the build_attach_url MCP tool to show the QR here.',\n 'dashboard.pages.section': 'Connected Pages',\n 'dashboard.pages.empty': 'No attached pages',\n\n // qr-http-server — attach page (server-side, Node, per-request)\n 'attach.title': 'AIT Debug Session — QR Scan',\n 'attach.deployment': 'deployment: {label}',\n 'attach.steps.section': 'How to scan',\n 'attach.step1': 'Open the Toss app.',\n 'attach.step2': 'Scan the QR code with your phone camera app.',\n 'attach.step3': 'Tap <strong>\"Open in Toss\"</strong> when the popup appears.',\n 'attach.step4': 'The mini-app opens and the debug session attaches automatically.',\n 'attach.faq.section': 'Troubleshooting checklist',\n 'attach.faq.appNotOpen':\n '<strong>Toss app does not open</strong> — check app version; scan with the system camera app (not the Toss in-app QR reader)',\n 'attach.faq.prepare':\n '<strong>Mini-app stuck in PREPARE state</strong> — verify the deep-link has a <code>_deploymentId</code> parameter',\n 'attach.faq.chii':\n '<strong>Chii injection failure / console is empty</strong> — verify the mini-app bundle has an <code>in-app</code> debug import',\n 'attach.faq.totp':\n '<strong>TOTP gate Layer C is inactive</strong> — check that <code>AIT_DEBUG_TOTP_SECRET</code> is set on the relay server',\n 'attach.url.section': 'URL (fallback)',\n\n // Launcher PWA\n 'launcher.title': 'AITC DevTools Launcher',\n 'launcher.description': 'Scan the terminal QR code or paste the tunnel URL.',\n 'launcher.installCta': 'Install launcher to your phone',\n 'launcher.urlPlaceholder': 'https://example.trycloudflare.com',\n 'launcher.openBtn': 'Open',\n 'launcher.scanBtn': 'Scan QR with camera',\n 'launcher.rescanBtn': 'Rescan',\n 'launcher.noCamera': 'No camera available — paste the URL instead.',\n 'launcher.cameraError': 'Could not access the camera — paste the URL instead.',\n 'launcher.invalidUrlHttps': 'Enter a valid https:// URL (the tunnel URL from your terminal).',\n 'launcher.invalidUrl': 'Enter a valid http(s):// URL.',\n 'launcher.debugAuthFailed': 'Debug connection authentication failed',\n 'launcher.debugAuthFailedHint': 'The QR code may have expired. Scan a fresh QR code.',\n 'launcher.debugAuthRescanCta': 'Scan a new QR',\n};\n","// Korean string catalog (source of truth — keys are typed from this file).\n// Keys follow `<area>.<purpose>` convention. Variable interpolation uses\n// `{name}` placeholders resolved by `t(key, { name: value })`.\n//\n// Some chrome (button labels like \"Load\", \"Show\", \"Clear\", \"Apply\") is left as\n// English in both locales because the panel is an internal devtools surface\n// and these terms are universally recognised by developers in both locales.\n\nexport const ko = {\n // Panel chrome\n 'panel.title': 'AIT DevTools',\n 'panel.toggle.title': 'AIT DevTools',\n 'panel.close': 'Close',\n 'panel.editMode.on': 'EDIT',\n 'panel.editMode.off': 'READ-ONLY',\n 'panel.editMode.toggleTitle': '패널 편집 모드 전환',\n 'panel.tabError': '\"{tab}\" 탭 렌더링 중 오류가 발생했습니다.',\n\n // Tab names\n 'panel.tab.env': 'Environment',\n 'panel.tab.presets': 'Presets',\n 'panel.tab.viewport': 'Viewport',\n 'panel.tab.permissions': 'Permissions',\n 'panel.tab.notifications': 'Notifications',\n 'panel.tab.location': 'Location',\n 'panel.tab.device': 'Device',\n 'panel.tab.iap': 'IAP',\n 'panel.tab.ads': 'Ads',\n 'panel.tab.events': 'Events',\n 'panel.tab.analytics': 'Analytics',\n 'panel.tab.storage': 'Storage',\n\n // Common\n 'common.readOnly': '읽기 전용 — mock 응답은 빌드 타임에 고정됩니다.',\n\n // Consent toast\n 'toast.consent.title': '익명 사용 통계를 보낼까요?',\n 'toast.consent.body': '도구 개선을 위해 익명 이벤트만 수집해요. 언제든 환경 탭에서 끌 수 있어요.',\n 'toast.consent.learnMore': '더 알아보기',\n 'toast.consent.accept': '네, 보낼게요',\n 'toast.consent.deny': '아니요',\n\n // Environment tab\n 'env.section.platform': 'Platform',\n 'env.row.os': 'OS',\n 'env.row.appVersion': 'App Version',\n 'env.row.environment': 'Environment',\n 'env.row.locale': 'Locale',\n 'env.section.network': 'Network',\n 'env.row.networkStatus': 'Status',\n 'env.section.safeArea': 'Safe Area Insets',\n 'env.row.safeArea.top': 'Top',\n 'env.row.safeArea.bottom': 'Bottom',\n 'env.section.navigation': 'Navigation',\n 'env.row.iosSwipeGesture': 'iOS swipe-back',\n 'env.value.iosSwipeGesture.unset': '미호출',\n 'env.value.iosSwipeGesture.enabled': 'enabled',\n 'env.value.iosSwipeGesture.disabled': 'disabled',\n 'env.hint.iosSwipeGesture':\n 'setIosSwipeGestureEnabled의 마지막 호출값. Environment를 toss로 바꾸면 toss-gated 가드가 이 값을 토글합니다.',\n\n // Environment > Telemetry section\n 'env.telemetry.section': 'Telemetry',\n // Tier 0 — opt-out anonymous signal\n 'env.telemetry.t0Row': '익명 사용 신호 (Tier 0)',\n 'env.telemetry.t0On': 'On',\n 'env.telemetry.t0Off': 'Off',\n 'env.telemetry.t0TurnOn': 'Turn on',\n 'env.telemetry.t0TurnOff': 'Turn off',\n 'env.telemetry.t0Desc': '버전·날짜만 수집, PII 없음. 하루 1회. 패키지 개선에 사용됩니다.',\n // Tier 1 — opt-in extended telemetry\n 'env.telemetry.row': '확장 텔레메트리 (Tier 1)',\n 'env.telemetry.on': 'On',\n 'env.telemetry.off': 'Off',\n 'env.telemetry.turnOn': 'Turn on',\n 'env.telemetry.turnOff': 'Turn off',\n 'env.telemetry.anonIdLabel': 'anon_id: {value}',\n 'env.telemetry.anonIdNotSet': '(not yet set)',\n 'env.telemetry.anonIdCopyTitle': '전체 anon_id 복사',\n 'env.telemetry.deleteBtn': '내 데이터 삭제',\n 'env.telemetry.deleting': '삭제 중…',\n 'env.telemetry.deleted': '삭제 완료',\n 'env.telemetry.deleteFailedRetry': '삭제 실패 (다시 시도해주세요)',\n 'env.telemetry.deleteFailed': '삭제 실패',\n 'env.telemetry.privacyLink': '개인정보 처리방침 →',\n\n // Environment > Language toggle (new)\n 'env.section.language': 'Language',\n 'env.language.row': 'Language',\n 'env.language.ko': '한국어',\n 'env.language.en': 'English',\n\n // Permissions tab\n 'permissions.section.device': 'Device Permissions',\n\n // Location tab\n 'location.section.current': 'Current Location',\n 'location.row.latitude': 'Latitude',\n 'location.row.longitude': 'Longitude',\n 'location.row.accuracy': 'Accuracy',\n\n // Device tab\n 'device.section.modes': 'Device API Modes',\n 'device.row.camera': 'Camera',\n 'device.row.photos': 'Photos',\n 'device.row.location': 'Location',\n 'device.row.network': 'Network',\n 'device.row.clipboard': 'Clipboard',\n 'device.section.mockImages': 'Mock Images ({count})',\n 'device.btn.add': '+ Add',\n 'device.btn.useDefaults': 'Use defaults',\n 'device.btn.clear': 'Clear',\n 'device.prompt.camera.title': 'Camera Prompt — 이미지를 선택하세요',\n 'device.prompt.photos.title': 'Photos Prompt — 이미지를 선택하세요',\n 'device.prompt.location.title': 'Location Prompt — 좌표 입력',\n 'device.prompt.locationUpdate.title': 'Location Update — 좌표 전송',\n 'device.prompt.fallbackTitle': 'Prompt: {type}',\n 'device.prompt.label.lat': 'Lat',\n 'device.prompt.label.lng': 'Lng',\n 'device.prompt.send': 'Send',\n 'device.prompt.cancel': 'Cancel',\n\n // Device tab — Haptic section\n 'device.section.haptic': 'Haptic',\n 'device.haptic.lastCall': '마지막 haptic',\n 'device.haptic.noneYet': '(아직 없음)',\n 'device.haptic.trigger': 'Haptic 트리거',\n\n // Viewport tab\n 'viewport.section.device': 'Device',\n 'viewport.row.preset': 'Preset',\n 'viewport.row.orientation': 'Orientation',\n 'viewport.row.notchSide': 'Notch side',\n 'viewport.section.custom': 'Custom size',\n 'viewport.row.width': 'Width (px)',\n 'viewport.row.height': 'Height (px)',\n 'viewport.section.appearance': 'Appearance',\n 'viewport.row.showFrame': 'Show frame',\n 'viewport.row.showAitNavBar': 'Apps in Toss 내비게이션 바 표시',\n 'viewport.row.navBarType': 'Nav bar type',\n 'viewport.status.noConstraint': '뷰포트 제약 없음 — body가 창을 가득 채웁니다.',\n 'viewport.status.cssPhysical': 'CSS / physical',\n 'viewport.status.safeArea': 'Safe area',\n 'viewport.status.aitNavBar': 'AIT nav bar',\n 'viewport.status.aitNavBarValue': '{height}px → SafeArea top · {type}',\n 'viewport.orientation.autoSuffix': '{orient} (auto)',\n\n // IAP tab\n 'iap.section.simulator': 'IAP Simulator',\n 'iap.row.nextResult': 'Next Purchase Result',\n 'iap.section.tossPay': 'TossPay',\n 'iap.row.tossPayResult': 'Next Payment Result',\n 'iap.section.pending': 'Pending Orders ({count})',\n 'iap.empty.pending': '(대기 중인 주문 없음)',\n 'iap.section.completed': 'Completed Orders ({count})',\n 'iap.empty.completed': '(완료된 주문 없음)',\n 'iap.btn.complete': 'Complete',\n 'iap.label.pending': 'PENDING',\n\n // Events tab\n 'events.section.navigation': 'Navigation Events',\n 'events.btn.triggerBack': 'Back 이벤트 발생',\n 'events.btn.triggerHome': 'Home 이벤트 발생',\n 'events.section.login': 'Login',\n 'events.row.loggedIn': 'Logged In',\n 'events.row.tossLoginIntegrated': 'Toss Login Integrated',\n\n // Analytics tab\n 'analytics.section.log': 'Analytics Log ({count})',\n 'analytics.btn.clear': 'Clear',\n 'analytics.calls.section': 'SDK Calls ({count})',\n 'analytics.calls.btn.clear': 'Clear',\n 'analytics.calls.empty': '(아직 SDK 호출 없음)',\n\n // Storage tab\n 'storage.section.title': 'Storage ({count} items)',\n 'storage.btn.clearAll': 'Clear All',\n 'storage.empty': '저장된 항목이 없습니다',\n\n // Presets tab\n 'presets.section.builtIn': 'Built-in scenarios',\n 'presets.section.saved': 'Saved presets ({count})',\n 'presets.section.save': 'Save',\n 'presets.save.description':\n 'network / permissions / auth / IAP / ads / payment 슬라이스를 캡처합니다.',\n 'presets.btn.saveCurrent': '현재 상태를 프리셋으로 저장',\n 'presets.btn.apply': 'Apply',\n 'presets.btn.reApply': 'Re-apply',\n 'presets.btn.delete': 'Delete',\n 'presets.empty.saved': '저장된 프리셋이 아직 없습니다.',\n 'presets.empty.builtIn': '내장 프리셋이 없습니다.',\n 'presets.prompt.label': '프리셋 라벨을 입력하세요',\n 'presets.confirm.delete': '\"{label}\" 프리셋을 삭제할까요?',\n\n // Ads tab\n 'ads.section.state': 'Ads State',\n 'ads.row.isLoaded': 'isLoaded',\n 'ads.row.forceNoFill': '강제 \"no fill\"',\n 'ads.empty.events': '아직 이벤트가 없습니다',\n 'ads.section.googleAdMob': 'GoogleAdMob',\n 'ads.section.tossAds': 'TossAds',\n 'ads.section.fullScreenAd': 'FullScreenAd',\n 'ads.btn.load': 'Load',\n 'ads.btn.show': 'Show',\n 'ads.section.tossAdsBanner': 'TossAds 배너',\n 'ads.row.rewardUnitType': '리워드 단위 타입',\n 'ads.row.rewardAmount': '리워드 수량',\n 'ads.btn.render': 'Render',\n 'ads.btn.noFill': 'No-fill',\n 'ads.btn.click': 'Click',\n 'ads.btn.destroy': 'Destroy',\n\n // Notifications tab\n 'notifications.section.title': 'requestNotificationAgreement',\n 'notifications.option.newAgreement': 'newAgreement (최초 동의)',\n 'notifications.option.alreadyAgreed': 'alreadyAgreed (이미 동의됨)',\n 'notifications.option.agreementRejected': 'agreementRejected (사용자 거절)',\n\n // qr-http-server — dashboard page (server-side, Node, per-request)\n 'dashboard.title': 'AIT 디버그 Dashboard',\n 'dashboard.updated': '마지막 갱신: {ts}',\n 'dashboard.tunnel.section': '터널 상태',\n 'dashboard.tunnel.up': '연결됨',\n 'dashboard.tunnel.down': '끊어짐',\n 'dashboard.attach.section': 'Attach QR',\n 'dashboard.attach.hint': 'build_attach_url MCP tool을 호출하면 QR이 여기에 표시됩니다.',\n 'dashboard.pages.section': '연결된 Pages',\n 'dashboard.pages.empty': 'attach된 페이지 없음',\n\n // qr-http-server — attach page (server-side, Node, per-request)\n 'attach.title': 'AIT 디버그 세션 — QR 스캔',\n 'attach.deployment': 'deployment: {label}',\n 'attach.steps.section': '스캔 절차',\n 'attach.step1': '토스 앱을 실행하세요.',\n 'attach.step2': '폰 카메라 앱으로 QR 코드를 스캔하세요.',\n 'attach.step3': '팝업이 뜨면 <strong>\"토스로 열기\"</strong>를 탭하세요.',\n 'attach.step4': '미니앱이 열리고 디버그 세션이 자동으로 attach됩니다.',\n 'attach.faq.section': '진단 체크리스트',\n 'attach.faq.appNotOpen':\n '<strong>토스 앱이 안 열리는 경우</strong> — 앱 버전 확인, 카메라 앱으로 스캔 (토스 앱 내 QR 리더 X)',\n 'attach.faq.prepare':\n '<strong>미니앱이 PREPARE 상태에서 멈추는 경우</strong> — deep-link에 <code>_deploymentId</code> 파라미터가 있는지 확인',\n 'attach.faq.chii':\n '<strong>Chii 주입 실패 / 콘솔이 비어 있는 경우</strong> — 미니앱 번들에 <code>in-app</code> debug import가 있는지 확인',\n 'attach.faq.totp':\n '<strong>TOTP gate Layer C가 비활성인 경우</strong> — relay 서버에 <code>AIT_DEBUG_TOTP_SECRET</code>이 설정돼 있는지 확인',\n 'attach.url.section': 'URL (fallback)',\n\n // Launcher PWA\n 'launcher.title': 'AITC DevTools Launcher',\n 'launcher.description': '터미널 QR을 스캔하거나 URL을 입력하세요.',\n 'launcher.installCta': '폰에 런처 설치하기',\n 'launcher.urlPlaceholder': 'https://example.trycloudflare.com',\n 'launcher.openBtn': 'Open',\n 'launcher.scanBtn': 'QR 카메라로 스캔',\n 'launcher.rescanBtn': 'Rescan',\n 'launcher.noCamera': '카메라를 사용할 수 없습니다 — URL을 직접 붙여넣으세요.',\n 'launcher.cameraError': '카메라에 접근할 수 없습니다 — URL을 직접 붙여넣으세요.',\n 'launcher.invalidUrlHttps': '올바른 https:// URL을 입력하세요 (터미널의 터널 URL).',\n 'launcher.invalidUrl': '올바른 http(s):// URL을 입력하세요.',\n 'launcher.debugAuthFailed': '디버그 연결 인증 실패',\n 'launcher.debugAuthFailedHint': 'QR 코드가 만료되었을 수 있어요. 새 QR을 다시 스캔하세요.',\n 'launcher.debugAuthRescanCta': '새 QR 스캔하기',\n} as const;\n\nexport type StringKey = keyof typeof ko;\n","/**\n * Vanilla TS i18n for the floating DevTools panel.\n *\n * Public surface:\n * - `t(key, vars?)` — look up a UI string, with `{name}` placeholder\n * interpolation. Falls back to the key itself if a translation is missing.\n * - `getLocale()` / `setLocale(locale)` — read/persist the active locale.\n * `setLocale` dispatches `__ait:localechange` so the panel can remount.\n * - `detectLocale()` — first-run heuristic from `navigator.language`.\n *\n * `ko` is the source of truth (keys are typed from it). `en` is also a full\n * `Record<StringKey, string>` (devtools is developer-facing, en is a real\n * audience). The `Partial` lookup table preserves the runtime `?? key` safety\n * net even though we ship complete catalogs today.\n */\n\nimport { en } from './en.js';\nimport { ko, type StringKey } from './ko.js';\n\nexport type Locale = 'ko' | 'en';\n\nconst LOCALE_STORAGE_KEY = '__ait_locale';\nconst LOCALE_CHANGE_EVENT = '__ait:localechange';\n\nconst tables: Record<Locale, Partial<Record<StringKey, string>>> = { ko, en };\n\nlet currentLocale: Locale | null = null;\n\nfunction safeReadStorage(): Locale | null {\n if (typeof localStorage === 'undefined') return null;\n try {\n const raw = localStorage.getItem(LOCALE_STORAGE_KEY);\n if (raw === 'ko' || raw === 'en') return raw;\n } catch {\n /* localStorage can throw in privacy modes — fall back silently */\n }\n return null;\n}\n\nfunction safeWriteStorage(locale: Locale): void {\n if (typeof localStorage === 'undefined') return;\n try {\n localStorage.setItem(LOCALE_STORAGE_KEY, locale);\n } catch {\n /* ignore quota / privacy errors */\n }\n}\n\n/**\n * Decide a locale from a BCP-47 language tag. `ko` (and `ko-*`) → `'ko'`,\n * everything else → `'en'`. Shared by the browser (`navigator.language`) and\n * Node (`Accept-Language` header) paths so both resolve identically.\n */\nfunction localeFromLanguageTag(lang: string): Locale {\n return /^ko\\b/i.test(lang) ? 'ko' : 'en';\n}\n\n/**\n * Read `navigator.language` and decide a locale. `ko` (and `ko-*`) → `'ko'`,\n * everything else → `'en'`. Pure function; does not touch storage.\n */\nexport function detectLocale(): Locale {\n if (typeof navigator === 'undefined') return 'en';\n return localeFromLanguageTag(navigator.language ?? '');\n}\n\n/**\n * Decide a locale from an HTTP `Accept-Language` header value. The Node-served\n * surfaces (e.g. the qr-http-server dashboard) have no `navigator`, so the\n * request header is the only language signal. Reads the FIRST language tag\n * (highest priority, ignoring `q=` weights — good enough for ko/en) and feeds\n * it through the same `ko`-vs-`en` heuristic `detectLocale` uses. Returns `'en'`\n * for an empty/missing header.\n */\nexport function parseAcceptLanguage(header: string | undefined | null): Locale {\n if (!header) return 'en';\n const first = header.split(',')[0]?.trim().split(';')[0]?.trim() ?? '';\n return localeFromLanguageTag(first);\n}\n\n/**\n * A locale-bound string resolver for surfaces that can't use the in-memory\n * `getLocale()` cache — notably the Node HTTP server, which resolves locale\n * per-request from `Accept-Language` rather than from a process-global. Returns\n * a `t`-compatible closure over the SAME `ko`/`en` tables (single source of\n * truth), so the dashboard/attach HTML shares the exact 169-key catalog the\n * browser surfaces use. The `key: StringKey` signature keeps compile-time key\n * safety on the Node path identical to `t()`.\n */\nexport function resolveLocaleStrings(\n locale: Locale,\n): (key: StringKey, vars?: Record<string, string | number>) => string {\n const table = tables[locale];\n return (key, vars) => {\n const raw = table[key] ?? key;\n if (!vars) return raw;\n return raw.replace(/\\{(\\w+)\\}/g, (match, name: string) => {\n const value = vars[name];\n return value === undefined ? match : String(value);\n });\n };\n}\n\n/**\n * Resolve the active locale, in order:\n * 1. previously set in-memory value (set by `setLocale`)\n * 2. localStorage `__ait_locale`\n * 3. `detectLocale()` from navigator\n */\nexport function getLocale(): Locale {\n if (currentLocale) return currentLocale;\n const stored = safeReadStorage();\n currentLocale = stored ?? detectLocale();\n return currentLocale;\n}\n\n/**\n * Persist a locale choice and notify listeners. The panel listens for\n * `__ait:localechange` and re-mounts so every string re-evaluates.\n */\nexport function setLocale(locale: Locale): void {\n currentLocale = locale;\n safeWriteStorage(locale);\n if (typeof window !== 'undefined') {\n window.dispatchEvent(new CustomEvent(LOCALE_CHANGE_EVENT));\n }\n}\n\n/**\n * Look up a UI string for the current locale. Falls back to the key if missing,\n * so a forgotten key surfaces visibly rather than rendering empty.\n */\nexport function t(key: StringKey, vars?: Record<string, string | number>): string {\n const raw = tables[getLocale()][key] ?? key;\n if (!vars) return raw;\n return raw.replace(/\\{(\\w+)\\}/g, (match, name: string) => {\n const value = vars[name];\n return value === undefined ? match : String(value);\n });\n}\n\nexport type { StringKey };\nexport { LOCALE_CHANGE_EVENT, LOCALE_STORAGE_KEY };\n\n/**\n * Test-only escape hatch — resets the cached in-memory locale so subsequent\n * `getLocale()` calls re-read storage / re-detect. Production code never needs\n * this; tests use it between cases.\n */\nexport function _resetLocaleCacheForTests(): void {\n currentLocale = null;\n}\n","/**\n * dashboard.generated.ts\n *\n * AUTO-GENERATED by scripts/build-dashboard-html.ts — DO NOT EDIT BY HAND.\n * Regenerate: pnpm build:dashboard-html\n *\n * Exports precompiled HTML chrome strings for each locale. Per-request\n * dynamic values are inserted by qr-http-server.ts at runtime via simple\n * string replacement of __PLACEHOLDER__ tokens.\n *\n * Token map (dashboard chrome):\n * __TUNNEL_CLASS__ CSS class: \"status-up\" | \"status-down\"\n * __TUNNEL_STATUS__ localised tunnel status label\n * __ATTACH_SECTION__ QR img+url-box HTML, or hint text\n * __PAGES_SECTION__ pages <section> block, or empty string\n * __NOW__ ISO timestamp of current render\n *\n * Token map (attach chrome):\n * __QR_DATA_URL__ base64 data URL for the QR image\n * __SAFE_LABEL__ HTML-escaped deploymentId label\n * __SAFE_ATTACH_URL__ HTML-escaped attach URL\n *\n * SECRET-HANDLING: wssUrl MUST NOT appear here. If it does, the build script's\n * assertion would have caught it — this file should be react-free and secret-free.\n */\n\nimport type { Locale } from '../i18n/index.js';\n\n// ── locale: ko ──────────────────────────────────────────────────────\n\nexport const dashboardChromeHtmlKo =\n`<!DOCTYPE html>\n<html lang=\"ko\"><head><meta charSet=\"utf-8\"/><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"/><title>AIT 디버그 Dashboard</title><style>\n*, *::before, *::after { box-sizing: border-box; }\nbody {\n font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", sans-serif;\n background: #0d1117; color: #c9d1d9;\n display: flex; flex-direction: column; align-items: center;\n min-height: 100vh; margin: 0; padding: 2rem 1rem;\n gap: 1.5rem;\n}\nh1 { font-size: 1.25rem; font-weight: 600; color: #e6edf3; margin: 0; text-align: center; }\n.updated { font-size: 0.75rem; opacity: 0.4; font-family: monospace; margin: 0; }\nsection { width: 100%; max-width: 520px; }\nh2 { font-size: 1rem; font-weight: 600; color: #e6edf3; margin: 0 0 0.5rem; }\n.status { display: inline-block; padding: 0.25rem 0.75rem; border-radius: 999px; font-size: 0.8rem; font-weight: 600; }\n.status-up { background: #238636; color: #fff; }\n.status-down { background: #6e7681; color: #fff; }\nimg.qr {\n width: min(80vw, 300px); height: auto;\n image-rendering: pixelated;\n background: #fff; padding: 0.75rem; border-radius: 10px;\n display: block; margin: 0.5rem auto;\n}\n.url-box {\n font-family: monospace; font-size: 0.7rem;\n word-break: break-all; opacity: 0.45;\n background: #161b22; padding: 0.6rem 0.85rem;\n border-radius: 6px; border: 1px solid #30363d; margin: 0.5rem 0 0;\n}\n.hint { font-size: 0.85rem; opacity: 0.5; margin: 0.25rem 0 0; }\nul { margin: 0; padding-left: 1.25rem; }\nli { margin-bottom: 0.35rem; font-size: 0.85rem; line-height: 1.5; }\nli.empty { opacity: 0.4; list-style: none; padding-left: 0; }\n.page-id { font-family: monospace; font-size: 0.75rem; opacity: 0.5; margin-right: 0.4rem; }\n.page-url { word-break: break-all; }\nhr { border: none; border-top: 1px solid #21262d; width: 100%; margin: 0; }\n</style></head><body><h1>AIT 디버그 Dashboard</h1><p class=\"updated\" id=\"updated\">마지막 갱신: __NOW__</p><section><h2>터널 상태</h2><span class=\"status __TUNNEL_CLASS__\" id=\"tunnel-status\">__TUNNEL_STATUS__</span></section><hr/><section><h2>Attach QR</h2><div id=\"attach-section\">__ATTACH_SECTION__</div></section>__PAGES_SECTION__</body></html>`;\n\nexport const attachChromeHtmlKo =\n`<!DOCTYPE html>\n<html lang=\"ko\"><head><meta charSet=\"utf-8\"/><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"/><link rel=\"preload\" as=\"image\" href=\"__QR_DATA_URL__\"/><title>AIT 디버그 세션 — QR 스캔</title><style>\n*, *::before, *::after { box-sizing: border-box; }\nbody {\n font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", sans-serif;\n background: #0d1117; color: #c9d1d9;\n display: flex; flex-direction: column; align-items: center;\n min-height: 100vh; margin: 0; padding: 2rem 1rem;\n gap: 1.5rem;\n}\nh1 { font-size: 1.25rem; font-weight: 600; color: #e6edf3; margin: 0; text-align: center; }\n.label { font-size: 0.8rem; opacity: 0.5; font-family: monospace; margin: 0; }\nimg.qr {\n width: min(90vw, 360px); height: auto;\n image-rendering: pixelated;\n background: #fff; padding: 1rem; border-radius: 12px;\n display: block; margin: 0 auto;\n}\nsection { width: 100%; max-width: 480px; }\nh2 { font-size: 1rem; font-weight: 600; color: #e6edf3; margin: 0 0 0.5rem; }\nol, ul { margin: 0; padding-left: 1.25rem; }\nli { margin-bottom: 0.4rem; font-size: 0.9rem; line-height: 1.5; }\n.url-box {\n font-family: monospace; font-size: 0.72rem;\n word-break: break-all; opacity: 0.4;\n background: #161b22; padding: 0.75rem 1rem;\n border-radius: 6px; border: 1px solid #30363d;\n}\nhr { border: none; border-top: 1px solid #21262d; width: 100%; margin: 0.5rem 0; }\n</style></head><body><h1>AIT 디버그 세션 — QR 스캔</h1><p class=\"label\">deployment: __SAFE_LABEL__</p><div id=\"attach-section\"><img class=\"qr\" src=\"__QR_DATA_URL__\" alt=\"attach QR\"/></div><section><h2>스캔 절차</h2><ol><li>토스 앱을 실행하세요.</li><li>폰 카메라 앱으로 QR 코드를 스캔하세요.</li><li>팝업이 뜨면 <strong>\"토스로 열기\"</strong>를 탭하세요.</li><li>미니앱이 열리고 디버그 세션이 자동으로 attach됩니다.</li></ol></section><hr/><section><h2>진단 체크리스트</h2><ul><li><strong>토스 앱이 안 열리는 경우</strong> — 앱 버전 확인, 카메라 앱으로 스캔 (토스 앱 내 QR 리더 X)</li><li><strong>미니앱이 PREPARE 상태에서 멈추는 경우</strong> — deep-link에 <code>_deploymentId</code> 파라미터가 있는지 확인</li><li><strong>Chii 주입 실패 / 콘솔이 비어 있는 경우</strong> — 미니앱 번들에 <code>in-app</code> debug import가 있는지 확인</li><li><strong>TOTP gate Layer C가 비활성인 경우</strong> — relay 서버에 <code>AIT_DEBUG_TOTP_SECRET</code>이 설정돼 있는지 확인</li></ul></section><hr/><section><h2>URL (fallback)</h2><p class=\"url-box\">__SAFE_ATTACH_URL__</p></section></body></html>`;\n\n// ── locale: en ──────────────────────────────────────────────────────\n\nexport const dashboardChromeHtmlEn =\n`<!DOCTYPE html>\n<html lang=\"en\"><head><meta charSet=\"utf-8\"/><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"/><title>AIT Debug Dashboard</title><style>\n*, *::before, *::after { box-sizing: border-box; }\nbody {\n font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", sans-serif;\n background: #0d1117; color: #c9d1d9;\n display: flex; flex-direction: column; align-items: center;\n min-height: 100vh; margin: 0; padding: 2rem 1rem;\n gap: 1.5rem;\n}\nh1 { font-size: 1.25rem; font-weight: 600; color: #e6edf3; margin: 0; text-align: center; }\n.updated { font-size: 0.75rem; opacity: 0.4; font-family: monospace; margin: 0; }\nsection { width: 100%; max-width: 520px; }\nh2 { font-size: 1rem; font-weight: 600; color: #e6edf3; margin: 0 0 0.5rem; }\n.status { display: inline-block; padding: 0.25rem 0.75rem; border-radius: 999px; font-size: 0.8rem; font-weight: 600; }\n.status-up { background: #238636; color: #fff; }\n.status-down { background: #6e7681; color: #fff; }\nimg.qr {\n width: min(80vw, 300px); height: auto;\n image-rendering: pixelated;\n background: #fff; padding: 0.75rem; border-radius: 10px;\n display: block; margin: 0.5rem auto;\n}\n.url-box {\n font-family: monospace; font-size: 0.7rem;\n word-break: break-all; opacity: 0.45;\n background: #161b22; padding: 0.6rem 0.85rem;\n border-radius: 6px; border: 1px solid #30363d; margin: 0.5rem 0 0;\n}\n.hint { font-size: 0.85rem; opacity: 0.5; margin: 0.25rem 0 0; }\nul { margin: 0; padding-left: 1.25rem; }\nli { margin-bottom: 0.35rem; font-size: 0.85rem; line-height: 1.5; }\nli.empty { opacity: 0.4; list-style: none; padding-left: 0; }\n.page-id { font-family: monospace; font-size: 0.75rem; opacity: 0.5; margin-right: 0.4rem; }\n.page-url { word-break: break-all; }\nhr { border: none; border-top: 1px solid #21262d; width: 100%; margin: 0; }\n</style></head><body><h1>AIT Debug Dashboard</h1><p class=\"updated\" id=\"updated\">Last updated: __NOW__</p><section><h2>Tunnel status</h2><span class=\"status __TUNNEL_CLASS__\" id=\"tunnel-status\">__TUNNEL_STATUS__</span></section><hr/><section><h2>Attach QR</h2><div id=\"attach-section\">__ATTACH_SECTION__</div></section>__PAGES_SECTION__</body></html>`;\n\nexport const attachChromeHtmlEn =\n`<!DOCTYPE html>\n<html lang=\"en\"><head><meta charSet=\"utf-8\"/><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"/><link rel=\"preload\" as=\"image\" href=\"__QR_DATA_URL__\"/><title>AIT Debug Session — QR Scan</title><style>\n*, *::before, *::after { box-sizing: border-box; }\nbody {\n font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", sans-serif;\n background: #0d1117; color: #c9d1d9;\n display: flex; flex-direction: column; align-items: center;\n min-height: 100vh; margin: 0; padding: 2rem 1rem;\n gap: 1.5rem;\n}\nh1 { font-size: 1.25rem; font-weight: 600; color: #e6edf3; margin: 0; text-align: center; }\n.label { font-size: 0.8rem; opacity: 0.5; font-family: monospace; margin: 0; }\nimg.qr {\n width: min(90vw, 360px); height: auto;\n image-rendering: pixelated;\n background: #fff; padding: 1rem; border-radius: 12px;\n display: block; margin: 0 auto;\n}\nsection { width: 100%; max-width: 480px; }\nh2 { font-size: 1rem; font-weight: 600; color: #e6edf3; margin: 0 0 0.5rem; }\nol, ul { margin: 0; padding-left: 1.25rem; }\nli { margin-bottom: 0.4rem; font-size: 0.9rem; line-height: 1.5; }\n.url-box {\n font-family: monospace; font-size: 0.72rem;\n word-break: break-all; opacity: 0.4;\n background: #161b22; padding: 0.75rem 1rem;\n border-radius: 6px; border: 1px solid #30363d;\n}\nhr { border: none; border-top: 1px solid #21262d; width: 100%; margin: 0.5rem 0; }\n</style></head><body><h1>AIT Debug Session — QR Scan</h1><p class=\"label\">deployment: __SAFE_LABEL__</p><div id=\"attach-section\"><img class=\"qr\" src=\"__QR_DATA_URL__\" alt=\"attach QR\"/></div><section><h2>How to scan</h2><ol><li>Open the Toss app.</li><li>Scan the QR code with your phone camera app.</li><li>Tap <strong>\"Open in Toss\"</strong> when the popup appears.</li><li>The mini-app opens and the debug session attaches automatically.</li></ol></section><hr/><section><h2>Troubleshooting checklist</h2><ul><li><strong>Toss app does not open</strong> — check app version; scan with the system camera app (not the Toss in-app QR reader)</li><li><strong>Mini-app stuck in PREPARE state</strong> — verify the deep-link has a <code>_deploymentId</code> parameter</li><li><strong>Chii injection failure / console is empty</strong> — verify the mini-app bundle has an <code>in-app</code> debug import</li><li><strong>TOTP gate Layer C is inactive</strong> — check that <code>AIT_DEBUG_TOTP_SECRET</code> is set on the relay server</li></ul></section><hr/><section><h2>URL (fallback)</h2><p class=\"url-box\">__SAFE_ATTACH_URL__</p></section></body></html>`;\n\n/** Map from Locale to the precompiled dashboard chrome string. */\nexport const dashboardChromeByLocale: Record<Locale, string> = {\n ko: dashboardChromeHtmlKo,\n en: dashboardChromeHtmlEn,\n};\n\n/** Map from Locale to the precompiled attach page chrome string. */\nexport const attachChromeByLocale: Record<Locale, string> = {\n ko: attachChromeHtmlKo,\n en: attachChromeHtmlEn,\n};\n","/**\n * 로컬 HTTP 서버 — QR 페이지를 `http://127.0.0.1:<port>` 에서 서빙한다.\n *\n * file:// origin 대신 HTTP origin을 쓰는 이유: 브라우저 보안 정책상 file://에서\n * 로드된 페이지는 외부 fetch/script가 전부 차단되며, file:// 절대 경로를 <img src>에\n * 넣으면 브라우저에 따라 빈 화면이 된다. 127.0.0.1 HTTP는 modern 브라우저가 fully trust.\n *\n * INSTALL-GRAPH INVARIANT:\n * 이 모듈은 react/react-dom을 절대 import하지 않는다. dashboard/attach HTML은\n * scripts/build-dashboard-html.ts가 빌드 타임에 precompile해 dashboard.generated.ts\n * (plain string exports)로 커밋한다. 이 모듈은 그 생성된 string만 import한다.\n * check-mcp-react-free.sh 가드가 dist/mcp/cli.js·server.js의 react 유입을 기계적으로 검증.\n *\n * HTML 조립 전략 (token-fill vs runtime builder):\n * - static chrome (head/style/섹션 레이블) → 빌드타임 precompile, dashboard.generated.ts\n * - 동적 부분 → 런타임 string 조립:\n * __NOW__ : per-request ISO timestamp\n * __TUNNEL_CLASS__ : \"status-up\" | \"status-down\"\n * __TUNNEL_STATUS__ : 로컬라이즈된 tunnel 상태 레이블\n * __ATTACH_SECTION__ : QR img+url-box, 또는 hint 텍스트\n * __PAGES_SECTION__ : pages <section> 블록, 또는 빈 문자열 (null → '')\n * - inline SSE <script> → 런타임 suffix로 append (localised string 포함)\n *\n * i18n:\n * GET / 와 GET /attach 라우트에서 req.headers['accept-language']를 읽어\n * parseAcceptLanguage()로 locale 결정. resolveLocaleStrings()로 동적 부분의\n * localised 문자열을 해결. navigator 없음, React hook 없음 (Node 표면).\n *\n * SECRET-HANDLING:\n * - 127.0.0.1 바인딩만 — 외부 노출 0.\n * - attachUrl은 HTML 본문과 /qr.png query에만 들어간다 (의도된 전달 경로).\n * - wssUrl은 dashboard HTML에 절대 들어가지 않는다. tunnel.up boolean만 사용.\n * - stdout/stderr/로그에 별도 출력하지 않는다.\n * - tmp 파일 만들지 않음 — 모든 응답을 메모리에서 생성.\n * - TOTP at= 코드는 attachUrl 캡슐 안에서만 노출 — SSE payload나 page 목록 등\n * 다른 필드에 TOTP 코드를 평문으로 싣지 않는다.\n */\n\nimport type { IncomingMessage, Server, ServerResponse } from 'node:http';\nimport { createServer } from 'node:http';\nimport { parseAcceptLanguage, resolveLocaleStrings } from '../i18n/index.js';\nimport { attachChromeByLocale, dashboardChromeByLocale } from './dashboard.generated.js';\n\n/** dashboard에 노출되는 현재 상태 스냅샷. */\nexport interface DashboardState {\n /** 현재 터널 상태 — up/down + wssUrl. SECRET: wssUrl은 로그 출력 금지. */\n tunnel: { up: boolean; wssUrl: string | null };\n /**\n * 현재 연결된 page 목록 (id/url만).\n *\n * - `Array<…>` — env 3/4(MCP): relay에 attach된 페이지를 라이브 조회한 목록.\n * 빈 배열 `[]`은 \"attach된 페이지 없음\"으로 정직하게 표시한다.\n * - `null` — env 2(unplugin 터널): 플러그인 핸들이 connected target을 노출하지\n * 않아 라이브 page 목록을 알 수 없다. 거짓 빈 목록을 보여주느니 \"연결된 Pages\"\n * 섹션 자체를 숨긴다(#411). 정적 렌더와 SSE 갱신 양쪽에서 섹션이 사라진다.\n */\n pages: Array<{ id: string; url: string }> | null;\n /** 마지막으로 생성된 attachUrl (없으면 null). TOTP at= 코드는 이 안에 캡슐화. */\n attachUrl: string | null;\n}\n\nexport interface QrHttpServer {\n port: number;\n /** `http://127.0.0.1:<port>/attach?u=<encoded>` URL 생성 헬퍼. */\n buildAttachPageUrl(attachUrl: string): string;\n /**\n * 상태 변경 시 호출 — SSE 구독자에게 최신 상태를 push한다.\n * `getDashboardState`가 주입돼 있지 않으면 no-op.\n */\n notifyStateChange(): void;\n close(): Promise<void>;\n}\n\n/** HTML 특수문자를 이스케이프한다. */\nfunction escapeHtml(s: string): string {\n return s.replace(/[<>&\"']/g, (c) => `&#${c.charCodeAt(0)};`);\n}\n\n/**\n * Dashboard HTML — precompiled chrome에 per-request 동적 값을 채워 완성한다.\n *\n * 토큰 채우기 순서:\n * 1. chrome string(locale별 precompile)을 가져온다.\n * 2. 동적 부분을 단순 replaceAll로 채운다 (토큰이 HTML context 밖에 있으므로 안전).\n * 3. inline SSE <script>를 </body> 직전에 주입한다.\n *\n * 동적 파트 분류:\n * - \"token-fill\": 단일 값 교체 (__NOW__, __TUNNEL_CLASS__, __TUNNEL_STATUS__,\n * __ATTACH_SECTION__)\n * - \"runtime builder\": 가변 길이 구조 (__PAGES_SECTION__ — 조건부 렌더 + 가변 rows)\n * - \"suffix\": inline SSE <script> (빌드 파이프라인 없는 클라이언트 스크립트, locale\n * aware 문자열 포함)\n *\n * SECRET-HANDLING:\n * - attachUrl은 url-box 안에서만 노출 (TOTP at= 코드 캡슐 그대로).\n * - tunnel wssUrl은 \"터널 연결됨\" 상태 표시에서 UP/DOWN만 노출.\n * wssUrl 값 자체는 dashboard HTML에 넣지 않는다.\n */\nfunction buildDashboardHtml(\n state: DashboardState,\n qrDataUrl: string | null,\n locale: 'ko' | 'en',\n): string {\n const s = resolveLocaleStrings(locale);\n const now = new Date().toISOString();\n\n const tunnelStatus = state.tunnel.up ? s('dashboard.tunnel.up') : s('dashboard.tunnel.down');\n const tunnelClass = state.tunnel.up ? 'status-up' : 'status-down';\n\n // attachSection: QR img + url-box, or hint\n let attachSection: string;\n if (qrDataUrl && state.attachUrl) {\n const safeAttachUrl = escapeHtml(state.attachUrl);\n attachSection = `<img class=\"qr\" src=\"${qrDataUrl}\" alt=\"attach QR\" /><p class=\"url-box\">${safeAttachUrl}</p>`;\n } else {\n attachSection = `<p class=\"hint\">${escapeHtml(s('dashboard.attach.hint'))}</p>`;\n }\n\n // pagesSection — \"연결된 Pages\" 섹션: env 3/4(pages: Array)에서만 렌더한다.\n // env 2(pages: null)는 라이브 page 목록을 알 수 없어 섹션 자체를 숨긴다(#411).\n // runtime builder: 조건부 블록 + 가변 row 목록이라 token-fill로는 불충분.\n const pagesSection =\n state.pages === null\n ? ''\n : `<hr /><section id=\"pages-section\"><h2>${escapeHtml(s('dashboard.pages.section'))}</h2><ul id=\"pages-list\">${\n state.pages.length > 0\n ? state.pages\n .map((p) => {\n const safeId = escapeHtml(p.id);\n const safeUrl = escapeHtml(p.url.slice(0, 120));\n return `<li><span class=\"page-id\">${safeId}</span> <span class=\"page-url\">${safeUrl}</span></li>`;\n })\n .join('\\n')\n : `<li class=\"empty\">${escapeHtml(s('dashboard.pages.empty'))}</li>`\n }</ul></section>`;\n\n // locale-aware strings for the inline SSE client script\n const sseStrings = {\n tunnelUp: JSON.stringify(s('dashboard.tunnel.up')),\n tunnelDown: JSON.stringify(s('dashboard.tunnel.down')),\n pagesEmpty: JSON.stringify(s('dashboard.pages.empty')),\n attachHint: JSON.stringify(s('dashboard.attach.hint')),\n };\n\n // Fill token placeholders in the precompiled chrome.\n // replaceAll is safe because these __TOKEN__ strings cannot appear in\n // any legitimate user-facing value (they are sentinel strings).\n const chrome = dashboardChromeByLocale[locale];\n const filled = chrome\n .replaceAll('__NOW__', escapeHtml(now))\n .replaceAll('__TUNNEL_CLASS__', tunnelClass)\n .replaceAll('__TUNNEL_STATUS__', escapeHtml(tunnelStatus))\n .replaceAll('__ATTACH_SECTION__', attachSection)\n .replaceAll('__PAGES_SECTION__', pagesSection);\n\n // Append the inline SSE <script> suffix directly before </body>.\n // This keeps the client script out of the precompiled chrome (it references\n // locale-aware strings resolved per-request) while staying self-contained.\n const sseScript = buildSseScript(sseStrings);\n return filled.replace('</body>', `${sseScript}\\n</body>`);\n}\n\ninterface SseScriptStrings {\n tunnelUp: string;\n tunnelDown: string;\n pagesEmpty: string;\n attachHint: string;\n}\n\n/**\n * Inline SSE client <script> — injected into the dashboard HTML at runtime.\n *\n * Subscribes to /events and updates the DOM without a build pipeline.\n * client side: attachUrl은 DOM에 렌더링, wssUrl은 절대 렌더링하지 않는다.\n * pages === null 이면 섹션을 건드리지 않는다 (#411).\n *\n * 문자열 인자는 빌드타임에 ko/en 테이블에서 가져와 JSON.stringify로 이미 escape됨.\n */\nfunction buildSseScript(strings: SseScriptStrings): string {\n return `<script>\n // SSE — /events 구독해 상태 자동 갱신. 빌드 파이프라인 없는 인라인 스크립트.\n (function () {\n var TUNNEL_UP = ${strings.tunnelUp};\n var TUNNEL_DOWN = ${strings.tunnelDown};\n var PAGES_EMPTY = ${strings.pagesEmpty};\n var ATTACH_HINT = ${strings.attachHint};\n var src = new EventSource('/events');\n src.onmessage = function (e) {\n try {\n var s = JSON.parse(e.data);\n // 터널 상태 갱신\n var el = document.getElementById('tunnel-status');\n if (el) {\n el.textContent = s.tunnel && s.tunnel.up ? TUNNEL_UP : TUNNEL_DOWN;\n el.className = 'status ' + (s.tunnel && s.tunnel.up ? 'status-up' : 'status-down');\n }\n // page 목록 갱신 — pages === null(env 2)이면 섹션 자체를 숨긴 채 둔다.\n // 정적 렌더가 #pages-section을 아예 안 그렸으므로 여기서도 손대지 않아\n // SSE push 때 섹션이 되살아나지 않는다(#411). 배열일 때만 목록을 채운다.\n if (s.pages !== null && s.pages !== undefined) {\n var ul = document.getElementById('pages-list');\n if (ul) {\n if (s.pages.length === 0) {\n ul.innerHTML = '<li class=\"empty\">' + PAGES_EMPTY + '</li>';\n } else {\n ul.innerHTML = s.pages.map(function (p) {\n var sid = String(p.id || '').slice(0, 36).replace(/[<>&\"']/g, function (c) { return '&#' + c.charCodeAt(0) + ';'; });\n var su = String(p.url || '').slice(0, 120).replace(/[<>&\"']/g, function (c) { return '&#' + c.charCodeAt(0) + ';'; });\n return '<li><span class=\"page-id\">' + sid + '</span> <span class=\"page-url\">' + su + '</span></li>';\n }).join('');\n }\n }\n }\n // attachUrl QR 갱신 — attachUrl이 없으면 hint 표시.\n var sec = document.getElementById('attach-section');\n if (sec) {\n if (s.attachUrl) {\n // QR은 서버에서 새로 렌더한 /qr.png?u= 로 img src 교체.\n // TOTP at= 코드는 attachUrl 안에 캡슐화 — 별도 노출 없음.\n // wssUrl은 절대 DOM에 렌더하지 않는다 (SECRET-HANDLING).\n var encoded = encodeURIComponent(s.attachUrl);\n var safeUrl = String(s.attachUrl).slice(0, 2000).replace(/[<>&\"']/g, function (c) { return '&#' + c.charCodeAt(0) + ';'; });\n sec.innerHTML =\n '<img class=\"qr\" src=\"/qr.png?u=' + encoded + '\" alt=\"attach QR\" />' +\n '<p class=\"url-box\">' + safeUrl + '</p>';\n } else {\n sec.innerHTML = '<p class=\"hint\">' + ATTACH_HINT + '</p>';\n }\n }\n // 갱신 시각\n var upd = document.getElementById('updated');\n if (upd) upd.textContent = upd.textContent.replace(/[^ ]+$/, new Date().toISOString());\n } catch (_) { /* 파싱 오류 무시 */ }\n };\n src.onerror = function () {\n // 재연결은 EventSource가 자동 처리 (spec 기본 동작).\n };\n })();\n </script>`;\n}\n\n/**\n * Attach 페이지 HTML — precompiled chrome에 per-request 동적 값을 채워 완성한다.\n *\n * 동적 파트:\n * - __QR_DATA_URL__ : base64 data URL (QR 이미지)\n * - __SAFE_LABEL__ : HTML-escaped deploymentId label\n * - __SAFE_ATTACH_URL__ : HTML-escaped attach URL (TOTP at= 코드 포함 — 의도된 전달)\n *\n * SSE 스크립트도 주입 — `#attach-section` hook이 있으면 `/events` push 때 QR이\n * `/qr.png?u=<fresh attachUrl>`로 자동 갱신된다. `#tunnel-status`·`#pages-list` 등\n * 나머지 selector는 /attach 페이지에 없으므로 null-guard로 no-op.\n *\n * SECRET-HANDLING: TOTP at= 코드는 attachUrl 캡슐 안에서만 노출 — 의도된 transport.\n */\nfunction buildAttachHtml(\n qrDataUrl: string,\n safeLabel: string,\n safeAttachUrl: string,\n locale: 'ko' | 'en',\n): string {\n const s = resolveLocaleStrings(locale);\n const chrome = attachChromeByLocale[locale];\n const filled = chrome\n .replaceAll('__QR_DATA_URL__', qrDataUrl)\n .replaceAll('__SAFE_LABEL__', safeLabel)\n .replaceAll('__SAFE_ATTACH_URL__', safeAttachUrl);\n\n // Inject SSE script so QR auto-refreshes on each /events push.\n // `#attach-section` is the only selector present on /attach — other selectors\n // (#tunnel-status, #pages-list, #updated) are null-guarded and are no-ops here.\n const sseStrings = {\n tunnelUp: JSON.stringify(s('dashboard.tunnel.up')),\n tunnelDown: JSON.stringify(s('dashboard.tunnel.down')),\n pagesEmpty: JSON.stringify(s('dashboard.pages.empty')),\n attachHint: JSON.stringify(s('dashboard.attach.hint')),\n };\n const sseScript = buildSseScript(sseStrings);\n return filled.replace('</body>', `${sseScript}\\n</body>`);\n}\n\n/**\n * 로컬 HTTP 서버를 127.0.0.1 random port(또는 `AIT_DEBUG_HTTP_PORT` env)로 시작한다.\n * MCP debug server 생애주기에 묶어 사용 — `runDebugServer` shutdown 시 `close()`로 정리.\n *\n * @param getDashboardState - dashboard 상태를 반환하는 클로저. 주입 시 `GET /` dashboard와\n * `GET /events` SSE 스트림이 활성화된다. 미주입 시 두 라우트는 204/서비스 없음으로 응답.\n */\nexport async function startQrHttpServer(\n getDashboardState?: () => DashboardState,\n): Promise<QrHttpServer> {\n const { default: QRCode } = await import('qrcode');\n\n /** SSE 활성 연결 목록 — `notifyStateChange()` 시 전체 push. */\n const sseClients: ServerResponse[] = [];\n\n /** SSE 연결 하나에 상태 이벤트를 flush한다. */\n function pushStateToClient(res: ServerResponse, state: DashboardState): void {\n const payload = JSON.stringify({\n tunnel: { up: state.tunnel.up, wssUrl: state.tunnel.wssUrl },\n pages: state.pages,\n // attachUrl은 캡슐 그대로 전달 — TOTP at= 코드 분리 없음 (의도된 설계).\n attachUrl: state.attachUrl,\n });\n // SSE frame: \"data: <json>\\n\\n\"\n res.write(`data: ${payload}\\n\\n`);\n }\n\n const server: Server = createServer(async (req: IncomingMessage, res: ServerResponse) => {\n const rawUrl = req.url ?? '/';\n const [path, query = ''] = rawUrl.split('?', 2) as [string, string | undefined];\n const params = new URLSearchParams(query ?? '');\n\n // per-request locale — Accept-Language header에서 결정.\n const locale = parseAcceptLanguage(req.headers['accept-language']);\n\n // ── GET / — dashboard 루트 ─────────────────────────────────────────────\n if (path === '/') {\n if (!getDashboardState) {\n res.writeHead(204, { 'Content-Type': 'text/plain; charset=utf-8' });\n res.end();\n return;\n }\n const state = getDashboardState();\n let qrDataUrl: string | null = null;\n if (state.attachUrl) {\n try {\n qrDataUrl = await QRCode.toDataURL(state.attachUrl, {\n type: 'image/png',\n errorCorrectionLevel: 'M',\n });\n } catch {\n // QR 생성 실패 시 null 유지 — dashboard는 텍스트 fallback 표시\n }\n }\n const html = buildDashboardHtml(state, qrDataUrl, locale);\n res.writeHead(200, {\n 'Content-Type': 'text/html; charset=utf-8',\n 'Cache-Control': 'no-store',\n });\n res.end(html);\n return;\n }\n\n // ── GET /events — SSE 스트림 ──────────────────────────────────────────\n if (path === '/events') {\n if (!getDashboardState) {\n res.writeHead(204, { 'Content-Type': 'text/plain; charset=utf-8' });\n res.end();\n return;\n }\n res.writeHead(200, {\n 'Content-Type': 'text/event-stream',\n 'Cache-Control': 'no-cache',\n Connection: 'keep-alive',\n 'X-Accel-Buffering': 'no',\n });\n // 즉시 현재 상태를 한 번 push — 페이지 로드 시 최신 상태 보장.\n const initialState = getDashboardState();\n pushStateToClient(res, initialState);\n\n sseClients.push(res);\n\n // 연결 끊기면 목록에서 제거.\n req.once('close', () => {\n const idx = sseClients.indexOf(res);\n if (idx !== -1) sseClients.splice(idx, 1);\n });\n return;\n }\n\n if (path === '/attach') {\n const encodedU = params.get('u') ?? '';\n let attachUrl: string;\n try {\n attachUrl = decodeURIComponent(encodedU);\n } catch {\n res.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8' });\n res.end('잘못된 u 파라미터입니다.');\n return;\n }\n\n // deploymentId 라벨 — attachUrl에서 _deploymentId 파라미터만 추출 (at= 노출 방지).\n let deploymentIdLabel = 'attach';\n try {\n const dpMatch = attachUrl.match(/[?&]_deploymentId=([^&]+)/);\n if (dpMatch?.[1]) {\n deploymentIdLabel = decodeURIComponent(dpMatch[1]).slice(0, 36);\n }\n } catch {\n // best-effort\n }\n\n // QR을 base64 data URL로 인라인 생성 — 외부 fetch 없이 self-contained HTML.\n QRCode.toDataURL(attachUrl, { type: 'image/png', errorCorrectionLevel: 'M' })\n .then((dataUrl: string) => {\n const safeLabel = escapeHtml(deploymentIdLabel);\n const safeAttachUrl = escapeHtml(attachUrl);\n const html = buildAttachHtml(dataUrl, safeLabel, safeAttachUrl, locale);\n res.writeHead(200, {\n 'Content-Type': 'text/html; charset=utf-8',\n 'Cache-Control': 'no-store',\n });\n res.end(html);\n })\n .catch(() => {\n res.writeHead(500, { 'Content-Type': 'text/plain; charset=utf-8' });\n res.end('QR 생성에 실패했습니다.');\n });\n return;\n }\n\n if (path === '/qr.png') {\n const encodedU = params.get('u') ?? '';\n let attachUrl: string;\n try {\n attachUrl = decodeURIComponent(encodedU);\n } catch {\n res.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8' });\n res.end('잘못된 u 파라미터입니다.');\n return;\n }\n\n QRCode.toBuffer(attachUrl, { type: 'png', errorCorrectionLevel: 'M' })\n .then((buf: Buffer) => {\n res.writeHead(200, {\n 'Content-Type': 'image/png',\n 'Cache-Control': 'no-store',\n 'Content-Length': String(buf.length),\n });\n res.end(buf);\n })\n .catch(() => {\n res.writeHead(500, { 'Content-Type': 'text/plain; charset=utf-8' });\n res.end('QR PNG 생성에 실패했습니다.');\n });\n return;\n }\n\n res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });\n res.end('Not Found');\n });\n\n const listenPort = Number(process.env.AIT_DEBUG_HTTP_PORT ?? 0);\n\n await new Promise<void>((resolve, reject) => {\n server.listen(listenPort, '127.0.0.1', () => resolve());\n server.once('error', reject);\n });\n\n const address = server.address();\n if (!address || typeof address === 'string') {\n throw new Error('qr-http-server: server.address()가 예상하지 못한 형태입니다.');\n }\n const port = address.port;\n\n return {\n port,\n buildAttachPageUrl(attachUrl: string): string {\n return `http://127.0.0.1:${port}/attach?u=${encodeURIComponent(attachUrl)}`;\n },\n notifyStateChange(): void {\n if (!getDashboardState) return;\n const state = getDashboardState();\n for (const client of sseClients) {\n try {\n pushStateToClient(client, state);\n } catch {\n // 연결이 이미 끊어진 경우 — 무시 (close 핸들러가 목록에서 제거함).\n }\n }\n },\n close(): Promise<void> {\n return new Promise((resolve, reject) => {\n server.close((err) => (err ? reject(err) : resolve()));\n });\n },\n };\n}\n"],"mappings":"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"}
|
|
@@ -90,7 +90,7 @@ function relaySecretFilePath(start, existsSyncFn) {
|
|
|
90
90
|
*/
|
|
91
91
|
async function loadRelaySecretReadOnly(deps) {
|
|
92
92
|
const { projectRoot, env = process.env, fs: fsDep, existsSync: existsSyncDep } = deps ?? {};
|
|
93
|
-
const { isValidRelayAuthSecret } = await import("./totp-
|
|
93
|
+
const { isValidRelayAuthSecret } = await import("./totp-CQFmgOhM.js");
|
|
94
94
|
if (isValidRelayAuthSecret(env.AIT_DEBUG_TOTP_SECRET)) return;
|
|
95
95
|
if (projectRoot === void 0) return;
|
|
96
96
|
const fs = fsDep ?? await import("node:fs");
|
|
@@ -108,4 +108,4 @@ async function loadRelaySecretReadOnly(deps) {
|
|
|
108
108
|
//#endregion
|
|
109
109
|
export { nearestPackageJsonDir as n, loadRelaySecretReadOnly as t };
|
|
110
110
|
|
|
111
|
-
//# sourceMappingURL=relay-secret-store-
|
|
111
|
+
//# sourceMappingURL=relay-secret-store-DBcKWUl9.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"relay-secret-store-5A7_7zOp.js","names":[],"sources":["../src/mcp/relay-secret-store.ts"],"sourcesContent":["/**\n * Project-local relay TOTP secret store (#394 first-run auto-mint, #396 moved to\n * a project-local single file `.ait_relay`).\n *\n * Two surfaces, intentionally split by who is allowed to write:\n *\n * - {@link ensureRelaySecret} — WRITE path, called ONLY from the unplugin\n * (env-2 relay boot). Mints a fresh secret on first run and persists it to\n * `<projectRoot>/.ait_relay` (0600). A single file — no directory is created.\n *\n * - {@link loadRelaySecretReadOnly} — READ-ONLY path, called from the MCP\n * daemon when switching into a relay environment. It NEVER mints, chmods, or\n * creates anything: it only reads an already-existing `.ait_relay` and injects\n * its value into `env`. A daemon that minted would defeat the #250 fail-fast\n * (the daemon is the verifier side — a self-minted secret would let a leaked\n * tunnel URL attach unauthenticated), so the daemon stays read-only.\n *\n * Why a per-session `projectRoot` instead of `process.cwd()`: the daemon cannot\n * trust its own cwd — agent-plugin spawns it via `npx` without `cwd`, so cwd is\n * frozen at Claude Code launch and a cwd-walk stops at the monorepo workspace\n * root (which always has a package.json). So the project root is supplied\n * per-debug-session through `start_debug`.\n *\n * SECRET-HANDLING: this module handles AIT_DEBUG_TOTP_SECRET — the raw value and\n * its length MUST NOT appear in any log, error message, stdout, stderr, or\n * assertion output. Only boolean pass/fail signals are safe to surface, and the\n * discovered file path is never logged either. The persist file is written mode\n * 0600.\n */\n\nimport { dirname, join } from 'node:path';\n\n// ---------------------------------------------------------------------------\n// Constants\n// ---------------------------------------------------------------------------\n\n/** Project-local secret file name (single file, not a directory). */\nexport const RELAY_SECRET_FILE_NAME = '.ait_relay';\n\n// ---------------------------------------------------------------------------\n// Dependency injection surface\n// ---------------------------------------------------------------------------\n\n/** Minimal fs subset needed by {@link ensureRelaySecret} — injectable for tests. */\nexport interface RelaySecretFs {\n writeFileSync(path: string, data: string, options: { mode: number; flag: string }): void;\n readFileSync(path: string, encoding: BufferEncoding): string;\n chmodSync(path: string, mode: number): void;\n existsSync(path: string): boolean;\n}\n\n/**\n * Minimal fs subset needed by {@link loadRelaySecretReadOnly} — strictly the two\n * read-only operations. Deliberately omits writeFileSync/mkdirSync/chmodSync so\n * the daemon path cannot mutate the filesystem even by accident (the type\n * forbids it).\n */\nexport interface RelaySecretReadOnlyFs {\n existsSync(path: string): boolean;\n readFileSync(path: string, encoding: BufferEncoding): string;\n}\n\nexport interface RelaySecretDeps {\n /**\n * Project root (typically Vite `server.config.root`). The `.ait_relay` file is\n * resolved against the nearest `package.json` directory at or above this path.\n * When omitted, the current working directory is used as the start point —\n * retained for back-compat/tests; the unplugin always passes it.\n */\n projectRoot?: string;\n /** Process environment to read from and inject into. Defaults to process.env. */\n env?: NodeJS.ProcessEnv;\n /** Cryptographically secure random bytes. Defaults to node:crypto randomBytes. */\n randomBytes?: (n: number) => Buffer;\n /** Filesystem operations. Defaults to node:fs synchronous functions. */\n fs?: RelaySecretFs;\n /** existsSync used to resolve the nearest package.json directory. Defaults to node:fs. */\n existsSync?: (path: string) => boolean;\n /** Current working directory resolver (used only when `projectRoot` is omitted). */\n cwd?: () => string;\n /** Log function for first-mint announcement. Defaults to process.stderr.write. */\n log?: (msg: string) => void;\n}\n\nexport interface RelaySecretReadOnlyDeps {\n /**\n * Project root supplied per-debug-session via `start_debug`. The daemon reads\n * `<nearest package.json dir from projectRoot>/.ait_relay`. When omitted, the\n * loader is a no-op (the daemon has no anchor to read from).\n */\n projectRoot?: string;\n /** Process environment to read from and inject into. Defaults to process.env. */\n env?: NodeJS.ProcessEnv;\n /** Read-only filesystem operations. Defaults to node:fs (existsSync + readFileSync). */\n fs?: RelaySecretReadOnlyFs;\n /** existsSync used to resolve the nearest package.json directory. Defaults to node:fs. */\n existsSync?: (path: string) => boolean;\n /** Optional log sink — never receives the secret value, length, or file path. */\n log?: (msg: string) => void;\n}\n\n// ---------------------------------------------------------------------------\n// Path helpers\n// ---------------------------------------------------------------------------\n\n/**\n * Walks upward from `start` and returns the nearest directory that contains a\n * `package.json`. Falls back to `start` itself when none is found (so a write\n * still lands somewhere deterministic).\n *\n * The write (unplugin) and read (daemon) sides use the SAME anchor so a secret\n * minted by `pnpm dev` is found by the daemon: real mini-apps keep\n * `vite.config.ts` and `package.json` in the same directory, so\n * `server.config.root === package.json-dir`. In a monorepo subdir the anchor is\n * the package's own directory — the one the daemon can also reach via the\n * per-session projectRoot.\n *\n * @param start - Directory to start the upward walk from.\n * @param existsSyncFn - Injectable existence check (defaults to node:fs).\n */\nexport function nearestPackageJsonDir(\n start: string,\n existsSyncFn: (path: string) => boolean,\n): string {\n let dir = start;\n // Stop at the filesystem root (dirname of root === root).\n while (true) {\n if (existsSyncFn(join(dir, 'package.json'))) {\n return dir;\n }\n const parent = dirname(dir);\n if (parent === dir) {\n // Reached the filesystem root without finding a package.json — fall back\n // to the original start directory.\n return start;\n }\n dir = parent;\n }\n}\n\n/**\n * Absolute path to the project-local `.ait_relay` file for a given start\n * directory (resolved against the nearest package.json directory).\n *\n * Exported so tests can compute the expected path without duplicating the\n * resolution logic.\n */\nexport function relaySecretFilePath(\n start: string,\n existsSyncFn: (path: string) => boolean,\n): string {\n return join(nearestPackageJsonDir(start, existsSyncFn), RELAY_SECRET_FILE_NAME);\n}\n\n// ---------------------------------------------------------------------------\n// WRITE path (unplugin only) — mint + persist\n// ---------------------------------------------------------------------------\n\n/**\n * Ensures `env.AIT_DEBUG_TOTP_SECRET` is set to a valid relay TOTP secret,\n * persisting a freshly-minted one to `<projectRoot>/.ait_relay` (0600) on first\n * run and loading it silently on subsequent runs.\n *\n * Writes a SINGLE file into the already-existing project directory — it never\n * creates a directory (so no `mkdirSync`/dir `chmod`). The file is created with\n * `O_EXCL` (`flag: 'wx'`) so a concurrent process cannot be clobbered; on the\n * EEXIST race the winner's value is read instead.\n *\n * Called ONLY from the unplugin (env-2 relay boot). The MCP daemon uses\n * {@link loadRelaySecretReadOnly} (read-only) — it must never mint.\n *\n * @param deps - Optional dependency overrides for testing.\n */\nexport async function ensureRelaySecret(deps?: RelaySecretDeps): Promise<void> {\n const {\n projectRoot,\n env = process.env,\n randomBytes: randomBytesFn,\n fs: fsDep,\n existsSync: existsSyncDep,\n cwd: cwdFn,\n log,\n } = deps ?? {};\n\n const logFn: (msg: string) => void = log ?? ((msg: string) => process.stderr.write(msg));\n\n // Lazily import isValidRelayAuthSecret to avoid pulling in node:crypto at\n // module-load time (keeps the import side-effect free).\n const { isValidRelayAuthSecret } = await import('./totp.js');\n\n // 1. Already configured — no-op (operator export or earlier run wins).\n if (isValidRelayAuthSecret(env.AIT_DEBUG_TOTP_SECRET)) {\n return;\n }\n\n // Resolve injected or real dependencies lazily to keep the import graph clean.\n const rb: (n: number) => Buffer = randomBytesFn ?? (await import('node:crypto')).randomBytes;\n const fs: RelaySecretFs = fsDep ?? (await import('node:fs'));\n const existsSyncFn: (path: string) => boolean = existsSyncDep ?? fs.existsSync;\n\n const start = projectRoot ?? (cwdFn ?? (() => process.cwd()))();\n const secretPath = relaySecretFilePath(start, existsSyncFn);\n\n // 2. Persist file exists — read and inject (silent reload).\n if (fs.existsSync(secretPath)) {\n return readAndInject(secretPath, fs, env, logFn, isValidRelayAuthSecret, rb);\n }\n\n // 3. Mint a fresh secret.\n return mintAndPersist(secretPath, fs, env, rb, logFn, isValidRelayAuthSecret);\n}\n\n// ---------------------------------------------------------------------------\n// READ-ONLY path (daemon only) — never mints, chmods, or creates anything\n// ---------------------------------------------------------------------------\n\n/**\n * Reads an already-existing `<projectRoot>/.ait_relay` and, if its contents are a\n * valid relay TOTP secret, injects them into `env.AIT_DEBUG_TOTP_SECRET`.\n *\n * Strictly READ-ONLY: it uses only `existsSync` + `readFileSync` and NEVER mints,\n * chmods, or creates files/directories. The daemon must not mint because it is\n * the relay verifier side — a self-minted secret would defeat the #250 fail-fast\n * (a leaked tunnel URL could then attach unauthenticated). If no valid secret is\n * found the function leaves `env` untouched and returns without throwing, so the\n * downstream `assertRelayAuthConfigured()` stays the single fail-fast.\n *\n * Resolution order:\n * 1. `env.AIT_DEBUG_TOTP_SECRET` already valid → no-op (operator export wins).\n * 2. `projectRoot` given → read `<nearest package.json dir>/.ait_relay`; inject\n * iff the contents pass {@link isValidRelayAuthSecret}.\n * 3. Otherwise (no projectRoot, file absent, or invalid) → silent no-op.\n *\n * SECRET-HANDLING: the read value is passed ONLY to the boolean predicate before\n * assignment; its value, length, and the discovered file path are never logged.\n *\n * @param deps - Optional dependency overrides for testing.\n */\nexport async function loadRelaySecretReadOnly(deps?: RelaySecretReadOnlyDeps): Promise<void> {\n const { projectRoot, env = process.env, fs: fsDep, existsSync: existsSyncDep } = deps ?? {};\n\n const { isValidRelayAuthSecret } = await import('./totp.js');\n\n // 1. Already configured — no-op (operator export or unplugin run wins).\n if (isValidRelayAuthSecret(env.AIT_DEBUG_TOTP_SECRET)) {\n return;\n }\n\n // 2. No anchor → nothing to read.\n if (projectRoot === undefined) {\n return;\n }\n\n const fs: RelaySecretReadOnlyFs = fsDep ?? (await import('node:fs'));\n const existsSyncFn: (path: string) => boolean = existsSyncDep ?? fs.existsSync;\n\n const secretPath = relaySecretFilePath(projectRoot, existsSyncFn);\n if (!fs.existsSync(secretPath)) {\n return;\n }\n\n let stored: string;\n try {\n stored = fs.readFileSync(secretPath, 'utf8').trim();\n } catch {\n // Unreadable file (permissions, transient FS error) — stay silent and let\n // the downstream assert be the single fail-fast. SECRET-HANDLING: the error\n // and path are not surfaced.\n return;\n }\n\n // SECRET-HANDLING: the value flows only through the boolean predicate.\n if (!isValidRelayAuthSecret(stored)) {\n return;\n }\n\n env.AIT_DEBUG_TOTP_SECRET = stored;\n}\n\n// ---------------------------------------------------------------------------\n// Internal helpers (not exported — single-use extracted for readability)\n// ---------------------------------------------------------------------------\n\nasync function readAndInject(\n secretPath: string,\n fs: RelaySecretFs,\n env: NodeJS.ProcessEnv,\n logFn: (msg: string) => void,\n isValidRelayAuthSecret: (s: string | undefined) => s is string,\n rb: (n: number) => Buffer,\n): Promise<void> {\n let stored: string;\n try {\n stored = fs.readFileSync(secretPath, 'utf8').trim();\n } catch (err) {\n const msg = err instanceof Error ? err.message : String(err);\n throw new Error(`[@ait-co/devtools] relay 시크릿 파일 읽기 실패: ${msg}`);\n }\n\n if (!isValidRelayAuthSecret(stored)) {\n // Stored value is corrupt — re-mint over the same path.\n logFn('[@ait-co/devtools] relay 시크릿 파일의 값이 유효하지 않습니다. 재생성합니다.\\n');\n return mintAndPersist(secretPath, fs, env, rb, logFn, isValidRelayAuthSecret, true);\n }\n\n // Inject into env — silent path (no log on successful reload).\n env.AIT_DEBUG_TOTP_SECRET = stored;\n}\n\nasync function mintAndPersist(\n secretPath: string,\n fs: RelaySecretFs,\n env: NodeJS.ProcessEnv,\n rb: (n: number) => Buffer,\n logFn: (msg: string) => void,\n isValidRelayAuthSecret: (s: string | undefined) => s is string,\n /** When re-minting over a corrupt file, the existing file must be overwritten. */\n overwrite = false,\n): Promise<void> {\n // SECRET-HANDLING: the raw bytes are never written to any log or string other\n // than the persist file and the env variable.\n const secret = rb(32).toString('hex'); // 64 hex chars = 256 bits\n\n // Self-consistency guard: our own minted secret must pass validation.\n if (!isValidRelayAuthSecret(secret)) {\n throw new Error(\n '[@ait-co/devtools] 내부 오류: mint된 시크릿이 유효성 검사를 통과하지 못했습니다.',\n );\n }\n\n // Write a SINGLE file into the already-existing project directory — no\n // directory is created. `O_EXCL` (flag 'wx') makes the create exclusive so a\n // concurrent process cannot be clobbered; on EEXIST we read the winner's value.\n // (When re-minting over a corrupt file we must overwrite, so use 'w'.)\n const flag = overwrite ? 'w' : 'wx';\n try {\n fs.writeFileSync(secretPath, secret, { mode: 0o600, flag });\n // Belt-and-suspenders: apply chmod after write in case umask relaxed the mode.\n fs.chmodSync(secretPath, 0o600);\n } catch (err) {\n if ((err as NodeJS.ErrnoException).code === 'EEXIST') {\n // Race: another process already wrote the file — read their value.\n let stored: string;\n try {\n stored = fs.readFileSync(secretPath, 'utf8').trim();\n } catch (readErr) {\n const msg = readErr instanceof Error ? readErr.message : String(readErr);\n throw new Error(`[@ait-co/devtools] relay 시크릿 파일 읽기 실패(경합): ${msg}`);\n }\n if (!isValidRelayAuthSecret(stored)) {\n throw new Error('[@ait-co/devtools] relay 시크릿 파일이 경합 후에도 유효하지 않습니다.');\n }\n env.AIT_DEBUG_TOTP_SECRET = stored;\n return;\n }\n throw err;\n }\n\n // Inject into the current process env so the immediately following\n // assertRelayAuthConfigured() / buildRelayVerifyAuth() calls see the value.\n env.AIT_DEBUG_TOTP_SECRET = secret;\n\n // First-mint announcement (value never included — SECRET-HANDLING). The file\n // name is fixed (`.ait_relay`); we do not echo the resolved directory either.\n logFn(\n `[@ait-co/devtools] relay 인증 시크릿을 생성해 프로젝트의 ${RELAY_SECRET_FILE_NAME} 파일에 저장했습니다 (권한 0600).\\n` +\n `다음 실행부터 자동으로 사용됩니다. 직접 export할 필요 없습니다.\\n` +\n `팀이 같은 relay를 공유하려면 이 파일을 repo에 커밋하세요(비공개 repo 권장).\\n` +\n `자세히: https://docs.aitc.dev/guides/relay-auth-totp\\n`,\n );\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAqCA,MAAa,yBAAyB;;;;;;;;;;;;;;;;AAmFtC,SAAgB,sBACd,OACA,cACQ;CACR,IAAI,MAAM;AAEV,QAAO,MAAM;AACX,MAAI,aAAa,KAAK,KAAK,eAAe,CAAC,CACzC,QAAO;EAET,MAAM,SAAS,QAAQ,IAAI;AAC3B,MAAI,WAAW,IAGb,QAAO;AAET,QAAM;;;;;;;;;;AAWV,SAAgB,oBACd,OACA,cACQ;AACR,QAAO,KAAK,sBAAsB,OAAO,aAAa,EAAE,uBAAuB;;;;;;;;;;;;;;;;;;;;;;;;AAuFjF,eAAsB,wBAAwB,MAA+C;CAC3F,MAAM,EAAE,aAAa,MAAM,QAAQ,KAAK,IAAI,OAAO,YAAY,kBAAkB,QAAQ,EAAE;CAE3F,MAAM,EAAE,2BAA2B,MAAM,OAAO;AAGhD,KAAI,uBAAuB,IAAI,sBAAsB,CACnD;AAIF,KAAI,gBAAgB,KAAA,EAClB;CAGF,MAAM,KAA4B,SAAU,MAAM,OAAO;CAGzD,MAAM,aAAa,oBAAoB,aAFS,iBAAiB,GAAG,WAEH;AACjE,KAAI,CAAC,GAAG,WAAW,WAAW,CAC5B;CAGF,IAAI;AACJ,KAAI;AACF,WAAS,GAAG,aAAa,YAAY,OAAO,CAAC,MAAM;SAC7C;AAIN;;AAIF,KAAI,CAAC,uBAAuB,OAAO,CACjC;AAGF,KAAI,wBAAwB"}
|
|
1
|
+
{"version":3,"file":"relay-secret-store-DBcKWUl9.js","names":[],"sources":["../src/mcp/relay-secret-store.ts"],"sourcesContent":["/**\n * Project-local relay TOTP secret store (#394 first-run auto-mint, #396 moved to\n * a project-local single file `.ait_relay`).\n *\n * Two surfaces, intentionally split by who is allowed to write:\n *\n * - {@link ensureRelaySecret} — WRITE path, called ONLY from the unplugin\n * (env-2 relay boot). Mints a fresh secret on first run and persists it to\n * `<projectRoot>/.ait_relay` (0600). A single file — no directory is created.\n *\n * - {@link loadRelaySecretReadOnly} — READ-ONLY path, called from the MCP\n * daemon when switching into a relay environment. It NEVER mints, chmods, or\n * creates anything: it only reads an already-existing `.ait_relay` and injects\n * its value into `env`. A daemon that minted would defeat the #250 fail-fast\n * (the daemon is the verifier side — a self-minted secret would let a leaked\n * tunnel URL attach unauthenticated), so the daemon stays read-only.\n *\n * Why a per-session `projectRoot` instead of `process.cwd()`: the daemon cannot\n * trust its own cwd — agent-plugin spawns it via `npx` without `cwd`, so cwd is\n * frozen at Claude Code launch and a cwd-walk stops at the monorepo workspace\n * root (which always has a package.json). So the project root is supplied\n * per-debug-session through `start_debug`.\n *\n * SECRET-HANDLING: this module handles AIT_DEBUG_TOTP_SECRET — the raw value and\n * its length MUST NOT appear in any log, error message, stdout, stderr, or\n * assertion output. Only boolean pass/fail signals are safe to surface, and the\n * discovered file path is never logged either. The persist file is written mode\n * 0600.\n */\n\nimport { dirname, join } from 'node:path';\n\n// ---------------------------------------------------------------------------\n// Constants\n// ---------------------------------------------------------------------------\n\n/** Project-local secret file name (single file, not a directory). */\nexport const RELAY_SECRET_FILE_NAME = '.ait_relay';\n\n// ---------------------------------------------------------------------------\n// Dependency injection surface\n// ---------------------------------------------------------------------------\n\n/** Minimal fs subset needed by {@link ensureRelaySecret} — injectable for tests. */\nexport interface RelaySecretFs {\n writeFileSync(path: string, data: string, options: { mode: number; flag: string }): void;\n readFileSync(path: string, encoding: BufferEncoding): string;\n chmodSync(path: string, mode: number): void;\n existsSync(path: string): boolean;\n}\n\n/**\n * Minimal fs subset needed by {@link loadRelaySecretReadOnly} — strictly the two\n * read-only operations. Deliberately omits writeFileSync/mkdirSync/chmodSync so\n * the daemon path cannot mutate the filesystem even by accident (the type\n * forbids it).\n */\nexport interface RelaySecretReadOnlyFs {\n existsSync(path: string): boolean;\n readFileSync(path: string, encoding: BufferEncoding): string;\n}\n\nexport interface RelaySecretDeps {\n /**\n * Project root (typically Vite `server.config.root`). The `.ait_relay` file is\n * resolved against the nearest `package.json` directory at or above this path.\n * When omitted, the current working directory is used as the start point —\n * retained for back-compat/tests; the unplugin always passes it.\n */\n projectRoot?: string;\n /** Process environment to read from and inject into. Defaults to process.env. */\n env?: NodeJS.ProcessEnv;\n /** Cryptographically secure random bytes. Defaults to node:crypto randomBytes. */\n randomBytes?: (n: number) => Buffer;\n /** Filesystem operations. Defaults to node:fs synchronous functions. */\n fs?: RelaySecretFs;\n /** existsSync used to resolve the nearest package.json directory. Defaults to node:fs. */\n existsSync?: (path: string) => boolean;\n /** Current working directory resolver (used only when `projectRoot` is omitted). */\n cwd?: () => string;\n /** Log function for first-mint announcement. Defaults to process.stderr.write. */\n log?: (msg: string) => void;\n}\n\nexport interface RelaySecretReadOnlyDeps {\n /**\n * Project root supplied per-debug-session via `start_debug`. The daemon reads\n * `<nearest package.json dir from projectRoot>/.ait_relay`. When omitted, the\n * loader is a no-op (the daemon has no anchor to read from).\n */\n projectRoot?: string;\n /** Process environment to read from and inject into. Defaults to process.env. */\n env?: NodeJS.ProcessEnv;\n /** Read-only filesystem operations. Defaults to node:fs (existsSync + readFileSync). */\n fs?: RelaySecretReadOnlyFs;\n /** existsSync used to resolve the nearest package.json directory. Defaults to node:fs. */\n existsSync?: (path: string) => boolean;\n /** Optional log sink — never receives the secret value, length, or file path. */\n log?: (msg: string) => void;\n}\n\n// ---------------------------------------------------------------------------\n// Path helpers\n// ---------------------------------------------------------------------------\n\n/**\n * Walks upward from `start` and returns the nearest directory that contains a\n * `package.json`. Falls back to `start` itself when none is found (so a write\n * still lands somewhere deterministic).\n *\n * The write (unplugin) and read (daemon) sides use the SAME anchor so a secret\n * minted by `pnpm dev` is found by the daemon: real mini-apps keep\n * `vite.config.ts` and `package.json` in the same directory, so\n * `server.config.root === package.json-dir`. In a monorepo subdir the anchor is\n * the package's own directory — the one the daemon can also reach via the\n * per-session projectRoot.\n *\n * @param start - Directory to start the upward walk from.\n * @param existsSyncFn - Injectable existence check (defaults to node:fs).\n */\nexport function nearestPackageJsonDir(\n start: string,\n existsSyncFn: (path: string) => boolean,\n): string {\n let dir = start;\n // Stop at the filesystem root (dirname of root === root).\n while (true) {\n if (existsSyncFn(join(dir, 'package.json'))) {\n return dir;\n }\n const parent = dirname(dir);\n if (parent === dir) {\n // Reached the filesystem root without finding a package.json — fall back\n // to the original start directory.\n return start;\n }\n dir = parent;\n }\n}\n\n/**\n * Absolute path to the project-local `.ait_relay` file for a given start\n * directory (resolved against the nearest package.json directory).\n *\n * Exported so tests can compute the expected path without duplicating the\n * resolution logic.\n */\nexport function relaySecretFilePath(\n start: string,\n existsSyncFn: (path: string) => boolean,\n): string {\n return join(nearestPackageJsonDir(start, existsSyncFn), RELAY_SECRET_FILE_NAME);\n}\n\n// ---------------------------------------------------------------------------\n// WRITE path (unplugin only) — mint + persist\n// ---------------------------------------------------------------------------\n\n/**\n * Ensures `env.AIT_DEBUG_TOTP_SECRET` is set to a valid relay TOTP secret,\n * persisting a freshly-minted one to `<projectRoot>/.ait_relay` (0600) on first\n * run and loading it silently on subsequent runs.\n *\n * Writes a SINGLE file into the already-existing project directory — it never\n * creates a directory (so no `mkdirSync`/dir `chmod`). The file is created with\n * `O_EXCL` (`flag: 'wx'`) so a concurrent process cannot be clobbered; on the\n * EEXIST race the winner's value is read instead.\n *\n * Called ONLY from the unplugin (env-2 relay boot). The MCP daemon uses\n * {@link loadRelaySecretReadOnly} (read-only) — it must never mint.\n *\n * @param deps - Optional dependency overrides for testing.\n */\nexport async function ensureRelaySecret(deps?: RelaySecretDeps): Promise<void> {\n const {\n projectRoot,\n env = process.env,\n randomBytes: randomBytesFn,\n fs: fsDep,\n existsSync: existsSyncDep,\n cwd: cwdFn,\n log,\n } = deps ?? {};\n\n const logFn: (msg: string) => void = log ?? ((msg: string) => process.stderr.write(msg));\n\n // Lazily import isValidRelayAuthSecret to avoid pulling in node:crypto at\n // module-load time (keeps the import side-effect free).\n const { isValidRelayAuthSecret } = await import('./totp.js');\n\n // 1. Already configured — no-op (operator export or earlier run wins).\n if (isValidRelayAuthSecret(env.AIT_DEBUG_TOTP_SECRET)) {\n return;\n }\n\n // Resolve injected or real dependencies lazily to keep the import graph clean.\n const rb: (n: number) => Buffer = randomBytesFn ?? (await import('node:crypto')).randomBytes;\n const fs: RelaySecretFs = fsDep ?? (await import('node:fs'));\n const existsSyncFn: (path: string) => boolean = existsSyncDep ?? fs.existsSync;\n\n const start = projectRoot ?? (cwdFn ?? (() => process.cwd()))();\n const secretPath = relaySecretFilePath(start, existsSyncFn);\n\n // 2. Persist file exists — read and inject (silent reload).\n if (fs.existsSync(secretPath)) {\n return readAndInject(secretPath, fs, env, logFn, isValidRelayAuthSecret, rb);\n }\n\n // 3. Mint a fresh secret.\n return mintAndPersist(secretPath, fs, env, rb, logFn, isValidRelayAuthSecret);\n}\n\n// ---------------------------------------------------------------------------\n// READ-ONLY path (daemon only) — never mints, chmods, or creates anything\n// ---------------------------------------------------------------------------\n\n/**\n * Reads an already-existing `<projectRoot>/.ait_relay` and, if its contents are a\n * valid relay TOTP secret, injects them into `env.AIT_DEBUG_TOTP_SECRET`.\n *\n * Strictly READ-ONLY: it uses only `existsSync` + `readFileSync` and NEVER mints,\n * chmods, or creates files/directories. The daemon must not mint because it is\n * the relay verifier side — a self-minted secret would defeat the #250 fail-fast\n * (a leaked tunnel URL could then attach unauthenticated). If no valid secret is\n * found the function leaves `env` untouched and returns without throwing, so the\n * downstream `assertRelayAuthConfigured()` stays the single fail-fast.\n *\n * Resolution order:\n * 1. `env.AIT_DEBUG_TOTP_SECRET` already valid → no-op (operator export wins).\n * 2. `projectRoot` given → read `<nearest package.json dir>/.ait_relay`; inject\n * iff the contents pass {@link isValidRelayAuthSecret}.\n * 3. Otherwise (no projectRoot, file absent, or invalid) → silent no-op.\n *\n * SECRET-HANDLING: the read value is passed ONLY to the boolean predicate before\n * assignment; its value, length, and the discovered file path are never logged.\n *\n * @param deps - Optional dependency overrides for testing.\n */\nexport async function loadRelaySecretReadOnly(deps?: RelaySecretReadOnlyDeps): Promise<void> {\n const { projectRoot, env = process.env, fs: fsDep, existsSync: existsSyncDep } = deps ?? {};\n\n const { isValidRelayAuthSecret } = await import('./totp.js');\n\n // 1. Already configured — no-op (operator export or unplugin run wins).\n if (isValidRelayAuthSecret(env.AIT_DEBUG_TOTP_SECRET)) {\n return;\n }\n\n // 2. No anchor → nothing to read.\n if (projectRoot === undefined) {\n return;\n }\n\n const fs: RelaySecretReadOnlyFs = fsDep ?? (await import('node:fs'));\n const existsSyncFn: (path: string) => boolean = existsSyncDep ?? fs.existsSync;\n\n const secretPath = relaySecretFilePath(projectRoot, existsSyncFn);\n if (!fs.existsSync(secretPath)) {\n return;\n }\n\n let stored: string;\n try {\n stored = fs.readFileSync(secretPath, 'utf8').trim();\n } catch {\n // Unreadable file (permissions, transient FS error) — stay silent and let\n // the downstream assert be the single fail-fast. SECRET-HANDLING: the error\n // and path are not surfaced.\n return;\n }\n\n // SECRET-HANDLING: the value flows only through the boolean predicate.\n if (!isValidRelayAuthSecret(stored)) {\n return;\n }\n\n env.AIT_DEBUG_TOTP_SECRET = stored;\n}\n\n// ---------------------------------------------------------------------------\n// Internal helpers (not exported — single-use extracted for readability)\n// ---------------------------------------------------------------------------\n\nasync function readAndInject(\n secretPath: string,\n fs: RelaySecretFs,\n env: NodeJS.ProcessEnv,\n logFn: (msg: string) => void,\n isValidRelayAuthSecret: (s: string | undefined) => s is string,\n rb: (n: number) => Buffer,\n): Promise<void> {\n let stored: string;\n try {\n stored = fs.readFileSync(secretPath, 'utf8').trim();\n } catch (err) {\n const msg = err instanceof Error ? err.message : String(err);\n throw new Error(`[@ait-co/devtools] relay 시크릿 파일 읽기 실패: ${msg}`);\n }\n\n if (!isValidRelayAuthSecret(stored)) {\n // Stored value is corrupt — re-mint over the same path.\n logFn('[@ait-co/devtools] relay 시크릿 파일의 값이 유효하지 않습니다. 재생성합니다.\\n');\n return mintAndPersist(secretPath, fs, env, rb, logFn, isValidRelayAuthSecret, true);\n }\n\n // Inject into env — silent path (no log on successful reload).\n env.AIT_DEBUG_TOTP_SECRET = stored;\n}\n\nasync function mintAndPersist(\n secretPath: string,\n fs: RelaySecretFs,\n env: NodeJS.ProcessEnv,\n rb: (n: number) => Buffer,\n logFn: (msg: string) => void,\n isValidRelayAuthSecret: (s: string | undefined) => s is string,\n /** When re-minting over a corrupt file, the existing file must be overwritten. */\n overwrite = false,\n): Promise<void> {\n // SECRET-HANDLING: the raw bytes are never written to any log or string other\n // than the persist file and the env variable.\n const secret = rb(32).toString('hex'); // 64 hex chars = 256 bits\n\n // Self-consistency guard: our own minted secret must pass validation.\n if (!isValidRelayAuthSecret(secret)) {\n throw new Error(\n '[@ait-co/devtools] 내부 오류: mint된 시크릿이 유효성 검사를 통과하지 못했습니다.',\n );\n }\n\n // Write a SINGLE file into the already-existing project directory — no\n // directory is created. `O_EXCL` (flag 'wx') makes the create exclusive so a\n // concurrent process cannot be clobbered; on EEXIST we read the winner's value.\n // (When re-minting over a corrupt file we must overwrite, so use 'w'.)\n const flag = overwrite ? 'w' : 'wx';\n try {\n fs.writeFileSync(secretPath, secret, { mode: 0o600, flag });\n // Belt-and-suspenders: apply chmod after write in case umask relaxed the mode.\n fs.chmodSync(secretPath, 0o600);\n } catch (err) {\n if ((err as NodeJS.ErrnoException).code === 'EEXIST') {\n // Race: another process already wrote the file — read their value.\n let stored: string;\n try {\n stored = fs.readFileSync(secretPath, 'utf8').trim();\n } catch (readErr) {\n const msg = readErr instanceof Error ? readErr.message : String(readErr);\n throw new Error(`[@ait-co/devtools] relay 시크릿 파일 읽기 실패(경합): ${msg}`);\n }\n if (!isValidRelayAuthSecret(stored)) {\n throw new Error('[@ait-co/devtools] relay 시크릿 파일이 경합 후에도 유효하지 않습니다.');\n }\n env.AIT_DEBUG_TOTP_SECRET = stored;\n return;\n }\n throw err;\n }\n\n // Inject into the current process env so the immediately following\n // assertRelayAuthConfigured() / buildRelayVerifyAuth() calls see the value.\n env.AIT_DEBUG_TOTP_SECRET = secret;\n\n // First-mint announcement (value never included — SECRET-HANDLING). The file\n // name is fixed (`.ait_relay`); we do not echo the resolved directory either.\n logFn(\n `[@ait-co/devtools] relay 인증 시크릿을 생성해 프로젝트의 ${RELAY_SECRET_FILE_NAME} 파일에 저장했습니다 (권한 0600).\\n` +\n `다음 실행부터 자동으로 사용됩니다. 직접 export할 필요 없습니다.\\n` +\n `팀이 같은 relay를 공유하려면 이 파일을 repo에 커밋하세요(비공개 repo 권장).\\n` +\n `자세히: https://docs.aitc.dev/guides/relay-auth-totp\\n`,\n );\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAqCA,MAAa,yBAAyB;;;;;;;;;;;;;;;;AAmFtC,SAAgB,sBACd,OACA,cACQ;CACR,IAAI,MAAM;AAEV,QAAO,MAAM;AACX,MAAI,aAAa,KAAK,KAAK,eAAe,CAAC,CACzC,QAAO;EAET,MAAM,SAAS,QAAQ,IAAI;AAC3B,MAAI,WAAW,IAGb,QAAO;AAET,QAAM;;;;;;;;;;AAWV,SAAgB,oBACd,OACA,cACQ;AACR,QAAO,KAAK,sBAAsB,OAAO,aAAa,EAAE,uBAAuB;;;;;;;;;;;;;;;;;;;;;;;;AAuFjF,eAAsB,wBAAwB,MAA+C;CAC3F,MAAM,EAAE,aAAa,MAAM,QAAQ,KAAK,IAAI,OAAO,YAAY,kBAAkB,QAAQ,EAAE;CAE3F,MAAM,EAAE,2BAA2B,MAAM,OAAO;AAGhD,KAAI,uBAAuB,IAAI,sBAAsB,CACnD;AAIF,KAAI,gBAAgB,KAAA,EAClB;CAGF,MAAM,KAA4B,SAAU,MAAM,OAAO;CAGzD,MAAM,aAAa,oBAAoB,aAFS,iBAAiB,GAAG,WAEH;AACjE,KAAI,CAAC,GAAG,WAAW,WAAW,CAC5B;CAGF,IAAI;AACJ,KAAI;AACF,WAAS,GAAG,aAAa,YAAY,OAAO,CAAC,MAAM;SAC7C;AAIN;;AAIF,KAAI,CAAC,uBAAuB,OAAO,CACjC;AAGF,KAAI,wBAAwB"}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { n as nearestPackageJsonDir } from "./relay-secret-store-
|
|
2
|
+
import { n as nearestPackageJsonDir } from "./relay-secret-store-DBcKWUl9.js";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
//#region src/mcp/relay-url-store.ts
|
|
5
5
|
/**
|
|
@@ -115,4 +115,4 @@ async function readRelayUrls(deps) {
|
|
|
115
115
|
//#endregion
|
|
116
116
|
export { readRelayUrls };
|
|
117
117
|
|
|
118
|
-
//# sourceMappingURL=relay-url-store-
|
|
118
|
+
//# sourceMappingURL=relay-url-store-Dq3vpd95.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"relay-url-store-qaoe0zOD.js","names":[],"sources":["../src/mcp/relay-url-store.ts"],"sourcesContent":["/**\n * Project-local ephemeral URL store (#424).\n *\n * Environment-2 (\"AITC Sandbox PWA\") cold-start requires two ephemeral URLs:\n * - `relayBaseUrl` — the CDP relay's https base (was `AIT_RELAY_BASE_URL`)\n * - `tunnelBaseUrl` — the app's https tunnel base (was `AIT_TUNNEL_BASE_URL`)\n *\n * Quick-tunnel URLs change every run. Previously the user had to copy-paste them\n * into env vars on every cold-start — a single typo silently broke attach. This\n * module replaces that manual hand-off with a file-based discovery pattern that\n * exactly mirrors the `.ait_relay` TOTP-secret store (relay-secret-store.ts).\n *\n * Two surfaces, intentionally split by who is allowed to write:\n *\n * - {@link writeRelayUrls} — WRITE path, called ONLY from the unplugin\n * (env-2 tunnel boot). Writes JSON to `<projectRoot>/.ait_urls` (0600) on\n * every boot (`flag: 'w'` — overwrite). A single file — no directory is\n * created.\n *\n * - {@link readRelayUrls} — READ-ONLY path, called from the MCP daemon as a\n * fallback when `AIT_RELAY_BASE_URL`/`AIT_TUNNEL_BASE_URL` are not set. It\n * NEVER writes, chmods, or creates anything: it only reads an existing\n * `.ait_urls`. On any failure (missing file / bad JSON / wrong shape) it\n * returns `null` silently, letting the downstream assertion be the single\n * fail-fast.\n *\n * - {@link deleteRelayUrls} — called from the unplugin `cleanup()` on\n * teardown (via `void deleteRelayUrls(...)`). A stale `.ait_urls` pointing\n * at a dead tunnel would cause the MCP daemon to attempt a doomed attach on\n * the next cold-start — deletion is non-negotiable. Silently swallows all\n * errors.\n *\n * Design note: the env vars (`AIT_RELAY_BASE_URL`, `AIT_TUNNEL_BASE_URL`) are\n * PRESERVED as operator overrides. The file is the fallback — env wins.\n *\n * Why `nearestPackageJsonDir` instead of `process.cwd()`: see relay-secret-store.ts.\n * The unplugin (writer) and the MCP daemon (reader) both anchor to the nearest\n * package.json from their respective `projectRoot` inputs, ensuring both sides\n * find the same file.\n *\n * SECRET-HANDLING: `relayBaseUrl` and `tunnelBaseUrl` carry the relay/tunnel host\n * — same sensitivity class as `.ait_relay`. The raw URL values, partial values,\n * and the resolved file path MUST NOT appear in any log, error message, stdout,\n * stderr, or assertion output anywhere in this module or at its call sites.\n * Only boolean pass/fail signals are safe to surface. The file is written mode\n * 0600.\n */\n\nimport { join } from 'node:path';\nimport { nearestPackageJsonDir } from './relay-secret-store.js';\n\n// ---------------------------------------------------------------------------\n// Constants\n// ---------------------------------------------------------------------------\n\n/** Project-local ephemeral URL file name (single file, not a directory). */\nexport const URLS_FILE_NAME = '.ait_urls';\n\n// ---------------------------------------------------------------------------\n// Dependency injection surfaces\n// ---------------------------------------------------------------------------\n\n/** Minimal fs subset needed by {@link writeRelayUrls} — injectable for tests. */\nexport interface RelayUrlWriteFs {\n writeFileSync(path: string, data: string, options: { mode: number; flag: string }): void;\n existsSync(path: string): boolean;\n}\n\n/** Minimal fs subset needed by {@link readRelayUrls} — injectable for tests. */\nexport interface RelayUrlReadFs {\n existsSync(path: string): boolean;\n readFileSync(path: string, encoding: BufferEncoding): string;\n}\n\n/** Minimal fs subset needed by {@link deleteRelayUrls} — injectable for tests. */\nexport interface RelayUrlDeleteFs {\n existsSync(path: string): boolean;\n unlinkSync(path: string): void;\n}\n\n// ---------------------------------------------------------------------------\n// Path helper\n// ---------------------------------------------------------------------------\n\n/**\n * Absolute path to the project-local `.ait_urls` file for a given start\n * directory (resolved against the nearest package.json directory).\n *\n * Exported so tests can compute the expected path without duplicating the\n * resolution logic.\n */\nexport function urlsFilePath(start: string, existsSyncFn: (path: string) => boolean): string {\n return join(nearestPackageJsonDir(start, existsSyncFn), URLS_FILE_NAME);\n}\n\n// ---------------------------------------------------------------------------\n// WRITE path (unplugin only) — overwrite on every boot\n// ---------------------------------------------------------------------------\n\nexport interface WriteRelayUrlsDeps {\n /** Project root (typically Vite `server.config.root`). */\n projectRoot: string;\n /**\n * The CDP relay's https base URL (same value as `AIT_RELAY_BASE_URL`).\n * Omit when the relay was not started (e.g. `cdp:false`).\n * SECRET-HANDLING: never log this value.\n */\n relayBaseUrl?: string;\n /**\n * The app tunnel's https base URL (same value as `AIT_TUNNEL_BASE_URL`).\n * Omit when no tunnel URL is available.\n * SECRET-HANDLING: never log this value.\n */\n tunnelBaseUrl?: string;\n /** Filesystem operations. Defaults to node:fs synchronous functions. */\n fs?: RelayUrlWriteFs;\n /** existsSync used to resolve the nearest package.json directory. Defaults to node:fs. */\n existsSync?: (path: string) => boolean;\n}\n\n/**\n * Writes `{ relayBaseUrl, tunnelBaseUrl }` (omitting absent keys) to\n * `<projectRoot>/.ait_urls` (mode 0600). Uses `flag: 'w'` (overwrite) because\n * URLs are ephemeral — a fresh URL replaces the previous one on every boot.\n *\n * Unlike the `.ait_relay` secret store this does NOT use `O_EXCL` (`'wx'`):\n * there is no race concern here (only the unplugin writes this file) and the\n * URL must be fresh on every cold-start.\n *\n * Called ONLY from the unplugin (env-2 tunnel boot). The MCP daemon uses\n * {@link readRelayUrls} (read-only) — it must never write.\n *\n * SECRET-HANDLING: URL values are never logged.\n */\nexport async function writeRelayUrls(deps: WriteRelayUrlsDeps): Promise<void> {\n const { projectRoot, relayBaseUrl, tunnelBaseUrl, fs: fsDep, existsSync: existsSyncDep } = deps;\n\n const fs: RelayUrlWriteFs = fsDep ?? (await import('node:fs'));\n const existsSyncFn: (path: string) => boolean = existsSyncDep ?? fs.existsSync;\n\n const filePath = urlsFilePath(projectRoot, existsSyncFn);\n\n // Build the payload — omit keys whose values are absent.\n const payload: { relayBaseUrl?: string; tunnelBaseUrl?: string } = {};\n if (typeof relayBaseUrl === 'string' && relayBaseUrl !== '') {\n payload.relayBaseUrl = relayBaseUrl;\n }\n if (typeof tunnelBaseUrl === 'string' && tunnelBaseUrl !== '') {\n payload.tunnelBaseUrl = tunnelBaseUrl;\n }\n\n // SECRET-HANDLING: JSON content (which includes URL values) is written to\n // the file only — never to any log, stdout, or stderr.\n const data = JSON.stringify(payload);\n\n // Overwrite on every boot (`flag: 'w'`) — URLs are ephemeral.\n fs.writeFileSync(filePath, data, { mode: 0o600, flag: 'w' });\n}\n\n// ---------------------------------------------------------------------------\n// READ-ONLY path (daemon only) — never writes, chmods, or creates anything\n// ---------------------------------------------------------------------------\n\nexport interface ReadRelayUrlsDeps {\n /** Project root supplied per-debug-session. When omitted, returns `null`. */\n projectRoot?: string;\n /** Read-only filesystem operations. Defaults to node:fs (existsSync + readFileSync). */\n fs?: RelayUrlReadFs;\n /** existsSync used to resolve the nearest package.json directory. Defaults to node:fs. */\n existsSync?: (path: string) => boolean;\n}\n\n/**\n * Reads `<projectRoot>/.ait_urls` and returns the stored URLs, or `null` on\n * any failure.\n *\n * Strictly READ-ONLY: only `existsSync` + `readFileSync`. Never writes,\n * chmods, or creates files/directories.\n *\n * Returns `null` (silently, no throw, no log) on:\n * - missing `projectRoot`\n * - missing `.ait_urls` file\n * - unreadable file (permissions, transient FS error)\n * - invalid JSON\n * - wrong shape (non-object, non-string values)\n *\n * Trims string values before returning. Ignores non-string fields.\n *\n * SECRET-HANDLING: URL values and the file path are never logged.\n */\nexport async function readRelayUrls(deps?: ReadRelayUrlsDeps): Promise<{\n relayBaseUrl?: string;\n tunnelBaseUrl?: string;\n} | null> {\n const { projectRoot, fs: fsDep, existsSync: existsSyncDep } = deps ?? {};\n\n if (projectRoot === undefined) {\n return null;\n }\n\n const fs: RelayUrlReadFs = fsDep ?? (await import('node:fs'));\n const existsSyncFn: (path: string) => boolean = existsSyncDep ?? fs.existsSync;\n\n const filePath = urlsFilePath(projectRoot, existsSyncFn);\n\n if (!fs.existsSync(filePath)) {\n return null;\n }\n\n let raw: string;\n try {\n raw = fs.readFileSync(filePath, 'utf8');\n } catch {\n // Unreadable file — silent no-op, let the downstream assert be the fail-fast.\n // SECRET-HANDLING: the error and path are not surfaced.\n return null;\n }\n\n let parsed: unknown;\n try {\n parsed = JSON.parse(raw);\n } catch {\n // Invalid JSON — silent no-op.\n return null;\n }\n\n if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {\n return null;\n }\n\n const obj = parsed as Record<string, unknown>;\n const result: { relayBaseUrl?: string; tunnelBaseUrl?: string } = {};\n\n const relay = obj.relayBaseUrl;\n if (typeof relay === 'string') {\n const trimmed = relay.trim();\n if (trimmed !== '') result.relayBaseUrl = trimmed;\n }\n\n const tunnel = obj.tunnelBaseUrl;\n if (typeof tunnel === 'string') {\n const trimmed = tunnel.trim();\n if (trimmed !== '') result.tunnelBaseUrl = trimmed;\n }\n\n return result;\n}\n\n// ---------------------------------------------------------------------------\n// DELETE path (unplugin cleanup only)\n// ---------------------------------------------------------------------------\n\nexport interface DeleteRelayUrlsDeps {\n /** Project root. */\n projectRoot: string;\n /** Filesystem operations. Defaults to node:fs (existsSync + unlinkSync). */\n fs?: RelayUrlDeleteFs;\n /** existsSync used to resolve the nearest package.json directory. */\n existsSync?: (path: string) => boolean;\n}\n\n/**\n * Removes `<projectRoot>/.ait_urls` if present. Silently swallows ENOENT and\n * any other error so cleanup always succeeds.\n *\n * Called ONLY from the unplugin's `cleanup()` on `httpServer 'close'` + signals\n * (via `void deleteRelayUrls(...)`). A stale `.ait_urls` pointing at a dead\n * tunnel would cause the MCP daemon to attempt a doomed attach on the next\n * cold-start — deletion is non-negotiable.\n *\n * SECRET-HANDLING: the file path is never logged.\n */\nexport async function deleteRelayUrls(deps: DeleteRelayUrlsDeps): Promise<void> {\n const { projectRoot, fs: fsDep, existsSync: existsSyncDep } = deps;\n\n const fs: RelayUrlDeleteFs = fsDep ?? (await import('node:fs'));\n const existsSyncFn: (path: string) => boolean = existsSyncDep ?? fs.existsSync;\n\n const filePath = urlsFilePath(projectRoot, existsSyncFn);\n\n try {\n if (fs.existsSync(filePath)) {\n fs.unlinkSync(filePath);\n }\n } catch {\n // Swallow ENOENT and any other error — cleanup is best-effort.\n // SECRET-HANDLING: the path is not logged.\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAwDA,MAAa,iBAAiB;;;;;;;;AAmC9B,SAAgB,aAAa,OAAe,cAAiD;AAC3F,QAAO,KAAK,sBAAsB,OAAO,aAAa,EAAE,eAAe;;;;;;;;;;;;;;;;;;;;AAkGzE,eAAsB,cAAc,MAG1B;CACR,MAAM,EAAE,aAAa,IAAI,OAAO,YAAY,kBAAkB,QAAQ,EAAE;AAExE,KAAI,gBAAgB,KAAA,EAClB,QAAO;CAGT,MAAM,KAAqB,SAAU,MAAM,OAAO;CAGlD,MAAM,WAAW,aAAa,aAFkB,iBAAiB,GAAG,WAEZ;AAExD,KAAI,CAAC,GAAG,WAAW,SAAS,CAC1B,QAAO;CAGT,IAAI;AACJ,KAAI;AACF,QAAM,GAAG,aAAa,UAAU,OAAO;SACjC;AAGN,SAAO;;CAGT,IAAI;AACJ,KAAI;AACF,WAAS,KAAK,MAAM,IAAI;SAClB;AAEN,SAAO;;AAGT,KAAI,OAAO,WAAW,YAAY,WAAW,QAAQ,MAAM,QAAQ,OAAO,CACxE,QAAO;CAGT,MAAM,MAAM;CACZ,MAAM,SAA4D,EAAE;CAEpE,MAAM,QAAQ,IAAI;AAClB,KAAI,OAAO,UAAU,UAAU;EAC7B,MAAM,UAAU,MAAM,MAAM;AAC5B,MAAI,YAAY,GAAI,QAAO,eAAe;;CAG5C,MAAM,SAAS,IAAI;AACnB,KAAI,OAAO,WAAW,UAAU;EAC9B,MAAM,UAAU,OAAO,MAAM;AAC7B,MAAI,YAAY,GAAI,QAAO,gBAAgB;;AAG7C,QAAO"}
|
|
1
|
+
{"version":3,"file":"relay-url-store-Dq3vpd95.js","names":[],"sources":["../src/mcp/relay-url-store.ts"],"sourcesContent":["/**\n * Project-local ephemeral URL store (#424).\n *\n * Environment-2 (\"AITC Sandbox PWA\") cold-start requires two ephemeral URLs:\n * - `relayBaseUrl` — the CDP relay's https base (was `AIT_RELAY_BASE_URL`)\n * - `tunnelBaseUrl` — the app's https tunnel base (was `AIT_TUNNEL_BASE_URL`)\n *\n * Quick-tunnel URLs change every run. Previously the user had to copy-paste them\n * into env vars on every cold-start — a single typo silently broke attach. This\n * module replaces that manual hand-off with a file-based discovery pattern that\n * exactly mirrors the `.ait_relay` TOTP-secret store (relay-secret-store.ts).\n *\n * Two surfaces, intentionally split by who is allowed to write:\n *\n * - {@link writeRelayUrls} — WRITE path, called ONLY from the unplugin\n * (env-2 tunnel boot). Writes JSON to `<projectRoot>/.ait_urls` (0600) on\n * every boot (`flag: 'w'` — overwrite). A single file — no directory is\n * created.\n *\n * - {@link readRelayUrls} — READ-ONLY path, called from the MCP daemon as a\n * fallback when `AIT_RELAY_BASE_URL`/`AIT_TUNNEL_BASE_URL` are not set. It\n * NEVER writes, chmods, or creates anything: it only reads an existing\n * `.ait_urls`. On any failure (missing file / bad JSON / wrong shape) it\n * returns `null` silently, letting the downstream assertion be the single\n * fail-fast.\n *\n * - {@link deleteRelayUrls} — called from the unplugin `cleanup()` on\n * teardown (via `void deleteRelayUrls(...)`). A stale `.ait_urls` pointing\n * at a dead tunnel would cause the MCP daemon to attempt a doomed attach on\n * the next cold-start — deletion is non-negotiable. Silently swallows all\n * errors.\n *\n * Design note: the env vars (`AIT_RELAY_BASE_URL`, `AIT_TUNNEL_BASE_URL`) are\n * PRESERVED as operator overrides. The file is the fallback — env wins.\n *\n * Why `nearestPackageJsonDir` instead of `process.cwd()`: see relay-secret-store.ts.\n * The unplugin (writer) and the MCP daemon (reader) both anchor to the nearest\n * package.json from their respective `projectRoot` inputs, ensuring both sides\n * find the same file.\n *\n * SECRET-HANDLING: `relayBaseUrl` and `tunnelBaseUrl` carry the relay/tunnel host\n * — same sensitivity class as `.ait_relay`. The raw URL values, partial values,\n * and the resolved file path MUST NOT appear in any log, error message, stdout,\n * stderr, or assertion output anywhere in this module or at its call sites.\n * Only boolean pass/fail signals are safe to surface. The file is written mode\n * 0600.\n */\n\nimport { join } from 'node:path';\nimport { nearestPackageJsonDir } from './relay-secret-store.js';\n\n// ---------------------------------------------------------------------------\n// Constants\n// ---------------------------------------------------------------------------\n\n/** Project-local ephemeral URL file name (single file, not a directory). */\nexport const URLS_FILE_NAME = '.ait_urls';\n\n// ---------------------------------------------------------------------------\n// Dependency injection surfaces\n// ---------------------------------------------------------------------------\n\n/** Minimal fs subset needed by {@link writeRelayUrls} — injectable for tests. */\nexport interface RelayUrlWriteFs {\n writeFileSync(path: string, data: string, options: { mode: number; flag: string }): void;\n existsSync(path: string): boolean;\n}\n\n/** Minimal fs subset needed by {@link readRelayUrls} — injectable for tests. */\nexport interface RelayUrlReadFs {\n existsSync(path: string): boolean;\n readFileSync(path: string, encoding: BufferEncoding): string;\n}\n\n/** Minimal fs subset needed by {@link deleteRelayUrls} — injectable for tests. */\nexport interface RelayUrlDeleteFs {\n existsSync(path: string): boolean;\n unlinkSync(path: string): void;\n}\n\n// ---------------------------------------------------------------------------\n// Path helper\n// ---------------------------------------------------------------------------\n\n/**\n * Absolute path to the project-local `.ait_urls` file for a given start\n * directory (resolved against the nearest package.json directory).\n *\n * Exported so tests can compute the expected path without duplicating the\n * resolution logic.\n */\nexport function urlsFilePath(start: string, existsSyncFn: (path: string) => boolean): string {\n return join(nearestPackageJsonDir(start, existsSyncFn), URLS_FILE_NAME);\n}\n\n// ---------------------------------------------------------------------------\n// WRITE path (unplugin only) — overwrite on every boot\n// ---------------------------------------------------------------------------\n\nexport interface WriteRelayUrlsDeps {\n /** Project root (typically Vite `server.config.root`). */\n projectRoot: string;\n /**\n * The CDP relay's https base URL (same value as `AIT_RELAY_BASE_URL`).\n * Omit when the relay was not started (e.g. `cdp:false`).\n * SECRET-HANDLING: never log this value.\n */\n relayBaseUrl?: string;\n /**\n * The app tunnel's https base URL (same value as `AIT_TUNNEL_BASE_URL`).\n * Omit when no tunnel URL is available.\n * SECRET-HANDLING: never log this value.\n */\n tunnelBaseUrl?: string;\n /** Filesystem operations. Defaults to node:fs synchronous functions. */\n fs?: RelayUrlWriteFs;\n /** existsSync used to resolve the nearest package.json directory. Defaults to node:fs. */\n existsSync?: (path: string) => boolean;\n}\n\n/**\n * Writes `{ relayBaseUrl, tunnelBaseUrl }` (omitting absent keys) to\n * `<projectRoot>/.ait_urls` (mode 0600). Uses `flag: 'w'` (overwrite) because\n * URLs are ephemeral — a fresh URL replaces the previous one on every boot.\n *\n * Unlike the `.ait_relay` secret store this does NOT use `O_EXCL` (`'wx'`):\n * there is no race concern here (only the unplugin writes this file) and the\n * URL must be fresh on every cold-start.\n *\n * Called ONLY from the unplugin (env-2 tunnel boot). The MCP daemon uses\n * {@link readRelayUrls} (read-only) — it must never write.\n *\n * SECRET-HANDLING: URL values are never logged.\n */\nexport async function writeRelayUrls(deps: WriteRelayUrlsDeps): Promise<void> {\n const { projectRoot, relayBaseUrl, tunnelBaseUrl, fs: fsDep, existsSync: existsSyncDep } = deps;\n\n const fs: RelayUrlWriteFs = fsDep ?? (await import('node:fs'));\n const existsSyncFn: (path: string) => boolean = existsSyncDep ?? fs.existsSync;\n\n const filePath = urlsFilePath(projectRoot, existsSyncFn);\n\n // Build the payload — omit keys whose values are absent.\n const payload: { relayBaseUrl?: string; tunnelBaseUrl?: string } = {};\n if (typeof relayBaseUrl === 'string' && relayBaseUrl !== '') {\n payload.relayBaseUrl = relayBaseUrl;\n }\n if (typeof tunnelBaseUrl === 'string' && tunnelBaseUrl !== '') {\n payload.tunnelBaseUrl = tunnelBaseUrl;\n }\n\n // SECRET-HANDLING: JSON content (which includes URL values) is written to\n // the file only — never to any log, stdout, or stderr.\n const data = JSON.stringify(payload);\n\n // Overwrite on every boot (`flag: 'w'`) — URLs are ephemeral.\n fs.writeFileSync(filePath, data, { mode: 0o600, flag: 'w' });\n}\n\n// ---------------------------------------------------------------------------\n// READ-ONLY path (daemon only) — never writes, chmods, or creates anything\n// ---------------------------------------------------------------------------\n\nexport interface ReadRelayUrlsDeps {\n /** Project root supplied per-debug-session. When omitted, returns `null`. */\n projectRoot?: string;\n /** Read-only filesystem operations. Defaults to node:fs (existsSync + readFileSync). */\n fs?: RelayUrlReadFs;\n /** existsSync used to resolve the nearest package.json directory. Defaults to node:fs. */\n existsSync?: (path: string) => boolean;\n}\n\n/**\n * Reads `<projectRoot>/.ait_urls` and returns the stored URLs, or `null` on\n * any failure.\n *\n * Strictly READ-ONLY: only `existsSync` + `readFileSync`. Never writes,\n * chmods, or creates files/directories.\n *\n * Returns `null` (silently, no throw, no log) on:\n * - missing `projectRoot`\n * - missing `.ait_urls` file\n * - unreadable file (permissions, transient FS error)\n * - invalid JSON\n * - wrong shape (non-object, non-string values)\n *\n * Trims string values before returning. Ignores non-string fields.\n *\n * SECRET-HANDLING: URL values and the file path are never logged.\n */\nexport async function readRelayUrls(deps?: ReadRelayUrlsDeps): Promise<{\n relayBaseUrl?: string;\n tunnelBaseUrl?: string;\n} | null> {\n const { projectRoot, fs: fsDep, existsSync: existsSyncDep } = deps ?? {};\n\n if (projectRoot === undefined) {\n return null;\n }\n\n const fs: RelayUrlReadFs = fsDep ?? (await import('node:fs'));\n const existsSyncFn: (path: string) => boolean = existsSyncDep ?? fs.existsSync;\n\n const filePath = urlsFilePath(projectRoot, existsSyncFn);\n\n if (!fs.existsSync(filePath)) {\n return null;\n }\n\n let raw: string;\n try {\n raw = fs.readFileSync(filePath, 'utf8');\n } catch {\n // Unreadable file — silent no-op, let the downstream assert be the fail-fast.\n // SECRET-HANDLING: the error and path are not surfaced.\n return null;\n }\n\n let parsed: unknown;\n try {\n parsed = JSON.parse(raw);\n } catch {\n // Invalid JSON — silent no-op.\n return null;\n }\n\n if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {\n return null;\n }\n\n const obj = parsed as Record<string, unknown>;\n const result: { relayBaseUrl?: string; tunnelBaseUrl?: string } = {};\n\n const relay = obj.relayBaseUrl;\n if (typeof relay === 'string') {\n const trimmed = relay.trim();\n if (trimmed !== '') result.relayBaseUrl = trimmed;\n }\n\n const tunnel = obj.tunnelBaseUrl;\n if (typeof tunnel === 'string') {\n const trimmed = tunnel.trim();\n if (trimmed !== '') result.tunnelBaseUrl = trimmed;\n }\n\n return result;\n}\n\n// ---------------------------------------------------------------------------\n// DELETE path (unplugin cleanup only)\n// ---------------------------------------------------------------------------\n\nexport interface DeleteRelayUrlsDeps {\n /** Project root. */\n projectRoot: string;\n /** Filesystem operations. Defaults to node:fs (existsSync + unlinkSync). */\n fs?: RelayUrlDeleteFs;\n /** existsSync used to resolve the nearest package.json directory. */\n existsSync?: (path: string) => boolean;\n}\n\n/**\n * Removes `<projectRoot>/.ait_urls` if present. Silently swallows ENOENT and\n * any other error so cleanup always succeeds.\n *\n * Called ONLY from the unplugin's `cleanup()` on `httpServer 'close'` + signals\n * (via `void deleteRelayUrls(...)`). A stale `.ait_urls` pointing at a dead\n * tunnel would cause the MCP daemon to attempt a doomed attach on the next\n * cold-start — deletion is non-negotiable.\n *\n * SECRET-HANDLING: the file path is never logged.\n */\nexport async function deleteRelayUrls(deps: DeleteRelayUrlsDeps): Promise<void> {\n const { projectRoot, fs: fsDep, existsSync: existsSyncDep } = deps;\n\n const fs: RelayUrlDeleteFs = fsDep ?? (await import('node:fs'));\n const existsSyncFn: (path: string) => boolean = existsSyncDep ?? fs.existsSync;\n\n const filePath = urlsFilePath(projectRoot, existsSyncFn);\n\n try {\n if (fs.existsSync(filePath)) {\n fs.unlinkSync(filePath);\n }\n } catch {\n // Swallow ENOENT and any other error — cleanup is best-effort.\n // SECRET-HANDLING: the path is not logged.\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAwDA,MAAa,iBAAiB;;;;;;;;AAmC9B,SAAgB,aAAa,OAAe,cAAiD;AAC3F,QAAO,KAAK,sBAAsB,OAAO,aAAa,EAAE,eAAe;;;;;;;;;;;;;;;;;;;;AAkGzE,eAAsB,cAAc,MAG1B;CACR,MAAM,EAAE,aAAa,IAAI,OAAO,YAAY,kBAAkB,QAAQ,EAAE;AAExE,KAAI,gBAAgB,KAAA,EAClB,QAAO;CAGT,MAAM,KAAqB,SAAU,MAAM,OAAO;CAGlD,MAAM,WAAW,aAAa,aAFkB,iBAAiB,GAAG,WAEZ;AAExD,KAAI,CAAC,GAAG,WAAW,SAAS,CAC1B,QAAO;CAGT,IAAI;AACJ,KAAI;AACF,QAAM,GAAG,aAAa,UAAU,OAAO;SACjC;AAGN,SAAO;;CAGT,IAAI;AACJ,KAAI;AACF,WAAS,KAAK,MAAM,IAAI;SAClB;AAEN,SAAO;;AAGT,KAAI,OAAO,WAAW,YAAY,WAAW,QAAQ,MAAM,QAAQ,OAAO,CACxE,QAAO;CAGT,MAAM,MAAM;CACZ,MAAM,SAA4D,EAAE;CAEpE,MAAM,QAAQ,IAAI;AAClB,KAAI,OAAO,UAAU,UAAU;EAC7B,MAAM,UAAU,MAAM,MAAM;AAC5B,MAAI,YAAY,GAAI,QAAO,eAAe;;CAG5C,MAAM,SAAS,IAAI;AACnB,KAAI,OAAO,WAAW,UAAU;EAC9B,MAAM,UAAU,OAAO,MAAM;AAC7B,MAAI,YAAY,GAAI,QAAO,gBAAgB;;AAG7C,QAAO"}
|
|
@@ -184,4 +184,4 @@ function buildRelayVerifyAuth(env = process.env) {
|
|
|
184
184
|
//#endregion
|
|
185
185
|
export { isValidRelayAuthSecret as a, generateTotp as i, assertRelayAuthConfigured as n, verifyTotp as o, buildRelayVerifyAuth as r, RELAY_AUTH_SECRET_MISSING_MESSAGE as t };
|
|
186
186
|
|
|
187
|
-
//# sourceMappingURL=totp-
|
|
187
|
+
//# sourceMappingURL=totp-D0a8VwoR.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"totp-BIrJHsQn.js","names":[],"sources":["../src/mcp/totp.ts"],"sourcesContent":["/**\n * RFC 6238 TOTP implementation (Node.js, node:crypto only).\n *\n * External TOTP libraries (otplib, speakeasy, …) are intentionally NOT used\n * to keep the dependency surface minimal. This hand-roll is ~30 lines and\n * covers exactly what relay-side auth needs.\n *\n * Algorithm summary (RFC 6238 + RFC 4226):\n * T = floor(now / 30) — 30-second time step counter\n * K = Buffer.from(secret, 'hex') — shared secret (raw bytes, hex-encoded)\n * MAC = HMAC-SHA1(K, T as 8-byte big-endian uint64)\n * offset = MAC[19] & 0x0f\n * code = (MAC[offset..offset+4] & 0x7fffffff) % 10^6 — 6 digits\n *\n * Security note (keep this comment accurate):\n * The baked-in secret in a dogfood build is extractable from the bundle by a\n * determined reverse engineer. This mechanism raises the bar from\n * \"anyone with the URL\" to \"URL + bundle extraction + live TOTP calculation\".\n * Casual URL leaks (Slack paste, QR screenshot, shoulder-surfing) are\n * blocked; deliberate reverse engineering is not. See threat model in\n * src/mcp/chii-relay.ts and umbrella CLAUDE.md §4.\n *\n * SECRET-HANDLING: secret values and computed codes MUST NOT appear in any\n * log, error message, or string visible outside this module. Only boolean\n * pass/fail and reason enum values are safe to surface.\n */\n\nimport { createHmac, timingSafeEqual } from 'node:crypto';\n\n/** Time step window in seconds (RFC 6238 default). */\nconst TIME_STEP = 30;\n\n/** Number of digits in the generated code. */\nconst DIGITS = 6;\n\n/**\n * Derives a 6-digit TOTP code from a hex-encoded secret at the given wall-\n * clock time.\n *\n * @param secret - The shared secret as a hex string (e.g. 64 hex chars = 32\n * bytes). Must be the output of `generateAttachToken()` or compatible.\n * @param when - Unix timestamp in milliseconds. Defaults to `Date.now()`.\n * @returns A zero-padded 6-digit decimal string, e.g. `\"042193\"`.\n */\nexport function generateTotp(secret: string, when: number = Date.now()): string {\n const key = Buffer.from(secret, 'hex');\n // Clamp to 0 so negative timestamps (e.g. in ±skew checks near epoch) do not\n // produce a negative counter, which would cause writeUInt32BE to throw.\n const counter = Math.max(0, Math.floor(when / 1000 / TIME_STEP));\n\n // Encode counter as 8-byte big-endian unsigned integer.\n const counterBuf = Buffer.alloc(8);\n // JavaScript numbers are safe integers up to 2^53; counter is ~7.5×10^10 at\n // year 9999 — well within safe range so standard bitwise ops are fine.\n const hi = Math.floor(counter / 0x100000000);\n const lo = counter >>> 0;\n counterBuf.writeUInt32BE(hi, 0);\n counterBuf.writeUInt32BE(lo, 4);\n\n const mac = createHmac('sha1', key).update(counterBuf).digest();\n\n // Dynamic truncation (RFC 4226 §5.4).\n const offset = mac[19] & 0x0f;\n const binCode =\n ((mac[offset] & 0x7f) << 24) |\n ((mac[offset + 1] & 0xff) << 16) |\n ((mac[offset + 2] & 0xff) << 8) |\n (mac[offset + 3] & 0xff);\n\n const otp = binCode % 10 ** DIGITS;\n return otp.toString().padStart(DIGITS, '0');\n}\n\n/**\n * Verifies a TOTP code against the secret, accepting ±`skew` time steps to\n * tolerate clock drift between the relay host and the client device.\n *\n * Uses `timingSafeEqual` for constant-time comparison to prevent timing\n * side-channel attacks.\n *\n * @param secret - Hex-encoded shared secret.\n * @param code - The 6-digit code to verify (string or numeric).\n * @param when - Unix timestamp in milliseconds. Defaults to `Date.now()`.\n * @param skew - Number of adjacent steps to accept on either side. Default 1\n * (accepts T-1, T, T+1 — a 90-second acceptance window).\n * @returns `true` if the code matches any accepted step, `false` otherwise.\n */\nexport function verifyTotp(\n secret: string,\n code: string,\n when: number = Date.now(),\n skew: number = 1,\n): boolean {\n const normalised = String(code).padStart(DIGITS, '0');\n if (normalised.length !== DIGITS || !/^\\d{6}$/.test(normalised)) {\n return false;\n }\n\n const candidateBuf = Buffer.from(normalised, 'utf8');\n\n for (let delta = -skew; delta <= skew; delta++) {\n const stepWhen = when + delta * TIME_STEP * 1000;\n const expected = generateTotp(secret, stepWhen);\n const expectedBuf = Buffer.from(expected, 'utf8');\n if (timingSafeEqual(expectedBuf, candidateBuf)) {\n return true;\n }\n }\n\n return false;\n}\n\n/**\n * Minimum length (in hex characters) accepted for `AIT_DEBUG_TOTP_SECRET`.\n *\n * The secret is hex-encoded (see {@link generateTotp} — `Buffer.from(secret,\n * 'hex')`). 32 hex chars = 16 bytes = 128 bits, the floor for an HMAC-SHA1 key\n * we are willing to gate a public relay behind. `generateAttachToken()` emits\n * 64 hex chars (32 bytes), comfortably above this bar.\n */\nconst MIN_SECRET_HEX_CHARS = 32;\n\n/** Hex string: one or more hex digits, case-insensitive (RFC 4648 base16). */\nconst HEX_RE = /^[0-9a-fA-F]+$/;\n\n/**\n * Human-facing guidance printed when {@link assertRelayAuthConfigured} fails.\n *\n * SECRET-HANDLING: this message states only the REQUIREMENT (≥32 hex chars) and\n * how to mint one. It NEVER echoes the configured value, its length, or any\n * fragment derived from it — see {@link assertRelayAuthConfigured}.\n *\n * Note on encoding: the secret is hex (base16), not base32 — `generateTotp`\n * decodes it with `Buffer.from(secret, 'hex')`. A base32 string would be\n * silently mis-decoded and every TOTP code would fail to match, so the minting\n * command emits hex.\n */\nexport const RELAY_AUTH_SECRET_MISSING_MESSAGE = [\n '[ait-debug] AIT_DEBUG_TOTP_SECRET이 필수입니다. 32자 이상 16진수(hex) 문자열을 설정하세요.',\n '발급: openssl rand -hex 32',\n '데몬은 start_debug의 projectRoot 인자로 받은 디렉토리에서 .ait_relay 파일을 읽어 이 시크릿을 채웁니다.',\n '프로젝트에서 pnpm dev를 한 번 띄우면 unplugin이 .ait_relay를 자동 생성하니, projectRoot를 전달하세요.',\n '자세히: https://docs.aitc.dev/guides/relay-auth-totp',\n].join('\\n');\n\n/**\n * Whether `secret` is a well-formed relay-auth TOTP secret: a hex string of at\n * least {@link MIN_SECRET_HEX_CHARS} characters with an even length (an odd\n * length would have its trailing nibble silently dropped by `Buffer.from(...,\n * 'hex')`, weakening the key without warning).\n *\n * Pure predicate so callers can test the validation independently of the\n * fail-fast side effect in {@link assertRelayAuthConfigured}.\n *\n * SECRET-HANDLING: returns only a boolean — the input value is never returned,\n * logged, or echoed.\n */\nexport function isValidRelayAuthSecret(secret: string | undefined): secret is string {\n if (secret === undefined || secret === '') return false;\n if (secret.length < MIN_SECRET_HEX_CHARS) return false;\n if (secret.length % 2 !== 0) return false;\n return HEX_RE.test(secret);\n}\n\n/**\n * Fail-fast guard enforcing that a relay-auth TOTP secret is configured before\n * a public-internet-exposed relay is booted (issue #250).\n *\n * Relay-auth (the §4 Layer C TOTP gate) is the only fail-fast layer that closes\n * the real gap: a leaked `wss://…trycloudflare.com` URL otherwise lets a third\n * party attach a debugger to a dogfood/live mini-app. Without a secret the relay\n * comes up unauthenticated, so this guard is called at every relay-boot site —\n * `bootRelayFamily` (intoss env 3/4) and `bootExternalRelayFamily` (env-2 PWA),\n * both eager and lazy. Local-only sessions never boot a relay and so never reach\n * this guard, matching the issue's exemption for non-relay debugging.\n *\n * Throws when the secret is unset, empty, too short, or not a valid hex string.\n * The thrown message is the bin entry's fatal stderr (see `cli.ts` `main().catch`)\n * — the same fatal model as the missing-`AIT_RELAY_BASE_URL` path.\n *\n * SECRET-HANDLING: the env value is read once, passed ONLY to the boolean\n * predicate, and never logged. The thrown message names the requirement, never\n * the value, its length, or any derived fragment.\n *\n * @param env - Environment to read from. Defaults to `process.env`; injectable\n * for tests so they never mutate the real process environment.\n */\nexport function assertRelayAuthConfigured(env: NodeJS.ProcessEnv = process.env): void {\n if (!isValidRelayAuthSecret(env.AIT_DEBUG_TOTP_SECRET)) {\n throw new Error(RELAY_AUTH_SECRET_MISSING_MESSAGE);\n }\n}\n\n/**\n * Reads `AIT_DEBUG_TOTP_SECRET` from `process.env` at runtime and builds a\n * `verifyAuth` predicate for the Chii relay's WebSocket upgrade gate.\n *\n * The predicate checks the `at` query parameter against the current and\n * adjacent TOTP time steps (±1 skew) using {@link verifyTotp}.\n *\n * Returns `undefined` when the env var is not set — callers treat that as\n * \"auth disabled\" (no predicate registered on the relay). Note that since\n * issue #250 the secret is MANDATORY at every relay-boot site (enforced by\n * {@link assertRelayAuthConfigured} BEFORE the relay starts), so in production\n * this never returns `undefined` for a relay that actually boots; the\n * `undefined` branch only matters for the no-relay local path and tests.\n *\n * Lives here (not in the MCP server) so the unplugin's env-2 relay can wire the\n * same gate without importing the heavy MCP server module graph. Re-exported\n * from `debug-server.ts` for back-compat.\n *\n * SECRET-HANDLING: The secret value read from env is captured in a closure and\n * is NEVER written to any log, error message, or process output.\n */\nexport function buildRelayVerifyAuth(\n env: NodeJS.ProcessEnv = process.env,\n): ((req: import('node:http').IncomingMessage) => boolean) | undefined {\n const secret = env.AIT_DEBUG_TOTP_SECRET;\n if (!secret) return undefined;\n\n return (req) => {\n // Parse the `at` query param from the upgrade request URL.\n // req.url is the raw request path + query, e.g. `/client/id?target=…&at=123456`\n const rawUrl = req.url ?? '';\n const qIndex = rawUrl.indexOf('?');\n const queryStr = qIndex === -1 ? '' : rawUrl.slice(qIndex + 1);\n const params = new URLSearchParams(queryStr);\n const code = params.get('at') ?? '';\n\n // Do NOT log `code`, `secret`, or any derived value here.\n return verifyTotp(secret, code);\n };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA8BA,MAAM,YAAY;;AAGlB,MAAM,SAAS;;;;;;;;;;AAWf,SAAgB,aAAa,QAAgB,OAAe,KAAK,KAAK,EAAU;CAC9E,MAAM,MAAM,OAAO,KAAK,QAAQ,MAAM;CAGtC,MAAM,UAAU,KAAK,IAAI,GAAG,KAAK,MAAM,OAAO,MAAO,UAAU,CAAC;CAGhE,MAAM,aAAa,OAAO,MAAM,EAAE;CAGlC,MAAM,KAAK,KAAK,MAAM,UAAU,WAAY;CAC5C,MAAM,KAAK,YAAY;AACvB,YAAW,cAAc,IAAI,EAAE;AAC/B,YAAW,cAAc,IAAI,EAAE;CAE/B,MAAM,MAAM,WAAW,QAAQ,IAAI,CAAC,OAAO,WAAW,CAAC,QAAQ;CAG/D,MAAM,SAAS,IAAI,MAAM;AAQzB,WANI,IAAI,UAAU,QAAS,MACvB,IAAI,SAAS,KAAK,QAAS,MAC3B,IAAI,SAAS,KAAK,QAAS,IAC5B,IAAI,SAAS,KAAK,OAEC,MAAM,QACjB,UAAU,CAAC,SAAS,QAAQ,IAAI;;;;;;;;;;;;;;;;AAiB7C,SAAgB,WACd,QACA,MACA,OAAe,KAAK,KAAK,EACzB,OAAe,GACN;CACT,MAAM,aAAa,OAAO,KAAK,CAAC,SAAS,QAAQ,IAAI;AACrD,KAAI,WAAW,WAAW,UAAU,CAAC,UAAU,KAAK,WAAW,CAC7D,QAAO;CAGT,MAAM,eAAe,OAAO,KAAK,YAAY,OAAO;AAEpD,MAAK,IAAI,QAAQ,CAAC,MAAM,SAAS,MAAM,SAAS;EAE9C,MAAM,WAAW,aAAa,QADb,OAAO,QAAQ,YAAY,IACG;AAE/C,MAAI,gBADgB,OAAO,KAAK,UAAU,OAAO,EAChB,aAAa,CAC5C,QAAO;;AAIX,QAAO;;;;;;;;;;AAWT,MAAM,uBAAuB;;AAG7B,MAAM,SAAS;;;;;;;;;;;;;AAcf,MAAa,oCAAoC;CAC/C;CACA;CACA;CACA;CACA;CACD,CAAC,KAAK,KAAK;;;;;;;;;;;;;AAcZ,SAAgB,uBAAuB,QAA8C;AACnF,KAAI,WAAW,KAAA,KAAa,WAAW,GAAI,QAAO;AAClD,KAAI,OAAO,SAAS,qBAAsB,QAAO;AACjD,KAAI,OAAO,SAAS,MAAM,EAAG,QAAO;AACpC,QAAO,OAAO,KAAK,OAAO;;;;;;;;;;;;;;;;;;;;;;;;;AA0B5B,SAAgB,0BAA0B,MAAyB,QAAQ,KAAW;AACpF,KAAI,CAAC,uBAAuB,IAAI,sBAAsB,CACpD,OAAM,IAAI,MAAM,kCAAkC;;;;;;;;;;;;;;;;;;;;;;;AAyBtD,SAAgB,qBACd,MAAyB,QAAQ,KACoC;CACrE,MAAM,SAAS,IAAI;AACnB,KAAI,CAAC,OAAQ,QAAO,KAAA;AAEpB,SAAQ,QAAQ;EAGd,MAAM,SAAS,IAAI,OAAO;EAC1B,MAAM,SAAS,OAAO,QAAQ,IAAI;EAClC,MAAM,WAAW,WAAW,KAAK,KAAK,OAAO,MAAM,SAAS,EAAE;AAK9D,SAAO,WAAW,QAJH,IAAI,gBAAgB,SAAS,CACxB,IAAI,KAAK,IAAI,GAGF"}
|
|
1
|
+
{"version":3,"file":"totp-D0a8VwoR.js","names":[],"sources":["../src/mcp/totp.ts"],"sourcesContent":["/**\n * RFC 6238 TOTP implementation (Node.js, node:crypto only).\n *\n * External TOTP libraries (otplib, speakeasy, …) are intentionally NOT used\n * to keep the dependency surface minimal. This hand-roll is ~30 lines and\n * covers exactly what relay-side auth needs.\n *\n * Algorithm summary (RFC 6238 + RFC 4226):\n * T = floor(now / 30) — 30-second time step counter\n * K = Buffer.from(secret, 'hex') — shared secret (raw bytes, hex-encoded)\n * MAC = HMAC-SHA1(K, T as 8-byte big-endian uint64)\n * offset = MAC[19] & 0x0f\n * code = (MAC[offset..offset+4] & 0x7fffffff) % 10^6 — 6 digits\n *\n * Security note (keep this comment accurate):\n * The baked-in secret in a dogfood build is extractable from the bundle by a\n * determined reverse engineer. This mechanism raises the bar from\n * \"anyone with the URL\" to \"URL + bundle extraction + live TOTP calculation\".\n * Casual URL leaks (Slack paste, QR screenshot, shoulder-surfing) are\n * blocked; deliberate reverse engineering is not. See threat model in\n * src/mcp/chii-relay.ts and umbrella CLAUDE.md §4.\n *\n * SECRET-HANDLING: secret values and computed codes MUST NOT appear in any\n * log, error message, or string visible outside this module. Only boolean\n * pass/fail and reason enum values are safe to surface.\n */\n\nimport { createHmac, timingSafeEqual } from 'node:crypto';\n\n/** Time step window in seconds (RFC 6238 default). */\nconst TIME_STEP = 30;\n\n/** Number of digits in the generated code. */\nconst DIGITS = 6;\n\n/**\n * Derives a 6-digit TOTP code from a hex-encoded secret at the given wall-\n * clock time.\n *\n * @param secret - The shared secret as a hex string (e.g. 64 hex chars = 32\n * bytes). Must be the output of `generateAttachToken()` or compatible.\n * @param when - Unix timestamp in milliseconds. Defaults to `Date.now()`.\n * @returns A zero-padded 6-digit decimal string, e.g. `\"042193\"`.\n */\nexport function generateTotp(secret: string, when: number = Date.now()): string {\n const key = Buffer.from(secret, 'hex');\n // Clamp to 0 so negative timestamps (e.g. in ±skew checks near epoch) do not\n // produce a negative counter, which would cause writeUInt32BE to throw.\n const counter = Math.max(0, Math.floor(when / 1000 / TIME_STEP));\n\n // Encode counter as 8-byte big-endian unsigned integer.\n const counterBuf = Buffer.alloc(8);\n // JavaScript numbers are safe integers up to 2^53; counter is ~7.5×10^10 at\n // year 9999 — well within safe range so standard bitwise ops are fine.\n const hi = Math.floor(counter / 0x100000000);\n const lo = counter >>> 0;\n counterBuf.writeUInt32BE(hi, 0);\n counterBuf.writeUInt32BE(lo, 4);\n\n const mac = createHmac('sha1', key).update(counterBuf).digest();\n\n // Dynamic truncation (RFC 4226 §5.4).\n const offset = mac[19] & 0x0f;\n const binCode =\n ((mac[offset] & 0x7f) << 24) |\n ((mac[offset + 1] & 0xff) << 16) |\n ((mac[offset + 2] & 0xff) << 8) |\n (mac[offset + 3] & 0xff);\n\n const otp = binCode % 10 ** DIGITS;\n return otp.toString().padStart(DIGITS, '0');\n}\n\n/**\n * Verifies a TOTP code against the secret, accepting ±`skew` time steps to\n * tolerate clock drift between the relay host and the client device.\n *\n * Uses `timingSafeEqual` for constant-time comparison to prevent timing\n * side-channel attacks.\n *\n * @param secret - Hex-encoded shared secret.\n * @param code - The 6-digit code to verify (string or numeric).\n * @param when - Unix timestamp in milliseconds. Defaults to `Date.now()`.\n * @param skew - Number of adjacent steps to accept on either side. Default 1\n * (accepts T-1, T, T+1 — a 90-second acceptance window).\n * @returns `true` if the code matches any accepted step, `false` otherwise.\n */\nexport function verifyTotp(\n secret: string,\n code: string,\n when: number = Date.now(),\n skew: number = 1,\n): boolean {\n const normalised = String(code).padStart(DIGITS, '0');\n if (normalised.length !== DIGITS || !/^\\d{6}$/.test(normalised)) {\n return false;\n }\n\n const candidateBuf = Buffer.from(normalised, 'utf8');\n\n for (let delta = -skew; delta <= skew; delta++) {\n const stepWhen = when + delta * TIME_STEP * 1000;\n const expected = generateTotp(secret, stepWhen);\n const expectedBuf = Buffer.from(expected, 'utf8');\n if (timingSafeEqual(expectedBuf, candidateBuf)) {\n return true;\n }\n }\n\n return false;\n}\n\n/**\n * Minimum length (in hex characters) accepted for `AIT_DEBUG_TOTP_SECRET`.\n *\n * The secret is hex-encoded (see {@link generateTotp} — `Buffer.from(secret,\n * 'hex')`). 32 hex chars = 16 bytes = 128 bits, the floor for an HMAC-SHA1 key\n * we are willing to gate a public relay behind. `generateAttachToken()` emits\n * 64 hex chars (32 bytes), comfortably above this bar.\n */\nconst MIN_SECRET_HEX_CHARS = 32;\n\n/** Hex string: one or more hex digits, case-insensitive (RFC 4648 base16). */\nconst HEX_RE = /^[0-9a-fA-F]+$/;\n\n/**\n * Human-facing guidance printed when {@link assertRelayAuthConfigured} fails.\n *\n * SECRET-HANDLING: this message states only the REQUIREMENT (≥32 hex chars) and\n * how to mint one. It NEVER echoes the configured value, its length, or any\n * fragment derived from it — see {@link assertRelayAuthConfigured}.\n *\n * Note on encoding: the secret is hex (base16), not base32 — `generateTotp`\n * decodes it with `Buffer.from(secret, 'hex')`. A base32 string would be\n * silently mis-decoded and every TOTP code would fail to match, so the minting\n * command emits hex.\n */\nexport const RELAY_AUTH_SECRET_MISSING_MESSAGE = [\n '[ait-debug] AIT_DEBUG_TOTP_SECRET이 필수입니다. 32자 이상 16진수(hex) 문자열을 설정하세요.',\n '발급: openssl rand -hex 32',\n '데몬은 start_debug의 projectRoot 인자로 받은 디렉토리에서 .ait_relay 파일을 읽어 이 시크릿을 채웁니다.',\n '프로젝트에서 pnpm dev를 한 번 띄우면 unplugin이 .ait_relay를 자동 생성하니, projectRoot를 전달하세요.',\n '자세히: https://docs.aitc.dev/guides/relay-auth-totp',\n].join('\\n');\n\n/**\n * Whether `secret` is a well-formed relay-auth TOTP secret: a hex string of at\n * least {@link MIN_SECRET_HEX_CHARS} characters with an even length (an odd\n * length would have its trailing nibble silently dropped by `Buffer.from(...,\n * 'hex')`, weakening the key without warning).\n *\n * Pure predicate so callers can test the validation independently of the\n * fail-fast side effect in {@link assertRelayAuthConfigured}.\n *\n * SECRET-HANDLING: returns only a boolean — the input value is never returned,\n * logged, or echoed.\n */\nexport function isValidRelayAuthSecret(secret: string | undefined): secret is string {\n if (secret === undefined || secret === '') return false;\n if (secret.length < MIN_SECRET_HEX_CHARS) return false;\n if (secret.length % 2 !== 0) return false;\n return HEX_RE.test(secret);\n}\n\n/**\n * Fail-fast guard enforcing that a relay-auth TOTP secret is configured before\n * a public-internet-exposed relay is booted (issue #250).\n *\n * Relay-auth (the §4 Layer C TOTP gate) is the only fail-fast layer that closes\n * the real gap: a leaked `wss://…trycloudflare.com` URL otherwise lets a third\n * party attach a debugger to a dogfood/live mini-app. Without a secret the relay\n * comes up unauthenticated, so this guard is called at every relay-boot site —\n * `bootRelayFamily` (intoss env 3/4) and `bootExternalRelayFamily` (env-2 PWA),\n * both eager and lazy. Local-only sessions never boot a relay and so never reach\n * this guard, matching the issue's exemption for non-relay debugging.\n *\n * Throws when the secret is unset, empty, too short, or not a valid hex string.\n * The thrown message is the bin entry's fatal stderr (see `cli.ts` `main().catch`)\n * — the same fatal model as the missing-`AIT_RELAY_BASE_URL` path.\n *\n * SECRET-HANDLING: the env value is read once, passed ONLY to the boolean\n * predicate, and never logged. The thrown message names the requirement, never\n * the value, its length, or any derived fragment.\n *\n * @param env - Environment to read from. Defaults to `process.env`; injectable\n * for tests so they never mutate the real process environment.\n */\nexport function assertRelayAuthConfigured(env: NodeJS.ProcessEnv = process.env): void {\n if (!isValidRelayAuthSecret(env.AIT_DEBUG_TOTP_SECRET)) {\n throw new Error(RELAY_AUTH_SECRET_MISSING_MESSAGE);\n }\n}\n\n/**\n * Reads `AIT_DEBUG_TOTP_SECRET` from `process.env` at runtime and builds a\n * `verifyAuth` predicate for the Chii relay's WebSocket upgrade gate.\n *\n * The predicate checks the `at` query parameter against the current and\n * adjacent TOTP time steps (±1 skew) using {@link verifyTotp}.\n *\n * Returns `undefined` when the env var is not set — callers treat that as\n * \"auth disabled\" (no predicate registered on the relay). Note that since\n * issue #250 the secret is MANDATORY at every relay-boot site (enforced by\n * {@link assertRelayAuthConfigured} BEFORE the relay starts), so in production\n * this never returns `undefined` for a relay that actually boots; the\n * `undefined` branch only matters for the no-relay local path and tests.\n *\n * Lives here (not in the MCP server) so the unplugin's env-2 relay can wire the\n * same gate without importing the heavy MCP server module graph. Re-exported\n * from `debug-server.ts` for back-compat.\n *\n * SECRET-HANDLING: The secret value read from env is captured in a closure and\n * is NEVER written to any log, error message, or process output.\n */\nexport function buildRelayVerifyAuth(\n env: NodeJS.ProcessEnv = process.env,\n): ((req: import('node:http').IncomingMessage) => boolean) | undefined {\n const secret = env.AIT_DEBUG_TOTP_SECRET;\n if (!secret) return undefined;\n\n return (req) => {\n // Parse the `at` query param from the upgrade request URL.\n // req.url is the raw request path + query, e.g. `/client/id?target=…&at=123456`\n const rawUrl = req.url ?? '';\n const qIndex = rawUrl.indexOf('?');\n const queryStr = qIndex === -1 ? '' : rawUrl.slice(qIndex + 1);\n const params = new URLSearchParams(queryStr);\n const code = params.get('at') ?? '';\n\n // Do NOT log `code`, `secret`, or any derived value here.\n return verifyTotp(secret, code);\n };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA8BA,MAAM,YAAY;;AAGlB,MAAM,SAAS;;;;;;;;;;AAWf,SAAgB,aAAa,QAAgB,OAAe,KAAK,KAAK,EAAU;CAC9E,MAAM,MAAM,OAAO,KAAK,QAAQ,MAAM;CAGtC,MAAM,UAAU,KAAK,IAAI,GAAG,KAAK,MAAM,OAAO,MAAO,UAAU,CAAC;CAGhE,MAAM,aAAa,OAAO,MAAM,EAAE;CAGlC,MAAM,KAAK,KAAK,MAAM,UAAU,WAAY;CAC5C,MAAM,KAAK,YAAY;AACvB,YAAW,cAAc,IAAI,EAAE;AAC/B,YAAW,cAAc,IAAI,EAAE;CAE/B,MAAM,MAAM,WAAW,QAAQ,IAAI,CAAC,OAAO,WAAW,CAAC,QAAQ;CAG/D,MAAM,SAAS,IAAI,MAAM;AAQzB,WANI,IAAI,UAAU,QAAS,MACvB,IAAI,SAAS,KAAK,QAAS,MAC3B,IAAI,SAAS,KAAK,QAAS,IAC5B,IAAI,SAAS,KAAK,OAEC,MAAM,QACjB,UAAU,CAAC,SAAS,QAAQ,IAAI;;;;;;;;;;;;;;;;AAiB7C,SAAgB,WACd,QACA,MACA,OAAe,KAAK,KAAK,EACzB,OAAe,GACN;CACT,MAAM,aAAa,OAAO,KAAK,CAAC,SAAS,QAAQ,IAAI;AACrD,KAAI,WAAW,WAAW,UAAU,CAAC,UAAU,KAAK,WAAW,CAC7D,QAAO;CAGT,MAAM,eAAe,OAAO,KAAK,YAAY,OAAO;AAEpD,MAAK,IAAI,QAAQ,CAAC,MAAM,SAAS,MAAM,SAAS;EAE9C,MAAM,WAAW,aAAa,QADb,OAAO,QAAQ,YAAY,IACG;AAE/C,MAAI,gBADgB,OAAO,KAAK,UAAU,OAAO,EAChB,aAAa,CAC5C,QAAO;;AAIX,QAAO;;;;;;;;;;AAWT,MAAM,uBAAuB;;AAG7B,MAAM,SAAS;;;;;;;;;;;;;AAcf,MAAa,oCAAoC;CAC/C;CACA;CACA;CACA;CACA;CACD,CAAC,KAAK,KAAK;;;;;;;;;;;;;AAcZ,SAAgB,uBAAuB,QAA8C;AACnF,KAAI,WAAW,KAAA,KAAa,WAAW,GAAI,QAAO;AAClD,KAAI,OAAO,SAAS,qBAAsB,QAAO;AACjD,KAAI,OAAO,SAAS,MAAM,EAAG,QAAO;AACpC,QAAO,OAAO,KAAK,OAAO;;;;;;;;;;;;;;;;;;;;;;;;;AA0B5B,SAAgB,0BAA0B,MAAyB,QAAQ,KAAW;AACpF,KAAI,CAAC,uBAAuB,IAAI,sBAAsB,CACpD,OAAM,IAAI,MAAM,kCAAkC;;;;;;;;;;;;;;;;;;;;;;;AAyBtD,SAAgB,qBACd,MAAyB,QAAQ,KACoC;CACrE,MAAM,SAAS,IAAI;AACnB,KAAI,CAAC,OAAQ,QAAO,KAAA;AAEpB,SAAQ,QAAQ;EAGd,MAAM,SAAS,IAAI,OAAO;EAC1B,MAAM,SAAS,OAAO,QAAQ,IAAI;EAClC,MAAM,WAAW,WAAW,KAAK,KAAK,OAAO,MAAM,SAAS,EAAE;AAK9D,SAAO,WAAW,QAJH,IAAI,gBAAgB,SAAS,CACxB,IAAI,KAAK,IAAI,GAGF"}
|
|
@@ -126,7 +126,7 @@ async function startTunnelDashboard(opts) {
|
|
|
126
126
|
if (opts.qr === false) return void 0;
|
|
127
127
|
const { isAutoDevtoolsDisabled } = await import("./devtools-opener-BbUXBzgA.js");
|
|
128
128
|
if (!(opts.shouldOpen ?? (() => !isAutoDevtoolsDisabled() && canOpenBrowser()))()) return void 0;
|
|
129
|
-
const { startQrHttpServer } = await import("./qr-http-server-
|
|
129
|
+
const { startQrHttpServer } = await import("./qr-http-server-DBgh4rxe.js");
|
|
130
130
|
const { buildLauncherAttachUrl } = await import("./deeplink-CaO6hZVG.js");
|
|
131
131
|
const { generateTotp } = await import("./totp-BjtKFt88.js");
|
|
132
132
|
const getDashboardState = () => {
|
|
@@ -248,4 +248,4 @@ async function startQuickTunnel(port) {
|
|
|
248
248
|
//#endregion
|
|
249
249
|
export { printTunnelBanner, startQuickTunnel, startTunnelDashboard };
|
|
250
250
|
|
|
251
|
-
//# sourceMappingURL=tunnel-
|
|
251
|
+
//# sourceMappingURL=tunnel-EVTPTwlb.js.map
|