ray-finance 0.2.2 → 0.2.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/.claude/settings.local.json +3 -1
- package/.github/ray-logo.png +0 -0
- package/.github/workflows/ci.yml +1 -0
- package/Dockerfile +2 -2
- package/README.md +31 -10
- package/SECURITY.md +1 -1
- package/dist/ai/agent.js +16 -3
- package/dist/ai/context.js +6 -2
- package/dist/ai/insights.js +26 -3
- package/dist/ai/redactor.js +11 -0
- package/dist/ai/system-prompt.js +2 -2
- package/dist/ai/tools.js +4 -0
- package/dist/cli/backup.js +18 -9
- package/dist/cli/chat.js +146 -40
- package/dist/cli/format.d.ts +2 -0
- package/dist/cli/format.js +25 -0
- package/dist/cli/index.js +12 -2
- package/dist/cli/setup.js +7 -1
- package/dist/daily-sync.js +19 -4
- package/dist/db/connection.js +9 -1
- package/dist/db/encryption.js +18 -7
- package/dist/db/schema.js +6 -1
- package/dist/public/favicon.png +0 -0
- package/dist/public/link.html +47 -24
- package/dist/public/ray-logo-dark.png +0 -0
- package/dist/queries/index.js +8 -8
- package/dist/server.js +33 -1
- package/package.json +4 -2
- package/site/package-lock.json +43 -0
- package/site/package.json +1 -0
- package/site/public/ray-logo-dark.png +0 -0
- package/site/public/ray-logo-light.png +0 -0
- package/site/src/app/copy-command.tsx +1 -3
- package/site/src/app/layout.tsx +2 -1
- package/src/ai/agent.ts +15 -3
- package/src/ai/context.ts +3 -2
- package/src/ai/insights.ts +25 -3
- package/src/ai/redactor.test.ts +63 -0
- package/src/ai/redactor.ts +12 -0
- package/src/ai/system-prompt.ts +2 -2
- package/src/ai/tools.ts +4 -0
- package/src/cli/backup.ts +23 -10
- package/src/cli/chat.ts +155 -41
- package/src/cli/format.ts +31 -0
- package/src/cli/index.ts +12 -2
- package/src/cli/setup.ts +6 -1
- package/src/daily-sync.test.ts +150 -0
- package/src/daily-sync.ts +19 -4
- package/src/db/connection.ts +12 -1
- package/src/db/encryption.test.ts +86 -0
- package/src/db/encryption.ts +17 -7
- package/src/db/schema.test.ts +53 -0
- package/src/db/schema.ts +7 -1
- package/src/public/favicon.png +0 -0
- package/src/public/link.html +47 -24
- package/src/public/ray-logo-dark.png +0 -0
- package/src/queries/index.test.ts +397 -0
- package/src/queries/index.ts +8 -8
- package/src/server.ts +37 -1
- package/tsconfig.json +1 -1
- package/vitest.config.ts +7 -0
- package/SPEC.md +0 -374
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { generateKey, encryptPlaidToken, decryptPlaidToken } from "./encryption.js";
|
|
3
|
+
import { createCipheriv, randomBytes, scryptSync } from "crypto";
|
|
4
|
+
|
|
5
|
+
describe("generateKey", () => {
|
|
6
|
+
it("returns a 64-char hex string (32 bytes)", () => {
|
|
7
|
+
const key = generateKey();
|
|
8
|
+
expect(key).toMatch(/^[0-9a-f]{64}$/);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it("returns unique keys each call", () => {
|
|
12
|
+
expect(generateKey()).not.toBe(generateKey());
|
|
13
|
+
});
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
describe("encryptPlaidToken / decryptPlaidToken", () => {
|
|
17
|
+
const secret = "test-secret-passphrase";
|
|
18
|
+
|
|
19
|
+
it("roundtrips a token", () => {
|
|
20
|
+
const token = "access-sandbox-abc123";
|
|
21
|
+
const encrypted = encryptPlaidToken(token, secret);
|
|
22
|
+
expect(decryptPlaidToken(encrypted, secret)).toBe(token);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("produces 4-part format (salt:iv:authTag:data)", () => {
|
|
26
|
+
const encrypted = encryptPlaidToken("token", secret);
|
|
27
|
+
expect(encrypted.split(":")).toHaveLength(4);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("produces different ciphertext each time (random salt+iv)", () => {
|
|
31
|
+
const a = encryptPlaidToken("same-token", secret);
|
|
32
|
+
const b = encryptPlaidToken("same-token", secret);
|
|
33
|
+
expect(a).not.toBe(b);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("roundtrips empty string", () => {
|
|
37
|
+
const encrypted = encryptPlaidToken("", secret);
|
|
38
|
+
expect(decryptPlaidToken(encrypted, secret)).toBe("");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("roundtrips unicode", () => {
|
|
42
|
+
const token = "tökén-with-émojis-🔑";
|
|
43
|
+
const encrypted = encryptPlaidToken(token, secret);
|
|
44
|
+
expect(decryptPlaidToken(encrypted, secret)).toBe(token);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("throws with wrong secret", () => {
|
|
48
|
+
const encrypted = encryptPlaidToken("token", secret);
|
|
49
|
+
expect(() => decryptPlaidToken(encrypted, "wrong-secret")).toThrow();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("throws when authTag is tampered", () => {
|
|
53
|
+
const encrypted = encryptPlaidToken("token", secret);
|
|
54
|
+
const parts = encrypted.split(":");
|
|
55
|
+
// Flip a byte in the auth tag
|
|
56
|
+
const tampered = parts[2][0] === "a" ? "b" + parts[2].slice(1) : "a" + parts[2].slice(1);
|
|
57
|
+
parts[2] = tampered;
|
|
58
|
+
expect(() => decryptPlaidToken(parts.join(":"), secret)).toThrow();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("throws when encrypted data is tampered", () => {
|
|
62
|
+
const encrypted = encryptPlaidToken("token", secret);
|
|
63
|
+
const parts = encrypted.split(":");
|
|
64
|
+
const tampered = parts[3][0] === "a" ? "b" + parts[3].slice(1) : "a" + parts[3].slice(1);
|
|
65
|
+
parts[3] = tampered;
|
|
66
|
+
expect(() => decryptPlaidToken(parts.join(":"), secret)).toThrow();
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe("legacy 3-part format", () => {
|
|
71
|
+
it("decrypts tokens encrypted with static salt", () => {
|
|
72
|
+
const secret = "legacy-secret";
|
|
73
|
+
const token = "access-sandbox-legacy";
|
|
74
|
+
|
|
75
|
+
// Manually create a 3-part encrypted token using the hardcoded legacy salt
|
|
76
|
+
const salt = Buffer.from("ray-finance-plaid-token", "utf8");
|
|
77
|
+
const key = scryptSync(secret, salt, 32);
|
|
78
|
+
const iv = randomBytes(16);
|
|
79
|
+
const cipher = createCipheriv("aes-256-gcm", key, iv);
|
|
80
|
+
const encrypted = Buffer.concat([cipher.update(token, "utf8"), cipher.final()]);
|
|
81
|
+
const authTag = cipher.getAuthTag();
|
|
82
|
+
|
|
83
|
+
const legacy = `${iv.toString("hex")}:${authTag.toString("hex")}:${encrypted.toString("hex")}`;
|
|
84
|
+
expect(decryptPlaidToken(legacy, secret)).toBe(token);
|
|
85
|
+
});
|
|
86
|
+
});
|
package/src/db/encryption.ts
CHANGED
|
@@ -1,28 +1,38 @@
|
|
|
1
1
|
import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from "crypto";
|
|
2
2
|
|
|
3
|
-
const SCRYPT_SALT = "ray-finance-plaid-token"; // Static salt is fine — secret is already high-entropy
|
|
4
3
|
const SCRYPT_KEYLEN = 32;
|
|
4
|
+
const SALT_LEN = 16;
|
|
5
5
|
|
|
6
6
|
export function generateKey(): string {
|
|
7
7
|
return randomBytes(32).toString("hex");
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
-
function deriveKey(secret: string): Buffer {
|
|
11
|
-
return scryptSync(secret,
|
|
10
|
+
function deriveKey(secret: string, salt: Buffer): Buffer {
|
|
11
|
+
return scryptSync(secret, salt, SCRYPT_KEYLEN);
|
|
12
12
|
}
|
|
13
13
|
|
|
14
14
|
export function encryptPlaidToken(token: string, secret: string): string {
|
|
15
|
-
const
|
|
15
|
+
const salt = randomBytes(SALT_LEN);
|
|
16
|
+
const key = deriveKey(secret, salt);
|
|
16
17
|
const iv = randomBytes(16);
|
|
17
18
|
const cipher = createCipheriv("aes-256-gcm", key, iv);
|
|
18
19
|
const encrypted = Buffer.concat([cipher.update(token, "utf8"), cipher.final()]);
|
|
19
20
|
const authTag = cipher.getAuthTag();
|
|
20
|
-
return `${iv.toString("hex")}:${authTag.toString("hex")}:${encrypted.toString("hex")}`;
|
|
21
|
+
return `${salt.toString("hex")}:${iv.toString("hex")}:${authTag.toString("hex")}:${encrypted.toString("hex")}`;
|
|
21
22
|
}
|
|
22
23
|
|
|
23
24
|
export function decryptPlaidToken(encrypted: string, secret: string): string {
|
|
24
|
-
const
|
|
25
|
-
|
|
25
|
+
const parts = encrypted.split(":");
|
|
26
|
+
// Support legacy 3-part format (static salt) and new 4-part format (random salt)
|
|
27
|
+
let salt: Buffer, ivHex: string, authTagHex: string, dataHex: string;
|
|
28
|
+
if (parts.length === 3) {
|
|
29
|
+
salt = Buffer.from("ray-finance-plaid-token", "utf8");
|
|
30
|
+
[ivHex, authTagHex, dataHex] = parts;
|
|
31
|
+
} else {
|
|
32
|
+
[, ivHex, authTagHex, dataHex] = parts;
|
|
33
|
+
salt = Buffer.from(parts[0], "hex");
|
|
34
|
+
}
|
|
35
|
+
const key = deriveKey(secret, salt);
|
|
26
36
|
const decipher = createDecipheriv("aes-256-gcm", key, Buffer.from(ivHex, "hex"));
|
|
27
37
|
decipher.setAuthTag(Buffer.from(authTagHex, "hex"));
|
|
28
38
|
return decipher.update(Buffer.from(dataHex, "hex")) + decipher.final("utf8");
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import Database from "better-sqlite3-multiple-ciphers";
|
|
3
|
+
import { migrate } from "./schema.js";
|
|
4
|
+
|
|
5
|
+
function freshDb() {
|
|
6
|
+
const db = new Database(":memory:");
|
|
7
|
+
db.pragma("foreign_keys = ON");
|
|
8
|
+
return db;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
describe("migrate", () => {
|
|
12
|
+
it("creates all 18 tables", () => {
|
|
13
|
+
const db = freshDb();
|
|
14
|
+
migrate(db);
|
|
15
|
+
|
|
16
|
+
const tables = db
|
|
17
|
+
.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name`)
|
|
18
|
+
.all()
|
|
19
|
+
.map((r: any) => r.name);
|
|
20
|
+
|
|
21
|
+
const expected = [
|
|
22
|
+
"accounts",
|
|
23
|
+
"achievements",
|
|
24
|
+
"ai_audit_log",
|
|
25
|
+
"budgets",
|
|
26
|
+
"conversation_history",
|
|
27
|
+
"daily_scores",
|
|
28
|
+
"goals",
|
|
29
|
+
"holdings",
|
|
30
|
+
"institutions",
|
|
31
|
+
"liabilities",
|
|
32
|
+
"memories",
|
|
33
|
+
"milestones",
|
|
34
|
+
"net_worth_history",
|
|
35
|
+
"recategorization_rules",
|
|
36
|
+
"recurring",
|
|
37
|
+
"recurring_bills",
|
|
38
|
+
"securities",
|
|
39
|
+
"settings",
|
|
40
|
+
"transactions",
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
for (const t of expected) {
|
|
44
|
+
expect(tables, `missing table: ${t}`).toContain(t);
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("is idempotent", () => {
|
|
49
|
+
const db = freshDb();
|
|
50
|
+
migrate(db);
|
|
51
|
+
expect(() => migrate(db)).not.toThrow();
|
|
52
|
+
});
|
|
53
|
+
});
|
package/src/db/schema.ts
CHANGED
|
@@ -102,7 +102,7 @@ export function migrate(db: Database.Database): void {
|
|
|
102
102
|
name TEXT NOT NULL,
|
|
103
103
|
target_amount REAL NOT NULL,
|
|
104
104
|
current_amount REAL DEFAULT 0,
|
|
105
|
-
|
|
105
|
+
target_date TEXT,
|
|
106
106
|
status TEXT DEFAULT 'active'
|
|
107
107
|
);
|
|
108
108
|
|
|
@@ -193,4 +193,10 @@ export function migrate(db: Database.Database): void {
|
|
|
193
193
|
created_at TEXT DEFAULT (datetime('now'))
|
|
194
194
|
);
|
|
195
195
|
`);
|
|
196
|
+
|
|
197
|
+
// Migrate: rename goals.deadline -> target_date for existing databases
|
|
198
|
+
const goalCols = db.prepare(`PRAGMA table_info(goals)`).all() as { name: string }[];
|
|
199
|
+
if (goalCols.some(c => c.name === "deadline") && !goalCols.some(c => c.name === "target_date")) {
|
|
200
|
+
db.exec(`ALTER TABLE goals RENAME COLUMN deadline TO target_date`);
|
|
201
|
+
}
|
|
196
202
|
}
|
|
Binary file
|
package/src/public/link.html
CHANGED
|
@@ -2,18 +2,15 @@
|
|
|
2
2
|
<html lang="en">
|
|
3
3
|
<head>
|
|
4
4
|
<meta charset="utf-8">
|
|
5
|
-
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
6
|
-
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
7
|
-
<link href="https://fonts.googleapis.com/css2?family=Geist+Mono:wght@700&display=swap" rel="stylesheet">
|
|
8
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
9
|
-
<link rel="icon" href="
|
|
6
|
+
<link rel="icon" href="/favicon.png">
|
|
10
7
|
<title>Ray — Connect Account</title>
|
|
11
8
|
<style>
|
|
12
|
-
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
9
|
+
* { margin: 0; padding: 0; box-sizing: border-box; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; }
|
|
13
10
|
body {
|
|
14
|
-
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
|
15
|
-
background: #
|
|
16
|
-
color: #
|
|
11
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
|
12
|
+
background: #fafaf9;
|
|
13
|
+
color: #1c1917;
|
|
17
14
|
display: flex;
|
|
18
15
|
align-items: center;
|
|
19
16
|
justify-content: center;
|
|
@@ -22,36 +19,37 @@
|
|
|
22
19
|
}
|
|
23
20
|
.card {
|
|
24
21
|
background: #fff;
|
|
25
|
-
border
|
|
26
|
-
|
|
22
|
+
border: 1px solid rgba(214, 211, 209, 0.6);
|
|
23
|
+
border-radius: 16px;
|
|
24
|
+
padding: 48px 40px;
|
|
27
25
|
max-width: 400px;
|
|
28
26
|
width: 100%;
|
|
29
27
|
text-align: center;
|
|
30
|
-
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
|
|
31
28
|
}
|
|
32
|
-
|
|
33
|
-
|
|
29
|
+
.logo { margin-bottom: 32px; }
|
|
30
|
+
.logo img { height: 24px; }
|
|
31
|
+
h1 { font-size: 20px; font-weight: 600; margin-bottom: 8px; color: #1c1917; }
|
|
32
|
+
p { font-size: 15px; color: #78716c; margin-bottom: 24px; line-height: 1.6; }
|
|
34
33
|
button {
|
|
35
|
-
background: #
|
|
34
|
+
background: #1c1917;
|
|
36
35
|
color: #fff;
|
|
37
36
|
border: none;
|
|
38
37
|
padding: 14px 32px;
|
|
39
38
|
border-radius: 9999px;
|
|
40
|
-
font-size:
|
|
39
|
+
font-size: 15px;
|
|
41
40
|
font-weight: 500;
|
|
42
41
|
cursor: pointer;
|
|
43
42
|
width: 100%;
|
|
44
43
|
transition: background 0.2s;
|
|
45
44
|
}
|
|
46
|
-
button:hover { background: #
|
|
47
|
-
button:disabled { background: #
|
|
48
|
-
.success { color: #
|
|
45
|
+
button:hover { background: #292524; }
|
|
46
|
+
button:disabled { background: #d6d3d1; cursor: not-allowed; }
|
|
47
|
+
.success { color: #6ab318; font-weight: 600; }
|
|
49
48
|
.error { color: #dc3545; font-size: 14px; margin-top: 16px; }
|
|
50
|
-
.logo { font-size: 28px; font-weight: 700; margin-bottom: 24px; color: #1a1a1a; font-family: 'Geist Mono', monospace; }
|
|
51
49
|
.checkmark {
|
|
52
50
|
width: 48px;
|
|
53
51
|
height: 48px;
|
|
54
|
-
border: 2.5px solid #
|
|
52
|
+
border: 2.5px solid #6ab318;
|
|
55
53
|
border-radius: 50%;
|
|
56
54
|
display: flex;
|
|
57
55
|
align-items: center;
|
|
@@ -61,7 +59,7 @@
|
|
|
61
59
|
.checkmark svg {
|
|
62
60
|
width: 24px;
|
|
63
61
|
height: 24px;
|
|
64
|
-
stroke: #
|
|
62
|
+
stroke: #6ab318;
|
|
65
63
|
fill: none;
|
|
66
64
|
stroke-width: 2.5;
|
|
67
65
|
stroke-linecap: round;
|
|
@@ -74,12 +72,24 @@
|
|
|
74
72
|
object-fit: contain;
|
|
75
73
|
margin-bottom: 16px;
|
|
76
74
|
}
|
|
75
|
+
.spinner {
|
|
76
|
+
display: inline-block;
|
|
77
|
+
width: 20px;
|
|
78
|
+
height: 20px;
|
|
79
|
+
border: 2px solid #d6d3d1;
|
|
80
|
+
border-top-color: #78716c;
|
|
81
|
+
border-radius: 50%;
|
|
82
|
+
animation: spin 0.6s linear infinite;
|
|
83
|
+
margin-bottom: 16px;
|
|
84
|
+
}
|
|
85
|
+
@keyframes spin { to { transform: rotate(360deg); } }
|
|
77
86
|
</style>
|
|
78
87
|
</head>
|
|
79
88
|
<body>
|
|
80
89
|
<div class="card">
|
|
81
|
-
<div class="logo"
|
|
90
|
+
<div class="logo"><img src="/ray-logo-dark.png" alt="Ray"></div>
|
|
82
91
|
<div id="connect-view">
|
|
92
|
+
<div class="spinner"></div>
|
|
83
93
|
<p>Opening Plaid...</p>
|
|
84
94
|
<div id="error" class="error" style="display:none"></div>
|
|
85
95
|
</div>
|
|
@@ -97,13 +107,26 @@
|
|
|
97
107
|
let linkHandler = null;
|
|
98
108
|
|
|
99
109
|
async function initPlaid() {
|
|
110
|
+
// Wait for Plaid SDK to load
|
|
111
|
+
if (typeof Plaid === 'undefined') {
|
|
112
|
+
let attempts = 0;
|
|
113
|
+
await new Promise((resolve, reject) => {
|
|
114
|
+
const check = setInterval(() => {
|
|
115
|
+
if (typeof Plaid !== 'undefined') { clearInterval(check); resolve(); }
|
|
116
|
+
else if (++attempts > 50) { clearInterval(check); reject(new Error('Failed to load Plaid SDK. Check your ad blocker or network connection.')); }
|
|
117
|
+
}, 100);
|
|
118
|
+
});
|
|
119
|
+
}
|
|
100
120
|
try {
|
|
101
121
|
const res = await fetch('/api/link-token', {
|
|
102
122
|
method: 'POST',
|
|
103
123
|
headers: { 'Content-Type': 'application/json' },
|
|
104
124
|
body: JSON.stringify({ session_id: sessionId }),
|
|
105
125
|
});
|
|
106
|
-
if (!res.ok)
|
|
126
|
+
if (!res.ok) {
|
|
127
|
+
const data = await res.json().catch(() => ({}));
|
|
128
|
+
throw new Error(data.error || 'Failed to connect. Please run "ray link" again.');
|
|
129
|
+
}
|
|
107
130
|
const { link_token } = await res.json();
|
|
108
131
|
|
|
109
132
|
linkHandler = Plaid.create({
|
|
@@ -145,7 +168,7 @@
|
|
|
145
168
|
// Auto-open Plaid Link
|
|
146
169
|
linkHandler.open();
|
|
147
170
|
} catch (e) {
|
|
148
|
-
showError('This link has expired. Please run "ray link" again.');
|
|
171
|
+
showError(e.message || 'This link has expired. Please run "ray link" again.');
|
|
149
172
|
}
|
|
150
173
|
}
|
|
151
174
|
|
|
Binary file
|