diff --git a/docs/superpowers/plans/2026-04-25-identity-neutral-export.md b/docs/superpowers/plans/2026-04-25-identity-neutral-export.md new file mode 100644 index 00000000..a5ac08e3 --- /dev/null +++ b/docs/superpowers/plans/2026-04-25-identity-neutral-export.md @@ -0,0 +1,315 @@ +# Identity-Neutral Export Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make `.krillnotes` export archives identity-neutral so any identity can import them as full owner. + +**Architecture:** Two surgical changes in `export.rs` — strip identity fields during export, stamp importer's identity during import. No other files change. + +**Tech Stack:** Rust, rusqlite, zip crate, serde_json, ed25519_dalek (for test signing keys) + +--- + +## File Structure + +| File | Action | Responsibility | +|------|--------|---------------| +| `krillnotes-core/src/core/export.rs` | Modify lines 217-224 and 260-267 | Strip identity from archive on export | +| `krillnotes-core/src/core/export.rs` | Modify lines 574-582 | Remove owner_pubkey restoration, stamp importer on notes | +| `krillnotes-core/src/core/export_tests.rs` | Add 2 new tests | Verify identity-neutral archive and round-trip identity | + +--- + +### Task 1: Strip identity fields during export + +**Files:** +- Modify: `krillnotes-core/src/core/export.rs:217-224` (notes identity strip) +- Modify: `krillnotes-core/src/core/export.rs:260-267` (workspace.json identity strip) + +- [ ] **Step 1: Write test — archive contains no identity data** + +Add this test to the end of `krillnotes-core/src/core/export_tests.rs`: + +```rust +#[test] +fn test_export_archive_is_identity_neutral() { + let temp = NamedTempFile::new().unwrap(); + let mut ws = Workspace::create( + temp.path(), + "", + "test-identity", + ed25519_dalek::SigningKey::from_bytes(&[1u8; 32]), + test_gate(), + None, + ) + .unwrap(); + + let root = ws.list_all_notes().unwrap()[0].clone(); + ws.create_note(&root.id, AddPosition::AsChild, "TextNote") + .unwrap(); + + let mut buf = Vec::new(); + export_workspace(&ws, Cursor::new(&mut buf), None).unwrap(); + + let mut archive = zip::ZipArchive::new(Cursor::new(&buf)).unwrap(); + + // workspace.json must not contain owner_pubkey + let ws_file = archive.by_name("workspace.json").unwrap(); + let ws_meta: WorkspaceMetadata = serde_json::from_reader(ws_file).unwrap(); + assert!( + ws_meta.owner_pubkey.is_none(), + "exported workspace.json must not contain owner_pubkey" + ); + + // notes.json must have empty created_by / modified_by + let notes_file = archive.by_name("notes.json").unwrap(); + let export_notes: ExportNotes = serde_json::from_reader(notes_file).unwrap(); + for note in &export_notes.notes { + assert!( + note.created_by.is_empty(), + "note '{}' created_by should be empty, got '{}'", + note.title, + note.created_by + ); + assert!( + note.modified_by.is_empty(), + "note '{}' modified_by should be empty, got '{}'", + note.title, + note.modified_by + ); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cargo test -p krillnotes-core test_export_archive_is_identity_neutral -- --nocapture` + +Expected: FAIL — `owner_pubkey` is `Some(...)` and `created_by`/`modified_by` are non-empty. + +- [ ] **Step 3: Strip identity fields in `export_workspace`** + +In `krillnotes-core/src/core/export.rs`, make two changes: + +**Change 1 — Clear note identity fields (lines 217-224).** Replace: + +```rust + // Write notes.json + let export_notes = ExportNotes { + version: 1, + app_version: APP_VERSION.to_string(), + notes, + }; +``` + +With: + +```rust + // Write notes.json — strip identity fields so the archive is identity-neutral + let notes = notes + .into_iter() + .map(|mut n| { + n.created_by = String::new(); + n.modified_by = String::new(); + n + }) + .collect(); + let export_notes = ExportNotes { + version: 1, + app_version: APP_VERSION.to_string(), + notes, + }; +``` + +**Change 2 — Omit owner_pubkey from workspace.json (line 265).** Replace: + +```rust + ws_meta.owner_pubkey = Some(workspace.owner_pubkey().to_string()); +``` + +With: + +```rust + ws_meta.owner_pubkey = None; +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cargo test -p krillnotes-core test_export_archive_is_identity_neutral -- --nocapture` + +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add krillnotes-core/src/core/export.rs krillnotes-core/src/core/export_tests.rs +git commit -m "feat: strip identity fields from export archive (#155)" +``` + +--- + +### Task 2: Stamp importer's identity on imported notes + +**Files:** +- Modify: `krillnotes-core/src/core/export.rs:574-582` (remove owner restoration, add identity stamp) + +- [ ] **Step 1: Write test — importer becomes owner and author of all notes** + +Add this test to the end of `krillnotes-core/src/core/export_tests.rs`: + +```rust +#[test] +fn test_import_stamps_importer_identity_on_notes() { + // Export from identity A + let temp_src = NamedTempFile::new().unwrap(); + let key_a = ed25519_dalek::SigningKey::from_bytes(&[1u8; 32]); + let mut ws_a = Workspace::create( + temp_src.path(), + "", + "identity-a", + key_a.clone(), + test_gate(), + None, + ) + .unwrap(); + + let root = ws_a.list_all_notes().unwrap()[0].clone(); + ws_a.create_note(&root.id, AddPosition::AsChild, "TextNote") + .unwrap(); + + let mut buf = Vec::new(); + export_workspace(&ws_a, Cursor::new(&mut buf), None).unwrap(); + + // Import as identity B (different key) + let temp_dst = NamedTempFile::new().unwrap(); + let key_b = ed25519_dalek::SigningKey::from_bytes(&[2u8; 32]); + import_workspace( + Cursor::new(&buf), + temp_dst.path(), + None, + "", + "identity-b", + key_b.clone(), + ) + .unwrap(); + + let ws_b = Workspace::open( + temp_dst.path(), + "", + "identity-b", + key_b, + test_gate(), + None, + ) + .unwrap(); + + // Importer is owner + assert!(ws_b.is_owner(), "importer should be workspace owner"); + + // All notes have importer's pubkey as created_by and modified_by + let importer_pubkey = ws_b.identity_pubkey().to_string(); + for note in ws_b.list_all_notes().unwrap() { + assert_eq!( + note.created_by, importer_pubkey, + "note '{}' created_by should be importer's pubkey", + note.title + ); + assert_eq!( + note.modified_by, importer_pubkey, + "note '{}' modified_by should be importer's pubkey", + note.title + ); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cargo test -p krillnotes-core test_import_stamps_importer_identity_on_notes -- --nocapture` + +Expected: FAIL — `created_by` and `modified_by` are empty strings (from export stripping), not the importer's pubkey. + +- [ ] **Step 3: Remove owner restoration and add identity stamp in `import_workspace`** + +In `krillnotes-core/src/core/export.rs`, replace lines 574-582: + +```rust + // Restore the original owner_pubkey from the archive, overriding the + // importer's key that Workspace::open() inserted. + if let Some(ref meta) = workspace_metadata { + if let Some(ref original_owner) = meta.owner_pubkey { + workspace + .set_owner_pubkey(original_owner) + .map_err(|e| ExportError::Database(e.to_string()))?; + } + } +``` + +With: + +```rust + // Stamp the importer's identity as author of all imported notes. + workspace + .connection() + .execute( + "UPDATE notes SET created_by = ?, modified_by = ?", + [workspace.identity_pubkey(), workspace.identity_pubkey()], + ) + .map_err(|e| ExportError::Database(e.to_string()))?; +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cargo test -p krillnotes-core test_import_stamps_importer_identity_on_notes -- --nocapture` + +Expected: PASS + +- [ ] **Step 5: Run full test suite to check for regressions** + +Run: `cargo test -p krillnotes-core` + +Expected: All tests pass. The existing `test_round_trip_export_import` test uses the same identity for export and import, so the stamped identity will match and existing assertions still hold. + +- [ ] **Step 6: Commit** + +```bash +git add krillnotes-core/src/core/export.rs krillnotes-core/src/core/export_tests.rs +git commit -m "feat: stamp importer identity on imported notes (#155)" +``` + +--- + +### Task 3: Verify and update existing tests + +**Files:** +- Modify: `krillnotes-core/src/core/export_tests.rs:310-329` (update `test_export_includes_workspace_json`) + +- [ ] **Step 1: Update existing workspace.json test** + +The existing `test_export_includes_workspace_json` (line 310) currently only checks `ws_meta.version == 1`. Add an assertion that `owner_pubkey` is `None`: + +After this line: +```rust + assert_eq!(ws_meta.version, 1); +``` + +Add: +```rust + assert!( + ws_meta.owner_pubkey.is_none(), + "exported workspace.json must not contain owner_pubkey" + ); +``` + +- [ ] **Step 2: Run full test suite** + +Run: `cargo test -p krillnotes-core` + +Expected: All tests pass. + +- [ ] **Step 3: Commit** + +```bash +git add krillnotes-core/src/core/export_tests.rs +git commit -m "test: assert identity-neutral workspace.json in existing export test (#155)" +``` diff --git a/docs/superpowers/specs/2026-04-25-identity-neutral-export-design.md b/docs/superpowers/specs/2026-04-25-identity-neutral-export-design.md new file mode 100644 index 00000000..d9b5ff75 --- /dev/null +++ b/docs/superpowers/specs/2026-04-25-identity-neutral-export-design.md @@ -0,0 +1,53 @@ +# Identity-Neutral Export + +**Issue:** [#155](https://github.com/2pisoftware/krillnotes/issues/155) — Exporting a shared workspace as owner creates unusable workspace + +**Date:** 2026-04-25 + +## Problem + +When an owner exports a shared workspace and a different identity imports it, the archive carries the original `owner_pubkey`. Import restores that key, so the importer is not recognized as root owner and cannot use the workspace. + +## Principle + +A `.krillnotes` archive is completely identity-neutral. It contains content, attachments, and scripts — nothing else. + +## Scope + +Export path only. Duplicate reuses export/import but the same identity owns both sides, so it works correctly today. + +## Changes + +### 1. Export (`export_workspace` in `export.rs`) + +- Set `ws_meta.owner_pubkey = None` before writing `workspace.json` (currently writes the owner's pubkey). +- Clear `created_by` and `modified_by` to `""` on each note before writing `notes.json`. + +After these changes the archive contains zero identity data. + +### 2. Import (`import_workspace` in `export.rs`) + +- Remove the `set_owner_pubkey` restoration block (lines 574-582). The importer's identity, set by `Workspace::open()`, naturally becomes root owner. +- After bulk-inserting notes, run `UPDATE notes SET created_by = ?, modified_by = ?` with `workspace.identity_pubkey()` so the importer is recorded as creator/modifier of all imported notes. + +### 3. Tests (`export_tests.rs`) + +- **Archive contents test:** Export a workspace with notes, read the zip, deserialize `workspace.json` and `notes.json`. Assert `owner_pubkey` is absent and all `created_by`/`modified_by` fields are empty strings. +- **Round-trip identity test:** Export workspace A (owned by identity X), import as workspace B (owned by identity Y). Assert `owner_pubkey` of B matches identity Y. Assert all notes in B have `created_by` and `modified_by` equal to identity Y's pubkey. + +## Identity Data Audit + +| Location | Field | Current | After fix | +|----------|-------|---------|-----------| +| `workspace.json` | `owner_pubkey` | Original owner's pubkey | `None` (omitted) | +| `notes.json` | `created_by` | Original author's pubkey | `""` (empty) | +| `notes.json` | `modified_by` | Last modifier's pubkey | `""` (empty) | +| `operations` table | (all columns) | N/A — already excluded from export | No change | +| `attachments` | (no identity fields) | Clean | No change | +| `user_scripts` | (no identity fields) | Clean | No change | + +## Non-Goals + +- Backward compatibility with old archives carrying `owner_pubkey` (no such archives exist without the bug). +- Changes to the duplicate workflow (works correctly today). +- Stripping `verified_by` (lives on the operations table, already excluded from export). diff --git a/krillnotes-core/src/core/export.rs b/krillnotes-core/src/core/export.rs index 46445ecd..a6dbbb55 100644 --- a/krillnotes-core/src/core/export.rs +++ b/krillnotes-core/src/core/export.rs @@ -214,7 +214,15 @@ pub fn export_workspace( None => SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated), }; - // Write notes.json + // Write notes.json — strip identity fields so the archive is identity-neutral + let notes = notes + .into_iter() + .map(|mut n| { + n.created_by = String::new(); + n.modified_by = String::new(); + n + }) + .collect(); let export_notes = ExportNotes { version: 1, app_version: APP_VERSION.to_string(), @@ -262,7 +270,7 @@ pub fn export_workspace( .get_workspace_metadata() .map_err(|e| ExportError::Database(e.to_string()))?; ws_meta.version = 1; - ws_meta.owner_pubkey = Some(workspace.owner_pubkey().to_string()); + ws_meta.owner_pubkey = None; zip.start_file("workspace.json", options)?; serde_json::to_writer_pretty(&mut zip, &ws_meta)?; @@ -571,15 +579,14 @@ pub fn import_workspace( .map_err(|e| ExportError::Database(e.to_string()))?; } - // Restore the original owner_pubkey from the archive, overriding the - // importer's key that Workspace::open() inserted. - if let Some(ref meta) = workspace_metadata { - if let Some(ref original_owner) = meta.owner_pubkey { - workspace - .set_owner_pubkey(original_owner) - .map_err(|e| ExportError::Database(e.to_string()))?; - } - } + // Stamp the importer's identity as author of all imported notes. + workspace + .connection() + .execute( + "UPDATE notes SET created_by = ?, modified_by = ?", + [workspace.identity_pubkey(), workspace.identity_pubkey()], + ) + .map_err(|e| ExportError::Database(e.to_string()))?; Ok(ImportResult { app_version: export_notes.app_version, diff --git a/krillnotes-core/src/core/export_tests.rs b/krillnotes-core/src/core/export_tests.rs index c1f1ca75..e792bdcf 100644 --- a/krillnotes-core/src/core/export_tests.rs +++ b/krillnotes-core/src/core/export_tests.rs @@ -243,6 +243,55 @@ fn test_round_trip_export_import() { assert!(widget.source_code.contains("@name: Custom Widget")); } +#[test] +fn test_export_archive_is_identity_neutral() { + let temp = NamedTempFile::new().unwrap(); + let mut ws = Workspace::create( + temp.path(), + "", + "test-identity", + ed25519_dalek::SigningKey::from_bytes(&[1u8; 32]), + test_gate(), + None, + ) + .unwrap(); + + let root = ws.list_all_notes().unwrap()[0].clone(); + ws.create_note(&root.id, AddPosition::AsChild, "TextNote") + .unwrap(); + + let mut buf = Vec::new(); + export_workspace(&ws, Cursor::new(&mut buf), None).unwrap(); + + let mut archive = zip::ZipArchive::new(Cursor::new(&buf)).unwrap(); + + // workspace.json must not contain owner_pubkey + let ws_file = archive.by_name("workspace.json").unwrap(); + let ws_meta: WorkspaceMetadata = serde_json::from_reader(ws_file).unwrap(); + assert!( + ws_meta.owner_pubkey.is_none(), + "exported workspace.json must not contain owner_pubkey" + ); + + // notes.json must have empty created_by / modified_by + let notes_file = archive.by_name("notes.json").unwrap(); + let export_notes: ExportNotes = serde_json::from_reader(notes_file).unwrap(); + for note in &export_notes.notes { + assert!( + note.created_by.is_empty(), + "note '{}' created_by should be empty, got '{}'", + note.title, + note.created_by + ); + assert!( + note.modified_by.is_empty(), + "note '{}' modified_by should be empty, got '{}'", + note.title, + note.modified_by + ); + } +} + #[test] fn test_round_trip_preserves_script_category() { // Regression test: imported scripts must retain their original category. @@ -326,6 +375,10 @@ fn test_export_includes_workspace_json() { let ws_file = archive.by_name("workspace.json").unwrap(); let ws_meta: WorkspaceMetadata = serde_json::from_reader(ws_file).unwrap(); assert_eq!(ws_meta.version, 1); + assert!( + ws_meta.owner_pubkey.is_none(), + "exported workspace.json must not contain owner_pubkey" + ); } #[test] @@ -548,6 +601,70 @@ fn test_peek_import_with_wrong_password_returns_invalid_password() { assert!(matches!(err, ExportError::InvalidPassword), "got: {err:?}"); } +#[test] +fn test_import_stamps_importer_identity_on_notes() { + // Export from identity A + let temp_src = NamedTempFile::new().unwrap(); + let key_a = ed25519_dalek::SigningKey::from_bytes(&[1u8; 32]); + let mut ws_a = Workspace::create( + temp_src.path(), + "", + "identity-a", + key_a.clone(), + test_gate(), + None, + ) + .unwrap(); + + let root = ws_a.list_all_notes().unwrap()[0].clone(); + ws_a.create_note(&root.id, AddPosition::AsChild, "TextNote") + .unwrap(); + + let mut buf = Vec::new(); + export_workspace(&ws_a, Cursor::new(&mut buf), None).unwrap(); + + // Import as identity B (different key) + let temp_dst = NamedTempFile::new().unwrap(); + let key_b = ed25519_dalek::SigningKey::from_bytes(&[2u8; 32]); + import_workspace( + Cursor::new(&buf), + temp_dst.path(), + None, + "", + "identity-b", + key_b.clone(), + ) + .unwrap(); + + let ws_b = Workspace::open( + temp_dst.path(), + "", + "identity-b", + key_b, + test_gate(), + None, + ) + .unwrap(); + + // Importer is owner + assert!(ws_b.is_owner(), "importer should be workspace owner"); + + // All notes have importer's pubkey as created_by and modified_by + let importer_pubkey = ws_b.identity_pubkey().to_string(); + for note in ws_b.list_all_notes().unwrap() { + assert_eq!( + note.created_by, importer_pubkey, + "note '{}' created_by should be importer's pubkey", + note.title + ); + assert_eq!( + note.modified_by, importer_pubkey, + "note '{}' modified_by should be importer's pubkey", + note.title + ); + } +} + #[test] fn test_encrypted_round_trip_import() { let temp_src = NamedTempFile::new().unwrap(); @@ -989,14 +1106,9 @@ fn test_peek_import_returns_none_metadata_for_old_archives() { } #[test] -fn test_m7_import_preserves_original_owner_pubkey() { +fn test_import_makes_importer_the_owner() { // Create original workspace with key A let key_a = ed25519_dalek::SigningKey::from_bytes(&[1u8; 32]); - let pubkey_a = { - use base64::Engine as _; - let vk = ed25519_dalek::VerifyingKey::from(&key_a); - base64::engine::general_purpose::STANDARD.encode(vk.as_bytes()) - }; let temp_src = NamedTempFile::new().unwrap(); let ws = Workspace::create( temp_src.path(), @@ -1007,9 +1119,8 @@ fn test_m7_import_preserves_original_owner_pubkey() { None, ) .unwrap(); - assert_eq!(ws.owner_pubkey(), pubkey_a); - // Export + // Export (Task 1 strips owner_pubkey from archive) let mut buf = Vec::new(); export_workspace(&ws, Cursor::new(&mut buf), None).unwrap(); @@ -1031,13 +1142,13 @@ fn test_m7_import_preserves_original_owner_pubkey() { ) .unwrap(); - // Re-open and verify that owner is still key A, NOT key B + // Re-open and verify that importer (key B) is now the owner let imported_ws = Workspace::open(temp_dst.path(), "", "identity-b", key_b, test_gate(), None).unwrap(); assert_eq!( imported_ws.owner_pubkey(), - pubkey_a, - "imported workspace must preserve original owner, not importer" + pubkey_b, + "importer should become workspace owner after import" ); - assert_ne!(imported_ws.owner_pubkey(), pubkey_b); + assert!(imported_ws.is_owner(), "importer should be recognized as owner"); }