diff --git a/docs/superpowers/plans/2026-06-20-v0.9.0-keyfile-core-cli.md b/docs/superpowers/plans/2026-06-20-v0.9.0-keyfile-core-cli.md new file mode 100644 index 0000000..8a6e0ba --- /dev/null +++ b/docs/superpowers/plans/2026-06-20-v0.9.0-keyfile-core-cli.md @@ -0,0 +1,397 @@ +# Key-File Second Factor — Core + CLI 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 the vault's 256-bit second factor pluggable — carryable by a plain key file as well as the stego image — at the core and CLI layers, chosen at `init` and recorded by a non-secret params hint. + +**Architecture:** The second factor is already just 32 bytes (`image_secret`); stego is only the transport. We add a key-file armor format and a raw-secret unlock path in `relicario-core`, surface both over `relicario-wasm`, and branch the CLI's factor resolution on a new non-secret `second_factor` field in `params.json`. The Argon2id KDF and AEAD are byte-for-byte unchanged — only the *source* of the 32 bytes differs. + +**Tech Stack:** Rust (relicario-core, relicario-cli, relicario-wasm), wasm-bindgen, clap. + +## Global Constraints + +- Release target: v0.9.0. +- No new crypto primitive: reuse `derive_master_key` (`crates/relicario-core/src/crypto.rs:207`). The key-file path must derive the *same* master key as the stego path for the same 32-byte secret (equivalence is the security argument). +- `params.json` `second_factor` is **non-secret** and defaults to `image` when absent (every existing vault keeps working). +- The key file (`.relkey`) holds the secret in the clear — it is the "something you have," protected by needing the passphrase too. Same posture as the reference JPEG. Do not imply it is encrypted. +- Rust tests use fast Argon2id params (m=256, t=1, p=1). +- Capitalize "Relicario" in prose; the binary/command stays lowercase `relicario`. + +--- + +## File Structure + +- `crates/relicario-core/src/crypto.rs` — add `SecondFactor` enum + `second_factor` field on `KdfParams` (the struct serialized to `params.json`); confirm the 1:1 mapping first. +- `crates/relicario-core/src/keyfile.rs` *(new)* — `keyfile_encode` / `keyfile_decode` (armor format, `Zeroizing`). +- `crates/relicario-core/src/lib.rs` — `pub mod keyfile;` re-export. +- `crates/relicario-wasm/src/lib.rs` — `#[wasm_bindgen]` `keyfile_encode`, `keyfile_decode`, `unlock_with_secret`. +- `extension/src/wasm.d.ts` — declare the three (consumed by Plan 5). +- `crates/relicario-cli/src/session.rs` — `get_keyfile_path()`; branch `unlock_interactive` on the params hint. +- `crates/relicario-cli/src/commands/init.rs` (or wherever `init` lives — locate first) — `--key-file` path. +- `crates/relicario-cli/src/main.rs` — clap `--key-file` flag on `init` (and `RELICARIO_KEYFILE` doc in help). +- Tests: core unit tests in `keyfile.rs` + `crypto.rs`; `crates/relicario-wasm` equivalence test; `crates/relicario-cli/tests/keyfile_flows.rs` *(new)*. + +--- + +### Task 1: `SecondFactor` hint in `KdfParams` + +**Files:** +- Modify: `crates/relicario-core/src/crypto.rs:157` (`KdfParams`) +- Test: `crates/relicario-core/src/crypto.rs` (`#[cfg(test)]`) + +**Interfaces:** +- Produces: `pub enum SecondFactor { Image, Keyfile }` (serde `rename_all = "lowercase"`); `KdfParams.second_factor: SecondFactor` with `#[serde(default)]` (default `Image`). + +- [ ] **Step 1: Confirm `KdfParams` ⟷ `params.json` is 1:1.** Grep where `params.json` is written/read in `crates/relicario-cli/src` and `backup.rs`; confirm it serializes `KdfParams`. If a wrapper struct is used instead, put `second_factor` there. Note the finding in a comment. + +- [ ] **Step 2: Write the failing test** + +```rust +#[test] +fn params_default_second_factor_is_image_and_is_backcompat() { + // Old params.json (no second_factor) must deserialize as Image. + let old = r#"{"argon2_m":256,"argon2_t":1,"argon2_p":1}"#; + let p: KdfParams = serde_json::from_str(old).unwrap(); + assert_eq!(p.second_factor, SecondFactor::Image); + // New keyfile params round-trip. + let kf = r#"{"argon2_m":256,"argon2_t":1,"argon2_p":1,"second_factor":"keyfile"}"#; + let p2: KdfParams = serde_json::from_str(kf).unwrap(); + assert_eq!(p2.second_factor, SecondFactor::Keyfile); +} +``` + +- [ ] **Step 3: Run to verify it fails** + +Run: `cargo test -p relicario-core params_default_second_factor` +Expected: FAIL — `SecondFactor` not found. + +- [ ] **Step 4: Implement** + +```rust +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum SecondFactor { + #[default] + Image, + Keyfile, +} +// in KdfParams: +#[serde(default)] +pub second_factor: SecondFactor, +``` + +- [ ] **Step 5: Run to verify it passes** + +Run: `cargo test -p relicario-core params_default_second_factor` then `cargo test -p relicario-core` (no regressions; check `format_v2`/backup tests still pass since params.json gained an optional field). +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add crates/relicario-core/src/crypto.rs +git commit -m "feat(core): SecondFactor hint on KdfParams (default image, back-compat)" +``` + +--- + +### Task 2: Key-file armor (`keyfile_encode` / `keyfile_decode`) + +**Files:** +- Create: `crates/relicario-core/src/keyfile.rs` +- Modify: `crates/relicario-core/src/lib.rs` (`pub mod keyfile;`) +- Test: `crates/relicario-core/src/keyfile.rs` (`#[cfg(test)]`) + +**Interfaces:** +- Produces: `pub fn keyfile_encode(secret: &[u8; 32]) -> Vec`; `pub fn keyfile_decode(bytes: &[u8]) -> Result>`. Format: literal line `relicario-keyfile-v1\n`, then base64 (standard, no-pad-agnostic) of the 32 bytes, then `\n`. + +- [ ] **Step 1: Write the failing test** + +```rust +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn round_trip() { + let secret = [9u8; 32]; + let armored = keyfile_encode(&secret); + assert!(std::str::from_utf8(&armored).unwrap().starts_with("relicario-keyfile-v1\n")); + let back = keyfile_decode(&armored).unwrap(); + assert_eq!(*back, secret); + } + #[test] + fn rejects_bad_header() { + assert!(keyfile_decode(b"not-a-keyfile\nAAAA\n").is_err()); + } + #[test] + fn rejects_wrong_length() { + assert!(keyfile_decode(b"relicario-keyfile-v1\nAAAA\n").is_err()); // decodes to <32 bytes + } +} +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `cargo test -p relicario-core keyfile` +Expected: FAIL — module/functions not found. + +- [ ] **Step 3: Implement `keyfile.rs`** + +```rust +//! Key-file armor for the pluggable second factor. The file holds the raw +//! 32-byte secret (base64) behind a version header — it is the "something +//! you have", not an encrypted artifact (the passphrase is the other factor). +use base64::{engine::general_purpose::STANDARD, Engine}; +use zeroize::Zeroizing; +use crate::error::{RelicarioError, Result}; + +const HEADER: &str = "relicario-keyfile-v1"; + +pub fn keyfile_encode(secret: &[u8; 32]) -> Vec { + format!("{HEADER}\n{}\n", STANDARD.encode(secret)).into_bytes() +} + +pub fn keyfile_decode(bytes: &[u8]) -> Result> { + let text = std::str::from_utf8(bytes) + .map_err(|_| RelicarioError::InvalidFormat("key file is not UTF-8".into()))?; + let mut lines = text.lines(); + if lines.next() != Some(HEADER) { + return Err(RelicarioError::InvalidFormat("bad key-file header".into())); + } + let b64 = lines.next().unwrap_or("").trim(); + let decoded = STANDARD.decode(b64) + .map_err(|_| RelicarioError::InvalidFormat("key-file body not base64".into()))?; + let arr: [u8; 32] = decoded.as_slice().try_into() + .map_err(|_| RelicarioError::InvalidFormat("key-file secret must be 32 bytes".into()))?; + Ok(Zeroizing::new(arr)) +} +``` + +(Match the real `RelicarioError` variant — use the existing invalid-format/parse variant; `grep RelicarioError crates/relicario-core/src/error.rs` first.) + +- [ ] **Step 4: Run to verify it passes** + +Run: `cargo test -p relicario-core keyfile` +Expected: PASS (all three). + +- [ ] **Step 5: Commit** + +```bash +git add crates/relicario-core/src/keyfile.rs crates/relicario-core/src/lib.rs +git commit -m "feat(core): key-file armor (relicario-keyfile-v1) encode/decode" +``` + +--- + +### Task 3: WASM bindings + master-key equivalence + +**Files:** +- Modify: `crates/relicario-wasm/src/lib.rs` +- Modify: `extension/src/wasm.d.ts` +- Test: `crates/relicario-wasm/src/lib.rs` (`#[cfg(test)]`) + +**Interfaces:** +- Produces (consumed by Plan 5): `keyfile_encode(secret: Uint8Array): Uint8Array`; `keyfile_decode(bytes: Uint8Array): Uint8Array`; `unlock_with_secret(passphrase: string, secret: Uint8Array, salt: Uint8Array, params_json: string): SessionHandle`. + +- [ ] **Step 1: Write the failing test** (equivalence: same secret ⇒ same master key, proven by cross-decrypt) + +```rust +#[test] +fn unlock_with_secret_matches_unlock_from_jpeg() { + let secret = [3u8; 32]; + let salt = [1u8; 32]; + let params = r#"{"argon2_m":256,"argon2_t":1,"argon2_p":1}"#; + let jpeg = relicario_core::imgsecret::embed(&make_test_jpeg(), &secret).unwrap(); + + let h_img = unlock(/*passphrase*/ "pw", &jpeg, &salt, params).unwrap(); + let h_key = unlock_with_secret("pw", &secret, &salt, params).unwrap(); + + // Same key ⇒ a blob encrypted under one handle decrypts under the other. + let ct = item_encrypt(&h_img, r#"{"id":"a","core":{"type":"SecureNote","body":"z"}}"#).unwrap(); + let pt = item_decrypt(&h_key, &ct).unwrap(); + assert!(format!("{pt:?}").contains("SecureNote")); +} +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `cargo test -p relicario-wasm unlock_with_secret` +Expected: FAIL — `unlock_with_secret` not found. + +- [ ] **Step 3: Implement** (mirror `unlock` at `lib.rs:49`, skipping `imgsecret::extract`) + +```rust +#[wasm_bindgen] +pub fn unlock_with_secret( + passphrase: &str, + secret: &[u8], + salt: &[u8], + params_json: &str, +) -> Result { + let params: KdfParams = serde_json::from_str(params_json) + .map_err(|e| JsError::new(&format!("params: {e}")))?; + let secret_arr: &[u8; 32] = secret.try_into() + .map_err(|_| JsError::new("secret must be exactly 32 bytes"))?; + let salt_arr: &[u8; 32] = salt.try_into() + .map_err(|_| JsError::new("salt must be exactly 32 bytes"))?; + let master_key = derive_master_key(passphrase.as_bytes(), secret_arr, salt_arr, ¶ms) + .map_err(|e| JsError::new(&e.to_string()))?; + let handle = session::insert(master_key, Zeroizing::new(*secret_arr)); + Ok(SessionHandle(handle)) +} + +#[wasm_bindgen] +pub fn keyfile_encode(secret: &[u8]) -> Result, JsError> { + let arr: &[u8; 32] = secret.try_into().map_err(|_| JsError::new("secret must be 32 bytes"))?; + Ok(relicario_core::keyfile::keyfile_encode(arr)) +} + +#[wasm_bindgen] +pub fn keyfile_decode(bytes: &[u8]) -> Result, JsError> { + let s = relicario_core::keyfile::keyfile_decode(bytes).map_err(|e| JsError::new(&e.to_string()))?; + Ok(s.to_vec()) +} +``` + +- [ ] **Step 4: Run to verify it passes** + +Run: `cargo test -p relicario-wasm` +Expected: PASS. + +- [ ] **Step 5: Declare in `wasm.d.ts` + build** + +Add to `extension/src/wasm.d.ts`: +```ts +export function keyfile_encode(secret: Uint8Array): Uint8Array; +export function keyfile_decode(bytes: Uint8Array): Uint8Array; +export function unlock_with_secret(passphrase: string, secret: Uint8Array, salt: Uint8Array, params_json: string): SessionHandle; +``` +Run: `cargo build -p relicario-wasm --target wasm32-unknown-unknown` + the project wasm-pack step. + +- [ ] **Step 6: Commit** + +```bash +git add crates/relicario-wasm/src/lib.rs extension/src/wasm.d.ts +git commit -m "feat(wasm): unlock_with_secret + keyfile encode/decode bindings" +``` + +--- + +### Task 4: CLI unlock branches on the params hint + +**Files:** +- Modify: `crates/relicario-cli/src/session.rs` (`get_keyfile_path`, `unlock_interactive`) +- Test: `crates/relicario-cli/tests/keyfile_flows.rs` *(new)* + +**Interfaces:** +- Consumes: `keyfile_decode`, `derive_master_key`, `KdfParams.second_factor`. +- Produces: `pub fn get_keyfile_path() -> Result` (mirrors `get_image_path` at `session.rs:165`: `RELICARIO_KEYFILE` env → `/vault.relkey` convention → interactive prompt). `unlock_interactive` reads `second_factor` from params and resolves the image OR the key file accordingly. + +- [ ] **Step 1: Write the failing integration test** + +```rust +#[test] +fn init_keyfile_then_unlock_keyfile_round_trips() { + let dir = tempfile::tempdir().unwrap(); + // init with a key file (Task 5 wires the flag; here drive it once that lands) + relicario(&dir).args(["init", "--key-file", "vault.relkey"]).env("RELICARIO_PASSPHRASE","correct horse").assert().success(); + // unlock + add + get using the key file + relicario(&dir) + .args(["add", "login", "--title", "gh", "--username", "u", "--password", "p"]) + .env("RELICARIO_PASSPHRASE","correct horse") + .env("RELICARIO_KEYFILE", dir.path().join("vault.relkey")) + .assert().success(); + relicario(&dir).args(["get","gh","--show"]) + .env("RELICARIO_PASSPHRASE","correct horse") + .env("RELICARIO_KEYFILE", dir.path().join("vault.relkey")) + .assert().success().stdout(predicates::str::contains("u")); +} +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `cargo test -p relicario-cli --test keyfile_flows` +Expected: FAIL — `--key-file` unknown / `RELICARIO_KEYFILE` ignored. + +- [ ] **Step 3: Implement `get_keyfile_path` + the unlock branch** + +```rust +pub fn get_keyfile_path() -> Result { + if let Ok(path) = std::env::var("RELICARIO_KEYFILE") { return Ok(PathBuf::from(path)); } + if let Some(root) = find_vault_root() { // mirror get_image_path's convention block + let default = root.join("vault.relkey"); + if default.exists() { return Ok(default); } + } + let trimmed = prompt("key file path: ")?; + if trimmed.is_empty() { bail!("no key file path provided"); } + Ok(PathBuf::from(trimmed)) +} +``` + +In `unlock_interactive` (`session.rs:33`): after loading `params`, branch — `SecondFactor::Image` keeps today's `get_image_path` + `imgsecret::extract`; `SecondFactor::Keyfile` does `keyfile_decode(fs::read(get_keyfile_path()?)?)` → `derive_master_key(passphrase, &secret, &salt, ¶ms)`. Map a missing/garbled key file to a clear `invalid_key_file` error distinct from the wrong-passphrase AEAD failure. + +- [ ] **Step 4: Run to verify it passes** (after Task 5 wires `init --key-file`; if executing in order, mark this test `#[ignore]` until Task 5, then un-ignore) + +Run: `cargo test -p relicario-cli --test keyfile_flows` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add crates/relicario-cli/src/session.rs crates/relicario-cli/tests/keyfile_flows.rs +git commit -m "feat(cli): unlock resolves second factor from params hint (image|keyfile)" +``` + +--- + +### Task 5: CLI `init --key-file` + +**Files:** +- Modify: the `init` command handler (locate: `grep -rn 'Init\|fn.*init' crates/relicario-cli/src/`), `crates/relicario-cli/src/main.rs` (clap flag) +- Test: `crates/relicario-cli/tests/keyfile_flows.rs` (un-ignore Task 4's test) + +**Interfaces:** +- Consumes: `keyfile_encode`; `KdfParams { second_factor: Keyfile, .. }`. +- Produces: `relicario init --key-file ` — generates the 32-byte secret with `OsRng`, writes `keyfile_encode(secret)` to ``, derives the master key from passphrase+secret, and writes `params.json` with `second_factor: "keyfile"`. The existing `--image`/`--output` path stays the default and writes `second_factor: "image"`. + +- [ ] **Step 1: Write the failing test** — un-ignore `init_keyfile_then_unlock_keyfile_round_trips` and add: + +```rust +#[test] +fn init_keyfile_writes_relkey_and_keyfile_params() { + let dir = tempfile::tempdir().unwrap(); + relicario(&dir).args(["init","--key-file","vault.relkey"]).env("RELICARIO_PASSPHRASE","correct horse").assert().success(); + assert!(dir.path().join("vault.relkey").exists()); + let params = std::fs::read_to_string(dir.path().join(".relicario/params.json")).unwrap(); + assert!(params.contains("\"second_factor\":\"keyfile\"")); +} +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `cargo test -p relicario-cli --test keyfile_flows init_keyfile_writes` +Expected: FAIL — `--key-file` not a known arg. + +- [ ] **Step 3: Implement** — add `#[arg(long, conflicts_with = "image")] key_file: Option` to the init args in `main.rs`; in the init handler, when `key_file` is set: `let secret: [u8;32] = OsRng.gen();` → `fs::write(path, keyfile_encode(&secret))` → derive master key from `&secret` → set `KdfParams { second_factor: SecondFactor::Keyfile, ..default }` before writing `params.json`. Reuse the existing init crypto/write path otherwise. + +- [ ] **Step 4: Run to verify it passes** + +Run: `cargo test -p relicario-cli --test keyfile_flows` +Expected: PASS (both tests). + +- [ ] **Step 5: Full suite + commit** + +Run: `cargo test` (workspace) — confirm no personal-vault init/unlock regressions. + +```bash +git add crates/relicario-cli/src/main.rs crates/relicario-cli/src/commands/ crates/relicario-cli/tests/keyfile_flows.rs +git commit -m "feat(cli): init --key-file generates a .relkey second factor" +``` + +--- + +## Hand-off contract (consumed by Plan 5: extension + positioning) + +- Core: `keyfile_encode(&[u8;32]) -> Vec`, `keyfile_decode(&[u8]) -> Result>`, `SecondFactor { Image, Keyfile }` on `KdfParams` (absent ⇒ `Image`). +- WASM / `wasm.d.ts`: `keyfile_encode(Uint8Array): Uint8Array`, `keyfile_decode(Uint8Array): Uint8Array`, `unlock_with_secret(passphrase, secret, salt, params_json): SessionHandle`. +- Armor: `relicario-keyfile-v1\n` + base64(32 bytes) + `\n`; extension `.relkey`. +- `params.json` carries `"second_factor": "image" | "keyfile"`. Plan 5's setup wizard writes the hint and its unlock reads it to choose the image picker vs the key-file picker, calling `unlock_with_secret` for the key-file path. diff --git a/docs/superpowers/plans/2026-06-20-v0.9.0-keyfile-ext-positioning.md b/docs/superpowers/plans/2026-06-20-v0.9.0-keyfile-ext-positioning.md new file mode 100644 index 0000000..d14b53a --- /dev/null +++ b/docs/superpowers/plans/2026-06-20-v0.9.0-keyfile-ext-positioning.md @@ -0,0 +1,277 @@ +# Key-File Second Factor — Extension + Positioning 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:** Let users choose a key file instead of the stego image at setup, unlock with it in the browser, and re-lead the project's positioning on the durable thesis. + +**Architecture:** The setup wizard gains a second-factor container choice; in key-file mode the SW `create_vault` generates the 32-byte secret, returns the `.relkey` armor for download, stores `keyfileBase64` (exactly as `imageBase64` is stored today), and writes `params.json` `second_factor: "keyfile"`. The SW `unlock` handler branches on that hint — image path unchanged, key-file path calls `unlock_with_secret`. Then the docs lead with the thesis and frame stego as an option. + +**Tech Stack:** TypeScript (extension setup + SW), vitest; Markdown docs. Consumes Plan 4's core/WASM/params contract. + +## Global Constraints + +- Release target: v0.9.0. +- Consume Plan 4 verbatim: WASM `keyfile_encode`/`keyfile_decode`/`unlock_with_secret`; `params.json` `second_factor: "image"|"keyfile"` (absent ⇒ image). +- Binary crosses `chrome.runtime.sendMessage` base64-enveloped (`shared/message-binary.ts`) — ArrayBuffers are dropped otherwise. +- `keyfileBase64` is the second factor in the clear in `chrome.storage.local`, exactly the posture of today's `imageBase64`. Document it as equivalent, not weaker. +- Existing image vaults must be unaffected (the `second_factor` default is `image`). +- Keep `manifest.json`/`manifest.firefox.json` in sync. Capitalize "Relicario" in prose. + +--- + +## File Structure + +- `extension/src/setup/setup-steps.ts` — `WizardState.secondFactor`; step-3 container-choice UI; key-file download flow. +- `extension/src/service-worker/router/popup-only.ts` — `create_vault` key-file branch (`:636`); `unlock` branch on the params hint (`:40-51`); store `keyfileBase64` in `save_setup` (`:144`). +- `extension/src/service-worker/vault.ts` — `create_vault` orchestration: key-file mode generates the secret + returns `.relkey`. +- `extension/src/shared/messages.ts` — `create_vault` request gains `secondFactor`; response carries optional `relkeyBytes`. +- Docs: `README.md`, `DESIGN.md`, `docs/CRYPTO.md`, `docs/FORMATS.md`, `docs/SECURITY.md`. +- Tests: `extension/src/setup/__tests__/setup-steps.test.ts`, `extension/src/service-worker/__tests__/keyfile-unlock.test.ts`. + +--- + +### Task 1: Wizard container choice (Image | Key File) + +**Files:** +- Modify: `extension/src/setup/setup-steps.ts` (`WizardState` ~`:47`, step-3 new-vault render ~`:398`) +- Test: `extension/src/setup/__tests__/setup-steps.test.ts` + +**Interfaces:** +- Produces: `WizardState.secondFactor: 'image' | 'keyfile'` (default `'image'`); step-3 shows a radio/segmented control; selecting "Key File" hides the carrier-image drop and shows a "a 32-byte key file will be generated for you to save" note. + +- [ ] **Step 1: Write the failing test** + +```ts +import { renderStep3New, defaultWizardState } from '../setup-steps'; +test('step 3 offers a second-factor choice; key-file hides the carrier drop', () => { + const state = { ...defaultWizardState(), secondFactor: 'keyfile' as const }; + const html = renderStep3New(state); + expect(html).toContain('Key File'); + expect(html).not.toContain('A 256-bit secret will be steganographically embedded'); +}); +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `cd extension && npx vitest run src/setup/__tests__/setup-steps.test.ts -t "second-factor choice"` +Expected: FAIL — no `secondFactor` field / choice UI. + +- [ ] **Step 3: Implement** — add `secondFactor: 'image'|'keyfile'` to `WizardState` (default `'image'` in `defaultWizardState`, ~`:63`); add a segmented control to the step-3 new-vault markup (`:398-406`); when `'keyfile'`, replace the carrier drop with the key-file note. + +- [ ] **Step 4: Run to verify it passes** + +Run: `cd extension && npx vitest run src/setup/__tests__/setup-steps.test.ts -t "second-factor choice"` +Expected: PASS. + +- [ ] **Step 5: Type-check + commit** + +Run: `cd extension && npm run build:all` + +```bash +git add extension/src/setup/setup-steps.ts extension/src/setup/__tests__/setup-steps.test.ts +git commit -m "feat(ext/setup): second-factor container choice (image | key file)" +``` + +--- + +### Task 2: SW `create_vault` key-file branch + +**Files:** +- Modify: `extension/src/service-worker/vault.ts` (`create_vault` orchestration), `router/popup-only.ts:636`, `extension/src/shared/messages.ts` +- Test: `extension/src/service-worker/__tests__/keyfile-unlock.test.ts` + +**Interfaces:** +- Consumes: `wasm.keyfile_encode`, `wasm.unlock_with_secret` (Plan 4). +- Produces: `create_vault` request gains `secondFactor: 'image'|'keyfile'`; in key-file mode the SW generates a 32-byte secret (`crypto.getRandomValues`), derives via `unlock_with_secret`, writes `params.json` with `second_factor: "keyfile"`, stores `keyfileBase64`, and returns `{ ok, data: { relkeyBytes } }` (base64-enveloped) for download. Image mode is unchanged. + +- [ ] **Step 1: Write the failing test** + +```ts +test('create_vault keyfile mode stores keyfileBase64 and returns relkey bytes', async () => { + const set = vi.spyOn(chrome.storage.local, 'set').mockResolvedValue(); + const resp = await handleCreateVault({ secondFactor: 'keyfile', config: fakeConfig } as any, fakeState); + expect(resp.ok).toBe(true); + expect(resp.data.relkeyBytes).toBeDefined(); + const stored = JSON.stringify(set.mock.calls); + expect(stored).toContain('keyfileBase64'); + expect(stored).not.toContain('imageBase64'); +}); +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `cd extension && npx vitest run src/service-worker/__tests__/keyfile-unlock.test.ts -t "create_vault keyfile"` +Expected: FAIL — `create_vault` ignores `secondFactor`. + +- [ ] **Step 3: Implement** — in the `create_vault` path: if `secondFactor === 'keyfile'`, `const secret = crypto.getRandomValues(new Uint8Array(32))`; `const handle = w.unlock_with_secret(passphrase, secret, salt, paramsJsonWithKeyfileHint)`; encrypt+push empty manifest/settings (reuse the image path's tail); `storageUpdate.keyfileBase64 = base64(keyfile_encode(secret))`; set `params.json` `second_factor: "keyfile"`; return `{ relkeyBytes: keyfile_encode(secret) }` base64-enveloped. Add `secondFactor` to the `create_vault` request type and `relkeyBytes?` to its response in `messages.ts`. + +- [ ] **Step 4: Run to verify it passes** + +Run: `cd extension && npx vitest run src/service-worker/__tests__/keyfile-unlock.test.ts -t "create_vault keyfile"` +Expected: PASS. + +- [ ] **Step 5: Type-check + commit** + +Run: `cd extension && npm run build:all` + +```bash +git add extension/src/service-worker/vault.ts extension/src/service-worker/router/popup-only.ts extension/src/shared/messages.ts +git commit -m "feat(ext/sw): create_vault key-file mode (generate secret, store keyfileBase64)" +``` + +--- + +### Task 3: Wizard key-file download flow + +**Files:** +- Modify: `extension/src/setup/setup-steps.ts` (finish/device step) +- Test: `extension/src/setup/__tests__/setup-steps.test.ts` + +**Interfaces:** +- Consumes: `create_vault` response `{ relkeyBytes }`. +- Produces: after a key-file `create_vault`, the wizard triggers a download of `vault.relkey` (the returned bytes) and shows "save this key file — it is your second factor; you cannot unlock without it." + +- [ ] **Step 1: Write the failing test** + +```ts +test('keyfile setup triggers a .relkey download from create_vault response', async () => { + const dl = vi.fn(); + vi.stubGlobal('URL', { createObjectURL: () => 'blob:x', revokeObjectURL: () => {} }); + await finishKeyfileSetup({ relkeyBytes: new Uint8Array([1,2,3]) }, dl); // dl = injected download trigger + expect(dl).toHaveBeenCalledWith('vault.relkey', expect.any(Blob)); +}); +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `cd extension && npx vitest run src/setup/__tests__/setup-steps.test.ts -t "relkey download"` +Expected: FAIL — no download path. + +- [ ] **Step 3: Implement** — when `secondFactor === 'keyfile'`, the finish step sends `create_vault { secondFactor: 'keyfile' }`, decodes `relkeyBytes` (base64 envelope), and triggers a `vault.relkey` download (anchor + object URL); show the "save this key file" copy. + +- [ ] **Step 4: Run to verify it passes** + +Run: `cd extension && npx vitest run src/setup/__tests__/setup-steps.test.ts -t "relkey download"` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add extension/src/setup/setup-steps.ts extension/src/setup/__tests__/setup-steps.test.ts +git commit -m "feat(ext/setup): download the generated .relkey at finish" +``` + +--- + +### Task 4: SW `unlock` branches on the params hint + +**Files:** +- Modify: `extension/src/service-worker/router/popup-only.ts:40-51` +- Test: `extension/src/service-worker/__tests__/keyfile-unlock.test.ts` + +**Interfaces:** +- Consumes: `params.json` `second_factor`; `keyfileBase64`; `wasm.keyfile_decode`, `wasm.unlock_with_secret`. + +- [ ] **Step 1: Write the failing test** + +```ts +test('unlock uses unlock_with_secret when params say keyfile', async () => { + chrome.storage.local.get = vi.fn().mockResolvedValue({ vaultConfig: fakeCfg, keyfileBase64: KF_B64 }); + const w = { keyfile_decode: vi.fn(() => new Uint8Array(32)), unlock_with_secret: vi.fn(() => fakeHandle), unlock: vi.fn() }; + await handleUnlock({ type: 'unlock', passphrase: 'pw' }, stateWith(w, /*params second_factor=keyfile*/)); + expect(w.unlock_with_secret).toHaveBeenCalled(); + expect(w.unlock).not.toHaveBeenCalled(); +}); +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `cd extension && npx vitest run src/service-worker/__tests__/keyfile-unlock.test.ts -t "unlock uses unlock_with_secret"` +Expected: FAIL — unlock always calls `w.unlock`. + +- [ ] **Step 3: Implement** — parse `meta.paramsJson`; if `second_factor === 'keyfile'`: load `keyfileBase64`, `const secret = w.keyfile_decode(base64ToUint8Array(keyfileBase64))`, `w.unlock_with_secret(passphrase, secret, salt, paramsJson)`. Else the existing image path. Map a missing/garbled key file to `invalid_key_file`. + +- [ ] **Step 4: Run to verify it passes** + +Run: `cd extension && npx vitest run src/service-worker/__tests__/keyfile-unlock.test.ts` +Expected: PASS (image-mode unlock test still green). + +- [ ] **Step 5: Type-check + commit** + +Run: `cd extension && npm run build:all` + +```bash +git add extension/src/service-worker/router/popup-only.ts +git commit -m "feat(ext/sw): unlock resolves second factor from params hint" +``` + +--- + +### Task 5: Attach-mode key-file picker + +**Files:** +- Modify: `extension/src/setup/setup-steps.ts` (step-3 attach branch ~`:353-362`), `router/popup-only.ts` (`attach_vault`) +- Test: `extension/src/setup/__tests__/setup-steps.test.ts` + +**Interfaces:** +- Produces: when attaching to a vault whose probe/params indicate `second_factor: "keyfile"`, the attach step prompts for the `.relkey` file (mirroring the reference-image `` at `:357-360`) instead of the JPEG; the chosen bytes are stored as `keyfileBase64`. + +- [ ] **Step 1: Write the failing test** + +```ts +test('attach step asks for a key file when the vault uses keyfile', () => { + const html = renderStep3Attach({ ...defaultWizardState(), attachSecondFactor: 'keyfile' } as any); + expect(html).toContain('key file (.relkey)'); + expect(html).not.toContain('reference image (JPEG)'); +}); +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `cd extension && npx vitest run src/setup/__tests__/setup-steps.test.ts -t "attach step asks for a key file"` +Expected: FAIL. + +- [ ] **Step 3: Implement** — detect the vault's `second_factor` during the connection probe; in the attach step render a `.relkey` file input when keyfile; `attach_vault` stores `keyfileBase64` and verifies by attempting `unlock_with_secret`. + +- [ ] **Step 4: Run to verify it passes** + +Run: `cd extension && npx vitest run src/setup/__tests__/setup-steps.test.ts` +Expected: PASS. + +- [ ] **Step 5: Full suite + commit** + +Run: `cd extension && npx vitest run && npm run build:all` + +```bash +git add extension/src/setup/setup-steps.ts extension/src/service-worker/router/popup-only.ts +git commit -m "feat(ext/setup): attach via key file when the vault uses one" +``` + +--- + +### Task 6: Positioning pivot — docs + +**Files:** +- Modify: `README.md`, `DESIGN.md`, `docs/CRYPTO.md`, `docs/FORMATS.md`, `docs/SECURITY.md` + +No automated test — this is prose. The "verification" is the consistency checklist in Step 4. + +- [ ] **Step 1: Re-lead `README.md`.** Open with the thesis: "two independent secrets into the KDF, self-hosted, a server that holds only opaque ciphertext, and a git-backed audit log." Move the steganography explanation below that, framed as one **option** for the second factor (with the key file as the plain alternative); keep the dead-drop story as flavor, not the headline. Update the "How it works" diagram caption to say "passphrase + second factor (reference image or key file)". + +- [ ] **Step 2: `docs/CRYPTO.md` + `docs/FORMATS.md`.** CRYPTO: add the pluggable-transport framing — "the second factor is 32 bytes; the reference image, the key file, and the recovery QR are interchangeable containers for it; the Argon2id input and master-key derivation are identical regardless of container." FORMATS: document the `.relkey` armor (`relicario-keyfile-v1` + base64(32 bytes)) and the `params.json` `second_factor` field (`"image"|"keyfile"`, absent ⇒ image), citing `crates/relicario-core/src/keyfile.rs` and `crypto.rs` `KdfParams`. + +- [ ] **Step 3: `DESIGN.md` secrets-map + `docs/SECURITY.md`.** DESIGN: add the key file to the secrets map alongside the reference image. SECURITY: state that `.relkey` / `keyfileBase64` is the second factor in the clear — the same posture as the reference JPEG / `imageBase64` — protected by the passphrase being required too; it is NOT an encrypted artifact. + +- [ ] **Step 4: Consistency check + commit.** Verify: README leads with the thesis (not stego); every place that said "passphrase + reference image" now reads "passphrase + second factor (image or key file)"; FORMATS cites the source files; no doc claims the key file is encrypted. Per CLAUDE.md living-docs discipline, confirm scope headers/Next-footers still hold. + +```bash +git add README.md DESIGN.md docs/CRYPTO.md docs/FORMATS.md docs/SECURITY.md +git commit -m "docs: re-lead positioning on the two-factor-KDF thesis; document the key-file second factor" +``` + +--- + +## Notes + +- The attach-mode probe must learn the vault's `second_factor` before the user supplies the factor — read `params.json` during the existing connection-test/probe step (`setup/probe.ts`). +- Security-review gate (per spec): after this plan, run `/security-review` on the key-file path — equivalence to the stego path, armor parsing, and the in-the-clear-storage documentation. diff --git a/docs/superpowers/plans/2026-06-20-v0.9.0-org-a-foundation.md b/docs/superpowers/plans/2026-06-20-v0.9.0-org-a-foundation.md new file mode 100644 index 0000000..094a813 --- /dev/null +++ b/docs/superpowers/plans/2026-06-20-v0.9.0-org-a-foundation.md @@ -0,0 +1,467 @@ +# Org Foundation (SW + WASM) 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:** Give the extension service worker the data layer to switch into an org vault, unwrap the org master key into a Zeroizing WASM handle, and serve a grant-filtered org manifest — no UI. + +**Architecture:** Org reuses the existing key-agnostic WASM session registry (`relicario-wasm/src/session.rs`) and the existing `item_decrypt`/`manifest_decrypt` AEAD (org items share the personal `.enc` format, org key used directly). The only new WASM function is `org_unwrap_key`. In the SW, a new multi-context session replaces the single-handle model, and a new `org-vault.ts` module mirrors `vault.ts` for org reads. Plans 2 (read UI) and 3 (write) consume the SW message contract this plan produces — they never touch WASM. + +**Tech Stack:** Rust (relicario-core/wasm), wasm-bindgen, TypeScript (extension service worker), vitest + happy-dom. + +## Global Constraints + +- Release target: v0.9.0. +- Org master key NEVER written to `localStorage`/`IndexedDB`/any persistent store — it lives only in a Zeroizing WASM session (`relicario-core` `Drop` zeroizes on `.free()`). +- Master key never crosses the WASM boundary; JS holds only the opaque `SessionHandle` (`u32`). +- Every new SW message needs all three: `PopupMessage` union entry + `POPUP_ONLY_TYPES` entry + handler arm (`extension/src/shared/messages.ts`) — a message in the union but not the set is silently rejected. +- Org crypto bypasses Argon2id (X25519 key-wrap), so the fast-Argon2id test-params convention does not apply to org tests; standard params apply only where shared fixtures touch the personal path. +- Capitalize "Relicario" in prose. + +--- + +## File Structure + +- `crates/relicario-wasm/src/lib.rs` — add `#[wasm_bindgen] org_unwrap_key`. (Reuses `session::insert`; reuses existing `manifest_decrypt`/`item_decrypt`/`item_encrypt`/`manifest_encrypt` on the returned handle.) +- `crates/relicario-core/src/manifest.rs` — ensure a `ManifestEntry` carries an optional `collection: Option` so the org manifest round-trips through the existing manifest (de)serialization. (Verify first; only add if absent.) +- `extension/src/wasm.d.ts` — declare `org_unwrap_key`. +- `extension/src/service-worker/session.ts` — replace single-handle model with a context map (personal + orgs); zero ALL on lock/expiry. +- `extension/src/service-worker/org-config.ts` *(new)* — `orgConfigs` read/write over `chrome.storage.local`. +- `extension/src/service-worker/org-vault.ts` *(new)* — org read ops: load `members.json`/`collections.json`, match this device's member, unwrap key, fetch+decrypt+grant-filter the org manifest, get one item. +- `extension/src/service-worker/router/org-handlers.ts` *(new)* — handler arms for the org messages (keeps `popup-only.ts` from bloating). +- `extension/src/service-worker/router/popup-only.ts` — dispatch the new org message types into `org-handlers.ts`. +- `extension/src/shared/messages.ts` — org message request/response shapes + `POPUP_ONLY_TYPES` entries. +- `extension/src/shared/types.ts` — `OrgConfig`, `OrgConfigSummary`, `Collection`, `OrgMember`, manifest `collection?`. +- Tests: `crates/relicario-wasm` inline test for `org_unwrap_key`; `extension/src/service-worker/__tests__/org-session.test.ts`, `org-config.test.ts`, `org-vault.test.ts`. + +--- + +### Task 1: WASM `org_unwrap_key` + +**Files:** +- Modify: `crates/relicario-wasm/src/lib.rs` (add after the personal `unlock`, ~`:49-65`) +- Modify: `extension/src/wasm.d.ts` +- Test: `crates/relicario-wasm/src/lib.rs` (`#[cfg(test)]` module) or `crates/relicario-core/src/org.rs` test if wasm-bindgen blocks a unit test + +**Interfaces:** +- Consumes: `relicario_core::org::unwrap_org_key(wrapped: &[u8], ed25519_seed: &Zeroizing<[u8;32]>) -> Result>` (`crates/relicario-core/src/org.rs:299`); `session::insert(master_key, image_secret) -> u32` (`crates/relicario-wasm/src/session.rs`). +- Produces: `org_unwrap_key(keys_blob: &[u8], device_private_key_base64: &str) -> Result`. The returned handle is an ordinary `SessionHandle` — callers use the existing `item_decrypt`/`item_encrypt`/`manifest_decrypt`/`manifest_encrypt` with it. + +- [ ] **Step 1: Confirm the device-key form.** Read how `device_private_key` is produced — `crates/relicario-wasm/src/lib.rs` `register_device`/`generate_device_keypair` and `crates/relicario-core/src/device.rs`. Determine whether `private_key_base64` is the raw 32-byte ed25519 seed or an OpenSSH blob, and write `org_unwrap_key` to decode it to the 32-byte seed `Zeroizing<[u8;32]>` that `unwrap_org_key` expects. Note the finding in a code comment. + +- [ ] **Step 2: Write the failing test** + +```rust +#[cfg(test)] +mod org_tests { + use super::*; + use relicario_core::org::wrap_org_key; + use zeroize::Zeroizing; + + #[test] + fn org_unwrap_key_yields_a_session_that_decrypts_org_blobs() { + // Generate a device keypair, wrap a known org key to it, unwrap via the wasm path, + // then encrypt+decrypt an item through the returned handle and assert round-trip. + let org_key = Zeroizing::new([7u8; 32]); + let (pub_openssh, priv_b64) = test_device_keypair(); // helper mirrors generate_device_keypair output + let wrapped = wrap_org_key(&org_key, &pub_openssh).unwrap(); + + let handle = org_unwrap_key(&wrapped, &priv_b64).unwrap(); + let ct = item_encrypt(&handle, r#"{"id":"a1","core":{"type":"SecureNote","body":"x"}}"#).unwrap(); + let pt = item_decrypt(&handle, &ct).unwrap(); // JsValue → assert it deserializes + assert!(format!("{pt:?}").contains("SecureNote")); + } +} +``` + +- [ ] **Step 3: Run test to verify it fails** + +Run: `cargo test -p relicario-wasm org_unwrap_key` +Expected: FAIL — `cannot find function org_unwrap_key`. + +- [ ] **Step 4: Implement `org_unwrap_key`** + +```rust +/// Unwrap a member's ECIES-wrapped org master key into a session handle. +/// The org key is held in the same Zeroizing WASM session registry as the +/// personal master key; org items share the personal `.enc` AEAD format, so +/// the returned handle works with item_decrypt/manifest_decrypt unchanged. +#[wasm_bindgen] +pub fn org_unwrap_key( + keys_blob: &[u8], + device_private_key_base64: &str, +) -> Result { + let seed = decode_device_seed(device_private_key_base64) // per Step 1 finding + .map_err(|e| JsError::new(&format!("bad device key: {e}")))?; + let org_key = relicario_core::org::unwrap_org_key(keys_blob, &seed) + .map_err(|e| JsError::new(&format!("org unwrap failed: {e}")))?; + // image_secret slot unused for org; fill with zeroized placeholder. + let handle = session::insert(org_key, Zeroizing::new([0u8; 32])); + Ok(SessionHandle(handle)) +} +``` + +- [ ] **Step 5: Run test to verify it passes** + +Run: `cargo test -p relicario-wasm org_unwrap_key` +Expected: PASS. + +- [ ] **Step 6: Declare in `wasm.d.ts` + build** + +Add to `extension/src/wasm.d.ts`: `export function org_unwrap_key(keys_blob: Uint8Array, device_private_key_base64: string): SessionHandle;` +Run: `cargo build -p relicario-wasm --target wasm32-unknown-unknown` then the project's wasm-pack step (see root `CLAUDE.md`). +Expected: builds clean. + +- [ ] **Step 7: Commit** + +```bash +git add crates/relicario-wasm/src/lib.rs extension/src/wasm.d.ts +git commit -m "feat(wasm): org_unwrap_key — ECIES unwrap into a session handle" +``` + +--- + +### Task 2: Org manifest `collection` field round-trips + +**Files:** +- Modify: `crates/relicario-core/src/manifest.rs` +- Modify: `extension/src/shared/types.ts` +- Test: `crates/relicario-core/tests/format_v2.rs` (or the manifest test module) + +**Interfaces:** +- Produces: `ManifestEntry.collection: Option` (serde `skip_serializing_if = "Option::is_none"`), mirrored in TS as `collection?: string`. + +- [ ] **Step 1: Check current state.** Grep `crates/relicario-core/src/manifest.rs` for `collection`. If the org manifest already round-trips (org CLI works, so it likely uses a dedicated type or already has the field), this task is a no-op verification — confirm with a test and skip to commit. If `ManifestEntry` lacks `collection`, proceed. + +- [ ] **Step 2: Write the failing test** + +```rust +#[test] +fn manifest_entry_round_trips_collection_slug() { + let json = r#"{"id":"a1","title":"db","collection":"prod-infra","modified":1}"#; + let entry: ManifestEntry = serde_json::from_str(json).unwrap(); + assert_eq!(entry.collection.as_deref(), Some("prod-infra")); + let back = serde_json::to_string(&entry).unwrap(); + assert!(back.contains("prod-infra")); +} +``` + +- [ ] **Step 3: Run to verify it fails** + +Run: `cargo test -p relicario-core manifest_entry_round_trips_collection_slug` +Expected: FAIL (unknown field or missing accessor) — or PASS immediately if the field already exists (then this task is verification-only). + +- [ ] **Step 4: Add the field if absent** + +```rust +#[serde(skip_serializing_if = "Option::is_none", default)] +pub collection: Option, +``` + +- [ ] **Step 5: Run to verify it passes** + +Run: `cargo test -p relicario-core manifest` +Expected: PASS, no other manifest test regressed. + +- [ ] **Step 6: Mirror in TS + commit** + +Add `collection?: string;` to the `ManifestEntry` interface in `extension/src/shared/types.ts`. + +```bash +git add crates/relicario-core/src/manifest.rs extension/src/shared/types.ts +git commit -m "feat(core): ManifestEntry carries optional collection slug" +``` + +--- + +### Task 3: Multi-context SW session + +**Files:** +- Modify: `extension/src/service-worker/session.ts` +- Modify: `extension/src/service-worker/index.ts` (timer-expiry zero-all), `router/popup-only.ts` (the `lock` handler) +- Test: `extension/src/service-worker/__tests__/org-session.test.ts` + +**Interfaces:** +- Produces: `setPersonal(h)`, `getPersonal()`, `setOrg(orgId, h)`, `getOrg(orgId)`, `setContext('personal'|orgId)`, `currentContext()`, `requireCurrentHandle()` (throws `vault_locked`), `clearAll()` (frees every handle). Keeps `getCurrent()`/`requireCurrent()`/`clearCurrent()` as thin wrappers over the personal handle so existing personal callers compile unchanged. + +- [ ] **Step 1: Write the failing test** + +```ts +import * as session from '../session'; +test('clearAll frees personal and every org handle', () => { + const free = vi.fn(); + const mk = (id: number) => ({ value: id, free } as unknown as SessionHandle); + session.setPersonal(mk(1)); + session.setOrg('org-a', mk(2)); + session.setOrg('org-b', mk(3)); + session.clearAll(); + expect(free).toHaveBeenCalledTimes(3); + expect(session.getPersonal()).toBeNull(); + expect(session.getOrg('org-a')).toBeNull(); +}); +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `cd extension && npx vitest run src/service-worker/__tests__/org-session.test.ts` +Expected: FAIL — `setPersonal`/`setOrg`/`clearAll` not exported. + +- [ ] **Step 3: Implement the context model** + +```ts +import type { SessionHandle } from '../../wasm/relicario_wasm'; + +let personal: SessionHandle | null = null; +const orgs = new Map(); +let context: 'personal' | string = 'personal'; + +export function setPersonal(h: SessionHandle): void { personal = h; } +export function getPersonal(): SessionHandle | null { return personal; } +export function setOrg(orgId: string, h: SessionHandle): void { orgs.set(orgId, h); } +export function getOrg(orgId: string): SessionHandle | null { return orgs.get(orgId) ?? null; } +export function setContext(c: 'personal' | string): void { context = c; } +export function currentContext(): 'personal' | string { return context; } + +export function requireCurrentHandle(): SessionHandle { + const h = context === 'personal' ? personal : orgs.get(context) ?? null; + if (!h) throw new Error('vault_locked'); + return h; +} + +export function clearAll(): void { + if (personal) { personal.free(); personal = null; } + for (const [, h] of orgs) h.free(); + orgs.clear(); + context = 'personal'; +} + +// Back-compat wrappers so existing personal-vault callers compile unchanged: +export function setCurrent(h: SessionHandle): void { setPersonal(h); } +export function getCurrent(): SessionHandle | null { return getPersonal(); } +export function requireCurrent(): SessionHandle { if (!personal) throw new Error('vault_locked'); return personal; } +export function clearCurrent(): void { clearAll(); } +``` + +- [ ] **Step 4: Run to verify it passes** + +Run: `cd extension && npx vitest run src/service-worker/__tests__/org-session.test.ts` +Expected: PASS. + +- [ ] **Step 5: Point lock + timer at `clearAll`** + +In `router/popup-only.ts` (the `lock` handler) and `index.ts` (`onExpired`), confirm they call `session.clearCurrent()` — now aliased to `clearAll()` — so a lock or timeout zeroes every org handle too. Run the full SW suite: `cd extension && npx vitest run src/service-worker/`. Expected: green (no personal regressions). + +- [ ] **Step 6: Type-check + commit** + +Run: `cd extension && npm run build:all` (NOT `npx tsc` — it can't resolve the generated wasm module). + +```bash +git add extension/src/service-worker/session.ts extension/src/service-worker/index.ts extension/src/service-worker/router/popup-only.ts +git commit -m "feat(ext/sw): multi-context session (personal + orgs), clearAll zeroes all" +``` + +--- + +### Task 4: Org config storage + `org_list_configs` + +**Files:** +- Create: `extension/src/service-worker/org-config.ts` +- Modify: `extension/src/shared/messages.ts`, `extension/src/shared/types.ts`, `extension/src/service-worker/router/org-handlers.ts` (new), `router/popup-only.ts` +- Test: `extension/src/service-worker/__tests__/org-config.test.ts` + +**Interfaces:** +- Produces: `type OrgConfig = { orgId: string; displayName: string; hostType: 'gitea'|'github'; hostUrl: string; repoPath: string; apiToken: string; memberId: string }`; `type OrgConfigSummary = { orgId: string; displayName: string }`; `loadOrgConfigs(): Promise`; SW message `org_list_configs → { ok, data: OrgConfigSummary[] }`. + +- [ ] **Step 1: Write the failing test** + +```ts +test('org_list_configs returns id+displayName only (no tokens)', async () => { + chrome.storage.local.get = vi.fn().mockResolvedValue({ orgConfigs: [ + { orgId: 'o1', displayName: 'Acme', hostType: 'gitea', hostUrl: 'h', repoPath: 'r', apiToken: 'SECRET', memberId: 'm1' }, + ]}); + const resp = await handleOrgListConfigs(); + expect(resp).toEqual({ ok: true, data: [{ orgId: 'o1', displayName: 'Acme' }] }); +}); +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `cd extension && npx vitest run src/service-worker/__tests__/org-config.test.ts` +Expected: FAIL — `handleOrgListConfigs` undefined. + +- [ ] **Step 3: Implement `org-config.ts` + handler** + +```ts +// org-config.ts +export type OrgConfig = { orgId: string; displayName: string; hostType: 'gitea'|'github'; hostUrl: string; repoPath: string; apiToken: string; memberId: string }; +export async function loadOrgConfigs(): Promise { + const { orgConfigs } = await chrome.storage.local.get('orgConfigs'); + return (orgConfigs as OrgConfig[] | undefined) ?? []; +} +``` + +```ts +// org-handlers.ts +import { loadOrgConfigs } from '../org-config'; +export async function handleOrgListConfigs() { + const cfgs = await loadOrgConfigs(); + return { ok: true as const, data: cfgs.map(c => ({ orgId: c.orgId, displayName: c.displayName })) }; +} +``` + +- [ ] **Step 4: Wire the message (all three places)** + +Add `org_list_configs` to the `PopupMessage` union and `POPUP_ONLY_TYPES` in `shared/messages.ts`, and a dispatch arm in `router/popup-only.ts` → `handleOrgListConfigs()`. + +- [ ] **Step 5: Run to verify it passes** + +Run: `cd extension && npx vitest run src/service-worker/__tests__/org-config.test.ts` +Expected: PASS. + +- [ ] **Step 6: Type-check + commit** + +Run: `cd extension && npm run build:all` + +```bash +git add extension/src/service-worker/org-config.ts extension/src/service-worker/router/org-handlers.ts extension/src/service-worker/router/popup-only.ts extension/src/shared/messages.ts extension/src/shared/types.ts +git commit -m "feat(ext/sw): org config storage + org_list_configs message" +``` + +--- + +### Task 5: Org read core — load grants, unwrap, fetch + grant-filter manifest + +**Files:** +- Create: `extension/src/service-worker/org-vault.ts` +- Modify: `extension/src/shared/types.ts` (`Collection`, `OrgMember`) +- Test: `extension/src/service-worker/__tests__/org-vault.test.ts` + +**Interfaces:** +- Consumes: `createGitHost` (`service-worker/git-host.ts`); `org_unwrap_key` (Task 1); device key from `chrome.storage.local.device_private_key`; `wasm.manifest_decrypt` (existing). +- Produces: `openOrg(cfg: OrgConfig): Promise` where `OrgHandleState = { handle: SessionHandle; grants: string[]; offline: boolean }`; `listOrgItems(state): ManifestEntry[]` (filtered to `grants`); `getOrgItem(state, id): Promise`; `listOrgCollections(state): Collection[]`. + +- [ ] **Step 1: Write the failing test** (mock the GitHost + wasm boundary as `router.test.ts` does) + +```ts +test('listOrgItems hides entries for ungranted collections', () => { + const manifest = { items: { + a: { id: 'a', title: 'x', collection: 'prod-infra', modified: 1 }, + b: { id: 'b', title: 'y', collection: 'secret-ops', modified: 1 }, + }}; + const state = { handle: {} as any, grants: ['prod-infra'], offline: false }; + expect(listOrgItems(state, manifest).map(e => e.id)).toEqual(['a']); +}); +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `cd extension && npx vitest run src/service-worker/__tests__/org-vault.test.ts` +Expected: FAIL — `listOrgItems` undefined. + +- [ ] **Step 3: Implement `org-vault.ts`** (open flow + filters) + +```ts +import { createGitHost } from './git-host'; +import { fingerprint } from '../shared/ssh-fingerprint'; +import type { OrgConfig } from './org-config'; + +export type OrgHandleState = { handle: SessionHandle; grants: string[]; offline: boolean }; + +export async function openOrg(cfg: OrgConfig, wasm: WasmModule): Promise { + const host = createGitHost(cfg.hostType, cfg.hostUrl, cfg.repoPath, cfg.apiToken); + const members = JSON.parse(new TextDecoder().decode(await host.readFile('members.json'))); + const { device_private_key } = await chrome.storage.local.get('device_private_key'); + const me = matchMember(members, await deviceFingerprint()); // by ed25519 fingerprint + if (!me) throw new Error('not_an_org_member'); + const wrapped = await host.readFile(`keys/${me.member_id}.enc`); + const handle = wasm.org_unwrap_key(wrapped, device_private_key); + return { handle, grants: me.collections, offline: false }; +} + +export function listOrgItems(state: OrgHandleState, manifest: Manifest): ManifestEntry[] { + return Object.values(manifest.items) + .filter(e => e.collection && state.grants.includes(e.collection)); +} +``` + +(Fetch-and-decrypt the manifest with `wasm.manifest_decrypt(state.handle, ct)`, mirroring `vault.ts fetchAndDecryptManifest`. `getOrgItem` reads `items//.enc` and `wasm.item_decrypt`.) + +- [ ] **Step 4: Run to verify it passes** + +Run: `cd extension && npx vitest run src/service-worker/__tests__/org-vault.test.ts` +Expected: PASS. + +- [ ] **Step 5: Add the "key never persisted" assertion test** + +```ts +test('opening an org never writes the org key to storage', async () => { + const setSpy = vi.spyOn(chrome.storage.local, 'set'); + await openOrg(fakeCfg, fakeWasm); + for (const call of setSpy.mock.calls) { + expect(JSON.stringify(call)).not.toContain('orgMasterKey'); + } +}); +``` + +Run it; expected PASS (we never call `storage.local.set` with the key). + +- [ ] **Step 6: Commit** + +```bash +git add extension/src/service-worker/org-vault.ts extension/src/shared/types.ts extension/src/service-worker/__tests__/org-vault.test.ts +git commit -m "feat(ext/sw): org-vault — unwrap, fetch, grant-filter manifest" +``` + +--- + +### Task 6: `org_switch` (with offline detection) + read messages + +**Files:** +- Modify: `extension/src/service-worker/router/org-handlers.ts`, `router/popup-only.ts`, `shared/messages.ts` +- Test: `extension/src/service-worker/__tests__/org-vault.test.ts` + +**Interfaces:** +- Produces SW messages: `org_switch {context}` → `{ ok, data: { context, offline } }`; `org_list_items` → `{ ok, data: ManifestEntry[] }`; `org_get_item {id}` → `{ ok, data: Item }`; `org_list_collections` → `{ ok, data: Collection[] }`. On a git network error during switch, set `offline: true` and serve the last-cached manifest read-only. + +- [ ] **Step 1: Write the failing test** + +```ts +test('org_switch flags offline when the git fetch throws a network error', async () => { + const resp = await handleOrgSwitch({ context: 'o1' }, { ...stateWithNetworkError }); + expect(resp).toEqual({ ok: true, data: { context: 'o1', offline: true } }); +}); +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `cd extension && npx vitest run src/service-worker/__tests__/org-vault.test.ts` +Expected: FAIL — `handleOrgSwitch` undefined. + +- [ ] **Step 3: Implement the four handlers** (switch sets `session.setContext`, caches the org `OrgHandleState`; on network error reuse the cached manifest and return `offline: true`; the three read handlers project from the cached state via `listOrgItems`/`getOrgItem`/`listOrgCollections`). Wire all four messages in `shared/messages.ts` (union + `POPUP_ONLY_TYPES`) and `popup-only.ts`. + +- [ ] **Step 4: Run to verify it passes** + +Run: `cd extension && npx vitest run src/service-worker/` +Expected: PASS (all org + personal SW tests green). + +- [ ] **Step 5: Type-check + commit** + +Run: `cd extension && npm run build:all` + +```bash +git add extension/src/service-worker/router/org-handlers.ts extension/src/service-worker/router/popup-only.ts extension/src/shared/messages.ts +git commit -m "feat(ext/sw): org_switch + org read messages (grant-filtered, offline-aware)" +``` + +--- + +## Hand-off contract (consumed by Plan 2 read UI and Plan 3 write) + +Plans 2 and 3 are UI-only and talk to the SW exclusively through these messages (sent via the `shared/state.ts` `sendMessage` wrapper from `popup.html`/`vault.html`): + +- `org_list_configs` → `{ ok, data: OrgConfigSummary[] }` where `OrgConfigSummary = { orgId, displayName }` +- `org_switch { context: 'personal' | }` → `{ ok, data: { context, offline: boolean } }` +- `org_list_items` → `{ ok, data: ManifestEntry[] }` (already grant-filtered; entries carry `collection`) +- `org_get_item { id }` → `{ ok, data: Item }` +- `org_list_collections` → `{ ok, data: Collection[] }` where `Collection = { slug, display_name }` + +The SW holds the org context after `org_switch`; subsequent `org_list_items`/`org_get_item` operate on the current context until the next `org_switch` (including back to `'personal'`). Plan 3 adds `org_add_item`/`org_update_item`/`org_delete_item` against this same context model. diff --git a/docs/superpowers/plans/2026-06-20-v0.9.0-org-b-read-ui.md b/docs/superpowers/plans/2026-06-20-v0.9.0-org-b-read-ui.md new file mode 100644 index 0000000..dfdd1c6 --- /dev/null +++ b/docs/superpowers/plans/2026-06-20-v0.9.0-org-b-read-ui.md @@ -0,0 +1,300 @@ +# Org Read UI 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:** Let a member browse and view org items in the browser — a context switcher (Personal + each org), grant-filtered list/detail reusing the existing renderers, and an offline indicator. + +**Architecture:** UI-only. All org data comes from the SW messages Plan 1 produced, sent through the `shared/state.ts` `sendMessage` channel. The current context lives in `PopupState.orgContext`; list/detail data-loading branches on it (`list_items` vs `org_list_items`, `get_item` vs `org_get_item`) but reuses the same `popup/components/*` renderers via the `StateHost` service locator, so org items render with the unchanged per-type detail views. + +**Tech Stack:** TypeScript, vitest + happy-dom. + +## Global Constraints + +- Release target: v0.9.0. +- Reuse existing `popup/components/*` renderers via `shared/state.ts` — do NOT fork per-type views for org. +- This plan is READ-ONLY: no add/edit/delete UI (Plan 3). In org context, hide write affordances. +- Org messages are popup-class (sent only from `popup.html` / `vault.html`). +- Consume Plan 1's contract verbatim: `org_list_configs`, `org_switch {context}`, `org_list_items`, `org_get_item {id}`, `org_list_collections`. +- Keep `manifest.json` and `manifest.firefox.json` in sync if permissions change (they should not for this plan). +- Capitalize "Relicario" in prose. + +--- + +## File Structure + +- `extension/src/shared/popup-state.ts` — add `orgContext`, `orgConfigs`, `orgCollections`, `orgOffline` to `PopupState`. +- `extension/src/shared/org-context.ts` *(new)* — `currentContext()`, `messageForList()`, `messageForGet(id)` helpers that pick the personal vs org message by context (single source of truth, consumed by list + detail). +- `extension/src/popup/components/org-switcher.ts` *(new)* — the Personal/org selector + offline banner; mounted in both the popup header and the vault sidebar header. +- `extension/src/popup/components/item-list.ts` — load via `messageForList()`; hide the "+ new" affordance in org context. +- `extension/src/popup/components/item-detail.ts` — load via `messageForGet(id)`. +- `extension/src/vault/vault-sidebar.ts` — mount `org-switcher` in `vault-sidebar__header`; add a collection facet for org context. +- `extension/src/popup/popup.ts` — mount `org-switcher` in the popup header. +- Tests: `extension/src/popup/components/__tests__/org-switcher.test.ts`, `org-context.test.ts`, and additions to `item-list.test.ts`. + +--- + +### Task 1: `PopupState` org fields + context message helper + +**Files:** +- Modify: `extension/src/shared/popup-state.ts` +- Create: `extension/src/shared/org-context.ts` +- Test: `extension/src/shared/__tests__/org-context.test.ts` + +**Interfaces:** +- Produces: `PopupState.orgContext: 'personal' | string` (default `'personal'`), `orgConfigs: OrgConfigSummary[]`, `orgCollections: Collection[]`, `orgOffline: boolean`; `currentContext(): 'personal' | string`; `messageForList(): Request`; `messageForGet(id: string): Request`. + +- [ ] **Step 1: Write the failing test** + +```ts +import { messageForList, messageForGet } from '../org-context'; +import * as state from '../state'; + +test('messageForList/Get pick personal vs org by current context', () => { + vi.spyOn(state, 'getState').mockReturnValue({ orgContext: 'personal' } as any); + expect(messageForList()).toEqual({ type: 'list_items' }); + expect(messageForGet('x')).toEqual({ type: 'get_item', id: 'x' }); + + vi.spyOn(state, 'getState').mockReturnValue({ orgContext: 'org-1' } as any); + expect(messageForList()).toEqual({ type: 'org_list_items' }); + expect(messageForGet('x')).toEqual({ type: 'org_get_item', id: 'x' }); +}); +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `cd extension && npx vitest run src/shared/__tests__/org-context.test.ts` +Expected: FAIL — `org-context` module missing. + +- [ ] **Step 3: Implement** + +```ts +// org-context.ts +import { getState } from './state'; +import type { Request } from './messages'; + +export function currentContext(): 'personal' | string { + return getState().orgContext ?? 'personal'; +} +export function messageForList(): Request { + return currentContext() === 'personal' ? { type: 'list_items' } : { type: 'org_list_items' }; +} +export function messageForGet(id: string): Request { + return currentContext() === 'personal' ? { type: 'get_item', id } : { type: 'org_get_item', id }; +} +``` + +Add the four fields to `PopupState` in `popup-state.ts` (defaults: `orgContext: 'personal'`, `orgConfigs: []`, `orgCollections: []`, `orgOffline: false`). + +- [ ] **Step 4: Run to verify it passes** + +Run: `cd extension && npx vitest run src/shared/__tests__/org-context.test.ts` +Expected: PASS. + +- [ ] **Step 5: Type-check + commit** + +Run: `cd extension && npm run build:all` + +```bash +git add extension/src/shared/popup-state.ts extension/src/shared/org-context.ts extension/src/shared/__tests__/org-context.test.ts +git commit -m "feat(ext): PopupState org fields + context-aware message helper" +``` + +--- + +### Task 2: Org switcher component + +**Files:** +- Create: `extension/src/popup/components/org-switcher.ts` +- Test: `extension/src/popup/components/__tests__/org-switcher.test.ts` + +**Interfaces:** +- Consumes: `org_list_configs`, `org_switch` (via `sendMessage`); `setState`, `navigate`. +- Produces: `renderOrgSwitcher(host: HTMLElement): Promise` (renders a `` (Personal + each), on `change` send `org_switch`, write `setState({ orgContext, orgOffline })`, then `navigate('list', {})` to reload. Render an "org offline — writes disabled" badge when `data.offline`. + +- [ ] **Step 4: Run to verify it passes** + +Run: `cd extension && npx vitest run src/popup/components/__tests__/org-switcher.test.ts` +Expected: PASS. + +- [ ] **Step 5: Mount in both surfaces + commit** — call `renderOrgSwitcher` into the popup header (`popup.ts`) and the `vault-sidebar__header` (`vault-sidebar.ts`, after the brand block at `:26-29`). + +Run: `cd extension && npm run build:all` + +```bash +git add extension/src/popup/components/org-switcher.ts extension/src/popup/popup.ts extension/src/vault/vault-sidebar.ts extension/src/popup/components/__tests__/org-switcher.test.ts +git commit -m "feat(ext): org context switcher (popup header + vault sidebar)" +``` + +--- + +### Task 3: List + detail consume the context-aware data source + +**Files:** +- Modify: `extension/src/popup/components/item-list.ts`, `item-detail.ts` +- Test: additions to `extension/src/popup/components/__tests__/item-list.test.ts` + +**Interfaces:** +- Consumes: `messageForList()` / `messageForGet(id)` (Task 1). + +- [ ] **Step 1: Write the failing test** + +```ts +test('item-list loads org items (grant-filtered) when context is an org', async () => { + vi.spyOn(state, 'getState').mockReturnValue({ orgContext: 'org-1', items: [] } as any); + const send = vi.spyOn(state, 'sendMessage').mockResolvedValue({ ok: true, data: [ + { id: 'a', title: 'db', collection: 'prod-infra', modified: 1 }, + ]}); + await renderItemList(document.createElement('div')); + expect(send).toHaveBeenCalledWith({ type: 'org_list_items' }); +}); +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `cd extension && npx vitest run src/popup/components/__tests__/item-list.test.ts -t "org items"` +Expected: FAIL — list still sends `list_items`. + +- [ ] **Step 3: Implement** — replace the hard-coded `sendMessage({ type: 'list_items' })` in `item-list.ts` with `sendMessage(messageForList())`, and `get_item` in `item-detail.ts` with `messageForGet(id)`. In org context, hide the "+ new item" button (read-only this plan). + +- [ ] **Step 4: Run to verify it passes** + +Run: `cd extension && npx vitest run src/popup/components/__tests__/item-list.test.ts` +Expected: PASS (personal path unchanged: context `'personal'` → `list_items`). + +- [ ] **Step 5: Type-check + commit** + +Run: `cd extension && npm run build:all` + +```bash +git add extension/src/popup/components/item-list.ts extension/src/popup/components/item-detail.ts extension/src/popup/components/__tests__/item-list.test.ts +git commit -m "feat(ext): list/detail load org items in org context" +``` + +--- + +### Task 4: Collection facet in the vault sidebar (org context) + +**Files:** +- Modify: `extension/src/vault/vault-sidebar.ts`, `extension/src/vault/vault-context.ts` (filter helper) +- Test: `extension/src/popup/components/__tests__/org-switcher.test.ts` (extend) or a new `vault-sidebar` test + +**Interfaces:** +- Consumes: `org_list_collections`; `PopupState.orgCollections`. +- Produces: a collection nav list (parallel to the type-category nav) shown only in org context; selecting a collection filters the org list to that slug. + +- [ ] **Step 1: Write the failing test** + +```ts +test('org context renders a collection facet from org_list_collections', async () => { + vi.spyOn(state, 'getState').mockReturnValue({ orgContext: 'org-1', orgCollections: [ + { slug: 'prod-infra', display_name: 'Production Infra' }, + ]} as any); + const el = document.createElement('div'); + renderCollectionFacet(el); + expect(el.textContent).toContain('Production Infra'); +}); +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `cd extension && npx vitest run -t "collection facet"` +Expected: FAIL — `renderCollectionFacet` missing. + +- [ ] **Step 3: Implement** — on `org_switch` success, fetch `org_list_collections` into `state.orgCollections`; render a collection list in the sidebar (reuse the category-nav markup pattern at `vault-sidebar.ts:33`); clicking a collection sets a `collectionFilter` in state and re-renders the filtered list. Hidden when `orgContext === 'personal'`. + +- [ ] **Step 4: Run to verify it passes** + +Run: `cd extension && npx vitest run -t "collection facet"` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add extension/src/vault/vault-sidebar.ts extension/src/vault/vault-context.ts +git commit -m "feat(ext): collection facet for org browse" +``` + +--- + +### Task 5: Offline read-only banner + +**Files:** +- Modify: `extension/src/popup/components/org-switcher.ts` (or a small `org-banner.ts`) +- Test: `extension/src/popup/components/__tests__/org-switcher.test.ts` (extend) + +**Interfaces:** +- Consumes: `org_switch` response `{ offline }`; `PopupState.orgOffline`. + +- [ ] **Step 1: Write the failing test** + +```ts +test('offline org_switch renders the writes-disabled banner', async () => { + vi.spyOn(state, 'sendMessage').mockImplementation(async (req: any) => + req.type === 'org_switch' ? { ok: true, data: { context: 'org-1', offline: true } } + : req.type === 'org_list_configs' ? { ok: true, data: [{ orgId: 'org-1', displayName: 'Acme' }] } + : { ok: true, data: [] }); + const host = document.createElement('div'); + await renderOrgSwitcher(host); + (host.querySelector('select') as HTMLSelectElement).value = 'org-1'; + host.querySelector('select')!.dispatchEvent(new Event('change')); + await Promise.resolve(); + expect(host.textContent).toContain('org offline — writes disabled'); +}); +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `cd extension && npx vitest run -t "writes-disabled banner"` +Expected: FAIL — no banner. + +- [ ] **Step 3: Implement** — when `org_switch` returns `offline: true`, set `state.orgOffline` and render the banner element in the switcher host. (Plan 3's write UI reads `orgOffline` to disable add/edit.) + +- [ ] **Step 4: Run to verify it passes** + +Run: `cd extension && npx vitest run src/popup/components/__tests__/org-switcher.test.ts` +Expected: PASS. + +- [ ] **Step 5: Full suite, type-check, commit** + +Run: `cd extension && npx vitest run && npm run build:all` + +```bash +git add extension/src/popup/components/org-switcher.ts extension/src/popup/components/__tests__/org-switcher.test.ts +git commit -m "feat(ext): org offline read-only banner" +``` + +--- + +## Hand-off note (Plan 3 write builds on this) + +Plan 3 adds the write affordances this plan deliberately hid: the "+ new item" button in org context, edit/delete in the org item detail, and a granted-collection picker on add. It reads `PopupState.orgOffline` to disable writes when offline, and `PopupState.orgCollections` for the collection picker. Write operations call the `org_add_item`/`org_update_item`/`org_delete_item` messages Plan 3 adds to the SW. diff --git a/docs/superpowers/plans/2026-06-20-v0.9.0-org-c-write.md b/docs/superpowers/plans/2026-06-20-v0.9.0-org-c-write.md new file mode 100644 index 0000000..83aab00 --- /dev/null +++ b/docs/superpowers/plans/2026-06-20-v0.9.0-org-c-write.md @@ -0,0 +1,237 @@ +# Org Write Implementation Plan (spike-gated) + +> **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. **Task 1 is a GO/NO-GO spike — do not start Tasks 2+ until it passes.** + +**Goal:** Let org members add, edit, and delete org items from the extension — via ed25519-signed commits the org pre-receive hook accepts. + +**Architecture:** The org hook rejects unsigned commits, and the extension's Contents-API write path produces unsigned commits. So org writes go through the host **Git Data API** (blob → tree → **signed** commit → update-ref), signing the commit object with `sign_for_git` (already in WASM, unused today). `gitea.ts`/`github.ts` already use the Git Data API for large attachments, so this extends existing machinery. Whether the host API preserves a caller-supplied SSH signature through to `git verify-commit` is unproven — Task 1 proves it before anything else is built. + +**Tech Stack:** TypeScript (extension SW + UI), vitest; `relicario-server` (the hook, for the spike); a live Gitea (and GitHub) repo for the spike. + +## Global Constraints + +- Release target: v0.9.0. +- **Task 1 gates the rest.** If the spike fails on both hosts, STOP: record the result in `docs/superpowers/specs/2026-06-20-extension-org-gui-design.md` and `STATUS.md`, ship org **read** (Plans 1+2) for v0.9.0, and move org write to a follow-up. Do not build Tasks 2–5 against a push path the server will reject. +- Consume Plan 1's context model + Plan 2's read UI. Org writes operate on the current org context. +- Manifest mutation writes BOTH `items//.enc` AND `manifest.enc` (the personal "both writes" invariant) — both inside ONE signed commit where possible. +- Org master key stays in the Zeroizing WASM handle; signing uses the device key from `chrome.storage.local.device_private_key`. +- Capitalize "Relicario" in prose. + +--- + +## File Structure + +- *(spike)* `docs/superpowers/spikes/2026-06-20-org-signed-commit-spike.md` *(new)* — the experiment + its result. +- `extension/src/service-worker/git-host.ts` — add `commitSigned(files, message, sign)` to the interface. +- `extension/src/service-worker/gitea.ts` / `github.ts` — implement `commitSigned` over the Git Data API with an attached SSH signature. +- `extension/src/service-worker/org-vault.ts` — `orgAddItem` / `orgUpdateItem` / `orgDeleteItem` (encrypt with org handle, collection-scoped path, manifest update, signed commit). +- `extension/src/service-worker/router/org-handlers.ts`, `shared/messages.ts` — `org_add_item` / `org_update_item` / `org_delete_item`. +- `extension/src/popup/components/item-list.ts`, `item-detail.ts`, `item-form.ts` — un-hide write affordances in org context; collection picker on add. +- Tests: `extension/src/service-worker/__tests__/org-write.test.ts`; component tests for the org write affordances. + +--- + +### Task 1: GO/NO-GO spike — signed commit via the host Git Data API + +**Files:** +- Create: `docs/superpowers/spikes/2026-06-20-org-signed-commit-spike.md` + +This is a spike, not a TDD cycle. The deliverable is a written GO/NO-GO with evidence. + +- [ ] **Step 1: Set up a throwaway org repo + hook.** On a local Gitea, create a repo, install `relicario-server generate-org-hook` as the pre-receive hook, and register a test device's ed25519 public key in `members.json` (use `relicario org init`/`add-member` from the CLI to bootstrap a valid org repo). + +- [ ] **Step 2: Construct and sign a commit object in a Node/SW-like harness.** Build a canonical git commit object (tree + parent + author/committer) for a small `items//.enc` change, sign the commit payload with the device key via `sign_for_git` (export it the way the SW would call WASM), and format the SSH signature into the commit (`gpgsig`-style SSH signature block). + +- [ ] **Step 3: Push it via the Gitea Git Data API.** `POST /git/blobs` → `POST /git/trees` → `POST /git/commits` (with the `signature` field if Gitea supports it; otherwise the raw signed commit object) → `PATCH /git/refs/heads/{branch}`. Record the exact API shape that carries the signature. + +- [ ] **Step 4: Verify server-side.** Confirm the pushed commit passes `git verify-commit ` on the server AND is accepted by `relicario-server verify-org-commit`. Repeat Steps 2–4 against **GitHub** (create-commit `signature` field). + +- [ ] **Step 5: Record the verdict.** Write the spike doc with: GO/NO-GO per host, the exact Git Data API call sequence that worked (or the failure mode), and any constraints (e.g., committer identity must match). Commit it. + +```bash +git add docs/superpowers/spikes/2026-06-20-org-signed-commit-spike.md +git commit -m "spike: org signed-commit push via host Git Data API — result" +``` + +**Decision gate:** GO on at least one host → continue to Task 2 (ship write for the passing host(s), note any host-specific limitation). NO-GO on both → update the spec + `STATUS.md`, ship org read only, stop here. + +--- + +### Task 2: `commitSigned` GitHost method + +**Files:** +- Modify: `extension/src/service-worker/git-host.ts`, `gitea.ts`, `github.ts` +- Test: `extension/src/service-worker/__tests__/git-host-signed.test.ts` + +**Interfaces:** +- Consumes: `wasm.sign_for_git(data: Uint8Array)`; device key from `chrome.storage.local`. +- Produces: `commitSigned(files: Array<{ path: string; content: Uint8Array }>, message: string, sign: (payload: Uint8Array) => string): Promise` on the `GitHost` interface — builds one commit containing all `files` via the Git Data API, signs the commit object, pushes per the Task-1 sequence. + +- [ ] **Step 1: Write the failing test** (mock fetch to the Git Data API endpoints; assert the call sequence + that the commit body includes the signature) + +```ts +test('commitSigned creates a blob+tree+signed-commit and updates the ref', async () => { + const calls: string[] = []; + globalThis.fetch = vi.fn(async (url: string) => { calls.push(String(url)); return okJson({ sha: 'x' }); }) as any; + const host = new GiteaHost('https://git.example', 'o/r', 'tok'); + await host.commitSigned([{ path: 'items/c/i.enc', content: new Uint8Array([1]) }], 'add', () => 'SSHSIG...'); + expect(calls.some(u => u.includes('/git/blobs'))).toBe(true); + expect(calls.some(u => u.includes('/git/commits'))).toBe(true); + expect(calls.some(u => u.includes('/git/refs/'))).toBe(true); +}); +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `cd extension && npx vitest run src/service-worker/__tests__/git-host-signed.test.ts` +Expected: FAIL — `commitSigned` not defined. + +- [ ] **Step 3: Implement** — extend the existing Git Data API code (the large-attachment path) into `commitSigned`, following the exact sequence the spike proved. Add `commitSigned` to the `GitHost` interface and both hosts. + +- [ ] **Step 4: Run to verify it passes** + +Run: `cd extension && npx vitest run src/service-worker/__tests__/git-host-signed.test.ts` +Expected: PASS. + +- [ ] **Step 5: Type-check + commit** + +Run: `cd extension && npm run build:all` + +```bash +git add extension/src/service-worker/git-host.ts extension/src/service-worker/gitea.ts extension/src/service-worker/github.ts extension/src/service-worker/__tests__/git-host-signed.test.ts +git commit -m "feat(ext/sw): commitSigned — signed multi-file commit via Git Data API" +``` + +--- + +### Task 3: Org write SW handlers + +**Files:** +- Modify: `extension/src/service-worker/org-vault.ts`, `router/org-handlers.ts`, `router/popup-only.ts`, `shared/messages.ts` +- Test: `extension/src/service-worker/__tests__/org-write.test.ts` + +**Interfaces:** +- Consumes: `commitSigned` (Task 2); the org `OrgHandleState` (Plan 1); `wasm.item_encrypt`, `wasm.manifest_encrypt`. +- Produces: SW messages `org_add_item { collection, item }`, `org_update_item { id, item }`, `org_delete_item { id }`. Each encrypts with the org handle, writes the collection-scoped `items//.enc` AND the updated `manifest.enc` in ONE signed commit, and refuses writes to ungranted collections client-side (the hook is the backstop). + +- [ ] **Step 1: Write the failing test** + +```ts +test('org_add_item refuses a collection the member is not granted', async () => { + const state = { ...orgStateWithGrants(['prod-infra']) }; + const resp = await handleOrgAddItem({ collection: 'secret-ops', item: fakeLogin }, state); + expect(resp).toEqual({ ok: false, error: 'collection_not_granted' }); +}); + +test('org_add_item writes item + manifest in one signed commit to the granted path', async () => { + const state = { ...orgStateWithGrants(['prod-infra']) }; + const commit = vi.spyOn(state.host, 'commitSigned').mockResolvedValue(); + await handleOrgAddItem({ collection: 'prod-infra', item: fakeLogin }, state); + const [files] = commit.mock.calls[0]; + expect(files.map((f: any) => f.path).sort()).toEqual(['items/prod-infra/' + expect.any(String), 'manifest.enc'].sort()); +}); +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `cd extension && npx vitest run src/service-worker/__tests__/org-write.test.ts` +Expected: FAIL — handlers undefined. + +- [ ] **Step 3: Implement** the three handlers in `org-vault.ts` (encrypt, collection-scoped path, manifest update, single `commitSigned`), wire the three messages (union + `POPUP_ONLY_TYPES` + dispatch), and the grant check. + +- [ ] **Step 4: Run to verify it passes** + +Run: `cd extension && npx vitest run src/service-worker/` +Expected: PASS (org read + write + personal all green). + +- [ ] **Step 5: Type-check + commit** + +Run: `cd extension && npm run build:all` + +```bash +git add extension/src/service-worker/org-vault.ts extension/src/service-worker/router/org-handlers.ts extension/src/service-worker/router/popup-only.ts extension/src/shared/messages.ts extension/src/service-worker/__tests__/org-write.test.ts +git commit -m "feat(ext/sw): org_add/update/delete_item via signed commits" +``` + +--- + +### Task 4: Org write UI + +**Files:** +- Modify: `extension/src/popup/components/item-list.ts`, `item-detail.ts`, `item-form.ts` +- Test: `extension/src/popup/components/__tests__/item-form.test.ts` (org additions) + +**Interfaces:** +- Consumes: `org_add_item`/`org_update_item`/`org_delete_item`; `PopupState.orgCollections`, `PopupState.orgOffline`. +- Produces: in org context — the "+ new item" button reappears (Plan 2 hid it) with a granted-collection picker; edit + delete in the org item detail; all write affordances disabled when `orgOffline`. + +- [ ] **Step 1: Write the failing test** + +```ts +test('org add form requires a granted collection and sends org_add_item', async () => { + vi.spyOn(state, 'getState').mockReturnValue({ orgContext: 'org-1', orgOffline: false, + orgCollections: [{ slug: 'prod-infra', display_name: 'Prod' }] } as any); + const send = vi.spyOn(state, 'sendMessage').mockResolvedValue({ ok: true, data: {} } as any); + await renderItemForm(document.createElement('div'), { type: 'Login' }); + // fill + pick collection 'prod-infra' + save → assert message + // ... + expect(send).toHaveBeenCalledWith(expect.objectContaining({ type: 'org_add_item', collection: 'prod-infra' })); +}); + +test('write affordances are disabled when org is offline', async () => { + vi.spyOn(state, 'getState').mockReturnValue({ orgContext: 'org-1', orgOffline: true } as any); + const el = document.createElement('div'); await renderItemDetail(el, fakeItem); + expect(el.querySelector('[data-action="edit"]')?.hasAttribute('disabled')).toBe(true); +}); +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `cd extension && npx vitest run src/popup/components/__tests__/item-form.test.ts` +Expected: FAIL — no org collection picker / no save routing / not disabled offline. + +- [ ] **Step 3: Implement** — in org context, the save path sends the `org_*` messages (route via a small helper paralleling Plan 2's `messageForList`), add a collection `