@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.
Files changed (68) hide show
  1. package/drizzle.config.ts +7 -0
  2. package/next.config.ts +1 -0
  3. package/package.json +25 -4
  4. package/src/app/api/avatars/[id]/route.ts +122 -25
  5. package/src/app/api/openclaw/agents/[id]/avatar/route.ts +216 -0
  6. package/src/app/api/openclaw/agents/route.ts +77 -41
  7. package/src/app/api/openclaw/agents/status/route.ts +55 -0
  8. package/src/app/api/openclaw/chat/attachments/route.ts +230 -0
  9. package/src/app/api/openclaw/chat/channels/route.ts +214 -0
  10. package/src/app/api/openclaw/chat/route.ts +272 -0
  11. package/src/app/api/openclaw/chat/search/route.ts +149 -0
  12. package/src/app/api/openclaw/chat/storage/route.ts +75 -0
  13. package/src/app/api/openclaw/config/route.ts +45 -4
  14. package/src/app/api/openclaw/events/route.ts +31 -2
  15. package/src/app/api/openclaw/logs/route.ts +20 -5
  16. package/src/app/api/openclaw/restart/route.ts +12 -4
  17. package/src/app/api/openclaw/session/status/route.ts +42 -0
  18. package/src/app/api/settings/avatar/route.ts +190 -0
  19. package/src/app/api/settings/route.ts +88 -0
  20. package/src/app/chat/[channelId]/error-boundary.tsx +64 -0
  21. package/src/app/chat/[channelId]/page.tsx +305 -0
  22. package/src/app/chat/layout.tsx +96 -0
  23. package/src/app/chat/page.tsx +52 -0
  24. package/src/app/globals.css +89 -2
  25. package/src/app/layout.tsx +7 -1
  26. package/src/app/page.tsx +147 -28
  27. package/src/app/settings/page.tsx +300 -0
  28. package/src/cli/onboarding.ts +202 -37
  29. package/src/components/chat/agent-mention-popup.tsx +89 -0
  30. package/src/components/chat/archived-channels.tsx +190 -0
  31. package/src/components/chat/channel-list.tsx +140 -0
  32. package/src/components/chat/chat-input.tsx +310 -0
  33. package/src/components/chat/create-channel-dialog.tsx +171 -0
  34. package/src/components/chat/markdown-content.tsx +205 -0
  35. package/src/components/chat/message-bubble.tsx +152 -0
  36. package/src/components/chat/message-list.tsx +508 -0
  37. package/src/components/chat/message-queue.tsx +68 -0
  38. package/src/components/chat/session-divider.tsx +61 -0
  39. package/src/components/chat/session-stats-panel.tsx +139 -0
  40. package/src/components/chat/storage-indicator.tsx +76 -0
  41. package/src/components/layout/sidebar.tsx +126 -45
  42. package/src/components/layout/user-menu.tsx +29 -4
  43. package/src/components/providers/presence-provider.tsx +8 -0
  44. package/src/components/providers/search-provider.tsx +81 -0
  45. package/src/components/search/search-dialog.tsx +269 -0
  46. package/src/components/ui/avatar.tsx +11 -9
  47. package/src/components/ui/dialog.tsx +10 -4
  48. package/src/components/ui/tooltip.tsx +25 -8
  49. package/src/components/ui/twemoji-text.tsx +37 -0
  50. package/src/lib/api-security.ts +188 -0
  51. package/src/lib/config.ts +36 -4
  52. package/src/lib/date-utils.ts +79 -0
  53. package/src/lib/db/__tests__/queries.test.ts +318 -0
  54. package/src/lib/db/index.ts +642 -0
  55. package/src/lib/db/queries.ts +1017 -0
  56. package/src/lib/db/schema.ts +160 -0
  57. package/src/lib/device-identity.ts +303 -0
  58. package/src/lib/gateway-connection.ts +273 -36
  59. package/src/lib/hooks/use-agent-status.ts +251 -0
  60. package/src/lib/hooks/use-chat.ts +775 -0
  61. package/src/lib/hooks/use-openclaw.ts +105 -70
  62. package/src/lib/hooks/use-search.ts +113 -0
  63. package/src/lib/hooks/use-session-stats.ts +57 -0
  64. package/src/lib/hooks/use-user-settings.ts +46 -0
  65. package/src/lib/types/chat.ts +186 -0
  66. package/src/lib/types/search.ts +60 -0
  67. package/src/middleware.ts +52 -0
  68. 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, writeFileSync, existsSync } from "fs";
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
- * God mode: reads OpenClaw config from filesystem
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
- controller.enqueue(encoder.encode(`data: ${JSON.stringify(evt)}\n\n`));
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 file = searchParams.get("file")?.trim() || "gateway";
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,