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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fluxy-bot",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "description": "Self-hosted AI bot — run your own AI assistant from anywhere",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -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
- fetch('/api/settings')
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
- fetch('/api/settings')
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 fetch('/api/context/current').then((r) => r.json());
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 fetch(`/api/conversations/${ctx.conversationId}`);
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
- this.ws = new WebSocket(this.url);
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;
@@ -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 === '/fluxy/ws') {
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 || '');