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.
- package/.env.example +11 -11
- package/CHANGELOG.md +757 -675
- package/QUICKSTART.md +185 -182
- package/README.md +329 -322
- package/dist/appDb.d.ts +40 -1
- package/dist/appDb.js +120 -51
- package/dist/cli.js +47 -0
- package/dist/dashboard.js +820 -820
- package/dist/dashboardCharts.js +42 -42
- package/dist/deviceTokens.d.ts +29 -0
- package/dist/deviceTokens.js +49 -0
- package/dist/history/context.js +35 -35
- package/dist/history/schema.js +66 -66
- package/dist/httpServer.js +64 -1
- package/dist/pairApi.js +11 -2
- package/dist/promptApi.js +5 -5
- package/dist/usage.js +17 -17
- package/package.json +82 -77
- package/dist/appDb.d.ts.map +0 -1
- package/dist/appDb.js.map +0 -1
- package/dist/check.d.ts.map +0 -1
- package/dist/check.js.map +0 -1
- package/dist/cli.d.ts.map +0 -1
- package/dist/cli.js.map +0 -1
- package/dist/config.d.ts.map +0 -1
- package/dist/config.js.map +0 -1
- package/dist/dashboard.d.ts.map +0 -1
- package/dist/dashboard.js.map +0 -1
- package/dist/dashboardCharts.d.ts.map +0 -1
- package/dist/dashboardCharts.js.map +0 -1
- package/dist/dashboardData.d.ts.map +0 -1
- package/dist/dashboardData.js.map +0 -1
- package/dist/detect/baseline.d.ts.map +0 -1
- package/dist/detect/baseline.js.map +0 -1
- package/dist/detect/countdown.d.ts.map +0 -1
- package/dist/detect/countdown.js.map +0 -1
- package/dist/detect/detectors.d.ts.map +0 -1
- package/dist/detect/detectors.js.map +0 -1
- package/dist/detect/findings.d.ts.map +0 -1
- package/dist/detect/findings.js.map +0 -1
- package/dist/detect/forecast.d.ts.map +0 -1
- package/dist/detect/forecast.js.map +0 -1
- package/dist/detect/index.d.ts.map +0 -1
- package/dist/detect/index.js.map +0 -1
- package/dist/detect/sleepQuality.d.ts.map +0 -1
- package/dist/detect/sleepQuality.js.map +0 -1
- package/dist/detect/trimp.d.ts.map +0 -1
- package/dist/detect/trimp.js.map +0 -1
- package/dist/detect/week.d.ts.map +0 -1
- package/dist/detect/week.js.map +0 -1
- package/dist/garmin/auth.d.ts.map +0 -1
- package/dist/garmin/auth.js.map +0 -1
- package/dist/garmin/cache.d.ts.map +0 -1
- package/dist/garmin/cache.js.map +0 -1
- package/dist/garmin/client.d.ts.map +0 -1
- package/dist/garmin/client.js.map +0 -1
- package/dist/garmin/daily.d.ts.map +0 -1
- package/dist/garmin/daily.js.map +0 -1
- package/dist/garmin/garminApiTypes.d.ts.map +0 -1
- package/dist/garmin/garminApiTypes.js.map +0 -1
- package/dist/garmin/garminConnect.d.ts.map +0 -1
- package/dist/garmin/garminConnect.js.map +0 -1
- package/dist/garmin/partial.d.ts.map +0 -1
- package/dist/garmin/partial.js.map +0 -1
- package/dist/garmin/rawApi.d.ts.map +0 -1
- package/dist/garmin/rawApi.js.map +0 -1
- package/dist/garmin/types.d.ts.map +0 -1
- package/dist/garmin/types.js.map +0 -1
- package/dist/history/capture.d.ts.map +0 -1
- package/dist/history/capture.js.map +0 -1
- package/dist/history/context.d.ts.map +0 -1
- package/dist/history/context.js.map +0 -1
- package/dist/history/fallback.d.ts.map +0 -1
- package/dist/history/fallback.js.map +0 -1
- package/dist/history/ingest.d.ts.map +0 -1
- package/dist/history/ingest.js.map +0 -1
- package/dist/history/scheduler.d.ts.map +0 -1
- package/dist/history/scheduler.js.map +0 -1
- package/dist/history/schema.d.ts.map +0 -1
- package/dist/history/schema.js.map +0 -1
- package/dist/history/store.d.ts.map +0 -1
- package/dist/history/store.js.map +0 -1
- package/dist/httpServer.d.ts.map +0 -1
- package/dist/httpServer.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/mcpConfig.d.ts.map +0 -1
- package/dist/mcpConfig.js.map +0 -1
- package/dist/pairApi.d.ts.map +0 -1
- package/dist/pairApi.js.map +0 -1
- package/dist/paths.d.ts.map +0 -1
- package/dist/paths.js.map +0 -1
- package/dist/profile.d.ts.map +0 -1
- package/dist/profile.js.map +0 -1
- package/dist/promptApi.d.ts.map +0 -1
- package/dist/promptApi.js.map +0 -1
- package/dist/promptSuggestions.d.ts.map +0 -1
- package/dist/promptSuggestions.js.map +0 -1
- package/dist/selfTest.d.ts.map +0 -1
- package/dist/selfTest.js.map +0 -1
- package/dist/server.d.ts.map +0 -1
- package/dist/server.js.map +0 -1
- package/dist/setup.d.ts.map +0 -1
- package/dist/setup.js.map +0 -1
- package/dist/toolErrors.d.ts.map +0 -1
- package/dist/toolErrors.js.map +0 -1
- package/dist/tools/activities.d.ts.map +0 -1
- package/dist/tools/activities.js.map +0 -1
- package/dist/tools/bodyComposition.d.ts.map +0 -1
- package/dist/tools/bodyComposition.js.map +0 -1
- package/dist/tools/context.d.ts.map +0 -1
- package/dist/tools/context.js.map +0 -1
- package/dist/tools/findings.d.ts.map +0 -1
- package/dist/tools/findings.js.map +0 -1
- package/dist/tools/heartRate.d.ts.map +0 -1
- package/dist/tools/heartRate.js.map +0 -1
- package/dist/tools/index.d.ts.map +0 -1
- package/dist/tools/index.js.map +0 -1
- package/dist/tools/payloads.d.ts.map +0 -1
- package/dist/tools/payloads.js.map +0 -1
- package/dist/tools/recovery.d.ts.map +0 -1
- package/dist/tools/recovery.js.map +0 -1
- package/dist/tools/sleep.d.ts.map +0 -1
- package/dist/tools/sleep.js.map +0 -1
- package/dist/tools/stress.d.ts.map +0 -1
- package/dist/tools/stress.js.map +0 -1
- package/dist/tools/trainingInsights.d.ts.map +0 -1
- package/dist/tools/trainingInsights.js.map +0 -1
- package/dist/tools/types.d.ts.map +0 -1
- package/dist/tools/types.js.map +0 -1
- package/dist/tools/vo2Max.d.ts.map +0 -1
- package/dist/tools/vo2Max.js.map +0 -1
- package/dist/tools/week.d.ts.map +0 -1
- package/dist/tools/week.js.map +0 -1
- package/dist/usage.d.ts.map +0 -1
- package/dist/usage.js.map +0 -1
- package/dist/utils/batch.d.ts.map +0 -1
- package/dist/utils/batch.js.map +0 -1
- package/dist/utils/helpers.d.ts.map +0 -1
- package/dist/utils/helpers.js.map +0 -1
- package/dist/utils/jsonFile.d.ts.map +0 -1
- package/dist/utils/jsonFile.js.map +0 -1
- package/dist/utils/logger.d.ts.map +0 -1
- package/dist/utils/logger.js.map +0 -1
- package/dist/utils/secretFile.d.ts.map +0 -1
- package/dist/utils/secretFile.js.map +0 -1
- package/dist/utils/stdio.d.ts.map +0 -1
- package/dist/utils/stdio.js.map +0 -1
- package/dist/version.d.ts.map +0 -1
- package/dist/version.js.map +0 -1
- package/dist/watchApi.d.ts.map +0 -1
- package/dist/watchApi.js.map +0 -1
package/dist/dashboardCharts.js
CHANGED
|
@@ -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
|
package/dist/history/context.js
CHANGED
|
@@ -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
|
}
|
package/dist/history/schema.js
CHANGED
|
@@ -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
|
package/dist/httpServer.js
CHANGED
|
@@ -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:
|
|
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
|
});
|