hearth-dash 1.1.2 → 1.1.3
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/LICENSE +32 -32
- package/README.md +149 -143
- package/cli/commands/config.js +26 -26
- package/cli/commands/deploy.js +271 -262
- package/cli/commands/mcp.js +73 -73
- package/cli/index.js +63 -63
- package/cli/lib/platform.js +12 -12
- package/cli/lib/prompts.js +44 -44
- package/cli/lib/ui.js +41 -41
- package/cli/lib/wrangler.js +150 -100
- package/oauth-entry.js +47 -47
- package/package.json +47 -47
- package/schema.sql +104 -95
- package/worker.js +1667 -1577
- package/wrangler.toml +28 -28
package/cli/lib/wrangler.js
CHANGED
|
@@ -1,100 +1,150 @@
|
|
|
1
|
-
import { spawn } from "node:child_process";
|
|
2
|
-
import { readFileSync } from "node:fs";
|
|
3
|
-
|
|
4
|
-
function spawnCmd(cmd, args, opts) {
|
|
5
|
-
if (process.platform === "win32") {
|
|
6
|
-
const full = [cmd, ...args].map((a) => a.includes(" ") ? `"${a}"` : a).join(" ");
|
|
7
|
-
return spawn(full, [], { ...opts, shell: true });
|
|
8
|
-
}
|
|
9
|
-
return spawn(cmd, args, opts);
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
export function execWrangler(args, cwd, stdinData) {
|
|
13
|
-
return new Promise((resolve) => {
|
|
14
|
-
const proc = spawnCmd("npx", ["wrangler", ...args], {
|
|
15
|
-
cwd, stdio: ["pipe", "pipe", "pipe"],
|
|
16
|
-
env: { ...process.env, FORCE_COLOR: "0" },
|
|
17
|
-
});
|
|
18
|
-
let stdout = "", stderr = "";
|
|
19
|
-
proc.stdout.on("data", (d) => { stdout += d.toString(); });
|
|
20
|
-
proc.stderr.on("data", (d) => { stderr += d.toString(); });
|
|
21
|
-
if (stdinData) { proc.stdin.write(stdinData); proc.stdin.end(); }
|
|
22
|
-
else { proc.stdin.end(); }
|
|
23
|
-
proc.on("close", (code) => resolve({ stdout: stdout.trim(), stderr: stderr.trim(), code }));
|
|
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
|
-
}
|
|
39
|
-
|
|
40
|
-
export function parseD1CreateOutput(output) {
|
|
41
|
-
const combined = output.stdout + "\n" + output.stderr;
|
|
42
|
-
const match = combined.match(/database_id\s*=\s*"([^"]+)"/);
|
|
43
|
-
return match ? match[1] : null;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
export function parseDeployOutput(output) {
|
|
47
|
-
const combined = output.stdout + "\n" + output.stderr;
|
|
48
|
-
const match = combined.match(/https:\/\/[^\s)]+\.workers\.dev/);
|
|
49
|
-
return match ? match[0] : null;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
export async function checkWranglerAuth(cwd) {
|
|
53
|
-
const result = await execWrangler(["whoami"], cwd);
|
|
54
|
-
if (result.code !== 0) return false;
|
|
55
|
-
const combined = result.stdout + result.stderr;
|
|
56
|
-
return !combined.includes("Not logged in") && !combined.includes("not authenticated");
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
export async function wranglerLogin(cwd) {
|
|
60
|
-
return new Promise((resolve) => {
|
|
61
|
-
const proc = spawnCmd("npx", ["wrangler", "login"], { cwd, stdio: "inherit" });
|
|
62
|
-
proc.on("close", (code) => resolve(code === 0));
|
|
63
|
-
});
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
export async function setSecret(name, value, cwd) {
|
|
67
|
-
return execWrangler(["secret", "put", name], cwd, value + "\n");
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
export async function executeSchema(dbName, schemaPath, cwd) {
|
|
71
|
-
const result = await execWrangler(["d1", "execute", dbName, "--remote", "--file=" + schemaPath], cwd);
|
|
72
|
-
if (result.code === 0) return { ok: true };
|
|
73
|
-
const sql = readFileSync(schemaPath, "utf-8");
|
|
74
|
-
const statements = sql.split(";").map((s) => s.trim()).filter((s) => s.length > 0);
|
|
75
|
-
for (const stmt of statements) {
|
|
76
|
-
const r = await execWrangler(["d1", "execute", dbName, "--remote", "--command", stmt + ";"], cwd);
|
|
77
|
-
if (r.code !== 0) return { ok: false, error: r.stderr || r.stdout };
|
|
78
|
-
}
|
|
79
|
-
return { ok: true };
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
export
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
|
|
4
|
+
function spawnCmd(cmd, args, opts) {
|
|
5
|
+
if (process.platform === "win32") {
|
|
6
|
+
const full = [cmd, ...args].map((a) => a.includes(" ") ? `"${a}"` : a).join(" ");
|
|
7
|
+
return spawn(full, [], { ...opts, shell: true });
|
|
8
|
+
}
|
|
9
|
+
return spawn(cmd, args, opts);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function execWrangler(args, cwd, stdinData) {
|
|
13
|
+
return new Promise((resolve) => {
|
|
14
|
+
const proc = spawnCmd("npx", ["wrangler", ...args], {
|
|
15
|
+
cwd, stdio: ["pipe", "pipe", "pipe"],
|
|
16
|
+
env: { ...process.env, FORCE_COLOR: "0" },
|
|
17
|
+
});
|
|
18
|
+
let stdout = "", stderr = "";
|
|
19
|
+
proc.stdout.on("data", (d) => { stdout += d.toString(); });
|
|
20
|
+
proc.stderr.on("data", (d) => { stderr += d.toString(); });
|
|
21
|
+
if (stdinData) { proc.stdin.write(stdinData); proc.stdin.end(); }
|
|
22
|
+
else { proc.stdin.end(); }
|
|
23
|
+
proc.on("close", (code) => resolve({ stdout: stdout.trim(), stderr: stderr.trim(), code }));
|
|
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
|
+
}
|
|
39
|
+
|
|
40
|
+
export function parseD1CreateOutput(output) {
|
|
41
|
+
const combined = output.stdout + "\n" + output.stderr;
|
|
42
|
+
const match = combined.match(/database_id\s*=\s*"([^"]+)"/);
|
|
43
|
+
return match ? match[1] : null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function parseDeployOutput(output) {
|
|
47
|
+
const combined = output.stdout + "\n" + output.stderr;
|
|
48
|
+
const match = combined.match(/https:\/\/[^\s)]+\.workers\.dev/);
|
|
49
|
+
return match ? match[0] : null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function checkWranglerAuth(cwd) {
|
|
53
|
+
const result = await execWrangler(["whoami"], cwd);
|
|
54
|
+
if (result.code !== 0) return false;
|
|
55
|
+
const combined = result.stdout + result.stderr;
|
|
56
|
+
return !combined.includes("Not logged in") && !combined.includes("not authenticated");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function wranglerLogin(cwd) {
|
|
60
|
+
return new Promise((resolve) => {
|
|
61
|
+
const proc = spawnCmd("npx", ["wrangler", "login"], { cwd, stdio: "inherit" });
|
|
62
|
+
proc.on("close", (code) => resolve(code === 0));
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function setSecret(name, value, cwd) {
|
|
67
|
+
return execWrangler(["secret", "put", name], cwd, value + "\n");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function executeSchema(dbName, schemaPath, cwd) {
|
|
71
|
+
const result = await execWrangler(["d1", "execute", dbName, "--remote", "--file=" + schemaPath], cwd);
|
|
72
|
+
if (result.code === 0) return { ok: true };
|
|
73
|
+
const sql = readFileSync(schemaPath, "utf-8");
|
|
74
|
+
const statements = sql.split(";").map((s) => s.trim()).filter((s) => s.length > 0);
|
|
75
|
+
for (const stmt of statements) {
|
|
76
|
+
const r = await execWrangler(["d1", "execute", dbName, "--remote", "--command", stmt + ";"], cwd);
|
|
77
|
+
if (r.code !== 0) return { ok: false, error: r.stderr || r.stdout };
|
|
78
|
+
}
|
|
79
|
+
return { ok: true };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function hasRequiredSchemaTables(output, requiredTables) {
|
|
83
|
+
try {
|
|
84
|
+
const parsed = JSON.parse(output);
|
|
85
|
+
const envelopes = Array.isArray(parsed) ? parsed : [parsed];
|
|
86
|
+
const names = new Set(envelopes.flatMap(item => item?.results || []).map(row => row.name));
|
|
87
|
+
return requiredTables.every(name => names.has(name));
|
|
88
|
+
} catch {
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function hasRequiredOAuthCsrfColumns(output) {
|
|
94
|
+
try {
|
|
95
|
+
const parsed = JSON.parse(output);
|
|
96
|
+
const envelopes = Array.isArray(parsed) ? parsed : [parsed];
|
|
97
|
+
const columns = new Map(envelopes.flatMap(item => item?.results || []).map(row => [row.name, row]));
|
|
98
|
+
const token = columns.get('token');
|
|
99
|
+
const fingerprint = columns.get('request_fingerprint');
|
|
100
|
+
const expires = columns.get('expires_at');
|
|
101
|
+
return columns.size === 3
|
|
102
|
+
&& String(token?.type).toUpperCase() === 'TEXT' && Number(token?.pk) === 1
|
|
103
|
+
&& String(fingerprint?.type).toUpperCase() === 'TEXT' && Number(fingerprint?.notnull) === 1
|
|
104
|
+
&& String(expires?.type).toUpperCase() === 'INTEGER' && Number(expires?.notnull) === 1;
|
|
105
|
+
} catch {
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export async function verifySecuritySchema(dbName, cwd) {
|
|
111
|
+
const requiredTables = ['rate_limits', 'oauth_csrf_tokens'];
|
|
112
|
+
const quoted = requiredTables.map(name => `'${name.replaceAll("'", "''")}'`).join(', ');
|
|
113
|
+
const query = `SELECT name FROM sqlite_master WHERE type = 'table' AND name IN (${quoted}) ORDER BY name;`;
|
|
114
|
+
const tables = await execWrangler(['d1', 'execute', dbName, '--remote', '--command', query, '--json'], cwd);
|
|
115
|
+
if (tables.code !== 0 || !hasRequiredSchemaTables(tables.stdout, requiredTables)) return false;
|
|
116
|
+
const columns = await execWrangler([
|
|
117
|
+
'd1', 'execute', dbName, '--remote', '--command', 'PRAGMA table_info(oauth_csrf_tokens);', '--json',
|
|
118
|
+
], cwd);
|
|
119
|
+
return columns.code === 0 && hasRequiredOAuthCsrfColumns(columns.stdout);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export async function provisionAfterVerifiedSchema({ applySchema, verifySchema, provisionWorker, onSchemaVerified = () => {} }) {
|
|
123
|
+
const schemaResult = await applySchema();
|
|
124
|
+
if (!schemaResult.ok) return { ok: false, stage: 'schema', schemaResult };
|
|
125
|
+
const schemaVerified = await verifySchema();
|
|
126
|
+
if (!schemaVerified) return { ok: false, stage: 'verification', schemaResult };
|
|
127
|
+
onSchemaVerified();
|
|
128
|
+
const provisionResult = await provisionWorker();
|
|
129
|
+
return { ok: provisionResult.code === 0, stage: 'provision', schemaResult, provisionResult };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export async function listD1Databases(cwd) {
|
|
133
|
+
const result = await execWrangler(["d1", "list", "--json"], cwd);
|
|
134
|
+
if (result.code !== 0) return [];
|
|
135
|
+
try { return JSON.parse(result.stdout); } catch { return []; }
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export async function listKvNamespaces(cwd) {
|
|
139
|
+
const result = await execWrangler(['kv', 'namespace', 'list'], cwd);
|
|
140
|
+
if (result.code !== 0) return [];
|
|
141
|
+
try { return JSON.parse(result.stdout); } catch { return []; }
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function parseKvCreateOutput(output) {
|
|
145
|
+
const combined = output.stdout + '\n' + output.stderr;
|
|
146
|
+
const toml = combined.match(/id\s*=\s*"([a-f0-9]{32})"/i);
|
|
147
|
+
if (toml) return toml[1];
|
|
148
|
+
const json = combined.match(/"id"\s*:\s*"([a-f0-9]{32})"/i);
|
|
149
|
+
return json ? json[1] : null;
|
|
150
|
+
}
|
package/oauth-entry.js
CHANGED
|
@@ -1,47 +1,47 @@
|
|
|
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
|
-
};
|
|
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,47 +1,47 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "hearth-dash",
|
|
3
|
-
"version": "1.1.
|
|
4
|
-
"description": "Personal dashboard — moods, moments, food diary, weather, and more. Deploy to Cloudflare in one command.",
|
|
5
|
-
"type": "module",
|
|
6
|
-
"bin": {
|
|
7
|
-
"hearth-dash": "cli/index.js"
|
|
8
|
-
},
|
|
9
|
-
"scripts": {
|
|
10
|
-
"test": "node --test",
|
|
11
|
-
"dev": "npx wrangler dev",
|
|
12
|
-
"deploy": "npx wrangler deploy",
|
|
13
|
-
"db:init": "npx wrangler d1 execute hearth-dash-db --file schema.sql",
|
|
14
|
-
"db:init:remote": "npx wrangler d1 execute hearth-dash-db --remote --file schema.sql"
|
|
15
|
-
},
|
|
16
|
-
"keywords": [
|
|
17
|
-
"dashboard",
|
|
18
|
-
"cloudflare-workers",
|
|
19
|
-
"mcp",
|
|
20
|
-
"mood-tracker",
|
|
21
|
-
"food-diary",
|
|
22
|
-
"weather",
|
|
23
|
-
"personal"
|
|
24
|
-
],
|
|
25
|
-
"author": "AIDHD",
|
|
26
|
-
"license": "SEE LICENSE IN LICENSE",
|
|
27
|
-
"repository": {
|
|
28
|
-
"type": "git",
|
|
29
|
-
"url": "git+https://github.com/martusha89/hearth-dash.git"
|
|
30
|
-
},
|
|
31
|
-
"engines": {
|
|
32
|
-
"node": ">=22"
|
|
33
|
-
},
|
|
34
|
-
"files": [
|
|
35
|
-
"worker.js",
|
|
36
|
-
"oauth-entry.js",
|
|
37
|
-
"schema.sql",
|
|
38
|
-
"wrangler.toml",
|
|
39
|
-
"cli/",
|
|
40
|
-
"README.md",
|
|
41
|
-
"LICENSE"
|
|
42
|
-
],
|
|
43
|
-
"dependencies": {
|
|
44
|
-
"@cloudflare/workers-oauth-provider": "0.10.3",
|
|
45
|
-
"wrangler": "4.135.0"
|
|
46
|
-
}
|
|
47
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "hearth-dash",
|
|
3
|
+
"version": "1.1.3",
|
|
4
|
+
"description": "Personal dashboard — moods, moments, food diary, weather, and more. Deploy to Cloudflare in one command.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"hearth-dash": "cli/index.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"test": "node --test",
|
|
11
|
+
"dev": "npx wrangler dev",
|
|
12
|
+
"deploy": "npx wrangler deploy",
|
|
13
|
+
"db:init": "npx wrangler d1 execute hearth-dash-db --file schema.sql",
|
|
14
|
+
"db:init:remote": "npx wrangler d1 execute hearth-dash-db --remote --file schema.sql"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"dashboard",
|
|
18
|
+
"cloudflare-workers",
|
|
19
|
+
"mcp",
|
|
20
|
+
"mood-tracker",
|
|
21
|
+
"food-diary",
|
|
22
|
+
"weather",
|
|
23
|
+
"personal"
|
|
24
|
+
],
|
|
25
|
+
"author": "AIDHD",
|
|
26
|
+
"license": "SEE LICENSE IN LICENSE",
|
|
27
|
+
"repository": {
|
|
28
|
+
"type": "git",
|
|
29
|
+
"url": "git+https://github.com/martusha89/hearth-dash.git"
|
|
30
|
+
},
|
|
31
|
+
"engines": {
|
|
32
|
+
"node": ">=22"
|
|
33
|
+
},
|
|
34
|
+
"files": [
|
|
35
|
+
"worker.js",
|
|
36
|
+
"oauth-entry.js",
|
|
37
|
+
"schema.sql",
|
|
38
|
+
"wrangler.toml",
|
|
39
|
+
"cli/",
|
|
40
|
+
"README.md",
|
|
41
|
+
"LICENSE"
|
|
42
|
+
],
|
|
43
|
+
"dependencies": {
|
|
44
|
+
"@cloudflare/workers-oauth-provider": "0.10.3",
|
|
45
|
+
"wrangler": "4.135.0"
|
|
46
|
+
}
|
|
47
|
+
}
|
package/schema.sql
CHANGED
|
@@ -1,95 +1,104 @@
|
|
|
1
|
-
-- Hearth Dash — Database Schema
|
|
2
|
-
-- Deploy: npx wrangler d1 execute hearth-dash-db --file schema.sql
|
|
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';
|
|
12
|
-
|
|
13
|
-
CREATE TABLE IF NOT EXISTS moods (
|
|
14
|
-
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
15
|
-
partner TEXT NOT NULL,
|
|
16
|
-
mood TEXT NOT NULL,
|
|
17
|
-
note TEXT,
|
|
18
|
-
created_at TEXT NOT NULL
|
|
19
|
-
);
|
|
20
|
-
|
|
21
|
-
CREATE TABLE IF NOT EXISTS notes (
|
|
22
|
-
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
23
|
-
from_partner TEXT NOT NULL,
|
|
24
|
-
content TEXT NOT NULL,
|
|
25
|
-
created_at TEXT NOT NULL
|
|
26
|
-
);
|
|
27
|
-
|
|
28
|
-
CREATE TABLE IF NOT EXISTS moments (
|
|
29
|
-
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
30
|
-
date TEXT NOT NULL,
|
|
31
|
-
title TEXT NOT NULL,
|
|
32
|
-
description TEXT,
|
|
33
|
-
created_at TEXT NOT NULL
|
|
34
|
-
);
|
|
35
|
-
|
|
36
|
-
CREATE TABLE IF NOT EXISTS dates (
|
|
37
|
-
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
38
|
-
date TEXT NOT NULL,
|
|
39
|
-
title TEXT NOT NULL,
|
|
40
|
-
recurring INTEGER DEFAULT 0,
|
|
41
|
-
created_at TEXT NOT NULL
|
|
42
|
-
);
|
|
43
|
-
|
|
44
|
-
CREATE TABLE IF NOT EXISTS shopping (
|
|
45
|
-
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
46
|
-
item TEXT NOT NULL,
|
|
47
|
-
category TEXT DEFAULT 'Other',
|
|
48
|
-
checked INTEGER DEFAULT 0,
|
|
49
|
-
added_by TEXT NOT NULL,
|
|
50
|
-
created_at TEXT NOT NULL
|
|
51
|
-
);
|
|
52
|
-
|
|
53
|
-
CREATE TABLE IF NOT EXISTS pressure_log (
|
|
54
|
-
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
55
|
-
pressure_hpa REAL NOT NULL,
|
|
56
|
-
temp REAL,
|
|
57
|
-
recorded_at TEXT NOT NULL
|
|
58
|
-
);
|
|
59
|
-
CREATE INDEX IF NOT EXISTS idx_pressure_recorded ON pressure_log(recorded_at);
|
|
60
|
-
|
|
61
|
-
CREATE TABLE IF NOT EXISTS food_diary (
|
|
62
|
-
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
63
|
-
date TEXT NOT NULL,
|
|
64
|
-
time TEXT NOT NULL,
|
|
65
|
-
meal_type TEXT NOT NULL,
|
|
66
|
-
note TEXT,
|
|
67
|
-
photo_key TEXT,
|
|
68
|
-
created_at TEXT DEFAULT (datetime('now'))
|
|
69
|
-
);
|
|
70
|
-
CREATE INDEX IF NOT EXISTS idx_food_diary_date ON food_diary(date);
|
|
71
|
-
|
|
72
|
-
CREATE TABLE IF NOT EXISTS water_log (
|
|
73
|
-
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
74
|
-
date TEXT NOT NULL,
|
|
75
|
-
amount_ml INTEGER NOT NULL,
|
|
76
|
-
created_at TEXT DEFAULT (datetime('now'))
|
|
77
|
-
);
|
|
78
|
-
CREATE INDEX IF NOT EXISTS idx_water_log_date ON water_log(date);
|
|
79
|
-
|
|
80
|
-
CREATE TABLE IF NOT EXISTS food_reviews (
|
|
81
|
-
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
82
|
-
date TEXT UNIQUE NOT NULL,
|
|
83
|
-
review TEXT NOT NULL,
|
|
84
|
-
reviewer TEXT NOT NULL DEFAULT 'AI',
|
|
85
|
-
created_at TEXT DEFAULT (datetime('now'))
|
|
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);
|
|
1
|
+
-- Hearth Dash — Database Schema
|
|
2
|
+
-- Deploy: npx wrangler d1 execute hearth-dash-db --file schema.sql
|
|
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';
|
|
12
|
+
|
|
13
|
+
CREATE TABLE IF NOT EXISTS moods (
|
|
14
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
15
|
+
partner TEXT NOT NULL,
|
|
16
|
+
mood TEXT NOT NULL,
|
|
17
|
+
note TEXT,
|
|
18
|
+
created_at TEXT NOT NULL
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
CREATE TABLE IF NOT EXISTS notes (
|
|
22
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
23
|
+
from_partner TEXT NOT NULL,
|
|
24
|
+
content TEXT NOT NULL,
|
|
25
|
+
created_at TEXT NOT NULL
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
CREATE TABLE IF NOT EXISTS moments (
|
|
29
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
30
|
+
date TEXT NOT NULL,
|
|
31
|
+
title TEXT NOT NULL,
|
|
32
|
+
description TEXT,
|
|
33
|
+
created_at TEXT NOT NULL
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
CREATE TABLE IF NOT EXISTS dates (
|
|
37
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
38
|
+
date TEXT NOT NULL,
|
|
39
|
+
title TEXT NOT NULL,
|
|
40
|
+
recurring INTEGER DEFAULT 0,
|
|
41
|
+
created_at TEXT NOT NULL
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
CREATE TABLE IF NOT EXISTS shopping (
|
|
45
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
46
|
+
item TEXT NOT NULL,
|
|
47
|
+
category TEXT DEFAULT 'Other',
|
|
48
|
+
checked INTEGER DEFAULT 0,
|
|
49
|
+
added_by TEXT NOT NULL,
|
|
50
|
+
created_at TEXT NOT NULL
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
CREATE TABLE IF NOT EXISTS pressure_log (
|
|
54
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
55
|
+
pressure_hpa REAL NOT NULL,
|
|
56
|
+
temp REAL,
|
|
57
|
+
recorded_at TEXT NOT NULL
|
|
58
|
+
);
|
|
59
|
+
CREATE INDEX IF NOT EXISTS idx_pressure_recorded ON pressure_log(recorded_at);
|
|
60
|
+
|
|
61
|
+
CREATE TABLE IF NOT EXISTS food_diary (
|
|
62
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
63
|
+
date TEXT NOT NULL,
|
|
64
|
+
time TEXT NOT NULL,
|
|
65
|
+
meal_type TEXT NOT NULL,
|
|
66
|
+
note TEXT,
|
|
67
|
+
photo_key TEXT,
|
|
68
|
+
created_at TEXT DEFAULT (datetime('now'))
|
|
69
|
+
);
|
|
70
|
+
CREATE INDEX IF NOT EXISTS idx_food_diary_date ON food_diary(date);
|
|
71
|
+
|
|
72
|
+
CREATE TABLE IF NOT EXISTS water_log (
|
|
73
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
74
|
+
date TEXT NOT NULL,
|
|
75
|
+
amount_ml INTEGER NOT NULL,
|
|
76
|
+
created_at TEXT DEFAULT (datetime('now'))
|
|
77
|
+
);
|
|
78
|
+
CREATE INDEX IF NOT EXISTS idx_water_log_date ON water_log(date);
|
|
79
|
+
|
|
80
|
+
CREATE TABLE IF NOT EXISTS food_reviews (
|
|
81
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
82
|
+
date TEXT UNIQUE NOT NULL,
|
|
83
|
+
review TEXT NOT NULL,
|
|
84
|
+
reviewer TEXT NOT NULL DEFAULT 'AI',
|
|
85
|
+
created_at TEXT DEFAULT (datetime('now'))
|
|
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);
|
|
96
|
+
|
|
97
|
+
-- Short-lived, single-use OAuth authorization transactions. D1 provides an
|
|
98
|
+
-- atomic DELETE ... RETURNING consume step so consent forms cannot be replayed.
|
|
99
|
+
CREATE TABLE IF NOT EXISTS oauth_csrf_tokens (
|
|
100
|
+
token TEXT PRIMARY KEY,
|
|
101
|
+
request_fingerprint TEXT NOT NULL,
|
|
102
|
+
expires_at INTEGER NOT NULL
|
|
103
|
+
);
|
|
104
|
+
CREATE INDEX IF NOT EXISTS idx_oauth_csrf_expires ON oauth_csrf_tokens(expires_at);
|