Add /cloud/sync routes

Thin FastAPI layer over the identity flows and the sync engine: setup
(returns the recovery phrase exactly once when this device mints the
account key), restore, adopt, status, and run. Service errors surface
as 400s with their message.
This commit is contained in:
Jamie Pine
2026-07-01 15:18:55 -07:00
parent 25602555b1
commit 2c1e0f90a7
2 changed files with 127 additions and 9 deletions
+38
View File
@@ -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
+89 -9
View File
@@ -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"""<!doctype html>
<html lang="en"><head><meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
@@ -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,
)