@ait-co/devtools 0.1.54 → 0.1.56

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.en.md +64 -17
  2. package/README.md +63 -16
  3. package/dist/chii-relay-57BfqF_5.cjs +88 -0
  4. package/dist/chii-relay-57BfqF_5.cjs.map +1 -0
  5. package/dist/chii-relay-itXOz7kS.js +89 -0
  6. package/dist/chii-relay-itXOz7kS.js.map +1 -0
  7. package/dist/in-app/index.d.ts +45 -12
  8. package/dist/in-app/index.d.ts.map +1 -1
  9. package/dist/in-app/index.js +38 -7
  10. package/dist/in-app/index.js.map +1 -1
  11. package/dist/mcp/cli.d.ts +8 -3
  12. package/dist/mcp/cli.d.ts.map +1 -1
  13. package/dist/mcp/cli.js +782 -226
  14. package/dist/mcp/cli.js.map +1 -1
  15. package/dist/mcp/server.js +18 -13
  16. package/dist/mcp/server.js.map +1 -1
  17. package/dist/mock/index.d.ts.map +1 -1
  18. package/dist/mock/index.js +18 -2
  19. package/dist/mock/index.js.map +1 -1
  20. package/dist/panel/index.js +66 -4
  21. package/dist/panel/index.js.map +1 -1
  22. package/dist/totp-BkKP4m8H.cjs +185 -0
  23. package/dist/totp-BkKP4m8H.cjs.map +1 -0
  24. package/dist/totp-CxHsagqY.js +184 -0
  25. package/dist/totp-CxHsagqY.js.map +1 -0
  26. package/dist/{tunnel-D0_TwDNE.js → tunnel-Cj8g1LIL.js} +12 -4
  27. package/dist/tunnel-Cj8g1LIL.js.map +1 -0
  28. package/dist/{tunnel-BYP0yRBN.cjs → tunnel-p-q6eVWT.cjs} +12 -4
  29. package/dist/tunnel-p-q6eVWT.cjs.map +1 -0
  30. package/dist/unplugin/index.cjs +29 -3
  31. package/dist/unplugin/index.cjs.map +1 -1
  32. package/dist/unplugin/index.d.cts +11 -0
  33. package/dist/unplugin/index.d.cts.map +1 -1
  34. package/dist/unplugin/index.d.ts +12 -1
  35. package/dist/unplugin/index.d.ts.map +1 -1
  36. package/dist/unplugin/index.js +29 -3
  37. package/dist/unplugin/index.js.map +1 -1
  38. package/dist/unplugin/tunnel.cjs +11 -3
  39. package/dist/unplugin/tunnel.cjs.map +1 -1
  40. package/dist/unplugin/tunnel.d.cts +13 -1
  41. package/dist/unplugin/tunnel.d.cts.map +1 -1
  42. package/dist/unplugin/tunnel.d.ts +13 -1
  43. package/dist/unplugin/tunnel.d.ts.map +1 -1
  44. package/dist/unplugin/tunnel.js +11 -3
  45. package/dist/unplugin/tunnel.js.map +1 -1
  46. package/package.json +2 -2
  47. package/dist/tunnel-BYP0yRBN.cjs.map +0 -1
  48. package/dist/tunnel-D0_TwDNE.js.map +0 -1
@@ -0,0 +1,185 @@
1
+ let node_crypto = require("node:crypto");
2
+ //#region src/mcp/totp.ts
3
+ /**
4
+ * RFC 6238 TOTP implementation (Node.js, node:crypto only).
5
+ *
6
+ * External TOTP libraries (otplib, speakeasy, …) are intentionally NOT used
7
+ * to keep the dependency surface minimal. This hand-roll is ~30 lines and
8
+ * covers exactly what relay-side auth needs.
9
+ *
10
+ * Algorithm summary (RFC 6238 + RFC 4226):
11
+ * T = floor(now / 30) — 30-second time step counter
12
+ * K = Buffer.from(secret, 'hex') — shared secret (raw bytes, hex-encoded)
13
+ * MAC = HMAC-SHA1(K, T as 8-byte big-endian uint64)
14
+ * offset = MAC[19] & 0x0f
15
+ * code = (MAC[offset..offset+4] & 0x7fffffff) % 10^6 — 6 digits
16
+ *
17
+ * Security note (keep this comment accurate):
18
+ * The baked-in secret in a dogfood build is extractable from the bundle by a
19
+ * determined reverse engineer. This mechanism raises the bar from
20
+ * "anyone with the URL" to "URL + bundle extraction + live TOTP calculation".
21
+ * Casual URL leaks (Slack paste, QR screenshot, shoulder-surfing) are
22
+ * blocked; deliberate reverse engineering is not. See threat model in
23
+ * src/mcp/chii-relay.ts and umbrella CLAUDE.md §4.
24
+ *
25
+ * SECRET-HANDLING: secret values and computed codes MUST NOT appear in any
26
+ * log, error message, or string visible outside this module. Only boolean
27
+ * pass/fail and reason enum values are safe to surface.
28
+ */
29
+ /** Time step window in seconds (RFC 6238 default). */
30
+ const TIME_STEP = 30;
31
+ /** Number of digits in the generated code. */
32
+ const DIGITS = 6;
33
+ /**
34
+ * Derives a 6-digit TOTP code from a hex-encoded secret at the given wall-
35
+ * clock time.
36
+ *
37
+ * @param secret - The shared secret as a hex string (e.g. 64 hex chars = 32
38
+ * bytes). Must be the output of `generateAttachToken()` or compatible.
39
+ * @param when - Unix timestamp in milliseconds. Defaults to `Date.now()`.
40
+ * @returns A zero-padded 6-digit decimal string, e.g. `"042193"`.
41
+ */
42
+ function generateTotp(secret, when = Date.now()) {
43
+ const key = Buffer.from(secret, "hex");
44
+ const counter = Math.max(0, Math.floor(when / 1e3 / TIME_STEP));
45
+ const counterBuf = Buffer.alloc(8);
46
+ const hi = Math.floor(counter / 4294967296);
47
+ const lo = counter >>> 0;
48
+ counterBuf.writeUInt32BE(hi, 0);
49
+ counterBuf.writeUInt32BE(lo, 4);
50
+ const mac = (0, node_crypto.createHmac)("sha1", key).update(counterBuf).digest();
51
+ const offset = mac[19] & 15;
52
+ return (((mac[offset] & 127) << 24 | (mac[offset + 1] & 255) << 16 | (mac[offset + 2] & 255) << 8 | mac[offset + 3] & 255) % 10 ** DIGITS).toString().padStart(DIGITS, "0");
53
+ }
54
+ /**
55
+ * Verifies a TOTP code against the secret, accepting ±`skew` time steps to
56
+ * tolerate clock drift between the relay host and the client device.
57
+ *
58
+ * Uses `timingSafeEqual` for constant-time comparison to prevent timing
59
+ * side-channel attacks.
60
+ *
61
+ * @param secret - Hex-encoded shared secret.
62
+ * @param code - The 6-digit code to verify (string or numeric).
63
+ * @param when - Unix timestamp in milliseconds. Defaults to `Date.now()`.
64
+ * @param skew - Number of adjacent steps to accept on either side. Default 1
65
+ * (accepts T-1, T, T+1 — a 90-second acceptance window).
66
+ * @returns `true` if the code matches any accepted step, `false` otherwise.
67
+ */
68
+ function verifyTotp(secret, code, when = Date.now(), skew = 1) {
69
+ const normalised = String(code).padStart(DIGITS, "0");
70
+ if (normalised.length !== DIGITS || !/^\d{6}$/.test(normalised)) return false;
71
+ const candidateBuf = Buffer.from(normalised, "utf8");
72
+ for (let delta = -skew; delta <= skew; delta++) {
73
+ const expected = generateTotp(secret, when + delta * TIME_STEP * 1e3);
74
+ if ((0, node_crypto.timingSafeEqual)(Buffer.from(expected, "utf8"), candidateBuf)) return true;
75
+ }
76
+ return false;
77
+ }
78
+ /**
79
+ * Minimum length (in hex characters) accepted for `AIT_DEBUG_TOTP_SECRET`.
80
+ *
81
+ * The secret is hex-encoded (see {@link generateTotp} — `Buffer.from(secret,
82
+ * 'hex')`). 32 hex chars = 16 bytes = 128 bits, the floor for an HMAC-SHA1 key
83
+ * we are willing to gate a public relay behind. `generateAttachToken()` emits
84
+ * 64 hex chars (32 bytes), comfortably above this bar.
85
+ */
86
+ const MIN_SECRET_HEX_CHARS = 32;
87
+ /** Hex string: one or more hex digits, case-insensitive (RFC 4648 base16). */
88
+ const HEX_RE = /^[0-9a-fA-F]+$/;
89
+ /**
90
+ * Human-facing guidance printed when {@link assertRelayAuthConfigured} fails.
91
+ *
92
+ * SECRET-HANDLING: this message states only the REQUIREMENT (≥32 hex chars) and
93
+ * how to mint one. It NEVER echoes the configured value, its length, or any
94
+ * fragment derived from it — see {@link assertRelayAuthConfigured}.
95
+ *
96
+ * Note on encoding: the secret is hex (base16), not base32 — `generateTotp`
97
+ * decodes it with `Buffer.from(secret, 'hex')`. A base32 string would be
98
+ * silently mis-decoded and every TOTP code would fail to match, so the minting
99
+ * command emits hex.
100
+ */
101
+ const RELAY_AUTH_SECRET_MISSING_MESSAGE = [
102
+ "[ait-debug] AIT_DEBUG_TOTP_SECRET이 필수입니다. 32자 이상 16진수(hex) 문자열을 설정하세요.",
103
+ "발급: openssl rand -hex 32",
104
+ "자세히: https://docs.aitc.dev/guides/relay-auth-totp"
105
+ ].join("\n");
106
+ /**
107
+ * Whether `secret` is a well-formed relay-auth TOTP secret: a hex string of at
108
+ * least {@link MIN_SECRET_HEX_CHARS} characters with an even length (an odd
109
+ * length would have its trailing nibble silently dropped by `Buffer.from(...,
110
+ * 'hex')`, weakening the key without warning).
111
+ *
112
+ * Pure predicate so callers can test the validation independently of the
113
+ * fail-fast side effect in {@link assertRelayAuthConfigured}.
114
+ *
115
+ * SECRET-HANDLING: returns only a boolean — the input value is never returned,
116
+ * logged, or echoed.
117
+ */
118
+ function isValidRelayAuthSecret(secret) {
119
+ if (secret === void 0 || secret === "") return false;
120
+ if (secret.length < MIN_SECRET_HEX_CHARS) return false;
121
+ if (secret.length % 2 !== 0) return false;
122
+ return HEX_RE.test(secret);
123
+ }
124
+ /**
125
+ * Fail-fast guard enforcing that a relay-auth TOTP secret is configured before
126
+ * a public-internet-exposed relay is booted (issue #250).
127
+ *
128
+ * Relay-auth (the §4 Layer C TOTP gate) is the only fail-fast layer that closes
129
+ * the real gap: a leaked `wss://…trycloudflare.com` URL otherwise lets a third
130
+ * party attach a debugger to a dogfood/live mini-app. Without a secret the relay
131
+ * comes up unauthenticated, so this guard is called at every relay-boot site —
132
+ * `bootRelayFamily` (intoss env 3/4) and `bootExternalRelayFamily` (env-2 PWA),
133
+ * both eager and lazy. Local-only sessions never boot a relay and so never reach
134
+ * this guard, matching the issue's exemption for non-relay debugging.
135
+ *
136
+ * Throws when the secret is unset, empty, too short, or not a valid hex string.
137
+ * The thrown message is the bin entry's fatal stderr (see `cli.ts` `main().catch`)
138
+ * — the same fatal model as the missing-`AIT_RELAY_BASE_URL` path.
139
+ *
140
+ * SECRET-HANDLING: the env value is read once, passed ONLY to the boolean
141
+ * predicate, and never logged. The thrown message names the requirement, never
142
+ * the value, its length, or any derived fragment.
143
+ *
144
+ * @param env - Environment to read from. Defaults to `process.env`; injectable
145
+ * for tests so they never mutate the real process environment.
146
+ */
147
+ function assertRelayAuthConfigured(env = process.env) {
148
+ if (!isValidRelayAuthSecret(env.AIT_DEBUG_TOTP_SECRET)) throw new Error(RELAY_AUTH_SECRET_MISSING_MESSAGE);
149
+ }
150
+ /**
151
+ * Reads `AIT_DEBUG_TOTP_SECRET` from `process.env` at runtime and builds a
152
+ * `verifyAuth` predicate for the Chii relay's WebSocket upgrade gate.
153
+ *
154
+ * The predicate checks the `at` query parameter against the current and
155
+ * adjacent TOTP time steps (±1 skew) using {@link verifyTotp}.
156
+ *
157
+ * Returns `undefined` when the env var is not set — callers treat that as
158
+ * "auth disabled" (no predicate registered on the relay). Note that since
159
+ * issue #250 the secret is MANDATORY at every relay-boot site (enforced by
160
+ * {@link assertRelayAuthConfigured} BEFORE the relay starts), so in production
161
+ * this never returns `undefined` for a relay that actually boots; the
162
+ * `undefined` branch only matters for the no-relay local path and tests.
163
+ *
164
+ * Lives here (not in the MCP server) so the unplugin's env-2 relay can wire the
165
+ * same gate without importing the heavy MCP server module graph. Re-exported
166
+ * from `debug-server.ts` for back-compat.
167
+ *
168
+ * SECRET-HANDLING: The secret value read from env is captured in a closure and
169
+ * is NEVER written to any log, error message, or process output.
170
+ */
171
+ function buildRelayVerifyAuth(env = process.env) {
172
+ const secret = env.AIT_DEBUG_TOTP_SECRET;
173
+ if (!secret) return void 0;
174
+ return (req) => {
175
+ const rawUrl = req.url ?? "";
176
+ const qIndex = rawUrl.indexOf("?");
177
+ const queryStr = qIndex === -1 ? "" : rawUrl.slice(qIndex + 1);
178
+ return verifyTotp(secret, new URLSearchParams(queryStr).get("at") ?? "");
179
+ };
180
+ }
181
+ //#endregion
182
+ exports.assertRelayAuthConfigured = assertRelayAuthConfigured;
183
+ exports.buildRelayVerifyAuth = buildRelayVerifyAuth;
184
+
185
+ //# sourceMappingURL=totp-BkKP4m8H.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"totp-BkKP4m8H.cjs","names":[],"sources":["../src/mcp/totp.ts"],"sourcesContent":["/**\n * RFC 6238 TOTP implementation (Node.js, node:crypto only).\n *\n * External TOTP libraries (otplib, speakeasy, …) are intentionally NOT used\n * to keep the dependency surface minimal. This hand-roll is ~30 lines and\n * covers exactly what relay-side auth needs.\n *\n * Algorithm summary (RFC 6238 + RFC 4226):\n * T = floor(now / 30) — 30-second time step counter\n * K = Buffer.from(secret, 'hex') — shared secret (raw bytes, hex-encoded)\n * MAC = HMAC-SHA1(K, T as 8-byte big-endian uint64)\n * offset = MAC[19] & 0x0f\n * code = (MAC[offset..offset+4] & 0x7fffffff) % 10^6 — 6 digits\n *\n * Security note (keep this comment accurate):\n * The baked-in secret in a dogfood build is extractable from the bundle by a\n * determined reverse engineer. This mechanism raises the bar from\n * \"anyone with the URL\" to \"URL + bundle extraction + live TOTP calculation\".\n * Casual URL leaks (Slack paste, QR screenshot, shoulder-surfing) are\n * blocked; deliberate reverse engineering is not. See threat model in\n * src/mcp/chii-relay.ts and umbrella CLAUDE.md §4.\n *\n * SECRET-HANDLING: secret values and computed codes MUST NOT appear in any\n * log, error message, or string visible outside this module. Only boolean\n * pass/fail and reason enum values are safe to surface.\n */\n\nimport { createHmac, timingSafeEqual } from 'node:crypto';\n\n/** Time step window in seconds (RFC 6238 default). */\nconst TIME_STEP = 30;\n\n/** Number of digits in the generated code. */\nconst DIGITS = 6;\n\n/**\n * Derives a 6-digit TOTP code from a hex-encoded secret at the given wall-\n * clock time.\n *\n * @param secret - The shared secret as a hex string (e.g. 64 hex chars = 32\n * bytes). Must be the output of `generateAttachToken()` or compatible.\n * @param when - Unix timestamp in milliseconds. Defaults to `Date.now()`.\n * @returns A zero-padded 6-digit decimal string, e.g. `\"042193\"`.\n */\nexport function generateTotp(secret: string, when: number = Date.now()): string {\n const key = Buffer.from(secret, 'hex');\n // Clamp to 0 so negative timestamps (e.g. in ±skew checks near epoch) do not\n // produce a negative counter, which would cause writeUInt32BE to throw.\n const counter = Math.max(0, Math.floor(when / 1000 / TIME_STEP));\n\n // Encode counter as 8-byte big-endian unsigned integer.\n const counterBuf = Buffer.alloc(8);\n // JavaScript numbers are safe integers up to 2^53; counter is ~7.5×10^10 at\n // year 9999 — well within safe range so standard bitwise ops are fine.\n const hi = Math.floor(counter / 0x100000000);\n const lo = counter >>> 0;\n counterBuf.writeUInt32BE(hi, 0);\n counterBuf.writeUInt32BE(lo, 4);\n\n const mac = createHmac('sha1', key).update(counterBuf).digest();\n\n // Dynamic truncation (RFC 4226 §5.4).\n const offset = mac[19] & 0x0f;\n const binCode =\n ((mac[offset] & 0x7f) << 24) |\n ((mac[offset + 1] & 0xff) << 16) |\n ((mac[offset + 2] & 0xff) << 8) |\n (mac[offset + 3] & 0xff);\n\n const otp = binCode % 10 ** DIGITS;\n return otp.toString().padStart(DIGITS, '0');\n}\n\n/**\n * Verifies a TOTP code against the secret, accepting ±`skew` time steps to\n * tolerate clock drift between the relay host and the client device.\n *\n * Uses `timingSafeEqual` for constant-time comparison to prevent timing\n * side-channel attacks.\n *\n * @param secret - Hex-encoded shared secret.\n * @param code - The 6-digit code to verify (string or numeric).\n * @param when - Unix timestamp in milliseconds. Defaults to `Date.now()`.\n * @param skew - Number of adjacent steps to accept on either side. Default 1\n * (accepts T-1, T, T+1 — a 90-second acceptance window).\n * @returns `true` if the code matches any accepted step, `false` otherwise.\n */\nexport function verifyTotp(\n secret: string,\n code: string,\n when: number = Date.now(),\n skew: number = 1,\n): boolean {\n const normalised = String(code).padStart(DIGITS, '0');\n if (normalised.length !== DIGITS || !/^\\d{6}$/.test(normalised)) {\n return false;\n }\n\n const candidateBuf = Buffer.from(normalised, 'utf8');\n\n for (let delta = -skew; delta <= skew; delta++) {\n const stepWhen = when + delta * TIME_STEP * 1000;\n const expected = generateTotp(secret, stepWhen);\n const expectedBuf = Buffer.from(expected, 'utf8');\n if (timingSafeEqual(expectedBuf, candidateBuf)) {\n return true;\n }\n }\n\n return false;\n}\n\n/**\n * Minimum length (in hex characters) accepted for `AIT_DEBUG_TOTP_SECRET`.\n *\n * The secret is hex-encoded (see {@link generateTotp} — `Buffer.from(secret,\n * 'hex')`). 32 hex chars = 16 bytes = 128 bits, the floor for an HMAC-SHA1 key\n * we are willing to gate a public relay behind. `generateAttachToken()` emits\n * 64 hex chars (32 bytes), comfortably above this bar.\n */\nconst MIN_SECRET_HEX_CHARS = 32;\n\n/** Hex string: one or more hex digits, case-insensitive (RFC 4648 base16). */\nconst HEX_RE = /^[0-9a-fA-F]+$/;\n\n/**\n * Human-facing guidance printed when {@link assertRelayAuthConfigured} fails.\n *\n * SECRET-HANDLING: this message states only the REQUIREMENT (≥32 hex chars) and\n * how to mint one. It NEVER echoes the configured value, its length, or any\n * fragment derived from it — see {@link assertRelayAuthConfigured}.\n *\n * Note on encoding: the secret is hex (base16), not base32 — `generateTotp`\n * decodes it with `Buffer.from(secret, 'hex')`. A base32 string would be\n * silently mis-decoded and every TOTP code would fail to match, so the minting\n * command emits hex.\n */\nexport const RELAY_AUTH_SECRET_MISSING_MESSAGE = [\n '[ait-debug] AIT_DEBUG_TOTP_SECRET이 필수입니다. 32자 이상 16진수(hex) 문자열을 설정하세요.',\n '발급: openssl rand -hex 32',\n '자세히: https://docs.aitc.dev/guides/relay-auth-totp',\n].join('\\n');\n\n/**\n * Whether `secret` is a well-formed relay-auth TOTP secret: a hex string of at\n * least {@link MIN_SECRET_HEX_CHARS} characters with an even length (an odd\n * length would have its trailing nibble silently dropped by `Buffer.from(...,\n * 'hex')`, weakening the key without warning).\n *\n * Pure predicate so callers can test the validation independently of the\n * fail-fast side effect in {@link assertRelayAuthConfigured}.\n *\n * SECRET-HANDLING: returns only a boolean — the input value is never returned,\n * logged, or echoed.\n */\nexport function isValidRelayAuthSecret(secret: string | undefined): secret is string {\n if (secret === undefined || secret === '') return false;\n if (secret.length < MIN_SECRET_HEX_CHARS) return false;\n if (secret.length % 2 !== 0) return false;\n return HEX_RE.test(secret);\n}\n\n/**\n * Fail-fast guard enforcing that a relay-auth TOTP secret is configured before\n * a public-internet-exposed relay is booted (issue #250).\n *\n * Relay-auth (the §4 Layer C TOTP gate) is the only fail-fast layer that closes\n * the real gap: a leaked `wss://…trycloudflare.com` URL otherwise lets a third\n * party attach a debugger to a dogfood/live mini-app. Without a secret the relay\n * comes up unauthenticated, so this guard is called at every relay-boot site —\n * `bootRelayFamily` (intoss env 3/4) and `bootExternalRelayFamily` (env-2 PWA),\n * both eager and lazy. Local-only sessions never boot a relay and so never reach\n * this guard, matching the issue's exemption for non-relay debugging.\n *\n * Throws when the secret is unset, empty, too short, or not a valid hex string.\n * The thrown message is the bin entry's fatal stderr (see `cli.ts` `main().catch`)\n * — the same fatal model as the missing-`AIT_RELAY_BASE_URL` path.\n *\n * SECRET-HANDLING: the env value is read once, passed ONLY to the boolean\n * predicate, and never logged. The thrown message names the requirement, never\n * the value, its length, or any derived fragment.\n *\n * @param env - Environment to read from. Defaults to `process.env`; injectable\n * for tests so they never mutate the real process environment.\n */\nexport function assertRelayAuthConfigured(env: NodeJS.ProcessEnv = process.env): void {\n if (!isValidRelayAuthSecret(env.AIT_DEBUG_TOTP_SECRET)) {\n throw new Error(RELAY_AUTH_SECRET_MISSING_MESSAGE);\n }\n}\n\n/**\n * Reads `AIT_DEBUG_TOTP_SECRET` from `process.env` at runtime and builds a\n * `verifyAuth` predicate for the Chii relay's WebSocket upgrade gate.\n *\n * The predicate checks the `at` query parameter against the current and\n * adjacent TOTP time steps (±1 skew) using {@link verifyTotp}.\n *\n * Returns `undefined` when the env var is not set — callers treat that as\n * \"auth disabled\" (no predicate registered on the relay). Note that since\n * issue #250 the secret is MANDATORY at every relay-boot site (enforced by\n * {@link assertRelayAuthConfigured} BEFORE the relay starts), so in production\n * this never returns `undefined` for a relay that actually boots; the\n * `undefined` branch only matters for the no-relay local path and tests.\n *\n * Lives here (not in the MCP server) so the unplugin's env-2 relay can wire the\n * same gate without importing the heavy MCP server module graph. Re-exported\n * from `debug-server.ts` for back-compat.\n *\n * SECRET-HANDLING: The secret value read from env is captured in a closure and\n * is NEVER written to any log, error message, or process output.\n */\nexport function buildRelayVerifyAuth(\n env: NodeJS.ProcessEnv = process.env,\n): ((req: import('node:http').IncomingMessage) => boolean) | undefined {\n const secret = env.AIT_DEBUG_TOTP_SECRET;\n if (!secret) return undefined;\n\n return (req) => {\n // Parse the `at` query param from the upgrade request URL.\n // req.url is the raw request path + query, e.g. `/client/id?target=…&at=123456`\n const rawUrl = req.url ?? '';\n const qIndex = rawUrl.indexOf('?');\n const queryStr = qIndex === -1 ? '' : rawUrl.slice(qIndex + 1);\n const params = new URLSearchParams(queryStr);\n const code = params.get('at') ?? '';\n\n // Do NOT log `code`, `secret`, or any derived value here.\n return verifyTotp(secret, code);\n };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA8BA,MAAM,YAAY;;AAGlB,MAAM,SAAS;;;;;;;;;;AAWf,SAAgB,aAAa,QAAgB,OAAe,KAAK,KAAK,EAAU;CAC9E,MAAM,MAAM,OAAO,KAAK,QAAQ,MAAM;CAGtC,MAAM,UAAU,KAAK,IAAI,GAAG,KAAK,MAAM,OAAO,MAAO,UAAU,CAAC;CAGhE,MAAM,aAAa,OAAO,MAAM,EAAE;CAGlC,MAAM,KAAK,KAAK,MAAM,UAAU,WAAY;CAC5C,MAAM,KAAK,YAAY;AACvB,YAAW,cAAc,IAAI,EAAE;AAC/B,YAAW,cAAc,IAAI,EAAE;CAE/B,MAAM,OAAA,GAAA,YAAA,YAAiB,QAAQ,IAAI,CAAC,OAAO,WAAW,CAAC,QAAQ;CAG/D,MAAM,SAAS,IAAI,MAAM;AAQzB,WANI,IAAI,UAAU,QAAS,MACvB,IAAI,SAAS,KAAK,QAAS,MAC3B,IAAI,SAAS,KAAK,QAAS,IAC5B,IAAI,SAAS,KAAK,OAEC,MAAM,QACjB,UAAU,CAAC,SAAS,QAAQ,IAAI;;;;;;;;;;;;;;;;AAiB7C,SAAgB,WACd,QACA,MACA,OAAe,KAAK,KAAK,EACzB,OAAe,GACN;CACT,MAAM,aAAa,OAAO,KAAK,CAAC,SAAS,QAAQ,IAAI;AACrD,KAAI,WAAW,WAAW,UAAU,CAAC,UAAU,KAAK,WAAW,CAC7D,QAAO;CAGT,MAAM,eAAe,OAAO,KAAK,YAAY,OAAO;AAEpD,MAAK,IAAI,QAAQ,CAAC,MAAM,SAAS,MAAM,SAAS;EAE9C,MAAM,WAAW,aAAa,QADb,OAAO,QAAQ,YAAY,IACG;AAE/C,OAAA,GAAA,YAAA,iBADoB,OAAO,KAAK,UAAU,OAAO,EAChB,aAAa,CAC5C,QAAO;;AAIX,QAAO;;;;;;;;;;AAWT,MAAM,uBAAuB;;AAG7B,MAAM,SAAS;;;;;;;;;;;;;AAcf,MAAa,oCAAoC;CAC/C;CACA;CACA;CACD,CAAC,KAAK,KAAK;;;;;;;;;;;;;AAcZ,SAAgB,uBAAuB,QAA8C;AACnF,KAAI,WAAW,KAAA,KAAa,WAAW,GAAI,QAAO;AAClD,KAAI,OAAO,SAAS,qBAAsB,QAAO;AACjD,KAAI,OAAO,SAAS,MAAM,EAAG,QAAO;AACpC,QAAO,OAAO,KAAK,OAAO;;;;;;;;;;;;;;;;;;;;;;;;;AA0B5B,SAAgB,0BAA0B,MAAyB,QAAQ,KAAW;AACpF,KAAI,CAAC,uBAAuB,IAAI,sBAAsB,CACpD,OAAM,IAAI,MAAM,kCAAkC;;;;;;;;;;;;;;;;;;;;;;;AAyBtD,SAAgB,qBACd,MAAyB,QAAQ,KACoC;CACrE,MAAM,SAAS,IAAI;AACnB,KAAI,CAAC,OAAQ,QAAO,KAAA;AAEpB,SAAQ,QAAQ;EAGd,MAAM,SAAS,IAAI,OAAO;EAC1B,MAAM,SAAS,OAAO,QAAQ,IAAI;EAClC,MAAM,WAAW,WAAW,KAAK,KAAK,OAAO,MAAM,SAAS,EAAE;AAK9D,SAAO,WAAW,QAJH,IAAI,gBAAgB,SAAS,CACxB,IAAI,KAAK,IAAI,GAGF"}
@@ -0,0 +1,184 @@
1
+ import { createHmac, timingSafeEqual } from "node:crypto";
2
+ //#region src/mcp/totp.ts
3
+ /**
4
+ * RFC 6238 TOTP implementation (Node.js, node:crypto only).
5
+ *
6
+ * External TOTP libraries (otplib, speakeasy, …) are intentionally NOT used
7
+ * to keep the dependency surface minimal. This hand-roll is ~30 lines and
8
+ * covers exactly what relay-side auth needs.
9
+ *
10
+ * Algorithm summary (RFC 6238 + RFC 4226):
11
+ * T = floor(now / 30) — 30-second time step counter
12
+ * K = Buffer.from(secret, 'hex') — shared secret (raw bytes, hex-encoded)
13
+ * MAC = HMAC-SHA1(K, T as 8-byte big-endian uint64)
14
+ * offset = MAC[19] & 0x0f
15
+ * code = (MAC[offset..offset+4] & 0x7fffffff) % 10^6 — 6 digits
16
+ *
17
+ * Security note (keep this comment accurate):
18
+ * The baked-in secret in a dogfood build is extractable from the bundle by a
19
+ * determined reverse engineer. This mechanism raises the bar from
20
+ * "anyone with the URL" to "URL + bundle extraction + live TOTP calculation".
21
+ * Casual URL leaks (Slack paste, QR screenshot, shoulder-surfing) are
22
+ * blocked; deliberate reverse engineering is not. See threat model in
23
+ * src/mcp/chii-relay.ts and umbrella CLAUDE.md §4.
24
+ *
25
+ * SECRET-HANDLING: secret values and computed codes MUST NOT appear in any
26
+ * log, error message, or string visible outside this module. Only boolean
27
+ * pass/fail and reason enum values are safe to surface.
28
+ */
29
+ /** Time step window in seconds (RFC 6238 default). */
30
+ const TIME_STEP = 30;
31
+ /** Number of digits in the generated code. */
32
+ const DIGITS = 6;
33
+ /**
34
+ * Derives a 6-digit TOTP code from a hex-encoded secret at the given wall-
35
+ * clock time.
36
+ *
37
+ * @param secret - The shared secret as a hex string (e.g. 64 hex chars = 32
38
+ * bytes). Must be the output of `generateAttachToken()` or compatible.
39
+ * @param when - Unix timestamp in milliseconds. Defaults to `Date.now()`.
40
+ * @returns A zero-padded 6-digit decimal string, e.g. `"042193"`.
41
+ */
42
+ function generateTotp(secret, when = Date.now()) {
43
+ const key = Buffer.from(secret, "hex");
44
+ const counter = Math.max(0, Math.floor(when / 1e3 / TIME_STEP));
45
+ const counterBuf = Buffer.alloc(8);
46
+ const hi = Math.floor(counter / 4294967296);
47
+ const lo = counter >>> 0;
48
+ counterBuf.writeUInt32BE(hi, 0);
49
+ counterBuf.writeUInt32BE(lo, 4);
50
+ const mac = createHmac("sha1", key).update(counterBuf).digest();
51
+ const offset = mac[19] & 15;
52
+ return (((mac[offset] & 127) << 24 | (mac[offset + 1] & 255) << 16 | (mac[offset + 2] & 255) << 8 | mac[offset + 3] & 255) % 10 ** DIGITS).toString().padStart(DIGITS, "0");
53
+ }
54
+ /**
55
+ * Verifies a TOTP code against the secret, accepting ±`skew` time steps to
56
+ * tolerate clock drift between the relay host and the client device.
57
+ *
58
+ * Uses `timingSafeEqual` for constant-time comparison to prevent timing
59
+ * side-channel attacks.
60
+ *
61
+ * @param secret - Hex-encoded shared secret.
62
+ * @param code - The 6-digit code to verify (string or numeric).
63
+ * @param when - Unix timestamp in milliseconds. Defaults to `Date.now()`.
64
+ * @param skew - Number of adjacent steps to accept on either side. Default 1
65
+ * (accepts T-1, T, T+1 — a 90-second acceptance window).
66
+ * @returns `true` if the code matches any accepted step, `false` otherwise.
67
+ */
68
+ function verifyTotp(secret, code, when = Date.now(), skew = 1) {
69
+ const normalised = String(code).padStart(DIGITS, "0");
70
+ if (normalised.length !== DIGITS || !/^\d{6}$/.test(normalised)) return false;
71
+ const candidateBuf = Buffer.from(normalised, "utf8");
72
+ for (let delta = -skew; delta <= skew; delta++) {
73
+ const expected = generateTotp(secret, when + delta * TIME_STEP * 1e3);
74
+ if (timingSafeEqual(Buffer.from(expected, "utf8"), candidateBuf)) return true;
75
+ }
76
+ return false;
77
+ }
78
+ /**
79
+ * Minimum length (in hex characters) accepted for `AIT_DEBUG_TOTP_SECRET`.
80
+ *
81
+ * The secret is hex-encoded (see {@link generateTotp} — `Buffer.from(secret,
82
+ * 'hex')`). 32 hex chars = 16 bytes = 128 bits, the floor for an HMAC-SHA1 key
83
+ * we are willing to gate a public relay behind. `generateAttachToken()` emits
84
+ * 64 hex chars (32 bytes), comfortably above this bar.
85
+ */
86
+ const MIN_SECRET_HEX_CHARS = 32;
87
+ /** Hex string: one or more hex digits, case-insensitive (RFC 4648 base16). */
88
+ const HEX_RE = /^[0-9a-fA-F]+$/;
89
+ /**
90
+ * Human-facing guidance printed when {@link assertRelayAuthConfigured} fails.
91
+ *
92
+ * SECRET-HANDLING: this message states only the REQUIREMENT (≥32 hex chars) and
93
+ * how to mint one. It NEVER echoes the configured value, its length, or any
94
+ * fragment derived from it — see {@link assertRelayAuthConfigured}.
95
+ *
96
+ * Note on encoding: the secret is hex (base16), not base32 — `generateTotp`
97
+ * decodes it with `Buffer.from(secret, 'hex')`. A base32 string would be
98
+ * silently mis-decoded and every TOTP code would fail to match, so the minting
99
+ * command emits hex.
100
+ */
101
+ const RELAY_AUTH_SECRET_MISSING_MESSAGE = [
102
+ "[ait-debug] AIT_DEBUG_TOTP_SECRET이 필수입니다. 32자 이상 16진수(hex) 문자열을 설정하세요.",
103
+ "발급: openssl rand -hex 32",
104
+ "자세히: https://docs.aitc.dev/guides/relay-auth-totp"
105
+ ].join("\n");
106
+ /**
107
+ * Whether `secret` is a well-formed relay-auth TOTP secret: a hex string of at
108
+ * least {@link MIN_SECRET_HEX_CHARS} characters with an even length (an odd
109
+ * length would have its trailing nibble silently dropped by `Buffer.from(...,
110
+ * 'hex')`, weakening the key without warning).
111
+ *
112
+ * Pure predicate so callers can test the validation independently of the
113
+ * fail-fast side effect in {@link assertRelayAuthConfigured}.
114
+ *
115
+ * SECRET-HANDLING: returns only a boolean — the input value is never returned,
116
+ * logged, or echoed.
117
+ */
118
+ function isValidRelayAuthSecret(secret) {
119
+ if (secret === void 0 || secret === "") return false;
120
+ if (secret.length < MIN_SECRET_HEX_CHARS) return false;
121
+ if (secret.length % 2 !== 0) return false;
122
+ return HEX_RE.test(secret);
123
+ }
124
+ /**
125
+ * Fail-fast guard enforcing that a relay-auth TOTP secret is configured before
126
+ * a public-internet-exposed relay is booted (issue #250).
127
+ *
128
+ * Relay-auth (the §4 Layer C TOTP gate) is the only fail-fast layer that closes
129
+ * the real gap: a leaked `wss://…trycloudflare.com` URL otherwise lets a third
130
+ * party attach a debugger to a dogfood/live mini-app. Without a secret the relay
131
+ * comes up unauthenticated, so this guard is called at every relay-boot site —
132
+ * `bootRelayFamily` (intoss env 3/4) and `bootExternalRelayFamily` (env-2 PWA),
133
+ * both eager and lazy. Local-only sessions never boot a relay and so never reach
134
+ * this guard, matching the issue's exemption for non-relay debugging.
135
+ *
136
+ * Throws when the secret is unset, empty, too short, or not a valid hex string.
137
+ * The thrown message is the bin entry's fatal stderr (see `cli.ts` `main().catch`)
138
+ * — the same fatal model as the missing-`AIT_RELAY_BASE_URL` path.
139
+ *
140
+ * SECRET-HANDLING: the env value is read once, passed ONLY to the boolean
141
+ * predicate, and never logged. The thrown message names the requirement, never
142
+ * the value, its length, or any derived fragment.
143
+ *
144
+ * @param env - Environment to read from. Defaults to `process.env`; injectable
145
+ * for tests so they never mutate the real process environment.
146
+ */
147
+ function assertRelayAuthConfigured(env = process.env) {
148
+ if (!isValidRelayAuthSecret(env.AIT_DEBUG_TOTP_SECRET)) throw new Error(RELAY_AUTH_SECRET_MISSING_MESSAGE);
149
+ }
150
+ /**
151
+ * Reads `AIT_DEBUG_TOTP_SECRET` from `process.env` at runtime and builds a
152
+ * `verifyAuth` predicate for the Chii relay's WebSocket upgrade gate.
153
+ *
154
+ * The predicate checks the `at` query parameter against the current and
155
+ * adjacent TOTP time steps (±1 skew) using {@link verifyTotp}.
156
+ *
157
+ * Returns `undefined` when the env var is not set — callers treat that as
158
+ * "auth disabled" (no predicate registered on the relay). Note that since
159
+ * issue #250 the secret is MANDATORY at every relay-boot site (enforced by
160
+ * {@link assertRelayAuthConfigured} BEFORE the relay starts), so in production
161
+ * this never returns `undefined` for a relay that actually boots; the
162
+ * `undefined` branch only matters for the no-relay local path and tests.
163
+ *
164
+ * Lives here (not in the MCP server) so the unplugin's env-2 relay can wire the
165
+ * same gate without importing the heavy MCP server module graph. Re-exported
166
+ * from `debug-server.ts` for back-compat.
167
+ *
168
+ * SECRET-HANDLING: The secret value read from env is captured in a closure and
169
+ * is NEVER written to any log, error message, or process output.
170
+ */
171
+ function buildRelayVerifyAuth(env = process.env) {
172
+ const secret = env.AIT_DEBUG_TOTP_SECRET;
173
+ if (!secret) return void 0;
174
+ return (req) => {
175
+ const rawUrl = req.url ?? "";
176
+ const qIndex = rawUrl.indexOf("?");
177
+ const queryStr = qIndex === -1 ? "" : rawUrl.slice(qIndex + 1);
178
+ return verifyTotp(secret, new URLSearchParams(queryStr).get("at") ?? "");
179
+ };
180
+ }
181
+ //#endregion
182
+ export { assertRelayAuthConfigured, buildRelayVerifyAuth };
183
+
184
+ //# sourceMappingURL=totp-CxHsagqY.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"totp-CxHsagqY.js","names":[],"sources":["../src/mcp/totp.ts"],"sourcesContent":["/**\n * RFC 6238 TOTP implementation (Node.js, node:crypto only).\n *\n * External TOTP libraries (otplib, speakeasy, …) are intentionally NOT used\n * to keep the dependency surface minimal. This hand-roll is ~30 lines and\n * covers exactly what relay-side auth needs.\n *\n * Algorithm summary (RFC 6238 + RFC 4226):\n * T = floor(now / 30) — 30-second time step counter\n * K = Buffer.from(secret, 'hex') — shared secret (raw bytes, hex-encoded)\n * MAC = HMAC-SHA1(K, T as 8-byte big-endian uint64)\n * offset = MAC[19] & 0x0f\n * code = (MAC[offset..offset+4] & 0x7fffffff) % 10^6 — 6 digits\n *\n * Security note (keep this comment accurate):\n * The baked-in secret in a dogfood build is extractable from the bundle by a\n * determined reverse engineer. This mechanism raises the bar from\n * \"anyone with the URL\" to \"URL + bundle extraction + live TOTP calculation\".\n * Casual URL leaks (Slack paste, QR screenshot, shoulder-surfing) are\n * blocked; deliberate reverse engineering is not. See threat model in\n * src/mcp/chii-relay.ts and umbrella CLAUDE.md §4.\n *\n * SECRET-HANDLING: secret values and computed codes MUST NOT appear in any\n * log, error message, or string visible outside this module. Only boolean\n * pass/fail and reason enum values are safe to surface.\n */\n\nimport { createHmac, timingSafeEqual } from 'node:crypto';\n\n/** Time step window in seconds (RFC 6238 default). */\nconst TIME_STEP = 30;\n\n/** Number of digits in the generated code. */\nconst DIGITS = 6;\n\n/**\n * Derives a 6-digit TOTP code from a hex-encoded secret at the given wall-\n * clock time.\n *\n * @param secret - The shared secret as a hex string (e.g. 64 hex chars = 32\n * bytes). Must be the output of `generateAttachToken()` or compatible.\n * @param when - Unix timestamp in milliseconds. Defaults to `Date.now()`.\n * @returns A zero-padded 6-digit decimal string, e.g. `\"042193\"`.\n */\nexport function generateTotp(secret: string, when: number = Date.now()): string {\n const key = Buffer.from(secret, 'hex');\n // Clamp to 0 so negative timestamps (e.g. in ±skew checks near epoch) do not\n // produce a negative counter, which would cause writeUInt32BE to throw.\n const counter = Math.max(0, Math.floor(when / 1000 / TIME_STEP));\n\n // Encode counter as 8-byte big-endian unsigned integer.\n const counterBuf = Buffer.alloc(8);\n // JavaScript numbers are safe integers up to 2^53; counter is ~7.5×10^10 at\n // year 9999 — well within safe range so standard bitwise ops are fine.\n const hi = Math.floor(counter / 0x100000000);\n const lo = counter >>> 0;\n counterBuf.writeUInt32BE(hi, 0);\n counterBuf.writeUInt32BE(lo, 4);\n\n const mac = createHmac('sha1', key).update(counterBuf).digest();\n\n // Dynamic truncation (RFC 4226 §5.4).\n const offset = mac[19] & 0x0f;\n const binCode =\n ((mac[offset] & 0x7f) << 24) |\n ((mac[offset + 1] & 0xff) << 16) |\n ((mac[offset + 2] & 0xff) << 8) |\n (mac[offset + 3] & 0xff);\n\n const otp = binCode % 10 ** DIGITS;\n return otp.toString().padStart(DIGITS, '0');\n}\n\n/**\n * Verifies a TOTP code against the secret, accepting ±`skew` time steps to\n * tolerate clock drift between the relay host and the client device.\n *\n * Uses `timingSafeEqual` for constant-time comparison to prevent timing\n * side-channel attacks.\n *\n * @param secret - Hex-encoded shared secret.\n * @param code - The 6-digit code to verify (string or numeric).\n * @param when - Unix timestamp in milliseconds. Defaults to `Date.now()`.\n * @param skew - Number of adjacent steps to accept on either side. Default 1\n * (accepts T-1, T, T+1 — a 90-second acceptance window).\n * @returns `true` if the code matches any accepted step, `false` otherwise.\n */\nexport function verifyTotp(\n secret: string,\n code: string,\n when: number = Date.now(),\n skew: number = 1,\n): boolean {\n const normalised = String(code).padStart(DIGITS, '0');\n if (normalised.length !== DIGITS || !/^\\d{6}$/.test(normalised)) {\n return false;\n }\n\n const candidateBuf = Buffer.from(normalised, 'utf8');\n\n for (let delta = -skew; delta <= skew; delta++) {\n const stepWhen = when + delta * TIME_STEP * 1000;\n const expected = generateTotp(secret, stepWhen);\n const expectedBuf = Buffer.from(expected, 'utf8');\n if (timingSafeEqual(expectedBuf, candidateBuf)) {\n return true;\n }\n }\n\n return false;\n}\n\n/**\n * Minimum length (in hex characters) accepted for `AIT_DEBUG_TOTP_SECRET`.\n *\n * The secret is hex-encoded (see {@link generateTotp} — `Buffer.from(secret,\n * 'hex')`). 32 hex chars = 16 bytes = 128 bits, the floor for an HMAC-SHA1 key\n * we are willing to gate a public relay behind. `generateAttachToken()` emits\n * 64 hex chars (32 bytes), comfortably above this bar.\n */\nconst MIN_SECRET_HEX_CHARS = 32;\n\n/** Hex string: one or more hex digits, case-insensitive (RFC 4648 base16). */\nconst HEX_RE = /^[0-9a-fA-F]+$/;\n\n/**\n * Human-facing guidance printed when {@link assertRelayAuthConfigured} fails.\n *\n * SECRET-HANDLING: this message states only the REQUIREMENT (≥32 hex chars) and\n * how to mint one. It NEVER echoes the configured value, its length, or any\n * fragment derived from it — see {@link assertRelayAuthConfigured}.\n *\n * Note on encoding: the secret is hex (base16), not base32 — `generateTotp`\n * decodes it with `Buffer.from(secret, 'hex')`. A base32 string would be\n * silently mis-decoded and every TOTP code would fail to match, so the minting\n * command emits hex.\n */\nexport const RELAY_AUTH_SECRET_MISSING_MESSAGE = [\n '[ait-debug] AIT_DEBUG_TOTP_SECRET이 필수입니다. 32자 이상 16진수(hex) 문자열을 설정하세요.',\n '발급: openssl rand -hex 32',\n '자세히: https://docs.aitc.dev/guides/relay-auth-totp',\n].join('\\n');\n\n/**\n * Whether `secret` is a well-formed relay-auth TOTP secret: a hex string of at\n * least {@link MIN_SECRET_HEX_CHARS} characters with an even length (an odd\n * length would have its trailing nibble silently dropped by `Buffer.from(...,\n * 'hex')`, weakening the key without warning).\n *\n * Pure predicate so callers can test the validation independently of the\n * fail-fast side effect in {@link assertRelayAuthConfigured}.\n *\n * SECRET-HANDLING: returns only a boolean — the input value is never returned,\n * logged, or echoed.\n */\nexport function isValidRelayAuthSecret(secret: string | undefined): secret is string {\n if (secret === undefined || secret === '') return false;\n if (secret.length < MIN_SECRET_HEX_CHARS) return false;\n if (secret.length % 2 !== 0) return false;\n return HEX_RE.test(secret);\n}\n\n/**\n * Fail-fast guard enforcing that a relay-auth TOTP secret is configured before\n * a public-internet-exposed relay is booted (issue #250).\n *\n * Relay-auth (the §4 Layer C TOTP gate) is the only fail-fast layer that closes\n * the real gap: a leaked `wss://…trycloudflare.com` URL otherwise lets a third\n * party attach a debugger to a dogfood/live mini-app. Without a secret the relay\n * comes up unauthenticated, so this guard is called at every relay-boot site —\n * `bootRelayFamily` (intoss env 3/4) and `bootExternalRelayFamily` (env-2 PWA),\n * both eager and lazy. Local-only sessions never boot a relay and so never reach\n * this guard, matching the issue's exemption for non-relay debugging.\n *\n * Throws when the secret is unset, empty, too short, or not a valid hex string.\n * The thrown message is the bin entry's fatal stderr (see `cli.ts` `main().catch`)\n * — the same fatal model as the missing-`AIT_RELAY_BASE_URL` path.\n *\n * SECRET-HANDLING: the env value is read once, passed ONLY to the boolean\n * predicate, and never logged. The thrown message names the requirement, never\n * the value, its length, or any derived fragment.\n *\n * @param env - Environment to read from. Defaults to `process.env`; injectable\n * for tests so they never mutate the real process environment.\n */\nexport function assertRelayAuthConfigured(env: NodeJS.ProcessEnv = process.env): void {\n if (!isValidRelayAuthSecret(env.AIT_DEBUG_TOTP_SECRET)) {\n throw new Error(RELAY_AUTH_SECRET_MISSING_MESSAGE);\n }\n}\n\n/**\n * Reads `AIT_DEBUG_TOTP_SECRET` from `process.env` at runtime and builds a\n * `verifyAuth` predicate for the Chii relay's WebSocket upgrade gate.\n *\n * The predicate checks the `at` query parameter against the current and\n * adjacent TOTP time steps (±1 skew) using {@link verifyTotp}.\n *\n * Returns `undefined` when the env var is not set — callers treat that as\n * \"auth disabled\" (no predicate registered on the relay). Note that since\n * issue #250 the secret is MANDATORY at every relay-boot site (enforced by\n * {@link assertRelayAuthConfigured} BEFORE the relay starts), so in production\n * this never returns `undefined` for a relay that actually boots; the\n * `undefined` branch only matters for the no-relay local path and tests.\n *\n * Lives here (not in the MCP server) so the unplugin's env-2 relay can wire the\n * same gate without importing the heavy MCP server module graph. Re-exported\n * from `debug-server.ts` for back-compat.\n *\n * SECRET-HANDLING: The secret value read from env is captured in a closure and\n * is NEVER written to any log, error message, or process output.\n */\nexport function buildRelayVerifyAuth(\n env: NodeJS.ProcessEnv = process.env,\n): ((req: import('node:http').IncomingMessage) => boolean) | undefined {\n const secret = env.AIT_DEBUG_TOTP_SECRET;\n if (!secret) return undefined;\n\n return (req) => {\n // Parse the `at` query param from the upgrade request URL.\n // req.url is the raw request path + query, e.g. `/client/id?target=…&at=123456`\n const rawUrl = req.url ?? '';\n const qIndex = rawUrl.indexOf('?');\n const queryStr = qIndex === -1 ? '' : rawUrl.slice(qIndex + 1);\n const params = new URLSearchParams(queryStr);\n const code = params.get('at') ?? '';\n\n // Do NOT log `code`, `secret`, or any derived value here.\n return verifyTotp(secret, code);\n };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA8BA,MAAM,YAAY;;AAGlB,MAAM,SAAS;;;;;;;;;;AAWf,SAAgB,aAAa,QAAgB,OAAe,KAAK,KAAK,EAAU;CAC9E,MAAM,MAAM,OAAO,KAAK,QAAQ,MAAM;CAGtC,MAAM,UAAU,KAAK,IAAI,GAAG,KAAK,MAAM,OAAO,MAAO,UAAU,CAAC;CAGhE,MAAM,aAAa,OAAO,MAAM,EAAE;CAGlC,MAAM,KAAK,KAAK,MAAM,UAAU,WAAY;CAC5C,MAAM,KAAK,YAAY;AACvB,YAAW,cAAc,IAAI,EAAE;AAC/B,YAAW,cAAc,IAAI,EAAE;CAE/B,MAAM,MAAM,WAAW,QAAQ,IAAI,CAAC,OAAO,WAAW,CAAC,QAAQ;CAG/D,MAAM,SAAS,IAAI,MAAM;AAQzB,WANI,IAAI,UAAU,QAAS,MACvB,IAAI,SAAS,KAAK,QAAS,MAC3B,IAAI,SAAS,KAAK,QAAS,IAC5B,IAAI,SAAS,KAAK,OAEC,MAAM,QACjB,UAAU,CAAC,SAAS,QAAQ,IAAI;;;;;;;;;;;;;;;;AAiB7C,SAAgB,WACd,QACA,MACA,OAAe,KAAK,KAAK,EACzB,OAAe,GACN;CACT,MAAM,aAAa,OAAO,KAAK,CAAC,SAAS,QAAQ,IAAI;AACrD,KAAI,WAAW,WAAW,UAAU,CAAC,UAAU,KAAK,WAAW,CAC7D,QAAO;CAGT,MAAM,eAAe,OAAO,KAAK,YAAY,OAAO;AAEpD,MAAK,IAAI,QAAQ,CAAC,MAAM,SAAS,MAAM,SAAS;EAE9C,MAAM,WAAW,aAAa,QADb,OAAO,QAAQ,YAAY,IACG;AAE/C,MAAI,gBADgB,OAAO,KAAK,UAAU,OAAO,EAChB,aAAa,CAC5C,QAAO;;AAIX,QAAO;;;;;;;;;;AAWT,MAAM,uBAAuB;;AAG7B,MAAM,SAAS;;;;;;;;;;;;;AAcf,MAAa,oCAAoC;CAC/C;CACA;CACA;CACD,CAAC,KAAK,KAAK;;;;;;;;;;;;;AAcZ,SAAgB,uBAAuB,QAA8C;AACnF,KAAI,WAAW,KAAA,KAAa,WAAW,GAAI,QAAO;AAClD,KAAI,OAAO,SAAS,qBAAsB,QAAO;AACjD,KAAI,OAAO,SAAS,MAAM,EAAG,QAAO;AACpC,QAAO,OAAO,KAAK,OAAO;;;;;;;;;;;;;;;;;;;;;;;;;AA0B5B,SAAgB,0BAA0B,MAAyB,QAAQ,KAAW;AACpF,KAAI,CAAC,uBAAuB,IAAI,sBAAsB,CACpD,OAAM,IAAI,MAAM,kCAAkC;;;;;;;;;;;;;;;;;;;;;;;AAyBtD,SAAgB,qBACd,MAAyB,QAAQ,KACoC;CACrE,MAAM,SAAS,IAAI;AACnB,KAAI,CAAC,OAAQ,QAAO,KAAA;AAEpB,SAAQ,QAAQ;EAGd,MAAM,SAAS,IAAI,OAAO;EAC1B,MAAM,SAAS,OAAO,QAAQ,IAAI;EAClC,MAAM,WAAW,WAAW,KAAK,KAAK,OAAO,MAAM,SAAS,EAAE;AAK9D,SAAO,WAAW,QAJH,IAAI,gBAAgB,SAAS,CACxB,IAAI,KAAK,IAAI,GAGF"}
@@ -32,9 +32,16 @@ const LAUNCHER_URL = "https://devtools.aitc.dev/launcher/";
32
32
  * Plain-text raw URL is no longer enough — the launcher gates its setup UI to
33
33
  * the installed PWA, so a raw tunnel URL opened in a normal browser tab would
34
34
  * land on a "please install" screen.
35
+ *
36
+ * When `relayWssUrl` is given (env-2 CDP wiring), the deep-link also carries
37
+ * `&debug=1&relay=<wss>`; the launcher folds those onto the framed tunnel URL so
38
+ * the in-app debug gate's Layer C (`debug=1` opt-in + `relay=<wss>`) is met and
39
+ * a Chii target.js is injected into the live view.
35
40
  */
36
- function buildLauncherDeepLink(tunnelUrl) {
37
- return `${LAUNCHER_URL}?url=${encodeURIComponent(tunnelUrl)}`;
41
+ function buildLauncherDeepLink(tunnelUrl, relayWssUrl) {
42
+ const base = `${LAUNCHER_URL}?url=${encodeURIComponent(tunnelUrl)}`;
43
+ if (!relayWssUrl) return base;
44
+ return `${base}&debug=1&relay=${encodeURIComponent(relayWssUrl)}`;
38
45
  }
39
46
  /**
40
47
  * Print the terminal banner announcing the live tunnel: the public URL, an ASCII
@@ -44,7 +51,7 @@ function buildLauncherDeepLink(tunnelUrl) {
44
51
  */
45
52
  async function printTunnelBanner(url, opts = {}) {
46
53
  const log = opts.log ?? ((m) => console.log(m));
47
- const deepLink = buildLauncherDeepLink(url);
54
+ const deepLink = buildLauncherDeepLink(url, opts.relayWssUrl);
48
55
  log([
49
56
  "",
50
57
  " ┌─ @ait-co/devtools · live tunnel ────────────────────────────",
@@ -53,6 +60,7 @@ async function printTunnelBanner(url, opts = {}) {
53
60
  ` │ Install the launcher PWA once: ${LAUNCHER_URL}`,
54
61
  " │ Then scan the QR below — it opens the launcher directly",
55
62
  " │ into this tunnel URL (no manual paste needed).",
63
+ ...opts.relayWssUrl ? [" │ The same scan also attaches CDP — connect your AI host", " │ to the relay and debug the live view on-device."] : [],
56
64
  " │ Quick tunnels are unauthenticated, change every run, and are",
57
65
  " │ not for production use.",
58
66
  " └──────────────────────────────────────────────────────────────",
@@ -130,4 +138,4 @@ async function startQuickTunnel(port) {
130
138
  //#endregion
131
139
  export { printTunnelBanner, startQuickTunnel };
132
140
 
133
- //# sourceMappingURL=tunnel-D0_TwDNE.js.map
141
+ //# sourceMappingURL=tunnel-Cj8g1LIL.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tunnel-Cj8g1LIL.js","names":[],"sources":["../src/unplugin/tunnel.ts"],"sourcesContent":["/**\n * Cloudflare quick-tunnel helper for the devtools unplugin.\n *\n * Loaded lazily (`await import('./tunnel.js')`) only when the `tunnel` option is\n * on, so `cloudflared` / `qrcode-terminal` are never pulled in for the common\n * case. This is the one place in `@ait-co/devtools` that depends on Node-only\n * APIs (`child_process` via the `cloudflared` wrapper) — keep it thin and out of\n * jsdom unit tests; the spawn path is verified by hand / e2e (same spirit as the\n * \"web 모드는 e2e\" rule in CLAUDE.md). The pure helpers below\n * (`parseTrycloudflareUrl`, `printTunnelBanner`) are unit-tested.\n */\n\nimport { existsSync } from 'node:fs';\nimport { mkdir } from 'node:fs/promises';\nimport { dirname } from 'node:path';\n\n/** Matches the public URL cloudflared prints for an unauthenticated quick tunnel. */\nconst TRYCLOUDFLARE_RE = /https:\\/\\/[a-z0-9-]+\\.trycloudflare\\.com/i;\n\n/**\n * Extract the `https://<sub>.trycloudflare.com` URL from a line of cloudflared\n * output, or `null` if the line doesn't contain one. Pulled out as a pure\n * function so it can be unit-tested without spawning anything.\n */\nexport function parseTrycloudflareUrl(line: string): string | null {\n const m = line.match(TRYCLOUDFLARE_RE);\n return m ? m[0] : null;\n}\n\nexport interface PrintTunnelBannerOptions {\n /** Print an ASCII QR encoding the tunnel URL (default: true). */\n qr?: boolean;\n /** Sink for the banner text (default: `console.log`). Injected for testing. */\n log?: (msg: string) => void;\n /**\n * The `wss://` relay URL of the env-2 CDP tunnel, if `tunnel.cdp` is on. When\n * present the QR deep-link additionally carries `&debug=1&relay=<wss>` so the\n * framed PWA passes the in-app debug gate and attaches a Chii target — the\n * same single scan opens screen preview *and* CDP debugging.\n */\n relayWssUrl?: string;\n}\n\nconst LAUNCHER_URL = 'https://devtools.aitc.dev/launcher/';\n\n/**\n * Build the deep-link URL that QR codes encode: when the launcher PWA is\n * already on the phone's home screen, scanning this opens it directly into the\n * live view for `tunnelUrl` (the launcher consumes `?url=` and clears it).\n * Plain-text raw URL is no longer enough — the launcher gates its setup UI to\n * the installed PWA, so a raw tunnel URL opened in a normal browser tab would\n * land on a \"please install\" screen.\n *\n * When `relayWssUrl` is given (env-2 CDP wiring), the deep-link also carries\n * `&debug=1&relay=<wss>`; the launcher folds those onto the framed tunnel URL so\n * the in-app debug gate's Layer C (`debug=1` opt-in + `relay=<wss>`) is met and\n * a Chii target.js is injected into the live view.\n */\nexport function buildLauncherDeepLink(tunnelUrl: string, relayWssUrl?: string): string {\n const base = `${LAUNCHER_URL}?url=${encodeURIComponent(tunnelUrl)}`;\n if (!relayWssUrl) return base;\n return `${base}&debug=1&relay=${encodeURIComponent(relayWssUrl)}`;\n}\n\n/**\n * Print the terminal banner announcing the live tunnel: the public URL, an ASCII\n * QR encoding a launcher deep-link, and a one-line note that quick tunnels are\n * ephemeral, unauthenticated and not for production. Pure w.r.t. side effects\n * other than the injected `log` sink and `qrcode-terminal` — unit-tested.\n */\nexport async function printTunnelBanner(\n url: string,\n opts: PrintTunnelBannerOptions = {},\n): Promise<void> {\n const log = opts.log ?? ((m: string) => console.log(m));\n const deepLink = buildLauncherDeepLink(url, opts.relayWssUrl);\n const lines: string[] = [\n '',\n ' ┌─ @ait-co/devtools · live tunnel ────────────────────────────',\n ` │ ${url}`,\n ' │',\n ` │ Install the launcher PWA once: ${LAUNCHER_URL}`,\n ' │ Then scan the QR below — it opens the launcher directly',\n ' │ into this tunnel URL (no manual paste needed).',\n ...(opts.relayWssUrl\n ? [\n ' │ The same scan also attaches CDP — connect your AI host',\n ' │ to the relay and debug the live view on-device.',\n ]\n : []),\n ' │ Quick tunnels are unauthenticated, change every run, and are',\n ' │ not for production use.',\n ' └──────────────────────────────────────────────────────────────',\n '',\n ];\n log(lines.join('\\n'));\n\n if (opts.qr !== false) {\n // qrcode-terminal is only pulled in on this code path (ambient types live\n // in src/qrcode-terminal.d.ts).\n const qrcode = (await import('qrcode-terminal')).default;\n await new Promise<void>((resolve) => {\n qrcode.generate(deepLink, { small: true }, (out) => {\n log(out);\n resolve();\n });\n });\n }\n}\n\nexport interface QuickTunnel {\n /** The public `https://*.trycloudflare.com` URL. */\n url: string;\n /** Stop the underlying `cloudflared` process. Idempotent. */\n stop: () => void;\n}\n\nconst URL_TIMEOUT_MS = 20_000;\n\n/**\n * Start an unauthenticated Cloudflare quick tunnel to `http://localhost:<port>`\n * and resolve once the public URL is known. Downloads the `cloudflared` binary\n * on first use if it is not already installed. Rejects with a friendly error if\n * no URL appears within {@link URL_TIMEOUT_MS}.\n */\nexport async function startQuickTunnel(port: number): Promise<QuickTunnel> {\n const cloudflared = await import('cloudflared');\n const { bin, install, Tunnel } = cloudflared;\n\n if (!existsSync(bin)) {\n await mkdir(dirname(bin), { recursive: true });\n await install(bin);\n }\n\n const tunnel = Tunnel.quick(`http://localhost:${port}`);\n let stopped = false;\n const stop = () => {\n if (stopped) return;\n stopped = true;\n try {\n tunnel.stop();\n } catch {\n // process may already be gone\n }\n };\n\n return new Promise<QuickTunnel>((resolve, reject) => {\n const timer = setTimeout(() => {\n cleanup();\n stop();\n reject(\n new Error(\n `[@ait-co/devtools] cloudflared did not report a tunnel URL within ${\n URL_TIMEOUT_MS / 1000\n }s. Check your network connection, or run \\`cloudflared tunnel --url http://localhost:${port}\\` manually.`,\n ),\n );\n }, URL_TIMEOUT_MS);\n\n const onUrl = (line: string) => {\n const found = parseTrycloudflareUrl(line);\n if (!found) return;\n clearTimeout(timer);\n // Stop scanning further output once we have the URL.\n cleanup();\n resolve({ url: found, stop });\n };\n\n const cleanup = () => {\n tunnel.off('stdout', onUrl);\n tunnel.off('stderr', onUrl);\n };\n\n // The library emits a parsed `url` event; we also scan raw stdout/stderr in\n // case the output format shifts.\n tunnel.once('url', onUrl);\n tunnel.on('stdout', onUrl);\n tunnel.on('stderr', onUrl);\n tunnel.once('error', (err: Error) => {\n clearTimeout(timer);\n cleanup();\n stop();\n reject(err);\n });\n tunnel.once('exit', (code: number | null) => {\n if (stopped) return;\n clearTimeout(timer);\n cleanup();\n reject(\n new Error(\n `[@ait-co/devtools] cloudflared exited (code ${code ?? 'null'}) before reporting a tunnel URL.`,\n ),\n );\n });\n });\n}\n"],"mappings":";;;;;;;;;;;;;;;;AAiBA,MAAM,mBAAmB;;;;;;AAOzB,SAAgB,sBAAsB,MAA6B;CACjE,MAAM,IAAI,KAAK,MAAM,iBAAiB;AACtC,QAAO,IAAI,EAAE,KAAK;;AAiBpB,MAAM,eAAe;;;;;;;;;;;;;;AAerB,SAAgB,sBAAsB,WAAmB,aAA8B;CACrF,MAAM,OAAO,GAAG,aAAa,OAAO,mBAAmB,UAAU;AACjE,KAAI,CAAC,YAAa,QAAO;AACzB,QAAO,GAAG,KAAK,iBAAiB,mBAAmB,YAAY;;;;;;;;AASjE,eAAsB,kBACpB,KACA,OAAiC,EAAE,EACpB;CACf,MAAM,MAAM,KAAK,SAAS,MAAc,QAAQ,IAAI,EAAE;CACtD,MAAM,WAAW,sBAAsB,KAAK,KAAK,YAAY;AAoB7D,KAnBwB;EACtB;EACA;EACA,QAAQ;EACR;EACA,wCAAwC;EACxC;EACA;EACA,GAAI,KAAK,cACL,CACE,+DACA,uDACD,GACD,EAAE;EACN;EACA;EACA;EACA;EACD,CACS,KAAK,KAAK,CAAC;AAErB,KAAI,KAAK,OAAO,OAAO;EAGrB,MAAM,UAAU,MAAM,OAAO,oBAAoB;AACjD,QAAM,IAAI,SAAe,YAAY;AACnC,UAAO,SAAS,UAAU,EAAE,OAAO,MAAM,GAAG,QAAQ;AAClD,QAAI,IAAI;AACR,aAAS;KACT;IACF;;;AAWN,MAAM,iBAAiB;;;;;;;AAQvB,eAAsB,iBAAiB,MAAoC;CAEzE,MAAM,EAAE,KAAK,SAAS,WADF,MAAM,OAAO;AAGjC,KAAI,CAAC,WAAW,IAAI,EAAE;AACpB,QAAM,MAAM,QAAQ,IAAI,EAAE,EAAE,WAAW,MAAM,CAAC;AAC9C,QAAM,QAAQ,IAAI;;CAGpB,MAAM,SAAS,OAAO,MAAM,oBAAoB,OAAO;CACvD,IAAI,UAAU;CACd,MAAM,aAAa;AACjB,MAAI,QAAS;AACb,YAAU;AACV,MAAI;AACF,UAAO,MAAM;UACP;;AAKV,QAAO,IAAI,SAAsB,SAAS,WAAW;EACnD,MAAM,QAAQ,iBAAiB;AAC7B,YAAS;AACT,SAAM;AACN,0BACE,IAAI,MACF,qEACE,iBAAiB,IAClB,uFAAuF,KAAK,cAC9F,CACF;KACA,eAAe;EAElB,MAAM,SAAS,SAAiB;GAC9B,MAAM,QAAQ,sBAAsB,KAAK;AACzC,OAAI,CAAC,MAAO;AACZ,gBAAa,MAAM;AAEnB,YAAS;AACT,WAAQ;IAAE,KAAK;IAAO;IAAM,CAAC;;EAG/B,MAAM,gBAAgB;AACpB,UAAO,IAAI,UAAU,MAAM;AAC3B,UAAO,IAAI,UAAU,MAAM;;AAK7B,SAAO,KAAK,OAAO,MAAM;AACzB,SAAO,GAAG,UAAU,MAAM;AAC1B,SAAO,GAAG,UAAU,MAAM;AAC1B,SAAO,KAAK,UAAU,QAAe;AACnC,gBAAa,MAAM;AACnB,YAAS;AACT,SAAM;AACN,UAAO,IAAI;IACX;AACF,SAAO,KAAK,SAAS,SAAwB;AAC3C,OAAI,QAAS;AACb,gBAAa,MAAM;AACnB,YAAS;AACT,0BACE,IAAI,MACF,+CAA+C,QAAQ,OAAO,kCAC/D,CACF;IACD;GACF"}
@@ -32,9 +32,16 @@ const LAUNCHER_URL = "https://devtools.aitc.dev/launcher/";
32
32
  * Plain-text raw URL is no longer enough — the launcher gates its setup UI to
33
33
  * the installed PWA, so a raw tunnel URL opened in a normal browser tab would
34
34
  * land on a "please install" screen.
35
+ *
36
+ * When `relayWssUrl` is given (env-2 CDP wiring), the deep-link also carries
37
+ * `&debug=1&relay=<wss>`; the launcher folds those onto the framed tunnel URL so
38
+ * the in-app debug gate's Layer C (`debug=1` opt-in + `relay=<wss>`) is met and
39
+ * a Chii target.js is injected into the live view.
35
40
  */
36
- function buildLauncherDeepLink(tunnelUrl) {
37
- return `${LAUNCHER_URL}?url=${encodeURIComponent(tunnelUrl)}`;
41
+ function buildLauncherDeepLink(tunnelUrl, relayWssUrl) {
42
+ const base = `${LAUNCHER_URL}?url=${encodeURIComponent(tunnelUrl)}`;
43
+ if (!relayWssUrl) return base;
44
+ return `${base}&debug=1&relay=${encodeURIComponent(relayWssUrl)}`;
38
45
  }
39
46
  /**
40
47
  * Print the terminal banner announcing the live tunnel: the public URL, an ASCII
@@ -44,7 +51,7 @@ function buildLauncherDeepLink(tunnelUrl) {
44
51
  */
45
52
  async function printTunnelBanner(url, opts = {}) {
46
53
  const log = opts.log ?? ((m) => console.log(m));
47
- const deepLink = buildLauncherDeepLink(url);
54
+ const deepLink = buildLauncherDeepLink(url, opts.relayWssUrl);
48
55
  log([
49
56
  "",
50
57
  " ┌─ @ait-co/devtools · live tunnel ────────────────────────────",
@@ -53,6 +60,7 @@ async function printTunnelBanner(url, opts = {}) {
53
60
  ` │ Install the launcher PWA once: ${LAUNCHER_URL}`,
54
61
  " │ Then scan the QR below — it opens the launcher directly",
55
62
  " │ into this tunnel URL (no manual paste needed).",
63
+ ...opts.relayWssUrl ? [" │ The same scan also attaches CDP — connect your AI host", " │ to the relay and debug the live view on-device."] : [],
56
64
  " │ Quick tunnels are unauthenticated, change every run, and are",
57
65
  " │ not for production use.",
58
66
  " └──────────────────────────────────────────────────────────────",
@@ -131,4 +139,4 @@ async function startQuickTunnel(port) {
131
139
  exports.printTunnelBanner = printTunnelBanner;
132
140
  exports.startQuickTunnel = startQuickTunnel;
133
141
 
134
- //# sourceMappingURL=tunnel-BYP0yRBN.cjs.map
142
+ //# sourceMappingURL=tunnel-p-q6eVWT.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tunnel-p-q6eVWT.cjs","names":[],"sources":["../src/unplugin/tunnel.ts"],"sourcesContent":["/**\n * Cloudflare quick-tunnel helper for the devtools unplugin.\n *\n * Loaded lazily (`await import('./tunnel.js')`) only when the `tunnel` option is\n * on, so `cloudflared` / `qrcode-terminal` are never pulled in for the common\n * case. This is the one place in `@ait-co/devtools` that depends on Node-only\n * APIs (`child_process` via the `cloudflared` wrapper) — keep it thin and out of\n * jsdom unit tests; the spawn path is verified by hand / e2e (same spirit as the\n * \"web 모드는 e2e\" rule in CLAUDE.md). The pure helpers below\n * (`parseTrycloudflareUrl`, `printTunnelBanner`) are unit-tested.\n */\n\nimport { existsSync } from 'node:fs';\nimport { mkdir } from 'node:fs/promises';\nimport { dirname } from 'node:path';\n\n/** Matches the public URL cloudflared prints for an unauthenticated quick tunnel. */\nconst TRYCLOUDFLARE_RE = /https:\\/\\/[a-z0-9-]+\\.trycloudflare\\.com/i;\n\n/**\n * Extract the `https://<sub>.trycloudflare.com` URL from a line of cloudflared\n * output, or `null` if the line doesn't contain one. Pulled out as a pure\n * function so it can be unit-tested without spawning anything.\n */\nexport function parseTrycloudflareUrl(line: string): string | null {\n const m = line.match(TRYCLOUDFLARE_RE);\n return m ? m[0] : null;\n}\n\nexport interface PrintTunnelBannerOptions {\n /** Print an ASCII QR encoding the tunnel URL (default: true). */\n qr?: boolean;\n /** Sink for the banner text (default: `console.log`). Injected for testing. */\n log?: (msg: string) => void;\n /**\n * The `wss://` relay URL of the env-2 CDP tunnel, if `tunnel.cdp` is on. When\n * present the QR deep-link additionally carries `&debug=1&relay=<wss>` so the\n * framed PWA passes the in-app debug gate and attaches a Chii target — the\n * same single scan opens screen preview *and* CDP debugging.\n */\n relayWssUrl?: string;\n}\n\nconst LAUNCHER_URL = 'https://devtools.aitc.dev/launcher/';\n\n/**\n * Build the deep-link URL that QR codes encode: when the launcher PWA is\n * already on the phone's home screen, scanning this opens it directly into the\n * live view for `tunnelUrl` (the launcher consumes `?url=` and clears it).\n * Plain-text raw URL is no longer enough — the launcher gates its setup UI to\n * the installed PWA, so a raw tunnel URL opened in a normal browser tab would\n * land on a \"please install\" screen.\n *\n * When `relayWssUrl` is given (env-2 CDP wiring), the deep-link also carries\n * `&debug=1&relay=<wss>`; the launcher folds those onto the framed tunnel URL so\n * the in-app debug gate's Layer C (`debug=1` opt-in + `relay=<wss>`) is met and\n * a Chii target.js is injected into the live view.\n */\nexport function buildLauncherDeepLink(tunnelUrl: string, relayWssUrl?: string): string {\n const base = `${LAUNCHER_URL}?url=${encodeURIComponent(tunnelUrl)}`;\n if (!relayWssUrl) return base;\n return `${base}&debug=1&relay=${encodeURIComponent(relayWssUrl)}`;\n}\n\n/**\n * Print the terminal banner announcing the live tunnel: the public URL, an ASCII\n * QR encoding a launcher deep-link, and a one-line note that quick tunnels are\n * ephemeral, unauthenticated and not for production. Pure w.r.t. side effects\n * other than the injected `log` sink and `qrcode-terminal` — unit-tested.\n */\nexport async function printTunnelBanner(\n url: string,\n opts: PrintTunnelBannerOptions = {},\n): Promise<void> {\n const log = opts.log ?? ((m: string) => console.log(m));\n const deepLink = buildLauncherDeepLink(url, opts.relayWssUrl);\n const lines: string[] = [\n '',\n ' ┌─ @ait-co/devtools · live tunnel ────────────────────────────',\n ` │ ${url}`,\n ' │',\n ` │ Install the launcher PWA once: ${LAUNCHER_URL}`,\n ' │ Then scan the QR below — it opens the launcher directly',\n ' │ into this tunnel URL (no manual paste needed).',\n ...(opts.relayWssUrl\n ? [\n ' │ The same scan also attaches CDP — connect your AI host',\n ' │ to the relay and debug the live view on-device.',\n ]\n : []),\n ' │ Quick tunnels are unauthenticated, change every run, and are',\n ' │ not for production use.',\n ' └──────────────────────────────────────────────────────────────',\n '',\n ];\n log(lines.join('\\n'));\n\n if (opts.qr !== false) {\n // qrcode-terminal is only pulled in on this code path (ambient types live\n // in src/qrcode-terminal.d.ts).\n const qrcode = (await import('qrcode-terminal')).default;\n await new Promise<void>((resolve) => {\n qrcode.generate(deepLink, { small: true }, (out) => {\n log(out);\n resolve();\n });\n });\n }\n}\n\nexport interface QuickTunnel {\n /** The public `https://*.trycloudflare.com` URL. */\n url: string;\n /** Stop the underlying `cloudflared` process. Idempotent. */\n stop: () => void;\n}\n\nconst URL_TIMEOUT_MS = 20_000;\n\n/**\n * Start an unauthenticated Cloudflare quick tunnel to `http://localhost:<port>`\n * and resolve once the public URL is known. Downloads the `cloudflared` binary\n * on first use if it is not already installed. Rejects with a friendly error if\n * no URL appears within {@link URL_TIMEOUT_MS}.\n */\nexport async function startQuickTunnel(port: number): Promise<QuickTunnel> {\n const cloudflared = await import('cloudflared');\n const { bin, install, Tunnel } = cloudflared;\n\n if (!existsSync(bin)) {\n await mkdir(dirname(bin), { recursive: true });\n await install(bin);\n }\n\n const tunnel = Tunnel.quick(`http://localhost:${port}`);\n let stopped = false;\n const stop = () => {\n if (stopped) return;\n stopped = true;\n try {\n tunnel.stop();\n } catch {\n // process may already be gone\n }\n };\n\n return new Promise<QuickTunnel>((resolve, reject) => {\n const timer = setTimeout(() => {\n cleanup();\n stop();\n reject(\n new Error(\n `[@ait-co/devtools] cloudflared did not report a tunnel URL within ${\n URL_TIMEOUT_MS / 1000\n }s. Check your network connection, or run \\`cloudflared tunnel --url http://localhost:${port}\\` manually.`,\n ),\n );\n }, URL_TIMEOUT_MS);\n\n const onUrl = (line: string) => {\n const found = parseTrycloudflareUrl(line);\n if (!found) return;\n clearTimeout(timer);\n // Stop scanning further output once we have the URL.\n cleanup();\n resolve({ url: found, stop });\n };\n\n const cleanup = () => {\n tunnel.off('stdout', onUrl);\n tunnel.off('stderr', onUrl);\n };\n\n // The library emits a parsed `url` event; we also scan raw stdout/stderr in\n // case the output format shifts.\n tunnel.once('url', onUrl);\n tunnel.on('stdout', onUrl);\n tunnel.on('stderr', onUrl);\n tunnel.once('error', (err: Error) => {\n clearTimeout(timer);\n cleanup();\n stop();\n reject(err);\n });\n tunnel.once('exit', (code: number | null) => {\n if (stopped) return;\n clearTimeout(timer);\n cleanup();\n reject(\n new Error(\n `[@ait-co/devtools] cloudflared exited (code ${code ?? 'null'}) before reporting a tunnel URL.`,\n ),\n );\n });\n });\n}\n"],"mappings":";;;;;;;;;;;;;;;;AAiBA,MAAM,mBAAmB;;;;;;AAOzB,SAAgB,sBAAsB,MAA6B;CACjE,MAAM,IAAI,KAAK,MAAM,iBAAiB;AACtC,QAAO,IAAI,EAAE,KAAK;;AAiBpB,MAAM,eAAe;;;;;;;;;;;;;;AAerB,SAAgB,sBAAsB,WAAmB,aAA8B;CACrF,MAAM,OAAO,GAAG,aAAa,OAAO,mBAAmB,UAAU;AACjE,KAAI,CAAC,YAAa,QAAO;AACzB,QAAO,GAAG,KAAK,iBAAiB,mBAAmB,YAAY;;;;;;;;AASjE,eAAsB,kBACpB,KACA,OAAiC,EAAE,EACpB;CACf,MAAM,MAAM,KAAK,SAAS,MAAc,QAAQ,IAAI,EAAE;CACtD,MAAM,WAAW,sBAAsB,KAAK,KAAK,YAAY;AAoB7D,KAnBwB;EACtB;EACA;EACA,QAAQ;EACR;EACA,wCAAwC;EACxC;EACA;EACA,GAAI,KAAK,cACL,CACE,+DACA,uDACD,GACD,EAAE;EACN;EACA;EACA;EACA;EACD,CACS,KAAK,KAAK,CAAC;AAErB,KAAI,KAAK,OAAO,OAAO;EAGrB,MAAM,UAAU,MAAM,OAAO,oBAAoB;AACjD,QAAM,IAAI,SAAe,YAAY;AACnC,UAAO,SAAS,UAAU,EAAE,OAAO,MAAM,GAAG,QAAQ;AAClD,QAAI,IAAI;AACR,aAAS;KACT;IACF;;;AAWN,MAAM,iBAAiB;;;;;;;AAQvB,eAAsB,iBAAiB,MAAoC;CAEzE,MAAM,EAAE,KAAK,SAAS,WADF,MAAM,OAAO;AAGjC,KAAI,EAAA,GAAA,QAAA,YAAY,IAAI,EAAE;AACpB,SAAA,GAAA,iBAAA,QAAA,GAAA,UAAA,SAAoB,IAAI,EAAE,EAAE,WAAW,MAAM,CAAC;AAC9C,QAAM,QAAQ,IAAI;;CAGpB,MAAM,SAAS,OAAO,MAAM,oBAAoB,OAAO;CACvD,IAAI,UAAU;CACd,MAAM,aAAa;AACjB,MAAI,QAAS;AACb,YAAU;AACV,MAAI;AACF,UAAO,MAAM;UACP;;AAKV,QAAO,IAAI,SAAsB,SAAS,WAAW;EACnD,MAAM,QAAQ,iBAAiB;AAC7B,YAAS;AACT,SAAM;AACN,0BACE,IAAI,MACF,qEACE,iBAAiB,IAClB,uFAAuF,KAAK,cAC9F,CACF;KACA,eAAe;EAElB,MAAM,SAAS,SAAiB;GAC9B,MAAM,QAAQ,sBAAsB,KAAK;AACzC,OAAI,CAAC,MAAO;AACZ,gBAAa,MAAM;AAEnB,YAAS;AACT,WAAQ;IAAE,KAAK;IAAO;IAAM,CAAC;;EAG/B,MAAM,gBAAgB;AACpB,UAAO,IAAI,UAAU,MAAM;AAC3B,UAAO,IAAI,UAAU,MAAM;;AAK7B,SAAO,KAAK,OAAO,MAAM;AACzB,SAAO,GAAG,UAAU,MAAM;AAC1B,SAAO,GAAG,UAAU,MAAM;AAC1B,SAAO,KAAK,UAAU,QAAe;AACnC,gBAAa,MAAM;AACnB,YAAS;AACT,SAAM;AACN,UAAO,IAAI;IACX;AACF,SAAO,KAAK,SAAS,SAAwB;AAC3C,OAAI,QAAS;AACb,gBAAa,MAAM;AACnB,YAAS;AACT,0BACE,IAAI,MACF,+CAA+C,QAAQ,OAAO,kCAC/D,CACF;IACD;GACF"}
@@ -125,6 +125,8 @@ const aitDevtoolsPlugin = (0, unplugin.createUnplugin)((options) => {
125
125
  });
126
126
  if (shouldTunnel) {
127
127
  let tunnel = null;
128
+ let relayTunnel = null;
129
+ let relay = null;
128
130
  const httpServer = server.httpServer;
129
131
  httpServer?.once("listening", () => {
130
132
  const address = httpServer?.address();
@@ -133,15 +135,39 @@ const aitDevtoolsPlugin = (0, unplugin.createUnplugin)((options) => {
133
135
  console.warn("[@ait-co/devtools] tunnel: could not determine the dev server port; skipping.");
134
136
  return;
135
137
  }
136
- Promise.resolve().then(() => require("../tunnel-BYP0yRBN.cjs")).then(async ({ startQuickTunnel, printTunnelBanner }) => {
138
+ Promise.resolve().then(() => require("../tunnel-p-q6eVWT.cjs")).then(async ({ startQuickTunnel, printTunnelBanner }) => {
137
139
  const t = await startQuickTunnel(port);
138
140
  tunnel = t;
139
- await printTunnelBanner(t.url, { qr: tunnelConfig.qr });
141
+ let relayWssUrl;
142
+ if (tunnelConfig.cdp) try {
143
+ const { assertRelayAuthConfigured, buildRelayVerifyAuth } = await Promise.resolve().then(() => require("../totp-BkKP4m8H.cjs"));
144
+ assertRelayAuthConfigured();
145
+ const verifyAuth = buildRelayVerifyAuth();
146
+ const { startChiiRelay } = await Promise.resolve().then(() => require("../chii-relay-57BfqF_5.cjs"));
147
+ const r = await startChiiRelay({
148
+ port: 0,
149
+ verifyAuth
150
+ });
151
+ relay = r;
152
+ const rt = await startQuickTunnel(r.port);
153
+ relayTunnel = rt;
154
+ relayWssUrl = rt.url.replace(/^https:/, "wss:");
155
+ } catch (err) {
156
+ console.warn(`[@ait-co/devtools] tunnel: CDP relay not started — screen preview works without on-device debugging: ${err instanceof Error ? err.message : String(err)}`);
157
+ }
158
+ await printTunnelBanner(t.url, {
159
+ qr: tunnelConfig.qr,
160
+ relayWssUrl
161
+ });
140
162
  }).catch((err) => {
141
163
  console.warn(`[@ait-co/devtools] tunnel failed to start: ${err instanceof Error ? err.message : String(err)}`);
142
164
  });
143
165
  });
144
- const cleanup = () => tunnel?.stop();
166
+ const cleanup = () => {
167
+ tunnel?.stop();
168
+ relayTunnel?.stop();
169
+ relay?.close();
170
+ };
145
171
  httpServer?.once("close", cleanup);
146
172
  process.once("SIGINT", cleanup);
147
173
  process.once("SIGTERM", cleanup);