@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/auth.js +2 -2
- package/dist/auth.js.map +1 -1
- package/dist/index.js +95 -126
- package/dist/index.js.map +1 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +329 -293
- package/dist/server.js.map +1 -1
- package/package.json +1 -1
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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
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,
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
//
|
|
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
|
|
229
|
+
return routeApi(req, res, path, ctx);
|
|
165
230
|
}
|
|
166
|
-
//
|
|
167
|
-
if (staticDir) {
|
|
168
|
-
return serveStatic(
|
|
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
|
-
|
|
174
|
-
|
|
175
|
-
if (
|
|
176
|
-
res
|
|
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
|
|
181
|
-
|
|
182
|
-
|
|
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
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
//
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
res.
|
|
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
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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
|
-
|
|
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
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
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
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
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
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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
|
-
|
|
352
|
-
|
|
353
|
-
|
|
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
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
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
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
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
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
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
|
-
|
|
420
|
-
|
|
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
|
|
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
|
|
435
|
-
res.end(JSON.stringify({ error: "provider is required" }));
|
|
498
|
+
jsonError(res, 400, "provider is required");
|
|
436
499
|
return;
|
|
437
500
|
}
|
|
438
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|