@clappstore/connect 0.8.1 → 0.8.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/server.js CHANGED
@@ -7,6 +7,57 @@ import { StateStore } from "./state-store.js";
7
7
  import { authenticateRequest, authenticateWsUpgrade, checkRateLimit, setSessionCookie, getLoginPageHtml, } from "./auth.js";
8
8
  import { OAuthHandler } from "./oauth-handler.js";
9
9
  import QRCode from "qrcode";
10
+ // --- Response helpers ---
11
+ function json(res, data) {
12
+ res.writeHead(200, { "Content-Type": "application/json" });
13
+ res.end(JSON.stringify(data));
14
+ }
15
+ function jsonError(res, status, error) {
16
+ res.writeHead(status, { "Content-Type": "application/json" });
17
+ res.end(JSON.stringify({ error }));
18
+ }
19
+ // --- Rate limiting for write endpoints ---
20
+ const apiRateMap = new Map();
21
+ const API_RATE_LIMIT = 60; // requests per window
22
+ const API_RATE_WINDOW = 60_000; // 1 minute
23
+ function checkApiRateLimit(req) {
24
+ const ip = req.headers["x-forwarded-for"]?.split(",")[0]?.trim()
25
+ ?? req.socket.remoteAddress ?? "unknown";
26
+ const now = Date.now();
27
+ const entry = apiRateMap.get(ip);
28
+ if (!entry || now > entry.resetAt) {
29
+ apiRateMap.set(ip, { count: 1, resetAt: now + API_RATE_WINDOW });
30
+ return true;
31
+ }
32
+ entry.count++;
33
+ return entry.count <= API_RATE_LIMIT;
34
+ }
35
+ const MAX_BODY_SIZE = 10 * 1024 * 1024; // 10 MB
36
+ function readBody(req) {
37
+ return new Promise((resolve, reject) => {
38
+ const chunks = [];
39
+ let size = 0;
40
+ req.on("data", (chunk) => {
41
+ size += chunk.length;
42
+ if (size > MAX_BODY_SIZE) {
43
+ req.destroy();
44
+ reject(new Error("Payload too large"));
45
+ return;
46
+ }
47
+ chunks.push(chunk);
48
+ });
49
+ req.on("end", () => resolve(Buffer.concat(chunks).toString()));
50
+ req.on("error", reject);
51
+ });
52
+ }
53
+ /** Validate that a template/clapp ID is safe for use in file paths. */
54
+ function isValidId(id) {
55
+ return /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(id) && !id.includes("..");
56
+ }
57
+ function escapeHtml(s) {
58
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
59
+ }
60
+ // --- Server entrypoint ---
10
61
  const MIME_TYPES = {
11
62
  ".html": "text/html",
12
63
  ".js": "text/javascript",
@@ -27,14 +78,24 @@ const MIME_TYPES = {
27
78
  ".woff2": "font/woff2",
28
79
  };
29
80
  export function startServer(options) {
30
- const { port, store, onIntent, staticDir, agentConnected, onConnect, accessToken, oauthHandler, openclawHome, templatesDir, stateDir } = options;
81
+ const { port, store, onIntent, onConnect, accessToken, oauthHandler } = options;
82
+ const ctx = {
83
+ store,
84
+ onIntent,
85
+ agentConnected: options.agentConnected,
86
+ accessToken: accessToken ?? null,
87
+ oauthHandler,
88
+ openclawHome: options.openclawHome,
89
+ templatesDir: options.templatesDir,
90
+ stateDir: options.stateDir,
91
+ staticDir: options.staticDir,
92
+ };
31
93
  const server = createServer((req, res) => {
32
- handleRequest(req, res, store, onIntent, staticDir, agentConnected, accessToken ?? null, oauthHandler, openclawHome, templatesDir, stateDir);
94
+ handleRequest(req, res, ctx);
33
95
  });
34
- // Use noServer mode so we can authenticate WS upgrades
35
96
  const wss = new WebSocketServer({ noServer: true });
36
97
  server.on("upgrade", (req, socket, head) => {
37
- if (!authenticateWsUpgrade(req, accessToken ?? null)) {
98
+ if (!authenticateWsUpgrade(req, ctx.accessToken)) {
38
99
  socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
39
100
  socket.destroy();
40
101
  return;
@@ -45,7 +106,6 @@ export function startServer(options) {
45
106
  });
46
107
  wss.on("connection", (ws) => {
47
108
  const client = store.addClient(ws);
48
- // Notify that a client connected (for state refresh)
49
109
  onConnect?.();
50
110
  ws.on("message", (raw) => {
51
111
  try {
@@ -97,18 +157,27 @@ function handleWsMessage(msg, client, store, onIntent) {
97
157
  }
98
158
  }
99
159
  }
100
- async function handleRequest(req, res, store, onIntent, staticDir, agentConnected, accessToken, oauthHandler, openclawHome, templatesDir, stateDir) {
160
+ // --- Request router ---
161
+ async function handleRequest(req, res, ctx) {
101
162
  const url = new URL(req.url ?? "/", `http://${req.headers.host}`);
102
163
  const path = url.pathname;
103
- // CORS headers
104
- if (accessToken != null) {
105
- // With auth: reflect origin and allow credentials
106
- const origin = req.headers.origin;
107
- if (origin)
108
- res.setHeader("Access-Control-Allow-Origin", origin);
109
- res.setHeader("Access-Control-Allow-Credentials", "true");
110
- }
111
- else {
164
+ // CORS — only allow same-host origins (different ports are OK for local dev)
165
+ const origin = req.headers.origin;
166
+ if (origin) {
167
+ try {
168
+ const reqHost = req.headers.host?.split(":")[0] ?? "";
169
+ const originHost = new URL(origin).hostname;
170
+ if (originHost === reqHost || originHost === "localhost" || originHost === "127.0.0.1") {
171
+ res.setHeader("Access-Control-Allow-Origin", origin);
172
+ if (ctx.accessToken != null) {
173
+ res.setHeader("Access-Control-Allow-Credentials", "true");
174
+ }
175
+ }
176
+ }
177
+ catch { /* invalid origin, skip CORS headers */ }
178
+ }
179
+ else if (ctx.accessToken == null) {
180
+ // No auth mode: allow all origins (development only)
112
181
  res.setHeader("Access-Control-Allow-Origin", "*");
113
182
  }
114
183
  res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
@@ -120,9 +189,8 @@ async function handleRequest(req, res, store, onIntent, staticDir, agentConnecte
120
189
  }
121
190
  // Auth-exempt routes
122
191
  if (path === "/api/health") {
123
- return handleApi(req, res, path, store, onIntent, agentConnected, openclawHome, templatesDir, stateDir);
192
+ return handleHealth(res, ctx);
124
193
  }
125
- // Login routes
126
194
  if (path === "/auth/login") {
127
195
  if (req.method === "GET") {
128
196
  res.writeHead(200, { "Content-Type": "text/html" });
@@ -130,326 +198,316 @@ async function handleRequest(req, res, store, onIntent, staticDir, agentConnecte
130
198
  return;
131
199
  }
132
200
  if (req.method === "POST") {
133
- return handleLogin(req, res, accessToken);
201
+ return handleLogin(req, res, ctx.accessToken);
134
202
  }
135
203
  }
136
- // Auth check for everything else
137
- if (accessToken != null) {
138
- const { authenticated } = authenticateRequest(req, accessToken);
204
+ // Auth check
205
+ if (ctx.accessToken != null) {
206
+ const { authenticated } = authenticateRequest(req, ctx.accessToken);
139
207
  if (!authenticated) {
140
208
  if (path.startsWith("/api/")) {
141
- res.writeHead(401, { "Content-Type": "application/json" });
142
- res.end(JSON.stringify({ error: "unauthorized" }));
209
+ jsonError(res, 401, "unauthorized");
143
210
  return;
144
211
  }
145
- // Browser routes → show login page
146
212
  res.writeHead(401, { "Content-Type": "text/html" });
147
213
  res.end(getLoginPageHtml());
148
214
  return;
149
215
  }
150
216
  }
151
- // OAuth routes (authenticated)
152
- if (path === "/api/oauth/init" && oauthHandler) {
153
- return handleOAuthInit(req, res, oauthHandler);
217
+ // Authenticated routes
218
+ if (path === "/api/oauth/init" && ctx.oauthHandler) {
219
+ return handleOAuthInit(req, res, ctx.oauthHandler);
154
220
  }
155
- if (path === "/api/oauth/callback" && oauthHandler) {
156
- return handleOAuthCallback(req, res, oauthHandler);
221
+ if (path === "/api/oauth/callback" && ctx.oauthHandler) {
222
+ return handleOAuthCallback(req, res, ctx.oauthHandler);
157
223
  }
158
- // Connect page (QR code for mobile app setup)
159
224
  if (path === "/connect") {
160
- return handleConnectPage(req, res, accessToken);
225
+ return handleConnectPage(req, res, ctx.accessToken);
161
226
  }
162
227
  // API routes
163
228
  if (path.startsWith("/api/")) {
164
- return handleApi(req, res, path, store, onIntent, agentConnected, openclawHome, templatesDir, stateDir);
229
+ return routeApi(req, res, path, ctx);
165
230
  }
166
- // Serve static SPA files
167
- if (staticDir) {
168
- return serveStatic(req, res, path, staticDir);
231
+ // Static SPA
232
+ if (ctx.staticDir) {
233
+ return serveStatic(res, path, ctx.staticDir);
169
234
  }
170
235
  res.writeHead(404, { "Content-Type": "text/plain" });
171
236
  res.end("Not Found");
172
237
  }
173
- async function handleLogin(req, res, accessToken) {
174
- // If no auth, just redirect
175
- if (accessToken === null) {
176
- res.writeHead(302, { Location: "/" });
177
- res.end();
178
- return;
238
+ // --- API router (dispatches to individual handlers) ---
239
+ async function routeApi(req, res, path, ctx) {
240
+ if (path === "/api/apps" && req.method === "GET") {
241
+ return handleApps(res, ctx);
179
242
  }
180
- const ip = req.headers["x-forwarded-for"]?.split(",")[0]?.trim() ??
181
- req.socket.remoteAddress ??
182
- "unknown";
183
- if (!checkRateLimit(ip)) {
184
- res.writeHead(429, { "Content-Type": "text/html" });
185
- res.end(getLoginPageHtml("Too many attempts. Please wait a minute."));
186
- return;
243
+ const stateMatch = path.match(/^\/api\/state\/([^/]+)$/);
244
+ if (stateMatch && req.method === "GET") {
245
+ return handleState(res, ctx, stateMatch[1]);
187
246
  }
188
- const body = await readBody(req);
189
- const params = new URLSearchParams(body);
190
- const password = params.get("password")?.trim() ?? "";
191
- const remember = params.get("remember") === "1";
192
- // Normalize: strip dashes for comparison (user may paste formatted code)
193
- const normalized = password.replace(/-/g, "");
194
- if (normalized !== accessToken) {
195
- res.writeHead(401, { "Content-Type": "text/html" });
196
- res.end(getLoginPageHtml("Invalid access code."));
247
+ const viewMatch = path.match(/^\/api\/views\/([^/]+)$/);
248
+ if (viewMatch && req.method === "GET") {
249
+ return handleView(res, ctx, viewMatch[1]);
250
+ }
251
+ // Rate-limit write endpoints
252
+ if (req.method === "POST" && !checkApiRateLimit(req)) {
253
+ res.writeHead(429, { "Content-Type": "application/json" });
254
+ res.end(JSON.stringify({ error: "too many requests" }));
197
255
  return;
198
256
  }
199
- setSessionCookie(res, accessToken, req, remember);
200
- res.writeHead(302, { Location: "/" });
201
- res.end();
257
+ if (path === "/api/templates" && req.method === "POST") {
258
+ return handleTemplates(req, res, ctx);
259
+ }
260
+ if (path === "/api/intent" && req.method === "POST") {
261
+ return handleIntentRoute(req, res, ctx);
262
+ }
263
+ if (path === "/api/health" && req.method === "GET") {
264
+ return handleHealth(res, ctx);
265
+ }
266
+ const assetMatch = path.match(/^\/api\/chat-assets\/([^/]+)\/([^/]+)$/);
267
+ if (assetMatch && req.method === "GET") {
268
+ return handleChatAsset(res, ctx, decodeURIComponent(assetMatch[1]), decodeURIComponent(assetMatch[2]));
269
+ }
270
+ jsonError(res, 404, "not found");
202
271
  }
203
- async function handleApi(req, res, path, store, onIntent, agentConnected, openclawHome, templatesDir, stateDir) {
204
- // GET /api/apps (merge agent-registered apps with provisioned template manifests)
205
- if (path === "/api/apps" && req.method === "GET") {
206
- const storeApps = store.getApps();
207
- if (!templatesDir || !existsSync(templatesDir)) {
208
- json(res, storeApps);
209
- return;
210
- }
211
- try {
212
- const dirs = await readdir(templatesDir, { withFileTypes: true });
213
- const storeIds = new Set(storeApps.map((a) => a.id));
214
- const allApps = [...storeApps];
215
- for (const dir of dirs) {
216
- if (!dir.isDirectory() || storeIds.has(dir.name))
217
- continue;
218
- const mPath = resolve(templatesDir, dir.name, "manifest.json");
219
- if (!existsSync(mPath))
220
- continue;
221
- try {
222
- const m = JSON.parse(await readFile(mPath, "utf-8"));
223
- allApps.push({ id: m.id, name: m.name, icon: m.icon, color: m.color });
224
- }
225
- catch { /* skip invalid manifests */ }
272
+ // --- Individual API route handlers ---
273
+ async function handleApps(res, ctx) {
274
+ const storeApps = ctx.store.getApps();
275
+ if (!ctx.templatesDir || !existsSync(ctx.templatesDir)) {
276
+ json(res, storeApps);
277
+ return;
278
+ }
279
+ try {
280
+ const dirs = await readdir(ctx.templatesDir, { withFileTypes: true });
281
+ const storeIds = new Set(storeApps.map((a) => a.id));
282
+ const allApps = [...storeApps];
283
+ for (const dir of dirs) {
284
+ if (!dir.isDirectory() || storeIds.has(dir.name))
285
+ continue;
286
+ const mPath = resolve(ctx.templatesDir, dir.name, "manifest.json");
287
+ if (!existsSync(mPath))
288
+ continue;
289
+ try {
290
+ const m = JSON.parse(await readFile(mPath, "utf-8"));
291
+ allApps.push({ id: m.id, name: m.name, icon: m.icon, color: m.color });
226
292
  }
227
- json(res, allApps);
228
- }
229
- catch {
230
- json(res, storeApps);
293
+ catch { /* skip invalid manifests */ }
231
294
  }
295
+ json(res, allApps);
296
+ }
297
+ catch {
298
+ json(res, storeApps);
299
+ }
300
+ }
301
+ async function handleState(res, ctx, clappId) {
302
+ const state = ctx.store.getState(clappId);
303
+ if (!state) {
304
+ jsonError(res, 404, "not found");
232
305
  return;
233
306
  }
234
- // GET /api/state/:clappId (injects _views from provisioned templates)
235
- const stateMatch = path.match(/^\/api\/state\/([^/]+)$/);
236
- if (stateMatch && req.method === "GET") {
237
- const clappId = stateMatch[1];
238
- const state = store.getState(clappId);
239
- if (!state) {
240
- res.writeHead(404, { "Content-Type": "application/json" });
241
- res.end(JSON.stringify({ error: "not found" }));
242
- return;
243
- }
244
- // Inject _views from templates if available
245
- if (templatesDir) {
246
- const viewsPath = resolve(templatesDir, clappId, "views");
247
- if (existsSync(viewsPath)) {
248
- try {
249
- const viewFiles = await readdir(viewsPath);
250
- const views = {};
251
- for (const file of viewFiles) {
252
- if (!file.endsWith(".json"))
253
- continue;
254
- const content = await readFile(resolve(viewsPath, file), "utf-8");
255
- views[file.replace(/\.json$/, "")] = JSON.parse(content);
256
- }
257
- if (Object.keys(views).length > 0) {
258
- json(res, { ...state, _views: views });
259
- return;
260
- }
307
+ // Inject _views from templates if available
308
+ if (ctx.templatesDir) {
309
+ const viewsPath = resolve(ctx.templatesDir, clappId, "views");
310
+ if (existsSync(viewsPath)) {
311
+ try {
312
+ const viewFiles = await readdir(viewsPath);
313
+ const views = {};
314
+ for (const file of viewFiles) {
315
+ if (!file.endsWith(".json"))
316
+ continue;
317
+ const content = await readFile(resolve(viewsPath, file), "utf-8");
318
+ views[file.replace(/\.json$/, "")] = JSON.parse(content);
319
+ }
320
+ if (Object.keys(views).length > 0) {
321
+ json(res, { ...state, _views: views });
322
+ return;
261
323
  }
262
- catch { /* fall through to normal response */ }
263
324
  }
325
+ catch { /* fall through */ }
264
326
  }
265
- json(res, state);
327
+ }
328
+ json(res, state);
329
+ }
330
+ function handleView(res, ctx, viewId) {
331
+ const content = ctx.store.getView(viewId);
332
+ if (!content) {
333
+ res.writeHead(404, { "Content-Type": "text/plain" });
334
+ res.end("not found");
266
335
  return;
267
336
  }
268
- // GET /api/views/:viewId
269
- const viewMatch = path.match(/^\/api\/views\/([^/]+)$/);
270
- if (viewMatch && req.method === "GET") {
271
- const content = store.getView(viewMatch[1]);
272
- if (!content) {
273
- res.writeHead(404, { "Content-Type": "text/plain" });
274
- res.end("not found");
275
- return;
276
- }
277
- res.writeHead(200, { "Content-Type": "text/plain" });
278
- res.end(content);
337
+ res.writeHead(200, { "Content-Type": "text/plain" });
338
+ res.end(content);
339
+ }
340
+ async function handleTemplates(req, res, ctx) {
341
+ if (!ctx.templatesDir || !ctx.stateDir) {
342
+ jsonError(res, 500, "templates not configured");
279
343
  return;
280
344
  }
281
- // POST /api/templates (provision iOS template bundle)
282
- if (path === "/api/templates" && req.method === "POST") {
283
- if (!templatesDir || !stateDir) {
284
- res.writeHead(500, { "Content-Type": "application/json" });
285
- res.end(JSON.stringify({ error: "templates not configured" }));
345
+ const body = await readBody(req);
346
+ try {
347
+ const data = JSON.parse(body);
348
+ const { id, name, version, icon, color, entryView, contract, views, initialState } = data;
349
+ if (!id || !version) {
350
+ jsonError(res, 400, "id and version required");
286
351
  return;
287
352
  }
288
- const body = await readBody(req);
289
- try {
290
- const data = JSON.parse(body);
291
- const { id, name, version, icon, color, entryView, contract, views, initialState } = data;
292
- if (!id || !version) {
293
- res.writeHead(400, { "Content-Type": "application/json" });
294
- res.end(JSON.stringify({ error: "id and version required" }));
295
- return;
296
- }
297
- const templateDir = resolve(templatesDir, id);
298
- const manifestPath = resolve(templateDir, "manifest.json");
299
- // Check if same version already stored
300
- if (existsSync(manifestPath)) {
301
- try {
302
- const existing = JSON.parse(await readFile(manifestPath, "utf-8"));
303
- if (existing.version === version) {
304
- json(res, { status: "unchanged" });
305
- return;
306
- }
307
- }
308
- catch { /* proceed with overwrite */ }
309
- }
310
- const isNew = !existsSync(templateDir);
311
- // Create directory structure
312
- const viewsPath = resolve(templateDir, "views");
313
- await mkdir(viewsPath, { recursive: true });
314
- // Write manifest
315
- await writeFile(manifestPath, JSON.stringify({ id, name, version, icon, color, entryView }, null, 2));
316
- // Write contract
317
- if (contract) {
318
- await writeFile(resolve(templateDir, "contract.json"), JSON.stringify(contract, null, 2));
319
- }
320
- // Write views
321
- if (views && typeof views === "object") {
322
- for (const [viewName, viewData] of Object.entries(views)) {
323
- await writeFile(resolve(viewsPath, `${viewName}.json`), JSON.stringify(viewData, null, 2));
353
+ if (!isValidId(id)) {
354
+ jsonError(res, 400, "invalid template id");
355
+ return;
356
+ }
357
+ const templateDir = resolve(ctx.templatesDir, id);
358
+ if (!templateDir.startsWith(resolve(ctx.templatesDir))) {
359
+ jsonError(res, 400, "invalid template id");
360
+ return;
361
+ }
362
+ const manifestPath = resolve(templateDir, "manifest.json");
363
+ // Check if same version already stored
364
+ if (existsSync(manifestPath)) {
365
+ try {
366
+ const existing = JSON.parse(await readFile(manifestPath, "utf-8"));
367
+ if (existing.version === version) {
368
+ json(res, { status: "unchanged" });
369
+ return;
324
370
  }
325
371
  }
326
- // Write initial state if none exists on disk
327
- if (initialState) {
328
- const statePath = resolve(stateDir, `${id}.json`);
329
- if (!existsSync(statePath)) {
330
- await writeFile(statePath, JSON.stringify(initialState, null, 2));
331
- store.setState(id, initialState);
332
- }
372
+ catch { /* proceed with overwrite */ }
373
+ }
374
+ const isNew = !existsSync(templateDir);
375
+ // Create directory structure and write files
376
+ const viewsPath = resolve(templateDir, "views");
377
+ await mkdir(viewsPath, { recursive: true });
378
+ await writeFile(manifestPath, JSON.stringify({ id, name, version, icon, color, entryView }, null, 2));
379
+ if (contract) {
380
+ await writeFile(resolve(templateDir, "contract.json"), JSON.stringify(contract, null, 2));
381
+ }
382
+ if (views && typeof views === "object") {
383
+ for (const [viewName, viewData] of Object.entries(views)) {
384
+ await writeFile(resolve(viewsPath, `${viewName}.json`), JSON.stringify(viewData, null, 2));
333
385
  }
334
- // Notify agent on first provision
335
- if (isNew) {
336
- const installIntent = {
337
- id: crypto.randomUUID(),
338
- agentId: "system",
339
- clappId: id,
340
- intent: "system.templateInstalled",
341
- payload: {
342
- name: name ?? id,
343
- contractPath: resolve(templateDir, "contract.json"),
344
- },
345
- timestamp: new Date().toISOString(),
346
- };
347
- onIntent(installIntent);
386
+ }
387
+ // Write initial state if none exists on disk
388
+ if (initialState) {
389
+ const statePath = resolve(ctx.stateDir, `${id}.json`);
390
+ if (!existsSync(statePath)) {
391
+ await writeFile(statePath, JSON.stringify(initialState, null, 2));
392
+ ctx.store.setState(id, initialState);
348
393
  }
349
- json(res, { status: "provisioned" });
350
394
  }
351
- catch {
352
- res.writeHead(400, { "Content-Type": "application/json" });
353
- res.end(JSON.stringify({ error: "invalid JSON" }));
395
+ // Notify agent on first provision
396
+ if (isNew) {
397
+ ctx.onIntent({
398
+ id: crypto.randomUUID(),
399
+ agentId: "system",
400
+ clappId: id,
401
+ intent: "system.templateInstalled",
402
+ payload: { name: name ?? id, contractPath: resolve(templateDir, "contract.json") },
403
+ timestamp: new Date().toISOString(),
404
+ });
354
405
  }
406
+ json(res, { status: "provisioned" });
407
+ }
408
+ catch {
409
+ jsonError(res, 400, "invalid JSON");
410
+ }
411
+ }
412
+ async function handleIntentRoute(req, res, ctx) {
413
+ const body = await readBody(req);
414
+ try {
415
+ const data = JSON.parse(body);
416
+ ctx.onIntent({
417
+ id: data.id ?? crypto.randomUUID(),
418
+ agentId: data.agentId ?? "local",
419
+ clappId: data.clappId ?? "",
420
+ intent: data.intent,
421
+ payload: data.payload ?? {},
422
+ timestamp: data.timestamp ?? new Date().toISOString(),
423
+ });
424
+ json(res, { ok: true });
425
+ }
426
+ catch {
427
+ jsonError(res, 400, "invalid JSON");
428
+ }
429
+ }
430
+ function handleHealth(res, ctx) {
431
+ json(res, {
432
+ status: "ok",
433
+ agent: ctx.agentConnected ? ctx.agentConnected() : false,
434
+ });
435
+ }
436
+ async function handleChatAsset(res, ctx, sessionKey, fileName) {
437
+ if (!/^session-\d+$/.test(sessionKey) || fileName.includes("..") || fileName.includes("/")) {
438
+ jsonError(res, 400, "invalid asset path");
355
439
  return;
356
440
  }
357
- // POST /api/intent
358
- if (path === "/api/intent" && req.method === "POST") {
359
- const body = await readBody(req);
360
- try {
361
- const data = JSON.parse(body);
362
- const intent = {
363
- id: data.id ?? crypto.randomUUID(),
364
- agentId: data.agentId ?? "local",
365
- clappId: data.clappId ?? "",
366
- intent: data.intent,
367
- payload: data.payload ?? {},
368
- timestamp: data.timestamp ?? new Date().toISOString(),
369
- };
370
- onIntent(intent);
371
- json(res, { ok: true });
372
- }
373
- catch {
374
- res.writeHead(400, { "Content-Type": "application/json" });
375
- res.end(JSON.stringify({ error: "invalid JSON" }));
376
- }
441
+ const { homedir } = await import("node:os");
442
+ const home = ctx.openclawHome ?? homedir();
443
+ const assetPath = resolve(home, ".openclaw", "workspace", "chat-sessions", "assets", sessionKey, fileName);
444
+ if (!existsSync(assetPath)) {
445
+ jsonError(res, 404, "asset not found");
377
446
  return;
378
447
  }
379
- // GET /api/health
380
- if (path === "/api/health" && req.method === "GET") {
381
- json(res, {
382
- status: "ok",
383
- agent: agentConnected ? agentConnected() : false,
384
- });
448
+ try {
449
+ const content = await readFile(assetPath);
450
+ const ext = extname(assetPath);
451
+ const mime = MIME_TYPES[ext] ?? "application/octet-stream";
452
+ res.writeHead(200, { "Content-Type": mime, "Cache-Control": "private, max-age=31536000, immutable" });
453
+ res.end(content);
454
+ }
455
+ catch {
456
+ jsonError(res, 500, "failed to read asset");
457
+ }
458
+ }
459
+ // --- Non-API route handlers ---
460
+ async function handleLogin(req, res, accessToken) {
461
+ if (accessToken === null) {
462
+ res.writeHead(302, { Location: "/" });
463
+ res.end();
385
464
  return;
386
465
  }
387
- // GET /api/chat-assets/:sessionKey/:fileName
388
- const assetMatch = path.match(/^\/api\/chat-assets\/([^/]+)\/([^/]+)$/);
389
- if (assetMatch && req.method === "GET") {
390
- const sessionKey = decodeURIComponent(assetMatch[1]);
391
- const fileName = decodeURIComponent(assetMatch[2]);
392
- if (!/^session-\d+$/.test(sessionKey) || fileName.includes("..") || fileName.includes("/")) {
393
- res.writeHead(400, { "Content-Type": "application/json" });
394
- res.end(JSON.stringify({ error: "invalid asset path" }));
395
- return;
396
- }
397
- const { homedir } = await import("node:os");
398
- const home = openclawHome ?? homedir();
399
- const assetPath = resolve(home, ".openclaw", "workspace", "chat-sessions", "assets", sessionKey, fileName);
400
- if (!existsSync(assetPath)) {
401
- res.writeHead(404, { "Content-Type": "application/json" });
402
- res.end(JSON.stringify({ error: "asset not found" }));
403
- return;
404
- }
405
- try {
406
- const content = await readFile(assetPath);
407
- const ext = extname(assetPath);
408
- const mime = MIME_TYPES[ext] ?? "application/octet-stream";
409
- res.writeHead(200, { "Content-Type": mime, "Cache-Control": "private, max-age=31536000, immutable" });
410
- res.end(content);
411
- return;
412
- }
413
- catch {
414
- res.writeHead(500, { "Content-Type": "application/json" });
415
- res.end(JSON.stringify({ error: "failed to read asset" }));
416
- return;
417
- }
466
+ const ip = req.headers["x-forwarded-for"]?.split(",")[0]?.trim() ??
467
+ req.socket.remoteAddress ??
468
+ "unknown";
469
+ if (!checkRateLimit(ip)) {
470
+ res.writeHead(429, { "Content-Type": "text/html" });
471
+ res.end(getLoginPageHtml("Too many attempts. Please wait a minute."));
472
+ return;
418
473
  }
419
- res.writeHead(404, { "Content-Type": "application/json" });
420
- res.end(JSON.stringify({ error: "not found" }));
474
+ const body = await readBody(req);
475
+ const params = new URLSearchParams(body);
476
+ const password = params.get("password")?.trim() ?? "";
477
+ const remember = params.get("remember") === "1";
478
+ const normalized = password.replace(/-/g, "");
479
+ if (normalized !== accessToken) {
480
+ res.writeHead(401, { "Content-Type": "text/html" });
481
+ res.end(getLoginPageHtml("Invalid access code."));
482
+ return;
483
+ }
484
+ setSessionCookie(res, accessToken, req, remember);
485
+ res.writeHead(302, { Location: "/" });
486
+ res.end();
421
487
  }
422
488
  async function handleOAuthInit(req, res, oauthHandler) {
423
489
  if (req.method !== "POST") {
424
- res.writeHead(405, { "Content-Type": "application/json" });
425
- res.end(JSON.stringify({ error: "method not allowed" }));
490
+ jsonError(res, 405, "method not allowed");
426
491
  return;
427
492
  }
428
493
  try {
429
494
  const body = await readBody(req);
430
495
  const data = JSON.parse(body);
431
496
  const provider = data.provider;
432
- const customName = data.customName;
433
497
  if (!provider) {
434
- res.writeHead(400, { "Content-Type": "application/json" });
435
- res.end(JSON.stringify({ error: "provider is required" }));
498
+ jsonError(res, 400, "provider is required");
436
499
  return;
437
500
  }
438
- const result = oauthHandler.initOAuth(provider, customName);
439
- json(res, result);
501
+ json(res, oauthHandler.initOAuth(provider, data.customName));
440
502
  }
441
503
  catch (error) {
442
504
  console.error("[oauth] Init failed:", error);
443
- res.writeHead(500, { "Content-Type": "application/json" });
444
- res.end(JSON.stringify({
445
- error: error instanceof Error ? error.message : "OAuth init failed"
446
- }));
505
+ jsonError(res, 500, "OAuth init failed");
447
506
  }
448
507
  }
449
508
  async function handleOAuthCallback(req, res, oauthHandler) {
450
509
  if (req.method !== "POST") {
451
- res.writeHead(405, { "Content-Type": "application/json" });
452
- res.end(JSON.stringify({ error: "method not allowed" }));
510
+ jsonError(res, 405, "method not allowed");
453
511
  return;
454
512
  }
455
513
  try {
@@ -457,31 +515,24 @@ async function handleOAuthCallback(req, res, oauthHandler) {
457
515
  const data = JSON.parse(body);
458
516
  const callbackUrl = data.callbackUrl;
459
517
  if (!callbackUrl) {
460
- res.writeHead(400, { "Content-Type": "application/json" });
461
- res.end(JSON.stringify({ error: "callbackUrl is required" }));
518
+ jsonError(res, 400, "callbackUrl is required");
462
519
  return;
463
520
  }
464
521
  const parsed = new URL(callbackUrl);
465
522
  const code = parsed.searchParams.get("code");
466
523
  const state = parsed.searchParams.get("state");
467
524
  if (!code || !state) {
468
- res.writeHead(400, { "Content-Type": "application/json" });
469
- res.end(JSON.stringify({ error: "URL must contain code and state parameters" }));
525
+ jsonError(res, 400, "URL must contain code and state parameters");
470
526
  return;
471
527
  }
472
- const result = await oauthHandler.handleCallback(code, state);
473
- json(res, result);
528
+ json(res, await oauthHandler.handleCallback(code, state));
474
529
  }
475
530
  catch (error) {
476
531
  console.error("[oauth] Callback failed:", error);
477
- res.writeHead(500, { "Content-Type": "application/json" });
478
- res.end(JSON.stringify({
479
- error: error instanceof Error ? error.message : "OAuth callback failed",
480
- }));
532
+ jsonError(res, 500, "OAuth callback failed");
481
533
  }
482
534
  }
483
535
  async function handleConnectPage(req, res, accessToken) {
484
- // Derive server URL from the request Host header
485
536
  const host = req.headers.host ?? "localhost";
486
537
  const protocol = req.headers["x-forwarded-proto"] ?? "http";
487
538
  const serverURL = `${protocol}://${host}`;
@@ -497,7 +548,7 @@ async function handleConnectPage(req, res, accessToken) {
497
548
  return;
498
549
  }
499
550
  const tokenDisplay = accessToken
500
- ? `<div class="field"><div class="label">Access Code</div><code>${accessToken}</code></div>`
551
+ ? `<div class="field"><div class="label">Access Code</div><code>${escapeHtml(accessToken)}</code></div>`
501
552
  : `<div class="field"><div class="label">Auth</div><code>disabled</code></div>`;
502
553
  const html = `<!DOCTYPE html>
503
554
  <html lang="en">
@@ -534,17 +585,14 @@ async function handleConnectPage(req, res, accessToken) {
534
585
  res.writeHead(200, { "Content-Type": "text/html" });
535
586
  res.end(html);
536
587
  }
537
- async function serveStatic(_req, res, path, staticDir) {
538
- // Normalize path
588
+ async function serveStatic(res, path, staticDir) {
539
589
  let filePath = path === "/" ? "/index.html" : path;
540
- // Security: prevent directory traversal
541
590
  const resolved = resolve(staticDir, filePath.slice(1));
542
591
  if (!resolved.startsWith(resolve(staticDir))) {
543
592
  res.writeHead(403);
544
593
  res.end("Forbidden");
545
594
  return;
546
595
  }
547
- // Try the exact file, then fall back to index.html (SPA routing)
548
596
  let target = resolved;
549
597
  if (!existsSync(target)) {
550
598
  target = resolve(staticDir, "index.html");
@@ -561,16 +609,4 @@ async function serveStatic(_req, res, path, staticDir) {
561
609
  res.end("Not Found");
562
610
  }
563
611
  }
564
- function json(res, data) {
565
- res.writeHead(200, { "Content-Type": "application/json" });
566
- res.end(JSON.stringify(data));
567
- }
568
- function readBody(req) {
569
- return new Promise((resolve, reject) => {
570
- const chunks = [];
571
- req.on("data", (chunk) => chunks.push(chunk));
572
- req.on("end", () => resolve(Buffer.concat(chunks).toString()));
573
- req.on("error", reject);
574
- });
575
- }
576
612
  //# sourceMappingURL=server.js.map