trainbud 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.
Files changed (274) hide show
  1. package/.env.example +11 -0
  2. package/CHANGELOG.md +675 -0
  3. package/LICENSE +21 -0
  4. package/QUICKSTART.md +182 -0
  5. package/README.md +322 -0
  6. package/dist/appDb.d.ts +83 -0
  7. package/dist/appDb.d.ts.map +1 -0
  8. package/dist/appDb.js +239 -0
  9. package/dist/appDb.js.map +1 -0
  10. package/dist/check.d.ts +12 -0
  11. package/dist/check.d.ts.map +1 -0
  12. package/dist/check.js +230 -0
  13. package/dist/check.js.map +1 -0
  14. package/dist/cli.d.ts +3 -0
  15. package/dist/cli.d.ts.map +1 -0
  16. package/dist/cli.js +457 -0
  17. package/dist/cli.js.map +1 -0
  18. package/dist/config.d.ts +61 -0
  19. package/dist/config.d.ts.map +1 -0
  20. package/dist/config.js +241 -0
  21. package/dist/config.js.map +1 -0
  22. package/dist/dashboard.d.ts +8 -0
  23. package/dist/dashboard.d.ts.map +1 -0
  24. package/dist/dashboard.js +1004 -0
  25. package/dist/dashboard.js.map +1 -0
  26. package/dist/dashboardCharts.d.ts +99 -0
  27. package/dist/dashboardCharts.d.ts.map +1 -0
  28. package/dist/dashboardCharts.js +317 -0
  29. package/dist/dashboardCharts.js.map +1 -0
  30. package/dist/dashboardData.d.ts +89 -0
  31. package/dist/dashboardData.d.ts.map +1 -0
  32. package/dist/dashboardData.js +228 -0
  33. package/dist/dashboardData.js.map +1 -0
  34. package/dist/detect/baseline.d.ts +17 -0
  35. package/dist/detect/baseline.d.ts.map +1 -0
  36. package/dist/detect/baseline.js +62 -0
  37. package/dist/detect/baseline.js.map +1 -0
  38. package/dist/detect/countdown.d.ts +30 -0
  39. package/dist/detect/countdown.d.ts.map +1 -0
  40. package/dist/detect/countdown.js +79 -0
  41. package/dist/detect/countdown.js.map +1 -0
  42. package/dist/detect/detectors.d.ts +20 -0
  43. package/dist/detect/detectors.d.ts.map +1 -0
  44. package/dist/detect/detectors.js +291 -0
  45. package/dist/detect/detectors.js.map +1 -0
  46. package/dist/detect/findings.d.ts +29 -0
  47. package/dist/detect/findings.d.ts.map +1 -0
  48. package/dist/detect/findings.js +2 -0
  49. package/dist/detect/findings.js.map +1 -0
  50. package/dist/detect/forecast.d.ts +33 -0
  51. package/dist/detect/forecast.d.ts.map +1 -0
  52. package/dist/detect/forecast.js +122 -0
  53. package/dist/detect/forecast.js.map +1 -0
  54. package/dist/detect/index.d.ts +50 -0
  55. package/dist/detect/index.d.ts.map +1 -0
  56. package/dist/detect/index.js +114 -0
  57. package/dist/detect/index.js.map +1 -0
  58. package/dist/detect/sleepQuality.d.ts +21 -0
  59. package/dist/detect/sleepQuality.d.ts.map +1 -0
  60. package/dist/detect/sleepQuality.js +164 -0
  61. package/dist/detect/sleepQuality.js.map +1 -0
  62. package/dist/detect/trimp.d.ts +22 -0
  63. package/dist/detect/trimp.d.ts.map +1 -0
  64. package/dist/detect/trimp.js +77 -0
  65. package/dist/detect/trimp.js.map +1 -0
  66. package/dist/detect/week.d.ts +31 -0
  67. package/dist/detect/week.d.ts.map +1 -0
  68. package/dist/detect/week.js +184 -0
  69. package/dist/detect/week.js.map +1 -0
  70. package/dist/garmin/auth.d.ts +10 -0
  71. package/dist/garmin/auth.d.ts.map +1 -0
  72. package/dist/garmin/auth.js +81 -0
  73. package/dist/garmin/auth.js.map +1 -0
  74. package/dist/garmin/cache.d.ts +42 -0
  75. package/dist/garmin/cache.d.ts.map +1 -0
  76. package/dist/garmin/cache.js +141 -0
  77. package/dist/garmin/cache.js.map +1 -0
  78. package/dist/garmin/client.d.ts +22 -0
  79. package/dist/garmin/client.d.ts.map +1 -0
  80. package/dist/garmin/client.js +237 -0
  81. package/dist/garmin/client.js.map +1 -0
  82. package/dist/garmin/daily.d.ts +24 -0
  83. package/dist/garmin/daily.d.ts.map +1 -0
  84. package/dist/garmin/daily.js +96 -0
  85. package/dist/garmin/daily.js.map +1 -0
  86. package/dist/garmin/garminApiTypes.d.ts +70 -0
  87. package/dist/garmin/garminApiTypes.d.ts.map +1 -0
  88. package/dist/garmin/garminApiTypes.js +3 -0
  89. package/dist/garmin/garminApiTypes.js.map +1 -0
  90. package/dist/garmin/garminConnect.d.ts +17 -0
  91. package/dist/garmin/garminConnect.d.ts.map +1 -0
  92. package/dist/garmin/garminConnect.js +5 -0
  93. package/dist/garmin/garminConnect.js.map +1 -0
  94. package/dist/garmin/partial.d.ts +32 -0
  95. package/dist/garmin/partial.d.ts.map +1 -0
  96. package/dist/garmin/partial.js +38 -0
  97. package/dist/garmin/partial.js.map +1 -0
  98. package/dist/garmin/rawApi.d.ts +59 -0
  99. package/dist/garmin/rawApi.d.ts.map +1 -0
  100. package/dist/garmin/rawApi.js +113 -0
  101. package/dist/garmin/rawApi.js.map +1 -0
  102. package/dist/garmin/types.d.ts +85 -0
  103. package/dist/garmin/types.d.ts.map +1 -0
  104. package/dist/garmin/types.js +11 -0
  105. package/dist/garmin/types.js.map +1 -0
  106. package/dist/history/capture.d.ts +12 -0
  107. package/dist/history/capture.d.ts.map +1 -0
  108. package/dist/history/capture.js +63 -0
  109. package/dist/history/capture.js.map +1 -0
  110. package/dist/history/context.d.ts +51 -0
  111. package/dist/history/context.d.ts.map +1 -0
  112. package/dist/history/context.js +122 -0
  113. package/dist/history/context.js.map +1 -0
  114. package/dist/history/fallback.d.ts +72 -0
  115. package/dist/history/fallback.d.ts.map +1 -0
  116. package/dist/history/fallback.js +185 -0
  117. package/dist/history/fallback.js.map +1 -0
  118. package/dist/history/ingest.d.ts +44 -0
  119. package/dist/history/ingest.d.ts.map +1 -0
  120. package/dist/history/ingest.js +347 -0
  121. package/dist/history/ingest.js.map +1 -0
  122. package/dist/history/scheduler.d.ts +20 -0
  123. package/dist/history/scheduler.d.ts.map +1 -0
  124. package/dist/history/scheduler.js +54 -0
  125. package/dist/history/scheduler.js.map +1 -0
  126. package/dist/history/schema.d.ts +41 -0
  127. package/dist/history/schema.d.ts.map +1 -0
  128. package/dist/history/schema.js +91 -0
  129. package/dist/history/schema.js.map +1 -0
  130. package/dist/history/store.d.ts +112 -0
  131. package/dist/history/store.d.ts.map +1 -0
  132. package/dist/history/store.js +282 -0
  133. package/dist/history/store.js.map +1 -0
  134. package/dist/httpServer.d.ts +55 -0
  135. package/dist/httpServer.d.ts.map +1 -0
  136. package/dist/httpServer.js +1027 -0
  137. package/dist/httpServer.js.map +1 -0
  138. package/dist/index.d.ts +3 -0
  139. package/dist/index.d.ts.map +1 -0
  140. package/dist/index.js +11 -0
  141. package/dist/index.js.map +1 -0
  142. package/dist/mcpConfig.d.ts +56 -0
  143. package/dist/mcpConfig.d.ts.map +1 -0
  144. package/dist/mcpConfig.js +106 -0
  145. package/dist/mcpConfig.js.map +1 -0
  146. package/dist/pairApi.d.ts +14 -0
  147. package/dist/pairApi.d.ts.map +1 -0
  148. package/dist/pairApi.js +38 -0
  149. package/dist/pairApi.js.map +1 -0
  150. package/dist/paths.d.ts +45 -0
  151. package/dist/paths.d.ts.map +1 -0
  152. package/dist/paths.js +105 -0
  153. package/dist/paths.js.map +1 -0
  154. package/dist/profile.d.ts +123 -0
  155. package/dist/profile.d.ts.map +1 -0
  156. package/dist/profile.js +327 -0
  157. package/dist/profile.js.map +1 -0
  158. package/dist/promptApi.d.ts +43 -0
  159. package/dist/promptApi.d.ts.map +1 -0
  160. package/dist/promptApi.js +328 -0
  161. package/dist/promptApi.js.map +1 -0
  162. package/dist/promptSuggestions.d.ts +16 -0
  163. package/dist/promptSuggestions.d.ts.map +1 -0
  164. package/dist/promptSuggestions.js +141 -0
  165. package/dist/promptSuggestions.js.map +1 -0
  166. package/dist/selfTest.d.ts +53 -0
  167. package/dist/selfTest.d.ts.map +1 -0
  168. package/dist/selfTest.js +225 -0
  169. package/dist/selfTest.js.map +1 -0
  170. package/dist/server.d.ts +10 -0
  171. package/dist/server.d.ts.map +1 -0
  172. package/dist/server.js +67 -0
  173. package/dist/server.js.map +1 -0
  174. package/dist/setup.d.ts +2 -0
  175. package/dist/setup.d.ts.map +1 -0
  176. package/dist/setup.js +210 -0
  177. package/dist/setup.js.map +1 -0
  178. package/dist/toolErrors.d.ts +2 -0
  179. package/dist/toolErrors.d.ts.map +1 -0
  180. package/dist/toolErrors.js +18 -0
  181. package/dist/toolErrors.js.map +1 -0
  182. package/dist/tools/activities.d.ts +18 -0
  183. package/dist/tools/activities.d.ts.map +1 -0
  184. package/dist/tools/activities.js +182 -0
  185. package/dist/tools/activities.js.map +1 -0
  186. package/dist/tools/bodyComposition.d.ts +10 -0
  187. package/dist/tools/bodyComposition.d.ts.map +1 -0
  188. package/dist/tools/bodyComposition.js +139 -0
  189. package/dist/tools/bodyComposition.js.map +1 -0
  190. package/dist/tools/context.d.ts +8 -0
  191. package/dist/tools/context.d.ts.map +1 -0
  192. package/dist/tools/context.js +148 -0
  193. package/dist/tools/context.js.map +1 -0
  194. package/dist/tools/findings.d.ts +7 -0
  195. package/dist/tools/findings.d.ts.map +1 -0
  196. package/dist/tools/findings.js +51 -0
  197. package/dist/tools/findings.js.map +1 -0
  198. package/dist/tools/heartRate.d.ts +10 -0
  199. package/dist/tools/heartRate.d.ts.map +1 -0
  200. package/dist/tools/heartRate.js +105 -0
  201. package/dist/tools/heartRate.js.map +1 -0
  202. package/dist/tools/index.d.ts +66 -0
  203. package/dist/tools/index.d.ts.map +1 -0
  204. package/dist/tools/index.js +101 -0
  205. package/dist/tools/index.js.map +1 -0
  206. package/dist/tools/payloads.d.ts +148 -0
  207. package/dist/tools/payloads.d.ts.map +1 -0
  208. package/dist/tools/payloads.js +2 -0
  209. package/dist/tools/payloads.js.map +1 -0
  210. package/dist/tools/recovery.d.ts +31 -0
  211. package/dist/tools/recovery.d.ts.map +1 -0
  212. package/dist/tools/recovery.js +350 -0
  213. package/dist/tools/recovery.js.map +1 -0
  214. package/dist/tools/sleep.d.ts +10 -0
  215. package/dist/tools/sleep.d.ts.map +1 -0
  216. package/dist/tools/sleep.js +124 -0
  217. package/dist/tools/sleep.js.map +1 -0
  218. package/dist/tools/stress.d.ts +11 -0
  219. package/dist/tools/stress.d.ts.map +1 -0
  220. package/dist/tools/stress.js +108 -0
  221. package/dist/tools/stress.js.map +1 -0
  222. package/dist/tools/trainingInsights.d.ts +15 -0
  223. package/dist/tools/trainingInsights.d.ts.map +1 -0
  224. package/dist/tools/trainingInsights.js +79 -0
  225. package/dist/tools/trainingInsights.js.map +1 -0
  226. package/dist/tools/types.d.ts +12 -0
  227. package/dist/tools/types.d.ts.map +1 -0
  228. package/dist/tools/types.js +2 -0
  229. package/dist/tools/types.js.map +1 -0
  230. package/dist/tools/vo2Max.d.ts +11 -0
  231. package/dist/tools/vo2Max.d.ts.map +1 -0
  232. package/dist/tools/vo2Max.js +110 -0
  233. package/dist/tools/vo2Max.js.map +1 -0
  234. package/dist/tools/week.d.ts +12 -0
  235. package/dist/tools/week.d.ts.map +1 -0
  236. package/dist/tools/week.js +74 -0
  237. package/dist/tools/week.js.map +1 -0
  238. package/dist/usage.d.ts +104 -0
  239. package/dist/usage.d.ts.map +1 -0
  240. package/dist/usage.js +247 -0
  241. package/dist/usage.js.map +1 -0
  242. package/dist/utils/batch.d.ts +2 -0
  243. package/dist/utils/batch.d.ts.map +1 -0
  244. package/dist/utils/batch.js +18 -0
  245. package/dist/utils/batch.js.map +1 -0
  246. package/dist/utils/helpers.d.ts +32 -0
  247. package/dist/utils/helpers.d.ts.map +1 -0
  248. package/dist/utils/helpers.js +188 -0
  249. package/dist/utils/helpers.js.map +1 -0
  250. package/dist/utils/jsonFile.d.ts +19 -0
  251. package/dist/utils/jsonFile.d.ts.map +1 -0
  252. package/dist/utils/jsonFile.js +24 -0
  253. package/dist/utils/jsonFile.js.map +1 -0
  254. package/dist/utils/logger.d.ts +4 -0
  255. package/dist/utils/logger.d.ts.map +1 -0
  256. package/dist/utils/logger.js +97 -0
  257. package/dist/utils/logger.js.map +1 -0
  258. package/dist/utils/secretFile.d.ts +15 -0
  259. package/dist/utils/secretFile.d.ts.map +1 -0
  260. package/dist/utils/secretFile.js +47 -0
  261. package/dist/utils/secretFile.js.map +1 -0
  262. package/dist/utils/stdio.d.ts +10 -0
  263. package/dist/utils/stdio.d.ts.map +1 -0
  264. package/dist/utils/stdio.js +70 -0
  265. package/dist/utils/stdio.js.map +1 -0
  266. package/dist/version.d.ts +2 -0
  267. package/dist/version.d.ts.map +1 -0
  268. package/dist/version.js +7 -0
  269. package/dist/version.js.map +1 -0
  270. package/dist/watchApi.d.ts +264 -0
  271. package/dist/watchApi.d.ts.map +1 -0
  272. package/dist/watchApi.js +333 -0
  273. package/dist/watchApi.js.map +1 -0
  274. package/package.json +77 -0
@@ -0,0 +1,1027 @@
1
+ import http from "node:http";
2
+ import { randomBytes, timingSafeEqual } from "node:crypto";
3
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
4
+ import { closeCache } from "./garmin/cache.js";
5
+ import { CARD_IDS, DEFAULT_PROFILE, getProfile, updateProfile, } from "./profile.js";
6
+ import { budgetState, clearFeatureUsage, dailyAiSpend, featureCounts, monthToDateSpend, recentAiUsage, recordFeature, } from "./usage.js";
7
+ import { closeAppDb, reconcilePromptJobsOnStartup, setSetting } from "./appDb.js";
8
+ import { assertGarminCredentials, assertApiKey, appConfig } from "./config.js";
9
+ import { createMcpServerInstance } from "./server.js";
10
+ import { configureLogger, logger } from "./utils/logger.js";
11
+ import { buildWatchSummary } from "./watchApi.js";
12
+ import { requestPairing, checkPairStatus, approvePairing } from "./pairApi.js";
13
+ import { submitPrompt, getPromptStatus, isAiConfigured, clearDailyInsight } from "./promptApi.js";
14
+ import { renderDashboard, renderPairSuccess, renderPairError, getDashboardStatus } from "./dashboard.js";
15
+ import { runSelfTest } from "./selfTest.js";
16
+ import { addContextEntry, closeContextEntry } from "./history/context.js";
17
+ import { CONTEXT_KINDS } from "./history/schema.js";
18
+ // SECTION: HTTP MCP Server
19
+ const MAX_BODY_BYTES = 1024 * 1024;
20
+ const RATE_LIMIT_WINDOW_MS = 60_000;
21
+ const RATE_LIMIT_MAX_REQUESTS = 60;
22
+ // Pairing is the only flow reachable with no credential at all, and the prize
23
+ // for guessing a code is the API key itself. Six digits is a small space, so
24
+ // these endpoints get their own, much tighter budget: a real watch polls once
25
+ // every five seconds, which is twelve requests a minute.
26
+ const PAIR_RATE_LIMIT_MAX_REQUESTS = 30;
27
+ const RATE_LIMIT_PRUNE_THRESHOLD = 256;
28
+ const WATCH_API_CACHE_TTL_MS = 5 * 60_000;
29
+ const requestLog = new Map();
30
+ const pairRequestLog = new Map();
31
+ let watchApiCache = null;
32
+ /**
33
+ * How many per-request MCP servers are still holding their transport open.
34
+ *
35
+ * This exists because "did that request release its handles" had no observable
36
+ * at all: a leak was a number that only grew inside the process, so a test could
37
+ * not tell a fixed build from a broken one, and neither could an operator. It
38
+ * settles back to zero once every in-flight request has finished or been
39
+ * abandoned; a value that climbs and stays there is the leak.
40
+ */
41
+ let liveMcpSessions = 0;
42
+ export function liveMcpSessionCount() {
43
+ return liveMcpSessions;
44
+ }
45
+ /**
46
+ * Who to charge a request to.
47
+ *
48
+ * The socket address alone was wrong for the deployment this server actually
49
+ * runs in: behind a tunnel every request arrives from the tunnel agent on
50
+ * loopback, so the watch, the dashboard and a stranger all shared one bucket --
51
+ * one noisy client throttled everyone, and an attacker walking the pair code
52
+ * space was indistinguishable from the watch.
53
+ *
54
+ * The forwarded address is only trusted when the request came from loopback,
55
+ * i.e. from a local tunnel agent; a direct caller cannot promote itself by
56
+ * inventing the header. The last hop is used, not the first, because a client
57
+ * can send its own X-Forwarded-For and the proxy appends the address it
58
+ * actually saw.
59
+ */
60
+ function getClientKey(req) {
61
+ const socketAddress = req.socket.remoteAddress ?? "unknown";
62
+ if (!isLoopbackAddress(socketAddress)) {
63
+ return socketAddress;
64
+ }
65
+ const forwarded = req.headers["x-forwarded-for"];
66
+ const raw = Array.isArray(forwarded) ? forwarded.join(",") : forwarded;
67
+ const hops = (raw ?? "")
68
+ .split(",")
69
+ .map((hop) => hop.trim())
70
+ .filter((hop) => hop.length > 0);
71
+ return hops.length > 0 ? hops[hops.length - 1] : socketAddress;
72
+ }
73
+ function isLoopbackAddress(address) {
74
+ const name = address.replace(/^::ffff:/, "");
75
+ return name === "127.0.0.1" || name === "::1" || name.startsWith("127.");
76
+ }
77
+ /** Drop windows that have already rolled over, so the map cannot grow forever. */
78
+ function pruneRateLimitLog(log, now) {
79
+ if (log.size < RATE_LIMIT_PRUNE_THRESHOLD) {
80
+ return;
81
+ }
82
+ for (const [key, entry] of log) {
83
+ if (now - entry.windowStart >= RATE_LIMIT_WINDOW_MS) {
84
+ log.delete(key);
85
+ }
86
+ }
87
+ }
88
+ function isRateLimited(clientKey, log = requestLog, max = RATE_LIMIT_MAX_REQUESTS) {
89
+ const now = Date.now();
90
+ pruneRateLimitLog(log, now);
91
+ const entry = log.get(clientKey);
92
+ if (!entry || now - entry.windowStart >= RATE_LIMIT_WINDOW_MS) {
93
+ log.set(clientKey, { count: 1, windowStart: now });
94
+ return false;
95
+ }
96
+ entry.count += 1;
97
+ return entry.count > max;
98
+ }
99
+ /** True for the endpoints that can be reached without a credential. */
100
+ function isPairPath(pathname) {
101
+ return pathname === "/api/pair" || pathname.startsWith("/api/pair/");
102
+ }
103
+ function sendJson(res, statusCode, body) {
104
+ res.writeHead(statusCode, { "Content-Type": "application/json" });
105
+ res.end(JSON.stringify(body));
106
+ }
107
+ /**
108
+ * The dashboard drives these endpoints over fetch and expects JSON; a plain
109
+ * browser form post still gets HTML. Keeps the no-JavaScript path working.
110
+ */
111
+ function wantsJson(req) {
112
+ return (req.headers.accept ?? "").includes("application/json");
113
+ }
114
+ function normalizePathname(pathname) {
115
+ if (pathname.length > 1 && pathname.endsWith("/")) {
116
+ return pathname.slice(0, -1);
117
+ }
118
+ return pathname;
119
+ }
120
+ // SECTION: Dashboard session
121
+ //
122
+ // The dashboard used to authenticate with `?token=<API key>` and nothing else,
123
+ // so the live key sat in the address bar for the whole session -- in browser
124
+ // history, in the tunnel provider's access log, and in every screenshot of the
125
+ // page. The token still works, but now only as the way in: the first request
126
+ // carrying it is handed an opaque session cookie and redirected to a clean
127
+ // URL, and the cookie is what the page uses from then on.
128
+ //
129
+ // The session id is not the key. It is a random 32-byte value that maps to an
130
+ // expiry in this process's memory, so it grants nothing after a restart and
131
+ // cannot be replayed against a different server.
132
+ const SESSION_COOKIE = "tb_session";
133
+ const SESSION_TTL_MS = 30 * 24 * 60 * 60_000;
134
+ const SESSION_PRUNE_THRESHOLD = 64;
135
+ const dashboardSessions = new Map();
136
+ function parseCookies(req) {
137
+ const jar = new Map();
138
+ const header = req.headers.cookie;
139
+ if (!header)
140
+ return jar;
141
+ for (const part of header.split(";")) {
142
+ const eq = part.indexOf("=");
143
+ if (eq < 1)
144
+ continue;
145
+ const name = part.slice(0, eq).trim();
146
+ const value = part.slice(eq + 1).trim();
147
+ if (name)
148
+ jar.set(name, value);
149
+ }
150
+ return jar;
151
+ }
152
+ function hasValidSession(req) {
153
+ const id = parseCookies(req).get(SESSION_COOKIE);
154
+ if (!id)
155
+ return false;
156
+ const expiresAt = dashboardSessions.get(id);
157
+ if (expiresAt === undefined)
158
+ return false;
159
+ if (expiresAt <= Date.now()) {
160
+ dashboardSessions.delete(id);
161
+ return false;
162
+ }
163
+ return true;
164
+ }
165
+ function createDashboardSession() {
166
+ // Sessions are only created by a request that already proved it holds the
167
+ // API key, so the map cannot be grown by a stranger. It is still pruned, so
168
+ // a long-lived server does not accumulate one entry per browser forever.
169
+ if (dashboardSessions.size >= SESSION_PRUNE_THRESHOLD) {
170
+ const now = Date.now();
171
+ for (const [id, expiresAt] of dashboardSessions) {
172
+ if (expiresAt <= now)
173
+ dashboardSessions.delete(id);
174
+ }
175
+ }
176
+ const id = randomBytes(32).toString("hex");
177
+ dashboardSessions.set(id, Date.now() + SESSION_TTL_MS);
178
+ return id;
179
+ }
180
+ /** Exposed for tests: drops every session, as a restart would. */
181
+ export function clearDashboardSessions() {
182
+ dashboardSessions.clear();
183
+ }
184
+ /**
185
+ * `Secure` is omitted when the request did not arrive over TLS, because a
186
+ * browser silently discards a Secure cookie on a plain-http origin -- which is
187
+ * exactly how the local `http://127.0.0.1:3847` dashboard is reached. Through
188
+ * a tunnel the proxy sets `x-forwarded-proto: https`, and the flag goes on.
189
+ *
190
+ * `SameSite=Lax` is what keeps cookie auth from adding CSRF: it is sent on
191
+ * top-level navigations and same-origin requests, never on a cross-site POST.
192
+ */
193
+ function sessionCookieHeader(req, id) {
194
+ const forwarded = req.headers["x-forwarded-proto"];
195
+ const proto = (Array.isArray(forwarded) ? forwarded[0] : forwarded)?.split(",")[0]?.trim();
196
+ const secure = proto === "https" ? "; Secure" : "";
197
+ const maxAge = Math.floor(SESSION_TTL_MS / 1000);
198
+ return `${SESSION_COOKIE}=${id}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAge}${secure}`;
199
+ }
200
+ function isAuthorized(req, queryToken) {
201
+ const header = req.headers.authorization;
202
+ const headerToken = header?.startsWith("Bearer ") ? header.slice("Bearer ".length).trim() : null;
203
+ const token = headerToken ?? queryToken ?? null;
204
+ if (token !== null && matchesApiKey(token)) {
205
+ return true;
206
+ }
207
+ return hasValidSession(req);
208
+ }
209
+ /**
210
+ * Constant-time comparison. `===` returns as soon as two bytes differ, which
211
+ * leaks the length of the matching prefix to anyone who can time the response,
212
+ * and this key is reachable over a public tunnel. timingSafeEqual throws on a
213
+ * length mismatch, so the lengths are compared first -- that leaks only the
214
+ * key's length, which is fixed and public anyway.
215
+ */
216
+ function matchesApiKey(token) {
217
+ const expected = appConfig.mcpApiKey;
218
+ if (!expected || !token) {
219
+ return false;
220
+ }
221
+ const provided = Buffer.from(token, "utf8");
222
+ const secret = Buffer.from(expected, "utf8");
223
+ if (provided.length !== secret.length) {
224
+ return false;
225
+ }
226
+ return timingSafeEqual(provided, secret);
227
+ }
228
+ async function getCachedWatchSummary() {
229
+ const now = Date.now();
230
+ if (watchApiCache && now < watchApiCache.expiresAt) {
231
+ return watchApiCache.summary;
232
+ }
233
+ const summary = await buildWatchSummary();
234
+ watchApiCache = {
235
+ summary,
236
+ expiresAt: now + WATCH_API_CACHE_TTL_MS,
237
+ };
238
+ return summary;
239
+ }
240
+ /** Drain the request body once, as text. */
241
+ async function readRawBody(req) {
242
+ const chunks = [];
243
+ let total = 0;
244
+ for await (const chunk of req) {
245
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
246
+ total += buffer.length;
247
+ if (total > MAX_BODY_BYTES) {
248
+ throw new Error("Request body too large");
249
+ }
250
+ chunks.push(buffer);
251
+ }
252
+ return Buffer.concat(chunks).toString("utf8");
253
+ }
254
+ /**
255
+ * Pull a field from a body that may be JSON or URL-encoded, without re-reading
256
+ * the stream. The dashboard posts URL-encoded; API clients post JSON.
257
+ */
258
+ export function readFormOrJsonField(raw, field) {
259
+ if (!raw) {
260
+ return null;
261
+ }
262
+ const trimmed = raw.trim();
263
+ if (trimmed.startsWith("{")) {
264
+ try {
265
+ const parsed = JSON.parse(trimmed);
266
+ const value = parsed[field];
267
+ return typeof value === "string" ? value : null;
268
+ }
269
+ catch {
270
+ return null;
271
+ }
272
+ }
273
+ return new URLSearchParams(raw).get(field);
274
+ }
275
+ /**
276
+ * Deliberately separate from readFormOrJsonField, which returns null for any
277
+ * non-string JSON value. That is not an oversight there: a pair code is a
278
+ * zero-padded six-digit string, `000042` is not valid JSON as a number, and
279
+ * silently coercing 42 would turn "you sent this in a form that cannot
280
+ * represent it" into a failed lookup.
281
+ *
282
+ * A row id has no such problem, and a JSON client naturally sends {"id": 5}.
283
+ */
284
+ export function readNumericField(raw, field) {
285
+ const trimmed = raw.trim();
286
+ if (trimmed.startsWith("{")) {
287
+ try {
288
+ const value = JSON.parse(trimmed)[field];
289
+ if (typeof value === "number" && Number.isFinite(value)) {
290
+ return value;
291
+ }
292
+ if (typeof value === "string" && /^-?\d+$/.test(value.trim())) {
293
+ return Number.parseInt(value, 10);
294
+ }
295
+ return null;
296
+ }
297
+ catch {
298
+ return null;
299
+ }
300
+ }
301
+ const formValue = new URLSearchParams(raw).get(field);
302
+ if (formValue !== null && /^-?\d+$/.test(formValue.trim())) {
303
+ return Number.parseInt(formValue, 10);
304
+ }
305
+ return null;
306
+ }
307
+ async function readJsonBody(req) {
308
+ const chunks = [];
309
+ let total = 0;
310
+ for await (const chunk of req) {
311
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
312
+ total += buffer.length;
313
+ if (total > MAX_BODY_BYTES) {
314
+ throw new Error("Request body too large");
315
+ }
316
+ chunks.push(buffer);
317
+ }
318
+ const raw = Buffer.concat(chunks).toString("utf8");
319
+ if (!raw) {
320
+ return undefined;
321
+ }
322
+ return JSON.parse(raw);
323
+ }
324
+ async function handleMcpRequest(req, res) {
325
+ if (!isAuthorized(req)) {
326
+ res.setHeader("WWW-Authenticate", 'Bearer realm="trainbud"');
327
+ sendJson(res, 401, {
328
+ error: "Unauthorized",
329
+ message: "Missing or invalid Authorization: Bearer token.",
330
+ });
331
+ return;
332
+ }
333
+ const server = createMcpServerInstance();
334
+ const transport = new StreamableHTTPServerTransport({
335
+ sessionIdGenerator: undefined,
336
+ });
337
+ /**
338
+ * A per-request MCP server and transport are only released by the `close`
339
+ * listener below, and that listener used to be registered in a `finally` --
340
+ * after the awaits. A client that aborted mid-request (a watch losing
341
+ * Bluetooth, a browser tab closed, a tunnel dropping) had already emitted
342
+ * `close` by then, and `close` does not fire twice: the listener was attached
343
+ * to a dead response and never ran. Every aborted request leaked a server, a
344
+ * transport and their listeners for the lifetime of the process.
345
+ *
346
+ * Registered before the first await now, and idempotent, because the two
347
+ * paths that reach it -- the event and the direct call when the socket is
348
+ * already gone -- can both happen.
349
+ */
350
+ let released = false;
351
+ liveMcpSessions += 1;
352
+ const release = () => {
353
+ if (released) {
354
+ return;
355
+ }
356
+ released = true;
357
+ liveMcpSessions -= 1;
358
+ void Promise.resolve(transport.close()).catch((error) => {
359
+ logger.debug({ err: error }, "MCP transport close failed");
360
+ });
361
+ void Promise.resolve(server.close()).catch((error) => {
362
+ logger.debug({ err: error }, "MCP server close failed");
363
+ });
364
+ };
365
+ res.once("close", release);
366
+ try {
367
+ await server.connect(transport);
368
+ const parsedBody = req.method === "POST" ? await readJsonBody(req) : undefined;
369
+ await transport.handleRequest(req, res, parsedBody);
370
+ }
371
+ catch (error) {
372
+ logger.error({ error }, "HTTP MCP request failed");
373
+ if (!res.headersSent) {
374
+ sendJson(res, 500, {
375
+ jsonrpc: "2.0",
376
+ error: {
377
+ code: -32603,
378
+ message: "Internal server error",
379
+ },
380
+ id: null,
381
+ });
382
+ }
383
+ }
384
+ finally {
385
+ // `close` cannot arrive for a response that is already destroyed, so the
386
+ // only chance to release those handles is right here.
387
+ if (res.destroyed || res.writableEnded) {
388
+ release();
389
+ }
390
+ }
391
+ }
392
+ // Query-string parameters whose values must never be written to the log.
393
+ // The dashboard authenticates with ?token=<API key>, so request logging was
394
+ // persisting a live credential to .trainbud/mcp.log on every dashboard hit.
395
+ const REDACTED_QUERY_KEYS = new Set(["token", "api_key", "apikey", "key", "secret", "password"]);
396
+ export function redactQuery(search) {
397
+ if (!search || search === "?")
398
+ return "";
399
+ const params = new URLSearchParams(search.startsWith("?") ? search.slice(1) : search);
400
+ let changed = false;
401
+ for (const name of [...params.keys()]) {
402
+ if (REDACTED_QUERY_KEYS.has(name.toLowerCase())) {
403
+ params.set(name, "<redacted>");
404
+ changed = true;
405
+ }
406
+ }
407
+ if (!changed)
408
+ return search;
409
+ return "?" + params.toString().replace(/%3Credacted%3E/gi, "<redacted>");
410
+ }
411
+ /**
412
+ * Redact a live pairing code out of a request path.
413
+ *
414
+ * The query string was redacted and the path was not, so every status poll wrote
415
+ * `/api/pair/418902/status` into the log -- a code that is, for the next five
416
+ * minutes, a bearer credential: `/api/pair/<code>/status` hands out the API key
417
+ * the moment it is approved. The log file sits next to the database and, until
418
+ * the fix above, was as readable as it was.
419
+ *
420
+ * The code is the thing worth hiding; the shape of the path is worth keeping,
421
+ * because "did the watch poll at all" is the question this log exists to answer.
422
+ */
423
+ export function redactPath(pathname) {
424
+ return pathname.replace(/^\/api\/pair\/[^/]+/, "/api/pair/<code>");
425
+ }
426
+ /**
427
+ * Both redactions, applied to a RAW `req.url`.
428
+ *
429
+ * The request-logging line was fixed to redact and the unhandled-error handler
430
+ * was not, so a 500 wrote `?token=<the live API key>` into `.trainbud/mcp.log`
431
+ * in plaintext. Same file, same log, same key, one fix short.
432
+ *
433
+ * `req.url` is an origin-form path, not an absolute URL, so it cannot go
434
+ * through `new URL()` without a base -- and this runs inside the handler whose
435
+ * entire purpose is that nothing in it may take the process down, so it must
436
+ * not throw on a malformed one either. Split by hand, and fall back to naming
437
+ * the shape rather than printing the value.
438
+ */
439
+ export function redactRequestUrl(rawUrl) {
440
+ const raw = rawUrl ?? "";
441
+ const queryStart = raw.indexOf("?");
442
+ const pathname = queryStart === -1 ? raw : raw.slice(0, queryStart);
443
+ const search = queryStart === -1 ? "" : raw.slice(queryStart);
444
+ try {
445
+ return redactPath(pathname) + redactQuery(search);
446
+ }
447
+ catch {
448
+ // URLSearchParams is forgiving, but a credential must not survive on the
449
+ // strength of that assumption.
450
+ return `${redactPath(pathname)}<unparsable query redacted>`;
451
+ }
452
+ }
453
+ // The public URL to hand a watch during pairing.
454
+ //
455
+ // This used to come solely from appConfig.publicUrl, which falls back to
456
+ // .trainbud/watch-setup.json — a file written once by `trainbud setup` and
457
+ // never updated when the tunnel changes. A watch that paired successfully was
458
+ // therefore told to switch to a tunnel that had been dead for weeks, so pairing
459
+ // "worked" and the app went blank on the very next request. Deriving it from
460
+ // the host the request actually arrived on is correct by construction for any
461
+ // tunnel; an explicit TRAINBUD_PUBLIC_URL still wins, since that is deliberate.
462
+ export function resolvePublicUrl(req) {
463
+ const configured = (process.env["TRAINBUD_PUBLIC_URL"] ?? "").replace(/\/$/, "");
464
+ if (configured)
465
+ return configured;
466
+ const forwardedHost = firstHeaderValue(req.headers["x-forwarded-host"]);
467
+ const host = forwardedHost ?? req.headers.host;
468
+ if (host && !isLocalHost(host)) {
469
+ const proto = firstHeaderValue(req.headers["x-forwarded-proto"]) ?? "https";
470
+ return `${proto}://${host}`.replace(/\/$/, "");
471
+ }
472
+ return appConfig.publicUrl;
473
+ }
474
+ function firstHeaderValue(value) {
475
+ if (value === undefined)
476
+ return undefined;
477
+ const raw = Array.isArray(value) ? value[0] : value;
478
+ return raw?.split(",")[0]?.trim() || undefined;
479
+ }
480
+ // A watch can never reach the server on a loopback address, and Connect IQ
481
+ // refuses plain http:// outright, so a local Host header means the request came
482
+ // in directly rather than through the tunnel the watch would need.
483
+ function isLocalHost(host) {
484
+ const name = host.split(":")[0]?.toLowerCase() ?? "";
485
+ return name === "localhost" || name === "127.0.0.1" || name === "::1" || name === "0.0.0.0";
486
+ }
487
+ export function createHttpMcpServer() {
488
+ let httpServer = null;
489
+ return {
490
+ async start() {
491
+ assertGarminCredentials();
492
+ assertApiKey();
493
+ configureLogger(appConfig.logPath);
494
+ // The one process that owns prompt jobs, reclaiming its own wreckage
495
+ // exactly once. This used to happen on every app.db open in every
496
+ // process, which is how a CLI command killed a live watch answer.
497
+ reconcilePromptJobsOnStartup();
498
+ // Load Claude key saved via dashboard into process.env if not already set
499
+ const savedClaudeKey = (await import("./appDb.js")).getSetting("anthropic_api_key");
500
+ if (savedClaudeKey && !process.env["ANTHROPIC_API_KEY"]) {
501
+ process.env["ANTHROPIC_API_KEY"] = savedClaudeKey;
502
+ }
503
+ httpServer = http.createServer((req, res) => {
504
+ // Nothing in the handler may take the process down.
505
+ //
506
+ // The whole body was one unguarded `async` callback, so any throw
507
+ // anywhere in three hundred lines became an unhandled rejection, and
508
+ // Node has exited the process on those by default since v15. Two ways in
509
+ // were reachable with no credential at all:
510
+ //
511
+ // * `new URL(..., "http://" + req.headers.host)` was the FIRST
512
+ // statement, before rate limiting and before any auth check. Node's
513
+ // parser accepts a Host header that is not a valid URL authority
514
+ // ("a b", "[", "%"); new URL then throws ERR_INVALID_URL.
515
+ // * `await readRawBody(req)` throws on an oversized or truncated body,
516
+ // outside any try/catch.
517
+ //
518
+ // Reachability is limited in the documented deployment -- the server
519
+ // binds loopback and a tunnel routes by Host, so a malformed Host
520
+ // largely cannot traverse it -- but "the crash is hard to reach" is a
521
+ // property of today's deployment, not of the code, and the fix is a
522
+ // wrapper.
523
+ void handleRequest(req, res).catch((error) => {
524
+ // Redacted, like the request line above it. This wrote the dashboard's
525
+ // ?token= -- the live API key -- into .trainbud/mcp.log on any 500.
526
+ logger.error({ error, url: redactRequestUrl(req.url) }, "Unhandled error in request handler");
527
+ if (!res.headersSent) {
528
+ sendJson(res, 500, { error: "Internal Server Error" });
529
+ }
530
+ else if (!res.writableEnded) {
531
+ res.end();
532
+ }
533
+ });
534
+ });
535
+ const handleRequest = async (req, res) => {
536
+ // A Host that is not a valid authority is not worth a 500: fall back to
537
+ // a name that always parses. The host only matters for reading the path
538
+ // and, in resolvePublicUrl, for telling a paired watch where to call
539
+ // back -- and that path validates its own input.
540
+ let url;
541
+ try {
542
+ url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
543
+ }
544
+ catch {
545
+ url = new URL(req.url ?? "/", "http://localhost");
546
+ }
547
+ const pathname = normalizePathname(url.pathname);
548
+ // Every request, with the client's identity. Without this there is no way
549
+ // to tell "the watch sent a request that failed" from "the watch never
550
+ // sent one" — a distinction that decides where a pairing bug lives.
551
+ logger.info({
552
+ method: req.method,
553
+ // Full path including the query string. Logging url.pathname alone
554
+ // silently dropped every query parameter, which hid diagnostics the
555
+ // watch app was deliberately sending for exactly this purpose.
556
+ // Redacted, because the dashboard passes the API key as ?token= and
557
+ // this line was writing it to the log file in plaintext.
558
+ path: redactPath(pathname) + redactQuery(url.search),
559
+ ua: req.headers["user-agent"] ?? "(none)",
560
+ accept: req.headers["accept"] ?? "(none)",
561
+ encoding: req.headers["accept-encoding"] ?? "(none)",
562
+ }, "request");
563
+ // Rate limiting used to be wired into the MCP handler alone, so every
564
+ // other route -- including the unauthenticated pair endpoints, the one
565
+ // place a stranger can reach -- was unthrottled.
566
+ const clientKey = getClientKey(req);
567
+ const overLimit = isPairPath(pathname)
568
+ ? isRateLimited(clientKey, pairRequestLog, PAIR_RATE_LIMIT_MAX_REQUESTS)
569
+ : isRateLimited(clientKey);
570
+ if (overLimit && pathname !== "/health") {
571
+ sendJson(res, 429, {
572
+ error: "Too Many Requests",
573
+ message: "Rate limit exceeded. Try again in a minute.",
574
+ });
575
+ return;
576
+ }
577
+ if (pathname === "/" || pathname === "") {
578
+ sendJson(res, 200, {
579
+ service: "trainbud",
580
+ endpoints: {
581
+ health: "/health",
582
+ watch: "/api/watch",
583
+ pair: "/api/pair",
584
+ prompt: "/api/prompt",
585
+ dashboard: "/dashboard",
586
+ mcp: "/mcp",
587
+ },
588
+ message: "Dashboard: GET /dashboard?token=YOUR_API_KEY",
589
+ });
590
+ return;
591
+ }
592
+ if (pathname === "/health") {
593
+ sendJson(res, 200, { status: "ok", service: "trainbud" });
594
+ return;
595
+ }
596
+ // --- Pairing ---
597
+ if (pathname === "/api/pair" && req.method === "POST") {
598
+ const result = requestPairing();
599
+ sendJson(res, 200, result);
600
+ return;
601
+ }
602
+ if (pathname.startsWith("/api/pair/") && pathname.endsWith("/status") && req.method === "GET") {
603
+ const code = pathname.slice("/api/pair/".length, -"/status".length);
604
+ const status = checkPairStatus(code, resolvePublicUrl(req));
605
+ if (!status) {
606
+ sendJson(res, 404, { error: "Pair code not found or expired" });
607
+ }
608
+ else {
609
+ sendJson(res, 200, status);
610
+ }
611
+ return;
612
+ }
613
+ // --- Prompt ---
614
+ if (pathname === "/api/prompt" && req.method === "POST") {
615
+ if (!isAuthorized(req)) {
616
+ res.setHeader("WWW-Authenticate", 'Bearer realm="trainbud"');
617
+ sendJson(res, 401, { error: "Unauthorized" });
618
+ return;
619
+ }
620
+ // After the auth check, never before it: a counter incremented on
621
+ // the unauthenticated path is a counter anyone on the tunnel can
622
+ // drive, and this one is the user's own record of what they asked.
623
+ recordFeature("ai.ask");
624
+ let body;
625
+ try {
626
+ body = await readJsonBody(req);
627
+ }
628
+ catch {
629
+ sendJson(res, 400, { error: "Invalid JSON body" });
630
+ return;
631
+ }
632
+ if (typeof body !== "object" || body === null || typeof body["prompt"] !== "string") {
633
+ sendJson(res, 400, { error: "Missing prompt field" });
634
+ return;
635
+ }
636
+ const prompt = body["prompt"].trim();
637
+ if (prompt.length === 0 || prompt.length > 500) {
638
+ sendJson(res, 400, { error: "Prompt must be 1–500 characters" });
639
+ return;
640
+ }
641
+ const result = submitPrompt(prompt);
642
+ sendJson(res, 202, result);
643
+ return;
644
+ }
645
+ if (pathname.startsWith("/api/prompt/") && req.method === "GET") {
646
+ if (!isAuthorized(req)) {
647
+ res.setHeader("WWW-Authenticate", 'Bearer realm="trainbud"');
648
+ sendJson(res, 401, { error: "Unauthorized" });
649
+ return;
650
+ }
651
+ const jobId = pathname.slice("/api/prompt/".length);
652
+ const status = getPromptStatus(jobId);
653
+ if (!status) {
654
+ sendJson(res, 404, { error: "Job not found" });
655
+ }
656
+ else {
657
+ sendJson(res, 200, status);
658
+ }
659
+ return;
660
+ }
661
+ // --- Dashboard ---
662
+ const queryToken = url.searchParams.get("token") ?? undefined;
663
+ if (pathname === "/dashboard") {
664
+ if (!isAuthorized(req, queryToken)) {
665
+ res.setHeader("WWW-Authenticate", 'Bearer realm="trainbud"');
666
+ res.writeHead(401, { "Content-Type": "text/html" });
667
+ res.end("<h1>401 Unauthorized</h1><p>Add <code>Authorization: Bearer YOUR_API_KEY</code> header, or use the URL <code>/dashboard?token=YOUR_API_KEY</code></p>");
668
+ return;
669
+ }
670
+ // The key came in on the URL. Trade it for a session cookie and
671
+ // bounce to the bare path, so the address bar -- and anything that
672
+ // copies it: history, a tunnel log, a screenshot posted in a thread
673
+ // -- never holds the key again. Anyone still driving the dashboard
674
+ // with a Bearer header is left alone; there is no URL to clean.
675
+ if (queryToken && !hasValidSession(req)) {
676
+ res.writeHead(302, {
677
+ "Set-Cookie": sessionCookieHeader(req, createDashboardSession()),
678
+ Location: "/dashboard",
679
+ });
680
+ res.end();
681
+ return;
682
+ }
683
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
684
+ res.end(renderDashboard(resolvePublicUrl(req)));
685
+ return;
686
+ }
687
+ if (pathname === "/dashboard/pair/approve" && req.method === "POST") {
688
+ if (!isAuthorized(req, queryToken)) {
689
+ res.setHeader("WWW-Authenticate", 'Bearer realm="trainbud"');
690
+ res.writeHead(401, { "Content-Type": "text/html" });
691
+ res.end("<h1>401 Unauthorized</h1>");
692
+ return;
693
+ }
694
+ // Read the body exactly once. The previous version called
695
+ // readJsonBody() first, which drains the stream, and on a JSON parse
696
+ // failure tried to read the stream a second time for URL-encoded
697
+ // data — by then it was empty, so `code` was always null and every
698
+ // form post answered "Missing code parameter". Dashboard pairing
699
+ // approval could never have succeeded.
700
+ const formCode = readFormOrJsonField(await readRawBody(req), "code");
701
+ if (!formCode) {
702
+ if (wantsJson(req)) {
703
+ sendJson(res, 400, { error: "Missing code parameter." });
704
+ return;
705
+ }
706
+ res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" });
707
+ res.end(renderPairError("Missing code parameter.", queryToken));
708
+ return;
709
+ }
710
+ const ok = approvePairing(formCode);
711
+ // The dashboard approves over fetch and stays on the page; the HTML
712
+ // fallback still renders a page, now carrying the token so the "back"
713
+ // link does not 401.
714
+ if (wantsJson(req)) {
715
+ sendJson(res, ok ? 200 : 404, ok ? { ok: true } : { error: "Code not found or expired." });
716
+ return;
717
+ }
718
+ res.writeHead(ok ? 200 : 404, { "Content-Type": "text/html; charset=utf-8" });
719
+ res.end(ok
720
+ ? renderPairSuccess(formCode, queryToken)
721
+ : renderPairError("Code not found or expired.", queryToken));
722
+ return;
723
+ }
724
+ if (pathname === "/dashboard/settings" && req.method === "POST") {
725
+ if (!isAuthorized(req, queryToken)) {
726
+ res.setHeader("WWW-Authenticate", 'Bearer realm="trainbud"');
727
+ res.writeHead(401, { "Content-Type": "text/html" });
728
+ res.end("<h1>401 Unauthorized</h1>");
729
+ return;
730
+ }
731
+ // Unbounded before: the body was read with no size cap, unlike every
732
+ // other endpoint. readRawBody enforces MAX_BODY_BYTES.
733
+ const key = readFormOrJsonField(await readRawBody(req), "anthropic_api_key")?.trim();
734
+ if (key && key.length > 0) {
735
+ setSetting("anthropic_api_key", key);
736
+ process.env["ANTHROPIC_API_KEY"] = key;
737
+ // The watch summary carries ai_configured, and it is cached for five
738
+ // minutes. Without dropping it here the user pastes a key, the
739
+ // dashboard says "enabled", and the watch keeps drawing "AI not set
740
+ // up" for another five minutes -- which is exactly the confusion
741
+ // this whole release exists to end.
742
+ watchApiCache = null;
743
+ }
744
+ if (wantsJson(req)) {
745
+ sendJson(res, 200, { ok: true, ai_configured: isAiConfigured() });
746
+ return;
747
+ }
748
+ // Without the token the redirect target 401s, which is what the
749
+ // non-JS form flow used to do after every save.
750
+ res.writeHead(302, {
751
+ Location: queryToken ? `/dashboard?token=${encodeURIComponent(queryToken)}` : "/dashboard",
752
+ });
753
+ res.end();
754
+ return;
755
+ }
756
+ if (pathname === "/dashboard/context" && req.method === "POST") {
757
+ if (!isAuthorized(req, queryToken)) {
758
+ sendJson(res, 401, { error: "Unauthorized" });
759
+ return;
760
+ }
761
+ const body = await readRawBody(req);
762
+ const kind = readFormOrJsonField(body, "kind")?.trim() ?? "";
763
+ const text = readFormOrJsonField(body, "text")?.trim() ?? "";
764
+ const effectiveTo = readFormOrJsonField(body, "effective_to")?.trim();
765
+ if (!CONTEXT_KINDS.includes(kind)) {
766
+ sendJson(res, 400, {
767
+ error: `Unknown kind "${kind}". Choose one of: ${CONTEXT_KINDS.join(", ")}.`,
768
+ });
769
+ return;
770
+ }
771
+ if (text.length === 0) {
772
+ sendJson(res, 400, { error: "Tell me what to remember." });
773
+ return;
774
+ }
775
+ try {
776
+ const entry = addContextEntry(kind, text, {
777
+ effectiveTo: effectiveTo && effectiveTo.length > 0 ? effectiveTo : undefined,
778
+ });
779
+ sendJson(res, 200, { ok: true, id: entry.id });
780
+ }
781
+ catch (error) {
782
+ sendJson(res, 400, {
783
+ error: error instanceof Error ? error.message : "Could not save that.",
784
+ });
785
+ }
786
+ return;
787
+ }
788
+ if (pathname === "/dashboard/context/close" && req.method === "POST") {
789
+ if (!isAuthorized(req, queryToken)) {
790
+ sendJson(res, 401, { error: "Unauthorized" });
791
+ return;
792
+ }
793
+ const id = readNumericField(await readRawBody(req), "id");
794
+ if (id === null) {
795
+ sendJson(res, 400, { error: "Which entry?" });
796
+ return;
797
+ }
798
+ sendJson(res, 200, { ok: closeContextEntry(id) });
799
+ return;
800
+ }
801
+ if (pathname === "/dashboard/status" && req.method === "GET") {
802
+ if (!isAuthorized(req, queryToken)) {
803
+ sendJson(res, 401, { error: "Unauthorized" });
804
+ return;
805
+ }
806
+ sendJson(res, 200, getDashboardStatus(resolvePublicUrl(req)));
807
+ return;
808
+ }
809
+ // What the watch would see, asked from outside this machine.
810
+ //
811
+ // Everything else on this server reports on itself, which is exactly
812
+ // why the -400 report took as long as it did: the server was healthy,
813
+ // the watch was healthy, and the broken hop was the one nothing looked
814
+ // at. This fetches the configured public URL over the internet with the
815
+ // watch's own headers and grades the answer the way the watch grades
816
+ // it. Same route under /api for the CLI and for scripting.
817
+ if ((pathname === "/dashboard/selftest" || pathname === "/api/selftest") &&
818
+ req.method === "GET") {
819
+ if (!isAuthorized(req, queryToken)) {
820
+ sendJson(res, 401, { error: "Unauthorized" });
821
+ return;
822
+ }
823
+ sendJson(res, 200, await runSelfTest());
824
+ return;
825
+ }
826
+ if (pathname === "/dashboard/insight/regenerate" && req.method === "POST") {
827
+ if (!isAuthorized(req, queryToken)) {
828
+ sendJson(res, 401, { error: "Unauthorized" });
829
+ return;
830
+ }
831
+ clearDailyInsight();
832
+ // Drop the watch summary cache too, otherwise the next /api/watch
833
+ // serves the old response and the insight looks unchanged.
834
+ watchApiCache = null;
835
+ sendJson(res, 200, { ok: true });
836
+ return;
837
+ }
838
+ // Profile — read and write everything the product knows about the user
839
+ // that Garmin does not. One endpoint for the dashboard and any other
840
+ // client; the watch receives the resolved consequences inside
841
+ // /api/watch rather than the settings themselves, so it never has to
842
+ // agree with the server about where a threshold falls.
843
+ if (pathname === "/api/profile") {
844
+ if (!isAuthorized(req, queryToken)) {
845
+ res.setHeader("WWW-Authenticate", 'Bearer realm="trainbud"');
846
+ sendJson(res, 401, { error: "Unauthorized" });
847
+ return;
848
+ }
849
+ if (req.method === "GET") {
850
+ sendJson(res, 200, {
851
+ profile: getProfile(),
852
+ cards: CARD_IDS,
853
+ defaults: DEFAULT_PROFILE,
854
+ });
855
+ return;
856
+ }
857
+ if (req.method === "PUT" || req.method === "POST") {
858
+ let body;
859
+ try {
860
+ body = await readJsonBody(req);
861
+ }
862
+ catch {
863
+ sendJson(res, 400, { error: "Bad Request", message: "Body must be JSON." });
864
+ return;
865
+ }
866
+ if (typeof body !== "object" || body === null) {
867
+ sendJson(res, 400, { error: "Bad Request", message: "Body must be a JSON object." });
868
+ return;
869
+ }
870
+ try {
871
+ const updated = updateProfile(body);
872
+ // Units, thresholds and card order all change the bytes the watch
873
+ // is served, and that payload is cached for five minutes. Without
874
+ // this the user saves a setting, the dashboard confirms it, and
875
+ // the wrist keeps drawing the old one -- the same five-minute lie
876
+ // the API-key save had to fix.
877
+ watchApiCache = null;
878
+ sendJson(res, 200, { ok: true, profile: updated });
879
+ }
880
+ catch (error) {
881
+ // The message names the field, so the dashboard can say which
882
+ // input was refused instead of colouring the whole form red.
883
+ sendJson(res, 400, {
884
+ error: "Bad Request",
885
+ message: error instanceof Error ? error.message : "Invalid profile.",
886
+ });
887
+ }
888
+ return;
889
+ }
890
+ sendJson(res, 405, { error: "Method Not Allowed", message: "Use GET or PUT." });
891
+ return;
892
+ }
893
+ // Deleting the local feature counters. "Stop counting" and "forget
894
+ // what you counted" are different requests, and a control that only
895
+ // does the first is not the privacy control it looks like.
896
+ if (pathname === "/api/usage/features" && req.method === "DELETE") {
897
+ if (!isAuthorized(req, queryToken)) {
898
+ res.setHeader("WWW-Authenticate", 'Bearer realm="trainbud"');
899
+ sendJson(res, 401, { error: "Unauthorized" });
900
+ return;
901
+ }
902
+ clearFeatureUsage();
903
+ sendJson(res, 200, { ok: true });
904
+ return;
905
+ }
906
+ // Usage — what the AI has cost this month, and what gets opened.
907
+ if (pathname === "/api/usage" && req.method === "GET") {
908
+ if (!isAuthorized(req, queryToken)) {
909
+ res.setHeader("WWW-Authenticate", 'Bearer realm="trainbud"');
910
+ sendJson(res, 401, { error: "Unauthorized" });
911
+ return;
912
+ }
913
+ sendJson(res, 200, {
914
+ month: monthToDateSpend(),
915
+ budget: budgetState(),
916
+ recent: recentAiUsage(20),
917
+ daily: dailyAiSpend(30),
918
+ features: featureCounts(30),
919
+ });
920
+ return;
921
+ }
922
+ if (pathname === "/api/watch") {
923
+ if (req.method !== "GET") {
924
+ sendJson(res, 405, { error: "Method Not Allowed", message: "Use GET for /api/watch." });
925
+ return;
926
+ }
927
+ if (!isAuthorized(req)) {
928
+ res.setHeader("WWW-Authenticate", 'Bearer realm="trainbud"');
929
+ sendJson(res, 401, {
930
+ error: "Unauthorized",
931
+ message: "Missing or invalid Authorization: Bearer token.",
932
+ });
933
+ return;
934
+ }
935
+ // Which card the watch was showing. It rides along on a request the
936
+ // watch was already making; counting it per swipe would cost a
937
+ // request every time a wrist turns. Unknown ids are ignored rather
938
+ // than stored, so a query string cannot mint arbitrary counter rows.
939
+ const card = url.searchParams.get("card");
940
+ if (card && CARD_IDS.includes(card)) {
941
+ recordFeature(`card.${card}`);
942
+ }
943
+ recordFeature("watch.sync");
944
+ try {
945
+ const summary = await getCachedWatchSummary();
946
+ sendJson(res, 200, summary);
947
+ }
948
+ catch (error) {
949
+ logger.error({ error }, "Watch API request failed");
950
+ sendJson(res, 500, {
951
+ error: "Internal Server Error",
952
+ message: "Failed to build watch summary.",
953
+ });
954
+ }
955
+ return;
956
+ }
957
+ if (pathname === "/mcp") {
958
+ if (req.method !== "POST") {
959
+ sendJson(res, 405, {
960
+ jsonrpc: "2.0",
961
+ error: {
962
+ code: -32000,
963
+ message: "Method not allowed. Use POST for MCP requests.",
964
+ },
965
+ id: null,
966
+ });
967
+ return;
968
+ }
969
+ await handleMcpRequest(req, res);
970
+ return;
971
+ }
972
+ sendJson(res, 404, { error: "Not Found" });
973
+ };
974
+ await new Promise((resolve, reject) => {
975
+ httpServer.listen(appConfig.mcpPort, appConfig.mcpHost, (error) => {
976
+ if (error) {
977
+ reject(error);
978
+ return;
979
+ }
980
+ resolve();
981
+ });
982
+ });
983
+ logger.info({ host: appConfig.mcpHost, port: appConfig.mcpPort }, "TrainBud HTTP MCP server listening");
984
+ },
985
+ async close() {
986
+ if (!httpServer) {
987
+ return;
988
+ }
989
+ await new Promise((resolve, reject) => {
990
+ httpServer.close((error) => {
991
+ if (error) {
992
+ reject(error);
993
+ return;
994
+ }
995
+ resolve();
996
+ });
997
+ });
998
+ httpServer = null;
999
+ watchApiCache = null;
1000
+ closeCache();
1001
+ closeAppDb();
1002
+ },
1003
+ };
1004
+ }
1005
+ export function getRemoteConnectorInstructions(publicUrl) {
1006
+ return [
1007
+ "Remote MCP connector setup:",
1008
+ "",
1009
+ `1. Start the server: trainbud serve`,
1010
+ `2. Expose HTTPS (e.g. Cloudflare Tunnel): cloudflared tunnel --url http://127.0.0.1:${appConfig.mcpPort}`,
1011
+ `3. Use your public URL + /mcp as the connector endpoint`,
1012
+ "",
1013
+ "Claude.ai:",
1014
+ "- Settings → Connectors → Add custom connector",
1015
+ `- URL: ${publicUrl.replace(/\/$/, "")}/mcp`,
1016
+ "- Authentication: Bearer token (your TRAINBUD_API_KEY from .env)",
1017
+ "",
1018
+ "ChatGPT (Developer Mode):",
1019
+ "- Settings → Connectors → create MCP connector",
1020
+ `- Server URL: ${publicUrl.replace(/\/$/, "")}/mcp`,
1021
+ "- Auth: Bearer token with TRAINBUD_API_KEY",
1022
+ "- Note: ChatGPT MCP auth behavior may differ; test after Claude.ai works",
1023
+ "",
1024
+ `Health check: ${publicUrl.replace(/\/$/, "")}/health`,
1025
+ ].join("\n");
1026
+ }
1027
+ //# sourceMappingURL=httpServer.js.map