personal memory agent
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

pairing: add api + ui blueprints and wire into convey

+471
+5
convey/__init__.py
··· 112 112 from think.push.runtime import start_push_runtime 113 113 from think.voice.runtime import start_voice_runtime 114 114 115 + from .pairing import pairing_bp, pairing_ui_bp 115 116 from .push import push_bp 116 117 from .voice import voice_bp 117 118 ··· 154 155 155 156 # Register push API blueprint 156 157 app.register_blueprint(push_bp) 158 + 159 + # Register pairing API and UI blueprints 160 + app.register_blueprint(pairing_bp) 161 + app.register_blueprint(pairing_ui_bp) 157 162 158 163 # Initialize and register app system 159 164 registry = AppRegistry()
+230
convey/pairing.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + """Root-level pairing API and UI.""" 5 + 6 + from __future__ import annotations 7 + 8 + import logging 9 + import time 10 + from typing import Any 11 + from urllib.parse import quote 12 + 13 + from flask import Blueprint, g, jsonify, render_template, request 14 + from werkzeug.exceptions import BadRequest 15 + 16 + from convey.auth import is_owner_authed, require_paired_device, resolve_paired_device 17 + from think.pairing.config import get_host_url, get_owner_identity 18 + from think.pairing.devices import ( 19 + Device, 20 + load_devices, 21 + register_device, 22 + remove_device, 23 + status_view, 24 + touch_last_seen, 25 + ) 26 + from think.pairing.keys import ( 27 + generate_session_key, 28 + hash_session_key, 29 + mask_session_key, 30 + validate_public_key, 31 + ) 32 + from think.pairing.tokens import consume_token, peek_token 33 + from think.pairing.tokens import create_token as mint_pairing_token 34 + from think.utils import get_journal 35 + 36 + logger = logging.getLogger(__name__) 37 + 38 + MAX_DEVICE_NAME_LENGTH = 128 39 + 40 + pairing_bp = Blueprint("pairing", __name__, url_prefix="/api/pairing") 41 + pairing_ui_bp = Blueprint("pairing_ui", __name__, url_prefix="/app/pairing") 42 + 43 + 44 + def _error(message: str, status: int, reason: str): 45 + return jsonify({"error": message, "reason": reason}), status 46 + 47 + 48 + def _optional_json_object() -> tuple[dict[str, Any], Any | None]: 49 + if not request.get_data(cache=True): 50 + return {}, None 51 + try: 52 + data = request.get_json(silent=False) 53 + except BadRequest: 54 + return {}, _error("request body must be valid JSON", 400, "invalid_json") 55 + if not isinstance(data, dict): 56 + return {}, _error("request body must be a JSON object", 400, "invalid_request") 57 + return data, None 58 + 59 + 60 + def _required_json_object() -> tuple[dict[str, Any], Any | None]: 61 + try: 62 + data = request.get_json(silent=False) 63 + except BadRequest: 64 + return {}, _error("request body must be valid JSON", 400, "invalid_json") 65 + if not isinstance(data, dict): 66 + return {}, _error("request body must be a JSON object", 400, "invalid_request") 67 + return data, None 68 + 69 + 70 + def _require_field(body: dict[str, Any], field: str) -> str | None: 71 + value = str(body.get(field) or "").strip() 72 + if value: 73 + return value 74 + return None 75 + 76 + 77 + def _resolve_owner_or_paired_device() -> tuple[Device | None, Any | None]: 78 + device = resolve_paired_device() 79 + if device is not None: 80 + g.paired_device = device 81 + return device, None 82 + if is_owner_authed(): 83 + return None, None 84 + return None, _error("owner or paired device required", 401, "auth_required") 85 + 86 + 87 + def _server_version() -> str: 88 + try: 89 + from think.version import __version__ 90 + 91 + return __version__ 92 + except Exception: 93 + return "unknown" 94 + 95 + 96 + @pairing_bp.post("/create") 97 + def create_token(): 98 + _, error = _optional_json_object() 99 + if error is not None: 100 + return error 101 + token = mint_pairing_token() 102 + host_url = get_host_url() 103 + pairing_url = f"solstone://pair?token={token.token}&host={quote(host_url, safe='')}" 104 + logger.info("pairing token minted expires_at=%s", token.expires_at) 105 + return jsonify( 106 + { 107 + "token": token.token, 108 + "expires_at": token.expires_at, 109 + "pairing_url": pairing_url, 110 + "qr_data": pairing_url, 111 + } 112 + ) 113 + 114 + 115 + @pairing_bp.post("/confirm") 116 + def confirm_pairing(): 117 + body, error = _required_json_object() 118 + if error is not None: 119 + return error 120 + 121 + token = _require_field(body, "token") 122 + public_key = _require_field(body, "public_key") 123 + device_name = _require_field(body, "device_name") 124 + platform = _require_field(body, "platform") 125 + bundle_id = _require_field(body, "bundle_id") 126 + app_version = _require_field(body, "app_version") 127 + 128 + if token is None: 129 + return _error("token is required", 400, "invalid_request") 130 + if public_key is None: 131 + return _error("public_key is required", 400, "invalid_request") 132 + if device_name is None: 133 + return _error("device_name is required", 400, "invalid_request") 134 + if len(device_name) > MAX_DEVICE_NAME_LENGTH: 135 + return _error("device_name is too long", 400, "invalid_request") 136 + if platform != "ios": 137 + return _error("platform must be ios", 400, "invalid_platform") 138 + if bundle_id is None: 139 + return _error("bundle_id is required", 400, "invalid_request") 140 + if app_version is None: 141 + return _error("app_version is required", 400, "invalid_request") 142 + 143 + try: 144 + normalized_public_key = validate_public_key(public_key) 145 + except ValueError: 146 + return _error( 147 + "public_key must be a valid ssh-ed25519 key", 148 + 400, 149 + "invalid_public_key", 150 + ) 151 + 152 + now = int(time.time()) 153 + entry = peek_token(token, now=now) 154 + if entry is None: 155 + return _error("pairing token is invalid", 400, "invalid_token") 156 + if entry.expires_at <= now: 157 + return _error("pairing token expired", 410, "token_expired") 158 + if entry.consumed_at is not None: 159 + return _error("pairing token already used", 410, "token_consumed") 160 + 161 + consumed = consume_token(token, now=now) 162 + if consumed is None: 163 + entry = peek_token(token, now=now) 164 + if entry is not None and entry.expires_at <= now: 165 + return _error("pairing token expired", 410, "token_expired") 166 + if entry is not None and entry.consumed_at is not None: 167 + return _error("pairing token already used", 410, "token_consumed") 168 + return _error("pairing token is invalid", 400, "invalid_token") 169 + 170 + session_key = generate_session_key() 171 + device = register_device( 172 + name=device_name, 173 + platform=platform, 174 + public_key=normalized_public_key, 175 + session_key_hash=hash_session_key(session_key), 176 + bundle_id=bundle_id, 177 + app_version=app_version, 178 + ) 179 + logger.info( 180 + "pairing confirmed device_id=%s platform=%s session_key=%s", 181 + device["id"], 182 + device["platform"], 183 + mask_session_key(session_key), 184 + ) 185 + return jsonify( 186 + { 187 + "session_key": session_key, 188 + "device_id": device["id"], 189 + "journal_root": str(get_journal()), 190 + "owner_identity": get_owner_identity(), 191 + "server_version": _server_version(), 192 + } 193 + ) 194 + 195 + 196 + @pairing_bp.post("/heartbeat") 197 + @require_paired_device 198 + def heartbeat(): 199 + _, error = _optional_json_object() 200 + if error is not None: 201 + return error 202 + if not touch_last_seen(g.paired_device["id"]): 203 + return _error("paired device not found", 404, "device_not_found") 204 + return jsonify({"ok": True}) 205 + 206 + 207 + @pairing_bp.get("/devices") 208 + def list_devices(): 209 + _, error = _resolve_owner_or_paired_device() 210 + if error is not None: 211 + return error 212 + return jsonify({"devices": [status_view(device) for device in load_devices()]}) 213 + 214 + 215 + @pairing_bp.delete("/devices/<device_id>") 216 + def unpair_device(device_id: str): 217 + _, error = _resolve_owner_or_paired_device() 218 + if error is not None: 219 + return error 220 + if not remove_device(device_id): 221 + return _error("paired device not found", 404, "device_not_found") 222 + return jsonify({"unpaired": True}) 223 + 224 + 225 + @pairing_ui_bp.get("/") 226 + def index(): 227 + return render_template("pairing.html") 228 + 229 + 230 + __all__ = ["pairing_bp", "pairing_ui_bp"]
+5
convey/root.py
··· 105 105 "app:import.ingest_facets", 106 106 "app:import.ingest_imports", 107 107 "app:import.ingest_config", 108 + # Pairing endpoints with explicit bearer or mixed auth 109 + "pairing.confirm_pairing", 110 + "pairing.heartbeat", 111 + "pairing.list_devices", 112 + "pairing.unpair_device", 108 113 }: 109 114 return None 110 115
+231
tests/test_pairing_routes.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + from __future__ import annotations 5 + 6 + import json 7 + 8 + import pytest 9 + from cryptography.hazmat.primitives import serialization 10 + from cryptography.hazmat.primitives.asymmetric import ed25519 11 + 12 + from convey import create_app 13 + from think.pairing.devices import find_device_by_id, load_devices 14 + from think.pairing.tokens import create_token as mint_pairing_token 15 + 16 + 17 + def _write_config(journal_copy, payload: dict) -> None: 18 + (journal_copy / "config" / "journal.json").write_text( 19 + json.dumps(payload), encoding="utf-8" 20 + ) 21 + 22 + 23 + def _read_config(journal_copy) -> dict: 24 + return json.loads((journal_copy / "config" / "journal.json").read_text("utf-8")) 25 + 26 + 27 + def _owner_login(client) -> None: 28 + with client.session_transaction() as session: 29 + session["logged_in"] = True 30 + session.permanent = True 31 + 32 + 33 + def _public_key() -> str: 34 + return ( 35 + ed25519.Ed25519PrivateKey.generate() 36 + .public_key() 37 + .public_bytes( 38 + encoding=serialization.Encoding.OpenSSH, 39 + format=serialization.PublicFormat.OpenSSH, 40 + ) 41 + .decode("utf-8") 42 + ) 43 + 44 + 45 + @pytest.fixture 46 + def pairing_app(journal_copy): 47 + app = create_app(str(journal_copy)) 48 + app.config["TESTING"] = True 49 + return app 50 + 51 + 52 + @pytest.fixture 53 + def pairing_client(pairing_app): 54 + client = pairing_app.test_client() 55 + _owner_login(client) 56 + return client 57 + 58 + 59 + def test_create_token_happy_path(pairing_client): 60 + response = pairing_client.post("/api/pairing/create", json={}) 61 + 62 + body = response.get_json() 63 + assert response.status_code == 200 64 + assert body["token"].startswith("ptk_") 65 + assert body["expires_at"] > 0 66 + assert body["pairing_url"].startswith("solstone://pair?token=") 67 + assert body["qr_data"] == body["pairing_url"] 68 + 69 + 70 + def test_create_token_rejects_non_object(pairing_client): 71 + response = pairing_client.post("/api/pairing/create", json=["bad"]) 72 + 73 + assert response.status_code == 400 74 + assert response.get_json() == { 75 + "error": "request body must be a JSON object", 76 + "reason": "invalid_request", 77 + } 78 + 79 + 80 + def test_confirm_pairing_happy_path(pairing_client, journal_copy): 81 + config = _read_config(journal_copy) 82 + config["identity"] = {"name": "Sol", "preferred": "Preferred Sol"} 83 + _write_config(journal_copy, config) 84 + 85 + create_response = pairing_client.post("/api/pairing/create", json={}) 86 + token = create_response.get_json()["token"] 87 + public_key = _public_key() 88 + 89 + response = pairing_client.post( 90 + "/api/pairing/confirm", 91 + json={ 92 + "token": token, 93 + "public_key": public_key, 94 + "device_name": "Phone", 95 + "platform": "ios", 96 + "bundle_id": "org.solpbc.solstone-swift", 97 + "app_version": "0.1.0", 98 + }, 99 + ) 100 + 101 + body = response.get_json() 102 + assert response.status_code == 200 103 + assert body["session_key"].startswith("dsk_") 104 + assert body["device_id"].startswith("dev_") 105 + assert body["journal_root"] == str(journal_copy.resolve()) 106 + assert body["owner_identity"] == "Preferred Sol" 107 + assert body["server_version"] == "unknown" 108 + stored = find_device_by_id(body["device_id"]) 109 + assert stored is not None 110 + assert stored["session_key_hash"] != body["session_key"] 111 + 112 + 113 + def test_confirm_pairing_rejects_bad_public_key(pairing_client): 114 + token = pairing_client.post("/api/pairing/create", json={}).get_json()["token"] 115 + 116 + response = pairing_client.post( 117 + "/api/pairing/confirm", 118 + json={ 119 + "token": token, 120 + "public_key": "ssh-ed25519 bad", 121 + "device_name": "Phone", 122 + "platform": "ios", 123 + "bundle_id": "org.solpbc.solstone-swift", 124 + "app_version": "0.1.0", 125 + }, 126 + ) 127 + 128 + assert response.status_code == 400 129 + assert response.get_json() == { 130 + "error": "public_key must be a valid ssh-ed25519 key", 131 + "reason": "invalid_public_key", 132 + } 133 + 134 + 135 + def test_confirm_pairing_distinguishes_expired_and_consumed_tokens( 136 + pairing_client, monkeypatch 137 + ): 138 + expired = mint_pairing_token(ttl_seconds=60, now=1000) 139 + monkeypatch.setattr("convey.pairing.time.time", lambda: 1060) 140 + 141 + expired_response = pairing_client.post( 142 + "/api/pairing/confirm", 143 + json={ 144 + "token": expired.token, 145 + "public_key": _public_key(), 146 + "device_name": "Phone", 147 + "platform": "ios", 148 + "bundle_id": "org.solpbc.solstone-swift", 149 + "app_version": "0.1.0", 150 + }, 151 + ) 152 + 153 + assert expired_response.status_code == 410 154 + assert expired_response.get_json()["reason"] == "token_expired" 155 + 156 + token = pairing_client.post("/api/pairing/create", json={}).get_json()["token"] 157 + body = { 158 + "token": token, 159 + "public_key": _public_key(), 160 + "device_name": "Phone", 161 + "platform": "ios", 162 + "bundle_id": "org.solpbc.solstone-swift", 163 + "app_version": "0.1.0", 164 + } 165 + assert pairing_client.post("/api/pairing/confirm", json=body).status_code == 200 166 + 167 + consumed_response = pairing_client.post("/api/pairing/confirm", json=body) 168 + 169 + assert consumed_response.status_code == 410 170 + assert consumed_response.get_json()["reason"] == "token_consumed" 171 + 172 + 173 + def test_heartbeat_requires_valid_bearer(pairing_client): 174 + response = pairing_client.post("/api/pairing/heartbeat") 175 + 176 + assert response.status_code == 401 177 + assert response.get_json() == { 178 + "error": "paired device required", 179 + "reason": "auth_required", 180 + } 181 + 182 + 183 + def test_list_devices_allows_bearer_or_owner(pairing_client): 184 + confirm = pairing_client.post( 185 + "/api/pairing/confirm", 186 + json={ 187 + "token": pairing_client.post("/api/pairing/create", json={}).get_json()[ 188 + "token" 189 + ], 190 + "public_key": _public_key(), 191 + "device_name": "Phone", 192 + "platform": "ios", 193 + "bundle_id": "org.solpbc.solstone-swift", 194 + "app_version": "0.1.0", 195 + }, 196 + ).get_json() 197 + 198 + owner_response = pairing_client.get("/api/pairing/devices") 199 + bearer_response = pairing_client.get( 200 + "/api/pairing/devices", 201 + headers={"Authorization": f"Bearer {confirm['session_key']}"}, 202 + ) 203 + anon_client = pairing_client.application.test_client() 204 + anon_response = anon_client.get( 205 + "/api/pairing/devices", headers={"X-Forwarded-For": "1.2.3.4"} 206 + ) 207 + 208 + assert owner_response.status_code == 200 209 + assert bearer_response.status_code == 200 210 + assert owner_response.get_json() == bearer_response.get_json() 211 + assert owner_response.get_json()["devices"] == [ 212 + { 213 + "id": confirm["device_id"], 214 + "name": "Phone", 215 + "platform": "ios", 216 + "paired_at": load_devices()[0]["paired_at"], 217 + "last_seen_at": None, 218 + } 219 + ] 220 + assert anon_response.status_code == 401 221 + assert anon_response.get_json()["reason"] == "auth_required" 222 + 223 + 224 + def test_unpair_device_returns_404_for_unknown_device(pairing_client): 225 + response = pairing_client.delete("/api/pairing/devices/dev_missing") 226 + 227 + assert response.status_code == 404 228 + assert response.get_json() == { 229 + "error": "paired device not found", 230 + "reason": "device_not_found", 231 + }