@ait-co/devtools 0.1.55 → 0.1.57

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.en.md +77 -32
  2. package/README.md +76 -31
  3. package/dist/chii-relay-57BfqF_5.cjs +88 -0
  4. package/dist/chii-relay-57BfqF_5.cjs.map +1 -0
  5. package/dist/chii-relay-itXOz7kS.js +89 -0
  6. package/dist/chii-relay-itXOz7kS.js.map +1 -0
  7. package/dist/in-app/index.d.ts +45 -12
  8. package/dist/in-app/index.d.ts.map +1 -1
  9. package/dist/in-app/index.js +38 -7
  10. package/dist/in-app/index.js.map +1 -1
  11. package/dist/mcp/cli.d.ts +8 -3
  12. package/dist/mcp/cli.d.ts.map +1 -1
  13. package/dist/mcp/cli.js +1160 -381
  14. package/dist/mcp/cli.js.map +1 -1
  15. package/dist/mcp/server.js +22 -11
  16. package/dist/mcp/server.js.map +1 -1
  17. package/dist/panel/index.js +2 -2
  18. package/dist/relay-secret-store-DnTNl-9z.cjs +140 -0
  19. package/dist/relay-secret-store-DnTNl-9z.cjs.map +1 -0
  20. package/dist/relay-secret-store-DqyUoeXy.js +140 -0
  21. package/dist/relay-secret-store-DqyUoeXy.js.map +1 -0
  22. package/dist/totp-BkP5yU2K.js +186 -0
  23. package/dist/totp-BkP5yU2K.js.map +1 -0
  24. package/dist/totp-CQFmgOhM.js +3 -0
  25. package/dist/totp-D0a8VwoR.js +187 -0
  26. package/dist/totp-D0a8VwoR.js.map +1 -0
  27. package/dist/totp-DLgGbySX.cjs +188 -0
  28. package/dist/totp-DLgGbySX.cjs.map +1 -0
  29. package/dist/{tunnel-D0_TwDNE.js → tunnel-CI61NvPI.js} +13 -5
  30. package/dist/tunnel-CI61NvPI.js.map +1 -0
  31. package/dist/{tunnel-BYP0yRBN.cjs → tunnel-nKYPtc-g.cjs} +13 -5
  32. package/dist/tunnel-nKYPtc-g.cjs.map +1 -0
  33. package/dist/unplugin/index.cjs +31 -3
  34. package/dist/unplugin/index.cjs.map +1 -1
  35. package/dist/unplugin/index.d.cts +11 -0
  36. package/dist/unplugin/index.d.cts.map +1 -1
  37. package/dist/unplugin/index.d.ts +12 -1
  38. package/dist/unplugin/index.d.ts.map +1 -1
  39. package/dist/unplugin/index.js +31 -3
  40. package/dist/unplugin/index.js.map +1 -1
  41. package/dist/unplugin/tunnel.cjs +11 -3
  42. package/dist/unplugin/tunnel.cjs.map +1 -1
  43. package/dist/unplugin/tunnel.d.cts +13 -1
  44. package/dist/unplugin/tunnel.d.cts.map +1 -1
  45. package/dist/unplugin/tunnel.d.ts +13 -1
  46. package/dist/unplugin/tunnel.d.ts.map +1 -1
  47. package/dist/unplugin/tunnel.js +11 -3
  48. package/dist/unplugin/tunnel.js.map +1 -1
  49. package/package.json +2 -2
  50. package/dist/tunnel-BYP0yRBN.cjs.map +0 -1
  51. package/dist/tunnel-D0_TwDNE.js.map +0 -1
@@ -1108,7 +1108,7 @@ function readGlobalString(key) {
1108
1108
  }
1109
1109
  const TELEMETRY_ENDPOINT = readGlobalString("__TELEMETRY_ENDPOINT__") ?? "https://t.aitc.dev";
1110
1110
  function getVersion() {
1111
- return "0.1.55";
1111
+ return "0.1.57";
1112
1112
  }
1113
1113
  let panelVisibleSince = null;
1114
1114
  let accumulatedMs = 0;
@@ -4923,7 +4923,7 @@ function mount() {
4923
4923
  mockBadge.textContent = aitState.state.panelEditable ? t("panel.editMode.on") : t("panel.editMode.off");
4924
4924
  refreshPanel();
4925
4925
  });
4926
- const headerRight = h("span", { style: "display:flex;align-items:center;gap:6px" }, mockBadge, h("span", { style: "font-size:11px;color:#666;font-weight:400" }, `v0.1.55`), closeBtn);
4926
+ const headerRight = h("span", { style: "display:flex;align-items:center;gap:6px" }, mockBadge, h("span", { style: "font-size:11px;color:#666;font-weight:400" }, `v0.1.57`), closeBtn);
4927
4927
  const header = h("div", { className: "ait-panel-header" }, h("span", {}, t("panel.title")), headerRight);
4928
4928
  tabsEl = h("div", { className: "ait-panel-tabs" });
4929
4929
  for (const tab of getTabs()) {
@@ -0,0 +1,140 @@
1
+ let node_path = require("node:path");
2
+ //#region src/mcp/relay-secret-store.ts
3
+ /**
4
+ * Project-local relay TOTP secret store (#394 first-run auto-mint, #396 moved to
5
+ * a project-local single file `.ait_relay`).
6
+ *
7
+ * Two surfaces, intentionally split by who is allowed to write:
8
+ *
9
+ * - {@link ensureRelaySecret} — WRITE path, called ONLY from the unplugin
10
+ * (env-2 relay boot). Mints a fresh secret on first run and persists it to
11
+ * `<projectRoot>/.ait_relay` (0600). A single file — no directory is created.
12
+ *
13
+ * - {@link loadRelaySecretReadOnly} — READ-ONLY path, called from the MCP
14
+ * daemon when switching into a relay environment. It NEVER mints, chmods, or
15
+ * creates anything: it only reads an already-existing `.ait_relay` and injects
16
+ * its value into `env`. A daemon that minted would defeat the #250 fail-fast
17
+ * (the daemon is the verifier side — a self-minted secret would let a leaked
18
+ * tunnel URL attach unauthenticated), so the daemon stays read-only.
19
+ *
20
+ * Why a per-session `projectRoot` instead of `process.cwd()`: the daemon cannot
21
+ * trust its own cwd — agent-plugin spawns it via `npx` without `cwd`, so cwd is
22
+ * frozen at Claude Code launch and a cwd-walk stops at the monorepo workspace
23
+ * root (which always has a package.json). So the project root is supplied
24
+ * per-debug-session through `start_debug`.
25
+ *
26
+ * SECRET-HANDLING: this module handles AIT_DEBUG_TOTP_SECRET — the raw value and
27
+ * its length MUST NOT appear in any log, error message, stdout, stderr, or
28
+ * assertion output. Only boolean pass/fail signals are safe to surface, and the
29
+ * discovered file path is never logged either. The persist file is written mode
30
+ * 0600.
31
+ */
32
+ /** Project-local secret file name (single file, not a directory). */
33
+ const RELAY_SECRET_FILE_NAME = ".ait_relay";
34
+ /**
35
+ * Walks upward from `start` and returns the nearest directory that contains a
36
+ * `package.json`. Falls back to `start` itself when none is found (so a write
37
+ * still lands somewhere deterministic).
38
+ *
39
+ * The write (unplugin) and read (daemon) sides use the SAME anchor so a secret
40
+ * minted by `pnpm dev` is found by the daemon: real mini-apps keep
41
+ * `vite.config.ts` and `package.json` in the same directory, so
42
+ * `server.config.root === package.json-dir`. In a monorepo subdir the anchor is
43
+ * the package's own directory — the one the daemon can also reach via the
44
+ * per-session projectRoot.
45
+ *
46
+ * @param start - Directory to start the upward walk from.
47
+ * @param existsSyncFn - Injectable existence check (defaults to node:fs).
48
+ */
49
+ function nearestPackageJsonDir(start, existsSyncFn) {
50
+ let dir = start;
51
+ while (true) {
52
+ if (existsSyncFn((0, node_path.join)(dir, "package.json"))) return dir;
53
+ const parent = (0, node_path.dirname)(dir);
54
+ if (parent === dir) return start;
55
+ dir = parent;
56
+ }
57
+ }
58
+ /**
59
+ * Absolute path to the project-local `.ait_relay` file for a given start
60
+ * directory (resolved against the nearest package.json directory).
61
+ *
62
+ * Exported so tests can compute the expected path without duplicating the
63
+ * resolution logic.
64
+ */
65
+ function relaySecretFilePath(start, existsSyncFn) {
66
+ return (0, node_path.join)(nearestPackageJsonDir(start, existsSyncFn), RELAY_SECRET_FILE_NAME);
67
+ }
68
+ /**
69
+ * Ensures `env.AIT_DEBUG_TOTP_SECRET` is set to a valid relay TOTP secret,
70
+ * persisting a freshly-minted one to `<projectRoot>/.ait_relay` (0600) on first
71
+ * run and loading it silently on subsequent runs.
72
+ *
73
+ * Writes a SINGLE file into the already-existing project directory — it never
74
+ * creates a directory (so no `mkdirSync`/dir `chmod`). The file is created with
75
+ * `O_EXCL` (`flag: 'wx'`) so a concurrent process cannot be clobbered; on the
76
+ * EEXIST race the winner's value is read instead.
77
+ *
78
+ * Called ONLY from the unplugin (env-2 relay boot). The MCP daemon uses
79
+ * {@link loadRelaySecretReadOnly} (read-only) — it must never mint.
80
+ *
81
+ * @param deps - Optional dependency overrides for testing.
82
+ */
83
+ async function ensureRelaySecret(deps) {
84
+ const { projectRoot, env = process.env, randomBytes: randomBytesFn, fs: fsDep, existsSync: existsSyncDep, cwd: cwdFn, log } = deps ?? {};
85
+ const logFn = log ?? ((msg) => process.stderr.write(msg));
86
+ const { isValidRelayAuthSecret } = await Promise.resolve().then(() => require("./totp-DLgGbySX.cjs"));
87
+ if (isValidRelayAuthSecret(env.AIT_DEBUG_TOTP_SECRET)) return;
88
+ const rb = randomBytesFn ?? (await import("node:crypto")).randomBytes;
89
+ const fs = fsDep ?? await import("node:fs");
90
+ const existsSyncFn = existsSyncDep ?? fs.existsSync;
91
+ const secretPath = relaySecretFilePath(projectRoot ?? (cwdFn ?? (() => process.cwd()))(), existsSyncFn);
92
+ if (fs.existsSync(secretPath)) return readAndInject(secretPath, fs, env, logFn, isValidRelayAuthSecret, rb);
93
+ return mintAndPersist(secretPath, fs, env, rb, logFn, isValidRelayAuthSecret);
94
+ }
95
+ async function readAndInject(secretPath, fs, env, logFn, isValidRelayAuthSecret, rb) {
96
+ let stored;
97
+ try {
98
+ stored = fs.readFileSync(secretPath, "utf8").trim();
99
+ } catch (err) {
100
+ const msg = err instanceof Error ? err.message : String(err);
101
+ throw new Error(`[@ait-co/devtools] relay 시크릿 파일 읽기 실패: ${msg}`);
102
+ }
103
+ if (!isValidRelayAuthSecret(stored)) {
104
+ logFn("[@ait-co/devtools] relay 시크릿 파일의 값이 유효하지 않습니다. 재생성합니다.\n");
105
+ return mintAndPersist(secretPath, fs, env, rb, logFn, isValidRelayAuthSecret, true);
106
+ }
107
+ env.AIT_DEBUG_TOTP_SECRET = stored;
108
+ }
109
+ async function mintAndPersist(secretPath, fs, env, rb, logFn, isValidRelayAuthSecret, overwrite = false) {
110
+ const secret = rb(32).toString("hex");
111
+ if (!isValidRelayAuthSecret(secret)) throw new Error("[@ait-co/devtools] 내부 오류: mint된 시크릿이 유효성 검사를 통과하지 못했습니다.");
112
+ const flag = overwrite ? "w" : "wx";
113
+ try {
114
+ fs.writeFileSync(secretPath, secret, {
115
+ mode: 384,
116
+ flag
117
+ });
118
+ fs.chmodSync(secretPath, 384);
119
+ } catch (err) {
120
+ if (err.code === "EEXIST") {
121
+ let stored;
122
+ try {
123
+ stored = fs.readFileSync(secretPath, "utf8").trim();
124
+ } catch (readErr) {
125
+ const msg = readErr instanceof Error ? readErr.message : String(readErr);
126
+ throw new Error(`[@ait-co/devtools] relay 시크릿 파일 읽기 실패(경합): ${msg}`);
127
+ }
128
+ if (!isValidRelayAuthSecret(stored)) throw new Error("[@ait-co/devtools] relay 시크릿 파일이 경합 후에도 유효하지 않습니다.");
129
+ env.AIT_DEBUG_TOTP_SECRET = stored;
130
+ return;
131
+ }
132
+ throw err;
133
+ }
134
+ env.AIT_DEBUG_TOTP_SECRET = secret;
135
+ logFn(`[@ait-co/devtools] relay 인증 시크릿을 생성해 프로젝트의 ${RELAY_SECRET_FILE_NAME} 파일에 저장했습니다 (권한 0600).\n다음 실행부터 자동으로 사용됩니다. 직접 export할 필요 없습니다.\n팀이 같은 relay를 공유하려면 이 파일을 repo에 커밋하세요(비공개 repo 권장).\n자세히: https://docs.aitc.dev/guides/relay-auth-totp\n`);
136
+ }
137
+ //#endregion
138
+ exports.ensureRelaySecret = ensureRelaySecret;
139
+
140
+ //# sourceMappingURL=relay-secret-store-DnTNl-9z.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"relay-secret-store-DnTNl-9z.cjs","names":[],"sources":["../src/mcp/relay-secret-store.ts"],"sourcesContent":["/**\n * Project-local relay TOTP secret store (#394 first-run auto-mint, #396 moved to\n * a project-local single file `.ait_relay`).\n *\n * Two surfaces, intentionally split by who is allowed to write:\n *\n * - {@link ensureRelaySecret} — WRITE path, called ONLY from the unplugin\n * (env-2 relay boot). Mints a fresh secret on first run and persists it to\n * `<projectRoot>/.ait_relay` (0600). A single file — no directory is created.\n *\n * - {@link loadRelaySecretReadOnly} — READ-ONLY path, called from the MCP\n * daemon when switching into a relay environment. It NEVER mints, chmods, or\n * creates anything: it only reads an already-existing `.ait_relay` and injects\n * its value into `env`. A daemon that minted would defeat the #250 fail-fast\n * (the daemon is the verifier side — a self-minted secret would let a leaked\n * tunnel URL attach unauthenticated), so the daemon stays read-only.\n *\n * Why a per-session `projectRoot` instead of `process.cwd()`: the daemon cannot\n * trust its own cwd — agent-plugin spawns it via `npx` without `cwd`, so cwd is\n * frozen at Claude Code launch and a cwd-walk stops at the monorepo workspace\n * root (which always has a package.json). So the project root is supplied\n * per-debug-session through `start_debug`.\n *\n * SECRET-HANDLING: this module handles AIT_DEBUG_TOTP_SECRET — the raw value and\n * its length MUST NOT appear in any log, error message, stdout, stderr, or\n * assertion output. Only boolean pass/fail signals are safe to surface, and the\n * discovered file path is never logged either. The persist file is written mode\n * 0600.\n */\n\nimport { dirname, join } from 'node:path';\n\n// ---------------------------------------------------------------------------\n// Constants\n// ---------------------------------------------------------------------------\n\n/** Project-local secret file name (single file, not a directory). */\nexport const RELAY_SECRET_FILE_NAME = '.ait_relay';\n\n// ---------------------------------------------------------------------------\n// Dependency injection surface\n// ---------------------------------------------------------------------------\n\n/** Minimal fs subset needed by {@link ensureRelaySecret} — injectable for tests. */\nexport interface RelaySecretFs {\n writeFileSync(path: string, data: string, options: { mode: number; flag: string }): void;\n readFileSync(path: string, encoding: BufferEncoding): string;\n chmodSync(path: string, mode: number): void;\n existsSync(path: string): boolean;\n}\n\n/**\n * Minimal fs subset needed by {@link loadRelaySecretReadOnly} — strictly the two\n * read-only operations. Deliberately omits writeFileSync/mkdirSync/chmodSync so\n * the daemon path cannot mutate the filesystem even by accident (the type\n * forbids it).\n */\nexport interface RelaySecretReadOnlyFs {\n existsSync(path: string): boolean;\n readFileSync(path: string, encoding: BufferEncoding): string;\n}\n\nexport interface RelaySecretDeps {\n /**\n * Project root (typically Vite `server.config.root`). The `.ait_relay` file is\n * resolved against the nearest `package.json` directory at or above this path.\n * When omitted, the current working directory is used as the start point —\n * retained for back-compat/tests; the unplugin always passes it.\n */\n projectRoot?: string;\n /** Process environment to read from and inject into. Defaults to process.env. */\n env?: NodeJS.ProcessEnv;\n /** Cryptographically secure random bytes. Defaults to node:crypto randomBytes. */\n randomBytes?: (n: number) => Buffer;\n /** Filesystem operations. Defaults to node:fs synchronous functions. */\n fs?: RelaySecretFs;\n /** existsSync used to resolve the nearest package.json directory. Defaults to node:fs. */\n existsSync?: (path: string) => boolean;\n /** Current working directory resolver (used only when `projectRoot` is omitted). */\n cwd?: () => string;\n /** Log function for first-mint announcement. Defaults to process.stderr.write. */\n log?: (msg: string) => void;\n}\n\nexport interface RelaySecretReadOnlyDeps {\n /**\n * Project root supplied per-debug-session via `start_debug`. The daemon reads\n * `<nearest package.json dir from projectRoot>/.ait_relay`. When omitted, the\n * loader is a no-op (the daemon has no anchor to read from).\n */\n projectRoot?: string;\n /** Process environment to read from and inject into. Defaults to process.env. */\n env?: NodeJS.ProcessEnv;\n /** Read-only filesystem operations. Defaults to node:fs (existsSync + readFileSync). */\n fs?: RelaySecretReadOnlyFs;\n /** existsSync used to resolve the nearest package.json directory. Defaults to node:fs. */\n existsSync?: (path: string) => boolean;\n /** Optional log sink — never receives the secret value, length, or file path. */\n log?: (msg: string) => void;\n}\n\n// ---------------------------------------------------------------------------\n// Path helpers\n// ---------------------------------------------------------------------------\n\n/**\n * Walks upward from `start` and returns the nearest directory that contains a\n * `package.json`. Falls back to `start` itself when none is found (so a write\n * still lands somewhere deterministic).\n *\n * The write (unplugin) and read (daemon) sides use the SAME anchor so a secret\n * minted by `pnpm dev` is found by the daemon: real mini-apps keep\n * `vite.config.ts` and `package.json` in the same directory, so\n * `server.config.root === package.json-dir`. In a monorepo subdir the anchor is\n * the package's own directory — the one the daemon can also reach via the\n * per-session projectRoot.\n *\n * @param start - Directory to start the upward walk from.\n * @param existsSyncFn - Injectable existence check (defaults to node:fs).\n */\nexport function nearestPackageJsonDir(\n start: string,\n existsSyncFn: (path: string) => boolean,\n): string {\n let dir = start;\n // Stop at the filesystem root (dirname of root === root).\n while (true) {\n if (existsSyncFn(join(dir, 'package.json'))) {\n return dir;\n }\n const parent = dirname(dir);\n if (parent === dir) {\n // Reached the filesystem root without finding a package.json — fall back\n // to the original start directory.\n return start;\n }\n dir = parent;\n }\n}\n\n/**\n * Absolute path to the project-local `.ait_relay` file for a given start\n * directory (resolved against the nearest package.json directory).\n *\n * Exported so tests can compute the expected path without duplicating the\n * resolution logic.\n */\nexport function relaySecretFilePath(\n start: string,\n existsSyncFn: (path: string) => boolean,\n): string {\n return join(nearestPackageJsonDir(start, existsSyncFn), RELAY_SECRET_FILE_NAME);\n}\n\n// ---------------------------------------------------------------------------\n// WRITE path (unplugin only) — mint + persist\n// ---------------------------------------------------------------------------\n\n/**\n * Ensures `env.AIT_DEBUG_TOTP_SECRET` is set to a valid relay TOTP secret,\n * persisting a freshly-minted one to `<projectRoot>/.ait_relay` (0600) on first\n * run and loading it silently on subsequent runs.\n *\n * Writes a SINGLE file into the already-existing project directory — it never\n * creates a directory (so no `mkdirSync`/dir `chmod`). The file is created with\n * `O_EXCL` (`flag: 'wx'`) so a concurrent process cannot be clobbered; on the\n * EEXIST race the winner's value is read instead.\n *\n * Called ONLY from the unplugin (env-2 relay boot). The MCP daemon uses\n * {@link loadRelaySecretReadOnly} (read-only) — it must never mint.\n *\n * @param deps - Optional dependency overrides for testing.\n */\nexport async function ensureRelaySecret(deps?: RelaySecretDeps): Promise<void> {\n const {\n projectRoot,\n env = process.env,\n randomBytes: randomBytesFn,\n fs: fsDep,\n existsSync: existsSyncDep,\n cwd: cwdFn,\n log,\n } = deps ?? {};\n\n const logFn: (msg: string) => void = log ?? ((msg: string) => process.stderr.write(msg));\n\n // Lazily import isValidRelayAuthSecret to avoid pulling in node:crypto at\n // module-load time (keeps the import side-effect free).\n const { isValidRelayAuthSecret } = await import('./totp.js');\n\n // 1. Already configured — no-op (operator export or earlier run wins).\n if (isValidRelayAuthSecret(env.AIT_DEBUG_TOTP_SECRET)) {\n return;\n }\n\n // Resolve injected or real dependencies lazily to keep the import graph clean.\n const rb: (n: number) => Buffer = randomBytesFn ?? (await import('node:crypto')).randomBytes;\n const fs: RelaySecretFs = fsDep ?? (await import('node:fs'));\n const existsSyncFn: (path: string) => boolean = existsSyncDep ?? fs.existsSync;\n\n const start = projectRoot ?? (cwdFn ?? (() => process.cwd()))();\n const secretPath = relaySecretFilePath(start, existsSyncFn);\n\n // 2. Persist file exists — read and inject (silent reload).\n if (fs.existsSync(secretPath)) {\n return readAndInject(secretPath, fs, env, logFn, isValidRelayAuthSecret, rb);\n }\n\n // 3. Mint a fresh secret.\n return mintAndPersist(secretPath, fs, env, rb, logFn, isValidRelayAuthSecret);\n}\n\n// ---------------------------------------------------------------------------\n// READ-ONLY path (daemon only) — never mints, chmods, or creates anything\n// ---------------------------------------------------------------------------\n\n/**\n * Reads an already-existing `<projectRoot>/.ait_relay` and, if its contents are a\n * valid relay TOTP secret, injects them into `env.AIT_DEBUG_TOTP_SECRET`.\n *\n * Strictly READ-ONLY: it uses only `existsSync` + `readFileSync` and NEVER mints,\n * chmods, or creates files/directories. The daemon must not mint because it is\n * the relay verifier side — a self-minted secret would defeat the #250 fail-fast\n * (a leaked tunnel URL could then attach unauthenticated). If no valid secret is\n * found the function leaves `env` untouched and returns without throwing, so the\n * downstream `assertRelayAuthConfigured()` stays the single fail-fast.\n *\n * Resolution order:\n * 1. `env.AIT_DEBUG_TOTP_SECRET` already valid → no-op (operator export wins).\n * 2. `projectRoot` given → read `<nearest package.json dir>/.ait_relay`; inject\n * iff the contents pass {@link isValidRelayAuthSecret}.\n * 3. Otherwise (no projectRoot, file absent, or invalid) → silent no-op.\n *\n * SECRET-HANDLING: the read value is passed ONLY to the boolean predicate before\n * assignment; its value, length, and the discovered file path are never logged.\n *\n * @param deps - Optional dependency overrides for testing.\n */\nexport async function loadRelaySecretReadOnly(deps?: RelaySecretReadOnlyDeps): Promise<void> {\n const { projectRoot, env = process.env, fs: fsDep, existsSync: existsSyncDep } = deps ?? {};\n\n const { isValidRelayAuthSecret } = await import('./totp.js');\n\n // 1. Already configured — no-op (operator export or unplugin run wins).\n if (isValidRelayAuthSecret(env.AIT_DEBUG_TOTP_SECRET)) {\n return;\n }\n\n // 2. No anchor → nothing to read.\n if (projectRoot === undefined) {\n return;\n }\n\n const fs: RelaySecretReadOnlyFs = fsDep ?? (await import('node:fs'));\n const existsSyncFn: (path: string) => boolean = existsSyncDep ?? fs.existsSync;\n\n const secretPath = relaySecretFilePath(projectRoot, existsSyncFn);\n if (!fs.existsSync(secretPath)) {\n return;\n }\n\n let stored: string;\n try {\n stored = fs.readFileSync(secretPath, 'utf8').trim();\n } catch {\n // Unreadable file (permissions, transient FS error) — stay silent and let\n // the downstream assert be the single fail-fast. SECRET-HANDLING: the error\n // and path are not surfaced.\n return;\n }\n\n // SECRET-HANDLING: the value flows only through the boolean predicate.\n if (!isValidRelayAuthSecret(stored)) {\n return;\n }\n\n env.AIT_DEBUG_TOTP_SECRET = stored;\n}\n\n// ---------------------------------------------------------------------------\n// Internal helpers (not exported — single-use extracted for readability)\n// ---------------------------------------------------------------------------\n\nasync function readAndInject(\n secretPath: string,\n fs: RelaySecretFs,\n env: NodeJS.ProcessEnv,\n logFn: (msg: string) => void,\n isValidRelayAuthSecret: (s: string | undefined) => s is string,\n rb: (n: number) => Buffer,\n): Promise<void> {\n let stored: string;\n try {\n stored = fs.readFileSync(secretPath, 'utf8').trim();\n } catch (err) {\n const msg = err instanceof Error ? err.message : String(err);\n throw new Error(`[@ait-co/devtools] relay 시크릿 파일 읽기 실패: ${msg}`);\n }\n\n if (!isValidRelayAuthSecret(stored)) {\n // Stored value is corrupt — re-mint over the same path.\n logFn('[@ait-co/devtools] relay 시크릿 파일의 값이 유효하지 않습니다. 재생성합니다.\\n');\n return mintAndPersist(secretPath, fs, env, rb, logFn, isValidRelayAuthSecret, true);\n }\n\n // Inject into env — silent path (no log on successful reload).\n env.AIT_DEBUG_TOTP_SECRET = stored;\n}\n\nasync function mintAndPersist(\n secretPath: string,\n fs: RelaySecretFs,\n env: NodeJS.ProcessEnv,\n rb: (n: number) => Buffer,\n logFn: (msg: string) => void,\n isValidRelayAuthSecret: (s: string | undefined) => s is string,\n /** When re-minting over a corrupt file, the existing file must be overwritten. */\n overwrite = false,\n): Promise<void> {\n // SECRET-HANDLING: the raw bytes are never written to any log or string other\n // than the persist file and the env variable.\n const secret = rb(32).toString('hex'); // 64 hex chars = 256 bits\n\n // Self-consistency guard: our own minted secret must pass validation.\n if (!isValidRelayAuthSecret(secret)) {\n throw new Error(\n '[@ait-co/devtools] 내부 오류: mint된 시크릿이 유효성 검사를 통과하지 못했습니다.',\n );\n }\n\n // Write a SINGLE file into the already-existing project directory — no\n // directory is created. `O_EXCL` (flag 'wx') makes the create exclusive so a\n // concurrent process cannot be clobbered; on EEXIST we read the winner's value.\n // (When re-minting over a corrupt file we must overwrite, so use 'w'.)\n const flag = overwrite ? 'w' : 'wx';\n try {\n fs.writeFileSync(secretPath, secret, { mode: 0o600, flag });\n // Belt-and-suspenders: apply chmod after write in case umask relaxed the mode.\n fs.chmodSync(secretPath, 0o600);\n } catch (err) {\n if ((err as NodeJS.ErrnoException).code === 'EEXIST') {\n // Race: another process already wrote the file — read their value.\n let stored: string;\n try {\n stored = fs.readFileSync(secretPath, 'utf8').trim();\n } catch (readErr) {\n const msg = readErr instanceof Error ? readErr.message : String(readErr);\n throw new Error(`[@ait-co/devtools] relay 시크릿 파일 읽기 실패(경합): ${msg}`);\n }\n if (!isValidRelayAuthSecret(stored)) {\n throw new Error('[@ait-co/devtools] relay 시크릿 파일이 경합 후에도 유효하지 않습니다.');\n }\n env.AIT_DEBUG_TOTP_SECRET = stored;\n return;\n }\n throw err;\n }\n\n // Inject into the current process env so the immediately following\n // assertRelayAuthConfigured() / buildRelayVerifyAuth() calls see the value.\n env.AIT_DEBUG_TOTP_SECRET = secret;\n\n // First-mint announcement (value never included — SECRET-HANDLING). The file\n // name is fixed (`.ait_relay`); we do not echo the resolved directory either.\n logFn(\n `[@ait-co/devtools] relay 인증 시크릿을 생성해 프로젝트의 ${RELAY_SECRET_FILE_NAME} 파일에 저장했습니다 (권한 0600).\\n` +\n `다음 실행부터 자동으로 사용됩니다. 직접 export할 필요 없습니다.\\n` +\n `팀이 같은 relay를 공유하려면 이 파일을 repo에 커밋하세요(비공개 repo 권장).\\n` +\n `자세히: https://docs.aitc.dev/guides/relay-auth-totp\\n`,\n );\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAqCA,MAAa,yBAAyB;;;;;;;;;;;;;;;;AAmFtC,SAAgB,sBACd,OACA,cACQ;CACR,IAAI,MAAM;AAEV,QAAO,MAAM;AACX,MAAI,cAAA,GAAA,UAAA,MAAkB,KAAK,eAAe,CAAC,CACzC,QAAO;EAET,MAAM,UAAA,GAAA,UAAA,SAAiB,IAAI;AAC3B,MAAI,WAAW,IAGb,QAAO;AAET,QAAM;;;;;;;;;;AAWV,SAAgB,oBACd,OACA,cACQ;AACR,SAAA,GAAA,UAAA,MAAY,sBAAsB,OAAO,aAAa,EAAE,uBAAuB;;;;;;;;;;;;;;;;;AAsBjF,eAAsB,kBAAkB,MAAuC;CAC7E,MAAM,EACJ,aACA,MAAM,QAAQ,KACd,aAAa,eACb,IAAI,OACJ,YAAY,eACZ,KAAK,OACL,QACE,QAAQ,EAAE;CAEd,MAAM,QAA+B,SAAS,QAAgB,QAAQ,OAAO,MAAM,IAAI;CAIvF,MAAM,EAAE,2BAA2B,MAAA,QAAA,SAAA,CAAA,WAAA,QAAM,sBAAA,CAAA;AAGzC,KAAI,uBAAuB,IAAI,sBAAsB,CACnD;CAIF,MAAM,KAA4B,kBAAkB,MAAM,OAAO,gBAAgB;CACjF,MAAM,KAAoB,SAAU,MAAM,OAAO;CACjD,MAAM,eAA0C,iBAAiB,GAAG;CAGpE,MAAM,aAAa,oBADL,gBAAgB,gBAAgB,QAAQ,KAAK,IAAI,EACjB,aAAa;AAG3D,KAAI,GAAG,WAAW,WAAW,CAC3B,QAAO,cAAc,YAAY,IAAI,KAAK,OAAO,wBAAwB,GAAG;AAI9E,QAAO,eAAe,YAAY,IAAI,KAAK,IAAI,OAAO,uBAAuB;;AA0E/E,eAAe,cACb,YACA,IACA,KACA,OACA,wBACA,IACe;CACf,IAAI;AACJ,KAAI;AACF,WAAS,GAAG,aAAa,YAAY,OAAO,CAAC,MAAM;UAC5C,KAAK;EACZ,MAAM,MAAM,eAAe,QAAQ,IAAI,UAAU,OAAO,IAAI;AAC5D,QAAM,IAAI,MAAM,0CAA0C,MAAM;;AAGlE,KAAI,CAAC,uBAAuB,OAAO,EAAE;AAEnC,QAAM,2DAA2D;AACjE,SAAO,eAAe,YAAY,IAAI,KAAK,IAAI,OAAO,wBAAwB,KAAK;;AAIrF,KAAI,wBAAwB;;AAG9B,eAAe,eACb,YACA,IACA,KACA,IACA,OACA,wBAEA,YAAY,OACG;CAGf,MAAM,SAAS,GAAG,GAAG,CAAC,SAAS,MAAM;AAGrC,KAAI,CAAC,uBAAuB,OAAO,CACjC,OAAM,IAAI,MACR,2DACD;CAOH,MAAM,OAAO,YAAY,MAAM;AAC/B,KAAI;AACF,KAAG,cAAc,YAAY,QAAQ;GAAE,MAAM;GAAO;GAAM,CAAC;AAE3D,KAAG,UAAU,YAAY,IAAM;UACxB,KAAK;AACZ,MAAK,IAA8B,SAAS,UAAU;GAEpD,IAAI;AACJ,OAAI;AACF,aAAS,GAAG,aAAa,YAAY,OAAO,CAAC,MAAM;YAC5C,SAAS;IAChB,MAAM,MAAM,mBAAmB,QAAQ,QAAQ,UAAU,OAAO,QAAQ;AACxE,UAAM,IAAI,MAAM,8CAA8C,MAAM;;AAEtE,OAAI,CAAC,uBAAuB,OAAO,CACjC,OAAM,IAAI,MAAM,qDAAqD;AAEvE,OAAI,wBAAwB;AAC5B;;AAEF,QAAM;;AAKR,KAAI,wBAAwB;AAI5B,OACE,8CAA8C,uBAAuB,0KAItE"}
@@ -0,0 +1,140 @@
1
+ import { dirname, join } from "node:path";
2
+ //#region src/mcp/relay-secret-store.ts
3
+ /**
4
+ * Project-local relay TOTP secret store (#394 first-run auto-mint, #396 moved to
5
+ * a project-local single file `.ait_relay`).
6
+ *
7
+ * Two surfaces, intentionally split by who is allowed to write:
8
+ *
9
+ * - {@link ensureRelaySecret} — WRITE path, called ONLY from the unplugin
10
+ * (env-2 relay boot). Mints a fresh secret on first run and persists it to
11
+ * `<projectRoot>/.ait_relay` (0600). A single file — no directory is created.
12
+ *
13
+ * - {@link loadRelaySecretReadOnly} — READ-ONLY path, called from the MCP
14
+ * daemon when switching into a relay environment. It NEVER mints, chmods, or
15
+ * creates anything: it only reads an already-existing `.ait_relay` and injects
16
+ * its value into `env`. A daemon that minted would defeat the #250 fail-fast
17
+ * (the daemon is the verifier side — a self-minted secret would let a leaked
18
+ * tunnel URL attach unauthenticated), so the daemon stays read-only.
19
+ *
20
+ * Why a per-session `projectRoot` instead of `process.cwd()`: the daemon cannot
21
+ * trust its own cwd — agent-plugin spawns it via `npx` without `cwd`, so cwd is
22
+ * frozen at Claude Code launch and a cwd-walk stops at the monorepo workspace
23
+ * root (which always has a package.json). So the project root is supplied
24
+ * per-debug-session through `start_debug`.
25
+ *
26
+ * SECRET-HANDLING: this module handles AIT_DEBUG_TOTP_SECRET — the raw value and
27
+ * its length MUST NOT appear in any log, error message, stdout, stderr, or
28
+ * assertion output. Only boolean pass/fail signals are safe to surface, and the
29
+ * discovered file path is never logged either. The persist file is written mode
30
+ * 0600.
31
+ */
32
+ /** Project-local secret file name (single file, not a directory). */
33
+ const RELAY_SECRET_FILE_NAME = ".ait_relay";
34
+ /**
35
+ * Walks upward from `start` and returns the nearest directory that contains a
36
+ * `package.json`. Falls back to `start` itself when none is found (so a write
37
+ * still lands somewhere deterministic).
38
+ *
39
+ * The write (unplugin) and read (daemon) sides use the SAME anchor so a secret
40
+ * minted by `pnpm dev` is found by the daemon: real mini-apps keep
41
+ * `vite.config.ts` and `package.json` in the same directory, so
42
+ * `server.config.root === package.json-dir`. In a monorepo subdir the anchor is
43
+ * the package's own directory — the one the daemon can also reach via the
44
+ * per-session projectRoot.
45
+ *
46
+ * @param start - Directory to start the upward walk from.
47
+ * @param existsSyncFn - Injectable existence check (defaults to node:fs).
48
+ */
49
+ function nearestPackageJsonDir(start, existsSyncFn) {
50
+ let dir = start;
51
+ while (true) {
52
+ if (existsSyncFn(join(dir, "package.json"))) return dir;
53
+ const parent = dirname(dir);
54
+ if (parent === dir) return start;
55
+ dir = parent;
56
+ }
57
+ }
58
+ /**
59
+ * Absolute path to the project-local `.ait_relay` file for a given start
60
+ * directory (resolved against the nearest package.json directory).
61
+ *
62
+ * Exported so tests can compute the expected path without duplicating the
63
+ * resolution logic.
64
+ */
65
+ function relaySecretFilePath(start, existsSyncFn) {
66
+ return join(nearestPackageJsonDir(start, existsSyncFn), RELAY_SECRET_FILE_NAME);
67
+ }
68
+ /**
69
+ * Ensures `env.AIT_DEBUG_TOTP_SECRET` is set to a valid relay TOTP secret,
70
+ * persisting a freshly-minted one to `<projectRoot>/.ait_relay` (0600) on first
71
+ * run and loading it silently on subsequent runs.
72
+ *
73
+ * Writes a SINGLE file into the already-existing project directory — it never
74
+ * creates a directory (so no `mkdirSync`/dir `chmod`). The file is created with
75
+ * `O_EXCL` (`flag: 'wx'`) so a concurrent process cannot be clobbered; on the
76
+ * EEXIST race the winner's value is read instead.
77
+ *
78
+ * Called ONLY from the unplugin (env-2 relay boot). The MCP daemon uses
79
+ * {@link loadRelaySecretReadOnly} (read-only) — it must never mint.
80
+ *
81
+ * @param deps - Optional dependency overrides for testing.
82
+ */
83
+ async function ensureRelaySecret(deps) {
84
+ const { projectRoot, env = process.env, randomBytes: randomBytesFn, fs: fsDep, existsSync: existsSyncDep, cwd: cwdFn, log } = deps ?? {};
85
+ const logFn = log ?? ((msg) => process.stderr.write(msg));
86
+ const { isValidRelayAuthSecret } = await import("./totp-BkP5yU2K.js");
87
+ if (isValidRelayAuthSecret(env.AIT_DEBUG_TOTP_SECRET)) return;
88
+ const rb = randomBytesFn ?? (await import("node:crypto")).randomBytes;
89
+ const fs = fsDep ?? await import("node:fs");
90
+ const existsSyncFn = existsSyncDep ?? fs.existsSync;
91
+ const secretPath = relaySecretFilePath(projectRoot ?? (cwdFn ?? (() => process.cwd()))(), existsSyncFn);
92
+ if (fs.existsSync(secretPath)) return readAndInject(secretPath, fs, env, logFn, isValidRelayAuthSecret, rb);
93
+ return mintAndPersist(secretPath, fs, env, rb, logFn, isValidRelayAuthSecret);
94
+ }
95
+ async function readAndInject(secretPath, fs, env, logFn, isValidRelayAuthSecret, rb) {
96
+ let stored;
97
+ try {
98
+ stored = fs.readFileSync(secretPath, "utf8").trim();
99
+ } catch (err) {
100
+ const msg = err instanceof Error ? err.message : String(err);
101
+ throw new Error(`[@ait-co/devtools] relay 시크릿 파일 읽기 실패: ${msg}`);
102
+ }
103
+ if (!isValidRelayAuthSecret(stored)) {
104
+ logFn("[@ait-co/devtools] relay 시크릿 파일의 값이 유효하지 않습니다. 재생성합니다.\n");
105
+ return mintAndPersist(secretPath, fs, env, rb, logFn, isValidRelayAuthSecret, true);
106
+ }
107
+ env.AIT_DEBUG_TOTP_SECRET = stored;
108
+ }
109
+ async function mintAndPersist(secretPath, fs, env, rb, logFn, isValidRelayAuthSecret, overwrite = false) {
110
+ const secret = rb(32).toString("hex");
111
+ if (!isValidRelayAuthSecret(secret)) throw new Error("[@ait-co/devtools] 내부 오류: mint된 시크릿이 유효성 검사를 통과하지 못했습니다.");
112
+ const flag = overwrite ? "w" : "wx";
113
+ try {
114
+ fs.writeFileSync(secretPath, secret, {
115
+ mode: 384,
116
+ flag
117
+ });
118
+ fs.chmodSync(secretPath, 384);
119
+ } catch (err) {
120
+ if (err.code === "EEXIST") {
121
+ let stored;
122
+ try {
123
+ stored = fs.readFileSync(secretPath, "utf8").trim();
124
+ } catch (readErr) {
125
+ const msg = readErr instanceof Error ? readErr.message : String(readErr);
126
+ throw new Error(`[@ait-co/devtools] relay 시크릿 파일 읽기 실패(경합): ${msg}`);
127
+ }
128
+ if (!isValidRelayAuthSecret(stored)) throw new Error("[@ait-co/devtools] relay 시크릿 파일이 경합 후에도 유효하지 않습니다.");
129
+ env.AIT_DEBUG_TOTP_SECRET = stored;
130
+ return;
131
+ }
132
+ throw err;
133
+ }
134
+ env.AIT_DEBUG_TOTP_SECRET = secret;
135
+ logFn(`[@ait-co/devtools] relay 인증 시크릿을 생성해 프로젝트의 ${RELAY_SECRET_FILE_NAME} 파일에 저장했습니다 (권한 0600).\n다음 실행부터 자동으로 사용됩니다. 직접 export할 필요 없습니다.\n팀이 같은 relay를 공유하려면 이 파일을 repo에 커밋하세요(비공개 repo 권장).\n자세히: https://docs.aitc.dev/guides/relay-auth-totp\n`);
136
+ }
137
+ //#endregion
138
+ export { ensureRelaySecret };
139
+
140
+ //# sourceMappingURL=relay-secret-store-DqyUoeXy.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"relay-secret-store-DqyUoeXy.js","names":[],"sources":["../src/mcp/relay-secret-store.ts"],"sourcesContent":["/**\n * Project-local relay TOTP secret store (#394 first-run auto-mint, #396 moved to\n * a project-local single file `.ait_relay`).\n *\n * Two surfaces, intentionally split by who is allowed to write:\n *\n * - {@link ensureRelaySecret} — WRITE path, called ONLY from the unplugin\n * (env-2 relay boot). Mints a fresh secret on first run and persists it to\n * `<projectRoot>/.ait_relay` (0600). A single file — no directory is created.\n *\n * - {@link loadRelaySecretReadOnly} — READ-ONLY path, called from the MCP\n * daemon when switching into a relay environment. It NEVER mints, chmods, or\n * creates anything: it only reads an already-existing `.ait_relay` and injects\n * its value into `env`. A daemon that minted would defeat the #250 fail-fast\n * (the daemon is the verifier side — a self-minted secret would let a leaked\n * tunnel URL attach unauthenticated), so the daemon stays read-only.\n *\n * Why a per-session `projectRoot` instead of `process.cwd()`: the daemon cannot\n * trust its own cwd — agent-plugin spawns it via `npx` without `cwd`, so cwd is\n * frozen at Claude Code launch and a cwd-walk stops at the monorepo workspace\n * root (which always has a package.json). So the project root is supplied\n * per-debug-session through `start_debug`.\n *\n * SECRET-HANDLING: this module handles AIT_DEBUG_TOTP_SECRET — the raw value and\n * its length MUST NOT appear in any log, error message, stdout, stderr, or\n * assertion output. Only boolean pass/fail signals are safe to surface, and the\n * discovered file path is never logged either. The persist file is written mode\n * 0600.\n */\n\nimport { dirname, join } from 'node:path';\n\n// ---------------------------------------------------------------------------\n// Constants\n// ---------------------------------------------------------------------------\n\n/** Project-local secret file name (single file, not a directory). */\nexport const RELAY_SECRET_FILE_NAME = '.ait_relay';\n\n// ---------------------------------------------------------------------------\n// Dependency injection surface\n// ---------------------------------------------------------------------------\n\n/** Minimal fs subset needed by {@link ensureRelaySecret} — injectable for tests. */\nexport interface RelaySecretFs {\n writeFileSync(path: string, data: string, options: { mode: number; flag: string }): void;\n readFileSync(path: string, encoding: BufferEncoding): string;\n chmodSync(path: string, mode: number): void;\n existsSync(path: string): boolean;\n}\n\n/**\n * Minimal fs subset needed by {@link loadRelaySecretReadOnly} — strictly the two\n * read-only operations. Deliberately omits writeFileSync/mkdirSync/chmodSync so\n * the daemon path cannot mutate the filesystem even by accident (the type\n * forbids it).\n */\nexport interface RelaySecretReadOnlyFs {\n existsSync(path: string): boolean;\n readFileSync(path: string, encoding: BufferEncoding): string;\n}\n\nexport interface RelaySecretDeps {\n /**\n * Project root (typically Vite `server.config.root`). The `.ait_relay` file is\n * resolved against the nearest `package.json` directory at or above this path.\n * When omitted, the current working directory is used as the start point —\n * retained for back-compat/tests; the unplugin always passes it.\n */\n projectRoot?: string;\n /** Process environment to read from and inject into. Defaults to process.env. */\n env?: NodeJS.ProcessEnv;\n /** Cryptographically secure random bytes. Defaults to node:crypto randomBytes. */\n randomBytes?: (n: number) => Buffer;\n /** Filesystem operations. Defaults to node:fs synchronous functions. */\n fs?: RelaySecretFs;\n /** existsSync used to resolve the nearest package.json directory. Defaults to node:fs. */\n existsSync?: (path: string) => boolean;\n /** Current working directory resolver (used only when `projectRoot` is omitted). */\n cwd?: () => string;\n /** Log function for first-mint announcement. Defaults to process.stderr.write. */\n log?: (msg: string) => void;\n}\n\nexport interface RelaySecretReadOnlyDeps {\n /**\n * Project root supplied per-debug-session via `start_debug`. The daemon reads\n * `<nearest package.json dir from projectRoot>/.ait_relay`. When omitted, the\n * loader is a no-op (the daemon has no anchor to read from).\n */\n projectRoot?: string;\n /** Process environment to read from and inject into. Defaults to process.env. */\n env?: NodeJS.ProcessEnv;\n /** Read-only filesystem operations. Defaults to node:fs (existsSync + readFileSync). */\n fs?: RelaySecretReadOnlyFs;\n /** existsSync used to resolve the nearest package.json directory. Defaults to node:fs. */\n existsSync?: (path: string) => boolean;\n /** Optional log sink — never receives the secret value, length, or file path. */\n log?: (msg: string) => void;\n}\n\n// ---------------------------------------------------------------------------\n// Path helpers\n// ---------------------------------------------------------------------------\n\n/**\n * Walks upward from `start` and returns the nearest directory that contains a\n * `package.json`. Falls back to `start` itself when none is found (so a write\n * still lands somewhere deterministic).\n *\n * The write (unplugin) and read (daemon) sides use the SAME anchor so a secret\n * minted by `pnpm dev` is found by the daemon: real mini-apps keep\n * `vite.config.ts` and `package.json` in the same directory, so\n * `server.config.root === package.json-dir`. In a monorepo subdir the anchor is\n * the package's own directory — the one the daemon can also reach via the\n * per-session projectRoot.\n *\n * @param start - Directory to start the upward walk from.\n * @param existsSyncFn - Injectable existence check (defaults to node:fs).\n */\nexport function nearestPackageJsonDir(\n start: string,\n existsSyncFn: (path: string) => boolean,\n): string {\n let dir = start;\n // Stop at the filesystem root (dirname of root === root).\n while (true) {\n if (existsSyncFn(join(dir, 'package.json'))) {\n return dir;\n }\n const parent = dirname(dir);\n if (parent === dir) {\n // Reached the filesystem root without finding a package.json — fall back\n // to the original start directory.\n return start;\n }\n dir = parent;\n }\n}\n\n/**\n * Absolute path to the project-local `.ait_relay` file for a given start\n * directory (resolved against the nearest package.json directory).\n *\n * Exported so tests can compute the expected path without duplicating the\n * resolution logic.\n */\nexport function relaySecretFilePath(\n start: string,\n existsSyncFn: (path: string) => boolean,\n): string {\n return join(nearestPackageJsonDir(start, existsSyncFn), RELAY_SECRET_FILE_NAME);\n}\n\n// ---------------------------------------------------------------------------\n// WRITE path (unplugin only) — mint + persist\n// ---------------------------------------------------------------------------\n\n/**\n * Ensures `env.AIT_DEBUG_TOTP_SECRET` is set to a valid relay TOTP secret,\n * persisting a freshly-minted one to `<projectRoot>/.ait_relay` (0600) on first\n * run and loading it silently on subsequent runs.\n *\n * Writes a SINGLE file into the already-existing project directory — it never\n * creates a directory (so no `mkdirSync`/dir `chmod`). The file is created with\n * `O_EXCL` (`flag: 'wx'`) so a concurrent process cannot be clobbered; on the\n * EEXIST race the winner's value is read instead.\n *\n * Called ONLY from the unplugin (env-2 relay boot). The MCP daemon uses\n * {@link loadRelaySecretReadOnly} (read-only) — it must never mint.\n *\n * @param deps - Optional dependency overrides for testing.\n */\nexport async function ensureRelaySecret(deps?: RelaySecretDeps): Promise<void> {\n const {\n projectRoot,\n env = process.env,\n randomBytes: randomBytesFn,\n fs: fsDep,\n existsSync: existsSyncDep,\n cwd: cwdFn,\n log,\n } = deps ?? {};\n\n const logFn: (msg: string) => void = log ?? ((msg: string) => process.stderr.write(msg));\n\n // Lazily import isValidRelayAuthSecret to avoid pulling in node:crypto at\n // module-load time (keeps the import side-effect free).\n const { isValidRelayAuthSecret } = await import('./totp.js');\n\n // 1. Already configured — no-op (operator export or earlier run wins).\n if (isValidRelayAuthSecret(env.AIT_DEBUG_TOTP_SECRET)) {\n return;\n }\n\n // Resolve injected or real dependencies lazily to keep the import graph clean.\n const rb: (n: number) => Buffer = randomBytesFn ?? (await import('node:crypto')).randomBytes;\n const fs: RelaySecretFs = fsDep ?? (await import('node:fs'));\n const existsSyncFn: (path: string) => boolean = existsSyncDep ?? fs.existsSync;\n\n const start = projectRoot ?? (cwdFn ?? (() => process.cwd()))();\n const secretPath = relaySecretFilePath(start, existsSyncFn);\n\n // 2. Persist file exists — read and inject (silent reload).\n if (fs.existsSync(secretPath)) {\n return readAndInject(secretPath, fs, env, logFn, isValidRelayAuthSecret, rb);\n }\n\n // 3. Mint a fresh secret.\n return mintAndPersist(secretPath, fs, env, rb, logFn, isValidRelayAuthSecret);\n}\n\n// ---------------------------------------------------------------------------\n// READ-ONLY path (daemon only) — never mints, chmods, or creates anything\n// ---------------------------------------------------------------------------\n\n/**\n * Reads an already-existing `<projectRoot>/.ait_relay` and, if its contents are a\n * valid relay TOTP secret, injects them into `env.AIT_DEBUG_TOTP_SECRET`.\n *\n * Strictly READ-ONLY: it uses only `existsSync` + `readFileSync` and NEVER mints,\n * chmods, or creates files/directories. The daemon must not mint because it is\n * the relay verifier side — a self-minted secret would defeat the #250 fail-fast\n * (a leaked tunnel URL could then attach unauthenticated). If no valid secret is\n * found the function leaves `env` untouched and returns without throwing, so the\n * downstream `assertRelayAuthConfigured()` stays the single fail-fast.\n *\n * Resolution order:\n * 1. `env.AIT_DEBUG_TOTP_SECRET` already valid → no-op (operator export wins).\n * 2. `projectRoot` given → read `<nearest package.json dir>/.ait_relay`; inject\n * iff the contents pass {@link isValidRelayAuthSecret}.\n * 3. Otherwise (no projectRoot, file absent, or invalid) → silent no-op.\n *\n * SECRET-HANDLING: the read value is passed ONLY to the boolean predicate before\n * assignment; its value, length, and the discovered file path are never logged.\n *\n * @param deps - Optional dependency overrides for testing.\n */\nexport async function loadRelaySecretReadOnly(deps?: RelaySecretReadOnlyDeps): Promise<void> {\n const { projectRoot, env = process.env, fs: fsDep, existsSync: existsSyncDep } = deps ?? {};\n\n const { isValidRelayAuthSecret } = await import('./totp.js');\n\n // 1. Already configured — no-op (operator export or unplugin run wins).\n if (isValidRelayAuthSecret(env.AIT_DEBUG_TOTP_SECRET)) {\n return;\n }\n\n // 2. No anchor → nothing to read.\n if (projectRoot === undefined) {\n return;\n }\n\n const fs: RelaySecretReadOnlyFs = fsDep ?? (await import('node:fs'));\n const existsSyncFn: (path: string) => boolean = existsSyncDep ?? fs.existsSync;\n\n const secretPath = relaySecretFilePath(projectRoot, existsSyncFn);\n if (!fs.existsSync(secretPath)) {\n return;\n }\n\n let stored: string;\n try {\n stored = fs.readFileSync(secretPath, 'utf8').trim();\n } catch {\n // Unreadable file (permissions, transient FS error) — stay silent and let\n // the downstream assert be the single fail-fast. SECRET-HANDLING: the error\n // and path are not surfaced.\n return;\n }\n\n // SECRET-HANDLING: the value flows only through the boolean predicate.\n if (!isValidRelayAuthSecret(stored)) {\n return;\n }\n\n env.AIT_DEBUG_TOTP_SECRET = stored;\n}\n\n// ---------------------------------------------------------------------------\n// Internal helpers (not exported — single-use extracted for readability)\n// ---------------------------------------------------------------------------\n\nasync function readAndInject(\n secretPath: string,\n fs: RelaySecretFs,\n env: NodeJS.ProcessEnv,\n logFn: (msg: string) => void,\n isValidRelayAuthSecret: (s: string | undefined) => s is string,\n rb: (n: number) => Buffer,\n): Promise<void> {\n let stored: string;\n try {\n stored = fs.readFileSync(secretPath, 'utf8').trim();\n } catch (err) {\n const msg = err instanceof Error ? err.message : String(err);\n throw new Error(`[@ait-co/devtools] relay 시크릿 파일 읽기 실패: ${msg}`);\n }\n\n if (!isValidRelayAuthSecret(stored)) {\n // Stored value is corrupt — re-mint over the same path.\n logFn('[@ait-co/devtools] relay 시크릿 파일의 값이 유효하지 않습니다. 재생성합니다.\\n');\n return mintAndPersist(secretPath, fs, env, rb, logFn, isValidRelayAuthSecret, true);\n }\n\n // Inject into env — silent path (no log on successful reload).\n env.AIT_DEBUG_TOTP_SECRET = stored;\n}\n\nasync function mintAndPersist(\n secretPath: string,\n fs: RelaySecretFs,\n env: NodeJS.ProcessEnv,\n rb: (n: number) => Buffer,\n logFn: (msg: string) => void,\n isValidRelayAuthSecret: (s: string | undefined) => s is string,\n /** When re-minting over a corrupt file, the existing file must be overwritten. */\n overwrite = false,\n): Promise<void> {\n // SECRET-HANDLING: the raw bytes are never written to any log or string other\n // than the persist file and the env variable.\n const secret = rb(32).toString('hex'); // 64 hex chars = 256 bits\n\n // Self-consistency guard: our own minted secret must pass validation.\n if (!isValidRelayAuthSecret(secret)) {\n throw new Error(\n '[@ait-co/devtools] 내부 오류: mint된 시크릿이 유효성 검사를 통과하지 못했습니다.',\n );\n }\n\n // Write a SINGLE file into the already-existing project directory — no\n // directory is created. `O_EXCL` (flag 'wx') makes the create exclusive so a\n // concurrent process cannot be clobbered; on EEXIST we read the winner's value.\n // (When re-minting over a corrupt file we must overwrite, so use 'w'.)\n const flag = overwrite ? 'w' : 'wx';\n try {\n fs.writeFileSync(secretPath, secret, { mode: 0o600, flag });\n // Belt-and-suspenders: apply chmod after write in case umask relaxed the mode.\n fs.chmodSync(secretPath, 0o600);\n } catch (err) {\n if ((err as NodeJS.ErrnoException).code === 'EEXIST') {\n // Race: another process already wrote the file — read their value.\n let stored: string;\n try {\n stored = fs.readFileSync(secretPath, 'utf8').trim();\n } catch (readErr) {\n const msg = readErr instanceof Error ? readErr.message : String(readErr);\n throw new Error(`[@ait-co/devtools] relay 시크릿 파일 읽기 실패(경합): ${msg}`);\n }\n if (!isValidRelayAuthSecret(stored)) {\n throw new Error('[@ait-co/devtools] relay 시크릿 파일이 경합 후에도 유효하지 않습니다.');\n }\n env.AIT_DEBUG_TOTP_SECRET = stored;\n return;\n }\n throw err;\n }\n\n // Inject into the current process env so the immediately following\n // assertRelayAuthConfigured() / buildRelayVerifyAuth() calls see the value.\n env.AIT_DEBUG_TOTP_SECRET = secret;\n\n // First-mint announcement (value never included — SECRET-HANDLING). The file\n // name is fixed (`.ait_relay`); we do not echo the resolved directory either.\n logFn(\n `[@ait-co/devtools] relay 인증 시크릿을 생성해 프로젝트의 ${RELAY_SECRET_FILE_NAME} 파일에 저장했습니다 (권한 0600).\\n` +\n `다음 실행부터 자동으로 사용됩니다. 직접 export할 필요 없습니다.\\n` +\n `팀이 같은 relay를 공유하려면 이 파일을 repo에 커밋하세요(비공개 repo 권장).\\n` +\n `자세히: https://docs.aitc.dev/guides/relay-auth-totp\\n`,\n );\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAqCA,MAAa,yBAAyB;;;;;;;;;;;;;;;;AAmFtC,SAAgB,sBACd,OACA,cACQ;CACR,IAAI,MAAM;AAEV,QAAO,MAAM;AACX,MAAI,aAAa,KAAK,KAAK,eAAe,CAAC,CACzC,QAAO;EAET,MAAM,SAAS,QAAQ,IAAI;AAC3B,MAAI,WAAW,IAGb,QAAO;AAET,QAAM;;;;;;;;;;AAWV,SAAgB,oBACd,OACA,cACQ;AACR,QAAO,KAAK,sBAAsB,OAAO,aAAa,EAAE,uBAAuB;;;;;;;;;;;;;;;;;AAsBjF,eAAsB,kBAAkB,MAAuC;CAC7E,MAAM,EACJ,aACA,MAAM,QAAQ,KACd,aAAa,eACb,IAAI,OACJ,YAAY,eACZ,KAAK,OACL,QACE,QAAQ,EAAE;CAEd,MAAM,QAA+B,SAAS,QAAgB,QAAQ,OAAO,MAAM,IAAI;CAIvF,MAAM,EAAE,2BAA2B,MAAM,OAAO;AAGhD,KAAI,uBAAuB,IAAI,sBAAsB,CACnD;CAIF,MAAM,KAA4B,kBAAkB,MAAM,OAAO,gBAAgB;CACjF,MAAM,KAAoB,SAAU,MAAM,OAAO;CACjD,MAAM,eAA0C,iBAAiB,GAAG;CAGpE,MAAM,aAAa,oBADL,gBAAgB,gBAAgB,QAAQ,KAAK,IAAI,EACjB,aAAa;AAG3D,KAAI,GAAG,WAAW,WAAW,CAC3B,QAAO,cAAc,YAAY,IAAI,KAAK,OAAO,wBAAwB,GAAG;AAI9E,QAAO,eAAe,YAAY,IAAI,KAAK,IAAI,OAAO,uBAAuB;;AA0E/E,eAAe,cACb,YACA,IACA,KACA,OACA,wBACA,IACe;CACf,IAAI;AACJ,KAAI;AACF,WAAS,GAAG,aAAa,YAAY,OAAO,CAAC,MAAM;UAC5C,KAAK;EACZ,MAAM,MAAM,eAAe,QAAQ,IAAI,UAAU,OAAO,IAAI;AAC5D,QAAM,IAAI,MAAM,0CAA0C,MAAM;;AAGlE,KAAI,CAAC,uBAAuB,OAAO,EAAE;AAEnC,QAAM,2DAA2D;AACjE,SAAO,eAAe,YAAY,IAAI,KAAK,IAAI,OAAO,wBAAwB,KAAK;;AAIrF,KAAI,wBAAwB;;AAG9B,eAAe,eACb,YACA,IACA,KACA,IACA,OACA,wBAEA,YAAY,OACG;CAGf,MAAM,SAAS,GAAG,GAAG,CAAC,SAAS,MAAM;AAGrC,KAAI,CAAC,uBAAuB,OAAO,CACjC,OAAM,IAAI,MACR,2DACD;CAOH,MAAM,OAAO,YAAY,MAAM;AAC/B,KAAI;AACF,KAAG,cAAc,YAAY,QAAQ;GAAE,MAAM;GAAO;GAAM,CAAC;AAE3D,KAAG,UAAU,YAAY,IAAM;UACxB,KAAK;AACZ,MAAK,IAA8B,SAAS,UAAU;GAEpD,IAAI;AACJ,OAAI;AACF,aAAS,GAAG,aAAa,YAAY,OAAO,CAAC,MAAM;YAC5C,SAAS;IAChB,MAAM,MAAM,mBAAmB,QAAQ,QAAQ,UAAU,OAAO,QAAQ;AACxE,UAAM,IAAI,MAAM,8CAA8C,MAAM;;AAEtE,OAAI,CAAC,uBAAuB,OAAO,CACjC,OAAM,IAAI,MAAM,qDAAqD;AAEvE,OAAI,wBAAwB;AAC5B;;AAEF,QAAM;;AAKR,KAAI,wBAAwB;AAI5B,OACE,8CAA8C,uBAAuB,0KAItE"}
@@ -0,0 +1,186 @@
1
+ import { createHmac, timingSafeEqual } from "node:crypto";
2
+ //#region src/mcp/totp.ts
3
+ /**
4
+ * RFC 6238 TOTP implementation (Node.js, node:crypto only).
5
+ *
6
+ * External TOTP libraries (otplib, speakeasy, …) are intentionally NOT used
7
+ * to keep the dependency surface minimal. This hand-roll is ~30 lines and
8
+ * covers exactly what relay-side auth needs.
9
+ *
10
+ * Algorithm summary (RFC 6238 + RFC 4226):
11
+ * T = floor(now / 30) — 30-second time step counter
12
+ * K = Buffer.from(secret, 'hex') — shared secret (raw bytes, hex-encoded)
13
+ * MAC = HMAC-SHA1(K, T as 8-byte big-endian uint64)
14
+ * offset = MAC[19] & 0x0f
15
+ * code = (MAC[offset..offset+4] & 0x7fffffff) % 10^6 — 6 digits
16
+ *
17
+ * Security note (keep this comment accurate):
18
+ * The baked-in secret in a dogfood build is extractable from the bundle by a
19
+ * determined reverse engineer. This mechanism raises the bar from
20
+ * "anyone with the URL" to "URL + bundle extraction + live TOTP calculation".
21
+ * Casual URL leaks (Slack paste, QR screenshot, shoulder-surfing) are
22
+ * blocked; deliberate reverse engineering is not. See threat model in
23
+ * src/mcp/chii-relay.ts and umbrella CLAUDE.md §4.
24
+ *
25
+ * SECRET-HANDLING: secret values and computed codes MUST NOT appear in any
26
+ * log, error message, or string visible outside this module. Only boolean
27
+ * pass/fail and reason enum values are safe to surface.
28
+ */
29
+ /** Time step window in seconds (RFC 6238 default). */
30
+ const TIME_STEP = 30;
31
+ /** Number of digits in the generated code. */
32
+ const DIGITS = 6;
33
+ /**
34
+ * Derives a 6-digit TOTP code from a hex-encoded secret at the given wall-
35
+ * clock time.
36
+ *
37
+ * @param secret - The shared secret as a hex string (e.g. 64 hex chars = 32
38
+ * bytes). Must be the output of `generateAttachToken()` or compatible.
39
+ * @param when - Unix timestamp in milliseconds. Defaults to `Date.now()`.
40
+ * @returns A zero-padded 6-digit decimal string, e.g. `"042193"`.
41
+ */
42
+ function generateTotp(secret, when = Date.now()) {
43
+ const key = Buffer.from(secret, "hex");
44
+ const counter = Math.max(0, Math.floor(when / 1e3 / TIME_STEP));
45
+ const counterBuf = Buffer.alloc(8);
46
+ const hi = Math.floor(counter / 4294967296);
47
+ const lo = counter >>> 0;
48
+ counterBuf.writeUInt32BE(hi, 0);
49
+ counterBuf.writeUInt32BE(lo, 4);
50
+ const mac = createHmac("sha1", key).update(counterBuf).digest();
51
+ const offset = mac[19] & 15;
52
+ return (((mac[offset] & 127) << 24 | (mac[offset + 1] & 255) << 16 | (mac[offset + 2] & 255) << 8 | mac[offset + 3] & 255) % 10 ** DIGITS).toString().padStart(DIGITS, "0");
53
+ }
54
+ /**
55
+ * Verifies a TOTP code against the secret, accepting ±`skew` time steps to
56
+ * tolerate clock drift between the relay host and the client device.
57
+ *
58
+ * Uses `timingSafeEqual` for constant-time comparison to prevent timing
59
+ * side-channel attacks.
60
+ *
61
+ * @param secret - Hex-encoded shared secret.
62
+ * @param code - The 6-digit code to verify (string or numeric).
63
+ * @param when - Unix timestamp in milliseconds. Defaults to `Date.now()`.
64
+ * @param skew - Number of adjacent steps to accept on either side. Default 1
65
+ * (accepts T-1, T, T+1 — a 90-second acceptance window).
66
+ * @returns `true` if the code matches any accepted step, `false` otherwise.
67
+ */
68
+ function verifyTotp(secret, code, when = Date.now(), skew = 1) {
69
+ const normalised = String(code).padStart(DIGITS, "0");
70
+ if (normalised.length !== DIGITS || !/^\d{6}$/.test(normalised)) return false;
71
+ const candidateBuf = Buffer.from(normalised, "utf8");
72
+ for (let delta = -skew; delta <= skew; delta++) {
73
+ const expected = generateTotp(secret, when + delta * TIME_STEP * 1e3);
74
+ if (timingSafeEqual(Buffer.from(expected, "utf8"), candidateBuf)) return true;
75
+ }
76
+ return false;
77
+ }
78
+ /**
79
+ * Minimum length (in hex characters) accepted for `AIT_DEBUG_TOTP_SECRET`.
80
+ *
81
+ * The secret is hex-encoded (see {@link generateTotp} — `Buffer.from(secret,
82
+ * 'hex')`). 32 hex chars = 16 bytes = 128 bits, the floor for an HMAC-SHA1 key
83
+ * we are willing to gate a public relay behind. `generateAttachToken()` emits
84
+ * 64 hex chars (32 bytes), comfortably above this bar.
85
+ */
86
+ const MIN_SECRET_HEX_CHARS = 32;
87
+ /** Hex string: one or more hex digits, case-insensitive (RFC 4648 base16). */
88
+ const HEX_RE = /^[0-9a-fA-F]+$/;
89
+ /**
90
+ * Human-facing guidance printed when {@link assertRelayAuthConfigured} fails.
91
+ *
92
+ * SECRET-HANDLING: this message states only the REQUIREMENT (≥32 hex chars) and
93
+ * how to mint one. It NEVER echoes the configured value, its length, or any
94
+ * fragment derived from it — see {@link assertRelayAuthConfigured}.
95
+ *
96
+ * Note on encoding: the secret is hex (base16), not base32 — `generateTotp`
97
+ * decodes it with `Buffer.from(secret, 'hex')`. A base32 string would be
98
+ * silently mis-decoded and every TOTP code would fail to match, so the minting
99
+ * command emits hex.
100
+ */
101
+ const RELAY_AUTH_SECRET_MISSING_MESSAGE = [
102
+ "[ait-debug] AIT_DEBUG_TOTP_SECRET이 필수입니다. 32자 이상 16진수(hex) 문자열을 설정하세요.",
103
+ "발급: openssl rand -hex 32",
104
+ "데몬은 start_debug의 projectRoot 인자로 받은 디렉토리에서 .ait_relay 파일을 읽어 이 시크릿을 채웁니다.",
105
+ "프로젝트에서 pnpm dev를 한 번 띄우면 unplugin이 .ait_relay를 자동 생성하니, projectRoot를 전달하세요.",
106
+ "자세히: https://docs.aitc.dev/guides/relay-auth-totp"
107
+ ].join("\n");
108
+ /**
109
+ * Whether `secret` is a well-formed relay-auth TOTP secret: a hex string of at
110
+ * least {@link MIN_SECRET_HEX_CHARS} characters with an even length (an odd
111
+ * length would have its trailing nibble silently dropped by `Buffer.from(...,
112
+ * 'hex')`, weakening the key without warning).
113
+ *
114
+ * Pure predicate so callers can test the validation independently of the
115
+ * fail-fast side effect in {@link assertRelayAuthConfigured}.
116
+ *
117
+ * SECRET-HANDLING: returns only a boolean — the input value is never returned,
118
+ * logged, or echoed.
119
+ */
120
+ function isValidRelayAuthSecret(secret) {
121
+ if (secret === void 0 || secret === "") return false;
122
+ if (secret.length < MIN_SECRET_HEX_CHARS) return false;
123
+ if (secret.length % 2 !== 0) return false;
124
+ return HEX_RE.test(secret);
125
+ }
126
+ /**
127
+ * Fail-fast guard enforcing that a relay-auth TOTP secret is configured before
128
+ * a public-internet-exposed relay is booted (issue #250).
129
+ *
130
+ * Relay-auth (the §4 Layer C TOTP gate) is the only fail-fast layer that closes
131
+ * the real gap: a leaked `wss://…trycloudflare.com` URL otherwise lets a third
132
+ * party attach a debugger to a dogfood/live mini-app. Without a secret the relay
133
+ * comes up unauthenticated, so this guard is called at every relay-boot site —
134
+ * `bootRelayFamily` (intoss env 3/4) and `bootExternalRelayFamily` (env-2 PWA),
135
+ * both eager and lazy. Local-only sessions never boot a relay and so never reach
136
+ * this guard, matching the issue's exemption for non-relay debugging.
137
+ *
138
+ * Throws when the secret is unset, empty, too short, or not a valid hex string.
139
+ * The thrown message is the bin entry's fatal stderr (see `cli.ts` `main().catch`)
140
+ * — the same fatal model as the missing-`AIT_RELAY_BASE_URL` path.
141
+ *
142
+ * SECRET-HANDLING: the env value is read once, passed ONLY to the boolean
143
+ * predicate, and never logged. The thrown message names the requirement, never
144
+ * the value, its length, or any derived fragment.
145
+ *
146
+ * @param env - Environment to read from. Defaults to `process.env`; injectable
147
+ * for tests so they never mutate the real process environment.
148
+ */
149
+ function assertRelayAuthConfigured(env = process.env) {
150
+ if (!isValidRelayAuthSecret(env.AIT_DEBUG_TOTP_SECRET)) throw new Error(RELAY_AUTH_SECRET_MISSING_MESSAGE);
151
+ }
152
+ /**
153
+ * Reads `AIT_DEBUG_TOTP_SECRET` from `process.env` at runtime and builds a
154
+ * `verifyAuth` predicate for the Chii relay's WebSocket upgrade gate.
155
+ *
156
+ * The predicate checks the `at` query parameter against the current and
157
+ * adjacent TOTP time steps (±1 skew) using {@link verifyTotp}.
158
+ *
159
+ * Returns `undefined` when the env var is not set — callers treat that as
160
+ * "auth disabled" (no predicate registered on the relay). Note that since
161
+ * issue #250 the secret is MANDATORY at every relay-boot site (enforced by
162
+ * {@link assertRelayAuthConfigured} BEFORE the relay starts), so in production
163
+ * this never returns `undefined` for a relay that actually boots; the
164
+ * `undefined` branch only matters for the no-relay local path and tests.
165
+ *
166
+ * Lives here (not in the MCP server) so the unplugin's env-2 relay can wire the
167
+ * same gate without importing the heavy MCP server module graph. Re-exported
168
+ * from `debug-server.ts` for back-compat.
169
+ *
170
+ * SECRET-HANDLING: The secret value read from env is captured in a closure and
171
+ * is NEVER written to any log, error message, or process output.
172
+ */
173
+ function buildRelayVerifyAuth(env = process.env) {
174
+ const secret = env.AIT_DEBUG_TOTP_SECRET;
175
+ if (!secret) return void 0;
176
+ return (req) => {
177
+ const rawUrl = req.url ?? "";
178
+ const qIndex = rawUrl.indexOf("?");
179
+ const queryStr = qIndex === -1 ? "" : rawUrl.slice(qIndex + 1);
180
+ return verifyTotp(secret, new URLSearchParams(queryStr).get("at") ?? "");
181
+ };
182
+ }
183
+ //#endregion
184
+ export { assertRelayAuthConfigured, buildRelayVerifyAuth, isValidRelayAuthSecret };
185
+
186
+ //# sourceMappingURL=totp-BkP5yU2K.js.map
@@ -0,0 +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"}
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ import { a as isValidRelayAuthSecret } from "./totp-D0a8VwoR.js";
3
+ export { isValidRelayAuthSecret };