@ait-co/debug-console 0.1.1

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/auto.cjs ADDED
@@ -0,0 +1,1491 @@
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ //#region ../internal-protocol/src/attach-handshake.ts
3
+ /**
4
+ * Version handshake carried on the attach path.
5
+ *
6
+ * WHY THIS EXISTS — every other clause of the device↔host contract is a
7
+ * value-duplicated string or number with zero compile-time linkage: the
8
+ * `window.__ait_bridge` snapshot and its field names, the `ait:bridge-call` /
9
+ * `ait:relay-ws-state` event names, the `__ait_relay_ws_observed` flag, the
10
+ * `#__ait_debug_indicator` element id, the `/at/<code>/target.js` path
11
+ * convention, `window.__sdk` / `window.__sdkCall`, and close code 4401. When
12
+ * the two sides drift, none of that crashes — it silently misbehaves. The
13
+ * indicator renders empty, a field reads `undefined`, a close frame goes
14
+ * unrecognised. Nothing in the stack says why.
15
+ *
16
+ * Changesets `fixed` keeps `@ait-co/debugger` and `@ait-co/debug-console` on
17
+ * one version number, so "same version" is exactly "mutually tested pair". What
18
+ * `fixed` cannot do is stop a consumer from installing a skewed pair. This
19
+ * handshake turns that skew from a silent misbehaviour into one diagnostic
20
+ * line: the device reports its build-time `__VERSION__` on a fire-and-forget
21
+ * request just before it injects `target.js`, and the daemon compares it with
22
+ * its own.
23
+ *
24
+ * Why a dedicated request rather than a query param on `target.js`: chii's
25
+ * stock `target.js` derives its WebSocket endpoint from its own script `src`
26
+ * via `src.replace('target.js', '')` and then appends `target/<id>`. Any query
27
+ * string on that URL would land in the middle of the derived endpoint and break
28
+ * the dial. The path is therefore separate, and it rides the same
29
+ * `/at/<code>/` prefix so the relay's existing auth gate covers it unchanged.
30
+ *
31
+ * SECRET-HANDLING: the payload is a version string and nothing else. The
32
+ * request carries a TOTP code only in the same path-prefix form the target
33
+ * script already uses; neither side may log the URL, the code, or the relay
34
+ * host. The mismatch diagnostic names two version strings — never a URL.
35
+ */
36
+ /**
37
+ * Relay HTTP path the device pings once, immediately before injecting
38
+ * `target.js`. Served by the daemon's relay; the response body is empty.
39
+ */
40
+ const ATTACH_HANDSHAKE_PATH = "/ait-attach";
41
+ //#endregion
42
+ //#region ../internal-protocol/src/bridge-observer-state.ts
43
+ /**
44
+ * CustomEvent fired (no detail) on every bridge-call start/settle so the
45
+ * indicator badge re-renders promptly. SECRET-HANDLING: carries no detail
46
+ * payload at all — the badge reads the enum-only snapshot on receipt.
47
+ */
48
+ const BRIDGE_CALL_EVENT = "ait:bridge-call";
49
+ /**
50
+ * CustomEvent fired with `{ detail: { state } }` on every relay-WebSocket
51
+ * open/close, so a CDP-injected indicator can follow relay liveness without
52
+ * installing a second Proxy on `window.WebSocket`. `state` is an enum
53
+ * (`'open' | 'closed'`) — never a URL.
54
+ */
55
+ const RELAY_WS_STATE_EVENT = "ait:relay-ws-state";
56
+ //#endregion
57
+ //#region ../internal-protocol/src/relay-auth-close.ts
58
+ /**
59
+ * Shared constants for the relay's named TOTP-auth rejection.
60
+ *
61
+ * Before the accept-then-close fix the relay rejected an unauthenticated
62
+ * WebSocket upgrade with a raw `HTTP/1.1 401` + `socket.destroy()`. A handshake
63
+ * aborted that way is indistinguishable from a network failure on the browser
64
+ * side — the WebSocket only ever sees close code 1006, so the phone (env-2
65
+ * launcher PWA) could not tell "stale TOTP code" apart from "tunnel down" and
66
+ * stayed silent. The fix is accept-then-close: complete the handshake, then
67
+ * close with an application close code that NAMES the rejection.
68
+ *
69
+ * Three parties share this contract:
70
+ * - the relay (`@ait-co/debugger`, Node) sends the close frame / HTTP error body;
71
+ * - the on-device console (`@ait-co/debug-console`, browser) observes
72
+ * relay-bound WebSockets and surfaces the code to the launcher shell;
73
+ * - the daemon's own CDP client (`@ait-co/debugger`, Node) recognises the code
74
+ * as an auth failure on its `/client` dial.
75
+ *
76
+ * This module is intentionally dependency-free (no Node, no DOM) so it is safe
77
+ * to inline into both the browser bundle and the daemon bundle.
78
+ *
79
+ * SECRET-HANDLING: these are fixed enum values. The close reason / error body
80
+ * must never grow to carry a secret, a TOTP code, or a host.
81
+ */
82
+ /**
83
+ * WebSocket close code sent by the relay when TOTP auth is rejected.
84
+ *
85
+ * 4000–4999 is the application-reserved range (RFC 6455 §7.4.2); 4401 mirrors
86
+ * HTTP 401 so it reads as "unauthorized" at a glance.
87
+ */
88
+ const RELAY_AUTH_REJECT_CLOSE_CODE = 4401;
89
+ /**
90
+ * Close reason string accompanying {@link RELAY_AUTH_REJECT_CLOSE_CODE}, and
91
+ * the `error` value of the relay's HTTP 401 JSON body. Enum string only —
92
+ * never interpolated with request data.
93
+ */
94
+ const RELAY_AUTH_REJECT_REASON = "totp-rejected";
95
+ //#endregion
96
+ //#region src/bridge-observer.ts
97
+ /**
98
+ * In-app native-bridge call observer (issue #749).
99
+ *
100
+ * WHY THIS EXISTS — the mock's `observe()` (`src/mock/observe.ts` → `aitState.
101
+ * sdkCallLog`) only wraps the MOCK SDK. In the on-device debug context (env 3,
102
+ * real Toss WebView, run7) the REAL `@apps-in-toss/web-framework` is loaded, so
103
+ * that groundwork sees nothing. The in-app debug surface needs its own
104
+ * observation point at the one place every real async bridge call funnels
105
+ * through, so the on-phone indicator can answer run7's question: is a spinner a
106
+ * pending native call, the app's own UI, or a wedged JS main thread?
107
+ *
108
+ * OBSERVATION POINTS (version-agnostic — both SDK lines route through one of
109
+ * these; picked so a GA flip 2.x↔3.0 does not break the signal):
110
+ *
111
+ * - 3.0 line — `window.__appsInTossNativeBridge.callAsyncMethod(name, params)`
112
+ * is the single async dispatcher; it returns the native Promise. Wrapping
113
+ * it gives the COMPLETE lifecycle (pending on call, settle on resolve/
114
+ * reject) from ONE hook, with no reliance on how native calls back.
115
+ * - 2.x line — there is no single dispatcher method (each async bridge is an
116
+ * independent `createAsyncBridge` closure). The universal START choke point
117
+ * is `window.ReactNativeWebView.postMessage(json)`; the matching SETTLE
118
+ * signal is `window.__GRANITE_NATIVE_EMITTER.emit('<method>/resolve|reject/
119
+ * <eventId>')`. We wrap both.
120
+ *
121
+ * The observer publishes a read-only snapshot on `window.__ait_bridge` (an
122
+ * id→pending map + a last-call record) and fires a payload-less
123
+ * {@link BRIDGE_CALL_EVENT} CustomEvent on every change, so the CDP-injected
124
+ * indicator badge (`buildIndicatorExpression`) can render the pending list +
125
+ * last-call stamp without polling — the same pub/sub decoupling the relay-WS
126
+ * observer uses for `ait:relay-ws-state` (#730).
127
+ *
128
+ * SECRET-HANDLING: this module records API METHOD NAMES + timestamps + a
129
+ * correlation id ONLY. It NEVER reads, stores, or forwards call arguments
130
+ * (`params`/`args`) or results — those may carry tokens, URLs, or user data.
131
+ * The outbound-message parser reads `type`/`name`/`functionName`/`callbackId`/
132
+ * `eventId` and deliberately never touches `params`/`args`.
133
+ *
134
+ * Lives in the in-app graph (reached only via {@link maybeAttach}); a release
135
+ * consumer build DCEs it with the rest of the debug surface (§check:debug-
136
+ * surface-absent). Never throws into the host app — every hook is guarded.
137
+ */
138
+ /**
139
+ * Pending entries older than this are pruned on the next start — a safety net
140
+ * for the fallback path where a settle signal might be missed, so the pending
141
+ * list can never grow unbounded or show a forever-stuck row. Generous enough
142
+ * that a genuinely slow native call (the exact signal we want to surface) still
143
+ * shows while it is plausibly in flight.
144
+ */
145
+ const MAX_PENDING_AGE_MS = 12e4;
146
+ /** Guard so the observer wraps the bridge at most once per page lifecycle. */
147
+ let bridgeObserverInstalled = false;
148
+ /** Monotonic id source for the primary (3.0) path, where native gives us none. */
149
+ let callIdCounter = 0;
150
+ /** Undo hooks that {@link uninstallBridgeObserver} runs to restore originals. */
151
+ let restoreHooks = [];
152
+ /** Drops pending entries older than {@link MAX_PENDING_AGE_MS}. */
153
+ function pruneStale(state, now) {
154
+ for (const id of Object.keys(state.pending)) {
155
+ const entry = state.pending[id];
156
+ if (entry !== void 0 && now - entry.startedAt > MAX_PENDING_AGE_MS) delete state.pending[id];
157
+ }
158
+ }
159
+ /** Fires the payload-less notify event so the badge re-renders. */
160
+ function broadcast() {
161
+ if (typeof window === "undefined") return;
162
+ window.dispatchEvent(new CustomEvent(BRIDGE_CALL_EVENT));
163
+ }
164
+ /** Records a call start: add to pending, set last=pending, notify. */
165
+ function startCall(state, id, method, now) {
166
+ pruneStale(state, now);
167
+ state.pending[id] = {
168
+ method,
169
+ startedAt: now
170
+ };
171
+ state.last = {
172
+ method,
173
+ at: now,
174
+ status: "pending"
175
+ };
176
+ broadcast();
177
+ }
178
+ /** Records a call settle: remove from pending, stamp last, notify. */
179
+ function settleCall(state, id, status, now) {
180
+ const entry = state.pending[id];
181
+ delete state.pending[id];
182
+ state.last = {
183
+ method: entry?.method ?? state.last?.method ?? "unknown",
184
+ at: now,
185
+ status
186
+ };
187
+ broadcast();
188
+ }
189
+ /**
190
+ * Parses an outbound `ReactNativeWebView.postMessage` JSON envelope and records
191
+ * a START for async request/response calls only. Event subscriptions
192
+ * (`addEventListener`/`removeEventListener`/`callEventMethod`), cleanup, and
193
+ * constants are ignored — they are not the "spinner is a pending call" signal.
194
+ *
195
+ * SECRET-HANDLING: reads `type`/`name`/`functionName`/`callbackId`/`eventId`
196
+ * ONLY — never `params`/`args`.
197
+ */
198
+ function observeOutbound(state, message) {
199
+ if (typeof message !== "string") return;
200
+ let parsed;
201
+ try {
202
+ parsed = JSON.parse(message);
203
+ } catch {
204
+ return;
205
+ }
206
+ const now = Date.now();
207
+ if (parsed.type === "callAsyncMethod" && typeof parsed.name === "string" && typeof parsed.callbackId === "string") {
208
+ startCall(state, parsed.callbackId, parsed.name, now);
209
+ return;
210
+ }
211
+ if (parsed.type === "method" && typeof parsed.functionName === "string" && typeof parsed.eventId === "string") startCall(state, parsed.eventId, parsed.functionName, now);
212
+ }
213
+ /**
214
+ * Parses a `__GRANITE_NATIVE_EMITTER.emit` event name and records a SETTLE for
215
+ * 2.x async resolves/rejects (`<method>/resolve/<eventId>` |
216
+ * `<method>/reject/<eventId>`). Event-bridge emits (`.../onEvent/...`) are
217
+ * ignored. SECRET-HANDLING: reads the event NAME only — never the emitted args.
218
+ */
219
+ function observeSettle(state, event) {
220
+ if (typeof event !== "string") return;
221
+ const match = /\/(resolve|reject)\/([^/]+)$/.exec(event);
222
+ if (match === null) return;
223
+ settleCall(state, match[2], match[1] === "resolve" ? "resolved" : "rejected", Date.now());
224
+ }
225
+ /**
226
+ * Installs the native-bridge call observer (#749). Idempotent per page
227
+ * lifecycle. Called by {@link maybeAttach} after the gate passes (debug builds
228
+ * only). Prefers the 3.0 single-dispatcher wrap; falls back to the universal
229
+ * 2.x postMessage-start + emitter-settle pair. A context with no observable
230
+ * bridge (env 2 mock) leaves `window.__ait_bridge` as an empty snapshot — the
231
+ * badge then shows the heartbeat only, which is correct.
232
+ *
233
+ * Never throws into the host app — a wrapped hook that somehow fails is caught
234
+ * and the original behavior is always preserved.
235
+ */
236
+ function installBridgeObserver() {
237
+ if (bridgeObserverInstalled) return;
238
+ if (typeof window === "undefined") return;
239
+ bridgeObserverInstalled = true;
240
+ const state = {
241
+ pending: Object.create(null),
242
+ last: null
243
+ };
244
+ window.__ait_bridge = state;
245
+ const nativeBridge = window.__appsInTossNativeBridge;
246
+ if (nativeBridge !== void 0 && typeof nativeBridge.callAsyncMethod === "function") {
247
+ const original = nativeBridge.callAsyncMethod;
248
+ const wrapped = function(name, params) {
249
+ const id = `c${++callIdCounter}`;
250
+ startCall(state, id, String(name), Date.now());
251
+ let result;
252
+ try {
253
+ result = original.call(this, name, params);
254
+ } catch (err) {
255
+ settleCall(state, id, "rejected", Date.now());
256
+ throw err;
257
+ }
258
+ if (result !== null && typeof result.then === "function") result.then(() => settleCall(state, id, "resolved", Date.now()), () => settleCall(state, id, "rejected", Date.now()));
259
+ else settleCall(state, id, "resolved", Date.now());
260
+ return result;
261
+ };
262
+ nativeBridge.callAsyncMethod = wrapped;
263
+ restoreHooks.push(() => {
264
+ nativeBridge.callAsyncMethod = original;
265
+ });
266
+ return;
267
+ }
268
+ const webView = window.ReactNativeWebView;
269
+ if (webView !== void 0 && typeof webView.postMessage === "function") {
270
+ const originalPost = webView.postMessage;
271
+ webView.postMessage = function(message) {
272
+ try {
273
+ observeOutbound(state, message);
274
+ } catch {}
275
+ originalPost.call(this, message);
276
+ };
277
+ restoreHooks.push(() => {
278
+ webView.postMessage = originalPost;
279
+ });
280
+ }
281
+ const emitter = window.__GRANITE_NATIVE_EMITTER;
282
+ if (emitter !== void 0 && typeof emitter.emit === "function") {
283
+ const originalEmit = emitter.emit;
284
+ emitter.emit = function(event, args) {
285
+ try {
286
+ observeSettle(state, event);
287
+ } catch {}
288
+ originalEmit.call(this, event, args);
289
+ };
290
+ restoreHooks.push(() => {
291
+ emitter.emit = originalEmit;
292
+ });
293
+ }
294
+ }
295
+ /**
296
+ * Restores every wrapped bridge hook and removes `window.__ait_bridge` (#749).
297
+ * Idempotent; safe to call when nothing was installed. Wired into
298
+ * {@link detachDebugSurface} (#748) so no bridge wrap survives a run's end.
299
+ */
300
+ function uninstallBridgeObserver() {
301
+ if (!bridgeObserverInstalled) return;
302
+ bridgeObserverInstalled = false;
303
+ const hooks = restoreHooks;
304
+ restoreHooks = [];
305
+ for (const undo of hooks) try {
306
+ undo();
307
+ } catch {}
308
+ if (typeof window !== "undefined") window.__ait_bridge = void 0;
309
+ }
310
+ //#endregion
311
+ //#region src/eruda-overlay.ts
312
+ /**
313
+ * In-app eruda console overlay for the debug attach flow.
314
+ *
315
+ * Spec: docs/superpowers/specs/2026-05-18-in-app-debug-mcp.md
316
+ *
317
+ * This module mounts the eruda in-page console (https://github.com/liriliri/eruda)
318
+ * on the phone screen when a debug session attaches. It is the mobile-only
319
+ * counterpart to the Chii `target.js` injection in {@link attach.ts}: Chii is a
320
+ * REMOTE CDP transport (phone → relay → PC DevTools frontend), whereas eruda is
321
+ * a LOCAL in-page view — a floating button + console/network/DOM/storage panels
322
+ * rendered directly on the phone, with no relay or second device. The two are
323
+ * orthogonal and coexist (eruda opens no WebSocket, mounts into its own
324
+ * `#eruda` shadow host — it cannot collide with the relay WS or the Chii DOM).
325
+ *
326
+ * Build-time absence (the security contract): this module lives in the
327
+ * `@ait-co/debug-console` graph. A consumer wraps its
328
+ * `import('@ait-co/debug-console')` call site in `if (__DEBUG_BUILD__) { … }`;
329
+ * a release build folds that constant to `false` and dead-code-eliminates the
330
+ * whole module — so eruda (and its dynamic `import('eruda')` chunk) is simply
331
+ * absent from release bundles, exactly like the Chii target.js injection. The
332
+ * `import('eruda')` here is a dynamic import precisely so the bundler emits it
333
+ * as a separate chunk that the dead branch never pulls in.
334
+ *
335
+ * Runtime gate: `mountEruda()` is called only from `maybeAttach()` AFTER the
336
+ * full Layer B/C gate has passed (`gateResult.attach === true`) — host
337
+ * allowlist, `debug=1`, relay URL, and TOTP. So eruda inherits the same
338
+ * four-layer defence as the Chii injection, byte-for-byte, with no eruda-
339
+ * specific gate of its own.
340
+ *
341
+ * SECRET-HANDLING: this module reads no secret, TOTP code, relay URL, or host
342
+ * value, and logs none. eruda observes only the page it is mounted on.
343
+ */
344
+ /** Module-level guard against double mount across repeated `maybeAttach` calls. */
345
+ let erudaMounted = false;
346
+ /**
347
+ * The loaded eruda module, captured on a successful {@link mountEruda} so
348
+ * {@link unmountEruda} can call `.destroy()` on the same instance during
349
+ * graceful detach (#748). `null` when eruda was never mounted.
350
+ */
351
+ let erudaModule = null;
352
+ /**
353
+ * Mounts the eruda in-page console once.
354
+ *
355
+ * Idempotent: repeated calls after a successful mount are no-ops, mirroring the
356
+ * `attached` guard in {@link attach.ts}. Fail-silent: if the dynamic import or
357
+ * `eruda.init()` throws (eruda absent, or a runtime that rejects it), the Chii
358
+ * debug session is unaffected — eruda is an additive convenience, not a
359
+ * dependency of the relay path.
360
+ *
361
+ * `eruda.init()` mounts eruda's own floating entry button on the phone screen;
362
+ * tapping it opens the console. We do not add a separate button.
363
+ */
364
+ async function mountEruda() {
365
+ if (erudaMounted || typeof document === "undefined") return;
366
+ erudaMounted = true;
367
+ try {
368
+ const eruda = (await import("eruda")).default;
369
+ eruda.init();
370
+ erudaModule = eruda;
371
+ } catch (err) {
372
+ erudaMounted = false;
373
+ console.debug("[@ait-co/debug-console] eruda console mount skipped:", err);
374
+ }
375
+ }
376
+ /**
377
+ * Unmounts the eruda in-page console (#748 graceful detach).
378
+ *
379
+ * Calls `eruda.destroy()`, which removes eruda's floating entry button, any
380
+ * open panel, and its `#eruda` shadow host — returning the phone screen to a
381
+ * clean, non-debug state when a debug session ends. After a successful unmount
382
+ * the guard is reset so a later {@link mountEruda} (a fresh attach) can
383
+ * re-mount.
384
+ *
385
+ * Idempotent: a call when eruda was never mounted (or already unmounted) is a
386
+ * no-op. Fail-silent: a `destroy()` throw is swallowed — teardown must never
387
+ * throw into the host app.
388
+ */
389
+ function unmountEruda() {
390
+ if (!erudaMounted || erudaModule === null) return;
391
+ try {
392
+ erudaModule.destroy();
393
+ } catch (err) {
394
+ console.debug("[@ait-co/debug-console] eruda console unmount skipped:", err);
395
+ } finally {
396
+ erudaMounted = false;
397
+ erudaModule = null;
398
+ }
399
+ }
400
+ //#endregion
401
+ //#region ../internal-protocol/src/debug-host.ts
402
+ /**
403
+ * Host allowlist predicates — the one part of the activation gate that BOTH
404
+ * sides of the device↔host protocol evaluate.
405
+ *
406
+ * The device evaluates them inside the runtime gate (`@ait-co/debug-console`'s
407
+ * `gate.ts`, Layer B1). The daemon evaluates the same predicate when it decides
408
+ * whether a CDP target's URL is allowed to be driven (`@ait-co/debugger`'s
409
+ * `debug-server.ts`). Before the split those two call sites shared one module
410
+ * by reaching from the daemon into the in-app tree — a reverse edge that would
411
+ * have dragged the browser bundle into the daemon's graph once the two surfaces
412
+ * became separate packages. Extracting the 5 predicates here removes the edge
413
+ * without duplicating the rule.
414
+ *
415
+ * This module is intentionally dependency-free (no Node, no DOM — only
416
+ * `String#endsWith` and one regex) so it inlines into both bundles. There is no
417
+ * barrel index in this package: each consumer imports the one subpath it needs,
418
+ * and its bundler inlines the handful of statements at build time. Nothing here
419
+ * survives as a runtime cross-package import.
420
+ *
421
+ * SECRET-HANDLING: a hostname is an input, never an output. These functions
422
+ * return booleans only — no predicate may grow to log or return the hostname,
423
+ * a relay URL, or a tunnel host.
424
+ */
425
+ /**
426
+ * The host suffix the Toss app uses to serve dogfood / private mini-apps.
427
+ *
428
+ * A dogfood entry maps to a host such as
429
+ * `example.private-apps.tossmini.com`. A production entry is served from
430
+ * `*.apps.tossmini.com` — the `.private-apps.` segment is absent.
431
+ */
432
+ const PRIVATE_APPS_HOST_SUFFIX = ".private-apps.tossmini.com";
433
+ /**
434
+ * The parent host suffix for the whole mini-app serving family.
435
+ *
436
+ * The 3.0 runtime loader serves mini-app pages from tossmini.com hosts that are
437
+ * NOT `*.private-apps.tossmini.com`, so under 3.0 the hostname no longer
438
+ * distinguishes a dogfood candidate from a production entry. For those hosts
439
+ * the host layer is demoted from a stage discriminator to a "known host family"
440
+ * filter and the effective boundary moves to the opt-in + relay + TOTP layer.
441
+ */
442
+ const TOSSMINI_HOST_SUFFIX = ".tossmini.com";
443
+ /**
444
+ * The host suffix Cloudflare quick-tunnels serve from — the env 2 (PWA) entry.
445
+ */
446
+ const TRYCLOUDFLARE_HOST_SUFFIX = ".trycloudflare.com";
447
+ /**
448
+ * Returns whether `hostname` is a `*.private-apps.tossmini.com` subdomain —
449
+ * the host reserved for dogfood / private mini-app entries.
450
+ *
451
+ * The match is an exact suffix check, not a substring `.includes()`: a
452
+ * substring test would also accept an attacker-controlled host like
453
+ * `private-apps.tossmini.com.evil.example`, which ends in `.example`, not in
454
+ * `.tossmini.com`. Requiring the string to END with the suffix closes that.
455
+ * The leading `.` in the suffix also forces a real subdomain label, so a bare
456
+ * `private-apps.tossmini.com` (no mini-app subdomain) does not match.
457
+ */
458
+ function isPrivateAppsHost(hostname) {
459
+ return hostname.endsWith(PRIVATE_APPS_HOST_SUFFIX);
460
+ }
461
+ /**
462
+ * Returns whether `hostname` is any `*.tossmini.com` subdomain — the host
463
+ * family mini-app pages are served from. Includes the 2.x
464
+ * `*.private-apps.tossmini.com` dogfood hosts and the 3.0 unified serving
465
+ * hosts.
466
+ *
467
+ * Same exact-suffix `endsWith` check as {@link isPrivateAppsHost} — never a
468
+ * substring `.includes()`, which would accept `x.tossmini.com.evil.example`.
469
+ */
470
+ function isTossminiHost(hostname) {
471
+ return hostname.endsWith(TOSSMINI_HOST_SUFFIX);
472
+ }
473
+ /**
474
+ * Returns whether `hostname` is a Cloudflare quick-tunnel host — the env 2
475
+ * (PWA) entry.
476
+ *
477
+ * Env 2 serves the local dev server through a `*.trycloudflare.com` quick
478
+ * tunnel. It has no production runtime: the SDK is the devtools mock and the
479
+ * page is the developer's own dev build. The host safety net (which stops a
480
+ * dogfood build that lands on a production host from attaching) has nothing to
481
+ * protect against here — but the remaining gate layers (opt-in, relay URL,
482
+ * TOTP) still apply, so a leaked tunnel URL is still blocked.
483
+ *
484
+ * Same exact-suffix `endsWith` check as {@link isPrivateAppsHost} — never a
485
+ * substring `.includes()`, which would accept
486
+ * `evil.trycloudflare.com.example.com`.
487
+ */
488
+ function isTrycloudflareHost(hostname) {
489
+ return hostname.endsWith(TRYCLOUDFLARE_HOST_SUFFIX);
490
+ }
491
+ /**
492
+ * Returns true when the hostname is a localhost/loopback address.
493
+ * Allowed: `localhost`, `127.x.x.x` (full RFC 5735 loopback block), `[::1]`,
494
+ * `0.0.0.0`, `*.localhost`.
495
+ *
496
+ * Security note: `hostname.startsWith('127.')` is intentionally NOT used —
497
+ * that pattern would accept `127.evil.com`, which starts with "127." but is an
498
+ * attacker-controlled hostname, not a loopback address. Instead, the 127/8
499
+ * loopback block is matched with a strict numeric-quad regex so only valid
500
+ * dotted-decimal IPv4 in the 127.x.x.x range pass.
501
+ */
502
+ function isLocalhostHost(hostname) {
503
+ if (hostname === "localhost" || hostname === "0.0.0.0") return true;
504
+ if (hostname === "[::1]") return true;
505
+ if (/^127\.\d+\.\d+\.\d+$/.test(hostname)) return true;
506
+ if (hostname.endsWith(".localhost")) return true;
507
+ return false;
508
+ }
509
+ /**
510
+ * Positive-allowlist kill-switch: returns true when the hostname is a known
511
+ * debug-allowed host. The debug surface is ONLY active on:
512
+ * - localhost / loopback (env 1 desktop dev)
513
+ * - `*.trycloudflare.com` (env 2 PWA tunnel)
514
+ * - `*.tossmini.com` (env 3 dog-food — 2.x private-apps hosts AND the 3.0
515
+ * unified serving family)
516
+ *
517
+ * Any other host is silently blocked. Unlisted hosts never had a debug surface
518
+ * regardless, but this function makes it explicit and auditable in one place.
519
+ *
520
+ * The 3.0 loader serves dogfood candidates and production entries from the same
521
+ * host family, so the hostname alone can no longer separate them. The "no naked
522
+ * attach on a production-family host" invariant is preserved one layer down: on
523
+ * tossmini hosts that are not `*.private-apps.*` the TOTP code is mandatory,
524
+ * and production entry URLs carry no debug/relay/at params at all.
525
+ *
526
+ * SECRET-HANDLING: the hostname value MUST NOT be logged or included in any
527
+ * error reason string — only benign labels ('host not in allowlist') are safe.
528
+ */
529
+ function isDebugAllowedHost(hostname) {
530
+ return isLocalhostHost(hostname) || isTrycloudflareHost(hostname) || isTossminiHost(hostname);
531
+ }
532
+ //#endregion
533
+ //#region src/gate.ts
534
+ /**
535
+ * Runtime activation gate for the in-app debug surface.
536
+ *
537
+ * Spec: docs/superpowers/specs/2026-05-18-in-app-debug-mcp.md
538
+ * "3-layer activation gate". This is the pure gate decision; the Chii client,
539
+ * WebSocket transport, MCP server, and CLI that consume it live in src/mcp/.
540
+ *
541
+ * This function evaluates the two RUNTIME layers, B and C. Layer A — the
542
+ * build-time gate — is NOT evaluated here, and deliberately so: it is enforced
543
+ * entirely by the consumer's `if (__DEBUG_BUILD__) { … }` guard around the
544
+ * import site (see sdk-example `src/main.tsx`). `__DEBUG_BUILD__` is a
545
+ * consumer-build-time constant; a release consumer build folds it to `false`
546
+ * and dead-code-eliminates the whole import of `@ait-co/debug-console`, so
547
+ * this code is simply absent from release bundles. A pre-built npm package
548
+ * cannot re-check that flag — it was already baked at devtools' own publish
549
+ * time — so any `isDebugBuild` check inside this function would be permanently
550
+ * `false` and could never pass. Layer A is the consumer guard; B and C are
551
+ * here.
552
+ *
553
+ * Layer B has two parts:
554
+ * B1 — host allowlist: `hostname` must be a `*.private-apps.tossmini.com`
555
+ * subdomain (Toss dogfood entry) OR a `*.trycloudflare.com` host (env 2
556
+ * PWA dev tunnel). The Toss app serves dogfood / private mini-apps from
557
+ * a separate `private-apps` host; a production (`intoss://`) entry is
558
+ * served from `*.apps.tossmini.com` WITHOUT the `private-apps` segment.
559
+ * This is the security gate against a dogfood build that somehow lands
560
+ * on a production entry — see the comment on {@link isPrivateAppsHost}.
561
+ * The env 2 tunnel host is allowed because it has no production runtime
562
+ * (mock SDK, the developer's own dev server) — see {@link
563
+ * isTrycloudflareHost}.
564
+ * B2 — entry query: `_deploymentId` must be present and non-empty. Applies to
565
+ * the Toss path only; the env 2 tunnel has no deployed bundle, so B2 is
566
+ * skipped for `*.trycloudflare.com` hosts.
567
+ *
568
+ * Layer C — opt-in + relay + optional TOTP auth:
569
+ * C1 — opt-in: `debug=1` must be present.
570
+ * C2 — relay URL: `relay=<wss-url>` must be a valid `wss:` URL.
571
+ * C3 — TOTP auth: When `verifyTotpCode` is provided (consumer injected the
572
+ * baked secret at build time via `__DEBUG_TOTP_SECRET__`),
573
+ * `at=<code>` is checked. Invalid or absent code → BLOCKED.
574
+ * When no verifier is provided (TOTP disabled), `at` is
575
+ * ignored (backward compatible).
576
+ *
577
+ * Security note on baked secrets:
578
+ * The TOTP secret baked in via `__DEBUG_TOTP_SECRET__` is present in the
579
+ * dogfood bundle and is extractable by a determined reverse engineer.
580
+ * The practical bar raised is: "URL leak" (Slack paste, QR screenshot) →
581
+ * blocked; "URL + bundle extraction + live TOTP code" → not blocked.
582
+ * This is the intended threat model. Do not overpromise on this guarantee.
583
+ *
584
+ * SECRET-HANDLING: `verifyTotpCode` is a black-box predicate. This module
585
+ * does NOT log the secret, any code value, or pass/fail details beyond the
586
+ * `'auth'` reason enum.
587
+ *
588
+ * Decision matrix (gate only runs in a debug build — Layer A already passed):
589
+ *
590
+ * host | _deploymentId | debug=1 | relay ok | TOTP ok* | result
591
+ * neither | (any) | (any) | (any) | (any) | BLOCKED (host)
592
+ * private-apps| absent | (any) | (any) | (any) | BLOCKED (entry)
593
+ * private-apps| present | absent | (any) | (any) | BLOCKED (opt-in)
594
+ * private-apps| present | present | invalid | (any) | BLOCKED (invalid-relay)
595
+ * private-apps| present | present | valid | fail* | BLOCKED (auth)
596
+ * private-apps| present | present | valid | pass/n/a | ATTACH
597
+ * trycloudflare| (skipped) | absent | (any) | (any) | BLOCKED (opt-in)
598
+ * trycloudflare| (skipped) | present | invalid | (any) | BLOCKED (invalid-relay)
599
+ * trycloudflare| (skipped) | present | valid | fail* | BLOCKED (auth)
600
+ * trycloudflare| (skipped) | present | valid | pass/n/a | ATTACH
601
+ * tossmini(3.0)| (reported) | absent | (any) | (any) | BLOCKED (opt-in)
602
+ * tossmini(3.0)| (reported) | present | invalid | (any) | BLOCKED (invalid-relay)
603
+ * tossmini(3.0)| (reported) | present | valid | at absent| BLOCKED (auth — TOTP mandatory, #760)
604
+ * tossmini(3.0)| (reported) | present | valid | pass/at present | ATTACH
605
+ *
606
+ * * "TOTP ok" column only applies when `verifyTotpCode` is provided.
607
+ * When no verifier is injected, TOTP check is skipped entirely — EXCEPT
608
+ * on tossmini(3.0) hosts (non-private-apps), where a missing `at=` code
609
+ * blocks with 'auth' even without a verifier (devtools#760; the relay
610
+ * side remains the authoritative verifier).
611
+ *
612
+ * tossmini(3.0) = `*.tossmini.com` hosts that are not
613
+ * `*.private-apps.tossmini.com` — the 3.0 unified serving family. The 3.0
614
+ * loader consumes `_deploymentId` natively, so B2 reports it when present
615
+ * but never requires it there (devtools#760).
616
+ * For trycloudflare (env 2 tunnel) hosts B1 is bypassed and B2 is skipped;
617
+ * C1/C2/C3 still apply identically. The ATTACH result carries
618
+ * `deploymentId: ''` for tunnel hosts.
619
+ */
620
+ /**
621
+ * Pure function that evaluates the runtime debug activation layers (B and C).
622
+ *
623
+ * Has no side effects. The input is explicit. Returns a discriminated union
624
+ * so callers can pattern-match on `result.attach`.
625
+ *
626
+ * Layer A (build-time) is intentionally not evaluated here — see the file-level
627
+ * comment. By the time this function runs, the consumer's `if (__DEBUG_BUILD__)`
628
+ * guard has already passed; this function only decides B and C.
629
+ *
630
+ * @example
631
+ * ```ts
632
+ * const result = evaluateDebugGate({
633
+ * hostname: window.location.hostname,
634
+ * searchParams: new URLSearchParams(window.location.search),
635
+ * });
636
+ * if (result.attach) {
637
+ * // Proceed to load Chii client
638
+ * }
639
+ * ```
640
+ */
641
+ function evaluateDebugGate(input) {
642
+ const isTunnel = isTrycloudflareHost(input.hostname);
643
+ const isLocal = isLocalhostHost(input.hostname);
644
+ if (!isDebugAllowedHost(input.hostname)) return {
645
+ attach: false,
646
+ reason: "host"
647
+ };
648
+ let deploymentId = "";
649
+ if (isPrivateAppsHost(input.hostname)) {
650
+ deploymentId = input.searchParams.get("_deploymentId") ?? "";
651
+ if (deploymentId === "") return {
652
+ attach: false,
653
+ reason: "entry"
654
+ };
655
+ } else if (!isTunnel && !isLocal) deploymentId = input.searchParams.get("_deploymentId") ?? "";
656
+ if (input.searchParams.get("debug") !== "1") return {
657
+ attach: false,
658
+ reason: "opt-in"
659
+ };
660
+ const relayRaw = input.searchParams.get("relay") ?? "";
661
+ if (relayRaw === "") return {
662
+ attach: false,
663
+ reason: "invalid-relay"
664
+ };
665
+ let relayUrl;
666
+ try {
667
+ relayUrl = new URL(relayRaw);
668
+ } catch {
669
+ return {
670
+ attach: false,
671
+ reason: "invalid-relay"
672
+ };
673
+ }
674
+ if (relayUrl.protocol !== "wss:") return {
675
+ attach: false,
676
+ reason: "invalid-relay"
677
+ };
678
+ const atCode = input.searchParams.get("at") ?? "";
679
+ if (input.verifyTotpCode !== void 0) {
680
+ if (!input.verifyTotpCode(atCode)) return {
681
+ attach: false,
682
+ reason: "auth"
683
+ };
684
+ } else if (isTossminiHost(input.hostname) && !isPrivateAppsHost(input.hostname) && atCode === "") return {
685
+ attach: false,
686
+ reason: "auth"
687
+ };
688
+ return {
689
+ attach: true,
690
+ relayUrl: relayUrl.href,
691
+ deploymentId
692
+ };
693
+ }
694
+ //#endregion
695
+ //#region src/index.ts
696
+ /**
697
+ * @ait-co/debug-console entry point.
698
+ *
699
+ * Spec: docs/superpowers/specs/2026-05-18-in-app-debug-mcp.md
700
+ *
701
+ * Phase 1 — gate + browser-side Chii target injection.
702
+ * WebSocket relay, QR/paste UI, and AI-host MCP bin are later phases that
703
+ * require real-device validation and are not included here.
704
+ *
705
+ * This thin entry reads `window.location` and calls the pure
706
+ * {@link evaluateDebugGate} function. All testable logic lives in `./gate.ts`
707
+ * and `./attach.ts`, not here.
708
+ *
709
+ * Layer A of the activation gate (build-time) is NOT enforced in this module.
710
+ * It is the consumer's responsibility: the consumer wraps its
711
+ * `import('@ait-co/debug-console')` call site in `if (__DEBUG_BUILD__) { … }`
712
+ * (see sdk-example `src/main.tsx`), where `__DEBUG_BUILD__` is a
713
+ * consumer-build-time constant. A release consumer build folds that constant
714
+ * to `false` and dead-code-eliminates this whole module. This package is
715
+ * pre-built and ships with `__DEBUG_BUILD__` already resolved at devtools'
716
+ * publish time, so it could never re-evaluate the consumer's build channel —
717
+ * which is exactly why Layer A lives at the consumer guard, not here.
718
+ */
719
+ /**
720
+ * Evaluates the runtime debug activation layers (B and C) against the current
721
+ * page URL.
722
+ *
723
+ * Returns the gate result. Callers can check `result.attach` to decide whether
724
+ * to proceed with debug surface attachment.
725
+ *
726
+ * This function reads `window.location` only — both the hostname (Layer B1
727
+ * host allowlist) and the search params (Layers B2 and C). Layer A
728
+ * (build-time) is enforced by the consumer's `if (__DEBUG_BUILD__)` guard
729
+ * around the import site, not here — see the file-level comment. Consumers
730
+ * call this with no arguments, so the Layer B1 host check is picked up with
731
+ * no change at the call site.
732
+ */
733
+ function checkDebugGate() {
734
+ return evaluateDebugGate({
735
+ hostname: window.location.hostname,
736
+ searchParams: new URLSearchParams(window.location.search)
737
+ });
738
+ }
739
+ //#endregion
740
+ //#region src/sdk-probe.ts
741
+ /**
742
+ * Runtime probe for the optional Apps in Toss SDK.
743
+ *
744
+ * WHY A PROBE AND NOT AN IMPORT — this package is the only one in the split
745
+ * that can end up inside a consumer's production bundle, and it ships with
746
+ * `eruda` as its single dependency and no peer dependencies at all. A static
747
+ * `import { setScreenAwakeMode } from '@apps-in-toss/web-framework'` would undo
748
+ * both properties at once: it forces the SDK into the module graph of every
749
+ * consumer that reaches this file, and it pins the package to whichever SDK
750
+ * major exports that symbol. `setScreenAwakeMode` is not part of
751
+ * `@apps-in-toss/web-framework` 2.x's web surface at all — it arrives in the
752
+ * 3.0 line — so a static import does not merely couple us to a version, it
753
+ * breaks 2.x consumers outright.
754
+ *
755
+ * The probe keeps the dependency edge at runtime, where it can be absent
756
+ * without consequence: `import()` inside a `try`, a `typeof === 'function'`
757
+ * check on the export we actually want, and silence when either step fails. The
758
+ * SDK is a devDependency here purely so `tsc` and the unit tests can resolve the
759
+ * specifier; the bundle marks it external so it is never inlined. That makes a
760
+ * 2.x↔3.x GA flip a no-op for this package — the same shape `@ait-co/polyfill`'s
761
+ * `loadTossSdk()` uses.
762
+ *
763
+ * SECRET-HANDLING: nothing here reads or logs a relay URL, a tunnel host, or an
764
+ * auth code. Failures are swallowed; the only thing ever printed is a package-
765
+ * prefixed debug line naming the API that was unavailable.
766
+ */
767
+ /**
768
+ * Resolved SDK namespace, `null` once a probe has established the SDK is
769
+ * absent, `undefined` while no probe has run yet. Module-scoped, so a
770
+ * `vi.resetModules()` in a test gives a fresh probe.
771
+ */
772
+ let sdkCache;
773
+ /**
774
+ * Loads the SDK module if the consumer has it installed, else `null`.
775
+ *
776
+ * Never throws and never rejects. The return type is deliberately a bare
777
+ * property bag rather than `typeof import('@apps-in-toss/web-framework')`:
778
+ * callers must feature-sniff the export they need, which is what keeps this
779
+ * file compiling — and behaving — identically against 2.x and 3.x type
780
+ * surfaces.
781
+ */
782
+ async function loadTossSdk() {
783
+ if (sdkCache !== void 0) return sdkCache;
784
+ try {
785
+ sdkCache = await import("@apps-in-toss/web-framework");
786
+ } catch {
787
+ sdkCache = null;
788
+ }
789
+ return sdkCache;
790
+ }
791
+ /**
792
+ * The SDK namespace if a previous {@link loadTossSdk} already resolved it, else
793
+ * `null`. Never starts a probe of its own.
794
+ *
795
+ * This exists for the unload path. `pagehide` / `beforeunload` handlers are
796
+ * expected to do their work synchronously — a browser tearing the page down is
797
+ * under no obligation to drain the microtask queue first, so a teardown that
798
+ * begins with `await import(...)` can simply never reach its own body and leave
799
+ * the screen held awake. After a successful attach the cache is warm (the same
800
+ * probe already ran to hold the screen), so the release can be dispatched
801
+ * immediately instead.
802
+ */
803
+ function getLoadedTossSdk() {
804
+ return sdkCache ?? null;
805
+ }
806
+ /**
807
+ * Reads a callable export off the SDK namespace, or `null` when it is absent or
808
+ * is not a function. May throw only if the namespace itself is hostile — every
809
+ * caller therefore keeps this inside its own `try`.
810
+ */
811
+ function pickExport(sdk, name) {
812
+ const value = sdk[name];
813
+ return typeof value === "function" ? value : null;
814
+ }
815
+ /**
816
+ * Calls an SDK export by name when it exists, otherwise resolves quietly.
817
+ *
818
+ * @param name - Export to look up on the SDK namespace.
819
+ * @param args - Arguments forwarded verbatim to the export.
820
+ * @returns `true` when the call was dispatched and settled without throwing,
821
+ * `false` when the SDK or the export was unavailable or the call rejected.
822
+ */
823
+ async function callTossSdk(name, ...args) {
824
+ const sdk = await loadTossSdk();
825
+ if (sdk === null) return false;
826
+ try {
827
+ const fn = pickExport(sdk, name);
828
+ if (fn === null) return false;
829
+ await fn(...args);
830
+ return true;
831
+ } catch (err) {
832
+ console.debug(`[@ait-co/debug-console] ${name} failed:`, err);
833
+ return false;
834
+ }
835
+ }
836
+ /**
837
+ * Dispatches an SDK call in the current tick, using only an already-warm cache.
838
+ *
839
+ * Returns `false` without doing anything when no probe has resolved yet — the
840
+ * caller is expected to fall back to {@link callTossSdk}. Unlike that function,
841
+ * `true` here means only "dispatched": a returned promise is left to settle on
842
+ * its own, because the whole point is not to await inside an unload handler.
843
+ *
844
+ * @param name - Export to look up on the SDK namespace.
845
+ * @param args - Arguments forwarded verbatim to the export.
846
+ */
847
+ function callTossSdkNow(name, ...args) {
848
+ const sdk = getLoadedTossSdk();
849
+ if (sdk === null) return false;
850
+ try {
851
+ const fn = pickExport(sdk, name);
852
+ if (fn === null) return false;
853
+ const result = fn(...args);
854
+ if (result instanceof Promise) result.catch((err) => {
855
+ console.debug(`[@ait-co/debug-console] ${name} failed:`, err);
856
+ });
857
+ return true;
858
+ } catch (err) {
859
+ console.debug(`[@ait-co/debug-console] ${name} failed:`, err);
860
+ return false;
861
+ }
862
+ }
863
+ /**
864
+ * Keeps the phone screen awake for the duration of a debug session (or releases
865
+ * it again). No-op when the SDK is absent or does not expose the API.
866
+ *
867
+ * SECRET-HANDLING: only flips a boolean flag — reads nothing from the relay URL
868
+ * or the auth code.
869
+ *
870
+ * @param enabled - `true` to hold the screen awake, `false` to restore normal
871
+ * sleep behaviour.
872
+ */
873
+ async function setScreenAwake(enabled) {
874
+ return callTossSdk("setScreenAwakeMode", { enabled });
875
+ }
876
+ /**
877
+ * Same as {@link setScreenAwake}, but dispatched in the current tick from an
878
+ * already-warm cache; `false` means nothing was dispatched and the caller
879
+ * should fall back to the async form. Used by the teardown path — see
880
+ * {@link getLoadedTossSdk} for why an unload handler cannot await.
881
+ *
882
+ * @param enabled - `true` to hold the screen awake, `false` to restore normal
883
+ * sleep behaviour.
884
+ */
885
+ function setScreenAwakeNow(enabled) {
886
+ return callTossSdkNow("setScreenAwakeMode", { enabled });
887
+ }
888
+ //#endregion
889
+ //#region src/attach.ts
890
+ /**
891
+ * In-app Chii target injection for the debug attach flow.
892
+ *
893
+ * Spec: docs/superpowers/specs/2026-05-18-in-app-debug-mcp.md
894
+ * "MCP attach" topology section — Phase 1 browser-side implementation.
895
+ *
896
+ * This module bridges the 3-layer gate result to a Chii `target.js` script
897
+ * injection. The Chii npm package is the relay SERVER — the in-app side is
898
+ * a plain `<script src="…/target.js">` pointing at the relay host. No chii
899
+ * npm dependency is needed here.
900
+ */
901
+ /**
902
+ * Converts a validated `wss:` relay URL into the Chii `target.js` script URL.
903
+ *
904
+ * Scheme is mapped `wss:` → `https:`. Host and port are preserved.
905
+ * Pathname is set to `/target.js` (or `/at/<code>/target.js` when a TOTP code
906
+ * is given) regardless of the relay path. Query params and hash from the
907
+ * relay URL are dropped — the target script URL is a static asset path on the
908
+ * same host.
909
+ *
910
+ * TOTP path-prefix transport (issue #466): chii's stock `target.js` derives
911
+ * its WS endpoint from the script `src` (`scriptEl.src.replace('target.js',
912
+ * '')`), so embedding the current TOTP code in the script URL *path* is the
913
+ * only way the phone-side WS upgrade can carry it — both the script fetch and
914
+ * the derived `wss://<host>/at/<code>/target/<id>` dial inherit the prefix,
915
+ * and the relay verifies + strips it before chii parses the URL. The
916
+ * `window.ChiiServerUrl` + query alternative does NOT work: chii appends
917
+ * `target/<id>` to the serverUrl string, which would land after a `?`.
918
+ *
919
+ * SECRET-HANDLING: `atCode` rides only inside the returned URL (the intended
920
+ * transport — same exposure grade as the daemon client's `at=` query). It is
921
+ * never logged here.
922
+ *
923
+ * @example
924
+ * deriveTargetScriptUrl('wss://abc.trycloudflare.com/relay')
925
+ * // → 'https://abc.trycloudflare.com/target.js'
926
+ *
927
+ * deriveTargetScriptUrl('wss://h.example.com:9100/', '123456')
928
+ * // → 'https://h.example.com:9100/at/123456/target.js'
929
+ *
930
+ * @param relayUrl - Validated `wss:` relay URL from the gate result.
931
+ * @param atCode - Current TOTP code from the page URL's `at` query param, or
932
+ * `null`/`undefined`/`''` to keep the legacy un-prefixed URL.
933
+ */
934
+ function deriveTargetScriptUrl(relayUrl, atCode) {
935
+ const u = new URL(relayUrl);
936
+ u.protocol = "https:";
937
+ u.pathname = atCode !== void 0 && atCode !== null && atCode !== "" ? `/at/${encodeURIComponent(atCode)}/target.js` : "/target.js";
938
+ u.search = "";
939
+ u.hash = "";
940
+ return u.toString();
941
+ }
942
+ /**
943
+ * Converts a validated `wss:` relay URL into the version-handshake URL.
944
+ *
945
+ * Same scheme/host/TOTP-prefix derivation as {@link deriveTargetScriptUrl},
946
+ * with this package's build-time `__VERSION__` as the single query value. The
947
+ * handshake gets its own path rather than a query on `target.js` because chii's
948
+ * stock `target.js` derives its WebSocket endpoint from its own script `src`
949
+ * (`src.replace('target.js', '')`) and then appends `target/<id>` — a query
950
+ * string there would land in the middle of the derived endpoint.
951
+ *
952
+ * SECRET-HANDLING: `atCode` rides only inside the returned URL (the same
953
+ * transport the target script already uses) and is never logged.
954
+ *
955
+ * @param relayUrl - Validated `wss:` relay URL from the gate result.
956
+ * @param atCode - Current TOTP code from the page URL's `at` query param, or
957
+ * `null`/`undefined`/`''` for the un-prefixed form.
958
+ */
959
+ function deriveHandshakeUrl(relayUrl, atCode) {
960
+ const u = new URL(relayUrl);
961
+ u.protocol = "https:";
962
+ u.pathname = atCode !== void 0 && atCode !== null && atCode !== "" ? `/at/${encodeURIComponent(atCode)}${ATTACH_HANDSHAKE_PATH}` : ATTACH_HANDSHAKE_PATH;
963
+ u.search = `?v=${encodeURIComponent("0.1.1")}`;
964
+ u.hash = "";
965
+ return u.toString();
966
+ }
967
+ /**
968
+ * Reports this package's build-time version to the relay, fire-and-forget.
969
+ *
970
+ * Runs just before `target.js` is injected. Deliberately unawaited and
971
+ * fail-silent: a device↔host version skew is worth a diagnostic on the daemon
972
+ * side, never a reason to hold up or abort an attach. `mode: 'no-cors'` because
973
+ * the relay origin differs from the page origin and we have no use for the
974
+ * response — the daemon is the only party that acts on this.
975
+ *
976
+ * SECRET-HANDLING: the URL (which carries the TOTP code in its path prefix) is
977
+ * never logged, not even on failure.
978
+ */
979
+ function reportProtocolVersion(relayUrl, atCode) {
980
+ if (typeof fetch !== "function") return;
981
+ try {
982
+ fetch(deriveHandshakeUrl(relayUrl, atCode), {
983
+ method: "GET",
984
+ mode: "no-cors",
985
+ cache: "no-store",
986
+ keepalive: true
987
+ }).catch(() => {});
988
+ } catch {}
989
+ }
990
+ /** Module-level guard against double-injection within a page lifecycle. */
991
+ let attached = false;
992
+ /** One-shot guard for the parent notification (both observer + onerror probe). */
993
+ let authExpiredNotified = false;
994
+ /** Set once a relay-bound socket closed with 4401 — flips dials to fail-fast. */
995
+ let relayAuthExpired = false;
996
+ /** Guard against stacking multiple observer wrappers on window.WebSocket. */
997
+ let wsObserverInstalled = false;
998
+ /**
999
+ * Broadcasts relay-socket lifecycle to any in-page listener (#730) — the
1000
+ * on-phone debug indicator subscribes to this instead of wrapping
1001
+ * `window.WebSocket` a second time.
1002
+ *
1003
+ * SECRET-HANDLING: the CustomEvent `detail` carries ONLY the enum
1004
+ * `'open' | 'close'` — never a close code, host, relay URL, or TOTP value.
1005
+ */
1006
+ function broadcastRelayWsState(state) {
1007
+ if (typeof window === "undefined") return;
1008
+ window.dispatchEvent(new CustomEvent(RELAY_WS_STATE_EVENT, { detail: { state } }));
1009
+ }
1010
+ /**
1011
+ * Posts the `auth-expired` block signal to the parent launcher shell, once.
1012
+ *
1013
+ * Mirrors the existing `reason: 'auth'` postMessage in {@link maybeAttach}.
1014
+ * SECRET-HANDLING: the payload carries ONLY the reason enum — never the code,
1015
+ * secret, host, or relay URL.
1016
+ */
1017
+ function notifyAuthExpired() {
1018
+ if (authExpiredNotified) return;
1019
+ if (typeof window === "undefined" || window.parent === window) return;
1020
+ authExpiredNotified = true;
1021
+ window.parent.postMessage({
1022
+ type: "ait:debug-attach-blocked",
1023
+ reason: "auth-expired"
1024
+ }, "*");
1025
+ }
1026
+ /**
1027
+ * Normalises a URL into a comparable origin key, mapping the HTTP scheme pair
1028
+ * onto the WS pair (`https:`→`wss:`, `http:`→`ws:`) so the `wss:` relay URL
1029
+ * from the gate result matches the dials target.js derives from its
1030
+ * `https://…/target.js` script src. Returns `null` for unparsable URLs.
1031
+ */
1032
+ function wsOriginKey(rawUrl) {
1033
+ let parsed;
1034
+ try {
1035
+ parsed = new URL(rawUrl);
1036
+ } catch {
1037
+ return null;
1038
+ }
1039
+ return `${parsed.protocol === "https:" ? "wss:" : parsed.protocol === "http:" ? "ws:" : parsed.protocol}//${parsed.host}`;
1040
+ }
1041
+ /**
1042
+ * Builds a dummy WebSocket that never connects and closes immediately
1043
+ * (asynchronously, with the 4401 code) — returned for relay-bound dials after
1044
+ * auth expiry so chii's internal reconnect loop stops producing real network
1045
+ * traffic. We cannot stop the loop itself (it lives inside stock target.js);
1046
+ * we can only make each iteration free.
1047
+ *
1048
+ * Both `onclose`-style property handlers and `addEventListener` listeners are
1049
+ * fired — stock target.js uses property handlers, but we cannot know every
1050
+ * consumer. (A consumer wiring BOTH would see a double callback; acceptable
1051
+ * for a retry scheduler and irrelevant for chii.)
1052
+ */
1053
+ function createFailFastSocket(url) {
1054
+ const eventTarget = new EventTarget();
1055
+ const sock = {
1056
+ url,
1057
+ readyState: 3,
1058
+ bufferedAmount: 0,
1059
+ extensions: "",
1060
+ protocol: "",
1061
+ binaryType: "blob",
1062
+ onopen: null,
1063
+ onmessage: null,
1064
+ onerror: null,
1065
+ onclose: null,
1066
+ close() {},
1067
+ send() {},
1068
+ addEventListener: eventTarget.addEventListener.bind(eventTarget),
1069
+ removeEventListener: eventTarget.removeEventListener.bind(eventTarget),
1070
+ dispatchEvent: eventTarget.dispatchEvent.bind(eventTarget),
1071
+ CONNECTING: 0,
1072
+ OPEN: 1,
1073
+ CLOSING: 2,
1074
+ CLOSED: 3
1075
+ };
1076
+ setTimeout(() => {
1077
+ const errorEvent = new Event("error");
1078
+ sock.onerror?.(errorEvent);
1079
+ eventTarget.dispatchEvent(errorEvent);
1080
+ let closeEvent;
1081
+ try {
1082
+ closeEvent = new CloseEvent("close", {
1083
+ code: RELAY_AUTH_REJECT_CLOSE_CODE,
1084
+ reason: RELAY_AUTH_REJECT_REASON,
1085
+ wasClean: false
1086
+ });
1087
+ } catch {
1088
+ closeEvent = Object.assign(new Event("close"), {
1089
+ code: RELAY_AUTH_REJECT_CLOSE_CODE,
1090
+ reason: RELAY_AUTH_REJECT_REASON,
1091
+ wasClean: false
1092
+ });
1093
+ }
1094
+ sock.onclose?.(closeEvent);
1095
+ eventTarget.dispatchEvent(closeEvent);
1096
+ }, 0);
1097
+ return sock;
1098
+ }
1099
+ /** Grace window before a non-terminal relay close tears the surface down. */
1100
+ const RECONNECT_GRACE_MS = 5e3;
1101
+ /** One-shot guard so the teardown runs at most once per page lifecycle. */
1102
+ let debugSurfaceDetached = false;
1103
+ /** Pending grace-window timer for a non-terminal close, or `null`. */
1104
+ let pendingDetachTimer = null;
1105
+ /** Cancels a scheduled teardown — called when a relay socket re-opens. */
1106
+ function cancelScheduledDetach() {
1107
+ if (pendingDetachTimer !== null) {
1108
+ clearTimeout(pendingDetachTimer);
1109
+ pendingDetachTimer = null;
1110
+ }
1111
+ }
1112
+ /**
1113
+ * Schedules {@link detachDebugSurface} after {@link RECONNECT_GRACE_MS} unless
1114
+ * a reconnect cancels it first. No-op if teardown already ran or is already
1115
+ * scheduled. Defensive: if `setTimeout` is somehow unavailable, tears down
1116
+ * immediately rather than never.
1117
+ */
1118
+ function scheduleDetach() {
1119
+ if (debugSurfaceDetached || pendingDetachTimer !== null) return;
1120
+ if (typeof setTimeout === "undefined") {
1121
+ detachDebugSurface();
1122
+ return;
1123
+ }
1124
+ pendingDetachTimer = setTimeout(() => {
1125
+ pendingDetachTimer = null;
1126
+ detachDebugSurface();
1127
+ }, RECONNECT_GRACE_MS);
1128
+ }
1129
+ /**
1130
+ * Idempotent, non-throwing teardown of the in-app debug surface (issue #748).
1131
+ *
1132
+ * Removes every debug-surface element WE injected and restores the one side
1133
+ * effect WE applied:
1134
+ * 1. The CDP-injected `#__ait_debug_indicator` badge (the persistent
1135
+ * "Debugger Disconnected" element). Its live heartbeat/pending-call timer
1136
+ * (#749) is stopped first via the badge controller's `stop()` so no 1 Hz
1137
+ * interval leaks past detach; `buildIndicatorExpression` also
1138
+ * self-dismisses the node — this is the in-app hard guarantee, idempotent,
1139
+ * a no-op if it is already gone.
1140
+ * 2. The eruda in-page console (floating button + any open panel).
1141
+ * 3. The native-bridge call observer (#749) — its `callAsyncMethod` /
1142
+ * `postMessage` / emitter wraps are restored and `window.__ait_bridge` is
1143
+ * removed, so nothing we wrapped survives the run's end.
1144
+ * 4. keepAwake — forced on at attach; restored here so a run that ends
1145
+ * WITHOUT a page unload does not leave the screen pinned awake (the
1146
+ * existing `beforeunload` restore only covers the unload path).
1147
+ *
1148
+ * Deliberately NOT touched: the `window.WebSocket` observer proxy (kept so the
1149
+ * #478 post-4401 fail-fast survives; it is non-blocking and never absorbs
1150
+ * input), and any NATIVE overlay (out of our layer — see the block comment
1151
+ * above and issue #748 hypothesis (a)).
1152
+ *
1153
+ * Never throws into the host app — every step is individually guarded.
1154
+ * Exported for unit tests and for a consumer that wants to force a clean detach.
1155
+ */
1156
+ function detachDebugSurface() {
1157
+ if (debugSurfaceDetached) return;
1158
+ debugSurfaceDetached = true;
1159
+ cancelScheduledDetach();
1160
+ try {
1161
+ if (typeof window !== "undefined") window.__ait_indicator?.stop?.();
1162
+ if (typeof document !== "undefined") document.getElementById("__ait_debug_indicator")?.remove();
1163
+ } catch {}
1164
+ try {
1165
+ unmountEruda();
1166
+ } catch {}
1167
+ try {
1168
+ uninstallBridgeObserver();
1169
+ } catch {}
1170
+ try {
1171
+ if (!setScreenAwakeNow(false)) setScreenAwake(false);
1172
+ } catch {}
1173
+ }
1174
+ /**
1175
+ * Wraps `window.WebSocket` with a relay-origin-scoped observer (issue #478).
1176
+ *
1177
+ * - Connections whose URL origin does NOT match the relay origin pass through
1178
+ * to the native constructor untouched — app traffic is never observed.
1179
+ * - Relay-origin connections get a `close` listener: code 4401 (the relay's
1180
+ * named TOTP rejection) flips the module into the expired state and posts
1181
+ * `reason: 'auth-expired'` to the parent launcher shell (once).
1182
+ * - After 4401, further relay-origin dials return a fail-fast dummy socket so
1183
+ * target.js's autonomous reconnect loop stops hitting the network.
1184
+ *
1185
+ * Installed by {@link maybeAttach} BEFORE target.js is injected so the very
1186
+ * first dial is already observed. Idempotent per page lifecycle. Exported for
1187
+ * unit tests.
1188
+ */
1189
+ function installRelayWsObserver(relayUrl) {
1190
+ if (wsObserverInstalled) return;
1191
+ if (typeof window === "undefined" || typeof window.WebSocket !== "function") return;
1192
+ const relayKey = wsOriginKey(relayUrl);
1193
+ if (relayKey === null) return;
1194
+ wsObserverInstalled = true;
1195
+ window.__ait_relay_ws_observed = true;
1196
+ window.addEventListener("pagehide", () => detachDebugSurface(), { once: true });
1197
+ const NativeWebSocket = window.WebSocket;
1198
+ const observed = new Proxy(NativeWebSocket, { construct(target, args) {
1199
+ const url = String(args[0]);
1200
+ if (wsOriginKey(url) !== relayKey) return Reflect.construct(target, args);
1201
+ if (relayAuthExpired) return createFailFastSocket(url);
1202
+ const ws = Reflect.construct(target, args);
1203
+ ws.addEventListener("open", () => {
1204
+ broadcastRelayWsState("open");
1205
+ cancelScheduledDetach();
1206
+ });
1207
+ ws.addEventListener("close", (event) => {
1208
+ broadcastRelayWsState("close");
1209
+ if (event.code === 4401) {
1210
+ relayAuthExpired = true;
1211
+ notifyAuthExpired();
1212
+ detachDebugSurface();
1213
+ } else scheduleDetach();
1214
+ });
1215
+ ws.addEventListener("error", () => {
1216
+ scheduleDetach();
1217
+ });
1218
+ return ws;
1219
+ } });
1220
+ window.WebSocket = observed;
1221
+ }
1222
+ /**
1223
+ * The webViewType self-report postMessage type (#580).
1224
+ *
1225
+ * Canonical definition + the receive-side parser live in
1226
+ * `src/mock/safe-area-bridge.ts` (`WEB_VIEW_TYPE_MESSAGE_TYPE`,
1227
+ * `parseWebViewTypeMessage`). It is re-declared here as a local literal so the
1228
+ * in-app entry does NOT import the mock barrel (which would drag mock internals
1229
+ * — navigation/state — into the dogfood in-app graph). The two literals are
1230
+ * kept in sync by value; if one changes, change both. Same decoupling pattern
1231
+ * the launcher fixture uses for its message-type constants.
1232
+ */
1233
+ const WEB_VIEW_TYPE_MESSAGE_TYPE = "ait:web-view-type";
1234
+ /** Guard so the webViewType self-report is posted at most once per page. */
1235
+ let webViewTypeReported = false;
1236
+ /**
1237
+ * Self-report the mini-app's webViewType to the parent launcher shell, ONCE
1238
+ * (#580).
1239
+ *
1240
+ * The mini-app's type is the build constant `__WEB_VIEW_TYPE__`, injected by
1241
+ * the devtools unplugin from `granite.config.ts`'s `webViewProps.type`. The
1242
+ * launcher (env-2 PWA) is cross-origin and cannot read it directly, so the
1243
+ * framed page posts it to `window.parent`; the launcher switches to game mode
1244
+ * automatically (no manual `?navBarType=game` URL edit).
1245
+ *
1246
+ * Defensive by construction — must NEVER break attach:
1247
+ * - `__WEB_VIEW_TYPE__` is a CONSUMER-build define; it does not exist in
1248
+ * devtools' own build or where the unplugin did not inject it. The `typeof`
1249
+ * guard avoids a ReferenceError; an absent constant is a silent no-op.
1250
+ * - Only posts when inside an iframe (`window.parent !== window`) — a
1251
+ * top-level load has no launcher shell to receive the message.
1252
+ * - The SDK's deprecated `'external'` alias of `partner` (web-framework 2.6.1)
1253
+ * is mapped to `'partner'`; the launcher only emulates `partner` | `game`.
1254
+ * - Wrapped in try/catch so any postMessage/iframe edge case is swallowed.
1255
+ *
1256
+ * SECRET-HANDLING: the payload carries ONLY the webViewType enum — no host,
1257
+ * relay URL, code, or secret.
1258
+ */
1259
+ function reportWebViewType() {
1260
+ if (webViewTypeReported) return;
1261
+ try {
1262
+ if (typeof window === "undefined" || window.parent === window) return;
1263
+ const raw = typeof __WEB_VIEW_TYPE__ !== "undefined" ? __WEB_VIEW_TYPE__ : void 0;
1264
+ if (raw === void 0) return;
1265
+ const value = raw === "game" ? "game" : raw === "partner" || raw === "external" ? "partner" : null;
1266
+ if (value === null) return;
1267
+ webViewTypeReported = true;
1268
+ window.parent.postMessage({
1269
+ type: WEB_VIEW_TYPE_MESSAGE_TYPE,
1270
+ value
1271
+ }, "*");
1272
+ } catch {}
1273
+ }
1274
+ /**
1275
+ * Evaluates the 3-layer debug gate and, if the gate passes, injects the Chii
1276
+ * `target.js` script into `document.head`.
1277
+ *
1278
+ * Idempotent — calling more than once is safe. The second call is a no-op if
1279
+ * a script with the same `src` is already present in the document, and the
1280
+ * module-level `attached` flag prevents redundant DOM queries after the first
1281
+ * successful injection.
1282
+ *
1283
+ * Safe to call even if `document` is somehow unavailable (defensive boundary
1284
+ * guard — in practice this always runs in a real WebView).
1285
+ *
1286
+ * **keepAwake side effect**: on a successful attach, `setScreenAwakeMode({
1287
+ * enabled: true })` is called so the phone screen stays awake during the debug
1288
+ * session. A `beforeunload` handler restores normal sleep on page unload.
1289
+ * Opt out by adding `noKeepAwake=1` to the page URL query string — the check
1290
+ * reads `window.location.search` directly, consistent with other guards in
1291
+ * this file.
1292
+ *
1293
+ * @param gateResult - Optional pre-evaluated gate result for testability.
1294
+ * Defaults to `checkDebugGate()` which reads the current page URL. Passing a
1295
+ * custom value avoids the need to manipulate `window.location` in tests.
1296
+ */
1297
+ function maybeAttach(gateResult = checkDebugGate()) {
1298
+ reportWebViewType();
1299
+ if (!gateResult.attach) {
1300
+ console.debug(`[@ait-co/debug-console] debug attach skipped — gate blocked (reason: ${gateResult.reason})`);
1301
+ if (gateResult.reason === "auth" && typeof window !== "undefined" && window.parent !== window) window.parent.postMessage({
1302
+ type: "ait:debug-attach-blocked",
1303
+ reason: "auth"
1304
+ }, "*");
1305
+ return;
1306
+ }
1307
+ if (attached) return;
1308
+ if (typeof document === "undefined") return;
1309
+ const atCode = typeof window !== "undefined" ? new URLSearchParams(window.location.search).get("at") : null;
1310
+ const src = deriveTargetScriptUrl(gateResult.relayUrl, atCode);
1311
+ reportProtocolVersion(gateResult.relayUrl, atCode);
1312
+ installRelayWsObserver(gateResult.relayUrl);
1313
+ installBridgeObserver();
1314
+ if (document.querySelector(`script[src="${src}"]`) !== null) {
1315
+ attached = true;
1316
+ return;
1317
+ }
1318
+ const script = document.createElement("script");
1319
+ script.src = src;
1320
+ script.async = true;
1321
+ script.onerror = () => {
1322
+ fetch(src).then((res) => {
1323
+ if (res.status === 401) notifyAuthExpired();
1324
+ }).catch(() => {});
1325
+ };
1326
+ (document.head ?? document.documentElement).appendChild(script);
1327
+ attached = true;
1328
+ mountEruda();
1329
+ if (typeof window !== "undefined" && new URLSearchParams(window.location.search).get("noKeepAwake") === "1") return;
1330
+ setScreenAwake(true).then((held) => {
1331
+ if (!held) return;
1332
+ window.addEventListener("beforeunload", () => {
1333
+ if (!setScreenAwakeNow(false)) setScreenAwake(false);
1334
+ }, { once: true });
1335
+ });
1336
+ }
1337
+ //#endregion
1338
+ //#region src/auto.ts
1339
+ /**
1340
+ * @ait-co/debug-console/auto — self-gating side-effect entry.
1341
+ *
1342
+ * Consumers add a single line to their mini-app entry:
1343
+ *
1344
+ * import '@ait-co/debug-console/auto';
1345
+ *
1346
+ * The entry self-gates: if none of the debug activation signals are present
1347
+ * (no `?debug=1`, no `?relay=`, and not a DEV build), it does nothing. The
1348
+ * imported chunk stays dormant and `window.__sdk` / `window.__sdkCall` are
1349
+ * never installed on a normal production load.
1350
+ *
1351
+ * DEPRECATED for builds that require debug code to be PHYSICALLY ABSENT from
1352
+ * the release bundle. This entry is a RUNTIME self-gate, not a build-time one:
1353
+ * the imported chunk (Chii target.js injection, the SDK bridge, and the eruda
1354
+ * console it pulls in via `maybeAttach()`) stays in the production bundle as a
1355
+ * dormant chunk and is only kept asleep at runtime. If your threat model needs
1356
+ * "zero bytes of debug surface in release" (no dormant chunk to extract or
1357
+ * re-enable), do NOT use this entry. Instead guard the call site yourself:
1358
+ *
1359
+ * if (__DEBUG_BUILD__) {
1360
+ * import('@ait-co/debug-console').then((m) => m.maybeAttach());
1361
+ * }
1362
+ *
1363
+ * with `define: { __DEBUG_BUILD__: 'false' }` in your release build — the
1364
+ * bundler then dead-code-eliminates the whole `@ait-co/debug-console` graph
1365
+ * (verified on Vite 8/rolldown). This entry stays for the convenience case
1366
+ * where a dormant chunk gated at runtime is acceptable.
1367
+ *
1368
+ * When the gate passes it:
1369
+ * 1. Calls `maybeAttach()` — runs the full Layer B/C gate (host allowlist,
1370
+ * opt-in params, relay URL, TOTP) and injects the Chii `target.js` script.
1371
+ * Gate semantics are NOT changed — this is a thin self-gate wrapper.
1372
+ * 2. Installs the SDK bridge (`window.__sdk` / `window.__sdkCall`) so an AI
1373
+ * agent can drive any SDK API over the CDP relay without hand-synthesising
1374
+ * the Granite/ReactNative bridge envelope. SDK access uses a dynamic
1375
+ * import of `@apps-in-toss/web-framework` — which this package does not
1376
+ * declare at all, not even as an optional peer, so if the SDK is not
1377
+ * installed the bridge install is silently skipped (fail-silent).
1378
+ * The namespace mirror pattern (iterate `Object.keys`) is
1379
+ * SDK version-neutral: 2.x and 3.x are both covered without any static
1380
+ * import that would couple the entry to a specific SDK line.
1381
+ *
1382
+ * SECRET-HANDLING: no secret, TOTP code, relay URL, or host value is ever
1383
+ * logged or surfaced beyond the reason enum in `maybeAttach()`.
1384
+ *
1385
+ * Layer A (build-time DCE) is NOT enforced here — this entry IS the
1386
+ * consumer-facing alternative to `if (__DEBUG_BUILD__) { … }`. The self-gate
1387
+ * below performs the same dormancy guarantee via a URL param check, which is
1388
+ * safe in a side-effect import context (the gate runs at module evaluation
1389
+ * time, before any React tree mounts). Consumers who already manage their own
1390
+ * `__DEBUG_BUILD__` guard can keep using `@ait-co/debug-console` directly.
1391
+ *
1392
+ * DEV detection uses two complementary signals:
1393
+ * 1. `import.meta.env.DEV` — resolved by the consumer's bundler at their
1394
+ * build time (Vite/Webpack/Rspack inject the value via top-level source
1395
+ * transforms). Works when the consumer's source code (not node_modules)
1396
+ * is processed — same pattern used by the polyfill's `auto` entry.
1397
+ * 2. `process.env.NODE_ENV === 'development'` — resolved by the consumer's
1398
+ * bundler via esbuild `define` (Vite dep-prebundle) or DefinePlugin
1399
+ * (webpack/Rspack). This token IS substituted in dep code inside
1400
+ * node_modules (how React's own dev/prod branching works), fixing the
1401
+ * env-1 regression where signal (1) was never injected into dep code
1402
+ * (sdk-example#180 / issue #520).
1403
+ * IMPORTANT: the `process.env.NODE_ENV` token must be written verbatim
1404
+ * — bundler define substitution is a textual token match. A `typeof
1405
+ * process` guard would survive substitution as-is and always evaluate to
1406
+ * `false` in a browser, killing the comparison. Instead we rely on
1407
+ * try/catch: if `process` is not defined (raw ESM in a browser without
1408
+ * bundler substitution) a ReferenceError is caught → fail-closed (dormant).
1409
+ */
1410
+ /**
1411
+ * Detects whether the current build is a DEV build by consulting two signals.
1412
+ *
1413
+ * Signal A — `import.meta.env.DEV`:
1414
+ * Substituted by Vite/Webpack/Rspack in the consumer's own source files.
1415
+ * NOT substituted in node_modules dep code (esbuild prebundle does not
1416
+ * apply Vite's define pass to deps) — this was the root cause of #520.
1417
+ *
1418
+ * Signal B — `process.env.NODE_ENV === 'development'`:
1419
+ * Substituted by esbuild's dep-prebundle define pass (Vite) and by
1420
+ * DefinePlugin (webpack/Rspack) even inside node_modules. This is how
1421
+ * React itself gates its dev-only code paths. Writing the token verbatim
1422
+ * ensures textual substitution works; a `typeof process` guard would not
1423
+ * be substituted and would evaluate to `'undefined'` in the browser,
1424
+ * killing the comparison. A try/catch catches the ReferenceError when
1425
+ * `process` is genuinely absent (raw ESM without bundler, e.g. direct
1426
+ * browser import or test runners that leave identifiers in place) →
1427
+ * fail-closed (dormant).
1428
+ *
1429
+ * Exported for unit tests — pass an explicit `isDev` override to bypass
1430
+ * the environment detection in controlled test scenarios.
1431
+ */
1432
+ function detectDevSignal() {
1433
+ try {
1434
+ if ({}.env?.DEV === true) return true;
1435
+ } catch {}
1436
+ try {
1437
+ if (process.env.NODE_ENV === "development") return true;
1438
+ } catch {}
1439
+ return false;
1440
+ }
1441
+ /**
1442
+ * Pure predicate for the self-gate. Exported for unit tests.
1443
+ *
1444
+ * @param isDev - Whether the consumer's bundler signals a DEV build.
1445
+ * Default: calls `detectDevSignal()` which consults both
1446
+ * `import.meta.env.DEV` (consumer source pass) and
1447
+ * `process.env.NODE_ENV === 'development'` (dep prebundle pass, fixing
1448
+ * the env-1 regression in issue #520).
1449
+ * Pass an explicit value in tests to control the DEV signal without
1450
+ * depending on the Vite/vitest build environment.
1451
+ * @param searchStr - URL search string to inspect. Defaults to
1452
+ * `window.location.search` when called in a browser context.
1453
+ */
1454
+ function shouldActivate(isDev = detectDevSignal(), searchStr = typeof window !== "undefined" ? window.location.search : "") {
1455
+ if (isDev) return true;
1456
+ const params = new URLSearchParams(searchStr);
1457
+ return params.get("debug") === "1" || params.has("relay");
1458
+ }
1459
+ if (!shouldActivate()) {} else if (typeof window === "undefined" || !isDebugAllowedHost(window.location.hostname)) {} else {
1460
+ maybeAttach();
1461
+ loadTossSdk().then((sdk) => {
1462
+ if (sdk === null) return;
1463
+ if (typeof window === "undefined") return;
1464
+ const bridge = {};
1465
+ for (const key of Object.keys(sdk)) bridge[key] = sdk[key];
1466
+ window.__sdk = bridge;
1467
+ window.__sdkCall = async (name, ...args) => {
1468
+ const fn = bridge[name];
1469
+ if (typeof fn !== "function") return {
1470
+ ok: false,
1471
+ error: `__sdk.${name} is not a function`
1472
+ };
1473
+ try {
1474
+ return {
1475
+ ok: true,
1476
+ value: await fn(...args)
1477
+ };
1478
+ } catch (e) {
1479
+ return {
1480
+ ok: false,
1481
+ error: e instanceof Error ? e.message : String(e)
1482
+ };
1483
+ }
1484
+ };
1485
+ });
1486
+ }
1487
+ //#endregion
1488
+ exports.detectDevSignal = detectDevSignal;
1489
+ exports.shouldActivate = shouldActivate;
1490
+
1491
+ //# sourceMappingURL=auto.cjs.map