Compare commits
65 Commits
feature/v0
...
dd0010db62
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd0010db62 | ||
|
|
29146439bb | ||
|
|
cf66bd97b7 | ||
|
|
061facd5a9 | ||
|
|
bd6a30155e | ||
|
|
8baef5b3cb | ||
|
|
ddfb95d683 | ||
|
|
7df76c692a | ||
|
|
b4d253c60b | ||
|
|
c16adc4335 | ||
|
|
9a8cdf8e4f | ||
|
|
ade44b4ea1 | ||
|
|
1d4b018f9a | ||
|
|
882a89bedd | ||
|
|
37c20b28a6 | ||
|
|
3553150a53 | ||
|
|
b50f49b597 | ||
|
|
1ec8965910 | ||
|
|
ad6e4a2cd9 | ||
|
|
b768f649a2 | ||
|
|
8b197a7525 | ||
|
|
117716f6cf | ||
|
|
c5e8b52e12 | ||
|
|
a1b66a9147 | ||
|
|
934dfe05c2 | ||
|
|
33d2a4a311 | ||
|
|
f17944a404 | ||
|
|
4851857070 | ||
|
|
a6071b4c0c | ||
|
|
ada00895d4 | ||
|
|
42b746f9af | ||
|
|
762a008171 | ||
|
|
f93bce7388 | ||
|
|
8eabaf5f31 | ||
|
|
04142dc116 | ||
|
|
8739f1f67b | ||
|
|
7d6fd76e86 | ||
|
|
4dc034d846 | ||
|
|
3021ef9d9f | ||
|
|
b2749826b1 | ||
|
|
a332a9e80d | ||
|
|
d45dd10917 | ||
|
|
4d02a50cc8 | ||
|
|
5d9a7ee8d3 | ||
|
|
006e67c361 | ||
|
|
95d1ff833c | ||
|
|
4fc1357368 | ||
|
|
df58b0dda1 | ||
|
|
ed9fcbe6ba | ||
|
|
0172a06698 | ||
|
|
6d5a2570d4 | ||
|
|
6a1c6d5875 | ||
|
|
6d8f699fcb | ||
|
|
c0921b134d | ||
|
|
0443f6a3b4 | ||
|
|
5e8e617a4d | ||
|
|
efac53d527 | ||
|
|
af8626fb5f | ||
|
|
9c97f9f939 | ||
|
|
76d092d4f6 | ||
|
|
1342228a51 | ||
|
|
d539050aec | ||
|
|
8fd9a05875 | ||
|
|
8a72b5e192 | ||
|
|
ca059e7507 |
@@ -1,4 +1,10 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"relay": {
|
||||
"type": "sse",
|
||||
"url": "http://localhost:7331/sse"
|
||||
}
|
||||
},
|
||||
"enabledPlugins": {
|
||||
"superpowers@claude-plugins-official": true
|
||||
}
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -7,3 +7,4 @@ extension/dist-firefox/
|
||||
extension/wasm/
|
||||
reference.jpg
|
||||
ref.jpg
|
||||
tools/relay/node_modules/
|
||||
|
||||
108
CHANGELOG.md
108
CHANGELOG.md
@@ -1,9 +1,76 @@
|
||||
# Changelog
|
||||
|
||||
## Unreleased
|
||||
## v0.5.0 — 2026-05-02
|
||||
|
||||
Three release trains roll into one tag — backup/restore + LastPass
|
||||
import (originally v0.3.0), device authentication (originally v0.4.0),
|
||||
and the v0.5.0 polish + harden bundle (security fixes + UX fixes +
|
||||
two confirmed bugs).
|
||||
|
||||
### Security
|
||||
|
||||
- **Pre-receive hook now actually verifies signatures (audit S1, HIGH).**
|
||||
Earlier `relicario-server` builds accepted any commit with a
|
||||
`Good signature` line on stderr regardless of which key signed it —
|
||||
device-auth was a no-op. The hook now builds an `allowed_signers`
|
||||
file from `devices.json` at the commit (via `GIT_CONFIG_*` env, no
|
||||
global git-config mutation), parses the SSH SHA-256 fingerprint out
|
||||
of `git verify-commit --raw` stderr, and rejects unregistered keys or
|
||||
revoked keys whose committer-date is at or after the revocation
|
||||
timestamp. Bootstrap mode is preserved only when **both**
|
||||
`devices.json` AND `revoked.json` are empty (closes an
|
||||
empty-devices.json privilege-escalation route).
|
||||
- **Backup-restore tar unpacking hardened (audit S2).** `relicario
|
||||
backup restore` no longer trusts `tar::Archive::unpack`'s defaults.
|
||||
A new `relicario_core::safe_unpack_git_archive` validates each
|
||||
entry's path components (rejects `..`, absolute paths, Windows
|
||||
drive prefixes), rejects symlinks/hardlinks, and caps total
|
||||
uncompressed size at the lower of 100×compressed-bytes or 1 GiB.
|
||||
The CLI restore path adds a paranoid `dest.starts_with(.git/)`
|
||||
check after path-joining as defense-in-depth.
|
||||
- **`RELICARIO_*` env-var surface audited (audit S3).** `docs/SECURITY.md`
|
||||
gains a per-variable trust table. `RELICARIO_NO_GROUPS_CACHE` (a
|
||||
developer escape hatch, not a user knob) is now
|
||||
`cfg(debug_assertions)`-gated and is a no-op in `--release` builds;
|
||||
the env-var lookup is removed from the binary by the optimiser.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Strength meter no longer goes stale after the regenerate button (B1).**
|
||||
Programmatic `input.value = newPassword` doesn't fire `input`
|
||||
events; the regenerate handler now dispatches a synthetic
|
||||
`InputEvent('input', { bubbles: true })` so the meter listener
|
||||
re-rates the new value.
|
||||
- **Snake_case error codes no longer leak into the UI (B2 / P4).**
|
||||
Errors like `vault_locked`, `origin_mismatch`, `unauthorized_sender`
|
||||
used to render verbatim in the fullscreen vault tab and (in some
|
||||
cases) the popup. New `extension/src/shared/error-copy.ts` central
|
||||
registry maps every service-worker error code to friendly
|
||||
title/body/CTA copy; the popup and fullscreen tab consume the
|
||||
same map. The fullscreen lock screen's `vault_locked` block now
|
||||
reads `Vault locked / Unlock your vault to continue. / [Unlock
|
||||
vault]`. A generated test enumerates the live error codes via
|
||||
grep so the registry can't drift.
|
||||
|
||||
### Added
|
||||
|
||||
- **Sidebar logo in the fullscreen vault tab.** The
|
||||
`vault-sidebar__header` now renders the 16-optimized SVG logo
|
||||
inline before the "Relicario" wordmark (20×20 px, `flex-shrink: 0`
|
||||
so it survives narrow-pane wraps). Popup unaffected.
|
||||
- **Password coloring (P1).** Revealed passwords in the popup
|
||||
item-detail, fullscreen item view, field-history viewer, and
|
||||
generator preview render digits and symbols in distinct colors.
|
||||
Defaults: blue digits, red symbols. Users can override via the
|
||||
new Display section in settings (color pickers + live preview
|
||||
swatch + reset). Defaults round-trip via
|
||||
`chrome.storage.sync.password_display_scheme`; cross-device when
|
||||
Chrome sync is enabled.
|
||||
- **Setup wizard hands off to the fullscreen vault tab on completion
|
||||
(P2).** Both create-new and attach-existing flows now open
|
||||
`vault.html` in a new tab and best-effort close the setup tab
|
||||
after device registration succeeds — replaces the prior
|
||||
setup-tab-stays-open terminal screen.
|
||||
- **Sync now button** in the extension settings view — surfaces the
|
||||
previously hidden `{ type: 'sync' }` SW message to users with success /
|
||||
error feedback.
|
||||
@@ -59,6 +126,30 @@
|
||||
file `cmd_backup_export` writes on success). Reads "never" for
|
||||
fresh vaults, "4 days ago" otherwise.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Form layout in the fullscreen vault tab is now visually consistent
|
||||
(P3).** Notes, custom-fields disclosure, attachments disclosure, and
|
||||
form-actions in fullscreen logins now sit inside a `.form-lower`
|
||||
wrapper with the same `max-width: 960px; margin: 0 auto` envelope as
|
||||
the `.form-grid` cards above. Removes the visual rhythm break at the
|
||||
2-col → full-width transition. The popup surface is unchanged.
|
||||
- **Documentation refreshed for v0.5.0 (doc audit, 14 findings).**
|
||||
`docs/architecture/overview.md` now describes four codebases (the
|
||||
`relicario-server` pre-receive hook crate is no longer invisible);
|
||||
`CLAUDE.md` project tree and roadmap reflect current state;
|
||||
`docs/SECURITY.md` names the server crate and its `verify-commit` /
|
||||
`generate-hook` subcommands and notes the without-the-hook-it's-
|
||||
advisory caveat; `docs/ARCHITECTURE.md` shows `settings.enc` as a
|
||||
parallel artifact in the vault-creation flow; the foundational
|
||||
design spec gains a "historical" status banner pointing readers at
|
||||
the current docs.
|
||||
- `relicario generate` now consults `VaultSettings.generator_defaults` when
|
||||
invoked inside an initialized vault. Explicit flags (`--length`,
|
||||
`--bip39`, `--words`, `--symbols`, `--separator`) override the vault
|
||||
default. Outside a vault, behavior is unchanged (length 20, safe symbol
|
||||
set, 5 BIP39 words, space separator).
|
||||
|
||||
### Known limitations
|
||||
|
||||
- **Mid-restore failure leaves the target remote in a half-written
|
||||
@@ -74,6 +165,13 @@
|
||||
|
||||
### Internal
|
||||
|
||||
- 5 stale local feature branches and 3 worktrees pruned (audit C1).
|
||||
- Pre-existing clippy warnings cleaned up across `relicario-{core,cli}`
|
||||
(deref operators, `Option::is_none_or` over `map_or(true, ...)`,
|
||||
`iter_mut().enumerate()` patterns, `div_ceil()`) so the workspace
|
||||
builds clean under `-D warnings`.
|
||||
- `Cargo.lock` regenerated and committed; was stale since the
|
||||
`--totp-qr` commit.
|
||||
- Refactored `cmd_add` and `cmd_edit` in the CLI: each `ItemCore` variant
|
||||
now has its own `build_*_item` / `edit_*` helper. Pure mechanical
|
||||
extraction; behavior unchanged. The dispatcher matches and delegates.
|
||||
@@ -83,14 +181,6 @@
|
||||
`setup.ts` since it walks live wizard state. Setup.ts went from
|
||||
1205 → 1137 lines.
|
||||
|
||||
### Changed
|
||||
|
||||
- `relicario generate` now consults `VaultSettings.generator_defaults` when
|
||||
invoked inside an initialized vault. Explicit flags (`--length`,
|
||||
`--bip39`, `--words`, `--symbols`, `--separator`) override the vault
|
||||
default. Outside a vault, behavior is unchanged (length 20, safe symbol
|
||||
set, 5 BIP39 words, space separator).
|
||||
|
||||
## v0.2.0 — 2026-04-27
|
||||
|
||||
### Fixed
|
||||
|
||||
12
CLAUDE.md
12
CLAUDE.md
@@ -48,9 +48,11 @@ crates/
|
||||
│ ├── src/helpers.rs # vault_dir, git_command, iso8601
|
||||
│ ├── src/session.rs # UnlockedVault (master key in Zeroizing)
|
||||
│ └── tests/ # basic_flows, edit_and_history, attachments, settings, vault_detection
|
||||
└── relicario-wasm/ # WASM bindings for the extension
|
||||
├── src/lib.rs # #[wasm_bindgen] surface
|
||||
└── src/session.rs # opaque SessionHandle → Zeroizing<[u8;32]>
|
||||
├── relicario-wasm/ # WASM bindings for the extension
|
||||
│ ├── src/lib.rs # #[wasm_bindgen] surface
|
||||
│ └── src/session.rs # opaque SessionHandle → Zeroizing<[u8;32]>
|
||||
└── relicario-server/ # `relicario-server` binary (pre-receive Git hook)
|
||||
└── src/main.rs # verify-commit + generate-hook subcommands
|
||||
```
|
||||
|
||||
## Key design decisions
|
||||
@@ -76,7 +78,7 @@ passphrase (UTF-8 bytes) || image_secret (32 bytes from reference JPEG)
|
||||
|
||||
- Tests use fast Argon2id params (m=256, t=1, p=1) so they don't take forever.
|
||||
- Test JPEGs are generated synthetically via `make_test_jpeg()` — no binary test fixtures.
|
||||
- Item IDs are random 8-char hex strings.
|
||||
- Item IDs and Field IDs are random 16-char hex strings (64 bits of OsRng entropy). AttachmentIds are content-addressed: first 32 hex chars of SHA-256 over the plaintext (128 bits).
|
||||
- Git history is preserved as an audit log — no squashing.
|
||||
- The CLI shells out to `git` for sync — no libgit2/gitoxide dependency.
|
||||
|
||||
@@ -90,4 +92,4 @@ Full threat model, entropy analysis, and architecture: `docs/superpowers/specs/2
|
||||
|
||||
## Roadmap
|
||||
|
||||
Next: WASM build + Chrome MV3 browser extension (Plan 2). Then mobile (Rust core compiles to ARM).
|
||||
Next: v0.5.0 polish + harden (in progress). After that, Phases 3/4 of the fullscreen UX redesign (vault-tab shell + command palette), Plan 1C-γ (attachments + Document + trash/history/device UI), and the LastPass importer. Mobile (Rust core compiles to ARM) and recovery QR remain on the roadmap.
|
||||
|
||||
947
Cargo.lock
generated
947
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "relicario-cli"
|
||||
version = "0.2.0"
|
||||
version = "0.5.0"
|
||||
edition = "2021"
|
||||
description = "CLI for relicario password manager"
|
||||
|
||||
@@ -28,10 +28,10 @@ clap_complete = "4"
|
||||
image = { version = "0.25", default-features = false, features = ["jpeg", "png"] }
|
||||
rqrr = "0.7"
|
||||
reqwest = { version = "0.12", features = ["blocking", "json"] }
|
||||
qrcode = { version = "0.14", features = ["svg"] }
|
||||
|
||||
[dev-dependencies]
|
||||
assert_cmd = "2"
|
||||
predicates = "3"
|
||||
tempfile = "3"
|
||||
qrcode = "0.14"
|
||||
serde_json = "1"
|
||||
|
||||
@@ -91,6 +91,7 @@ pub fn store_device_keys(
|
||||
}
|
||||
|
||||
/// Load the signing private key for a device.
|
||||
#[allow(dead_code)]
|
||||
pub fn load_signing_key(name: &str) -> Result<Zeroizing<String>> {
|
||||
let path = device_dir(name)?.join("signing.key");
|
||||
let key = fs::read_to_string(&path)
|
||||
@@ -99,6 +100,7 @@ pub fn load_signing_key(name: &str) -> Result<Zeroizing<String>> {
|
||||
}
|
||||
|
||||
/// Load the deploy private key for a device.
|
||||
#[allow(dead_code)]
|
||||
pub fn load_deploy_key(name: &str) -> Result<Zeroizing<String>> {
|
||||
let path = device_dir(name)?.join("deploy.key");
|
||||
let key = fs::read_to_string(&path)
|
||||
@@ -115,6 +117,7 @@ pub fn load_gitea_key_id(name: &str) -> Result<u64> {
|
||||
}
|
||||
|
||||
/// Delete the local key directory for a device.
|
||||
#[allow(dead_code)]
|
||||
pub fn delete_device_keys(name: &str) -> Result<()> {
|
||||
let dir = device_dir(name)?;
|
||||
if dir.exists() {
|
||||
|
||||
@@ -21,7 +21,9 @@ struct CreateKeyRequest<'a> {
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct DeployKey {
|
||||
pub id: u64,
|
||||
#[allow(dead_code)]
|
||||
pub title: String,
|
||||
#[allow(dead_code)]
|
||||
pub key: String,
|
||||
}
|
||||
|
||||
@@ -89,6 +91,7 @@ impl GiteaClient {
|
||||
}
|
||||
|
||||
/// List all deploy keys.
|
||||
#[allow(dead_code)]
|
||||
pub fn list_deploy_keys(&self) -> Result<Vec<DeployKey>> {
|
||||
let url = format!(
|
||||
"{}/repos/{}/{}/keys",
|
||||
|
||||
@@ -34,6 +34,7 @@ pub fn vault_dir() -> Result<PathBuf> {
|
||||
}
|
||||
|
||||
/// Path to the `.relicario/` configuration directory within the vault.
|
||||
#[allow(dead_code)]
|
||||
pub fn relicario_dir() -> Result<PathBuf> {
|
||||
Ok(vault_dir()?.join(".relicario"))
|
||||
}
|
||||
@@ -88,19 +89,21 @@ fn plural(n: i64) -> &'static str { if n == 1 { "" } else { "s" } }
|
||||
///
|
||||
/// **Plaintext leak:** group names land on disk in cleartext alongside the
|
||||
/// vault directory. This is intentional — the file feeds shell completion,
|
||||
/// which cannot prompt for a passphrase. Set `RELICARIO_NO_GROUPS_CACHE=1`
|
||||
/// to suppress the write.
|
||||
/// which cannot prompt for a passphrase. In debug builds, set
|
||||
/// `RELICARIO_NO_GROUPS_CACHE=1` to suppress the write.
|
||||
pub fn groups_cache_path(vault_dir: &Path) -> PathBuf {
|
||||
vault_dir.join(".relicario").join("groups.cache")
|
||||
}
|
||||
|
||||
/// Write the sorted set of group names to `<vault_dir>/.relicario/groups.cache`,
|
||||
/// one name per line. A no-op if `RELICARIO_NO_GROUPS_CACHE` is set.
|
||||
/// one name per line. In debug builds, setting `RELICARIO_NO_GROUPS_CACHE`
|
||||
/// suppresses the write (developer debugging tool). In release builds the env
|
||||
/// var is ignored.
|
||||
pub fn write_groups_cache(
|
||||
vault_dir: &Path,
|
||||
groups: &std::collections::BTreeSet<String>,
|
||||
) -> std::io::Result<()> {
|
||||
if std::env::var_os("RELICARIO_NO_GROUPS_CACHE").is_some() {
|
||||
if cfg!(debug_assertions) && std::env::var_os("RELICARIO_NO_GROUPS_CACHE").is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
let path = groups_cache_path(vault_dir);
|
||||
|
||||
@@ -170,7 +170,7 @@ enum Commands {
|
||||
///
|
||||
/// For `--group <TAB>` autocomplete, the bash/zsh/fish scripts read
|
||||
/// the plaintext `${RELICARIO_VAULT}/.relicario/groups.cache` file,
|
||||
/// which the CLI refreshes on every manifest read. Set
|
||||
/// which the CLI refreshes on every manifest read. In debug builds, set
|
||||
/// `RELICARIO_NO_GROUPS_CACHE=1` to opt out of the cache (completion
|
||||
/// will fall back to no value enumeration).
|
||||
///
|
||||
@@ -196,6 +196,12 @@ enum Commands {
|
||||
#[command(subcommand)]
|
||||
action: DeviceAction,
|
||||
},
|
||||
|
||||
/// Recovery QR operations — generate or unwrap the 2FA recovery code.
|
||||
RecoveryQr {
|
||||
#[command(subcommand)]
|
||||
cmd: RecoveryQrCmd,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
@@ -403,6 +409,14 @@ enum DeviceAction {
|
||||
List,
|
||||
}
|
||||
|
||||
#[derive(clap::Subcommand)]
|
||||
enum RecoveryQrCmd {
|
||||
/// Generate a recovery QR code and display it as ASCII art in the terminal.
|
||||
Generate,
|
||||
/// Unwrap a recovery QR payload (base64) to recover the image_secret as hex.
|
||||
Unwrap,
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let cli = Cli::parse();
|
||||
match cli.command {
|
||||
@@ -436,6 +450,7 @@ fn main() -> Result<()> {
|
||||
}
|
||||
Commands::Rate { passphrase } => cmd_rate(passphrase),
|
||||
Commands::Device { action } => cmd_device(action),
|
||||
Commands::RecoveryQr { cmd } => cmd_recovery_qr(cmd),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -540,7 +555,7 @@ fn cmd_init(image: PathBuf, output: PathBuf) -> Result<()> {
|
||||
};
|
||||
let carrier = fs::read(&image)
|
||||
.with_context(|| format!("failed to read carrier image {}", image.display()))?;
|
||||
let stego = imgsecret::embed(&carrier, &*image_secret)?;
|
||||
let stego = imgsecret::embed(&carrier, &image_secret)?;
|
||||
fs::write(&output, &stego)
|
||||
.with_context(|| format!("failed to write reference image {}", output.display()))?;
|
||||
|
||||
@@ -550,7 +565,7 @@ fn cmd_init(image: PathBuf, output: PathBuf) -> Result<()> {
|
||||
let params = KdfParams { argon2_m: 65536, argon2_t: 3, argon2_p: 4 };
|
||||
|
||||
// Derive master key, then persist an empty Manifest + default VaultSettings.
|
||||
let master_key = derive_master_key(passphrase.as_bytes(), &*image_secret, &salt, ¶ms)?;
|
||||
let master_key = derive_master_key(passphrase.as_bytes(), &image_secret, &salt, ¶ms)?;
|
||||
|
||||
fs::create_dir_all(&relicario_dir)?;
|
||||
fs::create_dir_all(root.join("items"))?;
|
||||
@@ -645,6 +660,7 @@ fn cmd_add(kind: AddKind) -> Result<()> {
|
||||
// (for attachment-cap settings + writing the encrypted blob alongside
|
||||
// the item).
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn build_login_item(
|
||||
title: Option<String>,
|
||||
username: Option<String>,
|
||||
@@ -860,6 +876,7 @@ fn build_document_item(
|
||||
Ok(item)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn build_totp_item(
|
||||
title: Option<String>,
|
||||
issuer: Option<String>,
|
||||
@@ -924,7 +941,7 @@ fn prompt_optional(label: &str) -> Result<Option<String>> {
|
||||
|
||||
fn parse_month_year(s: &str) -> Result<relicario_core::MonthYear> {
|
||||
// Accepts MM/YYYY or MM-YYYY or MM/YY.
|
||||
let (m_str, y_str) = s.split_once(|c: char| c == '/' || c == '-')
|
||||
let (m_str, y_str) = s.split_once(['/', '-'])
|
||||
.ok_or_else(|| anyhow::anyhow!("expected MM/YYYY"))?;
|
||||
let month: u8 = m_str.parse().context("invalid month")?;
|
||||
let year: u16 = if y_str.len() == 2 {
|
||||
@@ -998,12 +1015,12 @@ fn cmd_get(query: String, show: bool, copy: bool) -> Result<()> {
|
||||
if let Some(u) = &l.url { println!("URL: {u}"); }
|
||||
if let Some(t) = &l.totp {
|
||||
if show {
|
||||
println!("TOTP: {}", data_encoding::BASE32.encode(&*t.secret));
|
||||
println!("TOTP: {}", data_encoding::BASE32.encode(&t.secret));
|
||||
} else {
|
||||
println!("TOTP: **** (use --show to reveal)");
|
||||
}
|
||||
}
|
||||
if let Some(p) = &l.password { Some(p.clone()) } else { None }
|
||||
l.password.clone()
|
||||
}
|
||||
ItemCore::SecureNote(n) => {
|
||||
if show { println!("Body:\n{}", n.body.as_str()); }
|
||||
@@ -1125,8 +1142,8 @@ fn cmd_list(
|
||||
Some(t) => e.r#type == t,
|
||||
None => true,
|
||||
})
|
||||
.filter(|e| group_filter.as_ref().map_or(true, |g| e.group.as_deref() == Some(g.as_str())))
|
||||
.filter(|e| tag_filter.as_ref().map_or(true, |t| e.tags.iter().any(|x| x == t)))
|
||||
.filter(|e| group_filter.as_ref().is_none_or(|g| e.group.as_deref() == Some(g.as_str())))
|
||||
.filter(|e| tag_filter.as_ref().is_none_or(|t| e.tags.iter().any(|x| x == t)))
|
||||
.collect();
|
||||
entries.sort_by(|a, b| a.title.to_lowercase().cmp(&b.title.to_lowercase()));
|
||||
|
||||
@@ -1135,7 +1152,7 @@ fn cmd_list(
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
println!("{:<16} {:<14} {:<6} {}", "ID", "TYPE", "FAV", "TITLE");
|
||||
println!("{:<16} {:<14} {:<6} TITLE", "ID", "TYPE", "FAV");
|
||||
for e in entries {
|
||||
let fav = if e.favorite { " *" } else { "" };
|
||||
println!("{:<16} {:<14} {:<6} {}", e.id.as_str(), format!("{:?}", e.r#type), fav, e.title);
|
||||
@@ -1718,9 +1735,32 @@ fn cmd_backup_restore(input: PathBuf, target: PathBuf) -> Result<()> {
|
||||
|
||||
// .git/ history.
|
||||
if let Some(tar_bytes) = &unpacked.git_archive {
|
||||
let mut archive = tar::Archive::new(tar_bytes.as_slice());
|
||||
archive.unpack(target.join(".git"))
|
||||
.with_context(|| "failed to untar .git/")?;
|
||||
// Cap: 100× the compressed bundle size, or 1 GiB, whichever is lower.
|
||||
let cap = std::cmp::min(
|
||||
(tar_bytes.len() as u64).saturating_mul(100),
|
||||
relicario_core::DEFAULT_MAX_UNCOMPRESSED,
|
||||
);
|
||||
let entries = relicario_core::safe_unpack_git_archive(tar_bytes, cap)
|
||||
.with_context(|| "failed to safely unpack .git/ archive")?;
|
||||
let git_dir = target.join(".git");
|
||||
for (rel_path, body) in entries {
|
||||
let dest = git_dir.join(&rel_path);
|
||||
// Paranoid OS-level check even after textual validation in core.
|
||||
if !dest.starts_with(&git_dir) {
|
||||
anyhow::bail!(
|
||||
"tar entry {} resolved outside .git/ (path traversal blocked)",
|
||||
rel_path.display()
|
||||
);
|
||||
}
|
||||
if let Some(parent) = dest.parent() {
|
||||
fs::create_dir_all(parent).with_context(|| {
|
||||
format!("create parent {}", parent.display())
|
||||
})?;
|
||||
}
|
||||
fs::write(&dest, &body).with_context(|| {
|
||||
format!("write {}", dest.display())
|
||||
})?;
|
||||
}
|
||||
} else {
|
||||
// No history bundled — start a fresh git repo.
|
||||
let status = crate::helpers::git_command(&target, &["init"]).status()?;
|
||||
@@ -1950,7 +1990,7 @@ fn cmd_attachments(query: String) -> Result<()> {
|
||||
let entry = resolve_query(&manifest, &query)?;
|
||||
let item = vault.load_item(&entry.id)?;
|
||||
if item.attachments.is_empty() { eprintln!("(no attachments)"); return Ok(()); }
|
||||
println!("{:<17} {:>12} {:<22} {}", "AID", "SIZE", "MIME", "FILENAME");
|
||||
println!("{:<17} {:>12} {:<22} FILENAME", "AID", "SIZE", "MIME");
|
||||
for a in &item.attachments {
|
||||
println!("{:<17} {:>12} {:<22} {}", a.id.as_str(), a.size, a.mime_type, a.filename);
|
||||
}
|
||||
@@ -2518,7 +2558,7 @@ fn cmd_device(action: DeviceAction) -> Result<()> {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
println!("{:<20} {:<20} {}", "NAME", "ADDED", "SIGNING KEY (prefix)");
|
||||
println!("{:<20} {:<20} SIGNING KEY (prefix)", "NAME", "ADDED");
|
||||
println!("{}", "-".repeat(72));
|
||||
for d in &devices {
|
||||
let marker = if d.name == current { " *" } else { "" };
|
||||
@@ -2535,3 +2575,67 @@ fn cmd_device(action: DeviceAction) -> Result<()> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn cmd_recovery_qr(cmd: RecoveryQrCmd) -> Result<()> {
|
||||
match cmd {
|
||||
RecoveryQrCmd::Generate => cmd_recovery_qr_generate(),
|
||||
RecoveryQrCmd::Unwrap => cmd_recovery_qr_unwrap(),
|
||||
}
|
||||
}
|
||||
|
||||
fn cmd_recovery_qr_generate() -> Result<()> {
|
||||
use relicario_core::{generate_recovery_qr, imgsecret};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
let image_path = crate::session::get_image_path()?;
|
||||
let image_bytes = std::fs::read(&image_path)
|
||||
.with_context(|| format!("read reference image {}", image_path.display()))?;
|
||||
let image_secret = imgsecret::extract(&image_bytes)
|
||||
.context("extract image secret")?;
|
||||
|
||||
let passphrase = Zeroizing::new(
|
||||
rpassword::prompt_password("Enter vault passphrase: ")
|
||||
.context("read passphrase")?
|
||||
);
|
||||
|
||||
let payload = generate_recovery_qr(passphrase.as_str(), &image_secret)
|
||||
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
|
||||
use qrcode::{EcLevel, QrCode, render::unicode};
|
||||
let code = QrCode::with_error_correction_level(payload.as_bytes(), EcLevel::M)
|
||||
.expect("valid payload");
|
||||
let image = code
|
||||
.render::<unicode::Dense1x2>()
|
||||
.dark_color(unicode::Dense1x2::Dark)
|
||||
.light_color(unicode::Dense1x2::Light)
|
||||
.build();
|
||||
println!("{image}");
|
||||
println!("Recovery QR generated. Print or photograph this code and store it securely.");
|
||||
println!("The QR has NOT been saved to disk.");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cmd_recovery_qr_unwrap() -> Result<()> {
|
||||
use relicario_core::unwrap_recovery_qr;
|
||||
use std::io::BufRead;
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
println!("Paste the base64 recovery QR payload and press Enter:");
|
||||
let stdin = std::io::stdin();
|
||||
let payload_b64 = stdin.lock().lines().next()
|
||||
.context("no input")??;
|
||||
let payload_b64 = payload_b64.trim().to_owned();
|
||||
|
||||
let bytes = data_encoding::BASE64.decode(payload_b64.as_bytes())
|
||||
.map_err(|e| anyhow::anyhow!("base64 decode: {e}"))?;
|
||||
|
||||
let passphrase = Zeroizing::new(
|
||||
rpassword::prompt_password("Enter passphrase: ")
|
||||
.context("read passphrase")?
|
||||
);
|
||||
|
||||
let secret = unwrap_recovery_qr(&bytes, passphrase.as_str())
|
||||
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
println!("image_secret: {}", hex::encode(secret.as_ref()));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ impl UnlockedVault {
|
||||
|
||||
let master_key = derive_master_key(
|
||||
passphrase.as_bytes(),
|
||||
&*image_secret,
|
||||
&image_secret,
|
||||
&salt,
|
||||
¶ms,
|
||||
)?;
|
||||
|
||||
@@ -68,7 +68,7 @@ fn detach_removes_attachment_and_blob() {
|
||||
// Encrypted blob file is gone.
|
||||
let blob_path = v.path()
|
||||
.join("attachments")
|
||||
.join(stdout.lines().nth(1).is_some().then_some("").unwrap_or(""));
|
||||
.join("");
|
||||
let item_attach_dir = std::fs::read_dir(v.path().join("attachments"))
|
||||
.unwrap().next().unwrap().unwrap().path();
|
||||
let blob = item_attach_dir.join(format!("{aid}.enc"));
|
||||
|
||||
@@ -78,6 +78,7 @@ impl TestVault {
|
||||
cmd.output().unwrap()
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn run_with_backup_pass(&self, args: &[&str], backup_pass: &str) -> std::process::Output {
|
||||
let mut cmd = Command::cargo_bin("relicario").unwrap();
|
||||
cmd.current_dir(self.dir.path())
|
||||
@@ -91,6 +92,7 @@ impl TestVault {
|
||||
cmd.output().unwrap()
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn run_with_input(&self, args: &[&str], extra: &[&str]) -> std::process::Output {
|
||||
let mut cmd = Command::cargo_bin("relicario").unwrap();
|
||||
cmd.current_dir(self.dir.path())
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "relicario-core"
|
||||
version = "0.2.0"
|
||||
version = "0.5.0"
|
||||
edition = "2021"
|
||||
description = "Core library for relicario password manager"
|
||||
|
||||
@@ -31,5 +31,6 @@ zstd = { version = "0.13", default-features = false }
|
||||
tar = { version = "0.4", default-features = false }
|
||||
base64 = "0.22"
|
||||
csv = "1"
|
||||
qrcode = { version = "0.14", default-features = false, features = ["svg"] }
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
@@ -243,6 +243,23 @@ pub fn derive_master_key(
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
/// Like `derive_master_key` but takes an already-assembled `input` byte slice directly,
|
||||
/// allowing callers to apply their own domain separation before KDF.
|
||||
pub fn derive_master_key_raw(
|
||||
input: &[u8],
|
||||
salt: &[u8; 32],
|
||||
params: &KdfParams,
|
||||
) -> Result<Zeroizing<[u8; 32]>> {
|
||||
let argon2_params = Params::new(params.argon2_m, params.argon2_t, params.argon2_p, Some(32))
|
||||
.map_err(|e| RelicarioError::Kdf(e.to_string()))?;
|
||||
let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, argon2_params);
|
||||
let mut output = Zeroizing::new([0u8; 32]);
|
||||
argon2
|
||||
.hash_password_into(input, salt, output.as_mut())
|
||||
.map_err(|e| RelicarioError::Kdf(e.to_string()))?;
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -408,7 +425,7 @@ mod tests {
|
||||
blob.extend_from_slice(&[0u8; 16]);
|
||||
|
||||
let key = Zeroizing::new([0u8; 32]);
|
||||
let err = decrypt(&*key, &blob).expect_err("v1 blob should fail decrypt");
|
||||
let err = decrypt(&key, &blob).expect_err("v1 blob should fail decrypt");
|
||||
match err {
|
||||
RelicarioError::UnsupportedFormatVersion { found, expected } => {
|
||||
assert_eq!(found, 0x01);
|
||||
|
||||
@@ -106,6 +106,16 @@ pub fn verify(public_key_openssh: &str, data: &[u8], signature_b64: &str) -> Res
|
||||
Ok(verifying_key.verify(data, &signature).is_ok())
|
||||
}
|
||||
|
||||
/// Compute the OpenSSH SHA-256 fingerprint of a public key.
|
||||
/// Output format matches `ssh-keygen -lf` and `git verify-commit --raw`:
|
||||
/// `SHA256:<43-char base64 without padding>`.
|
||||
pub fn fingerprint(public_key_openssh: &str) -> Result<String> {
|
||||
use ssh_key::HashAlg;
|
||||
let public = PublicKey::from_openssh(public_key_openssh)
|
||||
.map_err(|e| RelicarioError::DeviceKey(format!("parse public key: {e}")))?;
|
||||
Ok(public.fingerprint(HashAlg::Sha256).to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -132,4 +142,27 @@ mod tests {
|
||||
let sig = sign(&private, b"hello").unwrap();
|
||||
assert!(!verify(&other_public, b"hello", &sig).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fingerprint_matches_ssh_keygen_format() {
|
||||
let (_, public) = generate_keypair().unwrap();
|
||||
let fp = fingerprint(&public).unwrap();
|
||||
assert!(fp.starts_with("SHA256:"), "fingerprint should start with SHA256: prefix, got {fp}");
|
||||
let body = fp.strip_prefix("SHA256:").unwrap();
|
||||
assert_eq!(body.len(), 43, "SHA-256 fingerprint body is 43 base64 chars (no padding)");
|
||||
assert!(body.chars().all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '/'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fingerprint_is_deterministic() {
|
||||
let (_, public) = generate_keypair().unwrap();
|
||||
assert_eq!(fingerprint(&public).unwrap(), fingerprint(&public).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fingerprint_differs_per_key() {
|
||||
let (_, p1) = generate_keypair().unwrap();
|
||||
let (_, p2) = generate_keypair().unwrap();
|
||||
assert_ne!(fingerprint(&p1).unwrap(), fingerprint(&p2).unwrap());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,6 +51,10 @@ pub enum RelicarioError {
|
||||
#[error("backup envelope schema v{found}; this Relicario reads v{expected}")]
|
||||
BackupSchemaMismatch { found: u32, expected: u32 },
|
||||
|
||||
/// An error during backup restore (e.g., tar safety validation failure).
|
||||
#[error("backup restore: {0}")]
|
||||
BackupRestore(String),
|
||||
|
||||
/// CSV header doesn't match the LastPass column layout.
|
||||
#[error("unrecognized CSV header — expected LastPass export format ({0})")]
|
||||
ImportCsvHeader(String),
|
||||
@@ -115,6 +119,10 @@ pub enum RelicarioError {
|
||||
/// immediately. Use TOTP instead.
|
||||
#[error("HOTP is not supported: counter persistence requires vault save after each use")]
|
||||
HotpNotSupported,
|
||||
|
||||
/// Recovery QR generation or parsing failed.
|
||||
#[error("recovery QR: {0}")]
|
||||
RecoveryQr(String),
|
||||
}
|
||||
|
||||
/// Crate-wide result alias, reducing boilerplate in function signatures.
|
||||
|
||||
@@ -83,7 +83,7 @@ const BITS_PER_BLOCK: usize = 12; // EMBED_POSITIONS.len()
|
||||
|
||||
/// Number of 8x8 blocks needed to hold one complete copy of the 256-bit secret.
|
||||
/// ceil(256 / 12) = 22 blocks per copy.
|
||||
const BLOCKS_PER_COPY: usize = (SECRET_BITS + BITS_PER_BLOCK - 1) / BITS_PER_BLOCK; // 22
|
||||
const BLOCKS_PER_COPY: usize = SECRET_BITS.div_ceil(BITS_PER_BLOCK); // 22
|
||||
|
||||
/// Mid-frequency DCT coefficient positions for embedding, specified as
|
||||
/// (row, col) indices into the 8x8 DCT coefficient matrix.
|
||||
@@ -302,9 +302,9 @@ fn read_block_abs(y: &YChannel, px: usize, py: usize) -> Option<[[f64; 8]; 8]> {
|
||||
return None;
|
||||
}
|
||||
let mut block = [[0.0f64; 8]; 8];
|
||||
for row in 0..8 {
|
||||
for col in 0..8 {
|
||||
block[row][col] = y.get(px + col, py + row);
|
||||
for (row, block_row) in block.iter_mut().enumerate() {
|
||||
for (col, cell) in block_row.iter_mut().enumerate() {
|
||||
*cell = y.get(px + col, py + row);
|
||||
}
|
||||
}
|
||||
Some(block)
|
||||
@@ -323,9 +323,9 @@ fn read_block(y: &YChannel, bx: usize, by: usize, region: &EmbedRegion) -> [[f64
|
||||
fn write_block(y: &mut YChannel, bx: usize, by: usize, region: &EmbedRegion, block: &[[f64; 8]; 8]) {
|
||||
let start_x = region.x_offset + bx * BLOCK_SIZE;
|
||||
let start_y = region.y_offset + by * BLOCK_SIZE;
|
||||
for row in 0..8 {
|
||||
for col in 0..8 {
|
||||
y.set(start_x + col, start_y + row, block[row][col]);
|
||||
for (row, block_row) in block.iter().enumerate() {
|
||||
for (col, &cell) in block_row.iter().enumerate() {
|
||||
y.set(start_x + col, start_y + row, cell);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -349,17 +349,17 @@ fn write_block(y: &mut YChannel, bx: usize, by: usize, region: &EmbedRegion, blo
|
||||
/// where c(0) = sqrt(1/8) and c(k) = sqrt(2/8) for k > 0.
|
||||
fn dct1d(input: &[f64; 8]) -> [f64; 8] {
|
||||
let mut output = [0.0f64; 8];
|
||||
for k in 0..8 {
|
||||
for (k, out_k) in output.iter_mut().enumerate() {
|
||||
let ck = if k == 0 {
|
||||
(1.0 / 8.0_f64).sqrt()
|
||||
} else {
|
||||
(2.0 / 8.0_f64).sqrt()
|
||||
};
|
||||
let mut sum = 0.0;
|
||||
for i in 0..8 {
|
||||
sum += input[i] * ((2 * i + 1) as f64 * k as f64 * PI / 16.0).cos();
|
||||
for (i, &x) in input.iter().enumerate() {
|
||||
sum += x * ((2 * i + 1) as f64 * k as f64 * PI / 16.0).cos();
|
||||
}
|
||||
output[k] = ck * sum;
|
||||
*out_k = ck * sum;
|
||||
}
|
||||
output
|
||||
}
|
||||
@@ -370,17 +370,17 @@ fn dct1d(input: &[f64; 8]) -> [f64; 8] {
|
||||
/// x[i] = sum_{k=0}^{7} c(k) * X[k] * cos((2i+1)*k*pi/16)
|
||||
fn idct1d(input: &[f64; 8]) -> [f64; 8] {
|
||||
let mut output = [0.0f64; 8];
|
||||
for i in 0..8 {
|
||||
for (i, out_i) in output.iter_mut().enumerate() {
|
||||
let mut sum = 0.0;
|
||||
for k in 0..8 {
|
||||
for (k, &x) in input.iter().enumerate() {
|
||||
let ck = if k == 0 {
|
||||
(1.0 / 8.0_f64).sqrt()
|
||||
} else {
|
||||
(2.0 / 8.0_f64).sqrt()
|
||||
};
|
||||
sum += ck * input[k] * ((2 * i + 1) as f64 * k as f64 * PI / 16.0).cos();
|
||||
sum += ck * x * ((2 * i + 1) as f64 * k as f64 * PI / 16.0).cos();
|
||||
}
|
||||
output[i] = sum;
|
||||
*out_i = sum;
|
||||
}
|
||||
output
|
||||
}
|
||||
@@ -501,7 +501,7 @@ fn bytes_to_bits(bytes: &[u8]) -> Vec<u8> {
|
||||
///
|
||||
/// Pads the last byte with zeros if the bit count is not a multiple of 8.
|
||||
fn bits_to_bytes(bits: &[u8]) -> Vec<u8> {
|
||||
let mut bytes = Vec::with_capacity((bits.len() + 7) / 8);
|
||||
let mut bytes = Vec::with_capacity(bits.len().div_ceil(8));
|
||||
for chunk in bits.chunks(8) {
|
||||
let mut byte = 0u8;
|
||||
for (i, &bit) in chunk.iter().enumerate() {
|
||||
|
||||
@@ -52,18 +52,15 @@ pub enum TotpAlgorithm {
|
||||
Sha512,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum TotpKind {
|
||||
#[default]
|
||||
Totp,
|
||||
Hotp { counter: u64 },
|
||||
Steam,
|
||||
}
|
||||
|
||||
impl Default for TotpKind {
|
||||
fn default() -> Self { TotpKind::Totp }
|
||||
}
|
||||
|
||||
/// Compute a TOTP/Steam code for `config` at the given Unix timestamp.
|
||||
///
|
||||
/// For TOTP and Steam: counter = `now_unix_seconds / period_seconds`.
|
||||
|
||||
@@ -85,4 +85,15 @@ pub mod import_lastpass;
|
||||
pub use import_lastpass::{parse_lastpass_csv, ImportWarning};
|
||||
|
||||
pub mod device;
|
||||
pub use device::{DeviceEntry, RevokedEntry, generate_keypair, sign, verify};
|
||||
pub use device::{fingerprint, DeviceEntry, RevokedEntry, generate_keypair, sign, verify};
|
||||
|
||||
pub mod tar_safe;
|
||||
pub use tar_safe::{safe_unpack_git_archive, DEFAULT_MAX_UNCOMPRESSED};
|
||||
|
||||
pub mod recovery_qr;
|
||||
pub use recovery_qr::{
|
||||
generate_recovery_qr, generate_recovery_qr_with_params,
|
||||
recovery_qr_to_svg,
|
||||
unwrap_recovery_qr, unwrap_recovery_qr_with_params,
|
||||
RecoveryQrPayload,
|
||||
};
|
||||
|
||||
129
crates/relicario-core/src/recovery_qr.rs
Normal file
129
crates/relicario-core/src/recovery_qr.rs
Normal file
@@ -0,0 +1,129 @@
|
||||
use chacha20poly1305::{XChaCha20Poly1305, Key, KeyInit, aead::Aead};
|
||||
use rand::RngCore;
|
||||
use unicode_normalization::UnicodeNormalization;
|
||||
use zeroize::Zeroizing;
|
||||
use crate::{crypto::KdfParams, error::{RelicarioError, Result}};
|
||||
|
||||
const MAGIC: &[u8; 4] = b"RREC";
|
||||
const VERSION: u8 = 0x01;
|
||||
const PAYLOAD_LEN: usize = 4 + 1 + 32 + 24 + 48; // 109
|
||||
|
||||
pub struct RecoveryQrPayload {
|
||||
bytes: [u8; PAYLOAD_LEN],
|
||||
}
|
||||
|
||||
impl RecoveryQrPayload {
|
||||
pub fn as_bytes(&self) -> &[u8; PAYLOAD_LEN] {
|
||||
&self.bytes
|
||||
}
|
||||
}
|
||||
|
||||
fn recovery_kdf_input(passphrase: &str) -> Vec<u8> {
|
||||
let nfc: String = passphrase.nfc().collect();
|
||||
let nfc_bytes = nfc.as_bytes();
|
||||
let prefix = b"relicario-recovery-v1\0";
|
||||
let mut input = Vec::with_capacity(prefix.len() + 8 + nfc_bytes.len());
|
||||
input.extend_from_slice(prefix);
|
||||
input.extend_from_slice(&(nfc_bytes.len() as u64).to_be_bytes());
|
||||
input.extend_from_slice(nfc_bytes);
|
||||
input
|
||||
}
|
||||
|
||||
fn production_params() -> KdfParams {
|
||||
KdfParams { argon2_m: 65536, argon2_t: 3, argon2_p: 4 }
|
||||
}
|
||||
|
||||
fn derive_wrap_key(
|
||||
passphrase: &str,
|
||||
kdf_salt: &[u8; 32],
|
||||
params: &KdfParams,
|
||||
) -> Result<Zeroizing<[u8; 32]>> {
|
||||
let input = recovery_kdf_input(passphrase);
|
||||
crate::crypto::derive_master_key_raw(&input, kdf_salt, params)
|
||||
}
|
||||
|
||||
pub fn generate_recovery_qr(
|
||||
passphrase: &str,
|
||||
image_secret: &[u8; 32],
|
||||
) -> Result<RecoveryQrPayload> {
|
||||
generate_recovery_qr_with_params(passphrase, image_secret, &production_params())
|
||||
}
|
||||
|
||||
#[doc(hidden)]
|
||||
pub fn generate_recovery_qr_with_params(
|
||||
passphrase: &str,
|
||||
image_secret: &[u8; 32],
|
||||
params: &KdfParams,
|
||||
) -> Result<RecoveryQrPayload> {
|
||||
let mut kdf_salt = [0u8; 32];
|
||||
rand::rngs::OsRng.fill_bytes(&mut kdf_salt);
|
||||
|
||||
let mut wrap_nonce = [0u8; 24];
|
||||
rand::rngs::OsRng.fill_bytes(&mut wrap_nonce);
|
||||
|
||||
let wrap_key = derive_wrap_key(passphrase, &kdf_salt, params)?;
|
||||
let cipher = XChaCha20Poly1305::new(Key::from_slice(wrap_key.as_ref()));
|
||||
let nonce = chacha20poly1305::XNonce::from_slice(&wrap_nonce);
|
||||
let ciphertext = cipher.encrypt(nonce, image_secret.as_ref())
|
||||
.map_err(|_| RelicarioError::RecoveryQr("wrap encrypt failed".into()))?;
|
||||
|
||||
let mut bytes = [0u8; PAYLOAD_LEN];
|
||||
let mut pos = 0;
|
||||
bytes[pos..pos+4].copy_from_slice(MAGIC); pos += 4;
|
||||
bytes[pos] = VERSION; pos += 1;
|
||||
bytes[pos..pos+32].copy_from_slice(&kdf_salt); pos += 32;
|
||||
bytes[pos..pos+24].copy_from_slice(&wrap_nonce); pos += 24;
|
||||
bytes[pos..pos+48].copy_from_slice(&ciphertext);
|
||||
|
||||
Ok(RecoveryQrPayload { bytes })
|
||||
}
|
||||
|
||||
pub fn unwrap_recovery_qr(
|
||||
payload_bytes: &[u8],
|
||||
passphrase: &str,
|
||||
) -> Result<Zeroizing<[u8; 32]>> {
|
||||
unwrap_recovery_qr_with_params(payload_bytes, passphrase, &production_params())
|
||||
}
|
||||
|
||||
#[doc(hidden)]
|
||||
pub fn unwrap_recovery_qr_with_params(
|
||||
payload_bytes: &[u8],
|
||||
passphrase: &str,
|
||||
params: &KdfParams,
|
||||
) -> Result<Zeroizing<[u8; 32]>> {
|
||||
if payload_bytes.len() != PAYLOAD_LEN {
|
||||
return Err(RelicarioError::RecoveryQr(
|
||||
format!("payload must be {PAYLOAD_LEN} bytes, got {}", payload_bytes.len())
|
||||
));
|
||||
}
|
||||
if &payload_bytes[0..4] != MAGIC {
|
||||
return Err(RelicarioError::RecoveryQr("bad magic".into()));
|
||||
}
|
||||
if payload_bytes[4] != VERSION {
|
||||
return Err(RelicarioError::RecoveryQr(
|
||||
format!("unsupported version 0x{:02x}", payload_bytes[4])
|
||||
));
|
||||
}
|
||||
let kdf_salt: &[u8; 32] = payload_bytes[5..37].try_into().expect("slice length validated above");
|
||||
let wrap_nonce = &payload_bytes[37..61];
|
||||
let ciphertext = &payload_bytes[61..109];
|
||||
|
||||
let wrap_key = derive_wrap_key(passphrase, kdf_salt, params)?;
|
||||
let cipher = XChaCha20Poly1305::new(Key::from_slice(wrap_key.as_ref()));
|
||||
let nonce = chacha20poly1305::XNonce::from_slice(wrap_nonce);
|
||||
let plaintext = cipher.decrypt(nonce, ciphertext)
|
||||
.map_err(|_| RelicarioError::Decrypt)?;
|
||||
|
||||
let mut out = Zeroizing::new([0u8; 32]);
|
||||
out.copy_from_slice(&plaintext);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub fn recovery_qr_to_svg(payload: &RecoveryQrPayload) -> String {
|
||||
use qrcode::{QrCode, EcLevel};
|
||||
let code = QrCode::with_error_correction_level(payload.bytes.as_ref(), EcLevel::M)
|
||||
.expect("109 bytes fits well within QR v40 capacity at EcLevel::M");
|
||||
code.render::<qrcode::render::svg::Color>()
|
||||
.min_dimensions(140, 140)
|
||||
.build()
|
||||
}
|
||||
138
crates/relicario-core/src/tar_safe.rs
Normal file
138
crates/relicario-core/src/tar_safe.rs
Normal file
@@ -0,0 +1,138 @@
|
||||
//! Safe tar unpacking for backup restore.
|
||||
//!
|
||||
//! The standard `tar::Archive::unpack` has no guards against path traversal,
|
||||
//! absolute paths, symlinks, hardlinks, or tar bombs. This module replaces it
|
||||
//! with `safe_unpack_git_archive`, which validates every entry before returning
|
||||
//! `(relative_path, bytes)` pairs to the caller.
|
||||
|
||||
use std::io::Read;
|
||||
use std::path::{Component, PathBuf};
|
||||
|
||||
use tar::EntryType;
|
||||
|
||||
use crate::error::{RelicarioError, Result};
|
||||
|
||||
/// Default cap on total uncompressed bytes extracted in one restore (1 GiB).
|
||||
pub const DEFAULT_MAX_UNCOMPRESSED: u64 = 1024 * 1024 * 1024;
|
||||
|
||||
/// Decode `tar_bytes` and return `(relative_path, file_bytes)` pairs for
|
||||
/// regular files only.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns `Err(RelicarioError::BackupRestore(...))` if:
|
||||
///
|
||||
/// - Any path component is `..` (`Component::ParentDir`) — "path traversal blocked".
|
||||
/// - Any path starts with `/` (`Component::RootDir`) — "path traversal blocked".
|
||||
/// - Any path has a Windows drive prefix (`Component::Prefix`) — "path traversal blocked".
|
||||
/// - An entry is a symlink or hardlink — "symlink/link rejected".
|
||||
/// - An entry's declared size exceeds `max_uncompressed_bytes` — "size cap exceeded".
|
||||
/// - The running total of all entry sizes exceeds `max_uncompressed_bytes` — "size cap exceeded".
|
||||
/// - An entry has an unexpected type (not regular file, not directory) — "unexpected entry type".
|
||||
pub fn safe_unpack_git_archive(
|
||||
tar_bytes: &[u8],
|
||||
max_uncompressed_bytes: u64,
|
||||
) -> Result<Vec<(PathBuf, Vec<u8>)>> {
|
||||
let mut archive = tar::Archive::new(tar_bytes);
|
||||
let entries = archive
|
||||
.entries()
|
||||
.map_err(|e| RelicarioError::BackupRestore(format!("failed to read tar entries: {e}")))?;
|
||||
|
||||
let mut result: Vec<(PathBuf, Vec<u8>)> = Vec::new();
|
||||
let mut cumulative: u64 = 0;
|
||||
|
||||
for entry in entries {
|
||||
let mut entry = entry.map_err(|e| {
|
||||
RelicarioError::BackupRestore(format!("failed to read tar entry: {e}"))
|
||||
})?;
|
||||
|
||||
let header = entry.header();
|
||||
let entry_type = header.entry_type();
|
||||
|
||||
// Reject symlinks and hardlinks.
|
||||
match entry_type {
|
||||
EntryType::Symlink => {
|
||||
return Err(RelicarioError::BackupRestore(
|
||||
"symlink entry rejected".to_string(),
|
||||
));
|
||||
}
|
||||
EntryType::Link => {
|
||||
return Err(RelicarioError::BackupRestore(
|
||||
"hardlink entry rejected".to_string(),
|
||||
));
|
||||
}
|
||||
EntryType::Directory => {
|
||||
// Directories are implicit — skip without reading body.
|
||||
continue;
|
||||
}
|
||||
EntryType::Regular | EntryType::Continuous | EntryType::GNUSparse => {
|
||||
// These are normal file types; fall through to path checks.
|
||||
}
|
||||
_ => {
|
||||
return Err(RelicarioError::BackupRestore(format!(
|
||||
"unexpected entry type: {:?}",
|
||||
entry_type
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
// Validate the path.
|
||||
let path = entry.path().map_err(|e| {
|
||||
RelicarioError::BackupRestore(format!("invalid path in tar entry: {e}"))
|
||||
})?;
|
||||
let path = path.into_owned();
|
||||
|
||||
for component in path.components() {
|
||||
match component {
|
||||
Component::ParentDir => {
|
||||
return Err(RelicarioError::BackupRestore(
|
||||
"path traversal blocked: entry contains '..' component".to_string(),
|
||||
));
|
||||
}
|
||||
Component::RootDir => {
|
||||
return Err(RelicarioError::BackupRestore(
|
||||
"path traversal blocked: entry has absolute path".to_string(),
|
||||
));
|
||||
}
|
||||
Component::Prefix(_) => {
|
||||
return Err(RelicarioError::BackupRestore(
|
||||
"path traversal blocked: entry has Windows drive prefix".to_string(),
|
||||
));
|
||||
}
|
||||
Component::Normal(_) | Component::CurDir => {
|
||||
// Acceptable components.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check declared size before reading body.
|
||||
let claimed = header.size().map_err(|e| {
|
||||
RelicarioError::BackupRestore(format!("could not read entry size: {e}"))
|
||||
})?;
|
||||
|
||||
if claimed > max_uncompressed_bytes {
|
||||
return Err(RelicarioError::BackupRestore(format!(
|
||||
"size cap exceeded: entry claims {claimed} bytes (cap {max_uncompressed_bytes})"
|
||||
)));
|
||||
}
|
||||
|
||||
let new_total = cumulative.saturating_add(claimed);
|
||||
if new_total > max_uncompressed_bytes {
|
||||
return Err(RelicarioError::BackupRestore(format!(
|
||||
"size cap exceeded: cumulative size would reach {new_total} bytes (cap {max_uncompressed_bytes})"
|
||||
)));
|
||||
}
|
||||
|
||||
// Read the file body.
|
||||
let mut body = Vec::with_capacity(claimed as usize);
|
||||
entry.read_to_end(&mut body).map_err(|e| {
|
||||
RelicarioError::BackupRestore(format!("failed to read entry body: {e}"))
|
||||
})?;
|
||||
|
||||
cumulative += body.len() as u64;
|
||||
|
||||
result.push((path, body));
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
@@ -19,7 +19,7 @@ impl MonthYear {
|
||||
if !(1..=12).contains(&month) {
|
||||
return Err("month must be 1..=12");
|
||||
}
|
||||
if year < 2000 || year > 2099 {
|
||||
if !(2000..=2099).contains(&year) {
|
||||
return Err("year must be 2000..=2099");
|
||||
}
|
||||
Ok(Self { month, year })
|
||||
|
||||
60
crates/relicario-core/tests/recovery_qr.rs
Normal file
60
crates/relicario-core/tests/recovery_qr.rs
Normal file
@@ -0,0 +1,60 @@
|
||||
use relicario_core::{
|
||||
crypto::KdfParams,
|
||||
generate_recovery_qr_with_params, recovery_qr_to_svg, unwrap_recovery_qr_with_params,
|
||||
};
|
||||
|
||||
fn fast_params() -> KdfParams {
|
||||
KdfParams { argon2_m: 256, argon2_t: 1, argon2_p: 1 }
|
||||
}
|
||||
|
||||
fn test_secret() -> [u8; 32] {
|
||||
let mut s = [0u8; 32];
|
||||
for (i, b) in s.iter_mut().enumerate() { *b = i as u8; }
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roundtrip_recovers_image_secret() {
|
||||
let passphrase = "correct-horse-battery-staple";
|
||||
let secret = test_secret();
|
||||
let payload = generate_recovery_qr_with_params(passphrase, &secret, &fast_params())
|
||||
.expect("generate ok");
|
||||
let recovered = unwrap_recovery_qr_with_params(payload.as_bytes(), passphrase, &fast_params())
|
||||
.expect("unwrap ok");
|
||||
assert_eq!(recovered.as_ref(), &secret);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrong_passphrase_fails_decrypt() {
|
||||
let secret = test_secret();
|
||||
let payload = generate_recovery_qr_with_params("right-pass", &secret, &fast_params())
|
||||
.expect("generate ok");
|
||||
let result = unwrap_recovery_qr_with_params(payload.as_bytes(), "wrong-pass", &fast_params());
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn payload_is_109_bytes() {
|
||||
let secret = test_secret();
|
||||
let payload = generate_recovery_qr_with_params("test", &secret, &fast_params())
|
||||
.expect("generate ok");
|
||||
assert_eq!(payload.as_bytes().len(), 109);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn svg_output_is_non_empty_xml() {
|
||||
let secret = test_secret();
|
||||
let payload = generate_recovery_qr_with_params("test", &secret, &fast_params())
|
||||
.expect("generate ok");
|
||||
let svg = recovery_qr_to_svg(&payload);
|
||||
assert!(svg.contains("<svg"), "SVG output should contain <svg tag");
|
||||
assert!(!svg.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_magic_returns_error() {
|
||||
let mut bad = [0u8; 109];
|
||||
bad[0..4].copy_from_slice(b"NOPE");
|
||||
let result = unwrap_recovery_qr_with_params(&bad, "pass", &fast_params());
|
||||
assert!(result.is_err());
|
||||
}
|
||||
187
crates/relicario-core/tests/safe_unpack.rs
Normal file
187
crates/relicario-core/tests/safe_unpack.rs
Normal file
@@ -0,0 +1,187 @@
|
||||
use std::path::PathBuf;
|
||||
use tar::{Builder, Header, EntryType};
|
||||
use relicario_core::safe_unpack_git_archive;
|
||||
|
||||
/// Craft a raw POSIX ustar tar with a single entry using the given raw path bytes.
|
||||
/// The tar crate's `Builder` sanitises paths, so we write the 512-byte header
|
||||
/// manually to produce truly malicious archives.
|
||||
fn raw_tar_with_path(raw_path: &[u8], content: &[u8]) -> Vec<u8> {
|
||||
let mut buf = vec![0u8; 512]; // one header block
|
||||
|
||||
// Bytes 0-99: name field (null-padded)
|
||||
let name_len = raw_path.len().min(100);
|
||||
buf[..name_len].copy_from_slice(&raw_path[..name_len]);
|
||||
|
||||
// Bytes 100-107: mode = "0000644\0"
|
||||
buf[100..108].copy_from_slice(b"0000644\0");
|
||||
|
||||
// Bytes 108-115: uid
|
||||
buf[108..116].copy_from_slice(b"0000000\0");
|
||||
|
||||
// Bytes 116-123: gid
|
||||
buf[116..124].copy_from_slice(b"0000000\0");
|
||||
|
||||
// Bytes 124-135: size (octal, 11 digits + null)
|
||||
let size_str = format!("{:011o}\0", content.len());
|
||||
buf[124..136].copy_from_slice(size_str.as_bytes());
|
||||
|
||||
// Bytes 136-147: mtime
|
||||
buf[136..148].copy_from_slice(b"00000000000\0");
|
||||
|
||||
// Bytes 148-155: checksum placeholder (spaces during compute)
|
||||
buf[148..156].copy_from_slice(b" ");
|
||||
|
||||
// Byte 156: typeflag = '0' (regular file)
|
||||
buf[156] = b'0';
|
||||
|
||||
// Bytes 257-262: magic "ustar\0"
|
||||
buf[257..263].copy_from_slice(b"ustar\0");
|
||||
// Bytes 263-264: version "00"
|
||||
buf[263..265].copy_from_slice(b"00");
|
||||
|
||||
// Compute checksum (sum of all bytes, checksum field treated as spaces).
|
||||
let checksum: u32 = buf.iter().map(|&b| b as u32).sum();
|
||||
let cksum_str = format!("{:06o}\0 ", checksum);
|
||||
buf[148..156].copy_from_slice(cksum_str.as_bytes());
|
||||
|
||||
// Append padded content blocks.
|
||||
let mut out = buf;
|
||||
if !content.is_empty() {
|
||||
out.extend_from_slice(content);
|
||||
// Pad to 512-byte boundary.
|
||||
let remainder = content.len() % 512;
|
||||
if remainder != 0 {
|
||||
out.extend(vec![0u8; 512 - remainder]);
|
||||
}
|
||||
}
|
||||
|
||||
// Two zero blocks = end-of-archive.
|
||||
out.extend(vec![0u8; 1024]);
|
||||
out
|
||||
}
|
||||
|
||||
/// Build a tar with a raw symlink entry (typeflag = '2').
|
||||
fn raw_symlink_tar() -> Vec<u8> {
|
||||
let mut buf = vec![0u8; 512];
|
||||
|
||||
// name
|
||||
buf[..9].copy_from_slice(b"evil_link");
|
||||
// mode
|
||||
buf[100..108].copy_from_slice(b"0000755\0");
|
||||
// uid/gid
|
||||
buf[108..116].copy_from_slice(b"0000000\0");
|
||||
buf[116..124].copy_from_slice(b"0000000\0");
|
||||
// size = 0
|
||||
buf[124..136].copy_from_slice(b"00000000000\0");
|
||||
// mtime
|
||||
buf[136..148].copy_from_slice(b"00000000000\0");
|
||||
// checksum placeholder
|
||||
buf[148..156].copy_from_slice(b" ");
|
||||
// typeflag = '2' (symlink)
|
||||
buf[156] = b'2';
|
||||
// linkname
|
||||
let target = b"/etc/passwd";
|
||||
buf[157..157 + target.len()].copy_from_slice(target);
|
||||
// magic
|
||||
buf[257..263].copy_from_slice(b"ustar\0");
|
||||
buf[263..265].copy_from_slice(b"00");
|
||||
|
||||
// Compute checksum.
|
||||
let checksum: u32 = buf.iter().map(|&b| b as u32).sum();
|
||||
let cksum_str = format!("{:06o}\0 ", checksum);
|
||||
buf[148..156].copy_from_slice(cksum_str.as_bytes());
|
||||
|
||||
let mut out = buf;
|
||||
out.extend(vec![0u8; 1024]); // end-of-archive
|
||||
out
|
||||
}
|
||||
|
||||
fn build_normal_tar() -> Vec<u8> {
|
||||
let mut buf = Vec::new();
|
||||
{
|
||||
let mut builder = Builder::new(&mut buf);
|
||||
let content = b"hello";
|
||||
let mut header = Header::new_gnu();
|
||||
header.set_entry_type(EntryType::Regular);
|
||||
header.set_size(content.len() as u64);
|
||||
header.set_cksum();
|
||||
builder
|
||||
.append_data(&mut header, "subdir/hello.txt", content.as_ref())
|
||||
.unwrap();
|
||||
builder.finish().unwrap();
|
||||
}
|
||||
buf
|
||||
}
|
||||
|
||||
fn build_oversize_tar() -> Vec<u8> {
|
||||
// Actual 2048-byte body; test will use cap=1024
|
||||
let mut buf = Vec::new();
|
||||
{
|
||||
let mut builder = Builder::new(&mut buf);
|
||||
let content = vec![0u8; 2048];
|
||||
let mut header = Header::new_gnu();
|
||||
header.set_entry_type(EntryType::Regular);
|
||||
header.set_size(content.len() as u64);
|
||||
header.set_cksum();
|
||||
builder
|
||||
.append_data(&mut header, "bigfile.bin", content.as_slice())
|
||||
.unwrap();
|
||||
builder.finish().unwrap();
|
||||
}
|
||||
buf
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restore_rejects_path_traversal() {
|
||||
// Craft a tar with "../../escaped.txt" using raw bytes (Builder sanitises paths).
|
||||
let bytes = raw_tar_with_path(b"../../escaped.txt", b"evil content");
|
||||
let err = safe_unpack_git_archive(&bytes, 1024 * 1024).unwrap_err();
|
||||
let msg = format!("{err:#}");
|
||||
assert!(
|
||||
msg.contains("path traversal") || msg.contains(".."),
|
||||
"got: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restore_rejects_absolute_path() {
|
||||
// Craft a tar with "/etc/escaped.txt" using raw bytes.
|
||||
let bytes = raw_tar_with_path(b"/etc/escaped.txt", b"evil content");
|
||||
let err = safe_unpack_git_archive(&bytes, 1024 * 1024).unwrap_err();
|
||||
let msg = format!("{err:#}");
|
||||
assert!(
|
||||
msg.contains("path traversal") || msg.contains("absolute"),
|
||||
"got: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restore_rejects_symlink() {
|
||||
let bytes = raw_symlink_tar();
|
||||
let err = safe_unpack_git_archive(&bytes, 1024 * 1024).unwrap_err();
|
||||
let msg = format!("{err:#}");
|
||||
assert!(
|
||||
msg.contains("symlink") || msg.contains("link"),
|
||||
"got: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restore_rejects_size_bomb() {
|
||||
let bytes = build_oversize_tar(); // actual 2048-byte entry
|
||||
let err = safe_unpack_git_archive(&bytes, 1024).unwrap_err(); // cap = 1024 bytes
|
||||
let msg = format!("{err:#}");
|
||||
assert!(
|
||||
msg.contains("size") || msg.contains("cap") || msg.contains("too large"),
|
||||
"got: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restore_accepts_normal_files() {
|
||||
let buf = build_normal_tar();
|
||||
let entries = safe_unpack_git_archive(&buf, 1024 * 1024).expect("happy path");
|
||||
assert_eq!(entries.len(), 1);
|
||||
assert_eq!(entries[0].0, PathBuf::from("subdir/hello.txt"));
|
||||
assert_eq!(entries[0].1, b"hello");
|
||||
}
|
||||
@@ -9,3 +9,10 @@ anyhow = "1"
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tempfile = "3"
|
||||
regex = "1"
|
||||
|
||||
[dev-dependencies]
|
||||
assert_cmd = "2"
|
||||
predicates = "3"
|
||||
tempfile = "3"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! relicario-server -- pre-receive hook for signature verification.
|
||||
|
||||
use std::fs;
|
||||
use std::process::Command;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
@@ -34,49 +35,120 @@ fn main() -> Result<()> {
|
||||
}
|
||||
|
||||
fn verify_commit(commit: &str) -> Result<()> {
|
||||
// Get devices.json at this commit
|
||||
let devices_json = match git_show(commit, ".relicario/devices.json") {
|
||||
Ok(json) => json,
|
||||
Err(_) => {
|
||||
// No devices.json yet -- bootstrap mode, allow unsigned
|
||||
eprintln!("OK: commit {} (bootstrap - no devices.json)", commit);
|
||||
eprintln!("OK: commit {commit} (bootstrap - no devices.json)");
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
let devices: Vec<DeviceEntry> = serde_json::from_str(&devices_json)
|
||||
.context("parse devices.json")?;
|
||||
|
||||
// Bootstrap: if devices.json is empty, allow unsigned
|
||||
if devices.is_empty() {
|
||||
eprintln!("OK: commit {} (bootstrap - empty devices.json)", commit);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Get revoked.json (may not exist)
|
||||
let revoked: Vec<RevokedEntry> = git_show(commit, ".relicario/revoked.json")
|
||||
.ok()
|
||||
.and_then(|s| serde_json::from_str(&s).ok())
|
||||
.unwrap_or_default();
|
||||
|
||||
// Get commit signature
|
||||
// True bootstrap: no devices ever registered and none revoked.
|
||||
if devices.is_empty() && revoked.is_empty() {
|
||||
eprintln!("OK: commit {commit} (bootstrap - no devices registered)");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Build temp allowed-signers file from registered devices.
|
||||
let tmp = tempfile::tempdir().context("create tempdir")?;
|
||||
let allowed_path = tmp.path().join("allowed_signers");
|
||||
let mut allowed_body = String::new();
|
||||
for d in &devices {
|
||||
allowed_body.push_str("relicario ");
|
||||
allowed_body.push_str(d.public_key.trim());
|
||||
allowed_body.push('\n');
|
||||
}
|
||||
fs::write(&allowed_path, &allowed_body).context("write allowed_signers")?;
|
||||
|
||||
// Run git verify-commit --raw. Capture both exit code and stderr.
|
||||
// NOTE: we do NOT short-circuit on non-zero exit here because even for
|
||||
// unregistered keys git still outputs "Good ... key SHA256:..." on stderr.
|
||||
let output = Command::new("git")
|
||||
.args(["verify-commit", "--raw", commit])
|
||||
.env("GIT_CONFIG_COUNT", "1")
|
||||
.env("GIT_CONFIG_KEY_0", "gpg.ssh.allowedSignersFile")
|
||||
.env("GIT_CONFIG_VALUE_0", allowed_path.as_os_str())
|
||||
.output()
|
||||
.context("git verify-commit")?;
|
||||
|
||||
// Check if signed
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
if !stderr.contains("GOODSIG") && !stderr.contains("Good signature") {
|
||||
eprintln!("REJECT: commit {} is not signed by a registered device", commit);
|
||||
|
||||
// Parse the SHA-256 fingerprint from stderr.
|
||||
// SSH signature output: "Good "git" signature ... with ED25519 key SHA256:<base64>"
|
||||
let re = regex::Regex::new(r"key (SHA256:[A-Za-z0-9+/]+)").expect("static regex");
|
||||
let signing_fp = match re.captures(&stderr).and_then(|c| c.get(1)) {
|
||||
Some(m) => m.as_str().to_string(),
|
||||
None => {
|
||||
// No fingerprint in stderr = unsigned or completely malformed signature.
|
||||
eprintln!(
|
||||
"REJECT: commit {commit} — no valid signature found (stderr: {})",
|
||||
stderr.trim()
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
// Build fingerprint → entry maps.
|
||||
let mut device_by_fp: std::collections::HashMap<String, &DeviceEntry> =
|
||||
std::collections::HashMap::new();
|
||||
for d in &devices {
|
||||
if let Ok(fp) = relicario_core::device::fingerprint(&d.public_key) {
|
||||
device_by_fp.insert(fp, d);
|
||||
}
|
||||
}
|
||||
|
||||
let mut revoked_by_fp: std::collections::HashMap<String, &RevokedEntry> =
|
||||
std::collections::HashMap::new();
|
||||
for r in &revoked {
|
||||
if let Ok(fp) = relicario_core::device::fingerprint(&r.public_key) {
|
||||
revoked_by_fp.insert(fp, r);
|
||||
}
|
||||
}
|
||||
|
||||
// Get committer date (NOT author date).
|
||||
let ct_out = Command::new("git")
|
||||
.args(["show", "-s", "--format=%ct", commit])
|
||||
.output()
|
||||
.context("git show committer date")?;
|
||||
let committer_ts: i64 = String::from_utf8_lossy(&ct_out.stdout)
|
||||
.trim()
|
||||
.parse()
|
||||
.context("parse committer timestamp")?;
|
||||
|
||||
// Check revocation FIRST (revoked entries may not be in devices anymore).
|
||||
if let Some(r) = revoked_by_fp.get(&signing_fp) {
|
||||
if committer_ts >= r.revoked_at {
|
||||
eprintln!(
|
||||
"REJECT: commit {commit} — signed by revoked device '{}' \
|
||||
(committer ts {committer_ts} >= revoked_at {})",
|
||||
r.name, r.revoked_at
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
// Historical commit: committer_ts < revoked_at → was valid when signed.
|
||||
eprintln!(
|
||||
"OK: commit {commit} — historical commit signed by '{}' before revocation",
|
||||
r.name
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Not revoked — must be in active devices.
|
||||
if !device_by_fp.contains_key(&signing_fp) {
|
||||
eprintln!(
|
||||
"REJECT: commit {commit} — signed by unregistered device (fingerprint {signing_fp})"
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
// Ensure the signing key is not revoked.
|
||||
// The allowed-signers file approach means git verify-commit already checks
|
||||
// against the list; we additionally guard against revoked.json entries.
|
||||
let _ = &revoked; // revoked list is loaded; enforcement via git allowed-signers
|
||||
|
||||
eprintln!("OK: commit {} verified", commit);
|
||||
eprintln!("OK: commit {commit} verified (signed by '{}')", device_by_fp[&signing_fp].name);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
230
crates/relicario-server/tests/verify_commit.rs
Normal file
230
crates/relicario-server/tests/verify_commit.rs
Normal file
@@ -0,0 +1,230 @@
|
||||
//! Acceptance tests for `relicario-server verify-commit`.
|
||||
//!
|
||||
//! Four scenarios from audit S1:
|
||||
//! 1. Registered non-revoked key → exit 0
|
||||
//! 2. Unregistered key → exit 1 (stderr contains "unregistered")
|
||||
//! 3. Revoked key, commit AFTER revoked_at → exit 1 (stderr contains "revoked")
|
||||
//! 4. Revoked key, commit BEFORE revoked_at (historical) → exit 0
|
||||
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
use assert_cmd::Command as AssertCommand;
|
||||
use predicates::prelude::*;
|
||||
use relicario_core::device::{generate_keypair, DeviceEntry, RevokedEntry};
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn write_keypair(dir: &Path, name: &str) -> (PathBuf, PathBuf, String) {
|
||||
let (priv_pem, pub_line) = generate_keypair().expect("generate keypair");
|
||||
let priv_path = dir.join(format!("{name}.key"));
|
||||
let pub_path = dir.join(format!("{name}.pub"));
|
||||
fs::write(&priv_path, priv_pem.as_str()).unwrap();
|
||||
fs::write(&pub_path, &pub_line).unwrap();
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
fs::set_permissions(&priv_path, fs::Permissions::from_mode(0o600)).unwrap();
|
||||
}
|
||||
(priv_path, pub_path, pub_line)
|
||||
}
|
||||
|
||||
fn git(repo: &Path, args: &[&str], extra_env: &[(&str, &str)]) {
|
||||
let mut cmd = Command::new("git");
|
||||
cmd.current_dir(repo).args(args);
|
||||
for (k, v) in extra_env {
|
||||
cmd.env(k, v);
|
||||
}
|
||||
let status = cmd.status().expect("spawn git");
|
||||
assert!(status.success(), "git {args:?} failed");
|
||||
}
|
||||
|
||||
fn init_repo(repo: &Path) {
|
||||
git(repo, &["init", "-q", "-b", "main"], &[]);
|
||||
git(repo, &["config", "user.email", "test@test"], &[]);
|
||||
git(repo, &["config", "user.name", "test"], &[]);
|
||||
git(repo, &["commit", "--allow-empty", "-q", "-m", "init"], &[]);
|
||||
}
|
||||
|
||||
fn sign_commit(
|
||||
repo: &Path,
|
||||
signing_key: &Path,
|
||||
allowed_signers: &Path,
|
||||
committer_unix: i64,
|
||||
msg: &str,
|
||||
file_path: &str,
|
||||
file_content: &str,
|
||||
) -> String {
|
||||
fs::write(repo.join(file_path), file_content).unwrap();
|
||||
git(repo, &["add", file_path], &[]);
|
||||
let date = format!("@{committer_unix} +0000");
|
||||
git(
|
||||
repo,
|
||||
&[
|
||||
"-c", "gpg.format=ssh",
|
||||
"-c", &format!("user.signingkey={}", signing_key.display()),
|
||||
"-c", &format!("gpg.ssh.allowedSignersFile={}", allowed_signers.display()),
|
||||
"commit", "-S", "-q", "-m", msg,
|
||||
],
|
||||
&[
|
||||
("GIT_AUTHOR_DATE", &date),
|
||||
("GIT_COMMITTER_DATE", &date),
|
||||
],
|
||||
);
|
||||
let out = Command::new("git")
|
||||
.current_dir(repo)
|
||||
.args(["rev-parse", "HEAD"])
|
||||
.output()
|
||||
.unwrap();
|
||||
String::from_utf8(out.stdout).unwrap().trim().to_string()
|
||||
}
|
||||
|
||||
fn write_device_files(repo: &Path, devices: &[DeviceEntry], revoked: &[RevokedEntry]) {
|
||||
let dir = repo.join(".relicario");
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
fs::write(dir.join("devices.json"), serde_json::to_string_pretty(devices).unwrap()).unwrap();
|
||||
fs::write(dir.join("revoked.json"), serde_json::to_string_pretty(revoked).unwrap()).unwrap();
|
||||
git(repo, &["add", ".relicario"], &[]);
|
||||
git(repo, &["commit", "-q", "-m", "device files"], &[]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registered_non_revoked_key_accepted() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let repo = tmp.path();
|
||||
init_repo(repo);
|
||||
|
||||
let (priv_a, _, pub_a) = write_keypair(repo, "alice");
|
||||
write_device_files(
|
||||
repo,
|
||||
&[DeviceEntry {
|
||||
name: "alice".into(),
|
||||
public_key: pub_a.clone(),
|
||||
added_at: 1_700_000_000,
|
||||
added_by: "bootstrap".into(),
|
||||
}],
|
||||
&[],
|
||||
);
|
||||
|
||||
let allowed = repo.join("test_allowed_signers");
|
||||
fs::write(&allowed, format!("relicario {}\n", pub_a.trim())).unwrap();
|
||||
|
||||
let sha = sign_commit(repo, &priv_a, &allowed, 1_710_000_000, "x", "a.txt", "hi");
|
||||
|
||||
AssertCommand::cargo_bin("relicario-server")
|
||||
.unwrap()
|
||||
.current_dir(repo)
|
||||
.args(["verify-commit", &sha])
|
||||
.assert()
|
||||
.success();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unregistered_key_rejected() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let repo = tmp.path();
|
||||
init_repo(repo);
|
||||
|
||||
let (_, _, pub_a) = write_keypair(repo, "alice");
|
||||
let (priv_evil, _, pub_evil) = write_keypair(repo, "evil");
|
||||
|
||||
// Only Alice is registered.
|
||||
write_device_files(
|
||||
repo,
|
||||
&[DeviceEntry {
|
||||
name: "alice".into(),
|
||||
public_key: pub_a.clone(),
|
||||
added_at: 1_700_000_000,
|
||||
added_by: "bootstrap".into(),
|
||||
}],
|
||||
&[],
|
||||
);
|
||||
|
||||
// Evil signs against a file containing both keys so git commit signing works,
|
||||
// but the binary's allowed-signers (from devices.json) only has Alice.
|
||||
let allowed = repo.join("test_allowed_signers");
|
||||
fs::write(
|
||||
&allowed,
|
||||
format!("relicario {}\nrelicario {}\n", pub_a.trim(), pub_evil.trim()),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let sha = sign_commit(repo, &priv_evil, &allowed, 1_710_000_000, "evil", "a.txt", "hi");
|
||||
|
||||
AssertCommand::cargo_bin("relicario-server")
|
||||
.unwrap()
|
||||
.current_dir(repo)
|
||||
.args(["verify-commit", &sha])
|
||||
.assert()
|
||||
.failure()
|
||||
.stderr(predicate::str::contains("unregistered"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn revoked_key_after_revoked_at_rejected() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let repo = tmp.path();
|
||||
init_repo(repo);
|
||||
|
||||
let (priv_a, _, pub_a) = write_keypair(repo, "alice");
|
||||
|
||||
// Alice's entry is only in revoked.json (was removed from devices.json after revocation).
|
||||
write_device_files(
|
||||
repo,
|
||||
&[],
|
||||
&[RevokedEntry {
|
||||
name: "alice".into(),
|
||||
public_key: pub_a.clone(),
|
||||
revoked_at: 1_705_000_000,
|
||||
revoked_by: "admin".into(),
|
||||
}],
|
||||
);
|
||||
|
||||
let allowed = repo.join("test_allowed_signers");
|
||||
fs::write(&allowed, format!("relicario {}\n", pub_a.trim())).unwrap();
|
||||
|
||||
// Commit dated AFTER revocation.
|
||||
let sha = sign_commit(repo, &priv_a, &allowed, 1_710_000_000, "post", "a.txt", "hi");
|
||||
|
||||
AssertCommand::cargo_bin("relicario-server")
|
||||
.unwrap()
|
||||
.current_dir(repo)
|
||||
.args(["verify-commit", &sha])
|
||||
.assert()
|
||||
.failure()
|
||||
.stderr(predicate::str::contains("revoked"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn revoked_key_before_revoked_at_accepted_historical() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let repo = tmp.path();
|
||||
init_repo(repo);
|
||||
|
||||
let (priv_a, _, pub_a) = write_keypair(repo, "alice");
|
||||
|
||||
// Same as above: Alice only in revoked.json.
|
||||
write_device_files(
|
||||
repo,
|
||||
&[],
|
||||
&[RevokedEntry {
|
||||
name: "alice".into(),
|
||||
public_key: pub_a.clone(),
|
||||
revoked_at: 1_705_000_000,
|
||||
revoked_by: "admin".into(),
|
||||
}],
|
||||
);
|
||||
|
||||
let allowed = repo.join("test_allowed_signers");
|
||||
fs::write(&allowed, format!("relicario {}\n", pub_a.trim())).unwrap();
|
||||
|
||||
// Commit dated BEFORE revocation -- historical case must pass.
|
||||
let sha = sign_commit(repo, &priv_a, &allowed, 1_700_000_000, "historical", "a.txt", "hi");
|
||||
|
||||
AssertCommand::cargo_bin("relicario-server")
|
||||
.unwrap()
|
||||
.current_dir(repo)
|
||||
.args(["verify-commit", &sha])
|
||||
.assert()
|
||||
.success();
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "relicario-wasm"
|
||||
version = "0.2.0"
|
||||
version = "0.5.0"
|
||||
edition = "2021"
|
||||
description = "WASM bindings for relicario password manager"
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ mod session;
|
||||
mod device;
|
||||
|
||||
use wasm_bindgen::prelude::*;
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
use relicario_core::{derive_master_key, imgsecret, KdfParams};
|
||||
|
||||
@@ -36,7 +37,8 @@ pub fn unlock(
|
||||
.map_err(|_| JsError::new("salt must be exactly 32 bytes"))?;
|
||||
let master_key = derive_master_key(passphrase.as_bytes(), &image_secret, salt_arr, ¶ms)
|
||||
.map_err(|e| JsError::new(&e.to_string()))?;
|
||||
let handle = session::insert(master_key);
|
||||
let stored_secret = Zeroizing::new(image_secret);
|
||||
let handle = session::insert(master_key, stored_secret);
|
||||
Ok(SessionHandle(handle))
|
||||
}
|
||||
|
||||
@@ -484,6 +486,39 @@ pub fn parse_lastpass_csv_json(csv_bytes: &[u8]) -> Result<String, JsError> {
|
||||
Ok(json.to_string())
|
||||
}
|
||||
|
||||
// ── Recovery QR bindings ─────────────────────────────────────────────────────
|
||||
|
||||
use relicario_core::{generate_recovery_qr, recovery_qr_to_svg, unwrap_recovery_qr};
|
||||
|
||||
/// Generate a recovery QR SVG for the current session.
|
||||
/// Returns the SVG string. The passphrase wraps the image_secret under a
|
||||
/// separate key (domain-separated from the master key derivation).
|
||||
#[wasm_bindgen]
|
||||
pub fn wasm_generate_recovery_qr(
|
||||
handle: &SessionHandle,
|
||||
passphrase: &str,
|
||||
) -> Result<String, JsError> {
|
||||
let payload = session::with_image_secret(handle.0, |s| generate_recovery_qr(passphrase, s))
|
||||
.ok_or_else(|| JsError::new("invalid or locked session handle"))?
|
||||
.map_err(|e| JsError::new(&e.to_string()))?;
|
||||
Ok(recovery_qr_to_svg(&payload))
|
||||
}
|
||||
|
||||
/// Unwrap a recovery QR payload (base64-encoded 109-byte blob) using the passphrase.
|
||||
/// Returns the raw image_secret bytes (32 bytes).
|
||||
#[wasm_bindgen]
|
||||
pub fn wasm_unwrap_recovery_qr(
|
||||
payload_b64: &str,
|
||||
passphrase: &str,
|
||||
) -> Result<Vec<u8>, JsError> {
|
||||
use base64::{engine::general_purpose::STANDARD, Engine};
|
||||
let bytes = STANDARD.decode(payload_b64)
|
||||
.map_err(|e| JsError::new(&format!("base64: {e}")))?;
|
||||
let recovered = unwrap_recovery_qr(&bytes, passphrase)
|
||||
.map_err(|e| JsError::new(&e.to_string()))?;
|
||||
Ok(recovered.to_vec())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod session_tests {
|
||||
use super::*;
|
||||
@@ -492,7 +527,7 @@ mod session_tests {
|
||||
#[test]
|
||||
fn insert_then_remove_clears_entry() {
|
||||
session::clear();
|
||||
let h = session::insert(Zeroizing::new([0x11u8; 32]));
|
||||
let h = session::insert(Zeroizing::new([0x11u8; 32]), Zeroizing::new([0u8; 32]));
|
||||
assert_ne!(h, 0);
|
||||
assert!(session::remove(h));
|
||||
assert!(!session::remove(h)); // second remove false
|
||||
@@ -501,7 +536,7 @@ mod session_tests {
|
||||
#[test]
|
||||
fn with_yields_key_only_while_session_lives() {
|
||||
session::clear();
|
||||
let h = session::insert(Zeroizing::new([0x22u8; 32]));
|
||||
let h = session::insert(Zeroizing::new([0x22u8; 32]), Zeroizing::new([0u8; 32]));
|
||||
let byte = session::with(h, |k| k[0]);
|
||||
assert_eq!(byte, Some(0x22));
|
||||
session::remove(h);
|
||||
@@ -513,7 +548,7 @@ mod session_tests {
|
||||
fn manifest_round_trip_via_handle() {
|
||||
use relicario_core::{Manifest, decrypt_manifest};
|
||||
session::clear();
|
||||
let h = session::insert(Zeroizing::new([0x55u8; 32]));
|
||||
let h = session::insert(Zeroizing::new([0x55u8; 32]), Zeroizing::new([0u8; 32]));
|
||||
let handle = SessionHandle(h);
|
||||
let key = Zeroizing::new([0x55u8; 32]);
|
||||
let empty = Manifest::new();
|
||||
|
||||
@@ -6,12 +6,17 @@ use std::cell::RefCell;
|
||||
use std::collections::HashMap;
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
pub struct SessionData {
|
||||
pub master_key: Zeroizing<[u8; 32]>,
|
||||
pub image_secret: Zeroizing<[u8; 32]>,
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static SESSIONS: RefCell<HashMap<u32, Zeroizing<[u8; 32]>>> = RefCell::new(HashMap::new());
|
||||
static SESSIONS: RefCell<HashMap<u32, SessionData>> = RefCell::new(HashMap::new());
|
||||
static NEXT_HANDLE: RefCell<u32> = const { RefCell::new(1) };
|
||||
}
|
||||
|
||||
pub fn insert(key: Zeroizing<[u8; 32]>) -> u32 {
|
||||
pub fn insert(master_key: Zeroizing<[u8; 32]>, image_secret: Zeroizing<[u8; 32]>) -> u32 {
|
||||
let handle = NEXT_HANDLE.with(|n| {
|
||||
let mut n = n.borrow_mut();
|
||||
let h = *n;
|
||||
@@ -19,15 +24,26 @@ pub fn insert(key: Zeroizing<[u8; 32]>) -> u32 {
|
||||
if *n == 0 { *n = 1; } // avoid reserving 0 as a valid handle
|
||||
h
|
||||
});
|
||||
SESSIONS.with(|s| { s.borrow_mut().insert(handle, key); });
|
||||
SESSIONS.with(|s| {
|
||||
s.borrow_mut().insert(handle, SessionData { master_key, image_secret });
|
||||
});
|
||||
handle
|
||||
}
|
||||
|
||||
/// Access the master key for a handle. Preserves original `with` signature for all existing callers.
|
||||
pub fn with<F, R>(handle: u32, f: F) -> Option<R>
|
||||
where
|
||||
F: FnOnce(&Zeroizing<[u8; 32]>) -> R,
|
||||
{
|
||||
SESSIONS.with(|s| s.borrow().get(&handle).map(f))
|
||||
SESSIONS.with(|s| s.borrow().get(&handle).map(|d| f(&d.master_key)))
|
||||
}
|
||||
|
||||
/// Access the image_secret for a handle (used by recovery QR).
|
||||
pub fn with_image_secret<F, R>(handle: u32, f: F) -> Option<R>
|
||||
where
|
||||
F: FnOnce(&Zeroizing<[u8; 32]>) -> R,
|
||||
{
|
||||
SESSIONS.with(|s| s.borrow().get(&handle).map(|d| f(&d.image_secret)))
|
||||
}
|
||||
|
||||
pub fn remove(handle: u32) -> bool {
|
||||
|
||||
@@ -83,8 +83,9 @@ vault_salt ────────►│ │
|
||||
|
||||
┌──────────────────┐
|
||||
master_key ────────►│ XChaCha20- │──────► manifest.enc
|
||||
empty manifest ────►│ Poly1305 │
|
||||
└──────────────────┘
|
||||
empty manifest ────►│ Poly1305 │ settings.enc
|
||||
default settings ──►│ encrypt (×2) │ (parallel artifacts;
|
||||
└──────────────────┘ independent nonces)
|
||||
|
||||
┌──────────────────┐
|
||||
│ git init │──────► vault repo
|
||||
@@ -92,6 +93,14 @@ empty manifest ────►│ Poly1305 │
|
||||
└──────────────────┘
|
||||
```
|
||||
|
||||
Item creation, the typed-item envelope (`Item` + per-type `ItemCore`),
|
||||
attachment encryption, and field-history tracking are not shown above —
|
||||
they are described in [`crates/relicario-core/ARCHITECTURE.md`](../crates/relicario-core/ARCHITECTURE.md).
|
||||
The flow above covers only the crypto-pipeline shape that vault init
|
||||
establishes; the per-item lifecycle reuses the same `master_key` +
|
||||
XChaCha20-Poly1305 primitives against `items/<id>.enc` and
|
||||
`attachments/<item-id>/<aid>.enc`.
|
||||
|
||||
## Unlock Flow (every vault operation)
|
||||
|
||||
```
|
||||
|
||||
@@ -48,6 +48,19 @@ When enabled, device authentication provides:
|
||||
- **Push access control**: Deploy keys managed via Gitea API
|
||||
- **Instant revocation**: One command cuts off both signing and push
|
||||
|
||||
Enforcement requires deploying the `relicario-server` pre-receive hook
|
||||
on the vault remote. The crate provides two subcommands:
|
||||
|
||||
- `relicario-server generate-hook` — emits the hook script to install at
|
||||
`<repo>/hooks/pre-receive`
|
||||
- `relicario-server verify-commit <sha>` — checks one commit's signature
|
||||
against `.relicario/devices.json` and `.relicario/revoked.json` as of
|
||||
that commit; the hook calls this for every pushed ref
|
||||
|
||||
Without the server hook, signed commits provide authorship metadata only
|
||||
— any process with push access can land an unsigned commit, since
|
||||
verification is otherwise advisory.
|
||||
|
||||
See `docs/superpowers/specs/2026-05-02-device-authentication-design.md`.
|
||||
|
||||
## Access Control
|
||||
@@ -57,5 +70,35 @@ Without device authentication, access control is transport-layer only:
|
||||
- **CLI**: SSH key authentication to git remote
|
||||
- **Extension**: Git credentials in browser storage
|
||||
|
||||
Device registration was optional before v0.4.0. With device auth enabled,
|
||||
all commits must be signed by a registered device.
|
||||
Device registration is optional but recommended for shared vaults.
|
||||
|
||||
## Configuration env vars
|
||||
|
||||
Relicario reads the following environment variables. Each is a trust
|
||||
boundary: an attacker who can set them in the user's environment can
|
||||
influence Relicario's behavior. They are listed here for security
|
||||
reviewers to audit the surface in one place.
|
||||
|
||||
### User-facing (active in all builds)
|
||||
|
||||
| Variable | Purpose | Trust |
|
||||
|---|---|---|
|
||||
| `RELICARIO_IMAGE` | Override the reference-image JPEG path used during vault unlock. | Trusted: filesystem path under the user's control. Read-only; its bytes feed `imgsecret::extract_secret`. |
|
||||
| `RELICARIO_GITEA_URL` | Gitea API base URL for `relicario device add`. Equivalent to `--gitea-url`. | Trusted: HTTPS URL. Used only in the device-add code path. |
|
||||
| `RELICARIO_GITEA_TOKEN` | Gitea personal-access token. Equivalent to `--gitea-token`. | **Secret**: anyone who can read this env var can manage the user's deploy keys via the Gitea API. The CLI never logs it. |
|
||||
| `RELICARIO_GITEA_OWNER` | Gitea repository owner (e.g. `alee`). Equivalent to `--owner`. | Trusted: opaque string. |
|
||||
| `RELICARIO_GITEA_REPO` | Gitea repository name (e.g. `vault`). Equivalent to `--repo`. | Trusted: opaque string. |
|
||||
|
||||
### Debug-only (compiled out of `cargo build --release`)
|
||||
|
||||
The following variables are gated behind `cfg(debug_assertions)` and
|
||||
are **no-ops** in release builds. The env-var lookup is removed by the
|
||||
optimiser from any binary built without debug assertions (i.e. the
|
||||
standard `--release` profile).
|
||||
|
||||
| Variable | Purpose |
|
||||
|---|---|
|
||||
| `RELICARIO_NO_GROUPS_CACHE` | Suppress the plaintext `groups.cache` write. Developer debugging tool for the cache logic. |
|
||||
| `RELICARIO_TEST_PASSPHRASE` | Bypass the `rpassword` prompt during integration tests. |
|
||||
| `RELICARIO_TEST_ITEM_SECRET` | Bypass the `rpassword` prompt for item-secret fields during integration tests. |
|
||||
| `RELICARIO_TEST_BACKUP_PASSPHRASE` | Bypass the `rpassword` prompt for backup export/restore passphrases during integration tests. |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Architecture overview — Relicario
|
||||
|
||||
This is the cross-codebase entry point. It describes how the three Relicario codebases fit together, the contracts that flow between them, and the conventions they share. It is **deliberately thin**; the deep content lives in per-codebase docs.
|
||||
This is the cross-codebase entry point. It describes how the four Relicario codebases fit together, the contracts that flow between them, and the conventions they share. It is **deliberately thin**; the deep content lives in per-codebase docs.
|
||||
|
||||
> If you are about to make a change in a single codebase, read its `ARCHITECTURE.md` first:
|
||||
>
|
||||
@@ -10,44 +10,48 @@ This is the cross-codebase entry point. It describes how the three Relicario cod
|
||||
>
|
||||
> If you want historical *why*, see `docs/superpowers/specs/` — those are time-stamped decision artifacts. This overview describes what *is*.
|
||||
|
||||
## The three codebases
|
||||
## The four codebases
|
||||
|
||||
```
|
||||
┌─────────────────────┐
|
||||
│ relicario-core │
|
||||
│ (Rust, no I/O) │
|
||||
│ crypto · items │
|
||||
│ manifest · stego │
|
||||
└──────────┬──────────┘
|
||||
│
|
||||
┌─────────────────────┼─────────────────────┐
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌────────────────┐ ┌────────────────────┐ (compiled to WASM
|
||||
│ relicario-cli │ │ relicario-wasm │ inside the )
|
||||
│ (Rust binary) │ │ (#[wasm_bindgen] │ extension │
|
||||
│ │ │ bindings) │ │
|
||||
│ filesystem + │ │ │ │
|
||||
│ git + │ └────────┬───────────┘ │
|
||||
│ clap UX │ │ │
|
||||
└────────────────┘ ▼ │
|
||||
┌─────────────────────┐ │
|
||||
│ extension │ │
|
||||
│ (TypeScript) │ │
|
||||
│ popup · vault │ │
|
||||
│ setup · content │ │
|
||||
│ service worker │ │
|
||||
└─────────────────────┘
|
||||
┌─────────────────────┐
|
||||
│ relicario-core │
|
||||
│ (Rust, no I/O) │
|
||||
│ crypto · items │
|
||||
│ manifest · stego │
|
||||
│ device keys + fp │
|
||||
└──┬───────────┬──────┘
|
||||
│ │
|
||||
┌────────────────┼───────────┴──────┬────────────────────┐
|
||||
│ │ │ │
|
||||
▼ ▼ ▼ ▼
|
||||
┌────────────────┐ ┌──────────────────┐ ┌────────────────────┐
|
||||
│ relicario-cli │ │ relicario-server │ │ relicario-wasm │
|
||||
│ (Rust binary) │ │ (Rust binary) │ │ (#[wasm_bindgen] │
|
||||
│ │ │ │ │ bindings) │
|
||||
│ filesystem + │ │ pre-receive hook │ │ │
|
||||
│ git + │ │ verify-commit + │ │ compiled to WASM │
|
||||
│ clap UX │ │ generate-hook │ │ for the extension │
|
||||
└────────────────┘ └──────────────────┘ └──────────┬─────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────┐
|
||||
│ extension │
|
||||
│ (TypeScript) │
|
||||
│ popup · vault │
|
||||
│ setup · content │
|
||||
│ service worker │
|
||||
└─────────────────────┘
|
||||
```
|
||||
|
||||
| Codebase | Language | Role | Key boundary |
|
||||
|---|---|---|---|
|
||||
| `relicario-core` | Rust | Crypto, item types, manifest, attachments, imgsecret, generators. Pure, no I/O. | Only `bytes-in / bytes-out`. No filesystem, no git, no network. |
|
||||
| `relicario-core` | Rust | Crypto, item types, manifest, attachments, imgsecret, generators, device keys / fingerprints. Pure, no I/O. | Only `bytes-in / bytes-out`. No filesystem, no git, no network. |
|
||||
| `relicario-cli` | Rust binary | Wraps core with filesystem ops, git plumbing, clap UX. | Only entry point that runs without a browser; sole working interface during disaster recovery. |
|
||||
| `relicario-wasm` | Rust → WASM | Thin `#[wasm_bindgen]` exports from core for the extension. | Compiles `relicario-core` to WASM; no extra logic. |
|
||||
| `relicario-server` | Rust binary | Pre-receive Git hook (`verify-commit`) plus hook installer (`generate-hook`) running on the vault remote. Verifies SSH-signed commits against `.relicario/devices.json` and `.relicario/revoked.json`. | Lives on the git server, not on a client device. The only Relicario component the user does not run themselves. Sees only public key material. |
|
||||
| `extension` | TypeScript | Browser-resident UI. Five entry-point bundles (popup, vault tab, setup, content script, service worker). | The service worker is the only crypto holder; popup/vault/content/setup never touch the master key. |
|
||||
|
||||
The CLI and the extension are **at parity**: every user-facing capability lands in both surfaces together. Diverging is allowed only with a documented reason. See the per-codebase docs for which surface owns which user flow.
|
||||
The CLI and the extension are **at parity**: every user-facing capability lands in both surfaces together. Diverging is allowed only with a documented reason. See the per-codebase docs for which surface owns which user flow. The server has no user-facing surface — it is a server-side enforcer of the device-auth invariant the clients already agreed to.
|
||||
|
||||
## Inter-codebase contracts
|
||||
|
||||
@@ -151,6 +155,7 @@ The CLI keeps its master key in process memory; if the process exits or crashes,
|
||||
| Target | Tool | Output | When to run |
|
||||
|---|---|---|---|
|
||||
| Native CLI | `cargo build` (debug or `--release`) | `target/{debug,release}/relicario` | After CLI changes; for distribution |
|
||||
| Server hook | `cargo build -p relicario-server --release` | `target/release/relicario-server` | After server changes; deploy onto the git remote |
|
||||
| Native test suites | `cargo test` (workspace) | — | After any Rust change |
|
||||
| WASM module | `wasm-pack build --target web` (via `npm run build:wasm`) | `extension/wasm/relicario_wasm{,_bg.wasm,.js}` | After core or wasm crate changes |
|
||||
| Chrome extension | `webpack` (`npm run build`) | `extension/dist/` | After TS or WASM changes; for Chrome distribution |
|
||||
@@ -196,6 +201,7 @@ Core tests use **fast Argon2id params** (m=256, t=1, p=1) so they don't take for
|
||||
| A new popup view, vault tab feature, or autofill change | [`extension/ARCHITECTURE.md`](../../extension/ARCHITECTURE.md) |
|
||||
| A new SW message type | `extension/src/shared/messages.ts` (capability sets), then [`extension/ARCHITECTURE.md § Invariants`](../../extension/ARCHITECTURE.md) |
|
||||
| A new GitHost (e.g. GitLab support) | `extension/src/service-worker/git-host.ts` (interface) and existing implementations |
|
||||
| The pre-receive hook / device-auth enforcement | `crates/relicario-server/src/main.rs`, then `docs/superpowers/specs/2026-05-02-device-authentication-design.md` for rationale |
|
||||
| Adding a new item type | core's `item_types/` mod, then CLI's `build_*_item`/`edit_*` helpers, then extension's `popup/components/types/<type>.ts` |
|
||||
| Threat model / why a primitive was chosen | `docs/superpowers/specs/2026-04-11-relicario-design.md` (historical, but authoritative for rationale) |
|
||||
| Format of the import/export feature | `docs/superpowers/specs/2026-04-27-relicario-import-export-design.md` (designed but not yet implemented) |
|
||||
|
||||
165
docs/superpowers/MULTI-AGENT.md
Normal file
165
docs/superpowers/MULTI-AGENT.md
Normal file
@@ -0,0 +1,165 @@
|
||||
# Multi-Agent Development Paradigm
|
||||
|
||||
This repo uses a three-terminal workflow for large development lifts: one Claude Code session acts as **PM** and two act as **senior developers** (Dev-A, Dev-B), each working in their own git worktree on a parallel feature branch.
|
||||
|
||||
A local relay MCP server eliminates manual message copying between terminals — agents call `post_message`/`read_messages` instead of asking the user to copy-paste.
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
| Role | Terminal | Branch | Responsibilities |
|
||||
|------|----------|--------|-----------------|
|
||||
| PM | 1 | `main` (read-only) | Drive doc-audit follow-ups, review PRs, write CHANGELOG, authorize merges and tagging |
|
||||
| Dev-A | 2 | `feature/<release>-plan-a-*` | Implement Plan A tasks in their own worktree |
|
||||
| Dev-B | 3 | `feature/<release>-plan-b-*` | Implement Plan B tasks in their own worktree |
|
||||
| Relay server | 4 | — | Message bus; Ctrl-C to stop at end of lift |
|
||||
|
||||
**User's job:** authorize merges (the PM asks), resolve escalations the PM can't handle, and watch the streams. You are no longer the message bus.
|
||||
|
||||
---
|
||||
|
||||
## Starting a lift
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- [ ] Kickoff prompts exist in `docs/superpowers/coordination/` (generate with the `multi-agent-kickoff` skill if not)
|
||||
- [ ] No uncommitted changes in main that would confuse the devs
|
||||
- [ ] `tools/relay/` is present (run `ls tools/relay/` to confirm)
|
||||
|
||||
### Launch sequence
|
||||
|
||||
```bash
|
||||
# 1. Start the relay server (this terminal becomes the relay log)
|
||||
tools/relay/start.sh # prints copy-paste instructions, then starts server
|
||||
|
||||
# Optional: use a multiplexer to auto-open all four terminals
|
||||
tools/relay/start.sh --tmux # creates tmux session "relay-lift" with 4 windows
|
||||
tools/relay/start.sh --kitty # creates kitty tab "relay" + 3 windows
|
||||
```
|
||||
|
||||
`start.sh` prints the paths to the three kickoff prompt files. In each Claude Code terminal, run `cat <path>` and paste everything below the `---` line as the first message.
|
||||
|
||||
---
|
||||
|
||||
## Coordination protocol
|
||||
|
||||
Agents communicate by posting structured blocks to each other's inboxes. Four message kinds:
|
||||
|
||||
| Kind | Block header | When used |
|
||||
|------|-------------|-----------|
|
||||
| `status` | `## STATUS UPDATE — DEV-*` | After completing a task, getting blocked, or reaching a review-ready state |
|
||||
| `question` | `## QUESTION TO PM — DEV-*` | When a dev needs PM input mid-task |
|
||||
| `directive` | `## DIRECTIVE TO DEV-*` | When PM instructs a dev to proceed, hold, rescope, or approve a PR |
|
||||
| `free` | (none) | Ad-hoc messages not covered by the above |
|
||||
|
||||
A well-formed `status` block:
|
||||
|
||||
```
|
||||
## STATUS UPDATE — DEV-B
|
||||
Time: 2026-05-02T14:30:00-07:00
|
||||
Branch: feature/v0.5.0-plan-b-extension-ux
|
||||
Task: P4 / error-copy map
|
||||
Status: DONE
|
||||
Last commit: abc1234 feat(extension): centralize ERROR_COPY map
|
||||
Tests: green
|
||||
Notes: No issues. Ready for PM review of P4 before starting B1.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Using the relay tools
|
||||
|
||||
All three Claude Code sessions have these tools available when the relay server is running:
|
||||
|
||||
```
|
||||
post_message(from, to, kind, body) → { id }
|
||||
read_messages(for) → RelayMessage[] (drains inbox)
|
||||
list_pending(for) → { count, kinds } (non-destructive)
|
||||
```
|
||||
|
||||
Typical dev flow per task:
|
||||
|
||||
```
|
||||
1. read_messages(for="dev-b") # check for directives before starting
|
||||
2. ... do the work ...
|
||||
3. post_message(from="dev-b", to="pm", kind="status", body="## STATUS UPDATE...")
|
||||
```
|
||||
|
||||
Typical PM flow:
|
||||
|
||||
```
|
||||
1. read_messages(for="pm") # see what devs posted
|
||||
2. ... review ...
|
||||
3. post_message(from="pm", to="dev-b", kind="directive", body="## DIRECTIVE TO DEV-B...")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## If the relay server isn't running
|
||||
|
||||
Claude Code will show a yellow MCP connection warning for the `relay` server. The tools will be unavailable.
|
||||
|
||||
Agents fall back to the manual protocol: they emit the structured blocks as text and ask the user to copy-paste them to the relevant terminal. This is slower but fully functional — the coordination protocol works either way.
|
||||
|
||||
To restart a crashed server mid-lift:
|
||||
|
||||
```bash
|
||||
tools/relay/start.sh
|
||||
```
|
||||
|
||||
In-flight messages are lost on restart. Any agent with unread messages should re-post them.
|
||||
|
||||
---
|
||||
|
||||
## Generating kickoff prompts
|
||||
|
||||
### Full workflow (spec → plans → kickoff)
|
||||
|
||||
**Step 1 — Write a spec**
|
||||
|
||||
Run the `superpowers:brainstorming` skill. At the end it invokes `superpowers:writing-plans` for each dev stream. Each stream gets its own plan file in `docs/superpowers/plans/`. The spec lives in `docs/superpowers/specs/`.
|
||||
|
||||
**Step 2 — Invoke the kickoff skill**
|
||||
|
||||
Say anything like:
|
||||
- "kick off the multi-agent thing for v0.6.0"
|
||||
- "spin up PM and devs for this release"
|
||||
- "set up the three-terminal paradigm"
|
||||
|
||||
The `multi-agent-kickoff` skill auto-triggers on those phrases. It will:
|
||||
|
||||
1. Auto-discover the spec and plans by date/release label (asks to confirm if ambiguous)
|
||||
2. Generate `docs/superpowers/coordination/<release>-pm-prompt.md` and one `-dev-<letter>-prompt.md` per plan
|
||||
3. Inject the relay paragraph, branch names, worktree paths, test commands, and scope partitioning automatically from the plans and `CLAUDE.md`
|
||||
4. Commit the prompts and print launch instructions
|
||||
|
||||
N>2 devs works automatically — 3 plans produces PM + Dev-A/B/C prompts.
|
||||
|
||||
**Step 3 — Launch**
|
||||
|
||||
```bash
|
||||
tools/relay/start.sh # prints prompt file paths, starts relay server
|
||||
# open N+1 terminals, paste each prompt below its '---' line
|
||||
```
|
||||
|
||||
The skill reminder: run `tools/relay/start.sh` **before** opening the Claude Code sessions — the MCP tools need the server up when each session initializes.
|
||||
|
||||
---
|
||||
|
||||
## Ending a lift
|
||||
|
||||
1. PM emits `REVIEW-COMPLETE` and `MERGE-APPROVED` for each dev's PR
|
||||
2. User merges each PR (the PM session does `gh pr merge` with user authorization)
|
||||
3. PM tags the release (only after explicit user `yes`)
|
||||
4. Ctrl-C the relay terminal — all in-memory messages are discarded
|
||||
|
||||
---
|
||||
|
||||
## Roles and boundaries (quick reference)
|
||||
|
||||
**PM must not:** write feature code, merge without user authorization, tag without user approval, run `git push --force` / `git reset --hard` without asking.
|
||||
|
||||
**Devs must not:** merge their branch to main, push `--force`, run `git reset --hard` without asking.
|
||||
|
||||
**User must:** authorize all merges and the release tag. Everything else is delegated.
|
||||
@@ -5,8 +5,9 @@ Pre-v0.5.0 audit of Relicario's documentation against the current codebase.
|
||||
## Summary
|
||||
|
||||
- **Total findings:** 14
|
||||
- **Fixed inline:** 6
|
||||
- **Need user input (proposed only):** 8
|
||||
- **Fixed inline (initial pass):** 6
|
||||
- **Fixed during v0.5.0 PM run (this audit, follow-up commits):** 8
|
||||
- **No action needed:** 0
|
||||
- **Top 3 recommendations:**
|
||||
1. **Add `relicario-server` to architecture docs.** It exists in `crates/`, is referenced by SECURITY.md, and underpins device-auth, but `docs/architecture/overview.md`'s "three codebases" framing and `CLAUDE.md`'s project-structure tree still pretend it doesn't exist (Findings 1, 2, 9). This is the single biggest gap before tagging v0.5.0.
|
||||
2. **Replace `CLAUDE.md`'s Roadmap.** It still says "Next: WASM build + Chrome MV3 browser extension (Plan 2)" — a milestone that shipped weeks ago. Multiple subsequent train rounds (typed items, attachments, backup, LastPass, device auth, fullscreen UX phases) have shipped, none of which are reflected (Finding 3).
|
||||
@@ -24,7 +25,7 @@ The codebase itself is well-documented — `crates/{relicario-core,relicario-cli
|
||||
**Issue:** The repo now has **four** Rust crates (`relicario-core`, `relicario-cli`, `relicario-wasm`, `relicario-server`) plus the extension. The framing "The three codebases" + accompanying ASCII diagram + four-row table all predate the May 2026 server crate. `relicario-server` is the pre-receive hook binary that enforces device-signature verification — load-bearing for the device-auth model that SECURITY.md already advertises.
|
||||
**Fix:** Re-title the section ("The four codebases" or "The relicario codebases"), add a server box to the diagram, add a row to the table. The role is "Pre-receive Git hook that verifies commit signatures against `.relicario/devices.json` and `.relicario/revoked.json`".
|
||||
**Severity:** must-fix-before-v0.5.0
|
||||
**Status:** Proposed; needs user decision (>50 words of new prose; touches the framing of the whole overview doc)
|
||||
**Status:** Fixed in `ca059e7` (PM follow-up, 2026-05-02): "four codebases" framing, ASCII diagram fans core out to cli + server + wasm, table row added, build matrix gains `cargo build -p relicario-server`, "Where to look next" points at server src + design spec.
|
||||
|
||||
---
|
||||
|
||||
@@ -34,7 +35,7 @@ The codebase itself is well-documented — `crates/{relicario-core,relicario-cli
|
||||
**Issue:** The `crates/` tree only lists `relicario-core/`, `relicario-cli/`, `relicario-wasm/`. `relicario-server/` is missing. Since CLAUDE.md is the project-level summary every Claude session reads, this is the highest-leverage staleness.
|
||||
**Fix:** Add a fourth crate entry for `relicario-server/` with `src/main.rs # pre-receive hook: verify_commit + generate_hook`.
|
||||
**Severity:** must-fix-before-v0.5.0
|
||||
**Status:** Proposed; needs user decision (CLAUDE.md is user-controlled per audit constraints)
|
||||
**Status:** Fixed in `8fd9a05` (PM follow-up, 2026-05-02 with user approval): added `relicario-server/` entry to project tree.
|
||||
|
||||
---
|
||||
|
||||
@@ -44,7 +45,7 @@ The codebase itself is well-documented — `crates/{relicario-core,relicario-cli
|
||||
**Issue:** Says `Next: WASM build + Chrome MV3 browser extension (Plan 2). Then mobile (Rust core compiles to ARM).` Plan 2 (extension) shipped, then Plans 1A-1C, 3A (backup), 3B (LastPass), Plan 4 (security fixes + device auth), and Phases 1-2B of the fullscreen UX redesign all shipped. The current "next thing" per project memory is v0.5.0 polish + harden plus Phase 3/4 of fullscreen UX.
|
||||
**Fix:** Replace with a current-state Roadmap line (e.g. `Next: v0.5.0 polish + harden, then Phase 3 (vault tab shell). Mobile (ARM) and recovery QR remain on the roadmap.`).
|
||||
**Severity:** must-fix-before-v0.5.0
|
||||
**Status:** Proposed; needs user decision (CLAUDE.md is user-controlled; phrasing is a judgment call)
|
||||
**Status:** Fixed in `8fd9a05` (PM follow-up, 2026-05-02 with user approval): replaced with the v0.5.0 / Phases 3-4 / 1C-γ / LastPass / mobile / recovery-QR picture.
|
||||
|
||||
---
|
||||
|
||||
@@ -54,7 +55,7 @@ The codebase itself is well-documented — `crates/{relicario-core,relicario-cli
|
||||
**Issue:** Audit M8 bumped `ItemId`/`FieldId` to 16-char hex (64 bits). Verified against `crates/relicario-core/src/ids.rs:3-4, 35-37` and `tests/integration` — they're 16 hex chars. The same line also doesn't mention that `AttachmentId` was bumped to 32 hex chars / 128 bits (audit I2/B4).
|
||||
**Fix:** Change to: `Item IDs and Field IDs are random 16-char hex strings (64 bits of OsRng entropy). AttachmentIds are content-addressed: first 32 hex chars of SHA-256(plaintext) (128 bits, audit I2/B4).`
|
||||
**Severity:** must-fix-before-v0.5.0
|
||||
**Status:** Proposed; needs user decision (CLAUDE.md is user-controlled)
|
||||
**Status:** Fixed in `8fd9a05` (PM follow-up, 2026-05-02 with user approval): now reads "Item IDs and Field IDs are random 16-char hex strings (64 bits of OsRng entropy). AttachmentIds are content-addressed: first 32 hex chars of SHA-256 over the plaintext (128 bits)."
|
||||
|
||||
---
|
||||
|
||||
@@ -114,7 +115,7 @@ The codebase itself is well-documented — `crates/{relicario-core,relicario-cli
|
||||
**Issue:** The vault-creation pipeline in this doc shows `master_key → XChaCha20-Poly1305 → manifest.enc` only. In reality `cmd_init` also encrypts and writes `settings.enc` (default `VaultSettings`). Field-history-tracked items, attachments, the `Item` envelope shape — none of these are in the flow doc. Without context on typed items, a new contributor reading this doc would have a v0.1-era model of the system.
|
||||
**Fix:** Add a settings.enc step to the flow; either expand the items section or note that the full item lifecycle is in `crates/relicario-core/ARCHITECTURE.md`.
|
||||
**Severity:** nice-to-have (the per-codebase ARCHITECTURE.md files are the source of truth; this top-level doc could just point at them)
|
||||
**Status:** Proposed; needs user decision (>50 words of new prose, design choice between rewriting vs trimming)
|
||||
**Status:** Fixed in `76d092d` (PM follow-up, 2026-05-02): trim path. Added settings.enc as a parallel artifact in the encrypt step, then a short paragraph pointing at `crates/relicario-core/ARCHITECTURE.md` for the per-item lifecycle.
|
||||
|
||||
---
|
||||
|
||||
@@ -124,7 +125,7 @@ The codebase itself is well-documented — `crates/{relicario-core,relicario-cli
|
||||
**Issue:** Says `Device registration was optional before v0.4.0. With device auth enabled, all commits must be signed by a registered device.` But (a) v0.4.0 hasn't been tagged yet — the changelog goes v0.1.0 → v0.2.0 → "Unreleased", and the next tag-in-flight per project memory is v0.5.0; (b) per the v0.5.0 polish + harden spec, device-auth enforcement is **currently a no-op** because the pre-receive hook fix (S1) hasn't landed. Saying "all commits MUST be signed" is aspirational, not current.
|
||||
**Fix:** Reword to clarify (a) the actual version line (e.g. "Pre-v0.5.0 vaults can opt out by leaving `devices.json` empty"), AND (b) acknowledge that signature *enforcement* depends on the pre-receive hook being deployed and the S1 fix landing. Could just be a one-line caveat.
|
||||
**Severity:** must-fix-before-v0.5.0 (security-doc accuracy is part of the legibility pitch)
|
||||
**Status:** Proposed; needs user decision (security wording — exact phrasing matters)
|
||||
**Status:** Fixed in `1342228` (PM follow-up, 2026-05-02 with user approval): dropped the "before v0.4.0" version line entirely (v0.4.0 was never tagged); replaced with a single line saying registration is optional but recommended for shared vaults. Enforcement story now lives in the Device Authentication section (see F12).
|
||||
|
||||
---
|
||||
|
||||
@@ -134,7 +135,7 @@ The codebase itself is well-documented — `crates/{relicario-core,relicario-cli
|
||||
**Issue:** The "Device Authentication" section refers to a "pre-receive hook" but never says it lives in `crates/relicario-server`, what binary the hook calls (`relicario-server verify-commit <sha>`), or how to install it (`relicario-server generate-hook`). For a self-hosted user reading this to decide whether to enable it, those are the two essential operational facts.
|
||||
**Fix:** Add a short paragraph naming the crate and the two subcommands, pointing to the design spec.
|
||||
**Severity:** nice-to-have
|
||||
**Status:** Proposed; needs user decision (>50 words of new prose)
|
||||
**Status:** Fixed in `1342228` (PM follow-up, 2026-05-02): added paragraph naming the `relicario-server` crate, both subcommands (`generate-hook`, `verify-commit`), and the caveat that signed commits without the server hook provide authorship metadata only.
|
||||
|
||||
---
|
||||
|
||||
@@ -144,7 +145,7 @@ The codebase itself is well-documented — `crates/{relicario-core,relicario-cli
|
||||
**Issue:** This doc is explicitly historical (per `docs/architecture/overview.md` "Stale spec docs" disclaimer), so editing it as architecture would violate convention. Still worth flagging that "Post-V1 Ideas" lists secure notes, secure documents, mobile, LastPass import, Firefox extension, TOTP — most of which have shipped. Per project policy this is *informational only*; the spec is a time-stamped decision artifact.
|
||||
**Fix:** None — leave alone. If desired, prepend a one-line "Status: V1 shipped 2026-04-22; many Post-V1 ideas have since landed — see CHANGELOG.md" at the top of the file.
|
||||
**Severity:** informational
|
||||
**Status:** Proposed; needs user decision (touches a historical spec the user may want to leave frozen)
|
||||
**Status:** Fixed in `9c97f9f` (PM follow-up, 2026-05-02): added the optional one-line status banner at the top of the spec pointing at CHANGELOG.md and overview.md for current state. Body of the spec untouched per the "specs are frozen decision artifacts" convention.
|
||||
|
||||
---
|
||||
|
||||
@@ -160,10 +161,18 @@ The codebase itself is well-documented — `crates/{relicario-core,relicario-cli
|
||||
|
||||
## Inline-fix verification
|
||||
|
||||
Files modified during this audit:
|
||||
### Initial pass (commit `900ccf1`):
|
||||
|
||||
- `README.md` — vault layout (`items/`, `settings.enc`, `attachments/`), crate tree (added `relicario-wasm`, `relicario-server`, typed-items modules), ID width, Roadmap.
|
||||
- `docs/ARCHITECTURE.md` — git-server box (`items/`, `settings.enc`, `attachments/`, `revoked.json`), crate-architecture inner box (current core modules), removed "Future: relicario-wasm" line.
|
||||
- `docs/architecture/overview.md` — conventions table (16-char hex IDs, 128-bit AttachmentIds).
|
||||
|
||||
No source files, `Cargo.lock`, or extension code were modified. CLAUDE.md, SECURITY.md, and the foundational design spec were not modified — those changes need user review per the audit constraints.
|
||||
### v0.5.0 PM follow-up pass (commits `ca059e7`, `8fd9a05`, `1342228`, `76d092d`, `9c97f9f`):
|
||||
|
||||
- `docs/architecture/overview.md` — F1: four-codebases framing, ASCII diagram fans out to server, table row, build matrix, "Where to look next".
|
||||
- `CLAUDE.md` — F2: project tree gains `relicario-server`. F3: Roadmap line replaced. F4: Item/Field/Attachment ID widths and entropy noted.
|
||||
- `docs/SECURITY.md` — F11: dropped `before v0.4.0` line. F12: Device Authentication section now names the `relicario-server` crate and its subcommands, with the "without the hook, commits are advisory" caveat.
|
||||
- `docs/ARCHITECTURE.md` — F10: settings.enc shown alongside manifest.enc in the Vault Creation Flow; pointer added to per-crate ARCHITECTURE.md for typed-items detail.
|
||||
- `docs/superpowers/specs/2026-04-11-relicario-design.md` — F13: optional one-line "historical spec" status banner at top.
|
||||
|
||||
No source files, `Cargo.lock`, or extension code were modified at any point.
|
||||
|
||||
1448
docs/superpowers/coordination/v0.5.1-dev-a-prompt.md
Normal file
1448
docs/superpowers/coordination/v0.5.1-dev-a-prompt.md
Normal file
File diff suppressed because it is too large
Load Diff
1074
docs/superpowers/coordination/v0.5.1-dev-b-prompt.md
Normal file
1074
docs/superpowers/coordination/v0.5.1-dev-b-prompt.md
Normal file
File diff suppressed because it is too large
Load Diff
1353
docs/superpowers/coordination/v0.5.1-dev-c-prompt.md
Normal file
1353
docs/superpowers/coordination/v0.5.1-dev-c-prompt.md
Normal file
File diff suppressed because it is too large
Load Diff
165
docs/superpowers/coordination/v0.5.1-pm-prompt.md
Normal file
165
docs/superpowers/coordination/v0.5.1-pm-prompt.md
Normal file
@@ -0,0 +1,165 @@
|
||||
# PM Kickoff Prompt — v0.5.1 UX Polish + Recovery QR
|
||||
|
||||
Paste everything below the `---` line into a fresh Claude Code terminal as the first user message.
|
||||
|
||||
---
|
||||
|
||||
You are the **project manager** for the Relicario v0.5.1 release. Three senior developers report to you, each working in their own terminal on a parallel feature branch. The user runs all four terminals and relays messages between them.
|
||||
|
||||
## Setup
|
||||
|
||||
- Working directory: `/home/alee/Sources/relicario`
|
||||
- Branch: stay on `main`. Do not check out feature branches.
|
||||
- Today: 2026-05-03. Project rules in `CLAUDE.md` apply (Spanish flourish, capitalization, autonomy defaults, never run git-destructive commands without asking).
|
||||
|
||||
## Required reading (in order)
|
||||
|
||||
1. `CLAUDE.md` — project rules
|
||||
2. `docs/superpowers/specs/2026-05-03-v0.5.x-ux-polish-and-recovery-qr-design.md` — full spec
|
||||
3. `docs/superpowers/coordination/v0.5.1-dev-a-prompt.md` — Dev A's plan (Stream A: fullscreen + popup layout)
|
||||
4. `docs/superpowers/coordination/v0.5.1-dev-b-prompt.md` — Dev B's plan (Stream B: settings UX)
|
||||
5. `docs/superpowers/coordination/v0.5.1-dev-c-prompt.md` — Dev C's plan (Stream C: recovery QR)
|
||||
|
||||
## Your authority
|
||||
|
||||
- Approve or deny scope changes from devs
|
||||
- Review and merge PRs from all three feature branches
|
||||
- **Drive the interface contract** between B and C (see below) — this is your first hands-on action
|
||||
- Write the `CHANGELOG.md` entry for v0.5.1
|
||||
- Tag `v0.5.1` once everything is integrated **— but only after explicit user approval**
|
||||
|
||||
## Your boundaries
|
||||
|
||||
- Don't write feature code yourself. Edits to docs / CHANGELOG / CLAUDE.md are fine.
|
||||
- Don't deviate from the spec without user approval.
|
||||
- Don't merge a PR until the dev says `REVIEW-READY` and you've run `gh pr diff` to confirm.
|
||||
- Don't tag without user approval.
|
||||
- Project rule: ask the user before any git-destructive op.
|
||||
|
||||
## Stream overview
|
||||
|
||||
| Stream | Branch | Owner | Core files |
|
||||
|--------|--------|-------|-----------|
|
||||
| A — Fullscreen + popup layout | `feature/v0.5.1-stream-a-layout` | DEV-A | `vault.ts`, `vault.css`, `item-list.ts`, `item-form.ts`, `glyphs.ts`, `toast.ts` |
|
||||
| B — Settings UX | `feature/v0.5.1-stream-b-settings` | DEV-B | `settings.ts`, `settings-vault.ts` (decomposed), `settings-security.ts` (stub only) |
|
||||
| C — Recovery QR | `feature/v0.5.1-stream-c-recovery-qr` | DEV-C | `recovery_qr.rs`, WASM `session.rs`/`lib.rs`, `settings-security.ts`, `setup.ts` |
|
||||
|
||||
## Interface contracts (enforce before work starts)
|
||||
|
||||
### A–B: Settings component signature
|
||||
|
||||
DEV-B's settings component is wired into vault.ts by DEV-A. Both must agree before either proceeds with their vault.ts / settings.ts work.
|
||||
|
||||
**Agreed interface** (post to both devs as your first directive):
|
||||
|
||||
```ts
|
||||
// extension/src/popup/components/settings.ts
|
||||
|
||||
/**
|
||||
* Render the full sectioned settings view into `container`.
|
||||
* May be called from vault.ts (fullscreen, full-width pane) or popup.ts (popup).
|
||||
*/
|
||||
export async function renderSettings(container: HTMLElement): Promise<void>;
|
||||
|
||||
/**
|
||||
* Teardown: close any open generator panel, remove keyboard listeners.
|
||||
* Call before navigating away from the settings view.
|
||||
*/
|
||||
export function teardownSettings(): void;
|
||||
```
|
||||
|
||||
DEV-A imports `{ renderSettings, teardownSettings }` from `settings.ts` in vault.ts.
|
||||
DEV-B exports these names with these exact signatures.
|
||||
|
||||
### B–C: Security section component signature
|
||||
|
||||
DEV-C owns and implements `settings-security.ts`. DEV-B imports it for the Security section. They must agree before DEV-B writes B4 (Security section) or DEV-C writes C8 (settings-security.ts).
|
||||
|
||||
**Agreed interface** (post to both devs as your first directive):
|
||||
|
||||
```ts
|
||||
// extension/src/popup/components/settings-security.ts
|
||||
|
||||
/**
|
||||
* Render the three-state Recovery QR + trusted devices security section
|
||||
* into `container`. `sessionHandle` is the current WASM session handle value
|
||||
* (from the service-worker's session), or null if the vault is locked.
|
||||
*/
|
||||
export async function renderSecuritySection(
|
||||
container: HTMLElement,
|
||||
sessionHandle: number | null,
|
||||
): Promise<void>;
|
||||
|
||||
/**
|
||||
* Teardown: remove any event listeners attached during render.
|
||||
*/
|
||||
export function teardownSecuritySection(): void;
|
||||
```
|
||||
|
||||
DEV-B stubs this interface in `settings-security.ts` immediately after receiving this directive. DEV-C replaces it with the real implementation.
|
||||
|
||||
## Merge order and strategy
|
||||
|
||||
1. **C lands first** (or concurrently with A; no A or B dependency). Merge once DEV-C posts REVIEW-READY.
|
||||
2. **A and B can merge in either order** after C is on main, since both will rebase/merge main before PR.
|
||||
3. No squash merges — git history is preserved per project rule.
|
||||
4. No force pushes. Each dev opens a PR; PM reviews diff; PM merges with `gh pr merge --merge`.
|
||||
|
||||
## Coordination protocol
|
||||
|
||||
You are one of four terminals. The user relays messages.
|
||||
|
||||
**You receive:** `## STATUS UPDATE — DEV-A/B/C` or `## QUESTION TO PM — DEV-X` blocks.
|
||||
|
||||
**You emit:** a `## DIRECTIVE TO DEV-X` block. Format:
|
||||
|
||||
```
|
||||
## DIRECTIVE TO DEV-A (or B or C)
|
||||
Time: <iso8601>
|
||||
Action: PROCEED | HOLD | RESCOPE | REVIEW-COMPLETE | MERGE-APPROVED
|
||||
Notes: <one paragraph max>
|
||||
Next: <one concrete instruction or "continue plan">
|
||||
```
|
||||
|
||||
When asked "status?" by the user at any time:
|
||||
|
||||
```
|
||||
## RELEASE STATUS — v0.5.1
|
||||
Dev A: <task X of Y, status>
|
||||
Dev B: <task X of Y, status>
|
||||
Dev C: <task X of Y, status>
|
||||
PM: <current action>
|
||||
Blockers: <list, or "none">
|
||||
Next milestone: <e.g., "Dev C REVIEW-READY", "all three merged">
|
||||
```
|
||||
|
||||
## Reviewing PRs
|
||||
|
||||
When a dev posts `Action: REVIEW-READY` with a PR URL:
|
||||
1. `gh pr view <url>` to read description and CI status
|
||||
2. `gh pr diff <url>` to read changes
|
||||
3. Check diff against the spec sections owned by that stream
|
||||
4. If green: post `Action: MERGE-APPROVED` and run `gh pr merge --merge`
|
||||
5. If red: post `Action: HOLD` with specific concerns
|
||||
|
||||
## Pre-tag checklist
|
||||
|
||||
Before tagging v0.5.1:
|
||||
|
||||
- [ ] `feature/v0.5.1-stream-a-layout` merged to main
|
||||
- [ ] `feature/v0.5.1-stream-b-settings` merged to main
|
||||
- [ ] `feature/v0.5.1-stream-c-recovery-qr` merged to main
|
||||
- [ ] `cargo test` green on main
|
||||
- [ ] `bun run test` green (extension)
|
||||
- [ ] `cargo build -p relicario-wasm --target wasm32-unknown-unknown` green
|
||||
- [ ] `bun run build` + `bun run build:firefox` clean (extension)
|
||||
- [ ] No emoji in any UI surface (grep: `'🔑\|📝\|🪪\|💳\|🗝\|📄\|⏱️'` in `extension/src/`)
|
||||
- [ ] `GLYPH_VAULT_TAB` in glyphs.ts; no inline `⤴` anywhere
|
||||
- [ ] `recovery_qr_generated_at` is the only persisted QR artifact (grep: no QR SVG in chrome.storage calls)
|
||||
- [ ] Settings left-nav sections all render without console errors
|
||||
- [ ] `CHANGELOG.md` entry for v0.5.1 written
|
||||
- [ ] Explicit user approval to tag
|
||||
|
||||
## First action
|
||||
|
||||
After reading: post a `## RELEASE STATUS — v0.5.1` block, then post your first directive to all three devs simultaneously — confirming the A–B and B–C interface contracts above. Wait for devs to acknowledge before instructing them to proceed with their task lists.
|
||||
956
docs/superpowers/plans/2026-05-02-relay-server.md
Normal file
956
docs/superpowers/plans/2026-05-02-relay-server.md
Normal file
@@ -0,0 +1,956 @@
|
||||
# Relay Server 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:** Build a local MCP SSE server that gives PM, Dev-A, and Dev-B Claude Code sessions native `post_message`/`read_messages`/`list_pending` tools, eliminating manual copy-paste during multi-agent development lifts.
|
||||
|
||||
**Architecture:** A single Node.js process hosts an HTTP server with SSE transport for the MCP protocol. Three named in-memory FIFO queues (one per role) hold consume-once messages. A `start.sh` launcher prints copy-paste instructions (default) or spawns a tmux/kitty layout (flags). The multi-agent-kickoff skill templates get a `<<RELAY_PARAGRAPH>>` placeholder injected so every future lift prompt auto-includes relay instructions.
|
||||
|
||||
**Tech Stack:** Node.js v25, `@modelcontextprotocol/sdk` (MCP + SSE transport), `tsx` (dev dep, runs TypeScript directly), Node built-in `node:test` runner. No Express, no Hono, no Zod as a direct dep.
|
||||
|
||||
---
|
||||
|
||||
## File map
|
||||
|
||||
| Action | Path | Responsibility |
|
||||
|--------|------|----------------|
|
||||
| Create | `tools/relay/package.json` | npm metadata, scripts, single runtime dep + tsx devDep |
|
||||
| Create | `tools/relay/tsconfig.json` | TypeScript config for ESM Node target |
|
||||
| Create | `tools/relay/queue.ts` | `RelayQueue` class — in-memory FIFO, `post`/`read`/`pending`, `isRole` guard |
|
||||
| Create | `tools/relay/queue.test.ts` | Node `node:test` unit tests for queue (5 cases) |
|
||||
| Create | `tools/relay/server.ts` | MCP `Server` + `SSEServerTransport` HTTP server on port 7331 |
|
||||
| Create | `tools/relay/start.sh` | Launcher: `--manual` (default), `--tmux`, `--kitty` |
|
||||
| Modify | `.gitignore` | Add `tools/relay/node_modules/` |
|
||||
| Modify | `.claude/settings.json` | Add `mcpServers.relay` SSE entry |
|
||||
| Create | `docs/superpowers/MULTI-AGENT.md` | Paradigm reference README |
|
||||
| Modify | `~/.claude/skills/multi-agent-kickoff/templates/pm-prompt.md` | Add `<<RELAY_PARAGRAPH>>` section |
|
||||
| Modify | `~/.claude/skills/multi-agent-kickoff/templates/dev-prompt.md` | Add `<<RELAY_PARAGRAPH>>` section |
|
||||
| Modify | `~/.claude/skills/multi-agent-kickoff/SKILL.md` | Placeholder ref + step 8 update + `<<DEV_ROLE>>` placeholder |
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Scaffold `tools/relay/`
|
||||
|
||||
**Files:**
|
||||
- Create: `tools/relay/package.json`
|
||||
- Create: `tools/relay/tsconfig.json`
|
||||
- Modify: `.gitignore`
|
||||
|
||||
- [ ] **Step 1: Create `tools/relay/package.json`**
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "@relicario/relay",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "npx tsx server.ts",
|
||||
"test": "node --import=tsx/esm --test queue.test.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.10.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"tsx": "^4.19.0",
|
||||
"@types/node": "^22.0.0"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Create `tools/relay/tsconfig.json`**
|
||||
|
||||
```json
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"strict": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["*.ts"]
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add to root `.gitignore`**
|
||||
|
||||
Open `/home/alee/Sources/relicario/.gitignore` and append:
|
||||
|
||||
```
|
||||
tools/relay/node_modules/
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Install dependencies and verify**
|
||||
|
||||
```bash
|
||||
cd tools/relay && npm install
|
||||
```
|
||||
|
||||
Expected: `node_modules/` created, no errors. Verify with:
|
||||
|
||||
```bash
|
||||
ls node_modules/@modelcontextprotocol/sdk && ls node_modules/tsx
|
||||
```
|
||||
|
||||
Expected: both directories exist.
|
||||
|
||||
- [ ] **Step 5: Commit scaffold**
|
||||
|
||||
```bash
|
||||
git add tools/relay/package.json tools/relay/tsconfig.json tools/relay/package-lock.json .gitignore
|
||||
git commit -m "chore(relay): scaffold tools/relay with MCP SDK dep"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: `queue.ts` — TDD
|
||||
|
||||
**Files:**
|
||||
- Create: `tools/relay/queue.ts`
|
||||
- Create: `tools/relay/queue.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Create `tools/relay/queue.test.ts`:
|
||||
|
||||
```typescript
|
||||
import { describe, it, beforeEach } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { RelayQueue, isRole } from "./queue.ts";
|
||||
|
||||
describe("RelayQueue", () => {
|
||||
let q: RelayQueue;
|
||||
|
||||
beforeEach(() => {
|
||||
q = new RelayQueue();
|
||||
});
|
||||
|
||||
it("post + read roundtrip returns the message with correct fields", () => {
|
||||
q.post("dev-b", "pm", "status", "Task P4 DONE");
|
||||
const msgs = q.read("pm");
|
||||
assert.equal(msgs.length, 1);
|
||||
assert.equal(msgs[0].from, "dev-b");
|
||||
assert.equal(msgs[0].to, "pm");
|
||||
assert.equal(msgs[0].kind, "status");
|
||||
assert.equal(msgs[0].body, "Task P4 DONE");
|
||||
assert.ok(typeof msgs[0].id === "string" && msgs[0].id.length > 0);
|
||||
assert.ok(typeof msgs[0].ts === "string");
|
||||
});
|
||||
|
||||
it("consume-once: second read returns empty", () => {
|
||||
q.post("dev-a", "pm", "question", "Should I use approach A?");
|
||||
q.read("pm");
|
||||
const second = q.read("pm");
|
||||
assert.deepEqual(second, []);
|
||||
});
|
||||
|
||||
it("list_pending does not drain inbox", () => {
|
||||
q.post("dev-b", "pm", "directive", "PROCEED");
|
||||
const before = q.pending("pm");
|
||||
assert.equal(before.count, 1);
|
||||
const after = q.read("pm");
|
||||
assert.equal(after.length, 1);
|
||||
});
|
||||
|
||||
it("FIFO ordering across multiple senders", () => {
|
||||
q.post("dev-a", "pm", "status", "first");
|
||||
q.post("dev-b", "pm", "status", "second");
|
||||
q.post("dev-a", "pm", "question", "third");
|
||||
const msgs = q.read("pm");
|
||||
assert.equal(msgs.length, 3);
|
||||
assert.equal(msgs[0].body, "first");
|
||||
assert.equal(msgs[1].body, "second");
|
||||
assert.equal(msgs[2].body, "third");
|
||||
});
|
||||
|
||||
it("isRole rejects unknown strings", () => {
|
||||
assert.ok(isRole("pm"));
|
||||
assert.ok(isRole("dev-a"));
|
||||
assert.ok(isRole("dev-b"));
|
||||
assert.ok(!isRole("dev-c"));
|
||||
assert.ok(!isRole(""));
|
||||
assert.ok(!isRole("PM"));
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to confirm they fail**
|
||||
|
||||
```bash
|
||||
cd tools/relay && node --import=tsx/esm --test queue.test.ts
|
||||
```
|
||||
|
||||
Expected: fails with `Cannot find module './queue.ts'` or similar. If it fails with a different error, investigate before continuing.
|
||||
|
||||
- [ ] **Step 3: Write `queue.ts`**
|
||||
|
||||
Create `tools/relay/queue.ts`:
|
||||
|
||||
```typescript
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
export type Role = "pm" | "dev-a" | "dev-b";
|
||||
export type MessageKind = "status" | "question" | "directive" | "free";
|
||||
|
||||
export interface RelayMessage {
|
||||
id: string;
|
||||
from: Role;
|
||||
to: Role;
|
||||
kind: MessageKind;
|
||||
body: string;
|
||||
ts: string;
|
||||
}
|
||||
|
||||
const KNOWN_ROLES = new Set<string>(["pm", "dev-a", "dev-b"]);
|
||||
|
||||
export function isRole(s: string): s is Role {
|
||||
return KNOWN_ROLES.has(s);
|
||||
}
|
||||
|
||||
export class RelayQueue {
|
||||
private readonly queues = new Map<Role, RelayMessage[]>([
|
||||
["pm", []],
|
||||
["dev-a", []],
|
||||
["dev-b", []],
|
||||
]);
|
||||
|
||||
post(from: Role, to: Role, kind: MessageKind, body: string): RelayMessage {
|
||||
const msg: RelayMessage = {
|
||||
id: randomUUID(),
|
||||
from,
|
||||
to,
|
||||
kind,
|
||||
body,
|
||||
ts: new Date().toISOString(),
|
||||
};
|
||||
this.queues.get(to)!.push(msg);
|
||||
return msg;
|
||||
}
|
||||
|
||||
read(forRole: Role): RelayMessage[] {
|
||||
const inbox = this.queues.get(forRole)!;
|
||||
const messages = [...inbox];
|
||||
inbox.length = 0;
|
||||
return messages;
|
||||
}
|
||||
|
||||
pending(forRole: Role): { count: number; kinds: MessageKind[] } {
|
||||
const inbox = this.queues.get(forRole)!;
|
||||
return {
|
||||
count: inbox.length,
|
||||
kinds: inbox.map((m) => m.kind),
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests to confirm they pass**
|
||||
|
||||
```bash
|
||||
cd tools/relay && node --import=tsx/esm --test queue.test.ts
|
||||
```
|
||||
|
||||
Expected output (all 5 passing):
|
||||
```
|
||||
▶ RelayQueue
|
||||
✔ post + read roundtrip returns the message with correct fields
|
||||
✔ consume-once: second read returns empty
|
||||
✔ list_pending does not drain inbox
|
||||
✔ FIFO ordering across multiple senders
|
||||
✔ isRole rejects unknown strings
|
||||
▶ RelayQueue (Xms)
|
||||
ℹ tests 5
|
||||
ℹ pass 5
|
||||
ℹ fail 0
|
||||
```
|
||||
|
||||
If any test fails, fix `queue.ts` before proceeding.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add tools/relay/queue.ts tools/relay/queue.test.ts
|
||||
git commit -m "feat(relay): in-memory queue with consume-once semantics"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: `server.ts`
|
||||
|
||||
**Files:**
|
||||
- Create: `tools/relay/server.ts`
|
||||
|
||||
- [ ] **Step 1: Write `server.ts`**
|
||||
|
||||
Create `tools/relay/server.ts`:
|
||||
|
||||
```typescript
|
||||
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
||||
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
|
||||
import {
|
||||
CallToolRequestSchema,
|
||||
ListToolsRequestSchema,
|
||||
} from "@modelcontextprotocol/sdk/types.js";
|
||||
import http from "node:http";
|
||||
import { RelayQueue, isRole } from "./queue.ts";
|
||||
|
||||
const PORT = 7331;
|
||||
const queue = new RelayQueue();
|
||||
|
||||
const mcpServer = new Server(
|
||||
{ name: "relay", version: "0.1.0" },
|
||||
{ capabilities: { tools: {} } }
|
||||
);
|
||||
|
||||
const TOOLS = [
|
||||
{
|
||||
name: "post_message",
|
||||
description:
|
||||
"Push a message to a recipient's inbox. Returns the assigned message id.",
|
||||
inputSchema: {
|
||||
type: "object" as const,
|
||||
properties: {
|
||||
from: {
|
||||
type: "string",
|
||||
enum: ["pm", "dev-a", "dev-b"],
|
||||
description: "Your role name",
|
||||
},
|
||||
to: {
|
||||
type: "string",
|
||||
enum: ["pm", "dev-a", "dev-b"],
|
||||
description: "Recipient role name",
|
||||
},
|
||||
kind: {
|
||||
type: "string",
|
||||
enum: ["status", "question", "directive", "free"],
|
||||
description: "Message type matching the coordination protocol",
|
||||
},
|
||||
body: {
|
||||
type: "string",
|
||||
description: "Message body — freeform markdown, typically the full formatted block",
|
||||
},
|
||||
},
|
||||
required: ["from", "to", "kind", "body"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "read_messages",
|
||||
description:
|
||||
"Pop and return all pending messages for this recipient. Inbox is empty after this call (consume-once).",
|
||||
inputSchema: {
|
||||
type: "object" as const,
|
||||
properties: {
|
||||
for: {
|
||||
type: "string",
|
||||
enum: ["pm", "dev-a", "dev-b"],
|
||||
description: "Your role name",
|
||||
},
|
||||
},
|
||||
required: ["for"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "list_pending",
|
||||
description:
|
||||
"Return count and kinds of pending messages without consuming them.",
|
||||
inputSchema: {
|
||||
type: "object" as const,
|
||||
properties: {
|
||||
for: {
|
||||
type: "string",
|
||||
enum: ["pm", "dev-a", "dev-b"],
|
||||
description: "Your role name",
|
||||
},
|
||||
},
|
||||
required: ["for"],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
mcpServer.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
|
||||
|
||||
mcpServer.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
const { name, arguments: args } = request.params;
|
||||
const a = args as Record<string, string>;
|
||||
|
||||
if (name === "post_message") {
|
||||
if (!isRole(a.from)) {
|
||||
return { content: [{ type: "text" as const, text: `Error: unknown role "${a.from}"` }], isError: true };
|
||||
}
|
||||
if (!isRole(a.to)) {
|
||||
return { content: [{ type: "text" as const, text: `Error: unknown role "${a.to}"` }], isError: true };
|
||||
}
|
||||
const kind = a.kind as "status" | "question" | "directive" | "free";
|
||||
const msg = queue.post(a.from, a.to, kind, a.body);
|
||||
const ts = new Date(msg.ts).toTimeString().slice(0, 8);
|
||||
const preview = a.body.slice(0, 60).replace(/\n/g, " ");
|
||||
const ellipsis = a.body.length > 60 ? "..." : "";
|
||||
process.stdout.write(`[${ts}] ${a.from} → ${a.to} [${kind}] "${preview}${ellipsis}"\n`);
|
||||
return { content: [{ type: "text" as const, text: JSON.stringify({ id: msg.id }) }] };
|
||||
}
|
||||
|
||||
if (name === "read_messages") {
|
||||
if (!isRole(a.for)) {
|
||||
return { content: [{ type: "text" as const, text: `Error: unknown role "${a.for}"` }], isError: true };
|
||||
}
|
||||
const messages = queue.read(a.for);
|
||||
return { content: [{ type: "text" as const, text: JSON.stringify(messages) }] };
|
||||
}
|
||||
|
||||
if (name === "list_pending") {
|
||||
if (!isRole(a.for)) {
|
||||
return { content: [{ type: "text" as const, text: `Error: unknown role "${a.for}"` }], isError: true };
|
||||
}
|
||||
const result = queue.pending(a.for);
|
||||
return { content: [{ type: "text" as const, text: JSON.stringify(result) }] };
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{ type: "text" as const, text: `Error: unknown tool "${name}"` }],
|
||||
isError: true,
|
||||
};
|
||||
});
|
||||
|
||||
const transports = new Map<string, SSEServerTransport>();
|
||||
|
||||
const httpServer = http.createServer(async (req, res) => {
|
||||
try {
|
||||
if (req.method === "GET" && req.url === "/sse") {
|
||||
const transport = new SSEServerTransport("/message", res);
|
||||
transports.set(transport.sessionId, transport);
|
||||
transport.onclose = () => transports.delete(transport.sessionId);
|
||||
await mcpServer.connect(transport);
|
||||
} else if (req.method === "POST" && req.url?.startsWith("/message")) {
|
||||
const url = new URL(req.url, `http://127.0.0.1:${PORT}`);
|
||||
const sessionId = url.searchParams.get("sessionId") ?? "";
|
||||
const transport = transports.get(sessionId);
|
||||
if (transport) {
|
||||
await transport.handlePostMessage(req, res);
|
||||
} else {
|
||||
res.writeHead(404, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ error: "session not found" }));
|
||||
}
|
||||
} else {
|
||||
res.writeHead(404).end("not found");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[relay] error:", err);
|
||||
if (!res.headersSent) res.writeHead(500).end(String(err));
|
||||
}
|
||||
});
|
||||
|
||||
httpServer.listen(PORT, "127.0.0.1", () => {
|
||||
console.log(`[relay] server ready on :${PORT}`);
|
||||
console.log(`[relay] tools: post_message, read_messages, list_pending`);
|
||||
console.log(`[relay] waiting for connections — Ctrl-C to stop`);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Smoke-test server startup**
|
||||
|
||||
In one terminal:
|
||||
```bash
|
||||
cd tools/relay && npx tsx server.ts
|
||||
```
|
||||
|
||||
Expected output:
|
||||
```
|
||||
[relay] server ready on :7331
|
||||
[relay] tools: post_message, read_messages, list_pending
|
||||
[relay] waiting for connections — Ctrl-C to stop
|
||||
```
|
||||
|
||||
In a second terminal, verify the port is listening:
|
||||
```bash
|
||||
curl -s --max-time 2 http://127.0.0.1:7331/sse | head -3
|
||||
```
|
||||
|
||||
Expected: SSE `data:` stream begins (it won't complete — the connection stays open). Ctrl-C both.
|
||||
|
||||
If the server errors on startup, check that `@modelcontextprotocol/sdk` is installed and review any TypeScript errors by running `npx tsc --noEmit` in `tools/relay/`.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add tools/relay/server.ts
|
||||
git commit -m "feat(relay): MCP SSE server with post_message/read_messages/list_pending"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: `start.sh`
|
||||
|
||||
**Files:**
|
||||
- Create: `tools/relay/start.sh`
|
||||
|
||||
- [ ] **Step 1: Write `start.sh`**
|
||||
|
||||
Create `tools/relay/start.sh`:
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(git -C "$SCRIPT_DIR" rev-parse --show-toplevel)"
|
||||
PORT=7331
|
||||
MODE="manual"
|
||||
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--tmux) MODE="tmux" ;;
|
||||
--kitty) MODE="kitty" ;;
|
||||
--manual) MODE="manual" ;;
|
||||
*) echo "Unknown option: $arg" >&2; echo "Usage: $0 [--manual|--tmux|--kitty]" >&2; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Port check
|
||||
if lsof -ti:"$PORT" &>/dev/null; then
|
||||
echo "Error: port $PORT is already in use."
|
||||
echo "Relay already running? Kill it with: kill \$(lsof -ti:$PORT)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Install deps (no-op if node_modules current)
|
||||
cd "$SCRIPT_DIR"
|
||||
npm install --silent
|
||||
|
||||
# Discover latest coordination prompts for instructions
|
||||
COORD_DIR="$REPO_ROOT/docs/superpowers/coordination"
|
||||
PM_PROMPT="$(ls -t "$COORD_DIR"/*-pm-prompt.md 2>/dev/null | head -1 || echo "(none found — run multi-agent-kickoff skill first)")"
|
||||
DEV_A_PROMPT="$(ls -t "$COORD_DIR"/*-dev-a-prompt.md 2>/dev/null | head -1 || echo "(none found)")"
|
||||
DEV_B_PROMPT="$(ls -t "$COORD_DIR"/*-dev-b-prompt.md 2>/dev/null | head -1 || echo "(none found)")"
|
||||
|
||||
print_manual_instructions() {
|
||||
echo ""
|
||||
echo "╔══════════════════════════════════════════════════════════════╗"
|
||||
echo "║ RELAY SERVER — MULTI-AGENT LIFT LAUNCHER ║"
|
||||
echo "╚══════════════════════════════════════════════════════════════╝"
|
||||
echo ""
|
||||
echo "Open 3 new terminals. In each, start Claude Code and paste"
|
||||
echo "the content BELOW the '---' line from the corresponding file."
|
||||
echo ""
|
||||
echo " Terminal 1 (PM): cat '$PM_PROMPT'"
|
||||
echo " Terminal 2 (Dev A): cat '$DEV_A_PROMPT'"
|
||||
echo " Terminal 3 (Dev B): cat '$DEV_B_PROMPT'"
|
||||
echo ""
|
||||
echo "This terminal becomes the relay log. Keep it open."
|
||||
echo ""
|
||||
echo "══════════════════════════════════════════════════════════════"
|
||||
}
|
||||
|
||||
launch_tmux() {
|
||||
SESSION="relay-lift"
|
||||
tmux new-session -d -s "$SESSION" -n "relay" \
|
||||
"cd '$SCRIPT_DIR' && npx tsx server.ts"
|
||||
tmux new-window -t "$SESSION:" -n "pm" "cd '$REPO_ROOT' && claude"
|
||||
tmux new-window -t "$SESSION:" -n "dev-a" "cd '$REPO_ROOT' && claude"
|
||||
tmux new-window -t "$SESSION:" -n "dev-b" "cd '$REPO_ROOT' && claude"
|
||||
echo ""
|
||||
echo "[relay] Opened tmux session '$SESSION' with 4 windows: relay, pm, dev-a, dev-b."
|
||||
echo "[relay] Paste the kickoff prompt into each Claude window."
|
||||
echo " Prompts:"
|
||||
echo " PM: $PM_PROMPT"
|
||||
echo " Dev A: $DEV_A_PROMPT"
|
||||
echo " Dev B: $DEV_B_PROMPT"
|
||||
echo ""
|
||||
tmux attach-session -t "$SESSION"
|
||||
}
|
||||
|
||||
launch_kitty() {
|
||||
kitty @ launch --new-tab --tab-title "relay" -- \
|
||||
bash -c "cd '$SCRIPT_DIR' && npx tsx server.ts"
|
||||
kitty @ launch --new-window --window-title "PM" -- \
|
||||
bash -c "cd '$REPO_ROOT' && claude"
|
||||
kitty @ launch --new-window --window-title "Dev-A" -- \
|
||||
bash -c "cd '$REPO_ROOT' && claude"
|
||||
kitty @ launch --new-window --window-title "Dev-B" -- \
|
||||
bash -c "cd '$REPO_ROOT' && claude"
|
||||
echo ""
|
||||
echo "[relay] Opened kitty tab 'relay' + 3 windows (PM, Dev-A, Dev-B)."
|
||||
echo " Paste the kickoff prompts into each Claude window."
|
||||
echo " PM: $PM_PROMPT"
|
||||
echo " Dev A: $DEV_A_PROMPT"
|
||||
echo " Dev B: $DEV_B_PROMPT"
|
||||
}
|
||||
|
||||
case "$MODE" in
|
||||
manual)
|
||||
print_manual_instructions
|
||||
exec npx tsx "$SCRIPT_DIR/server.ts"
|
||||
;;
|
||||
tmux)
|
||||
launch_tmux
|
||||
;;
|
||||
kitty)
|
||||
launch_kitty
|
||||
;;
|
||||
esac
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Make executable**
|
||||
|
||||
```bash
|
||||
chmod +x tools/relay/start.sh
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Smoke-test `--manual` mode**
|
||||
|
||||
```bash
|
||||
cd /home/alee/Sources/relicario && tools/relay/start.sh
|
||||
```
|
||||
|
||||
Expected: prints the launch box with prompt paths, then server starts and shows `[relay] server ready on :7331`. Ctrl-C to stop.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add tools/relay/start.sh
|
||||
git commit -m "feat(relay): start.sh launcher with --manual/--tmux/--kitty modes"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Claude Code MCP configuration
|
||||
|
||||
**Files:**
|
||||
- Modify: `.claude/settings.json`
|
||||
|
||||
- [ ] **Step 1: Read current `.claude/settings.json`**
|
||||
|
||||
```bash
|
||||
cat .claude/settings.json
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add the relay MCP server entry**
|
||||
|
||||
The file currently has `{ "enabledPlugins": { ... } }`. Add `"mcpServers"` at the top level:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"relay": {
|
||||
"type": "sse",
|
||||
"url": "http://localhost:7331/sse"
|
||||
}
|
||||
},
|
||||
"enabledPlugins": {
|
||||
"superpowers@claude-plugins-official": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Preserve whatever is already in `enabledPlugins` — only add the `mcpServers` key.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add .claude/settings.json
|
||||
git commit -m "chore(relay): add relay MCP server to project Claude config"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: `docs/superpowers/MULTI-AGENT.md`
|
||||
|
||||
**Files:**
|
||||
- Create: `docs/superpowers/MULTI-AGENT.md`
|
||||
|
||||
- [ ] **Step 1: Write the paradigm README**
|
||||
|
||||
Create `docs/superpowers/MULTI-AGENT.md`:
|
||||
|
||||
```markdown
|
||||
# Multi-Agent Development Paradigm
|
||||
|
||||
This repo uses a three-terminal workflow for large development lifts: one Claude Code session acts as **PM** and two act as **senior developers** (Dev-A, Dev-B), each working in their own git worktree on a parallel feature branch.
|
||||
|
||||
A local relay MCP server eliminates manual message copying between terminals — agents call `post_message`/`read_messages` instead of asking the user to copy-paste.
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
| Role | Terminal | Branch | Responsibilities |
|
||||
|------|----------|--------|-----------------|
|
||||
| PM | 1 | `main` (read-only) | Drive doc-audit follow-ups, review PRs, write CHANGELOG, authorize merges and tagging |
|
||||
| Dev-A | 2 | `feature/<release>-plan-a-*` | Implement Plan A tasks in their own worktree |
|
||||
| Dev-B | 3 | `feature/<release>-plan-b-*` | Implement Plan B tasks in their own worktree |
|
||||
| Relay server | 4 | — | Message bus; Ctrl-C to stop at end of lift |
|
||||
|
||||
**User's job:** authorize merges (the PM asks), resolve escalations the PM can't handle, and watch the streams. You are no longer the message bus.
|
||||
|
||||
---
|
||||
|
||||
## Starting a lift
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- [ ] Kickoff prompts exist in `docs/superpowers/coordination/` (generate with the `multi-agent-kickoff` skill if not)
|
||||
- [ ] No uncommitted changes in main that would confuse the devs
|
||||
- [ ] `tools/relay/` is present (run `ls tools/relay/` to confirm)
|
||||
|
||||
### Launch sequence
|
||||
|
||||
```bash
|
||||
# 1. Start the relay server (this terminal becomes the relay log)
|
||||
tools/relay/start.sh # prints copy-paste instructions, then starts server
|
||||
|
||||
# Optional: use a multiplexer to auto-open all four terminals
|
||||
tools/relay/start.sh --tmux # creates tmux session "relay-lift" with 4 windows
|
||||
tools/relay/start.sh --kitty # creates kitty tab "relay" + 3 windows
|
||||
```
|
||||
|
||||
`start.sh` prints the paths to the three kickoff prompt files. In each Claude Code terminal, run `cat <path>` and paste everything below the `---` line as the first message.
|
||||
|
||||
---
|
||||
|
||||
## Coordination protocol
|
||||
|
||||
Agents communicate by posting structured blocks to each other's inboxes. Four message kinds:
|
||||
|
||||
| Kind | Block header | When used |
|
||||
|------|-------------|-----------|
|
||||
| `status` | `## STATUS UPDATE — DEV-*` | After completing a task, getting blocked, or reaching a review-ready state |
|
||||
| `question` | `## QUESTION TO PM — DEV-*` | When a dev needs PM input mid-task |
|
||||
| `directive` | `## DIRECTIVE TO DEV-*` | When PM instructs a dev to proceed, hold, rescope, or approve a PR |
|
||||
| `free` | (none) | Ad-hoc messages not covered by the above |
|
||||
|
||||
A well-formed `status` block:
|
||||
|
||||
```
|
||||
## STATUS UPDATE — DEV-B
|
||||
Time: 2026-05-02T14:30:00-07:00
|
||||
Branch: feature/v0.5.0-plan-b-extension-ux
|
||||
Task: P4 / error-copy map
|
||||
Status: DONE
|
||||
Last commit: abc1234 feat(extension): centralize ERROR_COPY map
|
||||
Tests: green
|
||||
Notes: No issues. Ready for PM review of P4 before starting B1.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Using the relay tools
|
||||
|
||||
All three Claude Code sessions have these tools available when the relay server is running:
|
||||
|
||||
```
|
||||
post_message(from, to, kind, body) → { id }
|
||||
read_messages(for) → RelayMessage[] (drains inbox)
|
||||
list_pending(for) → { count, kinds } (non-destructive)
|
||||
```
|
||||
|
||||
Typical dev flow per task:
|
||||
|
||||
```
|
||||
1. read_messages(for="dev-b") # check for directives before starting
|
||||
2. ... do the work ...
|
||||
3. post_message(from="dev-b", to="pm", kind="status", body="## STATUS UPDATE...")
|
||||
```
|
||||
|
||||
Typical PM flow:
|
||||
|
||||
```
|
||||
1. read_messages(for="pm") # see what devs posted
|
||||
2. ... review ...
|
||||
3. post_message(from="pm", to="dev-b", kind="directive", body="## DIRECTIVE TO DEV-B...")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## If the relay server isn't running
|
||||
|
||||
Claude Code will show a yellow MCP connection warning for the `relay` server. The tools will be unavailable.
|
||||
|
||||
Agents fall back to the manual protocol: they emit the structured blocks as text and ask the user to copy-paste them to the relevant terminal. This is slower but fully functional — the coordination protocol works either way.
|
||||
|
||||
To restart a crashed server mid-lift:
|
||||
|
||||
```bash
|
||||
tools/relay/start.sh
|
||||
```
|
||||
|
||||
In-flight messages are lost on restart. Any agent with unread messages should re-post them.
|
||||
|
||||
---
|
||||
|
||||
## Generating kickoff prompts
|
||||
|
||||
Use the `multi-agent-kickoff` skill (in the `superpowers` plugin). It auto-discovers the spec and plans for the release, substitutes all placeholders including the relay paragraph, and writes files to `docs/superpowers/coordination/`.
|
||||
|
||||
The skill reminder: run `tools/relay/start.sh` **before** opening the three Claude Code sessions — the MCP tools need the server to be up when each session initializes.
|
||||
|
||||
---
|
||||
|
||||
## Ending a lift
|
||||
|
||||
1. PM emits `REVIEW-COMPLETE` and `MERGE-APPROVED` for each dev's PR
|
||||
2. User merges each PR (the PM session does `gh pr merge` with user authorization)
|
||||
3. PM tags the release (only after explicit user `yes`)
|
||||
4. Ctrl-C the relay terminal — all in-memory messages are discarded
|
||||
|
||||
---
|
||||
|
||||
## Roles and boundaries (quick reference)
|
||||
|
||||
**PM must not:** write feature code, merge without user authorization, tag without user approval, run `git push --force` / `git reset --hard` without asking.
|
||||
|
||||
**Devs must not:** merge their branch to main, push `--force`, run `git reset --hard` without asking.
|
||||
|
||||
**User must:** authorize all merges and the release tag. Everything else is delegated.
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add docs/superpowers/MULTI-AGENT.md
|
||||
git commit -m "docs: add multi-agent development paradigm README"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Update `multi-agent-kickoff` skill
|
||||
|
||||
**Files:**
|
||||
- Modify: `~/.claude/skills/multi-agent-kickoff/templates/pm-prompt.md`
|
||||
- Modify: `~/.claude/skills/multi-agent-kickoff/templates/dev-prompt.md`
|
||||
- Modify: `~/.claude/skills/multi-agent-kickoff/SKILL.md`
|
||||
|
||||
- [ ] **Step 1: Read current templates**
|
||||
|
||||
```bash
|
||||
cat ~/.claude/skills/multi-agent-kickoff/templates/pm-prompt.md
|
||||
cat ~/.claude/skills/multi-agent-kickoff/templates/dev-prompt.md
|
||||
```
|
||||
|
||||
Note where the "Setup" section ends in each template. The relay paragraph goes right after it (before "Required reading").
|
||||
|
||||
- [ ] **Step 2: Add `<<RELAY_PARAGRAPH>>` to `pm-prompt.md`**
|
||||
|
||||
In `~/.claude/skills/multi-agent-kickoff/templates/pm-prompt.md`, find the "## Setup" section and add the placeholder block immediately after it (before the "## Required reading" heading):
|
||||
|
||||
```markdown
|
||||
<<RELAY_PARAGRAPH>>
|
||||
```
|
||||
|
||||
The generated output for this placeholder (substituted by the skill at generation time) is:
|
||||
|
||||
```markdown
|
||||
## Relay server
|
||||
|
||||
A message-bus MCP server is running on `localhost:7331`. You have three native tools:
|
||||
|
||||
- `post_message(from, to, kind, body)` — push a message; `from` is always `"pm"` for you
|
||||
- `read_messages(for)` — drain your inbox; call with `for="pm"` before each action
|
||||
- `list_pending(for)` — check inbox count without consuming
|
||||
|
||||
Recipients: `pm`, `dev-a`, `dev-b`. Use these instead of asking the user to copy-paste. After sending any directive, call `post_message(from="pm", to="dev-a", kind="directive", body="...")`.
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add `<<RELAY_PARAGRAPH>>` to `dev-prompt.md`**
|
||||
|
||||
In `~/.claude/skills/multi-agent-kickoff/templates/dev-prompt.md`, find the "## Setup" section and add immediately after it (before "## Required reading"):
|
||||
|
||||
```markdown
|
||||
<<RELAY_PARAGRAPH>>
|
||||
```
|
||||
|
||||
The generated output for this placeholder is role-specific (uses `<<DEV_ROLE>>`):
|
||||
|
||||
```markdown
|
||||
## Relay server
|
||||
|
||||
A message-bus MCP server is running on `localhost:7331`. You have three native tools:
|
||||
|
||||
- `post_message(from, to, kind, body)` — push a message; your `from` is always `"<<DEV_ROLE>>"`
|
||||
- `read_messages(for)` — drain your inbox; call with `for="<<DEV_ROLE>>"` before each task
|
||||
- `list_pending(for)` — check inbox count without consuming
|
||||
|
||||
Recipients: `pm`, `dev-a`, `dev-b`. Use these instead of asking the user to copy-paste. Before starting each task: `read_messages(for="<<DEV_ROLE>>")`. After emitting any status/question block: `post_message(from="<<DEV_ROLE>>", to="pm", kind="status"|"question", body="...")`.
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Update `SKILL.md` — add two entries to the placeholder reference table**
|
||||
|
||||
In `~/.claude/skills/multi-agent-kickoff/SKILL.md`, find the "### Common to all prompts" section of the Placeholder reference and add:
|
||||
|
||||
```markdown
|
||||
- `<<RELAY_PARAGRAPH>>` — the relay server instruction block (substituted from the template above). For the PM prompt, `from` is hardcoded to `"pm"`. For dev prompts, uses `<<DEV_ROLE>>`.
|
||||
```
|
||||
|
||||
In the "### Per-dev" section, add:
|
||||
|
||||
```markdown
|
||||
- `<<DEV_ROLE>>` — lowercase relay role name, e.g. `dev-a`, `dev-b`. Derived from `<<DEV_LETTER>>` by lowercasing and prepending `dev-`. Set when `<<DEV_LETTER>>` is set.
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Update `SKILL.md` — step 8 (kickoff instructions)**
|
||||
|
||||
Find step 8 in the Process section ("Print kickoff instructions") and prepend a bullet:
|
||||
|
||||
```markdown
|
||||
8. **Print kickoff instructions.** Tell the user exactly what to do:
|
||||
- **Start the relay server first:** `tools/relay/start.sh` (or `--tmux`/`--kitty` for auto-layout). The server must be running before the sessions open so the MCP tools initialize correctly.
|
||||
- Open three terminal windows (or panes — their choice of multiplexer)
|
||||
...rest of existing bullets unchanged...
|
||||
```
|
||||
|
||||
Also update the "After generation" section — change bullet 4 ("From that point on, they're the message bus...") to:
|
||||
|
||||
```markdown
|
||||
4. The relay server handles message routing — agents call `post_message`/`read_messages` directly. The user only needs to step in for escalations the PM can't resolve, or if the relay server is down (manual fallback: copy-paste the block to the relevant terminal as before)
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Commit skill changes**
|
||||
|
||||
The skill files live outside the git repo, so no git commit needed. Verify the changes look right:
|
||||
|
||||
```bash
|
||||
grep -n "RELAY_PARAGRAPH\|DEV_ROLE\|relay server" ~/.claude/skills/multi-agent-kickoff/SKILL.md | head -10
|
||||
grep -n "RELAY_PARAGRAPH" ~/.claude/skills/multi-agent-kickoff/templates/pm-prompt.md
|
||||
grep -n "RELAY_PARAGRAPH" ~/.claude/skills/multi-agent-kickoff/templates/dev-prompt.md
|
||||
```
|
||||
|
||||
Expected: each grep returns at least one match.
|
||||
|
||||
---
|
||||
|
||||
## Final verification
|
||||
|
||||
- [ ] **Run queue tests one more time from repo root**
|
||||
|
||||
```bash
|
||||
cd tools/relay && node --import=tsx/esm --test queue.test.ts
|
||||
```
|
||||
|
||||
Expected: 5 passing, 0 failing.
|
||||
|
||||
- [ ] **Start server and verify it binds**
|
||||
|
||||
```bash
|
||||
tools/relay/start.sh &
|
||||
sleep 1
|
||||
curl -s --max-time 1 http://127.0.0.1:7331/sse | head -1 || true
|
||||
kill %1
|
||||
```
|
||||
|
||||
Expected: `data:` line appears (SSE stream started), then server killed cleanly.
|
||||
|
||||
- [ ] **Verify MCP config is present**
|
||||
|
||||
```bash
|
||||
python3 -c "import json; d=json.load(open('.claude/settings.json')); print(d['mcpServers']['relay'])"
|
||||
```
|
||||
|
||||
Expected: `{'type': 'sse', 'url': 'http://localhost:7331/sse'}`
|
||||
|
||||
- [ ] **Verify skill placeholders were added**
|
||||
|
||||
```bash
|
||||
grep "RELAY_PARAGRAPH" ~/.claude/skills/multi-agent-kickoff/templates/pm-prompt.md \
|
||||
~/.claude/skills/multi-agent-kickoff/templates/dev-prompt.md
|
||||
```
|
||||
|
||||
Expected: one match per file.
|
||||
265
docs/superpowers/reviews/2026-05-04-architecture-review.md
Normal file
265
docs/superpowers/reviews/2026-05-04-architecture-review.md
Normal file
@@ -0,0 +1,265 @@
|
||||
# Relicario — Whole-Codebase Architecture Review
|
||||
|
||||
**Date:** 2026-05-04
|
||||
**Reviewers:** DEV-A (Rust core), DEV-B (Rust consumers — CLI, server, WASM), DEV-C (TypeScript — extension, relay)
|
||||
**Synthesis:** PM
|
||||
**Goal lens:** "Make this app's architecture logical and readable for a smart developer who doesn't know Rust but wants to learn by tinkering."
|
||||
|
||||
## Executive summary
|
||||
|
||||
The architecture is fundamentally sound: bytes-in/bytes-out core, no I/O leakage from `relicario-core`, a server that structurally cannot decrypt (no AEAD or KDF crate in its dep graph), a service-worker boundary in the extension that holds (content scripts make zero WASM calls), and CLI/extension parity at 22/23 capabilities. What hurts the goal lens is uneven detail: a few outsized files (`cli/main.rs` 2641 LOC, `extension/src/setup/setup.ts` 1220 LOC, `extension/src/vault/vault.ts` 1027 LOC) absorb concerns that belong in smaller modules, and a couple of cross-cutting boilerplate clusters (16× duplicated git-shell error UX in the CLI; duplicated SW router helpers; hand-maintained `wasm.d.ts`) make a learner re-derive the same pattern repeatedly. The single most important thing to address is a defense-in-depth crypto issue spanning Rust and JS: `SessionHandle` has no `impl Drop`, so the wasm-bindgen-generated `.free()` is a no-op for cleanup — the master key stays in WASM linear memory until JS explicitly calls `lock()`. The strongest aspects worth preserving are the documentation density of the security-critical core files (`crypto.rs`, `imgsecret.rs`, `backup.rs`, `tar_safe.rs` all open with multi-paragraph rationale), the discriminated-union message contract in the extension (`shared/messages.ts` + the popup-only / content-callable capability sets), and the server's structural enforcement of the ciphertext-only invariant via its import surface. These three patterns are the model for what every other surface should look like.
|
||||
|
||||
## Top-priority recommendations (P1)
|
||||
|
||||
### P1.1 — `SessionHandle` has no `impl Drop`; `.free()` is a cleanup no-op
|
||||
**Area:** cross-cutting (wasm + extension)
|
||||
**File(s):** `crates/relicario-wasm/src/lib.rs:15-23`, `crates/relicario-wasm/src/session.rs:1-58`, `extension/src/service-worker/session.ts:26`
|
||||
**Found by:** DEV-B (Rust side, headline finding); DEV-C (symmetric JS-side concern, originally [P2])
|
||||
**Observation:** `SessionHandle` is `pub struct SessionHandle(u32)` with no `Drop` impl. wasm-bindgen's auto-generated JS `.free()` drops a `u32` — i.e. nothing. The `SESSIONS` HashMap entry stays alive with the master key and image_secret in WASM linear memory until JS calls the explicit `lock(handle)` (which calls `session::remove`). DEV-C separately observed `service-worker/session.ts:26` swallows `free()` errors with `try { current.free() }` — that swallow is hiding the fact that the call wasn't doing crypto cleanup anyway.
|
||||
**Why it matters for the user's goal:** This is the only finding in the review where the gap between "what the code looks like it does" and "what it actually does" is dangerous. A tinkerer reading `session.ts` reasonably assumes `free()` cleans up the WASM-side key; it does not. Defense-in-depth here is one Rust block and one JS audit.
|
||||
**Suggested direction:** Add `impl Drop for SessionHandle { fn drop(&mut self) { session::remove(self.0); } }` to the WASM crate so `.free()` becomes the safety net and `lock()` becomes the explicit "I am done" call. In parallel, remove the `try { ... }` swallow at `service-worker/session.ts:26` (let exceptions propagate or log + counter) and audit every `.free()` callsite under `extension/src/` to ensure `wasm.lock(handle)` happens first regardless. A `wasm-bindgen-test` covering construct → drop → confirm registry empty locks the contract.
|
||||
**Effort:** S (Rust fix) + S (JS audit) = ~1-2 hours total
|
||||
|
||||
### P1.2 — `crates/relicario-cli/src/main.rs` is a 2641-line monolith
|
||||
**Area:** cli
|
||||
**File(s):** `crates/relicario-cli/src/main.rs:1-2641`
|
||||
**Found by:** DEV-B
|
||||
**Observation:** The clap surface (lines 1-455) is a tour of the product and reads beautifully. Past line 456 the file becomes flat: every `cmd_*`, every `build_*_item`, every `edit_*`, six prompt helpers, three parsing helpers, and 24+ git shell-outs all live as peers. `cmd_add` calls 7 different `build_*_item` functions (each ~50-60 lines) with no module boundaries. Searching "where does add work?" requires scrolling, not navigating.
|
||||
**Why it matters for the user's goal:** This is the first file a tinkerer opens after `cargo run -p relicario-cli -- --help`. Today they have to scroll through 2200 lines of flat code to follow any one command end-to-end. Splitting this is the precondition for fixing P1.3 and centralizing several other CLI patterns (groups-cache discipline, prompt helpers).
|
||||
**Suggested direction:** Keep `main.rs` as clap definitions + the dispatching `match` (~470 lines). Split into `commands/{add,get,list,edit,trash,backup,import,attach,settings,sync,status,device,recovery_qr}.rs`, plus `prompt.rs` (the six `prompt_*` helpers + `prompt_secret`) and `parse.rs` (`parse_month_year`, `base32_decode_lenient`, `guess_mime`). Same LOC, different reading experience.
|
||||
**Effort:** M (mechanical split, no logic changes)
|
||||
|
||||
### P1.3 — Git invocation boilerplate duplicated 16× with one-line errors
|
||||
**Area:** cli
|
||||
**File(s):** `crates/relicario-cli/src/main.rs:601, 602, 610, 986, 988, 1477, 1480, 1767, 1897, 1900, 2432, 2438, 2533, 2540` (and others)
|
||||
**Found by:** DEV-B
|
||||
**Observation:** Every `git_command` invocation that bails uses the same shape: `.args([...]).status()? + if !status.success() { bail!("git foo failed") }`. Child stderr is inherited interactively, but in test runs and noninteractive tooling it's lost, and the bail message is just the verb. When this fires in the wild — pre-receive reject, missing remote, dirty tree, signing-key prompt — the user sees one line and nothing actionable.
|
||||
**Why it matters for the user's goal:** This is the *entire* error UX for the git side of the CLI. Failures are common, the diagnostic is uniformly unhelpful, and a learner will hit "git commit failed" with no context the first time they touch a misconfigured remote.
|
||||
**Suggested direction:** Add `helpers::git_run(repo, args, context)` that uses `.output()` (capturing stderr), prints captured stderr unmodified on failure, and embeds the human-readable `context` ("commit add: GitHub", "register device", "purge trashed item"). 16 copies become one-liners.
|
||||
**Effort:** S (single helper + sweep)
|
||||
|
||||
### P1.4 — `extension/src/setup/setup.ts` bypasses the SW and orchestrates WASM directly
|
||||
**Area:** extension
|
||||
**File(s):** `extension/src/setup/setup.ts:28-37`, `:1118-1120` (and the whole 1220-LOC file)
|
||||
**Found by:** DEV-C
|
||||
**Observation:** Popup, vault tab, and content scripts all funnel WASM through the service-worker. `setup.ts` is the only surface that imports `relicario-wasm` directly and orchestrates `unlock` / `embed_image_secret` / `register_device` / `manifest_encrypt` itself — duplicating ~400 LOC of crypto orchestration the SW already knows how to do. Side-effect: the setup tab can't be locked by the same session-timer the rest of the extension uses, and `WizardState` (passphrase + JPEG bytes + WASM handle) persists in module scope if the user abandons mid-wizard.
|
||||
**Why it matters for the user's goal:** This is the single biggest "code lies about the pattern" surface in the extension. A learner who opens `setup.ts` first will see WASM imported directly and conclude that's how the extension works — it isn't; everywhere else routes through `chrome.runtime.sendMessage`. The file is also a 1220-LOC procedural wizard that could be a step registry.
|
||||
**Suggested direction:** Add `create_vault` and `attach_vault` messages to the SW; turn `setup.ts` into a UI that posts those messages with the gathered config + image bytes. Convert the 6-step flow into a `{ id, render, attach }[]` step registry. The 1220 LOC drops to ~500. Add a `clearWizardState()` bound to `beforeunload` and to "return to step 0" so abandoned wizards don't persist sensitive material.
|
||||
**Effort:** L (architectural — touches setup, the SW message router, and `wasm.d.ts`/types)
|
||||
|
||||
### P1.5 — `extension/src/vault/vault.ts` is a 1027-LOC do-everything file
|
||||
**Area:** extension
|
||||
**File(s):** `extension/src/vault/vault.ts:1-1027`
|
||||
**Found by:** DEV-C
|
||||
**Observation:** Single file owns: shell init, hash routing, sidebar render, list render, drawer state, type-picker, form-wrapper, deep-link routing, teardown coupling. Adding a new pane view today is a 5-place edit (`teardownPaneComponents` lines 813-820 is the symptom). Two state-management oddities sit inside: vault tab intercepts `vault_locked` errors via the RPC layer while the popup uses the `session_expired` event (two channels for one outcome), and `state.drawerOpen = true` survives navigation to non-list views.
|
||||
**Why it matters for the user's goal:** This is the second steepest cliff for a tinkerer (after `setup.ts`). The vault tab is the user's primary fullscreen experience; the file that drives it should read as orchestration, not implementation.
|
||||
**Suggested direction:** Split into `vault-shell.ts`, `vault-sidebar.ts`, `vault-list.ts`, `vault-drawer.ts`, `vault-form-wrapper.ts`, leaving `vault.ts` to own only routing and state. While doing so, lift the `vault_locked` RPC intercept into `shared/state.ts` (or a wrapper around `sendMessage`) so popup and vault use one path; reset `state.drawerOpen` at the start of `renderPane` for non-list views.
|
||||
**Effort:** M (mechanical split, plus one channel-unification touch)
|
||||
|
||||
### P1.6 — `extension/src/shared/state.ts` is fully `any`-typed
|
||||
**Area:** extension (shared)
|
||||
**File(s):** `extension/src/shared/state.ts:10-35`
|
||||
**Found by:** DEV-C
|
||||
**Observation:** `StateHost` is the bridge that lets popup components run inside the vault tab — and it's the bridge most likely to drift between the two render targets — but its entire contract is `any`-typed. TS gives no signal when popup-only state shape diverges from vault-tab expectations. Module-scope `host` singleton additionally has no double-registration guard; tests forget a reset and the leak silently breaks isolation.
|
||||
**Why it matters for the user's goal:** Two-render-target reuse is exactly the kind of architecture decision that pays off in maintainability — but only if the contract is type-checked. As-is, the bridge is the weakest learning surface in the extension.
|
||||
**Suggested direction:** Define a concrete `StateHost` interface: `state: PopupState`, `navigate: (view: View) => void`, `popOutToTab(): void`, `isInTab(): boolean`, `openVaultTab(hash?: string): void`. Make `getState`/`setState` generic over `keyof PopupState`. Throw on `registerHost()` re-register; export a `__resetHostForTests()` helper.
|
||||
**Effort:** S-M (type definitions + sweep through callers)
|
||||
|
||||
### P1.7 — `crates/relicario-core/src/recovery_qr.rs` is undocumented
|
||||
**Area:** core
|
||||
**File(s):** `crates/relicario-core/src/recovery_qr.rs:1-130`
|
||||
**Found by:** DEV-A
|
||||
**Observation:** No module-level `//!` header. No doc comments on `RecoveryQrPayload`, `generate_recovery_qr`, `unwrap_recovery_qr`, or `recovery_qr_to_svg`. Magic constants (`RREC`, `VERSION = 0x01`, `PAYLOAD_LEN = 109`) sit at the top with no explanation; the 4+1+32+24+48 layout is hand-counted with no struct, diagram, or asserts. The domain-separation prefix `b"relicario-recovery-v1\0"` exists but isn't explained. `production_params()` redeclares `KdfParams::default()` values with no comment on why the recovery format pins them.
|
||||
**Why it matters for the user's goal:** Every other security-relevant file in the core (`crypto.rs`, `imgsecret.rs`, `backup.rs`, `tar_safe.rs`) has explanatory framing. A learner hitting `recovery_qr.rs` sees a different style and assumes either it doesn't matter or they've stumbled out of the documented zone. It does matter — this is the vault-key escape hatch — and a misuse here (e.g., reusing `production_params` as `KdfParams::default()` and then changing the default) silently breaks all extant recovery QRs.
|
||||
**Suggested direction:** Add a module-level `//!` summarizing the format, the KDF-input domain separation, and the parameter-pinning rationale. Add a short ASCII diagram of the 109-byte layout near the constants. Doc-comment the four public functions. Either replace `production_params()` with a `const` or add a comment explaining the deliberate divergence from `KdfParams::default()`.
|
||||
**Effort:** S (documentation only)
|
||||
|
||||
### P1.8 — `tools/relay/queue.test.ts` fails on uncommitted state
|
||||
**Area:** tooling
|
||||
**File(s):** `tools/relay/queue.test.ts:54`
|
||||
**Found by:** DEV-C (verified via `bun test` → 1 fail / 4 pass)
|
||||
**Observation:** Uncommitted changes added `dev-c` to the `Role` union and `KNOWN_ROLES` set in `queue.ts`, but the test still asserts the old enum: `assert.ok(!isRole("dev-c"))`. One-line fix.
|
||||
**Why it matters for the user's goal:** A learner running `bun test` from `tools/relay/` immediately sees a failure and assumes the codebase is broken. The relay is the literal coordination substrate of this review; a green test run is table stakes.
|
||||
**Suggested direction:** Update the test assertion to `assert.ok(isRole("dev-c"))` and add a negative case (`assert.ok(!isRole("dev-d"))`). Confirm `start.sh` opens a fourth window for dev-c (DEV-C suspected `:80` still hardcodes "Dev-B" in user-facing output).
|
||||
**Effort:** S (one-line edit + one-launcher-line check)
|
||||
|
||||
### P1.9 — Duplicated SW router helpers (storage helpers + `itemToManifestEntry`)
|
||||
**Area:** extension (service-worker)
|
||||
**File(s):** `extension/src/service-worker/router/popup-only.ts:687-703` and `:~169`; `extension/src/service-worker/router/content-callable.ts:187-205` and `:~169`
|
||||
**Found by:** DEV-C
|
||||
**Observation:** Three identical `chrome.storage.local` helpers (`loadDeviceSettings`, `loadBlacklist`, `saveBlacklist`) and the 17-line `itemToManifestEntry()` projection are duplicated across both router files. Both code paths can mutate the blacklist via different definitions; future drift will silently corrupt one path. Manifest schema refactors will need to be made twice.
|
||||
**Why it matters for the user's goal:** A learner reading either router file sees private helpers and assumes that's where they live; finding the same name in the sibling router with a near-identical body is a routine "wait, why is this duplicated?" moment that should not exist in a tightly-typed message-router architecture.
|
||||
**Suggested direction:** Extract the three storage helpers to `service-worker/storage.ts`; move `itemToManifestEntry` into `service-worker/vault.ts`. Import from both router files. Pair this with [P1.6] for `shared/state.ts` typing — both are about giving the extension's "shared utilities" a concrete shape.
|
||||
**Effort:** S (extract + sweep)
|
||||
|
||||
### P1.10 — Pure parsers in CLI that the extension will eventually need
|
||||
**Area:** cli → core
|
||||
**File(s):** `crates/relicario-cli/src/main.rs:942-980` (`parse_month_year`, `base32_decode_lenient`, `guess_mime`)
|
||||
**Found by:** DEV-B
|
||||
**Observation:** Three pure parsers producing typed core values currently live only in the CLI. Per the project's CLI/extension parity philosophy (CLAUDE.md memory rule), anything the CLI does in pure logic the extension will eventually need too — QR-import, month-year smart input, attachment MIME detection. There is also a `base32_encode` in `core/item.rs` and a `decode_base32_totp` in `core/import_lastpass.rs` that are inverse pairs in different modules — DEV-A flagged the same shape from the core side ([P2] base32 has three implementations).
|
||||
**Why it matters for the user's goal:** Parity is a stated design philosophy, and parser drift between CLI and extension is the most likely place for it to fail silently.
|
||||
**Suggested direction:** Migrate to core: `MonthYear::parse` (already partial in `time.rs`), `Totp::parse_secret` (or `ItemCore::parse_totp_seed`), `mime::guess_for_extension`. Pair with extracting a `pub(crate) mod base32` in core with `encode_rfc4648` / `decode_rfc4648`, leaving Steam's bespoke alphabet where it is. The CLI keeps thin wrappers; the WASM crate exposes the new functions; the extension calls them via SW message handlers.
|
||||
**Effort:** M (move + adjust CLI callers + add WASM exports)
|
||||
|
||||
## P2 recommendations
|
||||
|
||||
### Core (Rust)
|
||||
- **`extract_with_crop_recovery` is narrower than the spec describes.** `crates/relicario-core/src/imgsecret.rs:849-899`. Spec promises 15% from any edge; impl pins `dx=0, dy=0` and varies only `orig_w`/`orig_h`. Left/top crops won't recover. Either extend the recovery loop or update the spec language to "right/bottom crop tolerance only." (DEV-A)
|
||||
- **Stale "in-progress rewrite" headers** in `vault.rs:1-7`, `manifest.rs:1-2`, `attachment.rs:1-4`. Each comment describes work that already shipped. Replace with one-line module summaries. (DEV-A)
|
||||
- **Two dead fields in `EmbedRegion`** (`imgsecret.rs:225-229`). `region_width`/`region_height` are computed, stored, and silenced with `#[allow(dead_code)]`. Either delete or comment the future use. (DEV-A)
|
||||
- **Three base32 implementations.** `item.rs:255-275`, `import_lastpass.rs:202-220`, `item_types/totp.rs:13` (Steam, intentionally different). Extract a `pub(crate) mod base32`; leave Steam alone with a neighbour comment. (DEV-A; folds into P1.10.)
|
||||
- **Backup format embeds the reference JPEG as base64-in-JSON** (`backup.rs:148-152, 274-280, 343-345`). Round-trip works; bloats by ~33% over the binary baseline post-zstd. Defer until backup-size pressure shows up. (DEV-A)
|
||||
|
||||
### CLI (Rust)
|
||||
- **`build_*_item` functions mix prompting, parsing, and core construction.** `main.rs:664-921`. Compress with a `prompt_or_flag<T>` helper. (DEV-B)
|
||||
- **`refresh_groups_cache` invocation discipline is manual at 7 sites.** `main.rs:641, 998, 1123, 1197, 1414, 1432, 1474`. Rule "every mutating handler must call this" is unenforced. Wrap in `Vault::after_manifest_change(&self, manifest: &Manifest)`. (DEV-B)
|
||||
- **`ParamsFile` defined twice with mismatching shapes.** Write side `main.rs:2287` has `aead`, `salt_path`, `format_version`; read side `session.rs:114` takes only `kdf`. Single struct in core or shared session module. (DEV-B)
|
||||
- **`cmd_purge` and `cmd_trash_empty` duplicate the manifest-add-and-commit dance** (`main.rs:1476-1480, 1896-1900`). 50-item purge does 150 git invocations; batch the staging. (DEV-B)
|
||||
|
||||
### Server (Rust)
|
||||
- **`generate-hook` assumes `$PATH`.** `relicario-server/src/main.rs:170`. Most Gitea hook environments don't have `/usr/local/bin`; `command not found` is the failure mode. Embed `current_exe()` or emit an explicit `PATH=` line. (DEV-B)
|
||||
- **Bootstrap branch is permissive.** `:38-44, 54-57`. Missing `.relicario/devices.json` at `newrev` is treated as bootstrap. An attacker pushing a brand-new branch that strips `.relicario/` could push unsigned commits. Either document the rule or enforce: `oldrev != 0` ⇒ `devices.json` must exist in `newrev`. (DEV-B)
|
||||
- **stdin-parsing lives in the shell hook only.** `:130-189`. Wiring up the binary by hand isn't possible without re-implementing the per-line `<old> <new> <ref>` parse. Add `verify-commit --from-stdin` or doc-comment the constraint. (DEV-B)
|
||||
- **Test coverage gaps** (`tests/verify_commit.rs`): unsigned-commit, malformed `devices.json`, bootstrap-empty, stripped-`.relicario/`. Each is one extra `#[test]`. (DEV-B)
|
||||
|
||||
### WASM (Rust)
|
||||
- **Redundant double-lookup pattern.** `lib.rs:73, 84, 92, 103, 111, 122, 160, 172` all do `need_key(handle)?` then `session::with(handle.0, |k| ...).unwrap()` — two HashMap lookups per call. Single-`with` helper that returns `JsError` on miss. (DEV-B)
|
||||
- **`Vec<u8>` getters clone on every read.** `EncryptedAttachment::aid` and `bytes` (`lib.rs:141-150`). Document "call once, cache locally" or consume by value. (DEV-B)
|
||||
- **`wasm_*_recovery_qr` prefix is inconsistent** with everything else. `lib.rs:497, 510`. Rename to `generate_recovery_qr_svg` and `unwrap_recovery_qr` (and update `extension/src/wasm.d.ts`). Trivial breaking rename — do it before any new caller appears. (DEV-B)
|
||||
- **`device.rs` and `session.rs` use different concurrency primitives** (`Lazy<Mutex<...>>` vs `thread_local! { RefCell<...> }`). Pick one. (DEV-B)
|
||||
- **`extension/src/wasm.d.ts` is hand-written and explicitly requires manual sync** with `crates/relicario-wasm/src/lib.rs`. Every change to `#[wasm_bindgen]` must be mirrored manually; today they're aligned. Add a CI check comparing `wasm-pack`–generated `.d.ts` against this file, or import the generated file directly. (DEV-C; partner finding to DEV-B's WASM section.)
|
||||
|
||||
### Extension — service-worker
|
||||
- **Inactivity timer reset skips content-callable messages** (`service-worker/index.ts:76-78`). A user actively autofilling but not opening the popup will be force-locked despite continuous use. Reset on all messages except known read-only content calls. (DEV-C)
|
||||
- **Session expiry clears `state.manifest` but leaves `state.gitHost`** (`service-worker/index.ts:51-58`). Cached git-host client survives expiry; rotation could mix with stale connection state. Null `state.gitHost` alongside. (DEV-C)
|
||||
- **`try { current.free() }` swallows free errors** (`service-worker/session.ts:26`). See P1.1 — this becomes important once `impl Drop` lands. (DEV-C)
|
||||
|
||||
### Extension — popup + components
|
||||
- **Duplicated teardown helpers** (`settings.ts:56-65` and `settings-vault.ts:15-22`). After the recent stub-restore commits (`8baef5b`, `ddfb95d`), teardown leaks are a known regression class — duplicated cleanup is exactly the pattern that re-introduces them. Extract a single `teardownSettingsCommon()`. (DEV-C; originally P1, demoted by PM because the leak vector is small but the duplication is real.)
|
||||
- **Settings module-scope singletons** (`settings.ts:33-34`). `pendingVaultSettings` and `sessionHandle` survive section navigation. Reset on `renderSection` entry, or scope into a closure per render. (DEV-C)
|
||||
- **Item-list popover wires listeners on every render** (`item-list.ts:152-353`) without a reuse path. Cache the DOM and reuse, or use AbortController. (DEV-C)
|
||||
- **`Promise.all` without per-promise error handling** (`devices.ts:47-50`, `trash.ts:39-46`). Single rejected RPC fails the whole render. Use `Promise.allSettled`. (DEV-C)
|
||||
- **Generator-panel cleanup not idempotent-guarded** (`generator-panel.ts:89-261`). Currently safe by accident. Add `if (!activePanel) return`. (DEV-C)
|
||||
- **Popup teardown calls every type module unconditionally** (`popup.ts:178-181`). Track last-mounted type. (DEV-C)
|
||||
|
||||
### Extension — vault tab
|
||||
- **Vault tab intercepts `vault_locked` via RPC; popup uses `session_expired` event.** `vault/vault.ts:47-74`. See P1.5 — collapse into one mechanism in `shared/state.ts`. (DEV-C)
|
||||
- **Drawer doesn't auto-close on non-list view changes** (`vault.ts:495-536`). Reset `state.drawerOpen = false` in `renderPane`. (DEV-C)
|
||||
- **Sidebar re-renders on every search keystroke without debounce** (`vault.ts:648-695`). 50-100ms debounce. (DEV-C)
|
||||
|
||||
### Extension — content scripts
|
||||
- **`fillFields()` returns silently when no password field is found** (`content/fill.ts:50-64`). Dynamic forms can race the listener; user clicks autofill, nothing happens. Send a `fill_failed` ack. (DEV-C)
|
||||
- **MutationObserver scan is not debounced** (`content/detector.ts:96-103`). SPA churn re-runs the full scan many times per second. Wrap in `requestIdleCallback` or 200ms timer. (DEV-C)
|
||||
- **Outside-click listener leaks on alternate close paths** (`content/icon.ts:169-175`). AbortController scoped to picker open, or remove in every close path. (DEV-C)
|
||||
|
||||
### Extension — setup
|
||||
- **`setup.ts` 1220-LOC procedural wizard.** Step registry pattern. See P1.4. (DEV-C)
|
||||
- **`WizardState` is module-scope; sensitive material persists if user abandons mid-wizard** (`setup.ts:69-94`). Add `clearWizardState()` on `beforeunload` and on returning to step 0. Folds into P1.4. (DEV-C)
|
||||
- **Manifest path constants duplicated** between `setup/probe.ts:11-23` and `service-worker/vault.ts`. Define `VAULT_PATHS` in `shared/types.ts` (or `shared/paths.ts`). (DEV-C)
|
||||
|
||||
### Extension — shared utilities
|
||||
- **Base `Response` is `{ data?: unknown }`; every consumer does hand-written `as ListItemsResponse` casts** (`shared/messages.ts:85-87`). Generic `Response<TKind extends Request['type']>` mapped from a single `MessageMap` table. (DEV-C)
|
||||
- **`group-autocomplete.ts:26` builds list HTML via `innerHTML` with only `"` escaping.** Group names are user-entered. `<` and `>` aren't escaped — markup-injection risk. Use `document.createElement('option')`. (DEV-C)
|
||||
- **`restore_backup` flattens `newRemote` inline** (`shared/messages.ts:56-66`). Inconsistent with sibling messages. Extract `RestoreBackupPayload`. (DEV-C)
|
||||
|
||||
### WASM boundary (JS side)
|
||||
- **`__stubs__/relicario_wasm.stub.ts` only stubs 7 of ~25 exports.** Adding a vitest test that touches a new WASM call needs an ad-hoc per-test mock. Round out the stub or provide a `mockWasm({...})` test helper. (DEV-C)
|
||||
|
||||
### Relay tooling
|
||||
- **`tools/relay/start.sh:80` may still reference "Dev-B"** in user-facing output despite the dev-c expansion. Confirm a fourth window opens for dev-c. (DEV-C)
|
||||
- **`tools/relay/call.py` and `call.ts` are untracked but load-bearing** for the multi-agent fallback path (kickoff prompts reference `call.py` by path). Either track them with a one-line header explaining "MCP-fallback shim" or add to `.gitignore`. (DEV-C)
|
||||
- **In-memory queue has no TTL, persistence, or cap** (`queue.ts:21-27`). Document the dev-only ephemeral contract or add a per-role cap. (DEV-C)
|
||||
|
||||
### Cross-cutting
|
||||
- **`#[allow(dead_code)]` without explanation** appears in `cli/device.rs`, `cli/gitea.rs`, and `wasm/device.rs`. Each is either "API completeness" or "scar tissue." Annotate with `// TODO(<plan>):` or delete. (DEV-B)
|
||||
- **Direct `chrome.storage.local` reads from popup components** (`settings-security.ts:112-113`, `setup.ts:1056-1062`). Every other module routes via `sendMessage`. Pick one paradigm and document the exception. (DEV-C)
|
||||
- **`bun test` is not the project's intended runner** (`extension/package.json:13` is `vitest run`; `tools/relay/` uses `node:test` via `bun test`). README clarification. (DEV-C)
|
||||
- **Error formatting is inconsistent** across all three Rust crates: CLI mixes sentence-case and lowercase fragments; server is `eprintln!("REJECT: ...")` *except* the malformed-devices.json path; WASM ranges from explicit messages to `RelicarioError::Display` passthrough. Short style note + audit pass. (DEV-B)
|
||||
|
||||
## P3 / nice-to-have
|
||||
|
||||
A long tail of style sweeps and small ergonomic wins. Pulling the most representative; full lists in the per-reviewer notes.
|
||||
|
||||
- Inconsistent error types and constant styles in core (`MonthYear::new -> Result<_, &'static str>`; `pub const MAGIC: [u8;4]` vs `const MAGIC: &[u8;4]`; empty `[dev-dependencies]`). (DEV-A)
|
||||
- Mid-file `use` blocks in `item.rs:117-122` and `attachment.rs:43-46`. (DEV-A)
|
||||
- `r#type` field on `Item` and `ManifestEntry` — rename to `item_type` with `#[serde(rename = "type")]`. (DEV-A)
|
||||
- BIP39 minimum word count of 3 is misleading vs. the 128-bit-security framing — restrict to canonical sets or rename to "BIP39-wordlist passphrase generator." (DEV-A)
|
||||
- `STEAM_ALPHABET` source comment contradicts its own test about the '5' character. (DEV-A)
|
||||
- `let _ = entry;` newcomer-hostile pattern repeated 6× in CLI. (DEV-B)
|
||||
- `cmd_recovery_qr_unwrap` doesn't check empty input before base64 decode; QR ASCII and "NOT been saved" mixed on stdout (pipe-unfriendly). (DEV-B)
|
||||
- `Lock` CLI subcommand is a visible no-op; either `#[command(hide = true)]` or document the parity rationale. (DEV-B)
|
||||
- Three test-only env vars duplicated under `#[cfg(debug_assertions)]/#[cfg(not(debug_assertions))]` — one macro flattens ~30 lines. (DEV-B)
|
||||
- WASM exports use snake_case in JS (`manifest_encrypt`); JS idiom is camelCase. Decide once via `#[wasm_bindgen(js_name = ...)]`. (DEV-B/DEV-C)
|
||||
- Glyph-rule partial adoption — six popup files use raw glyph literals (`⧉`, `↻`, `▸`, `▾`, `≡`, `⤓`) inline despite the `glyphs.ts` abstraction. Add the missing constants and migrate. (DEV-C)
|
||||
- `TotpKind = 'totp' | 'steam' | { hotp: { counter: number } }` mixed string/object union — flat discriminated union reads cheaper. (DEV-C)
|
||||
- `void tick()` in `totp-tools.ts:39-46` swallows promise rejections. (DEV-C)
|
||||
- `setup.ts:1-7` header says "5-step flow"; code is 6 steps (0..5). (DEV-C)
|
||||
- `helpers.rs:37 #[allow(dead_code)] pub fn relicario_dir()` has no callers but several `vault_dir.join(".relicario")` sites should use it. (DEV-B)
|
||||
- `gitea.rs` constructs a fresh `reqwest::blocking::Client::new()` per method call — make it a struct field. (DEV-B)
|
||||
|
||||
## Cross-cutting themes
|
||||
|
||||
**1. The "where the work happens" boundary holds, but two surfaces lie about it.** `relicario-core` is genuinely platform-agnostic (no fs, no net, no git) and the server provably cannot decrypt (no AEAD/KDF crate present). The extension's content scripts make zero direct WASM calls. These are excellent structural invariants. The two surfaces that break the pattern are `extension/src/setup/setup.ts` (loads WASM and orchestrates crypto directly, bypassing the SW) and the `SessionHandle` `.free()` non-cleanup contract on the WASM/JS boundary. Both are P1; both are also single-surface fixes. The architecture is one P1.1 + one P1.4 away from being uniform.
|
||||
|
||||
**2. Duplication concentrates at boundaries, not inside modules.** The two router files duplicate storage helpers and `itemToManifestEntry` (P1.9). The CLI duplicates git-shell error UX 16× (P1.3). Three base32 implementations live in core (P2). Two `ParamsFile` definitions disagree (P2). `parse_month_year` / `base32_decode_lenient` / `guess_mime` live only in the CLI but the extension will need them (P1.10). The pattern: every time a concept crosses a module/crate boundary, the second crossing copies the first instead of importing it. The remedy is small extractions (one helper module per cluster), not refactors. None of these are individually expensive; together they account for a meaningful fraction of total findings.
|
||||
|
||||
**3. Three files account for most of the readability cost.** `cli/main.rs` (2641 LOC), `setup.ts` (1220 LOC), `vault.ts` (1027 LOC). Each carries multiple concerns that belong in sibling modules. Splitting all three is cumulative ~M-L effort and unlocks several smaller cleanups (centralizing groups-cache discipline depends on splitting `main.rs`; the SW message-routing fix depends on extracting `setup.ts`'s WASM orchestration; lifting the `vault_locked` channel depends on splitting `vault.ts`). For the user's stated learning-by-tinkering goal, these three splits are the highest-leverage architectural moves available.
|
||||
|
||||
**4. Documentation density is uneven in exactly one place.** Core's security-critical files (`crypto.rs`, `imgsecret.rs`, `backup.rs`, `tar_safe.rs`) open with multi-paragraph rationale walking through the *why*. `recovery_qr.rs` does not — and it's the user-visible last-resort recovery mechanism. Bringing one file up to the existing standard is a P1.7 effort of S. Beyond that one file, the core doc story is genuinely strong; preserve it.
|
||||
|
||||
## What's strong (preserve)
|
||||
|
||||
- **`crypto.rs`, `imgsecret.rs`, `backup.rs`, `tar_safe.rs`: documentation density.** Each opens with multi-paragraph rationale (XChaCha20 vs AES-GCM, QIM with QUANT_STEP=50, length-prefixed concatenation, tar-bomb defenses). This is the model the rest of the crate should be measured against.
|
||||
- **`relicario-server` trust-model enforcement via the import surface.** The server cannot decrypt the vault even in principle: no AEAD or KDF crate in its dep graph; the entire `relicario-core` import surface is `DeviceEntry`, `RevokedEntry`, `fingerprint`, and (in tests) `generate_keypair` — all plaintext device metadata. This is structural, not by convention.
|
||||
- **Discriminated-union message contract in the extension.** `shared/messages.ts` + the `POPUP_ONLY_TYPES` / `CONTENT_CALLABLE_TYPES` capability sets give every router handler typed dispatch with origin-aware gating. Content scripts make zero WASM calls and re-validate origin in `fill.ts:32-38`. The boundary discipline holds.
|
||||
- **Test design across the codebase.** Synthetic JPEGs via `make_test_jpeg()` (no binary fixtures), raw-byte tar bombs that bypass the tar crate's sanitizer, RFC6238 vector for TOTP, an independent reference impl cross-check for Steam, ~30 LastPass importer cases, NFC/NFD round-trips, day-boundary tests for retention. The tests are themselves a documentation artifact.
|
||||
- **Helpers.rs in the CLI.** Small, focused, tested, and the doc-comment at `:90-93` explaining the plaintext `groups.cache` trade-off is admirably explicit. The git-command hardening (`core.hooksPath=/dev/null`, `commit.gpgsign=false`, `core.editor=true`) is exactly the right kind of comment to leave for a learner.
|
||||
|
||||
## CLI/extension parity status
|
||||
|
||||
Per DEV-C's full table: **22 of 23 CLI subcommands have a clean extension equivalent**.
|
||||
|
||||
- **Clean parity (✓)**: `init`, `add` (all 7 item kinds), `get`, `list` (with all filters), `edit`, `history`, `rm` (soft delete), `restore`, `purge`, `trash list`/`empty`, `backup export`/`restore`, `import lastpass`, `attach`, `attachments`, `extract`, `generate`, `settings *`, `sync`, `lock`, `rate`, `device add`/`revoke`/list, `recovery-qr generate`/`unwrap`. The settings unification under v0.5.1's left-nav (commit `bd6a301`) is the right shape.
|
||||
- **Partial (✗ish)**: `detach <item> <aid>`. Extension uses a roundtrip through `update_item` with the `attachments[]` mutated client-side. Functional but racy if two devices edit at once. **Suggested fix**: add a `delete_attachment` SW message that does the surgical remove server-side. (P3.)
|
||||
- **True gap (✗)**: `relicario status`. CLI shows pending sync state, ahead/behind, dirty-tree summary; extension surfaces nothing comparable. **Suggested fix**: `get_vault_status` message returning `{ ahead, behind, lastSyncAt, pendingItems }` plus a small status indicator in the vault sidebar. (P2.)
|
||||
- **Browser-only (by design)**: `get_autofill_candidates`, `get_credentials`, `check_credential`, `blacklist_site`, `capture_save_login`, `fill_credentials`, `ack_autofill_origin`, `get_blacklist`, `remove_blacklist`, `get_active_tab_url`, `update_settings` for `DeviceSettings`. No CLI counterpart needed.
|
||||
- **CLI-only (by design)**: `completions <shell>`. n/a.
|
||||
|
||||
**No "CLI first, extension follow-up" violations found** under this lens. The parity philosophy is intact; the one gap and the one partial are surgical fixes, not architectural debt.
|
||||
|
||||
## Beginner-friendliness assessment
|
||||
|
||||
Three reviewers converge on a consistent story for a smart developer who's never written Rust but wants to learn by tinkering.
|
||||
|
||||
**Where the floor is high already:**
|
||||
- `crates/relicario-core/` reads beautifully: bytes-in/bytes-out boundary holds, public API is enumerated in one `lib.rs`, module names map directly to vault concepts (`item`, `manifest`, `attachment`, `settings`, `generators`, `vault`, `backup`, `device`, `recovery_qr`), and the algorithmically dense `imgsecret.rs` is the most heavily commented. A reader can land in `crypto.rs`, follow `derive_master_key` → `encrypt` → `vault::encrypt_item`, and reach `tests/integration.rs::full_workflow_login_and_note` in one sitting.
|
||||
- `relicario-server` is 189 lines, one trust-model question, every dependency is plaintext metadata. Readable end-to-end in under ten minutes.
|
||||
- The extension's discriminated-union message contract (`shared/messages.ts` + `types.ts`) gives the entire vocabulary in 500 lines. The per-type item form modules (`popup/components/types/login.ts` and siblings) are small parallel surfaces — once one clicks, the others read as variations.
|
||||
|
||||
**Where the cliff is:**
|
||||
- **`cli/main.rs`** at 2641 LOC is the first real wall. A reader following `cmd_add` finds 7 peer `build_*_item` functions plus 6 prompt helpers plus 3 parsers, all in a flat file. (P1.2.)
|
||||
- **`extension/src/setup/setup.ts`** at 1220 LOC is the second wall, and it's worse because it lies about the architecture: a reader who opens this file first sees WASM imported directly and concludes that's the pattern — it isn't. (P1.4.)
|
||||
- **`extension/src/vault/vault.ts`** at 1027 LOC is the third. The vault tab is the user's primary surface; the orchestrator file shouldn't also be the implementation file. (P1.5.)
|
||||
- **`shared/state.ts`** is the weakest learning surface in the extension — `any`-typed bridge between popup and vault tab, no double-registration guard. (P1.6.)
|
||||
- **`recovery_qr.rs`** in core is the one file where a reader will hit a wall in an otherwise-well-documented crate. (P1.7.)
|
||||
|
||||
**The single most valuable change** for the user's stated goal: split `cli/main.rs` into a `commands/` folder (P1.2). It's the first file a tinkerer opens, the change is mechanical, and it unlocks several smaller cleanups (centralized git error UX, unified groups-cache discipline). After that, in order: bring `recovery_qr.rs` to the existing core doc standard (P1.7), extract `setup.ts`'s WASM orchestration into SW messages (P1.4), and split `vault.ts` (P1.5). The four together turn "this is mostly readable" into "this is uniformly readable."
|
||||
|
||||
## Open architectural decisions (escalated by reviewers; user judgement)
|
||||
|
||||
Pulled from DEV-B's question list and DEV-C's flags. None are blockers for the synthesis; each is a one-paragraph user decision.
|
||||
|
||||
1. **Was `impl Drop for SessionHandle` deliberately omitted?** (e.g. to avoid double-free if JS holds two refs.) PM verdict at synthesis: not deliberate; it's the headline fix (P1.1). User: confirm.
|
||||
2. **Should CLI `parse_month_year`, `base32_decode_lenient`, `guess_mime` migrate to core?** PM verdict: yes, for parity (P1.10). User: confirm timing.
|
||||
3. **Is "missing `.relicario/devices.json` = bootstrap = accept" intended in perpetuity?** Or should it tighten once a repo has any non-empty devices.json in history? (DEV-B P2.) User: pick a rule.
|
||||
4. **Is the `Lock` no-op CLI subcommand worth keeping visible in `--help` for parity?** Or hide behind `#[command(hide = true)]`? (DEV-B P3.) User: pick.
|
||||
5. **Has Task 12 shipped?** `cmd_backup_export` still reads `devices.json` per a "Task 12 will remove" TODO at `main.rs:1535-1537`. (DEV-B P3.) User: confirm and clean up.
|
||||
6. **Track `tools/relay/call.py` and `call.ts`, or `.gitignore` them?** They're load-bearing for the multi-agent fallback path. (DEV-C P2.) PM verdict: track them — they're documented in coordination prompts.
|
||||
7. **WASM JS naming: snake_case (current) or camelCase?** Trivial breaking rename via `#[wasm_bindgen(js_name = ...)]`, but only if done before the surface grows. (DEV-B/DEV-C P3.) User: pick once.
|
||||
8. **`.gitea_env_vars` is untracked.** Name suggests local credentials. PM verdict: should be `.gitignore`d if it isn't already. User: confirm.
|
||||
|
||||
## Appendix: pointers to per-reviewer notes
|
||||
|
||||
- [DEV-A notes — Rust core](./2026-05-04-dev-a-notes.md)
|
||||
- [DEV-B notes — Rust consumers (CLI, server, WASM)](./2026-05-04-dev-b-notes.md)
|
||||
- [DEV-C notes — TypeScript (extension + relay)](./2026-05-04-dev-c-notes.md)
|
||||
191
docs/superpowers/reviews/2026-05-04-dev-a-notes.md
Normal file
191
docs/superpowers/reviews/2026-05-04-dev-a-notes.md
Normal file
@@ -0,0 +1,191 @@
|
||||
# DEV-A Architecture Review Notes — Rust Core
|
||||
|
||||
Scope: `crates/relicario-core/src/**` (17 source files, ~5.5 kLOC) and `crates/relicario-core/tests/**` (9 integration suites). HEAD reviewed; the only uncommitted change in core is a version bump (`0.2.0 → 0.5.0` in `Cargo.toml`), nothing semantic.
|
||||
|
||||
## Summary
|
||||
|
||||
The crate has a clear, well-shaped architecture: a thin `lib.rs` re-exporting one concept per module, a unified `RelicarioError` enum, and a strict bytes-in/bytes-out posture (no fs, no net, no git anywhere — the boundary is held). The strongest part is the documentation density of the security-critical files: `crypto.rs`, `imgsecret.rs`, `backup.rs`, and `tar_safe.rs` each open with multi-paragraph rationale that walks a reader through the *why* (XChaCha20 vs AES-GCM, QIM with QUANT_STEP=50, length-prefixed concatenation, tar-bomb defenses) — exactly the "legibility-as-security" philosophy the README aspires to. Tests are excellent: RFC6238 vector for TOTP, an independent reference impl cross-check for Steam, NFC/NFD round-trips, raw-byte tar bombs that bypass the tar crate's sanitiser, and ~30 LastPass importer cases. The weakest part is unevenness — `recovery_qr.rs` is essentially undocumented despite being the user-visible last-resort recovery mechanism, and three modules (`vault.rs`, `manifest.rs`, `attachment.rs`) still carry "during this rewrite" / "added later in Task 22" headers from work that has long since shipped, which will mislead a newcomer about what's load-bearing.
|
||||
|
||||
`cargo clippy -p relicario-core --all-targets --no-deps` runs clean (no warnings).
|
||||
|
||||
## Findings (prioritized)
|
||||
|
||||
### P1 — `recovery_qr.rs` doc gap is conspicuous against the rest of the crate
|
||||
|
||||
**File(s):** `crates/relicario-core/src/recovery_qr.rs:1-130` (whole file)
|
||||
**Observation:** No module-level `//!` header. No doc comments on `RecoveryQrPayload`, `generate_recovery_qr`, `unwrap_recovery_qr`, or `recovery_qr_to_svg`. Magic constants (`RREC`, `VERSION = 0x01`, `PAYLOAD_LEN = 109`) sit at top with no explanation, and the 4+1+32+24+48 layout is hand-counted with no struct, no diagram, and no asserts. The domain-separation prefix `b"relicario-recovery-v1\0"` exists but isn't explained. `production_params()` redeclares the same values as `KdfParams::default()` with no comment on why the recovery format pins them.
|
||||
**Why it matters:** Every other security-relevant file (`crypto.rs`, `imgsecret.rs`, `backup.rs`, `tar_safe.rs`) has the explanatory framing this codebase rewards readers for. A newcomer hitting `recovery_qr.rs` sees a different style and assumes either it doesn't matter or they've stumbled out of the documented zone. It does matter — this is a vault-key escape hatch — and a misuse here (e.g., reusing `production_params` as `KdfParams::default()` and then changing the default) silently breaks all extant recovery QRs.
|
||||
**Suggested direction:** Add a module-level `//!` summarizing the format, the KDF-input domain separation, and the parameter-pinning rationale. Add a short ASCII diagram of the 109-byte layout near the constants. Doc-comment the four public functions. Either replace `production_params()` with a `const` or add a comment explaining the deliberate divergence from `KdfParams::default()`.
|
||||
|
||||
### P2 — Stale "in-progress rewrite" headers in three core files
|
||||
|
||||
**File(s):**
|
||||
- `crates/relicario-core/src/vault.rs:1-7` ("v1 helpers ... intentionally NOT carried forward. The CLI rewrite in Plan 1B switches to the new helpers.")
|
||||
- `crates/relicario-core/src/manifest.rs:1-2` ("Lives next to the old entry.rs Manifest during this rewrite; entry.rs is deleted in Task 25.")
|
||||
- `crates/relicario-core/src/attachment.rs:1-4` ("Encryption helpers ... are added later in Task 22 once the crypto module is settled.")
|
||||
|
||||
**Observation:** Each comment describes work that has already shipped. Task 22 added the helpers (they're 30 lines below in the same file). `entry.rs` is gone. Plan 1B is merged. The text reads as if there's a sibling file or a future change to wait for.
|
||||
**Why it matters:** A newcomer trying to understand the manifest will go looking for `entry.rs` to compare. A newcomer reading `attachment.rs` will read past the helpers thinking "those are coming later." These are the cheapest possible cleanup — comment edits — and they each remove a tripwire.
|
||||
**Suggested direction:** Replace with a one-line description of what the module *is*, not what it was during the rewrite.
|
||||
|
||||
### P2 — `extract_with_crop_recovery` is narrower than the spec describes
|
||||
|
||||
**File(s):** `crates/relicario-core/src/imgsecret.rs:849-899`
|
||||
**Observation:** The design spec (`docs/superpowers/specs/2026-04-11-relicario-design.md` §imgsecret extraction step 3) says crop recovery iterates `(dx, dy)` from -15% to +15% stepping by 8 px (~16,800 candidates for 4000×3000). The code only varies assumed original `orig_w`/`orig_h` while pinning `dx = 0, dy = 0`. The successful-crop test at `imgsecret.rs:1108-1137` only crops the *right* edge, where dx=0 happens to be the correct offset.
|
||||
**Why it matters:** Crops from the *left* or *top* (e.g. an Instagram square crop centered on a portrait, or any social-media platform that re-frames around faces) won't recover. The spec promises "15% from any edge"; the implementation delivers ~15% from the right and bottom only. Either the spec is wrong (which is allowed — the spec is marked historical) or the implementation has a quiet hole in the recovery surface. If the user ever uploads their reference JPEG to a service that left-crops, recovery will fail and the failure mode looks like "your image is wrong" rather than "we don't try that crop direction."
|
||||
**Suggested direction:** Either (a) extend the recovery loop with a small dx/dy search bounded by the 15% margin, or (b) update the spec language and the user-facing docs to say "right/bottom crop tolerance only." A third option is to add a test that left-crops a watermarked image and currently fails — that captures the gap and lets future work close it.
|
||||
|
||||
### P2 — Two dead fields in `EmbedRegion`
|
||||
|
||||
**File(s):** `crates/relicario-core/src/imgsecret.rs:225-229`
|
||||
**Observation:** `region_width` and `region_height` are computed in `compute_region`, stored in the struct, and silenced with `#[allow(dead_code)]`. Nothing reads them — downstream code uses `blocks_x` / `blocks_y` and `BLOCK_SIZE`.
|
||||
**Why it matters:** The `#[allow(dead_code)]` is an explicit "I know this is unused" — a newcomer reasonably assumes the fields are load-bearing and will hunt for the consumers, finding nothing. Either they're pre-positioned for a future feature (in which case a comment saying so would help) or they should go.
|
||||
**Suggested direction:** Delete both fields and the allow attributes, or add a one-line comment explaining the future use. (The struct is private, so removal is risk-free.)
|
||||
|
||||
### P2 — Three base32 implementations live in one crate
|
||||
|
||||
**File(s):**
|
||||
- `crates/relicario-core/src/item.rs:255-275` (`base32_encode`, RFC 4648 alphabet)
|
||||
- `crates/relicario-core/src/import_lastpass.rs:202-220` (`decode_base32_totp`, same alphabet)
|
||||
- `crates/relicario-core/src/item_types/totp.rs:13` (`STEAM_ALPHABET`, *different* alphabet — by design)
|
||||
|
||||
**Observation:** The first two are inverses of each other but live in different modules with no shared helper. The third is intentionally different (Steam Guard's de-ambiguated alphabet). They all hand-roll the bit-packing loop.
|
||||
**Why it matters:** A reader who finds one of the three has to grep to discover whether there are others, and whether they agree. A future change to the RFC 4648 path (e.g., padding behavior) needs to be applied in two places.
|
||||
**Suggested direction:** Extract a small `pub(crate) mod base32` with `encode_rfc4648`, `decode_rfc4648`, leaving Steam's bespoke alphabet where it is (with a `// not RFC 4648 — Steam Guard's de-ambiguated alphabet` neighbour comment).
|
||||
|
||||
### P2 — Backup format embeds the reference JPEG as base64-in-JSON
|
||||
|
||||
**File(s):** `crates/relicario-core/src/backup.rs:148-152, 274-280, 343-345`
|
||||
**Observation:** The `Envelope.vault.reference_jpg: Option<String>` carries the JPEG as base64-encoded JSON string. After zstd (which can't compress JPEG), a 4 MB reference photo bloats by ~33% from base64 plus JSON overhead.
|
||||
**Why it matters:** Backup files for users who bundle the reference image will be substantially larger than necessary. Round-trip works (covered by `tests/backup.rs:96-106`), so this is a footprint concern, not a correctness one. Worth flagging if backup size ever shows up in a complaint.
|
||||
**Suggested direction:** Bump `FORMAT_VERSION` and put `reference_jpg` and `git_archive` in a binary tail outside the JSON envelope, base64 only the small bytes. Defer until there's actual user pressure on backup size.
|
||||
|
||||
### P3 — Inconsistent error types and constant styles
|
||||
|
||||
**Observations (all small, batched here):**
|
||||
- `crates/relicario-core/src/time.rs:18` — `MonthYear::new` returns `Result<Self, &'static str>` instead of `RelicarioError`. Every other constructor in the crate uses the unified error type.
|
||||
- `crates/relicario-core/src/backup.rs:30` declares `pub const MAGIC: [u8; 4] = *b"RBAK";`; `crates/relicario-core/src/recovery_qr.rs:7` declares `const MAGIC: &[u8; 4] = b"RREC";`. Two different idioms for the same concept in adjacent files.
|
||||
- `crates/relicario-core/Cargo.toml:36` has an empty `[dev-dependencies]` table (delete the header).
|
||||
- `crates/relicario-core/src/crypto.rs:248-261` `derive_master_key_raw` is `pub` but only consumed by `recovery_qr.rs` inside this crate (verified via grep). `pub(crate)` would prevent accidental external misuse.
|
||||
|
||||
**Why it matters:** Each is trivial in isolation, but for a Rust newcomer reading the crate front-to-back, every inconsistency is a moment of "wait, why is this one different?" — and the answer is almost always "no reason, just historical."
|
||||
**Suggested direction:** Pick one form for each pattern, sweep.
|
||||
|
||||
### P3 — Mid-file `use` blocks in `item.rs` and `attachment.rs`
|
||||
|
||||
**File(s):** `crates/relicario-core/src/item.rs:117-122`, `crates/relicario-core/src/attachment.rs:43-46`
|
||||
**Observation:** Both files have `use` statements partway down the file (in `item.rs`, after `Section`; in `attachment.rs`, after `AttachmentSummary`). Idiomatic Rust hoists all `use` to the top.
|
||||
**Why it matters:** Newcomer expectation; trivial to fix.
|
||||
|
||||
### P3 — `derive_icon_hint` only handles `Login` with no comment about other types
|
||||
|
||||
**File(s):** `crates/relicario-core/src/manifest.rs:84, 92-99`
|
||||
**Observation:** Only `ItemCore::Login` produces a hostname hint; `_ => None` for the other six. No comment explaining why card-brand / favicon-from-URL / etc aren't derived for other types.
|
||||
**Suggested direction:** One-line `// only Login items have a URL today; other types don't have an obvious icon source.` (Or implement card-brand / identity-favicon if the popup UI wants them.)
|
||||
|
||||
### P3 — `attachment.rs` has WHAT-comments on a deref pattern
|
||||
|
||||
**File(s):** `crates/relicario-core/src/attachment.rs:60-63, 83-85`
|
||||
**Observation:** Two "Call-site adaptation" sections explain `&**master_key` in prose. The pattern is idiomatic Rust deref-coercion; the comment explains *what* not *why*.
|
||||
**Suggested direction:** Delete both comment blocks. The code is self-documenting; if anything, the cognitive load is in the comment, not the deref.
|
||||
|
||||
### P3 — TOTP dynamic-truncation extraction is reproduced four times
|
||||
|
||||
**File(s):** `crates/relicario-core/src/item_types/totp.rs:75-99` (3 algorithm arms each duplicate the DT slice math), `217-228` (test reference impl reproduces it again).
|
||||
**Suggested direction:** Extract a `fn dt(hmac_out: &[u8]) -> u32` helper. The test would still need its own copy as the cross-check.
|
||||
|
||||
### P3 — `STEAM_ALPHABET` source comment contradicts its own test
|
||||
|
||||
**File(s):** `crates/relicario-core/src/item_types/totp.rs:13` and `:287-291`
|
||||
**Observation:** Source comment says "excludes ambiguous glyphs (0/O, 1/I/L, S/5, A/Z)". Test docstring at line 287 says "Note: '5' IS in the alphabet — S is excluded, so 5 is unambiguous." The test is right; the source comment is wrong about '5'.
|
||||
**Suggested direction:** Fix the source comment to match the test (and the actual alphabet).
|
||||
|
||||
### P3 — `device.rs::sign` and `verify` share an extraction pattern that begs for a helper
|
||||
|
||||
**File(s):** `crates/relicario-core/src/device.rs:64-72, 86-94`
|
||||
**Observation:** Both functions do `key_data.ed25519().ok_or(...)?.try_into::<[u8; 32]>().map_err(...)?`. Five-line copy.
|
||||
**Suggested direction:** A `fn ed25519_bytes_from_private(...) -> Result<[u8; 32]>` and a `fn ed25519_bytes_from_public(...)` would each fold the extraction. Minor; not worth a refactor on its own but a free win if the file is being touched.
|
||||
|
||||
### P3 — `imgsecret.rs::read_block`'s `.unwrap()` deserves a one-liner
|
||||
|
||||
**File(s):** `crates/relicario-core/src/imgsecret.rs:315-319`
|
||||
**Observation:** `read_block_abs(...).unwrap()` is safe because `compute_region` guarantees the block lies inside the embed region, but the invariant isn't stated.
|
||||
**Suggested direction:** `// safe: compute_region ensures (start_x, start_y) + 8 fits within the image`. Same idea as the existing `expect("ascii-only charset")` in `generators.rs:64`.
|
||||
|
||||
### P3 — `r#type` field on `Item` and `ManifestEntry`
|
||||
|
||||
**File(s):** `crates/relicario-core/src/item.rs:134`, `crates/relicario-core/src/manifest.rs:23`
|
||||
**Observation:** Using `r#type` (raw identifier) because `type` is a reserved keyword. Functional but jarring; a Rust newcomer doesn't know what `r#` means and won't immediately realize it's a field name not a type prefix.
|
||||
**Suggested direction:** Rename to `item_type` with `#[serde(rename = "type")]` to keep wire format. Minor ergonomic win.
|
||||
|
||||
### P3 — BIP39 minimum word count of 3 is misleading
|
||||
|
||||
**File(s):** `crates/relicario-core/src/generators.rs:79-86`
|
||||
**Observation:** `word_count` accepted range is `3..=12`. BIP39 spec proper starts at 12 words. 3-word output has only ~33 bits of entropy and would never pass `validate_passphrase_strength` for security uses, but the API permits it. The comment "This gives full-entropy sourcing even for short passphrases" elides that effective entropy is `word_count * log2(2048) = 11 * word_count`, not 128 bits.
|
||||
**Suggested direction:** Either restrict to BIP39's actual word counts (12, 15, 18, 21, 24) or document that this is a *passphrase generator inspired by BIP39* (using its wordlist) rather than a BIP39 mnemonic generator. The latter is honest about what the code actually does.
|
||||
|
||||
### P3 — `StrengthEstimate::guesses_log10` uses base-10 while the spec talks bits
|
||||
|
||||
**File(s):** `crates/relicario-core/src/generators.rs:111-113`
|
||||
**Observation:** `guesses_log10: f64` is base-10 log of guess count; the design spec discusses entropy in bits. Mild domain-translation friction for callers.
|
||||
**Suggested direction:** Add a one-line comment showing the bits conversion (`bits ≈ guesses_log10 * log2(10) ≈ guesses_log10 * 3.32`), or expose a `bits_estimate()` accessor.
|
||||
|
||||
## File-by-file walk
|
||||
|
||||
**`lib.rs` (99 lines).** Crate-level docs are tight and accurate; the crypto pipeline diagram in the header is the right thing to greet a newcomer with. Public re-exports surface every meaningful type from one location. Clear.
|
||||
|
||||
**`error.rs` (195 lines).** Single error enum with thiserror. Every variant carries helpful context (item id, byte counts, found/expected version) except `Decrypt` which is opaque on purpose (audit M4). Tests exercise the public message format. Reads well.
|
||||
|
||||
**`crypto.rs` (437 lines).** The cornerstone. Module-level doc explains why XChaCha over AES-GCM and the binary layout. `derive_master_key` does NFC normalization + length-prefixed concatenation, both with explicit "audit H1" provenance comments. `decrypt_v1` rejection is tested. `derive_master_key_raw` is the seam used by the recovery QR. Solid.
|
||||
|
||||
**`ids.rs` (161 lines).** `ItemId`/`FieldId` are 64-bit random hex; `AttachmentId` is content-addressed (SHA-256 truncated to 128 bits). `is_valid()` provides a path-traversal guard (tested for `../../etc`). Header comment cites the audit IDs (M8, I2/B4) that motivated the entropy bumps from v1 — that traceability is great.
|
||||
|
||||
**`time.rs` (63 lines).** `now_unix()` and `MonthYear`. Only blemish is `MonthYear::new -> Result<_, &'static str>`; everything else returns `RelicarioError`.
|
||||
|
||||
**`vault.rs` (90 lines).** Six typed encrypt/decrypt wrappers (item / manifest / settings) that JSON-roundtrip through the crypto layer. Mechanical and correct. Stale module header (P2).
|
||||
|
||||
**`item.rs` (497 lines).** Defines the Item envelope, Field/FieldKind/FieldValue, Section, FieldHistoryEntry. Kind/value invariant enforced at construction and `validate()` post-deserialization. `set_field_value` captures history for password/concealed/totp kinds with kind-change rejection. `prune_history` honors LastN/Days/Forever. Mid-file `use` block (P3). Inline `base32_encode` (P2 above). Otherwise solid; the test at `set_field_value_captures_history_for_password` is exactly the right shape.
|
||||
|
||||
**`manifest.rs` (159 lines).** Browse-without-decrypt index v2. `upsert`/`remove`/`get`/`search`. `derive_icon_hint` only handles Login (P3). `r#type` field (P3). Stale header (P2).
|
||||
|
||||
**`attachment.rs` (166 lines).** `AttachmentRef` (carried on Item) and `AttachmentSummary` (carried in Manifest) plus `encrypt_attachment`/`decrypt_attachment`. The cap check fires before any crypto work — good order. Stale header + WHAT-comments (P2 + P3).
|
||||
|
||||
**`settings.rs` (184 lines).** `VaultSettings` with `TrashRetention`, `HistoryRetention`, `GeneratorRequest`, `AttachmentCaps`, plus the autofill TOFU ack map. Defaults match the design spec. `should_purge` is tested at the day-boundary. Clear.
|
||||
|
||||
**`generators.rs` (269 lines).** CSPRNG passwords (rejection-sampled via `Uniform::from`) and BIP39 passphrases (with capitalization variants) plus a zxcvbn-backed strength gate. Solid except the BIP39 lower-bound and `guesses_log10` ergonomics (both P3).
|
||||
|
||||
**`imgsecret.rs` (1138 lines).** The novel component. Documentation is excellent — DCT, QIM, EMBED_POSITIONS, MAX_DIMENSION rejection, JPEG header peek (audit M3), even an inline ITU-R BT.601 derivation. Tests cover round-trip, recompression to Q85, 10% crop, oversized-header rejection, and synthetic JPEG generation. Three real concerns: dead `region_width`/`region_height` (P2), narrower-than-spec crop recovery (P2), unannotated `unwrap()` in `read_block` (P3). The 1100-line size is fine — the algorithm warrants it.
|
||||
|
||||
**`backup.rs` (348 lines).** `.relbak` container: magic + version + salt + nonce + zstd(JSON envelope). Pinned Argon2id params. Schema/format versioning is paranoid in the right places. Reference-JPEG embedding is base64-in-JSON (P2). Otherwise solid.
|
||||
|
||||
**`device.rs` (168 lines).** ed25519 keypair generation in OpenSSH format, sign/verify, and SHA-256 fingerprint. The ssh-key + ed25519-dalek choreography is awkward but unavoidable. Sign/verify share an extraction pattern (P3). Tests cover sign, verify, wrong-data, wrong-key, and fingerprint format/determinism — comprehensive.
|
||||
|
||||
**`recovery_qr.rs` (129 lines).** The doc-gap finding (P1). Mechanically correct: domain-separated KDF input, AEAD wrap of the 32-byte image_secret, 109-byte payload that fits a QR v40 at EcLevel::M, SVG render via `qrcode`. But the documentation density doesn't match the rest of the crate.
|
||||
|
||||
**`import_lastpass.rs` (220 lines).** Header validation is strict (column count + order). Per-row failures degrade gracefully into `ImportWarning` rather than aborting. SecureNote vs Login dispatch via the `http://sn` sentinel matches the spec (D10). Custom base32 decoder (P2). Otherwise clean.
|
||||
|
||||
**`tar_safe.rs` (138 lines).** Replaces `tar::Archive::unpack` with a validated extractor. Rejects `..`, absolute paths, Windows prefixes, symlinks, hardlinks, oversized claimed sizes, and cumulative-size bombs. Returns `(PathBuf, Vec<u8>)` to the caller. Surgical and well-scoped.
|
||||
|
||||
**`item_types/mod.rs` (127 lines).** `ItemType` and `ItemCore` enums plus a `pub use` of every per-type core. The `// INVARIANT: no *Core struct may have a field serialized as "type"` comment at line 38 is exactly the kind of cross-cutting note that earns its keep — preserving that convention is what `ItemCore`'s tag-based serde works against. Comprehensive round-trip test at `item_core_round_trips_for_all_seven_types`.
|
||||
|
||||
**`item_types/login.rs` (63 lines).** Username/password/url/totp, all Optional, password Zeroizing. `omitted_fields_dont_appear_in_json` is the right shape for a serde test.
|
||||
|
||||
**`item_types/secure_note.rs` (30 lines).** Just a Zeroizing body. Right-sized.
|
||||
|
||||
**`item_types/identity.rs` (45 lines).** Five Optional fields. `empty_identity_omits_all_fields` round-trips to `{}` — clean.
|
||||
|
||||
**`item_types/card.rs` (68 lines).** Number/holder/expiry/cvv/pin/kind. `CardKind` defaults to `Credit`. `MonthYear` reused from `time.rs`.
|
||||
|
||||
**`item_types/key.rs` (42 lines).** Key material as Zeroizing string + label/public_key/algorithm. Loose schema (algorithm is a free string), which is appropriate for "any kind of key material."
|
||||
|
||||
**`item_types/document.rs` (40 lines).** Filename + mime + AttachmentId pointer to the primary blob. The actual bytes live in the attachment store, not the item.
|
||||
|
||||
**`item_types/totp.rs` (293 lines).** TotpCore + the shared TotpConfig (also reused as a field on Login). RFC6238 SHA1 vector check, an independent reference impl for Steam, an alphabet exhaustiveness sweep. HOTP is rejected with a typed error rather than silently mis-counted — the right choice for a stateless library. DT extraction duplicated four times (P3). Source vs test comment disagreement on the '5' character (P3).
|
||||
|
||||
**Tests folder (9 files, ~1.1 kLOC).** `integration.rs` covers the full encrypt/decrypt pipeline plus two-factor independence. `format_v2.rs` pins the version byte, the v1-rejection error type, and the length-prefix domain separation. `field_history.rs` covers capture, prune, and survival across encrypt/decrypt. `attachments.rs` covers round-trip + AID determinism + cap. `backup.rs` is thorough — round-trip with and without reference image / git archive, bad magic, unsupported version, wrong passphrase, truncation, tag tamper, NFC/NFD passphrase round-trip. `generators.rs` does class-balance statistics across 10k chars (well-documented why aggregating, since per-call cap is 128). `import_lastpass.rs` is the single largest suite (~30 cases) and exercises every column-mapping edge. `recovery_qr.rs` is minimal but covers the essentials. `safe_unpack.rs` builds raw-byte tars by hand to bypass `tar::Builder`'s sanitizer — exactly the right way to test a security boundary. The `fast_params()` helper is repeated across most files; a `tests/common/mod.rs` could DRY it but it's not a real cost.
|
||||
|
||||
## Beginner-friendliness assessment
|
||||
|
||||
For a competent dev who has never written Rust, this crate is unusually approachable. The boundary discipline is consistent (no I/O anywhere), the public surface is enumerated in one place (`lib.rs`), the module names map directly to vault concepts a reader already understands (item, manifest, attachment, settings, generators, vault, backup, device, recovery_qr), and the most algorithmically dense file (`imgsecret.rs`) is the most heavily commented. A reader can land in `crypto.rs`, follow `derive_master_key` → `encrypt` → `vault::encrypt_item`, and reach `tests/integration.rs::full_workflow_login_and_note` to see the whole pipeline run, all in one sitting. The Rust idioms that would trip up a newcomer (deref-coercion, `r#type` raw identifiers, `Zeroizing` wrappers, `&**master_key`) are all present, but they cluster in patterns a reader will see often enough to absorb.
|
||||
|
||||
The single change that would help most: write `recovery_qr.rs` to the same documentation standard as `crypto.rs` and `backup.rs`. It's the only file where a learning reader will hit a wall. Closing that gap brings the floor up to the ceiling and makes the "read it like a security proof" pitch true everywhere, not just in 16 of 17 files.
|
||||
240
docs/superpowers/reviews/2026-05-04-dev-b-notes.md
Normal file
240
docs/superpowers/reviews/2026-05-04-dev-b-notes.md
Normal file
@@ -0,0 +1,240 @@
|
||||
# DEV-B Architecture Review Notes — Rust Consumers (CLI, Server, WASM)
|
||||
|
||||
**Date:** 2026-05-04
|
||||
**Scope:** `crates/relicario-cli/`, `crates/relicario-server/`, `crates/relicario-wasm/`
|
||||
**Out of scope:** `relicario-core` internals (DEV-A), `extension/` and `tools/relay/` (DEV-C)
|
||||
**Method:** read-only walk of every src + test file; `cargo check` and `cargo clippy` per crate; `cargo build --target wasm32-unknown-unknown` for the WASM crate.
|
||||
|
||||
## Summary
|
||||
|
||||
The consumer layer is in good shape conceptually but uneven in execution. **`relicario-server` is the highlight**: 189 lines, one obvious responsibility, every dependency on `relicario-core` is plaintext device metadata only — the "server only ever sees ciphertext" invariant is structurally enforced by the import surface, not just by convention. **`relicario-wasm` is small and clean but has one real Rust-side defect**: `SessionHandle` lacks an `impl Drop`, so when wasm-bindgen's auto-generated `.free()` runs, the master key stays in WASM linear memory until `lock()` is also called explicitly — defense-in-depth that is currently missing. **`relicario-cli` does its job correctly but is hard to read**: `src/main.rs` is a 2641-line file with no submodule boundaries between the clap surface, item builders, edit handlers, prompt helpers, and parsers — the single biggest readability blocker in the consumer layer. Across all three crates, naming and module structure are good; what hurts is duplicated boilerplate and the absence of a few obvious helpers.
|
||||
|
||||
---
|
||||
|
||||
## Findings
|
||||
|
||||
### relicario-cli
|
||||
|
||||
#### P1 — `main.rs` is a 2641-line monolith with no submodule boundaries
|
||||
**File(s):** `crates/relicario-cli/src/main.rs:1-2641`
|
||||
**Observation:** every subcommand handler, every per-type item builder, every per-type edit handler, prompt helpers, parsing helpers, the `ParamsFile` shape, the clap surface, and 24+ git shell-outs all live in one file. The clap surface (lines 1-455) reads as a tour of the product and is excellent; lines 456-2641 are a flat sequence of `cmd_*`, `build_*_item`, `edit_*`, prompt helpers, and parse helpers, all peers. A newcomer searching "where does add work?" finds `cmd_add` calling 7 different `build_*_item` functions (each ~50-60 lines) with no module boundaries.
|
||||
**Why it matters:** this is the first file a newcomer opens. Today they have to scroll, not navigate.
|
||||
**Suggested direction:** keep `main.rs` as clap definitions + `match` dispatcher only (~470 lines). Split into `commands/{add,get,list,edit,trash,backup,import,attach,settings,sync,status,device,recovery_qr}.rs`, plus `prompt.rs` (the six `prompt_*` helpers + `prompt_secret`) and `parse.rs` (`parse_month_year`, `base32_decode_lenient`, `guess_mime`). Same LOC, completely different reading experience.
|
||||
|
||||
#### P1 — Git invocation boilerplate duplicated ~16× with one-line errors
|
||||
**File(s):** `crates/relicario-cli/src/main.rs:601, 602, 610, 986, 988, 1477, 1480, 1767, 1897, 1900, 2432, 2438, 2533, 2540` (and others)
|
||||
**Observation:** every `git_command` invocation that bails uses the same shape: `git_command(repo).args([...]).status()? + if !status.success() { bail!("git foo failed") }`. Child stderr is inherited to the parent tty (which helps interactively) but in test runs and noninteractive tooling it is lost, and the bail message is just the verb (`"git commit failed"`). When this fires in the wild — pre-receive reject, missing remote, dirty tree, signing-key prompt — the user sees one line of "$verb failed" and nothing else.
|
||||
**Why it matters:** this is the entire error UX for the git side of the CLI. Failure modes are common; the diagnostic is actively unhelpful.
|
||||
**Suggested direction:** add `helpers::git_run(repo, args, context)` that uses `.output()` (capturing stderr), prints captured stderr unmodified on failure, and includes the human-readable `context` ("commit add: GitHub", "register device", "purge trashed item"). Replaces 16 copies with one-liners.
|
||||
|
||||
#### P2 — `build_*_item` functions mix prompting, parsing, and core construction
|
||||
**File(s):** `crates/relicario-cli/src/main.rs:664-921`
|
||||
Each `build_*` does its own prompt-or-flag fallback (`title.map(Ok).unwrap_or_else(|| prompt("Title"))?`), parses domain values (URL, MonthYear, base32 TOTP), then constructs an `ItemCore`. Adding a new item type is currently 50-80 lines of mechanical code. A `prompt_or_flag<T>` helper plus per-type builders that take already-validated values would compress this materially.
|
||||
|
||||
#### P2 — Pure parsers belong in core, not the CLI
|
||||
**File(s):** `crates/relicario-cli/src/main.rs:942-980`
|
||||
`base32_decode_lenient` and `parse_month_year` are pure parsing producing typed core values. Per the user's CLI/extension parity philosophy, these need to be reachable from WASM too — the extension will want them when it gets QR-import / month-year smart input. `MonthYear::parse` and an `ItemCore::parse_totp_seed` (or `Totp::parse_secret`) on the core side would avoid future duplication.
|
||||
|
||||
#### P2 — `refresh_groups_cache` invocation discipline is manual at 7 sites
|
||||
**File(s):** `crates/relicario-cli/src/main.rs:641, 998, 1123, 1197, 1414, 1432, 1474` (and `helpers.rs:90-93` for the doc comment)
|
||||
The plaintext `groups.cache` is updated by-hand after every manifest mutation. The "failures are silently swallowed" rationale is documented at `main.rs:462`, but the rule "every mutating handler must call this" is not enforced — easy to forget on a new command. Either invoke from `Vault::save_manifest` (couples session to cache layout — maybe wrong) or wrap in `Vault::after_manifest_change(&self, manifest: &Manifest)`.
|
||||
|
||||
#### P2 — `ParamsFile` is defined twice with mismatching shapes
|
||||
**File(s):** `crates/relicario-cli/src/main.rs:2287` (write) vs `crates/relicario-cli/src/session.rs:114` (read)
|
||||
The write side has `aead`, `salt_path`, `format_version`; the read side takes only `kdf`. Two definitions of the on-disk `params.json` shape, in different files, with overlapping but non-equal fields. A single `Params` struct in `relicario-core` (or in `session.rs`) used by both readers and writers would eliminate the drift risk.
|
||||
|
||||
#### P2 — `cmd_purge` and `cmd_trash_empty` duplicate the manifest-add-and-commit dance
|
||||
**File(s):** `crates/relicario-cli/src/main.rs:1476-1480, 1896-1900`
|
||||
Same three lines, same error strings, same git rm + git add + git commit per item. `cmd_trash_empty` invokes `purge_item` per item, each of which is its own three-git-invocation loop. Batching the staging would reduce a 50-item purge from 150 git invocations to 3.
|
||||
|
||||
#### P3 — Selection of the `let _ = entry;` pattern, repeated 6×
|
||||
**File(s):** `crates/relicario-cli/src/main.rs:1170, 1407, 1426, 1469, 1913, 2030`
|
||||
Drop-the-borrow-before-reborrow-mutably is a Rust newcomer's worst surprise. A NLL-friendly refactor (clone `id` and `title` eagerly, let the borrow end naturally) would make these lines disappear.
|
||||
|
||||
#### P3 — Other nits
|
||||
- `cmd_recovery_qr_unwrap` does not check for empty input before base64 decode (`main.rs:2625-2630`)
|
||||
- `cmd_recovery_qr_generate` mixes the QR ASCII and a "NOT been saved to disk" message both on stdout — pipe-unfriendly (`main.rs:2612-2614`)
|
||||
- `Lock` subcommand is a no-op but visible in `--help`; either `#[command(hide = true)]` or accept the parity-with-extension argument and document why (`main.rs:445`, doc-comment at `:166`)
|
||||
- `tests/attachments.rs:69-76` has a dead variable (`blob_path`) kept "to avoid an unused warning" — delete
|
||||
- Three test-only env vars (`RELICARIO_TEST_PASSPHRASE`, `RELICARIO_TEST_ITEM_SECRET`, `RELICARIO_TEST_BACKUP_PASSPHRASE`) each duplicated under `#[cfg(debug_assertions)]/#[cfg(not(debug_assertions))]` — one macro would flatten ~30 lines
|
||||
- `cmd_backup_export` still reads `devices.json` (with `[]` fallback) per a "Task 12 will remove" TODO at `:1535-1537`. If Task 12 has shipped, this code can simplify
|
||||
- `format!("{:?}", e.r#type)` for the TYPE column at `main.rs:1158` — Debug-format for user-facing output. Add `Display` to `ItemType` in core
|
||||
- `helpers.rs:37 #[allow(dead_code)] pub fn relicario_dir()` — helper has no callers but several `vault_dir.join(".relicario")` sites in main.rs should be using it
|
||||
- `device.rs:94, 103, 120` and `gitea.rs:24, 26, 47, 77, 94, 101` have `#[allow(dead_code)]` markers without explanation. Either wire up or add `// TODO(<plan>):` so a newcomer knows whether they're scaffolding or scar tissue
|
||||
- `gitea.rs` constructs a fresh `reqwest::blocking::Client::new()` per method call (3 sites) — make it a struct field
|
||||
- `tests/edit_and_history.rs` writes hardcoded prompt sequences (`["", "", "", "", "", "y"]`) blindly; if main.rs reorders prompts, tests hang silently. A scripted-test layer (`expect_prompt("Title"); respond("");`) would survive refactors
|
||||
|
||||
---
|
||||
|
||||
### relicario-server
|
||||
|
||||
#### Trust-model assessment (the headline question)
|
||||
**The server respects the "ciphertext only" invariant. Confirmed.** The crate's entire `relicario-core` import surface is `DeviceEntry`, `RevokedEntry`, `fingerprint` (and `generate_keypair` in tests) — every one of those is plaintext-only device metadata. There is no import of `vault`, `crypto`, `imgsecret`, `item`, `manifest`, or `settings`. A grep over `crates/relicario-server/` for `decrypt|wrapped|encrypted|vault::` returns nothing. The Cargo.toml dep surface (`anyhow`, `clap`, `serde_json`, `tempfile`, `regex`) confirms it: there is no AEAD or KDF crate present. The server cannot decrypt the vault even in principle — it never reads the passphrase, the reference image, the salt, or the params.
|
||||
|
||||
The two on-disk files the server reads from the commit tree are `.relicario/devices.json` and `.relicario/revoked.json` (`main.rs:38, 48`), both plaintext metadata. All other operations are `git` subprocesses (`git show`, `git verify-commit --raw`, `git show -s --format=%ct`) plus local fingerprint computation. `generate-hook` emits a pure shell script that re-invokes `relicario-server verify-commit` per commit; it embeds no secret material. **No P1 findings.**
|
||||
|
||||
#### P2 — `generate-hook` assumes `$PATH`
|
||||
**File(s):** `crates/relicario-server/src/main.rs:170`
|
||||
The emitted script calls `relicario-server verify-commit "$commit"` with no path. Most Gitea deployments do not put `/usr/local/bin` on the hook process's `$PATH`, so this will fail with "command not found" or silently no-op. Either embed `std::env::current_exe()` at hook-generation time, or document an explicit `PATH=` line in the emitted script. There is no test that the generated hook actually executes.
|
||||
|
||||
#### P2 — Bootstrap branch is permissive
|
||||
**File(s):** `crates/relicario-server/src/main.rs:38-44, 54-57`
|
||||
A missing `.relicario/devices.json` at `newrev` is treated as bootstrap and accepted. Combined with "devices empty AND revoked empty → OK", an attacker who pushes a brand-new branch with no `.relicario/` directory could push arbitrary unsigned commits. There's no test for "second push to an established repo where devices.json was stripped." Worth either documenting the rule (first push wins; once devices.json exists in history, it can never be removed) or enforcing it: `oldrev != 0` should imply `devices.json` exists in `newrev`.
|
||||
|
||||
#### P2 — stdin-parsing lives in the shell hook only; binary alone is not hook-shaped
|
||||
**File(s):** `crates/relicario-server/src/main.rs:130-189` (`generate_hook`)
|
||||
The Rust binary's `verify-commit` takes a single SHA argument; the per-line `<old> <new> <ref>` parsing is delegated to bash in `generate_hook`. Defensible split, but means anyone wiring up a hook by hand cannot use the binary alone. A `verify-commit --from-stdin` mode (or at least a doc comment on `VerifyCommit` calling this out) would help.
|
||||
|
||||
#### P2 — Test coverage gaps vs. trust-model
|
||||
**File(s):** `crates/relicario-server/tests/verify_commit.rs`
|
||||
Tests cover: accepted, unregistered → reject, revoked-after → reject, revoked-before → accept. Missing: unsigned commit (no signature at all), malformed `devices.json` (parse error path on `:46`), bootstrap-empty-devices acceptance (`:54`), and one of the two stripped-`.relicario/` cases. Each is one extra `#[test]`.
|
||||
|
||||
#### P3 — Server nits
|
||||
- Regex compiled per call at `main.rs:85` — use `LazyLock` or `once_cell::sync::Lazy`; the `expect("static regex")` comment hints the author knew
|
||||
- The malformed-devices.json path at `:46` returns an `anyhow` chain on stderr without the consistent `REJECT: ...` prefix the rest of the file uses — ops parsing logs for `REJECT:` will miss it
|
||||
- No `--version` flag exposed in clap (small ops courtesy)
|
||||
- `generate-hook` doesn't tell users to `chmod +x` the result (one-line comment header in the emitted script would help)
|
||||
|
||||
---
|
||||
|
||||
### relicario-wasm
|
||||
|
||||
#### P1 — `SessionHandle` has no `impl Drop`; master key leaks on JS GC / `.free()`
|
||||
**File(s):** `crates/relicario-wasm/src/lib.rs:15-23` and `crates/relicario-wasm/src/session.rs:1-58`
|
||||
**Observation:** `SessionHandle` is `pub struct SessionHandle(u32)` with no `Drop` impl. wasm-bindgen auto-generates a JS-side `.free()` that, on the Rust side, drops the `SessionHandle` wrapper — i.e. drops a `u32`, a no-op. The `SESSIONS` HashMap entry stays alive **with the master key + image_secret still in WASM linear memory** until JS calls the explicit `lock(handle)` function (which calls `session::remove`). I confirmed via `grep -n "impl Drop" crates/relicario-wasm/src/*.rs` — empty.
|
||||
**Why it matters:** every `.free()` callsite that does not also call `lock()` first is a key-residency window of unbounded duration. wasm-bindgen does **not** auto-call `free()` on JS GC, but JS code that does call `.free()` reasonably expects the Rust side to clean up. The current contract requires JS to call `lock()` *and then* `free()`, which is asymmetric and easy to get wrong on the JS side (see boundary notes for DEV-C).
|
||||
**Suggested direction:** add
|
||||
```rust
|
||||
impl Drop for SessionHandle {
|
||||
fn drop(&mut self) { session::remove(self.0); }
|
||||
}
|
||||
```
|
||||
to `lib.rs`. `lock()` becomes the explicit "I am done now" call; `.free()` (auto or manual on the JS side) is the safety net. Defense in depth — the cost is one impl block. Worth a `wasm-bindgen-test` covering construct → drop → confirm registry empty.
|
||||
|
||||
#### P2 — Redundant `need_key` + `with(...).unwrap()` double-lookup
|
||||
**File(s):** `crates/relicario-wasm/src/lib.rs:73, 84, 92, 103, 111, 122, 160, 172`
|
||||
Every per-call op does `need_key(handle)?` and then `session::with(handle.0, |k| ...).unwrap()`. Two HashMap lookups per call, with the second `.unwrap()` justified only because the first proved the key existed. Single-threaded WASM makes this safe today, but if anyone ever introduces a reentrant path (`Serializer` callback that calls back into WASM), the assumption breaks. Refactor to a single `session::with(...).ok_or_else(|| JsError::new("invalid or locked session handle"))?` helper.
|
||||
|
||||
#### P2 — `Vec<u8>` getters clone on every read
|
||||
**File(s):** `crates/relicario-wasm/src/lib.rs:141-150` (`EncryptedAttachment::aid` and `bytes`)
|
||||
Each call clones the whole field. JS can call `enc.bytes` repeatedly without realising. For attachment payloads (potentially MB-sized), that's a real cost. Either consume by value or document "call once, cache locally."
|
||||
|
||||
#### P2 — Naming: `wasm_*_recovery_qr` prefix is inconsistent with everything else
|
||||
**File(s):** `crates/relicario-wasm/src/lib.rs:497, 510`
|
||||
`wasm_generate_recovery_qr` and `wasm_unwrap_recovery_qr` are the only exports with the `wasm_` prefix. Rename to `generate_recovery_qr_svg` and `unwrap_recovery_qr` (and update `extension/src/wasm.d.ts` accordingly). Trivial breaking rename, do it before any new caller appears.
|
||||
|
||||
#### P2 — `device.rs` and `session.rs` use different concurrency primitives
|
||||
**File(s):** `crates/relicario-wasm/src/device.rs` (`Lazy<Mutex<...>>`) vs `crates/relicario-wasm/src/session.rs:14-17` (`thread_local! { RefCell<HashMap<...>> }`)
|
||||
Both work in single-threaded WASM. The inconsistency hurts readability — pick one pattern. `thread_local! + RefCell` is fine and avoids `Mutex` overhead; `Mutex` over `Lazy` is closer to typical Rust idioms. Either is defensible; consistency is the win.
|
||||
|
||||
#### P3 — WASM nits
|
||||
- All `#[wasm_bindgen]` exports use snake_case (`manifest_encrypt`, `parse_lastpass_csv_json`); JS idiom is camelCase. The `wasm.d.ts` mirrors snake_case verbatim, so it's consistent — but if DEV-C ever wants idiomatic JS naming, `#[wasm_bindgen(js_name = "manifestEncrypt")]` is the path. Decide once, cite the decision in the module doc
|
||||
- `lib.rs:50` comment "Subsequent wasm_bindgen fns added in Tasks 19-21" is stale historical scaffolding; remove
|
||||
- `device.rs:18 #[allow(dead_code)] deploy_private` — wire it or remove it
|
||||
- `session.rs:24 if *n == 0 { *n = 1; }` runs on every insert; logically only matters after wraparound — move into the `wrapping_add` returned-zero branch
|
||||
- `session_tests` mod inside `lib.rs:522-591` covers session + lastpass; rename or split
|
||||
- `SessionHandle` doc-comment says "opaque to JS" but `value()` getter (`:21-22`) makes the u32 visible — align the comment with the API
|
||||
- `pack_backup_json` does six `b64.decode().map_err(...)` blocks (`lib.rs:387-410`) — a small `b64_decode(s) -> Result<Vec<u8>, JsError>` helper would compress ~20 lines
|
||||
- `get_field_history` walks sections and constructs `serde_json::json!` manually rather than serializing a typed struct (`lib.rs:262-295`); the `_ => String::new()` fallback at `:277` silently swallows future `FieldValue` variants. Use exhaustive match
|
||||
|
||||
---
|
||||
|
||||
### Cross-cutting (all three crates)
|
||||
|
||||
1. **Pure parsers / formatters belong in core.** `parse_month_year`, `base32_decode_lenient`, `guess_mime` (CLI), and `get_field_history`'s walk (WASM) are all examples of logic that today lives in a consumer crate but logically belongs in `relicario-core` so all consumers share it. The CLI/extension parity philosophy makes this concrete: anything the CLI does in pure logic, the extension will eventually need too.
|
||||
|
||||
2. **Error formatting is inconsistent.** CLI uses `anyhow::bail!` with a mix of sentence-case ("Settings updated.") and lowercase fragments ("git commit failed"). Server is uniformly `eprintln!("REJECT: ...")` *except* for the malformed-devices.json path that surfaces an anyhow chain. WASM uses `JsError::new(&e.to_string())` mostly, but the messages range from "salt must be exactly 32 bytes" to whatever `RelicarioError::Display` produces. A short style note ("user-facing errors lead with sentence-case context, internal errors use lowercase") plus an audit pass would unify these.
|
||||
|
||||
3. **`#[allow(dead_code)]` without explanation appears in cli/device.rs, cli/gitea.rs, and wasm/device.rs.** Each one is either "API completeness for a feature that hasn't shipped" or "scar tissue from a refactor." A newcomer cannot tell which. Either delete or annotate with `// TODO(<plan>):`.
|
||||
|
||||
4. **Layering is correct.** None of the three consumer crates reaches past `relicario-core`'s public API. The CLI doesn't import from server or wasm; server doesn't import from CLI or wasm; wasm doesn't import from CLI or server. The only shared concept (device entries, fingerprints) is correctly a core export. ¡Chido! [cool!]
|
||||
|
||||
5. **Workspace `Cargo.toml` working-tree changes are cosmetic** (version bumps `0.2.0 → 0.5.0` on cli/core/wasm). No structural concern. Worth committing or reverting before the next merge to keep `git status` quiet.
|
||||
|
||||
---
|
||||
|
||||
## File-by-file walk
|
||||
|
||||
### relicario-cli
|
||||
|
||||
- **`Cargo.toml`** — 18 runtime + 4 dev deps, all reasonable. `arboard` (clipboard), `rqrr` (QR decode), `qrcode` (QR encode), `image`, `rpassword`, `dirs`, `reqwest` blocking + JSON for Gitea, `data-encoding`, `tar`. Platform concerns ferried correctly away from core.
|
||||
- **`src/main.rs`** — entrypoint, dispatcher, **and** every command handler, item builder, edit handler, prompt helper, parse helper. 2641 lines, 71 functions. Clap surface (lines 1-455) is itself a tour of the product and is excellent. Past line 456 the file becomes flat; see P1 #1.
|
||||
- **`src/helpers.rs`** — the cleanest file. ~239 lines: `vault_dir`, `git_command` (with `core.hooksPath=/dev/null`, `commit.gpgsign=false`, `core.editor=true` hardening — newcomers should read the comment at `:42-45`), `iso8601`, `humanize_age`, `groups_cache_path`, `sanitize_for_commit`, `decode_totp_qr`. Has its own `#[cfg(test)] mod tests` covering `humanize_age`, `sanitize_for_commit`, `iso8601`. `find_vault_dir_from` walks parents. Plaintext `groups.cache` is a deliberate trade-off and the doc-comment at `:90-93` is admirably explicit.
|
||||
- **`src/session.rs`** — short and clear (~151 lines). `UnlockedVault { root, master_key: Zeroizing<[u8; 32]> }` plus `load_*`/`save_*` for Item, Manifest, VaultSettings. `unlock_interactive()` does passphrase prompt → image extract → KDF. `key()` accessor returns `&Zeroizing<...>` used at 4 call sites; consider passing through `encrypt_attachment`/`decrypt_attachment` methods so the key never leaves this module. `read_params` defines an inner `ParamsFile` struct that mismatches the writer in `main.rs:2287` — see P2.
|
||||
- **`src/device.rs`** — ~169 lines. ed25519 keypair storage under `~/.config/relicario/devices/<name>/`. `configure_git_signing` runs four `git config` calls. Three `#[allow(dead_code)]` items (`load_signing_key`, `load_deploy_key`, `delete_device_keys`) — API completeness without callers.
|
||||
- **`src/gitea.rs`** — ~117 lines. Plain blocking reqwest client. `create_deploy_key` parses the JSON response into `DeployKey { id, title, key }` (latter two `#[allow(dead_code)]`). `delete_deploy_key` treats 404 as success — sensible. Each method allocates a fresh `reqwest::blocking::Client::new()`.
|
||||
- **`tests/basic_flows.rs`** — 137 lines. Tests at the right level: spawn binary via `assert_cmd`, assert on stdout/stderr/exit. `init_creates_expected_layout`, `add_login_then_list_shows_it`, `get_masks_by_default_shows_with_flag`, `rm_restore_purge_cycle`, `generate_random_and_bip39`. Solid CLI-surface coverage.
|
||||
- **`tests/edit_and_history.rs`** — 191 lines. Most subtle test, interactive `edit` flow. `run_edit_with_pw_change` and `run_edit_totp` write hardcoded prompt sequences (`["", "", "", "", "", "y"]`) to stdin — fragile to prompt reordering. See P3.
|
||||
- **`tests/attachments.rs`** — 106 lines. Round-trip attach → extract → detach. Has a dead variable kept "to avoid an unused warning" (P3). `attach_rejects_over_cap` exercises only the per-attachment cap, not per-item or per-vault — coverage gap.
|
||||
- **`tests/settings.rs`** — 158 lines. `settings_roundtrip_trash_retention`, conflicting-flags rejection, generator-defaults end-to-end, `status` command coverage including `last_backup` round-trip. Solid.
|
||||
- **`tests/backup.rs`** — 142 lines. Export/restore round-trip, `--include-image`, `--no-history`, refusal of non-empty target, wrong-passphrase failure. Excellent coverage.
|
||||
- **`tests/import_lastpass.rs`** — 127 lines. Importer integration: success, single-commit guarantee, zero-items rejection, header validation, duplicate-import-IDs-are-unique invariant.
|
||||
- **`tests/smart_inputs.rs`** — 210 lines. Completion-script smoke tests, groups-cache write/suppress, `rate` command (strong/weak/`-` stdin), `--totp-qr` via in-process synthesized QR PNG (`make_test_qr`) — adheres to "synthetic fixtures, no binary blobs."
|
||||
- **`tests/vault_detection.rs`** — 59 lines. `list_refuses_without_vault_marker`, `get_finds_vault_in_parent_dir`, `v1_vault_is_rejected_with_clear_error` (`.idfoto/` ignored because lookup is for `.relicario/`).
|
||||
- **`tests/common/mod.rs`** — 132 lines. `TestVault` harness; `init()` creates a `TempDir`, generates synthetic JPEG via `make_test_jpeg`, runs binary with `RELICARIO_TEST_PASSPHRASE` set. `run`, `run_with_input`, `run_with_backup_pass` variants. No shared global state — parallel-test safe.
|
||||
|
||||
### relicario-server
|
||||
|
||||
- **`src/main.rs`** — 189 lines, very legible. Two clap subcommands cleanly mapped. `verify_commit` reads devices.json + revoked.json from the commit's tree (`git show`), spawns `git verify-commit --raw` with a dynamically-built allowed-signers file injected via `GIT_CONFIG_*` env vars (a clever touch — chido [cool] — avoids touching user gitconfig), parses the SHA-256 fingerprint from stderr, then enforces revocation-first then registration logic. Uses committer timestamp (not author) for revocation cutoff (`:115-123`) — correct for non-rebased histories. All rejection paths use `eprintln!("REJECT: ...")` with actionable context (commit SHA, fingerprint, reason) and exit 1 — visible to the pushing client via `git push` stderr. `generate_hook` emits a clean bash script handling branch creation (`oldrev = 000...`) and branch deletion (`newrev = 000...`).
|
||||
- **`tests/verify_commit.rs`** — 230 lines, four named scenarios mapping to audit S1. Each test stands up a tempdir git repo, generates real ed25519 keypairs via `relicario_core::device::generate_keypair`, signs a commit with explicit committer date, and shells out to the cargo-built binary via `assert_cmd`. Coverage gaps noted in P2.
|
||||
- **`Cargo.toml`** — minimal, no surprises. Importantly: no AEAD or KDF crate, which structurally guarantees the server cannot decrypt.
|
||||
|
||||
### relicario-wasm
|
||||
|
||||
- **`Cargo.toml`** — 27 lines. Right deps. `getrandom = { features = ["js"] }` correctly enabled for browser entropy routing. `image` is dev-only — good. `relicario-core/Cargo.toml` does NOT enable the `js` getrandom feature (correct: core stays platform-agnostic), and the wasm crate "lifts" the feature flag for the dep graph.
|
||||
- **`src/lib.rs`** — 591 lines, the bulk of the surface. Module doc-comment is concise. Imports are sprinkled mid-file (`:52, :138, :180, :310, :340, :469, :491`) instead of clustered at the top — historical from incremental authoring per Task 19/20/21 markers. Consolidating saves scroll. Sections are demarcated by `// ── ... ──` dividers which help.
|
||||
- **`src/session.rs`** — 57 lines. `SessionData { master_key, image_secret }` stored in `thread_local! { RefCell<HashMap<u32, _>> }`, monotonic `NEXT_HANDLE` u32 (skips 0 on wraparound). `insert`, `with`, `with_image_secret`, `remove`, `clear` (test-only). Missing `impl Drop for SessionHandle` — see P1.
|
||||
- **`src/device.rs`** — 71 lines. Clean. `Zeroizing<String>` for private keys (correct — `String::zeroize` wipes the heap allocation). Uses `Lazy<Mutex<DeviceState>>` (different pattern from `session.rs` — see P2).
|
||||
|
||||
### Build / clippy
|
||||
|
||||
- `cargo check -p relicario-cli` — clean
|
||||
- `cargo check -p relicario-server` — clean
|
||||
- `cargo check -p relicario-wasm` — clean
|
||||
- `cargo build -p relicario-wasm --target wasm32-unknown-unknown` — clean, finishes in ~6s, zero warnings
|
||||
- `cargo clippy --workspace` — silent (per subagent reports)
|
||||
|
||||
---
|
||||
|
||||
## Boundary notes for DEV-C
|
||||
|
||||
These are the items that look fine from the Rust side but DEV-C must verify on the TypeScript side:
|
||||
|
||||
1. **CRITICAL — every `.free()` callsite on a `SessionHandle`.** wasm-bindgen's auto-generated `.free()` does NOT remove the entry from the Rust-side `SESSIONS` registry today, because there is no `impl Drop for SessionHandle` (P1 above). Until that lands, every `.free()` callsite in TypeScript that does not first call `wasm.lock(handle)` is a master-key residency window in WASM linear memory of unbounded duration. Audit `extension/src` for every `.free()` on a `SessionHandle`. The Rust-side fix is preferred (defense in depth); DEV-C should also confirm that every TS-side lock path calls `wasm.lock(handle)` before `.free()` regardless.
|
||||
2. **Verify every `.free()` callsite, full stop.** Same principle applies to `EncryptedAttachment.free()` and `TotpCode.free()`. wasm-bindgen will not call `free` automatically when the JS object is GC'd — JS GC does not trigger Rust drop. Any handle that goes out of scope without explicit `.free()` leaks in WASM linear memory.
|
||||
3. **`unlock()` failure semantics.** When unlock throws (bad passphrase, bad params_json, salt wrong length, image_secret extract failure), no `SessionHandle` is created. TS callers should not wrap in `try { ... } finally { handle.free() }` because the handle var will be undefined.
|
||||
4. **`manifest_decrypt`/`item_decrypt`/`settings_decrypt` return `JsValue` typed as `unknown` in TS.** Rust uses `serde_wasm_bindgen::Serializer::new().serialize_maps_as_objects(true)` (`lib.rs:65`). Verify TS doesn't assume `Map` semantics anywhere — should be plain JS objects. Binary fields decode to `Uint8Array`; confirm TS doesn't try to `JSON.stringify` a decrypted item containing binary.
|
||||
5. **`*_encrypt` functions take `*_json: string`.** TS must `JSON.stringify` before calling. wasm-bindgen TS bindings will catch this at compile time, but verify no `as any` casts bypass.
|
||||
6. **`totp_compute(_, now_unix_seconds: bigint)` and `attachment_encrypt(_, _, max_bytes: bigint)`** — TS must use `BigInt(...)`, not `number`. wasm-bindgen throws at runtime on mismatch.
|
||||
7. **`Uint8Array` arguments are copied into WASM linear memory.** TS doesn't need defensive copies. But a `Uint8Array` view onto a `SharedArrayBuffer` may behave differently — verify TS isn't passing those.
|
||||
8. **`EncryptedAttachment.aid` and `.bytes` clone on every read** (P2). TS code that does `enc.bytes` twice does double work + double copy. Cache locally.
|
||||
9. **`SessionHandle.value` getter is exposed.** It's a u32 monotonic counter. If TS ever logs it (telemetry/debug), it's a session correlation identifier that survives across handles.
|
||||
10. **`get_field_history` returns JS objects with snake_case keys** (`field_id`, `field_name`, `current_value`, `changed_at`). Verify TS components consume these names — easy mismatch with TS-side camelCase conventions.
|
||||
11. **`register_device`/`sign_for_git`/`get_device_info`/`clear_device` are global, not per-session** — backed by `static DEVICE_STATE` (`Lazy<Mutex<>>`). Single SW = single state, fine. If TS ever instantiates multiple WASM modules (e.g. one per offscreen doc), each gets its own state — verify TS uses one shared init path.
|
||||
12. **Naming inconsistency**: only `wasm_generate_recovery_qr` and `wasm_unwrap_recovery_qr` carry the `wasm_` prefix. If DEV-C maintains a name-mapping table or auto-generated wrappers, flag for a rename pass.
|
||||
13. **`parse_lastpass_csv_json` returns `string` (JSON-encoded), not a `JsValue`.** TS must `JSON.parse` the result — different shape from `manifest_decrypt` which returns already-deserialized `unknown`. Verify `import_lastpass.ts` does `JSON.parse(...)` on the result.
|
||||
14. **All exports are snake_case in JS.** If DEV-C ever wants idiomatic camelCase, the mechanism is `#[wasm_bindgen(js_name = "...")]` per export. Decide once, before the surface grows.
|
||||
|
||||
---
|
||||
|
||||
## Beginner-friendliness assessment
|
||||
|
||||
For a competent dev who's never written Rust:
|
||||
|
||||
- **Server is ideal.** 189 lines, one trust-model question, every dependency is plaintext metadata. A newcomer can read it end-to-end in under ten minutes and walk away understanding what the server does and does not do. The single change that would help most is a paragraph-length module-level comment near the top of `main.rs` explaining *why* the server only verifies signatures (because the vault is encrypted client-side, the server has no key, the hook's only job is to gate writes by device authenticity). That paragraph would make the trust story self-evident on first contact.
|
||||
|
||||
- **WASM is approachable but has one cliff.** 720 lines across 3 files; `lib.rs` reads top-to-bottom okay; the doc-comment on `SessionHandle` is clear about the opaque-handle contract; the section dividers help. The cliff is the missing `Drop` impl: a beginner reasonably assumes wasm-bindgen handles registry cleanup automatically (it does not). A short comment in `session.rs` saying "**Drop the SessionHandle ≠ remove from registry; you must call `lock()`**" would prevent that mistake — but better to fix the missing `Drop` so the comment isn't needed.
|
||||
|
||||
- **CLI has one big roadblock and a lot of small ones.** `helpers.rs`, `session.rs`, `device.rs`, `gitea.rs` all read like real code: small modules, focused responsibilities, doc-comment headers, sensible names. `helpers.rs`'s tests double as documentation. The clap surface in `main.rs:1-455` is itself a product tour. Past line 456, `main.rs` becomes a 2200-line flat file with no submodule boundaries. A newcomer searching "where does add work?" finds `cmd_add` calling 7 different `build_*_item` functions (each ~50-60 lines) plus 6 prompt helpers, all peers. Plus the Rust-specific tripwires — the `let _ = entry;` pattern repeated 6×, the `Zeroizing` newtype, the `Option<Foo>::map(Ok).unwrap_or_else(...)?` chain at every builder — compound the unfamiliarity.
|
||||
|
||||
**Single biggest change across the consumer layer:** split `crates/relicario-cli/src/main.rs` into a folder. Keep `main.rs` as clap definitions + dispatcher (~470 lines, very readable). Move every `cmd_*` to `commands/<name>.rs`, prompts to `prompt.rs`, parsers to `parse.rs`. Same LOC, completely different reading experience — and this is the precondition for fixing the duplicated git boilerplate (CLI P1 #2) and centralizing `refresh_groups_cache`.
|
||||
|
||||
---
|
||||
|
||||
## Open questions for DEV-B (escalated to PM separately)
|
||||
|
||||
1. Was `impl Drop for SessionHandle` deliberately omitted (e.g. to avoid double-free if JS holds two refs to the same handle)? If yes, document it; if no, this is the headline fix.
|
||||
2. CLI `parse_month_year`, `base32_decode_lenient`, `guess_mime` — should they migrate to `relicario-core` for CLI/extension parity?
|
||||
3. Is "missing `.relicario/devices.json` = bootstrap = accept" intended in perpetuity, or should it be tightened once a repo has any non-empty devices.json in history?
|
||||
4. Is the `Lock` no-op CLI subcommand worth keeping visible in `--help` for parity with the extension, or hide behind `#[command(hide = true)]`?
|
||||
5. `cmd_backup_export` still reads `devices.json` per a "Task 12 will remove" TODO. Is Task 12 landed, deferred, or abandoned?
|
||||
343
docs/superpowers/reviews/2026-05-04-dev-c-notes.md
Normal file
343
docs/superpowers/reviews/2026-05-04-dev-c-notes.md
Normal file
@@ -0,0 +1,343 @@
|
||||
# DEV-C Architecture Review Notes — TypeScript (Extension + Relay)
|
||||
|
||||
**Reviewer:** dev-c (TypeScript scope) **Date:** 2026-05-04 **Branch:** main (read-only review)
|
||||
|
||||
## Summary
|
||||
|
||||
The TypeScript layer is **soundly organized but unevenly distributed**: a tight discriminated-union message contract (`shared/messages.ts`) and a clean Manifest V3 trust split (popup/setup/content/SW) anchor a codebase that earns its complexity from the two-factor unlock UX. The strongest part is the **service-worker router**: every popup/content message has a typed handler with origin-aware gating, and content scripts never touch WASM. The weakest parts are **two oversized modules that pre-empt the otherwise-clean component model**: `extension/src/setup/setup.ts` (1220 LOC) bypasses the SW and orchestrates WASM directly, and `extension/src/vault/vault.ts` (1027 LOC) inlines shell, sidebar, list, drawer, type-picker, form-wrapper and routing into one file. **CLI/extension parity is excellent overall** — every CLI subcommand has a UI path through the unified left-nav settings (v0.5.1) or vault tab — with two real gaps: explicit per-attachment detach (extension does it via `update_item`) and a vault-status surface equivalent to `relicario status`. One **broken test** in uncommitted relay code (`tools/relay/queue.test.ts:54`) blocks `bun test` from going green.
|
||||
|
||||
## Findings (prioritized: P1 / P2 / P3)
|
||||
|
||||
### Extension — service-worker
|
||||
|
||||
**[P1] router/popup-only.ts:687–703 & router/content-callable.ts:187–205 — duplicated storage helpers (`loadDeviceSettings`, `loadBlacklist`, `saveBlacklist`)**
|
||||
WHY: Three identical chrome.storage.local helpers in both router files; both code paths can mutate blacklist via different definitions, so future drift will silently corrupt one path.
|
||||
DIRECTION: Extract to `service-worker/storage.ts`; import from both routers.
|
||||
|
||||
**[P1] router/popup-only.ts:~169 & router/content-callable.ts:~169 — `itemToManifestEntry()` duplicated**
|
||||
WHY: Identical 17-line manifest projection in both router files; refactors of the manifest schema will need to be made twice.
|
||||
DIRECTION: Move into `service-worker/vault.ts` and import.
|
||||
|
||||
**[P2] service-worker/index.ts:76–78 — inactivity timer reset skips content-callable messages**
|
||||
WHY: Only popup/vault sends reset the timer; a user who is actively autofilling but never opens the popup will eventually be force-locked despite continuous use. Conversely, "every_time" mode is fine.
|
||||
DIRECTION: Reset on all messages except known read-only content calls (`get_autofill_candidates`); document the exclusion in the timer module.
|
||||
|
||||
**[P2] service-worker/index.ts:51–58 — session expiry clears `state.manifest` but leaves `state.gitHost`**
|
||||
WHY: After expiry, the cached git-host client survives; the next unlock could mix with stale connection state if a remote was rotated.
|
||||
DIRECTION: Null `state.gitHost` alongside `state.manifest` in the expiry callback; the initializer rebuilds it on demand.
|
||||
|
||||
**[P2] service-worker/session.ts:26 — `try { current.free() }` swallows free errors**
|
||||
WHY: A double-free or invalid-handle on the WASM session would be silently ignored, masking a lifecycle bug that could leave the master key zeroed but the JS handle dangling.
|
||||
DIRECTION: Let exceptions propagate (or log + counter); silent swallow is the wrong default for crypto state transitions.
|
||||
|
||||
**[P3] service-worker/index.ts:61–65 — chrome.storage.local.get on startup swallows errors**
|
||||
WHY: A typo or storage quota event leaves the timer in default state with no signal in the logs.
|
||||
DIRECTION: Surface failure via console.warn with the key name.
|
||||
|
||||
**[P3] service-worker/git-host.ts vs github.ts/gitea.ts — deploy-key API only on Gitea side**
|
||||
WHY: The interface is asymmetric; either GitHub doesn't need it (document) or it's an unimplemented feature.
|
||||
DIRECTION: Add a doc comment on `GitHost` explaining the asymmetry, or add the GitHub op.
|
||||
|
||||
### Extension — popup + components
|
||||
|
||||
**[P1] settings.ts:56–65 & settings-vault.ts:15–22 — duplicated teardown helpers**
|
||||
WHY: After the stub-restore commits (`8baef5b`, `ddfb95d`), teardown leaks are a known regression class; duplicated cleanup is exactly the shape that re-introduces them.
|
||||
DIRECTION: Extract `teardownSettingsCommon()` into a single helper; have each settings section call it on unmount.
|
||||
|
||||
**[P2] popup.ts:178–181 — teardown calls every type module unconditionally**
|
||||
WHY: Cycling views runs all 7 type teardowns on each transition; harmless today, but makes the lifecycle hard to reason about and hides which module was actually mounted.
|
||||
DIRECTION: Track last-mounted type and tear down only that one (or use a registry of `() => teardown` returned by mount).
|
||||
|
||||
**[P2] settings.ts:33–34 — `pendingVaultSettings` and `sessionHandle` are module-scope singletons**
|
||||
WHY: Section navigation can leave stale `pendingVaultSettings` from a previous section in scope; the current code is safe by accident, not by design.
|
||||
DIRECTION: Reset to `null` on each `renderSection` entry, or scope into a closure per render.
|
||||
|
||||
**[P2] item-list.ts:152–353 — settings-picker popover wires listeners on every render without a reuse path**
|
||||
WHY: Open/close cycles attach + detach listeners cleanly today, but rapid re-opens with throttled `setTimeout` cleanup are exactly the pattern teardown bugs hide in.
|
||||
DIRECTION: Cache the popover DOM and reuse, or use AbortController for the listeners with an explicit `signal` per open.
|
||||
|
||||
**[P2] devices.ts:47–50 & trash.ts:39–46 — `Promise.all` without per-promise error handling**
|
||||
WHY: A single rejected RPC fails the whole render; the second response is discarded even when its data was fetched successfully.
|
||||
DIRECTION: `Promise.allSettled`, then check `.ok` per response.
|
||||
|
||||
**[P2] generator-panel.ts:89–261 — module-scope `activePanel`; `closeGeneratorPanel()` not idempotent-guarded**
|
||||
WHY: The cleanup is currently a no-op when called twice, but the contract is implicit.
|
||||
DIRECTION: Add `if (!activePanel) return` at top.
|
||||
|
||||
**[P3] form-header.ts:20 + types/login.ts — `isInTab()` checked twice (header + caller)**
|
||||
WHY: Redundant coupling; the popout button visibility decision happens in two places.
|
||||
DIRECTION: Pass `showPopout: boolean` into `renderFormHeader` once.
|
||||
|
||||
**[P3] popup.ts:36–38 — `isInTab()` heuristic is `window.location.search.length > 0`**
|
||||
WHY: Any query string on `popup.html` triggers tab mode; brittle if popup is ever opened with diagnostic params.
|
||||
DIRECTION: Parse a specific param (`?view=tab` or check `window.location.pathname.endsWith('vault.html')`).
|
||||
|
||||
**[P3] item-form.ts:95–104 — `renderComingSoon()` defined but unused**
|
||||
WHY: Document type now has a real form (`types/document.ts`); the placeholder is dead code.
|
||||
DIRECTION: Delete or leave a one-line comment explaining future use.
|
||||
|
||||
**[P3] types/login.ts ~700 LOC — `renderForm` mixes HTML, wiring, password-strength, TOTP-ticker, and section editor**
|
||||
WHY: The reference implementation for the other 6 type modules; size makes the per-affordance lifecycle hard to follow for a learner.
|
||||
DIRECTION: Extract `wireUrlField`, `wirePasswordField`, `wireTotpField` from the body of `renderForm`. Other type modules already follow simpler patterns.
|
||||
|
||||
### Extension — vault tab
|
||||
|
||||
**[P1] vault/vault.ts (1027 LOC) — single file owns shell + sidebar + list + drawer + type-picker + form-wrapper + routing + teardown**
|
||||
WHY: A learner opening this file faces all responsibilities at once; teardown coupling (`teardownPaneComponents` lines 813–820) makes adding a new pane view a 5-place edit.
|
||||
DIRECTION: Split into `vault-shell.ts`, `vault-sidebar.ts`, `vault-list.ts`, `vault-drawer.ts`, `vault-form-wrapper.ts`, leaving `vault.ts` to own only routing and state.
|
||||
|
||||
**[P2] vault/vault.ts:47–74 — vault tab intercepts `vault_locked` errors via the RPC layer; popup uses only the `session_expired` event**
|
||||
WHY: Two different mechanisms reach the same outcome; popup-targeted components running in the vault tab don't know which channel will fire first.
|
||||
DIRECTION: Lift the RPC `vault_locked` intercept into `shared/state.ts` (or a wrapper around `sendMessage`) so both surfaces use one path.
|
||||
|
||||
**[P2] vault/vault.ts:495–536 — drawer doesn't auto-close on non-list view changes**
|
||||
WHY: Selecting an item opens the drawer; navigating to Trash via the sidebar leaves `state.drawerOpen = true` even though the drawer is no longer rendered.
|
||||
DIRECTION: `state.drawerOpen = false` at the start of `renderPane`, or when a non-list view becomes active.
|
||||
|
||||
**[P2] vault/vault.ts:648–695 — sidebar categories re-render on every search keystroke without debounce**
|
||||
WHY: Counts and active state must sync, but at hundreds of items the keystroke path stamps the whole sidebar.
|
||||
DIRECTION: 50–100ms debounce on the search input handler before re-rendering.
|
||||
|
||||
**[P3] vault/vault.ts:18–26 — backup-panel and import-panel are `vault/components/`-only by design**
|
||||
WHY: Correct call (popup has no room for these), but worth noting in the file header so a contributor doesn't try to import them into popup.
|
||||
DIRECTION: Add a short comment in `vault/components/index.ts` (or top of each file) explaining the vault-tab-only scope.
|
||||
|
||||
### Extension — content scripts
|
||||
|
||||
**[P2] content/fill.ts:50–64 — `fillFields()` returns silently when no password field is found**
|
||||
WHY: Dynamic forms (React/SPA mounting after `document_idle`) can race the fill listener; user clicks autofill, nothing visible happens, no error reaches the popup.
|
||||
DIRECTION: Send a `{type: 'fill_failed', reason: 'no_password_field'}` ack back to SW so the icon can flash an error glyph.
|
||||
|
||||
**[P2] content/detector.ts:96–103 — MutationObserver `scan()` is not debounced**
|
||||
WHY: SPA churn fires DOM mutations many times per second; the detector re-runs the full scan each time. WeakSet stops icon double-injection, but the scan cost is wasted.
|
||||
DIRECTION: Wrap `scan()` in `requestIdleCallback` or a 200ms timer.
|
||||
|
||||
**[P2] content/icon.ts:169–175 — outside-click listener registered in `setTimeout(0)` not removed on alternate close paths**
|
||||
WHY: Picker can close via Escape or programmatic destroy; the doc-level listener for outside-click leaks unless `closeOverlay()` is the close path.
|
||||
DIRECTION: Store the handler reference and `removeEventListener` in every close path; or `AbortController` scoped to picker open.
|
||||
|
||||
**[P3] capture.ts vs detector.ts vs fill.ts — username-finder logic copy-pasted three ways**
|
||||
WHY: `findUsernameField`, `findUsernameForFill`, `findUsernameValue` are three forks of the same heuristic.
|
||||
DIRECTION: Extract a shared `getUsernameField(pwField, scope, { valueOnly?: boolean })`.
|
||||
|
||||
**[P3] capture.ts:269–299 — submit-button hook scope is `form ?? pwField.parentElement`**
|
||||
WHY: A submit button outside the form (e.g. a custom React `<button onClick={...}>` siblings to the form) won't trigger capture; the user never sees the save prompt.
|
||||
DIRECTION: Walk up to the nearest form ancestor and listen for `keydown.Enter` on the password field as a fallback.
|
||||
|
||||
**[Positive note] content scripts make zero direct WASM calls and re-validate origin in `fill.ts:32–38`** — boundary discipline holds.
|
||||
|
||||
### Extension — setup
|
||||
|
||||
**[P1] setup/setup.ts:28–37, 1118–1120 — WASM is loaded and called directly inside the setup wizard, bypassing the service-worker abstraction the rest of the codebase uses**
|
||||
WHY: Popup, vault tab, and content all funnel WASM through the SW; setup is the only surface that imports `relicario-wasm` and orchestrates `unlock`/`embed_image_secret`/`register_device`/`manifest_encrypt` itself. This duplicates ~400 LOC of crypto orchestration that the SW already knows how to do, and it means the setup tab can't be locked by the same session-timer the rest of the extension uses.
|
||||
DIRECTION: Add `create_vault` and `attach_vault` messages to the SW; turn `setup.ts` into a UI that posts those messages with the gathered config + image bytes. This collapses ~600 LOC into ~200 and unifies the crypto state machine.
|
||||
|
||||
**[P2] setup/setup.ts (1220 LOC) — render/attach pairs per step are inlined**
|
||||
WHY: 6 steps × (renderStepN, attachStepN) = a switch statement that reads procedurally; adding a step means edits to the renderer, the attacher, and the wizard state.
|
||||
DIRECTION: Step registry: `{ id: 0, render: () => string, attach: (root) => Teardown }[]` keyed by mode. The 1220 LOC drops to ~500 after the WASM extraction in [P1] above and this restructure.
|
||||
|
||||
**[P2] setup/setup.ts:69–94 — `WizardState` is module-scope; passphrase + JPEG bytes + WASM handle persist if the user abandons mid-wizard**
|
||||
WHY: A user who closes the setup tab mid-flow leaves sensitive material in JS memory until the page is unloaded. The handle isn't `free()`d.
|
||||
DIRECTION: Add a `clearWizardState()` called on `beforeunload` and on returning to step 0; explicitly `Zeroize`-equivalent for the byte arrays where possible.
|
||||
|
||||
**[P2] setup/probe.ts:11–23 vs service-worker/vault.ts — manifest path constants duplicated (`.relicario/salt`, `.relicario/params.json`, `manifest.enc`)**
|
||||
WHY: Two sources of truth; if the metadata layout ever moves, probe and vault disagree about whether a remote is initialized.
|
||||
DIRECTION: Define `VAULT_PATHS` in `shared/types.ts` (or a new `shared/paths.ts`) and import into both.
|
||||
|
||||
**[P3] setup/setup.ts:56, 82–83 — passphrase score uses `-1` as "not yet scored" sentinel**
|
||||
WHY: The score is conflated with strength label index 0..4; `-1` magic number is checked in two places.
|
||||
DIRECTION: Use `{ scored: false } | { scored: true; score: number; guessesLog10: number }`.
|
||||
|
||||
**[P3] setup/setup.ts:1056–1062 — `recovery_qr_generated_at` written directly to `chrome.storage.local`, bypassing `WizardState`**
|
||||
WHY: One piece of setup result state lives outside the wizard's own state model.
|
||||
DIRECTION: Add it to `WizardState`, persist on a single `finishSetup` storage write.
|
||||
|
||||
**[P3] setup/setup.ts:1–7 — header comment says "5-step flow"; code is 6 steps (0..5)**
|
||||
WHY: Cosmetic but actively misleading; line 42 even has a comment about the renumber.
|
||||
DIRECTION: Update header.
|
||||
|
||||
### Extension — shared utilities
|
||||
|
||||
**[P1] shared/state.ts:10–35 — entire `StateHost` contract is `any`-typed**
|
||||
WHY: This is the bridge that lets popup components run in the vault tab; it's also the bridge most likely to drift between the two render targets, and TS gives no signal when it does.
|
||||
DIRECTION: Define a concrete `StateHost` interface with `state: PopupState`, `navigate: (view: View) => void`, `popOutToTab(): void`, `isInTab(): boolean`, `openVaultTab(hash?: string): void`. Make `getState`/`setState` generic over `keyof PopupState`.
|
||||
|
||||
**[P1] shared/state.ts:20 — module-scope `host` singleton with no double-registration guard**
|
||||
WHY: A second `registerHost()` call silently overwrites; in tests, the leak from a previous test breaks isolation if `beforeEach` forgets a reset.
|
||||
DIRECTION: Throw on re-register; export a `__resetHostForTests()` helper for vitest.
|
||||
|
||||
**[P2] shared/messages.ts:85–87 — base `Response` is `{ data?: unknown }`; every consumer casts via `Extract<Response, { ok: true }>` plus `data: ...`**
|
||||
WHY: The pattern works but every call site has a hand-written `as ListItemsResponse` cast; the discriminated-union narrowing the rest of the file relies on stops at `ok: true`.
|
||||
DIRECTION: Generic `Response<TKind extends Request['type']>` mapped from a single `MessageMap` table, so the response type is inferred at the send site.
|
||||
|
||||
**[P2] shared/form-affordances/group-autocomplete.ts:26 — `list.innerHTML = ...map(g => …).join('')` with only `replace(/"/g, '"')` sanitization**
|
||||
WHY: Group names come from user-entered item data. `<` and `>` are not escaped; a malicious / mistaken group name could inject markup into the autocomplete list.
|
||||
DIRECTION: `document.createElement('option')` per group with `option.value = g; list.appendChild(option)`.
|
||||
|
||||
**[P2] shared/messages.ts:56–66 — `restore_backup` flattens `newRemote` inline; other multi-field messages factor out a payload type**
|
||||
WHY: Inconsistent shape vs sibling messages; harder to reuse the type for the form that posts it.
|
||||
DIRECTION: Extract `RestoreBackupPayload` and intersect with `{ type: 'restore_backup' }`.
|
||||
|
||||
**[P3] glyphs.ts vs popup/components — raw glyph literals (`⧉`, `↻`, `▸`, `▾`, `≡`, `⤓`) appear inline in `item-form.ts`, `item-list.ts`, `settings.ts`, `generator-panel.ts`, `attachments-disclosure.ts`, `fields.ts`**
|
||||
WHY: The whole point of `glyphs.ts` is the no-inline-emoji rule; partial adoption defeats the audit story.
|
||||
DIRECTION: Add the missing constants (`GLYPH_COLLAPSE`, `GLYPH_EXPAND`, `GLYPH_ATTACHMENT`, `GLYPH_REGENERATE`, `GLYPH_OVERFLOW`, `GLYPH_DOWNLOAD`) and migrate the call sites. Pair with `extension/src/shared/__tests__/glyphs.test.ts` to lock it in.
|
||||
|
||||
**[P3] shared/types.ts:82 — `TotpKind = 'totp' | 'steam' | { hotp: { counter: number } }`**
|
||||
WHY: Mixed string/object union forces every consumer to do `typeof k === 'string' ? k : k.hotp`; a flat discriminated union is cheaper to read.
|
||||
DIRECTION: `{ kind: 'totp' } | { kind: 'steam' } | { kind: 'hotp'; counter: number }` with serde rename if the wire format must stay.
|
||||
|
||||
**[P3] shared/form-affordances/totp-tools.ts:39–46 — `void tick()` swallows promise rejections**
|
||||
WHY: TOTP preview RPC can fail (e.g., decrypt error); the user sees a frozen ticker with no signal.
|
||||
DIRECTION: `try { await tick() } catch (e) { renderError(row, e) }`.
|
||||
|
||||
### WASM boundary (JS side)
|
||||
|
||||
**[P2] extension/src/wasm.d.ts — declarations are hand-written and explicitly require manual sync with `crates/relicario-wasm/src/lib.rs`**
|
||||
WHY: Comment at top of the file says so; this is exactly the kind of contract that drifts when one side adds a new export. (Today, declarations and the Rust signatures are aligned.)
|
||||
DIRECTION: Add a CI check that compares `crates/relicario-wasm/pkg/relicario_wasm.d.ts` (wasm-pack output) against `extension/src/wasm.d.ts`, or import the generated `.d.ts` directly via `wasm-pack build --target web` and the runtime loader. Note for DEV-B in boundary section.
|
||||
|
||||
**[P2] extension/src/__stubs__/relicario_wasm.stub.ts — only 7 of ~25 exports are stubbed; the rest will throw `wasm stub: X not mocked`**
|
||||
WHY: Adding a vitest test that touches a new WASM call needs an ad-hoc mock per test; central stub is incomplete.
|
||||
DIRECTION: Either round out the stub with throwing stubs for the full surface, or provide a `mockWasm({ unlock, item_decrypt, ... })` test helper.
|
||||
|
||||
### Relay tooling
|
||||
|
||||
**[P1] tools/relay/queue.test.ts:54 — `assert.ok(!isRole("dev-c"))` fails (verified: `bun test` → 1 fail)**
|
||||
WHY: Uncommitted change added `dev-c` to the `Role` union and `KNOWN_ROLES` set in `queue.ts`, but the test still asserts the old enum. This is a P1 because it's a real test failure on uncommitted code, blocking a green test run.
|
||||
DIRECTION: Update the test assertion to `assert.ok(isRole("dev-c"))` and add a negative case (`assert.ok(!isRole("dev-d"))`).
|
||||
|
||||
**[P1] tools/relay/start.sh — kitty mode launches Dev-C window? Verify against the script**
|
||||
WHY: The 4-role refactor (pm/dev-a/dev-b/dev-c) needs a fourth window in the launcher. Per the subagent's read, `start.sh:80` still hardcodes "Dev-B" in user-facing output.
|
||||
DIRECTION: Update the launcher prints to mention all 4 roles and confirm a 4th `--type=tab --hold` window is opened with the dev-c prompt.
|
||||
|
||||
**[P2] tools/relay/call.py and tools/relay/call.ts — untracked, no .gitignore policy**
|
||||
WHY: Coordination prompts (including this one's "Fallback" section) reference `call.py` by path — it's load-bearing for the multi-agent flow, not an experiment. Untracked load-bearing files are a coordination footgun (a fresh checkout breaks the fallback).
|
||||
DIRECTION: Track both with a one-line header explaining "MCP-fallback shim for the relay; see docs/superpowers/coordination/...". If either is genuinely scratch, add to `.gitignore` instead — but pick one.
|
||||
|
||||
**[P2] tools/relay/queue.ts:21–27 — in-memory queue with no TTL, persistence, or cap**
|
||||
WHY: A long-running relay accumulates messages indefinitely; if a session sleeps for a day, the queue is the only audit trail and could grow unbounded.
|
||||
DIRECTION: Document the dev-only ephemeral contract at the top of `queue.ts`, or add a per-role cap (e.g., last 1000 messages).
|
||||
|
||||
**[P3] tools/relay/server.ts:115–127 — `makeServer()` factory pattern is correct but unexplained**
|
||||
WHY: The diff swap from a global `mcpServer` to per-connection factories is load-bearing for concurrent client isolation, but a future contributor might revert it as "unnecessary complexity".
|
||||
DIRECTION: One-line comment above `makeServer`: "Per-connection MCP server prevents routing collisions across concurrent SSE clients."
|
||||
|
||||
### CLI/extension parity gaps
|
||||
|
||||
**[P2] No equivalent to `relicario status`** — CLI shows pending sync state, ahead/behind, dirty-tree summary; extension surfaces nothing comparable. The vault-tab footer or a sidebar badge would be the natural home.
|
||||
DIRECTION: Add a `get_vault_status` message returning `{ ahead: n, behind: n, lastSyncAt: ts, pendingItems: n }` and a small status indicator in the vault sidebar.
|
||||
|
||||
**[P3] No explicit per-attachment detach message** — CLI has `relicario detach <item> <aid>`; extension forces a roundtrip through `update_item` with the `attachments[]` mutated client-side. Functional, but it's racy if two devices edit at once.
|
||||
DIRECTION: Add a `delete_attachment` message that does the surgical remove on the SW side.
|
||||
|
||||
**[P3] CLI `list --tag X` filter** — extension does tag filtering client-side after `list_items`; that's fine for the family-vault scale spec but may surprise a learner who reads the CLI side first.
|
||||
DIRECTION: Document the choice in `messages.ts` near `list_items`.
|
||||
|
||||
### Cross-cutting
|
||||
|
||||
**[P2] Direct `chrome.storage.local` reads from popup components** — `settings-security.ts:112–113` and `setup.ts:1056–1062` read storage directly while every other popup module routes via `sendMessage`. Inconsistency makes the data-flow story split across two paradigms.
|
||||
DIRECTION: Pick one: either every storage read goes through `get_settings`/`get_session_config`, or document explicitly that `setup` and `settings-security` are SW-bypass code paths for stated reasons.
|
||||
|
||||
**[P2] `bun test` is not the project's intended runner** — `extension/package.json:13` defines `test: vitest run`, but `tools/relay/` uses `node:test` via `bun test`. A learner will hit failures running `bun test` from the repo root because `bun` doesn't load `happy-dom`.
|
||||
DIRECTION: Add a top-level README note: "extension uses `cd extension && npm run test`; relay uses `cd tools/relay && bun test`."
|
||||
|
||||
**[P3] Manifest version 0.5.0** — both `extension/manifest.json` and `manifest.firefox.json` show `0.5.0`, while `package.json` is also `0.5.0`. The roadmap is at v0.5.1-dev; uncommitted bumps to package.json/manifest may be coming. Worth a quick PM check.
|
||||
|
||||
## File-by-file walk
|
||||
|
||||
### `extension/src/wasm.d.ts` and `__stubs__/relicario_wasm.stub.ts`
|
||||
Hand-written ambient module declarations mirroring `crates/relicario-wasm/src/lib.rs`. 25+ exports cover unlock/lock, manifest+item+settings encrypt/decrypt, attachment encrypt/decrypt, ID generation, password/passphrase generation + zxcvbn rate, image-secret embed/extract, TOTP compute, device register/sign/get/clear, recovery-QR. The stub used by vitest covers only 7 exports — every other call throws "not mocked" at test time.
|
||||
|
||||
### `extension/src/shared/`
|
||||
`types.ts` (286 LOC) is the canonical TS view of the Rust core schema; well-commented mappings to serde. `messages.ts` (207 LOC) is the discriminated-union message contract and the `POPUP_ONLY_TYPES` / `CONTENT_CALLABLE_TYPES` capability sets that the router uses for sender gating. `state.ts` (62 LOC) is the popup-vs-vault host indirection — currently `any`-typed. `glyphs.ts` (39 LOC, recently edited) is the centralized monospace glyph set. `color-scheme.ts`, `error-copy.ts`, `base32.ts`, `password-coloring.ts`, `toast.ts` are tight focused utilities. `form-affordances/` holds 5 input-behavior wirings (group autocomplete, notes mono toggle, password reveal/strength/QR, TOTP preview/QR, URL fill) plus a tiny `index.ts`.
|
||||
|
||||
### `extension/src/service-worker/`
|
||||
`index.ts` is the entry point: WASM load on first message, RouterState construction, session-timer wiring, `chrome.runtime.onMessage` listener. `vault.ts` (397 LOC) is the encrypted I/O layer — every WASM call passes the `SessionHandle`, never a raw key. `session.ts` is the single-handle store; `session-timer.ts` implements both `inactivity` and `every_time` modes. `devices.ts` reads/writes `.relicario/devices.json` and `revoked.json`. `gitea.ts` and `github.ts` mirror each other (Gitea has deploy-key ops; GitHub doesn't); `git-host.ts` is the abstraction. `router/index.ts` classifies the sender and dispatches to `popup-only.ts` (40+ handlers) or `content-callable.ts` (5 handlers).
|
||||
|
||||
### `extension/src/popup/`
|
||||
`popup.ts` (entry) wires the host registration, view router, and lock/unlock flow. `index.html` is a single `#popup-app` container. `styles.css` carries the popup theme. `components/` holds the visible widgets: `unlock`, `item-list`, `item-detail`, `item-form`, `form-header`, `fields`, `field-history`, `attachments-disclosure`, `generator-panel`, `devices`, `trash`, plus the new `settings.ts` / `settings-vault.ts` / `settings-security.ts` left-nav (v0.5.1). `components/types/` holds the 7 type-specific item modules — `login.ts` is the reference, the others (secure-note, identity, card, key, document, totp) are smaller variants.
|
||||
|
||||
### `extension/src/vault/`
|
||||
`vault.html` (12 LOC) loads `vault.css` + `vault.js`. `vault.css` (~2200 LOC) carries the dark/gold theme + 3-column layout. `vault.ts` (1027 LOC) orchestrates everything: shell init, hash routing, sidebar, list, drawer, type-picker, form-wrapper, deep-link routing, teardown. `components/backup-panel.ts` and `components/import-panel.ts` are vault-tab-only (high-risk operations need fullscreen affordances). Tests: `form-wrapper.test.ts`, `sidebar-glyphs.test.ts`.
|
||||
|
||||
### `extension/src/content/`
|
||||
`detector.ts` finds password fields and injects icons via a MutationObserver. `fill.ts` receives `fill_credentials`, re-validates the page hostname, and uses the native-setter trick to set values past React/Vue. `capture.ts` hooks form submits and shows a closed-Shadow-DOM save prompt. `icon.ts` renders the in-page icon and credentials picker. `shadow.ts` is the closed-Shadow-DOM helper. **Zero WASM calls; clean boundary.**
|
||||
|
||||
### `extension/src/setup/`
|
||||
`setup.ts` (1220 LOC) is the 6-step (0..5) wizard: mode pick → remote config → image select / passphrase → derive+verify or create+embed → device register → recovery QR. Imports WASM directly. `setup-helpers.ts` (84 LOC) is a clean utility module (escapeHtml, debounced rate, strength labels). `probe.ts` (23 LOC) checks for an existing vault on the configured remote.
|
||||
|
||||
### `tools/relay/`
|
||||
`server.ts` is the MCP server with HTTP/SSE transport (per-connection `makeServer()`). `queue.ts` is the in-memory FIFO with `Role` enum and `isRole()` guard. `queue.test.ts` is `node:test` based (4 pass / 1 fail on uncommitted state). `call.py` and `call.ts` are the MCP-fallback shims (untracked). `start.sh` launches manual / tmux / kitty modes. `package.json`, `tsconfig.json` keep the relay self-contained.
|
||||
|
||||
## CLI/extension parity table
|
||||
|
||||
| CLI capability | Extension equivalent | Notes |
|
||||
|---|---|---|
|
||||
| `init --image --output` | `save_setup` + setup wizard step 3-new | ✓ (parity via setup.ts; see [P1] about routing through SW) |
|
||||
| `add login/secure_note/identity/card/key/document/totp` | `add_item` + `types/<kind>.ts` form | ✓ |
|
||||
| `get <query> [--show] [--copy]` | `get_item` + popup detail; clipboard via `navigator.clipboard` | ✓ |
|
||||
| `list [--type] [--group] [--tag] [--trashed]` | `list_items`, `list_trashed`, client-side filter; `list_groups` for autocomplete | ✓ |
|
||||
| `edit <query> [--totp-qr]` | `update_item` + `wireTotpQr` (jsQR lazy import) | ✓ |
|
||||
| `history <query>` | `get_field_history` + `field-history.ts` | ✓ |
|
||||
| `rm <query>` (soft) | `delete_item` | ✓ |
|
||||
| `restore <query>` | `restore_item` | ✓ |
|
||||
| `purge <query>` | `purge_item` | ✓ |
|
||||
| `trash list / empty` | `list_trashed` / `purge_all_trash` + `trash.ts` | ✓ |
|
||||
| `backup export` | `export_backup` + `backup-panel.ts` | ✓ |
|
||||
| `backup restore` | `restore_backup` + `backup-panel.ts` (restore path) | ✓ |
|
||||
| `import lastpass <csv>` | `parse_lastpass_csv` + `import_lastpass_commit` + `import-panel.ts` | ✓ |
|
||||
| `attach <query> <file>` | `upload_attachment` | ✓ |
|
||||
| `attachments <query>` | `AttachmentRef[]` already inside `get_item` | ✓ (no separate message; same data) |
|
||||
| `extract <query> <aid>` | `download_attachment` | ✓ |
|
||||
| `detach <query> <aid>` | (no message) — done via `update_item` with mutated `attachments[]` | **partial / ✗** |
|
||||
| `generate --length / --bip39 …` | `generate_password` / `generate_passphrase` + `generator-panel.ts` | ✓ |
|
||||
| `settings trash-retention / history-retention / attachment-cap / generator-defaults` | `get_vault_settings` / `update_vault_settings` + `settings-vault.ts` | ✓ |
|
||||
| `sync` | `sync` | ✓ |
|
||||
| `status` | (no message) | **✗ — gap** |
|
||||
| `lock` | `lock` | ✓ |
|
||||
| `completions <shell>` | N/A (CLI-only) | n/a |
|
||||
| `rate <passphrase>` | `rate_passphrase` (zxcvbn gate) | ✓ |
|
||||
| `device add / revoke` (+ list) | `add_device` / `register_this_device` / `revoke_device` / `list_devices` / `list_revoked` + `devices.ts` | ✓ |
|
||||
| `recovery-qr generate / unwrap` | `generate_recovery_qr` / `unwrap_recovery_qr` + `settings-security.ts` | ✓ |
|
||||
| (browser-only autofill) | `get_autofill_candidates`, `get_credentials`, `check_credential`, `blacklist_site`, `capture_save_login`, `fill_credentials`, `ack_autofill_origin`, `get_blacklist`, `remove_blacklist`, `get_active_tab_url` | extension-only (no CLI counterpart, by design) |
|
||||
| (browser-only device UX) | `get_settings` / `update_settings` (DeviceSettings: captureEnabled, captureStyle) | extension-only |
|
||||
|
||||
**Summary:** 22/23 CLI capabilities have a clean extension path; `status` is the one true gap, and `detach` is partial. No "CLI first, extension follow-up" violations under this lens.
|
||||
|
||||
## Boundary notes for DEV-B
|
||||
|
||||
1. **`extension/src/wasm.d.ts` is hand-maintained** — every change to `crates/relicario-wasm/src/lib.rs`'s `#[wasm_bindgen]` surface must be mirrored manually. Worth a CI guardrail comparing the wasm-pack–generated `.d.ts` against this file. (See [P2] in WASM boundary.)
|
||||
|
||||
2. **The session handle is opaque on the JS side** (`SessionHandle.value: number`, `free()`). DEV-B owns the Rust-side lifecycle: confirm that double-`free()` from JS is a no-op rather than a panic, since `service-worker/session.ts:26` currently swallows free errors silently. If the Rust side could panic on double-free, the silent swallow becomes a crash mask.
|
||||
|
||||
3. **`attachment_encrypt(handle, plaintext, max_bytes: bigint)`** — the JS side passes `BigInt` for `max_bytes`. Confirm the Rust binding accepts `u64` and that the conversion is loss-free. The extension is going to push this with γ₁ enforcement (per project memory).
|
||||
|
||||
4. **`register_device(name)` returns `{ signing_public_key, deploy_public_key }`** as a plain object (not a class). Setup wizard relies on both being hex strings (`device.public_key` lookup paths). Confirm the device key types stay strings on the Rust side rather than ever moving to `Uint8Array`.
|
||||
|
||||
5. **`generate_recovery_qr(handle, passphrase) → string`** and `unwrap_recovery_qr(payload_b64, passphrase) → Uint8Array` — the QR payload format is a contract between this surface and the CLI's `recovery-qr` subcommand. If DEV-B is reviewing recovery-QR end-to-end, confirm that re-deriving from a recovery QR produces the same `master_key` regardless of which side (CLI vs extension) generated it.
|
||||
|
||||
6. **`extract_image_secret(image_bytes) → Uint8Array` and `embed_image_secret(carrier, secret) → Uint8Array`** — the central-embed DCT scheme has `MAX_DIMENSION` and `QUANT_STEP = 50.0` constants on the Rust side. The setup wizard does no client-side dimension check before passing the image; if a >MAX_DIMENSION JPEG is selected, the failure message bubbles up generically. DEV-B may want a more specific Rust-side error variant the extension can re-render.
|
||||
|
||||
## Beginner-friendliness assessment (TS side)
|
||||
|
||||
**Reading order recommendation:** start with `extension/src/shared/messages.ts` and `types.ts` — they tell you the entire vocabulary in 500 lines. Then `service-worker/router/index.ts` to see how messages get routed. Then pick **one** vertical to follow end-to-end: `popup/components/types/login.ts` → `service-worker/router/popup-only.ts` (the `add_item`/`update_item` handlers) → `service-worker/vault.ts`. After that, `content/detector.ts` + `content/fill.ts` + the corresponding `content-callable.ts` handlers cover the autofill story.
|
||||
|
||||
**Trip wires:** the two oversized files (`vault.ts` 1027 LOC, `setup.ts` 1220 LOC) are the steepest cliffs. A learner who opens `setup.ts` first will see WASM imported directly and conclude that's the pattern — it isn't; that file is the exception. Adding a short `EXTENSION_ARCH.md` (or a comment block at the top of `setup.ts`) noting "WASM is loaded directly here only because vault creation predates the SW; everywhere else, route through `chrome.runtime.sendMessage`" would save half a day.
|
||||
|
||||
**Strongest learning surfaces:** the discriminated-union message contract, the per-type item form modules (small, parallel, easy to diff), and `service-worker/router/router.test.ts`. The shared form-affordances are also a model of pure-function wiring with explicit teardown — once those patterns click, the rest of the popup is "more of the same."
|
||||
|
||||
**Weakest learning surface:** `shared/state.ts` — it's the bridge between popup and vault tab, but the `any` typing tells you nothing about what flows through it. Tightening this is a high-leverage change.
|
||||
|
||||
## Uncommitted-state read
|
||||
|
||||
The working tree has substantial uncommitted changes. Architecturally relevant:
|
||||
|
||||
- **`extension/src/vault/vault.ts` (+151/−99) and `vault.css` (+238/−99)** — appear to be active work on the form wrapper sticky bar, drawer field grid, and gold-rebrand color palette (Phase 2B). Treat these as **in flight, ignore for this review** — they don't change the architectural shape, only the visual surface.
|
||||
- **`extension/src/shared/glyphs.ts` (+/−2) and `__tests__/glyphs.test.ts` (+/−2)** — small additions, likely a new glyph constant. Doesn't affect the [P3] glyph adoption finding (the inline-literal call sites would still need migration).
|
||||
- **`extension/manifest.json` and `manifest.firefox.json` (+/−2)** — likely a version bump in flight; PM should decide whether v0.5.1-dev gets a synced bump in `package.json` too.
|
||||
- **`tools/relay/queue.ts`, `server.ts`, `start.sh` (modified) + `call.py`, `call.ts` (untracked)** — these are **architecturally relevant**: the dev-c role expansion is real, and `call.py`/`call.ts` are documented-but-untracked fallback shims. The broken test (`queue.test.ts:54`) is the P1 the rest of this review flags. **Do not ignore.**
|
||||
- **`Cargo.lock`, `crates/*/Cargo.toml`** — out of scope; flag to DEV-A/DEV-B if not already on their list.
|
||||
- **`.gitea_env_vars` (untracked)** — name suggests local credentials; should be `.gitignore`d if not already (PM check).
|
||||
- **`docs/superpowers/coordination/v0.5.1-*` and `architecture-review-*`** — coordination prompt evolution; not architectural.
|
||||
|
||||
**My call:** vault/vault.css/glyphs/manifest changes are in-flight and shouldn't change findings. The relay changes ARE architecturally on-the-table because the new dev-c role + factory pattern are visible in the running review process. The relay test failure is treated as a P1 because it blocks `bun test` from going green and the kickoff prompt explicitly says "Review is not gated on green; it's gated on understanding" — but this is one cheap fix away from a green test run, and a learner running `bun test` first will assume the codebase is broken.
|
||||
@@ -1,5 +1,7 @@
|
||||
# Relicario — Design Specification
|
||||
|
||||
> **Status:** historical. V1 shipped 2026-04-22; several "Post-V1 Ideas" listed below (typed items, attachments, secure documents, TOTP, Firefox extension, LastPass import, device authentication) have since shipped. See `CHANGELOG.md` and `docs/architecture/overview.md` for current state. Do not edit this spec as if it were architecture documentation — it is a time-stamped decision artifact.
|
||||
|
||||
A git-backed, self-hostable password manager with a Rust core, CLI, and Chrome browser extension. The reference image as a DCT-embedded secret carrier is the core differentiator.
|
||||
|
||||
## Overview
|
||||
|
||||
218
docs/superpowers/specs/2026-05-02-relay-server-design.md
Normal file
218
docs/superpowers/specs/2026-05-02-relay-server-design.md
Normal file
@@ -0,0 +1,218 @@
|
||||
# Relay Server Design
|
||||
|
||||
**Date:** 2026-05-02
|
||||
**Status:** Approved
|
||||
**Scope:** Dev tooling — not shipped in any product artifact
|
||||
|
||||
---
|
||||
|
||||
## Problem
|
||||
|
||||
Multi-agent development lifts (PM + Dev-A + Dev-B in parallel Claude Code sessions) require passing status updates, questions, and directives between terminals. Today the user manually copies and pastes every message block. This is error-prone, breaks flow, and scales poorly as lift complexity grows.
|
||||
|
||||
## Goal
|
||||
|
||||
A lightweight MCP server running on localhost that gives all three Claude Code sessions native tools to post and read messages. The user stops being the message bus.
|
||||
|
||||
---
|
||||
|
||||
## Repository layout
|
||||
|
||||
```
|
||||
tools/relay/
|
||||
├── package.json # private, not published; single dep: @modelcontextprotocol/sdk
|
||||
├── tsconfig.json
|
||||
├── server.ts # MCP SSE server entry point (~150 lines)
|
||||
├── queue.ts # in-memory queue logic (~50 lines)
|
||||
├── queue.test.ts # Node built-in test runner
|
||||
└── start.sh # launcher script
|
||||
```
|
||||
|
||||
Added to root `.gitignore`: `tools/relay/node_modules/`, `tools/relay/dist/`.
|
||||
|
||||
---
|
||||
|
||||
## Tech stack
|
||||
|
||||
- **Runtime:** Node.js (v25, already installed at `/usr/bin/node`)
|
||||
- **Package manager:** npm (bun has known compat gaps with the MCP SDK's SSE transport)
|
||||
- **Dependencies:** `@modelcontextprotocol/sdk`, `tsx` (devDependency, runs TypeScript directly — no compile step)
|
||||
- **Transport:** SSE (`SSEServerTransport` from the SDK handles the HTTP layer — no Express or Hono needed)
|
||||
- **Port:** `7331` (hardcoded; easy to remember, unlikely to collide)
|
||||
|
||||
---
|
||||
|
||||
## Queue model
|
||||
|
||||
Three named inboxes: `pm`, `dev-a`, `dev-b`. Each is a FIFO array in memory.
|
||||
|
||||
Message shape:
|
||||
|
||||
```ts
|
||||
interface RelayMessage {
|
||||
id: string; // uuid v4
|
||||
from: string; // sender role name
|
||||
to: string; // recipient role name
|
||||
kind: "status" | "question" | "directive" | "free";
|
||||
body: string; // freeform, typically the existing markdown block format
|
||||
ts: string; // ISO 8601
|
||||
}
|
||||
```
|
||||
|
||||
`kind` maps to the existing coordination protocol:
|
||||
|
||||
| kind | existing block |
|
||||
|-------------|-----------------------------|
|
||||
| `status` | `## STATUS UPDATE — DEV-*` |
|
||||
| `question` | `## QUESTION TO PM — DEV-*` |
|
||||
| `directive` | `## DIRECTIVE TO DEV-*` |
|
||||
| `free` | ad-hoc / unstructured |
|
||||
|
||||
Messages are **consume-once**: `read_messages` drains the inbox. There is no persistence — if the server restarts mid-lift, in-flight messages are lost. Acceptable for a dev tool; agents re-send on reconnect.
|
||||
|
||||
---
|
||||
|
||||
## MCP tool surface
|
||||
|
||||
All three tools are exposed to every connected session.
|
||||
|
||||
### `post_message`
|
||||
|
||||
```
|
||||
post_message(from: "pm"|"dev-a"|"dev-b", to: "pm"|"dev-a"|"dev-b", kind: "status"|"question"|"directive"|"free", body: string) → { id: string }
|
||||
```
|
||||
|
||||
Pushes one message onto the target's inbox. Returns the assigned message id. Errors if `to` or `from` is not a known role. Agents declare their own identity via `from` — the kickoff prompt tells each agent its role name.
|
||||
|
||||
### `read_messages`
|
||||
|
||||
```
|
||||
read_messages(for: "pm"|"dev-a"|"dev-b") → RelayMessage[]
|
||||
```
|
||||
|
||||
Pops and returns all pending messages for that recipient, in FIFO order. After this call the inbox is empty.
|
||||
|
||||
### `list_pending`
|
||||
|
||||
```
|
||||
list_pending(for: "pm"|"dev-a"|"dev-b") → { count: number, kinds: string[] }
|
||||
```
|
||||
|
||||
Returns count and kind breakdown of pending messages without consuming them. Lets an agent cheaply check "do I have anything to act on?" before committing to a `read_messages` call.
|
||||
|
||||
---
|
||||
|
||||
## Server terminal output
|
||||
|
||||
Every `post_message` call prints a one-liner to stdout in the dedicated relay terminal:
|
||||
|
||||
```
|
||||
[14:32:01] dev-b → pm [status] "Task P4 DONE, last commit abc1234..."
|
||||
[14:33:15] pm → dev-b [directive] "PROCEED to task B1"
|
||||
```
|
||||
|
||||
This log is the operational value of keeping the server in a dedicated terminal rather than backgrounding it.
|
||||
|
||||
---
|
||||
|
||||
## Launcher script (`start.sh`)
|
||||
|
||||
`start.sh` accepts one optional flag:
|
||||
|
||||
| Flag | Behavior |
|
||||
|------------|----------|
|
||||
| *(default)*| `--manual` mode: prints three labeled prompt blocks (one per role) for copy-paste into fresh Claude Code sessions, then starts the server in the foreground |
|
||||
| `--tmux` | Creates a new tmux window with four panes: relay server + PM + Dev-A + Dev-B, each pre-loaded with its kickoff command |
|
||||
| `--kitty` | Same layout using kitty's `launch --new-tab` / `--new-window` |
|
||||
|
||||
Execution order (all modes):
|
||||
|
||||
1. `cd tools/relay && npm install --silent` (no-op if `node_modules` is current)
|
||||
2. Print the session snippet (copy-paste blocks or multiplexer launch)
|
||||
3. Foreground `npx tsx server.ts` — the terminal that ran `start.sh` becomes the relay terminal; no compile step needed
|
||||
|
||||
Port-already-in-use check before step 3: if `:7331` is bound, print `relay already running? kill it with: kill $(lsof -ti:7331)` and exit 1.
|
||||
|
||||
---
|
||||
|
||||
## Claude Code configuration
|
||||
|
||||
Add to project `.claude/settings.json`:
|
||||
|
||||
```json
|
||||
"mcpServers": {
|
||||
"relay": {
|
||||
"type": "sse",
|
||||
"url": "http://localhost:7331/sse"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This is project-scoped — the relay tools only appear in Relicario Claude Code sessions. When the server is not running, Claude Code shows a yellow MCP connection warning but does not break. Agents gracefully fall back to asking the user to relay manually (existing behavior).
|
||||
|
||||
---
|
||||
|
||||
## Kickoff prompt changes
|
||||
|
||||
One paragraph added near the top of each coordination prompt (`v0.5.0-pm-prompt.md`, `v0.5.0-dev-a-prompt.md`, `v0.5.0-dev-b-prompt.md` as template):
|
||||
|
||||
> **Relay server:** A message-bus MCP server is running. You have three tools: `post_message(to, kind, body)`, `read_messages(for)`, `list_pending(for)`. Recipients: `pm`, `dev-a`, `dev-b`. Use these instead of asking the user to copy-paste. Before starting each task call `read_messages(for="<your-role>")`. After emitting any status, question, or directive block, call `post_message` with `kind` set to the block type and `body` set to the formatted block.
|
||||
|
||||
The `multi-agent-kickoff` skill is updated to:
|
||||
- Remind the user to run `tools/relay/start.sh` before opening the three sessions
|
||||
- Inject the relay paragraph automatically into every generated kickoff prompt
|
||||
|
||||
---
|
||||
|
||||
## Error handling
|
||||
|
||||
| Scenario | Behavior |
|
||||
|----------|----------|
|
||||
| Unknown `to` in `post_message` | MCP error returned; message not queued |
|
||||
| Server crash / restart | In-flight messages lost; agent re-sends |
|
||||
| Port 7331 in use at startup | Startup exits 1 with a kill hint |
|
||||
| Session connects before server starts | Claude Code shows MCP warning; agent falls back to manual relay |
|
||||
|
||||
No authentication. This is localhost-only, single-machine, dev-tool use.
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
`queue.test.ts` using Node's built-in `node:test` runner. No extra test dep.
|
||||
|
||||
Coverage:
|
||||
- `post_message` + `read_messages` roundtrip (single and multiple messages)
|
||||
- Consume-once: second `read_messages` on same inbox returns empty
|
||||
- `list_pending` does not drain inbox
|
||||
- FIFO ordering across multiple senders to the same inbox
|
||||
- Unknown recipient returns an error
|
||||
|
||||
No integration test against the MCP SSE transport — that is the SDK's responsibility.
|
||||
|
||||
---
|
||||
|
||||
## Top-level README (`docs/superpowers/MULTI-AGENT.md`)
|
||||
|
||||
A durable reference document covering the whole development paradigm — not the relay server specifically, but the entire three-terminal workflow that the relay server enables. Lives in `docs/superpowers/` alongside the specs and plans it describes.
|
||||
|
||||
**Contents:**
|
||||
|
||||
1. **Overview** — the PM/Dev-A/Dev-B pattern: why three terminals, what each role owns, what the user's job is (authorize merges, resolve escalations)
|
||||
2. **Starting a lift** — prerequisites checklist, then: `tools/relay/start.sh` → three sessions → paste kickoff prompts
|
||||
3. **Coordination protocol reference** — the four message kinds (`status`, `question`, `directive`, `free`), when each is used, what a well-formed body looks like
|
||||
4. **Using the relay tools** — `post_message`, `read_messages`, `list_pending` with one-liner examples
|
||||
5. **If the relay server isn't running** — fallback to manual copy-paste; the coordination protocol still works, just with the user as bus
|
||||
6. **Generating kickoff prompts** — point to the `multi-agent-kickoff` skill; note that the skill injects the relay paragraph automatically
|
||||
7. **Ending a lift** — PM emits MERGE-APPROVED, devs push branches, user authorizes merges, Ctrl-C the relay terminal
|
||||
|
||||
This README is written for future-you opening the repo six months from now, not for the current lift.
|
||||
|
||||
---
|
||||
|
||||
## What this is not
|
||||
|
||||
- Not a product feature — never bundled with the extension or CLI
|
||||
- Not persistent — no SQLite, no file queue, in-memory only
|
||||
- Not authenticated — localhost dev tool, no threat model
|
||||
- Not a general-purpose message bus — three hardcoded roles, no dynamic registration
|
||||
@@ -0,0 +1,357 @@
|
||||
# v0.5.x — UX Polish, Settings Redesign & Recovery QR — Design
|
||||
|
||||
**Date:** 2026-05-03
|
||||
**Status:** Draft
|
||||
**Target:** v0.5.1 / next release train
|
||||
|
||||
## Overview
|
||||
|
||||
Three parallel streams building on the v0.5.0 base:
|
||||
|
||||
- **Stream A — Fullscreen + popup layout polish** — fullscreen vault tab gets a new 3-column layout (sidebar with type-category nav, full-width list, slide-in detail drawer); popup gets a polished type-picker; glyph additions; toast system; empty states.
|
||||
- **Stream B — Settings UX redesign** — replace the current flat settings dump with a left-nav sectioned settings page; Security section with trusted-devices and Recovery QR integration.
|
||||
- **Stream C — Recovery QR + setup wizard** — implement the recovery QR cryptographic feature (Rust core + WASM); integrate into the setup wizard's final step; wire into the vault-tab Security settings section.
|
||||
|
||||
Streams A and B share no files with Stream C (Rust/WASM). A and B share only `glyphs.ts` and `styles.css`; all other files are disjoint. All three can run in parallel.
|
||||
|
||||
---
|
||||
|
||||
## Stream A — Fullscreen + Popup Layout Polish
|
||||
|
||||
### A1. Fullscreen vault tab — 3-column layout
|
||||
|
||||
**Current state.** `extension/src/vault/vault.ts` renders a fixed sidebar (~220px) with brand, search, item list, and bottom nav buttons. Clicking `+ new item` navigates to the type-picker. The main pane shows the selected item in a single-column layout.
|
||||
|
||||
**New layout.**
|
||||
|
||||
```
|
||||
┌─────────────┬──────────────────────────┬──────────────────────────┐
|
||||
│ sidebar │ full-width list │ detail drawer (440px) │
|
||||
│ (200px) │ (flex: 1) │ slides in on row click │
|
||||
└─────────────┴──────────────────────────┴──────────────────────────┘
|
||||
```
|
||||
|
||||
**Sidebar changes:**
|
||||
- Replaces the current flat item list with **type-category nav**: all items are listed by section (Logins N, Secure Notes N, Cards N, Identities N, TOTP N, Keys N, Documents N) plus an "All items" entry at the top.
|
||||
- Search bar stays above the category list.
|
||||
- Bottom nav buttons remain (+ new item, ▦ trash, ⌬ devices, ⚙ settings, ⏻ lock) — the `+ new item` button triggers the bottom sheet (see A3).
|
||||
- `⧉` replaces the current `⤴` pop-out button in the **popup toolbar only** — it stays in the popup toolbar and is not added to the fullscreen sidebar (you're already there).
|
||||
|
||||
**Full-width list:**
|
||||
- Each row: 32px type icon (rounded, gold-tinted on selection) + title (13px) + subtitle (URL or type description, 11px muted) + `last-modified` age (10px dim, right-aligned).
|
||||
- Clicking a row: highlights the row and slides in the detail drawer from the right. The list narrows to accommodate the 440px drawer — flex layout handles this naturally.
|
||||
- Active row stays highlighted while drawer is open.
|
||||
|
||||
**Detail drawer (440px):**
|
||||
- Header: type pill (e.g. `LOGIN`) left, action buttons right (`edit`, `history`, `copy pwd` where applicable), `✕` close.
|
||||
- Body: title (18-20px bold) + subtitle (URL/description, muted), then a **2-column field grid** for sibling fields (username/password, first/last name, number/expiry, etc.). Full-width spans for URL, notes, address, and any field without a natural pair.
|
||||
- Close (`✕` or Esc): drawer slides out, list returns to full width.
|
||||
- At ≤ 720px viewport: drawer pushes full-page (list hidden), back breadcrumb `← <Section>` navigates back.
|
||||
|
||||
**Files affected:**
|
||||
- `extension/src/vault/vault.ts` — full layout rewrite (sidebar list → category nav, main pane wiring, drawer state)
|
||||
- `extension/src/vault/vault.css` — layout rules for 3-column, drawer, list rows, responsive breakpoint
|
||||
|
||||
### A2. Fullscreen vault tab — "new item" bottom sheet
|
||||
|
||||
**Current state.** Clicking `+ new item` in the sidebar sets `state.newType = null` and calls `renderPane()` which renders the type-picker inline in the main pane.
|
||||
|
||||
**New behaviour.** A bottom sheet slides up from the bottom edge of the **main pane** (pane-only scrim — sidebar stays interactive).
|
||||
|
||||
- Sheet structure: drag handle, "New item — choose type" label, 7-item type grid (Login, Secure Note, TOTP, Card, Identity, SSH/API Key, Document) as cards with large glyph (28px), name (11px muted). Selected type border turns gold on hover.
|
||||
- Clicking a type: sheet closes, main pane renders the add form for that type.
|
||||
- Dismissing (Esc, click scrim, `✕`): sheet closes, main pane returns to previous state.
|
||||
- Scrim covers the main pane only (not the sidebar). Sidebar nav remains clickable.
|
||||
|
||||
**Files affected:**
|
||||
- `extension/src/vault/vault.ts` — sheet trigger, render, dismiss logic
|
||||
- `extension/src/vault/vault.css` — sheet, scrim, type-card styles
|
||||
|
||||
### A3. Popup — polished type-picker page
|
||||
|
||||
**Current state.** `+ new` button in the popup toolbar navigates directly to the `add` route. `renderItemForm` is called with `state.newType = null`, which presumably renders a type picker inline.
|
||||
|
||||
**New behaviour.** Keep the current navigation model (navigate to `add` route) but upgrade the type-picker page:
|
||||
- Back arrow + "New item" title in the search-bar row (replacing search input).
|
||||
- 2-column grid of type cards: icon (glyph, 20px), name (12px bold), description (10px muted). E.g. "Login / Username + password", "TOTP / 2FA token".
|
||||
- Glyphs not emoji for type icons (use the per-type glyph table from A5).
|
||||
- `Esc` navigates back to the list.
|
||||
- Keyhint bar updates to show `Esc back`.
|
||||
|
||||
**Files affected:**
|
||||
- `extension/src/popup/components/item-list.ts` — `+ new` button label/glyph, keyhint
|
||||
- `extension/src/popup/components/item-form.ts` (or wherever the type picker lives) — card layout, glyphs
|
||||
|
||||
### A4. Glyphs
|
||||
|
||||
Add to `extension/src/shared/glyphs.ts`:
|
||||
|
||||
```ts
|
||||
export const GLYPH_VAULT_TAB = '⧉'; // pop-out to fullscreen vault tab (replaces ⤴)
|
||||
```
|
||||
|
||||
Remove the inline `⤴` from `extension/src/popup/components/item-list.ts:69` and replace with `GLYPH_VAULT_TAB`.
|
||||
|
||||
### A5. Item row type icons
|
||||
|
||||
The popup item list (`buildRowsHtml` in `item-list.ts`) currently renders title-only rows with no visual type anchor. Add a per-type glyph to each row using the item's `ManifestEntry.type` field:
|
||||
|
||||
| Type | Glyph |
|
||||
|------|-------|
|
||||
| login | `◉` |
|
||||
| secure_note | `◫` |
|
||||
| totp | `⊡` |
|
||||
| card | `▭` |
|
||||
| identity | `⌬` |
|
||||
| key | `⊹` |
|
||||
| document | `≡` |
|
||||
|
||||
Icon: 26×26px, rounded, `--bg-elevated` fill, gold-tinted border on active row.
|
||||
|
||||
**Files affected:** `extension/src/popup/components/item-list.ts`, `extension/src/popup/styles.css`
|
||||
|
||||
### A6. Empty states
|
||||
|
||||
Two surfaces:
|
||||
|
||||
1. **Popup item list, vault empty** — centered message: glyph `◈` (28px dim), "No items yet", "Press `+` to add your first item."
|
||||
2. **Popup item list, search returns nothing** — centered message: glyph `⊘` (28px dim), "No results for "{query}"", "Try a shorter search term."
|
||||
3. **Fullscreen list pane, section empty** — same treatment scaled for the wider pane.
|
||||
|
||||
**Files affected:** `extension/src/popup/components/item-list.ts`, `extension/src/vault/vault.ts`
|
||||
|
||||
### A7. Toast notification system
|
||||
|
||||
Replace the current ad-hoc `sync-status` div with a shared toast system:
|
||||
|
||||
- `showToast(message: string, type: 'success' | 'error' | 'info', durationMs = 2500)` in `extension/src/shared/toast.ts`.
|
||||
- Toasts appear bottom-center of the popup / bottom-right of the vault tab, auto-dismiss.
|
||||
- Used for: sync success/failure, copy-to-clipboard confirmation, device registration success.
|
||||
|
||||
**Files affected:** new `extension/src/shared/toast.ts`, `extension/src/popup/styles.css`, `extension/src/vault/vault.css`, call sites in `item-list.ts` and `vault.ts`
|
||||
|
||||
---
|
||||
|
||||
## Stream B — Settings UX Redesign
|
||||
|
||||
### B1. Settings page structure
|
||||
|
||||
Replace the current flat settings dump (`settings.ts` + `settings-vault.ts`) with a unified settings page that renders within the fullscreen vault tab's main pane (and a compact equivalent in the popup).
|
||||
|
||||
**Left-nav sections:**
|
||||
|
||||
```
|
||||
Device
|
||||
⊙ Autofill
|
||||
◈ Display
|
||||
Vault
|
||||
◉ Security ← Recovery QR + trusted devices (replaces devices.ts nav)
|
||||
↻ Generator
|
||||
▦ Retention
|
||||
⤓ Backup
|
||||
≡ Import
|
||||
```
|
||||
|
||||
Each section renders its content in the right panel. The left nav is 148px; content area fills the remainder.
|
||||
|
||||
**Device vs Vault distinction:**
|
||||
- "Device" sections read/write `chrome.storage.local` (per-browser settings).
|
||||
- "Vault" sections read/write encrypted `VaultSettings` (shared across devices via git).
|
||||
|
||||
**Files affected:**
|
||||
- `extension/src/popup/components/settings.ts` — rewrite as sectioned layout
|
||||
- `extension/src/popup/components/settings-vault.ts` — content moves into new section components
|
||||
|
||||
**Note on vault.ts:** DEV-B delivers the settings component with a stable export signature. The `⚙ settings` nav wiring in `vault.ts` is updated as part of Stream A's vault.ts rewrite. DEV-A and DEV-B must agree on the component's export signature before either lands.
|
||||
|
||||
### B2. Autofill section (Device)
|
||||
|
||||
Content replaces the current flat settings dump:
|
||||
|
||||
- **Capture** group: "Auto-detect logins" toggle (was checkbox); "Prompt style" select (bar / toast).
|
||||
- **Blocked sites** group: list of blacklisted hostnames, each with a remove button. Add-hostname input at bottom.
|
||||
|
||||
All options use the standardised `setting-row` pattern: left (title + description), right (control).
|
||||
|
||||
### B3. Display section (Device)
|
||||
|
||||
Moves the existing password-coloring UI (digit color picker, symbol color picker, live swatch, reset) from its current location into a proper Display section card.
|
||||
|
||||
### B4. Security section (Vault)
|
||||
|
||||
**Recovery QR card** (three states, see Stream C for implementation):
|
||||
|
||||
- **State 1 — no QR:** amber warning ("▲ No recovery QR generated — losing your reference image would make this vault unrecoverable"), single "Generate recovery QR…" button.
|
||||
- **State 2 — QR exists, at rest:** green status ("◉ Recovery QR is set up"), last-generated date. Buttons: "Show / print QR…" and "Regenerate…". **No QR is visible in this state.**
|
||||
- **State 3 — explicit view:** modal overlay (scrim over main pane only). QR rendered at ~140×140px. Warning: "▲ Close this window before stepping away. This QR is only displayed, never saved." Actions: "⎙ Print" (triggers `window.print()` scoped to modal) and "Done" (dismisses).
|
||||
|
||||
**Trusted devices** group: subsumes the current `⌬ devices` sidebar nav entry. Each registered device shows name, registration date, fingerprint, and a revoke button. "Register this device" entry for unregistered browsers. Once Stream B lands, the `⌬ devices` button is removed from the vault sidebar nav (settings → Security replaces it).
|
||||
|
||||
### B5. Generator section (Vault)
|
||||
|
||||
Pulls the existing generator-defaults content from `settings-vault.ts` into the new section layout. No functional changes — just consistent styling.
|
||||
|
||||
### B6. Retention section (Vault)
|
||||
|
||||
Pulls the existing retention content (trash retention, field history retention). No functional changes.
|
||||
|
||||
### B7. Backup section (Vault)
|
||||
|
||||
Pulls the existing backup & restore section. No functional changes.
|
||||
|
||||
### B8. Import section (Vault)
|
||||
|
||||
Pulls the existing import section. No functional changes.
|
||||
|
||||
---
|
||||
|
||||
## Stream C — Recovery QR
|
||||
|
||||
### C1. Rust core — `relicario-core/src/recovery_qr.rs`
|
||||
|
||||
Per the existing spec at `docs/superpowers/specs/2026-05-01-recovery-qr-design.md`. Key implementation points:
|
||||
|
||||
**KDF input:**
|
||||
```
|
||||
b"relicario-recovery-v1\0" || u64_be(len(nfc(passphrase))) || nfc(passphrase)
|
||||
```
|
||||
Fed to Argon2id with production params (`m=64MiB, t=3, p=4`), fresh 32-byte salt per generation.
|
||||
|
||||
**Wrap:** `XChaCha20-Poly1305(wrap_key, nonce=OsRng(24), image_secret)` — 32+16=48 bytes ciphertext.
|
||||
|
||||
**Binary payload (109 bytes):**
|
||||
```
|
||||
[magic "RREC" 4B][version 0x01 1B][salt 32B][nonce 24B][ciphertext 48B]
|
||||
```
|
||||
|
||||
**QR encoding:** byte mode, error-correction M, version 6 (41×41 modules). Library: `qrcode` crate (already in workspace or add it).
|
||||
|
||||
**API surface:**
|
||||
```rust
|
||||
pub struct RecoveryQrPayload { /* opaque */ }
|
||||
|
||||
pub fn generate_recovery_qr(
|
||||
passphrase: &str,
|
||||
image_secret: &[u8; 32],
|
||||
) -> Result<RecoveryQrPayload, RelicarioError>;
|
||||
|
||||
pub fn recovery_qr_to_svg(payload: &RecoveryQrPayload) -> String;
|
||||
|
||||
pub fn unwrap_recovery_qr(
|
||||
payload_bytes: &[u8],
|
||||
passphrase: &str,
|
||||
) -> Result<Zeroizing<[u8; 32]>, RelicarioError>;
|
||||
```
|
||||
|
||||
The payload bytes are never written to disk by this module — callers are responsible for rendering only.
|
||||
|
||||
**Passphrase entropy floor:** enforce `zxcvbn score ≥ 3` at vault init in the CLI and the setup wizard (already gated in the extension by 1C-α; confirm CLI `create` command applies the same gate).
|
||||
|
||||
**Files affected:**
|
||||
- `crates/relicario-core/src/recovery_qr.rs` — new module
|
||||
- `crates/relicario-core/src/lib.rs` — pub mod recovery_qr
|
||||
- `crates/relicario-core/src/error.rs` — add `RecoveryQr` error variants if needed
|
||||
- `crates/relicario-core/Cargo.toml` — add `qrcode` crate
|
||||
- `crates/relicario-core/tests/` — new `recovery_qr.rs` test file
|
||||
|
||||
### C2. CLI — `relicario recovery-qr` subcommand group
|
||||
|
||||
```
|
||||
relicario recovery-qr generate # prompts passphrase, renders QR to terminal (kitty/iTerm2 inline protocol or ASCII fallback)
|
||||
relicario recovery-qr unwrap # prompts passphrase, prints image_secret as hex
|
||||
```
|
||||
|
||||
`generate` never writes a file. It renders the QR inline in the terminal using the Kitty graphics protocol if `$TERM` indicates support, falling back to ASCII art via the `qrcode` crate's built-in ASCII renderer.
|
||||
|
||||
**Files affected:** `crates/relicario-cli/src/main.rs`
|
||||
|
||||
### C3. WASM bindings
|
||||
|
||||
```ts
|
||||
// relicario-wasm/src/lib.rs
|
||||
generate_recovery_qr(passphrase: &str, image_secret: &[u8]) -> Result<String, JsValue> // returns SVG string
|
||||
unwrap_recovery_qr(payload_b64: &str, passphrase: &str) -> Result<Vec<u8>, JsValue> // returns image_secret bytes
|
||||
```
|
||||
|
||||
**Files affected:** `crates/relicario-wasm/src/lib.rs`, `crates/relicario-wasm/Cargo.toml`
|
||||
|
||||
### C4. Extension — Recovery QR in Security settings
|
||||
|
||||
Implement the three-state Security section card described in B4:
|
||||
|
||||
- State determined by `chrome.storage.local.recovery_qr_generated_at` (timestamp or null).
|
||||
- "Generate recovery QR…" button: calls WASM `generate_recovery_qr(passphrase, image_secret)` → stores `recovery_qr_generated_at = Date.now()` in local storage → transitions to State 3 (show modal with SVG).
|
||||
- "Show / print QR…" button: re-derives QR (requires vault to be unlocked, master key in session) → shows State 3 modal.
|
||||
- "Regenerate…" button: same as generate, with a confirmation step first.
|
||||
- Print: injects SVG into a `<iframe>` styled for print, calls `iframe.contentWindow.print()`.
|
||||
|
||||
**Files affected:**
|
||||
- New `extension/src/popup/components/settings-security.ts`
|
||||
- `extension/src/popup/components/settings.ts` — wire Security section
|
||||
|
||||
### C5. Extension — Recovery QR in setup wizard (Step 5 "Done")
|
||||
|
||||
The wizard's final step adds a **skippable banner** above the "Download reference image" button:
|
||||
|
||||
```
|
||||
◫ Generate a recovery QR before you go
|
||||
If you lose your reference image, this QR lets you recover your vault.
|
||||
[Generate now] [Skip — I'll do this in Settings]
|
||||
```
|
||||
|
||||
- "Generate now": calls WASM → shows QR modal inline on the wizard page. After dismissing, banner becomes green "◉ Recovery QR generated".
|
||||
- "Skip": dismisses banner permanently for this session; user can generate later from Settings → Security.
|
||||
- The banner is informational, not a blocker. Vault is fully usable without a recovery QR.
|
||||
|
||||
**Files affected:** `extension/src/setup/setup.ts`
|
||||
|
||||
### C6. Setup wizard redesign (Style C)
|
||||
|
||||
Redesign the setup wizard from the current single-column glass-card layout to **Style C (centered hero card)**:
|
||||
|
||||
- Full-page dark background (`--bg-page`).
|
||||
- Relicario logo glyph + wordmark centered at top.
|
||||
- **Colored progress track**: 5 segments, `--success` fill for completed, `--gold` for current, `--border` for pending.
|
||||
- Centered card (max-width 560px): step eyebrow label ("Step N of 5 · <step name>"), h2 heading, hint text, form content, action row.
|
||||
- **Glyphs not emoji** throughout. Mode cards use `◈` (create new) and `⌥` (attach). Mode-card glyphs at 28px. All other icons from the existing glyph set.
|
||||
- Probe-banner success state uses `◉` (filled circle, matches ⊙/⊘ family).
|
||||
- Action row: "◂ back" text button (left), "Continue ▸" primary button (right).
|
||||
|
||||
This is a pure CSS/markup change — no logic changes.
|
||||
|
||||
**Files affected:** `extension/src/setup/setup.ts`, setup CSS (inline or extracted)
|
||||
|
||||
---
|
||||
|
||||
## Responsive behaviour
|
||||
|
||||
| Viewport | Fullscreen behaviour |
|
||||
|---|---|
|
||||
| ≥ 960px | 3-column: sidebar + list + drawer |
|
||||
| 720–960px | 2-column: sidebar + list; drawer pushes full-pane on click |
|
||||
| ≤ 720px | Sidebar collapses (hamburger/icon strip); list full-width; detail is full-page push |
|
||||
|
||||
The popup is always narrow (~340px) — popup-specific components are unaffected by the fullscreen responsive rules.
|
||||
|
||||
---
|
||||
|
||||
## Acceptance criteria (shared)
|
||||
|
||||
- `cargo test` green. `bun run test` green. `bun run build` + `bun run build:firefox` clean.
|
||||
- No raw `snake_case` error codes in any UI surface.
|
||||
- No emoji in any UI surface — all icons are Unicode monochrome glyphs.
|
||||
- `glyphs.ts` is the single source of truth for all icon constants; no inline Unicode literals at call sites.
|
||||
- QR code is never written to any file, `chrome.storage`, or git. `recovery_qr_generated_at` (timestamp only) is the only persisted artifact.
|
||||
- Settings left-nav sections all render without console errors. Device sections read/write `chrome.storage.local`. Vault sections read/write `VaultSettings`.
|
||||
|
||||
---
|
||||
|
||||
## Stream split summary (for multi-agent kickoff)
|
||||
|
||||
| Stream | Owner | Core files | Dependency |
|
||||
|---|---|---|---|
|
||||
| A — Fullscreen + popup layout | DEV-A | `vault.ts`, `vault.css`, `item-list.ts`, `glyphs.ts` | none |
|
||||
| B — Settings UX | DEV-B | `settings.ts`, `settings-vault.ts`, new `settings-security.ts` | waits for C4 interface (can stub) |
|
||||
| C — Recovery QR | DEV-C | `recovery_qr.rs`, `relicario-wasm/src/lib.rs`, `setup.ts`, `settings-security.ts` | none |
|
||||
|
||||
B and C share `settings-security.ts` — DEV-C owns the file, DEV-B wires it into the nav. Coordinate on interface (component export signature) before DEV-B proceeds with B4.
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "Relicario",
|
||||
"version": "0.2.0",
|
||||
"version": "0.5.0",
|
||||
"description": "Two-factor encrypted password manager",
|
||||
"icons": {
|
||||
"16": "icons/icon-16.png",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "Relicario",
|
||||
"version": "0.2.0",
|
||||
"version": "0.5.0",
|
||||
"description": "Two-factor encrypted password manager",
|
||||
"icons": {
|
||||
"16": "icons/icon-16.png",
|
||||
|
||||
4
extension/package-lock.json
generated
4
extension/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "relicario-extension",
|
||||
"version": "0.2.0",
|
||||
"version": "0.5.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "relicario-extension",
|
||||
"version": "0.2.0",
|
||||
"version": "0.5.0",
|
||||
"dependencies": {
|
||||
"jsqr": "^1.4.0"
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "relicario-extension",
|
||||
"version": "0.2.0",
|
||||
"version": "0.5.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "webpack --mode production",
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
|
||||
vi.stubGlobal('chrome', {
|
||||
storage: {
|
||||
local: {
|
||||
get: vi.fn((_keys: unknown, cb: (r: Record<string, unknown>) => void) => cb({})),
|
||||
set: vi.fn((_data: unknown, cb?: () => void) => cb?.()),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
import * as settingsMod from '../settings';
|
||||
|
||||
describe('settings module contract', () => {
|
||||
it('exports renderSettings as a function', () => {
|
||||
expect(typeof settingsMod.renderSettings).toBe('function');
|
||||
});
|
||||
|
||||
it('exports teardownSettings as a function', () => {
|
||||
expect(typeof settingsMod.teardownSettings).toBe('function');
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
import { sendMessage, escapeHtml } from '../../shared/state';
|
||||
import type { AttachmentRef, VaultSettings } from '../../shared/types';
|
||||
import { GLYPH_TYPE_DOCUMENT } from '../../shared/glyphs';
|
||||
|
||||
export type DisclosureMode = 'edit' | 'view';
|
||||
|
||||
@@ -53,8 +54,8 @@ export function renderAttachmentsDisclosure(opts: AttachmentsDisclosureOpts): st
|
||||
const action = opts.mode === 'edit' ? '×' : '↓';
|
||||
const actionClass = opts.mode === 'edit' ? 'attachment-row__remove' : 'attachment-row__download';
|
||||
const iconHtml = isImage(a.mime_type)
|
||||
? `<span class="attachment-row__thumb" data-att-id="${escapeHtml(a.id)}" data-mime="${escapeHtml(a.mime_type)}">📄</span>`
|
||||
: `<span class="attachment-row__icon">📄</span>`;
|
||||
? `<span class="attachment-row__thumb" data-att-id="${escapeHtml(a.id)}" data-mime="${escapeHtml(a.mime_type)}">${GLYPH_TYPE_DOCUMENT}</span>`
|
||||
: `<span class="attachment-row__icon">${GLYPH_TYPE_DOCUMENT}</span>`;
|
||||
return `
|
||||
<div class="attachment-row" data-att-id="${escapeHtml(a.id)}">
|
||||
${iconHtml}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { getState, setState, sendMessage, navigate, escapeHtml } from '../../shared/state';
|
||||
import { colorizePassword } from '../../shared/password-coloring';
|
||||
import type { FieldHistoryView } from '../../shared/types';
|
||||
import { GLYPH_COPY } from '../../shared/glyphs';
|
||||
|
||||
function relativeTime(unixSec: number): string {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
@@ -75,7 +76,7 @@ export async function renderFieldHistory(app: HTMLElement): Promise<void> {
|
||||
${isCurrent ? '<span class="history-entry__current">current</span>' : ''}
|
||||
<span>${isCurrent ? 'set' : 'changed'} ${relativeTime(timestamp)}</span>
|
||||
</div>
|
||||
<button class="history-entry__copy" data-entry-copy="${escapeHtml(entryKey)}" title="Copy">📋</button>
|
||||
<button class="history-entry__copy" data-entry-copy="${escapeHtml(entryKey)}" title="Copy">${GLYPH_COPY}</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -140,7 +141,7 @@ export async function renderFieldHistory(app: HTMLElement): Promise<void> {
|
||||
const value = valueStore.get(key) ?? '';
|
||||
await navigator.clipboard.writeText(value);
|
||||
btn.textContent = '✓';
|
||||
setTimeout(() => { btn.textContent = '📋'; }, 1500);
|
||||
setTimeout(() => { btn.textContent = GLYPH_COPY; }, 1500);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3,15 +3,19 @@
|
||||
|
||||
import { navigate, getState, setState, escapeHtml, popOutToTab, isInTab } from '../../shared/state';
|
||||
import type { Item, ItemType } from '../../shared/types';
|
||||
import {
|
||||
GLYPH_TYPE_LOGIN, GLYPH_TYPE_SECURE_NOTE, GLYPH_TYPE_TOTP,
|
||||
GLYPH_TYPE_CARD, GLYPH_TYPE_IDENTITY, GLYPH_TYPE_KEY, GLYPH_TYPE_DOCUMENT,
|
||||
} from '../../shared/glyphs';
|
||||
|
||||
const TYPE_OPTIONS: Array<{ type: ItemType; icon: string; label: string }> = [
|
||||
{ type: 'login', icon: '🔑', label: 'login' },
|
||||
{ type: 'secure_note', icon: '📝', label: 'secure note' },
|
||||
{ type: 'identity', icon: '👤', label: 'identity' },
|
||||
{ type: 'card', icon: '💳', label: 'card' },
|
||||
{ type: 'key', icon: '🔐', label: 'key' },
|
||||
{ type: 'document', icon: '📄', label: 'document' },
|
||||
{ type: 'totp', icon: '⏱️', label: 'totp' },
|
||||
const TYPE_OPTIONS: Array<{ type: ItemType; icon: string; label: string; description: string }> = [
|
||||
{ type: 'login', icon: GLYPH_TYPE_LOGIN, label: 'Login', description: 'Username + password' },
|
||||
{ type: 'secure_note', icon: GLYPH_TYPE_SECURE_NOTE, label: 'Secure Note', description: 'Encrypted text note' },
|
||||
{ type: 'identity', icon: GLYPH_TYPE_IDENTITY, label: 'Identity', description: 'Personal details' },
|
||||
{ type: 'card', icon: GLYPH_TYPE_CARD, label: 'Card', description: 'Credit / debit card' },
|
||||
{ type: 'key', icon: GLYPH_TYPE_KEY, label: 'SSH / API Key', description: 'Keys and tokens' },
|
||||
{ type: 'document', icon: GLYPH_TYPE_DOCUMENT, label: 'Document', description: 'File attachment' },
|
||||
{ type: 'totp', icon: GLYPH_TYPE_TOTP, label: 'TOTP', description: '2FA authenticator' },
|
||||
];
|
||||
import * as login from './types/login';
|
||||
import * as secureNote from './types/secure-note';
|
||||
@@ -54,36 +58,36 @@ export function renderItemForm(app: HTMLElement, mode: 'add' | 'edit'): void {
|
||||
function renderTypeSelection(app: HTMLElement): void {
|
||||
app.innerHTML = `
|
||||
<div class="pad">
|
||||
<div style="display:flex; align-items:center; gap:12px;">
|
||||
<button class="btn" id="back-btn">← back</button>
|
||||
<h3 style="margin:0;">new item</h3>
|
||||
<div style="display:flex; align-items:center; gap:12px; margin-bottom:12px;">
|
||||
<button class="btn" id="back-btn">◂ back</button>
|
||||
<span style="font-size:14px; font-weight:600;">New item</span>
|
||||
<span style="flex:1;"></span>
|
||||
${isInTab() ? '' : '<button class="btn" id="popout-btn" title="Open in tab">⤴</button>'}
|
||||
${isInTab() ? '' : '<button class="btn" id="popout-btn" title="Open in tab">⧉</button>'}
|
||||
</div>
|
||||
${isInTab() ? '<div class="form-subtitle">esc to cancel</div>' : '<div style="margin-bottom:16px;"></div>'}
|
||||
<div class="type-select-list">
|
||||
<div class="type-card-grid">
|
||||
${TYPE_OPTIONS.map((opt) => `
|
||||
<button class="type-select-row" data-type="${opt.type}">
|
||||
<span class="type-select-icon">${opt.icon}</span>
|
||||
<span>${escapeHtml(opt.label)}</span>
|
||||
<button class="type-card" data-type="${opt.type}">
|
||||
<span class="type-card__icon" aria-hidden="true">${opt.icon}</span>
|
||||
<span class="type-card__label">${escapeHtml(opt.label)}</span>
|
||||
<span class="type-card__desc">${escapeHtml(opt.description)}</span>
|
||||
</button>
|
||||
`).join('')}
|
||||
</div>
|
||||
<div class="keyhints"><span><kbd>Esc</kbd> back</span></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.getElementById('back-btn')?.addEventListener('click', () => navigate('list'));
|
||||
document.getElementById('popout-btn')?.addEventListener('click', popOutToTab);
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') navigate('list');
|
||||
}, { once: true });
|
||||
|
||||
document.querySelectorAll<HTMLButtonElement>('[data-type]').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
const type = btn.dataset.type as ItemType;
|
||||
setState({ newType: type });
|
||||
if (type === 'login' || type === 'secure_note') {
|
||||
renderItemForm(app, 'add');
|
||||
} else {
|
||||
popOutToTab();
|
||||
}
|
||||
renderItemForm(app, 'add');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3,6 +3,13 @@
|
||||
/// to the detail view.
|
||||
|
||||
import { getState, setState, sendMessage, navigate, escapeHtml, openVaultTab } from '../../shared/state';
|
||||
import { showToast } from '../../shared/toast';
|
||||
import {
|
||||
GLYPH_VAULT_TAB,
|
||||
GLYPH_DEVICES, GLYPH_LOCK,
|
||||
GLYPH_TYPE_LOGIN, GLYPH_TYPE_SECURE_NOTE, GLYPH_TYPE_TOTP,
|
||||
GLYPH_TYPE_CARD, GLYPH_TYPE_IDENTITY, GLYPH_TYPE_KEY, GLYPH_TYPE_DOCUMENT,
|
||||
} from '../../shared/glyphs';
|
||||
import type { ItemId, ItemType, ManifestEntry, Item } from '../../shared/types';
|
||||
|
||||
/// Extract the display hostname from an icon_hint or fallback to the first tag.
|
||||
@@ -12,30 +19,46 @@ function metaLine(e: ManifestEntry): string {
|
||||
return '';
|
||||
}
|
||||
|
||||
/// Emoji icon per item type. Placeholder until we ship real SVG icons.
|
||||
/// Glyph icon per item type.
|
||||
function typeIcon(t: ItemType): string {
|
||||
switch (t) {
|
||||
case 'login': return '🔑';
|
||||
case 'secure_note': return '📝';
|
||||
case 'identity': return '🪪';
|
||||
case 'card': return '💳';
|
||||
case 'key': return '🗝';
|
||||
case 'document': return '📄';
|
||||
case 'totp': return '⏱';
|
||||
case 'login': return GLYPH_TYPE_LOGIN;
|
||||
case 'secure_note': return GLYPH_TYPE_SECURE_NOTE;
|
||||
case 'identity': return GLYPH_TYPE_IDENTITY;
|
||||
case 'card': return GLYPH_TYPE_CARD;
|
||||
case 'key': return GLYPH_TYPE_KEY;
|
||||
case 'document': return GLYPH_TYPE_DOCUMENT;
|
||||
case 'totp': return GLYPH_TYPE_TOTP;
|
||||
}
|
||||
}
|
||||
|
||||
function buildRowsHtml(): string {
|
||||
const state = getState();
|
||||
const filtered = getFilteredEntries();
|
||||
return filtered.length > 0
|
||||
? filtered.map(([id, e], i) => `
|
||||
if (filtered.length > 0) {
|
||||
return filtered.map(([id, e], i) => `
|
||||
<div class="entry-row ${i === state.selectedIndex ? 'selected' : ''}" data-id="${escapeHtml(id)}" data-index="${i}">
|
||||
<span class="entry-name"><span class="type-icon" aria-hidden="true">${typeIcon(e.type)}</span> ${escapeHtml(e.title)}${e.attachment_summaries.length > 0 ? ' <span class="entry-row__attach-indicator" title="has attachments">📎</span>' : ''}</span>
|
||||
<span class="entry-name"><span class="type-icon" aria-hidden="true">${typeIcon(e.type)}</span> ${escapeHtml(e.title)}${e.attachment_summaries.length > 0 ? ' <span class="entry-row__attach-indicator" title="has attachments">⊕</span>' : ''}</span>
|
||||
<span class="entry-meta">${escapeHtml(metaLine(e))}</span>
|
||||
</div>
|
||||
`).join('')
|
||||
: '<div class="empty">no items</div>';
|
||||
`).join('');
|
||||
}
|
||||
if (state.searchQuery) {
|
||||
return `
|
||||
<div class="empty-state">
|
||||
<span class="empty-state__icon" aria-hidden="true">⊘</span>
|
||||
<div class="empty-state__title">No results for "${escapeHtml(state.searchQuery)}"</div>
|
||||
<div class="empty-state__hint">Try a shorter search term.</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
return `
|
||||
<div class="empty-state">
|
||||
<span class="empty-state__icon" aria-hidden="true">◈</span>
|
||||
<div class="empty-state__title">No items yet</div>
|
||||
<div class="empty-state__hint">Press <kbd>+</kbd> to add your first item.</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function updateItemList(): void {
|
||||
@@ -66,7 +89,7 @@ export function renderItemList(app: HTMLElement): void {
|
||||
<button class="btn" id="new-btn" style="font-size:11px;">+ new</button>
|
||||
<button class="btn" id="sync-btn" style="font-size:11px;">sync</button>
|
||||
<span style="flex:1;"></span>
|
||||
<button class="btn" id="vault-btn" style="font-size:11px;" title="Open vault (Shift+F)">⤴</button>
|
||||
<button class="btn" id="vault-btn" style="font-size:11px;" title="Open vault (Shift+F)">${GLYPH_VAULT_TAB}</button>
|
||||
<button class="btn" id="settings-btn" style="font-size:11px;">settings</button>
|
||||
<button class="btn" id="lock-btn" style="font-size:11px;">lock</button>
|
||||
</div>
|
||||
@@ -108,11 +131,14 @@ export function renderItemList(app: HTMLElement): void {
|
||||
if (listResp.ok) {
|
||||
const data = listResp.data as { items: Array<[ItemId, ManifestEntry]> };
|
||||
setState({ entries: data.items, loading: false });
|
||||
showToast('Synced', 'success');
|
||||
return;
|
||||
}
|
||||
setState({ loading: false, error: listResp.error });
|
||||
showToast(listResp.error ?? 'Sync failed', 'error');
|
||||
} else {
|
||||
setState({ loading: false, error: resp.error });
|
||||
showToast(resp.error ?? 'Sync failed', 'error');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -253,8 +279,8 @@ function handleListKeydown(e: KeyboardEvent): void {
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
const SETTINGS_OPTIONS: Array<{ view: 'settings' | 'settings-vault'; icon: string; label: string }> = [
|
||||
{ view: 'settings', icon: '🖥', label: 'device settings' },
|
||||
{ view: 'settings-vault', icon: '🔐', label: 'vault settings' },
|
||||
{ view: 'settings', icon: GLYPH_DEVICES, label: 'device settings' },
|
||||
{ view: 'settings-vault', icon: GLYPH_LOCK, label: 'vault settings' },
|
||||
];
|
||||
|
||||
function showSettingsPicker(anchor: HTMLElement): void {
|
||||
|
||||
329
extension/src/popup/components/settings-security.ts
Normal file
329
extension/src/popup/components/settings-security.ts
Normal file
@@ -0,0 +1,329 @@
|
||||
/// Security settings section — three-state Recovery QR + Trusted Devices panel.
|
||||
///
|
||||
/// Exported contract:
|
||||
/// renderSecuritySection(container, sessionHandle): renders into `container`
|
||||
/// teardownSecuritySection(): removes any open QR modal
|
||||
|
||||
import { sendMessage, escapeHtml } from '../../shared/state';
|
||||
import type { Device } from '../../shared/types';
|
||||
|
||||
// --- Relative time helper ---
|
||||
|
||||
function relativeTime(unixSec: number): string {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const diff = now - unixSec;
|
||||
if (diff < 60) return 'just now';
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
|
||||
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
|
||||
if (diff < 2592000) return `${Math.floor(diff / 86400)}d ago`;
|
||||
return `${Math.floor(diff / 2592000)}mo ago`;
|
||||
}
|
||||
|
||||
// --- Modal helpers ---
|
||||
|
||||
const MODAL_ID = 'relicario-qr-modal';
|
||||
|
||||
function removeModal(): void {
|
||||
document.getElementById(MODAL_ID)?.remove();
|
||||
}
|
||||
|
||||
function showQrModal(svgContent: string): void {
|
||||
removeModal();
|
||||
|
||||
const overlay = document.createElement('div');
|
||||
overlay.id = MODAL_ID;
|
||||
overlay.style.cssText = [
|
||||
'position:fixed', 'inset:0', 'z-index:9999',
|
||||
'background:rgba(0,0,0,0.85)',
|
||||
'display:flex', 'flex-direction:column',
|
||||
'align-items:center', 'justify-content:center',
|
||||
'padding:16px', 'box-sizing:border-box',
|
||||
].join(';');
|
||||
|
||||
overlay.innerHTML = `
|
||||
<div style="
|
||||
background:#161b22; border:1px solid #30363d; border-radius:8px;
|
||||
padding:16px; max-width:340px; width:100%; text-align:center;
|
||||
">
|
||||
<div style="font-size:13px; font-weight:600; margin-bottom:8px; color:#e6edf3;">
|
||||
Recovery QR
|
||||
</div>
|
||||
<div style="font-size:11px; color:#8b949e; margin-bottom:12px;">
|
||||
Print or store this QR. It encodes your reference image secret,
|
||||
protected by your passphrase.
|
||||
</div>
|
||||
<div id="relicario-qr-svg" style="
|
||||
background:#fff; border-radius:4px; padding:8px;
|
||||
display:inline-block; max-width:280px; width:100%;
|
||||
">
|
||||
${svgContent}
|
||||
</div>
|
||||
<div style="display:flex; gap:8px; margin-top:12px; justify-content:center;">
|
||||
<button id="relicario-qr-print" class="btn btn-primary" style="font-size:12px;">
|
||||
Print
|
||||
</button>
|
||||
<button id="relicario-qr-done" class="btn" style="font-size:12px;">
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
document.getElementById('relicario-qr-done')?.addEventListener('click', removeModal);
|
||||
|
||||
document.getElementById('relicario-qr-print')?.addEventListener('click', () => {
|
||||
const win = window.open('', '_blank', 'width=400,height=500');
|
||||
if (!win) return;
|
||||
win.document.write(`
|
||||
<!DOCTYPE html>
|
||||
<html><head><title>Recovery QR</title>
|
||||
<style>
|
||||
body { margin: 0; display: flex; flex-direction: column; align-items: center;
|
||||
font-family: sans-serif; padding: 24px; }
|
||||
h2 { font-size: 16px; margin-bottom: 8px; }
|
||||
p { font-size: 12px; color: #555; margin-bottom: 16px; text-align: center; }
|
||||
svg { max-width: 280px; width: 100%; }
|
||||
</style></head><body>
|
||||
<h2>Relicario Recovery QR</h2>
|
||||
<p>Scan with the Relicario app to recover your reference image secret.<br>
|
||||
Keep this page in a safe physical location.</p>
|
||||
${svgContent}
|
||||
<script>window.onload = () => { window.print(); window.close(); }<\/script>
|
||||
</body></html>
|
||||
`);
|
||||
win.document.close();
|
||||
});
|
||||
|
||||
// Close on backdrop click
|
||||
overlay.addEventListener('click', (e) => {
|
||||
if (e.target === overlay) removeModal();
|
||||
});
|
||||
}
|
||||
|
||||
// --- Main render ---
|
||||
|
||||
export async function renderSecuritySection(
|
||||
container: HTMLElement,
|
||||
sessionHandle: number | null,
|
||||
): Promise<void> {
|
||||
// Read timestamp from device-local storage (never the QR payload itself)
|
||||
const stored = await chrome.storage.local.get(['recovery_qr_generated_at']);
|
||||
const generatedAt: number | null = (stored.recovery_qr_generated_at as number) ?? null;
|
||||
|
||||
const isUnlocked = sessionHandle !== null;
|
||||
|
||||
// --- QR status section ---
|
||||
let qrStatusHtml: string;
|
||||
if (generatedAt === null) {
|
||||
qrStatusHtml = `
|
||||
<div style="
|
||||
display:flex; align-items:flex-start; gap:10px;
|
||||
background:#2d1f00; border:1px solid #7c5719; border-radius:6px;
|
||||
padding:10px; margin-bottom:12px;
|
||||
">
|
||||
<span style="font-size:16px;">⚠</span>
|
||||
<div style="flex:1; font-size:12px;">
|
||||
<div style="color:#e3a726; font-weight:600; margin-bottom:2px;">
|
||||
No recovery QR generated
|
||||
</div>
|
||||
<div style="color:#8b949e;">
|
||||
If you lose access to your reference image, you will be locked out permanently.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
id="sec-generate-qr"
|
||||
${isUnlocked ? '' : 'disabled title="Unlock the vault first"'}
|
||||
style="width:100%; font-size:12px; margin-bottom:4px;"
|
||||
>
|
||||
Generate recovery QR…
|
||||
</button>
|
||||
`;
|
||||
} else {
|
||||
qrStatusHtml = `
|
||||
<div style="
|
||||
display:flex; align-items:flex-start; gap:10px;
|
||||
background:#0a2a1a; border:1px solid #238636; border-radius:6px;
|
||||
padding:10px; margin-bottom:12px;
|
||||
">
|
||||
<span style="font-size:16px;">✓</span>
|
||||
<div style="flex:1; font-size:12px;">
|
||||
<div style="color:#3fb950; font-weight:600; margin-bottom:2px;">
|
||||
Recovery QR set up
|
||||
</div>
|
||||
<div style="color:#8b949e;">
|
||||
Generated ${relativeTime(generatedAt)}. Store the printout in a safe place.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex; gap:8px; margin-bottom:4px;">
|
||||
<button
|
||||
class="btn"
|
||||
id="sec-show-qr"
|
||||
${isUnlocked ? '' : 'disabled title="Unlock the vault first"'}
|
||||
style="flex:1; font-size:12px;"
|
||||
>
|
||||
Show / print QR…
|
||||
</button>
|
||||
<button
|
||||
class="btn"
|
||||
id="sec-regenerate-qr"
|
||||
${isUnlocked ? '' : 'disabled title="Unlock the vault first"'}
|
||||
style="flex:1; font-size:12px;"
|
||||
>
|
||||
Regenerate…
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// --- Devices section ---
|
||||
const devicesResp = await sendMessage({ type: 'list_devices' });
|
||||
let devicesHtml: string;
|
||||
if (!devicesResp.ok) {
|
||||
devicesHtml = `<p class="muted" style="font-size:12px;">Could not load devices.</p>`;
|
||||
} else {
|
||||
const devices = (devicesResp.data as { devices: Device[] }).devices;
|
||||
const currentDeviceNameStored = await chrome.storage.local.get(['device_name']);
|
||||
const currentDeviceName: string | undefined = currentDeviceNameStored.device_name as string | undefined;
|
||||
|
||||
if (devices.length === 0) {
|
||||
devicesHtml = `<p class="muted" style="font-size:12px; text-align:center; margin-top:8px;">No devices registered.</p>`;
|
||||
} else {
|
||||
devicesHtml = devices.map((d) => {
|
||||
const isCurrent = d.name === currentDeviceName;
|
||||
return `
|
||||
<div class="device-row" style="display:flex; align-items:center; justify-content:space-between; padding:6px 0; border-bottom:1px solid #21262d;">
|
||||
<div style="flex:1; min-width:0;">
|
||||
<div style="font-size:12px; font-weight:500; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">
|
||||
${escapeHtml(d.name)}${isCurrent ? ' <span style="color:#8b949e; font-weight:400; font-size:11px;">(this device)</span>' : ''}
|
||||
</div>
|
||||
<div style="font-size:11px; color:#8b949e;">added ${relativeTime(d.added_at)}</div>
|
||||
</div>
|
||||
${isCurrent ? '' : `
|
||||
<button
|
||||
class="btn sec-revoke-btn"
|
||||
data-device-name="${escapeHtml(d.name)}"
|
||||
style="font-size:11px; margin-left:8px; flex-shrink:0;"
|
||||
>revoke</button>
|
||||
`}
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
}
|
||||
|
||||
// --- Assemble ---
|
||||
container.innerHTML = `
|
||||
<div class="settings-section" style="margin-top:0;">
|
||||
<div class="settings-section__title" style="font-size:12px; color:#8b949e; margin-bottom:8px; text-transform:uppercase; letter-spacing:0.05em;">
|
||||
Recovery QR
|
||||
</div>
|
||||
${qrStatusHtml}
|
||||
<div id="sec-qr-error" style="font-size:11px; color:#f85149; margin-top:4px; min-height:14px;"></div>
|
||||
</div>
|
||||
|
||||
<div class="settings-section" style="margin-top:16px;">
|
||||
<div class="settings-section__title" style="font-size:12px; color:#8b949e; margin-bottom:8px; text-transform:uppercase; letter-spacing:0.05em;">
|
||||
Trusted Devices
|
||||
</div>
|
||||
<div id="sec-devices-list">
|
||||
${devicesHtml}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// --- Wire handlers ---
|
||||
|
||||
const setQrError = (msg: string): void => {
|
||||
const el = document.getElementById('sec-qr-error');
|
||||
if (el) el.textContent = msg;
|
||||
};
|
||||
|
||||
async function doGenerateQr(isRegen: boolean): Promise<void> {
|
||||
const passphrase = prompt(
|
||||
isRegen
|
||||
? 'Enter your vault passphrase to regenerate the recovery QR:'
|
||||
: 'Enter your vault passphrase to generate the recovery QR:',
|
||||
);
|
||||
if (!passphrase) return;
|
||||
|
||||
const btn = document.getElementById(isRegen ? 'sec-regenerate-qr' : 'sec-generate-qr') as HTMLButtonElement | null;
|
||||
if (btn) { btn.disabled = true; btn.textContent = '…'; }
|
||||
|
||||
const resp = await sendMessage({ type: 'generate_recovery_qr', passphrase });
|
||||
if (!resp.ok) {
|
||||
setQrError(`Failed: ${resp.error}`);
|
||||
if (btn) { btn.disabled = false; btn.textContent = isRegen ? 'Regenerate…' : 'Generate recovery QR…'; }
|
||||
return;
|
||||
}
|
||||
|
||||
const svg = (resp.data as { svg: string }).svg;
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
// Store only the timestamp, NEVER the QR payload
|
||||
await chrome.storage.local.set({ recovery_qr_generated_at: now });
|
||||
|
||||
showQrModal(svg);
|
||||
|
||||
// Re-render to reflect new state (timestamp now exists)
|
||||
await renderSecuritySection(container, sessionHandle);
|
||||
}
|
||||
|
||||
document.getElementById('sec-generate-qr')?.addEventListener('click', () => {
|
||||
void doGenerateQr(false);
|
||||
});
|
||||
|
||||
document.getElementById('sec-regenerate-qr')?.addEventListener('click', () => {
|
||||
void doGenerateQr(true);
|
||||
});
|
||||
|
||||
document.getElementById('sec-show-qr')?.addEventListener('click', async () => {
|
||||
const passphrase = prompt('Enter your vault passphrase to view the recovery QR:');
|
||||
if (!passphrase) return;
|
||||
|
||||
const btn = document.getElementById('sec-show-qr') as HTMLButtonElement | null;
|
||||
if (btn) { btn.disabled = true; btn.textContent = '…'; }
|
||||
|
||||
const resp = await sendMessage({ type: 'generate_recovery_qr', passphrase });
|
||||
if (!resp.ok) {
|
||||
setQrError(`Failed: ${resp.error}`);
|
||||
if (btn) { btn.disabled = false; btn.textContent = 'Show / print QR…'; }
|
||||
return;
|
||||
}
|
||||
|
||||
if (btn) { btn.disabled = false; btn.textContent = 'Show / print QR…'; }
|
||||
const svg = (resp.data as { svg: string }).svg;
|
||||
showQrModal(svg);
|
||||
});
|
||||
|
||||
// Revoke buttons
|
||||
container.querySelectorAll<HTMLButtonElement>('.sec-revoke-btn').forEach((btn) => {
|
||||
btn.addEventListener('click', async () => {
|
||||
const name = btn.dataset.deviceName;
|
||||
if (!name) return;
|
||||
if (!confirm(`Revoke "${name}"? This device will no longer be authorized.`)) return;
|
||||
|
||||
btn.disabled = true;
|
||||
btn.textContent = '…';
|
||||
|
||||
const result = await sendMessage({ type: 'revoke_device', name });
|
||||
if (result.ok) {
|
||||
await sendMessage({ type: 'sync' });
|
||||
// Re-render to refresh device list
|
||||
await renderSecuritySection(container, sessionHandle);
|
||||
} else {
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'revoke';
|
||||
setQrError(`Revoke failed: ${result.error}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function teardownSecuritySection(): void {
|
||||
removeModal();
|
||||
}
|
||||
@@ -1,18 +1,111 @@
|
||||
/// Settings view — capture toggle, prompt style, and blacklist management.
|
||||
|
||||
import { sendMessage, navigate, escapeHtml } from '../../shared/state';
|
||||
import type { DeviceSettings } from '../../shared/types';
|
||||
import { GLYPH_TRASH, GLYPH_DEVICES } from '../../shared/glyphs';
|
||||
import { sendMessage, escapeHtml, openVaultTab } from '../../shared/state';
|
||||
import type { VaultSettings, DeviceSettings, TrashRetention, HistoryRetention } from '../../shared/types';
|
||||
import type { ColorScheme } from '../../shared/color-scheme';
|
||||
import {
|
||||
loadColorScheme, saveColorScheme, resetColorScheme,
|
||||
DEFAULT_DIGIT_COLOR, DEFAULT_SYMBOL_COLOR,
|
||||
} from '../../shared/color-scheme';
|
||||
import { colorizePassword } from '../../shared/password-coloring';
|
||||
import { openGeneratorPanel, closeGeneratorPanel, isGeneratorPanelOpen } from './generator-panel';
|
||||
import { renderSecuritySection, teardownSecuritySection } from './settings-security';
|
||||
|
||||
export async function renderSettings(app: HTMLElement): Promise<void> {
|
||||
app.innerHTML = '<div class="pad" style="text-align:center; padding-top:20px;"><span class="spinner"></span></div>';
|
||||
type SettingsSection =
|
||||
| 'autofill'
|
||||
| 'display'
|
||||
| 'security'
|
||||
| 'generator'
|
||||
| 'retention'
|
||||
| 'backup'
|
||||
| 'import';
|
||||
|
||||
// Load settings and blacklist in parallel
|
||||
const NAV_ITEMS: Array<{ id: SettingsSection; icon: string; label: string; group: 'device' | 'vault' }> = [
|
||||
{ id: 'autofill', icon: '⊙', label: 'Autofill', group: 'device' },
|
||||
{ id: 'display', icon: '◈', label: 'Display', group: 'device' },
|
||||
{ id: 'security', icon: '◉', label: 'Security', group: 'vault' },
|
||||
{ id: 'generator', icon: '↻', label: 'Generator', group: 'vault' },
|
||||
{ id: 'retention', icon: '▦', label: 'Retention', group: 'vault' },
|
||||
{ id: 'backup', icon: '⤓', label: 'Backup', group: 'vault' },
|
||||
{ id: 'import', icon: '≡', label: 'Import', group: 'vault' },
|
||||
];
|
||||
|
||||
let activeSection: SettingsSection = 'autofill';
|
||||
let activeKeyHandler: ((e: KeyboardEvent) => void) | null = null;
|
||||
let pendingVaultSettings: VaultSettings | null = null;
|
||||
let sessionHandle: number | null = null;
|
||||
|
||||
export async function renderSettings(container: HTMLElement): Promise<void> {
|
||||
container.innerHTML = `
|
||||
<div class="settings-layout">
|
||||
<nav class="settings-nav" id="settings-nav">
|
||||
<div class="settings-nav__group-label">Device</div>
|
||||
${NAV_ITEMS.filter(n => n.group === 'device').map(navItemHtml).join('')}
|
||||
<div class="settings-nav__group-label">Vault</div>
|
||||
${NAV_ITEMS.filter(n => n.group === 'vault').map(navItemHtml).join('')}
|
||||
</nav>
|
||||
<div class="settings-content" id="settings-content"></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const unlockedResp = await sendMessage({ type: 'is_unlocked' });
|
||||
sessionHandle = (unlockedResp.ok && unlockedResp.data && (unlockedResp.data as { unlocked: boolean }).unlocked) ? 1 : null;
|
||||
|
||||
wireNav();
|
||||
await renderSection(activeSection);
|
||||
}
|
||||
|
||||
export function teardownSettings(): void {
|
||||
closeGeneratorPanel();
|
||||
teardownSecuritySection();
|
||||
if (activeKeyHandler) {
|
||||
document.removeEventListener('keydown', activeKeyHandler);
|
||||
activeKeyHandler = null;
|
||||
}
|
||||
pendingVaultSettings = null;
|
||||
sessionHandle = null;
|
||||
}
|
||||
|
||||
function navItemHtml(item: (typeof NAV_ITEMS)[0]): string {
|
||||
const active = item.id === activeSection ? ' settings-nav__item--active' : '';
|
||||
return `
|
||||
<button class="settings-nav__item${active}" data-section="${item.id}">
|
||||
<span class="settings-nav__icon" aria-hidden="true">${item.icon}</span>
|
||||
<span class="settings-nav__label">${escapeHtml(item.label)}</span>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
function wireNav(): void {
|
||||
document.getElementById('settings-nav')?.querySelectorAll<HTMLButtonElement>('[data-section]')
|
||||
.forEach((btn) => {
|
||||
btn.addEventListener('click', async () => {
|
||||
teardownSecuritySection();
|
||||
closeGeneratorPanel();
|
||||
activeSection = btn.dataset.section as SettingsSection;
|
||||
document.querySelectorAll('.settings-nav__item').forEach(b => b.classList.remove('settings-nav__item--active'));
|
||||
btn.classList.add('settings-nav__item--active');
|
||||
await renderSection(activeSection);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function renderSection(section: SettingsSection): Promise<void> {
|
||||
const content = document.getElementById('settings-content');
|
||||
if (!content) return;
|
||||
|
||||
switch (section) {
|
||||
case 'autofill': return renderAutofillSection(content);
|
||||
case 'display': return renderDisplaySection(content);
|
||||
case 'security': return renderSecuritySection(content, sessionHandle);
|
||||
case 'generator': return renderGeneratorSection(content);
|
||||
case 'retention': return renderRetentionSection(content);
|
||||
case 'backup': return renderBackupSection(content);
|
||||
case 'import': return renderImportSection(content);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Section stubs (filled in by Tasks 3-9) ---
|
||||
|
||||
async function renderAutofillSection(content: HTMLElement): Promise<void> {
|
||||
const [settingsResp, blacklistResp] = await Promise.all([
|
||||
sendMessage({ type: 'get_settings' }),
|
||||
sendMessage({ type: 'get_blacklist' }),
|
||||
@@ -26,166 +119,314 @@ export async function renderSettings(app: HTMLElement): Promise<void> {
|
||||
? (blacklistResp.data as { blacklist: string[] }).blacklist
|
||||
: [];
|
||||
|
||||
const blacklistHtml = blacklist.length > 0
|
||||
? blacklist.map((h) => `
|
||||
<div style="display:flex; align-items:center; justify-content:space-between; padding:4px 0; border-bottom:1px solid #21262d;">
|
||||
<span style="font-size:12px; overflow:hidden; text-overflow:ellipsis;">${escapeHtml(h)}</span>
|
||||
<button class="relicario-remove-bl" data-hostname="${escapeHtml(h)}" style="
|
||||
background:transparent; color:#ab2b20; border:none; cursor:pointer;
|
||||
font-size:11px; padding:2px 6px;
|
||||
">remove</button>
|
||||
</div>
|
||||
`).join('')
|
||||
: '<p class="muted" style="font-size:12px;">no blacklisted sites</p>';
|
||||
|
||||
app.innerHTML = `
|
||||
<div class="pad" style="padding-top:12px;">
|
||||
<div style="display:flex; align-items:center; margin-bottom:16px;">
|
||||
<button id="settings-back" class="btn" style="font-size:11px; margin-right:8px;">←</button>
|
||||
<span style="font-size:14px; font-weight:600;">settings</span>
|
||||
content.innerHTML = `
|
||||
<h3 class="settings-section-title">Capture</h3>
|
||||
<div class="setting-row">
|
||||
<div class="setting-row__info">
|
||||
<div class="setting-row__title">Auto-detect logins</div>
|
||||
<div class="setting-row__desc">Show a prompt when a login form is detected.</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom:16px;">
|
||||
<label style="display:flex; align-items:center; gap:8px; cursor:pointer; font-size:13px;">
|
||||
<input type="checkbox" id="capture-enabled" ${settings.captureEnabled ? 'checked' : ''}>
|
||||
auto-detect logins
|
||||
</label>
|
||||
<div class="setting-row__control">
|
||||
<input type="checkbox" id="capture-enabled" ${settings.captureEnabled ? 'checked' : ''}>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom:16px;">
|
||||
<div style="font-size:12px; color:#8b949e; margin-bottom:6px;">prompt style</div>
|
||||
<div style="display:flex; gap:8px;">
|
||||
<button id="style-bar" class="btn" style="font-size:11px; ${settings.captureStyle === 'bar' ? 'background:#7c5719; color:#fff;' : ''}">bar</button>
|
||||
<button id="style-toast" class="btn" style="font-size:11px; ${settings.captureStyle === 'toast' ? 'background:#7c5719; color:#fff;' : ''}">toast</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<div class="setting-row__info">
|
||||
<div class="setting-row__title">Prompt style</div>
|
||||
<div class="setting-row__desc">How to prompt when a login is detected.</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom:16px;">
|
||||
<button class="btn" id="trash-btn" style="width:100%;margin-bottom:8px;">${GLYPH_TRASH} trash</button>
|
||||
<button class="btn" id="devices-btn" style="width:100%;margin-bottom:8px;">${GLYPH_DEVICES} devices</button>
|
||||
<button class="btn" id="sync-now-btn" style="width:100%;margin-bottom:8px;">📤 Sync now</button>
|
||||
<div id="sync-status" class="muted" style="font-size:12px;min-height:16px;"></div>
|
||||
<div class="setting-row__control" style="display:flex; gap:6px;">
|
||||
<button class="btn ${settings.captureStyle === 'bar' ? 'btn-active' : ''}" id="style-bar" style="font-size:11px;">bar</button>
|
||||
<button class="btn ${settings.captureStyle === 'toast' ? 'btn-active' : ''}" id="style-toast" style="font-size:11px;">toast</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom:16px;" id="display-section-container">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div style="font-size:12px; color:#8b949e; margin-bottom:6px;">blacklisted sites</div>
|
||||
<div id="blacklist-container">
|
||||
${blacklistHtml}
|
||||
</div>
|
||||
</div>
|
||||
<h3 class="settings-section-title" style="margin-top:20px;">Blocked sites</h3>
|
||||
<div id="blacklist-container">
|
||||
${blacklist.length > 0
|
||||
? blacklist.map((h) => `
|
||||
<div class="setting-row">
|
||||
<div class="setting-row__info">
|
||||
<div class="setting-row__title">${escapeHtml(h)}</div>
|
||||
</div>
|
||||
<button class="btn remove-bl" data-hostname="${escapeHtml(h)}" style="font-size:11px;">remove</button>
|
||||
</div>
|
||||
`).join('')
|
||||
: '<p class="muted" style="font-size:12px; padding:8px 0;">No blocked sites.</p>'}
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Back button
|
||||
document.getElementById('settings-back')?.addEventListener('click', () => {
|
||||
navigate('locked');
|
||||
});
|
||||
|
||||
// Navigation buttons
|
||||
document.getElementById('trash-btn')?.addEventListener('click', () => navigate('trash'));
|
||||
document.getElementById('devices-btn')?.addEventListener('click', () => navigate('devices'));
|
||||
|
||||
// Sync now button
|
||||
document.getElementById('sync-now-btn')?.addEventListener('click', async () => {
|
||||
const btn = document.getElementById('sync-now-btn') as HTMLButtonElement | null;
|
||||
const status = document.getElementById('sync-status');
|
||||
if (!btn || !status) return;
|
||||
btn.disabled = true;
|
||||
status.textContent = 'syncing...';
|
||||
const result = await sendMessage({ type: 'sync' });
|
||||
btn.disabled = false;
|
||||
status.textContent = result.ok ? 'synced ✓' : `sync failed: ${result.error}`;
|
||||
});
|
||||
|
||||
// Capture enabled toggle
|
||||
document.getElementById('capture-enabled')?.addEventListener('change', async (e) => {
|
||||
const checked = (e.target as HTMLInputElement).checked;
|
||||
await sendMessage({ type: 'update_settings', settings: { captureEnabled: checked } });
|
||||
const enabled = (e.target as HTMLInputElement).checked;
|
||||
await sendMessage({ type: 'update_settings', settings: { captureEnabled: enabled } });
|
||||
});
|
||||
|
||||
// Style buttons
|
||||
document.getElementById('style-bar')?.addEventListener('click', async () => {
|
||||
await sendMessage({ type: 'update_settings', settings: { captureStyle: 'bar' } });
|
||||
renderSettings(app);
|
||||
renderAutofillSection(content);
|
||||
});
|
||||
|
||||
document.getElementById('style-toast')?.addEventListener('click', async () => {
|
||||
await sendMessage({ type: 'update_settings', settings: { captureStyle: 'toast' } });
|
||||
renderSettings(app);
|
||||
renderAutofillSection(content);
|
||||
});
|
||||
|
||||
// Blacklist remove buttons
|
||||
document.querySelectorAll('.relicario-remove-bl').forEach((btn) => {
|
||||
content.querySelectorAll<HTMLButtonElement>('.remove-bl').forEach((btn) => {
|
||||
btn.addEventListener('click', async () => {
|
||||
const hostname = (btn as HTMLElement).dataset.hostname;
|
||||
if (hostname) {
|
||||
await sendMessage({ type: 'remove_blacklist', hostname });
|
||||
renderSettings(app);
|
||||
}
|
||||
const host = btn.dataset.hostname;
|
||||
if (!host) return;
|
||||
await sendMessage({ type: 'remove_blacklist', hostname: host });
|
||||
renderAutofillSection(content);
|
||||
});
|
||||
});
|
||||
|
||||
// Render Display section after the rest of the DOM is ready
|
||||
await renderDisplaySection();
|
||||
}
|
||||
|
||||
function updateSwatch(swatch: HTMLElement, digitColor: string, symbolColor: string): void {
|
||||
swatch.style.setProperty('--relicario-pwd-digit-color', digitColor);
|
||||
swatch.style.setProperty('--relicario-pwd-symbol-color', symbolColor);
|
||||
swatch.innerHTML = '';
|
||||
swatch.appendChild(colorizePassword('Abc123!@#xyz'));
|
||||
}
|
||||
|
||||
async function renderDisplaySection(): Promise<void> {
|
||||
// The Display section container must be present in the DOM before we call this
|
||||
const container = document.getElementById('display-section-container');
|
||||
if (!container) return;
|
||||
|
||||
async function renderDisplaySection(content: HTMLElement): Promise<void> {
|
||||
const scheme = await loadColorScheme();
|
||||
|
||||
container.innerHTML = `
|
||||
<div style="font-size:12px; color:#8b949e; margin-bottom:6px;">display</div>
|
||||
<div style="margin-bottom:8px;">
|
||||
<label style="display:flex; align-items:center; gap:8px; font-size:13px;">
|
||||
<input type="color" id="display-digit-color" value="${escapeHtml(scheme.digit_color)}">
|
||||
digit color
|
||||
</label>
|
||||
content.innerHTML = `
|
||||
<h3 class="settings-section-title">Password coloring</h3>
|
||||
<div class="setting-row">
|
||||
<div class="setting-row__info">
|
||||
<div class="setting-row__title">Digit color</div>
|
||||
</div>
|
||||
<div class="setting-row__control">
|
||||
<input type="color" id="digit-color" value="${escapeHtml(scheme.digit_color)}">
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-bottom:8px;">
|
||||
<label style="display:flex; align-items:center; gap:8px; font-size:13px;">
|
||||
<input type="color" id="display-symbol-color" value="${escapeHtml(scheme.symbol_color)}">
|
||||
symbol color
|
||||
</label>
|
||||
<div class="setting-row">
|
||||
<div class="setting-row__info">
|
||||
<div class="setting-row__title">Symbol color</div>
|
||||
</div>
|
||||
<div class="setting-row__control">
|
||||
<input type="color" id="symbol-color" value="${escapeHtml(scheme.symbol_color)}">
|
||||
</div>
|
||||
</div>
|
||||
<div id="display-swatch" class="color-preview-swatch"></div>
|
||||
<div style="margin-top:8px;">
|
||||
<button id="display-reset" class="btn" style="font-size:11px;">reset to defaults</button>
|
||||
<div class="setting-row">
|
||||
<div class="setting-row__info">
|
||||
<div class="setting-row__title">Preview</div>
|
||||
</div>
|
||||
<div class="setting-row__control">
|
||||
<span id="color-preview" style="font-family:monospace; font-size:13px;"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-top:12px;">
|
||||
<button class="btn" id="reset-colors" style="font-size:11px;">Reset defaults</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const digitInput = document.getElementById('display-digit-color') as HTMLInputElement;
|
||||
const symbolInput = document.getElementById('display-symbol-color') as HTMLInputElement;
|
||||
const swatch = document.getElementById('display-swatch') as HTMLElement;
|
||||
|
||||
// Render initial swatch
|
||||
updateSwatch(swatch, scheme.digit_color, scheme.symbol_color);
|
||||
|
||||
async function onColorChange(): Promise<void> {
|
||||
const newScheme = { digit_color: digitInput.value, symbol_color: symbolInput.value };
|
||||
await saveColorScheme(newScheme);
|
||||
updateSwatch(swatch, newScheme.digit_color, newScheme.symbol_color);
|
||||
function refreshPreview(s: ColorScheme): void {
|
||||
const preview = document.getElementById('color-preview');
|
||||
if (!preview) return;
|
||||
preview.style.setProperty('--relicario-pwd-digit-color', s.digit_color);
|
||||
preview.style.setProperty('--relicario-pwd-symbol-color', s.symbol_color);
|
||||
preview.innerHTML = '';
|
||||
preview.appendChild(colorizePassword('Abc123!@#'));
|
||||
}
|
||||
|
||||
digitInput.addEventListener('change', () => void onColorChange());
|
||||
symbolInput.addEventListener('change', () => void onColorChange());
|
||||
refreshPreview(scheme);
|
||||
|
||||
document.getElementById('display-reset')?.addEventListener('click', async () => {
|
||||
document.getElementById('digit-color')?.addEventListener('change', async (e) => {
|
||||
const color = (e.target as HTMLInputElement).value;
|
||||
const current = await loadColorScheme();
|
||||
await saveColorScheme({ ...current, digit_color: color });
|
||||
refreshPreview({ ...current, digit_color: color });
|
||||
});
|
||||
|
||||
document.getElementById('symbol-color')?.addEventListener('change', async (e) => {
|
||||
const color = (e.target as HTMLInputElement).value;
|
||||
const current = await loadColorScheme();
|
||||
await saveColorScheme({ ...current, symbol_color: color });
|
||||
refreshPreview({ ...current, symbol_color: color });
|
||||
});
|
||||
|
||||
document.getElementById('reset-colors')?.addEventListener('click', async () => {
|
||||
await resetColorScheme();
|
||||
digitInput.value = DEFAULT_DIGIT_COLOR;
|
||||
symbolInput.value = DEFAULT_SYMBOL_COLOR;
|
||||
updateSwatch(swatch, DEFAULT_DIGIT_COLOR, DEFAULT_SYMBOL_COLOR);
|
||||
renderDisplaySection(content);
|
||||
});
|
||||
}
|
||||
|
||||
async function renderGeneratorSection(content: HTMLElement): Promise<void> {
|
||||
content.innerHTML = '<p class="muted" style="padding:20px;font-size:12px;">Loading…</p>';
|
||||
const resp = await sendMessage({ type: 'get_vault_settings' });
|
||||
if (!resp.ok) {
|
||||
const errorMsg = (resp as { ok: false; error: string }).error;
|
||||
content.innerHTML = `<p class="muted" style="padding:20px;">Failed to load: ${escapeHtml(errorMsg)}</p>`;
|
||||
return;
|
||||
}
|
||||
const settings = (resp.data as { settings: VaultSettings }).settings;
|
||||
|
||||
content.innerHTML = `
|
||||
<h3 class="settings-section-title">Generator defaults</h3>
|
||||
<div class="setting-row">
|
||||
<div class="setting-row__info">
|
||||
<div class="setting-row__title">Configure generator</div>
|
||||
<div class="setting-row__desc">Password length, character classes, passphrase word count.</div>
|
||||
</div>
|
||||
<div class="setting-row__control">
|
||||
<button class="btn" id="open-generator-panel" style="font-size:11px;">Configure ▸</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.getElementById('open-generator-panel')?.addEventListener('click', (e) => {
|
||||
const trigger = e.currentTarget as HTMLElement;
|
||||
if (isGeneratorPanelOpen()) {
|
||||
closeGeneratorPanel();
|
||||
return;
|
||||
}
|
||||
openGeneratorPanel({
|
||||
parent: content,
|
||||
trigger,
|
||||
initial: settings.generator_defaults,
|
||||
context: 'configure-defaults',
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function renderRetentionSection(content: HTMLElement): Promise<void> {
|
||||
content.innerHTML = '<p class="muted" style="padding:20px;font-size:12px;">Loading…</p>';
|
||||
const resp = await sendMessage({ type: 'get_vault_settings' });
|
||||
if (!resp.ok) {
|
||||
content.innerHTML = `<p class="muted" style="padding:20px;">Failed to load: ${escapeHtml(resp.error ?? 'unknown')}</p>`;
|
||||
return;
|
||||
}
|
||||
const settings = (resp.data as { settings: VaultSettings }).settings;
|
||||
pendingVaultSettings = { ...settings };
|
||||
|
||||
content.innerHTML = `
|
||||
<h3 class="settings-section-title">Trash retention</h3>
|
||||
<div class="setting-row">
|
||||
<div class="setting-row__info">
|
||||
<div class="setting-row__title">Keep deleted items for</div>
|
||||
<div class="setting-row__desc">Items in trash older than this are permanently deleted on the next sync.</div>
|
||||
</div>
|
||||
<div class="setting-row__control">
|
||||
<select id="trash-retention" style="font-size:12px;">
|
||||
<option value="days:7">7 days</option>
|
||||
<option value="days:30">30 days</option>
|
||||
<option value="days:90">90 days</option>
|
||||
<option value="forever">Forever</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 class="settings-section-title" style="margin-top:20px;">Field history retention</h3>
|
||||
<div class="setting-row">
|
||||
<div class="setting-row__info">
|
||||
<div class="setting-row__title">Keep password history for</div>
|
||||
<div class="setting-row__desc">History entries older than this are pruned on save.</div>
|
||||
</div>
|
||||
<div class="setting-row__control">
|
||||
<select id="history-retention" style="font-size:12px;">
|
||||
<option value="last_n:5">Last 5</option>
|
||||
<option value="last_n:10">Last 10</option>
|
||||
<option value="days:90">90 days</option>
|
||||
<option value="days:365">1 year</option>
|
||||
<option value="forever">Forever</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-top:12px;">
|
||||
<button class="btn btn-primary" id="save-retention" style="font-size:11px;">Save retention settings</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Set current select values
|
||||
(document.getElementById('trash-retention') as HTMLSelectElement).value =
|
||||
trashRetentionToValue(settings.trash_retention);
|
||||
(document.getElementById('history-retention') as HTMLSelectElement).value =
|
||||
historyRetentionToValue(settings.field_history_retention);
|
||||
|
||||
document.getElementById('trash-retention')?.addEventListener('change', (e) => {
|
||||
if (pendingVaultSettings) {
|
||||
pendingVaultSettings.trash_retention = valueToTrashRetention((e.target as HTMLSelectElement).value);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('history-retention')?.addEventListener('change', (e) => {
|
||||
if (pendingVaultSettings) {
|
||||
pendingVaultSettings.field_history_retention = valueToHistoryRetention((e.target as HTMLSelectElement).value);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('save-retention')?.addEventListener('click', async () => {
|
||||
if (!pendingVaultSettings) return;
|
||||
const r = await sendMessage({ type: 'update_vault_settings', settings: pendingVaultSettings });
|
||||
if (!r.ok) alert(`Save failed: ${r.error}`);
|
||||
});
|
||||
}
|
||||
|
||||
function trashRetentionToValue(r: TrashRetention): string {
|
||||
if (r.kind === 'forever') return 'forever';
|
||||
return `days:${r.value}`;
|
||||
}
|
||||
|
||||
function valueToTrashRetention(v: string): TrashRetention {
|
||||
if (v === 'forever') return { kind: 'forever' };
|
||||
const m = /^days:(\d+)$/.exec(v);
|
||||
if (m) return { kind: 'days', value: Number(m[1]) };
|
||||
return { kind: 'forever' };
|
||||
}
|
||||
|
||||
function historyRetentionToValue(r: HistoryRetention): string {
|
||||
if (r.kind === 'forever') return 'forever';
|
||||
if (r.kind === 'last_n') return `last_n:${r.value}`;
|
||||
return `days:${r.value}`;
|
||||
}
|
||||
|
||||
function valueToHistoryRetention(v: string): HistoryRetention {
|
||||
if (v === 'forever') return { kind: 'forever' };
|
||||
const mLast = /^last_n:(\d+)$/.exec(v);
|
||||
if (mLast) return { kind: 'last_n', value: Number(mLast[1]) };
|
||||
const mDays = /^days:(\d+)$/.exec(v);
|
||||
if (mDays) return { kind: 'days', value: Number(mDays[1]) };
|
||||
return { kind: 'forever' };
|
||||
}
|
||||
|
||||
function renderBackupSection(content: HTMLElement): void {
|
||||
content.innerHTML = `
|
||||
<h3 class="settings-section-title">Backup & restore</h3>
|
||||
<div class="setting-row">
|
||||
<div class="setting-row__info">
|
||||
<div class="setting-row__title">Export & restore backup</div>
|
||||
<div class="setting-row__desc">Download an encrypted backup or restore from a file. Opens in the vault tab.</div>
|
||||
</div>
|
||||
<div class="setting-row__control">
|
||||
<button class="btn" id="open-backup-tab" style="font-size:11px;">Open backup ▸</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.getElementById('open-backup-tab')?.addEventListener('click', () => openVaultTab('backup'));
|
||||
}
|
||||
|
||||
function renderImportSection(content: HTMLElement): void {
|
||||
content.innerHTML = `
|
||||
<h3 class="settings-section-title">Import</h3>
|
||||
<div class="setting-row">
|
||||
<div class="setting-row__info">
|
||||
<div class="setting-row__title">Import from LastPass</div>
|
||||
<div class="setting-row__desc">Import a LastPass CSV export. Opens in the vault tab for review before committing.</div>
|
||||
</div>
|
||||
<div class="setting-row__control">
|
||||
<button class="btn" id="open-import-tab" style="font-size:11px;">Open import ▸</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.getElementById('open-import-tab')?.addEventListener('click', () => openVaultTab('import'));
|
||||
}
|
||||
|
||||
export { renderAutofillSection, renderDisplaySection, renderGeneratorSection,
|
||||
renderRetentionSection, renderBackupSection, renderImportSection };
|
||||
|
||||
// Suppress unused-import warnings — these are used by Tasks 3-9
|
||||
void sendMessage;
|
||||
void loadColorScheme;
|
||||
void saveColorScheme;
|
||||
void resetColorScheme;
|
||||
void DEFAULT_DIGIT_COLOR;
|
||||
void DEFAULT_SYMBOL_COLOR;
|
||||
void colorizePassword;
|
||||
void openGeneratorPanel;
|
||||
void pendingVaultSettings;
|
||||
void activeKeyHandler;
|
||||
|
||||
@@ -2,10 +2,19 @@
|
||||
|
||||
import { getState, setState, sendMessage, navigate, escapeHtml } from '../../shared/state';
|
||||
import type { ItemId, ManifestEntry, VaultSettings } from '../../shared/types';
|
||||
import {
|
||||
GLYPH_TYPE_LOGIN, GLYPH_TYPE_SECURE_NOTE, GLYPH_TYPE_IDENTITY, GLYPH_TYPE_CARD,
|
||||
GLYPH_TYPE_KEY, GLYPH_TYPE_DOCUMENT, GLYPH_TYPE_TOTP,
|
||||
} from '../../shared/glyphs';
|
||||
|
||||
const TYPE_ICONS: Record<string, string> = {
|
||||
login: '🔑', secure_note: '📝', identity: '👤', card: '💳',
|
||||
key: '🔐', document: '📄', totp: '⏱️',
|
||||
login: GLYPH_TYPE_LOGIN,
|
||||
secure_note: GLYPH_TYPE_SECURE_NOTE,
|
||||
identity: GLYPH_TYPE_IDENTITY,
|
||||
card: GLYPH_TYPE_CARD,
|
||||
key: GLYPH_TYPE_KEY,
|
||||
document: GLYPH_TYPE_DOCUMENT,
|
||||
totp: GLYPH_TYPE_TOTP,
|
||||
};
|
||||
|
||||
function relativeTime(unixSec: number): string {
|
||||
@@ -64,7 +73,7 @@ export async function renderTrash(app: HTMLElement): Promise<void> {
|
||||
? `<p class="muted" style="text-align:center;margin-top:32px;">Trash is empty</p>`
|
||||
: items.map(([id, entry]) => `
|
||||
<div class="trash-row" data-id="${escapeHtml(id)}">
|
||||
<span class="trash-row__icon">${TYPE_ICONS[entry.type] ?? '📦'}</span>
|
||||
<span class="trash-row__icon">${TYPE_ICONS[entry.type] ?? '◻'}</span>
|
||||
<div class="trash-row__info">
|
||||
<span class="trash-row__title">${escapeHtml(entry.title)}</span>
|
||||
<span class="trash-row__meta">trashed ${relativeTime(entry.trashed_at ?? 0)}</span>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
import { getState, setState, sendMessage, navigate, escapeHtml, popOutToTab } from '../../../shared/state';
|
||||
import { renderFormHeader } from '../form-header';
|
||||
import { REQUIRED_PILL_HTML } from '../../../shared/glyphs';
|
||||
import { REQUIRED_PILL_HTML, GLYPH_TYPE_DOCUMENT, GLYPH_PREVIEW } from '../../../shared/glyphs';
|
||||
import type { Item, ItemId, ManifestEntry, Section, AttachmentRef } from '../../../shared/types';
|
||||
import {
|
||||
renderSectionsEditor, wireSectionsEditor,
|
||||
@@ -76,7 +76,7 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite
|
||||
}
|
||||
return `
|
||||
<div class="document-primary-row" id="primary-picker">
|
||||
<span class="document-primary-row__thumb">📄</span>
|
||||
<span class="document-primary-row__thumb">${GLYPH_TYPE_DOCUMENT}</span>
|
||||
<span class="document-primary-row__name">${escapeHtml(primaryRef.filename)}</span>
|
||||
<span class="document-primary-row__meta">${formatBytes(primaryRef.size)}</span>
|
||||
<span class="document-primary-row__action">↑ change</span>
|
||||
@@ -283,13 +283,13 @@ export async function renderDetail(app: HTMLElement, item: Item): Promise<void>
|
||||
<div class="detail-title" style="margin-bottom:12px;">${escapeHtml(item.title)}</div>
|
||||
|
||||
<div class="document-signature-block" id="doc-sigblock">
|
||||
<div class="document-signature-block__thumb" data-att-id="${escapeHtml(primaryRef.id)}" data-mime="${escapeHtml(primaryRef.mime_type)}">📄</div>
|
||||
<div class="document-signature-block__thumb" data-att-id="${escapeHtml(primaryRef.id)}" data-mime="${escapeHtml(primaryRef.mime_type)}">${GLYPH_TYPE_DOCUMENT}</div>
|
||||
<div class="document-signature-block__info">
|
||||
<div class="document-signature-block__name">${escapeHtml(primaryRef.filename)}</div>
|
||||
<div class="document-signature-block__meta">${formatBytes(primaryRef.size)} · ${new Date(primaryRef.created * 1000).toISOString().slice(0, 10)}</div>
|
||||
<div class="document-signature-block__actions">
|
||||
<span id="doc-download" style="cursor:pointer;color:#d2ab43;">↓ download</span>
|
||||
${isImageMime ? '<span id="doc-preview" style="cursor:pointer;color:#d2ab43;margin-left:10px;">🔍 preview</span>' : ''}
|
||||
${isImageMime ? `<span id="doc-preview" style="cursor:pointer;color:#d2ab43;margin-left:10px;">${GLYPH_PREVIEW} preview</span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -11,7 +11,7 @@ import { renderUnlock } from './components/unlock';
|
||||
import { renderItemList } from './components/item-list';
|
||||
import { renderItemDetail } from './components/item-detail';
|
||||
import { renderItemForm } from './components/item-form';
|
||||
import { renderSettings } from './components/settings';
|
||||
import { renderSettings, teardownSettings } from './components/settings';
|
||||
import { renderVaultSettings } from './components/settings-vault';
|
||||
import { renderTrash } from './components/trash';
|
||||
import { renderDevices } from './components/devices';
|
||||
@@ -178,6 +178,7 @@ function render(): void {
|
||||
teardownTrash();
|
||||
teardownDevices();
|
||||
teardownFieldHistory();
|
||||
teardownSettings();
|
||||
|
||||
switch (currentState.view) {
|
||||
case 'locked':
|
||||
|
||||
@@ -424,6 +424,41 @@ textarea {
|
||||
background: #aa812a;
|
||||
}
|
||||
|
||||
/* Setup wizard — Style C progress track */
|
||||
.setup-progress-track {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
width: 100%;
|
||||
max-width: 560px;
|
||||
margin: 8px auto 16px;
|
||||
}
|
||||
.setup-progress-segment {
|
||||
flex: 1;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
.setup-progress-segment--completed { background: var(--success, #238636); }
|
||||
.setup-progress-segment--active { background: var(--gold, #b8860b); }
|
||||
.setup-progress-segment--pending { background: var(--border, #30363d); }
|
||||
|
||||
/* Setup wizard — Recovery QR banner */
|
||||
.recovery-qr-banner {
|
||||
padding: 12px 16px;
|
||||
background: var(--bg-elevated, #161b22);
|
||||
border: 1px solid var(--gold, #b8860b);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.recovery-qr-banner__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.recovery-qr-banner__actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* Spinner */
|
||||
.spinner {
|
||||
display: inline-block;
|
||||
@@ -1573,3 +1608,183 @@ textarea {
|
||||
margin-top: 8px;
|
||||
background: var(--bg-input);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-state__icon {
|
||||
font-size: 28px;
|
||||
color: var(--text-muted, #8b949e);
|
||||
margin-bottom: 12px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.empty-state__title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.empty-state__hint {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted, #8b949e);
|
||||
}
|
||||
|
||||
.type-card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.type-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
padding: 10px 12px;
|
||||
background: var(--bg-elevated, #161b22);
|
||||
border: 1px solid var(--border-mid, #30363d);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.type-card:hover { border-color: var(--gold-base, #a88a4a); }
|
||||
|
||||
.type-card__icon { font-size: 20px; margin-bottom: 4px; }
|
||||
.type-card__label { font-size: 12px; font-weight: 600; }
|
||||
|
||||
/* Toast notifications */
|
||||
.relicario-toast-container {
|
||||
position: fixed;
|
||||
bottom: 16px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
pointer-events: none;
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
.vault-shell .relicario-toast-container {
|
||||
left: auto;
|
||||
right: 24px;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.relicario-toast {
|
||||
padding: 8px 16px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
transition: opacity 0.2s, transform 0.2s;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.relicario-toast--visible {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.relicario-toast--success { background: #1f4a24; color: #aff0b5; border: 1px solid #238636; }
|
||||
.relicario-toast--error { background: #4a1f1f; color: #f0afaf; border: 1px solid #ab2b20; }
|
||||
.relicario-toast--info { background: #1f2d4a; color: #afc8f0; border: 1px solid #1f6feb; }
|
||||
.type-card__desc { font-size: 10px; color: var(--text-muted, #8b949e); margin-top: 2px; }
|
||||
|
||||
/* === Settings layout === */
|
||||
.settings-layout {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.settings-nav {
|
||||
width: 148px;
|
||||
min-width: 148px;
|
||||
border-right: 1px solid var(--border, #30363d);
|
||||
padding: 12px 0;
|
||||
overflow-y: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.settings-nav__group-label {
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-muted, #8b949e);
|
||||
padding: 8px 12px 4px;
|
||||
}
|
||||
|
||||
.settings-nav__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 7px 12px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.settings-nav__item:hover { background: var(--bg-hover, #161b22); }
|
||||
.settings-nav__item--active { background: var(--bg-selected, #1c2d41); }
|
||||
|
||||
.settings-nav__icon { font-size: 14px; flex-shrink: 0; }
|
||||
|
||||
.settings-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 20px 24px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.setting-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid var(--border-subtle, #21262d);
|
||||
}
|
||||
|
||||
.setting-row:last-child { border-bottom: none; }
|
||||
|
||||
.setting-row__info { flex: 1; }
|
||||
.setting-row__title { font-size: 13px; font-weight: 500; }
|
||||
.setting-row__desc { font-size: 11px; color: var(--text-muted, #8b949e); margin-top: 2px; }
|
||||
.setting-row__control { flex-shrink: 0; }
|
||||
|
||||
.settings-section-title {
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-muted, #8b949e);
|
||||
margin: 0 0 12px;
|
||||
padding-bottom: 6px;
|
||||
border-bottom: 1px solid var(--border, #30363d);
|
||||
}
|
||||
|
||||
.setting-card {
|
||||
padding: 12px 16px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border, #30363d);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.setting-card--ok { border-color: var(--success, #238636); background: rgba(35, 134, 54, 0.06); }
|
||||
.setting-card--warn { border-color: var(--gold, #b8860b); background: rgba(184, 134, 11, 0.06); }
|
||||
|
||||
.setting-card__status { font-size: 13px; margin-bottom: 8px; }
|
||||
.setting-card__actions { display: flex; gap: 8px; }
|
||||
|
||||
@@ -574,6 +574,26 @@ export async function handle(
|
||||
}
|
||||
}
|
||||
|
||||
case 'generate_recovery_qr': {
|
||||
const handle = session.getCurrent();
|
||||
if (!handle) return { ok: false, error: 'vault_locked' };
|
||||
try {
|
||||
const svg: string = state.wasm.wasm_generate_recovery_qr(handle, msg.passphrase);
|
||||
return { ok: true, data: { svg } };
|
||||
} catch (e) {
|
||||
return { ok: false, error: (e as Error).message };
|
||||
}
|
||||
}
|
||||
|
||||
case 'unwrap_recovery_qr': {
|
||||
try {
|
||||
const imageSecretBytes: Uint8Array = state.wasm.wasm_unwrap_recovery_qr(msg.payload_b64, msg.passphrase);
|
||||
return { ok: true, data: { image_secret: Array.from(imageSecretBytes) } };
|
||||
} catch (e) {
|
||||
return { ok: false, error: (e as Error).message };
|
||||
}
|
||||
}
|
||||
|
||||
case 'import_lastpass_commit': {
|
||||
const handle = session.getCurrent();
|
||||
if (!handle || !state.gitHost || !state.manifest) return { ok: false, error: 'vault_locked' };
|
||||
|
||||
@@ -93,6 +93,17 @@ const state: WizardState = {
|
||||
deviceName: '',
|
||||
};
|
||||
|
||||
// --- Progress track ---
|
||||
|
||||
const SETUP_STEP_NAMES = ['mode', 'host', 'connection', 'vault', 'device', 'done'];
|
||||
|
||||
function renderProgressTrack(current: number): string {
|
||||
return `<div class="setup-progress-track">${SETUP_STEP_NAMES.map((_, i) => {
|
||||
const cls = i < current ? 'completed' : i === current ? 'active' : 'pending';
|
||||
return `<div class="setup-progress-segment setup-progress-segment--${cls}" title="${SETUP_STEP_NAMES[i]}"></div>`;
|
||||
}).join('')}</div>`;
|
||||
}
|
||||
|
||||
// --- State-coupled helpers (pure helpers live in ./setup-helpers.ts) ---
|
||||
|
||||
/// Update just the meter DOM without a full re-render (so the input keeps
|
||||
@@ -168,16 +179,7 @@ function render(): void {
|
||||
const app = document.getElementById('app');
|
||||
if (!app) return;
|
||||
|
||||
const progressHtml = `
|
||||
<div class="progress-bar">
|
||||
<div class="step ${state.step > 0 ? 'done' : state.step === 0 ? 'current' : ''}"></div>
|
||||
<div class="step ${state.step > 1 ? 'done' : state.step === 1 ? 'current' : ''}"></div>
|
||||
<div class="step ${state.step > 2 ? 'done' : state.step === 2 ? 'current' : ''}"></div>
|
||||
<div class="step ${state.step > 3 ? 'done' : state.step === 3 ? 'current' : ''}"></div>
|
||||
<div class="step ${state.step > 4 ? 'done' : state.step === 4 ? 'current' : ''}"></div>
|
||||
<div class="step ${state.step > 5 ? 'done' : state.step === 5 ? 'current' : ''}"></div>
|
||||
</div>
|
||||
`;
|
||||
const progressHtml = renderProgressTrack(state.step);
|
||||
|
||||
let stepHtml = '';
|
||||
switch (state.step) {
|
||||
@@ -224,6 +226,7 @@ function renderStep0(): string {
|
||||
</p>
|
||||
<div class="mode-cards">
|
||||
<button class="mode-card glass ${isNew ? 'active' : ''}" data-mode="new">
|
||||
<span class="mode-card__icon" style="font-size:28px;">◈</span>
|
||||
<div class="mode-card-title">create new vault</div>
|
||||
<p class="mode-card-blurb">
|
||||
I'm setting up Relicario for the first time. This will create a fresh
|
||||
@@ -231,6 +234,7 @@ function renderStep0(): string {
|
||||
</p>
|
||||
</button>
|
||||
<button class="mode-card glass ${isAttach ? 'active' : ''}" data-mode="attach">
|
||||
<span class="mode-card__icon" style="font-size:28px;">⌥</span>
|
||||
<div class="mode-card-title">attach this device</div>
|
||||
<p class="mode-card-blurb">
|
||||
I already have a vault on another device. Connect this browser to it
|
||||
@@ -981,6 +985,22 @@ function renderStep5(): string {
|
||||
const configJson = JSON.stringify(config, null, 2);
|
||||
const isAttach = state.mode === 'attach';
|
||||
|
||||
const qrBannerHtml = (!isAttach && state.verifiedHandle !== null) ? `
|
||||
<div class="recovery-qr-banner" id="recovery-qr-banner" style="margin-bottom:16px;">
|
||||
<div class="recovery-qr-banner__header">
|
||||
<span style="font-size:20px;">◫</span>
|
||||
<strong>Generate a recovery QR before you go</strong>
|
||||
</div>
|
||||
<p class="muted" style="font-size:12px;margin:4px 0 8px;">
|
||||
If you lose your reference image, this QR lets you recover your vault. Print it and store it safely.
|
||||
</p>
|
||||
<div class="recovery-qr-banner__actions">
|
||||
<button class="btn btn-primary" id="setup-gen-qr">Generate now</button>
|
||||
<button class="btn" id="setup-skip-qr">Skip — I'll do this in Settings</button>
|
||||
</div>
|
||||
</div>
|
||||
` : '';
|
||||
|
||||
return `
|
||||
<div class="wizard-step glass" style="padding: 24px; margin-top: 16px;">
|
||||
<div class="success-box">
|
||||
@@ -992,6 +1012,8 @@ function renderStep5(): string {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
${qrBannerHtml}
|
||||
|
||||
${isAttach ? '' : `
|
||||
<div class="form-group">
|
||||
<label class="label">reference image</label>
|
||||
@@ -1026,6 +1048,48 @@ function renderStep5(): string {
|
||||
}
|
||||
|
||||
function attachStep5(): void {
|
||||
document.getElementById('setup-gen-qr')?.addEventListener('click', async () => {
|
||||
if (!state.verifiedHandle) return;
|
||||
const btn = document.getElementById('setup-gen-qr') as HTMLButtonElement | null;
|
||||
if (btn) { btn.disabled = true; btn.textContent = 'Generating…'; }
|
||||
try {
|
||||
const { sendMessage } = await import('../shared/state');
|
||||
const resp = await sendMessage({
|
||||
type: 'generate_recovery_qr',
|
||||
sessionHandle: state.verifiedHandle.value,
|
||||
passphrase: state.passphrase,
|
||||
} as any) as any;
|
||||
if (!resp.ok || !resp.data) throw new Error(resp.error ?? 'unknown error');
|
||||
const svg = (resp.data as { svg: string }).svg;
|
||||
await new Promise<void>((resolve) => {
|
||||
chrome.storage.local.set({ recovery_qr_generated_at: Date.now() }, resolve);
|
||||
});
|
||||
const banner = document.getElementById('recovery-qr-banner');
|
||||
if (banner) {
|
||||
banner.innerHTML = `
|
||||
<div style="text-align:center;">${svg}</div>
|
||||
<p style="font-size:12px;color:var(--success,#238636);margin:8px 0 0;">
|
||||
◉ Recovery QR generated — save or print this now.
|
||||
</p>
|
||||
<div style="margin-top:8px;">
|
||||
<button class="btn" id="setup-qr-done">Done</button>
|
||||
</div>
|
||||
`;
|
||||
document.getElementById('setup-qr-done')?.addEventListener('click', () => {
|
||||
banner.style.display = 'none';
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
if (btn) { btn.disabled = false; btn.textContent = 'Generate now'; }
|
||||
alert(`Failed to generate QR: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('setup-skip-qr')?.addEventListener('click', () => {
|
||||
const banner = document.getElementById('recovery-qr-banner');
|
||||
if (banner) banner.style.display = 'none';
|
||||
});
|
||||
|
||||
document.getElementById('download-ref-btn')?.addEventListener('click', () => {
|
||||
if (!state.referenceImageBytes) return;
|
||||
const blob = new Blob([state.referenceImageBytes.buffer as ArrayBuffer], { type: 'image/jpeg' });
|
||||
|
||||
@@ -41,3 +41,30 @@ describe('glyph constants', () => {
|
||||
expect(GLYPH_NEXT).toBe('▸');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Stream A glyphs (vault tab + type icons)', () => {
|
||||
it('exports GLYPH_VAULT_TAB as U+29C9', () => {
|
||||
expect(glyphs.GLYPH_VAULT_TAB).toBe('⧉');
|
||||
});
|
||||
|
||||
it('exports per-type glyph constants', () => {
|
||||
expect(glyphs.GLYPH_TYPE_LOGIN).toBe('◉');
|
||||
expect(glyphs.GLYPH_TYPE_SECURE_NOTE).toBe('◫');
|
||||
expect(glyphs.GLYPH_TYPE_TOTP).toBe('⊡');
|
||||
expect(glyphs.GLYPH_TYPE_CARD).toBe('▭');
|
||||
expect(glyphs.GLYPH_TYPE_IDENTITY).toBe('◍');
|
||||
expect(glyphs.GLYPH_TYPE_KEY).toBe('⊹');
|
||||
expect(glyphs.GLYPH_TYPE_DOCUMENT).toBe('≡');
|
||||
});
|
||||
|
||||
it('per-type glyphs are single codepoints (no emoji)', () => {
|
||||
const typeGlyphs = [
|
||||
glyphs.GLYPH_TYPE_LOGIN, glyphs.GLYPH_TYPE_SECURE_NOTE, glyphs.GLYPH_TYPE_TOTP,
|
||||
glyphs.GLYPH_TYPE_CARD, glyphs.GLYPH_TYPE_IDENTITY, glyphs.GLYPH_TYPE_KEY,
|
||||
glyphs.GLYPH_TYPE_DOCUMENT,
|
||||
];
|
||||
for (const g of typeGlyphs) {
|
||||
expect([...g].length).toBe(1);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,6 +17,19 @@ export const GLYPH_DEVICES = '⌬'; // sidebar devices nav
|
||||
export const GLYPH_SETTINGS = '⚙'; // sidebar settings nav
|
||||
export const GLYPH_LOCK = '⏻'; // sidebar lock nav
|
||||
export const GLYPH_NEXT = '▸'; // forward / next button (matches ▾/▸ disclosure family)
|
||||
export const GLYPH_COPY = '⎘'; // copy to clipboard
|
||||
export const GLYPH_SYNC = '⇅'; // sync / upload
|
||||
export const GLYPH_PREVIEW = '⊕'; // preview / expand
|
||||
|
||||
export const GLYPH_VAULT_TAB = '⧉'; // U+29C9 pop-out to fullscreen vault tab
|
||||
|
||||
export const GLYPH_TYPE_LOGIN = '◉'; // login
|
||||
export const GLYPH_TYPE_SECURE_NOTE = '◫'; // secure note
|
||||
export const GLYPH_TYPE_TOTP = '⊡'; // totp / 2FA
|
||||
export const GLYPH_TYPE_CARD = '▭'; // card
|
||||
export const GLYPH_TYPE_IDENTITY = '◍'; // identity (distinct from GLYPH_DEVICES ⌬)
|
||||
export const GLYPH_TYPE_KEY = '⊹'; // SSH / API key
|
||||
export const GLYPH_TYPE_DOCUMENT = '≡'; // document
|
||||
|
||||
/// Inline HTML snippet for the required-field pill. Use after a label's text:
|
||||
/// `<label class="label" for="f-title">title ${REQUIRED_PILL_HTML}</label>`
|
||||
|
||||
@@ -61,7 +61,9 @@ export type PopupMessage =
|
||||
}
|
||||
| { type: 'parse_lastpass_csv'; bytes: ArrayBuffer }
|
||||
| { type: 'import_lastpass_commit'; items: Item[] }
|
||||
| { type: 'preview_totp_from_secret'; secret_b32: string };
|
||||
| { type: 'preview_totp_from_secret'; secret_b32: string }
|
||||
| { type: 'generate_recovery_qr'; passphrase: string }
|
||||
| { type: 'unwrap_recovery_qr'; payload_b64: string; passphrase: string };
|
||||
|
||||
// --- Messages a content script may send ---
|
||||
|
||||
@@ -173,6 +175,7 @@ export const POPUP_ONLY_TYPES: ReadonlySet<PopupMessage['type']> = new Set([
|
||||
'export_backup', 'restore_backup',
|
||||
'parse_lastpass_csv', 'import_lastpass_commit',
|
||||
'preview_totp_from_secret',
|
||||
'generate_recovery_qr', 'unwrap_recovery_qr',
|
||||
] as PopupMessage['type'][]);
|
||||
|
||||
export interface ExportBackupResponse extends Extract<Response, { ok: true }> {
|
||||
|
||||
26
extension/src/shared/toast.ts
Normal file
26
extension/src/shared/toast.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
export function showToast(
|
||||
message: string,
|
||||
type: 'success' | 'error' | 'info' = 'info',
|
||||
durationMs = 2500,
|
||||
): void {
|
||||
let container = document.querySelector<HTMLElement>('.relicario-toast-container');
|
||||
if (!container) {
|
||||
container = document.createElement('div');
|
||||
container.className = 'relicario-toast-container';
|
||||
document.body.appendChild(container);
|
||||
}
|
||||
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `relicario-toast relicario-toast--${type}`;
|
||||
toast.textContent = message;
|
||||
container.appendChild(toast);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => toast.classList.add('relicario-toast--visible'));
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
toast.classList.remove('relicario-toast--visible');
|
||||
toast.addEventListener('transitionend', () => toast.remove(), { once: true });
|
||||
}, durationMs);
|
||||
}
|
||||
@@ -438,6 +438,14 @@ textarea {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
/* When the sticky save bar (fullscreen) provides external actions,
|
||||
the form's inner action row sets the [hidden] attribute. The default
|
||||
user-agent rule for [hidden] is display:none, but our .form-actions
|
||||
display:flex above wins specificity. Re-assert hidden takes priority. */
|
||||
.form-actions[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.inline-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
@@ -626,16 +634,18 @@ textarea {
|
||||
.gen-trigger {
|
||||
background: #7c5719;
|
||||
color: #fff3cf;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
padding: 0 12px;
|
||||
font-size: 16px;
|
||||
border: 1px solid #7c5719;
|
||||
border-radius: 3px;
|
||||
padding: 0 8px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
min-width: 38px;
|
||||
min-width: 28px;
|
||||
height: 28px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.gen-trigger:hover { background: #aa812a; }
|
||||
.gen-trigger[aria-expanded="true"] { background: #aa812a; }
|
||||
@@ -1290,6 +1300,13 @@ textarea {
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.vault-sidebar__header .brand-logo {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
margin: 0;
|
||||
display: block;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.vault-sidebar__search {
|
||||
padding: 8px 12px;
|
||||
@@ -1389,6 +1406,392 @@ textarea {
|
||||
color: #484f58;
|
||||
}
|
||||
|
||||
/* === 3-column shell === */
|
||||
.vault-shell {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
background: var(--bg-page, #0d1117);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.vault-sidebar {
|
||||
width: 200px;
|
||||
min-width: 200px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-right: 1px solid var(--border, #30363d);
|
||||
background: var(--bg-page);
|
||||
overflow-y: auto;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.vault-list-pane {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Pane occupies the same flex slot as list-pane for non-list views */
|
||||
.vault-pane {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
min-width: 0;
|
||||
padding: 24px 32px;
|
||||
}
|
||||
.vault-pane--empty {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-dim);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* View-specific visibility: only one of list-pane / vault-pane is visible.
|
||||
Default to list view if neither class is set. */
|
||||
.vault-shell .vault-pane { display: none; }
|
||||
.vault-shell--pane .vault-list-pane { display: none; }
|
||||
.vault-shell--pane .vault-pane { display: flex; }
|
||||
.vault-shell--pane .vault-drawer { display: none; }
|
||||
|
||||
.vault-drawer {
|
||||
width: 440px;
|
||||
min-width: 440px;
|
||||
max-width: 440px;
|
||||
border-left: 1px solid var(--border, #30363d);
|
||||
background: var(--bg-elevated, #161b22);
|
||||
overflow-y: auto;
|
||||
transform: translateX(100%);
|
||||
transition: transform 0.2s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.vault-drawer--open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
/* Empty state — centered, gold-accented icon, polished */
|
||||
.empty-state {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
padding: 48px 24px;
|
||||
text-align: center;
|
||||
min-height: 240px;
|
||||
}
|
||||
.empty-state__icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
border-radius: 50%;
|
||||
background: var(--gold-soft);
|
||||
border: 1px solid var(--gold-ring);
|
||||
color: var(--gold-text);
|
||||
font-size: 30px;
|
||||
line-height: 1;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.empty-state__title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.empty-state__hint {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
max-width: 320px;
|
||||
}
|
||||
|
||||
.vault-list-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 16px;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid var(--border-subtle, #21262d);
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.vault-list-row:hover { background: var(--bg-hover, #161b22); }
|
||||
|
||||
.vault-list-row--selected {
|
||||
background: var(--bg-selected, #1c2d41);
|
||||
border-left: 2px solid var(--gold, #b8860b);
|
||||
}
|
||||
|
||||
.vault-list-row__icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg-elevated, #161b22);
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border, #30363d);
|
||||
font-size: 14px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.vault-list-row--selected .vault-list-row__icon { border-color: var(--gold, #b8860b); }
|
||||
|
||||
.vault-list-row__text { flex: 1; min-width: 0; }
|
||||
|
||||
.vault-list-row__title {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.vault-list-row__subtitle {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted, #8b949e);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.vault-list-row__age {
|
||||
font-size: 10px;
|
||||
color: var(--text-dim, #6e7681);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Type picker — secondary panel that slides out from behind the left sidebar */
|
||||
.vault-type-panel-scrim {
|
||||
position: absolute;
|
||||
inset: 0 0 0 200px;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.2s ease;
|
||||
z-index: 3;
|
||||
}
|
||||
.vault-type-panel-scrim--visible {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.vault-type-panel {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 200px;
|
||||
width: 280px;
|
||||
background: var(--bg-elevated);
|
||||
border-right: 1px solid var(--border-mid);
|
||||
transform: translateX(-100%);
|
||||
transition: transform 0.22s ease;
|
||||
z-index: 4;
|
||||
overflow-y: auto;
|
||||
padding: 14px 12px;
|
||||
box-shadow: 8px 0 24px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
.vault-type-panel--open { transform: translateX(0); }
|
||||
|
||||
.vault-type-panel__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 4px 10px;
|
||||
margin-bottom: 8px;
|
||||
border-bottom: 1px solid var(--border-soft);
|
||||
}
|
||||
.vault-type-panel__title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--gold-text);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
.vault-type-panel__close {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.vault-type-panel__close:hover { color: var(--text); background: var(--bg-pane); }
|
||||
.vault-type-panel__hint {
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
padding: 0 4px 8px;
|
||||
}
|
||||
|
||||
.vault-type-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.vault-type-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 9px 10px;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
transition: background 0.1s, border-color 0.1s;
|
||||
}
|
||||
.vault-type-item:hover {
|
||||
background: var(--bg-pane);
|
||||
border-color: var(--border-mid);
|
||||
}
|
||||
.vault-type-item:focus-visible {
|
||||
outline: none;
|
||||
border-color: var(--gold-base);
|
||||
background: var(--gold-soft);
|
||||
}
|
||||
.vault-type-item__icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
flex-shrink: 0;
|
||||
background: var(--gold-soft);
|
||||
border: 1px solid var(--gold-ring);
|
||||
border-radius: 5px;
|
||||
font-size: 15px;
|
||||
color: var(--gold-text);
|
||||
}
|
||||
.vault-type-item__name {
|
||||
flex: 1;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* Sidebar nav button — primary new-item variant */
|
||||
.vault-sidebar__nav-item--primary {
|
||||
color: var(--gold-text);
|
||||
font-weight: 600;
|
||||
}
|
||||
.vault-sidebar__nav-item--primary:hover {
|
||||
background: var(--gold-soft);
|
||||
color: var(--gold-hi-end);
|
||||
}
|
||||
|
||||
/* Drawer header and body */
|
||||
.vault-drawer__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border, #30363d);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.vault-drawer__type-pill {
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
padding: 2px 8px;
|
||||
background: var(--bg-page, #0d1117);
|
||||
border: 1px solid var(--border, #30363d);
|
||||
border-radius: 4px;
|
||||
color: var(--text-muted, #8b949e);
|
||||
}
|
||||
|
||||
.vault-drawer__actions { display: flex; gap: 6px; margin-left: auto; }
|
||||
|
||||
.vault-drawer__close {
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
color: var(--text-muted, #8b949e);
|
||||
padding: 4px 6px;
|
||||
}
|
||||
|
||||
.vault-drawer__body { padding: 20px 20px 16px; }
|
||||
|
||||
.vault-drawer__title { font-size: 18px; font-weight: 700; margin-bottom: 4px; }
|
||||
.vault-drawer__subtitle { font-size: 12px; color: var(--text-muted, #8b949e); margin-bottom: 16px; }
|
||||
|
||||
.vault-drawer__field-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.vault-drawer__field-grid > .vault-drawer__field--full { grid-column: 1 / -1; }
|
||||
|
||||
.vault-drawer__field-label {
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--text-muted, #8b949e);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.vault-drawer__field-value {
|
||||
font-size: 13px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* Category nav */
|
||||
.vault-category-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 6px 12px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: inherit;
|
||||
font-size: 13px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.vault-category-row:hover { background: var(--bg-hover, #161b22); }
|
||||
.vault-category-row--active { background: var(--bg-selected, #1c2d41); }
|
||||
.vault-category-row__icon { font-size: 14px; flex-shrink: 0; }
|
||||
.vault-category-row__label { flex: 1; }
|
||||
.vault-category-row__count { font-size: 11px; color: var(--text-muted, #8b949e); }
|
||||
|
||||
/* === Responsive === */
|
||||
@media (max-width: 960px) {
|
||||
.vault-drawer {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.vault-sidebar {
|
||||
width: 48px;
|
||||
min-width: 48px;
|
||||
}
|
||||
.vault-sidebar__category-label,
|
||||
.vault-sidebar__category-count,
|
||||
.vault-sidebar__nav-label {
|
||||
display: none;
|
||||
}
|
||||
.vault-sidebar__nav-item { justify-content: center; padding: 10px 0; }
|
||||
}
|
||||
|
||||
/* --- Lock screen (vault tab) --- */
|
||||
|
||||
.vault-lock-screen {
|
||||
@@ -1620,7 +2023,7 @@ textarea {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 24px;
|
||||
max-width: 960px;
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
@@ -1630,7 +2033,7 @@ textarea {
|
||||
/* P3: lower form sections constrained to the same envelope as .form-grid.
|
||||
Gated on surface === 'fullscreen' in login.ts; popup unaffected. */
|
||||
.form-lower {
|
||||
max-width: 960px;
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.form-lower > .form-group,
|
||||
@@ -1683,6 +2086,9 @@ textarea {
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.form-scroll {
|
||||
flex: 1;
|
||||
@@ -1712,3 +2118,41 @@ textarea {
|
||||
background: linear-gradient(to top, rgba(17, 22, 30, 0.7), transparent);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Toast notifications */
|
||||
.relicario-toast-container {
|
||||
position: fixed;
|
||||
bottom: 16px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
pointer-events: none;
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
.vault-shell .relicario-toast-container {
|
||||
left: auto;
|
||||
right: 24px;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.relicario-toast {
|
||||
padding: 8px 16px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
transition: opacity 0.2s, transform 0.2s;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.relicario-toast--visible {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.relicario-toast--success { background: #1f4a24; color: #aff0b5; border: 1px solid #238636; }
|
||||
.relicario-toast--error { background: #4a1f1f; color: #f0afaf; border: 1px solid #ab2b20; }
|
||||
.relicario-toast--info { background: #1f2d4a; color: #afc8f0; border: 1px solid #1f6feb; }
|
||||
|
||||
@@ -10,18 +10,36 @@ import type {
|
||||
} from '../shared/types';
|
||||
import { registerHost } from '../shared/state';
|
||||
import { lookupErrorCopy, type ErrorCta } from '../shared/error-copy';
|
||||
import { GLYPH_TRASH, GLYPH_DEVICES, GLYPH_SETTINGS, GLYPH_LOCK } from '../shared/glyphs';
|
||||
import {
|
||||
GLYPH_TRASH, GLYPH_DEVICES, GLYPH_SETTINGS, GLYPH_LOCK,
|
||||
GLYPH_TYPE_LOGIN, GLYPH_TYPE_SECURE_NOTE, GLYPH_TYPE_TOTP,
|
||||
GLYPH_TYPE_CARD, GLYPH_TYPE_IDENTITY, GLYPH_TYPE_KEY, GLYPH_TYPE_DOCUMENT,
|
||||
} from '../shared/glyphs';
|
||||
import { renderItemDetail } from '../popup/components/item-detail';
|
||||
import { renderItemForm } from '../popup/components/item-form';
|
||||
import { renderTrash, teardown as teardownTrash } from '../popup/components/trash';
|
||||
import { renderDevices, teardown as teardownDevices } from '../popup/components/devices';
|
||||
import { renderSettings } from '../popup/components/settings';
|
||||
import { renderSettings, teardownSettings } from '../popup/components/settings';
|
||||
import { renderVaultSettings as renderVaultSettingsView } from '../popup/components/settings-vault';
|
||||
import { renderFieldHistory, teardown as teardownFieldHistory } from '../popup/components/field-history';
|
||||
import { renderBackupPanel, teardown as teardownBackup } from './components/backup-panel';
|
||||
import { renderImportPanel, teardown as teardownImport } from './components/import-panel';
|
||||
import { applyColorScheme } from '../shared/color-scheme';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Type picker (right side panel)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const PICKER_TYPES: Array<{ type: ItemType; label: string }> = [
|
||||
{ type: 'login', label: 'Login' },
|
||||
{ type: 'secure_note', label: 'Secure Note' },
|
||||
{ type: 'totp', label: 'TOTP' },
|
||||
{ type: 'card', label: 'Card' },
|
||||
{ type: 'identity', label: 'Identity' },
|
||||
{ type: 'key', label: 'SSH / API Key' },
|
||||
{ type: 'document', label: 'Document' },
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -29,6 +47,27 @@ import { applyColorScheme } from '../shared/color-scheme';
|
||||
function sendMessage(request: Request): Promise<Response> {
|
||||
return new Promise((resolve) => {
|
||||
chrome.runtime.sendMessage(request, (response: Response) => {
|
||||
// MV3 service workers are evicted after ~30s idle, which wipes the
|
||||
// in-memory session/manifest/gitHost. The fullscreen tab stays open
|
||||
// and has no signal that the SW restarted — the next RPC just comes
|
||||
// back `vault_locked`. Treat that as "session lost" and force the
|
||||
// lock screen so the user can re-enter their passphrase. Skip for
|
||||
// is_unlocked / unlock themselves to avoid loops on cold start.
|
||||
if (
|
||||
response &&
|
||||
!response.ok &&
|
||||
response.error === 'vault_locked' &&
|
||||
request.type !== 'is_unlocked' &&
|
||||
request.type !== 'unlock' &&
|
||||
state.unlocked
|
||||
) {
|
||||
state.unlocked = false;
|
||||
state.selectedId = null;
|
||||
state.selectedItem = null;
|
||||
state.entries = [];
|
||||
state.error = 'Session expired — please unlock again.';
|
||||
render();
|
||||
}
|
||||
resolve(response);
|
||||
});
|
||||
});
|
||||
@@ -60,26 +99,35 @@ function renderErrorBlock(code: string | null | undefined): string {
|
||||
|
||||
function typeIcon(t: ItemType): string {
|
||||
switch (t) {
|
||||
case 'login': return '\u{1F511}'; // key
|
||||
case 'secure_note': return '\u{1F4DD}'; // memo
|
||||
case 'identity': return '\u{1FAAA}'; // id card
|
||||
case 'card': return '\u{1F4B3}'; // credit card
|
||||
case 'key': return '\u{1F5DD}'; // old key
|
||||
case 'document': return '\u{1F4C4}'; // page facing up
|
||||
case 'totp': return '⏱'; // stopwatch
|
||||
case 'login': return GLYPH_TYPE_LOGIN;
|
||||
case 'secure_note': return GLYPH_TYPE_SECURE_NOTE;
|
||||
case 'identity': return GLYPH_TYPE_IDENTITY;
|
||||
case 'card': return GLYPH_TYPE_CARD;
|
||||
case 'key': return GLYPH_TYPE_KEY;
|
||||
case 'document': return GLYPH_TYPE_DOCUMENT;
|
||||
case 'totp': return GLYPH_TYPE_TOTP;
|
||||
}
|
||||
}
|
||||
|
||||
function typeLabel(t: ItemType): string {
|
||||
switch (t) {
|
||||
case 'login': return 'Logins';
|
||||
case 'secure_note': return 'Secure Notes';
|
||||
case 'identity': return 'Identities';
|
||||
case 'card': return 'Cards';
|
||||
case 'key': return 'Keys';
|
||||
case 'document': return 'Documents';
|
||||
case 'totp': return 'TOTP';
|
||||
}
|
||||
const labels: Record<ItemType, string> = {
|
||||
login: 'Login',
|
||||
secure_note: 'Secure Note',
|
||||
identity: 'Identity',
|
||||
card: 'Card',
|
||||
key: 'SSH / API Key',
|
||||
document: 'Document',
|
||||
totp: 'TOTP',
|
||||
};
|
||||
return labels[t];
|
||||
}
|
||||
|
||||
function relativeTime(unixSec: number): string {
|
||||
const diffS = Math.floor(Date.now() / 1000) - unixSec;
|
||||
if (diffS < 60) return 'just now';
|
||||
if (diffS < 3600) return `${Math.floor(diffS / 60)}m ago`;
|
||||
if (diffS < 86400) return `${Math.floor(diffS / 3600)}h ago`;
|
||||
return `${Math.floor(diffS / 86400)}d ago`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -138,6 +186,8 @@ interface VaultState {
|
||||
selectedIndex: number;
|
||||
searchQuery: string;
|
||||
activeGroup: string | null;
|
||||
drawerOpen: boolean;
|
||||
typePanelOpen: boolean;
|
||||
vaultSettings: VaultSettings | null;
|
||||
generatorDefaults: GeneratorRequest | null;
|
||||
error: string | null;
|
||||
@@ -157,6 +207,8 @@ const state: VaultState = {
|
||||
selectedIndex: 0,
|
||||
searchQuery: '',
|
||||
activeGroup: null,
|
||||
drawerOpen: false,
|
||||
typePanelOpen: false,
|
||||
vaultSettings: null,
|
||||
generatorDefaults: null,
|
||||
error: null,
|
||||
@@ -180,7 +232,9 @@ registerHost({
|
||||
navigate: (view: string, extras?: any) => {
|
||||
Object.assign(state, { view, error: null, loading: false, ...extras });
|
||||
setHash(view as VaultView);
|
||||
renderSidebarList();
|
||||
applyShellViewClass();
|
||||
renderSidebarCategories();
|
||||
if (state.view === 'list') renderListPane();
|
||||
renderPane();
|
||||
},
|
||||
sendMessage,
|
||||
@@ -249,38 +303,253 @@ function renderLockScreen(app: HTMLElement): void {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shell (sidebar + pane)
|
||||
// Shell (3-column: sidebar + list pane + drawer)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function renderShell(app: HTMLElement): void {
|
||||
// Only create the shell structure if it's not present yet
|
||||
if (!app.querySelector('.vault-sidebar')) {
|
||||
if (!app.querySelector('.vault-shell')) {
|
||||
app.innerHTML = `
|
||||
<div class="vault-sidebar">
|
||||
<div class="vault-sidebar__header">
|
||||
<span class="brand">Relicario</span>
|
||||
<div class="vault-shell">
|
||||
<div class="vault-sidebar">
|
||||
<div class="vault-sidebar__header">
|
||||
<img class="brand-logo" src="icons/relicario-logo.svg" alt="">
|
||||
<span class="brand">Relicario</span>
|
||||
</div>
|
||||
<div class="vault-sidebar__search">
|
||||
<input type="text" id="vault-search" placeholder="/ search…" />
|
||||
</div>
|
||||
<nav class="vault-sidebar__categories" id="vault-categories" aria-label="Item types"></nav>
|
||||
<div class="vault-sidebar__nav">
|
||||
<button class="vault-sidebar__nav-item vault-sidebar__nav-item--primary" data-nav="add" title="New item">+ new item</button>
|
||||
<button class="vault-sidebar__nav-item" data-nav="trash" title="Trash">${GLYPH_TRASH} <span class="vault-sidebar__nav-label">trash</span></button>
|
||||
<button class="vault-sidebar__nav-item" data-nav="devices" title="Devices">${GLYPH_DEVICES} <span class="vault-sidebar__nav-label">devices</span></button>
|
||||
<button class="vault-sidebar__nav-item" data-nav="settings" title="Settings">${GLYPH_SETTINGS} <span class="vault-sidebar__nav-label">settings</span></button>
|
||||
<button class="vault-sidebar__nav-item" data-nav="lock" title="Lock">${GLYPH_LOCK} <span class="vault-sidebar__nav-label">lock</span></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="vault-sidebar__search">
|
||||
<input type="text" id="vault-search" placeholder="/ search..." />
|
||||
</div>
|
||||
<div class="vault-sidebar__list" id="vault-sidebar-list"></div>
|
||||
<div class="vault-sidebar__nav">
|
||||
<button class="vault-sidebar__nav-item" data-nav="add">+ new item</button>
|
||||
<button class="vault-sidebar__nav-item" data-nav="trash">${GLYPH_TRASH} trash</button>
|
||||
<button class="vault-sidebar__nav-item" data-nav="devices">${GLYPH_DEVICES} devices</button>
|
||||
<button class="vault-sidebar__nav-item" data-nav="settings">${GLYPH_SETTINGS} settings</button>
|
||||
<button class="vault-sidebar__nav-item" data-nav="lock">${GLYPH_LOCK} lock</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="vault-pane vault-pane--empty" id="vault-pane">
|
||||
select an item
|
||||
<div class="vault-list-pane" id="vault-list-pane"></div>
|
||||
<div class="vault-pane" id="vault-pane"></div>
|
||||
<div class="vault-drawer" id="vault-drawer"></div>
|
||||
<div class="vault-type-panel-scrim" id="vault-type-scrim"></div>
|
||||
<aside class="vault-type-panel" id="vault-type-panel" aria-label="Choose item type"></aside>
|
||||
</div>
|
||||
`;
|
||||
wireSidebar();
|
||||
wireTypePanel();
|
||||
}
|
||||
|
||||
renderSidebarList();
|
||||
renderPane();
|
||||
applyShellViewClass();
|
||||
renderSidebarCategories();
|
||||
if (state.view === 'list') {
|
||||
renderListPane();
|
||||
if (state.drawerOpen && state.selectedItem) {
|
||||
renderDrawer(state.selectedItem);
|
||||
}
|
||||
} else {
|
||||
renderPane();
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle which middle column is visible based on the current view.
|
||||
// list view → list-pane (+ optional drawer); other views → vault-pane.
|
||||
function applyShellViewClass(): void {
|
||||
const shell = document.querySelector('.vault-shell');
|
||||
if (!shell) return;
|
||||
shell.classList.toggle('vault-shell--list', state.view === 'list');
|
||||
shell.classList.toggle('vault-shell--pane', state.view !== 'list');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Right-side type picker panel
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function wireTypePanel(): void {
|
||||
document.getElementById('vault-type-scrim')?.addEventListener('click', closeTypePanel);
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape' && state.typePanelOpen) closeTypePanel();
|
||||
});
|
||||
}
|
||||
|
||||
function openTypePanel(): void {
|
||||
const panel = document.getElementById('vault-type-panel');
|
||||
const scrim = document.getElementById('vault-type-scrim');
|
||||
if (!panel || !scrim) return;
|
||||
|
||||
panel.innerHTML = `
|
||||
<div class="vault-type-panel__head">
|
||||
<div class="vault-type-panel__title">New item</div>
|
||||
<button class="vault-type-panel__close" id="vault-type-close" title="Close (Esc)" aria-label="Close">✕</button>
|
||||
</div>
|
||||
<div class="vault-type-panel__hint">Choose a type</div>
|
||||
<div class="vault-type-list" role="menu">
|
||||
${PICKER_TYPES.map((t) => `
|
||||
<button class="vault-type-item" data-type="${t.type}" role="menuitem">
|
||||
<span class="vault-type-item__icon" aria-hidden="true">${typeIcon(t.type)}</span>
|
||||
<span class="vault-type-item__name">${escapeHtml(t.label)}</span>
|
||||
</button>
|
||||
`).join('')}
|
||||
</div>
|
||||
`;
|
||||
|
||||
panel.classList.add('vault-type-panel--open');
|
||||
scrim.classList.add('vault-type-panel-scrim--visible');
|
||||
state.typePanelOpen = true;
|
||||
|
||||
panel.querySelector('#vault-type-close')?.addEventListener('click', closeTypePanel);
|
||||
|
||||
panel.querySelectorAll<HTMLButtonElement>('[data-type]').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
const type = btn.dataset.type as ItemType;
|
||||
closeTypePanel();
|
||||
// Use the host's navigate hook so view + hash + visibility all update
|
||||
// together. This was the bug: bare setHash + renderPane left the
|
||||
// shell stuck in list view with #vault-pane hidden.
|
||||
state.newType = type;
|
||||
state.selectedId = null;
|
||||
state.selectedItem = null;
|
||||
state.drawerOpen = false;
|
||||
state.view = 'add';
|
||||
setHash('add', type);
|
||||
applyShellViewClass();
|
||||
renderSidebarCategories();
|
||||
renderPane();
|
||||
});
|
||||
});
|
||||
|
||||
// Focus first item for keyboard users
|
||||
(panel.querySelector('.vault-type-item') as HTMLElement | null)?.focus();
|
||||
}
|
||||
|
||||
function closeTypePanel(): void {
|
||||
document.getElementById('vault-type-panel')?.classList.remove('vault-type-panel--open');
|
||||
document.getElementById('vault-type-scrim')?.classList.remove('vault-type-panel-scrim--visible');
|
||||
state.typePanelOpen = false;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Drawer (implemented in Task 10)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function openDrawer(): void {
|
||||
document.getElementById('vault-drawer')?.classList.add('vault-drawer--open');
|
||||
}
|
||||
|
||||
function closeDrawer(): void {
|
||||
state.drawerOpen = false;
|
||||
state.selectedId = null;
|
||||
state.selectedItem = null;
|
||||
document.getElementById('vault-drawer')?.classList.remove('vault-drawer--open');
|
||||
}
|
||||
|
||||
function getDrawerCoreFields(item: Item): Array<[string, string, boolean]> {
|
||||
const core = item.core as unknown as Record<string, unknown>;
|
||||
if (!core) return [];
|
||||
const fields: Array<[string, string, boolean]> = [];
|
||||
|
||||
switch (item.type) {
|
||||
case 'login':
|
||||
if ('username' in core) fields.push(['username', String(core.username ?? ''), false]);
|
||||
if ('password' in core) fields.push(['password', '••••••••', false]);
|
||||
if ('url' in core) fields.push(['url', String(core.url ?? ''), true]);
|
||||
break;
|
||||
case 'card': {
|
||||
if ('number' in core) fields.push(['number', String(core.number ?? ''), false]);
|
||||
if ('holder' in core) fields.push(['holder', String(core.holder ?? ''), false]);
|
||||
if ('expiry' in core && core.expiry) {
|
||||
const exp = core.expiry as { month: number; year: number };
|
||||
fields.push(['expiry', `${String(exp.month).padStart(2, '0')}/${exp.year}`, false]);
|
||||
}
|
||||
if ('cvv' in core) fields.push(['cvv', '•••', false]);
|
||||
if ('pin' in core) fields.push(['pin', '••••', false]);
|
||||
break;
|
||||
}
|
||||
case 'identity':
|
||||
if ('full_name' in core) fields.push(['full name', String(core.full_name ?? ''), true]);
|
||||
if ('email' in core) fields.push(['email', String(core.email ?? ''), true]);
|
||||
if ('phone' in core) fields.push(['phone', String(core.phone ?? ''), false]);
|
||||
if ('address' in core) fields.push(['address', String(core.address ?? ''), true]);
|
||||
if ('date_of_birth' in core) fields.push(['date of birth', String(core.date_of_birth ?? ''), false]);
|
||||
break;
|
||||
case 'key':
|
||||
if ('label' in core) fields.push(['label', String(core.label ?? ''), true]);
|
||||
if ('algorithm' in core) fields.push(['algorithm', String(core.algorithm ?? ''), false]);
|
||||
if ('public_key' in core) fields.push(['public key', String(core.public_key ?? ''), true]);
|
||||
break;
|
||||
case 'secure_note':
|
||||
if ('body' in core) fields.push(['body', String(core.body ?? ''), true]);
|
||||
break;
|
||||
case 'totp':
|
||||
if ('issuer' in core) fields.push(['issuer', String(core.issuer ?? ''), false]);
|
||||
if ('label' in core) fields.push(['label', String(core.label ?? ''), false]);
|
||||
break;
|
||||
case 'document':
|
||||
if ('filename' in core) fields.push(['filename', String(core.filename ?? ''), true]);
|
||||
if ('mime_type' in core) fields.push(['type', String(core.mime_type ?? ''), false]);
|
||||
break;
|
||||
}
|
||||
|
||||
if (item.notes) fields.push(['notes', item.notes, true]);
|
||||
return fields;
|
||||
}
|
||||
|
||||
function renderDrawer(item: Item): void {
|
||||
const drawer = document.getElementById('vault-drawer');
|
||||
if (!drawer) return;
|
||||
|
||||
const coreFields = getDrawerCoreFields(item);
|
||||
|
||||
drawer.innerHTML = `
|
||||
<div class="vault-drawer__header">
|
||||
<span class="vault-drawer__type-pill">${item.type.replace('_', ' ').toUpperCase()}</span>
|
||||
<div class="vault-drawer__actions">
|
||||
<button class="btn" id="drawer-edit-btn" style="font-size:11px;">edit</button>
|
||||
<button class="vault-drawer__close" id="drawer-close-btn" title="Close (Esc)">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="vault-drawer__body">
|
||||
<div class="vault-drawer__title">${escapeHtml(item.title)}</div>
|
||||
${item.type === 'login' && (item.core as { url?: string }).url
|
||||
? `<div class="vault-drawer__subtitle">${escapeHtml((item.core as { url?: string }).url ?? '')}</div>`
|
||||
: ''}
|
||||
<div class="vault-drawer__field-grid">
|
||||
${coreFields.map(([label, value, full]) => `
|
||||
<div class="vault-drawer__field${full ? ' vault-drawer__field--full' : ''}">
|
||||
<div class="vault-drawer__field-label">${escapeHtml(label)}</div>
|
||||
<div class="vault-drawer__field-value">${escapeHtml(value)}</div>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.getElementById('drawer-close-btn')?.addEventListener('click', () => {
|
||||
closeDrawer();
|
||||
renderListPane();
|
||||
});
|
||||
|
||||
document.getElementById('drawer-edit-btn')?.addEventListener('click', () => {
|
||||
if (state.selectedId) {
|
||||
setHash('edit', state.selectedId);
|
||||
renderPane();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Item selection (implemented in Task 10)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function selectItemForDrawer(id: string): Promise<void> {
|
||||
const resp = await sendMessage({ type: 'get_item', id });
|
||||
if (!resp.ok) return;
|
||||
const data = resp.data as { item: Item };
|
||||
state.selectedId = id;
|
||||
state.selectedItem = data.item;
|
||||
state.drawerOpen = true;
|
||||
renderSidebarCategories();
|
||||
renderListPane();
|
||||
renderDrawer(data.item);
|
||||
openDrawer();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -292,7 +561,8 @@ function wireSidebar(): void {
|
||||
const searchInput = document.getElementById('vault-search') as HTMLInputElement | null;
|
||||
searchInput?.addEventListener('input', () => {
|
||||
state.searchQuery = searchInput.value;
|
||||
renderSidebarList();
|
||||
renderSidebarCategories();
|
||||
renderListPane();
|
||||
});
|
||||
|
||||
// Nav buttons
|
||||
@@ -312,26 +582,35 @@ function wireSidebar(): void {
|
||||
state.selectedId = null;
|
||||
state.selectedItem = null;
|
||||
state.newType = null;
|
||||
setHash('add');
|
||||
renderPane();
|
||||
state.drawerOpen = false;
|
||||
closeDrawer();
|
||||
openTypePanel();
|
||||
return;
|
||||
}
|
||||
if (nav === 'trash' || nav === 'devices' || nav === 'settings') {
|
||||
state.selectedId = null;
|
||||
state.selectedItem = null;
|
||||
state.newType = null;
|
||||
state.drawerOpen = false;
|
||||
state.view = nav;
|
||||
setHash(nav);
|
||||
applyShellViewClass();
|
||||
renderPane();
|
||||
return;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Global "/" shortcut to focus search
|
||||
// Global "/" shortcut to focus search; Esc to close drawer
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === '/' && !isEditableTarget(e.target)) {
|
||||
e.preventDefault();
|
||||
searchInput?.focus();
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Escape' && state.drawerOpen) {
|
||||
closeDrawer();
|
||||
renderListPane();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -345,7 +624,7 @@ function isEditableTarget(target: EventTarget | null): boolean {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sidebar list
|
||||
// Sidebar category nav
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function getFilteredEntries(): Array<[ItemId, ManifestEntry]> {
|
||||
@@ -366,70 +645,100 @@ function getFilteredEntries(): Array<[ItemId, ManifestEntry]> {
|
||||
return filtered;
|
||||
}
|
||||
|
||||
function renderSidebarList(): void {
|
||||
const container = document.getElementById('vault-sidebar-list');
|
||||
function renderSidebarCategories(): void {
|
||||
const container = document.getElementById('vault-categories');
|
||||
if (!container) return;
|
||||
|
||||
const filtered = getFilteredEntries();
|
||||
|
||||
// Group by type
|
||||
const groups = new Map<ItemType, Array<[ItemId, ManifestEntry]>>();
|
||||
for (const entry of filtered) {
|
||||
const t = entry[1].type;
|
||||
if (!groups.has(t)) groups.set(t, []);
|
||||
groups.get(t)!.push(entry);
|
||||
}
|
||||
|
||||
if (filtered.length === 0) {
|
||||
container.innerHTML = '<div class="empty">no items</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '';
|
||||
// Stable type ordering
|
||||
const typeOrder: ItemType[] = ['login', 'secure_note', 'identity', 'card', 'key', 'document', 'totp'];
|
||||
|
||||
const allCount = filtered.length;
|
||||
const isAllActive = !state.activeGroup && state.view === 'list';
|
||||
|
||||
let html = `
|
||||
<button class="vault-category-row ${isAllActive ? 'vault-category-row--active' : ''}" data-group="">
|
||||
<span class="vault-category-row__icon">◈</span>
|
||||
<span class="vault-category-row__label vault-sidebar__category-label">All items</span>
|
||||
<span class="vault-category-row__count vault-sidebar__category-count">${allCount}</span>
|
||||
</button>
|
||||
`;
|
||||
|
||||
for (const t of typeOrder) {
|
||||
const items = groups.get(t);
|
||||
if (!items || items.length === 0) continue;
|
||||
html += `<div class="vault-group-header">${typeIcon(t)} ${escapeHtml(typeLabel(t))}</div>`;
|
||||
for (const [id, e] of items) {
|
||||
const sel = id === state.selectedId ? ' selected' : '';
|
||||
const meta = e.icon_hint ? escapeHtml(e.icon_hint) : '';
|
||||
html += `
|
||||
<div class="vault-entry${sel}" data-id="${escapeHtml(id)}">
|
||||
<span class="vault-entry__title">${escapeHtml(e.title)}</span>
|
||||
${meta ? `<span class="vault-entry__meta">${meta}</span>` : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
const count = filtered.filter(([, e]) => e.type === t).length;
|
||||
// Always show Login (staple type); hide other types when empty.
|
||||
if (count === 0 && t !== 'login') continue;
|
||||
const isActive = state.activeGroup === t;
|
||||
html += `
|
||||
<button class="vault-category-row ${isActive ? 'vault-category-row--active' : ''}" data-group="${t}">
|
||||
<span class="vault-category-row__icon">${typeIcon(t)}</span>
|
||||
<span class="vault-category-row__label vault-sidebar__category-label">${typeLabel(t)}</span>
|
||||
<span class="vault-category-row__count vault-sidebar__category-count">${count}</span>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
container.innerHTML = html;
|
||||
|
||||
// Wire clicks
|
||||
container.querySelectorAll('.vault-entry').forEach((el) => {
|
||||
el.addEventListener('click', async () => {
|
||||
const id = (el as HTMLElement).dataset.id!;
|
||||
await selectItem(id);
|
||||
container.querySelectorAll<HTMLButtonElement>('.vault-category-row').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
state.activeGroup = btn.dataset.group || null;
|
||||
state.drawerOpen = false;
|
||||
state.selectedId = null;
|
||||
state.selectedItem = null;
|
||||
state.view = 'list';
|
||||
setHash('list');
|
||||
applyShellViewClass();
|
||||
renderSidebarCategories();
|
||||
renderListPane();
|
||||
closeDrawer();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function selectItem(id: ItemId): Promise<void> {
|
||||
state.loading = true;
|
||||
const resp = await sendMessage({ type: 'get_item', id });
|
||||
if (resp.ok) {
|
||||
const data = resp.data as { item: Item };
|
||||
state.selectedId = id;
|
||||
state.selectedItem = data.item;
|
||||
state.loading = false;
|
||||
setHash('detail', id);
|
||||
renderSidebarList();
|
||||
renderPane();
|
||||
} else {
|
||||
state.loading = false;
|
||||
state.error = (resp as { error: string }).error;
|
||||
// ---------------------------------------------------------------------------
|
||||
// List pane
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function renderListPane(): void {
|
||||
const pane = document.getElementById('vault-list-pane');
|
||||
if (!pane) return;
|
||||
|
||||
const group = state.activeGroup as ItemType | null;
|
||||
let items = getFilteredEntries();
|
||||
if (group) items = items.filter(([, e]) => e.type === group);
|
||||
|
||||
if (items.length === 0) {
|
||||
pane.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<span class="empty-state__icon" aria-hidden="true">${state.searchQuery ? '⊘' : '◈'}</span>
|
||||
<div class="empty-state__title">${state.searchQuery ? `No results for "${escapeHtml(state.searchQuery)}"` : 'No items yet'}</div>
|
||||
<div class="empty-state__hint">${state.searchQuery ? 'Try a shorter search term.' : 'Click + new item to get started.'}</div>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
pane.innerHTML = items.map(([id, e]) => {
|
||||
const sel = id === state.selectedId ? ' vault-list-row--selected' : '';
|
||||
const subtitle = (e as any).icon_hint ?? (e.tags?.length > 0 ? e.tags.join(', ') : '');
|
||||
const modifiedAgo = e.modified ? relativeTime(e.modified) : '';
|
||||
return `
|
||||
<div class="vault-list-row${sel}" data-id="${escapeHtml(id)}">
|
||||
<div class="vault-list-row__icon" aria-hidden="true">${typeIcon(e.type)}</div>
|
||||
<div class="vault-list-row__text">
|
||||
<div class="vault-list-row__title">${escapeHtml(e.title)}</div>
|
||||
${subtitle ? `<div class="vault-list-row__subtitle">${escapeHtml(subtitle)}</div>` : ''}
|
||||
</div>
|
||||
${modifiedAgo ? `<div class="vault-list-row__age">${escapeHtml(modifiedAgo)}</div>` : ''}
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
pane.querySelectorAll<HTMLElement>('.vault-list-row').forEach((row) => {
|
||||
row.addEventListener('click', async () => {
|
||||
await selectItemForDrawer(row.dataset.id!);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -445,8 +754,8 @@ const SAVE_HINT = isMac ? '⌘+S to save' : 'Ctrl+S to save';
|
||||
|
||||
function renderFormWrapped(app: HTMLElement, mode: 'add' | 'edit'): void {
|
||||
const itemType = state.selectedItem?.type ?? state.newType ?? 'login';
|
||||
const typeLabel = itemType.replace('_', ' ');
|
||||
const titleText = mode === 'add' ? `new ${typeLabel}` : `edit ${typeLabel}`;
|
||||
const typeLabelText = itemType.replace('_', ' ');
|
||||
const titleText = mode === 'add' ? `new ${typeLabelText}` : `edit ${typeLabelText}`;
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'form-pane';
|
||||
wrapper.innerHTML = `
|
||||
@@ -504,6 +813,7 @@ export const __test__ = { renderFormWrapped };
|
||||
function teardownPaneComponents(): void {
|
||||
teardownTrash();
|
||||
teardownDevices();
|
||||
teardownSettings();
|
||||
teardownFieldHistory();
|
||||
teardownBackup();
|
||||
teardownImport();
|
||||
@@ -518,6 +828,7 @@ function renderPane(): void {
|
||||
const route = parseHash();
|
||||
// Keep state.view in sync with hash for components that read it
|
||||
state.view = route.view;
|
||||
applyShellViewClass();
|
||||
|
||||
pane.className = 'vault-pane';
|
||||
|
||||
@@ -553,7 +864,7 @@ function renderPane(): void {
|
||||
renderDevices(pane);
|
||||
break;
|
||||
case 'settings':
|
||||
renderSettings(pane);
|
||||
void renderSettings(pane);
|
||||
break;
|
||||
case 'settings-vault':
|
||||
renderVaultSettingsView(pane);
|
||||
@@ -668,12 +979,15 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||
if (!state.unlocked) return;
|
||||
|
||||
const route = parseHash();
|
||||
state.view = route.view;
|
||||
applyShellViewClass();
|
||||
|
||||
// If navigating to a detail/edit view for an item we already have loaded
|
||||
if ((route.view === 'detail' || route.view === 'edit') && route.id) {
|
||||
if (state.selectedId === route.id && state.selectedItem) {
|
||||
renderPane();
|
||||
renderSidebarList();
|
||||
renderSidebarCategories();
|
||||
if (state.view === 'list') renderListPane();
|
||||
return;
|
||||
}
|
||||
// Need to fetch the item
|
||||
@@ -684,7 +998,30 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||
// For non-item views, just re-render the pane
|
||||
state.selectedId = null;
|
||||
state.selectedItem = null;
|
||||
renderSidebarList();
|
||||
renderSidebarCategories();
|
||||
if (state.view === 'list') renderListPane();
|
||||
renderPane();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Legacy selectItem — used by hash-change deep linking
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function selectItem(id: ItemId): Promise<void> {
|
||||
state.loading = true;
|
||||
const resp = await sendMessage({ type: 'get_item', id });
|
||||
if (resp.ok) {
|
||||
const data = resp.data as { item: Item };
|
||||
state.selectedId = id;
|
||||
state.selectedItem = data.item;
|
||||
state.loading = false;
|
||||
setHash('detail', id);
|
||||
renderSidebarCategories();
|
||||
renderListPane();
|
||||
renderPane();
|
||||
} else {
|
||||
state.loading = false;
|
||||
state.error = (resp as { error: string }).error;
|
||||
}
|
||||
}
|
||||
|
||||
3
extension/src/wasm.d.ts
vendored
3
extension/src/wasm.d.ts
vendored
@@ -79,6 +79,9 @@ declare module 'relicario-wasm' {
|
||||
export function clear_device(): void;
|
||||
export function get_field_history(item_json: string): unknown;
|
||||
|
||||
export function wasm_generate_recovery_qr(handle: SessionHandle, passphrase: string): string;
|
||||
export function wasm_unwrap_recovery_qr(payload_b64: string, passphrase: string): Uint8Array;
|
||||
|
||||
export default function init(module_or_path?: unknown): Promise<void>;
|
||||
export function initSync(args: { module: WebAssembly.Module }): void;
|
||||
}
|
||||
|
||||
81
tools/relay/call.py
Normal file
81
tools/relay/call.py
Normal file
@@ -0,0 +1,81 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
CLI shim: call a relay MCP tool via raw SSE protocol.
|
||||
Usage:
|
||||
python3 call.py read_messages '{"for":"pm"}'
|
||||
python3 call.py post_message '{"from":"pm","to":"dev-a","kind":"directive","body":"..."}'
|
||||
python3 call.py list_pending '{"for":"pm"}'
|
||||
"""
|
||||
import sys, json, threading, requests, time
|
||||
|
||||
BASE = "http://localhost:7331"
|
||||
tool_name = sys.argv[1]
|
||||
args = json.loads(sys.argv[2]) if len(sys.argv) > 2 else {}
|
||||
|
||||
result = {"done": False, "value": None}
|
||||
endpoint_url = {"url": None}
|
||||
msg_id = 1
|
||||
|
||||
def read_sse():
|
||||
with requests.get(f"{BASE}/sse", stream=True, timeout=15) as r:
|
||||
for line in r.iter_lines(decode_unicode=True):
|
||||
if not line:
|
||||
continue
|
||||
if line.startswith("data:"):
|
||||
data = line[5:].strip()
|
||||
if not endpoint_url["url"] and data.startswith("/message"):
|
||||
endpoint_url["url"] = BASE + data
|
||||
elif endpoint_url["url"]:
|
||||
try:
|
||||
payload = json.loads(data)
|
||||
if payload.get("id") == msg_id:
|
||||
result["value"] = payload
|
||||
result["done"] = True
|
||||
return
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
t = threading.Thread(target=read_sse, daemon=True)
|
||||
t.start()
|
||||
|
||||
# Wait for endpoint
|
||||
for _ in range(50):
|
||||
if endpoint_url["url"]:
|
||||
break
|
||||
time.sleep(0.1)
|
||||
if not endpoint_url["url"]:
|
||||
print(json.dumps({"error": "no endpoint received"}))
|
||||
sys.exit(1)
|
||||
|
||||
# Send initialize
|
||||
init_payload = {
|
||||
"jsonrpc": "2.0", "id": 0, "method": "initialize",
|
||||
"params": {
|
||||
"protocolVersion": "2024-11-05",
|
||||
"capabilities": {},
|
||||
"clientInfo": {"name": "relay-cli", "version": "0.1.0"}
|
||||
}
|
||||
}
|
||||
requests.post(endpoint_url["url"], json=init_payload, timeout=5)
|
||||
|
||||
# Send tools/call
|
||||
call_payload = {
|
||||
"jsonrpc": "2.0", "id": msg_id, "method": "tools/call",
|
||||
"params": {"name": tool_name, "arguments": args}
|
||||
}
|
||||
requests.post(endpoint_url["url"], json=call_payload, timeout=5)
|
||||
|
||||
# Wait for result
|
||||
for _ in range(100):
|
||||
if result["done"]:
|
||||
break
|
||||
time.sleep(0.1)
|
||||
|
||||
if result["done"]:
|
||||
content = result["value"].get("result", {}).get("content", [])
|
||||
for item in content:
|
||||
if item.get("type") == "text":
|
||||
print(item["text"])
|
||||
else:
|
||||
print(json.dumps({"error": "timeout waiting for response"}))
|
||||
sys.exit(1)
|
||||
25
tools/relay/call.ts
Normal file
25
tools/relay/call.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* CLI shim: call a relay MCP tool from bash.
|
||||
* Usage:
|
||||
* npx tsx call.ts read_messages '{"for":"pm"}'
|
||||
* npx tsx call.ts post_message '{"from":"pm","to":"dev-a","kind":"directive","body":"..."}'
|
||||
* npx tsx call.ts list_pending '{"for":"pm"}'
|
||||
*/
|
||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
|
||||
|
||||
const [, , toolName, argsJson] = process.argv;
|
||||
if (!toolName) {
|
||||
console.error("Usage: call.ts <tool_name> <args_json>");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const args = argsJson ? JSON.parse(argsJson) : {};
|
||||
const url = new URL("http://localhost:7331/sse");
|
||||
const transport = new SSEClientTransport(url);
|
||||
const client = new Client({ name: "relay-cli", version: "0.1.0" }, { capabilities: {} });
|
||||
|
||||
await client.connect(transport);
|
||||
const result = await client.callTool({ name: toolName, arguments: args });
|
||||
console.log(JSON.stringify(result));
|
||||
await client.close();
|
||||
1701
tools/relay/package-lock.json
generated
Normal file
1701
tools/relay/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
17
tools/relay/package.json
Normal file
17
tools/relay/package.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "@relicario/relay",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "npx tsx server.ts",
|
||||
"test": "node --import=tsx/esm --test queue.test.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.10.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"tsx": "^4.19.0",
|
||||
"@types/node": "^22.0.0"
|
||||
}
|
||||
}
|
||||
59
tools/relay/queue.test.ts
Normal file
59
tools/relay/queue.test.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { describe, it, beforeEach } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { RelayQueue, isRole } from "./queue.ts";
|
||||
|
||||
describe("RelayQueue", () => {
|
||||
let q: RelayQueue;
|
||||
|
||||
beforeEach(() => {
|
||||
q = new RelayQueue();
|
||||
});
|
||||
|
||||
it("post + read roundtrip returns the message with correct fields", () => {
|
||||
q.post("dev-b", "pm", "status", "Task P4 DONE");
|
||||
const msgs = q.read("pm");
|
||||
assert.equal(msgs.length, 1);
|
||||
assert.equal(msgs[0].from, "dev-b");
|
||||
assert.equal(msgs[0].to, "pm");
|
||||
assert.equal(msgs[0].kind, "status");
|
||||
assert.equal(msgs[0].body, "Task P4 DONE");
|
||||
assert.ok(typeof msgs[0].id === "string" && msgs[0].id.length > 0);
|
||||
assert.ok(typeof msgs[0].ts === "string");
|
||||
});
|
||||
|
||||
it("consume-once: second read returns empty", () => {
|
||||
q.post("dev-a", "pm", "question", "Should I use approach A?");
|
||||
q.read("pm");
|
||||
const second = q.read("pm");
|
||||
assert.deepEqual(second, []);
|
||||
});
|
||||
|
||||
it("list_pending does not drain inbox", () => {
|
||||
q.post("dev-b", "pm", "directive", "PROCEED");
|
||||
const before = q.pending("pm");
|
||||
assert.equal(before.count, 1);
|
||||
const after = q.read("pm");
|
||||
assert.equal(after.length, 1);
|
||||
});
|
||||
|
||||
it("FIFO ordering across multiple senders", () => {
|
||||
q.post("dev-a", "pm", "status", "first");
|
||||
q.post("dev-b", "pm", "status", "second");
|
||||
q.post("dev-a", "pm", "question", "third");
|
||||
const msgs = q.read("pm");
|
||||
assert.equal(msgs.length, 3);
|
||||
assert.equal(msgs[0].body, "first");
|
||||
assert.equal(msgs[1].body, "second");
|
||||
assert.equal(msgs[2].body, "third");
|
||||
});
|
||||
|
||||
it("isRole rejects unknown strings", () => {
|
||||
assert.ok(isRole("pm"));
|
||||
assert.ok(isRole("dev-a"));
|
||||
assert.ok(isRole("dev-b"));
|
||||
assert.ok(isRole("dev-c"));
|
||||
assert.ok(!isRole("dev-d"));
|
||||
assert.ok(!isRole(""));
|
||||
assert.ok(!isRole("PM"));
|
||||
});
|
||||
});
|
||||
56
tools/relay/queue.ts
Normal file
56
tools/relay/queue.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
export type Role = "pm" | "dev-a" | "dev-b" | "dev-c";
|
||||
export type MessageKind = "status" | "question" | "directive" | "free";
|
||||
|
||||
export interface RelayMessage {
|
||||
id: string;
|
||||
from: Role;
|
||||
to: Role;
|
||||
kind: MessageKind;
|
||||
body: string;
|
||||
ts: string;
|
||||
}
|
||||
|
||||
const KNOWN_ROLES = new Set<string>(["pm", "dev-a", "dev-b", "dev-c"]);
|
||||
|
||||
export function isRole(s: string): s is Role {
|
||||
return KNOWN_ROLES.has(s);
|
||||
}
|
||||
|
||||
export class RelayQueue {
|
||||
private readonly queues = new Map<Role, RelayMessage[]>([
|
||||
["pm", []],
|
||||
["dev-a", []],
|
||||
["dev-b", []],
|
||||
["dev-c", []],
|
||||
]);
|
||||
|
||||
post(from: Role, to: Role, kind: MessageKind, body: string): RelayMessage {
|
||||
const msg: RelayMessage = {
|
||||
id: randomUUID(),
|
||||
from,
|
||||
to,
|
||||
kind,
|
||||
body,
|
||||
ts: new Date().toISOString(),
|
||||
};
|
||||
this.queues.get(to)!.push(msg);
|
||||
return msg;
|
||||
}
|
||||
|
||||
read(forRole: Role): RelayMessage[] {
|
||||
const inbox = this.queues.get(forRole)!;
|
||||
const messages = [...inbox];
|
||||
inbox.length = 0;
|
||||
return messages;
|
||||
}
|
||||
|
||||
pending(forRole: Role): { count: number; kinds: MessageKind[] } {
|
||||
const inbox = this.queues.get(forRole)!;
|
||||
return {
|
||||
count: inbox.length,
|
||||
kinds: inbox.map((m) => m.kind),
|
||||
};
|
||||
}
|
||||
}
|
||||
163
tools/relay/server.ts
Normal file
163
tools/relay/server.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
||||
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
|
||||
import {
|
||||
CallToolRequestSchema,
|
||||
ListToolsRequestSchema,
|
||||
} from "@modelcontextprotocol/sdk/types.js";
|
||||
import http from "node:http";
|
||||
import { RelayQueue, isRole } from "./queue.ts";
|
||||
|
||||
const PORT = 7331;
|
||||
const queue = new RelayQueue();
|
||||
|
||||
const TOOLS = [
|
||||
{
|
||||
name: "post_message",
|
||||
description:
|
||||
"Push a message to a recipient's inbox. Returns the assigned message id.",
|
||||
inputSchema: {
|
||||
type: "object" as const,
|
||||
properties: {
|
||||
from: {
|
||||
type: "string",
|
||||
enum: ["pm", "dev-a", "dev-b", "dev-c"],
|
||||
description: "Your role name",
|
||||
},
|
||||
to: {
|
||||
type: "string",
|
||||
enum: ["pm", "dev-a", "dev-b", "dev-c"],
|
||||
description: "Recipient role name",
|
||||
},
|
||||
kind: {
|
||||
type: "string",
|
||||
enum: ["status", "question", "directive", "free"],
|
||||
description: "Message type matching the coordination protocol",
|
||||
},
|
||||
body: {
|
||||
type: "string",
|
||||
description: "Message body — freeform markdown, typically the full formatted block",
|
||||
},
|
||||
},
|
||||
required: ["from", "to", "kind", "body"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "read_messages",
|
||||
description:
|
||||
"Pop and return all pending messages for this recipient. Inbox is empty after this call (consume-once).",
|
||||
inputSchema: {
|
||||
type: "object" as const,
|
||||
properties: {
|
||||
for: {
|
||||
type: "string",
|
||||
enum: ["pm", "dev-a", "dev-b", "dev-c"],
|
||||
description: "Your role name",
|
||||
},
|
||||
},
|
||||
required: ["for"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "list_pending",
|
||||
description:
|
||||
"Return count and kinds of pending messages without consuming them.",
|
||||
inputSchema: {
|
||||
type: "object" as const,
|
||||
properties: {
|
||||
for: {
|
||||
type: "string",
|
||||
enum: ["pm", "dev-a", "dev-b", "dev-c"],
|
||||
description: "Your role name",
|
||||
},
|
||||
},
|
||||
required: ["for"],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
function handleToolCall(name: string, args: Record<string, string>) {
|
||||
if (name === "post_message") {
|
||||
if (!isRole(args.from)) {
|
||||
return { content: [{ type: "text" as const, text: `Error: unknown role "${args.from}"` }], isError: true };
|
||||
}
|
||||
if (!isRole(args.to)) {
|
||||
return { content: [{ type: "text" as const, text: `Error: unknown role "${args.to}"` }], isError: true };
|
||||
}
|
||||
const kind = args.kind as "status" | "question" | "directive" | "free";
|
||||
const msg = queue.post(args.from, args.to, kind, args.body);
|
||||
const ts = new Date(msg.ts).toTimeString().slice(0, 8);
|
||||
const preview = args.body.slice(0, 60).replace(/\n/g, " ");
|
||||
const ellipsis = args.body.length > 60 ? "..." : "";
|
||||
process.stdout.write(`[${ts}] ${args.from} → ${args.to} [${kind}] "${preview}${ellipsis}"\n`);
|
||||
return { content: [{ type: "text" as const, text: JSON.stringify({ id: msg.id }) }] };
|
||||
}
|
||||
|
||||
if (name === "read_messages") {
|
||||
if (!isRole(args.for)) {
|
||||
return { content: [{ type: "text" as const, text: `Error: unknown role "${args.for}"` }], isError: true };
|
||||
}
|
||||
const messages = queue.read(args.for);
|
||||
return { content: [{ type: "text" as const, text: JSON.stringify(messages) }] };
|
||||
}
|
||||
|
||||
if (name === "list_pending") {
|
||||
if (!isRole(args.for)) {
|
||||
return { content: [{ type: "text" as const, text: `Error: unknown role "${args.for}"` }], isError: true };
|
||||
}
|
||||
const result = queue.pending(args.for);
|
||||
return { content: [{ type: "text" as const, text: JSON.stringify(result) }] };
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{ type: "text" as const, text: `Error: unknown tool "${name}"` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
function makeServer() {
|
||||
const srv = new Server(
|
||||
{ name: "relay", version: "0.1.0" },
|
||||
{ capabilities: { tools: {} } }
|
||||
);
|
||||
srv.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
|
||||
srv.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
const { name, arguments: args } = request.params;
|
||||
return handleToolCall(name, args as Record<string, string>);
|
||||
});
|
||||
return srv;
|
||||
}
|
||||
|
||||
const transports = new Map<string, SSEServerTransport>();
|
||||
|
||||
const httpServer = http.createServer(async (req, res) => {
|
||||
try {
|
||||
if (req.method === "GET" && req.url === "/sse") {
|
||||
const transport = new SSEServerTransport("/message", res);
|
||||
transports.set(transport.sessionId, transport);
|
||||
transport.onclose = () => transports.delete(transport.sessionId);
|
||||
// Each connection gets its own Server instance so multiple clients can coexist.
|
||||
await makeServer().connect(transport);
|
||||
} else if (req.method === "POST" && req.url?.startsWith("/message")) {
|
||||
const url = new URL(req.url, `http://127.0.0.1:${PORT}`);
|
||||
const sessionId = url.searchParams.get("sessionId") ?? "";
|
||||
const transport = transports.get(sessionId);
|
||||
if (transport) {
|
||||
await transport.handlePostMessage(req, res);
|
||||
} else {
|
||||
res.writeHead(404, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ error: "session not found" }));
|
||||
}
|
||||
} else {
|
||||
res.writeHead(404).end("not found");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[relay] error:", err);
|
||||
if (!res.headersSent) res.writeHead(500).end(String(err));
|
||||
}
|
||||
});
|
||||
|
||||
httpServer.listen(PORT, "127.0.0.1", () => {
|
||||
console.log(`[relay] server ready on :${PORT}`);
|
||||
console.log(`[relay] tools: post_message, read_messages, list_pending`);
|
||||
console.log(`[relay] waiting for connections — Ctrl-C to stop`);
|
||||
});
|
||||
99
tools/relay/start.sh
Executable file
99
tools/relay/start.sh
Executable file
@@ -0,0 +1,99 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(git -C "$SCRIPT_DIR" rev-parse --show-toplevel)"
|
||||
PORT=7331
|
||||
MODE="manual"
|
||||
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--tmux) MODE="tmux" ;;
|
||||
--kitty) MODE="kitty" ;;
|
||||
--manual) MODE="manual" ;;
|
||||
*) echo "Unknown option: $arg" >&2; echo "Usage: $0 [--manual|--tmux|--kitty]" >&2; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Port check
|
||||
if lsof -ti:"$PORT" &>/dev/null; then
|
||||
echo "Error: port $PORT is already in use."
|
||||
echo "Relay already running? Kill it with: kill \$(lsof -ti:$PORT)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Install deps (no-op if node_modules current)
|
||||
cd "$SCRIPT_DIR"
|
||||
npm install --silent
|
||||
|
||||
# Discover latest coordination prompts for instructions
|
||||
COORD_DIR="$REPO_ROOT/docs/superpowers/coordination"
|
||||
PM_PROMPT="$(ls -t "$COORD_DIR"/*-pm-prompt.md 2>/dev/null | head -1 || echo "(none found — run multi-agent-kickoff skill first)")"
|
||||
DEV_A_PROMPT="$(ls -t "$COORD_DIR"/*-dev-a-prompt.md 2>/dev/null | head -1 || echo "(none found)")"
|
||||
DEV_B_PROMPT="$(ls -t "$COORD_DIR"/*-dev-b-prompt.md 2>/dev/null | head -1 || echo "(none found)")"
|
||||
|
||||
print_manual_instructions() {
|
||||
echo ""
|
||||
echo "╔══════════════════════════════════════════════════════════════╗"
|
||||
echo "║ RELAY SERVER — MULTI-AGENT LIFT LAUNCHER ║"
|
||||
echo "╚══════════════════════════════════════════════════════════════╝"
|
||||
echo ""
|
||||
echo "Open 3 new terminals. In each, start Claude Code and paste"
|
||||
echo "the content BELOW the '---' line from the corresponding file."
|
||||
echo ""
|
||||
echo " Terminal 1 (PM): cat '$PM_PROMPT'"
|
||||
echo " Terminal 2 (Dev A): cat '$DEV_A_PROMPT'"
|
||||
echo " Terminal 3 (Dev B): cat '$DEV_B_PROMPT'"
|
||||
echo ""
|
||||
echo "This terminal becomes the relay log. Keep it open."
|
||||
echo ""
|
||||
echo "══════════════════════════════════════════════════════════════"
|
||||
}
|
||||
|
||||
launch_tmux() {
|
||||
SESSION="relay-lift"
|
||||
tmux new-session -d -s "$SESSION" -n "relay" \
|
||||
"cd '$SCRIPT_DIR' && npx tsx server.ts"
|
||||
tmux new-window -t "$SESSION:" -n "pm" "cd '$REPO_ROOT' && claude"
|
||||
tmux new-window -t "$SESSION:" -n "dev-a" "cd '$REPO_ROOT' && claude"
|
||||
tmux new-window -t "$SESSION:" -n "dev-b" "cd '$REPO_ROOT' && claude"
|
||||
echo ""
|
||||
echo "[relay] Opened tmux session '$SESSION' with 4 windows: relay, pm, dev-a, dev-b."
|
||||
echo "[relay] Paste the kickoff prompt into each Claude window."
|
||||
echo " Prompts:"
|
||||
echo " PM: $PM_PROMPT"
|
||||
echo " Dev A: $DEV_A_PROMPT"
|
||||
echo " Dev B: $DEV_B_PROMPT"
|
||||
echo ""
|
||||
tmux attach-session -t "$SESSION"
|
||||
}
|
||||
|
||||
launch_kitty() {
|
||||
kitty @ launch --type=tab --tab-title "relay" -- \
|
||||
bash -c "cd '$SCRIPT_DIR' && npx tsx server.ts"
|
||||
kitty @ launch --type=tab --tab-title "PM" --hold -- \
|
||||
bash -l -i -c "cd '$REPO_ROOT' && claude"
|
||||
kitty @ launch --type=tab --tab-title "Dev-A" --hold -- \
|
||||
bash -l -i -c "cd '$REPO_ROOT' && claude"
|
||||
kitty @ launch --type=tab --tab-title "Dev-B" --hold -- \
|
||||
bash -l -i -c "cd '$REPO_ROOT' && claude"
|
||||
echo ""
|
||||
echo "[relay] Opened kitty tab 'relay' + 3 windows (PM, Dev-A, Dev-B)."
|
||||
echo " Paste the kickoff prompts into each Claude window."
|
||||
echo " PM: $PM_PROMPT"
|
||||
echo " Dev A: $DEV_A_PROMPT"
|
||||
echo " Dev B: $DEV_B_PROMPT"
|
||||
}
|
||||
|
||||
case "$MODE" in
|
||||
manual)
|
||||
print_manual_instructions
|
||||
exec npx tsx "$SCRIPT_DIR/server.ts"
|
||||
;;
|
||||
tmux)
|
||||
launch_tmux
|
||||
;;
|
||||
kitty)
|
||||
launch_kitty
|
||||
;;
|
||||
esac
|
||||
10
tools/relay/tsconfig.json
Normal file
10
tools/relay/tsconfig.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"strict": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user