diff --git a/docs/dev_control_center.md b/docs/dev_control_center.md index a645a737..c548e306 100644 --- a/docs/dev_control_center.md +++ b/docs/dev_control_center.md @@ -37,6 +37,7 @@ Use these docs as inputs, but treat this file as the decision layer: - TUI v2 integration execution plan (current working plan): `docs/tui_v2_integration_execution_plan_2026-03-02.md` - TUI v2 mockup gap audit: `docs/tui_v2_mockup_gap_audit_2026-03-02.md` - Semantic vector index integration plan: `docs/semantic_vector_index_plan.md` +- Index0 atlas execution plan: `docs/index0_atlas_execution_plan_2026-03-05.md` - Cutover memo: `docs/tui_v2_cutover_decision.md` - Security readiness: `docs/security_readiness_checklist.md` - Supply-chain integrity: `docs/supply_chain_release_integrity.md` @@ -79,6 +80,28 @@ Use these docs as inputs, but treat this file as the decision layer: - Phase C complete baseline (legacy + TUI v2 semantic workflows and mode toggles) - Next: incremental mutation hooks + hybrid quality hardening +### Index0 Atlas Roadmap +- New architecture/spec track drafted: + - `docs/index0_atlas_execution_plan_2026-03-05.md` +- Phase 0 implementation spec drafted: + - `docs/index0_phase0_spec_2026-03-05.md` +- Phase 1 foundation slice landed: + - reserved `_system.index0` namespace normalization + - deterministic `index0` merge helper + - Nostr manifest optional `index0` metadata support + - focused tests in `src/tests/test_index0_core.py` +- Intent: + - add canonical encrypted atlas state inside the existing payload for audit, hierarchy wayfinding, and future KB/chat navigation +- Key implementation direction: + - reserved `_system.index0` namespace inside the synced payload + - deterministic merge + checkpointed hash-chain validation + - rebuildable wayfinder views layered on top +- Sequencing: + - Phase 0 spec/schema is drafted + - Phase 1 foundation is partially implemented + - next implementation slice: event emission hooks from entry CRUD/link flows + - implementation phases should not preempt current TUI cutover-critical or security-readiness blockers + ## 4) Recommended Next Steps (Priority Order) 1. Execute Phases A-C from `docs/tui_v2_integration_execution_plan_2026-03-02.md`: @@ -91,6 +114,12 @@ Use these docs as inputs, but treat this file as the decision layer: : unified lexical+tag+semantic search integration. 5. Execute Phase F: : final per-board fidelity pass and strict mockup closeout. +6. Start Index0 Atlas Phase 0 from `docs/index0_atlas_execution_plan_2026-03-05.md`: +: lock schema, merge contract extension, manifest checkpoint fields, and compaction rules. +7. After cutover-critical TUI/security slices stabilize, execute Index0 Atlas Phases 1-3: +: canonical storage, event emission hooks, and manifest checkpoint validation. +8. After semantic hardening resumes, execute Index0 Atlas Phase 5: +: atlas read APIs/views and semantic interplay. ## 5) Next Slice Definition (Recommended Immediate Work) diff --git a/docs/index0_atlas_execution_plan_2026-03-05.md b/docs/index0_atlas_execution_plan_2026-03-05.md new file mode 100644 index 00000000..8159e62b --- /dev/null +++ b/docs/index0_atlas_execution_plan_2026-03-05.md @@ -0,0 +1,613 @@ +# Index0 Atlas Execution Plan (2026-03-05) + +Status: Proposed +Branch target: `beta` +Scope: Core index payload, Nostr sync, hierarchy navigation, future KB and agent workflows + +## 1) Goal + +Implement `index0` as the canonical encrypted atlas for SeedPass: + +1. record what exists +2. record what changed +3. expose deterministic wayfinding state for users and agents +4. preserve existing SeedPass sync, merge, and security guarantees + +Working slogan: + +`Index0 is the atlas: it records what exists, what changed, and how to navigate it.` + +## 2) Beta Branch Baseline To Reuse + +This plan is grounded in the current `beta` branch, not a greenfield design. + +Existing contracts already in place: + +1. Nostr sync uses manifest + snapshot chunks + deltas: + - manifest `30070` + - snapshot chunk `30071` + - delta `30072` + - references: `docs/SPEC.md`, `docs/nostr_setup.md`, `src/nostr/backup_models.py`, `src/nostr/snapshot.py` +2. Deterministic convergence already exists through `modified_ts_hash_tombstone_v2`: + - references: `docs/sync_conflict_contract.md`, `src/seedpass/core/sync_conflict.py` +3. Retention caps already exist for unbounded metadata growth: + - current example: tombstone retention cap in `src/seedpass/core/sync_conflict.py` +4. Hierarchy and namespace reset are already part of the product model: + - key hierarchy: `docs/SPEC.md` + - namespace reset / `nostr_account_idx`: `docs/nostr_namespace_reset.md`, `src/seedpass/core/state_manager.py` +5. Entry graph links already provide typed relationships between artifacts: + - reference: `docs/entry_graph.md` +6. Semantic retrieval already has the correct architectural shape for derived caches: + - local-only derived index, not canonical sync state + - reference: `docs/semantic_vector_index_plan.md` + +## 3) Core Design Decision + +`index0` should live inside the existing encrypted index payload and ride the existing snapshot/delta pipeline. + +Recommendation for `beta`: + +1. Do not create a separate database or alternate sync protocol. +2. Do not put `index0` records into the user `entries` map initially. +3. Add a reserved top-level system namespace in the canonical payload, then expose entry-like adapters later if needed. + +Recommended payload shape: + +```json +{ + "schema_version": 3, + "entries": {}, + "_sync_meta": {}, + "_system": { + "index0": { + "schema_version": 1, + "events": {}, + "checkpoints": {}, + "canonical_views": {}, + "view_manifest": {}, + "heads": {}, + "stats": {} + } + } +} +``` + +## 4) Why `_system.index0` Instead Of User `entries` + +The current `beta` code strongly assumes user entries are numeric IDs: + +1. create/update flows allocate integer IDs in `src/seedpass/core/entry_management.py` +2. retrieve/modify/list paths index into `entries[str(index)]` +3. several CLI/TUI/API paths expect user-facing entry IDs to remain numeric + +Shoving reserved `index0` records directly into `entries` would create unnecessary compatibility risk. + +So the practical `beta` implementation is: + +1. canonical storage inside the same synced payload +2. separate reserved namespace for system-owned records +3. optional projection into UI/API later as a virtual view layer + +This still satisfies the main requirement: `index0` remains first-class canonical state inside the same encrypted, merged, synced vault. + +## 5) What `index0` Must Contain + +`index0` should contain three record classes. + +### 5.1 `index0_event` + +Ground-truth change records. + +Purpose: + +1. append change history +2. capture hierarchy and relationship mutations +3. support audit, sync replay validation, and activity feeds + +Minimum fields: + +```json +{ + "event_id": "sha256(...)", + "event_type": "entry_created", + "subject_type": "entry", + "subject_id": "42", + "subject_kind": "document", + "scope_path": "seed//managed/", + "actor_type": "user", + "actor_id": "", + "writer_id": "", + "modified_ts": 1760000000, + "prev_hash": "", + "classification": "internal", + "partition": "standard", + "policy_ref": "", + "payload_ref": { + "entry_id": "42" + }, + "links": [ + { + "target_id": "42", + "relation": "describes" + } + ], + "tags": ["kb", "chat", "audit"], + "visibility": "private", + "integrity_hash": "sha256(...)" +} +``` + +Initial event types: + +1. `entry_created` +2. `entry_modified` +3. `entry_deleted` +4. `entry_archived` +5. `entry_restored` +6. `link_added` +7. `link_removed` +8. `profile_added` +9. `profile_removed` +10. `agent_added` +11. `agent_removed` +12. `sync_snapshot_published` +13. `sync_delta_applied` +14. `nostr_message_indexed` +15. `document_indexed` + +### 5.2 `index0_checkpoint` + +Deterministic integrity and compaction anchors. + +Purpose: + +1. summarize older event windows +2. cap growth +3. store checkpoint hashes that manifests can reference + +Minimum fields: + +```json +{ + "checkpoint_id": "cp-000001", + "window_start_ts": 1760000000, + "window_end_ts": 1760086400, + "event_count": 512, + "head_hashes": { + "writer:": "abc123", + "writer:": "def456" + }, + "summary_hash": "sha256(...)", + "rollup": { + "events_by_type": { + "entry_modified": 300, + "nostr_message_indexed": 90 + }, + "subjects_by_kind": { + "document": 120, + "note": 90 + } + }, + "modified_ts": 1760086400 +} +``` + +### 5.3 `index0_view` + +Derived, regeneratable navigation caches. + +Purpose: + +1. speed up browsing +2. avoid recomputing common hierarchy and activity summaries +3. support future agent navigation and hybrid search routing + +Initial views: + +1. `children_of:` +2. `recent_activity:` +3. `top_tags:` +4. `counts_by_kind:` +5. `hot_nodes:` +6. `conversation_index:` + +Views are not the source of truth. They must always be rebuildable from canonical entries plus `index0_event` and `index0_checkpoint`. + +### 5.4 Canonical vs local derived views + +This is an important design refinement for `beta`. + +Do not sync every derived navigation or search view. + +Recommended split: + +1. `canonical_views`: + - low-cardinality, deterministic, cross-device-useful summaries + - examples: `children_of`, `counts_by_kind`, compact `recent_activity` +2. local derived views: + - expensive, high-churn, or personalization-oriented caches + - examples: semantic neighbor expansions, ranking caches, UI-specific hotlists + +Rules: + +1. only sync views that materially improve restore/bootstrap/navigation across devices +2. keep search-heavy and high-cardinality projections local +3. treat `view_manifest` as the contract for what canonical views exist and how they are versioned + +This keeps `index0` useful without recreating a second bloated database inside the vault payload. + +## 6) Hash Chaining And Tamper Evidence + +For `beta`, use lightweight tamper evidence instead of a full Merkle epoch system. + +### 6.1 Event integrity + +Each `index0_event` stores: + +1. `integrity_hash`: canonical hash of the event payload +2. `prev_hash`: optional hash of the prior event in the same logical writer stream +3. `writer_id`: deterministic stream identifier + +### 6.2 Why writer streams instead of one global chain + +A single global append-only chain is fragile under concurrent multi-device mutation. + +The current `beta` branch is already multi-device and delta-merge oriented, so the safer MVP is: + +1. hash-chain per logical writer stream +2. deterministic checkpoint rollups that record the set of stream heads +3. manifest-level checkpoint metadata for restore validation + +This preserves tamper evidence without forcing total ordering across all concurrent writers. + +### 6.3 Manifest checkpoint metadata + +Extend manifest payloads to optionally include: + +```json +{ + "index0_checkpoint_id": "cp-000001", + "index0_checkpoint_hash": "sha256(...)", + "index0_stream_heads": { + "writer:": "abc123" + } +} +``` + +Validation path on restore: + +1. restore latest snapshot +2. replay newer deltas +3. recompute current `index0` checkpoint/head material +4. compare against manifest metadata +5. raise warning or fail closed based on policy setting + +## 7) Deterministic Merge Contract For `index0` + +`index0` must not bypass the current merge contract. It should extend it. + +Required behavior: + +1. base entries still merge with `modified_ts_hash_tombstone_v2` +2. `_system.index0.events` merges by `event_id` +3. identical `event_id` payloads are idempotent +4. equal-subject competing events resolve deterministically using: + - `modified_ts` + - canonical event hash + - tombstone/deletion precedence where applicable +5. checkpoints and views are regenerated or selected deterministically + +Implementation direction: + +1. add a dedicated `merge_system_index0(...)` helper beside `merge_index_payloads(...)` +2. fold its result into the canonical merged payload +3. keep all timestamps replay-safe and derived from payload data, not wall clock, where merge state is computed + +## 8) Deterministic Compaction + +`index0` will grow quickly if it tracks chat, docs, and agent activity. + +MVP compaction rules: + +1. keep recent raw events under a deterministic retention window +2. roll older events into checkpoints +3. preserve enough raw events to support recent activity and recent forensic review +4. preserve checkpoint hashes indefinitely or under a much larger cap + +Recommended deterministic policy: + +1. retain last `N` raw events per writer stream +2. retain last `M` days of raw events globally +3. compact older events into fixed windows: + - daily first + - weekly later if scale requires +4. compute checkpoint IDs from canonical window bounds, not device-local counters + +Important: + +Compaction must be deterministic from payload state alone so two devices compacting the same data converge to the same post-compaction state. + +Recommended initial defaults: + +1. keep checkpoints indefinitely until an explicit larger cap is required +2. keep only compact canonical views in sync +3. rebuild local heavy views on demand after sync or unlock + +## 9) Nostr And Communication Scope + +The goal is not to make `index0` hold every byte of communication content. + +Recommended split: + +1. canonical communication artifacts remain normal vault data or future dedicated entry kinds +2. `index0` records: + - existence + - ownership/visibility metadata + - hierarchy placement + - conversation references + - recent activity summaries +3. optional future message/document bodies continue to live in encrypted entries or high-risk partitions + +This keeps `index0` useful as a map without turning it into a giant bottleneck payload. + +Retention and exposure rules: + +1. public Nostr references may be stored as lightweight refs in canonical vault data +2. private DM content should default to encrypted entry payloads or high-risk partitions, not inline `index0_event` bodies +3. `index0` should store enough metadata to navigate to a message, not enough to become the message store + +## 10) Hierarchy And Permission Model + +`index0` must not weaken SeedPass security posture. + +Rules: + +1. `index0` is encrypted inside the same profile payload as the rest of the vault +2. `index0` only reveals what the active profile is already authorized to decrypt +3. manager access to subordinate data should continue to use the existing hierarchy and profile-loading model +4. future agent access should reuse approval/export/partition controls rather than adding an `index0` bypass +5. event metadata for high-risk partitions should be minimal and policy-aware + +Policy alignment: + +1. reuse policy stamps / policy-as-code identifiers where policy-shaped access affects atlas visibility +2. reuse audit expectations from the agent autonomy plan rather than inventing a second audit vocabulary +3. keep `classification` and `partition` fields small, deterministic, and non-secret + +Institutional knowledge model: + +1. each user/agent continues producing canonical artifacts in their scope +2. `index0` links those artifacts into a searchable atlas +3. manager-level profiles can build broader navigation views when they already have legitimate access to those scopes + +## 11) Interaction With Existing Roadmaps + +### 11.1 TUI v2 plan + +`index0` is not a prerequisite for the current TUI v2 parity/cutover work. + +However, once present it directly improves: + +1. real hierarchy navigation +2. recent activity surfaces +3. context-aware wayfinding +4. future conversation and KB sidebars + +### 11.2 Semantic index plan + +The semantic vector index should remain local-only derived state. + +Recommended relationship: + +1. `index0` is canonical synced atlas data +2. semantic vectors remain device-local derived retrieval data +3. semantic chunking can consume `index0_view` and graph context as ranking features + +### 11.3 Entry graph plan + +`index0` should reuse `links` conventions instead of inventing a second graph model. + +## 12) Implementation Touchpoints In `beta` + +Primary files likely to change: + +1. `src/seedpass/core/sync_conflict.py` + - add deterministic merge support for `_system.index0` +2. `src/seedpass/core/vault.py` + - load/save/migration handling for `_system.index0` +3. `src/seedpass/core/encryption.py` + - merge path for restored payloads +4. `src/nostr/backup_models.py` + - manifest metadata extension for `index0` checkpoint fields +5. `src/nostr/snapshot.py` + - publish/fetch manifest support for `index0` checkpoint metadata +6. `src/seedpass/core/entry_management.py` + - emit `index0_event` records on create/update/delete/link actions +7. `src/seedpass/core/api.py` + - expose atlas inspection/status endpoints +8. `src/seedpass/core/semantic_index.py` + - optional future use of `index0_view` context during hybrid retrieval +9. `src/seedpass/core/state_manager.py` + - only if local rebuild or validation state must be persisted +10. `src/seedpass/core/agent_export_policy.py` + - if atlas reads need policy-shaped export or redaction behavior + +## 13) Delivery Phases + +## Phase 0: Spec And Schema Lock + +Deliver: + +1. finalize `_system.index0` payload shape +2. finalize event/checkpoint/view schemas +3. define manifest extension fields +4. define deterministic compaction contract + +Exit criteria: + +1. schema doc merged +2. migration strategy documented +3. no open ambiguity on user-entry ID compatibility + +## Phase 1: Canonical Storage + Merge + +Deliver: + +1. payload migrations for `_system.index0` +2. `merge_system_index0(...)` +3. event ID and integrity hash helpers +4. deterministic checkpoint selection rules + +Exit criteria: + +1. replay idempotency holds +2. stale payloads cannot override newer `index0` state +3. merge order independence proven by tests + +## Phase 2: Event Emission Hooks + +Deliver: + +1. emit events on entry CRUD +2. emit events on archive/restore +3. emit events on link add/remove +4. emit events on profile/agent lifecycle operations where already represented in core flows + +Exit criteria: + +1. user-visible mutations update `index0` automatically +2. no manual rebuild required for core audit path + +## Phase 3: Manifest Checkpoints + Validation + +Deliver: + +1. extend manifest schema +2. write checkpoint metadata during snapshot publish +3. validate checkpoint/head metadata during restore +4. define warning vs hard-fail policy + +Exit criteria: + +1. snapshot/delta replay can verify `index0` checkpoint continuity +2. failure states are explicit and test-covered + +## Phase 4: Deterministic Compaction + +Deliver: + +1. raw-event retention windows +2. checkpoint rollup generation +3. deterministic pruning +4. migration path for older payloads without checkpoints + +Exit criteria: + +1. event growth remains bounded +2. two devices compacting the same payload converge + +## Phase 5: Read APIs And Wayfinder Views + +Deliver: + +1. atlas status API/CLI surface +2. recent activity feed +3. children-of and counts-by-kind views +4. hot-nodes and top-tags views + +Exit criteria: + +1. user/agent can navigate from atlas state without scanning the full vault +2. views can be rebuilt from canonical data + +## Phase 6: Communication And KB Integration + +Deliver: + +1. add event hooks for future Nostr chat/document workflows +2. add message/conversation references +3. integrate atlas cues into KB and semantic search experiences +4. keep raw private communication content out of canonical atlas events + +Exit criteria: + +1. institutional knowledge use cases work without changing cryptographic source-of-truth rules + +## 14) Testing Plan + +Required new tests: + +1. unit: + - event hash generation + - writer-stream hash chaining + - checkpoint hash generation + - deterministic compaction selection +2. merge: + - order independence + - replay idempotency + - stale event rejection + - concurrent writer-stream head preservation +3. migration: + - old payload without `_system.index0` upgrades cleanly + - namespace reset still works +4. Nostr sync: + - manifest metadata round-trip + - snapshot + delta restore validation + - checkpoint mismatch handling +5. integration: + - CRUD emits events + - link operations emit events + - archive/delete semantics converge +6. scale: + - large KB/chat event volume + - compaction boundedness + +Likely test homes: + +1. `src/tests/test_delta_merge.py` +2. new `src/tests/test_index0_merge.py` +3. new `src/tests/test_index0_compaction.py` +4. new `src/tests/test_index0_manifest_validation.py` +5. targeted API/CLI/TUI tests once read surfaces exist + +## 15) Sequencing Against The Current Dev Plan + +Recommended sequencing in `beta`: + +1. now: + - Phase 0 spec and schema lock + - no conflict with current TUI work +2. after current cutover-critical TUI/security slices are stable: + - Phases 1 through 3 +3. after semantic hybrid hardening resumes: + - Phase 5 atlas read surfaces and semantic interplay +4. after communication entry types or Nostr DM indexing lands: + - Phase 6 + +Practical interpretation: + +1. start the architecture/spec work immediately +2. do not let `index0` disrupt TUI parity closeout or current security readiness gates +3. implement core atlas plumbing as the next major core-data track once current cutover work is no longer the top release blocker + +## 16) Open Design Questions + +These should be resolved in Phase 0 before code lands: + +1. should `writer_id` be profile-fingerprint based, device based, or actor based? +2. should checkpoint mismatch fail restore or warn and continue by default? +3. which communication payloads are canonical vault entries versus external references? +4. should manager-level aggregate atlas views be materialized or computed on demand? +5. should `writer_id` default to profile scope first and only later split by agent identity where needed? + +## 17) Bottom Line + +For the current `beta` branch, the right implementation is: + +1. keep `index0` inside the existing encrypted snapshot/delta payload +2. model it as a reserved `_system.index0` atlas namespace +3. use lightweight hash-chained writer streams plus manifest checkpoint hashes +4. compact deterministically +5. expose fast navigation through rebuildable views + +This gives SeedPass a canonical launch pad and map for institutional knowledge without abandoning the sync, determinism, and hierarchy model that `beta` already has. diff --git a/docs/index0_phase0_spec_2026-03-05.md b/docs/index0_phase0_spec_2026-03-05.md new file mode 100644 index 00000000..2e3ade42 --- /dev/null +++ b/docs/index0_phase0_spec_2026-03-05.md @@ -0,0 +1,620 @@ +# Index0 Phase 0 Implementation Spec (2026-03-05) + +Status: Proposed +Branch target: `beta` +Depends on: `docs/index0_atlas_execution_plan_2026-03-05.md` + +## 1) Goal + +Lock the Phase 0 implementation contract for `index0` so coding can begin without schema drift. + +This document defines: + +1. canonical payload schema +2. manifest extension fields +3. normalization and hashing rules +4. deterministic merge rules +5. deterministic compaction defaults +6. first implementation slice +7. first test matrix + +## 2) Canonical Payload Contract + +`index0` lives inside the same encrypted vault payload in a reserved namespace: + +```json +{ + "schema_version": 3, + "entries": {}, + "_sync_meta": {}, + "_system": { + "index0": { + "schema_version": 1, + "events": {}, + "checkpoints": {}, + "canonical_views": {}, + "view_manifest": {}, + "heads": {}, + "stats": {} + } + } +} +``` + +Normalization requirements: + +1. missing `_system` must be treated as `{}` +2. missing `_system.index0` must be initialized during load or merge +3. all `events`, `checkpoints`, `canonical_views`, `heads`, and `stats` members must be JSON objects +4. all keys must be ASCII strings +5. the canonical JSON hash function must use `sort_keys=True` and compact separators `(",", ":")` + +## 3) Schema Definitions + +### 3.1 `index0_event` + +Storage key: + +- `events[event_id]` + +Required fields: + +```json +{ + "event_id": "e:sha256hex", + "event_type": "entry_created", + "subject_type": "entry", + "subject_id": "42", + "subject_kind": "document", + "scope_path": "seed//managed/", + "actor_type": "user", + "actor_id": "", + "writer_id": "writer:", + "modified_ts": 1760000000, + "prev_hash": "", + "classification": "internal", + "partition": "standard", + "payload_ref": { + "entry_id": "42" + }, + "links": [], + "tags": [], + "visibility": "private", + "integrity_hash": "sha256hex" +} +``` + +Optional fields: + +```json +{ + "policy_ref": "", + "source": "entry_management", + "source_event_id": "", + "summary": "" +} +``` + +Enum constraints: + +1. `event_type`: + - `entry_created` + - `entry_modified` + - `entry_deleted` + - `entry_archived` + - `entry_restored` + - `link_added` + - `link_removed` + - `profile_added` + - `profile_removed` + - `agent_added` + - `agent_removed` + - `sync_snapshot_published` + - `sync_delta_applied` + - `nostr_message_indexed` + - `document_indexed` +2. `subject_type`: + - `entry` + - `profile` + - `agent` + - `conversation` + - `document` + - `sync` +3. `actor_type`: + - `user` + - `agent` + - `system` +4. `classification`: + - `public` + - `internal` + - `restricted` + - `secret` +5. `partition`: + - `standard` + - `high_risk` +6. `visibility`: + - `public` + - `private` + - `policy_scoped` + +Normalization rules: + +1. `event_id`, `subject_id`, `actor_id`, and `writer_id` are stored as strings +2. `modified_ts` must be an integer greater than zero +3. `prev_hash` defaults to `""` +4. `links` must use the same canonical link normalization rules already used for entries +5. `tags` must be unique and lexicographically sorted +6. `summary` must never contain secret material +7. `integrity_hash` is computed from the canonical event body excluding the `integrity_hash` field itself + +### 3.2 `index0_checkpoint` + +Storage key: + +- `checkpoints[checkpoint_id]` + +Required fields: + +```json +{ + "checkpoint_id": "cp:day:2026-03-05:writer:", + "window_type": "day", + "window_key": "2026-03-05", + "writer_id": "writer:", + "window_start_ts": 1760000000, + "window_end_ts": 1760086399, + "event_count": 512, + "head_hash": "sha256hex", + "summary_hash": "sha256hex", + "rollup": { + "events_by_type": {}, + "subjects_by_kind": {}, + "subjects": [] + }, + "modified_ts": 1760086399 +} +``` + +Normalization rules: + +1. initial implementation supports only `window_type = "day"` +2. `checkpoint_id` must be deterministic from `window_type`, `window_key`, and `writer_id` +3. `subjects` must be a sorted unique list and capped deterministically +4. `summary_hash` is computed from the canonical checkpoint body excluding `summary_hash` + +### 3.3 `canonical_view` + +Storage key: + +- `canonical_views[view_id]` + +Required fields: + +```json +{ + "view_id": "children_of:seed/", + "view_type": "children_of", + "scope_path": "seed/", + "source_checkpoint_ids": [], + "source_event_ids": [], + "data": {}, + "modified_ts": 1760086400, + "view_hash": "sha256hex" +} +``` + +Normalization rules: + +1. only compact low-cardinality views are eligible for canonical sync in Phase 1 +2. `data` must be deterministic and sorted by stable keys +3. `view_hash` is computed from the canonical view body excluding `view_hash` + +### 3.4 `view_manifest` + +Purpose: + +1. declare canonical synced views +2. version view builders +3. make local rebuild policy explicit + +Schema: + +```json +{ + "version": 1, + "canonical_view_types": [ + "children_of", + "counts_by_kind", + "recent_activity" + ], + "local_only_view_types": [ + "hot_nodes", + "conversation_index", + "semantic_neighbors" + ], + "builder_versions": { + "children_of": 1, + "counts_by_kind": 1, + "recent_activity": 1 + } +} +``` + +### 3.5 `heads` + +Purpose: + +Track current writer-stream head hashes. + +Schema: + +```json +{ + "writer:": { + "event_id": "e:sha256hex", + "head_hash": "sha256hex", + "modified_ts": 1760086400 + } +} +``` + +Rules: + +1. each `writer_id` has at most one head record +2. higher `modified_ts` wins +3. equal timestamp resolves by lexicographically larger `head_hash` + +### 3.6 `stats` + +Initial synced stats: + +```json +{ + "event_count": 1280, + "checkpoint_count": 14, + "writer_count": 3, + "last_compaction_ts": 1760086400, + "last_validation_ts": 1760086400 +} +``` + +Stats are advisory and deterministically derived. They are never the source of truth. + +## 4) Manifest Extension Contract + +The current manifest already carries snapshot metadata. Extend it with optional `index0` fields: + +```json +{ + "ver": 1, + "algo": "gzip", + "chunks": [], + "delta_since": 1760086400, + "nonce": "base64...", + "index0": { + "schema_version": 1, + "checkpoint_ids": [ + "cp:day:2026-03-05:writer:" + ], + "checkpoint_hashes": { + "cp:day:2026-03-05:writer:": "sha256hex" + }, + "stream_heads": { + "writer:": "sha256hex" + } + } +} +``` + +Manifest rules: + +1. manifest `index0` section is optional for backward compatibility +2. restore must accept manifests with no `index0` +3. manifest `index0` metadata is validation material, not source-of-truth state +4. if present, it must be recomputable from the decrypted payload after replay + +## 5) Hashing And ID Rules + +### 5.1 Canonical helper + +Use the same canonical JSON discipline already used by the sync layer: + +```python +json.dumps(value, sort_keys=True, separators=(",", ":")) +``` + +### 5.2 Event body hash + +Pseudo: + +```python +def canonical_event_body(event): + body = dict(event) + body.pop("integrity_hash", None) + return body + +def compute_event_hash(event): + return sha256(canonical(canonical_event_body(event))) +``` + +### 5.3 Event ID + +For Phase 1, event IDs should be content-addressed and deterministic: + +```python +event_id = "e:" + compute_event_hash(event) +``` + +This intentionally makes duplicate event publication idempotent. + +### 5.4 Head hash + +Pseudo: + +```python +def compute_head_hash(event): + marker = { + "event_id": event["event_id"], + "integrity_hash": event["integrity_hash"], + "prev_hash": event.get("prev_hash", ""), + "writer_id": event["writer_id"], + } + return sha256(canonical(marker)) +``` + +### 5.5 Checkpoint ID + +Pseudo: + +```python +checkpoint_id = f"cp:day:{window_key}:{writer_id}" +``` + +### 5.6 Checkpoint hash + +Pseudo: + +```python +def compute_checkpoint_hash(checkpoint): + body = dict(checkpoint) + body.pop("summary_hash", None) + return sha256(canonical(body)) +``` + +## 6) Writer Model + +Phase 1 writer rule: + +1. `writer_id` defaults to profile scope, not device instance +2. format: + - root profile: `writer:profile:` + - managed scope: `writer:profile::managed:` + - agent scope later if needed: `writer:agent:` + +Rationale: + +1. profile-scoped writers are simpler and stable across devices +2. this fits current `beta` multi-device deterministic merge better than device-scoped streams +3. agent-specific streams can be added later without invalidating the contract + +## 7) Deterministic Merge Rules + +`merge_index_payloads(...)` remains the top-level orchestrator. + +Add: + +```python +merge_system_index0(current_index0, incoming_index0) -> merged_index0 +``` + +### 7.1 Event merge + +Rules: + +1. merge by `event_id` +2. if only one side contains an event, keep it +3. if both sides contain the same `event_id` and canonical bodies match, keep one +4. if both sides contain the same `event_id` but bodies differ, pick the event with lexicographically larger canonical body hash and record a validation warning path later + +This last case should be treated as corruption-suspicious and covered by tests even if it should not occur in normal generation. + +### 7.2 Head merge + +Rules: + +1. merge by `writer_id` +2. higher `modified_ts` wins +3. equal timestamp resolves by larger `head_hash` + +### 7.3 Checkpoint merge + +Rules: + +1. merge by `checkpoint_id` +2. if both sides contain the same `checkpoint_id` with different bodies, select lexicographically larger `summary_hash` +3. equal bodies remain idempotent + +### 7.4 Canonical view merge + +Rules: + +1. merge by `view_id` +2. higher `modified_ts` wins +3. equal timestamp resolves by larger `view_hash` +4. local-only views must never be written into canonical synced payload + +### 7.5 Stats merge + +Rules: + +1. stats are recomputed from merged payload where practical +2. if not recomputed, use deterministic max/union semantics only + +## 8) Merge Pseudocode + +```python +def merge_system_index0(current, incoming): + cur = normalize_index0(current) + inc = normalize_index0(incoming) + + merged_events = merge_events(cur["events"], inc["events"]) + merged_heads = merge_heads(cur["heads"], inc["heads"]) + merged_checkpoints = merge_checkpoints( + cur["checkpoints"], inc["checkpoints"] + ) + merged_views = merge_canonical_views( + cur["canonical_views"], inc["canonical_views"], cur["view_manifest"], inc["view_manifest"] + ) + merged_view_manifest = merge_view_manifest( + cur["view_manifest"], inc["view_manifest"] + ) + + merged = { + "schema_version": max(cur["schema_version"], inc["schema_version"]), + "events": merged_events, + "checkpoints": merged_checkpoints, + "canonical_views": merged_views, + "view_manifest": merged_view_manifest, + "heads": merged_heads, + "stats": recompute_stats( + merged_events, merged_checkpoints, merged_heads + ), + } + return compact_index0(merged) +``` + +## 9) Deterministic Compaction Defaults + +Phase 1 compaction must stay intentionally simple. + +Defaults: + +1. raw event retention per writer: + - keep last `512` raw events +2. raw event retention by age: + - keep last `30` days +3. checkpoint window: + - daily +4. canonical view retention: + - keep only latest view per `view_id` +5. checkpoint subject list cap: + - `128` + +Compaction order: + +1. group events by `writer_id` +2. within each writer, sort by: + - `modified_ts` + - `event_id` +3. materialize daily checkpoints for prunable windows +4. prune raw events only after checkpoint exists +5. recompute heads and stats + +Guarantee: + +If two devices compact the same normalized payload, they must produce the same payload. + +## 10) Initial Builder Scope + +The first code slice should not try to solve the full knowledge-base product. + +Phase 1 implementation should include only: + +1. `_system.index0` schema initialization and migration +2. event emission for: + - entry create + - entry modify + - entry delete + - entry archive + - entry restore + - link add + - link remove +3. merge support +4. head maintenance +5. manifest `index0` metadata round-trip + +Deferred until later phases: + +1. communication-specific event emission +2. local rebuild daemons +3. UI-facing atlas panels +4. semantic interplay +5. policy-filtered atlas export + +## 11) Failure Handling + +Rules: + +1. malformed `index0` objects must normalize to empty structures where safe +2. malformed individual events/checkpoints/views should be dropped during normalization if required fields are invalid +3. manifest `index0` mismatches should warn by default in the first implementation +4. hard-fail behavior can be added later behind policy/config + +## 12) Test Matrix + +### 12.1 Unit + +1. `normalize_index0` fills defaults and drops malformed objects +2. `compute_event_hash` is stable +3. `compute_head_hash` is stable +4. `compute_checkpoint_hash` is stable +5. tag normalization is deterministic +6. checkpoint ID generation is deterministic + +### 12.2 Merge + +1. event merge is idempotent +2. event merge is order independent +3. conflicting same-`event_id` bodies resolve deterministically +4. head merge prefers newer `modified_ts` +5. equal timestamp head merge prefers larger `head_hash` +6. view merge rejects local-only view types in canonical payload + +### 12.3 Replay / vault boundary + +1. payload with `_system.index0` survives encrypt/decrypt round-trip +2. repeated merge through vault boundary is idempotent +3. stale payload replay cannot remove newer heads/checkpoints + +### 12.4 Migration + +1. old payload without `_system` upgrades cleanly +2. old payload with malformed `_system.index0` repairs cleanly +3. existing entry CRUD remains unchanged + +### 12.5 Nostr manifest + +1. manifest serializes and parses optional `index0` +2. restore accepts legacy manifest with no `index0` +3. restore warning path triggers on checkpoint/head mismatch + +### 12.6 Integration + +1. add entry emits `entry_created` +2. modify entry emits `entry_modified` +3. delete path emits `entry_deleted` +4. archive/restore emit expected events +5. link add/remove emit expected events + +## 13) Implementation Order + +Recommended coding order: + +1. add normalization + hashing helpers in a new `index0` module +2. extend merge logic with `merge_system_index0` +3. extend manifest dataclass and serializer/parser +4. add payload migration/init in vault load path +5. emit events from entry-management hooks +6. add tests in lockstep + +## 14) Definition Of Phase 0 Complete + +Phase 0 is complete when: + +1. this schema contract is accepted +2. manifest extension fields are accepted +3. merge and compaction rules are accepted +4. first implementation slice is accepted +5. test inventory is accepted + +At that point, implementation can move directly into Phase 1 without reopening architecture questions. diff --git a/src/nostr/backup_models.py b/src/nostr/backup_models.py index 7918c5c1..860c1bc7 100644 --- a/src/nostr/backup_models.py +++ b/src/nostr/backup_models.py @@ -1,5 +1,5 @@ from dataclasses import dataclass -from typing import List, Optional +from typing import Any, List, Optional # Event kind constants used for SeedPass backups KIND_MANIFEST = 30070 @@ -26,3 +26,4 @@ class Manifest: chunks: List[ChunkMeta] delta_since: Optional[int] = None nonce: Optional[str] = None + index0: Optional[dict[str, Any]] = None diff --git a/src/nostr/snapshot.py b/src/nostr/snapshot.py index 1d4858eb..2a238f5b 100644 --- a/src/nostr/snapshot.py +++ b/src/nostr/snapshot.py @@ -114,6 +114,7 @@ class SnapshotHandler: "chunks": [meta.__dict__ for meta in manifest.chunks], "delta_since": manifest.delta_since, "nonce": manifest.nonce, + "index0": manifest.index0, } ) @@ -151,6 +152,9 @@ class SnapshotHandler: else None ), nonce=data.get("nonce"), + index0=( + data.get("index0") if isinstance(data.get("index0"), dict) else None + ), ) except Exception: return None @@ -376,6 +380,7 @@ class SnapshotHandler: ], "delta_since": self.current_manifest.delta_since, "nonce": self.current_manifest.nonce, + "index0": self.current_manifest.index0, } ) manifest_event = ( diff --git a/src/seedpass/cli/__init__.py b/src/seedpass/cli/__init__.py index 6cc2ea3c..efbd3003 100644 --- a/src/seedpass/cli/__init__.py +++ b/src/seedpass/cli/__init__.py @@ -14,7 +14,7 @@ from .common import _get_entry_service, _get_services from seedpass.core.errors import SeedPassError from constants import GUI_BACKEND_CONFIG from seedpass.tui_v2.app import check_tui2_runtime, launch_tui2 -from seedpass.tui_v3 import launch_tui3 +from seedpass.tui_v3 import check_tui3_runtime, launch_tui3 app = typer.Typer( help=( @@ -49,11 +49,11 @@ deterministic_totp_option = typer.Option( legacy_tui_option = typer.Option( False, "--legacy-tui", - help="Use legacy interactive TUI (temporary fallback during TUI v2 cutover)", + help="Use legacy interactive TUI explicitly", ) tui_version_option = typer.Option( - "v2", + "v3", "--tui", help="Select TUI version: legacy, v2, v3", ) @@ -154,6 +154,36 @@ def _launch_legacy_tui(*, fingerprint: Optional[str]) -> typer.Exit: return typer.Exit(legacy_main(fingerprint=fingerprint)) +def _launch_tui3_or_exit( + *, + fingerprint: Optional[str], + services: Optional[dict[str, object]] = None, +) -> None: + kwargs: dict[str, object] = {"fingerprint": fingerprint} + if services is not None: + kwargs.update( + { + "entry_service_factory": lambda: services["entry"], + "profile_service_factory": lambda: services["profile"], + "config_service_factory": lambda: services["config"], + "nostr_service_factory": lambda: services["nostr"], + "sync_service_factory": lambda: services["sync"], + "utility_service_factory": lambda: services["utility"], + "vault_service_factory": lambda: services["vault"], + "semantic_service_factory": lambda: services["semantic"], + } + ) + launched = launch_tui3(**kwargs) + if launched: + return + typer.echo( + "TUI v3 runtime unavailable. Run `seedpass tui3 --check` for diagnostics, " + "`seedpass tui2` to launch TUI v2, or `seedpass legacy` for the legacy interface.", + err=True, + ) + raise typer.Exit(1) + + @app.callback(invoke_without_command=True) def main( ctx: typer.Context, @@ -188,27 +218,17 @@ def main( if legacy_tui or tui_version == "legacy": raise _launch_legacy_tui(fingerprint=fingerprint) + if tui_version == "v3": + _launch_tui3_or_exit(fingerprint=fingerprint) + return + services, preflight_error = _prime_tui2_service(ctx) if preflight_error is not None: typer.echo( - f"TUI {tui_version} preflight failed; falling back to legacy TUI. " - f"Details: {preflight_error}" + f"TUI {tui_version} preflight failed: {preflight_error}", + err=True, ) - raise _launch_legacy_tui(fingerprint=fingerprint) - - if tui_version == "v3": - launch_tui3( - fingerprint=fingerprint, - entry_service_factory=lambda: services["entry"], - profile_service_factory=lambda: services["profile"], - config_service_factory=lambda: services["config"], - nostr_service_factory=lambda: services["nostr"], - sync_service_factory=lambda: services["sync"], - utility_service_factory=lambda: services["utility"], - vault_service_factory=lambda: services["vault"], - semantic_service_factory=lambda: services["semantic"], - ) - return + raise typer.Exit(1) launched = launch_tui2( fingerprint=fingerprint, @@ -225,10 +245,10 @@ def main( return typer.echo( - f"TUI {tui_version} runtime unavailable; falling back to legacy TUI. " - "Run `seedpass tui2 --check` for diagnostics." + "TUI v2 runtime unavailable. Run `seedpass tui2 --check` for diagnostics, " + "or use `seedpass legacy` for the legacy interface." ) - raise _launch_legacy_tui(fingerprint=fingerprint) + raise typer.Exit(1) @app.command("lock") @@ -310,30 +330,13 @@ def tui3( help="Check whether TUI v3 runtime dependencies are available", ), ) -> None: - """Launch the experimental TUI v3 scratch architecture.""" + """Launch the current TUI v3 interface.""" if check: - from seedpass.tui_v3 import check_tui3_runtime - typer.echo(json.dumps(check_tui3_runtime(), indent=2, sort_keys=True)) return fingerprint = (ctx.obj or {}).get("fingerprint") - services, preflight_error = _prime_tui2_service(ctx) - if preflight_error is not None: - typer.echo(f"TUI v3 preflight failed: {preflight_error}", err=True) - raise typer.Exit(1) - - launch_tui3( - fingerprint=fingerprint, - entry_service_factory=lambda: services["entry"], - profile_service_factory=lambda: services["profile"], - config_service_factory=lambda: services["config"], - nostr_service_factory=lambda: services["nostr"], - sync_service_factory=lambda: services["sync"], - utility_service_factory=lambda: services["utility"], - vault_service_factory=lambda: services["vault"], - semantic_service_factory=lambda: services["semantic"], - ) + _launch_tui3_or_exit(fingerprint=fingerprint) @app.command("legacy") diff --git a/src/seedpass/cli/capabilities.py b/src/seedpass/cli/capabilities.py index fedba3d4..20e3ccb6 100644 --- a/src/seedpass/cli/capabilities.py +++ b/src/seedpass/cli/capabilities.py @@ -14,11 +14,12 @@ def build_capabilities() -> dict[str, Any]: "product": "SeedPass", "interfaces": { "cli": { + "default_interface": "tui3", "root_commands": [ "lock", "gui", - "tui2", "tui3", + "tui2", "legacy", "capabilities", "entry", @@ -266,8 +267,9 @@ def build_capabilities() -> dict[str, Any]: "Run `seedpass --help` for top-level commands.", "Run `seedpass --help` for detailed options.", "Use `seedpass capabilities --format json` for machine-readable discovery.", - "Run `seedpass tui2 --check` before adopting Textual-based TUI v2.", - "Use `seedpass --legacy-tui` to force the legacy interface during cutover.", + "Run `seedpass` or `seedpass tui3` to launch the current TUI v3.", + "Run `seedpass tui2 --check` before using the maintained-but-non-default TUI v2.", + "Use `seedpass legacy` or `seedpass --legacy-tui` for the legacy interface.", "In TUI v2, use `?` for help, `j` to jump to entry id, `1/2/3` to focus panes, `Ctrl+P` for palette, `p/n` for paging, and `x` to retry.", "For graph workflows, start with `seedpass entry link-add --help` and `seedpass entry links --help`.", "For semantic KB retrieval, run `seedpass semantic --help` and check `seedpass semantic status`.", diff --git a/src/seedpass/core/encryption.py b/src/seedpass/core/encryption.py index 07ba0405..0e825580 100644 --- a/src/seedpass/core/encryption.py +++ b/src/seedpass/core/encryption.py @@ -29,6 +29,7 @@ from mnemonic import Mnemonic from utils.password_prompt import prompt_existing_password from utils.key_derivation import KdfConfig, CURRENT_KDF_VERSION from .errors import DecryptionError +from .index0 import ensure_index0_payload from .sync_conflict import merge_index_payloads # Instantiate the logger @@ -479,6 +480,7 @@ class EncryptionManager: data = json_lib.loads(decrypted) else: data = json_lib.loads(decrypted.decode("utf-8")) + data = ensure_index0_payload(data) existing_file = self.resolve_relative_path(relative_path) if merge and existing_file.exists(): current = self.load_json_data(relative_path) diff --git a/src/seedpass/core/index0.py b/src/seedpass/core/index0.py new file mode 100644 index 00000000..d7907f50 --- /dev/null +++ b/src/seedpass/core/index0.py @@ -0,0 +1,437 @@ +from __future__ import annotations + +import hashlib +import json +from typing import Any + +INDEX0_SCHEMA_VERSION = 1 +LOCAL_ONLY_VIEW_TYPES = {"hot_nodes", "conversation_index", "semantic_neighbors"} + + +def _canonical(value: Any) -> str: + return json.dumps(value, sort_keys=True, separators=(",", ":")) + + +def _sha256_hex(value: Any) -> str: + if isinstance(value, str): + payload = value.encode("utf-8") + else: + payload = _canonical(value).encode("utf-8") + return hashlib.sha256(payload).hexdigest() + + +def _normalize_string(value: Any) -> str: + return str(value).strip() + + +def _safe_int(raw: Any, *, default: int = 0) -> int: + try: + return int(raw) + except Exception: + return int(default) + + +def _normalize_tags(raw_tags: Any) -> list[str]: + if not isinstance(raw_tags, list): + return [] + out = sorted({_normalize_string(tag) for tag in raw_tags if _normalize_string(tag)}) + return out + + +def _normalize_links(raw_links: Any) -> list[dict[str, Any]]: + if not isinstance(raw_links, list): + return [] + out: dict[str, dict[str, Any]] = {} + for item in raw_links: + if not isinstance(item, dict): + continue + target_id = _normalize_string(item.get("target_id", "")) + relation = _normalize_string(item.get("relation", "")) + note = _normalize_string(item.get("note", "")) + if not target_id or not relation: + continue + normalized = {"target_id": target_id, "relation": relation} + if note: + normalized["note"] = note + out[_canonical(normalized)] = normalized + return [out[key] for key in sorted(out.keys())] + + +def _normalize_mapping(raw: Any) -> dict[str, Any]: + return dict(raw) if isinstance(raw, dict) else {} + + +def normalize_index0(raw: Any) -> dict[str, Any]: + data = dict(raw) if isinstance(raw, dict) else {} + normalized = { + "schema_version": max( + INDEX0_SCHEMA_VERSION, + _safe_int(data.get("schema_version", INDEX0_SCHEMA_VERSION), default=1), + ), + "events": {}, + "checkpoints": {}, + "canonical_views": {}, + "view_manifest": {}, + "heads": {}, + "stats": {}, + } + for event_id, event in _normalize_mapping(data.get("events")).items(): + normalized_event = normalize_index0_event(event) + if normalized_event is None: + continue + normalized["events"][str(event_id)] = normalized_event + for checkpoint_id, checkpoint in _normalize_mapping( + data.get("checkpoints") + ).items(): + normalized_checkpoint = normalize_index0_checkpoint(checkpoint) + if normalized_checkpoint is None: + continue + normalized["checkpoints"][str(checkpoint_id)] = normalized_checkpoint + view_manifest = normalize_view_manifest(data.get("view_manifest")) + normalized["view_manifest"] = view_manifest + for view_id, view in _normalize_mapping(data.get("canonical_views")).items(): + normalized_view = normalize_canonical_view(view, view_manifest=view_manifest) + if normalized_view is None: + continue + normalized["canonical_views"][str(view_id)] = normalized_view + for writer_id, head in _normalize_mapping(data.get("heads")).items(): + normalized_head = normalize_head(head) + if normalized_head is None: + continue + normalized["heads"][str(writer_id)] = normalized_head + normalized["stats"] = recompute_index0_stats(normalized) + return normalized + + +def ensure_index0_payload(data: dict[str, Any]) -> dict[str, Any]: + out = dict(data) if isinstance(data, dict) else {} + system = dict(out.get("_system")) if isinstance(out.get("_system"), dict) else {} + system["index0"] = normalize_index0(system.get("index0")) + out["_system"] = system + return out + + +def normalize_view_manifest(raw: Any) -> dict[str, Any]: + data = dict(raw) if isinstance(raw, dict) else {} + canonical_view_types = sorted( + { + _normalize_string(item) + for item in data.get("canonical_view_types", []) + if _normalize_string(item) + } + ) + local_only_view_types = sorted( + { + _normalize_string(item) + for item in data.get("local_only_view_types", []) + if _normalize_string(item) + } + ) + builder_versions = {} + raw_versions = data.get("builder_versions", {}) + if isinstance(raw_versions, dict): + for key, value in raw_versions.items(): + key_str = _normalize_string(key) + if not key_str: + continue + builder_versions[key_str] = _safe_int(value, default=1) + return { + "version": max(1, _safe_int(data.get("version", 1), default=1)), + "canonical_view_types": canonical_view_types, + "local_only_view_types": local_only_view_types, + "builder_versions": dict(sorted(builder_versions.items())), + } + + +def compute_event_hash(event: dict[str, Any]) -> str: + body = dict(event) + body.pop("integrity_hash", None) + return _sha256_hex(body) + + +def compute_event_id(event: dict[str, Any]) -> str: + return f"e:{compute_event_hash(event)}" + + +def compute_head_hash(event: dict[str, Any]) -> str: + marker = { + "event_id": _normalize_string(event.get("event_id", "")), + "integrity_hash": _normalize_string(event.get("integrity_hash", "")), + "prev_hash": _normalize_string(event.get("prev_hash", "")), + "writer_id": _normalize_string(event.get("writer_id", "")), + } + return _sha256_hex(marker) + + +def compute_checkpoint_hash(checkpoint: dict[str, Any]) -> str: + body = dict(checkpoint) + body.pop("summary_hash", None) + return _sha256_hex(body) + + +def compute_view_hash(view: dict[str, Any]) -> str: + body = dict(view) + body.pop("view_hash", None) + return _sha256_hex(body) + + +def normalize_index0_event(raw: Any) -> dict[str, Any] | None: + if not isinstance(raw, dict): + return None + modified_ts = _safe_int(raw.get("modified_ts", 0), default=0) + if modified_ts <= 0: + return None + event = { + "event_type": _normalize_string(raw.get("event_type", "")), + "subject_type": _normalize_string(raw.get("subject_type", "")), + "subject_id": _normalize_string(raw.get("subject_id", "")), + "subject_kind": _normalize_string(raw.get("subject_kind", "")), + "scope_path": _normalize_string(raw.get("scope_path", "")), + "actor_type": _normalize_string(raw.get("actor_type", "")), + "actor_id": _normalize_string(raw.get("actor_id", "")), + "writer_id": _normalize_string(raw.get("writer_id", "")), + "modified_ts": modified_ts, + "prev_hash": _normalize_string(raw.get("prev_hash", "")), + "classification": _normalize_string(raw.get("classification", "internal")) + or "internal", + "partition": _normalize_string(raw.get("partition", "standard")) or "standard", + "payload_ref": _normalize_mapping(raw.get("payload_ref")), + "links": _normalize_links(raw.get("links")), + "tags": _normalize_tags(raw.get("tags")), + "visibility": _normalize_string(raw.get("visibility", "private")) or "private", + } + for key in ("policy_ref", "source", "source_event_id", "summary"): + value = _normalize_string(raw.get(key, "")) + if value: + event[key] = value + required = ( + event["event_type"], + event["subject_type"], + event["subject_id"], + event["writer_id"], + ) + if not all(required): + return None + event["integrity_hash"] = _normalize_string(raw.get("integrity_hash", "")) + expected_hash = compute_event_hash(event) + if not event["integrity_hash"]: + event["integrity_hash"] = expected_hash + event["event_id"] = ( + _normalize_string(raw.get("event_id", "")) or f"e:{expected_hash}" + ) + return event + + +def normalize_index0_checkpoint(raw: Any) -> dict[str, Any] | None: + if not isinstance(raw, dict): + return None + checkpoint_id = _normalize_string(raw.get("checkpoint_id", "")) + writer_id = _normalize_string(raw.get("writer_id", "")) + window_type = _normalize_string(raw.get("window_type", "")) + window_key = _normalize_string(raw.get("window_key", "")) + modified_ts = _safe_int(raw.get("modified_ts", 0), default=0) + if ( + not checkpoint_id + or not writer_id + or not window_type + or not window_key + or modified_ts <= 0 + ): + return None + rollup_raw = _normalize_mapping(raw.get("rollup")) + events_by_type = _normalize_mapping(rollup_raw.get("events_by_type")) + subjects_by_kind = _normalize_mapping(rollup_raw.get("subjects_by_kind")) + subjects = _normalize_tags(rollup_raw.get("subjects")) + checkpoint = { + "checkpoint_id": checkpoint_id, + "window_type": window_type, + "window_key": window_key, + "writer_id": writer_id, + "window_start_ts": _safe_int(raw.get("window_start_ts", 0), default=0), + "window_end_ts": _safe_int(raw.get("window_end_ts", 0), default=0), + "event_count": max(0, _safe_int(raw.get("event_count", 0), default=0)), + "head_hash": _normalize_string(raw.get("head_hash", "")), + "rollup": { + "events_by_type": { + _normalize_string(k): _safe_int(v, default=0) + for k, v in sorted( + events_by_type.items(), key=lambda item: str(item[0]) + ) + if _normalize_string(k) + }, + "subjects_by_kind": { + _normalize_string(k): _safe_int(v, default=0) + for k, v in sorted( + subjects_by_kind.items(), key=lambda item: str(item[0]) + ) + if _normalize_string(k) + }, + "subjects": subjects, + }, + "modified_ts": modified_ts, + } + checkpoint["summary_hash"] = _normalize_string( + raw.get("summary_hash", "") + ) or compute_checkpoint_hash(checkpoint) + return checkpoint + + +def normalize_canonical_view( + raw: Any, *, view_manifest: dict[str, Any] | None = None +) -> dict[str, Any] | None: + if not isinstance(raw, dict): + return None + view_type = _normalize_string(raw.get("view_type", "")) + modified_ts = _safe_int(raw.get("modified_ts", 0), default=0) + if not view_type or modified_ts <= 0: + return None + if view_type in LOCAL_ONLY_VIEW_TYPES: + return None + if view_manifest: + local_only = set(view_manifest.get("local_only_view_types", [])) + if view_type in local_only: + return None + view = { + "view_id": _normalize_string(raw.get("view_id", "")), + "view_type": view_type, + "scope_path": _normalize_string(raw.get("scope_path", "")), + "source_checkpoint_ids": _normalize_tags(raw.get("source_checkpoint_ids")), + "source_event_ids": _normalize_tags(raw.get("source_event_ids")), + "data": _normalize_mapping(raw.get("data")), + "modified_ts": modified_ts, + } + if not view["view_id"]: + return None + view["view_hash"] = _normalize_string( + raw.get("view_hash", "") + ) or compute_view_hash(view) + return view + + +def normalize_head(raw: Any) -> dict[str, Any] | None: + if not isinstance(raw, dict): + return None + event_id = _normalize_string(raw.get("event_id", "")) + head_hash = _normalize_string(raw.get("head_hash", "")) + modified_ts = _safe_int(raw.get("modified_ts", 0), default=0) + if not event_id or not head_hash or modified_ts <= 0: + return None + return {"event_id": event_id, "head_hash": head_hash, "modified_ts": modified_ts} + + +def _prefer_hashed_record( + current: dict[str, Any], incoming: dict[str, Any], *, hash_field: str +) -> bool: + cur_ts = _safe_int(current.get("modified_ts", 0), default=0) + inc_ts = _safe_int(incoming.get("modified_ts", 0), default=0) + if inc_ts != cur_ts: + return inc_ts > cur_ts + return _normalize_string(incoming.get(hash_field, "")) > _normalize_string( + current.get(hash_field, "") + ) + + +def merge_system_index0(current: Any, incoming: Any) -> dict[str, Any]: + cur = normalize_index0(current) + inc = normalize_index0(incoming) + + events = dict(cur["events"]) + for event_id, event in inc["events"].items(): + existing = events.get(event_id) + if existing is None: + events[event_id] = event + continue + if _canonical(existing) == _canonical(event): + continue + if _sha256_hex(event) > _sha256_hex(existing): + events[event_id] = event + + checkpoints = dict(cur["checkpoints"]) + for checkpoint_id, checkpoint in inc["checkpoints"].items(): + existing = checkpoints.get(checkpoint_id) + if existing is None: + checkpoints[checkpoint_id] = checkpoint + continue + if _prefer_hashed_record(existing, checkpoint, hash_field="summary_hash"): + checkpoints[checkpoint_id] = checkpoint + + view_manifest = normalize_view_manifest( + { + "version": max( + _safe_int(cur["view_manifest"].get("version", 1), default=1), + _safe_int(inc["view_manifest"].get("version", 1), default=1), + ), + "canonical_view_types": sorted( + set(cur["view_manifest"].get("canonical_view_types", [])) + | set(inc["view_manifest"].get("canonical_view_types", [])) + ), + "local_only_view_types": sorted( + set(cur["view_manifest"].get("local_only_view_types", [])) + | set(inc["view_manifest"].get("local_only_view_types", [])) + ), + "builder_versions": { + **cur["view_manifest"].get("builder_versions", {}), + **inc["view_manifest"].get("builder_versions", {}), + }, + } + ) + + views = dict(cur["canonical_views"]) + for view_id, view in inc["canonical_views"].items(): + normalized_view = normalize_canonical_view(view, view_manifest=view_manifest) + if normalized_view is None: + continue + existing = views.get(view_id) + if existing is None: + views[view_id] = normalized_view + continue + if _prefer_hashed_record(existing, normalized_view, hash_field="view_hash"): + views[view_id] = normalized_view + + heads = dict(cur["heads"]) + for writer_id, head in inc["heads"].items(): + existing = heads.get(writer_id) + if existing is None or _prefer_hashed_record( + existing, head, hash_field="head_hash" + ): + heads[writer_id] = head + + merged = { + "schema_version": max(cur["schema_version"], inc["schema_version"]), + "events": dict(sorted(events.items())), + "checkpoints": dict(sorted(checkpoints.items())), + "canonical_views": dict(sorted(views.items())), + "view_manifest": view_manifest, + "heads": dict(sorted(heads.items())), + "stats": {}, + } + merged["stats"] = recompute_index0_stats(merged) + return merged + + +def recompute_index0_stats(index0: dict[str, Any]) -> dict[str, Any]: + events = _normalize_mapping(index0.get("events")) + checkpoints = _normalize_mapping(index0.get("checkpoints")) + heads = _normalize_mapping(index0.get("heads")) + stats = { + "event_count": len(events), + "checkpoint_count": len(checkpoints), + "writer_count": len(heads), + "last_compaction_ts": 0, + "last_validation_ts": 0, + } + checkpoint_ts = [ + _safe_int(value.get("modified_ts", 0), default=0) + for value in checkpoints.values() + if isinstance(value, dict) + ] + head_ts = [ + _safe_int(value.get("modified_ts", 0), default=0) + for value in heads.values() + if isinstance(value, dict) + ] + if checkpoint_ts: + stats["last_compaction_ts"] = max(checkpoint_ts) + if head_ts: + stats["last_validation_ts"] = max(head_ts) + return stats diff --git a/src/seedpass/core/manager.py b/src/seedpass/core/manager.py index 37144957..c490c425 100644 --- a/src/seedpass/core/manager.py +++ b/src/seedpass/core/manager.py @@ -245,7 +245,11 @@ class PasswordManager: deterministic_totp: bool = False def __init__( - self, fingerprint: Optional[str] = None, *, password: Optional[str] = None + self, + fingerprint: Optional[str] = None, + *, + password: Optional[str] = None, + bootstrap_only: bool = False, ) -> None: """Initialize the PasswordManager. @@ -321,13 +325,16 @@ class PasswordManager: if fingerprint: # Load the specified profile without prompting self.select_fingerprint(fingerprint, password=password) - else: + elif not bootstrap_only: # Ensure a parent seed is set up before accessing the fingerprint directory self.setup_parent_seed() # Set the current fingerprint directory after selection self.fingerprint_dir = ( self.fingerprint_manager.get_current_fingerprint_dir() ) + else: + self.current_fingerprint = None + self.fingerprint_dir = None @requires_unlocked def get_bip85_entropy( @@ -1017,6 +1024,77 @@ class PasswordManager: print(colored(f"Error: Profile recovery failed: {exc}", "red")) return False + def recover_profile_with_blank_index_data( + self, + *, + fingerprint: str, + parent_seed: str, + password: str, + ) -> bool: + """Recover ``fingerprint`` with ``parent_seed`` and a blank local index.""" + fingerprints = self.fingerprint_manager.list_fingerprints() + if fingerprint not in fingerprints: + raise SeedPassError("Selected profile does not exist.") + + fingerprint_dir = self.fingerprint_manager.get_fingerprint_directory( + fingerprint + ) + if not fingerprint_dir: + raise SeedPassError("Profile directory not found.") + + if not self.validate_bip85_seed(parent_seed): + raise SeedPassError("Invalid BIP-85 seed phrase.") + + computed_fp = generate_fingerprint(parent_seed) + if computed_fp != fingerprint: + raise SeedPassError("Seed does not match selected profile fingerprint.") + + try: + self.current_fingerprint = fingerprint + self.fingerprint_manager.current_fingerprint = fingerprint + self.fingerprint_dir = fingerprint_dir + self.parent_seed = parent_seed + + seed_bytes = Bip39SeedGenerator(parent_seed).Generate() + self.derive_key_hierarchy(seed_bytes) + index_key = base64.urlsafe_b64encode(self.KEY_STORAGE) + seed_key = self._derive_seed_key(password, fingerprint) + + self.encryption_manager = EncryptionManager(index_key, fingerprint_dir) + seed_mgr = EncryptionManager(seed_key, fingerprint_dir) + self.vault = Vault(self.encryption_manager, fingerprint_dir) + self.config_manager = ConfigManager( + vault=self.vault, + fingerprint_dir=fingerprint_dir, + ) + + self._encrypt_parent_seed_compat( + seed_mgr, + parent_seed, + fingerprint, + mode=self._get_kdf_mode(), + ) + self.store_hashed_password(password) + + for stale_file in ( + fingerprint_dir / "seedpass_entries_db.json.enc", + fingerprint_dir / "seedpass_passwords_db.json.enc", + fingerprint_dir / "seedpass_entries_db_checksum.txt", + fingerprint_dir / "seedpass_passwords_db_checksum.txt", + ): + try: + stale_file.unlink() + except FileNotFoundError: + pass + + self.initialize_bip85() + self.initialize_managers() + self.start_background_sync() + return True + except Exception as exc: + logger.error("Profile recovery failed: %s", exc, exc_info=True) + raise SeedPassError(f"Profile recovery failed: {exc}") from exc + def setup_encryption_manager( self, fingerprint_dir: Path, @@ -1605,6 +1683,19 @@ class PasswordManager: print(colored(f"Error: Failed to generate BIP-85 seed: {e}", "red")) raise SeedPassError(f"Failed to generate BIP-85 seed: {e}") from e + def create_profile_from_generated_seed( + self, + *, + password: str, + seed: Optional[str] = None, + ) -> tuple[str, str]: + """Create a new profile from a generated seed and return ``(fingerprint, seed)``.""" + new_seed = seed or self.generate_bip85_seed() + fingerprint = self._finalize_existing_seed(new_seed, password=password) + if not fingerprint: + raise SeedPassError("Failed to create profile from generated seed.") + return fingerprint, new_seed + @requires_unlocked def save_and_encrypt_seed( self, seed: str, fingerprint_dir: Path, *, password: Optional[str] = None @@ -2234,6 +2325,47 @@ class PasswordManager: self.notify("Starting with a new, empty vault.", level="INFO") return + def restore_from_nostr_with_guidance_data( + self, + *, + seed_phrase: str, + password: str, + continue_without_backup: bool = False, + ) -> tuple[str, bool]: + """Restore a profile from Nostr without interactive prompts. + + Returns ``(fingerprint, have_backup)``. + """ + have_backup = self.check_nostr_backup_exists(seed_phrase) + if not have_backup and not continue_without_backup: + raise SeedPassError("No Nostr backup found for this seed profile.") + + fingerprint = self._finalize_existing_seed(seed_phrase, password=password) + if not fingerprint: + raise SeedPassError("Failed to initialize profile from provided seed.") + + if have_backup: + success = self.attempt_initial_sync() + if not success: + raise SeedPassError("Failed to download or decrypt vault from Nostr.") + return fingerprint, have_backup + + def restore_from_local_backup_data( + self, + *, + seed_phrase: str, + password: str, + backup_path: str, + ) -> str: + """Restore a profile from a local encrypted backup without prompts.""" + fingerprint = self._finalize_existing_seed(seed_phrase, password=password) + if not fingerprint: + raise SeedPassError("Failed to initialize profile from provided seed.") + if not self.backup_manager: + raise SeedPassError("Backup manager is not initialized.") + self.backup_manager.restore_from_backup(backup_path) + return fingerprint + def _clear_header_add_entry(self, subtitle: str) -> None: fp, parent_fp, child_fp = self.header_fingerprint_args clear_header_with_notification( diff --git a/src/seedpass/core/sync_conflict.py b/src/seedpass/core/sync_conflict.py index ebdc0e1b..1539cee9 100644 --- a/src/seedpass/core/sync_conflict.py +++ b/src/seedpass/core/sync_conflict.py @@ -4,6 +4,8 @@ import hashlib import json from typing import Any +from .index0 import ensure_index0_payload, merge_system_index0 + TOMBSTONE_RETENTION_CAP = 2048 @@ -190,7 +192,8 @@ def merge_index_payloads( current: dict[str, Any], incoming: dict[str, Any], *, source_tag: str = "" ) -> dict[str, Any]: """Merge two index payloads using deterministic conflict resolution.""" - out = dict(current) if isinstance(current, dict) else {} + out = ensure_index0_payload(dict(current) if isinstance(current, dict) else {}) + incoming = ensure_index0_payload(incoming if isinstance(incoming, dict) else {}) cur_entries = out.get("entries", {}) if not isinstance(cur_entries, dict): cur_entries = {} @@ -318,4 +321,14 @@ def merge_index_payloads( } ) out["_sync_meta"] = meta + out_system = out.get("_system", {}) + inc_system = incoming.get("_system", {}) + if not isinstance(out_system, dict): + out_system = {} + if not isinstance(inc_system, dict): + inc_system = {} + out_system["index0"] = merge_system_index0( + out_system.get("index0"), inc_system.get("index0") + ) + out["_system"] = out_system return out diff --git a/src/seedpass/core/vault.py b/src/seedpass/core/vault.py index a0a639d1..2124e25b 100644 --- a/src/seedpass/core/vault.py +++ b/src/seedpass/core/vault.py @@ -14,6 +14,7 @@ from .encryption import ( USE_ORJSON, json_lib, ) +from .index0 import ensure_index0_payload from utils.key_derivation import KdfConfig, CURRENT_KDF_VERSION from utils.password_prompt import prompt_existing_password @@ -232,12 +233,14 @@ class Vault: self.migrated_from_legacy = ( legacy_detected or migration_performed or schema_migrated ) + data = ensure_index0_payload(data) if return_migration_flags: return data, self.migrated_from_legacy, migration_performed return data def save_index(self, data: dict) -> None: """Encrypt and write password index.""" + data = ensure_index0_payload(data) self.encryption_manager.save_json_data( data, self.index_file, kdf=self._hkdf_kdf() ) diff --git a/src/seedpass/tui_v3/app.py b/src/seedpass/tui_v3/app.py index b1d6ef9b..3b9e3853 100644 --- a/src/seedpass/tui_v3/app.py +++ b/src/seedpass/tui_v3/app.py @@ -2,12 +2,14 @@ from __future__ import annotations import time from typing import Any, Callable +from constants import APP_DIR from textual.app import App, ComposeResult from textual.binding import Binding from textual.reactive import reactive from textual.screen import Screen -from textual.widgets import Header, Footer, Static +from textual.widgets import Button, Input, Label, Static from textual.containers import Horizontal, Vertical +from utils.fingerprint_manager import FingerprintManager from .widgets.header import RibbonHeader from .widgets.sidebar import SidebarContainer @@ -171,6 +173,507 @@ class MainScreen(Screen): yield ActionBar(id="action-bar") +class StartupScreen(Screen): + """Profile-selection and unlock screen for TUI v3 startup.""" + + BINDINGS = [ + Binding("enter", "submit", "Unlock", show=False), + Binding("escape", "app.quit", "Quit", show=False), + ] + + def __init__( + self, + *, + selected_fingerprint: str | None = None, + prompt: str | None = None, + ) -> None: + super().__init__() + self.selected_fingerprint = selected_fingerprint + self.prompt = prompt or "Select a seed profile and unlock to continue." + + def compose(self) -> ComposeResult: + with Vertical(id="startup-shell"): + yield Label("SeedPass TUI v3", id="startup-title") + yield Static(self.prompt, id="startup-prompt") + yield Static("", id="startup-profiles") + yield Input(placeholder="Profile number", id="startup-profile-choice") + yield Input( + placeholder="Master password", + password=True, + id="startup-password", + ) + with Horizontal(id="startup-actions"): + yield Button("Unlock", id="startup-unlock", variant="primary") + yield Button("Refresh", id="startup-refresh") + yield Button("Add New", id="startup-add") + yield Button("Recover", id="startup-recover") + yield Static("", id="startup-status") + + def on_mount(self) -> None: + self.refresh_profiles() + + def refresh_profiles(self) -> None: + profiles = self.app._list_boot_profiles() + lines = ["Available Seed Profiles:"] + if profiles: + for idx, profile in enumerate(profiles, start=1): + marker = ( + " *" if profile["fingerprint"] == self.selected_fingerprint else "" + ) + lines.append(f"{idx}. {profile['label']}{marker}") + else: + lines.append("No existing seed profiles found.") + lines.append(f"{len(profiles) + 1}. Add a new seed profile") + lines.append( + f"{len(profiles) + 2}. Recover existing profile with blank local index" + ) + lines.append("Q. Exit") + self.query_one("#startup-profiles", Static).update("\n".join(lines)) + profile_input = self.query_one("#startup-profile-choice", Input) + if self.selected_fingerprint: + for idx, profile in enumerate(profiles, start=1): + if profile["fingerprint"] == self.selected_fingerprint: + profile_input.value = str(idx) + break + elif len(profiles) == 1 and not profile_input.value: + profile_input.value = "1" + + def action_submit(self) -> None: + self._submit_unlock() + + def on_input_submitted(self, _event: Input.Submitted) -> None: + self._submit_unlock() + + def on_button_pressed(self, event: Button.Pressed) -> None: + button_id = event.button.id + if button_id == "startup-unlock": + self._submit_unlock() + return + if button_id == "startup-refresh": + self.refresh_profiles() + self._set_status("Profile list refreshed.") + return + if button_id == "startup-add": + self.app.push_screen(CreateProfileScreen()) + return + if button_id == "startup-recover": + self.app.push_screen(RecoverProfileScreen()) + return + + def _set_status(self, message: str) -> None: + self.query_one("#startup-status", Static).update(message) + + def _submit_unlock(self) -> None: + profiles = self.app._list_boot_profiles() + choice = self.query_one("#startup-profile-choice", Input).value.strip() + password = self.query_one("#startup-password", Input).value + if choice.lower() in {"q", "quit", "exit"}: + self.app.exit() + return + if not choice.isdigit(): + self._set_status("Enter the profile number from the list above.") + return + selected = int(choice) + add_idx = len(profiles) + 1 + recover_idx = len(profiles) + 2 + if selected == add_idx: + self.app.push_screen(CreateProfileScreen()) + return + if selected == recover_idx: + self.app.push_screen(RecoverProfileScreen()) + return + if not (1 <= selected <= len(profiles)): + self._set_status("Invalid selection.") + return + if not password: + self._set_status("Enter the master password for the selected profile.") + return + fingerprint = profiles[selected - 1]["fingerprint"] + self.app._bootstrap_profile_session(fingerprint, password) + + DEFAULT_CSS = """ + StartupScreen { + align: center middle; + background: #999999; + } + #startup-shell { + width: 90; + max-width: 90; + border: heavy black; + background: #000000; + color: #ffffff; + padding: 1 2; + } + #startup-title { + text-style: bold; + margin-bottom: 1; + } + #startup-prompt { + color: #cccccc; + margin-bottom: 1; + } + #startup-profiles { + border: solid #ffffff; + padding: 1; + margin-bottom: 1; + min-height: 10; + } + #startup-profile-choice, #startup-password { + margin-bottom: 1; + } + #startup-actions { + height: auto; + margin-bottom: 1; + } + #startup-actions Button { + margin-right: 1; + } + #startup-status { + min-height: 2; + color: #daf2e5; + } + """ + + +class CreateProfileScreen(Screen): + """Create a new profile or import an existing seed inside TUI v3.""" + + def compose(self) -> ComposeResult: + with Vertical(id="startup-shell"): + yield Label("Create Seed Profile", id="startup-title") + yield Static( + "Modes: existing, words, generate, nostr, backup. This screen now covers the main legacy onboarding branches inside v3.", + id="startup-prompt", + ) + yield Input( + placeholder="Mode: existing | words | generate | nostr | backup", + id="create-mode", + ) + yield Input( + placeholder="Seed phrase (paste full phrase; for 'words' you can type the 12 words space-separated)", + id="create-seed", + ) + yield Input( + placeholder="Backup path (required for backup mode)", + id="create-backup-path", + ) + yield Input( + placeholder="Continue without remote backup? yes | no (used for nostr mode)", + id="create-nostr-empty-ok", + ) + yield Input( + placeholder="Master password", + password=True, + id="create-password", + ) + with Horizontal(id="startup-actions"): + yield Button("Word Entry", id="create-words") + yield Button("Generate Seed", id="create-generate") + yield Button("Create Profile", id="create-submit", variant="primary") + yield Button("Back", id="create-back") + yield Static("", id="create-status") + + def on_mount(self) -> None: + self.query_one("#create-mode", Input).value = "existing" + self.query_one("#create-nostr-empty-ok", Input).value = "no" + + def on_button_pressed(self, event: Button.Pressed) -> None: + button_id = event.button.id + if button_id == "create-back": + self.app.pop_screen() + return + if button_id == "create-words": + self.app.push_screen(SeedWordsScreen(on_done=self._apply_seed_words)) + return + if button_id == "create-generate": + self._generate_seed() + return + if button_id == "create-submit": + self._submit() + + def on_input_submitted(self, _event: Input.Submitted) -> None: + self._submit() + + def _set_status(self, message: str) -> None: + self.query_one("#create-status", Static).update(message) + + def _apply_seed_words(self, seed_phrase: str) -> None: + self.query_one("#create-mode", Input).value = "words" + self.query_one("#create-seed", Input).value = seed_phrase + self._set_status("Seed phrase captured from word-by-word entry.") + + def _generate_seed(self) -> None: + try: + seed = self.app._generate_bootstrap_seed() + self.query_one("#create-mode", Input).value = "generate" + self.query_one("#create-seed", Input).value = seed + self._set_status( + "Generated new seed. Review it carefully and store it before creating the profile." + ) + except Exception as e: + self._set_status(f"Seed generation failed: {e}") + + def _submit(self) -> None: + mode = self.query_one("#create-mode", Input).value.strip().lower() or "existing" + seed = self.query_one("#create-seed", Input).value.strip() + backup_path = self.query_one("#create-backup-path", Input).value.strip() + continue_without_backup = self.query_one( + "#create-nostr-empty-ok", Input + ).value.strip().lower() in {"y", "yes", "true", "1"} + password = self.query_one("#create-password", Input).value + if not password: + self._set_status("Enter a master password.") + return + try: + if mode == "generate": + if not seed: + seed = self.app._generate_bootstrap_seed() + fingerprint = self.app._create_generated_profile( + password=password, seed=seed + ) + elif mode == "nostr": + if not seed: + self._set_status("Enter the seed phrase for Nostr restore.") + return + fingerprint = self.app._restore_from_nostr_profile( + seed=seed, + password=password, + continue_without_backup=continue_without_backup, + ) + elif mode == "backup": + if not seed: + self._set_status("Enter the seed phrase for backup restore.") + return + if not backup_path: + self._set_status("Enter the backup path for backup mode.") + return + fingerprint = self.app._restore_from_backup_profile( + seed=seed, + password=password, + backup_path=backup_path, + ) + else: + if not seed: + self._set_status("Enter the seed phrase to import.") + return + fingerprint = self.app._create_existing_profile( + seed=seed, + password=password, + ) + self.app._bootstrap_profile_session(fingerprint, password) + except Exception as e: + self._set_status(f"Profile creation failed: {e}") + + +class SeedWordsScreen(Screen): + """Guided word-by-word seed entry for onboarding parity.""" + + def __init__(self, *, on_done: Callable[[str], None]) -> None: + super().__init__() + self._on_done = on_done + from mnemonic import Mnemonic + + self._mnemonic = Mnemonic("english") + + def compose(self) -> ComposeResult: + with Vertical(id="startup-shell"): + yield Label("Enter Seed Words", id="startup-title") + yield Static( + "Enter the 12-word seed phrase one word per field, following the legacy flow.", + id="startup-prompt", + ) + yield Static("Progress: 0/12 words entered", id="seed-words-progress") + for idx in range(12): + yield Input(placeholder=f"Word {idx + 1}", id=f"seed-word-{idx + 1}") + with Horizontal(id="startup-actions"): + yield Button("Use Phrase", id="seed-words-submit", variant="primary") + yield Button("Back", id="seed-words-back") + yield Static("", id="seed-words-status") + + def on_button_pressed(self, event: Button.Pressed) -> None: + if event.button.id == "seed-words-back": + self.app.pop_screen() + return + if event.button.id == "seed-words-submit": + self._submit() + + def on_input_submitted(self, _event: Input.Submitted) -> None: + self._submit() + + def on_input_changed(self, event: Input.Changed) -> None: + input_id = event.input.id or "" + if not input_id.startswith("seed-word-"): + return + self._refresh_progress() + value = event.value.strip().lower() + if not value: + self.query_one("#seed-words-status", Static).update( + f"{input_id.replace('seed-word-', 'Word ')} is empty." + ) + return + if value not in self._mnemonic.wordlist: + self.query_one("#seed-words-status", Static).update( + f"{input_id.replace('seed-word-', 'Word ')} is not in the BIP-39 wordlist." + ) + return + self.query_one("#seed-words-status", Static).update( + f"{input_id.replace('seed-word-', 'Word ')} accepted." + ) + + def _refresh_progress(self) -> None: + count = 0 + invalid = 0 + for idx in range(12): + value = self.query_one(f"#seed-word-{idx + 1}", Input).value.strip().lower() + if not value: + continue + count += 1 + if value not in self._mnemonic.wordlist: + invalid += 1 + suffix = f" | invalid: {invalid}" if invalid else "" + self.query_one("#seed-words-progress", Static).update( + f"Progress: {count}/12 words entered{suffix}" + ) + + def _submit(self) -> None: + words: list[str] = [] + for idx in range(12): + value = self.query_one(f"#seed-word-{idx + 1}", Input).value.strip().lower() + if not value: + self.query_one("#seed-words-status", Static).update( + f"Word {idx + 1} is required." + ) + return + if value not in self._mnemonic.wordlist: + self.query_one("#seed-words-status", Static).update( + f"Word {idx + 1} is not in the BIP-39 wordlist." + ) + return + words.append(value) + phrase = " ".join(words) + if not self._mnemonic.check(phrase): + self.query_one("#seed-words-status", Static).update( + "The full 12-word phrase failed BIP-39 validation." + ) + return + self.app.push_screen( + SeedWordsReviewScreen( + phrase=phrase, + on_confirm=self._confirm_phrase, + ) + ) + + def _confirm_phrase(self, phrase: str) -> None: + self._on_done(phrase) + self.app.pop_screen() + self.app.pop_screen() + + +class SeedWordsReviewScreen(Screen): + """Confirmation screen for the assembled seed phrase.""" + + def __init__(self, *, phrase: str, on_confirm: Callable[[str], None]) -> None: + super().__init__() + self._phrase = phrase + self._on_confirm = on_confirm + + def compose(self) -> ComposeResult: + with Vertical(id="startup-shell"): + yield Label("Review Seed Phrase", id="startup-title") + yield Static( + "Confirm this phrase before it is applied to the create flow.", + id="startup-prompt", + ) + yield Static(self._phrase, id="seed-review-phrase") + with Horizontal(id="startup-actions"): + yield Button( + "Confirm Phrase", id="seed-review-confirm", variant="primary" + ) + yield Button("Edit Words", id="seed-review-back") + yield Static("", id="seed-review-status") + + def on_button_pressed(self, event: Button.Pressed) -> None: + if event.button.id == "seed-review-back": + self.app.pop_screen() + return + if event.button.id == "seed-review-confirm": + self._on_confirm(self._phrase) + + +class RecoverProfileScreen(Screen): + """Recover an existing profile with a blank local index inside TUI v3.""" + + def compose(self) -> ComposeResult: + with Vertical(id="startup-shell"): + yield Label("Recover Existing Profile", id="startup-title") + yield Static( + "This matches the legacy recovery path: select an existing profile, provide the matching seed, and reset the local index.", + id="startup-prompt", + ) + yield Static("", id="recover-profiles") + yield Input(placeholder="Profile number", id="recover-choice") + yield Input(placeholder="Seed phrase", id="recover-seed") + yield Input( + placeholder="Master password", + password=True, + id="recover-password", + ) + with Horizontal(id="startup-actions"): + yield Button("Recover Profile", id="recover-submit", variant="primary") + yield Button("Back", id="recover-back") + yield Static("", id="recover-status") + + def on_mount(self) -> None: + self.refresh_profiles() + + def refresh_profiles(self) -> None: + profiles = self.app._list_boot_profiles() + lines = ["Available Seed Profiles:"] + for idx, profile in enumerate(profiles, start=1): + lines.append(f"{idx}. {profile['label']}") + self.query_one("#recover-profiles", Static).update("\n".join(lines)) + + def on_button_pressed(self, event: Button.Pressed) -> None: + button_id = event.button.id + if button_id == "recover-back": + self.app.pop_screen() + return + if button_id == "recover-submit": + self._submit() + + def on_input_submitted(self, _event: Input.Submitted) -> None: + self._submit() + + def _set_status(self, message: str) -> None: + self.query_one("#recover-status", Static).update(message) + + def _submit(self) -> None: + profiles = self.app._list_boot_profiles() + choice = self.query_one("#recover-choice", Input).value.strip() + seed = self.query_one("#recover-seed", Input).value.strip() + password = self.query_one("#recover-password", Input).value + if not choice.isdigit(): + self._set_status("Enter the profile number to recover.") + return + idx = int(choice) + if not (1 <= idx <= len(profiles)): + self._set_status("Invalid profile selection.") + return + if not seed or not password: + self._set_status("Seed phrase and master password are required.") + return + fingerprint = profiles[idx - 1]["fingerprint"] + try: + self.app._recover_profile( + fingerprint=fingerprint, + seed=seed, + password=password, + ) + self.app._bootstrap_profile_session(fingerprint, password) + except Exception as e: + self._set_status(f"Recovery failed: {e}") + + class SeedPassTuiV3(App[None]): """ SeedPass TUI v3 - Rebuilt from scratch for modularity and mockup fidelity. @@ -256,21 +759,176 @@ class SeedPassTuiV3(App[None]): # Initialized services self.services: dict[str, Any] = {} self._initial_fingerprint = fingerprint + self._main_screen_initialized = False def on_mount(self) -> None: - """Initialize services and push the main screen.""" + """Initialize provided services or start in profile-unlock mode.""" self.processor = CommandProcessor(self) + self._initialize_factory_services() + if self.services: + self._enter_main_workspace(initial=True) + else: + self.present_startup_screen( + selected_fingerprint=self._initial_fingerprint, + prompt="Select a seed profile and unlock to enter SeedPass.", + ) + # Global UI Heartbeat (for 2FA ticking etc) + self.set_interval(1.0, self.action_refresh_ui_quiet) + + def _initialize_factory_services(self) -> None: for name, factory in self.factories.items(): if factory: try: - self.services[name] = factory() + service = factory() except Exception as e: self.log(f"Failed to init service {name}: {e}") + continue + if service is not None: + self.services[name] = service - self.active_fingerprint = self._initial_fingerprint or "" - self.push_screen(MainScreen()) - # Global UI Heartbeat (for 2FA ticking etc) - self.set_interval(1.0, self.action_refresh_ui_quiet) + def _list_boot_profiles(self) -> list[dict[str, str]]: + try: + manager = FingerprintManager(APP_DIR) + return [ + {"fingerprint": fp, "label": manager.display_name(fp)} + for fp in manager.list_fingerprints() + ] + except Exception as e: + self.log(f"Failed to list startup profiles: {e}") + return [] + + def present_startup_screen( + self, + *, + selected_fingerprint: str | None = None, + prompt: str | None = None, + ) -> None: + self.push_screen( + StartupScreen( + selected_fingerprint=selected_fingerprint, + prompt=prompt, + ) + ) + + def _bootstrap_profile_session(self, fingerprint: str, password: str) -> None: + try: + from seedpass.core.api import ( + ConfigService, + EntryService, + NostrService, + ProfileService, + SemanticIndexService, + SyncService, + UtilityService, + VaultService, + ) + from seedpass.core.manager import PasswordManager + + pm = PasswordManager(fingerprint=fingerprint, password=password) + self.services = { + "entry": EntryService(pm), + "profile": ProfileService(pm), + "config": ConfigService(pm), + "nostr": NostrService(pm), + "sync": SyncService(pm), + "utility": UtilityService(pm), + "vault": VaultService(pm), + "semantic": SemanticIndexService(pm), + } + self.active_fingerprint = pm.current_fingerprint or fingerprint + self.session_locked = False + self._enter_main_workspace(initial=not self._main_screen_initialized) + self.notify(f"Unlocked profile {self.active_fingerprint[:12]}") + except Exception as e: + try: + if isinstance(self.screen, StartupScreen): + self.screen._set_status(f"Unlock failed: {e}") + except Exception: + pass + self.notify(f"Unlock failed: {e}", severity="error") + + def _make_bootstrap_manager(self): + from seedpass.core.manager import PasswordManager + + return PasswordManager(bootstrap_only=True) + + def _generate_bootstrap_seed(self) -> str: + manager = self._make_bootstrap_manager() + return manager.generate_bip85_seed() + + def _create_existing_profile(self, *, seed: str, password: str) -> str: + manager = self._make_bootstrap_manager() + fingerprint = manager.setup_existing_seed(seed=seed, password=password) + if not fingerprint: + raise RuntimeError("Profile creation did not return a fingerprint.") + return fingerprint + + def _create_generated_profile( + self, *, password: str, seed: str | None = None + ) -> str: + manager = self._make_bootstrap_manager() + fingerprint, _generated_seed = manager.create_profile_from_generated_seed( + password=password, + seed=seed, + ) + return fingerprint + + def _recover_profile(self, *, fingerprint: str, seed: str, password: str) -> None: + manager = self._make_bootstrap_manager() + manager.recover_profile_with_blank_index_data( + fingerprint=fingerprint, + parent_seed=seed, + password=password, + ) + + def _restore_from_nostr_profile( + self, + *, + seed: str, + password: str, + continue_without_backup: bool, + ) -> str: + manager = self._make_bootstrap_manager() + fingerprint, _have_backup = manager.restore_from_nostr_with_guidance_data( + seed_phrase=seed, + password=password, + continue_without_backup=continue_without_backup, + ) + return fingerprint + + def _restore_from_backup_profile( + self, + *, + seed: str, + password: str, + backup_path: str, + ) -> str: + manager = self._make_bootstrap_manager() + return manager.restore_from_local_backup_data( + seed_phrase=seed, + password=password, + backup_path=backup_path, + ) + + def _resolve_active_fingerprint(self) -> str: + try: + manager = self.services["vault"]._manager + current = getattr(manager, "current_fingerprint", None) + if current: + return str(current) + except Exception: + pass + return self._initial_fingerprint or self.active_fingerprint or "" + + def _enter_main_workspace(self, *, initial: bool) -> None: + self.active_fingerprint = self._resolve_active_fingerprint() + if initial or not self._main_screen_initialized: + self.push_screen(MainScreen()) + self._main_screen_initialized = True + return + if isinstance(self.screen, StartupScreen): + self.pop_screen() + self.action_refresh() def on_command_palette_command_executed( self, message: CommandPalette.CommandExecuted @@ -338,6 +996,8 @@ class SeedPassTuiV3(App[None]): def action_refresh(self) -> None: """Force a global UI refresh.""" + if not self._main_screen_initialized: + return self.screen.query_one("#profile-tree")._refresh_tree() self.screen.query_one("#entry-data-table")._refresh_data() self.notify("UI Refreshed") @@ -421,6 +1081,10 @@ class SeedPassTuiV3(App[None]): locker() self.session_locked = True self.notify("Vault locked") + self.present_startup_screen( + selected_fingerprint=self.active_fingerprint or None, + prompt="Vault locked. Re-enter your master password to continue.", + ) except Exception as e: self.notify(f"Lock failed: {e}", severity="error") @@ -442,6 +1106,9 @@ class SeedPassTuiV3(App[None]): except Exception: unlocker(password) self.session_locked = False + if isinstance(self.screen, StartupScreen) and self._main_screen_initialized: + self.pop_screen() + self.action_refresh() self.notify("Vault unlocked") except Exception as e: self.notify(f"Unlock failed: {e}", severity="error") diff --git a/src/seedpass/tui_v3/widgets/sidebar.py b/src/seedpass/tui_v3/widgets/sidebar.py index 6bb11ad8..e3d7f044 100644 --- a/src/seedpass/tui_v3/widgets/sidebar.py +++ b/src/seedpass/tui_v3/widgets/sidebar.py @@ -70,8 +70,13 @@ class ProfileTree(Tree): self.app.notify(f"Opened Entry #{entry_id} from profile tree") return - self.app.active_fingerprint = value - self.app.notify(f"Switched to profile: {value[:8]}") + if value == self.app.active_fingerprint: + self.app.notify(f"Profile already active: {value[:8]}") + return + self.app.present_startup_screen( + selected_fingerprint=value, + prompt=f"Unlock {value[:12]} to switch profiles.", + ) class SidebarContainer(Static): diff --git a/src/tests/test_index0_core.py b/src/tests/test_index0_core.py new file mode 100644 index 00000000..7aad3ba9 --- /dev/null +++ b/src/tests/test_index0_core.py @@ -0,0 +1,130 @@ +from pathlib import Path +from tempfile import TemporaryDirectory + +from helpers import create_vault +from nostr.backup_models import ChunkMeta, Manifest +from seedpass.core.index0 import ( + compute_event_hash, + compute_event_id, + ensure_index0_payload, + merge_system_index0, + normalize_index0, +) + + +def test_ensure_index0_payload_initializes_reserved_namespace(): + payload = ensure_index0_payload({"schema_version": 4, "entries": {}}) + + assert payload["_system"]["index0"]["schema_version"] == 1 + assert payload["_system"]["index0"]["events"] == {} + assert payload["_system"]["index0"]["canonical_views"] == {} + assert payload["_system"]["index0"]["stats"]["event_count"] == 0 + + +def test_merge_system_index0_is_order_independent_for_events_and_heads(): + base_event = { + "event_type": "entry_modified", + "subject_type": "entry", + "subject_id": "1", + "subject_kind": "password", + "scope_path": "seed/root", + "actor_type": "user", + "actor_id": "fp1", + "writer_id": "writer:profile:fp1", + "modified_ts": 100, + "payload_ref": {"entry_id": "1"}, + "links": [], + "tags": ["b", "a"], + "visibility": "private", + "classification": "internal", + "partition": "standard", + } + event_a = dict(base_event) + event_a["integrity_hash"] = compute_event_hash(event_a) + event_a["event_id"] = compute_event_id(event_a) + event_b = dict(base_event) + event_b["modified_ts"] = 200 + event_b["prev_hash"] = "prev" + event_b["integrity_hash"] = compute_event_hash(event_b) + event_b["event_id"] = compute_event_id(event_b) + + current = normalize_index0( + { + "events": {event_a["event_id"]: event_a}, + "heads": { + "writer:profile:fp1": { + "event_id": event_a["event_id"], + "head_hash": "aaa", + "modified_ts": 100, + } + }, + } + ) + incoming = normalize_index0( + { + "events": {event_b["event_id"]: event_b}, + "heads": { + "writer:profile:fp1": { + "event_id": event_b["event_id"], + "head_hash": "bbb", + "modified_ts": 200, + } + }, + } + ) + + ab = merge_system_index0(current, incoming) + ba = merge_system_index0(incoming, current) + + assert ab == ba + assert ab["stats"]["event_count"] == 2 + assert ab["heads"]["writer:profile:fp1"]["head_hash"] == "bbb" + + +def test_vault_save_and_load_index_normalizes_index0_namespace(): + with TemporaryDirectory() as tmpdir: + base = Path(tmpdir) + vault, _ = create_vault(base) + vault.save_index({"schema_version": 4, "entries": {}}) + + loaded = vault.load_index() + + assert "_system" in loaded + assert "index0" in loaded["_system"] + assert loaded["_system"]["index0"]["stats"]["writer_count"] == 0 + + +def test_manifest_supports_optional_index0_metadata_round_trip(): + manifest = Manifest( + ver=1, + algo="gzip", + chunks=[ChunkMeta(id="c1", size=10, hash="abc", event_id="evt1")], + delta_since=123, + nonce="nonce", + index0={ + "schema_version": 1, + "checkpoint_ids": ["cp:day:2026-03-05:writer:profile:fp1"], + "checkpoint_hashes": {"cp:day:2026-03-05:writer:profile:fp1": "abc123"}, + "stream_heads": {"writer:profile:fp1": "head123"}, + }, + ) + payload = { + "ver": manifest.ver, + "algo": manifest.algo, + "chunks": [meta.__dict__ for meta in manifest.chunks], + "delta_since": manifest.delta_since, + "nonce": manifest.nonce, + "index0": manifest.index0, + } + + parsed = Manifest( + ver=payload["ver"], + algo=payload["algo"], + chunks=[ChunkMeta(**chunk) for chunk in payload["chunks"]], + delta_since=payload["delta_since"], + nonce=payload["nonce"], + index0=payload["index0"], + ) + + assert parsed.index0 is not None + assert parsed.index0["stream_heads"]["writer:profile:fp1"] == "head123" diff --git a/src/tests/test_tui_v3_smoke.py b/src/tests/test_tui_v3_smoke.py index 329121f1..a897e66c 100644 --- a/src/tests/test_tui_v3_smoke.py +++ b/src/tests/test_tui_v3_smoke.py @@ -5,9 +5,17 @@ from types import SimpleNamespace import pytest pytest.importorskip("textual") -from textual.widgets import DataTable +from textual.widgets import DataTable, Input from seedpass.tui_v3 import launch_tui3 +from seedpass.tui_v3.app import ( + CreateProfileScreen, + RecoverProfileScreen, + SeedPassTuiV3, + SeedWordsScreen, + SeedWordsReviewScreen, + StartupScreen, +) class V3EntryService: @@ -126,6 +134,7 @@ async def test_tui3_palette_lock_unlock_and_session_status() -> None: await pilot.pause() assert app.session_locked is True assert vault.locked is True + assert isinstance(app.screen, StartupScreen) app.processor.execute("unlock hunter2") await pilot.pause() assert app.session_locked is False @@ -169,3 +178,245 @@ async def test_tui3_grid_focus_refresh_enables_selection_after_runtime_add() -> table._refresh_data() await pilot.pause() assert table.row_count >= 4 + + +@pytest.mark.anyio +async def test_tui3_starts_with_unlock_screen_when_services_are_not_preloaded() -> None: + app = SeedPassTuiV3() + app._list_boot_profiles = lambda: [ + {"fingerprint": "EFBE51E70ED1B53A", "label": "Primary (EFBE51E70ED1B53A)"} + ] + captured: dict[str, str] = {} + + def _bootstrap(fingerprint: str, password: str) -> None: + captured["fingerprint"] = fingerprint + captured["password"] = password + + app._bootstrap_profile_session = _bootstrap + + async with app.run_test() as pilot: + await pilot.pause() + assert isinstance(app.screen, StartupScreen) + assert app.screen.query_one("#startup-profile-choice", Input).value == "1" + app.screen.query_one("#startup-password", Input).value = "hunter2" + await pilot.press("enter") + await pilot.pause() + assert captured == { + "fingerprint": "EFBE51E70ED1B53A", + "password": "hunter2", + } + + +@pytest.mark.anyio +async def test_tui3_create_profile_screen_uses_in_app_bootstrap_helpers() -> None: + app = SeedPassTuiV3() + app._list_boot_profiles = lambda: [] + created: dict[str, str] = {} + booted: dict[str, str] = {} + + def _create_existing_profile(*, seed: str, password: str) -> str: + created["seed"] = seed + created["password"] = password + return "NEWFP123" + + def _bootstrap(fingerprint: str, password: str) -> None: + booted["fingerprint"] = fingerprint + booted["password"] = password + + app._create_existing_profile = _create_existing_profile + app._bootstrap_profile_session = _bootstrap + + async with app.run_test() as pilot: + await pilot.pause() + app.push_screen(CreateProfileScreen()) + await pilot.pause() + screen = app.screen + screen.query_one("#create-mode", Input).value = "existing" + screen.query_one("#create-seed", Input).value = ( + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" + ) + screen.query_one("#create-password", Input).value = "hunter2" + await pilot.press("enter") + await pilot.pause() + assert created["password"] == "hunter2" + assert booted == {"fingerprint": "NEWFP123", "password": "hunter2"} + + +@pytest.mark.anyio +async def test_tui3_recover_profile_screen_uses_in_app_bootstrap_helpers() -> None: + app = SeedPassTuiV3() + app._list_boot_profiles = lambda: [ + {"fingerprint": "EFBE51E70ED1B53A", "label": "Primary (EFBE51E70ED1B53A)"} + ] + recovered: dict[str, str] = {} + booted: dict[str, str] = {} + + def _recover_profile(*, fingerprint: str, seed: str, password: str) -> None: + recovered["fingerprint"] = fingerprint + recovered["seed"] = seed + recovered["password"] = password + + def _bootstrap(fingerprint: str, password: str) -> None: + booted["fingerprint"] = fingerprint + booted["password"] = password + + app._recover_profile = _recover_profile + app._bootstrap_profile_session = _bootstrap + + async with app.run_test() as pilot: + await pilot.pause() + app.push_screen(RecoverProfileScreen()) + await pilot.pause() + screen = app.screen + screen.query_one("#recover-choice", Input).value = "1" + screen.query_one("#recover-seed", Input).value = ( + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" + ) + screen.query_one("#recover-password", Input).value = "hunter2" + await pilot.press("enter") + await pilot.pause() + assert recovered["fingerprint"] == "EFBE51E70ED1B53A" + assert booted == { + "fingerprint": "EFBE51E70ED1B53A", + "password": "hunter2", + } + + +@pytest.mark.anyio +async def test_tui3_create_profile_screen_supports_nostr_restore_mode() -> None: + app = SeedPassTuiV3() + app._list_boot_profiles = lambda: [] + restored: dict[str, object] = {} + booted: dict[str, str] = {} + + def _restore_from_nostr_profile( + *, seed: str, password: str, continue_without_backup: bool + ) -> str: + restored["seed"] = seed + restored["password"] = password + restored["continue_without_backup"] = continue_without_backup + return "NOSTRFP1" + + def _bootstrap(fingerprint: str, password: str) -> None: + booted["fingerprint"] = fingerprint + booted["password"] = password + + app._restore_from_nostr_profile = _restore_from_nostr_profile + app._bootstrap_profile_session = _bootstrap + + async with app.run_test() as pilot: + await pilot.pause() + app.push_screen(CreateProfileScreen()) + await pilot.pause() + screen = app.screen + screen.query_one("#create-mode", Input).value = "nostr" + screen.query_one("#create-seed", Input).value = ( + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" + ) + screen.query_one("#create-nostr-empty-ok", Input).value = "yes" + screen.query_one("#create-password", Input).value = "hunter2" + await pilot.press("enter") + await pilot.pause() + assert restored["continue_without_backup"] is True + assert booted == {"fingerprint": "NOSTRFP1", "password": "hunter2"} + + +@pytest.mark.anyio +async def test_tui3_create_profile_screen_supports_backup_restore_mode() -> None: + app = SeedPassTuiV3() + app._list_boot_profiles = lambda: [] + restored: dict[str, str] = {} + booted: dict[str, str] = {} + + def _restore_from_backup_profile( + *, seed: str, password: str, backup_path: str + ) -> str: + restored["seed"] = seed + restored["password"] = password + restored["backup_path"] = backup_path + return "BACKUPFP1" + + def _bootstrap(fingerprint: str, password: str) -> None: + booted["fingerprint"] = fingerprint + booted["password"] = password + + app._restore_from_backup_profile = _restore_from_backup_profile + app._bootstrap_profile_session = _bootstrap + + async with app.run_test() as pilot: + await pilot.pause() + app.push_screen(CreateProfileScreen()) + await pilot.pause() + screen = app.screen + screen.query_one("#create-mode", Input).value = "backup" + screen.query_one("#create-seed", Input).value = ( + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" + ) + screen.query_one("#create-backup-path", Input).value = "/tmp/profile-backup.enc" + screen.query_one("#create-password", Input).value = "hunter2" + await pilot.press("enter") + await pilot.pause() + assert restored["backup_path"] == "/tmp/profile-backup.enc" + assert booted == {"fingerprint": "BACKUPFP1", "password": "hunter2"} + + +@pytest.mark.anyio +async def test_tui3_word_by_word_seed_entry_feeds_create_profile_screen() -> None: + app = SeedPassTuiV3() + app._list_boot_profiles = lambda: [] + + async with app.run_test() as pilot: + await pilot.pause() + app.push_screen(CreateProfileScreen()) + await pilot.pause() + create_screen = app.screen + assert isinstance(create_screen, CreateProfileScreen) + create_screen.query_one("#create-words").press() + await pilot.pause() + assert isinstance(app.screen, SeedWordsScreen) + for idx, word in enumerate( + [ + "abandon", + "abandon", + "abandon", + "abandon", + "abandon", + "abandon", + "abandon", + "abandon", + "abandon", + "abandon", + "abandon", + "about", + ], + start=1, + ): + app.screen.query_one(f"#seed-word-{idx}", Input).value = word + await pilot.press("enter") + await pilot.pause() + assert isinstance(app.screen, SeedWordsReviewScreen) + app.screen.query_one("#seed-review-confirm").press() + await pilot.pause() + assert isinstance(app.screen, CreateProfileScreen) + assert app.screen.query_one("#create-mode", Input).value == "words" + assert ( + app.screen.query_one("#create-seed", Input).value + == "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" + ) + + +@pytest.mark.anyio +async def test_tui3_word_entry_flags_invalid_bip39_word() -> None: + app = SeedPassTuiV3() + app._list_boot_profiles = lambda: [] + + async with app.run_test() as pilot: + await pilot.pause() + app.push_screen(SeedWordsScreen(on_done=lambda _phrase: None)) + await pilot.pause() + app.screen.query_one("#seed-word-1", Input).value = "notaword" + await pilot.pause() + status = str(app.screen.query_one("#seed-words-status").render()) + progress = str(app.screen.query_one("#seed-words-progress").render()) + assert "not in the BIP-39 wordlist" in status + assert "invalid: 1" in progress diff --git a/src/tests/test_typer_cli.py b/src/tests/test_typer_cli.py index 931023ce..2f082613 100644 --- a/src/tests/test_typer_cli.py +++ b/src/tests/test_typer_cli.py @@ -684,6 +684,7 @@ def test_capabilities_json(): payload = json.loads(result.stdout) assert payload["schema_version"] == 1 assert "cli" in payload["interfaces"] + assert payload["interfaces"]["cli"]["default_interface"] == "tui3" assert "agent" in payload["interfaces"]["cli"]["root_commands"] assert "tui3" in payload["interfaces"]["cli"]["root_commands"] assert "legacy" in payload["interfaces"]["cli"]["root_commands"] @@ -1011,8 +1012,23 @@ def test_update_checksum_command(monkeypatch): assert called.get("called") is True -def test_root_tui2_forward_fingerprint(monkeypatch): - """Ensure --fingerprint is forwarded when launching default TUI v2.""" +def test_root_tui3_forward_fingerprint(monkeypatch): + """Ensure --fingerprint is forwarded when launching default TUI v3.""" + called = {} + + def fake_launch(**kwargs): + called["fp"] = kwargs.get("fingerprint") + called["factory"] = kwargs.get("entry_service_factory") + return True + + monkeypatch.setattr(cli, "launch_tui3", fake_launch) + result = runner.invoke(app, ["--fingerprint", "abc"]) + assert result.exit_code == 0 + assert called.get("fp") == "abc" + assert called.get("factory") is None + + +def test_root_explicit_tui2_forward_fingerprint(monkeypatch): called = {} def fake_launch(**kwargs): @@ -1022,7 +1038,7 @@ def test_root_tui2_forward_fingerprint(monkeypatch): monkeypatch.setattr(cli, "_get_entry_service", lambda _ctx: object()) monkeypatch.setattr(cli, "launch_tui2", fake_launch) - result = runner.invoke(app, ["--fingerprint", "abc"]) + result = runner.invoke(app, ["--tui", "v2", "--fingerprint", "abc"]) assert result.exit_code == 0 assert called.get("fp") == "abc" assert callable(called.get("factory")) @@ -1095,8 +1111,8 @@ def test_tui2_check(monkeypatch): def test_tui3_check(monkeypatch): monkeypatch.setattr( cli, - "check_tui2_runtime", - lambda: {"status": "ok", "backend": "textual", "textual_available": True}, + "check_tui3_runtime", + lambda: {"status": "ok", "backend": "textual-v3", "textual_available": True}, ) result = runner.invoke(app, ["tui3", "--check"]) assert result.exit_code == 0 @@ -1153,24 +1169,24 @@ def test_tui2_fallback_legacy_strips_subcommand_argv(monkeypatch): assert called.get("argv") == [] -def test_root_fallback_legacy_when_tui2_unavailable(monkeypatch): - called = {} - monkeypatch.setattr(cli, "_get_entry_service", lambda _ctx: object()) - monkeypatch.setattr(cli, "launch_tui2", lambda **_: False) +def test_root_tui3_unavailable_returns_error(monkeypatch): + monkeypatch.setattr(cli, "launch_tui3", lambda **_: False) + result = runner.invoke(app, ["--fingerprint", "abc"]) + assert result.exit_code == 1 + assert "TUI v3 runtime unavailable" in result.stderr + assert "seedpass tui2" in result.stderr + assert "seedpass legacy" in result.stderr - def fake_main(*, fingerprint=None): - called["fp"] = fingerprint - return 0 +def test_root_tui3_does_not_preflight_entry_service(monkeypatch): monkeypatch.setattr( cli, - "importlib", - SimpleNamespace(import_module=lambda _: SimpleNamespace(main=fake_main)), + "_get_entry_service", + lambda _ctx: (_ for _ in ()).throw(RuntimeError("should not run")), ) - result = runner.invoke(app, ["--fingerprint", "abc"]) + monkeypatch.setattr(cli, "launch_tui3", lambda **_: True) + result = runner.invoke(app, []) assert result.exit_code == 0 - assert called.get("fp") == "abc" - assert "falling back to legacy TUI" in result.stdout def test_tui2_preflight_failure_without_fallback(monkeypatch):