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.
- package/.env.example +11 -0
- package/CHANGELOG.md +675 -0
- package/LICENSE +21 -0
- package/QUICKSTART.md +182 -0
- package/README.md +322 -0
- package/dist/appDb.d.ts +83 -0
- package/dist/appDb.d.ts.map +1 -0
- package/dist/appDb.js +239 -0
- package/dist/appDb.js.map +1 -0
- package/dist/check.d.ts +12 -0
- package/dist/check.d.ts.map +1 -0
- package/dist/check.js +230 -0
- package/dist/check.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +457 -0
- package/dist/cli.js.map +1 -0
- package/dist/config.d.ts +61 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +241 -0
- package/dist/config.js.map +1 -0
- package/dist/dashboard.d.ts +8 -0
- package/dist/dashboard.d.ts.map +1 -0
- package/dist/dashboard.js +1004 -0
- package/dist/dashboard.js.map +1 -0
- package/dist/dashboardCharts.d.ts +99 -0
- package/dist/dashboardCharts.d.ts.map +1 -0
- package/dist/dashboardCharts.js +317 -0
- package/dist/dashboardCharts.js.map +1 -0
- package/dist/dashboardData.d.ts +89 -0
- package/dist/dashboardData.d.ts.map +1 -0
- package/dist/dashboardData.js +228 -0
- package/dist/dashboardData.js.map +1 -0
- package/dist/detect/baseline.d.ts +17 -0
- package/dist/detect/baseline.d.ts.map +1 -0
- package/dist/detect/baseline.js +62 -0
- package/dist/detect/baseline.js.map +1 -0
- package/dist/detect/countdown.d.ts +30 -0
- package/dist/detect/countdown.d.ts.map +1 -0
- package/dist/detect/countdown.js +79 -0
- package/dist/detect/countdown.js.map +1 -0
- package/dist/detect/detectors.d.ts +20 -0
- package/dist/detect/detectors.d.ts.map +1 -0
- package/dist/detect/detectors.js +291 -0
- package/dist/detect/detectors.js.map +1 -0
- package/dist/detect/findings.d.ts +29 -0
- package/dist/detect/findings.d.ts.map +1 -0
- package/dist/detect/findings.js +2 -0
- package/dist/detect/findings.js.map +1 -0
- package/dist/detect/forecast.d.ts +33 -0
- package/dist/detect/forecast.d.ts.map +1 -0
- package/dist/detect/forecast.js +122 -0
- package/dist/detect/forecast.js.map +1 -0
- package/dist/detect/index.d.ts +50 -0
- package/dist/detect/index.d.ts.map +1 -0
- package/dist/detect/index.js +114 -0
- package/dist/detect/index.js.map +1 -0
- package/dist/detect/sleepQuality.d.ts +21 -0
- package/dist/detect/sleepQuality.d.ts.map +1 -0
- package/dist/detect/sleepQuality.js +164 -0
- package/dist/detect/sleepQuality.js.map +1 -0
- package/dist/detect/trimp.d.ts +22 -0
- package/dist/detect/trimp.d.ts.map +1 -0
- package/dist/detect/trimp.js +77 -0
- package/dist/detect/trimp.js.map +1 -0
- package/dist/detect/week.d.ts +31 -0
- package/dist/detect/week.d.ts.map +1 -0
- package/dist/detect/week.js +184 -0
- package/dist/detect/week.js.map +1 -0
- package/dist/garmin/auth.d.ts +10 -0
- package/dist/garmin/auth.d.ts.map +1 -0
- package/dist/garmin/auth.js +81 -0
- package/dist/garmin/auth.js.map +1 -0
- package/dist/garmin/cache.d.ts +42 -0
- package/dist/garmin/cache.d.ts.map +1 -0
- package/dist/garmin/cache.js +141 -0
- package/dist/garmin/cache.js.map +1 -0
- package/dist/garmin/client.d.ts +22 -0
- package/dist/garmin/client.d.ts.map +1 -0
- package/dist/garmin/client.js +237 -0
- package/dist/garmin/client.js.map +1 -0
- package/dist/garmin/daily.d.ts +24 -0
- package/dist/garmin/daily.d.ts.map +1 -0
- package/dist/garmin/daily.js +96 -0
- package/dist/garmin/daily.js.map +1 -0
- package/dist/garmin/garminApiTypes.d.ts +70 -0
- package/dist/garmin/garminApiTypes.d.ts.map +1 -0
- package/dist/garmin/garminApiTypes.js +3 -0
- package/dist/garmin/garminApiTypes.js.map +1 -0
- package/dist/garmin/garminConnect.d.ts +17 -0
- package/dist/garmin/garminConnect.d.ts.map +1 -0
- package/dist/garmin/garminConnect.js +5 -0
- package/dist/garmin/garminConnect.js.map +1 -0
- package/dist/garmin/partial.d.ts +32 -0
- package/dist/garmin/partial.d.ts.map +1 -0
- package/dist/garmin/partial.js +38 -0
- package/dist/garmin/partial.js.map +1 -0
- package/dist/garmin/rawApi.d.ts +59 -0
- package/dist/garmin/rawApi.d.ts.map +1 -0
- package/dist/garmin/rawApi.js +113 -0
- package/dist/garmin/rawApi.js.map +1 -0
- package/dist/garmin/types.d.ts +85 -0
- package/dist/garmin/types.d.ts.map +1 -0
- package/dist/garmin/types.js +11 -0
- package/dist/garmin/types.js.map +1 -0
- package/dist/history/capture.d.ts +12 -0
- package/dist/history/capture.d.ts.map +1 -0
- package/dist/history/capture.js +63 -0
- package/dist/history/capture.js.map +1 -0
- package/dist/history/context.d.ts +51 -0
- package/dist/history/context.d.ts.map +1 -0
- package/dist/history/context.js +122 -0
- package/dist/history/context.js.map +1 -0
- package/dist/history/fallback.d.ts +72 -0
- package/dist/history/fallback.d.ts.map +1 -0
- package/dist/history/fallback.js +185 -0
- package/dist/history/fallback.js.map +1 -0
- package/dist/history/ingest.d.ts +44 -0
- package/dist/history/ingest.d.ts.map +1 -0
- package/dist/history/ingest.js +347 -0
- package/dist/history/ingest.js.map +1 -0
- package/dist/history/scheduler.d.ts +20 -0
- package/dist/history/scheduler.d.ts.map +1 -0
- package/dist/history/scheduler.js +54 -0
- package/dist/history/scheduler.js.map +1 -0
- package/dist/history/schema.d.ts +41 -0
- package/dist/history/schema.d.ts.map +1 -0
- package/dist/history/schema.js +91 -0
- package/dist/history/schema.js.map +1 -0
- package/dist/history/store.d.ts +112 -0
- package/dist/history/store.d.ts.map +1 -0
- package/dist/history/store.js +282 -0
- package/dist/history/store.js.map +1 -0
- package/dist/httpServer.d.ts +55 -0
- package/dist/httpServer.d.ts.map +1 -0
- package/dist/httpServer.js +1027 -0
- package/dist/httpServer.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -0
- package/dist/mcpConfig.d.ts +56 -0
- package/dist/mcpConfig.d.ts.map +1 -0
- package/dist/mcpConfig.js +106 -0
- package/dist/mcpConfig.js.map +1 -0
- package/dist/pairApi.d.ts +14 -0
- package/dist/pairApi.d.ts.map +1 -0
- package/dist/pairApi.js +38 -0
- package/dist/pairApi.js.map +1 -0
- package/dist/paths.d.ts +45 -0
- package/dist/paths.d.ts.map +1 -0
- package/dist/paths.js +105 -0
- package/dist/paths.js.map +1 -0
- package/dist/profile.d.ts +123 -0
- package/dist/profile.d.ts.map +1 -0
- package/dist/profile.js +327 -0
- package/dist/profile.js.map +1 -0
- package/dist/promptApi.d.ts +43 -0
- package/dist/promptApi.d.ts.map +1 -0
- package/dist/promptApi.js +328 -0
- package/dist/promptApi.js.map +1 -0
- package/dist/promptSuggestions.d.ts +16 -0
- package/dist/promptSuggestions.d.ts.map +1 -0
- package/dist/promptSuggestions.js +141 -0
- package/dist/promptSuggestions.js.map +1 -0
- package/dist/selfTest.d.ts +53 -0
- package/dist/selfTest.d.ts.map +1 -0
- package/dist/selfTest.js +225 -0
- package/dist/selfTest.js.map +1 -0
- package/dist/server.d.ts +10 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +67 -0
- package/dist/server.js.map +1 -0
- package/dist/setup.d.ts +2 -0
- package/dist/setup.d.ts.map +1 -0
- package/dist/setup.js +210 -0
- package/dist/setup.js.map +1 -0
- package/dist/toolErrors.d.ts +2 -0
- package/dist/toolErrors.d.ts.map +1 -0
- package/dist/toolErrors.js +18 -0
- package/dist/toolErrors.js.map +1 -0
- package/dist/tools/activities.d.ts +18 -0
- package/dist/tools/activities.d.ts.map +1 -0
- package/dist/tools/activities.js +182 -0
- package/dist/tools/activities.js.map +1 -0
- package/dist/tools/bodyComposition.d.ts +10 -0
- package/dist/tools/bodyComposition.d.ts.map +1 -0
- package/dist/tools/bodyComposition.js +139 -0
- package/dist/tools/bodyComposition.js.map +1 -0
- package/dist/tools/context.d.ts +8 -0
- package/dist/tools/context.d.ts.map +1 -0
- package/dist/tools/context.js +148 -0
- package/dist/tools/context.js.map +1 -0
- package/dist/tools/findings.d.ts +7 -0
- package/dist/tools/findings.d.ts.map +1 -0
- package/dist/tools/findings.js +51 -0
- package/dist/tools/findings.js.map +1 -0
- package/dist/tools/heartRate.d.ts +10 -0
- package/dist/tools/heartRate.d.ts.map +1 -0
- package/dist/tools/heartRate.js +105 -0
- package/dist/tools/heartRate.js.map +1 -0
- package/dist/tools/index.d.ts +66 -0
- package/dist/tools/index.d.ts.map +1 -0
- package/dist/tools/index.js +101 -0
- package/dist/tools/index.js.map +1 -0
- package/dist/tools/payloads.d.ts +148 -0
- package/dist/tools/payloads.d.ts.map +1 -0
- package/dist/tools/payloads.js +2 -0
- package/dist/tools/payloads.js.map +1 -0
- package/dist/tools/recovery.d.ts +31 -0
- package/dist/tools/recovery.d.ts.map +1 -0
- package/dist/tools/recovery.js +350 -0
- package/dist/tools/recovery.js.map +1 -0
- package/dist/tools/sleep.d.ts +10 -0
- package/dist/tools/sleep.d.ts.map +1 -0
- package/dist/tools/sleep.js +124 -0
- package/dist/tools/sleep.js.map +1 -0
- package/dist/tools/stress.d.ts +11 -0
- package/dist/tools/stress.d.ts.map +1 -0
- package/dist/tools/stress.js +108 -0
- package/dist/tools/stress.js.map +1 -0
- package/dist/tools/trainingInsights.d.ts +15 -0
- package/dist/tools/trainingInsights.d.ts.map +1 -0
- package/dist/tools/trainingInsights.js +79 -0
- package/dist/tools/trainingInsights.js.map +1 -0
- package/dist/tools/types.d.ts +12 -0
- package/dist/tools/types.d.ts.map +1 -0
- package/dist/tools/types.js +2 -0
- package/dist/tools/types.js.map +1 -0
- package/dist/tools/vo2Max.d.ts +11 -0
- package/dist/tools/vo2Max.d.ts.map +1 -0
- package/dist/tools/vo2Max.js +110 -0
- package/dist/tools/vo2Max.js.map +1 -0
- package/dist/tools/week.d.ts +12 -0
- package/dist/tools/week.d.ts.map +1 -0
- package/dist/tools/week.js +74 -0
- package/dist/tools/week.js.map +1 -0
- package/dist/usage.d.ts +104 -0
- package/dist/usage.d.ts.map +1 -0
- package/dist/usage.js +247 -0
- package/dist/usage.js.map +1 -0
- package/dist/utils/batch.d.ts +2 -0
- package/dist/utils/batch.d.ts.map +1 -0
- package/dist/utils/batch.js +18 -0
- package/dist/utils/batch.js.map +1 -0
- package/dist/utils/helpers.d.ts +32 -0
- package/dist/utils/helpers.d.ts.map +1 -0
- package/dist/utils/helpers.js +188 -0
- package/dist/utils/helpers.js.map +1 -0
- package/dist/utils/jsonFile.d.ts +19 -0
- package/dist/utils/jsonFile.d.ts.map +1 -0
- package/dist/utils/jsonFile.js +24 -0
- package/dist/utils/jsonFile.js.map +1 -0
- package/dist/utils/logger.d.ts +4 -0
- package/dist/utils/logger.d.ts.map +1 -0
- package/dist/utils/logger.js +97 -0
- package/dist/utils/logger.js.map +1 -0
- package/dist/utils/secretFile.d.ts +15 -0
- package/dist/utils/secretFile.d.ts.map +1 -0
- package/dist/utils/secretFile.js +47 -0
- package/dist/utils/secretFile.js.map +1 -0
- package/dist/utils/stdio.d.ts +10 -0
- package/dist/utils/stdio.d.ts.map +1 -0
- package/dist/utils/stdio.js +70 -0
- package/dist/utils/stdio.js.map +1 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +7 -0
- package/dist/version.js.map +1 -0
- package/dist/watchApi.d.ts +264 -0
- package/dist/watchApi.d.ts.map +1 -0
- package/dist/watchApi.js +333 -0
- package/dist/watchApi.js.map +1 -0
- 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
|