Files
relicario/docs/superpowers/plans/2026-06-20-relicario-v0.8.1-parity.md
adlee-was-taken f27dc72e96 docs(plan): v0.8.1 org item-type parity — 4-stream multi-agent plan
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L5JvzEse4xUxLZKhofyeCD
2026-06-20 16:48:46 -04:00

1217 lines
59 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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/<slug>/<id>.enc`; attachments (new) `attachments/<slug>/<item-id>/<att-id>.enc`. The leading `<slug>` 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/<slug>/<item-id>/<att-id>.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<FieldId, Vec<FieldHistoryEntry>>;
// --- secret resolution ---
pub(crate) fn resolve_secret_line(from_stdin: bool, label: &str) -> Result<String>;
pub(crate) fn resolve_secret_multiline(from_stdin: bool, hint: &str) -> Result<String>;
// --- parsers ---
pub(crate) fn parse_card_kind(s: &str) -> Result<relicario_core::item_types::CardKind>;
pub(crate) fn parse_totp_algorithm(s: &str) -> Result<relicario_core::item_types::TotpAlgorithm>;
// --- builders (return Item with title + core only; caller sets group/tags/favorite) ---
pub(crate) fn build_login(title: String, username: Option<String>, url: Option<String>,
password: Option<String>, password_stdin: bool, password_prompt: bool,
totp_qr: Option<PathBuf>) -> Result<Item>;
pub(crate) fn build_secure_note(title: String, body: Option<String>, body_stdin: bool) -> Result<Item>;
pub(crate) fn build_identity(title: String, full_name: Option<String>, email: Option<String>,
phone: Option<String>, date_of_birth: Option<String>) -> Result<Item>;
pub(crate) fn build_card(title: String, holder: Option<String>, expiry: Option<String>,
kind: &str, number_stdin: bool, cvv_stdin: bool, pin_stdin: bool) -> Result<Item>;
pub(crate) fn build_key(title: String, label: Option<String>, algorithm: Option<String>,
public_key: Option<String>, material_stdin: bool) -> Result<Item>;
pub(crate) fn build_totp(title: String, issuer: Option<String>, label: Option<String>,
secret: Option<String>, secret_stdin: bool, period: u32, digits: u8, algorithm: &str) -> Result<Item>;
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<PathBuf>) -> 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<String>);
```
### 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<FieldId, Vec<FieldHistoryEntry>>;
/// 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<String> {
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<String> {
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<CardKind> {
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<TotpAlgorithm> {
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<String>, url: Option<String>,
password: Option<String>, password_stdin: bool, password_prompt: bool,
totp_qr: Option<PathBuf>,
) -> Result<Item> {
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<String>, body_stdin: bool) -> Result<Item> {
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<String>, email: Option<String>,
phone: Option<String>, date_of_birth: Option<String>,
) -> Result<Item> {
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<String>, expiry: Option<String>, kind: &str,
number_stdin: bool, cvv_stdin: bool, pin_stdin: bool,
) -> Result<Item> {
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<String>, algorithm: Option<String>,
public_key: Option<String>, material_stdin: bool,
) -> Result<Item> {
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<String>, label: Option<String>,
secret: Option<String>, secret_stdin: bool, period: u32, digits: u8, algorithm: &str,
) -> Result<Item> {
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<String>,
#[arg(long)] holder: Option<String>,
#[arg(long)] expiry: Option<String>,
#[arg(long, default_value = "credit")] kind: String,
#[arg(long)] group: Option<String>,
#[arg(long, value_delimiter = ',')] tags: Vec<String>,
#[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<String>, url: Option<String>,
password: Option<String>, password_stdin: bool },
SecureNote { title: String, body: Option<String>, body_stdin: bool },
Identity { title: String, full_name: Option<String>, email: Option<String>, phone: Option<String> },
Card { title: String, holder: Option<String>, expiry: Option<String>, kind: String,
number_stdin: bool, cvv_stdin: bool, pin_stdin: bool },
Key { title: String, label: Option<String>, algorithm: Option<String>,
public_key: Option<String>, material_stdin: bool },
Totp { title: String, issuer: Option<String>, label: Option<String>,
secret: Option<String>, secret_stdin: bool, period: u32, digits: u8, algorithm: String },
// Document added by Dev-C.
}
fn build_org_item(kind: OrgAddKind) -> Result<Item>; // 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<Item> {
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<String>)`; 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<String>,
#[arg(long)] expiry: Option<String>,
#[arg(long, default_value = "credit")] kind: String,
#[arg(long, value_delimiter = ',')] tags: Vec<String>,
#[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<String>,
#[arg(long)] algorithm: Option<String>,
#[arg(long)] public_key: Option<String>,
#[arg(long, value_delimiter = ',')] tags: Vec<String>,
#[arg(long)] material_stdin: bool,
},
Totp {
#[arg(long)] collection: String,
#[arg(long)] title: String,
#[arg(long)] issuer: Option<String>,
#[arg(long)] label: Option<String>,
#[arg(long)] secret: Option<String>,
#[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<String>,
#[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<String>` 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<PathBuf>)`:
```rust
pub fn run_edit(dir: &Path, query: &str, totp_qr: Option<std::path::PathBuf>) -> 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<PathBuf> }` 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<String>;
pub fn load_attachment(&self, collection_slug: &str, item_id: &ItemId, att_id: &AttachmentId) -> Result<Zeroizing<Vec<u8>>>;
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<String> {
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<Zeroizing<Vec<u8>>> {
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<String>) -> 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<String>) = 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<Item>` 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<String>,
},
```
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<PathBuf>` 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/<item>/<att>.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/<slug>/<item-id>/<att-id>.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/<slug>/<item-id>/<att-id>.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: <slug>/<item-id>/<att-id>.enc → three segments.
let segments: Vec<&str> = rest.split('/').collect();
if segments.len() != 3 {
return PathClass::Rejected(
"attachments path must be attachments/<slug>/<item-id>/<att-id>.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/<slug>/<id>.enc` and `attachments/<slug>/<item-id>/<att-id>.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 (A1A4). ✓
- Org Card/Key/Totp add+edit parity (spec §Design.2, .3) → Dev-B (B1B4). ✓
- Org Document + attachment storage + cap (spec §Design.4) → Dev-C (C1C4). ✓
- 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).