mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2026-10-31 09:52:03 +00:00
Make TUI v3 the default and add index0 foundations
This commit is contained in:
1 parent
0fa8aebc1b
commit
e8a2b81e7b
17 files changed
+3005
-76
No files matched your search
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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`.",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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")
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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"
|
||||
@@ -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
@@ -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):
|
||||
|
||||
Reference in new issue
Block a user