fluxy-bot 0.3.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/supervisor/chat/fluxy-main.tsx +77 -5
- package/supervisor/chat/src/components/LoginScreen.tsx +87 -0
- package/supervisor/chat/src/hooks/useFluxyChat.ts +3 -2
- package/supervisor/chat/src/lib/auth.ts +30 -0
- package/supervisor/chat/src/lib/ws-client.ts +12 -2
- package/supervisor/index.ts +87 -3
- package/worker/db.ts +19 -0
- package/worker/index.ts +24 -1
package/package.json
CHANGED
|
@@ -4,6 +4,8 @@ import { ArrowLeft, MoreVertical, Trash2, Wand2 } from 'lucide-react';
|
|
|
4
4
|
import { WsClient } from './src/lib/ws-client';
|
|
5
5
|
import { useFluxyChat } from './src/hooks/useFluxyChat';
|
|
6
6
|
import OnboardWizard from './OnboardWizard';
|
|
7
|
+
import LoginScreen from './src/components/LoginScreen';
|
|
8
|
+
import { getAuthToken, setAuthToken, clearAuthToken, authFetch } from './src/lib/auth';
|
|
7
9
|
import MessageList from './src/components/Chat/MessageList';
|
|
8
10
|
import InputBar from './src/components/Chat/InputBar';
|
|
9
11
|
import './src/styles/globals.css';
|
|
@@ -19,10 +21,66 @@ function FluxyApp() {
|
|
|
19
21
|
const menuRef = useRef<HTMLDivElement>(null);
|
|
20
22
|
const wasConnected = useRef(false);
|
|
21
23
|
|
|
24
|
+
// Auth state
|
|
25
|
+
const [authChecked, setAuthChecked] = useState(false);
|
|
26
|
+
const [authRequired, setAuthRequired] = useState(false);
|
|
27
|
+
const [authenticated, setAuthenticated] = useState(false);
|
|
28
|
+
|
|
29
|
+
// Check auth on mount
|
|
22
30
|
useEffect(() => {
|
|
31
|
+
(async () => {
|
|
32
|
+
try {
|
|
33
|
+
const res = await fetch('/api/onboard/status');
|
|
34
|
+
const data = await res.json();
|
|
35
|
+
if (!data.portalConfigured) {
|
|
36
|
+
// No password set — skip auth entirely
|
|
37
|
+
setAuthRequired(false);
|
|
38
|
+
setAuthenticated(true);
|
|
39
|
+
setAuthChecked(true);
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
setAuthRequired(true);
|
|
44
|
+
|
|
45
|
+
// Check if we have a valid token in localStorage
|
|
46
|
+
const token = getAuthToken();
|
|
47
|
+
if (token) {
|
|
48
|
+
const vRes = await fetch('/api/portal/validate-token', {
|
|
49
|
+
method: 'POST',
|
|
50
|
+
headers: { 'Content-Type': 'application/json' },
|
|
51
|
+
body: JSON.stringify({ token }),
|
|
52
|
+
});
|
|
53
|
+
const vData = await vRes.json();
|
|
54
|
+
if (vData.valid) {
|
|
55
|
+
setAuthenticated(true);
|
|
56
|
+
setAuthChecked(true);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
clearAuthToken();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
setAuthenticated(false);
|
|
63
|
+
setAuthChecked(true);
|
|
64
|
+
} catch {
|
|
65
|
+
// Worker not ready — skip auth, let it retry later
|
|
66
|
+
setAuthenticated(true);
|
|
67
|
+
setAuthChecked(true);
|
|
68
|
+
}
|
|
69
|
+
})();
|
|
70
|
+
}, []);
|
|
71
|
+
|
|
72
|
+
const handleLogin = (token: string) => {
|
|
73
|
+
setAuthToken(token);
|
|
74
|
+
setAuthenticated(true);
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
// Connect WebSocket only when authenticated
|
|
78
|
+
useEffect(() => {
|
|
79
|
+
if (!authenticated) return;
|
|
80
|
+
|
|
23
81
|
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
24
82
|
const host = location.host;
|
|
25
|
-
const client = new WsClient(`${proto}//${host}/fluxy/ws
|
|
83
|
+
const client = new WsClient(`${proto}//${host}/fluxy/ws`, getAuthToken);
|
|
26
84
|
clientRef.current = client;
|
|
27
85
|
|
|
28
86
|
const unsub = client.onStatus((isConnected) => {
|
|
@@ -62,18 +120,19 @@ function FluxyApp() {
|
|
|
62
120
|
unsubHmr();
|
|
63
121
|
client.disconnect();
|
|
64
122
|
};
|
|
65
|
-
}, []);
|
|
123
|
+
}, [authenticated]);
|
|
66
124
|
|
|
67
125
|
// Try to load settings (will work when worker is up, fail silently when down)
|
|
68
126
|
useEffect(() => {
|
|
69
|
-
|
|
127
|
+
if (!authenticated) return;
|
|
128
|
+
authFetch('/api/settings')
|
|
70
129
|
.then((r) => r.json())
|
|
71
130
|
.then((s) => {
|
|
72
131
|
if (s.agent_name) setBotName(s.agent_name);
|
|
73
132
|
if (s.whisper_enabled === 'true') setWhisperEnabled(true);
|
|
74
133
|
})
|
|
75
134
|
.catch(() => {});
|
|
76
|
-
}, []);
|
|
135
|
+
}, [authenticated]);
|
|
77
136
|
|
|
78
137
|
// Close menu on outside click
|
|
79
138
|
useEffect(() => {
|
|
@@ -88,6 +147,19 @@ function FluxyApp() {
|
|
|
88
147
|
const { messages, streaming, streamBuffer, tools, sendMessage, stopStreaming, clearContext } =
|
|
89
148
|
useFluxyChat(clientRef.current, reloadTrigger);
|
|
90
149
|
|
|
150
|
+
// Auth gate: show spinner while checking, login screen if needed
|
|
151
|
+
if (!authChecked) {
|
|
152
|
+
return (
|
|
153
|
+
<div className="flex items-center justify-center h-dvh">
|
|
154
|
+
<div className="w-6 h-6 border-2 border-white/10 border-t-white/50 rounded-full animate-spin" />
|
|
155
|
+
</div>
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (authRequired && !authenticated) {
|
|
160
|
+
return <LoginScreen onLogin={handleLogin} />;
|
|
161
|
+
}
|
|
162
|
+
|
|
91
163
|
return (
|
|
92
164
|
<div className="flex flex-col h-dvh overflow-hidden">
|
|
93
165
|
{/* Header */}
|
|
@@ -143,7 +215,7 @@ function FluxyApp() {
|
|
|
143
215
|
onComplete={() => {
|
|
144
216
|
setShowWizard(false);
|
|
145
217
|
// Reload settings (bot name, whisper, etc.)
|
|
146
|
-
|
|
218
|
+
authFetch('/api/settings')
|
|
147
219
|
.then((r) => r.json())
|
|
148
220
|
.then((s) => {
|
|
149
221
|
if (s.agent_name) setBotName(s.agent_name);
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { useState, type KeyboardEvent } from 'react';
|
|
2
|
+
import { Lock, LoaderCircle, ArrowRight } from 'lucide-react';
|
|
3
|
+
|
|
4
|
+
interface Props {
|
|
5
|
+
onLogin: (token: string) => void;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export default function LoginScreen({ onLogin }: Props) {
|
|
9
|
+
const [password, setPassword] = useState('');
|
|
10
|
+
const [error, setError] = useState('');
|
|
11
|
+
const [loading, setLoading] = useState(false);
|
|
12
|
+
|
|
13
|
+
const handleSubmit = async () => {
|
|
14
|
+
if (!password.trim() || loading) return;
|
|
15
|
+
setLoading(true);
|
|
16
|
+
setError('');
|
|
17
|
+
|
|
18
|
+
try {
|
|
19
|
+
const res = await fetch('/api/portal/login', {
|
|
20
|
+
method: 'POST',
|
|
21
|
+
headers: { 'Content-Type': 'application/json' },
|
|
22
|
+
body: JSON.stringify({ password }),
|
|
23
|
+
});
|
|
24
|
+
const data = await res.json();
|
|
25
|
+
|
|
26
|
+
if (res.ok && data.token) {
|
|
27
|
+
onLogin(data.token);
|
|
28
|
+
} else {
|
|
29
|
+
setError(data.error || 'Invalid password');
|
|
30
|
+
}
|
|
31
|
+
} catch {
|
|
32
|
+
setError('Could not reach server');
|
|
33
|
+
} finally {
|
|
34
|
+
setLoading(false);
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const handleKeyDown = (e: KeyboardEvent) => {
|
|
39
|
+
if (e.key === 'Enter') handleSubmit();
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<div className="flex flex-col items-center justify-center h-dvh px-6">
|
|
44
|
+
<div className="w-full max-w-[320px] flex flex-col items-center">
|
|
45
|
+
<div className="w-14 h-14 rounded-2xl bg-white/[0.04] border border-white/[0.08] flex items-center justify-center mb-5">
|
|
46
|
+
<Lock className="h-6 w-6 text-white/40" />
|
|
47
|
+
</div>
|
|
48
|
+
|
|
49
|
+
<h1 className="text-xl font-bold text-white tracking-tight mb-1">
|
|
50
|
+
Welcome back
|
|
51
|
+
</h1>
|
|
52
|
+
<p className="text-white/40 text-[13px] mb-6">
|
|
53
|
+
Enter your portal password to continue.
|
|
54
|
+
</p>
|
|
55
|
+
|
|
56
|
+
{error && (
|
|
57
|
+
<div className="w-full bg-red-500/8 border border-red-500/15 rounded-xl px-4 py-2.5 mb-4">
|
|
58
|
+
<p className="text-red-400/90 text-[12px]">{error}</p>
|
|
59
|
+
</div>
|
|
60
|
+
)}
|
|
61
|
+
|
|
62
|
+
<input
|
|
63
|
+
type="password"
|
|
64
|
+
value={password}
|
|
65
|
+
onChange={(e) => setPassword(e.target.value)}
|
|
66
|
+
onKeyDown={handleKeyDown}
|
|
67
|
+
placeholder="Password"
|
|
68
|
+
autoFocus
|
|
69
|
+
autoComplete="current-password"
|
|
70
|
+
className="w-full bg-white/[0.05] border border-white/[0.08] text-white rounded-xl px-4 py-3 text-base outline-none input-glow placeholder:text-white/20 transition-all"
|
|
71
|
+
/>
|
|
72
|
+
|
|
73
|
+
<button
|
|
74
|
+
onClick={handleSubmit}
|
|
75
|
+
disabled={!password.trim() || loading}
|
|
76
|
+
className="w-full mt-4 py-3 bg-gradient-brand hover:opacity-90 text-white text-[14px] font-semibold rounded-full transition-colors flex items-center justify-center gap-2 disabled:opacity-40"
|
|
77
|
+
>
|
|
78
|
+
{loading ? (
|
|
79
|
+
<><LoaderCircle className="h-4 w-4 animate-spin" />Signing in...</>
|
|
80
|
+
) : (
|
|
81
|
+
<>Sign In<ArrowRight className="h-4 w-4" /></>
|
|
82
|
+
)}
|
|
83
|
+
</button>
|
|
84
|
+
</div>
|
|
85
|
+
</div>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
2
|
import type { WsClient } from '../lib/ws-client';
|
|
3
3
|
import type { ChatMessage, ToolActivity, Attachment, StoredAttachment } from './useChat';
|
|
4
|
+
import { authFetch } from '../lib/auth';
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Chat hook for the standalone Fluxy chat app.
|
|
@@ -18,11 +19,11 @@ export function useFluxyChat(ws: WsClient | null, triggerReload?: number) {
|
|
|
18
19
|
// Load current conversation from DB
|
|
19
20
|
const loadFromDb = useCallback(async () => {
|
|
20
21
|
try {
|
|
21
|
-
const ctx = await
|
|
22
|
+
const ctx = await authFetch('/api/context/current').then((r) => r.json());
|
|
22
23
|
if (!ctx.conversationId) return;
|
|
23
24
|
setConversationId(ctx.conversationId);
|
|
24
25
|
|
|
25
|
-
const res = await
|
|
26
|
+
const res = await authFetch(`/api/conversations/${ctx.conversationId}`);
|
|
26
27
|
if (!res.ok) return;
|
|
27
28
|
const data = await res.json();
|
|
28
29
|
if (!data.messages?.length) return;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
const TOKEN_KEY = 'fluxy_token';
|
|
2
|
+
|
|
3
|
+
export function getAuthToken(): string | null {
|
|
4
|
+
return localStorage.getItem(TOKEN_KEY);
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function setAuthToken(token: string): void {
|
|
8
|
+
localStorage.setItem(TOKEN_KEY, token);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function clearAuthToken(): void {
|
|
12
|
+
localStorage.removeItem(TOKEN_KEY);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function authFetch(url: string, options: RequestInit = {}): Promise<Response> {
|
|
16
|
+
const token = getAuthToken();
|
|
17
|
+
const headers = new Headers(options.headers);
|
|
18
|
+
if (token) {
|
|
19
|
+
headers.set('Authorization', `Bearer ${token}`);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const res = await fetch(url, { ...options, headers });
|
|
23
|
+
|
|
24
|
+
if (res.status === 401) {
|
|
25
|
+
clearAuthToken();
|
|
26
|
+
window.location.reload();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return res;
|
|
30
|
+
}
|
|
@@ -17,16 +17,26 @@ export class WsClient {
|
|
|
17
17
|
private intentionalClose = false;
|
|
18
18
|
private reconnectDelay = 1000;
|
|
19
19
|
private static MAX_RECONNECT_DELAY = 8000;
|
|
20
|
+
private tokenGetter: (() => string | null) | null = null;
|
|
20
21
|
|
|
21
|
-
constructor(url?: string) {
|
|
22
|
+
constructor(url?: string, tokenGetter?: (() => string | null) | null) {
|
|
22
23
|
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
23
24
|
const host = import.meta.env.DEV ? 'localhost:3000' : location.host;
|
|
24
25
|
this.url = url ?? `${proto}//${host}/ws`;
|
|
26
|
+
this.tokenGetter = tokenGetter ?? null;
|
|
25
27
|
}
|
|
26
28
|
|
|
27
29
|
connect(): void {
|
|
28
30
|
this.intentionalClose = false;
|
|
29
|
-
|
|
31
|
+
let wsUrl = this.url;
|
|
32
|
+
if (this.tokenGetter) {
|
|
33
|
+
const token = this.tokenGetter();
|
|
34
|
+
if (token) {
|
|
35
|
+
const sep = wsUrl.includes('?') ? '&' : '?';
|
|
36
|
+
wsUrl = `${wsUrl}${sep}token=${token}`;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
this.ws = new WebSocket(wsUrl);
|
|
30
40
|
|
|
31
41
|
this.ws.onopen = () => {
|
|
32
42
|
this.reconnectDelay = 1000;
|
package/supervisor/index.ts
CHANGED
|
@@ -89,8 +89,65 @@ export async function startSupervisor() {
|
|
|
89
89
|
}
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
+
// ── Auth middleware ──
|
|
93
|
+
const tokenCache = new Map<string, number>(); // token → expiry timestamp
|
|
94
|
+
const TOKEN_CACHE_TTL = 60_000; // 60s
|
|
95
|
+
|
|
96
|
+
async function validateToken(token: string): Promise<boolean> {
|
|
97
|
+
const cached = tokenCache.get(token);
|
|
98
|
+
if (cached && cached > Date.now()) return true;
|
|
99
|
+
|
|
100
|
+
try {
|
|
101
|
+
const res = await fetch(`http://127.0.0.1:${workerPort}/api/portal/validate-token`, {
|
|
102
|
+
method: 'POST',
|
|
103
|
+
headers: { 'Content-Type': 'application/json' },
|
|
104
|
+
body: JSON.stringify({ token }),
|
|
105
|
+
});
|
|
106
|
+
const data = await res.json() as { valid: boolean };
|
|
107
|
+
if (data.valid) {
|
|
108
|
+
tokenCache.set(token, Date.now() + TOKEN_CACHE_TTL);
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
tokenCache.delete(token);
|
|
112
|
+
return false;
|
|
113
|
+
} catch {
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
let authRequiredCache: { value: boolean; expires: number } | null = null;
|
|
119
|
+
|
|
120
|
+
async function isAuthRequired(): Promise<boolean> {
|
|
121
|
+
if (authRequiredCache && authRequiredCache.expires > Date.now()) return authRequiredCache.value;
|
|
122
|
+
try {
|
|
123
|
+
const res = await fetch(`http://127.0.0.1:${workerPort}/api/onboard/status`);
|
|
124
|
+
const data = await res.json() as { portalConfigured: boolean };
|
|
125
|
+
const required = !!data.portalConfigured;
|
|
126
|
+
authRequiredCache = { value: required, expires: Date.now() + 30_000 };
|
|
127
|
+
return required;
|
|
128
|
+
} catch {
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const AUTH_EXEMPT_ROUTES = [
|
|
134
|
+
'POST /api/portal/login',
|
|
135
|
+
'POST /api/portal/validate-token',
|
|
136
|
+
'GET /api/onboard/status',
|
|
137
|
+
'GET /api/health',
|
|
138
|
+
'POST /api/onboard',
|
|
139
|
+
];
|
|
140
|
+
|
|
141
|
+
function isExemptRoute(method: string, url: string): boolean {
|
|
142
|
+
const path = url.split('?')[0];
|
|
143
|
+
return AUTH_EXEMPT_ROUTES.some((r) => {
|
|
144
|
+
const [m, p] = r.split(' ');
|
|
145
|
+
return method === m && path === p;
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
92
149
|
// HTTP server — proxies to Vite dev servers + worker API
|
|
93
|
-
const server = http.createServer((req, res) => {
|
|
150
|
+
const server = http.createServer(async (req, res) => {
|
|
94
151
|
// Fluxy widget — served directly (not part of Vite build)
|
|
95
152
|
if (req.url === '/fluxy/widget.js') {
|
|
96
153
|
console.log('[supervisor] Serving /fluxy/widget.js directly');
|
|
@@ -136,6 +193,20 @@ export async function startSupervisor() {
|
|
|
136
193
|
return;
|
|
137
194
|
}
|
|
138
195
|
|
|
196
|
+
// Auth check for API routes
|
|
197
|
+
if (!isExemptRoute(req.method || 'GET', req.url || '')) {
|
|
198
|
+
const needsAuth = await isAuthRequired();
|
|
199
|
+
if (needsAuth) {
|
|
200
|
+
const authHeader = req.headers['authorization'];
|
|
201
|
+
const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null;
|
|
202
|
+
if (!token || !(await validateToken(token))) {
|
|
203
|
+
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
204
|
+
res.end(JSON.stringify({ error: 'Unauthorized' }));
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
139
210
|
const proxy = http.request(
|
|
140
211
|
{ host: '127.0.0.1', port: workerPort, path: req.url, method: req.method, headers: req.headers },
|
|
141
212
|
(proxyRes) => {
|
|
@@ -384,10 +455,23 @@ export async function startSupervisor() {
|
|
|
384
455
|
});
|
|
385
456
|
});
|
|
386
457
|
|
|
387
|
-
server.on('upgrade', (req, socket: net.Socket, head) => {
|
|
458
|
+
server.on('upgrade', async (req, socket: net.Socket, head) => {
|
|
388
459
|
console.log(`[supervisor] WebSocket upgrade: ${req.url}`);
|
|
389
460
|
|
|
390
|
-
if (req.url
|
|
461
|
+
if (req.url?.startsWith('/fluxy/ws')) {
|
|
462
|
+
// Auth check for WebSocket
|
|
463
|
+
const needsAuth = await isAuthRequired();
|
|
464
|
+
if (needsAuth) {
|
|
465
|
+
const urlObj = new URL(req.url, `http://${req.headers.host}`);
|
|
466
|
+
const token = urlObj.searchParams.get('token');
|
|
467
|
+
if (!token || !(await validateToken(token))) {
|
|
468
|
+
console.log('[supervisor] WS auth failed — rejecting');
|
|
469
|
+
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
|
|
470
|
+
socket.destroy();
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
391
475
|
console.log('[supervisor] → Fluxy chat WebSocket');
|
|
392
476
|
fluxyWss.handleUpgrade(req, socket, head, (ws) => fluxyWss.emit('connection', ws, req));
|
|
393
477
|
return;
|
package/worker/db.ts
CHANGED
|
@@ -27,6 +27,11 @@ CREATE TABLE IF NOT EXISTS settings (
|
|
|
27
27
|
value TEXT NOT NULL,
|
|
28
28
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
29
29
|
);
|
|
30
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
31
|
+
token TEXT PRIMARY KEY,
|
|
32
|
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
33
|
+
expires_at DATETIME NOT NULL
|
|
34
|
+
);
|
|
30
35
|
`;
|
|
31
36
|
|
|
32
37
|
let db: Database.Database;
|
|
@@ -93,6 +98,20 @@ export function getAllSettings() {
|
|
|
93
98
|
return Object.fromEntries(rows.map((r) => [r.key, r.value]));
|
|
94
99
|
}
|
|
95
100
|
|
|
101
|
+
// Auth sessions
|
|
102
|
+
export function createSession(token: string, expiresAt: string) {
|
|
103
|
+
db.prepare('INSERT INTO sessions (token, expires_at) VALUES (?, ?)').run(token, expiresAt);
|
|
104
|
+
}
|
|
105
|
+
export function getSession(token: string): { token: string; created_at: string; expires_at: string } | undefined {
|
|
106
|
+
return db.prepare('SELECT * FROM sessions WHERE token = ? AND expires_at > datetime(\'now\')').get(token) as any;
|
|
107
|
+
}
|
|
108
|
+
export function deleteSession(token: string) {
|
|
109
|
+
db.prepare('DELETE FROM sessions WHERE token = ?').run(token);
|
|
110
|
+
}
|
|
111
|
+
export function deleteExpiredSessions() {
|
|
112
|
+
db.prepare('DELETE FROM sessions WHERE expires_at <= datetime(\'now\')').run();
|
|
113
|
+
}
|
|
114
|
+
|
|
96
115
|
// Session ID (Agent SDK)
|
|
97
116
|
export function getSessionId(convId: string): string | null {
|
|
98
117
|
const row = db.prepare('SELECT session_id FROM conversations WHERE id = ?').get(convId) as any;
|
package/worker/index.ts
CHANGED
|
@@ -3,7 +3,7 @@ import crypto from 'crypto';
|
|
|
3
3
|
import { loadConfig, saveConfig } from '../shared/config.js';
|
|
4
4
|
import { paths } from '../shared/paths.js';
|
|
5
5
|
import { log } from '../shared/logger.js';
|
|
6
|
-
import { initDb, closeDb, listConversations, createConversation, deleteConversation, getMessages, addMessage, getSetting, getAllSettings, setSetting } from './db.js';
|
|
6
|
+
import { initDb, closeDb, listConversations, createConversation, deleteConversation, getMessages, addMessage, getSetting, getAllSettings, setSetting, createSession, getSession, deleteExpiredSessions } from './db.js';
|
|
7
7
|
import { startCodexOAuth, cancelCodexOAuth, getCodexAuthStatus, readCodexAccessToken } from './codex-auth.js';
|
|
8
8
|
import { startClaudeOAuth, exchangeClaudeCode, getClaudeAuthStatus, readClaudeAccessToken } from './claude-auth.js';
|
|
9
9
|
import { checkAvailability, registerHandle, releaseHandle, updateTunnelUrl, startHeartbeat, stopHeartbeat } from '../shared/relay.js';
|
|
@@ -251,6 +251,29 @@ app.post('/api/portal/verify-password', (req, res) => {
|
|
|
251
251
|
res.json({ valid: verifyPassword(password, stored) });
|
|
252
252
|
});
|
|
253
253
|
|
|
254
|
+
app.post('/api/portal/login', (req, res) => {
|
|
255
|
+
const { password } = req.body;
|
|
256
|
+
if (!password) { res.status(400).json({ error: 'Password required' }); return; }
|
|
257
|
+
const stored = getSetting('portal_pass');
|
|
258
|
+
if (!stored) { res.status(400).json({ error: 'No password set' }); return; }
|
|
259
|
+
if (!verifyPassword(password, stored)) { res.status(401).json({ error: 'Invalid password' }); return; }
|
|
260
|
+
|
|
261
|
+
// Clean up expired sessions opportunistically
|
|
262
|
+
deleteExpiredSessions();
|
|
263
|
+
|
|
264
|
+
const token = crypto.randomBytes(64).toString('hex');
|
|
265
|
+
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString();
|
|
266
|
+
createSession(token, expiresAt);
|
|
267
|
+
res.json({ token, expiresAt });
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
app.post('/api/portal/validate-token', (req, res) => {
|
|
271
|
+
const { token } = req.body;
|
|
272
|
+
if (!token) { res.json({ valid: false }); return; }
|
|
273
|
+
const session = getSession(token);
|
|
274
|
+
res.json({ valid: !!session });
|
|
275
|
+
});
|
|
276
|
+
|
|
254
277
|
app.post('/api/onboard', (req, res) => {
|
|
255
278
|
const { userName, agentName, provider, model, apiKey, baseUrl, portalUser, portalPass, whisperEnabled, whisperKey } = req.body;
|
|
256
279
|
setSetting('user_name', userName || '');
|