@ait-co/devtools 0.1.58 → 0.1.59
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/deeplink-BONXxWEO.cjs +44 -0
- package/dist/deeplink-BONXxWEO.cjs.map +1 -0
- package/dist/deeplink-CCGiyoHq.cjs +44 -0
- package/dist/deeplink-CCGiyoHq.cjs.map +1 -0
- package/dist/deeplink-CaO6hZVG.js +44 -0
- package/dist/deeplink-CaO6hZVG.js.map +1 -0
- package/dist/deeplink-Cqli4qzm.js +44 -0
- package/dist/deeplink-Cqli4qzm.js.map +1 -0
- package/dist/devtools-opener-BbUXBzgA.js +65 -0
- package/dist/devtools-opener-BbUXBzgA.js.map +1 -0
- package/dist/devtools-opener-Bp671YXu.cjs +62 -0
- package/dist/devtools-opener-Bp671YXu.cjs.map +1 -0
- package/dist/devtools-opener-D84kZFtR.js +65 -0
- package/dist/devtools-opener-D84kZFtR.js.map +1 -0
- package/dist/devtools-opener-h6A-UjzC.cjs +62 -0
- package/dist/devtools-opener-h6A-UjzC.cjs.map +1 -0
- package/dist/mcp/cli.js +860 -403
- package/dist/mcp/cli.js.map +1 -1
- package/dist/mcp/server.js +1 -1
- package/dist/panel/index.d.ts +15 -9
- package/dist/panel/index.d.ts.map +1 -1
- package/dist/panel/index.js +27707 -1965
- package/dist/panel/index.js.map +1 -1
- package/dist/qr-http-server-Bk-9AO9Y.js +903 -0
- package/dist/qr-http-server-Bk-9AO9Y.js.map +1 -0
- package/dist/qr-http-server-C536UmTm.js +903 -0
- package/dist/qr-http-server-C536UmTm.js.map +1 -0
- package/dist/qr-http-server-CQZnEAcl.cjs +903 -0
- package/dist/qr-http-server-CQZnEAcl.cjs.map +1 -0
- package/dist/qr-http-server-GwRt-B9_.cjs +903 -0
- package/dist/qr-http-server-GwRt-B9_.cjs.map +1 -0
- package/dist/relay-secret-store-5A7_7zOp.js +111 -0
- package/dist/relay-secret-store-5A7_7zOp.js.map +1 -0
- package/dist/{relay-secret-store-DqyUoeXy.js → relay-secret-store-C4QQN5NA.js} +3 -3
- package/dist/{relay-secret-store-DqyUoeXy.js.map → relay-secret-store-C4QQN5NA.js.map} +1 -1
- package/dist/{relay-secret-store-DnTNl-9z.cjs → relay-secret-store-CLkF8Pa0.cjs} +3 -2
- package/dist/{relay-secret-store-DnTNl-9z.cjs.map → relay-secret-store-CLkF8Pa0.cjs.map} +1 -1
- package/dist/relay-url-store-COG2dSql.cjs +113 -0
- package/dist/relay-url-store-COG2dSql.cjs.map +1 -0
- package/dist/relay-url-store-WKfo0VQV.js +112 -0
- package/dist/relay-url-store-WKfo0VQV.js.map +1 -0
- package/dist/relay-url-store-qaoe0zOD.js +118 -0
- package/dist/relay-url-store-qaoe0zOD.js.map +1 -0
- package/dist/totp-86i_CNqh.js +3 -0
- package/dist/{totp-D0a8VwoR.js → totp-BIrJHsQn.js} +1 -1
- package/dist/{totp-D0a8VwoR.js.map → totp-BIrJHsQn.js.map} +1 -1
- package/dist/{totp-BkP5yU2K.js → totp-BjtKFt88.js} +2 -2
- package/dist/{totp-BkP5yU2K.js.map → totp-BjtKFt88.js.map} +1 -1
- package/dist/totp-BxtxuEt4.js +64 -0
- package/dist/totp-BxtxuEt4.js.map +1 -0
- package/dist/totp-D9rndqg_.cjs +64 -0
- package/dist/totp-D9rndqg_.cjs.map +1 -0
- package/dist/{totp-DLgGbySX.cjs → totp-DA8vjAi7.cjs} +2 -1
- package/dist/{totp-DLgGbySX.cjs.map → totp-DA8vjAi7.cjs.map} +1 -1
- package/dist/{tunnel-nKYPtc-g.cjs → tunnel-CAaBFOro.cjs} +114 -3
- package/dist/tunnel-CAaBFOro.cjs.map +1 -0
- package/dist/{tunnel-CI61NvPI.js → tunnel-COMs-wZU.js} +114 -4
- package/dist/tunnel-COMs-wZU.js.map +1 -0
- package/dist/unplugin/index.cjs +116 -4
- package/dist/unplugin/index.cjs.map +1 -1
- package/dist/unplugin/index.d.cts +20 -1
- package/dist/unplugin/index.d.cts.map +1 -1
- package/dist/unplugin/index.d.ts +20 -1
- package/dist/unplugin/index.d.ts.map +1 -1
- package/dist/unplugin/index.js +116 -5
- package/dist/unplugin/index.js.map +1 -1
- package/dist/unplugin/tunnel.cjs +114 -2
- package/dist/unplugin/tunnel.cjs.map +1 -1
- package/dist/unplugin/tunnel.d.cts +62 -1
- package/dist/unplugin/tunnel.d.cts.map +1 -1
- package/dist/unplugin/tunnel.d.ts +62 -1
- package/dist/unplugin/tunnel.d.ts.map +1 -1
- package/dist/unplugin/tunnel.js +113 -3
- package/dist/unplugin/tunnel.js.map +1 -1
- package/package.json +11 -3
- package/dist/totp-CQFmgOhM.js +0 -3
- package/dist/tunnel-CI61NvPI.js.map +0 -1
- package/dist/tunnel-nKYPtc-g.cjs.map +0 -1
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
const require_relay_secret_store = require("./relay-secret-store-CLkF8Pa0.cjs");
|
|
2
|
+
let node_path = require("node:path");
|
|
3
|
+
//#region src/mcp/relay-url-store.ts
|
|
4
|
+
/**
|
|
5
|
+
* Project-local ephemeral URL store (#424).
|
|
6
|
+
*
|
|
7
|
+
* Environment-2 ("AITC Sandbox PWA") cold-start requires two ephemeral URLs:
|
|
8
|
+
* - `relayBaseUrl` — the CDP relay's https base (was `AIT_RELAY_BASE_URL`)
|
|
9
|
+
* - `tunnelBaseUrl` — the app's https tunnel base (was `AIT_TUNNEL_BASE_URL`)
|
|
10
|
+
*
|
|
11
|
+
* Quick-tunnel URLs change every run. Previously the user had to copy-paste them
|
|
12
|
+
* into env vars on every cold-start — a single typo silently broke attach. This
|
|
13
|
+
* module replaces that manual hand-off with a file-based discovery pattern that
|
|
14
|
+
* exactly mirrors the `.ait_relay` TOTP-secret store (relay-secret-store.ts).
|
|
15
|
+
*
|
|
16
|
+
* Two surfaces, intentionally split by who is allowed to write:
|
|
17
|
+
*
|
|
18
|
+
* - {@link writeRelayUrls} — WRITE path, called ONLY from the unplugin
|
|
19
|
+
* (env-2 tunnel boot). Writes JSON to `<projectRoot>/.ait_urls` (0600) on
|
|
20
|
+
* every boot (`flag: 'w'` — overwrite). A single file — no directory is
|
|
21
|
+
* created.
|
|
22
|
+
*
|
|
23
|
+
* - {@link readRelayUrls} — READ-ONLY path, called from the MCP daemon as a
|
|
24
|
+
* fallback when `AIT_RELAY_BASE_URL`/`AIT_TUNNEL_BASE_URL` are not set. It
|
|
25
|
+
* NEVER writes, chmods, or creates anything: it only reads an existing
|
|
26
|
+
* `.ait_urls`. On any failure (missing file / bad JSON / wrong shape) it
|
|
27
|
+
* returns `null` silently, letting the downstream assertion be the single
|
|
28
|
+
* fail-fast.
|
|
29
|
+
*
|
|
30
|
+
* - {@link deleteRelayUrls} — called from the unplugin `cleanup()` on
|
|
31
|
+
* teardown (via `void deleteRelayUrls(...)`). A stale `.ait_urls` pointing
|
|
32
|
+
* at a dead tunnel would cause the MCP daemon to attempt a doomed attach on
|
|
33
|
+
* the next cold-start — deletion is non-negotiable. Silently swallows all
|
|
34
|
+
* errors.
|
|
35
|
+
*
|
|
36
|
+
* Design note: the env vars (`AIT_RELAY_BASE_URL`, `AIT_TUNNEL_BASE_URL`) are
|
|
37
|
+
* PRESERVED as operator overrides. The file is the fallback — env wins.
|
|
38
|
+
*
|
|
39
|
+
* Why `nearestPackageJsonDir` instead of `process.cwd()`: see relay-secret-store.ts.
|
|
40
|
+
* The unplugin (writer) and the MCP daemon (reader) both anchor to the nearest
|
|
41
|
+
* package.json from their respective `projectRoot` inputs, ensuring both sides
|
|
42
|
+
* find the same file.
|
|
43
|
+
*
|
|
44
|
+
* SECRET-HANDLING: `relayBaseUrl` and `tunnelBaseUrl` carry the relay/tunnel host
|
|
45
|
+
* — same sensitivity class as `.ait_relay`. The raw URL values, partial values,
|
|
46
|
+
* and the resolved file path MUST NOT appear in any log, error message, stdout,
|
|
47
|
+
* stderr, or assertion output anywhere in this module or at its call sites.
|
|
48
|
+
* Only boolean pass/fail signals are safe to surface. The file is written mode
|
|
49
|
+
* 0600.
|
|
50
|
+
*/
|
|
51
|
+
/** Project-local ephemeral URL file name (single file, not a directory). */
|
|
52
|
+
const URLS_FILE_NAME = ".ait_urls";
|
|
53
|
+
/**
|
|
54
|
+
* Absolute path to the project-local `.ait_urls` file for a given start
|
|
55
|
+
* directory (resolved against the nearest package.json directory).
|
|
56
|
+
*
|
|
57
|
+
* Exported so tests can compute the expected path without duplicating the
|
|
58
|
+
* resolution logic.
|
|
59
|
+
*/
|
|
60
|
+
function urlsFilePath(start, existsSyncFn) {
|
|
61
|
+
return (0, node_path.join)(require_relay_secret_store.nearestPackageJsonDir(start, existsSyncFn), URLS_FILE_NAME);
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Writes `{ relayBaseUrl, tunnelBaseUrl }` (omitting absent keys) to
|
|
65
|
+
* `<projectRoot>/.ait_urls` (mode 0600). Uses `flag: 'w'` (overwrite) because
|
|
66
|
+
* URLs are ephemeral — a fresh URL replaces the previous one on every boot.
|
|
67
|
+
*
|
|
68
|
+
* Unlike the `.ait_relay` secret store this does NOT use `O_EXCL` (`'wx'`):
|
|
69
|
+
* there is no race concern here (only the unplugin writes this file) and the
|
|
70
|
+
* URL must be fresh on every cold-start.
|
|
71
|
+
*
|
|
72
|
+
* Called ONLY from the unplugin (env-2 tunnel boot). The MCP daemon uses
|
|
73
|
+
* {@link readRelayUrls} (read-only) — it must never write.
|
|
74
|
+
*
|
|
75
|
+
* SECRET-HANDLING: URL values are never logged.
|
|
76
|
+
*/
|
|
77
|
+
async function writeRelayUrls(deps) {
|
|
78
|
+
const { projectRoot, relayBaseUrl, tunnelBaseUrl, fs: fsDep, existsSync: existsSyncDep } = deps;
|
|
79
|
+
const fs = fsDep ?? await import("node:fs");
|
|
80
|
+
const filePath = urlsFilePath(projectRoot, existsSyncDep ?? fs.existsSync);
|
|
81
|
+
const payload = {};
|
|
82
|
+
if (typeof relayBaseUrl === "string" && relayBaseUrl !== "") payload.relayBaseUrl = relayBaseUrl;
|
|
83
|
+
if (typeof tunnelBaseUrl === "string" && tunnelBaseUrl !== "") payload.tunnelBaseUrl = tunnelBaseUrl;
|
|
84
|
+
const data = JSON.stringify(payload);
|
|
85
|
+
fs.writeFileSync(filePath, data, {
|
|
86
|
+
mode: 384,
|
|
87
|
+
flag: "w"
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Removes `<projectRoot>/.ait_urls` if present. Silently swallows ENOENT and
|
|
92
|
+
* any other error so cleanup always succeeds.
|
|
93
|
+
*
|
|
94
|
+
* Called ONLY from the unplugin's `cleanup()` on `httpServer 'close'` + signals
|
|
95
|
+
* (via `void deleteRelayUrls(...)`). A stale `.ait_urls` pointing at a dead
|
|
96
|
+
* tunnel would cause the MCP daemon to attempt a doomed attach on the next
|
|
97
|
+
* cold-start — deletion is non-negotiable.
|
|
98
|
+
*
|
|
99
|
+
* SECRET-HANDLING: the file path is never logged.
|
|
100
|
+
*/
|
|
101
|
+
async function deleteRelayUrls(deps) {
|
|
102
|
+
const { projectRoot, fs: fsDep, existsSync: existsSyncDep } = deps;
|
|
103
|
+
const fs = fsDep ?? await import("node:fs");
|
|
104
|
+
const filePath = urlsFilePath(projectRoot, existsSyncDep ?? fs.existsSync);
|
|
105
|
+
try {
|
|
106
|
+
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
|
|
107
|
+
} catch {}
|
|
108
|
+
}
|
|
109
|
+
//#endregion
|
|
110
|
+
exports.deleteRelayUrls = deleteRelayUrls;
|
|
111
|
+
exports.writeRelayUrls = writeRelayUrls;
|
|
112
|
+
|
|
113
|
+
//# sourceMappingURL=relay-url-store-COG2dSql.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"relay-url-store-COG2dSql.cjs","names":["nearestPackageJsonDir"],"sources":["../src/mcp/relay-url-store.ts"],"sourcesContent":["/**\n * Project-local ephemeral URL store (#424).\n *\n * Environment-2 (\"AITC Sandbox PWA\") cold-start requires two ephemeral URLs:\n * - `relayBaseUrl` — the CDP relay's https base (was `AIT_RELAY_BASE_URL`)\n * - `tunnelBaseUrl` — the app's https tunnel base (was `AIT_TUNNEL_BASE_URL`)\n *\n * Quick-tunnel URLs change every run. Previously the user had to copy-paste them\n * into env vars on every cold-start — a single typo silently broke attach. This\n * module replaces that manual hand-off with a file-based discovery pattern that\n * exactly mirrors the `.ait_relay` TOTP-secret store (relay-secret-store.ts).\n *\n * Two surfaces, intentionally split by who is allowed to write:\n *\n * - {@link writeRelayUrls} — WRITE path, called ONLY from the unplugin\n * (env-2 tunnel boot). Writes JSON to `<projectRoot>/.ait_urls` (0600) on\n * every boot (`flag: 'w'` — overwrite). A single file — no directory is\n * created.\n *\n * - {@link readRelayUrls} — READ-ONLY path, called from the MCP daemon as a\n * fallback when `AIT_RELAY_BASE_URL`/`AIT_TUNNEL_BASE_URL` are not set. It\n * NEVER writes, chmods, or creates anything: it only reads an existing\n * `.ait_urls`. On any failure (missing file / bad JSON / wrong shape) it\n * returns `null` silently, letting the downstream assertion be the single\n * fail-fast.\n *\n * - {@link deleteRelayUrls} — called from the unplugin `cleanup()` on\n * teardown (via `void deleteRelayUrls(...)`). A stale `.ait_urls` pointing\n * at a dead tunnel would cause the MCP daemon to attempt a doomed attach on\n * the next cold-start — deletion is non-negotiable. Silently swallows all\n * errors.\n *\n * Design note: the env vars (`AIT_RELAY_BASE_URL`, `AIT_TUNNEL_BASE_URL`) are\n * PRESERVED as operator overrides. The file is the fallback — env wins.\n *\n * Why `nearestPackageJsonDir` instead of `process.cwd()`: see relay-secret-store.ts.\n * The unplugin (writer) and the MCP daemon (reader) both anchor to the nearest\n * package.json from their respective `projectRoot` inputs, ensuring both sides\n * find the same file.\n *\n * SECRET-HANDLING: `relayBaseUrl` and `tunnelBaseUrl` carry the relay/tunnel host\n * — same sensitivity class as `.ait_relay`. The raw URL values, partial values,\n * and the resolved file path MUST NOT appear in any log, error message, stdout,\n * stderr, or assertion output anywhere in this module or at its call sites.\n * Only boolean pass/fail signals are safe to surface. The file is written mode\n * 0600.\n */\n\nimport { join } from 'node:path';\nimport { nearestPackageJsonDir } from './relay-secret-store.js';\n\n// ---------------------------------------------------------------------------\n// Constants\n// ---------------------------------------------------------------------------\n\n/** Project-local ephemeral URL file name (single file, not a directory). */\nexport const URLS_FILE_NAME = '.ait_urls';\n\n// ---------------------------------------------------------------------------\n// Dependency injection surfaces\n// ---------------------------------------------------------------------------\n\n/** Minimal fs subset needed by {@link writeRelayUrls} — injectable for tests. */\nexport interface RelayUrlWriteFs {\n writeFileSync(path: string, data: string, options: { mode: number; flag: string }): void;\n existsSync(path: string): boolean;\n}\n\n/** Minimal fs subset needed by {@link readRelayUrls} — injectable for tests. */\nexport interface RelayUrlReadFs {\n existsSync(path: string): boolean;\n readFileSync(path: string, encoding: BufferEncoding): string;\n}\n\n/** Minimal fs subset needed by {@link deleteRelayUrls} — injectable for tests. */\nexport interface RelayUrlDeleteFs {\n existsSync(path: string): boolean;\n unlinkSync(path: string): void;\n}\n\n// ---------------------------------------------------------------------------\n// Path helper\n// ---------------------------------------------------------------------------\n\n/**\n * Absolute path to the project-local `.ait_urls` file for a given start\n * directory (resolved against the nearest package.json directory).\n *\n * Exported so tests can compute the expected path without duplicating the\n * resolution logic.\n */\nexport function urlsFilePath(start: string, existsSyncFn: (path: string) => boolean): string {\n return join(nearestPackageJsonDir(start, existsSyncFn), URLS_FILE_NAME);\n}\n\n// ---------------------------------------------------------------------------\n// WRITE path (unplugin only) — overwrite on every boot\n// ---------------------------------------------------------------------------\n\nexport interface WriteRelayUrlsDeps {\n /** Project root (typically Vite `server.config.root`). */\n projectRoot: string;\n /**\n * The CDP relay's https base URL (same value as `AIT_RELAY_BASE_URL`).\n * Omit when the relay was not started (e.g. `cdp:false`).\n * SECRET-HANDLING: never log this value.\n */\n relayBaseUrl?: string;\n /**\n * The app tunnel's https base URL (same value as `AIT_TUNNEL_BASE_URL`).\n * Omit when no tunnel URL is available.\n * SECRET-HANDLING: never log this value.\n */\n tunnelBaseUrl?: string;\n /** Filesystem operations. Defaults to node:fs synchronous functions. */\n fs?: RelayUrlWriteFs;\n /** existsSync used to resolve the nearest package.json directory. Defaults to node:fs. */\n existsSync?: (path: string) => boolean;\n}\n\n/**\n * Writes `{ relayBaseUrl, tunnelBaseUrl }` (omitting absent keys) to\n * `<projectRoot>/.ait_urls` (mode 0600). Uses `flag: 'w'` (overwrite) because\n * URLs are ephemeral — a fresh URL replaces the previous one on every boot.\n *\n * Unlike the `.ait_relay` secret store this does NOT use `O_EXCL` (`'wx'`):\n * there is no race concern here (only the unplugin writes this file) and the\n * URL must be fresh on every cold-start.\n *\n * Called ONLY from the unplugin (env-2 tunnel boot). The MCP daemon uses\n * {@link readRelayUrls} (read-only) — it must never write.\n *\n * SECRET-HANDLING: URL values are never logged.\n */\nexport async function writeRelayUrls(deps: WriteRelayUrlsDeps): Promise<void> {\n const { projectRoot, relayBaseUrl, tunnelBaseUrl, fs: fsDep, existsSync: existsSyncDep } = deps;\n\n const fs: RelayUrlWriteFs = fsDep ?? (await import('node:fs'));\n const existsSyncFn: (path: string) => boolean = existsSyncDep ?? fs.existsSync;\n\n const filePath = urlsFilePath(projectRoot, existsSyncFn);\n\n // Build the payload — omit keys whose values are absent.\n const payload: { relayBaseUrl?: string; tunnelBaseUrl?: string } = {};\n if (typeof relayBaseUrl === 'string' && relayBaseUrl !== '') {\n payload.relayBaseUrl = relayBaseUrl;\n }\n if (typeof tunnelBaseUrl === 'string' && tunnelBaseUrl !== '') {\n payload.tunnelBaseUrl = tunnelBaseUrl;\n }\n\n // SECRET-HANDLING: JSON content (which includes URL values) is written to\n // the file only — never to any log, stdout, or stderr.\n const data = JSON.stringify(payload);\n\n // Overwrite on every boot (`flag: 'w'`) — URLs are ephemeral.\n fs.writeFileSync(filePath, data, { mode: 0o600, flag: 'w' });\n}\n\n// ---------------------------------------------------------------------------\n// READ-ONLY path (daemon only) — never writes, chmods, or creates anything\n// ---------------------------------------------------------------------------\n\nexport interface ReadRelayUrlsDeps {\n /** Project root supplied per-debug-session. When omitted, returns `null`. */\n projectRoot?: string;\n /** Read-only filesystem operations. Defaults to node:fs (existsSync + readFileSync). */\n fs?: RelayUrlReadFs;\n /** existsSync used to resolve the nearest package.json directory. Defaults to node:fs. */\n existsSync?: (path: string) => boolean;\n}\n\n/**\n * Reads `<projectRoot>/.ait_urls` and returns the stored URLs, or `null` on\n * any failure.\n *\n * Strictly READ-ONLY: only `existsSync` + `readFileSync`. Never writes,\n * chmods, or creates files/directories.\n *\n * Returns `null` (silently, no throw, no log) on:\n * - missing `projectRoot`\n * - missing `.ait_urls` file\n * - unreadable file (permissions, transient FS error)\n * - invalid JSON\n * - wrong shape (non-object, non-string values)\n *\n * Trims string values before returning. Ignores non-string fields.\n *\n * SECRET-HANDLING: URL values and the file path are never logged.\n */\nexport async function readRelayUrls(deps?: ReadRelayUrlsDeps): Promise<{\n relayBaseUrl?: string;\n tunnelBaseUrl?: string;\n} | null> {\n const { projectRoot, fs: fsDep, existsSync: existsSyncDep } = deps ?? {};\n\n if (projectRoot === undefined) {\n return null;\n }\n\n const fs: RelayUrlReadFs = fsDep ?? (await import('node:fs'));\n const existsSyncFn: (path: string) => boolean = existsSyncDep ?? fs.existsSync;\n\n const filePath = urlsFilePath(projectRoot, existsSyncFn);\n\n if (!fs.existsSync(filePath)) {\n return null;\n }\n\n let raw: string;\n try {\n raw = fs.readFileSync(filePath, 'utf8');\n } catch {\n // Unreadable file — silent no-op, let the downstream assert be the fail-fast.\n // SECRET-HANDLING: the error and path are not surfaced.\n return null;\n }\n\n let parsed: unknown;\n try {\n parsed = JSON.parse(raw);\n } catch {\n // Invalid JSON — silent no-op.\n return null;\n }\n\n if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {\n return null;\n }\n\n const obj = parsed as Record<string, unknown>;\n const result: { relayBaseUrl?: string; tunnelBaseUrl?: string } = {};\n\n const relay = obj.relayBaseUrl;\n if (typeof relay === 'string') {\n const trimmed = relay.trim();\n if (trimmed !== '') result.relayBaseUrl = trimmed;\n }\n\n const tunnel = obj.tunnelBaseUrl;\n if (typeof tunnel === 'string') {\n const trimmed = tunnel.trim();\n if (trimmed !== '') result.tunnelBaseUrl = trimmed;\n }\n\n return result;\n}\n\n// ---------------------------------------------------------------------------\n// DELETE path (unplugin cleanup only)\n// ---------------------------------------------------------------------------\n\nexport interface DeleteRelayUrlsDeps {\n /** Project root. */\n projectRoot: string;\n /** Filesystem operations. Defaults to node:fs (existsSync + unlinkSync). */\n fs?: RelayUrlDeleteFs;\n /** existsSync used to resolve the nearest package.json directory. */\n existsSync?: (path: string) => boolean;\n}\n\n/**\n * Removes `<projectRoot>/.ait_urls` if present. Silently swallows ENOENT and\n * any other error so cleanup always succeeds.\n *\n * Called ONLY from the unplugin's `cleanup()` on `httpServer 'close'` + signals\n * (via `void deleteRelayUrls(...)`). A stale `.ait_urls` pointing at a dead\n * tunnel would cause the MCP daemon to attempt a doomed attach on the next\n * cold-start — deletion is non-negotiable.\n *\n * SECRET-HANDLING: the file path is never logged.\n */\nexport async function deleteRelayUrls(deps: DeleteRelayUrlsDeps): Promise<void> {\n const { projectRoot, fs: fsDep, existsSync: existsSyncDep } = deps;\n\n const fs: RelayUrlDeleteFs = fsDep ?? (await import('node:fs'));\n const existsSyncFn: (path: string) => boolean = existsSyncDep ?? fs.existsSync;\n\n const filePath = urlsFilePath(projectRoot, existsSyncFn);\n\n try {\n if (fs.existsSync(filePath)) {\n fs.unlinkSync(filePath);\n }\n } catch {\n // Swallow ENOENT and any other error — cleanup is best-effort.\n // SECRET-HANDLING: the path is not logged.\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAwDA,MAAa,iBAAiB;;;;;;;;AAmC9B,SAAgB,aAAa,OAAe,cAAiD;AAC3F,SAAA,GAAA,UAAA,MAAYA,2BAAAA,sBAAsB,OAAO,aAAa,EAAE,eAAe;;;;;;;;;;;;;;;;AA0CzE,eAAsB,eAAe,MAAyC;CAC5E,MAAM,EAAE,aAAa,cAAc,eAAe,IAAI,OAAO,YAAY,kBAAkB;CAE3F,MAAM,KAAsB,SAAU,MAAM,OAAO;CAGnD,MAAM,WAAW,aAAa,aAFkB,iBAAiB,GAAG,WAEZ;CAGxD,MAAM,UAA6D,EAAE;AACrE,KAAI,OAAO,iBAAiB,YAAY,iBAAiB,GACvD,SAAQ,eAAe;AAEzB,KAAI,OAAO,kBAAkB,YAAY,kBAAkB,GACzD,SAAQ,gBAAgB;CAK1B,MAAM,OAAO,KAAK,UAAU,QAAQ;AAGpC,IAAG,cAAc,UAAU,MAAM;EAAE,MAAM;EAAO,MAAM;EAAK,CAAC;;;;;;;;;;;;;AAoH9D,eAAsB,gBAAgB,MAA0C;CAC9E,MAAM,EAAE,aAAa,IAAI,OAAO,YAAY,kBAAkB;CAE9D,MAAM,KAAuB,SAAU,MAAM,OAAO;CAGpD,MAAM,WAAW,aAAa,aAFkB,iBAAiB,GAAG,WAEZ;AAExD,KAAI;AACF,MAAI,GAAG,WAAW,SAAS,CACzB,IAAG,WAAW,SAAS;SAEnB"}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { nearestPackageJsonDir } from "./relay-secret-store-C4QQN5NA.js";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
//#region src/mcp/relay-url-store.ts
|
|
4
|
+
/**
|
|
5
|
+
* Project-local ephemeral URL store (#424).
|
|
6
|
+
*
|
|
7
|
+
* Environment-2 ("AITC Sandbox PWA") cold-start requires two ephemeral URLs:
|
|
8
|
+
* - `relayBaseUrl` — the CDP relay's https base (was `AIT_RELAY_BASE_URL`)
|
|
9
|
+
* - `tunnelBaseUrl` — the app's https tunnel base (was `AIT_TUNNEL_BASE_URL`)
|
|
10
|
+
*
|
|
11
|
+
* Quick-tunnel URLs change every run. Previously the user had to copy-paste them
|
|
12
|
+
* into env vars on every cold-start — a single typo silently broke attach. This
|
|
13
|
+
* module replaces that manual hand-off with a file-based discovery pattern that
|
|
14
|
+
* exactly mirrors the `.ait_relay` TOTP-secret store (relay-secret-store.ts).
|
|
15
|
+
*
|
|
16
|
+
* Two surfaces, intentionally split by who is allowed to write:
|
|
17
|
+
*
|
|
18
|
+
* - {@link writeRelayUrls} — WRITE path, called ONLY from the unplugin
|
|
19
|
+
* (env-2 tunnel boot). Writes JSON to `<projectRoot>/.ait_urls` (0600) on
|
|
20
|
+
* every boot (`flag: 'w'` — overwrite). A single file — no directory is
|
|
21
|
+
* created.
|
|
22
|
+
*
|
|
23
|
+
* - {@link readRelayUrls} — READ-ONLY path, called from the MCP daemon as a
|
|
24
|
+
* fallback when `AIT_RELAY_BASE_URL`/`AIT_TUNNEL_BASE_URL` are not set. It
|
|
25
|
+
* NEVER writes, chmods, or creates anything: it only reads an existing
|
|
26
|
+
* `.ait_urls`. On any failure (missing file / bad JSON / wrong shape) it
|
|
27
|
+
* returns `null` silently, letting the downstream assertion be the single
|
|
28
|
+
* fail-fast.
|
|
29
|
+
*
|
|
30
|
+
* - {@link deleteRelayUrls} — called from the unplugin `cleanup()` on
|
|
31
|
+
* teardown (via `void deleteRelayUrls(...)`). A stale `.ait_urls` pointing
|
|
32
|
+
* at a dead tunnel would cause the MCP daemon to attempt a doomed attach on
|
|
33
|
+
* the next cold-start — deletion is non-negotiable. Silently swallows all
|
|
34
|
+
* errors.
|
|
35
|
+
*
|
|
36
|
+
* Design note: the env vars (`AIT_RELAY_BASE_URL`, `AIT_TUNNEL_BASE_URL`) are
|
|
37
|
+
* PRESERVED as operator overrides. The file is the fallback — env wins.
|
|
38
|
+
*
|
|
39
|
+
* Why `nearestPackageJsonDir` instead of `process.cwd()`: see relay-secret-store.ts.
|
|
40
|
+
* The unplugin (writer) and the MCP daemon (reader) both anchor to the nearest
|
|
41
|
+
* package.json from their respective `projectRoot` inputs, ensuring both sides
|
|
42
|
+
* find the same file.
|
|
43
|
+
*
|
|
44
|
+
* SECRET-HANDLING: `relayBaseUrl` and `tunnelBaseUrl` carry the relay/tunnel host
|
|
45
|
+
* — same sensitivity class as `.ait_relay`. The raw URL values, partial values,
|
|
46
|
+
* and the resolved file path MUST NOT appear in any log, error message, stdout,
|
|
47
|
+
* stderr, or assertion output anywhere in this module or at its call sites.
|
|
48
|
+
* Only boolean pass/fail signals are safe to surface. The file is written mode
|
|
49
|
+
* 0600.
|
|
50
|
+
*/
|
|
51
|
+
/** Project-local ephemeral URL file name (single file, not a directory). */
|
|
52
|
+
const URLS_FILE_NAME = ".ait_urls";
|
|
53
|
+
/**
|
|
54
|
+
* Absolute path to the project-local `.ait_urls` file for a given start
|
|
55
|
+
* directory (resolved against the nearest package.json directory).
|
|
56
|
+
*
|
|
57
|
+
* Exported so tests can compute the expected path without duplicating the
|
|
58
|
+
* resolution logic.
|
|
59
|
+
*/
|
|
60
|
+
function urlsFilePath(start, existsSyncFn) {
|
|
61
|
+
return join(nearestPackageJsonDir(start, existsSyncFn), URLS_FILE_NAME);
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Writes `{ relayBaseUrl, tunnelBaseUrl }` (omitting absent keys) to
|
|
65
|
+
* `<projectRoot>/.ait_urls` (mode 0600). Uses `flag: 'w'` (overwrite) because
|
|
66
|
+
* URLs are ephemeral — a fresh URL replaces the previous one on every boot.
|
|
67
|
+
*
|
|
68
|
+
* Unlike the `.ait_relay` secret store this does NOT use `O_EXCL` (`'wx'`):
|
|
69
|
+
* there is no race concern here (only the unplugin writes this file) and the
|
|
70
|
+
* URL must be fresh on every cold-start.
|
|
71
|
+
*
|
|
72
|
+
* Called ONLY from the unplugin (env-2 tunnel boot). The MCP daemon uses
|
|
73
|
+
* {@link readRelayUrls} (read-only) — it must never write.
|
|
74
|
+
*
|
|
75
|
+
* SECRET-HANDLING: URL values are never logged.
|
|
76
|
+
*/
|
|
77
|
+
async function writeRelayUrls(deps) {
|
|
78
|
+
const { projectRoot, relayBaseUrl, tunnelBaseUrl, fs: fsDep, existsSync: existsSyncDep } = deps;
|
|
79
|
+
const fs = fsDep ?? await import("node:fs");
|
|
80
|
+
const filePath = urlsFilePath(projectRoot, existsSyncDep ?? fs.existsSync);
|
|
81
|
+
const payload = {};
|
|
82
|
+
if (typeof relayBaseUrl === "string" && relayBaseUrl !== "") payload.relayBaseUrl = relayBaseUrl;
|
|
83
|
+
if (typeof tunnelBaseUrl === "string" && tunnelBaseUrl !== "") payload.tunnelBaseUrl = tunnelBaseUrl;
|
|
84
|
+
const data = JSON.stringify(payload);
|
|
85
|
+
fs.writeFileSync(filePath, data, {
|
|
86
|
+
mode: 384,
|
|
87
|
+
flag: "w"
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Removes `<projectRoot>/.ait_urls` if present. Silently swallows ENOENT and
|
|
92
|
+
* any other error so cleanup always succeeds.
|
|
93
|
+
*
|
|
94
|
+
* Called ONLY from the unplugin's `cleanup()` on `httpServer 'close'` + signals
|
|
95
|
+
* (via `void deleteRelayUrls(...)`). A stale `.ait_urls` pointing at a dead
|
|
96
|
+
* tunnel would cause the MCP daemon to attempt a doomed attach on the next
|
|
97
|
+
* cold-start — deletion is non-negotiable.
|
|
98
|
+
*
|
|
99
|
+
* SECRET-HANDLING: the file path is never logged.
|
|
100
|
+
*/
|
|
101
|
+
async function deleteRelayUrls(deps) {
|
|
102
|
+
const { projectRoot, fs: fsDep, existsSync: existsSyncDep } = deps;
|
|
103
|
+
const fs = fsDep ?? await import("node:fs");
|
|
104
|
+
const filePath = urlsFilePath(projectRoot, existsSyncDep ?? fs.existsSync);
|
|
105
|
+
try {
|
|
106
|
+
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
|
|
107
|
+
} catch {}
|
|
108
|
+
}
|
|
109
|
+
//#endregion
|
|
110
|
+
export { deleteRelayUrls, writeRelayUrls };
|
|
111
|
+
|
|
112
|
+
//# sourceMappingURL=relay-url-store-WKfo0VQV.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"relay-url-store-WKfo0VQV.js","names":[],"sources":["../src/mcp/relay-url-store.ts"],"sourcesContent":["/**\n * Project-local ephemeral URL store (#424).\n *\n * Environment-2 (\"AITC Sandbox PWA\") cold-start requires two ephemeral URLs:\n * - `relayBaseUrl` — the CDP relay's https base (was `AIT_RELAY_BASE_URL`)\n * - `tunnelBaseUrl` — the app's https tunnel base (was `AIT_TUNNEL_BASE_URL`)\n *\n * Quick-tunnel URLs change every run. Previously the user had to copy-paste them\n * into env vars on every cold-start — a single typo silently broke attach. This\n * module replaces that manual hand-off with a file-based discovery pattern that\n * exactly mirrors the `.ait_relay` TOTP-secret store (relay-secret-store.ts).\n *\n * Two surfaces, intentionally split by who is allowed to write:\n *\n * - {@link writeRelayUrls} — WRITE path, called ONLY from the unplugin\n * (env-2 tunnel boot). Writes JSON to `<projectRoot>/.ait_urls` (0600) on\n * every boot (`flag: 'w'` — overwrite). A single file — no directory is\n * created.\n *\n * - {@link readRelayUrls} — READ-ONLY path, called from the MCP daemon as a\n * fallback when `AIT_RELAY_BASE_URL`/`AIT_TUNNEL_BASE_URL` are not set. It\n * NEVER writes, chmods, or creates anything: it only reads an existing\n * `.ait_urls`. On any failure (missing file / bad JSON / wrong shape) it\n * returns `null` silently, letting the downstream assertion be the single\n * fail-fast.\n *\n * - {@link deleteRelayUrls} — called from the unplugin `cleanup()` on\n * teardown (via `void deleteRelayUrls(...)`). A stale `.ait_urls` pointing\n * at a dead tunnel would cause the MCP daemon to attempt a doomed attach on\n * the next cold-start — deletion is non-negotiable. Silently swallows all\n * errors.\n *\n * Design note: the env vars (`AIT_RELAY_BASE_URL`, `AIT_TUNNEL_BASE_URL`) are\n * PRESERVED as operator overrides. The file is the fallback — env wins.\n *\n * Why `nearestPackageJsonDir` instead of `process.cwd()`: see relay-secret-store.ts.\n * The unplugin (writer) and the MCP daemon (reader) both anchor to the nearest\n * package.json from their respective `projectRoot` inputs, ensuring both sides\n * find the same file.\n *\n * SECRET-HANDLING: `relayBaseUrl` and `tunnelBaseUrl` carry the relay/tunnel host\n * — same sensitivity class as `.ait_relay`. The raw URL values, partial values,\n * and the resolved file path MUST NOT appear in any log, error message, stdout,\n * stderr, or assertion output anywhere in this module or at its call sites.\n * Only boolean pass/fail signals are safe to surface. The file is written mode\n * 0600.\n */\n\nimport { join } from 'node:path';\nimport { nearestPackageJsonDir } from './relay-secret-store.js';\n\n// ---------------------------------------------------------------------------\n// Constants\n// ---------------------------------------------------------------------------\n\n/** Project-local ephemeral URL file name (single file, not a directory). */\nexport const URLS_FILE_NAME = '.ait_urls';\n\n// ---------------------------------------------------------------------------\n// Dependency injection surfaces\n// ---------------------------------------------------------------------------\n\n/** Minimal fs subset needed by {@link writeRelayUrls} — injectable for tests. */\nexport interface RelayUrlWriteFs {\n writeFileSync(path: string, data: string, options: { mode: number; flag: string }): void;\n existsSync(path: string): boolean;\n}\n\n/** Minimal fs subset needed by {@link readRelayUrls} — injectable for tests. */\nexport interface RelayUrlReadFs {\n existsSync(path: string): boolean;\n readFileSync(path: string, encoding: BufferEncoding): string;\n}\n\n/** Minimal fs subset needed by {@link deleteRelayUrls} — injectable for tests. */\nexport interface RelayUrlDeleteFs {\n existsSync(path: string): boolean;\n unlinkSync(path: string): void;\n}\n\n// ---------------------------------------------------------------------------\n// Path helper\n// ---------------------------------------------------------------------------\n\n/**\n * Absolute path to the project-local `.ait_urls` file for a given start\n * directory (resolved against the nearest package.json directory).\n *\n * Exported so tests can compute the expected path without duplicating the\n * resolution logic.\n */\nexport function urlsFilePath(start: string, existsSyncFn: (path: string) => boolean): string {\n return join(nearestPackageJsonDir(start, existsSyncFn), URLS_FILE_NAME);\n}\n\n// ---------------------------------------------------------------------------\n// WRITE path (unplugin only) — overwrite on every boot\n// ---------------------------------------------------------------------------\n\nexport interface WriteRelayUrlsDeps {\n /** Project root (typically Vite `server.config.root`). */\n projectRoot: string;\n /**\n * The CDP relay's https base URL (same value as `AIT_RELAY_BASE_URL`).\n * Omit when the relay was not started (e.g. `cdp:false`).\n * SECRET-HANDLING: never log this value.\n */\n relayBaseUrl?: string;\n /**\n * The app tunnel's https base URL (same value as `AIT_TUNNEL_BASE_URL`).\n * Omit when no tunnel URL is available.\n * SECRET-HANDLING: never log this value.\n */\n tunnelBaseUrl?: string;\n /** Filesystem operations. Defaults to node:fs synchronous functions. */\n fs?: RelayUrlWriteFs;\n /** existsSync used to resolve the nearest package.json directory. Defaults to node:fs. */\n existsSync?: (path: string) => boolean;\n}\n\n/**\n * Writes `{ relayBaseUrl, tunnelBaseUrl }` (omitting absent keys) to\n * `<projectRoot>/.ait_urls` (mode 0600). Uses `flag: 'w'` (overwrite) because\n * URLs are ephemeral — a fresh URL replaces the previous one on every boot.\n *\n * Unlike the `.ait_relay` secret store this does NOT use `O_EXCL` (`'wx'`):\n * there is no race concern here (only the unplugin writes this file) and the\n * URL must be fresh on every cold-start.\n *\n * Called ONLY from the unplugin (env-2 tunnel boot). The MCP daemon uses\n * {@link readRelayUrls} (read-only) — it must never write.\n *\n * SECRET-HANDLING: URL values are never logged.\n */\nexport async function writeRelayUrls(deps: WriteRelayUrlsDeps): Promise<void> {\n const { projectRoot, relayBaseUrl, tunnelBaseUrl, fs: fsDep, existsSync: existsSyncDep } = deps;\n\n const fs: RelayUrlWriteFs = fsDep ?? (await import('node:fs'));\n const existsSyncFn: (path: string) => boolean = existsSyncDep ?? fs.existsSync;\n\n const filePath = urlsFilePath(projectRoot, existsSyncFn);\n\n // Build the payload — omit keys whose values are absent.\n const payload: { relayBaseUrl?: string; tunnelBaseUrl?: string } = {};\n if (typeof relayBaseUrl === 'string' && relayBaseUrl !== '') {\n payload.relayBaseUrl = relayBaseUrl;\n }\n if (typeof tunnelBaseUrl === 'string' && tunnelBaseUrl !== '') {\n payload.tunnelBaseUrl = tunnelBaseUrl;\n }\n\n // SECRET-HANDLING: JSON content (which includes URL values) is written to\n // the file only — never to any log, stdout, or stderr.\n const data = JSON.stringify(payload);\n\n // Overwrite on every boot (`flag: 'w'`) — URLs are ephemeral.\n fs.writeFileSync(filePath, data, { mode: 0o600, flag: 'w' });\n}\n\n// ---------------------------------------------------------------------------\n// READ-ONLY path (daemon only) — never writes, chmods, or creates anything\n// ---------------------------------------------------------------------------\n\nexport interface ReadRelayUrlsDeps {\n /** Project root supplied per-debug-session. When omitted, returns `null`. */\n projectRoot?: string;\n /** Read-only filesystem operations. Defaults to node:fs (existsSync + readFileSync). */\n fs?: RelayUrlReadFs;\n /** existsSync used to resolve the nearest package.json directory. Defaults to node:fs. */\n existsSync?: (path: string) => boolean;\n}\n\n/**\n * Reads `<projectRoot>/.ait_urls` and returns the stored URLs, or `null` on\n * any failure.\n *\n * Strictly READ-ONLY: only `existsSync` + `readFileSync`. Never writes,\n * chmods, or creates files/directories.\n *\n * Returns `null` (silently, no throw, no log) on:\n * - missing `projectRoot`\n * - missing `.ait_urls` file\n * - unreadable file (permissions, transient FS error)\n * - invalid JSON\n * - wrong shape (non-object, non-string values)\n *\n * Trims string values before returning. Ignores non-string fields.\n *\n * SECRET-HANDLING: URL values and the file path are never logged.\n */\nexport async function readRelayUrls(deps?: ReadRelayUrlsDeps): Promise<{\n relayBaseUrl?: string;\n tunnelBaseUrl?: string;\n} | null> {\n const { projectRoot, fs: fsDep, existsSync: existsSyncDep } = deps ?? {};\n\n if (projectRoot === undefined) {\n return null;\n }\n\n const fs: RelayUrlReadFs = fsDep ?? (await import('node:fs'));\n const existsSyncFn: (path: string) => boolean = existsSyncDep ?? fs.existsSync;\n\n const filePath = urlsFilePath(projectRoot, existsSyncFn);\n\n if (!fs.existsSync(filePath)) {\n return null;\n }\n\n let raw: string;\n try {\n raw = fs.readFileSync(filePath, 'utf8');\n } catch {\n // Unreadable file — silent no-op, let the downstream assert be the fail-fast.\n // SECRET-HANDLING: the error and path are not surfaced.\n return null;\n }\n\n let parsed: unknown;\n try {\n parsed = JSON.parse(raw);\n } catch {\n // Invalid JSON — silent no-op.\n return null;\n }\n\n if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {\n return null;\n }\n\n const obj = parsed as Record<string, unknown>;\n const result: { relayBaseUrl?: string; tunnelBaseUrl?: string } = {};\n\n const relay = obj.relayBaseUrl;\n if (typeof relay === 'string') {\n const trimmed = relay.trim();\n if (trimmed !== '') result.relayBaseUrl = trimmed;\n }\n\n const tunnel = obj.tunnelBaseUrl;\n if (typeof tunnel === 'string') {\n const trimmed = tunnel.trim();\n if (trimmed !== '') result.tunnelBaseUrl = trimmed;\n }\n\n return result;\n}\n\n// ---------------------------------------------------------------------------\n// DELETE path (unplugin cleanup only)\n// ---------------------------------------------------------------------------\n\nexport interface DeleteRelayUrlsDeps {\n /** Project root. */\n projectRoot: string;\n /** Filesystem operations. Defaults to node:fs (existsSync + unlinkSync). */\n fs?: RelayUrlDeleteFs;\n /** existsSync used to resolve the nearest package.json directory. */\n existsSync?: (path: string) => boolean;\n}\n\n/**\n * Removes `<projectRoot>/.ait_urls` if present. Silently swallows ENOENT and\n * any other error so cleanup always succeeds.\n *\n * Called ONLY from the unplugin's `cleanup()` on `httpServer 'close'` + signals\n * (via `void deleteRelayUrls(...)`). A stale `.ait_urls` pointing at a dead\n * tunnel would cause the MCP daemon to attempt a doomed attach on the next\n * cold-start — deletion is non-negotiable.\n *\n * SECRET-HANDLING: the file path is never logged.\n */\nexport async function deleteRelayUrls(deps: DeleteRelayUrlsDeps): Promise<void> {\n const { projectRoot, fs: fsDep, existsSync: existsSyncDep } = deps;\n\n const fs: RelayUrlDeleteFs = fsDep ?? (await import('node:fs'));\n const existsSyncFn: (path: string) => boolean = existsSyncDep ?? fs.existsSync;\n\n const filePath = urlsFilePath(projectRoot, existsSyncFn);\n\n try {\n if (fs.existsSync(filePath)) {\n fs.unlinkSync(filePath);\n }\n } catch {\n // Swallow ENOENT and any other error — cleanup is best-effort.\n // SECRET-HANDLING: the path is not logged.\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAwDA,MAAa,iBAAiB;;;;;;;;AAmC9B,SAAgB,aAAa,OAAe,cAAiD;AAC3F,QAAO,KAAK,sBAAsB,OAAO,aAAa,EAAE,eAAe;;;;;;;;;;;;;;;;AA0CzE,eAAsB,eAAe,MAAyC;CAC5E,MAAM,EAAE,aAAa,cAAc,eAAe,IAAI,OAAO,YAAY,kBAAkB;CAE3F,MAAM,KAAsB,SAAU,MAAM,OAAO;CAGnD,MAAM,WAAW,aAAa,aAFkB,iBAAiB,GAAG,WAEZ;CAGxD,MAAM,UAA6D,EAAE;AACrE,KAAI,OAAO,iBAAiB,YAAY,iBAAiB,GACvD,SAAQ,eAAe;AAEzB,KAAI,OAAO,kBAAkB,YAAY,kBAAkB,GACzD,SAAQ,gBAAgB;CAK1B,MAAM,OAAO,KAAK,UAAU,QAAQ;AAGpC,IAAG,cAAc,UAAU,MAAM;EAAE,MAAM;EAAO,MAAM;EAAK,CAAC;;;;;;;;;;;;;AAoH9D,eAAsB,gBAAgB,MAA0C;CAC9E,MAAM,EAAE,aAAa,IAAI,OAAO,YAAY,kBAAkB;CAE9D,MAAM,KAAuB,SAAU,MAAM,OAAO;CAGpD,MAAM,WAAW,aAAa,aAFkB,iBAAiB,GAAG,WAEZ;AAExD,KAAI;AACF,MAAI,GAAG,WAAW,SAAS,CACzB,IAAG,WAAW,SAAS;SAEnB"}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { n as nearestPackageJsonDir } from "./relay-secret-store-5A7_7zOp.js";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
//#region src/mcp/relay-url-store.ts
|
|
5
|
+
/**
|
|
6
|
+
* Project-local ephemeral URL store (#424).
|
|
7
|
+
*
|
|
8
|
+
* Environment-2 ("AITC Sandbox PWA") cold-start requires two ephemeral URLs:
|
|
9
|
+
* - `relayBaseUrl` — the CDP relay's https base (was `AIT_RELAY_BASE_URL`)
|
|
10
|
+
* - `tunnelBaseUrl` — the app's https tunnel base (was `AIT_TUNNEL_BASE_URL`)
|
|
11
|
+
*
|
|
12
|
+
* Quick-tunnel URLs change every run. Previously the user had to copy-paste them
|
|
13
|
+
* into env vars on every cold-start — a single typo silently broke attach. This
|
|
14
|
+
* module replaces that manual hand-off with a file-based discovery pattern that
|
|
15
|
+
* exactly mirrors the `.ait_relay` TOTP-secret store (relay-secret-store.ts).
|
|
16
|
+
*
|
|
17
|
+
* Two surfaces, intentionally split by who is allowed to write:
|
|
18
|
+
*
|
|
19
|
+
* - {@link writeRelayUrls} — WRITE path, called ONLY from the unplugin
|
|
20
|
+
* (env-2 tunnel boot). Writes JSON to `<projectRoot>/.ait_urls` (0600) on
|
|
21
|
+
* every boot (`flag: 'w'` — overwrite). A single file — no directory is
|
|
22
|
+
* created.
|
|
23
|
+
*
|
|
24
|
+
* - {@link readRelayUrls} — READ-ONLY path, called from the MCP daemon as a
|
|
25
|
+
* fallback when `AIT_RELAY_BASE_URL`/`AIT_TUNNEL_BASE_URL` are not set. It
|
|
26
|
+
* NEVER writes, chmods, or creates anything: it only reads an existing
|
|
27
|
+
* `.ait_urls`. On any failure (missing file / bad JSON / wrong shape) it
|
|
28
|
+
* returns `null` silently, letting the downstream assertion be the single
|
|
29
|
+
* fail-fast.
|
|
30
|
+
*
|
|
31
|
+
* - {@link deleteRelayUrls} — called from the unplugin `cleanup()` on
|
|
32
|
+
* teardown (via `void deleteRelayUrls(...)`). A stale `.ait_urls` pointing
|
|
33
|
+
* at a dead tunnel would cause the MCP daemon to attempt a doomed attach on
|
|
34
|
+
* the next cold-start — deletion is non-negotiable. Silently swallows all
|
|
35
|
+
* errors.
|
|
36
|
+
*
|
|
37
|
+
* Design note: the env vars (`AIT_RELAY_BASE_URL`, `AIT_TUNNEL_BASE_URL`) are
|
|
38
|
+
* PRESERVED as operator overrides. The file is the fallback — env wins.
|
|
39
|
+
*
|
|
40
|
+
* Why `nearestPackageJsonDir` instead of `process.cwd()`: see relay-secret-store.ts.
|
|
41
|
+
* The unplugin (writer) and the MCP daemon (reader) both anchor to the nearest
|
|
42
|
+
* package.json from their respective `projectRoot` inputs, ensuring both sides
|
|
43
|
+
* find the same file.
|
|
44
|
+
*
|
|
45
|
+
* SECRET-HANDLING: `relayBaseUrl` and `tunnelBaseUrl` carry the relay/tunnel host
|
|
46
|
+
* — same sensitivity class as `.ait_relay`. The raw URL values, partial values,
|
|
47
|
+
* and the resolved file path MUST NOT appear in any log, error message, stdout,
|
|
48
|
+
* stderr, or assertion output anywhere in this module or at its call sites.
|
|
49
|
+
* Only boolean pass/fail signals are safe to surface. The file is written mode
|
|
50
|
+
* 0600.
|
|
51
|
+
*/
|
|
52
|
+
/** Project-local ephemeral URL file name (single file, not a directory). */
|
|
53
|
+
const URLS_FILE_NAME = ".ait_urls";
|
|
54
|
+
/**
|
|
55
|
+
* Absolute path to the project-local `.ait_urls` file for a given start
|
|
56
|
+
* directory (resolved against the nearest package.json directory).
|
|
57
|
+
*
|
|
58
|
+
* Exported so tests can compute the expected path without duplicating the
|
|
59
|
+
* resolution logic.
|
|
60
|
+
*/
|
|
61
|
+
function urlsFilePath(start, existsSyncFn) {
|
|
62
|
+
return join(nearestPackageJsonDir(start, existsSyncFn), URLS_FILE_NAME);
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Reads `<projectRoot>/.ait_urls` and returns the stored URLs, or `null` on
|
|
66
|
+
* any failure.
|
|
67
|
+
*
|
|
68
|
+
* Strictly READ-ONLY: only `existsSync` + `readFileSync`. Never writes,
|
|
69
|
+
* chmods, or creates files/directories.
|
|
70
|
+
*
|
|
71
|
+
* Returns `null` (silently, no throw, no log) on:
|
|
72
|
+
* - missing `projectRoot`
|
|
73
|
+
* - missing `.ait_urls` file
|
|
74
|
+
* - unreadable file (permissions, transient FS error)
|
|
75
|
+
* - invalid JSON
|
|
76
|
+
* - wrong shape (non-object, non-string values)
|
|
77
|
+
*
|
|
78
|
+
* Trims string values before returning. Ignores non-string fields.
|
|
79
|
+
*
|
|
80
|
+
* SECRET-HANDLING: URL values and the file path are never logged.
|
|
81
|
+
*/
|
|
82
|
+
async function readRelayUrls(deps) {
|
|
83
|
+
const { projectRoot, fs: fsDep, existsSync: existsSyncDep } = deps ?? {};
|
|
84
|
+
if (projectRoot === void 0) return null;
|
|
85
|
+
const fs = fsDep ?? await import("node:fs");
|
|
86
|
+
const filePath = urlsFilePath(projectRoot, existsSyncDep ?? fs.existsSync);
|
|
87
|
+
if (!fs.existsSync(filePath)) return null;
|
|
88
|
+
let raw;
|
|
89
|
+
try {
|
|
90
|
+
raw = fs.readFileSync(filePath, "utf8");
|
|
91
|
+
} catch {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
let parsed;
|
|
95
|
+
try {
|
|
96
|
+
parsed = JSON.parse(raw);
|
|
97
|
+
} catch {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null;
|
|
101
|
+
const obj = parsed;
|
|
102
|
+
const result = {};
|
|
103
|
+
const relay = obj.relayBaseUrl;
|
|
104
|
+
if (typeof relay === "string") {
|
|
105
|
+
const trimmed = relay.trim();
|
|
106
|
+
if (trimmed !== "") result.relayBaseUrl = trimmed;
|
|
107
|
+
}
|
|
108
|
+
const tunnel = obj.tunnelBaseUrl;
|
|
109
|
+
if (typeof tunnel === "string") {
|
|
110
|
+
const trimmed = tunnel.trim();
|
|
111
|
+
if (trimmed !== "") result.tunnelBaseUrl = trimmed;
|
|
112
|
+
}
|
|
113
|
+
return result;
|
|
114
|
+
}
|
|
115
|
+
//#endregion
|
|
116
|
+
export { readRelayUrls };
|
|
117
|
+
|
|
118
|
+
//# sourceMappingURL=relay-url-store-qaoe0zOD.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"relay-url-store-qaoe0zOD.js","names":[],"sources":["../src/mcp/relay-url-store.ts"],"sourcesContent":["/**\n * Project-local ephemeral URL store (#424).\n *\n * Environment-2 (\"AITC Sandbox PWA\") cold-start requires two ephemeral URLs:\n * - `relayBaseUrl` — the CDP relay's https base (was `AIT_RELAY_BASE_URL`)\n * - `tunnelBaseUrl` — the app's https tunnel base (was `AIT_TUNNEL_BASE_URL`)\n *\n * Quick-tunnel URLs change every run. Previously the user had to copy-paste them\n * into env vars on every cold-start — a single typo silently broke attach. This\n * module replaces that manual hand-off with a file-based discovery pattern that\n * exactly mirrors the `.ait_relay` TOTP-secret store (relay-secret-store.ts).\n *\n * Two surfaces, intentionally split by who is allowed to write:\n *\n * - {@link writeRelayUrls} — WRITE path, called ONLY from the unplugin\n * (env-2 tunnel boot). Writes JSON to `<projectRoot>/.ait_urls` (0600) on\n * every boot (`flag: 'w'` — overwrite). A single file — no directory is\n * created.\n *\n * - {@link readRelayUrls} — READ-ONLY path, called from the MCP daemon as a\n * fallback when `AIT_RELAY_BASE_URL`/`AIT_TUNNEL_BASE_URL` are not set. It\n * NEVER writes, chmods, or creates anything: it only reads an existing\n * `.ait_urls`. On any failure (missing file / bad JSON / wrong shape) it\n * returns `null` silently, letting the downstream assertion be the single\n * fail-fast.\n *\n * - {@link deleteRelayUrls} — called from the unplugin `cleanup()` on\n * teardown (via `void deleteRelayUrls(...)`). A stale `.ait_urls` pointing\n * at a dead tunnel would cause the MCP daemon to attempt a doomed attach on\n * the next cold-start — deletion is non-negotiable. Silently swallows all\n * errors.\n *\n * Design note: the env vars (`AIT_RELAY_BASE_URL`, `AIT_TUNNEL_BASE_URL`) are\n * PRESERVED as operator overrides. The file is the fallback — env wins.\n *\n * Why `nearestPackageJsonDir` instead of `process.cwd()`: see relay-secret-store.ts.\n * The unplugin (writer) and the MCP daemon (reader) both anchor to the nearest\n * package.json from their respective `projectRoot` inputs, ensuring both sides\n * find the same file.\n *\n * SECRET-HANDLING: `relayBaseUrl` and `tunnelBaseUrl` carry the relay/tunnel host\n * — same sensitivity class as `.ait_relay`. The raw URL values, partial values,\n * and the resolved file path MUST NOT appear in any log, error message, stdout,\n * stderr, or assertion output anywhere in this module or at its call sites.\n * Only boolean pass/fail signals are safe to surface. The file is written mode\n * 0600.\n */\n\nimport { join } from 'node:path';\nimport { nearestPackageJsonDir } from './relay-secret-store.js';\n\n// ---------------------------------------------------------------------------\n// Constants\n// ---------------------------------------------------------------------------\n\n/** Project-local ephemeral URL file name (single file, not a directory). */\nexport const URLS_FILE_NAME = '.ait_urls';\n\n// ---------------------------------------------------------------------------\n// Dependency injection surfaces\n// ---------------------------------------------------------------------------\n\n/** Minimal fs subset needed by {@link writeRelayUrls} — injectable for tests. */\nexport interface RelayUrlWriteFs {\n writeFileSync(path: string, data: string, options: { mode: number; flag: string }): void;\n existsSync(path: string): boolean;\n}\n\n/** Minimal fs subset needed by {@link readRelayUrls} — injectable for tests. */\nexport interface RelayUrlReadFs {\n existsSync(path: string): boolean;\n readFileSync(path: string, encoding: BufferEncoding): string;\n}\n\n/** Minimal fs subset needed by {@link deleteRelayUrls} — injectable for tests. */\nexport interface RelayUrlDeleteFs {\n existsSync(path: string): boolean;\n unlinkSync(path: string): void;\n}\n\n// ---------------------------------------------------------------------------\n// Path helper\n// ---------------------------------------------------------------------------\n\n/**\n * Absolute path to the project-local `.ait_urls` file for a given start\n * directory (resolved against the nearest package.json directory).\n *\n * Exported so tests can compute the expected path without duplicating the\n * resolution logic.\n */\nexport function urlsFilePath(start: string, existsSyncFn: (path: string) => boolean): string {\n return join(nearestPackageJsonDir(start, existsSyncFn), URLS_FILE_NAME);\n}\n\n// ---------------------------------------------------------------------------\n// WRITE path (unplugin only) — overwrite on every boot\n// ---------------------------------------------------------------------------\n\nexport interface WriteRelayUrlsDeps {\n /** Project root (typically Vite `server.config.root`). */\n projectRoot: string;\n /**\n * The CDP relay's https base URL (same value as `AIT_RELAY_BASE_URL`).\n * Omit when the relay was not started (e.g. `cdp:false`).\n * SECRET-HANDLING: never log this value.\n */\n relayBaseUrl?: string;\n /**\n * The app tunnel's https base URL (same value as `AIT_TUNNEL_BASE_URL`).\n * Omit when no tunnel URL is available.\n * SECRET-HANDLING: never log this value.\n */\n tunnelBaseUrl?: string;\n /** Filesystem operations. Defaults to node:fs synchronous functions. */\n fs?: RelayUrlWriteFs;\n /** existsSync used to resolve the nearest package.json directory. Defaults to node:fs. */\n existsSync?: (path: string) => boolean;\n}\n\n/**\n * Writes `{ relayBaseUrl, tunnelBaseUrl }` (omitting absent keys) to\n * `<projectRoot>/.ait_urls` (mode 0600). Uses `flag: 'w'` (overwrite) because\n * URLs are ephemeral — a fresh URL replaces the previous one on every boot.\n *\n * Unlike the `.ait_relay` secret store this does NOT use `O_EXCL` (`'wx'`):\n * there is no race concern here (only the unplugin writes this file) and the\n * URL must be fresh on every cold-start.\n *\n * Called ONLY from the unplugin (env-2 tunnel boot). The MCP daemon uses\n * {@link readRelayUrls} (read-only) — it must never write.\n *\n * SECRET-HANDLING: URL values are never logged.\n */\nexport async function writeRelayUrls(deps: WriteRelayUrlsDeps): Promise<void> {\n const { projectRoot, relayBaseUrl, tunnelBaseUrl, fs: fsDep, existsSync: existsSyncDep } = deps;\n\n const fs: RelayUrlWriteFs = fsDep ?? (await import('node:fs'));\n const existsSyncFn: (path: string) => boolean = existsSyncDep ?? fs.existsSync;\n\n const filePath = urlsFilePath(projectRoot, existsSyncFn);\n\n // Build the payload — omit keys whose values are absent.\n const payload: { relayBaseUrl?: string; tunnelBaseUrl?: string } = {};\n if (typeof relayBaseUrl === 'string' && relayBaseUrl !== '') {\n payload.relayBaseUrl = relayBaseUrl;\n }\n if (typeof tunnelBaseUrl === 'string' && tunnelBaseUrl !== '') {\n payload.tunnelBaseUrl = tunnelBaseUrl;\n }\n\n // SECRET-HANDLING: JSON content (which includes URL values) is written to\n // the file only — never to any log, stdout, or stderr.\n const data = JSON.stringify(payload);\n\n // Overwrite on every boot (`flag: 'w'`) — URLs are ephemeral.\n fs.writeFileSync(filePath, data, { mode: 0o600, flag: 'w' });\n}\n\n// ---------------------------------------------------------------------------\n// READ-ONLY path (daemon only) — never writes, chmods, or creates anything\n// ---------------------------------------------------------------------------\n\nexport interface ReadRelayUrlsDeps {\n /** Project root supplied per-debug-session. When omitted, returns `null`. */\n projectRoot?: string;\n /** Read-only filesystem operations. Defaults to node:fs (existsSync + readFileSync). */\n fs?: RelayUrlReadFs;\n /** existsSync used to resolve the nearest package.json directory. Defaults to node:fs. */\n existsSync?: (path: string) => boolean;\n}\n\n/**\n * Reads `<projectRoot>/.ait_urls` and returns the stored URLs, or `null` on\n * any failure.\n *\n * Strictly READ-ONLY: only `existsSync` + `readFileSync`. Never writes,\n * chmods, or creates files/directories.\n *\n * Returns `null` (silently, no throw, no log) on:\n * - missing `projectRoot`\n * - missing `.ait_urls` file\n * - unreadable file (permissions, transient FS error)\n * - invalid JSON\n * - wrong shape (non-object, non-string values)\n *\n * Trims string values before returning. Ignores non-string fields.\n *\n * SECRET-HANDLING: URL values and the file path are never logged.\n */\nexport async function readRelayUrls(deps?: ReadRelayUrlsDeps): Promise<{\n relayBaseUrl?: string;\n tunnelBaseUrl?: string;\n} | null> {\n const { projectRoot, fs: fsDep, existsSync: existsSyncDep } = deps ?? {};\n\n if (projectRoot === undefined) {\n return null;\n }\n\n const fs: RelayUrlReadFs = fsDep ?? (await import('node:fs'));\n const existsSyncFn: (path: string) => boolean = existsSyncDep ?? fs.existsSync;\n\n const filePath = urlsFilePath(projectRoot, existsSyncFn);\n\n if (!fs.existsSync(filePath)) {\n return null;\n }\n\n let raw: string;\n try {\n raw = fs.readFileSync(filePath, 'utf8');\n } catch {\n // Unreadable file — silent no-op, let the downstream assert be the fail-fast.\n // SECRET-HANDLING: the error and path are not surfaced.\n return null;\n }\n\n let parsed: unknown;\n try {\n parsed = JSON.parse(raw);\n } catch {\n // Invalid JSON — silent no-op.\n return null;\n }\n\n if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {\n return null;\n }\n\n const obj = parsed as Record<string, unknown>;\n const result: { relayBaseUrl?: string; tunnelBaseUrl?: string } = {};\n\n const relay = obj.relayBaseUrl;\n if (typeof relay === 'string') {\n const trimmed = relay.trim();\n if (trimmed !== '') result.relayBaseUrl = trimmed;\n }\n\n const tunnel = obj.tunnelBaseUrl;\n if (typeof tunnel === 'string') {\n const trimmed = tunnel.trim();\n if (trimmed !== '') result.tunnelBaseUrl = trimmed;\n }\n\n return result;\n}\n\n// ---------------------------------------------------------------------------\n// DELETE path (unplugin cleanup only)\n// ---------------------------------------------------------------------------\n\nexport interface DeleteRelayUrlsDeps {\n /** Project root. */\n projectRoot: string;\n /** Filesystem operations. Defaults to node:fs (existsSync + unlinkSync). */\n fs?: RelayUrlDeleteFs;\n /** existsSync used to resolve the nearest package.json directory. */\n existsSync?: (path: string) => boolean;\n}\n\n/**\n * Removes `<projectRoot>/.ait_urls` if present. Silently swallows ENOENT and\n * any other error so cleanup always succeeds.\n *\n * Called ONLY from the unplugin's `cleanup()` on `httpServer 'close'` + signals\n * (via `void deleteRelayUrls(...)`). A stale `.ait_urls` pointing at a dead\n * tunnel would cause the MCP daemon to attempt a doomed attach on the next\n * cold-start — deletion is non-negotiable.\n *\n * SECRET-HANDLING: the file path is never logged.\n */\nexport async function deleteRelayUrls(deps: DeleteRelayUrlsDeps): Promise<void> {\n const { projectRoot, fs: fsDep, existsSync: existsSyncDep } = deps;\n\n const fs: RelayUrlDeleteFs = fsDep ?? (await import('node:fs'));\n const existsSyncFn: (path: string) => boolean = existsSyncDep ?? fs.existsSync;\n\n const filePath = urlsFilePath(projectRoot, existsSyncFn);\n\n try {\n if (fs.existsSync(filePath)) {\n fs.unlinkSync(filePath);\n }\n } catch {\n // Swallow ENOENT and any other error — cleanup is best-effort.\n // SECRET-HANDLING: the path is not logged.\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAwDA,MAAa,iBAAiB;;;;;;;;AAmC9B,SAAgB,aAAa,OAAe,cAAiD;AAC3F,QAAO,KAAK,sBAAsB,OAAO,aAAa,EAAE,eAAe;;;;;;;;;;;;;;;;;;;;AAkGzE,eAAsB,cAAc,MAG1B;CACR,MAAM,EAAE,aAAa,IAAI,OAAO,YAAY,kBAAkB,QAAQ,EAAE;AAExE,KAAI,gBAAgB,KAAA,EAClB,QAAO;CAGT,MAAM,KAAqB,SAAU,MAAM,OAAO;CAGlD,MAAM,WAAW,aAAa,aAFkB,iBAAiB,GAAG,WAEZ;AAExD,KAAI,CAAC,GAAG,WAAW,SAAS,CAC1B,QAAO;CAGT,IAAI;AACJ,KAAI;AACF,QAAM,GAAG,aAAa,UAAU,OAAO;SACjC;AAGN,SAAO;;CAGT,IAAI;AACJ,KAAI;AACF,WAAS,KAAK,MAAM,IAAI;SAClB;AAEN,SAAO;;AAGT,KAAI,OAAO,WAAW,YAAY,WAAW,QAAQ,MAAM,QAAQ,OAAO,CACxE,QAAO;CAGT,MAAM,MAAM;CACZ,MAAM,SAA4D,EAAE;CAEpE,MAAM,QAAQ,IAAI;AAClB,KAAI,OAAO,UAAU,UAAU;EAC7B,MAAM,UAAU,MAAM,MAAM;AAC5B,MAAI,YAAY,GAAI,QAAO,eAAe;;CAG5C,MAAM,SAAS,IAAI;AACnB,KAAI,OAAO,WAAW,UAAU;EAC9B,MAAM,UAAU,OAAO,MAAM;AAC7B,MAAI,YAAY,GAAI,QAAO,gBAAgB;;AAG7C,QAAO"}
|
|
@@ -184,4 +184,4 @@ function buildRelayVerifyAuth(env = process.env) {
|
|
|
184
184
|
//#endregion
|
|
185
185
|
export { isValidRelayAuthSecret as a, generateTotp as i, assertRelayAuthConfigured as n, verifyTotp as o, buildRelayVerifyAuth as r, RELAY_AUTH_SECRET_MISSING_MESSAGE as t };
|
|
186
186
|
|
|
187
|
-
//# sourceMappingURL=totp-
|
|
187
|
+
//# sourceMappingURL=totp-BIrJHsQn.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"totp-D0a8VwoR.js","names":[],"sources":["../src/mcp/totp.ts"],"sourcesContent":["/**\n * RFC 6238 TOTP implementation (Node.js, node:crypto only).\n *\n * External TOTP libraries (otplib, speakeasy, …) are intentionally NOT used\n * to keep the dependency surface minimal. This hand-roll is ~30 lines and\n * covers exactly what relay-side auth needs.\n *\n * Algorithm summary (RFC 6238 + RFC 4226):\n * T = floor(now / 30) — 30-second time step counter\n * K = Buffer.from(secret, 'hex') — shared secret (raw bytes, hex-encoded)\n * MAC = HMAC-SHA1(K, T as 8-byte big-endian uint64)\n * offset = MAC[19] & 0x0f\n * code = (MAC[offset..offset+4] & 0x7fffffff) % 10^6 — 6 digits\n *\n * Security note (keep this comment accurate):\n * The baked-in secret in a dogfood build is extractable from the bundle by a\n * determined reverse engineer. This mechanism raises the bar from\n * \"anyone with the URL\" to \"URL + bundle extraction + live TOTP calculation\".\n * Casual URL leaks (Slack paste, QR screenshot, shoulder-surfing) are\n * blocked; deliberate reverse engineering is not. See threat model in\n * src/mcp/chii-relay.ts and umbrella CLAUDE.md §4.\n *\n * SECRET-HANDLING: secret values and computed codes MUST NOT appear in any\n * log, error message, or string visible outside this module. Only boolean\n * pass/fail and reason enum values are safe to surface.\n */\n\nimport { createHmac, timingSafeEqual } from 'node:crypto';\n\n/** Time step window in seconds (RFC 6238 default). */\nconst TIME_STEP = 30;\n\n/** Number of digits in the generated code. */\nconst DIGITS = 6;\n\n/**\n * Derives a 6-digit TOTP code from a hex-encoded secret at the given wall-\n * clock time.\n *\n * @param secret - The shared secret as a hex string (e.g. 64 hex chars = 32\n * bytes). Must be the output of `generateAttachToken()` or compatible.\n * @param when - Unix timestamp in milliseconds. Defaults to `Date.now()`.\n * @returns A zero-padded 6-digit decimal string, e.g. `\"042193\"`.\n */\nexport function generateTotp(secret: string, when: number = Date.now()): string {\n const key = Buffer.from(secret, 'hex');\n // Clamp to 0 so negative timestamps (e.g. in ±skew checks near epoch) do not\n // produce a negative counter, which would cause writeUInt32BE to throw.\n const counter = Math.max(0, Math.floor(when / 1000 / TIME_STEP));\n\n // Encode counter as 8-byte big-endian unsigned integer.\n const counterBuf = Buffer.alloc(8);\n // JavaScript numbers are safe integers up to 2^53; counter is ~7.5×10^10 at\n // year 9999 — well within safe range so standard bitwise ops are fine.\n const hi = Math.floor(counter / 0x100000000);\n const lo = counter >>> 0;\n counterBuf.writeUInt32BE(hi, 0);\n counterBuf.writeUInt32BE(lo, 4);\n\n const mac = createHmac('sha1', key).update(counterBuf).digest();\n\n // Dynamic truncation (RFC 4226 §5.4).\n const offset = mac[19] & 0x0f;\n const binCode =\n ((mac[offset] & 0x7f) << 24) |\n ((mac[offset + 1] & 0xff) << 16) |\n ((mac[offset + 2] & 0xff) << 8) |\n (mac[offset + 3] & 0xff);\n\n const otp = binCode % 10 ** DIGITS;\n return otp.toString().padStart(DIGITS, '0');\n}\n\n/**\n * Verifies a TOTP code against the secret, accepting ±`skew` time steps to\n * tolerate clock drift between the relay host and the client device.\n *\n * Uses `timingSafeEqual` for constant-time comparison to prevent timing\n * side-channel attacks.\n *\n * @param secret - Hex-encoded shared secret.\n * @param code - The 6-digit code to verify (string or numeric).\n * @param when - Unix timestamp in milliseconds. Defaults to `Date.now()`.\n * @param skew - Number of adjacent steps to accept on either side. Default 1\n * (accepts T-1, T, T+1 — a 90-second acceptance window).\n * @returns `true` if the code matches any accepted step, `false` otherwise.\n */\nexport function verifyTotp(\n secret: string,\n code: string,\n when: number = Date.now(),\n skew: number = 1,\n): boolean {\n const normalised = String(code).padStart(DIGITS, '0');\n if (normalised.length !== DIGITS || !/^\\d{6}$/.test(normalised)) {\n return false;\n }\n\n const candidateBuf = Buffer.from(normalised, 'utf8');\n\n for (let delta = -skew; delta <= skew; delta++) {\n const stepWhen = when + delta * TIME_STEP * 1000;\n const expected = generateTotp(secret, stepWhen);\n const expectedBuf = Buffer.from(expected, 'utf8');\n if (timingSafeEqual(expectedBuf, candidateBuf)) {\n return true;\n }\n }\n\n return false;\n}\n\n/**\n * Minimum length (in hex characters) accepted for `AIT_DEBUG_TOTP_SECRET`.\n *\n * The secret is hex-encoded (see {@link generateTotp} — `Buffer.from(secret,\n * 'hex')`). 32 hex chars = 16 bytes = 128 bits, the floor for an HMAC-SHA1 key\n * we are willing to gate a public relay behind. `generateAttachToken()` emits\n * 64 hex chars (32 bytes), comfortably above this bar.\n */\nconst MIN_SECRET_HEX_CHARS = 32;\n\n/** Hex string: one or more hex digits, case-insensitive (RFC 4648 base16). */\nconst HEX_RE = /^[0-9a-fA-F]+$/;\n\n/**\n * Human-facing guidance printed when {@link assertRelayAuthConfigured} fails.\n *\n * SECRET-HANDLING: this message states only the REQUIREMENT (≥32 hex chars) and\n * how to mint one. It NEVER echoes the configured value, its length, or any\n * fragment derived from it — see {@link assertRelayAuthConfigured}.\n *\n * Note on encoding: the secret is hex (base16), not base32 — `generateTotp`\n * decodes it with `Buffer.from(secret, 'hex')`. A base32 string would be\n * silently mis-decoded and every TOTP code would fail to match, so the minting\n * command emits hex.\n */\nexport const RELAY_AUTH_SECRET_MISSING_MESSAGE = [\n '[ait-debug] AIT_DEBUG_TOTP_SECRET이 필수입니다. 32자 이상 16진수(hex) 문자열을 설정하세요.',\n '발급: openssl rand -hex 32',\n '데몬은 start_debug의 projectRoot 인자로 받은 디렉토리에서 .ait_relay 파일을 읽어 이 시크릿을 채웁니다.',\n '프로젝트에서 pnpm dev를 한 번 띄우면 unplugin이 .ait_relay를 자동 생성하니, projectRoot를 전달하세요.',\n '자세히: https://docs.aitc.dev/guides/relay-auth-totp',\n].join('\\n');\n\n/**\n * Whether `secret` is a well-formed relay-auth TOTP secret: a hex string of at\n * least {@link MIN_SECRET_HEX_CHARS} characters with an even length (an odd\n * length would have its trailing nibble silently dropped by `Buffer.from(...,\n * 'hex')`, weakening the key without warning).\n *\n * Pure predicate so callers can test the validation independently of the\n * fail-fast side effect in {@link assertRelayAuthConfigured}.\n *\n * SECRET-HANDLING: returns only a boolean — the input value is never returned,\n * logged, or echoed.\n */\nexport function isValidRelayAuthSecret(secret: string | undefined): secret is string {\n if (secret === undefined || secret === '') return false;\n if (secret.length < MIN_SECRET_HEX_CHARS) return false;\n if (secret.length % 2 !== 0) return false;\n return HEX_RE.test(secret);\n}\n\n/**\n * Fail-fast guard enforcing that a relay-auth TOTP secret is configured before\n * a public-internet-exposed relay is booted (issue #250).\n *\n * Relay-auth (the §4 Layer C TOTP gate) is the only fail-fast layer that closes\n * the real gap: a leaked `wss://…trycloudflare.com` URL otherwise lets a third\n * party attach a debugger to a dogfood/live mini-app. Without a secret the relay\n * comes up unauthenticated, so this guard is called at every relay-boot site —\n * `bootRelayFamily` (intoss env 3/4) and `bootExternalRelayFamily` (env-2 PWA),\n * both eager and lazy. Local-only sessions never boot a relay and so never reach\n * this guard, matching the issue's exemption for non-relay debugging.\n *\n * Throws when the secret is unset, empty, too short, or not a valid hex string.\n * The thrown message is the bin entry's fatal stderr (see `cli.ts` `main().catch`)\n * — the same fatal model as the missing-`AIT_RELAY_BASE_URL` path.\n *\n * SECRET-HANDLING: the env value is read once, passed ONLY to the boolean\n * predicate, and never logged. The thrown message names the requirement, never\n * the value, its length, or any derived fragment.\n *\n * @param env - Environment to read from. Defaults to `process.env`; injectable\n * for tests so they never mutate the real process environment.\n */\nexport function assertRelayAuthConfigured(env: NodeJS.ProcessEnv = process.env): void {\n if (!isValidRelayAuthSecret(env.AIT_DEBUG_TOTP_SECRET)) {\n throw new Error(RELAY_AUTH_SECRET_MISSING_MESSAGE);\n }\n}\n\n/**\n * Reads `AIT_DEBUG_TOTP_SECRET` from `process.env` at runtime and builds a\n * `verifyAuth` predicate for the Chii relay's WebSocket upgrade gate.\n *\n * The predicate checks the `at` query parameter against the current and\n * adjacent TOTP time steps (±1 skew) using {@link verifyTotp}.\n *\n * Returns `undefined` when the env var is not set — callers treat that as\n * \"auth disabled\" (no predicate registered on the relay). Note that since\n * issue #250 the secret is MANDATORY at every relay-boot site (enforced by\n * {@link assertRelayAuthConfigured} BEFORE the relay starts), so in production\n * this never returns `undefined` for a relay that actually boots; the\n * `undefined` branch only matters for the no-relay local path and tests.\n *\n * Lives here (not in the MCP server) so the unplugin's env-2 relay can wire the\n * same gate without importing the heavy MCP server module graph. Re-exported\n * from `debug-server.ts` for back-compat.\n *\n * SECRET-HANDLING: The secret value read from env is captured in a closure and\n * is NEVER written to any log, error message, or process output.\n */\nexport function buildRelayVerifyAuth(\n env: NodeJS.ProcessEnv = process.env,\n): ((req: import('node:http').IncomingMessage) => boolean) | undefined {\n const secret = env.AIT_DEBUG_TOTP_SECRET;\n if (!secret) return undefined;\n\n return (req) => {\n // Parse the `at` query param from the upgrade request URL.\n // req.url is the raw request path + query, e.g. `/client/id?target=…&at=123456`\n const rawUrl = req.url ?? '';\n const qIndex = rawUrl.indexOf('?');\n const queryStr = qIndex === -1 ? '' : rawUrl.slice(qIndex + 1);\n const params = new URLSearchParams(queryStr);\n const code = params.get('at') ?? '';\n\n // Do NOT log `code`, `secret`, or any derived value here.\n return verifyTotp(secret, code);\n };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA8BA,MAAM,YAAY;;AAGlB,MAAM,SAAS;;;;;;;;;;AAWf,SAAgB,aAAa,QAAgB,OAAe,KAAK,KAAK,EAAU;CAC9E,MAAM,MAAM,OAAO,KAAK,QAAQ,MAAM;CAGtC,MAAM,UAAU,KAAK,IAAI,GAAG,KAAK,MAAM,OAAO,MAAO,UAAU,CAAC;CAGhE,MAAM,aAAa,OAAO,MAAM,EAAE;CAGlC,MAAM,KAAK,KAAK,MAAM,UAAU,WAAY;CAC5C,MAAM,KAAK,YAAY;AACvB,YAAW,cAAc,IAAI,EAAE;AAC/B,YAAW,cAAc,IAAI,EAAE;CAE/B,MAAM,MAAM,WAAW,QAAQ,IAAI,CAAC,OAAO,WAAW,CAAC,QAAQ;CAG/D,MAAM,SAAS,IAAI,MAAM;AAQzB,WANI,IAAI,UAAU,QAAS,MACvB,IAAI,SAAS,KAAK,QAAS,MAC3B,IAAI,SAAS,KAAK,QAAS,IAC5B,IAAI,SAAS,KAAK,OAEC,MAAM,QACjB,UAAU,CAAC,SAAS,QAAQ,IAAI;;;;;;;;;;;;;;;;AAiB7C,SAAgB,WACd,QACA,MACA,OAAe,KAAK,KAAK,EACzB,OAAe,GACN;CACT,MAAM,aAAa,OAAO,KAAK,CAAC,SAAS,QAAQ,IAAI;AACrD,KAAI,WAAW,WAAW,UAAU,CAAC,UAAU,KAAK,WAAW,CAC7D,QAAO;CAGT,MAAM,eAAe,OAAO,KAAK,YAAY,OAAO;AAEpD,MAAK,IAAI,QAAQ,CAAC,MAAM,SAAS,MAAM,SAAS;EAE9C,MAAM,WAAW,aAAa,QADb,OAAO,QAAQ,YAAY,IACG;AAE/C,MAAI,gBADgB,OAAO,KAAK,UAAU,OAAO,EAChB,aAAa,CAC5C,QAAO;;AAIX,QAAO;;;;;;;;;;AAWT,MAAM,uBAAuB;;AAG7B,MAAM,SAAS;;;;;;;;;;;;;AAcf,MAAa,oCAAoC;CAC/C;CACA;CACA;CACA;CACA;CACD,CAAC,KAAK,KAAK;;;;;;;;;;;;;AAcZ,SAAgB,uBAAuB,QAA8C;AACnF,KAAI,WAAW,KAAA,KAAa,WAAW,GAAI,QAAO;AAClD,KAAI,OAAO,SAAS,qBAAsB,QAAO;AACjD,KAAI,OAAO,SAAS,MAAM,EAAG,QAAO;AACpC,QAAO,OAAO,KAAK,OAAO;;;;;;;;;;;;;;;;;;;;;;;;;AA0B5B,SAAgB,0BAA0B,MAAyB,QAAQ,KAAW;AACpF,KAAI,CAAC,uBAAuB,IAAI,sBAAsB,CACpD,OAAM,IAAI,MAAM,kCAAkC;;;;;;;;;;;;;;;;;;;;;;;AAyBtD,SAAgB,qBACd,MAAyB,QAAQ,KACoC;CACrE,MAAM,SAAS,IAAI;AACnB,KAAI,CAAC,OAAQ,QAAO,KAAA;AAEpB,SAAQ,QAAQ;EAGd,MAAM,SAAS,IAAI,OAAO;EAC1B,MAAM,SAAS,OAAO,QAAQ,IAAI;EAClC,MAAM,WAAW,WAAW,KAAK,KAAK,OAAO,MAAM,SAAS,EAAE;AAK9D,SAAO,WAAW,QAJH,IAAI,gBAAgB,SAAS,CACxB,IAAI,KAAK,IAAI,GAGF"}
|
|
1
|
+
{"version":3,"file":"totp-BIrJHsQn.js","names":[],"sources":["../src/mcp/totp.ts"],"sourcesContent":["/**\n * RFC 6238 TOTP implementation (Node.js, node:crypto only).\n *\n * External TOTP libraries (otplib, speakeasy, …) are intentionally NOT used\n * to keep the dependency surface minimal. This hand-roll is ~30 lines and\n * covers exactly what relay-side auth needs.\n *\n * Algorithm summary (RFC 6238 + RFC 4226):\n * T = floor(now / 30) — 30-second time step counter\n * K = Buffer.from(secret, 'hex') — shared secret (raw bytes, hex-encoded)\n * MAC = HMAC-SHA1(K, T as 8-byte big-endian uint64)\n * offset = MAC[19] & 0x0f\n * code = (MAC[offset..offset+4] & 0x7fffffff) % 10^6 — 6 digits\n *\n * Security note (keep this comment accurate):\n * The baked-in secret in a dogfood build is extractable from the bundle by a\n * determined reverse engineer. This mechanism raises the bar from\n * \"anyone with the URL\" to \"URL + bundle extraction + live TOTP calculation\".\n * Casual URL leaks (Slack paste, QR screenshot, shoulder-surfing) are\n * blocked; deliberate reverse engineering is not. See threat model in\n * src/mcp/chii-relay.ts and umbrella CLAUDE.md §4.\n *\n * SECRET-HANDLING: secret values and computed codes MUST NOT appear in any\n * log, error message, or string visible outside this module. Only boolean\n * pass/fail and reason enum values are safe to surface.\n */\n\nimport { createHmac, timingSafeEqual } from 'node:crypto';\n\n/** Time step window in seconds (RFC 6238 default). */\nconst TIME_STEP = 30;\n\n/** Number of digits in the generated code. */\nconst DIGITS = 6;\n\n/**\n * Derives a 6-digit TOTP code from a hex-encoded secret at the given wall-\n * clock time.\n *\n * @param secret - The shared secret as a hex string (e.g. 64 hex chars = 32\n * bytes). Must be the output of `generateAttachToken()` or compatible.\n * @param when - Unix timestamp in milliseconds. Defaults to `Date.now()`.\n * @returns A zero-padded 6-digit decimal string, e.g. `\"042193\"`.\n */\nexport function generateTotp(secret: string, when: number = Date.now()): string {\n const key = Buffer.from(secret, 'hex');\n // Clamp to 0 so negative timestamps (e.g. in ±skew checks near epoch) do not\n // produce a negative counter, which would cause writeUInt32BE to throw.\n const counter = Math.max(0, Math.floor(when / 1000 / TIME_STEP));\n\n // Encode counter as 8-byte big-endian unsigned integer.\n const counterBuf = Buffer.alloc(8);\n // JavaScript numbers are safe integers up to 2^53; counter is ~7.5×10^10 at\n // year 9999 — well within safe range so standard bitwise ops are fine.\n const hi = Math.floor(counter / 0x100000000);\n const lo = counter >>> 0;\n counterBuf.writeUInt32BE(hi, 0);\n counterBuf.writeUInt32BE(lo, 4);\n\n const mac = createHmac('sha1', key).update(counterBuf).digest();\n\n // Dynamic truncation (RFC 4226 §5.4).\n const offset = mac[19] & 0x0f;\n const binCode =\n ((mac[offset] & 0x7f) << 24) |\n ((mac[offset + 1] & 0xff) << 16) |\n ((mac[offset + 2] & 0xff) << 8) |\n (mac[offset + 3] & 0xff);\n\n const otp = binCode % 10 ** DIGITS;\n return otp.toString().padStart(DIGITS, '0');\n}\n\n/**\n * Verifies a TOTP code against the secret, accepting ±`skew` time steps to\n * tolerate clock drift between the relay host and the client device.\n *\n * Uses `timingSafeEqual` for constant-time comparison to prevent timing\n * side-channel attacks.\n *\n * @param secret - Hex-encoded shared secret.\n * @param code - The 6-digit code to verify (string or numeric).\n * @param when - Unix timestamp in milliseconds. Defaults to `Date.now()`.\n * @param skew - Number of adjacent steps to accept on either side. Default 1\n * (accepts T-1, T, T+1 — a 90-second acceptance window).\n * @returns `true` if the code matches any accepted step, `false` otherwise.\n */\nexport function verifyTotp(\n secret: string,\n code: string,\n when: number = Date.now(),\n skew: number = 1,\n): boolean {\n const normalised = String(code).padStart(DIGITS, '0');\n if (normalised.length !== DIGITS || !/^\\d{6}$/.test(normalised)) {\n return false;\n }\n\n const candidateBuf = Buffer.from(normalised, 'utf8');\n\n for (let delta = -skew; delta <= skew; delta++) {\n const stepWhen = when + delta * TIME_STEP * 1000;\n const expected = generateTotp(secret, stepWhen);\n const expectedBuf = Buffer.from(expected, 'utf8');\n if (timingSafeEqual(expectedBuf, candidateBuf)) {\n return true;\n }\n }\n\n return false;\n}\n\n/**\n * Minimum length (in hex characters) accepted for `AIT_DEBUG_TOTP_SECRET`.\n *\n * The secret is hex-encoded (see {@link generateTotp} — `Buffer.from(secret,\n * 'hex')`). 32 hex chars = 16 bytes = 128 bits, the floor for an HMAC-SHA1 key\n * we are willing to gate a public relay behind. `generateAttachToken()` emits\n * 64 hex chars (32 bytes), comfortably above this bar.\n */\nconst MIN_SECRET_HEX_CHARS = 32;\n\n/** Hex string: one or more hex digits, case-insensitive (RFC 4648 base16). */\nconst HEX_RE = /^[0-9a-fA-F]+$/;\n\n/**\n * Human-facing guidance printed when {@link assertRelayAuthConfigured} fails.\n *\n * SECRET-HANDLING: this message states only the REQUIREMENT (≥32 hex chars) and\n * how to mint one. It NEVER echoes the configured value, its length, or any\n * fragment derived from it — see {@link assertRelayAuthConfigured}.\n *\n * Note on encoding: the secret is hex (base16), not base32 — `generateTotp`\n * decodes it with `Buffer.from(secret, 'hex')`. A base32 string would be\n * silently mis-decoded and every TOTP code would fail to match, so the minting\n * command emits hex.\n */\nexport const RELAY_AUTH_SECRET_MISSING_MESSAGE = [\n '[ait-debug] AIT_DEBUG_TOTP_SECRET이 필수입니다. 32자 이상 16진수(hex) 문자열을 설정하세요.',\n '발급: openssl rand -hex 32',\n '데몬은 start_debug의 projectRoot 인자로 받은 디렉토리에서 .ait_relay 파일을 읽어 이 시크릿을 채웁니다.',\n '프로젝트에서 pnpm dev를 한 번 띄우면 unplugin이 .ait_relay를 자동 생성하니, projectRoot를 전달하세요.',\n '자세히: https://docs.aitc.dev/guides/relay-auth-totp',\n].join('\\n');\n\n/**\n * Whether `secret` is a well-formed relay-auth TOTP secret: a hex string of at\n * least {@link MIN_SECRET_HEX_CHARS} characters with an even length (an odd\n * length would have its trailing nibble silently dropped by `Buffer.from(...,\n * 'hex')`, weakening the key without warning).\n *\n * Pure predicate so callers can test the validation independently of the\n * fail-fast side effect in {@link assertRelayAuthConfigured}.\n *\n * SECRET-HANDLING: returns only a boolean — the input value is never returned,\n * logged, or echoed.\n */\nexport function isValidRelayAuthSecret(secret: string | undefined): secret is string {\n if (secret === undefined || secret === '') return false;\n if (secret.length < MIN_SECRET_HEX_CHARS) return false;\n if (secret.length % 2 !== 0) return false;\n return HEX_RE.test(secret);\n}\n\n/**\n * Fail-fast guard enforcing that a relay-auth TOTP secret is configured before\n * a public-internet-exposed relay is booted (issue #250).\n *\n * Relay-auth (the §4 Layer C TOTP gate) is the only fail-fast layer that closes\n * the real gap: a leaked `wss://…trycloudflare.com` URL otherwise lets a third\n * party attach a debugger to a dogfood/live mini-app. Without a secret the relay\n * comes up unauthenticated, so this guard is called at every relay-boot site —\n * `bootRelayFamily` (intoss env 3/4) and `bootExternalRelayFamily` (env-2 PWA),\n * both eager and lazy. Local-only sessions never boot a relay and so never reach\n * this guard, matching the issue's exemption for non-relay debugging.\n *\n * Throws when the secret is unset, empty, too short, or not a valid hex string.\n * The thrown message is the bin entry's fatal stderr (see `cli.ts` `main().catch`)\n * — the same fatal model as the missing-`AIT_RELAY_BASE_URL` path.\n *\n * SECRET-HANDLING: the env value is read once, passed ONLY to the boolean\n * predicate, and never logged. The thrown message names the requirement, never\n * the value, its length, or any derived fragment.\n *\n * @param env - Environment to read from. Defaults to `process.env`; injectable\n * for tests so they never mutate the real process environment.\n */\nexport function assertRelayAuthConfigured(env: NodeJS.ProcessEnv = process.env): void {\n if (!isValidRelayAuthSecret(env.AIT_DEBUG_TOTP_SECRET)) {\n throw new Error(RELAY_AUTH_SECRET_MISSING_MESSAGE);\n }\n}\n\n/**\n * Reads `AIT_DEBUG_TOTP_SECRET` from `process.env` at runtime and builds a\n * `verifyAuth` predicate for the Chii relay's WebSocket upgrade gate.\n *\n * The predicate checks the `at` query parameter against the current and\n * adjacent TOTP time steps (±1 skew) using {@link verifyTotp}.\n *\n * Returns `undefined` when the env var is not set — callers treat that as\n * \"auth disabled\" (no predicate registered on the relay). Note that since\n * issue #250 the secret is MANDATORY at every relay-boot site (enforced by\n * {@link assertRelayAuthConfigured} BEFORE the relay starts), so in production\n * this never returns `undefined` for a relay that actually boots; the\n * `undefined` branch only matters for the no-relay local path and tests.\n *\n * Lives here (not in the MCP server) so the unplugin's env-2 relay can wire the\n * same gate without importing the heavy MCP server module graph. Re-exported\n * from `debug-server.ts` for back-compat.\n *\n * SECRET-HANDLING: The secret value read from env is captured in a closure and\n * is NEVER written to any log, error message, or process output.\n */\nexport function buildRelayVerifyAuth(\n env: NodeJS.ProcessEnv = process.env,\n): ((req: import('node:http').IncomingMessage) => boolean) | undefined {\n const secret = env.AIT_DEBUG_TOTP_SECRET;\n if (!secret) return undefined;\n\n return (req) => {\n // Parse the `at` query param from the upgrade request URL.\n // req.url is the raw request path + query, e.g. `/client/id?target=…&at=123456`\n const rawUrl = req.url ?? '';\n const qIndex = rawUrl.indexOf('?');\n const queryStr = qIndex === -1 ? '' : rawUrl.slice(qIndex + 1);\n const params = new URLSearchParams(queryStr);\n const code = params.get('at') ?? '';\n\n // Do NOT log `code`, `secret`, or any derived value here.\n return verifyTotp(secret, code);\n };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA8BA,MAAM,YAAY;;AAGlB,MAAM,SAAS;;;;;;;;;;AAWf,SAAgB,aAAa,QAAgB,OAAe,KAAK,KAAK,EAAU;CAC9E,MAAM,MAAM,OAAO,KAAK,QAAQ,MAAM;CAGtC,MAAM,UAAU,KAAK,IAAI,GAAG,KAAK,MAAM,OAAO,MAAO,UAAU,CAAC;CAGhE,MAAM,aAAa,OAAO,MAAM,EAAE;CAGlC,MAAM,KAAK,KAAK,MAAM,UAAU,WAAY;CAC5C,MAAM,KAAK,YAAY;AACvB,YAAW,cAAc,IAAI,EAAE;AAC/B,YAAW,cAAc,IAAI,EAAE;CAE/B,MAAM,MAAM,WAAW,QAAQ,IAAI,CAAC,OAAO,WAAW,CAAC,QAAQ;CAG/D,MAAM,SAAS,IAAI,MAAM;AAQzB,WANI,IAAI,UAAU,QAAS,MACvB,IAAI,SAAS,KAAK,QAAS,MAC3B,IAAI,SAAS,KAAK,QAAS,IAC5B,IAAI,SAAS,KAAK,OAEC,MAAM,QACjB,UAAU,CAAC,SAAS,QAAQ,IAAI;;;;;;;;;;;;;;;;AAiB7C,SAAgB,WACd,QACA,MACA,OAAe,KAAK,KAAK,EACzB,OAAe,GACN;CACT,MAAM,aAAa,OAAO,KAAK,CAAC,SAAS,QAAQ,IAAI;AACrD,KAAI,WAAW,WAAW,UAAU,CAAC,UAAU,KAAK,WAAW,CAC7D,QAAO;CAGT,MAAM,eAAe,OAAO,KAAK,YAAY,OAAO;AAEpD,MAAK,IAAI,QAAQ,CAAC,MAAM,SAAS,MAAM,SAAS;EAE9C,MAAM,WAAW,aAAa,QADb,OAAO,QAAQ,YAAY,IACG;AAE/C,MAAI,gBADgB,OAAO,KAAK,UAAU,OAAO,EAChB,aAAa,CAC5C,QAAO;;AAIX,QAAO;;;;;;;;;;AAWT,MAAM,uBAAuB;;AAG7B,MAAM,SAAS;;;;;;;;;;;;;AAcf,MAAa,oCAAoC;CAC/C;CACA;CACA;CACA;CACA;CACD,CAAC,KAAK,KAAK;;;;;;;;;;;;;AAcZ,SAAgB,uBAAuB,QAA8C;AACnF,KAAI,WAAW,KAAA,KAAa,WAAW,GAAI,QAAO;AAClD,KAAI,OAAO,SAAS,qBAAsB,QAAO;AACjD,KAAI,OAAO,SAAS,MAAM,EAAG,QAAO;AACpC,QAAO,OAAO,KAAK,OAAO;;;;;;;;;;;;;;;;;;;;;;;;;AA0B5B,SAAgB,0BAA0B,MAAyB,QAAQ,KAAW;AACpF,KAAI,CAAC,uBAAuB,IAAI,sBAAsB,CACpD,OAAM,IAAI,MAAM,kCAAkC;;;;;;;;;;;;;;;;;;;;;;;AAyBtD,SAAgB,qBACd,MAAyB,QAAQ,KACoC;CACrE,MAAM,SAAS,IAAI;AACnB,KAAI,CAAC,OAAQ,QAAO,KAAA;AAEpB,SAAQ,QAAQ;EAGd,MAAM,SAAS,IAAI,OAAO;EAC1B,MAAM,SAAS,OAAO,QAAQ,IAAI;EAClC,MAAM,WAAW,WAAW,KAAK,KAAK,OAAO,MAAM,SAAS,EAAE;AAK9D,SAAO,WAAW,QAJH,IAAI,gBAAgB,SAAS,CACxB,IAAI,KAAK,IAAI,GAGF"}
|
|
@@ -181,6 +181,6 @@ function buildRelayVerifyAuth(env = process.env) {
|
|
|
181
181
|
};
|
|
182
182
|
}
|
|
183
183
|
//#endregion
|
|
184
|
-
export { assertRelayAuthConfigured, buildRelayVerifyAuth, isValidRelayAuthSecret };
|
|
184
|
+
export { assertRelayAuthConfigured, buildRelayVerifyAuth, generateTotp, isValidRelayAuthSecret };
|
|
185
185
|
|
|
186
|
-
//# sourceMappingURL=totp-
|
|
186
|
+
//# sourceMappingURL=totp-BjtKFt88.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"totp-BkP5yU2K.js","names":[],"sources":["../src/mcp/totp.ts"],"sourcesContent":["/**\n * RFC 6238 TOTP implementation (Node.js, node:crypto only).\n *\n * External TOTP libraries (otplib, speakeasy, …) are intentionally NOT used\n * to keep the dependency surface minimal. This hand-roll is ~30 lines and\n * covers exactly what relay-side auth needs.\n *\n * Algorithm summary (RFC 6238 + RFC 4226):\n * T = floor(now / 30) — 30-second time step counter\n * K = Buffer.from(secret, 'hex') — shared secret (raw bytes, hex-encoded)\n * MAC = HMAC-SHA1(K, T as 8-byte big-endian uint64)\n * offset = MAC[19] & 0x0f\n * code = (MAC[offset..offset+4] & 0x7fffffff) % 10^6 — 6 digits\n *\n * Security note (keep this comment accurate):\n * The baked-in secret in a dogfood build is extractable from the bundle by a\n * determined reverse engineer. This mechanism raises the bar from\n * \"anyone with the URL\" to \"URL + bundle extraction + live TOTP calculation\".\n * Casual URL leaks (Slack paste, QR screenshot, shoulder-surfing) are\n * blocked; deliberate reverse engineering is not. See threat model in\n * src/mcp/chii-relay.ts and umbrella CLAUDE.md §4.\n *\n * SECRET-HANDLING: secret values and computed codes MUST NOT appear in any\n * log, error message, or string visible outside this module. Only boolean\n * pass/fail and reason enum values are safe to surface.\n */\n\nimport { createHmac, timingSafeEqual } from 'node:crypto';\n\n/** Time step window in seconds (RFC 6238 default). */\nconst TIME_STEP = 30;\n\n/** Number of digits in the generated code. */\nconst DIGITS = 6;\n\n/**\n * Derives a 6-digit TOTP code from a hex-encoded secret at the given wall-\n * clock time.\n *\n * @param secret - The shared secret as a hex string (e.g. 64 hex chars = 32\n * bytes). Must be the output of `generateAttachToken()` or compatible.\n * @param when - Unix timestamp in milliseconds. Defaults to `Date.now()`.\n * @returns A zero-padded 6-digit decimal string, e.g. `\"042193\"`.\n */\nexport function generateTotp(secret: string, when: number = Date.now()): string {\n const key = Buffer.from(secret, 'hex');\n // Clamp to 0 so negative timestamps (e.g. in ±skew checks near epoch) do not\n // produce a negative counter, which would cause writeUInt32BE to throw.\n const counter = Math.max(0, Math.floor(when / 1000 / TIME_STEP));\n\n // Encode counter as 8-byte big-endian unsigned integer.\n const counterBuf = Buffer.alloc(8);\n // JavaScript numbers are safe integers up to 2^53; counter is ~7.5×10^10 at\n // year 9999 — well within safe range so standard bitwise ops are fine.\n const hi = Math.floor(counter / 0x100000000);\n const lo = counter >>> 0;\n counterBuf.writeUInt32BE(hi, 0);\n counterBuf.writeUInt32BE(lo, 4);\n\n const mac = createHmac('sha1', key).update(counterBuf).digest();\n\n // Dynamic truncation (RFC 4226 §5.4).\n const offset = mac[19] & 0x0f;\n const binCode =\n ((mac[offset] & 0x7f) << 24) |\n ((mac[offset + 1] & 0xff) << 16) |\n ((mac[offset + 2] & 0xff) << 8) |\n (mac[offset + 3] & 0xff);\n\n const otp = binCode % 10 ** DIGITS;\n return otp.toString().padStart(DIGITS, '0');\n}\n\n/**\n * Verifies a TOTP code against the secret, accepting ±`skew` time steps to\n * tolerate clock drift between the relay host and the client device.\n *\n * Uses `timingSafeEqual` for constant-time comparison to prevent timing\n * side-channel attacks.\n *\n * @param secret - Hex-encoded shared secret.\n * @param code - The 6-digit code to verify (string or numeric).\n * @param when - Unix timestamp in milliseconds. Defaults to `Date.now()`.\n * @param skew - Number of adjacent steps to accept on either side. Default 1\n * (accepts T-1, T, T+1 — a 90-second acceptance window).\n * @returns `true` if the code matches any accepted step, `false` otherwise.\n */\nexport function verifyTotp(\n secret: string,\n code: string,\n when: number = Date.now(),\n skew: number = 1,\n): boolean {\n const normalised = String(code).padStart(DIGITS, '0');\n if (normalised.length !== DIGITS || !/^\\d{6}$/.test(normalised)) {\n return false;\n }\n\n const candidateBuf = Buffer.from(normalised, 'utf8');\n\n for (let delta = -skew; delta <= skew; delta++) {\n const stepWhen = when + delta * TIME_STEP * 1000;\n const expected = generateTotp(secret, stepWhen);\n const expectedBuf = Buffer.from(expected, 'utf8');\n if (timingSafeEqual(expectedBuf, candidateBuf)) {\n return true;\n }\n }\n\n return false;\n}\n\n/**\n * Minimum length (in hex characters) accepted for `AIT_DEBUG_TOTP_SECRET`.\n *\n * The secret is hex-encoded (see {@link generateTotp} — `Buffer.from(secret,\n * 'hex')`). 32 hex chars = 16 bytes = 128 bits, the floor for an HMAC-SHA1 key\n * we are willing to gate a public relay behind. `generateAttachToken()` emits\n * 64 hex chars (32 bytes), comfortably above this bar.\n */\nconst MIN_SECRET_HEX_CHARS = 32;\n\n/** Hex string: one or more hex digits, case-insensitive (RFC 4648 base16). */\nconst HEX_RE = /^[0-9a-fA-F]+$/;\n\n/**\n * Human-facing guidance printed when {@link assertRelayAuthConfigured} fails.\n *\n * SECRET-HANDLING: this message states only the REQUIREMENT (≥32 hex chars) and\n * how to mint one. It NEVER echoes the configured value, its length, or any\n * fragment derived from it — see {@link assertRelayAuthConfigured}.\n *\n * Note on encoding: the secret is hex (base16), not base32 — `generateTotp`\n * decodes it with `Buffer.from(secret, 'hex')`. A base32 string would be\n * silently mis-decoded and every TOTP code would fail to match, so the minting\n * command emits hex.\n */\nexport const RELAY_AUTH_SECRET_MISSING_MESSAGE = [\n '[ait-debug] AIT_DEBUG_TOTP_SECRET이 필수입니다. 32자 이상 16진수(hex) 문자열을 설정하세요.',\n '발급: openssl rand -hex 32',\n '데몬은 start_debug의 projectRoot 인자로 받은 디렉토리에서 .ait_relay 파일을 읽어 이 시크릿을 채웁니다.',\n '프로젝트에서 pnpm dev를 한 번 띄우면 unplugin이 .ait_relay를 자동 생성하니, projectRoot를 전달하세요.',\n '자세히: https://docs.aitc.dev/guides/relay-auth-totp',\n].join('\\n');\n\n/**\n * Whether `secret` is a well-formed relay-auth TOTP secret: a hex string of at\n * least {@link MIN_SECRET_HEX_CHARS} characters with an even length (an odd\n * length would have its trailing nibble silently dropped by `Buffer.from(...,\n * 'hex')`, weakening the key without warning).\n *\n * Pure predicate so callers can test the validation independently of the\n * fail-fast side effect in {@link assertRelayAuthConfigured}.\n *\n * SECRET-HANDLING: returns only a boolean — the input value is never returned,\n * logged, or echoed.\n */\nexport function isValidRelayAuthSecret(secret: string | undefined): secret is string {\n if (secret === undefined || secret === '') return false;\n if (secret.length < MIN_SECRET_HEX_CHARS) return false;\n if (secret.length % 2 !== 0) return false;\n return HEX_RE.test(secret);\n}\n\n/**\n * Fail-fast guard enforcing that a relay-auth TOTP secret is configured before\n * a public-internet-exposed relay is booted (issue #250).\n *\n * Relay-auth (the §4 Layer C TOTP gate) is the only fail-fast layer that closes\n * the real gap: a leaked `wss://…trycloudflare.com` URL otherwise lets a third\n * party attach a debugger to a dogfood/live mini-app. Without a secret the relay\n * comes up unauthenticated, so this guard is called at every relay-boot site —\n * `bootRelayFamily` (intoss env 3/4) and `bootExternalRelayFamily` (env-2 PWA),\n * both eager and lazy. Local-only sessions never boot a relay and so never reach\n * this guard, matching the issue's exemption for non-relay debugging.\n *\n * Throws when the secret is unset, empty, too short, or not a valid hex string.\n * The thrown message is the bin entry's fatal stderr (see `cli.ts` `main().catch`)\n * — the same fatal model as the missing-`AIT_RELAY_BASE_URL` path.\n *\n * SECRET-HANDLING: the env value is read once, passed ONLY to the boolean\n * predicate, and never logged. The thrown message names the requirement, never\n * the value, its length, or any derived fragment.\n *\n * @param env - Environment to read from. Defaults to `process.env`; injectable\n * for tests so they never mutate the real process environment.\n */\nexport function assertRelayAuthConfigured(env: NodeJS.ProcessEnv = process.env): void {\n if (!isValidRelayAuthSecret(env.AIT_DEBUG_TOTP_SECRET)) {\n throw new Error(RELAY_AUTH_SECRET_MISSING_MESSAGE);\n }\n}\n\n/**\n * Reads `AIT_DEBUG_TOTP_SECRET` from `process.env` at runtime and builds a\n * `verifyAuth` predicate for the Chii relay's WebSocket upgrade gate.\n *\n * The predicate checks the `at` query parameter against the current and\n * adjacent TOTP time steps (±1 skew) using {@link verifyTotp}.\n *\n * Returns `undefined` when the env var is not set — callers treat that as\n * \"auth disabled\" (no predicate registered on the relay). Note that since\n * issue #250 the secret is MANDATORY at every relay-boot site (enforced by\n * {@link assertRelayAuthConfigured} BEFORE the relay starts), so in production\n * this never returns `undefined` for a relay that actually boots; the\n * `undefined` branch only matters for the no-relay local path and tests.\n *\n * Lives here (not in the MCP server) so the unplugin's env-2 relay can wire the\n * same gate without importing the heavy MCP server module graph. Re-exported\n * from `debug-server.ts` for back-compat.\n *\n * SECRET-HANDLING: The secret value read from env is captured in a closure and\n * is NEVER written to any log, error message, or process output.\n */\nexport function buildRelayVerifyAuth(\n env: NodeJS.ProcessEnv = process.env,\n): ((req: import('node:http').IncomingMessage) => boolean) | undefined {\n const secret = env.AIT_DEBUG_TOTP_SECRET;\n if (!secret) return undefined;\n\n return (req) => {\n // Parse the `at` query param from the upgrade request URL.\n // req.url is the raw request path + query, e.g. `/client/id?target=…&at=123456`\n const rawUrl = req.url ?? '';\n const qIndex = rawUrl.indexOf('?');\n const queryStr = qIndex === -1 ? '' : rawUrl.slice(qIndex + 1);\n const params = new URLSearchParams(queryStr);\n const code = params.get('at') ?? '';\n\n // Do NOT log `code`, `secret`, or any derived value here.\n return verifyTotp(secret, code);\n };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA8BA,MAAM,YAAY;;AAGlB,MAAM,SAAS;;;;;;;;;;AAWf,SAAgB,aAAa,QAAgB,OAAe,KAAK,KAAK,EAAU;CAC9E,MAAM,MAAM,OAAO,KAAK,QAAQ,MAAM;CAGtC,MAAM,UAAU,KAAK,IAAI,GAAG,KAAK,MAAM,OAAO,MAAO,UAAU,CAAC;CAGhE,MAAM,aAAa,OAAO,MAAM,EAAE;CAGlC,MAAM,KAAK,KAAK,MAAM,UAAU,WAAY;CAC5C,MAAM,KAAK,YAAY;AACvB,YAAW,cAAc,IAAI,EAAE;AAC/B,YAAW,cAAc,IAAI,EAAE;CAE/B,MAAM,MAAM,WAAW,QAAQ,IAAI,CAAC,OAAO,WAAW,CAAC,QAAQ;CAG/D,MAAM,SAAS,IAAI,MAAM;AAQzB,WANI,IAAI,UAAU,QAAS,MACvB,IAAI,SAAS,KAAK,QAAS,MAC3B,IAAI,SAAS,KAAK,QAAS,IAC5B,IAAI,SAAS,KAAK,OAEC,MAAM,QACjB,UAAU,CAAC,SAAS,QAAQ,IAAI;;;;;;;;;;;;;;;;AAiB7C,SAAgB,WACd,QACA,MACA,OAAe,KAAK,KAAK,EACzB,OAAe,GACN;CACT,MAAM,aAAa,OAAO,KAAK,CAAC,SAAS,QAAQ,IAAI;AACrD,KAAI,WAAW,WAAW,UAAU,CAAC,UAAU,KAAK,WAAW,CAC7D,QAAO;CAGT,MAAM,eAAe,OAAO,KAAK,YAAY,OAAO;AAEpD,MAAK,IAAI,QAAQ,CAAC,MAAM,SAAS,MAAM,SAAS;EAE9C,MAAM,WAAW,aAAa,QADb,OAAO,QAAQ,YAAY,IACG;AAE/C,MAAI,gBADgB,OAAO,KAAK,UAAU,OAAO,EAChB,aAAa,CAC5C,QAAO;;AAIX,QAAO;;;;;;;;;;AAWT,MAAM,uBAAuB;;AAG7B,MAAM,SAAS;;;;;;;;;;;;;AAcf,MAAa,oCAAoC;CAC/C;CACA;CACA;CACA;CACA;CACD,CAAC,KAAK,KAAK;;;;;;;;;;;;;AAcZ,SAAgB,uBAAuB,QAA8C;AACnF,KAAI,WAAW,KAAA,KAAa,WAAW,GAAI,QAAO;AAClD,KAAI,OAAO,SAAS,qBAAsB,QAAO;AACjD,KAAI,OAAO,SAAS,MAAM,EAAG,QAAO;AACpC,QAAO,OAAO,KAAK,OAAO;;;;;;;;;;;;;;;;;;;;;;;;;AA0B5B,SAAgB,0BAA0B,MAAyB,QAAQ,KAAW;AACpF,KAAI,CAAC,uBAAuB,IAAI,sBAAsB,CACpD,OAAM,IAAI,MAAM,kCAAkC;;;;;;;;;;;;;;;;;;;;;;;AAyBtD,SAAgB,qBACd,MAAyB,QAAQ,KACoC;CACrE,MAAM,SAAS,IAAI;AACnB,KAAI,CAAC,OAAQ,QAAO,KAAA;AAEpB,SAAQ,QAAQ;EAGd,MAAM,SAAS,IAAI,OAAO;EAC1B,MAAM,SAAS,OAAO,QAAQ,IAAI;EAClC,MAAM,WAAW,WAAW,KAAK,KAAK,OAAO,MAAM,SAAS,EAAE;AAK9D,SAAO,WAAW,QAJH,IAAI,gBAAgB,SAAS,CACxB,IAAI,KAAK,IAAI,GAGF"}
|
|
1
|
+
{"version":3,"file":"totp-BjtKFt88.js","names":[],"sources":["../src/mcp/totp.ts"],"sourcesContent":["/**\n * RFC 6238 TOTP implementation (Node.js, node:crypto only).\n *\n * External TOTP libraries (otplib, speakeasy, …) are intentionally NOT used\n * to keep the dependency surface minimal. This hand-roll is ~30 lines and\n * covers exactly what relay-side auth needs.\n *\n * Algorithm summary (RFC 6238 + RFC 4226):\n * T = floor(now / 30) — 30-second time step counter\n * K = Buffer.from(secret, 'hex') — shared secret (raw bytes, hex-encoded)\n * MAC = HMAC-SHA1(K, T as 8-byte big-endian uint64)\n * offset = MAC[19] & 0x0f\n * code = (MAC[offset..offset+4] & 0x7fffffff) % 10^6 — 6 digits\n *\n * Security note (keep this comment accurate):\n * The baked-in secret in a dogfood build is extractable from the bundle by a\n * determined reverse engineer. This mechanism raises the bar from\n * \"anyone with the URL\" to \"URL + bundle extraction + live TOTP calculation\".\n * Casual URL leaks (Slack paste, QR screenshot, shoulder-surfing) are\n * blocked; deliberate reverse engineering is not. See threat model in\n * src/mcp/chii-relay.ts and umbrella CLAUDE.md §4.\n *\n * SECRET-HANDLING: secret values and computed codes MUST NOT appear in any\n * log, error message, or string visible outside this module. Only boolean\n * pass/fail and reason enum values are safe to surface.\n */\n\nimport { createHmac, timingSafeEqual } from 'node:crypto';\n\n/** Time step window in seconds (RFC 6238 default). */\nconst TIME_STEP = 30;\n\n/** Number of digits in the generated code. */\nconst DIGITS = 6;\n\n/**\n * Derives a 6-digit TOTP code from a hex-encoded secret at the given wall-\n * clock time.\n *\n * @param secret - The shared secret as a hex string (e.g. 64 hex chars = 32\n * bytes). Must be the output of `generateAttachToken()` or compatible.\n * @param when - Unix timestamp in milliseconds. Defaults to `Date.now()`.\n * @returns A zero-padded 6-digit decimal string, e.g. `\"042193\"`.\n */\nexport function generateTotp(secret: string, when: number = Date.now()): string {\n const key = Buffer.from(secret, 'hex');\n // Clamp to 0 so negative timestamps (e.g. in ±skew checks near epoch) do not\n // produce a negative counter, which would cause writeUInt32BE to throw.\n const counter = Math.max(0, Math.floor(when / 1000 / TIME_STEP));\n\n // Encode counter as 8-byte big-endian unsigned integer.\n const counterBuf = Buffer.alloc(8);\n // JavaScript numbers are safe integers up to 2^53; counter is ~7.5×10^10 at\n // year 9999 — well within safe range so standard bitwise ops are fine.\n const hi = Math.floor(counter / 0x100000000);\n const lo = counter >>> 0;\n counterBuf.writeUInt32BE(hi, 0);\n counterBuf.writeUInt32BE(lo, 4);\n\n const mac = createHmac('sha1', key).update(counterBuf).digest();\n\n // Dynamic truncation (RFC 4226 §5.4).\n const offset = mac[19] & 0x0f;\n const binCode =\n ((mac[offset] & 0x7f) << 24) |\n ((mac[offset + 1] & 0xff) << 16) |\n ((mac[offset + 2] & 0xff) << 8) |\n (mac[offset + 3] & 0xff);\n\n const otp = binCode % 10 ** DIGITS;\n return otp.toString().padStart(DIGITS, '0');\n}\n\n/**\n * Verifies a TOTP code against the secret, accepting ±`skew` time steps to\n * tolerate clock drift between the relay host and the client device.\n *\n * Uses `timingSafeEqual` for constant-time comparison to prevent timing\n * side-channel attacks.\n *\n * @param secret - Hex-encoded shared secret.\n * @param code - The 6-digit code to verify (string or numeric).\n * @param when - Unix timestamp in milliseconds. Defaults to `Date.now()`.\n * @param skew - Number of adjacent steps to accept on either side. Default 1\n * (accepts T-1, T, T+1 — a 90-second acceptance window).\n * @returns `true` if the code matches any accepted step, `false` otherwise.\n */\nexport function verifyTotp(\n secret: string,\n code: string,\n when: number = Date.now(),\n skew: number = 1,\n): boolean {\n const normalised = String(code).padStart(DIGITS, '0');\n if (normalised.length !== DIGITS || !/^\\d{6}$/.test(normalised)) {\n return false;\n }\n\n const candidateBuf = Buffer.from(normalised, 'utf8');\n\n for (let delta = -skew; delta <= skew; delta++) {\n const stepWhen = when + delta * TIME_STEP * 1000;\n const expected = generateTotp(secret, stepWhen);\n const expectedBuf = Buffer.from(expected, 'utf8');\n if (timingSafeEqual(expectedBuf, candidateBuf)) {\n return true;\n }\n }\n\n return false;\n}\n\n/**\n * Minimum length (in hex characters) accepted for `AIT_DEBUG_TOTP_SECRET`.\n *\n * The secret is hex-encoded (see {@link generateTotp} — `Buffer.from(secret,\n * 'hex')`). 32 hex chars = 16 bytes = 128 bits, the floor for an HMAC-SHA1 key\n * we are willing to gate a public relay behind. `generateAttachToken()` emits\n * 64 hex chars (32 bytes), comfortably above this bar.\n */\nconst MIN_SECRET_HEX_CHARS = 32;\n\n/** Hex string: one or more hex digits, case-insensitive (RFC 4648 base16). */\nconst HEX_RE = /^[0-9a-fA-F]+$/;\n\n/**\n * Human-facing guidance printed when {@link assertRelayAuthConfigured} fails.\n *\n * SECRET-HANDLING: this message states only the REQUIREMENT (≥32 hex chars) and\n * how to mint one. It NEVER echoes the configured value, its length, or any\n * fragment derived from it — see {@link assertRelayAuthConfigured}.\n *\n * Note on encoding: the secret is hex (base16), not base32 — `generateTotp`\n * decodes it with `Buffer.from(secret, 'hex')`. A base32 string would be\n * silently mis-decoded and every TOTP code would fail to match, so the minting\n * command emits hex.\n */\nexport const RELAY_AUTH_SECRET_MISSING_MESSAGE = [\n '[ait-debug] AIT_DEBUG_TOTP_SECRET이 필수입니다. 32자 이상 16진수(hex) 문자열을 설정하세요.',\n '발급: openssl rand -hex 32',\n '데몬은 start_debug의 projectRoot 인자로 받은 디렉토리에서 .ait_relay 파일을 읽어 이 시크릿을 채웁니다.',\n '프로젝트에서 pnpm dev를 한 번 띄우면 unplugin이 .ait_relay를 자동 생성하니, projectRoot를 전달하세요.',\n '자세히: https://docs.aitc.dev/guides/relay-auth-totp',\n].join('\\n');\n\n/**\n * Whether `secret` is a well-formed relay-auth TOTP secret: a hex string of at\n * least {@link MIN_SECRET_HEX_CHARS} characters with an even length (an odd\n * length would have its trailing nibble silently dropped by `Buffer.from(...,\n * 'hex')`, weakening the key without warning).\n *\n * Pure predicate so callers can test the validation independently of the\n * fail-fast side effect in {@link assertRelayAuthConfigured}.\n *\n * SECRET-HANDLING: returns only a boolean — the input value is never returned,\n * logged, or echoed.\n */\nexport function isValidRelayAuthSecret(secret: string | undefined): secret is string {\n if (secret === undefined || secret === '') return false;\n if (secret.length < MIN_SECRET_HEX_CHARS) return false;\n if (secret.length % 2 !== 0) return false;\n return HEX_RE.test(secret);\n}\n\n/**\n * Fail-fast guard enforcing that a relay-auth TOTP secret is configured before\n * a public-internet-exposed relay is booted (issue #250).\n *\n * Relay-auth (the §4 Layer C TOTP gate) is the only fail-fast layer that closes\n * the real gap: a leaked `wss://…trycloudflare.com` URL otherwise lets a third\n * party attach a debugger to a dogfood/live mini-app. Without a secret the relay\n * comes up unauthenticated, so this guard is called at every relay-boot site —\n * `bootRelayFamily` (intoss env 3/4) and `bootExternalRelayFamily` (env-2 PWA),\n * both eager and lazy. Local-only sessions never boot a relay and so never reach\n * this guard, matching the issue's exemption for non-relay debugging.\n *\n * Throws when the secret is unset, empty, too short, or not a valid hex string.\n * The thrown message is the bin entry's fatal stderr (see `cli.ts` `main().catch`)\n * — the same fatal model as the missing-`AIT_RELAY_BASE_URL` path.\n *\n * SECRET-HANDLING: the env value is read once, passed ONLY to the boolean\n * predicate, and never logged. The thrown message names the requirement, never\n * the value, its length, or any derived fragment.\n *\n * @param env - Environment to read from. Defaults to `process.env`; injectable\n * for tests so they never mutate the real process environment.\n */\nexport function assertRelayAuthConfigured(env: NodeJS.ProcessEnv = process.env): void {\n if (!isValidRelayAuthSecret(env.AIT_DEBUG_TOTP_SECRET)) {\n throw new Error(RELAY_AUTH_SECRET_MISSING_MESSAGE);\n }\n}\n\n/**\n * Reads `AIT_DEBUG_TOTP_SECRET` from `process.env` at runtime and builds a\n * `verifyAuth` predicate for the Chii relay's WebSocket upgrade gate.\n *\n * The predicate checks the `at` query parameter against the current and\n * adjacent TOTP time steps (±1 skew) using {@link verifyTotp}.\n *\n * Returns `undefined` when the env var is not set — callers treat that as\n * \"auth disabled\" (no predicate registered on the relay). Note that since\n * issue #250 the secret is MANDATORY at every relay-boot site (enforced by\n * {@link assertRelayAuthConfigured} BEFORE the relay starts), so in production\n * this never returns `undefined` for a relay that actually boots; the\n * `undefined` branch only matters for the no-relay local path and tests.\n *\n * Lives here (not in the MCP server) so the unplugin's env-2 relay can wire the\n * same gate without importing the heavy MCP server module graph. Re-exported\n * from `debug-server.ts` for back-compat.\n *\n * SECRET-HANDLING: The secret value read from env is captured in a closure and\n * is NEVER written to any log, error message, or process output.\n */\nexport function buildRelayVerifyAuth(\n env: NodeJS.ProcessEnv = process.env,\n): ((req: import('node:http').IncomingMessage) => boolean) | undefined {\n const secret = env.AIT_DEBUG_TOTP_SECRET;\n if (!secret) return undefined;\n\n return (req) => {\n // Parse the `at` query param from the upgrade request URL.\n // req.url is the raw request path + query, e.g. `/client/id?target=…&at=123456`\n const rawUrl = req.url ?? '';\n const qIndex = rawUrl.indexOf('?');\n const queryStr = qIndex === -1 ? '' : rawUrl.slice(qIndex + 1);\n const params = new URLSearchParams(queryStr);\n const code = params.get('at') ?? '';\n\n // Do NOT log `code`, `secret`, or any derived value here.\n return verifyTotp(secret, code);\n };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA8BA,MAAM,YAAY;;AAGlB,MAAM,SAAS;;;;;;;;;;AAWf,SAAgB,aAAa,QAAgB,OAAe,KAAK,KAAK,EAAU;CAC9E,MAAM,MAAM,OAAO,KAAK,QAAQ,MAAM;CAGtC,MAAM,UAAU,KAAK,IAAI,GAAG,KAAK,MAAM,OAAO,MAAO,UAAU,CAAC;CAGhE,MAAM,aAAa,OAAO,MAAM,EAAE;CAGlC,MAAM,KAAK,KAAK,MAAM,UAAU,WAAY;CAC5C,MAAM,KAAK,YAAY;AACvB,YAAW,cAAc,IAAI,EAAE;AAC/B,YAAW,cAAc,IAAI,EAAE;CAE/B,MAAM,MAAM,WAAW,QAAQ,IAAI,CAAC,OAAO,WAAW,CAAC,QAAQ;CAG/D,MAAM,SAAS,IAAI,MAAM;AAQzB,WANI,IAAI,UAAU,QAAS,MACvB,IAAI,SAAS,KAAK,QAAS,MAC3B,IAAI,SAAS,KAAK,QAAS,IAC5B,IAAI,SAAS,KAAK,OAEC,MAAM,QACjB,UAAU,CAAC,SAAS,QAAQ,IAAI;;;;;;;;;;;;;;;;AAiB7C,SAAgB,WACd,QACA,MACA,OAAe,KAAK,KAAK,EACzB,OAAe,GACN;CACT,MAAM,aAAa,OAAO,KAAK,CAAC,SAAS,QAAQ,IAAI;AACrD,KAAI,WAAW,WAAW,UAAU,CAAC,UAAU,KAAK,WAAW,CAC7D,QAAO;CAGT,MAAM,eAAe,OAAO,KAAK,YAAY,OAAO;AAEpD,MAAK,IAAI,QAAQ,CAAC,MAAM,SAAS,MAAM,SAAS;EAE9C,MAAM,WAAW,aAAa,QADb,OAAO,QAAQ,YAAY,IACG;AAE/C,MAAI,gBADgB,OAAO,KAAK,UAAU,OAAO,EAChB,aAAa,CAC5C,QAAO;;AAIX,QAAO;;;;;;;;;;AAWT,MAAM,uBAAuB;;AAG7B,MAAM,SAAS;;;;;;;;;;;;;AAcf,MAAa,oCAAoC;CAC/C;CACA;CACA;CACA;CACA;CACD,CAAC,KAAK,KAAK;;;;;;;;;;;;;AAcZ,SAAgB,uBAAuB,QAA8C;AACnF,KAAI,WAAW,KAAA,KAAa,WAAW,GAAI,QAAO;AAClD,KAAI,OAAO,SAAS,qBAAsB,QAAO;AACjD,KAAI,OAAO,SAAS,MAAM,EAAG,QAAO;AACpC,QAAO,OAAO,KAAK,OAAO;;;;;;;;;;;;;;;;;;;;;;;;;AA0B5B,SAAgB,0BAA0B,MAAyB,QAAQ,KAAW;AACpF,KAAI,CAAC,uBAAuB,IAAI,sBAAsB,CACpD,OAAM,IAAI,MAAM,kCAAkC;;;;;;;;;;;;;;;;;;;;;;;AAyBtD,SAAgB,qBACd,MAAyB,QAAQ,KACoC;CACrE,MAAM,SAAS,IAAI;AACnB,KAAI,CAAC,OAAQ,QAAO,KAAA;AAEpB,SAAQ,QAAQ;EAGd,MAAM,SAAS,IAAI,OAAO;EAC1B,MAAM,SAAS,OAAO,QAAQ,IAAI;EAClC,MAAM,WAAW,WAAW,KAAK,KAAK,OAAO,MAAM,SAAS,EAAE;AAK9D,SAAO,WAAW,QAJH,IAAI,gBAAgB,SAAS,CACxB,IAAI,KAAK,IAAI,GAGF"}
|