diff --git a/backend/models.py b/backend/models.py index 4f608f40..e5e586ce 100644 --- a/backend/models.py +++ b/backend/models.py @@ -813,3 +813,41 @@ class CloudStatusResponse(BaseModel): account_user_id: Optional[str] = None key_prefix: Optional[str] = None connected_at: Optional[datetime] = None + + +class CloudSyncSetupResponse(BaseModel): + """Result of registering this install as a sync device. + + ``recovery_phrase`` is present exactly once, when this device minted the + account's key material (first device). The UI must force-display it and + never persist it. When absent, the account already has key material and + this device is awaiting provisioning (wrapped key from an existing device, + or a recovery-phrase restore).""" + + status: str # unregistered | awaiting_provision | ready + device_id: Optional[str] = None + recovery_phrase: Optional[str] = None + + +class CloudSyncStatusResponse(BaseModel): + """Sync identity + progress for the settings UI.""" + + status: str # unregistered | awaiting_provision | ready + device_id: Optional[str] = None + sync_cursor: int = 0 + + +class CloudRestoreRequest(BaseModel): + """Recovery-phrase restore on a fresh device.""" + + phrase: str + + +class CloudSyncRunResponse(BaseModel): + """Outcome of one push+pull sync pass.""" + + pushed: int + pushed_deletes: int + pulled: int + pulled_deletes: int + cursor: int diff --git a/backend/routes/cloud.py b/backend/routes/cloud.py index 3abf2ef5..52bf6c55 100644 --- a/backend/routes/cloud.py +++ b/backend/routes/cloud.py @@ -1,4 +1,4 @@ -"""Voicebox Cloud device login routes. +"""Voicebox Cloud routes: device login + encrypted backup/sync. The browser-based pairing flow: 1. POST /cloud/login/start — opens the browser to the cloud authorize page. @@ -6,17 +6,31 @@ The browser-based pairing flow: 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. + +Sync, once logged in: + 5. POST /cloud/sync/setup — register as an encryption device; on a keyless + account this mints the master key and returns + the recovery phrase (shown exactly once). + 6. POST /cloud/sync/restore — recover the master key from the phrase. + 7. POST /cloud/sync/adopt — pick up a wrapped key another device provisioned. + 8. GET /cloud/sync/status — identity state + cursor. + 9. POST /cloud/sync/run — one full push+pull pass. """ import socket -from fastapi import APIRouter, Depends, Request +from fastapi import APIRouter, Depends, HTTPException, 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 +from ..database import CloudSettings as DBCloudSettings, get_db +from ..services import cloud as cloud_service, cloud_account, cloud_sync +from ..services.cloud_account import CloudAccountError +from ..services.cloud_api import CloudApiError +from ..services.cloud_crypto import CloudCryptoError +from ..services.cloud_keys import CloudKeyStoreError +from ..services.cloud_sync import CloudSyncError router = APIRouter(prefix="/cloud", tags=["cloud"]) @@ -44,11 +58,7 @@ async def cloud_callback( 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 - ) + sub = "Voicebox is now linked to your account. You can close this tab and return to the app." if ok else message html = f""" @@ -73,3 +83,73 @@ async def cloud_status(db: Session = Depends(get_db)): async def cloud_disconnect(db: Session = Depends(get_db)): cloud_service.disconnect(db) return models.CloudStatusResponse(**cloud_service.get_status(db)) + + +# ─── Encrypted backup & sync ───────────────────────────────────────────── + +_SYNC_ERRORS = (CloudAccountError, CloudApiError, CloudCryptoError, CloudKeyStoreError, CloudSyncError) + + +def _sync_status(db: Session) -> models.CloudSyncStatusResponse: + identity = cloud_account.identity_status(db) + row = db.query(DBCloudSettings).filter(DBCloudSettings.id == 1).first() + return models.CloudSyncStatusResponse( + status=identity.status, + device_id=identity.device_id, + sync_cursor=(row.sync_cursor or 0) if row else 0, + ) + + +@router.post("/sync/setup", response_model=models.CloudSyncSetupResponse) +async def cloud_sync_setup(db: Session = Depends(get_db)): + try: + phrase = await cloud_account.setup_device(db) + identity = cloud_account.identity_status(db) + except _SYNC_ERRORS as err: + raise HTTPException(status_code=400, detail=str(err)) from err + return models.CloudSyncSetupResponse( + status=identity.status, + device_id=identity.device_id, + recovery_phrase=phrase, + ) + + +@router.post("/sync/restore", response_model=models.CloudSyncStatusResponse) +async def cloud_sync_restore(body: models.CloudRestoreRequest, db: Session = Depends(get_db)): + try: + await cloud_account.restore_with_phrase(db, body.phrase) + except _SYNC_ERRORS as err: + raise HTTPException(status_code=400, detail=str(err)) from err + return _sync_status(db) + + +@router.post("/sync/adopt", response_model=models.CloudSyncStatusResponse) +async def cloud_sync_adopt(db: Session = Depends(get_db)): + try: + await cloud_account.adopt_wrapped_key(db) + except _SYNC_ERRORS as err: + raise HTTPException(status_code=400, detail=str(err)) from err + return _sync_status(db) + + +@router.get("/sync/status", response_model=models.CloudSyncStatusResponse) +async def cloud_sync_status(db: Session = Depends(get_db)): + try: + return _sync_status(db) + except CloudAccountError: + return models.CloudSyncStatusResponse(status="unregistered", device_id=None, sync_cursor=0) + + +@router.post("/sync/run", response_model=models.CloudSyncRunResponse) +async def cloud_sync_run(db: Session = Depends(get_db)): + try: + report = await cloud_sync.run_sync(db) + except _SYNC_ERRORS as err: + raise HTTPException(status_code=400, detail=str(err)) from err + return models.CloudSyncRunResponse( + pushed=report.pushed, + pushed_deletes=report.pushed_deletes, + pulled=report.pulled, + pulled_deletes=report.pulled_deletes, + cursor=report.cursor, + )