Files
hope-voice-api/control

Account control plane

Run python -m control with FastAPI and uvicorn installed. Defaults to 127.0.0.1:8092; configure CONTROL_HOST / CONTROL_PORT explicitly if needed. Run one control process against a database (lifecycle scheduling is process-local). No Docker logic lives in the account API: production imports control.provisioner.Provisioner() (locally aliased HermesProvisioner) lazily on startup.

Runtime settings

  • CONTROL_DB_PATH: SQLite path, default /data/control/accounts.sqlite3. Mount persistent private storage. The DB contains hashes and Hermes keys; protect the directory and its WAL/SHM files, backups, and host access.
  • CONTROL_ADMIN_USERNAME: initial administrator lowercase slug, default admin.
  • CONTROL_ADMIN_PASSWORD: required when DB is empty, 12–256 characters; no built-in password. Ignored once existing accounts are present. Do not log it.
  • CONTROL_SERVICE_SECRET: >=32-character secret for trusted internal callers. Missing/short secret disables all internal access; send as X-Control-Secret.
  • CONTROL_ALLOWED_ORIGINS: exact comma-separated HTTPS browser origins. Set this behind nginx; no forwarded host/proto headers are trusted.

Native login sends Content-Type: application/json and no browser Origin. Browser login requires its actual Origin to match. Cookie mutations require a matching Origin; bearer-authenticated requests are exempt. Login is throttled per validated client IP and username (10 failed attempts / 5 minutes; successes release their reservation). Only a direct loopback peer may supply X-Real-IP, which nginx must overwrite with $remote_addr. Do not expose the control port or preserve untrusted client-supplied identity headers in another proxy. Opaque sessions last 24 hours, are hashed at rest and revoked by logout, password reset or deletion. Cookies are Secure, HttpOnly, SameSite=Strict.

Public API

  • POST /api/auth/login JSON {username,password} → {token,user,expires_at} (expiry Unix seconds) plus hope_session cookie.
  • POST /api/auth/logout → {ok:true}; invalid sessions return 401.
  • GET /api/auth/me → {user}.
  • Administrator-only GET /api/admin/users → {users:[...]}.
  • Administrator-only POST /api/admin/users JSON {username,display_name,password,role?:"user"} → 201 {user}.
  • Administrator-only DELETE /api/admin/users/{id} → 202 {user} in deleting state. Sessions/rooms revoked synchronously before return; container deletion is background, durable and retriable. Last administrator is protected.
  • Administrator-only POST /api/admin/users/{id}/password JSON {password} → {ok:true}; revokes all sessions and room grants.
  • Administrator-only POST /api/admin/users/{id}/retry → 202 {user}; retries failed provisioning or removal, preserving intended lifecycle.
  • GET /health → {status:"ok"} (control HTTP liveness, not Hermes readiness).

Public user is exactly {id,username,display_name,role,status,container_name,error}. IDs are stable UUID hex. Usernames match [a-z][a-z0-9]*(?:-[a-z0-9]+)*, 1–48 characters. Status is provisioning, ready, error or deleting. Deleting failures remain deleting with a sanitized visible error, never become login-eligible. Background reconcile runs on startup and every 30 seconds.

Trusted speech boundary

Every internal route requires X-Control-Secret, including room lookups.

  • POST /internal/resolve-session JSON {token} validates the exact cookie or bearer token; returns {user,hermes_url,hermes_api_key} only for ready user.
  • GET /internal/users/{id} returns the same ready-user metadata.
  • POST /internal/rooms JSON {user_id,room_name,session_token,ttl?:3600} validates that session belongs to the user and persists a unique server-minted room grant, returning 201 {room_name,user_id}. TTL 1–86400 sec, capped at original session expiry.
  • GET /internal/rooms/{room_name} resolves that grant only while unexpired and user still active/ready. Returns the same metadata. Deletion/reset revoke grants immediately; speech agents should poll to stop active conversations.

401 means absent/invalid auth; 403 forbidden role/Origin; 404 missing/deleted user or invalid room; 503 means not yet ready. Metadata is never in public APIs. Do not proxy internal endpoints publicly. Token service resolves client sessions, mints an opaque room name, persists its grant before issuing LiveKit credentials, and never accepts browser-supplied account IDs, room ownership or Hermes URLs. For colocated FastAPI integration use Depends(control.current_user) and Depends(control.administrator); these return public user dicts and require this application's app.state.accounts/settings. Separate services use the secret-authenticated internal endpoints instead.

Provisioner interface

create_app(settings: Settings, provisioner=...) supports offline injection. The adapter receives a private trusted dict, including id, username, container_name, desired, and stored hermes_url / hermes_api_key.

  • async ensure(user) -> dict: idempotently create/recover owned container; return {container_name,hermes_url,hermes_api_key}. Container name must match hope-hermes-<username>-<id[:8]>; metadata is persisted before readiness check.
  • async ready(user) -> bool: owned service health, not existence alone.
  • async remove(user) -> None: verify management/user ownership before any removal; retain private data by default; failure raises and remains retriable.
  • Optional async aclose(): close adapter resources during shutdown.

Async adapters offload blocking Docker/HTTP calls themselves; synchronous adapters are supported using a worker thread. Account SQLite and scrypt work are offloaded from the ASGI loop. Slow provision/remove run separate tasks; request handlers do not await container startup. Exception text is not logged or returned because provider failures may contain credentials.

Offline tests:

python -m pytest tests/unit/test_accounts.py --confcutdir=tests/unit -q

--confcutdir avoids this repository's deployment-oriented top-level fixtures. Use a temporary DB and fake adapter; no Docker or provider calls are made.