Files
voicebox/backend/routes/pairing.py
T
James Pine f4d21504e3 Mobile companion app + paired-device backend
New iOS-first companion (Expo SDK 54 + NativeWind v4) with three tabs:
Captures (the hero — floating gold mic, live mic-meter waveform,
expand-row playback), Generate (profile picker + speak + autoplay +
recent), and Voices (searchable profile list).

Pairing (V0): backend mints a one-time token, mobile scans/pastes the
voicebox:// URL, server returns a long-lived bearer it stores only as a
SHA-256 hash. Bearer-or-loopback auth applied to every user-data router
so binding 0.0.0.0 doesn't leak existing endpoints. Loopback callers
(the desktop app) keep their friction-free access.

Desktop Settings → Mobile pane: live host picker (LAN / Tailscale auto-
detected via the App-bundle binary path on macOS), QR rendering,
5-minute expiry countdown, copyable URL fallback, paired-device list
with revoke. Auto-closes when a new device pairs.

just dev now binds the backend to 0.0.0.0 so paired phones can reach
it — and just setup-python pins mlx-audio==0.4.1 + mlx-lm so fresh
Apple Silicon worktrees get a working STT path on first install.
2026-04-25 17:09:32 -07:00

93 lines
3.0 KiB
Python

"""Mobile device pairing endpoints (V0 — bearer auth)."""
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy.orm import Session
from .. import models
from ..database import PairedDevice, get_db
from ..services import pairing
from ..utils.auth import require_loopback, require_paired_device
router = APIRouter()
@router.post(
"/pair/init",
response_model=models.PairInitResponse,
dependencies=[Depends(require_loopback)],
)
async def pair_init(request: Request, db: Session = Depends(get_db)):
"""Mint a one-time pairing token (loopback callers only).
Optional ``?host=`` query param overrides what's embedded in the QR's
pairing URL. The desktop UI should pass whatever address is reachable
from the mobile device — LAN IP, Tailscale 100.x address, or MagicDNS
name. Defaults to the request Host header for curl-driven local testing.
"""
host = request.query_params.get("host") or (
request.headers.get("host") or "127.0.0.1:17493"
)
return pairing.init_pairing_token(db, host=host)
@router.post("/pair/complete", response_model=models.PairCompleteResponse)
async def pair_complete(
body: models.PairCompleteRequest,
db: Session = Depends(get_db),
):
"""Exchange a pairing token for a long-lived bearer.
Open endpoint — possession of the (one-time, short-TTL) token is
itself the proof of authorization.
"""
try:
return pairing.complete_pairing(db, body.token, body.device_name)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@router.get(
"/devices",
response_model=list[models.PairedDeviceResponse],
dependencies=[Depends(require_loopback)],
)
async def list_paired_devices(db: Session = Depends(get_db)):
"""List paired devices for the desktop Settings → Mobile pane."""
return pairing.list_devices(db)
@router.delete(
"/devices/{device_id}",
status_code=204,
dependencies=[Depends(require_loopback)],
)
async def revoke_paired_device(device_id: str, db: Session = Depends(get_db)):
"""Revoke a paired device's bearer."""
try:
pairing.revoke_device(db, device_id)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
@router.get("/me", response_model=models.MeResponse)
async def me(device: PairedDevice = Depends(require_paired_device)):
"""Identity of the bearer-authenticated caller — used by mobile to
confirm pairing succeeded and the bearer round-trips.
"""
return models.MeResponse(
device_id=device.id,
device_name=device.name,
last_seen_at=device.last_seen_at,
)
@router.get(
"/pair/host-candidates",
response_model=list[models.HostCandidate],
dependencies=[Depends(require_loopback)],
)
async def host_candidates(request: Request):
"""Suggested host strings (LAN IP, Tailscale, loopback) for the QR."""
port = request.url.port or 17493
return pairing.list_host_candidates(port=port)