trainbud 0.5.1 → 0.5.2

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/CHANGELOG.md CHANGED
@@ -2,6 +2,44 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [0.5.2] — server 0.5.2 · watch 2.0.2 — 2026-09-06
6
+
7
+ ### Added — a paired watch no longer holds the master key
8
+
9
+ - **Pairing mints a token scoped to that device.** `/api/pair/<code>/status` used
10
+ to hand the watch `TRAINBUD_API_KEY` itself: the credential that also opens the
11
+ dashboard and `/mcp`, sent to a device over a public tunnel, with no way to take
12
+ it back except rotating the key for everything at once. It now mints a
13
+ 256-bit token per pairing and stores only its SHA-256, so a copy of `app.db` is
14
+ not a working watch credential.
15
+ - **`trainbud devices`** lists paired watches with when they were paired and last
16
+ seen; **`trainbud devices revoke <id>`** (or `--all`) takes one away without
17
+ logging out the dashboard, the MCP endpoint, or the other watches. Revoking is
18
+ now the documented way to remove a watch — rotating `TRAINBUD_API_KEY` is not.
19
+ - **Nothing on the watch changed and no re-pair is forced.** The field name in the
20
+ pairing response is unchanged, the master key is still accepted, and a watch
21
+ paired before this release keeps working. Re-pair it to swap its stored master
22
+ key for a scoped token.
23
+
24
+ ### Added — security headers, on every response including the 401s
25
+
26
+ - The server shipped 0.5.1 with none, while being reachable from the internet for
27
+ as long as the watch's tunnel is up. Now sends `Content-Security-Policy`
28
+ (`frame-ancestors 'none'`, `form-action 'self'`, no external script or style),
29
+ `X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy: no-referrer`, and
30
+ the two `Cross-Origin-*` policies. Applied before routing, so an error path
31
+ cannot miss them.
32
+ - `script-src` keeps `'unsafe-inline'`: the dashboard is server-rendered HTML with
33
+ inline handlers, and a nonce policy is a rewrite of the page, not a header.
34
+ - **HSTS only when the request actually arrived over TLS.** Pinning https on a
35
+ host the user reaches at `http://127.0.0.1:3847` would lock them out of their
36
+ own dashboard.
37
+
38
+ ### Changed
39
+
40
+ - npm keywords: added `mcp-server`, `cursor`, `chatgpt`, `connect-iq`.
41
+ - 569 tests (567 pass, 2 skipped), up from 551.
42
+
5
43
  ## [0.5.1] — server 0.5.1 · watch 2.0.2 — 2026-09-06
6
44
 
7
45
  First release published to npm: `npx trainbud setup`. Trusted publishing over
package/README.md CHANGED
@@ -224,6 +224,8 @@ trainbud findings # What stands out against your own baselines
224
224
  trainbud start # Start the MCP server (stdio)
225
225
  trainbud auth # Force re-authentication
226
226
  trainbud cache clear # Clear cached data
227
+ trainbud devices # List paired watches
228
+ trainbud devices revoke <id> # Take one watch's access away
227
229
  trainbud status # Show session and cache status
228
230
  trainbud --version # Print version
229
231
  ```
@@ -265,10 +267,14 @@ repo root, or call the built entry point directly with `node dist/index.js docto
265
267
  - The dashboard takes the key once, on `/dashboard?token=…`, then trades it for an
266
268
  `HttpOnly` session cookie and redirects to a clean URL — so the key does not sit in
267
269
  your address bar, your history, or a screenshot
268
- - **A paired watch holds the API key itself**, not a scoped per-device token. Pairing
269
- approval hands over the same key that opens the dashboard and `/mcp`. Revoke a watch
270
- by changing `TRAINBUD_API_KEY` and pairing again. A narrower per-device token is
271
- planned; until then, treat pairing as handing out a password
270
+ - **A paired watch holds a token scoped to that watch**, minted at pairing and stored
271
+ on the server as a SHA-256 hash. `trainbud devices` lists them, `trainbud devices
272
+ revoke <id>` takes one away without logging out the dashboard, `/mcp`, or your
273
+ other watches. A watch paired before 0.5.2 holds the API key itself; re-pair it to
274
+ swap that for a scoped token
275
+ - Every response carries `Content-Security-Policy`, `X-Content-Type-Options`,
276
+ `X-Frame-Options` and `Referrer-Policy`, including the 401s. HSTS is sent only on a
277
+ request that actually arrived over TLS, so the loopback dashboard stays reachable
272
278
 
273
279
  ### What TrainBud is not
274
280
 
package/dist/appDb.d.ts CHANGED
@@ -14,7 +14,7 @@ export interface PromptJob {
14
14
  created_at: number;
15
15
  completed_at: number | null;
16
16
  }
17
- export declare const APP_DB_SCHEMA = "\n CREATE TABLE IF NOT EXISTS settings (\n key TEXT PRIMARY KEY,\n value TEXT NOT NULL\n );\n CREATE TABLE IF NOT EXISTS pair_tokens (\n code TEXT PRIMARY KEY,\n created_at INTEGER NOT NULL,\n expires_at INTEGER NOT NULL,\n approved_at INTEGER\n );\n CREATE TABLE IF NOT EXISTS prompt_jobs (\n id TEXT PRIMARY KEY,\n prompt TEXT NOT NULL,\n status TEXT NOT NULL DEFAULT 'pending',\n result TEXT,\n error TEXT,\n created_at INTEGER NOT NULL,\n completed_at INTEGER\n );\n\n -- Usage. cost_usd is nullable ON PURPOSE: a model this build has no\n -- published price for records its tokens and leaves the cost unknown,\n -- because a call priced at zero is a monthly cap that can never trip.\n -- The schema lives here rather than in usage.ts so that opening the\n -- database does not have to import the module that writes to it.\n CREATE TABLE IF NOT EXISTS ai_usage (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n at INTEGER NOT NULL,\n kind TEXT NOT NULL,\n model TEXT NOT NULL,\n source TEXT NOT NULL,\n input_tokens INTEGER NOT NULL,\n output_tokens INTEGER NOT NULL,\n cache_read_tokens INTEGER NOT NULL DEFAULT 0,\n cache_write_tokens INTEGER NOT NULL DEFAULT 0,\n cost_usd REAL\n );\n CREATE INDEX IF NOT EXISTS ai_usage_at ON ai_usage(at);\n\n CREATE TABLE IF NOT EXISTS feature_usage (\n name TEXT NOT NULL,\n day TEXT NOT NULL,\n count INTEGER NOT NULL,\n PRIMARY KEY (name, day)\n );\n";
17
+ export declare const APP_DB_SCHEMA = "\n CREATE TABLE IF NOT EXISTS settings (\n key TEXT PRIMARY KEY,\n value TEXT NOT NULL\n );\n CREATE TABLE IF NOT EXISTS pair_tokens (\n code TEXT PRIMARY KEY,\n created_at INTEGER NOT NULL,\n expires_at INTEGER NOT NULL,\n approved_at INTEGER\n );\n -- One row per paired watch. Only the SHA-256 of the token is kept; see\n -- deviceTokens.ts for why the plaintext is not stored anywhere. Created by\n -- IF NOT EXISTS on every open, so a database written before 0.5.2 gains\n -- the table without a migration step and without touching its other rows.\n CREATE TABLE IF NOT EXISTS device_tokens (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n token_hash TEXT NOT NULL UNIQUE,\n label TEXT NOT NULL,\n created_at INTEGER NOT NULL,\n last_seen_at INTEGER\n );\n\n CREATE TABLE IF NOT EXISTS prompt_jobs (\n id TEXT PRIMARY KEY,\n prompt TEXT NOT NULL,\n status TEXT NOT NULL DEFAULT 'pending',\n result TEXT,\n error TEXT,\n created_at INTEGER NOT NULL,\n completed_at INTEGER\n );\n\n -- Usage. cost_usd is nullable ON PURPOSE: a model this build has no\n -- published price for records its tokens and leaves the cost unknown,\n -- because a call priced at zero is a monthly cap that can never trip.\n -- The schema lives here rather than in usage.ts so that opening the\n -- database does not have to import the module that writes to it.\n CREATE TABLE IF NOT EXISTS ai_usage (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n at INTEGER NOT NULL,\n kind TEXT NOT NULL,\n model TEXT NOT NULL,\n source TEXT NOT NULL,\n input_tokens INTEGER NOT NULL,\n output_tokens INTEGER NOT NULL,\n cache_read_tokens INTEGER NOT NULL DEFAULT 0,\n cache_write_tokens INTEGER NOT NULL DEFAULT 0,\n cost_usd REAL\n );\n CREATE INDEX IF NOT EXISTS ai_usage_at ON ai_usage(at);\n\n CREATE TABLE IF NOT EXISTS feature_usage (\n name TEXT NOT NULL,\n day TEXT NOT NULL,\n count INTEGER NOT NULL,\n PRIMARY KEY (name, day)\n );\n";
18
18
  /**
19
19
  * The open handle, for modules that own their own tables.
20
20
  *
@@ -44,6 +44,45 @@ export declare function getPairToken(code: string): PairToken | null;
44
44
  export declare function approvePairToken(code: string): boolean;
45
45
  export declare function deletePairToken(code: string): void;
46
46
  export declare function listPendingPairTokens(): PairToken[];
47
+ export interface DeviceToken {
48
+ id: number;
49
+ label: string;
50
+ created_at: number;
51
+ last_seen_at: number | null;
52
+ }
53
+ /**
54
+ * Mints a token, stores its hash, and returns the plaintext exactly once.
55
+ *
56
+ * There is no way to read it back afterwards, which is the point: the only
57
+ * copy that survives this call is the one the watch stores. A lost token is
58
+ * re-paired, not recovered.
59
+ */
60
+ export declare function createDeviceToken(label: string): {
61
+ id: number;
62
+ token: string;
63
+ };
64
+ /**
65
+ * The id behind a token, or null.
66
+ *
67
+ * The lookup is an indexed equality match on a hash rather than a constant-time
68
+ * comparison, and that is deliberate: timing here can only leak how close a
69
+ * guess came to an existing *hash*, and producing a token that hashes to a
70
+ * known value is the preimage problem. The master key comparison in
71
+ * httpServer.ts is timing-safe because there the secret itself is compared.
72
+ */
73
+ export declare function findDeviceTokenId(token: string): number | null;
74
+ /**
75
+ * Records that a device was seen, at most once a minute.
76
+ *
77
+ * The watch polls, and an unthrottled write here would mean a database write
78
+ * per request for a column nobody reads more precisely than "today".
79
+ */
80
+ export declare function touchDeviceToken(id: number, now?: number): void;
81
+ export declare function listDeviceTokens(): DeviceToken[];
82
+ /** True if a row was actually removed, so the CLI can say so honestly. */
83
+ export declare function revokeDeviceToken(id: number): boolean;
84
+ /** Returns how many were revoked. */
85
+ export declare function revokeAllDeviceTokens(): number;
47
86
  /**
48
87
  * How old an unfinished job has to be before it counts as wreckage.
49
88
  *
package/dist/appDb.js CHANGED
@@ -3,6 +3,7 @@ import { randomInt } from "node:crypto";
3
3
  import fs from "node:fs";
4
4
  import path from "node:path";
5
5
  import { appConfig } from "./config.js";
6
+ import { generateDeviceToken, hashDeviceToken, looksLikeDeviceToken } from "./deviceTokens.js";
6
7
  import { restrictExistingFile } from "./utils/secretFile.js";
7
8
  // SECTION: App DB — settings, pair tokens, prompt jobs
8
9
  const DB_PATH = path.resolve(path.dirname(appConfig.cachePath), "app.db");
@@ -18,6 +19,18 @@ export const APP_DB_SCHEMA = `
18
19
  expires_at INTEGER NOT NULL,
19
20
  approved_at INTEGER
20
21
  );
22
+ -- One row per paired watch. Only the SHA-256 of the token is kept; see
23
+ -- deviceTokens.ts for why the plaintext is not stored anywhere. Created by
24
+ -- IF NOT EXISTS on every open, so a database written before 0.5.2 gains
25
+ -- the table without a migration step and without touching its other rows.
26
+ CREATE TABLE IF NOT EXISTS device_tokens (
27
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
28
+ token_hash TEXT NOT NULL UNIQUE,
29
+ label TEXT NOT NULL,
30
+ created_at INTEGER NOT NULL,
31
+ last_seen_at INTEGER
32
+ );
33
+
21
34
  CREATE TABLE IF NOT EXISTS prompt_jobs (
22
35
  id TEXT PRIMARY KEY,
23
36
  prompt TEXT NOT NULL,
@@ -161,6 +174,62 @@ export function listPendingPairTokens() {
161
174
  .prepare("SELECT code, created_at, expires_at, approved_at FROM pair_tokens WHERE expires_at > ? AND approved_at IS NULL ORDER BY created_at DESC")
162
175
  .all(now);
163
176
  }
177
+ /**
178
+ * Mints a token, stores its hash, and returns the plaintext exactly once.
179
+ *
180
+ * There is no way to read it back afterwards, which is the point: the only
181
+ * copy that survives this call is the one the watch stores. A lost token is
182
+ * re-paired, not recovered.
183
+ */
184
+ export function createDeviceToken(label) {
185
+ const token = generateDeviceToken();
186
+ const now = Math.floor(Date.now() / 1000);
187
+ const result = getDb()
188
+ .prepare("INSERT INTO device_tokens (token_hash, label, created_at, last_seen_at) VALUES (?, ?, ?, NULL)")
189
+ .run(hashDeviceToken(token), label, now);
190
+ return { id: Number(result.lastInsertRowid), token };
191
+ }
192
+ /**
193
+ * The id behind a token, or null.
194
+ *
195
+ * The lookup is an indexed equality match on a hash rather than a constant-time
196
+ * comparison, and that is deliberate: timing here can only leak how close a
197
+ * guess came to an existing *hash*, and producing a token that hashes to a
198
+ * known value is the preimage problem. The master key comparison in
199
+ * httpServer.ts is timing-safe because there the secret itself is compared.
200
+ */
201
+ export function findDeviceTokenId(token) {
202
+ if (!looksLikeDeviceToken(token))
203
+ return null;
204
+ const row = getDb()
205
+ .prepare("SELECT id FROM device_tokens WHERE token_hash = ?")
206
+ .get(hashDeviceToken(token));
207
+ return row?.id ?? null;
208
+ }
209
+ /**
210
+ * Records that a device was seen, at most once a minute.
211
+ *
212
+ * The watch polls, and an unthrottled write here would mean a database write
213
+ * per request for a column nobody reads more precisely than "today".
214
+ */
215
+ export function touchDeviceToken(id, now = Math.floor(Date.now() / 1000)) {
216
+ getDb()
217
+ .prepare("UPDATE device_tokens SET last_seen_at = ? WHERE id = ? AND (last_seen_at IS NULL OR last_seen_at < ?)")
218
+ .run(now, id, now - 60);
219
+ }
220
+ export function listDeviceTokens() {
221
+ return getDb()
222
+ .prepare("SELECT id, label, created_at, last_seen_at FROM device_tokens ORDER BY created_at DESC")
223
+ .all();
224
+ }
225
+ /** True if a row was actually removed, so the CLI can say so honestly. */
226
+ export function revokeDeviceToken(id) {
227
+ return getDb().prepare("DELETE FROM device_tokens WHERE id = ?").run(id).changes > 0;
228
+ }
229
+ /** Returns how many were revoked. */
230
+ export function revokeAllDeviceTokens() {
231
+ return getDb().prepare("DELETE FROM device_tokens").run().changes;
232
+ }
164
233
  // Prompt jobs
165
234
  /** Kept long enough to answer "what did it say earlier today", not forever. */
166
235
  const PROMPT_JOB_TTL_SECONDS = 7 * 24 * 60 * 60;
package/dist/cli.js CHANGED
@@ -17,6 +17,7 @@ import { startHistoryScheduler } from "./history/scheduler.js";
17
17
  import { closeHistoryDb, historyStats, pruneRawPayloads, RAW_RETENTION_DAYS, RAW_REVISIONS_KEPT, } from "./history/store.js";
18
18
  import { describeFindingsCoverage, runDetectors } from "./detect/index.js";
19
19
  import { printLiveCheckResults, runLiveCheck } from "./check.js";
20
+ import { listDeviceTokens, revokeAllDeviceTokens, revokeDeviceToken } from "./appDb.js";
20
21
  // SECTION: Bootstrap
21
22
  //
22
23
  // Runs before every command. Moves pre-0.3.0 state out of `.garmin/` and warns
@@ -440,6 +441,52 @@ export function createCliProgram() {
440
441
  process.exitCode = 1;
441
442
  }
442
443
  });
444
+ // Revoking a watch used to mean rotating TRAINBUD_API_KEY, which logged out
445
+ // the dashboard and every MCP client at the same time -- and, until watch
446
+ // 2.0.2, left the watch holding a dead key it would not replace. A paired
447
+ // watch now carries a token of its own, and this is where it is taken away.
448
+ const devicesCommand = program.command("devices").description("List and revoke paired watches");
449
+ devicesCommand
450
+ .command("list", { isDefault: true })
451
+ .description("Show paired watches, newest first")
452
+ .action(() => {
453
+ const devices = listDeviceTokens();
454
+ if (devices.length === 0) {
455
+ console.log("No paired watches. Pair one from the watch app's setup screen.");
456
+ return;
457
+ }
458
+ for (const device of devices) {
459
+ const created = new Date(device.created_at * 1000).toISOString().slice(0, 10);
460
+ const seen = device.last_seen_at
461
+ ? new Date(device.last_seen_at * 1000).toISOString().slice(0, 16).replace("T", " ")
462
+ : "never";
463
+ console.log(`${String(device.id).padStart(3)} ${device.label} paired ${created} last seen ${seen}`);
464
+ }
465
+ });
466
+ devicesCommand
467
+ .command("revoke [id]")
468
+ .description("Revoke one paired watch, or --all of them")
469
+ .option("--all", "Revoke every paired watch")
470
+ .action((id, options) => {
471
+ if (options.all) {
472
+ const count = revokeAllDeviceTokens();
473
+ console.log(count === 0 ? "Nothing to revoke." : `Revoked ${count} watch${count === 1 ? "" : "es"}.`);
474
+ return;
475
+ }
476
+ const numericId = Number(id);
477
+ if (!id || !Number.isInteger(numericId)) {
478
+ console.error("Pass the id from `trainbud devices list`, or --all.");
479
+ process.exitCode = 1;
480
+ return;
481
+ }
482
+ if (revokeDeviceToken(numericId)) {
483
+ console.log(`Revoked ${numericId}. That watch will show "Watch not authorised" and can pair again.`);
484
+ }
485
+ else {
486
+ console.error(`No paired watch with id ${numericId}.`);
487
+ process.exitCode = 1;
488
+ }
489
+ });
443
490
  program
444
491
  .command("status")
445
492
  .description("Show session and cache status")
@@ -0,0 +1,29 @@
1
+ /**
2
+ * The prefix is load-bearing, not decoration. Every authenticated request has
3
+ * to decide whether a bearer token is the master key or a device token, and
4
+ * without a marker that decision costs a database round trip on every single
5
+ * request -- including the dashboard's, which never carries a device token.
6
+ */
7
+ export declare const DEVICE_TOKEN_PREFIX = "tbd_";
8
+ /** 32 bytes from the CSPRNG. The prefix is not part of the entropy. */
9
+ export declare function generateDeviceToken(): string;
10
+ /**
11
+ * Only the hash is stored.
12
+ *
13
+ * `app.db` already holds the Anthropic key in the clear, which is why it is
14
+ * chmod'd 0600 -- but a backup, a sync folder or a support upload defeats file
15
+ * permissions, and a plaintext device token in that file is a working watch
16
+ * credential for whoever reads it. A SHA-256 of a 256-bit random value has no
17
+ * dictionary to attack: there is nothing to salt and nothing to guess.
18
+ */
19
+ export declare function hashDeviceToken(token: string): string;
20
+ /**
21
+ * A cheap structural check, deliberately not a security boundary.
22
+ *
23
+ * It decides which lookup to try, nothing more. A forged string carrying the
24
+ * prefix still has to hash to a row that exists.
25
+ */
26
+ export declare function looksLikeDeviceToken(token: string): boolean;
27
+ /** `watch-2026-09-06`, so the device list reads as something a human paired. */
28
+ export declare function defaultDeviceLabel(now?: Date): string;
29
+ //# sourceMappingURL=deviceTokens.d.ts.map
@@ -0,0 +1,49 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ // SECTION: Per-device tokens
3
+ //
4
+ // A paired watch used to be handed `appConfig.mcpApiKey` -- the master key,
5
+ // the same credential that unlocks the dashboard and every MCP route. Two
6
+ // things followed from that. Revoking one watch meant rotating the key, which
7
+ // logged out everything else and, until 2.0.2, bricked the watch outright. And
8
+ // a token read off a watch was full server access, not watch access.
9
+ //
10
+ // A device token is minted per pairing, carried by the watch in exactly the
11
+ // same `Authorization: Bearer` header, and revocable on its own. The master key
12
+ // keeps working, so a watch paired before this change does not have to re-pair.
13
+ /**
14
+ * The prefix is load-bearing, not decoration. Every authenticated request has
15
+ * to decide whether a bearer token is the master key or a device token, and
16
+ * without a marker that decision costs a database round trip on every single
17
+ * request -- including the dashboard's, which never carries a device token.
18
+ */
19
+ export const DEVICE_TOKEN_PREFIX = "tbd_";
20
+ /** 32 bytes from the CSPRNG. The prefix is not part of the entropy. */
21
+ export function generateDeviceToken() {
22
+ return DEVICE_TOKEN_PREFIX + randomBytes(32).toString("hex");
23
+ }
24
+ /**
25
+ * Only the hash is stored.
26
+ *
27
+ * `app.db` already holds the Anthropic key in the clear, which is why it is
28
+ * chmod'd 0600 -- but a backup, a sync folder or a support upload defeats file
29
+ * permissions, and a plaintext device token in that file is a working watch
30
+ * credential for whoever reads it. A SHA-256 of a 256-bit random value has no
31
+ * dictionary to attack: there is nothing to salt and nothing to guess.
32
+ */
33
+ export function hashDeviceToken(token) {
34
+ return createHash("sha256").update(token, "utf8").digest("hex");
35
+ }
36
+ /**
37
+ * A cheap structural check, deliberately not a security boundary.
38
+ *
39
+ * It decides which lookup to try, nothing more. A forged string carrying the
40
+ * prefix still has to hash to a row that exists.
41
+ */
42
+ export function looksLikeDeviceToken(token) {
43
+ return token.startsWith(DEVICE_TOKEN_PREFIX) && token.length === DEVICE_TOKEN_PREFIX.length + 64;
44
+ }
45
+ /** `watch-2026-09-06`, so the device list reads as something a human paired. */
46
+ export function defaultDeviceLabel(now = new Date()) {
47
+ return `watch-${now.toISOString().slice(0, 10)}`;
48
+ }
49
+ //# sourceMappingURL=deviceTokens.js.map
@@ -4,7 +4,8 @@ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/
4
4
  import { closeCache } from "./garmin/cache.js";
5
5
  import { CARD_IDS, DEFAULT_PROFILE, getProfile, updateProfile, } from "./profile.js";
6
6
  import { budgetState, clearFeatureUsage, dailyAiSpend, featureCounts, monthToDateSpend, recentAiUsage, recordFeature, } from "./usage.js";
7
- import { closeAppDb, reconcilePromptJobsOnStartup, setSetting } from "./appDb.js";
7
+ import { closeAppDb, findDeviceTokenId, reconcilePromptJobsOnStartup, setSetting, touchDeviceToken, } from "./appDb.js";
8
+ import { looksLikeDeviceToken } from "./deviceTokens.js";
8
9
  import { assertGarminCredentials, assertApiKey, appConfig } from "./config.js";
9
10
  import { createMcpServerInstance } from "./server.js";
10
11
  import { configureLogger, logger } from "./utils/logger.js";
@@ -197,6 +198,46 @@ function sessionCookieHeader(req, id) {
197
198
  const maxAge = Math.floor(SESSION_TTL_MS / 1000);
198
199
  return `${SESSION_COOKIE}=${id}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAge}${secure}`;
199
200
  }
201
+ /**
202
+ * Set on every response, before routing, so a 401 and a 500 carry them too.
203
+ *
204
+ * This server is reachable from the public internet whenever the tunnel the
205
+ * watch needs is up, and it was sending no security headers at all.
206
+ *
207
+ * `script-src` has to keep `'unsafe-inline'`: the dashboard is server-rendered
208
+ * HTML with one inline script and inline handlers, and a nonce-based policy is
209
+ * a rewrite of the page, not a header change. What the policy still buys is
210
+ * real -- no external script or style can load, the page cannot be framed, and
211
+ * `form-action 'self'` stops a submission being retargeted.
212
+ *
213
+ * HSTS is conditional on purpose. It is sent only on a request that actually
214
+ * arrived over TLS, because pinning https on a host someone later serves over
215
+ * plain http locally is a self-inflicted outage.
216
+ */
217
+ function applySecurityHeaders(req, res) {
218
+ res.setHeader("Content-Security-Policy", [
219
+ "default-src 'self'",
220
+ "script-src 'self' 'unsafe-inline'",
221
+ "style-src 'self' 'unsafe-inline'",
222
+ "img-src 'self' data:",
223
+ "connect-src 'self'",
224
+ "frame-ancestors 'none'",
225
+ "base-uri 'none'",
226
+ "form-action 'self'",
227
+ ].join("; "));
228
+ res.setHeader("X-Content-Type-Options", "nosniff");
229
+ res.setHeader("X-Frame-Options", "DENY");
230
+ // The dashboard is reached with ?token=<key> exactly once before the redirect
231
+ // to a clean URL. no-referrer keeps that one URL out of any outbound request.
232
+ res.setHeader("Referrer-Policy", "no-referrer");
233
+ res.setHeader("Cross-Origin-Opener-Policy", "same-origin");
234
+ res.setHeader("Cross-Origin-Resource-Policy", "same-origin");
235
+ const forwarded = req.headers["x-forwarded-proto"];
236
+ const proto = (Array.isArray(forwarded) ? forwarded[0] : forwarded)?.split(",")[0]?.trim();
237
+ if (proto === "https") {
238
+ res.setHeader("Strict-Transport-Security", "max-age=31536000");
239
+ }
240
+ }
200
241
  function isAuthorized(req, queryToken) {
201
242
  const header = req.headers.authorization;
202
243
  const headerToken = header?.startsWith("Bearer ") ? header.slice("Bearer ".length).trim() : null;
@@ -204,8 +245,29 @@ function isAuthorized(req, queryToken) {
204
245
  if (token !== null && matchesApiKey(token)) {
205
246
  return true;
206
247
  }
248
+ if (token !== null && matchesDeviceToken(token)) {
249
+ return true;
250
+ }
207
251
  return hasValidSession(req);
208
252
  }
253
+ /**
254
+ * A paired watch presents a token minted for it alone.
255
+ *
256
+ * Checked after the master key, and only for strings shaped like a device
257
+ * token, so the dashboard and MCP paths -- which never carry one -- do not pay
258
+ * a database lookup per request. Revoking the row is what logs a watch out;
259
+ * rotating the master key no longer has to, which is what made rotation brick
260
+ * a watch before 2.0.2.
261
+ */
262
+ function matchesDeviceToken(token) {
263
+ if (!looksLikeDeviceToken(token))
264
+ return false;
265
+ const id = findDeviceTokenId(token);
266
+ if (id === null)
267
+ return false;
268
+ touchDeviceToken(id);
269
+ return true;
270
+ }
209
271
  /**
210
272
  * Constant-time comparison. `===` returns as soon as two bytes differ, which
211
273
  * leaks the length of the matching prefix to anyone who can time the response,
@@ -533,6 +595,7 @@ export function createHttpMcpServer() {
533
595
  });
534
596
  });
535
597
  const handleRequest = async (req, res) => {
598
+ applySecurityHeaders(req, res);
536
599
  // A Host that is not a valid authority is not worth a 500: fall back to
537
600
  // a name that always parses. The host only matters for reading the path
538
601
  // and, in resolvePublicUrl, for telling a paired watch where to call
package/dist/pairApi.js CHANGED
@@ -1,5 +1,6 @@
1
- import { createPairToken, getPairToken, approvePairToken, deletePairToken, listPendingPairTokens } from "./appDb.js";
1
+ import { createPairToken, getPairToken, approvePairToken, deletePairToken, listPendingPairTokens, createDeviceToken } from "./appDb.js";
2
2
  import { appConfig } from "./config.js";
3
+ import { defaultDeviceLabel } from "./deviceTokens.js";
3
4
  export function requestPairing() {
4
5
  const token = createPairToken();
5
6
  const now = Math.floor(Date.now() / 1000);
@@ -21,9 +22,17 @@ export function checkPairStatus(code, publicUrl) {
21
22
  return null;
22
23
  if (token.approved_at !== null) {
23
24
  deletePairToken(code);
25
+ // What the watch receives here used to be appConfig.mcpApiKey: the master
26
+ // key, handed to a device over a tunnel, with no way to take it back except
27
+ // rotating the key for everything. It is now a token minted for this
28
+ // pairing alone, revocable by itself. The field name is unchanged on
29
+ // purpose -- the watch stores whatever arrives here and sends it as a
30
+ // bearer token, so nothing on the device had to change and no already
31
+ // paired watch has to pair again.
32
+ const device = createDeviceToken(defaultDeviceLabel());
24
33
  return {
25
34
  approved: true,
26
- api_key: appConfig.mcpApiKey,
35
+ api_key: device.token,
27
36
  server_url: publicUrl || appConfig.publicUrl,
28
37
  };
29
38
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trainbud",
3
- "version": "0.5.1",
3
+ "version": "0.5.2",
4
4
  "description": "TrainBud \u2014 talk to your Connect fitness data through Claude and other MCP clients",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -32,8 +32,12 @@
32
32
  "garmin",
33
33
  "garmin-connect",
34
34
  "mcp",
35
+ "mcp-server",
35
36
  "model-context-protocol",
36
37
  "claude",
38
+ "cursor",
39
+ "chatgpt",
40
+ "connect-iq",
37
41
  "fitness",
38
42
  "health",
39
43
  "trainbud"