···11+# Wave 5 pairing server
22+33+## 1. Summary
44+55+Wave 5 adds a self-hosted iOS pairing flow to the existing Convey process: one JSON API blueprint at `/api/pairing/*` plus one owner-facing HTML blueprint at `/app/pairing/*`, both defined in `convey/pairing.py`. This follows the Wave 2 / Wave 3 root-blueprint pattern rather than adding an `apps/pairing/` package (`convey/voice.py:27-197`, `convey/push.py:24-127`, `convey/__init__.py:110-169`, `/home/jer/projects/extro/vpe/workspace/hop-solstone-pairing-wave-5-server.md:20-78`).
66+77+The server mints one-time `ptk_...` pairing tokens in memory, accepts ssh-ed25519 public keys from the iOS client, stores paired devices in `journal/config/paired_devices.json`, and returns a one-time `dsk_...` bearer session key whose hash is the only value persisted at rest. The companion iOS contract is fixed: `solstone://pair?token=...&host=...`, `POST {host}/api/pairing/confirm`, then `Authorization: Bearer <session_key>` on subsequent API calls (`/home/jer/projects/extro/vpe/workspace/hop-solstone-pairing-wave-5-server.md:26-56`, `/home/jer/projects/extro/vpe/workspace/hop-solstone-swift-wave-5-client.md:115-137`).
88+99+The design keeps config defaults compatible with existing journals by using both a new `pairing` block in `think/journal_default.json` and in-code defaults in `think/pairing/config.py`, because `get_config()` does not merge defaults once `config/journal.json` exists (`think/journal_default.json:1-53`, `think/utils.py:557-588`, `tests/conftest.py:77-84`). It also promotes `cryptography` to a direct dependency: the repo already imports it directly in the link pairing surface, but `pyproject.toml` does not currently declare it (`apps/link/routes.py:33-35`, `pyproject.toml:32-93`).
1010+1111+## 2. Module layout
1212+1313+| Path | Role |
1414+|---|---|
1515+| `pyproject.toml` | Add direct dependency `cryptography>=42` because pairing will import `cryptography` directly, and the repo already does so in `apps/link/routes.py` (`apps/link/routes.py:33-35`, `pyproject.toml:55-61`). |
1616+| `think/journal_default.json` | Add a flat `pairing` config block beside `voice` and `push`. `identity.name` and `identity.preferred` already exist here and are the source for `owner_identity` resolution (`think/journal_default.json:2-15`, `35-53`). |
1717+| `think/pairing/__init__.py` | Narrow re-export surface so callers do not reach into module-private helpers, matching the small package-surface pattern used by `think/push` and `think/voice` (`docs/design/push.md:13-24`, `docs/design/voice-server.md:11-26`). |
1818+| `think/pairing/config.py` | Config readers for `pairing.host_url`, `pairing.token_ttl_seconds`, and owner identity fallback. Mirrors the small-reader style in `think/push/config.py` and must supply defaults in code because fixture journals already have a `journal.json` (`think/push/config.py:17-81`, `think/utils.py:557-588`). |
1919+| `think/pairing/tokens.py` | In-memory token store with a module-level singleton and `threading.Lock`, modeled after `think/link/nonces.py`’s single-use TTL store but intentionally kept process-local instead of journal-backed (`think/link/nonces.py:25-103`). |
2020+| `think/pairing/keys.py` | Public-key validation, bearer-session-key generation, SHA-256 hashing, and log masking. This is the only module that knows the `ptk_...` / `dsk_...` wire formats and the ssh-ed25519-only rule. |
2121+| `think/pairing/devices.py` | Sole writer for `journal/config/paired_devices.json`, mirroring the whole-file atomic rewrite pattern from `think/push/devices.py` (`think/push/devices.py:21-153`). |
2222+| `convey/auth.py` | Shared bearer/owner auth helpers. Factors the Bearer extraction pattern out of `apps/observer/routes.py` / `apps/import/journal_sources.py` and adds paired-device resolution and owner-auth inspection without redirects (`apps/observer/routes.py:63-70`, `503-538`, `apps/import/journal_sources.py:108-128`, `convey/root.py:49-57`, `81-139`). |
2323+| `convey/pairing.py` | Defines `pairing_bp = Blueprint("pairing", ..., url_prefix="/api/pairing")` and `pairing_ui_bp = Blueprint("pairing_ui", ..., url_prefix="/app/pairing")`, using the same local `_error`, `_required_json_object`, and `_optional_json_object` request-validation pattern as `convey/voice.py` / `convey/push.py` (`convey/voice.py:27-53`, `convey/push.py:24-50`). |
2424+| `convey/__init__.py` | Import and register both pairing blueprints in the same root-blueprint block as `voice_bp` / `push_bp`, before app discovery (`convey/__init__.py:112-161`). |
2525+| `convey/root.py` | Extend the exact-name allowlist in `require_login()` with `pairing.confirm_pairing`, `pairing.heartbeat`, `pairing.list_devices`, and `pairing.unpair_device`. Leave `pairing.create_token` and `pairing_ui.index` owner-authed (`convey/root.py:81-139`). |
2626+| `convey/templates/pairing.html` | Flat owner-facing desktop page. Use a heading of “Pair a phone” to distinguish this flow from tunnel pairing in `apps/link/routes.py` (`apps/link/routes.py:4-24`). |
2727+| `convey/static/pairing-qr.js` | Vendored `qrcode-generator` browser build, loaded directly because package data currently only includes flat `static/*` assets (`pyproject.toml:110-118`, `convey/__init__.py:118-133`). |
2828+| `convey/static/pairing.js` | Page logic: mint token, render QR, countdown, 5-second polling against `GET /api/pairing/devices`, copy-paste fallback, success/error state. |
2929+| `convey/static/pairing.css` | Minimal page styling only if `app.css` reuse is insufficient. |
3030+3131+### Public API surface
3232+3333+The public function surface is intentionally small and explicit.
3434+3535+#### `think/pairing/config.py`
3636+3737+- `def get_host_url() -> str:`
3838+ Return the configured pairing host URL, or synthesize `http://localhost:<convey-port>` when `pairing.host_url` is null by reading the recorded Convey port and falling back to the installed default port `5015` (`think/utils.py:922-935`, `think/service.py:32-34`).
3939+- `def get_token_ttl_seconds() -> int:`
4040+ Return the configured token TTL, clamped to `60..3600`, defaulting to `600`.
4141+- `def get_owner_identity() -> str:`
4242+ Return `config.identity.preferred`, else `config.identity.name`, else `""` (`think/journal_default.json:2-15`).
4343+4444+#### `think/pairing/tokens.py`
4545+4646+- `def create_token(*, ttl_seconds: int | None = None, now: int | None = None) -> PairingToken:`
4747+ Mint a `ptk_...` token, insert it into the in-memory store, and return its issued/expires metadata.
4848+- `def consume_token(token: str, *, now: int | None = None) -> PairingToken | None:`
4949+ Atomically validate and consume a token; return `None` for missing, expired, or already-consumed tokens.
5050+- `def peek_token(token: str, *, now: int | None = None) -> PairingToken | None:`
5151+ Return current token metadata without consuming it while still pruning expired entries.
5252+- `def purge_expired_tokens(*, now: int | None = None) -> int:`
5353+ Remove expired tokens and return the number purged.
5454+5555+#### `think/pairing/keys.py`
5656+5757+- `def validate_public_key(public_key: str) -> str:`
5858+ Parse and validate an ssh-ed25519 public key, reject any non-Ed25519 algorithm, and return the normalized string.
5959+- `def generate_session_key() -> str:`
6060+ Mint a one-time `dsk_...` bearer session key for the paired device.
6161+- `def hash_session_key(session_key: str) -> str:`
6262+ Return `sha256:<hex>` for storage and lookup.
6363+- `def mask_session_key(session_key: str) -> str:`
6464+ Return a log-safe mask showing only the last four characters and the total length.
6565+6666+#### `think/pairing/devices.py`
6767+6868+- `def load_devices() -> list[Device]:`
6969+ Load and validate `paired_devices.json`, returning `[]` on missing or malformed stores with a warning.
7070+- `def find_device_by_id(device_id: str) -> Device | None:`
7171+ Return one paired device by `id`, or `None`.
7272+- `def find_device_by_session_key_hash(session_key_hash: str) -> Device | None:`
7373+ Return one paired device matching a stored `session_key_hash`, or `None`.
7474+- `def register_device(*, name: str, platform: str, public_key: str, session_key_hash: str, bundle_id: str, app_version: str, paired_at: str | None = None) -> Device:`
7575+ Create or update a device row keyed by `public_key`, generating a stable `dev_...` id on first registration and rotating the stored `session_key_hash` on re-pair.
7676+- `def touch_last_seen(device_id: str, *, last_seen_at: str | None = None) -> bool:`
7777+ Update `last_seen_at` for an existing paired device.
7878+- `def remove_device(device_id: str) -> bool:`
7979+ Remove one paired device by `id`.
8080+- `def status_view(device: Device) -> dict[str, Any]:`
8181+ Return the non-secret JSON view exposed by `GET /api/pairing/devices`.
8282+8383+#### `convey/auth.py`
8484+8585+- `def extract_bearer_token() -> str | None:`
8686+ Return the trimmed `Authorization: Bearer ...` token if present, else `None`.
8787+- `def resolve_paired_device() -> Device | None:`
8888+ Hash the presented bearer token, load the matching paired device, and return it when valid.
8989+- `def is_owner_authed() -> bool:`
9090+ Return `True` when the current request already satisfies the owner checks used by `require_login()` without triggering redirects: session cookie, Basic Auth, or the completed-setup localhost bypass (`convey/root.py:49-57`, `81-139`).
9191+- `def require_paired_device(f):`
9292+ Decorator that resolves a paired-device bearer, stores it on `g.paired_device`, and returns `401` JSON when no valid paired-device bearer is present.
9393+9494+## 3. Flow diagrams
9595+9696+### 3.1 Token mint (`POST /api/pairing/create`)
9797+9898+```text
9999+owner browser on /app/pairing/
100100+ -> POST /api/pairing/create (owner-auth via require_login)
101101+ -> pairing config resolves host URL + TTL
102102+ -> think.pairing.tokens.create_token(...)
103103+ -> response includes token, expires_at, pairing_url, qr_data
104104+ -> pairing.js renders QR client-side and starts countdown
105105+```
106106+107107+This mirrors the local-request-validation shape of `convey/voice.py` and `convey/push.py`, but the write target is an in-memory store rather than journal state (`convey/voice.py:30-53`, `convey/push.py:27-50`, `think/link/nonces.py:45-103`).
108108+109109+### 3.2 Confirm (`POST /api/pairing/confirm`)
110110+111111+```text
112112+iOS client
113113+ -> POST /api/pairing/confirm
114114+ -> allowlist bypasses require_login
115115+ -> route validates JSON object + token/public_key/device metadata
116116+ -> think.pairing.tokens.consume_token(token)
117117+ -> think.pairing.keys.validate_public_key(public_key)
118118+ -> think.pairing.keys.generate_session_key() + hash_session_key(...)
119119+ -> think.pairing.devices.register_device(...)
120120+ -> response returns session_key once, plus device_id/journal_root/owner_identity/server_version
121121+```
122122+123123+The confirm flow deliberately follows the existing “token in header/body, then validate against a feature-owned store” pattern from observer ingest and journal-source ingest, but pairing consumes its own in-memory token and persists only the device ledger (`apps/observer/routes.py:524-538`, `apps/import/journal_sources.py:108-128`, `/home/jer/projects/extro/vpe/workspace/hop-solstone-swift-wave-5-client.md:115-124`).
124124+125125+### 3.3 List / heartbeat (`GET /api/pairing/devices`, `POST /api/pairing/heartbeat`)
126126+127127+```text
128128+paired device
129129+ -> Authorization: Bearer dsk_...
130130+ -> list: resolve paired device or owner path, return non-secret device rows
131131+ -> heartbeat: @require_paired_device resolves bearer and stores g.paired_device
132132+ -> think.pairing.devices.touch_last_seen(g.paired_device["id"])
133133+```
134134+135135+Heartbeat is bearer-only. List is mixed-auth: allowlist bypass at `require_login()`, then explicit handler-level acceptance of either a paired-device bearer or an already-owner-authenticated request.
136136+137137+### 3.4 Unpair (`DELETE /api/pairing/devices/<device_id>`)
138138+139139+```text
140140+paired device OR owner browser
141141+ -> allowlist bypasses require_login
142142+ -> route resolves either bearer device or owner auth
143143+ -> think.pairing.devices.remove_device(device_id)
144144+ -> 200 {"unpaired": true} on success
145145+```
146146+147147+The storage rule is simple: unpair removes the row from `paired_devices.json` rather than soft-deleting it. That keeps the store authoritative for current pairings only, matching the existing push-device store style (`think/push/devices.py:76-124`).
148148+149149+### 3.5 Restart semantics
150150+151151+```text
152152+process restart
153153+ -> in-memory token store is empty
154154+ -> pending QR codes immediately stop working
155155+ -> paired_devices.json persists
156156+ -> existing dsk_... bearer tokens continue to resolve
157157+```
158158+159159+This split is deliberate. The token store is ephemeral by scope; the device ledger is durable config (`/home/jer/projects/extro/vpe/workspace/hop-solstone-pairing-wave-5-server.md:32`, `147-148`).
160160+161161+## 4. Endpoint specs
162162+163163+### 4.1 Endpoint table
164164+165165+| Route | Auth gate | Request shape | Success response | Error cases |
166166+|---|---|---|---|---|
167167+| `POST /api/pairing/create` | `require_login` only | Optional JSON object; no required fields in Wave 5 | `{"token","expires_at","pairing_url","qr_data"}` | `400` invalid JSON / non-object, `500` token mint failure |
168168+| `POST /api/pairing/confirm` | allowlist only | Required JSON object with `token`, `public_key`, `device_name`, `platform`, `bundle_id`, `app_version` | `{"session_key","device_id","journal_root","owner_identity","server_version"}` | `400` bad JSON / missing fields / bad key / unsupported platform, `410` token expired or used |
169169+| `POST /api/pairing/heartbeat` | allowlist + `@require_paired_device` | Optional empty body or JSON object ignored in Wave 5 | `{"ok": true}` | `400` invalid JSON / non-object, `401` missing or invalid bearer |
170170+| `GET /api/pairing/devices` | allowlist + chained | No body | `{"devices":[...]}` | `401` no valid paired-device bearer and not owner-authed |
171171+| `DELETE /api/pairing/devices/<device_id>` | allowlist + chained | No body | `{"unpaired": true}` | `401` no valid paired-device bearer and not owner-authed, `404` unknown `device_id` |
172172+| `GET /app/pairing/` | `require_login` only | No body | `200` HTML page | No route-specific auth bypass; unauthenticated requests redirect via `require_login()` |
173173+174174+### 4.2 Allowlist additions
175175+176176+Add these exact endpoint names to `convey/root.py`’s `require_login()` allowlist and no others:
177177+178178+- `pairing.confirm_pairing`
179179+- `pairing.heartbeat`
180180+- `pairing.list_devices`
181181+- `pairing.unpair_device`
182182+183183+Do **not** add `pairing.create_token`. Do **not** add `pairing_ui.index` (`convey/root.py:81-139`).
184184+185185+### 4.3 Mixed-auth chain for `list_devices` and `unpair_device`
186186+187187+The mixed-auth routes intentionally do **not** use `@require_paired_device`, because that decorator is bearer-only and would reject a valid owner request before the owner path could run. Their route-level chain is:
188188+189189+1. `require_login()` is bypassed by endpoint-name allowlist.
190190+2. The handler calls `resolve_paired_device()`.
191191+3. If a device is found, the handler sets `g.paired_device = device` and proceeds.
192192+4. If no device is found, the handler calls `is_owner_authed()`.
193193+5. If neither path succeeds, the handler returns `401 {"error": "...", "reason": "auth_required"}`.
194194+195195+This keeps the bypass explicit, preserves owner access via cookie / Basic Auth / localhost semantics, and avoids redirect responses on API routes (`convey/root.py:49-57`, `81-139`).
196196+197197+## 5. Config keys
198198+199199+Add this block to `think/journal_default.json` after `push` and before `retention`:
200200+201201+```json
202202+"pairing": {
203203+ "host_url": null,
204204+ "token_ttl_seconds": 600
205205+}
206206+```
207207+208208+Key rules:
209209+210210+- `pairing.host_url`
211211+ - Default in `journal_default.json`: `null`.
212212+ - Runtime behavior: when null, synthesize `http://localhost:<convey-port>` using `read_service_port("convey")` and fall back to `DEFAULT_SERVICE_PORT = 5015` (`think/utils.py:922-935`, `think/service.py:32-34`).
213213+ - Operator behavior: set this explicitly when Convey is exposed through a tunnel, reverse proxy, or non-localhost origin.
214214+- `pairing.token_ttl_seconds`
215215+ - Default: `600`.
216216+ - Runtime clamp: `60..3600`.
217217+218218+`owner_identity` does not need its own config key; it resolves from existing identity fields: `identity.preferred`, then `identity.name`, then `""` (`think/journal_default.json:2-15`).
219219+220220+## 6. Feature-specific detail
221221+222222+### 6.1 Token store
223223+224224+Decision: use a module-level singleton store backed by `threading.Lock` and a plain dict. Rationale: the scope explicitly accepts restart-invalidated tokens, and the existing link nonce store shows the TTL + single-use semantics we need without implying journal persistence (`think/link/nonces.py:45-103`, `/home/jer/projects/extro/vpe/workspace/hop-solstone-pairing-wave-5-server.md:32`, `147-148`).
225225+226226+Token rules:
227227+228228+- Format: `ptk_<urlsafe-base64-32-bytes>`.
229229+- TTL default: `600` seconds.
230230+- TTL clamp: `60..3600`.
231231+- Single-use: `consume_token()` is the only mutating read path.
232232+- Restart invariant: store is empty after process restart; paired devices remain valid.
233233+234234+### 6.2 Session-key crypto
235235+236236+Decision: bearer session keys are `dsk_<urlsafe-base64-32-bytes>` and are stored only as `sha256:<hex>`. Rationale: this matches the scope’s one-time-return contract and keeps the journal ledger non-secret at rest (`/home/jer/projects/extro/vpe/workspace/hop-solstone-pairing-wave-5-server.md:41-54`, `147-148`).
237237+238238+### 6.3 Public-key validation
239239+240240+Decision: accept ssh-ed25519 only, with a `2048`-character cap on the incoming public-key string and a `128`-character cap on `device_name`. Rationale: the client contract already generates Ed25519 keys, and tight bounds keep the parser and logs safe (`/home/jer/projects/extro/vpe/workspace/hop-solstone-swift-wave-5-client.md:116-122`).
241241+242242+### 6.4 `paired_devices.json` schema
243243+244244+The on-disk store is a single JSON object:
245245+246246+```json
247247+{
248248+ "devices": [
249249+ {
250250+ "id": "dev_...",
251251+ "name": "jer's iPhone 15 Pro",
252252+ "platform": "ios",
253253+ "public_key": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI...",
254254+ "session_key_hash": "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
255255+ "bundle_id": "org.solpbc.solstone-swift",
256256+ "app_version": "0.1.0",
257257+ "paired_at": "2026-04-20T15:31:02Z",
258258+ "last_seen_at": null
259259+ }
260260+ ]
261261+}
262262+```
263263+264264+Store rules:
265265+266266+- Top-level object only.
267267+- Device identity is unique by `public_key`; re-pairing the same phone updates the existing row in place and rotates `session_key_hash`.
268268+- `last_seen_at` starts `null` and is updated only by `POST /api/pairing/heartbeat`.
269269+- Unpair removes the row entirely.
270270+271271+### 6.5 Auth chain
272272+273273+Decision: ship a pairing-focused `convey/auth.py` now, but do not thread it into existing voice/push/observer routes. Rationale: Wave 5 needs the helper surface immediately, but the scope explicitly defers cross-route enforcement to Wave 5.1 (`/home/jer/projects/extro/vpe/workspace/hop-solstone-pairing-wave-5-server.md:66-72`, `134-148`).
274274+275275+Helper contract:
276276+277277+- `extract_bearer_token()` mirrors the existing header parsing in observer and import.
278278+- `resolve_paired_device()` hashes the bearer and resolves `paired_devices.json`.
279279+- `require_paired_device` is used only on bearer-only pairing routes in this lode.
280280+- `is_owner_authed()` mirrors `require_login()`’s owner checks without redirects.
281281+282282+### 6.6 Error model
283283+284284+Decision: every API error is JSON shaped as `{"error": "<human message>", "reason": "<stable_reason>"}`. Rationale: this preserves the concise error-helper style from `convey/voice.py` / `convey/push.py` while giving the client a stable machine-readable string that does not echo input back (`convey/voice.py:30-53`, `convey/push.py:27-50`).
285285+286286+### 6.7 QR generation
287287+288288+Decision: vendor `qrcode-generator` version `1.4.4` under `convey/static/pairing-qr.js` and generate the SVG client-side from the `qr_data` field returned by `POST /api/pairing/create`. Rationale: the repo packaging only includes flat `static/*` assets, and this keeps the QR dependency browser-only, MIT-licensed, and separate from the page logic in `convey/static/pairing.js` (`pyproject.toml:110-118`, `convey/__init__.py:118-133`, `/home/jer/projects/extro/vpe/workspace/hop-solstone-pairing-wave-5-server.md:60-64`).
289289+290290+### 6.8 Logging
291291+292292+Decision: use `logging` only; never log raw `session_key`; log masked session keys as last four chars + total length; log public keys only at `DEBUG` with truncation. Rationale: the scope forbids raw secret leakage and pairing is explicitly bearer-token based (`/home/jer/projects/extro/vpe/workspace/hop-solstone-pairing-wave-5-server.md:147-148`).
293293+294294+## 7. Domain write-ownership (L1-L9 declarations)
295295+296296+This lode stays within the repo’s layer-hygiene invariants (`scripts/check_layer_hygiene.py:38-110`, `183-240`).
297297+298298+- **L1 Layer boundaries**: `think/pairing/devices.py` owns the device ledger. `convey/pairing.py` only validates requests, coordinates feature calls, and returns HTTP responses, matching the root-blueprint split in voice/push (`convey/voice.py:30-197`, `convey/push.py:27-127`).
299299+- **L2 Domain write ownership**: `journal/config/paired_devices.json` is owned exclusively by `think/pairing/devices.py`. No other module writes it. The token store is in memory only, so no extra journal domain is created.
300300+- **L3 Naming contract**: read helpers use read verbs (`load_devices`, `find_device_by_id`, `find_device_by_session_key_hash`); write helpers use write verbs (`register_device`, `touch_last_seen`, `remove_device`).
301301+- **L4 CLI read verbs are read-only**: no new CLI surface is introduced in this lode.
302302+- **L5 Write-verb defaults**: not applicable to CLI; the only mutating surfaces are explicit HTTP write routes.
303303+- **L6 Indexers never mutate source data**: not applicable; no indexer changes.
304304+- **L7 Importers only write to imports/**: not applicable; no importer changes.
305305+- **L8 Hooks have declared outputs**: not applicable; no hook changes.
306306+- **L9 Event handlers are idempotent**: `heartbeat` is idempotent by row update; `confirm` is single-use because the token store enforces consume-once; `create` is side-effectful by construction but scoped to ephemeral memory.
307307+308308+## 8. Tests
309309+310310+### 8.1 `tests/test_pairing_config.py`
311311+312312+Verify config defaults, null-host synthesis, TTL clamp behavior, and owner-identity fallback against fixture journals that already contain `config/journal.json` (`think/utils.py:557-588`, `tests/conftest.py:77-84`).
313313+314314+### 8.2 `tests/test_pairing_tokens.py`
315315+316316+Verify token shape, TTL metadata, single-use semantics, expiry purge, and restart-local assumptions of the module singleton (`think/link/nonces.py:45-103`).
317317+318318+### 8.3 `tests/test_pairing_keys.py`
319319+320320+Verify ssh-ed25519 acceptance, rejection of ssh-rsa / ecdsa / malformed keys, session-key shape, SHA-256 hash format, and masking helpers.
321321+322322+### 8.4 `tests/test_pairing_devices.py`
323323+324324+Verify malformed-store recovery, atomic whole-file rewrites, public-key-keyed upsert behavior, `last_seen_at` updates, removal by id, and `status_view()` redaction pattern matching the push-device precedent (`think/push/devices.py:64-153`).
325325+326326+### 8.5 `tests/test_pairing_auth.py`
327327+328328+Verify `extract_bearer_token()`, `resolve_paired_device()`, `require_paired_device`, and `is_owner_authed()` against cookie, Basic Auth, and `trust_localhost` cases from `convey/root.py` (`convey/root.py:49-57`, `81-139`).
329329+330330+### 8.6 `tests/test_pairing_routes.py`
331331+332332+Verify the 5 JSON routes plus the UI route: JSON validation, allowlist expectations, mixed-auth acceptance on list/unpair, confirm success/error shapes, and polling-facing device list output.
333333+334334+### 8.7 `tests/test_pairing_integration.py`
335335+336336+Exercise the full owner-create -> confirm -> bearer-list -> heartbeat -> unpair round trip using `journal_copy`, with no fixture `pairing` stanza added (`tests/conftest.py:77-84`).
337337+338338+## 9. Security considerations
339339+340340+### Token reuse and replay
341341+342342+`consume_token()` is the only mutating read path and it enforces single-use + TTL. Tokens are not written to disk, so process restart invalidates all outstanding QR codes, which the scope explicitly accepts (`/home/jer/projects/extro/vpe/workspace/hop-solstone-pairing-wave-5-server.md:32`, `147-148`, `think/link/nonces.py:66-85`).
343343+344344+### Key validation and bounded input
345345+346346+Only ssh-ed25519 public keys are accepted. Rejecting all other SSH algorithms aligns the server with the iOS client contract and keeps the parser surface narrow (`/home/jer/projects/extro/vpe/workspace/hop-solstone-swift-wave-5-client.md:116-122`).
347347+348348+### Log masking
349349+350350+No raw `session_key` appears in logs, error messages, or `paired_devices.json`. Public keys are truncated when logged at `DEBUG`. This is stricter than the existing link pair route, which still echoes CSR parse failures in the JSON error body; pairing should not repeat that pattern (`apps/link/routes.py:230-235`, `/home/jer/projects/extro/vpe/workspace/hop-solstone-pairing-wave-5-server.md:147-148`).
351351+352352+### Restart semantics
353353+354354+Ephemeral pairing tokens disappear on restart; paired-device bearers remain valid because only their hashes are persisted. This split is acceptable at MVP scale and keeps the durable store strictly journal-config scoped (`/home/jer/projects/extro/vpe/workspace/hop-solstone-pairing-wave-5-server.md:32`, `41-54`, `147-148`).
355355+356356+### Wave 5.1 enforcement gap
357357+358358+This lode ships `convey/auth.py` and the pairing-only route protections, but it does not yet enforce paired-device auth on existing voice, push, or observer routes. That follow-up is explicit and documented below.
359359+360360+### Naming and UI risk
361361+362362+There is already a separate “pair” concept in the tunnel subsystem (`apps/link/routes.py:4-24`, `161-256`, `think/link/README.md:1-20`). To reduce operator confusion, the desktop page heading should read **“Pair a phone”** rather than the more ambiguous **“Pair a device.”** This is a UX pitfall to revisit if operators continue to confuse iOS app pairing with tunnel pairing.
363363+364364+## 10. Live validation
365365+366366+Sandbox smoke-test commands:
367367+368368+```sh
369369+BASE_URL=${BASE_URL:-http://127.0.0.1:5015}
370370+AUTH=${AUTH:-":$SOL_PASSWORD"}
371371+TMPDIR=$(mktemp -d)
372372+KEY_PREFIX="$TMPDIR/pairing-smoke"
373373+374374+ssh-keygen -q -t ed25519 -N '' -f "$KEY_PREFIX" >/dev/null
375375+PUBLIC_KEY=$(tr -d '\n' < "$KEY_PREFIX.pub")
376376+377377+CREATE_JSON=$(curl -u "$AUTH" \
378378+ -H 'Content-Type: application/json' \
379379+ -X POST "$BASE_URL/api/pairing/create" \
380380+ -d '{}')
381381+printf '%s\n' "$CREATE_JSON"
382382+383383+TOKEN=$(CREATE_JSON="$CREATE_JSON" python - <<'PY'
384384+import json, os
385385+print(json.loads(os.environ["CREATE_JSON"])["token"])
386386+PY
387387+)
388388+389389+CONFIRM_JSON=$(curl \
390390+ -H 'Content-Type: application/json' \
391391+ -X POST "$BASE_URL/api/pairing/confirm" \
392392+ -d '{
393393+ "token": "'"$TOKEN"'",
394394+ "public_key": "'"$PUBLIC_KEY"'",
395395+ "device_name": "Pairing Smoke iPhone",
396396+ "platform": "ios",
397397+ "bundle_id": "org.solpbc.solstone-swift",
398398+ "app_version": "0.1.0"
399399+ }' \
400400+ "$BASE_URL/api/pairing/confirm")
401401+printf '%s\n' "$CONFIRM_JSON"
402402+403403+SESSION_KEY=$(CONFIRM_JSON="$CONFIRM_JSON" python - <<'PY'
404404+import json, os
405405+print(json.loads(os.environ["CONFIRM_JSON"])["session_key"])
406406+PY
407407+)
408408+DEVICE_ID=$(CONFIRM_JSON="$CONFIRM_JSON" python - <<'PY'
409409+import json, os
410410+print(json.loads(os.environ["CONFIRM_JSON"])["device_id"])
411411+PY
412412+)
413413+414414+curl -H "Authorization: Bearer $SESSION_KEY" \
415415+ "$BASE_URL/api/pairing/devices"
416416+417417+curl -H "Authorization: Bearer $SESSION_KEY" \
418418+ -H 'Content-Type: application/json' \
419419+ -X POST "$BASE_URL/api/pairing/heartbeat" \
420420+ -d '{}'
421421+422422+curl -u "$AUTH" \
423423+ "$BASE_URL/api/pairing/devices"
424424+425425+curl -H "Authorization: Bearer $SESSION_KEY" \
426426+ -X DELETE "$BASE_URL/api/pairing/devices/$DEVICE_ID"
427427+```
428428+429429+Basic Auth uses only the password component, so `-u ":$SOL_PASSWORD"` is the portable form for owner-auth pairing routes (`convey/root.py:49-57`, `docs/design/push.md:609-646`).
430430+431431+## 11. Wave 5.1 follow-up
432432+433433+Wave 5.1 will apply `@require_paired_device` to existing iOS-facing routes, but **nothing in those files changes in this lode**.
434434+435435+Routes queued for Wave 5.1:
436436+437437+- `POST /api/voice/session`, `POST /api/voice/connect`, `POST /api/voice/refresh-brain`, `GET /api/voice/nav-hints`, `GET /api/voice/observer-actions`, `GET /api/voice/status` in `convey/voice.py` (`convey/voice.py:65-194`).
438438+- `POST /api/push/register`, `DELETE /api/push/register`, `GET /api/push/status`, `POST /api/push/test` in `convey/push.py` (`convey/push.py:53-124`).
439439+- `GET /api/voice/observer-actions` remains the immediate observer-actions surface requiring paired-device auth in addition to the pairing routes themselves (`convey/voice.py:170-176`).
440440+441441+The follow-up will be a targeted auth-enforcement sweep only; the helper surface is shipped in Wave 5 so that sweep can stay mechanical.
442442+443443+## 12. Open questions
444444+445445+- None — ready to implement.
446446+447447+## 13. Sources
448448+449449+- Wave 5 server scope: `/home/jer/projects/extro/vpe/workspace/hop-solstone-pairing-wave-5-server.md:1-161`
450450+- Wave 5 iOS companion scope: `/home/jer/projects/extro/vpe/workspace/hop-solstone-swift-wave-5-client.md:112-137`
451451+- Root blueprint pattern: `convey/voice.py:27-197`, `convey/push.py:24-127`, `convey/__init__.py:110-169`
452452+- Current auth gate and owner auth semantics: `convey/root.py:30-57`, `81-139`
453453+- Existing Bearer-auth prior art: `apps/observer/routes.py:63-70`, `503-538`, `apps/import/journal_sources.py:108-128`
454454+- Durable config/default behavior: `think/journal_default.json:2-53`, `think/utils.py:557-588`, `tests/conftest.py:77-84`
455455+- Push-device storage prior art: `think/push/devices.py:21-153`, `think/push/config.py:17-81`
456456+- Convey packaging/static constraints: `pyproject.toml:110-118`, `convey/__init__.py:118-133`
457457+- Link pairing naming collision and prior-art nonce store: `apps/link/routes.py:4-24`, `74-89`, `161-256`, `think/link/nonces.py:25-103`, `think/link/README.md:1-20`, `think/link/paths.py:74-95`
458458+- Design-doc structure precedent: `docs/design/push.md:1-654`, `docs/design/voice-server.md:1-465`
459459+- Layer-hygiene scope: `scripts/check_layer_hygiene.py:38-110`, `183-240`