bloby-bot 0.53.9 → 0.53.10

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": "bloby-bot",
3
- "version": "0.53.9",
3
+ "version": "0.53.10",
4
4
  "releaseNotes": [
5
5
  "1. New Morphy animation system: config-driven sprites loaded from /morphy/*.json",
6
6
  "2. Swapped teleporting (splash) and headphones (bubble + chat) to the new format",
@@ -197,7 +197,7 @@ function BlobyApp() {
197
197
  const reg = await navigator.serviceWorker.ready;
198
198
  const subscription = await reg.pushManager.getSubscription();
199
199
  if (subscription) {
200
- const res = await fetch(`/api/push/status?endpoint=${encodeURIComponent(subscription.endpoint)}`);
200
+ const res = await authFetch(`/api/push/status?endpoint=${encodeURIComponent(subscription.endpoint)}`);
201
201
  const data = await res.json();
202
202
  setPushState(data.subscribed ? 'subscribed' : 'unsubscribed');
203
203
  } else {
@@ -1,5 +1,6 @@
1
1
  import { useCallback, useEffect, useState, useRef } from 'react';
2
2
  import type { WsClient } from '../lib/ws-client';
3
+ import { authFetch } from '../lib/auth';
3
4
 
4
5
  export interface StoredAttachment {
5
6
  type: string;
@@ -45,7 +46,7 @@ export function useChat(ws: WsClient | null) {
45
46
  useEffect(() => {
46
47
  if (loaded.current) return;
47
48
  loaded.current = true;
48
- fetch('/api/context/current')
49
+ authFetch('/api/context/current')
49
50
  .then((r) => r.json())
50
51
  .then((data) => {
51
52
  if (data.conversationId) {
@@ -58,7 +59,7 @@ export function useChat(ws: WsClient | null) {
58
59
  // Load messages when conversationId is set
59
60
  useEffect(() => {
60
61
  if (!conversationId) return;
61
- fetch(`/api/conversations/${conversationId}`)
62
+ authFetch(`/api/conversations/${conversationId}`)
62
63
  .then((r) => {
63
64
  if (!r.ok) throw new Error('not found');
64
65
  return r.json();
@@ -105,7 +106,7 @@ export function useChat(ws: WsClient | null) {
105
106
  .catch(() => {
106
107
  // Conversation gone — clear
107
108
  setConversationId(null);
108
- fetch('/api/context/clear', { method: 'POST' }).catch(() => {});
109
+ authFetch('/api/context/clear', { method: 'POST' }).catch(() => {});
109
110
  });
110
111
  }, [conversationId]);
111
112
 
@@ -114,7 +115,7 @@ export function useChat(ws: WsClient | null) {
114
115
  useEffect(() => {
115
116
  if (conversationId && conversationId !== prevConvId.current) {
116
117
  prevConvId.current = conversationId;
117
- fetch('/api/context/set', {
118
+ authFetch('/api/context/set', {
118
119
  method: 'POST',
119
120
  headers: { 'Content-Type': 'application/json' },
120
121
  body: JSON.stringify({ conversationId }),
@@ -274,7 +275,7 @@ export function useChat(ws: WsClient | null) {
274
275
  setTools([]);
275
276
  prevConvId.current = null;
276
277
  loaded.current = false;
277
- fetch('/api/context/clear', { method: 'POST' }).catch(() => {});
278
+ authFetch('/api/context/clear', { method: 'POST' }).catch(() => {});
278
279
  }, []);
279
280
 
280
281
  return { messages, streaming, streamBuffer, conversationId, tools, sendMessage, stopStreaming, clearContext };
@@ -406,10 +406,21 @@ export async function startSupervisor() {
406
406
  // The request handler is set up later via server.on('request')
407
407
  const server = http.createServer();
408
408
 
409
- // Start Vite dev server — pass supervisor server so Vite attaches HMR WebSocket directly
409
+ // Start Vite dev server — pass supervisor server so Vite attaches HMR WebSocket directly.
410
+ // A Vite boot failure must NOT take down the supervisor (G1): chat is served independently and
411
+ // is the lifeline the user needs to ask the agent to fix things. On failure we fall back to a
412
+ // sentinel port — the dashboard proxy then serves the branded "Reconnecting" page (which polls
413
+ // and carries the chat widget) while chat, the worker API, channels, and the tunnel all still
414
+ // come up. Previously this throw reached the top-level catch → process.exit before chat listened.
410
415
  console.log('[supervisor] Starting Vite dev server...');
411
- const vitePorts = await startViteDevServers(config.port, server);
412
- console.log(`[supervisor] Vite ready — dashboard :${vitePorts.dashboard}`);
416
+ let vitePorts: { dashboard: number };
417
+ try {
418
+ vitePorts = await startViteDevServers(config.port, server);
419
+ console.log(`[supervisor] Vite ready — dashboard :${vitePorts.dashboard}`);
420
+ } catch (err) {
421
+ log.error(`Vite dev server failed to start — dashboard degraded, chat still available: ${err instanceof Error ? err.message : err}`);
422
+ vitePorts = { dashboard: -1 }; // sentinel → dashboard proxy serves RECOVERING_HTML
423
+ }
413
424
  console.log(`[supervisor] Upgrade listeners on server: ${server.listenerCount('upgrade')}`);
414
425
 
415
426
  // Ensure file storage dirs exist
@@ -504,36 +515,31 @@ export async function startSupervisor() {
504
515
  }
505
516
  }
506
517
 
507
- const AUTH_EXEMPT_ROUTES = [
508
- 'POST /api/portal/login',
509
- 'GET /api/portal/login',
510
- 'POST /api/portal/validate-token',
511
- 'GET /api/portal/validate-token',
512
- 'GET /api/onboard/status',
518
+ // SECURITY MODEL — public allowlist (secure by default). When a portal password is set, EVERY
519
+ // /api/* route requires a valid Bearer token EXCEPT the ones listed here (the pre-login surface:
520
+ // login/onboarding/health/non-secret config). A new /api route is therefore GATED by default —
521
+ // it can only leak if someone explicitly adds it here. (Previously the gate skipped ALL GET/HEAD,
522
+ // so every data read — conversations, context, wallet, devices — was readable with no token.)
523
+ // Note: /api/agent/* (agent secret) and /api/channels/* are intercepted + returned BEFORE this
524
+ // gate, so they're unaffected; the channel entries below are belt-and-suspenders.
525
+ const PUBLIC_PRELOGIN_ROUTES = [
513
526
  'GET /api/health',
514
- // NOTE: 'POST /api/onboard' is intentionally NOT blanket-exempt. It is gated in the
515
- // request handler below: open on genuine first run (no portal_pass yet), token-required
516
- // afterward. Re-onboard from the dashboard uses the internal x-internal WS path.
527
+ 'GET /api/onboard/status',
528
+ 'GET /api/settings', // secrets already stripped (worker denylist); widget + onboard read flags pre-login
517
529
  'GET /api/push/vapid-public-key',
518
- 'GET /api/push/status',
519
- 'POST /api/auth/claude/start',
520
- 'POST /api/auth/claude/exchange',
521
- 'GET /api/auth/claude/status',
522
- 'POST /api/auth/codex/start',
523
- 'POST /api/auth/codex/cancel',
524
- 'GET /api/auth/codex/status',
525
- 'GET /api/auth/pi/providers',
526
- 'GET /api/auth/pi/status',
527
- 'POST /api/auth/pi/test',
528
- 'POST /api/auth/pi/save',
529
- 'DELETE /api/auth/pi',
530
- 'POST /api/auth/pi/completion',
531
- 'POST /api/portal/totp/setup',
532
- 'POST /api/portal/totp/verify-setup',
533
- 'POST /api/portal/totp/disable',
534
- 'GET /api/portal/totp/status',
530
+ 'GET /api/portal/login',
531
+ 'POST /api/portal/login',
532
+ 'GET /api/portal/validate-token',
533
+ 'POST /api/portal/validate-token',
535
534
  'GET /api/portal/login/totp',
536
- 'POST /api/portal/devices/revoke',
535
+ 'GET /api/portal/totp/status',
536
+ 'POST /api/portal/totp/setup', // self-protected in-handler (Bearer OR password OR first-run)
537
+ 'POST /api/portal/totp/verify-setup', // self-protected in-handler
538
+ 'POST /api/portal/totp/disable', // self-protected (requires password + valid code)
539
+ 'POST /api/portal/verify-password', // verifies the password itself — cannot require a token
540
+ // NOTE: 'POST /api/onboard' is NOT public — gated below: open only on genuine first run
541
+ // (no portal_pass yet), token-required afterward. Dashboard re-onboard uses the x-internal WS path.
542
+ // Channel onboarding (also intercepted earlier; kept for completeness/safety):
537
543
  'GET /api/channels/status',
538
544
  'GET /api/channels/whatsapp/qr',
539
545
  'GET /api/channels/whatsapp/qr-page',
@@ -546,12 +552,23 @@ export async function startSupervisor() {
546
552
  'POST /api/channels/send',
547
553
  'POST /api/channels/alexa/handle',
548
554
  ];
555
+ // Method-specific public PREFIXES — onboarding namespaces with sub-paths / params that carry no
556
+ // private chat data: provider OAuth setup/status (all of /api/auth/*), and handle availability
557
+ // (GET /api/handle/* only — handle register/change are POSTs and stay gated).
558
+ const PUBLIC_PRELOGIN_PREFIXES = [
559
+ 'POST /api/auth/',
560
+ 'GET /api/auth/',
561
+ 'DELETE /api/auth/',
562
+ 'GET /api/handle/',
563
+ ];
549
564
 
550
- function isExemptRoute(method: string, url: string): boolean {
565
+ function isPublicRoute(method: string, url: string): boolean {
566
+ const m = method === 'HEAD' ? 'GET' : method; // a HEAD to a public GET route is public
551
567
  const path = url.split('?')[0];
552
- return AUTH_EXEMPT_ROUTES.some((r) => {
553
- const [m, p] = r.split(' ');
554
- return method === m && path === p;
568
+ if (PUBLIC_PRELOGIN_ROUTES.includes(`${m} ${path}`)) return true;
569
+ return PUBLIC_PRELOGIN_PREFIXES.some((r) => {
570
+ const sp = r.indexOf(' ');
571
+ return m === r.slice(0, sp) && path.startsWith(r.slice(sp + 1));
555
572
  });
556
573
  }
557
574
 
@@ -1728,9 +1745,11 @@ mint();
1728
1745
  const isInternal = req.headers['x-internal'] === internalSecret;
1729
1746
 
1730
1747
  if (!isInternal) {
1731
- // Auth check for mutation routes (POST/PUT/DELETE) GET/HEAD are read-only, skip auth
1748
+ // Require a token for EVERY /api route except the public pre-login allowlist. This now
1749
+ // covers GET data reads (conversations, context, wallet, devices, push status) that
1750
+ // previously skipped auth and leaked over the public relay.
1732
1751
  const method = req.method || 'GET';
1733
- if (method !== 'GET' && method !== 'HEAD' && !isExemptRoute(method, req.url || '')) {
1752
+ if (!isPublicRoute(method, req.url || '')) {
1734
1753
  // POST /api/onboard is open only on genuine first run (no portal_pass yet). Read the
1735
1754
  // setting DIRECTLY rather than the 30s-cached isAuthRequired() so the gate closes the
1736
1755
  // instant onboarding sets a password — no stale-cache window for a takeover. The
@@ -1848,6 +1867,14 @@ mint();
1848
1867
  return;
1849
1868
  }
1850
1869
 
1870
+ // Vite failed to boot (sentinel port) → serve the recovering page directly instead of
1871
+ // proxying to a dead port. Chat (/bloby/*) is served earlier, so the lifeline stays up.
1872
+ if (vitePorts.dashboard < 0) {
1873
+ res.writeHead(503, { 'Content-Type': 'text/html', 'Cache-Control': 'no-store, no-cache, must-revalidate' });
1874
+ res.end(RECOVERING_HTML);
1875
+ return;
1876
+ }
1877
+
1851
1878
  // Everything else → proxy to dashboard Vite dev server
1852
1879
  console.log(`[supervisor] → dashboard Vite :${vitePorts.dashboard} | ${req.method} ${(req.url || '').split('?')[0]}`);
1853
1880
  const GUARD_TAG = '<script defer src="/bloby/workspace-guard.js"></script>';
@@ -2788,14 +2815,36 @@ mint();
2788
2815
  }, 1000);
2789
2816
  }
2790
2817
 
2791
- // Watch backend/ for code changes
2792
- const backendWatcher = fs.watch(backendDir, { recursive: true }, (_event, filename) => {
2793
- if (!filename || !filename.match(/\.(ts|js|json)$/)) return;
2794
- scheduleBackendRestart(`Backend file changed: ${filename}`);
2795
- });
2818
+ // Self-healing file watchers. Two failure modes the audit flagged, both of which would hurt G3
2819
+ // (auto-heal) or G1 (chat): (a) fs.watch throws synchronously if its target is missing — at boot
2820
+ // that reached the top-level catch → process.exit before chat listened; (b) a watcher 'error'
2821
+ // event (EMFILE under load, the watched inode removed during a workspace swap) has no listener,
2822
+ // so it crashes the supervisor AND leaves a silently-dead watcher (auto-heal stops with no
2823
+ // signal). Fix: ensure the dir exists, attach an 'error' listener, and re-arm with backoff.
2824
+ let backendWatcher: fs.FSWatcher | null = null;
2825
+ let workspaceWatcher: fs.FSWatcher | null = null;
2826
+
2827
+ function armBackendWatcher() {
2828
+ try {
2829
+ fs.mkdirSync(backendDir, { recursive: true }); // fs.watch throws if the target is missing
2830
+ const w = fs.watch(backendDir, { recursive: true }, (_event, filename) => {
2831
+ if (!filename || !filename.toString().match(/\.(ts|js|json)$/)) return;
2832
+ scheduleBackendRestart(`Backend file changed: ${filename}`);
2833
+ });
2834
+ w.on('error', (err: any) => {
2835
+ log.warn(`[watcher] backend watcher error: ${err?.message || err} — re-arming in 2s`);
2836
+ try { w.close(); } catch {}
2837
+ backendWatcher = null;
2838
+ setTimeout(armBackendWatcher, 2000);
2839
+ });
2840
+ backendWatcher = w;
2841
+ } catch (err: any) {
2842
+ log.warn(`[watcher] backend watcher failed to arm: ${err?.message || err} — retry in 5s`);
2843
+ setTimeout(armBackendWatcher, 5000);
2844
+ }
2845
+ }
2796
2846
 
2797
- // Watch workspace root for .env, dependency, and .restart/.update changes
2798
- const workspaceWatcher = fs.watch(workspaceDir, (_event, filename) => {
2847
+ function onWorkspaceChange(_event: fs.WatchEventType, filename: string | Buffer | null) {
2799
2848
  if (!filename) return;
2800
2849
  if (filename === '.env') {
2801
2850
  scheduleBackendRestart('.env changed');
@@ -2832,7 +2881,26 @@ mint();
2832
2881
  runDeferredUpdate();
2833
2882
  }
2834
2883
  }
2835
- });
2884
+ }
2885
+
2886
+ function armWorkspaceWatcher() {
2887
+ try {
2888
+ const w = fs.watch(workspaceDir, onWorkspaceChange);
2889
+ w.on('error', (err: any) => {
2890
+ log.warn(`[watcher] workspace watcher error: ${err?.message || err} — re-arming in 2s`);
2891
+ try { w.close(); } catch {}
2892
+ workspaceWatcher = null;
2893
+ setTimeout(armWorkspaceWatcher, 2000);
2894
+ });
2895
+ workspaceWatcher = w;
2896
+ } catch (err: any) {
2897
+ log.warn(`[watcher] workspace watcher failed to arm: ${err?.message || err} — retry in 5s`);
2898
+ setTimeout(armWorkspaceWatcher, 5000);
2899
+ }
2900
+ }
2901
+
2902
+ armBackendWatcher();
2903
+ armWorkspaceWatcher();
2836
2904
 
2837
2905
  // Tunnel
2838
2906
  let tunnelUrl: string | null = null;
@@ -2994,8 +3062,8 @@ mint();
2994
3062
  log.info('Shutting down...');
2995
3063
  await channelManager.disconnectAll();
2996
3064
  stopScheduler();
2997
- backendWatcher.close();
2998
- workspaceWatcher.close();
3065
+ backendWatcher?.close();
3066
+ workspaceWatcher?.close();
2999
3067
  if (backendRestartTimer) clearTimeout(backendRestartTimer);
3000
3068
  if (watchdogInterval) clearInterval(watchdogInterval);
3001
3069
  stopHeartbeat();