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
1217 lines
59 KiB
Markdown
1217 lines
59 KiB
Markdown
# 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 (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).
|