docs(plans): v0.9.0 implementation plans — 5 streams across 2 specs

Full-TDD per-stream plans for the v0.9.0 multi-agent train:
- org-a-foundation (A0+A1): WASM org_unwrap_key + multi-context SW session +
  org config + grant-filtered manifest read.
- org-b-read-ui (A2): org switcher + grant-filtered browse/read + offline banner.
- org-c-write (A3): GO/NO-GO signing spike first, then commitSigned + org write
  handlers + UI. Spike-gated; NO-GO ships read-only.
- keyfile-core-cli (B1+B2): core armor + unlock_with_secret + params hint +
  WASM bindings + CLI init/unlock --key-file.
- keyfile-ext-positioning (B3+B4): setup container choice + unlock + the
  README/DESIGN/CRYPTO/FORMATS positioning pivot.

Cross-plan contracts pinned and self-reviewed for consistency.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQbgrP6KQW5pibjbPEoTSs
This commit is contained in:
adlee-was-taken
2026-06-21 09:35:44 -04:00
parent 9b38aac188
commit 74cee8ac67
5 changed files with 1678 additions and 0 deletions

View File

@@ -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<u8>`; `pub fn keyfile_decode(bytes: &[u8]) -> Result<Zeroizing<[u8; 32]>>`. 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<u8> {
format!("{HEADER}\n{}\n", STANDARD.encode(secret)).into_bytes()
}
pub fn keyfile_decode(bytes: &[u8]) -> Result<Zeroizing<[u8; 32]>> {
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<SessionHandle, JsError> {
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, &params)
.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<Vec<u8>, 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<Vec<u8>, 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<PathBuf>` (mirrors `get_image_path` at `session.rs:165`: `RELICARIO_KEYFILE` env → `<vault_root>/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<PathBuf> {
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, &params)`. 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 <path>` — generates the 32-byte secret with `OsRng`, writes `keyfile_encode(secret)` to `<path>`, 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<PathBuf>` 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<u8>`, `keyfile_decode(&[u8]) -> Result<Zeroizing<[u8;32]>>`, `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.

View File

@@ -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 `<input type="file">` 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.

View File

@@ -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<String>` 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<Zeroizing<[u8;32]>>` (`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<SessionHandle, JsError>`. 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<SessionHandle, JsError> {
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<String>` (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<String>,
```
- [ ] **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<string, SessionHandle>();
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<OrgConfig[]>`; 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<OrgConfig[]> {
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<OrgHandleState>` where `OrgHandleState = { handle: SessionHandle; grants: string[]; offline: boolean }`; `listOrgItems(state): ManifestEntry[]` (filtered to `grants`); `getOrgItem(state, id): Promise<Item>`; `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<OrgHandleState> {
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/<collection>/<id>.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' | <orgId> }``{ 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.

View File

@@ -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<void>` (renders a `<select>` of Personal + each org, an offline badge), `teardown()`.
- [ ] **Step 1: Write the failing test** (mock `shared/state`, the established component-test pattern)
```ts
import { renderOrgSwitcher } from '../org-switcher';
import * as state from '../../../shared/state';
test('switching to an org sends org_switch and reloads the list', async () => {
const send = vi.spyOn(state, 'sendMessage').mockImplementation(async (req: any) => {
if (req.type === 'org_list_configs') return { ok: true, data: [{ orgId: 'org-1', displayName: 'Acme' }] };
if (req.type === 'org_switch') return { ok: true, data: { context: 'org-1', offline: false } };
return { ok: true, data: [] };
});
const nav = vi.spyOn(state, 'navigate').mockImplementation(() => {});
const host = document.createElement('div');
await renderOrgSwitcher(host);
const sel = host.querySelector('select') as HTMLSelectElement;
sel.value = 'org-1'; sel.dispatchEvent(new Event('change'));
await Promise.resolve();
expect(send).toHaveBeenCalledWith({ type: 'org_switch', context: 'org-1' });
expect(nav).toHaveBeenCalledWith('list', expect.anything());
});
```
- [ ] **Step 2: Run to verify it fails**
Run: `cd extension && npx vitest run src/popup/components/__tests__/org-switcher.test.ts`
Expected: FAIL — `org-switcher` missing.
- [ ] **Step 3: Implement** — fetch `org_list_configs`, render `<select>` (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.

View File

@@ -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 25 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/<slug>/<id>.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/<slug>/<id>.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 <sha>` on the server AND is accepted by `relicario-server verify-org-commit`. Repeat Steps 24 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 — <GO|NO-GO> 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<void>` 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/<slug>/<id>.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 `<select>` (from `orgCollections`) to the add form, and gate edit/delete/add on `!orgOffline`.
- [ ] **Step 4: Run to verify it passes**
Run: `cd extension && npx vitest run src/popup/components/`
Expected: PASS.
- [ ] **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/item-form.ts extension/src/popup/components/__tests__/item-form.test.ts
git commit -m "feat(ext): org write UI — collection picker, edit/delete, offline-gated"
```
---
### Task 5: Org write acceptance tests
**Files:**
- Create/extend: `extension/src/service-worker/__tests__/org-write.test.ts`
- [ ] **Step 1: Write the acceptance tests** — (a) a write produces a `commitSigned` call whose commit the org hook contract would accept (assert a signature is attached); (b) an ungranted-collection write is refused client-side; (c) every org write touches BOTH the item path and `manifest.enc`; (d) offline context blocks writes before any network call.
- [ ] **Step 2: Run them**
Run: `cd extension && npx vitest run src/service-worker/__tests__/org-write.test.ts`
Expected: PASS.
- [ ] **Step 3: Full suite + commit**
Run: `cd extension && npx vitest run && npm run build:all`
```bash
git add extension/src/service-worker/__tests__/org-write.test.ts
git commit -m "test(ext/sw): org write acceptance — signature, grants, dual-write, offline"
```
---
## Notes
- The spike (Task 1) is the project risk concentrated into one day. Treat a NO-GO as a legitimate, value-preserving outcome: org read (Plans 1+2) + the entire key-file lift (Plans 4+5) still ship in v0.9.0.
- Committer identity: the org hook attributes the audit entry to the verified signing key; make sure the commit's committer/author and the signature key are consistent with the member's `members.json` record (the spike must confirm the hook's matching rule).