diff --git a/solstone/apps/network/routes.py b/solstone/apps/network/routes.py index 64dd5dadc..22a115fe1 100644 --- a/solstone/apps/network/routes.py +++ b/solstone/apps/network/routes.py @@ -65,6 +65,7 @@ from solstone.convey.reasons import ( INVALID_CONFIG_VALUE, INVALID_OPERATION_FOR_STATE, INVALID_REQUEST_VALUE, + LOCAL_REQUEST_ONLY, MISSING_REQUIRED_FIELD, OPERATION_NO_LONGER_AVAILABLE, PAIRED_DEVICE_NOT_FOUND, @@ -184,8 +185,13 @@ def _rough_network(mode: str) -> str: return "anywhere" if mode == "pl-via-spl" else "network" -def _is_loopback_request() -> bool: - return request.remote_addr in {"127.0.0.1", "::1"} +def _is_hardened_loopback_request() -> bool: + if request.remote_addr not in {"127.0.0.1", "::1"}: + return False + return not any( + request.headers.get(header) + for header in ("X-Forwarded-For", "X-Real-IP", "X-Forwarded-Host") + ) def _read_link_health() -> dict[str, Any] | None: @@ -656,7 +662,7 @@ def set_home_address_route() -> Any: @network_bp.get("/local-endpoints") def local_endpoints() -> Any: - if not _is_loopback_request(): + if not _is_hardened_loopback_request(): abort(404) response = LocalEndpointsResponse( v=1, @@ -667,6 +673,15 @@ def local_endpoints() -> Any: return jsonify(response_to_dict(response)) +def _same_machine_requested(payload: dict[str, Any]) -> bool | None: + value = payload.get("same_machine") + if value is None: + return False + if isinstance(value, bool): + return value + return None + + # --------------------------------------------------------------------------- # pair ceremony # --------------------------------------------------------------------------- @@ -688,6 +703,33 @@ def pair_start() -> Any: if not isinstance(role, str) or role not in VALID_ROLES: return error_response(PAIRING_REQUEST_INVALID, detail="invalid role") + same_machine = _same_machine_requested(payload) + if same_machine is None: + return error_response( + PAIRING_REQUEST_INVALID, + detail="same_machine must be boolean", + ) + if same_machine: + if not _is_hardened_loopback_request(): + return error_response(LOCAL_REQUEST_ONLY) + ca_fp = _ca_fingerprint() + port = _secure_listener_port() + nonce = generate_nonce() + pair_link = _build_pair_link("127.0.0.1", port, nonce, ca_fp) + _nonces().add( + nonce, + device_label, + role=role, + ) + response = PairStartResponse( + nonce=nonce, + pair_link=pair_link, + expires_in=300, + device_label=device_label, + ca_fingerprint=ca_fp, + ) + return _jsonify_preserving_order(asdict(response)) + if read_posture() == "spl": service_token = load_service_token() if service_token is None: diff --git a/solstone/apps/network/tests/test_pair_start.py b/solstone/apps/network/tests/test_pair_start.py index dd5843ade..7aeb88d51 100644 --- a/solstone/apps/network/tests/test_pair_start.py +++ b/solstone/apps/network/tests/test_pair_start.py @@ -9,6 +9,8 @@ import ipaddress import json import re +import pytest + from solstone.apps.network import routes as link_routes from solstone.apps.network.crockford32 import decode as crockford_decode from solstone.apps.network.relay_link import decode_pair_window_link, derive_rk @@ -26,6 +28,28 @@ PAIR_START_KEYS = [ ] +FIXED_NONCE = "11" * 16 +FIXED_SPL_NONCE = bytes.fromhex("0102030405060708") + + +def _set_home_address(env, value: str) -> None: + config_path = env.journal / "config" / "journal.json" + config = json.loads(config_path.read_text("utf-8")) + config["pairing"] = {"home_address": value} + config_path.write_text(json.dumps(config, indent=2), encoding="utf-8") + + +def _decoded_pair_link_port(pair_link: str) -> int: + decoded = _decode_pair_link(pair_link) + return int.from_bytes(decoded[6:8], "big") + + +def _assert_single_pair_link_address(pair_link: str, address: str) -> None: + decoded = _decode_pair_link(pair_link) + assert decoded[0:2] == b"\x04\x01" + assert decoded[2:6] == ipaddress.IPv4Address(address).packed + + def test_pair_start_shape_and_locked_order(link_env) -> None: env = link_env() @@ -49,6 +73,116 @@ def test_pair_start_shape_and_locked_order(link_env) -> None: assert "qr_payload" not in payload +@pytest.mark.parametrize("remote_addr", ("127.0.0.1", "::1")) +def test_pair_start_same_machine_loopback_direct_link_wins_over_home_address( + link_env, + remote_addr, +) -> None: + env = link_env() + _set_home_address(env, "192.0.2.44:7657") + + response = env.client.post( + "/app/network/pair-start", + json={"device_label": "Test Phone", "same_machine": True}, + environ_base={"REMOTE_ADDR": remote_addr}, + ) + + assert response.status_code == 200 + payload = response.get_json() + ca = load_or_generate_ca(ca_dir()) + assert list(payload.keys()) == PAIR_START_KEYS + assert payload["ca_fingerprint"] == ca.fingerprint_sha256() + _assert_single_pair_link_address(payload["pair_link"], "127.0.0.1") + assert _decoded_pair_link_port(payload["pair_link"]) == ( + link_routes._secure_listener_port() + ) + snap = link_routes._nonces().snapshot() + assert len(snap) == 1 + assert snap[0].value == payload["nonce"] + assert snap[0].device_label == "Test Phone" + + +@pytest.mark.parametrize( + "kwargs", + ( + pytest.param( + {"environ_base": {"REMOTE_ADDR": "192.168.1.5"}}, + id="non_loopback", + ), + pytest.param({"headers": {"X-Forwarded-For": "1.2.3.4"}}, id="forwarded_for"), + pytest.param({"headers": {"X-Real-IP": "1.2.3.4"}}, id="real_ip"), + pytest.param( + {"headers": {"X-Forwarded-Host": "example.test"}}, + id="forwarded_host", + ), + ), +) +def test_pair_start_same_machine_requires_hardened_loopback( + link_env, + kwargs, +) -> None: + env = link_env() + + response = env.client.post( + "/app/network/pair-start", + json={"device_label": "Test Phone", "same_machine": True}, + **kwargs, + ) + + assert response.status_code == 403 + assert response.get_json()["reason_code"] == "local_request_only" + assert link_routes._nonces().snapshot() == [] + + +@pytest.mark.parametrize("same_machine", ("true", 1, [], {})) +def test_pair_start_same_machine_non_boolean_rejected_without_nonce( + link_env, + same_machine, +) -> None: + env = link_env() + + response = env.client.post( + "/app/network/pair-start", + json={"same_machine": same_machine}, + ) + + assert response.status_code == 400 + assert response.get_json()["reason_code"] == "pairing_request_invalid" + assert link_routes._nonces().snapshot() == [] + + +def test_pair_start_same_machine_returns_before_spl_posture_reads( + link_env, + monkeypatch, +) -> None: + env = link_env() + monkeypatch.setattr( + link_routes, + "read_posture", + lambda: (_ for _ in ()).throw(RuntimeError("posture read")), + ) + monkeypatch.setattr( + link_routes, + "load_service_token", + lambda: (_ for _ in ()).throw(RuntimeError("service token")), + ) + monkeypatch.setattr( + link_routes, + "start_pair_window", + lambda **_kwargs: (_ for _ in ()).throw(RuntimeError("pair window")), + ) + + response = env.client.post( + "/app/network/pair-start", + json={"device_label": "Test Phone", "same_machine": True}, + ) + + assert response.status_code == 200 + payload = response.get_json() + _assert_single_pair_link_address(payload["pair_link"], "127.0.0.1") + assert len(link_routes._nonces().snapshot()) == 1 + + def test_pair_start_omitted_assigned_label_stores_empty(link_env) -> None: env = link_env() @@ -279,6 +413,106 @@ def test_pair_start_detected_order_matches_api_status( assert pair_addresses == status_addresses +def test_pair_start_without_same_machine_from_loopback_uses_lan_candidate( + link_env, +) -> None: + env = link_env() + + response = env.client.post( + "/app/network/pair-start", + json={"device_label": "Test Phone"}, + ) + + assert response.status_code == 200 + _assert_single_pair_link_address(response.get_json()["pair_link"], "192.168.1.50") + + +def test_pair_start_false_same_machine_matches_home_override_path( + link_env, + monkeypatch, +) -> None: + env = link_env() + _set_home_address(env, "192.0.2.44:7657") + monkeypatch.setattr(link_routes, "generate_nonce", lambda: FIXED_NONCE) + + absent = env.client.post( + "/app/network/pair-start", + json={"device_label": "Test Phone"}, + ) + false = env.client.post( + "/app/network/pair-start", + json={"device_label": "Test Phone", "same_machine": False}, + ) + + assert absent.status_code == false.status_code == 200 + assert absent.get_json() == false.get_json() + + +def test_pair_start_false_same_machine_matches_lan_candidate_path( + link_env, + monkeypatch, +) -> None: + env = link_env() + monkeypatch.setattr(link_routes, "generate_nonce", lambda: FIXED_NONCE) + + absent = env.client.post( + "/app/network/pair-start", + json={"device_label": "Test Phone"}, + ) + false = env.client.post( + "/app/network/pair-start", + json={"device_label": "Test Phone", "same_machine": False}, + ) + + assert absent.status_code == false.status_code == 200 + assert absent.get_json() == false.get_json() + + +def test_pair_start_false_same_machine_matches_no_candidates_refusal( + link_env, + monkeypatch, +) -> None: + env = link_env(local_endpoints=[]) + monkeypatch.setattr(link_routes, "_detect_lan_ip", lambda: None) + + absent = env.client.post( + "/app/network/pair-start", + json={"device_label": "Test Phone"}, + ) + false = env.client.post( + "/app/network/pair-start", + json={"device_label": "Test Phone", "same_machine": False}, + ) + + assert absent.status_code == false.status_code == 400 + assert absent.get_json() == false.get_json() + assert absent.get_json()["reason_code"] == "pairing_request_invalid" + + +def test_pair_start_false_same_machine_matches_spl_path( + link_env, + monkeypatch, +) -> None: + env = link_env(posture="spl", service_token="svc") + monkeypatch.setattr( + link_routes, + "generate_pair_window_nonce", + lambda: FIXED_SPL_NONCE, + ) + + absent = env.client.post( + "/app/network/pair-start", + json={"device_label": "Test Phone"}, + ) + false = env.client.post( + "/app/network/pair-start", + json={"device_label": "Test Phone", "same_machine": False}, + ) + + assert absent.status_code == false.status_code == 200 + assert absent.get_json() == false.get_json() + + def _fragment(pair_link: str) -> str: return pair_link.rsplit("#", 1)[1] diff --git a/tests/link/test_local_endpoints_route.py b/tests/link/test_local_endpoints_route.py index 04a8e290a..d9c824e15 100644 --- a/tests/link/test_local_endpoints_route.py +++ b/tests/link/test_local_endpoints_route.py @@ -85,3 +85,16 @@ def test_local_endpoints_non_loopback_404(link_client) -> None: ) assert response.status_code == 404 + + +@pytest.mark.parametrize( + "header", + ("X-Forwarded-For", "X-Real-IP", "X-Forwarded-Host"), +) +def test_local_endpoints_proxy_headers_404(link_client, header: str) -> None: + response = link_client.get( + "/app/network/local-endpoints", + headers={header: "1.2.3.4"}, + ) + + assert response.status_code == 404 diff --git a/tests/link/test_network_alias_routes.py b/tests/link/test_network_alias_routes.py index 805bbd29c..55969a380 100644 --- a/tests/link/test_network_alias_routes.py +++ b/tests/link/test_network_alias_routes.py @@ -70,6 +70,23 @@ def test_legacy_link_prefix_serves_native_client_routes( assert client.get("/app/link/local-endpoints").status_code == 200 +@pytest.mark.parametrize( + "header", + ("X-Forwarded-For", "X-Real-IP", "X-Forwarded-Host"), +) +def test_legacy_link_local_endpoints_proxy_headers_404( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + header: str, +) -> None: + app, _journal = make_convey_app(tmp_path, monkeypatch, link={"posture": "spl"}) + client = app.test_client() + + response = client.get("/app/link/local-endpoints", headers={header: "1.2.3.4"}) + + assert response.status_code == 404 + + def _full_rule(prefix: str, suffix: str) -> str: return f"{prefix}/" if suffix == "/" else f"{prefix}{suffix}" -- 2.51.2 From 8650467c26ef9018ce514da05a32125c5d227327 Mon Sep 17 00:00:00 2001 From: Jer Miller Date: Fri, 31 Jul 2026 21:34:34 -0600 Subject: [PATCH 2/5] feat(observer): surface bindings in observer health Expose device_binding_kind across observer CLI and capture health surfaces without changing status semantics. Return revoked observer stream details from unpair, carry partial failure payloads, and render the new response in the native command. --- .../solstone-core-sol-client/src/error.rs | 8 ++ core/fixtures/native-sol/parity/link.jsonl | 4 +- solstone/apps/network/native/command.rs | 64 ++++++++++++- solstone/apps/network/routes.py | 37 ++++++- .../tests/test_unpair_record_revoke_parity.py | 91 ++++++++++++++++-- solstone/apps/observer/utils.py | 13 ++- solstone/observe/observer_cli.py | 20 ++-- solstone/think/capture_health.py | 2 + solstone/think/doctor.py | 26 +++++ tests/test_capture_health.py | 25 ++++- tests/test_journal_doctor.py | 96 +++++++++++++++++++ tests/test_observer_cli.py | 21 +++- 12 files changed, 384 insertions(+), 23 deletions(-) diff --git a/core/crates/solstone-core-sol-client/src/error.rs b/core/crates/solstone-core-sol-client/src/error.rs index 97708f12c..7f2398933 100644 --- a/core/crates/solstone-core-sol-client/src/error.rs +++ b/core/crates/solstone-core-sol-client/src/error.rs @@ -90,6 +90,14 @@ impl ClientError { ClientError::Unreachable { .. } | ClientError::Timeout { .. } => None, } } + + #[must_use] + pub fn payload(&self) -> Option<&serde_json::Value> { + match self { + ClientError::ReasonRejected { payload, .. } => Some(payload), + _ => None, + } + } } impl fmt::Display for ClientError { diff --git a/core/fixtures/native-sol/parity/link.jsonl b/core/fixtures/native-sol/parity/link.jsonl index 9d94504ab..350990b97 100644 --- a/core/fixtures/native-sol/parity/link.jsonl +++ b/core/fixtures/native-sol/parity/link.jsonl @@ -6,8 +6,8 @@ {"id":"link-private-link-status-service-down","surface":"sol-call","argv":["link","private-link","status"],"env":{},"stdin":"","files":{},"clock":{"today":"20260723","unix_seconds":3600},"transport":{"requests":[{"method":"GET","path":"/app/network/api/private-link","fault":{"kind":"service_down"}}]},"normalizations":[],"expected":{"stdout":"","stderr":"sol: solstone isn't running. Start it with 'journal up' and retry.\n","exit":1,"requests":[{"method":"GET","path":"/app/network/api/private-link","query":[],"json":null,"headers":[],"timeout_policy":"api"}]}} {"id":"link-private-link-disable-success","surface":"sol-call","argv":["link","private-link","disable"],"env":{},"stdin":"","files":{},"clock":{"today":"20260723","unix_seconds":3600},"transport":{"requests":[{"method":"POST","path":"/app/network/private-link/disable","response":{"status":200,"json":{"success":true,"service":"spl","result":{"was_enabled":true},"status":{"success":true,"service":"spl","state":"not_enabled","posture":"direct","enrolled":false,"relay_url":"https://relay.example","actions":{"enable":true,"disable":false},"operation":null}}}}]},"normalizations":[],"expected":{"stdout":"your private network is off. devices connect directly again.\n","stderr":"","exit":0,"requests":[{"method":"POST","path":"/app/network/private-link/disable","query":[],"json":null,"headers":[],"timeout_policy":"api"}]}} {"id":"link-private-link-disable-failed","surface":"sol-call","argv":["link","private-link","disable"],"env":{},"stdin":"","files":{},"clock":{"today":"20260723","unix_seconds":3600},"transport":{"requests":[{"method":"POST","path":"/app/network/private-link/disable","fault":{"kind":"reason","status":500,"reason_code":"service_operation_failed","error":"service_operation_failed"}}]},"normalizations":[],"expected":{"stdout":"","stderr":"couldn't turn off your private network — it's still on. try again.\n","exit":1,"requests":[{"method":"POST","path":"/app/network/private-link/disable","query":[],"json":null,"headers":[],"timeout_policy":"api"}]}} -{"id":"link-unpair-label-success","surface":"sol-call","argv":["link","unpair","Phone"],"env":{},"stdin":"","files":{},"clock":{"today":"20260723","unix_seconds":3600},"transport":{"requests":[{"method":"POST","path":"/app/network/unpair","json":{"device_label":"Phone"},"response":{"status":200,"json":{"unpaired":"sha256:aaa111"}}}]},"normalizations":[],"expected":{"stdout":"Unpaired.\n","stderr":"","exit":0,"requests":[{"method":"POST","path":"/app/network/unpair","query":[],"json":{"device_label":"Phone"},"headers":[],"timeout_policy":"api"}]}} -{"id":"link-unpair-fingerprint-success","surface":"sol-call","argv":["link","unpair","sha256:aaa111"],"env":{},"stdin":"","files":{},"clock":{"today":"20260723","unix_seconds":3600},"transport":{"requests":[{"method":"POST","path":"/app/network/unpair","json":{"fingerprint":"sha256:aaa111"},"response":{"status":200,"json":{"unpaired":"sha256:aaa111"}}}]},"normalizations":[],"expected":{"stdout":"Unpaired.\n","stderr":"","exit":0,"requests":[{"method":"POST","path":"/app/network/unpair","query":[],"json":{"fingerprint":"sha256:aaa111"},"headers":[],"timeout_policy":"api"}]}} +{"id":"link-unpair-label-success","surface":"sol-call","argv":["link","unpair","Phone"],"env":{},"stdin":"","files":{},"clock":{"today":"20260723","unix_seconds":3600},"transport":{"requests":[{"method":"POST","path":"/app/network/unpair","json":{"device_label":"Phone"},"response":{"status":200,"json":{"unpaired":"sha256:aaa111","revoked_observers":[{"name":"phone-a","prefix":"phone-a-"},{"name":"phone-b","prefix":"phone-b-"}]}}}]},"normalizations":[],"expected":{"stdout":"Unpaired.\nRevoked observer streams: phone-a, phone-b\n","stderr":"","exit":0,"requests":[{"method":"POST","path":"/app/network/unpair","query":[],"json":{"device_label":"Phone"},"headers":[],"timeout_policy":"api"}]}} +{"id":"link-unpair-fingerprint-success","surface":"sol-call","argv":["link","unpair","sha256:aaa111"],"env":{},"stdin":"","files":{},"clock":{"today":"20260723","unix_seconds":3600},"transport":{"requests":[{"method":"POST","path":"/app/network/unpair","json":{"fingerprint":"sha256:aaa111"},"response":{"status":200,"json":{"unpaired":"sha256:aaa111","revoked_observers":[]}}}]},"normalizations":[],"expected":{"stdout":"Unpaired.\nNo bound observer streams were revoked.\n","stderr":"","exit":0,"requests":[{"method":"POST","path":"/app/network/unpair","query":[],"json":{"fingerprint":"sha256:aaa111"},"headers":[],"timeout_policy":"api"}]}} {"id":"link-unpair-label-not-found","surface":"sol-call","argv":["link","unpair","Missing"],"env":{},"stdin":"","files":{},"clock":{"today":"20260723","unix_seconds":3600},"transport":{"requests":[{"method":"POST","path":"/app/network/unpair","json":{"device_label":"Missing"},"fault":{"kind":"reason","status":404,"reason_code":"paired_device_not_found","error":"not found","detail":"no paired device with that label"}}]},"normalizations":[],"expected":{"stdout":"No paired device with label 'Missing'\n","stderr":"","exit":1,"requests":[{"method":"POST","path":"/app/network/unpair","query":[],"json":{"device_label":"Missing"},"headers":[],"timeout_policy":"api"}]}} {"id":"link-pair-invalid-role","surface":"sol-call","argv":["link","pair","--as","bad"],"env":{},"stdin":"","files":{},"clock":{"today":"20260723","unix_seconds":3600},"transport":{"requests":[]},"normalizations":[],"expected":{"stdout":"","stderr":"invalid role; expected one of: phone, observer, peer\n","exit":2,"requests":[]}} {"id":"link-pair-request-invalid","surface":"sol-call","argv":["link","pair"],"env":{},"stdin":"","files":{},"clock":{"today":"20260723","unix_seconds":3600},"transport":{"requests":[{"method":"POST","path":"/app/network/pair-start","json":{"device_label":""},"fault":{"kind":"reason","status":400,"reason_code":"pairing_request_invalid","error":"invalid","detail":"pair-link requires an IPv4 LAN address; none found"}}]},"normalizations":[],"expected":{"stdout":"","stderr":"can't start pairing — your journal isn't reachable on a network address yet. turn on your private network to pair from anywhere, or connect this device to your home network.\n","exit":1,"requests":[{"method":"POST","path":"/app/network/pair-start","query":[],"json":{"device_label":""},"headers":[],"timeout_policy":"api"}]}} diff --git a/solstone/apps/network/native/command.rs b/solstone/apps/network/native/command.rs index 4c4fd0643..08140ebde 100644 --- a/solstone/apps/network/native/command.rs +++ b/solstone/apps/network/native/command.rs @@ -406,7 +406,7 @@ pub fn unpair(ctx: CommandContext<'_>) -> CommandOutput { vec![], Some(payload), ) { - Ok(_response) => stdout_line("Unpaired."), + Ok(response) => unpair_success_output(&response), Err(error) => { if error.reason_code() == Some("paired_device_not_found") { let message = if target.starts_with("sha256:") { @@ -420,11 +420,73 @@ pub fn unpair(ctx: CommandContext<'_>) -> CommandOutput { exit: 1, }; } + if let Some(output) = unpair_partial_failure_output(&error) { + return output; + } link_error(error) } } } +fn unpair_success_output(response: &Value) -> CommandOutput { + let mut lines = vec!["Unpaired.".to_string()]; + let names = revoked_observer_names(response); + if names.is_empty() { + lines.push("No bound observer streams were revoked.".to_string()); + } else { + lines.push(format!("Revoked observer streams: {}", names.join(", "))); + } + stdout(lines) +} + +fn unpair_partial_failure_output(error: &ClientError) -> Option { + if error.reason_code() != Some("internal_error") { + return None; + } + let payload = error.payload()?; + if payload.get("failed_operation").and_then(Value::as_str) != Some("observer_revoke") { + return None; + } + payload.get("unpaired").and_then(Value::as_str)?; + let mut stdout_value = String::new(); + push_line(&mut stdout_value, "Unpaired."); + let names = revoked_observer_names(payload); + if names.is_empty() { + push_line( + &mut stdout_value, + "No bound observer streams were revoked before failure.", + ); + } else { + push_line( + &mut stdout_value, + format!( + "Revoked observer streams before failure: {}", + names.join(", ") + ), + ); + } + Some(CommandOutput { + stdout: stdout_value, + stderr: "Error: failed to revoke all bound observer streams.\n".to_string(), + exit: 1, + }) +} + +fn revoked_observer_names(response: &Value) -> Vec { + response + .get("revoked_observers") + .and_then(Value::as_array) + .map(|items| { + items + .iter() + .filter_map(|item| item.get("name").and_then(Value::as_str)) + .filter(|name| !name.is_empty()) + .map(str::to_string) + .collect() + }) + .unwrap_or_default() +} + fn devices(ctx: CommandContext<'_>) -> Result, ClientError> { let body = request_json( ctx, diff --git a/solstone/apps/network/routes.py b/solstone/apps/network/routes.py index 22a115fe1..c6e96a557 100644 --- a/solstone/apps/network/routes.py +++ b/solstone/apps/network/routes.py @@ -55,13 +55,17 @@ from solstone.apps.network.relay_link import ( derive_rk, encode_pair_window_link, ) -from solstone.apps.observer.utils import revoke_observers_bound_to_device +from solstone.apps.observer.utils import ( + ObserverRevokeError, + revoke_observers_bound_to_device, +) from solstone.apps.utils import log_app_action from solstone.convey import emit from solstone.convey.bridge import get_cached_state from solstone.convey.reasons import ( CONVEY_OPERATION_FAILED, FILE_READ_FAILED, + INTERNAL_ERROR, INVALID_CONFIG_VALUE, INVALID_OPERATION_FOR_STATE, INVALID_REQUEST_VALUE, @@ -989,6 +993,17 @@ def pair() -> Any: return jsonify(response) +def _revoked_observer_projection(observers: list[dict]) -> list[dict[str, str]]: + projected = [ + { + "name": str(observer.get("name") or ""), + "prefix": str(observer.get("filename_prefix") or ""), + } + for observer in observers + ] + return sorted(projected, key=lambda item: (item["name"], item["prefix"])) + + @network_bp.route("/rename", methods=["POST"]) def rename() -> Any: """Rename a paired device by fingerprint.""" @@ -1092,8 +1107,24 @@ def unpair() -> Any: authorized.remove(fingerprint) else: authorized.remove(fingerprint) - revoke_observers_bound_to_device(fingerprint) - return jsonify({"unpaired": fingerprint}) + try: + revoked_observers = revoke_observers_bound_to_device(fingerprint) + except ObserverRevokeError as exc: + return error_response( + INTERNAL_ERROR, + detail="Failed to revoke one or more bound observer streams.", + extra={ + "unpaired": fingerprint, + "revoked_observers": _revoked_observer_projection(exc.revoked), + "failed_operation": "observer_revoke", + }, + ) + return jsonify( + { + "unpaired": fingerprint, + "revoked_observers": _revoked_observer_projection(revoked_observers), + } + ) def _entry_to_json(entry: ClientEntry) -> dict[str, Any]: diff --git a/solstone/apps/network/tests/test_unpair_record_revoke_parity.py b/solstone/apps/network/tests/test_unpair_record_revoke_parity.py index f8128ee1b..c8fef5afa 100644 --- a/solstone/apps/network/tests/test_unpair_record_revoke_parity.py +++ b/solstone/apps/network/tests/test_unpair_record_revoke_parity.py @@ -8,6 +8,7 @@ import logging from importlib import import_module import solstone.apps.network.routes as link_routes +import solstone.apps.observer.utils as observer_utils from solstone.apps.observer.utils import load_observer, save_observer from solstone.think.link.auth import AuthorizedClients from solstone.think.link.paths import authorized_clients_path @@ -56,10 +57,32 @@ def _save_bound_observer(handle: str, name: str, fingerprint: str) -> None: ) +def _save_unbound_observer(handle: str, name: str) -> None: + assert save_observer( + { + "key": handle, + "name": name, + "created_at": 1, + "enabled": True, + "revoked": False, + "stats": {"segments_received": 0, "bytes_received": 0}, + } + ) + + def _post_unpair(env, payload: dict): return env.client.post("/app/network/unpair", json=payload) +def _unpair_payload( + fingerprint: str, revoked_observers: list[dict] | None = None +) -> dict: + return { + "unpaired": fingerprint, + "revoked_observers": revoked_observers or [], + } + + def _action_entries(env) -> list[dict]: actions_dir = env.journal / "config" / "actions" entries = [] @@ -78,7 +101,7 @@ def test_unpair_phone_by_fingerprint_removes_authorized(link_env) -> None: response = _post_unpair(env, {"fingerprint": PHONE_FINGERPRINT}) assert response.status_code == 200 - assert response.get_json() == {"unpaired": PHONE_FINGERPRINT} + assert response.get_json() == _unpair_payload(PHONE_FINGERPRINT) assert _authorized().is_authorized(PHONE_FINGERPRINT) is False assert load_journal_source_by_fingerprint(PHONE_FINGERPRINT) is None @@ -93,13 +116,65 @@ def test_unpair_phone_revokes_bound_observer_records(link_env) -> None: response = _post_unpair(env, {"fingerprint": PHONE_FINGERPRINT}) assert response.status_code == 200 - assert response.get_json() == {"unpaired": PHONE_FINGERPRINT} + assert response.get_json() == _unpair_payload( + PHONE_FINGERPRINT, + [ + {"name": "phone-a", "prefix": "phone-a-"}, + {"name": "phone-b", "prefix": "phone-b-"}, + ], + ) assert _authorized().is_authorized(PHONE_FINGERPRINT) is False assert load_observer("phone-a-observer")["revoked"] is True assert load_observer("phone-b-observer")["revoked"] is True assert load_observer("other-observer")["revoked"] is False +def test_unpair_phone_leaves_unbound_observer_records_unrevoked(link_env) -> None: + env = link_env() + _add_authorized(PHONE_FINGERPRINT, "phone") + _save_unbound_observer("phone-a-observer", "phone-a") + _save_unbound_observer("phone-b-observer", "phone-b") + + response = _post_unpair(env, {"fingerprint": PHONE_FINGERPRINT}) + + assert response.status_code == 200 + assert response.get_json() == _unpair_payload(PHONE_FINGERPRINT) + assert _authorized().is_authorized(PHONE_FINGERPRINT) is False + assert load_observer("phone-a-observer")["revoked"] is False + assert load_observer("phone-b-observer")["revoked"] is False + + +def test_unpair_partial_observer_revoke_failure_reports_saved_revocations( + link_env, + monkeypatch, +) -> None: + env = link_env() + _add_authorized(PHONE_FINGERPRINT, "phone") + _save_bound_observer("phone-a-observer", "phone-a", PHONE_FINGERPRINT) + _save_bound_observer("phone-b-observer", "phone-b", PHONE_FINGERPRINT) + real_save_observer = observer_utils.save_observer + + def fail_second_bound_observer(observer: dict) -> bool: + if observer.get("name") == "phone-b": + return False + return real_save_observer(observer) + + monkeypatch.setattr(observer_utils, "save_observer", fail_second_bound_observer) + + response = _post_unpair(env, {"fingerprint": PHONE_FINGERPRINT}) + + assert response.status_code == 500 + body = response.get_json() + assert body["reason_code"] == "internal_error" + assert body["detail"] == "Failed to revoke one or more bound observer streams." + assert body["unpaired"] == PHONE_FINGERPRINT + assert body["failed_operation"] == "observer_revoke" + assert body["revoked_observers"] == [{"name": "phone-a", "prefix": "phone-a-"}] + assert _authorized().is_authorized(PHONE_FINGERPRINT) is False + assert load_observer("phone-a-observer")["revoked"] is True + assert load_observer("phone-b-observer")["revoked"] is False + + def test_unpair_cascade_runs_after_authorized_removal( link_env, monkeypatch, @@ -123,7 +198,7 @@ def test_unpair_cascade_runs_after_authorized_removal( response = _post_unpair(env, {"fingerprint": PHONE_FINGERPRINT}) assert response.status_code == 200 - assert response.get_json() == {"unpaired": PHONE_FINGERPRINT} + assert response.get_json() == _unpair_payload(PHONE_FINGERPRINT) assert authorized_present_during_cascade == [False] @@ -138,7 +213,7 @@ def test_unpair_unknown_role_removes_authorized_without_warning( response = _post_unpair(env, {"fingerprint": UNKNOWN_ROLE_FINGERPRINT}) assert response.status_code == 200 - assert response.get_json() == {"unpaired": UNKNOWN_ROLE_FINGERPRINT} + assert response.get_json() == _unpair_payload(UNKNOWN_ROLE_FINGERPRINT) assert _authorized().is_authorized(UNKNOWN_ROLE_FINGERPRINT) is False assert [ record @@ -162,7 +237,7 @@ def test_unpair_peer_revokes_source_removes_authorized_and_logs_action( response = _post_unpair(env, {"device_label": "peer"}) assert response.status_code == 200 - assert response.get_json() == {"unpaired": PEER_FINGERPRINT} + assert response.get_json() == _unpair_payload(PEER_FINGERPRINT) assert _authorized().is_authorized(PEER_FINGERPRINT) is False source = load_journal_source_by_fingerprint(PEER_FINGERPRINT) assert source is not None @@ -200,7 +275,7 @@ def test_unpair_peer_already_revoked_removes_authorized_and_warns( response = _post_unpair(env, {"fingerprint": PEER_FINGERPRINT}) assert response.status_code == 200 - assert response.get_json() == {"unpaired": PEER_FINGERPRINT} + assert response.get_json() == _unpair_payload(PEER_FINGERPRINT) assert _authorized().is_authorized(PEER_FINGERPRINT) is False source = load_journal_source_by_fingerprint(PEER_FINGERPRINT) assert source is not None @@ -221,7 +296,7 @@ def test_unpair_peer_missing_source_removes_authorized_and_warns( response = _post_unpair(env, {"fingerprint": PEER_FINGERPRINT}) assert response.status_code == 200 - assert response.get_json() == {"unpaired": PEER_FINGERPRINT} + assert response.get_json() == _unpair_payload(PEER_FINGERPRINT) assert _authorized().is_authorized(PEER_FINGERPRINT) is False assert load_journal_source_by_fingerprint(PEER_FINGERPRINT) is None assert "peer journal source missing" in caplog.text @@ -245,7 +320,7 @@ def test_unpair_peer_save_failure_removes_authorized_and_logs_error( response = _post_unpair(env, {"device_label": "peer-save-fails"}) assert response.status_code == 200 - assert response.get_json() == {"unpaired": PEER_FINGERPRINT} + assert response.get_json() == _unpair_payload(PEER_FINGERPRINT) assert _authorized().is_authorized(PEER_FINGERPRINT) is False source = load_journal_source_by_fingerprint(PEER_FINGERPRINT) assert source is not None diff --git a/solstone/apps/observer/utils.py b/solstone/apps/observer/utils.py index b330cf8e5..ea98a49dd 100644 --- a/solstone/apps/observer/utils.py +++ b/solstone/apps/observer/utils.py @@ -92,6 +92,11 @@ def observer_device_binding(record: dict[str, Any]) -> dict[str, str] | None: return None +def observer_device_binding_kind(record: dict[str, Any]) -> str | None: + binding = observer_device_binding(record) + return None if binding is None else binding["kind"] + + def get_observers_dir(*, ensure_exists: bool = True) -> Path: """Get the observers storage directory.""" return get_app_storage_path("observer", "observers", ensure_exists=ensure_exists) @@ -515,6 +520,12 @@ def revoke_observer_record(identifier: str) -> dict: return observer +class ObserverRevokeError(RuntimeError): + def __init__(self, message: str, revoked: list[dict]) -> None: + super().__init__(message) + self.revoked = revoked + + def revoke_observers_bound_to_device(device: str) -> list[dict]: """Revoke all observer records bound to a paired-device identity.""" revoked: list[dict] = [] @@ -530,7 +541,7 @@ def revoke_observers_bound_to_device(device: str) -> list[dict]: observer["revoked"] = True observer["revoked_at"] = revoked_at if not save_observer(observer): - raise RuntimeError("failed to save observer") + raise ObserverRevokeError("failed to save observer", revoked) key_prefix = observer_filename_prefix(observer) log_app_action( app="observer", diff --git a/solstone/observe/observer_cli.py b/solstone/observe/observer_cli.py index 7ab2df4d6..46ad3039f 100644 --- a/solstone/observe/observer_cli.py +++ b/solstone/observe/observer_cli.py @@ -30,6 +30,7 @@ from solstone.apps.observer.utils import ( get_hist_dir, list_observers, load_history, + observer_device_binding_kind, observer_filename_prefix, pruned_segments, revoke_observer_record, @@ -189,6 +190,7 @@ def cmd_list(args: argparse.Namespace) -> int: "name": r.get("name", ""), "prefix": observer_filename_prefix(r), "status": _status_label(r), + "device_binding_kind": observer_device_binding_kind(r), "last_seen": r.get("last_seen"), "last_segment_received_at": r.get("last_segment_received_at"), "last_segment_day": r.get("last_segment_day"), @@ -204,22 +206,23 @@ def cmd_list(args: argparse.Namespace) -> int: return 0 print( - f"{'Name':<20} {'Prefix':<18} {'Status':<14} " + f"{'Name':<20} {'Prefix':<18} {'Status':<14} {'Binding':<10} " f"{'Last Seen':<18} {'Last Segment':<12} {'Segments':>10} {'Bytes':>12}" ) - print("-" * 107) + print("-" * 118) for r in observers: name = r.get("name", "") prefix = observer_filename_prefix(r) status = _status_label(r) + binding = observer_device_binding_kind(r) or "unbound" last_seen = _fmt_time(r.get("last_seen")) last_segment = _fmt_compact_age(r.get("last_segment_received_at")) stats = r.get("stats", {}) segments = stats.get("segments_received", 0) bytes_recv = _fmt_bytes(stats.get("bytes_received", 0)) print( - f"{name:<20} {prefix:<18} {status:<14} " + f"{name:<20} {prefix:<18} {status:<14} {binding:<10} " f"{last_seen:<18} {last_segment:<12} {segments:>10} {bytes_recv:>12}" ) @@ -380,6 +383,7 @@ def _status_single(identifier: str, json_output: bool = False) -> int: "name": name, "prefix": key_prefix, "status": _status_label(observer), + "device_binding_kind": observer_device_binding_kind(observer), "created_at": observer.get("created_at"), "last_seen": observer.get("last_seen"), "last_segment_received_at": observer.get( @@ -424,6 +428,7 @@ def _status_single(identifier: str, json_output: bool = False) -> int: print(f"Observer: {name}") print_field("Prefix:", key_prefix) print_field("Status:", _status_label(observer)) + print_field("Binding:", observer_device_binding_kind(observer) or "unbound") print_field("Created:", _fmt_time(observer.get("created_at"))) print_field("Last seen:", _fmt_time(observer.get("last_seen"))) print_field("Last segment:", last_segment_context) @@ -496,6 +501,7 @@ def _status_all(json_output: bool = False) -> int: "name": r.get("name", ""), "prefix": observer_filename_prefix(r), "status": _status_label(r), + "device_binding_kind": observer_device_binding_kind(r), "last_seen": r.get("last_seen"), "last_segment_received_at": r.get( "last_segment_received_at" @@ -517,18 +523,20 @@ def _status_all(json_output: bool = False) -> int: print(f" Total bytes: {_fmt_bytes(total_bytes)}") print( - f"\n{'Name':<20} {'Prefix':<18} {'Status':<14} " + f"\n{'Name':<20} {'Prefix':<18} {'Status':<14} {'Binding':<10} " f"{'Last Seen':<18} {'Last Segment':<12}" ) - print("-" * 87) + print("-" * 98) for r in observers: name = r.get("name", "") prefix = observer_filename_prefix(r) status = _status_label(r) + binding = observer_device_binding_kind(r) or "unbound" last_seen = _fmt_time(r.get("last_seen")) last_segment = _fmt_compact_age(r.get("last_segment_received_at")) print( - f"{name:<20} {prefix:<18} {status:<14} {last_seen:<18} {last_segment:<12}" + f"{name:<20} {prefix:<18} {status:<14} {binding:<10} " + f"{last_seen:<18} {last_segment:<12}" ) return 0 diff --git a/solstone/think/capture_health.py b/solstone/think/capture_health.py index 01d178693..b7ff83eb1 100644 --- a/solstone/think/capture_health.py +++ b/solstone/think/capture_health.py @@ -35,6 +35,7 @@ def get_capture_health() -> dict: get_active_ingest_rejection, get_health_beacon, list_observers, + observer_device_binding_kind, ) try: @@ -73,6 +74,7 @@ def get_capture_health() -> dict: "name": o.get("name", "unknown"), "last_seen": last_seen, "status": obs_status, + "device_binding_kind": observer_device_binding_kind(o), } rejection = get_active_ingest_rejection(o) diff --git a/solstone/think/doctor.py b/solstone/think/doctor.py index 8662034f1..c0e39b525 100644 --- a/solstone/think/doctor.py +++ b/solstone/think/doctor.py @@ -157,6 +157,7 @@ JOURNAL_CAUGHT_UP_CHECK = Check("journal_caught_up", "advisory", ("linux", "darw JOURNAL_MAINT_TASKS_CHECK = Check("journal_maint_tasks", "blocker", ("linux", "darwin")) TASK_PACE_CHECK = Check("task_pace", "advisory", ("linux", "darwin")) CAPTURE_HEALTH_CHECK = Check("capture_health", "advisory", ("linux", "darwin")) +OBSERVER_BINDING_CHECK = Check("observer_binding", "advisory", ("linux", "darwin")) OBSERVER_INGEST_HEALTH_CHECK = Check( "observer_ingest_health", "advisory", ("linux", "darwin") ) @@ -1115,6 +1116,30 @@ def capture_health_check(args: Args) -> CheckResult: ) +def observer_binding_check(args: Args) -> CheckResult: + del args + result = get_capture_health() + observers = result.get("observers", []) + total = len(observers) + unbound = [ + observer + for observer in observers + if observer.get("device_binding_kind") is None + ] + if not unbound: + return make_result( + OBSERVER_BINDING_CHECK, + "ok", + f"active observer records={total}; unbound=0", + ) + names = ", ".join(str(observer.get("name", "unknown")) for observer in unbound) + return make_result( + OBSERVER_BINDING_CHECK, + "ok", + f"active observer records={total}; unbound={len(unbound)}; streams={names}", + ) + + def _observer_delivery_stall_clause( observer: dict, facts: dict, @@ -1475,6 +1500,7 @@ JOURNAL_CHECKS: list[tuple[Check, Runner]] = [ (TASK_PACE_CHECK, task_pace_check), (BRAIN_CHECK, brain_check), (CAPTURE_HEALTH_CHECK, capture_health_check), + (OBSERVER_BINDING_CHECK, observer_binding_check), (OBSERVER_DELIVERY_STALL_CHECK, observer_delivery_stall_check), (OBSERVER_INGEST_HEALTH_CHECK, observer_ingest_health_check), (ORPHAN_SEGMENT_PDF_CHECK, orphan_segment_pdf_check), diff --git a/tests/test_capture_health.py b/tests/test_capture_health.py index 791d101b1..28f43fb7d 100644 --- a/tests/test_capture_health.py +++ b/tests/test_capture_health.py @@ -33,6 +33,7 @@ def test_no_last_seen_is_offline(monkeypatch): result = get_capture_health() assert result["observers"][0]["status"] == "offline" + assert result["observers"][0]["device_binding_kind"] == "cert" def test_disabled_observers_excluded(monkeypatch): @@ -89,6 +90,7 @@ def test_degraded_status_from_rejection(monkeypatch): assert result["status"] == "degraded" observer = result["observers"][0] assert observer["status"] == "degraded" + assert observer["device_binding_kind"] == "cert" assert observer["ingest_rejection"]["reason_code"] == "ingest_contract_invalid" assert "segment" not in observer["ingest_rejection"] @@ -104,6 +106,7 @@ def test_legacy_observer_not_failed(monkeypatch): assert result["status"] == "active" assert result["observers"][0]["status"] == "active" + assert result["observers"][0]["device_binding_kind"] == "cert" assert "ingest_rejection" not in result["observers"][0] monkeypatch.setattr( @@ -122,6 +125,7 @@ def test_legacy_observer_not_failed(monkeypatch): assert result["status"] == "active" assert result["observers"][0]["status"] == "active" + assert result["observers"][0]["device_binding_kind"] == "cert" assert "ingest_rejection" not in result["observers"][0] assert result["observers"][0]["beacon"]["version"] == "0.3.1" @@ -138,4 +142,23 @@ def test_unbound_observer_uses_freshness_status(monkeypatch): assert result["status"] == "active" observer = result["observers"][0] assert observer["status"] == "active" - assert "unbound" not in observer + assert observer["device_binding_kind"] is None + + +def test_all_unbound_active_rolls_up_to_active(monkeypatch): + monkeypatch.setattr("solstone.think.capture_health.now_ms", lambda: 1000) + monkeypatch.setattr( + "solstone.apps.observer.utils.list_observers", + lambda: [ + {"name": "a", "enabled": True, "last_seen": 1000}, + {"name": "b", "enabled": True, "last_seen": 990}, + ], + ) + + result = get_capture_health() + + assert result["status"] == "active" + assert [observer["device_binding_kind"] for observer in result["observers"]] == [ + None, + None, + ] diff --git a/tests/test_journal_doctor.py b/tests/test_journal_doctor.py index 85797503d..d489b0646 100644 --- a/tests/test_journal_doctor.py +++ b/tests/test_journal_doctor.py @@ -339,6 +339,96 @@ def test_capture_health_check_maps_every_rollup_status(doctor, monkeypatch): assert result.detail == "rollup=unknown; observer records unavailable" +def test_observer_binding_check_counts_active_unbound_records(doctor, monkeypatch): + now = DOCTOR_NOW_MS + monkeypatch.setattr("solstone.think.capture_health.now_ms", lambda: now) + monkeypatch.setattr( + "solstone.apps.observer.utils.list_observers", + lambda: [ + {"name": "unbound-a", "enabled": True, "last_seen": now}, + {"name": "unbound-b", "enabled": True, "last_seen": now}, + bound_observer(name="bound", enabled=True, last_seen=now), + ], + ) + + result = doctor.observer_binding_check(args(doctor)) + + assert result.name == "observer_binding" + assert result.status == "ok" + assert result.detail == ( + "active observer records=3; unbound=2; streams=unbound-a, unbound-b" + ) + + +def test_observer_binding_check_ignores_revoked_unbound_records( + doctor, + monkeypatch, +): + now = DOCTOR_NOW_MS + monkeypatch.setattr("solstone.think.capture_health.now_ms", lambda: now) + monkeypatch.setattr( + "solstone.apps.observer.utils.list_observers", + lambda: [ + {"name": "unbound-a", "enabled": True, "last_seen": now}, + {"name": "revoked-unbound", "revoked": True, "last_seen": now}, + bound_observer(name="bound", enabled=True, last_seen=now), + ], + ) + + result = doctor.observer_binding_check(args(doctor)) + + assert result.status == "ok" + assert result.detail == "active observer records=2; unbound=1; streams=unbound-a" + + +def test_observer_binding_check_zero_observer_and_zero_unbound_wording( + doctor, + monkeypatch, +): + monkeypatch.setattr("solstone.apps.observer.utils.list_observers", lambda: []) + + result = doctor.observer_binding_check(args(doctor)) + + assert result.status == "ok" + assert result.detail == "active observer records=0; unbound=0" + + now = DOCTOR_NOW_MS + monkeypatch.setattr("solstone.think.capture_health.now_ms", lambda: now) + monkeypatch.setattr( + "solstone.apps.observer.utils.list_observers", + lambda: [bound_observer(name="bound", enabled=True, last_seen=now)], + ) + + result = doctor.observer_binding_check(args(doctor)) + + assert result.status == "ok" + assert result.detail == "active observer records=1; unbound=0" + + +def test_observer_binding_check_warning_counts_match_bound_and_unbound( + doctor, + monkeypatch, +): + now = DOCTOR_NOW_MS + monkeypatch.setattr("solstone.think.capture_health.now_ms", lambda: now) + monkeypatch.setattr( + "solstone.apps.observer.utils.list_observers", + lambda: [{"name": "unbound", "enabled": True, "last_seen": now}], + ) + unbound_counts = doctor.summary_counts( + [doctor.observer_binding_check(args(doctor))] + ) + + monkeypatch.setattr( + "solstone.apps.observer.utils.list_observers", + lambda: [bound_observer(name="bound", enabled=True, last_seen=now)], + ) + bound_counts = doctor.summary_counts([doctor.observer_binding_check(args(doctor))]) + + assert unbound_counts["failed"] == bound_counts["failed"] == 0 + assert unbound_counts["warnings"] == bound_counts["warnings"] == 0 + + def test_lockstep_stale_stamps_warn_on_capture_health_only(doctor, monkeypatch): now = DOCTOR_NOW_MS stale_stamp = now - (7 * HOUR_MS) @@ -694,8 +784,14 @@ def test_new_doctor_checks_are_registered(doctor): doctor.OBSERVER_DELIVERY_STALL_CHECK, doctor.observer_delivery_stall_check, ) in doctor.JOURNAL_CHECKS + assert ( + doctor.OBSERVER_BINDING_CHECK, + doctor.observer_binding_check, + ) in doctor.JOURNAL_CHECKS assert doctor.CAPTURE_HEALTH_CHECK.severity == "advisory" assert doctor.CAPTURE_HEALTH_CHECK.platforms == ("linux", "darwin") + assert doctor.OBSERVER_BINDING_CHECK.severity == "advisory" + assert doctor.OBSERVER_BINDING_CHECK.platforms == ("linux", "darwin") assert doctor.OBSERVER_DELIVERY_STALL_CHECK.severity == "advisory" assert doctor.OBSERVER_DELIVERY_STALL_CHECK.platforms == ("linux", "darwin") diff --git a/tests/test_observer_cli.py b/tests/test_observer_cli.py index cae793175..f5f51098c 100644 --- a/tests/test_observer_cli.py +++ b/tests/test_observer_cli.py @@ -440,6 +440,7 @@ def _table_cell(output: str, row: str, column: str) -> str: "Name", "Prefix", "Status", + "Binding", "Last Seen", "Last Segment", "Segments", @@ -756,6 +757,7 @@ def test_cmd_list_json_includes_prefix_and_status( rows = {row["name"]: row for row in json.loads(captured.out)} assert rows["desktop"]["prefix"] == "abcdefgh" assert rows["desktop"]["status"] == "disconnected" + assert rows["desktop"]["device_binding_kind"] == "cert" assert "mode" not in rows["desktop"] @@ -789,7 +791,9 @@ def test_missing_binding_uses_connection_status_in_list_and_status( assert rc == 0 rows = {row["name"]: row for row in json.loads(captured.out)} assert rows["unbound"]["status"] == "connected" + assert rows["unbound"]["device_binding_kind"] is None assert rows["revoked"]["status"] == "revoked" + assert rows["revoked"]["device_binding_kind"] is None rc = observer_cli.cmd_list(argparse.Namespace(json_output=False)) @@ -798,6 +802,9 @@ def test_missing_binding_uses_connection_status_in_list_and_status( assert _table_cell(captured.out, _table_row(captured.out, "unbound"), "Status") == ( "connected" ) + assert _table_cell( + captured.out, _table_row(captured.out, "unbound"), "Binding" + ) == ("unbound") assert _table_cell(captured.out, _table_row(captured.out, "revoked"), "Status") == ( "revoked" ) @@ -808,7 +815,9 @@ def test_missing_binding_uses_connection_status_in_list_and_status( captured = capsys.readouterr() assert rc == 0 - assert json.loads(captured.out)["status"] == "connected" + single = json.loads(captured.out) + assert single["status"] == "connected" + assert single["device_binding_kind"] is None rc = observer_cli.cmd_status( argparse.Namespace(identifier="unbound", json_output=False) @@ -817,6 +826,7 @@ def test_missing_binding_uses_connection_status_in_list_and_status( captured = capsys.readouterr() assert rc == 0 assert " Status: connected\n" in captured.out + assert " Binding: unbound\n" in captured.out rc = observer_cli.cmd_status(argparse.Namespace(identifier=None, json_output=True)) @@ -830,6 +840,12 @@ def test_missing_binding_uses_connection_status_in_list_and_status( "unbound": "connected", "revoked": "revoked", } + assert { + row["name"]: row["device_binding_kind"] for row in payload["observers"] + } == { + "unbound": None, + "revoked": None, + } rc = observer_cli.cmd_status(argparse.Namespace(identifier=None, json_output=False)) @@ -839,6 +855,9 @@ def test_missing_binding_uses_connection_status_in_list_and_status( assert _table_cell(captured.out, _table_row(captured.out, "unbound"), "Status") == ( "connected" ) + assert _table_cell( + captured.out, _table_row(captured.out, "unbound"), "Binding" + ) == ("unbound") def test_fmt_compact_age_units_and_guards( -- 2.51.2 From 19d29f6f1d2996bbfd73be5569a1ad56c2aa2910 Mon Sep 17 00:00:00 2001 From: Jer Miller Date: Fri, 31 Jul 2026 21:34:55 -0600 Subject: [PATCH 3/5] fix(observer): refuse manual observer minting Keep the observer create route but return the retired-operation response, remove the workspace add form, and migrate tests to registration helpers that preserve bound and unbound shapes. Refuse URL export destinations and keep peer-label export coverage on the live path. --- solstone/apps/observer/routes.py | 54 +-- solstone/apps/observer/tests/conftest.py | 57 +-- solstone/apps/observer/tests/test_events.py | 12 +- .../tests/test_ingest_rejection_log.py | 6 +- solstone/apps/observer/tests/test_routes.py | 414 ++++-------------- .../tests/test_workspace_html_terminology.py | 5 - solstone/apps/observer/workspace.html | 95 +--- solstone/observe/export.py | 17 +- tests/observer_registration_helpers.py | 63 +++ tests/test_bad_media_corpus.py | 20 +- tests/test_export_integration.py | 33 +- tests/test_export_pl.py | 70 +-- 12 files changed, 268 insertions(+), 578 deletions(-) create mode 100644 tests/observer_registration_helpers.py diff --git a/solstone/apps/observer/routes.py b/solstone/apps/observer/routes.py index 160543fe2..b1e3f0084 100644 --- a/solstone/apps/observer/routes.py +++ b/solstone/apps/observer/routes.py @@ -52,6 +52,7 @@ from solstone.convey.reasons import ( INVALID_SEGMENT_OR_STREAM, LOCAL_REQUEST_ONLY, MISSING_REQUIRED_FIELD, + OPERATION_NO_LONGER_AVAILABLE, PAIRED_DEVICE_NOT_FOUND, PL_REVOKED, SETTINGS_OPERATION_FAILED, @@ -462,54 +463,15 @@ def callosum_sse() -> Any: @observer_bp.route("/api/create", methods=["POST"]) def api_create() -> Any: - """Create a new observer registration (legacy web-UI mint). - - Used only by the observer management page's "add observer" button - (apps/observer/workspace.html). Auto-registering observer clients use - POST /app/observer/register instead, which takes a self-descriptor and - locks a stream identity onto the record. This route is kept for the - human-facing management flow; it always mints and returns ``prefix``. - """ - data = request.get_json(force=True) if request.is_json else {} - name = data.get("name", "").strip() - if not name: - return error_response(MISSING_REQUIRED_FIELD, detail="Name is required") - - # Generate key - key = _generate_key() - - # Create observer record - observer_data = { - "key": key, - "name": name, - "created_at": now_ms(), - "last_seen": None, - "last_segment": None, - "last_segment_received_at": None, - "last_segment_day": None, - "enabled": True, - "stats": { - "segments_received": 0, - "bytes_received": 0, - }, - } - - if not save_observer(observer_data): - return error_response( - SETTINGS_OPERATION_FAILED, - detail="Failed to save observer", - ) - - # Log observer creation (journal-level, no facet) - log_app_action( - app="observer", - facet=None, - action="observer_create", - params={"name": name, "key_prefix": key[:8]}, + """Refuse retired hand-created observer registrations.""" + return error_response( + OPERATION_NO_LONGER_AVAILABLE, + detail=( + "Observer records are no longer created by hand. " + "A device registers itself when you pair it." + ), ) - return jsonify(_register_descriptor(observer_data)) - _REGISTER_REQUIRED_FIELDS = ("platform", "hostname", "stream_type", "version") _REGISTER_LOOPBACK_REMOTE_ADDRS = frozenset({"127.0.0.1", "::1", "localhost"}) diff --git a/solstone/apps/observer/tests/conftest.py b/solstone/apps/observer/tests/conftest.py index 9164abec1..eeb99765d 100644 --- a/solstone/apps/observer/tests/conftest.py +++ b/solstone/apps/observer/tests/conftest.py @@ -23,6 +23,12 @@ if str(ROOT) not in sys.path: TEST_PL_FINGERPRINT = "sha256:" + ("c" * 64) +from tests.observer_registration_helpers import ( # noqa: E402 + pl_identity, + register_bound_observer, + register_unbound_observer, +) + @pytest.fixture(scope="module") def observer_app(): @@ -54,22 +60,12 @@ def observer_env(tmp_path, monkeypatch, observer_app): from solstone.convey import root as convey_root from solstone.convey import state - from solstone.convey.secure_listener import ConveyIdentity from solstone.observe.protocol import OBSERVER_HANDLE_HEADER from solstone.think.link.auth import AuthorizedClients from solstone.think.link.paths import authorized_clients_path original_journal_root = state.journal_root - def _pl_identity() -> ConveyIdentity: - return ConveyIdentity( - mode="pl-via-spl", - fingerprint=TEST_PL_FINGERPRINT, - device_label="pl-observer", - paired_at="2026-05-20T00:00:00Z", - session_id="session-1", - ) - class BoundObserverClient: def __init__(self, client): self._client = client @@ -88,35 +84,12 @@ def observer_env(tmp_path, monkeypatch, observer_app): and isinstance(headers, dict) and ("Authorization" in headers or OBSERVER_HANDLE_HEADER in headers) ): - overrides["pl.identity"] = _pl_identity() + overrides["pl.identity"] = pl_identity(TEST_PL_FINGERPRINT) adjusted["environ_overrides"] = overrides return adjusted - def _bind_created_observer(self, response) -> None: - if response.status_code != 200: - return - data = response.get_json(silent=True) - key = data.get("key") if isinstance(data, dict) else None - if not isinstance(key, str) or not key: - return - from solstone.apps.observer.utils import load_observer, save_observer - - observer = load_observer(key) - if observer is None: - return - observer["device_binding"] = { - "device": TEST_PL_FINGERPRINT, - "kind": "cert", - } - assert save_observer(observer) - def post(self, path: str, *args: Any, **kwargs: Any): - response = self._client.post( - path, *args, **self._request_kwargs(path, kwargs) - ) - if path == "/app/observer/api/create": - self._bind_created_observer(response) - return response + return self._client.post(path, *args, **self._request_kwargs(path, kwargs)) def get(self, path: str, *args: Any, **kwargs: Any): return self._client.get(path, *args, **self._request_kwargs(path, kwargs)) @@ -153,13 +126,25 @@ def observer_env(tmp_path, monkeypatch, observer_app): paired_at="2026-05-20T00:00:00Z", ) monkeypatch.setattr(convey_root, "get_authorized_clients", lambda: authorized) - client = BoundObserverClient(observer_app.test_client()) + raw_client = observer_app.test_client() + client = BoundObserverClient(raw_client) class Env: def __init__(self): self.journal = journal self.client = client + self.unbound_client = raw_client self.app = observer_app + self.register_bound_observer = lambda name: register_bound_observer( + raw_client, + name, + TEST_PL_FINGERPRINT, + ) + self.register_unbound_observer = lambda name: register_unbound_observer( + raw_client, + name, + ) + self.pl_identity = lambda: pl_identity(TEST_PL_FINGERPRINT) return Env() diff --git a/solstone/apps/observer/tests/test_events.py b/solstone/apps/observer/tests/test_events.py index 174670281..465a4278c 100644 --- a/solstone/apps/observer/tests/test_events.py +++ b/solstone/apps/observer/tests/test_events.py @@ -57,11 +57,7 @@ def observer_journal(tmp_path, monkeypatch): def test_status_beacon_persisted_allowlisted(observer_env): env = observer_env() - resp = env.client.post( - "/app/observer/api/create", - json={"name": "beacon-test"}, - content_type="application/json", - ) + resp = env.register_bound_observer("beacon-test") assert resp.status_code == 200 key = resp.get_json()["key"] @@ -102,11 +98,7 @@ def test_status_beacon_persisted_allowlisted(observer_env): def test_legacy_status_event_no_beacon(observer_env): env = observer_env() - resp = env.client.post( - "/app/observer/api/create", - json={"name": "legacy-status-test"}, - content_type="application/json", - ) + resp = env.register_bound_observer("legacy-status-test") assert resp.status_code == 200 key = resp.get_json()["key"] diff --git a/solstone/apps/observer/tests/test_ingest_rejection_log.py b/solstone/apps/observer/tests/test_ingest_rejection_log.py index 9b176e1c3..d4137b797 100644 --- a/solstone/apps/observer/tests/test_ingest_rejection_log.py +++ b/solstone/apps/observer/tests/test_ingest_rejection_log.py @@ -74,11 +74,7 @@ def _tick_rejection( def _create_observer(env, name: str) -> str: - resp = env.client.post( - "/app/observer/api/create", - json={"name": name}, - content_type="application/json", - ) + resp = env.register_bound_observer(name) assert resp.status_code == 200 return resp.get_json()["key"] diff --git a/solstone/apps/observer/tests/test_routes.py b/solstone/apps/observer/tests/test_routes.py index c6a4612a9..d3bf965ea 100644 --- a/solstone/apps/observer/tests/test_routes.py +++ b/solstone/apps/observer/tests/test_routes.py @@ -99,11 +99,7 @@ def _day_dir(env, day: str = "20250103"): def _create_observer(env, name: str) -> str: - resp = env.client.post( - "/app/observer/api/create", - json={"name": name}, - content_type="application/json", - ) + resp = env.register_bound_observer(name) assert resp.status_code == 200 return resp.get_json()["key"] @@ -655,15 +651,11 @@ def test_api_list_empty(observer_env): } -def test_api_create_observer(observer_env): - """Test creating a new observer.""" +def test_register_bound_observer_helper_returns_descriptor(observer_env): + """Test creating a registered observer fixture.""" env = observer_env() - resp = env.client.post( - "/app/observer/api/create", - json={"name": "test-laptop"}, - content_type="application/json", - ) + resp = env.register_bound_observer("test-laptop") assert resp.status_code == 200 data = resp.get_json() @@ -678,26 +670,21 @@ def test_api_create_observer(observer_env): assert data["protocol_version"] == OBSERVER_PROTOCOL_VERSION -def test_api_create_requires_name(observer_env): - """Test that creating a observer requires a name.""" +def test_api_create_refuses_hand_mint(observer_env): env = observer_env() - # Missing name resp = env.client.post( "/app/observer/api/create", - json={}, + json={"name": "test-laptop"}, content_type="application/json", ) - assert resp.status_code == 400 - assert "Name is required" in resp.get_json()["detail"] - - # Empty name - resp = env.client.post( - "/app/observer/api/create", - json={"name": " "}, - content_type="application/json", + body = resp.get_json() + assert resp.status_code == 410 + assert body["reason_code"] == "operation_no_longer_available" + assert body["detail"] == ( + "Observer records are no longer created by hand. " + "A device registers itself when you pair it." ) - assert resp.status_code == 400 def test_api_list_shows_created_observer(observer_env): @@ -705,11 +692,7 @@ def test_api_list_shows_created_observer(observer_env): env = observer_env() # Create a observer - resp = env.client.post( - "/app/observer/api/create", - json={"name": "my-observer"}, - content_type="application/json", - ) + resp = env.register_bound_observer("my-observer") assert resp.status_code == 200 key_prefix = resp.get_json()["prefix"] @@ -734,11 +717,7 @@ def test_api_list_shows_created_observer(observer_env): def test_api_list_includes_last_chat_request_at(observer_env): env = observer_env() - resp = env.client.post( - "/app/observer/api/create", - json={"name": "my-observer"}, - content_type="application/json", - ) + resp = env.register_bound_observer("my-observer") assert resp.status_code == 200 key_prefix = resp.get_json()["prefix"] handle = convey_bridge.register_sse_subscriber(key_prefix) @@ -761,11 +740,7 @@ def test_api_delete_observer(observer_env): env = observer_env() # Create a observer - resp = env.client.post( - "/app/observer/api/create", - json={"name": "to-revoke"}, - content_type="application/json", - ) + resp = env.register_bound_observer("to-revoke") key_prefix = resp.get_json()["prefix"] # Revoke it @@ -788,11 +763,7 @@ def test_api_delete_observer(observer_env): def test_api_delete_dl_observer_does_not_touch_authorized_clients(observer_env): env = observer_env() - resp = env.client.post( - "/app/observer/api/create", - json={"name": "dl-delete"}, - content_type="application/json", - ) + resp = env.register_unbound_observer("dl-delete") key_prefix = resp.get_json()["prefix"] fingerprint = "sha256:" + ("e" * 64) AuthorizedClients(authorized_clients_path()).add( @@ -1091,11 +1062,7 @@ def test_delete_source_requires_auth(observer_env): def test_delete_source_hard_pin_rejects_other_stream(observer_env): """A valid observer key can only delete allowlisted source streams.""" env = observer_env() - create_resp = env.client.post( - "/app/observer/api/create", - json={"name": "test-observer"}, - content_type="application/json", - ) + create_resp = env.register_bound_observer("test-observer") key = create_resp.get_json()["key"] headers = {"Authorization": f"Bearer {key}"} @@ -1150,11 +1117,7 @@ def test_delete_source_hard_pin_rejects_other_stream(observer_env): def test_delete_source_location_happy_path(observer_env): env = observer_env() - create_resp = env.client.post( - "/app/observer/api/create", - json={"name": "test-observer"}, - content_type="application/json", - ) + create_resp = env.register_bound_observer("test-observer") key = create_resp.get_json()["key"] seg_dir = _plant_location_segment(env) @@ -1177,11 +1140,7 @@ def test_ingest_missing_segment(observer_env): env = observer_env() # Create a observer - resp = env.client.post( - "/app/observer/api/create", - json={"name": "test"}, - content_type="application/json", - ) + resp = env.register_bound_observer("test") key = resp.get_json()["key"] # Upload without segment @@ -1199,11 +1158,7 @@ def test_ingest_missing_day(observer_env): env = observer_env() # Create a observer - resp = env.client.post( - "/app/observer/api/create", - json={"name": "test"}, - content_type="application/json", - ) + resp = env.register_bound_observer("test") key = resp.get_json()["key"] # Upload without day @@ -1221,11 +1176,7 @@ def test_ingest_invalid_segment_format(observer_env): env = observer_env() # Create a observer - resp = env.client.post( - "/app/observer/api/create", - json={"name": "test"}, - content_type="application/json", - ) + resp = env.register_bound_observer("test") key = resp.get_json()["key"] # Invalid segment format @@ -1243,11 +1194,7 @@ def test_ingest_invalid_day_format(observer_env): env = observer_env() # Create a observer - resp = env.client.post( - "/app/observer/api/create", - json={"name": "test"}, - content_type="application/json", - ) + resp = env.register_bound_observer("test") key = resp.get_json()["key"] # Invalid day format @@ -1265,11 +1212,7 @@ def test_ingest_no_files(observer_env): env = observer_env() # Create a observer - resp = env.client.post( - "/app/observer/api/create", - json={"name": "test"}, - content_type="application/json", - ) + resp = env.register_bound_observer("test") key = resp.get_json()["key"] # Upload without files @@ -1287,11 +1230,7 @@ def test_ingest_success(observer_env): env = observer_env() # Create a observer - resp = env.client.post( - "/app/observer/api/create", - json={"name": "test-observer"}, - content_type="application/json", - ) + resp = env.register_bound_observer("test-observer") key = resp.get_json()["key"] # Upload a file @@ -1317,6 +1256,29 @@ def test_ingest_success(observer_env): assert expected_file.read_bytes() == test_data +def test_ingest_unbound_record_authenticates_without_pl_identity(observer_env): + env = observer_env() + resp = env.register_unbound_observer("unbound-ingest") + assert resp.status_code == 200 + key = resp.get_json()["key"] + + test_data = b"unbound audio content" + resp = env.unbound_client.post( + "/app/observer/ingest", + headers={"Authorization": f"Bearer {key}"}, + data={ + "day": "20250103", + "segment": "120000_300", + "files": (io.BytesIO(test_data), "test_audio.flac"), + }, + ) + + assert resp.status_code == 200 + data = resp.get_json() + assert data["status"] == "ok" + assert data["files"] == ["test_audio.flac"] + + def test_ingest_mixed_segment_stores_all_sources(observer_env): env = observer_env() key = _create_observer(env, "pixel") @@ -1366,11 +1328,7 @@ def test_ingest_reuses_startup_contract_bundle(observer_env, monkeypatch): env = observer_env() - resp = env.client.post( - "/app/observer/api/create", - json={"name": "contract-cache-test"}, - content_type="application/json", - ) + resp = env.register_bound_observer("contract-cache-test") key = resp.get_json()["key"] for index in range(2): @@ -1401,11 +1359,7 @@ def test_ingest_updates_stats(observer_env): env = observer_env() # Create a observer - resp = env.client.post( - "/app/observer/api/create", - json={"name": "stats-test"}, - content_type="application/json", - ) + resp = env.register_bound_observer("stats-test") key = resp.get_json()["key"] # Upload a file @@ -1546,11 +1500,7 @@ def test_ingest_event_relay(observer_env): env = observer_env() # Create a observer - resp = env.client.post( - "/app/observer/api/create", - json={"name": "event-test"}, - content_type="application/json", - ) + resp = env.register_bound_observer("event-test") key = resp.get_json()["key"] # Send an event @@ -1585,11 +1535,7 @@ def test_ingest_event_missing_tract(observer_env): env = observer_env() # Create a observer - resp = env.client.post( - "/app/observer/api/create", - json={"name": "test"}, - content_type="application/json", - ) + resp = env.register_bound_observer("test") key = resp.get_json()["key"] # Missing tract @@ -1608,11 +1554,7 @@ def test_ingest_revoked_key(observer_env): env = observer_env() # Create and revoke a observer - resp = env.client.post( - "/app/observer/api/create", - json={"name": "revoked-test"}, - content_type="application/json", - ) + resp = env.register_bound_observer("revoked-test") data = resp.get_json() key = data["key"] key_prefix = data["prefix"] @@ -1638,11 +1580,7 @@ def test_ingest_revoked_key(observer_env): def test_keyless_ingest_bearer_rejects_revoked_and_disabled_keys(observer_env): env = observer_env() - resp = env.client.post( - "/app/observer/api/create", - json={"name": "keyless-revoked-test"}, - content_type="application/json", - ) + resp = env.register_bound_observer("keyless-revoked-test") revoked_data = resp.get_json() revoked_key = revoked_data["key"] @@ -1663,11 +1601,7 @@ def test_keyless_ingest_bearer_rejects_revoked_and_disabled_keys(observer_env): assert body["reason_code"] == "pl_revoked" assert body["detail"] == "Observer revoked" - resp = env.client.post( - "/app/observer/api/create", - json={"name": "keyless-disabled-test"}, - content_type="application/json", - ) + resp = env.register_bound_observer("keyless-disabled-test") disabled_data = resp.get_json() disabled_key = disabled_data["key"] assert save_observer( @@ -1707,11 +1641,7 @@ def test_ingest_event_revoked_key(observer_env): env = observer_env() # Create and revoke a observer - resp = env.client.post( - "/app/observer/api/create", - json={"name": "revoked-event-test"}, - content_type="application/json", - ) + resp = env.register_bound_observer("revoked-event-test") data = resp.get_json() key = data["key"] key_prefix = data["prefix"] @@ -1803,11 +1733,7 @@ def test_observer_health_missing_and_invalid_identity(observer_env): def test_observer_health_revoked_key(observer_env): env = observer_env() - resp = env.client.post( - "/app/observer/api/create", - json={"name": "revoked-health-test"}, - content_type="application/json", - ) + resp = env.register_bound_observer("revoked-health-test") data = resp.get_json() key = data["key"] @@ -1829,11 +1755,7 @@ def test_api_get_key(observer_env): env = observer_env() # Create a observer - resp = env.client.post( - "/app/observer/api/create", - json={"name": "key-test"}, - content_type="application/json", - ) + resp = env.register_bound_observer("key-test") create_data = resp.get_json() key = create_data["key"] key_prefix = create_data["prefix"] @@ -1856,11 +1778,7 @@ def test_mint_responses_protocol_version_single_source_and_keyless_unconditional monkeypatch.setattr("solstone.observe.protocol.OBSERVER_PROTOCOL_VERSION", 99) env = observer_env() - resp = env.client.post( - "/app/observer/api/create", - json={"name": "mint-protocol-test"}, - content_type="application/json", - ) + resp = env.register_bound_observer("mint-protocol-test") assert resp.status_code == 200 create_data = resp.get_json() assert create_data["protocol_version"] == 99 @@ -1886,11 +1804,7 @@ def test_api_get_key_revoked(observer_env): env = observer_env() # Create then revoke - resp = env.client.post( - "/app/observer/api/create", - json={"name": "revoke-key-test"}, - content_type="application/json", - ) + resp = env.register_bound_observer("revoke-key-test") create_data = resp.get_json() key_prefix = create_data["prefix"] @@ -1908,11 +1822,7 @@ def test_api_get_key_audit_log(observer_env): env = observer_env() - resp = env.client.post( - "/app/observer/api/create", - json={"name": "audit-test"}, - content_type="application/json", - ) + resp = env.register_bound_observer("audit-test") create_data = resp.get_json() key_prefix = create_data["prefix"] @@ -1962,11 +1872,7 @@ def test_ingest_collision_adjusts_segment(observer_env): env = observer_env() # Create a observer - resp = env.client.post( - "/app/observer/api/create", - json={"name": "collision-test"}, - content_type="application/json", - ) + resp = env.register_bound_observer("collision-test") key = resp.get_json()["key"] # Create a conflicting segment directory under the stream @@ -2011,11 +1917,7 @@ def test_ingest_no_collision_preserves_segment(observer_env): env = observer_env() # Create a observer - resp = env.client.post( - "/app/observer/api/create", - json={"name": "no-collision-test"}, - content_type="application/json", - ) + resp = env.register_bound_observer("no-collision-test") key = resp.get_json()["key"] # Upload without any conflicting segment directory @@ -2045,11 +1947,7 @@ def test_ingest_stats_use_adjusted_segment(observer_env): env = observer_env() # Create a observer - resp = env.client.post( - "/app/observer/api/create", - json={"name": "stats-adjust-test"}, - content_type="application/json", - ) + resp = env.register_bound_observer("stats-adjust-test") key = resp.get_json()["key"] # Spec §5.2.6: the ladder fires on conflicting content at the requested @@ -2094,11 +1992,7 @@ def test_ingest_creates_sync_history(observer_env): env = observer_env() # Create a observer - resp = env.client.post( - "/app/observer/api/create", - json={"name": "history-test"}, - content_type="application/json", - ) + resp = env.register_bound_observer("history-test") data = resp.get_json() key = data["key"] key_prefix = data["prefix"] @@ -2149,11 +2043,7 @@ def test_ingest_history_with_collision(observer_env): env = observer_env() # Create a observer - resp = env.client.post( - "/app/observer/api/create", - json={"name": "collision-history-test"}, - content_type="application/json", - ) + resp = env.register_bound_observer("collision-history-test") data = resp.get_json() key = data["key"] key_prefix = data["prefix"] @@ -2208,11 +2098,7 @@ def test_segments_endpoint_empty(observer_env): env = observer_env() # Create a observer - resp = env.client.post( - "/app/observer/api/create", - json={"name": "segments-empty-test"}, - content_type="application/json", - ) + resp = env.register_bound_observer("segments-empty-test") key = resp.get_json()["key"] # Query segments - should be empty @@ -2242,11 +2128,7 @@ def test_segments_endpoint_invalid_day(observer_env): env = observer_env() # Create a observer - resp = env.client.post( - "/app/observer/api/create", - json={"name": "segments-day-test"}, - content_type="application/json", - ) + resp = env.register_bound_observer("segments-day-test") key = resp.get_json()["key"] resp = env.client.get( @@ -2262,11 +2144,7 @@ def test_segments_endpoint_lists_uploads(observer_env): env = observer_env() # Create a observer - resp = env.client.post( - "/app/observer/api/create", - json={"name": "segments-list-test"}, - content_type="application/json", - ) + resp = env.register_bound_observer("segments-list-test") key = resp.get_json()["key"] # Upload a file @@ -2312,11 +2190,7 @@ def test_segments_endpoint_omits_submitted_name_when_name_unchanged(observer_env """Test segments endpoint omits submitted_name when no filename rewrite occurred.""" env = observer_env() - resp = env.client.post( - "/app/observer/api/create", - json={"name": "segments-no-rewrite-test"}, - content_type="application/json", - ) + resp = env.register_bound_observer("segments-no-rewrite-test") key = resp.get_json()["key"] test_data = b"test audio content" @@ -2345,11 +2219,7 @@ def test_segments_endpoint_v2_empty(observer_env): """Test v2 segments endpoint returns collection envelope for no uploads.""" env = observer_env() - resp = env.client.post( - "/app/observer/api/create", - json={"name": "segments-v2-empty-test"}, - content_type="application/json", - ) + resp = env.register_bound_observer("segments-v2-empty-test") key = resp.get_json()["key"] resp = env.client.get( @@ -2372,11 +2242,7 @@ def test_segments_endpoint_v2_populated(observer_env): """Test v2 segments endpoint envelopes uploaded segments.""" env = observer_env() - resp = env.client.post( - "/app/observer/api/create", - json={"name": "segments-v2-list-test"}, - content_type="application/json", - ) + resp = env.register_bound_observer("segments-v2-list-test") key = resp.get_json()["key"] test_data = b"test audio content" @@ -2414,11 +2280,7 @@ def test_protocol_version_single_source(observer_env, monkeypatch): monkeypatch.setattr("solstone.observe.protocol.OBSERVER_PROTOCOL_VERSION", 99) env = observer_env() - resp = env.client.post( - "/app/observer/api/create", - json={"name": "segments-patched-protocol-test"}, - content_type="application/json", - ) + resp = env.register_bound_observer("segments-patched-protocol-test") key = resp.get_json()["key"] resp = env.client.get( @@ -2479,11 +2341,7 @@ def test_segments_endpoint_shows_collision(observer_env): env = observer_env() # Create a observer - resp = env.client.post( - "/app/observer/api/create", - json={"name": "segments-collision-test"}, - content_type="application/json", - ) + resp = env.register_bound_observer("segments-collision-test") key = resp.get_json()["key"] # Spec §5.2.6: the ladder fires on conflicting content at the requested @@ -2531,11 +2389,7 @@ def test_segments_endpoint_missing_file(observer_env): env = observer_env() # Create a observer - resp = env.client.post( - "/app/observer/api/create", - json={"name": "segments-missing-test"}, - content_type="application/json", - ) + resp = env.register_bound_observer("segments-missing-test") key = resp.get_json()["key"] # Upload a file @@ -2902,11 +2756,7 @@ def test_segments_endpoint_revoked_key(observer_env): env = observer_env() # Create and revoke a observer - resp = env.client.post( - "/app/observer/api/create", - json={"name": "segments-revoked-test"}, - content_type="application/json", - ) + resp = env.register_bound_observer("segments-revoked-test") data = resp.get_json() key = data["key"] key_prefix = data["prefix"] @@ -2931,11 +2781,7 @@ def test_segments_endpoint_deduplicates_by_sha256(observer_env): env = observer_env() # Create a observer - resp = env.client.post( - "/app/observer/api/create", - json={"name": "segments-dedup-test"}, - content_type="application/json", - ) + resp = env.register_bound_observer("segments-dedup-test") key = resp.get_json()["key"] # Upload a file @@ -2985,11 +2831,7 @@ def test_segments_endpoint_shows_observed_status(observer_env): env = observer_env() # Create a observer - resp = env.client.post( - "/app/observer/api/create", - json={"name": "observed-test"}, - content_type="application/json", - ) + resp = env.register_bound_observer("observed-test") data = resp.get_json() key = data["key"] key_prefix = data["prefix"] @@ -3038,11 +2880,7 @@ def test_api_list_includes_segments_observed_stat(observer_env): env = observer_env() # Create a observer - resp = env.client.post( - "/app/observer/api/create", - json={"name": "stats-test"}, - content_type="application/json", - ) + resp = env.register_bound_observer("stats-test") data = resp.get_json() key_prefix = data["prefix"] @@ -3074,11 +2912,7 @@ def test_ingest_duplicate_segment_returns_duplicate_status(observer_env): env = observer_env() # Create a observer - resp = env.client.post( - "/app/observer/api/create", - json={"name": "duplicate-test"}, - content_type="application/json", - ) + resp = env.register_bound_observer("duplicate-test") key = resp.get_json()["key"] # First upload @@ -3216,11 +3050,7 @@ def test_ingest_duplicate_does_not_emit_event(observer_env, monkeypatch): monkeypatch.setattr(routes_module, "emit", emit_mock) # Create a observer - resp = env.client.post( - "/app/observer/api/create", - json={"name": "no-event-test"}, - content_type="application/json", - ) + resp = env.register_bound_observer("no-event-test") key = resp.get_json()["key"] test_data = b"test audio for event test" @@ -3258,11 +3088,7 @@ def test_ingest_duplicate_increments_duplicates_rejected_stat(observer_env): env = observer_env() # Create a observer - resp = env.client.post( - "/app/observer/api/create", - json={"name": "dup-stat-test"}, - content_type="application/json", - ) + resp = env.register_bound_observer("dup-stat-test") key = resp.get_json()["key"] test_data = b"test audio for stat test" @@ -3306,11 +3132,7 @@ def test_ingest_conflicting_content_uses_deterministic_ladder(observer_env): env = observer_env() # Create a observer - resp = env.client.post( - "/app/observer/api/create", - json={"name": "partial-dup-test"}, - content_type="application/json", - ) + resp = env.register_bound_observer("partial-dup-test") key = resp.get_json()["key"] audio_data = b"test audio content" @@ -3371,11 +3193,7 @@ def test_ingest_same_segment_addition_records_written_file(observer_env): env = observer_env() # Create a observer - resp = env.client.post( - "/app/observer/api/create", - json={"name": "partial-log-test"}, - content_type="application/json", - ) + resp = env.register_bound_observer("partial-log-test") data = resp.get_json() key = data["key"] key_prefix = data["prefix"] @@ -4578,11 +4396,7 @@ def test_ingest_returns_collision_status_when_adjusted(observer_env): env = observer_env() # Create a observer - resp = env.client.post( - "/app/observer/api/create", - json={"name": "collision-status-test"}, - content_type="application/json", - ) + resp = env.register_bound_observer("collision-status-test") key = resp.get_json()["key"] # Spec §5.2.6: the ladder fires on conflicting content at the requested @@ -4616,11 +4430,7 @@ def test_ingest_zero_byte_file_rejected(observer_env): env = observer_env() # Create a observer - resp = env.client.post( - "/app/observer/api/create", - json={"name": "test-observer"}, - content_type="application/json", - ) + resp = env.register_bound_observer("test-observer") key = resp.get_json()["key"] # Upload a 0-byte file @@ -4642,11 +4452,7 @@ def test_ingest_mixed_zero_byte_files(observer_env): env = observer_env() # Create a observer - resp = env.client.post( - "/app/observer/api/create", - json={"name": "test-observer"}, - content_type="application/json", - ) + resp = env.register_bound_observer("test-observer") key = resp.get_json()["key"] # Upload one valid file and one 0-byte file @@ -4686,11 +4492,7 @@ def test_ingest_contract_sidecar_invalid_quarantined_without_emit( lambda tract, event, **fields: emitted.append((tract, event, fields)), ) - resp = env.client.post( - "/app/observer/api/create", - json={"name": "contract-test"}, - content_type="application/json", - ) + resp = env.register_bound_observer("contract-test") key = resp.get_json()["key"] invalid_audio = b'{"raw":"audio.flac"}\n{"start":"00:00:00"}\n' @@ -4740,7 +4542,7 @@ def test_ingest_contract_invalid_records_rejection(observer_env): assert rejection["segment"] == "120000_300" assert rejection["stream"] == "contract-rejection-test" assert rejection["summary"] - assert rejection["version"] is None + assert rejection["version"] == "test" def test_repeated_invalid_keeps_first_ts_increments_count(observer_env, monkeypatch): @@ -4860,11 +4662,7 @@ def test_ingest_contract_sidecars_valid_are_accepted(observer_env, monkeypatch): lambda tract, event, **fields: emitted.append((tract, event, fields)), ) - resp = env.client.post( - "/app/observer/api/create", - json={"name": "contract-valid-test"}, - content_type="application/json", - ) + resp = env.register_bound_observer("contract-valid-test") key = resp.get_json()["key"] audio = b'{"raw":"audio.flac"}\n{"start":"00:00:00","text":"hello"}\n' @@ -4937,11 +4735,7 @@ def test_ingest_contract_sidecars_without_raw_are_accepted(observer_env, monkeyp lambda tract, event, **fields: emitted.append((tract, event, fields)), ) - resp = env.client.post( - "/app/observer/api/create", - json={"name": "contract-no-raw-test"}, - content_type="application/json", - ) + resp = env.register_bound_observer("contract-no-raw-test") key = resp.get_json()["key"] audio = b'{"observer":"external"}\n{"start":"00:00:00","text":"hi"}\n' @@ -4981,11 +4775,7 @@ def test_ingest_contract_screen_floor_violation_quarantined_without_emit( lambda tract, event, **fields: emitted.append((tract, event, fields)), ) - resp = env.client.post( - "/app/observer/api/create", - json={"name": "contract-screen-invalid-test"}, - content_type="application/json", - ) + resp = env.register_bound_observer("contract-screen-invalid-test") key = resp.get_json()["key"] invalid_screen = b'{"observer":"tmux"}\n{"content":{}}\n' @@ -5026,11 +4816,7 @@ def test_ingest_stream_qualifier_preserved(observer_env): env = observer_env() # Register as the tmux observer would (name = stream name with qualifier) - resp = env.client.post( - "/app/observer/api/create", - json={"name": "fedora.tmux"}, - content_type="application/json", - ) + resp = env.register_bound_observer("fedora.tmux") key = resp.get_json()["key"] test_data = b"tmux capture content" @@ -5057,11 +4843,7 @@ def test_manifest_day_listing(observer_env): """Test manifest day listing from observer history.""" env = observer_env() - resp = env.client.post( - "/app/observer/api/create", - json={"name": "manifest-list-test"}, - content_type="application/json", - ) + resp = env.register_bound_observer("manifest-list-test") key = resp.get_json()["key"] resp = env.client.post( @@ -5087,11 +4869,7 @@ def test_manifest_per_day(observer_env): """Test per-day manifest format matches transfer manifest v1.""" env = observer_env() - resp = env.client.post( - "/app/observer/api/create", - json={"name": "manifest-day-test"}, - content_type="application/json", - ) + resp = env.register_bound_observer("manifest-day-test") key = resp.get_json()["key"] resp = env.client.post( @@ -5120,9 +4898,9 @@ def test_manifest_per_day(observer_env): assert data["day"] == "20250103" assert isinstance(data["created_at"], int) assert "host" in data - assert "remote.host/120000_300" in data["segments"] + assert "manifest-day-test/120000_300" in data["segments"] - files = data["segments"]["remote.host/120000_300"]["files"] + files = data["segments"]["manifest-day-test/120000_300"]["files"] names = {file_info["name"] for file_info in files} assert {"audio.flac", "screen.webm"}.issubset(names) for file_info in files: diff --git a/solstone/apps/observer/tests/test_workspace_html_terminology.py b/solstone/apps/observer/tests/test_workspace_html_terminology.py index d1708d0fa..25f22e1d9 100644 --- a/solstone/apps/observer/tests/test_workspace_html_terminology.py +++ b/solstone/apps/observer/tests/test_workspace_html_terminology.py @@ -72,10 +72,6 @@ def test_workspace_owner_copy_replaces_retired_observer_phrases(): expected_phrases = ( 'aria-label="devices"', '

devices

', - 'aria-label="set up a device"', - '

set up a device

', - '', - '', "loading devices…", 'aria-label="device key"', '

sol on

', @@ -93,7 +89,6 @@ def test_workspace_owner_copy_replaces_retired_observer_phrases(): 'Remove "${name}"? sol on that device won\'t be able to add to your journal.', "i couldn't remove that device — your journal may not be reachable.", "i couldn't retrieve the key for that device — your journal may not be reachable.", - "i couldn't set up that device — your journal may not be reachable.", ) unescaped = text.replace("\\'", "'") for phrase in expected_phrases: diff --git a/solstone/apps/observer/workspace.html b/solstone/apps/observer/workspace.html index 08424a009..a17699756 100644 --- a/solstone/apps/observer/workspace.html +++ b/solstone/apps/observer/workspace.html @@ -183,46 +183,6 @@ background: #f1b0b7; } -/* Add observer form */ -.add-observer-form { - display: flex; - gap: 8px; - margin-bottom: 1em; -} -.add-observer-form input { - flex: 1; - padding: 8px 12px; - border: 1px solid #ccc; - border-radius: 4px; - font-size: 1em; -} -.add-observer-form button { - padding: 8px 16px; - background: color-mix(in srgb, var(--facet-color, var(--orange)) 14%, var(--paper)); - color: var(--ink); - border: none; - box-shadow: inset 0 0 0 2px var(--facet-color, var(--orange)); - border-radius: 4px; - cursor: pointer; - font-weight: 600; - min-height: 44px; -} -.add-observer-form button:hover { - background: color-mix(in srgb, var(--facet-color, var(--orange)) 22%, var(--paper)); -} -.add-observer-form button:focus-visible { - outline: 2px solid var(--facet-color, var(--focus)); - outline-offset: 2px; -} -.add-observer-form button:active { - background: color-mix(in srgb, var(--facet-color, var(--orange)) 30%, var(--paper)); -} -.add-observer-form button:disabled { - background: #ccc; - box-shadow: none; - cursor: not-allowed; -} - /* Observer key modal */ #keyModal .modal-content { max-width: 600px; @@ -389,13 +349,6 @@ /* ── Responsive: Tablet (≤768px) ── */ @media (max-width: 768px) { - .add-observer-form { - flex-direction: column; - } - .add-observer-form input, - .add-observer-form button { - width: 100%; - } .observer-header { flex-wrap: wrap; gap: 8px; @@ -494,7 +447,6 @@ /* ── Transitions: interactive elements ── */ .observer-actions button, -.add-observer-form button, .copy-btn, .modal-close, .modal-actions button, @@ -515,14 +467,6 @@

devices

-
-

set up a device

-
- - - -
-
@@ -532,7 +476,7 @@
- +