Compare commits

..

1 Commits

Author SHA1 Message Date
adlee-was-taken
c5b1917eb0 docs(spec): extension<->CLI parity gap analysis — matrix, gap classification, prioritized work list
Forward-planning survey (not v0.8.1 scope). Two independent sweeps (PM 3-agent +
Dev-D 4-reader workflow with adversarial critic), reconciled and hand-verified
against source at b09e0ce. Headline: core item-CRUD at full parity (extension
often ahead); genuine extension gaps cluster in metadata management (group/tag/
filter editing limited to specific forms, zero favorites UI), backend-exists-no-
wire items (remove_attachment, per-item purge, isInTab attachment gate), autofill
hostname matching, and the org vault (largest, already specced/deferred).
2026-06-20 21:00:43 -04:00
8 changed files with 285 additions and 678 deletions

View File

@@ -3,7 +3,7 @@
//! (`commands/org.rs`). Centralizing it keeps the two surfaces from drifting.
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::path::PathBuf;
use anyhow::{Context, Result};
use zeroize::Zeroizing;
@@ -255,39 +255,23 @@ pub(crate) fn build_totp(
})))
}
/// Read a file and encrypt it as an attachment under `key`, deriving its display
/// metadata. The plaintext is held in a `Zeroizing` buffer so it is wiped after
/// encryption. Returns the encrypted blob plus (filename, mime_type, size).
pub(crate) fn encrypt_document_file(
path: &Path,
key: &Zeroizing<[u8; 32]>,
max_bytes: u64,
) -> Result<(EncryptedAttachment, String, String, u64)> {
use relicario_core::encrypt_attachment;
let bytes = Zeroizing::new(
std::fs::read(path).with_context(|| format!("failed to read {}", path.display()))?,
);
let enc = encrypt_attachment(&bytes, key, max_bytes)?;
let filename = path
.file_name()
.ok_or_else(|| anyhow::anyhow!("file path has no filename: {}", path.display()))?
.to_string_lossy()
.into_owned();
let mime_type = crate::parse::guess_mime(&filename);
Ok((enc, filename, mime_type, bytes.len() as u64))
}
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::AttachmentRef;
let (enc, filename, mime_type, size) = encrypt_document_file(&file, key, max_bytes)?;
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: enc.id.clone(),
filename: filename.clone(), mime_type: mime_type.clone(), primary_attachment: primary_attachment.clone(),
}));
item.attachments.push(AttachmentRef {
id: enc.id.clone(), filename, mime_type, size, created: item.created,
id: primary_attachment, filename, mime_type, size: bytes.len() as u64, created: item.created,
});
Ok((item, enc))
}

View File

@@ -6,13 +6,12 @@ use std::path::Path;
use anyhow::{Context, Result};
use relicario_core::{
generate_org_key, wrap_org_key,
CollectionDef, Item, MemberId, OrgCollections, OrgManifest, OrgMembers, OrgMeta,
CollectionDef, Item, ItemCore, MemberId, OrgCollections, OrgManifest, OrgMembers, OrgMeta,
OrgRole, OrgMember,
encrypt_org_manifest,
};
use crate::org_session::atomic_write;
use crate::commands::item_build as ib;
pub fn run_init(dir: &Path, name: &str) -> Result<()> {
// Create directory structure
@@ -746,20 +745,17 @@ pub fn run_audit(
Ok(())
}
/// Item kinds `org add` supports. Secrets resolve via `--*-stdin` flags or an
/// interactive prompt inside the shared `item_build` builders.
/// Item kinds `org add` supports without interactive prompts.
pub 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,
body: String,
},
Identity {
title: String,
@@ -767,57 +763,43 @@ pub enum OrgAddKind {
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 { title: String, file: std::path::PathBuf },
}
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)
fn build_org_item(kind: OrgAddKind, tags: Vec<String>) -> Result<Item> {
use relicario_core::item_types::{IdentityCore, LoginCore, SecureNoteCore};
use zeroize::Zeroizing;
let mut item = match kind {
OrgAddKind::Login { title, username, url, password } => {
let parsed_url = match url {
Some(s) => Some(url::Url::parse(&s).with_context(|| format!("invalid URL: {s}"))?),
None => None,
};
let password = password.map(Zeroizing::new);
Item::new(title, ItemCore::Login(LoginCore {
username,
password,
url: parsed_url,
totp: None,
}))
}
OrgAddKind::SecureNote { title, body, body_stdin } => {
ib::build_secure_note(title, body, body_stdin)
OrgAddKind::SecureNote { title, body } => {
Item::new(title, ItemCore::SecureNote(SecureNoteCore {
body: Zeroizing::new(body),
}))
}
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)
}
OrgAddKind::Document { .. } => unreachable!("Document handled in run_add before build_org_item"),
Item::new(title, ItemCore::Identity(IdentityCore {
full_name,
address: None,
phone,
email,
date_of_birth: None,
}))
}
};
item.tags = tags;
Ok(item)
}
pub fn run_add(dir: &Path, collection: &str, kind: OrgAddKind, tags: Vec<String>) -> Result<()> {
@@ -834,17 +816,7 @@ pub fn run_add(dir: &Path, collection: &str, kind: OrgAddKind, tags: Vec<String>
// …and the caller must hold a grant for it.
UnlockedOrgVault::ensure_grant(&caller, collection)?;
// Build the item; Document additionally 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) = ib::build_document(
title, file, vault.key(), crate::org_session::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 = build_org_item(kind, tags)?;
let item_rel = vault.save_item(collection, &item)?;
// Upsert the manifest entry (collection slug stored plaintext inside the
@@ -865,11 +837,11 @@ pub fn run_add(dir: &Path, collection: &str, kind: OrgAddKind, tags: Vec<String>
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,
&["add", &item_rel, "manifest.enc"],
"org add: git add",
)?;
crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "org add: git commit")?;
println!("Added {} ({}) to `{}`", item.title, item.id.as_str(), collection);
@@ -1001,9 +973,21 @@ fn resolve_org_query<'a>(
}
}
pub fn run_edit(dir: &Path, query: &str, totp_qr: Option<std::path::PathBuf>, file: Option<std::path::PathBuf>) -> Result<()> {
pub fn run_edit(
dir: &Path,
query: &str,
title: Option<String>,
username: Option<String>,
url: Option<String>,
password: Option<String>,
body: Option<String>,
email: Option<String>,
phone: Option<String>,
full_name: Option<String>,
) -> Result<()> {
use relicario_core::time::now_unix;
use relicario_core::ItemCore;
use zeroize::Zeroizing;
let vault = crate::org_session::open_org_vault(Some(dir))?;
let caller = vault.current_member()?;
@@ -1015,56 +999,31 @@ pub fn run_edit(dir: &Path, query: &str, totp_qr: Option<std::path::PathBuf>, fi
crate::org_session::UnlockedOrgVault::ensure_grant(&caller, &collection)?;
let mut item = vault.load_item(&collection, &id)?;
if file.is_some() && !matches!(item.core, ItemCore::Document(_)) {
anyhow::bail!("--file is only valid when editing a Document item");
}
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;
let mut doc_attachment_rel: Option<String> = None;
let mut new_doc_attachments: Option<Vec<relicario_core::AttachmentRef>> = None;
if let Some(t) = title { item.title = t; }
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(d) => {
if let Some(path) = &file {
let (enc, filename, mime_type, size) = ib::encrypt_document_file(
path, vault.key(), crate::org_session::DEFAULT_ORG_ATTACHMENT_MAX_BYTES)?;
vault.remove_item_attachments(&collection, &id)?;
let rel = vault.save_attachment(&collection, &id, &enc)?;
d.filename = filename.clone();
d.mime_type = mime_type.clone();
d.primary_attachment = enc.id.clone();
new_doc_attachments = Some(vec![relicario_core::AttachmentRef {
id: enc.id,
filename,
mime_type,
size,
created: now_unix(),
}]);
doc_attachment_rel = Some(rel);
} else {
ib::edit_document_message();
ItemCore::Login(l) => {
if let Some(u) = username { l.username = Some(u); }
if let Some(u) = url {
l.url = Some(url::Url::parse(&u).with_context(|| format!("invalid URL: {u}"))?);
}
if let Some(p) = password { l.password = Some(Zeroizing::new(p)); }
}
ItemCore::Totp(t) => ib::edit_totp(t, history)?,
ItemCore::SecureNote(n) => {
if let Some(b) = body { n.body = Zeroizing::new(b); }
}
if let Some(atts) = new_doc_attachments {
item.attachments = atts;
ItemCore::Identity(i) => {
if let Some(v) = full_name { i.full_name = Some(v); }
if let Some(v) = email { i.email = Some(v); }
if let Some(v) = phone { i.phone = Some(v); }
}
_ => anyhow::bail!("org edit currently supports login, secure-note, and identity items"),
}
item.modified = now_unix();
let item_rel = vault.save_item(&collection, &item)?;
let mut manifest = vault.load_manifest()?;
upsert_org_entry(&mut manifest, &item, &collection);
vault.save_manifest(&manifest)?;
@@ -1076,20 +1035,12 @@ pub fn run_edit(dir: &Path, query: &str, totp_qr: Option<std::path::PathBuf>, fi
);
let commit_msg = format!(
"{subject}\n\nRelicario-Actor: {} {}\nRelicario-Action: item-update\nRelicario-Collection: {}\nRelicario-Item: {}",
caller.display_name,
caller.member_id.as_str(),
collection,
item.id.as_str()
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"];
let att_dir_rel;
if doc_attachment_rel.is_some() {
att_dir_rel = format!("attachments/{}/{}", collection, id.as_str());
add_args.push(&att_dir_rel);
}
crate::org_session::org_git_run(&vault.root, &add_args, "org edit: git add")?;
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);
println!("Updated {}", item.id.as_str());
Ok(())
}
@@ -1163,14 +1114,12 @@ pub fn run_purge(dir: &Path, query: &str) -> Result<()> {
// Remove the blob from disk, drop the manifest entry, stage with git rm.
vault.remove_item(&collection, &id)?;
vault.remove_item_attachments(&collection, &id)?;
let mut manifest = vault.load_manifest()?;
manifest.entries.retain(|e| e.id != id);
vault.save_manifest(&manifest)?;
let item_rel = format!("items/{}/{}.enc", collection, id.as_str());
let att_dir_rel = format!("attachments/{}/{}", collection, id.as_str());
crate::helpers::git_rm(&vault.root, &[item_rel, att_dir_rel], "org purge: git rm")?;
crate::helpers::git_rm(&vault.root, &[item_rel], "org purge: git rm")?;
crate::org_session::org_git_run(&vault.root, &["add", "manifest.enc"], "org purge: git add manifest")?;
let commit_msg = format!(

View File

@@ -535,14 +535,18 @@ pub(crate) enum OrgCommands {
List {
#[arg(long)] trashed: bool,
},
/// Edit an org item interactively (per-type prompts; blank keeps current).
/// Edit an org item's fields (flag-driven; blank flags keep current values).
Edit {
/// Item id or case-insensitive title substring.
query: String,
/// Replace the login TOTP secret from a QR image.
#[arg(long)] totp_qr: Option<std::path::PathBuf>,
/// Replace a Document item's attachment file.
#[arg(long)] file: Option<std::path::PathBuf>,
#[arg(long)] title: Option<String>,
#[arg(long)] username: Option<String>,
#[arg(long)] url: Option<String>,
#[arg(long)] password: Option<String>,
#[arg(long)] body: Option<String>,
#[arg(long)] email: Option<String>,
#[arg(long)] phone: Option<String>,
#[arg(long)] full_name: Option<String>,
},
/// Soft-delete an org item (reversible via `org restore`).
Rm { query: String },
@@ -562,15 +566,13 @@ pub(crate) enum OrgAddKind {
#[arg(long)] url: Option<String>,
#[arg(long)] password: Option<String>,
#[arg(long, value_delimiter = ',')] tags: Vec<String>,
#[arg(long)] password_stdin: bool,
},
/// A secure note.
SecureNote {
#[arg(long)] collection: String,
#[arg(long)] title: String,
#[arg(long)] body: Option<String>,
#[arg(long)] body: String,
#[arg(long, value_delimiter = ',')] tags: Vec<String>,
#[arg(long)] body_stdin: bool,
},
/// An identity record.
Identity {
@@ -581,48 +583,6 @@ pub(crate) enum OrgAddKind {
#[arg(long)] phone: Option<String>,
#[arg(long, value_delimiter = ',')] tags: Vec<String>,
},
/// A payment card (number / cvv / pin entered via --*-stdin or prompt).
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,
},
/// A key / credential blob (material entered via --material-stdin or prompt).
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,
},
/// A TOTP authenticator (base32 secret via --secret or --secret-stdin).
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,
},
/// A document (file payload encrypted into a collection-scoped attachment).
Document {
#[arg(long)] collection: String,
#[arg(long)] title: String,
#[arg(long)] file: std::path::PathBuf,
#[arg(long, value_delimiter = ',')] tags: Vec<String>,
},
}
fn main() -> Result<()> {
@@ -716,14 +676,14 @@ fn main() -> Result<()> {
OrgCommands::Add { kind } => {
let d = crate::org_session::org_dir(dir_path)?;
let (collection, add_kind, tags) = match kind {
OrgAddKind::Login { collection, title, username, url, password, tags, password_stdin } => (
OrgAddKind::Login { collection, title, username, url, password, tags } => (
collection,
commands::org::OrgAddKind::Login { title, username, url, password, password_stdin },
commands::org::OrgAddKind::Login { title, username, url, password },
tags,
),
OrgAddKind::SecureNote { collection, title, body, tags, body_stdin } => (
OrgAddKind::SecureNote { collection, title, body, tags } => (
collection,
commands::org::OrgAddKind::SecureNote { title, body, body_stdin },
commands::org::OrgAddKind::SecureNote { title, body },
tags,
),
OrgAddKind::Identity { collection, title, full_name, email, phone, tags } => (
@@ -731,26 +691,6 @@ fn main() -> Result<()> {
commands::org::OrgAddKind::Identity { title, full_name, email, phone },
tags,
),
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,
),
OrgAddKind::Document { collection, title, file, tags } => (
collection,
commands::org::OrgAddKind::Document { title, file },
tags,
),
};
commands::org::run_add(&d, &collection, add_kind, tags)?;
}
@@ -762,9 +702,9 @@ fn main() -> Result<()> {
let d = crate::org_session::org_dir(dir_path)?;
commands::org::run_list(&d, trashed)?;
}
OrgCommands::Edit { query, totp_qr, file } => {
OrgCommands::Edit { query, title, username, url, password, body, email, phone, full_name } => {
let d = crate::org_session::org_dir(dir_path)?;
commands::org::run_edit(&d, &query, totp_qr, file)?;
commands::org::run_edit(&d, &query, title, username, url, password, body, email, phone, full_name)?;
}
OrgCommands::Rm { query } => {
let d = crate::org_session::org_dir(dir_path)?;

View File

@@ -9,16 +9,9 @@ use zeroize::Zeroizing;
use relicario_core::{
decrypt_item, decrypt_org_manifest, encrypt_item, encrypt_org_manifest,
AttachmentId, EncryptedAttachment, Item, ItemId, MemberId, OrgCollections, OrgManifest,
OrgMember, OrgMembers, OrgMeta,
Item, ItemId, MemberId, OrgCollections, OrgManifest, OrgMember, OrgMembers, OrgMeta,
};
/// Default per-attachment cap for org vaults. Org vaults have no settings.enc,
/// so this mirrors the personal-vault default
/// `AttachmentCaps::per_attachment_max_bytes` at
/// crates/relicario-core/src/settings.rs:116.
pub const DEFAULT_ORG_ATTACHMENT_MAX_BYTES: u64 = 10 * 1024 * 1024;
pub struct UnlockedOrgVault {
pub root: PathBuf,
pub org_key: Zeroizing<[u8; 32]>,
@@ -122,40 +115,6 @@ 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()))
}
/// Encrypt-already-done blob: persist it and return the repo-relative path for git staging.
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()))
}
// Retained for a future `org document read/extract` command (mirrors `org_meta_path` convention).
#[allow(dead_code)]
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)?)
}
/// Remove an item's whole attachment directory. Missing dir is NOT an error
/// (mirrors `remove_item`'s NotFound-tolerant behavior, for partial-write recovery).
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()))),
}
}
/// Bail unless `member` has `slug` in their collection grants. The slug
/// existence check is done separately by the caller against collections.json.
pub fn ensure_grant(member: &OrgMember, slug: &str) -> Result<()> {
@@ -333,22 +292,6 @@ mod tests {
assert_eq!(loaded.entries.len(), 1);
}
#[test]
fn attachment_round_trip_collection_scoped() {
use relicario_core::encrypt_attachment;
let key = Zeroizing::new([7u8; 32]);
let (dir, vault) = make_vault(key);
let _ = dir; // keep tempdir alive
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());
}
#[test]
fn save_and_load_members() {
let key = Zeroizing::new([0u8; 32]);

View File

@@ -152,9 +152,7 @@ fn org_get_edit_rm_restore_purge_reject_ungranted_member() {
);
for (label, args) in [
// `org edit` is now interactive (no flat flags); the ungranted member is
// rejected at manifest lookup, before any prompt is read.
("edit", vec!["org", "edit", "GitHub"]),
("edit", vec!["org", "edit", "GitHub", "--username", "evil"]),
("rm", vec!["org", "rm", "GitHub"]),
("restore", vec!["org", "restore", "GitHub"]),
("purge", vec!["org", "purge", "GitHub"]),
@@ -172,12 +170,13 @@ fn org_get_edit_rm_restore_purge_reject_ungranted_member() {
}
// The item is untouched: the owner can still read the original password and
// username the ungranted member's get/edit/rm/restore/purge were all denied.
// the username was NOT changed to the ungranted member's "evil" attempt.
let owner_get = owner_dev.run(vault, &["org", "get", "GitHub", "--show"]);
let owner_out = String::from_utf8_lossy(&owner_get.stdout).to_string();
assert!(owner_get.status.success(), "owner should still read the item");
assert!(owner_out.contains("hunter2"), "owner read must still show original password: {owner_out}");
assert!(owner_out.contains("alice"), "ungranted member must not have modified the item: {owner_out}");
assert!(owner_out.contains("alice"), "edit by ungranted member must not have changed username: {owner_out}");
assert!(!owner_out.contains("evil"), "ungranted edit leaked through: {owner_out}");
}
#[test]

View File

@@ -67,39 +67,6 @@ impl OrgFixture {
let v: serde_json::Value = serde_json::from_str(&s).unwrap();
v["members"][0]["member_id"].as_str().unwrap().to_string()
}
/// Like `run`, but pipes `stdin_data` into the child's stdin — used to drive
/// `--*-stdin` secret flags and the interactive edit prompts. `wait_with_output`
/// closes stdin for us, so multiline secrets (read-to-EOF) terminate cleanly.
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()
}
/// Create collection `slug` and grant the owner access to it — the common
/// setup the item-type round-trip tests share.
fn create_collection_and_grant(&self, slug: &str) {
let owner = self.owner_member_id();
assert!(
self.run(&["org", "create-collection", slug, "--name", slug]).status.success(),
"create-collection {slug} failed",
);
assert!(
self.run(&["org", "grant", &owner, slug]).status.success(),
"grant {slug} failed",
);
}
}
#[test]
@@ -184,17 +151,21 @@ fn org_add_rejects_unknown_collection() {
#[test]
fn org_edit_updates_fields_and_commits_update_trailer() {
let f = OrgFixture::new();
f.create_collection_and_grant("prod");
let owner = f.owner_member_id();
assert!(f.run(&["org", "create-collection", "prod", "--name", "Production"]).status.success());
assert!(f.run(&["org", "grant", &owner, "prod"]).status.success());
assert!(f.run(&[
"org", "add", "login", "--collection", "prod",
"--title", "Mail", "--username", "old", "--password", "pw",
]).status.success());
// org edit is now interactive per-type: keep title, set username=new-user,
// keep URL, decline password change.
let out = f.run_stdin(&["org", "edit", "Mail"], "\nnew-user\n\nn\n");
// Edit the username.
let out = f.run(&[
"org", "edit", "Mail", "--username", "new-user",
]);
assert!(out.status.success(), "org edit: {}", String::from_utf8_lossy(&out.stderr));
// get --show reflects the new username.
let out = f.run(&["org", "get", "Mail", "--show"]);
let stdout = String::from_utf8_lossy(&out.stdout).to_string();
assert!(stdout.contains("new-user"), "edit did not take: {stdout}");
@@ -244,344 +215,3 @@ fn org_rm_restore_purge_cycle() {
let body = String::from_utf8_lossy(&log.stdout).to_string();
assert!(body.contains("Relicario-Action: item-purge"), "missing purge trailer: {body}");
}
// --- v0.8.1 org item-type parity: Card / Key / Totp -------------------------
// These drive the new `org add <card|key|totp>` subcommands. Secrets enter via
// `--*-stdin` (read from piped stdin) or, for Totp, the `--secret` flag. `org get`
// must mask every secret unless `--show` is passed — asserted below.
#[test]
fn org_add_card_via_stdin_then_get_masks_secret() {
let f = OrgFixture::new();
f.create_collection_and_grant("eng");
// build_card reads number, then cvv, then pin — one line each, in that order.
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));
// get masks the card number by default.
let got = f.run(&["org", "get", "Corp Visa"]);
let stdout = String::from_utf8_lossy(&got.stdout).to_string();
assert!(stdout.contains("Corp Visa"), "title missing: {stdout}");
assert!(stdout.contains("********"), "card number must be masked without --show: {stdout}");
assert!(!stdout.contains("4111111111111111"), "secret leaked without --show: {stdout}");
// --show reveals it.
let shown = f.run(&["org", "get", "Corp Visa", "--show"]);
let shown = String::from_utf8_lossy(&shown.stdout).to_string();
assert!(shown.contains("4111111111111111"), "number not revealed with --show: {shown}");
}
#[test]
fn org_add_key_via_stdin_then_get_masks_material() {
let f = OrgFixture::new();
f.create_collection_and_grant("eng");
// build_key reads key material from stdin to EOF (multiline secret).
let out = f.run_stdin(
&[
"org", "add", "key", "--collection", "eng", "--title", "Deploy Key",
"--label", "ci", "--algorithm", "ed25519", "--material-stdin",
],
"-----BEGIN OPENSSH PRIVATE KEY-----\nAAAAsecretmaterial\n-----END OPENSSH PRIVATE KEY-----\n",
);
assert!(out.status.success(), "add key: {}", String::from_utf8_lossy(&out.stderr));
let got = f.run(&["org", "get", "Deploy Key"]);
let stdout = String::from_utf8_lossy(&got.stdout).to_string();
assert!(stdout.contains("Label: ci"), "label missing: {stdout}");
assert!(stdout.contains("********"), "key material must be masked without --show: {stdout}");
assert!(!stdout.contains("secretmaterial"), "key material leaked without --show: {stdout}");
}
#[test]
fn org_add_totp_with_secret_flag_round_trips() {
let f = OrgFixture::new();
f.create_collection_and_grant("eng");
// Totp accepts the base32 secret via --secret (no stdin needed).
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"]);
let stdout = String::from_utf8_lossy(&got.stdout).to_string();
assert!(stdout.contains("AWS root"), "title missing: {stdout}");
assert!(stdout.contains("Issuer: AWS"), "issuer missing: {stdout}");
}
#[test]
fn org_edit_card_interactive_changes_holder() {
let f = OrgFixture::new();
f.create_collection_and_grant("eng");
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));
// Interactive edit: keep title, set holder, decline number change.
let out = f.run_stdin(&["org", "edit", "Corp Visa"], "\nJane Q. Public\nn\n");
assert!(out.status.success(), "org edit card: {}", String::from_utf8_lossy(&out.stderr));
let got = f.run(&["org", "get", "Corp Visa"]);
let stdout = String::from_utf8_lossy(&got.stdout).to_string();
assert!(stdout.contains("Holder: Jane Q. Public"), "holder edit did not take: {stdout}");
assert!(stdout.contains("********"), "number must stay masked after declining change: {stdout}");
assert!(!stdout.contains("4111111111111111"), "number leaked without --show: {stdout}");
}
#[test]
fn org_edit_totp_interactive_changes_issuer() {
let f = OrgFixture::new();
f.create_collection_and_grant("eng");
assert!(f.run(&[
"org", "add", "totp", "--collection", "eng", "--title", "AWS root",
"--issuer", "AWS", "--secret", "JBSWY3DPEHPK3PXP",
]).status.success());
// Interactive edit: keep title, set issuer=GitHub, keep label, decline secret change.
let out = f.run_stdin(&["org", "edit", "AWS root"], "\nGitHub\n\nn\n");
assert!(out.status.success(), "org edit totp: {}", String::from_utf8_lossy(&out.stderr));
let got = f.run(&["org", "get", "AWS root"]);
assert!(String::from_utf8_lossy(&got.stdout).contains("Issuer: GitHub"), "issuer edit did not take");
}
// --- grant enforcement + remaining --*-stdin paths for the new types ---------
#[test]
fn org_add_card_key_totp_reject_ungranted_and_unknown_collection() {
let f = OrgFixture::new();
// `secret` exists but is NOT granted to the owner.
assert!(f.run(&["org", "create-collection", "secret", "--name", "secret"]).status.success());
// ensure_grant runs before any secret prompt in run_add, so these need no
// stdin — each new type must be rejected for a collection it lacks a grant for.
for args in [
vec!["org", "add", "card", "--collection", "secret", "--title", "X", "--kind", "credit"],
vec!["org", "add", "key", "--collection", "secret", "--title", "X"],
vec!["org", "add", "totp", "--collection", "secret", "--title", "X", "--secret", "JBSWY3DPEHPK3PXP"],
] {
let out = f.run(&args);
assert!(!out.status.success(), "ungranted add must fail: {args:?}");
let err = String::from_utf8_lossy(&out.stderr).to_string();
assert!(err.contains("access denied") || err.contains("grant"),
"expected grant denial for {args:?}: {err}");
}
// …and rejected for a nonexistent collection.
for args in [
vec!["org", "add", "card", "--collection", "ghost", "--title", "X", "--kind", "credit"],
vec!["org", "add", "key", "--collection", "ghost", "--title", "X"],
vec!["org", "add", "totp", "--collection", "ghost", "--title", "X", "--secret", "JBSWY3DPEHPK3PXP"],
] {
let out = f.run(&args);
assert!(!out.status.success(), "unknown-collection add must fail: {args:?}");
let err = String::from_utf8_lossy(&out.stderr).to_string();
assert!(err.contains("does not exist") || err.contains("ghost"),
"expected unknown-collection error for {args:?}: {err}");
}
}
#[test]
fn org_add_secure_note_via_body_stdin_masks_body() {
let f = OrgFixture::new();
f.create_collection_and_grant("eng");
// build_secure_note(body_stdin=true) reads the body from stdin to EOF.
let out = f.run_stdin(
&["org", "add", "secure-note", "--collection", "eng", "--title", "Runbook", "--body-stdin"],
"line one\nsuper-secret-line\n",
);
assert!(out.status.success(), "add note: {}", String::from_utf8_lossy(&out.stderr));
let got = f.run(&["org", "get", "Runbook"]);
let stdout = String::from_utf8_lossy(&got.stdout).to_string();
assert!(stdout.contains("********"), "note body must be masked without --show: {stdout}");
assert!(!stdout.contains("super-secret-line"), "note body leaked without --show: {stdout}");
let shown = f.run(&["org", "get", "Runbook", "--show"]);
assert!(String::from_utf8_lossy(&shown.stdout).contains("super-secret-line"), "body not revealed with --show");
}
#[test]
fn org_add_totp_via_secret_stdin_round_trips() {
let f = OrgFixture::new();
f.create_collection_and_grant("eng");
// build_totp(secret_stdin=true) reads one base32 line from stdin.
let out = f.run_stdin(
&["org", "add", "totp", "--collection", "eng", "--title", "VPN", "--issuer", "Corp", "--secret-stdin"],
"JBSWY3DPEHPK3PXP\n",
);
assert!(out.status.success(), "add totp: {}", String::from_utf8_lossy(&out.stderr));
let got = f.run(&["org", "get", "VPN"]);
assert!(String::from_utf8_lossy(&got.stdout).contains("Issuer: Corp"), "issuer missing");
}
#[test]
fn org_edit_key_replaces_material_and_reveals_with_show() {
let f = OrgFixture::new();
f.create_collection_and_grant("eng");
let out = f.run_stdin(
&["org", "add", "key", "--collection", "eng", "--title", "Signing Key",
"--label", "ci", "--material-stdin"],
"OLD-MATERIAL-aaaa\n",
);
assert!(out.status.success(), "add key: {}", String::from_utf8_lossy(&out.stderr));
// Interactive edit: keep title, ACCEPT "Replace key material?" -> new material
// read from stdin to EOF (edit_key). Exercises the accept branch + history push.
let out = f.run_stdin(&["org", "edit", "Signing Key"], "\ny\nNEW-MATERIAL-bbbb\n");
assert!(out.status.success(), "org edit key: {}", String::from_utf8_lossy(&out.stderr));
let masked = f.run(&["org", "get", "Signing Key"]);
let masked = String::from_utf8_lossy(&masked.stdout).to_string();
assert!(masked.contains("********"), "material must be masked without --show: {masked}");
assert!(!masked.contains("NEW-MATERIAL"), "material leaked without --show: {masked}");
let shown = f.run(&["org", "get", "Signing Key", "--show"]);
let shown = String::from_utf8_lossy(&shown.stdout).to_string();
assert!(shown.contains("NEW-MATERIAL-bbbb"), "replaced material not revealed with --show: {shown}");
assert!(!shown.contains("OLD-MATERIAL"), "old material still present after replace: {shown}");
}
// --- v0.8.1 org Document tests -----------------------------------------------
/// `git status --porcelain` output for the org repo (trimmed). Empty-of-`attachments/`
/// proves every attachment add/remove was staged into the signed commit.
fn git_porcelain(repo: &str) -> String {
let out = std::process::Command::new("git")
.args(["-C", repo, "status", "--porcelain"])
.output()
.unwrap();
String::from_utf8_lossy(&out.stdout).trim().to_string()
}
#[test]
fn org_add_document_stores_collection_scoped_attachment() {
let f = OrgFixture::new();
f.create_collection_and_grant("eng");
let srcdir = TempDir::new().unwrap();
let src = srcdir.path().join("note.txt");
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));
// Encrypted blob at attachments/eng/<item-id>/<att-id>.enc (3 segments).
let att_eng = f.vault_path().join("attachments").join("eng");
assert!(att_eng.exists(), "attachment dir missing");
let item_dirs: Vec<_> = std::fs::read_dir(&att_eng).unwrap().map(|e| e.unwrap().path()).collect();
assert_eq!(item_dirs.len(), 1, "expected exactly one item attachment dir");
let blobs: Vec<_> = std::fs::read_dir(&item_dirs[0]).unwrap().map(|e| e.unwrap().path()).collect();
assert_eq!(blobs.len(), 1, "expected exactly one attachment blob");
assert_eq!(blobs[0].extension().and_then(|e| e.to_str()), Some("enc"), "blob must be .enc");
let got = f.run(&["org", "get", "Q3 Memo"]);
let stdout = String::from_utf8_lossy(&got.stdout);
assert!(stdout.contains("Filename: note.txt"), "get missing filename: {stdout}");
// Staging proof: nothing attachment-related left uncommitted.
assert!(!git_porcelain(f.vault_str()).contains("attachments/"), "unstaged attachment after add");
}
#[test]
fn org_purge_document_removes_attachment_dir() {
let f = OrgFixture::new();
f.create_collection_and_grant("eng");
let srcdir = TempDir::new().unwrap();
let src = srcdir.path().join("d.bin");
std::fs::write(&src, b"bytes").unwrap();
assert!(f.run(&["org", "add", "document", "--collection", "eng",
"--title", "Doc", "--file", src.to_str().unwrap()]).status.success());
let att_eng = f.vault_path().join("attachments").join("eng");
assert!(std::fs::read_dir(&att_eng).unwrap().next().is_some(), "attachment must exist after add");
assert!(f.run(&["org", "rm", "Doc"]).status.success(), "rm");
let out = f.run(&["org", "purge", "Doc"]);
assert!(out.status.success(), "purge: {}", String::from_utf8_lossy(&out.stderr));
let empty = !att_eng.exists() || std::fs::read_dir(&att_eng).unwrap().next().is_none();
assert!(empty, "attachment dir should be gone after purge");
assert!(!git_porcelain(f.vault_str()).contains("attachments/"), "unstaged attachment removal after purge");
}
#[test]
fn org_edit_document_replaces_attachment_and_stages_cleanly() {
let f = OrgFixture::new();
f.create_collection_and_grant("eng");
let srcdir = TempDir::new().unwrap();
let a = srcdir.path().join("a.txt");
std::fs::write(&a, b"version A").unwrap();
assert!(f.run(&["org", "add", "document", "--collection", "eng",
"--title", "Spec", "--file", a.to_str().unwrap()]).status.success());
let b = srcdir.path().join("b.md");
std::fs::write(&b, b"version B has different content").unwrap();
let out = f.run(&["org", "edit", "Spec", "--file", b.to_str().unwrap()]);
assert!(out.status.success(), "edit --file: {}", String::from_utf8_lossy(&out.stderr));
let got = String::from_utf8_lossy(&f.run(&["org", "get", "Spec"]).stdout).to_string();
assert!(got.contains("Filename: b.md"), "get should show new filename: {got}");
assert!(!got.contains("a.txt"), "old filename should be gone: {got}");
// Old blob replaced, not accumulated: exactly one blob remains.
let att_eng = f.vault_path().join("attachments").join("eng");
let item_dirs: Vec<_> = std::fs::read_dir(&att_eng).unwrap().map(|e| e.unwrap().path()).collect();
assert_eq!(item_dirs.len(), 1, "one item attachment dir");
let blobs = std::fs::read_dir(&item_dirs[0]).unwrap().count();
assert_eq!(blobs, 1, "old blob must be replaced, not accumulated");
// The key staging proof: no orphaned old blob / unstaged new blob.
assert!(!git_porcelain(f.vault_str()).contains("attachments/"),
"edit-replace left attachment changes unstaged (incomplete git add)");
}
#[test]
fn org_edit_file_on_non_document_is_rejected() {
let f = OrgFixture::new();
f.create_collection_and_grant("eng");
assert!(f.run(&["org", "add", "login", "--collection", "eng",
"--title", "Site", "--password", "p"]).status.success());
let srcdir = TempDir::new().unwrap();
let x = srcdir.path().join("x.txt");
std::fs::write(&x, b"nope").unwrap();
let out = f.run(&["org", "edit", "Site", "--file", x.to_str().unwrap()]);
assert!(!out.status.success(), "--file on a Login must be rejected");
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(stderr.contains("--file is only valid"), "unexpected error: {stderr}");
}
#[test]
fn org_add_document_into_ungranted_collection_is_denied() {
let f = OrgFixture::new();
// Collection exists but the owner is NOT granted.
assert!(f.run(&["org", "create-collection", "secret", "--name", "Secret"]).status.success(),
"create-collection");
let srcdir = TempDir::new().unwrap();
let src = srcdir.path().join("f.txt");
std::fs::write(&src, b"data").unwrap();
let out = f.run(&["org", "add", "document", "--collection", "secret",
"--title", "X", "--file", src.to_str().unwrap()]);
assert!(!out.status.success(), "ungranted document add must fail");
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(stderr.contains("access denied") || stderr.contains("grant"), "unexpected error: {stderr}");
// Grant is enforced before any attachment is written.
assert!(!f.vault_path().join("attachments").join("secret").exists(),
"no attachment dir should exist on a denied add");
}

View File

@@ -82,7 +82,6 @@ collections.json # collection definitions
keys/<member-id>.enc # org master key wrapped to that member's device key
manifest.enc # OrgManifest (schema_version 1, per-member-filtered)
items/<collection-slug>/<item-id>.enc # collection-scoped item blobs
attachments/<collection-slug>/<item-id>/<att-id>.enc # Document attachment blobs (collection-scoped)
```
### `org.json` — OrgMeta
@@ -124,13 +123,7 @@ Standard `.enc` blob (see **Encrypted blob** above), encrypted under the org mas
These blobs are written and read by the `relicario org` item commands (`org add` / `get` / `list` / `edit` / `rm` / `restore` / `purge`), all collection-scoped and grant-enforced. `org add` currently creates Login / SecureNote / Identity items; `get` / `list` display any item type present.
### `attachments/<collection-slug>/<item-id>/<att-id>.enc`
Standard `.enc` blob (see **Encrypted blob** above), encrypted under the org master key — the encrypted file payload of a Document item. As with item blobs, the blob does **not** name its collection; the leading `<collection-slug>` path segment carries it, so the pre-receive hook (`relicario-server`, `classify_path`) authorizes the write by slug without decrypting — reusing the same grant + slug-existence check as the `items/` branch. The path is **exactly three segments** after `attachments/` (`<collection-slug>/<item-id>/<att-id>.enc`); the hook rejects any other shape (segment-count and `.`-free slug guards). `<att-id>` is the content-addressed `AttachmentId` (see **Item IDs and Field IDs** below).
Per-attachment size is capped at `DEFAULT_ORG_ATTACHMENT_MAX_BYTES = 10 * 1024 * 1024` (10 MiB) (`org_session.rs:24`), mirroring the personal-vault default `AttachmentCaps::per_attachment_max_bytes` (`settings.rs:116`). Org vaults have no `settings.enc`, so this cap is a fixed default rather than per-org configurable. Blobs are persisted / read / removed by `UnlockedOrgVault::save_attachment` / `load_attachment` / `remove_item_attachments` (`org_session.rs:137`, `:147`, `:156`). The storage primitives back the org **Document** item type; the `org add document` / Document-edit commands that produce these blobs land in v0.8.1 (see the item-type-parity note below).
**TODO (extension follow-up):** extension UI for browsing and editing org vault items. **Deferred:** `org add` / `edit` parity for Card / Key / Document / Totp item types (landing in v0.8.1; Document file payloads use the attachment layout above).
**TODO (extension follow-up):** extension UI for browsing and editing org vault items. **Deferred:** `org add` / `edit` parity for Card / Key / Document / Totp item types.
## Item IDs and Field IDs

View File

@@ -0,0 +1,169 @@
# Extension ↔ CLI Parity Gap Analysis
- **Date:** 2026-06-20
- **Author:** Dev-D, reconciled against an independent PM parity sweep
- **Status:** Draft for review — **forward-planning**, NOT v0.8.1 scope
- **Anchor commit:** `origin/main` `b09e0ce` (v0.8.0 org vault + v0.8.1 Dev-A foundation merged; Dev-B/C/D in flight)
- **Scope note:** This plans a *future* milestone. Extension org **writes** remain explicitly out of scope for v0.8.1 per `docs/superpowers/specs/2026-06-20-relicario-v0.8.1-parity.md` (Plan B-2).
## Purpose
Survey the gap between the Relicario **CLI** (`relicario`) and the **browser extension**, classify every gap as a *real parity gap*, an *intended CLI-only* capability, or *already-planned-in-a-spec*, and produce a prioritized work list (with rough sizing) to bring the extension up to CLI parity. The driver is the project's **CLI/extension parity philosophy**: features should not ship "CLI-first, extension-later" without an explicit, recorded decision — this doc is that record for the current backlog.
## Method
Two **independent** surveys were run and then reconciled:
- **PM sweep** — 3 inventory agents + synthesis.
- **Dev-D sweep** — 4 parallel readers (CLI / extension-UI / extension-SW / specs+roadmap) → synthesis → an adversarial completeness/accuracy critic, all reading from a worktree pinned at `b09e0ce`.
The two sweeps were deliberately blind to each other. All load-bearing claims in this document were **hand-verified against source** (greps + line reads); where the two sweeps disagree, the disagreement is flagged explicitly in §Reconciliation. Line citations are point-in-time against `b09e0ce` and may drift.
## Executive summary
Core item-CRUD parity is **excellent**. All 7 item types (Login, Secure Note, Identity, Card, Key, TOTP, Document) and the add / edit / view / list / trash / restore lifecycle are at full parity, and in several places the **extension is the richer surface** (live TOTP codes, custom fields/sections, TOTP-from-QR, password coloring, session auto-lock, autofill/capture). Where a *per-type* gap exists it is most often on the **CLI** side, not the extension's.
The genuine **extension-side** gaps cluster into three buckets:
1. **Metadata-management gaps (the headline finding):** editing **groups**, **tags**, and **filtering** is wired into only specific forms/surfaces in the extension, while the CLI offers them uniformly across all types; **favorites** has *zero* extension UI (strictly worse than the CLI). These are real, currently-shipping parity gaps on the *personal* vault.
2. **Backend-exists-but-no-wire/UI:** attachment **removal** (`removeAttachmentsFromItem` helper exists, no `remove_attachment` router message), **per-item purge** (`purge_item` handler exists, only a bulk "empty trash" UI), and the `isInTab()` popup-mode gate that hides login/secure-note attachment editing in the popup window.
3. **The org (enterprise) vault** — the single largest gap. The entire org feature (shipped CLI-only in v0.8.0) has **no extension presence** (no org routes, no org context). This is fully specced and explicitly deferred (Dev-D org-read / Plan B-2 org-write).
Plus one quality gap on the personal side surfaced by the PM sweep: **autofill hostname matching** is a naive exact-equality match.
Intentionally **CLI-only by design** (not gaps): real `git pull`/`push` and `.git`-history backup bundling (the extension writes straight to the host Contents API and keeps no local repo), the `imgsecret embed` recovery subcommand, recovery-QR's deliberate no-file-write contract, org **admin** (members/collections/grants/rotate/audit), and shell completions.
## Reconciliation with the PM sweep
The PM sweep concluded: extension at *near-full parity* on the personal surface, ahead in places, with the **org vault as the one material gap** and **autofill hostname matching as the only personal-side quality gap**.
**Agreements (both sweeps, independently):**
- Org vault is the largest gap; it is fully specced and deferred (Dev-D read / Plan B-2 write).
- The extension leads on live TOTP, custom fields/sections, password coloring.
- The intended-CLI-only set: git sync/push, `.git` backup bundling, device-key deploy-key plumbing, org admin, shell completions.
**Dev-D refines / partially refutes the PM:** the personal surface is **not** "near-full parity with autofill as the only gap." There is a real cluster of **personal-side extension gaps** the PM sweep understated:
- **Favorites — none in the extension** (`favorite` only round-trips through save fns; no toggle, no star in lists, no filter). The CLI is itself only add-only, so the extension is *strictly worse*. The PM hypothesis did not list this.
- **Group editing — Login-form only** (`f-group` + `wireGroupAutocomplete` live in `login.ts` only; card/key/identity/totp/document forms pass `group` through without an input).
- **Tag editing — Document-form only** (`f-tags` in `document.ts` only; other forms preserve-but-don't-edit).
- **Filter — popup has no type filter** (vault-tab only) and **no tag filter** anywhere.
- **Per-item purge** and **attachment add/remove** have working backends but no popup-reachable UI / no router wire.
**PM caught, Dev-D's taxonomy missed:** **autofill hostname matching.** `service-worker/vault.ts` (`findByHostname`, equality at `:344`) matches credentials by exact `icon_hint` equality (`(e.icon_hint ?? '').toLowerCase() === hostname`) — no `www.` strip, no registrable-domain (eTLD+1) match, so `www.example.com` will not match an item stored as `example.com`. Confirmed; folded in as a real LOW-MED personal-side gap. (Dev-D's capability taxonomy centered on item-CRUD/features and under-weighted the content-script autofill path — the PM sweep is the reason it appears here.)
**Methodology correction (a Dev-D self-sweep error, struck here):** the Dev-D extension-SW inventory referred to a `messages.ts` "that does not exist at that path." **That is false** — the file exists at `extension/src/shared/messages.ts` (227 lines): it holds the `PopupMessage` union (with `delete_item // soft-delete` at line 23), `POPUP_ONLY_TYPES` (line 168), and `CONTENT_CALLABLE_TYPES` (line 224). The inventory had merely dropped the `shared/` directory prefix. The substantive findings it supported (the unwired `searchItems`/`removeAttachmentsFromItem` helpers) are independently verified correct; the "file doesn't exist" caveat is removed from this document.
## Parity matrix
Support: **full** / **partial** / **none** / **n/a**. `gap_class`: **at-parity** · **real-gap** (extension work) · **real-gap (CLI-side)** (extension already ahead; CLI backlog) · **cli-only-by-design** · **already-planned**.
### Item types
| Capability | CLI | Ext | gap_class | Notes (evidence @ `b09e0ce`) |
|---|---|---|---|---|
| Login: create/view/edit | full | full | at-parity | CLI `add/get/edit login`; ext form + `add_item`/`update_item`/`get_item`. |
| Secure Note: create/view/edit | full | full | at-parity | Both complete. |
| Identity: create | full | full | at-parity | Both; ext also exposes `address`. |
| Identity: view | full | full | at-parity | Both. |
| Identity: edit | partial | full | real-gap (CLI-side) | CLI `edit_identity` omits `date_of_birth` + records no history; ext edits all. CLI backlog. |
| Card: create/view | full | full | at-parity | Both. |
| Card: edit | partial | full | real-gap (CLI-side) | CLI `edit_card` = holder+number only (no CVV/PIN/expiry/kind); ext edits all. CLI backlog. |
| Key: create/view | full | full | at-parity | Both; ext takes `public_key` interactively. |
| Key: edit | partial | full | real-gap (CLI-side) | CLI `edit_key` = key-material only (no label/algorithm/public_key); ext edits all. CLI backlog. |
| TOTP: create | full | full | at-parity | Both; ext adds Steam Guard kind. |
| TOTP: view | partial | full | real-gap (CLI-side) | CLI shows metadata only; ext shows live rotating code. See "TOTP live code". |
| TOTP: edit | full | full | at-parity | Both. |
| Document: create | full | full | at-parity | CLI encrypts file as attachment; ext `upload_attachment`. |
| Document: view | partial | full | real-gap (CLI-side) | CLI metadata + `extract`; ext inline image preview. CLI backlog. |
| Document: edit | none | full | real-gap (CLI-side) | CLI `edit` on Document is a no-op redirect to attach/extract; ext changes primary/supplementary files. CLI backlog. |
### Operations
| Capability | CLI | Ext | gap_class | Notes |
|---|---|---|---|---|
| add / edit / get / list | full | full | at-parity | All 7 types both surfaces. |
| rm / soft-delete | full | full | at-parity | CLI `rm`; ext `delete_item` (`messages.ts:23`, handler `popup-only.ts`). |
| trash (list) | full | full | at-parity | CLI `trash`; ext trash view. |
| restore from trash | full | full | at-parity | CLI `restore`; ext `restore_item`. |
| purge (permanent) | full | partial | **real-gap** | Ext UI only bulk "empty trash" (`purge_all_trash`, `popup-only.ts:420`); **no per-item purge UI**, though `purge_item` handler exists (`popup-only.ts:409`). CLI has single + bulk. |
### Features
| Capability | CLI | Ext | gap_class | Notes |
|---|---|---|---|---|
| Attachments: add | full | partial | **real-gap** | Login/secure-note attachment editing gated behind `isInTab()` (`login.ts:370,388`; `secure-note.ts:123,140`) — unavailable in popup window; document renders unconditionally (`document.ts`). SW `upload_attachment` is full. |
| Attachments: view/download | full | full | at-parity | CLI `extract`; ext download + `download_attachment`. |
| Attachments: remove | full | partial | **real-gap** | SW helper `removeAttachmentsFromItem` (`vault.ts:492`) has **no router wire** (`remove_attachment` absent — confirmed). UI removes refs at form-save only, with the same `isInTab()` caveat. CLI `detach` is full. |
| TOTP: live code | none | full | real-gap (CLI-side) | CLI reveals raw base32 only; ext computes live codes. Extension leads. No spec mandates CLI OTP. |
| Generator: password / passphrase | full | full | at-parity | CLI `generate`; ext generator-panel + `generate_password`. |
| Settings: view / edit | full | full | at-parity | CLI `settings`; ext `get/set_vault_settings`. |
| Search | partial | partial | at-parity | CLI: title-substring. Ext: client-side over title/group/tags/icon_hint; SW `searchItems` (`vault.ts:316`) exists but **unwired** (no `search_items` message). Neither does field-value full-text. |
| Filter | full | partial | **real-gap** | CLI `list` filters type/group/tag/trashed. Ext: type filter is **vault-tab-only**; popup has none; **no tag filter anywhere**. SW `list_items` filters by `group` only. |
| Favorites | partial | **none** | **real-gap** | CLI add-only (`--favorite` on Login add, `*` in list; no toggle/filter). Ext: **zero UI**`favorite` only round-trips. Ext strictly worse; needs a paired CLI+ext design. |
| Tags | full | partial | **real-gap** | CLI full create+filter all types. Ext: only Document form edits tags (`f-tags` in `document.ts`); no tag chips in lists; no tag filter. SW round-trips tags. |
| Groups/folders | full | partial | **real-gap** | CLI all types `--group`, `list --group`. Ext: only Login form has `f-group`+autocomplete; other forms set no group; vault-tab "group" filter is actually a type filter. SW `list_groups`/group-filter full. |
| Field history (view) | full | full | at-parity | CLI `history`; ext `get_field_history`. |
| Custom fields / sections | none | full | real-gap (CLI-side) | CLI has no custom-field/section commands (core supports them); ext `renderSectionsEditor` covers all 7 types. CLI backlog. |
| Autofill hostname matching | n/a | partial | **real-gap** | Ext-only feature; matcher (`vault.ts` `findByHostname`, `:344`) is exact `icon_hint` equality — no `www.` strip / eTLD+1. `www.x.com``x.com`. (PM-surfaced.) |
### Org (enterprise) vault
| Capability | CLI | Ext | gap_class | Notes |
|---|---|---|---|---|
| Org: read items | full | none | already-planned | CLI `org get/list` grant-filtered, all 7 types. Ext has zero org code. Planned Dev-D. Spec: `2026-06-06-relicario-enterprise-org-vault-design.md` § Extension — Org Context; ROADMAP "Extension org parity — read". |
| Org: write (add/edit/rm) | partial | none | already-planned | CLI write = Login/SecureNote/Identity only (`OrgAddKind`, `main.rs:560`; Card/Key/Document/Totp absent — v0.8.1 lift in flight). Ext none. Planned Plan B-2. Spec: `2026-06-20-relicario-v0.8.1-parity.md` § Out of scope. |
| Org: member/collection mgmt | full | none | cli-only-by-design | CLI full lifecycle (~19 subcommands). Ext none — org **admin** is intended CLI-only (high-trust, low-frequency). |
### Vault lifecycle / infra
| Capability | CLI | Ext | gap_class | Notes |
|---|---|---|---|---|
| Vault init / setup | full | full | at-parity | CLI `init`; ext setup wizard + `create_vault`/`attach_vault`. |
| Git sync (pull/push) | full | partial | cli-only-by-design | CLI real `git pull --rebase`/`push`. Ext writes straight to host Contents API; no local graph (`ahead`/`behind` always 0). Functionally syncs; architecturally different by design (`extension/ARCHITECTURE.md`). |
| Device management | full | full | at-parity | CLI `device`; ext `renderDevices` + SW device CRUD. (GitHub/GitLab deploy-key API is the deferred edge.) |
| Backup / restore | full | full | at-parity | CLI `.relbak` + git-history bundling; ext `export/restore_backup`. `.git` bundling sub-aspect is cli-only-by-design (ext has no local repo). |
| Import (LastPass) | partial | partial | at-parity | Both LastPass-CSV only; other importers deferred both surfaces by policy. |
| Recovery QR | full | full | at-parity | CLI generate/unwrap; ext `generate/unwrap_recovery_qr`. Webcam scan deferred both. |
| Standalone generate (no vault) | full | none | cli-only-by-design (low-confidence) | CLI `generate`/`rate` work outside a vault; ext generator is embedded in login form + settings (needs unlocked vault). A browser extension lacks the "no vault" generator use-case a shell has. No spec; flag if user demand appears. |
### Intended CLI-only (no taxonomy row; recorded so they are not re-litigated as gaps)
| Capability | gap_class | Notes / spec |
|---|---|---|
| Recovery-QR file-write (`--out`) | cli-only-by-design | Negative API contract — no surface writes the payload to disk; absence *is* the security property. `2026-05-01-recovery-qr-design.md`. |
| Org delete-org push to remote | cli-only-by-design | Phase-1 delete-org is a local tombstone; pre-receive hook rejects protected-file deletion. Pushable delete-org is phase-2. `2026-06-06-...-design.md`. |
| `imgsecret embed` subcommand | cli-only-by-design | CLI disaster-recovery tool; the extension setup wizard's image flow covers the equivalent. |
| Password coloring (CLI TTY) | cli-only-by-design (inverted) | Ext shipped it (v0.5.1); CLI TTY parity deferred until demand. `2026-05-01-password-coloring-design.md` § Out of scope. |
| Shell completions | cli-only-by-design | No extension analogue. |
## Gap classification summary
- **Real extension gaps (extension work closes them):** per-item purge UI; attachment add/remove UI + `remove_attachment` wire + `isInTab()` gate; popup type filter + tag filter; tag editing on all forms; group editing on all forms; favorites UI; autofill registrable-domain matching; **org read** (specced); **org write** (specced, behind CLI type parity).
- **CLI-side gaps (extension already ahead — separate CLI backlog, NOT extension work):** Identity/Card/Key edit field coverage; Document view/edit; live TOTP code; custom fields/sections commands.
- **Intended CLI-only (not gaps):** git pull/push, `.git` backup bundling, org admin, `imgsecret embed`, recovery-QR file-write, shell completions, standalone generate.
- **Already planned / deferred:** org read (Dev-D), org write + org item-type breadth (Plan B-2), org attachments/multi-vault (behind org).
## Prioritized forward work (extension)
Only items where **extension work** closes the gap. CLI-side gaps and intended-CLI-only items are excluded.
| Pri | Item | Size | Why | Depends on |
|---|---|---|---|---|
| **P0** | **Attachment remove + un-gate popup:** wire a `remove_attachment` router message to `removeAttachmentsFromItem` (`vault.ts:492`); drop the `isInTab()` gate so login/secure-note attachment **add & remove** work in the popup. Closes *both* the add half (row "Attachments: add") and remove half. | M | Backend exists and is unreachable — highest value-per-effort. The popup-mode gate is a UX cliff (can't manage login attachments without popping out). | — |
| **P0** | **Per-item purge UI:** surface the existing `purge_item` handler (`popup-only.ts:409`) as a per-row permanent-delete in the trash view (today only bulk `purge_all_trash`). | S | Pure UI wiring over an existing handler; CLI has single-item purge. | — |
| **P1** | **Group editing on all type forms:** add `f-group` + `wireGroupAutocomplete` to card/key/identity/totp/document (Login-only today). | M | SW (`list_groups`, group filter) already full; replicate one existing form pattern across 5 forms. | — |
| **P1** | **Tag editing on all type forms + tag chips in lists:** promote Document's `f-tags` to a shared affordance on all 7 forms. | M | SW round-trips tags fully; only Document edits them today. | — |
| **P1** | **Filter parity:** add a type filter to the popup (vault-tab has it) and a tag filter to popup + vault tab; optionally push type/tag params into `list_items`. | M | CLI filters type/group/tag; ext type filter is fullscreen-only, tag filter absent. | Tag editing (so tags exist to filter on) |
| **P2** | **Favorites (paired CLI + ext):** favorite toggle in detail/edit, favorites filter, star in list rows — and extend the CLI beyond add-only, to reach *true* parity per the parity philosophy. | M | Ext strictly worse than CLI (none vs partial); both surfaces weak. Write a short spec first. | — (spec TBD) |
| **P2** | **Autofill registrable-domain matching:** replace exact `icon_hint` equality (`vault.ts` `findByHostname:344`) with `www.`-strip + eTLD+1 matching. | SM | `www.x.com``x.com` today; the one personal-side quality gap. | — |
| **P2** | **Search wire-up (hardening):** expose `searchItems` (`vault.ts:316`) via a `search_items` message, or formally adopt client-side filtering and remove the dangling helper. | S | Functionally at-parity, but an unwired helper is dead-code drift. | — |
| **P3** | **Org read in extension (Dev-D):** org context switcher + SW org handlers (unwrap org master key into a `Zeroizing` session handle) + grant-filtered manifest browse/read in popup + vault tab. | XL | Largest single gap; entire org feature is CLI-only in the extension. Specced, deferred. | — |
| **P3** | **Org offline read-only indicator:** "org offline — writes disabled" banner when the git remote is unreachable in org context. | S | Spec-mandated UX. | Org read |
| **P3** | **Org SW acceptance tests:** org context replaces personal manifest cleanly; org master key never in localStorage/IndexedDB; offline mode triggers on network error. | M | Spec-mandated coverage following the feature. | Org read |
| **P3** | **Org write in extension (Plan B-2):** org add/edit/rm including Card/Key/Document/Totp. | XL | Closes org write + item breadth. Deferred past v0.8.1. | Org read **and** CLI reaching Card/Key/Document/Totp org-write parity (v0.8.1) |
## Caveats
- Line citations are point-in-time against `b09e0ce` and drift with edits.
- This is a planning artifact, not a commitment; sizes are rough (S ≈ hours, M ≈ a day, L ≈ days, XL ≈ a multi-stream lift).
- Two analytical errors caught during cross-check and corrected here: (1) the struck `messages.ts`-doesn't-exist claim (file exists at `extension/src/shared/messages.ts`); (2) a few inventory line numbers were off by single digits and have been replaced with hand-verified ones.