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.
This commit is contained in:
James Pine
2026-04-25 17:09:32 -07:00
parent 2bcb98d1a8
commit f4d21504e3
49 changed files with 5229 additions and 30 deletions
+4
View File
@@ -16,6 +16,8 @@ from .models import (
GenerationSettings,
GenerationVersion,
MCPClientBinding,
PairedDevice,
PairingToken,
ProfileChannelMapping,
ProfileSample,
Project,
@@ -37,6 +39,8 @@ __all__ = [
"GenerationSettings",
"GenerationVersion",
"MCPClientBinding",
"PairedDevice",
"PairingToken",
"ProfileChannelMapping",
"ProfileSample",
"Project",
+34
View File
@@ -279,3 +279,37 @@ class Capture(Base):
llm_model = Column(String, nullable=True)
refinement_flags = Column(Text, nullable=True) # JSON blob
created_at = Column(DateTime, default=datetime.utcnow)
class PairedDevice(Base):
"""A mobile device paired with this Voicebox install (V0 pair flow).
Stores only the SHA-256 of the bearer token; the bearer plaintext is
returned to the device once at pairing time and never persisted
server-side. If the device loses its bearer the user must re-pair.
"""
__tablename__ = "paired_devices"
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
name = Column(String, nullable=False)
bearer_hash = Column(String, nullable=False, unique=True, index=True)
revoked = Column(Boolean, default=False, nullable=False)
created_at = Column(DateTime, default=datetime.utcnow)
last_seen_at = Column(DateTime, nullable=True)
class PairingToken(Base):
"""One-time token used to complete a device pairing.
Minted by the desktop UI via /pair/init, redeemed by the mobile
device via /pair/complete in exchange for a long-lived bearer.
Single-use; expires after ~5 minutes.
"""
__tablename__ = "pairing_tokens"
token = Column(String, primary_key=True)
expires_at = Column(DateTime, nullable=False)
used_at = Column(DateTime, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)
+58
View File
@@ -793,3 +793,61 @@ class AvailableEffectsResponse(BaseModel):
"""Response listing all available effect types."""
effects: List[AvailableEffect]
# --- Mobile pairing (V0) -----------------------------------------------------
class HostCandidate(BaseModel):
"""A reachable address the desktop can embed in the pair QR."""
address: str # ``host:port``, e.g. "192.168.1.5:17493"
label: str # human-friendly name shown in the desktop host picker
kind: str # "lan" | "tailscale" | "loopback"
class PairInitResponse(BaseModel):
"""Response for POST /pair/init — desktop renders ``pairing_url`` as a QR."""
token: str
expires_at: datetime
pairing_url: str # full voicebox://pair?host=…&token=… URL
class PairCompleteRequest(BaseModel):
"""Mobile-side request body for POST /pair/complete."""
token: str = Field(..., min_length=1, max_length=128)
device_name: str = Field(..., min_length=1, max_length=80)
class PairCompleteResponse(BaseModel):
"""One-time response after successful pairing.
``bearer`` is returned in plaintext exactly once and is never persisted
server-side; the device must save it (e.g. iOS SecureStore) immediately.
"""
device_id: str
bearer: str
device_name: str
class PairedDeviceResponse(BaseModel):
"""A row in the desktop's Settings → Mobile device list."""
id: str
name: str
revoked: bool
created_at: datetime
last_seen_at: Optional[datetime] = None
class Config:
from_attributes = True
class MeResponse(BaseModel):
"""Identity of the bearer-authenticated caller."""
device_id: str
device_name: str
last_seen_at: Optional[datetime] = None
+42 -19
View File
@@ -1,6 +1,23 @@
"""Route registration for the voicebox API."""
"""Route registration for the voicebox API.
from fastapi import FastAPI
Authentication model
--------------------
Two router groups:
* **Open** ``health`` (status checks anyone on the LAN may probe) and
``pairing`` (pre-pair endpoints + admin endpoints with their own
loopback-only or token-only gates).
* **Protected** everything else, gated by ``require_bearer_or_loopback``:
loopback callers (the desktop app over 127.0.0.1) pass without auth as
before; LAN/Tailscale callers must present a valid paired-device bearer.
This is what lets ``just dev`` bind to 0.0.0.0 without exposing user
data to anyone on the same network.
"""
from fastapi import Depends, FastAPI
from ..utils.auth import require_bearer_or_loopback
def register_routers(app: FastAPI) -> None:
@@ -23,22 +40,28 @@ 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 .pairing import router as pairing_router
# Open — health probes and the pre-pair / admin pairing endpoints.
app.include_router(health_router)
app.include_router(profiles_router)
app.include_router(channels_router)
app.include_router(generations_router)
app.include_router(history_router)
app.include_router(transcription_router)
app.include_router(llm_router)
app.include_router(captures_router)
app.include_router(stories_router)
app.include_router(effects_router)
app.include_router(audio_router)
app.include_router(models_router)
app.include_router(settings_router)
app.include_router(tasks_router)
app.include_router(cuda_router)
app.include_router(speak_router)
app.include_router(mcp_bindings_router)
app.include_router(events_router)
app.include_router(pairing_router)
# Protected — loopback callers pass through; LAN callers need a paired bearer.
protected = [Depends(require_bearer_or_loopback)]
app.include_router(profiles_router, dependencies=protected)
app.include_router(channels_router, dependencies=protected)
app.include_router(generations_router, dependencies=protected)
app.include_router(history_router, dependencies=protected)
app.include_router(transcription_router, dependencies=protected)
app.include_router(llm_router, dependencies=protected)
app.include_router(captures_router, dependencies=protected)
app.include_router(stories_router, dependencies=protected)
app.include_router(effects_router, dependencies=protected)
app.include_router(audio_router, dependencies=protected)
app.include_router(models_router, dependencies=protected)
app.include_router(settings_router, dependencies=protected)
app.include_router(tasks_router, dependencies=protected)
app.include_router(cuda_router, dependencies=protected)
app.include_router(speak_router, dependencies=protected)
app.include_router(mcp_bindings_router, dependencies=protected)
app.include_router(events_router, dependencies=protected)
+92
View File
@@ -0,0 +1,92 @@
"""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)
+201
View File
@@ -0,0 +1,201 @@
"""Mobile device pairing service (V0 — bearer auth, no E2E payload encryption yet).
Flow:
1. Desktop UI (loopback) calls POST /pair/init mints a single-use token
with a 5-minute TTL and returns a ``voicebox://pair?...`` URL.
2. Mobile scans the QR (or pastes the URL) and POSTs /pair/complete with
the token + a human-readable device name.
3. Server validates the token, mints a long-lived bearer, and stores
only SHA-256(bearer). The bearer plaintext is returned exactly once.
4. Mobile saves the bearer in SecureStore. Subsequent calls carry
``Authorization: Bearer <bearer>``.
The bearer is returned exactly once. Server has no path to recover it; if
the device loses its key the user must re-pair (and revoke the old device
from Settings Mobile if they want to be tidy).
Phase 2 will layer XChaCha20-Poly1305 payload encryption + HKDF session
keys on top see ``mobile/PLAN.md`` § Pairing & transport. The bearer
established here is the foundation either way.
"""
import hashlib
import logging
import secrets
import socket
import subprocess
import uuid
from datetime import datetime, timedelta
from typing import Optional
from sqlalchemy.orm import Session
from ..database import PairedDevice, PairingToken
from ..models import (
HostCandidate,
PairCompleteResponse,
PairInitResponse,
PairedDeviceResponse,
)
logger = logging.getLogger(__name__)
PAIRING_TOKEN_TTL = timedelta(minutes=5)
TOKEN_BYTES = 32 # urlsafe-b64 encoded → ~44 chars
def _hash_bearer(bearer: str) -> str:
return hashlib.sha256(bearer.encode("utf-8")).hexdigest()
def _build_pairing_url(token: str, host: str) -> str:
# ``host`` should be reachable from the mobile device — LAN IP, Tailscale
# 100.x address, MagicDNS hostname (e.g. ``mac.tail-xxxx.ts.net:17493``).
# The desktop UI is responsible for picking the right host; loopback is
# only useful for curl-driven local testing.
return f"voicebox://pair?host={host}&token={token}"
def init_pairing_token(db: Session, host: str) -> PairInitResponse:
"""Mint a one-time pairing token. Caller must already be authorized as loopback."""
token = secrets.token_urlsafe(TOKEN_BYTES)
expires_at = datetime.utcnow() + PAIRING_TOKEN_TTL
db.add(PairingToken(token=token, expires_at=expires_at))
db.commit()
return PairInitResponse(
token=token,
expires_at=expires_at,
pairing_url=_build_pairing_url(token, host),
)
def complete_pairing(db: Session, token: str, device_name: str) -> PairCompleteResponse:
"""Exchange a pairing token for a long-lived device bearer.
Raises ValueError on invalid / expired / already-used token.
"""
row = db.query(PairingToken).filter(PairingToken.token == token).first()
if row is None:
raise ValueError("Invalid pairing token")
if row.used_at is not None:
raise ValueError("Pairing token already used")
if row.expires_at < datetime.utcnow():
raise ValueError("Pairing token expired")
row.used_at = datetime.utcnow()
bearer = secrets.token_urlsafe(TOKEN_BYTES)
device = PairedDevice(
id=str(uuid.uuid4()),
name=device_name.strip(),
bearer_hash=_hash_bearer(bearer),
)
db.add(device)
db.commit()
logger.info("Paired new device id=%s name=%s", device.id, device.name)
return PairCompleteResponse(
device_id=device.id,
bearer=bearer,
device_name=device.name,
)
def authenticate_bearer(db: Session, bearer: str) -> Optional[PairedDevice]:
"""Look up a paired device by bearer. Bumps last_seen_at on success."""
if not bearer:
return None
bearer_hash = _hash_bearer(bearer)
device = (
db.query(PairedDevice)
.filter(PairedDevice.bearer_hash == bearer_hash, PairedDevice.revoked.is_(False))
.first()
)
if device is not None:
device.last_seen_at = datetime.utcnow()
db.commit()
return device
def list_devices(db: Session) -> list[PairedDeviceResponse]:
"""Return all paired devices (revoked included) for the desktop UI."""
rows = db.query(PairedDevice).order_by(PairedDevice.created_at.desc()).all()
return [PairedDeviceResponse.model_validate(r) for r in rows]
def revoke_device(db: Session, device_id: str) -> None:
"""Mark a device as revoked. Idempotent on repeat calls."""
device = db.query(PairedDevice).filter(PairedDevice.id == device_id).first()
if device is None:
raise ValueError("Device not found")
device.revoked = True
db.commit()
logger.info("Revoked device id=%s name=%s", device.id, device.name)
# --- Host discovery ---------------------------------------------------------
def _get_lan_ip() -> Optional[str]:
"""Best-effort outbound IPv4 of the host. Uses the UDP-connect trick —
no packets are actually sent; the kernel just picks the source IP it
would use to reach 8.8.8.8.
"""
try:
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
s.connect(("8.8.8.8", 80))
return s.getsockname()[0]
except OSError:
return None
def _get_tailscale_ip() -> Optional[str]:
"""Return the host's Tailscale 100.x address if Tailscale is installed
and reports one. Returns ``None`` on any failure Tailscale is optional.
Tries ``tailscale`` on PATH first, then falls back to the Mac App Store
install location (the macOS App bundle doesn't symlink onto PATH by
default, only into the user's shell init via an alias subprocess can't see).
"""
candidate_binaries = [
"tailscale",
"/Applications/Tailscale.app/Contents/MacOS/Tailscale",
]
for binary in candidate_binaries:
try:
result = subprocess.run(
[binary, "ip", "--4"],
capture_output=True,
text=True,
timeout=2,
)
except (FileNotFoundError, subprocess.TimeoutExpired):
continue
if result.returncode != 0:
continue
ip = result.stdout.strip().splitlines()[0].strip() if result.stdout else ""
if ip:
return ip
return None
def list_host_candidates(port: int) -> list[HostCandidate]:
"""Return suggested host strings the desktop can embed in the QR.
Order matters the UI defaults to the first non-loopback entry.
"""
candidates: list[HostCandidate] = []
lan_ip = _get_lan_ip()
if lan_ip and not lan_ip.startswith("127."):
candidates.append(
HostCandidate(address=f"{lan_ip}:{port}", label="Local network", kind="lan")
)
tailscale_ip = _get_tailscale_ip()
if tailscale_ip and tailscale_ip != lan_ip:
candidates.append(
HostCandidate(address=f"{tailscale_ip}:{port}", label="Tailscale", kind="tailscale")
)
candidates.append(
HostCandidate(address=f"127.0.0.1:{port}", label="Loopback (testing only)", kind="loopback")
)
return candidates
+103
View File
@@ -0,0 +1,103 @@
"""FastAPI dependencies for the V0 mobile-pair auth model.
Two dependencies are exposed:
* ``require_loopback`` reject calls that don't originate from a loopback
address. Used to gate desktop-only admin endpoints (pair init, devices
list, revoke). Loopback callers stay unauthenticated everywhere else
too the desktop app talks to its own backend over 127.0.0.1.
* ``require_paired_device`` validate ``Authorization: Bearer <token>``
against the ``paired_devices`` table. Used to identify paired mobile
callers and bumps ``last_seen_at`` on success.
Phase 2 will layer XChaCha20-Poly1305 payload encryption on top of the
bearer (see ``mobile/PLAN.md``); the bearer stays the identity primitive.
"""
from typing import Optional
from fastapi import Depends, HTTPException, Request, status
from sqlalchemy.orm import Session
from ..database import PairedDevice, get_db
from ..services import pairing as pairing_service
LOOPBACK_HOSTS = frozenset({"127.0.0.1", "::1", "localhost"})
def require_loopback(request: Request) -> None:
"""Reject calls from non-loopback addresses."""
client = request.client
host = client.host if client else None
if host not in LOOPBACK_HOSTS:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Loopback only",
)
def _extract_bearer(request: Request) -> Optional[str]:
auth = request.headers.get("Authorization") or request.headers.get("authorization")
if not auth:
return None
parts = auth.split(None, 1)
if len(parts) != 2 or parts[0].lower() != "bearer":
return None
return parts[1].strip()
def require_paired_device(
request: Request,
db: Session = Depends(get_db),
) -> PairedDevice:
"""Resolve the PairedDevice authenticated by the request bearer."""
bearer = _extract_bearer(request)
if not bearer:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Missing bearer token",
headers={"WWW-Authenticate": "Bearer"},
)
device = pairing_service.authenticate_bearer(db, bearer)
if device is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or revoked bearer token",
headers={"WWW-Authenticate": "Bearer"},
)
return device
def require_bearer_or_loopback(
request: Request,
db: Session = Depends(get_db),
) -> None:
"""Loopback callers pass without auth; everyone else needs a paired bearer.
Applied as a router-level dependency on user-data endpoints so the
desktop UI (which talks over 127.0.0.1) keeps its current friction-free
access while LAN-reachable callers must be a paired mobile device.
The pre-pair endpoints (``POST /pair/complete``) and the desktop-only
admin endpoints (``POST /pair/init``, ``GET /devices``) intentionally
stay outside this gate they have their own dependencies.
"""
client = request.client
host = client.host if client else None
if host in LOOPBACK_HOSTS:
return
bearer = _extract_bearer(request)
if not bearer:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Missing bearer token",
headers={"WWW-Authenticate": "Bearer"},
)
device = pairing_service.authenticate_bearer(db, bearer)
if device is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or revoked bearer token",
headers={"WWW-Authenticate": "Bearer"},
)