From 8c19e3cfda88bd46d137f0ff82e9a5dd13512f20 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Fri, 19 Jun 2026 19:22:09 -0400 Subject: [PATCH] =?UTF-8?q?docs(plan):=20rewrite=20org-vault=20plan=20per?= =?UTF-8?q?=20review=20=E2=80=94=2025=20tasks,=204=20streams?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Corrects every critical/high finding from the adversarial review and adds the two scope expansions (full item CRUD + extension parity): - Device-key helpers built on the real devices//signing.{key,pub} layout + ssh-key CLI dep (was: invented ~/.config/relicario/device.key) - Signature-verifying pre-receive hook on every commit + path-scoped write authz via items//.enc (was: bare %GF, unenforceable flat items) - Org item CRUD (add/get/list/edit/rm/restore/purge), collection-scoped - Audit attributed to verified signer + TAMPERED flag (was: spoofable trailers) - rotate-key re-encrypts every item blob (was: manifest only) - Zeroize KDF intermediates; fix ssh_key::PrivateKey::from test helpers - Owner-only role-gating; fingerprint-based member matching; %x1e/%x1f audit parser framing; signed org commits via org_git_run - Extension stream (WASM bindings + SW org session + switcher + 3 vitest tests) - Stream-prefixed task IDs (A/B/C/D) with explicit cross-stream deps - Living-docs task Co-Authored-By: Claude Opus 4.8 (1M context) --- .../plans/2026-06-06-enterprise-org-vault.md | 6318 ++++++++++++----- 1 file changed, 4678 insertions(+), 1640 deletions(-) diff --git a/docs/superpowers/plans/2026-06-06-enterprise-org-vault.md b/docs/superpowers/plans/2026-06-06-enterprise-org-vault.md index 972fc27..0552715 100644 --- a/docs/superpowers/plans/2026-06-06-enterprise-org-vault.md +++ b/docs/superpowers/plans/2026-06-06-enterprise-org-vault.md @@ -2,17 +2,22 @@ > **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:** Implement git-native multi-user org vaults for security-conscious self-hosting shops — per-user repos + a shared org repo, X25519-wrapped org master key per member, collections + role-based access, and a structured git-trailer audit trail. +**Goal:** Implement git-native multi-user org vaults for security-conscious self-hosting shops — per-user repos + a shared org repo, X25519-wrapped org master key per member, **collection-scoped item storage**, role-based access, full **org item CRUD** (`add`/`get`/`list`/`edit`/`rm`/`restore`/`purge`), a **signature-verifying** pre-receive hook with path-scoped write authorization, a tamper-evident structured-git-trailer audit trail attributed to the **verified signer**, and **extension parity** (org switcher + read-only browse + the spec's vitest acceptance tests). -**Architecture:** The org repo is a separate git repository with a defined schema (`org.json`, `members.json`, `collections.json`, `keys/.enc`, `manifest.enc`, `items/*.enc`). Each member holds a copy of the 256-bit org master key wrapped (ECIES/X25519 + XChaCha20-Poly1305) to their existing ed25519 device key. All org management is CLI-only in this plan; extension integration is Plan B. The pre-receive hook in `relicario-server` gains an `org` mode enforcing role-based path authorization. +**Architecture:** The org repo is a separate git repository with a defined schema (`org.json`, `members.json`, `collections.json`, `keys/.enc`, `manifest.enc`, and **collection-scoped** items at `items//.enc`). Each member holds a copy of the 256-bit org master key wrapped (ECIES/X25519 + XChaCha20-Poly1305) to their existing ed25519 device key. Two enforcement boundaries work together: **(1) cryptographic** — only a wrapped-key holder can unwrap the org master key, and rotation re-encrypts every item blob under a fresh key; **(2) the git pre-receive hook** — every commit is **signature-verified** against `members.json` (signer resolved by ed25519 fingerprint), and writes are authorized by role (for management files) or by **collection path segment** (for item files). Item storage is collection-scoped precisely so the hook can authorize an item write by its leading path segment *without decrypting anything*. All org commits are **signed**; `org init` configures git signing and org commits route through a non-hardened `org_git_run` (the standard `helpers::git_run` force-disables signing). Both the CLI and the extension consume the same `relicario-core::org` module; the extension ships org switch + read in this phase (writes are a tracked follow-up). -**Tech Stack:** Rust, `x25519-dalek 2`, `ed25519-dalek 2`, `sha2`, `chacha20poly1305 0.10`, `ssh-key 0.6`, `serde_json`, `clap`, `anyhow`, `zeroize`, `tempfile` (tests). +**Tech Stack:** Rust, `x25519-dalek 2` (new, in `relicario-core`), `ed25519-dalek 2`, `sha2`, `chacha20poly1305 0.10`, `ssh-key 0.6` (new, in `relicario-cli`), `regex`, `tempfile`, `serde_json`, `clap`, `anyhow`, `zeroize`; WASM bindings (`relicario-wasm`, `base64`) + extension TypeScript with **vitest** for the parity acceptance tests. **Multi-stream assignment for PM:** -- **Dev-A** — Tasks 1–4 (relicario-core org module). No dependencies. -- **Dev-B** — Tasks 5–13 (relicario-cli org commands). Depends on Dev-A completing. -- **Dev-C** — Task 14 (relicario-server hook extension). Depends on Dev-A completing. -- **Integration** — Task 15. Depends on Dev-B and Dev-C completing. + +| Stream | Tasks | Scope | Depends on | +|---|---|---|---| +| **Dev-A** | A1–A4 (+ **A5** docs, final) | `relicario-core` org module: deps, types, crypto, vault wrappers, integration tests; then the living-docs sweep | A5 depends on all streams merged | +| **Dev-B** | B1–B14 | `relicario-cli`: session, device helpers, admin commands, item CRUD, wiring | **A2–A3 merged first** (imports `relicario_core::org`); B9–B13 depend on B1; B14 depends on the command fns existing | +| **Dev-C** | C1–C2 | `relicario-server` pre-receive hook: signature + path-scoped authz, schema monotonicity | **A2–A3 merged first** (imports `relicario_core::org`) | +| **Dev-D** | D1–D4 | Extension parity: WASM org bindings, SW org session/handlers, vault-tab switcher, vitest tests | **A2–A3 merged first** (bindings call `relicario_core::{unwrap_org_key, decrypt_org_manifest, decrypt_item}`); independent of Dev-B/Dev-C | + +> **Hard dependency note:** Dev-B, Dev-C, and Dev-D all import `relicario_core::org` types/functions, so **Tasks A2–A3 must be MERGED before those streams begin**. Within Dev-B, the item-CRUD tasks **B9–B13 depend on B1** (the collection-scoped `item_path` + session helpers), and **B14 depends on every `run_*` command fn existing** (it wires them into `main.rs`). A5 is the final living-docs task and runs after all code streams merge. --- @@ -20,20 +25,38 @@ | Action | Path | Responsibility | |---|---|---| -| Create | `crates/relicario-core/src/org.rs` | Org types + crypto (IDs, members, collections, key wrap/unwrap) | +| Modify | `crates/relicario-core/Cargo.toml` | Add `x25519-dalek = "2"` | +| Create | `crates/relicario-core/src/org.rs` | Org types + crypto (IDs, members, collections, key wrap/unwrap; KDF intermediates in `Zeroizing`) | | Modify | `crates/relicario-core/src/vault.rs` | Add `encrypt_org_manifest` / `decrypt_org_manifest` | | Modify | `crates/relicario-core/src/lib.rs` | `pub mod org` + re-exports | -| Modify | `crates/relicario-core/Cargo.toml` | Add `x25519-dalek = "2"` | | Create | `crates/relicario-core/tests/org.rs` | Integration tests for org crypto | -| Create | `crates/relicario-cli/src/org_session.rs` | `UnlockedOrgVault` session type | -| Create | `crates/relicario-cli/src/commands/org.rs` | All `relicario org` subcommands | +| Modify | `crates/relicario-cli/Cargo.toml` | Add `ssh-key = "0.6"`; ensure `regex = "1"` + `tempfile = "3"` in `[dependencies]`; `ed25519-dalek` dev-dep | +| Create | `crates/relicario-cli/src/org_session.rs` | `UnlockedOrgVault` session type (collection-scoped `item_path`, item I/O helpers, fingerprint member match, signed `org_git_run`) | +| Modify | `crates/relicario-cli/src/device.rs` | `current_device_seed` / `current_device_pubkey` helpers | +| Create | `crates/relicario-cli/src/commands/org.rs` | All `relicario org` subcommands — admin **and** item commands | | Modify | `crates/relicario-cli/src/commands/mod.rs` | `pub mod org` | -| Modify | `crates/relicario-cli/src/main.rs` | `Commands::Org` arm | -| Modify | `crates/relicario-server/src/main.rs` | `verify-org-commit` subcommand | +| Modify | `crates/relicario-cli/src/main.rs` | `Commands::Org` arm + `OrgCommands` (admin + item subcommands) | +| Modify | `crates/relicario-server/Cargo.toml` | Ensure `tempfile` in `[dependencies]`; add `[lib]` + `[[bin]]` | +| Create | `crates/relicario-server/src/lib.rs` | Pure path-classification + schema-version helpers (`classify_path`, `PathClass`, `extract_schema_version`) | +| Modify | `crates/relicario-server/src/main.rs` | `verify-org-commit` (signature + path/role authz + schema monotonicity) + `generate-org-hook` | +| Create | `crates/relicario-server/tests/org_hook.rs` | Hook path-classification + schema-version tests | +| Modify | `crates/relicario-wasm/src/lib.rs` | `org_open` / `org_manifest_decrypt` / `org_item_decrypt` bindings | +| Modify | `crates/relicario-wasm/Cargo.toml` | `base64` (if absent) | +| Regenerate | `extension/wasm/relicario_wasm.{js,d.ts}`, `relicario_wasm_bg.wasm` | wasm-pack output consumed by the extension | +| Create | `extension/src/service-worker/org.ts` | SW org session + read ops | +| Modify | `extension/src/service-worker/index.ts` | Wire `org.setWasm`; clear org session on expiry | +| Modify | `extension/src/service-worker/router/popup-only.ts` | 4 org handler arms | +| Modify | `extension/src/shared/messages.ts` | Org message types + responses + capability-set entries | +| Create | `extension/src/vault/vault-org-switcher.ts` | Vault-tab org context switcher + read-only banner | +| Modify | `extension/src/vault/vault-sidebar.ts`, `vault-context.ts`, `vault.ts`, `vault.css` | Mount switcher; `activeOrgId` state; styling | +| Create | `extension/src/service-worker/__tests__/org.test.ts` | 3 spec-mandated vitest tests | +| Modify | living docs (see **A5**) | FORMATS, CRYPTO, DESIGN, SECURITY, core/cli/ext ARCHITECTURE, STATUS, ROADMAP | --- -## [Dev-A] Task 1: Add x25519-dalek and stub org module +## Dev-A — relicario-core org module + +## [Dev-A] Task A1: Add x25519-dalek and stub org module **Files:** - Modify: `crates/relicario-core/Cargo.toml` @@ -58,34 +81,33 @@ Create `crates/relicario-core/src/org.rs` with just a module-level comment: - [ ] **Step 3: Wire into lib.rs** -In `crates/relicario-core/src/lib.rs`, add after the `device` module block: +In `crates/relicario-core/src/lib.rs`, add after the `device` module block. Wire the `pub mod org;` line only, no `pub use` yet (the re-exports would fail until Task A2 defines the symbols): ```rust pub mod org; -pub use org::{ - CollectionDef, MemberId, OrgCollections, OrgId, OrgManifest, OrgManifestEntry, - OrgMember, OrgMembers, OrgMeta, OrgRole, - generate_org_key, wrap_org_key, unwrap_org_key, -}; ``` +Then add the `pub use` items in Task A2 once the symbols exist. + - [ ] **Step 4: Verify it compiles** ```bash cargo check -p relicario-core ``` -Expected: compiles (stub is empty, re-exports will fail — that's fine until Task 2 defines them). +Expected: compiles (stub is empty; only `pub mod org;` is wired). -Actually at this step the re-exports will fail. Wire the `pub mod org;` line only, no `pub use` yet: +- [ ] **Step 5: Verify the WASM target still builds** -```rust -pub mod org; +`x25519-dalek` lands in the core that `relicario-wasm` compiles, so build the WASM target now to catch any feature-unification regression early: + +```bash +cargo build -p relicario-wasm --target wasm32-unknown-unknown 2>&1 | tail -10 ``` -Then add the `pub use` items incrementally as each Task defines the symbols. +Expected: clean build. -- [ ] **Step 5: Commit** +- [ ] **Step 6: Commit** ```bash git add crates/relicario-core/Cargo.toml crates/relicario-core/src/org.rs crates/relicario-core/src/lib.rs @@ -94,10 +116,13 @@ git commit -m "feat(core/org): add x25519-dalek dep + stub org module" --- -## [Dev-A] Task 2: Org types — IDs, members, collections, org meta +## [Dev-A] Task A2: Org types — IDs, members, collections, org meta, ECIES wrap/unwrap **Files:** - Modify: `crates/relicario-core/src/org.rs` +- Modify: `crates/relicario-core/src/lib.rs` + +This task implements all org types plus the ECIES key-wrap/unwrap. Two corrections from the adversarial review are folded in: **H6** (all KDF intermediates carrying the DH secret are held in `Zeroizing`) and **H7** (the test keypair helper uses `PrivateKey::from(Ed25519Keypair::from(&signing_key))` — `ssh-key` 0.6.7 has no `From for PrivateKey`). - [ ] **Step 1: Write failing test for MemberId format** @@ -137,7 +162,7 @@ cargo test -p relicario-core org::tests::member_id_is_16_hex_chars 2>&1 | tail - Expected: FAIL — `MemberId` not defined. -- [ ] **Step 2: Implement all org types** +- [ ] **Step 2: Implement all org types + ECIES wrap/unwrap (with H6 Zeroizing folded in)** Replace the stub `org.rs` with: @@ -417,14 +442,19 @@ pub fn wrap_org_key(org_key: &Zeroizing<[u8; 32]>, recipient_openssh_pubkey: &st let shared = ephemeral_sk.diffie_hellman(&recipient_pk); - // Domain-separated KDF - let mut kdf_input = Vec::with_capacity(32 + 32 + 32); + // Domain-separated KDF. All intermediates carrying the DH secret are held in + // Zeroizing so they are wiped on drop (H6). + let mut kdf_input: Zeroizing> = Zeroizing::new(Vec::with_capacity(32 + 32 + 32)); kdf_input.extend_from_slice(shared.as_bytes()); kdf_input.extend_from_slice(ephemeral_pk.as_bytes()); kdf_input.extend_from_slice(recipient_pk.as_bytes()); - let wrap_key_hash = Sha256::digest(&kdf_input); - let mut wrap_key = [0u8; 32]; - wrap_key.copy_from_slice(&wrap_key_hash); + + // Copy the digest straight into a Zeroizing array. The GenericArray returned + // by Sha256::digest is not Zeroize (generic-array's impl is feature-gated and + // not enabled here), so we move the bytes into an owned [u8; 32] whose own + // Zeroize impl wipes them on drop. + let mut wrap_key: Zeroizing<[u8; 32]> = Zeroizing::new([0u8; 32]); + wrap_key.copy_from_slice(&Sha256::digest(kdf_input.as_slice())); let encrypted = crate::crypto::encrypt(&wrap_key, org_key.as_ref())?; @@ -453,13 +483,13 @@ pub fn unwrap_org_key(wrapped: &[u8], ed25519_seed: &Zeroizing<[u8; 32]>) -> Res let shared = recipient_sk.diffie_hellman(&ephemeral_pk); - let mut kdf_input = Vec::with_capacity(32 + 32 + 32); + let mut kdf_input: Zeroizing> = Zeroizing::new(Vec::with_capacity(32 + 32 + 32)); kdf_input.extend_from_slice(shared.as_bytes()); kdf_input.extend_from_slice(ephemeral_pk.as_bytes()); kdf_input.extend_from_slice(recipient_pk.as_bytes()); - let wrap_key_hash = Sha256::digest(&kdf_input); - let mut wrap_key = [0u8; 32]; - wrap_key.copy_from_slice(&wrap_key_hash); + + let mut wrap_key: Zeroizing<[u8; 32]> = Zeroizing::new([0u8; 32]); + wrap_key.copy_from_slice(&Sha256::digest(kdf_input.as_slice())); let plaintext = Zeroizing::new(crate::crypto::decrypt(&wrap_key, encrypted)?); if plaintext.len() != 32 { @@ -570,10 +600,12 @@ mod tests { let mut seed = [0u8; 32]; OsRng.fill_bytes(&mut seed); let signing_key = SigningKey::from_bytes(&seed); - let pubkey_openssh = ssh_key::PrivateKey::from(signing_key.clone()) - .public_key() - .to_openssh() - .expect("openssh"); + let pubkey_openssh = ssh_key::PrivateKey::from( + ssh_key::private::Ed25519Keypair::from(&signing_key), + ) + .public_key() + .to_openssh() + .expect("openssh"); let org_key = generate_org_key(); let wrapped = wrap_org_key(&org_key, &pubkey_openssh).expect("wrap"); @@ -589,10 +621,12 @@ mod tests { let mut seed = [0u8; 32]; OsRng.fill_bytes(&mut seed); let signing_key = SigningKey::from_bytes(&seed); - let pubkey_openssh = ssh_key::PrivateKey::from(signing_key) - .public_key() - .to_openssh() - .expect("openssh"); + let pubkey_openssh = ssh_key::PrivateKey::from( + ssh_key::private::Ed25519Keypair::from(&signing_key), + ) + .public_key() + .to_openssh() + .expect("openssh"); let org_key = generate_org_key(); let wrapped = wrap_org_key(&org_key, &pubkey_openssh).expect("wrap"); @@ -614,7 +648,7 @@ Expected: all org tests pass. - [ ] **Step 4: Update lib.rs re-exports** -Add to `crates/relicario-core/src/lib.rs` (replace the `pub mod org;` stub line): +Replace the `pub mod org;` stub line in `crates/relicario-core/src/lib.rs` with: ```rust pub mod org; @@ -635,15 +669,16 @@ Expected: clean compile. ```bash git add crates/relicario-core/src/org.rs crates/relicario-core/src/lib.rs -git commit -m "feat(core/org): org types, manifest, and X25519 key wrap/unwrap" +git commit -m "feat(core/org): org types, manifest, and X25519 key wrap/unwrap (Zeroizing KDF)" ``` --- -## [Dev-A] Task 3: Org manifest vault wrappers +## [Dev-A] Task A3: Org manifest vault wrappers **Files:** - Modify: `crates/relicario-core/src/vault.rs` +- Modify: `crates/relicario-core/src/lib.rs` - [ ] **Step 1: Write failing tests** @@ -714,11 +749,6 @@ pub use vault::{ ```bash cargo test -p relicario-core vault::tests::org_manifest_round_trip -``` - -Expected: PASS. - -```bash cargo test -p relicario-core ``` @@ -733,1545 +763,14 @@ git commit -m "feat(core/org): encrypt/decrypt_org_manifest vault wrappers" --- -## [Dev-B] Task 4: UnlockedOrgVault session type - -**Files:** -- Create: `crates/relicario-cli/src/org_session.rs` - -- [ ] **Step 1: Write failing test** - -Create `crates/relicario-cli/src/org_session.rs` with just the test: - -```rust -#[cfg(test)] -mod tests { - use super::*; - use tempfile::TempDir; - use std::fs; - - fn make_org_dir() -> TempDir { - let dir = TempDir::new().unwrap(); - let root = dir.path(); - fs::create_dir_all(root.join("items")).unwrap(); - fs::create_dir_all(root.join("keys")).unwrap(); - dir - } - - #[test] - fn unlocked_org_vault_paths() { - let dir = make_org_dir(); - let root = dir.path().to_path_buf(); - let key = zeroize::Zeroizing::new([0u8; 32]); - let vault = UnlockedOrgVault { root: root.clone(), org_key: key }; - - assert_eq!(vault.manifest_path(), root.join("manifest.enc")); - assert_eq!(vault.member_key_path(&relicario_core::MemberId("abc0def1abc0def1".into())), - root.join("keys/abc0def1abc0def1.enc")); - } -} -``` - -```bash -cargo test -p relicario-cli org_session 2>&1 | tail -5 -``` - -Expected: FAIL — `UnlockedOrgVault` not defined. - -- [ ] **Step 2: Implement UnlockedOrgVault** - -```rust -//! Unlocked org vault session: holds the org master key for the duration of a -//! CLI invocation. - -use std::fs; -use std::path::{Path, PathBuf}; - -use anyhow::{bail, Context, Result}; -use zeroize::Zeroizing; - -use relicario_core::{ - decrypt_org_manifest, encrypt_org_manifest, - MemberId, OrgCollections, OrgManifest, OrgMembers, OrgMeta, -}; - -pub struct UnlockedOrgVault { - pub root: PathBuf, - pub org_key: Zeroizing<[u8; 32]>, -} - -impl UnlockedOrgVault { - pub fn root(&self) -> &Path { &self.root } - pub fn key(&self) -> &Zeroizing<[u8; 32]> { &self.org_key } - - pub fn manifest_path(&self) -> PathBuf { self.root.join("manifest.enc") } - pub fn item_path(&self, id: &relicario_core::ItemId) -> PathBuf { - self.root.join("items").join(format!("{}.enc", id.as_str())) - } - pub fn member_key_path(&self, id: &MemberId) -> PathBuf { - self.root.join("keys").join(format!("{}.enc", id.as_str())) - } - pub fn members_path(&self) -> PathBuf { self.root.join("members.json") } - pub fn collections_path(&self) -> PathBuf { self.root.join("collections.json") } - pub fn org_meta_path(&self) -> PathBuf { self.root.join("org.json") } - - pub fn load_meta(&self) -> Result { - let s = fs::read_to_string(self.org_meta_path()).context("read org.json")?; - Ok(serde_json::from_str(&s).context("parse org.json")?) - } - - pub fn load_members(&self) -> Result { - let s = fs::read_to_string(self.members_path()).context("read members.json")?; - Ok(serde_json::from_str(&s).context("parse members.json")?) - } - - pub fn save_members(&self, members: &OrgMembers) -> Result<()> { - let json = serde_json::to_string_pretty(members)?; - atomic_write(&self.members_path(), json.as_bytes()) - } - - pub fn load_collections(&self) -> Result { - let s = fs::read_to_string(self.collections_path()).context("read collections.json")?; - Ok(serde_json::from_str(&s).context("parse collections.json")?) - } - - pub fn save_collections(&self, collections: &OrgCollections) -> Result<()> { - let json = serde_json::to_string_pretty(collections)?; - atomic_write(&self.collections_path(), json.as_bytes()) - } - - pub fn load_manifest(&self) -> Result { - let bytes = fs::read(self.manifest_path()).context("read manifest.enc")?; - Ok(decrypt_org_manifest(&bytes, &self.org_key)?) - } - - pub fn save_manifest(&self, manifest: &OrgManifest) -> Result<()> { - let bytes = encrypt_org_manifest(manifest, &self.org_key)?; - atomic_write(&self.manifest_path(), &bytes) - } - - /// Load members.json, find the caller's member entry by matching their device - /// pubkey against all member pubkeys. Returns the matching member or bails. - pub fn current_member(&self) -> Result { - let device_pubkey = crate::device::current_device_pubkey()?; - let members = self.load_members()?; - members.members.into_iter() - .find(|m| m.ed25519_pubkey.trim() == device_pubkey.trim()) - .ok_or_else(|| anyhow::anyhow!( - "your device key is not registered in this org — ask an admin to run `org add-member`" - )) - } -} - -/// Locate the org vault root from RELICARIO_ORG_DIR env var or --dir flag value. -pub fn org_dir(dir_flag: Option<&std::path::Path>) -> Result { - if let Some(d) = dir_flag { - return Ok(d.to_path_buf()); - } - if let Ok(v) = std::env::var("RELICARIO_ORG_DIR") { - return Ok(PathBuf::from(v)); - } - bail!("org vault location required: set RELICARIO_ORG_DIR or pass --dir ") -} - -/// Open an org vault: locate the root, read members.json to find the caller's -/// member entry, unwrap their keys/.enc to get the org master key. -pub fn open_org_vault(dir_flag: Option<&std::path::Path>) -> Result { - let root = org_dir(dir_flag)?; - - // Find caller's member entry by device pubkey - let device_pubkey = crate::device::current_device_pubkey()?; - let members_json = fs::read_to_string(root.join("members.json")) - .context("read members.json — is this an org vault?")?; - let members: OrgMembers = serde_json::from_str(&members_json).context("parse members.json")?; - let member = members.members.iter() - .find(|m| m.ed25519_pubkey.trim() == device_pubkey.trim()) - .ok_or_else(|| anyhow::anyhow!("your device key is not in this org"))?; - - // Load this member's wrapped key blob - let key_path = root.join("keys").join(format!("{}.enc", member.member_id.as_str())); - let wrapped = fs::read(&key_path) - .with_context(|| format!("read {}", key_path.display()))?; - - // Get device seed to unwrap - let seed = crate::device::current_device_seed()?; - let org_key = relicario_core::unwrap_org_key(&wrapped, &seed)?; - - Ok(UnlockedOrgVault { root, org_key }) -} - -pub(crate) fn atomic_write(path: &Path, data: &[u8]) -> Result<()> { - let mut tmp = path.as_os_str().to_owned(); - tmp.push(".tmp"); - let tmp = PathBuf::from(tmp); - fs::write(&tmp, data).with_context(|| format!("write {}", tmp.display()))?; - fs::rename(&tmp, path).with_context(|| format!("rename {}", tmp.display()))?; - Ok(()) -} - -pub(crate) fn org_git_run(root: &Path, args: &[&str], context: &str) -> Result<()> { - crate::helpers::git_run(root, args, context) -} - -#[cfg(test)] -mod tests { - use super::*; - use tempfile::TempDir; - use std::fs; - - fn make_vault(key: Zeroizing<[u8; 32]>) -> (TempDir, UnlockedOrgVault) { - let dir = TempDir::new().unwrap(); - let root = dir.path().to_path_buf(); - fs::create_dir_all(root.join("items")).unwrap(); - fs::create_dir_all(root.join("keys")).unwrap(); - let vault = UnlockedOrgVault { root, org_key: key }; - (dir, vault) - } - - #[test] - fn unlocked_org_vault_paths() { - let key = Zeroizing::new([0u8; 32]); - let (dir, vault) = make_vault(key); - let root = dir.path().to_path_buf(); - assert_eq!(vault.manifest_path(), root.join("manifest.enc")); - assert_eq!( - vault.member_key_path(&MemberId("abc0def1abc0def1".into())), - root.join("keys/abc0def1abc0def1.enc") - ); - } - - #[test] - fn save_and_load_manifest() { - let key = Zeroizing::new([0xAAu8; 32]); - let (dir, vault) = make_vault(key); - let _ = dir; // keep alive - let mut m = OrgManifest::new(); - m.entries.push(relicario_core::OrgManifestEntry { - id: relicario_core::ItemId::new(), - r#type: relicario_core::ItemType::SecureNote, - title: "test".into(), - tags: vec![], - modified: 0, - trashed_at: None, - collection: "prod".into(), - }); - vault.save_manifest(&m).unwrap(); - let loaded = vault.load_manifest().unwrap(); - assert_eq!(loaded.entries.len(), 1); - } - - #[test] - fn save_and_load_members() { - let key = Zeroizing::new([0u8; 32]); - let (dir, vault) = make_vault(key); - let root = dir.path().to_path_buf(); - // need members.json location - let _ = root; - let members = OrgMembers::new(); - vault.save_members(&members).unwrap(); - let loaded = vault.load_members().unwrap(); - assert_eq!(loaded.schema_version, 1); - } -} -``` - -- [ ] **Step 3: Wire into main.rs module declarations** - -In `crates/relicario-cli/src/main.rs`, add after the existing `mod session;` line: - -```rust -mod org_session; -``` - -- [ ] **Step 4: Run tests** - -```bash -cargo test -p relicario-cli org_session 2>&1 | tail -20 -``` - -Expected: all org_session tests pass. - -- [ ] **Step 5: Commit** - -```bash -git add crates/relicario-cli/src/org_session.rs crates/relicario-cli/src/main.rs -git commit -m "feat(cli/org): UnlockedOrgVault session type" -``` - -> **Note:** `current_device_pubkey()` and `current_device_seed()` are referenced above. These need to be added to `crates/relicario-cli/src/device.rs` in Task 5. If `device.rs` already has a way to get the current device pubkey, use that. Otherwise, implement them in Task 5. - ---- - -## [Dev-B] Task 5: Device seed/pubkey helpers + org commands module stub - -**Files:** -- Modify: `crates/relicario-cli/src/device.rs` (or wherever device helpers live) -- Create: `crates/relicario-cli/src/commands/org.rs` (stub) -- Modify: `crates/relicario-cli/src/commands/mod.rs` - -- [ ] **Step 1: Check existing device module** - -```bash -grep -n "pubkey\|seed\|signing_key\|device" crates/relicario-cli/src/device.rs | head -30 -``` - -Look for existing functions that expose the device's ed25519 signing key or public key. If `current_device_pubkey()` already exists in some form, adapt. If not, proceed to Step 2. - -- [ ] **Step 2: Add device seed + pubkey helpers** - -In `crates/relicario-cli/src/device.rs` (or create it if the file doesn't exist with these helpers), add: - -```rust -/// Read the current device's ed25519 seed from the device key file. -/// The key file location is RELICARIO_DEVICE_KEY env var or ~/.config/relicario/device.key. -pub fn current_device_seed() -> anyhow::Result> { - let path = device_key_path()?; - let pem = std::fs::read_to_string(&path) - .with_context(|| format!("read device key {}", path.display()))?; - let private_key = ssh_key::PrivateKey::from_openssh(&pem) - .map_err(|e| anyhow::anyhow!("parse device key: {e}"))?; - let ed = private_key.key_data().ed25519() - .ok_or_else(|| anyhow::anyhow!("device key is not ed25519"))?; - let seed_bytes = ed.private.as_ref(); - if seed_bytes.len() != 32 { - anyhow::bail!("ed25519 seed has wrong length: {}", seed_bytes.len()); - } - let mut seed = zeroize::Zeroizing::new([0u8; 32]); - seed.copy_from_slice(seed_bytes); - Ok(seed) -} - -/// Read the current device's ed25519 public key in OpenSSH format. -pub fn current_device_pubkey() -> anyhow::Result { - let path = device_key_path()?; - let pem = std::fs::read_to_string(&path) - .with_context(|| format!("read device key {}", path.display()))?; - let private_key = ssh_key::PrivateKey::from_openssh(&pem) - .map_err(|e| anyhow::anyhow!("parse device key: {e}"))?; - Ok(private_key.public_key().to_openssh() - .map_err(|e| anyhow::anyhow!("serialize pubkey: {e}"))?) -} - -fn device_key_path() -> anyhow::Result { - if let Ok(p) = std::env::var("RELICARIO_DEVICE_KEY") { - return Ok(std::path::PathBuf::from(p)); - } - let home = std::env::var("HOME") - .map_err(|_| anyhow::anyhow!("HOME not set"))?; - Ok(std::path::PathBuf::from(home) - .join(".config/relicario/device.key")) -} -``` - -> **Note:** Check how the existing `device.rs` in `relicario-cli` generates/reads device keys. Adapt the path logic to match. The existing `crates/relicario-cli/src/device.rs` may already have a `device_key_path()` — don't duplicate it, just add the two public helpers if absent. - -- [ ] **Step 3: Create org commands stub** - -Create `crates/relicario-cli/src/commands/org.rs`: - -```rust -//! `relicario org` subcommands for multi-user org vault management. - -use anyhow::Result; - -pub fn run_init(_dir: &std::path::Path, _name: &str) -> Result<()> { - todo!("org init") -} -``` - -Add to `crates/relicario-cli/src/commands/mod.rs`: - -```rust -pub mod org; -``` - -- [ ] **Step 4: Verify compile** - -```bash -cargo check -p relicario-cli -``` - -Expected: clean (todo! is fine at compile time). - -- [ ] **Step 5: Commit** - -```bash -git add crates/relicario-cli/src/device.rs crates/relicario-cli/src/commands/org.rs crates/relicario-cli/src/commands/mod.rs -git commit -m "feat(cli/org): device seed/pubkey helpers + org commands stub" -``` - ---- - -## [Dev-B] Task 6: org init command - -**Files:** -- Modify: `crates/relicario-cli/src/commands/org.rs` - -`org init` creates the org directory structure, generates the org master key, wraps it to the caller's device key, writes all initial files, runs `git init` + first commit. - -- [ ] **Step 1: Write failing integration test** - -Create `crates/relicario-cli/tests/org_init.rs`: - -```rust -use std::fs; -use tempfile::TempDir; - -fn run(args: &[&str]) -> std::process::Output { - std::process::Command::new(env!("CARGO_BIN_EXE_relicario")) - .args(args) - .output() - .expect("run relicario") -} - -#[test] -#[ignore] // requires a device key on disk; run manually -fn org_init_creates_expected_files() { - let dir = TempDir::new().unwrap(); - let path = dir.path().to_str().unwrap(); - let out = run(&["org", "init", "--dir", path, "--name", "Test Org"]); - assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr)); - assert!(dir.path().join("org.json").exists()); - assert!(dir.path().join("members.json").exists()); - assert!(dir.path().join("collections.json").exists()); - assert!(dir.path().join("manifest.enc").exists()); - assert!(dir.path().join(".git").exists()); -} -``` - -```bash -cargo test -p relicario-cli --test org_init 2>&1 | tail -5 -``` - -Expected: test compiles and is skipped (ignored). - -- [ ] **Step 2: Implement org init** - -Replace `run_init` stub in `commands/org.rs`: - -```rust -use std::fs; -use std::path::Path; -use anyhow::{Context, Result}; -use relicario_core::{ - generate_org_key, wrap_org_key, - CollectionDef, MemberId, OrgCollections, OrgManifest, OrgMembers, OrgMeta, OrgRole, OrgMember, - encrypt_org_manifest, -}; -use crate::org_session::atomic_write; - -pub fn run_init(dir: &Path, name: &str) -> Result<()> { - // Create directory structure - fs::create_dir_all(dir.join("items")).context("create items/")?; - fs::create_dir_all(dir.join("keys")).context("create keys/")?; - - // Get caller's device info - let device_pubkey = crate::device::current_device_pubkey() - .context("read device key — run `relicario device add` first")?; - - // Generate org master key - let org_key = generate_org_key(); - - // Wrap org key to caller's device key - let wrapped = wrap_org_key(&org_key, &device_pubkey) - .context("wrap org key to device key")?; - - // Create initial members.json with caller as owner - let caller_id = MemberId::new(); - let now = relicario_core::now_unix(); - let member = OrgMember { - member_id: caller_id.clone(), - display_name: whoami(), - role: OrgRole::Owner, - ed25519_pubkey: device_pubkey, - collections: vec![], - added_at: now, - added_by: caller_id.clone(), - }; - let mut members = OrgMembers::new(); - members.members.push(member); - - // Write wrapped key - let key_path = dir.join("keys").join(format!("{}.enc", caller_id.as_str())); - fs::write(&key_path, &wrapped).context("write caller key blob")?; - - // Write org.json - let meta = OrgMeta::new(name.to_string()); - let meta_json = serde_json::to_string_pretty(&meta)?; - atomic_write(&dir.join("org.json"), meta_json.as_bytes())?; - - // Write members.json - let members_json = serde_json::to_string_pretty(&members)?; - atomic_write(&dir.join("members.json"), members_json.as_bytes())?; - - // Write collections.json (empty) - let collections = OrgCollections::new(); - let coll_json = serde_json::to_string_pretty(&collections)?; - atomic_write(&dir.join("collections.json"), coll_json.as_bytes())?; - - // Write empty manifest.enc - let manifest = OrgManifest::new(); - let manifest_bytes = encrypt_org_manifest(&manifest, &org_key)?; - atomic_write(&dir.join("manifest.enc"), &manifest_bytes)?; - - // git init + initial commit - crate::helpers::git_run(dir, &["init"], "git init")?; - crate::helpers::git_run(dir, &["add", "."], "git add")?; - let commit_msg = format!( - "init: org vault \"{name}\"\n\nRelicario-Actor: {} <{}>\nRelicario-Action: org-init", - members.members[0].display_name, - caller_id.as_str() - ); - crate::helpers::git_run(dir, &["commit", "-m", &commit_msg], "git commit")?; - - println!("Org vault initialized at {}", dir.display()); - println!("Your member ID: {}", caller_id.as_str()); - Ok(()) -} - -fn whoami() -> String { - std::env::var("USER") - .or_else(|_| std::env::var("USERNAME")) - .unwrap_or_else(|_| "unknown".into()) -} -``` - -- [ ] **Step 3: Build and smoke-test manually** - -```bash -cargo build -p relicario-cli 2>&1 | tail -10 -``` - -Expected: clean build. - -- [ ] **Step 4: Commit** - -```bash -git add crates/relicario-cli/src/commands/org.rs crates/relicario-cli/tests/org_init.rs -git commit -m "feat(cli/org): org init command" -``` - ---- - -## [Dev-B] Task 7: org add-member, remove-member, set-role - -**Files:** -- Modify: `crates/relicario-cli/src/commands/org.rs` - -All three commands share the same open-vault → edit members.json → commit pattern. - -- [ ] **Step 1: Write failing unit tests** - -Add to `commands/org.rs`: - -```rust -#[cfg(test)] -mod tests { - use super::*; - use relicario_core::{MemberId, OrgMembers, OrgRole, OrgMember}; - - fn alice() -> OrgMember { - OrgMember { - member_id: MemberId::new(), - display_name: "Alice".into(), - role: OrgRole::Member, - ed25519_pubkey: "ssh-ed25519 AAAA fake".into(), - collections: vec![], - added_at: 0, - added_by: MemberId::new(), - } - } - - #[test] - fn set_role_changes_role() { - let mut members = OrgMembers::new(); - let a = alice(); - let id = a.member_id.clone(); - members.members.push(a); - if let Some(m) = members.find_by_id_mut(&id) { - m.role = OrgRole::Admin; - } - assert_eq!(members.find_by_id(&id).unwrap().role, OrgRole::Admin); - } -} -``` - -```bash -cargo test -p relicario-cli commands::org::tests 2>&1 | tail -5 -``` - -Expected: PASS (pure logic test, no I/O). - -- [ ] **Step 2: Implement add-member** - -Add to `commands/org.rs`: - -```rust -pub fn run_add_member( - dir: &Path, - pubkey: &str, - name: &str, - role: OrgRole, -) -> Result<()> { - let vault = crate::org_session::open_org_vault(Some(dir))?; - let caller = vault.current_member()?; - if !caller.role.can_manage_members() { - anyhow::bail!("only owners and admins can add members"); - } - - let mut members = vault.load_members()?; - - // Check pubkey not already present - if members.members.iter().any(|m| m.ed25519_pubkey.trim() == pubkey.trim()) { - anyhow::bail!("this public key is already registered in the org"); - } - - let new_id = MemberId::new(); - let now = relicario_core::now_unix(); - let wrapped = wrap_org_key(vault.key(), pubkey) - .context("wrap org key to new member's key")?; - - fs::write(vault.member_key_path(&new_id), &wrapped) - .context("write member key blob")?; - - members.members.push(OrgMember { - member_id: new_id.clone(), - display_name: name.to_string(), - role, - ed25519_pubkey: pubkey.trim().to_string(), - collections: vec![], - added_at: now, - added_by: caller.member_id.clone(), - }); - vault.save_members(&members)?; - - let commit_msg = format!( - "org: add member \"{name}\"\n\nRelicario-Actor: {} <{}>\nRelicario-Action: member-add\nRelicario-Member: {}", - caller.display_name, caller.member_id.as_str(), new_id.as_str() - ); - crate::org_session::org_git_run( - &vault.root, - &["add", "members.json", &format!("keys/{}.enc", new_id.as_str())], - "git add", - )?; - crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "git commit")?; - - println!("Added {} ({})", name, new_id.as_str()); - Ok(()) -} -``` - -- [ ] **Step 3: Implement remove-member** - -```rust -pub fn run_remove_member(dir: &Path, member_id_prefix: &str) -> Result<()> { - let vault = crate::org_session::open_org_vault(Some(dir))?; - let caller = vault.current_member()?; - if !caller.role.can_manage_members() { - anyhow::bail!("only owners and admins can remove members"); - } - - let mut members = vault.load_members()?; - let target_id = resolve_member_id(&members, member_id_prefix)?; - - let target = members.find_by_id(&target_id).unwrap(); - if target.role == OrgRole::Owner && !caller.role.can_manage_owners() { - anyhow::bail!("only owners can remove other owners"); - } - let target_name = target.display_name.clone(); - - // Delete key blob - let key_path = vault.member_key_path(&target_id); - if key_path.exists() { fs::remove_file(&key_path).context("delete key blob")?; } - - members.members.retain(|m| m.member_id != target_id); - vault.save_members(&members)?; - - let commit_msg = format!( - "org: remove member \"{target_name}\"\n\nRelicario-Actor: {} <{}>\nRelicario-Action: member-remove\nRelicario-Member: {}", - caller.display_name, caller.member_id.as_str(), target_id.as_str() - ); - crate::org_session::org_git_run( - &vault.root, - &["add", "members.json", &format!("keys/{}.enc", target_id.as_str())], - "git add", - )?; - crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "git commit")?; - - eprintln!("⚠ Run `relicario org rotate-key --dir {}` to complete revocation.", vault.root.display()); - println!("Removed {}", target_name); - Ok(()) -} -``` - -- [ ] **Step 4: Implement set-role** - -```rust -pub fn run_set_role(dir: &Path, member_id_prefix: &str, role: OrgRole) -> Result<()> { - let vault = crate::org_session::open_org_vault(Some(dir))?; - let caller = vault.current_member()?; - - let mut members = vault.load_members()?; - let target_id = resolve_member_id(&members, member_id_prefix)?; - - if matches!(role, OrgRole::Admin | OrgRole::Owner) && !caller.role.can_manage_owners() { - anyhow::bail!("only owners can promote to admin or owner"); - } - if !caller.role.can_manage_members() { - anyhow::bail!("only owners and admins can change roles"); - } - - let target = members.find_by_id_mut(&target_id) - .ok_or_else(|| anyhow::anyhow!("member not found"))?; - let old_role = target.role; - target.role = role; - vault.save_members(&members)?; - - let commit_msg = format!( - "org: set role {} → {:?}\n\nRelicario-Actor: {} <{}>\nRelicario-Action: member-role-change\nRelicario-Member: {}", - target_id.as_str(), role, - caller.display_name, caller.member_id.as_str(), - target_id.as_str() - ); - crate::org_session::org_git_run(&vault.root, &["add", "members.json"], "git add")?; - crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "git commit")?; - - println!("Changed role {:?} → {:?}", old_role, role); - Ok(()) -} - -/// Resolve a member_id prefix (or full ID) to a MemberId. -fn resolve_member_id(members: &OrgMembers, prefix: &str) -> Result { - let hits: Vec<_> = members.members.iter() - .filter(|m| m.member_id.as_str().starts_with(prefix)) - .collect(); - match hits.len() { - 0 => anyhow::bail!("no member matches `{prefix}`"), - 1 => Ok(hits[0].member_id.clone()), - _ => anyhow::bail!("ambiguous prefix `{prefix}` — {} matches", hits.len()), - } -} -``` - -- [ ] **Step 5: Compile check** - -```bash -cargo build -p relicario-cli 2>&1 | tail -10 -``` - -Expected: clean build. - -- [ ] **Step 6: Commit** - -```bash -git add crates/relicario-cli/src/commands/org.rs -git commit -m "feat(cli/org): add-member, remove-member, set-role commands" -``` - ---- - -## [Dev-B] Task 8: org create-collection, grant, revoke - -**Files:** -- Modify: `crates/relicario-cli/src/commands/org.rs` - -- [ ] **Step 1: Write failing test** - -Add to the `tests` block in `commands/org.rs`: - -```rust -#[test] -fn grant_adds_slug_to_member_collections() { - let mut members = OrgMembers::new(); - let a = alice(); - let id = a.member_id.clone(); - members.members.push(a); - - let m = members.find_by_id_mut(&id).unwrap(); - if !m.collections.contains(&"prod".to_string()) { - m.collections.push("prod".to_string()); - } - assert!(members.find_by_id(&id).unwrap().collections.contains(&"prod".to_string())); -} - -#[test] -fn revoke_removes_slug_from_member_collections() { - let mut members = OrgMembers::new(); - let mut a = alice(); - a.collections = vec!["prod".into(), "dev".into()]; - let id = a.member_id.clone(); - members.members.push(a); - - let m = members.find_by_id_mut(&id).unwrap(); - m.collections.retain(|s| s != "prod"); - assert!(!members.find_by_id(&id).unwrap().collections.contains(&"prod".to_string())); - assert!(members.find_by_id(&id).unwrap().collections.contains(&"dev".to_string())); -} -``` - -```bash -cargo test -p relicario-cli commands::org::tests 2>&1 | tail -5 -``` - -Expected: all pass. - -- [ ] **Step 2: Implement create-collection** - -```rust -pub fn run_create_collection(dir: &Path, slug: &str, display_name: &str) -> Result<()> { - let vault = crate::org_session::open_org_vault(Some(dir))?; - let caller = vault.current_member()?; - if !caller.role.can_manage_members() { - anyhow::bail!("only owners and admins can create collections"); - } - - let mut collections = vault.load_collections()?; - if collections.contains_slug(slug) { - anyhow::bail!("collection `{slug}` already exists"); - } - if slug.is_empty() || slug.contains('/') || slug.contains('.') { - anyhow::bail!("invalid slug `{slug}` — no slashes or dots, no empty string"); - } - - collections.collections.push(CollectionDef { - slug: slug.to_string(), - display_name: display_name.to_string(), - created_by: caller.member_id.clone(), - created_at: relicario_core::now_unix(), - }); - vault.save_collections(&collections)?; - - let commit_msg = format!( - "org: create collection \"{slug}\"\n\nRelicario-Actor: {} <{}>\nRelicario-Action: collection-create\nRelicario-Collection: {slug}", - caller.display_name, caller.member_id.as_str() - ); - crate::org_session::org_git_run(&vault.root, &["add", "collections.json"], "git add")?; - crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "git commit")?; - - println!("Created collection `{slug}`"); - Ok(()) -} -``` - -- [ ] **Step 3: Implement grant** - -```rust -pub fn run_grant(dir: &Path, member_id_prefix: &str, slug: &str) -> Result<()> { - let vault = crate::org_session::open_org_vault(Some(dir))?; - let caller = vault.current_member()?; - if !caller.role.can_manage_members() { - anyhow::bail!("only owners and admins can grant collection access"); - } - - let collections = vault.load_collections()?; - if !collections.contains_slug(slug) { - anyhow::bail!("collection `{slug}` does not exist — create it first"); - } - - let mut members = vault.load_members()?; - let target_id = resolve_member_id(&members, member_id_prefix)?; - let target = members.find_by_id_mut(&target_id).unwrap(); - if target.collections.contains(&slug.to_string()) { - anyhow::bail!("member already has access to `{slug}`"); - } - target.collections.push(slug.to_string()); - vault.save_members(&members)?; - - let commit_msg = format!( - "org: grant {slug} to {}\n\nRelicario-Actor: {} <{}>\nRelicario-Action: collection-grant\nRelicario-Collection: {slug}\nRelicario-Member: {}", - target_id.as_str(), caller.display_name, caller.member_id.as_str(), target_id.as_str() - ); - crate::org_session::org_git_run(&vault.root, &["add", "members.json"], "git add")?; - crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "git commit")?; - - println!("Granted `{slug}` to {}", target_id.as_str()); - Ok(()) -} -``` - -- [ ] **Step 4: Implement revoke** - -```rust -pub fn run_revoke(dir: &Path, member_id_prefix: &str, slug: &str) -> Result<()> { - let vault = crate::org_session::open_org_vault(Some(dir))?; - let caller = vault.current_member()?; - if !caller.role.can_manage_members() { - anyhow::bail!("only owners and admins can revoke collection access"); - } - - let mut members = vault.load_members()?; - let target_id = resolve_member_id(&members, member_id_prefix)?; - let target = members.find_by_id_mut(&target_id).unwrap(); - if !target.collections.contains(&slug.to_string()) { - anyhow::bail!("member does not have access to `{slug}`"); - } - target.collections.retain(|s| s != slug); - vault.save_members(&members)?; - - let commit_msg = format!( - "org: revoke {slug} from {}\n\nRelicario-Actor: {} <{}>\nRelicario-Action: collection-revoke\nRelicario-Collection: {slug}\nRelicario-Member: {}", - target_id.as_str(), caller.display_name, caller.member_id.as_str(), target_id.as_str() - ); - crate::org_session::org_git_run(&vault.root, &["add", "members.json"], "git add")?; - crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "git commit")?; - - println!("Revoked `{slug}` from {}", target_id.as_str()); - Ok(()) -} -``` - -- [ ] **Step 5: Compile + commit** - -```bash -cargo build -p relicario-cli 2>&1 | tail -5 -git add crates/relicario-cli/src/commands/org.rs -git commit -m "feat(cli/org): create-collection, grant, revoke commands" -``` - ---- - -## [Dev-B] Task 9: org rotate-key - -**Files:** -- Modify: `crates/relicario-cli/src/commands/org.rs` - -`rotate-key` generates a new org master key, re-wraps it for all current members, re-encrypts the manifest (item blobs are NOT re-encrypted), and commits. - -- [ ] **Step 1: Write failing test** - -Add to tests block: - -```rust -#[test] -fn new_key_differs_from_old_key() { - let k1 = relicario_core::generate_org_key(); - let k2 = relicario_core::generate_org_key(); - assert_ne!(*k1, *k2); -} -``` - -```bash -cargo test -p relicario-cli commands::org::tests::new_key_differs 2>&1 | tail -5 -``` - -Expected: PASS. - -- [ ] **Step 2: Implement rotate-key** - -```rust -pub fn run_rotate_key(dir: &Path) -> Result<()> { - // Pull latest state first to detect concurrent rotations - let pull_result = crate::helpers::git_run(dir, &["pull", "--rebase"], "git pull --rebase"); - if let Err(e) = pull_result { - // Non-fatal if no remote configured (local-only orgs) - eprintln!("Note: git pull --rebase failed ({}). Proceeding with local state.", e); - } - - let vault = crate::org_session::open_org_vault(Some(dir))?; - let caller = vault.current_member()?; - if !caller.role.can_manage_owners() { - anyhow::bail!("only owners can rotate the org master key"); - } - - let members = vault.load_members()?; - let new_org_key = relicario_core::generate_org_key(); - - // Re-wrap for all current members - let mut staged_paths: Vec = Vec::new(); - for member in &members.members { - let wrapped = wrap_org_key(&new_org_key, &member.ed25519_pubkey) - .with_context(|| format!("wrap key for {}", member.display_name))?; - let key_path = vault.member_key_path(&member.member_id); - fs::write(&key_path, &wrapped) - .with_context(|| format!("write key for {}", member.display_name))?; - staged_paths.push(format!("keys/{}.enc", member.member_id.as_str())); - } - - // Re-encrypt manifest with new key (items do not need re-encryption) - let manifest = vault.load_manifest()?; - let new_manifest_bytes = relicario_core::encrypt_org_manifest(&manifest, &new_org_key)?; - crate::org_session::atomic_write(&vault.manifest_path(), &new_manifest_bytes)?; - staged_paths.push("manifest.enc".to_string()); - - // Commit - let mut add_args = vec!["add"]; - let path_refs: Vec<&str> = staged_paths.iter().map(|s| s.as_str()).collect(); - add_args.extend_from_slice(&path_refs); - crate::org_session::org_git_run(&vault.root, &add_args, "git add")?; - - let commit_msg = format!( - "org: rotate org master key\n\nRelicario-Actor: {} <{}>\nRelicario-Action: key-rotate", - caller.display_name, caller.member_id.as_str() - ); - crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "git commit")?; - - println!("Key rotated. {} member key(s) re-wrapped.", members.members.len()); - Ok(()) -} -``` - -- [ ] **Step 3: Build + commit** - -```bash -cargo build -p relicario-cli 2>&1 | tail -5 -git add crates/relicario-cli/src/commands/org.rs -git commit -m "feat(cli/org): rotate-key command" -``` - ---- - -## [Dev-B] Task 10: org status + org audit - -**Files:** -- Modify: `crates/relicario-cli/src/commands/org.rs` - -- [ ] **Step 1: Implement status** - -```rust -pub fn run_status(dir: &Path) -> Result<()> { - let root = crate::org_session::org_dir(Some(dir))?; - - let meta: relicario_core::OrgMeta = { - let s = fs::read_to_string(root.join("org.json")).context("read org.json")?; - serde_json::from_str(&s)? - }; - let members: OrgMembers = { - let s = fs::read_to_string(root.join("members.json")).context("read members.json")?; - serde_json::from_str(&s)? - }; - let collections: OrgCollections = { - let s = fs::read_to_string(root.join("collections.json")).context("read collections.json")?; - serde_json::from_str(&s)? - }; - - println!("Org: {} ({})", meta.display_name, meta.org_id.as_str()); - println!(); - println!("Members ({}):", members.members.len()); - for m in &members.members { - let colls = if m.collections.is_empty() { - "(no collections)".to_string() - } else { - m.collections.join(", ") - }; - println!(" {:?} {} {} [{}]", m.role, m.member_id.as_str(), m.display_name, colls); - } - println!(); - println!("Collections ({}):", collections.collections.len()); - for c in &collections.collections { - println!(" {} — {}", c.slug, c.display_name); - } - Ok(()) -} -``` - -- [ ] **Step 2: Write failing test for audit trailer parsing** - -Add to tests block: - -```rust -#[test] -fn parse_trailers_extracts_relicario_fields() { - let raw = "Relicario-Actor: alice \nRelicario-Action: item-create\nRelicario-Collection: prod\n"; - let event = parse_trailer_block("abc123", "2026-06-06T12:00:00+00:00", raw); - assert_eq!(event.action.as_deref(), Some("item-create")); - assert_eq!(event.collection.as_deref(), Some("prod")); - assert_eq!(event.actor_id.as_deref(), Some("a1b2c3d4e5f6a1b2")); -} -``` - -```bash -cargo test -p relicario-cli commands::org::tests::parse_trailers 2>&1 | tail -5 -``` - -Expected: FAIL — `parse_trailer_block` not defined. - -- [ ] **Step 3: Implement audit** - -```rust -#[derive(Debug, serde::Serialize)] -pub struct AuditEvent { - pub commit: String, - pub timestamp: String, - pub actor_name: Option, - pub actor_id: Option, - pub action: Option, - pub collection: Option, - pub item_id: Option, - pub device_id: Option, -} - -fn parse_trailer_block(commit: &str, timestamp: &str, trailers: &str) -> AuditEvent { - let mut ev = AuditEvent { - commit: commit.to_string(), - timestamp: timestamp.to_string(), - actor_name: None, actor_id: None, action: None, - collection: None, item_id: None, device_id: None, - }; - for line in trailers.lines() { - if let Some(rest) = line.strip_prefix("Relicario-Actor: ") { - // Format: "Name " - if let (Some(lt), Some(gt)) = (rest.rfind('<'), rest.rfind('>')) { - ev.actor_name = Some(rest[..lt].trim().to_string()); - ev.actor_id = Some(rest[lt+1..gt].to_string()); - } - } else if let Some(v) = line.strip_prefix("Relicario-Action: ") { - ev.action = Some(v.trim().to_string()); - } else if let Some(v) = line.strip_prefix("Relicario-Collection: ") { - ev.collection = Some(v.trim().to_string()); - } else if let Some(v) = line.strip_prefix("Relicario-Item: ") { - ev.item_id = Some(v.trim().to_string()); - } else if let Some(v) = line.strip_prefix("Relicario-Device: ") { - ev.device_id = Some(v.trim().to_string()); - } - } - ev -} - -pub fn run_audit( - dir: &Path, - since: Option<&str>, - member_filter: Option<&str>, - collection_filter: Option<&str>, - action_filter: Option<&str>, - json: bool, -) -> Result<()> { - let root = crate::org_session::org_dir(Some(dir))?; - - // git log with separator-delimited format - let sep = "\x1F"; // ASCII unit separator — won't appear in messages - let fmt = format!("{sep}%H{sep}%aI{sep}%(trailers)"); - let mut args = vec!["log", &format!("--format={fmt}")]; - let since_arg; - if let Some(s) = since { - since_arg = format!("--since={s}"); - args.push(&since_arg); - } - - let output = std::process::Command::new("git") - .args(&args) - .current_dir(&root) - .output() - .context("git log")?; - let log = String::from_utf8_lossy(&output.stdout); - - let mut events: Vec = Vec::new(); - for chunk in log.split(sep).collect::>().chunks(4) { - if chunk.len() < 4 { continue; } - let (_marker, commit, ts, trailers) = (chunk[0], chunk[1], chunk[2], chunk[3]); - if commit.trim().is_empty() { continue; } - let ev = parse_trailer_block(commit.trim(), ts.trim(), trailers); - if ev.action.is_none() { continue; } // not an org commit - - if let Some(mid) = member_filter { - if ev.actor_id.as_deref() != Some(mid) { continue; } - } - if let Some(col) = collection_filter { - if ev.collection.as_deref() != Some(col) { continue; } - } - if let Some(act) = action_filter { - if ev.action.as_deref() != Some(act) { continue; } - } - events.push(ev); - } - - if json { - println!("{}", serde_json::to_string_pretty(&events)?); - } else { - println!("{:<44} {:<26} {:<20} {:<15}", "COMMIT", "TIMESTAMP", "ACTION", "ACTOR"); - for ev in &events { - println!("{:<44} {:<26} {:<20} {}", - ev.commit, - ev.timestamp, - ev.action.as_deref().unwrap_or("-"), - ev.actor_name.as_deref().unwrap_or("-"), - ); - } - } - Ok(()) -} -``` - -- [ ] **Step 4: Run the trailer test** - -```bash -cargo test -p relicario-cli commands::org::tests::parse_trailers 2>&1 | tail -5 -``` - -Expected: PASS. - -- [ ] **Step 5: Build + commit** - -```bash -cargo build -p relicario-cli 2>&1 | tail -5 -git add crates/relicario-cli/src/commands/org.rs -git commit -m "feat(cli/org): status and audit commands" -``` - ---- - -## [Dev-B] Task 11: Wire Commands::Org into main.rs - -**Files:** -- Modify: `crates/relicario-cli/src/main.rs` - -- [ ] **Step 1: Read the current Commands enum top** - -```bash -grep -n "Subcommand\|Commands\|enum\|Org" crates/relicario-cli/src/main.rs | head -40 -``` - -Look for where the `Commands` enum and the `match cli.command` dispatch live. - -- [ ] **Step 2: Add Org subcommand to the Commands enum** - -In the `Commands` enum, add (after the last existing variant): - -```rust -/// Manage a multi-user org vault. -Org { - /// Path to the org vault directory (overrides RELICARIO_ORG_DIR). - #[arg(long, global = true)] - dir: Option, - #[command(subcommand)] - subcommand: OrgCommands, -}, -``` - -Add the `OrgCommands` enum (top-level, after the `Commands` enum): - -```rust -#[derive(Subcommand)] -enum OrgCommands { - /// Create a new org vault. - Init { - #[arg(long)] - name: String, - }, - /// Add a member to the org. - AddMember { - /// OpenSSH ed25519 public key of the new member. - #[arg(long)] - key: String, - /// Display name. - #[arg(long)] - name: String, - /// Role: owner, admin, or member. - #[arg(long, default_value = "member")] - role: String, - }, - /// Remove a member from the org. - RemoveMember { - /// Member ID prefix. - member_id: String, - }, - /// Change a member's role. - SetRole { - member_id: String, - role: String, - }, - /// Create a collection. - CreateCollection { - slug: String, - #[arg(long)] - name: String, - }, - /// Grant a member access to a collection. - Grant { - member_id: String, - collection: String, - }, - /// Revoke a member's access to a collection. - Revoke { - member_id: String, - collection: String, - }, - /// Rotate the org master key (run after removing a member). - RotateKey, - /// Show org members and collections. - Status, - /// Query the org audit log. - Audit { - #[arg(long)] - since: Option, - #[arg(long)] - member: Option, - #[arg(long)] - collection: Option, - #[arg(long)] - action: Option, - #[arg(long)] - json: bool, - }, -} -``` - -- [ ] **Step 3: Add dispatch arm in main()** - -In the `match cli.command { ... }` block, add: - -```rust -Commands::Org { dir, subcommand } => { - let dir_path = dir.as_deref(); - match subcommand { - OrgCommands::Init { name } => { - let d = dir_path.ok_or_else(|| anyhow::anyhow!("--dir required for org init"))?; - commands::org::run_init(d, &name)?; - } - OrgCommands::AddMember { key, name, role } => { - let d = crate::org_session::org_dir(dir_path)?; - let role = parse_org_role(&role)?; - commands::org::run_add_member(&d, &key, &name, role)?; - } - OrgCommands::RemoveMember { member_id } => { - let d = crate::org_session::org_dir(dir_path)?; - commands::org::run_remove_member(&d, &member_id)?; - } - OrgCommands::SetRole { member_id, role } => { - let d = crate::org_session::org_dir(dir_path)?; - let role = parse_org_role(&role)?; - commands::org::run_set_role(&d, &member_id, role)?; - } - OrgCommands::CreateCollection { slug, name } => { - let d = crate::org_session::org_dir(dir_path)?; - commands::org::run_create_collection(&d, &slug, &name)?; - } - OrgCommands::Grant { member_id, collection } => { - let d = crate::org_session::org_dir(dir_path)?; - commands::org::run_grant(&d, &member_id, &collection)?; - } - OrgCommands::Revoke { member_id, collection } => { - let d = crate::org_session::org_dir(dir_path)?; - commands::org::run_revoke(&d, &member_id, &collection)?; - } - OrgCommands::RotateKey => { - let d = crate::org_session::org_dir(dir_path)?; - commands::org::run_rotate_key(&d)?; - } - OrgCommands::Status => { - let d = crate::org_session::org_dir(dir_path)?; - commands::org::run_status(&d)?; - } - OrgCommands::Audit { since, member, collection, action, json } => { - let d = crate::org_session::org_dir(dir_path)?; - commands::org::run_audit(&d, since.as_deref(), member.as_deref(), - collection.as_deref(), action.as_deref(), json)?; - } - } -} -``` - -Add the `parse_org_role` helper in `main.rs`: - -```rust -fn parse_org_role(s: &str) -> anyhow::Result { - match s { - "owner" => Ok(relicario_core::OrgRole::Owner), - "admin" => Ok(relicario_core::OrgRole::Admin), - "member" => Ok(relicario_core::OrgRole::Member), - other => anyhow::bail!("unknown role `{other}` — use owner, admin, or member"), - } -} -``` - -- [ ] **Step 4: Build and test help output** - -```bash -cargo build -p relicario-cli 2>&1 | tail -10 -./target/debug/relicario org --help -./target/debug/relicario org init --help -``` - -Expected: clean build, help text for all org subcommands. - -- [ ] **Step 5: Commit** - -```bash -git add crates/relicario-cli/src/main.rs -git commit -m "feat(cli): wire Commands::Org subcommand into main.rs" -``` - ---- - -## [Dev-C] Task 12: Pre-receive hook org extension in relicario-server - -**Files:** -- Modify: `crates/relicario-server/src/main.rs` - -The server gains a `verify-org-commit` subcommand that validates: -1. Only owners/admins wrote to `members.json`, `collections.json`, `org.json` -2. Schema version did not decrease -3. (Warning) If a `member-remove` commit happened without a following `key-rotate`, emit a warning on the next push - -- [ ] **Step 1: Write failing test** - -Create `crates/relicario-server/tests/org_hook.rs`: - -```rust -#[test] -fn parse_changed_paths_detects_members_json() { - let paths = vec!["members.json", "items/abc123.enc"]; - assert!(paths.iter().any(|p| *p == "members.json")); -} -``` - -```bash -cargo test -p relicario-server 2>&1 | tail -5 -``` - -Expected: PASS (trivial smoke test to verify the test harness works). - -- [ ] **Step 2: Add VerifyOrgCommit subcommand** - -In `crates/relicario-server/src/main.rs`, add to the `Commands` enum: - -```rust -/// Verify that a commit to an org vault respects role-based path authorization. -VerifyOrgCommit { - /// The commit SHA to verify. - commit: String, -}, -/// Generate an org pre-receive hook script. -GenerateOrgHook, -``` - -Add dispatch in `main()`: - -```rust -Commands::VerifyOrgCommit { commit } => verify_org_commit(&commit), -Commands::GenerateOrgHook => generate_org_hook(), -``` - -- [ ] **Step 3: Implement verify_org_commit** - -```rust -fn verify_org_commit(commit: &str) -> Result<()> { - // Read members.json from the commit tree - let members_json = match git_show(commit, "members.json") { - Ok(s) => s, - Err(_) => { - eprintln!("OK: org commit {commit} (bootstrap - no members.json)"); - return Ok(()); - } - }; - - let members: relicario_core::OrgMembers = serde_json::from_str(&members_json) - .context("parse members.json")?; - members.validate().context("members.json schema invalid")?; - - // Get changed paths in this commit vs its parent - let parent_output = Command::new("git") - .args(["diff-tree", "--no-commit-id", "-r", "--name-only", commit]) - .output() - .context("git diff-tree")?; - let changed_paths: Vec = String::from_utf8_lossy(&parent_output.stdout) - .lines() - .map(|l| l.trim().to_string()) - .collect(); - - // Protected paths: only owner/admin may write these - let protected = ["members.json", "collections.json", "org.json"]; - let touches_protected = changed_paths.iter().any(|p| protected.contains(&p.as_str())); - - if touches_protected { - // Find the signing key fingerprint of this commit - let fp = commit_signing_fingerprint(commit)?; - - // Look up which member has this fingerprint - let signing_member = members.members.iter().find(|m| { - relicario_core::fingerprint(&m.ed25519_pubkey) - .ok() - .as_deref() == Some(&fp) - }); - - match signing_member { - None => { - eprintln!("REJECT: org commit {commit} — signer not in members.json"); - std::process::exit(1); - } - Some(m) if !m.role.can_manage_members() => { - eprintln!( - "REJECT: org commit {commit} — member '{}' (role {:?}) cannot write protected files", - m.display_name, m.role - ); - std::process::exit(1); - } - Some(m) => { - eprintln!("OK: org commit {commit} — protected-path write by '{}' ({:?})", - m.display_name, m.role); - } - } - } else { - eprintln!("OK: org commit {commit} (no protected paths touched)"); - } - - // Rotation warning: if members.json changed but keys/ directory did NOT change, - // emit a warning (member removed without rotating key) - let members_changed = changed_paths.iter().any(|p| p == "members.json"); - let keys_changed = changed_paths.iter().any(|p| p.starts_with("keys/")); - if members_changed && !keys_changed { - eprintln!("WARN: org commit {commit} — members.json changed but no key rotation detected"); - eprintln!("WARN: run `relicario org rotate-key` to complete member revocation"); - } - - Ok(()) -} - -fn commit_signing_fingerprint(commit: &str) -> Result { - let output = Command::new("git") - .args(["show", "-s", "--format=%GF", commit]) - .output() - .context("git show --format=%GF")?; - let fp = String::from_utf8_lossy(&output.stdout).trim().to_string(); - if fp.is_empty() { - anyhow::bail!("commit {commit} has no GPG/SSH signature fingerprint"); - } - Ok(fp) -} - -fn generate_org_hook() -> Result<()> { - print!(r#"#!/bin/bash -# Relicario org pre-receive hook - -while read oldrev newrev refname; do - [ "$newrev" = "0000000000000000000000000000000000000000" ] && continue - - if [ "$oldrev" = "0000000000000000000000000000000000000000" ]; then - commits=$(git rev-list "$newrev") - else - commits=$(git rev-list "$oldrev..$newrev") - fi - - for commit in $commits; do - relicario-server verify-org-commit "$commit" || exit 1 - done -done -"#); - Ok(()) -} -``` - -- [ ] **Step 4: Build relicario-server** - -```bash -cargo build -p relicario-server 2>&1 | tail -10 -``` - -Expected: clean build. - -- [ ] **Step 5: Commit** - -```bash -git add crates/relicario-server/src/main.rs crates/relicario-server/tests/org_hook.rs -git commit -m "feat(server): verify-org-commit + generate-org-hook subcommands" -``` - ---- - -## Task 13: Full org lifecycle integration test +## [Dev-A] Task A4: Full org lifecycle integration test (core) **Files:** - Create: `crates/relicario-core/tests/org.rs` -This test exercises the complete core-level org flow without requiring a device key on disk. +This test exercises the complete core-level org flow without requiring a device key on disk. The keypair helper uses the **H7 fix** (`PrivateKey::from(Ed25519Keypair::from(&signing_key))`). + +> **Note:** The old plan's "add ed25519-dalek and ssh-key as dev-dependencies" step is **removed** — these crates are already in `relicario-core`'s `[dependencies]`, so integration tests (which compile against the crate) already have them available. No `[dev-dependencies]` edit is required. - [ ] **Step 1: Write the integration test** @@ -2293,10 +792,12 @@ fn make_member_keypair() -> (Zeroizing<[u8; 32]>, String) { let mut seed = [0u8; 32]; OsRng.fill_bytes(&mut seed); let signing_key = ed25519_dalek::SigningKey::from_bytes(&seed); - let pubkey_openssh = ssh_key::PrivateKey::from(signing_key) - .public_key() - .to_openssh() - .expect("openssh"); + let pubkey_openssh = ssh_key::PrivateKey::from( + ssh_key::private::Ed25519Keypair::from(&signing_key), + ) + .public_key() + .to_openssh() + .expect("openssh"); (Zeroizing::new(seed), pubkey_openssh) } @@ -2398,71 +899,4608 @@ fn members_validation_rejects_invalid_id() { } ``` -- [ ] **Step 2: Add ed25519-dalek and ssh-key as dev-dependencies in relicario-core** - -In `crates/relicario-core/Cargo.toml`, add to `[dev-dependencies]`: - -```toml -ed25519-dalek = { version = "2", features = ["rand_core"] } -ssh-key = { version = "0.6", features = ["ed25519", "std"] } -``` - -(These are already in `[dependencies]` — just make sure they're also available in tests. If they're already in `[dependencies]`, they're already available; skip this step.) - -- [ ] **Step 3: Run all org integration tests** +- [ ] **Step 2: Run all org integration tests** ```bash cargo test -p relicario-core --test org 2>&1 | tail -20 -``` - -Expected: all 5 tests pass. - -- [ ] **Step 4: Run the full test suite** - -```bash cargo test 2>&1 | tail -20 ``` -Expected: all tests pass across all crates. +Expected: all 5 tests pass; full suite green. -- [ ] **Step 5: Final commit** +- [ ] **Step 3: Final commit** ```bash -git add crates/relicario-core/tests/org.rs crates/relicario-core/Cargo.toml +git add crates/relicario-core/tests/org.rs git commit -m "test(core/org): full org lifecycle integration tests" ``` --- +## Dev-B — relicario-cli (session, device, admin commands, item CRUD, wiring) + +> **Stream prerequisite:** Tasks A2–A3 must be merged before Dev-B begins (every command imports `relicario_core::org` types/functions). + +## [Dev-B] Task B1: UnlockedOrgVault session type (collection-scoped item_path + fingerprint member match + signed org_git_run) + +**Files:** +- Create: `crates/relicario-cli/src/org_session.rs` +- Modify: `crates/relicario-cli/src/main.rs` + +`UnlockedOrgVault` holds the org master key for one CLI invocation. Two corrections from the review are baked in from the start: (1) `item_path` is **collection-scoped** — `items//.enc` — so the hook can authorize item writes by leading path segment without decrypting; (2) the caller is matched to `members.json` by **ed25519 fingerprint** (via `relicario_core::fingerprint`), not brittle raw-OpenSSH-string equality. `org_git_run` runs **bare git** (NOT the hardened `helpers::git_run`, which force-disables signing) so org commits are signed. + +- [ ] **Step 1: Implement UnlockedOrgVault** + +Create `crates/relicario-cli/src/org_session.rs`: + +```rust +//! Unlocked org vault session: holds the org master key for the duration of a +//! CLI invocation. + +use std::fs; +use std::path::{Path, PathBuf}; + +use anyhow::{bail, Context, Result}; +use zeroize::Zeroizing; + +use relicario_core::{ + decrypt_item, decrypt_org_manifest, encrypt_item, encrypt_org_manifest, + Item, ItemId, MemberId, OrgCollections, OrgManifest, OrgMember, OrgMembers, OrgMeta, +}; + +pub struct UnlockedOrgVault { + pub root: PathBuf, + pub org_key: Zeroizing<[u8; 32]>, +} + +impl UnlockedOrgVault { + pub fn root(&self) -> &Path { &self.root } + pub fn key(&self) -> &Zeroizing<[u8; 32]> { &self.org_key } + + pub fn manifest_path(&self) -> PathBuf { self.root.join("manifest.enc") } + + /// Collection-scoped item path: `items//.enc`. + /// The leading slug segment is what the pre-receive hook authorizes against + /// members.json — it never decrypts the blob. The slug must be non-empty and + /// already validated. + pub fn item_path(&self, collection_slug: &str, id: &ItemId) -> PathBuf { + self.root + .join("items") + .join(collection_slug) + .join(format!("{}.enc", id.as_str())) + } + + pub fn member_key_path(&self, id: &MemberId) -> PathBuf { + self.root.join("keys").join(format!("{}.enc", id.as_str())) + } + pub fn members_path(&self) -> PathBuf { self.root.join("members.json") } + pub fn collections_path(&self) -> PathBuf { self.root.join("collections.json") } + pub fn org_meta_path(&self) -> PathBuf { self.root.join("org.json") } + + pub fn load_meta(&self) -> Result { + let s = fs::read_to_string(self.org_meta_path()).context("read org.json")?; + Ok(serde_json::from_str(&s).context("parse org.json")?) + } + + pub fn load_members(&self) -> Result { + let s = fs::read_to_string(self.members_path()).context("read members.json")?; + Ok(serde_json::from_str(&s).context("parse members.json")?) + } + + pub fn save_members(&self, members: &OrgMembers) -> Result<()> { + let json = serde_json::to_string_pretty(members)?; + atomic_write(&self.members_path(), json.as_bytes()) + } + + pub fn load_collections(&self) -> Result { + let s = fs::read_to_string(self.collections_path()).context("read collections.json")?; + Ok(serde_json::from_str(&s).context("parse collections.json")?) + } + + pub fn save_collections(&self, collections: &OrgCollections) -> Result<()> { + let json = serde_json::to_string_pretty(collections)?; + atomic_write(&self.collections_path(), json.as_bytes()) + } + + pub fn load_manifest(&self) -> Result { + let bytes = fs::read(self.manifest_path()).context("read manifest.enc")?; + Ok(decrypt_org_manifest(&bytes, &self.org_key)?) + } + + pub fn save_manifest(&self, manifest: &OrgManifest) -> Result<()> { + let bytes = encrypt_org_manifest(manifest, &self.org_key)?; + atomic_write(&self.manifest_path(), &bytes) + } + + /// Encrypt + write an item under its collection directory, creating the + /// directory if needed. Returns the repo-relative path for git staging. + pub fn save_item(&self, collection_slug: &str, item: &Item) -> Result { + let path = self.item_path(collection_slug, &item.id); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("create {}", parent.display()))?; + } + let bytes = encrypt_item(item, &self.org_key)?; + atomic_write(&path, &bytes)?; + Ok(format!("items/{}/{}.enc", collection_slug, item.id.as_str())) + } + + /// Read + decrypt an item from its collection directory. + pub fn load_item(&self, collection_slug: &str, id: &ItemId) -> Result { + let path = self.item_path(collection_slug, id); + let bytes = fs::read(&path) + .with_context(|| format!("read item {}", path.display()))?; + Ok(decrypt_item(&bytes, &self.org_key)?) + } + + /// Delete an item blob. Missing file is not an error (partial-write + /// recovery, same as the personal-vault purge path). + pub fn remove_item(&self, collection_slug: &str, id: &ItemId) -> Result<()> { + let path = self.item_path(collection_slug, id); + match fs::remove_file(&path) { + Ok(()) => Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(anyhow::Error::from(e) + .context(format!("delete {}", path.display()))), + } + } + + /// Bail unless `member` has `slug` in their collection grants. The slug + /// existence check is done separately by the caller against collections.json. + pub fn ensure_grant(member: &OrgMember, slug: &str) -> Result<()> { + if member.collections.iter().any(|c| c == slug) { + Ok(()) + } else { + bail!( + "access denied: you do not have a grant for collection `{slug}` — ask an admin to run `relicario org grant`" + ) + } + } + + /// Load members.json and find the caller's member entry by matching the + /// current device's ed25519 fingerprint against each member's pubkey + /// fingerprint. Fingerprint comparison (not raw OpenSSH-string equality) + /// tolerates comment/whitespace differences in the serialized key. + pub fn current_member(&self) -> Result { + let device_fp = current_device_fingerprint()?; + let members = self.load_members()?; + members + .members + .into_iter() + .find(|m| { + relicario_core::fingerprint(&m.ed25519_pubkey) + .ok() + .as_deref() + == Some(device_fp.as_str()) + }) + .ok_or_else(|| { + anyhow::anyhow!( + "your device key is not registered in this org — ask an admin to run `org add-member`" + ) + }) + } +} + +/// Locate the org vault root from RELICARIO_ORG_DIR env var or --dir flag value. +pub fn org_dir(dir_flag: Option<&std::path::Path>) -> Result { + if let Some(d) = dir_flag { + return Ok(d.to_path_buf()); + } + if let Ok(v) = std::env::var("RELICARIO_ORG_DIR") { + return Ok(PathBuf::from(v)); + } + bail!("org vault location required: set RELICARIO_ORG_DIR or pass --dir ") +} + +/// Open an org vault: locate the root, read members.json to find the caller's +/// member entry (by ed25519 fingerprint), then unwrap their keys/.enc to +/// recover the org master key. +pub fn open_org_vault(dir_flag: Option<&std::path::Path>) -> Result { + let root = org_dir(dir_flag)?; + + let device_fp = current_device_fingerprint()?; + let members_json = fs::read_to_string(root.join("members.json")) + .context("read members.json — is this an org vault?")?; + let members: OrgMembers = serde_json::from_str(&members_json).context("parse members.json")?; + let member = members + .members + .iter() + .find(|m| { + relicario_core::fingerprint(&m.ed25519_pubkey) + .ok() + .as_deref() + == Some(device_fp.as_str()) + }) + .ok_or_else(|| anyhow::anyhow!("your device key is not in this org"))?; + + // Load this member's wrapped key blob. + let key_path = root + .join("keys") + .join(format!("{}.enc", member.member_id.as_str())); + let wrapped = + fs::read(&key_path).with_context(|| format!("read {}", key_path.display()))?; + + // Recover the device ed25519 seed and unwrap. + let seed = current_device_seed()?; + let org_key = relicario_core::unwrap_org_key(&wrapped, &seed)?; + + Ok(UnlockedOrgVault { root, org_key }) +} + +/// OpenSSH SHA-256 fingerprint of the active device's signing key. +fn current_device_fingerprint() -> Result { + let name = crate::device::current_device()? + .ok_or_else(|| anyhow::anyhow!("no active device — run `relicario device add` first"))?; + let pub_path = crate::device::device_dir(&name)?.join("signing.pub"); + let pubkey = fs::read_to_string(&pub_path) + .with_context(|| format!("read {}", pub_path.display()))?; + Ok(relicario_core::fingerprint(pubkey.trim())?) +} + +/// Recover the active device's ed25519 seed (the 32-byte private scalar source) +/// from its OpenSSH `signing.key`, for ECIES unwrap. +fn current_device_seed() -> Result> { + let name = crate::device::current_device()? + .ok_or_else(|| anyhow::anyhow!("no active device — run `relicario device add` first"))?; + let key_pem = crate::device::load_signing_key(&name)?; + let private = ssh_key::PrivateKey::from_openssh(key_pem.as_str()) + .map_err(|e| anyhow::anyhow!("parse device signing key: {e}"))?; + let ed = private + .key_data() + .ed25519() + .ok_or_else(|| anyhow::anyhow!("device signing key is not ed25519"))?; + // Ed25519PrivateKey derefs to its 32-byte seed. + let seed_bytes: &[u8] = ed.private.as_ref(); + if seed_bytes.len() != 32 { + anyhow::bail!("ed25519 seed has wrong length: {}", seed_bytes.len()); + } + let mut seed = Zeroizing::new([0u8; 32]); + seed.copy_from_slice(seed_bytes); + Ok(seed) +} + +pub(crate) fn atomic_write(path: &Path, data: &[u8]) -> Result<()> { + let mut tmp = path.as_os_str().to_owned(); + tmp.push(".tmp"); + let tmp = PathBuf::from(tmp); + fs::write(&tmp, data).with_context(|| format!("write {}", tmp.display()))?; + fs::rename(&tmp, path).with_context(|| format!("rename {}", tmp.display()))?; + Ok(()) +} + +/// Run `git ` in the org repo, capturing output and replaying it on +/// failure. Unlike `crate::helpers::git_run`, this does NOT inject +/// `commit.gpgsign=false` / `core.hooksPath=/dev/null`: org commits MUST be +/// signed (the pre-receive hook verifies every commit's signature), and the +/// repo's signing config is established by `configure_git_signing` during +/// `org init`. +pub(crate) fn org_git_run(root: &Path, args: &[&str], context: &str) -> Result<()> { + let output = std::process::Command::new("git") + .current_dir(root) + .args(args) + .output() + .with_context(|| format!("{context}: failed to spawn git"))?; + if !output.status.success() { + if !output.stdout.is_empty() { + eprint!("{}", String::from_utf8_lossy(&output.stdout)); + } + if !output.stderr.is_empty() { + eprint!("{}", String::from_utf8_lossy(&output.stderr)); + } + anyhow::bail!("{context}: git failed ({})", output.status); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + use std::fs; + + fn make_vault(key: Zeroizing<[u8; 32]>) -> (TempDir, UnlockedOrgVault) { + let dir = TempDir::new().unwrap(); + let root = dir.path().to_path_buf(); + fs::create_dir_all(root.join("items")).unwrap(); + fs::create_dir_all(root.join("keys")).unwrap(); + let vault = UnlockedOrgVault { root, org_key: key }; + (dir, vault) + } + + #[test] + fn unlocked_org_vault_paths() { + let key = Zeroizing::new([0u8; 32]); + let (dir, vault) = make_vault(key); + let root = dir.path().to_path_buf(); + assert_eq!(vault.manifest_path(), root.join("manifest.enc")); + assert_eq!( + vault.member_key_path(&MemberId("abc0def1abc0def1".into())), + root.join("keys/abc0def1abc0def1.enc") + ); + assert_eq!( + vault.item_path("prod", &relicario_core::ItemId("0123456789abcdef".into())), + root.join("items/prod/0123456789abcdef.enc") + ); + } + + #[test] + fn save_and_load_manifest() { + let key = Zeroizing::new([0xAAu8; 32]); + let (dir, vault) = make_vault(key); + let _ = dir; // keep alive + let mut m = OrgManifest::new(); + m.entries.push(relicario_core::OrgManifestEntry { + id: relicario_core::ItemId::new(), + r#type: relicario_core::ItemType::SecureNote, + title: "test".into(), + tags: vec![], + modified: 0, + trashed_at: None, + collection: "prod".into(), + }); + vault.save_manifest(&m).unwrap(); + let loaded = vault.load_manifest().unwrap(); + assert_eq!(loaded.entries.len(), 1); + } + + #[test] + fn save_and_load_members() { + let key = Zeroizing::new([0u8; 32]); + let (dir, vault) = make_vault(key); + let _ = dir; + let members = OrgMembers::new(); + vault.save_members(&members).unwrap(); + let loaded = vault.load_members().unwrap(); + assert_eq!(loaded.schema_version, 1); + } +} +``` + +> **Note:** `current_device()`, `device_dir(name)`, and `load_signing_key(name)` are real accessors in `crates/relicario-cli/src/device.rs` (device.rs:32, :27, :95). `relicario_core::fingerprint` is re-exported at core `lib.rs:94`. The `ItemId(..)`/`MemberId(..)` tuple constructors in the tests are valid because both are `pub` tuple structs. If `item_path`/`save_item`/`load_item`/`remove_item`/`ensure_grant` warn as unused at this point, that is fine — B9–B13 consume them in the same PR series; do NOT add `#[allow(dead_code)]`. + +- [ ] **Step 2: Wire into main.rs module declarations** + +In `crates/relicario-cli/src/main.rs`, add after the existing `mod session;` line: + +```rust +mod org_session; +``` + +- [ ] **Step 3: Run tests** + +```bash +cargo test -p relicario-cli org_session 2>&1 | tail -20 +``` + +Expected: all org_session tests pass. + +- [ ] **Step 4: Commit** + +```bash +git add crates/relicario-cli/src/org_session.rs crates/relicario-cli/src/main.rs +git commit -m "feat(cli/org): UnlockedOrgVault session (collection-scoped item_path, fingerprint match, signed org_git_run)" +``` + +--- + +## [Dev-B] Task B2: Device seed/pubkey helpers + `ssh-key` CLI dep + +**Files:** +- Modify: `crates/relicario-cli/Cargo.toml` +- Modify: `crates/relicario-cli/src/device.rs` + +> **Context (verified against the real codebase):** Device keys live under `~/.config/relicario/devices//signing.key` (OpenSSH private) + `signing.pub` (OpenSSH public single line). The active device name is in `~/.config/relicario/devices/current`. There is **no** `~/.config/relicario/device.key` and **no** `RELICARIO_DEVICE_KEY` env var. Use the real accessors already in `crates/relicario-cli/src/device.rs`: `current_device() -> Result>` (device.rs:32), `device_dir(name) -> Result` (device.rs:27), `load_signing_key(name) -> Result>` (device.rs:95, returns the OpenSSH private PEM text). The seed-extraction path is verified against `ssh-key` 0.6.7: `PrivateKey::from_openssh(&pem)` → `.key_data()` → `.ed25519()` (`Option<&Ed25519Keypair>`) → `.private` (`Ed25519PrivateKey`) → `.as_ref()` (`&[u8; 32]`). The core crate already uses this exact chain in `relicario-core/src/device.rs:64-69`. + +- [ ] **Step 1: Add `ssh-key` to the CLI crate's dependencies** + +`ssh-key` is **not** currently a dependency of `relicario-cli` (verified: no `ssh-key` line in `crates/relicario-cli/Cargo.toml`). It *is* already in the workspace lock at version **0.6.7** (pulled in by `relicario-core`). In `crates/relicario-cli/Cargo.toml`, under `[dependencies]`, add the line after the `qrcode` entry: + +```toml +ssh-key = { version = "0.6", features = ["ed25519", "std"] } +``` + +The full `[dependencies]` tail should read: + +```toml +reqwest = { version = "0.12", features = ["blocking", "json"] } +qrcode = { version = "0.14", features = ["svg"] } +ssh-key = { version = "0.6", features = ["ed25519", "std"] } +``` + +> **Why these features:** `ed25519` enables the `Ed25519Keypair`/`Ed25519PrivateKey` accessors and the `key_data().ed25519()` path; `std` enables `PrivateKey::from_openssh` / `PublicKey::to_openssh`. This matches the feature set `relicario-core` already relies on, so resolution stays at 0.6.7 — no new lockfile entry, no version bump. + +- [ ] **Step 2: Confirm the dependency resolves without changing the lock version** + +```bash +cargo tree -p relicario-cli -i ssh-key 2>&1 | head -5 +grep -A1 'name = "ssh-key"' Cargo.lock | head -3 +``` + +Expected: `ssh-key v0.6.7` appears for `relicario-cli`, and `Cargo.lock` still pins `version = "0.6.7"` (no second copy of `ssh-key` added). + +- [ ] **Step 3: Add a failing unit test for the round-trip** + +Append this test module to the end of `crates/relicario-cli/src/device.rs`. It generates a keypair with `relicario_core::device::generate_keypair()`, writes it to a temp device dir, points `current` at it, and asserts the seed we extract re-derives the same OpenSSH public key. + +> **Note:** This test mutates `XDG_CONFIG_HOME` to redirect `dirs::config_dir()`; it serializes via a `Mutex` and restores the env afterwards. Requires the `tempfile` dev-dependency (already present in `[dev-dependencies]`). + +```rust +#[cfg(test)] +mod seed_helper_tests { + use super::*; + use std::sync::Mutex; + + // dirs::config_dir() reads process-wide env; serialize these tests. + static ENV_LOCK: Mutex<()> = Mutex::new(()); + + #[test] + fn current_device_seed_and_pubkey_round_trip() { + let _guard = ENV_LOCK.lock().unwrap(); + let tmp = tempfile::tempdir().unwrap(); + let prev_xdg = std::env::var_os("XDG_CONFIG_HOME"); + std::env::set_var("XDG_CONFIG_HOME", tmp.path()); + + // Generate a real ed25519 device keypair (OpenSSH text) via core. + let (private_openssh, public_openssh) = + relicario_core::device::generate_keypair().unwrap(); + + // Lay out devices/test-dev/{signing.key,signing.pub} + devices/current. + let dir = device_dir("test-dev").unwrap(); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("signing.key"), private_openssh.as_str()).unwrap(); + std::fs::write(dir.join("signing.pub"), &public_openssh).unwrap(); + set_current_device("test-dev").unwrap(); + + // pubkey helper returns exactly the stored OpenSSH public line. + let got_pub = current_device_pubkey().unwrap(); + assert_eq!(got_pub.trim(), public_openssh.trim()); + + // seed helper returns the 32-byte ed25519 seed; re-derive the public + // key from it and confirm it matches. + let seed = current_device_seed().unwrap(); + let signing = ed25519_dalek::SigningKey::from_bytes(&seed); + let derived = signing.verifying_key(); + let parsed_pub = ssh_key::PublicKey::from_openssh(&public_openssh).unwrap(); + let parsed_bytes: &[u8] = parsed_pub.key_data().ed25519().unwrap().as_ref(); + assert_eq!(derived.as_bytes().as_slice(), parsed_bytes); + + // restore env + match prev_xdg { + Some(v) => std::env::set_var("XDG_CONFIG_HOME", v), + None => std::env::remove_var("XDG_CONFIG_HOME"), + } + } +} +``` + +> **New dev-dependency note:** This test references `ed25519_dalek::SigningKey`. `ed25519-dalek` is **not** a dev-dependency of `relicario-cli`. Add it to `crates/relicario-cli/Cargo.toml` under `[dev-dependencies]` (already in the workspace lock at the version `relicario-core` uses, so no new lock entry): +> +> ```toml +> ed25519-dalek = "2" +> ``` + +Run it — it must **fail to compile** now (the two helper fns don't exist yet): + +```bash +cargo test -p relicario-cli --lib seed_helper_tests 2>&1 | tail -20 +``` + +Expected: compile error `cannot find function 'current_device_seed'` (and `current_device_pubkey`). + +- [ ] **Step 4: Implement the two helpers** + +Add these two public functions to `crates/relicario-cli/src/device.rs`, immediately after `load_signing_key` (after device.rs:100). Do **not** add a `device_key_path()`. + +```rust +/// Read the active device's ed25519 public key (OpenSSH single-line format, +/// e.g. `ssh-ed25519 AAAA... comment`) from `signing.pub`. +/// +/// Errors if no device is selected (`devices/current` missing/empty) — the +/// caller should hint the user to run `relicario device add` first. +pub fn current_device_pubkey() -> Result { + let name = current_device()? + .ok_or_else(|| anyhow::anyhow!("no active device — run `relicario device add` first"))?; + let path = device_dir(&name)?.join("signing.pub"); + let pubkey = fs::read_to_string(&path) + .with_context(|| format!("read signing.pub for device '{name}'"))?; + let trimmed = pubkey.trim(); + if trimmed.is_empty() { + anyhow::bail!("signing.pub for device '{name}' is empty"); + } + Ok(trimmed.to_string()) +} + +/// Read the active device's 32-byte ed25519 seed from `signing.key` +/// (OpenSSH private-key format). +/// +/// The seed is the secret scalar used to sign org commits and to unwrap the +/// org key. It is returned in `Zeroizing` so it is wiped on drop. Errors if no +/// device is selected, the key file is unreadable, or the key is not ed25519. +pub fn current_device_seed() -> Result> { + let name = current_device()? + .ok_or_else(|| anyhow::anyhow!("no active device — run `relicario device add` first"))?; + // load_signing_key reads signing.key as OpenSSH private-key text. + let pem = load_signing_key(&name)?; + let private = ssh_key::PrivateKey::from_openssh(pem.as_str()) + .map_err(|e| anyhow::anyhow!("parse signing.key for device '{name}': {e}"))?; + let keypair = private + .key_data() + .ed25519() + .ok_or_else(|| anyhow::anyhow!("signing.key for device '{name}' is not ed25519"))?; + // Ed25519PrivateKey::as_ref() yields &[u8; 32] (verified: ssh-key 0.6.7 + // private/ed25519.rs:42). Copy into a Zeroizing array so the seed is wiped. + let mut seed = Zeroizing::new([0u8; 32]); + seed.copy_from_slice(keypair.private.as_ref()); + Ok(seed) +} +``` + +> **Import note:** `fs`, `Context`, `Result`, and `Zeroizing` are already imported at the top of `device.rs` (lines 13–17). `ssh_key` is referenced by fully-qualified path. `anyhow::anyhow!` / `anyhow::bail!` are used fully-qualified to match the existing style. + +> **Note on `current_device_seed` overlap:** `org_session.rs` (Task B1) defines its own private `current_device_seed`/`current_device_fingerprint` for the unwrap path. These device.rs helpers are the *public* device-module surface (also used by Task B4's signing test and by any future caller). They are not the same functions; B1's are module-private to `org_session`. Both reading the same on-disk key is intentional and correct. + +- [ ] **Step 5: Verify the test goes green + regression** + +```bash +cargo test -p relicario-cli --lib seed_helper_tests 2>&1 | tail -20 +cargo check -p relicario-cli 2>&1 | tail -5 +cargo test -p relicario-cli --lib 2>&1 | tail -15 +``` + +Expected: `current_device_seed_and_pubkey_round_trip ... ok`; clean check; all lib tests pass. + +- [ ] **Step 6: Commit** + +```bash +git add crates/relicario-cli/Cargo.toml Cargo.lock crates/relicario-cli/src/device.rs +git commit -m "feat(cli/device): current_device_seed + current_device_pubkey helpers + +Read the active device's ed25519 seed/pubkey from +devices//signing.{key,pub}. Adds ssh-key (0.6) as a CLI dep +(already at 0.6.7 in the workspace lock via relicario-core) and +ed25519-dalek as a dev-dep for the round-trip test. + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## [Dev-B] Task B3: Org commands module stub + `pub mod org` wiring + +**Files:** +- Create: `crates/relicario-cli/src/commands/org.rs` (stub) +- Modify: `crates/relicario-cli/src/commands/mod.rs` + +- [ ] **Step 1: Check existing module layout** + +```bash +grep -n "pub mod" crates/relicario-cli/src/commands/mod.rs | head -30 +``` + +- [ ] **Step 2: Create org commands stub** + +Create `crates/relicario-cli/src/commands/org.rs`: + +```rust +//! `relicario org` subcommands for multi-user org vault management. + +use anyhow::Result; + +pub fn run_init(_dir: &std::path::Path, _name: &str) -> Result<()> { + todo!("org init") +} +``` + +Add to `crates/relicario-cli/src/commands/mod.rs`: + +```rust +pub mod org; +``` + +- [ ] **Step 3: Verify compile** + +```bash +cargo check -p relicario-cli +``` + +Expected: clean (`todo!` is fine at compile time). + +- [ ] **Step 4: Commit** + +```bash +git add crates/relicario-cli/src/commands/org.rs crates/relicario-cli/src/commands/mod.rs +git commit -m "feat(cli/org): org commands module stub + pub mod wiring" +``` + +--- + +## [Dev-B] Task B4: `org init` (with git signing) + +**Files:** +- Modify: `crates/relicario-cli/src/commands/org.rs` +- Create: `crates/relicario-cli/tests/org_init.rs` +- Create: `crates/relicario-cli/tests/org_init_signing.rs` + +`org init` creates the org directory structure, generates the org master key, wraps it to the caller's device key, writes all initial files, runs `git init`, **configures git signing** (`configure_git_signing`), and makes a **signed** bootstrap commit via `org_git_run`. + +> **Why signing is load-bearing:** `crate::helpers::git_run` (helpers.rs:73) flows through `git_command` (helpers.rs:46), which hard-codes `-c commit.gpgsign=false` (helpers.rs:51). Any commit made through `git_run` is **unsigned** — and the pre-receive hook (Dev-C) rejects/cannot-attribute unsigned commits. So `org init` must (1) call `configure_git_signing(dir, &device_name)` (device.rs:133) to set `gpg.format=ssh`, `user.signingkey=`, `commit.gpgsign=true`, and (2) make the bootstrap commit via `org_git_run` (Task B1), which does NOT disable signing. This task folds the org-init structure/wrap logic and the signing wiring into one — there is **no** separate standalone signing task. + +- [ ] **Step 1: Write the plain integration test** + +Create `crates/relicario-cli/tests/org_init.rs`: + +```rust +use tempfile::TempDir; + +fn run(args: &[&str]) -> std::process::Output { + std::process::Command::new(env!("CARGO_BIN_EXE_relicario")) + .args(args) + .output() + .expect("run relicario") +} + +#[test] +#[ignore] // requires a device key on disk; run manually or via org_init_signing +fn org_init_creates_expected_files() { + let dir = TempDir::new().unwrap(); + let path = dir.path().to_str().unwrap(); + let out = run(&["--dir", path, "org", "init", "--name", "Test Org"]); + assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr)); + assert!(dir.path().join("org.json").exists()); + assert!(dir.path().join("members.json").exists()); + assert!(dir.path().join("collections.json").exists()); + assert!(dir.path().join("manifest.enc").exists()); + assert!(dir.path().join(".git").exists()); +} +``` + +- [ ] **Step 2: Write the failing signing integration test** + +Create `crates/relicario-cli/tests/org_init_signing.rs`: + +```rust +use std::path::Path; +use std::process::Command; +use tempfile::TempDir; + +fn relicario(config_home: &Path, args: &[&str]) -> std::process::Output { + Command::new(env!("CARGO_BIN_EXE_relicario")) + .env("XDG_CONFIG_HOME", config_home) + .env("HOME", config_home) // belt-and-suspenders for dirs on all platforms + .args(args) + .output() + .expect("run relicario") +} + +fn git(repo: &Path, args: &[&str]) -> std::process::Output { + Command::new("git") + .current_dir(repo) + .args(args) + .output() + .expect("run git") +} + +#[test] +fn org_init_produces_a_signed_initial_commit() { + let cfg = TempDir::new().unwrap(); + let org = TempDir::new().unwrap(); + + // Register a device so current_device_pubkey()/configure_git_signing work. + let add = relicario(cfg.path(), &["device", "add", "--name", "test-dev", "--local"]); + assert!( + add.status.success(), + "device add failed: {}", + String::from_utf8_lossy(&add.stderr) + ); + + // Initialize the org vault. + let init = relicario( + cfg.path(), + &["--dir", org.path().to_str().unwrap(), "org", "init", "--name", "Acme"], + ); + assert!( + init.status.success(), + "org init failed: {}", + String::from_utf8_lossy(&init.stderr) + ); + + // The org repo must be configured to sign. + let cfg_out = git(org.path(), &["config", "commit.gpgsign"]); + assert_eq!( + String::from_utf8_lossy(&cfg_out.stdout).trim(), + "true", + "org repo must have commit.gpgsign=true" + ); + + // The HEAD commit object must carry a signature header. + let head = git(org.path(), &["cat-file", "commit", "HEAD"]); + let body = String::from_utf8_lossy(&head.stdout); + assert!( + body.contains("gpgsig "), + "HEAD commit must be signed (no gpgsig header found):\n{body}" + ); + + // And it must actually verify against the configured signer. + let verify = git(org.path(), &["verify-commit", "HEAD"]); + assert!( + verify.status.success(), + "git verify-commit HEAD failed: {}", + String::from_utf8_lossy(&verify.stderr) + ); +} +``` + +> **Test-harness assumption:** `relicario device add --name --local` creates the device keys under `$XDG_CONFIG_HOME/relicario/devices//` and sets `current` without contacting Gitea. If the real `device add` surface differs (verify against `main.rs` `DeviceAction` and `commands::device`), adjust the flags — but the assertions (signed HEAD, `commit.gpgsign=true`, `verify-commit` success) are load-bearing and must stay. If `device add` cannot run offline in CI, lay out `devices//signing.{key,pub}` + `current` directly using `relicario_core::device::generate_keypair()` (same layout as Task B2 Step 3). + +```bash +cargo test -p relicario-cli --test org_init_signing 2>&1 | tail -25 +``` + +Expected red: `commit.gpgsign` empty or `gpgsig ` header absent (init not implemented / committed via unsigned path). + +- [ ] **Step 3: Implement `run_init` (structure + wrap + signing + signed commit)** + +Replace `run_init` stub in `commands/org.rs`: + +```rust +use std::fs; +use std::path::Path; +use anyhow::{Context, Result}; +use relicario_core::{ + generate_org_key, wrap_org_key, + CollectionDef, MemberId, OrgCollections, OrgManifest, OrgMembers, OrgMeta, OrgRole, OrgMember, + encrypt_org_manifest, +}; +use crate::org_session::atomic_write; + +pub fn run_init(dir: &Path, name: &str) -> Result<()> { + // Create directory structure + fs::create_dir_all(dir.join("items")).context("create items/")?; + fs::create_dir_all(dir.join("keys")).context("create keys/")?; + + // Get caller's device info + let device_pubkey = crate::device::current_device_pubkey() + .context("read device key — run `relicario device add` first")?; + + // Generate org master key + let org_key = generate_org_key(); + + // Wrap org key to caller's device key + let wrapped = wrap_org_key(&org_key, &device_pubkey) + .context("wrap org key to device key")?; + + // Create initial members.json with caller as owner + let caller_id = MemberId::new(); + let now = relicario_core::now_unix(); + let member = OrgMember { + member_id: caller_id.clone(), + display_name: whoami(), + role: OrgRole::Owner, + ed25519_pubkey: device_pubkey, + collections: vec![], + added_at: now, + added_by: caller_id.clone(), + }; + let mut members = OrgMembers::new(); + members.members.push(member); + + // Write wrapped key + let key_path = dir.join("keys").join(format!("{}.enc", caller_id.as_str())); + fs::write(&key_path, &wrapped).context("write caller key blob")?; + + // Write org.json + let meta = OrgMeta::new(name.to_string()); + let meta_json = serde_json::to_string_pretty(&meta)?; + atomic_write(&dir.join("org.json"), meta_json.as_bytes())?; + + // Write members.json + let members_json = serde_json::to_string_pretty(&members)?; + atomic_write(&dir.join("members.json"), members_json.as_bytes())?; + + // Write collections.json (empty) + let collections = OrgCollections::new(); + let coll_json = serde_json::to_string_pretty(&collections)?; + atomic_write(&dir.join("collections.json"), coll_json.as_bytes())?; + + // Write empty manifest.enc + let manifest = OrgManifest::new(); + let manifest_bytes = encrypt_org_manifest(&manifest, &org_key)?; + atomic_write(&dir.join("manifest.enc"), &manifest_bytes)?; + + // git init, then configure THIS repo to sign commits with the active device + // key. Org commits must be signed; the pre-receive hook verifies every one. + crate::helpers::git_run(dir, &["init"], "git init")?; + let device_name = crate::device::current_device()? + .ok_or_else(|| anyhow::anyhow!("no active device — run `relicario device add` first"))?; + crate::device::configure_git_signing(dir, &device_name) + .context("configure org repo signing")?; + + // Stage everything and make the signed bootstrap commit via org_git_run + // (which does NOT disable signing, unlike helpers::git_run). + crate::org_session::org_git_run(dir, &["add", "."], "git add")?; + let commit_msg = format!( + "init: org vault \"{name}\"\n\nRelicario-Actor: {} {}\nRelicario-Action: org-init", + members.members[0].display_name, + caller_id.as_str() + ); + crate::org_session::org_git_run(dir, &["commit", "-m", &commit_msg], "git commit")?; + + println!("Org vault initialized at {}", dir.display()); + println!("Your member ID: {}", caller_id.as_str()); + Ok(()) +} + +fn whoami() -> String { + std::env::var("USER") + .or_else(|_| std::env::var("USERNAME")) + .unwrap_or_else(|_| "unknown".into()) +} +``` + +> **Verified:** `configure_git_signing(vault_root: &Path, name: &str) -> Result<()>` (device.rs:133). The trailer uses `Relicario-Actor: ` (space-separated, no angle brackets) per the canonical contract — the hook ignores the trailer for actor attribution (it uses the verified signer), but keeping it contract-shaped matters for the audit `TAMPERED` cross-check. + +- [ ] **Step 4: Verify both tests + build** + +```bash +cargo test -p relicario-cli --test org_init_signing 2>&1 | tail -25 +cargo build -p relicario-cli 2>&1 | tail -10 +``` + +Expected: `org_init_produces_a_signed_initial_commit ... ok`; clean build. + +- [ ] **Step 5: Commit** + +```bash +git add crates/relicario-cli/src/commands/org.rs crates/relicario-cli/tests/org_init.rs crates/relicario-cli/tests/org_init_signing.rs +git commit -m "feat(cli/org): org init — structure + wrap + configure_git_signing + signed bootstrap commit" +``` + +--- + +## [Dev-B] Task B5: `org add-member` / `remove-member` / `set-role` (with owner-only role-gating) + +**Files:** +- Modify: `crates/relicario-cli/src/commands/org.rs` + +All three commands share the open-vault → edit members.json → signed commit pattern. **Role-gating correction:** `add-member` only checked `can_manage_members()` and then trusted the caller-supplied `role` unconditionally — an admin could mint a new owner. The fix adds an owner-only gate so non-owners cannot create owner/admin members (spec line 148/273). + +- [ ] **Step 1: Write failing unit test** + +Add to `commands/org.rs`: + +```rust +#[cfg(test)] +mod tests { + use super::*; + use relicario_core::{MemberId, OrgMembers, OrgRole, OrgMember}; + + fn alice() -> OrgMember { + OrgMember { + member_id: MemberId::new(), + display_name: "Alice".into(), + role: OrgRole::Member, + ed25519_pubkey: "ssh-ed25519 AAAA fake".into(), + collections: vec![], + added_at: 0, + added_by: MemberId::new(), + } + } + + #[test] + fn set_role_changes_role() { + let mut members = OrgMembers::new(); + let a = alice(); + let id = a.member_id.clone(); + members.members.push(a); + if let Some(m) = members.find_by_id_mut(&id) { + m.role = OrgRole::Admin; + } + assert_eq!(members.find_by_id(&id).unwrap().role, OrgRole::Admin); + } +} +``` + +```bash +cargo test -p relicario-cli commands::org::tests 2>&1 | tail -5 +``` + +Expected: PASS (pure logic test). + +- [ ] **Step 2: Implement add-member (with owner-only escalation guard)** + +Add to `commands/org.rs`: + +```rust +pub fn run_add_member( + dir: &Path, + pubkey: &str, + name: &str, + role: OrgRole, +) -> Result<()> { + let vault = crate::org_session::open_org_vault(Some(dir))?; + let caller = vault.current_member()?; + if !caller.role.can_manage_members() { + anyhow::bail!("only owners and admins can add members"); + } + // Privilege-escalation guard: only an owner may create an owner or admin. + if matches!(role, OrgRole::Owner | OrgRole::Admin) && !caller.role.can_manage_owners() { + anyhow::bail!("only owners can add members with the owner or admin role"); + } + + let mut members = vault.load_members()?; + + // Check pubkey not already present + if members.members.iter().any(|m| m.ed25519_pubkey.trim() == pubkey.trim()) { + anyhow::bail!("this public key is already registered in the org"); + } + + let new_id = MemberId::new(); + let now = relicario_core::now_unix(); + let wrapped = wrap_org_key(vault.key(), pubkey) + .context("wrap org key to new member's key")?; + + fs::write(vault.member_key_path(&new_id), &wrapped) + .context("write member key blob")?; + + members.members.push(OrgMember { + member_id: new_id.clone(), + display_name: name.to_string(), + role, + ed25519_pubkey: pubkey.trim().to_string(), + collections: vec![], + added_at: now, + added_by: caller.member_id.clone(), + }); + vault.save_members(&members)?; + + let commit_msg = format!( + "org: add member \"{name}\"\n\nRelicario-Actor: {} {}\nRelicario-Action: member-add\nRelicario-Member: {}", + caller.display_name, caller.member_id.as_str(), new_id.as_str() + ); + crate::org_session::org_git_run( + &vault.root, + &["add", "members.json", &format!("keys/{}.enc", new_id.as_str())], + "git add", + )?; + crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "git commit")?; + + println!("Added {} ({})", name, new_id.as_str()); + Ok(()) +} +``` + +> `role: OrgRole` is the existing parameter; `OrgRole` derives `Copy`, so `matches!(role, ...)` does not move it. + +- [ ] **Step 3: Implement remove-member** + +```rust +pub fn run_remove_member(dir: &Path, member_id_prefix: &str) -> Result<()> { + let vault = crate::org_session::open_org_vault(Some(dir))?; + let caller = vault.current_member()?; + if !caller.role.can_manage_members() { + anyhow::bail!("only owners and admins can remove members"); + } + + let mut members = vault.load_members()?; + let target_id = resolve_member_id(&members, member_id_prefix)?; + + let target = members.find_by_id(&target_id).unwrap(); + if target.role == OrgRole::Owner && !caller.role.can_manage_owners() { + anyhow::bail!("only owners can remove other owners"); + } + let target_name = target.display_name.clone(); + + // Delete key blob + let key_path = vault.member_key_path(&target_id); + if key_path.exists() { fs::remove_file(&key_path).context("delete key blob")?; } + + members.members.retain(|m| m.member_id != target_id); + vault.save_members(&members)?; + + let commit_msg = format!( + "org: remove member \"{target_name}\"\n\nRelicario-Actor: {} {}\nRelicario-Action: member-remove\nRelicario-Member: {}", + caller.display_name, caller.member_id.as_str(), target_id.as_str() + ); + crate::org_session::org_git_run( + &vault.root, + &["add", "members.json", &format!("keys/{}.enc", target_id.as_str())], + "git add", + )?; + crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "git commit")?; + + eprintln!("⚠ Run `relicario org rotate-key --dir {}` to complete revocation.", vault.root.display()); + println!("Removed {}", target_name); + Ok(()) +} +``` + +- [ ] **Step 4: Implement set-role** + +```rust +pub fn run_set_role(dir: &Path, member_id_prefix: &str, role: OrgRole) -> Result<()> { + let vault = crate::org_session::open_org_vault(Some(dir))?; + let caller = vault.current_member()?; + + let mut members = vault.load_members()?; + let target_id = resolve_member_id(&members, member_id_prefix)?; + + if matches!(role, OrgRole::Admin | OrgRole::Owner) && !caller.role.can_manage_owners() { + anyhow::bail!("only owners can promote to admin or owner"); + } + if !caller.role.can_manage_members() { + anyhow::bail!("only owners and admins can change roles"); + } + + let target = members.find_by_id_mut(&target_id) + .ok_or_else(|| anyhow::anyhow!("member not found"))?; + let old_role = target.role; + target.role = role; + vault.save_members(&members)?; + + let commit_msg = format!( + "org: set role {} → {:?}\n\nRelicario-Actor: {} {}\nRelicario-Action: member-role-change\nRelicario-Member: {}", + target_id.as_str(), role, + caller.display_name, caller.member_id.as_str(), + target_id.as_str() + ); + crate::org_session::org_git_run(&vault.root, &["add", "members.json"], "git add")?; + crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "git commit")?; + + println!("Changed role {:?} → {:?}", old_role, role); + Ok(()) +} + +/// Resolve a member_id prefix (or full ID) to a MemberId. +fn resolve_member_id(members: &OrgMembers, prefix: &str) -> Result { + let hits: Vec<_> = members.members.iter() + .filter(|m| m.member_id.as_str().starts_with(prefix)) + .collect(); + match hits.len() { + 0 => anyhow::bail!("no member matches `{prefix}`"), + 1 => Ok(hits[0].member_id.clone()), + _ => anyhow::bail!("ambiguous prefix `{prefix}` — {} matches", hits.len()), + } +} +``` + +- [ ] **Step 5: Compile + commit** + +```bash +cargo build -p relicario-cli 2>&1 | tail -10 +git add crates/relicario-cli/src/commands/org.rs +git commit -m "feat(cli/org): add-member (owner-only escalation guard), remove-member, set-role" +``` + +--- + +## [Dev-B] Task B6: `org create-collection` / `grant` / `revoke` + +**Files:** +- Modify: `crates/relicario-cli/src/commands/org.rs` + +- [ ] **Step 1: Write failing tests** + +Add to the `tests` block in `commands/org.rs`: + +```rust +#[test] +fn grant_adds_slug_to_member_collections() { + let mut members = OrgMembers::new(); + let a = alice(); + let id = a.member_id.clone(); + members.members.push(a); + + let m = members.find_by_id_mut(&id).unwrap(); + if !m.collections.contains(&"prod".to_string()) { + m.collections.push("prod".to_string()); + } + assert!(members.find_by_id(&id).unwrap().collections.contains(&"prod".to_string())); +} + +#[test] +fn revoke_removes_slug_from_member_collections() { + let mut members = OrgMembers::new(); + let mut a = alice(); + a.collections = vec!["prod".into(), "dev".into()]; + let id = a.member_id.clone(); + members.members.push(a); + + let m = members.find_by_id_mut(&id).unwrap(); + m.collections.retain(|s| s != "prod"); + assert!(!members.find_by_id(&id).unwrap().collections.contains(&"prod".to_string())); + assert!(members.find_by_id(&id).unwrap().collections.contains(&"dev".to_string())); +} +``` + +```bash +cargo test -p relicario-cli commands::org::tests 2>&1 | tail -5 +``` + +Expected: all pass. + +- [ ] **Step 2: Implement create-collection** + +```rust +pub fn run_create_collection(dir: &Path, slug: &str, display_name: &str) -> Result<()> { + let vault = crate::org_session::open_org_vault(Some(dir))?; + let caller = vault.current_member()?; + if !caller.role.can_manage_members() { + anyhow::bail!("only owners and admins can create collections"); + } + + let mut collections = vault.load_collections()?; + if collections.contains_slug(slug) { + anyhow::bail!("collection `{slug}` already exists"); + } + if slug.is_empty() || slug.contains('/') || slug.contains('.') { + anyhow::bail!("invalid slug `{slug}` — no slashes or dots, no empty string"); + } + + collections.collections.push(CollectionDef { + slug: slug.to_string(), + display_name: display_name.to_string(), + created_by: caller.member_id.clone(), + created_at: relicario_core::now_unix(), + }); + vault.save_collections(&collections)?; + + let commit_msg = format!( + "org: create collection \"{slug}\"\n\nRelicario-Actor: {} {}\nRelicario-Action: collection-create\nRelicario-Collection: {slug}", + caller.display_name, caller.member_id.as_str() + ); + crate::org_session::org_git_run(&vault.root, &["add", "collections.json"], "git add")?; + crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "git commit")?; + + println!("Created collection `{slug}`"); + Ok(()) +} +``` + +- [ ] **Step 3: Implement grant** + +```rust +pub fn run_grant(dir: &Path, member_id_prefix: &str, slug: &str) -> Result<()> { + let vault = crate::org_session::open_org_vault(Some(dir))?; + let caller = vault.current_member()?; + if !caller.role.can_manage_members() { + anyhow::bail!("only owners and admins can grant collection access"); + } + + let collections = vault.load_collections()?; + if !collections.contains_slug(slug) { + anyhow::bail!("collection `{slug}` does not exist — create it first"); + } + + let mut members = vault.load_members()?; + let target_id = resolve_member_id(&members, member_id_prefix)?; + let target = members.find_by_id_mut(&target_id).unwrap(); + if target.collections.contains(&slug.to_string()) { + anyhow::bail!("member already has access to `{slug}`"); + } + target.collections.push(slug.to_string()); + vault.save_members(&members)?; + + let commit_msg = format!( + "org: grant {slug} to {}\n\nRelicario-Actor: {} {}\nRelicario-Action: collection-grant\nRelicario-Collection: {slug}\nRelicario-Member: {}", + target_id.as_str(), caller.display_name, caller.member_id.as_str(), target_id.as_str() + ); + crate::org_session::org_git_run(&vault.root, &["add", "members.json"], "git add")?; + crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "git commit")?; + + println!("Granted `{slug}` to {}", target_id.as_str()); + Ok(()) +} +``` + +- [ ] **Step 4: Implement revoke** + +```rust +pub fn run_revoke(dir: &Path, member_id_prefix: &str, slug: &str) -> Result<()> { + let vault = crate::org_session::open_org_vault(Some(dir))?; + let caller = vault.current_member()?; + if !caller.role.can_manage_members() { + anyhow::bail!("only owners and admins can revoke collection access"); + } + + let mut members = vault.load_members()?; + let target_id = resolve_member_id(&members, member_id_prefix)?; + let target = members.find_by_id_mut(&target_id).unwrap(); + if !target.collections.contains(&slug.to_string()) { + anyhow::bail!("member does not have access to `{slug}`"); + } + target.collections.retain(|s| s != slug); + vault.save_members(&members)?; + + let commit_msg = format!( + "org: revoke {slug} from {}\n\nRelicario-Actor: {} {}\nRelicario-Action: collection-revoke\nRelicario-Collection: {slug}\nRelicario-Member: {}", + target_id.as_str(), caller.display_name, caller.member_id.as_str(), target_id.as_str() + ); + crate::org_session::org_git_run(&vault.root, &["add", "members.json"], "git add")?; + crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "git commit")?; + + println!("Revoked `{slug}` from {}", target_id.as_str()); + Ok(()) +} +``` + +- [ ] **Step 5: Compile + commit** + +```bash +cargo build -p relicario-cli 2>&1 | tail -5 +git add crates/relicario-cli/src/commands/org.rs +git commit -m "feat(cli/org): create-collection, grant, revoke commands" +``` + +--- + +## [Dev-B] Task B7: `org rotate-key` (re-encrypt every item blob + race abort) + +**Files:** +- Modify: `crates/relicario-cli/src/commands/org.rs` + +`rotate-key` generates a new org master key, re-wraps it for all current members, **re-encrypts every item blob AND the manifest** under the new key, and commits. Two corrections from the review: **(H9)** the old code re-encrypted only `manifest.enc`, leaving every `items//.enc` under the *old* key — a removed member with a clone could still decrypt all pre-rotation items; we now walk and re-encrypt every blob. **(race)** the old pull-failure handler swallowed *all* errors (including a genuine non-fast-forward / concurrent rotation) and proceeded; we now abort on a real conflict with the exact spec string while still distinguishing a missing remote. + +- [ ] **Step 1: Write failing test** + +Add to tests block: + +```rust +#[test] +fn new_key_differs_from_old_key() { + let k1 = relicario_core::generate_org_key(); + let k2 = relicario_core::generate_org_key(); + assert_ne!(*k1, *k2); +} +``` + +```bash +cargo test -p relicario-cli commands::org::tests::new_key_differs 2>&1 | tail -5 +``` + +Expected: PASS. + +- [ ] **Step 2: Implement rotate-key** + +```rust +pub fn run_rotate_key(dir: &Path) -> Result<()> { + // Pull latest state first to detect a concurrent rotation. We must + // distinguish three outcomes: + // * success -> proceed + // * no upstream / no remote -> local-only org, proceed + // * non-fast-forward / conflict -> concurrent rotation, ABORT + let pull = std::process::Command::new("git") + .current_dir(dir) + .args(["pull", "--rebase"]) + .output() + .context("spawn git pull --rebase")?; + if !pull.status.success() { + let stderr = String::from_utf8_lossy(&pull.stderr); + let no_upstream = stderr.contains("no tracking information") + || stderr.contains("There is no tracking information") + || stderr.contains("does not appear to be a git repository") + || stderr.contains("Could not read from remote repository") + || stderr.contains("No remote repository specified"); + if no_upstream { + eprintln!("Note: no upstream configured; proceeding with local state."); + } else { + // Best-effort: leave the working tree clean for the retry. + let _ = std::process::Command::new("git") + .current_dir(dir) + .args(["rebase", "--abort"]) + .output(); + anyhow::bail!( + "Concurrent key rotation detected — pull and re-run org rotate-key." + ); + } + } + + let vault = crate::org_session::open_org_vault(Some(dir))?; + let caller = vault.current_member()?; + if !caller.role.can_manage_owners() { + anyhow::bail!("only owners can rotate the org master key"); + } + + let members = vault.load_members()?; + let new_org_key = relicario_core::generate_org_key(); + + // Re-wrap the org key for every current member. + let mut staged_paths: Vec = Vec::new(); + for member in &members.members { + let wrapped = wrap_org_key(&new_org_key, &member.ed25519_pubkey) + .with_context(|| format!("wrap key for {}", member.display_name))?; + let key_path = vault.member_key_path(&member.member_id); + fs::write(&key_path, &wrapped) + .with_context(|| format!("write key for {}", member.display_name))?; + staged_paths.push(format!("keys/{}.enc", member.member_id.as_str())); + } + + // Re-encrypt EVERY item blob under the new key. Items live collection-scoped + // at items//.enc. Decrypt with the old key (held in the + // open vault session) and re-encrypt with the new one, in place. Without this + // a removed member who kept the old key + a clone could still decrypt every + // pre-rotation item. + let items_root = vault.root().join("items"); + if items_root.is_dir() { + for slug_entry in fs::read_dir(&items_root).context("read items/")? { + let slug_entry = slug_entry.context("read items/ entry")?; + let slug_dir = slug_entry.path(); + if !slug_dir.is_dir() { + continue; + } + let slug = slug_entry.file_name().to_string_lossy().to_string(); + for item_entry in fs::read_dir(&slug_dir) + .with_context(|| format!("read items/{slug}/"))? + { + let item_entry = item_entry.context("read item entry")?; + let item_path = item_entry.path(); + if item_path.extension().and_then(|e| e.to_str()) != Some("enc") { + continue; + } + let old_bytes = fs::read(&item_path) + .with_context(|| format!("read {}", item_path.display()))?; + let item = relicario_core::decrypt_item(&old_bytes, vault.key()) + .with_context(|| format!("decrypt {}", item_path.display()))?; + let new_bytes = relicario_core::encrypt_item(&item, &new_org_key) + .with_context(|| format!("re-encrypt {}", item_path.display()))?; + crate::org_session::atomic_write(&item_path, &new_bytes)?; + let file_name = item_entry.file_name().to_string_lossy().to_string(); + staged_paths.push(format!("items/{slug}/{file_name}")); + } + } + } + + // Re-encrypt the manifest with the new key. + let manifest = vault.load_manifest()?; + let new_manifest_bytes = relicario_core::encrypt_org_manifest(&manifest, &new_org_key)?; + crate::org_session::atomic_write(&vault.manifest_path(), &new_manifest_bytes)?; + staged_paths.push("manifest.enc".to_string()); + + // Commit + let mut add_args = vec!["add"]; + let path_refs: Vec<&str> = staged_paths.iter().map(|s| s.as_str()).collect(); + add_args.extend_from_slice(&path_refs); + crate::org_session::org_git_run(&vault.root, &add_args, "git add")?; + + let commit_msg = format!( + "org: rotate org master key\n\nRelicario-Actor: {} {}\nRelicario-Action: key-rotate", + caller.display_name, caller.member_id.as_str() + ); + crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "git commit")?; + + println!( + "Key rotated. {} member key(s) re-wrapped; all item blobs + manifest re-encrypted.", + members.members.len() + ); + Ok(()) +} +``` + +> **Verified:** `decrypt_item(&[u8], &Zeroizing<[u8;32]>) -> Result` and `encrypt_item(&Item, &Zeroizing<[u8;32]>) -> Result>` (re-exported at core lib.rs:81–82). `vault.key()` returns the OLD key; `vault.root()` returns `&PathBuf`. The `git add` step stages `staged_paths`, now including every `items//.enc`. + +- [ ] **Step 3: Build + commit** + +```bash +cargo build -p relicario-cli 2>&1 | tail -5 +git add crates/relicario-cli/src/commands/org.rs +git commit -m "feat(cli/org): rotate-key — re-encrypt every item blob + abort on concurrent rotation" +``` + +--- + +## [Dev-B] Task B8: `org status` + `org audit` (verified-signer attribution + TAMPERED flag) + +**Files:** +- Modify: `crates/relicario-cli/src/commands/org.rs` +- Modify: `crates/relicario-cli/Cargo.toml` (ensure `regex` + `tempfile` in `[dependencies]`) + +`status` prints members + collections with no decryption. `audit` parses `git log`, **resolves each commit's verified signer** to a member and reports *that* as the actor (trailers are advisory), flags trailer/signer mismatch as `TAMPERED`, and frames records with `%x1e`/`%x1f` (so multi-line trailer values cannot misalign records) using the **committer** date (`%cI`). + +> **Dependency note:** `run_audit`/`resolve_signer` need `regex` and `tempfile` available to `relicario-cli` at runtime (not just dev). Add to `crates/relicario-cli/Cargo.toml` `[dependencies]` if absent: `regex = "1"` and `tempfile = "3"` (both already in the workspace lock via relicario-server). + +- [ ] **Step 1: Implement status** + +```rust +pub fn run_status(dir: &Path) -> Result<()> { + let root = crate::org_session::org_dir(Some(dir))?; + + let meta: relicario_core::OrgMeta = { + let s = fs::read_to_string(root.join("org.json")).context("read org.json")?; + serde_json::from_str(&s)? + }; + let members: OrgMembers = { + let s = fs::read_to_string(root.join("members.json")).context("read members.json")?; + serde_json::from_str(&s)? + }; + let collections: OrgCollections = { + let s = fs::read_to_string(root.join("collections.json")).context("read collections.json")?; + serde_json::from_str(&s)? + }; + + println!("Org: {} ({})", meta.display_name, meta.org_id.as_str()); + println!(); + println!("Members ({}):", members.members.len()); + for m in &members.members { + let colls = if m.collections.is_empty() { + "(no collections)".to_string() + } else { + m.collections.join(", ") + }; + println!(" {:?} {} {} [{}]", m.role, m.member_id.as_str(), m.display_name, colls); + } + println!(); + println!("Collections ({}):", collections.collections.len()); + for c in &collections.collections { + println!(" {} — {}", c.slug, c.display_name); + } + Ok(()) +} +``` + +- [ ] **Step 2: Write failing test for audit trailer parsing** + +Add to tests block: + +```rust +#[test] +fn parse_trailers_extracts_relicario_fields() { + // Contract trailer shape: "Relicario-Actor: ". + let raw = "Relicario-Actor: alice a1b2c3d4e5f6a1b2\nRelicario-Action: item-create\nRelicario-Collection: prod\n"; + let event = parse_trailer_block("abc123", "2026-06-06T12:00:00+00:00", raw); + assert_eq!(event.action.as_deref(), Some("item-create")); + assert_eq!(event.collection.as_deref(), Some("prod")); + // The verified actor_id is resolved later from the signature, not the trailer; + // the trailer only populates trailer_actor_id here. + assert_eq!(event.trailer_actor_id.as_deref(), Some("a1b2c3d4e5f6a1b2")); + assert_eq!(event.actor_id, None); + assert!(!event.tampered); +} +``` + +```bash +cargo test -p relicario-cli commands::org::tests::parse_trailers 2>&1 | tail -5 +``` + +Expected: FAIL — `parse_trailer_block` not defined. + +- [ ] **Step 3: Implement audit (struct, trailer parser, signer resolver, run_audit)** + +```rust +#[derive(Debug, serde::Serialize)] +pub struct AuditEvent { + pub commit: String, + pub timestamp: String, + /// Actor as resolved from the VERIFIED signing key (authoritative). + pub actor_name: Option, + pub actor_id: Option, + /// Actor id as CLAIMED by the commit trailer (advisory; for tamper-checking). + pub trailer_actor_id: Option, + pub action: Option, + pub collection: Option, + pub item_id: Option, + pub device_id: Option, + /// True when the trailer's claimed actor disagrees with the verified signer, + /// or when no current member matches the signing key. + pub tampered: bool, +} + +fn parse_trailer_block(commit: &str, timestamp: &str, trailers: &str) -> AuditEvent { + let mut ev = AuditEvent { + commit: commit.to_string(), + timestamp: timestamp.to_string(), + actor_name: None, + actor_id: None, + trailer_actor_id: None, + action: None, + collection: None, + item_id: None, + device_id: None, + tampered: false, + }; + for line in trailers.lines() { + let line = line.trim(); + if let Some(rest) = line.strip_prefix("Relicario-Actor:") { + // Contract format: " " (member_id is the last token). + let rest = rest.trim(); + if let Some((_name, id)) = rest.rsplit_once(' ') { + ev.trailer_actor_id = Some(id.trim().to_string()); + } else if !rest.is_empty() { + ev.trailer_actor_id = Some(rest.to_string()); + } + } else if let Some(v) = line.strip_prefix("Relicario-Action:") { + ev.action = Some(v.trim().to_string()); + } else if let Some(v) = line.strip_prefix("Relicario-Collection:") { + ev.collection = Some(v.trim().to_string()); + } else if let Some(v) = line.strip_prefix("Relicario-Item:") { + ev.item_id = Some(v.trim().to_string()); + } else if let Some(v) = line.strip_prefix("Relicario-Device:") { + ev.device_id = Some(v.trim().to_string()); + } + } + ev +} + +/// Resolve a commit's SSH signature fingerprint to a current member, mirroring +/// the pre-receive hook: build an allowed_signers from members.json, inject it +/// via GIT_CONFIG_*, run `git verify-commit --raw`, parse the SHA256: key from +/// stderr. Returns None if the commit is unsigned or the signer is not a member. +fn resolve_signer<'m>( + root: &Path, + commit: &str, + members: &'m relicario_core::OrgMembers, +) -> Option<&'m relicario_core::OrgMember> { + use std::io::Write; + let mut tmp = tempfile::NamedTempFile::new().ok()?; + for m in &members.members { + let _ = writeln!(tmp, "relicario {}", m.ed25519_pubkey.trim()); + } + let allowed_path = tmp.path(); + + let output = std::process::Command::new("git") + .current_dir(root) + .args(["verify-commit", "--raw", commit]) + .env("GIT_CONFIG_COUNT", "1") + .env("GIT_CONFIG_KEY_0", "gpg.ssh.allowedSignersFile") + .env("GIT_CONFIG_VALUE_0", allowed_path.as_os_str()) + .output() + .ok()?; + let stderr = String::from_utf8_lossy(&output.stderr); + + let re = regex::Regex::new(r"key (SHA256:[A-Za-z0-9+/]+)").ok()?; + let fp = re.captures(&stderr)?.get(1)?.as_str().to_string(); + + members.members.iter().find(|m| { + relicario_core::fingerprint(&m.ed25519_pubkey).ok().as_deref() == Some(fp.as_str()) + }) +} + +pub fn run_audit( + dir: &Path, + since: Option<&str>, + member_filter: Option<&str>, + collection_filter: Option<&str>, + action_filter: Option<&str>, + json: bool, +) -> Result<()> { + let root = crate::org_session::org_dir(Some(dir))?; + + // members.json — needed to resolve each commit's verified signer to a member. + let members: relicario_core::OrgMembers = { + let s = fs::read_to_string(root.join("members.json")).context("read members.json")?; + serde_json::from_str(&s).context("parse members.json")? + }; + + // git log framed with a record separator (%x1e, U+001E) PER COMMIT and a + // field separator (%x1f, U+001F) between fields, so multi-line trailer + // values cannot misalign record boundaries. Committer date (%cI), not + // author date: it is what revocation/audit is anchored to. + let fmt = "%x1e%H%x1f%cI%x1f%(trailers:only=true,unfold=true)"; + let mut args: Vec = vec!["log".into(), format!("--format={fmt}")]; + if let Some(s) = since { + args.push(format!("--since={s}")); + } + + let output = std::process::Command::new("git") + .current_dir(&root) + .args(&args) + .output() + .context("git log")?; + let log = String::from_utf8_lossy(&output.stdout); + + let mut events: Vec = Vec::new(); + for record in log.split('\u{1e}') { + let record = record.trim_start_matches('\n'); + if record.trim().is_empty() { + continue; + } + let mut fields = record.splitn(3, '\u{1f}'); + let commit = fields.next().unwrap_or("").trim(); + let ts = fields.next().unwrap_or("").trim(); + let trailers = fields.next().unwrap_or(""); + if commit.is_empty() { + continue; + } + + let mut ev = parse_trailer_block(commit, ts, trailers); + if ev.action.is_none() { + continue; // not an org commit + } + + // Resolve the VERIFIED signer and attribute it as the authoritative actor. + match resolve_signer(&root, commit, &members) { + Some(m) => { + ev.actor_name = Some(m.display_name.clone()); + ev.actor_id = Some(m.member_id.as_str().to_string()); + // Tampered if the trailer claims a different actor than the signer. + if let Some(claimed) = ev.trailer_actor_id.as_deref() { + if claimed != m.member_id.as_str() { + ev.tampered = true; + } + } + } + None => { + // No current member matched the signature -> cannot trust the + // trailer's claimed actor. + ev.tampered = true; + } + } + + if let Some(mid) = member_filter { + // Filter on the VERIFIED actor id, not the spoofable trailer. + if ev.actor_id.as_deref() != Some(mid) { + continue; + } + } + if let Some(col) = collection_filter { + if ev.collection.as_deref() != Some(col) { + continue; + } + } + if let Some(act) = action_filter { + if ev.action.as_deref() != Some(act) { + continue; + } + } + events.push(ev); + } + + if json { + println!("{}", serde_json::to_string_pretty(&events)?); + } else { + println!("{:<44} {:<26} {:<20} {:<18} {}", "COMMIT", "TIMESTAMP", "ACTION", "ACTOR", "FLAG"); + for ev in &events { + println!("{:<44} {:<26} {:<20} {:<18} {}", + ev.commit, + ev.timestamp, + ev.action.as_deref().unwrap_or("-"), + ev.actor_name.as_deref().unwrap_or(""), + if ev.tampered { "TAMPERED" } else { "" }, + ); + } + } + Ok(()) +} +``` + +> **Verified:** mirrors `relicario-server`'s `verify_commit` exactly (temp allowed_signers prefixed `relicario `, `GIT_CONFIG_COUNT/KEY_0/VALUE_0 = gpg.ssh.allowedSignersFile`, `git verify-commit --raw`, regex `key (SHA256:[A-Za-z0-9+/]+)` over stderr, fingerprint match via `relicario_core::fingerprint`). git 2.54.0 supports `%(trailers:only=true,unfold=true)` and `%x1e`/`%x1f`; `%x1e` prefixes each record so `split('\u{1e}')` yields a leading empty element (filtered by the empty-record guard). `%cI` emits strict ISO 8601 with offset. + +- [ ] **Step 4: Run the trailer test + build** + +```bash +cargo test -p relicario-cli commands::org::tests::parse_trailers 2>&1 | tail -5 +cargo build -p relicario-cli 2>&1 | tail -5 +``` + +Expected: PASS; clean build. + +- [ ] **Step 5: Commit** + +```bash +git add crates/relicario-cli/src/commands/org.rs crates/relicario-cli/Cargo.toml Cargo.lock +git commit -m "feat(cli/org): status + audit (verified-signer attribution, TAMPERED flag, committer-date framing)" +``` + +--- + +## [Dev-B] Task B9: Confirm collection-scoped item helpers on `UnlockedOrgVault` + +**Files:** +- Verify only: `crates/relicario-cli/src/org_session.rs` + +> **Reconciliation note:** The collection-scoped `item_path(collection_slug, id)` and the item I/O helpers (`save_item`, `load_item`, `remove_item`, `ensure_grant`) were already defined in **Task B1** to avoid a duplicate `item_path` definition. This task is the checkpoint that those helpers exist with the exact signatures the item commands (B10–B13) consume. It adds **no new `item_path` definition** — there is exactly ONE, in B1. + +- [ ] **Step 1: Verify the helper surface exists** + +```bash +grep -n "pub fn item_path\|pub fn save_item\|pub fn load_item\|pub fn remove_item\|pub fn ensure_grant" crates/relicario-cli/src/org_session.rs +``` + +Expected: each appears exactly once. `item_path(&self, collection_slug: &str, id: &ItemId)`, `save_item(&self, collection_slug, &Item) -> Result`, `load_item(&self, collection_slug, &ItemId) -> Result`, `remove_item(&self, collection_slug, &ItemId) -> Result<()>`, `ensure_grant(member: &OrgMember, slug: &str) -> Result<()>`. + +```bash +grep -rn "item_path" crates/relicario-cli/src/ | grep -v "collection_slug" +``` + +Expected: the only non-`collection_slug` matches are the personal-vault `session.rs` (a different type — leave it alone) and the B1 doc-comment lines. No flat `items/.enc` call sites. + +- [ ] **Step 2: Compile check** + +```bash +cargo build -p relicario-cli 2>&1 | tail -15 +``` + +Expected: clean build (unused-helper warnings are fine — B10–B13 consume them in the same PR series; do NOT add `#[allow(dead_code)]`). + +> No commit for this task — it is a verification gate. If a helper is missing, fix it in B1's file and amend B1's commit (or add a follow-up commit) before proceeding. + +--- + +## [Dev-B] Task B10: `org add` — create a typed item in a collection + +**Files:** +- Modify: `crates/relicario-cli/src/commands/org.rs` +- Modify: `crates/relicario-cli/src/main.rs` +- Create: `crates/relicario-cli/tests/org_items.rs` + +`run_add` mirrors the personal-vault `cmd_add`: build a typed `Item`, write the encrypted blob (collection-scoped), upsert the manifest entry, re-encrypt the manifest, commit with structured trailers. The caller's grant is enforced and the collection must exist. Supports Login, SecureNote, and Identity (the three non-interactive builders); Card/Key/Document/Totp parity is deferred (see the follow-up note after B13). + +- [ ] **Step 1: Write the failing integration test** + +Create `crates/relicario-cli/tests/org_items.rs`. The harness seeds a device signing key under a tempdir `XDG_CONFIG_HOME`, runs `org init` to create the owner + wrap the org key, then drives `org add` / `org get` / `org list`. + +```rust +mod common; + +use assert_cmd::cargo::CommandCargoExt as _; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use tempfile::TempDir; + +/// A throwaway org vault with a device signing key wired via XDG_CONFIG_HOME. +struct OrgFixture { + _config: TempDir, + vault: TempDir, + xdg: PathBuf, +} + +impl OrgFixture { + /// Generate an ed25519 signing key in OpenSSH format using ssh-keygen and + /// register it as the current device, then `org init`. + fn new() -> Self { + let config = TempDir::new().unwrap(); + let xdg = config.path().to_path_buf(); + let devices = xdg.join("relicario").join("devices").join("laptop"); + std::fs::create_dir_all(&devices).unwrap(); + + // Generate an OpenSSH ed25519 keypair without a passphrase. + let keyfile = devices.join("signing.key"); + let status = Command::new("ssh-keygen") + .args(["-t", "ed25519", "-N", "", "-C", "relicario-test", "-f"]) + .arg(&keyfile) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .expect("ssh-keygen"); + assert!(status.success(), "ssh-keygen failed"); + // ssh-keygen writes signing.key + signing.key.pub; rename the .pub to signing.pub. + std::fs::rename(devices.join("signing.key.pub"), devices.join("signing.pub")).unwrap(); + // Mark this device current. + std::fs::write( + xdg.join("relicario").join("devices").join("current"), + "laptop\n", + ) + .unwrap(); + + let vault = TempDir::new().unwrap(); + let f = OrgFixture { _config: config, vault, xdg }; + + let out = f.run(&["org", "init", "--dir", f.vault_str(), "--name", "Acme"]); + assert!(out.status.success(), "org init failed: {}", String::from_utf8_lossy(&out.stderr)); + f + } + + fn vault_path(&self) -> &Path { self.vault.path() } + fn vault_str(&self) -> &str { self.vault.path().to_str().unwrap() } + + fn run(&self, args: &[&str]) -> std::process::Output { + let mut cmd = Command::cargo_bin("relicario").unwrap(); + cmd.env("XDG_CONFIG_HOME", &self.xdg) + .env("RELICARIO_ORG_DIR", self.vault.path()) + .args(args) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + cmd.output().unwrap() + } + + /// Owner member id printed by `org init`/`org status`. We read it from + /// members.json directly to avoid parsing stdout. + fn owner_member_id(&self) -> String { + let s = std::fs::read_to_string(self.vault.path().join("members.json")).unwrap(); + let v: serde_json::Value = serde_json::from_str(&s).unwrap(); + v["members"][0]["member_id"].as_str().unwrap().to_string() + } +} + +#[test] +fn org_add_get_list_round_trip() { + let f = OrgFixture::new(); + let owner = f.owner_member_id(); + + // Create a collection and grant the owner access to it. + let out = f.run(&["org", "create-collection", "prod", "--name", "Production"]); + assert!(out.status.success(), "create-collection: {}", String::from_utf8_lossy(&out.stderr)); + let out = f.run(&["org", "grant", &owner, "prod"]); + assert!(out.status.success(), "grant: {}", String::from_utf8_lossy(&out.stderr)); + + // Add a login into the prod collection. + let out = f.run(&[ + "org", "add", "login", "--collection", "prod", + "--title", "GitHub", "--username", "alice", + "--url", "https://github.com", "--password", "hunter2", + ]); + assert!(out.status.success(), "org add: {}", String::from_utf8_lossy(&out.stderr)); + + // The blob must live under items/prod/, NOT flat items/. + let prod_dir = f.vault_path().join("items").join("prod"); + let blobs: Vec<_> = std::fs::read_dir(&prod_dir).unwrap().collect(); + assert_eq!(blobs.len(), 1, "expected one blob under items/prod/"); + + // list shows it. + let out = f.run(&["org", "list"]); + let stdout = String::from_utf8_lossy(&out.stdout).to_string(); + assert!(stdout.contains("GitHub"), "list missing GitHub: {stdout}"); + + // get masks by default. + let out = f.run(&["org", "get", "GitHub"]); + let stdout = String::from_utf8_lossy(&out.stdout).to_string(); + assert!(stdout.contains("********"), "expected masked secret: {stdout}"); + assert!(!stdout.contains("hunter2"), "leaked plaintext: {stdout}"); + + // get --show reveals. + let out = f.run(&["org", "get", "GitHub", "--show"]); + let stdout = String::from_utf8_lossy(&out.stdout).to_string(); + assert!(stdout.contains("hunter2"), "expected plaintext with --show: {stdout}"); + + // The commit trailer records the action + collection + item. + let log = Command::new("git") + .args(["-C", f.vault_str(), "log", "-1", "--format=%B"]) + .output() + .unwrap(); + let body = String::from_utf8_lossy(&log.stdout).to_string(); + assert!(body.contains("Relicario-Action: item-create"), "missing action trailer: {body}"); + assert!(body.contains("Relicario-Collection: prod"), "missing collection trailer: {body}"); + assert!(body.contains("Relicario-Item: "), "missing item trailer: {body}"); +} + +#[test] +fn org_add_rejects_ungranted_collection() { + let f = OrgFixture::new(); + // Create the collection but do NOT grant the owner. + let out = f.run(&["org", "create-collection", "secret", "--name", "Secret"]); + assert!(out.status.success(), "create-collection: {}", String::from_utf8_lossy(&out.stderr)); + + let out = f.run(&[ + "org", "add", "login", "--collection", "secret", + "--title", "X", "--username", "u", "--password", "p", + ]); + assert!(!out.status.success(), "add into ungranted collection must fail"); + let stderr = String::from_utf8_lossy(&out.stderr).to_string(); + assert!(stderr.contains("access denied") || stderr.contains("grant"), "unexpected error: {stderr}"); +} + +#[test] +fn org_add_rejects_unknown_collection() { + let f = OrgFixture::new(); + let out = f.run(&[ + "org", "add", "login", "--collection", "ghost", + "--title", "X", "--username", "u", "--password", "p", + ]); + assert!(!out.status.success(), "add into nonexistent collection must fail"); + let stderr = String::from_utf8_lossy(&out.stderr).to_string(); + assert!(stderr.contains("does not exist") || stderr.contains("ghost"), "unexpected error: {stderr}"); +} +``` + +```bash +cargo test -p relicario-cli --test org_items 2>&1 | tail -20 +``` + +Expected: FAIL to compile (`org add` subcommand + `run_add` not defined yet). + +> **Note:** This test requires `ssh-keygen` on `PATH`. `mod common;` reuses `tests/common/mod.rs` for `Command::cargo_bin`-style ergonomics via `assert_cmd` (already a dev-dependency via `basic_flows.rs`). + +- [ ] **Step 2: Implement `run_add` and the three item builders in `commands/org.rs`** + +Append after the existing command fns: + +```rust +use relicario_core::{Item, ItemCore, ItemId}; + +/// Item kinds `org add` supports without interactive prompts. +pub enum OrgAddKind { + Login { + title: String, + username: Option, + url: Option, + password: Option, + }, + SecureNote { + title: String, + body: String, + }, + Identity { + title: String, + full_name: Option, + email: Option, + phone: Option, + }, +} + +fn build_org_item(kind: OrgAddKind, tags: Vec) -> Result { + use relicario_core::item_types::{IdentityCore, LoginCore, SecureNoteCore}; + use zeroize::Zeroizing; + + let mut item = match kind { + OrgAddKind::Login { title, username, url, password } => { + let parsed_url = match url { + Some(s) => Some(url::Url::parse(&s).with_context(|| format!("invalid URL: {s}"))?), + None => None, + }; + let password = password.map(Zeroizing::new); + Item::new(title, ItemCore::Login(LoginCore { + username, + password, + url: parsed_url, + totp: None, + })) + } + OrgAddKind::SecureNote { title, body } => { + Item::new(title, ItemCore::SecureNote(SecureNoteCore { + body: Zeroizing::new(body), + })) + } + OrgAddKind::Identity { title, full_name, email, phone } => { + Item::new(title, ItemCore::Identity(IdentityCore { + full_name, + address: None, + phone, + email, + date_of_birth: None, + })) + } + }; + item.tags = tags; + Ok(item) +} + +pub fn run_add(dir: &Path, collection: &str, kind: OrgAddKind, tags: Vec) -> Result<()> { + use crate::org_session::UnlockedOrgVault; + + let vault = crate::org_session::open_org_vault(Some(dir))?; + let caller = vault.current_member()?; + + // Slug must exist in collections.json… + let collections = vault.load_collections()?; + if !collections.contains_slug(collection) { + anyhow::bail!("collection `{collection}` does not exist — create it with `relicario org create-collection`"); + } + // …and the caller must hold a grant for it. + UnlockedOrgVault::ensure_grant(&caller, collection)?; + + let item = build_org_item(kind, tags)?; + let item_rel = vault.save_item(collection, &item)?; + + // Upsert the manifest entry (collection slug stored plaintext inside the + // encrypted manifest). + let mut manifest = vault.load_manifest()?; + upsert_org_entry(&mut manifest, &item, collection); + vault.save_manifest(&manifest)?; + + let subject = format!( + "org add: {} ({})", + crate::helpers::sanitize_for_commit(&item.title), + item.id.as_str() + ); + let commit_msg = format!( + "{subject}\n\nRelicario-Actor: {} {}\nRelicario-Action: item-create\nRelicario-Collection: {}\nRelicario-Item: {}", + caller.display_name, + caller.member_id.as_str(), + collection, + item.id.as_str() + ); + crate::org_session::org_git_run( + &vault.root, + &["add", &item_rel, "manifest.enc"], + "org add: git add", + )?; + crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "org add: git commit")?; + + println!("Added {} ({}) to `{}`", item.title, item.id.as_str(), collection); + Ok(()) +} + +/// Insert-or-replace an `OrgManifestEntry` mirroring the personal-vault +/// `Manifest::upsert`. Keyed by item id. +fn upsert_org_entry( + manifest: &mut relicario_core::OrgManifest, + item: &Item, + collection: &str, +) { + let entry = relicario_core::OrgManifestEntry { + id: item.id.clone(), + r#type: item.r#type, + title: item.title.clone(), + tags: item.tags.clone(), + modified: item.modified, + trashed_at: item.trashed_at, + collection: collection.to_string(), + }; + if let Some(slot) = manifest.entries.iter_mut().find(|e| e.id == item.id) { + *slot = entry; + } else { + manifest.entries.push(entry); + } +} +``` + +> **Note:** Keep exactly one set of imports for `Item, ItemCore, ItemId`. If an earlier task already imported them at module scope, drop the `use relicario_core::{Item, ItemCore, ItemId};` line here. Do not import `encrypt_org_manifest` — `vault.save_manifest` already wraps it. + +- [ ] **Step 3: Add the `Add` variant to `OrgCommands` and dispatch (extends B14)** + +In `crates/relicario-cli/src/main.rs`, add to the `OrgCommands` enum: + +```rust + /// Add an item to a collection in the org vault. + Add { + #[command(subcommand)] + kind: OrgAddKind, + }, +``` + +Add a top-level clap subcommand enum next to `OrgCommands`: + +```rust +#[derive(Subcommand)] +pub(crate) enum OrgAddKind { + /// A login (username / url / password). + Login { + #[arg(long)] collection: String, + #[arg(long)] title: String, + #[arg(long)] username: Option, + #[arg(long)] url: Option, + #[arg(long)] password: Option, + #[arg(long, value_delimiter = ',')] tags: Vec, + }, + /// A secure note. + SecureNote { + #[arg(long)] collection: String, + #[arg(long)] title: String, + #[arg(long)] body: String, + #[arg(long, value_delimiter = ',')] tags: Vec, + }, + /// An identity record. + Identity { + #[arg(long)] collection: String, + #[arg(long)] title: String, + #[arg(long)] full_name: Option, + #[arg(long)] email: Option, + #[arg(long)] phone: Option, + #[arg(long, value_delimiter = ',')] tags: Vec, + }, +} +``` + +In the `Commands::Org { dir, subcommand }` dispatch block, add an arm to the inner `match subcommand`: + +```rust + OrgCommands::Add { kind } => { + let d = crate::org_session::org_dir(dir_path)?; + let (collection, add_kind, tags) = match kind { + OrgAddKind::Login { collection, title, username, url, password, tags } => ( + collection, + commands::org::OrgAddKind::Login { title, username, url, password }, + tags, + ), + OrgAddKind::SecureNote { collection, title, body, tags } => ( + collection, + commands::org::OrgAddKind::SecureNote { title, body }, + tags, + ), + OrgAddKind::Identity { collection, title, full_name, email, phone, tags } => ( + collection, + commands::org::OrgAddKind::Identity { title, full_name, email, phone }, + tags, + ), + }; + commands::org::run_add(&d, &collection, add_kind, tags)?; + } +``` + +> **Note:** The clap-side `OrgAddKind` (in `main.rs`, with `collection`/`tags` per-variant) and the handler-side `commands::org::OrgAddKind` (no `collection`/`tags`) are deliberately two distinct enums so the handler stays unaware of clap. Do not merge them. + +- [ ] **Step 4: Run the guard tests** + +```bash +cargo test -p relicario-cli --test org_items org_add_rejects 2>&1 | tail -20 +``` + +Expected: both guard tests PASS. (The full `org_add_get_list_round_trip` passes once B11 lands `get`/`list`.) + +- [ ] **Step 5: Commit** + +```bash +git add crates/relicario-cli/src/commands/org.rs crates/relicario-cli/src/main.rs crates/relicario-cli/tests/org_items.rs +git commit -m "feat(cli/org): org add — collection-scoped typed item create with grant guard" +``` + +--- + +## [Dev-B] Task B11: `org get` + `org list` + +**Files:** +- Modify: `crates/relicario-cli/src/commands/org.rs` +- Modify: `crates/relicario-cli/src/main.rs` + +`run_get` mirrors `commands/get.rs` masking; `run_list` uses `OrgManifest::filter_for_member` so a member only ever sees entries in collections they hold a grant for. Both enforce that the caller has a grant for the target item's collection. + +- [ ] **Step 1: Implement `run_list`** + +Append to `commands/org.rs`: + +```rust +pub fn run_list(dir: &Path, trashed: bool) -> Result<()> { + let vault = crate::org_session::open_org_vault(Some(dir))?; + let caller = vault.current_member()?; + let manifest = vault.load_manifest()?; + + // filter_for_member restricts to the caller's granted collections. + let visible = manifest.filter_for_member(&caller); + + let mut entries: Vec<_> = visible.entries.iter() + .filter(|e| if trashed { e.trashed_at.is_some() } else { e.trashed_at.is_none() }) + .collect(); + entries.sort_by(|a, b| a.title.to_lowercase().cmp(&b.title.to_lowercase())); + + if entries.is_empty() { + eprintln!("(no items match)"); + return Ok(()); + } + + println!("{:<16} {:<14} {:<12} TITLE", "ID", "TYPE", "COLLECTION"); + for e in entries { + println!( + "{:<16} {:<14} {:<12} {}", + e.id.as_str(), + format!("{:?}", e.r#type), + e.collection, + e.title + ); + } + Ok(()) +} +``` + +- [ ] **Step 2: Implement `run_get` (query resolution + masking)** + +Append to `commands/org.rs`. The query resolver runs over the caller-visible manifest only: + +```rust +pub fn run_get(dir: &Path, query: &str, show: bool) -> Result<()> { + use relicario_core::ItemCore; + use zeroize::Zeroizing; + + let vault = crate::org_session::open_org_vault(Some(dir))?; + let caller = vault.current_member()?; + let manifest = vault.load_manifest()?; + let visible = manifest.filter_for_member(&caller); + + let entry = resolve_org_query(&visible, query)?; + // Double-check the grant for the resolved collection (defense in depth). + crate::org_session::UnlockedOrgVault::ensure_grant(&caller, &entry.collection)?; + + let item = vault.load_item(&entry.collection, &entry.id)?; + + println!("ID: {}", item.id.as_str()); + println!("Title: {}", item.title); + println!("Type: {:?}", item.r#type); + println!("Collection: {}", entry.collection); + if !item.tags.is_empty() { println!("Tags: {}", item.tags.join(", ")); } + println!("Modified: {}", crate::helpers::iso8601(item.modified)); + if let Some(t) = item.trashed_at { println!("Trashed: {}", crate::helpers::iso8601(t)); } + println!(); + + let primary_secret: Option> = match &item.core { + ItemCore::Login(l) => { + if let Some(u) = &l.username { println!("Username: {u}"); } + if let Some(u) = &l.url { println!("URL: {u}"); } + l.password.clone() + } + ItemCore::SecureNote(n) => { + if show { println!("Body:\n{}", n.body.as_str()); } + else { println!("Body: ********"); } + None + } + ItemCore::Identity(i) => { + if let Some(v) = &i.full_name { println!("Name: {v}"); } + if let Some(v) = &i.email { println!("Email: {v}"); } + if let Some(v) = &i.phone { println!("Phone: {v}"); } + None + } + ItemCore::Card(c) => { + if let Some(h) = &c.holder { println!("Holder: {h}"); } + c.number.clone() + } + ItemCore::Key(k) => { + if let Some(l) = &k.label { println!("Label: {l}"); } + Some(k.key_material.clone()) + } + ItemCore::Document(d) => { + println!("Filename: {}", d.filename); + println!("MIME: {}", d.mime_type); + None + } + ItemCore::Totp(t) => { + if let Some(i) = &t.issuer { println!("Issuer: {i}"); } + if let Some(l) = &t.label { println!("Label: {l}"); } + None + } + }; + + if let Some(secret) = primary_secret { + if show { + println!("Secret: {}", secret.as_str()); + } else { + println!("Secret: ******** (use --show to reveal)"); + } + } + Ok(()) +} + +/// Resolve a query (exact id, else case-insensitive title substring) against an +/// already-grant-filtered manifest. +fn resolve_org_query<'a>( + manifest: &'a relicario_core::OrgManifest, + query: &str, +) -> Result<&'a relicario_core::OrgManifestEntry> { + if let Some(entry) = manifest.entries.iter().find(|e| e.id.as_str() == query) { + return Ok(entry); + } + let needle = query.to_lowercase(); + let hits: Vec<&relicario_core::OrgManifestEntry> = manifest.entries.iter() + .filter(|e| e.title.to_lowercase().contains(&needle)) + .collect(); + match hits.len() { + 0 => anyhow::bail!("no item matches `{query}`"), + 1 => Ok(hits[0]), + _ => { + let titles: Vec<&str> = hits.iter().map(|e| e.title.as_str()).collect(); + anyhow::bail!("ambiguous — {} matches: {}", hits.len(), titles.join(", ")) + } + } +} +``` + +- [ ] **Step 3: Add `Get` / `List` variants + dispatch (extends B14)** + +In `main.rs` `OrgCommands` enum, add: + +```rust + /// Print an org item (secrets masked unless --show). + Get { + /// Item id or case-insensitive title substring. + query: String, + #[arg(long)] show: bool, + }, + /// List org items visible to you (filtered by your collection grants). + List { + #[arg(long)] trashed: bool, + }, +``` + +In the `match subcommand` dispatch, add: + +```rust + OrgCommands::Get { query, show } => { + let d = crate::org_session::org_dir(dir_path)?; + commands::org::run_get(&d, &query, show)?; + } + OrgCommands::List { trashed } => { + let d = crate::org_session::org_dir(dir_path)?; + commands::org::run_list(&d, trashed)?; + } +``` + +- [ ] **Step 4: Run the full add/get/list integration test** + +```bash +cargo test -p relicario-cli --test org_items 2>&1 | tail -30 +``` + +Expected: all three tests in `org_items.rs` now PASS. + +- [ ] **Step 5: Commit** + +```bash +git add crates/relicario-cli/src/commands/org.rs crates/relicario-cli/src/main.rs +git commit -m "feat(cli/org): org get + list with per-member grant filtering" +``` + +--- + +## [Dev-B] Task B12: `org edit` + +**Files:** +- Modify: `crates/relicario-cli/src/commands/org.rs` +- Modify: `crates/relicario-cli/src/main.rs` + +`run_edit` mirrors `commands/edit.rs` for the three supported types but takes new values from flags. It enforces the caller's grant, re-saves the blob in place under `items//`, upserts the manifest, and commits with `Relicario-Action: item-update`. + +- [ ] **Step 1: Write a failing integration test (append to `tests/org_items.rs`)** + +```rust +#[test] +fn org_edit_updates_fields_and_commits_update_trailer() { + let f = OrgFixture::new(); + let owner = f.owner_member_id(); + assert!(f.run(&["org", "create-collection", "prod", "--name", "Production"]).status.success()); + assert!(f.run(&["org", "grant", &owner, "prod"]).status.success()); + assert!(f.run(&[ + "org", "add", "login", "--collection", "prod", + "--title", "Mail", "--username", "old", "--password", "pw", + ]).status.success()); + + // Edit the username. + let out = f.run(&[ + "org", "edit", "Mail", "--username", "new-user", + ]); + assert!(out.status.success(), "org edit: {}", String::from_utf8_lossy(&out.stderr)); + + // get --show reflects the new username. + let out = f.run(&["org", "get", "Mail", "--show"]); + let stdout = String::from_utf8_lossy(&out.stdout).to_string(); + assert!(stdout.contains("new-user"), "edit did not take: {stdout}"); + + let log = Command::new("git") + .args(["-C", f.vault_str(), "log", "-1", "--format=%B"]) + .output().unwrap(); + let body = String::from_utf8_lossy(&log.stdout).to_string(); + assert!(body.contains("Relicario-Action: item-update"), "missing update trailer: {body}"); + assert!(body.contains("Relicario-Collection: prod"), "missing collection trailer: {body}"); +} +``` + +```bash +cargo test -p relicario-cli --test org_items org_edit 2>&1 | tail -20 +``` + +Expected: FAIL — `org edit` not defined. + +- [ ] **Step 2: Implement `run_edit`** + +Append to `commands/org.rs`: + +```rust +pub fn run_edit( + dir: &Path, + query: &str, + title: Option, + username: Option, + url: Option, + password: Option, + body: Option, + email: Option, + phone: Option, + full_name: Option, +) -> Result<()> { + use relicario_core::time::now_unix; + use relicario_core::ItemCore; + use zeroize::Zeroizing; + + let vault = crate::org_session::open_org_vault(Some(dir))?; + let caller = vault.current_member()?; + let manifest = vault.load_manifest()?; + let visible = manifest.filter_for_member(&caller); + let entry = resolve_org_query(&visible, query)?; + let collection = entry.collection.clone(); + let id = entry.id.clone(); + crate::org_session::UnlockedOrgVault::ensure_grant(&caller, &collection)?; + + let mut item = vault.load_item(&collection, &id)?; + + if let Some(t) = title { item.title = t; } + + match &mut item.core { + ItemCore::Login(l) => { + if let Some(u) = username { l.username = Some(u); } + if let Some(u) = url { + l.url = Some(url::Url::parse(&u).with_context(|| format!("invalid URL: {u}"))?); + } + if let Some(p) = password { l.password = Some(Zeroizing::new(p)); } + } + ItemCore::SecureNote(n) => { + if let Some(b) = body { n.body = Zeroizing::new(b); } + } + ItemCore::Identity(i) => { + if let Some(v) = full_name { i.full_name = Some(v); } + if let Some(v) = email { i.email = Some(v); } + if let Some(v) = phone { i.phone = Some(v); } + } + _ => anyhow::bail!("org edit currently supports login, secure-note, and identity items"), + } + + item.modified = now_unix(); + let item_rel = vault.save_item(&collection, &item)?; + + let mut manifest = vault.load_manifest()?; + upsert_org_entry(&mut manifest, &item, &collection); + vault.save_manifest(&manifest)?; + + let subject = format!( + "org edit: {} ({})", + crate::helpers::sanitize_for_commit(&item.title), + item.id.as_str() + ); + let commit_msg = format!( + "{subject}\n\nRelicario-Actor: {} {}\nRelicario-Action: item-update\nRelicario-Collection: {}\nRelicario-Item: {}", + caller.display_name, caller.member_id.as_str(), collection, item.id.as_str() + ); + crate::org_session::org_git_run(&vault.root, &["add", &item_rel, "manifest.enc"], "org edit: git add")?; + crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "org edit: git commit")?; + + println!("Updated {}", item.id.as_str()); + Ok(()) +} +``` + +- [ ] **Step 3: Add `Edit` variant + dispatch (extends B14)** + +In `main.rs` `OrgCommands`: + +```rust + /// Edit an org item's fields (flag-driven; blank flags keep current values). + Edit { + /// Item id or case-insensitive title substring. + query: String, + #[arg(long)] title: Option, + #[arg(long)] username: Option, + #[arg(long)] url: Option, + #[arg(long)] password: Option, + #[arg(long)] body: Option, + #[arg(long)] email: Option, + #[arg(long)] phone: Option, + #[arg(long)] full_name: Option, + }, +``` + +Dispatch arm: + +```rust + OrgCommands::Edit { query, title, username, url, password, body, email, phone, full_name } => { + let d = crate::org_session::org_dir(dir_path)?; + commands::org::run_edit(&d, &query, title, username, url, password, body, email, phone, full_name)?; + } +``` + +- [ ] **Step 4: Run the test** + +```bash +cargo test -p relicario-cli --test org_items org_edit 2>&1 | tail -20 +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add crates/relicario-cli/src/commands/org.rs crates/relicario-cli/src/main.rs +git commit -m "feat(cli/org): org edit — flag-driven field update for login/note/identity" +``` + +--- + +## [Dev-B] Task B13: `org rm` / `org restore` / `org purge` + +**Files:** +- Modify: `crates/relicario-cli/src/commands/org.rs` +- Modify: `crates/relicario-cli/src/main.rs` + +Trash lifecycle mirrors `commands/trash.rs`: `rm` soft-deletes (sets `trashed_at`), `restore` clears it, `purge` permanently `git rm`s the blob and drops the manifest entry. All three enforce the caller's grant. + +- [ ] **Step 1: Write a failing integration test (append to `tests/org_items.rs`)** + +```rust +#[test] +fn org_rm_restore_purge_cycle() { + let f = OrgFixture::new(); + let owner = f.owner_member_id(); + assert!(f.run(&["org", "create-collection", "prod", "--name", "Production"]).status.success()); + assert!(f.run(&["org", "grant", &owner, "prod"]).status.success()); + assert!(f.run(&[ + "org", "add", "secure-note", "--collection", "prod", + "--title", "Recovery", "--body", "codes-here", + ]).status.success()); + + // rm → appears only with --trashed. + assert!(f.run(&["org", "rm", "Recovery"]).status.success()); + let listed = String::from_utf8_lossy(&f.run(&["org", "list"]).stdout).to_string(); + assert!(!listed.contains("Recovery"), "trashed item still in default list: {listed}"); + let trashed = String::from_utf8_lossy(&f.run(&["org", "list", "--trashed"]).stdout).to_string(); + assert!(trashed.contains("Recovery"), "trashed item not in --trashed list: {trashed}"); + + // restore → back in default list. + assert!(f.run(&["org", "restore", "Recovery"]).status.success()); + let listed = String::from_utf8_lossy(&f.run(&["org", "list"]).stdout).to_string(); + assert!(listed.contains("Recovery"), "restore did not bring it back: {listed}"); + + // purge → blob gone, entry gone, item-purge trailer. + assert!(f.run(&["org", "purge", "Recovery"]).status.success()); + let prod_dir = f.vault_path().join("items").join("prod"); + let count = std::fs::read_dir(&prod_dir).map(|d| d.count()).unwrap_or(0); + assert_eq!(count, 0, "blob not purged from items/prod/"); + let listed = String::from_utf8_lossy(&f.run(&["org", "list", "--trashed"]).stdout).to_string(); + assert!(!listed.contains("Recovery"), "purged item still listed: {listed}"); + + let log = Command::new("git") + .args(["-C", f.vault_str(), "log", "-1", "--format=%B"]) + .output().unwrap(); + let body = String::from_utf8_lossy(&log.stdout).to_string(); + assert!(body.contains("Relicario-Action: item-purge"), "missing purge trailer: {body}"); +} +``` + +```bash +cargo test -p relicario-cli --test org_items org_rm_restore_purge 2>&1 | tail -20 +``` + +Expected: FAIL — `org rm`/`restore`/`purge` not defined. + +- [ ] **Step 2: Implement `run_rm`, `run_restore`, `run_purge`** + +Append to `commands/org.rs`: + +```rust +/// Resolve a query to (collection, item) with grant enforcement. Used by the +/// trash-lifecycle commands. +fn open_org_item( + vault: &crate::org_session::UnlockedOrgVault, + caller: &relicario_core::OrgMember, + query: &str, +) -> Result<(String, relicario_core::Item)> { + let manifest = vault.load_manifest()?; + let visible = manifest.filter_for_member(caller); + let entry = resolve_org_query(&visible, query)?; + let collection = entry.collection.clone(); + let id = entry.id.clone(); + crate::org_session::UnlockedOrgVault::ensure_grant(caller, &collection)?; + let item = vault.load_item(&collection, &id)?; + Ok((collection, item)) +} + +pub fn run_rm(dir: &Path, query: &str) -> Result<()> { + let vault = crate::org_session::open_org_vault(Some(dir))?; + let caller = vault.current_member()?; + let (collection, mut item) = open_org_item(&vault, &caller, query)?; + + item.soft_delete(); + let item_rel = vault.save_item(&collection, &item)?; + let mut manifest = vault.load_manifest()?; + upsert_org_entry(&mut manifest, &item, &collection); + vault.save_manifest(&manifest)?; + + let commit_msg = format!( + "org trash: {} ({})\n\nRelicario-Actor: {} {}\nRelicario-Action: item-trash\nRelicario-Collection: {}\nRelicario-Item: {}", + crate::helpers::sanitize_for_commit(&item.title), item.id.as_str(), + caller.display_name, caller.member_id.as_str(), collection, item.id.as_str() + ); + crate::org_session::org_git_run(&vault.root, &["add", &item_rel, "manifest.enc"], "org rm: git add")?; + crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "org rm: git commit")?; + println!("Moved to trash: {}", item.title); + Ok(()) +} + +pub fn run_restore(dir: &Path, query: &str) -> Result<()> { + let vault = crate::org_session::open_org_vault(Some(dir))?; + let caller = vault.current_member()?; + let (collection, mut item) = open_org_item(&vault, &caller, query)?; + + item.restore(); + let item_rel = vault.save_item(&collection, &item)?; + let mut manifest = vault.load_manifest()?; + upsert_org_entry(&mut manifest, &item, &collection); + vault.save_manifest(&manifest)?; + + let commit_msg = format!( + "org restore: {} ({})\n\nRelicario-Actor: {} {}\nRelicario-Action: item-restore\nRelicario-Collection: {}\nRelicario-Item: {}", + crate::helpers::sanitize_for_commit(&item.title), item.id.as_str(), + caller.display_name, caller.member_id.as_str(), collection, item.id.as_str() + ); + crate::org_session::org_git_run(&vault.root, &["add", &item_rel, "manifest.enc"], "org restore: git add")?; + crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "org restore: git commit")?; + println!("Restored: {}", item.title); + Ok(()) +} + +pub fn run_purge(dir: &Path, query: &str) -> Result<()> { + let vault = crate::org_session::open_org_vault(Some(dir))?; + let caller = vault.current_member()?; + let (collection, item) = open_org_item(&vault, &caller, query)?; + let title = item.title.clone(); + let id = item.id.clone(); + + // Remove the blob from disk, drop the manifest entry, stage with git rm. + vault.remove_item(&collection, &id)?; + let mut manifest = vault.load_manifest()?; + manifest.entries.retain(|e| e.id != id); + vault.save_manifest(&manifest)?; + + let item_rel = format!("items/{}/{}.enc", collection, id.as_str()); + crate::helpers::git_rm(&vault.root, &[item_rel], "org purge: git rm")?; + crate::org_session::org_git_run(&vault.root, &["add", "manifest.enc"], "org purge: git add manifest")?; + + let commit_msg = format!( + "org purge: {} ({})\n\nRelicario-Actor: {} {}\nRelicario-Action: item-purge\nRelicario-Collection: {}\nRelicario-Item: {}", + crate::helpers::sanitize_for_commit(&title), id.as_str(), + caller.display_name, caller.member_id.as_str(), collection, id.as_str() + ); + crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "org purge: git commit")?; + println!("Purged: {title}"); + Ok(()) +} +``` + +> **Note:** `git_rm` is `pub` in `crate::helpers` (helpers.rs:93, signature `git_rm(repo: &Path, paths: &[String], context: &str)`) and uses `git rm -rf --ignore-unmatch`, so a missing blob is tolerated. The grant guard runs *before* any deletion. + +- [ ] **Step 3: Add `Rm` / `Restore` / `Purge` variants + dispatch (extends B14)** + +In `main.rs` `OrgCommands`: + +```rust + /// Soft-delete an org item (reversible via `org restore`). + Rm { query: String }, + /// Restore a soft-deleted org item. + Restore { query: String }, + /// Permanently purge an org item (deletes the encrypted blob). + Purge { query: String }, +``` + +Dispatch arms: + +```rust + OrgCommands::Rm { query } => { + let d = crate::org_session::org_dir(dir_path)?; + commands::org::run_rm(&d, &query)?; + } + OrgCommands::Restore { query } => { + let d = crate::org_session::org_dir(dir_path)?; + commands::org::run_restore(&d, &query)?; + } + OrgCommands::Purge { query } => { + let d = crate::org_session::org_dir(dir_path)?; + commands::org::run_purge(&d, &query)?; + } +``` + +- [ ] **Step 4: Run the trash test + the full org_items suite + workspace verify** + +```bash +cargo test -p relicario-cli --test org_items 2>&1 | tail -30 +cargo test 2>&1 | tail -25 +``` + +Expected: all `org_items.rs` tests PASS; green across all crates. No `todo!`/`unimplemented!` left in the org item path. + +- [ ] **Step 5: Commit** + +```bash +git add crates/relicario-cli/src/commands/org.rs crates/relicario-cli/src/main.rs crates/relicario-cli/tests/org_items.rs +git commit -m "feat(cli/org): org rm/restore/purge trash lifecycle (collection-scoped)" +``` + +> **Follow-up note (not a task):** `org add`/`org edit` cover Login, SecureNote, and Identity (the non-interactive builders). Card / Key / Document / Totp parity is deferred because those builders read secrets via `rpassword`/stdin; wiring them needs `--*-stdin` flags or a test escape hatch and belongs in a follow-up parity task. The CLI/extension-parity philosophy applies to the *extension* of this surface (Dev-D), not to per-type coverage within this CLI-only plan. + +--- + +## [Dev-B] Task B14: Wire `Commands::Org` into `main.rs` (admin + item subcommands + lifecycle stubs) + +**Files:** +- Modify: `crates/relicario-cli/src/main.rs` + +This is the integration task that defines the `Commands::Org` arm, the `OrgCommands` enum, and the dispatch block. **Depends on every `run_*` command fn existing** (B4–B13). The item subcommand variants (`Add`/`Get`/`List`/`Edit`/`Rm`/`Restore`/`Purge`) and their dispatch arms are added by B10–B13 *extending* this enum/block; this task lands the admin variants plus the dispatch skeleton, and adds minimal stubs for `transfer-ownership` and `delete-org` (which the spec lists under Admin Operations). + +- [ ] **Step 1: Read the current Commands enum** + +```bash +grep -n "Subcommand\|enum Commands\|match cli.command" crates/relicario-cli/src/main.rs | head -40 +``` + +- [ ] **Step 2: Add the `Org` arm to the `Commands` enum** + +```rust +/// Manage a multi-user org vault. +Org { + /// Path to the org vault directory (overrides RELICARIO_ORG_DIR). + #[arg(long, global = true)] + dir: Option, + #[command(subcommand)] + subcommand: OrgCommands, +}, +``` + +- [ ] **Step 3: Add the `OrgCommands` enum (admin variants + lifecycle stubs)** + +The item variants (`Add`/`Get`/`List`/`Edit`/`Rm`/`Restore`/`Purge`) are added by B10–B13. The admin + lifecycle variants: + +```rust +#[derive(Subcommand)] +enum OrgCommands { + /// Create a new org vault. + Init { + #[arg(long)] + name: String, + }, + /// Add a member to the org. + AddMember { + /// OpenSSH ed25519 public key of the new member. + #[arg(long)] + key: String, + /// Display name. + #[arg(long)] + name: String, + /// Role: owner, admin, or member. + #[arg(long, default_value = "member")] + role: String, + }, + /// Remove a member from the org. + RemoveMember { + /// Member ID prefix. + member_id: String, + }, + /// Change a member's role. + SetRole { + member_id: String, + role: String, + }, + /// Create a collection. + CreateCollection { + slug: String, + #[arg(long)] + name: String, + }, + /// Grant a member access to a collection. + Grant { + member_id: String, + collection: String, + }, + /// Revoke a member's access to a collection. + Revoke { + member_id: String, + collection: String, + }, + /// Rotate the org master key (run after removing a member). + RotateKey, + /// Transfer ownership to another member (owner only). + TransferOwnership { + member_id: String, + }, + /// Delete the org (owner only; requires --confirm). + DeleteOrg { + #[arg(long)] + confirm: bool, + }, + /// Show org members and collections. + Status, + /// Query the org audit log. + Audit { + #[arg(long)] + since: Option, + #[arg(long)] + member: Option, + #[arg(long)] + collection: Option, + #[arg(long)] + action: Option, + #[arg(long)] + json: bool, + }, + // Item subcommands (Add/Get/List/Edit/Rm/Restore/Purge) are added by + // Tasks B10–B13, which extend this enum. +} +``` + +- [ ] **Step 4: Add the dispatch block + `parse_org_role` helper** + +```rust +Commands::Org { dir, subcommand } => { + let dir_path = dir.as_deref(); + match subcommand { + OrgCommands::Init { name } => { + let d = dir_path.ok_or_else(|| anyhow::anyhow!("--dir required for org init"))?; + commands::org::run_init(d, &name)?; + } + OrgCommands::AddMember { key, name, role } => { + let d = crate::org_session::org_dir(dir_path)?; + let role = parse_org_role(&role)?; + commands::org::run_add_member(&d, &key, &name, role)?; + } + OrgCommands::RemoveMember { member_id } => { + let d = crate::org_session::org_dir(dir_path)?; + commands::org::run_remove_member(&d, &member_id)?; + } + OrgCommands::SetRole { member_id, role } => { + let d = crate::org_session::org_dir(dir_path)?; + let role = parse_org_role(&role)?; + commands::org::run_set_role(&d, &member_id, role)?; + } + OrgCommands::CreateCollection { slug, name } => { + let d = crate::org_session::org_dir(dir_path)?; + commands::org::run_create_collection(&d, &slug, &name)?; + } + OrgCommands::Grant { member_id, collection } => { + let d = crate::org_session::org_dir(dir_path)?; + commands::org::run_grant(&d, &member_id, &collection)?; + } + OrgCommands::Revoke { member_id, collection } => { + let d = crate::org_session::org_dir(dir_path)?; + commands::org::run_revoke(&d, &member_id, &collection)?; + } + OrgCommands::RotateKey => { + let d = crate::org_session::org_dir(dir_path)?; + commands::org::run_rotate_key(&d)?; + } + OrgCommands::TransferOwnership { member_id } => { + let d = crate::org_session::org_dir(dir_path)?; + commands::org::run_transfer_ownership(&d, &member_id)?; + } + OrgCommands::DeleteOrg { confirm } => { + let d = crate::org_session::org_dir(dir_path)?; + commands::org::run_delete_org(&d, confirm)?; + } + OrgCommands::Status => { + let d = crate::org_session::org_dir(dir_path)?; + commands::org::run_status(&d)?; + } + OrgCommands::Audit { since, member, collection, action, json } => { + let d = crate::org_session::org_dir(dir_path)?; + commands::org::run_audit(&d, since.as_deref(), member.as_deref(), + collection.as_deref(), action.as_deref(), json)?; + } + // Item dispatch arms (Add/Get/List/Edit/Rm/Restore/Purge) added by + // Tasks B10–B13. + } +} +``` + +```rust +fn parse_org_role(s: &str) -> anyhow::Result { + match s { + "owner" => Ok(relicario_core::OrgRole::Owner), + "admin" => Ok(relicario_core::OrgRole::Admin), + "member" => Ok(relicario_core::OrgRole::Member), + other => anyhow::bail!("unknown role `{other}` — use owner, admin, or member"), + } +} +``` + +- [ ] **Step 5: Implement `run_transfer_ownership` and `run_delete_org` in `commands/org.rs`** + +The spec lists both under Admin Operations (owner-only). `transfer-ownership` promotes the target to Owner (the caller may optionally self-demote to Admin); `delete-org` requires `--confirm` and removes the working tree's org files. Minimal correct implementations: + +```rust +pub fn run_transfer_ownership(dir: &Path, member_id_prefix: &str) -> Result<()> { + let vault = crate::org_session::open_org_vault(Some(dir))?; + let caller = vault.current_member()?; + if !caller.role.can_manage_owners() { + anyhow::bail!("only an owner can transfer ownership"); + } + let mut members = vault.load_members()?; + let target_id = resolve_member_id(&members, member_id_prefix)?; + if target_id == caller.member_id { + anyhow::bail!("you are already the owner"); + } + { + let target = members.find_by_id_mut(&target_id) + .ok_or_else(|| anyhow::anyhow!("member not found"))?; + target.role = OrgRole::Owner; + } + vault.save_members(&members)?; + + let commit_msg = format!( + "org: transfer ownership to {}\n\nRelicario-Actor: {} {}\nRelicario-Action: ownership-transfer\nRelicario-Member: {}", + target_id.as_str(), caller.display_name, caller.member_id.as_str(), target_id.as_str() + ); + crate::org_session::org_git_run(&vault.root, &["add", "members.json"], "git add")?; + crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "git commit")?; + println!("Ownership transferred to {}", target_id.as_str()); + Ok(()) +} + +pub fn run_delete_org(dir: &Path, confirm: bool) -> Result<()> { + let vault = crate::org_session::open_org_vault(Some(dir))?; + let caller = vault.current_member()?; + if !caller.role.can_manage_owners() { + anyhow::bail!("only an owner can delete the org"); + } + if !confirm { + anyhow::bail!("refusing to delete org without --confirm"); + } + let commit_msg = format!( + "org: delete org\n\nRelicario-Actor: {} {}\nRelicario-Action: org-delete", + caller.display_name, caller.member_id.as_str() + ); + // Remove org files (the git history is retained as the audit record). + for f in ["org.json", "members.json", "collections.json", "manifest.enc"] { + let _ = fs::remove_file(vault.root.join(f)); + } + let _ = std::fs::remove_dir_all(vault.root.join("items")); + let _ = std::fs::remove_dir_all(vault.root.join("keys")); + crate::org_session::org_git_run(&vault.root, &["add", "-A"], "git add")?; + crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "git commit")?; + println!("Org deleted (git history retained as audit record)."); + Ok(()) +} +``` + +> **Tracked gap:** `delete-org`'s tombstone-commit semantics (whether the hook should *allow* a protected-file deletion by an owner) is a design corner the pre-receive hook (C1) currently REJECTs (`enforce_schema_monotonicity` forbids deleting protected JSON). This is an acknowledged interaction: for phase 1, `delete-org` is a **local** operation; pushing a delete to a hook-protected remote is out of scope and tracked as a follow-up. `transfer-ownership` is fully hook-compatible (it only mutates `members.json` roles, signed by the owner). + +- [ ] **Step 6: Build and test help output** + +```bash +cargo build -p relicario-cli 2>&1 | tail -10 +./target/debug/relicario org --help +./target/debug/relicario org init --help +``` + +Expected: clean build; help text for all org subcommands (admin + item). + +- [ ] **Step 7: Commit** + +```bash +git add crates/relicario-cli/src/main.rs crates/relicario-cli/src/commands/org.rs +git commit -m "feat(cli): wire Commands::Org (admin + item subcommands) + transfer-ownership/delete-org" +``` + +--- + +## Dev-C — relicario-server pre-receive hook + +> **Stream prerequisite:** Tasks A2–A3 must be merged before Dev-C begins (the hook imports `relicario_core::org::{OrgMember, OrgMembers, OrgCollections}`). No new Cargo deps beyond ensuring `tempfile` is a runtime dependency. +> +> **Dependency on the corrected item layout:** `classify_path` assumes `items//.enc`. It must match `UnlockedOrgVault::item_path(collection_slug, id)` from Task B1. If item commits ever used a flat `items/.enc`, every item commit would be `Rejected`. + +## [Dev-C] Task C1: `verify-org-commit` — signature + path-scoped authorization + +**Files:** +- Modify: `crates/relicario-server/Cargo.toml` +- Modify: `crates/relicario-server/src/main.rs` +- Create: `crates/relicario-server/src/lib.rs` +- Create: `crates/relicario-server/tests/org_hook.rs` + +The org pre-receive path mirrors the existing `verify_commit` (main.rs:37–153): build a temp `allowed_signers` from member ed25519 pubkeys, inject `gpg.ssh.allowedSignersFile` via `GIT_CONFIG_*`, run `git verify-commit --raw`, and parse the `key (SHA256:...)` fingerprint from **stderr**. We do **not** use `%GF` (it is empty without an allowed-signers file configured server-side). The verified signer's fingerprint maps to a current member via `relicario_core::fingerprint`; **that member is the authority** for every authorization check (trailers are advisory and ignored here). + +Authorization rules enforced per commit: +1. **Every** commit must carry a GOOD signature whose fingerprint maps to a current member. Unsigned / unknown-signer → REJECT. +2. **Protected paths** (`members.json`, `collections.json`, `org.json`): signer must have `role.can_manage_members()` (Owner or Admin). +3. **Item writes** `items//.enc`: the leading path segment `` must appear in the signing member's `collections` grant list. (Owners/Admins are **not** auto-granted — grants are explicit.) +4. Schema-version monotonicity for the three JSON files (Task C2). +5. Root commit (no parent) → inspect full tree, allow as genesis. Merge commit (>1 parent) → REJECT. + +- [ ] **Step 1: Ensure `tempfile` is a runtime dependency** + +Confirm `tempfile` is under `[dependencies]` (not only `[dev-dependencies]`) in `crates/relicario-server/Cargo.toml`. If the `[dependencies]` table lacks it, add: + +```toml +tempfile = "3" +``` + +```bash +cargo build -p relicario-server 2>&1 | tail -5 +``` + +Expected: clean build (the existing `verify_commit` already references `tempfile`, so it is almost certainly already present; this is a guard). + +- [ ] **Step 2: Write failing unit tests for path-segment parsing** + +Create `crates/relicario-server/tests/org_hook.rs`: + +```rust +// Integration tests for relicario-server org-hook path classification. + +use relicario_server::{classify_path, PathClass}; + +#[test] +fn protected_files_are_classified_protected() { + assert_eq!(classify_path("members.json"), PathClass::Protected); + assert_eq!(classify_path("collections.json"), PathClass::Protected); + assert_eq!(classify_path("org.json"), PathClass::Protected); +} + +#[test] +fn item_write_yields_collection_slug() { + assert_eq!( + classify_path("items/prod/a1b2c3d4e5f6a1b2.enc"), + PathClass::Item { collection: "prod".to_string() } + ); +} + +#[test] +fn item_write_nested_slug_takes_leading_segment_only() { + // Slugs cannot contain '/', so a 4-segment path is malformed → Rejected. + assert_eq!( + classify_path("items/prod/sub/x.enc"), + PathClass::Rejected("items path must be items//.enc".to_string()) + ); +} + +#[test] +fn key_blobs_and_manifest_are_unrestricted() { + // keys/.enc and manifest.enc are written by org operations; the SIGNATURE + // check (every commit must be signed by a current member) is the gate for them. + assert_eq!(classify_path("keys/a1b2c3d4e5f6a1b2.enc"), PathClass::Unrestricted); + assert_eq!(classify_path("manifest.enc"), PathClass::Unrestricted); +} + +#[test] +fn items_without_slug_segment_are_rejected() { + // Flat items/.enc (the OLD, now-removed layout) is no longer valid. + assert_eq!( + classify_path("items/a1b2c3d4e5f6a1b2.enc"), + PathClass::Rejected("items path must be items//.enc".to_string()) + ); +} + +#[test] +fn empty_slug_segment_is_rejected() { + assert_eq!( + classify_path("items//x.enc"), + PathClass::Rejected("empty collection slug in items path".to_string()) + ); +} +``` + +```bash +cargo test -p relicario-server --test org_hook 2>&1 | tail -20 +``` + +Expected: FAIL to compile — `classify_path` / `PathClass` not defined and no library target yet. + +- [ ] **Step 3: Add a library target with the pure helpers** + +Create `crates/relicario-server/src/lib.rs`: + +```rust +//! Library surface for relicario-server, exposing pure helpers used by the +//! pre-receive hooks so they can be unit-tested. + +/// Classification of a single changed path inside an org repo. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PathClass { + /// `members.json`, `collections.json`, `org.json` — only Owner/Admin may write. + Protected, + /// `items//.enc` — writer must hold a grant for ``. + Item { collection: String }, + /// `keys/.enc`, `manifest.enc`, `.gitignore`, etc. — gated only by the + /// per-commit signature check (signer must be a current member). + Unrestricted, + /// Structurally invalid path; commit must be rejected. + Rejected(String), +} + +/// Classify a repo-relative path. Pure; no I/O. +pub fn classify_path(path: &str) -> PathClass { + match path { + "members.json" | "collections.json" | "org.json" => return PathClass::Protected, + _ => {} + } + + if let Some(rest) = path.strip_prefix("items/") { + // Expect exactly: /.enc → two segments after the prefix. + let segments: Vec<&str> = rest.split('/').collect(); + if segments.len() != 2 { + return PathClass::Rejected("items path must be items//.enc".to_string()); + } + let slug = segments[0]; + if slug.is_empty() { + return PathClass::Rejected("empty collection slug in items path".to_string()); + } + return PathClass::Item { collection: slug.to_string() }; + } + + PathClass::Unrestricted +} +``` + +Wire the lib into `Cargo.toml`. In `crates/relicario-server/Cargo.toml`, add an explicit `[lib]` + `[[bin]]` pair: + +```toml +[lib] +name = "relicario_server" +path = "src/lib.rs" + +[[bin]] +name = "relicario-server" +path = "src/main.rs" +``` + +In `crates/relicario-server/src/main.rs`, import the helpers (after the existing `use` block): + +```rust +use relicario_server::{classify_path, PathClass}; +``` + +- [ ] **Step 4: Run the path-classification tests** + +```bash +cargo test -p relicario-server --test org_hook 2>&1 | tail -20 +``` + +Expected: all six `org_hook` tests pass. + +- [ ] **Step 5: Add the `VerifyOrgCommit` / `GenerateOrgHook` subcommands** + +In `crates/relicario-server/src/main.rs`, add to the `Commands` enum (after `GenerateHook`): + +```rust + /// Verify a commit to an org vault: signature + role/path authorization. + VerifyOrgCommit { + /// The commit SHA to verify. + commit: String, + }, + /// Generate an org pre-receive hook script. + GenerateOrgHook, +``` + +Add to the `match cli.command` block in `main()`: + +```rust + Commands::VerifyOrgCommit { commit } => verify_org_commit(&commit), + Commands::GenerateOrgHook => generate_org_hook(), +``` + +- [ ] **Step 6: Implement the signer-resolution helper (mirrors `verify_commit`)** + +```rust +use relicario_core::org::{OrgMember, OrgMembers}; + +/// Verify the SSH signature on `commit` against the given org members and return +/// the matching member. On any failure (unsigned, malformed, or unknown signer) +/// this prints REJECT and calls `std::process::exit(1)`; it only returns on success. +fn verify_org_signer(commit: &str, members: &OrgMembers) -> OrgMember { + // Build a temp allowed-signers file from every current member's pubkey. + let tmp = match tempfile::tempdir() { + Ok(t) => t, + Err(e) => { + eprintln!("REJECT: org commit {commit} — cannot create tempdir: {e}"); + std::process::exit(1); + } + }; + let allowed_path = tmp.path().join("allowed_signers"); + let mut allowed_body = String::new(); + for m in &members.members { + allowed_body.push_str("relicario "); + allowed_body.push_str(m.ed25519_pubkey.trim()); + allowed_body.push('\n'); + } + if let Err(e) = fs::write(&allowed_path, &allowed_body) { + eprintln!("REJECT: org commit {commit} — cannot write allowed_signers: {e}"); + std::process::exit(1); + } + + // Run git verify-commit --raw with the allowed-signers file injected. + let output = match Command::new("git") + .args(["verify-commit", "--raw", commit]) + .env("GIT_CONFIG_COUNT", "1") + .env("GIT_CONFIG_KEY_0", "gpg.ssh.allowedSignersFile") + .env("GIT_CONFIG_VALUE_0", allowed_path.as_os_str()) + .output() + { + Ok(o) => o, + Err(e) => { + eprintln!("REJECT: org commit {commit} — git verify-commit failed to run: {e}"); + std::process::exit(1); + } + }; + let stderr = String::from_utf8_lossy(&output.stderr); + + // Parse the SHA-256 fingerprint from stderr (same regex as verify_commit). + let re = regex::Regex::new(r"key (SHA256:[A-Za-z0-9+/]+)").expect("static regex"); + let signing_fp = match re.captures(&stderr).and_then(|c| c.get(1)) { + Some(m) => m.as_str().to_string(), + None => { + eprintln!( + "REJECT: org commit {commit} — no valid signature found (stderr: {})", + stderr.trim() + ); + std::process::exit(1); + } + }; + + // Map fingerprint → member via relicario_core::fingerprint over each pubkey. + for m in &members.members { + if let Ok(fp) = relicario_core::fingerprint(&m.ed25519_pubkey) { + if fp == signing_fp { + return m.clone(); + } + } + } + + eprintln!( + "REJECT: org commit {commit} — signer (fingerprint {signing_fp}) is not a current org member" + ); + std::process::exit(1); +} +``` + +- [ ] **Step 7: Implement `verify_org_commit` (parent/merge handling + per-path authorization)** + +> `enforce_schema_monotonicity` is defined in Task C2. The two tasks land in the same file and share one commit boundary at the end of C2 — do not run the full build until C2 has added that function. + +```rust +fn verify_org_commit(commit: &str) -> Result<()> { + // Determine parent count from %P (space-separated parent SHAs; empty = root). + let parents_out = Command::new("git") + .args(["show", "-s", "--format=%P", commit]) + .output() + .context("git show parents")?; + let parents_line = String::from_utf8_lossy(&parents_out.stdout); + let parents: Vec<&str> = parents_line.split_whitespace().collect(); + + // Merge commits are rejected. Org repos are linear (CLI uses pull --rebase). + if parents.len() > 1 { + eprintln!( + "REJECT: org commit {commit} — merge commits are not allowed in org vaults \ + ({} parents); rebase instead", + parents.len() + ); + std::process::exit(1); + } + let is_root = parents.is_empty(); + + // Load members.json AS OF THIS COMMIT so the genesis commit can authorize itself. + let members_json = match git_show(commit, "members.json") { + Ok(s) => s, + Err(_) => { + if is_root { + eprintln!("OK: org commit {commit} (root bootstrap - no members.json yet)"); + return Ok(()); + } + eprintln!("REJECT: org commit {commit} — members.json missing from non-root commit"); + std::process::exit(1); + } + }; + let members: OrgMembers = + serde_json::from_str(&members_json).context("parse members.json")?; + if members.members.is_empty() { + if is_root { + eprintln!("OK: org commit {commit} (root bootstrap - empty member list)"); + return Ok(()); + } + eprintln!("REJECT: org commit {commit} — members.json has no members"); + std::process::exit(1); + } + members + .validate() + .map_err(|e| anyhow::anyhow!("members.json invalid: {e}"))?; + + // Verify the signature and resolve the signing member (exits on failure). + let signer = verify_org_signer(commit, &members); + + // Enumerate changed paths. Root has no parent to diff, so use ls-tree. + let changed_paths: Vec = if is_root { + let out = Command::new("git") + .args(["ls-tree", "-r", "--name-only", commit]) + .output() + .context("git ls-tree")?; + String::from_utf8_lossy(&out.stdout) + .lines() + .map(|l| l.trim().to_string()) + .filter(|l| !l.is_empty()) + .collect() + } else { + let out = Command::new("git") + .args(["diff-tree", "--no-commit-id", "-r", "--name-only", commit]) + .output() + .context("git diff-tree")?; + String::from_utf8_lossy(&out.stdout) + .lines() + .map(|l| l.trim().to_string()) + .filter(|l| !l.is_empty()) + .collect() + }; + + // Authorize each changed path against the signing member's role/grants. + for path in &changed_paths { + match classify_path(path) { + PathClass::Rejected(why) => { + eprintln!("REJECT: org commit {commit} — invalid path `{path}`: {why}"); + std::process::exit(1); + } + PathClass::Protected => { + if !signer.role.can_manage_members() { + eprintln!( + "REJECT: org commit {commit} — member '{}' (role {:?}) may not write protected file `{path}`", + signer.display_name, signer.role + ); + std::process::exit(1); + } + } + PathClass::Item { collection } => { + if !signer.collections.iter().any(|c| c == &collection) { + eprintln!( + "REJECT: org commit {commit} — member '{}' lacks a grant for collection `{collection}` (path `{path}`)", + signer.display_name + ); + std::process::exit(1); + } + } + PathClass::Unrestricted => { + // keys/.enc, manifest.enc, etc. — signature check already passed. + } + } + } + + // Schema-version monotonicity for the three JSON files (Task C2). + enforce_schema_monotonicity(commit, is_root, &changed_paths)?; + + eprintln!( + "OK: org commit {commit} verified — signed by '{}' ({:?}), {} path(s) authorized", + signer.display_name, + signer.role, + changed_paths.len() + ); + Ok(()) +} +``` + +- [ ] **Step 8: Implement `generate_org_hook`** + +```rust +fn generate_org_hook() -> Result<()> { + print!( + r#"#!/bin/bash +# Relicario org pre-receive hook -- verify signatures + role/path authorization + +while read oldrev newrev refname; do + [ "$newrev" = "0000000000000000000000000000000000000000" ] && continue + + if [ "$oldrev" = "0000000000000000000000000000000000000000" ]; then + commits=$(git rev-list "$newrev") + else + commits=$(git rev-list "$oldrev..$newrev") + fi + + for commit in $commits; do + relicario-server verify-org-commit "$commit" || exit 1 + done +done +"# + ); + Ok(()) +} +``` + +- [ ] **Step 9: Build (depends on Task C2's `enforce_schema_monotonicity`)** + +> Do not run the full build until Task C2 has added `enforce_schema_monotonicity` to the same file. Proceed to C2, then return. + +```bash +cargo build -p relicario-server 2>&1 | tail -15 +``` + +- [ ] **Step 10: Commit (combined with Task C2 — see C2 Step 4)** + +This task does not commit on its own; the signature/auth code and the schema-monotonicity code land together. Proceed to Task C2. + +--- + +## [Dev-C] Task C2: Schema-version monotonicity + JSON validation + +**Files:** +- Modify: `crates/relicario-server/src/main.rs` +- Modify: `crates/relicario-server/src/lib.rs` +- Modify: `crates/relicario-server/tests/org_hook.rs` + +Every push that touches `members.json`, `collections.json`, or `org.json` must not **decrease** its `schema_version` (compared against `{commit}^:file`). On the root commit any starting version is accepted. We also re-validate `collections.json` here (members is validated in C1). + +- [ ] **Step 1: Write failing test for the version-extraction helper** + +Append to `crates/relicario-server/tests/org_hook.rs`: + +```rust +use relicario_server::extract_schema_version; + +#[test] +fn extract_schema_version_reads_field() { + let json = r#"{ "schema_version": 3, "members": [] }"#; + assert_eq!(extract_schema_version(json).unwrap(), 3); +} + +#[test] +fn extract_schema_version_errors_on_missing_field() { + let json = r#"{ "members": [] }"#; + assert!(extract_schema_version(json).is_err()); +} + +#[test] +fn extract_schema_version_errors_on_garbage() { + assert!(extract_schema_version("not json").is_err()); +} +``` + +```bash +cargo test -p relicario-server --test org_hook 2>&1 | tail -10 +``` + +Expected: FAIL — `extract_schema_version` not defined. + +- [ ] **Step 2: Implement `extract_schema_version` in the lib** + +Add to `crates/relicario-server/src/lib.rs`: + +```rust +/// Extract the `schema_version` field from any org JSON document. +/// Returns an error if the field is absent or not a u32. +pub fn extract_schema_version(json: &str) -> Result { + let value: serde_json::Value = + serde_json::from_str(json).map_err(|e| format!("parse json: {e}"))?; + value + .get("schema_version") + .and_then(|v| v.as_u64()) + .map(|n| n as u32) + .ok_or_else(|| "missing or non-integer schema_version".to_string()) +} +``` + +`serde_json` is already in `[dependencies]`, so the lib target picks it up — no Cargo.toml change. + +```bash +cargo test -p relicario-server --test org_hook extract_schema_version 2>&1 | tail -10 +``` + +Expected: the three `extract_schema_version` tests pass. + +- [ ] **Step 3: Implement `enforce_schema_monotonicity` in main.rs** + +```rust +use relicario_server::extract_schema_version; + +/// For each protected JSON file changed in this commit, ensure schema_version did +/// not decrease vs the parent commit, and re-validate collections.json structure. +fn enforce_schema_monotonicity( + commit: &str, + is_root: bool, + changed_paths: &[String], +) -> Result<()> { + const VERSIONED: [&str; 3] = ["members.json", "collections.json", "org.json"]; + + for file in VERSIONED { + if !changed_paths.iter().any(|p| p == file) { + continue; + } + + // A deletion of a protected file is not allowed. + let new_content = match git_show(commit, file) { + Ok(s) => s, + Err(_) => { + eprintln!( + "REJECT: org commit {commit} — protected file `{file}` was deleted; \ + org vaults never delete {file}" + ); + std::process::exit(1); + } + }; + let new_version = match extract_schema_version(&new_content) { + Ok(v) => v, + Err(e) => { + eprintln!("REJECT: org commit {commit} — `{file}` invalid: {e}"); + std::process::exit(1); + } + }; + + // collections.json structural validation. + if file == "collections.json" { + match serde_json::from_str::(&new_content) { + Ok(c) => { + if let Err(e) = c.validate() { + eprintln!("REJECT: org commit {commit} — collections.json invalid: {e}"); + std::process::exit(1); + } + } + Err(e) => { + eprintln!("REJECT: org commit {commit} — collections.json parse error: {e}"); + std::process::exit(1); + } + } + } + + // On the root commit there is no parent baseline; any starting version is fine. + if is_root { + continue; + } + + // Parent version: if the file did not exist in the parent (newly added), + // there is no prior version to regress against — accept. + if let Ok(old_content) = git_show_parent(commit, file) { + let old_version = match extract_schema_version(&old_content) { + Ok(v) => v, + Err(_) => { + continue; + } + }; + if new_version < old_version { + eprintln!( + "REJECT: org commit {commit} — `{file}` schema_version decreased \ + ({old_version} -> {new_version})" + ); + std::process::exit(1); + } + } + } + + Ok(()) +} + +/// Read a file from a commit's FIRST PARENT tree: `git show {commit}^:{path}`. +fn git_show_parent(commit: &str, path: &str) -> Result { + let output = Command::new("git") + .args(["show", &format!("{}^:{}", commit, path)]) + .output() + .context("git show parent")?; + if !output.status.success() { + anyhow::bail!("git show {}^:{} failed", commit, path); + } + Ok(String::from_utf8(output.stdout)?) +} +``` + +- [ ] **Step 4: Build the whole crate, run all server tests, then commit** + +```bash +cargo build -p relicario-server 2>&1 | tail -15 +cargo test -p relicario-server 2>&1 | tail -25 +``` + +Expected: clean build; all `org_hook` tests pass; existing `verify_commit` tests still pass. + +```bash +cargo run -p relicario-server -- generate-org-hook | head -20 +cargo run -p relicario-server -- verify-org-commit --help 2>&1 | head -10 +``` + +Expected: the org hook script prints with the `verify-org-commit` loop; `--help` shows the subcommand. + +```bash +git add crates/relicario-server/Cargo.toml crates/relicario-server/src/lib.rs crates/relicario-server/src/main.rs crates/relicario-server/tests/org_hook.rs +git commit -m "feat(server): verify-org-commit — signature + path-scoped role/grant auth + schema monotonicity" +``` + +--- + +## Dev-D — extension (CLI/extension parity) + +> **Stream prerequisite:** Tasks A2–A3 must be merged before Dev-D begins (the WASM bindings call `relicario_core::{unwrap_org_key, decrypt_org_manifest, decrypt_item}`). Independent of Dev-B/Dev-C. +> +> **Scope:** This cluster ships extension org support as **read + switch only** (list orgs, open an org, browse items, read a single item, switch back to Personal cleanly). Full extension-side org item *creation/editing* is explicitly a tracked follow-up (see the note at the end of Task D4) — org writes require signed, collection-scoped commits and the browser GitHosts write via the Contents REST API with no commit-signing path. + +## [Dev-D] Task D1: WASM org bindings (unwrap org key → SessionHandle, org manifest/item decrypt) + +**Why this task exists:** The current WASM surface has **no** org functions. `manifest_decrypt`/`item_decrypt` are strictly typed (`Manifest`/`Item`) and keyed by an opaque `SessionHandle` whose master key never crosses into JS. An `OrgManifest` is a different serde schema, so it cannot reuse `manifest_decrypt`. These bindings add the org read path while preserving the "key never leaves WASM" invariant: the unwrapped org key is dropped straight into the existing session table and only a `u32` handle is returned to JS. + +**Files:** +- Modify: `crates/relicario-wasm/src/lib.rs` +- Modify: `crates/relicario-wasm/Cargo.toml` (only if `base64` is not already a dependency) +- Rebuild artifact: `extension/wasm/relicario_wasm.js`, `relicario_wasm_bg.wasm`, `relicario_wasm.d.ts` + +- [ ] **Step 1: Confirm the core org API the binding depends on exists** + +Dev-A's Tasks A2/A3 must be merged first. Verify: + +```bash +cargo doc -p relicario-core --no-deps 2>/dev/null +grep -n "pub fn unwrap_org_key\|pub fn decrypt_org_manifest\|pub fn decrypt_item" \ + crates/relicario-core/src/org.rs crates/relicario-core/src/vault.rs +``` + +Expected: `unwrap_org_key(&[u8], &Zeroizing<[u8;32]>) -> Result>` in `org.rs`; `decrypt_org_manifest(&[u8], &Zeroizing<[u8;32]>) -> Result` in `vault.rs`; `decrypt_item(&[u8], &Zeroizing<[u8;32]>) -> Result` re-exported. If any signature differs, STOP and reconcile. + +- [ ] **Step 2: Confirm/add the `base64` dependency** + +```bash +grep -n "^base64" crates/relicario-wasm/Cargo.toml +``` + +If absent, add under `[dependencies]` in `crates/relicario-wasm/Cargo.toml`: + +```toml +base64 = "0.22" +``` + +(Confirm `0.22` matches the workspace: `grep -A1 'name = "base64"' Cargo.lock`. Use whatever major version is already locked; do not introduce a second version.) + +- [ ] **Step 3: Add a failing wasm-bindgen-test for the round trip** + +Add to the `#[cfg(test)] mod tests` block near the bottom of `crates/relicario-wasm/src/lib.rs`: + +```rust +#[test] +fn org_open_unwraps_into_a_handle_and_decrypts_manifest() { + use ed25519_dalek::SigningKey; + use rand::rngs::OsRng; + use rand::RngCore; + use zeroize::Zeroizing; + + // Build a device keypair and its OpenSSH PEM, exactly as the extension stores it. + let mut seed = [0u8; 32]; + OsRng.fill_bytes(&mut seed); + let signing = SigningKey::from_bytes(&seed); + let ssh_priv = ssh_key::PrivateKey::from(signing.clone()); + let pem: String = ssh_priv.to_openssh(ssh_key::LineEnding::LF).unwrap().to_string(); + let pubkey_openssh = ssh_priv.public_key().to_openssh().unwrap(); + + // Org key wrapped to that pubkey, and an org manifest encrypted under the org key. + let org_key = relicario_core::generate_org_key(); + let wrapped = relicario_core::wrap_org_key(&org_key, &pubkey_openssh).unwrap(); + + let mut manifest = relicario_core::OrgManifest::new(); + manifest.entries.push(relicario_core::OrgManifestEntry { + id: relicario_core::ItemId::new(), + r#type: relicario_core::ItemType::SecureNote, + title: "secret".into(), + tags: vec![], + modified: 0, + trashed_at: None, + collection: "prod".into(), + }); + let enc_manifest = relicario_core::encrypt_org_manifest(&manifest, &org_key).unwrap(); + + // The PEM is stored base64-wrapped by the SW; mirror that here. + use base64::Engine; + let pem_b64 = base64::engine::general_purpose::STANDARD.encode(pem.as_bytes()); + + // Exercise the internal helpers the wasm-bindgen exports wrap. + let handle = org_open_handle(&pem_b64, &wrapped).expect("org_open_handle"); + let out = session::with(handle, |k| { + relicario_core::decrypt_org_manifest(&enc_manifest, k) + }) + .unwrap() + .unwrap(); + assert_eq!(out.entries.len(), 1); + assert_eq!(out.entries[0].collection, "prod"); + session::remove(handle); + + let _ = (seed, signing, org_key, Zeroizing::new([0u8; 0])); +} +``` + +```bash +cargo test -p relicario-wasm org_open_unwraps 2>&1 | tail -8 +``` + +Expected: FAIL — `org_open_handle` undefined. + +> **Note on the test's `PrivateKey::from(signing.clone())`:** `relicario-wasm` is a separate crate; this draft used the direct `From` form. If the installed `ssh-key` rejects it (same H7 issue as core), use `ssh_key::PrivateKey::from(ssh_key::private::Ed25519Keypair::from(&signing))`. Verify against the wasm crate's `ssh-key` version before running. + +- [ ] **Step 4: Implement the bindings** + +Add to `crates/relicario-wasm/src/lib.rs`: + +```rust +use base64::Engine as _; + +/// Internal: parse a base64-wrapped OpenSSH ed25519 private-key PEM into its +/// 32-byte seed, unwrap the org key blob, and insert the org key into the +/// session table. Returns the raw u32 handle. The org key never crosses to JS. +fn org_open_handle(device_private_key_b64: &str, wrapped_key: &[u8]) -> Result { + use zeroize::Zeroizing; + + let pem_bytes = base64::engine::general_purpose::STANDARD + .decode(device_private_key_b64.trim()) + .map_err(|e| JsError::new(&format!("device key base64: {e}")))?; + let pem = String::from_utf8(pem_bytes) + .map_err(|e| JsError::new(&format!("device key utf8: {e}")))?; + + let private = ssh_key::PrivateKey::from_openssh(&pem) + .map_err(|e| JsError::new(&format!("parse device key: {e}")))?; + let ed = private + .key_data() + .ed25519() + .ok_or_else(|| JsError::new("device key is not ed25519"))?; + let seed_slice: &[u8] = ed.private.as_ref(); + if seed_slice.len() != 32 { + return Err(JsError::new("ed25519 seed has wrong length")); + } + let mut seed = Zeroizing::new([0u8; 32]); + seed.copy_from_slice(seed_slice); + + let org_key = relicario_core::unwrap_org_key(wrapped_key, &seed) + .map_err(|e| JsError::new(&format!("unwrap org key: {e}")))?; + + // image_secret is unused for org sessions — store zeros. + let handle = session::insert(org_key, Zeroizing::new([0u8; 32])); + Ok(handle) +} + +/// Unwrap the caller's wrapped org-key blob with their device private key and +/// return an opaque SessionHandle bound to the org master key. The org key +/// stays inside WASM linear memory — JS receives only the handle. +#[wasm_bindgen] +pub fn org_open(device_private_key_b64: &str, wrapped_key: &[u8]) -> Result { + let handle = org_open_handle(device_private_key_b64, wrapped_key)?; + Ok(SessionHandle(handle)) +} + +/// Decrypt an org manifest ciphertext under an org SessionHandle. +#[wasm_bindgen] +pub fn org_manifest_decrypt(handle: &SessionHandle, encrypted: &[u8]) -> Result { + need_key(handle)?; + let out = session::with(handle.0, |k| relicario_core::decrypt_org_manifest(encrypted, k)) + .unwrap() + .map_err(|e| JsError::new(&e.to_string()))?; + js_value_for(&out) +} + +/// Decrypt an org item ciphertext under an org SessionHandle. +#[wasm_bindgen] +pub fn org_item_decrypt(handle: &SessionHandle, encrypted: &[u8]) -> Result { + need_key(handle)?; + let out = session::with(handle.0, |k| relicario_core::decrypt_item(encrypted, k)) + .unwrap() + .map_err(|e| JsError::new(&e.to_string()))?; + js_value_for(&out) +} +``` + +> **Notes for the implementer:** +> - `SessionHandle` is a tuple struct `SessionHandle(u32)` and `need_key`, `session::with`, `session::insert`, `js_value_for` already exist in this file. `SessionHandle.0` is accessible because the binding code is in the same module. +> - `lock(&SessionHandle)` already removes the session entry, so the existing JS `wasm.lock(handle)` path zeroes the org key too — no new teardown export needed. +> - `ssh_key`, `ed25519_dalek`, `zeroize`, `relicario_core` are already dependencies of `relicario-wasm`. + +- [ ] **Step 5: Run the unit test** + +```bash +cargo test -p relicario-wasm org_open_unwraps 2>&1 | tail -8 +``` + +Expected: PASS. + +- [ ] **Step 6: Rebuild the WASM artifact the extension consumes** + +```bash +cd crates/relicario-wasm && wasm-pack build --target web --out-dir ../../extension/wasm 2>&1 | tail -15 +``` + +Then confirm the new exports landed: + +```bash +grep -n "export function org_open\|export function org_manifest_decrypt\|export function org_item_decrypt" \ + extension/wasm/relicario_wasm.d.ts +``` + +Expected: all three present. + +- [ ] **Step 7: Commit** + +```bash +git add crates/relicario-wasm/src/lib.rs crates/relicario-wasm/Cargo.toml extension/wasm/ +git commit -m "feat(wasm/org): org_open + org_manifest_decrypt + org_item_decrypt bindings" +``` + +--- + +## [Dev-D] Task D2: SW org session module + handlers (list_orgs / open_org / get_org_items / get_org_item) + +**Files:** +- Create: `extension/src/service-worker/org.ts` +- Modify: `extension/src/service-worker/router/popup-only.ts` (add 4 handler arms) +- Modify: `extension/src/shared/messages.ts` (add 4 message types + responses + capability-set entries) +- Modify: `extension/src/service-worker/index.ts` (clear org session on session-expiry) + +**Storage model (matches existing extension conventions):** +- Org configs live in `chrome.storage.local` under key `orgConfigs`: an array `OrgConfigEntry[]`. +- The unwrapped org master key lives **only** as a `SessionHandle` in a module-scope variable in `org.ts` — **never** written to `chrome.storage.local` or IndexedDB. +- Offline → read-only is derived, not stored: any `git.readFile` rejection while an org session is open flips an in-memory `readOnly` flag. + +- [ ] **Step 1: Add the message types and capability-set entries** + +In `extension/src/shared/messages.ts`, add to the `PopupMessage` union (after the `get_vault_status` arm): + +```typescript + | { type: 'list_orgs' } + | { type: 'open_org'; orgId: string } + | { type: 'get_org_items'; collection?: string } + | { type: 'get_org_item'; id: ItemId } +``` + +Add these typed responses near the other `*Response` interfaces: + +```typescript +export interface OrgSummary { + orgId: string; + label: string; +} + +export interface ListOrgsResponse extends Extract { + data: { orgs: OrgSummary[]; activeOrgId: string | null }; +} + +export interface OpenOrgResponse extends Extract { + data: { orgId: string; label: string; readOnly: boolean }; +} + +export interface GetOrgItemsResponse extends Extract { + data: { + items: Array<{ + id: ItemId; type: string; title: string; tags: string[]; + modified: number; trashed_at?: number; collection: string; + }>; + readOnly: boolean; + }; +} + +export interface GetOrgItemResponse extends Extract { + data: { item: Item; readOnly: boolean }; +} +``` + +Add the four types to `POPUP_ONLY_TYPES`: + +```typescript + 'create_vault', 'attach_vault', 'get_vault_status', + 'list_orgs', 'open_org', 'get_org_items', 'get_org_item', +``` + +- [ ] **Step 2: Create the SW org session module** + +Create `extension/src/service-worker/org.ts`: + +```typescript +/// Org-vault session + read operations for the service worker. +/// +/// Mirrors session.ts/vault.ts for the personal vault, but for org repos: +/// - Holds at most one unwrapped org SessionHandle in module memory. The org +/// master key lives inside WASM linear memory; JS only ever sees the opaque +/// handle. NEVER persisted. +/// - Org configs live in chrome.storage.local under `orgConfigs` — config only. +/// - Offline is derived: a failed git.readFile flips the active session's +/// readOnly flag. + +import type { SessionHandle } from '../../wasm/relicario_wasm'; +import type { GitHost } from './git-host'; +import { createGitHost } from './git-host'; +import type { Item } from '../shared/types'; + +export interface OrgConfigEntry { + orgId: string; + label: string; + hostType: 'gitea' | 'github'; + hostUrl: string; + repoPath: string; + apiToken: string; + /// Path inside the org repo of THIS device's wrapped org-key blob, + /// e.g. "keys/.enc". + wrappedKeyPath: string; +} + +interface ActiveOrgSession { + orgId: string; + label: string; + handle: SessionHandle; + git: GitHost; + readOnly: boolean; +} + +// Module-scope, single active org session. NEVER serialized. +let active: ActiveOrgSession | null = null; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +let wasm: any = null; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function setWasm(w: any): void { wasm = w; } +function requireWasm(): any { + if (!wasm) throw new Error('WASM module not initialized'); + return wasm; +} + +export async function loadOrgConfigs(): Promise { + const r = await chrome.storage.local.get('orgConfigs'); + const raw = r.orgConfigs; + return Array.isArray(raw) ? (raw as OrgConfigEntry[]) : []; +} + +export function getActiveOrgId(): string | null { + return active ? active.orgId : null; +} + +export function isReadOnly(): boolean { + return active ? active.readOnly : false; +} + +/// Tear down the active org session: lock (zeroes the org key in WASM) and drop. +export function clearActiveOrg(): void { + if (active) { + try { requireWasm().lock(active.handle); } catch { /* may already be gone */ } + try { (active.handle as unknown as { free?: () => void }).free?.(); } catch { /* idempotent */ } + active = null; + } +} + +/// Read the device private key the SW persisted at setup/register time. +async function loadDevicePrivateKeyB64(): Promise { + const r = await chrome.storage.local.get('device_private_key'); + const key = r.device_private_key; + if (typeof key !== 'string' || key.length === 0) { + throw new Error('device_not_registered'); + } + return key; +} + +/// Open (unlock) an org by id: build its GitHost, fetch this device's wrapped +/// key blob, and unwrap into an org SessionHandle via WASM. +export async function openOrg(orgId: string): Promise<{ orgId: string; label: string; readOnly: boolean }> { + const w = requireWasm(); + const configs = await loadOrgConfigs(); + const cfg = configs.find((c) => c.orgId === orgId); + if (!cfg) throw new Error('org_not_found'); + + const devicePrivB64 = await loadDevicePrivateKeyB64(); + const git = createGitHost(cfg.hostType, cfg.hostUrl, cfg.repoPath, cfg.apiToken); + + let readOnly = false; + let wrapped: Uint8Array; + try { + wrapped = await git.readFile(cfg.wrappedKeyPath); + } catch { + throw new Error('org_offline'); + } + + const handle = w.org_open(devicePrivB64, wrapped) as SessionHandle; + + clearActiveOrg(); + active = { orgId: cfg.orgId, label: cfg.label, handle, git, readOnly }; + return { orgId: cfg.orgId, label: cfg.label, readOnly }; +} + +interface OrgManifestEntryJson { + id: string; type: string; title: string; tags?: string[]; + modified: number; trashed_at?: number; collection: string; +} +interface OrgManifestJson { schema_version: number; entries: OrgManifestEntryJson[]; } + +/// Fetch + decrypt the active org's manifest, optionally filtered to one collection. +export async function getOrgItems( + collection?: string, +): Promise<{ items: OrgManifestEntryJson[]; readOnly: boolean }> { + const w = requireWasm(); + if (!active) throw new Error('org_locked'); + let ciphertext: Uint8Array; + try { + ciphertext = await active.git.readFile('manifest.enc'); + } catch { + active.readOnly = true; + throw new Error('org_offline'); + } + const manifest = w.org_manifest_decrypt(active.handle, ciphertext) as OrgManifestJson; + const all = (manifest.entries ?? []).filter((e) => e.trashed_at === undefined); + const slug = collection?.toLowerCase(); + const items = slug ? all.filter((e) => e.collection.toLowerCase() === slug) : all; + return { + items: items.map((e) => ({ ...e, tags: e.tags ?? [] })), + readOnly: active.readOnly, + }; +} + +/// Fetch + decrypt a single org item. Items are collection-scoped on disk +/// (items//.enc), so we read the manifest entry first to learn the slug. +export async function getOrgItem(id: string): Promise<{ item: Item; readOnly: boolean }> { + const w = requireWasm(); + if (!active) throw new Error('org_locked'); + + let manifestCt: Uint8Array; + try { + manifestCt = await active.git.readFile('manifest.enc'); + } catch { + active.readOnly = true; + throw new Error('org_offline'); + } + const manifest = w.org_manifest_decrypt(active.handle, manifestCt) as OrgManifestJson; + const entry = manifest.entries.find((e) => e.id === id); + if (!entry) throw new Error('item_not_found'); + + let itemCt: Uint8Array; + try { + itemCt = await active.git.readFile(`items/${entry.collection}/${id}.enc`); + } catch { + active.readOnly = true; + throw new Error('org_offline'); + } + const item = w.org_item_decrypt(active.handle, itemCt) as Item; + return { item, readOnly: active.readOnly }; +} + +/// Build the list-orgs payload from config; the active id reflects the live +/// in-memory session, not anything persisted. +export async function listOrgs(): Promise<{ + orgs: Array<{ orgId: string; label: string }>; + activeOrgId: string | null; +}> { + const configs = await loadOrgConfigs(); + return { + orgs: configs.map((c) => ({ orgId: c.orgId, label: c.label })), + activeOrgId: getActiveOrgId(), + }; +} +``` + +- [ ] **Step 3: Wire `setWasm(orgModule)` at SW init** + +In `extension/src/service-worker/index.ts`, import the org module: + +```typescript +import * as org from './org'; +``` + +Inside `initWasm()`, right after `vault.setWasm(wasmBindings);`: + +```typescript + org.setWasm(wasmBindings); +``` + +And in the `sessionTimer.onExpired(...)` callback (after `state.gitHost = null;`), tear the org session down too: + +```typescript + org.clearActiveOrg(); +``` + +- [ ] **Step 4: Add the four handler arms** + +In `extension/src/service-worker/router/popup-only.ts`, add an import: + +```typescript +import * as org from '../org'; +``` + +Add these arms inside the `switch (msg.type)` in `handle(...)`, after the `get_vault_status` arm: + +```typescript + case 'list_orgs': { + const data = await org.listOrgs(); + return { ok: true, data }; + } + + case 'open_org': { + try { + const data = await org.openOrg(msg.orgId); + return { ok: true, data }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) }; + } + } + + case 'get_org_items': { + try { + const data = await org.getOrgItems(msg.collection); + return { ok: true, data }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) }; + } + } + + case 'get_org_item': { + try { + const data = await org.getOrgItem(msg.id); + return { ok: true, data }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) }; + } + } +``` + +> Recommended: also call `org.clearActiveOrg()` in the existing `lock` arm for symmetric teardown. + +- [ ] **Step 5: Type-check the whole extension (NOT bare `tsc --noEmit`)** + +```bash +cd extension && npm run build:all 2>&1 | tail -20 +``` + +Expected: clean build. The new `org_open`/`org_manifest_decrypt`/`org_item_decrypt` resolve against the regenerated `relicario_wasm.d.ts` from Task D1. + +- [ ] **Step 6: Commit** + +```bash +git add extension/src/service-worker/org.ts extension/src/service-worker/index.ts \ + extension/src/service-worker/router/popup-only.ts extension/src/shared/messages.ts +git commit -m "feat(ext/sw): org session module + list_orgs/open_org/get_org_items/get_org_item handlers" +``` + +--- + +## [Dev-D] Task D3: Vault-tab org switcher (Personal + each configured org) + +**Files:** +- Create: `extension/src/vault/vault-org-switcher.ts` +- Modify: `extension/src/vault/vault-sidebar.ts` (mount the switcher) +- Modify: `extension/src/vault/vault-context.ts`, `vault.ts` (add `activeOrgId` state) +- Modify: `extension/src/vault/vault.css` (switcher + read-only banner styling) + +The switcher is a small `