Make TUI v3 the default and add index0 foundations

This commit is contained in:
thePR0M3TH3AN committed 2026-03-05 21:52:39 -05:00
1 parent 0fa8aebc1b
commit e8a2b81e7b
17 files changed
+3005 -76

No files matched your search

+29
View File
@@ -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)
@@ -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/<root>/managed/<child>",
"actor_type": "user",
"actor_id": "<profile fingerprint or agent id>",
"writer_id": "<logical writer id>",
"modified_ts": 1760000000,
"prev_hash": "<optional prior hash in writer stream>",
"classification": "internal",
"partition": "standard",
"policy_ref": "<optional policy stamp or rule id>",
"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:<root>": "abc123",
"writer:<managed-user>": "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:<scope>`
2. `recent_activity:<scope>`
3. `top_tags:<scope>`
4. `counts_by_kind:<scope>`
5. `hot_nodes:<scope>`
6. `conversation_index:<scope>`
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:<root>": "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.
+620
View File
@@ -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/<root>/managed/<child>",
"actor_type": "user",
"actor_id": "<profile fingerprint or agent id>",
"writer_id": "writer:<scope-or-profile>",
"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": "<policy stamp or stable rule id>",
"source": "entry_management",
"source_event_id": "<nostr event id or external ref>",
"summary": "<short safe 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:<root>",
"window_type": "day",
"window_key": "2026-03-05",
"writer_id": "writer:<root>",
"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/<root>",
"view_type": "children_of",
"scope_path": "seed/<root>",
"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:<root>": {
"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:<root>"
],
"checkpoint_hashes": {
"cp:day:2026-03-05:writer:<root>": "sha256hex"
},
"stream_heads": {
"writer:<root>": "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:<fingerprint>`
- managed scope: `writer:profile:<fingerprint>:managed:<subject>`
- agent scope later if needed: `writer:agent:<agent_id>`
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.
+2 -1
View File
@@ -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
+5
View File
@@ -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 = (
+45 -42
View File
@@ -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")
+5 -3
View File
@@ -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 <group> --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`.",
+2
View File
@@ -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)
+437
View File
@@ -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
+134 -2
View File
@@ -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(
+14 -1
View File
@@ -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
+3
View File
@@ -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()
)
+674 -7
View File
@@ -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")
+7 -2
View File
@@ -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):
+130
View File
@@ -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"
+252 -1
View File
@@ -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
+33 -17
View File
@@ -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):