hearth-dash 1.1.0 → 1.1.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/worker.js CHANGED
@@ -6,108 +6,149 @@
6
6
  * Docs: https://github.com/martusha89/hearth-dash
7
7
  */
8
8
 
9
- const encoder = new TextEncoder();
10
- const MCP_PROTOCOL_VERSIONS = ['2025-06-18', '2025-03-26'];
11
- const MAX_JSON_BODY_BYTES = 64 * 1024;
12
-
13
- function getConfig(env) {
14
- return {
15
- PASSWORD: env.DASHBOARD_PASSWORD || null,
16
- SESSION_SECRET: env.SESSION_SECRET || null,
17
- MCP_ALLOWED_ORIGINS: (env.MCP_ALLOWED_ORIGINS || 'https://claude.ai')
18
- .split(',').map(value => value.trim()).filter(Boolean),
19
- PARTNER_1: env.PARTNER_1 || 'Partner 1',
20
- PARTNER_2: env.PARTNER_2 || 'Partner 2',
21
- };
22
- }
23
-
24
- function base64Url(bytes) {
25
- let binary = '';
26
- for (const byte of bytes) binary += String.fromCharCode(byte);
27
- return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
28
- }
29
-
30
- function fromBase64Url(value) {
31
- const padded = value.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat((4 - value.length % 4) % 4);
32
- const binary = atob(padded);
33
- return Uint8Array.from(binary, char => char.charCodeAt(0));
34
- }
35
-
36
- async function hmac(secret, value) {
37
- const key = await crypto.subtle.importKey('raw', encoder.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
38
- return new Uint8Array(await crypto.subtle.sign('HMAC', key, encoder.encode(value)));
39
- }
40
-
41
- async function safeEqual(left, right) {
42
- const leftDigest = new Uint8Array(await crypto.subtle.digest('SHA-256', encoder.encode(String(left || ''))));
43
- const rightDigest = new Uint8Array(await crypto.subtle.digest('SHA-256', encoder.encode(String(right || ''))));
44
- let mismatch = 0;
45
- for (let i = 0; i < leftDigest.length; i++) mismatch |= leftDigest[i] ^ rightDigest[i];
46
- return mismatch === 0;
47
- }
48
-
49
- async function createSession(secret) {
50
- const payload = base64Url(encoder.encode(JSON.stringify({
51
- exp: Math.floor(Date.now() / 1000) + 7 * 24 * 60 * 60,
52
- nonce: crypto.randomUUID(),
53
- })));
54
- return payload + '.' + base64Url(await hmac(secret, payload));
55
- }
9
+ const encoder = new TextEncoder();
10
+ const MCP_PROTOCOL_VERSIONS = ['2025-06-18', '2025-03-26'];
11
+ const MAX_JSON_BODY_BYTES = 64 * 1024;
12
+
13
+ function getConfig(env) {
14
+ return {
15
+ PASSWORD: env.DASHBOARD_PASSWORD || null,
16
+ SESSION_SECRET: env.SESSION_SECRET || null,
17
+ MCP_ALLOWED_ORIGINS: (env.MCP_ALLOWED_ORIGINS || 'https://claude.ai')
18
+ .split(',').map(value => value.trim()).filter(Boolean),
19
+ PARTNER_1: env.PARTNER_1 || 'Partner 1',
20
+ PARTNER_2: env.PARTNER_2 || 'Partner 2',
21
+ };
22
+ }
23
+
24
+ function base64Url(bytes) {
25
+ let binary = '';
26
+ for (const byte of bytes) binary += String.fromCharCode(byte);
27
+ return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
28
+ }
29
+
30
+ function fromBase64Url(value) {
31
+ const padded = value.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat((4 - value.length % 4) % 4);
32
+ const binary = atob(padded);
33
+ return Uint8Array.from(binary, char => char.charCodeAt(0));
34
+ }
35
+
36
+ async function hmac(secret, value) {
37
+ const key = await crypto.subtle.importKey('raw', encoder.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
38
+ return new Uint8Array(await crypto.subtle.sign('HMAC', key, encoder.encode(value)));
39
+ }
40
+
41
+ async function safeEqual(left, right) {
42
+ const leftDigest = new Uint8Array(await crypto.subtle.digest('SHA-256', encoder.encode(String(left || ''))));
43
+ const rightDigest = new Uint8Array(await crypto.subtle.digest('SHA-256', encoder.encode(String(right || ''))));
44
+ let mismatch = 0;
45
+ for (let i = 0; i < leftDigest.length; i++) mismatch |= leftDigest[i] ^ rightDigest[i];
46
+ return mismatch === 0;
47
+ }
48
+
49
+ async function createSession(secret) {
50
+ const payload = base64Url(encoder.encode(JSON.stringify({
51
+ exp: Math.floor(Date.now() / 1000) + 7 * 24 * 60 * 60,
52
+ nonce: crypto.randomUUID(),
53
+ })));
54
+ return payload + '.' + base64Url(await hmac(secret, payload));
55
+ }
56
+
57
+ async function verifySession(token, secret) {
58
+ if (!token || !secret) return false;
59
+ const parts = token.split('.');
60
+ if (parts.length !== 2) return false;
61
+ try {
62
+ const expected = base64Url(await hmac(secret, parts[0]));
63
+ if (!(await safeEqual(parts[1], expected))) return false;
64
+ const payload = JSON.parse(new TextDecoder().decode(fromBase64Url(parts[0])));
65
+ return Number.isFinite(payload.exp) && payload.exp > Math.floor(Date.now() / 1000);
66
+ } catch {
67
+ return false;
68
+ }
69
+ }
70
+
71
+ function securityHeaders(extra = {}) {
72
+ return {
73
+ 'Content-Security-Policy': "default-src 'self'; img-src 'self' data: https://openweathermap.org; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'",
74
+ 'Referrer-Policy': 'no-referrer',
75
+ 'Strict-Transport-Security': 'max-age=31536000; includeSubDomains',
76
+ 'X-Content-Type-Options': 'nosniff',
77
+ 'X-Frame-Options': 'DENY',
78
+ 'Permissions-Policy': 'camera=(), microphone=(), geolocation=()',
79
+ ...extra,
80
+ };
81
+ }
82
+
83
+ function sessionCookie(value, maxAge = 604800) {
84
+ return `__Host-hearth_session=${value}; Path=/; HttpOnly; Secure; SameSite=Strict; Max-Age=${maxAge}`;
85
+ }
86
+
87
+ export function sameOrigin(request) {
88
+ const targetOrigin = new URL(request.url).origin;
89
+ const fetchSite = request.headers.get('Sec-Fetch-Site');
90
+ const suppliedOrigin = classifyOrigin(request.headers.get('Origin'));
56
91
 
57
- async function verifySession(token, secret) {
58
- if (!token || !secret) return false;
59
- const parts = token.split('.');
60
- if (parts.length !== 2) return false;
61
- try {
62
- const expected = base64Url(await hmac(secret, parts[0]));
63
- if (!(await safeEqual(parts[1], expected))) return false;
64
- const payload = JSON.parse(new TextDecoder().decode(fromBase64Url(parts[0])));
65
- return Number.isFinite(payload.exp) && payload.exp > Math.floor(Date.now() / 1000);
66
- } catch {
67
- return false;
92
+ // Fetch Metadata is browser-controlled and cannot be forged by hostile page
93
+ // JavaScript. Chrome can legitimately serialize Origin as "null" here, so a
94
+ // same-origin classification is the decisive signal for null/missing Origin.
95
+ if (fetchSite === 'same-origin') {
96
+ if (suppliedOrigin.kind === 'missing' || suppliedOrigin.kind === 'null') return true;
97
+ return suppliedOrigin.kind === 'origin' && suppliedOrigin.value === targetOrigin;
68
98
  }
69
- }
70
99
 
71
- function securityHeaders(extra = {}) {
72
- return {
73
- 'Content-Security-Policy': "default-src 'self'; img-src 'self' data: https://openweathermap.org; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'",
74
- 'Referrer-Policy': 'no-referrer',
75
- 'Strict-Transport-Security': 'max-age=31536000; includeSubDomains',
76
- 'X-Content-Type-Options': 'nosniff',
77
- 'X-Frame-Options': 'DENY',
78
- 'Permissions-Policy': 'camera=(), microphone=(), geolocation=()',
79
- ...extra,
80
- };
81
- }
82
-
83
- function sessionCookie(value, maxAge = 604800) {
84
- return `__Host-hearth_session=${value}; Path=/; HttpOnly; Secure; SameSite=Strict; Max-Age=${maxAge}`;
85
- }
100
+ // Reject explicit cross-origin classifications even if another header is
101
+ // malformed or contradictory. "same-site" can still be another subdomain.
102
+ if (fetchSite === 'cross-site' || fetchSite === 'same-site' || fetchSite === 'none') return false;
86
103
 
87
- function sameOrigin(request) {
88
- const origin = request.headers.get('Origin');
89
- return !origin || origin === new URL(request.url).origin;
104
+ // Older/non-browser clients without Fetch Metadata must provide one exact,
105
+ // valid HTTP(S) Origin. Unknown future Fetch Metadata values use this same
106
+ // conservative fallback.
107
+ return suppliedOrigin.kind === 'origin' && suppliedOrigin.value === targetOrigin;
90
108
  }
91
109
 
92
- export async function withinRateLimit(env, request, scope, limit, windowSeconds) {
93
- const address = request.headers.get('CF-Connecting-IP') || 'unknown';
94
- const digest = new Uint8Array(await crypto.subtle.digest('SHA-256', encoder.encode(address)));
95
- const windowStart = Math.floor(Date.now() / 1000 / windowSeconds) * windowSeconds;
96
- const key = `${scope}:${windowStart}:${base64Url(digest).slice(0, 24)}`;
110
+ function classifyOrigin(value) {
111
+ if (value === null) return { kind: 'missing' };
112
+ if (value === 'null') return { kind: 'null' };
97
113
  try {
98
- await env.DB.prepare(
99
- 'INSERT INTO rate_limits (bucket_key, count, expires_at) VALUES (?, 1, ?) ON CONFLICT(bucket_key) DO UPDATE SET count = count + 1'
100
- ).bind(key, windowStart + windowSeconds * 2).run();
101
- const row = await env.DB.prepare('SELECT count FROM rate_limits WHERE bucket_key = ?').bind(key).first();
102
- if (Math.random() < 0.01) env.DB.prepare("DELETE FROM rate_limits WHERE expires_at < unixepoch('now')").run().catch(() => {});
103
- return !row || row.count <= limit;
114
+ const parsed = new URL(value);
115
+ if (!['http:', 'https:'].includes(parsed.protocol) || parsed.origin !== value) return { kind: 'invalid' };
116
+ return { kind: 'origin', value: parsed.origin };
104
117
  } catch {
105
- // Authentication and authorization endpoints must not silently lose their
106
- // abuse controls when D1 or the additive migration is unavailable.
107
- return false;
118
+ return { kind: 'invalid' };
108
119
  }
109
120
  }
110
121
 
122
+ export function isOAuthRoute(pathname) {
123
+ return pathname === '/mcp'
124
+ || pathname.startsWith('/mcp/')
125
+ || pathname === '/authorize'
126
+ || pathname === '/oauth/token'
127
+ || pathname === '/oauth/register'
128
+ || pathname === '/.well-known/oauth-authorization-server'
129
+ || pathname === '/.well-known/oauth-protected-resource'
130
+ || pathname.startsWith('/.well-known/oauth-protected-resource/');
131
+ }
132
+
133
+ export async function withinRateLimit(env, request, scope, limit, windowSeconds) {
134
+ const address = request.headers.get('CF-Connecting-IP') || 'unknown';
135
+ const digest = new Uint8Array(await crypto.subtle.digest('SHA-256', encoder.encode(address)));
136
+ const windowStart = Math.floor(Date.now() / 1000 / windowSeconds) * windowSeconds;
137
+ const key = `${scope}:${windowStart}:${base64Url(digest).slice(0, 24)}`;
138
+ try {
139
+ await env.DB.prepare(
140
+ 'INSERT INTO rate_limits (bucket_key, count, expires_at) VALUES (?, 1, ?) ON CONFLICT(bucket_key) DO UPDATE SET count = count + 1'
141
+ ).bind(key, windowStart + windowSeconds * 2).run();
142
+ const row = await env.DB.prepare('SELECT count FROM rate_limits WHERE bucket_key = ?').bind(key).first();
143
+ if (Math.random() < 0.01) env.DB.prepare("DELETE FROM rate_limits WHERE expires_at < unixepoch('now')").run().catch(() => {});
144
+ return !row || row.count <= limit;
145
+ } catch {
146
+ // Authentication and authorization endpoints must not silently lose their
147
+ // abuse controls when D1 or the additive migration is unavailable.
148
+ return false;
149
+ }
150
+ }
151
+
111
152
  function getSetupPage(error) {
112
153
  return `<!DOCTYPE html>
113
154
  <html lang="en">
@@ -179,19 +220,19 @@ function getLoginPage(config) {
179
220
  </html>`;
180
221
  }
181
222
 
182
- function escapeHtml(value) {
183
- return String(value).replace(/[&<>"']/g, char => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[char]);
184
- }
185
-
186
- function jsonForInlineScript(value) {
187
- return JSON.stringify(value).replace(/</g, '\\u003c').replace(/>/g, '\\u003e').replace(/&/g, '\\u0026');
188
- }
189
-
190
- function getDashboardHTML(config) {
191
- const P1 = config.PARTNER_1;
192
- const P2 = config.PARTNER_2;
193
- const P1HTML = escapeHtml(P1);
194
- const P2HTML = escapeHtml(P2);
223
+ function escapeHtml(value) {
224
+ return String(value).replace(/[&<>"']/g, char => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[char]);
225
+ }
226
+
227
+ function jsonForInlineScript(value) {
228
+ return JSON.stringify(value).replace(/</g, '\\u003c').replace(/>/g, '\\u003e').replace(/&/g, '\\u0026');
229
+ }
230
+
231
+ function getDashboardHTML(config) {
232
+ const P1 = config.PARTNER_1;
233
+ const P2 = config.PARTNER_2;
234
+ const P1HTML = escapeHtml(P1);
235
+ const P2HTML = escapeHtml(P2);
195
236
  return `<!DOCTYPE html>
196
237
  <html lang="en">
197
238
  <head>
@@ -346,7 +387,7 @@ function getDashboardHTML(config) {
346
387
  <!-- DASHBOARD TAB -->
347
388
  <div id="dashboard" class="tab-content active">
348
389
  <div class="grid">
349
- <div class="card"><h2>Current Moods</h2><div class="mood-display" id="mood-display"><div class="mood-person"><h3>${P1HTML}</h3><div class="mood-value" id="p1-mood">&mdash;</div><div class="mood-time" id="p1-mood-time"></div></div><div class="mood-person"><h3>${P2HTML}</h3><div class="mood-value" id="p2-mood">&mdash;</div><div class="mood-time" id="p2-mood-time"></div></div></div></div>
390
+ <div class="card"><h2>Current Moods</h2><div class="mood-display" id="mood-display"><div class="mood-person"><h3>${P1HTML}</h3><div class="mood-value" id="p1-mood">&mdash;</div><div class="mood-time" id="p1-mood-time"></div></div><div class="mood-person"><h3>${P2HTML}</h3><div class="mood-value" id="p2-mood">&mdash;</div><div class="mood-time" id="p2-mood-time"></div></div></div></div>
350
391
  <div class="card"><h2>Latest Note</h2><div id="latest-note"><p class="empty">No notes yet</p></div></div>
351
392
  <div class="card"><h2>Next Date</h2><div id="next-date"><p class="empty">No upcoming dates</p></div></div>
352
393
  <div class="card"><h2>Weather</h2><div id="weather-display"><p class="empty">Loading weather...</p></div></div>
@@ -358,7 +399,7 @@ function getDashboardHTML(config) {
358
399
  <!-- MOODS TAB -->
359
400
  <div id="moods" class="tab-content">
360
401
  <div class="grid">
361
- <div class="card"><h2>Log Mood</h2><form id="mood-form"><select name="partner" required style="margin-bottom:0.5rem"><option value="">Who are you?</option><option value="${P1HTML}">${P1HTML}</option><option value="${P2HTML}">${P2HTML}</option></select><select name="mood" required style="margin-bottom:0.5rem"><option value="">How are you feeling?</option><option value="great">&#128522; Great</option><option value="good">&#128578; Good</option><option value="okay">&#128528; Okay</option><option value="tired">&#128564; Tired</option><option value="stressed">&#128560; Stressed</option><option value="low">&#128532; Low</option></select><textarea name="note" placeholder="Any notes? (optional)" style="margin-bottom:0.5rem"></textarea><button type="submit" class="btn">Log Mood</button></form></div>
402
+ <div class="card"><h2>Log Mood</h2><form id="mood-form"><select name="partner" required style="margin-bottom:0.5rem"><option value="">Who are you?</option><option value="${P1HTML}">${P1HTML}</option><option value="${P2HTML}">${P2HTML}</option></select><select name="mood" required style="margin-bottom:0.5rem"><option value="">How are you feeling?</option><option value="great">&#128522; Great</option><option value="good">&#128578; Good</option><option value="okay">&#128528; Okay</option><option value="tired">&#128564; Tired</option><option value="stressed">&#128560; Stressed</option><option value="low">&#128532; Low</option></select><textarea name="note" placeholder="Any notes? (optional)" style="margin-bottom:0.5rem"></textarea><button type="submit" class="btn">Log Mood</button></form></div>
362
403
  <div class="card"><h2>Recent Moods</h2><div id="mood-history"><p class="empty">No mood entries yet</p></div></div>
363
404
  </div>
364
405
  </div>
@@ -366,7 +407,7 @@ function getDashboardHTML(config) {
366
407
  <!-- NOTES TAB -->
367
408
  <div id="notes" class="tab-content">
368
409
  <div class="grid">
369
- <div class="card"><h2>Leave a Note</h2><form id="note-form"><select name="from" required style="margin-bottom:0.5rem"><option value="">From</option><option value="${P1HTML}">${P1HTML}</option><option value="${P2HTML}">${P2HTML}</option></select><textarea name="content" placeholder="Your note..." required style="margin-bottom:0.5rem"></textarea><button type="submit" class="btn">Post Note</button></form></div>
410
+ <div class="card"><h2>Leave a Note</h2><form id="note-form"><select name="from" required style="margin-bottom:0.5rem"><option value="">From</option><option value="${P1HTML}">${P1HTML}</option><option value="${P2HTML}">${P2HTML}</option></select><textarea name="content" placeholder="Your note..." required style="margin-bottom:0.5rem"></textarea><button type="submit" class="btn">Post Note</button></form></div>
370
411
  <div class="card" style="grid-column:span 2"><h2>Fridge Notes</h2><div id="notes-display"><p class="empty">No notes on the fridge</p></div></div>
371
412
  </div>
372
413
  </div>
@@ -412,8 +453,8 @@ function getDashboardHTML(config) {
412
453
  <option value="Other">Other</option>
413
454
  </select>
414
455
  <select name="added_by" required style="margin-bottom:0.5rem">
415
- <option value="${P1HTML}">${P1HTML}</option>
416
- <option value="${P2HTML}">${P2HTML}</option>
456
+ <option value="${P1HTML}">${P1HTML}</option>
457
+ <option value="${P2HTML}">${P2HTML}</option>
417
458
  </select>
418
459
  <button type="submit" class="btn">Add</button>
419
460
  </form>
@@ -533,8 +574,8 @@ function getDashboardHTML(config) {
533
574
  </div>
534
575
 
535
576
  <script>
536
- const P1 = ${jsonForInlineScript(P1)};
537
- const P2 = ${jsonForInlineScript(P2)};
577
+ const P1 = ${jsonForInlineScript(P1)};
578
+ const P2 = ${jsonForInlineScript(P2)};
538
579
 
539
580
  /* === Tab Navigation === */
540
581
  document.querySelectorAll('nav a[data-tab]').forEach(link=>{link.addEventListener('click',e=>{e.preventDefault();const tab=e.target.dataset.tab;document.querySelectorAll('.tab-content').forEach(t=>t.classList.remove('active'));document.querySelectorAll('nav a[data-tab]').forEach(l=>l.classList.remove('active'));document.getElementById(tab).classList.add('active');e.target.classList.add('active');window.location.hash=tab;if(tab==='pressure'&&_pressureGraphData)setTimeout(()=>drawPressureChart(_pressureGraphData),50)})});
@@ -559,7 +600,7 @@ function getDashboardHTML(config) {
559
600
  }catch(err){console.error(err)}}
560
601
 
561
602
  /* === Tab Loaders === */
562
- async function loadMoods(){try{const data=await api('/moods');const c=document.getElementById('mood-history');if(data.moods&&data.moods.length>0){c.innerHTML=data.moods.map(m=>'<div class="moment-item"><strong>'+sanitize(m.partner)+'</strong>: '+moodEmoji(m.mood)+' '+sanitize(m.mood)+(m.note?'<br><small style="color:#71717a">'+sanitize(m.note)+'</small>':'')+'<div class="moment-date">'+timeAgo(m.created_at)+'</div></div>').join('')}}catch(err){console.error(err)}}
603
+ async function loadMoods(){try{const data=await api('/moods');const c=document.getElementById('mood-history');if(data.moods&&data.moods.length>0){c.innerHTML=data.moods.map(m=>'<div class="moment-item"><strong>'+sanitize(m.partner)+'</strong>: '+moodEmoji(m.mood)+' '+sanitize(m.mood)+(m.note?'<br><small style="color:#71717a">'+sanitize(m.note)+'</small>':'')+'<div class="moment-date">'+timeAgo(m.created_at)+'</div></div>').join('')}}catch(err){console.error(err)}}
563
604
  async function loadNotes(){try{const data=await api('/notes');const c=document.getElementById('notes-display');if(data.notes&&data.notes.length>0){c.innerHTML=data.notes.map(n=>'<div class="note-card">"'+sanitize(n.content)+'"<div class="note-meta">\\u2014 '+sanitize(n.from_partner)+', '+timeAgo(n.created_at)+'</div></div>').join('')}}catch(err){console.error(err)}}
564
605
  async function loadMoments(){try{const data=await api('/moments');const c=document.getElementById('moments-timeline');if(data.moments&&data.moments.length>0){c.innerHTML=data.moments.map(m=>'<div class="moment-item"><strong>'+sanitize(m.title)+'</strong>'+(m.description?'<br><small style="color:#71717a">'+sanitize(m.description)+'</small>':'')+'<div class="moment-date">'+new Date(m.date).toLocaleDateString('en-GB',{day:'numeric',month:'long',year:'numeric'})+'</div></div>').join('')}}catch(err){console.error(err)}}
565
606
 
@@ -608,47 +649,47 @@ function getDashboardHTML(config) {
608
649
 
609
650
  /* ========================= BACKEND ========================= */
610
651
 
611
- export const applicationHandler = {
652
+ export const applicationHandler = {
612
653
  async fetch(request, env, ctx) {
613
- const config = getConfig(env);
654
+ const config = getConfig(env);
614
655
  const url = new URL(request.url);
615
656
  const path = url.pathname;
616
657
 
617
658
  // MCP endpoint
618
- if (!config.PASSWORD || !config.SESSION_SECRET) {
619
- return new Response('Hearth Dash is not configured. Set DASHBOARD_PASSWORD and SESSION_SECRET as Cloudflare Worker secrets.', {
620
- status: 503,
621
- headers: securityHeaders({ 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-store' }),
622
- });
623
- }
624
-
625
- // Auth
626
- const sessionToken = getCookie(request, '__Host-hearth_session');
627
- const isAuthenticated = await verifySession(sessionToken, config.SESSION_SECRET);
628
-
629
- if (path === '/login') {
630
- if (request.method === 'POST') {
631
- if (!sameOrigin(request)) return new Response('Forbidden', { status: 403 });
632
- if (Number(request.headers.get('Content-Length') || 0) > 16 * 1024) return new Response('Request body is too large', { status: 413 });
633
- if (!(await withinRateLimit(env, request, 'login', 10, 600))) {
634
- return new Response('Too many login attempts. Try again later.', { status: 429, headers: securityHeaders({ 'Retry-After': '600' }) });
635
- }
636
- const formData = await request.formData();
637
- if (await safeEqual(formData.get('password'), config.PASSWORD)) {
638
- const session = await createSession(config.SESSION_SECRET);
639
- return new Response(null, { status: 302, headers: securityHeaders({ 'Location': '/', 'Set-Cookie': sessionCookie(session), 'Cache-Control': 'no-store' }) });
640
- }
641
- return new Response(getLoginPage(config).replace('{{ERROR}}', '<p class="error">Wrong password</p>'), { status: 401, headers: securityHeaders({ 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' }) });
642
- }
643
- if (!['GET', 'HEAD'].includes(request.method)) return new Response(null, { status: 405, headers: securityHeaders({ 'Allow': 'GET, POST' }) });
644
- return new Response(getLoginPage(config).replace('{{ERROR}}', ''), { headers: securityHeaders({ 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' }) });
645
- }
646
- if (path === '/logout') return new Response(null, { status: 302, headers: securityHeaders({ 'Location': '/login', 'Set-Cookie': sessionCookie('', 0), 'Cache-Control': 'no-store' }) });
647
- if (!isAuthenticated) return Response.redirect(url.origin + '/login', 302);
648
-
649
- if (!['GET', 'HEAD'].includes(request.method) && !sameOrigin(request)) {
650
- return new Response('Forbidden', { status: 403 });
651
- }
659
+ if (!config.PASSWORD || !config.SESSION_SECRET) {
660
+ return new Response('Hearth Dash is not configured. Set DASHBOARD_PASSWORD and SESSION_SECRET as Cloudflare Worker secrets.', {
661
+ status: 503,
662
+ headers: securityHeaders({ 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-store' }),
663
+ });
664
+ }
665
+
666
+ // Auth
667
+ const sessionToken = getCookie(request, '__Host-hearth_session');
668
+ const isAuthenticated = await verifySession(sessionToken, config.SESSION_SECRET);
669
+
670
+ if (path === '/login') {
671
+ if (request.method === 'POST') {
672
+ if (!sameOrigin(request)) return new Response('Forbidden', { status: 403 });
673
+ if (Number(request.headers.get('Content-Length') || 0) > 16 * 1024) return new Response('Request body is too large', { status: 413 });
674
+ if (!(await withinRateLimit(env, request, 'login', 10, 600))) {
675
+ return new Response('Too many login attempts. Try again later.', { status: 429, headers: securityHeaders({ 'Retry-After': '600' }) });
676
+ }
677
+ const formData = await request.formData();
678
+ if (await safeEqual(formData.get('password'), config.PASSWORD)) {
679
+ const session = await createSession(config.SESSION_SECRET);
680
+ return new Response(null, { status: 302, headers: securityHeaders({ 'Location': '/', 'Set-Cookie': sessionCookie(session), 'Cache-Control': 'no-store' }) });
681
+ }
682
+ return new Response(getLoginPage(config).replace('{{ERROR}}', '<p class="error">Wrong password</p>'), { status: 401, headers: securityHeaders({ 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' }) });
683
+ }
684
+ if (!['GET', 'HEAD'].includes(request.method)) return new Response(null, { status: 405, headers: securityHeaders({ 'Allow': 'GET, POST' }) });
685
+ return new Response(getLoginPage(config).replace('{{ERROR}}', ''), { headers: securityHeaders({ 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' }) });
686
+ }
687
+ if (path === '/logout') return new Response(null, { status: 302, headers: securityHeaders({ 'Location': '/login', 'Set-Cookie': sessionCookie('', 0), 'Cache-Control': 'no-store' }) });
688
+ if (!isAuthenticated) return Response.redirect(url.origin + '/login', 302);
689
+
690
+ if (!['GET', 'HEAD'].includes(request.method) && !sameOrigin(request)) {
691
+ return new Response('Forbidden', { status: 403 });
692
+ }
652
693
 
653
694
  // API routes
654
695
  if (path === '/api/weather') return handleWeather(env);
@@ -660,144 +701,144 @@ export const applicationHandler = {
660
701
  }
661
702
  if (path.startsWith('/api/')) return handleAPI(request, env, path.substring(4), config);
662
703
 
663
- return new Response(getDashboardHTML(config), { headers: securityHeaders({ 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' }) });
664
- }
665
- };
666
-
667
- const OAUTH_SCOPES = ['hearth:read', 'hearth:write'];
668
- const OAUTH_CSRF_COOKIE = '__Host-hearth_oauth_csrf';
669
-
670
- export const oauthApiHandler = {
671
- async fetch(request, env, ctx) {
672
- const config = getConfig(env);
673
- const remainder = new URL(request.url).pathname.substring('/mcp'.length);
674
- const bearer = request.headers.get('Authorization')?.match(/^Bearer\s+(.+)$/i)?.[1];
675
- const token = bearer ? await env.OAUTH_PROVIDER.unwrapToken(bearer) : null;
676
- const scopes = Array.isArray(token?.scope) ? token.scope : [];
677
- return handleMCP(request, env, config, remainder, scopes);
678
- },
679
- };
680
-
681
- export const oauthDefaultHandler = {
682
- async fetch(request, env, ctx) {
683
- const url = new URL(request.url);
684
- if (url.pathname !== '/authorize') return applicationHandler.fetch(request, env, ctx);
685
-
686
- const config = getConfig(env);
687
- if (!config.PASSWORD || !config.SESSION_SECRET) {
688
- return new Response('Hearth Dash is not configured.', { status: 503, headers: securityHeaders() });
689
- }
690
- if (!['GET', 'POST'].includes(request.method)) {
691
- return new Response(null, { status: 405, headers: securityHeaders({ Allow: 'GET, POST' }) });
692
- }
693
-
694
- let oauthRequest;
695
- try {
696
- oauthRequest = await env.OAUTH_PROVIDER.parseAuthRequest(request);
697
- } catch (error) {
698
- return renderOAuthAuthorizationError(error);
699
- }
700
- const client = await env.OAUTH_PROVIDER.lookupClient(oauthRequest.clientId);
701
- if (!client) return new Response('Unknown OAuth client', { status: 400, headers: securityHeaders() });
702
-
703
- const sessionToken = getCookie(request, '__Host-hearth_session');
704
- const signedIn = await verifySession(sessionToken, config.SESSION_SECRET);
705
-
706
- if (request.method === 'GET') {
707
- const csrf = base64Url(crypto.getRandomValues(new Uint8Array(32)));
708
- const headers = new Headers(securityHeaders({ 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' }));
709
- headers.append('Set-Cookie', `${OAUTH_CSRF_COOKIE}=${csrf}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=600`);
710
- return new Response(renderOAuthConsentPage(request.url, oauthRequest, client, csrf, signedIn), { headers });
711
- }
712
-
713
- if (!sameOrigin(request)) return new Response('Forbidden', { status: 403, headers: securityHeaders() });
714
- if (Number(request.headers.get('Content-Length') || 0) > 16 * 1024) return new Response('Request body is too large', { status: 413 });
715
- if (!(await withinRateLimit(env, request, 'oauth-consent', 10, 600))) {
716
- return new Response('Too many authorization attempts. Try again later.', { status: 429, headers: securityHeaders({ 'Retry-After': '600' }) });
717
- }
718
-
719
- const form = await request.formData();
720
- const csrfCookie = getCookie(request, OAUTH_CSRF_COOKIE);
721
- if (!csrfCookie || !(await safeEqual(csrfCookie, form.get('csrf_token')))) {
722
- return new Response('Authorization form expired. Start the connection again.', { status: 400, headers: securityHeaders() });
723
- }
724
- if (form.get('decision') === 'deny') return redirectOAuthDenial(oauthRequest);
725
-
726
- let authenticated = signedIn;
727
- let newSession = null;
728
- if (!authenticated) {
729
- authenticated = await safeEqual(form.get('password'), config.PASSWORD);
730
- if (authenticated) newSession = await createSession(config.SESSION_SECRET);
731
- }
732
- if (!authenticated) {
733
- const csrf = base64Url(crypto.getRandomValues(new Uint8Array(32)));
734
- const headers = new Headers(securityHeaders({ 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' }));
735
- headers.append('Set-Cookie', `${OAUTH_CSRF_COOKIE}=${csrf}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=600`);
736
- return new Response(renderOAuthConsentPage(request.url, oauthRequest, client, csrf, false, 'Wrong dashboard password'), { status: 401, headers });
737
- }
738
-
739
- const requestedScopes = oauthRequest.scope?.length ? oauthRequest.scope : ['hearth:read'];
740
- const grantedScopes = requestedScopes.filter(scope => OAUTH_SCOPES.includes(scope));
741
- const { redirectTo } = await env.OAUTH_PROVIDER.completeAuthorization({
742
- request: oauthRequest,
743
- userId: 'hearth-owner',
744
- metadata: { clientName: client.clientName || 'MCP client' },
745
- scope: grantedScopes,
746
- props: { userId: 'hearth-owner', scopes: grantedScopes },
747
- });
748
-
749
- const headers = new Headers(securityHeaders({ Location: redirectTo, 'Cache-Control': 'no-store' }));
750
- headers.append('Set-Cookie', `${OAUTH_CSRF_COOKIE}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0`);
751
- if (newSession) headers.append('Set-Cookie', sessionCookie(newSession));
752
- return new Response(null, { status: 302, headers });
753
- },
754
- };
755
-
756
- function renderOAuthAuthorizationError(error) {
757
- if (!error || typeof error.code !== 'string') return new Response('Invalid authorization request', { status: 400, headers: securityHeaders() });
758
- if (!error.redirectUri) return new Response(error.description || 'Invalid authorization request', { status: 400, headers: securityHeaders() });
759
- const redirect = new URL(error.redirectUri);
760
- redirect.searchParams.set('error', error.code);
761
- redirect.searchParams.set('error_description', error.description || 'Authorization failed');
762
- if (error.state) redirect.searchParams.set('state', error.state);
763
- if (error.issuer) redirect.searchParams.set('iss', error.issuer);
764
- return Response.redirect(redirect, 302);
765
- }
766
-
767
- function redirectOAuthDenial(oauthRequest) {
768
- const redirect = new URL(oauthRequest.redirectUri);
769
- redirect.searchParams.set('error', 'access_denied');
770
- redirect.searchParams.set('error_description', 'The Hearth owner denied access.');
771
- if (oauthRequest.state) redirect.searchParams.set('state', oauthRequest.state);
772
- if (oauthRequest.issuer) redirect.searchParams.set('iss', oauthRequest.issuer);
773
- return Response.redirect(redirect, 302);
774
- }
775
-
776
- function renderOAuthConsentPage(action, oauthRequest, client, csrf, signedIn, error = '') {
777
- const clientName = escapeHtml(client.clientName || 'An MCP client');
778
- const redirectHost = escapeHtml(new URL(oauthRequest.redirectUri).host);
779
- const scopes = (oauthRequest.scope?.length ? oauthRequest.scope : ['hearth:read'])
780
- .filter(scope => OAUTH_SCOPES.includes(scope));
781
- const permissions = scopes.map(scope => scope === 'hearth:write'
782
- ? '<li><strong>Write:</strong> add moods, notes, moments, dates, shopping items and food reviews</li>'
783
- : '<li><strong>Read:</strong> view the shared Hearth dashboard data</li>').join('');
784
- return `<!doctype html>
785
- <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
786
- <title>Authorize Hearth</title><style>
787
- body{font-family:system-ui,sans-serif;background:#0f0f1a;color:#fafafa;min-height:100vh;display:grid;place-items:center;margin:0}.box{background:#1a1a2e;border:1px solid #34344a;border-radius:16px;padding:2rem;max-width:520px;width:calc(100% - 3rem)}h1{margin-top:0}.muted{color:#a1a1aa}.error{color:#f87171}input{box-sizing:border-box;width:100%;padding:.9rem;background:#0f0f1a;color:#fff;border:1px solid #45455f;border-radius:8px;margin:.5rem 0 1rem}.actions{display:flex;gap:.75rem}.actions button{padding:.8rem 1rem;border-radius:8px;border:0;font-weight:650;cursor:pointer}.approve{background:#a855f7;color:white}.deny{background:#34344a;color:white}code{word-break:break-all;color:#c4b5fd}
788
- </style></head><body><main class="box"><h1>Authorize Hearth</h1>
789
- <p><strong>${clientName}</strong> wants access to this private dashboard.</p><ul>${permissions}</ul>
790
- <p class="muted">After approval you will return to <code>${redirectHost}</code>. Access is token-based and expires; remove or revoke the connector to end access.</p>
791
- ${error ? `<p class="error">${escapeHtml(error)}</p>` : ''}
792
- <form method="post" action="${escapeHtml(action)}"><input type="hidden" name="csrf_token" value="${escapeHtml(csrf)}">
793
- ${signedIn ? '' : '<label>Dashboard password<input type="password" name="password" autocomplete="current-password" required></label>'}
794
- <div class="actions"><button class="approve" type="submit" name="decision" value="approve">Approve</button><button class="deny" type="submit" name="decision" value="deny">Deny</button></div>
795
- </form></main></body></html>`;
796
- }
704
+ return new Response(getDashboardHTML(config), { headers: securityHeaders({ 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' }) });
705
+ }
706
+ };
797
707
 
798
- export default applicationHandler;
799
-
800
- /* ========================= REST API ========================= */
708
+ const OAUTH_SCOPES = ['hearth:read', 'hearth:write'];
709
+ const OAUTH_CSRF_COOKIE = '__Host-hearth_oauth_csrf';
710
+
711
+ export const oauthApiHandler = {
712
+ async fetch(request, env, ctx) {
713
+ const config = getConfig(env);
714
+ const remainder = new URL(request.url).pathname.substring('/mcp'.length);
715
+ const bearer = request.headers.get('Authorization')?.match(/^Bearer\s+(.+)$/i)?.[1];
716
+ const token = bearer ? await env.OAUTH_PROVIDER.unwrapToken(bearer) : null;
717
+ const scopes = Array.isArray(token?.scope) ? token.scope : [];
718
+ return handleMCP(request, env, config, remainder, scopes);
719
+ },
720
+ };
721
+
722
+ export const oauthDefaultHandler = {
723
+ async fetch(request, env, ctx) {
724
+ const url = new URL(request.url);
725
+ if (url.pathname !== '/authorize') return applicationHandler.fetch(request, env, ctx);
726
+
727
+ const config = getConfig(env);
728
+ if (!config.PASSWORD || !config.SESSION_SECRET) {
729
+ return new Response('Hearth Dash is not configured.', { status: 503, headers: securityHeaders() });
730
+ }
731
+ if (!['GET', 'POST'].includes(request.method)) {
732
+ return new Response(null, { status: 405, headers: securityHeaders({ Allow: 'GET, POST' }) });
733
+ }
734
+
735
+ let oauthRequest;
736
+ try {
737
+ oauthRequest = await env.OAUTH_PROVIDER.parseAuthRequest(request);
738
+ } catch (error) {
739
+ return renderOAuthAuthorizationError(error);
740
+ }
741
+ const client = await env.OAUTH_PROVIDER.lookupClient(oauthRequest.clientId);
742
+ if (!client) return new Response('Unknown OAuth client', { status: 400, headers: securityHeaders() });
743
+
744
+ const sessionToken = getCookie(request, '__Host-hearth_session');
745
+ const signedIn = await verifySession(sessionToken, config.SESSION_SECRET);
746
+
747
+ if (request.method === 'GET') {
748
+ const csrf = base64Url(crypto.getRandomValues(new Uint8Array(32)));
749
+ const headers = new Headers(securityHeaders({ 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' }));
750
+ headers.append('Set-Cookie', `${OAUTH_CSRF_COOKIE}=${csrf}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=600`);
751
+ return new Response(renderOAuthConsentPage(request.url, oauthRequest, client, csrf, signedIn), { headers });
752
+ }
753
+
754
+ if (!sameOrigin(request)) return new Response('Forbidden', { status: 403, headers: securityHeaders() });
755
+ if (Number(request.headers.get('Content-Length') || 0) > 16 * 1024) return new Response('Request body is too large', { status: 413 });
756
+ if (!(await withinRateLimit(env, request, 'oauth-consent', 10, 600))) {
757
+ return new Response('Too many authorization attempts. Try again later.', { status: 429, headers: securityHeaders({ 'Retry-After': '600' }) });
758
+ }
759
+
760
+ const form = await request.formData();
761
+ const csrfCookie = getCookie(request, OAUTH_CSRF_COOKIE);
762
+ if (!csrfCookie || !(await safeEqual(csrfCookie, form.get('csrf_token')))) {
763
+ return new Response('Authorization form expired. Start the connection again.', { status: 400, headers: securityHeaders() });
764
+ }
765
+ if (form.get('decision') === 'deny') return redirectOAuthDenial(oauthRequest);
766
+
767
+ let authenticated = signedIn;
768
+ let newSession = null;
769
+ if (!authenticated) {
770
+ authenticated = await safeEqual(form.get('password'), config.PASSWORD);
771
+ if (authenticated) newSession = await createSession(config.SESSION_SECRET);
772
+ }
773
+ if (!authenticated) {
774
+ const csrf = base64Url(crypto.getRandomValues(new Uint8Array(32)));
775
+ const headers = new Headers(securityHeaders({ 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' }));
776
+ headers.append('Set-Cookie', `${OAUTH_CSRF_COOKIE}=${csrf}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=600`);
777
+ return new Response(renderOAuthConsentPage(request.url, oauthRequest, client, csrf, false, 'Wrong dashboard password'), { status: 401, headers });
778
+ }
779
+
780
+ const requestedScopes = oauthRequest.scope?.length ? oauthRequest.scope : ['hearth:read'];
781
+ const grantedScopes = requestedScopes.filter(scope => OAUTH_SCOPES.includes(scope));
782
+ const { redirectTo } = await env.OAUTH_PROVIDER.completeAuthorization({
783
+ request: oauthRequest,
784
+ userId: 'hearth-owner',
785
+ metadata: { clientName: client.clientName || 'MCP client' },
786
+ scope: grantedScopes,
787
+ props: { userId: 'hearth-owner', scopes: grantedScopes },
788
+ });
789
+
790
+ const headers = new Headers(securityHeaders({ Location: redirectTo, 'Cache-Control': 'no-store' }));
791
+ headers.append('Set-Cookie', `${OAUTH_CSRF_COOKIE}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0`);
792
+ if (newSession) headers.append('Set-Cookie', sessionCookie(newSession));
793
+ return new Response(null, { status: 302, headers });
794
+ },
795
+ };
796
+
797
+ function renderOAuthAuthorizationError(error) {
798
+ if (!error || typeof error.code !== 'string') return new Response('Invalid authorization request', { status: 400, headers: securityHeaders() });
799
+ if (!error.redirectUri) return new Response(error.description || 'Invalid authorization request', { status: 400, headers: securityHeaders() });
800
+ const redirect = new URL(error.redirectUri);
801
+ redirect.searchParams.set('error', error.code);
802
+ redirect.searchParams.set('error_description', error.description || 'Authorization failed');
803
+ if (error.state) redirect.searchParams.set('state', error.state);
804
+ if (error.issuer) redirect.searchParams.set('iss', error.issuer);
805
+ return Response.redirect(redirect, 302);
806
+ }
807
+
808
+ function redirectOAuthDenial(oauthRequest) {
809
+ const redirect = new URL(oauthRequest.redirectUri);
810
+ redirect.searchParams.set('error', 'access_denied');
811
+ redirect.searchParams.set('error_description', 'The Hearth owner denied access.');
812
+ if (oauthRequest.state) redirect.searchParams.set('state', oauthRequest.state);
813
+ if (oauthRequest.issuer) redirect.searchParams.set('iss', oauthRequest.issuer);
814
+ return Response.redirect(redirect, 302);
815
+ }
816
+
817
+ function renderOAuthConsentPage(action, oauthRequest, client, csrf, signedIn, error = '') {
818
+ const clientName = escapeHtml(client.clientName || 'An MCP client');
819
+ const redirectHost = escapeHtml(new URL(oauthRequest.redirectUri).host);
820
+ const scopes = (oauthRequest.scope?.length ? oauthRequest.scope : ['hearth:read'])
821
+ .filter(scope => OAUTH_SCOPES.includes(scope));
822
+ const permissions = scopes.map(scope => scope === 'hearth:write'
823
+ ? '<li><strong>Write:</strong> add moods, notes, moments, dates, shopping items and food reviews</li>'
824
+ : '<li><strong>Read:</strong> view the shared Hearth dashboard data</li>').join('');
825
+ return `<!doctype html>
826
+ <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
827
+ <title>Authorize Hearth</title><style>
828
+ body{font-family:system-ui,sans-serif;background:#0f0f1a;color:#fafafa;min-height:100vh;display:grid;place-items:center;margin:0}.box{background:#1a1a2e;border:1px solid #34344a;border-radius:16px;padding:2rem;max-width:520px;width:calc(100% - 3rem)}h1{margin-top:0}.muted{color:#a1a1aa}.error{color:#f87171}input{box-sizing:border-box;width:100%;padding:.9rem;background:#0f0f1a;color:#fff;border:1px solid #45455f;border-radius:8px;margin:.5rem 0 1rem}.actions{display:flex;gap:.75rem}.actions button{padding:.8rem 1rem;border-radius:8px;border:0;font-weight:650;cursor:pointer}.approve{background:#a855f7;color:white}.deny{background:#34344a;color:white}code{word-break:break-all;color:#c4b5fd}
829
+ </style></head><body><main class="box"><h1>Authorize Hearth</h1>
830
+ <p><strong>${clientName}</strong> wants access to this private dashboard.</p><ul>${permissions}</ul>
831
+ <p class="muted">After approval you will return to <code>${redirectHost}</code>. Access is token-based and expires; remove or revoke the connector to end access.</p>
832
+ ${error ? `<p class="error">${escapeHtml(error)}</p>` : ''}
833
+ <form method="post" action="${escapeHtml(action)}"><input type="hidden" name="csrf_token" value="${escapeHtml(csrf)}">
834
+ ${signedIn ? '' : '<label>Dashboard password<input type="password" name="password" autocomplete="current-password" required></label>'}
835
+ <div class="actions"><button class="approve" type="submit" name="decision" value="approve">Approve</button><button class="deny" type="submit" name="decision" value="deny">Deny</button></div>
836
+ </form></main></body></html>`;
837
+ }
838
+
839
+ export default applicationHandler;
840
+
841
+ /* ========================= REST API ========================= */
801
842
 
802
843
  async function handleAPI(request, env, endpoint, config) {
803
844
  const method = request.method;
@@ -821,37 +862,37 @@ async function handleAPI(request, env, endpoint, config) {
821
862
 
822
863
  /* Moods */
823
864
  if (endpoint === '/moods' && method === 'GET') { const r = await env.DB.prepare('SELECT * FROM moods ORDER BY created_at DESC LIMIT 20').all(); return json({ moods: r.results }); }
824
- if (endpoint === '/moods' && method === 'POST') {
825
- const b = await readJsonObject(request);
826
- const partner = requireText(b, 'partner', 80, [config.PARTNER_1, config.PARTNER_2]);
827
- const mood = requireText(b, 'mood', 20, ['great', 'good', 'okay', 'tired', 'stressed', 'low']);
828
- await env.DB.prepare('INSERT INTO moods (partner, mood, note, created_at) VALUES (?, ?, ?, datetime("now"))').bind(partner, mood, optionalText(b, 'note', 2000)).run();
829
- return json({ success: true });
830
- }
865
+ if (endpoint === '/moods' && method === 'POST') {
866
+ const b = await readJsonObject(request);
867
+ const partner = requireText(b, 'partner', 80, [config.PARTNER_1, config.PARTNER_2]);
868
+ const mood = requireText(b, 'mood', 20, ['great', 'good', 'okay', 'tired', 'stressed', 'low']);
869
+ await env.DB.prepare('INSERT INTO moods (partner, mood, note, created_at) VALUES (?, ?, ?, datetime("now"))').bind(partner, mood, optionalText(b, 'note', 2000)).run();
870
+ return json({ success: true });
871
+ }
831
872
 
832
873
  /* Notes */
833
874
  if (endpoint === '/notes' && method === 'GET') { const r = await env.DB.prepare('SELECT * FROM notes ORDER BY created_at DESC LIMIT 20').all(); return json({ notes: r.results }); }
834
- if (endpoint === '/notes' && method === 'POST') {
835
- const b = await readJsonObject(request);
836
- await env.DB.prepare('INSERT INTO notes (from_partner, content, created_at) VALUES (?, ?, datetime("now"))').bind(requireText(b, 'from', 80), requireText(b, 'content', 4000)).run();
837
- return json({ success: true });
838
- }
875
+ if (endpoint === '/notes' && method === 'POST') {
876
+ const b = await readJsonObject(request);
877
+ await env.DB.prepare('INSERT INTO notes (from_partner, content, created_at) VALUES (?, ?, datetime("now"))').bind(requireText(b, 'from', 80), requireText(b, 'content', 4000)).run();
878
+ return json({ success: true });
879
+ }
839
880
 
840
881
  /* Moments */
841
882
  if (endpoint === '/moments' && method === 'GET') { const r = await env.DB.prepare('SELECT * FROM moments ORDER BY date DESC').all(); return json({ moments: r.results }); }
842
- if (endpoint === '/moments' && method === 'POST') {
843
- const b = await readJsonObject(request);
844
- await env.DB.prepare('INSERT INTO moments (date, title, description, created_at) VALUES (?, ?, ?, datetime("now"))').bind(requireDate(b), requireText(b, 'title', 200), optionalText(b, 'description', 4000)).run();
845
- return json({ success: true });
846
- }
883
+ if (endpoint === '/moments' && method === 'POST') {
884
+ const b = await readJsonObject(request);
885
+ await env.DB.prepare('INSERT INTO moments (date, title, description, created_at) VALUES (?, ?, ?, datetime("now"))').bind(requireDate(b), requireText(b, 'title', 200), optionalText(b, 'description', 4000)).run();
886
+ return json({ success: true });
887
+ }
847
888
 
848
889
  /* Dates */
849
890
  if (endpoint === '/dates' && method === 'GET') { const r = await env.DB.prepare('SELECT * FROM dates ORDER BY date ASC').all(); return json({ dates: r.results }); }
850
- if (endpoint === '/dates' && method === 'POST') {
851
- const b = await readJsonObject(request);
852
- await env.DB.prepare('INSERT INTO dates (date, title, recurring, created_at) VALUES (?, ?, ?, datetime("now"))').bind(requireDate(b), requireText(b, 'title', 200), b.recurring === true ? 1 : 0).run();
853
- return json({ success: true });
854
- }
891
+ if (endpoint === '/dates' && method === 'POST') {
892
+ const b = await readJsonObject(request);
893
+ await env.DB.prepare('INSERT INTO dates (date, title, recurring, created_at) VALUES (?, ?, ?, datetime("now"))').bind(requireDate(b), requireText(b, 'title', 200), b.recurring === true ? 1 : 0).run();
894
+ return json({ success: true });
895
+ }
855
896
  const dateDelMatch = endpoint.match(/^\/dates\/(\d+)$/);
856
897
  if (dateDelMatch && method === 'DELETE') {
857
898
  await env.DB.prepare('DELETE FROM dates WHERE id = ?').bind(+dateDelMatch[1]).run();
@@ -863,12 +904,12 @@ async function handleAPI(request, env, endpoint, config) {
863
904
  const r = await env.DB.prepare('SELECT * FROM shopping ORDER BY checked ASC, created_at DESC').all();
864
905
  return json({ items: r.results });
865
906
  }
866
- if (endpoint === '/shopping' && method === 'POST') {
867
- const b = await readJsonObject(request);
868
- const item = requireText(b, 'item', 300);
869
- const category = b.category ? requireText(b, 'category', 100) : 'Other';
870
- const addedBy = b.added_by ? requireText(b, 'added_by', 80) : config.PARTNER_1;
871
- await env.DB.prepare('INSERT INTO shopping (item, category, checked, added_by, created_at) VALUES (?, ?, 0, ?, datetime("now"))').bind(item, category, addedBy).run();
907
+ if (endpoint === '/shopping' && method === 'POST') {
908
+ const b = await readJsonObject(request);
909
+ const item = requireText(b, 'item', 300);
910
+ const category = b.category ? requireText(b, 'category', 100) : 'Other';
911
+ const addedBy = b.added_by ? requireText(b, 'added_by', 80) : config.PARTNER_1;
912
+ await env.DB.prepare('INSERT INTO shopping (item, category, checked, added_by, created_at) VALUES (?, ?, 0, ?, datetime("now"))').bind(item, category, addedBy).run();
872
913
  return json({ success: true });
873
914
  }
874
915
  if (endpoint === '/shopping/checked' && method === 'DELETE') {
@@ -894,13 +935,13 @@ async function handleAPI(request, env, endpoint, config) {
894
935
  : await env.DB.prepare("SELECT * FROM food_diary WHERE date = date('now') ORDER BY time ASC").all();
895
936
  return json({ entries: r.results });
896
937
  }
897
- if (endpoint === '/food' && method === 'POST') {
898
- const b = await readJsonObject(request);
899
- const date = b.date ? requireDate(b) : new Date().toISOString().slice(0, 10);
900
- const time = b.time ? requireText(b, 'time', 5) : new Date().toISOString().slice(11, 16);
901
- if (!/^\d{2}:\d{2}$/.test(time)) throw Object.assign(new Error('time must use HH:MM'), { status: 400 });
902
- const mealType = requireText(b, 'meal_type', 20, ['breakfast', 'lunch', 'dinner', 'snack']);
903
- await env.DB.prepare('INSERT INTO food_diary (date, time, meal_type, note, photo_key, created_at) VALUES (?, ?, ?, ?, ?, datetime("now"))').bind(date, time, mealType, optionalText(b, 'note', 4000), optionalText(b, 'photo_key', 500)).run();
938
+ if (endpoint === '/food' && method === 'POST') {
939
+ const b = await readJsonObject(request);
940
+ const date = b.date ? requireDate(b) : new Date().toISOString().slice(0, 10);
941
+ const time = b.time ? requireText(b, 'time', 5) : new Date().toISOString().slice(11, 16);
942
+ if (!/^\d{2}:\d{2}$/.test(time)) throw Object.assign(new Error('time must use HH:MM'), { status: 400 });
943
+ const mealType = requireText(b, 'meal_type', 20, ['breakfast', 'lunch', 'dinner', 'snack']);
944
+ await env.DB.prepare('INSERT INTO food_diary (date, time, meal_type, note, photo_key, created_at) VALUES (?, ?, ?, ?, ?, datetime("now"))').bind(date, time, mealType, optionalText(b, 'note', 4000), optionalText(b, 'photo_key', 500)).run();
904
945
  return json({ success: true });
905
946
  }
906
947
  const foodDelMatch = endpoint.match(/^\/food\/(\d+)$/);
@@ -927,11 +968,11 @@ async function handleAPI(request, env, endpoint, config) {
927
968
  const total = (r.results || []).reduce((sum, e) => sum + e.amount_ml, 0);
928
969
  return json({ entries: r.results, total_ml: total });
929
970
  }
930
- if (endpoint === '/water' && method === 'POST') {
931
- const b = await readJsonObject(request);
932
- const date = b.date ? requireDate(b) : new Date().toISOString().slice(0, 10);
933
- const amount = b.amount_ml === undefined ? 250 : b.amount_ml;
934
- if (!Number.isInteger(amount) || amount < 1 || amount > 5000) throw Object.assign(new Error('amount_ml must be an integer between 1 and 5000'), { status: 400 });
971
+ if (endpoint === '/water' && method === 'POST') {
972
+ const b = await readJsonObject(request);
973
+ const date = b.date ? requireDate(b) : new Date().toISOString().slice(0, 10);
974
+ const amount = b.amount_ml === undefined ? 250 : b.amount_ml;
975
+ if (!Number.isInteger(amount) || amount < 1 || amount > 5000) throw Object.assign(new Error('amount_ml must be an integer between 1 and 5000'), { status: 400 });
935
976
  await env.DB.prepare('INSERT INTO water_log (date, amount_ml, created_at) VALUES (?, ?, datetime("now"))').bind(date, amount).run();
936
977
  const r = date
937
978
  ? await env.DB.prepare('SELECT SUM(amount_ml) as total FROM water_log WHERE date = ?').bind(date).first()
@@ -954,270 +995,270 @@ async function handleAPI(request, env, endpoint, config) {
954
995
  const r = await env.DB.prepare('SELECT * FROM food_reviews ORDER BY date DESC LIMIT 7').all();
955
996
  return json({ reviews: r.results });
956
997
  }
957
- if (endpoint === '/food/reviews' && method === 'POST') {
958
- const b = await readJsonObject(request);
959
- await env.DB.prepare('INSERT OR REPLACE INTO food_reviews (date, review, reviewer, created_at) VALUES (?, ?, ?, datetime("now"))').bind(requireDate(b), requireText(b, 'review', 6000), b.reviewer ? requireText(b, 'reviewer', 80) : 'AI').run();
998
+ if (endpoint === '/food/reviews' && method === 'POST') {
999
+ const b = await readJsonObject(request);
1000
+ await env.DB.prepare('INSERT OR REPLACE INTO food_reviews (date, review, reviewer, created_at) VALUES (?, ?, ?, datetime("now"))').bind(requireDate(b), requireText(b, 'review', 6000), b.reviewer ? requireText(b, 'reviewer', 80) : 'AI').run();
960
1001
  return json({ success: true });
961
1002
  }
962
1003
 
963
1004
  return json({ error: 'Not found' }, 404);
964
- } catch (err) { return json({ error: err.status && err.status < 500 ? err.message : 'Request failed' }, err.status || 500); }
965
- }
1005
+ } catch (err) { return json({ error: err.status && err.status < 500 ? err.message : 'Request failed' }, err.status || 500); }
1006
+ }
966
1007
 
967
1008
  /* ========================= MCP ========================= */
968
1009
 
969
- const MCP_TOOLS = [
970
- toolDefinition('hearth_status', 'Dashboard status', 'Read the current Hearth overview.', {}, true),
971
- toolDefinition('hearth_mood', 'Mood log', 'Read recent moods or record a mood.', {
972
- action: enumProperty(['get', 'set'], 'Whether to read or record a mood.'),
973
- partner: stringProperty('Partner name.', 80),
974
- mood: enumProperty(['great', 'good', 'okay', 'tired', 'stressed', 'low'], 'Mood to record.'),
975
- note: stringProperty('Optional private note.', 2000),
976
- }, false, ['action']),
977
- toolDefinition('hearth_note', 'Fridge notes', 'Read or leave a shared fridge note.', {
978
- action: enumProperty(['get', 'leave'], 'Whether to read or leave notes.'),
979
- limit: integerProperty('Number of notes to read.', 1, 20),
980
- from: stringProperty('Author name.', 80),
981
- content: stringProperty('Note text.', 4000),
982
- }, false, ['action']),
983
- toolDefinition('hearth_moment', 'Shared moments', 'List or add a dated shared moment.', {
984
- action: enumProperty(['list', 'add'], 'Whether to list or add moments.'),
985
- limit: integerProperty('Number of moments to read.', 1, 50),
986
- date: stringProperty('Date in YYYY-MM-DD format.', 10),
987
- title: stringProperty('Moment title.', 200),
988
- description: stringProperty('Optional description.', 4000),
989
- }, false, ['action']),
990
- toolDefinition('hearth_date', 'Important dates', 'Read upcoming dates or add one.', {
991
- action: enumProperty(['upcoming', 'add'], 'Whether to read or add dates.'),
992
- limit: integerProperty('Number of dates to read.', 1, 50),
993
- date: stringProperty('Date in YYYY-MM-DD format.', 10),
994
- title: stringProperty('Date title.', 200),
995
- recurring: { type: 'boolean', description: 'Whether this repeats yearly.' },
996
- }, false, ['action']),
997
- toolDefinition('hearth_shopping_list', 'Shopping list', 'Read unchecked shopping-list items.', {}, true),
998
- toolDefinition('hearth_shopping_add', 'Add shopping item', 'Add one item to the shopping list.', {
999
- item: stringProperty('Item to add.', 300),
1000
- category: stringProperty('Optional category.', 100),
1001
- added_by: stringProperty('Name of the person adding it.', 80),
1002
- }, false, ['item']),
1003
- toolDefinition('hearth_pressure', 'Pressure and migraine risk', 'Read current, historical, or forecast barometric pressure.', {
1004
- action: enumProperty(['status', 'history', 'forecast'], 'Pressure view to return.'),
1005
- hours: integerProperty('History window in hours.', 1, 720),
1006
- }, true),
1007
- toolDefinition('hearth_food_diary_today', 'Today food diary', 'Read today\'s meals, water and review status.', {}, true),
1008
- toolDefinition('hearth_food_diary_history', 'Food diary history', 'Read food and water history over a bounded date range.', {
1009
- days: integerProperty('Days to include when dates are omitted.', 1, 90),
1010
- from: stringProperty('Start date in YYYY-MM-DD format.', 10),
1011
- to: stringProperty('End date in YYYY-MM-DD format.', 10),
1012
- }, true),
1013
- toolDefinition('hearth_food_review', 'Save food review', 'Save or replace an AI food-diary review for a date.', {
1014
- date: stringProperty('Date in YYYY-MM-DD format.', 10),
1015
- review: stringProperty('Review text.', 6000),
1016
- reviewer: stringProperty('Reviewer name.', 80),
1017
- }, false, ['date', 'review']),
1018
- toolDefinition('hearth_water_status', 'Water status', 'Read today\'s and recent water totals.', {}, true),
1019
- ];
1020
-
1021
- function stringProperty(description, maxLength) {
1022
- return { type: 'string', description, minLength: 1, maxLength };
1023
- }
1024
-
1025
- function integerProperty(description, minimum, maximum) {
1026
- return { type: 'integer', description, minimum, maximum };
1027
- }
1028
-
1029
- function enumProperty(values, description) {
1030
- return { type: 'string', enum: values, description };
1031
- }
1032
-
1033
- function toolDefinition(name, title, description, properties, readOnlyHint, required = []) {
1034
- return {
1035
- name, title, description,
1036
- inputSchema: { type: 'object', properties, required, additionalProperties: false },
1037
- annotations: { title, readOnlyHint, destructiveHint: false, idempotentHint: readOnlyHint, openWorldHint: false },
1038
- };
1039
- }
1040
-
1041
- function rpcResult(id, result, status = 200) {
1042
- return new Response(JSON.stringify({ jsonrpc: '2.0', id, result }), {
1043
- status,
1044
- headers: securityHeaders({ 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' }),
1045
- });
1046
- }
1047
-
1048
- function rpcError(id, code, message, status = 200, data) {
1049
- const error = { code, message };
1050
- if (data !== undefined) error.data = data;
1051
- return new Response(JSON.stringify({ jsonrpc: '2.0', id: id ?? null, error }), {
1052
- status,
1053
- headers: securityHeaders({ 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' }),
1054
- });
1055
- }
1056
-
1057
- function validateMcpOrigin(request, config) {
1058
- const origin = request.headers.get('Origin');
1059
- if (!origin) return true;
1060
- if (origin === new URL(request.url).origin) return true;
1061
- return config.MCP_ALLOWED_ORIGINS.includes(origin);
1062
- }
1063
-
1064
- function validateArguments(tool, args) {
1065
- if (!args || typeof args !== 'object' || Array.isArray(args)) return 'arguments must be an object';
1066
- const schema = tool.inputSchema;
1067
- for (const required of schema.required || []) {
1068
- if (args[required] === undefined || args[required] === null || args[required] === '') return required + ' is required';
1069
- }
1070
- for (const [key, value] of Object.entries(args)) {
1071
- const property = schema.properties[key];
1072
- if (!property) return 'unknown argument: ' + key;
1073
- if (property.type === 'string') {
1074
- if (typeof value !== 'string') return key + ' must be a string';
1075
- if (property.minLength && value.length < property.minLength) return key + ' is too short';
1076
- if (property.maxLength && value.length > property.maxLength) return key + ' is too long';
1077
- if (property.enum && !property.enum.includes(value)) return key + ' must be one of: ' + property.enum.join(', ');
1078
- } else if (property.type === 'integer') {
1079
- if (!Number.isInteger(value) || value < property.minimum || value > property.maximum) return key + ' is out of range';
1080
- } else if (property.type === 'boolean' && typeof value !== 'boolean') {
1081
- return key + ' must be a boolean';
1082
- }
1083
- }
1084
- const requiredForAction = {
1085
- hearth_mood: { set: ['partner', 'mood'] },
1086
- hearth_note: { leave: ['from', 'content'] },
1087
- hearth_moment: { add: ['date', 'title'] },
1088
- hearth_date: { add: ['date', 'title'] },
1089
- };
1090
- for (const key of requiredForAction[tool.name]?.[args.action] || []) {
1091
- if (args[key] === undefined || args[key] === null || args[key] === '') return key + ' is required for ' + args.action;
1092
- }
1093
- for (const key of ['date', 'from', 'to']) {
1094
- if (args[key] !== undefined && !/^\d{4}-\d{2}-\d{2}$/.test(args[key])) return key + ' must use YYYY-MM-DD';
1095
- }
1096
- return null;
1097
- }
1098
-
1099
- async function executeMcpTool(name, params, env, config) {
1100
- switch (name) {
1101
- case 'hearth_status': return mcpStatus(env, config);
1102
- case 'hearth_mood': return mcpMood(env, params, config);
1103
- case 'hearth_note': return mcpNote(env, params);
1104
- case 'hearth_moment': return mcpMoment(env, params);
1105
- case 'hearth_date': return mcpDate(env, params);
1106
- case 'hearth_shopping_list': return mcpShoppingList(env);
1107
- case 'hearth_shopping_add': return mcpShoppingAdd(env, params, config);
1108
- case 'hearth_pressure': return mcpPressure(env, params);
1109
- case 'hearth_food_diary_today': return mcpFoodDiaryToday(env, config);
1110
- case 'hearth_food_diary_history': return mcpFoodDiaryHistory(env, params);
1111
- case 'hearth_food_review': return mcpFoodReview(env, params);
1112
- case 'hearth_water_status': return mcpWaterStatus(env);
1113
- default: return null;
1114
- }
1115
- }
1116
-
1117
- function requiredScopeForCall(name, args) {
1118
- if (name === 'hearth_shopping_add' || name === 'hearth_food_review') return 'hearth:write';
1119
- if (name === 'hearth_mood' && args.action === 'set') return 'hearth:write';
1120
- if (name === 'hearth_note' && args.action === 'leave') return 'hearth:write';
1121
- if (name === 'hearth_moment' && args.action === 'add') return 'hearth:write';
1122
- if (name === 'hearth_date' && args.action === 'add') return 'hearth:write';
1123
- return 'hearth:read';
1124
- }
1125
-
1126
- function visibleToolsForScopes(scopes) {
1127
- if (scopes.includes('hearth:read') && scopes.includes('hearth:write')) return MCP_TOOLS;
1128
- if (scopes.includes('hearth:read')) {
1129
- return MCP_TOOLS.filter(tool => !['hearth_shopping_add', 'hearth_food_review'].includes(tool.name));
1130
- }
1131
- if (scopes.includes('hearth:write')) return MCP_TOOLS.filter(tool => !tool.annotations.readOnlyHint);
1132
- return [];
1133
- }
1134
-
1135
- async function handleMCP(request, env, config, remainder, scopes = []) {
1136
- if (remainder && remainder !== '/') return json({ error: 'Not found' }, 404);
1137
- if (!validateMcpOrigin(request, config)) return json({ error: 'Forbidden origin' }, 403);
1138
- if (!(await withinRateLimit(env, request, 'mcp', 120, 60))) {
1139
- return rpcError(null, -32000, 'Rate limit exceeded', 429);
1140
- }
1141
- if (request.method === 'GET') {
1142
- return new Response(null, { status: 405, headers: securityHeaders({ 'Allow': 'POST' }) });
1143
- }
1144
- if (request.method !== 'POST') return new Response(null, { status: 405, headers: securityHeaders({ 'Allow': 'POST' }) });
1145
-
1146
- const contentType = request.headers.get('Content-Type') || '';
1147
- if (!contentType.toLowerCase().startsWith('application/json')) return rpcError(null, -32600, 'Content-Type must be application/json', 415);
1148
- const declaredLength = Number(request.headers.get('Content-Length') || 0);
1149
- if (declaredLength > MAX_JSON_BODY_BYTES) return rpcError(null, -32600, 'Request body is too large', 413);
1150
-
1151
- let message;
1152
- try {
1153
- const text = await request.text();
1154
- if (encoder.encode(text).byteLength > MAX_JSON_BODY_BYTES) return rpcError(null, -32600, 'Request body is too large', 413);
1155
- message = JSON.parse(text);
1156
- } catch {
1157
- return rpcError(null, -32700, 'Parse error', 400);
1158
- }
1159
- if (!message || Array.isArray(message) || message.jsonrpc !== '2.0' || typeof message.method !== 'string') {
1160
- return rpcError(message?.id, -32600, 'Invalid Request', 400);
1161
- }
1162
-
1163
- if (message.method !== 'initialize') {
1164
- const protocolVersion = request.headers.get('MCP-Protocol-Version');
1165
- if (!protocolVersion || !MCP_PROTOCOL_VERSIONS.includes(protocolVersion)) {
1166
- return rpcError(message.id, -32600, 'Unsupported MCP protocol version', 400, { supported: MCP_PROTOCOL_VERSIONS });
1167
- }
1168
- }
1169
-
1170
- const isNotification = message.id === undefined;
1171
- if (message.method === 'notifications/initialized' || message.method === 'notifications/cancelled') {
1172
- return new Response(null, { status: 202, headers: securityHeaders({ 'Cache-Control': 'no-store' }) });
1173
- }
1174
- if (isNotification) return new Response(null, { status: 202, headers: securityHeaders({ 'Cache-Control': 'no-store' }) });
1175
-
1176
- if (message.method === 'initialize') {
1177
- const requested = message.params?.protocolVersion;
1178
- if (typeof requested !== 'string') return rpcError(message.id, -32602, 'protocolVersion is required', 400);
1179
- const protocolVersion = MCP_PROTOCOL_VERSIONS.includes(requested) ? requested : MCP_PROTOCOL_VERSIONS[0];
1180
- return rpcResult(message.id, {
1181
- protocolVersion,
1182
- capabilities: { tools: { listChanged: false } },
1183
- serverInfo: { name: 'hearth-dash', version: '1.1.0' },
1184
- instructions: 'Hearth is a private shared dashboard. Read tools do not change data; write tools change the shared household record.',
1185
- });
1186
- }
1187
-
1188
- if (message.method === 'ping') return rpcResult(message.id, {});
1189
- if (message.method === 'tools/list') return rpcResult(message.id, { tools: visibleToolsForScopes(scopes) });
1190
- if (message.method !== 'tools/call') return rpcError(message.id, -32601, 'Method not found');
1191
-
1192
- const name = message.params?.name;
1193
- const args = message.params?.arguments || {};
1194
- const tool = MCP_TOOLS.find(item => item.name === name);
1195
- if (!tool) return rpcError(message.id, -32602, 'Unknown tool: ' + String(name));
1196
- const validationError = validateArguments(tool, args);
1197
- if (validationError) {
1198
- return rpcResult(message.id, { content: [{ type: 'text', text: validationError }], isError: true });
1199
- }
1200
- const requiredScope = requiredScopeForCall(name, args);
1201
- if (!scopes.includes(requiredScope)) {
1202
- return rpcResult(message.id, {
1203
- content: [{ type: 'text', text: `Permission denied: ${requiredScope} scope is required` }],
1204
- isError: true,
1205
- });
1206
- }
1207
-
1208
- try {
1209
- const toolResponse = await executeMcpTool(name, args, env, config);
1210
- const data = await toolResponse.json();
1211
- const isError = !toolResponse.ok || !!data.error;
1212
- return rpcResult(message.id, {
1213
- content: [{ type: 'text', text: JSON.stringify(data) }],
1214
- structuredContent: data,
1215
- isError,
1216
- });
1217
- } catch (error) {
1218
- return rpcResult(message.id, { content: [{ type: 'text', text: 'Tool execution failed' }], isError: true });
1219
- }
1220
- }
1010
+ const MCP_TOOLS = [
1011
+ toolDefinition('hearth_status', 'Dashboard status', 'Read the current Hearth overview.', {}, true),
1012
+ toolDefinition('hearth_mood', 'Mood log', 'Read recent moods or record a mood.', {
1013
+ action: enumProperty(['get', 'set'], 'Whether to read or record a mood.'),
1014
+ partner: stringProperty('Partner name.', 80),
1015
+ mood: enumProperty(['great', 'good', 'okay', 'tired', 'stressed', 'low'], 'Mood to record.'),
1016
+ note: stringProperty('Optional private note.', 2000),
1017
+ }, false, ['action']),
1018
+ toolDefinition('hearth_note', 'Fridge notes', 'Read or leave a shared fridge note.', {
1019
+ action: enumProperty(['get', 'leave'], 'Whether to read or leave notes.'),
1020
+ limit: integerProperty('Number of notes to read.', 1, 20),
1021
+ from: stringProperty('Author name.', 80),
1022
+ content: stringProperty('Note text.', 4000),
1023
+ }, false, ['action']),
1024
+ toolDefinition('hearth_moment', 'Shared moments', 'List or add a dated shared moment.', {
1025
+ action: enumProperty(['list', 'add'], 'Whether to list or add moments.'),
1026
+ limit: integerProperty('Number of moments to read.', 1, 50),
1027
+ date: stringProperty('Date in YYYY-MM-DD format.', 10),
1028
+ title: stringProperty('Moment title.', 200),
1029
+ description: stringProperty('Optional description.', 4000),
1030
+ }, false, ['action']),
1031
+ toolDefinition('hearth_date', 'Important dates', 'Read upcoming dates or add one.', {
1032
+ action: enumProperty(['upcoming', 'add'], 'Whether to read or add dates.'),
1033
+ limit: integerProperty('Number of dates to read.', 1, 50),
1034
+ date: stringProperty('Date in YYYY-MM-DD format.', 10),
1035
+ title: stringProperty('Date title.', 200),
1036
+ recurring: { type: 'boolean', description: 'Whether this repeats yearly.' },
1037
+ }, false, ['action']),
1038
+ toolDefinition('hearth_shopping_list', 'Shopping list', 'Read unchecked shopping-list items.', {}, true),
1039
+ toolDefinition('hearth_shopping_add', 'Add shopping item', 'Add one item to the shopping list.', {
1040
+ item: stringProperty('Item to add.', 300),
1041
+ category: stringProperty('Optional category.', 100),
1042
+ added_by: stringProperty('Name of the person adding it.', 80),
1043
+ }, false, ['item']),
1044
+ toolDefinition('hearth_pressure', 'Pressure and migraine risk', 'Read current, historical, or forecast barometric pressure.', {
1045
+ action: enumProperty(['status', 'history', 'forecast'], 'Pressure view to return.'),
1046
+ hours: integerProperty('History window in hours.', 1, 720),
1047
+ }, true),
1048
+ toolDefinition('hearth_food_diary_today', 'Today food diary', 'Read today\'s meals, water and review status.', {}, true),
1049
+ toolDefinition('hearth_food_diary_history', 'Food diary history', 'Read food and water history over a bounded date range.', {
1050
+ days: integerProperty('Days to include when dates are omitted.', 1, 90),
1051
+ from: stringProperty('Start date in YYYY-MM-DD format.', 10),
1052
+ to: stringProperty('End date in YYYY-MM-DD format.', 10),
1053
+ }, true),
1054
+ toolDefinition('hearth_food_review', 'Save food review', 'Save or replace an AI food-diary review for a date.', {
1055
+ date: stringProperty('Date in YYYY-MM-DD format.', 10),
1056
+ review: stringProperty('Review text.', 6000),
1057
+ reviewer: stringProperty('Reviewer name.', 80),
1058
+ }, false, ['date', 'review']),
1059
+ toolDefinition('hearth_water_status', 'Water status', 'Read today\'s and recent water totals.', {}, true),
1060
+ ];
1061
+
1062
+ function stringProperty(description, maxLength) {
1063
+ return { type: 'string', description, minLength: 1, maxLength };
1064
+ }
1065
+
1066
+ function integerProperty(description, minimum, maximum) {
1067
+ return { type: 'integer', description, minimum, maximum };
1068
+ }
1069
+
1070
+ function enumProperty(values, description) {
1071
+ return { type: 'string', enum: values, description };
1072
+ }
1073
+
1074
+ function toolDefinition(name, title, description, properties, readOnlyHint, required = []) {
1075
+ return {
1076
+ name, title, description,
1077
+ inputSchema: { type: 'object', properties, required, additionalProperties: false },
1078
+ annotations: { title, readOnlyHint, destructiveHint: false, idempotentHint: readOnlyHint, openWorldHint: false },
1079
+ };
1080
+ }
1081
+
1082
+ function rpcResult(id, result, status = 200) {
1083
+ return new Response(JSON.stringify({ jsonrpc: '2.0', id, result }), {
1084
+ status,
1085
+ headers: securityHeaders({ 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' }),
1086
+ });
1087
+ }
1088
+
1089
+ function rpcError(id, code, message, status = 200, data) {
1090
+ const error = { code, message };
1091
+ if (data !== undefined) error.data = data;
1092
+ return new Response(JSON.stringify({ jsonrpc: '2.0', id: id ?? null, error }), {
1093
+ status,
1094
+ headers: securityHeaders({ 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' }),
1095
+ });
1096
+ }
1097
+
1098
+ function validateMcpOrigin(request, config) {
1099
+ const origin = request.headers.get('Origin');
1100
+ if (!origin) return true;
1101
+ if (origin === new URL(request.url).origin) return true;
1102
+ return config.MCP_ALLOWED_ORIGINS.includes(origin);
1103
+ }
1104
+
1105
+ function validateArguments(tool, args) {
1106
+ if (!args || typeof args !== 'object' || Array.isArray(args)) return 'arguments must be an object';
1107
+ const schema = tool.inputSchema;
1108
+ for (const required of schema.required || []) {
1109
+ if (args[required] === undefined || args[required] === null || args[required] === '') return required + ' is required';
1110
+ }
1111
+ for (const [key, value] of Object.entries(args)) {
1112
+ const property = schema.properties[key];
1113
+ if (!property) return 'unknown argument: ' + key;
1114
+ if (property.type === 'string') {
1115
+ if (typeof value !== 'string') return key + ' must be a string';
1116
+ if (property.minLength && value.length < property.minLength) return key + ' is too short';
1117
+ if (property.maxLength && value.length > property.maxLength) return key + ' is too long';
1118
+ if (property.enum && !property.enum.includes(value)) return key + ' must be one of: ' + property.enum.join(', ');
1119
+ } else if (property.type === 'integer') {
1120
+ if (!Number.isInteger(value) || value < property.minimum || value > property.maximum) return key + ' is out of range';
1121
+ } else if (property.type === 'boolean' && typeof value !== 'boolean') {
1122
+ return key + ' must be a boolean';
1123
+ }
1124
+ }
1125
+ const requiredForAction = {
1126
+ hearth_mood: { set: ['partner', 'mood'] },
1127
+ hearth_note: { leave: ['from', 'content'] },
1128
+ hearth_moment: { add: ['date', 'title'] },
1129
+ hearth_date: { add: ['date', 'title'] },
1130
+ };
1131
+ for (const key of requiredForAction[tool.name]?.[args.action] || []) {
1132
+ if (args[key] === undefined || args[key] === null || args[key] === '') return key + ' is required for ' + args.action;
1133
+ }
1134
+ for (const key of ['date', 'from', 'to']) {
1135
+ if (args[key] !== undefined && !/^\d{4}-\d{2}-\d{2}$/.test(args[key])) return key + ' must use YYYY-MM-DD';
1136
+ }
1137
+ return null;
1138
+ }
1139
+
1140
+ async function executeMcpTool(name, params, env, config) {
1141
+ switch (name) {
1142
+ case 'hearth_status': return mcpStatus(env, config);
1143
+ case 'hearth_mood': return mcpMood(env, params, config);
1144
+ case 'hearth_note': return mcpNote(env, params);
1145
+ case 'hearth_moment': return mcpMoment(env, params);
1146
+ case 'hearth_date': return mcpDate(env, params);
1147
+ case 'hearth_shopping_list': return mcpShoppingList(env);
1148
+ case 'hearth_shopping_add': return mcpShoppingAdd(env, params, config);
1149
+ case 'hearth_pressure': return mcpPressure(env, params);
1150
+ case 'hearth_food_diary_today': return mcpFoodDiaryToday(env, config);
1151
+ case 'hearth_food_diary_history': return mcpFoodDiaryHistory(env, params);
1152
+ case 'hearth_food_review': return mcpFoodReview(env, params);
1153
+ case 'hearth_water_status': return mcpWaterStatus(env);
1154
+ default: return null;
1155
+ }
1156
+ }
1157
+
1158
+ function requiredScopeForCall(name, args) {
1159
+ if (name === 'hearth_shopping_add' || name === 'hearth_food_review') return 'hearth:write';
1160
+ if (name === 'hearth_mood' && args.action === 'set') return 'hearth:write';
1161
+ if (name === 'hearth_note' && args.action === 'leave') return 'hearth:write';
1162
+ if (name === 'hearth_moment' && args.action === 'add') return 'hearth:write';
1163
+ if (name === 'hearth_date' && args.action === 'add') return 'hearth:write';
1164
+ return 'hearth:read';
1165
+ }
1166
+
1167
+ function visibleToolsForScopes(scopes) {
1168
+ if (scopes.includes('hearth:read') && scopes.includes('hearth:write')) return MCP_TOOLS;
1169
+ if (scopes.includes('hearth:read')) {
1170
+ return MCP_TOOLS.filter(tool => !['hearth_shopping_add', 'hearth_food_review'].includes(tool.name));
1171
+ }
1172
+ if (scopes.includes('hearth:write')) return MCP_TOOLS.filter(tool => !tool.annotations.readOnlyHint);
1173
+ return [];
1174
+ }
1175
+
1176
+ async function handleMCP(request, env, config, remainder, scopes = []) {
1177
+ if (remainder && remainder !== '/') return json({ error: 'Not found' }, 404);
1178
+ if (!validateMcpOrigin(request, config)) return json({ error: 'Forbidden origin' }, 403);
1179
+ if (!(await withinRateLimit(env, request, 'mcp', 120, 60))) {
1180
+ return rpcError(null, -32000, 'Rate limit exceeded', 429);
1181
+ }
1182
+ if (request.method === 'GET') {
1183
+ return new Response(null, { status: 405, headers: securityHeaders({ 'Allow': 'POST' }) });
1184
+ }
1185
+ if (request.method !== 'POST') return new Response(null, { status: 405, headers: securityHeaders({ 'Allow': 'POST' }) });
1186
+
1187
+ const contentType = request.headers.get('Content-Type') || '';
1188
+ if (!contentType.toLowerCase().startsWith('application/json')) return rpcError(null, -32600, 'Content-Type must be application/json', 415);
1189
+ const declaredLength = Number(request.headers.get('Content-Length') || 0);
1190
+ if (declaredLength > MAX_JSON_BODY_BYTES) return rpcError(null, -32600, 'Request body is too large', 413);
1191
+
1192
+ let message;
1193
+ try {
1194
+ const text = await request.text();
1195
+ if (encoder.encode(text).byteLength > MAX_JSON_BODY_BYTES) return rpcError(null, -32600, 'Request body is too large', 413);
1196
+ message = JSON.parse(text);
1197
+ } catch {
1198
+ return rpcError(null, -32700, 'Parse error', 400);
1199
+ }
1200
+ if (!message || Array.isArray(message) || message.jsonrpc !== '2.0' || typeof message.method !== 'string') {
1201
+ return rpcError(message?.id, -32600, 'Invalid Request', 400);
1202
+ }
1203
+
1204
+ if (message.method !== 'initialize') {
1205
+ const protocolVersion = request.headers.get('MCP-Protocol-Version');
1206
+ if (!protocolVersion || !MCP_PROTOCOL_VERSIONS.includes(protocolVersion)) {
1207
+ return rpcError(message.id, -32600, 'Unsupported MCP protocol version', 400, { supported: MCP_PROTOCOL_VERSIONS });
1208
+ }
1209
+ }
1210
+
1211
+ const isNotification = message.id === undefined;
1212
+ if (message.method === 'notifications/initialized' || message.method === 'notifications/cancelled') {
1213
+ return new Response(null, { status: 202, headers: securityHeaders({ 'Cache-Control': 'no-store' }) });
1214
+ }
1215
+ if (isNotification) return new Response(null, { status: 202, headers: securityHeaders({ 'Cache-Control': 'no-store' }) });
1216
+
1217
+ if (message.method === 'initialize') {
1218
+ const requested = message.params?.protocolVersion;
1219
+ if (typeof requested !== 'string') return rpcError(message.id, -32602, 'protocolVersion is required', 400);
1220
+ const protocolVersion = MCP_PROTOCOL_VERSIONS.includes(requested) ? requested : MCP_PROTOCOL_VERSIONS[0];
1221
+ return rpcResult(message.id, {
1222
+ protocolVersion,
1223
+ capabilities: { tools: { listChanged: false } },
1224
+ serverInfo: { name: 'hearth-dash', version: '1.1.0' },
1225
+ instructions: 'Hearth is a private shared dashboard. Read tools do not change data; write tools change the shared household record.',
1226
+ });
1227
+ }
1228
+
1229
+ if (message.method === 'ping') return rpcResult(message.id, {});
1230
+ if (message.method === 'tools/list') return rpcResult(message.id, { tools: visibleToolsForScopes(scopes) });
1231
+ if (message.method !== 'tools/call') return rpcError(message.id, -32601, 'Method not found');
1232
+
1233
+ const name = message.params?.name;
1234
+ const args = message.params?.arguments || {};
1235
+ const tool = MCP_TOOLS.find(item => item.name === name);
1236
+ if (!tool) return rpcError(message.id, -32602, 'Unknown tool: ' + String(name));
1237
+ const validationError = validateArguments(tool, args);
1238
+ if (validationError) {
1239
+ return rpcResult(message.id, { content: [{ type: 'text', text: validationError }], isError: true });
1240
+ }
1241
+ const requiredScope = requiredScopeForCall(name, args);
1242
+ if (!scopes.includes(requiredScope)) {
1243
+ return rpcResult(message.id, {
1244
+ content: [{ type: 'text', text: `Permission denied: ${requiredScope} scope is required` }],
1245
+ isError: true,
1246
+ });
1247
+ }
1248
+
1249
+ try {
1250
+ const toolResponse = await executeMcpTool(name, args, env, config);
1251
+ const data = await toolResponse.json();
1252
+ const isError = !toolResponse.ok || !!data.error;
1253
+ return rpcResult(message.id, {
1254
+ content: [{ type: 'text', text: JSON.stringify(data) }],
1255
+ structuredContent: data,
1256
+ isError,
1257
+ });
1258
+ } catch (error) {
1259
+ return rpcResult(message.id, { content: [{ type: 'text', text: 'Tool execution failed' }], isError: true });
1260
+ }
1261
+ }
1221
1262
 
1222
1263
  async function mcpStatus(env, config) {
1223
1264
  const [moodsP1, moodsP2, latestNote, nextDate] = await Promise.all([
@@ -1328,13 +1369,13 @@ async function mcpPressure(env, params) {
1328
1369
  return json({ error: 'Invalid action. Use: status, history, forecast' });
1329
1370
  }
1330
1371
 
1331
- async function mcpFoodDiaryToday(env, config) {
1372
+ async function mcpFoodDiaryToday(env, config) {
1332
1373
  const [meals, water, review] = await Promise.all([
1333
1374
  env.DB.prepare("SELECT * FROM food_diary WHERE date = date('now') ORDER BY time ASC").all(),
1334
1375
  env.DB.prepare("SELECT SUM(amount_ml) as total FROM water_log WHERE date = date('now')").first(),
1335
1376
  env.DB.prepare("SELECT * FROM food_reviews WHERE date = date('now')").first()
1336
1377
  ]);
1337
- const entries = (meals.results || []).map(m => ({ id: m.id, meal_type: m.meal_type, time: m.time, note: m.note, has_photo: !!m.photo_key }));
1378
+ const entries = (meals.results || []).map(m => ({ id: m.id, meal_type: m.meal_type, time: m.time, note: m.note, has_photo: !!m.photo_key }));
1338
1379
  const mealTypes = entries.map(e => e.meal_type);
1339
1380
  const logged = { breakfast: mealTypes.includes('breakfast'), lunch: mealTypes.includes('lunch'), dinner: mealTypes.includes('dinner') };
1340
1381
  const waterTotal = water ? water.total || 0 : 0;
@@ -1386,7 +1427,7 @@ async function mcpWaterStatus(env) {
1386
1427
 
1387
1428
  /* ========================= PHOTO HANDLERS ========================= */
1388
1429
 
1389
- async function handleFoodPhotoUpload(request, env) {
1430
+ async function handleFoodPhotoUpload(request, env) {
1390
1431
  try {
1391
1432
  const formData = await request.formData();
1392
1433
  const file = formData.get('photo');
@@ -1400,21 +1441,21 @@ async function handleFoodPhotoUpload(request, env) {
1400
1441
  const key = 'food/' + date + '/' + id + '.' + ext;
1401
1442
  await env.PHOTOS.put(key, file.stream(), { httpMetadata: { contentType: file.type } });
1402
1443
  return json({ success: true, key });
1403
- } catch (err) { return json({ error: 'Upload failed' }, 500); }
1404
- }
1444
+ } catch (err) { return json({ error: 'Upload failed' }, 500); }
1445
+ }
1405
1446
 
1406
1447
  async function handleFoodPhotoServe(key, env) {
1407
1448
  try {
1408
1449
  const object = await env.PHOTOS.get(key);
1409
1450
  if (!object) return new Response('Not found', { status: 404 });
1410
- const headers = new Headers();
1411
- headers.set('Content-Type', object.httpMetadata?.contentType || 'image/jpeg');
1412
- headers.set('Cache-Control', 'private, no-store');
1413
- headers.set('X-Content-Type-Options', 'nosniff');
1414
- headers.set('Content-Security-Policy', "default-src 'none'");
1415
- return new Response(object.body, { headers });
1416
- } catch (err) { return new Response('Could not load photo', { status: 500 }); }
1417
- }
1451
+ const headers = new Headers();
1452
+ headers.set('Content-Type', object.httpMetadata?.contentType || 'image/jpeg');
1453
+ headers.set('Cache-Control', 'private, no-store');
1454
+ headers.set('X-Content-Type-Options', 'nosniff');
1455
+ headers.set('Content-Security-Policy', "default-src 'none'");
1456
+ return new Response(object.body, { headers });
1457
+ } catch (err) { return new Response('Could not load photo', { status: 500 }); }
1458
+ }
1418
1459
 
1419
1460
  /* ========================= WEATHER ========================= */
1420
1461
 
@@ -1491,7 +1532,7 @@ async function handleWeather(env) {
1491
1532
  };
1492
1533
  weatherCache = { data, ts: now };
1493
1534
  return json(data);
1494
- } catch (err) { return json({ error: 'Failed to fetch weather' }, 500); }
1535
+ } catch (err) { return json({ error: 'Failed to fetch weather' }, 500); }
1495
1536
  }
1496
1537
 
1497
1538
  /* ========================= PRESSURE HISTORY ========================= */
@@ -1517,54 +1558,54 @@ async function handlePressure(env) {
1517
1558
  current = { pressure: last.pressure_hpa, timestamp: last.recorded_at };
1518
1559
  }
1519
1560
  return json({ history, forecast, current, migraine_days: [] });
1520
- } catch (err) { return json({ error: 'Pressure data error' }, 500); }
1561
+ } catch (err) { return json({ error: 'Pressure data error' }, 500); }
1521
1562
  }
1522
1563
 
1523
1564
  /* ========================= UTILITIES ========================= */
1524
1565
 
1525
- function getCookie(request, name) {
1566
+ function getCookie(request, name) {
1526
1567
  const cookies = request.headers.get('Cookie') || '';
1527
1568
  const match = cookies.match(new RegExp(`${name}=([^;]+)`));
1528
- return match ? match[1] : null;
1529
- }
1530
-
1531
- async function readJsonObject(request) {
1532
- const contentType = request.headers.get('Content-Type') || '';
1533
- if (!contentType.toLowerCase().startsWith('application/json')) {
1534
- throw Object.assign(new Error('Content-Type must be application/json'), { status: 415 });
1535
- }
1536
- const declaredLength = Number(request.headers.get('Content-Length') || 0);
1537
- if (declaredLength > MAX_JSON_BODY_BYTES) throw Object.assign(new Error('Request body is too large'), { status: 413 });
1538
- const text = await request.text();
1539
- if (encoder.encode(text).byteLength > MAX_JSON_BODY_BYTES) throw Object.assign(new Error('Request body is too large'), { status: 413 });
1540
- let value;
1541
- try { value = JSON.parse(text); } catch { throw Object.assign(new Error('Invalid JSON'), { status: 400 }); }
1542
- if (!value || typeof value !== 'object' || Array.isArray(value)) throw Object.assign(new Error('JSON body must be an object'), { status: 400 });
1543
- return value;
1544
- }
1545
-
1546
- function requireText(body, key, maxLength, allowed) {
1547
- const value = body[key];
1548
- if (typeof value !== 'string' || !value.trim()) throw Object.assign(new Error(key + ' is required'), { status: 400 });
1549
- if (value.length > maxLength) throw Object.assign(new Error(key + ' is too long'), { status: 400 });
1550
- if (allowed && !allowed.includes(value)) throw Object.assign(new Error(key + ' is invalid'), { status: 400 });
1551
- return value.trim();
1552
- }
1553
-
1554
- function optionalText(body, key, maxLength) {
1555
- if (body[key] === undefined || body[key] === null || body[key] === '') return null;
1556
- return requireText(body, key, maxLength);
1557
- }
1558
-
1559
- function requireDate(body, key = 'date') {
1560
- const value = requireText(body, key, 10);
1561
- if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) throw Object.assign(new Error(key + ' must use YYYY-MM-DD'), { status: 400 });
1562
- return value;
1563
- }
1564
-
1565
- function json(data, status = 200) {
1566
- return new Response(JSON.stringify(data), {
1567
- status,
1568
- headers: securityHeaders({ 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' }),
1569
- });
1570
- }
1569
+ return match ? match[1] : null;
1570
+ }
1571
+
1572
+ async function readJsonObject(request) {
1573
+ const contentType = request.headers.get('Content-Type') || '';
1574
+ if (!contentType.toLowerCase().startsWith('application/json')) {
1575
+ throw Object.assign(new Error('Content-Type must be application/json'), { status: 415 });
1576
+ }
1577
+ const declaredLength = Number(request.headers.get('Content-Length') || 0);
1578
+ if (declaredLength > MAX_JSON_BODY_BYTES) throw Object.assign(new Error('Request body is too large'), { status: 413 });
1579
+ const text = await request.text();
1580
+ if (encoder.encode(text).byteLength > MAX_JSON_BODY_BYTES) throw Object.assign(new Error('Request body is too large'), { status: 413 });
1581
+ let value;
1582
+ try { value = JSON.parse(text); } catch { throw Object.assign(new Error('Invalid JSON'), { status: 400 }); }
1583
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw Object.assign(new Error('JSON body must be an object'), { status: 400 });
1584
+ return value;
1585
+ }
1586
+
1587
+ function requireText(body, key, maxLength, allowed) {
1588
+ const value = body[key];
1589
+ if (typeof value !== 'string' || !value.trim()) throw Object.assign(new Error(key + ' is required'), { status: 400 });
1590
+ if (value.length > maxLength) throw Object.assign(new Error(key + ' is too long'), { status: 400 });
1591
+ if (allowed && !allowed.includes(value)) throw Object.assign(new Error(key + ' is invalid'), { status: 400 });
1592
+ return value.trim();
1593
+ }
1594
+
1595
+ function optionalText(body, key, maxLength) {
1596
+ if (body[key] === undefined || body[key] === null || body[key] === '') return null;
1597
+ return requireText(body, key, maxLength);
1598
+ }
1599
+
1600
+ function requireDate(body, key = 'date') {
1601
+ const value = requireText(body, key, 10);
1602
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) throw Object.assign(new Error(key + ' must use YYYY-MM-DD'), { status: 400 });
1603
+ return value;
1604
+ }
1605
+
1606
+ function json(data, status = 200) {
1607
+ return new Response(JSON.stringify(data), {
1608
+ status,
1609
+ headers: securityHeaders({ 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' }),
1610
+ });
1611
+ }