trainbud 0.5.0 → 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.
Files changed (152) hide show
  1. package/.env.example +11 -11
  2. package/CHANGELOG.md +757 -675
  3. package/QUICKSTART.md +185 -182
  4. package/README.md +329 -322
  5. package/dist/appDb.d.ts +40 -1
  6. package/dist/appDb.js +120 -51
  7. package/dist/cli.js +47 -0
  8. package/dist/dashboard.js +820 -820
  9. package/dist/dashboardCharts.js +42 -42
  10. package/dist/deviceTokens.d.ts +29 -0
  11. package/dist/deviceTokens.js +49 -0
  12. package/dist/history/context.js +35 -35
  13. package/dist/history/schema.js +66 -66
  14. package/dist/httpServer.js +64 -1
  15. package/dist/pairApi.js +11 -2
  16. package/dist/promptApi.js +5 -5
  17. package/dist/usage.js +17 -17
  18. package/package.json +82 -77
  19. package/dist/appDb.d.ts.map +0 -1
  20. package/dist/appDb.js.map +0 -1
  21. package/dist/check.d.ts.map +0 -1
  22. package/dist/check.js.map +0 -1
  23. package/dist/cli.d.ts.map +0 -1
  24. package/dist/cli.js.map +0 -1
  25. package/dist/config.d.ts.map +0 -1
  26. package/dist/config.js.map +0 -1
  27. package/dist/dashboard.d.ts.map +0 -1
  28. package/dist/dashboard.js.map +0 -1
  29. package/dist/dashboardCharts.d.ts.map +0 -1
  30. package/dist/dashboardCharts.js.map +0 -1
  31. package/dist/dashboardData.d.ts.map +0 -1
  32. package/dist/dashboardData.js.map +0 -1
  33. package/dist/detect/baseline.d.ts.map +0 -1
  34. package/dist/detect/baseline.js.map +0 -1
  35. package/dist/detect/countdown.d.ts.map +0 -1
  36. package/dist/detect/countdown.js.map +0 -1
  37. package/dist/detect/detectors.d.ts.map +0 -1
  38. package/dist/detect/detectors.js.map +0 -1
  39. package/dist/detect/findings.d.ts.map +0 -1
  40. package/dist/detect/findings.js.map +0 -1
  41. package/dist/detect/forecast.d.ts.map +0 -1
  42. package/dist/detect/forecast.js.map +0 -1
  43. package/dist/detect/index.d.ts.map +0 -1
  44. package/dist/detect/index.js.map +0 -1
  45. package/dist/detect/sleepQuality.d.ts.map +0 -1
  46. package/dist/detect/sleepQuality.js.map +0 -1
  47. package/dist/detect/trimp.d.ts.map +0 -1
  48. package/dist/detect/trimp.js.map +0 -1
  49. package/dist/detect/week.d.ts.map +0 -1
  50. package/dist/detect/week.js.map +0 -1
  51. package/dist/garmin/auth.d.ts.map +0 -1
  52. package/dist/garmin/auth.js.map +0 -1
  53. package/dist/garmin/cache.d.ts.map +0 -1
  54. package/dist/garmin/cache.js.map +0 -1
  55. package/dist/garmin/client.d.ts.map +0 -1
  56. package/dist/garmin/client.js.map +0 -1
  57. package/dist/garmin/daily.d.ts.map +0 -1
  58. package/dist/garmin/daily.js.map +0 -1
  59. package/dist/garmin/garminApiTypes.d.ts.map +0 -1
  60. package/dist/garmin/garminApiTypes.js.map +0 -1
  61. package/dist/garmin/garminConnect.d.ts.map +0 -1
  62. package/dist/garmin/garminConnect.js.map +0 -1
  63. package/dist/garmin/partial.d.ts.map +0 -1
  64. package/dist/garmin/partial.js.map +0 -1
  65. package/dist/garmin/rawApi.d.ts.map +0 -1
  66. package/dist/garmin/rawApi.js.map +0 -1
  67. package/dist/garmin/types.d.ts.map +0 -1
  68. package/dist/garmin/types.js.map +0 -1
  69. package/dist/history/capture.d.ts.map +0 -1
  70. package/dist/history/capture.js.map +0 -1
  71. package/dist/history/context.d.ts.map +0 -1
  72. package/dist/history/context.js.map +0 -1
  73. package/dist/history/fallback.d.ts.map +0 -1
  74. package/dist/history/fallback.js.map +0 -1
  75. package/dist/history/ingest.d.ts.map +0 -1
  76. package/dist/history/ingest.js.map +0 -1
  77. package/dist/history/scheduler.d.ts.map +0 -1
  78. package/dist/history/scheduler.js.map +0 -1
  79. package/dist/history/schema.d.ts.map +0 -1
  80. package/dist/history/schema.js.map +0 -1
  81. package/dist/history/store.d.ts.map +0 -1
  82. package/dist/history/store.js.map +0 -1
  83. package/dist/httpServer.d.ts.map +0 -1
  84. package/dist/httpServer.js.map +0 -1
  85. package/dist/index.d.ts.map +0 -1
  86. package/dist/index.js.map +0 -1
  87. package/dist/mcpConfig.d.ts.map +0 -1
  88. package/dist/mcpConfig.js.map +0 -1
  89. package/dist/pairApi.d.ts.map +0 -1
  90. package/dist/pairApi.js.map +0 -1
  91. package/dist/paths.d.ts.map +0 -1
  92. package/dist/paths.js.map +0 -1
  93. package/dist/profile.d.ts.map +0 -1
  94. package/dist/profile.js.map +0 -1
  95. package/dist/promptApi.d.ts.map +0 -1
  96. package/dist/promptApi.js.map +0 -1
  97. package/dist/promptSuggestions.d.ts.map +0 -1
  98. package/dist/promptSuggestions.js.map +0 -1
  99. package/dist/selfTest.d.ts.map +0 -1
  100. package/dist/selfTest.js.map +0 -1
  101. package/dist/server.d.ts.map +0 -1
  102. package/dist/server.js.map +0 -1
  103. package/dist/setup.d.ts.map +0 -1
  104. package/dist/setup.js.map +0 -1
  105. package/dist/toolErrors.d.ts.map +0 -1
  106. package/dist/toolErrors.js.map +0 -1
  107. package/dist/tools/activities.d.ts.map +0 -1
  108. package/dist/tools/activities.js.map +0 -1
  109. package/dist/tools/bodyComposition.d.ts.map +0 -1
  110. package/dist/tools/bodyComposition.js.map +0 -1
  111. package/dist/tools/context.d.ts.map +0 -1
  112. package/dist/tools/context.js.map +0 -1
  113. package/dist/tools/findings.d.ts.map +0 -1
  114. package/dist/tools/findings.js.map +0 -1
  115. package/dist/tools/heartRate.d.ts.map +0 -1
  116. package/dist/tools/heartRate.js.map +0 -1
  117. package/dist/tools/index.d.ts.map +0 -1
  118. package/dist/tools/index.js.map +0 -1
  119. package/dist/tools/payloads.d.ts.map +0 -1
  120. package/dist/tools/payloads.js.map +0 -1
  121. package/dist/tools/recovery.d.ts.map +0 -1
  122. package/dist/tools/recovery.js.map +0 -1
  123. package/dist/tools/sleep.d.ts.map +0 -1
  124. package/dist/tools/sleep.js.map +0 -1
  125. package/dist/tools/stress.d.ts.map +0 -1
  126. package/dist/tools/stress.js.map +0 -1
  127. package/dist/tools/trainingInsights.d.ts.map +0 -1
  128. package/dist/tools/trainingInsights.js.map +0 -1
  129. package/dist/tools/types.d.ts.map +0 -1
  130. package/dist/tools/types.js.map +0 -1
  131. package/dist/tools/vo2Max.d.ts.map +0 -1
  132. package/dist/tools/vo2Max.js.map +0 -1
  133. package/dist/tools/week.d.ts.map +0 -1
  134. package/dist/tools/week.js.map +0 -1
  135. package/dist/usage.d.ts.map +0 -1
  136. package/dist/usage.js.map +0 -1
  137. package/dist/utils/batch.d.ts.map +0 -1
  138. package/dist/utils/batch.js.map +0 -1
  139. package/dist/utils/helpers.d.ts.map +0 -1
  140. package/dist/utils/helpers.js.map +0 -1
  141. package/dist/utils/jsonFile.d.ts.map +0 -1
  142. package/dist/utils/jsonFile.js.map +0 -1
  143. package/dist/utils/logger.d.ts.map +0 -1
  144. package/dist/utils/logger.js.map +0 -1
  145. package/dist/utils/secretFile.d.ts.map +0 -1
  146. package/dist/utils/secretFile.js.map +0 -1
  147. package/dist/utils/stdio.d.ts.map +0 -1
  148. package/dist/utils/stdio.js.map +0 -1
  149. package/dist/version.d.ts.map +0 -1
  150. package/dist/version.js.map +0 -1
  151. package/dist/watchApi.d.ts.map +0 -1
  152. package/dist/watchApi.js.map +0 -1
@@ -58,9 +58,9 @@ function round(value, places = 2) {
58
58
  * whole file exists to preserve.
59
59
  */
60
60
  function emptyChart(width, height, message) {
61
- return `<svg class="chart" viewBox="0 0 ${width} ${height}" role="img" aria-label="${escapeXml(message)}" preserveAspectRatio="none">
62
- <text x="${width / 2}" y="${height / 2}" text-anchor="middle" dominant-baseline="middle"
63
- fill="${CHART_COLORS.muted}" font-size="13">${escapeXml(message)}</text>
61
+ return `<svg class="chart" viewBox="0 0 ${width} ${height}" role="img" aria-label="${escapeXml(message)}" preserveAspectRatio="none">
62
+ <text x="${width / 2}" y="${height / 2}" text-anchor="middle" dominant-baseline="middle"
63
+ fill="${CHART_COLORS.muted}" font-size="13">${escapeXml(message)}</text>
64
64
  </svg>`;
65
65
  }
66
66
  /**
@@ -152,30 +152,30 @@ export function lineChart(points, options) {
152
152
  const last = [...points].reverse().find((p) => p.value !== null);
153
153
  const lastIndex = last ? points.findIndex((p) => p.date === last.date) : -1;
154
154
  const baselineMarkup = options.baseline
155
- ? `<line x1="${padLeft}" x2="${width - padRight}" y1="${round(y(options.baseline.value))}" y2="${round(y(options.baseline.value))}"
155
+ ? `<line x1="${padLeft}" x2="${width - padRight}" y1="${round(y(options.baseline.value))}" y2="${round(y(options.baseline.value))}"
156
156
  stroke="${CHART_COLORS.muted}" stroke-width="1" stroke-dasharray="3 3" opacity="0.7"><title>${escapeXml(options.baseline.label)}</title></line>`
157
157
  : "";
158
158
  const dots = points
159
159
  .map((point, index) => point.value === null
160
160
  ? ""
161
- : `<circle cx="${round(x(index))}" cy="${round(y(point.value))}" r="4"
162
- fill="${CHART_COLORS.series}" stroke="${CHART_COLORS.surface}" stroke-width="2"
161
+ : `<circle cx="${round(x(index))}" cy="${round(y(point.value))}" r="4"
162
+ fill="${CHART_COLORS.series}" stroke="${CHART_COLORS.surface}" stroke-width="2"
163
163
  class="dot"><title>${escapeXml(point.date)}: ${escapeXml(format(point.value))}${escapeXml(options.unit ?? "")}</title></circle>`)
164
164
  .join("");
165
- return `<svg class="chart" viewBox="0 0 ${width} ${height}" role="img"
166
- aria-label="${escapeXml(options.label)}" preserveAspectRatio="none">
167
- <line x1="${padLeft}" x2="${width - padRight}" y1="${padTop}" y2="${padTop}" stroke="${CHART_COLORS.grid}" stroke-width="1"/>
168
- <line x1="${padLeft}" x2="${width - padRight}" y1="${padTop + plotHeight}" y2="${padTop + plotHeight}" stroke="${CHART_COLORS.grid}" stroke-width="1"/>
169
- <text x="4" y="${padTop + 4}" fill="${CHART_COLORS.muted}" font-size="10">${escapeXml(format(scale.max))}</text>
170
- <text x="4" y="${padTop + plotHeight}" fill="${CHART_COLORS.muted}" font-size="10">${escapeXml(format(scale.min))}</text>
171
- ${baselineMarkup}
172
- <path d="${segments.join(" ")}" fill="none" stroke="${CHART_COLORS.series}" stroke-width="2"
173
- stroke-linejoin="round" stroke-linecap="round"/>
174
- ${dots}
165
+ return `<svg class="chart" viewBox="0 0 ${width} ${height}" role="img"
166
+ aria-label="${escapeXml(options.label)}" preserveAspectRatio="none">
167
+ <line x1="${padLeft}" x2="${width - padRight}" y1="${padTop}" y2="${padTop}" stroke="${CHART_COLORS.grid}" stroke-width="1"/>
168
+ <line x1="${padLeft}" x2="${width - padRight}" y1="${padTop + plotHeight}" y2="${padTop + plotHeight}" stroke="${CHART_COLORS.grid}" stroke-width="1"/>
169
+ <text x="4" y="${padTop + 4}" fill="${CHART_COLORS.muted}" font-size="10">${escapeXml(format(scale.max))}</text>
170
+ <text x="4" y="${padTop + plotHeight}" fill="${CHART_COLORS.muted}" font-size="10">${escapeXml(format(scale.min))}</text>
171
+ ${baselineMarkup}
172
+ <path d="${segments.join(" ")}" fill="none" stroke="${CHART_COLORS.series}" stroke-width="2"
173
+ stroke-linejoin="round" stroke-linecap="round"/>
174
+ ${dots}
175
175
  ${lastIndex >= 0 && last?.value !== null && last !== undefined
176
- ? `<text x="${width - padRight}" y="${round(y(last.value)) - 8}" text-anchor="end"
176
+ ? `<text x="${width - padRight}" y="${round(y(last.value)) - 8}" text-anchor="end"
177
177
  fill="${CHART_COLORS.ink}" font-size="11">${escapeXml(format(last.value))}${escapeXml(options.unit ?? "")}</text>`
178
- : ""}
178
+ : ""}
179
179
  </svg>`;
180
180
  }
181
181
  /**
@@ -204,9 +204,9 @@ export function columnChart(points, options) {
204
204
  // data and left a hole in the page.
205
205
  if (maxValue <= 0) {
206
206
  const flat = 54;
207
- return `<svg class="chart" viewBox="0 0 ${width} ${flat}" role="img" aria-label="${escapeXml(options.label)}" preserveAspectRatio="none">
208
- <line x1="${padLeft}" x2="${width - padRight}" y1="${flat - 14}" y2="${flat - 14}" stroke="${CHART_COLORS.grid}" stroke-width="1"/>
209
- <text x="${width / 2}" y="${flat / 2 - 4}" text-anchor="middle" fill="${CHART_COLORS.muted}" font-size="12">Nothing spent</text>
207
+ return `<svg class="chart" viewBox="0 0 ${width} ${flat}" role="img" aria-label="${escapeXml(options.label)}" preserveAspectRatio="none">
208
+ <line x1="${padLeft}" x2="${width - padRight}" y1="${flat - 14}" y2="${flat - 14}" stroke="${CHART_COLORS.grid}" stroke-width="1"/>
209
+ <text x="${width / 2}" y="${flat / 2 - 4}" text-anchor="middle" fill="${CHART_COLORS.muted}" font-size="12">Nothing spent</text>
210
210
  </svg>`;
211
211
  }
212
212
  const plotWidth = width - padLeft - padRight;
@@ -223,22 +223,22 @@ export function columnChart(points, options) {
223
223
  if (barHeight <= 0) {
224
224
  // A recorded zero still gets a hit target, so hovering an empty day
225
225
  // says "nothing on this day" rather than nothing at all.
226
- return `<rect x="${bx}" y="${round(padTop + plotHeight - 1)}" width="${round(barWidth)}" height="1"
226
+ return `<rect x="${bx}" y="${round(padTop + plotHeight - 1)}" width="${round(barWidth)}" height="1"
227
227
  fill="${CHART_COLORS.grid}" class="bar"><title>${title}</title></rect>`;
228
228
  }
229
- return `<rect x="${bx}" y="${by}" width="${round(barWidth)}" height="${round(barHeight)}"
229
+ return `<rect x="${bx}" y="${by}" width="${round(barWidth)}" height="${round(barHeight)}"
230
230
  rx="${round(radius)}" fill="${CHART_COLORS.series}" class="bar"><title>${title}</title></rect>`;
231
231
  })
232
232
  .join("");
233
233
  const peakIndex = points.findIndex((p) => p.value === maxValue);
234
234
  const peakX = round(padLeft + peakIndex * slot + slot / 2);
235
235
  const peakY = round(padTop + plotHeight - (maxValue / maxValue) * plotHeight) - 4;
236
- return `<svg class="chart" viewBox="0 0 ${width} ${height}" role="img"
237
- aria-label="${escapeXml(options.label)}" preserveAspectRatio="none">
238
- <line x1="${padLeft}" x2="${width - padRight}" y1="${padTop + plotHeight}" y2="${padTop + plotHeight}" stroke="${CHART_COLORS.grid}" stroke-width="1"/>
239
- <text x="4" y="${padTop + 4}" fill="${CHART_COLORS.muted}" font-size="10">${escapeXml(format(maxValue))}</text>
240
- ${bars}
241
- <text x="${peakX}" y="${peakY}" text-anchor="middle" fill="${CHART_COLORS.ink}" font-size="11">${escapeXml(format(maxValue))}</text>
236
+ return `<svg class="chart" viewBox="0 0 ${width} ${height}" role="img"
237
+ aria-label="${escapeXml(options.label)}" preserveAspectRatio="none">
238
+ <line x1="${padLeft}" x2="${width - padRight}" y1="${padTop + plotHeight}" y2="${padTop + plotHeight}" stroke="${CHART_COLORS.grid}" stroke-width="1"/>
239
+ <text x="4" y="${padTop + 4}" fill="${CHART_COLORS.muted}" font-size="10">${escapeXml(format(maxValue))}</text>
240
+ ${bars}
241
+ <text x="${peakX}" y="${peakY}" text-anchor="middle" fill="${CHART_COLORS.ink}" font-size="11">${escapeXml(format(maxValue))}</text>
242
242
  </svg>`;
243
243
  }
244
244
  /**
@@ -288,30 +288,30 @@ export function dumbbellChart(rows, options = {}) {
288
288
  const px = labelWidth + (previous / max) * trackWidth;
289
289
  const cx = labelWidth + (current / max) * trackWidth;
290
290
  const y = firstRowY + index * rowHeight;
291
- return `
292
- <text x="0" y="${y + 4}" fill="${CHART_COLORS.muted}" font-size="11">${escapeXml(row.label)}</text>
293
- <line x1="${round(px)}" x2="${round(cx)}" y1="${y}" y2="${y}" stroke="${CHART_COLORS.grid}" stroke-width="2" stroke-linecap="round"/>
291
+ return `
292
+ <text x="0" y="${y + 4}" fill="${CHART_COLORS.muted}" font-size="11">${escapeXml(row.label)}</text>
293
+ <line x1="${round(px)}" x2="${round(cx)}" y1="${y}" y2="${y}" stroke="${CHART_COLORS.grid}" stroke-width="2" stroke-linecap="round"/>
294
294
  ${unchanged
295
295
  ? // Both weeks land on the same point. Drawn as a grey halo behind the
296
296
  // accent dot: with the normal marks, this week's 2px surface ring
297
297
  // covers last week's dot completely and "no change" renders as "last
298
298
  // week is missing" -- an absence, which is the one thing this codebase
299
299
  // must never draw a measurement as.
300
- `<circle cx="${round(cx)}" cy="${y}" r="8" fill="${CHART_COLORS.muted}" opacity="0.45"><title>No change: ${escapeXml(String(current))}${escapeXml(row.unit)}</title></circle>
300
+ `<circle cx="${round(cx)}" cy="${y}" r="8" fill="${CHART_COLORS.muted}" opacity="0.45"><title>No change: ${escapeXml(String(current))}${escapeXml(row.unit)}</title></circle>
301
301
  <circle cx="${round(cx)}" cy="${y}" r="4" fill="${CHART_COLORS.series}"><title>This week: ${escapeXml(String(current))}${escapeXml(row.unit)}</title></circle>`
302
- : `<circle cx="${round(px)}" cy="${y}" r="4" fill="${CHART_COLORS.muted}" stroke="${CHART_COLORS.surface}" stroke-width="2"><title>Last week: ${escapeXml(String(previous))}${escapeXml(row.unit)}</title></circle>
303
- <circle cx="${round(cx)}" cy="${y}" r="5" fill="${CHART_COLORS.series}" stroke="${CHART_COLORS.surface}" stroke-width="2"><title>This week: ${escapeXml(String(current))}${escapeXml(row.unit)}</title></circle>`}
302
+ : `<circle cx="${round(px)}" cy="${y}" r="4" fill="${CHART_COLORS.muted}" stroke="${CHART_COLORS.surface}" stroke-width="2"><title>Last week: ${escapeXml(String(previous))}${escapeXml(row.unit)}</title></circle>
303
+ <circle cx="${round(cx)}" cy="${y}" r="5" fill="${CHART_COLORS.series}" stroke="${CHART_COLORS.surface}" stroke-width="2"><title>This week: ${escapeXml(String(current))}${escapeXml(row.unit)}</title></circle>`}
304
304
  <text x="${width}" y="${y + 4}" text-anchor="end" fill="${CHART_COLORS.ink}" font-size="11">${escapeXml(String(current))}${escapeXml(row.unit)}</text>`;
305
305
  })
306
306
  .join("");
307
307
  // Two marks means a legend, always. Identity never rests on colour alone.
308
- return `<svg class="chart" viewBox="0 0 ${width} ${height}" role="img"
309
- aria-label="This week against last week" preserveAspectRatio="none">
310
- <circle cx="4" cy="6" r="4" fill="${CHART_COLORS.muted}"/>
311
- <text x="13" y="10" fill="${CHART_COLORS.muted}" font-size="10">last week</text>
312
- <circle cx="76" cy="6" r="5" fill="${CHART_COLORS.series}"/>
313
- <text x="86" y="10" fill="${CHART_COLORS.muted}" font-size="10">this week</text>
314
- ${body}
308
+ return `<svg class="chart" viewBox="0 0 ${width} ${height}" role="img"
309
+ aria-label="This week against last week" preserveAspectRatio="none">
310
+ <circle cx="4" cy="6" r="4" fill="${CHART_COLORS.muted}"/>
311
+ <text x="13" y="10" fill="${CHART_COLORS.muted}" font-size="10">last week</text>
312
+ <circle cx="76" cy="6" r="5" fill="${CHART_COLORS.series}"/>
313
+ <text x="86" y="10" fill="${CHART_COLORS.muted}" font-size="10">this week</text>
314
+ ${body}
315
315
  </svg>`;
316
316
  }
317
317
  //# sourceMappingURL=dashboardCharts.js.map
@@ -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
@@ -28,7 +28,7 @@ export function addContextEntry(kind, text, options = {}) {
28
28
  createdAt: nowSeconds(),
29
29
  };
30
30
  const result = getHistoryDb()
31
- .prepare(`INSERT INTO context_entry (kind, text, effective_from, effective_to, created_at)
31
+ .prepare(`INSERT INTO context_entry (kind, text, effective_from, effective_to, created_at)
32
32
  VALUES (?, ?, ?, ?, ?)`)
33
33
  .run(entry.kind, entry.text, entry.effectiveFrom, entry.effectiveTo, entry.createdAt);
34
34
  return { id: Number(result.lastInsertRowid), ...entry };
@@ -36,16 +36,16 @@ export function addContextEntry(kind, text, options = {}) {
36
36
  /** Everything true on the given date, newest first. */
37
37
  export function activeContext(onDate) {
38
38
  return getHistoryDb()
39
- .prepare(`SELECT
40
- id,
41
- kind,
42
- text,
43
- effective_from AS effectiveFrom,
44
- effective_to AS effectiveTo,
45
- created_at AS createdAt
46
- FROM context_entry
47
- WHERE effective_from <= ?
48
- AND (effective_to IS NULL OR effective_to > ?)
39
+ .prepare(`SELECT
40
+ id,
41
+ kind,
42
+ text,
43
+ effective_from AS effectiveFrom,
44
+ effective_to AS effectiveTo,
45
+ created_at AS createdAt
46
+ FROM context_entry
47
+ WHERE effective_from <= ?
48
+ AND (effective_to IS NULL OR effective_to > ?)
49
49
  ORDER BY effective_from DESC, id DESC`)
50
50
  .all(onDate, onDate);
51
51
  }
@@ -61,29 +61,29 @@ export function activeContext(onDate) {
61
61
  */
62
62
  export function upcomingContext(onDate) {
63
63
  return getHistoryDb()
64
- .prepare(`SELECT
65
- id,
66
- kind,
67
- text,
68
- effective_from AS effectiveFrom,
69
- effective_to AS effectiveTo,
70
- created_at AS createdAt
71
- FROM context_entry
72
- WHERE effective_from > ?
64
+ .prepare(`SELECT
65
+ id,
66
+ kind,
67
+ text,
68
+ effective_from AS effectiveFrom,
69
+ effective_to AS effectiveTo,
70
+ created_at AS createdAt
71
+ FROM context_entry
72
+ WHERE effective_from > ?
73
73
  ORDER BY effective_from ASC, id ASC`)
74
74
  .all(onDate);
75
75
  }
76
76
  /** Every entry ever recorded, newest first — for a dashboard or a tool listing. */
77
77
  export function allContext() {
78
78
  return getHistoryDb()
79
- .prepare(`SELECT
80
- id,
81
- kind,
82
- text,
83
- effective_from AS effectiveFrom,
84
- effective_to AS effectiveTo,
85
- created_at AS createdAt
86
- FROM context_entry
79
+ .prepare(`SELECT
80
+ id,
81
+ kind,
82
+ text,
83
+ effective_from AS effectiveFrom,
84
+ effective_to AS effectiveTo,
85
+ created_at AS createdAt
86
+ FROM context_entry
87
87
  ORDER BY effective_from DESC, id DESC`)
88
88
  .all();
89
89
  }
@@ -104,18 +104,18 @@ export function logSubjective(date, kind, value, note) {
104
104
  throw new Error(`A ${kind} rating must be a number between ${SUBJECTIVE_MIN} and ${SUBJECTIVE_MAX}.`);
105
105
  }
106
106
  getHistoryDb()
107
- .prepare(`INSERT INTO subjective (date, kind, value, note, created_at)
108
- VALUES (?, ?, ?, ?, ?)
109
- ON CONFLICT(date, kind) DO UPDATE SET
110
- value = excluded.value,
111
- note = excluded.note,
107
+ .prepare(`INSERT INTO subjective (date, kind, value, note, created_at)
108
+ VALUES (?, ?, ?, ?, ?)
109
+ ON CONFLICT(date, kind) DO UPDATE SET
110
+ value = excluded.value,
111
+ note = excluded.note,
112
112
  created_at = excluded.created_at`)
113
113
  .run(date, kind, value, note?.trim() || null, nowSeconds());
114
114
  }
115
115
  export function subjectiveSeries(kind, startDate, endDate) {
116
116
  return getHistoryDb()
117
- .prepare(`SELECT date, value, note FROM subjective
118
- WHERE kind = ? AND date >= ? AND date <= ?
117
+ .prepare(`SELECT date, value, note FROM subjective
118
+ WHERE kind = ? AND date >= ? AND date <= ?
119
119
  ORDER BY date ASC`)
120
120
  .all(kind, startDate, endDate);
121
121
  }
@@ -21,71 +21,71 @@ export const SUBJECTIVE_KINDS = ["rpe", "soreness", "mood"];
21
21
  * Connect does not send, and re-deriving from the archive beats re-fetching a
22
22
  * year against an unofficial API.
23
23
  */
24
- export const HISTORY_SCHEMA = `
25
- CREATE TABLE IF NOT EXISTS daily_metric (
26
- date TEXT NOT NULL,
27
- kind TEXT NOT NULL,
28
- value REAL NOT NULL,
29
- fetched_at INTEGER NOT NULL,
30
- PRIMARY KEY (date, kind)
31
- );
32
-
33
- CREATE TABLE IF NOT EXISTS raw_payload (
34
- id INTEGER PRIMARY KEY AUTOINCREMENT,
35
- date TEXT NOT NULL,
36
- source TEXT NOT NULL,
37
- fetched_at INTEGER NOT NULL,
38
- json TEXT NOT NULL
39
- );
40
-
41
- CREATE INDEX IF NOT EXISTS raw_payload_date_source
42
- ON raw_payload (date, source, fetched_at);
43
-
44
- CREATE TABLE IF NOT EXISTS ingest_day (
45
- date TEXT NOT NULL,
46
- source TEXT NOT NULL,
47
- fetched_at INTEGER NOT NULL,
48
- outcome TEXT NOT NULL,
49
- PRIMARY KEY (date, source)
50
- );
51
-
52
- CREATE TABLE IF NOT EXISTS activity (
53
- activity_id INTEGER PRIMARY KEY,
54
- date TEXT NOT NULL,
55
- start_time_local TEXT NOT NULL,
56
- name TEXT NOT NULL,
57
- type TEXT NOT NULL,
58
- distance_m REAL,
59
- duration_s REAL,
60
- avg_hr REAL,
61
- max_hr REAL,
62
- elevation_gain_m REAL,
63
- calories REAL,
64
- avg_speed_mps REAL,
65
- fetched_at INTEGER NOT NULL
66
- );
67
-
68
- CREATE INDEX IF NOT EXISTS activity_date ON activity (date);
69
-
70
- CREATE TABLE IF NOT EXISTS context_entry (
71
- id INTEGER PRIMARY KEY AUTOINCREMENT,
72
- kind TEXT NOT NULL,
73
- text TEXT NOT NULL,
74
- effective_from TEXT NOT NULL,
75
- effective_to TEXT,
76
- created_at INTEGER NOT NULL
77
- );
78
-
79
- CREATE INDEX IF NOT EXISTS context_entry_range
80
- ON context_entry (effective_from, effective_to);
81
-
82
- CREATE TABLE IF NOT EXISTS subjective (
83
- date TEXT NOT NULL,
84
- kind TEXT NOT NULL,
85
- value REAL NOT NULL,
86
- note TEXT,
87
- created_at INTEGER NOT NULL,
88
- PRIMARY KEY (date, kind)
89
- );
24
+ export const HISTORY_SCHEMA = `
25
+ CREATE TABLE IF NOT EXISTS daily_metric (
26
+ date TEXT NOT NULL,
27
+ kind TEXT NOT NULL,
28
+ value REAL NOT NULL,
29
+ fetched_at INTEGER NOT NULL,
30
+ PRIMARY KEY (date, kind)
31
+ );
32
+
33
+ CREATE TABLE IF NOT EXISTS raw_payload (
34
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
35
+ date TEXT NOT NULL,
36
+ source TEXT NOT NULL,
37
+ fetched_at INTEGER NOT NULL,
38
+ json TEXT NOT NULL
39
+ );
40
+
41
+ CREATE INDEX IF NOT EXISTS raw_payload_date_source
42
+ ON raw_payload (date, source, fetched_at);
43
+
44
+ CREATE TABLE IF NOT EXISTS ingest_day (
45
+ date TEXT NOT NULL,
46
+ source TEXT NOT NULL,
47
+ fetched_at INTEGER NOT NULL,
48
+ outcome TEXT NOT NULL,
49
+ PRIMARY KEY (date, source)
50
+ );
51
+
52
+ CREATE TABLE IF NOT EXISTS activity (
53
+ activity_id INTEGER PRIMARY KEY,
54
+ date TEXT NOT NULL,
55
+ start_time_local TEXT NOT NULL,
56
+ name TEXT NOT NULL,
57
+ type TEXT NOT NULL,
58
+ distance_m REAL,
59
+ duration_s REAL,
60
+ avg_hr REAL,
61
+ max_hr REAL,
62
+ elevation_gain_m REAL,
63
+ calories REAL,
64
+ avg_speed_mps REAL,
65
+ fetched_at INTEGER NOT NULL
66
+ );
67
+
68
+ CREATE INDEX IF NOT EXISTS activity_date ON activity (date);
69
+
70
+ CREATE TABLE IF NOT EXISTS context_entry (
71
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
72
+ kind TEXT NOT NULL,
73
+ text TEXT NOT NULL,
74
+ effective_from TEXT NOT NULL,
75
+ effective_to TEXT,
76
+ created_at INTEGER NOT NULL
77
+ );
78
+
79
+ CREATE INDEX IF NOT EXISTS context_entry_range
80
+ ON context_entry (effective_from, effective_to);
81
+
82
+ CREATE TABLE IF NOT EXISTS subjective (
83
+ date TEXT NOT NULL,
84
+ kind TEXT NOT NULL,
85
+ value REAL NOT NULL,
86
+ note TEXT,
87
+ created_at INTEGER NOT NULL,
88
+ PRIMARY KEY (date, kind)
89
+ );
90
90
  `;
91
91
  //# sourceMappingURL=schema.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/dist/promptApi.js CHANGED
@@ -209,11 +209,11 @@ async function callClaude(prompt, healthContext, meta) {
209
209
  const message = await client.messages.create({
210
210
  model,
211
211
  max_tokens: length.maxTokens,
212
- system: `You are a concise fitness coach assistant shown on a small smartwatch screen.
213
- Answer in ${length.sentences}. ${TONE_SPEC[profile.ai.tone]} No markdown formatting.
214
- ${audienceContext(profile)}
215
- Give general training and wellness guidance only. Do not diagnose conditions or give
216
- medical advice; if asked something medical, say it is outside what you can advise on.
212
+ system: `You are a concise fitness coach assistant shown on a small smartwatch screen.
213
+ Answer in ${length.sentences}. ${TONE_SPEC[profile.ai.tone]} No markdown formatting.
214
+ ${audienceContext(profile)}
215
+ Give general training and wellness guidance only. Do not diagnose conditions or give
216
+ medical advice; if asked something medical, say it is outside what you can advise on.
217
217
  ${healthContext}`,
218
218
  messages: [{ role: "user", content: prompt }],
219
219
  });