hanzi-browse 2.2.0

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 (78) hide show
  1. package/README.md +182 -0
  2. package/dist/agent/loop.d.ts +63 -0
  3. package/dist/agent/loop.js +186 -0
  4. package/dist/agent/system-prompt.d.ts +7 -0
  5. package/dist/agent/system-prompt.js +41 -0
  6. package/dist/agent/tools.d.ts +9 -0
  7. package/dist/agent/tools.js +154 -0
  8. package/dist/cli/detect-credentials.d.ts +31 -0
  9. package/dist/cli/detect-credentials.js +44 -0
  10. package/dist/cli/import-credentials-handler.d.ts +14 -0
  11. package/dist/cli/import-credentials-handler.js +22 -0
  12. package/dist/cli/session-files.d.ts +28 -0
  13. package/dist/cli/session-files.js +118 -0
  14. package/dist/cli/setup.d.ts +10 -0
  15. package/dist/cli/setup.js +915 -0
  16. package/dist/cli.d.ts +16 -0
  17. package/dist/cli.js +506 -0
  18. package/dist/dashboard/assets/index-CEFyesbT.js +46 -0
  19. package/dist/dashboard/assets/index-Dnht2kLU.css +1 -0
  20. package/dist/dashboard/index.html +13 -0
  21. package/dist/index.d.ts +2 -0
  22. package/dist/index.js +1116 -0
  23. package/dist/ipc/index.d.ts +8 -0
  24. package/dist/ipc/index.js +8 -0
  25. package/dist/ipc/native-host.d.ts +96 -0
  26. package/dist/ipc/native-host.js +223 -0
  27. package/dist/ipc/websocket-client.d.ts +73 -0
  28. package/dist/ipc/websocket-client.js +199 -0
  29. package/dist/license/manager.d.ts +20 -0
  30. package/dist/license/manager.js +15 -0
  31. package/dist/llm/client.d.ts +72 -0
  32. package/dist/llm/client.js +227 -0
  33. package/dist/llm/credentials.d.ts +61 -0
  34. package/dist/llm/credentials.js +200 -0
  35. package/dist/llm/vertex.d.ts +22 -0
  36. package/dist/llm/vertex.js +335 -0
  37. package/dist/managed/api-http.test.d.ts +7 -0
  38. package/dist/managed/api-http.test.js +623 -0
  39. package/dist/managed/api.d.ts +51 -0
  40. package/dist/managed/api.js +1448 -0
  41. package/dist/managed/api.test.d.ts +10 -0
  42. package/dist/managed/api.test.js +146 -0
  43. package/dist/managed/auth.d.ts +38 -0
  44. package/dist/managed/auth.js +192 -0
  45. package/dist/managed/billing.d.ts +70 -0
  46. package/dist/managed/billing.js +227 -0
  47. package/dist/managed/deploy.d.ts +17 -0
  48. package/dist/managed/deploy.js +385 -0
  49. package/dist/managed/e2e.test.d.ts +15 -0
  50. package/dist/managed/e2e.test.js +151 -0
  51. package/dist/managed/hardening.test.d.ts +14 -0
  52. package/dist/managed/hardening.test.js +346 -0
  53. package/dist/managed/integration.test.d.ts +8 -0
  54. package/dist/managed/integration.test.js +274 -0
  55. package/dist/managed/log.d.ts +18 -0
  56. package/dist/managed/log.js +31 -0
  57. package/dist/managed/server.d.ts +12 -0
  58. package/dist/managed/server.js +69 -0
  59. package/dist/managed/store-pg.d.ts +191 -0
  60. package/dist/managed/store-pg.js +479 -0
  61. package/dist/managed/store.d.ts +188 -0
  62. package/dist/managed/store.js +379 -0
  63. package/dist/relay/auto-start.d.ts +19 -0
  64. package/dist/relay/auto-start.js +71 -0
  65. package/dist/relay/server.d.ts +17 -0
  66. package/dist/relay/server.js +403 -0
  67. package/dist/types/index.d.ts +5 -0
  68. package/dist/types/index.js +4 -0
  69. package/dist/types/session.d.ts +134 -0
  70. package/dist/types/session.js +16 -0
  71. package/package.json +61 -0
  72. package/skills/README.md +48 -0
  73. package/skills/a11y-auditor/SKILL.md +42 -0
  74. package/skills/e2e-tester/SKILL.md +154 -0
  75. package/skills/hanzi-browse/SKILL.md +182 -0
  76. package/skills/linkedin-prospector/SKILL.md +149 -0
  77. package/skills/social-poster/SKILL.md +146 -0
  78. package/skills/x-marketer/SKILL.md +479 -0
@@ -0,0 +1,623 @@
1
+ /**
2
+ * HTTP-level API tests
3
+ *
4
+ * Starts the actual server and hits endpoints.
5
+ * Tests auth enforcement, workspace isolation, session validation.
6
+ */
7
+ import { initVertex } from "../llm/vertex.js";
8
+ import { startManagedAPI, initManagedAPI } from "./api.js";
9
+ import { createWorkspace, createApiKey } from "./store.js";
10
+ const PORT = 4567; // Use different port from production
11
+ const BASE = `http://localhost:${PORT}`;
12
+ // Mock relay — we don't need real WebSocket for API tests
13
+ const mockRelay = {
14
+ send: () => { },
15
+ onMessage: () => { },
16
+ connect: async () => { },
17
+ isConnected: () => true,
18
+ };
19
+ let defaultKey;
20
+ let otherKey;
21
+ async function setup() {
22
+ // Init Vertex (needed for imports, won't actually call it)
23
+ try {
24
+ initVertex("/tmp/hanzi-vertex-sa.json");
25
+ }
26
+ catch { }
27
+ // Always create fresh workspaces with known plaintext keys
28
+ const ws1 = createWorkspace("Test Workspace");
29
+ const key1 = createApiKey(ws1.id, "test-key");
30
+ defaultKey = key1.key; // plaintext from createApiKey
31
+ const ws2 = createWorkspace("Other Workspace");
32
+ const key2 = createApiKey(ws2.id, "other-key");
33
+ otherKey = key2.key;
34
+ initManagedAPI(mockRelay, () => false); // All sessions "not connected" by default
35
+ startManagedAPI(PORT);
36
+ await new Promise((r) => setTimeout(r, 500)); // Let server start
37
+ }
38
+ async function req(method, path, body, apiKey) {
39
+ const headers = { "Content-Type": "application/json" };
40
+ if (apiKey)
41
+ headers["Authorization"] = `Bearer ${apiKey}`;
42
+ const res = await fetch(`${BASE}${path}`, {
43
+ method,
44
+ headers,
45
+ body: body ? JSON.stringify(body) : undefined,
46
+ });
47
+ const data = await res.json().catch(() => null);
48
+ return { status: res.status, data };
49
+ }
50
+ function assert(condition, msg) {
51
+ if (!condition)
52
+ throw new Error(`FAIL: ${msg}`);
53
+ console.log(` ✓ ${msg}`);
54
+ }
55
+ // --- Tests ---
56
+ async function testHealthNoAuth() {
57
+ console.log("\n--- Health (no auth required) ---");
58
+ const { status, data } = await req("GET", "/v1/health");
59
+ assert(status === 200, "Health returns 200");
60
+ assert(data.status === "ok", "Health status is ok");
61
+ }
62
+ async function testAuthRequired() {
63
+ console.log("\n--- Auth required ---");
64
+ const { status: s1 } = await req("POST", "/v1/tasks", { task: "test" });
65
+ assert(s1 === 401, "POST /tasks without auth returns 401");
66
+ const { status: s2 } = await req("GET", "/v1/tasks");
67
+ assert(s2 === 401, "GET /tasks without auth returns 401");
68
+ const { status: s3 } = await req("GET", "/v1/usage");
69
+ assert(s3 === 401, "GET /usage without auth returns 401");
70
+ const { status: s4 } = await req("POST", "/v1/browser-sessions/pair");
71
+ assert(s4 === 401, "POST /browser-sessions/pair without auth returns 401");
72
+ const { status: s5 } = await req("GET", "/v1/tasks", undefined, "hic_live_bogus");
73
+ assert(s5 === 401, "Invalid API key returns 401");
74
+ }
75
+ async function testBrowserSessionIdRequired() {
76
+ console.log("\n--- browser_session_id required ---");
77
+ const { status, data } = await req("POST", "/v1/tasks", { task: "test" }, defaultKey);
78
+ assert(status === 400, "Task without browser_session_id returns 400");
79
+ assert(data.error.includes("browser_session_id"), "Error mentions browser_session_id");
80
+ }
81
+ async function testPairingFlow() {
82
+ console.log("\n--- Pairing flow ---");
83
+ // Create pairing token
84
+ const { status: s1, data: d1 } = await req("POST", "/v1/browser-sessions/pair", {}, defaultKey);
85
+ assert(s1 === 201, "Pairing token created");
86
+ assert(d1.pairing_token.startsWith("hic_pair_"), "Token has correct prefix");
87
+ assert(d1.expires_in_seconds > 0, "Token has expiry");
88
+ // Register with pairing token (no auth required — the token IS the auth)
89
+ const { status: s2, data: d2 } = await req("POST", "/v1/browser-sessions/register", {
90
+ pairing_token: d1.pairing_token,
91
+ });
92
+ assert(s2 === 201, "Session registered");
93
+ assert(d2.browser_session_id, "Got browser_session_id");
94
+ assert(d2.session_token.startsWith("hic_sess_"), "Got session_token");
95
+ // Cannot reuse pairing token
96
+ const { status: s3 } = await req("POST", "/v1/browser-sessions/register", {
97
+ pairing_token: d1.pairing_token,
98
+ });
99
+ assert(s3 === 401, "Reused pairing token rejected");
100
+ // Invalid pairing token
101
+ const { status: s4 } = await req("POST", "/v1/browser-sessions/register", {
102
+ pairing_token: "hic_pair_bogus",
103
+ });
104
+ assert(s4 === 401, "Bogus pairing token rejected");
105
+ }
106
+ async function testSessionNotConnectedRejectsTask() {
107
+ console.log("\n--- Session not connected rejects task ---");
108
+ // Create a session
109
+ const { data: d1 } = await req("POST", "/v1/browser-sessions/pair", {}, defaultKey);
110
+ const { data: d2 } = await req("POST", "/v1/browser-sessions/register", {
111
+ pairing_token: d1.pairing_token,
112
+ });
113
+ // Try to create task — session exists but not connected (mock returns false)
114
+ const { status, data } = await req("POST", "/v1/tasks", {
115
+ task: "test",
116
+ browser_session_id: d2.browser_session_id,
117
+ }, defaultKey);
118
+ assert(status === 409, "Disconnected session returns 409");
119
+ assert(data.error.includes("not connected"), "Error mentions not connected");
120
+ }
121
+ async function testWrongWorkspaceSessionRejectsTask() {
122
+ console.log("\n--- Wrong workspace session rejects task ---");
123
+ // Create session in workspace A (defaultKey)
124
+ const { data: d1 } = await req("POST", "/v1/browser-sessions/pair", {}, defaultKey);
125
+ const { data: d2 } = await req("POST", "/v1/browser-sessions/register", {
126
+ pairing_token: d1.pairing_token,
127
+ });
128
+ // Try to use it with workspace B's key
129
+ const { status, data } = await req("POST", "/v1/tasks", {
130
+ task: "test",
131
+ browser_session_id: d2.browser_session_id,
132
+ }, otherKey);
133
+ assert(status === 403, "Wrong workspace session returns 403");
134
+ assert(data.error.includes("does not belong"), "Error mentions workspace mismatch");
135
+ }
136
+ async function testTaskOwnershipIsolation() {
137
+ console.log("\n--- Task ownership isolation ---");
138
+ // Create a task in default workspace directly in the store
139
+ const { createTaskRun, validateApiKey } = await import("./store.js");
140
+ const resolved = validateApiKey(defaultKey);
141
+ const task = createTaskRun({
142
+ workspaceId: resolved.workspaceId,
143
+ apiKeyId: "test",
144
+ task: "ownership test",
145
+ browserSessionId: "test-session",
146
+ });
147
+ // Default key can see it
148
+ const { status: s1, data: d1 } = await req("GET", `/v1/tasks/${task.id}`, undefined, defaultKey);
149
+ assert(s1 === 200, "Owner can read task");
150
+ assert(d1.id === task.id, "Correct task returned");
151
+ // Other workspace gets 404 (not 403 — don't leak existence)
152
+ const { status: s2 } = await req("GET", `/v1/tasks/${task.id}`, undefined, otherKey);
153
+ assert(s2 === 404, "Non-owner gets 404 (not 403)");
154
+ // Other workspace can't cancel it
155
+ const { status: s3 } = await req("POST", `/v1/tasks/${task.id}/cancel`, {}, otherKey);
156
+ assert(s3 === 404, "Non-owner can't cancel (gets 404)");
157
+ // Nonexistent task
158
+ const { status: s4 } = await req("GET", "/v1/tasks/nonexistent-id", undefined, defaultKey);
159
+ assert(s4 === 404, "Nonexistent task returns 404");
160
+ }
161
+ async function testListTasksIsolation() {
162
+ console.log("\n--- List tasks isolation ---");
163
+ const { status: s1, data: d1 } = await req("GET", "/v1/tasks", undefined, defaultKey);
164
+ assert(s1 === 200, "Default workspace can list tasks");
165
+ const { status: s2, data: d2 } = await req("GET", "/v1/tasks", undefined, otherKey);
166
+ assert(s2 === 200, "Other workspace can list tasks");
167
+ // Other workspace should have zero tasks (all tasks belong to default)
168
+ assert(d2.tasks.length === 0, "Other workspace sees no tasks");
169
+ }
170
+ async function testUsageIsolation() {
171
+ console.log("\n--- Usage isolation ---");
172
+ const { status: s1, data: d1 } = await req("GET", "/v1/usage", undefined, defaultKey);
173
+ assert(s1 === 200, "Default workspace can get usage");
174
+ const { status: s2, data: d2 } = await req("GET", "/v1/usage", undefined, otherKey);
175
+ assert(s2 === 200, "Other workspace can get usage");
176
+ assert(d2.totalApiCalls === 0, "Other workspace has zero usage");
177
+ }
178
+ // --- Input Validation Tests (Slice 1 hardening) ---
179
+ async function testInputValidation() {
180
+ console.log("\n--- Input validation (hardening) ---");
181
+ // Task too long
182
+ const longTask = "a".repeat(10_001);
183
+ const { status: s1, data: d1 } = await req("POST", "/v1/tasks", {
184
+ task: longTask,
185
+ browser_session_id: "test",
186
+ }, defaultKey);
187
+ assert(s1 === 400, "Task exceeding 10000 chars returns 400");
188
+ assert(d1.error.includes("10000"), "Error mentions char limit");
189
+ // Context too long
190
+ const longCtx = "b".repeat(50_001);
191
+ const { status: s2, data: d2 } = await req("POST", "/v1/tasks", {
192
+ task: "test",
193
+ context: longCtx,
194
+ browser_session_id: "test",
195
+ }, defaultKey);
196
+ assert(s2 === 400, "Context exceeding 50000 chars returns 400");
197
+ assert(d2.error.includes("context"), "Error mentions context");
198
+ // Invalid URL
199
+ const { status: s3, data: d3 } = await req("POST", "/v1/tasks", {
200
+ task: "test",
201
+ url: "not-a-url",
202
+ browser_session_id: "test",
203
+ }, defaultKey);
204
+ assert(s3 === 400, "Invalid URL returns 400");
205
+ assert(d3.error.includes("url"), "Error mentions url");
206
+ // URL too long
207
+ const longUrl = "https://example.com/" + "x".repeat(2048);
208
+ const { status: s4, data: d4 } = await req("POST", "/v1/tasks", {
209
+ task: "test",
210
+ url: longUrl,
211
+ browser_session_id: "test",
212
+ }, defaultKey);
213
+ assert(s4 === 400, "URL exceeding 2048 chars returns 400");
214
+ // Empty task
215
+ const { status: s5 } = await req("POST", "/v1/tasks", {
216
+ task: " ",
217
+ browser_session_id: "test",
218
+ }, defaultKey);
219
+ assert(s5 === 400, "Whitespace-only task returns 400");
220
+ // Valid task passes validation (hits browser_session_id check next)
221
+ const { status: s6, data: d6 } = await req("POST", "/v1/tasks", {
222
+ task: "valid task",
223
+ url: "https://example.com",
224
+ context: "some context",
225
+ browser_session_id: "nonexistent-session",
226
+ }, defaultKey);
227
+ assert(s6 === 404, "Valid input with unknown session returns 404 (passes validation)");
228
+ }
229
+ async function testRequestBodyLimit() {
230
+ console.log("\n--- Request body size limit ---");
231
+ // Send a request with body > 128KB
232
+ const hugeBody = JSON.stringify({ task: "x".repeat(200_000) });
233
+ try {
234
+ const res = await fetch(`${BASE}/v1/tasks`, {
235
+ method: "POST",
236
+ headers: {
237
+ "Content-Type": "application/json",
238
+ "Authorization": `Bearer ${defaultKey}`,
239
+ },
240
+ body: hugeBody,
241
+ });
242
+ // Should get either 400 (body too large) or connection reset
243
+ assert(res.status >= 400, "Oversized body rejected");
244
+ }
245
+ catch {
246
+ // Connection reset is also acceptable — server destroyed the socket
247
+ assert(true, "Oversized body caused connection reset (acceptable)");
248
+ }
249
+ }
250
+ // --- Rate Limiting Tests (Slice 2) ---
251
+ async function testRateLimiting() {
252
+ console.log("\n--- Rate limiting (hardening) ---");
253
+ // Rate limit is checked AFTER input validation — bad requests (400) don't burn quota.
254
+ // To trigger rate limits, we need valid requests that pass validation but fail later
255
+ // (e.g., session exists but is not connected → 409).
256
+ const { createWorkspace, createApiKey, createPairingToken, consumePairingToken } = await import("./store.js");
257
+ const freshWs = createWorkspace("Rate Limit Test");
258
+ const freshKey = createApiKey(freshWs.id, "rate-key");
259
+ // Create a real session (but it won't be "connected" since mock returns false)
260
+ const pt = createPairingToken(freshWs.id, freshKey.id);
261
+ const session = consumePairingToken(pt._plainToken);
262
+ // Verify bad requests don't burn quota: send 5 invalid requests first
263
+ for (let i = 0; i < 5; i++) {
264
+ const { status } = await req("POST", "/v1/tasks", { task: `bad ${i}` }, freshKey.key);
265
+ assert(status === 400, "Bad request returns 400 without burning quota");
266
+ }
267
+ // Now hammer with valid-looking requests (reach rate limit check, get 409 = not connected)
268
+ let hitRateLimit = false;
269
+ for (let i = 0; i < 12; i++) {
270
+ const { status } = await req("POST", "/v1/tasks", {
271
+ task: `rate test ${i}`,
272
+ browser_session_id: session.id,
273
+ }, freshKey.key);
274
+ if (status === 429) {
275
+ hitRateLimit = true;
276
+ break;
277
+ }
278
+ }
279
+ assert(hitRateLimit, "Rate limit kicks in after max valid requests");
280
+ // Verify the error message is informative
281
+ const { status: s2, data: d2 } = await req("POST", "/v1/tasks", {
282
+ task: "one more",
283
+ browser_session_id: session.id,
284
+ }, freshKey.key);
285
+ assert(s2 === 429, "Rate limited request returns 429");
286
+ assert(d2.error.includes("Rate limit"), "Error mentions rate limit");
287
+ }
288
+ async function testCorsHeaders() {
289
+ console.log("\n--- CORS headers (hardening) ---");
290
+ // Known origin should get CORS headers
291
+ const res1 = await fetch(`${BASE}/v1/health`, {
292
+ headers: { Origin: "https://browse.hanzilla.co" },
293
+ });
294
+ const acao1 = res1.headers.get("access-control-allow-origin");
295
+ assert(acao1 === "https://browse.hanzilla.co", "Known origin gets reflected CORS");
296
+ // Unknown origin should NOT get CORS headers
297
+ const res2 = await fetch(`${BASE}/v1/health`, {
298
+ headers: { Origin: "https://evil.com" },
299
+ });
300
+ const acao2 = res2.headers.get("access-control-allow-origin");
301
+ assert(acao2 === null, "Unknown origin gets no CORS header");
302
+ // No origin header — no CORS headers
303
+ const res3 = await fetch(`${BASE}/v1/health`);
304
+ const acao3 = res3.headers.get("access-control-allow-origin");
305
+ assert(acao3 === null, "No origin header means no CORS header");
306
+ }
307
+ // --- Self-serve API Key Tests ---
308
+ async function testApiKeyCRUD() {
309
+ console.log("\n--- Self-serve API key CRUD ---");
310
+ // Create key
311
+ const { status: s1, data: d1 } = await req("POST", "/v1/api-keys", { name: "test-key" }, defaultKey);
312
+ assert(s1 === 201, "API key created (201)");
313
+ assert(d1.key.startsWith("hic_live_"), "Key has correct prefix");
314
+ assert(d1.name === "test-key", "Key has correct name");
315
+ assert(d1.id, "Key has an ID");
316
+ assert(d1._warning, "Response includes save warning");
317
+ const createdKeyId = d1.id;
318
+ const createdKeyValue = d1.key;
319
+ // List keys
320
+ const { status: s2, data: d2 } = await req("GET", "/v1/api-keys", undefined, defaultKey);
321
+ assert(s2 === 200, "List API keys returns 200");
322
+ assert(Array.isArray(d2.api_keys), "Response has api_keys array");
323
+ const found = d2.api_keys.find((k) => k.id === createdKeyId);
324
+ assert(!!found, "Created key appears in list");
325
+ assert(found.key_prefix.startsWith("hic_live_"), "Listed key shows readable prefix");
326
+ assert(!found.key_prefix.includes(createdKeyValue), "Listed key does not expose full key");
327
+ // The new key should work for auth
328
+ const { status: s3 } = await req("GET", "/v1/api-keys", undefined, createdKeyValue);
329
+ assert(s3 === 200, "Newly created key works for auth");
330
+ // Delete key
331
+ const { status: s4, data: d4 } = await req("DELETE", `/v1/api-keys/${createdKeyId}`, undefined, defaultKey);
332
+ assert(s4 === 200, "Delete returns 200");
333
+ assert(d4.deleted === true, "Response confirms deletion");
334
+ // Deleted key no longer works for auth
335
+ const { status: s5 } = await req("GET", "/v1/api-keys", undefined, createdKeyValue);
336
+ assert(s5 === 401, "Deleted key returns 401");
337
+ // Deleted key no longer in list
338
+ const { status: s6, data: d6 } = await req("GET", "/v1/api-keys", undefined, defaultKey);
339
+ const notFound = d6.api_keys.find((k) => k.id === createdKeyId);
340
+ assert(!notFound, "Deleted key removed from list");
341
+ // Delete nonexistent key
342
+ const { status: s7 } = await req("DELETE", "/v1/api-keys/nonexistent-id", undefined, defaultKey);
343
+ assert(s7 === 404, "Delete nonexistent key returns 404");
344
+ // Create with missing name
345
+ const { status: s8 } = await req("POST", "/v1/api-keys", {}, defaultKey);
346
+ assert(s8 === 400, "Create without name returns 400");
347
+ // Create with oversized name
348
+ const { status: s9 } = await req("POST", "/v1/api-keys", { name: "x".repeat(101) }, defaultKey);
349
+ assert(s9 === 400, "Create with name > 100 chars returns 400");
350
+ }
351
+ async function testApiKeyWorkspaceIsolation() {
352
+ console.log("\n--- API key workspace isolation ---");
353
+ // Create a key in default workspace
354
+ const { data: d1 } = await req("POST", "/v1/api-keys", { name: "ws-test" }, defaultKey);
355
+ // Other workspace cannot see it
356
+ const { data: d2 } = await req("GET", "/v1/api-keys", undefined, otherKey);
357
+ const found = d2.api_keys.find((k) => k.id === d1.id);
358
+ assert(!found, "Other workspace cannot see key from different workspace");
359
+ // Other workspace cannot delete it
360
+ const { status: s3 } = await req("DELETE", `/v1/api-keys/${d1.id}`, undefined, otherKey);
361
+ assert(s3 === 404, "Other workspace cannot delete key from different workspace");
362
+ // Clean up
363
+ await req("DELETE", `/v1/api-keys/${d1.id}`, undefined, defaultKey);
364
+ }
365
+ // --- Session Metadata Tests ---
366
+ async function testSessionMetadata() {
367
+ console.log("\n--- Session metadata propagation ---");
368
+ // Create pairing token WITH metadata
369
+ const { status: s1, data: d1 } = await req("POST", "/v1/browser-sessions/pair", {
370
+ label: "Dr. Smith's browser",
371
+ external_user_id: "user_abc123",
372
+ }, defaultKey);
373
+ assert(s1 === 201, "Pairing token with metadata created");
374
+ // Register session
375
+ const { status: s2, data: d2 } = await req("POST", "/v1/browser-sessions/register", {
376
+ pairing_token: d1.pairing_token,
377
+ });
378
+ assert(s2 === 201, "Session registered from token with metadata");
379
+ // List sessions — metadata should be present
380
+ const { data: d3 } = await req("GET", "/v1/browser-sessions", undefined, defaultKey);
381
+ const session = d3.sessions.find((s) => s.id === d2.browser_session_id);
382
+ assert(!!session, "Session appears in list");
383
+ assert(session.label === "Dr. Smith's browser", "Label propagated to session");
384
+ assert(session.external_user_id === "user_abc123", "external_user_id propagated to session");
385
+ }
386
+ async function testSessionMetadataOptional() {
387
+ console.log("\n--- Session metadata optional ---");
388
+ // Create pairing token WITHOUT metadata (backward compatibility)
389
+ const { status: s1, data: d1 } = await req("POST", "/v1/browser-sessions/pair", {}, defaultKey);
390
+ assert(s1 === 201, "Pairing token without metadata created");
391
+ // Register
392
+ const { data: d2 } = await req("POST", "/v1/browser-sessions/register", {
393
+ pairing_token: d1.pairing_token,
394
+ });
395
+ // List — null metadata is fine
396
+ const { data: d3 } = await req("GET", "/v1/browser-sessions", undefined, defaultKey);
397
+ const session = d3.sessions.find((s) => s.id === d2.browser_session_id);
398
+ assert(!!session, "Session without metadata appears in list");
399
+ assert(session.label === null || session.label === undefined, "Label is null when not provided");
400
+ assert(session.external_user_id === null || session.external_user_id === undefined, "external_user_id is null when not provided");
401
+ }
402
+ // --- Legacy Key Prefix Normalization ---
403
+ async function testLegacyKeyPrefixNormalization() {
404
+ console.log("\n--- Legacy key prefix normalization ---");
405
+ // Simulate a legacy key by injecting one without keyPrefix directly into the store
406
+ const { validateApiKey } = await import("./store.js");
407
+ const resolved = validateApiKey(defaultKey);
408
+ const store = await import("./store.js");
409
+ // The store's internal data is not directly accessible, but we can verify
410
+ // that all keys returned by the API have readable prefixes (not raw hashes).
411
+ const { status, data } = await req("GET", "/v1/api-keys", undefined, defaultKey);
412
+ assert(status === 200, "List API keys returns 200");
413
+ for (const k of data.api_keys) {
414
+ assert(k.key_prefix.startsWith("hic_live_") || k.key_prefix.startsWith("hic_live_***"), `Key prefix is readable: ${k.key_prefix.slice(0, 20)}`);
415
+ // Must never be a raw 64-char hex hash
416
+ assert(k.key_prefix.length < 40, `Key prefix is not a raw hash (length ${k.key_prefix.length})`);
417
+ }
418
+ }
419
+ // --- Managed Task Execution (mock relay) ---
420
+ async function testManagedTaskExecution() {
421
+ console.log("\n--- Managed task execution (mock relay) ---");
422
+ // This test proves the full managed path:
423
+ // 1. Create pairing token → 2. Register session → 3. Create task → 4. Task completes
424
+ //
425
+ // Limitation: we cannot test real LLM execution or real browser tool execution
426
+ // locally without a running extension + relay + LLM. What we CAN test:
427
+ // - The happy path up to task creation with a connected session
428
+ // - The mock relay session connectivity check
429
+ //
430
+ // For this test, we need a session that reports as "connected" via the
431
+ // isSessionConnectedFn. The test setup uses () => false, so all sessions
432
+ // appear disconnected. We test the 409 → proves the guard works.
433
+ // A real end-to-end test requires: live relay + extension + LLM.
434
+ // Create and register a session
435
+ const { data: d1 } = await req("POST", "/v1/browser-sessions/pair", {
436
+ label: "e2e test browser",
437
+ external_user_id: "e2e-user-1",
438
+ }, defaultKey);
439
+ const { data: d2 } = await req("POST", "/v1/browser-sessions/register", {
440
+ pairing_token: d1.pairing_token,
441
+ });
442
+ assert(d2.browser_session_id, "Session registered for e2e test");
443
+ // Verify session appears as disconnected (mock returns false)
444
+ const { data: d3 } = await req("GET", "/v1/browser-sessions", undefined, defaultKey);
445
+ const session = d3.sessions.find((s) => s.id === d2.browser_session_id);
446
+ assert(!!session, "Session in list");
447
+ assert(session.status === "disconnected", "Session reports disconnected (mock relay)");
448
+ assert(session.label === "e2e test browser", "Session has label");
449
+ assert(session.external_user_id === "e2e-user-1", "Session has external_user_id");
450
+ // Task creation is correctly rejected because session is not connected
451
+ const { status: s4, data: d4 } = await req("POST", "/v1/tasks", {
452
+ task: "Read the current page title",
453
+ browser_session_id: d2.browser_session_id,
454
+ }, defaultKey);
455
+ assert(s4 === 409, "Task rejected — session not connected (mock)");
456
+ assert(d4.error.includes("not connected"), "Error explains why");
457
+ // This proves: auth → pairing → session ownership → connectivity guard all work.
458
+ // What remains unproven: actual LLM call + tool execution + result retrieval.
459
+ // That requires: live relay, live extension, live LLM — tested by integration.test.ts
460
+ // against production.
461
+ }
462
+ // --- Better Auth Session Cookie ---
463
+ // Better Auth session-cookie auth requires:
464
+ // 1. A running Postgres instance (Better Auth stores sessions in DB)
465
+ // 2. Google OAuth or email/password signup to create a user + session
466
+ // 3. The session cookie to be passed in requests
467
+ //
468
+ // This cannot be tested in the local file-store test harness because:
469
+ // - createAuth() returns null when DATABASE_URL is not set
470
+ // - resolveSessionToWorkspace() returns null when auth is not initialized
471
+ //
472
+ // What we CAN prove: that the authenticate() function falls through correctly
473
+ // when no session cookie is present, and that API key auth still works.
474
+ // The full session-cookie path is proven by integration.test.ts against production.
475
+ async function testSessionCookieAuthFallthrough() {
476
+ console.log("\n--- Session cookie auth fallthrough ---");
477
+ // Request with no auth at all — should get 401
478
+ const { status: s1 } = await req("GET", "/v1/api-keys");
479
+ assert(s1 === 401, "No auth → 401 (cookie auth not available without DB)");
480
+ // Request with a fake cookie — should still get 401 (no DB = no session resolution)
481
+ const res = await fetch(`http://localhost:${PORT}/v1/api-keys`, {
482
+ headers: {
483
+ "Content-Type": "application/json",
484
+ "Cookie": "better-auth.session_token=fake_session_token_123",
485
+ },
486
+ });
487
+ assert(res.status === 401, "Fake session cookie → 401 (no DB backing)");
488
+ // API key auth still works (not broken by cookie auth attempt)
489
+ const { status: s3 } = await req("GET", "/v1/api-keys", undefined, defaultKey);
490
+ assert(s3 === 200, "API key auth still works after cookie fallthrough");
491
+ }
492
+ // --- Stuck-Task Recovery ---
493
+ async function testStuckTaskRecovery() {
494
+ console.log("\n--- Stuck-task recovery ---");
495
+ // Create a task directly in the store with "running" status and an old createdAt
496
+ const { createTaskRun, getTaskRun, validateApiKey, listStuckTasks } = await import("./store.js");
497
+ const resolved = validateApiKey(defaultKey);
498
+ const task = createTaskRun({
499
+ workspaceId: resolved.workspaceId,
500
+ apiKeyId: "test",
501
+ task: "stuck task test",
502
+ browserSessionId: "test-session",
503
+ });
504
+ assert(task.status === "running", "Task starts as running");
505
+ // Manually backdate the task's createdAt to simulate a stuck task
506
+ const stored = getTaskRun(task.id);
507
+ stored.createdAt = Date.now() - 40 * 60 * 1000; // 40 minutes ago
508
+ // listStuckTasks should find it
509
+ const stuck = listStuckTasks(35 * 60 * 1000); // 35-minute threshold
510
+ assert(stuck.some(t => t.id === task.id), "listStuckTasks finds the old running task");
511
+ // Call recoverStuckTasks
512
+ const { recoverStuckTasks } = await import("./api.js");
513
+ await recoverStuckTasks();
514
+ // Task should now be marked as error
515
+ const recovered = getTaskRun(task.id);
516
+ assert(recovered.status === "error", "Stuck task marked as error after recovery");
517
+ assert(recovered.answer.includes("server restart"), "Answer mentions server restart");
518
+ assert(recovered.completedAt > 0, "completedAt is set");
519
+ }
520
+ // --- Request ID Header ---
521
+ async function testRequestIdHeader() {
522
+ console.log("\n--- Request ID in response headers ---");
523
+ // All responses should have X-Request-Id header
524
+ const res1 = await fetch(`http://localhost:${PORT}/v1/health`);
525
+ const rid1 = res1.headers.get("x-request-id");
526
+ assert(!!rid1, "Health response has X-Request-Id header");
527
+ assert(rid1.length === 8, "Request ID is 8 chars");
528
+ // Error responses should also have it
529
+ const res2 = await fetch(`http://localhost:${PORT}/v1/tasks`, {
530
+ method: "POST",
531
+ headers: { "Content-Type": "application/json" },
532
+ body: JSON.stringify({ task: "test" }),
533
+ });
534
+ const rid2 = res2.headers.get("x-request-id");
535
+ assert(!!rid2, "401 error response has X-Request-Id header");
536
+ assert(rid2 !== rid1, "Different requests get different IDs");
537
+ }
538
+ // --- Billing Store Functions ---
539
+ async function testBillingWorkspaceFields() {
540
+ console.log("\n--- Billing workspace fields ---");
541
+ const { createWorkspace, getWorkspace, updateWorkspaceBilling } = await import("./store.js");
542
+ // New workspace defaults to free plan
543
+ const ws = createWorkspace("Billing Test");
544
+ assert(ws.plan === "free", "New workspace defaults to free plan");
545
+ assert(ws.stripeCustomerId === undefined, "No Stripe customer by default");
546
+ assert(ws.subscriptionId === undefined, "No subscription by default");
547
+ assert(ws.subscriptionStatus === undefined, "No subscription status by default");
548
+ // Update billing fields
549
+ const updated = updateWorkspaceBilling(ws.id, {
550
+ stripeCustomerId: "cus_test123",
551
+ plan: "pro",
552
+ subscriptionId: "sub_test456",
553
+ subscriptionStatus: "active",
554
+ });
555
+ assert(updated !== null, "Update returns workspace");
556
+ assert(updated.stripeCustomerId === "cus_test123", "Stripe customer ID persisted");
557
+ assert(updated.plan === "pro", "Plan updated to pro");
558
+ assert(updated.subscriptionId === "sub_test456", "Subscription ID persisted");
559
+ assert(updated.subscriptionStatus === "active", "Subscription status persisted");
560
+ // Verify getWorkspace returns the updated fields
561
+ const fetched = getWorkspace(ws.id);
562
+ assert(fetched.plan === "pro", "getWorkspace reflects updated plan");
563
+ assert(fetched.stripeCustomerId === "cus_test123", "getWorkspace reflects customer ID");
564
+ // Simulate subscription cancellation
565
+ const cancelled = updateWorkspaceBilling(ws.id, {
566
+ plan: "free",
567
+ subscriptionStatus: "cancelled",
568
+ });
569
+ assert(cancelled.plan === "free", "Plan reverted to free");
570
+ assert(cancelled.subscriptionStatus === "cancelled", "Status set to cancelled");
571
+ assert(cancelled.stripeCustomerId === "cus_test123", "Customer ID preserved on cancel");
572
+ // Update nonexistent workspace returns null
573
+ const missing = updateWorkspaceBilling("nonexistent-id", { plan: "pro" });
574
+ assert(missing === null, "Update nonexistent workspace returns null");
575
+ }
576
+ async function testBillingCheckoutEndpoint() {
577
+ console.log("\n--- Billing checkout endpoint ---");
578
+ // Billing is not configured in test environment (no STRIPE_SECRET_KEY)
579
+ const { status, data } = await req("POST", "/v1/billing/checkout", {
580
+ email: "test@example.com",
581
+ }, defaultKey);
582
+ assert(status === 503, "Checkout returns 503 when billing not configured");
583
+ assert(data.error.includes("not configured"), "Error mentions billing not configured");
584
+ }
585
+ // --- Run ---
586
+ async function runAll() {
587
+ await setup();
588
+ console.log("=== HTTP API Tests ===");
589
+ await testHealthNoAuth();
590
+ await testAuthRequired();
591
+ await testBrowserSessionIdRequired();
592
+ await testPairingFlow();
593
+ await testSessionNotConnectedRejectsTask();
594
+ await testWrongWorkspaceSessionRejectsTask();
595
+ await testTaskOwnershipIsolation();
596
+ await testListTasksIsolation();
597
+ await testUsageIsolation();
598
+ await testInputValidation();
599
+ await testRequestBodyLimit();
600
+ await testRateLimiting();
601
+ await testCorsHeaders();
602
+ await testApiKeyCRUD();
603
+ await testApiKeyWorkspaceIsolation();
604
+ await testSessionMetadata();
605
+ await testSessionMetadataOptional();
606
+ await testLegacyKeyPrefixNormalization();
607
+ await testManagedTaskExecution();
608
+ await testSessionCookieAuthFallthrough();
609
+ await testStuckTaskRecovery();
610
+ await testRequestIdHeader();
611
+ await testBillingWorkspaceFields();
612
+ await testBillingCheckoutEndpoint();
613
+ console.log("\n=== All HTTP API tests passed ===\n");
614
+ process.exit(0);
615
+ }
616
+ // Only run when executed directly (not when imported by vitest)
617
+ const isDirectRun = !process.env.VITEST;
618
+ if (isDirectRun) {
619
+ runAll().catch((err) => {
620
+ console.error("\n❌ TEST FAILED:", err.message);
621
+ process.exit(1);
622
+ });
623
+ }