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)
1.5 KiB
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.