total-recall-brain 3.16.1 → 3.17.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 (49) hide show
  1. package/frontend/dist/sw.js +7 -1
  2. package/frontend/src/App.tsx +34 -23
  3. package/frontend/src/api/_base.ts +3 -3
  4. package/frontend/src/api/chat.ts +1 -1
  5. package/frontend/src/api/headscale.ts +71 -0
  6. package/frontend/src/api/index.ts +1 -0
  7. package/frontend/src/api/keys.ts +1 -0
  8. package/frontend/src/api/network.spec.ts +6 -7
  9. package/frontend/src/api/network.ts +35 -42
  10. package/frontend/src/api/notifications.ts +41 -0
  11. package/frontend/src/api/secrets.spec.ts +62 -0
  12. package/frontend/src/api/secrets.ts +53 -0
  13. package/frontend/src/api/webhooks.ts +4 -0
  14. package/frontend/src/components/VoiceInput.tsx +13 -2
  15. package/frontend/src/index.css +150 -0
  16. package/frontend/src/pages/MeshPage.css +11 -0
  17. package/frontend/src/pages/MeshPage.spec.tsx +83 -3
  18. package/frontend/src/pages/MeshPage.tsx +312 -57
  19. package/frontend/src/pages/NetworkPage.css +263 -19
  20. package/frontend/src/pages/NetworkPage.spec.tsx +34 -17
  21. package/frontend/src/pages/NetworkPage.tsx +320 -124
  22. package/frontend/src/pages/NotificationsPage.css +219 -0
  23. package/frontend/src/pages/NotificationsPage.spec.tsx +62 -0
  24. package/frontend/src/pages/NotificationsPage.tsx +318 -0
  25. package/frontend/src/pages/SecretsPage.css +141 -0
  26. package/frontend/src/pages/{ApiKeysPage.spec.tsx → SecretsPage.spec.tsx} +4 -3
  27. package/frontend/src/pages/{ApiKeysPage.tsx → SecretsPage.tsx} +351 -19
  28. package/frontend/src/pages/TasksPage.spec.tsx +1 -1
  29. package/frontend/src/pages/WebhooksPage.css +138 -0
  30. package/frontend/src/pages/WebhooksPage.spec.tsx +93 -5
  31. package/frontend/src/pages/WebhooksPage.tsx +232 -45
  32. package/package.json +1 -1
  33. package/scaffold/.agent/skills/total-recall/modules/agents/agents.yml +1 -1
  34. package/src/core/project-brain.mjs +1 -0
  35. package/src/core/provider-catalog.mjs +13 -0
  36. package/src/core/runtime.mjs +1 -1
  37. package/src/core/secrets-store.mjs +2 -0
  38. package/src/server/index.mjs +1 -1
  39. package/src/server/rest.mjs +2 -0
  40. package/src/server/route-manifest.json +56 -0
  41. package/src/server/routes/headscale.mjs +121 -0
  42. package/src/server/routes/mesh.mjs +3 -3
  43. package/src/server/routes/secrets.mjs +156 -2
  44. package/src/server/routes/system.mjs +3 -0
  45. package/src/server/routes/webhooks.mjs +136 -13
  46. package/frontend/src/pages/ModelsPage.spec.tsx +0 -65
  47. package/frontend/src/pages/ModelsPage.tsx +0 -596
  48. package/frontend/src/pages/VaultPage.spec.tsx +0 -27
  49. package/frontend/src/pages/VaultPage.tsx +0 -223
@@ -105,7 +105,13 @@ async function staleWhileRevalidate(request) {
105
105
  }
106
106
  return response;
107
107
  })
108
- .catch(() => cached);
108
+ .catch((err) => {
109
+ if (cached) return cached;
110
+ return new Response(JSON.stringify({ error: 'Network error and no cache available' }), {
111
+ status: 503,
112
+ headers: { 'Content-Type': 'application/json' }
113
+ });
114
+ });
109
115
 
110
116
  return cached || fetchPromise;
111
117
  }
@@ -9,7 +9,7 @@ import MemoryPage from './pages/MemoryPage'
9
9
  import SettingsPage from './pages/SettingsPage'
10
10
  import NetworkPage from './pages/NetworkPage'
11
11
  import TasksPage from './pages/TasksPage'
12
- import ApiKeysPage from './pages/ApiKeysPage'
12
+ import SecretsPage from './pages/SecretsPage'
13
13
  import IntegrationsPage from './pages/IntegrationsPage'
14
14
  import { MeshPage } from './pages/MeshPage'
15
15
  import { WebhooksPage } from './pages/WebhooksPage'
@@ -20,6 +20,7 @@ import HelpPage from './pages/HelpPage'
20
20
  import GraphPage from './pages/GraphPage'
21
21
  import OpenWikiPage from './pages/OpenWikiPage'
22
22
  import OnboardingPage from './pages/OnboardingPage'
23
+ import { NotificationsPage } from './pages/NotificationsPage'
23
24
  import { isOnboardingComplete } from './utils/onboarding'
24
25
  import BrandMark from './components/brand/BrandMark'
25
26
  import ContextualHelp from './components/ContextualHelp'
@@ -56,6 +57,8 @@ interface SidebarProps {
56
57
  onBrainChange: (id: string) => void
57
58
  }
58
59
 
60
+ const getNavLinkClass = ({ isActive }: { isActive: boolean }) => `nav-link ${isActive ? 'active' : ''}`;
61
+
59
62
  function Sidebar({ onLogout, health, activeBrainId, onBrainChange }: SidebarProps) {
60
63
  return (
61
64
  <aside className="sidebar">
@@ -68,40 +71,40 @@ function Sidebar({ onLogout, health, activeBrainId, onBrainChange }: SidebarProp
68
71
  <nav className="sidebar-nav">
69
72
  <div className="nav-section-label">Memory</div>
70
73
  {!isOnboardingComplete() && (
71
- <NavLink to="/onboarding" className={({ isActive }) => `nav-link ${isActive ? 'active' : ''}`} id="nav-onboarding">
74
+ <NavLink to="/onboarding" className={getNavLinkClass} id="nav-onboarding">
72
75
  <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
73
76
  <path d="M12 2L2 7l10 5 10-5-10-5z" /><path d="M2 17l10 5 10-5" /><path d="M2 12l10 5 10-5" />
74
77
  </svg>
75
78
  Setup
76
79
  </NavLink>
77
80
  )}
78
- <NavLink to="/" end className={({ isActive }) => `nav-link ${isActive ? 'active' : ''}`} id="nav-chat">
81
+ <NavLink to="/" end className={getNavLinkClass} id="nav-chat">
79
82
  <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
80
83
  <path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z" />
81
84
  </svg>
82
85
  Chat
83
86
  </NavLink>
84
- <NavLink to="/memory" className={({ isActive }) => `nav-link ${isActive ? 'active' : ''}`} id="nav-memory">
87
+ <NavLink to="/memory" className={getNavLinkClass} id="nav-memory">
85
88
  <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
86
89
  <circle cx="12" cy="12" r="10" /><path d="M12 16v-4M12 8h.01" />
87
90
  </svg>
88
91
  Vault
89
92
  </NavLink>
90
- <NavLink to="/rules" className={({ isActive }) => `nav-link ${isActive ? 'active' : ''}`} id="nav-rules">
93
+ <NavLink to="/rules" className={getNavLinkClass} id="nav-rules">
91
94
  <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
92
95
  <path d="M14 2H6a2 2 0 0 0-2 2v16c0 1.1.9 2 2 2h12a2 2 0 0 0 2-2V8l-6-6z" />
93
96
  <path d="M14 3v5h5M16 13H8M16 17H8M10 9H8" />
94
97
  </svg>
95
98
  Rules
96
99
  </NavLink>
97
- <NavLink to="/graph" className={({ isActive }) => `nav-link ${isActive ? 'active' : ''}`} id="nav-graph">
100
+ <NavLink to="/graph" className={getNavLinkClass} id="nav-graph">
98
101
  <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
99
102
  <circle cx="5" cy="6" r="2" /><circle cx="19" cy="6" r="2" /><circle cx="12" cy="18" r="2" />
100
103
  <path d="M7 7l4 9M17 7l-4 9" />
101
104
  </svg>
102
105
  Graph
103
106
  </NavLink>
104
- <NavLink to="/tasks" className={({ isActive }) => `nav-link ${isActive ? 'active' : ''}`} id="nav-tasks">
107
+ <NavLink to="/tasks" className={getNavLinkClass} id="nav-tasks">
105
108
  <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
106
109
  <path d="M9 11l3 3L22 4" /><path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11" />
107
110
  </svg>
@@ -109,20 +112,20 @@ function Sidebar({ onLogout, health, activeBrainId, onBrainChange }: SidebarProp
109
112
  </NavLink>
110
113
 
111
114
  <div className="nav-section-label">Connect</div>
112
- <NavLink to="/integrations" className={({ isActive }) => `nav-link ${isActive ? 'active' : ''}`} id="nav-integrations">
115
+ <NavLink to="/integrations" className={getNavLinkClass} id="nav-integrations">
113
116
  <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
114
117
  <path d="M10 13a5 5 0 007.54.54l3-3a5 5 0 00-7.07-7.07l-1.72 1.71" />
115
118
  <path d="M14 11a5 5 0 00-7.54-.54l-3 3a5 5 0 007.07 7.07l1.71-1.71" />
116
119
  </svg>
117
120
  IDEs
118
121
  </NavLink>
119
- <NavLink to="/skills" className={({ isActive }) => `nav-link ${isActive ? 'active' : ''}`} id="nav-skills">
122
+ <NavLink to="/skills" className={getNavLinkClass} id="nav-skills">
120
123
  <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
121
124
  <path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5" />
122
125
  </svg>
123
126
  Skills
124
127
  </NavLink>
125
- <NavLink to="/openwiki" className={({ isActive }) => `nav-link ${isActive ? 'active' : ''}`} id="nav-openwiki">
128
+ <NavLink to="/openwiki" className={getNavLinkClass} id="nav-openwiki">
126
129
  <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
127
130
  <path d="M2 3h6a4 4 0 014 4v14a3 3 0 00-3-3H2z" />
128
131
  <path d="M22 3h-6a4 4 0 00-4 4v14a3 3 0 013-3h7z" />
@@ -131,13 +134,7 @@ function Sidebar({ onLogout, health, activeBrainId, onBrainChange }: SidebarProp
131
134
  </NavLink>
132
135
 
133
136
  <div className="nav-section-label">Account</div>
134
- <NavLink to="/keys" className={({ isActive }) => `nav-link ${isActive ? 'active' : ''}`} id="nav-keys">
135
- <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
136
- <path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 11-7.778 7.778 5.5 5.5 0 017.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4" />
137
- </svg>
138
- Keys & Usage
139
- </NavLink>
140
- <NavLink to="/network" className={({ isActive }) => `nav-link ${isActive ? 'active' : ''}`} id="nav-network">
137
+ <NavLink to="/network" className={getNavLinkClass} id="nav-network">
141
138
  <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
142
139
  <rect x="2" y="2" width="20" height="8" rx="2" ry="2" />
143
140
  <rect x="2" y="14" width="20" height="8" rx="2" ry="2" />
@@ -146,25 +143,37 @@ function Sidebar({ onLogout, health, activeBrainId, onBrainChange }: SidebarProp
146
143
  </svg>
147
144
  Network
148
145
  </NavLink>
149
- <NavLink to="/mesh" className={({ isActive }) => `nav-link ${isActive ? 'active' : ''}`} id="nav-mesh">
146
+ <NavLink to="/mesh" className={getNavLinkClass} id="nav-mesh">
150
147
  <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
151
148
  <path d="M12 2L2 7l10 5 10-5-10-5z" /><path d="M2 17l10 5 10-5" /><path d="M2 12l10 5 10-5" />
152
149
  </svg>
153
150
  Mesh
154
151
  </NavLink>
155
- <NavLink to="/webhooks" className={({ isActive }) => `nav-link ${isActive ? 'active' : ''}`} id="nav-webhooks">
152
+ <NavLink to="/webhooks" className={getNavLinkClass} id="nav-webhooks">
156
153
  <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
157
154
  <path d="M22 12h-4l-3 9L9 3l-3 9H2" />
158
155
  </svg>
159
156
  Webhooks
160
157
  </NavLink>
161
- <NavLink to="/settings" className={({ isActive }) => `nav-link ${isActive ? 'active' : ''}`} id="nav-settings">
158
+ <NavLink to="/secrets" className={getNavLinkClass} id="nav-secrets">
159
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
160
+ <rect x="3" y="11" width="18" height="11" rx="2" ry="2" /><path d="M7 11V7a5 5 0 0110 0v4" />
161
+ </svg>
162
+ Secrets & Keys
163
+ </NavLink>
164
+ <NavLink to="/notifications" className={getNavLinkClass} id="nav-notifications">
165
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
166
+ <path d="M18 8A6 6 0 006 8c0 7-3 9-3 9h18s-3-2-3-9" /><path d="M13.73 21a2 2 0 01-3.46 0" />
167
+ </svg>
168
+ Notifications
169
+ </NavLink>
170
+ <NavLink to="/settings" className={getNavLinkClass} id="nav-settings">
162
171
  <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
163
172
  <circle cx="12" cy="12" r="3" /><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 012.83-2.83l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z" />
164
173
  </svg>
165
174
  Settings
166
175
  </NavLink>
167
- <NavLink to="/help" className={({ isActive }) => `nav-link ${isActive ? 'active' : ''}`} id="nav-help">
176
+ <NavLink to="/help" className={getNavLinkClass} id="nav-help">
168
177
  <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
169
178
  <circle cx="12" cy="12" r="10" />
170
179
  <path d="M9.09 9a3 3 0 015.83 1c0 2-3 3-3 3" />
@@ -173,7 +182,7 @@ function Sidebar({ onLogout, health, activeBrainId, onBrainChange }: SidebarProp
173
182
  Help
174
183
  </NavLink>
175
184
  {isOnboardingComplete() && (
176
- <NavLink to="/onboarding" className={({ isActive }) => `nav-link ${isActive ? 'active' : ''}`} id="nav-onboarding-replay" style={{ opacity: 0.75 }}>
185
+ <NavLink to="/onboarding" className={getNavLinkClass} id="nav-onboarding-replay" style={{ opacity: 0.75 }}>
177
186
  <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
178
187
  <path d="M12 2L2 7l10 5 10-5-10-5z" /><path d="M2 17l10 5 10-5" /><path d="M2 12l10 5 10-5" />
179
188
  </svg>
@@ -338,11 +347,13 @@ function MainContent({ activeBrainId, onBrainChange }: { activeBrainId: string;
338
347
  <Route path="/skills" element={<SkillsPage activeBrainId={activeBrainId} />} />
339
348
  <Route path="/openwiki" element={<OpenWikiPage activeBrainId={activeBrainId} />} />
340
349
  <Route path="/integrations" element={<IntegrationsPage activeBrainId={activeBrainId} />} />
341
- <Route path="/keys" element={<ApiKeysPage />} />
350
+ <Route path="/keys" element={<Navigate to="/secrets" replace />} />
342
351
  <Route path="/settings" element={<SettingsPage activeBrainId={activeBrainId} />} />
343
352
  <Route path="/network" element={<NetworkPage />} />
344
353
  <Route path="/mesh" element={<MeshPage />} />
345
354
  <Route path="/webhooks" element={<WebhooksPage />} />
355
+ <Route path="/secrets" element={<SecretsPage />} />
356
+ <Route path="/notifications" element={<NotificationsPage />} />
346
357
  <Route path="/help" element={<HelpPage />} />
347
358
  {/* Legacy OS-control-plane routes → core product */}
348
359
  <Route path="/vault" element={<Navigate to="/memory" replace />} />
@@ -34,13 +34,13 @@ export async function apiFetch(url: string, options: RequestInit = {}): Promise<
34
34
  return res
35
35
  }
36
36
 
37
- export async function get(path: string) {
37
+ export async function get<T = any>(path: string): Promise<T> {
38
38
  const res = await apiFetch(API_BASE + path);
39
39
  if (!res.ok) throw new Error(`GET ${path} failed: ${res.status}`);
40
40
  return res.json();
41
41
  }
42
42
 
43
- export async function post(path: string, body: any) {
43
+ export async function post<T = any>(path: string, body: any): Promise<T> {
44
44
  const res = await apiFetch(API_BASE + path, {
45
45
  method: 'POST',
46
46
  headers: { 'Content-Type': 'application/json' },
@@ -50,7 +50,7 @@ export async function post(path: string, body: any) {
50
50
  return res.json();
51
51
  }
52
52
 
53
- export async function del(path: string) {
53
+ export async function del<T = any>(path: string): Promise<T> {
54
54
  const res = await apiFetch(API_BASE + path, {
55
55
  method: 'DELETE'
56
56
  });
@@ -37,7 +37,7 @@ export async function sendChat(
37
37
  })
38
38
  if (!res.ok) throw new Error(`Chat API error: ${res.status}`)
39
39
  const data = await res.json()
40
- return data.choices?.[0]?.message?.content ?? '(empty response)'
40
+ return data.choices?.[0]?.message?.content || '(empty response)'
41
41
  }
42
42
 
43
43
  export async function fetchChatHistory(sessionId?: string): Promise<{ role: string; content: string }[]> {
@@ -0,0 +1,71 @@
1
+ import { get, post, del } from './_base';
2
+
3
+ export interface HeadscaleNode {
4
+ id: string;
5
+ name: string;
6
+ user?: string | { name: string; id?: string };
7
+ ipAddresses: string[];
8
+ createdAt: string;
9
+ lastSeen: string;
10
+ online: boolean;
11
+ clientVersion?: string;
12
+ }
13
+
14
+ export interface PreAuthKey {
15
+ id: string;
16
+ key: string;
17
+ user: string;
18
+ reusable: boolean;
19
+ ephemeral?: boolean;
20
+ expiration: string;
21
+ createdAt: string;
22
+ used: boolean;
23
+ }
24
+
25
+ export interface HeadscaleUser {
26
+ id: string;
27
+ name: string;
28
+ createdAt: string;
29
+ }
30
+
31
+ export interface HeadscaleNodesResponse {
32
+ nodes?: HeadscaleNode[];
33
+ machines?: HeadscaleNode[];
34
+ }
35
+
36
+ export interface PreAuthKeysResponse {
37
+ preAuthKeys?: PreAuthKey[];
38
+ }
39
+
40
+ export interface UsersResponse {
41
+ users?: HeadscaleUser[];
42
+ }
43
+
44
+ export async function fetchHeadscaleNodes(): Promise<HeadscaleNode[]> {
45
+ const data = await get<HeadscaleNodesResponse>('/api/headscale/node');
46
+ return data.nodes || data.machines || [];
47
+ }
48
+
49
+ export async function deleteHeadscaleNode(id: string): Promise<void> {
50
+ return del(`/api/headscale/node/${encodeURIComponent(id)}`);
51
+ }
52
+
53
+ export async function fetchPreAuthKeys(user?: string): Promise<PreAuthKey[]> {
54
+ const query = user ? `?user=${encodeURIComponent(user)}` : '';
55
+ const data = await get<PreAuthKeysResponse>(`/api/headscale/preauthkey${query}`);
56
+ return data.preAuthKeys || [];
57
+ }
58
+
59
+ export async function createPreAuthKey(params: {
60
+ user: string;
61
+ reusable: boolean;
62
+ expiration: string;
63
+ ephemeral?: boolean;
64
+ }): Promise<PreAuthKey> {
65
+ return post<PreAuthKey>('/api/headscale/preauthkey', params);
66
+ }
67
+
68
+ export async function fetchHeadscaleUsers(): Promise<HeadscaleUser[]> {
69
+ const data = await get<UsersResponse>('/api/headscale/user');
70
+ return data.users || [];
71
+ }
@@ -18,3 +18,4 @@ export * from './extension'
18
18
  export * from './sessions'
19
19
  export * from './integrations'
20
20
  export * from './config'
21
+ export * from './secrets'
@@ -143,6 +143,7 @@ export interface SecretCatalogKey {
143
143
  created_at: string | null
144
144
  updated_at: string | null
145
145
  rotated_at: string | null
146
+ headscale_url?: string | null
146
147
  usage_30d: { events: number; cost_usd: number; input_tokens: number; output_tokens: number }
147
148
  tiers: { id: string; label: string; monthly_usd?: number | null }[]
148
149
  }
@@ -13,7 +13,7 @@ describe('networkApi', () => {
13
13
  });
14
14
 
15
15
  const result = await networkApi.getStats();
16
- expect(globalThis.fetch).toHaveBeenCalledWith('/api/network/stats');
16
+ expect(globalThis.fetch).toHaveBeenCalledWith('/api/network/stats', expect.any(Object));
17
17
  expect(result.stats.total).toBe(10);
18
18
  });
19
19
 
@@ -24,7 +24,7 @@ describe('networkApi', () => {
24
24
  });
25
25
 
26
26
  const result = await networkApi.getPolicy();
27
- expect(globalThis.fetch).toHaveBeenCalledWith('/api/network/policy');
27
+ expect(globalThis.fetch).toHaveBeenCalledWith('/api/network/policy', expect.any(Object));
28
28
  expect(result.id).toBe('network-policy');
29
29
  });
30
30
 
@@ -35,11 +35,10 @@ describe('networkApi', () => {
35
35
  });
36
36
 
37
37
  await networkApi.blockDomain('evil.com');
38
- expect(globalThis.fetch).toHaveBeenCalledWith('/api/network/block', {
38
+ expect(globalThis.fetch).toHaveBeenCalledWith('/api/network/block', expect.objectContaining({
39
39
  method: 'POST',
40
- headers: { 'Content-Type': 'application/json' },
41
40
  body: JSON.stringify({ domain: 'evil.com' })
42
- });
41
+ }));
43
42
  });
44
43
 
45
44
  it('unblockDomain DELETEs to /api/network/block/:domain', async () => {
@@ -49,8 +48,8 @@ describe('networkApi', () => {
49
48
  });
50
49
 
51
50
  await networkApi.unblockDomain('evil.com');
52
- expect(globalThis.fetch).toHaveBeenCalledWith('/api/network/block/evil.com', {
51
+ expect(globalThis.fetch).toHaveBeenCalledWith('/api/network/block/evil.com', expect.objectContaining({
53
52
  method: 'DELETE'
54
- });
53
+ }));
55
54
  });
56
55
  });
@@ -1,3 +1,5 @@
1
+ import { get, post, del, apiFetch, API_BASE } from './_base';
2
+
1
3
  export interface NetworkStats {
2
4
  total: number;
3
5
  blocked: number;
@@ -41,51 +43,42 @@ export interface AuditLogEntry {
41
43
  error?: string;
42
44
  }
43
45
 
44
- export const networkApi = {
45
- getStats: async (): Promise<{ stats: NetworkStats, audit_count: number }> => {
46
- const res = await fetch('/api/network/stats');
47
- if (!res.ok) throw new Error('Failed to fetch network stats');
48
- return res.json();
49
- },
46
+ export async function getNetworkStats(): Promise<{ stats: NetworkStats, audit_count: number }> {
47
+ return get('/api/network/stats');
48
+ }
49
+
50
+ export async function getNetworkPolicy(): Promise<NetworkPolicy> {
51
+ return get('/api/network/policy');
52
+ }
50
53
 
51
- getPolicy: async (): Promise<NetworkPolicy> => {
52
- const res = await fetch('/api/network/policy');
53
- if (!res.ok) throw new Error('Failed to fetch network policy');
54
- return res.json();
55
- },
54
+ export async function updateNetworkPolicy(patch: Partial<NetworkPolicy>): Promise<any> {
55
+ const res = await apiFetch(API_BASE + '/api/network/policy', {
56
+ method: 'PUT',
57
+ headers: { 'Content-Type': 'application/json' },
58
+ body: JSON.stringify(patch),
59
+ });
60
+ if (!res.ok) throw new Error(`PUT /api/network/policy failed: ${res.status}`);
61
+ return res.json();
62
+ }
56
63
 
57
- updatePolicy: async (patch: Partial<NetworkPolicy>): Promise<any> => {
58
- const res = await fetch('/api/network/policy', {
59
- method: 'PUT',
60
- headers: { 'Content-Type': 'application/json' },
61
- body: JSON.stringify(patch)
62
- });
63
- if (!res.ok) throw new Error('Failed to update network policy');
64
- return res.json();
65
- },
64
+ export async function blockDomain(domain: string): Promise<any> {
65
+ return post('/api/network/block', { domain });
66
+ }
66
67
 
67
- blockDomain: async (domain: string): Promise<any> => {
68
- const res = await fetch('/api/network/block', {
69
- method: 'POST',
70
- headers: { 'Content-Type': 'application/json' },
71
- body: JSON.stringify({ domain })
72
- });
73
- if (!res.ok) throw new Error('Failed to block domain');
74
- return res.json();
75
- },
68
+ export async function unblockDomain(domain: string): Promise<any> {
69
+ return del(`/api/network/block/${encodeURIComponent(domain)}`);
70
+ }
76
71
 
77
- unblockDomain: async (domain: string): Promise<any> => {
78
- const res = await fetch(`/api/network/block/${encodeURIComponent(domain)}`, {
79
- method: 'DELETE'
80
- });
81
- if (!res.ok) throw new Error('Failed to unblock domain');
82
- return res.json();
83
- },
72
+ export async function getAuditLog(params?: { domain?: string; status?: string; since?: string }): Promise<{ audit: AuditLogEntry[] }> {
73
+ const query = params ? '?' + new URLSearchParams(params as any).toString() : '';
74
+ return get(`/api/network/audit${query}`);
75
+ }
84
76
 
85
- getAuditLog: async (params?: { domain?: string; status?: string; since?: string }): Promise<{ audit: AuditLogEntry[] }> => {
86
- const query = new URLSearchParams(params as any).toString();
87
- const res = await fetch(`/api/network/audit?${query}`);
88
- if (!res.ok) throw new Error('Failed to fetch audit log');
89
- return res.json();
90
- }
77
+ export const networkApi = {
78
+ getStats: getNetworkStats,
79
+ getPolicy: getNetworkPolicy,
80
+ updateNetworkPolicy,
81
+ blockDomain,
82
+ unblockDomain,
83
+ getAuditLog,
91
84
  };
@@ -0,0 +1,41 @@
1
+ import { get, post, del } from './_base';
2
+
3
+ export interface NotificationRule {
4
+ id: string;
5
+ event: string;
6
+ channel: 'desktop' | 'webhook' | 'email';
7
+ priority: 'critical' | 'high' | 'low';
8
+ enabled: boolean;
9
+ quietHours: boolean;
10
+ }
11
+
12
+ export interface NotificationEntry {
13
+ id: string;
14
+ title: string;
15
+ message: string;
16
+ channel: string;
17
+ status: 'delivered' | 'failed';
18
+ timestamp: string;
19
+ }
20
+
21
+ export async function listNotificationRules(): Promise<NotificationRule[]> {
22
+ return get('/api/notifications/rules');
23
+ }
24
+
25
+ export async function createNotificationRule(
26
+ rule: Omit<NotificationRule, 'id'>,
27
+ ): Promise<NotificationRule> {
28
+ return post('/api/notifications/rules', rule);
29
+ }
30
+
31
+ export async function deleteNotificationRule(id: string): Promise<void> {
32
+ return del(`/api/notifications/rules/${encodeURIComponent(id)}`);
33
+ }
34
+
35
+ export async function getNotificationHistory(): Promise<NotificationEntry[]> {
36
+ return get('/api/notifications/history');
37
+ }
38
+
39
+ export async function sendTestNotification(): Promise<void> {
40
+ return post('/api/notifications/test', {});
41
+ }
@@ -0,0 +1,62 @@
1
+ // @vitest-environment jsdom
2
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
3
+ import { listSecrets, addSecret, triggerSync, getSyncStatus } from './secrets';
4
+
5
+ const mockFetch = vi.fn();
6
+ vi.stubGlobal('fetch', mockFetch);
7
+
8
+ describe('Secrets API client', () => {
9
+ beforeEach(() => {
10
+ vi.clearAllMocks();
11
+ });
12
+
13
+ it('listSecrets fetches secrets list metadata', async () => {
14
+ mockFetch.mockResolvedValue({
15
+ ok: true,
16
+ json: async () => ({ keys: [{ key: 'KEY_1', provider: 'openai' }] })
17
+ });
18
+
19
+ const res = await listSecrets();
20
+ expect(res).toEqual([{ key: 'KEY_1', provider: 'openai' }]);
21
+ expect(mockFetch).toHaveBeenCalledWith('/api/secrets/list', expect.any(Object));
22
+ });
23
+
24
+ it('addSecret sends POST to /api/secrets', async () => {
25
+ mockFetch.mockResolvedValue({
26
+ ok: true,
27
+ json: async () => ({ key: 'NEW_KEY', provider: 'stripe' })
28
+ });
29
+
30
+ const res = await addSecret('NEW_KEY', 'val_123', { provider: 'stripe' });
31
+ expect(res).toEqual({ key: 'NEW_KEY', provider: 'stripe' });
32
+ expect(mockFetch).toHaveBeenCalledWith('/api/secrets', expect.objectContaining({
33
+ method: 'POST',
34
+ body: JSON.stringify({ key: 'NEW_KEY', value: 'val_123', provider: 'stripe' })
35
+ }));
36
+ });
37
+
38
+ it('triggerSync sends POST to /api/secrets/sync/trigger', async () => {
39
+ mockFetch.mockResolvedValue({
40
+ ok: true,
41
+ json: async () => ({ success: true, results: [] })
42
+ });
43
+
44
+ const res = await triggerSync();
45
+ expect(res).toEqual({ success: true, results: [] });
46
+ expect(mockFetch).toHaveBeenCalledWith('/api/secrets/sync/trigger', expect.objectContaining({
47
+ method: 'POST',
48
+ body: '{}'
49
+ }));
50
+ });
51
+
52
+ it('getSyncStatus sends GET to /api/secrets/sync/status', async () => {
53
+ mockFetch.mockResolvedValue({
54
+ ok: true,
55
+ json: async () => ({ localChecksum: 'h1', nodes: [] })
56
+ });
57
+
58
+ const res = await getSyncStatus();
59
+ expect(res).toEqual({ localChecksum: 'h1', nodes: [] });
60
+ expect(mockFetch).toHaveBeenCalledWith('/api/secrets/sync/status', expect.any(Object));
61
+ });
62
+ });
@@ -0,0 +1,53 @@
1
+ import { get, post } from './_base';
2
+ import type { SecretCatalogKey } from './keys';
3
+
4
+ export interface SyncNodeStatus {
5
+ hostname: string;
6
+ ip: string;
7
+ status: 'synced' | 'out_of_sync' | 'unreachable';
8
+ checksum: string | null;
9
+ }
10
+
11
+ export interface SyncStatusResponse {
12
+ localChecksum: string;
13
+ nodes: SyncNodeStatus[];
14
+ }
15
+
16
+ export interface TriggerSyncResponse {
17
+ success: boolean;
18
+ results: {
19
+ hostname: string;
20
+ ip: string;
21
+ success: boolean;
22
+ error?: string;
23
+ }[];
24
+ }
25
+
26
+ export type SecretEntry = SecretCatalogKey;
27
+
28
+ export async function listSecrets(): Promise<SecretEntry[]> {
29
+ const data = (await get('/api/secrets/list')) as { keys: SecretEntry[] };
30
+ return data.keys;
31
+ }
32
+
33
+ export async function addSecret(key: string, value: string, payload: Record<string, unknown>): Promise<SecretEntry> {
34
+ return (await post('/api/secrets', { key, value, ...payload })) as SecretEntry;
35
+ }
36
+
37
+ export async function editSecret(key: string, patch: Record<string, unknown>): Promise<SecretEntry> {
38
+ const { updateSecretMeta } = await import('./keys');
39
+ return updateSecretMeta(key, patch);
40
+ }
41
+
42
+ export async function deleteSecret(key: string): Promise<void> {
43
+ const { deleteProviderSecret } = await import('./keys');
44
+ return deleteProviderSecret(key);
45
+ }
46
+
47
+ export async function triggerSync(): Promise<TriggerSyncResponse> {
48
+ return (await post('/api/secrets/sync/trigger', {})) as TriggerSyncResponse;
49
+ }
50
+
51
+ export async function getSyncStatus(): Promise<SyncStatusResponse> {
52
+ return (await get('/api/secrets/sync/status')) as SyncStatusResponse;
53
+ }
@@ -3,6 +3,9 @@ import { get, post, del } from './_base';
3
3
  export interface WebhookConfig {
4
4
  provider: string;
5
5
  status: 'active' | 'inactive';
6
+ secret?: string;
7
+ endpoint_url?: string;
8
+ events?: string[];
6
9
  lastReceived?: string;
7
10
  totalCount?: number;
8
11
  }
@@ -12,6 +15,7 @@ export interface WebhookEvent {
12
15
  provider: string;
13
16
  event_type: string;
14
17
  received_at: string;
18
+ payload?: any;
15
19
  }
16
20
 
17
21
  export async function fetchWebhookConfigs(): Promise<WebhookConfig[]> {