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.
- package/README.md +182 -0
- package/dist/agent/loop.d.ts +63 -0
- package/dist/agent/loop.js +186 -0
- package/dist/agent/system-prompt.d.ts +7 -0
- package/dist/agent/system-prompt.js +41 -0
- package/dist/agent/tools.d.ts +9 -0
- package/dist/agent/tools.js +154 -0
- package/dist/cli/detect-credentials.d.ts +31 -0
- package/dist/cli/detect-credentials.js +44 -0
- package/dist/cli/import-credentials-handler.d.ts +14 -0
- package/dist/cli/import-credentials-handler.js +22 -0
- package/dist/cli/session-files.d.ts +28 -0
- package/dist/cli/session-files.js +118 -0
- package/dist/cli/setup.d.ts +10 -0
- package/dist/cli/setup.js +915 -0
- package/dist/cli.d.ts +16 -0
- package/dist/cli.js +506 -0
- package/dist/dashboard/assets/index-CEFyesbT.js +46 -0
- package/dist/dashboard/assets/index-Dnht2kLU.css +1 -0
- package/dist/dashboard/index.html +13 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1116 -0
- package/dist/ipc/index.d.ts +8 -0
- package/dist/ipc/index.js +8 -0
- package/dist/ipc/native-host.d.ts +96 -0
- package/dist/ipc/native-host.js +223 -0
- package/dist/ipc/websocket-client.d.ts +73 -0
- package/dist/ipc/websocket-client.js +199 -0
- package/dist/license/manager.d.ts +20 -0
- package/dist/license/manager.js +15 -0
- package/dist/llm/client.d.ts +72 -0
- package/dist/llm/client.js +227 -0
- package/dist/llm/credentials.d.ts +61 -0
- package/dist/llm/credentials.js +200 -0
- package/dist/llm/vertex.d.ts +22 -0
- package/dist/llm/vertex.js +335 -0
- package/dist/managed/api-http.test.d.ts +7 -0
- package/dist/managed/api-http.test.js +623 -0
- package/dist/managed/api.d.ts +51 -0
- package/dist/managed/api.js +1448 -0
- package/dist/managed/api.test.d.ts +10 -0
- package/dist/managed/api.test.js +146 -0
- package/dist/managed/auth.d.ts +38 -0
- package/dist/managed/auth.js +192 -0
- package/dist/managed/billing.d.ts +70 -0
- package/dist/managed/billing.js +227 -0
- package/dist/managed/deploy.d.ts +17 -0
- package/dist/managed/deploy.js +385 -0
- package/dist/managed/e2e.test.d.ts +15 -0
- package/dist/managed/e2e.test.js +151 -0
- package/dist/managed/hardening.test.d.ts +14 -0
- package/dist/managed/hardening.test.js +346 -0
- package/dist/managed/integration.test.d.ts +8 -0
- package/dist/managed/integration.test.js +274 -0
- package/dist/managed/log.d.ts +18 -0
- package/dist/managed/log.js +31 -0
- package/dist/managed/server.d.ts +12 -0
- package/dist/managed/server.js +69 -0
- package/dist/managed/store-pg.d.ts +191 -0
- package/dist/managed/store-pg.js +479 -0
- package/dist/managed/store.d.ts +188 -0
- package/dist/managed/store.js +379 -0
- package/dist/relay/auto-start.d.ts +19 -0
- package/dist/relay/auto-start.js +71 -0
- package/dist/relay/server.d.ts +17 -0
- package/dist/relay/server.js +403 -0
- package/dist/types/index.d.ts +5 -0
- package/dist/types/index.js +4 -0
- package/dist/types/session.d.ts +134 -0
- package/dist/types/session.js +16 -0
- package/package.json +61 -0
- package/skills/README.md +48 -0
- package/skills/a11y-auditor/SKILL.md +42 -0
- package/skills/e2e-tester/SKILL.md +154 -0
- package/skills/hanzi-browse/SKILL.md +182 -0
- package/skills/linkedin-prospector/SKILL.md +149 -0
- package/skills/social-poster/SKILL.md +146 -0
- 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
|
+
}
|