botschat 0.1.6 → 0.1.7

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 (43) hide show
  1. package/README.md +62 -22
  2. package/migrations/0011_e2e_encryption.sql +35 -0
  3. package/package.json +4 -2
  4. package/packages/api/src/do/connection-do.ts +34 -9
  5. package/packages/api/src/index.ts +29 -7
  6. package/packages/api/src/routes/auth.ts +4 -1
  7. package/packages/api/src/routes/setup.ts +2 -0
  8. package/packages/plugin/dist/src/accounts.d.ts.map +1 -1
  9. package/packages/plugin/dist/src/accounts.js +1 -0
  10. package/packages/plugin/dist/src/accounts.js.map +1 -1
  11. package/packages/plugin/dist/src/channel.d.ts +1 -0
  12. package/packages/plugin/dist/src/channel.d.ts.map +1 -1
  13. package/packages/plugin/dist/src/channel.js +142 -6
  14. package/packages/plugin/dist/src/channel.js.map +1 -1
  15. package/packages/plugin/dist/src/types.d.ts +16 -0
  16. package/packages/plugin/dist/src/types.d.ts.map +1 -1
  17. package/packages/plugin/dist/src/ws-client.d.ts +2 -0
  18. package/packages/plugin/dist/src/ws-client.d.ts.map +1 -1
  19. package/packages/plugin/dist/src/ws-client.js +14 -3
  20. package/packages/plugin/dist/src/ws-client.js.map +1 -1
  21. package/packages/plugin/package.json +3 -2
  22. package/packages/web/dist/architecture.png +0 -0
  23. package/packages/web/dist/assets/index-BoNQoJjQ.js +1497 -0
  24. package/packages/web/dist/assets/{index-BST9bfvT.css → index-ewBIratI.css} +1 -1
  25. package/packages/web/dist/index.html +2 -2
  26. package/packages/web/package.json +1 -0
  27. package/packages/web/src/App.tsx +46 -8
  28. package/packages/web/src/analytics.ts +57 -0
  29. package/packages/web/src/api.ts +4 -0
  30. package/packages/web/src/components/ConnectionSettings.tsx +3 -1
  31. package/packages/web/src/components/E2ESettings.tsx +122 -0
  32. package/packages/web/src/components/IconRail.tsx +1 -12
  33. package/packages/web/src/components/LoginPage.tsx +19 -3
  34. package/packages/web/src/components/OnboardingPage.tsx +152 -5
  35. package/packages/web/src/e2e.ts +133 -0
  36. package/packages/web/src/main.tsx +3 -0
  37. package/packages/web/src/store.ts +4 -3
  38. package/packages/web/src/ws.ts +76 -4
  39. package/scripts/dev.sh +5 -5
  40. package/scripts/test-e2e-live.ts +194 -0
  41. package/scripts/verify-e2e-db.ts +48 -0
  42. package/scripts/verify-e2e.ts +56 -0
  43. package/packages/web/dist/assets/index-Da18EnTa.js +0 -851
@@ -0,0 +1,194 @@
1
+ /**
2
+ * Live E2E encryption integration test.
3
+ *
4
+ * Tests:
5
+ * 1. Web sends encrypted user.message via WS → plugin decrypts → agent responds → encrypted agent.text → Web decrypts
6
+ * 2. D1 stores ciphertext (encrypted=1), not plaintext
7
+ *
8
+ * Prerequisites:
9
+ * - wrangler dev running on localhost:8787 (ENVIRONMENT=development)
10
+ * - mini.local gateway running with e2ePassword set
11
+ * - Test user tong@mini.local exists
12
+ *
13
+ * Usage: npx tsx scripts/test-e2e-live.ts
14
+ */
15
+
16
+ import WebSocket from "ws";
17
+ import { deriveKey, encryptText, decryptText, toBase64, fromBase64 } from "../packages/e2e-crypto/e2e-crypto.js";
18
+ import { execSync } from "node:child_process";
19
+ import assert from "node:assert";
20
+
21
+ const API_BASE = "http://localhost:8787";
22
+ const E2E_PASSWORD = "e2e-test-2026";
23
+ const TEST_EMAIL = "tong@mini.local";
24
+ const TEST_PASS = "botschat123";
25
+ const SECRET_TEXT = `E2E_TEST_SECRET_${Date.now()}`;
26
+
27
+ async function login(): Promise<{ token: string; userId: string }> {
28
+ const res = await fetch(`${API_BASE}/api/auth/login`, {
29
+ method: "POST",
30
+ headers: { "Content-Type": "application/json" },
31
+ body: JSON.stringify({ email: TEST_EMAIL, password: TEST_PASS }),
32
+ });
33
+ if (!res.ok) throw new Error(`Login failed: ${res.status}`);
34
+ const data = await res.json() as { token: string; id: string };
35
+ return { token: data.token, userId: data.id };
36
+ }
37
+
38
+ async function getFirstSessionKey(token: string): Promise<{ channelId: string; sessionKey: string }> {
39
+ const res = await fetch(`${API_BASE}/api/channels`, {
40
+ headers: { Authorization: `Bearer ${token}` },
41
+ });
42
+ let { channels } = await res.json() as { channels: Array<{ id: string }> };
43
+ if (!channels.length) {
44
+ // Create a channel
45
+ const cr = await fetch(`${API_BASE}/api/channels`, {
46
+ method: "POST",
47
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
48
+ body: JSON.stringify({ name: "E2E Test", description: "E2E encryption test channel" }),
49
+ });
50
+ const ch = await cr.json() as { id: string };
51
+ channels = [ch];
52
+ }
53
+ const channelId = channels[0].id;
54
+
55
+ const res2 = await fetch(`${API_BASE}/api/channels/${channelId}/sessions`, {
56
+ headers: { Authorization: `Bearer ${token}` },
57
+ });
58
+ let { sessions } = await res2.json() as { sessions: Array<{ sessionKey: string }> };
59
+ if (!sessions.length) {
60
+ // Create a session
61
+ const sr = await fetch(`${API_BASE}/api/channels/${channelId}/sessions`, {
62
+ method: "POST",
63
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
64
+ body: JSON.stringify({ name: "E2E Test Session" }),
65
+ });
66
+ const s = await sr.json() as { sessionKey: string };
67
+ sessions = [s];
68
+ }
69
+ return { channelId, sessionKey: sessions[0].sessionKey };
70
+ }
71
+
72
+ function connectWS(userId: string, token: string): Promise<WebSocket> {
73
+ return new Promise((resolve, reject) => {
74
+ const ws = new WebSocket(`ws://localhost:8787/api/ws/${userId}/live-test`);
75
+ ws.on("open", () => {
76
+ ws.send(JSON.stringify({ type: "auth", token }));
77
+ });
78
+ ws.on("message", (data) => {
79
+ const msg = JSON.parse(data.toString());
80
+ if (msg.type === "auth.ok") resolve(ws);
81
+ if (msg.type === "auth.fail") reject(new Error("WS auth failed"));
82
+ });
83
+ ws.on("error", reject);
84
+ setTimeout(() => reject(new Error("WS connect timeout")), 10000);
85
+ });
86
+ }
87
+
88
+ function waitForAgentReply(ws: WebSocket, timeoutMs = 60000): Promise<Record<string, unknown>> {
89
+ return new Promise((resolve, reject) => {
90
+ const timer = setTimeout(() => reject(new Error("Agent reply timeout")), timeoutMs);
91
+ const handler = (data: WebSocket.Data) => {
92
+ const msg = JSON.parse(data.toString()) as Record<string, unknown>;
93
+ if (msg.type === "agent.text") {
94
+ clearTimeout(timer);
95
+ ws.off("message", handler);
96
+ resolve(msg);
97
+ }
98
+ };
99
+ ws.on("message", handler);
100
+ });
101
+ }
102
+
103
+ function d1Query(sql: string): string {
104
+ return execSync(
105
+ `npx wrangler d1 execute botschat-db --local --command "${sql.replace(/"/g, '\\"')}"`,
106
+ { encoding: "utf8", cwd: process.cwd() },
107
+ );
108
+ }
109
+
110
+ async function run() {
111
+ console.log("=== E2E Live Integration Test ===\n");
112
+
113
+ // Step 1: Login
114
+ console.log("1. Logging in...");
115
+ const { token, userId } = await login();
116
+ console.log(` userId: ${userId}`);
117
+
118
+ // Step 2: Derive E2E key (same as plugin)
119
+ console.log("2. Deriving E2E key...");
120
+ const key = await deriveKey(E2E_PASSWORD, userId);
121
+ console.log(` Key derived (${key.length} bytes)`);
122
+
123
+ // Step 3: Get session
124
+ console.log("3. Getting session key...");
125
+ const { sessionKey } = await getFirstSessionKey(token);
126
+ console.log(` sessionKey: ${sessionKey}`);
127
+
128
+ // Step 4: Connect WS
129
+ console.log("4. Connecting WebSocket...");
130
+ const ws = connectWS(userId, token);
131
+ const wsConn = await ws;
132
+ console.log(" Connected and authenticated");
133
+
134
+ // Step 5: Send encrypted message
135
+ console.log(`5. Sending encrypted message: "${SECRET_TEXT}"`);
136
+ const messageId = `e2e-test-${Date.now()}`;
137
+ const ciphertext = await encryptText(key, SECRET_TEXT, messageId);
138
+ const ciphertextB64 = toBase64(ciphertext);
139
+
140
+ wsConn.send(JSON.stringify({
141
+ type: "user.message",
142
+ sessionKey,
143
+ text: ciphertextB64,
144
+ messageId,
145
+ encrypted: true,
146
+ }));
147
+ console.log(` Sent (ciphertext length=${ciphertextB64.length}, messageId=${messageId})`);
148
+
149
+ // Step 6: Wait for agent reply
150
+ console.log("6. Waiting for agent reply (may take up to 60s)...");
151
+ const agentMsg = await waitForAgentReply(wsConn);
152
+ console.log(` Got agent.text reply (encrypted=${agentMsg.encrypted})`);
153
+
154
+ if (agentMsg.encrypted && agentMsg.messageId) {
155
+ const agentCiphertext = fromBase64(agentMsg.text as string);
156
+ const agentPlain = await decryptText(key, agentCiphertext, agentMsg.messageId as string);
157
+ console.log(` ✅ Decrypted agent reply: "${agentPlain.slice(0, 100)}..."`);
158
+ } else {
159
+ console.log(` ⚠️ Agent reply was NOT encrypted (encrypted=${agentMsg.encrypted})`);
160
+ console.log(` Text: "${(agentMsg.text as string || "").slice(0, 100)}..."`);
161
+ }
162
+
163
+ // Step 7: Check D1 for encrypted storage
164
+ console.log("7. Checking D1 for encrypted messages...");
165
+ // Allow a small delay for persistence
166
+ await new Promise((r) => setTimeout(r, 2000));
167
+
168
+ const result = d1Query(`SELECT id, text, encrypted FROM messages WHERE user_id = '${userId}' ORDER BY created_at DESC LIMIT 5`);
169
+ console.log(" D1 query result:");
170
+ console.log(result);
171
+
172
+ // Verify: the secret text should NOT appear as plaintext in D1
173
+ if (result.includes(SECRET_TEXT)) {
174
+ console.error(" ❌ FAIL: Plaintext found in D1! E2E storage is broken.");
175
+ process.exit(1);
176
+ } else {
177
+ console.log(" ✅ Plaintext NOT found in D1 — ciphertext stored correctly");
178
+ }
179
+
180
+ // Check that at least one message has encrypted=1
181
+ if (result.includes("encrypted: 1") || result.includes('"encrypted":1') || result.includes("encrypted\n1") || result.includes("| 1")) {
182
+ console.log(" ✅ Found encrypted=1 rows in D1");
183
+ } else {
184
+ console.log(" ⚠️ Could not confirm encrypted=1 in output (check manually)");
185
+ }
186
+
187
+ wsConn.close();
188
+ console.log("\n🎉 E2E Live Integration Test Complete!");
189
+ }
190
+
191
+ run().catch((err) => {
192
+ console.error("❌ Test failed:", err);
193
+ process.exit(1);
194
+ });
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Verify E2E DB schema: messages and jobs have BLOB columns and encrypted flag.
3
+ * Requires: npm run db:migrate (local D1) first.
4
+ * Run: npx tsx scripts/verify-e2e-db.ts
5
+ *
6
+ * For full DB content test (encrypted=1, content is BLOB not plaintext),
7
+ * run dev server, send an E2E-encrypted message via WS/API, then query D1.
8
+ */
9
+
10
+ import { execSync } from "node:child_process";
11
+
12
+ function wranglerD1Sql(sql: string): string {
13
+ return execSync(
14
+ `npx wrangler d1 execute botschat-db --local --command "${sql.replace(/"/g, '\\"')}"`,
15
+ { encoding: "utf8" }
16
+ );
17
+ }
18
+
19
+ async function run() {
20
+ console.log("E2E DB schema verification (local D1)...\n");
21
+
22
+ // Check messages table: must have encrypted column and BLOB-capable columns
23
+ const msgSchema = wranglerD1Sql("PRAGMA table_info(messages);");
24
+ if (!msgSchema.includes("encrypted")) {
25
+ throw new Error("messages table missing 'encrypted' column. Run: npm run db:migrate");
26
+ }
27
+ if (!msgSchema.includes("text") || !msgSchema.includes("a2ui")) {
28
+ throw new Error("messages table missing text/a2ui columns");
29
+ }
30
+ console.log(" ✅ messages table has encrypted column");
31
+
32
+ // Check jobs table
33
+ const jobsSchema = wranglerD1Sql("PRAGMA table_info(jobs);");
34
+ if (!jobsSchema.includes("encrypted")) {
35
+ throw new Error("jobs table missing 'encrypted' column. Run: npm run db:migrate");
36
+ }
37
+ if (!jobsSchema.includes("summary")) {
38
+ throw new Error("jobs table missing summary column");
39
+ }
40
+ console.log(" ✅ jobs table has encrypted column");
41
+
42
+ console.log("\n🎉 E2E DB schema OK. For content verification, send an E2E message then inspect D1.");
43
+ }
44
+
45
+ run().catch((err) => {
46
+ console.error("❌", err.message);
47
+ process.exit(1);
48
+ });
@@ -0,0 +1,56 @@
1
+ import { deriveKey, encryptText, decryptText, toBase64, fromBase64 } from "../packages/e2e-crypto/e2e-crypto";
2
+ import assert from "assert";
3
+
4
+ async function run() {
5
+ console.log("Starting E2E Encryption Verification...");
6
+
7
+ const password = "my-secret-password";
8
+ const userId = "user-123";
9
+
10
+ // 1. Key Derivation (Simulate Plugin & Web)
11
+ console.log("Testing Key Derivation...");
12
+ const key1 = await deriveKey(password, userId);
13
+ const key2 = await deriveKey(password, userId);
14
+
15
+ // Check keys byte-equal
16
+ assert.strictEqual(toBase64(new Uint8Array(key1)), toBase64(new Uint8Array(key2)), "Keys should be deterministic");
17
+ console.log("✅ Key Derivation Successful (consistent)");
18
+
19
+ // 2. Encrypt (Simulate Agent -> User)
20
+ console.log("Testing Encryption (Agent -> User)...");
21
+ const plaintext = "Hello Secret World";
22
+ const messageId = "msg-123-uuid"; // Context ID
23
+
24
+ const encryptedBytes = await encryptText(key1, plaintext, messageId);
25
+ const ciphertextBase64 = toBase64(encryptedBytes);
26
+ console.log("Ciphertext (Base64):", ciphertextBase64);
27
+
28
+ assert.notEqual(ciphertextBase64, plaintext, "Ciphertext should not match plaintext");
29
+ console.log("✅ Encryption Successful");
30
+
31
+ // 3. Decrypt (Simulate User -> Agent)
32
+ console.log("Testing Decryption (User -> Agent)...");
33
+ const decryptedText = await decryptText(key2, fromBase64(ciphertextBase64), messageId);
34
+ console.log("Decrypted:", decryptedText);
35
+ assert.strictEqual(decryptedText, plaintext, "Decrypted text MUST match original");
36
+ console.log("✅ Decryption Successful");
37
+
38
+ // 4. Test Task Encryption (Random IV flow)
39
+ console.log("Testing Task Encryption (Random IV)...");
40
+ const ivStr = "random-uuid-iv";
41
+ const schedule = "0 * * * *";
42
+ const encScheduleBytes = await encryptText(key1, schedule, ivStr);
43
+ const encScheduleBase64 = toBase64(encScheduleBytes);
44
+
45
+ const originalScheduleText = await decryptText(key2, fromBase64(encScheduleBase64), ivStr);
46
+
47
+ assert.strictEqual(originalScheduleText, schedule);
48
+ console.log("✅ Task Encryption/Decryption Successful");
49
+
50
+ console.log("🎉 All Checks Passed!");
51
+ }
52
+
53
+ run().catch(err => {
54
+ console.error("❌ Verification Failed:", err);
55
+ process.exit(1);
56
+ });