Split 3/4 of the companion bridge (#863, #871 landed 1/4 and 2/4). Adds admin-only device pairing to the companion router. - GET /api/companion/pair -- renders a form; never mints (a GET must not mint a credential: SameSite=Lax session cookies ride top-level GET navigations, so GET-minting would be CSRF-triggerable via a link/<img>) - POST /api/companion/pair -- mints a one-time chat-scoped token. Admin-cookie only; CSRF-safe because a SameSite=Lax cookie is not sent on a cross-site POST, the same protection POST /api/tokens relies on. ?format=json returns the pairing payload for an in-app screen. Minting invalidates the auth middleware's token cache so the code works on the next request with no restart. companion/pairing.py holds the mint/LAN/QR helpers; the token is shown once and stored only as a bcrypt hash + prefix (mirrors routes/api_token_routes.py). Tests (tests/test_companion_pairing.py): - a bearer/'api' caller and a non-admin user are rejected by require_admin (403); an admin passes - the token is returned once and persisted only as a hash - minting invalidates the cache (works without restart) - minting is exposed on POST, never GET (CSRF)
29 lines
1.5 KiB
Markdown
29 lines
1.5 KiB
Markdown
# Companion bridge
|
|
|
|
A thin, additive layer so a LAN client (e.g. a phone) can discover what an
|
|
Odysseus server offers and pair to it, without duplicating any LLM logic.
|
|
|
|
| Method | Path | Auth | Purpose |
|
|
|---|---|---|---|
|
|
| GET | `/api/companion/ping` | session or token | cheap, auth-validated health check |
|
|
| GET | `/api/companion/info` | session or token | server identity + capability flags |
|
|
| GET | `/api/companion/models` | session or token | the **caller's own** model endpoints |
|
|
| GET | `/api/companion/pair` | **admin cookie** | pairing page (a form; never mints) |
|
|
| POST | `/api/companion/pair` | **admin cookie** | mint a one-time pairing token (`?format=json` for an in-app screen) |
|
|
|
|
`/models` scopes to the caller's real owner plus legacy null-owner shared rows
|
|
(same rule as `owner_filter`) and never returns API-key material.
|
|
|
|
## Pairing CSRF posture
|
|
|
|
Minting happens **only on POST**. The session cookie is `SameSite=Lax`
|
|
(`routes/auth_routes.py`), so a browser will not send it on a cross-site POST —
|
|
the same protection `POST /api/tokens` relies on. A `GET` would be unsafe (Lax
|
|
cookies ride top-level GET navigations), so `GET /pair` only renders a form.
|
|
Minting invalidates the auth middleware's token cache, so a freshly minted token
|
|
works on the next request without a restart.
|
|
|
|
The pairing/scoping rules live in small, tested units (`token_owner`,
|
|
`owner_can_see`, `mint_pairing_token`, `pairing.*`) — see
|
|
`tests/test_companion_readonly.py` and `tests/test_companion_pairing.py`.
|