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,1448 @@
1
+ /**
2
+ * Managed API Server
3
+ *
4
+ * REST API for external clients to run browser tasks.
5
+ * Enforces: API key auth, workspace ownership, browser session validation.
6
+ *
7
+ * Endpoints:
8
+ * POST /v1/browser-sessions/pair - Create a pairing token
9
+ * POST /v1/browser-sessions/register - Exchange pairing token for session
10
+ * GET /v1/browser-sessions - List sessions for workspace
11
+ * POST /v1/tasks - Start a task (requires browser_session_id)
12
+ * GET /v1/tasks/:id - Get task status/result
13
+ * POST /v1/tasks/:id/cancel - Cancel a running task
14
+ * GET /v1/tasks - List tasks for workspace
15
+ * GET /v1/usage - Get usage summary
16
+ * POST /v1/api-keys - Create an API key (self-serve)
17
+ * GET /v1/api-keys - List API keys for workspace
18
+ * DELETE /v1/api-keys/:id - Delete an API key
19
+ * GET /v1/health - Health check (no auth)
20
+ */
21
+ import { createServer } from "http";
22
+ import { randomUUID } from "crypto";
23
+ import { log } from "./log.js";
24
+ import { runAgentLoop, } from "../agent/loop.js";
25
+ import * as fileStore from "./store.js";
26
+ import { createAuth, resolveSessionToWorkspace, resolveSessionProfile } from "./auth.js";
27
+ import { isBillingEnabled, createCheckoutSession, handleWebhook, recordTaskUsage } from "./billing.js";
28
+ import { existsSync, readFileSync } from "fs";
29
+ import { join, extname } from "path";
30
+ // Active store module — defaults to file store, can be swapped to Postgres via setStoreModule()
31
+ let S = fileStore;
32
+ /**
33
+ * Swap the backing store (e.g., to Postgres). Called by deploy.ts when DATABASE_URL is set.
34
+ */
35
+ export function setStoreModule(storeModule) {
36
+ S = storeModule;
37
+ }
38
+ let isSessionConnectedFn = null;
39
+ // --- State ---
40
+ let relayConnection = null;
41
+ const taskAborts = new Map();
42
+ /** Maps taskRunId → { workspaceId, startedAt } for concurrent task counting + stuck detection */
43
+ const taskWorkspaceMap = new Map();
44
+ const pendingToolExec = new Map();
45
+ // --- Rate Limiting ---
46
+ /** Per-workspace rate limit: max task creations in a sliding window */
47
+ const RATE_LIMIT_WINDOW_MS = 60_000; // 1 minute
48
+ const RATE_LIMIT_MAX_TASKS = 10; // max 10 task creations per minute per workspace
49
+ const MAX_CONCURRENT_TASKS = 5; // max 5 running tasks per workspace simultaneously
50
+ const rateBuckets = new Map();
51
+ function checkRateLimit(workspaceId) {
52
+ const now = Date.now();
53
+ let bucket = rateBuckets.get(workspaceId);
54
+ if (!bucket) {
55
+ bucket = { timestamps: [] };
56
+ rateBuckets.set(workspaceId, bucket);
57
+ }
58
+ // Purge old entries outside the window
59
+ bucket.timestamps = bucket.timestamps.filter((t) => now - t <= RATE_LIMIT_WINDOW_MS);
60
+ if (bucket.timestamps.length >= RATE_LIMIT_MAX_TASKS) {
61
+ return false; // Rate limit exceeded
62
+ }
63
+ bucket.timestamps.push(now);
64
+ return true;
65
+ }
66
+ function countConcurrentTasks(workspaceId) {
67
+ let count = 0;
68
+ for (const [, entry] of taskWorkspaceMap) {
69
+ if (entry.workspaceId === workspaceId)
70
+ count++;
71
+ }
72
+ return count;
73
+ }
74
+ // Periodic cleanup of stale rate limit buckets (every 5 minutes)
75
+ setInterval(() => {
76
+ const now = Date.now();
77
+ for (const [id, bucket] of rateBuckets) {
78
+ bucket.timestamps = bucket.timestamps.filter((t) => now - t < RATE_LIMIT_WINDOW_MS);
79
+ if (bucket.timestamps.length === 0)
80
+ rateBuckets.delete(id);
81
+ }
82
+ }, 5 * 60_000);
83
+ // Periodic cleanup of stale pendingToolExec entries (orphans from crashed tasks/disconnects)
84
+ const MAX_PENDING_AGE_MS = 2 * 35_000; // 2× max tool timeout (70s)
85
+ setInterval(() => {
86
+ const now = Date.now();
87
+ let cleaned = 0;
88
+ for (const [requestId, pending] of pendingToolExec) {
89
+ if (now - pending.createdAt > MAX_PENDING_AGE_MS) {
90
+ clearTimeout(pending.timeout);
91
+ pendingToolExec.delete(requestId);
92
+ pending.reject(new Error(`Tool execution orphaned (cleanup sweep): ${requestId}`));
93
+ cleaned++;
94
+ }
95
+ }
96
+ if (cleaned > 0) {
97
+ log.warn("Cleaned up orphaned pending tool executions", undefined, { count: cleaned });
98
+ }
99
+ }, 30_000); // Run every 30s
100
+ // Stuck-task janitor: abort and mark tasks that have been running longer than the timeout.
101
+ // Catches: leaked abort controllers, updateTaskRun failures, agent loop hangs.
102
+ const STUCK_TASK_THRESHOLD_MS = 35 * 60 * 1000; // 35 minutes (TASK_TIMEOUT_MS=30m + 5m buffer)
103
+ setInterval(async () => {
104
+ try {
105
+ const now = Date.now();
106
+ for (const [taskId, entry] of taskWorkspaceMap) {
107
+ if (now - entry.startedAt > STUCK_TASK_THRESHOLD_MS) {
108
+ // Task has been running too long — abort and mark as error
109
+ const abort = taskAborts.get(taskId);
110
+ if (abort)
111
+ abort.abort();
112
+ try {
113
+ await S.updateTaskRun(taskId, {
114
+ status: "error",
115
+ answer: "Task exceeded maximum duration (janitor cleanup).",
116
+ completedAt: now,
117
+ });
118
+ }
119
+ catch { }
120
+ taskAborts.delete(taskId);
121
+ taskWorkspaceMap.delete(taskId);
122
+ log.warn("Janitor: cleaned up stuck task", { taskId }, { runningMinutes: Math.round((now - entry.startedAt) / 60000) });
123
+ }
124
+ else if (!taskAborts.has(taskId)) {
125
+ // Task finished but map entry leaked — clean up
126
+ taskWorkspaceMap.delete(taskId);
127
+ }
128
+ }
129
+ }
130
+ catch (err) {
131
+ log.error("Stuck-task janitor error", undefined, { error: err.message });
132
+ }
133
+ }, 5 * 60_000); // Run every 5 minutes
134
+ /**
135
+ * Startup sweep: mark any tasks still "running" from a previous process as errored.
136
+ * Call once after store initialization.
137
+ */
138
+ export async function recoverStuckTasks() {
139
+ try {
140
+ const stuck = await S.listStuckTasks(STUCK_TASK_THRESHOLD_MS);
141
+ for (const task of stuck) {
142
+ await S.updateTaskRun(task.id, {
143
+ status: "error",
144
+ answer: "Task was interrupted by a server restart.",
145
+ completedAt: Date.now(),
146
+ });
147
+ log.info("Startup: marked stuck task as error", { taskId: task.id }, { ageMinutes: Math.round((Date.now() - task.createdAt) / 60000) });
148
+ }
149
+ if (stuck.length > 0) {
150
+ log.info("Startup: recovered stuck tasks", undefined, { count: stuck.length });
151
+ }
152
+ }
153
+ catch (err) {
154
+ log.error("Startup stuck-task recovery failed", undefined, { error: err.message });
155
+ }
156
+ }
157
+ /**
158
+ * Fail all pending tool executions for a disconnected browser session.
159
+ * Called by the relay when a managed session WebSocket closes.
160
+ * This avoids the agent loop waiting up to 15-35s for a timeout on each tool.
161
+ */
162
+ export function onSessionDisconnected(browserSessionId) {
163
+ let failed = 0;
164
+ for (const [requestId, pending] of pendingToolExec) {
165
+ if (pending.browserSessionId === browserSessionId) {
166
+ clearTimeout(pending.timeout);
167
+ pendingToolExec.delete(requestId);
168
+ pending.reject(new Error(`Browser session ${browserSessionId} disconnected`));
169
+ failed++;
170
+ }
171
+ }
172
+ if (failed > 0) {
173
+ log.warn("Failed pending tool executions for disconnected session", { sessionId: browserSessionId }, { count: failed });
174
+ }
175
+ }
176
+ /**
177
+ * Initialize the managed API.
178
+ */
179
+ export function initManagedAPI(relay, sessionConnectedCheck) {
180
+ relayConnection = relay;
181
+ if (sessionConnectedCheck) {
182
+ isSessionConnectedFn = sessionConnectedCheck;
183
+ }
184
+ }
185
+ /**
186
+ * Handle incoming relay messages (tool results from extension).
187
+ */
188
+ export function handleRelayMessage(message) {
189
+ if (message?.type === "tool_result" && message.requestId) {
190
+ const pending = pendingToolExec.get(message.requestId);
191
+ if (pending) {
192
+ clearTimeout(pending.timeout);
193
+ pendingToolExec.delete(message.requestId);
194
+ // Persist tab context if reported by extension — only if the browserSessionId
195
+ // matches the session that initiated this tool execution (prevents cross-session writes).
196
+ if (message.tabContext?.tabId && message.tabContext.browserSessionId === pending.browserSessionId) {
197
+ try {
198
+ void Promise.resolve(S.updateSessionContext(pending.browserSessionId, message.tabContext.tabId, message.tabContext.windowId)).catch(() => { });
199
+ }
200
+ catch { }
201
+ }
202
+ pending.resolve({
203
+ success: !message.error,
204
+ output: message.result ?? message.output,
205
+ error: message.error,
206
+ screenshot: message.screenshot
207
+ ? { data: message.screenshot, mediaType: "image/jpeg" }
208
+ : undefined,
209
+ });
210
+ return true;
211
+ }
212
+ }
213
+ // Handle create_task from sidepanel via relay
214
+ if (message?.type === "create_task" && message.task && message.browserSessionId) {
215
+ handleRelayCreateTask(message).catch(err => {
216
+ log.error("Relay create_task error", undefined, { error: err.message });
217
+ // Send error back to extension
218
+ if (relayConnection && message.browserSessionId) {
219
+ relayConnection.send({
220
+ type: "task_error",
221
+ targetSessionId: message.browserSessionId,
222
+ requestId: message.requestId,
223
+ error: err.message,
224
+ });
225
+ }
226
+ });
227
+ return true;
228
+ }
229
+ return false;
230
+ }
231
+ /**
232
+ * Handle a create_task message from the extension sidepanel via relay.
233
+ * Similar to handleCreateTask but authenticates via browser session instead of API key.
234
+ */
235
+ async function handleRelayCreateTask(message) {
236
+ const { task, url, context, browserSessionId, requestId } = message;
237
+ // Validate task
238
+ if (!task || typeof task !== "string" || task.length > MAX_TASK_LEN) {
239
+ throw new Error("Invalid task");
240
+ }
241
+ // Look up browser session to find workspace
242
+ const session = await S.getBrowserSession(browserSessionId);
243
+ if (!session)
244
+ throw new Error("Browser session not found");
245
+ // Check if session is connected
246
+ const connected = isSessionConnectedFn
247
+ ? isSessionConnectedFn(browserSessionId)
248
+ : session.status === "connected";
249
+ if (!connected) {
250
+ throw new Error("Browser not connected");
251
+ }
252
+ // Check credits
253
+ const allowance = await S.checkTaskAllowance(session.workspaceId);
254
+ if (!allowance.allowed)
255
+ throw new Error(allowance.reason || "No tasks remaining");
256
+ // Rate limit + concurrency
257
+ if (!checkRateLimit(session.workspaceId)) {
258
+ throw new Error(`Rate limit exceeded. Max ${RATE_LIMIT_MAX_TASKS} tasks per minute.`);
259
+ }
260
+ const running = countConcurrentTasks(session.workspaceId);
261
+ if (running >= MAX_CONCURRENT_TASKS) {
262
+ throw new Error(`Concurrent task limit reached (${MAX_CONCURRENT_TASKS}). Wait for running tasks to complete.`);
263
+ }
264
+ // Find a real API key UUID for this workspace (DB requires UUID type)
265
+ const wsKeys = await S.listApiKeys(session.workspaceId);
266
+ const apiKeyId = wsKeys.length > 0 ? wsKeys[0].id : session.workspaceId;
267
+ const taskRun = await S.createTaskRun({
268
+ workspaceId: session.workspaceId,
269
+ apiKeyId,
270
+ task,
271
+ url: url || undefined,
272
+ context: context || undefined,
273
+ browserSessionId,
274
+ });
275
+ const abort = new AbortController();
276
+ taskAborts.set(taskRun.id, abort);
277
+ taskWorkspaceMap.set(taskRun.id, { workspaceId: session.workspaceId, startedAt: Date.now() });
278
+ // Task-level timeout
279
+ const taskTimeout = setTimeout(() => {
280
+ abort.abort();
281
+ log.error("Relay task timed out", { requestId, taskId: taskRun.id, workspaceId: session.workspaceId }, { timeoutMinutes: TASK_TIMEOUT_MS / 60000 });
282
+ }, TASK_TIMEOUT_MS);
283
+ // Send task_started to extension
284
+ if (relayConnection) {
285
+ relayConnection.send({
286
+ type: "task_started",
287
+ targetSessionId: browserSessionId,
288
+ requestId,
289
+ taskId: taskRun.id,
290
+ });
291
+ }
292
+ // Track current step for screenshot association
293
+ let currentStep = 0;
294
+ // Run agent loop in background
295
+ runAgentLoop({
296
+ task,
297
+ url: url || undefined,
298
+ context: context || undefined,
299
+ executeTool: async (toolName, toolInput) => {
300
+ const startMs = Date.now();
301
+ const result = await executeToolViaRelay(toolName, toolInput, browserSessionId);
302
+ // Save screenshot from tool result (best-effort)
303
+ if (result.screenshot?.data) {
304
+ S.insertTaskStep({
305
+ taskRunId: taskRun.id,
306
+ step: currentStep,
307
+ status: "screenshot",
308
+ toolName,
309
+ screenshot: result.screenshot.data,
310
+ durationMs: Date.now() - startMs,
311
+ }).catch(() => { });
312
+ }
313
+ return result;
314
+ },
315
+ onStep: (step) => {
316
+ currentStep = step.step;
317
+ S.updateTaskRun(taskRun.id, { steps: step.step });
318
+ // Persist step details for observability
319
+ S.insertTaskStep({
320
+ taskRunId: taskRun.id,
321
+ step: step.step,
322
+ status: step.status,
323
+ toolName: step.toolName,
324
+ toolInput: step.toolInput,
325
+ output: step.text,
326
+ }).catch(() => { });
327
+ // Send step update to extension via relay
328
+ if (relayConnection) {
329
+ relayConnection.send({
330
+ type: "task_update",
331
+ targetSessionId: browserSessionId,
332
+ requestId,
333
+ taskId: taskRun.id,
334
+ step: { tool: step.toolName, input: step.toolInput, status: step.status },
335
+ steps: step.step,
336
+ });
337
+ }
338
+ },
339
+ maxSteps: 50,
340
+ signal: abort.signal,
341
+ })
342
+ .then(async (result) => {
343
+ const status = result.status === "complete" ? "complete" : "error";
344
+ // Deduct credit ONLY for completed tasks
345
+ if (status === "complete") {
346
+ try {
347
+ const source = await S.deductTaskCredit(session.workspaceId);
348
+ log.info("Relay task credit deducted", { taskId: taskRun.id, workspaceId: session.workspaceId }, { source });
349
+ }
350
+ catch (err) {
351
+ log.warn("Relay task credit deduction failed", { taskId: taskRun.id }, { error: err.message });
352
+ }
353
+ }
354
+ // Record usage
355
+ try {
356
+ await S.recordUsage({
357
+ workspaceId: session.workspaceId,
358
+ apiKeyId,
359
+ taskRunId: taskRun.id,
360
+ inputTokens: result.usage.inputTokens,
361
+ outputTokens: result.usage.outputTokens,
362
+ apiCalls: result.usage.apiCalls,
363
+ model: result.model || "gemini-2.5-flash",
364
+ });
365
+ }
366
+ catch (usageErr) {
367
+ log.warn("Relay task usage recording failed", { taskId: taskRun.id, workspaceId: session.workspaceId }, { error: usageErr.message });
368
+ }
369
+ // Report to Stripe if billing is enabled
370
+ if (isBillingEnabled()) {
371
+ await recordTaskUsage({
372
+ workspaceId: session.workspaceId,
373
+ taskId: taskRun.id,
374
+ steps: result.steps,
375
+ inputTokens: result.usage.inputTokens,
376
+ outputTokens: result.usage.outputTokens,
377
+ }).catch((err) => log.warn("Stripe usage metering failed (relay)", { taskId: taskRun.id }, { error: err.message }));
378
+ }
379
+ // Update task status with retry
380
+ for (let attempt = 0; attempt < 2; attempt++) {
381
+ try {
382
+ await S.updateTaskRun(taskRun.id, {
383
+ status,
384
+ answer: result.answer,
385
+ steps: result.steps,
386
+ usage: result.usage,
387
+ completedAt: Date.now(),
388
+ });
389
+ break;
390
+ }
391
+ catch (updateErr) {
392
+ if (attempt === 0) {
393
+ log.warn("Relay task status update failed, retrying", { taskId: taskRun.id }, { error: updateErr.message });
394
+ await new Promise(r => setTimeout(r, 1000));
395
+ }
396
+ else {
397
+ log.error("Relay task status update FAILED permanently", { taskId: taskRun.id }, { error: updateErr.message });
398
+ }
399
+ }
400
+ }
401
+ // Send completion to extension
402
+ if (relayConnection) {
403
+ relayConnection.send({
404
+ type: "task_complete",
405
+ targetSessionId: browserSessionId,
406
+ requestId,
407
+ taskId: taskRun.id,
408
+ answer: result.answer,
409
+ });
410
+ }
411
+ log.info("Relay task completed", { requestId, taskId: taskRun.id, workspaceId: session.workspaceId }, { status, steps: result.steps });
412
+ })
413
+ .catch(async (err) => {
414
+ for (let attempt = 0; attempt < 2; attempt++) {
415
+ try {
416
+ await S.updateTaskRun(taskRun.id, {
417
+ status: "error",
418
+ answer: `Agent loop crashed: ${err.message}`,
419
+ completedAt: Date.now(),
420
+ });
421
+ break;
422
+ }
423
+ catch (updateErr) {
424
+ if (attempt === 0) {
425
+ await new Promise(r => setTimeout(r, 1000));
426
+ }
427
+ else {
428
+ log.error("Relay task error status update FAILED permanently", { taskId: taskRun.id }, { error: updateErr.message });
429
+ }
430
+ }
431
+ }
432
+ // Send error to extension
433
+ if (relayConnection) {
434
+ relayConnection.send({
435
+ type: "task_error",
436
+ targetSessionId: browserSessionId,
437
+ requestId,
438
+ taskId: taskRun.id,
439
+ error: err.message,
440
+ });
441
+ }
442
+ log.error("Relay task crashed", { requestId, taskId: taskRun.id, workspaceId: session.workspaceId }, { error: err.message });
443
+ })
444
+ .finally(() => {
445
+ clearTimeout(taskTimeout);
446
+ taskAborts.delete(taskRun.id);
447
+ taskWorkspaceMap.delete(taskRun.id);
448
+ });
449
+ }
450
+ /**
451
+ * Execute a tool on a specific browser session via the relay.
452
+ * Uses targetSessionId for session-based routing.
453
+ */
454
+ async function executeToolViaRelay(toolName, toolInput, browserSessionId) {
455
+ if (!relayConnection) {
456
+ throw new Error("Relay not connected");
457
+ }
458
+ const requestId = randomUUID();
459
+ // Per-tool timeout: wait/navigate can take longer; most tools should be fast
460
+ const toolTimeoutMs = toolName === "computer" && toolInput?.action === "wait"
461
+ ? 35_000 // wait action: up to 30s + buffer
462
+ : toolName === "navigate"
463
+ ? 30_000 // navigation can be slow on heavy pages
464
+ : 15_000; // default: 15s for read_page, find, form_input, etc.
465
+ return new Promise((resolve, reject) => {
466
+ const timeout = setTimeout(() => {
467
+ pendingToolExec.delete(requestId);
468
+ reject(new Error(`Tool execution timed out after ${toolTimeoutMs / 1000}s: ${toolName}`));
469
+ }, toolTimeoutMs);
470
+ pendingToolExec.set(requestId, { resolve, reject, timeout, browserSessionId, createdAt: Date.now() });
471
+ // Route to the specific browser session, not "the extension"
472
+ // targetSessionId = relay routing key (consumed by relay)
473
+ // browserSessionId = included in payload so extension knows which session context to use
474
+ relayConnection.send({
475
+ type: "mcp_execute_tool",
476
+ requestId,
477
+ targetSessionId: browserSessionId,
478
+ browserSessionId,
479
+ tool: toolName,
480
+ input: toolInput,
481
+ });
482
+ });
483
+ }
484
+ // --- Auth ---
485
+ function extractApiKey(req) {
486
+ const auth = req.headers.authorization;
487
+ if (auth?.startsWith("Bearer ")) {
488
+ return auth.slice(7);
489
+ }
490
+ return null;
491
+ }
492
+ async function authenticate(req) {
493
+ // Try API key first (developer SDK path)
494
+ const key = extractApiKey(req);
495
+ if (key) {
496
+ return S.validateApiKey(key);
497
+ }
498
+ // Try Better Auth session cookie (first-party app path)
499
+ const sessionInfo = await resolveSessionToWorkspace(req);
500
+ if (sessionInfo) {
501
+ // Find an actual API key for this workspace (needed for UUID columns)
502
+ const wsKeys = await S.listApiKeys(sessionInfo.workspaceId);
503
+ const keyId = wsKeys.length > 0 ? wsKeys[0].id : sessionInfo.workspaceId;
504
+ return {
505
+ id: keyId,
506
+ key: "",
507
+ name: "session",
508
+ workspaceId: sessionInfo.workspaceId,
509
+ createdAt: Date.now(),
510
+ };
511
+ }
512
+ return null;
513
+ }
514
+ // --- Handlers ---
515
+ const MAX_TASK_LEN = 10_000;
516
+ const MAX_CONTEXT_LEN = 50_000;
517
+ const MAX_URL_LEN = 2048;
518
+ const TASK_TIMEOUT_MS = 30 * 60 * 1000; // 30-minute max per task
519
+ async function handleCreateTask(body, apiKey, requestId) {
520
+ const { task, url, context, browser_session_id } = body;
521
+ // --- Input validation first (400 errors don't burn rate limit quota) ---
522
+ if (!task?.trim()) {
523
+ return { status: 400, data: { error: "task is required" } };
524
+ }
525
+ if (typeof task !== "string" || task.length > MAX_TASK_LEN) {
526
+ return { status: 400, data: { error: `task must be a string of 1-${MAX_TASK_LEN} characters` } };
527
+ }
528
+ if (context !== undefined && (typeof context !== "string" || context.length > MAX_CONTEXT_LEN)) {
529
+ return { status: 400, data: { error: `context must be a string under ${MAX_CONTEXT_LEN} characters` } };
530
+ }
531
+ if (url !== undefined) {
532
+ if (typeof url !== "string" || url.length > MAX_URL_LEN) {
533
+ return { status: 400, data: { error: `url must be a string under ${MAX_URL_LEN} characters` } };
534
+ }
535
+ try {
536
+ new URL(url);
537
+ }
538
+ catch {
539
+ return { status: 400, data: { error: "url must be a valid URL" } };
540
+ }
541
+ }
542
+ // browser_session_id is REQUIRED for managed tasks
543
+ if (!browser_session_id) {
544
+ return {
545
+ status: 400,
546
+ data: { error: "browser_session_id is required. Create one via POST /v1/browser-sessions/pair" },
547
+ };
548
+ }
549
+ // --- Credit check (free tier + paid credits) ---
550
+ const allowance = await S.checkTaskAllowance(apiKey.workspaceId);
551
+ if (!allowance.allowed) {
552
+ return {
553
+ status: 402,
554
+ data: {
555
+ error: allowance.reason,
556
+ free_remaining: allowance.freeRemaining,
557
+ credit_balance: allowance.creditBalance,
558
+ },
559
+ };
560
+ }
561
+ // --- Rate limit + concurrency (checked AFTER validation so bad requests don't burn quota) ---
562
+ if (!checkRateLimit(apiKey.workspaceId)) {
563
+ return {
564
+ status: 429,
565
+ data: { error: `Rate limit exceeded. Max ${RATE_LIMIT_MAX_TASKS} tasks per minute.` },
566
+ };
567
+ }
568
+ const running = countConcurrentTasks(apiKey.workspaceId);
569
+ if (running >= MAX_CONCURRENT_TASKS) {
570
+ return {
571
+ status: 429,
572
+ data: { error: `Concurrent task limit reached (${MAX_CONCURRENT_TASKS}). Wait for running tasks to complete.` },
573
+ };
574
+ }
575
+ // Validate session exists and belongs to this workspace
576
+ const session = await S.getBrowserSession(browser_session_id);
577
+ if (!session) {
578
+ return { status: 404, data: { error: "Browser session not found" } };
579
+ }
580
+ if (session.workspaceId !== apiKey.workspaceId) {
581
+ return { status: 403, data: { error: "Browser session does not belong to your workspace" } };
582
+ }
583
+ // Validate session is connected
584
+ const connected = isSessionConnectedFn
585
+ ? isSessionConnectedFn(browser_session_id)
586
+ : session.status === "connected";
587
+ if (!connected) {
588
+ return {
589
+ status: 409,
590
+ data: { error: "Browser session is not connected. The extension must be running and registered." },
591
+ };
592
+ }
593
+ // Check session hasn't expired (relay connectivity alone isn't enough)
594
+ if (session.expiresAt && session.expiresAt < Date.now()) {
595
+ return {
596
+ status: 409,
597
+ data: { error: "Browser session has expired. Re-pair the extension." },
598
+ };
599
+ }
600
+ const taskRun = await S.createTaskRun({
601
+ workspaceId: apiKey.workspaceId,
602
+ apiKeyId: apiKey.id,
603
+ task,
604
+ url,
605
+ context,
606
+ browserSessionId: browser_session_id,
607
+ });
608
+ const abort = new AbortController();
609
+ taskAborts.set(taskRun.id, abort);
610
+ taskWorkspaceMap.set(taskRun.id, { workspaceId: apiKey.workspaceId, startedAt: Date.now() });
611
+ // Task-level timeout — abort if agent loop exceeds max duration
612
+ const taskTimeout = setTimeout(() => {
613
+ abort.abort();
614
+ log.error("Task timed out", { requestId, taskId: taskRun.id, workspaceId: apiKey.workspaceId }, { timeoutMinutes: TASK_TIMEOUT_MS / 60000 });
615
+ }, TASK_TIMEOUT_MS);
616
+ // Track current step for screenshot association
617
+ let currentStep = 0;
618
+ // Run agent loop in background
619
+ runAgentLoop({
620
+ task,
621
+ url,
622
+ context,
623
+ executeTool: async (toolName, toolInput) => {
624
+ const startMs = Date.now();
625
+ const result = await executeToolViaRelay(toolName, toolInput, browser_session_id);
626
+ // Save screenshot from tool result (best-effort)
627
+ if (result.screenshot?.data) {
628
+ S.insertTaskStep({
629
+ taskRunId: taskRun.id,
630
+ step: currentStep,
631
+ status: "screenshot",
632
+ toolName,
633
+ screenshot: result.screenshot.data,
634
+ durationMs: Date.now() - startMs,
635
+ }).catch(() => { });
636
+ }
637
+ return result;
638
+ },
639
+ onStep: (step) => {
640
+ currentStep = step.step;
641
+ S.updateTaskRun(taskRun.id, { steps: step.step });
642
+ // Persist step details for observability
643
+ S.insertTaskStep({
644
+ taskRunId: taskRun.id,
645
+ step: step.step,
646
+ status: step.status,
647
+ toolName: step.toolName,
648
+ toolInput: step.toolInput,
649
+ output: step.text,
650
+ }).catch(() => { }); // best-effort, don't block agent loop
651
+ },
652
+ maxSteps: 50,
653
+ signal: abort.signal,
654
+ })
655
+ .then(async (result) => {
656
+ const status = result.status === "complete" ? "complete" : "error";
657
+ // Deduct credit ONLY for completed tasks — errors/timeouts are free
658
+ if (status === "complete") {
659
+ try {
660
+ const source = await S.deductTaskCredit(apiKey.workspaceId);
661
+ log.info("Task credit deducted", { taskId: taskRun.id, workspaceId: apiKey.workspaceId }, { source });
662
+ }
663
+ catch (err) {
664
+ log.warn("Credit deduction failed", { taskId: taskRun.id }, { error: err.message });
665
+ }
666
+ }
667
+ // Record usage BEFORE marking task complete — if this fails, we retry or log.
668
+ // This ordering prevents "complete task with no billing event" scenarios.
669
+ try {
670
+ await S.recordUsage({
671
+ workspaceId: apiKey.workspaceId,
672
+ apiKeyId: apiKey.id,
673
+ taskRunId: taskRun.id,
674
+ inputTokens: result.usage.inputTokens,
675
+ outputTokens: result.usage.outputTokens,
676
+ apiCalls: result.usage.apiCalls,
677
+ model: result.model || "gemini-2.5-flash",
678
+ });
679
+ }
680
+ catch (usageErr) {
681
+ log.warn("Task usage recording failed", { taskId: taskRun.id, workspaceId: apiKey.workspaceId }, { error: usageErr.message });
682
+ }
683
+ // Report to Stripe if billing is enabled
684
+ if (isBillingEnabled()) {
685
+ await recordTaskUsage({
686
+ workspaceId: apiKey.workspaceId,
687
+ taskId: taskRun.id,
688
+ steps: result.steps,
689
+ inputTokens: result.usage.inputTokens,
690
+ outputTokens: result.usage.outputTokens,
691
+ }).catch((err) => log.warn("Stripe usage metering failed", { taskId: taskRun.id }, { error: err.message }));
692
+ }
693
+ // Retry-safe task status update — if first attempt fails, retry once.
694
+ // Without this, a DB hiccup leaves the task permanently "running".
695
+ let updated = false;
696
+ for (let attempt = 0; attempt < 2; attempt++) {
697
+ try {
698
+ await S.updateTaskRun(taskRun.id, {
699
+ status,
700
+ answer: result.answer,
701
+ steps: result.steps,
702
+ usage: result.usage,
703
+ completedAt: Date.now(),
704
+ });
705
+ updated = true;
706
+ break;
707
+ }
708
+ catch (updateErr) {
709
+ if (attempt === 0) {
710
+ log.warn("Task status update failed, retrying", { taskId: taskRun.id }, { error: updateErr.message });
711
+ await new Promise(r => setTimeout(r, 1000));
712
+ }
713
+ else {
714
+ log.error("Task status update FAILED permanently — may be stuck in running", { taskId: taskRun.id }, { error: updateErr.message });
715
+ }
716
+ }
717
+ }
718
+ if (updated) {
719
+ log.info("Task completed", { requestId, taskId: taskRun.id, workspaceId: apiKey.workspaceId }, { status, steps: result.steps });
720
+ }
721
+ })
722
+ .catch(async (err) => {
723
+ for (let attempt = 0; attempt < 2; attempt++) {
724
+ try {
725
+ await S.updateTaskRun(taskRun.id, {
726
+ status: "error",
727
+ answer: `Agent loop crashed: ${err.message}`,
728
+ completedAt: Date.now(),
729
+ });
730
+ break;
731
+ }
732
+ catch (updateErr) {
733
+ if (attempt === 0) {
734
+ await new Promise(r => setTimeout(r, 1000));
735
+ }
736
+ else {
737
+ log.error("Task error status update FAILED permanently", { taskId: taskRun.id }, { error: updateErr.message });
738
+ }
739
+ }
740
+ }
741
+ log.error("Task crashed", { requestId, taskId: taskRun.id, workspaceId: apiKey.workspaceId }, { error: err.message });
742
+ })
743
+ .finally(() => {
744
+ clearTimeout(taskTimeout);
745
+ taskAborts.delete(taskRun.id);
746
+ taskWorkspaceMap.delete(taskRun.id);
747
+ });
748
+ return {
749
+ status: 201,
750
+ data: {
751
+ id: taskRun.id,
752
+ status: "running",
753
+ task,
754
+ browser_session_id,
755
+ created_at: taskRun.createdAt,
756
+ },
757
+ };
758
+ }
759
+ // --- HTTP Server ---
760
+ const MAX_BODY_BYTES = 128 * 1024; // 128 KB max request body
761
+ function parseBody(req) {
762
+ return new Promise((resolve, reject) => {
763
+ let body = "";
764
+ let bytes = 0;
765
+ req.on("data", (chunk) => {
766
+ bytes += typeof chunk === "string" ? Buffer.byteLength(chunk) : chunk.length;
767
+ if (bytes > MAX_BODY_BYTES) {
768
+ req.destroy();
769
+ reject(new Error("Request body too large"));
770
+ return;
771
+ }
772
+ body += chunk;
773
+ });
774
+ req.on("end", () => {
775
+ try {
776
+ resolve(body ? JSON.parse(body) : {});
777
+ }
778
+ catch {
779
+ reject(new Error("Invalid JSON"));
780
+ }
781
+ });
782
+ req.on("error", reject);
783
+ });
784
+ }
785
+ // Explicit allow-list of origins — production only in production, includes localhost in dev
786
+ const ALLOWED_ORIGINS = [
787
+ "https://browse.hanzilla.co",
788
+ "https://api.hanzilla.co",
789
+ ...(process.env.NODE_ENV === "production" ? [] : [
790
+ "http://localhost:3000",
791
+ "http://localhost:5173", // Vite dev server
792
+ ]),
793
+ ];
794
+ /**
795
+ * Send a JSON response with CORS headers.
796
+ * `req` is passed explicitly — no global mutable state. This is safe under concurrent requests.
797
+ */
798
+ function sendJson(req, res, status, data) {
799
+ const origin = req.headers?.origin || "";
800
+ const headers = {
801
+ "Content-Type": "application/json",
802
+ "Vary": "Origin",
803
+ };
804
+ // Include request ID header if set (available on all responses for tracing)
805
+ const rid = req._requestId;
806
+ if (rid)
807
+ headers["X-Request-Id"] = rid;
808
+ // CORS: only echo back origins from the explicit allow-list.
809
+ // Never use `*` with credentials — browsers reject it per the CORS spec.
810
+ if (ALLOWED_ORIGINS.includes(origin)) {
811
+ headers["Access-Control-Allow-Origin"] = origin;
812
+ headers["Access-Control-Allow-Methods"] = "GET, POST, DELETE, OPTIONS";
813
+ headers["Access-Control-Allow-Headers"] = "Content-Type, Authorization, X-Workspace-Id";
814
+ headers["Access-Control-Allow-Credentials"] = "true";
815
+ }
816
+ res.writeHead(status, headers);
817
+ res.end(JSON.stringify(data));
818
+ }
819
+ async function handleRequest(req, res) {
820
+ const { method, url } = req;
821
+ const requestId = randomUUID().slice(0, 8);
822
+ req._requestId = requestId;
823
+ if (method === "OPTIONS") {
824
+ // CORS preflight — return headers with empty body (204 No Content)
825
+ const origin = req.headers?.origin || "";
826
+ const headers = { "Vary": "Origin" };
827
+ if (ALLOWED_ORIGINS.includes(origin)) {
828
+ headers["Access-Control-Allow-Origin"] = origin;
829
+ headers["Access-Control-Allow-Methods"] = "GET, POST, DELETE, OPTIONS";
830
+ headers["Access-Control-Allow-Headers"] = "Content-Type, Authorization, X-Workspace-Id";
831
+ headers["Access-Control-Allow-Credentials"] = "true";
832
+ headers["Access-Control-Max-Age"] = "86400";
833
+ }
834
+ res.writeHead(204, headers);
835
+ res.end();
836
+ return;
837
+ }
838
+ try {
839
+ // --- Better Auth routes (/api/auth/*) ---
840
+ if (url?.startsWith("/api/auth")) {
841
+ const auth = createAuth();
842
+ if (auth) {
843
+ // Use Better Auth's built-in Node handler for correct OAuth flow
844
+ try {
845
+ const { toNodeHandler } = await import("better-auth/node");
846
+ const handler = toNodeHandler(auth);
847
+ await handler(req, res);
848
+ }
849
+ catch (authErr) {
850
+ log.error("Better Auth handler error", { requestId }, { error: authErr.message, url });
851
+ if (!res.headersSent) {
852
+ sendJson(req, res, 500, { error: "Auth error: " + authErr.message });
853
+ }
854
+ }
855
+ return;
856
+ }
857
+ sendJson(req, res, 503, { error: "Auth not configured. Set DATABASE_URL and Google OAuth credentials." });
858
+ return;
859
+ }
860
+ // --- Dashboard + root redirect ---
861
+ // Serve dashboard static files from dist/dashboard/
862
+ if (method === "GET" && url?.startsWith("/dashboard")) {
863
+ const thisFile = new URL(import.meta.url).pathname;
864
+ const dashboardDir = join(thisFile, "../../dashboard");
865
+ let filePath = url === "/dashboard" || url === "/dashboard/"
866
+ ? join(dashboardDir, "index.html")
867
+ : join(dashboardDir, url.replace("/dashboard/", ""));
868
+ if (existsSync(filePath)) {
869
+ const ext = extname(filePath);
870
+ const mimeTypes = {
871
+ ".html": "text/html", ".js": "application/javascript",
872
+ ".css": "text/css", ".json": "application/json",
873
+ ".svg": "image/svg+xml", ".png": "image/png",
874
+ };
875
+ const cacheControl = ext === ".html" ? "no-cache, no-store, must-revalidate" : "public, max-age=31536000, immutable";
876
+ res.writeHead(200, { "Content-Type": mimeTypes[ext] || "application/octet-stream", "Cache-Control": cacheControl });
877
+ res.end(readFileSync(filePath));
878
+ return;
879
+ }
880
+ // SPA fallback — serve index.html for unmatched dashboard routes
881
+ const indexPath = join(dashboardDir, "index.html");
882
+ if (existsSync(indexPath)) {
883
+ res.writeHead(200, { "Content-Type": "text/html", "Cache-Control": "no-cache, no-store, must-revalidate" });
884
+ res.end(readFileSync(indexPath));
885
+ return;
886
+ }
887
+ }
888
+ if (method === "GET" && url === "/") {
889
+ // Authenticated users → dashboard. Others → landing page.
890
+ const session = await resolveSessionToWorkspace(req);
891
+ if (session) {
892
+ res.writeHead(302, { Location: "/dashboard" });
893
+ res.end();
894
+ }
895
+ else {
896
+ res.writeHead(302, { Location: "https://browse.hanzilla.co" });
897
+ res.end();
898
+ }
899
+ return;
900
+ }
901
+ // --- Serve landing pages locally (docs, etc.) ---
902
+ if (method === "GET" && (url === "/docs.html" || url?.startsWith("/docs.html"))) {
903
+ const landingDir = join(process.cwd(), "landing");
904
+ const filePath = join(landingDir, url === "/docs.html" || url?.startsWith("/docs.html") ? "docs.html" : "index.html");
905
+ if (existsSync(filePath)) {
906
+ res.writeHead(200, { "Content-Type": "text/html" });
907
+ res.end(readFileSync(filePath));
908
+ return;
909
+ }
910
+ }
911
+ // --- Embeddable pairing snippet ---
912
+ if (method === "GET" && url === "/hanzi-pair.js") {
913
+ const snippetPath = join(process.cwd(), "sdk/hanzi-pair.js");
914
+ if (existsSync(snippetPath)) {
915
+ res.writeHead(200, {
916
+ "Content-Type": "application/javascript",
917
+ "Access-Control-Allow-Origin": "*",
918
+ "Cache-Control": "public, max-age=3600",
919
+ });
920
+ res.end(readFileSync(snippetPath));
921
+ }
922
+ else {
923
+ res.writeHead(404);
924
+ res.end("Not found");
925
+ }
926
+ return;
927
+ }
928
+ // --- Self-service pairing for direct sidepanel users (/pair-self) ---
929
+ // One-click: sign in → auto-create workspace → auto-pair extension
930
+ if (method === "GET" && url === "/pair-self") {
931
+ const session = await resolveSessionToWorkspace(req);
932
+ if (!session) {
933
+ // Not signed in — redirect to Google OAuth, come back after
934
+ res.writeHead(302, { Location: "/api/auth/sign-in/social?provider=google&callbackURL=/pair-self" });
935
+ res.end();
936
+ return;
937
+ }
938
+ // User is signed in — auto-create a pairing token for their workspace
939
+ try {
940
+ const wsKeys = await S.listApiKeys(session.workspaceId);
941
+ const createdBy = wsKeys.length > 0 ? wsKeys[0].id : session.workspaceId;
942
+ const token = await S.createPairingToken(session.workspaceId, createdBy, { label: "Sidepanel" });
943
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
944
+ res.end(getSelfPairPageHtml(token._plainToken, req.headers.host || ""));
945
+ }
946
+ catch (err) {
947
+ res.writeHead(500, { "Content-Type": "text/html" });
948
+ res.end(`<html><body><p>Error: ${err.message}</p><a href="/pair-self">Try again</a></body></html>`);
949
+ }
950
+ return;
951
+ }
952
+ // --- Hosted pairing page (/pair/:token) ---
953
+ const pairMatch = url?.match(/^\/pair\/(.+)$/);
954
+ if (method === "GET" && pairMatch) {
955
+ const token = pairMatch[1];
956
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
957
+ res.end(getPairingPageHtml(token, req.headers.host || ""));
958
+ return;
959
+ }
960
+ // --- No-auth endpoints ---
961
+ if (method === "GET" && url === "/v1/health") {
962
+ let dbOk = true;
963
+ try {
964
+ // Use a valid UUID that won't match any real workspace.
965
+ // Returns null (not found) if DB is up. Throws if DB is down.
966
+ await Promise.resolve(S.getWorkspace("00000000-0000-0000-0000-000000000000"));
967
+ }
968
+ catch {
969
+ dbOk = false;
970
+ }
971
+ const allOk = !!relayConnection && dbOk;
972
+ sendJson(req, res, allOk ? 200 : 503, {
973
+ status: allOk ? "ok" : "degraded",
974
+ version: process.env.npm_package_version || "dev",
975
+ uptime_seconds: Math.round(process.uptime()),
976
+ store_type: process.env.DATABASE_URL ? "postgres" : "file",
977
+ relay_connected: !!relayConnection,
978
+ database_connected: dbOk,
979
+ active_tasks: taskAborts.size,
980
+ pending_tool_executions: pendingToolExec.size,
981
+ });
982
+ return;
983
+ }
984
+ // Debug: show cookies received
985
+ if (method === "GET" && url === "/v1/debug-cookies") {
986
+ const cookies = req.headers.cookie || '(none)';
987
+ const cookieNames = cookies === '(none)' ? [] : cookies.split(';').map((c) => c.trim().split('=')[0]);
988
+ sendJson(req, res, 200, { cookieNames, rawCookieHeader: cookies.substring(0, 200) });
989
+ return;
990
+ }
991
+ // Profile endpoint (session cookie auth — for developer console)
992
+ if (method === "GET" && url === "/v1/me") {
993
+ let profile = await resolveSessionProfile(req);
994
+ if (!profile) {
995
+ // Debug: try reading session directly from cookie + DB
996
+ const cookieHeader = req.headers.cookie || '';
997
+ const tokenMatch = cookieHeader.match(/better-auth[.\-]session_token=([^;.\s]+)/);
998
+ const rawToken = tokenMatch ? decodeURIComponent(tokenMatch[1]) : null;
999
+ sendJson(req, res, 401, { error: "Not signed in", debug: { rawToken: rawToken?.substring(0, 10), cookieHeader: cookieHeader.substring(0, 100) } });
1000
+ return;
1001
+ }
1002
+ sendJson(req, res, 200, {
1003
+ user: { name: profile.userName, email: profile.userEmail },
1004
+ workspace: { id: profile.workspaceId, name: profile.workspaceName, plan: profile.plan },
1005
+ });
1006
+ return;
1007
+ }
1008
+ // Stripe webhook (no API key — uses Stripe signature verification)
1009
+ if (method === "POST" && url === "/v1/billing/webhook") {
1010
+ if (!isBillingEnabled()) {
1011
+ sendJson(req, res, 503, { error: "Billing not configured" });
1012
+ return;
1013
+ }
1014
+ const rawBody = await new Promise((resolve, reject) => {
1015
+ let body = "";
1016
+ req.on("data", (chunk) => (body += chunk));
1017
+ req.on("end", () => resolve(body));
1018
+ req.on("error", reject);
1019
+ });
1020
+ const sig = req.headers["stripe-signature"];
1021
+ if (!sig) {
1022
+ sendJson(req, res, 400, { error: "Missing stripe-signature header" });
1023
+ return;
1024
+ }
1025
+ const result = await handleWebhook(rawBody, sig);
1026
+ sendJson(req, res, result.handled ? 200 : 400, { received: result.handled, event: result.event });
1027
+ return;
1028
+ }
1029
+ // Browser session registration (uses pairing token, not API key)
1030
+ if (method === "POST" && url === "/v1/browser-sessions/register") {
1031
+ const body = await parseBody(req);
1032
+ const { pairing_token } = body;
1033
+ if (!pairing_token) {
1034
+ sendJson(req, res, 400, { error: "pairing_token is required" });
1035
+ return;
1036
+ }
1037
+ const session = await S.consumePairingToken(pairing_token);
1038
+ if (!session) {
1039
+ sendJson(req, res, 401, { error: "Invalid, expired, or already consumed pairing token" });
1040
+ return;
1041
+ }
1042
+ sendJson(req, res, 201, {
1043
+ browser_session_id: session.id,
1044
+ session_token: session.sessionToken,
1045
+ workspace_id: session.workspaceId,
1046
+ });
1047
+ return;
1048
+ }
1049
+ // --- Authenticated endpoints ---
1050
+ const apiKey = await authenticate(req);
1051
+ if (!apiKey) {
1052
+ sendJson(req, res, 401, {
1053
+ error: "Authentication required. Use Authorization: Bearer hic_live_xxx (API key) or sign in at /api/auth/sign-in/social",
1054
+ });
1055
+ return;
1056
+ }
1057
+ // --- Browser Sessions ---
1058
+ // Create pairing token
1059
+ if (method === "POST" && url === "/v1/browser-sessions/pair") {
1060
+ const body = await parseBody(req);
1061
+ const label = typeof body.label === "string" ? body.label.slice(0, 200) : undefined;
1062
+ const externalUserId = typeof body.external_user_id === "string" ? body.external_user_id.slice(0, 200) : undefined;
1063
+ const token = await S.createPairingToken(apiKey.workspaceId, apiKey.id, { label, externalUserId });
1064
+ sendJson(req, res, 201, {
1065
+ pairing_token: token._plainToken,
1066
+ expires_at: token.expiresAt,
1067
+ expires_in_seconds: Math.round((token.expiresAt - Date.now()) / 1000),
1068
+ });
1069
+ return;
1070
+ }
1071
+ // List browser sessions
1072
+ if (method === "GET" && url === "/v1/browser-sessions") {
1073
+ const sessions = await S.listBrowserSessions(apiKey.workspaceId);
1074
+ sendJson(req, res, 200, {
1075
+ sessions: sessions.map((s) => ({
1076
+ id: s.id,
1077
+ status: isSessionConnectedFn ? (isSessionConnectedFn(s.id) ? "connected" : "disconnected") : s.status,
1078
+ connected_at: s.connectedAt,
1079
+ last_heartbeat: s.lastHeartbeat,
1080
+ label: s.label || null,
1081
+ external_user_id: s.externalUserId || null,
1082
+ })),
1083
+ });
1084
+ return;
1085
+ }
1086
+ // Delete a browser session
1087
+ const sessionMatch = url?.match(/^\/v1\/browser-sessions\/([^/]+)$/);
1088
+ if (sessionMatch && method === "DELETE") {
1089
+ const sessionId = sessionMatch[1];
1090
+ const deleted = await S.deleteBrowserSession(sessionId, apiKey.workspaceId);
1091
+ if (!deleted) {
1092
+ sendJson(req, res, 404, { error: "Session not found" });
1093
+ return;
1094
+ }
1095
+ sendJson(req, res, 200, { id: sessionId, deleted: true });
1096
+ return;
1097
+ }
1098
+ // --- Tasks ---
1099
+ if (method === "POST" && url === "/v1/tasks") {
1100
+ const body = await parseBody(req);
1101
+ const result = await handleCreateTask(body, apiKey, requestId);
1102
+ sendJson(req, res, result.status, result.data);
1103
+ return;
1104
+ }
1105
+ if (method === "GET" && url === "/v1/tasks") {
1106
+ const tasks = await S.listTaskRuns(apiKey.workspaceId);
1107
+ sendJson(req, res, 200, { tasks });
1108
+ return;
1109
+ }
1110
+ const taskMatch = url?.match(/^\/v1\/tasks\/([^/]+)(\/cancel|\/steps|\/screenshots\/(\d+))?$/);
1111
+ if (taskMatch) {
1112
+ const taskId = taskMatch[1];
1113
+ const run = await S.getTaskRun(taskId);
1114
+ if (!run) {
1115
+ sendJson(req, res, 404, { error: "Task not found" });
1116
+ return;
1117
+ }
1118
+ // Enforce workspace ownership
1119
+ if (run.workspaceId !== apiKey.workspaceId) {
1120
+ sendJson(req, res, 404, { error: "Task not found" }); // 404, not 403 — don't leak existence
1121
+ return;
1122
+ }
1123
+ // GET /v1/tasks/:id/steps — execution timeline
1124
+ if (method === "GET" && taskMatch[2] === "/steps") {
1125
+ const steps = await S.getTaskSteps(taskId);
1126
+ sendJson(req, res, 200, { steps });
1127
+ return;
1128
+ }
1129
+ // GET /v1/tasks/:id/screenshots/:step — screenshot at a specific step
1130
+ if (method === "GET" && taskMatch[3]) {
1131
+ const stepNum = parseInt(taskMatch[3], 10);
1132
+ const screenshot = await S.getTaskStepScreenshot(taskId, stepNum);
1133
+ if (!screenshot) {
1134
+ sendJson(req, res, 404, { error: "No screenshot at this step" });
1135
+ return;
1136
+ }
1137
+ const buf = Buffer.from(screenshot, "base64");
1138
+ res.writeHead(200, { "Content-Type": "image/jpeg", "Content-Length": buf.length });
1139
+ res.end(buf);
1140
+ return;
1141
+ }
1142
+ if (method === "GET" && !taskMatch[2]) {
1143
+ sendJson(req, res, 200, {
1144
+ id: run.id,
1145
+ status: run.status,
1146
+ task: run.task,
1147
+ answer: run.answer,
1148
+ steps: run.steps,
1149
+ usage: run.usage,
1150
+ browser_session_id: run.browserSessionId,
1151
+ created_at: run.createdAt,
1152
+ completed_at: run.completedAt,
1153
+ });
1154
+ return;
1155
+ }
1156
+ if (method === "POST" && taskMatch[2] === "/cancel") {
1157
+ if (run.status !== "running") {
1158
+ sendJson(req, res, 400, { error: "Task is not running" });
1159
+ return;
1160
+ }
1161
+ const abort = taskAborts.get(taskId);
1162
+ if (abort)
1163
+ abort.abort();
1164
+ await S.updateTaskRun(taskId, { status: "cancelled", completedAt: Date.now() });
1165
+ taskAborts.delete(taskId);
1166
+ taskWorkspaceMap.delete(taskId);
1167
+ sendJson(req, res, 200, { id: taskId, status: "cancelled" });
1168
+ return;
1169
+ }
1170
+ }
1171
+ // --- Usage ---
1172
+ if (method === "GET" && url === "/v1/usage") {
1173
+ const summary = await S.getUsageSummary(apiKey.workspaceId);
1174
+ sendJson(req, res, 200, summary);
1175
+ return;
1176
+ }
1177
+ // --- API Keys (self-serve) ---
1178
+ if (method === "POST" && url === "/v1/api-keys") {
1179
+ const body = await parseBody(req);
1180
+ const name = body.name?.trim();
1181
+ if (!name || typeof name !== "string" || name.length > 100) {
1182
+ sendJson(req, res, 400, { error: "name is required (string, max 100 chars)" });
1183
+ return;
1184
+ }
1185
+ const newKey = await S.createApiKey(apiKey.workspaceId, name);
1186
+ sendJson(req, res, 201, {
1187
+ id: newKey.id,
1188
+ key: newKey.key, // plaintext — shown once
1189
+ name: newKey.name,
1190
+ created_at: newKey.createdAt,
1191
+ workspace_id: newKey.workspaceId,
1192
+ _warning: "Save this key now. It will not be shown again.",
1193
+ });
1194
+ return;
1195
+ }
1196
+ if (method === "GET" && url === "/v1/api-keys") {
1197
+ const keys = await S.listApiKeys(apiKey.workspaceId);
1198
+ sendJson(req, res, 200, {
1199
+ api_keys: keys.map((k) => ({
1200
+ id: k.id,
1201
+ key_prefix: k.keyPrefix ? k.keyPrefix + "..." : k.key.slice(0, 12) + "...",
1202
+ name: k.name,
1203
+ created_at: k.createdAt,
1204
+ last_used_at: k.lastUsedAt,
1205
+ })),
1206
+ });
1207
+ return;
1208
+ }
1209
+ const apiKeyMatch = url?.match(/^\/v1\/api-keys\/([^/]+)$/);
1210
+ if (apiKeyMatch && method === "DELETE") {
1211
+ const keyId = apiKeyMatch[1];
1212
+ const deleted = await S.deleteApiKey(keyId, apiKey.workspaceId);
1213
+ if (!deleted) {
1214
+ sendJson(req, res, 404, { error: "API key not found" });
1215
+ return;
1216
+ }
1217
+ sendJson(req, res, 200, { id: keyId, deleted: true });
1218
+ return;
1219
+ }
1220
+ // --- Billing ---
1221
+ // GET /v1/billing/credits — check credit balance + free tier status
1222
+ if (method === "GET" && url === "/v1/billing/credits") {
1223
+ const allowance = await S.checkTaskAllowance(apiKey.workspaceId);
1224
+ sendJson(req, res, 200, {
1225
+ free_remaining: allowance.freeRemaining,
1226
+ credit_balance: allowance.creditBalance,
1227
+ free_tasks_per_month: 20,
1228
+ });
1229
+ return;
1230
+ }
1231
+ // POST /v1/billing/checkout — buy credits
1232
+ if (method === "POST" && url === "/v1/billing/checkout") {
1233
+ if (!isBillingEnabled()) {
1234
+ sendJson(req, res, 503, { error: "Billing not configured. Contact support." });
1235
+ return;
1236
+ }
1237
+ const body = await parseBody(req);
1238
+ const session = await createCheckoutSession({
1239
+ workspaceId: apiKey.workspaceId,
1240
+ userId: apiKey.id,
1241
+ email: body.email,
1242
+ credits: body.credits || 100,
1243
+ successUrl: body.success_url || "https://api.hanzilla.co/dashboard?checkout=success",
1244
+ cancelUrl: body.cancel_url || "https://api.hanzilla.co/dashboard?checkout=cancel",
1245
+ });
1246
+ sendJson(req, res, 200, session);
1247
+ return;
1248
+ }
1249
+ sendJson(req, res, 404, { error: "Not found" });
1250
+ }
1251
+ catch (err) {
1252
+ log.error("Request error", { requestId }, { method, url, error: err.message });
1253
+ sendJson(req, res, 500, { error: err.message, request_id: requestId });
1254
+ }
1255
+ }
1256
+ export function startManagedAPI(port = 3456) {
1257
+ const host = process.env.NODE_ENV === "production" ? "127.0.0.1" : "0.0.0.0";
1258
+ const server = createServer(handleRequest);
1259
+ server.listen(port, host, () => {
1260
+ log.info("Managed API listening", undefined, { host, port });
1261
+ });
1262
+ }
1263
+ /**
1264
+ * Graceful shutdown: abort all running tasks and update their status.
1265
+ * Called on SIGTERM/SIGINT to avoid leaving tasks in a permanent "running" state.
1266
+ */
1267
+ export async function shutdownManagedAPI() {
1268
+ const runningCount = taskAborts.size;
1269
+ if (runningCount === 0)
1270
+ return;
1271
+ log.info("Shutting down: aborting running tasks", undefined, { count: runningCount });
1272
+ const shutdownPromises = [];
1273
+ for (const [taskId, abort] of taskAborts) {
1274
+ abort.abort();
1275
+ shutdownPromises.push((async () => {
1276
+ try {
1277
+ await Promise.resolve(S.updateTaskRun(taskId, {
1278
+ status: "error",
1279
+ answer: "Task interrupted by server shutdown.",
1280
+ completedAt: Date.now(),
1281
+ }));
1282
+ }
1283
+ catch (err) {
1284
+ log.error("Failed to update task on shutdown", { taskId }, { error: err.message });
1285
+ }
1286
+ })());
1287
+ }
1288
+ await Promise.allSettled(shutdownPromises);
1289
+ taskAborts.clear();
1290
+ taskWorkspaceMap.clear();
1291
+ log.info("Shutdown complete", undefined, { tasksAborted: runningCount });
1292
+ }
1293
+ // ─── Self-Service Pairing Page (for direct sidepanel users) ─────
1294
+ function getSelfPairPageHtml(token, host) {
1295
+ const apiUrl = host.includes("localhost") ? `http://${host}` : `https://${host}`;
1296
+ const safeToken = token.replace(/[<>"'&]/g, "");
1297
+ return `<!DOCTYPE html>
1298
+ <html lang="en">
1299
+ <head>
1300
+ <meta charset="UTF-8">
1301
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
1302
+ <title>Connecting — Hanzi</title>
1303
+ <style>
1304
+ * { box-sizing: border-box; margin: 0; padding: 0; }
1305
+ body { min-height: 100vh; display: flex; align-items: center; justify-content: center; background: #f7f3ea; color: #1f1711; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; padding: 20px; }
1306
+ .card { max-width: 420px; width: 100%; background: #fffdf8; border: 1px solid #e5ddd0; border-radius: 16px; padding: 32px; text-align: center; }
1307
+ h1 { font-size: 22px; font-weight: 700; margin-bottom: 8px; }
1308
+ p { font-size: 15px; color: #6d6256; line-height: 1.6; margin-bottom: 20px; }
1309
+ .status { padding: 16px; border-radius: 10px; margin-bottom: 16px; font-size: 14px; font-weight: 500; }
1310
+ .status-connecting { background: #fceee4; color: #8d4524; }
1311
+ .status-success { background: #e8f0ec; color: #2f4a3d; }
1312
+ .status-error { background: #fce4e4; color: #c62828; }
1313
+ .spinner { display: inline-block; width: 16px; height: 16px; border: 2px solid #e5ddd0; border-top-color: #ad5a34; border-radius: 50%; animation: spin 0.8s linear infinite; margin-right: 8px; vertical-align: middle; }
1314
+ @keyframes spin { to { transform: rotate(360deg); } }
1315
+ .small { font-size: 12px; color: #6d6256; margin-top: 12px; }
1316
+ </style>
1317
+ </head>
1318
+ <body>
1319
+ <div class="card">
1320
+ <h1>Connecting your browser</h1>
1321
+ <p>This will connect Hanzi to your account so you can browse with managed AI.</p>
1322
+ <div id="status" class="status status-connecting">
1323
+ <span class="spinner"></span> Connecting to extension...
1324
+ </div>
1325
+ <p class="small">You can close this tab after connecting.</p>
1326
+ </div>
1327
+ <script>
1328
+ const TOKEN = "${safeToken}";
1329
+ const API_URL = "${apiUrl}";
1330
+ const statusEl = document.getElementById("status");
1331
+ let paired = false;
1332
+
1333
+ window.addEventListener("message", (e) => {
1334
+ if (e.data?.type === "HANZI_EXTENSION_READY" && !paired) {
1335
+ pair();
1336
+ }
1337
+ if (e.data?.type === "HANZI_PAIR_RESULT") {
1338
+ paired = true;
1339
+ if (e.data.success) {
1340
+ statusEl.className = "status status-success";
1341
+ statusEl.innerHTML = "✓ Connected! You can close this tab and use the sidepanel.";
1342
+ } else {
1343
+ statusEl.className = "status status-error";
1344
+ statusEl.innerHTML = "Failed: " + (e.data.error || "unknown error");
1345
+ }
1346
+ }
1347
+ });
1348
+
1349
+ function pair() {
1350
+ statusEl.innerHTML = '<span class="spinner"></span> Pairing...';
1351
+ window.postMessage({ type: "HANZI_PAIR", token: TOKEN, apiUrl: API_URL }, "*");
1352
+ }
1353
+
1354
+ window.postMessage({ type: "HANZI_PING" }, "*");
1355
+ setTimeout(() => {
1356
+ if (!paired) {
1357
+ statusEl.className = "status status-error";
1358
+ statusEl.innerHTML = 'Hanzi extension not detected. <a href="https://chromewebstore.google.com/detail/hanzi-browse/iklpkemlmbhemkiojndpbhoakgikpmcd" target="_blank" style="color:#ad5a34;font-weight:600">Install it</a>, then reload this page.';
1359
+ }
1360
+ }, 3000);
1361
+ </script>
1362
+ </body>
1363
+ </html>`;
1364
+ }
1365
+ // ─── Hosted Pairing Page (for developer integration) ─────
1366
+ function getPairingPageHtml(token, host) {
1367
+ const apiUrl = host.includes("localhost") ? `http://${host}` : `https://${host}`;
1368
+ const extensionUrl = "https://chromewebstore.google.com/detail/hanzi-browse/iklpkemlmbhemkiojndpbhoakgikpmcd";
1369
+ // Escape token for safe embedding in HTML
1370
+ const safeToken = token.replace(/[<>"'&]/g, "");
1371
+ return `<!DOCTYPE html>
1372
+ <html lang="en">
1373
+ <head>
1374
+ <meta charset="UTF-8">
1375
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
1376
+ <title>Connect your browser — Hanzi</title>
1377
+ <style>
1378
+ * { box-sizing: border-box; margin: 0; padding: 0; }
1379
+ body { min-height: 100vh; display: flex; align-items: center; justify-content: center; background: #f7f3ea; color: #1f1711; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; padding: 20px; }
1380
+ .card { max-width: 420px; width: 100%; background: #fffdf8; border: 1px solid #e5ddd0; border-radius: 16px; padding: 32px; text-align: center; }
1381
+ h1 { font-size: 22px; font-weight: 700; margin-bottom: 8px; }
1382
+ p { font-size: 15px; color: #6d6256; line-height: 1.6; margin-bottom: 20px; }
1383
+ .status { padding: 16px; border-radius: 10px; margin-bottom: 16px; font-size: 14px; font-weight: 500; }
1384
+ .status-connecting { background: #fceee4; color: #8d4524; }
1385
+ .status-success { background: #e8f0ec; color: #2f4a3d; }
1386
+ .status-error { background: #fce4e4; color: #c62828; }
1387
+ .status-install { background: #f5f1e8; color: #6d6256; }
1388
+ a { color: #ad5a34; font-weight: 600; text-decoration: none; }
1389
+ a:hover { text-decoration: underline; }
1390
+ .spinner { display: inline-block; width: 16px; height: 16px; border: 2px solid #e5ddd0; border-top-color: #ad5a34; border-radius: 50%; animation: spin 0.8s linear infinite; margin-right: 8px; vertical-align: middle; }
1391
+ @keyframes spin { to { transform: rotate(360deg); } }
1392
+ .small { font-size: 12px; color: #6d6256; margin-top: 12px; }
1393
+ </style>
1394
+ </head>
1395
+ <body>
1396
+ <div class="card">
1397
+ <h1>Connect your browser</h1>
1398
+ <p>This will connect your Chrome browser so the app can run tasks in it securely.</p>
1399
+ <div id="status" class="status status-connecting">
1400
+ <span class="spinner"></span> Detecting Hanzi extension...
1401
+ </div>
1402
+ <p class="small">Powered by <a href="https://browse.hanzilla.co">Hanzi</a></p>
1403
+ </div>
1404
+
1405
+ <script>
1406
+ const TOKEN = "${safeToken}";
1407
+ const API_URL = "${apiUrl}";
1408
+ const EXTENSION_URL = "${extensionUrl}";
1409
+ const statusEl = document.getElementById("status");
1410
+
1411
+ let extensionReady = false;
1412
+
1413
+ window.addEventListener("message", (e) => {
1414
+ if (e.data?.type === "HANZI_EXTENSION_READY") {
1415
+ extensionReady = true;
1416
+ pair();
1417
+ }
1418
+ if (e.data?.type === "HANZI_PAIR_RESULT") {
1419
+ if (e.data.success) {
1420
+ statusEl.className = "status status-success";
1421
+ statusEl.innerHTML = "✓ Browser connected! You can close this tab.";
1422
+ } else {
1423
+ statusEl.className = "status status-error";
1424
+ statusEl.innerHTML = "Pairing failed: " + (e.data.error || "unknown error") + ". The token may have expired.";
1425
+ }
1426
+ }
1427
+ });
1428
+
1429
+ function pair() {
1430
+ statusEl.className = "status status-connecting";
1431
+ statusEl.innerHTML = '<span class="spinner"></span> Connecting...';
1432
+ window.postMessage({ type: "HANZI_PAIR", token: TOKEN, apiUrl: API_URL }, "*");
1433
+ }
1434
+
1435
+ // Ping extension
1436
+ window.postMessage({ type: "HANZI_PING" }, "*");
1437
+
1438
+ // If extension not detected after 2s, show install prompt
1439
+ setTimeout(() => {
1440
+ if (!extensionReady) {
1441
+ statusEl.className = "status status-install";
1442
+ statusEl.innerHTML = 'Hanzi extension not found. <a href="' + EXTENSION_URL + '" target="_blank">Install it here</a>, then reload this page.';
1443
+ }
1444
+ }, 2000);
1445
+ </script>
1446
+ </body>
1447
+ </html>`;
1448
+ }