Compare commits
63 Commits
feature/v0
...
9a8cdf8e4f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
4e9d834920 | ||
|
|
631e9af470 | ||
|
|
b2fc56709a | ||
|
|
b928ed407b | ||
|
|
5d9a7ee8d3 | ||
|
|
6bca0b3526 | ||
|
|
f45c275566 | ||
|
|
3e4312ca6f | ||
|
|
4fc1357368 | ||
|
|
518b41e9cd | ||
|
|
df58b0dda1 | ||
|
|
ed9fcbe6ba | ||
|
|
0172a06698 | ||
|
|
1de7cda1b0 | ||
|
|
6d5a2570d4 | ||
|
|
6d8f699fcb | ||
|
|
25c9eb52a0 | ||
|
|
2df636e454 | ||
|
|
c0921b134d | ||
|
|
575343dc19 | ||
|
|
0443f6a3b4 | ||
|
|
5e8e617a4d | ||
|
|
1c641b4911 | ||
|
|
214e1e49f8 | ||
|
|
af8626fb5f | ||
|
|
9c97f9f939 | ||
|
|
76d092d4f6 | ||
|
|
648dcf386e | ||
|
|
1342228a51 | ||
|
|
8fd9a05875 | ||
|
|
ca059e7507 |
@@ -1,4 +1,10 @@
|
|||||||
{
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"relay": {
|
||||||
|
"type": "sse",
|
||||||
|
"url": "http://localhost:7331/sse"
|
||||||
|
}
|
||||||
|
},
|
||||||
"enabledPlugins": {
|
"enabledPlugins": {
|
||||||
"superpowers@claude-plugins-official": true
|
"superpowers@claude-plugins-official": true
|
||||||
}
|
}
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -7,3 +7,4 @@ extension/dist-firefox/
|
|||||||
extension/wasm/
|
extension/wasm/
|
||||||
reference.jpg
|
reference.jpg
|
||||||
ref.jpg
|
ref.jpg
|
||||||
|
tools/relay/node_modules/
|
||||||
|
|||||||
108
CHANGELOG.md
108
CHANGELOG.md
@@ -1,9 +1,76 @@
|
|||||||
# Changelog
|
# 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
|
### 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
|
- **Sync now button** in the extension settings view — surfaces the
|
||||||
previously hidden `{ type: 'sync' }` SW message to users with success /
|
previously hidden `{ type: 'sync' }` SW message to users with success /
|
||||||
error feedback.
|
error feedback.
|
||||||
@@ -59,6 +126,30 @@
|
|||||||
file `cmd_backup_export` writes on success). Reads "never" for
|
file `cmd_backup_export` writes on success). Reads "never" for
|
||||||
fresh vaults, "4 days ago" otherwise.
|
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
|
### Known limitations
|
||||||
|
|
||||||
- **Mid-restore failure leaves the target remote in a half-written
|
- **Mid-restore failure leaves the target remote in a half-written
|
||||||
@@ -74,6 +165,13 @@
|
|||||||
|
|
||||||
### Internal
|
### 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
|
- Refactored `cmd_add` and `cmd_edit` in the CLI: each `ItemCore` variant
|
||||||
now has its own `build_*_item` / `edit_*` helper. Pure mechanical
|
now has its own `build_*_item` / `edit_*` helper. Pure mechanical
|
||||||
extraction; behavior unchanged. The dispatcher matches and delegates.
|
extraction; behavior unchanged. The dispatcher matches and delegates.
|
||||||
@@ -83,14 +181,6 @@
|
|||||||
`setup.ts` since it walks live wizard state. Setup.ts went from
|
`setup.ts` since it walks live wizard state. Setup.ts went from
|
||||||
1205 → 1137 lines.
|
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
|
## v0.2.0 — 2026-04-27
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
12
CLAUDE.md
12
CLAUDE.md
@@ -48,9 +48,11 @@ crates/
|
|||||||
│ ├── src/helpers.rs # vault_dir, git_command, iso8601
|
│ ├── src/helpers.rs # vault_dir, git_command, iso8601
|
||||||
│ ├── src/session.rs # UnlockedVault (master key in Zeroizing)
|
│ ├── src/session.rs # UnlockedVault (master key in Zeroizing)
|
||||||
│ └── tests/ # basic_flows, edit_and_history, attachments, settings, vault_detection
|
│ └── tests/ # basic_flows, edit_and_history, attachments, settings, vault_detection
|
||||||
└── relicario-wasm/ # WASM bindings for the extension
|
├── relicario-wasm/ # WASM bindings for the extension
|
||||||
├── src/lib.rs # #[wasm_bindgen] surface
|
│ ├── src/lib.rs # #[wasm_bindgen] surface
|
||||||
└── src/session.rs # opaque SessionHandle → Zeroizing<[u8;32]>
|
│ └── 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
|
## 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.
|
- 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.
|
- 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.
|
- Git history is preserved as an audit log — no squashing.
|
||||||
- The CLI shells out to `git` for sync — no libgit2/gitoxide dependency.
|
- 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
|
## 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.
|
||||||
|
|||||||
941
Cargo.lock
generated
941
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -28,10 +28,10 @@ clap_complete = "4"
|
|||||||
image = { version = "0.25", default-features = false, features = ["jpeg", "png"] }
|
image = { version = "0.25", default-features = false, features = ["jpeg", "png"] }
|
||||||
rqrr = "0.7"
|
rqrr = "0.7"
|
||||||
reqwest = { version = "0.12", features = ["blocking", "json"] }
|
reqwest = { version = "0.12", features = ["blocking", "json"] }
|
||||||
|
qrcode = { version = "0.14", features = ["svg"] }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
assert_cmd = "2"
|
assert_cmd = "2"
|
||||||
predicates = "3"
|
predicates = "3"
|
||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
qrcode = "0.14"
|
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
|
|||||||
@@ -196,6 +196,12 @@ enum Commands {
|
|||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
action: DeviceAction,
|
action: DeviceAction,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/// Recovery QR operations — generate or unwrap the 2FA recovery code.
|
||||||
|
RecoveryQr {
|
||||||
|
#[command(subcommand)]
|
||||||
|
cmd: RecoveryQrCmd,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Subcommand)]
|
#[derive(Subcommand)]
|
||||||
@@ -403,6 +409,14 @@ enum DeviceAction {
|
|||||||
List,
|
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<()> {
|
fn main() -> Result<()> {
|
||||||
let cli = Cli::parse();
|
let cli = Cli::parse();
|
||||||
match cli.command {
|
match cli.command {
|
||||||
@@ -436,6 +450,7 @@ fn main() -> Result<()> {
|
|||||||
}
|
}
|
||||||
Commands::Rate { passphrase } => cmd_rate(passphrase),
|
Commands::Rate { passphrase } => cmd_rate(passphrase),
|
||||||
Commands::Device { action } => cmd_device(action),
|
Commands::Device { action } => cmd_device(action),
|
||||||
|
Commands::RecoveryQr { cmd } => cmd_recovery_qr(cmd),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2560,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(())
|
||||||
|
}
|
||||||
|
|||||||
@@ -31,5 +31,6 @@ zstd = { version = "0.13", default-features = false }
|
|||||||
tar = { version = "0.4", default-features = false }
|
tar = { version = "0.4", default-features = false }
|
||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
csv = "1"
|
csv = "1"
|
||||||
|
qrcode = { version = "0.14", default-features = false, features = ["svg"] }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
|
|||||||
@@ -243,6 +243,23 @@ pub fn derive_master_key(
|
|||||||
Ok(output)
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@@ -119,6 +119,10 @@ pub enum RelicarioError {
|
|||||||
/// immediately. Use TOTP instead.
|
/// immediately. Use TOTP instead.
|
||||||
#[error("HOTP is not supported: counter persistence requires vault save after each use")]
|
#[error("HOTP is not supported: counter persistence requires vault save after each use")]
|
||||||
HotpNotSupported,
|
HotpNotSupported,
|
||||||
|
|
||||||
|
/// Recovery QR generation or parsing failed.
|
||||||
|
#[error("recovery QR: {0}")]
|
||||||
|
RecoveryQr(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Crate-wide result alias, reducing boilerplate in function signatures.
|
/// Crate-wide result alias, reducing boilerplate in function signatures.
|
||||||
|
|||||||
@@ -89,3 +89,11 @@ pub use device::{fingerprint, DeviceEntry, RevokedEntry, generate_keypair, sign,
|
|||||||
|
|
||||||
pub mod tar_safe;
|
pub mod tar_safe;
|
||||||
pub use tar_safe::{safe_unpack_git_archive, DEFAULT_MAX_UNCOMPRESSED};
|
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()
|
||||||
|
}
|
||||||
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());
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ mod session;
|
|||||||
mod device;
|
mod device;
|
||||||
|
|
||||||
use wasm_bindgen::prelude::*;
|
use wasm_bindgen::prelude::*;
|
||||||
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
use relicario_core::{derive_master_key, imgsecret, KdfParams};
|
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"))?;
|
.map_err(|_| JsError::new("salt must be exactly 32 bytes"))?;
|
||||||
let master_key = derive_master_key(passphrase.as_bytes(), &image_secret, salt_arr, ¶ms)
|
let master_key = derive_master_key(passphrase.as_bytes(), &image_secret, salt_arr, ¶ms)
|
||||||
.map_err(|e| JsError::new(&e.to_string()))?;
|
.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))
|
Ok(SessionHandle(handle))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -484,6 +486,39 @@ pub fn parse_lastpass_csv_json(csv_bytes: &[u8]) -> Result<String, JsError> {
|
|||||||
Ok(json.to_string())
|
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)]
|
#[cfg(test)]
|
||||||
mod session_tests {
|
mod session_tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -492,7 +527,7 @@ mod session_tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn insert_then_remove_clears_entry() {
|
fn insert_then_remove_clears_entry() {
|
||||||
session::clear();
|
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_ne!(h, 0);
|
||||||
assert!(session::remove(h));
|
assert!(session::remove(h));
|
||||||
assert!(!session::remove(h)); // second remove false
|
assert!(!session::remove(h)); // second remove false
|
||||||
@@ -501,7 +536,7 @@ mod session_tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn with_yields_key_only_while_session_lives() {
|
fn with_yields_key_only_while_session_lives() {
|
||||||
session::clear();
|
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]);
|
let byte = session::with(h, |k| k[0]);
|
||||||
assert_eq!(byte, Some(0x22));
|
assert_eq!(byte, Some(0x22));
|
||||||
session::remove(h);
|
session::remove(h);
|
||||||
@@ -513,7 +548,7 @@ mod session_tests {
|
|||||||
fn manifest_round_trip_via_handle() {
|
fn manifest_round_trip_via_handle() {
|
||||||
use relicario_core::{Manifest, decrypt_manifest};
|
use relicario_core::{Manifest, decrypt_manifest};
|
||||||
session::clear();
|
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 handle = SessionHandle(h);
|
||||||
let key = Zeroizing::new([0x55u8; 32]);
|
let key = Zeroizing::new([0x55u8; 32]);
|
||||||
let empty = Manifest::new();
|
let empty = Manifest::new();
|
||||||
|
|||||||
@@ -6,12 +6,17 @@ use std::cell::RefCell;
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use zeroize::Zeroizing;
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
|
pub struct SessionData {
|
||||||
|
pub master_key: Zeroizing<[u8; 32]>,
|
||||||
|
pub image_secret: Zeroizing<[u8; 32]>,
|
||||||
|
}
|
||||||
|
|
||||||
thread_local! {
|
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) };
|
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 handle = NEXT_HANDLE.with(|n| {
|
||||||
let mut n = n.borrow_mut();
|
let mut n = n.borrow_mut();
|
||||||
let h = *n;
|
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
|
if *n == 0 { *n = 1; } // avoid reserving 0 as a valid handle
|
||||||
h
|
h
|
||||||
});
|
});
|
||||||
SESSIONS.with(|s| { s.borrow_mut().insert(handle, key); });
|
SESSIONS.with(|s| {
|
||||||
|
s.borrow_mut().insert(handle, SessionData { master_key, image_secret });
|
||||||
|
});
|
||||||
handle
|
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>
|
pub fn with<F, R>(handle: u32, f: F) -> Option<R>
|
||||||
where
|
where
|
||||||
F: FnOnce(&Zeroizing<[u8; 32]>) -> R,
|
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 {
|
pub fn remove(handle: u32) -> bool {
|
||||||
|
|||||||
@@ -83,8 +83,9 @@ vault_salt ────────►│ │
|
|||||||
|
|
||||||
┌──────────────────┐
|
┌──────────────────┐
|
||||||
master_key ────────►│ XChaCha20- │──────► manifest.enc
|
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
|
│ 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)
|
## Unlock Flow (every vault operation)
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -48,6 +48,19 @@ When enabled, device authentication provides:
|
|||||||
- **Push access control**: Deploy keys managed via Gitea API
|
- **Push access control**: Deploy keys managed via Gitea API
|
||||||
- **Instant revocation**: One command cuts off both signing and push
|
- **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`.
|
See `docs/superpowers/specs/2026-05-02-device-authentication-design.md`.
|
||||||
|
|
||||||
## Access Control
|
## Access Control
|
||||||
@@ -57,8 +70,7 @@ Without device authentication, access control is transport-layer only:
|
|||||||
- **CLI**: SSH key authentication to git remote
|
- **CLI**: SSH key authentication to git remote
|
||||||
- **Extension**: Git credentials in browser storage
|
- **Extension**: Git credentials in browser storage
|
||||||
|
|
||||||
Device registration was optional before v0.4.0. With device auth enabled,
|
Device registration is optional but recommended for shared vaults.
|
||||||
all commits must be signed by a registered device.
|
|
||||||
|
|
||||||
## Configuration env vars
|
## Configuration env vars
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Architecture overview — Relicario
|
# 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:
|
> 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*.
|
> 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 │
|
│ relicario-core │
|
||||||
│ (Rust, no I/O) │
|
│ (Rust, no I/O) │
|
||||||
│ crypto · items │
|
│ crypto · items │
|
||||||
│ manifest · stego │
|
│ manifest · stego │
|
||||||
└──────────┬──────────┘
|
│ device keys + fp │
|
||||||
│
|
└──┬───────────┬──────┘
|
||||||
┌─────────────────────┼─────────────────────┐
|
│ │
|
||||||
│ │ │
|
┌────────────────┼───────────┴──────┬────────────────────┐
|
||||||
▼ ▼ ▼
|
│ │ │ │
|
||||||
┌────────────────┐ ┌────────────────────┐ (compiled to WASM
|
▼ ▼ ▼ ▼
|
||||||
│ relicario-cli │ │ relicario-wasm │ inside the )
|
┌────────────────┐ ┌──────────────────┐ ┌────────────────────┐
|
||||||
│ (Rust binary) │ │ (#[wasm_bindgen] │ extension │
|
│ relicario-cli │ │ relicario-server │ │ relicario-wasm │
|
||||||
│ │ │ bindings) │ │
|
│ (Rust binary) │ │ (Rust binary) │ │ (#[wasm_bindgen] │
|
||||||
│ filesystem + │ │ │ │
|
│ │ │ │ │ bindings) │
|
||||||
│ git + │ └────────┬───────────┘ │
|
│ filesystem + │ │ pre-receive hook │ │ │
|
||||||
│ clap UX │ │ │
|
│ git + │ │ verify-commit + │ │ compiled to WASM │
|
||||||
└────────────────┘ ▼ │
|
│ clap UX │ │ generate-hook │ │ for the extension │
|
||||||
┌─────────────────────┐ │
|
└────────────────┘ └──────────────────┘ └──────────┬─────────┘
|
||||||
│ extension │ │
|
│
|
||||||
│ (TypeScript) │ │
|
▼
|
||||||
│ popup · vault │ │
|
┌─────────────────────┐
|
||||||
│ setup · content │ │
|
│ extension │
|
||||||
│ service worker │ │
|
│ (TypeScript) │
|
||||||
└─────────────────────┘
|
│ popup · vault │
|
||||||
|
│ setup · content │
|
||||||
|
│ service worker │
|
||||||
|
└─────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
| Codebase | Language | Role | Key boundary |
|
| 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-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-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. |
|
| `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
|
## 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 |
|
| Target | Tool | Output | When to run |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| Native CLI | `cargo build` (debug or `--release`) | `target/{debug,release}/relicario` | After CLI changes; for distribution |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 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 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 |
|
| 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` |
|
| 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) |
|
| 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) |
|
| 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
|
## Summary
|
||||||
|
|
||||||
- **Total findings:** 14
|
- **Total findings:** 14
|
||||||
- **Fixed inline:** 6
|
- **Fixed inline (initial pass):** 6
|
||||||
- **Need user input (proposed only):** 8
|
- **Fixed during v0.5.0 PM run (this audit, follow-up commits):** 8
|
||||||
|
- **No action needed:** 0
|
||||||
- **Top 3 recommendations:**
|
- **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.
|
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).
|
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.
|
**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`".
|
**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
|
**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.
|
**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`.
|
**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
|
**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.
|
**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.`).
|
**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
|
**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).
|
**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).`
|
**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
|
**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.
|
**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`.
|
**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)
|
**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.
|
**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.
|
**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)
|
**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.
|
**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.
|
**Fix:** Add a short paragraph naming the crate and the two subcommands, pointing to the design spec.
|
||||||
**Severity:** nice-to-have
|
**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.
|
**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.
|
**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
|
**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
|
## 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.
|
- `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.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).
|
- `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.
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
# Relicario — Design Specification
|
# 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.
|
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
|
## 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.
|
||||||
13
extension/src/__stubs__/relicario_wasm.stub.ts
Normal file
13
extension/src/__stubs__/relicario_wasm.stub.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
// Stub for the runtime-only WASM module. Used by vitest so that modules
|
||||||
|
// importing relicario_wasm.js can be loaded in a Node/happy-dom environment.
|
||||||
|
// Individual tests that exercise WASM calls should mock the relevant exports.
|
||||||
|
|
||||||
|
export default async function init(): Promise<void> {}
|
||||||
|
export const unlock = (): never => { throw new Error('wasm stub: unlock not mocked'); };
|
||||||
|
export const lock = (): void => {};
|
||||||
|
export const manifest_encrypt = (): never => { throw new Error('wasm stub: manifest_encrypt not mocked'); };
|
||||||
|
export const manifest_decrypt = (): never => { throw new Error('wasm stub: manifest_decrypt not mocked'); };
|
||||||
|
export const settings_encrypt = (): never => { throw new Error('wasm stub: settings_encrypt not mocked'); };
|
||||||
|
export const default_vault_settings_json = (): string => '{}';
|
||||||
|
export const embed_image_secret = (): never => { throw new Error('wasm stub: embed_image_secret not mocked'); };
|
||||||
|
export const register_device = (): never => { throw new Error('wasm stub: register_device not mocked'); };
|
||||||
@@ -12,6 +12,22 @@ vi.mock('../../../shared/state', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
import { sendMessage } from '../../../shared/state';
|
import { sendMessage } from '../../../shared/state';
|
||||||
|
import { DEFAULT_DIGIT_COLOR, DEFAULT_SYMBOL_COLOR } from '../../../shared/color-scheme';
|
||||||
|
|
||||||
|
function mockChromeStorage(initial: Record<string, unknown> = {}) {
|
||||||
|
const store: Record<string, unknown> = { ...initial };
|
||||||
|
(global as any).chrome = {
|
||||||
|
storage: {
|
||||||
|
sync: {
|
||||||
|
get: vi.fn((key: string) => Promise.resolve(
|
||||||
|
key in store ? { [key]: store[key] } : {})),
|
||||||
|
set: vi.fn((kv: Record<string, unknown>) => { Object.assign(store, kv); return Promise.resolve(); }),
|
||||||
|
remove: vi.fn((key: string) => { delete store[key]; return Promise.resolve(); }),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return store;
|
||||||
|
}
|
||||||
|
|
||||||
function settingsResponses() {
|
function settingsResponses() {
|
||||||
// Two parallel calls in renderSettings: get_settings + get_blacklist.
|
// Two parallel calls in renderSettings: get_settings + get_blacklist.
|
||||||
@@ -30,6 +46,7 @@ describe('settings view', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('renders a Sync now button', async () => {
|
it('renders a Sync now button', async () => {
|
||||||
|
mockChromeStorage();
|
||||||
settingsResponses();
|
settingsResponses();
|
||||||
|
|
||||||
await renderSettings(app);
|
await renderSettings(app);
|
||||||
@@ -38,6 +55,7 @@ describe('settings view', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('clicking Sync now sends a sync message and shows feedback on success', async () => {
|
it('clicking Sync now sends a sync message and shows feedback on success', async () => {
|
||||||
|
mockChromeStorage();
|
||||||
settingsResponses();
|
settingsResponses();
|
||||||
(sendMessage as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ ok: true });
|
(sendMessage as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ ok: true });
|
||||||
|
|
||||||
@@ -52,6 +70,7 @@ describe('settings view', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('shows the error when sync fails', async () => {
|
it('shows the error when sync fails', async () => {
|
||||||
|
mockChromeStorage();
|
||||||
settingsResponses();
|
settingsResponses();
|
||||||
(sendMessage as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ ok: false, error: 'remote_unreachable' });
|
(sendMessage as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ ok: false, error: 'remote_unreachable' });
|
||||||
|
|
||||||
@@ -64,3 +83,109 @@ describe('settings view', () => {
|
|||||||
expect(status.textContent).toMatch(/remote_unreachable/);
|
expect(status.textContent).toMatch(/remote_unreachable/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('settings Display section', () => {
|
||||||
|
let app: HTMLElement;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
document.body.innerHTML = '<div id="app"></div>';
|
||||||
|
app = document.getElementById('app')!;
|
||||||
|
(sendMessage as ReturnType<typeof vi.fn>).mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders digit and symbol color pickers with default values when storage is empty', async () => {
|
||||||
|
mockChromeStorage();
|
||||||
|
settingsResponses();
|
||||||
|
|
||||||
|
await renderSettings(app);
|
||||||
|
|
||||||
|
const digitInput = app.querySelector<HTMLInputElement>('#display-digit-color');
|
||||||
|
const symbolInput = app.querySelector<HTMLInputElement>('#display-symbol-color');
|
||||||
|
expect(digitInput).not.toBeNull();
|
||||||
|
expect(symbolInput).not.toBeNull();
|
||||||
|
expect(digitInput!.value).toBe(DEFAULT_DIGIT_COLOR);
|
||||||
|
expect(symbolInput!.value).toBe(DEFAULT_SYMBOL_COLOR);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders pickers with stored values when storage has a scheme', async () => {
|
||||||
|
mockChromeStorage({
|
||||||
|
password_display_scheme: { digit_color: '#112233', symbol_color: '#aabbcc' },
|
||||||
|
});
|
||||||
|
settingsResponses();
|
||||||
|
|
||||||
|
await renderSettings(app);
|
||||||
|
|
||||||
|
const digitInput = app.querySelector<HTMLInputElement>('#display-digit-color');
|
||||||
|
const symbolInput = app.querySelector<HTMLInputElement>('#display-symbol-color');
|
||||||
|
expect(digitInput!.value).toBe('#112233');
|
||||||
|
expect(symbolInput!.value).toBe('#aabbcc');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders a color-preview-swatch element', async () => {
|
||||||
|
mockChromeStorage();
|
||||||
|
settingsResponses();
|
||||||
|
|
||||||
|
await renderSettings(app);
|
||||||
|
|
||||||
|
expect(app.querySelector('#display-swatch')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('changing digit color calls saveColorScheme with updated scheme', async () => {
|
||||||
|
mockChromeStorage();
|
||||||
|
settingsResponses();
|
||||||
|
|
||||||
|
await renderSettings(app);
|
||||||
|
|
||||||
|
const digitInput = app.querySelector<HTMLInputElement>('#display-digit-color')!;
|
||||||
|
digitInput.value = '#ff0000';
|
||||||
|
digitInput.dispatchEvent(new Event('change'));
|
||||||
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
|
||||||
|
const syncSet = (global as any).chrome.storage.sync.set as ReturnType<typeof vi.fn>;
|
||||||
|
expect(syncSet).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
password_display_scheme: expect.objectContaining({ digit_color: '#ff0000' }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('changing symbol color calls saveColorScheme with updated scheme', async () => {
|
||||||
|
mockChromeStorage();
|
||||||
|
settingsResponses();
|
||||||
|
|
||||||
|
await renderSettings(app);
|
||||||
|
|
||||||
|
const symbolInput = app.querySelector<HTMLInputElement>('#display-symbol-color')!;
|
||||||
|
symbolInput.value = '#00ff00';
|
||||||
|
symbolInput.dispatchEvent(new Event('change'));
|
||||||
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
|
||||||
|
const syncSet = (global as any).chrome.storage.sync.set as ReturnType<typeof vi.fn>;
|
||||||
|
expect(syncSet).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
password_display_scheme: expect.objectContaining({ symbol_color: '#00ff00' }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clicking reset calls chrome.storage.sync.remove and restores defaults', async () => {
|
||||||
|
mockChromeStorage({
|
||||||
|
password_display_scheme: { digit_color: '#112233', symbol_color: '#aabbcc' },
|
||||||
|
});
|
||||||
|
settingsResponses();
|
||||||
|
|
||||||
|
await renderSettings(app);
|
||||||
|
|
||||||
|
const resetBtn = app.querySelector<HTMLButtonElement>('#display-reset')!;
|
||||||
|
resetBtn.click();
|
||||||
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
|
||||||
|
const syncRemove = (global as any).chrome.storage.sync.remove as ReturnType<typeof vi.fn>;
|
||||||
|
expect(syncRemove).toHaveBeenCalledWith('password_display_scheme');
|
||||||
|
|
||||||
|
const digitInput = app.querySelector<HTMLInputElement>('#display-digit-color')!;
|
||||||
|
const symbolInput = app.querySelector<HTMLInputElement>('#display-symbol-color')!;
|
||||||
|
expect(digitInput.value).toBe(DEFAULT_DIGIT_COLOR);
|
||||||
|
expect(symbolInput.value).toBe(DEFAULT_SYMBOL_COLOR);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
|
|
||||||
import { sendMessage, escapeHtml } from '../../shared/state';
|
import { sendMessage, escapeHtml } from '../../shared/state';
|
||||||
import type { AttachmentRef, VaultSettings } from '../../shared/types';
|
import type { AttachmentRef, VaultSettings } from '../../shared/types';
|
||||||
|
import { GLYPH_TYPE_DOCUMENT } from '../../shared/glyphs';
|
||||||
|
|
||||||
export type DisclosureMode = 'edit' | 'view';
|
export type DisclosureMode = 'edit' | 'view';
|
||||||
|
|
||||||
@@ -53,8 +54,8 @@ export function renderAttachmentsDisclosure(opts: AttachmentsDisclosureOpts): st
|
|||||||
const action = opts.mode === 'edit' ? '×' : '↓';
|
const action = opts.mode === 'edit' ? '×' : '↓';
|
||||||
const actionClass = opts.mode === 'edit' ? 'attachment-row__remove' : 'attachment-row__download';
|
const actionClass = opts.mode === 'edit' ? 'attachment-row__remove' : 'attachment-row__download';
|
||||||
const iconHtml = isImage(a.mime_type)
|
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__thumb" data-att-id="${escapeHtml(a.id)}" data-mime="${escapeHtml(a.mime_type)}">${GLYPH_TYPE_DOCUMENT}</span>`
|
||||||
: `<span class="attachment-row__icon">📄</span>`;
|
: `<span class="attachment-row__icon">${GLYPH_TYPE_DOCUMENT}</span>`;
|
||||||
return `
|
return `
|
||||||
<div class="attachment-row" data-att-id="${escapeHtml(a.id)}">
|
<div class="attachment-row" data-att-id="${escapeHtml(a.id)}">
|
||||||
${iconHtml}
|
${iconHtml}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
/// Field history view — shows password/concealed field history for an item.
|
/// Field history view — shows password/concealed field history for an item.
|
||||||
|
|
||||||
import { getState, setState, sendMessage, navigate, escapeHtml } from '../../shared/state';
|
import { getState, setState, sendMessage, navigate, escapeHtml } from '../../shared/state';
|
||||||
|
import { colorizePassword } from '../../shared/password-coloring';
|
||||||
import type { FieldHistoryView } from '../../shared/types';
|
import type { FieldHistoryView } from '../../shared/types';
|
||||||
|
import { GLYPH_COPY } from '../../shared/glyphs';
|
||||||
|
|
||||||
function relativeTime(unixSec: number): string {
|
function relativeTime(unixSec: number): string {
|
||||||
const now = Math.floor(Date.now() / 1000);
|
const now = Math.floor(Date.now() / 1000);
|
||||||
@@ -74,7 +76,7 @@ export async function renderFieldHistory(app: HTMLElement): Promise<void> {
|
|||||||
${isCurrent ? '<span class="history-entry__current">current</span>' : ''}
|
${isCurrent ? '<span class="history-entry__current">current</span>' : ''}
|
||||||
<span>${isCurrent ? 'set' : 'changed'} ${relativeTime(timestamp)}</span>
|
<span>${isCurrent ? 'set' : 'changed'} ${relativeTime(timestamp)}</span>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
@@ -103,6 +105,16 @@ export async function renderFieldHistory(app: HTMLElement): Promise<void> {
|
|||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
// Colorize revealed entries: replace plain-text content with colorized spans
|
||||||
|
app.querySelectorAll<HTMLElement>('.history-entry__value.revealed').forEach((el) => {
|
||||||
|
const key = el.closest<HTMLElement>('.history-entry')?.dataset.entry ?? '';
|
||||||
|
const plaintext = valueStore.get(key);
|
||||||
|
if (plaintext !== undefined) {
|
||||||
|
el.textContent = '';
|
||||||
|
el.appendChild(colorizePassword(plaintext));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Wire handlers
|
// Wire handlers
|
||||||
app.querySelector<HTMLButtonElement>('#back-btn')?.addEventListener('click', () => navigate('detail'));
|
app.querySelector<HTMLButtonElement>('#back-btn')?.addEventListener('click', () => navigate('detail'));
|
||||||
|
|
||||||
@@ -129,7 +141,7 @@ export async function renderFieldHistory(app: HTMLElement): Promise<void> {
|
|||||||
const value = valueStore.get(key) ?? '';
|
const value = valueStore.get(key) ?? '';
|
||||||
await navigator.clipboard.writeText(value);
|
await navigator.clipboard.writeText(value);
|
||||||
btn.textContent = '✓';
|
btn.textContent = '✓';
|
||||||
setTimeout(() => { btn.textContent = '📋'; }, 1500);
|
setTimeout(() => { btn.textContent = GLYPH_COPY; }, 1500);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
/// copy click handlers on any rendered rows.
|
/// copy click handlers on any rendered rows.
|
||||||
|
|
||||||
import { escapeHtml } from '../../shared/state';
|
import { escapeHtml } from '../../shared/state';
|
||||||
|
import { colorizePassword } from '../../shared/password-coloring';
|
||||||
import type { Item, Section, Field, FieldValue } from '../../shared/types';
|
import type { Item, Section, Field, FieldValue } from '../../shared/types';
|
||||||
|
|
||||||
export interface RowOpts {
|
export interface RowOpts {
|
||||||
@@ -46,6 +47,7 @@ export interface ConcealedRowOpts {
|
|||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
value: string;
|
value: string;
|
||||||
|
kind?: 'password' | 'concealed';
|
||||||
monospace?: boolean;
|
monospace?: boolean;
|
||||||
multiline?: boolean;
|
multiline?: boolean;
|
||||||
}
|
}
|
||||||
@@ -53,12 +55,15 @@ export interface ConcealedRowOpts {
|
|||||||
/// Concealed row — value rendered hidden until the user clicks "show".
|
/// Concealed row — value rendered hidden until the user clicks "show".
|
||||||
/// Plaintext is stored in `data-field-value` on the row element and copied
|
/// Plaintext is stored in `data-field-value` on the row element and copied
|
||||||
/// to the visible value span on reveal. Copy button always copies plaintext.
|
/// to the visible value span on reveal. Copy button always copies plaintext.
|
||||||
|
/// When `kind` is "password", wireFieldHandlers applies colorizePassword on
|
||||||
|
/// reveal so digits/symbols/letters are rendered in distinct colours.
|
||||||
export function renderConcealedRow(opts: ConcealedRowOpts): string {
|
export function renderConcealedRow(opts: ConcealedRowOpts): string {
|
||||||
const { id, label, value, monospace, multiline } = opts;
|
const { id, label, value, kind, monospace, multiline } = opts;
|
||||||
const placeholder = multiline ? `•••• (${value.length} chars)` : '••••';
|
const placeholder = multiline ? `•••• (${value.length} chars)` : '••••';
|
||||||
const valueClass = `field-row__value${monospace ? ' monospace' : ''}`;
|
const valueClass = `field-row__value${monospace ? ' monospace' : ''}`;
|
||||||
|
const kindAttr = kind ? ` data-field-kind="${escapeHtml(kind)}"` : '';
|
||||||
return `
|
return `
|
||||||
<div class="field-row" data-field-id="${escapeHtml(id)}" data-revealed="false" data-field-value="${escapeHtml(value)}" data-field-multiline="${multiline ? 'true' : 'false'}">
|
<div class="field-row" data-field-id="${escapeHtml(id)}" data-revealed="false" data-field-value="${escapeHtml(value)}" data-field-multiline="${multiline ? 'true' : 'false'}"${kindAttr}>
|
||||||
<span class="field-row__label">${escapeHtml(label)}</span>
|
<span class="field-row__label">${escapeHtml(label)}</span>
|
||||||
<span class="${valueClass}" data-field-role="value">${escapeHtml(placeholder)}</span>
|
<span class="${valueClass}" data-field-role="value">${escapeHtml(placeholder)}</span>
|
||||||
<span class="field-row__actions">
|
<span class="field-row__actions">
|
||||||
@@ -101,7 +106,13 @@ export function wireFieldHandlers(scope: HTMLElement): void {
|
|||||||
row.setAttribute('data-revealed', 'false');
|
row.setAttribute('data-revealed', 'false');
|
||||||
btn.textContent = 'show';
|
btn.textContent = 'show';
|
||||||
} else {
|
} else {
|
||||||
valueEl.textContent = plaintext;
|
const isPassword = row.getAttribute('data-field-kind') === 'password';
|
||||||
|
valueEl.textContent = '';
|
||||||
|
if (isPassword) {
|
||||||
|
valueEl.appendChild(colorizePassword(plaintext));
|
||||||
|
} else {
|
||||||
|
valueEl.textContent = plaintext;
|
||||||
|
}
|
||||||
row.setAttribute('data-revealed', 'true');
|
row.setAttribute('data-revealed', 'true');
|
||||||
btn.textContent = 'hide';
|
btn.textContent = 'hide';
|
||||||
}
|
}
|
||||||
@@ -150,6 +161,7 @@ export function renderSections(item: Item, idPrefix: string): string {
|
|||||||
id: `${idPrefix}-s${sIdx}-f${fIdx}`,
|
id: `${idPrefix}-s${sIdx}-f${fIdx}`,
|
||||||
label: field.label,
|
label: field.label,
|
||||||
value: field.value.value,
|
value: field.value.value,
|
||||||
|
kind: field.value.kind,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
import { sendMessage } from '../../shared/state';
|
import { sendMessage } from '../../shared/state';
|
||||||
import type { GeneratorRequest, VaultSettings } from '../../shared/types';
|
import type { GeneratorRequest, VaultSettings } from '../../shared/types';
|
||||||
|
import { colorizePassword } from '../../shared/password-coloring';
|
||||||
|
|
||||||
interface UiKnobs {
|
interface UiKnobs {
|
||||||
kind: 'random' | 'bip39';
|
kind: 'random' | 'bip39';
|
||||||
@@ -138,7 +139,10 @@ export function openGeneratorPanel(opts: OpenPanelOpts): void {
|
|||||||
const d = resp.data as { password?: string; passphrase?: string };
|
const d = resp.data as { password?: string; passphrase?: string };
|
||||||
currentPreview = d.password ?? d.passphrase ?? '';
|
currentPreview = d.password ?? d.passphrase ?? '';
|
||||||
const el = host.querySelector('.preview__value');
|
const el = host.querySelector('.preview__value');
|
||||||
if (el) el.textContent = currentPreview;
|
if (el) {
|
||||||
|
el.textContent = '';
|
||||||
|
el.appendChild(colorizePassword(currentPreview));
|
||||||
|
}
|
||||||
updateValidation();
|
updateValidation();
|
||||||
}
|
}
|
||||||
}, 150);
|
}, 150);
|
||||||
|
|||||||
@@ -3,15 +3,19 @@
|
|||||||
|
|
||||||
import { navigate, getState, setState, escapeHtml, popOutToTab, isInTab } from '../../shared/state';
|
import { navigate, getState, setState, escapeHtml, popOutToTab, isInTab } from '../../shared/state';
|
||||||
import type { Item, ItemType } from '../../shared/types';
|
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 }> = [
|
const TYPE_OPTIONS: Array<{ type: ItemType; icon: string; label: string; description: string }> = [
|
||||||
{ type: 'login', icon: '🔑', label: 'login' },
|
{ type: 'login', icon: GLYPH_TYPE_LOGIN, label: 'Login', description: 'Username + password' },
|
||||||
{ type: 'secure_note', icon: '📝', label: 'secure note' },
|
{ type: 'secure_note', icon: GLYPH_TYPE_SECURE_NOTE, label: 'Secure Note', description: 'Encrypted text note' },
|
||||||
{ type: 'identity', icon: '👤', label: 'identity' },
|
{ type: 'identity', icon: GLYPH_TYPE_IDENTITY, label: 'Identity', description: 'Personal details' },
|
||||||
{ type: 'card', icon: '💳', label: 'card' },
|
{ type: 'card', icon: GLYPH_TYPE_CARD, label: 'Card', description: 'Credit / debit card' },
|
||||||
{ type: 'key', icon: '🔐', label: 'key' },
|
{ type: 'key', icon: GLYPH_TYPE_KEY, label: 'SSH / API Key', description: 'Keys and tokens' },
|
||||||
{ type: 'document', icon: '📄', label: 'document' },
|
{ type: 'document', icon: GLYPH_TYPE_DOCUMENT, label: 'Document', description: 'File attachment' },
|
||||||
{ type: 'totp', icon: '⏱️', label: 'totp' },
|
{ type: 'totp', icon: GLYPH_TYPE_TOTP, label: 'TOTP', description: '2FA authenticator' },
|
||||||
];
|
];
|
||||||
import * as login from './types/login';
|
import * as login from './types/login';
|
||||||
import * as secureNote from './types/secure-note';
|
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 {
|
function renderTypeSelection(app: HTMLElement): void {
|
||||||
app.innerHTML = `
|
app.innerHTML = `
|
||||||
<div class="pad">
|
<div class="pad">
|
||||||
<div style="display:flex; align-items:center; gap:12px;">
|
<div style="display:flex; align-items:center; gap:12px; margin-bottom:12px;">
|
||||||
<button class="btn" id="back-btn">← back</button>
|
<button class="btn" id="back-btn">◂ back</button>
|
||||||
<h3 style="margin:0;">new item</h3>
|
<span style="font-size:14px; font-weight:600;">New item</span>
|
||||||
<span style="flex:1;"></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>
|
</div>
|
||||||
${isInTab() ? '<div class="form-subtitle">esc to cancel</div>' : '<div style="margin-bottom:16px;"></div>'}
|
<div class="type-card-grid">
|
||||||
<div class="type-select-list">
|
|
||||||
${TYPE_OPTIONS.map((opt) => `
|
${TYPE_OPTIONS.map((opt) => `
|
||||||
<button class="type-select-row" data-type="${opt.type}">
|
<button class="type-card" data-type="${opt.type}">
|
||||||
<span class="type-select-icon">${opt.icon}</span>
|
<span class="type-card__icon" aria-hidden="true">${opt.icon}</span>
|
||||||
<span>${escapeHtml(opt.label)}</span>
|
<span class="type-card__label">${escapeHtml(opt.label)}</span>
|
||||||
|
<span class="type-card__desc">${escapeHtml(opt.description)}</span>
|
||||||
</button>
|
</button>
|
||||||
`).join('')}
|
`).join('')}
|
||||||
</div>
|
</div>
|
||||||
|
<div class="keyhints"><span><kbd>Esc</kbd> back</span></div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
document.getElementById('back-btn')?.addEventListener('click', () => navigate('list'));
|
document.getElementById('back-btn')?.addEventListener('click', () => navigate('list'));
|
||||||
document.getElementById('popout-btn')?.addEventListener('click', popOutToTab);
|
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) => {
|
document.querySelectorAll<HTMLButtonElement>('[data-type]').forEach((btn) => {
|
||||||
btn.addEventListener('click', () => {
|
btn.addEventListener('click', () => {
|
||||||
const type = btn.dataset.type as ItemType;
|
const type = btn.dataset.type as ItemType;
|
||||||
setState({ newType: type });
|
setState({ newType: type });
|
||||||
if (type === 'login' || type === 'secure_note') {
|
renderItemForm(app, 'add');
|
||||||
renderItemForm(app, 'add');
|
|
||||||
} else {
|
|
||||||
popOutToTab();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,13 @@
|
|||||||
/// to the detail view.
|
/// to the detail view.
|
||||||
|
|
||||||
import { getState, setState, sendMessage, navigate, escapeHtml, openVaultTab } from '../../shared/state';
|
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';
|
import type { ItemId, ItemType, ManifestEntry, Item } from '../../shared/types';
|
||||||
|
|
||||||
/// Extract the display hostname from an icon_hint or fallback to the first tag.
|
/// Extract the display hostname from an icon_hint or fallback to the first tag.
|
||||||
@@ -12,30 +19,46 @@ function metaLine(e: ManifestEntry): string {
|
|||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Emoji icon per item type. Placeholder until we ship real SVG icons.
|
/// Glyph icon per item type.
|
||||||
function typeIcon(t: ItemType): string {
|
function typeIcon(t: ItemType): string {
|
||||||
switch (t) {
|
switch (t) {
|
||||||
case 'login': return '🔑';
|
case 'login': return GLYPH_TYPE_LOGIN;
|
||||||
case 'secure_note': return '📝';
|
case 'secure_note': return GLYPH_TYPE_SECURE_NOTE;
|
||||||
case 'identity': return '🪪';
|
case 'identity': return GLYPH_TYPE_IDENTITY;
|
||||||
case 'card': return '💳';
|
case 'card': return GLYPH_TYPE_CARD;
|
||||||
case 'key': return '🗝';
|
case 'key': return GLYPH_TYPE_KEY;
|
||||||
case 'document': return '📄';
|
case 'document': return GLYPH_TYPE_DOCUMENT;
|
||||||
case 'totp': return '⏱';
|
case 'totp': return GLYPH_TYPE_TOTP;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildRowsHtml(): string {
|
function buildRowsHtml(): string {
|
||||||
const state = getState();
|
const state = getState();
|
||||||
const filtered = getFilteredEntries();
|
const filtered = getFilteredEntries();
|
||||||
return filtered.length > 0
|
if (filtered.length > 0) {
|
||||||
? filtered.map(([id, e], i) => `
|
return filtered.map(([id, e], i) => `
|
||||||
<div class="entry-row ${i === state.selectedIndex ? 'selected' : ''}" data-id="${escapeHtml(id)}" data-index="${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>
|
<span class="entry-meta">${escapeHtml(metaLine(e))}</span>
|
||||||
</div>
|
</div>
|
||||||
`).join('')
|
`).join('');
|
||||||
: '<div class="empty">no items</div>';
|
}
|
||||||
|
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 {
|
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="new-btn" style="font-size:11px;">+ new</button>
|
||||||
<button class="btn" id="sync-btn" style="font-size:11px;">sync</button>
|
<button class="btn" id="sync-btn" style="font-size:11px;">sync</button>
|
||||||
<span style="flex:1;"></span>
|
<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="settings-btn" style="font-size:11px;">settings</button>
|
||||||
<button class="btn" id="lock-btn" style="font-size:11px;">lock</button>
|
<button class="btn" id="lock-btn" style="font-size:11px;">lock</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -108,11 +131,14 @@ export function renderItemList(app: HTMLElement): void {
|
|||||||
if (listResp.ok) {
|
if (listResp.ok) {
|
||||||
const data = listResp.data as { items: Array<[ItemId, ManifestEntry]> };
|
const data = listResp.data as { items: Array<[ItemId, ManifestEntry]> };
|
||||||
setState({ entries: data.items, loading: false });
|
setState({ entries: data.items, loading: false });
|
||||||
|
showToast('Synced', 'success');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setState({ loading: false, error: listResp.error });
|
setState({ loading: false, error: listResp.error });
|
||||||
|
showToast(listResp.error ?? 'Sync failed', 'error');
|
||||||
} else {
|
} else {
|
||||||
setState({ loading: false, error: resp.error });
|
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 }> = [
|
const SETTINGS_OPTIONS: Array<{ view: 'settings' | 'settings-vault'; icon: string; label: string }> = [
|
||||||
{ view: 'settings', icon: '🖥', label: 'device settings' },
|
{ view: 'settings', icon: GLYPH_DEVICES, label: 'device settings' },
|
||||||
{ view: 'settings-vault', icon: '🔐', label: 'vault settings' },
|
{ view: 'settings-vault', icon: GLYPH_LOCK, label: 'vault settings' },
|
||||||
];
|
];
|
||||||
|
|
||||||
function showSettingsPicker(anchor: HTMLElement): void {
|
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();
|
||||||
|
}
|
||||||
@@ -2,7 +2,12 @@
|
|||||||
|
|
||||||
import { sendMessage, navigate, escapeHtml } from '../../shared/state';
|
import { sendMessage, navigate, escapeHtml } from '../../shared/state';
|
||||||
import type { DeviceSettings } from '../../shared/types';
|
import type { DeviceSettings } from '../../shared/types';
|
||||||
import { GLYPH_TRASH, GLYPH_DEVICES } from '../../shared/glyphs';
|
import { GLYPH_TRASH, GLYPH_DEVICES, GLYPH_SYNC } from '../../shared/glyphs';
|
||||||
|
import {
|
||||||
|
loadColorScheme, saveColorScheme, resetColorScheme,
|
||||||
|
DEFAULT_DIGIT_COLOR, DEFAULT_SYMBOL_COLOR,
|
||||||
|
} from '../../shared/color-scheme';
|
||||||
|
import { colorizePassword } from '../../shared/password-coloring';
|
||||||
|
|
||||||
export async function renderSettings(app: HTMLElement): Promise<void> {
|
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>';
|
app.innerHTML = '<div class="pad" style="text-align:center; padding-top:20px;"><span class="spinner"></span></div>';
|
||||||
@@ -58,10 +63,13 @@ export async function renderSettings(app: HTMLElement): Promise<void> {
|
|||||||
<div style="margin-bottom:16px;">
|
<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="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="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>
|
<button class="btn" id="sync-now-btn" style="width:100%;margin-bottom:8px;">${GLYPH_SYNC} Sync now</button>
|
||||||
<div id="sync-status" class="muted" style="font-size:12px;min-height:16px;"></div>
|
<div id="sync-status" class="muted" style="font-size:12px;min-height:16px;"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-bottom:16px;" id="display-section-container">
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div style="font-size:12px; color:#8b949e; margin-bottom:6px;">blacklisted sites</div>
|
<div style="font-size:12px; color:#8b949e; margin-bottom:6px;">blacklisted sites</div>
|
||||||
<div id="blacklist-container">
|
<div id="blacklist-container">
|
||||||
@@ -119,4 +127,70 @@ export async function renderSettings(app: HTMLElement): Promise<void> {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
|
||||||
|
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>
|
||||||
|
</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>
|
||||||
|
<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>
|
||||||
|
`;
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
digitInput.addEventListener('change', () => void onColorChange());
|
||||||
|
symbolInput.addEventListener('change', () => void onColorChange());
|
||||||
|
|
||||||
|
document.getElementById('display-reset')?.addEventListener('click', async () => {
|
||||||
|
await resetColorScheme();
|
||||||
|
digitInput.value = DEFAULT_DIGIT_COLOR;
|
||||||
|
symbolInput.value = DEFAULT_SYMBOL_COLOR;
|
||||||
|
updateSwatch(swatch, DEFAULT_DIGIT_COLOR, DEFAULT_SYMBOL_COLOR);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// DEV-B interface contract stub — will be replaced with real teardown logic at merge time
|
||||||
|
export function teardownSettings(): void {
|
||||||
|
// no-op stub
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,10 +2,19 @@
|
|||||||
|
|
||||||
import { getState, setState, sendMessage, navigate, escapeHtml } from '../../shared/state';
|
import { getState, setState, sendMessage, navigate, escapeHtml } from '../../shared/state';
|
||||||
import type { ItemId, ManifestEntry, VaultSettings } from '../../shared/types';
|
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> = {
|
const TYPE_ICONS: Record<string, string> = {
|
||||||
login: '🔑', secure_note: '📝', identity: '👤', card: '💳',
|
login: GLYPH_TYPE_LOGIN,
|
||||||
key: '🔐', document: '📄', totp: '⏱️',
|
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 {
|
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>`
|
? `<p class="muted" style="text-align:center;margin-top:32px;">Trash is empty</p>`
|
||||||
: items.map(([id, entry]) => `
|
: items.map(([id, entry]) => `
|
||||||
<div class="trash-row" data-id="${escapeHtml(id)}">
|
<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">
|
<div class="trash-row__info">
|
||||||
<span class="trash-row__title">${escapeHtml(entry.title)}</span>
|
<span class="trash-row__title">${escapeHtml(entry.title)}</span>
|
||||||
<span class="trash-row__meta">trashed ${relativeTime(entry.trashed_at ?? 0)}</span>
|
<span class="trash-row__meta">trashed ${relativeTime(entry.trashed_at ?? 0)}</span>
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ vi.mock('../../../../setup/setup-helpers', () => ({
|
|||||||
entropyText: vi.fn(() => ''),
|
entropyText: vi.fn(() => ''),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { renderForm } from '../login';
|
import { renderForm, applyGeneratedPassword } from '../login';
|
||||||
import { sendMessage } from '../../../../shared/state';
|
import { sendMessage } from '../../../../shared/state';
|
||||||
|
|
||||||
describe('login form smart inputs', () => {
|
describe('login form smart inputs', () => {
|
||||||
@@ -154,3 +154,37 @@ describe('Login save shape', () => {
|
|||||||
expect(addCall).toBeUndefined();
|
expect(addCall).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('regenerate handler dispatches input event', () => {
|
||||||
|
it('dispatches an InputEvent on the input after value is set', () => {
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.type = 'password';
|
||||||
|
document.body.appendChild(input);
|
||||||
|
|
||||||
|
const dispatchSpy = vi.spyOn(input, 'dispatchEvent');
|
||||||
|
|
||||||
|
applyGeneratedPassword(input, 'sCMtTJkF%GN^mF#-N6D%');
|
||||||
|
|
||||||
|
expect(input.value).toBe('sCMtTJkF%GN^mF#-N6D%');
|
||||||
|
expect(input.type).toBe('text');
|
||||||
|
expect(dispatchSpy).toHaveBeenCalled();
|
||||||
|
const evt = dispatchSpy.mock.calls.find(c => c[0] instanceof InputEvent)?.[0] as InputEvent;
|
||||||
|
expect(evt).toBeDefined();
|
||||||
|
expect(evt.type).toBe('input');
|
||||||
|
expect(evt.bubbles).toBe(true);
|
||||||
|
|
||||||
|
document.body.removeChild(input);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bubbling listener fires when applyGeneratedPassword is called', () => {
|
||||||
|
const input = document.createElement('input');
|
||||||
|
document.body.appendChild(input);
|
||||||
|
|
||||||
|
let listenerFired = false;
|
||||||
|
input.addEventListener('input', () => { listenerFired = true; });
|
||||||
|
applyGeneratedPassword(input, 'newpass');
|
||||||
|
expect(listenerFired).toBe(true);
|
||||||
|
|
||||||
|
document.body.removeChild(input);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
import { getState, setState, sendMessage, navigate, escapeHtml, popOutToTab } from '../../../shared/state';
|
import { getState, setState, sendMessage, navigate, escapeHtml, popOutToTab } from '../../../shared/state';
|
||||||
import { renderFormHeader } from '../form-header';
|
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 type { Item, ItemId, ManifestEntry, Section, AttachmentRef } from '../../../shared/types';
|
||||||
import {
|
import {
|
||||||
renderSectionsEditor, wireSectionsEditor,
|
renderSectionsEditor, wireSectionsEditor,
|
||||||
@@ -76,7 +76,7 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite
|
|||||||
}
|
}
|
||||||
return `
|
return `
|
||||||
<div class="document-primary-row" id="primary-picker">
|
<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__name">${escapeHtml(primaryRef.filename)}</span>
|
||||||
<span class="document-primary-row__meta">${formatBytes(primaryRef.size)}</span>
|
<span class="document-primary-row__meta">${formatBytes(primaryRef.size)}</span>
|
||||||
<span class="document-primary-row__action">↑ change</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="detail-title" style="margin-bottom:12px;">${escapeHtml(item.title)}</div>
|
||||||
|
|
||||||
<div class="document-signature-block" id="doc-sigblock">
|
<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__info">
|
||||||
<div class="document-signature-block__name">${escapeHtml(primaryRef.filename)}</div>
|
<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__meta">${formatBytes(primaryRef.size)} · ${new Date(primaryRef.created * 1000).toISOString().slice(0, 10)}</div>
|
||||||
<div class="document-signature-block__actions">
|
<div class="document-signature-block__actions">
|
||||||
<span id="doc-download" style="cursor:pointer;color:#d2ab43;">↓ download</span>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -29,6 +29,15 @@ import { wireTotpPreview, wireTotpQr } from '../../../shared/form-affordances/to
|
|||||||
import { wireNotesMonoToggle } from '../../../shared/form-affordances/notes-tools';
|
import { wireNotesMonoToggle } from '../../../shared/form-affordances/notes-tools';
|
||||||
import { scheduleRate } from '../../../setup/setup-helpers';
|
import { scheduleRate } from '../../../setup/setup-helpers';
|
||||||
|
|
||||||
|
/// Sets a generated password on an input, reveals it as plain text, then
|
||||||
|
/// dispatches a synthetic InputEvent so listeners (e.g. the strength meter)
|
||||||
|
/// re-evaluate the new value.
|
||||||
|
export function applyGeneratedPassword(input: HTMLInputElement, value: string): void {
|
||||||
|
input.value = value;
|
||||||
|
input.type = 'text';
|
||||||
|
input.dispatchEvent(new InputEvent('input', { bubbles: true }));
|
||||||
|
}
|
||||||
|
|
||||||
/// Called by the dispatcher before each render. Stops any in-flight
|
/// Called by the dispatcher before each render. Stops any in-flight
|
||||||
/// tickers / intervals / listeners the previous view may have attached.
|
/// tickers / intervals / listeners the previous view may have attached.
|
||||||
export function teardown(): void {
|
export function teardown(): void {
|
||||||
@@ -75,7 +84,7 @@ export async function renderDetail(app: HTMLElement, item: Item): Promise<void>
|
|||||||
${renderSignatureBlock({ accent: 'gold', children: sigInner })}
|
${renderSignatureBlock({ accent: 'gold', children: sigInner })}
|
||||||
</div>
|
</div>
|
||||||
${username ? renderRow({ label: 'username', value: username, copyable: true }) : ''}
|
${username ? renderRow({ label: 'username', value: username, copyable: true }) : ''}
|
||||||
${renderConcealedRow({ id: 'login-password', label: 'password', value: password })}
|
${renderConcealedRow({ id: 'login-password', label: 'password', value: password, kind: 'password' })}
|
||||||
${url ? renderRow({ label: 'url', value: url, href: url }) : ''}
|
${url ? renderRow({ label: 'url', value: url, href: url }) : ''}
|
||||||
${hasTotp ? `
|
${hasTotp ? `
|
||||||
<div class="field-row">
|
<div class="field-row">
|
||||||
@@ -348,19 +357,21 @@ export function renderForm(
|
|||||||
|
|
||||||
${sectionsHtml}
|
${sectionsHtml}
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="${surface === 'fullscreen' ? 'form-lower' : ''}">
|
||||||
<div class="notes-with-toggle">
|
<div class="form-group">
|
||||||
<label class="label" for="f-notes" style="margin:0;flex:1;">notes</label>
|
<div class="notes-with-toggle">
|
||||||
<button id="notes-mono-btn" class="glyph-btn" type="button" title="toggle monospace">≡</button>
|
<label class="label" for="f-notes" style="margin:0;flex:1;">notes</label>
|
||||||
|
<button id="notes-mono-btn" class="glyph-btn" type="button" title="toggle monospace">≡</button>
|
||||||
|
</div>
|
||||||
|
<textarea id="f-notes" placeholder="recovery codes, security questions...">${escapeHtml(notes)}</textarea>
|
||||||
</div>
|
</div>
|
||||||
<textarea id="f-notes" placeholder="recovery codes, security questions...">${escapeHtml(notes)}</textarea>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
${renderSectionsEditor(sectionsDraft, sectionsExpanded)}
|
${renderSectionsEditor(sectionsDraft, sectionsExpanded)}
|
||||||
${isInTab() ? renderAttachmentsDisclosure({ itemId: existing?.id ?? '', attachments: attachmentsDraft, mode: 'edit' }) : ''}
|
${isInTab() ? renderAttachmentsDisclosure({ itemId: existing?.id ?? '', attachments: attachmentsDraft, mode: 'edit' }) : ''}
|
||||||
<div class="form-actions" ${externalActions ? 'hidden' : ''}>
|
<div class="form-actions" ${externalActions ? 'hidden' : ''}>
|
||||||
<button class="btn" id="cancel-btn">cancel</button>
|
<button class="btn" id="cancel-btn">cancel</button>
|
||||||
<button class="btn btn-primary" id="save-btn">${state.loading ? '<span class="spinner"></span>' : 'save'}</button>
|
<button class="btn btn-primary" id="save-btn">${state.loading ? '<span class="spinner"></span>' : 'save'}</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@@ -433,7 +444,7 @@ export function renderForm(
|
|||||||
context: 'fill-field',
|
context: 'fill-field',
|
||||||
onPicked: (value) => {
|
onPicked: (value) => {
|
||||||
const pw = document.getElementById('f-password') as HTMLInputElement | null;
|
const pw = document.getElementById('f-password') as HTMLInputElement | null;
|
||||||
if (pw) { pw.value = value; pw.type = 'text'; }
|
if (pw) applyGeneratedPassword(pw, value);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
/// Navigation works by updating `currentState` and calling `render()`.
|
/// Navigation works by updating `currentState` and calling `render()`.
|
||||||
|
|
||||||
import type { Request, Response } from '../shared/messages';
|
import type { Request, Response } from '../shared/messages';
|
||||||
|
import { lookupErrorCopy } from '../shared/error-copy';
|
||||||
import type { ItemId, ManifestEntry, Item } from '../shared/types';
|
import type { ItemId, ManifestEntry, Item } from '../shared/types';
|
||||||
import { registerHost } from '../shared/state';
|
import { registerHost } from '../shared/state';
|
||||||
import { renderUnlock } from './components/unlock';
|
import { renderUnlock } from './components/unlock';
|
||||||
@@ -18,6 +19,7 @@ import { renderFieldHistory } from './components/field-history';
|
|||||||
import { teardown as teardownTrash } from './components/trash';
|
import { teardown as teardownTrash } from './components/trash';
|
||||||
import { teardown as teardownDevices } from './components/devices';
|
import { teardown as teardownDevices } from './components/devices';
|
||||||
import { teardown as teardownFieldHistory } from './components/field-history';
|
import { teardown as teardownFieldHistory } from './components/field-history';
|
||||||
|
import { applyColorScheme } from '../shared/color-scheme';
|
||||||
|
|
||||||
// --- Escape HTML to prevent XSS ---
|
// --- Escape HTML to prevent XSS ---
|
||||||
export function escapeHtml(str: string): string {
|
export function escapeHtml(str: string): string {
|
||||||
@@ -144,19 +146,8 @@ export function humanizeError(err: string): string {
|
|||||||
if (/settings json:/i.test(err)) {
|
if (/settings json:/i.test(err)) {
|
||||||
return 'Settings are in an invalid format — try reloading the extension.';
|
return 'Settings are in an invalid format — try reloading the extension.';
|
||||||
}
|
}
|
||||||
if (/vault_locked/i.test(err)) {
|
const copy = lookupErrorCopy(err);
|
||||||
return 'Vault is locked. Unlock and try again.';
|
return copy.body;
|
||||||
}
|
|
||||||
if (/origin_mismatch/i.test(err)) {
|
|
||||||
return 'This login belongs to a different site — refusing to leak credentials cross-origin.';
|
|
||||||
}
|
|
||||||
if (/unauthorized_sender/i.test(err)) {
|
|
||||||
return 'This action is not allowed from here.';
|
|
||||||
}
|
|
||||||
if (/tab_navigated|captured_tab_gone/i.test(err)) {
|
|
||||||
return 'The browser tab changed before the fill could complete — try again.';
|
|
||||||
}
|
|
||||||
return err;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Navigation ---
|
// --- Navigation ---
|
||||||
@@ -225,6 +216,14 @@ function render(): void {
|
|||||||
// --- Init ---
|
// --- Init ---
|
||||||
|
|
||||||
async function init(): Promise<void> {
|
async function init(): Promise<void> {
|
||||||
|
await applyColorScheme();
|
||||||
|
|
||||||
|
chrome.storage.onChanged.addListener((changes, area) => {
|
||||||
|
if (area === 'sync' && 'password_display_scheme' in changes) {
|
||||||
|
void applyColorScheme();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Snapshot the active tab at popup-open — the fill path uses this
|
// Snapshot the active tab at popup-open — the fill path uses this
|
||||||
// tabId/url pair so the SW can verify the tab hasn't navigated before
|
// tabId/url pair so the SW can verify the tab hasn't navigated before
|
||||||
// forwarding credentials (audit M5 + TOCTOU close via expectedHost).
|
// forwarding credentials (audit M5 + TOCTOU close via expectedHost).
|
||||||
|
|||||||
@@ -38,6 +38,10 @@
|
|||||||
|
|
||||||
/* Focus */
|
/* Focus */
|
||||||
--focus-ring: 0 0 0 2px var(--gold-ring);
|
--focus-ring: 0 0 0 2px var(--gold-ring);
|
||||||
|
|
||||||
|
/* Password coloring (P1) */
|
||||||
|
--relicario-pwd-digit-color: #2563eb;
|
||||||
|
--relicario-pwd-symbol-color: #dc2626;
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
@@ -420,6 +424,41 @@ textarea {
|
|||||||
background: #aa812a;
|
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 */
|
||||||
.spinner {
|
.spinner {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
@@ -1554,3 +1593,109 @@ textarea {
|
|||||||
.logo-lockup .brand-logo { width: 42px; height: 42px; margin: 0 auto 10px; }
|
.logo-lockup .brand-logo { width: 42px; height: 42px; margin: 0 auto 10px; }
|
||||||
.logo-lockup .brand { font-size: 17px; font-weight: 600; color: var(--gold-text); letter-spacing: 0.5px; }
|
.logo-lockup .brand { font-size: 17px; font-weight: 600; color: var(--gold-text); letter-spacing: 0.5px; }
|
||||||
.tagline { color: var(--text-dim); font-size: 11px; margin-top: 4px; letter-spacing: 0.3px; }
|
.tagline { color: var(--text-dim); font-size: 11px; margin-top: 4px; letter-spacing: 0.3px; }
|
||||||
|
|
||||||
|
/* Password character-class coloring */
|
||||||
|
.pwd-digit { color: var(--relicario-pwd-digit-color); }
|
||||||
|
.pwd-symbol { color: var(--relicario-pwd-symbol-color); }
|
||||||
|
.pwd-letter { color: inherit; }
|
||||||
|
|
||||||
|
.color-preview-swatch {
|
||||||
|
font-family: ui-monospace, monospace;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid var(--border-mid);
|
||||||
|
border-radius: 4px;
|
||||||
|
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; }
|
||||||
|
|||||||
@@ -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': {
|
case 'import_lastpass_commit': {
|
||||||
const handle = session.getCurrent();
|
const handle = session.getCurrent();
|
||||||
if (!handle || !state.gitHost || !state.manifest) return { ok: false, error: 'vault_locked' };
|
if (!handle || !state.gitHost || !state.manifest) return { ok: false, error: 'vault_locked' };
|
||||||
|
|||||||
37
extension/src/setup/__tests__/setup.test.ts
Normal file
37
extension/src/setup/__tests__/setup.test.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { finishSetup } from '../setup';
|
||||||
|
|
||||||
|
describe('finishSetup', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
(global as any).chrome = {
|
||||||
|
tabs: {
|
||||||
|
create: vi.fn(() => Promise.resolve({ id: 999 })),
|
||||||
|
getCurrent: vi.fn(() => Promise.resolve({ id: 42 })),
|
||||||
|
remove: vi.fn(() => Promise.resolve()),
|
||||||
|
},
|
||||||
|
runtime: {
|
||||||
|
getURL: vi.fn((p: string) => `chrome-extension://abc/${p}`),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens vault.html in a new tab', async () => {
|
||||||
|
await finishSetup();
|
||||||
|
expect(chrome.runtime.getURL).toHaveBeenCalledWith('vault.html');
|
||||||
|
expect(chrome.tabs.create).toHaveBeenCalledWith({
|
||||||
|
url: 'chrome-extension://abc/vault.html',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('closes the current setup tab after opening the vault tab', async () => {
|
||||||
|
await finishSetup();
|
||||||
|
expect(chrome.tabs.getCurrent).toHaveBeenCalled();
|
||||||
|
expect(chrome.tabs.remove).toHaveBeenCalledWith(42);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still opens the vault tab even if closing the setup tab fails', async () => {
|
||||||
|
(chrome.tabs.remove as any).mockRejectedValueOnce(new Error('no permission'));
|
||||||
|
await expect(finishSetup()).resolves.not.toThrow();
|
||||||
|
expect(chrome.tabs.create).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -93,6 +93,17 @@ const state: WizardState = {
|
|||||||
deviceName: '',
|
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) ---
|
// --- State-coupled helpers (pure helpers live in ./setup-helpers.ts) ---
|
||||||
|
|
||||||
/// Update just the meter DOM without a full re-render (so the input keeps
|
/// 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');
|
const app = document.getElementById('app');
|
||||||
if (!app) return;
|
if (!app) return;
|
||||||
|
|
||||||
const progressHtml = `
|
const progressHtml = renderProgressTrack(state.step);
|
||||||
<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>
|
|
||||||
`;
|
|
||||||
|
|
||||||
let stepHtml = '';
|
let stepHtml = '';
|
||||||
switch (state.step) {
|
switch (state.step) {
|
||||||
@@ -224,6 +226,7 @@ function renderStep0(): string {
|
|||||||
</p>
|
</p>
|
||||||
<div class="mode-cards">
|
<div class="mode-cards">
|
||||||
<button class="mode-card glass ${isNew ? 'active' : ''}" data-mode="new">
|
<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>
|
<div class="mode-card-title">create new vault</div>
|
||||||
<p class="mode-card-blurb">
|
<p class="mode-card-blurb">
|
||||||
I'm setting up Relicario for the first time. This will create a fresh
|
I'm setting up Relicario for the first time. This will create a fresh
|
||||||
@@ -231,6 +234,7 @@ function renderStep0(): string {
|
|||||||
</p>
|
</p>
|
||||||
</button>
|
</button>
|
||||||
<button class="mode-card glass ${isAttach ? 'active' : ''}" data-mode="attach">
|
<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>
|
<div class="mode-card-title">attach this device</div>
|
||||||
<p class="mode-card-blurb">
|
<p class="mode-card-blurb">
|
||||||
I already have a vault on another device. Connect this browser to it
|
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 configJson = JSON.stringify(config, null, 2);
|
||||||
const isAttach = state.mode === 'attach';
|
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 `
|
return `
|
||||||
<div class="wizard-step glass" style="padding: 24px; margin-top: 16px;">
|
<div class="wizard-step glass" style="padding: 24px; margin-top: 16px;">
|
||||||
<div class="success-box">
|
<div class="success-box">
|
||||||
@@ -992,6 +1012,8 @@ function renderStep5(): string {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
${qrBannerHtml}
|
||||||
|
|
||||||
${isAttach ? '' : `
|
${isAttach ? '' : `
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="label">reference image</label>
|
<label class="label">reference image</label>
|
||||||
@@ -1026,6 +1048,48 @@ function renderStep5(): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function attachStep5(): void {
|
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', () => {
|
document.getElementById('download-ref-btn')?.addEventListener('click', () => {
|
||||||
if (!state.referenceImageBytes) return;
|
if (!state.referenceImageBytes) return;
|
||||||
const blob = new Blob([state.referenceImageBytes.buffer as ArrayBuffer], { type: 'image/jpeg' });
|
const blob = new Blob([state.referenceImageBytes.buffer as ArrayBuffer], { type: 'image/jpeg' });
|
||||||
@@ -1101,6 +1165,7 @@ function attachStep5(): void {
|
|||||||
|
|
||||||
state.configPushed = true;
|
state.configPushed = true;
|
||||||
render();
|
render();
|
||||||
|
void finishSetup();
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
console.error('[relicario setup] register device failed:', err);
|
console.error('[relicario setup] register device failed:', err);
|
||||||
state.error = `Failed to register device: ${err instanceof Error ? err.message : String(err)}`;
|
state.error = `Failed to register device: ${err instanceof Error ? err.message : String(err)}`;
|
||||||
@@ -1131,6 +1196,23 @@ function attachStep5(): void {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Completion handoff ---
|
||||||
|
|
||||||
|
/// Open the fullscreen vault tab and best-effort close the setup tab.
|
||||||
|
export async function finishSetup(): Promise<void> {
|
||||||
|
const vaultUrl = chrome.runtime.getURL('vault.html');
|
||||||
|
await chrome.tabs.create({ url: vaultUrl });
|
||||||
|
try {
|
||||||
|
const current = await chrome.tabs.getCurrent();
|
||||||
|
if (current?.id !== undefined) {
|
||||||
|
await chrome.tabs.remove(current.id);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Setup tab may not be closeable (e.g., opened as popup rather than a tab).
|
||||||
|
// The vault tab is open — that's the user-visible success.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- Boot ---
|
// --- Boot ---
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
|||||||
76
extension/src/shared/__tests__/color-scheme.test.ts
Normal file
76
extension/src/shared/__tests__/color-scheme.test.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import {
|
||||||
|
loadColorScheme, saveColorScheme, resetColorScheme, applyColorScheme,
|
||||||
|
DEFAULT_DIGIT_COLOR, DEFAULT_SYMBOL_COLOR,
|
||||||
|
} from '../color-scheme';
|
||||||
|
|
||||||
|
function mockChromeStorage(initial: any = {}) {
|
||||||
|
const store = { ...initial };
|
||||||
|
(global as any).chrome = {
|
||||||
|
storage: {
|
||||||
|
sync: {
|
||||||
|
get: vi.fn((key: string) => Promise.resolve(
|
||||||
|
key in store ? { [key]: store[key] } : {})),
|
||||||
|
set: vi.fn((kv: any) => { Object.assign(store, kv); return Promise.resolve(); }),
|
||||||
|
remove: vi.fn((key: string) => { delete store[key]; return Promise.resolve(); }),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return store;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('color-scheme storage', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
// happy-dom provides document globally; reset inline styles between tests
|
||||||
|
document.documentElement.removeAttribute('style');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('load returns defaults when storage is empty', async () => {
|
||||||
|
mockChromeStorage();
|
||||||
|
const scheme = await loadColorScheme();
|
||||||
|
expect(scheme.digit_color).toBe(DEFAULT_DIGIT_COLOR);
|
||||||
|
expect(scheme.symbol_color).toBe(DEFAULT_SYMBOL_COLOR);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('load returns stored values when present', async () => {
|
||||||
|
mockChromeStorage({
|
||||||
|
password_display_scheme: { digit_color: '#123456', symbol_color: '#abcdef' },
|
||||||
|
});
|
||||||
|
const scheme = await loadColorScheme();
|
||||||
|
expect(scheme.digit_color).toBe('#123456');
|
||||||
|
expect(scheme.symbol_color).toBe('#abcdef');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('save round-trips', async () => {
|
||||||
|
mockChromeStorage();
|
||||||
|
await saveColorScheme({ digit_color: '#111111', symbol_color: '#222222' });
|
||||||
|
const scheme = await loadColorScheme();
|
||||||
|
expect(scheme).toEqual({ digit_color: '#111111', symbol_color: '#222222' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reset removes the storage key', async () => {
|
||||||
|
const store = mockChromeStorage({
|
||||||
|
password_display_scheme: { digit_color: '#000000', symbol_color: '#ffffff' },
|
||||||
|
});
|
||||||
|
await resetColorScheme();
|
||||||
|
expect(store.password_display_scheme).toBeUndefined();
|
||||||
|
const scheme = await loadColorScheme();
|
||||||
|
expect(scheme.digit_color).toBe(DEFAULT_DIGIT_COLOR);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('apply sets CSS custom properties on document.documentElement', async () => {
|
||||||
|
mockChromeStorage({
|
||||||
|
password_display_scheme: { digit_color: '#deadbe', symbol_color: '#feed00' },
|
||||||
|
});
|
||||||
|
await applyColorScheme();
|
||||||
|
const root = document.documentElement.style;
|
||||||
|
expect(root.getPropertyValue('--relicario-pwd-digit-color').trim()).toBe('#deadbe');
|
||||||
|
expect(root.getPropertyValue('--relicario-pwd-symbol-color').trim()).toBe('#feed00');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('save rejects malformed hex values', async () => {
|
||||||
|
mockChromeStorage();
|
||||||
|
await expect(saveColorScheme({ digit_color: 'not-a-color', symbol_color: '#ffffff' }))
|
||||||
|
.rejects.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
44
extension/src/shared/__tests__/error-copy.test.ts
Normal file
44
extension/src/shared/__tests__/error-copy.test.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { execSync } from 'node:child_process';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import { ERROR_COPY, lookupErrorCopy } from '../error-copy';
|
||||||
|
|
||||||
|
const repoRoot = resolve(__dirname, '../../../..');
|
||||||
|
|
||||||
|
function discoverCodes(): Set<string> {
|
||||||
|
const out = execSync(
|
||||||
|
`grep -rohE "ok: false, error: '[^']+'" extension/src/service-worker/ \
|
||||||
|
--include="*.ts" --exclude-dir=__tests__`,
|
||||||
|
{ cwd: repoRoot, encoding: 'utf-8' },
|
||||||
|
);
|
||||||
|
const codes = new Set<string>();
|
||||||
|
for (const line of out.split('\n')) {
|
||||||
|
const m = line.match(/error: '([^']+)'/);
|
||||||
|
if (m) codes.add(m[1]);
|
||||||
|
}
|
||||||
|
return codes;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ERROR_COPY', () => {
|
||||||
|
it('contains an entry for every error code returned by the service worker', () => {
|
||||||
|
const discovered = discoverCodes();
|
||||||
|
expect(discovered.size).toBeGreaterThan(0);
|
||||||
|
const missing: string[] = [];
|
||||||
|
for (const code of discovered) {
|
||||||
|
if (!ERROR_COPY[code]) missing.push(code);
|
||||||
|
}
|
||||||
|
expect(missing).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lookupErrorCopy returns the mapped entry for known codes', () => {
|
||||||
|
const copy = lookupErrorCopy('vault_locked');
|
||||||
|
expect(copy.title).toBe('Vault locked');
|
||||||
|
expect(copy.body).toMatch(/unlock/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lookupErrorCopy falls back to a generic shape for unknown codes', () => {
|
||||||
|
const copy = lookupErrorCopy('made_up_code_xyz');
|
||||||
|
expect(copy.title).toBe('Something went wrong');
|
||||||
|
expect(copy.body).toContain('made_up_code_xyz');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -41,3 +41,30 @@ describe('glyph constants', () => {
|
|||||||
expect(GLYPH_NEXT).toBe('▸');
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
60
extension/src/shared/__tests__/password-coloring.test.ts
Normal file
60
extension/src/shared/__tests__/password-coloring.test.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { colorizePassword, PWD_DIGIT, PWD_SYMBOL, PWD_LETTER } from '../password-coloring';
|
||||||
|
|
||||||
|
describe('colorizePassword', () => {
|
||||||
|
|
||||||
|
function classes(frag: DocumentFragment): string[] {
|
||||||
|
return Array.from(frag.querySelectorAll('span')).map(s => s.className);
|
||||||
|
}
|
||||||
|
function texts(frag: DocumentFragment): string[] {
|
||||||
|
return Array.from(frag.querySelectorAll('span')).map(s => s.textContent ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
it('returns empty fragment for empty input', () => {
|
||||||
|
const frag = colorizePassword('');
|
||||||
|
expect(frag.childNodes.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('classifies a mixed-class run', () => {
|
||||||
|
const frag = colorizePassword('aB3$xY');
|
||||||
|
expect(classes(frag)).toEqual([PWD_LETTER, PWD_DIGIT, PWD_SYMBOL, PWD_LETTER]);
|
||||||
|
expect(texts(frag)).toEqual(['aB', '3', '$', 'xY']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('all-letters produces a single letter span', () => {
|
||||||
|
const frag = colorizePassword('passwd');
|
||||||
|
expect(classes(frag)).toEqual([PWD_LETTER]);
|
||||||
|
expect(texts(frag)).toEqual(['passwd']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('all-digits produces a single digit span', () => {
|
||||||
|
const frag = colorizePassword('123456');
|
||||||
|
expect(classes(frag)).toEqual([PWD_DIGIT]);
|
||||||
|
expect(texts(frag)).toEqual(['123456']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('all-symbols produces a single symbol span', () => {
|
||||||
|
const frag = colorizePassword('!@#$%^');
|
||||||
|
expect(classes(frag)).toEqual([PWD_SYMBOL]);
|
||||||
|
expect(texts(frag)).toEqual(['!@#$%^']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('classifies unicode letters as letters', () => {
|
||||||
|
const frag = colorizePassword('áñü');
|
||||||
|
expect(classes(frag)).toEqual([PWD_LETTER]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('classifies whitespace as symbol', () => {
|
||||||
|
const frag = colorizePassword('a b');
|
||||||
|
expect(classes(frag)).toEqual([PWD_LETTER, PWD_SYMBOL, PWD_LETTER]);
|
||||||
|
expect(texts(frag)).toEqual(['a', ' ', 'b']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('representative password snapshot: aB3$xY7&_!', () => {
|
||||||
|
const frag = colorizePassword('aB3$xY7&_!');
|
||||||
|
expect(classes(frag)).toEqual([
|
||||||
|
PWD_LETTER, PWD_DIGIT, PWD_SYMBOL, PWD_LETTER, PWD_DIGIT, PWD_SYMBOL,
|
||||||
|
]);
|
||||||
|
expect(texts(frag)).toEqual(['aB', '3', '$', 'xY', '7', '&_!']);
|
||||||
|
});
|
||||||
|
});
|
||||||
48
extension/src/shared/color-scheme.ts
Normal file
48
extension/src/shared/color-scheme.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
export const DEFAULT_DIGIT_COLOR = '#2563eb';
|
||||||
|
export const DEFAULT_SYMBOL_COLOR = '#dc2626';
|
||||||
|
const STORAGE_KEY = 'password_display_scheme';
|
||||||
|
const HEX_RE = /^#[0-9a-fA-F]{6}$/;
|
||||||
|
|
||||||
|
export interface ColorScheme {
|
||||||
|
digit_color: string;
|
||||||
|
symbol_color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_SCHEME: ColorScheme = {
|
||||||
|
digit_color: DEFAULT_DIGIT_COLOR,
|
||||||
|
symbol_color: DEFAULT_SYMBOL_COLOR,
|
||||||
|
};
|
||||||
|
|
||||||
|
function isValid(s: ColorScheme): boolean {
|
||||||
|
return HEX_RE.test(s.digit_color) && HEX_RE.test(s.symbol_color);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadColorScheme(): Promise<ColorScheme> {
|
||||||
|
const result = await chrome.storage.sync.get(STORAGE_KEY);
|
||||||
|
const stored = result[STORAGE_KEY] as Partial<ColorScheme> | undefined;
|
||||||
|
if (!stored) return { ...DEFAULT_SCHEME };
|
||||||
|
return {
|
||||||
|
digit_color: typeof stored.digit_color === 'string' && HEX_RE.test(stored.digit_color)
|
||||||
|
? stored.digit_color : DEFAULT_DIGIT_COLOR,
|
||||||
|
symbol_color: typeof stored.symbol_color === 'string' && HEX_RE.test(stored.symbol_color)
|
||||||
|
? stored.symbol_color : DEFAULT_SYMBOL_COLOR,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveColorScheme(scheme: ColorScheme): Promise<void> {
|
||||||
|
if (!isValid(scheme)) {
|
||||||
|
throw new Error('Invalid color values; expected #rrggbb hex strings.');
|
||||||
|
}
|
||||||
|
await chrome.storage.sync.set({ [STORAGE_KEY]: scheme });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resetColorScheme(): Promise<void> {
|
||||||
|
await chrome.storage.sync.remove(STORAGE_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function applyColorScheme(): Promise<void> {
|
||||||
|
const scheme = await loadColorScheme();
|
||||||
|
const root = document.documentElement.style;
|
||||||
|
root.setProperty('--relicario-pwd-digit-color', scheme.digit_color);
|
||||||
|
root.setProperty('--relicario-pwd-symbol-color', scheme.symbol_color);
|
||||||
|
}
|
||||||
102
extension/src/shared/error-copy.ts
Normal file
102
extension/src/shared/error-copy.ts
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
export interface ErrorCta {
|
||||||
|
label: string;
|
||||||
|
action?: 'unlock' | 'reload_extension' | 'open_setup';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ErrorCopy {
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
cta?: ErrorCta;
|
||||||
|
}
|
||||||
|
|
||||||
|
const UNLOCK_CTA: ErrorCta = { label: 'Unlock vault', action: 'unlock' };
|
||||||
|
|
||||||
|
export const ERROR_COPY: Record<string, ErrorCopy> = {
|
||||||
|
vault_locked: {
|
||||||
|
title: 'Vault locked',
|
||||||
|
body: 'Unlock your vault to continue.',
|
||||||
|
cta: UNLOCK_CTA,
|
||||||
|
},
|
||||||
|
unauthorized_sender: {
|
||||||
|
title: 'Action not allowed',
|
||||||
|
body: 'This action is not allowed from here.',
|
||||||
|
},
|
||||||
|
unknown_message_type: {
|
||||||
|
title: 'Internal error',
|
||||||
|
body: 'The extension received an unknown request — try reloading.',
|
||||||
|
cta: { label: 'Reload extension', action: 'reload_extension' },
|
||||||
|
},
|
||||||
|
origin_mismatch: {
|
||||||
|
title: 'Wrong site',
|
||||||
|
body: 'This login belongs to a different site — refusing to leak credentials cross-origin.',
|
||||||
|
},
|
||||||
|
not_a_login: {
|
||||||
|
title: 'Not a login',
|
||||||
|
body: 'That item does not have a username and password to fill.',
|
||||||
|
},
|
||||||
|
no_totp: {
|
||||||
|
title: 'No 2FA on this item',
|
||||||
|
body: 'This item does not have a TOTP secret configured.',
|
||||||
|
},
|
||||||
|
invalid_sender_url: {
|
||||||
|
title: 'Cannot read tab URL',
|
||||||
|
body: 'The current tab has no recognizable URL — try reloading the page.',
|
||||||
|
},
|
||||||
|
tab_navigated: {
|
||||||
|
title: 'Tab changed',
|
||||||
|
body: 'The browser tab changed before the action could complete — try again.',
|
||||||
|
},
|
||||||
|
captured_tab_gone: {
|
||||||
|
title: 'Tab is gone',
|
||||||
|
body: 'The browser tab closed before the action could complete — try again.',
|
||||||
|
},
|
||||||
|
item_not_found: {
|
||||||
|
title: 'Item not found',
|
||||||
|
body: 'That item is no longer in the vault — it may have been deleted from another device.',
|
||||||
|
},
|
||||||
|
attachment_not_found: {
|
||||||
|
title: 'Attachment missing',
|
||||||
|
body: 'The attachment is referenced in the item but is not present in the vault.',
|
||||||
|
},
|
||||||
|
upload_failed: {
|
||||||
|
title: 'Upload failed',
|
||||||
|
body: 'Could not upload the attachment — check your connection and try again.',
|
||||||
|
},
|
||||||
|
download_failed: {
|
||||||
|
title: 'Download failed',
|
||||||
|
body: 'Could not download the attachment — check your connection and try again.',
|
||||||
|
},
|
||||||
|
'invalid base32 secret': {
|
||||||
|
title: 'Invalid secret',
|
||||||
|
body: 'The TOTP secret must be valid Base32 (letters A-Z and digits 2-7 only).',
|
||||||
|
},
|
||||||
|
'no items to import': {
|
||||||
|
title: 'Nothing to import',
|
||||||
|
body: 'The CSV did not contain any importable items.',
|
||||||
|
},
|
||||||
|
'no reference image stored locally': {
|
||||||
|
title: 'No reference image',
|
||||||
|
body: 'This device has no reference image saved locally — re-attach the device or restore from backup.',
|
||||||
|
},
|
||||||
|
'remote already contains a Relicario vault': {
|
||||||
|
title: 'Vault already exists',
|
||||||
|
body: 'The selected repository already contains a vault — use Attach existing instead of Create new.',
|
||||||
|
},
|
||||||
|
'Extension not configured. Run setup first.': {
|
||||||
|
title: 'Extension not configured',
|
||||||
|
body: 'Run setup before using this action.',
|
||||||
|
cta: { label: 'Open setup', action: 'open_setup' },
|
||||||
|
},
|
||||||
|
'Reference image not set. Run setup first.': {
|
||||||
|
title: 'Reference image missing',
|
||||||
|
body: 'Run setup to save your reference image.',
|
||||||
|
cta: { label: 'Open setup', action: 'open_setup' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function lookupErrorCopy(code: string): ErrorCopy {
|
||||||
|
return ERROR_COPY[code] ?? {
|
||||||
|
title: 'Something went wrong',
|
||||||
|
body: code,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -17,6 +17,19 @@ export const GLYPH_DEVICES = '⌬'; // sidebar devices nav
|
|||||||
export const GLYPH_SETTINGS = '⚙'; // sidebar settings nav
|
export const GLYPH_SETTINGS = '⚙'; // sidebar settings nav
|
||||||
export const GLYPH_LOCK = '⏻'; // sidebar lock nav
|
export const GLYPH_LOCK = '⏻'; // sidebar lock nav
|
||||||
export const GLYPH_NEXT = '▸'; // forward / next button (matches ▾/▸ disclosure family)
|
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
|
||||||
|
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:
|
/// 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>`
|
/// `<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: 'parse_lastpass_csv'; bytes: ArrayBuffer }
|
||||||
| { type: 'import_lastpass_commit'; items: Item[] }
|
| { 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 ---
|
// --- Messages a content script may send ---
|
||||||
|
|
||||||
@@ -173,6 +175,7 @@ export const POPUP_ONLY_TYPES: ReadonlySet<PopupMessage['type']> = new Set([
|
|||||||
'export_backup', 'restore_backup',
|
'export_backup', 'restore_backup',
|
||||||
'parse_lastpass_csv', 'import_lastpass_commit',
|
'parse_lastpass_csv', 'import_lastpass_commit',
|
||||||
'preview_totp_from_secret',
|
'preview_totp_from_secret',
|
||||||
|
'generate_recovery_qr', 'unwrap_recovery_qr',
|
||||||
] as PopupMessage['type'][]);
|
] as PopupMessage['type'][]);
|
||||||
|
|
||||||
export interface ExportBackupResponse extends Extract<Response, { ok: true }> {
|
export interface ExportBackupResponse extends Extract<Response, { ok: true }> {
|
||||||
|
|||||||
35
extension/src/shared/password-coloring.ts
Normal file
35
extension/src/shared/password-coloring.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
export const PWD_DIGIT = 'pwd-digit';
|
||||||
|
export const PWD_SYMBOL = 'pwd-symbol';
|
||||||
|
export const PWD_LETTER = 'pwd-letter';
|
||||||
|
|
||||||
|
type Class = typeof PWD_DIGIT | typeof PWD_SYMBOL | typeof PWD_LETTER;
|
||||||
|
|
||||||
|
function classify(ch: string): Class {
|
||||||
|
if (/^\d$/.test(ch)) return PWD_DIGIT;
|
||||||
|
if (/^\p{L}$/u.test(ch)) return PWD_LETTER;
|
||||||
|
return PWD_SYMBOL;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function colorizePassword(text: string): DocumentFragment {
|
||||||
|
const frag = document.createDocumentFragment();
|
||||||
|
if (text.length === 0) return frag;
|
||||||
|
|
||||||
|
const codepoints = Array.from(text);
|
||||||
|
let runStart = 0;
|
||||||
|
let runClass = classify(codepoints[0]);
|
||||||
|
|
||||||
|
for (let i = 1; i <= codepoints.length; i++) {
|
||||||
|
const c = i < codepoints.length ? classify(codepoints[i]) : null;
|
||||||
|
if (c !== runClass) {
|
||||||
|
const span = document.createElement('span');
|
||||||
|
span.className = runClass;
|
||||||
|
span.textContent = codepoints.slice(runStart, i).join('');
|
||||||
|
frag.appendChild(span);
|
||||||
|
if (c !== null) {
|
||||||
|
runStart = i;
|
||||||
|
runClass = c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return frag;
|
||||||
|
}
|
||||||
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);
|
||||||
|
}
|
||||||
@@ -38,6 +38,10 @@
|
|||||||
|
|
||||||
/* Focus */
|
/* Focus */
|
||||||
--focus-ring: 0 0 0 2px var(--gold-ring);
|
--focus-ring: 0 0 0 2px var(--gold-ring);
|
||||||
|
|
||||||
|
/* Password coloring (P1) */
|
||||||
|
--relicario-pwd-digit-color: #2563eb;
|
||||||
|
--relicario-pwd-symbol-color: #dc2626;
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
@@ -144,6 +148,36 @@ body {
|
|||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.error-block {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
/* rgba channels derived from --danger (#ab2b20 = rgb(171, 43, 32)) */
|
||||||
|
border: 1px solid rgba(171, 43, 32, 0.4);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: rgba(171, 43, 32, 0.08);
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
.error-block .error-title {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
.error-block .error-body {
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 12px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.error-block .error-cta {
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Password character-class coloring */
|
||||||
|
.pwd-digit { color: var(--relicario-pwd-digit-color); }
|
||||||
|
.pwd-symbol { color: var(--relicario-pwd-symbol-color); }
|
||||||
|
.pwd-letter { color: inherit; }
|
||||||
|
|
||||||
/* Buttons */
|
/* Buttons */
|
||||||
.btn {
|
.btn {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
@@ -1256,6 +1290,13 @@ textarea {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
.vault-sidebar__header .brand-logo {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
margin: 0;
|
||||||
|
display: block;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.vault-sidebar__search {
|
.vault-sidebar__search {
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
@@ -1355,6 +1396,281 @@ textarea {
|
|||||||
color: #484f58;
|
color: #484f58;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* === 3-column shell === */
|
||||||
|
.vault-shell {
|
||||||
|
display: flex;
|
||||||
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--bg-page, #0d1117);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vault-sidebar {
|
||||||
|
width: 200px;
|
||||||
|
min-width: 200px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border-right: 1px solid var(--border, #30363d);
|
||||||
|
background: var(--bg-sidebar, #0d1117);
|
||||||
|
overflow-y: auto;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vault-list-pane {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow-y: auto;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bottom sheet */
|
||||||
|
.vault-bottom-sheet-scrim {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0 0 0 200px;
|
||||||
|
background: rgba(0,0,0,0.5);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vault-bottom-sheet-scrim--visible {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vault-bottom-sheet {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 200px;
|
||||||
|
right: 0;
|
||||||
|
background: var(--bg-elevated, #161b22);
|
||||||
|
border-top: 1px solid var(--border, #30363d);
|
||||||
|
border-radius: 12px 12px 0 0;
|
||||||
|
padding: 16px 24px 24px;
|
||||||
|
transform: translateY(100%);
|
||||||
|
transition: transform 0.25s ease;
|
||||||
|
z-index: 101;
|
||||||
|
max-height: 60vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vault-bottom-sheet--open { transform: translateY(0); }
|
||||||
|
|
||||||
|
.vault-bottom-sheet__handle {
|
||||||
|
width: 40px;
|
||||||
|
height: 4px;
|
||||||
|
background: var(--border, #30363d);
|
||||||
|
border-radius: 2px;
|
||||||
|
margin: 0 auto 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vault-bottom-sheet__title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vault-type-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vault-type-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 12px 8px;
|
||||||
|
background: var(--bg-page, #0d1117);
|
||||||
|
border: 1px solid var(--border, #30363d);
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vault-type-card:hover { border-color: var(--gold, #b8860b); }
|
||||||
|
|
||||||
|
.vault-type-card__icon { font-size: 28px; }
|
||||||
|
.vault-type-card__name { font-size: 11px; color: var(--text-muted, #8b949e); }
|
||||||
|
|
||||||
|
/* 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) --- */
|
/* --- Lock screen (vault tab) --- */
|
||||||
|
|
||||||
.vault-lock-screen {
|
.vault-lock-screen {
|
||||||
@@ -1592,6 +1908,20 @@ textarea {
|
|||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
.form-grid { grid-template-columns: 1fr; }
|
.form-grid { grid-template-columns: 1fr; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 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;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
.form-lower > .form-group,
|
||||||
|
.form-lower > .disclosure,
|
||||||
|
.form-lower > .attachments-disclosure,
|
||||||
|
.form-lower > .form-actions {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.form-col {
|
.form-col {
|
||||||
padding: 14px 16px;
|
padding: 14px 16px;
|
||||||
}
|
}
|
||||||
@@ -1664,3 +1994,41 @@ textarea {
|
|||||||
background: linear-gradient(to top, rgba(17, 22, 30, 0.7), transparent);
|
background: linear-gradient(to top, rgba(17, 22, 30, 0.7), transparent);
|
||||||
pointer-events: none;
|
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; }
|
||||||
|
|||||||
@@ -9,16 +9,36 @@ import type {
|
|||||||
ItemId, ItemType, ManifestEntry, Item, VaultSettings, GeneratorRequest,
|
ItemId, ItemType, ManifestEntry, Item, VaultSettings, GeneratorRequest,
|
||||||
} from '../shared/types';
|
} from '../shared/types';
|
||||||
import { registerHost } from '../shared/state';
|
import { registerHost } from '../shared/state';
|
||||||
import { GLYPH_TRASH, GLYPH_DEVICES, GLYPH_SETTINGS, GLYPH_LOCK } from '../shared/glyphs';
|
import { lookupErrorCopy, type ErrorCta } from '../shared/error-copy';
|
||||||
|
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 { renderItemDetail } from '../popup/components/item-detail';
|
||||||
import { renderItemForm } from '../popup/components/item-form';
|
import { renderItemForm } from '../popup/components/item-form';
|
||||||
import { renderTrash, teardown as teardownTrash } from '../popup/components/trash';
|
import { renderTrash, teardown as teardownTrash } from '../popup/components/trash';
|
||||||
import { renderDevices, teardown as teardownDevices } from '../popup/components/devices';
|
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 { renderVaultSettings as renderVaultSettingsView } from '../popup/components/settings-vault';
|
||||||
import { renderFieldHistory, teardown as teardownFieldHistory } from '../popup/components/field-history';
|
import { renderFieldHistory, teardown as teardownFieldHistory } from '../popup/components/field-history';
|
||||||
import { renderBackupPanel, teardown as teardownBackup } from './components/backup-panel';
|
import { renderBackupPanel, teardown as teardownBackup } from './components/backup-panel';
|
||||||
import { renderImportPanel, teardown as teardownImport } from './components/import-panel';
|
import { renderImportPanel, teardown as teardownImport } from './components/import-panel';
|
||||||
|
import { applyColorScheme } from '../shared/color-scheme';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Bottom sheet type picker
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const BOTTOM_SHEET_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
|
// Helpers
|
||||||
@@ -41,28 +61,52 @@ function escapeHtml(str: string): string {
|
|||||||
.replace(/'/g, ''');
|
.replace(/'/g, ''');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderErrorBlock(code: string | null | undefined): string {
|
||||||
|
if (!code) return '';
|
||||||
|
const copy = lookupErrorCopy(code);
|
||||||
|
const ctaHtml = copy.cta
|
||||||
|
? `<button class="btn btn-primary error-cta" data-cta="${escapeHtml(copy.cta.action ?? '')}">${escapeHtml(copy.cta.label)}</button>`
|
||||||
|
: '';
|
||||||
|
return `
|
||||||
|
<div class="error error-block">
|
||||||
|
<div class="error-title">${escapeHtml(copy.title)}</div>
|
||||||
|
<div class="error-body">${escapeHtml(copy.body)}</div>
|
||||||
|
${ctaHtml}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
function typeIcon(t: ItemType): string {
|
function typeIcon(t: ItemType): string {
|
||||||
switch (t) {
|
switch (t) {
|
||||||
case 'login': return '\u{1F511}'; // key
|
case 'login': return GLYPH_TYPE_LOGIN;
|
||||||
case 'secure_note': return '\u{1F4DD}'; // memo
|
case 'secure_note': return GLYPH_TYPE_SECURE_NOTE;
|
||||||
case 'identity': return '\u{1FAAA}'; // id card
|
case 'identity': return GLYPH_TYPE_IDENTITY;
|
||||||
case 'card': return '\u{1F4B3}'; // credit card
|
case 'card': return GLYPH_TYPE_CARD;
|
||||||
case 'key': return '\u{1F5DD}'; // old key
|
case 'key': return GLYPH_TYPE_KEY;
|
||||||
case 'document': return '\u{1F4C4}'; // page facing up
|
case 'document': return GLYPH_TYPE_DOCUMENT;
|
||||||
case 'totp': return '⏱'; // stopwatch
|
case 'totp': return GLYPH_TYPE_TOTP;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function typeLabel(t: ItemType): string {
|
function typeLabel(t: ItemType): string {
|
||||||
switch (t) {
|
const labels: Record<ItemType, string> = {
|
||||||
case 'login': return 'Logins';
|
login: 'Login',
|
||||||
case 'secure_note': return 'Secure Notes';
|
secure_note: 'Secure Note',
|
||||||
case 'identity': return 'Identities';
|
identity: 'Identity',
|
||||||
case 'card': return 'Cards';
|
card: 'Card',
|
||||||
case 'key': return 'Keys';
|
key: 'SSH / API Key',
|
||||||
case 'document': return 'Documents';
|
document: 'Document',
|
||||||
case 'totp': return 'TOTP';
|
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`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -121,6 +165,8 @@ interface VaultState {
|
|||||||
selectedIndex: number;
|
selectedIndex: number;
|
||||||
searchQuery: string;
|
searchQuery: string;
|
||||||
activeGroup: string | null;
|
activeGroup: string | null;
|
||||||
|
drawerOpen: boolean;
|
||||||
|
bottomSheetOpen: boolean;
|
||||||
vaultSettings: VaultSettings | null;
|
vaultSettings: VaultSettings | null;
|
||||||
generatorDefaults: GeneratorRequest | null;
|
generatorDefaults: GeneratorRequest | null;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
@@ -140,6 +186,8 @@ const state: VaultState = {
|
|||||||
selectedIndex: 0,
|
selectedIndex: 0,
|
||||||
searchQuery: '',
|
searchQuery: '',
|
||||||
activeGroup: null,
|
activeGroup: null,
|
||||||
|
drawerOpen: false,
|
||||||
|
bottomSheetOpen: false,
|
||||||
vaultSettings: null,
|
vaultSettings: null,
|
||||||
generatorDefaults: null,
|
generatorDefaults: null,
|
||||||
error: null,
|
error: null,
|
||||||
@@ -163,7 +211,8 @@ registerHost({
|
|||||||
navigate: (view: string, extras?: any) => {
|
navigate: (view: string, extras?: any) => {
|
||||||
Object.assign(state, { view, error: null, loading: false, ...extras });
|
Object.assign(state, { view, error: null, loading: false, ...extras });
|
||||||
setHash(view as VaultView);
|
setHash(view as VaultView);
|
||||||
renderSidebarList();
|
renderSidebarCategories();
|
||||||
|
renderListPane();
|
||||||
renderPane();
|
renderPane();
|
||||||
},
|
},
|
||||||
sendMessage,
|
sendMessage,
|
||||||
@@ -199,7 +248,7 @@ function renderLockScreen(app: HTMLElement): void {
|
|||||||
<div class="vault-lock-screen__form">
|
<div class="vault-lock-screen__form">
|
||||||
<input type="password" id="vault-passphrase" placeholder="passphrase" autocomplete="off" />
|
<input type="password" id="vault-passphrase" placeholder="passphrase" autocomplete="off" />
|
||||||
<button class="btn btn-primary" id="vault-unlock-btn" style="width:100%;">unlock</button>
|
<button class="btn btn-primary" id="vault-unlock-btn" style="width:100%;">unlock</button>
|
||||||
${state.error ? `<div class="error" style="text-align:center;">${escapeHtml(state.error)}</div>` : ''}
|
${renderErrorBlock(state.error)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@@ -232,38 +281,220 @@ function renderLockScreen(app: HTMLElement): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Shell (sidebar + pane)
|
// Shell (3-column: sidebar + list pane + drawer)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
function renderShell(app: HTMLElement): void {
|
function renderShell(app: HTMLElement): void {
|
||||||
// Only create the shell structure if it's not present yet
|
if (!app.querySelector('.vault-shell')) {
|
||||||
if (!app.querySelector('.vault-sidebar')) {
|
|
||||||
app.innerHTML = `
|
app.innerHTML = `
|
||||||
<div class="vault-sidebar">
|
<div class="vault-shell">
|
||||||
<div class="vault-sidebar__header">
|
<div class="vault-sidebar">
|
||||||
<span class="brand">Relicario</span>
|
<div class="vault-sidebar__header">
|
||||||
|
<img class="brand-logo" src="icons/relicario-logo-16.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" 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>
|
||||||
<div class="vault-sidebar__search">
|
<div class="vault-list-pane" id="vault-list-pane"></div>
|
||||||
<input type="text" id="vault-search" placeholder="/ search..." />
|
<div class="vault-drawer" id="vault-drawer"></div>
|
||||||
</div>
|
<div class="vault-bottom-sheet-scrim" id="vault-sheet-scrim"></div>
|
||||||
<div class="vault-sidebar__list" id="vault-sidebar-list"></div>
|
<div class="vault-bottom-sheet" id="vault-bottom-sheet"></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>
|
</div>
|
||||||
`;
|
`;
|
||||||
wireSidebar();
|
wireSidebar();
|
||||||
|
wireBottomSheet();
|
||||||
}
|
}
|
||||||
|
|
||||||
renderSidebarList();
|
renderSidebarCategories();
|
||||||
renderPane();
|
renderListPane();
|
||||||
|
if (state.drawerOpen && state.selectedItem) {
|
||||||
|
renderDrawer(state.selectedItem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Bottom sheet (wired in Task 11)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function wireBottomSheet(): void {
|
||||||
|
document.getElementById('vault-sheet-scrim')?.addEventListener('click', closeBottomSheet);
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Escape' && state.bottomSheetOpen) closeBottomSheet();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function openBottomSheet(): void {
|
||||||
|
const sheet = document.getElementById('vault-bottom-sheet');
|
||||||
|
const scrim = document.getElementById('vault-sheet-scrim');
|
||||||
|
if (!sheet || !scrim) return;
|
||||||
|
|
||||||
|
sheet.innerHTML = `
|
||||||
|
<div class="vault-bottom-sheet__handle"></div>
|
||||||
|
<div class="vault-bottom-sheet__title">New item — choose type</div>
|
||||||
|
<div class="vault-type-grid">
|
||||||
|
${BOTTOM_SHEET_TYPES.map((t) => `
|
||||||
|
<button class="vault-type-card" data-type="${t.type}">
|
||||||
|
<span class="vault-type-card__icon" aria-hidden="true">${typeIcon(t.type)}</span>
|
||||||
|
<span class="vault-type-card__name">${escapeHtml(t.label)}</span>
|
||||||
|
</button>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
sheet.classList.add('vault-bottom-sheet--open');
|
||||||
|
scrim.classList.add('vault-bottom-sheet-scrim--visible');
|
||||||
|
state.bottomSheetOpen = true;
|
||||||
|
|
||||||
|
sheet.querySelectorAll<HTMLButtonElement>('[data-type]').forEach((btn) => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const type = btn.dataset.type as ItemType;
|
||||||
|
closeBottomSheet();
|
||||||
|
setHash('add', type);
|
||||||
|
renderPane();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeBottomSheet(): void {
|
||||||
|
document.getElementById('vault-bottom-sheet')?.classList.remove('vault-bottom-sheet--open');
|
||||||
|
document.getElementById('vault-sheet-scrim')?.classList.remove('vault-bottom-sheet-scrim--visible');
|
||||||
|
state.bottomSheetOpen = 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();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -275,7 +506,8 @@ function wireSidebar(): void {
|
|||||||
const searchInput = document.getElementById('vault-search') as HTMLInputElement | null;
|
const searchInput = document.getElementById('vault-search') as HTMLInputElement | null;
|
||||||
searchInput?.addEventListener('input', () => {
|
searchInput?.addEventListener('input', () => {
|
||||||
state.searchQuery = searchInput.value;
|
state.searchQuery = searchInput.value;
|
||||||
renderSidebarList();
|
renderSidebarCategories();
|
||||||
|
renderListPane();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Nav buttons
|
// Nav buttons
|
||||||
@@ -295,8 +527,7 @@ function wireSidebar(): void {
|
|||||||
state.selectedId = null;
|
state.selectedId = null;
|
||||||
state.selectedItem = null;
|
state.selectedItem = null;
|
||||||
state.newType = null;
|
state.newType = null;
|
||||||
setHash('add');
|
openBottomSheet();
|
||||||
renderPane();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (nav === 'trash' || nav === 'devices' || nav === 'settings') {
|
if (nav === 'trash' || nav === 'devices' || nav === 'settings') {
|
||||||
@@ -310,11 +541,16 @@ function wireSidebar(): void {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Global "/" shortcut to focus search
|
// Global "/" shortcut to focus search; Esc to close drawer
|
||||||
document.addEventListener('keydown', (e) => {
|
document.addEventListener('keydown', (e) => {
|
||||||
if (e.key === '/' && !isEditableTarget(e.target)) {
|
if (e.key === '/' && !isEditableTarget(e.target)) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
searchInput?.focus();
|
searchInput?.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === 'Escape' && state.drawerOpen) {
|
||||||
|
closeDrawer();
|
||||||
|
renderListPane();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -328,7 +564,7 @@ function isEditableTarget(target: EventTarget | null): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Sidebar list
|
// Sidebar category nav
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
function getFilteredEntries(): Array<[ItemId, ManifestEntry]> {
|
function getFilteredEntries(): Array<[ItemId, ManifestEntry]> {
|
||||||
@@ -349,70 +585,96 @@ function getFilteredEntries(): Array<[ItemId, ManifestEntry]> {
|
|||||||
return filtered;
|
return filtered;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderSidebarList(): void {
|
function renderSidebarCategories(): void {
|
||||||
const container = document.getElementById('vault-sidebar-list');
|
const container = document.getElementById('vault-categories');
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
const filtered = getFilteredEntries();
|
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 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) {
|
for (const t of typeOrder) {
|
||||||
const items = groups.get(t);
|
const count = filtered.filter(([, e]) => e.type === t).length;
|
||||||
if (!items || items.length === 0) continue;
|
if (count === 0 && allCount > 0) continue;
|
||||||
html += `<div class="vault-group-header">${typeIcon(t)} ${escapeHtml(typeLabel(t))}</div>`;
|
const isActive = state.activeGroup === t;
|
||||||
for (const [id, e] of items) {
|
html += `
|
||||||
const sel = id === state.selectedId ? ' selected' : '';
|
<button class="vault-category-row ${isActive ? 'vault-category-row--active' : ''}" data-group="${t}">
|
||||||
const meta = e.icon_hint ? escapeHtml(e.icon_hint) : '';
|
<span class="vault-category-row__icon">${typeIcon(t)}</span>
|
||||||
html += `
|
<span class="vault-category-row__label vault-sidebar__category-label">${typeLabel(t)}</span>
|
||||||
<div class="vault-entry${sel}" data-id="${escapeHtml(id)}">
|
<span class="vault-category-row__count vault-sidebar__category-count">${count}</span>
|
||||||
<span class="vault-entry__title">${escapeHtml(e.title)}</span>
|
</button>
|
||||||
${meta ? `<span class="vault-entry__meta">${meta}</span>` : ''}
|
`;
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
container.innerHTML = html;
|
container.innerHTML = html;
|
||||||
|
|
||||||
// Wire clicks
|
container.querySelectorAll<HTMLButtonElement>('.vault-category-row').forEach((btn) => {
|
||||||
container.querySelectorAll('.vault-entry').forEach((el) => {
|
btn.addEventListener('click', () => {
|
||||||
el.addEventListener('click', async () => {
|
state.activeGroup = btn.dataset.group || null;
|
||||||
const id = (el as HTMLElement).dataset.id!;
|
state.drawerOpen = false;
|
||||||
await selectItem(id);
|
state.selectedId = null;
|
||||||
|
state.selectedItem = null;
|
||||||
|
renderSidebarCategories();
|
||||||
|
renderListPane();
|
||||||
|
closeDrawer();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function selectItem(id: ItemId): Promise<void> {
|
// ---------------------------------------------------------------------------
|
||||||
state.loading = true;
|
// List pane
|
||||||
const resp = await sendMessage({ type: 'get_item', id });
|
// ---------------------------------------------------------------------------
|
||||||
if (resp.ok) {
|
|
||||||
const data = resp.data as { item: Item };
|
function renderListPane(): void {
|
||||||
state.selectedId = id;
|
const pane = document.getElementById('vault-list-pane');
|
||||||
state.selectedItem = data.item;
|
if (!pane) return;
|
||||||
state.loading = false;
|
|
||||||
setHash('detail', id);
|
const group = state.activeGroup as ItemType | null;
|
||||||
renderSidebarList();
|
let items = getFilteredEntries();
|
||||||
renderPane();
|
if (group) items = items.filter(([, e]) => e.type === group);
|
||||||
} else {
|
|
||||||
state.loading = false;
|
if (items.length === 0) {
|
||||||
state.error = (resp as { error: string }).error;
|
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!);
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -428,8 +690,8 @@ const SAVE_HINT = isMac ? '⌘+S to save' : 'Ctrl+S to save';
|
|||||||
|
|
||||||
function renderFormWrapped(app: HTMLElement, mode: 'add' | 'edit'): void {
|
function renderFormWrapped(app: HTMLElement, mode: 'add' | 'edit'): void {
|
||||||
const itemType = state.selectedItem?.type ?? state.newType ?? 'login';
|
const itemType = state.selectedItem?.type ?? state.newType ?? 'login';
|
||||||
const typeLabel = itemType.replace('_', ' ');
|
const typeLabelText = itemType.replace('_', ' ');
|
||||||
const titleText = mode === 'add' ? `new ${typeLabel}` : `edit ${typeLabel}`;
|
const titleText = mode === 'add' ? `new ${typeLabelText}` : `edit ${typeLabelText}`;
|
||||||
const wrapper = document.createElement('div');
|
const wrapper = document.createElement('div');
|
||||||
wrapper.className = 'form-pane';
|
wrapper.className = 'form-pane';
|
||||||
wrapper.innerHTML = `
|
wrapper.innerHTML = `
|
||||||
@@ -487,6 +749,7 @@ export const __test__ = { renderFormWrapped };
|
|||||||
function teardownPaneComponents(): void {
|
function teardownPaneComponents(): void {
|
||||||
teardownTrash();
|
teardownTrash();
|
||||||
teardownDevices();
|
teardownDevices();
|
||||||
|
teardownSettings();
|
||||||
teardownFieldHistory();
|
teardownFieldHistory();
|
||||||
teardownBackup();
|
teardownBackup();
|
||||||
teardownImport();
|
teardownImport();
|
||||||
@@ -536,7 +799,7 @@ function renderPane(): void {
|
|||||||
renderDevices(pane);
|
renderDevices(pane);
|
||||||
break;
|
break;
|
||||||
case 'settings':
|
case 'settings':
|
||||||
renderSettings(pane);
|
void renderSettings(pane);
|
||||||
break;
|
break;
|
||||||
case 'settings-vault':
|
case 'settings-vault':
|
||||||
renderVaultSettingsView(pane);
|
renderVaultSettingsView(pane);
|
||||||
@@ -592,6 +855,36 @@ async function loadManifest(): Promise<void> {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', async () => {
|
document.addEventListener('DOMContentLoaded', async () => {
|
||||||
|
await applyColorScheme();
|
||||||
|
|
||||||
|
chrome.storage.onChanged.addListener((changes, area) => {
|
||||||
|
if (area === 'sync' && 'password_display_scheme' in changes) {
|
||||||
|
void applyColorScheme();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delegated handler for .error-cta buttons — set up once on the stable root.
|
||||||
|
const app = document.getElementById('vault-app')!;
|
||||||
|
app.addEventListener('click', (e) => {
|
||||||
|
const btn = (e.target as HTMLElement).closest<HTMLButtonElement>('.error-cta');
|
||||||
|
if (!btn) return;
|
||||||
|
const cta = btn.dataset.cta as ErrorCta['action'];
|
||||||
|
switch (cta) {
|
||||||
|
case 'unlock': {
|
||||||
|
document.getElementById('vault-passphrase')?.focus();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'open_setup': {
|
||||||
|
void chrome.tabs.create({ url: chrome.runtime.getURL('setup.html') });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'reload_extension': {
|
||||||
|
chrome.runtime.reload();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Check if already unlocked
|
// Check if already unlocked
|
||||||
const resp = await sendMessage({ type: 'is_unlocked' });
|
const resp = await sendMessage({ type: 'is_unlocked' });
|
||||||
if (resp.ok) {
|
if (resp.ok) {
|
||||||
@@ -626,7 +919,8 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||||||
if ((route.view === 'detail' || route.view === 'edit') && route.id) {
|
if ((route.view === 'detail' || route.view === 'edit') && route.id) {
|
||||||
if (state.selectedId === route.id && state.selectedItem) {
|
if (state.selectedId === route.id && state.selectedItem) {
|
||||||
renderPane();
|
renderPane();
|
||||||
renderSidebarList();
|
renderSidebarCategories();
|
||||||
|
renderListPane();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Need to fetch the item
|
// Need to fetch the item
|
||||||
@@ -637,7 +931,30 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||||||
// For non-item views, just re-render the pane
|
// For non-item views, just re-render the pane
|
||||||
state.selectedId = null;
|
state.selectedId = null;
|
||||||
state.selectedItem = null;
|
state.selectedItem = null;
|
||||||
renderSidebarList();
|
renderSidebarCategories();
|
||||||
|
renderListPane();
|
||||||
renderPane();
|
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 clear_device(): void;
|
||||||
export function get_field_history(item_json: string): unknown;
|
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 default function init(module_or_path?: unknown): Promise<void>;
|
||||||
export function initSync(args: { module: WebAssembly.Module }): void;
|
export function initSync(args: { module: WebAssembly.Module }): void;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,5 +12,5 @@
|
|||||||
"baseUrl": "."
|
"baseUrl": "."
|
||||||
},
|
},
|
||||||
"include": ["src/**/*"],
|
"include": ["src/**/*"],
|
||||||
"exclude": ["node_modules", "dist", "wasm", "src/**/__tests__/**"]
|
"exclude": ["node_modules", "dist", "wasm", "src/**/__tests__/**", "src/__stubs__/**"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
import { defineConfig } from 'vitest/config';
|
import { defineConfig } from 'vitest/config';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
// Stub the runtime-only WASM module so unit tests can import setup.ts.
|
||||||
|
'../relicario_wasm.js': path.resolve(__dirname, 'src/__stubs__/relicario_wasm.stub.ts'),
|
||||||
|
'relicario-wasm': path.resolve(__dirname, 'src/__stubs__/relicario_wasm.stub.ts'),
|
||||||
|
},
|
||||||
|
},
|
||||||
test: {
|
test: {
|
||||||
environment: 'happy-dom',
|
environment: 'happy-dom',
|
||||||
include: ['src/**/__tests__/**/*.test.ts'],
|
include: ['src/**/__tests__/**/*.test.ts'],
|
||||||
|
|||||||
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
58
tools/relay/queue.test.ts
Normal file
58
tools/relay/queue.test.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
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"));
|
||||||
|
});
|
||||||
|
});
|
||||||
55
tools/relay/queue.ts
Normal file
55
tools/relay/queue.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
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),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
159
tools/relay/server.ts
Normal file
159
tools/relay/server.ts
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
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`);
|
||||||
|
});
|
||||||
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 --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
|
||||||
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