mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2026-11-03 03:12:07 +00:00
Add Nostr sync reset/fresh namespace workflow and docs
This commit is contained in:
1 parent
24aa3f12a2
commit
b9d6c0d49d
10 files changed
+328
-3
No files matched your search
@@ -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:
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`
|
||||
@@ -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.
|
||||
@@ -60,6 +60,7 @@
|
||||
<li><a href="#" data-file="policy_as_code.md" class="doc-link">> Policy as Code</a></li>
|
||||
<li><a href="#" data-file="sync_conflict_contract.md" class="doc-link">> Sync Conflict Contract</a></li>
|
||||
<li><a href="#" data-file="nostr_setup.md" class="doc-link">> Nostr Setup</a></li>
|
||||
<li><a href="#" data-file="nostr_namespace_reset.md" class="doc-link">> Nostr Namespace Reset</a></li>
|
||||
<li><a href="#" data-file="packaging.md" class="doc-link">> Packaging</a></li>
|
||||
<li><a href="#" data-file="agent_testing_roadmap.md" class="doc-link">> Testing Roadmap</a>
|
||||
</li>
|
||||
|
||||
+115
-2
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
@@ -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: {
|
||||
|
||||
@@ -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)
|
||||
Reference in new issue
Block a user