From f27dc72e96452f622e9cf75ce055c6f6896fecdf Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sat, 20 Jun 2026 16:48:46 -0400 Subject: [PATCH] =?UTF-8?q?docs(plan):=20v0.8.1=20org=20item-type=20parity?= =?UTF-8?q?=20=E2=80=94=204-stream=20multi-agent=20plan?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dev-A shared item_build foundation + personal --*-stdin; Dev-B org Card/Key/Totp; Dev-C org Document + attachment storage; Dev-D server hook grant-scoping. TDD tasks with full code; A gates B/C, D independent. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01L5JvzEse4xUxLZKhofyeCD --- .../2026-06-20-relicario-v0.8.1-parity.md | 1216 +++++++++++++++++ 1 file changed, 1216 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-20-relicario-v0.8.1-parity.md diff --git a/docs/superpowers/plans/2026-06-20-relicario-v0.8.1-parity.md b/docs/superpowers/plans/2026-06-20-relicario-v0.8.1-parity.md new file mode 100644 index 0000000..6ba3ff9 --- /dev/null +++ b/docs/superpowers/plans/2026-06-20-relicario-v0.8.1-parity.md @@ -0,0 +1,1216 @@ +# v0.8.1 Org Vault Item-Type Parity — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Bring `relicario org add` / `relicario org edit` to full item-type parity with the personal vault by adding Card, Key, Document, and Totp support, with secrets entered via interactive prompts plus `--*-stdin` escape hatches, including the `relicario-server` hook change Document requires. + +**Architecture:** Extract per-type item construction, secret resolution, and interactive-edit logic into one shared CLI module (`commands/item_build.rs`) that both the personal and org command handlers call — eliminating the current personal↔org builder duplication. Org gains collection-scoped attachment storage; the pre-receive hook is extended to grant-scope attachment write paths. + +**Tech Stack:** Rust (`relicario-cli`, `relicario-core`, `relicario-server`), `clap`, `anyhow`, `zeroize::Zeroizing`, `assert_cmd` + `tempfile` for CLI integration tests, `ssh-keygen` for the org test fixture. + +**Spec:** `docs/superpowers/specs/2026-06-20-relicario-v0.8.1-parity.md` + +## Global Constraints + +- **Capitalization:** "Relicario" with a capital R in prose / UI / CLI help text; lowercase `relicario` for the binary, crate names, identifiers, and paths. +- **Secret-input model:** single-line secrets resolve as `--*-stdin` (read one trimmed-of-trailing-newline line from stdin) → else interactive `prompt_secret` (which honours `RELICARIO_TEST_ITEM_SECRET`). Multiline secrets (key material, note body) always read stdin to EOF; the `--*-stdin` flag only suppresses the interactive "paste; Ctrl-D" hint. +- **Field history:** core-field edits record prior values under synthetic `FieldId(format!("core:{key}"))` keys via `push_history` — never invent a different scheme. +- **Org commits:** every org write is committed through `crate::org_session::org_git_run` with the existing structured trailers (`Relicario-Actor`, `Relicario-Action`, `Relicario-Collection`, `Relicario-Item`). Do not introduce an unsigned commit path. +- **Org storage layout:** items `items//.enc`; attachments (new) `attachments///.enc`. The leading `` is what the hook authorizes against — it is never decrypted server-side. +- **Discipline:** DRY, YAGNI, TDD (failing test first), frequent commits. Conventional-commit messages. +- **Hook redeploy:** the `relicario-server` change (Dev-D) requires the deployed pre-receive hook to be rebuilt; the release notes must call this out. + +## File Structure + +| Path | Responsibility | Stream | +|---|---|---| +| `crates/relicario-cli/src/commands/item_build.rs` | **NEW.** Shared secret-resolution helpers, type parsers, per-type `build_*` item builders, per-type interactive `edit_*` helpers + `push_history`. | A | +| `crates/relicario-cli/src/commands/mod.rs` | Register `pub mod item_build;`. | A | +| `crates/relicario-cli/src/commands/add.rs` | `cmd_add` delegates to shared builders; sets `group`/`tags`/`favorite` post-build. | A | +| `crates/relicario-cli/src/commands/edit.rs` | `cmd_edit` delegates to shared `edit_*` helpers. | A | +| `crates/relicario-cli/src/main.rs` | Personal `AddKind` `--*-stdin` flags (A); org `OrgAddKind` Card/Key/Totp/Document variants + dispatch (B/C); `OrgCommands::Edit` → query-only interactive (B). | A/B/C | +| `crates/relicario-cli/src/commands/org.rs` | `commands::org::OrgAddKind` new variants; `run_add` arms; `run_edit` per-type interactive dispatch; `run_purge` removes attachments. | B/C | +| `crates/relicario-cli/src/org_session.rs` | Attachment path/save/load/remove methods + default cap constant. | C | +| `crates/relicario-cli/tests/org_items.rs` | Card/Key/Totp/Document add→get→edit→rm round-trips; secret masking; grant-denied attachment write. | B/C | +| `crates/relicario-server/src/lib.rs` | `classify_path` recognizes `attachments///.enc` → `Item{collection}`. | D | +| `crates/relicario-server/tests/org_hook.rs` | Attachment classification tests. | D | +| `crates/relicario-server/Cargo.toml` | Version bump. | D | +| `docs/FORMATS.md`, `crates/relicario-cli/ARCHITECTURE.md`, `docs/SECURITY.md` | Living-docs per stream. | C/A/D | + +## Stream dependency graph + +``` +Dev-A (foundation: shared module + personal refactor + personal --*-stdin) + ├──> Dev-B (org Card/Key/Totp) ──> [org.rs] ──> Dev-C (org Document + attachments) + └──> Dev-C (build_document) +Dev-D (server hook) ── independent, may start at minute zero +``` + +- **Dev-A** must merge before B and C integrate (they consume its public interface). +- **Dev-B** establishes the org per-type `OrgAddKind`/`run_edit` dispatch skeleton in `commands/org.rs`; **Dev-C** adds the Document arm into that skeleton, so **B merges before C** (C rebases on B). They share `org.rs` and `org_items.rs`. +- **Dev-D** touches only `relicario-server` — zero overlap; run it fully in parallel. + +--- + +# Dev-A — Shared item-build foundation + +**Interfaces produced (consumed by B and C):** + +```rust +// crates/relicario-cli/src/commands/item_build.rs +use std::path::PathBuf; +use std::collections::HashMap; +use anyhow::Result; +use zeroize::Zeroizing; +use relicario_core::{Item, EncryptedAttachment, FieldId}; +use relicario_core::item::FieldHistoryEntry; + +pub(crate) type FieldHistory = HashMap>; + +// --- secret resolution --- +pub(crate) fn resolve_secret_line(from_stdin: bool, label: &str) -> Result; +pub(crate) fn resolve_secret_multiline(from_stdin: bool, hint: &str) -> Result; + +// --- parsers --- +pub(crate) fn parse_card_kind(s: &str) -> Result; +pub(crate) fn parse_totp_algorithm(s: &str) -> Result; + +// --- builders (return Item with title + core only; caller sets group/tags/favorite) --- +pub(crate) fn build_login(title: String, username: Option, url: Option, + password: Option, password_stdin: bool, password_prompt: bool, + totp_qr: Option) -> Result; +pub(crate) fn build_secure_note(title: String, body: Option, body_stdin: bool) -> Result; +pub(crate) fn build_identity(title: String, full_name: Option, email: Option, + phone: Option, date_of_birth: Option) -> Result; +pub(crate) fn build_card(title: String, holder: Option, expiry: Option, + kind: &str, number_stdin: bool, cvv_stdin: bool, pin_stdin: bool) -> Result; +pub(crate) fn build_key(title: String, label: Option, algorithm: Option, + public_key: Option, material_stdin: bool) -> Result; +pub(crate) fn build_totp(title: String, issuer: Option, label: Option, + secret: Option, secret_stdin: bool, period: u32, digits: u8, algorithm: &str) -> Result; +pub(crate) fn build_document(title: String, file: PathBuf, + key: &Zeroizing<[u8; 32]>, max_bytes: u64) -> Result<(Item, EncryptedAttachment)>; + +// --- interactive edit helpers (mutate core in place; record history) --- +pub(crate) fn edit_login(l: &mut relicario_core::item_types::LoginCore, history: &mut FieldHistory, totp_qr: Option) -> Result<()>; +pub(crate) fn edit_secure_note(n: &mut relicario_core::item_types::SecureNoteCore, history: &mut FieldHistory) -> Result<()>; +pub(crate) fn edit_identity(i: &mut relicario_core::item_types::IdentityCore) -> Result<()>; +pub(crate) fn edit_card(c: &mut relicario_core::item_types::CardCore, history: &mut FieldHistory) -> Result<()>; +pub(crate) fn edit_key(k: &mut relicario_core::item_types::KeyCore, history: &mut FieldHistory) -> Result<()>; +pub(crate) fn edit_totp(t: &mut relicario_core::item_types::TotpCore, history: &mut FieldHistory) -> Result<()>; +pub(crate) fn edit_document_message(); +pub(crate) fn push_history(history: &mut FieldHistory, synthetic_key: &str, old_value: Zeroizing); +``` + +### Task A1: Shared module scaffold — secret resolution + parsers + +**Files:** +- Create: `crates/relicario-cli/src/commands/item_build.rs` +- Modify: `crates/relicario-cli/src/commands/mod.rs` (add `pub mod item_build;`) +- Test: inline `#[cfg(test)]` in `item_build.rs` + +- [ ] **Step 1: Register the module.** In `crates/relicario-cli/src/commands/mod.rs` add (alongside the other `pub mod` lines): + +```rust +pub mod item_build; +``` + +- [ ] **Step 2: Write the failing parser tests.** Create `crates/relicario-cli/src/commands/item_build.rs` with only the test module first: + +```rust +#[cfg(test)] +mod tests { + use super::*; + use relicario_core::item_types::{CardKind, TotpAlgorithm}; + + #[test] + fn card_kind_parses_known_values() { + assert_eq!(parse_card_kind("credit").unwrap(), CardKind::Credit); + assert_eq!(parse_card_kind("loyalty").unwrap(), CardKind::Loyalty); + } + + #[test] + fn card_kind_rejects_unknown() { + assert!(parse_card_kind("platinum").is_err()); + } + + #[test] + fn totp_algorithm_is_case_insensitive() { + assert_eq!(parse_totp_algorithm("SHA256").unwrap(), TotpAlgorithm::Sha256); + } + + #[test] + fn totp_algorithm_rejects_unknown() { + assert!(parse_totp_algorithm("md5").is_err()); + } +} +``` + +- [ ] **Step 3: Run to verify failure.** +Run: `cargo test -p relicario-cli --lib item_build` +Expected: FAIL — `parse_card_kind`/`parse_totp_algorithm` not found. + +- [ ] **Step 4: Implement helpers + parsers.** Prepend above the test module in `item_build.rs`: + +```rust +//! Shared per-type item construction + interactive editing for both the +//! personal vault (`commands/add.rs`, `commands/edit.rs`) and the org vault +//! (`commands/org.rs`). Centralizing it keeps the two surfaces from drifting. + +use std::collections::HashMap; +use std::path::PathBuf; + +use anyhow::{Context, Result}; +use zeroize::Zeroizing; + +use relicario_core::item::FieldHistoryEntry; +use relicario_core::item_types::{CardKind, TotpAlgorithm}; +use relicario_core::{EncryptedAttachment, FieldId, Item, ItemCore}; + +pub(crate) type FieldHistory = HashMap>; + +/// Resolve a single-line secret: from stdin when `from_stdin`, else an +/// interactive masked prompt (which honours `RELICARIO_TEST_ITEM_SECRET`). +pub(crate) fn resolve_secret_line(from_stdin: bool, label: &str) -> Result { + if from_stdin { + let mut s = String::new(); + std::io::stdin().read_line(&mut s)?; + Ok(s.trim_end_matches(['\n', '\r']).to_string()) + } else { + crate::prompt::prompt_secret(&format!("{label}: ")) + } +} + +/// Resolve a multiline secret (key material, note body). Both paths read stdin +/// to EOF; the interactive path first prints `hint` to stderr. +pub(crate) fn resolve_secret_multiline(from_stdin: bool, hint: &str) -> Result { + if !from_stdin { + eprintln!("{hint}"); + } + let mut s = String::new(); + std::io::Read::read_to_string(&mut std::io::stdin(), &mut s)?; + Ok(s) +} + +pub(crate) fn parse_card_kind(s: &str) -> Result { + Ok(match s { + "credit" => CardKind::Credit, + "debit" => CardKind::Debit, + "gift" => CardKind::Gift, + "loyalty" => CardKind::Loyalty, + "other" => CardKind::Other, + other => anyhow::bail!("unknown card kind: {other}"), + }) +} + +pub(crate) fn parse_totp_algorithm(s: &str) -> Result { + Ok(match s.to_ascii_lowercase().as_str() { + "sha1" => TotpAlgorithm::Sha1, + "sha256" => TotpAlgorithm::Sha256, + "sha512" => TotpAlgorithm::Sha512, + other => anyhow::bail!("unknown algorithm: {other}"), + }) +} +``` + +- [ ] **Step 5: Run to verify pass.** +Run: `cargo test -p relicario-cli --lib item_build` +Expected: PASS (4 tests). + +- [ ] **Step 6: Commit.** + +```bash +git add crates/relicario-cli/src/commands/item_build.rs crates/relicario-cli/src/commands/mod.rs +git commit -m "feat(cli): shared item_build module — secret resolution + type parsers" +``` + +### Task A2: Move interactive edit helpers into the shared module + +**Files:** +- Modify: `crates/relicario-cli/src/commands/item_build.rs` (add `edit_*` + `push_history`) +- Modify: `crates/relicario-cli/src/commands/edit.rs` (delete the moved fns; call shared ones) +- Test: existing `crates/relicario-cli/tests/edit_and_history.rs` (behavior-preserving) + +**Interfaces produced:** the six `edit_*` functions + `edit_document_message` + `push_history` (signatures in the Dev-A header). + +- [ ] **Step 1: Move the helpers verbatim.** Cut these functions from `crates/relicario-cli/src/commands/edit.rs` and paste them into `item_build.rs`: `edit_login`, `edit_secure_note`, `edit_identity`, `edit_card`, `edit_key`, `edit_document_message`, `edit_totp`, `push_history`, and the local `type FieldHistory` (delete the duplicate `type FieldHistory` in edit.rs — use the shared one). Change each from private `fn` to `pub(crate) fn`. They already use `crate::prompt::*`, `relicario_core::*`, `crate::parse::base32_decode_lenient` — keep those paths (they resolve identically from `item_build.rs`). Add the needed `use` lines to `item_build.rs`: + +```rust +use crate::parse::base32_decode_lenient; +use crate::prompt::{prompt_keep_opt, prompt_secret, prompt_yesno}; +use relicario_core::time::now_unix; +``` + +- [ ] **Step 2: Rewire edit.rs dispatch.** In `crates/relicario-cli/src/commands/edit.rs::cmd_edit`, the per-type match becomes calls into the shared module: + +```rust + let history = &mut item.field_history; + match &mut item.core { + ItemCore::Login(l) => crate::commands::item_build::edit_login(l, history, totp_qr)?, + ItemCore::SecureNote(n) => crate::commands::item_build::edit_secure_note(n, history)?, + ItemCore::Identity(i) => crate::commands::item_build::edit_identity(i)?, + ItemCore::Card(c) => crate::commands::item_build::edit_card(c, history)?, + ItemCore::Key(k) => crate::commands::item_build::edit_key(k, history)?, + ItemCore::Document(_) => crate::commands::item_build::edit_document_message(), + ItemCore::Totp(t) => crate::commands::item_build::edit_totp(t, history)?, + } +``` + +Remove now-unused imports from `edit.rs` (e.g. `prompt_secret`, `prompt_yesno`, `base32_decode_lenient` if no longer referenced). Keep `prompt_keep`/`prompt_keep_opt` only if the title/group/tags prompts at the top of `cmd_edit` still use them. + +- [ ] **Step 3: Run to verify it fails first if anything diverged.** +Run: `cargo build -p relicario-cli` +Expected: compiles. If a moved fn references a symbol not imported in `item_build.rs`, fix the `use`. + +- [ ] **Step 4: Run the edit/history tests.** +Run: `cargo test -p relicario-cli --test edit_and_history` +Expected: PASS — behavior is unchanged (functions only moved + re-exported). + +- [ ] **Step 5: Commit.** + +```bash +git add crates/relicario-cli/src/commands/item_build.rs crates/relicario-cli/src/commands/edit.rs +git commit -m "refactor(cli): move per-type edit helpers into shared item_build module" +``` + +### Task A3: Move add builders into the shared module + +**Files:** +- Modify: `crates/relicario-cli/src/commands/item_build.rs` (add `build_*`) +- Modify: `crates/relicario-cli/src/commands/add.rs` (delegate; set group/tags/favorite) +- Test: existing `crates/relicario-cli/tests/basic_flows.rs`, `attachments.rs` + +**Interfaces produced:** the seven `build_*` functions (signatures in the Dev-A header). + +- [ ] **Step 1: Add the builders to `item_build.rs`.** Port the bodies from the current `add.rs` `build_*_item` fns, with these changes: builders take an already-resolved `title: String`; they set only `title` + core (NOT group/tags/favorite); secrets go through `resolve_secret_line`/`resolve_secret_multiline`: + +```rust +pub(crate) fn build_login( + title: String, username: Option, url: Option, + password: Option, password_stdin: bool, password_prompt: bool, + totp_qr: Option, +) -> Result { + use relicario_core::item_types::{LoginCore, TotpAlgorithm, TotpConfig, TotpKind}; + let parsed_url = match url { + Some(s) => Some(url::Url::parse(&s).with_context(|| format!("invalid URL: {s}"))?), + None => None, + }; + let password = if let Some(p) = password { + Some(Zeroizing::new(p)) + } else if password_stdin { + Some(Zeroizing::new(resolve_secret_line(true, "Password")?)) + } else if password_prompt { + Some(Zeroizing::new(crate::prompt::prompt_secret("Password: ")?)) + } else { + None + }; + let totp = if let Some(path) = totp_qr { + let secret_b32 = crate::helpers::decode_totp_qr(&path)?; + let secret_bytes = base32_decode_lenient(&secret_b32)?; + Some(TotpConfig { + secret: Zeroizing::new(secret_bytes), algorithm: TotpAlgorithm::Sha1, + digits: 6, period_seconds: 30, kind: TotpKind::Totp, + }) + } else { None }; + Ok(Item::new(title, ItemCore::Login(LoginCore { username, password, url: parsed_url, totp }))) +} + +pub(crate) fn build_secure_note(title: String, body: Option, body_stdin: bool) -> Result { + use relicario_core::item_types::SecureNoteCore; + let body = match body { + Some(b) => b, + None => resolve_secret_multiline(body_stdin, "Enter note body; end with Ctrl-D on a blank line:")?, + }; + Ok(Item::new(title, ItemCore::SecureNote(SecureNoteCore { body: Zeroizing::new(body) }))) +} + +pub(crate) fn build_identity( + title: String, full_name: Option, email: Option, + phone: Option, date_of_birth: Option, +) -> Result { + use relicario_core::item_types::IdentityCore; + let dob = match date_of_birth { + Some(s) => Some(chrono::NaiveDate::parse_from_str(&s, "%Y-%m-%d") + .with_context(|| format!("invalid date {s} (expected YYYY-MM-DD)"))?), + None => None, + }; + Ok(Item::new(title, ItemCore::Identity(IdentityCore { + full_name, address: None, phone, email, date_of_birth: dob, + }))) +} + +pub(crate) fn build_card( + title: String, holder: Option, expiry: Option, kind: &str, + number_stdin: bool, cvv_stdin: bool, pin_stdin: bool, +) -> Result { + use relicario_core::item_types::CardCore; + let number = Zeroizing::new(resolve_secret_line(number_stdin, "Card number")?); + let cvv = resolve_secret_line(cvv_stdin, "CVV (blank to skip)")?; + let cvv = if cvv.is_empty() { None } else { Some(Zeroizing::new(cvv)) }; + let pin = resolve_secret_line(pin_stdin, "PIN (blank to skip)")?; + let pin = if pin.is_empty() { None } else { Some(Zeroizing::new(pin)) }; + let parsed_expiry = match expiry { Some(s) => Some(crate::parse::parse_month_year(&s)?), None => None }; + Ok(Item::new(title, ItemCore::Card(CardCore { + number: Some(number), holder, expiry: parsed_expiry, cvv, pin, kind: parse_card_kind(kind)?, + }))) +} + +pub(crate) fn build_key( + title: String, label: Option, algorithm: Option, + public_key: Option, material_stdin: bool, +) -> Result { + use relicario_core::item_types::KeyCore; + let key_material = resolve_secret_multiline(material_stdin, "Paste key material; end with Ctrl-D on a blank line:")?; + if key_material.trim().is_empty() { anyhow::bail!("key material required"); } + Ok(Item::new(title, ItemCore::Key(KeyCore { + key_material: Zeroizing::new(key_material), label, public_key, algorithm, + }))) +} + +pub(crate) fn build_totp( + title: String, issuer: Option, label: Option, + secret: Option, secret_stdin: bool, period: u32, digits: u8, algorithm: &str, +) -> Result { + use relicario_core::item_types::{TotpConfig, TotpCore, TotpKind}; + let secret_b32 = match secret { + Some(s) => s, + None => resolve_secret_line(secret_stdin, "TOTP secret (base32)")?, + }; + let secret_bytes = base32_decode_lenient(&secret_b32)?; + Ok(Item::new(title, ItemCore::Totp(TotpCore { + config: TotpConfig { + secret: Zeroizing::new(secret_bytes), algorithm: parse_totp_algorithm(algorithm)?, + digits, period_seconds: period, kind: TotpKind::Totp, + }, + issuer, label, + }))) +} + +pub(crate) fn build_document( + title: String, file: PathBuf, key: &Zeroizing<[u8; 32]>, max_bytes: u64, +) -> Result<(Item, EncryptedAttachment)> { + use relicario_core::item_types::DocumentCore; + use relicario_core::{encrypt_attachment, AttachmentRef}; + let bytes = std::fs::read(&file).with_context(|| format!("failed to read {}", file.display()))?; + let enc = encrypt_attachment(&bytes, key, max_bytes)?; + let filename = file.file_name() + .ok_or_else(|| anyhow::anyhow!("file path has no filename: {}", file.display()))? + .to_string_lossy().into_owned(); + let mime_type = crate::parse::guess_mime(&filename); + let primary_attachment = enc.id.clone(); + let mut item = Item::new(title, ItemCore::Document(DocumentCore { + filename: filename.clone(), mime_type: mime_type.clone(), primary_attachment: primary_attachment.clone(), + })); + item.attachments.push(AttachmentRef { + id: primary_attachment, filename, mime_type, size: bytes.len() as u64, created: item.created, + }); + Ok((item, enc)) +} +``` + +- [ ] **Step 2: Rewire `cmd_add`.** In `crates/relicario-cli/src/commands/add.rs`, delete the local `build_*_item` fns and replace `cmd_add`'s match so each arm resolves `title` (via `prompt_or_flag`), calls the shared builder, then sets `group`/`tags`/`favorite`. Document writes the encrypted blob to the personal path. Example arms: + +```rust +use crate::commands::item_build as ib; +use crate::prompt::{prompt_or_flag, prompt_or_flag_optional}; + +let item = match kind { + AddKind::Login { title, username, url, password_prompt, password, group, tags, favorite, totp_qr } => { + let title = prompt_or_flag(title, "Title", |s| Ok(s.to_string()))?; + let username = prompt_or_flag_optional(username, "Username", |s| Ok(s.to_string()))?; + let url = prompt_or_flag_optional(url, "URL", |s| Ok(s.to_string()))?; + let mut item = ib::build_login(title, username, url, password, /*password_stdin*/ false, password_prompt, totp_qr)?; + item.group = group; item.tags = tags; item.favorite = favorite; + item + } + AddKind::Card { title, holder, expiry, kind, group, tags } => { + let title = prompt_or_flag(title, "Title", |s| Ok(s.to_string()))?; + let mut item = ib::build_card(title, holder, expiry, &kind, false, false, false)?; + item.group = group; item.tags = tags; + item + } + AddKind::Document { title, file, group, tags } => { + let title = prompt_or_flag(title, "Title", |s| Ok(s.to_string()))?; + let caps = vault.load_settings()?.attachment_caps; + let (mut item, enc) = ib::build_document(title, file, vault.key(), caps.per_attachment_max_bytes)?; + item.group = group; item.tags = tags; + let att_dir = vault.root().join("attachments").join(item.id.as_str()); + std::fs::create_dir_all(&att_dir)?; + std::fs::write(att_dir.join(format!("{}.enc", enc.id.as_str())), &enc.bytes)?; + item + } + // SecureNote / Identity / Key / Totp arms follow the same shape, passing + // `false` for the new *_stdin booleans (A4 wires the real flags). + // ... +}; +``` + +> The `*_stdin` booleans are hard-wired `false` here; Task A4 adds the flags and threads the real values. This keeps A3 a pure, behavior-preserving refactor. + +- [ ] **Step 3: Run the failing build.** +Run: `cargo build -p relicario-cli` +Expected: compiles after all seven arms are ported. + +- [ ] **Step 4: Run personal add tests.** +Run: `cargo test -p relicario-cli --test basic_flows --test attachments` +Expected: PASS — personal add behavior unchanged. + +- [ ] **Step 5: Commit.** + +```bash +git add crates/relicario-cli/src/commands/item_build.rs crates/relicario-cli/src/commands/add.rs +git commit -m "refactor(cli): personal add delegates to shared item_build builders" +``` + +### Task A4: Add `--*-stdin` secret flags to the personal CLI + +**Files:** +- Modify: `crates/relicario-cli/src/main.rs` (`AddKind` variants + dispatch) +- Modify: `crates/relicario-cli/src/commands/add.rs` (thread the new booleans) +- Test: `crates/relicario-cli/tests/basic_flows.rs` (new stdin-driven case) + +- [ ] **Step 1: Write the failing test.** Append to `crates/relicario-cli/tests/basic_flows.rs` a test that adds a Card non-interactively via `--*-stdin` (model the secret-piping on the existing fixture; pipe `number\ncvv\npin\n`): + +```rust +#[test] +fn add_card_via_stdin_flags_is_non_interactive() { + let v = TestVault::new(); // existing personal-vault fixture in this file + let mut cmd = v.cmd(&["add", "card", "--title", "Visa", "--kind", "credit", + "--number-stdin", "--cvv-stdin", "--pin-stdin"]); + cmd.write_stdin("4111111111111111\n123\n4321\n"); + let out = cmd.output().unwrap(); + assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr)); + // The card lands and its number is masked without --show. + let got = v.run(&["get", "Visa"]); + assert!(String::from_utf8_lossy(&got.stdout).contains("********")); +} +``` + +> If the personal fixture in `basic_flows.rs` lacks a `write_stdin` helper, use `assert_cmd`'s `Command::write_stdin` on the `cargo_bin` command directly, mirroring `org_items.rs`'s `run` but with `Stdio::piped()` stdin. + +- [ ] **Step 2: Run to verify failure.** +Run: `cargo test -p relicario-cli --test basic_flows add_card_via_stdin_flags` +Expected: FAIL — `--number-stdin` is an unknown argument. + +- [ ] **Step 3: Add the flags.** In `crates/relicario-cli/src/main.rs`, extend the relevant `AddKind` variants with boolean stdin flags (clap `#[arg(long)]`, default false). Card example: + +```rust + Card { + #[arg(long)] title: Option, + #[arg(long)] holder: Option, + #[arg(long)] expiry: Option, + #[arg(long, default_value = "credit")] kind: String, + #[arg(long)] group: Option, + #[arg(long, value_delimiter = ',')] tags: Vec, + #[arg(long)] number_stdin: bool, + #[arg(long)] cvv_stdin: bool, + #[arg(long)] pin_stdin: bool, + }, +``` + +Add `password_stdin` to `Login`, `body_stdin` to `SecureNote`, `material_stdin` to `Key`, `secret_stdin` to `Totp`. (Document has no secret flag.) + +- [ ] **Step 4: Thread the booleans.** Update `cmd_add` arms to pass the real flag values instead of the hard-wired `false` from A3 (e.g. `ib::build_card(title, holder, expiry, &kind, number_stdin, cvv_stdin, pin_stdin)?`). Destructure the new fields in each `AddKind` arm. + +- [ ] **Step 5: Run to verify pass.** +Run: `cargo test -p relicario-cli --test basic_flows` +Expected: PASS (incl. the new case). + +- [ ] **Step 6: Update CLI architecture doc.** In `crates/relicario-cli/ARCHITECTURE.md`, document the new `commands/item_build.rs` module (shared builders + edit helpers) and the `--*-stdin` secret convention. Cite `item_build.rs` for the secret-resolution rule. + +- [ ] **Step 7: Commit.** + +```bash +git add crates/relicario-cli/src/main.rs crates/relicario-cli/src/commands/add.rs crates/relicario-cli/tests/basic_flows.rs crates/relicario-cli/ARCHITECTURE.md +git commit -m "feat(cli): --*-stdin secret flags for personal add (non-interactive secrets)" +``` + +--- + +# Dev-B — Org Card / Key / Totp parity + +**Consumes:** the Dev-A shared module (`crate::commands::item_build`). +**Produces:** the org per-type `add`/`edit` dispatch skeleton that Dev-C extends with Document. + +### Task B1: Extend `commands::org::OrgAddKind` + `run_add` for Card/Key/Totp + +**Files:** +- Modify: `crates/relicario-cli/src/commands/org.rs` (`OrgAddKind` enum + `build_org_item` → shared builders) + +**Interfaces produced:** + +```rust +// commands/org.rs +pub(crate) enum OrgAddKind { + Login { title: String, username: Option, url: Option, + password: Option, password_stdin: bool }, + SecureNote { title: String, body: Option, body_stdin: bool }, + Identity { title: String, full_name: Option, email: Option, phone: Option }, + Card { title: String, holder: Option, expiry: Option, kind: String, + number_stdin: bool, cvv_stdin: bool, pin_stdin: bool }, + Key { title: String, label: Option, algorithm: Option, + public_key: Option, material_stdin: bool }, + Totp { title: String, issuer: Option, label: Option, + secret: Option, secret_stdin: bool, period: u32, digits: u8, algorithm: String }, + // Document added by Dev-C. +} +fn build_org_item(kind: OrgAddKind) -> Result; // tags set by run_add caller +``` + +- [ ] **Step 1: Replace `build_org_item` to delegate to shared builders.** In `crates/relicario-cli/src/commands/org.rs`: + +```rust +use crate::commands::item_build as ib; + +fn build_org_item(kind: OrgAddKind) -> Result { + match kind { + OrgAddKind::Login { title, username, url, password, password_stdin } => + ib::build_login(title, username, url, password, password_stdin, false, None), + OrgAddKind::SecureNote { title, body, body_stdin } => + ib::build_secure_note(title, body, body_stdin), + OrgAddKind::Identity { title, full_name, email, phone } => + ib::build_identity(title, full_name, email, phone, None), + OrgAddKind::Card { title, holder, expiry, kind, number_stdin, cvv_stdin, pin_stdin } => + ib::build_card(title, holder, expiry, &kind, number_stdin, cvv_stdin, pin_stdin), + OrgAddKind::Key { title, label, algorithm, public_key, material_stdin } => + ib::build_key(title, label, algorithm, public_key, material_stdin), + OrgAddKind::Totp { title, issuer, label, secret, secret_stdin, period, digits, algorithm } => + ib::build_totp(title, issuer, label, secret, secret_stdin, period, digits, &algorithm), + } +} +``` + +- [ ] **Step 2: Update `run_add` to set tags after build.** `run_add`'s signature stays `run_add(dir, collection, kind: OrgAddKind, tags: Vec)`; after `let item = build_org_item(kind)?;` add `let mut item = item; item.tags = tags;` (or fold into the existing flow). The rest of `run_add` (grant check, `save_item`, manifest upsert, signed commit) is unchanged. + +- [ ] **Step 3: Build.** +Run: `cargo build -p relicario-cli` +Expected: compiles (main.rs dispatch updated in B2 — expect an error there until B2; build `commands/org.rs` in isolation by temporarily leaving the old main.rs mapping is not possible, so do B1+B2 together before building). Proceed to B2, then build. + +- [ ] **Step 4: Commit (with B2).** Combined commit at end of B2. + +### Task B2: Wire `main.rs` clap surface + dispatch for org Card/Key/Totp + +**Files:** +- Modify: `crates/relicario-cli/src/main.rs` (`OrgAddKind` clap enum + the `OrgCommands::Add` dispatch) + +- [ ] **Step 1: Extend the clap `OrgAddKind` enum.** Add Card/Key/Totp variants mirroring personal flags plus `--collection`, `--title` (required), and the `--*-stdin` flags: + +```rust + Card { + #[arg(long)] collection: String, + #[arg(long)] title: String, + #[arg(long)] holder: Option, + #[arg(long)] expiry: Option, + #[arg(long, default_value = "credit")] kind: String, + #[arg(long, value_delimiter = ',')] tags: Vec, + #[arg(long)] number_stdin: bool, + #[arg(long)] cvv_stdin: bool, + #[arg(long)] pin_stdin: bool, + }, + Key { + #[arg(long)] collection: String, + #[arg(long)] title: String, + #[arg(long)] label: Option, + #[arg(long)] algorithm: Option, + #[arg(long)] public_key: Option, + #[arg(long, value_delimiter = ',')] tags: Vec, + #[arg(long)] material_stdin: bool, + }, + Totp { + #[arg(long)] collection: String, + #[arg(long)] title: String, + #[arg(long)] issuer: Option, + #[arg(long)] label: Option, + #[arg(long)] secret: Option, + #[arg(long, default_value_t = 30)] period: u32, + #[arg(long, default_value_t = 6)] digits: u8, + #[arg(long, default_value = "sha1")] algorithm: String, + #[arg(long, value_delimiter = ',')] tags: Vec, + #[arg(long)] secret_stdin: bool, + }, +``` + +Also add `#[arg(long)] password_stdin: bool` to the existing clap `OrgAddKind::Login` and `#[arg(long)] body_stdin: bool` to `SecureNote` (and make `SecureNote.body` `Option` so it can be omitted for stdin/prompt). + +- [ ] **Step 2: Extend the dispatch mapping.** In `main.rs`'s `OrgCommands::Add` match, add arms mapping each clap variant to `commands::org::OrgAddKind`: + +```rust + OrgAddKind::Card { collection, title, holder, expiry, kind, tags, number_stdin, cvv_stdin, pin_stdin } => ( + collection, + commands::org::OrgAddKind::Card { title, holder, expiry, kind, number_stdin, cvv_stdin, pin_stdin }, + tags, + ), + OrgAddKind::Key { collection, title, label, algorithm, public_key, tags, material_stdin } => ( + collection, + commands::org::OrgAddKind::Key { title, label, algorithm, public_key, material_stdin }, + tags, + ), + OrgAddKind::Totp { collection, title, issuer, label, secret, period, digits, algorithm, tags, secret_stdin } => ( + collection, + commands::org::OrgAddKind::Totp { title, issuer, label, secret, secret_stdin, period, digits, algorithm }, + tags, + ), +``` + +Update the existing Login/SecureNote arms to pass the new `password_stdin`/`body_stdin` fields. + +- [ ] **Step 3: Build.** +Run: `cargo build -p relicario-cli` +Expected: compiles. + +- [ ] **Step 4: Commit B1+B2.** + +```bash +git add crates/relicario-cli/src/commands/org.rs crates/relicario-cli/src/main.rs +git commit -m "feat(cli/org): org add parity for Card/Key/Totp via shared builders" +``` + +### Task B3: Convert org `edit` to per-type interactive dispatch + +**Files:** +- Modify: `crates/relicario-cli/src/commands/org.rs` (`run_edit`) +- Modify: `crates/relicario-cli/src/main.rs` (`OrgCommands::Edit` args + dispatch) + +**Rationale:** match the personal `edit` (interactive, "blank to keep", field-history) by reusing the shared `edit_*` helpers. The flat `--username/--url/...` flags are dropped; non-interactive tests drive the prompts via piped stdin + `RELICARIO_TEST_ITEM_SECRET`. + +- [ ] **Step 1: Rewrite `run_edit`.** Replace the flat-flag signature with `run_edit(dir: &Path, query: &str, totp_qr: Option)`: + +```rust +pub fn run_edit(dir: &Path, query: &str, totp_qr: Option) -> Result<()> { + use relicario_core::time::now_unix; + use relicario_core::ItemCore; + use crate::commands::item_build as ib; + + 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)?; + eprintln!("Editing: {} ({}) — leave a prompt blank to keep the current value.", item.title, item.id.as_str()); + if let Some(v) = crate::prompt::prompt_keep("Title", &item.title)? { item.title = v; } + + let history = &mut item.field_history; + match &mut item.core { + ItemCore::Login(l) => ib::edit_login(l, history, totp_qr)?, + ItemCore::SecureNote(n) => ib::edit_secure_note(n, history)?, + ItemCore::Identity(i) => ib::edit_identity(i)?, + ItemCore::Card(c) => ib::edit_card(c, history)?, + ItemCore::Key(k) => ib::edit_key(k, history)?, + ItemCore::Document(_) => ib::edit_document_message(), + ItemCore::Totp(t) => ib::edit_totp(t, history)?, + } + + item.modified = now_unix(); + let item_rel = vault.save_item(&collection, &item)?; + let mut m = vault.load_manifest()?; + upsert_org_entry(&mut m, &item, &collection); + vault.save_manifest(&m)?; + let commit_msg = format!( + "org edit: {} ({})\n\nRelicario-Actor: {} {}\nRelicario-Action: item-edit\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 edit: git add")?; + crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "org edit: git commit")?; + println!("Updated {} ({}) in `{}`", item.title, item.id.as_str(), collection); + Ok(()) +} +``` + +- [ ] **Step 2: Update `OrgCommands::Edit` clap + dispatch.** In `main.rs`, change the `Edit` variant to `{ #[arg(long)] query: String (or positional), #[arg(long)] totp_qr: Option }` and the dispatch to `commands::org::run_edit(&d, &query, totp_qr)?`. Remove the dropped `--username/--url/--password/--body/--email/--phone/--full_name` fields. + +- [ ] **Step 3: Build.** +Run: `cargo build -p relicario-cli` +Expected: compiles. + +- [ ] **Step 4: Commit.** + +```bash +git add crates/relicario-cli/src/commands/org.rs crates/relicario-cli/src/main.rs +git commit -m "feat(cli/org): interactive per-type org edit via shared edit helpers" +``` + +### Task B4: Integration tests — org Card/Key/Totp round-trips + +**Files:** +- Modify: `crates/relicario-cli/tests/org_items.rs` (fixture stdin helper + new tests) + +- [ ] **Step 1: Add a stdin-piping run helper to `OrgFixture`.** + +```rust + /// Like `run`, but pipes `stdin_data` into the child's stdin (for --*-stdin + /// secrets and interactive edit prompts). + fn run_stdin(&self, args: &[&str], stdin_data: &str) -> std::process::Output { + use std::io::Write as _; + let mut child = Command::cargo_bin("relicario").unwrap() + .env("XDG_CONFIG_HOME", &self.xdg) + .env("RELICARIO_ORG_DIR", self.vault.path()) + .args(args) + .stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()) + .spawn().unwrap(); + child.stdin.as_mut().unwrap().write_all(stdin_data.as_bytes()).unwrap(); + child.wait_with_output().unwrap() + } +``` + +- [ ] **Step 2: Write the failing Card test.** Assumes the fixture already creates a collection `eng` and grants the owner (reuse the existing helper in this file that the Login/Identity tests use; if it is inline, replicate the `org create-collection` + `org grant` calls): + +```rust +#[test] +fn org_add_card_via_stdin_then_get_masks_secret() { + let f = OrgFixture::new(); + f.create_collection_and_grant("eng"); // existing helper used by the Login tests + let out = f.run_stdin( + &["org", "add", "card", "--collection", "eng", "--title", "Corp Visa", + "--kind", "credit", "--number-stdin", "--cvv-stdin", "--pin-stdin"], + "4111111111111111\n123\n4321\n", + ); + assert!(out.status.success(), "add card: {}", String::from_utf8_lossy(&out.stderr)); + + let got = f.run(&["org", "get", "Corp Visa"]); + let stdout = String::from_utf8_lossy(&got.stdout); + assert!(stdout.contains("Corp Visa")); + assert!(stdout.contains("********"), "card number must be masked without --show"); + assert!(!stdout.contains("4111111111111111"), "secret leaked without --show"); +} +``` + +- [ ] **Step 3: Run to verify failure.** +Run: `cargo test -p relicario-cli --test org_items org_add_card` +Expected: FAIL — `card` subcommand unknown (until B1/B2 merged). + +- [ ] **Step 4: Add Key + Totp + edit tests.** Key (material via stdin → `org get` shows `Label`, secret masked), Totp (`--secret` flag value → `org get` shows issuer/label), and an edit round-trip using `run_stdin` to drive the interactive prompts (e.g. change a Card holder: pipe `\nn\n` to keep title, answer holder, decline number change — match the exact prompt order of `edit_card`). + +```rust +#[test] +fn org_add_totp_with_secret_flag_round_trips() { + let f = OrgFixture::new(); + f.create_collection_and_grant("eng"); + let out = f.run(&["org", "add", "totp", "--collection", "eng", "--title", "AWS root", + "--issuer", "AWS", "--secret", "JBSWY3DPEHPK3PXP"]); + assert!(out.status.success(), "add totp: {}", String::from_utf8_lossy(&out.stderr)); + let got = f.run(&["org", "get", "AWS root"]); + assert!(String::from_utf8_lossy(&got.stdout).contains("Issuer: AWS")); +} +``` + +- [ ] **Step 5: Run all org tests.** +Run: `cargo test -p relicario-cli --test org_items` +Expected: PASS. + +- [ ] **Step 6: Commit.** + +```bash +git add crates/relicario-cli/tests/org_items.rs +git commit -m "test(cli/org): Card/Key/Totp add/get/edit round-trips via stdin" +``` + +--- + +# Dev-C — Org Document + attachment storage + +**Consumes:** Dev-A `build_document`; Dev-B's `OrgAddKind` + `run_add`/`run_edit` skeleton in `org.rs` (rebase on B). + +### Task C1: Org attachment storage in `org_session` + +**Files:** +- Modify: `crates/relicario-cli/src/org_session.rs` + +**Interfaces produced:** + +```rust +// org_session.rs +pub const DEFAULT_ORG_ATTACHMENT_MAX_BYTES: u64 = 25 * 1024 * 1024; // mirrors personal default; see note +impl UnlockedOrgVault { + pub fn attachment_path(&self, collection_slug: &str, item_id: &ItemId, att_id: &AttachmentId) -> PathBuf; + pub fn save_attachment(&self, collection_slug: &str, item_id: &ItemId, enc: &EncryptedAttachment) -> Result; + pub fn load_attachment(&self, collection_slug: &str, item_id: &ItemId, att_id: &AttachmentId) -> Result>>; + pub fn remove_item_attachments(&self, collection_slug: &str, item_id: &ItemId) -> Result<()>; +} +``` + +> **Cap value:** confirm `25 * 1024 * 1024` against the personal default at `crates/relicario-core/src/settings.rs` (`attachment_caps.per_attachment_max_bytes`). Use the personal default's exact value and cite its source line in a doc comment (code-constant-pinning rule). + +- [ ] **Step 1: Write the failing test.** Add to `org_session.rs` an inline `#[cfg(test)]` round-trip: + +```rust +#[cfg(test)] +mod attachment_tests { + use super::*; + use relicario_core::encrypt_attachment; + use tempfile::TempDir; + + #[test] + fn attachment_round_trip_collection_scoped() { + let dir = TempDir::new().unwrap(); + let vault = UnlockedOrgVault { root: dir.path().to_path_buf(), org_key: Zeroizing::new([7u8; 32]) }; + let item_id = ItemId::new(); + let enc = encrypt_attachment(b"hello world", &vault.org_key, DEFAULT_ORG_ATTACHMENT_MAX_BYTES).unwrap(); + let rel = vault.save_attachment("eng", &item_id, &enc).unwrap(); + assert_eq!(rel, format!("attachments/eng/{}/{}.enc", item_id.as_str(), enc.id.as_str())); + let got = vault.load_attachment("eng", &item_id, &enc.id).unwrap(); + assert_eq!(got.as_slice(), b"hello world"); + vault.remove_item_attachments("eng", &item_id).unwrap(); + assert!(vault.load_attachment("eng", &item_id, &enc.id).is_err()); + } +} +``` + +> Verify `ItemId::new()` exists (used throughout core); if the constructor differs, build an id the same way `Item::new` does. + +- [ ] **Step 2: Run to verify failure.** +Run: `cargo test -p relicario-cli --lib attachment_round_trip` +Expected: FAIL — methods/constant not found. + +- [ ] **Step 3: Implement.** Add to `org_session.rs` (imports: `AttachmentId`, `EncryptedAttachment`, `decrypt_attachment` from `relicario_core`): + +```rust +/// Default per-attachment cap for org vaults. Org has no settings.enc, so this +/// mirrors the personal-vault default at crates/relicario-core/src/settings.rs. +pub const DEFAULT_ORG_ATTACHMENT_MAX_BYTES: u64 = 25 * 1024 * 1024; + +impl UnlockedOrgVault { + pub fn attachment_path(&self, collection_slug: &str, item_id: &ItemId, att_id: &AttachmentId) -> PathBuf { + self.root.join("attachments").join(collection_slug) + .join(item_id.as_str()).join(format!("{}.enc", att_id.as_str())) + } + + pub fn save_attachment(&self, collection_slug: &str, item_id: &ItemId, enc: &EncryptedAttachment) -> Result { + let path = self.attachment_path(collection_slug, item_id, &enc.id); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?; + } + atomic_write(&path, &enc.bytes)?; + Ok(format!("attachments/{}/{}/{}.enc", collection_slug, item_id.as_str(), enc.id.as_str())) + } + + pub fn load_attachment(&self, collection_slug: &str, item_id: &ItemId, att_id: &AttachmentId) -> Result>> { + let path = self.attachment_path(collection_slug, item_id, att_id); + let bytes = fs::read(&path).with_context(|| format!("read attachment {}", path.display()))?; + Ok(relicario_core::decrypt_attachment(&bytes, &self.org_key)?) + } + + pub fn remove_item_attachments(&self, collection_slug: &str, item_id: &ItemId) -> Result<()> { + let dir = self.root.join("attachments").join(collection_slug).join(item_id.as_str()); + match fs::remove_dir_all(&dir) { + Ok(()) => Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(anyhow::Error::from(e).context(format!("remove {}", dir.display()))), + } + } +} +``` + +- [ ] **Step 4: Run to verify pass.** +Run: `cargo test -p relicario-cli --lib attachment_round_trip` +Expected: PASS. + +- [ ] **Step 5: Commit.** + +```bash +git add crates/relicario-cli/src/org_session.rs +git commit -m "feat(cli/org): collection-scoped attachment storage + default cap" +``` + +### Task C2: Org `add document` + commit the attachment path + +**Files:** +- Modify: `crates/relicario-cli/src/commands/org.rs` (`OrgAddKind::Document`, `run_add` document handling) +- Modify: `crates/relicario-cli/src/main.rs` (clap `OrgAddKind::Document` + dispatch) + +- [ ] **Step 1: Add the Document variant + builder call.** In `commands/org.rs`, add `Document { title: String, file: PathBuf }` to `OrgAddKind`. Because Document must write an attachment blob and stage its path, special-case it in `run_add` rather than `build_org_item`: + +```rust +pub fn run_add(dir: &Path, collection: &str, kind: OrgAddKind, tags: Vec) -> Result<()> { + use crate::org_session::{UnlockedOrgVault, DEFAULT_ORG_ATTACHMENT_MAX_BYTES}; + let vault = crate::org_session::open_org_vault(Some(dir))?; + let caller = vault.current_member()?; + let collections = vault.load_collections()?; + if !collections.contains_slug(collection) { + anyhow::bail!("collection `{collection}` does not exist — create it with `relicario org create-collection`"); + } + UnlockedOrgVault::ensure_grant(&caller, collection)?; + + // Build the item; Document also yields an encrypted attachment to persist. + let (mut item, attachment_rel): (relicario_core::Item, Option) = match kind { + OrgAddKind::Document { title, file } => { + let (item, enc) = crate::commands::item_build::build_document( + title, file, vault.key(), DEFAULT_ORG_ATTACHMENT_MAX_BYTES)?; + let rel = vault.save_attachment(collection, &item.id, &enc)?; + (item, Some(rel)) + } + other => (build_org_item(other)?, None), + }; + item.tags = tags; + + 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 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()); + + let mut add_args: Vec<&str> = vec!["add", &item_rel, "manifest.enc"]; + if let Some(ref rel) = attachment_rel { add_args.insert(1, rel); } + crate::org_session::org_git_run(&vault.root, &add_args, "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(()) +} +``` + +> Note: `build_org_item` loses its `tags` responsibility (Dev-B already moved tag-setting into `run_add`). Confirm the B1 version matches `fn build_org_item(kind) -> Result` with no tags. + +- [ ] **Step 2: Add clap `OrgAddKind::Document` + dispatch in `main.rs`:** + +```rust + Document { + #[arg(long)] collection: String, + #[arg(long)] title: String, + #[arg(long)] file: std::path::PathBuf, + #[arg(long, value_delimiter = ',')] tags: Vec, + }, +``` + +Dispatch arm: + +```rust + OrgAddKind::Document { collection, title, file, tags } => ( + collection, + commands::org::OrgAddKind::Document { title, file }, + tags, + ), +``` + +- [ ] **Step 3: Build.** +Run: `cargo build -p relicario-cli` +Expected: compiles. + +- [ ] **Step 4: Commit.** + +```bash +git add crates/relicario-cli/src/commands/org.rs crates/relicario-cli/src/main.rs +git commit -m "feat(cli/org): org add document with collection-scoped attachment" +``` + +### Task C3: Purge removes attachments + Document edit via `--file` + +**Files:** +- Modify: `crates/relicario-cli/src/commands/org.rs` (`run_purge`, Document edit) + +- [ ] **Step 1: Purge removes the attachment directory.** In `run_purge` (hard delete), after `vault.remove_item(&collection, &id)?` add `vault.remove_item_attachments(&collection, &id)?;` and stage the deletions in the commit (the attachment files are removed from the worktree; `git add -A`-style staging of the item + attachment paths). Match the existing `run_purge` commit-staging pattern; if it uses explicit paths, add the attachment dir. + +- [ ] **Step 2: Document edit via `--file`.** Since `edit_document_message()` tells personal users to use `attach`/`extract` (which org lacks), give org Document edit a real path: extend `run_edit` so a Document item with a provided `--file` replaces the primary attachment. Simplest: add an optional `file: Option` parameter to `run_edit`; in the `ItemCore::Document(d)` arm, if `file` is `Some`, build a new `EncryptedAttachment`, remove the old attachment dir, save the new blob, and update `d.filename`/`d.mime_type`/`d.primary_attachment` + `item.attachments`. If `file` is `None`, call `ib::edit_document_message()` (a no-op note). Wire `--file` into `OrgCommands::Edit` clap + dispatch. + +```rust + ItemCore::Document(d) => { + if let Some(path) = file { + let bytes = std::fs::read(&path).with_context(|| format!("read {}", path.display()))?; + let enc = relicario_core::encrypt_attachment(&bytes, vault.key(), + crate::org_session::DEFAULT_ORG_ATTACHMENT_MAX_BYTES)?; + vault.remove_item_attachments(&collection, &id)?; + let _rel = vault.save_attachment(&collection, &id, &enc)?; + let filename = path.file_name().unwrap().to_string_lossy().into_owned(); + d.mime_type = crate::parse::guess_mime(&filename); + d.primary_attachment = enc.id.clone(); + item.attachments = vec![relicario_core::AttachmentRef { + id: enc.id, filename: filename.clone(), mime_type: d.mime_type.clone(), + size: bytes.len() as u64, created: now_unix(), + }]; + d.filename = filename; + } else { + ib::edit_document_message(); + } + } +``` + +> When `--file` replaces the attachment, the commit must stage the new attachment path too — extend the `org_git_run "add"` arg list in `run_edit` to include the new attachment rel path (capture it from `save_attachment`). + +- [ ] **Step 3: Build.** +Run: `cargo build -p relicario-cli` +Expected: compiles. + +- [ ] **Step 4: Commit.** + +```bash +git add crates/relicario-cli/src/commands/org.rs crates/relicario-cli/src/main.rs +git commit -m "feat(cli/org): org document edit via --file + purge removes attachments" +``` + +### Task C4: Integration tests — org Document + docs + +**Files:** +- Modify: `crates/relicario-cli/tests/org_items.rs` +- Modify: `docs/FORMATS.md` + +- [ ] **Step 1: Write the failing Document test.** + +```rust +#[test] +fn org_add_document_stores_collection_scoped_attachment() { + let f = OrgFixture::new(); + f.create_collection_and_grant("eng"); + let src = f.vault.path().join("note.txt"); // any temp file + std::fs::write(&src, b"secret memo").unwrap(); + let out = f.run(&["org", "add", "document", "--collection", "eng", + "--title", "Q3 Memo", "--file", src.to_str().unwrap()]); + assert!(out.status.success(), "add doc: {}", String::from_utf8_lossy(&out.stderr)); + + // An encrypted attachment exists under attachments/eng//.enc. + let att_root = f.vault.path().join("attachments").join("eng"); + assert!(att_root.exists(), "attachment dir missing"); + let got = f.run(&["org", "get", "Q3 Memo"]); + let stdout = String::from_utf8_lossy(&got.stdout); + assert!(stdout.contains("Filename: note.txt")); +} +``` + +- [ ] **Step 2: Run to verify failure.** +Run: `cargo test -p relicario-cli --test org_items org_add_document` +Expected: FAIL — `document` subcommand unknown (until C2 merged). + +- [ ] **Step 3: Add a purge-removes-attachment test.** + +```rust +#[test] +fn org_purge_document_removes_attachment_dir() { + let f = OrgFixture::new(); + f.create_collection_and_grant("eng"); + let src = f.vault.path().join("d.bin"); + std::fs::write(&src, b"bytes").unwrap(); + f.run(&["org", "add", "document", "--collection", "eng", "--title", "Doc", "--file", src.to_str().unwrap()]); + f.run(&["org", "rm", "Doc"]); // trash + let out = f.run(&["org", "purge", "Doc"]); // hard delete + assert!(out.status.success(), "purge: {}", String::from_utf8_lossy(&out.stderr)); + let att_root = f.vault.path().join("attachments").join("eng"); + let empty = !att_root.exists() || std::fs::read_dir(&att_root).map(|mut d| d.next().is_none()).unwrap_or(true); + assert!(empty, "attachment dir should be gone after purge"); +} +``` + +- [ ] **Step 4: Run all org tests.** +Run: `cargo test -p relicario-cli --test org_items` +Expected: PASS. + +- [ ] **Step 5: Update FORMATS.md.** Add the org attachment layout (`attachments///.enc`) and the default cap constant, citing `crates/relicario-cli/src/org_session.rs` (`DEFAULT_ORG_ATTACHMENT_MAX_BYTES`). + +- [ ] **Step 6: Commit.** + +```bash +git add crates/relicario-cli/tests/org_items.rs docs/FORMATS.md +git commit -m "test(cli/org): org document storage round-trip + purge; FORMATS attachment layout" +``` + +--- + +# Dev-D — Server hook: grant-scope attachment paths + +**Independent — no dependency on A/B/C.** + +### Task D1: Extend `classify_path` for attachments + +**Files:** +- Modify: `crates/relicario-server/src/lib.rs` +- Modify: `crates/relicario-server/tests/org_hook.rs` +- Modify: `crates/relicario-server/Cargo.toml` (version bump) +- Modify: `docs/SECURITY.md` + +- [ ] **Step 1: Write the failing tests.** Append to `crates/relicario-server/tests/org_hook.rs`: + +```rust +#[test] +fn attachment_path_is_collection_scoped() { + assert_eq!( + classify_path("attachments/prod/a1b2c3d4e5f6a1b2/0011223344556677.enc"), + PathClass::Item { collection: "prod".to_string() } + ); +} + +#[test] +fn attachment_wrong_segment_count_is_rejected() { + assert_eq!( + classify_path("attachments/prod/onlytwo.enc"), + PathClass::Rejected("attachments path must be attachments///.enc".to_string()) + ); +} + +#[test] +fn attachment_empty_or_dotted_slug_is_rejected() { + assert!(matches!(classify_path("attachments//item/att.enc"), PathClass::Rejected(_))); + assert!(matches!(classify_path("attachments/../item/att.enc"), PathClass::Rejected(_))); +} +``` + +- [ ] **Step 2: Run to verify failure.** +Run: `cargo test -p relicario-server --test org_hook attachment_` +Expected: FAIL — attachments classify as `Unrestricted`. + +- [ ] **Step 3: Implement the attachments branch.** In `crates/relicario-server/src/lib.rs::classify_path`, before the final `PathClass::Unrestricted`, add: + +```rust + if let Some(rest) = path.strip_prefix("attachments/") { + // Expect exactly: //.enc → three segments. + let segments: Vec<&str> = rest.split('/').collect(); + if segments.len() != 3 { + return PathClass::Rejected( + "attachments path must be attachments///.enc".to_string()); + } + let slug = segments[0]; + if slug.is_empty() { + return PathClass::Rejected("empty collection slug in attachments path".to_string()); + } + if slug.contains('.') { + return PathClass::Rejected(format!("invalid collection slug: {:?}", slug)); + } + return PathClass::Item { collection: slug.to_string() }; + } +``` + +Update the `PathClass::Item` doc comment to mention both `items//.enc` and `attachments///.enc`. + +- [ ] **Step 4: Run to verify pass.** +Run: `cargo test -p relicario-server` +Expected: PASS (existing + 3 new tests). The hook's `main.rs` authorization loop already handles `PathClass::Item { collection }` (grant + slug-existence) — no `main.rs` change needed; attachment writes are now grant-scoped automatically. + +- [ ] **Step 5: Version bump + SECURITY doc.** Bump `version` in `crates/relicario-server/Cargo.toml` (patch bump). In `docs/SECURITY.md`, note that org attachment writes are now grant-scoped (previously `Unrestricted`), and that deploying this requires rebuilding the pre-receive hook on the server. + +- [ ] **Step 6: Commit.** + +```bash +git add crates/relicario-server/src/lib.rs crates/relicario-server/tests/org_hook.rs crates/relicario-server/Cargo.toml docs/SECURITY.md +git commit -m "feat(server): grant-scope org attachment write paths in pre-receive hook" +``` + +--- + +# Integration & release (after all four streams merge) + +- [ ] **Full test sweep.** Run `cargo test` (all crates) + `cargo build -p relicario-wasm --target wasm32-unknown-unknown`. Expected: green. +- [ ] **Type-check the extension.** `cd extension && npm run build:all` (the extension is unchanged, but verify nothing broke the workspace). Expected: clean. +- [ ] **STATUS.md / ROADMAP.md / CHANGELOG.md.** Mark org item-type parity (Card/Key/Document/Totp) + grant-scoped attachments landed; move them to shipped; note extension org *writes* (Plan B-2) still deferred. +- [ ] **Release.** Cut v0.8.1 via the release workflow (`Workflow({name:"release", args:{action:"release", release:"v0.8.1"}})`). The release notes MUST call out the **coordinated `relicario-server` redeploy** (rebuild the pre-receive hook) so attachment writes are grant-scoped in production. + +--- + +## Self-Review (plan author) + +**Spec coverage:** +- Shared builder module + `--*-stdin` (spec §Design.1, .2) → Dev-A (A1–A4). ✓ +- Org Card/Key/Totp add+edit parity (spec §Design.2, .3) → Dev-B (B1–B4). ✓ +- Org Document + attachment storage + cap (spec §Design.4) → Dev-C (C1–C4). ✓ +- Hook grant-scoping (spec §Design.5) → Dev-D (D1). ✓ +- Tests (spec §Design.6) → B4, C4, D1 + integration sweep. ✓ +- Living docs (spec §Design.7) → A4 (cli ARCHITECTURE), C4 (FORMATS), D1 (SECURITY), release task (STATUS/ROADMAP/CHANGELOG). ✓ +- Out-of-scope (extension writes, phase-2, configurable cap) → not planned. ✓ + +**Placeholder scan:** No "TBD"/"similar to Task N"/bare "add error handling". Concrete code in every code step. Two explicit verify-against-source notes (cap value vs `settings.rs`; `ItemId::new()` constructor) are flagged as verification steps, not placeholders. + +**Type consistency:** `OrgAddKind` (commands layer) used identically in B1/B2/C2; `build_document` returns `(Item, EncryptedAttachment)` consumed in A3 (personal) and C2 (org); `DEFAULT_ORG_ATTACHMENT_MAX_BYTES` defined in C1, used in C2/C3; `run_edit(dir, query, totp_qr[, file])` — note: C3 adds a `file` param to the B3 signature, so **C3 must update the B3 `run_edit` signature and its `main.rs` dispatch together** (called out in C3 Step 2).