fluxy-bot 0.9.6 → 0.9.8
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 +2 -2
- package/supervisor/app-ws.js +200 -0
- package/supervisor/index.ts +109 -5
- package/supervisor/widget.js +6 -0
- package/worker/prompts/fluxy-system-prompt.txt +54 -0
- package/workspace/client/index.html +16 -13
- package/workspace/client/src/App.tsx +49 -62
- package/workspace/client/src/main.tsx +7 -0
package/package.json
CHANGED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
(function () {
|
|
2
|
+
if (window.__appWs) return;
|
|
3
|
+
|
|
4
|
+
var RECONNECT_BASE = 1000;
|
|
5
|
+
var RECONNECT_MAX = 8000;
|
|
6
|
+
var HEARTBEAT_MS = 25000;
|
|
7
|
+
var REQUEST_TIMEOUT = 30000;
|
|
8
|
+
|
|
9
|
+
var ws = null;
|
|
10
|
+
var connected = false;
|
|
11
|
+
var reconnectDelay = RECONNECT_BASE;
|
|
12
|
+
var reconnectTimer = null;
|
|
13
|
+
var heartbeatTimer = null;
|
|
14
|
+
var pendingRequests = {};
|
|
15
|
+
var requestCounter = 0;
|
|
16
|
+
var intentionalClose = false;
|
|
17
|
+
var originalFetch = window.fetch;
|
|
18
|
+
|
|
19
|
+
function buildWsUrl() {
|
|
20
|
+
var proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
21
|
+
return proto + '//' + location.host + '/app/ws';
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function startHeartbeat() {
|
|
25
|
+
stopHeartbeat();
|
|
26
|
+
heartbeatTimer = setInterval(function () {
|
|
27
|
+
if (ws && ws.readyState === WebSocket.OPEN) ws.send('ping');
|
|
28
|
+
}, HEARTBEAT_MS);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function stopHeartbeat() {
|
|
32
|
+
if (heartbeatTimer) {
|
|
33
|
+
clearInterval(heartbeatTimer);
|
|
34
|
+
heartbeatTimer = null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function failoverPending() {
|
|
39
|
+
var ids = Object.keys(pendingRequests);
|
|
40
|
+
for (var i = 0; i < ids.length; i++) {
|
|
41
|
+
var p = pendingRequests[ids[i]];
|
|
42
|
+
clearTimeout(p.timer);
|
|
43
|
+
console.log('[app-ws] WS dropped, falling back to fetch: ' + p.method + ' ' + p.path);
|
|
44
|
+
p.resolve(originalFetch(p.input, p.init));
|
|
45
|
+
}
|
|
46
|
+
pendingRequests = {};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function connect() {
|
|
50
|
+
intentionalClose = false;
|
|
51
|
+
var url = buildWsUrl();
|
|
52
|
+
ws = new WebSocket(url);
|
|
53
|
+
|
|
54
|
+
ws.onopen = function () {
|
|
55
|
+
connected = true;
|
|
56
|
+
reconnectDelay = RECONNECT_BASE;
|
|
57
|
+
startHeartbeat();
|
|
58
|
+
console.log('[app-ws] Connected to /app/ws');
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
ws.onmessage = function (e) {
|
|
62
|
+
if (e.data === 'pong') return;
|
|
63
|
+
|
|
64
|
+
var msg;
|
|
65
|
+
try {
|
|
66
|
+
msg = JSON.parse(e.data);
|
|
67
|
+
} catch (err) {
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (msg.type === 'app:api:response' && msg.data && msg.data.id) {
|
|
72
|
+
var pending = pendingRequests[msg.data.id];
|
|
73
|
+
if (pending) {
|
|
74
|
+
clearTimeout(pending.timer);
|
|
75
|
+
delete pendingRequests[msg.data.id];
|
|
76
|
+
|
|
77
|
+
console.log('[app-ws] Response via WS: ' + msg.data.status + ' ' + pending.method + ' ' + pending.path);
|
|
78
|
+
|
|
79
|
+
var responseBody = msg.data.body;
|
|
80
|
+
if (responseBody === null || responseBody === undefined) responseBody = '';
|
|
81
|
+
|
|
82
|
+
var responseInit = {
|
|
83
|
+
status: msg.data.status,
|
|
84
|
+
headers: msg.data.headers || {},
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
pending.resolve(new Response(responseBody, responseInit));
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
ws.onclose = function () {
|
|
93
|
+
connected = false;
|
|
94
|
+
stopHeartbeat();
|
|
95
|
+
failoverPending();
|
|
96
|
+
|
|
97
|
+
if (!intentionalClose) {
|
|
98
|
+
console.log('[app-ws] Disconnected, reconnecting in ' + reconnectDelay + 'ms');
|
|
99
|
+
reconnectTimer = setTimeout(function () {
|
|
100
|
+
reconnectDelay = Math.min(reconnectDelay * 2, RECONNECT_MAX);
|
|
101
|
+
connect();
|
|
102
|
+
}, reconnectDelay);
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
ws.onerror = function () {
|
|
107
|
+
if (ws) ws.close();
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Override window.fetch to intercept /app/api/* calls
|
|
112
|
+
window.fetch = function (input, init) {
|
|
113
|
+
var url = input instanceof Request ? input.url : String(input);
|
|
114
|
+
var method = (init && init.method) || (input instanceof Request ? input.method : 'GET');
|
|
115
|
+
|
|
116
|
+
// Only intercept /app/api/* calls
|
|
117
|
+
if (url.indexOf('/app/api') === -1) {
|
|
118
|
+
return originalFetch.apply(this, arguments);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Fallback: WS not connected
|
|
122
|
+
if (!connected || !ws || ws.readyState !== WebSocket.OPEN) {
|
|
123
|
+
console.log('[app-ws] WS not connected, falling back to fetch: ' + method + ' ' + url);
|
|
124
|
+
return originalFetch.apply(this, arguments);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Fallback: non-serializable body (FormData, Blob, ArrayBuffer)
|
|
128
|
+
var body = init && init.body;
|
|
129
|
+
if (body && (body instanceof FormData || body instanceof Blob || body instanceof ArrayBuffer)) {
|
|
130
|
+
console.log('[app-ws] Non-serializable body, falling back to fetch: ' + method + ' ' + url);
|
|
131
|
+
return originalFetch.apply(this, arguments);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Build request
|
|
135
|
+
var id = 'r' + ++requestCounter + '-' + Date.now().toString(36);
|
|
136
|
+
|
|
137
|
+
var reqPath = url;
|
|
138
|
+
try {
|
|
139
|
+
var parsed = new URL(url, location.origin);
|
|
140
|
+
reqPath = parsed.pathname + parsed.search;
|
|
141
|
+
} catch (e) {}
|
|
142
|
+
|
|
143
|
+
var headers = {};
|
|
144
|
+
if (init && init.headers) {
|
|
145
|
+
if (init.headers instanceof Headers) {
|
|
146
|
+
init.headers.forEach(function (v, k) {
|
|
147
|
+
headers[k] = v;
|
|
148
|
+
});
|
|
149
|
+
} else if (typeof init.headers === 'object') {
|
|
150
|
+
var h = init.headers;
|
|
151
|
+
for (var k in h) {
|
|
152
|
+
if (Object.prototype.hasOwnProperty.call(h, k)) headers[k] = h[k];
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
var bodyStr = null;
|
|
158
|
+
if (body !== undefined && body !== null) {
|
|
159
|
+
bodyStr = typeof body === 'string' ? body : JSON.stringify(body);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
console.log('[app-ws] Proxying via WS: ' + method + ' ' + reqPath);
|
|
163
|
+
|
|
164
|
+
ws.send(
|
|
165
|
+
JSON.stringify({
|
|
166
|
+
type: 'app:api',
|
|
167
|
+
data: { id: id, method: method.toUpperCase(), path: reqPath, headers: headers, body: bodyStr },
|
|
168
|
+
})
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
var savedInput = input;
|
|
172
|
+
var savedInit = init;
|
|
173
|
+
|
|
174
|
+
return new Promise(function (resolve, reject) {
|
|
175
|
+
var timer = setTimeout(function () {
|
|
176
|
+
delete pendingRequests[id];
|
|
177
|
+
console.log('[app-ws] Request timed out, falling back to fetch: ' + method + ' ' + reqPath);
|
|
178
|
+
resolve(originalFetch(savedInput, savedInit));
|
|
179
|
+
}, REQUEST_TIMEOUT);
|
|
180
|
+
|
|
181
|
+
pendingRequests[id] = {
|
|
182
|
+
resolve: resolve,
|
|
183
|
+
reject: reject,
|
|
184
|
+
timer: timer,
|
|
185
|
+
input: savedInput,
|
|
186
|
+
init: savedInit,
|
|
187
|
+
method: method,
|
|
188
|
+
path: reqPath,
|
|
189
|
+
};
|
|
190
|
+
});
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
window.__appWs = {
|
|
194
|
+
connected: function () {
|
|
195
|
+
return connected;
|
|
196
|
+
},
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
connect();
|
|
200
|
+
})();
|
package/supervisor/index.ts
CHANGED
|
@@ -154,11 +154,12 @@ self.addEventListener('notificationclick', function(event) {
|
|
|
154
154
|
});
|
|
155
155
|
`;
|
|
156
156
|
|
|
157
|
-
const RECOVERING_HTML = `<!DOCTYPE html><html><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>
|
|
158
|
-
<style
|
|
159
|
-
div
|
|
160
|
-
<
|
|
161
|
-
<
|
|
157
|
+
const RECOVERING_HTML = `<!DOCTYPE html><html style="background:#222122"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Fluxy</title>
|
|
158
|
+
<style>@keyframes _fs{to{transform:rotate(360deg)}}body{background:#222122;margin:0}</style></head>
|
|
159
|
+
<body><div style="background:#222122;color:#fff;display:flex;flex-direction:column;align-items:center;justify-content:center;height:100dvh;width:100vw;font-family:system-ui,-apple-system,sans-serif">
|
|
160
|
+
<img src="/fluxy-icon-192.png" width="56" height="56" style="border-radius:14px;margin-bottom:20px" alt="" />
|
|
161
|
+
<div style="width:18px;height:18px;border:2px solid rgba(255,255,255,0.12);border-top-color:rgba(255,255,255,0.7);border-radius:50%;animation:_fs .6s linear infinite"></div>
|
|
162
|
+
</div><script>setTimeout(function(){location.reload()},3000)</script>
|
|
162
163
|
<script src="/fluxy/widget.js"></script></body></html>`;
|
|
163
164
|
|
|
164
165
|
export async function startSupervisor() {
|
|
@@ -294,6 +295,14 @@ export async function startSupervisor() {
|
|
|
294
295
|
return;
|
|
295
296
|
}
|
|
296
297
|
|
|
298
|
+
// App WS client — served directly (proxies /app/api calls through WebSocket)
|
|
299
|
+
if (req.url === '/fluxy/app-ws.js') {
|
|
300
|
+
console.log('[supervisor] Serving /fluxy/app-ws.js directly');
|
|
301
|
+
res.writeHead(200, { 'Content-Type': 'application/javascript', 'Cache-Control': 'no-cache' });
|
|
302
|
+
res.end(fs.readFileSync(path.join(PKG_DIR, 'supervisor', 'app-ws.js')));
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
|
|
297
306
|
// Service worker — served from embedded constant (supervisor/ is always updated)
|
|
298
307
|
if (req.url === '/sw.js' || req.url === '/fluxy/sw.js') {
|
|
299
308
|
res.writeHead(200, { 'Content-Type': 'application/javascript', 'Cache-Control': 'no-cache' });
|
|
@@ -426,6 +435,94 @@ export async function startSupervisor() {
|
|
|
426
435
|
// WebSocket: Fluxy chat + proxy worker WS
|
|
427
436
|
const fluxyWss = new WebSocketServer({ noServer: true });
|
|
428
437
|
|
|
438
|
+
// WebSocket: App API proxy (routes /app/api calls through WS to avoid tunnel POST issues)
|
|
439
|
+
const appWss = new WebSocketServer({ noServer: true });
|
|
440
|
+
|
|
441
|
+
appWss.on('connection', (ws) => {
|
|
442
|
+
console.log('[supervisor] App API WS client connected');
|
|
443
|
+
|
|
444
|
+
ws.on('message', (raw) => {
|
|
445
|
+
const rawStr = raw.toString();
|
|
446
|
+
|
|
447
|
+
if (rawStr === 'ping') {
|
|
448
|
+
if (ws.readyState === WebSocket.OPEN) ws.send('pong');
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
let msg: any;
|
|
453
|
+
try {
|
|
454
|
+
msg = JSON.parse(rawStr);
|
|
455
|
+
} catch {
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
if (msg.type !== 'app:api' || !msg.data) return;
|
|
460
|
+
|
|
461
|
+
const { id, method, path: reqPath, headers: reqHeaders, body } = msg.data;
|
|
462
|
+
const backendPath = (reqPath || '').replace(/^\/app\/api/, '') || '/';
|
|
463
|
+
|
|
464
|
+
console.log(`[supervisor] App WS → backend :${backendPort} | ${method} ${backendPath} (${id})`);
|
|
465
|
+
|
|
466
|
+
if (!isBackendAlive()) {
|
|
467
|
+
console.log('[supervisor] App WS: Backend down — returning 503');
|
|
468
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
469
|
+
ws.send(JSON.stringify({
|
|
470
|
+
type: 'app:api:response',
|
|
471
|
+
data: { id, status: 503, headers: { 'content-type': 'application/json' }, body: JSON.stringify({ error: 'Backend is starting...' }) },
|
|
472
|
+
}));
|
|
473
|
+
}
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
const proxyHeaders: Record<string, string> = { ...(reqHeaders || {}) };
|
|
478
|
+
if (body && !proxyHeaders['content-type']) {
|
|
479
|
+
proxyHeaders['content-type'] = 'application/json';
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
const proxyReq = http.request(
|
|
483
|
+
{ host: '127.0.0.1', port: backendPort, path: backendPath, method, headers: proxyHeaders },
|
|
484
|
+
(proxyRes) => {
|
|
485
|
+
const chunks: Buffer[] = [];
|
|
486
|
+
proxyRes.on('data', (chunk: Buffer) => chunks.push(chunk));
|
|
487
|
+
proxyRes.on('end', () => {
|
|
488
|
+
const responseBody = Buffer.concat(chunks).toString('utf-8');
|
|
489
|
+
const resHeaders: Record<string, string> = {};
|
|
490
|
+
for (const [k, v] of Object.entries(proxyRes.headers)) {
|
|
491
|
+
if (typeof v === 'string') resHeaders[k] = v;
|
|
492
|
+
else if (Array.isArray(v)) resHeaders[k] = v.join(', ');
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
console.log(`[supervisor] App WS ← backend: ${proxyRes.statusCode} (${id})`);
|
|
496
|
+
|
|
497
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
498
|
+
ws.send(JSON.stringify({
|
|
499
|
+
type: 'app:api:response',
|
|
500
|
+
data: { id, status: proxyRes.statusCode, headers: resHeaders, body: responseBody },
|
|
501
|
+
}));
|
|
502
|
+
}
|
|
503
|
+
});
|
|
504
|
+
},
|
|
505
|
+
);
|
|
506
|
+
|
|
507
|
+
proxyReq.on('error', (e) => {
|
|
508
|
+
console.error(`[supervisor] App WS backend error: ${backendPath}`, e.message);
|
|
509
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
510
|
+
ws.send(JSON.stringify({
|
|
511
|
+
type: 'app:api:response',
|
|
512
|
+
data: { id, status: 503, headers: { 'content-type': 'application/json' }, body: JSON.stringify({ error: 'Backend unavailable' }) },
|
|
513
|
+
}));
|
|
514
|
+
}
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
if (body) proxyReq.write(body);
|
|
518
|
+
proxyReq.end();
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
ws.on('close', () => {
|
|
522
|
+
console.log('[supervisor] App API WS client disconnected');
|
|
523
|
+
});
|
|
524
|
+
});
|
|
525
|
+
|
|
429
526
|
/** Send a message to all connected fluxy WS clients */
|
|
430
527
|
function broadcastFluxy(type: string, data: any = {}) {
|
|
431
528
|
const msg = JSON.stringify({ type, data });
|
|
@@ -805,6 +902,13 @@ export async function startSupervisor() {
|
|
|
805
902
|
server.on('upgrade', async (req, socket: net.Socket, head) => {
|
|
806
903
|
console.log(`[supervisor] WebSocket upgrade: ${req.url} | protocol=${req.headers['sec-websocket-protocol'] || 'none'}`);
|
|
807
904
|
|
|
905
|
+
// App API WebSocket — no auth (backend handles its own auth)
|
|
906
|
+
if (req.url?.startsWith('/app/ws')) {
|
|
907
|
+
console.log('[supervisor] → App API WebSocket');
|
|
908
|
+
appWss.handleUpgrade(req, socket, head, (ws) => appWss.emit('connection', ws, req));
|
|
909
|
+
return;
|
|
910
|
+
}
|
|
911
|
+
|
|
808
912
|
if (!req.url?.startsWith('/fluxy/ws')) {
|
|
809
913
|
console.log('[supervisor] → Letting Vite handle this upgrade');
|
|
810
914
|
return;
|
package/supervisor/widget.js
CHANGED
|
@@ -139,4 +139,10 @@
|
|
|
139
139
|
})
|
|
140
140
|
.catch(function () {});
|
|
141
141
|
} catch (e) {}
|
|
142
|
+
|
|
143
|
+
// Inject app-ws.js — proxies /app/api/* fetch calls through WebSocket
|
|
144
|
+
// (works around POST request failures through Cloudflare tunnel)
|
|
145
|
+
var awsScript = document.createElement('script');
|
|
146
|
+
awsScript.src = '/fluxy/app-ws.js';
|
|
147
|
+
document.head.appendChild(awsScript);
|
|
142
148
|
})();
|
|
@@ -265,9 +265,16 @@ Browser: GET /app/api/tasks → Supervisor strips prefix → Backend receives: G
|
|
|
265
265
|
- **Backend** Express routes: register as `/tasks`, `/health` — NO `/app/api` prefix
|
|
266
266
|
- No exceptions
|
|
267
267
|
|
|
268
|
+
**Tunnel reliability:** All `/app/api/*` fetch calls from the dashboard are automatically proxied through WebSocket when available. This is transparent — just use `fetch('/app/api/...')` normally. The WebSocket proxy activates automatically and falls back to regular HTTP if unavailable. You don't need to handle this in your code.
|
|
269
|
+
|
|
268
270
|
## Build Rules
|
|
269
271
|
NEVER run `npm run build`, `vite build`, or any build commands. Vite HMR handles frontend changes automatically. The backend auto-restarts when you edit files. Never look in `dist/` or `dist-fluxy/`.
|
|
270
272
|
|
|
273
|
+
## Installing Packages
|
|
274
|
+
You can install npm packages when a feature requires one that isn't already available. Run `npm install <package>` from the **project root** (`$PKG_DIR` / the parent of `workspace/`). Do NOT create a `package.json` inside `workspace/` — all dependencies live at the root level.
|
|
275
|
+
|
|
276
|
+
Before installing, check if a suitable package is already in `node_modules/`. Prefer well-known, maintained packages. After installing, the backend picks up new imports on the next auto-restart and Vite resolves new frontend imports via HMR — no build step needed.
|
|
277
|
+
|
|
271
278
|
## Backend Lifecycle (Critical)
|
|
272
279
|
|
|
273
280
|
The supervisor manages the backend process. You don't need to manage it yourself.
|
|
@@ -318,6 +325,53 @@ When an MCP server is configured, its tools appear alongside your built-in tools
|
|
|
318
325
|
- `shared/` — shared utilities
|
|
319
326
|
- `bin/` — CLI entry point
|
|
320
327
|
|
|
328
|
+
## Workspace Security — CRITICAL: Two Password Systems
|
|
329
|
+
|
|
330
|
+
There are TWO completely separate password systems in Fluxy. Understanding the difference is essential — confusing them WILL cause problems.
|
|
331
|
+
|
|
332
|
+
### 1. Chat Password (Portal Password) — DO NOT TOUCH
|
|
333
|
+
|
|
334
|
+
- **Set during onboarding** — it is MANDATORY. Every Fluxy has one.
|
|
335
|
+
- **Protects the chat interface** (fluxy.bot/your_name) where your human talks to you.
|
|
336
|
+
- Stored as `portal_pass` in the **worker** database (scrypt-hashed). You cannot and should not access or modify this.
|
|
337
|
+
- **This is the MOST CRITICAL credential** — if compromised, an attacker gets chat access, and through you, they get terminal access, file access, and potentially root access to the entire machine.
|
|
338
|
+
- Optional 2FA (TOTP) can be layered on top for extra protection.
|
|
339
|
+
- **YOU DO NOT SET OR CHANGE THIS.** It was configured during onboarding. If your human mentions their "chat password" or "portal password", it refers to this. Never try to look it up, reset it, or modify it. If they need to change it, they re-run onboarding.
|
|
340
|
+
|
|
341
|
+
### 2. Workspace Password (Dashboard Password) — YOU CAN SET THIS
|
|
342
|
+
|
|
343
|
+
- **OPTIONAL — not set by default.**
|
|
344
|
+
- Protects the **dashboard/workspace** (the `/app/` path) where your human's mini-apps, modules, data, and tools live.
|
|
345
|
+
- **Without this password, ANYONE who knows the URL can view the entire workspace** — all pages, all data displayed in the UI.
|
|
346
|
+
- Your human sets this **through you** — when they say "add a password", "protect the workspace", "lock the dashboard", or just "set a password", they mean THIS one. They already have a chat password.
|
|
347
|
+
|
|
348
|
+
**How to set the workspace password:**
|
|
349
|
+
```
|
|
350
|
+
POST /app/api/workspace/set-password
|
|
351
|
+
Body: { "password": "the_password" }
|
|
352
|
+
```
|
|
353
|
+
|
|
354
|
+
**How to remove it:**
|
|
355
|
+
```
|
|
356
|
+
POST /app/api/workspace/remove-password
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+
**How it works under the hood:**
|
|
360
|
+
- Password is hashed (scrypt with random salt) and stored in the workspace `app.db` database (`workspace_settings` table).
|
|
361
|
+
- When someone visits the dashboard, a lock screen appears asking for the password.
|
|
362
|
+
- On correct entry, a 7-day session token is created and stored in the browser's localStorage.
|
|
363
|
+
- The session persists across page reloads until it expires or the password is changed.
|
|
364
|
+
- Changing the password invalidates all existing sessions.
|
|
365
|
+
|
|
366
|
+
### Default State — BE AWARE
|
|
367
|
+
|
|
368
|
+
The workspace is **NOT secured by default**. If your human's Fluxy is accessible via the internet (relay, Cloudflare tunnel, etc.) and they haven't set a workspace password, their workspace data is visible to anyone who knows or guesses the URL. Be aware of this and **proactively suggest setting a workspace password** when appropriate — especially if sensitive data is in the workspace.
|
|
369
|
+
|
|
370
|
+
### The Cardinal Rule
|
|
371
|
+
|
|
372
|
+
**When your human says "add a password" or "set a password" → they mean the WORKSPACE password.**
|
|
373
|
+
They already have a chat password from onboarding. Don't confuse the two. Don't go looking in the worker database for `portal_pass`. Don't tell them "you already have a password set." Set the workspace password using the route above.
|
|
374
|
+
|
|
321
375
|
## Modular Philosophy
|
|
322
376
|
When your human asks for something new, don't rebuild the app — add a module. A sidebar icon, a dashboard card, a new page. Yesterday it was a CRM, today a finance tracker, tomorrow a diet log. They all coexist. Keep it organized.
|
|
323
377
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<!DOCTYPE html>
|
|
2
|
-
<html lang="en">
|
|
2
|
+
<html lang="en" style="background:#222122">
|
|
3
3
|
<head>
|
|
4
4
|
<meta charset="UTF-8" />
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, interactive-widget=resizes-content" />
|
|
@@ -10,9 +10,21 @@
|
|
|
10
10
|
<link rel="apple-touch-icon" href="/fluxy-icon-192.png" />
|
|
11
11
|
<link rel="manifest" href="/manifest.json" />
|
|
12
12
|
<title>Fluxy</title>
|
|
13
|
+
<style>
|
|
14
|
+
@keyframes _fs{to{transform:rotate(360deg)}}
|
|
15
|
+
</style>
|
|
13
16
|
</head>
|
|
14
|
-
<body class="bg-background text-foreground">
|
|
17
|
+
<body class="bg-background text-foreground" style="background:#222122">
|
|
18
|
+
<!-- App shell splash — visible instantly, no JS needed.
|
|
19
|
+
Covers the screen during reload/restore so there's never a white flash.
|
|
20
|
+
React hides it on mount; shown again before any reload. -->
|
|
21
|
+
<div id="splash" style="background:#222122;color:#fff;display:flex;flex-direction:column;align-items:center;justify-content:center;height:100dvh;width:100vw;position:fixed;inset:0;z-index:9999;font-family:system-ui,-apple-system,sans-serif;transition:opacity .25s ease-out">
|
|
22
|
+
<img src="/fluxy-icon-192.png" width="56" height="56" style="border-radius:14px;margin-bottom:20px" alt="" />
|
|
23
|
+
<div style="width:18px;height:18px;border:2px solid rgba(255,255,255,0.12);border-top-color:rgba(255,255,255,0.7);border-radius:50%;animation:_fs .6s linear infinite"></div>
|
|
24
|
+
</div>
|
|
25
|
+
|
|
15
26
|
<div id="root"></div>
|
|
27
|
+
|
|
16
28
|
<script>
|
|
17
29
|
// Global error handler — catches errors outside React's Error Boundary
|
|
18
30
|
// (e.g., Vite compilation errors, module loading failures)
|
|
@@ -33,21 +45,12 @@
|
|
|
33
45
|
<script>
|
|
34
46
|
if('serviceWorker' in navigator){
|
|
35
47
|
navigator.serviceWorker.register('/sw.js').then(function(r){
|
|
36
|
-
console.warn('[refresh-diag] SW registered, state:', r.active?.state, 'waiting:', !!r.waiting);
|
|
37
48
|
r.update();
|
|
38
|
-
if(r.waiting){
|
|
39
|
-
console.warn('[refresh-diag] SW: found waiting worker — sending SKIP_WAITING');
|
|
40
|
-
r.waiting.postMessage({type:'SKIP_WAITING'});
|
|
41
|
-
}
|
|
49
|
+
if(r.waiting){r.waiting.postMessage({type:'SKIP_WAITING'})}
|
|
42
50
|
r.addEventListener('updatefound',function(){
|
|
43
|
-
console.warn('[refresh-diag] SW: updatefound — new version installing');
|
|
44
51
|
var w=r.installing;
|
|
45
52
|
if(w)w.addEventListener('statechange',function(){
|
|
46
|
-
|
|
47
|
-
if(w.state==='installed'&&navigator.serviceWorker.controller){
|
|
48
|
-
console.warn('[refresh-diag] SW: new version installed while controller exists — sending SKIP_WAITING');
|
|
49
|
-
w.postMessage({type:'SKIP_WAITING'});
|
|
50
|
-
}
|
|
53
|
+
if(w.state==='installed'&&navigator.serviceWorker.controller)w.postMessage({type:'SKIP_WAITING'});
|
|
51
54
|
});
|
|
52
55
|
});
|
|
53
56
|
});
|
|
@@ -27,76 +27,69 @@ export default function App() {
|
|
|
27
27
|
const [rebuildState, setRebuildState] = useState<'idle' | 'rebuilding' | 'error'>('idle');
|
|
28
28
|
const [buildError, setBuildError] = useState('');
|
|
29
29
|
|
|
30
|
-
// ──
|
|
31
|
-
//
|
|
32
|
-
//
|
|
30
|
+
// ── Seamless reload: splash screen + freeze-thaw ──────────────────
|
|
31
|
+
// Prevents the "white flash" and "delayed reload" jank that plagues PWAs.
|
|
32
|
+
//
|
|
33
|
+
// How it works:
|
|
34
|
+
// 1. Any location.reload() shows the HTML splash BEFORE reloading
|
|
35
|
+
// so the user sees: app → splash → splash → app (no white gap).
|
|
36
|
+
// 2. When returning from background (> 30s hidden), the splash shows
|
|
37
|
+
// IMMEDIATELY — before Vite's delayed reconnect-reload can fire.
|
|
38
|
+
// If no reload comes within 3s, the splash fades away.
|
|
39
|
+
// 3. Vite full-reloads trigger the splash too (same mechanism).
|
|
33
40
|
useEffect(() => {
|
|
34
|
-
const
|
|
35
|
-
|
|
41
|
+
const splash = document.getElementById('splash');
|
|
42
|
+
let hiddenAt = 0;
|
|
36
43
|
|
|
37
|
-
//
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
44
|
+
// Show the splash screen (used before reloads and on resume)
|
|
45
|
+
function showSplash() {
|
|
46
|
+
if (!splash) return;
|
|
47
|
+
splash.style.display = 'flex';
|
|
48
|
+
splash.style.opacity = '1';
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Hide the splash screen with a fade
|
|
52
|
+
function hideSplash() {
|
|
53
|
+
if (!splash || splash.style.display === 'none') return;
|
|
54
|
+
splash.style.opacity = '0';
|
|
55
|
+
splash.addEventListener('transitionend', () => { splash.style.display = 'none'; }, { once: true });
|
|
56
|
+
}
|
|
41
57
|
|
|
42
|
-
//
|
|
58
|
+
// Wrap location.reload: show splash, wait for paint, then reload.
|
|
59
|
+
// This ensures the dark splash is visible during the brief unload→load gap.
|
|
43
60
|
const origReload = location.reload.bind(location);
|
|
44
61
|
location.reload = () => {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
origReload();
|
|
62
|
+
showSplash();
|
|
63
|
+
requestAnimationFrame(() => requestAnimationFrame(() => origReload()));
|
|
48
64
|
};
|
|
49
65
|
|
|
50
|
-
//
|
|
51
|
-
if (navigator.serviceWorker) {
|
|
52
|
-
navigator.serviceWorker.addEventListener('controllerchange', () => {
|
|
53
|
-
console.warn(`${tag} SW controllerchange — new SW took control (can cause reload in some browsers)`);
|
|
54
|
-
});
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
// 4. Vite HMR events (Vite injects import.meta.hot on the client)
|
|
66
|
+
// Vite HMR: show splash before a full-reload
|
|
58
67
|
if (import.meta.hot) {
|
|
59
|
-
|
|
60
|
-
import.meta.hot.on('vite:beforeFullReload', (payload: unknown) => {
|
|
61
|
-
console.error(`${tag} ⚠ Vite HMR: full-reload triggered!`, payload);
|
|
62
|
-
console.trace();
|
|
63
|
-
});
|
|
64
|
-
import.meta.hot.on('vite:beforeUpdate', (payload: unknown) => {
|
|
65
|
-
console.warn(`${tag} Vite HMR: beforeUpdate`, payload);
|
|
66
|
-
});
|
|
67
|
-
import.meta.hot.on('vite:error', (payload: unknown) => {
|
|
68
|
-
console.error(`${tag} Vite HMR: error`, payload);
|
|
69
|
-
});
|
|
70
|
-
import.meta.hot.on('vite:ws:disconnect', () => {
|
|
71
|
-
console.warn(`${tag} Vite HMR: WebSocket disconnected`);
|
|
72
|
-
});
|
|
73
|
-
import.meta.hot.on('vite:ws:connect', () => {
|
|
74
|
-
console.warn(`${tag} Vite HMR: WebSocket reconnected (may trigger full-reload if stale)`);
|
|
75
|
-
});
|
|
68
|
+
import.meta.hot.on('vite:beforeFullReload', () => showSplash());
|
|
76
69
|
}
|
|
77
70
|
|
|
78
|
-
//
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
window.addEventListener('pageshow', onPageShow);
|
|
85
|
-
document.addEventListener('freeze', onFreeze);
|
|
86
|
-
document.addEventListener('resume', onResume);
|
|
71
|
+
// Freeze-thaw: when returning from background after > 30s,
|
|
72
|
+
// show splash proactively so the user never sees the "working app
|
|
73
|
+
// suddenly yank away" pattern. If Vite decides to full-reload,
|
|
74
|
+
// the splash is already visible. If not, we fade it away after 3s.
|
|
75
|
+
let thawTimer: ReturnType<typeof setTimeout>;
|
|
76
|
+
const BACKGROUND_THRESHOLD = 30_000; // 30 seconds
|
|
87
77
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
78
|
+
const onVisChange = () => {
|
|
79
|
+
if (document.visibilityState === 'hidden') {
|
|
80
|
+
hiddenAt = Date.now();
|
|
81
|
+
} else if (hiddenAt && Date.now() - hiddenAt > BACKGROUND_THRESHOLD) {
|
|
82
|
+
showSplash();
|
|
83
|
+
// Give Vite 3s to trigger a reload; if it doesn't, hide splash
|
|
84
|
+
clearTimeout(thawTimer);
|
|
85
|
+
thawTimer = setTimeout(hideSplash, 3_000);
|
|
86
|
+
}
|
|
91
87
|
};
|
|
92
|
-
|
|
88
|
+
document.addEventListener('visibilitychange', onVisChange);
|
|
93
89
|
|
|
94
90
|
return () => {
|
|
95
91
|
document.removeEventListener('visibilitychange', onVisChange);
|
|
96
|
-
|
|
97
|
-
document.removeEventListener('freeze', onFreeze);
|
|
98
|
-
document.removeEventListener('resume', onResume);
|
|
99
|
-
window.removeEventListener('beforeunload', onBeforeUnload);
|
|
92
|
+
clearTimeout(thawTimer);
|
|
100
93
|
};
|
|
101
94
|
}, []);
|
|
102
95
|
|
|
@@ -114,22 +107,16 @@ export default function App() {
|
|
|
114
107
|
let safetyTimer: ReturnType<typeof setTimeout>;
|
|
115
108
|
const handler = (e: MessageEvent) => {
|
|
116
109
|
if (e.data?.type === 'fluxy:rebuilding') {
|
|
117
|
-
console.warn('[refresh-diag] fluxy:rebuilding received — starting 60s safety timer');
|
|
118
110
|
setRebuildState('rebuilding');
|
|
119
111
|
setBuildError('');
|
|
120
112
|
// Safety: auto-reload after 60s in case app:rebuilt message is lost
|
|
121
113
|
clearTimeout(safetyTimer);
|
|
122
|
-
safetyTimer = setTimeout(() =>
|
|
123
|
-
console.error('[refresh-diag] ⚠ 60s safety timer fired — forcing reload');
|
|
124
|
-
location.reload();
|
|
125
|
-
}, 60_000);
|
|
114
|
+
safetyTimer = setTimeout(() => location.reload(), 60_000);
|
|
126
115
|
} else if (e.data?.type === 'fluxy:rebuilt') {
|
|
127
|
-
console.warn('[refresh-diag] fluxy:rebuilt received — reloading now');
|
|
128
116
|
clearTimeout(safetyTimer);
|
|
129
117
|
setRebuildState('idle');
|
|
130
118
|
location.reload();
|
|
131
119
|
} else if (e.data?.type === 'fluxy:build-error') {
|
|
132
|
-
console.warn('[refresh-diag] fluxy:build-error received:', e.data.error);
|
|
133
120
|
clearTimeout(safetyTimer);
|
|
134
121
|
setRebuildState('error');
|
|
135
122
|
setBuildError(e.data.error || 'Build failed');
|
|
@@ -8,3 +8,10 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
|
|
|
8
8
|
<App />
|
|
9
9
|
</React.StrictMode>,
|
|
10
10
|
);
|
|
11
|
+
|
|
12
|
+
// Fade out the HTML splash screen now that React has mounted
|
|
13
|
+
const splash = document.getElementById('splash');
|
|
14
|
+
if (splash) {
|
|
15
|
+
splash.style.opacity = '0';
|
|
16
|
+
splash.addEventListener('transitionend', () => { splash.style.display = 'none'; }, { once: true });
|
|
17
|
+
}
|