diff --git a/app/src/components/ServerTab/CloudSection.tsx b/app/src/components/ServerTab/CloudSection.tsx
new file mode 100644
index 00000000..bbede3f4
--- /dev/null
+++ b/app/src/components/ServerTab/CloudSection.tsx
@@ -0,0 +1,131 @@
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import { Cloud, Loader2 } from 'lucide-react';
+import { useEffect, useState } from 'react';
+import { Button } from '@/components/ui/button';
+import { useToast } from '@/components/ui/use-toast';
+import { apiClient } from '@/lib/api/client';
+import { SettingRow, SettingSection } from './SettingRow';
+
+// "Log in with browser" device pairing. The backend opens the system browser
+// and completes the code exchange; here we just kick it off and poll status
+// until the link goes live. The API key never touches the frontend.
+export function CloudSection() {
+ const { toast } = useToast();
+ const queryClient = useQueryClient();
+ const [polling, setPolling] = useState(false);
+
+ const { data: status } = useQuery({
+ queryKey: ['cloud-status'],
+ queryFn: () => apiClient.getCloudStatus(),
+ refetchInterval: polling ? 2000 : false,
+ });
+
+ const connected = status?.connected ?? false;
+
+ // Once the browser flow completes, stop polling and celebrate.
+ useEffect(() => {
+ if (connected && polling) {
+ setPolling(false);
+ toast({
+ title: 'Connected to Voicebox Cloud',
+ description: `Linked as ${status?.device_name ?? 'this device'}.`,
+ });
+ }
+ }, [connected, polling, status?.device_name, toast]);
+
+ const startLogin = useMutation({
+ mutationFn: () => apiClient.startCloudLogin(),
+ onSuccess: () => {
+ setPolling(true);
+ toast({
+ title: 'Continue in your browser',
+ description: 'Authorize this device, then return here.',
+ });
+ },
+ onError: (error: Error) =>
+ toast({
+ title: 'Could not start sign-in',
+ description: error.message,
+ variant: 'destructive',
+ }),
+ });
+
+ const disconnect = useMutation({
+ mutationFn: () => apiClient.disconnectCloud(),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['cloud-status'] });
+ toast({
+ title: 'Disconnected',
+ description: 'This device is no longer linked. The key stays valid until revoked in your account.',
+ });
+ },
+ onError: (error: Error) =>
+ toast({ title: 'Could not disconnect', description: error.message, variant: 'destructive' }),
+ });
+
+ const busy = startLogin.isPending || polling;
+
+ return (
+
+ disconnect.mutate()}
+ size="sm"
+ variant="outline"
+ >
+ {disconnect.isPending ? (
+
+ ) : (
+ 'Disconnect'
+ )}
+
+ ) : (
+
+ )
+ }
+ />
+
+ {connected && (
+
+
+ Open account dashboard ↗
+
+
+ )}
+
+ );
+}
diff --git a/app/src/components/ServerTab/GeneralPage.tsx b/app/src/components/ServerTab/GeneralPage.tsx
index 70d35090..0d8d7af7 100644
--- a/app/src/components/ServerTab/GeneralPage.tsx
+++ b/app/src/components/ServerTab/GeneralPage.tsx
@@ -14,6 +14,7 @@ import { useAutoUpdater } from '@/hooks/useAutoUpdater';
import { useServerHealth } from '@/lib/hooks/useServer';
import { usePlatform } from '@/platform/PlatformContext';
import { useServerStore } from '@/stores/serverStore';
+import { CloudSection } from './CloudSection';
import { LanguageSelect } from './LanguageSelect';
import { SettingRow, SettingSection } from './SettingRow';
import { ThemeSelect } from './ThemeSelect';
@@ -207,6 +208,8 @@ export function GeneralPage() {
/>
+
+
{platform.metadata.isTauri && }
diff --git a/app/src/lib/api/client.ts b/app/src/lib/api/client.ts
index a8d030af..8090a0cd 100644
--- a/app/src/lib/api/client.ts
+++ b/app/src/lib/api/client.ts
@@ -50,6 +50,8 @@ import type {
MCPClientBinding,
MCPClientBindingListResponse,
MCPClientBindingUpsert,
+ CloudLoginStartResponse,
+ CloudStatus,
} from './types';
function formatErrorDetail(detail: unknown, fallback: string): string {
@@ -920,6 +922,21 @@ class ApiClient {
return response.blob();
}
+
+ // Cloud (backup & sync) — browser-based device login. startCloudLogin opens
+ // the system browser server-side; the UI then polls getCloudStatus until the
+ // backend completes the exchange and the link goes live.
+ async getCloudStatus(): Promise {
+ return this.request('/cloud/status');
+ }
+
+ async startCloudLogin(): Promise {
+ return this.request('/cloud/login/start', { method: 'POST' });
+ }
+
+ async disconnectCloud(): Promise {
+ return this.request('/cloud/disconnect', { method: 'POST' });
+ }
}
export const apiClient = new ApiClient();
diff --git a/app/src/lib/api/types.ts b/app/src/lib/api/types.ts
index 37ca4667..9a2c005f 100644
--- a/app/src/lib/api/types.ts
+++ b/app/src/lib/api/types.ts
@@ -521,3 +521,17 @@ export interface MCPClientBindingUpsert {
export interface MCPClientBindingListResponse {
items: MCPClientBinding[];
}
+
+/* ─── Cloud (backup & sync) ───────────────────────────────────────────── */
+
+export interface CloudLoginStartResponse {
+ authorize_url: string;
+}
+
+export interface CloudStatus {
+ connected: boolean;
+ device_name: string | null;
+ account_user_id: string | null;
+ key_prefix: string | null;
+ connected_at: string | null;
+}
diff --git a/backend/config.py b/backend/config.py
index 1929edbd..8d2df51a 100644
--- a/backend/config.py
+++ b/backend/config.py
@@ -138,3 +138,17 @@ def get_models_dir() -> Path:
path = _data_dir / "models"
path.mkdir(parents=True, exist_ok=True)
return path
+
+
+# Voicebox Cloud (backup & sync). Two hosts: the web app owns auth + device
+# pairing (voicebox.sh), the API owns sync + account endpoints
+# (api.voicebox.sh). Override both for local development, e.g.
+# VOICEBOX_CLOUD_URL=http://localhost:17592 VOICEBOX_CLOUD_API_URL=http://localhost:17593
+def get_cloud_web_url() -> str:
+ """Base URL of the Voicebox Cloud web app (auth + /connect + exchange)."""
+ return os.environ.get("VOICEBOX_CLOUD_URL", "https://voicebox.sh").rstrip("/")
+
+
+def get_cloud_api_url() -> str:
+ """Base URL of the Voicebox Cloud API (bearer-authenticated sync/account)."""
+ return os.environ.get("VOICEBOX_CLOUD_API_URL", "https://api.voicebox.sh").rstrip("/")
diff --git a/backend/database/__init__.py b/backend/database/__init__.py
index bfb4b124..fd1252bf 100644
--- a/backend/database/__init__.py
+++ b/backend/database/__init__.py
@@ -11,6 +11,7 @@ from .models import (
Capture,
CaptureSettings,
ChannelDeviceMapping,
+ CloudSettings,
EffectPreset,
Generation,
GenerationSettings,
@@ -32,6 +33,7 @@ __all__ = [
"Capture",
"CaptureSettings",
"ChannelDeviceMapping",
+ "CloudSettings",
"EffectPreset",
"Generation",
"GenerationSettings",
diff --git a/backend/database/models.py b/backend/database/models.py
index 6ef2213e..b85a55b1 100644
--- a/backend/database/models.py
+++ b/backend/database/models.py
@@ -234,6 +234,28 @@ class GenerationSettings(Base):
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
+class CloudSettings(Base):
+ """Singleton row holding the link to a Voicebox Cloud account.
+
+ Populated by the "Log in with browser" pairing flow (see services/cloud.py):
+ the browser hands back a one-time code, which the backend exchanges for an
+ ``api_key`` it stores here. The key is a bearer credential for
+ api.voicebox.sh — auth only, never an encryption key (E2E key material lives
+ elsewhere). Stored in the local app database alongside the user's other data;
+ moving it to the OS keychain is a future hardening step. The ``id`` is
+ always 1; a null ``api_key`` means "not connected".
+ """
+
+ __tablename__ = "cloud_settings"
+
+ id = Column(Integer, primary_key=True, default=1)
+ api_key = Column(String, nullable=True)
+ device_name = Column(String, nullable=True)
+ account_user_id = Column(String, nullable=True)
+ connected_at = Column(DateTime, nullable=True)
+ updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
+
+
class MCPClientBinding(Base):
"""Per-MCP-client settings (voice profile, engine, personality default).
diff --git a/backend/models.py b/backend/models.py
index 06f321ac..4f608f40 100644
--- a/backend/models.py
+++ b/backend/models.py
@@ -793,3 +793,23 @@ class AvailableEffectsResponse(BaseModel):
"""Response listing all available effect types."""
effects: List[AvailableEffect]
+
+
+# ─── Cloud (backup & sync) ──────────────────────────────────────────────
+
+
+class CloudLoginStartResponse(BaseModel):
+ """Returned when the desktop kicks off browser login. The backend has
+ already opened the browser; the URL is included for fallback/debugging."""
+
+ authorize_url: str
+
+
+class CloudStatusResponse(BaseModel):
+ """Current link between this device and a Voicebox Cloud account."""
+
+ connected: bool
+ device_name: Optional[str] = None
+ account_user_id: Optional[str] = None
+ key_prefix: Optional[str] = None
+ connected_at: Optional[datetime] = None
diff --git a/backend/routes/__init__.py b/backend/routes/__init__.py
index 35563aaa..ac6b015b 100644
--- a/backend/routes/__init__.py
+++ b/backend/routes/__init__.py
@@ -23,6 +23,7 @@ def register_routers(app: FastAPI) -> None:
from .speak import router as speak_router
from .mcp_bindings import router as mcp_bindings_router
from .events import router as events_router
+ from .cloud import router as cloud_router
app.include_router(health_router)
app.include_router(profiles_router)
@@ -42,3 +43,4 @@ def register_routers(app: FastAPI) -> None:
app.include_router(speak_router)
app.include_router(mcp_bindings_router)
app.include_router(events_router)
+ app.include_router(cloud_router)
diff --git a/backend/routes/cloud.py b/backend/routes/cloud.py
new file mode 100644
index 00000000..3abf2ef5
--- /dev/null
+++ b/backend/routes/cloud.py
@@ -0,0 +1,75 @@
+"""Voicebox Cloud device login routes.
+
+The browser-based pairing flow:
+ 1. POST /cloud/login/start — opens the browser to the cloud authorize page.
+ 2. GET /cloud/callback — the browser lands here with a one-time code;
+ the backend exchanges it for an API key.
+ 3. GET /cloud/status — the UI polls this to learn when it connected.
+ 4. POST /cloud/disconnect — forget the local credential.
+"""
+
+import socket
+
+from fastapi import APIRouter, Depends, Request
+from fastapi.responses import HTMLResponse
+from sqlalchemy.orm import Session
+
+from .. import models
+from ..database import get_db
+from ..services import cloud as cloud_service
+
+router = APIRouter(prefix="/cloud", tags=["cloud"])
+
+
+def _callback_url(request: Request) -> str:
+ # Always loopback — the cloud only redirects codes to 127.0.0.1/localhost.
+ port = request.url.port or 17493
+ return f"http://127.0.0.1:{port}/cloud/callback"
+
+
+@router.post("/login/start", response_model=models.CloudLoginStartResponse)
+async def start_cloud_login(request: Request):
+ device_name = socket.gethostname() or "Desktop"
+ authorize_url = cloud_service.start_login(_callback_url(request), device_name)
+ return models.CloudLoginStartResponse(authorize_url=authorize_url)
+
+
+@router.get("/callback", response_class=HTMLResponse)
+async def cloud_callback(
+ request: Request,
+ code: str = "",
+ state: str = "",
+ db: Session = Depends(get_db),
+):
+ ok, message = await cloud_service.handle_callback(db, code=code, state=state)
+ heading = "You're connected" if ok else "Couldn't connect"
+ accent = "#16a34a" if ok else "#dc2626"
+ sub = (
+ "Voicebox is now linked to your account. You can close this tab and return to the app."
+ if ok
+ else message
+ )
+ html = f"""
+
+
+Voicebox Cloud
+
+"""
+ return HTMLResponse(content=html, status_code=200 if ok else 400)
+
+
+@router.get("/status", response_model=models.CloudStatusResponse)
+async def cloud_status(db: Session = Depends(get_db)):
+ return models.CloudStatusResponse(**cloud_service.get_status(db))
+
+
+@router.post("/disconnect", response_model=models.CloudStatusResponse)
+async def cloud_disconnect(db: Session = Depends(get_db)):
+ cloud_service.disconnect(db)
+ return models.CloudStatusResponse(**cloud_service.get_status(db))
diff --git a/backend/services/cloud.py b/backend/services/cloud.py
new file mode 100644
index 00000000..1b3983b4
--- /dev/null
+++ b/backend/services/cloud.py
@@ -0,0 +1,160 @@
+"""
+Voicebox Cloud device login — the "Log in with browser" flow.
+
+The desktop opens the browser to ``{web}/connect``; the user authorizes while
+signed in; the cloud redirects a single-use code back to this backend's loopback
+callback. We exchange that code (server-to-server, over TLS) for a ``voicebox_…``
+API key, verify the key against the API, and store it locally. The key never
+travels through a browser URL, and an unfinished flow leaves nothing behind.
+
+The ``state`` we mint and round-trip prevents login-CSRF: a callback whose state
+we didn't issue (e.g. an attacker tricking the user into hitting the loopback
+callback with their own code) is rejected.
+"""
+
+import logging
+import secrets
+import time
+import webbrowser
+from urllib.parse import urlencode
+
+import httpx
+from sqlalchemy.orm import Session
+
+from .. import config
+from ..database import CloudSettings as DBCloudSettings
+
+logger = logging.getLogger(__name__)
+
+SINGLETON_ID = 1
+PENDING_TTL_SECONDS = 600 # the whole browser flow must finish within 10 min
+
+# state -> expiry epoch. In-memory: a single backend process owns the flow, and a
+# dropped pairing should simply be restarted.
+_pending: dict[str, float] = {}
+
+
+def _prune() -> None:
+ now = time.time()
+ for state, expiry in list(_pending.items()):
+ if expiry < now:
+ _pending.pop(state, None)
+
+
+def _consume_state(state: str) -> bool:
+ """Validate and single-use-consume a pending state."""
+ _prune()
+ expiry = _pending.pop(state, None)
+ return expiry is not None and expiry >= time.time()
+
+
+def start_login(callback_url: str, device_name: str) -> str:
+ """Mint a state, build the authorize URL, and open the browser.
+
+ Returns the authorize URL (also opened here) so the caller can surface it as
+ a fallback if the browser didn't open.
+ """
+ state = secrets.token_urlsafe(24)
+ _prune()
+ _pending[state] = time.time() + PENDING_TTL_SECONDS
+
+ params = urlencode({"redirect_uri": callback_url, "state": state, "name": device_name})
+ authorize_url = f"{config.get_cloud_web_url()}/connect?{params}"
+
+ try:
+ webbrowser.open(authorize_url)
+ except Exception: # pragma: no cover - platform dependent
+ logger.exception("failed to open browser for cloud login")
+
+ return authorize_url
+
+
+async def handle_callback(db: Session, code: str, state: str) -> tuple[bool, str]:
+ """Exchange the code for an API key and store it. Returns (ok, message)."""
+ if not _consume_state(state):
+ return False, "This sign-in link is invalid or has expired. Start again from the app."
+ if not code:
+ return False, "Missing authorization code."
+
+ web = config.get_cloud_web_url()
+ api = config.get_cloud_api_url()
+ try:
+ async with httpx.AsyncClient(timeout=15.0) as client:
+ exchanged = await client.post(f"{web}/api/connect/exchange", json={"code": code})
+ if exchanged.status_code != 200:
+ logger.warning("cloud exchange rejected code: %s", exchanged.status_code)
+ return False, "Could not complete sign-in — the code was rejected."
+ payload = exchanged.json()
+ api_key = payload.get("key")
+ device_name = payload.get("label")
+ if not api_key:
+ return False, "Voicebox Cloud did not return a key."
+
+ # Confirm the freshly minted key actually authenticates the API.
+ me = await client.get(
+ f"{api}/v1/account/me",
+ headers={"Authorization": f"Bearer {api_key}"},
+ )
+ if me.status_code != 200:
+ logger.warning("minted key failed verification: %s", me.status_code)
+ return False, "Sign-in succeeded but the key could not be verified."
+ account_user_id = (me.json().get("data") or {}).get("userId")
+ except httpx.HTTPError:
+ logger.exception("network error during cloud exchange")
+ return False, "Could not reach Voicebox Cloud. Check your connection and try again."
+
+ _store_key(db, api_key=api_key, device_name=device_name, account_user_id=account_user_id)
+ logger.info("connected to Voicebox Cloud as device %r", device_name)
+ return True, "Connected"
+
+
+def _get_or_create_row(db: Session) -> DBCloudSettings:
+ row = db.query(DBCloudSettings).filter(DBCloudSettings.id == SINGLETON_ID).first()
+ if row is None:
+ row = DBCloudSettings(id=SINGLETON_ID)
+ db.add(row)
+ db.commit()
+ db.refresh(row)
+ return row
+
+
+def _store_key(db: Session, *, api_key: str, device_name: str | None, account_user_id: str | None):
+ from datetime import datetime
+
+ row = _get_or_create_row(db)
+ row.api_key = api_key
+ row.device_name = device_name
+ row.account_user_id = account_user_id
+ row.connected_at = datetime.utcnow()
+ db.commit()
+
+
+def get_status(db: Session) -> dict:
+ """Local view of the cloud link — never returns the full key."""
+ row = _get_or_create_row(db)
+ connected = bool(row.api_key)
+ # Prefix only: "voicebox_" (9) + 8 chars, matching the cloud's key_prefix.
+ key_prefix = row.api_key[:17] if row.api_key else None
+ return {
+ "connected": connected,
+ "device_name": row.device_name if connected else None,
+ "account_user_id": row.account_user_id if connected else None,
+ "key_prefix": key_prefix,
+ "connected_at": row.connected_at if connected else None,
+ }
+
+
+def disconnect(db: Session) -> None:
+ """Forget the local credential. The key remains valid on the server until
+ revoked from the account dashboard — surface that in the UI."""
+ row = _get_or_create_row(db)
+ row.api_key = None
+ row.account_user_id = None
+ row.connected_at = None
+ db.commit()
+
+
+def get_api_key(db: Session) -> str | None:
+ """The stored bearer key, for the (future) sync client. None if not linked."""
+ row = _get_or_create_row(db)
+ return row.api_key