@swarmclawai/swarmclaw 0.5.2 → 0.5.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.
@@ -0,0 +1,30 @@
1
+ import { genId } from '@/lib/id'
2
+ import { saveNotification } from '@/lib/server/storage'
3
+ import { notify } from '@/lib/server/ws-hub'
4
+ import type { AppNotification } from '@/types'
5
+
6
+ /**
7
+ * Create and persist a notification, then push a WS invalidation.
8
+ */
9
+ export function createNotification(opts: {
10
+ type: AppNotification['type']
11
+ title: string
12
+ message?: string
13
+ entityType?: string
14
+ entityId?: string
15
+ }) {
16
+ const id = genId()
17
+ const notification: AppNotification = {
18
+ id,
19
+ type: opts.type,
20
+ title: opts.title,
21
+ message: opts.message,
22
+ entityType: opts.entityType,
23
+ entityId: opts.entityId,
24
+ read: false,
25
+ createdAt: Date.now(),
26
+ }
27
+ saveNotification(id, notification)
28
+ notify('notifications')
29
+ return notification
30
+ }
@@ -17,6 +17,7 @@ import { enqueueSessionRun } from './session-run-manager'
17
17
  import { WORKSPACE_DIR } from './data-dir'
18
18
  import { genId } from '@/lib/id'
19
19
  import type { WebhookRetryEntry } from '@/types'
20
+ import { createNotification } from '@/lib/server/create-notification'
20
21
 
21
22
  const QUEUE_CHECK_INTERVAL = 30_000 // 30 seconds
22
23
  const BROWSER_SWEEP_INTERVAL = 60_000 // 60 seconds
@@ -254,6 +255,13 @@ async function runConnectorHealthChecks(now: number) {
254
255
  connectors[connector.id] = connector
255
256
  saveConnectors(connectors)
256
257
  ds.connectorRestartState.delete(connector.id)
258
+ createNotification({
259
+ type: 'error',
260
+ title: `Connector "${connector.name}" failed`,
261
+ message: `Auto-restart gave up after ${MAX_WAKE_ATTEMPTS} consecutive failures.`,
262
+ entityType: 'connector',
263
+ entityId: connector.id,
264
+ })
257
265
  continue
258
266
  }
259
267
 
@@ -51,6 +51,7 @@ const COLLECTIONS = [
51
51
  'projects',
52
52
  'activity',
53
53
  'webhook_retry_queue',
54
+ 'notifications',
54
55
  ] as const
55
56
 
56
57
  for (const table of COLLECTIONS) {
@@ -775,6 +776,32 @@ export function deleteWebhookRetry(id: string) {
775
776
  deleteCollectionItem('webhook_retry_queue', id)
776
777
  }
777
778
 
779
+ // --- Notifications ---
780
+ export function loadNotifications(): Record<string, unknown> {
781
+ return loadCollection('notifications')
782
+ }
783
+
784
+ export function saveNotification(id: string, data: unknown) {
785
+ upsertCollectionItem('notifications', id, data)
786
+ }
787
+
788
+ export function deleteNotification(id: string) {
789
+ deleteCollectionItem('notifications', id)
790
+ }
791
+
792
+ export function markNotificationRead(id: string) {
793
+ const raw = getCollectionRawCache('notifications')
794
+ const json = raw.get(id)
795
+ if (!json) return
796
+ try {
797
+ const notification = JSON.parse(json) as Record<string, unknown>
798
+ notification.read = true
799
+ upsertCollectionItem('notifications', id, notification)
800
+ } catch {
801
+ // ignore malformed
802
+ }
803
+ }
804
+
778
805
  export function getSessionMessages(sessionId: string): Message[] {
779
806
  const stmt = db.prepare('SELECT data FROM sessions WHERE id = ?')
780
807
  const row = stmt.get(sessionId) as { data: string } | undefined
package/src/proxy.ts CHANGED
@@ -1,9 +1,52 @@
1
1
  import { NextResponse } from 'next/server'
2
2
  import type { NextRequest } from 'next/server'
3
3
 
4
- /** Simple access key auth proxy.
4
+ /* ------------------------------------------------------------------ */
5
+ /* Rate-limit state — HMR-safe via globalThis */
6
+ /* ------------------------------------------------------------------ */
7
+
8
+ interface RateLimitEntry {
9
+ count: number
10
+ lockedUntil: number
11
+ }
12
+
13
+ const rateLimitMap = (
14
+ (globalThis as Record<string, unknown>).__swarmclaw_rate_limit__ ??= new Map()
15
+ ) as Map<string, RateLimitEntry>
16
+
17
+ const MAX_ATTEMPTS = 5
18
+ const LOCKOUT_MS = 15 * 60 * 1000 // 15 minutes
19
+ const PRUNE_THRESHOLD = 1000
20
+
21
+ /** Prune expired entries when the map grows too large. */
22
+ function pruneRateLimitMap() {
23
+ if (rateLimitMap.size <= PRUNE_THRESHOLD) return
24
+ const now = Date.now()
25
+ rateLimitMap.forEach((entry, ip) => {
26
+ if (entry.lockedUntil < now && entry.count < MAX_ATTEMPTS) {
27
+ rateLimitMap.delete(ip)
28
+ }
29
+ })
30
+ }
31
+
32
+ /** Extract client IP from the request. */
33
+ function getClientIp(request: NextRequest): string {
34
+ const forwarded = request.headers.get('x-forwarded-for')
35
+ if (forwarded) {
36
+ const first = forwarded.split(',')[0]?.trim()
37
+ if (first) return first
38
+ }
39
+ return (request as unknown as { ip?: string }).ip ?? 'unknown'
40
+ }
41
+
42
+ /* ------------------------------------------------------------------ */
43
+ /* Proxy */
44
+ /* ------------------------------------------------------------------ */
45
+
46
+ /** Access key auth proxy with brute-force rate limiting.
5
47
  * Checks X-Access-Key header or ?key= param on all /api/ routes except /api/auth.
6
48
  * The key is validated against the ACCESS_KEY env var.
49
+ * After 5 failed attempts from a single IP the client is locked out for 15 minutes.
7
50
  */
8
51
  export function proxy(request: NextRequest) {
9
52
  const { pathname } = request.nextUrl
@@ -29,13 +72,47 @@ export function proxy(request: NextRequest) {
29
72
  return NextResponse.next()
30
73
  }
31
74
 
75
+ // --- Rate-limit housekeeping ---
76
+ pruneRateLimitMap()
77
+
78
+ const clientIp = getClientIp(request)
79
+ const entry = rateLimitMap.get(clientIp)
80
+
81
+ // Check lockout before even validating the key
82
+ if (entry && entry.lockedUntil > Date.now()) {
83
+ const retryAfter = Math.ceil((entry.lockedUntil - Date.now()) / 1000)
84
+ return NextResponse.json(
85
+ { error: 'Too many failed attempts. Try again later.', retryAfter },
86
+ { status: 429, headers: { 'Retry-After': String(retryAfter) } },
87
+ )
88
+ }
89
+
32
90
  const providedKey =
33
91
  request.headers.get('x-access-key')
34
92
  || request.nextUrl.searchParams.get('key')
35
93
  || ''
36
94
 
37
95
  if (providedKey !== accessKey) {
38
- return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
96
+ // Record the failed attempt
97
+ const current = rateLimitMap.get(clientIp) ?? { count: 0, lockedUntil: 0 }
98
+ current.count += 1
99
+
100
+ if (current.count >= MAX_ATTEMPTS) {
101
+ current.lockedUntil = Date.now() + LOCKOUT_MS
102
+ }
103
+
104
+ rateLimitMap.set(clientIp, current)
105
+
106
+ const remaining = Math.max(0, MAX_ATTEMPTS - current.count)
107
+ return NextResponse.json(
108
+ { error: 'Unauthorized' },
109
+ { status: 401, headers: { 'X-RateLimit-Remaining': String(remaining) } },
110
+ )
111
+ }
112
+
113
+ // Successful auth — clear any prior failed-attempt tracking for this IP
114
+ if (entry) {
115
+ rateLimitMap.delete(clientIp)
39
116
  }
40
117
 
41
118
  return NextResponse.next()
@@ -1,7 +1,7 @@
1
1
  'use client'
2
2
 
3
3
  import { create } from 'zustand'
4
- import type { Sessions, Session, NetworkInfo, Directory, ProviderInfo, Credentials, Agent, Schedule, AppView, BoardTask, AppSettings, OrchestratorSecret, ProviderConfig, Skill, Connector, Webhook, McpServerConfig, PluginMeta, Project, FleetFilter, ActivityEntry } from '../types'
4
+ import type { Sessions, Session, NetworkInfo, Directory, ProviderInfo, Credentials, Agent, Schedule, AppView, BoardTask, AppSettings, OrchestratorSecret, ProviderConfig, Skill, Connector, Webhook, McpServerConfig, PluginMeta, Project, FleetFilter, ActivityEntry, AppNotification } from '../types'
5
5
  import { fetchSessions, fetchDirs, fetchProviders, fetchCredentials } from '../lib/sessions'
6
6
  import { fetchAgents } from '../lib/agents'
7
7
  import { fetchSchedules } from '../lib/schedules'
@@ -184,6 +184,14 @@ interface AppState {
184
184
  activityEntries: ActivityEntry[]
185
185
  loadActivity: (filters?: { entityType?: string; limit?: number }) => Promise<void>
186
186
 
187
+ // Notifications
188
+ notifications: AppNotification[]
189
+ unreadNotificationCount: number
190
+ loadNotifications: () => Promise<void>
191
+ markNotificationRead: (id: string) => Promise<void>
192
+ markAllNotificationsRead: () => Promise<void>
193
+ clearReadNotifications: () => Promise<void>
194
+
187
195
  }
188
196
 
189
197
  export const useAppStore = create<AppState>((set, get) => ({
@@ -591,4 +599,53 @@ export const useAppStore = create<AppState>((set, get) => ({
591
599
  }
592
600
  },
593
601
 
602
+ // Notifications
603
+ notifications: [],
604
+ unreadNotificationCount: 0,
605
+ loadNotifications: async () => {
606
+ try {
607
+ const notifications = await api<AppNotification[]>('GET', '/notifications')
608
+ set({
609
+ notifications,
610
+ unreadNotificationCount: notifications.filter((n) => !n.read).length,
611
+ })
612
+ } catch {
613
+ // ignore
614
+ }
615
+ },
616
+ markNotificationRead: async (id) => {
617
+ const notifications = get().notifications.map((n) =>
618
+ n.id === id ? { ...n, read: true } : n,
619
+ )
620
+ set({
621
+ notifications,
622
+ unreadNotificationCount: notifications.filter((n) => !n.read).length,
623
+ })
624
+ try {
625
+ await api('PUT', `/notifications/${id}`, { read: true })
626
+ } catch {
627
+ // ignore
628
+ }
629
+ },
630
+ markAllNotificationsRead: async () => {
631
+ const notifications = get().notifications.map((n) => ({ ...n, read: true }))
632
+ set({ notifications, unreadNotificationCount: 0 })
633
+ try {
634
+ await Promise.all(
635
+ get().notifications.filter((n) => !n.read).map((n) => api('PUT', `/notifications/${n.id}`, { read: true })),
636
+ )
637
+ } catch {
638
+ // ignore
639
+ }
640
+ },
641
+ clearReadNotifications: async () => {
642
+ const notifications = get().notifications.filter((n) => !n.read)
643
+ set({ notifications, unreadNotificationCount: notifications.length })
644
+ try {
645
+ await api('DELETE', '/notifications')
646
+ } catch {
647
+ // ignore
648
+ }
649
+ },
650
+
594
651
  }))
@@ -375,6 +375,19 @@ export interface Project {
375
375
  updatedAt: number
376
376
  }
377
377
 
378
+ // --- Notifications ---
379
+
380
+ export interface AppNotification {
381
+ id: string
382
+ type: 'info' | 'success' | 'warning' | 'error'
383
+ title: string
384
+ message?: string
385
+ entityType?: string
386
+ entityId?: string
387
+ read: boolean
388
+ createdAt: number
389
+ }
390
+
378
391
  // --- Session Runs ---
379
392
 
380
393
  export type SessionRunStatus = 'queued' | 'running' | 'completed' | 'failed' | 'cancelled'