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.
Files changed (62) hide show
  1. package/.claude/settings.local.json +3 -1
  2. package/.github/ray-logo.png +0 -0
  3. package/.github/workflows/ci.yml +1 -0
  4. package/Dockerfile +2 -2
  5. package/README.md +31 -10
  6. package/SECURITY.md +1 -1
  7. package/dist/ai/agent.js +16 -3
  8. package/dist/ai/context.js +6 -2
  9. package/dist/ai/insights.js +26 -3
  10. package/dist/ai/redactor.js +11 -0
  11. package/dist/ai/system-prompt.js +2 -2
  12. package/dist/ai/tools.js +4 -0
  13. package/dist/cli/backup.js +18 -9
  14. package/dist/cli/chat.js +146 -40
  15. package/dist/cli/format.d.ts +2 -0
  16. package/dist/cli/format.js +25 -0
  17. package/dist/cli/index.js +12 -2
  18. package/dist/cli/setup.js +7 -1
  19. package/dist/daily-sync.js +19 -4
  20. package/dist/db/connection.js +9 -1
  21. package/dist/db/encryption.js +18 -7
  22. package/dist/db/schema.js +6 -1
  23. package/dist/public/favicon.png +0 -0
  24. package/dist/public/link.html +47 -24
  25. package/dist/public/ray-logo-dark.png +0 -0
  26. package/dist/queries/index.js +8 -8
  27. package/dist/server.js +33 -1
  28. package/package.json +4 -2
  29. package/site/package-lock.json +43 -0
  30. package/site/package.json +1 -0
  31. package/site/public/ray-logo-dark.png +0 -0
  32. package/site/public/ray-logo-light.png +0 -0
  33. package/site/src/app/copy-command.tsx +1 -3
  34. package/site/src/app/layout.tsx +2 -1
  35. package/src/ai/agent.ts +15 -3
  36. package/src/ai/context.ts +3 -2
  37. package/src/ai/insights.ts +25 -3
  38. package/src/ai/redactor.test.ts +63 -0
  39. package/src/ai/redactor.ts +12 -0
  40. package/src/ai/system-prompt.ts +2 -2
  41. package/src/ai/tools.ts +4 -0
  42. package/src/cli/backup.ts +23 -10
  43. package/src/cli/chat.ts +155 -41
  44. package/src/cli/format.ts +31 -0
  45. package/src/cli/index.ts +12 -2
  46. package/src/cli/setup.ts +6 -1
  47. package/src/daily-sync.test.ts +150 -0
  48. package/src/daily-sync.ts +19 -4
  49. package/src/db/connection.ts +12 -1
  50. package/src/db/encryption.test.ts +86 -0
  51. package/src/db/encryption.ts +17 -7
  52. package/src/db/schema.test.ts +53 -0
  53. package/src/db/schema.ts +7 -1
  54. package/src/public/favicon.png +0 -0
  55. package/src/public/link.html +47 -24
  56. package/src/public/ray-logo-dark.png +0 -0
  57. package/src/queries/index.test.ts +397 -0
  58. package/src/queries/index.ts +8 -8
  59. package/src/server.ts +37 -1
  60. package/tsconfig.json +1 -1
  61. package/vitest.config.ts +7 -0
  62. 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
+ });
@@ -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, SCRYPT_SALT, SCRYPT_KEYLEN);
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 key = deriveKey(secret);
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 [ivHex, authTagHex, dataHex] = encrypted.split(":");
25
- const key = deriveKey(secret);
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
- deadline TEXT,
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
@@ -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="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><circle cx='50' cy='50' r='40' fill='%231a1a1a'/></svg>">
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: #fafafa;
16
- color: #1a1a1a;
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-radius: 12px;
26
- padding: 40px 32px;
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
- h1 { font-size: 22px; font-weight: 600; margin-bottom: 8px; }
33
- p { font-size: 15px; color: #666; margin-bottom: 24px; line-height: 1.5; }
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: #1a1a1a;
34
+ background: #1c1917;
36
35
  color: #fff;
37
36
  border: none;
38
37
  padding: 14px 32px;
39
38
  border-radius: 9999px;
40
- font-size: 16px;
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: #333; }
47
- button:disabled { background: #ccc; cursor: not-allowed; }
48
- .success { color: #28a745; font-weight: 600; }
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 #28a745;
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: #28a745;
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">RAY</div>
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) throw new Error('Session expired');
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