@castlekit/castle 0.1.5 → 0.3.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/drizzle.config.ts +7 -0
- package/next.config.ts +1 -0
- package/package.json +25 -4
- package/src/app/api/avatars/[id]/route.ts +122 -25
- package/src/app/api/openclaw/agents/[id]/avatar/route.ts +216 -0
- package/src/app/api/openclaw/agents/route.ts +77 -41
- package/src/app/api/openclaw/agents/status/route.ts +55 -0
- package/src/app/api/openclaw/chat/attachments/route.ts +230 -0
- package/src/app/api/openclaw/chat/channels/route.ts +214 -0
- package/src/app/api/openclaw/chat/route.ts +272 -0
- package/src/app/api/openclaw/chat/search/route.ts +149 -0
- package/src/app/api/openclaw/chat/storage/route.ts +75 -0
- package/src/app/api/openclaw/config/route.ts +45 -4
- package/src/app/api/openclaw/events/route.ts +31 -2
- package/src/app/api/openclaw/logs/route.ts +20 -5
- package/src/app/api/openclaw/restart/route.ts +12 -4
- package/src/app/api/openclaw/session/status/route.ts +42 -0
- package/src/app/api/settings/avatar/route.ts +190 -0
- package/src/app/api/settings/route.ts +88 -0
- package/src/app/chat/[channelId]/error-boundary.tsx +64 -0
- package/src/app/chat/[channelId]/page.tsx +305 -0
- package/src/app/chat/layout.tsx +96 -0
- package/src/app/chat/page.tsx +52 -0
- package/src/app/globals.css +89 -2
- package/src/app/layout.tsx +7 -1
- package/src/app/page.tsx +147 -28
- package/src/app/settings/page.tsx +300 -0
- package/src/cli/onboarding.ts +202 -37
- package/src/components/chat/agent-mention-popup.tsx +89 -0
- package/src/components/chat/archived-channels.tsx +190 -0
- package/src/components/chat/channel-list.tsx +140 -0
- package/src/components/chat/chat-input.tsx +310 -0
- package/src/components/chat/create-channel-dialog.tsx +171 -0
- package/src/components/chat/markdown-content.tsx +205 -0
- package/src/components/chat/message-bubble.tsx +152 -0
- package/src/components/chat/message-list.tsx +508 -0
- package/src/components/chat/message-queue.tsx +68 -0
- package/src/components/chat/session-divider.tsx +61 -0
- package/src/components/chat/session-stats-panel.tsx +139 -0
- package/src/components/chat/storage-indicator.tsx +76 -0
- package/src/components/layout/sidebar.tsx +126 -45
- package/src/components/layout/user-menu.tsx +29 -4
- package/src/components/providers/presence-provider.tsx +8 -0
- package/src/components/providers/search-provider.tsx +81 -0
- package/src/components/search/search-dialog.tsx +269 -0
- package/src/components/ui/avatar.tsx +11 -9
- package/src/components/ui/dialog.tsx +10 -4
- package/src/components/ui/tooltip.tsx +25 -8
- package/src/components/ui/twemoji-text.tsx +37 -0
- package/src/lib/api-security.ts +188 -0
- package/src/lib/config.ts +36 -4
- package/src/lib/date-utils.ts +79 -0
- package/src/lib/db/__tests__/queries.test.ts +318 -0
- package/src/lib/db/index.ts +642 -0
- package/src/lib/db/queries.ts +1017 -0
- package/src/lib/db/schema.ts +160 -0
- package/src/lib/device-identity.ts +303 -0
- package/src/lib/gateway-connection.ts +273 -36
- package/src/lib/hooks/use-agent-status.ts +251 -0
- package/src/lib/hooks/use-chat.ts +775 -0
- package/src/lib/hooks/use-openclaw.ts +105 -70
- package/src/lib/hooks/use-search.ts +113 -0
- package/src/lib/hooks/use-session-stats.ts +57 -0
- package/src/lib/hooks/use-user-settings.ts +46 -0
- package/src/lib/types/chat.ts +186 -0
- package/src/lib/types/search.ts +60 -0
- package/src/middleware.ts +52 -0
- package/vitest.config.ts +13 -0
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import { randomUUID } from "crypto";
|
|
3
|
+
import { checkCsrf, sanitizeForApi, checkRateLimit, rateLimitKey } from "@/lib/api-security";
|
|
4
|
+
import { ensureGateway } from "@/lib/gateway-connection";
|
|
5
|
+
import {
|
|
6
|
+
createMessage,
|
|
7
|
+
updateMessage,
|
|
8
|
+
deleteMessage,
|
|
9
|
+
getMessagesByChannel,
|
|
10
|
+
getMessagesAfter,
|
|
11
|
+
getMessagesAround,
|
|
12
|
+
getMessageByRunId,
|
|
13
|
+
getLatestSessionKey,
|
|
14
|
+
createSession,
|
|
15
|
+
} from "@/lib/db/queries";
|
|
16
|
+
import type { ChatSendRequest, ChatCompleteRequest } from "@/lib/types/chat";
|
|
17
|
+
|
|
18
|
+
const MAX_MESSAGE_LENGTH = 32768; // 32KB
|
|
19
|
+
|
|
20
|
+
// ============================================================================
|
|
21
|
+
// POST /api/openclaw/chat — Send a message
|
|
22
|
+
// ============================================================================
|
|
23
|
+
|
|
24
|
+
export async function POST(request: NextRequest) {
|
|
25
|
+
const csrf = checkCsrf(request);
|
|
26
|
+
if (csrf) return csrf;
|
|
27
|
+
|
|
28
|
+
// Rate limit: 30 messages per minute
|
|
29
|
+
const rl = checkRateLimit(rateLimitKey(request, "chat:send"), 30);
|
|
30
|
+
if (rl) return rl;
|
|
31
|
+
|
|
32
|
+
let body: ChatSendRequest;
|
|
33
|
+
try {
|
|
34
|
+
body = await request.json();
|
|
35
|
+
} catch {
|
|
36
|
+
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Validate
|
|
40
|
+
if (!body.channelId || typeof body.channelId !== "string") {
|
|
41
|
+
return NextResponse.json({ error: "channelId is required" }, { status: 400 });
|
|
42
|
+
}
|
|
43
|
+
if (!body.content || typeof body.content !== "string") {
|
|
44
|
+
return NextResponse.json({ error: "content is required" }, { status: 400 });
|
|
45
|
+
}
|
|
46
|
+
if (body.content.length > MAX_MESSAGE_LENGTH) {
|
|
47
|
+
return NextResponse.json(
|
|
48
|
+
{ error: `Message too long (max ${MAX_MESSAGE_LENGTH} chars)` },
|
|
49
|
+
{ status: 400 }
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Persist user message to DB
|
|
54
|
+
const userMsg = createMessage({
|
|
55
|
+
channelId: body.channelId,
|
|
56
|
+
senderType: "user",
|
|
57
|
+
senderId: "local-user",
|
|
58
|
+
senderName: "You",
|
|
59
|
+
content: body.content,
|
|
60
|
+
mentionedAgentId: body.agentId,
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
const gateway = ensureGateway();
|
|
65
|
+
|
|
66
|
+
// Get existing session key for this channel (if any)
|
|
67
|
+
let sessionKey = getLatestSessionKey(body.channelId);
|
|
68
|
+
|
|
69
|
+
// If no existing session, create one with a proper structured key:
|
|
70
|
+
// agent:<agentId>:castle:<channelId>
|
|
71
|
+
// This maps Castle channels to Gateway sessions 1:1, so the session
|
|
72
|
+
// is stable and traceable between Castle DB and Gateway transcripts.
|
|
73
|
+
if (!sessionKey) {
|
|
74
|
+
const agentId = body.agentId || "main";
|
|
75
|
+
sessionKey = `agent:${agentId}:castle:${body.channelId}`;
|
|
76
|
+
|
|
77
|
+
createSession({
|
|
78
|
+
channelId: body.channelId,
|
|
79
|
+
sessionKey,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Build chat.send params per Gateway protocol
|
|
84
|
+
const rpcParams: Record<string, unknown> = {
|
|
85
|
+
message: body.content,
|
|
86
|
+
sessionKey,
|
|
87
|
+
idempotencyKey: randomUUID(),
|
|
88
|
+
timeoutMs: 120000,
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const result = await gateway.request<{
|
|
92
|
+
runId: string;
|
|
93
|
+
status?: string;
|
|
94
|
+
}>("chat.send", rpcParams);
|
|
95
|
+
|
|
96
|
+
// Update user message with runId and sessionKey
|
|
97
|
+
const runId = result.runId;
|
|
98
|
+
updateMessage(userMsg.id, {
|
|
99
|
+
runId,
|
|
100
|
+
sessionKey,
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
return NextResponse.json({
|
|
104
|
+
runId,
|
|
105
|
+
messageId: userMsg.id,
|
|
106
|
+
sessionKey,
|
|
107
|
+
});
|
|
108
|
+
} catch (err) {
|
|
109
|
+
// RPC failed — try to remove the optimistic user message
|
|
110
|
+
try {
|
|
111
|
+
deleteMessage(userMsg.id);
|
|
112
|
+
} catch (delErr) {
|
|
113
|
+
console.error("[Chat API] Cleanup failed:", (delErr as Error).message);
|
|
114
|
+
}
|
|
115
|
+
console.error("[Chat API] Send failed:", (err as Error).message);
|
|
116
|
+
return NextResponse.json(
|
|
117
|
+
{ error: sanitizeForApi((err as Error).message) },
|
|
118
|
+
{ status: 502 }
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ============================================================================
|
|
124
|
+
// PUT /api/openclaw/chat — Complete/update an agent message
|
|
125
|
+
// ============================================================================
|
|
126
|
+
|
|
127
|
+
export async function PUT(request: NextRequest) {
|
|
128
|
+
const csrf = checkCsrf(request);
|
|
129
|
+
if (csrf) return csrf;
|
|
130
|
+
|
|
131
|
+
let body: ChatCompleteRequest;
|
|
132
|
+
try {
|
|
133
|
+
body = await request.json();
|
|
134
|
+
} catch {
|
|
135
|
+
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (!body.runId || !body.channelId || !body.content) {
|
|
139
|
+
return NextResponse.json(
|
|
140
|
+
{ error: "runId, channelId, and content are required" },
|
|
141
|
+
{ status: 400 }
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
try {
|
|
146
|
+
// Check if we already have a message for this runId (from a previous partial save)
|
|
147
|
+
const existing = getMessageByRunId(body.runId);
|
|
148
|
+
|
|
149
|
+
if (existing) {
|
|
150
|
+
// Already complete with same content — idempotent, skip update to avoid
|
|
151
|
+
// triggering FTS5 update trigger with identical content (causes SQL error)
|
|
152
|
+
if (existing.status === "complete" && existing.content === body.content) {
|
|
153
|
+
return NextResponse.json({ messageId: existing.id, updated: false });
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Update existing message (e.g. partial → complete, or content changed)
|
|
157
|
+
updateMessage(existing.id, {
|
|
158
|
+
content: body.content,
|
|
159
|
+
status: body.status,
|
|
160
|
+
sessionKey: body.sessionKey,
|
|
161
|
+
inputTokens: body.inputTokens,
|
|
162
|
+
outputTokens: body.outputTokens,
|
|
163
|
+
});
|
|
164
|
+
return NextResponse.json({ messageId: existing.id, updated: true });
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Create new agent message
|
|
168
|
+
const agentMsg = createMessage({
|
|
169
|
+
channelId: body.channelId,
|
|
170
|
+
senderType: "agent",
|
|
171
|
+
senderId: body.agentId,
|
|
172
|
+
senderName: body.agentName,
|
|
173
|
+
content: body.content,
|
|
174
|
+
status: body.status,
|
|
175
|
+
runId: body.runId,
|
|
176
|
+
sessionKey: body.sessionKey,
|
|
177
|
+
inputTokens: body.inputTokens,
|
|
178
|
+
outputTokens: body.outputTokens,
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
return NextResponse.json({ messageId: agentMsg.id, updated: false });
|
|
182
|
+
} catch (err) {
|
|
183
|
+
console.error("[Chat API] Complete failed:", (err as Error).message);
|
|
184
|
+
return NextResponse.json(
|
|
185
|
+
{ error: sanitizeForApi((err as Error).message) },
|
|
186
|
+
{ status: 500 }
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ============================================================================
|
|
192
|
+
// DELETE /api/openclaw/chat — Abort streaming
|
|
193
|
+
// ============================================================================
|
|
194
|
+
|
|
195
|
+
export async function DELETE(request: NextRequest) {
|
|
196
|
+
const csrf = checkCsrf(request);
|
|
197
|
+
if (csrf) return csrf;
|
|
198
|
+
|
|
199
|
+
try {
|
|
200
|
+
const gateway = ensureGateway();
|
|
201
|
+
await gateway.request("chat.abort", {});
|
|
202
|
+
return NextResponse.json({ ok: true });
|
|
203
|
+
} catch (err) {
|
|
204
|
+
console.error("[Chat API] Abort failed:", (err as Error).message);
|
|
205
|
+
return NextResponse.json(
|
|
206
|
+
{ error: sanitizeForApi((err as Error).message) },
|
|
207
|
+
{ status: 502 }
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// ============================================================================
|
|
213
|
+
// GET /api/openclaw/chat?channelId=X&limit=50&before=Y — Load history
|
|
214
|
+
// ?around=msgId — Load a window centered on a specific message
|
|
215
|
+
// ?after=msgId — Forward pagination (newer messages)
|
|
216
|
+
// ?before=msgId — Backward pagination (older messages, existing)
|
|
217
|
+
// ============================================================================
|
|
218
|
+
|
|
219
|
+
export async function GET(request: NextRequest) {
|
|
220
|
+
const { searchParams } = new URL(request.url);
|
|
221
|
+
const channelId = searchParams.get("channelId");
|
|
222
|
+
const limit = Math.min(parseInt(searchParams.get("limit") || "50", 10), 200);
|
|
223
|
+
const around = searchParams.get("around") || undefined;
|
|
224
|
+
const after = searchParams.get("after") || undefined;
|
|
225
|
+
const before = searchParams.get("before") || undefined;
|
|
226
|
+
|
|
227
|
+
if (!channelId) {
|
|
228
|
+
return NextResponse.json({ error: "channelId is required" }, { status: 400 });
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
try {
|
|
232
|
+
// Anchor mode: load a window of messages around a specific message
|
|
233
|
+
if (around) {
|
|
234
|
+
const result = getMessagesAround(channelId, around, limit);
|
|
235
|
+
if (!result) {
|
|
236
|
+
// Anchor message not found — fall back to latest messages
|
|
237
|
+
const msgs = getMessagesByChannel(channelId, limit);
|
|
238
|
+
return NextResponse.json({
|
|
239
|
+
messages: msgs,
|
|
240
|
+
hasMore: msgs.length === limit,
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
return NextResponse.json({
|
|
244
|
+
messages: result.messages,
|
|
245
|
+
hasMoreBefore: result.hasMoreBefore,
|
|
246
|
+
hasMoreAfter: result.hasMoreAfter,
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Forward pagination: load messages newer than cursor
|
|
251
|
+
if (after) {
|
|
252
|
+
const msgs = getMessagesAfter(channelId, after, limit);
|
|
253
|
+
return NextResponse.json({
|
|
254
|
+
messages: msgs,
|
|
255
|
+
hasMore: msgs.length === limit,
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Default / backward pagination
|
|
260
|
+
const msgs = getMessagesByChannel(channelId, limit, before);
|
|
261
|
+
return NextResponse.json({
|
|
262
|
+
messages: msgs,
|
|
263
|
+
hasMore: msgs.length === limit,
|
|
264
|
+
});
|
|
265
|
+
} catch (err) {
|
|
266
|
+
console.error("[Chat API] History failed:", (err as Error).message);
|
|
267
|
+
return NextResponse.json(
|
|
268
|
+
{ error: sanitizeForApi((err as Error).message) },
|
|
269
|
+
{ status: 500 }
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import { sanitizeForApi, checkRateLimit, rateLimitKey, checkCsrf } from "@/lib/api-security";
|
|
3
|
+
import { searchMessages, getChannels, getRecentSearches, addRecentSearch, clearRecentSearches } from "@/lib/db/queries";
|
|
4
|
+
import type { MessageSearchResult, SearchResult } from "@/lib/types/search";
|
|
5
|
+
|
|
6
|
+
const MAX_QUERY_LENGTH = 500;
|
|
7
|
+
|
|
8
|
+
// ============================================================================
|
|
9
|
+
// GET /api/openclaw/chat/search?q=X — Universal full-text search
|
|
10
|
+
// ============================================================================
|
|
11
|
+
|
|
12
|
+
export async function GET(request: NextRequest) {
|
|
13
|
+
// Rate limit: 60 searches per minute
|
|
14
|
+
const rl = checkRateLimit(rateLimitKey(request, "chat:search"), 60);
|
|
15
|
+
if (rl) return rl;
|
|
16
|
+
|
|
17
|
+
const { searchParams } = new URL(request.url);
|
|
18
|
+
|
|
19
|
+
// GET /api/openclaw/chat/search?recent=1 — return recent searches
|
|
20
|
+
if (searchParams.get("recent") === "1") {
|
|
21
|
+
try {
|
|
22
|
+
const recent = getRecentSearches();
|
|
23
|
+
return NextResponse.json({ recent });
|
|
24
|
+
} catch (err) {
|
|
25
|
+
console.error("[Chat Search] Recent failed:", (err as Error).message);
|
|
26
|
+
return NextResponse.json({ recent: [] });
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const q = searchParams.get("q");
|
|
31
|
+
const limit = Math.min(parseInt(searchParams.get("limit") || "30", 10), 100);
|
|
32
|
+
|
|
33
|
+
if (!q || !q.trim()) {
|
|
34
|
+
return NextResponse.json({ results: [] });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (q.length > MAX_QUERY_LENGTH) {
|
|
38
|
+
return NextResponse.json(
|
|
39
|
+
{ error: `Search query too long (max ${MAX_QUERY_LENGTH} chars)` },
|
|
40
|
+
{ status: 400 }
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
// Build channel name lookup — include archived channels so their
|
|
46
|
+
// names still resolve in search results.
|
|
47
|
+
const activeChannels = getChannels(false);
|
|
48
|
+
const archivedChannels = getChannels(true);
|
|
49
|
+
const channelMap = new Map<string, { name: string; archived: boolean }>();
|
|
50
|
+
for (const ch of activeChannels) {
|
|
51
|
+
channelMap.set(ch.id, { name: ch.name, archived: false });
|
|
52
|
+
}
|
|
53
|
+
for (const ch of archivedChannels) {
|
|
54
|
+
channelMap.set(ch.id, { name: ch.name, archived: true });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Search messages across all channels
|
|
58
|
+
const rawMessages = searchMessages(q, undefined, limit);
|
|
59
|
+
|
|
60
|
+
// Map raw ChatMessage[] into typed MessageSearchResult[]
|
|
61
|
+
const results: SearchResult[] = rawMessages.map((msg): MessageSearchResult => {
|
|
62
|
+
const ch = channelMap.get(msg.channelId);
|
|
63
|
+
const channelName = ch?.name || "Unknown";
|
|
64
|
+
const archived = ch?.archived ?? false;
|
|
65
|
+
const senderName =
|
|
66
|
+
msg.senderType === "user"
|
|
67
|
+
? "You"
|
|
68
|
+
: msg.senderName || msg.senderId;
|
|
69
|
+
|
|
70
|
+
// Truncate snippet to ~200 chars
|
|
71
|
+
const snippet =
|
|
72
|
+
msg.content.length > 200
|
|
73
|
+
? msg.content.slice(0, 200) + "…"
|
|
74
|
+
: msg.content;
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
id: msg.id,
|
|
78
|
+
type: "message",
|
|
79
|
+
title: `#${channelName}`,
|
|
80
|
+
subtitle: senderName,
|
|
81
|
+
snippet,
|
|
82
|
+
timestamp: msg.createdAt,
|
|
83
|
+
href: `/chat/${msg.channelId}?m=${msg.id}`,
|
|
84
|
+
channelId: msg.channelId,
|
|
85
|
+
channelName,
|
|
86
|
+
messageId: msg.id,
|
|
87
|
+
senderType: msg.senderType,
|
|
88
|
+
senderName,
|
|
89
|
+
archived,
|
|
90
|
+
};
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// Future: merge results from searchTasks(), searchNotes(), etc.
|
|
94
|
+
// Sort by timestamp descending (already sorted by FTS query)
|
|
95
|
+
|
|
96
|
+
return NextResponse.json({ results });
|
|
97
|
+
} catch (err) {
|
|
98
|
+
console.error("[Chat Search] Failed:", (err as Error).message);
|
|
99
|
+
return NextResponse.json(
|
|
100
|
+
{ error: sanitizeForApi((err as Error).message) },
|
|
101
|
+
{ status: 500 }
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ============================================================================
|
|
107
|
+
// POST /api/openclaw/chat/search — Save a recent search
|
|
108
|
+
// ============================================================================
|
|
109
|
+
|
|
110
|
+
export async function POST(request: NextRequest) {
|
|
111
|
+
const csrf = checkCsrf(request);
|
|
112
|
+
if (csrf) return csrf;
|
|
113
|
+
|
|
114
|
+
try {
|
|
115
|
+
const body = await request.json();
|
|
116
|
+
const q = body.query;
|
|
117
|
+
if (!q || typeof q !== "string" || !q.trim()) {
|
|
118
|
+
return NextResponse.json({ error: "query is required" }, { status: 400 });
|
|
119
|
+
}
|
|
120
|
+
addRecentSearch(q.trim());
|
|
121
|
+
return NextResponse.json({ ok: true });
|
|
122
|
+
} catch (err) {
|
|
123
|
+
console.error("[Chat Search] Save recent failed:", (err as Error).message);
|
|
124
|
+
return NextResponse.json(
|
|
125
|
+
{ error: sanitizeForApi((err as Error).message) },
|
|
126
|
+
{ status: 500 }
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ============================================================================
|
|
132
|
+
// DELETE /api/openclaw/chat/search — Clear all recent searches
|
|
133
|
+
// ============================================================================
|
|
134
|
+
|
|
135
|
+
export async function DELETE(request: NextRequest) {
|
|
136
|
+
const csrf = checkCsrf(request);
|
|
137
|
+
if (csrf) return csrf;
|
|
138
|
+
|
|
139
|
+
try {
|
|
140
|
+
clearRecentSearches();
|
|
141
|
+
return NextResponse.json({ ok: true });
|
|
142
|
+
} catch (err) {
|
|
143
|
+
console.error("[Chat Search] Clear recent failed:", (err as Error).message);
|
|
144
|
+
return NextResponse.json(
|
|
145
|
+
{ error: sanitizeForApi((err as Error).message) },
|
|
146
|
+
{ status: 500 }
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
2
|
+
import { existsSync, statSync, readdirSync } from "fs";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
import { getCastleDir } from "@/lib/config";
|
|
5
|
+
import { getStorageStats } from "@/lib/db/queries";
|
|
6
|
+
import { getDbPath } from "@/lib/db/index";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Recursively calculate total size of a directory.
|
|
10
|
+
*/
|
|
11
|
+
function getDirSize(dir: string): number {
|
|
12
|
+
if (!existsSync(dir)) return 0;
|
|
13
|
+
|
|
14
|
+
let total = 0;
|
|
15
|
+
const entries = readdirSync(dir, { withFileTypes: true });
|
|
16
|
+
for (const entry of entries) {
|
|
17
|
+
const fullPath = join(dir, entry.name);
|
|
18
|
+
if (entry.isDirectory()) {
|
|
19
|
+
total += getDirSize(fullPath);
|
|
20
|
+
} else {
|
|
21
|
+
try {
|
|
22
|
+
total += statSync(fullPath).size;
|
|
23
|
+
} catch {
|
|
24
|
+
// Skip files we can't stat
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return total;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ============================================================================
|
|
32
|
+
// GET /api/openclaw/chat/storage — Storage stats
|
|
33
|
+
// ============================================================================
|
|
34
|
+
|
|
35
|
+
export async function GET() {
|
|
36
|
+
try {
|
|
37
|
+
const stats = getStorageStats();
|
|
38
|
+
|
|
39
|
+
// Get actual DB file size
|
|
40
|
+
const dbPath = getDbPath();
|
|
41
|
+
let dbSizeBytes = 0;
|
|
42
|
+
if (existsSync(dbPath)) {
|
|
43
|
+
dbSizeBytes = statSync(dbPath).size;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Get actual attachments directory size
|
|
47
|
+
const attachmentsDir = join(getCastleDir(), "data", "attachments");
|
|
48
|
+
const attachmentsDirSize = getDirSize(attachmentsDir);
|
|
49
|
+
|
|
50
|
+
// Define warning thresholds
|
|
51
|
+
const WARN_DB_SIZE = 500 * 1024 * 1024; // 500MB
|
|
52
|
+
const WARN_ATTACHMENTS = 2 * 1024 * 1024 * 1024; // 2GB
|
|
53
|
+
const warnings: string[] = [];
|
|
54
|
+
|
|
55
|
+
if (dbSizeBytes > WARN_DB_SIZE) {
|
|
56
|
+
warnings.push(`Database is ${(dbSizeBytes / 1024 / 1024).toFixed(0)}MB — consider archiving old messages`);
|
|
57
|
+
}
|
|
58
|
+
if (attachmentsDirSize > WARN_ATTACHMENTS) {
|
|
59
|
+
warnings.push(`Attachments using ${(attachmentsDirSize / 1024 / 1024 / 1024).toFixed(1)}GB of disk space`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return NextResponse.json({
|
|
63
|
+
...stats,
|
|
64
|
+
dbSizeBytes,
|
|
65
|
+
attachmentsDirBytes: attachmentsDirSize,
|
|
66
|
+
warnings,
|
|
67
|
+
});
|
|
68
|
+
} catch (err) {
|
|
69
|
+
console.error("[Storage] Stats failed:", (err as Error).message);
|
|
70
|
+
return NextResponse.json(
|
|
71
|
+
{ error: "Failed to get storage stats" },
|
|
72
|
+
{ status: 500 }
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -1,15 +1,53 @@
|
|
|
1
1
|
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
-
import { readFileSync,
|
|
2
|
+
import { readFileSync, existsSync } from "fs";
|
|
3
3
|
import { join } from "path";
|
|
4
4
|
import JSON5 from "json5";
|
|
5
5
|
import { getOpenClawDir } from "@/lib/config";
|
|
6
6
|
import { ensureGateway } from "@/lib/gateway-connection";
|
|
7
|
+
import { checkCsrf } from "@/lib/api-security";
|
|
7
8
|
|
|
8
9
|
export const dynamic = "force-dynamic";
|
|
9
10
|
|
|
11
|
+
/**
|
|
12
|
+
* Deep-clone a config object, redacting sensitive fields so they
|
|
13
|
+
* are never returned over HTTP.
|
|
14
|
+
*/
|
|
15
|
+
function redactSecrets(obj: unknown): unknown {
|
|
16
|
+
if (obj === null || obj === undefined) return obj;
|
|
17
|
+
if (typeof obj !== "object") return obj;
|
|
18
|
+
if (Array.isArray(obj)) return obj.map(redactSecrets);
|
|
19
|
+
|
|
20
|
+
const result: Record<string, unknown> = {};
|
|
21
|
+
for (const [key, value] of Object.entries(obj as Record<string, unknown>)) {
|
|
22
|
+
// Redact any key that looks like a token or secret
|
|
23
|
+
const lower = key.toLowerCase();
|
|
24
|
+
if (
|
|
25
|
+
lower === "token" ||
|
|
26
|
+
lower === "secret" ||
|
|
27
|
+
lower === "password" ||
|
|
28
|
+
lower === "apikey" ||
|
|
29
|
+
lower === "api_key" ||
|
|
30
|
+
lower === "privatekey" ||
|
|
31
|
+
lower === "private_key"
|
|
32
|
+
) {
|
|
33
|
+
if (typeof value === "string" && value.length > 0) {
|
|
34
|
+
result[key] = value.slice(0, 4) + "***";
|
|
35
|
+
} else {
|
|
36
|
+
result[key] = "***";
|
|
37
|
+
}
|
|
38
|
+
} else if (typeof value === "object") {
|
|
39
|
+
result[key] = redactSecrets(value);
|
|
40
|
+
} else {
|
|
41
|
+
result[key] = value;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return result;
|
|
45
|
+
}
|
|
46
|
+
|
|
10
47
|
/**
|
|
11
48
|
* GET /api/openclaw/config
|
|
12
|
-
*
|
|
49
|
+
* Reads OpenClaw config from filesystem.
|
|
50
|
+
* Sensitive fields (tokens, secrets, keys) are redacted before returning.
|
|
13
51
|
*/
|
|
14
52
|
export async function GET() {
|
|
15
53
|
const configPath = join(getOpenClawDir(), "openclaw.json");
|
|
@@ -27,7 +65,7 @@ export async function GET() {
|
|
|
27
65
|
try {
|
|
28
66
|
const raw = readFileSync(json5Path, "utf-8");
|
|
29
67
|
const config = JSON5.parse(raw);
|
|
30
|
-
return NextResponse.json({ config, format: "json5" });
|
|
68
|
+
return NextResponse.json({ config: redactSecrets(config), format: "json5" });
|
|
31
69
|
} catch (err) {
|
|
32
70
|
return NextResponse.json(
|
|
33
71
|
{ error: err instanceof Error ? err.message : "Failed to parse config" },
|
|
@@ -39,7 +77,7 @@ export async function GET() {
|
|
|
39
77
|
try {
|
|
40
78
|
const raw = readFileSync(configPath, "utf-8");
|
|
41
79
|
const config = JSON5.parse(raw);
|
|
42
|
-
return NextResponse.json({ config, format: "json" });
|
|
80
|
+
return NextResponse.json({ config: redactSecrets(config), format: "json" });
|
|
43
81
|
} catch (err) {
|
|
44
82
|
return NextResponse.json(
|
|
45
83
|
{ error: err instanceof Error ? err.message : "Failed to parse config" },
|
|
@@ -51,9 +89,12 @@ export async function GET() {
|
|
|
51
89
|
/**
|
|
52
90
|
* PATCH /api/openclaw/config
|
|
53
91
|
* Update OpenClaw config via Gateway's config.patch method.
|
|
92
|
+
* Protected against CSRF — only requests from the Castle UI are allowed.
|
|
54
93
|
* Body: { patch: { ... } } -- the patch to apply
|
|
55
94
|
*/
|
|
56
95
|
export async function PATCH(request: NextRequest) {
|
|
96
|
+
const csrf = checkCsrf(request);
|
|
97
|
+
if (csrf) return csrf;
|
|
57
98
|
const gw = ensureGateway();
|
|
58
99
|
|
|
59
100
|
if (!gw.isConnected) {
|
|
@@ -3,6 +3,34 @@ import { ensureGateway, type GatewayEvent } from "@/lib/gateway-connection";
|
|
|
3
3
|
export const dynamic = "force-dynamic";
|
|
4
4
|
export const runtime = "nodejs";
|
|
5
5
|
|
|
6
|
+
/**
|
|
7
|
+
* Strip sensitive fields (tokens, keys) from event payloads
|
|
8
|
+
* before forwarding to the browser.
|
|
9
|
+
*/
|
|
10
|
+
function redactEventPayload(evt: GatewayEvent): GatewayEvent {
|
|
11
|
+
if (!evt.payload || typeof evt.payload !== "object") return evt;
|
|
12
|
+
|
|
13
|
+
const payload = { ...(evt.payload as Record<string, unknown>) };
|
|
14
|
+
|
|
15
|
+
// Redact deviceToken from pairing events
|
|
16
|
+
if (typeof payload.deviceToken === "string") {
|
|
17
|
+
payload.deviceToken = "[REDACTED]";
|
|
18
|
+
}
|
|
19
|
+
// Redact nested auth.deviceToken
|
|
20
|
+
if (payload.auth && typeof payload.auth === "object") {
|
|
21
|
+
const auth = { ...(payload.auth as Record<string, unknown>) };
|
|
22
|
+
if (typeof auth.deviceToken === "string") auth.deviceToken = "[REDACTED]";
|
|
23
|
+
if (typeof auth.token === "string") auth.token = "[REDACTED]";
|
|
24
|
+
payload.auth = auth;
|
|
25
|
+
}
|
|
26
|
+
// Redact any top-level token field
|
|
27
|
+
if (typeof payload.token === "string") {
|
|
28
|
+
payload.token = "[REDACTED]";
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return { ...evt, payload };
|
|
32
|
+
}
|
|
33
|
+
|
|
6
34
|
/**
|
|
7
35
|
* GET /api/openclaw/events
|
|
8
36
|
* SSE endpoint -- streams OpenClaw Gateway events to the browser in real-time.
|
|
@@ -27,11 +55,12 @@ export async function GET() {
|
|
|
27
55
|
};
|
|
28
56
|
controller.enqueue(encoder.encode(`data: ${JSON.stringify(initial)}\n\n`));
|
|
29
57
|
|
|
30
|
-
// Forward gateway events
|
|
58
|
+
// Forward gateway events (with sensitive fields redacted)
|
|
31
59
|
const onGatewayEvent = (evt: GatewayEvent) => {
|
|
32
60
|
if (closed) return;
|
|
33
61
|
try {
|
|
34
|
-
|
|
62
|
+
const safe = redactEventPayload(evt);
|
|
63
|
+
controller.enqueue(encoder.encode(`data: ${JSON.stringify(safe)}\n\n`));
|
|
35
64
|
} catch {
|
|
36
65
|
// Stream may have closed
|
|
37
66
|
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { NextRequest, NextResponse } from "next/server";
|
|
2
2
|
import { readFileSync, existsSync, readdirSync } from "fs";
|
|
3
|
-
import { join } from "path";
|
|
3
|
+
import { join, resolve } from "path";
|
|
4
4
|
import { getOpenClawDir } from "@/lib/config";
|
|
5
|
+
import { sanitizeForApi } from "@/lib/api-security";
|
|
5
6
|
|
|
6
7
|
export const dynamic = "force-dynamic";
|
|
7
8
|
|
|
@@ -13,7 +14,17 @@ export async function GET(request: NextRequest) {
|
|
|
13
14
|
const { searchParams } = new URL(request.url);
|
|
14
15
|
const rawLines = parseInt(searchParams.get("lines") || "100", 10);
|
|
15
16
|
const lines = Math.min(Math.max(1, Number.isFinite(rawLines) ? rawLines : 100), 10000);
|
|
16
|
-
const
|
|
17
|
+
const rawFile = searchParams.get("file")?.trim() || "gateway";
|
|
18
|
+
|
|
19
|
+
// Sanitize file parameter: only allow alphanumeric, hyphens, underscores, dots
|
|
20
|
+
// Reject anything with path separators or traversal patterns
|
|
21
|
+
const file = rawFile.replace(/[^a-zA-Z0-9._-]/g, "");
|
|
22
|
+
if (!file || file.includes("..") || file !== rawFile) {
|
|
23
|
+
return NextResponse.json(
|
|
24
|
+
{ error: "Invalid log file name" },
|
|
25
|
+
{ status: 400 }
|
|
26
|
+
);
|
|
27
|
+
}
|
|
17
28
|
|
|
18
29
|
const logsDir = join(getOpenClawDir(), "logs");
|
|
19
30
|
|
|
@@ -26,7 +37,7 @@ export async function GET(request: NextRequest) {
|
|
|
26
37
|
? readdirSync(logsDir).filter((f) => f.endsWith(".log"))
|
|
27
38
|
: [];
|
|
28
39
|
|
|
29
|
-
// Find matching log file
|
|
40
|
+
// Find matching log file — must match prefix exactly within the logs directory
|
|
30
41
|
const logFile = availableFiles.find((f) => f.startsWith(file));
|
|
31
42
|
if (!logFile) {
|
|
32
43
|
return NextResponse.json({
|
|
@@ -38,11 +49,15 @@ export async function GET(request: NextRequest) {
|
|
|
38
49
|
|
|
39
50
|
try {
|
|
40
51
|
const logPath = join(logsDir, logFile);
|
|
52
|
+
// Final safety check: resolved path must be inside the logs directory
|
|
53
|
+
if (!resolve(logPath).startsWith(resolve(logsDir))) {
|
|
54
|
+
return NextResponse.json({ error: "Invalid log file path" }, { status: 400 });
|
|
55
|
+
}
|
|
41
56
|
const content = readFileSync(logPath, "utf-8");
|
|
42
57
|
const allLines = content.split("\n").filter(Boolean);
|
|
43
58
|
|
|
44
|
-
// Return last N lines
|
|
45
|
-
const tailLines = allLines.slice(-lines);
|
|
59
|
+
// Return last N lines, sanitized to strip tokens/keys
|
|
60
|
+
const tailLines = allLines.slice(-lines).map(sanitizeForApi);
|
|
46
61
|
|
|
47
62
|
return NextResponse.json({
|
|
48
63
|
logs: tailLines,
|