diff --git a/README.md b/README.md index deb58a15..95f37a17 100644 --- a/README.md +++ b/README.md @@ -669,7 +669,11 @@ You can manage your relays and sync with Nostr from the **Settings** menu: 7. Choose `5` to remove a relay by number. 8. Select `6` to reset to the default relay list. 9. Choose `7` to display your Nostr public key. -10. Select `8` to return to the Settings menu. +10. Choose `8` to reset Nostr sync state for this profile (keeps current namespace). +11. Select `9` to start a fresh Nostr namespace (increments deterministic key index). +12. Press **Enter** to return to the Settings menu. + +For migration-safe sync workflows, see [Nostr Namespace Reset](docs/nostr_namespace_reset.md). Back in the Settings menu you can: diff --git a/docs/README.md b/docs/README.md index cf7377bb..1f7c851f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -23,6 +23,7 @@ This directory contains SeedPass project documentation. - `SPEC.md` - `security.md` - `nostr_setup.md` +- `nostr_namespace_reset.md` - `packaging.md` ## QA and Testing Docs diff --git a/docs/nostr_namespace_reset.md b/docs/nostr_namespace_reset.md new file mode 100644 index 00000000..03c32a8a --- /dev/null +++ b/docs/nostr_namespace_reset.md @@ -0,0 +1,72 @@ +# Nostr Sync-State Reset and Fresh Namespace + +This guide explains how to safely ignore legacy Nostr history and start syncing +new data with the current SeedPass implementation. + +Use this when: + +- old backups were created before deterministic/key-derivation changes, +- you do not need to recover old relay data, +- you want robust publish/restore behavior going forward. + +## Why This Exists + +SeedPass tracks Nostr sync metadata per profile (`manifest_id`, `delta_since`, +`last_sync_ts`) and also tracks a deterministic Nostr account namespace via +`nostr_account_idx`. + +If historical relay data is from an older derivation behavior, carrying old +manifest metadata can lead to stale sync behavior. The reset options clear that +state and optionally move to a fresh deterministic namespace. + +## Menu Options + +Open: + +1. `Settings` +2. `Nostr` + +You now have: + +- `8. Reset Nostr sync state` +- `9. Start fresh Nostr namespace (new key index)` + +### Option 8: Reset Nostr Sync State + +This clears only sync metadata: + +- `manifest_id -> null` +- `delta_since -> 0` +- `last_sync_ts -> 0` + +It keeps your current `nostr_account_idx` unchanged. + +Use this when you want to keep the current deterministic Nostr keyspace but +discard stale local sync pointers. + +### Option 9: Start Fresh Nostr Namespace + +This does everything from Option 8 and also: + +- increments `nostr_account_idx` by 1, +- reinitializes the Nostr client for the new deterministic key index. + +Use this when you want a clean break from old relay history and publish under a +new deterministic namespace. + +## Recommended Workflow (Ignore Legacy Data) + +1. Choose `Settings > Nostr > 9` (fresh namespace). +2. Run `Backup to Nostr`. +3. On a second device/profile, use the same seed and same account index, then + run `Restore from Nostr`. +4. Verify entry count and several known records match. +5. Modify/add entries and repeat backup+restore once more to confirm deltas. + +## Notes + +- Keep Offline Mode disabled while syncing. +- If backup fails, SeedPass now prints detailed failure reasons in the Nostr + menu path, not only a generic "Sync failed". +- The sync-state file is profile-scoped at: + `~/.seedpass//seedpass_state.json` diff --git a/docs/nostr_setup.md b/docs/nostr_setup.md index 095ace1c..12615eb5 100644 --- a/docs/nostr_setup.md +++ b/docs/nostr_setup.md @@ -42,5 +42,8 @@ For the detailed merge contract, see - **No events found:** ensure the relays are reachable and that the correct fingerprint is selected. - **Connection failures:** some relays only support WebSocket over TLS; verify you are using `wss://` URLs where required. - **Stale data:** if deltas accumulate without a fresh snapshot, run `seedpass nostr sync` to publish an updated snapshot. +- **Legacy/state drift after deterministic changes:** use + [nostr_namespace_reset.md](nostr_namespace_reset.md) to reset sync metadata or + start a fresh deterministic Nostr namespace. Increasing log verbosity with `--verbose` can also help diagnose relay or network issues. diff --git a/landing/docs.html b/landing/docs.html index f0d621d8..af6b5edc 100644 --- a/landing/docs.html +++ b/landing/docs.html @@ -60,6 +60,7 @@
  • > Policy as Code
  • > Sync Conflict Contract
  • > Nostr Setup
  • +
  • > Nostr Namespace Reset
  • > Packaging
  • > Testing Roadmap
  • diff --git a/src/main.py b/src/main.py index f85a88e3..b5a5e5a8 100644 --- a/src/main.py +++ b/src/main.py @@ -424,8 +424,16 @@ def handle_post_to_nostr( print(f" delta: {did}") logging.info("Encrypted index posted to Nostr successfully.") else: - print(colored("\N{CROSS MARK} Sync failed…", "red")) - logging.error("Failed to post encrypted index to Nostr.") + client = getattr(password_manager, "nostr_client", None) + detail = ( + getattr(client, "last_error", None) if client is not None else None + ) + if detail: + print(colored(f"\N{CROSS MARK} Sync failed… {detail}", "red")) + logging.error("Failed to post encrypted index to Nostr: %s", detail) + else: + print(colored("\N{CROSS MARK} Sync failed…", "red")) + logging.error("Failed to post encrypted index to Nostr.") except Exception as e: logging.error(f"Failed to post to Nostr: {e}", exc_info=True) print(colored(f"Error: Failed to post to Nostr: {e}", "red")) @@ -599,6 +607,105 @@ def handle_reset_relays(password_manager: PasswordManager) -> None: pause() +def _get_profile_state_manager(password_manager: PasswordManager): + """Return the active profile StateManager or create one from fingerprint dir.""" + state_mgr = getattr(password_manager, "state_manager", None) + if state_mgr is not None: + return state_mgr + fingerprint_dir = getattr(password_manager, "fingerprint_dir", None) + if fingerprint_dir is None: + raise ValueError("State manager unavailable for current profile.") + from seedpass.core.state_manager import StateManager + + return StateManager(Path(fingerprint_dir)) + + +def _clear_nostr_runtime_state(password_manager: PasswordManager) -> None: + """Clear in-memory Nostr sync metadata for the active profile.""" + password_manager.manifest_id = None + password_manager.delta_since = 0 + password_manager.last_sync_ts = 0 + client = getattr(password_manager, "nostr_client", None) + if client is not None: + if hasattr(client, "last_error"): + client.last_error = None + if hasattr(client, "current_manifest_id"): + client.current_manifest_id = None + if hasattr(client, "current_manifest"): + client.current_manifest = None + if hasattr(client, "_delta_events"): + client._delta_events = [] + + +def handle_reset_nostr_sync_state(password_manager: PasswordManager) -> None: + """Reset Nostr sync state for the active profile without changing key index.""" + if not confirm_action( + "Reset Nostr sync state for this profile (manifest/delta metadata)? (Y/N): " + ): + print(colored("Nostr sync-state reset cancelled.", "yellow")) + pause() + return + try: + state_mgr = _get_profile_state_manager(password_manager) + state = state_mgr.state + state_mgr.update_state(manifest_id=None, delta_since=0, last_sync_ts=0) + _clear_nostr_runtime_state(password_manager) + password_manager.nostr_account_idx = int(state.get("nostr_account_idx", 0)) + print(colored("Nostr sync state reset for current profile.", "green")) + logging.info( + "Nostr sync state reset for profile %s", + getattr(password_manager, "current_fingerprint", "unknown"), + ) + except Exception as exc: + logging.error("Failed to reset Nostr sync state: %s", exc, exc_info=True) + print(colored(f"Error resetting Nostr sync state: {exc}", "red")) + finally: + pause() + + +def handle_start_fresh_nostr_namespace(password_manager: PasswordManager) -> None: + """Increment Nostr account index and reset sync metadata for fresh publishing.""" + if not confirm_action( + "Start a fresh Nostr namespace (new deterministic key index) for this profile? (Y/N): " + ): + print(colored("Fresh Nostr namespace action cancelled.", "yellow")) + pause() + return + try: + state_mgr = _get_profile_state_manager(password_manager) + state = state_mgr.state + next_idx = int(state.get("nostr_account_idx", 0)) + 1 + state_mgr.update_state( + manifest_id=None, + delta_since=0, + last_sync_ts=0, + nostr_account_idx=next_idx, + ) + _clear_nostr_runtime_state(password_manager) + password_manager.nostr_account_idx = next_idx + + reinit = getattr(password_manager, "_initialize_nostr_client", None) + if callable(reinit): + _safe_close_client_pool(password_manager) + reinit() + print( + colored( + f"Started fresh Nostr namespace at account index {next_idx}.", + "green", + ) + ) + logging.info( + "Started fresh Nostr namespace for profile %s at account index %s", + getattr(password_manager, "current_fingerprint", "unknown"), + next_idx, + ) + except Exception as exc: + logging.error("Failed to start fresh Nostr namespace: %s", exc, exc_info=True) + print(colored(f"Error starting fresh Nostr namespace: {exc}", "red")) + finally: + pause() + + def handle_set_inactivity_timeout(password_manager: PasswordManager) -> None: """Change the inactivity timeout for the current seed profile.""" cfg_mgr = password_manager.config_manager @@ -962,6 +1069,8 @@ def handle_nostr_menu(password_manager: PasswordManager) -> None: print(color_text("5. Remove a relay by number", "menu")) print(color_text("6. Reset to default relays", "menu")) print(color_text("7. Display Nostr Public Key", "menu")) + print(color_text("8. Reset Nostr sync state", "menu")) + print(color_text("9. Start fresh Nostr namespace (new key index)", "menu")) choice = input("Select an option or press Enter to go back: ").strip() password_manager.update_activity() try: @@ -979,6 +1088,10 @@ def handle_nostr_menu(password_manager: PasswordManager) -> None: handle_reset_relays(password_manager) elif choice == "7": handle_display_npub(password_manager) + elif choice == "8": + handle_reset_nostr_sync_state(password_manager) + elif choice == "9": + handle_start_fresh_nostr_namespace(password_manager) elif not choice: break else: diff --git a/src/seedpass/core/manager.py b/src/seedpass/core/manager.py index d40be4a0..4492915b 100644 --- a/src/seedpass/core/manager.py +++ b/src/seedpass/core/manager.py @@ -3967,6 +3967,12 @@ class PasswordManager: "delta_ids": list(delta_ids), } except Exception as e: + client = getattr(self, "nostr_client", None) + if client is not None and hasattr(client, "last_error"): + try: + client.last_error = str(e) + except Exception: + pass logging.error(f"Failed to sync vault: {e}", exc_info=True) return None diff --git a/src/tests/test_offline_mode_behavior.py b/src/tests/test_offline_mode_behavior.py index 34b05e8b..fab9abb6 100644 --- a/src/tests/test_offline_mode_behavior.py +++ b/src/tests/test_offline_mode_behavior.py @@ -24,3 +24,23 @@ def test_start_background_sync_offline(monkeypatch): pm.sync_index_from_nostr = lambda: called.__setitem__("sync", True) PasswordManager.start_background_sync(pm) assert called["sync"] is False + + +def test_sync_vault_sets_last_error_on_exception(): + pm = PasswordManager.__new__(PasswordManager) + pm.offline_mode = False + pm.get_encrypted_data = lambda: b"data" + pm.state_manager = None + + async def boom(_data): + raise RuntimeError("relay write failed") + + pm.nostr_client = SimpleNamespace( + publish_snapshot=boom, + get_delta_events=lambda: [], + last_error=None, + ) + + result = PasswordManager.sync_vault(pm) + assert result is None + assert pm.nostr_client.last_error == "relay write failed" diff --git a/src/tests/test_post_sync_messages.py b/src/tests/test_post_sync_messages.py index d45b74b3..3557cc19 100644 --- a/src/tests/test_post_sync_messages.py +++ b/src/tests/test_post_sync_messages.py @@ -31,6 +31,16 @@ def test_handle_post_failure(capsys): assert "❌ Sync failed…" in out +def test_handle_post_failure_shows_nostr_error(capsys): + pm = SimpleNamespace( + sync_vault=lambda alt_summary=None: None, + nostr_client=SimpleNamespace(last_error="Manifest out of date"), + ) + main.handle_post_to_nostr(pm) + out = capsys.readouterr().out + assert "❌ Sync failed… Manifest out of date" in out + + def test_handle_post_prints_all_ids(capsys): pm = SimpleNamespace( sync_vault=lambda alt_summary=None: { diff --git a/src/tests/test_settings_menu.py b/src/tests/test_settings_menu.py index da250e48..482c7cfc 100644 --- a/src/tests/test_settings_menu.py +++ b/src/tests/test_settings_menu.py @@ -17,6 +17,7 @@ import main from nostr.client import DEFAULT_RELAYS from seedpass.core.config_manager import ConfigManager from seedpass.core.manager import Notification, PasswordManager +from seedpass.core.state_manager import StateManager from seedpass.core.vault import Vault from seedpass.core.errors import SeedPassError from utils.fingerprint_manager import FingerprintManager @@ -263,3 +264,97 @@ def test_settings_import_missing_path_message(monkeypatch, capsys): out = capsys.readouterr().out assert "Import failed: file 'missing.json.enc' not found." in out + + +def test_reset_nostr_sync_state(monkeypatch): + with TemporaryDirectory() as tmpdir: + tmp_path = Path(tmpdir) + pm, _, _ = setup_pm(tmp_path, monkeypatch) + fp_dir = tmp_path / ".seedpass" / "fp" + state_mgr = StateManager(fp_dir) + state_mgr.update_state( + manifest_id="manifest-old", + delta_since=123, + last_sync_ts=456, + nostr_account_idx=2, + ) + pm.state_manager = state_mgr + pm.fingerprint_dir = fp_dir + pm.current_fingerprint = "fp" + pm.manifest_id = "manifest-old" + pm.delta_since = 123 + pm.last_sync_ts = 456 + + monkeypatch.setattr(main, "confirm_action", lambda *_: True) + monkeypatch.setattr(main, "pause", lambda: None) + main.handle_reset_nostr_sync_state(pm) + + state = state_mgr.state + assert state["manifest_id"] is None + assert state["delta_since"] == 0 + assert state["last_sync_ts"] == 0 + assert state["nostr_account_idx"] == 2 + assert pm.manifest_id is None + assert pm.delta_since == 0 + assert pm.last_sync_ts == 0 + assert pm.nostr_account_idx == 2 + + +def test_start_fresh_nostr_namespace(monkeypatch): + with TemporaryDirectory() as tmpdir: + tmp_path = Path(tmpdir) + pm, _, _ = setup_pm(tmp_path, monkeypatch) + fp_dir = tmp_path / ".seedpass" / "fp" + state_mgr = StateManager(fp_dir) + state_mgr.update_state( + manifest_id="manifest-old", + delta_since=123, + last_sync_ts=456, + nostr_account_idx=2, + ) + pm.state_manager = state_mgr + pm.fingerprint_dir = fp_dir + pm.current_fingerprint = "fp" + pm.manifest_id = "manifest-old" + pm.delta_since = 123 + pm.last_sync_ts = 456 + called = {"reinit": 0} + pm._initialize_nostr_client = lambda: called.__setitem__( + "reinit", called["reinit"] + 1 + ) + + monkeypatch.setattr(main, "confirm_action", lambda *_: True) + monkeypatch.setattr(main, "pause", lambda: None) + main.handle_start_fresh_nostr_namespace(pm) + + state = state_mgr.state + assert state["manifest_id"] is None + assert state["delta_since"] == 0 + assert state["last_sync_ts"] == 0 + assert state["nostr_account_idx"] == 3 + assert pm.nostr_account_idx == 3 + assert called["reinit"] == 1 + + +def test_nostr_menu_dispatches_reset_sync_state(monkeypatch): + with TemporaryDirectory() as tmpdir: + tmp_path = Path(tmpdir) + pm, _, _ = setup_pm(tmp_path, monkeypatch) + pm.update_activity = lambda: None + inputs = iter(["8", ""]) + with patch("main.handle_reset_nostr_sync_state") as handler: + with patch("builtins.input", side_effect=lambda *_: next(inputs)): + main.handle_nostr_menu(pm) + handler.assert_called_once_with(pm) + + +def test_nostr_menu_dispatches_fresh_namespace(monkeypatch): + with TemporaryDirectory() as tmpdir: + tmp_path = Path(tmpdir) + pm, _, _ = setup_pm(tmp_path, monkeypatch) + pm.update_activity = lambda: None + inputs = iter(["9", ""]) + with patch("main.handle_start_fresh_nostr_namespace") as handler: + with patch("builtins.input", side_effect=lambda *_: next(inputs)): + main.handle_nostr_menu(pm) + handler.assert_called_once_with(pm)