diff --git a/app/lib/history-analytics.ts b/app/lib/history-analytics.ts index bfbbc5c..e6e55b7 100644 --- a/app/lib/history-analytics.ts +++ b/app/lib/history-analytics.ts @@ -100,11 +100,6 @@ function nonNegative(value: number) { return Number.isFinite(value) && value >= 0 ? value : 0; } -function maxNullable(values: Array) { - const available = values.filter((value): value is number => value !== null); - return available.length ? Math.max(...available) : null; -} - function trendValue(point: ExerciseTrendPoint, metric: ExerciseTrendMetric) { if (metric === "weight") return point.bestWeight; if (metric === "duration") return point.longestDurationSeconds; @@ -112,15 +107,73 @@ function trendValue(point: ExerciseTrendPoint, metric: ExerciseTrendMetric) { return point.completedExecutions; } -function selectTrendMetric(points: ExerciseTrendPoint[]): ExerciseTrendMetric { - if (points.some((point) => point.bestWeight !== null)) return "weight"; - if (points.some((point) => point.longestDurationSeconds !== null)) { - return "duration"; - } - if (points.some((point) => point.bestRepetitions !== null)) { - return "repetitions"; +function summarizeExercisePoints(points: ExerciseTrendPoint[]) { + let bestWeight: number | null = null; + let repetitionsAtBestWeight: number | null = null; + let bestRepetitions: number | null = null; + let longestDurationSeconds: number | null = null; + let completedExecutions = 0; + let totalWorkingVolume = 0; + let rpeCount = 0; + let rpeTotal = 0; + + for (const point of points) { + completedExecutions += point.completedExecutions; + totalWorkingVolume += point.workingVolume; + rpeCount += point.rpeCount; + rpeTotal += (point.averageRpe ?? 0) * point.rpeCount; + + if (point.bestWeight !== null) { + if (bestWeight === null || point.bestWeight > bestWeight) { + bestWeight = point.bestWeight; + repetitionsAtBestWeight = point.repetitionsAtBestWeight; + } else if ( + point.bestWeight === bestWeight && + point.repetitionsAtBestWeight !== null + ) { + repetitionsAtBestWeight = Math.max( + repetitionsAtBestWeight ?? 0, + point.repetitionsAtBestWeight, + ); + } + } + if ( + point.bestRepetitions !== null && + (bestRepetitions === null || point.bestRepetitions > bestRepetitions) + ) { + bestRepetitions = point.bestRepetitions; + } + if ( + point.longestDurationSeconds !== null && + (longestDurationSeconds === null || + point.longestDurationSeconds > longestDurationSeconds) + ) { + longestDurationSeconds = point.longestDurationSeconds; + } } - return "completions"; + + const trendMetric: ExerciseTrendMetric = + bestWeight !== null + ? "weight" + : longestDurationSeconds !== null + ? "duration" + : bestRepetitions !== null + ? "repetitions" + : "completions"; + return { + bestWeight, + repetitionsAtBestWeight, + bestRepetitions, + longestDurationSeconds, + completedExecutions, + totalWorkingVolume, + averageRpe: rpeCount ? rpeTotal / rpeCount : null, + rpeCount, + trendMetric, + metricPoints: points.filter( + (point) => trendValue(point, trendMetric) !== null, + ), + }; } function makeExercisePoint( @@ -303,56 +356,22 @@ export function deriveHistoryAnalytics( const exercises = Array.from(exerciseGroups.values()) .map((group): ExerciseAnalytics => { - const trendMetric = selectTrendMetric(group.points); - const metricPoints = group.points.filter( - (point) => trendValue(point, trendMetric) !== null, - ); - const bestWeight = maxNullable( - group.points.map((point) => point.bestWeight), - ); - const rpeCount = group.points.reduce( - (total, point) => total + point.rpeCount, - 0, - ); - const rpeTotal = group.points.reduce( - (total, point) => - total + (point.averageRpe ?? 0) * point.rpeCount, - 0, - ); + const summary = summarizeExercisePoints(group.points); return { id: group.id, name: group.name, recordedSessions: group.points.length, - completedExecutions: group.points.reduce( - (total, point) => total + point.completedExecutions, - 0, - ), - bestWeight, - repetitionsAtBestWeight: - bestWeight === null - ? null - : maxNullable( - group.points.map((point) => - point.bestWeight === bestWeight - ? point.repetitionsAtBestWeight - : null, - ), - ), - bestRepetitions: maxNullable( - group.points.map((point) => point.bestRepetitions), - ), - longestDurationSeconds: maxNullable( - group.points.map((point) => point.longestDurationSeconds), - ), - totalWorkingVolume: group.points.reduce( - (total, point) => total + point.workingVolume, - 0, - ), - averageRpe: rpeCount ? rpeTotal / rpeCount : null, - rpeCount, - trendMetric, - latest: metricPoints[0] ?? group.points[0], - trend: metricPoints.slice(0, 8).reverse(), + completedExecutions: summary.completedExecutions, + bestWeight: summary.bestWeight, + repetitionsAtBestWeight: summary.repetitionsAtBestWeight, + bestRepetitions: summary.bestRepetitions, + longestDurationSeconds: summary.longestDurationSeconds, + totalWorkingVolume: summary.totalWorkingVolume, + averageRpe: summary.averageRpe, + rpeCount: summary.rpeCount, + trendMetric: summary.trendMetric, + latest: summary.metricPoints[0] ?? group.points[0], + trend: summary.metricPoints.slice(0, 8).reverse(), }; }) .sort((left, right) => { diff --git a/migrations/0002_mcp_read_tokens.sql b/migrations/0002_mcp_read_tokens.sql new file mode 100644 index 0000000..d22f82d --- /dev/null +++ b/migrations/0002_mcp_read_tokens.sql @@ -0,0 +1,12 @@ +CREATE TABLE mcp_read_tokens ( + id TEXT PRIMARY KEY NOT NULL, + user_id TEXT NOT NULL REFERENCES user(id) ON DELETE CASCADE, + name TEXT NOT NULL, + token_hash TEXT NOT NULL UNIQUE, + token_hint TEXT NOT NULL, + created_at INTEGER NOT NULL, + revoked_at INTEGER +); + +CREATE INDEX mcp_read_tokens_user_idx + ON mcp_read_tokens(user_id, revoked_at, created_at DESC); diff --git a/tests/history-analytics-performance.test.mjs b/tests/history-analytics-performance.test.mjs new file mode 100644 index 0000000..6c77137 --- /dev/null +++ b/tests/history-analytics-performance.test.mjs @@ -0,0 +1,137 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { performance } from "node:perf_hooks"; +import test from "node:test"; +import { createServer } from "vite"; + +const SIZES = [50, 250, 500]; +const ITERATIONS = 25; +const EXPECTED_HASHES = new Map([ + [50, "4a1278846a049e8abaab37035ef5dcf1f817a22fea8512d5b5926b6a855dc9b8"], + [250, "bc7296bb5223078b7eb1810b1822ef1bc343b4a20524794c1f23e10f62726579"], + [500, "d23d83d5588606a97912239ad1b189e522a1eb70ad3feef96cdd7057889d8a38"], +]); + +let deriveHistoryAnalytics; +let vite; + +test.before(async () => { + vite = await createServer({ + appType: "custom", + configFile: false, + server: { middlewareMode: true }, + }); + ({ deriveHistoryAnalytics } = await vite.ssrLoadModule( + "/app/lib/history-analytics.ts", + )); +}); + +test.after(async () => { + await vite.close(); +}); + +test("history analytics scales across the supported session limit", () => { + const metrics = []; + + for (const size of SIZES) { + const history = buildHistory(size); + const expected = JSON.stringify(deriveHistoryAnalytics(history)); + const expectedHash = createHash("sha256").update(expected).digest("hex"); + assert.equal(expectedHash, EXPECTED_HASHES.get(size)); + let durationMs = 0; + + for (let iteration = 0; iteration < ITERATIONS; iteration += 1) { + const startedAt = performance.now(); + const result = deriveHistoryAnalytics(history); + durationMs += performance.now() - startedAt; + const serialized = JSON.stringify(result); + assert.equal(serialized, expected); + assert.equal( + createHash("sha256").update(serialized).digest("hex"), + expectedHash, + ); + } + + metrics.push(`size${size}=${(durationMs / ITERATIONS).toFixed(3)}ms/op`); + } + + console.log(`[benchmark] ${metrics.join(" ")} (${ITERATIONS} iterations)`); + console.log(`[resource] maximum_supported_sessions=${SIZES.at(-1)}`); +}); + +function buildHistory(size) { + return Array.from({ length: size }, (_, historyIndex) => { + const executions = Array.from({ length: 18 }, (_, executionIndex) => { + const exerciseIndex = executionIndex % 12; + const weight = 40 + exerciseIndex * 2.5 + (historyIndex % 8) * 1.25; + const reps = 5 + ((historyIndex + executionIndex) % 8); + return { + id: `execution-${historyIndex}-${executionIndex}`, + source: "planned", + clonedFromId: null, + plannedPosition: executionIndex + 1, + performedPosition: executionIndex + 1, + deferred: false, + status: executionIndex % 17 === 0 ? "skipped" : "completed", + step: { + id: `step-${executionIndex}`, + plannedStepId: `step-${executionIndex}`, + exercise: `Exercise ${exerciseIndex}`, + setType: executionIndex % 6 === 0 ? "Warm-up" : "Working", + setLabel: `Set ${executionIndex + 1}`, + tracking: "weight-reps", + targetWeight: weight, + targetReps: reps, + targetRepsMax: reps + 2, + targetDurationSeconds: null, + restSeconds: 90, + targetRpe: 8, + cue: "", + optional: false, + }, + segments: [ + { + id: `segment-${historyIndex}-${executionIndex}`, + weight, + reps, + durationSeconds: null, + }, + ], + actualRpe: 6 + ((historyIndex + executionIndex) % 4), + startedAt: historyIndex * 100_000 + executionIndex * 1_000, + completedAt: historyIndex * 100_000 + executionIndex * 1_000 + 500, + authoredRestSeconds: 90, + adjustedRestSeconds: 90, + actualRestSeconds: 80 + (executionIndex % 20), + }; + }); + + return { + id: `history-${historyIndex}`, + workoutId: historyIndex % 10 === 0 ? "custom:conditioning" : "upper", + workoutName: historyIndex % 10 === 0 ? "Conditioning" : "Upper", + weekNumber: (historyIndex % 12) + 1, + completedAt: 2_000_000_000_000 - historyIndex * 86_400_000, + durationSeconds: 2_400 + (historyIndex % 600), + completedSets: executions.filter((record) => record.status === "completed").length, + modifiedSets: historyIndex % 3, + extraSets: historyIndex % 2, + deferredSets: historyIndex % 4, + skippedSets: executions.filter((record) => record.status === "skipped").length, + workingVolume: executions.reduce( + (total, record) => + record.status === "completed" && record.step.setType === "Working" + ? total + record.segments[0].weight * record.segments[0].reps + : total, + 0, + ), + warmupVolume: 0, + completedDurationSeconds: 0, + totalActualRestSeconds: 1_400, + averageRpe: 7.5, + quality: 4, + detailsAvailable: true, + executions, + }; + }); +} diff --git a/tests/mcp-read.test.mjs b/tests/mcp-read.test.mjs new file mode 100644 index 0000000..d56d2a7 --- /dev/null +++ b/tests/mcp-read.test.mjs @@ -0,0 +1,122 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createServer } from "vite"; + +let mcp; +let vite; + +test.before(async () => { + vite = await createServer({ + appType: "custom", + configFile: false, + server: { middlewareMode: true }, + }); + mcp = await vite.ssrLoadModule("/worker/mcp.ts"); +}); + +test.after(async () => { + await vite.close(); +}); + +test("read tokens are scoped, hashed, and reject browser credentials", async () => { + const token = mcp.createReadToken(); + assert.match(token, /^setline_read_[A-Za-z0-9_-]{40,}$/); + assert.match(await mcp.hashReadToken(token), /^[a-f0-9]{64}$/); + assert.equal(mcp.readBearerToken("Bearer header.payload.signature"), null); + assert.equal(mcp.readBearerToken("Bearer calorie_read_wrong_scope"), null); + assert.equal(mcp.readBearerToken("session=setline_read_cookie"), null); +}); + +test("history filtering preserves newest-first records and exact bounds", () => { + const first = { + id: "first", + workoutId: "upper", + workoutName: "Upper", + completedAt: Date.parse("2026-08-02T12:00:00Z"), + executions: [{ step: { exercise: "Bench press" } }], + }; + const second = { + ...first, + id: "second", + workoutId: "lower", + workoutName: "Lower", + completedAt: Date.parse("2026-08-03T12:00:00Z"), + executions: [{ step: { exercise: "Romanian deadlift" } }], + }; + const url = new URL( + "https://setline.example/api/mcp/history?start=2026-08-01&end=2026-08-03&workout=lower&exercise=deadlift", + ); + assert.deepEqual( + mcp.filterHistory([first, second], url).map((entry) => entry.id), + ["second"], + ); +}); + +test("MCP reads reject mutations and missing PATs before loading state", async () => { + const env = { DB: { prepare: () => assert.fail("database should not be read") } }; + const mutation = await mcp.handleMcpRead( + new Request("https://setline.example/api/mcp/history", { method: "POST" }), + env, + ); + assert.equal(mutation.status, 405); + + const anonymous = await mcp.handleMcpRead( + new Request("https://setline.example/api/mcp/history"), + env, + ); + assert.equal(anonymous.status, 401); +}); + +test("active tokens bind every private read to the resolved owner", async () => { + const calls = []; + const env = { + DB: { + prepare(sql) { + return { + bind(...args) { + calls.push({ sql, args }); + return { + first: async () => + sql.includes("FROM mcp_read_tokens") ? { user_id: "owner-a" } : null, + }; + }, + }; + }, + }, + }; + const response = await mcp.handleMcpRead( + new Request("https://setline.example/api/mcp/history?limit=500&offset=0", { + headers: { Authorization: `Bearer ${mcp.createReadToken()}` }, + }), + env, + ); + + assert.equal(response.status, 200); + const body = await response.json(); + assert.deepEqual(body.items, []); + assert.equal(body.page.limit, 100); + assert.equal(body.page.nextOffset, null); + const stateRead = calls.find((call) => call.sql.includes("FROM workout_state")); + assert.deepEqual(stateRead?.args, ["owner-a"]); +}); + +test("revoked tokens fail before private state is read", async () => { + const calls = []; + const env = { + DB: { + prepare(sql) { + calls.push(sql); + return { bind: () => ({ first: async () => null }) }; + }, + }, + }; + const response = await mcp.handleMcpRead( + new Request("https://setline.example/api/mcp/history", { + headers: { Authorization: `Bearer ${mcp.createReadToken()}` }, + }), + env, + ); + + assert.equal(response.status, 401); + assert.equal(calls.some((sql) => sql.includes("FROM workout_state")), false); +}); diff --git a/tests/mcp-source.test.mjs b/tests/mcp-source.test.mjs new file mode 100644 index 0000000..a998b15 --- /dev/null +++ b/tests/mcp-source.test.mjs @@ -0,0 +1,30 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; + +const [source, migration] = await Promise.all([ + readFile(new URL("../worker/mcp.ts", import.meta.url), "utf8"), + readFile(new URL("../migrations/0002_mcp_read_tokens.sql", import.meta.url), "utf8"), +]); + +test("Setline stores read-token hashes and owner-scopes revocation", () => { + assert.match(migration, /token_hash TEXT NOT NULL UNIQUE/); + assert.doesNotMatch(migration, /\btoken\s+TEXT/i); + assert.match(source, /WHERE id = \? AND user_id = \? AND revoked_at IS NULL/); + assert.match(source, /WHERE token_hash = \? AND revoked_at IS NULL/); +}); + +test("Setline MCP exposes projections without execution or whole-state writes", () => { + const readHandler = source.slice(source.indexOf("export async function handleMcpRead")); + assert.match(readHandler, /request\.method !== "GET"/); + assert.doesNotMatch(readHandler, /INSERT INTO workout_state|UPDATE workout_state/); + assert.doesNotMatch(readHandler, /acceptRecommendation|startWorkout|completeSet|syncState/); + assert.match(readHandler, /historySummary/); + assert.match(readHandler, /provenance: "calculated-from-recorded-history"/); +}); + +test("Setline pages remain bounded and state parsing fails closed", () => { + assert.match(source, /const MAX_LIMIT = 100/); + assert.match(source, /parseStoredState/); + assert.match(source, /Treat corrupt or unsupported cloud state as unavailable/); +}); diff --git a/worker/index.ts b/worker/index.ts index 1b08dc6..d5187d0 100644 --- a/worker/index.ts +++ b/worker/index.ts @@ -2,6 +2,7 @@ import handler from "vinext/server/app-router-entry"; import { handleAgentEdge } from "./agent-edge.mjs"; import { createAuth, isGoogleConfigured, type SetlineBindings } from "./auth"; +import { handleMcpRead, handleMcpTokenManagement } from "./mcp"; import { handlePrivateState } from "./state"; const SECURITY_HEADERS = { @@ -68,6 +69,38 @@ const worker = { return withApiHeaders(response); } + if (url.pathname.startsWith("/api/app/mcp-tokens")) { + try { + return withApiHeaders(await handleMcpTokenManagement(request, env)); + } catch (error) { + console.error( + JSON.stringify({ + event: "setline_mcp_token_error", + method: request.method, + path: url.pathname, + message: error instanceof Error ? error.message : "Unknown error", + }), + ); + return json({ code: "TOKEN_UNAVAILABLE", message: "Read-token access is unavailable." }, 503); + } + } + + if (url.pathname.startsWith("/api/mcp/")) { + try { + return withApiHeaders(await handleMcpRead(request, env)); + } catch (error) { + console.error( + JSON.stringify({ + event: "setline_mcp_read_error", + method: request.method, + path: url.pathname, + message: error instanceof Error ? error.message : "Unknown error", + }), + ); + return json({ code: "READ_UNAVAILABLE", message: "Workout reads are unavailable." }, 503); + } + } + if (url.pathname === "/api/app/state") { try { return withApiHeaders(await handlePrivateState(request, env)); diff --git a/worker/mcp.ts b/worker/mcp.ts new file mode 100644 index 0000000..0791580 --- /dev/null +++ b/worker/mcp.ts @@ -0,0 +1,381 @@ +import { deriveHistoryAnalytics } from "../app/lib/history-analytics"; +import { + PROGRAMME, + PROGRAMME_SCHEDULE, + resolveWorkout, + type BuiltInWorkoutId, +} from "../app/lib/programme"; +import { + parseStoredState, + type HistoryEntry, + type StoredState, +} from "../app/lib/workout-state"; +import { createAuth, type SetlineBindings } from "./auth"; + +const TOKEN_PREFIX = "setline_read_"; +const MAX_LIMIT = 100; + +type StateRow = { payload: string }; +type TokenRow = { + id: string; + name: string; + token_hint: string; + created_at: number; +}; + +function json(payload: unknown, status = 200) { + return Response.json(payload, { status }); +} + +function toHex(bytes: Uint8Array) { + return [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join(""); +} + +export function readBearerToken(header: string | null): string | null { + if (!header?.startsWith("Bearer ")) return null; + const token = header.slice("Bearer ".length).trim(); + return token.startsWith(TOKEN_PREFIX) && /^[A-Za-z0-9_-]+$/.test(token) + ? token + : null; +} + +export async function hashReadToken(token: string) { + const digest = await crypto.subtle.digest( + "SHA-256", + new TextEncoder().encode(token), + ); + return toHex(new Uint8Array(digest)); +} + +export function createReadToken() { + const bytes = crypto.getRandomValues(new Uint8Array(32)); + const encoded = btoa(String.fromCharCode(...bytes)) + .replaceAll("+", "-") + .replaceAll("/", "_") + .replaceAll("=", ""); + return `${TOKEN_PREFIX}${encoded}`; +} + +async function resolveSessionUserId(request: Request, env: SetlineBindings) { + const session = await createAuth(env, request.url).api.getSession({ + headers: request.headers, + }); + return session?.user?.id ?? null; +} + +async function resolveReadUserId(request: Request, env: SetlineBindings) { + const token = readBearerToken(request.headers.get("Authorization")); + if (!token) return null; + const row = await env.DB.prepare( + `SELECT user_id FROM mcp_read_tokens + WHERE token_hash = ? AND revoked_at IS NULL`, + ) + .bind(await hashReadToken(token)) + .first<{ user_id: string }>(); + return row?.user_id ?? null; +} + +function parseState(row: StateRow | null): StoredState { + if (row) { + try { + const state = parseStoredState(JSON.parse(row.payload) as unknown); + if (state) return state; + } catch { + // Treat corrupt or unsupported cloud state as unavailable, never as partial data. + } + } + return { + version: 6, + updatedAt: 0, + session: null, + history: [], + customWorkouts: [], + customProgramme: null, + }; +} + +async function readState(env: SetlineBindings, userId: string) { + const row = await env.DB.prepare( + "SELECT payload FROM workout_state WHERE user_id = ?", + ) + .bind(userId) + .first(); + return parseState(row); +} + +function boundedText(value: string | null, maximum = 160) { + const trimmed = value?.trim(); + return trimmed ? trimmed.slice(0, maximum) : null; +} + +function page(url: URL) { + const rawLimit = Number(url.searchParams.get("limit")); + const rawOffset = Number(url.searchParams.get("offset")); + return { + limit: + Number.isInteger(rawLimit) && rawLimit > 0 + ? Math.min(rawLimit, MAX_LIMIT) + : 30, + offset: + Number.isInteger(rawOffset) && rawOffset >= 0 + ? Math.min(rawOffset, 10_000) + : 0, + }; +} + +function pagination(total: number, limit: number, offset: number) { + return { + limit, + offset, + total, + nextOffset: offset + limit < total ? offset + limit : null, + }; +} + +function builtInTemplates() { + const seen = new Set(); + return PROGRAMME_SCHEDULE.flatMap((schedule) => { + if (seen.has(schedule.workoutId)) return []; + seen.add(schedule.workoutId); + const workout = resolveWorkout( + schedule.workoutId as BuiltInWorkoutId, + 1, + schedule.dayIndex, + ); + return [ + { + ...workout, + provenance: "authored" as const, + representativeWeek: 1, + }, + ]; + }); +} + +function bundledProgramme() { + return { + kind: "bundled" as const, + provenance: "authored" as const, + programme: PROGRAMME, + schedule: PROGRAMME_SCHEDULE, + note: "Week-specific targets remain authored; template detail is represented at week 1.", + }; +} + +function customProgramme(state: StoredState) { + return { + kind: "custom" as const, + provenance: "authored" as const, + programme: state.customProgramme, + templates: state.customWorkouts, + }; +} + +function historySummary(entry: HistoryEntry) { + return { + id: entry.id, + workoutId: entry.workoutId, + workoutName: entry.workoutName, + weekNumber: entry.weekNumber, + completedAt: entry.completedAt, + durationSeconds: entry.durationSeconds, + completedSets: entry.completedSets, + modifiedSets: entry.modifiedSets, + extraSets: entry.extraSets, + deferredSets: entry.deferredSets, + skippedSets: entry.skippedSets, + workingVolume: entry.workingVolume, + warmupVolume: entry.warmupVolume, + completedDurationSeconds: entry.completedDurationSeconds, + totalActualRestSeconds: entry.totalActualRestSeconds, + averageRpe: entry.averageRpe, + quality: entry.quality, + detailsAvailable: entry.detailsAvailable, + provenance: "recorded" as const, + }; +} + +function dateBoundary(value: string | null, end = false) { + if (!value || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return null; + const parsed = Date.parse(`${value}T00:00:00.000Z`); + return Number.isFinite(parsed) ? parsed + (end ? 86_400_000 : 0) : null; +} + +export function filterHistory(history: HistoryEntry[], url: URL) { + const start = dateBoundary(url.searchParams.get("start")); + const end = dateBoundary(url.searchParams.get("end"), true); + const workout = boundedText(url.searchParams.get("workout"))?.toLocaleLowerCase(); + const exercise = boundedText(url.searchParams.get("exercise"))?.toLocaleLowerCase(); + return [...history] + .sort((left, right) => right.completedAt - left.completedAt) + .filter((entry) => { + if (start !== null && entry.completedAt < start) return false; + if (end !== null && entry.completedAt >= end) return false; + if ( + workout && + !`${entry.workoutId} ${entry.workoutName}`.toLocaleLowerCase().includes(workout) + ) { + return false; + } + if ( + exercise && + !entry.executions.some((record) => + record.step.exercise.toLocaleLowerCase().includes(exercise), + ) + ) { + return false; + } + return true; + }); +} + +export async function handleMcpTokenManagement( + request: Request, + env: SetlineBindings, +) { + const userId = await resolveSessionUserId(request, env); + if (!userId) return json({ code: "UNAUTHORIZED", message: "Sign in to continue." }, 401); + const url = new URL(request.url); + if (url.pathname === "/api/app/mcp-tokens" && request.method === "GET") { + const result = await env.DB.prepare( + `SELECT id, name, token_hint, created_at FROM mcp_read_tokens + WHERE user_id = ? AND revoked_at IS NULL ORDER BY created_at DESC LIMIT 20`, + ) + .bind(userId) + .all(); + return json( + result.results.map((row) => ({ + id: row.id, + name: row.name, + tokenHint: row.token_hint, + createdAt: row.created_at, + })), + ); + } + if (url.pathname === "/api/app/mcp-tokens" && request.method === "POST") { + const body = await request + .json>() + .catch((): Record => ({})); + const requestedName = typeof body.name === "string" ? body.name.trim() : ""; + const name = requestedName.slice(0, 50) || "ChatGPT read access"; + const token = createReadToken(); + const id = crypto.randomUUID(); + const createdAt = Date.now(); + await env.DB.prepare( + `INSERT INTO mcp_read_tokens + (id, user_id, name, token_hash, token_hint, created_at, revoked_at) + VALUES (?, ?, ?, ?, ?, ?, NULL)`, + ) + .bind(id, userId, name, await hashReadToken(token), token.slice(0, 24), createdAt) + .run(); + return json({ id, name, token, tokenHint: token.slice(0, 24), createdAt }, 201); + } + const match = url.pathname.match(/^\/api\/app\/mcp-tokens\/([^/]+)$/); + if (match && request.method === "DELETE") { + const result = await env.DB.prepare( + `UPDATE mcp_read_tokens SET revoked_at = ? + WHERE id = ? AND user_id = ? AND revoked_at IS NULL`, + ) + .bind(Date.now(), decodeURIComponent(match[1]), userId) + .run(); + return result.meta.changes + ? new Response(null, { status: 204 }) + : json({ code: "NOT_FOUND", message: "Read token not found." }, 404); + } + return new Response("Method Not Allowed", { + status: 405, + headers: { Allow: "GET, POST, DELETE" }, + }); +} + +export async function handleMcpRead(request: Request, env: SetlineBindings) { + if (request.method !== "GET") { + return new Response("Method Not Allowed", { + status: 405, + headers: { Allow: "GET" }, + }); + } + const userId = await resolveReadUserId(request, env); + if (!userId) { + return json({ code: "UNAUTHORIZED", message: "Provide a valid Setline read token." }, 401); + } + const url = new URL(request.url); + const state = await readState(env, userId); + + if (url.pathname === "/api/mcp/programme") { + const requested = url.searchParams.get("kind") ?? "current"; + const useCustom = + requested === "custom" || + (requested === "current" && state.customProgramme?.enabled === true); + return json({ + schemaVersion: "1", + data: useCustom ? customProgramme(state) : bundledProgramme(), + }); + } + + if (url.pathname === "/api/mcp/templates") { + const { limit, offset } = page(url); + const items = [ + ...builtInTemplates(), + ...state.customWorkouts.map((template) => ({ + ...template, + provenance: "authored" as const, + })), + ]; + return json({ + schemaVersion: "1", + items: items.slice(offset, offset + limit), + page: pagination(items.length, limit, offset), + }); + } + + if (url.pathname === "/api/mcp/history") { + const { limit, offset } = page(url); + const filtered = filterHistory(state.history, url); + return json({ + schemaVersion: "1", + items: filtered.slice(offset, offset + limit).map(historySummary), + page: pagination(filtered.length, limit, offset), + }); + } + + const sessionMatch = url.pathname.match(/^\/api\/mcp\/history\/([^/]+)$/); + if (sessionMatch) { + const id = decodeURIComponent(sessionMatch[1]); + if (!/^[A-Za-z0-9:_-]{1,120}$/.test(id)) { + return json({ code: "NOT_FOUND", message: "Workout session not found." }, 404); + } + const entry = state.history.find((candidate) => candidate.id === id); + return entry + ? json({ schemaVersion: "1", data: { ...entry, provenance: "recorded" } }) + : json({ code: "NOT_FOUND", message: "Workout session not found." }, 404); + } + + if (url.pathname === "/api/mcp/progress") { + const exercise = boundedText(url.searchParams.get("exercise"))?.toLocaleLowerCase(); + const workout = boundedText(url.searchParams.get("workout"))?.toLocaleLowerCase(); + const analytics = deriveHistoryAnalytics(state.history); + return json({ + schemaVersion: "1", + provenance: "calculated-from-recorded-history", + data: { + overview: analytics.overview, + exercises: exercise + ? analytics.exercises.filter((item) => + `${item.id} ${item.name}`.toLocaleLowerCase().includes(exercise), + ) + : analytics.exercises, + workouts: workout + ? analytics.workouts.filter((item) => + `${item.workoutId} ${item.workoutName}` + .toLocaleLowerCase() + .includes(workout), + ) + : analytics.workouts, + programmeWeeks: analytics.programmeWeeks, + }, + }); + } + + return json({ code: "NOT_FOUND", message: "Read route not found." }, 404); +}