@ait-co/devtools 0.1.31 → 0.1.33

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.en.md CHANGED
@@ -427,7 +427,9 @@ Each preset includes:
427
427
  - **CSS viewport** (portrait `width × height`)
428
428
  - **DPR** (devicePixelRatio: 2, 3, 3.5, etc.)
429
429
  - **Notch** type (`none` / `notch` / `dynamic-island` / `punch-hole-center`)
430
- - **OS-level safe area insets** (status bar / home indicator / left/right insets based on notch rotation)
430
+ - **Notch inset** — the OS notch / Dynamic Island offset. Device-specific. In portrait this does *not* reach the miniapp's top inset (it's only used for the landscape side inset and to position the visual notch overlay).
431
+ - **Nav bar height** — the Toss host's top nav bar. Device-independent (`54px` for a `partner` WebView). For a `partner` app this height *is* `SafeAreaInsets.get().top`.
432
+ - **Home-indicator inset** — the bottom safe-area inset (home indicator), device-specific.
431
433
 
432
434
  ### Orientation
433
435
 
@@ -443,15 +445,17 @@ When switching to landscape:
443
445
 
444
446
  When **Show frame** is toggled on:
445
447
  - Border-radius + box-shadow to mimic the device bezel
446
- - Notch / Dynamic Island / punch-hole overlay (absolutely positioned at the top of body)
448
+ - Notch / Dynamic Island / punch-hole overlay — drawn in the status-bar area *above* the WebView (body), because on a real device the OS notch sits outside the WebView viewport (that's why `env(safe-area-inset-top)` is 0).
447
449
  - Home indicator pill (only on devices with `safeAreaBottom > 0`, positioned at the bottom of body)
448
450
  - App name uses `aitState.brand.displayName` (editable in the Environment tab, auto-updates)
449
451
  - The back button triggers `__ait:backEvent` and the X button calls `closeView()` — you can verify actual SDK event plumbing directly from the panel
450
452
 
451
453
  When **Show Apps in Toss nav bar** is toggled on (default on):
452
- - A 48px nav bar overlay simulating the Toss host's top nav bar (back / app icon+name / / ×)
453
- - Positioned just below the status bar, after the safe area top
454
- - **Important**: these 48px are **not included** in `env(safe-area-inset-top)` or `SafeAreaInsets.get().top` (this matches the SDK behavior). Toss-side examples compensate using the pattern `insets.top + 48`.
454
+ - A 54px nav bar overlay simulating the Toss host's top nav bar. Its shape depends on `Nav bar type`:
455
+ - `partner` (default for non-games): white background + back / app icon+name / ⋯ / ×. Pushes content down by the nav bar height.
456
+ - `game`: transparent background, / × only. Floats over the game canvas without pushing content — an in-game screen is full-screen per the [launch checklist](https://developers-apps-in-toss.toss.im/checklist/app-game.html).
457
+ - The nav bar sits at the **top (0)** of the WebView (body) coordinate space. On a real device the OS notch is outside the WebView (in the status bar above), so `env(safe-area-inset-top)` is 0 and content starts right below the nav bar (= `SafeAreaInsets.get().top`) — the simulator reproduces this stack (notch status bar → nav bar → content).
458
+ - For a `partner` WebView this nav bar height **is** `SafeAreaInsets.get().top`. Relay measurement of an iPhone 15 Pro (sandbox, portrait) showed `env(safe-area-inset-top)` = 0 (the OS notch stays outside the WebView viewport) and `SafeAreaInsets.get().top` = 54 px — i.e. the SDK top inset reports the host nav bar, not the notch. So a `partner` app lays out using `insets.top` alone. A `game` WebView is a transparent overlay that does not push content (top 0). Measured on iOS `partner`; Android values are provisional and `external` is not simulated.
455
459
 
456
460
  ### Console manipulation
457
461
 
@@ -482,8 +486,8 @@ __ait.patch('viewport', { preset: 'none' });
482
486
 
483
487
  The bottom of the Viewport tab shows the currently applied values in real time:
484
488
  - **CSS / physical**: `402×874@3x | 1206×2622 portrait (auto)`
485
- - **Safe area**: `T59 R0 B34 L0`
486
- - **AIT nav bar**: `48px (excl. SafeArea)`
489
+ - **Safe area**: `T54 R0 B34 L0` (portrait `partner` — top is the nav bar height, not the notch)
490
+ - **AIT nav bar**: `54px SafeArea top · partner`
487
491
 
488
492
  ### Persistence + technical details
489
493
 
package/README.md CHANGED
@@ -342,7 +342,7 @@ mock 모드에서 카메라/앨범 API는 더미 이미지를 반환합니다.
342
342
 
343
343
  | 탭 | 설명 |
344
344
  |---|---|
345
- | **Environment** | 플랫폼 OS (ios/android), 앱 버전, 환경 (toss/sandbox), 로케일, 네트워크 상태, Safe Area Insets |
345
+ | **Environment** | 플랫폼 OS (ios/android), 앱 버전, 환경 (toss/sandbox), 로케일, 네트워크 상태, Safe Area Insets, Navigation (SDK no-op API 호출값 관측) |
346
346
  | **Presets** | 자주 쓰는 QA 시나리오(권한 거부, offline, 미로그인 등)를 한 클릭으로 적용/해제. 사용자 preset 저장/삭제 가능 |
347
347
  | **Viewport** | 디바이스 프리셋(iPhone/Galaxy) + orientation 토글로 모바일 뷰포트 시뮬레이션 |
348
348
  | **Permissions** | camera, photos, geolocation, clipboard, contacts, microphone 권한 상태 제어 (allowed/denied/notDetermined) |
@@ -357,6 +357,16 @@ mock 모드에서 카메라/앨범 API는 더미 이미지를 반환합니다.
357
357
 
358
358
  > **prompt 모드 자동 열림**: prompt 모드로 설정된 API가 호출되면, Panel이 자동으로 Device 탭을 열고 사용자 입력 UI를 표시합니다.
359
359
 
360
+ ### toss-gated 동작을 dev에서 시험하기 (Environment + Navigation)
361
+
362
+ 실 토스 WebView에서 native bridge로만 발화하던 일부 no-op API(예: `setIosSwipeGestureEnabled`)는 mock에서 그 **마지막 호출값**을 관측 가능한 state로 비춥니다. Environment 탭의 **Navigation** 섹션이 이 값을 read-only로 표시합니다.
363
+
364
+ 이로써 `getOperationalEnvironment() === 'toss'`로 게이트된 코드 경로를 토스 앱 없이 검증할 수 있습니다:
365
+
366
+ 1. Environment 탭에서 **환경(Environment)** 을 `toss`로 전환 (기본은 `sandbox` — toss 진입은 명시적 opt-in).
367
+ 2. 앱의 toss-gated 가드(예: sdk-example `useDisableIosSwipeGestureInToss`)가 실행되며 `setIosSwipeGestureEnabled({ isEnabled: false })`를 호출.
368
+ 3. Navigation 섹션의 `iOS swipe-back` 값이 `미호출` → `disabled`로 실시간 전환되는 것을 패널에서 확인. `AIT.getMockState()`로도 `navigation.iosSwipeGestureEnabled`를 대조할 수 있습니다.
369
+
360
370
  ### Mock state preset library (Presets 탭)
361
371
 
362
372
  한 시나리오에 여러 mock 키가 동시에 일정 상태여야 하는 경우(예: "offline일 때 IAP `NETWORK_ERROR` + 결제 fail")를 매번 손으로 맞추지 않고 한 클릭으로 적용합니다. 적용된 preset은 ✓ 표시되며, 정의된 키 중 하나라도 변경되면 자동으로 indicator가 풀립니다 (preset이 정의하지 않은 키는 비교 대상이 아님).
@@ -427,7 +437,9 @@ mount(); // 깨끗한 상태로 다시 마운트. 중복 <style>·liste
427
437
  - **CSS viewport** (portrait `width × height`)
428
438
  - **DPR** (devicePixelRatio: 2, 3, 3.5 등)
429
439
  - **Notch** 종류 (`none` / `notch` / `dynamic-island` / `punch-hole-center`)
430
- - **OS-level safe area insets** (status bar / 인디케이터 / 노치 회전에 따른 좌우 인셋)
440
+ - **notch inset** (OS 노치/status bar landscape 좌우 인셋 + 시각 노치용, 기기별)
441
+ - **nav bar height** (토스 호스트 nav bar — `partner` portrait의 `SafeAreaInsets.get().top`, 실측 54px)
442
+ - **home-indicator inset** (`safeAreaBottom`, 기기별)
431
443
 
432
444
  ### Orientation
433
445
 
@@ -443,15 +455,17 @@ Landscape로 전환하면:
443
455
 
444
456
  **Show frame** 토글을 켜면:
445
457
  - 디바이스 베젤을 모사하는 border-radius + box-shadow
446
- - Notch / Dynamic Island / punch-hole 오버레이 (body 상단에 절대 배치)
458
+ - Notch / Dynamic Island / punch-hole 오버레이 — WebView(body) **밖** 위쪽 status bar 영역에 그립니다. 실기기에서 OS 노치는 WebView 뷰포트 바깥이라 `env(safe-area-inset-top)`이 0이기 때문입니다.
447
459
  - 홈 인디케이터 pill (iPhone 등 `safeAreaBottom > 0` 디바이스에 한정, body 하단에 배치)
448
460
  - 앱 이름은 `aitState.brand.displayName`을 사용 (Environment 탭에서 변경 가능, 자동 갱신)
449
461
  - 뒤로가기 버튼은 `__ait:backEvent`를 트리거하고, X 버튼은 `closeView()`를 호출 — 실제 SDK 이벤트 플러밍을 패널에서 직접 검증할 수 있습니다.
450
462
 
451
463
  **Show Apps in Toss nav bar** 토글(기본 on)을 켜면:
452
- - 토스 호스트의 상단 nav bar(뒤로가기 / 아이콘·이름 / / ×)를 48px 높이로 오버레이
453
- - status bar 바로 아래, safe area top 이후에 배치
454
- - **중요**: 48px는 `env(safe-area-inset-top)` `SafeAreaInsets.get().top`에 **포함되지 않습니다** (SDK 동작). 토스 예제들도 `insets.top + 48` 패턴으로 보정합니다.
464
+ - 토스 호스트의 상단 nav bar 54px 높이로 오버레이. `Nav bar type`에 따라 모양이 다릅니다:
465
+ - `partner` (비게임 기본): 배경 + 뒤로가기 / 앱 아이콘·이름 / ⋯ / ×. 콘텐츠를 nav bar 높이만큼 아래로 밀어냅니다.
466
+ - `game`: 투명 배경 + / × 만. 게임 캔버스 위에 있어 콘텐츠를 밀어내지 않습니다 인게임 화면은 full-screen이 [출시 요건](https://developers-apps-in-toss.toss.im/checklist/app-game.html).
467
+ - nav bar는 WebView(body) 좌표계의 **최상단(top 0)**에 앉습니다. 실기기에서 OS 노치는 WebView 밖(위쪽 status bar)이라 `env(safe-area-inset-top)`이 0이고, 콘텐츠 영역은 nav bar 바로 아래(= `SafeAreaInsets.get().top`)에서 시작하기 때문입니다 — 시뮬레이터는 이 스택(노치 status bar → nav bar → 콘텐츠)을 그대로 재현합니다.
468
+ - `partner` WebView에서는 **이 nav bar 높이가 곧 `SafeAreaInsets.get().top`** 입니다. iPhone 15 Pro on-device relay 실측([devtools#190](https://github.com/apps-in-toss-community/devtools/issues/190))에서 `env(safe-area-inset-top)`은 0(노치는 WebView 뷰포트 밖)이고 `SafeAreaInsets.get().top`은 54px이었으며, 그 54px가 호스트 nav bar 높이였습니다. 즉 `partner` 앱은 콘텐츠 상단을 `insets.top`만큼만 보정하면 됩니다(별도 `+ navBarHeight` 불필요). `game`은 콘텐츠를 밀어내지 않으므로 top inset이 0입니다. 이 54px는 iOS partner에서 실측됐고 Android nav bar 높이는 같은 값을 잠정 적용합니다. SDK의 `webViewProps.type`은 `partner` / `game` 외에 `external`도 있습니다 (현재 패널은 앞 둘만 시뮬레이션).
455
469
 
456
470
  ### 콘솔에서 직접 조작
457
471
 
@@ -482,8 +496,8 @@ __ait.patch('viewport', { preset: 'none' });
482
496
 
483
497
  Viewport 탭 하단에 현재 적용된 값을 실시간으로 보여줍니다:
484
498
  - **CSS / physical**: `402×874@3x | 1206×2622 portrait (auto)`
485
- - **Safe area**: `T59 R0 B34 L0`
486
- - **AIT nav bar**: `48px (excl. SafeArea)`
499
+ - **Safe area**: `T54 R0 B34 L0` (partner nav bar 기준 — top이 곧 nav bar 높이)
500
+ - **AIT nav bar**: `54px SafeArea top · partner`
487
501
 
488
502
  ### 영속성 + 기술 세부
489
503
 
@@ -492,12 +506,13 @@ Viewport 탭 하단에 현재 적용된 값을 실시간으로 보여줍니다:
492
506
  - 뷰포트는 `document.body`에 `max-width`/`max-height` + `margin:auto`로 적용됩니다. iframe을 쓰지 않으므로 앱 JS/CSS가 그대로 실행되고, 콘솔·DevTools도 정상 접근 가능합니다.
493
507
  - body에 `isolation: isolate`를 적용해 노치/nav bar/홈 인디케이터의 z-index가 stacking context 밖으로 새지 않습니다 (DevTools 패널이 그 위에 떠 있음).
494
508
  - 패널을 동적으로 제거하고 싶다면 `disposeViewport()`를 export로 제공합니다.
495
- - User-Agent spoofing / touch event emulation / network throttling은 하지 않습니다 (Chrome DevTools가 이미 제공).
509
+ - **기기 프리셋이 active일 때(= `none`/`custom` 아님) 브라우저 특성을 기기와 정합시킵니다**: `navigator.userAgent`(토스 WebView 형태 — `… AppsInToss TossApp/<appVersion>`), `navigator.platform`, `window.devicePixelRatio`(preset DPR), `screen.width`/`height`(CSS px × DPR), 그리고 `getPlatformOS()`가 읽는 `platform`(Apple→`ios` / Galaxy→`android`)을 모두 그 기기 값으로 override합니다. 특정 기기 frame을 제공하는 이상 UA/DPR만 호스트 데스크톱 값으로 남으면 비일관적이기 때문입니다. `none`/`custom`에선 override를 걸지 않아 일반 dev의 호스트 환경을 건드리지 않습니다.
496
510
 
497
511
  ### Known limitations
498
512
 
499
513
  - **Body가 스크롤 컨테이너가 됩니다** — 뷰포트 활성화 중에는 스크롤이 `window`가 아닌 `document.body`에서 발생합니다. `window.addEventListener('scroll', ...)`나 root에 붙은 `IntersectionObserver`는 실 디바이스와 다른 동작을 보일 수 있습니다. 미니앱 코드에서 스크롤을 다룬다면 `body`도 함께 검증하세요.
500
514
  - **추정 safe area** — Galaxy S26 시리즈는 출시 spec(phone-simulator.com 측정치) 기반이지만 safe area는 S25 값을 잠정 사용합니다 — 픽셀 단위 정확도가 필요한 QA는 실 기기 확인을 권장합니다.
515
+ - **기기 특성 override는 JS 읽기값만 바꿉니다** — 프리셋이 거는 `navigator.userAgent`·`devicePixelRatio`·`screen.*` override는 page-JS가 읽는 값만 그 기기로 보이게 합니다. 실 CSS media query(`@media (resolution)`, `@media (pointer)`), 실제 터치 이벤트, 엔진 레벨 레이아웃 단위는 호스트 브라우저 값이 그대로입니다 (프리셋 frame이 시각적 width/height는 이미 강제하므로 레이아웃은 근사됩니다). 픽셀·입력 단위까지 완전한 emulation이 필요하면 Chrome DevTools device-mode(또는 CDP)를 쓰세요.
501
516
 
502
517
  ## `window.__ait` 콘솔 API
503
518
 
@@ -27,14 +27,38 @@
27
27
  * on a production entry — see the comment on {@link isPrivateAppsHost}.
28
28
  * B2 — entry query: `_deploymentId` must be present and non-empty.
29
29
  *
30
- * Decision matrix (the gate only ever runs in a debug build Layer A already
31
- * passed by the time this code is reachable):
32
- *
33
- * private-apps host | _deploymentId | debug=1 | result
34
- * no | (any) | (any) | BLOCKED (Layer B1 host)
35
- * yes | absent | (any) | BLOCKED (Layer B2 entry)
36
- * yes | present | absent | BLOCKED (Layer C — opt-in)
37
- * yes | present | present | ATTACH
30
+ * Layer C opt-in + relay + optional TOTP auth:
31
+ * C1 opt-in: `debug=1` must be present.
32
+ * C2 — relay URL: `relay=<wss-url>` must be a valid `wss:` URL.
33
+ * C3 TOTP auth: When `verifyTotpCode` is provided (consumer injected the
34
+ * baked secret at build time via `__DEBUG_TOTP_SECRET__`),
35
+ * `at=<code>` is checked. Invalid or absent code → BLOCKED.
36
+ * When no verifier is provided (TOTP disabled), `at` is
37
+ * ignored (backward compatible).
38
+ *
39
+ * Security note on baked secrets:
40
+ * The TOTP secret baked in via `__DEBUG_TOTP_SECRET__` is present in the
41
+ * dogfood bundle and is extractable by a determined reverse engineer.
42
+ * The practical bar raised is: "URL leak" (Slack paste, QR screenshot) →
43
+ * blocked; "URL + bundle extraction + live TOTP code" → not blocked.
44
+ * This is the intended threat model. Do not overpromise on this guarantee.
45
+ *
46
+ * SECRET-HANDLING: `verifyTotpCode` is a black-box predicate. This module
47
+ * does NOT log the secret, any code value, or pass/fail details beyond the
48
+ * `'auth'` reason enum.
49
+ *
50
+ * Decision matrix (gate only runs in a debug build — Layer A already passed):
51
+ *
52
+ * host ok | _deploymentId | debug=1 | relay ok | TOTP ok* | result
53
+ * no | (any) | (any) | (any) | (any) | BLOCKED (host)
54
+ * yes | absent | (any) | (any) | (any) | BLOCKED (entry)
55
+ * yes | present | absent | (any) | (any) | BLOCKED (opt-in)
56
+ * yes | present | present | invalid | (any) | BLOCKED (invalid-relay)
57
+ * yes | present | present | valid | fail* | BLOCKED (auth)
58
+ * yes | present | present | valid | pass/n/a | ATTACH
59
+ *
60
+ * * "TOTP ok" column only applies when `verifyTotpCode` is provided.
61
+ * When no verifier is injected, TOTP check is skipped entirely.
38
62
  */
39
63
  /** Shape returned when the gate allows attachment. */
40
64
  interface GateResultAttach {
@@ -48,21 +72,26 @@ interface GateResultAttach {
48
72
  interface GateResultBlocked {
49
73
  readonly attach: false;
50
74
  /**
51
- * - `'host'` Layer B1: `hostname` is not a `*.private-apps.tossmini.com` host.
52
- * - `'entry'` Layer B2: `_deploymentId` param is absent or empty.
53
- * - `'opt-in'` Layer C: `debug=1` param is absent.
54
- * - `'invalid-relay'` Layer C: `relay` param is absent, empty, or not a `wss:` URL.
75
+ * - `'host'` Layer B1: `hostname` is not a `*.private-apps.tossmini.com` host.
76
+ * - `'entry'` Layer B2: `_deploymentId` param is absent or empty.
77
+ * - `'opt-in'` Layer C1: `debug=1` param is absent.
78
+ * - `'invalid-relay'` Layer C2: `relay` param is absent, empty, or not a `wss:` URL.
79
+ * - `'auth'` Layer C3: TOTP `at=` code is absent, invalid, or expired
80
+ * (only when a `verifyTotpCode` predicate is injected).
55
81
  *
56
82
  * There is no `'build'` reason: Layer A is enforced by the consumer's
57
83
  * `if (__DEBUG_BUILD__)` guard, not by this function.
84
+ *
85
+ * SECRET-HANDLING: `'auth'` is the only value surfaced for auth failures —
86
+ * no code value, expected value, or secret fragment is ever exposed.
58
87
  */
59
- readonly reason: 'host' | 'entry' | 'opt-in' | 'invalid-relay';
88
+ readonly reason: 'host' | 'entry' | 'opt-in' | 'invalid-relay' | 'auth';
60
89
  }
61
90
  type GateResult = GateResultAttach | GateResultBlocked;
62
91
  /**
63
92
  * Input for {@link evaluateDebugGate}.
64
93
  *
65
- * Both fields are explicit so the function is trivially testable without
94
+ * All fields are explicit so the function is trivially testable without
66
95
  * touching `window`.
67
96
  */
68
97
  interface GateInput {
@@ -86,6 +115,31 @@ interface GateInput {
86
115
  * without coupling the pure function to `window`.
87
116
  */
88
117
  readonly searchParams: URLSearchParams;
118
+ /**
119
+ * Optional TOTP code verifier for Layer C3 auth gate.
120
+ *
121
+ * When provided, `evaluateDebugGate` reads the `at` query param and passes
122
+ * it to this predicate. Return `true` to allow, `false` to block with
123
+ * `reason: 'auth'`.
124
+ *
125
+ * Inject via the consumer's build define, e.g.:
126
+ * ```ts
127
+ * // dogfood build entry — consumer's build injects __DEBUG_TOTP_SECRET__
128
+ * declare const __DEBUG_TOTP_SECRET__: string | undefined;
129
+ * const verifyTotpCode = typeof __DEBUG_TOTP_SECRET__ !== 'undefined'
130
+ * ? (code: string) => verifyTotp(__DEBUG_TOTP_SECRET__, code)
131
+ * : undefined;
132
+ * maybeAttach(evaluateDebugGate({ ...params, verifyTotpCode }));
133
+ * ```
134
+ *
135
+ * Security note: this predicate is a black-box from the gate's perspective.
136
+ * The gate only surfaces pass/fail and the `'auth'` reason code — no code
137
+ * value or secret fragment is ever logged or returned.
138
+ *
139
+ * When `undefined` (TOTP disabled), `at=` is silently ignored and the gate
140
+ * proceeds to ATTACH if all other layers pass.
141
+ */
142
+ readonly verifyTotpCode?: (code: string) => boolean;
89
143
  }
90
144
  /**
91
145
  * Returns whether `hostname` is a `*.private-apps.tossmini.com` subdomain —
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","names":[],"sources":["../../src/in-app/gate.ts","../../src/in-app/attach.ts","../../src/in-app/index.ts"],"mappings":";;AAuCA;;;;;;;;;AASA;;;;;AAcA;;;;;AAQA;;;;;;;;;AA8CA;;;;;AAyBA;;;;UAtGiB,gBAAA;EAAA,SACN,MAAA;EAqG0C;EAAA,SAnG1C,QAAA;EAmGoD;EAAA,SAjGpD,YAAA;AAAA;;UAIM,iBAAA;EAAA,SACN,MAAA;;;;ACQX;;;;;;WDEW,MAAA;AAAA;AAAA,KAGC,UAAA,GAAa,gBAAA,GAAmB,iBAAA;;;;;;;UAQ3B,SAAA;;;;;;;;;;;;;WAaN,QAAA;;;;;;;WAQA,YAAA,EAAc,eAAA;AAAA;;;;;;;;;;;;iBAyBT,iBAAA,CAAkB,QAAA;;;;;;;;;;;;;;;;;;;;;;iBAyBlB,iBAAA,CAAkB,KAAA,EAAO,SAAA,GAAY,UAAA;;;;;;AA/ErD;;;;;AAQA;;;;;;;iBCzCgB,qBAAA,CAAsB,QAAA;;ADuFtC;;;;;AAyBA;;;;;;;;;;iBCpFgB,WAAA,CAAY,UAAA,GAAY,UAAA;;;;;AD2DxC;;;;;AAyBA;;;;;;;iBEjGgB,cAAA,CAAA,GAAkB,UAAA"}
1
+ {"version":3,"file":"index.d.ts","names":[],"sources":["../../src/in-app/gate.ts","../../src/in-app/attach.ts","../../src/in-app/index.ts"],"mappings":";;AA+DA;;;;;;;;;AASA;;;;;AAmBA;;;;;AAQA;;;;;;;;;;;AAwEA;;;;;AAyBA;;;;;;;;;;;;ACvKA;;;;;AA4BA;;;;;;;;ACbA;AAAA,UFmBiB,gBAAA;EAAA,SACN,MAAA;EEpBuB;EAAA,SFsBvB,QAAA;;WAEA,YAAA;AAAA;;UAIM,iBAAA;EAAA,SACN,MAAA;;;;;;;;;;;;;;;WAeA,MAAA;AAAA;AAAA,KAGC,UAAA,GAAa,gBAAA,GAAmB,iBAAA;;;;;;;UAQ3B,SAAA;;;;;;;;;;;;;WAaN,QAAA;;;;;;;WAQA,YAAA,EAAc,eAAA;;;;;;;;;;;;;;;;;;;;;;;;;WA0Bd,cAAA,IAAkB,IAAA;AAAA;;;;;;;;;;;;iBAyBb,iBAAA,CAAkB,QAAA;;;;;;;;;;;;;;;;;;;;;;iBAyBlB,iBAAA,CAAkB,KAAA,EAAO,SAAA,GAAY,UAAA;;;;;;AAzGrD;;;;;AAQA;;;;;;;iBCtEgB,qBAAA,CAAsB,QAAA;;;;AD8ItC;;;;;AAyBA;;;;;;;;iBC3IgB,WAAA,CAAY,UAAA,GAAY,UAAA;;;;;;;ADkHxC;;;;;AAyBA;;;;;iBExJgB,cAAA,CAAA,GAAkB,UAAA"}
@@ -76,6 +76,13 @@ function evaluateDebugGate(input) {
76
76
  attach: false,
77
77
  reason: "invalid-relay"
78
78
  };
79
+ if (input.verifyTotpCode !== void 0) {
80
+ const code = input.searchParams.get("at") ?? "";
81
+ if (!input.verifyTotpCode(code)) return {
82
+ attach: false,
83
+ reason: "auth"
84
+ };
85
+ }
79
86
  return {
80
87
  attach: true,
81
88
  relayUrl: relayUrl.href,
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","names":[],"sources":["../../src/in-app/gate.ts","../../src/in-app/attach.ts","../../src/in-app/index.ts"],"sourcesContent":["/**\n * Runtime activation gate for the in-app debug surface.\n *\n * Spec: docs/superpowers/specs/2026-05-18-in-app-debug-mcp.md\n * \"3-layer activation gate\". This is the pure gate decision; the Chii client,\n * WebSocket transport, MCP server, and CLI that consume it live in src/mcp/.\n *\n * This function evaluates the two RUNTIME layers, B and C. Layer A — the\n * build-time gate — is NOT evaluated here, and deliberately so: it is enforced\n * entirely by the consumer's `if (__DEBUG_BUILD__) { … }` guard around the\n * import site (see sdk-example `src/main.tsx`). `__DEBUG_BUILD__` is a\n * consumer-build-time constant; a release consumer build folds it to `false`\n * and dead-code-eliminates the whole import of `@ait-co/devtools/in-app`, so\n * this code is simply absent from release bundles. A pre-built npm package\n * cannot re-check that flag — it was already baked at devtools' own publish\n * time — so any `isDebugBuild` check inside this function would be permanently\n * `false` and could never pass. Layer A is the consumer guard; B and C are\n * here.\n *\n * Layer B has two parts. Both must pass:\n * B1 — host allowlist: `hostname` must be a `*.private-apps.tossmini.com`\n * subdomain. The Toss app serves dogfood / private mini-apps from a\n * separate `private-apps` host; a production (`intoss://`) entry is\n * served from `*.apps.tossmini.com` WITHOUT the `private-apps` segment.\n * This is the security gate against a dogfood build that somehow lands\n * on a production entry — see the comment on {@link isPrivateAppsHost}.\n * B2 — entry query: `_deploymentId` must be present and non-empty.\n *\n * Decision matrix (the gate only ever runs in a debug build — Layer A already\n * passed by the time this code is reachable):\n *\n * private-apps host | _deploymentId | debug=1 | result\n * no | (any) | (any) | BLOCKED (Layer B1 — host)\n * yes | absent | (any) | BLOCKED (Layer B2 — entry)\n * yes | present | absent | BLOCKED (Layer C — opt-in)\n * yes | present | present | ATTACH\n */\n\n/** Shape returned when the gate allows attachment. */\nexport interface GateResultAttach {\n readonly attach: true;\n /** The validated `wss:` relay URL from the `relay` query param. */\n readonly relayUrl: string;\n /** The deployment ID extracted from the `_deploymentId` query param. */\n readonly deploymentId: string;\n}\n\n/** Shape returned when the gate blocks attachment, with a reason code. */\nexport interface GateResultBlocked {\n readonly attach: false;\n /**\n * - `'host'` Layer B1: `hostname` is not a `*.private-apps.tossmini.com` host.\n * - `'entry'` Layer B2: `_deploymentId` param is absent or empty.\n * - `'opt-in'` Layer C: `debug=1` param is absent.\n * - `'invalid-relay'` Layer C: `relay` param is absent, empty, or not a `wss:` URL.\n *\n * There is no `'build'` reason: Layer A is enforced by the consumer's\n * `if (__DEBUG_BUILD__)` guard, not by this function.\n */\n readonly reason: 'host' | 'entry' | 'opt-in' | 'invalid-relay';\n}\n\nexport type GateResult = GateResultAttach | GateResultBlocked;\n\n/**\n * Input for {@link evaluateDebugGate}.\n *\n * Both fields are explicit so the function is trivially testable without\n * touching `window`.\n */\nexport interface GateInput {\n /**\n * The host the page is served from — `window.location.hostname`.\n *\n * This is the Layer B1 security signal. Why hostname and not the entry\n * scheme: the Toss SDK normalises `intoss-private://` to `intoss://` in\n * `getSchemeUri()`, and `getOperationalEnvironment()` / `getWebViewType()`\n * return the same value (`\"toss\"` / `\"partner\"`) for both dogfood and\n * production entries — none of them distinguish a dogfood entry. The host\n * does: a dogfood / private-apps entry is served from\n * `*.private-apps.tossmini.com`, a production entry is not. This was\n * confirmed live over CDP against mini-app 31146 (see spec open question 2).\n */\n readonly hostname: string;\n\n /**\n * The URL search params to inspect for gate signals (Layers B2 and C).\n *\n * Prefer `URLSearchParams` so callers can pass `new URLSearchParams(location.search)`\n * without coupling the pure function to `window`.\n */\n readonly searchParams: URLSearchParams;\n}\n\n/**\n * The host suffix the Toss app uses to serve dogfood / private mini-apps.\n *\n * A `intoss-private://` (dogfood) entry maps to a host such as\n * `aitc-sdk-example.private-apps.tossmini.com`. A production `intoss://`\n * entry is served from `*.apps.tossmini.com` — the `.private-apps.` segment\n * is absent. Confirmed live over CDP for mini-app 31146; the exact production\n * host is to be re-confirmed once 31146 passes review (spec open question 2).\n */\nconst PRIVATE_APPS_HOST_SUFFIX = '.private-apps.tossmini.com';\n\n/**\n * Returns whether `hostname` is a `*.private-apps.tossmini.com` subdomain —\n * the host the Toss app reserves for dogfood / private mini-app entries.\n *\n * The match is an exact suffix check, not a substring `.includes()`: a\n * substring test would also accept an attacker-controlled host like\n * `private-apps.tossmini.com.evil.example`, which ends in `.example`, not in\n * `.tossmini.com`. Requiring the string to END with the suffix closes that.\n * The leading `.` in the suffix also forces a real subdomain label, so a\n * bare `private-apps.tossmini.com` (no mini-app subdomain) does not match.\n */\nexport function isPrivateAppsHost(hostname: string): boolean {\n return hostname.endsWith(PRIVATE_APPS_HOST_SUFFIX);\n}\n\n/**\n * Pure function that evaluates the runtime debug activation layers (B and C).\n *\n * Has no side effects. The input is explicit. Returns a discriminated union\n * so callers can pattern-match on `result.attach`.\n *\n * Layer A (build-time) is intentionally not evaluated here — see the file-level\n * comment. By the time this function runs, the consumer's `if (__DEBUG_BUILD__)`\n * guard has already passed; this function only decides B and C.\n *\n * @example\n * ```ts\n * const result = evaluateDebugGate({\n * hostname: window.location.hostname,\n * searchParams: new URLSearchParams(window.location.search),\n * });\n * if (result.attach) {\n * // Proceed to load Chii client\n * }\n * ```\n */\nexport function evaluateDebugGate(input: GateInput): GateResult {\n // Layer B1 — host allowlist (the security gate).\n // The page must be served from a `*.private-apps.tossmini.com` host. A\n // production `intoss://` entry is served from `*.apps.tossmini.com` and is\n // rejected here. This is what stops a dogfood build that somehow reaches a\n // production entry from attaching: Layer A keeps debug code out of release\n // bundles, and this layer keeps a dogfood bundle that lands on a production\n // host from attaching even though its code is present.\n if (!isPrivateAppsHost(input.hostname)) {\n return { attach: false, reason: 'host' };\n }\n\n // Layer B2 — runtime entry query gate.\n // `_deploymentId` must be present and non-empty. The `intoss-private://`\n // scheme used for dogfood entries includes this param; general user entry\n // paths do not.\n const deploymentId = input.searchParams.get('_deploymentId') ?? '';\n if (deploymentId === '') {\n return { attach: false, reason: 'entry' };\n }\n\n // Layer C — explicit opt-in gate.\n // Require `debug=1` so that an operator who opens a dogfood URL by accident\n // does not inadvertently trigger the debug surface.\n const debugParam = input.searchParams.get('debug');\n if (debugParam !== '1') {\n return { attach: false, reason: 'opt-in' };\n }\n\n // Layer C continued — relay URL validation.\n // `relay=<wss-url>` must be present and must use the `wss:` scheme.\n // Plain `ws:` is rejected (no TLS). `http:`/`https:` are rejected.\n const relayRaw = input.searchParams.get('relay') ?? '';\n if (relayRaw === '') {\n return { attach: false, reason: 'invalid-relay' };\n }\n\n let relayUrl: URL;\n try {\n relayUrl = new URL(relayRaw);\n } catch {\n return { attach: false, reason: 'invalid-relay' };\n }\n\n if (relayUrl.protocol !== 'wss:') {\n return { attach: false, reason: 'invalid-relay' };\n }\n\n return { attach: true, relayUrl: relayUrl.href, deploymentId };\n}\n","/**\n * In-app Chii target injection for the debug attach flow.\n *\n * Spec: docs/superpowers/specs/2026-05-18-in-app-debug-mcp.md\n * \"MCP attach\" topology section — Phase 1 browser-side implementation.\n *\n * This module bridges the 3-layer gate result to a Chii `target.js` script\n * injection. The Chii npm package is the relay SERVER — the in-app side is\n * a plain `<script src=\"…/target.js\">` pointing at the relay host. No chii\n * npm dependency is needed here.\n */\n\nimport { checkDebugGate, type GateResult } from './index.js';\n\n/**\n * Converts a validated `wss:` relay URL into the Chii `target.js` script URL.\n *\n * Scheme is mapped `wss:` → `https:`. Host and port are preserved.\n * Pathname is set to `/target.js` regardless of the relay path.\n * Query params and hash from the relay URL are dropped — the target script\n * URL is a static asset path on the same host.\n *\n * @example\n * deriveTargetScriptUrl('wss://abc.trycloudflare.com/relay')\n * // → 'https://abc.trycloudflare.com/target.js'\n *\n * deriveTargetScriptUrl('wss://h.example.com:9100/')\n * // → 'https://h.example.com:9100/target.js'\n */\nexport function deriveTargetScriptUrl(relayUrl: string): string {\n const u = new URL(relayUrl);\n u.protocol = 'https:';\n u.pathname = '/target.js';\n u.search = '';\n u.hash = '';\n return u.toString();\n}\n\n/** Module-level guard against double-injection within a page lifecycle. */\nlet attached = false;\n\n/**\n * Evaluates the 3-layer debug gate and, if the gate passes, injects the Chii\n * `target.js` script into `document.head`.\n *\n * Idempotent — calling more than once is safe. The second call is a no-op if\n * a script with the same `src` is already present in the document, and the\n * module-level `attached` flag prevents redundant DOM queries after the first\n * successful injection.\n *\n * Safe to call even if `document` is somehow unavailable (defensive boundary\n * guard — in practice this always runs in a real WebView).\n *\n * @param gateResult - Optional pre-evaluated gate result for testability.\n * Defaults to `checkDebugGate()` which reads the current page URL. Passing a\n * custom value avoids the need to manipulate `window.location` in tests.\n */\nexport function maybeAttach(gateResult: GateResult = checkDebugGate()): void {\n if (!gateResult.attach) {\n console.debug(\n `[@ait-co/devtools] debug attach skipped — gate blocked (reason: ${gateResult.reason})`,\n );\n return;\n }\n\n // Guard against double-injection across repeated calls.\n if (attached) {\n return;\n }\n\n // Defensive: if document is not available (unusual, but possible in some\n // SSR-adjacent edge cases), bail silently rather than throwing.\n if (typeof document === 'undefined') {\n return;\n }\n\n const src = deriveTargetScriptUrl(gateResult.relayUrl);\n\n // Also guard against a script with the same src already in the DOM\n // (e.g. injected by a different code path or a page reload within SPA).\n const existing = document.querySelector<HTMLScriptElement>(`script[src=\"${src}\"]`);\n if (existing !== null) {\n attached = true;\n return;\n }\n\n const script = document.createElement('script');\n script.src = src;\n script.async = true;\n (document.head ?? document.documentElement).appendChild(script);\n\n attached = true;\n}\n","/**\n * @ait-co/devtools/in-app entry point.\n *\n * Spec: docs/superpowers/specs/2026-05-18-in-app-debug-mcp.md\n *\n * Phase 1 — gate + browser-side Chii target injection.\n * WebSocket relay, QR/paste UI, and AI-host MCP bin are later phases that\n * require real-device validation and are not included here.\n *\n * This thin entry reads `window.location` and calls the pure\n * {@link evaluateDebugGate} function. All testable logic lives in `./gate.ts`\n * and `./attach.ts`, not here.\n *\n * Layer A of the activation gate (build-time) is NOT enforced in this module.\n * It is the consumer's responsibility: the consumer wraps its\n * `import('@ait-co/devtools/in-app')` call site in `if (__DEBUG_BUILD__) { … }`\n * (see sdk-example `src/main.tsx`), where `__DEBUG_BUILD__` is a\n * consumer-build-time constant. A release consumer build folds that constant\n * to `false` and dead-code-eliminates this whole module. This package is\n * pre-built and ships with `__DEBUG_BUILD__` already resolved at devtools'\n * publish time, so it could never re-evaluate the consumer's build channel —\n * which is exactly why Layer A lives at the consumer guard, not here.\n */\n\nimport { evaluateDebugGate, type GateResult } from './gate.js';\n\nexport { deriveTargetScriptUrl, maybeAttach } from './attach.js';\nexport type { GateInput, GateResult, GateResultAttach, GateResultBlocked } from './gate.js';\nexport { evaluateDebugGate, isPrivateAppsHost } from './gate.js';\n\n/**\n * Evaluates the runtime debug activation layers (B and C) against the current\n * page URL.\n *\n * Returns the gate result. Callers can check `result.attach` to decide whether\n * to proceed with debug surface attachment.\n *\n * This function reads `window.location` only — both the hostname (Layer B1\n * host allowlist) and the search params (Layers B2 and C). Layer A\n * (build-time) is enforced by the consumer's `if (__DEBUG_BUILD__)` guard\n * around the import site, not here — see the file-level comment. Consumers\n * call this with no arguments, so the Layer B1 host check is picked up with\n * no change at the call site.\n */\nexport function checkDebugGate(): GateResult {\n return evaluateDebugGate({\n hostname: window.location.hostname,\n searchParams: new URLSearchParams(window.location.search),\n });\n}\n"],"mappings":";;;;;;;;;;AAuGA,MAAM,2BAA2B;;;;;;;;;;;;AAajC,SAAgB,kBAAkB,UAA2B;AAC3D,QAAO,SAAS,SAAS,yBAAyB;;;;;;;;;;;;;;;;;;;;;;;AAwBpD,SAAgB,kBAAkB,OAA8B;AAQ9D,KAAI,CAAC,kBAAkB,MAAM,SAAS,CACpC,QAAO;EAAE,QAAQ;EAAO,QAAQ;EAAQ;CAO1C,MAAM,eAAe,MAAM,aAAa,IAAI,gBAAgB,IAAI;AAChE,KAAI,iBAAiB,GACnB,QAAO;EAAE,QAAQ;EAAO,QAAQ;EAAS;AAO3C,KADmB,MAAM,aAAa,IAAI,QAAQ,KAC/B,IACjB,QAAO;EAAE,QAAQ;EAAO,QAAQ;EAAU;CAM5C,MAAM,WAAW,MAAM,aAAa,IAAI,QAAQ,IAAI;AACpD,KAAI,aAAa,GACf,QAAO;EAAE,QAAQ;EAAO,QAAQ;EAAiB;CAGnD,IAAI;AACJ,KAAI;AACF,aAAW,IAAI,IAAI,SAAS;SACtB;AACN,SAAO;GAAE,QAAQ;GAAO,QAAQ;GAAiB;;AAGnD,KAAI,SAAS,aAAa,OACxB,QAAO;EAAE,QAAQ;EAAO,QAAQ;EAAiB;AAGnD,QAAO;EAAE,QAAQ;EAAM,UAAU,SAAS;EAAM;EAAc;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AChKhE,SAAgB,sBAAsB,UAA0B;CAC9D,MAAM,IAAI,IAAI,IAAI,SAAS;AAC3B,GAAE,WAAW;AACb,GAAE,WAAW;AACb,GAAE,SAAS;AACX,GAAE,OAAO;AACT,QAAO,EAAE,UAAU;;;AAIrB,IAAI,WAAW;;;;;;;;;;;;;;;;;AAkBf,SAAgB,YAAY,aAAyB,gBAAgB,EAAQ;AAC3E,KAAI,CAAC,WAAW,QAAQ;AACtB,UAAQ,MACN,mEAAmE,WAAW,OAAO,GACtF;AACD;;AAIF,KAAI,SACF;AAKF,KAAI,OAAO,aAAa,YACtB;CAGF,MAAM,MAAM,sBAAsB,WAAW,SAAS;AAKtD,KADiB,SAAS,cAAiC,eAAe,IAAI,IAAI,KACjE,MAAM;AACrB,aAAW;AACX;;CAGF,MAAM,SAAS,SAAS,cAAc,SAAS;AAC/C,QAAO,MAAM;AACb,QAAO,QAAQ;AACf,EAAC,SAAS,QAAQ,SAAS,iBAAiB,YAAY,OAAO;AAE/D,YAAW;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AC/Cb,SAAgB,iBAA6B;AAC3C,QAAO,kBAAkB;EACvB,UAAU,OAAO,SAAS;EAC1B,cAAc,IAAI,gBAAgB,OAAO,SAAS,OAAO;EAC1D,CAAC"}
1
+ {"version":3,"file":"index.js","names":[],"sources":["../../src/in-app/gate.ts","../../src/in-app/attach.ts","../../src/in-app/index.ts"],"sourcesContent":["/**\n * Runtime activation gate for the in-app debug surface.\n *\n * Spec: docs/superpowers/specs/2026-05-18-in-app-debug-mcp.md\n * \"3-layer activation gate\". This is the pure gate decision; the Chii client,\n * WebSocket transport, MCP server, and CLI that consume it live in src/mcp/.\n *\n * This function evaluates the two RUNTIME layers, B and C. Layer A — the\n * build-time gate — is NOT evaluated here, and deliberately so: it is enforced\n * entirely by the consumer's `if (__DEBUG_BUILD__) { … }` guard around the\n * import site (see sdk-example `src/main.tsx`). `__DEBUG_BUILD__` is a\n * consumer-build-time constant; a release consumer build folds it to `false`\n * and dead-code-eliminates the whole import of `@ait-co/devtools/in-app`, so\n * this code is simply absent from release bundles. A pre-built npm package\n * cannot re-check that flag — it was already baked at devtools' own publish\n * time — so any `isDebugBuild` check inside this function would be permanently\n * `false` and could never pass. Layer A is the consumer guard; B and C are\n * here.\n *\n * Layer B has two parts. Both must pass:\n * B1 — host allowlist: `hostname` must be a `*.private-apps.tossmini.com`\n * subdomain. The Toss app serves dogfood / private mini-apps from a\n * separate `private-apps` host; a production (`intoss://`) entry is\n * served from `*.apps.tossmini.com` WITHOUT the `private-apps` segment.\n * This is the security gate against a dogfood build that somehow lands\n * on a production entry — see the comment on {@link isPrivateAppsHost}.\n * B2 — entry query: `_deploymentId` must be present and non-empty.\n *\n * Layer C — opt-in + relay + optional TOTP auth:\n * C1 — opt-in: `debug=1` must be present.\n * C2 — relay URL: `relay=<wss-url>` must be a valid `wss:` URL.\n * C3 — TOTP auth: When `verifyTotpCode` is provided (consumer injected the\n * baked secret at build time via `__DEBUG_TOTP_SECRET__`),\n * `at=<code>` is checked. Invalid or absent code → BLOCKED.\n * When no verifier is provided (TOTP disabled), `at` is\n * ignored (backward compatible).\n *\n * Security note on baked secrets:\n * The TOTP secret baked in via `__DEBUG_TOTP_SECRET__` is present in the\n * dogfood bundle and is extractable by a determined reverse engineer.\n * The practical bar raised is: \"URL leak\" (Slack paste, QR screenshot) →\n * blocked; \"URL + bundle extraction + live TOTP code\" → not blocked.\n * This is the intended threat model. Do not overpromise on this guarantee.\n *\n * SECRET-HANDLING: `verifyTotpCode` is a black-box predicate. This module\n * does NOT log the secret, any code value, or pass/fail details beyond the\n * `'auth'` reason enum.\n *\n * Decision matrix (gate only runs in a debug build — Layer A already passed):\n *\n * host ok | _deploymentId | debug=1 | relay ok | TOTP ok* | result\n * no | (any) | (any) | (any) | (any) | BLOCKED (host)\n * yes | absent | (any) | (any) | (any) | BLOCKED (entry)\n * yes | present | absent | (any) | (any) | BLOCKED (opt-in)\n * yes | present | present | invalid | (any) | BLOCKED (invalid-relay)\n * yes | present | present | valid | fail* | BLOCKED (auth)\n * yes | present | present | valid | pass/n/a | ATTACH\n *\n * * \"TOTP ok\" column only applies when `verifyTotpCode` is provided.\n * When no verifier is injected, TOTP check is skipped entirely.\n */\n\n/** Shape returned when the gate allows attachment. */\nexport interface GateResultAttach {\n readonly attach: true;\n /** The validated `wss:` relay URL from the `relay` query param. */\n readonly relayUrl: string;\n /** The deployment ID extracted from the `_deploymentId` query param. */\n readonly deploymentId: string;\n}\n\n/** Shape returned when the gate blocks attachment, with a reason code. */\nexport interface GateResultBlocked {\n readonly attach: false;\n /**\n * - `'host'` Layer B1: `hostname` is not a `*.private-apps.tossmini.com` host.\n * - `'entry'` Layer B2: `_deploymentId` param is absent or empty.\n * - `'opt-in'` Layer C1: `debug=1` param is absent.\n * - `'invalid-relay'` Layer C2: `relay` param is absent, empty, or not a `wss:` URL.\n * - `'auth'` Layer C3: TOTP `at=` code is absent, invalid, or expired\n * (only when a `verifyTotpCode` predicate is injected).\n *\n * There is no `'build'` reason: Layer A is enforced by the consumer's\n * `if (__DEBUG_BUILD__)` guard, not by this function.\n *\n * SECRET-HANDLING: `'auth'` is the only value surfaced for auth failures —\n * no code value, expected value, or secret fragment is ever exposed.\n */\n readonly reason: 'host' | 'entry' | 'opt-in' | 'invalid-relay' | 'auth';\n}\n\nexport type GateResult = GateResultAttach | GateResultBlocked;\n\n/**\n * Input for {@link evaluateDebugGate}.\n *\n * All fields are explicit so the function is trivially testable without\n * touching `window`.\n */\nexport interface GateInput {\n /**\n * The host the page is served from — `window.location.hostname`.\n *\n * This is the Layer B1 security signal. Why hostname and not the entry\n * scheme: the Toss SDK normalises `intoss-private://` to `intoss://` in\n * `getSchemeUri()`, and `getOperationalEnvironment()` / `getWebViewType()`\n * return the same value (`\"toss\"` / `\"partner\"`) for both dogfood and\n * production entries — none of them distinguish a dogfood entry. The host\n * does: a dogfood / private-apps entry is served from\n * `*.private-apps.tossmini.com`, a production entry is not. This was\n * confirmed live over CDP against mini-app 31146 (see spec open question 2).\n */\n readonly hostname: string;\n\n /**\n * The URL search params to inspect for gate signals (Layers B2 and C).\n *\n * Prefer `URLSearchParams` so callers can pass `new URLSearchParams(location.search)`\n * without coupling the pure function to `window`.\n */\n readonly searchParams: URLSearchParams;\n\n /**\n * Optional TOTP code verifier for Layer C3 auth gate.\n *\n * When provided, `evaluateDebugGate` reads the `at` query param and passes\n * it to this predicate. Return `true` to allow, `false` to block with\n * `reason: 'auth'`.\n *\n * Inject via the consumer's build define, e.g.:\n * ```ts\n * // dogfood build entry — consumer's build injects __DEBUG_TOTP_SECRET__\n * declare const __DEBUG_TOTP_SECRET__: string | undefined;\n * const verifyTotpCode = typeof __DEBUG_TOTP_SECRET__ !== 'undefined'\n * ? (code: string) => verifyTotp(__DEBUG_TOTP_SECRET__, code)\n * : undefined;\n * maybeAttach(evaluateDebugGate({ ...params, verifyTotpCode }));\n * ```\n *\n * Security note: this predicate is a black-box from the gate's perspective.\n * The gate only surfaces pass/fail and the `'auth'` reason code — no code\n * value or secret fragment is ever logged or returned.\n *\n * When `undefined` (TOTP disabled), `at=` is silently ignored and the gate\n * proceeds to ATTACH if all other layers pass.\n */\n readonly verifyTotpCode?: (code: string) => boolean;\n}\n\n/**\n * The host suffix the Toss app uses to serve dogfood / private mini-apps.\n *\n * A `intoss-private://` (dogfood) entry maps to a host such as\n * `aitc-sdk-example.private-apps.tossmini.com`. A production `intoss://`\n * entry is served from `*.apps.tossmini.com` — the `.private-apps.` segment\n * is absent. Confirmed live over CDP for mini-app 31146; the exact production\n * host is to be re-confirmed once 31146 passes review (spec open question 2).\n */\nconst PRIVATE_APPS_HOST_SUFFIX = '.private-apps.tossmini.com';\n\n/**\n * Returns whether `hostname` is a `*.private-apps.tossmini.com` subdomain —\n * the host the Toss app reserves for dogfood / private mini-app entries.\n *\n * The match is an exact suffix check, not a substring `.includes()`: a\n * substring test would also accept an attacker-controlled host like\n * `private-apps.tossmini.com.evil.example`, which ends in `.example`, not in\n * `.tossmini.com`. Requiring the string to END with the suffix closes that.\n * The leading `.` in the suffix also forces a real subdomain label, so a\n * bare `private-apps.tossmini.com` (no mini-app subdomain) does not match.\n */\nexport function isPrivateAppsHost(hostname: string): boolean {\n return hostname.endsWith(PRIVATE_APPS_HOST_SUFFIX);\n}\n\n/**\n * Pure function that evaluates the runtime debug activation layers (B and C).\n *\n * Has no side effects. The input is explicit. Returns a discriminated union\n * so callers can pattern-match on `result.attach`.\n *\n * Layer A (build-time) is intentionally not evaluated here — see the file-level\n * comment. By the time this function runs, the consumer's `if (__DEBUG_BUILD__)`\n * guard has already passed; this function only decides B and C.\n *\n * @example\n * ```ts\n * const result = evaluateDebugGate({\n * hostname: window.location.hostname,\n * searchParams: new URLSearchParams(window.location.search),\n * });\n * if (result.attach) {\n * // Proceed to load Chii client\n * }\n * ```\n */\nexport function evaluateDebugGate(input: GateInput): GateResult {\n // Layer B1 — host allowlist (the security gate).\n // The page must be served from a `*.private-apps.tossmini.com` host. A\n // production `intoss://` entry is served from `*.apps.tossmini.com` and is\n // rejected here. This is what stops a dogfood build that somehow reaches a\n // production entry from attaching: Layer A keeps debug code out of release\n // bundles, and this layer keeps a dogfood bundle that lands on a production\n // host from attaching even though its code is present.\n if (!isPrivateAppsHost(input.hostname)) {\n return { attach: false, reason: 'host' };\n }\n\n // Layer B2 — runtime entry query gate.\n // `_deploymentId` must be present and non-empty. The `intoss-private://`\n // scheme used for dogfood entries includes this param; general user entry\n // paths do not.\n const deploymentId = input.searchParams.get('_deploymentId') ?? '';\n if (deploymentId === '') {\n return { attach: false, reason: 'entry' };\n }\n\n // Layer C — explicit opt-in gate.\n // Require `debug=1` so that an operator who opens a dogfood URL by accident\n // does not inadvertently trigger the debug surface.\n const debugParam = input.searchParams.get('debug');\n if (debugParam !== '1') {\n return { attach: false, reason: 'opt-in' };\n }\n\n // Layer C continued — relay URL validation.\n // `relay=<wss-url>` must be present and must use the `wss:` scheme.\n // Plain `ws:` is rejected (no TLS). `http:`/`https:` are rejected.\n const relayRaw = input.searchParams.get('relay') ?? '';\n if (relayRaw === '') {\n return { attach: false, reason: 'invalid-relay' };\n }\n\n let relayUrl: URL;\n try {\n relayUrl = new URL(relayRaw);\n } catch {\n return { attach: false, reason: 'invalid-relay' };\n }\n\n if (relayUrl.protocol !== 'wss:') {\n return { attach: false, reason: 'invalid-relay' };\n }\n\n // Layer C3 — TOTP auth gate (fail-fast, only when a verifier is injected).\n // The `at` query param carries the current TOTP code. Absent or invalid code\n // → BLOCKED. When no verifier is provided (TOTP disabled), this check is\n // skipped entirely for backward compatibility.\n //\n // SECRET-HANDLING: we do NOT log `code`, the verifier's result, or anything\n // derived from the secret. Only the `'auth'` enum is surfaced on failure.\n if (input.verifyTotpCode !== undefined) {\n const code = input.searchParams.get('at') ?? '';\n if (!input.verifyTotpCode(code)) {\n return { attach: false, reason: 'auth' };\n }\n }\n\n return { attach: true, relayUrl: relayUrl.href, deploymentId };\n}\n","/**\n * In-app Chii target injection for the debug attach flow.\n *\n * Spec: docs/superpowers/specs/2026-05-18-in-app-debug-mcp.md\n * \"MCP attach\" topology section — Phase 1 browser-side implementation.\n *\n * This module bridges the 3-layer gate result to a Chii `target.js` script\n * injection. The Chii npm package is the relay SERVER — the in-app side is\n * a plain `<script src=\"…/target.js\">` pointing at the relay host. No chii\n * npm dependency is needed here.\n */\n\nimport { checkDebugGate, type GateResult } from './index.js';\n\n/**\n * Converts a validated `wss:` relay URL into the Chii `target.js` script URL.\n *\n * Scheme is mapped `wss:` → `https:`. Host and port are preserved.\n * Pathname is set to `/target.js` regardless of the relay path.\n * Query params and hash from the relay URL are dropped — the target script\n * URL is a static asset path on the same host.\n *\n * @example\n * deriveTargetScriptUrl('wss://abc.trycloudflare.com/relay')\n * // → 'https://abc.trycloudflare.com/target.js'\n *\n * deriveTargetScriptUrl('wss://h.example.com:9100/')\n * // → 'https://h.example.com:9100/target.js'\n */\nexport function deriveTargetScriptUrl(relayUrl: string): string {\n const u = new URL(relayUrl);\n u.protocol = 'https:';\n u.pathname = '/target.js';\n u.search = '';\n u.hash = '';\n return u.toString();\n}\n\n/** Module-level guard against double-injection within a page lifecycle. */\nlet attached = false;\n\n/**\n * Evaluates the 3-layer debug gate and, if the gate passes, injects the Chii\n * `target.js` script into `document.head`.\n *\n * Idempotent — calling more than once is safe. The second call is a no-op if\n * a script with the same `src` is already present in the document, and the\n * module-level `attached` flag prevents redundant DOM queries after the first\n * successful injection.\n *\n * Safe to call even if `document` is somehow unavailable (defensive boundary\n * guard — in practice this always runs in a real WebView).\n *\n * @param gateResult - Optional pre-evaluated gate result for testability.\n * Defaults to `checkDebugGate()` which reads the current page URL. Passing a\n * custom value avoids the need to manipulate `window.location` in tests.\n */\nexport function maybeAttach(gateResult: GateResult = checkDebugGate()): void {\n if (!gateResult.attach) {\n console.debug(\n `[@ait-co/devtools] debug attach skipped — gate blocked (reason: ${gateResult.reason})`,\n );\n return;\n }\n\n // Guard against double-injection across repeated calls.\n if (attached) {\n return;\n }\n\n // Defensive: if document is not available (unusual, but possible in some\n // SSR-adjacent edge cases), bail silently rather than throwing.\n if (typeof document === 'undefined') {\n return;\n }\n\n const src = deriveTargetScriptUrl(gateResult.relayUrl);\n\n // Also guard against a script with the same src already in the DOM\n // (e.g. injected by a different code path or a page reload within SPA).\n const existing = document.querySelector<HTMLScriptElement>(`script[src=\"${src}\"]`);\n if (existing !== null) {\n attached = true;\n return;\n }\n\n const script = document.createElement('script');\n script.src = src;\n script.async = true;\n (document.head ?? document.documentElement).appendChild(script);\n\n attached = true;\n}\n","/**\n * @ait-co/devtools/in-app entry point.\n *\n * Spec: docs/superpowers/specs/2026-05-18-in-app-debug-mcp.md\n *\n * Phase 1 — gate + browser-side Chii target injection.\n * WebSocket relay, QR/paste UI, and AI-host MCP bin are later phases that\n * require real-device validation and are not included here.\n *\n * This thin entry reads `window.location` and calls the pure\n * {@link evaluateDebugGate} function. All testable logic lives in `./gate.ts`\n * and `./attach.ts`, not here.\n *\n * Layer A of the activation gate (build-time) is NOT enforced in this module.\n * It is the consumer's responsibility: the consumer wraps its\n * `import('@ait-co/devtools/in-app')` call site in `if (__DEBUG_BUILD__) { … }`\n * (see sdk-example `src/main.tsx`), where `__DEBUG_BUILD__` is a\n * consumer-build-time constant. A release consumer build folds that constant\n * to `false` and dead-code-eliminates this whole module. This package is\n * pre-built and ships with `__DEBUG_BUILD__` already resolved at devtools'\n * publish time, so it could never re-evaluate the consumer's build channel —\n * which is exactly why Layer A lives at the consumer guard, not here.\n */\n\nimport { evaluateDebugGate, type GateResult } from './gate.js';\n\nexport { deriveTargetScriptUrl, maybeAttach } from './attach.js';\nexport type { GateInput, GateResult, GateResultAttach, GateResultBlocked } from './gate.js';\nexport { evaluateDebugGate, isPrivateAppsHost } from './gate.js';\n\n/**\n * Evaluates the runtime debug activation layers (B and C) against the current\n * page URL.\n *\n * Returns the gate result. Callers can check `result.attach` to decide whether\n * to proceed with debug surface attachment.\n *\n * This function reads `window.location` only — both the hostname (Layer B1\n * host allowlist) and the search params (Layers B2 and C). Layer A\n * (build-time) is enforced by the consumer's `if (__DEBUG_BUILD__)` guard\n * around the import site, not here — see the file-level comment. Consumers\n * call this with no arguments, so the Layer B1 host check is picked up with\n * no change at the call site.\n */\nexport function checkDebugGate(): GateResult {\n return evaluateDebugGate({\n hostname: window.location.hostname,\n searchParams: new URLSearchParams(window.location.search),\n });\n}\n"],"mappings":";;;;;;;;;;AA8JA,MAAM,2BAA2B;;;;;;;;;;;;AAajC,SAAgB,kBAAkB,UAA2B;AAC3D,QAAO,SAAS,SAAS,yBAAyB;;;;;;;;;;;;;;;;;;;;;;;AAwBpD,SAAgB,kBAAkB,OAA8B;AAQ9D,KAAI,CAAC,kBAAkB,MAAM,SAAS,CACpC,QAAO;EAAE,QAAQ;EAAO,QAAQ;EAAQ;CAO1C,MAAM,eAAe,MAAM,aAAa,IAAI,gBAAgB,IAAI;AAChE,KAAI,iBAAiB,GACnB,QAAO;EAAE,QAAQ;EAAO,QAAQ;EAAS;AAO3C,KADmB,MAAM,aAAa,IAAI,QAAQ,KAC/B,IACjB,QAAO;EAAE,QAAQ;EAAO,QAAQ;EAAU;CAM5C,MAAM,WAAW,MAAM,aAAa,IAAI,QAAQ,IAAI;AACpD,KAAI,aAAa,GACf,QAAO;EAAE,QAAQ;EAAO,QAAQ;EAAiB;CAGnD,IAAI;AACJ,KAAI;AACF,aAAW,IAAI,IAAI,SAAS;SACtB;AACN,SAAO;GAAE,QAAQ;GAAO,QAAQ;GAAiB;;AAGnD,KAAI,SAAS,aAAa,OACxB,QAAO;EAAE,QAAQ;EAAO,QAAQ;EAAiB;AAUnD,KAAI,MAAM,mBAAmB,KAAA,GAAW;EACtC,MAAM,OAAO,MAAM,aAAa,IAAI,KAAK,IAAI;AAC7C,MAAI,CAAC,MAAM,eAAe,KAAK,CAC7B,QAAO;GAAE,QAAQ;GAAO,QAAQ;GAAQ;;AAI5C,QAAO;EAAE,QAAQ;EAAM,UAAU,SAAS;EAAM;EAAc;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;ACrOhE,SAAgB,sBAAsB,UAA0B;CAC9D,MAAM,IAAI,IAAI,IAAI,SAAS;AAC3B,GAAE,WAAW;AACb,GAAE,WAAW;AACb,GAAE,SAAS;AACX,GAAE,OAAO;AACT,QAAO,EAAE,UAAU;;;AAIrB,IAAI,WAAW;;;;;;;;;;;;;;;;;AAkBf,SAAgB,YAAY,aAAyB,gBAAgB,EAAQ;AAC3E,KAAI,CAAC,WAAW,QAAQ;AACtB,UAAQ,MACN,mEAAmE,WAAW,OAAO,GACtF;AACD;;AAIF,KAAI,SACF;AAKF,KAAI,OAAO,aAAa,YACtB;CAGF,MAAM,MAAM,sBAAsB,WAAW,SAAS;AAKtD,KADiB,SAAS,cAAiC,eAAe,IAAI,IAAI,KACjE,MAAM;AACrB,aAAW;AACX;;CAGF,MAAM,SAAS,SAAS,cAAc,SAAS;AAC/C,QAAO,MAAM;AACb,QAAO,QAAQ;AACf,EAAC,SAAS,QAAQ,SAAS,iBAAiB,YAAY,OAAO;AAE/D,YAAW;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AC/Cb,SAAgB,iBAA6B;AAC3C,QAAO,kBAAkB;EACvB,UAAU,OAAO,SAAS;EAC1B,cAAc,IAAI,gBAAgB,OAAO,SAAS,OAAO;EAC1D,CAAC"}