@castlekit/castle 0.1.6 → 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 (60) hide show
  1. package/drizzle.config.ts +7 -0
  2. package/next.config.ts +1 -0
  3. package/package.json +20 -3
  4. package/src/app/api/avatars/[id]/route.ts +57 -7
  5. package/src/app/api/openclaw/agents/status/route.ts +55 -0
  6. package/src/app/api/openclaw/chat/attachments/route.ts +230 -0
  7. package/src/app/api/openclaw/chat/channels/route.ts +214 -0
  8. package/src/app/api/openclaw/chat/route.ts +272 -0
  9. package/src/app/api/openclaw/chat/search/route.ts +149 -0
  10. package/src/app/api/openclaw/chat/storage/route.ts +75 -0
  11. package/src/app/api/openclaw/logs/route.ts +17 -3
  12. package/src/app/api/openclaw/restart/route.ts +6 -1
  13. package/src/app/api/openclaw/session/status/route.ts +42 -0
  14. package/src/app/api/settings/avatar/route.ts +190 -0
  15. package/src/app/api/settings/route.ts +88 -0
  16. package/src/app/chat/[channelId]/error-boundary.tsx +64 -0
  17. package/src/app/chat/[channelId]/page.tsx +305 -0
  18. package/src/app/chat/layout.tsx +96 -0
  19. package/src/app/chat/page.tsx +52 -0
  20. package/src/app/globals.css +89 -2
  21. package/src/app/layout.tsx +7 -1
  22. package/src/app/page.tsx +49 -17
  23. package/src/app/settings/page.tsx +300 -0
  24. package/src/components/chat/agent-mention-popup.tsx +89 -0
  25. package/src/components/chat/archived-channels.tsx +190 -0
  26. package/src/components/chat/channel-list.tsx +140 -0
  27. package/src/components/chat/chat-input.tsx +310 -0
  28. package/src/components/chat/create-channel-dialog.tsx +171 -0
  29. package/src/components/chat/markdown-content.tsx +205 -0
  30. package/src/components/chat/message-bubble.tsx +152 -0
  31. package/src/components/chat/message-list.tsx +508 -0
  32. package/src/components/chat/message-queue.tsx +68 -0
  33. package/src/components/chat/session-divider.tsx +61 -0
  34. package/src/components/chat/session-stats-panel.tsx +139 -0
  35. package/src/components/chat/storage-indicator.tsx +76 -0
  36. package/src/components/layout/sidebar.tsx +126 -45
  37. package/src/components/layout/user-menu.tsx +29 -4
  38. package/src/components/providers/presence-provider.tsx +8 -0
  39. package/src/components/providers/search-provider.tsx +81 -0
  40. package/src/components/search/search-dialog.tsx +269 -0
  41. package/src/components/ui/avatar.tsx +11 -9
  42. package/src/components/ui/dialog.tsx +10 -4
  43. package/src/components/ui/tooltip.tsx +25 -8
  44. package/src/components/ui/twemoji-text.tsx +37 -0
  45. package/src/lib/api-security.ts +125 -0
  46. package/src/lib/date-utils.ts +79 -0
  47. package/src/lib/db/__tests__/queries.test.ts +318 -0
  48. package/src/lib/db/index.ts +642 -0
  49. package/src/lib/db/queries.ts +1017 -0
  50. package/src/lib/db/schema.ts +160 -0
  51. package/src/lib/hooks/use-agent-status.ts +251 -0
  52. package/src/lib/hooks/use-chat.ts +775 -0
  53. package/src/lib/hooks/use-openclaw.ts +105 -70
  54. package/src/lib/hooks/use-search.ts +113 -0
  55. package/src/lib/hooks/use-session-stats.ts +57 -0
  56. package/src/lib/hooks/use-user-settings.ts +46 -0
  57. package/src/lib/types/chat.ts +186 -0
  58. package/src/lib/types/search.ts +60 -0
  59. package/src/middleware.ts +52 -0
  60. package/vitest.config.ts +13 -0
@@ -0,0 +1,269 @@
1
+ "use client";
2
+
3
+ import { useEffect, useRef, useState, type ReactNode } from "react";
4
+ import { useRouter } from "next/navigation";
5
+ import { Search, Clock, X } from "lucide-react";
6
+ import { cn } from "@/lib/utils";
7
+ import { formatDateTime } from "@/lib/date-utils";
8
+ import { useSearch } from "@/lib/hooks/use-search";
9
+ import type {
10
+ SearchResult,
11
+ SearchResultType,
12
+ MessageSearchResult,
13
+ } from "@/lib/types/search";
14
+
15
+ // ============================================================================
16
+ // Result renderers — one per content type (pluggable registry)
17
+ // ============================================================================
18
+
19
+ function MessageResultRow({ result }: { result: MessageSearchResult }) {
20
+ const timeStr = formatDateTime(result.timestamp);
21
+
22
+ return (
23
+ <div className="flex items-start gap-3 min-w-0">
24
+ <div className="min-w-0 flex-1">
25
+ <div className="flex items-center gap-2 text-xs text-foreground-secondary mb-0.5">
26
+ <span className="px-1.5 py-0.5 rounded bg-accent/10 text-accent font-medium text-[11px]">
27
+ {result.title}
28
+ </span>
29
+ {result.archived && (
30
+ <span className="text-[11px] text-foreground-secondary/50 italic">archived</span>
31
+ )}
32
+ <span className="font-medium">{result.subtitle}</span>
33
+ <span className="text-foreground-secondary/60">{timeStr}</span>
34
+ </div>
35
+ <p className="text-sm text-foreground truncate">{result.snippet}</p>
36
+ </div>
37
+ </div>
38
+ );
39
+ }
40
+
41
+ // Registry: map each SearchResultType to its renderer
42
+ const resultRenderers: Record<
43
+ SearchResultType,
44
+ ((result: SearchResult) => ReactNode) | null
45
+ > = {
46
+ message: (r) => <MessageResultRow result={r as MessageSearchResult} />,
47
+ task: null, // Future
48
+ note: null, // Future
49
+ project: null, // Future
50
+ };
51
+
52
+ // ============================================================================
53
+ // SearchDialog
54
+ // ============================================================================
55
+
56
+ interface SearchDialogProps {
57
+ open: boolean;
58
+ onClose: () => void;
59
+ }
60
+
61
+ export function SearchDialog({ open, onClose }: SearchDialogProps) {
62
+ const router = useRouter();
63
+ const inputRef = useRef<HTMLInputElement>(null);
64
+ const listRef = useRef<HTMLDivElement>(null);
65
+ const { query, setQuery, results, isSearching, recentSearches, clearRecentSearches } =
66
+ useSearch();
67
+ const [selectedIndex, setSelectedIndex] = useState(-1);
68
+
69
+ // Focus input on open
70
+ useEffect(() => {
71
+ if (open) {
72
+ // Small delay to ensure the DOM is ready
73
+ requestAnimationFrame(() => inputRef.current?.focus());
74
+ setSelectedIndex(-1);
75
+ }
76
+ }, [open]);
77
+
78
+ // Reset selection when results change
79
+ useEffect(() => {
80
+ setSelectedIndex(-1);
81
+ }, [results]);
82
+
83
+ // Determine what items are shown (results or recent searches)
84
+ const showRecent = !query.trim() && recentSearches.length > 0;
85
+ const itemCount = showRecent ? recentSearches.length : results.length;
86
+
87
+ // Keyboard navigation
88
+ const handleKeyDown = (e: React.KeyboardEvent) => {
89
+ if (e.key === "Escape") {
90
+ e.preventDefault();
91
+ onClose();
92
+ return;
93
+ }
94
+
95
+ if (e.key === "ArrowDown") {
96
+ e.preventDefault();
97
+ setSelectedIndex((prev) => (prev < itemCount - 1 ? prev + 1 : 0));
98
+ return;
99
+ }
100
+
101
+ if (e.key === "ArrowUp") {
102
+ e.preventDefault();
103
+ setSelectedIndex((prev) => (prev > 0 ? prev - 1 : itemCount - 1));
104
+ return;
105
+ }
106
+
107
+ if (e.key === "Enter" && selectedIndex >= 0) {
108
+ e.preventDefault();
109
+ if (showRecent) {
110
+ // Fill input with the recent search
111
+ setQuery(recentSearches[selectedIndex]);
112
+ } else if (results[selectedIndex]) {
113
+ navigateToResult(results[selectedIndex]);
114
+ }
115
+ return;
116
+ }
117
+ };
118
+
119
+ // Scroll selected item into view
120
+ useEffect(() => {
121
+ if (selectedIndex < 0 || !listRef.current) return;
122
+ const items = listRef.current.querySelectorAll("[data-search-item]");
123
+ items[selectedIndex]?.scrollIntoView({ block: "nearest" });
124
+ }, [selectedIndex]);
125
+
126
+ const navigateToResult = (result: SearchResult) => {
127
+ router.push(result.href);
128
+ // Close after navigation is dispatched — ensures router.push isn't
129
+ // interrupted by the dialog unmounting
130
+ setTimeout(onClose, 0);
131
+ };
132
+
133
+ if (!open) return null;
134
+
135
+ return (
136
+ <div
137
+ className="fixed inset-0 z-50 flex items-start justify-center pt-[15vh]"
138
+ onClick={onClose}
139
+ >
140
+ {/* Backdrop */}
141
+ <div className="absolute inset-0 bg-black/50 backdrop-blur-sm" />
142
+
143
+ {/* Dialog */}
144
+ <div
145
+ className="relative w-full max-w-[560px] rounded-[var(--radius-md)] bg-surface border border-border shadow-2xl overflow-hidden"
146
+ onClick={(e) => e.stopPropagation()}
147
+ >
148
+ {/* Search input */}
149
+ <div className="flex items-center gap-3 px-4 py-3 border-b border-border">
150
+ <Search className="h-5 w-5 text-foreground-secondary shrink-0" />
151
+ <input
152
+ ref={inputRef}
153
+ type="text"
154
+ value={query}
155
+ onChange={(e) => setQuery(e.target.value)}
156
+ onKeyDown={handleKeyDown}
157
+ placeholder="Search across all channels..."
158
+ className="flex-1 bg-transparent text-[15px] focus:outline-none placeholder:text-foreground-secondary/50"
159
+ />
160
+ <kbd className="hidden sm:inline-flex items-center gap-0.5 px-1.5 py-0.5 text-[11px] font-medium text-foreground-secondary/60 bg-surface-hover rounded border border-border/50">
161
+ ESC
162
+ </kbd>
163
+ </div>
164
+
165
+ {/* Results / Recent searches */}
166
+ <div ref={listRef} className="max-h-[400px] overflow-y-auto">
167
+ {/* Recent searches (before typing) */}
168
+ {showRecent && (
169
+ <div>
170
+ <div className="flex items-center justify-between px-4 pt-3 pb-1.5">
171
+ <span className="text-xs font-medium text-foreground-secondary/60 uppercase tracking-wider">
172
+ Recent searches
173
+ </span>
174
+ <button
175
+ onClick={clearRecentSearches}
176
+ className="text-xs text-foreground-secondary/60 hover:text-foreground-secondary transition-colors cursor-pointer"
177
+ >
178
+ Clear
179
+ </button>
180
+ </div>
181
+ {recentSearches.map((recent, i) => (
182
+ <button
183
+ key={recent}
184
+ data-search-item
185
+ onClick={() => setQuery(recent)}
186
+ className={cn(
187
+ "flex items-center gap-3 w-full px-4 py-2.5 text-sm text-left transition-colors cursor-pointer",
188
+ selectedIndex === i
189
+ ? "bg-accent/10 text-accent"
190
+ : "text-foreground hover:bg-surface-hover"
191
+ )}
192
+ >
193
+ <Clock className="h-4 w-4 text-foreground-secondary/60 shrink-0" />
194
+ <span className="truncate">{recent}</span>
195
+ </button>
196
+ ))}
197
+ </div>
198
+ )}
199
+
200
+ {/* Empty state — no query, no recent */}
201
+ {!query.trim() && recentSearches.length === 0 && (
202
+ <div className="px-4 py-8 text-center text-sm text-foreground-secondary/60">
203
+ Search across all channels
204
+ </div>
205
+ )}
206
+
207
+ {/* Loading skeleton */}
208
+ {query.trim() && isSearching && results.length === 0 && (
209
+ <div className="px-2">
210
+ {[1, 2, 3, 4, 5].map((i) => (
211
+ <div key={i} className="px-2 py-3 border-b border-border/20 last:border-b-0">
212
+ <div className="flex items-center gap-2 mb-1.5">
213
+ <div className="skeleton h-4 w-16 rounded" />
214
+ <div className="skeleton h-3 w-12 rounded" />
215
+ <div className="skeleton h-3 w-10 rounded" />
216
+ </div>
217
+ <div className="skeleton h-3.5 rounded" style={{ width: `${55 + i * 8}%` }} />
218
+ </div>
219
+ ))}
220
+ </div>
221
+ )}
222
+
223
+ {/* Results */}
224
+ {query.trim() && !isSearching && results.length === 0 && (
225
+ <div className="px-4 py-8 text-center text-sm text-foreground-secondary">
226
+ No results found
227
+ </div>
228
+ )}
229
+
230
+ {query.trim() &&
231
+ results.map((result, i) => {
232
+ const renderer = resultRenderers[result.type];
233
+ if (!renderer) return null;
234
+ return (
235
+ <button
236
+ key={result.id}
237
+ data-search-item
238
+ onClick={() => navigateToResult(result)}
239
+ className={cn(
240
+ "w-full px-4 py-3 text-left transition-colors cursor-pointer border-b border-border/20 last:border-b-0",
241
+ selectedIndex === i
242
+ ? "bg-accent/10"
243
+ : "hover:bg-surface-hover"
244
+ )}
245
+ >
246
+ {renderer(result)}
247
+ </button>
248
+ );
249
+ })}
250
+ </div>
251
+
252
+ {/* Footer hint */}
253
+ {query.trim() && results.length > 0 && (
254
+ <div className="px-4 py-2 border-t border-border/50 text-[11px] text-foreground-secondary/50 flex items-center gap-3">
255
+ <span>
256
+ <kbd className="px-1 py-0.5 rounded bg-surface-hover border border-border/50 text-[10px]">↑↓</kbd> Navigate
257
+ </span>
258
+ <span>
259
+ <kbd className="px-1 py-0.5 rounded bg-surface-hover border border-border/50 text-[10px]">↵</kbd> Open
260
+ </span>
261
+ <span>
262
+ <kbd className="px-1 py-0.5 rounded bg-surface-hover border border-border/50 text-[10px]">esc</kbd> Close
263
+ </span>
264
+ </div>
265
+ )}
266
+ </div>
267
+ </div>
268
+ );
269
+ }
@@ -4,17 +4,18 @@ import { cn } from "@/lib/utils";
4
4
  export interface AvatarProps extends HTMLAttributes<HTMLDivElement> {
5
5
  size?: "sm" | "md" | "lg";
6
6
  status?: "online" | "offline" | "busy" | "away";
7
+ statusPulse?: boolean;
7
8
  }
8
9
 
9
10
  const Avatar = forwardRef<HTMLDivElement, AvatarProps>(
10
- ({ className, size = "md", status, children, ...props }, ref) => {
11
+ ({ className, size = "md", status, statusPulse, children, ...props }, ref) => {
11
12
  return (
12
- <div className="relative inline-block">
13
+ <div className="relative inline-block shrink-0">
13
14
  <div
14
15
  className={cn(
15
- "relative flex shrink-0 overflow-hidden rounded-[var(--radius-full)] bg-surface border border-border",
16
+ "relative flex shrink-0 overflow-hidden rounded-[4px] bg-surface border border-border",
16
17
  {
17
- "h-8 w-8": size === "sm",
18
+ "h-9 w-9": size === "sm",
18
19
  "h-10 w-10": size === "md",
19
20
  "h-12 w-12": size === "lg",
20
21
  },
@@ -28,18 +29,19 @@ const Avatar = forwardRef<HTMLDivElement, AvatarProps>(
28
29
  {status && (
29
30
  <span
30
31
  className={cn(
31
- "absolute bottom-0 right-0 block rounded-[var(--radius-full)] ring-2 ring-background",
32
+ "absolute block rounded-full ring-2 ring-background",
32
33
  {
33
- "h-2.5 w-2.5": size === "sm",
34
- "h-3 w-3": size === "md",
35
- "h-3.5 w-3.5": size === "lg",
34
+ "h-2.5 w-2.5 -bottom-0.5 -right-0.5": size === "sm",
35
+ "h-3 w-3 -bottom-0.5 -right-0.5": size === "md",
36
+ "h-3.5 w-3.5 -bottom-0.5 -right-0.5": size === "lg",
36
37
  },
37
38
  {
38
39
  "bg-success": status === "online",
39
40
  "bg-foreground-muted": status === "offline",
40
41
  "bg-error": status === "busy",
41
42
  "bg-warning": status === "away",
42
- }
43
+ },
44
+ statusPulse && "animate-pulse"
43
45
  )}
44
46
  />
45
47
  )}
@@ -7,17 +7,23 @@ import { cn } from "@/lib/utils";
7
7
  export interface DialogProps extends HTMLAttributes<HTMLDivElement> {
8
8
  open?: boolean;
9
9
  onClose?: () => void;
10
+ onOpenChange?: (open: boolean) => void;
10
11
  }
11
12
 
12
13
  const Dialog = forwardRef<HTMLDivElement, DialogProps>(
13
- ({ className, open = false, onClose, children, ...props }, ref) => {
14
+ ({ className, open = false, onClose, onOpenChange, children, ...props }, ref) => {
14
15
  if (!open) return null;
15
16
 
17
+ const handleClose = () => {
18
+ onClose?.();
19
+ onOpenChange?.(false);
20
+ };
21
+
16
22
  return (
17
23
  <div className="fixed inset-0 z-50 flex items-center justify-center">
18
24
  <div
19
25
  className="fixed inset-0 bg-black/50 backdrop-blur-sm"
20
- onClick={onClose}
26
+ onClick={handleClose}
21
27
  />
22
28
  <div
23
29
  className={cn(
@@ -27,9 +33,9 @@ const Dialog = forwardRef<HTMLDivElement, DialogProps>(
27
33
  ref={ref}
28
34
  {...props}
29
35
  >
30
- {onClose && (
36
+ {(onClose || onOpenChange) && (
31
37
  <button
32
- onClick={onClose}
38
+ onClick={handleClose}
33
39
  className="absolute right-4 top-4 p-1 rounded-[var(--radius-sm)] text-foreground-muted hover:text-foreground hover:bg-surface-hover transition-colors"
34
40
  >
35
41
  <X className="h-4 w-4" />
@@ -9,16 +9,20 @@ export interface TooltipProps {
9
9
  content: string;
10
10
  side?: "top" | "right" | "bottom" | "left";
11
11
  className?: string;
12
+ /** Delay in ms before showing the tooltip (default 0) */
13
+ delay?: number;
12
14
  }
13
15
 
14
16
  function Tooltip({
15
17
  children,
16
18
  content,
17
19
  side = "right",
18
- className
20
+ className,
21
+ delay = 0,
19
22
  }: TooltipProps) {
20
23
  const [isHovered, setIsHovered] = useState(false);
21
24
  const [mounted, setMounted] = useState(false);
25
+ const delayRef = useRef<ReturnType<typeof setTimeout> | null>(null);
22
26
  const [position, setPosition] = useState({ x: 0, y: 0 });
23
27
  const [isPositioned, setIsPositioned] = useState(false);
24
28
  const triggerRef = useRef<HTMLDivElement>(null);
@@ -26,6 +30,10 @@ function Tooltip({
26
30
 
27
31
  useEffect(() => {
28
32
  setMounted(true);
33
+ return () => {
34
+ // Clean up delay timer on unmount to prevent setState on unmounted component
35
+ if (delayRef.current) clearTimeout(delayRef.current);
36
+ };
29
37
  }, []);
30
38
 
31
39
  const updatePosition = useCallback(() => {
@@ -83,8 +91,17 @@ function Tooltip({
83
91
  <>
84
92
  <div
85
93
  ref={triggerRef}
86
- onMouseEnter={() => setIsHovered(true)}
87
- onMouseLeave={() => setIsHovered(false)}
94
+ onMouseEnter={() => {
95
+ if (delay > 0) {
96
+ delayRef.current = setTimeout(() => setIsHovered(true), delay);
97
+ } else {
98
+ setIsHovered(true);
99
+ }
100
+ }}
101
+ onMouseLeave={() => {
102
+ if (delayRef.current) { clearTimeout(delayRef.current); delayRef.current = null; }
103
+ setIsHovered(false);
104
+ }}
88
105
  className={className}
89
106
  >
90
107
  {children}
@@ -110,26 +127,26 @@ function Tooltip({
110
127
  side === "bottom" && (isPositioned ? "translate-y-0" : "-translate-y-2")
111
128
  )}
112
129
  >
113
- <div className="relative bg-foreground text-background text-sm font-medium px-3 py-1.5 rounded-[4px] whitespace-nowrap shadow-xl shadow-black/25">
130
+ <div className="relative bg-[#1a1a1a] text-white text-sm font-medium px-3 py-1.5 rounded-[4px] whitespace-nowrap shadow-xl shadow-black/25">
114
131
  {content}
115
132
  {side === "right" && (
116
133
  <div className="absolute -left-[7px] top-1/2 -translate-y-1/2">
117
- <div className="w-0 h-0 border-t-[8px] border-t-transparent border-b-[8px] border-b-transparent border-r-[8px] border-r-foreground" />
134
+ <div className="w-0 h-0 border-t-[8px] border-t-transparent border-b-[8px] border-b-transparent border-r-[8px] border-r-[#1a1a1a]" />
118
135
  </div>
119
136
  )}
120
137
  {side === "left" && (
121
138
  <div className="absolute -right-[7px] top-1/2 -translate-y-1/2">
122
- <div className="w-0 h-0 border-t-[8px] border-t-transparent border-b-[8px] border-b-transparent border-l-[8px] border-l-foreground" />
139
+ <div className="w-0 h-0 border-t-[8px] border-t-transparent border-b-[8px] border-b-transparent border-l-[8px] border-l-[#1a1a1a]" />
123
140
  </div>
124
141
  )}
125
142
  {side === "top" && (
126
143
  <div className="absolute -bottom-[7px] left-1/2 -translate-x-1/2">
127
- <div className="w-0 h-0 border-l-[8px] border-l-transparent border-r-[8px] border-r-transparent border-t-[8px] border-t-foreground" />
144
+ <div className="w-0 h-0 border-l-[8px] border-l-transparent border-r-[8px] border-r-transparent border-t-[8px] border-t-[#1a1a1a]" />
128
145
  </div>
129
146
  )}
130
147
  {side === "bottom" && (
131
148
  <div className="absolute -top-[7px] left-1/2 -translate-x-1/2">
132
- <div className="w-0 h-0 border-l-[8px] border-l-transparent border-r-[8px] border-r-transparent border-b-[8px] border-b-foreground" />
149
+ <div className="w-0 h-0 border-l-[8px] border-l-transparent border-r-[8px] border-r-transparent border-b-[8px] border-b-[#1a1a1a]" />
133
150
  </div>
134
151
  )}
135
152
  </div>
@@ -0,0 +1,37 @@
1
+ "use client";
2
+
3
+ import { useRef, useEffect, memo } from "react";
4
+ import twemoji from "@twemoji/api";
5
+
6
+ interface TwemojiTextProps {
7
+ children: React.ReactNode;
8
+ className?: string;
9
+ }
10
+
11
+ /**
12
+ * Wraps children and replaces native Unicode emojis with Twemoji SVG images.
13
+ * Uses twemoji.parse() on the rendered DOM node after mount/update.
14
+ */
15
+ export const TwemojiText = memo(function TwemojiText({
16
+ children,
17
+ className,
18
+ }: TwemojiTextProps) {
19
+ const ref = useRef<HTMLSpanElement>(null);
20
+
21
+ useEffect(() => {
22
+ if (ref.current) {
23
+ twemoji.parse(ref.current, {
24
+ folder: "svg",
25
+ ext: ".svg",
26
+ // Use jsDelivr CDN for the SVG assets
27
+ base: "https://cdn.jsdelivr.net/gh/jdecked/twemoji@latest/assets/",
28
+ });
29
+ }
30
+ });
31
+
32
+ return (
33
+ <span ref={ref} className={className}>
34
+ {children}
35
+ </span>
36
+ );
37
+ });
@@ -1,5 +1,130 @@
1
1
  import { NextRequest, NextResponse } from "next/server";
2
2
 
3
+ // ============================================================================
4
+ // Localhost guard — reject requests from non-local IPs
5
+ // ============================================================================
6
+
7
+ const LOCALHOST_IPS = new Set([
8
+ "127.0.0.1",
9
+ "::1",
10
+ "::ffff:127.0.0.1",
11
+ "localhost",
12
+ ]);
13
+
14
+ /**
15
+ * Reject requests that don't originate from localhost.
16
+ * Uses x-forwarded-for (if behind a proxy) or falls back to the host header.
17
+ * Returns a 403 response if the request is from a non-local IP, or null if OK.
18
+ */
19
+ export function checkLocalhost(request: NextRequest): NextResponse | null {
20
+ // If running in development, skip the check
21
+ if (process.env.NODE_ENV === "development") return null;
22
+
23
+ const forwarded = request.headers.get("x-forwarded-for");
24
+ const host = request.headers.get("host") || "";
25
+
26
+ // Check x-forwarded-for first (proxy scenario)
27
+ if (forwarded) {
28
+ const clientIp = forwarded.split(",")[0].trim();
29
+ if (!LOCALHOST_IPS.has(clientIp)) {
30
+ return NextResponse.json(
31
+ { error: "Forbidden — Castle is only accessible from localhost" },
32
+ { status: 403 }
33
+ );
34
+ }
35
+ }
36
+
37
+ // Check host header — reject if it's not a localhost variant
38
+ const hostname = host.split(":")[0];
39
+ if (hostname && !LOCALHOST_IPS.has(hostname)) {
40
+ return NextResponse.json(
41
+ { error: "Forbidden — Castle is only accessible from localhost" },
42
+ { status: 403 }
43
+ );
44
+ }
45
+
46
+ return null;
47
+ }
48
+
49
+ // ============================================================================
50
+ // Rate limiting — in-memory sliding window
51
+ // ============================================================================
52
+
53
+ interface RateLimitEntry {
54
+ timestamps: number[];
55
+ }
56
+
57
+ const rateLimitStore = new Map<string, RateLimitEntry>();
58
+
59
+ // Clean up stale entries every 5 minutes
60
+ let cleanupTimer: ReturnType<typeof setInterval> | null = null;
61
+ function ensureCleanup() {
62
+ if (cleanupTimer) return;
63
+ cleanupTimer = setInterval(() => {
64
+ const now = Date.now();
65
+ for (const [key, entry] of rateLimitStore.entries()) {
66
+ entry.timestamps = entry.timestamps.filter((t) => now - t < 60_000);
67
+ if (entry.timestamps.length === 0) rateLimitStore.delete(key);
68
+ }
69
+ }, 5 * 60_000);
70
+ // Don't prevent process exit
71
+ if (typeof cleanupTimer === "object" && "unref" in cleanupTimer) {
72
+ cleanupTimer.unref();
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Simple in-memory rate limiter using a sliding window.
78
+ *
79
+ * @param key — Unique key for the rate limit bucket (e.g. "chat:send" or IP-based)
80
+ * @param limit — Max requests allowed within the window
81
+ * @param windowMs — Window duration in milliseconds (default: 60 seconds)
82
+ * @returns 429 response if rate limit exceeded, or null if OK
83
+ */
84
+ export function checkRateLimit(
85
+ key: string,
86
+ limit: number,
87
+ windowMs = 60_000
88
+ ): NextResponse | null {
89
+ ensureCleanup();
90
+
91
+ const now = Date.now();
92
+ let entry = rateLimitStore.get(key);
93
+
94
+ if (!entry) {
95
+ entry = { timestamps: [] };
96
+ rateLimitStore.set(key, entry);
97
+ }
98
+
99
+ // Drop timestamps outside the window
100
+ entry.timestamps = entry.timestamps.filter((t) => now - t < windowMs);
101
+
102
+ if (entry.timestamps.length >= limit) {
103
+ return NextResponse.json(
104
+ { error: "Too many requests — please slow down" },
105
+ { status: 429 }
106
+ );
107
+ }
108
+
109
+ entry.timestamps.push(now);
110
+ return null;
111
+ }
112
+
113
+ /**
114
+ * Helper to build a rate-limit key from the request IP + route.
115
+ */
116
+ export function rateLimitKey(request: NextRequest, route: string): string {
117
+ const ip =
118
+ request.headers.get("x-forwarded-for")?.split(",")[0].trim() ||
119
+ request.headers.get("x-real-ip") ||
120
+ "local";
121
+ return `${ip}:${route}`;
122
+ }
123
+
124
+ // ============================================================================
125
+ // CSRF protection
126
+ // ============================================================================
127
+
3
128
  /**
4
129
  * Verify that a mutating API request originated from the Castle UI itself,
5
130
  * not from a cross-origin attacker (CSRF protection).
@@ -0,0 +1,79 @@
1
+ // ============================================================================
2
+ // Shared date formatting — consistent across the app
3
+ // ============================================================================
4
+ //
5
+ // Formats:
6
+ // formatDate(ts) → "9 February 2026"
7
+ // formatDateTime(ts) → "9 February 2026 at 2:24 am"
8
+ // formatTime(ts) → "2:24 am"
9
+ // formatTimeAgo(ts) → "3 hours ago", "2 days ago", etc.
10
+ // formatDateShort(ts) → "9 Feb" (for compact UI like search results)
11
+
12
+ /**
13
+ * "9 February 2026"
14
+ */
15
+ export function formatDate(timestamp: number): string {
16
+ return new Date(timestamp).toLocaleDateString("en-GB", {
17
+ day: "numeric",
18
+ month: "long",
19
+ year: "numeric",
20
+ });
21
+ }
22
+
23
+ /**
24
+ * "9 February 2026 at 2:24 am"
25
+ */
26
+ export function formatDateTime(timestamp: number): string {
27
+ const date = new Date(timestamp);
28
+ const datePart = date.toLocaleDateString("en-GB", {
29
+ day: "numeric",
30
+ month: "long",
31
+ year: "numeric",
32
+ });
33
+ const timePart = date.toLocaleTimeString("en-GB", {
34
+ hour: "numeric",
35
+ minute: "2-digit",
36
+ hour12: true,
37
+ });
38
+ return `${datePart} at ${timePart}`;
39
+ }
40
+
41
+ /**
42
+ * "2:24 am"
43
+ */
44
+ export function formatTime(timestamp: number): string {
45
+ return new Date(timestamp).toLocaleTimeString("en-GB", {
46
+ hour: "numeric",
47
+ minute: "2-digit",
48
+ hour12: true,
49
+ });
50
+ }
51
+
52
+ /**
53
+ * "3 hours ago", "2 days ago", "just now", etc.
54
+ */
55
+ export function formatTimeAgo(timestamp: number): string {
56
+ const now = Date.now();
57
+ const diff = now - timestamp;
58
+
59
+ const seconds = Math.floor(diff / 1000);
60
+ if (seconds < 60) return "just now";
61
+
62
+ const minutes = Math.floor(seconds / 60);
63
+ if (minutes < 60) return `${minutes}m ago`;
64
+
65
+ const hours = Math.floor(minutes / 60);
66
+ if (hours < 24) return `${hours}h ago`;
67
+
68
+ const days = Math.floor(hours / 24);
69
+ if (days < 7) return `${days}d ago`;
70
+
71
+ const weeks = Math.floor(days / 7);
72
+ if (weeks < 5) return `${weeks}w ago`;
73
+
74
+ const months = Math.floor(days / 30);
75
+ if (months < 12) return `${months}mo ago`;
76
+
77
+ const years = Math.floor(days / 365);
78
+ return `${years}y ago`;
79
+ }