diff --git a/docs/design/lodes/observer-over-pl.md b/docs/design/lodes/observer-over-pl.md index d20617999..6a7bb7ade 100644 --- a/docs/design/lodes/observer-over-pl.md +++ b/docs/design/lodes/observer-over-pl.md @@ -1,5 +1,9 @@ # observer-over-pl +> Superseded note, link-mess step 2: the role-at-pair-time mechanism described +> below is historical. Observers now self-register with `POST +> /app/observer/register`; pairing is role-less except for the `peer` path. + ## Summary This lode adds a paired-link (`pl`) transport path to the observer client while @@ -46,6 +50,8 @@ pre-dial credential-file errors. ### 2. Cross-app write routing +Superseded by link-mess step 2: link pairing no longer mints observer records. + Chosen: `solstone/apps/link/routes.py:_complete_pairing()` will call `solstone.apps.observer.utils.mint_pl_observer_record()` only when `consumed.role == "observer"`. diff --git a/solstone/apps/link/call.py b/solstone/apps/link/call.py index 92b6c9557..62e8c5779 100644 --- a/solstone/apps/link/call.py +++ b/solstone/apps/link/call.py @@ -18,16 +18,13 @@ from solstone.convey.reasons import PAIRED_DEVICE_NOT_FOUND from solstone.think.convey_client import ConveyClientError, convey_cli, get_client app = typer.Typer( - help="Link — tunnel service for reaching this solstone from paired phones." + help="Link — tunnel service for reaching this solstone from linked systems." ) PAIR_TIMEOUT_SECONDS = 300 -VALID_ROLES = {"phone", "observer", "peer"} -ROLE_HEADINGS = { - "phone": "Phones:", - "observer": "Observers:", - "peer": "Peers:", -} +VALID_ROLES = {"", "phone", "observer", "peer"} +LINKED_SYSTEMS_HEADING = "Linked systems:" +PEERS_HEADING = "Peers:" def _detect_lan_ip() -> str | None: @@ -89,15 +86,14 @@ def _relative_time(iso: str | None) -> str: @convey_cli def pair( device_label: str = typer.Option( - ..., "--device-label", help="Label for the phone being paired" + ..., "--device-label", help="Label for the linked system being paired" ), - as_role: str = typer.Option( - "phone", + as_role: str | None = typer.Option( + None, "--as", help=( - "Role tag stored with the pairing — identity metadata that future route " - "handlers will key on (not just CLI grouping). One of: phone, observer, " - "peer." + "Optional tag for the linked system. Links are role-less by default; " + "only peer has special behavior. One of: phone, observer, peer." ), ), convey_host: str = typer.Option( @@ -113,19 +109,22 @@ def pair( timeout_seconds: int = typer.Option( PAIR_TIMEOUT_SECONDS, "--timeout", - help="How long to wait for the phone before giving up", + help="How long to wait for the linked system before giving up", ), ) -> None: """Mint a one-shot nonce, print the pair URL + QR-ready payload, wait for completion.""" - if as_role not in VALID_ROLES: + if as_role is not None and as_role not in VALID_ROLES: typer.echo("invalid role; expected one of: phone, observer, peer", err=True) raise typer.Exit(2) client = get_client() + payload = {"device_label": device_label} + if as_role is not None: + payload["role"] = as_role mint = client.request( "POST", "/app/link/api/pair/mint", - json={"device_label": device_label, "role": as_role}, + json=payload, ) value = mint["nonce"] manual_code = mint["manual_code"] @@ -139,9 +138,9 @@ def pair( typer.echo(f"manual code: {manual_code}") typer.echo(f"Pair URL: {url}") typer.echo(f"CA fingerprint: sha256:{ca_fp}") - typer.echo(f"Device: {device_label} (role: {as_role})") + typer.echo(f"Device: {device_label}{' (peer)' if as_role == 'peer' else ''}") typer.echo("") - typer.echo("Waiting for phone…") + typer.echo("Waiting for linked system…") before = { d["fingerprint"] @@ -154,7 +153,8 @@ def pair( new_entries = [d for d in devices if d["fingerprint"] not in before] if new_entries: entry = new_entries[-1] - typer.echo(f"Paired: {entry['device_label']} (role: {entry['role']})") + suffix = " (peer)" if entry["role"] == "peer" else "" + typer.echo(f"Paired: {entry['device_label']}{suffix}") typer.echo(f" fingerprint: {entry['fingerprint']}") typer.echo(f" paired_at: {entry['paired_at']}") raise typer.Exit(0) @@ -180,19 +180,26 @@ def list_devices() -> None: if not devices: typer.echo("No devices linked yet.") return - grouped = {role: [] for role in ROLE_HEADINGS} + linked_systems = [] + peers = [] for device in devices: - grouped.setdefault(device["role"], []).append(device) + # call.py is a pure HTTP client, so it cannot import link.auth.is_peer. + if device.get("role") == "peer": + peers.append(device) + else: + linked_systems.append(device) printed_section = False - for role, heading in ROLE_HEADINGS.items(): - role_entries = grouped[role] - if not role_entries: + for heading, entries in ( + (LINKED_SYSTEMS_HEADING, linked_systems), + (PEERS_HEADING, peers), + ): + if not entries: continue if printed_section: typer.echo("") typer.echo(heading) - for device in role_entries: + for device in entries: typer.echo( f"- {device['device_label']}" f" — added {_relative_time(device['paired_at'])}" diff --git a/solstone/apps/link/routes.py b/solstone/apps/link/routes.py index ad2b5498d..e3757183f 100644 --- a/solstone/apps/link/routes.py +++ b/solstone/apps/link/routes.py @@ -58,7 +58,6 @@ from solstone.apps.link.relay_link import ( compute_current_totp, encode_relay_pair_link, ) -from solstone.apps.observer.utils import mint_pl_observer_record, revoke_observer_record from solstone.apps.utils import log_app_action from solstone.convey import emit from solstone.convey.bridge import get_cached_state @@ -80,7 +79,7 @@ from solstone.convey.reasons import ( PAIRING_REQUEST_INVALID, ) from solstone.convey.utils import error_response -from solstone.think.link.auth import AuthorizedClients, ClientEntry +from solstone.think.link.auth import AuthorizedClients, ClientEntry, is_peer from solstone.think.link.ca import ( generate_nonce, generate_relay_nonce, @@ -119,7 +118,7 @@ from solstone.think.utils import get_config, get_journal, now_ms logger = logging.getLogger(__name__) MANUAL_CODE_RE = re.compile(rf"^[0-9A-HJKMNP-TV-Z]{{{MANUAL_CODE_LEN}}}$") _SENDER_INSTANCE_ID_RE = re.compile(r"^[A-Za-z0-9-]{1,256}$") -VALID_ROLES = {"phone", "observer", "peer"} +VALID_ROLES = {"", "phone", "observer", "peer"} # The watcher emits only lan/ula today; vpn stays empty until a scope is wired. VPN_SCOPES = {"vpn"} journal_sources = import_module("solstone.apps.import.journal_sources") @@ -468,7 +467,8 @@ def pair_start() -> Any: device_label = ( str(payload.get("device_label") or "").strip() or _default_device_label() ) - role = payload.get("role", "phone") + raw_role = payload.get("role", "") + role = "" if raw_role is None else raw_role if not isinstance(role, str) or role not in VALID_ROLES: return error_response(PAIRING_REQUEST_INVALID, detail="invalid role") @@ -565,10 +565,9 @@ def _complete_pairing( if endpoints: response["local_endpoints"] = [endpoint_to_dict(ep) for ep in endpoints] - observer_record_path = None journal_source_record_path = None try: - if consumed.role == "peer": + if is_peer(consumed.role): journal_source_record_path = mint_pl_journal_source_record( fingerprint=fingerprint, device_label=device_label, @@ -576,26 +575,15 @@ def _complete_pairing( peer_instance_id=sender_instance_id, ) create_state_directory(Path(get_journal()), journal_source_record_path.stem) - if consumed.role == "observer": - observer_record_path = mint_pl_observer_record( - fingerprint=fingerprint, - device_label=device_label, - paired_at=paired_at, - ) _authorized().add( fingerprint=fingerprint, device_label=device_label, instance_id=state.instance_id, - role=consumed.role, + role="peer" if is_peer(consumed.role) else "", paired_at=paired_at, network=network, ) except Exception: - if observer_record_path is not None: - try: - observer_record_path.unlink() - except FileNotFoundError: - pass if journal_source_record_path is not None: try: journal_source_record_path.unlink() @@ -823,33 +811,7 @@ def unpair() -> Any: short_fp = fp_hex[:16] role = entry.role - if role == "phone": - removed = authorized.remove(fingerprint) - if not removed: - logger.warning( - "unpair: phone entry %s already absent from authorized_clients", - short_fp, - ) - elif role == "observer": - try: - revoke_observer_record(short_fp) - except ValueError as exc: - msg = str(exc) - if "already revoked" in msg: - logger.warning("unpair: observer %s already revoked: %s", short_fp, msg) - else: - logger.warning( - "unpair: observer record missing for %s: %s", short_fp, msg - ) - authorized.remove(fingerprint) - except RuntimeError as exc: - logger.error( - "unpair: failed to save observer record for %s: %s", - short_fp, - exc, - ) - authorized.remove(fingerprint) - elif role == "peer": + if is_peer(role): source = load_journal_source_by_fingerprint(fingerprint) if source is None: logger.warning("unpair: peer journal source missing for %s", short_fp) @@ -874,11 +836,6 @@ def unpair() -> Any: ) authorized.remove(fingerprint) else: - logger.warning( - "unpair: unexpected role %r for entry %s; treating as phone", - role, - short_fp, - ) authorized.remove(fingerprint) return jsonify({"unpaired": fingerprint}) diff --git a/solstone/apps/link/tests/test_api_devices_role.py b/solstone/apps/link/tests/test_api_devices_role.py index 7565a5a6e..5ecfa259d 100644 --- a/solstone/apps/link/tests/test_api_devices_role.py +++ b/solstone/apps/link/tests/test_api_devices_role.py @@ -12,8 +12,8 @@ from solstone.think.link.paths import authorized_clients_path def test_devices_api_includes_role_field(link_env) -> None: env = link_env() store = AuthorizedClients(authorized_clients_path()) - store.add("sha256:phone", "phone", "inst-1", role="phone") - store.add("sha256:observer", "observer", "inst-1", role="observer") + store.add("sha256:linked", "linked", "inst-1") + store.add("sha256:peer", "peer", "inst-1", role="peer") response = env.client.get("/app/link/api/devices") @@ -21,12 +21,12 @@ def test_devices_api_includes_role_field(link_env) -> None: devices = response.get_json()["devices"] roles_by_label = {device["device_label"]: device["role"] for device in devices} assert roles_by_label == { - "phone": "phone", - "observer": "observer", + "linked": "", + "peer": "peer", } -def test_devices_api_legacy_entry_defaults_to_phone(link_env) -> None: +def test_devices_api_legacy_entry_defaults_to_role_less(link_env) -> None: env = link_env() path = authorized_clients_path() path.write_text( @@ -50,4 +50,4 @@ def test_devices_api_legacy_entry_defaults_to_phone(link_env) -> None: assert response.status_code == 200 devices = response.get_json()["devices"] assert len(devices) == 1 - assert devices[0]["role"] == "phone" + assert devices[0]["role"] == "" diff --git a/solstone/apps/link/tests/test_call_unpair_record_revoke_parity.py b/solstone/apps/link/tests/test_call_unpair_record_revoke_parity.py index ae39ca65d..0bd6974c5 100644 --- a/solstone/apps/link/tests/test_call_unpair_record_revoke_parity.py +++ b/solstone/apps/link/tests/test_call_unpair_record_revoke_parity.py @@ -11,11 +11,6 @@ from typer.testing import CliRunner from solstone.apps.link import call as link_call from solstone.apps.link import routes as link_routes -from solstone.apps.observer.utils import ( - load_observer_by_fingerprint, - mint_pl_observer_record, - save_observer, -) from solstone.think.convey_client import ConveyClient from solstone.think.link.auth import AuthorizedClients from solstone.think.link.paths import authorized_clients_path @@ -27,7 +22,6 @@ save_journal_source = journal_sources.save_journal_source PAIRED_AT = "2026-05-20T00:00:00Z" PHONE_FINGERPRINT = "sha256:" + ("a" * 64) -OBSERVER_FINGERPRINT = "sha256:" + ("b" * 64) PEER_FINGERPRINT = "sha256:" + ("c" * 64) UNKNOWN_ROLE_FINGERPRINT = "sha256:" + ("d" * 64) @@ -40,9 +34,7 @@ def _authorized() -> AuthorizedClients: return AuthorizedClients(authorized_clients_path()) -def _add_authorized( - fingerprint: str, device_label: str, *, role: str = "phone" -) -> None: +def _add_authorized(fingerprint: str, device_label: str, *, role: str = "") -> None: _authorized().add( fingerprint, device_label, @@ -82,11 +74,10 @@ def test_unpair_phone_by_fingerprint_removes_authorized(link_env, monkeypatch) - assert result.exit_code == 0 assert "Unpaired." in result.stdout assert _authorized().is_authorized(PHONE_FINGERPRINT) is False - assert load_observer_by_fingerprint(PHONE_FINGERPRINT) is None assert load_journal_source_by_fingerprint(PHONE_FINGERPRINT) is None -def test_unpair_unknown_role_treats_as_phone_and_warns( +def test_unpair_unknown_role_removes_authorized_without_warning( link_env, caplog, monkeypatch, @@ -101,125 +92,12 @@ def test_unpair_unknown_role_treats_as_phone_and_warns( assert result.exit_code == 0 assert "Unpaired." in result.stdout assert _authorized().is_authorized(UNKNOWN_ROLE_FINGERPRINT) is False - assert "unexpected role" in caplog.text - assert "tablet" in caplog.text - - -def test_unpair_observer_revokes_record_and_does_not_double_remove( - link_env, - monkeypatch, -) -> None: - env = link_env() - _configure_cli(env, monkeypatch) - mint_pl_observer_record( - fingerprint=OBSERVER_FINGERPRINT, - device_label="observer", - paired_at=PAIRED_AT, - ) - _add_authorized(OBSERVER_FINGERPRINT, "observer", role="observer") - original_remove = AuthorizedClients.remove - remove_calls = [] - - def spy_remove(self, fingerprint: str) -> bool: - if fingerprint == OBSERVER_FINGERPRINT: - remove_calls.append(fingerprint) - return original_remove(self, fingerprint) - - monkeypatch.setattr(AuthorizedClients, "remove", spy_remove) - - result = _invoke("observer") - - assert result.exit_code == 0 - assert "Unpaired." in result.stdout - assert remove_calls == [OBSERVER_FINGERPRINT] - assert _authorized().is_authorized(OBSERVER_FINGERPRINT) is False - observer = load_observer_by_fingerprint(OBSERVER_FINGERPRINT) - assert observer is not None - assert observer["revoked"] is True - assert observer["revoked_at"] is not None - - -def test_unpair_observer_already_revoked_removes_authorized_and_warns( - link_env, - caplog, - monkeypatch, -) -> None: - env = link_env() - _configure_cli(env, monkeypatch) - mint_pl_observer_record( - fingerprint=OBSERVER_FINGERPRINT, - device_label="observer-revoked", - paired_at=PAIRED_AT, - ) - observer = load_observer_by_fingerprint(OBSERVER_FINGERPRINT) - assert observer is not None - observer["revoked"] = True - observer["revoked_at"] = 123 - assert save_observer(observer) is True - _add_authorized(OBSERVER_FINGERPRINT, "observer-revoked", role="observer") - caplog.set_level(logging.WARNING, logger="solstone.apps.link.routes") - - result = _invoke(OBSERVER_FINGERPRINT) - - assert result.exit_code == 0 - assert "Unpaired." in result.stdout - assert _authorized().is_authorized(OBSERVER_FINGERPRINT) is False - observer = load_observer_by_fingerprint(OBSERVER_FINGERPRINT) - assert observer is not None - assert observer["revoked"] is True - assert observer["revoked_at"] == 123 - assert "already revoked" in caplog.text - - -def test_unpair_observer_missing_record_removes_authorized_and_warns( - link_env, - caplog, - monkeypatch, -) -> None: - env = link_env() - _configure_cli(env, monkeypatch) - _add_authorized(OBSERVER_FINGERPRINT, "observer-missing", role="observer") - caplog.set_level(logging.WARNING, logger="solstone.apps.link.routes") - - result = _invoke(OBSERVER_FINGERPRINT) - - assert result.exit_code == 0 - assert "Unpaired." in result.stdout - assert _authorized().is_authorized(OBSERVER_FINGERPRINT) is False - assert load_observer_by_fingerprint(OBSERVER_FINGERPRINT) is None - assert "observer record missing" in caplog.text - - -def test_unpair_observer_save_failure_removes_authorized_and_logs_error( - link_env, - caplog, - monkeypatch, -) -> None: - env = link_env() - _configure_cli(env, monkeypatch) - mint_pl_observer_record( - fingerprint=OBSERVER_FINGERPRINT, - device_label="observer-save-fails", - paired_at=PAIRED_AT, - ) - _add_authorized(OBSERVER_FINGERPRINT, "observer-save-fails", role="observer") - monkeypatch.setattr( - "solstone.apps.observer.utils.save_observer", - lambda *_a, **_kw: False, - ) - caplog.set_level(logging.ERROR, logger="solstone.apps.link.routes") - - result = _invoke("observer-save-fails") - - assert result.exit_code == 0 - assert "Unpaired." in result.stdout - assert _authorized().is_authorized(OBSERVER_FINGERPRINT) is False - observer = load_observer_by_fingerprint(OBSERVER_FINGERPRINT) - assert observer is not None - assert observer.get("revoked") is not True - assert observer.get("revoked_at") is None - assert _short(OBSERVER_FINGERPRINT) in caplog.text - assert "failed to save observer record" in caplog.text + assert [ + record + for record in caplog.records + if record.name == "solstone.apps.link.routes" + and record.levelno >= logging.WARNING + ] == [] def test_unpair_peer_revokes_source_removes_authorized_and_logs_action( diff --git a/solstone/apps/link/tests/test_pair_role_propagation.py b/solstone/apps/link/tests/test_pair_role_propagation.py index 76132d822..c26999567 100644 --- a/solstone/apps/link/tests/test_pair_role_propagation.py +++ b/solstone/apps/link/tests/test_pair_role_propagation.py @@ -31,8 +31,15 @@ def _start_pair(env, role: str) -> dict: return response.get_json() -@pytest.mark.parametrize("role", ["phone", "observer", "peer"]) -def test_pair_route_persists_consumed_nonce_role(link_env, role: str) -> None: +@pytest.mark.parametrize( + ("role", "expected_role"), + [("phone", ""), ("observer", ""), ("peer", "peer")], +) +def test_pair_route_normalizes_consumed_nonce_role( + link_env, + role: str, + expected_role: str, +) -> None: env = link_env() started = _start_pair(env, role) @@ -44,11 +51,18 @@ def test_pair_route_persists_consumed_nonce_role(link_env, role: str) -> None: assert response.status_code == 200 entries = link_routes._authorized().snapshot() assert len(entries) == 1 - assert entries[0].role == role + assert entries[0].role == expected_role -@pytest.mark.parametrize("role", ["phone", "observer", "peer"]) -def test_by_code_route_persists_consumed_nonce_role(link_env, role: str) -> None: +@pytest.mark.parametrize( + ("role", "expected_role"), + [("phone", ""), ("observer", ""), ("peer", "peer")], +) +def test_by_code_route_normalizes_consumed_nonce_role( + link_env, + role: str, + expected_role: str, +) -> None: env = link_env() started = _start_pair(env, role) @@ -60,4 +74,4 @@ def test_by_code_route_persists_consumed_nonce_role(link_env, role: str) -> None assert response.status_code == 200 entries = link_routes._authorized().snapshot() assert len(entries) == 1 - assert entries[0].role == role + assert entries[0].role == expected_role diff --git a/solstone/apps/link/tests/test_pair_start.py b/solstone/apps/link/tests/test_pair_start.py index c7005ad39..51c5cd462 100644 --- a/solstone/apps/link/tests/test_pair_start.py +++ b/solstone/apps/link/tests/test_pair_start.py @@ -189,19 +189,20 @@ def test_pair_start_spl_uses_thirty_second_expiry_and_nonce_ttl(link_env) -> Non assert snap[0].expires_at - snap[0].issued_at == TOTP_STEP_SECONDS -def test_pair_start_spl_keeps_role_home_private(link_env, monkeypatch) -> None: +def test_pair_start_spl_keeps_role_less_home_private(link_env, monkeypatch) -> None: env = link_env(posture="spl", totp_secret="GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ") monkeypatch.setattr(link_routes, "generate_relay_nonce", lambda: "00" * 16) response = env.client.post( "/app/link/pair-start", - json={"device_label": "Observer", "role": "observer"}, + json={"device_label": "Linked System"}, ) assert response.status_code == 200 payload = response.get_json() - assert link_routes._nonces().snapshot()[0].role == "observer" + assert link_routes._nonces().snapshot()[0].role == "" assert b"observer" not in _decode_pair_link(payload["pair_link"]) + assert b"phone" not in _decode_pair_link(payload["pair_link"]) def test_pair_start_spl_missing_totp_secret_errors_without_nonce(link_env) -> None: diff --git a/solstone/apps/link/tests/test_pair_start_role.py b/solstone/apps/link/tests/test_pair_start_role.py index 8e4bc4d04..2ef31fd45 100644 --- a/solstone/apps/link/tests/test_pair_start_role.py +++ b/solstone/apps/link/tests/test_pair_start_role.py @@ -8,8 +8,20 @@ import pytest from solstone.apps.link import routes as link_routes -@pytest.mark.parametrize("role", ["phone", "observer", "peer"]) -def test_pair_start_stores_role(link_env, role: str) -> None: +@pytest.mark.parametrize( + ("role", "expected_role"), + [ + ("", ""), + ("phone", "phone"), + ("observer", "observer"), + ("peer", "peer"), + ], +) +def test_pair_start_stores_accepted_role( + link_env, + role: str, + expected_role: str, +) -> None: env = link_env() response = env.client.post( @@ -20,10 +32,10 @@ def test_pair_start_stores_role(link_env, role: str) -> None: assert response.status_code == 200 nonces = link_routes._nonces().snapshot() assert len(nonces) == 1 - assert nonces[0].role == role + assert nonces[0].role == expected_role -def test_pair_start_default_role_phone(link_env) -> None: +def test_pair_start_default_role_less(link_env) -> None: env = link_env() response = env.client.post( @@ -34,10 +46,24 @@ def test_pair_start_default_role_phone(link_env) -> None: assert response.status_code == 200 nonces = link_routes._nonces().snapshot() assert len(nonces) == 1 - assert nonces[0].role == "phone" + assert nonces[0].role == "" -@pytest.mark.parametrize("role", ["bogus", "Observer", "", None, 42]) +def test_pair_start_null_role_is_role_less(link_env) -> None: + env = link_env() + + response = env.client.post( + "/app/link/pair-start", + json={"device_label": "x", "role": None}, + ) + + assert response.status_code == 200 + nonces = link_routes._nonces().snapshot() + assert len(nonces) == 1 + assert nonces[0].role == "" + + +@pytest.mark.parametrize("role", ["bogus", "Observer", 42]) def test_pair_start_rejects_invalid_role(link_env, role: object) -> None: env = link_env() diff --git a/solstone/apps/link/tests/test_peer_journal_source_pairing.py b/solstone/apps/link/tests/test_peer_journal_source_pairing.py index 4c25f5b78..8caee6f11 100644 --- a/solstone/apps/link/tests/test_peer_journal_source_pairing.py +++ b/solstone/apps/link/tests/test_peer_journal_source_pairing.py @@ -205,15 +205,17 @@ def test_phone_role_pairing_does_not_mint_journal_source(link_env) -> None: assert _journal_source_paths(env) == [] entries = link_routes._authorized().snapshot() assert len(entries) == 1 - assert entries[0].role == "phone" + assert entries[0].role == "" -def test_observer_role_pairing_mints_observer_not_journal_source(link_env) -> None: +def test_observer_role_pairing_does_not_mint_observer_or_journal_source( + link_env, +) -> None: env = link_env() response = _pair(env, role="observer", label="Observer Laptop") - assert load_observer_by_fingerprint(response["fingerprint"]) is not None + assert load_observer_by_fingerprint(response["fingerprint"]) is None assert ( journal_sources.load_journal_source_by_fingerprint(response["fingerprint"]) is None @@ -221,7 +223,7 @@ def test_observer_role_pairing_mints_observer_not_journal_source(link_env) -> No assert _journal_source_paths(env) == [] entries = link_routes._authorized().snapshot() assert len(entries) == 1 - assert entries[0].role == "observer" + assert entries[0].role == "" def test_observer_role_pairing_validates_but_ignores_sender_instance_id( @@ -236,9 +238,7 @@ def test_observer_role_pairing_validates_but_ignores_sender_instance_id( sender_instance_id="abc-123", ) - observer = load_observer_by_fingerprint(response["fingerprint"]) - assert observer is not None - assert "peer_instance_id" not in observer + assert load_observer_by_fingerprint(response["fingerprint"]) is None assert ( journal_sources.load_journal_source_by_fingerprint(response["fingerprint"]) is None @@ -246,6 +246,7 @@ def test_observer_role_pairing_validates_but_ignores_sender_instance_id( entries = link_routes._authorized().snapshot() assert len(entries) == 1 assert not hasattr(entries[0], "peer_instance_id") + assert entries[0].role == "" def test_peer_journal_source_mint_failure_does_not_add_authorized( diff --git a/solstone/apps/link/tests/test_unpair_record_revoke_parity.py b/solstone/apps/link/tests/test_unpair_record_revoke_parity.py index 8f6c2e64a..e54cc1034 100644 --- a/solstone/apps/link/tests/test_unpair_record_revoke_parity.py +++ b/solstone/apps/link/tests/test_unpair_record_revoke_parity.py @@ -8,11 +8,6 @@ import logging from importlib import import_module import solstone.apps.link.routes as link_routes -from solstone.apps.observer.utils import ( - load_observer_by_fingerprint, - mint_pl_observer_record, - save_observer, -) from solstone.think.link.auth import AuthorizedClients from solstone.think.link.paths import authorized_clients_path @@ -23,7 +18,6 @@ save_journal_source = journal_sources.save_journal_source PAIRED_AT = "2026-05-20T00:00:00Z" PHONE_FINGERPRINT = "sha256:" + ("a" * 64) -OBSERVER_FINGERPRINT = "sha256:" + ("b" * 64) PEER_FINGERPRINT = "sha256:" + ("c" * 64) UNKNOWN_ROLE_FINGERPRINT = "sha256:" + ("d" * 64) @@ -36,9 +30,7 @@ def _authorized() -> AuthorizedClients: return AuthorizedClients(authorized_clients_path()) -def _add_authorized( - fingerprint: str, device_label: str, *, role: str = "phone" -) -> None: +def _add_authorized(fingerprint: str, device_label: str, *, role: str = "") -> None: _authorized().add( fingerprint, device_label, @@ -72,11 +64,10 @@ def test_unpair_phone_by_fingerprint_removes_authorized(link_env) -> None: assert response.status_code == 200 assert response.get_json() == {"unpaired": PHONE_FINGERPRINT} assert _authorized().is_authorized(PHONE_FINGERPRINT) is False - assert load_observer_by_fingerprint(PHONE_FINGERPRINT) is None assert load_journal_source_by_fingerprint(PHONE_FINGERPRINT) is None -def test_unpair_unknown_role_treats_as_phone_and_warns( +def test_unpair_unknown_role_removes_authorized_without_warning( link_env, caplog, ) -> None: @@ -89,119 +80,12 @@ def test_unpair_unknown_role_treats_as_phone_and_warns( assert response.status_code == 200 assert response.get_json() == {"unpaired": UNKNOWN_ROLE_FINGERPRINT} assert _authorized().is_authorized(UNKNOWN_ROLE_FINGERPRINT) is False - assert "unexpected role" in caplog.text - assert "tablet" in caplog.text - - -def test_unpair_observer_revokes_record_and_does_not_double_remove( - link_env, - monkeypatch, -) -> None: - env = link_env() - mint_pl_observer_record( - fingerprint=OBSERVER_FINGERPRINT, - device_label="observer", - paired_at=PAIRED_AT, - ) - _add_authorized(OBSERVER_FINGERPRINT, "observer", role="observer") - original_remove = AuthorizedClients.remove - remove_calls = [] - - def spy_remove(self, fingerprint: str) -> bool: - if fingerprint == OBSERVER_FINGERPRINT: - remove_calls.append(fingerprint) - return original_remove(self, fingerprint) - - monkeypatch.setattr(AuthorizedClients, "remove", spy_remove) - - response = _post_unpair(env, {"device_label": "observer"}) - - assert response.status_code == 200 - assert response.get_json() == {"unpaired": OBSERVER_FINGERPRINT} - assert remove_calls == [OBSERVER_FINGERPRINT] - assert _authorized().is_authorized(OBSERVER_FINGERPRINT) is False - observer = load_observer_by_fingerprint(OBSERVER_FINGERPRINT) - assert observer is not None - assert observer["revoked"] is True - assert observer["revoked_at"] is not None - - -def test_unpair_observer_already_revoked_removes_authorized_and_warns( - link_env, - caplog, -) -> None: - env = link_env() - mint_pl_observer_record( - fingerprint=OBSERVER_FINGERPRINT, - device_label="observer-revoked", - paired_at=PAIRED_AT, - ) - observer = load_observer_by_fingerprint(OBSERVER_FINGERPRINT) - assert observer is not None - observer["revoked"] = True - observer["revoked_at"] = 123 - assert save_observer(observer) is True - _add_authorized(OBSERVER_FINGERPRINT, "observer-revoked", role="observer") - caplog.set_level(logging.WARNING, logger="solstone.apps.link.routes") - - response = _post_unpair(env, {"fingerprint": OBSERVER_FINGERPRINT}) - - assert response.status_code == 200 - assert response.get_json() == {"unpaired": OBSERVER_FINGERPRINT} - assert _authorized().is_authorized(OBSERVER_FINGERPRINT) is False - observer = load_observer_by_fingerprint(OBSERVER_FINGERPRINT) - assert observer is not None - assert observer["revoked"] is True - assert observer["revoked_at"] == 123 - assert "already revoked" in caplog.text - - -def test_unpair_observer_missing_record_removes_authorized_and_warns( - link_env, - caplog, -) -> None: - env = link_env() - _add_authorized(OBSERVER_FINGERPRINT, "observer-missing", role="observer") - caplog.set_level(logging.WARNING, logger="solstone.apps.link.routes") - - response = _post_unpair(env, {"fingerprint": OBSERVER_FINGERPRINT}) - - assert response.status_code == 200 - assert response.get_json() == {"unpaired": OBSERVER_FINGERPRINT} - assert _authorized().is_authorized(OBSERVER_FINGERPRINT) is False - assert load_observer_by_fingerprint(OBSERVER_FINGERPRINT) is None - assert "observer record missing" in caplog.text - - -def test_unpair_observer_save_failure_removes_authorized_and_logs_error( - link_env, - caplog, - monkeypatch, -) -> None: - env = link_env() - mint_pl_observer_record( - fingerprint=OBSERVER_FINGERPRINT, - device_label="observer-save-fails", - paired_at=PAIRED_AT, - ) - _add_authorized(OBSERVER_FINGERPRINT, "observer-save-fails", role="observer") - monkeypatch.setattr( - "solstone.apps.observer.utils.save_observer", - lambda *_a, **_kw: False, - ) - caplog.set_level(logging.ERROR, logger="solstone.apps.link.routes") - - response = _post_unpair(env, {"device_label": "observer-save-fails"}) - - assert response.status_code == 200 - assert response.get_json() == {"unpaired": OBSERVER_FINGERPRINT} - assert _authorized().is_authorized(OBSERVER_FINGERPRINT) is False - observer = load_observer_by_fingerprint(OBSERVER_FINGERPRINT) - assert observer is not None - assert observer.get("revoked") is not True - assert observer.get("revoked_at") is None - assert _short(OBSERVER_FINGERPRINT) in caplog.text - assert "failed to save observer record" in caplog.text + assert [ + record + for record in caplog.records + if record.name == "solstone.apps.link.routes" + and record.levelno >= logging.WARNING + ] == [] def test_unpair_peer_revokes_source_removes_authorized_and_logs_action( diff --git a/solstone/apps/observer/tests/test_pl_pairing.py b/solstone/apps/observer/tests/test_pl_pairing.py index 055817d32..198458e89 100644 --- a/solstone/apps/observer/tests/test_pl_pairing.py +++ b/solstone/apps/observer/tests/test_pl_pairing.py @@ -1,7 +1,7 @@ # SPDX-License-Identifier: AGPL-3.0-only # Copyright (c) 2026 sol pbc -"""Tests for PL observer record minting during link pairing.""" +"""Tests for role-less PL link pairing before observer self-registration.""" from __future__ import annotations @@ -66,7 +66,7 @@ def _make_csr(label: str = "test") -> str: return csr.public_bytes(serialization.Encoding.PEM).decode("ascii") -def _start_pair(env, *, role: str, label: str = "Pair Device") -> dict: +def _start_pair(env, *, role: str = "", label: str = "Pair Device") -> dict: response = env.client.post( "/app/link/pair-start", json={"device_label": label, "role": role}, @@ -75,7 +75,7 @@ def _start_pair(env, *, role: str, label: str = "Pair Device") -> dict: return response.get_json() -def _pair(env, *, role: str, label: str = "Pair Device") -> dict: +def _pair(env, *, role: str = "", label: str = "Pair Device") -> dict: started = _start_pair(env, role=role, label=label) response = env.client.post( "/app/link/pair", @@ -99,67 +99,26 @@ def _observer_record_paths(env) -> list: return sorted((env.journal / "apps" / "observer" / "observers").glob("*.json")) -def test_observer_role_pairing_mints_observer_record_and_authorized_client( - pair_env, -) -> None: - env = pair_env() - - response = _pair(env, role="observer", label="Observer Laptop") - - observer = load_observer_by_fingerprint(response["fingerprint"]) - assert observer is not None - assert observer["name"] == "Observer Laptop" - assert observer["mode"] == "pl" - entries = link_routes._authorized().snapshot() - assert len(entries) == 1 - assert entries[0].fingerprint == response["fingerprint"] - assert entries[0].role == "observer" - - -def test_observer_role_pairing_ignores_sender_instance_id(pair_env) -> None: - env = pair_env() - started = _start_pair(env, role="observer", label="Observer Laptop") - - response = env.client.post( - "/app/link/pair", - json={ - "nonce": started["nonce"], - "csr": _make_csr("Observer Laptop"), - "sender_instance_id": "abc-123", - }, - ) - - assert response.status_code == 200 - fingerprint = response.get_json()["fingerprint"] - observer = load_observer_by_fingerprint(fingerprint) - assert observer is not None - assert "peer_instance_id" not in observer - entries = link_routes._authorized().snapshot() - assert len(entries) == 1 - assert not hasattr(entries[0], "peer_instance_id") - journal_sources_dir = env.journal / "apps" / "import" / "journal_sources" - assert ( - not journal_sources_dir.exists() - or list(journal_sources_dir.glob("*.json")) == [] - ) +def _journal_source_paths(env) -> list: + return sorted((env.journal / "apps" / "import" / "journal_sources").glob("*.json")) -def test_phone_role_pairing_does_not_mint_observer_record(pair_env) -> None: +def test_role_less_pairing_does_not_mint_observer_record(pair_env) -> None: env = pair_env() - response = _pair(env, role="phone", label="Owner Phone") + response = _pair(env, label="Linked System") assert load_observer_by_fingerprint(response["fingerprint"]) is None assert _observer_record_paths(env) == [] entries = link_routes._authorized().snapshot() assert len(entries) == 1 assert entries[0].fingerprint == response["fingerprint"] - assert entries[0].role == "phone" + assert entries[0].role == "" -def test_phone_role_pl_ingest_returns_auth_required(pair_env) -> None: +def test_role_less_pl_ingest_returns_auth_required(pair_env) -> None: env = pair_env() - response = _pair(env, role="phone", label="Owner Phone") + response = _pair(env, label="Linked System") ingest = env.client.post( "/app/observer/ingest", @@ -201,7 +160,7 @@ def test_attestation_failure_does_not_write_observer_or_authorized( expires_at=now + 300, used=True, manual_code=None, - role="observer", + role="", ) with pytest.raises(RuntimeError, match="attestation failed"): @@ -215,44 +174,7 @@ def test_attestation_failure_does_not_write_observer_or_authorized( assert _observer_record_paths(env) == [] -def test_observer_record_mint_failure_does_not_add_authorized_client( - pair_env, - monkeypatch, -) -> None: - env = pair_env() - - def fail_mint(*args, **kwargs): - raise RuntimeError("observer mint failed") - - class Authorized: - def add(self, *args, **kwargs) -> None: - pytest.fail("authorized add should not run after observer mint failure") - - monkeypatch.setattr(link_routes, "mint_pl_observer_record", fail_mint) - monkeypatch.setattr(link_routes, "_authorized", lambda: Authorized()) - now = int(time.time()) - consumed = Nonce( - value="nonce", - device_label="Observer Laptop", - issued_at=now, - expires_at=now + 300, - used=True, - manual_code=None, - role="observer", - ) - - with pytest.raises(RuntimeError, match="observer mint failed"): - link_routes._complete_pairing( - consumed, - _make_csr("mint"), - "Observer Laptop", - network="network", - ) - - assert _observer_record_paths(env) == [] - - -def test_observer_record_rolls_back_when_authorized_add_fails( +def test_peer_journal_source_rolls_back_when_authorized_add_fails( pair_env, monkeypatch, ) -> None: @@ -266,36 +188,21 @@ def test_observer_record_rolls_back_when_authorized_add_fails( now = int(time.time()) consumed = Nonce( value="nonce", - device_label="Observer Laptop", + device_label="Peer Laptop", issued_at=now, expires_at=now + 300, used=True, manual_code=None, - role="observer", + role="peer", ) with pytest.raises(RuntimeError, match="ledger write failed"): link_routes._complete_pairing( consumed, _make_csr("rollback"), - "Observer Laptop", + "Peer Laptop", network="network", ) assert _observer_record_paths(env) == [] - - -def test_repair_same_label_leaves_old_observer_record(pair_env) -> None: - env = pair_env() - - first = _pair(env, role="observer", label="Observer Laptop") - second = _pair(env, role="observer", label="Observer Laptop") - - assert first["fingerprint"] != second["fingerprint"] - first_record = load_observer_by_fingerprint(first["fingerprint"]) - second_record = load_observer_by_fingerprint(second["fingerprint"]) - assert first_record is not None - assert second_record is not None - assert first_record["enabled"] is True - assert second_record["enabled"] is True - assert len(_observer_record_paths(env)) == 2 + assert _journal_source_paths(env) == [] diff --git a/solstone/think/link/auth.py b/solstone/think/link/auth.py index 055225bae..bd0f78211 100644 --- a/solstone/think/link/auth.py +++ b/solstone/think/link/auth.py @@ -12,15 +12,16 @@ fields for UX: "device_label": "Jer's iPhone", "paired_at": "2026-04-19T17:42:13Z", "instance_id": "", - "role": "phone", + "role": "", "last_seen_at": "2026-04-19T18:03:12Z", // optional; null/absent = never "network": "network" // optional; local display label source } -Readers reload the file on mtime change so an unpair action takes effect -within ~500 ms of the file write. Convey's pair and unpair routes own the -pairing writer surface; the secure listener updates `last_seen_at` and uses -this ledger for TLS verification and per-request authorization. +Role-less linked systems are stored with `role: ""`; peers are stored with +`role: "peer"`. Readers reload the file on mtime change so an unpair action +takes effect within ~500 ms of the file write. Convey's pair and unpair routes +own the pairing writer surface; the secure listener updates `last_seen_at` and +uses this ledger for TLS verification and per-request authorization. `last_seen_at` and `network` are local-only — never transmitted externally. """ @@ -38,13 +39,17 @@ from solstone.think.journal_io import hold_lock, write_json MAX_DEVICE_LABEL_LEN = 80 +def is_peer(role: str) -> bool: + return role == "peer" + + @dataclass(frozen=True) class ClientEntry: fingerprint: str device_label: str paired_at: str instance_id: str - role: str = "phone" + role: str = "" last_seen_at: str | None = None network: str | None = None @@ -91,7 +96,7 @@ class AuthorizedClients: device_label: str, instance_id: str, *, - role: str = "phone", + role: str = "", paired_at: str | None = None, network: str | None = None, ) -> None: @@ -206,11 +211,7 @@ class AuthorizedClients: device_label=str(item.get("device_label", "")), paired_at=str(item.get("paired_at", "")), instance_id=str(item.get("instance_id", "")), - role=( - item.get("role") - if isinstance(item.get("role"), str) - else "phone" - ), + role=item.get("role") if isinstance(item.get("role"), str) else "", last_seen_at=last_seen if isinstance(last_seen, str) else None, network=network if isinstance(network, str) else None, ) diff --git a/solstone/think/link/join_cli.py b/solstone/think/link/join_cli.py index 2461f87f5..f6bff3a24 100644 --- a/solstone/think/link/join_cli.py +++ b/solstone/think/link/join_cli.py @@ -6,7 +6,7 @@ Manual short-code form posts to `/app/link/by-code`; v3 pair-link URL form decodes the embedded nonce and posts to `/app/link/pair?token=`. -Observer credentials are written under +Role-less linked-system credentials are written under `$XDG_CONFIG_HOME/solstone-observer/spl/