Add Nostr sync reset/fresh namespace workflow and docs

This commit is contained in:
thePR0M3TH3AN committed 2026-03-01 01:24:33 -05:00
1 parent 24aa3f12a2
commit b9d6c0d49d
10 files changed
+328 -3

No files matched your search

+5 -1
View File
@@ -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:
+1
View File
@@ -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
+72
View File
@@ -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/<fingerprint>/seedpass_state.json`
+3
View File
@@ -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.
+1
View File
@@ -60,6 +60,7 @@
<li><a href="#" data-file="policy_as_code.md" class="doc-link">&gt; Policy as Code</a></li>
<li><a href="#" data-file="sync_conflict_contract.md" class="doc-link">&gt; Sync Conflict Contract</a></li>
<li><a href="#" data-file="nostr_setup.md" class="doc-link">&gt; Nostr Setup</a></li>
<li><a href="#" data-file="nostr_namespace_reset.md" class="doc-link">&gt; Nostr Namespace Reset</a></li>
<li><a href="#" data-file="packaging.md" class="doc-link">&gt; Packaging</a></li>
<li><a href="#" data-file="agent_testing_roadmap.md" class="doc-link">&gt; Testing Roadmap</a>
</li>
+115 -2
View File
@@ -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:
+6
View File
@@ -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
+20
View File
@@ -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"
+10
View File
@@ -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: {
+95
View File
@@ -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)