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/README.md +149 -141
- package/cli/commands/config.js +1 -1
- package/cli/commands/deploy.js +107 -107
- package/cli/commands/mcp.js +12 -12
- package/cli/index.js +1 -1
- package/cli/lib/wrangler.js +31 -31
- package/oauth-entry.js +47 -41
- package/package.json +3 -3
- package/schema.sql +18 -18
- package/worker.js +682 -641
- package/wrangler.toml +10 -10
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
if (
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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">—</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">—</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">—</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">—</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">😊 Great</option><option value="good">🙂 Good</option><option value="okay">😐 Okay</option><option value="tired">😴 Tired</option><option value="stressed">😰 Stressed</option><option value="low">😔 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">😊 Great</option><option value="good">🙂 Good</option><option value="okay">😐 Okay</option><option value="tired">😴 Tired</option><option value="stressed">😰 Stressed</option><option value="low">😔 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
|
-
|
|
799
|
-
|
|
800
|
-
|
|
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
|
+
}
|