dowafu 0.4.0 → 0.5.0

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/doctor.js CHANGED
@@ -11,8 +11,6 @@ import fs from "node:fs";
11
11
  import os from "node:os";
12
12
  import path from "node:path";
13
13
  import { resolveDispatchHome } from "./dispatch-home.js";
14
- import { SECRET_ENV_VARS } from "./secret-env.js";
15
- import { PROVIDERS_FORMAT_VERSION } from "./providers.js";
16
14
  import { lensClosingLineStatus } from "./report.js";
17
15
  import { m } from "./messages.js";
18
16
  const DEFAULT_PROBE = {
@@ -41,74 +39,136 @@ const DEFAULT_PROBE = {
41
39
  }
42
40
  },
43
41
  };
44
- function buildConfigDirValue(lang, env, dispatchHome) {
45
- if (dispatchHome === null)
46
- return m(lang, "doctorConfigDirUnresolved");
47
- if (env.DISPATCH_HOME)
48
- return m(lang, "doctorConfigDirSourceDispatchHome", dispatchHome);
49
- if (env.XDG_CONFIG_HOME)
50
- return m(lang, "doctorConfigDirSourceXdgConfigHome", dispatchHome);
51
- return m(lang, "doctorConfigDirSourceDefault", dispatchHome);
42
+ // 工單 key-db §B-3 #6:逐 provider 印出實際生效的是哪一層,不是只印 process.env 的 ✓/✗。
43
+ // keyStatusRows 由 cli.ts 呼叫 provider-key.ts 的 buildKeyStatusRows() 算好傳進來——
44
+ // 本檔維持同步、純函式(見檔頭「providers.json 的載入是唯一沒有走這條注入路徑」的慣例,
45
+ // DB 也一樣不在本檔內讀取)。
46
+ function buildApiKeyValue(lang, rows) {
47
+ if (rows.length === 0)
48
+ return m(lang, "doctorApiKeySourceMissing");
49
+ return rows
50
+ .map((row) => {
51
+ const name = row.provider.toUpperCase();
52
+ const status = row.maskedTail === null
53
+ ? m(lang, "doctorApiKeySourceMissing")
54
+ : m(lang, "doctorApiKeySourceDb", row.maskedTail, row.updatedAt ? row.updatedAt.slice(0, 10) : "") +
55
+ (row.lastTestSucceeded === null || row.lastTestedAt === null || row.lastTestModel === null
56
+ ? m(lang, "keyStatusUntested")
57
+ : m(lang, row.lastTestSucceeded ? "keyStatusTestSucceeded" : "keyStatusTestFailed", row.lastTestModel, row.lastTestedAt.slice(0, 10)));
58
+ return m(lang, "doctorApiKeyRow", name, status);
59
+ })
60
+ .join(" ");
52
61
  }
53
- function buildEnvValue(lang, probe, dispatchHome) {
54
- if (dispatchHome === null)
55
- return m(lang, "doctorEnvUnresolvedValue");
56
- const envPath = path.join(dispatchHome, ".env");
57
- return probe.fileExists(envPath) ? m(lang, "doctorEnvPresentValue") : m(lang, "doctorEnvMissingValue", envPath);
62
+ function buildDaemonValue(lang, daemon) {
63
+ if (daemon?.alive)
64
+ return m(lang, "doctorDaemonAlive", daemon.pid);
65
+ return m(lang, "doctorDaemonMissing", daemon?.reason ?? "heartbeat_missing");
58
66
  }
59
- function buildModelListValue(lang, providers) {
60
- if (!providers.ok)
61
- return m(lang, "doctorModelListLoadFailedValue", providers.reason);
62
- const joiner = lang === "zh" ? "、" : ", ";
63
- const items = Object.entries(providers.providers)
64
- .map(([name, config]) => m(lang, "doctorProviderCountItem", name, config.models.length))
65
- .join(joiner);
66
- return m(lang, "doctorModelListValue", PROVIDERS_FORMAT_VERSION, items);
67
+ function buildConfigDirValue(lang, configDir) {
68
+ if (configDir.source === "unresolved")
69
+ return m(lang, "doctorConfigDirUnresolved");
70
+ if (configDir.source === "dispatch_home")
71
+ return m(lang, "doctorConfigDirSourceDispatchHome", configDir.path);
72
+ if (configDir.source === "xdg_config_home")
73
+ return m(lang, "doctorConfigDirSourceXdgConfigHome", configDir.path);
74
+ return m(lang, "doctorConfigDirSourceDefault", configDir.path);
75
+ }
76
+ // A(gaps #2):本版根本不讀 `.env`,所以「這個檔不存在」不是一則診斷,是雜訊——它讀起來
77
+ // 像「你少了這個檔」。**只有檔案真的在的時候才印那一行**,措辭沿用既有的「有,但本版不讀取」
78
+ // ——那句對從 0.4.x 升上來、家目錄還躺著舊 `.env` 的人正好是需要的提醒。
79
+ // 回傳 null 代表整行不印(不是印一行「沒有」)。
80
+ function buildModelListValue(lang, models) {
81
+ if (!models.ok)
82
+ return m(lang, "doctorModelListLoadFailedValue", models.reason);
83
+ const name = models.mostExpensive
84
+ ? `${models.mostExpensive.provider}/${models.mostExpensive.model} ($${models.mostExpensive.outputPerM}/M)`
85
+ : m(lang, "noneLabel");
86
+ return m(lang, "doctorModelListValue", models.enabled, models.total, name);
67
87
  }
68
88
  // 工單 X1 v1.1 §三:doctor 不再只認 `hole-finder` 這個名字——只裝 translation-* 一類 lens
69
89
  // 的專案先前會被回報 0 個,使用者會誤以為裝錯了。改列出 `.claude/agents/` 底下所有 `.md`,
70
90
  // 並依「檔案內文最後一句是不是固定收尾句」分成兩組(判定沿用 report.ts 的
71
91
  // lensClosingLineStatus,不在此重寫)。沒有收尾句不是錯誤——`explore-haiku.md` 就是這種。
72
- function buildLensValue(lang, probe, cwd) {
73
- const lensDir = path.join(cwd, ".claude", "agents");
74
- const entries = probe.readDir(lensDir);
92
+ function buildLensData(probe, cwd) {
93
+ const dirPath = path.join(cwd, ".claude", "agents");
94
+ const entries = probe.readDir(dirPath);
75
95
  if (entries === null)
76
- return m(lang, "doctorLensDirMissingValue", lensDir);
77
- const joiner = lang === "zh" ? "、" : ", ";
78
- const mdFiles = entries.filter((f) => f.endsWith(".md"));
96
+ return { found: false, dirPath };
79
97
  const withClosing = [];
80
98
  const withoutClosing = [];
81
- for (const file of mdFiles) {
99
+ for (const file of entries.filter((f) => f.endsWith(".md"))) {
82
100
  const name = file.slice(0, -".md".length);
83
- const content = probe.readFile(path.join(lensDir, file));
101
+ const content = probe.readFile(path.join(dirPath, file));
84
102
  // 讀不到內容(不存在於這一刻、權限不足…)不得讓整支 doctor 失敗——歸入無收尾句一組。
85
103
  // 取捨:輸出上這與「確實沒有收尾句」分不出來,讀者看到的都是「無收尾句」;工單允許
86
104
  // 「無法判定」或「無收尾句」二選一,這裡選後者,不是宣稱「照實說」出兩者的差異。
87
105
  const status = content !== null ? lensClosingLineStatus(content) : "none";
88
106
  (status === "none" ? withoutClosing : withClosing).push(name);
89
107
  }
90
- // 熱修補(2026-08-15):對「去掉 .md 的名字」排序,不是對檔名排序——'-'(45) < '.'(46),
91
- // 對檔名排序會讓無後綴的 hole-finder.md 排到 hole-finder-cost.md 之後,看起來像被降級。
92
108
  withClosing.sort();
93
109
  withoutClosing.sort();
94
- const noClosingSuffix = withoutClosing.length > 0
95
- ? m(lang, "doctorLensNoClosingSuffix", withoutClosing.length, withoutClosing.join(joiner))
96
- : "";
97
- const closingNames = withClosing.length > 0 ? withClosing.join(joiner) : m(lang, "noneLabel");
98
- return m(lang, "doctorLensFoundValue", lensDir, mdFiles.length, withClosing.length, closingNames, noClosingSuffix);
110
+ return { found: true, dirPath, withClosing, withoutClosing };
99
111
  }
100
- export function buildDoctorReport(lang, cmd, providers, env = process.env, homedir = os.homedir, probe = DEFAULT_PROBE, cwd = process.cwd()) {
112
+ function buildLensValue(lang, lenses) {
113
+ if (!lenses.found)
114
+ return m(lang, "doctorLensDirMissingValue", lenses.dirPath);
115
+ const joiner = lang === "zh" ? "、" : ", ";
116
+ const noClosingSuffix = lenses.withoutClosing.length > 0 ? m(lang, "doctorLensNoClosingSuffix", lenses.withoutClosing.length, lenses.withoutClosing.join(joiner)) : "";
117
+ const closingNames = lenses.withClosing.length > 0 ? lenses.withClosing.join(joiner) : m(lang, "noneLabel");
118
+ return m(lang, "doctorLensFoundValue", lenses.dirPath, lenses.withClosing.length + lenses.withoutClosing.length, lenses.withClosing.length, closingNames, noClosingSuffix);
119
+ }
120
+ export function buildDoctorReportData(cmd, providers, env = process.env, homedir = os.homedir, probe = DEFAULT_PROBE, cwd = process.cwd(), daemon, keyStatusRows = [], dbPath) {
101
121
  const dispatchHome = resolveDispatchHome(env, homedir);
102
- const apiKeyList = SECRET_ENV_VARS.map((k) => `${k.replace(/_API_KEY$/, "")} ${env[k] ? "✓" : "✗"}`).join(" ");
122
+ const configDir = dispatchHome === null
123
+ ? { source: "unresolved" }
124
+ : env.DISPATCH_HOME
125
+ ? { source: "dispatch_home", path: dispatchHome }
126
+ : env.XDG_CONFIG_HOME
127
+ ? { source: "xdg_config_home", path: dispatchHome }
128
+ : { source: "default", path: dispatchHome };
129
+ const models = !providers.ok
130
+ ? { ok: false, reason: providers.reason }
131
+ : (() => {
132
+ const enabled = providers.models.filter((model) => model.enabled);
133
+ const mostExpensive = enabled.filter((model) => model.outputPerM !== undefined).sort((a, b) => (b.outputPerM ?? 0) - (a.outputPerM ?? 0))[0];
134
+ return {
135
+ ok: true,
136
+ enabled: enabled.length,
137
+ total: providers.models.length,
138
+ mostExpensive: mostExpensive && mostExpensive.outputPerM !== undefined
139
+ ? { provider: mostExpensive.provider, model: mostExpensive.model, outputPerM: mostExpensive.outputPerM }
140
+ : null,
141
+ };
142
+ })();
143
+ // cmd remains part of the human presentation header, not a diagnostic observation.
144
+ void cmd;
145
+ return {
146
+ configDir,
147
+ ...(dbPath ? { dbPath } : {}),
148
+ envFilePresent: dispatchHome !== null && probe.fileExists(path.join(dispatchHome, ".env")),
149
+ apiKeys: keyStatusRows.map((row) => ({ ...row })),
150
+ models,
151
+ daemon: daemon ?? { alive: false, reason: "heartbeat_missing" },
152
+ lenses: buildLensData(probe, cwd),
153
+ };
154
+ }
155
+ export function renderDoctorReport(lang, cmd, data) {
103
156
  const lines = [
104
157
  m(lang, "doctorHeader", cmd),
105
- m(lang, "doctorConfigDirLine", buildConfigDirValue(lang, env, dispatchHome)),
106
- m(lang, "doctorEnvLine", buildEnvValue(lang, probe, dispatchHome)),
107
- m(lang, "doctorApiKeyLine", apiKeyList),
108
- m(lang, "doctorModelListLine", buildModelListValue(lang, providers)),
109
- m(lang, "doctorLensLine", buildLensValue(lang, probe, cwd)),
158
+ m(lang, "doctorConfigDirLine", buildConfigDirValue(lang, data.configDir)),
159
+ // A(gaps #1):這一行印的是**這次真的會用到的** DB 檔——`--db` 帶了就是它。
160
+ // 先前 doctor 寫死走預設 DB,於是 `--doctor --db <別的>` 會對著另一個資料庫回答你。
161
+ ...(data.dbPath ? [m(lang, "doctorDbLine", data.dbPath)] : []),
162
+ ...(data.envFilePresent ? [m(lang, "doctorEnvLine", m(lang, "doctorEnvPresentValue"))] : []),
163
+ m(lang, "doctorApiKeyLine", buildApiKeyValue(lang, data.apiKeys)),
164
+ m(lang, "doctorModelListLine", buildModelListValue(lang, data.models)),
165
+ m(lang, "doctorDaemonLine", buildDaemonValue(lang, data.daemon)),
166
+ m(lang, "doctorLensLine", buildLensValue(lang, data.lenses)),
110
167
  "",
111
168
  m(lang, "doctorFooter"),
112
169
  ];
113
170
  return lines.join("\n");
114
171
  }
172
+ export function buildDoctorReport(lang, cmd, providers, env = process.env, homedir = os.homedir, probe = DEFAULT_PROBE, cwd = process.cwd(), daemon, keyStatusRows = [], dbPath) {
173
+ return renderDoctorReport(lang, cmd, buildDoctorReportData(cmd, providers, env, homedir, probe, cwd, daemon, keyStatusRows, dbPath));
174
+ }
package/dist/gate.js CHANGED
@@ -12,12 +12,17 @@ function estimateTokensFromChars(chars, charsPerToken) {
12
12
  export function estimateTokens(text, charsPerToken) {
13
13
  return estimateTokensFromChars(text.length, charsPerToken);
14
14
  }
15
- export function estimateAllowlistTokens(filePaths, charsPerToken) {
16
- const totalChars = filePaths.reduce((sum, p) => sum + fs.readFileSync(p, "utf8").length, 0);
15
+ // D1 consumes captured contents. The small filesystem fallback only keeps the old
16
+ // unit-test helper callable; the CLI always passes DB content and never takes it.
17
+ function compatibilityContent(value) {
18
+ return fs.existsSync(value) ? fs.readFileSync(value, "utf8") : value;
19
+ }
20
+ export function estimateAllowlistTokens(contents, charsPerToken) {
21
+ const totalChars = contents.reduce((sum, content) => sum + compatibilityContent(content).length, 0);
17
22
  return estimateTokensFromChars(totalChars, charsPerToken);
18
23
  }
19
- export function estimateSequentialRead(filePaths, charsPerToken) {
20
- const sizes = filePaths.map((p) => fs.readFileSync(p, "utf8").length);
24
+ export function estimateSequentialRead(contents, charsPerToken) {
25
+ const sizes = contents.map((content) => compatibilityContent(content).length);
21
26
  const amplify = (ordered) => {
22
27
  const n = ordered.length;
23
28
  const chars = ordered.reduce((sum, s, i) => sum + s * (n - i), 0);
package/dist/job.js ADDED
@@ -0,0 +1,97 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { now } from "./db.js";
3
+ export const JOB_STATUSES = [
4
+ "pending_approval",
5
+ "approved",
6
+ "running",
7
+ "completed",
8
+ "failed",
9
+ "interrupted",
10
+ "rejected",
11
+ ];
12
+ function one(db, sql, ...values) {
13
+ return db.prepare(sql).get(...values) ?? null;
14
+ }
15
+ export function getJob(db, id) {
16
+ return one(db, "SELECT * FROM jobs WHERE id = ?", id);
17
+ }
18
+ export function findJobsByIdPrefix(db, prefix) {
19
+ return db.prepare("SELECT * FROM jobs WHERE id LIKE ? ORDER BY id").all(`${prefix}%`);
20
+ }
21
+ export function listPendingApprovalJobs(db) {
22
+ return db.prepare("SELECT * FROM jobs WHERE status = 'pending_approval' ORDER BY created_at, id").all();
23
+ }
24
+ export function submitJob(db, ticket, id = randomUUID()) {
25
+ if (!db.prepare("SELECT 1 FROM tickets WHERE name = ?").get(ticket))
26
+ throw new Error(`ticket does not exist: ${ticket}`);
27
+ const createdAt = now();
28
+ db.prepare("INSERT INTO jobs (id, ticket, status, created_at) VALUES (?, ?, 'pending_approval', ?)").run(id, ticket, createdAt);
29
+ return getJob(db, id);
30
+ }
31
+ export function approveJob(db, id) {
32
+ return one(db, `UPDATE jobs
33
+ SET status = 'approved', approved_at = ?
34
+ WHERE id = ? AND status = 'pending_approval'
35
+ RETURNING *`, now(), id);
36
+ }
37
+ export function rejectJob(db, id) {
38
+ return one(db, `UPDATE jobs
39
+ SET status = 'rejected', finished_at = ?
40
+ WHERE id = ? AND status IN ('pending_approval', 'approved')
41
+ RETURNING *`, now(), id);
42
+ }
43
+ // This is deliberately one guarded UPDATE. A second daemon can observe the same
44
+ // approved row, but only one can transition it to running and receive it back.
45
+ export function claimNextJob(db) {
46
+ const claimedAt = now();
47
+ return one(db, `UPDATE jobs
48
+ SET status = 'running', claimed_at = ?, heartbeat_at = ?
49
+ WHERE id = (
50
+ SELECT id FROM jobs WHERE status = 'approved' ORDER BY created_at, id LIMIT 1
51
+ ) AND status = 'approved'
52
+ RETURNING *`, claimedAt, claimedAt);
53
+ }
54
+ export function heartbeatJob(db, id) {
55
+ db.prepare("UPDATE jobs SET heartbeat_at = ? WHERE id = ? AND status = 'running'").run(now(), id);
56
+ }
57
+ export function finishJob(db, id, result) {
58
+ const status = result.error ? "failed" : "completed";
59
+ return one(db, `UPDATE jobs
60
+ SET status = ?, finished_at = ?, heartbeat_at = NULL, error = ?, cost_usd = ?
61
+ WHERE id = ? AND status = 'running'
62
+ RETURNING *`, status, now(), result.error ?? null, result.costUsd, id);
63
+ }
64
+ // A worker can outlive its daemon: it may persist a spoke result after the first reap has
65
+ // marked its job interrupted. Revisit only a bounded recent window (30 minutes, well over
66
+ // the 15-second stale threshold and normal worker shutdown lag), rather than scanning all
67
+ // historical interrupted jobs every five-second daemon tick.
68
+ export const REAP_INTERRUPTED_LOOKBACK_MS = 30 * 60 * 1_000;
69
+ // `resultCost` stays in daemon.ts because that module owns the result-cost calculation used
70
+ // by normal finishJob too. This function owns the state transition and asks its caller for
71
+ // that existing calculation only after it finds a real (non-summary) spoke row.
72
+ export function reapInterruptedJobs(db, staleBefore, resultCost, reapedAt = now()) {
73
+ const revisitAfter = new Date(Date.parse(reapedAt) - REAP_INTERRUPTED_LOOKBACK_MS).toISOString();
74
+ const candidates = db
75
+ .prepare(`SELECT id, status FROM jobs
76
+ WHERE (status = 'running' AND (heartbeat_at IS NULL OR heartbeat_at < ?))
77
+ OR (status = 'interrupted' AND finished_at >= ?)
78
+ ORDER BY id`)
79
+ .all(staleBefore, revisitAfter);
80
+ const hasResult = db.prepare("SELECT 1 FROM results WHERE job_id = ? AND agent != '__summary__' LIMIT 1");
81
+ const complete = db.prepare(`UPDATE jobs
82
+ SET status = 'completed', finished_at = ?, heartbeat_at = NULL, cost_usd = ?
83
+ WHERE id = ? AND status IN ('running', 'interrupted')`);
84
+ const interrupt = db.prepare(`UPDATE jobs
85
+ SET status = 'interrupted', finished_at = ?, heartbeat_at = NULL
86
+ WHERE id = ? AND status = 'running'`);
87
+ let changes = 0;
88
+ for (const job of candidates) {
89
+ if (hasResult.get(job.id)) {
90
+ changes += Number(complete.run(reapedAt, resultCost(job.id), job.id).changes);
91
+ }
92
+ else if (job.status === "running") {
93
+ changes += Number(interrupt.run(reapedAt, job.id).changes);
94
+ }
95
+ }
96
+ return changes;
97
+ }
@@ -0,0 +1,39 @@
1
+ import fs from "node:fs";
2
+ import { m } from "./messages.js";
3
+ export const DAEMON_STALE_MS = 15_000;
4
+ const DEFAULT_DEPS = {
5
+ readFile: fs.readFileSync,
6
+ writeFile: fs.writeFileSync,
7
+ kill: process.kill.bind(process),
8
+ now: Date.now,
9
+ };
10
+ export function daemonStatePath(dbPath) {
11
+ return `${dbPath}.daemon`;
12
+ }
13
+ export function writeHeartbeat(statePath, pid = process.pid, deps = DEFAULT_DEPS) {
14
+ deps.writeFile(statePath, JSON.stringify({ pid, at: deps.now() }));
15
+ }
16
+ export function daemonAlive(statePath, deps = DEFAULT_DEPS) {
17
+ let heartbeat;
18
+ try {
19
+ heartbeat = JSON.parse(deps.readFile(statePath, "utf8"));
20
+ }
21
+ catch {
22
+ return { alive: false, reason: "heartbeat_missing" };
23
+ }
24
+ if (!Number.isInteger(heartbeat.pid) || !Number.isFinite(heartbeat.at))
25
+ return { alive: false, reason: "heartbeat_missing" };
26
+ if (deps.now() - heartbeat.at > DAEMON_STALE_MS)
27
+ return { alive: false, reason: "heartbeat_stale" };
28
+ try {
29
+ deps.kill(heartbeat.pid, 0);
30
+ }
31
+ catch {
32
+ return { alive: false, reason: "pid_gone" };
33
+ }
34
+ return { alive: true, pid: heartbeat.pid };
35
+ }
36
+ // submit/status callers use this rather than treating an offline queue as success.
37
+ export function daemonWarning(state, lang) {
38
+ return state.alive ? null : m(lang, "daemonOfflineWarning", state.reason);
39
+ }
@@ -0,0 +1,178 @@
1
+ // 工單 C §A:daemon 上的 HTTP binding(Streamable HTTP、POST-only、無狀態)。
2
+ // 協定層共用 protocol.ts 的 handleRpc,一行都不重複——HTTP 只是換一個傳輸層把訊息餵進去。
3
+ //
4
+ // 參考實作 tmp/mcp-mvp/http.mjs(128 行,claude.ai 與 Codex 兩個真 client 上跑通過)。
5
+ // 照抄形狀,不照抄品質;C-1 的 body 上限判定時機刻意不同(見下方 onData)。
6
+ import http from "node:http";
7
+ import { findTokenIdForPresented } from "../api-token.js";
8
+ import { createProtocol } from "./protocol.js";
9
+ export const HTTP_PATH = "/mcp";
10
+ export const DEFAULT_HTTP_PORT = 7391;
11
+ export const DEFAULT_MAX_BODY_BYTES = 1_000_000;
12
+ // C-2:計數存行程內記憶體即可(daemon 是單一行程)。刻意取捨——daemon 重啟時計數歸零,
13
+ // 這不是漏洞,是「單人本機工具,重啟後重新累積」的可接受代價,見 report_c.md。
14
+ export const DEFAULT_RATE_LIMIT_WINDOW_MS = 60_000;
15
+ export const DEFAULT_RATE_LIMIT_MAX_REQUESTS = 30;
16
+ // A-2 #12:憑證只記長度,絕不記值,log 與診斷輸出皆然。
17
+ const SECRET_HEADER = /^(x-api-key|x-apikey|authorization|cookie|set-cookie)$/i;
18
+ function headerSummary(headers) {
19
+ return Object.entries(headers)
20
+ .map(([key, value]) => (SECRET_HEADER.test(key) ? `${key}=<len:${String(value ?? "").length}>` : `${key}=${JSON.stringify(value)}`))
21
+ .join(" ");
22
+ }
23
+ // C-2:per-token,不是 per-IP——綁 127.0.0.1 + tunnel 之下所有外部流量的來源 IP 都相同,
24
+ // per-IP 在這個形態下等於沒有。未認證請求(無效 token 或未帶)全部落在同一個 "unauth"
25
+ // 桶,讓「猜 token 的人」本身受限,不因為每次換一個猜測值就重新得到額度。
26
+ export function rateLimitBucketKey(tokenId) {
27
+ return tokenId ? `token:${tokenId}` : "unauth";
28
+ }
29
+ function createRateLimiter(windowMs, max) {
30
+ const buckets = new Map();
31
+ return {
32
+ check(key, nowMs) {
33
+ const bucket = buckets.get(key);
34
+ if (!bucket || nowMs - bucket.windowStart >= windowMs) {
35
+ buckets.set(key, { count: 1, windowStart: nowMs });
36
+ return { limited: false, retryAfterSec: 0 };
37
+ }
38
+ bucket.count += 1;
39
+ if (bucket.count > max) {
40
+ const retryAfterSec = Math.max(1, Math.ceil((bucket.windowStart + windowMs - nowMs) / 1000));
41
+ return { limited: true, retryAfterSec };
42
+ }
43
+ return { limited: false, retryAfterSec: 0 };
44
+ },
45
+ };
46
+ }
47
+ function writeJson(res, status, body, extraHeaders = {}) {
48
+ res.writeHead(status, { "content-type": "application/json", ...extraHeaders });
49
+ res.end(JSON.stringify(body));
50
+ }
51
+ // 拆成獨立、可單元測試的 request handler(不倚賴真的 socket),createHttpServer 只是把它
52
+ // 接上 http.createServer。F 的多數變異鎖點都靠對這個函式餵假 req/res 驗證,不需要真連線。
53
+ export function createRequestHandler(options) {
54
+ const maxBodyBytes = options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES;
55
+ const rateLimiter = createRateLimiter(options.rateLimitWindowMs ?? DEFAULT_RATE_LIMIT_WINDOW_MS, options.rateLimitMax ?? DEFAULT_RATE_LIMIT_MAX_REQUESTS);
56
+ const nowMs = options.now ?? Date.now;
57
+ const log = options.log ?? ((line) => process.stderr.write(`${line}\n`));
58
+ const protocol = createProtocol({ db: options.db, dbPath: options.dbPath, lang: options.lang ?? "en" });
59
+ return (req, res) => {
60
+ const url = (req.url ?? "").split("?")[0];
61
+ log(`>>> ${req.method} ${url} headers: ${headerSummary(req.headers)}`);
62
+ if (url !== HTTP_PATH) {
63
+ // 熱修補(票 C 驗收後):404 原本不印回應行,於是 Codex 那七條 /.well-known/*
64
+ // 探測在 log 裡只看得到 `>>>` 沒有 `<<<`——看起來像伺服器沒回應,實際是回了 404。
65
+ // 「沒有紀錄」與「沒有發生」在排查時被讀成同一件事,那正是 igopms G-78。
66
+ writeJson(res, 404, { error: "not_found" });
67
+ log(`<<< 404 ${req.method} ${url}`);
68
+ return;
69
+ }
70
+ // A-3 #1:Codex 的 client 開場會送 GET /mcp(想開下行 SSE),拿到 405 之後照樣完成
71
+ // 握手(facts_mcp.md)。不要「順手」改成支援 SSE 下行。
72
+ if (req.method !== "POST") {
73
+ res.writeHead(405, { allow: "POST", "content-type": "application/json" });
74
+ res.end(JSON.stringify({ error: "method_not_allowed" }));
75
+ log(`<<< 405 ${req.method}`);
76
+ return;
77
+ }
78
+ // A-2 #4:防護順序固定 auth → accept → body,不可調換。rate limit 綁在 auth 的判定
79
+ // 結果上(用哪個桶)一併在這裡處理,仍在 accept/body 之前。
80
+ const presentedRaw = req.headers["x-api-key"];
81
+ const presented = typeof presentedRaw === "string" ? presentedRaw : undefined;
82
+ const tokenId = findTokenIdForPresented(options.db, presented);
83
+ const rl = rateLimiter.check(rateLimitBucketKey(tokenId), nowMs());
84
+ if (rl.limited) {
85
+ res.writeHead(429, { "retry-after": String(rl.retryAfterSec), "content-type": "application/json" });
86
+ res.end(JSON.stringify({ error: "rate_limited", retryAfterSeconds: rl.retryAfterSec }));
87
+ log(`<<< 429 bucket=${rateLimitBucketKey(tokenId)} retryAfter=${rl.retryAfterSec}s`);
88
+ return;
89
+ }
90
+ if (!tokenId) {
91
+ // A-3 #3:claude.ai 下拉選單的預設 header 是 x-apikey(少一個連字號)。401 本身不
92
+ // 提這件事(那是給未認證對象看的),但診斷輸出要點出來,否則使用者看到的 401 沒有線索。
93
+ const wrongName = req.headers["x-apikey"];
94
+ const hint = wrongName ? " hint: received 'x-apikey' (missing the hyphen) — claude.ai's dropdown default header name" : "";
95
+ log(` !!! 401 x-api-key=${presented ? `<len:${presented.length}>` : "(none)"}${hint}`);
96
+ writeJson(res, 401, { error: "unauthorized" });
97
+ return;
98
+ }
99
+ // mcp-handler 也是這樣要求的:少一種型別會 406,而 406 常被 client 呈現成「連不到」
100
+ // (A-3 #2;igopms G-77/G-78 燒掉整個排查成本的失敗形態)。
101
+ const accept = String(req.headers.accept ?? "");
102
+ if (!accept.includes("application/json") || !accept.includes("text/event-stream")) {
103
+ writeJson(res, 406, { error: "not_acceptable", detail: "Client must accept both application/json and text/event-stream" });
104
+ log(`<<< 406 accept=${JSON.stringify(accept)}`);
105
+ return;
106
+ }
107
+ // C-1:不照抄 MVP 的判定時機。一超過就 destroy,不要等對方送完——不然這個上限只擋得住
108
+ // 記憶體,擋不住頻寬與行程時間。
109
+ let size = 0;
110
+ const chunks = [];
111
+ let aborted = false;
112
+ req.on("data", (chunk) => {
113
+ if (aborted)
114
+ return;
115
+ size += chunk.length;
116
+ if (size > maxBodyBytes) {
117
+ aborted = true;
118
+ writeJson(res, 413, { error: "payload_too_large" });
119
+ log(`<<< 413 size>${maxBodyBytes}`);
120
+ req.destroy();
121
+ return;
122
+ }
123
+ chunks.push(chunk);
124
+ });
125
+ req.on("error", () => {
126
+ aborted = true;
127
+ });
128
+ req.on("end", () => {
129
+ if (aborted)
130
+ return;
131
+ const raw = Buffer.concat(chunks).toString("utf8");
132
+ let msg;
133
+ try {
134
+ msg = JSON.parse(raw);
135
+ }
136
+ catch {
137
+ writeJson(res, 400, { jsonrpc: "2.0", id: null, error: { code: -32700, message: "parse error" } });
138
+ return;
139
+ }
140
+ // 熱修補(票 C 驗收後):印出 method 與 id。JSON-RPC 的錯誤是**包在 200 裡**回的,
141
+ // 所以原本只印 `<<< 200 sse` 等於什麼都沒說——排查 client 問題時最需要知道的
142
+ // 「它到底叫了什麼、我回了什麼錯」兩項全都看不到。MVP 當初會印整包 body,
143
+ // 我們沒接過來。這裡只印 method/id/錯誤碼,不印 body:tool 參數可能含工單內容。
144
+ const call = msg;
145
+ const method = call && typeof call.method === "string" ? call.method : "(no method)";
146
+ const callId = call && (typeof call.id === "string" || typeof call.id === "number") ? String(call.id) : "-";
147
+ log(` → ${method} (id=${callId})`);
148
+ // A-1/A-2 #11:同一支 handleRpc,server/discover → -32601 這條也自動涵蓋,HTTP 不
149
+ // 另外處理。A-2 #10:不帶 mcp-session-id header——這裡從未加過,維持不加。
150
+ const outcome = protocol.handleRpc(msg, log);
151
+ if ("none" in outcome) {
152
+ res.writeHead(202);
153
+ res.end();
154
+ log(`<<< 202 (notification) ${method}`);
155
+ return;
156
+ }
157
+ const body = `event: message\ndata: ${JSON.stringify(outcome.response)}\n\n`;
158
+ res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache" });
159
+ res.end(body);
160
+ // 200 但帶 JSON-RPC error:這是「失敗長得像成功」在傳輸層的原形,一定要標出來。
161
+ const rpcError = outcome.response.error;
162
+ // 「成功」有三層,前兩層今天都被誤讀過一次:
163
+ // HTTP 200 ≠ 成功——JSON-RPC 的 error 包在 200 裡
164
+ // JSON-RPC 成功 ≠ 成功——tool 的失敗是 result.isError,不是 JSON-RPC error
165
+ // 第三層(tool 回的內容對不對)不是 log 該管的,但前兩層必須在這行分得出來。
166
+ const toolResult = outcome.response.result;
167
+ if (rpcError)
168
+ log(`<<< 200 sse ${method} → JSON-RPC error ${rpcError.code}: ${rpcError.message}`);
169
+ else if (toolResult?.isError)
170
+ log(`<<< 200 sse ${method} → tool isError`);
171
+ else
172
+ log(`<<< 200 sse ${method} ok`);
173
+ });
174
+ };
175
+ }
176
+ export function createHttpServer(options) {
177
+ return http.createServer(createRequestHandler(options));
178
+ }