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/cli/commands/mcp.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { readFileSync, existsSync, chmodSync } from "node:fs";
|
|
1
|
+
import { readFileSync, existsSync, chmodSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
import { banner, bold, dim, cyan, warn, info } from "../lib/ui.js";
|
|
@@ -14,13 +14,13 @@ export default async function mcpCommand(args) {
|
|
|
14
14
|
}
|
|
15
15
|
|
|
16
16
|
const config = JSON.parse(readFileSync(CONFIG_PATH, "utf-8"));
|
|
17
|
-
const mcpUrl = config.workerUrl + "/mcp";
|
|
17
|
+
const mcpUrl = config.workerUrl + "/mcp";
|
|
18
18
|
|
|
19
19
|
console.log(bold(" MCP Configuration\n"));
|
|
20
20
|
|
|
21
|
-
console.log(` ${bold("Endpoint:")} ${cyan(mcpUrl)}\n`);
|
|
22
|
-
info("Hearth uses OAuth 2.1. Your dashboard password stays out of the connector URL and Claude stores only revocable tokens.");
|
|
23
|
-
console.log();
|
|
21
|
+
console.log(` ${bold("Endpoint:")} ${cyan(mcpUrl)}\n`);
|
|
22
|
+
info("Hearth uses OAuth 2.1. Your dashboard password stays out of the connector URL and Claude stores only revocable tokens.");
|
|
23
|
+
console.log();
|
|
24
24
|
|
|
25
25
|
console.log(` ${bold("For Claude Code")} (add to ${dim("~/.claude.json")}):\n`);
|
|
26
26
|
console.log(` {`);
|
|
@@ -31,10 +31,10 @@ export default async function mcpCommand(args) {
|
|
|
31
31
|
console.log(` }`);
|
|
32
32
|
console.log(` }\n`);
|
|
33
33
|
|
|
34
|
-
console.log(` ${bold("For Claude Desktop")} (Settings > Developer > MCP):\n`);
|
|
34
|
+
console.log(` ${bold("For Claude Desktop")} (Settings > Developer > MCP):\n`);
|
|
35
35
|
console.log(` Name: hearth-dash`);
|
|
36
|
-
console.log(` URL: ${mcpUrl}\n`);
|
|
37
|
-
console.log(` ${dim("When prompted, sign in with the Hearth dashboard password and approve access.")}\n`);
|
|
36
|
+
console.log(` URL: ${mcpUrl}\n`);
|
|
37
|
+
console.log(` ${dim("When prompted, sign in with the Hearth dashboard password and approve access.")}\n`);
|
|
38
38
|
|
|
39
39
|
console.log(` ${bold("Connector URL")} (for Claude.ai mobile):`);
|
|
40
40
|
console.log(` ${cyan(mcpUrl)}\n`);
|
|
@@ -65,9 +65,9 @@ export default async function mcpCommand(args) {
|
|
|
65
65
|
try { claudeConfig = JSON.parse(readFileSync(claudeConfigPath, "utf-8")); } catch {}
|
|
66
66
|
}
|
|
67
67
|
if (!claudeConfig.mcpServers) claudeConfig.mcpServers = {};
|
|
68
|
-
claudeConfig.mcpServers["hearth-dash"] = { url: mcpUrl };
|
|
69
|
-
writeFileSync(claudeConfigPath, JSON.stringify(claudeConfig, null, 2) + "\n", "utf-8");
|
|
70
|
-
try { chmodSync(claudeConfigPath, 0o600); } catch {}
|
|
71
|
-
info("MCP config written to " + claudeConfigPath);
|
|
68
|
+
claudeConfig.mcpServers["hearth-dash"] = { url: mcpUrl };
|
|
69
|
+
writeFileSync(claudeConfigPath, JSON.stringify(claudeConfig, null, 2) + "\n", "utf-8");
|
|
70
|
+
try { chmodSync(claudeConfigPath, 0o600); } catch {}
|
|
71
|
+
info("MCP config written to " + claudeConfigPath);
|
|
72
72
|
}
|
|
73
73
|
}
|
package/cli/index.js
CHANGED
package/cli/lib/wrangler.js
CHANGED
|
@@ -9,7 +9,7 @@ function spawnCmd(cmd, args, opts) {
|
|
|
9
9
|
return spawn(cmd, args, opts);
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
-
export function execWrangler(args, cwd, stdinData) {
|
|
12
|
+
export function execWrangler(args, cwd, stdinData) {
|
|
13
13
|
return new Promise((resolve) => {
|
|
14
14
|
const proc = spawnCmd("npx", ["wrangler", ...args], {
|
|
15
15
|
cwd, stdio: ["pipe", "pipe", "pipe"],
|
|
@@ -22,20 +22,20 @@ export function execWrangler(args, cwd, stdinData) {
|
|
|
22
22
|
else { proc.stdin.end(); }
|
|
23
23
|
proc.on("close", (code) => resolve({ stdout: stdout.trim(), stderr: stderr.trim(), code }));
|
|
24
24
|
});
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
export function execCommand(command, args, cwd) {
|
|
28
|
-
return new Promise((resolve) => {
|
|
29
|
-
const proc = spawnCmd(command, args, {
|
|
30
|
-
cwd, stdio: ['ignore', 'pipe', 'pipe'],
|
|
31
|
-
env: { ...process.env, FORCE_COLOR: '0' },
|
|
32
|
-
});
|
|
33
|
-
let stdout = '', stderr = '';
|
|
34
|
-
proc.stdout.on('data', (data) => { stdout += data.toString(); });
|
|
35
|
-
proc.stderr.on('data', (data) => { stderr += data.toString(); });
|
|
36
|
-
proc.on('close', (code) => resolve({ stdout: stdout.trim(), stderr: stderr.trim(), code }));
|
|
37
|
-
});
|
|
38
|
-
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function execCommand(command, args, cwd) {
|
|
28
|
+
return new Promise((resolve) => {
|
|
29
|
+
const proc = spawnCmd(command, args, {
|
|
30
|
+
cwd, stdio: ['ignore', 'pipe', 'pipe'],
|
|
31
|
+
env: { ...process.env, FORCE_COLOR: '0' },
|
|
32
|
+
});
|
|
33
|
+
let stdout = '', stderr = '';
|
|
34
|
+
proc.stdout.on('data', (data) => { stdout += data.toString(); });
|
|
35
|
+
proc.stderr.on('data', (data) => { stderr += data.toString(); });
|
|
36
|
+
proc.on('close', (code) => resolve({ stdout: stdout.trim(), stderr: stderr.trim(), code }));
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
39
|
|
|
40
40
|
export function parseD1CreateOutput(output) {
|
|
41
41
|
const combined = output.stdout + "\n" + output.stderr;
|
|
@@ -79,22 +79,22 @@ export async function executeSchema(dbName, schemaPath, cwd) {
|
|
|
79
79
|
return { ok: true };
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
-
export async function listD1Databases(cwd) {
|
|
82
|
+
export async function listD1Databases(cwd) {
|
|
83
83
|
const result = await execWrangler(["d1", "list", "--json"], cwd);
|
|
84
84
|
if (result.code !== 0) return [];
|
|
85
85
|
try { return JSON.parse(result.stdout); } catch { return []; }
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
export async function listKvNamespaces(cwd) {
|
|
89
|
-
const result = await execWrangler(['kv', 'namespace', 'list'], cwd);
|
|
90
|
-
if (result.code !== 0) return [];
|
|
91
|
-
try { return JSON.parse(result.stdout); } catch { return []; }
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
export function parseKvCreateOutput(output) {
|
|
95
|
-
const combined = output.stdout + '\n' + output.stderr;
|
|
96
|
-
const toml = combined.match(/id\s*=\s*"([a-f0-9]{32})"/i);
|
|
97
|
-
if (toml) return toml[1];
|
|
98
|
-
const json = combined.match(/"id"\s*:\s*"([a-f0-9]{32})"/i);
|
|
99
|
-
return json ? json[1] : null;
|
|
100
|
-
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export async function listKvNamespaces(cwd) {
|
|
89
|
+
const result = await execWrangler(['kv', 'namespace', 'list'], cwd);
|
|
90
|
+
if (result.code !== 0) return [];
|
|
91
|
+
try { return JSON.parse(result.stdout); } catch { return []; }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function parseKvCreateOutput(output) {
|
|
95
|
+
const combined = output.stdout + '\n' + output.stderr;
|
|
96
|
+
const toml = combined.match(/id\s*=\s*"([a-f0-9]{32})"/i);
|
|
97
|
+
if (toml) return toml[1];
|
|
98
|
+
const json = combined.match(/"id"\s*:\s*"([a-f0-9]{32})"/i);
|
|
99
|
+
return json ? json[1] : null;
|
|
100
|
+
}
|
package/oauth-entry.js
CHANGED
|
@@ -1,41 +1,47 @@
|
|
|
1
|
-
import { OAuthProvider } from '@cloudflare/workers-oauth-provider';
|
|
2
|
-
import { oauthApiHandler, oauthDefaultHandler, withinRateLimit } from './worker.js';
|
|
3
|
-
|
|
4
|
-
const OAUTH_SCOPES = ['hearth:read', 'hearth:write'];
|
|
5
|
-
|
|
6
|
-
function createOAuthProvider(request, env) {
|
|
7
|
-
const origin = new URL(request.url).origin;
|
|
8
|
-
const resource = `${origin}/mcp`;
|
|
9
|
-
return new OAuthProvider({
|
|
10
|
-
apiRoute: '/mcp',
|
|
11
|
-
apiHandler: oauthApiHandler,
|
|
12
|
-
defaultHandler: oauthDefaultHandler,
|
|
13
|
-
authorizeEndpoint: '/authorize',
|
|
14
|
-
tokenEndpoint: '/oauth/token',
|
|
15
|
-
clientRegistrationEndpoint: '/oauth/register',
|
|
16
|
-
async clientRegistrationCallback({ request: registrationRequest }) {
|
|
17
|
-
const allowed = await withinRateLimit(env, registrationRequest, 'oauth-register', 20, 3600);
|
|
18
|
-
if (!allowed) {
|
|
19
|
-
return { code: 'access_denied', description: 'Too many client registrations. Try again later.', status: 429 };
|
|
20
|
-
}
|
|
21
|
-
},
|
|
22
|
-
clientIdMetadataDocumentEnabled: true,
|
|
23
|
-
scopesSupported: OAUTH_SCOPES,
|
|
24
|
-
accessTokenTTL: 3600,
|
|
25
|
-
refreshTokenTTL: 30 * 24 * 60 * 60,
|
|
26
|
-
clientRegistrationTTL: 90 * 24 * 60 * 60,
|
|
27
|
-
resourceMetadata: {
|
|
28
|
-
resource,
|
|
29
|
-
authorization_servers: [origin],
|
|
30
|
-
scopes_supported: ['hearth:read'],
|
|
31
|
-
bearer_methods_supported: ['header'],
|
|
32
|
-
resource_name: 'Hearth Dash',
|
|
33
|
-
},
|
|
34
|
-
});
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
export default {
|
|
38
|
-
fetch(request, env, ctx) {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
1
|
+
import { OAuthProvider } from '@cloudflare/workers-oauth-provider';
|
|
2
|
+
import { applicationHandler, isOAuthRoute, oauthApiHandler, oauthDefaultHandler, withinRateLimit } from './worker.js';
|
|
3
|
+
|
|
4
|
+
const OAUTH_SCOPES = ['hearth:read', 'hearth:write'];
|
|
5
|
+
|
|
6
|
+
function createOAuthProvider(request, env) {
|
|
7
|
+
const origin = new URL(request.url).origin;
|
|
8
|
+
const resource = `${origin}/mcp`;
|
|
9
|
+
return new OAuthProvider({
|
|
10
|
+
apiRoute: '/mcp',
|
|
11
|
+
apiHandler: oauthApiHandler,
|
|
12
|
+
defaultHandler: oauthDefaultHandler,
|
|
13
|
+
authorizeEndpoint: '/authorize',
|
|
14
|
+
tokenEndpoint: '/oauth/token',
|
|
15
|
+
clientRegistrationEndpoint: '/oauth/register',
|
|
16
|
+
async clientRegistrationCallback({ request: registrationRequest }) {
|
|
17
|
+
const allowed = await withinRateLimit(env, registrationRequest, 'oauth-register', 20, 3600);
|
|
18
|
+
if (!allowed) {
|
|
19
|
+
return { code: 'access_denied', description: 'Too many client registrations. Try again later.', status: 429 };
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
clientIdMetadataDocumentEnabled: true,
|
|
23
|
+
scopesSupported: OAUTH_SCOPES,
|
|
24
|
+
accessTokenTTL: 3600,
|
|
25
|
+
refreshTokenTTL: 30 * 24 * 60 * 60,
|
|
26
|
+
clientRegistrationTTL: 90 * 24 * 60 * 60,
|
|
27
|
+
resourceMetadata: {
|
|
28
|
+
resource,
|
|
29
|
+
authorization_servers: [origin],
|
|
30
|
+
scopes_supported: ['hearth:read'],
|
|
31
|
+
bearer_methods_supported: ['header'],
|
|
32
|
+
resource_name: 'Hearth Dash',
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export default {
|
|
38
|
+
fetch(request, env, ctx) {
|
|
39
|
+
// Keep ordinary dashboard traffic out of the OAuth provider. Besides being
|
|
40
|
+
// unnecessary, wrapping /login and /api requests can obscure the browser's
|
|
41
|
+
// public origin on some Cloudflare routes and make valid CSRF checks fail.
|
|
42
|
+
if (!isOAuthRoute(new URL(request.url).pathname)) {
|
|
43
|
+
return applicationHandler.fetch(request, env, ctx);
|
|
44
|
+
}
|
|
45
|
+
return createOAuthProvider(request, env).fetch(request, env, ctx);
|
|
46
|
+
},
|
|
47
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hearth-dash",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.2",
|
|
4
4
|
"description": "Personal dashboard — moods, moments, food diary, weather, and more. Deploy to Cloudflare in one command.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -28,8 +28,8 @@
|
|
|
28
28
|
"type": "git",
|
|
29
29
|
"url": "git+https://github.com/martusha89/hearth-dash.git"
|
|
30
30
|
},
|
|
31
|
-
"engines": {
|
|
32
|
-
"node": ">=22"
|
|
31
|
+
"engines": {
|
|
32
|
+
"node": ">=22"
|
|
33
33
|
},
|
|
34
34
|
"files": [
|
|
35
35
|
"worker.js",
|
package/schema.sql
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
-- Hearth Dash — Database Schema
|
|
2
2
|
-- Deploy: npx wrangler d1 execute hearth-dash-db --file schema.sql
|
|
3
3
|
|
|
4
|
-
CREATE TABLE IF NOT EXISTS config (
|
|
5
|
-
key TEXT PRIMARY KEY,
|
|
6
|
-
value TEXT NOT NULL
|
|
7
|
-
);
|
|
8
|
-
|
|
9
|
-
-- v1.0.1's first-visit setup stored the dashboard password as plaintext.
|
|
10
|
-
-- Authentication now uses Cloudflare Worker secrets; remove any legacy copy.
|
|
11
|
-
DELETE FROM config WHERE key = 'password';
|
|
4
|
+
CREATE TABLE IF NOT EXISTS config (
|
|
5
|
+
key TEXT PRIMARY KEY,
|
|
6
|
+
value TEXT NOT NULL
|
|
7
|
+
);
|
|
8
|
+
|
|
9
|
+
-- v1.0.1's first-visit setup stored the dashboard password as plaintext.
|
|
10
|
+
-- Authentication now uses Cloudflare Worker secrets; remove any legacy copy.
|
|
11
|
+
DELETE FROM config WHERE key = 'password';
|
|
12
12
|
|
|
13
13
|
CREATE TABLE IF NOT EXISTS moods (
|
|
14
14
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
@@ -77,19 +77,19 @@ CREATE TABLE IF NOT EXISTS water_log (
|
|
|
77
77
|
);
|
|
78
78
|
CREATE INDEX IF NOT EXISTS idx_water_log_date ON water_log(date);
|
|
79
79
|
|
|
80
|
-
CREATE TABLE IF NOT EXISTS food_reviews (
|
|
80
|
+
CREATE TABLE IF NOT EXISTS food_reviews (
|
|
81
81
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
82
82
|
date TEXT UNIQUE NOT NULL,
|
|
83
83
|
review TEXT NOT NULL,
|
|
84
84
|
reviewer TEXT NOT NULL DEFAULT 'AI',
|
|
85
85
|
created_at TEXT DEFAULT (datetime('now'))
|
|
86
86
|
);
|
|
87
|
-
CREATE INDEX IF NOT EXISTS idx_food_reviews_date ON food_reviews(date);
|
|
88
|
-
|
|
89
|
-
-- Coarse per-IP request buckets. IP addresses are hashed before storage.
|
|
90
|
-
CREATE TABLE IF NOT EXISTS rate_limits (
|
|
91
|
-
bucket_key TEXT PRIMARY KEY,
|
|
92
|
-
count INTEGER NOT NULL DEFAULT 1,
|
|
93
|
-
expires_at INTEGER NOT NULL
|
|
94
|
-
);
|
|
95
|
-
CREATE INDEX IF NOT EXISTS idx_rate_limits_expires ON rate_limits(expires_at);
|
|
87
|
+
CREATE INDEX IF NOT EXISTS idx_food_reviews_date ON food_reviews(date);
|
|
88
|
+
|
|
89
|
+
-- Coarse per-IP request buckets. IP addresses are hashed before storage.
|
|
90
|
+
CREATE TABLE IF NOT EXISTS rate_limits (
|
|
91
|
+
bucket_key TEXT PRIMARY KEY,
|
|
92
|
+
count INTEGER NOT NULL DEFAULT 1,
|
|
93
|
+
expires_at INTEGER NOT NULL
|
|
94
|
+
);
|
|
95
|
+
CREATE INDEX IF NOT EXISTS idx_rate_limits_expires ON rate_limits(expires_at);
|