65 Commits

Author SHA1 Message Date
adlee-was-taken
dd0010db62 feat(relay): expand to dev-c role + python/ts MCP fallback shims
queue.ts and server.ts now know about dev-c alongside pm/dev-a/dev-b
so the four-role coordination paradigm works end-to-end. start.sh
opens a fourth window for dev-c. call.py and call.ts are HTTP shims
that agents can use when the MCP relay tools aren't registered in
their session (the kickoff prompts reference call.py by path as a
fallback).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-05 17:49:21 -04:00
adlee-was-taken
29146439bb fix(ext): glyph + vault polish — distinct identity glyph, sticky-bar form-actions hidden
GLYPH_TYPE_IDENTITY changed from ⌬ to ◍ so it's visually distinct from
GLYPH_DEVICES (also ⌬). Adds a CSS rule asserting [hidden] over the
.form-actions display:flex so the fullscreen sticky save bar can hide
the inner action row by attribute.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-05 17:49:16 -04:00
adlee-was-taken
cf66bd97b7 chore: bump crate and extension versions to 0.5.0
Catches the workspace and the extension manifests up to the v0.5.x
release line (was still showing 0.2.0).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-05 17:49:09 -04:00
adlee-was-taken
061facd5a9 docs(reviews): whole-codebase architecture audit 2026-05-04
Three-reviewer architecture audit (DEV-A: core, DEV-B: cli/server/wasm,
DEV-C: extension/relay) plus PM synthesis. Lens: make the codebase
readable for a smart developer who doesn't know Rust but wants to learn
by tinkering.

Top synthesis findings (P1):
- SessionHandle has no impl Drop; .free() is a cleanup no-op (cross-cutting Rust+JS)
- cli/main.rs is a 2641-line monolith with no submodule boundaries
- setup.ts (1220 LOC) bypasses the SW and orchestrates WASM directly
- vault.ts (1027 LOC) owns shell + sidebar + list + drawer + routing
- shared/state.ts is fully any-typed
- recovery_qr.rs is undocumented vs. rest of crypto-adjacent core
- duplicated SW router helpers (storage + itemToManifestEntry)
- pure parsers (parse_month_year, base32_decode_lenient) belong in core
- 16x duplicated git invocation boilerplate with one-line errors

CLI/extension parity: 22/23 capabilities ✓; only true gap is `relicario
status` (no get_vault_status); `detach` is partial via update_item.

Also fixes tools/relay/queue.test.ts:54 to match the dev-c role
expansion already in queue.ts (was failing 1/4; now 5/5 pass).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-04 23:27:26 -04:00
adlee-was-taken
bd6a30155e Merge feature/v0.5.1-stream-b-settings: unified left-nav settings (Autofill/Display/Security/Generator/Retention/Backup/Import) 2026-05-03 21:51:43 -04:00
adlee-was-taken
8baef5b3cb fix(ext/settings): restore settings-security.ts from main (Stream C real implementation) 2026-05-03 21:51:08 -04:00
adlee-was-taken
ddfb95d683 fix(ext/settings): call teardownSettings in popup render to prevent listener leak 2026-05-03 21:47:46 -04:00
adlee-was-taken
7df76c692a feat(ext/settings): settings left-nav skeleton with section routing
Two-panel layout (148px nav sidebar + content area) with 7 nav items
(Autofill, Display, Security, Generator, Retention, Backup, Import),
stub section functions, and settings layout CSS classes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 21:46:11 -04:00
adlee-was-taken
b4d253c60b chore(ext/settings): stub settings-security.ts (DEV-C replaces implementation) 2026-05-03 21:45:14 -04:00
adlee-was-taken
c16adc4335 Merge feature/v0.5.1-stream-a-layout: 3-column vault layout, toast system, glyph constants, emoji sweep 2026-05-03 21:41:57 -04:00
adlee-was-taken
9a8cdf8e4f fix: replace all remaining emoji with monochrome glyph constants
- trash.ts TYPE_ICONS map uses GLYPH_TYPE_* constants
- field-history.ts copy button uses GLYPH_COPY
- attachments-disclosure.ts thumbnail/icon uses GLYPH_TYPE_DOCUMENT
- settings.ts sync button uses GLYPH_SYNC
- document.ts thumb/sigblock/preview use GLYPH_TYPE_DOCUMENT + GLYPH_PREVIEW
- glyphs.ts adds GLYPH_COPY, GLYPH_SYNC, GLYPH_PREVIEW
- vault.ts adds GLYPH_DEVICES import + devices sidebar nav button

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 21:37:23 -04:00
adlee-was-taken
ade44b4ea1 feat(ext/vault): wire renderSettings / teardownSettings from settings component 2026-05-03 21:37:23 -04:00
adlee-was-taken
1d4b018f9a feat(ext/vault): bottom sheet type picker for new item 2026-05-03 21:37:23 -04:00
adlee-was-taken
882a89bedd feat(ext/vault): detail drawer — open/close state + core fields display 2026-05-03 21:37:23 -04:00
adlee-was-taken
37c20b28a6 feat(ext/vault): 3-column shell — sidebar category nav + list pane 2026-05-03 21:37:23 -04:00
adlee-was-taken
3553150a53 feat(ext/vault): 3-column layout CSS — drawer, bottom sheet, list rows, responsive 2026-05-03 21:37:23 -04:00
adlee-was-taken
b50f49b597 fix(ext/vault): replace emoji typeIcon with glyph constants 2026-05-03 21:37:23 -04:00
adlee-was-taken
1ec8965910 feat(ext): shared toast notification system 2026-05-03 21:37:23 -04:00
adlee-was-taken
ad6e4a2cd9 feat(ext/popup): polished 2-column type-picker with glyph icons 2026-05-03 21:37:23 -04:00
adlee-was-taken
b768f649a2 feat(ext/popup): empty states with glyph icons in item-list 2026-05-03 21:37:23 -04:00
adlee-was-taken
8b197a7525 fix(ext/popup): replace emoji in SETTINGS_OPTIONS with glyph constants 2026-05-03 21:37:23 -04:00
adlee-was-taken
117716f6cf fix(ext/popup): replace emoji typeIcon with glyph constants in item-list 2026-05-03 21:37:23 -04:00
adlee-was-taken
c5e8b52e12 fix(ext/popup): replace inline &#x2934; vault-tab button with GLYPH_VAULT_TAB 2026-05-03 21:37:23 -04:00
adlee-was-taken
a1b66a9147 feat(ext/glyphs): add GLYPH_VAULT_TAB and per-type icon constants 2026-05-03 21:37:23 -04:00
adlee-was-taken
934dfe05c2 Merge feature/v0.5.1-stream-c-recovery-qr: Recovery QR (Rust core + WASM + CLI + settings-security.ts + setup wizard) 2026-05-03 21:26:40 -04:00
adlee-was-taken
33d2a4a311 feat(ext/setup): wizard Style C progress track, glyph mode icons, recovery QR banner
- Replace dot-based progress indicator with colored horizontal segment track
  (completed=green, active=gold, pending=border) via renderProgressTrack()
- Add SETUP_STEP_NAMES constant for track segment titles
- Update Step 0 mode cards with glyph icons (◈ create, ⌥ attach)
- Add recovery QR banner in Step 5 (new-vault only, verifiedHandle present)
  with Generate now / Skip buttons wired in attachStep5()
- Add CSS for .setup-progress-track, .setup-progress-segment variants,
  and .recovery-qr-banner to styles.css

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 21:17:05 -04:00
adlee-was-taken
f17944a404 fix(core,wasm): correct QR version comment, expect msg, zeroize image_secret in closure 2026-05-03 21:09:02 -04:00
adlee-was-taken
4851857070 feat(ext/settings): settings-security.ts three-state recovery QR + devices component
- Add settings-security.ts with renderSecuritySection / teardownSecuritySection
- Three states: amber warning (no QR), green status (QR set up), modal overlay (show/print SVG)
- Device list with inline revoke; passphrase collected via prompt()
- QR payload never written to chrome.storage; only recovery_qr_generated_at timestamp stored
- Add generate_recovery_qr / unwrap_recovery_qr message types to messages.ts + POPUP_ONLY_TYPES
- Add SW handlers in popup-only.ts delegating to wasm_generate_recovery_qr / wasm_unwrap_recovery_qr
- Declare wasm_generate_recovery_qr and wasm_unwrap_recovery_qr in wasm.d.ts

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 21:06:43 -04:00
adlee-was-taken
a6071b4c0c feat(cli): recovery-qr generate / unwrap subcommands
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 21:01:29 -04:00
adlee-was-taken
ada00895d4 feat(wasm): expose generate_recovery_qr and unwrap_recovery_qr bindings 2026-05-03 20:57:55 -04:00
adlee-was-taken
42b746f9af feat(wasm): session stores image_secret for recovery QR generation 2026-05-03 20:56:39 -04:00
adlee-was-taken
762a008171 test(core): recovery_qr roundtrip + error cases 2026-05-03 20:53:59 -04:00
adlee-was-taken
f93bce7388 chore(core): re-export recovery_qr module 2026-05-03 20:51:36 -04:00
adlee-was-taken
8eabaf5f31 feat(core): recovery_qr generate + unwrap + SVG functions 2026-05-03 20:51:33 -04:00
adlee-was-taken
04142dc116 feat(core): add derive_master_key_raw + RecoveryQr error variant 2026-05-03 20:51:29 -04:00
adlee-was-taken
8739f1f67b chore(core): add qrcode dependency for recovery QR 2026-05-03 20:48:38 -04:00
adlee-was-taken
7d6fd76e86 feat: v0.5.1 multi-agent coordination plans (PM + DEV-A/B/C)
- coordination/v0.5.1-pm-prompt.md — PM coordinates 3 streams, enforces
  interface contracts (A-B settings signature, B-C security component),
  owns merge order and pre-tag checklist
- coordination/v0.5.1-dev-a-prompt.md — Stream A: fullscreen 3-column
  layout, sidebar category nav, detail drawer, bottom sheet, popup type-
  picker polish, per-type glyph icons, empty states, toast system (13 tasks)
- coordination/v0.5.1-dev-b-prompt.md — Stream B: settings left-nav
  redesign (Autofill, Display, Security, Generator, Retention, Backup,
  Import sections), security component stub (10 tasks)
- coordination/v0.5.1-dev-c-prompt.md — Stream C: recovery_qr.rs core,
  WASM session expansion, CLI subcommand, settings-security.ts three-state
  component, setup wizard Style C redesign + QR banner (12 tasks)
- Archive v0.5.0 coordination files to coordination/archive/

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 20:26:19 -04:00
adlee-was-taken
4dc034d846 docs(spec): v0.5.x UX polish, settings redesign, and recovery QR design
Three-stream spec for the next release train:
- Stream A: fullscreen 3-col layout, popup type-picker polish, glyphs, toasts, empty states
- Stream B: settings UX redesign with left-nav sections (Device/Vault split)
- Stream C: recovery QR crypto (Rust/WASM), setup wizard redesign (Style C), security settings tab

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 19:32:43 -04:00
adlee-was-taken
3021ef9d9f feat(ext/vault): sidebar logo before "Relicario" wordmark
Renders the 16-optimized SVG (icons/relicario-logo-16.svg) inline
before the brand text in .vault-sidebar__header. Sized to 20×20 px
with flex-shrink: 0 so it survives narrow-pane wraps. The header
already had display: flex + gap: 8px, so the layout absorbed the new
element without further changes. Popup surface is untouched (this
override is scoped to .vault-sidebar__header .brand-logo).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-03 18:53:09 -04:00
adlee-was-taken
b2749826b1 docs: CHANGELOG entry for v0.5.0
Three release trains roll into one tag — v0.3.0 backup/restore +
LastPass import, v0.4.0 device authentication, and the v0.5.0
polish + harden bundle.

Renames the existing "Unreleased" heading to v0.5.0 — 2026-05-02
and prepends the polish + harden additions:

- Security: S1 pre-receive hook fix (HIGH-severity authentication
  bypass), S2 tar-restore path-traversal hardening, S3 RELICARIO_*
  env-var audit + cfg-gate.
- Fixed: B1 strength-meter regenerate desync, B2/P4 raw error-code
  leakage in the fullscreen tab.
- Added: P1 password coloring (four reveal surfaces + settings UI),
  P2 setup → fullscreen vault tab handoff. Existing v0.3.0/v0.4.0
  Added entries (sync, register-from-popup, generator-defaults, edit
  TOTP, history, detach, status, backup/restore, vault-tab panel,
  LastPass import + popup deep link, status export age) preserved
  verbatim.
- Changed: P3 form-layout envelope, doc-audit refresh across
  overview / CLAUDE / SECURITY / ARCHITECTURE / foundational spec.
- Internal: C1 stale-branch prune, clippy cleanup, Cargo.lock
  regenerated, CLI/extension refactors preserved from prior trains.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-02 20:48:22 -04:00
adlee-was-taken
a332a9e80d Merge feature/v0.5.0-plan-a-security-cleanup: Plan A security + cleanup
v0.5.0 Plan A — Security Fixes + Repo Cleanup. 7 commits, ~800 net
insertions across the Rust workspace. Four items delivered:

- S1 (HIGH-severity authentication bypass fix): rewrite verify_commit
  in relicario-server. The previous implementation accepted any
  GOODSIG/Good signature line on stderr, ignoring whether the signing
  key was registered or revoked. The new implementation:
  * builds a temp gpg.ssh.allowedSignersFile from devices.json at the
    commit (no global git-config mutation)
  * parses the SHA-256 fingerprint from `git verify-commit --raw`
    stderr via regex
  * checks revocation FIRST (revoked entries may have been removed
    from devices.json), with the historical-commit case
    (committer_ts < revoked_at) explicitly allowed
  * uses committer date (GIT_COMMITTER_DATE / `git show -s
    --format=%ct`), not author date or wall clock
  * tightened the bootstrap guard to require BOTH devices and revoked
    to be empty (closes an empty-devices.json privilege-escalation
    route present in the original code)
  * 4 acceptance integration tests build real on-disk repos with
    SSH-signed commits and verify each scenario

- S2 (tar archive path-traversal hardening): replace
  tar::Archive::unpack with safe_unpack_git_archive. Located in
  relicario-core (per-spec, so integration tests can reach it without
  the bytes-in/bytes-out invariant breaking). Validates each entry's
  type (rejects symlinks/hardlinks), path components (rejects '..',
  RootDir, Windows drive Prefix), and declared size (rejects
  individual or cumulative > 100×compressed-or-1-GiB whichever is
  lower). The CLI's restore path adds a paranoid OS-level
  starts_with(.git/) check on the joined destination as
  defense-in-depth even after textual validation. 5 acceptance tests
  cover path traversal, symlinks, oversized headers (header claim of
  2 GiB tested without allocating disk).

- S3 (RELICARIO_* env-var audit): docs/SECURITY.md gains a
  "Configuration env vars" section enumerating each variable, its
  purpose, and trust assumption. Active-in-all-builds variables
  (RELICARIO_IMAGE, RELICARIO_GITEA_*) are documented; debug-only
  variables (RELICARIO_NO_GROUPS_CACHE, RELICARIO_TEST_*) are gated
  behind cfg(debug_assertions) so the env-var lookup is removed from
  --release binaries.

- C1 (stale feature branch prune): 5 merged feature branches and
  3 worktrees pruned interactively per dev report.

- Bonus: 4d02a50 fixes pre-existing clippy warnings across
  crates/relicario-{core,cli} (deref operators, Option::is_none_or
  vs map_or(true, ...), iter_mut().enumerate() patterns,
  div_ceil()) so the workspace builds clean under `-D warnings`.

Merge resolution: docs/SECURITY.md had a conflict where main's F11/F12
(Device Authentication paragraph naming relicario-server + simplified
"Device registration is optional" line) collided with Plan A's S3
section. Resolved by keeping both — F11/F12's wording for the
Device Authentication section, then Plan A's "Configuration env vars"
section appended below.

Cargo.lock regenerated. The previous committed lock was stale since
commit 8855078 (--totp-qr); cargo test on both devs' worktrees
produced identical regenerated locks. Plan A genuinely added regex +
tempfile to relicario-server (both already transitively present from
relicario-cli), so no new top-level deps; the Cargo.lock churn is
catch-up of crate-version bumps that have happened since the last
commit-of-record.

Tests: 248 cargo tests pass; extension tests unchanged (336/8 with 8
pre-existing device-auth scaffolding failures).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-02 19:54:12 -04:00
adlee-was-taken
d45dd10917 Merge feature/v0.5.0-plan-b-extension-ux: Plan B extension UX
v0.5.0 Plan B — Extension UX Polish + Bug Fixes. 15 commits, 22 files,
+853/-33 lines, all in extension/. Five features delivered:

- P4: ERROR_COPY centralized map; popup humanizeError now a thin shell
  over lookupErrorCopy; fullscreen tab gets friendly title/body/CTA blocks
  (closes B2). Generated test enumerates every grep'd error code so the
  registry can't drift.
- B1: applyGeneratedPassword dispatches a synthetic input event after
  the regenerate handler sets the password value, so the strength-meter
  listener re-rates the new value.
- P1: end-to-end password coloring — pure colorizePassword utility,
  chrome.storage.sync round-trip via applyColorScheme, CSS rules with
  custom properties, four reveal surfaces (popup item-detail, vault
  item-detail, field-history, generator preview), boot wiring + storage
  listener, Display section in settings with color pickers + swatch +
  reset.
- P3: .form-lower wrapper constrains lower form sections (notes,
  custom-fields, attachments, actions) to the same max-width: 960px
  envelope as .form-grid above, gated on surface === 'fullscreen' so
  the popup is unaffected.
- P2: finishSetup() opens the fullscreen vault tab and best-effort
  closes the setup tab after successful device registration. Both
  create-new and attach-existing flows funnel through it.

Implementation notes:
- vault.ts uses event delegation on the stable #vault-app root for
  .error-cta clicks (better than the plan's per-render handler attach;
  survives re-renders without leaking listeners).
- fields.ts gained a kind: 'password' | 'concealed' option on
  ConcealedRowOpts so wireFieldHandlers can apply colorizePassword
  selectively at the shared rendering layer.
- New WASM stub at src/__stubs__/relicario_wasm.stub.ts + vitest config
  alias lets unit tests import setup.ts without exploding on the
  runtime-only WASM module.

Tests: +28 (336/8 vs main's 308/8); 8 pre-existing device-auth
scaffolding failures unchanged. Builds clean: cargo wasm + Chrome
bundle + Firefox bundle.

Manual acceptance items (P3 viewport sweep at 1920/1440/1024/768,
P2 setup-flow smoke) deferred to user's pre-tag smoke walk.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-02 19:42:22 -04:00
adlee-was-taken
4d02a50cc8 chore(core): fix pre-existing clippy warnings (-D warnings gate)
Resolves pre-existing lint issues in imgsecret.rs, time.rs, totp.rs,
and crypto.rs that blocked the cargo clippy --workspace -D warnings
gate. No logic changes: loop-index → iterator, manual div_ceil →
.div_ceil(), manual range contains → .contains(), auto-deref cleanup.

Also fixes pre-existing warnings in relicario-cli (main.rs, session.rs,
device.rs, gitea.rs, helpers.rs, test helpers): dead_code suppression,
too_many_arguments, literal_with_empty_format_string, manual_char_cmp,
map_or → is_none_or, and repeat().take() → vec! in test helpers.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 19:32:45 -04:00
adlee-was-taken
5d9a7ee8d3 docs(multi-agent): expand kickoff section with full spec→plans→launch workflow 2026-05-02 18:51:52 -04:00
adlee-was-taken
006e67c361 fix(cli): cfg-gate RELICARIO_NO_GROUPS_CACHE to debug builds (audit S3)
The groups-cache opt-out is a developer debugging knob, not a
user-facing config. Gating the env-var lookup behind cfg!(debug_assertions)
makes release builds ignore the variable; the optimiser removes the
lookup entirely, so the variable name doesn't appear in release binary
strings output.

Doc-comments updated to reflect the new behaviour.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-05-02 18:51:15 -04:00
adlee-was-taken
95d1ff833c docs: enumerate RELICARIO_* env vars in SECURITY.md (audit S3)
Adds a "Configuration env vars" section listing every RELICARIO_*
variable read by production code, with purpose and trust boundary.
Splits user-facing vars from debug-only ones (cfg(debug_assertions))
to make the attack surface explicit for security reviewers.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-05-02 18:50:44 -04:00
adlee-was-taken
4fc1357368 docs: add multi-agent development paradigm README 2026-05-02 17:19:25 -04:00
adlee-was-taken
df58b0dda1 chore(relay): add relay MCP server to project Claude config 2026-05-02 17:18:47 -04:00
adlee-was-taken
ed9fcbe6ba feat(relay): start.sh launcher with --manual/--tmux/--kitty modes 2026-05-02 17:18:31 -04:00
adlee-was-taken
0172a06698 feat(relay): MCP SSE server with post_message/read_messages/list_pending 2026-05-02 17:17:41 -04:00
adlee-was-taken
6d5a2570d4 feat(relay): in-memory queue with consume-once semantics 2026-05-02 17:16:21 -04:00
adlee-was-taken
6a1c6d5875 fix(core,cli): harden backup-restore tar unpack against path traversal (audit S2)
cmd_backup_restore previously called tar::Archive::unpack with default
settings, allowing malicious .relbak archives to escape the target
directory via .. entries, absolute paths, or symlinks. No size cap
meant tar bombs could exhaust disk space.

Replaced with relicario_core::safe_unpack_git_archive which:
- Rejects .. (ParentDir), absolute (RootDir), and drive-prefix
  (Prefix) components with "path traversal blocked" error.
- Rejects symlinks and hardlinks outright.
- Checks declared header size before reading body; rejects entries or
  cumulative totals exceeding the caller's cap.
- Returns (relative-path, bytes) pairs; the CLI re-checks
  dest.starts_with(git_dir) after OS-level path resolution.
- CLI cap: min(100 × compressed size, 1 GiB).

Acceptance: 5 unit tests in relicario-core (traversal, absolute path,
symlink, size bomb, happy path); existing CLI backup roundtrip tests
remain green.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 17:16:11 -04:00
adlee-was-taken
6d8f699fcb chore(relay): scaffold tools/relay with MCP SDK dep 2026-05-02 17:15:46 -04:00
adlee-was-taken
c0921b134d docs(plan): relay server implementation plan
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 16:44:06 -04:00
adlee-was-taken
0443f6a3b4 docs(spec): add top-level README section to relay server design
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 16:38:30 -04:00
adlee-was-taken
5e8e617a4d docs(spec): relay server design for multi-agent message bus
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 16:37:17 -04:00
adlee-was-taken
efac53d527 fix(server): real signature verification in pre-receive hook (audit S1)
verify_commit previously loaded devices.json/revoked.json and threw
both away, accepting any commit whose stderr contained "GOODSIG" or
"Good signature". This left device registration and revocation as
no-ops: unregistered keys could push, revoked keys kept working.

The fix:
- Build a temp gpg.ssh.allowedSignersFile from devices.json at the
  commit, passed via GIT_CONFIG_COUNT/KEY/VALUE env (no global git
  config mutation).
- Run git verify-commit --raw and parse SHA256 fingerprint from stderr
  regardless of exit code (SSH git outputs the "Good" line even for
  keys not in allowed-signers, with "No principal matched" + exit 1).
- Check revoked.json FIRST: reject if committer_ts >= revoked_at;
  accept historical commits (committer_ts < revoked_at).
- Reject if fingerprint is not in active devices.json.
- Bootstrap: accept only when BOTH devices.json AND revoked.json are
  empty/absent (not just devices.json alone).

Acceptance: 4 integration tests covering the matrix.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 16:34:37 -04:00
adlee-was-taken
af8626fb5f docs(audit): mark all 8 proposed findings fixed (PM follow-up)
Updates each Status: line from "Proposed; needs user decision" to
the actual fix-commit SHA. The audit doc now records the full state:
6 trivial findings fixed in the initial 900ccf1 pass; 8 deeper
findings fixed across ca059e7, 8fd9a05, 1342228, 76d092d, 9c97f9f
during v0.5.0 PM kickoff.

Pre-tag checklist: doc-audit follow-ups item is now done.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-02 16:28:08 -04:00
adlee-was-taken
9c97f9f939 docs(spec): banner foundational design spec as historical (audit F13)
The 2026-04-11 design spec lists secure notes, secure documents, TOTP,
Firefox extension, LastPass import, and device authentication as
"Post-V1 Ideas" — most of which shipped over the following weeks.
Per the doc/architecture/overview.md convention, specs are frozen
decision artifacts and shouldn't be retro-edited; instead, add a
one-line status banner pointing readers at CHANGELOG.md and the
overview doc for current state.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-02 16:26:39 -04:00
adlee-was-taken
76d092d4f6 docs(architecture): note settings.enc + typed items in vault-creation flow (audit F10)
The Vault Creation Flow ASCII showed only manifest.enc as init's
encrypted artifact; cmd_init has been writing settings.enc in parallel
since the VaultSettings rollout. Update the encrypt step to show both
artifacts side-by-side with independent nonces.

Below the ASCII, add a short pointer noting that the per-item lifecycle
(typed-item envelope, attachment encryption, field-history) lives in
crates/relicario-core/ARCHITECTURE.md and reuses the same master_key +
XChaCha20-Poly1305 primitives. The doc-audit framing is "this top-level
doc could just point at the per-crate docs" — taking that trim path.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-02 16:26:09 -04:00
adlee-was-taken
1342228a51 docs(security): name relicario-server in device-auth section (audit F11/F12)
- F12: Device Authentication section now names the relicario-server crate
  and its two subcommands (generate-hook, verify-commit), and notes that
  signed commits without the server-side hook provide authorship only —
  any pusher can still land an unsigned commit.
- F11: drop the "optional before v0.4.0" version line (v0.4.0 was never
  tagged; v0.5.0 is the first release with the hook) and replace with a
  one-liner: registration is optional but recommended for shared vaults.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-02 16:25:21 -04:00
adlee-was-taken
d539050aec chore(server): add assert_cmd/predicates/tempfile dev-deps
Needed for the upcoming verify-commit acceptance suite (audit S1).

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-05-02 16:23:24 -04:00
adlee-was-taken
8fd9a05875 docs(claude): refresh project tree, IDs line, and roadmap (audit F2/F3/F4)
- F2: add relicario-server crate to the project-structure tree
- F3: replace stale "Next: WASM + Chrome MV3 (Plan 2)" roadmap line with
  the v0.5.0/Phase-3/1C-γ/LastPass picture
- F4: ItemIds and FieldIds are 16-char hex (64 bits) per audit M8;
  AttachmentIds are first 32 hex of SHA-256 (128 bits) per audit I2/B4

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-02 16:23:17 -04:00
adlee-was-taken
8a72b5e192 feat(core): add device::fingerprint helper for SSH SHA256 fingerprints
Wraps ssh-key's PublicKey::fingerprint(HashAlg::Sha256). Output format
matches ssh-keygen -lf and git verify-commit --raw stderr
(SHA256:<43-char base64>). Used by the upcoming relicario-server
verify-commit rewrite (audit S1).

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-05-02 16:23:10 -04:00
adlee-was-taken
ca059e7507 docs(overview): add relicario-server crate to four-codebase framing
Doc-audit Finding 1. The repo has had four Rust crates since early May
when the pre-receive hook crate landed, but docs/architecture/overview.md
still framed itself around three. Update:

- "The three codebases" → "The four codebases" (intro + heading)
- ASCII diagram fans core out to cli + server + wasm, with wasm feeding
  the extension
- Table gains a relicario-server row noting it lives on the git server
  and only sees public key material
- Build matrix adds `cargo build -p relicario-server --release`
- "Where to look next" points at server src + the device-auth design spec

Server has no user-facing surface, so the CLI/extension parity rule is
clarified to exclude it (it is server-side enforcement of an invariant
the clients already agreed to).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-02 16:20:45 -04:00
84 changed files with 13382 additions and 447 deletions

View File

@@ -1,4 +1,10 @@
{
"mcpServers": {
"relay": {
"type": "sse",
"url": "http://localhost:7331/sse"
}
},
"enabledPlugins": {
"superpowers@claude-plugins-official": true
}

1
.gitignore vendored
View File

@@ -7,3 +7,4 @@ extension/dist-firefox/
extension/wasm/
reference.jpg
ref.jpg
tools/relay/node_modules/

View File

@@ -1,9 +1,76 @@
# Changelog
## Unreleased
## v0.5.0 — 2026-05-02
Three release trains roll into one tag — backup/restore + LastPass
import (originally v0.3.0), device authentication (originally v0.4.0),
and the v0.5.0 polish + harden bundle (security fixes + UX fixes +
two confirmed bugs).
### Security
- **Pre-receive hook now actually verifies signatures (audit S1, HIGH).**
Earlier `relicario-server` builds accepted any commit with a
`Good signature` line on stderr regardless of which key signed it —
device-auth was a no-op. The hook now builds an `allowed_signers`
file from `devices.json` at the commit (via `GIT_CONFIG_*` env, no
global git-config mutation), parses the SSH SHA-256 fingerprint out
of `git verify-commit --raw` stderr, and rejects unregistered keys or
revoked keys whose committer-date is at or after the revocation
timestamp. Bootstrap mode is preserved only when **both**
`devices.json` AND `revoked.json` are empty (closes an
empty-devices.json privilege-escalation route).
- **Backup-restore tar unpacking hardened (audit S2).** `relicario
backup restore` no longer trusts `tar::Archive::unpack`'s defaults.
A new `relicario_core::safe_unpack_git_archive` validates each
entry's path components (rejects `..`, absolute paths, Windows
drive prefixes), rejects symlinks/hardlinks, and caps total
uncompressed size at the lower of 100×compressed-bytes or 1 GiB.
The CLI restore path adds a paranoid `dest.starts_with(.git/)`
check after path-joining as defense-in-depth.
- **`RELICARIO_*` env-var surface audited (audit S3).** `docs/SECURITY.md`
gains a per-variable trust table. `RELICARIO_NO_GROUPS_CACHE` (a
developer escape hatch, not a user knob) is now
`cfg(debug_assertions)`-gated and is a no-op in `--release` builds;
the env-var lookup is removed from the binary by the optimiser.
### Fixed
- **Strength meter no longer goes stale after the regenerate button (B1).**
Programmatic `input.value = newPassword` doesn't fire `input`
events; the regenerate handler now dispatches a synthetic
`InputEvent('input', { bubbles: true })` so the meter listener
re-rates the new value.
- **Snake_case error codes no longer leak into the UI (B2 / P4).**
Errors like `vault_locked`, `origin_mismatch`, `unauthorized_sender`
used to render verbatim in the fullscreen vault tab and (in some
cases) the popup. New `extension/src/shared/error-copy.ts` central
registry maps every service-worker error code to friendly
title/body/CTA copy; the popup and fullscreen tab consume the
same map. The fullscreen lock screen's `vault_locked` block now
reads `Vault locked / Unlock your vault to continue. / [Unlock
vault]`. A generated test enumerates the live error codes via
grep so the registry can't drift.
### Added
- **Sidebar logo in the fullscreen vault tab.** The
`vault-sidebar__header` now renders the 16-optimized SVG logo
inline before the "Relicario" wordmark (20×20 px, `flex-shrink: 0`
so it survives narrow-pane wraps). Popup unaffected.
- **Password coloring (P1).** Revealed passwords in the popup
item-detail, fullscreen item view, field-history viewer, and
generator preview render digits and symbols in distinct colors.
Defaults: blue digits, red symbols. Users can override via the
new Display section in settings (color pickers + live preview
swatch + reset). Defaults round-trip via
`chrome.storage.sync.password_display_scheme`; cross-device when
Chrome sync is enabled.
- **Setup wizard hands off to the fullscreen vault tab on completion
(P2).** Both create-new and attach-existing flows now open
`vault.html` in a new tab and best-effort close the setup tab
after device registration succeeds — replaces the prior
setup-tab-stays-open terminal screen.
- **Sync now button** in the extension settings view — surfaces the
previously hidden `{ type: 'sync' }` SW message to users with success /
error feedback.
@@ -59,6 +126,30 @@
file `cmd_backup_export` writes on success). Reads "never" for
fresh vaults, "4 days ago" otherwise.
### Changed
- **Form layout in the fullscreen vault tab is now visually consistent
(P3).** Notes, custom-fields disclosure, attachments disclosure, and
form-actions in fullscreen logins now sit inside a `.form-lower`
wrapper with the same `max-width: 960px; margin: 0 auto` envelope as
the `.form-grid` cards above. Removes the visual rhythm break at the
2-col → full-width transition. The popup surface is unchanged.
- **Documentation refreshed for v0.5.0 (doc audit, 14 findings).**
`docs/architecture/overview.md` now describes four codebases (the
`relicario-server` pre-receive hook crate is no longer invisible);
`CLAUDE.md` project tree and roadmap reflect current state;
`docs/SECURITY.md` names the server crate and its `verify-commit` /
`generate-hook` subcommands and notes the without-the-hook-it's-
advisory caveat; `docs/ARCHITECTURE.md` shows `settings.enc` as a
parallel artifact in the vault-creation flow; the foundational
design spec gains a "historical" status banner pointing readers at
the current docs.
- `relicario generate` now consults `VaultSettings.generator_defaults` when
invoked inside an initialized vault. Explicit flags (`--length`,
`--bip39`, `--words`, `--symbols`, `--separator`) override the vault
default. Outside a vault, behavior is unchanged (length 20, safe symbol
set, 5 BIP39 words, space separator).
### Known limitations
- **Mid-restore failure leaves the target remote in a half-written
@@ -74,6 +165,13 @@
### Internal
- 5 stale local feature branches and 3 worktrees pruned (audit C1).
- Pre-existing clippy warnings cleaned up across `relicario-{core,cli}`
(deref operators, `Option::is_none_or` over `map_or(true, ...)`,
`iter_mut().enumerate()` patterns, `div_ceil()`) so the workspace
builds clean under `-D warnings`.
- `Cargo.lock` regenerated and committed; was stale since the
`--totp-qr` commit.
- Refactored `cmd_add` and `cmd_edit` in the CLI: each `ItemCore` variant
now has its own `build_*_item` / `edit_*` helper. Pure mechanical
extraction; behavior unchanged. The dispatcher matches and delegates.
@@ -83,14 +181,6 @@
`setup.ts` since it walks live wizard state. Setup.ts went from
1205 → 1137 lines.
### Changed
- `relicario generate` now consults `VaultSettings.generator_defaults` when
invoked inside an initialized vault. Explicit flags (`--length`,
`--bip39`, `--words`, `--symbols`, `--separator`) override the vault
default. Outside a vault, behavior is unchanged (length 20, safe symbol
set, 5 BIP39 words, space separator).
## v0.2.0 — 2026-04-27
### Fixed

View File

@@ -48,9 +48,11 @@ crates/
│ ├── src/helpers.rs # vault_dir, git_command, iso8601
│ ├── src/session.rs # UnlockedVault (master key in Zeroizing)
│ └── tests/ # basic_flows, edit_and_history, attachments, settings, vault_detection
── relicario-wasm/ # WASM bindings for the extension
├── src/lib.rs # #[wasm_bindgen] surface
└── src/session.rs # opaque SessionHandle → Zeroizing<[u8;32]>
── relicario-wasm/ # WASM bindings for the extension
├── src/lib.rs # #[wasm_bindgen] surface
└── src/session.rs # opaque SessionHandle → Zeroizing<[u8;32]>
└── relicario-server/ # `relicario-server` binary (pre-receive Git hook)
└── src/main.rs # verify-commit + generate-hook subcommands
```
## Key design decisions
@@ -76,7 +78,7 @@ passphrase (UTF-8 bytes) || image_secret (32 bytes from reference JPEG)
- Tests use fast Argon2id params (m=256, t=1, p=1) so they don't take forever.
- Test JPEGs are generated synthetically via `make_test_jpeg()` — no binary test fixtures.
- Item IDs are random 8-char hex strings.
- Item IDs and Field IDs are random 16-char hex strings (64 bits of OsRng entropy). AttachmentIds are content-addressed: first 32 hex chars of SHA-256 over the plaintext (128 bits).
- Git history is preserved as an audit log — no squashing.
- The CLI shells out to `git` for sync — no libgit2/gitoxide dependency.
@@ -90,4 +92,4 @@ Full threat model, entropy analysis, and architecture: `docs/superpowers/specs/2
## Roadmap
Next: WASM build + Chrome MV3 browser extension (Plan 2). Then mobile (Rust core compiles to ARM).
Next: v0.5.0 polish + harden (in progress). After that, Phases 3/4 of the fullscreen UX redesign (vault-tab shell + command palette), Plan 1C-γ (attachments + Document + trash/history/device UI), and the LastPass importer. Mobile (Rust core compiles to ARM) and recovery QR remain on the roadmap.

947
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
[package]
name = "relicario-cli"
version = "0.2.0"
version = "0.5.0"
edition = "2021"
description = "CLI for relicario password manager"
@@ -28,10 +28,10 @@ clap_complete = "4"
image = { version = "0.25", default-features = false, features = ["jpeg", "png"] }
rqrr = "0.7"
reqwest = { version = "0.12", features = ["blocking", "json"] }
qrcode = { version = "0.14", features = ["svg"] }
[dev-dependencies]
assert_cmd = "2"
predicates = "3"
tempfile = "3"
qrcode = "0.14"
serde_json = "1"

View File

@@ -91,6 +91,7 @@ pub fn store_device_keys(
}
/// Load the signing private key for a device.
#[allow(dead_code)]
pub fn load_signing_key(name: &str) -> Result<Zeroizing<String>> {
let path = device_dir(name)?.join("signing.key");
let key = fs::read_to_string(&path)
@@ -99,6 +100,7 @@ pub fn load_signing_key(name: &str) -> Result<Zeroizing<String>> {
}
/// Load the deploy private key for a device.
#[allow(dead_code)]
pub fn load_deploy_key(name: &str) -> Result<Zeroizing<String>> {
let path = device_dir(name)?.join("deploy.key");
let key = fs::read_to_string(&path)
@@ -115,6 +117,7 @@ pub fn load_gitea_key_id(name: &str) -> Result<u64> {
}
/// Delete the local key directory for a device.
#[allow(dead_code)]
pub fn delete_device_keys(name: &str) -> Result<()> {
let dir = device_dir(name)?;
if dir.exists() {

View File

@@ -21,7 +21,9 @@ struct CreateKeyRequest<'a> {
#[derive(Debug, Deserialize)]
pub struct DeployKey {
pub id: u64,
#[allow(dead_code)]
pub title: String,
#[allow(dead_code)]
pub key: String,
}
@@ -89,6 +91,7 @@ impl GiteaClient {
}
/// List all deploy keys.
#[allow(dead_code)]
pub fn list_deploy_keys(&self) -> Result<Vec<DeployKey>> {
let url = format!(
"{}/repos/{}/{}/keys",

View File

@@ -34,6 +34,7 @@ pub fn vault_dir() -> Result<PathBuf> {
}
/// Path to the `.relicario/` configuration directory within the vault.
#[allow(dead_code)]
pub fn relicario_dir() -> Result<PathBuf> {
Ok(vault_dir()?.join(".relicario"))
}
@@ -88,19 +89,21 @@ fn plural(n: i64) -> &'static str { if n == 1 { "" } else { "s" } }
///
/// **Plaintext leak:** group names land on disk in cleartext alongside the
/// vault directory. This is intentional — the file feeds shell completion,
/// which cannot prompt for a passphrase. Set `RELICARIO_NO_GROUPS_CACHE=1`
/// to suppress the write.
/// which cannot prompt for a passphrase. In debug builds, set
/// `RELICARIO_NO_GROUPS_CACHE=1` to suppress the write.
pub fn groups_cache_path(vault_dir: &Path) -> PathBuf {
vault_dir.join(".relicario").join("groups.cache")
}
/// Write the sorted set of group names to `<vault_dir>/.relicario/groups.cache`,
/// one name per line. A no-op if `RELICARIO_NO_GROUPS_CACHE` is set.
/// one name per line. In debug builds, setting `RELICARIO_NO_GROUPS_CACHE`
/// suppresses the write (developer debugging tool). In release builds the env
/// var is ignored.
pub fn write_groups_cache(
vault_dir: &Path,
groups: &std::collections::BTreeSet<String>,
) -> std::io::Result<()> {
if std::env::var_os("RELICARIO_NO_GROUPS_CACHE").is_some() {
if cfg!(debug_assertions) && std::env::var_os("RELICARIO_NO_GROUPS_CACHE").is_some() {
return Ok(());
}
let path = groups_cache_path(vault_dir);

View File

@@ -170,7 +170,7 @@ enum Commands {
///
/// For `--group <TAB>` autocomplete, the bash/zsh/fish scripts read
/// the plaintext `${RELICARIO_VAULT}/.relicario/groups.cache` file,
/// which the CLI refreshes on every manifest read. Set
/// which the CLI refreshes on every manifest read. In debug builds, set
/// `RELICARIO_NO_GROUPS_CACHE=1` to opt out of the cache (completion
/// will fall back to no value enumeration).
///
@@ -196,6 +196,12 @@ enum Commands {
#[command(subcommand)]
action: DeviceAction,
},
/// Recovery QR operations — generate or unwrap the 2FA recovery code.
RecoveryQr {
#[command(subcommand)]
cmd: RecoveryQrCmd,
},
}
#[derive(Subcommand)]
@@ -403,6 +409,14 @@ enum DeviceAction {
List,
}
#[derive(clap::Subcommand)]
enum RecoveryQrCmd {
/// Generate a recovery QR code and display it as ASCII art in the terminal.
Generate,
/// Unwrap a recovery QR payload (base64) to recover the image_secret as hex.
Unwrap,
}
fn main() -> Result<()> {
let cli = Cli::parse();
match cli.command {
@@ -436,6 +450,7 @@ fn main() -> Result<()> {
}
Commands::Rate { passphrase } => cmd_rate(passphrase),
Commands::Device { action } => cmd_device(action),
Commands::RecoveryQr { cmd } => cmd_recovery_qr(cmd),
}
}
@@ -540,7 +555,7 @@ fn cmd_init(image: PathBuf, output: PathBuf) -> Result<()> {
};
let carrier = fs::read(&image)
.with_context(|| format!("failed to read carrier image {}", image.display()))?;
let stego = imgsecret::embed(&carrier, &*image_secret)?;
let stego = imgsecret::embed(&carrier, &image_secret)?;
fs::write(&output, &stego)
.with_context(|| format!("failed to write reference image {}", output.display()))?;
@@ -550,7 +565,7 @@ fn cmd_init(image: PathBuf, output: PathBuf) -> Result<()> {
let params = KdfParams { argon2_m: 65536, argon2_t: 3, argon2_p: 4 };
// Derive master key, then persist an empty Manifest + default VaultSettings.
let master_key = derive_master_key(passphrase.as_bytes(), &*image_secret, &salt, &params)?;
let master_key = derive_master_key(passphrase.as_bytes(), &image_secret, &salt, &params)?;
fs::create_dir_all(&relicario_dir)?;
fs::create_dir_all(root.join("items"))?;
@@ -645,6 +660,7 @@ fn cmd_add(kind: AddKind) -> Result<()> {
// (for attachment-cap settings + writing the encrypted blob alongside
// the item).
#[allow(clippy::too_many_arguments)]
fn build_login_item(
title: Option<String>,
username: Option<String>,
@@ -860,6 +876,7 @@ fn build_document_item(
Ok(item)
}
#[allow(clippy::too_many_arguments)]
fn build_totp_item(
title: Option<String>,
issuer: Option<String>,
@@ -924,7 +941,7 @@ fn prompt_optional(label: &str) -> Result<Option<String>> {
fn parse_month_year(s: &str) -> Result<relicario_core::MonthYear> {
// Accepts MM/YYYY or MM-YYYY or MM/YY.
let (m_str, y_str) = s.split_once(|c: char| c == '/' || c == '-')
let (m_str, y_str) = s.split_once(['/', '-'])
.ok_or_else(|| anyhow::anyhow!("expected MM/YYYY"))?;
let month: u8 = m_str.parse().context("invalid month")?;
let year: u16 = if y_str.len() == 2 {
@@ -998,12 +1015,12 @@ fn cmd_get(query: String, show: bool, copy: bool) -> Result<()> {
if let Some(u) = &l.url { println!("URL: {u}"); }
if let Some(t) = &l.totp {
if show {
println!("TOTP: {}", data_encoding::BASE32.encode(&*t.secret));
println!("TOTP: {}", data_encoding::BASE32.encode(&t.secret));
} else {
println!("TOTP: **** (use --show to reveal)");
}
}
if let Some(p) = &l.password { Some(p.clone()) } else { None }
l.password.clone()
}
ItemCore::SecureNote(n) => {
if show { println!("Body:\n{}", n.body.as_str()); }
@@ -1125,8 +1142,8 @@ fn cmd_list(
Some(t) => e.r#type == t,
None => true,
})
.filter(|e| group_filter.as_ref().map_or(true, |g| e.group.as_deref() == Some(g.as_str())))
.filter(|e| tag_filter.as_ref().map_or(true, |t| e.tags.iter().any(|x| x == t)))
.filter(|e| group_filter.as_ref().is_none_or(|g| e.group.as_deref() == Some(g.as_str())))
.filter(|e| tag_filter.as_ref().is_none_or(|t| e.tags.iter().any(|x| x == t)))
.collect();
entries.sort_by(|a, b| a.title.to_lowercase().cmp(&b.title.to_lowercase()));
@@ -1135,7 +1152,7 @@ fn cmd_list(
return Ok(());
}
println!("{:<16} {:<14} {:<6} {}", "ID", "TYPE", "FAV", "TITLE");
println!("{:<16} {:<14} {:<6} TITLE", "ID", "TYPE", "FAV");
for e in entries {
let fav = if e.favorite { " *" } else { "" };
println!("{:<16} {:<14} {:<6} {}", e.id.as_str(), format!("{:?}", e.r#type), fav, e.title);
@@ -1718,9 +1735,32 @@ fn cmd_backup_restore(input: PathBuf, target: PathBuf) -> Result<()> {
// .git/ history.
if let Some(tar_bytes) = &unpacked.git_archive {
let mut archive = tar::Archive::new(tar_bytes.as_slice());
archive.unpack(target.join(".git"))
.with_context(|| "failed to untar .git/")?;
// Cap: 100× the compressed bundle size, or 1 GiB, whichever is lower.
let cap = std::cmp::min(
(tar_bytes.len() as u64).saturating_mul(100),
relicario_core::DEFAULT_MAX_UNCOMPRESSED,
);
let entries = relicario_core::safe_unpack_git_archive(tar_bytes, cap)
.with_context(|| "failed to safely unpack .git/ archive")?;
let git_dir = target.join(".git");
for (rel_path, body) in entries {
let dest = git_dir.join(&rel_path);
// Paranoid OS-level check even after textual validation in core.
if !dest.starts_with(&git_dir) {
anyhow::bail!(
"tar entry {} resolved outside .git/ (path traversal blocked)",
rel_path.display()
);
}
if let Some(parent) = dest.parent() {
fs::create_dir_all(parent).with_context(|| {
format!("create parent {}", parent.display())
})?;
}
fs::write(&dest, &body).with_context(|| {
format!("write {}", dest.display())
})?;
}
} else {
// No history bundled — start a fresh git repo.
let status = crate::helpers::git_command(&target, &["init"]).status()?;
@@ -1950,7 +1990,7 @@ fn cmd_attachments(query: String) -> Result<()> {
let entry = resolve_query(&manifest, &query)?;
let item = vault.load_item(&entry.id)?;
if item.attachments.is_empty() { eprintln!("(no attachments)"); return Ok(()); }
println!("{:<17} {:>12} {:<22} {}", "AID", "SIZE", "MIME", "FILENAME");
println!("{:<17} {:>12} {:<22} FILENAME", "AID", "SIZE", "MIME");
for a in &item.attachments {
println!("{:<17} {:>12} {:<22} {}", a.id.as_str(), a.size, a.mime_type, a.filename);
}
@@ -2518,7 +2558,7 @@ fn cmd_device(action: DeviceAction) -> Result<()> {
return Ok(());
}
println!("{:<20} {:<20} {}", "NAME", "ADDED", "SIGNING KEY (prefix)");
println!("{:<20} {:<20} SIGNING KEY (prefix)", "NAME", "ADDED");
println!("{}", "-".repeat(72));
for d in &devices {
let marker = if d.name == current { " *" } else { "" };
@@ -2535,3 +2575,67 @@ fn cmd_device(action: DeviceAction) -> Result<()> {
}
}
}
fn cmd_recovery_qr(cmd: RecoveryQrCmd) -> Result<()> {
match cmd {
RecoveryQrCmd::Generate => cmd_recovery_qr_generate(),
RecoveryQrCmd::Unwrap => cmd_recovery_qr_unwrap(),
}
}
fn cmd_recovery_qr_generate() -> Result<()> {
use relicario_core::{generate_recovery_qr, imgsecret};
use zeroize::Zeroizing;
let image_path = crate::session::get_image_path()?;
let image_bytes = std::fs::read(&image_path)
.with_context(|| format!("read reference image {}", image_path.display()))?;
let image_secret = imgsecret::extract(&image_bytes)
.context("extract image secret")?;
let passphrase = Zeroizing::new(
rpassword::prompt_password("Enter vault passphrase: ")
.context("read passphrase")?
);
let payload = generate_recovery_qr(passphrase.as_str(), &image_secret)
.map_err(|e| anyhow::anyhow!("{e}"))?;
use qrcode::{EcLevel, QrCode, render::unicode};
let code = QrCode::with_error_correction_level(payload.as_bytes(), EcLevel::M)
.expect("valid payload");
let image = code
.render::<unicode::Dense1x2>()
.dark_color(unicode::Dense1x2::Dark)
.light_color(unicode::Dense1x2::Light)
.build();
println!("{image}");
println!("Recovery QR generated. Print or photograph this code and store it securely.");
println!("The QR has NOT been saved to disk.");
Ok(())
}
fn cmd_recovery_qr_unwrap() -> Result<()> {
use relicario_core::unwrap_recovery_qr;
use std::io::BufRead;
use zeroize::Zeroizing;
println!("Paste the base64 recovery QR payload and press Enter:");
let stdin = std::io::stdin();
let payload_b64 = stdin.lock().lines().next()
.context("no input")??;
let payload_b64 = payload_b64.trim().to_owned();
let bytes = data_encoding::BASE64.decode(payload_b64.as_bytes())
.map_err(|e| anyhow::anyhow!("base64 decode: {e}"))?;
let passphrase = Zeroizing::new(
rpassword::prompt_password("Enter passphrase: ")
.context("read passphrase")?
);
let secret = unwrap_recovery_qr(&bytes, passphrase.as_str())
.map_err(|e| anyhow::anyhow!("{e}"))?;
println!("image_secret: {}", hex::encode(secret.as_ref()));
Ok(())
}

View File

@@ -50,7 +50,7 @@ impl UnlockedVault {
let master_key = derive_master_key(
passphrase.as_bytes(),
&*image_secret,
&image_secret,
&salt,
&params,
)?;

View File

@@ -68,7 +68,7 @@ fn detach_removes_attachment_and_blob() {
// Encrypted blob file is gone.
let blob_path = v.path()
.join("attachments")
.join(stdout.lines().nth(1).is_some().then_some("").unwrap_or(""));
.join("");
let item_attach_dir = std::fs::read_dir(v.path().join("attachments"))
.unwrap().next().unwrap().unwrap().path();
let blob = item_attach_dir.join(format!("{aid}.enc"));

View File

@@ -78,6 +78,7 @@ impl TestVault {
cmd.output().unwrap()
}
#[allow(dead_code)]
pub fn run_with_backup_pass(&self, args: &[&str], backup_pass: &str) -> std::process::Output {
let mut cmd = Command::cargo_bin("relicario").unwrap();
cmd.current_dir(self.dir.path())
@@ -91,6 +92,7 @@ impl TestVault {
cmd.output().unwrap()
}
#[allow(dead_code)]
pub fn run_with_input(&self, args: &[&str], extra: &[&str]) -> std::process::Output {
let mut cmd = Command::cargo_bin("relicario").unwrap();
cmd.current_dir(self.dir.path())

View File

@@ -1,6 +1,6 @@
[package]
name = "relicario-core"
version = "0.2.0"
version = "0.5.0"
edition = "2021"
description = "Core library for relicario password manager"
@@ -31,5 +31,6 @@ zstd = { version = "0.13", default-features = false }
tar = { version = "0.4", default-features = false }
base64 = "0.22"
csv = "1"
qrcode = { version = "0.14", default-features = false, features = ["svg"] }
[dev-dependencies]

View File

@@ -243,6 +243,23 @@ pub fn derive_master_key(
Ok(output)
}
/// Like `derive_master_key` but takes an already-assembled `input` byte slice directly,
/// allowing callers to apply their own domain separation before KDF.
pub fn derive_master_key_raw(
input: &[u8],
salt: &[u8; 32],
params: &KdfParams,
) -> Result<Zeroizing<[u8; 32]>> {
let argon2_params = Params::new(params.argon2_m, params.argon2_t, params.argon2_p, Some(32))
.map_err(|e| RelicarioError::Kdf(e.to_string()))?;
let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, argon2_params);
let mut output = Zeroizing::new([0u8; 32]);
argon2
.hash_password_into(input, salt, output.as_mut())
.map_err(|e| RelicarioError::Kdf(e.to_string()))?;
Ok(output)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -408,7 +425,7 @@ mod tests {
blob.extend_from_slice(&[0u8; 16]);
let key = Zeroizing::new([0u8; 32]);
let err = decrypt(&*key, &blob).expect_err("v1 blob should fail decrypt");
let err = decrypt(&key, &blob).expect_err("v1 blob should fail decrypt");
match err {
RelicarioError::UnsupportedFormatVersion { found, expected } => {
assert_eq!(found, 0x01);

View File

@@ -106,6 +106,16 @@ pub fn verify(public_key_openssh: &str, data: &[u8], signature_b64: &str) -> Res
Ok(verifying_key.verify(data, &signature).is_ok())
}
/// Compute the OpenSSH SHA-256 fingerprint of a public key.
/// Output format matches `ssh-keygen -lf` and `git verify-commit --raw`:
/// `SHA256:<43-char base64 without padding>`.
pub fn fingerprint(public_key_openssh: &str) -> Result<String> {
use ssh_key::HashAlg;
let public = PublicKey::from_openssh(public_key_openssh)
.map_err(|e| RelicarioError::DeviceKey(format!("parse public key: {e}")))?;
Ok(public.fingerprint(HashAlg::Sha256).to_string())
}
#[cfg(test)]
mod tests {
use super::*;
@@ -132,4 +142,27 @@ mod tests {
let sig = sign(&private, b"hello").unwrap();
assert!(!verify(&other_public, b"hello", &sig).unwrap());
}
#[test]
fn fingerprint_matches_ssh_keygen_format() {
let (_, public) = generate_keypair().unwrap();
let fp = fingerprint(&public).unwrap();
assert!(fp.starts_with("SHA256:"), "fingerprint should start with SHA256: prefix, got {fp}");
let body = fp.strip_prefix("SHA256:").unwrap();
assert_eq!(body.len(), 43, "SHA-256 fingerprint body is 43 base64 chars (no padding)");
assert!(body.chars().all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '/'));
}
#[test]
fn fingerprint_is_deterministic() {
let (_, public) = generate_keypair().unwrap();
assert_eq!(fingerprint(&public).unwrap(), fingerprint(&public).unwrap());
}
#[test]
fn fingerprint_differs_per_key() {
let (_, p1) = generate_keypair().unwrap();
let (_, p2) = generate_keypair().unwrap();
assert_ne!(fingerprint(&p1).unwrap(), fingerprint(&p2).unwrap());
}
}

View File

@@ -51,6 +51,10 @@ pub enum RelicarioError {
#[error("backup envelope schema v{found}; this Relicario reads v{expected}")]
BackupSchemaMismatch { found: u32, expected: u32 },
/// An error during backup restore (e.g., tar safety validation failure).
#[error("backup restore: {0}")]
BackupRestore(String),
/// CSV header doesn't match the LastPass column layout.
#[error("unrecognized CSV header — expected LastPass export format ({0})")]
ImportCsvHeader(String),
@@ -115,6 +119,10 @@ pub enum RelicarioError {
/// immediately. Use TOTP instead.
#[error("HOTP is not supported: counter persistence requires vault save after each use")]
HotpNotSupported,
/// Recovery QR generation or parsing failed.
#[error("recovery QR: {0}")]
RecoveryQr(String),
}
/// Crate-wide result alias, reducing boilerplate in function signatures.

View File

@@ -83,7 +83,7 @@ const BITS_PER_BLOCK: usize = 12; // EMBED_POSITIONS.len()
/// Number of 8x8 blocks needed to hold one complete copy of the 256-bit secret.
/// ceil(256 / 12) = 22 blocks per copy.
const BLOCKS_PER_COPY: usize = (SECRET_BITS + BITS_PER_BLOCK - 1) / BITS_PER_BLOCK; // 22
const BLOCKS_PER_COPY: usize = SECRET_BITS.div_ceil(BITS_PER_BLOCK); // 22
/// Mid-frequency DCT coefficient positions for embedding, specified as
/// (row, col) indices into the 8x8 DCT coefficient matrix.
@@ -302,9 +302,9 @@ fn read_block_abs(y: &YChannel, px: usize, py: usize) -> Option<[[f64; 8]; 8]> {
return None;
}
let mut block = [[0.0f64; 8]; 8];
for row in 0..8 {
for col in 0..8 {
block[row][col] = y.get(px + col, py + row);
for (row, block_row) in block.iter_mut().enumerate() {
for (col, cell) in block_row.iter_mut().enumerate() {
*cell = y.get(px + col, py + row);
}
}
Some(block)
@@ -323,9 +323,9 @@ fn read_block(y: &YChannel, bx: usize, by: usize, region: &EmbedRegion) -> [[f64
fn write_block(y: &mut YChannel, bx: usize, by: usize, region: &EmbedRegion, block: &[[f64; 8]; 8]) {
let start_x = region.x_offset + bx * BLOCK_SIZE;
let start_y = region.y_offset + by * BLOCK_SIZE;
for row in 0..8 {
for col in 0..8 {
y.set(start_x + col, start_y + row, block[row][col]);
for (row, block_row) in block.iter().enumerate() {
for (col, &cell) in block_row.iter().enumerate() {
y.set(start_x + col, start_y + row, cell);
}
}
}
@@ -349,17 +349,17 @@ fn write_block(y: &mut YChannel, bx: usize, by: usize, region: &EmbedRegion, blo
/// where c(0) = sqrt(1/8) and c(k) = sqrt(2/8) for k > 0.
fn dct1d(input: &[f64; 8]) -> [f64; 8] {
let mut output = [0.0f64; 8];
for k in 0..8 {
for (k, out_k) in output.iter_mut().enumerate() {
let ck = if k == 0 {
(1.0 / 8.0_f64).sqrt()
} else {
(2.0 / 8.0_f64).sqrt()
};
let mut sum = 0.0;
for i in 0..8 {
sum += input[i] * ((2 * i + 1) as f64 * k as f64 * PI / 16.0).cos();
for (i, &x) in input.iter().enumerate() {
sum += x * ((2 * i + 1) as f64 * k as f64 * PI / 16.0).cos();
}
output[k] = ck * sum;
*out_k = ck * sum;
}
output
}
@@ -370,17 +370,17 @@ fn dct1d(input: &[f64; 8]) -> [f64; 8] {
/// x[i] = sum_{k=0}^{7} c(k) * X[k] * cos((2i+1)*k*pi/16)
fn idct1d(input: &[f64; 8]) -> [f64; 8] {
let mut output = [0.0f64; 8];
for i in 0..8 {
for (i, out_i) in output.iter_mut().enumerate() {
let mut sum = 0.0;
for k in 0..8 {
for (k, &x) in input.iter().enumerate() {
let ck = if k == 0 {
(1.0 / 8.0_f64).sqrt()
} else {
(2.0 / 8.0_f64).sqrt()
};
sum += ck * input[k] * ((2 * i + 1) as f64 * k as f64 * PI / 16.0).cos();
sum += ck * x * ((2 * i + 1) as f64 * k as f64 * PI / 16.0).cos();
}
output[i] = sum;
*out_i = sum;
}
output
}
@@ -501,7 +501,7 @@ fn bytes_to_bits(bytes: &[u8]) -> Vec<u8> {
///
/// Pads the last byte with zeros if the bit count is not a multiple of 8.
fn bits_to_bytes(bits: &[u8]) -> Vec<u8> {
let mut bytes = Vec::with_capacity((bits.len() + 7) / 8);
let mut bytes = Vec::with_capacity(bits.len().div_ceil(8));
for chunk in bits.chunks(8) {
let mut byte = 0u8;
for (i, &bit) in chunk.iter().enumerate() {

View File

@@ -52,18 +52,15 @@ pub enum TotpAlgorithm {
Sha512,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum TotpKind {
#[default]
Totp,
Hotp { counter: u64 },
Steam,
}
impl Default for TotpKind {
fn default() -> Self { TotpKind::Totp }
}
/// Compute a TOTP/Steam code for `config` at the given Unix timestamp.
///
/// For TOTP and Steam: counter = `now_unix_seconds / period_seconds`.

View File

@@ -85,4 +85,15 @@ pub mod import_lastpass;
pub use import_lastpass::{parse_lastpass_csv, ImportWarning};
pub mod device;
pub use device::{DeviceEntry, RevokedEntry, generate_keypair, sign, verify};
pub use device::{fingerprint, DeviceEntry, RevokedEntry, generate_keypair, sign, verify};
pub mod tar_safe;
pub use tar_safe::{safe_unpack_git_archive, DEFAULT_MAX_UNCOMPRESSED};
pub mod recovery_qr;
pub use recovery_qr::{
generate_recovery_qr, generate_recovery_qr_with_params,
recovery_qr_to_svg,
unwrap_recovery_qr, unwrap_recovery_qr_with_params,
RecoveryQrPayload,
};

View 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()
}

View File

@@ -0,0 +1,138 @@
//! Safe tar unpacking for backup restore.
//!
//! The standard `tar::Archive::unpack` has no guards against path traversal,
//! absolute paths, symlinks, hardlinks, or tar bombs. This module replaces it
//! with `safe_unpack_git_archive`, which validates every entry before returning
//! `(relative_path, bytes)` pairs to the caller.
use std::io::Read;
use std::path::{Component, PathBuf};
use tar::EntryType;
use crate::error::{RelicarioError, Result};
/// Default cap on total uncompressed bytes extracted in one restore (1 GiB).
pub const DEFAULT_MAX_UNCOMPRESSED: u64 = 1024 * 1024 * 1024;
/// Decode `tar_bytes` and return `(relative_path, file_bytes)` pairs for
/// regular files only.
///
/// # Errors
///
/// Returns `Err(RelicarioError::BackupRestore(...))` if:
///
/// - Any path component is `..` (`Component::ParentDir`) — "path traversal blocked".
/// - Any path starts with `/` (`Component::RootDir`) — "path traversal blocked".
/// - Any path has a Windows drive prefix (`Component::Prefix`) — "path traversal blocked".
/// - An entry is a symlink or hardlink — "symlink/link rejected".
/// - An entry's declared size exceeds `max_uncompressed_bytes` — "size cap exceeded".
/// - The running total of all entry sizes exceeds `max_uncompressed_bytes` — "size cap exceeded".
/// - An entry has an unexpected type (not regular file, not directory) — "unexpected entry type".
pub fn safe_unpack_git_archive(
tar_bytes: &[u8],
max_uncompressed_bytes: u64,
) -> Result<Vec<(PathBuf, Vec<u8>)>> {
let mut archive = tar::Archive::new(tar_bytes);
let entries = archive
.entries()
.map_err(|e| RelicarioError::BackupRestore(format!("failed to read tar entries: {e}")))?;
let mut result: Vec<(PathBuf, Vec<u8>)> = Vec::new();
let mut cumulative: u64 = 0;
for entry in entries {
let mut entry = entry.map_err(|e| {
RelicarioError::BackupRestore(format!("failed to read tar entry: {e}"))
})?;
let header = entry.header();
let entry_type = header.entry_type();
// Reject symlinks and hardlinks.
match entry_type {
EntryType::Symlink => {
return Err(RelicarioError::BackupRestore(
"symlink entry rejected".to_string(),
));
}
EntryType::Link => {
return Err(RelicarioError::BackupRestore(
"hardlink entry rejected".to_string(),
));
}
EntryType::Directory => {
// Directories are implicit — skip without reading body.
continue;
}
EntryType::Regular | EntryType::Continuous | EntryType::GNUSparse => {
// These are normal file types; fall through to path checks.
}
_ => {
return Err(RelicarioError::BackupRestore(format!(
"unexpected entry type: {:?}",
entry_type
)));
}
}
// Validate the path.
let path = entry.path().map_err(|e| {
RelicarioError::BackupRestore(format!("invalid path in tar entry: {e}"))
})?;
let path = path.into_owned();
for component in path.components() {
match component {
Component::ParentDir => {
return Err(RelicarioError::BackupRestore(
"path traversal blocked: entry contains '..' component".to_string(),
));
}
Component::RootDir => {
return Err(RelicarioError::BackupRestore(
"path traversal blocked: entry has absolute path".to_string(),
));
}
Component::Prefix(_) => {
return Err(RelicarioError::BackupRestore(
"path traversal blocked: entry has Windows drive prefix".to_string(),
));
}
Component::Normal(_) | Component::CurDir => {
// Acceptable components.
}
}
}
// Check declared size before reading body.
let claimed = header.size().map_err(|e| {
RelicarioError::BackupRestore(format!("could not read entry size: {e}"))
})?;
if claimed > max_uncompressed_bytes {
return Err(RelicarioError::BackupRestore(format!(
"size cap exceeded: entry claims {claimed} bytes (cap {max_uncompressed_bytes})"
)));
}
let new_total = cumulative.saturating_add(claimed);
if new_total > max_uncompressed_bytes {
return Err(RelicarioError::BackupRestore(format!(
"size cap exceeded: cumulative size would reach {new_total} bytes (cap {max_uncompressed_bytes})"
)));
}
// Read the file body.
let mut body = Vec::with_capacity(claimed as usize);
entry.read_to_end(&mut body).map_err(|e| {
RelicarioError::BackupRestore(format!("failed to read entry body: {e}"))
})?;
cumulative += body.len() as u64;
result.push((path, body));
}
Ok(result)
}

View File

@@ -19,7 +19,7 @@ impl MonthYear {
if !(1..=12).contains(&month) {
return Err("month must be 1..=12");
}
if year < 2000 || year > 2099 {
if !(2000..=2099).contains(&year) {
return Err("year must be 2000..=2099");
}
Ok(Self { month, year })

View 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());
}

View File

@@ -0,0 +1,187 @@
use std::path::PathBuf;
use tar::{Builder, Header, EntryType};
use relicario_core::safe_unpack_git_archive;
/// Craft a raw POSIX ustar tar with a single entry using the given raw path bytes.
/// The tar crate's `Builder` sanitises paths, so we write the 512-byte header
/// manually to produce truly malicious archives.
fn raw_tar_with_path(raw_path: &[u8], content: &[u8]) -> Vec<u8> {
let mut buf = vec![0u8; 512]; // one header block
// Bytes 0-99: name field (null-padded)
let name_len = raw_path.len().min(100);
buf[..name_len].copy_from_slice(&raw_path[..name_len]);
// Bytes 100-107: mode = "0000644\0"
buf[100..108].copy_from_slice(b"0000644\0");
// Bytes 108-115: uid
buf[108..116].copy_from_slice(b"0000000\0");
// Bytes 116-123: gid
buf[116..124].copy_from_slice(b"0000000\0");
// Bytes 124-135: size (octal, 11 digits + null)
let size_str = format!("{:011o}\0", content.len());
buf[124..136].copy_from_slice(size_str.as_bytes());
// Bytes 136-147: mtime
buf[136..148].copy_from_slice(b"00000000000\0");
// Bytes 148-155: checksum placeholder (spaces during compute)
buf[148..156].copy_from_slice(b" ");
// Byte 156: typeflag = '0' (regular file)
buf[156] = b'0';
// Bytes 257-262: magic "ustar\0"
buf[257..263].copy_from_slice(b"ustar\0");
// Bytes 263-264: version "00"
buf[263..265].copy_from_slice(b"00");
// Compute checksum (sum of all bytes, checksum field treated as spaces).
let checksum: u32 = buf.iter().map(|&b| b as u32).sum();
let cksum_str = format!("{:06o}\0 ", checksum);
buf[148..156].copy_from_slice(cksum_str.as_bytes());
// Append padded content blocks.
let mut out = buf;
if !content.is_empty() {
out.extend_from_slice(content);
// Pad to 512-byte boundary.
let remainder = content.len() % 512;
if remainder != 0 {
out.extend(vec![0u8; 512 - remainder]);
}
}
// Two zero blocks = end-of-archive.
out.extend(vec![0u8; 1024]);
out
}
/// Build a tar with a raw symlink entry (typeflag = '2').
fn raw_symlink_tar() -> Vec<u8> {
let mut buf = vec![0u8; 512];
// name
buf[..9].copy_from_slice(b"evil_link");
// mode
buf[100..108].copy_from_slice(b"0000755\0");
// uid/gid
buf[108..116].copy_from_slice(b"0000000\0");
buf[116..124].copy_from_slice(b"0000000\0");
// size = 0
buf[124..136].copy_from_slice(b"00000000000\0");
// mtime
buf[136..148].copy_from_slice(b"00000000000\0");
// checksum placeholder
buf[148..156].copy_from_slice(b" ");
// typeflag = '2' (symlink)
buf[156] = b'2';
// linkname
let target = b"/etc/passwd";
buf[157..157 + target.len()].copy_from_slice(target);
// magic
buf[257..263].copy_from_slice(b"ustar\0");
buf[263..265].copy_from_slice(b"00");
// Compute checksum.
let checksum: u32 = buf.iter().map(|&b| b as u32).sum();
let cksum_str = format!("{:06o}\0 ", checksum);
buf[148..156].copy_from_slice(cksum_str.as_bytes());
let mut out = buf;
out.extend(vec![0u8; 1024]); // end-of-archive
out
}
fn build_normal_tar() -> Vec<u8> {
let mut buf = Vec::new();
{
let mut builder = Builder::new(&mut buf);
let content = b"hello";
let mut header = Header::new_gnu();
header.set_entry_type(EntryType::Regular);
header.set_size(content.len() as u64);
header.set_cksum();
builder
.append_data(&mut header, "subdir/hello.txt", content.as_ref())
.unwrap();
builder.finish().unwrap();
}
buf
}
fn build_oversize_tar() -> Vec<u8> {
// Actual 2048-byte body; test will use cap=1024
let mut buf = Vec::new();
{
let mut builder = Builder::new(&mut buf);
let content = vec![0u8; 2048];
let mut header = Header::new_gnu();
header.set_entry_type(EntryType::Regular);
header.set_size(content.len() as u64);
header.set_cksum();
builder
.append_data(&mut header, "bigfile.bin", content.as_slice())
.unwrap();
builder.finish().unwrap();
}
buf
}
#[test]
fn restore_rejects_path_traversal() {
// Craft a tar with "../../escaped.txt" using raw bytes (Builder sanitises paths).
let bytes = raw_tar_with_path(b"../../escaped.txt", b"evil content");
let err = safe_unpack_git_archive(&bytes, 1024 * 1024).unwrap_err();
let msg = format!("{err:#}");
assert!(
msg.contains("path traversal") || msg.contains(".."),
"got: {msg}"
);
}
#[test]
fn restore_rejects_absolute_path() {
// Craft a tar with "/etc/escaped.txt" using raw bytes.
let bytes = raw_tar_with_path(b"/etc/escaped.txt", b"evil content");
let err = safe_unpack_git_archive(&bytes, 1024 * 1024).unwrap_err();
let msg = format!("{err:#}");
assert!(
msg.contains("path traversal") || msg.contains("absolute"),
"got: {msg}"
);
}
#[test]
fn restore_rejects_symlink() {
let bytes = raw_symlink_tar();
let err = safe_unpack_git_archive(&bytes, 1024 * 1024).unwrap_err();
let msg = format!("{err:#}");
assert!(
msg.contains("symlink") || msg.contains("link"),
"got: {msg}"
);
}
#[test]
fn restore_rejects_size_bomb() {
let bytes = build_oversize_tar(); // actual 2048-byte entry
let err = safe_unpack_git_archive(&bytes, 1024).unwrap_err(); // cap = 1024 bytes
let msg = format!("{err:#}");
assert!(
msg.contains("size") || msg.contains("cap") || msg.contains("too large"),
"got: {msg}"
);
}
#[test]
fn restore_accepts_normal_files() {
let buf = build_normal_tar();
let entries = safe_unpack_git_archive(&buf, 1024 * 1024).expect("happy path");
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].0, PathBuf::from("subdir/hello.txt"));
assert_eq!(entries[0].1, b"hello");
}

View File

@@ -9,3 +9,10 @@ anyhow = "1"
clap = { version = "4", features = ["derive"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tempfile = "3"
regex = "1"
[dev-dependencies]
assert_cmd = "2"
predicates = "3"
tempfile = "3"

View File

@@ -1,5 +1,6 @@
//! relicario-server -- pre-receive hook for signature verification.
use std::fs;
use std::process::Command;
use anyhow::{Context, Result};
@@ -34,49 +35,120 @@ fn main() -> Result<()> {
}
fn verify_commit(commit: &str) -> Result<()> {
// Get devices.json at this commit
let devices_json = match git_show(commit, ".relicario/devices.json") {
Ok(json) => json,
Err(_) => {
// No devices.json yet -- bootstrap mode, allow unsigned
eprintln!("OK: commit {} (bootstrap - no devices.json)", commit);
eprintln!("OK: commit {commit} (bootstrap - no devices.json)");
return Ok(());
}
};
let devices: Vec<DeviceEntry> = serde_json::from_str(&devices_json)
.context("parse devices.json")?;
// Bootstrap: if devices.json is empty, allow unsigned
if devices.is_empty() {
eprintln!("OK: commit {} (bootstrap - empty devices.json)", commit);
return Ok(());
}
// Get revoked.json (may not exist)
let revoked: Vec<RevokedEntry> = git_show(commit, ".relicario/revoked.json")
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default();
// Get commit signature
// True bootstrap: no devices ever registered and none revoked.
if devices.is_empty() && revoked.is_empty() {
eprintln!("OK: commit {commit} (bootstrap - no devices registered)");
return Ok(());
}
// Build temp allowed-signers file from registered devices.
let tmp = tempfile::tempdir().context("create tempdir")?;
let allowed_path = tmp.path().join("allowed_signers");
let mut allowed_body = String::new();
for d in &devices {
allowed_body.push_str("relicario ");
allowed_body.push_str(d.public_key.trim());
allowed_body.push('\n');
}
fs::write(&allowed_path, &allowed_body).context("write allowed_signers")?;
// Run git verify-commit --raw. Capture both exit code and stderr.
// NOTE: we do NOT short-circuit on non-zero exit here because even for
// unregistered keys git still outputs "Good ... key SHA256:..." on stderr.
let output = Command::new("git")
.args(["verify-commit", "--raw", commit])
.env("GIT_CONFIG_COUNT", "1")
.env("GIT_CONFIG_KEY_0", "gpg.ssh.allowedSignersFile")
.env("GIT_CONFIG_VALUE_0", allowed_path.as_os_str())
.output()
.context("git verify-commit")?;
// Check if signed
let stderr = String::from_utf8_lossy(&output.stderr);
if !stderr.contains("GOODSIG") && !stderr.contains("Good signature") {
eprintln!("REJECT: commit {} is not signed by a registered device", commit);
// Parse the SHA-256 fingerprint from stderr.
// SSH signature output: "Good "git" signature ... with ED25519 key SHA256:<base64>"
let re = regex::Regex::new(r"key (SHA256:[A-Za-z0-9+/]+)").expect("static regex");
let signing_fp = match re.captures(&stderr).and_then(|c| c.get(1)) {
Some(m) => m.as_str().to_string(),
None => {
// No fingerprint in stderr = unsigned or completely malformed signature.
eprintln!(
"REJECT: commit {commit} — no valid signature found (stderr: {})",
stderr.trim()
);
std::process::exit(1);
}
};
// Build fingerprint → entry maps.
let mut device_by_fp: std::collections::HashMap<String, &DeviceEntry> =
std::collections::HashMap::new();
for d in &devices {
if let Ok(fp) = relicario_core::device::fingerprint(&d.public_key) {
device_by_fp.insert(fp, d);
}
}
let mut revoked_by_fp: std::collections::HashMap<String, &RevokedEntry> =
std::collections::HashMap::new();
for r in &revoked {
if let Ok(fp) = relicario_core::device::fingerprint(&r.public_key) {
revoked_by_fp.insert(fp, r);
}
}
// Get committer date (NOT author date).
let ct_out = Command::new("git")
.args(["show", "-s", "--format=%ct", commit])
.output()
.context("git show committer date")?;
let committer_ts: i64 = String::from_utf8_lossy(&ct_out.stdout)
.trim()
.parse()
.context("parse committer timestamp")?;
// Check revocation FIRST (revoked entries may not be in devices anymore).
if let Some(r) = revoked_by_fp.get(&signing_fp) {
if committer_ts >= r.revoked_at {
eprintln!(
"REJECT: commit {commit} — signed by revoked device '{}' \
(committer ts {committer_ts} >= revoked_at {})",
r.name, r.revoked_at
);
std::process::exit(1);
}
// Historical commit: committer_ts < revoked_at → was valid when signed.
eprintln!(
"OK: commit {commit} — historical commit signed by '{}' before revocation",
r.name
);
return Ok(());
}
// Not revoked — must be in active devices.
if !device_by_fp.contains_key(&signing_fp) {
eprintln!(
"REJECT: commit {commit} — signed by unregistered device (fingerprint {signing_fp})"
);
std::process::exit(1);
}
// Ensure the signing key is not revoked.
// The allowed-signers file approach means git verify-commit already checks
// against the list; we additionally guard against revoked.json entries.
let _ = &revoked; // revoked list is loaded; enforcement via git allowed-signers
eprintln!("OK: commit {} verified", commit);
eprintln!("OK: commit {commit} verified (signed by '{}')", device_by_fp[&signing_fp].name);
Ok(())
}

View File

@@ -0,0 +1,230 @@
//! Acceptance tests for `relicario-server verify-commit`.
//!
//! Four scenarios from audit S1:
//! 1. Registered non-revoked key → exit 0
//! 2. Unregistered key → exit 1 (stderr contains "unregistered")
//! 3. Revoked key, commit AFTER revoked_at → exit 1 (stderr contains "revoked")
//! 4. Revoked key, commit BEFORE revoked_at (historical) → exit 0
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use assert_cmd::Command as AssertCommand;
use predicates::prelude::*;
use relicario_core::device::{generate_keypair, DeviceEntry, RevokedEntry};
use tempfile::TempDir;
fn write_keypair(dir: &Path, name: &str) -> (PathBuf, PathBuf, String) {
let (priv_pem, pub_line) = generate_keypair().expect("generate keypair");
let priv_path = dir.join(format!("{name}.key"));
let pub_path = dir.join(format!("{name}.pub"));
fs::write(&priv_path, priv_pem.as_str()).unwrap();
fs::write(&pub_path, &pub_line).unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&priv_path, fs::Permissions::from_mode(0o600)).unwrap();
}
(priv_path, pub_path, pub_line)
}
fn git(repo: &Path, args: &[&str], extra_env: &[(&str, &str)]) {
let mut cmd = Command::new("git");
cmd.current_dir(repo).args(args);
for (k, v) in extra_env {
cmd.env(k, v);
}
let status = cmd.status().expect("spawn git");
assert!(status.success(), "git {args:?} failed");
}
fn init_repo(repo: &Path) {
git(repo, &["init", "-q", "-b", "main"], &[]);
git(repo, &["config", "user.email", "test@test"], &[]);
git(repo, &["config", "user.name", "test"], &[]);
git(repo, &["commit", "--allow-empty", "-q", "-m", "init"], &[]);
}
fn sign_commit(
repo: &Path,
signing_key: &Path,
allowed_signers: &Path,
committer_unix: i64,
msg: &str,
file_path: &str,
file_content: &str,
) -> String {
fs::write(repo.join(file_path), file_content).unwrap();
git(repo, &["add", file_path], &[]);
let date = format!("@{committer_unix} +0000");
git(
repo,
&[
"-c", "gpg.format=ssh",
"-c", &format!("user.signingkey={}", signing_key.display()),
"-c", &format!("gpg.ssh.allowedSignersFile={}", allowed_signers.display()),
"commit", "-S", "-q", "-m", msg,
],
&[
("GIT_AUTHOR_DATE", &date),
("GIT_COMMITTER_DATE", &date),
],
);
let out = Command::new("git")
.current_dir(repo)
.args(["rev-parse", "HEAD"])
.output()
.unwrap();
String::from_utf8(out.stdout).unwrap().trim().to_string()
}
fn write_device_files(repo: &Path, devices: &[DeviceEntry], revoked: &[RevokedEntry]) {
let dir = repo.join(".relicario");
fs::create_dir_all(&dir).unwrap();
fs::write(dir.join("devices.json"), serde_json::to_string_pretty(devices).unwrap()).unwrap();
fs::write(dir.join("revoked.json"), serde_json::to_string_pretty(revoked).unwrap()).unwrap();
git(repo, &["add", ".relicario"], &[]);
git(repo, &["commit", "-q", "-m", "device files"], &[]);
}
#[test]
fn registered_non_revoked_key_accepted() {
let tmp = TempDir::new().unwrap();
let repo = tmp.path();
init_repo(repo);
let (priv_a, _, pub_a) = write_keypair(repo, "alice");
write_device_files(
repo,
&[DeviceEntry {
name: "alice".into(),
public_key: pub_a.clone(),
added_at: 1_700_000_000,
added_by: "bootstrap".into(),
}],
&[],
);
let allowed = repo.join("test_allowed_signers");
fs::write(&allowed, format!("relicario {}\n", pub_a.trim())).unwrap();
let sha = sign_commit(repo, &priv_a, &allowed, 1_710_000_000, "x", "a.txt", "hi");
AssertCommand::cargo_bin("relicario-server")
.unwrap()
.current_dir(repo)
.args(["verify-commit", &sha])
.assert()
.success();
}
#[test]
fn unregistered_key_rejected() {
let tmp = TempDir::new().unwrap();
let repo = tmp.path();
init_repo(repo);
let (_, _, pub_a) = write_keypair(repo, "alice");
let (priv_evil, _, pub_evil) = write_keypair(repo, "evil");
// Only Alice is registered.
write_device_files(
repo,
&[DeviceEntry {
name: "alice".into(),
public_key: pub_a.clone(),
added_at: 1_700_000_000,
added_by: "bootstrap".into(),
}],
&[],
);
// Evil signs against a file containing both keys so git commit signing works,
// but the binary's allowed-signers (from devices.json) only has Alice.
let allowed = repo.join("test_allowed_signers");
fs::write(
&allowed,
format!("relicario {}\nrelicario {}\n", pub_a.trim(), pub_evil.trim()),
)
.unwrap();
let sha = sign_commit(repo, &priv_evil, &allowed, 1_710_000_000, "evil", "a.txt", "hi");
AssertCommand::cargo_bin("relicario-server")
.unwrap()
.current_dir(repo)
.args(["verify-commit", &sha])
.assert()
.failure()
.stderr(predicate::str::contains("unregistered"));
}
#[test]
fn revoked_key_after_revoked_at_rejected() {
let tmp = TempDir::new().unwrap();
let repo = tmp.path();
init_repo(repo);
let (priv_a, _, pub_a) = write_keypair(repo, "alice");
// Alice's entry is only in revoked.json (was removed from devices.json after revocation).
write_device_files(
repo,
&[],
&[RevokedEntry {
name: "alice".into(),
public_key: pub_a.clone(),
revoked_at: 1_705_000_000,
revoked_by: "admin".into(),
}],
);
let allowed = repo.join("test_allowed_signers");
fs::write(&allowed, format!("relicario {}\n", pub_a.trim())).unwrap();
// Commit dated AFTER revocation.
let sha = sign_commit(repo, &priv_a, &allowed, 1_710_000_000, "post", "a.txt", "hi");
AssertCommand::cargo_bin("relicario-server")
.unwrap()
.current_dir(repo)
.args(["verify-commit", &sha])
.assert()
.failure()
.stderr(predicate::str::contains("revoked"));
}
#[test]
fn revoked_key_before_revoked_at_accepted_historical() {
let tmp = TempDir::new().unwrap();
let repo = tmp.path();
init_repo(repo);
let (priv_a, _, pub_a) = write_keypair(repo, "alice");
// Same as above: Alice only in revoked.json.
write_device_files(
repo,
&[],
&[RevokedEntry {
name: "alice".into(),
public_key: pub_a.clone(),
revoked_at: 1_705_000_000,
revoked_by: "admin".into(),
}],
);
let allowed = repo.join("test_allowed_signers");
fs::write(&allowed, format!("relicario {}\n", pub_a.trim())).unwrap();
// Commit dated BEFORE revocation -- historical case must pass.
let sha = sign_commit(repo, &priv_a, &allowed, 1_700_000_000, "historical", "a.txt", "hi");
AssertCommand::cargo_bin("relicario-server")
.unwrap()
.current_dir(repo)
.args(["verify-commit", &sha])
.assert()
.success();
}

View File

@@ -1,6 +1,6 @@
[package]
name = "relicario-wasm"
version = "0.2.0"
version = "0.5.0"
edition = "2021"
description = "WASM bindings for relicario password manager"

View File

@@ -8,6 +8,7 @@ mod session;
mod device;
use wasm_bindgen::prelude::*;
use zeroize::Zeroizing;
use relicario_core::{derive_master_key, imgsecret, KdfParams};
@@ -36,7 +37,8 @@ pub fn unlock(
.map_err(|_| JsError::new("salt must be exactly 32 bytes"))?;
let master_key = derive_master_key(passphrase.as_bytes(), &image_secret, salt_arr, &params)
.map_err(|e| JsError::new(&e.to_string()))?;
let handle = session::insert(master_key);
let stored_secret = Zeroizing::new(image_secret);
let handle = session::insert(master_key, stored_secret);
Ok(SessionHandle(handle))
}
@@ -484,6 +486,39 @@ pub fn parse_lastpass_csv_json(csv_bytes: &[u8]) -> Result<String, JsError> {
Ok(json.to_string())
}
// ── Recovery QR bindings ─────────────────────────────────────────────────────
use relicario_core::{generate_recovery_qr, recovery_qr_to_svg, unwrap_recovery_qr};
/// Generate a recovery QR SVG for the current session.
/// Returns the SVG string. The passphrase wraps the image_secret under a
/// separate key (domain-separated from the master key derivation).
#[wasm_bindgen]
pub fn wasm_generate_recovery_qr(
handle: &SessionHandle,
passphrase: &str,
) -> Result<String, JsError> {
let payload = session::with_image_secret(handle.0, |s| generate_recovery_qr(passphrase, s))
.ok_or_else(|| JsError::new("invalid or locked session handle"))?
.map_err(|e| JsError::new(&e.to_string()))?;
Ok(recovery_qr_to_svg(&payload))
}
/// Unwrap a recovery QR payload (base64-encoded 109-byte blob) using the passphrase.
/// Returns the raw image_secret bytes (32 bytes).
#[wasm_bindgen]
pub fn wasm_unwrap_recovery_qr(
payload_b64: &str,
passphrase: &str,
) -> Result<Vec<u8>, JsError> {
use base64::{engine::general_purpose::STANDARD, Engine};
let bytes = STANDARD.decode(payload_b64)
.map_err(|e| JsError::new(&format!("base64: {e}")))?;
let recovered = unwrap_recovery_qr(&bytes, passphrase)
.map_err(|e| JsError::new(&e.to_string()))?;
Ok(recovered.to_vec())
}
#[cfg(test)]
mod session_tests {
use super::*;
@@ -492,7 +527,7 @@ mod session_tests {
#[test]
fn insert_then_remove_clears_entry() {
session::clear();
let h = session::insert(Zeroizing::new([0x11u8; 32]));
let h = session::insert(Zeroizing::new([0x11u8; 32]), Zeroizing::new([0u8; 32]));
assert_ne!(h, 0);
assert!(session::remove(h));
assert!(!session::remove(h)); // second remove false
@@ -501,7 +536,7 @@ mod session_tests {
#[test]
fn with_yields_key_only_while_session_lives() {
session::clear();
let h = session::insert(Zeroizing::new([0x22u8; 32]));
let h = session::insert(Zeroizing::new([0x22u8; 32]), Zeroizing::new([0u8; 32]));
let byte = session::with(h, |k| k[0]);
assert_eq!(byte, Some(0x22));
session::remove(h);
@@ -513,7 +548,7 @@ mod session_tests {
fn manifest_round_trip_via_handle() {
use relicario_core::{Manifest, decrypt_manifest};
session::clear();
let h = session::insert(Zeroizing::new([0x55u8; 32]));
let h = session::insert(Zeroizing::new([0x55u8; 32]), Zeroizing::new([0u8; 32]));
let handle = SessionHandle(h);
let key = Zeroizing::new([0x55u8; 32]);
let empty = Manifest::new();

View File

@@ -6,12 +6,17 @@ use std::cell::RefCell;
use std::collections::HashMap;
use zeroize::Zeroizing;
pub struct SessionData {
pub master_key: Zeroizing<[u8; 32]>,
pub image_secret: Zeroizing<[u8; 32]>,
}
thread_local! {
static SESSIONS: RefCell<HashMap<u32, Zeroizing<[u8; 32]>>> = RefCell::new(HashMap::new());
static SESSIONS: RefCell<HashMap<u32, SessionData>> = RefCell::new(HashMap::new());
static NEXT_HANDLE: RefCell<u32> = const { RefCell::new(1) };
}
pub fn insert(key: Zeroizing<[u8; 32]>) -> u32 {
pub fn insert(master_key: Zeroizing<[u8; 32]>, image_secret: Zeroizing<[u8; 32]>) -> u32 {
let handle = NEXT_HANDLE.with(|n| {
let mut n = n.borrow_mut();
let h = *n;
@@ -19,15 +24,26 @@ pub fn insert(key: Zeroizing<[u8; 32]>) -> u32 {
if *n == 0 { *n = 1; } // avoid reserving 0 as a valid handle
h
});
SESSIONS.with(|s| { s.borrow_mut().insert(handle, key); });
SESSIONS.with(|s| {
s.borrow_mut().insert(handle, SessionData { master_key, image_secret });
});
handle
}
/// Access the master key for a handle. Preserves original `with` signature for all existing callers.
pub fn with<F, R>(handle: u32, f: F) -> Option<R>
where
F: FnOnce(&Zeroizing<[u8; 32]>) -> R,
{
SESSIONS.with(|s| s.borrow().get(&handle).map(f))
SESSIONS.with(|s| s.borrow().get(&handle).map(|d| f(&d.master_key)))
}
/// Access the image_secret for a handle (used by recovery QR).
pub fn with_image_secret<F, R>(handle: u32, f: F) -> Option<R>
where
F: FnOnce(&Zeroizing<[u8; 32]>) -> R,
{
SESSIONS.with(|s| s.borrow().get(&handle).map(|d| f(&d.image_secret)))
}
pub fn remove(handle: u32) -> bool {

View File

@@ -83,8 +83,9 @@ vault_salt ────────►│ │
┌──────────────────┐
master_key ────────►│ XChaCha20- │──────► manifest.enc
empty manifest ────►│ Poly1305 │
└──────────────────┘
empty manifest ────►│ Poly1305 │ settings.enc
default settings ──►│ encrypt (×2) (parallel artifacts;
└──────────────────┘ independent nonces)
┌──────────────────┐
│ git init │──────► vault repo
@@ -92,6 +93,14 @@ empty manifest ────►│ Poly1305 │
└──────────────────┘
```
Item creation, the typed-item envelope (`Item` + per-type `ItemCore`),
attachment encryption, and field-history tracking are not shown above —
they are described in [`crates/relicario-core/ARCHITECTURE.md`](../crates/relicario-core/ARCHITECTURE.md).
The flow above covers only the crypto-pipeline shape that vault init
establishes; the per-item lifecycle reuses the same `master_key` +
XChaCha20-Poly1305 primitives against `items/<id>.enc` and
`attachments/<item-id>/<aid>.enc`.
## Unlock Flow (every vault operation)
```

View File

@@ -48,6 +48,19 @@ When enabled, device authentication provides:
- **Push access control**: Deploy keys managed via Gitea API
- **Instant revocation**: One command cuts off both signing and push
Enforcement requires deploying the `relicario-server` pre-receive hook
on the vault remote. The crate provides two subcommands:
- `relicario-server generate-hook` — emits the hook script to install at
`<repo>/hooks/pre-receive`
- `relicario-server verify-commit <sha>` — checks one commit's signature
against `.relicario/devices.json` and `.relicario/revoked.json` as of
that commit; the hook calls this for every pushed ref
Without the server hook, signed commits provide authorship metadata only
— any process with push access can land an unsigned commit, since
verification is otherwise advisory.
See `docs/superpowers/specs/2026-05-02-device-authentication-design.md`.
## Access Control
@@ -57,5 +70,35 @@ Without device authentication, access control is transport-layer only:
- **CLI**: SSH key authentication to git remote
- **Extension**: Git credentials in browser storage
Device registration was optional before v0.4.0. With device auth enabled,
all commits must be signed by a registered device.
Device registration is optional but recommended for shared vaults.
## Configuration env vars
Relicario reads the following environment variables. Each is a trust
boundary: an attacker who can set them in the user's environment can
influence Relicario's behavior. They are listed here for security
reviewers to audit the surface in one place.
### User-facing (active in all builds)
| Variable | Purpose | Trust |
|---|---|---|
| `RELICARIO_IMAGE` | Override the reference-image JPEG path used during vault unlock. | Trusted: filesystem path under the user's control. Read-only; its bytes feed `imgsecret::extract_secret`. |
| `RELICARIO_GITEA_URL` | Gitea API base URL for `relicario device add`. Equivalent to `--gitea-url`. | Trusted: HTTPS URL. Used only in the device-add code path. |
| `RELICARIO_GITEA_TOKEN` | Gitea personal-access token. Equivalent to `--gitea-token`. | **Secret**: anyone who can read this env var can manage the user's deploy keys via the Gitea API. The CLI never logs it. |
| `RELICARIO_GITEA_OWNER` | Gitea repository owner (e.g. `alee`). Equivalent to `--owner`. | Trusted: opaque string. |
| `RELICARIO_GITEA_REPO` | Gitea repository name (e.g. `vault`). Equivalent to `--repo`. | Trusted: opaque string. |
### Debug-only (compiled out of `cargo build --release`)
The following variables are gated behind `cfg(debug_assertions)` and
are **no-ops** in release builds. The env-var lookup is removed by the
optimiser from any binary built without debug assertions (i.e. the
standard `--release` profile).
| Variable | Purpose |
|---|---|
| `RELICARIO_NO_GROUPS_CACHE` | Suppress the plaintext `groups.cache` write. Developer debugging tool for the cache logic. |
| `RELICARIO_TEST_PASSPHRASE` | Bypass the `rpassword` prompt during integration tests. |
| `RELICARIO_TEST_ITEM_SECRET` | Bypass the `rpassword` prompt for item-secret fields during integration tests. |
| `RELICARIO_TEST_BACKUP_PASSPHRASE` | Bypass the `rpassword` prompt for backup export/restore passphrases during integration tests. |

View File

@@ -1,6 +1,6 @@
# Architecture overview — Relicario
This is the cross-codebase entry point. It describes how the three Relicario codebases fit together, the contracts that flow between them, and the conventions they share. It is **deliberately thin**; the deep content lives in per-codebase docs.
This is the cross-codebase entry point. It describes how the four Relicario codebases fit together, the contracts that flow between them, and the conventions they share. It is **deliberately thin**; the deep content lives in per-codebase docs.
> If you are about to make a change in a single codebase, read its `ARCHITECTURE.md` first:
>
@@ -10,44 +10,48 @@ This is the cross-codebase entry point. It describes how the three Relicario cod
>
> If you want historical *why*, see `docs/superpowers/specs/` — those are time-stamped decision artifacts. This overview describes what *is*.
## The three codebases
## The four codebases
```
┌─────────────────────┐
│ relicario-core │
│ (Rust, no I/O) │
│ crypto · items │
│ manifest · stego │
└──────────┬──────────┘
┌─────────────────────┼─────────────────────┐
│ │ │
┌────────────────┐ ┌────────────────────┐ (compiled to WASM
│ relicario-cli │ │ relicario-wasm │ inside the )
(Rust binary) │ │ (#[wasm_bindgen] │ extension
bindings) │
filesystem + │ │ │
git + │ └────────┬───────────┘
clap UX
└────────────────┘ ▼
┌─────────────────────┐ │
extension
(TypeScript) │
popup · vault
setup · content
service worker
└─────────────────────┘
┌─────────────────────┐
│ relicario-core │
│ (Rust, no I/O) │
│ crypto · items │
│ manifest · stego │
│ device keys + fp │
└──┬───────────┬──────┘
│ │
┌────────────────┼───────────┴──────┬────────────────────┐
▼ ▼ ▼ ▼
┌────────────────┐ ┌──────────────────┐ ┌────────────────────┐
relicario-cli │ │ relicario-server │ │ relicario-wasm
(Rust binary) │ │ (Rust binary) │ │ (#[wasm_bindgen]
│ │ │ │ bindings)
filesystem + │ │ pre-receive hook │ │
git + │ │ verify-commit + │ │ compiled to WASM
│ clap UX │ │ generate-hook │ │ for the extension
└────────────────┘ └──────────────────┘ └───────────────────
┌─────────────────────┐
│ extension
│ (TypeScript)
│ popup · vault │
│ setup · content │
│ service worker │
└─────────────────────┘
```
| Codebase | Language | Role | Key boundary |
|---|---|---|---|
| `relicario-core` | Rust | Crypto, item types, manifest, attachments, imgsecret, generators. Pure, no I/O. | Only `bytes-in / bytes-out`. No filesystem, no git, no network. |
| `relicario-core` | Rust | Crypto, item types, manifest, attachments, imgsecret, generators, device keys / fingerprints. Pure, no I/O. | Only `bytes-in / bytes-out`. No filesystem, no git, no network. |
| `relicario-cli` | Rust binary | Wraps core with filesystem ops, git plumbing, clap UX. | Only entry point that runs without a browser; sole working interface during disaster recovery. |
| `relicario-wasm` | Rust → WASM | Thin `#[wasm_bindgen]` exports from core for the extension. | Compiles `relicario-core` to WASM; no extra logic. |
| `relicario-server` | Rust binary | Pre-receive Git hook (`verify-commit`) plus hook installer (`generate-hook`) running on the vault remote. Verifies SSH-signed commits against `.relicario/devices.json` and `.relicario/revoked.json`. | Lives on the git server, not on a client device. The only Relicario component the user does not run themselves. Sees only public key material. |
| `extension` | TypeScript | Browser-resident UI. Five entry-point bundles (popup, vault tab, setup, content script, service worker). | The service worker is the only crypto holder; popup/vault/content/setup never touch the master key. |
The CLI and the extension are **at parity**: every user-facing capability lands in both surfaces together. Diverging is allowed only with a documented reason. See the per-codebase docs for which surface owns which user flow.
The CLI and the extension are **at parity**: every user-facing capability lands in both surfaces together. Diverging is allowed only with a documented reason. See the per-codebase docs for which surface owns which user flow. The server has no user-facing surface — it is a server-side enforcer of the device-auth invariant the clients already agreed to.
## Inter-codebase contracts
@@ -151,6 +155,7 @@ The CLI keeps its master key in process memory; if the process exits or crashes,
| Target | Tool | Output | When to run |
|---|---|---|---|
| Native CLI | `cargo build` (debug or `--release`) | `target/{debug,release}/relicario` | After CLI changes; for distribution |
| Server hook | `cargo build -p relicario-server --release` | `target/release/relicario-server` | After server changes; deploy onto the git remote |
| Native test suites | `cargo test` (workspace) | — | After any Rust change |
| WASM module | `wasm-pack build --target web` (via `npm run build:wasm`) | `extension/wasm/relicario_wasm{,_bg.wasm,.js}` | After core or wasm crate changes |
| Chrome extension | `webpack` (`npm run build`) | `extension/dist/` | After TS or WASM changes; for Chrome distribution |
@@ -196,6 +201,7 @@ Core tests use **fast Argon2id params** (m=256, t=1, p=1) so they don't take for
| A new popup view, vault tab feature, or autofill change | [`extension/ARCHITECTURE.md`](../../extension/ARCHITECTURE.md) |
| A new SW message type | `extension/src/shared/messages.ts` (capability sets), then [`extension/ARCHITECTURE.md § Invariants`](../../extension/ARCHITECTURE.md) |
| A new GitHost (e.g. GitLab support) | `extension/src/service-worker/git-host.ts` (interface) and existing implementations |
| The pre-receive hook / device-auth enforcement | `crates/relicario-server/src/main.rs`, then `docs/superpowers/specs/2026-05-02-device-authentication-design.md` for rationale |
| Adding a new item type | core's `item_types/` mod, then CLI's `build_*_item`/`edit_*` helpers, then extension's `popup/components/types/<type>.ts` |
| Threat model / why a primitive was chosen | `docs/superpowers/specs/2026-04-11-relicario-design.md` (historical, but authoritative for rationale) |
| Format of the import/export feature | `docs/superpowers/specs/2026-04-27-relicario-import-export-design.md` (designed but not yet implemented) |

View 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.

View File

@@ -5,8 +5,9 @@ Pre-v0.5.0 audit of Relicario's documentation against the current codebase.
## Summary
- **Total findings:** 14
- **Fixed inline:** 6
- **Need user input (proposed only):** 8
- **Fixed inline (initial pass):** 6
- **Fixed during v0.5.0 PM run (this audit, follow-up commits):** 8
- **No action needed:** 0
- **Top 3 recommendations:**
1. **Add `relicario-server` to architecture docs.** It exists in `crates/`, is referenced by SECURITY.md, and underpins device-auth, but `docs/architecture/overview.md`'s "three codebases" framing and `CLAUDE.md`'s project-structure tree still pretend it doesn't exist (Findings 1, 2, 9). This is the single biggest gap before tagging v0.5.0.
2. **Replace `CLAUDE.md`'s Roadmap.** It still says "Next: WASM build + Chrome MV3 browser extension (Plan 2)" — a milestone that shipped weeks ago. Multiple subsequent train rounds (typed items, attachments, backup, LastPass, device auth, fullscreen UX phases) have shipped, none of which are reflected (Finding 3).
@@ -24,7 +25,7 @@ The codebase itself is well-documented — `crates/{relicario-core,relicario-cli
**Issue:** The repo now has **four** Rust crates (`relicario-core`, `relicario-cli`, `relicario-wasm`, `relicario-server`) plus the extension. The framing "The three codebases" + accompanying ASCII diagram + four-row table all predate the May 2026 server crate. `relicario-server` is the pre-receive hook binary that enforces device-signature verification — load-bearing for the device-auth model that SECURITY.md already advertises.
**Fix:** Re-title the section ("The four codebases" or "The relicario codebases"), add a server box to the diagram, add a row to the table. The role is "Pre-receive Git hook that verifies commit signatures against `.relicario/devices.json` and `.relicario/revoked.json`".
**Severity:** must-fix-before-v0.5.0
**Status:** Proposed; needs user decision (>50 words of new prose; touches the framing of the whole overview doc)
**Status:** Fixed in `ca059e7` (PM follow-up, 2026-05-02): "four codebases" framing, ASCII diagram fans core out to cli + server + wasm, table row added, build matrix gains `cargo build -p relicario-server`, "Where to look next" points at server src + design spec.
---
@@ -34,7 +35,7 @@ The codebase itself is well-documented — `crates/{relicario-core,relicario-cli
**Issue:** The `crates/` tree only lists `relicario-core/`, `relicario-cli/`, `relicario-wasm/`. `relicario-server/` is missing. Since CLAUDE.md is the project-level summary every Claude session reads, this is the highest-leverage staleness.
**Fix:** Add a fourth crate entry for `relicario-server/` with `src/main.rs # pre-receive hook: verify_commit + generate_hook`.
**Severity:** must-fix-before-v0.5.0
**Status:** Proposed; needs user decision (CLAUDE.md is user-controlled per audit constraints)
**Status:** Fixed in `8fd9a05` (PM follow-up, 2026-05-02 with user approval): added `relicario-server/` entry to project tree.
---
@@ -44,7 +45,7 @@ The codebase itself is well-documented — `crates/{relicario-core,relicario-cli
**Issue:** Says `Next: WASM build + Chrome MV3 browser extension (Plan 2). Then mobile (Rust core compiles to ARM).` Plan 2 (extension) shipped, then Plans 1A-1C, 3A (backup), 3B (LastPass), Plan 4 (security fixes + device auth), and Phases 1-2B of the fullscreen UX redesign all shipped. The current "next thing" per project memory is v0.5.0 polish + harden plus Phase 3/4 of fullscreen UX.
**Fix:** Replace with a current-state Roadmap line (e.g. `Next: v0.5.0 polish + harden, then Phase 3 (vault tab shell). Mobile (ARM) and recovery QR remain on the roadmap.`).
**Severity:** must-fix-before-v0.5.0
**Status:** Proposed; needs user decision (CLAUDE.md is user-controlled; phrasing is a judgment call)
**Status:** Fixed in `8fd9a05` (PM follow-up, 2026-05-02 with user approval): replaced with the v0.5.0 / Phases 3-4 / 1C-γ / LastPass / mobile / recovery-QR picture.
---
@@ -54,7 +55,7 @@ The codebase itself is well-documented — `crates/{relicario-core,relicario-cli
**Issue:** Audit M8 bumped `ItemId`/`FieldId` to 16-char hex (64 bits). Verified against `crates/relicario-core/src/ids.rs:3-4, 35-37` and `tests/integration` — they're 16 hex chars. The same line also doesn't mention that `AttachmentId` was bumped to 32 hex chars / 128 bits (audit I2/B4).
**Fix:** Change to: `Item IDs and Field IDs are random 16-char hex strings (64 bits of OsRng entropy). AttachmentIds are content-addressed: first 32 hex chars of SHA-256(plaintext) (128 bits, audit I2/B4).`
**Severity:** must-fix-before-v0.5.0
**Status:** Proposed; needs user decision (CLAUDE.md is user-controlled)
**Status:** Fixed in `8fd9a05` (PM follow-up, 2026-05-02 with user approval): now reads "Item IDs and Field IDs are random 16-char hex strings (64 bits of OsRng entropy). AttachmentIds are content-addressed: first 32 hex chars of SHA-256 over the plaintext (128 bits)."
---
@@ -114,7 +115,7 @@ The codebase itself is well-documented — `crates/{relicario-core,relicario-cli
**Issue:** The vault-creation pipeline in this doc shows `master_key → XChaCha20-Poly1305 → manifest.enc` only. In reality `cmd_init` also encrypts and writes `settings.enc` (default `VaultSettings`). Field-history-tracked items, attachments, the `Item` envelope shape — none of these are in the flow doc. Without context on typed items, a new contributor reading this doc would have a v0.1-era model of the system.
**Fix:** Add a settings.enc step to the flow; either expand the items section or note that the full item lifecycle is in `crates/relicario-core/ARCHITECTURE.md`.
**Severity:** nice-to-have (the per-codebase ARCHITECTURE.md files are the source of truth; this top-level doc could just point at them)
**Status:** Proposed; needs user decision (>50 words of new prose, design choice between rewriting vs trimming)
**Status:** Fixed in `76d092d` (PM follow-up, 2026-05-02): trim path. Added settings.enc as a parallel artifact in the encrypt step, then a short paragraph pointing at `crates/relicario-core/ARCHITECTURE.md` for the per-item lifecycle.
---
@@ -124,7 +125,7 @@ The codebase itself is well-documented — `crates/{relicario-core,relicario-cli
**Issue:** Says `Device registration was optional before v0.4.0. With device auth enabled, all commits must be signed by a registered device.` But (a) v0.4.0 hasn't been tagged yet — the changelog goes v0.1.0 → v0.2.0 → "Unreleased", and the next tag-in-flight per project memory is v0.5.0; (b) per the v0.5.0 polish + harden spec, device-auth enforcement is **currently a no-op** because the pre-receive hook fix (S1) hasn't landed. Saying "all commits MUST be signed" is aspirational, not current.
**Fix:** Reword to clarify (a) the actual version line (e.g. "Pre-v0.5.0 vaults can opt out by leaving `devices.json` empty"), AND (b) acknowledge that signature *enforcement* depends on the pre-receive hook being deployed and the S1 fix landing. Could just be a one-line caveat.
**Severity:** must-fix-before-v0.5.0 (security-doc accuracy is part of the legibility pitch)
**Status:** Proposed; needs user decision (security wording — exact phrasing matters)
**Status:** Fixed in `1342228` (PM follow-up, 2026-05-02 with user approval): dropped the "before v0.4.0" version line entirely (v0.4.0 was never tagged); replaced with a single line saying registration is optional but recommended for shared vaults. Enforcement story now lives in the Device Authentication section (see F12).
---
@@ -134,7 +135,7 @@ The codebase itself is well-documented — `crates/{relicario-core,relicario-cli
**Issue:** The "Device Authentication" section refers to a "pre-receive hook" but never says it lives in `crates/relicario-server`, what binary the hook calls (`relicario-server verify-commit <sha>`), or how to install it (`relicario-server generate-hook`). For a self-hosted user reading this to decide whether to enable it, those are the two essential operational facts.
**Fix:** Add a short paragraph naming the crate and the two subcommands, pointing to the design spec.
**Severity:** nice-to-have
**Status:** Proposed; needs user decision (>50 words of new prose)
**Status:** Fixed in `1342228` (PM follow-up, 2026-05-02): added paragraph naming the `relicario-server` crate, both subcommands (`generate-hook`, `verify-commit`), and the caveat that signed commits without the server hook provide authorship metadata only.
---
@@ -144,7 +145,7 @@ The codebase itself is well-documented — `crates/{relicario-core,relicario-cli
**Issue:** This doc is explicitly historical (per `docs/architecture/overview.md` "Stale spec docs" disclaimer), so editing it as architecture would violate convention. Still worth flagging that "Post-V1 Ideas" lists secure notes, secure documents, mobile, LastPass import, Firefox extension, TOTP — most of which have shipped. Per project policy this is *informational only*; the spec is a time-stamped decision artifact.
**Fix:** None — leave alone. If desired, prepend a one-line "Status: V1 shipped 2026-04-22; many Post-V1 ideas have since landed — see CHANGELOG.md" at the top of the file.
**Severity:** informational
**Status:** Proposed; needs user decision (touches a historical spec the user may want to leave frozen)
**Status:** Fixed in `9c97f9f` (PM follow-up, 2026-05-02): added the optional one-line status banner at the top of the spec pointing at CHANGELOG.md and overview.md for current state. Body of the spec untouched per the "specs are frozen decision artifacts" convention.
---
@@ -160,10 +161,18 @@ The codebase itself is well-documented — `crates/{relicario-core,relicario-cli
## Inline-fix verification
Files modified during this audit:
### Initial pass (commit `900ccf1`):
- `README.md` — vault layout (`items/`, `settings.enc`, `attachments/`), crate tree (added `relicario-wasm`, `relicario-server`, typed-items modules), ID width, Roadmap.
- `docs/ARCHITECTURE.md` — git-server box (`items/`, `settings.enc`, `attachments/`, `revoked.json`), crate-architecture inner box (current core modules), removed "Future: relicario-wasm" line.
- `docs/architecture/overview.md` — conventions table (16-char hex IDs, 128-bit AttachmentIds).
No source files, `Cargo.lock`, or extension code were modified. CLAUDE.md, SECURITY.md, and the foundational design spec were not modified — those changes need user review per the audit constraints.
### v0.5.0 PM follow-up pass (commits `ca059e7`, `8fd9a05`, `1342228`, `76d092d`, `9c97f9f`):
- `docs/architecture/overview.md` — F1: four-codebases framing, ASCII diagram fans out to server, table row, build matrix, "Where to look next".
- `CLAUDE.md` — F2: project tree gains `relicario-server`. F3: Roadmap line replaced. F4: Item/Field/Attachment ID widths and entropy noted.
- `docs/SECURITY.md` — F11: dropped `before v0.4.0` line. F12: Device Authentication section now names the `relicario-server` crate and its subcommands, with the "without the hook, commits are advisory" caveat.
- `docs/ARCHITECTURE.md` — F10: settings.enc shown alongside manifest.enc in the Vault Creation Flow; pointer added to per-crate ARCHITECTURE.md for typed-items detail.
- `docs/superpowers/specs/2026-04-11-relicario-design.md` — F13: optional one-line "historical spec" status banner at top.
No source files, `Cargo.lock`, or extension code were modified at any point.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View 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)
### AB: 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.
### BC: 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 `&#x2934;` 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 AB and BC interface contracts above. Wait for devs to acknowledge before instructing them to proceed with their task lists.

View 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.

View File

@@ -0,0 +1,265 @@
# Relicario — Whole-Codebase Architecture Review
**Date:** 2026-05-04
**Reviewers:** DEV-A (Rust core), DEV-B (Rust consumers — CLI, server, WASM), DEV-C (TypeScript — extension, relay)
**Synthesis:** PM
**Goal lens:** "Make this app's architecture logical and readable for a smart developer who doesn't know Rust but wants to learn by tinkering."
## Executive summary
The architecture is fundamentally sound: bytes-in/bytes-out core, no I/O leakage from `relicario-core`, a server that structurally cannot decrypt (no AEAD or KDF crate in its dep graph), a service-worker boundary in the extension that holds (content scripts make zero WASM calls), and CLI/extension parity at 22/23 capabilities. What hurts the goal lens is uneven detail: a few outsized files (`cli/main.rs` 2641 LOC, `extension/src/setup/setup.ts` 1220 LOC, `extension/src/vault/vault.ts` 1027 LOC) absorb concerns that belong in smaller modules, and a couple of cross-cutting boilerplate clusters (16× duplicated git-shell error UX in the CLI; duplicated SW router helpers; hand-maintained `wasm.d.ts`) make a learner re-derive the same pattern repeatedly. The single most important thing to address is a defense-in-depth crypto issue spanning Rust and JS: `SessionHandle` has no `impl Drop`, so the wasm-bindgen-generated `.free()` is a no-op for cleanup — the master key stays in WASM linear memory until JS explicitly calls `lock()`. The strongest aspects worth preserving are the documentation density of the security-critical core files (`crypto.rs`, `imgsecret.rs`, `backup.rs`, `tar_safe.rs` all open with multi-paragraph rationale), the discriminated-union message contract in the extension (`shared/messages.ts` + the popup-only / content-callable capability sets), and the server's structural enforcement of the ciphertext-only invariant via its import surface. These three patterns are the model for what every other surface should look like.
## Top-priority recommendations (P1)
### P1.1 — `SessionHandle` has no `impl Drop`; `.free()` is a cleanup no-op
**Area:** cross-cutting (wasm + extension)
**File(s):** `crates/relicario-wasm/src/lib.rs:15-23`, `crates/relicario-wasm/src/session.rs:1-58`, `extension/src/service-worker/session.ts:26`
**Found by:** DEV-B (Rust side, headline finding); DEV-C (symmetric JS-side concern, originally [P2])
**Observation:** `SessionHandle` is `pub struct SessionHandle(u32)` with no `Drop` impl. wasm-bindgen's auto-generated JS `.free()` drops a `u32` — i.e. nothing. The `SESSIONS` HashMap entry stays alive with the master key and image_secret in WASM linear memory until JS calls the explicit `lock(handle)` (which calls `session::remove`). DEV-C separately observed `service-worker/session.ts:26` swallows `free()` errors with `try { current.free() }` — that swallow is hiding the fact that the call wasn't doing crypto cleanup anyway.
**Why it matters for the user's goal:** This is the only finding in the review where the gap between "what the code looks like it does" and "what it actually does" is dangerous. A tinkerer reading `session.ts` reasonably assumes `free()` cleans up the WASM-side key; it does not. Defense-in-depth here is one Rust block and one JS audit.
**Suggested direction:** Add `impl Drop for SessionHandle { fn drop(&mut self) { session::remove(self.0); } }` to the WASM crate so `.free()` becomes the safety net and `lock()` becomes the explicit "I am done" call. In parallel, remove the `try { ... }` swallow at `service-worker/session.ts:26` (let exceptions propagate or log + counter) and audit every `.free()` callsite under `extension/src/` to ensure `wasm.lock(handle)` happens first regardless. A `wasm-bindgen-test` covering construct → drop → confirm registry empty locks the contract.
**Effort:** S (Rust fix) + S (JS audit) = ~1-2 hours total
### P1.2 — `crates/relicario-cli/src/main.rs` is a 2641-line monolith
**Area:** cli
**File(s):** `crates/relicario-cli/src/main.rs:1-2641`
**Found by:** DEV-B
**Observation:** The clap surface (lines 1-455) is a tour of the product and reads beautifully. Past line 456 the file becomes flat: every `cmd_*`, every `build_*_item`, every `edit_*`, six prompt helpers, three parsing helpers, and 24+ git shell-outs all live as peers. `cmd_add` calls 7 different `build_*_item` functions (each ~50-60 lines) with no module boundaries. Searching "where does add work?" requires scrolling, not navigating.
**Why it matters for the user's goal:** This is the first file a tinkerer opens after `cargo run -p relicario-cli -- --help`. Today they have to scroll through 2200 lines of flat code to follow any one command end-to-end. Splitting this is the precondition for fixing P1.3 and centralizing several other CLI patterns (groups-cache discipline, prompt helpers).
**Suggested direction:** Keep `main.rs` as clap definitions + the dispatching `match` (~470 lines). Split into `commands/{add,get,list,edit,trash,backup,import,attach,settings,sync,status,device,recovery_qr}.rs`, plus `prompt.rs` (the six `prompt_*` helpers + `prompt_secret`) and `parse.rs` (`parse_month_year`, `base32_decode_lenient`, `guess_mime`). Same LOC, different reading experience.
**Effort:** M (mechanical split, no logic changes)
### P1.3 — Git invocation boilerplate duplicated 16× with one-line errors
**Area:** cli
**File(s):** `crates/relicario-cli/src/main.rs:601, 602, 610, 986, 988, 1477, 1480, 1767, 1897, 1900, 2432, 2438, 2533, 2540` (and others)
**Found by:** DEV-B
**Observation:** Every `git_command` invocation that bails uses the same shape: `.args([...]).status()? + if !status.success() { bail!("git foo failed") }`. Child stderr is inherited interactively, but in test runs and noninteractive tooling it's lost, and the bail message is just the verb. When this fires in the wild — pre-receive reject, missing remote, dirty tree, signing-key prompt — the user sees one line and nothing actionable.
**Why it matters for the user's goal:** This is the *entire* error UX for the git side of the CLI. Failures are common, the diagnostic is uniformly unhelpful, and a learner will hit "git commit failed" with no context the first time they touch a misconfigured remote.
**Suggested direction:** Add `helpers::git_run(repo, args, context)` that uses `.output()` (capturing stderr), prints captured stderr unmodified on failure, and embeds the human-readable `context` ("commit add: GitHub", "register device", "purge trashed item"). 16 copies become one-liners.
**Effort:** S (single helper + sweep)
### P1.4 — `extension/src/setup/setup.ts` bypasses the SW and orchestrates WASM directly
**Area:** extension
**File(s):** `extension/src/setup/setup.ts:28-37`, `:1118-1120` (and the whole 1220-LOC file)
**Found by:** DEV-C
**Observation:** Popup, vault tab, and content scripts all funnel WASM through the service-worker. `setup.ts` is the only surface that imports `relicario-wasm` directly and orchestrates `unlock` / `embed_image_secret` / `register_device` / `manifest_encrypt` itself — duplicating ~400 LOC of crypto orchestration the SW already knows how to do. Side-effect: the setup tab can't be locked by the same session-timer the rest of the extension uses, and `WizardState` (passphrase + JPEG bytes + WASM handle) persists in module scope if the user abandons mid-wizard.
**Why it matters for the user's goal:** This is the single biggest "code lies about the pattern" surface in the extension. A learner who opens `setup.ts` first will see WASM imported directly and conclude that's how the extension works — it isn't; everywhere else routes through `chrome.runtime.sendMessage`. The file is also a 1220-LOC procedural wizard that could be a step registry.
**Suggested direction:** Add `create_vault` and `attach_vault` messages to the SW; turn `setup.ts` into a UI that posts those messages with the gathered config + image bytes. Convert the 6-step flow into a `{ id, render, attach }[]` step registry. The 1220 LOC drops to ~500. Add a `clearWizardState()` bound to `beforeunload` and to "return to step 0" so abandoned wizards don't persist sensitive material.
**Effort:** L (architectural — touches setup, the SW message router, and `wasm.d.ts`/types)
### P1.5 — `extension/src/vault/vault.ts` is a 1027-LOC do-everything file
**Area:** extension
**File(s):** `extension/src/vault/vault.ts:1-1027`
**Found by:** DEV-C
**Observation:** Single file owns: shell init, hash routing, sidebar render, list render, drawer state, type-picker, form-wrapper, deep-link routing, teardown coupling. Adding a new pane view today is a 5-place edit (`teardownPaneComponents` lines 813-820 is the symptom). Two state-management oddities sit inside: vault tab intercepts `vault_locked` errors via the RPC layer while the popup uses the `session_expired` event (two channels for one outcome), and `state.drawerOpen = true` survives navigation to non-list views.
**Why it matters for the user's goal:** This is the second steepest cliff for a tinkerer (after `setup.ts`). The vault tab is the user's primary fullscreen experience; the file that drives it should read as orchestration, not implementation.
**Suggested direction:** Split into `vault-shell.ts`, `vault-sidebar.ts`, `vault-list.ts`, `vault-drawer.ts`, `vault-form-wrapper.ts`, leaving `vault.ts` to own only routing and state. While doing so, lift the `vault_locked` RPC intercept into `shared/state.ts` (or a wrapper around `sendMessage`) so popup and vault use one path; reset `state.drawerOpen` at the start of `renderPane` for non-list views.
**Effort:** M (mechanical split, plus one channel-unification touch)
### P1.6 — `extension/src/shared/state.ts` is fully `any`-typed
**Area:** extension (shared)
**File(s):** `extension/src/shared/state.ts:10-35`
**Found by:** DEV-C
**Observation:** `StateHost` is the bridge that lets popup components run inside the vault tab — and it's the bridge most likely to drift between the two render targets — but its entire contract is `any`-typed. TS gives no signal when popup-only state shape diverges from vault-tab expectations. Module-scope `host` singleton additionally has no double-registration guard; tests forget a reset and the leak silently breaks isolation.
**Why it matters for the user's goal:** Two-render-target reuse is exactly the kind of architecture decision that pays off in maintainability — but only if the contract is type-checked. As-is, the bridge is the weakest learning surface in the extension.
**Suggested direction:** Define a concrete `StateHost` interface: `state: PopupState`, `navigate: (view: View) => void`, `popOutToTab(): void`, `isInTab(): boolean`, `openVaultTab(hash?: string): void`. Make `getState`/`setState` generic over `keyof PopupState`. Throw on `registerHost()` re-register; export a `__resetHostForTests()` helper.
**Effort:** S-M (type definitions + sweep through callers)
### P1.7 — `crates/relicario-core/src/recovery_qr.rs` is undocumented
**Area:** core
**File(s):** `crates/relicario-core/src/recovery_qr.rs:1-130`
**Found by:** DEV-A
**Observation:** No module-level `//!` header. No doc comments on `RecoveryQrPayload`, `generate_recovery_qr`, `unwrap_recovery_qr`, or `recovery_qr_to_svg`. Magic constants (`RREC`, `VERSION = 0x01`, `PAYLOAD_LEN = 109`) sit at the top with no explanation; the 4+1+32+24+48 layout is hand-counted with no struct, diagram, or asserts. The domain-separation prefix `b"relicario-recovery-v1\0"` exists but isn't explained. `production_params()` redeclares `KdfParams::default()` values with no comment on why the recovery format pins them.
**Why it matters for the user's goal:** Every other security-relevant file in the core (`crypto.rs`, `imgsecret.rs`, `backup.rs`, `tar_safe.rs`) has explanatory framing. A learner hitting `recovery_qr.rs` sees a different style and assumes either it doesn't matter or they've stumbled out of the documented zone. It does matter — this is the vault-key escape hatch — and a misuse here (e.g., reusing `production_params` as `KdfParams::default()` and then changing the default) silently breaks all extant recovery QRs.
**Suggested direction:** Add a module-level `//!` summarizing the format, the KDF-input domain separation, and the parameter-pinning rationale. Add a short ASCII diagram of the 109-byte layout near the constants. Doc-comment the four public functions. Either replace `production_params()` with a `const` or add a comment explaining the deliberate divergence from `KdfParams::default()`.
**Effort:** S (documentation only)
### P1.8 — `tools/relay/queue.test.ts` fails on uncommitted state
**Area:** tooling
**File(s):** `tools/relay/queue.test.ts:54`
**Found by:** DEV-C (verified via `bun test` → 1 fail / 4 pass)
**Observation:** Uncommitted changes added `dev-c` to the `Role` union and `KNOWN_ROLES` set in `queue.ts`, but the test still asserts the old enum: `assert.ok(!isRole("dev-c"))`. One-line fix.
**Why it matters for the user's goal:** A learner running `bun test` from `tools/relay/` immediately sees a failure and assumes the codebase is broken. The relay is the literal coordination substrate of this review; a green test run is table stakes.
**Suggested direction:** Update the test assertion to `assert.ok(isRole("dev-c"))` and add a negative case (`assert.ok(!isRole("dev-d"))`). Confirm `start.sh` opens a fourth window for dev-c (DEV-C suspected `:80` still hardcodes "Dev-B" in user-facing output).
**Effort:** S (one-line edit + one-launcher-line check)
### P1.9 — Duplicated SW router helpers (storage helpers + `itemToManifestEntry`)
**Area:** extension (service-worker)
**File(s):** `extension/src/service-worker/router/popup-only.ts:687-703` and `:~169`; `extension/src/service-worker/router/content-callable.ts:187-205` and `:~169`
**Found by:** DEV-C
**Observation:** Three identical `chrome.storage.local` helpers (`loadDeviceSettings`, `loadBlacklist`, `saveBlacklist`) and the 17-line `itemToManifestEntry()` projection are duplicated across both router files. Both code paths can mutate the blacklist via different definitions; future drift will silently corrupt one path. Manifest schema refactors will need to be made twice.
**Why it matters for the user's goal:** A learner reading either router file sees private helpers and assumes that's where they live; finding the same name in the sibling router with a near-identical body is a routine "wait, why is this duplicated?" moment that should not exist in a tightly-typed message-router architecture.
**Suggested direction:** Extract the three storage helpers to `service-worker/storage.ts`; move `itemToManifestEntry` into `service-worker/vault.ts`. Import from both router files. Pair this with [P1.6] for `shared/state.ts` typing — both are about giving the extension's "shared utilities" a concrete shape.
**Effort:** S (extract + sweep)
### P1.10 — Pure parsers in CLI that the extension will eventually need
**Area:** cli → core
**File(s):** `crates/relicario-cli/src/main.rs:942-980` (`parse_month_year`, `base32_decode_lenient`, `guess_mime`)
**Found by:** DEV-B
**Observation:** Three pure parsers producing typed core values currently live only in the CLI. Per the project's CLI/extension parity philosophy (CLAUDE.md memory rule), anything the CLI does in pure logic the extension will eventually need too — QR-import, month-year smart input, attachment MIME detection. There is also a `base32_encode` in `core/item.rs` and a `decode_base32_totp` in `core/import_lastpass.rs` that are inverse pairs in different modules — DEV-A flagged the same shape from the core side ([P2] base32 has three implementations).
**Why it matters for the user's goal:** Parity is a stated design philosophy, and parser drift between CLI and extension is the most likely place for it to fail silently.
**Suggested direction:** Migrate to core: `MonthYear::parse` (already partial in `time.rs`), `Totp::parse_secret` (or `ItemCore::parse_totp_seed`), `mime::guess_for_extension`. Pair with extracting a `pub(crate) mod base32` in core with `encode_rfc4648` / `decode_rfc4648`, leaving Steam's bespoke alphabet where it is. The CLI keeps thin wrappers; the WASM crate exposes the new functions; the extension calls them via SW message handlers.
**Effort:** M (move + adjust CLI callers + add WASM exports)
## P2 recommendations
### Core (Rust)
- **`extract_with_crop_recovery` is narrower than the spec describes.** `crates/relicario-core/src/imgsecret.rs:849-899`. Spec promises 15% from any edge; impl pins `dx=0, dy=0` and varies only `orig_w`/`orig_h`. Left/top crops won't recover. Either extend the recovery loop or update the spec language to "right/bottom crop tolerance only." (DEV-A)
- **Stale "in-progress rewrite" headers** in `vault.rs:1-7`, `manifest.rs:1-2`, `attachment.rs:1-4`. Each comment describes work that already shipped. Replace with one-line module summaries. (DEV-A)
- **Two dead fields in `EmbedRegion`** (`imgsecret.rs:225-229`). `region_width`/`region_height` are computed, stored, and silenced with `#[allow(dead_code)]`. Either delete or comment the future use. (DEV-A)
- **Three base32 implementations.** `item.rs:255-275`, `import_lastpass.rs:202-220`, `item_types/totp.rs:13` (Steam, intentionally different). Extract a `pub(crate) mod base32`; leave Steam alone with a neighbour comment. (DEV-A; folds into P1.10.)
- **Backup format embeds the reference JPEG as base64-in-JSON** (`backup.rs:148-152, 274-280, 343-345`). Round-trip works; bloats by ~33% over the binary baseline post-zstd. Defer until backup-size pressure shows up. (DEV-A)
### CLI (Rust)
- **`build_*_item` functions mix prompting, parsing, and core construction.** `main.rs:664-921`. Compress with a `prompt_or_flag<T>` helper. (DEV-B)
- **`refresh_groups_cache` invocation discipline is manual at 7 sites.** `main.rs:641, 998, 1123, 1197, 1414, 1432, 1474`. Rule "every mutating handler must call this" is unenforced. Wrap in `Vault::after_manifest_change(&self, manifest: &Manifest)`. (DEV-B)
- **`ParamsFile` defined twice with mismatching shapes.** Write side `main.rs:2287` has `aead`, `salt_path`, `format_version`; read side `session.rs:114` takes only `kdf`. Single struct in core or shared session module. (DEV-B)
- **`cmd_purge` and `cmd_trash_empty` duplicate the manifest-add-and-commit dance** (`main.rs:1476-1480, 1896-1900`). 50-item purge does 150 git invocations; batch the staging. (DEV-B)
### Server (Rust)
- **`generate-hook` assumes `$PATH`.** `relicario-server/src/main.rs:170`. Most Gitea hook environments don't have `/usr/local/bin`; `command not found` is the failure mode. Embed `current_exe()` or emit an explicit `PATH=` line. (DEV-B)
- **Bootstrap branch is permissive.** `:38-44, 54-57`. Missing `.relicario/devices.json` at `newrev` is treated as bootstrap. An attacker pushing a brand-new branch that strips `.relicario/` could push unsigned commits. Either document the rule or enforce: `oldrev != 0``devices.json` must exist in `newrev`. (DEV-B)
- **stdin-parsing lives in the shell hook only.** `:130-189`. Wiring up the binary by hand isn't possible without re-implementing the per-line `<old> <new> <ref>` parse. Add `verify-commit --from-stdin` or doc-comment the constraint. (DEV-B)
- **Test coverage gaps** (`tests/verify_commit.rs`): unsigned-commit, malformed `devices.json`, bootstrap-empty, stripped-`.relicario/`. Each is one extra `#[test]`. (DEV-B)
### WASM (Rust)
- **Redundant double-lookup pattern.** `lib.rs:73, 84, 92, 103, 111, 122, 160, 172` all do `need_key(handle)?` then `session::with(handle.0, |k| ...).unwrap()` — two HashMap lookups per call. Single-`with` helper that returns `JsError` on miss. (DEV-B)
- **`Vec<u8>` getters clone on every read.** `EncryptedAttachment::aid` and `bytes` (`lib.rs:141-150`). Document "call once, cache locally" or consume by value. (DEV-B)
- **`wasm_*_recovery_qr` prefix is inconsistent** with everything else. `lib.rs:497, 510`. Rename to `generate_recovery_qr_svg` and `unwrap_recovery_qr` (and update `extension/src/wasm.d.ts`). Trivial breaking rename — do it before any new caller appears. (DEV-B)
- **`device.rs` and `session.rs` use different concurrency primitives** (`Lazy<Mutex<...>>` vs `thread_local! { RefCell<...> }`). Pick one. (DEV-B)
- **`extension/src/wasm.d.ts` is hand-written and explicitly requires manual sync** with `crates/relicario-wasm/src/lib.rs`. Every change to `#[wasm_bindgen]` must be mirrored manually; today they're aligned. Add a CI check comparing `wasm-pack`generated `.d.ts` against this file, or import the generated file directly. (DEV-C; partner finding to DEV-B's WASM section.)
### Extension — service-worker
- **Inactivity timer reset skips content-callable messages** (`service-worker/index.ts:76-78`). A user actively autofilling but not opening the popup will be force-locked despite continuous use. Reset on all messages except known read-only content calls. (DEV-C)
- **Session expiry clears `state.manifest` but leaves `state.gitHost`** (`service-worker/index.ts:51-58`). Cached git-host client survives expiry; rotation could mix with stale connection state. Null `state.gitHost` alongside. (DEV-C)
- **`try { current.free() }` swallows free errors** (`service-worker/session.ts:26`). See P1.1 — this becomes important once `impl Drop` lands. (DEV-C)
### Extension — popup + components
- **Duplicated teardown helpers** (`settings.ts:56-65` and `settings-vault.ts:15-22`). After the recent stub-restore commits (`8baef5b`, `ddfb95d`), teardown leaks are a known regression class — duplicated cleanup is exactly the pattern that re-introduces them. Extract a single `teardownSettingsCommon()`. (DEV-C; originally P1, demoted by PM because the leak vector is small but the duplication is real.)
- **Settings module-scope singletons** (`settings.ts:33-34`). `pendingVaultSettings` and `sessionHandle` survive section navigation. Reset on `renderSection` entry, or scope into a closure per render. (DEV-C)
- **Item-list popover wires listeners on every render** (`item-list.ts:152-353`) without a reuse path. Cache the DOM and reuse, or use AbortController. (DEV-C)
- **`Promise.all` without per-promise error handling** (`devices.ts:47-50`, `trash.ts:39-46`). Single rejected RPC fails the whole render. Use `Promise.allSettled`. (DEV-C)
- **Generator-panel cleanup not idempotent-guarded** (`generator-panel.ts:89-261`). Currently safe by accident. Add `if (!activePanel) return`. (DEV-C)
- **Popup teardown calls every type module unconditionally** (`popup.ts:178-181`). Track last-mounted type. (DEV-C)
### Extension — vault tab
- **Vault tab intercepts `vault_locked` via RPC; popup uses `session_expired` event.** `vault/vault.ts:47-74`. See P1.5 — collapse into one mechanism in `shared/state.ts`. (DEV-C)
- **Drawer doesn't auto-close on non-list view changes** (`vault.ts:495-536`). Reset `state.drawerOpen = false` in `renderPane`. (DEV-C)
- **Sidebar re-renders on every search keystroke without debounce** (`vault.ts:648-695`). 50-100ms debounce. (DEV-C)
### Extension — content scripts
- **`fillFields()` returns silently when no password field is found** (`content/fill.ts:50-64`). Dynamic forms can race the listener; user clicks autofill, nothing happens. Send a `fill_failed` ack. (DEV-C)
- **MutationObserver scan is not debounced** (`content/detector.ts:96-103`). SPA churn re-runs the full scan many times per second. Wrap in `requestIdleCallback` or 200ms timer. (DEV-C)
- **Outside-click listener leaks on alternate close paths** (`content/icon.ts:169-175`). AbortController scoped to picker open, or remove in every close path. (DEV-C)
### Extension — setup
- **`setup.ts` 1220-LOC procedural wizard.** Step registry pattern. See P1.4. (DEV-C)
- **`WizardState` is module-scope; sensitive material persists if user abandons mid-wizard** (`setup.ts:69-94`). Add `clearWizardState()` on `beforeunload` and on returning to step 0. Folds into P1.4. (DEV-C)
- **Manifest path constants duplicated** between `setup/probe.ts:11-23` and `service-worker/vault.ts`. Define `VAULT_PATHS` in `shared/types.ts` (or `shared/paths.ts`). (DEV-C)
### Extension — shared utilities
- **Base `Response` is `{ data?: unknown }`; every consumer does hand-written `as ListItemsResponse` casts** (`shared/messages.ts:85-87`). Generic `Response<TKind extends Request['type']>` mapped from a single `MessageMap` table. (DEV-C)
- **`group-autocomplete.ts:26` builds list HTML via `innerHTML` with only `&quot;` escaping.** Group names are user-entered. `<` and `>` aren't escaped — markup-injection risk. Use `document.createElement('option')`. (DEV-C)
- **`restore_backup` flattens `newRemote` inline** (`shared/messages.ts:56-66`). Inconsistent with sibling messages. Extract `RestoreBackupPayload`. (DEV-C)
### WASM boundary (JS side)
- **`__stubs__/relicario_wasm.stub.ts` only stubs 7 of ~25 exports.** Adding a vitest test that touches a new WASM call needs an ad-hoc per-test mock. Round out the stub or provide a `mockWasm({...})` test helper. (DEV-C)
### Relay tooling
- **`tools/relay/start.sh:80` may still reference "Dev-B"** in user-facing output despite the dev-c expansion. Confirm a fourth window opens for dev-c. (DEV-C)
- **`tools/relay/call.py` and `call.ts` are untracked but load-bearing** for the multi-agent fallback path (kickoff prompts reference `call.py` by path). Either track them with a one-line header explaining "MCP-fallback shim" or add to `.gitignore`. (DEV-C)
- **In-memory queue has no TTL, persistence, or cap** (`queue.ts:21-27`). Document the dev-only ephemeral contract or add a per-role cap. (DEV-C)
### Cross-cutting
- **`#[allow(dead_code)]` without explanation** appears in `cli/device.rs`, `cli/gitea.rs`, and `wasm/device.rs`. Each is either "API completeness" or "scar tissue." Annotate with `// TODO(<plan>):` or delete. (DEV-B)
- **Direct `chrome.storage.local` reads from popup components** (`settings-security.ts:112-113`, `setup.ts:1056-1062`). Every other module routes via `sendMessage`. Pick one paradigm and document the exception. (DEV-C)
- **`bun test` is not the project's intended runner** (`extension/package.json:13` is `vitest run`; `tools/relay/` uses `node:test` via `bun test`). README clarification. (DEV-C)
- **Error formatting is inconsistent** across all three Rust crates: CLI mixes sentence-case and lowercase fragments; server is `eprintln!("REJECT: ...")` *except* the malformed-devices.json path; WASM ranges from explicit messages to `RelicarioError::Display` passthrough. Short style note + audit pass. (DEV-B)
## P3 / nice-to-have
A long tail of style sweeps and small ergonomic wins. Pulling the most representative; full lists in the per-reviewer notes.
- Inconsistent error types and constant styles in core (`MonthYear::new -> Result<_, &'static str>`; `pub const MAGIC: [u8;4]` vs `const MAGIC: &[u8;4]`; empty `[dev-dependencies]`). (DEV-A)
- Mid-file `use` blocks in `item.rs:117-122` and `attachment.rs:43-46`. (DEV-A)
- `r#type` field on `Item` and `ManifestEntry` — rename to `item_type` with `#[serde(rename = "type")]`. (DEV-A)
- BIP39 minimum word count of 3 is misleading vs. the 128-bit-security framing — restrict to canonical sets or rename to "BIP39-wordlist passphrase generator." (DEV-A)
- `STEAM_ALPHABET` source comment contradicts its own test about the '5' character. (DEV-A)
- `let _ = entry;` newcomer-hostile pattern repeated 6× in CLI. (DEV-B)
- `cmd_recovery_qr_unwrap` doesn't check empty input before base64 decode; QR ASCII and "NOT been saved" mixed on stdout (pipe-unfriendly). (DEV-B)
- `Lock` CLI subcommand is a visible no-op; either `#[command(hide = true)]` or document the parity rationale. (DEV-B)
- Three test-only env vars duplicated under `#[cfg(debug_assertions)]/#[cfg(not(debug_assertions))]` — one macro flattens ~30 lines. (DEV-B)
- WASM exports use snake_case in JS (`manifest_encrypt`); JS idiom is camelCase. Decide once via `#[wasm_bindgen(js_name = ...)]`. (DEV-B/DEV-C)
- Glyph-rule partial adoption — six popup files use raw glyph literals (`⧉`, `↻`, `▸`, `▾`, `≡`, `⤓`) inline despite the `glyphs.ts` abstraction. Add the missing constants and migrate. (DEV-C)
- `TotpKind = 'totp' | 'steam' | { hotp: { counter: number } }` mixed string/object union — flat discriminated union reads cheaper. (DEV-C)
- `void tick()` in `totp-tools.ts:39-46` swallows promise rejections. (DEV-C)
- `setup.ts:1-7` header says "5-step flow"; code is 6 steps (0..5). (DEV-C)
- `helpers.rs:37 #[allow(dead_code)] pub fn relicario_dir()` has no callers but several `vault_dir.join(".relicario")` sites should use it. (DEV-B)
- `gitea.rs` constructs a fresh `reqwest::blocking::Client::new()` per method call — make it a struct field. (DEV-B)
## Cross-cutting themes
**1. The "where the work happens" boundary holds, but two surfaces lie about it.** `relicario-core` is genuinely platform-agnostic (no fs, no net, no git) and the server provably cannot decrypt (no AEAD/KDF crate present). The extension's content scripts make zero direct WASM calls. These are excellent structural invariants. The two surfaces that break the pattern are `extension/src/setup/setup.ts` (loads WASM and orchestrates crypto directly, bypassing the SW) and the `SessionHandle` `.free()` non-cleanup contract on the WASM/JS boundary. Both are P1; both are also single-surface fixes. The architecture is one P1.1 + one P1.4 away from being uniform.
**2. Duplication concentrates at boundaries, not inside modules.** The two router files duplicate storage helpers and `itemToManifestEntry` (P1.9). The CLI duplicates git-shell error UX 16× (P1.3). Three base32 implementations live in core (P2). Two `ParamsFile` definitions disagree (P2). `parse_month_year` / `base32_decode_lenient` / `guess_mime` live only in the CLI but the extension will need them (P1.10). The pattern: every time a concept crosses a module/crate boundary, the second crossing copies the first instead of importing it. The remedy is small extractions (one helper module per cluster), not refactors. None of these are individually expensive; together they account for a meaningful fraction of total findings.
**3. Three files account for most of the readability cost.** `cli/main.rs` (2641 LOC), `setup.ts` (1220 LOC), `vault.ts` (1027 LOC). Each carries multiple concerns that belong in sibling modules. Splitting all three is cumulative ~M-L effort and unlocks several smaller cleanups (centralizing groups-cache discipline depends on splitting `main.rs`; the SW message-routing fix depends on extracting `setup.ts`'s WASM orchestration; lifting the `vault_locked` channel depends on splitting `vault.ts`). For the user's stated learning-by-tinkering goal, these three splits are the highest-leverage architectural moves available.
**4. Documentation density is uneven in exactly one place.** Core's security-critical files (`crypto.rs`, `imgsecret.rs`, `backup.rs`, `tar_safe.rs`) open with multi-paragraph rationale walking through the *why*. `recovery_qr.rs` does not — and it's the user-visible last-resort recovery mechanism. Bringing one file up to the existing standard is a P1.7 effort of S. Beyond that one file, the core doc story is genuinely strong; preserve it.
## What's strong (preserve)
- **`crypto.rs`, `imgsecret.rs`, `backup.rs`, `tar_safe.rs`: documentation density.** Each opens with multi-paragraph rationale (XChaCha20 vs AES-GCM, QIM with QUANT_STEP=50, length-prefixed concatenation, tar-bomb defenses). This is the model the rest of the crate should be measured against.
- **`relicario-server` trust-model enforcement via the import surface.** The server cannot decrypt the vault even in principle: no AEAD or KDF crate in its dep graph; the entire `relicario-core` import surface is `DeviceEntry`, `RevokedEntry`, `fingerprint`, and (in tests) `generate_keypair` — all plaintext device metadata. This is structural, not by convention.
- **Discriminated-union message contract in the extension.** `shared/messages.ts` + the `POPUP_ONLY_TYPES` / `CONTENT_CALLABLE_TYPES` capability sets give every router handler typed dispatch with origin-aware gating. Content scripts make zero WASM calls and re-validate origin in `fill.ts:32-38`. The boundary discipline holds.
- **Test design across the codebase.** Synthetic JPEGs via `make_test_jpeg()` (no binary fixtures), raw-byte tar bombs that bypass the tar crate's sanitizer, RFC6238 vector for TOTP, an independent reference impl cross-check for Steam, ~30 LastPass importer cases, NFC/NFD round-trips, day-boundary tests for retention. The tests are themselves a documentation artifact.
- **Helpers.rs in the CLI.** Small, focused, tested, and the doc-comment at `:90-93` explaining the plaintext `groups.cache` trade-off is admirably explicit. The git-command hardening (`core.hooksPath=/dev/null`, `commit.gpgsign=false`, `core.editor=true`) is exactly the right kind of comment to leave for a learner.
## CLI/extension parity status
Per DEV-C's full table: **22 of 23 CLI subcommands have a clean extension equivalent**.
- **Clean parity (✓)**: `init`, `add` (all 7 item kinds), `get`, `list` (with all filters), `edit`, `history`, `rm` (soft delete), `restore`, `purge`, `trash list`/`empty`, `backup export`/`restore`, `import lastpass`, `attach`, `attachments`, `extract`, `generate`, `settings *`, `sync`, `lock`, `rate`, `device add`/`revoke`/list, `recovery-qr generate`/`unwrap`. The settings unification under v0.5.1's left-nav (commit `bd6a301`) is the right shape.
- **Partial (✗ish)**: `detach <item> <aid>`. Extension uses a roundtrip through `update_item` with the `attachments[]` mutated client-side. Functional but racy if two devices edit at once. **Suggested fix**: add a `delete_attachment` SW message that does the surgical remove server-side. (P3.)
- **True gap (✗)**: `relicario status`. CLI shows pending sync state, ahead/behind, dirty-tree summary; extension surfaces nothing comparable. **Suggested fix**: `get_vault_status` message returning `{ ahead, behind, lastSyncAt, pendingItems }` plus a small status indicator in the vault sidebar. (P2.)
- **Browser-only (by design)**: `get_autofill_candidates`, `get_credentials`, `check_credential`, `blacklist_site`, `capture_save_login`, `fill_credentials`, `ack_autofill_origin`, `get_blacklist`, `remove_blacklist`, `get_active_tab_url`, `update_settings` for `DeviceSettings`. No CLI counterpart needed.
- **CLI-only (by design)**: `completions <shell>`. n/a.
**No "CLI first, extension follow-up" violations found** under this lens. The parity philosophy is intact; the one gap and the one partial are surgical fixes, not architectural debt.
## Beginner-friendliness assessment
Three reviewers converge on a consistent story for a smart developer who's never written Rust but wants to learn by tinkering.
**Where the floor is high already:**
- `crates/relicario-core/` reads beautifully: bytes-in/bytes-out boundary holds, public API is enumerated in one `lib.rs`, module names map directly to vault concepts (`item`, `manifest`, `attachment`, `settings`, `generators`, `vault`, `backup`, `device`, `recovery_qr`), and the algorithmically dense `imgsecret.rs` is the most heavily commented. A reader can land in `crypto.rs`, follow `derive_master_key``encrypt``vault::encrypt_item`, and reach `tests/integration.rs::full_workflow_login_and_note` in one sitting.
- `relicario-server` is 189 lines, one trust-model question, every dependency is plaintext metadata. Readable end-to-end in under ten minutes.
- The extension's discriminated-union message contract (`shared/messages.ts` + `types.ts`) gives the entire vocabulary in 500 lines. The per-type item form modules (`popup/components/types/login.ts` and siblings) are small parallel surfaces — once one clicks, the others read as variations.
**Where the cliff is:**
- **`cli/main.rs`** at 2641 LOC is the first real wall. A reader following `cmd_add` finds 7 peer `build_*_item` functions plus 6 prompt helpers plus 3 parsers, all in a flat file. (P1.2.)
- **`extension/src/setup/setup.ts`** at 1220 LOC is the second wall, and it's worse because it lies about the architecture: a reader who opens this file first sees WASM imported directly and concludes that's the pattern — it isn't. (P1.4.)
- **`extension/src/vault/vault.ts`** at 1027 LOC is the third. The vault tab is the user's primary surface; the orchestrator file shouldn't also be the implementation file. (P1.5.)
- **`shared/state.ts`** is the weakest learning surface in the extension — `any`-typed bridge between popup and vault tab, no double-registration guard. (P1.6.)
- **`recovery_qr.rs`** in core is the one file where a reader will hit a wall in an otherwise-well-documented crate. (P1.7.)
**The single most valuable change** for the user's stated goal: split `cli/main.rs` into a `commands/` folder (P1.2). It's the first file a tinkerer opens, the change is mechanical, and it unlocks several smaller cleanups (centralized git error UX, unified groups-cache discipline). After that, in order: bring `recovery_qr.rs` to the existing core doc standard (P1.7), extract `setup.ts`'s WASM orchestration into SW messages (P1.4), and split `vault.ts` (P1.5). The four together turn "this is mostly readable" into "this is uniformly readable."
## Open architectural decisions (escalated by reviewers; user judgement)
Pulled from DEV-B's question list and DEV-C's flags. None are blockers for the synthesis; each is a one-paragraph user decision.
1. **Was `impl Drop for SessionHandle` deliberately omitted?** (e.g. to avoid double-free if JS holds two refs.) PM verdict at synthesis: not deliberate; it's the headline fix (P1.1). User: confirm.
2. **Should CLI `parse_month_year`, `base32_decode_lenient`, `guess_mime` migrate to core?** PM verdict: yes, for parity (P1.10). User: confirm timing.
3. **Is "missing `.relicario/devices.json` = bootstrap = accept" intended in perpetuity?** Or should it tighten once a repo has any non-empty devices.json in history? (DEV-B P2.) User: pick a rule.
4. **Is the `Lock` no-op CLI subcommand worth keeping visible in `--help` for parity?** Or hide behind `#[command(hide = true)]`? (DEV-B P3.) User: pick.
5. **Has Task 12 shipped?** `cmd_backup_export` still reads `devices.json` per a "Task 12 will remove" TODO at `main.rs:1535-1537`. (DEV-B P3.) User: confirm and clean up.
6. **Track `tools/relay/call.py` and `call.ts`, or `.gitignore` them?** They're load-bearing for the multi-agent fallback path. (DEV-C P2.) PM verdict: track them — they're documented in coordination prompts.
7. **WASM JS naming: snake_case (current) or camelCase?** Trivial breaking rename via `#[wasm_bindgen(js_name = ...)]`, but only if done before the surface grows. (DEV-B/DEV-C P3.) User: pick once.
8. **`.gitea_env_vars` is untracked.** Name suggests local credentials. PM verdict: should be `.gitignore`d if it isn't already. User: confirm.
## Appendix: pointers to per-reviewer notes
- [DEV-A notes — Rust core](./2026-05-04-dev-a-notes.md)
- [DEV-B notes — Rust consumers (CLI, server, WASM)](./2026-05-04-dev-b-notes.md)
- [DEV-C notes — TypeScript (extension + relay)](./2026-05-04-dev-c-notes.md)

View File

@@ -0,0 +1,191 @@
# DEV-A Architecture Review Notes — Rust Core
Scope: `crates/relicario-core/src/**` (17 source files, ~5.5 kLOC) and `crates/relicario-core/tests/**` (9 integration suites). HEAD reviewed; the only uncommitted change in core is a version bump (`0.2.0 → 0.5.0` in `Cargo.toml`), nothing semantic.
## Summary
The crate has a clear, well-shaped architecture: a thin `lib.rs` re-exporting one concept per module, a unified `RelicarioError` enum, and a strict bytes-in/bytes-out posture (no fs, no net, no git anywhere — the boundary is held). The strongest part is the documentation density of the security-critical files: `crypto.rs`, `imgsecret.rs`, `backup.rs`, and `tar_safe.rs` each open with multi-paragraph rationale that walks a reader through the *why* (XChaCha20 vs AES-GCM, QIM with QUANT_STEP=50, length-prefixed concatenation, tar-bomb defenses) — exactly the "legibility-as-security" philosophy the README aspires to. Tests are excellent: RFC6238 vector for TOTP, an independent reference impl cross-check for Steam, NFC/NFD round-trips, raw-byte tar bombs that bypass the tar crate's sanitiser, and ~30 LastPass importer cases. The weakest part is unevenness — `recovery_qr.rs` is essentially undocumented despite being the user-visible last-resort recovery mechanism, and three modules (`vault.rs`, `manifest.rs`, `attachment.rs`) still carry "during this rewrite" / "added later in Task 22" headers from work that has long since shipped, which will mislead a newcomer about what's load-bearing.
`cargo clippy -p relicario-core --all-targets --no-deps` runs clean (no warnings).
## Findings (prioritized)
### P1 — `recovery_qr.rs` doc gap is conspicuous against the rest of the crate
**File(s):** `crates/relicario-core/src/recovery_qr.rs:1-130` (whole file)
**Observation:** No module-level `//!` header. No doc comments on `RecoveryQrPayload`, `generate_recovery_qr`, `unwrap_recovery_qr`, or `recovery_qr_to_svg`. Magic constants (`RREC`, `VERSION = 0x01`, `PAYLOAD_LEN = 109`) sit at top with no explanation, and the 4+1+32+24+48 layout is hand-counted with no struct, no diagram, and no asserts. The domain-separation prefix `b"relicario-recovery-v1\0"` exists but isn't explained. `production_params()` redeclares the same values as `KdfParams::default()` with no comment on why the recovery format pins them.
**Why it matters:** Every other security-relevant file (`crypto.rs`, `imgsecret.rs`, `backup.rs`, `tar_safe.rs`) has the explanatory framing this codebase rewards readers for. A newcomer hitting `recovery_qr.rs` sees a different style and assumes either it doesn't matter or they've stumbled out of the documented zone. It does matter — this is a vault-key escape hatch — and a misuse here (e.g., reusing `production_params` as `KdfParams::default()` and then changing the default) silently breaks all extant recovery QRs.
**Suggested direction:** Add a module-level `//!` summarizing the format, the KDF-input domain separation, and the parameter-pinning rationale. Add a short ASCII diagram of the 109-byte layout near the constants. Doc-comment the four public functions. Either replace `production_params()` with a `const` or add a comment explaining the deliberate divergence from `KdfParams::default()`.
### P2 — Stale "in-progress rewrite" headers in three core files
**File(s):**
- `crates/relicario-core/src/vault.rs:1-7` ("v1 helpers ... intentionally NOT carried forward. The CLI rewrite in Plan 1B switches to the new helpers.")
- `crates/relicario-core/src/manifest.rs:1-2` ("Lives next to the old entry.rs Manifest during this rewrite; entry.rs is deleted in Task 25.")
- `crates/relicario-core/src/attachment.rs:1-4` ("Encryption helpers ... are added later in Task 22 once the crypto module is settled.")
**Observation:** Each comment describes work that has already shipped. Task 22 added the helpers (they're 30 lines below in the same file). `entry.rs` is gone. Plan 1B is merged. The text reads as if there's a sibling file or a future change to wait for.
**Why it matters:** A newcomer trying to understand the manifest will go looking for `entry.rs` to compare. A newcomer reading `attachment.rs` will read past the helpers thinking "those are coming later." These are the cheapest possible cleanup — comment edits — and they each remove a tripwire.
**Suggested direction:** Replace with a one-line description of what the module *is*, not what it was during the rewrite.
### P2 — `extract_with_crop_recovery` is narrower than the spec describes
**File(s):** `crates/relicario-core/src/imgsecret.rs:849-899`
**Observation:** The design spec (`docs/superpowers/specs/2026-04-11-relicario-design.md` §imgsecret extraction step 3) says crop recovery iterates `(dx, dy)` from -15% to +15% stepping by 8 px (~16,800 candidates for 4000×3000). The code only varies assumed original `orig_w`/`orig_h` while pinning `dx = 0, dy = 0`. The successful-crop test at `imgsecret.rs:1108-1137` only crops the *right* edge, where dx=0 happens to be the correct offset.
**Why it matters:** Crops from the *left* or *top* (e.g. an Instagram square crop centered on a portrait, or any social-media platform that re-frames around faces) won't recover. The spec promises "15% from any edge"; the implementation delivers ~15% from the right and bottom only. Either the spec is wrong (which is allowed — the spec is marked historical) or the implementation has a quiet hole in the recovery surface. If the user ever uploads their reference JPEG to a service that left-crops, recovery will fail and the failure mode looks like "your image is wrong" rather than "we don't try that crop direction."
**Suggested direction:** Either (a) extend the recovery loop with a small dx/dy search bounded by the 15% margin, or (b) update the spec language and the user-facing docs to say "right/bottom crop tolerance only." A third option is to add a test that left-crops a watermarked image and currently fails — that captures the gap and lets future work close it.
### P2 — Two dead fields in `EmbedRegion`
**File(s):** `crates/relicario-core/src/imgsecret.rs:225-229`
**Observation:** `region_width` and `region_height` are computed in `compute_region`, stored in the struct, and silenced with `#[allow(dead_code)]`. Nothing reads them — downstream code uses `blocks_x` / `blocks_y` and `BLOCK_SIZE`.
**Why it matters:** The `#[allow(dead_code)]` is an explicit "I know this is unused" — a newcomer reasonably assumes the fields are load-bearing and will hunt for the consumers, finding nothing. Either they're pre-positioned for a future feature (in which case a comment saying so would help) or they should go.
**Suggested direction:** Delete both fields and the allow attributes, or add a one-line comment explaining the future use. (The struct is private, so removal is risk-free.)
### P2 — Three base32 implementations live in one crate
**File(s):**
- `crates/relicario-core/src/item.rs:255-275` (`base32_encode`, RFC 4648 alphabet)
- `crates/relicario-core/src/import_lastpass.rs:202-220` (`decode_base32_totp`, same alphabet)
- `crates/relicario-core/src/item_types/totp.rs:13` (`STEAM_ALPHABET`, *different* alphabet — by design)
**Observation:** The first two are inverses of each other but live in different modules with no shared helper. The third is intentionally different (Steam Guard's de-ambiguated alphabet). They all hand-roll the bit-packing loop.
**Why it matters:** A reader who finds one of the three has to grep to discover whether there are others, and whether they agree. A future change to the RFC 4648 path (e.g., padding behavior) needs to be applied in two places.
**Suggested direction:** Extract a small `pub(crate) mod base32` with `encode_rfc4648`, `decode_rfc4648`, leaving Steam's bespoke alphabet where it is (with a `// not RFC 4648 — Steam Guard's de-ambiguated alphabet` neighbour comment).
### P2 — Backup format embeds the reference JPEG as base64-in-JSON
**File(s):** `crates/relicario-core/src/backup.rs:148-152, 274-280, 343-345`
**Observation:** The `Envelope.vault.reference_jpg: Option<String>` carries the JPEG as base64-encoded JSON string. After zstd (which can't compress JPEG), a 4 MB reference photo bloats by ~33% from base64 plus JSON overhead.
**Why it matters:** Backup files for users who bundle the reference image will be substantially larger than necessary. Round-trip works (covered by `tests/backup.rs:96-106`), so this is a footprint concern, not a correctness one. Worth flagging if backup size ever shows up in a complaint.
**Suggested direction:** Bump `FORMAT_VERSION` and put `reference_jpg` and `git_archive` in a binary tail outside the JSON envelope, base64 only the small bytes. Defer until there's actual user pressure on backup size.
### P3 — Inconsistent error types and constant styles
**Observations (all small, batched here):**
- `crates/relicario-core/src/time.rs:18``MonthYear::new` returns `Result<Self, &'static str>` instead of `RelicarioError`. Every other constructor in the crate uses the unified error type.
- `crates/relicario-core/src/backup.rs:30` declares `pub const MAGIC: [u8; 4] = *b"RBAK";`; `crates/relicario-core/src/recovery_qr.rs:7` declares `const MAGIC: &[u8; 4] = b"RREC";`. Two different idioms for the same concept in adjacent files.
- `crates/relicario-core/Cargo.toml:36` has an empty `[dev-dependencies]` table (delete the header).
- `crates/relicario-core/src/crypto.rs:248-261` `derive_master_key_raw` is `pub` but only consumed by `recovery_qr.rs` inside this crate (verified via grep). `pub(crate)` would prevent accidental external misuse.
**Why it matters:** Each is trivial in isolation, but for a Rust newcomer reading the crate front-to-back, every inconsistency is a moment of "wait, why is this one different?" — and the answer is almost always "no reason, just historical."
**Suggested direction:** Pick one form for each pattern, sweep.
### P3 — Mid-file `use` blocks in `item.rs` and `attachment.rs`
**File(s):** `crates/relicario-core/src/item.rs:117-122`, `crates/relicario-core/src/attachment.rs:43-46`
**Observation:** Both files have `use` statements partway down the file (in `item.rs`, after `Section`; in `attachment.rs`, after `AttachmentSummary`). Idiomatic Rust hoists all `use` to the top.
**Why it matters:** Newcomer expectation; trivial to fix.
### P3 — `derive_icon_hint` only handles `Login` with no comment about other types
**File(s):** `crates/relicario-core/src/manifest.rs:84, 92-99`
**Observation:** Only `ItemCore::Login` produces a hostname hint; `_ => None` for the other six. No comment explaining why card-brand / favicon-from-URL / etc aren't derived for other types.
**Suggested direction:** One-line `// only Login items have a URL today; other types don't have an obvious icon source.` (Or implement card-brand / identity-favicon if the popup UI wants them.)
### P3 — `attachment.rs` has WHAT-comments on a deref pattern
**File(s):** `crates/relicario-core/src/attachment.rs:60-63, 83-85`
**Observation:** Two "Call-site adaptation" sections explain `&**master_key` in prose. The pattern is idiomatic Rust deref-coercion; the comment explains *what* not *why*.
**Suggested direction:** Delete both comment blocks. The code is self-documenting; if anything, the cognitive load is in the comment, not the deref.
### P3 — TOTP dynamic-truncation extraction is reproduced four times
**File(s):** `crates/relicario-core/src/item_types/totp.rs:75-99` (3 algorithm arms each duplicate the DT slice math), `217-228` (test reference impl reproduces it again).
**Suggested direction:** Extract a `fn dt(hmac_out: &[u8]) -> u32` helper. The test would still need its own copy as the cross-check.
### P3 — `STEAM_ALPHABET` source comment contradicts its own test
**File(s):** `crates/relicario-core/src/item_types/totp.rs:13` and `:287-291`
**Observation:** Source comment says "excludes ambiguous glyphs (0/O, 1/I/L, S/5, A/Z)". Test docstring at line 287 says "Note: '5' IS in the alphabet — S is excluded, so 5 is unambiguous." The test is right; the source comment is wrong about '5'.
**Suggested direction:** Fix the source comment to match the test (and the actual alphabet).
### P3 — `device.rs::sign` and `verify` share an extraction pattern that begs for a helper
**File(s):** `crates/relicario-core/src/device.rs:64-72, 86-94`
**Observation:** Both functions do `key_data.ed25519().ok_or(...)?.try_into::<[u8; 32]>().map_err(...)?`. Five-line copy.
**Suggested direction:** A `fn ed25519_bytes_from_private(...) -> Result<[u8; 32]>` and a `fn ed25519_bytes_from_public(...)` would each fold the extraction. Minor; not worth a refactor on its own but a free win if the file is being touched.
### P3 — `imgsecret.rs::read_block`'s `.unwrap()` deserves a one-liner
**File(s):** `crates/relicario-core/src/imgsecret.rs:315-319`
**Observation:** `read_block_abs(...).unwrap()` is safe because `compute_region` guarantees the block lies inside the embed region, but the invariant isn't stated.
**Suggested direction:** `// safe: compute_region ensures (start_x, start_y) + 8 fits within the image`. Same idea as the existing `expect("ascii-only charset")` in `generators.rs:64`.
### P3 — `r#type` field on `Item` and `ManifestEntry`
**File(s):** `crates/relicario-core/src/item.rs:134`, `crates/relicario-core/src/manifest.rs:23`
**Observation:** Using `r#type` (raw identifier) because `type` is a reserved keyword. Functional but jarring; a Rust newcomer doesn't know what `r#` means and won't immediately realize it's a field name not a type prefix.
**Suggested direction:** Rename to `item_type` with `#[serde(rename = "type")]` to keep wire format. Minor ergonomic win.
### P3 — BIP39 minimum word count of 3 is misleading
**File(s):** `crates/relicario-core/src/generators.rs:79-86`
**Observation:** `word_count` accepted range is `3..=12`. BIP39 spec proper starts at 12 words. 3-word output has only ~33 bits of entropy and would never pass `validate_passphrase_strength` for security uses, but the API permits it. The comment "This gives full-entropy sourcing even for short passphrases" elides that effective entropy is `word_count * log2(2048) = 11 * word_count`, not 128 bits.
**Suggested direction:** Either restrict to BIP39's actual word counts (12, 15, 18, 21, 24) or document that this is a *passphrase generator inspired by BIP39* (using its wordlist) rather than a BIP39 mnemonic generator. The latter is honest about what the code actually does.
### P3 — `StrengthEstimate::guesses_log10` uses base-10 while the spec talks bits
**File(s):** `crates/relicario-core/src/generators.rs:111-113`
**Observation:** `guesses_log10: f64` is base-10 log of guess count; the design spec discusses entropy in bits. Mild domain-translation friction for callers.
**Suggested direction:** Add a one-line comment showing the bits conversion (`bits ≈ guesses_log10 * log2(10) ≈ guesses_log10 * 3.32`), or expose a `bits_estimate()` accessor.
## File-by-file walk
**`lib.rs` (99 lines).** Crate-level docs are tight and accurate; the crypto pipeline diagram in the header is the right thing to greet a newcomer with. Public re-exports surface every meaningful type from one location. Clear.
**`error.rs` (195 lines).** Single error enum with thiserror. Every variant carries helpful context (item id, byte counts, found/expected version) except `Decrypt` which is opaque on purpose (audit M4). Tests exercise the public message format. Reads well.
**`crypto.rs` (437 lines).** The cornerstone. Module-level doc explains why XChaCha over AES-GCM and the binary layout. `derive_master_key` does NFC normalization + length-prefixed concatenation, both with explicit "audit H1" provenance comments. `decrypt_v1` rejection is tested. `derive_master_key_raw` is the seam used by the recovery QR. Solid.
**`ids.rs` (161 lines).** `ItemId`/`FieldId` are 64-bit random hex; `AttachmentId` is content-addressed (SHA-256 truncated to 128 bits). `is_valid()` provides a path-traversal guard (tested for `../../etc`). Header comment cites the audit IDs (M8, I2/B4) that motivated the entropy bumps from v1 — that traceability is great.
**`time.rs` (63 lines).** `now_unix()` and `MonthYear`. Only blemish is `MonthYear::new -> Result<_, &'static str>`; everything else returns `RelicarioError`.
**`vault.rs` (90 lines).** Six typed encrypt/decrypt wrappers (item / manifest / settings) that JSON-roundtrip through the crypto layer. Mechanical and correct. Stale module header (P2).
**`item.rs` (497 lines).** Defines the Item envelope, Field/FieldKind/FieldValue, Section, FieldHistoryEntry. Kind/value invariant enforced at construction and `validate()` post-deserialization. `set_field_value` captures history for password/concealed/totp kinds with kind-change rejection. `prune_history` honors LastN/Days/Forever. Mid-file `use` block (P3). Inline `base32_encode` (P2 above). Otherwise solid; the test at `set_field_value_captures_history_for_password` is exactly the right shape.
**`manifest.rs` (159 lines).** Browse-without-decrypt index v2. `upsert`/`remove`/`get`/`search`. `derive_icon_hint` only handles Login (P3). `r#type` field (P3). Stale header (P2).
**`attachment.rs` (166 lines).** `AttachmentRef` (carried on Item) and `AttachmentSummary` (carried in Manifest) plus `encrypt_attachment`/`decrypt_attachment`. The cap check fires before any crypto work — good order. Stale header + WHAT-comments (P2 + P3).
**`settings.rs` (184 lines).** `VaultSettings` with `TrashRetention`, `HistoryRetention`, `GeneratorRequest`, `AttachmentCaps`, plus the autofill TOFU ack map. Defaults match the design spec. `should_purge` is tested at the day-boundary. Clear.
**`generators.rs` (269 lines).** CSPRNG passwords (rejection-sampled via `Uniform::from`) and BIP39 passphrases (with capitalization variants) plus a zxcvbn-backed strength gate. Solid except the BIP39 lower-bound and `guesses_log10` ergonomics (both P3).
**`imgsecret.rs` (1138 lines).** The novel component. Documentation is excellent — DCT, QIM, EMBED_POSITIONS, MAX_DIMENSION rejection, JPEG header peek (audit M3), even an inline ITU-R BT.601 derivation. Tests cover round-trip, recompression to Q85, 10% crop, oversized-header rejection, and synthetic JPEG generation. Three real concerns: dead `region_width`/`region_height` (P2), narrower-than-spec crop recovery (P2), unannotated `unwrap()` in `read_block` (P3). The 1100-line size is fine — the algorithm warrants it.
**`backup.rs` (348 lines).** `.relbak` container: magic + version + salt + nonce + zstd(JSON envelope). Pinned Argon2id params. Schema/format versioning is paranoid in the right places. Reference-JPEG embedding is base64-in-JSON (P2). Otherwise solid.
**`device.rs` (168 lines).** ed25519 keypair generation in OpenSSH format, sign/verify, and SHA-256 fingerprint. The ssh-key + ed25519-dalek choreography is awkward but unavoidable. Sign/verify share an extraction pattern (P3). Tests cover sign, verify, wrong-data, wrong-key, and fingerprint format/determinism — comprehensive.
**`recovery_qr.rs` (129 lines).** The doc-gap finding (P1). Mechanically correct: domain-separated KDF input, AEAD wrap of the 32-byte image_secret, 109-byte payload that fits a QR v40 at EcLevel::M, SVG render via `qrcode`. But the documentation density doesn't match the rest of the crate.
**`import_lastpass.rs` (220 lines).** Header validation is strict (column count + order). Per-row failures degrade gracefully into `ImportWarning` rather than aborting. SecureNote vs Login dispatch via the `http://sn` sentinel matches the spec (D10). Custom base32 decoder (P2). Otherwise clean.
**`tar_safe.rs` (138 lines).** Replaces `tar::Archive::unpack` with a validated extractor. Rejects `..`, absolute paths, Windows prefixes, symlinks, hardlinks, oversized claimed sizes, and cumulative-size bombs. Returns `(PathBuf, Vec<u8>)` to the caller. Surgical and well-scoped.
**`item_types/mod.rs` (127 lines).** `ItemType` and `ItemCore` enums plus a `pub use` of every per-type core. The `// INVARIANT: no *Core struct may have a field serialized as "type"` comment at line 38 is exactly the kind of cross-cutting note that earns its keep — preserving that convention is what `ItemCore`'s tag-based serde works against. Comprehensive round-trip test at `item_core_round_trips_for_all_seven_types`.
**`item_types/login.rs` (63 lines).** Username/password/url/totp, all Optional, password Zeroizing. `omitted_fields_dont_appear_in_json` is the right shape for a serde test.
**`item_types/secure_note.rs` (30 lines).** Just a Zeroizing body. Right-sized.
**`item_types/identity.rs` (45 lines).** Five Optional fields. `empty_identity_omits_all_fields` round-trips to `{}` — clean.
**`item_types/card.rs` (68 lines).** Number/holder/expiry/cvv/pin/kind. `CardKind` defaults to `Credit`. `MonthYear` reused from `time.rs`.
**`item_types/key.rs` (42 lines).** Key material as Zeroizing string + label/public_key/algorithm. Loose schema (algorithm is a free string), which is appropriate for "any kind of key material."
**`item_types/document.rs` (40 lines).** Filename + mime + AttachmentId pointer to the primary blob. The actual bytes live in the attachment store, not the item.
**`item_types/totp.rs` (293 lines).** TotpCore + the shared TotpConfig (also reused as a field on Login). RFC6238 SHA1 vector check, an independent reference impl for Steam, an alphabet exhaustiveness sweep. HOTP is rejected with a typed error rather than silently mis-counted — the right choice for a stateless library. DT extraction duplicated four times (P3). Source vs test comment disagreement on the '5' character (P3).
**Tests folder (9 files, ~1.1 kLOC).** `integration.rs` covers the full encrypt/decrypt pipeline plus two-factor independence. `format_v2.rs` pins the version byte, the v1-rejection error type, and the length-prefix domain separation. `field_history.rs` covers capture, prune, and survival across encrypt/decrypt. `attachments.rs` covers round-trip + AID determinism + cap. `backup.rs` is thorough — round-trip with and without reference image / git archive, bad magic, unsupported version, wrong passphrase, truncation, tag tamper, NFC/NFD passphrase round-trip. `generators.rs` does class-balance statistics across 10k chars (well-documented why aggregating, since per-call cap is 128). `import_lastpass.rs` is the single largest suite (~30 cases) and exercises every column-mapping edge. `recovery_qr.rs` is minimal but covers the essentials. `safe_unpack.rs` builds raw-byte tars by hand to bypass `tar::Builder`'s sanitizer — exactly the right way to test a security boundary. The `fast_params()` helper is repeated across most files; a `tests/common/mod.rs` could DRY it but it's not a real cost.
## Beginner-friendliness assessment
For a competent dev who has never written Rust, this crate is unusually approachable. The boundary discipline is consistent (no I/O anywhere), the public surface is enumerated in one place (`lib.rs`), the module names map directly to vault concepts a reader already understands (item, manifest, attachment, settings, generators, vault, backup, device, recovery_qr), and the most algorithmically dense file (`imgsecret.rs`) is the most heavily commented. A reader can land in `crypto.rs`, follow `derive_master_key``encrypt``vault::encrypt_item`, and reach `tests/integration.rs::full_workflow_login_and_note` to see the whole pipeline run, all in one sitting. The Rust idioms that would trip up a newcomer (deref-coercion, `r#type` raw identifiers, `Zeroizing` wrappers, `&**master_key`) are all present, but they cluster in patterns a reader will see often enough to absorb.
The single change that would help most: write `recovery_qr.rs` to the same documentation standard as `crypto.rs` and `backup.rs`. It's the only file where a learning reader will hit a wall. Closing that gap brings the floor up to the ceiling and makes the "read it like a security proof" pitch true everywhere, not just in 16 of 17 files.

View File

@@ -0,0 +1,240 @@
# DEV-B Architecture Review Notes — Rust Consumers (CLI, Server, WASM)
**Date:** 2026-05-04
**Scope:** `crates/relicario-cli/`, `crates/relicario-server/`, `crates/relicario-wasm/`
**Out of scope:** `relicario-core` internals (DEV-A), `extension/` and `tools/relay/` (DEV-C)
**Method:** read-only walk of every src + test file; `cargo check` and `cargo clippy` per crate; `cargo build --target wasm32-unknown-unknown` for the WASM crate.
## Summary
The consumer layer is in good shape conceptually but uneven in execution. **`relicario-server` is the highlight**: 189 lines, one obvious responsibility, every dependency on `relicario-core` is plaintext device metadata only — the "server only ever sees ciphertext" invariant is structurally enforced by the import surface, not just by convention. **`relicario-wasm` is small and clean but has one real Rust-side defect**: `SessionHandle` lacks an `impl Drop`, so when wasm-bindgen's auto-generated `.free()` runs, the master key stays in WASM linear memory until `lock()` is also called explicitly — defense-in-depth that is currently missing. **`relicario-cli` does its job correctly but is hard to read**: `src/main.rs` is a 2641-line file with no submodule boundaries between the clap surface, item builders, edit handlers, prompt helpers, and parsers — the single biggest readability blocker in the consumer layer. Across all three crates, naming and module structure are good; what hurts is duplicated boilerplate and the absence of a few obvious helpers.
---
## Findings
### relicario-cli
#### P1 — `main.rs` is a 2641-line monolith with no submodule boundaries
**File(s):** `crates/relicario-cli/src/main.rs:1-2641`
**Observation:** every subcommand handler, every per-type item builder, every per-type edit handler, prompt helpers, parsing helpers, the `ParamsFile` shape, the clap surface, and 24+ git shell-outs all live in one file. The clap surface (lines 1-455) reads as a tour of the product and is excellent; lines 456-2641 are a flat sequence of `cmd_*`, `build_*_item`, `edit_*`, prompt helpers, and parse helpers, all peers. A newcomer searching "where does add work?" finds `cmd_add` calling 7 different `build_*_item` functions (each ~50-60 lines) with no module boundaries.
**Why it matters:** this is the first file a newcomer opens. Today they have to scroll, not navigate.
**Suggested direction:** keep `main.rs` as clap definitions + `match` dispatcher only (~470 lines). Split into `commands/{add,get,list,edit,trash,backup,import,attach,settings,sync,status,device,recovery_qr}.rs`, plus `prompt.rs` (the six `prompt_*` helpers + `prompt_secret`) and `parse.rs` (`parse_month_year`, `base32_decode_lenient`, `guess_mime`). Same LOC, completely different reading experience.
#### P1 — Git invocation boilerplate duplicated ~16× with one-line errors
**File(s):** `crates/relicario-cli/src/main.rs:601, 602, 610, 986, 988, 1477, 1480, 1767, 1897, 1900, 2432, 2438, 2533, 2540` (and others)
**Observation:** every `git_command` invocation that bails uses the same shape: `git_command(repo).args([...]).status()? + if !status.success() { bail!("git foo failed") }`. Child stderr is inherited to the parent tty (which helps interactively) but in test runs and noninteractive tooling it is lost, and the bail message is just the verb (`"git commit failed"`). When this fires in the wild — pre-receive reject, missing remote, dirty tree, signing-key prompt — the user sees one line of "$verb failed" and nothing else.
**Why it matters:** this is the entire error UX for the git side of the CLI. Failure modes are common; the diagnostic is actively unhelpful.
**Suggested direction:** add `helpers::git_run(repo, args, context)` that uses `.output()` (capturing stderr), prints captured stderr unmodified on failure, and includes the human-readable `context` ("commit add: GitHub", "register device", "purge trashed item"). Replaces 16 copies with one-liners.
#### P2 — `build_*_item` functions mix prompting, parsing, and core construction
**File(s):** `crates/relicario-cli/src/main.rs:664-921`
Each `build_*` does its own prompt-or-flag fallback (`title.map(Ok).unwrap_or_else(|| prompt("Title"))?`), parses domain values (URL, MonthYear, base32 TOTP), then constructs an `ItemCore`. Adding a new item type is currently 50-80 lines of mechanical code. A `prompt_or_flag<T>` helper plus per-type builders that take already-validated values would compress this materially.
#### P2 — Pure parsers belong in core, not the CLI
**File(s):** `crates/relicario-cli/src/main.rs:942-980`
`base32_decode_lenient` and `parse_month_year` are pure parsing producing typed core values. Per the user's CLI/extension parity philosophy, these need to be reachable from WASM too — the extension will want them when it gets QR-import / month-year smart input. `MonthYear::parse` and an `ItemCore::parse_totp_seed` (or `Totp::parse_secret`) on the core side would avoid future duplication.
#### P2 — `refresh_groups_cache` invocation discipline is manual at 7 sites
**File(s):** `crates/relicario-cli/src/main.rs:641, 998, 1123, 1197, 1414, 1432, 1474` (and `helpers.rs:90-93` for the doc comment)
The plaintext `groups.cache` is updated by-hand after every manifest mutation. The "failures are silently swallowed" rationale is documented at `main.rs:462`, but the rule "every mutating handler must call this" is not enforced — easy to forget on a new command. Either invoke from `Vault::save_manifest` (couples session to cache layout — maybe wrong) or wrap in `Vault::after_manifest_change(&self, manifest: &Manifest)`.
#### P2 — `ParamsFile` is defined twice with mismatching shapes
**File(s):** `crates/relicario-cli/src/main.rs:2287` (write) vs `crates/relicario-cli/src/session.rs:114` (read)
The write side has `aead`, `salt_path`, `format_version`; the read side takes only `kdf`. Two definitions of the on-disk `params.json` shape, in different files, with overlapping but non-equal fields. A single `Params` struct in `relicario-core` (or in `session.rs`) used by both readers and writers would eliminate the drift risk.
#### P2 — `cmd_purge` and `cmd_trash_empty` duplicate the manifest-add-and-commit dance
**File(s):** `crates/relicario-cli/src/main.rs:1476-1480, 1896-1900`
Same three lines, same error strings, same git rm + git add + git commit per item. `cmd_trash_empty` invokes `purge_item` per item, each of which is its own three-git-invocation loop. Batching the staging would reduce a 50-item purge from 150 git invocations to 3.
#### P3 — Selection of the `let _ = entry;` pattern, repeated 6×
**File(s):** `crates/relicario-cli/src/main.rs:1170, 1407, 1426, 1469, 1913, 2030`
Drop-the-borrow-before-reborrow-mutably is a Rust newcomer's worst surprise. A NLL-friendly refactor (clone `id` and `title` eagerly, let the borrow end naturally) would make these lines disappear.
#### P3 — Other nits
- `cmd_recovery_qr_unwrap` does not check for empty input before base64 decode (`main.rs:2625-2630`)
- `cmd_recovery_qr_generate` mixes the QR ASCII and a "NOT been saved to disk" message both on stdout — pipe-unfriendly (`main.rs:2612-2614`)
- `Lock` subcommand is a no-op but visible in `--help`; either `#[command(hide = true)]` or accept the parity-with-extension argument and document why (`main.rs:445`, doc-comment at `:166`)
- `tests/attachments.rs:69-76` has a dead variable (`blob_path`) kept "to avoid an unused warning" — delete
- Three test-only env vars (`RELICARIO_TEST_PASSPHRASE`, `RELICARIO_TEST_ITEM_SECRET`, `RELICARIO_TEST_BACKUP_PASSPHRASE`) each duplicated under `#[cfg(debug_assertions)]/#[cfg(not(debug_assertions))]` — one macro would flatten ~30 lines
- `cmd_backup_export` still reads `devices.json` (with `[]` fallback) per a "Task 12 will remove" TODO at `:1535-1537`. If Task 12 has shipped, this code can simplify
- `format!("{:?}", e.r#type)` for the TYPE column at `main.rs:1158` — Debug-format for user-facing output. Add `Display` to `ItemType` in core
- `helpers.rs:37 #[allow(dead_code)] pub fn relicario_dir()` — helper has no callers but several `vault_dir.join(".relicario")` sites in main.rs should be using it
- `device.rs:94, 103, 120` and `gitea.rs:24, 26, 47, 77, 94, 101` have `#[allow(dead_code)]` markers without explanation. Either wire up or add `// TODO(<plan>):` so a newcomer knows whether they're scaffolding or scar tissue
- `gitea.rs` constructs a fresh `reqwest::blocking::Client::new()` per method call (3 sites) — make it a struct field
- `tests/edit_and_history.rs` writes hardcoded prompt sequences (`["", "", "", "", "", "y"]`) blindly; if main.rs reorders prompts, tests hang silently. A scripted-test layer (`expect_prompt("Title"); respond("");`) would survive refactors
---
### relicario-server
#### Trust-model assessment (the headline question)
**The server respects the "ciphertext only" invariant. Confirmed.** The crate's entire `relicario-core` import surface is `DeviceEntry`, `RevokedEntry`, `fingerprint` (and `generate_keypair` in tests) — every one of those is plaintext-only device metadata. There is no import of `vault`, `crypto`, `imgsecret`, `item`, `manifest`, or `settings`. A grep over `crates/relicario-server/` for `decrypt|wrapped|encrypted|vault::` returns nothing. The Cargo.toml dep surface (`anyhow`, `clap`, `serde_json`, `tempfile`, `regex`) confirms it: there is no AEAD or KDF crate present. The server cannot decrypt the vault even in principle — it never reads the passphrase, the reference image, the salt, or the params.
The two on-disk files the server reads from the commit tree are `.relicario/devices.json` and `.relicario/revoked.json` (`main.rs:38, 48`), both plaintext metadata. All other operations are `git` subprocesses (`git show`, `git verify-commit --raw`, `git show -s --format=%ct`) plus local fingerprint computation. `generate-hook` emits a pure shell script that re-invokes `relicario-server verify-commit` per commit; it embeds no secret material. **No P1 findings.**
#### P2 — `generate-hook` assumes `$PATH`
**File(s):** `crates/relicario-server/src/main.rs:170`
The emitted script calls `relicario-server verify-commit "$commit"` with no path. Most Gitea deployments do not put `/usr/local/bin` on the hook process's `$PATH`, so this will fail with "command not found" or silently no-op. Either embed `std::env::current_exe()` at hook-generation time, or document an explicit `PATH=` line in the emitted script. There is no test that the generated hook actually executes.
#### P2 — Bootstrap branch is permissive
**File(s):** `crates/relicario-server/src/main.rs:38-44, 54-57`
A missing `.relicario/devices.json` at `newrev` is treated as bootstrap and accepted. Combined with "devices empty AND revoked empty → OK", an attacker who pushes a brand-new branch with no `.relicario/` directory could push arbitrary unsigned commits. There's no test for "second push to an established repo where devices.json was stripped." Worth either documenting the rule (first push wins; once devices.json exists in history, it can never be removed) or enforcing it: `oldrev != 0` should imply `devices.json` exists in `newrev`.
#### P2 — stdin-parsing lives in the shell hook only; binary alone is not hook-shaped
**File(s):** `crates/relicario-server/src/main.rs:130-189` (`generate_hook`)
The Rust binary's `verify-commit` takes a single SHA argument; the per-line `<old> <new> <ref>` parsing is delegated to bash in `generate_hook`. Defensible split, but means anyone wiring up a hook by hand cannot use the binary alone. A `verify-commit --from-stdin` mode (or at least a doc comment on `VerifyCommit` calling this out) would help.
#### P2 — Test coverage gaps vs. trust-model
**File(s):** `crates/relicario-server/tests/verify_commit.rs`
Tests cover: accepted, unregistered → reject, revoked-after → reject, revoked-before → accept. Missing: unsigned commit (no signature at all), malformed `devices.json` (parse error path on `:46`), bootstrap-empty-devices acceptance (`:54`), and one of the two stripped-`.relicario/` cases. Each is one extra `#[test]`.
#### P3 — Server nits
- Regex compiled per call at `main.rs:85` — use `LazyLock` or `once_cell::sync::Lazy`; the `expect("static regex")` comment hints the author knew
- The malformed-devices.json path at `:46` returns an `anyhow` chain on stderr without the consistent `REJECT: ...` prefix the rest of the file uses — ops parsing logs for `REJECT:` will miss it
- No `--version` flag exposed in clap (small ops courtesy)
- `generate-hook` doesn't tell users to `chmod +x` the result (one-line comment header in the emitted script would help)
---
### relicario-wasm
#### P1 — `SessionHandle` has no `impl Drop`; master key leaks on JS GC / `.free()`
**File(s):** `crates/relicario-wasm/src/lib.rs:15-23` and `crates/relicario-wasm/src/session.rs:1-58`
**Observation:** `SessionHandle` is `pub struct SessionHandle(u32)` with no `Drop` impl. wasm-bindgen auto-generates a JS-side `.free()` that, on the Rust side, drops the `SessionHandle` wrapper — i.e. drops a `u32`, a no-op. The `SESSIONS` HashMap entry stays alive **with the master key + image_secret still in WASM linear memory** until JS calls the explicit `lock(handle)` function (which calls `session::remove`). I confirmed via `grep -n "impl Drop" crates/relicario-wasm/src/*.rs` — empty.
**Why it matters:** every `.free()` callsite that does not also call `lock()` first is a key-residency window of unbounded duration. wasm-bindgen does **not** auto-call `free()` on JS GC, but JS code that does call `.free()` reasonably expects the Rust side to clean up. The current contract requires JS to call `lock()` *and then* `free()`, which is asymmetric and easy to get wrong on the JS side (see boundary notes for DEV-C).
**Suggested direction:** add
```rust
impl Drop for SessionHandle {
fn drop(&mut self) { session::remove(self.0); }
}
```
to `lib.rs`. `lock()` becomes the explicit "I am done now" call; `.free()` (auto or manual on the JS side) is the safety net. Defense in depth — the cost is one impl block. Worth a `wasm-bindgen-test` covering construct → drop → confirm registry empty.
#### P2 — Redundant `need_key` + `with(...).unwrap()` double-lookup
**File(s):** `crates/relicario-wasm/src/lib.rs:73, 84, 92, 103, 111, 122, 160, 172`
Every per-call op does `need_key(handle)?` and then `session::with(handle.0, |k| ...).unwrap()`. Two HashMap lookups per call, with the second `.unwrap()` justified only because the first proved the key existed. Single-threaded WASM makes this safe today, but if anyone ever introduces a reentrant path (`Serializer` callback that calls back into WASM), the assumption breaks. Refactor to a single `session::with(...).ok_or_else(|| JsError::new("invalid or locked session handle"))?` helper.
#### P2 — `Vec<u8>` getters clone on every read
**File(s):** `crates/relicario-wasm/src/lib.rs:141-150` (`EncryptedAttachment::aid` and `bytes`)
Each call clones the whole field. JS can call `enc.bytes` repeatedly without realising. For attachment payloads (potentially MB-sized), that's a real cost. Either consume by value or document "call once, cache locally."
#### P2 — Naming: `wasm_*_recovery_qr` prefix is inconsistent with everything else
**File(s):** `crates/relicario-wasm/src/lib.rs:497, 510`
`wasm_generate_recovery_qr` and `wasm_unwrap_recovery_qr` are the only exports with the `wasm_` prefix. Rename to `generate_recovery_qr_svg` and `unwrap_recovery_qr` (and update `extension/src/wasm.d.ts` accordingly). Trivial breaking rename, do it before any new caller appears.
#### P2 — `device.rs` and `session.rs` use different concurrency primitives
**File(s):** `crates/relicario-wasm/src/device.rs` (`Lazy<Mutex<...>>`) vs `crates/relicario-wasm/src/session.rs:14-17` (`thread_local! { RefCell<HashMap<...>> }`)
Both work in single-threaded WASM. The inconsistency hurts readability — pick one pattern. `thread_local! + RefCell` is fine and avoids `Mutex` overhead; `Mutex` over `Lazy` is closer to typical Rust idioms. Either is defensible; consistency is the win.
#### P3 — WASM nits
- All `#[wasm_bindgen]` exports use snake_case (`manifest_encrypt`, `parse_lastpass_csv_json`); JS idiom is camelCase. The `wasm.d.ts` mirrors snake_case verbatim, so it's consistent — but if DEV-C ever wants idiomatic JS naming, `#[wasm_bindgen(js_name = "manifestEncrypt")]` is the path. Decide once, cite the decision in the module doc
- `lib.rs:50` comment "Subsequent wasm_bindgen fns added in Tasks 19-21" is stale historical scaffolding; remove
- `device.rs:18 #[allow(dead_code)] deploy_private` — wire it or remove it
- `session.rs:24 if *n == 0 { *n = 1; }` runs on every insert; logically only matters after wraparound — move into the `wrapping_add` returned-zero branch
- `session_tests` mod inside `lib.rs:522-591` covers session + lastpass; rename or split
- `SessionHandle` doc-comment says "opaque to JS" but `value()` getter (`:21-22`) makes the u32 visible — align the comment with the API
- `pack_backup_json` does six `b64.decode().map_err(...)` blocks (`lib.rs:387-410`) — a small `b64_decode(s) -> Result<Vec<u8>, JsError>` helper would compress ~20 lines
- `get_field_history` walks sections and constructs `serde_json::json!` manually rather than serializing a typed struct (`lib.rs:262-295`); the `_ => String::new()` fallback at `:277` silently swallows future `FieldValue` variants. Use exhaustive match
---
### Cross-cutting (all three crates)
1. **Pure parsers / formatters belong in core.** `parse_month_year`, `base32_decode_lenient`, `guess_mime` (CLI), and `get_field_history`'s walk (WASM) are all examples of logic that today lives in a consumer crate but logically belongs in `relicario-core` so all consumers share it. The CLI/extension parity philosophy makes this concrete: anything the CLI does in pure logic, the extension will eventually need too.
2. **Error formatting is inconsistent.** CLI uses `anyhow::bail!` with a mix of sentence-case ("Settings updated.") and lowercase fragments ("git commit failed"). Server is uniformly `eprintln!("REJECT: ...")` *except* for the malformed-devices.json path that surfaces an anyhow chain. WASM uses `JsError::new(&e.to_string())` mostly, but the messages range from "salt must be exactly 32 bytes" to whatever `RelicarioError::Display` produces. A short style note ("user-facing errors lead with sentence-case context, internal errors use lowercase") plus an audit pass would unify these.
3. **`#[allow(dead_code)]` without explanation appears in cli/device.rs, cli/gitea.rs, and wasm/device.rs.** Each one is either "API completeness for a feature that hasn't shipped" or "scar tissue from a refactor." A newcomer cannot tell which. Either delete or annotate with `// TODO(<plan>):`.
4. **Layering is correct.** None of the three consumer crates reaches past `relicario-core`'s public API. The CLI doesn't import from server or wasm; server doesn't import from CLI or wasm; wasm doesn't import from CLI or server. The only shared concept (device entries, fingerprints) is correctly a core export. ¡Chido! [cool!]
5. **Workspace `Cargo.toml` working-tree changes are cosmetic** (version bumps `0.2.0 → 0.5.0` on cli/core/wasm). No structural concern. Worth committing or reverting before the next merge to keep `git status` quiet.
---
## File-by-file walk
### relicario-cli
- **`Cargo.toml`** — 18 runtime + 4 dev deps, all reasonable. `arboard` (clipboard), `rqrr` (QR decode), `qrcode` (QR encode), `image`, `rpassword`, `dirs`, `reqwest` blocking + JSON for Gitea, `data-encoding`, `tar`. Platform concerns ferried correctly away from core.
- **`src/main.rs`** — entrypoint, dispatcher, **and** every command handler, item builder, edit handler, prompt helper, parse helper. 2641 lines, 71 functions. Clap surface (lines 1-455) is itself a tour of the product and is excellent. Past line 456 the file becomes flat; see P1 #1.
- **`src/helpers.rs`** — the cleanest file. ~239 lines: `vault_dir`, `git_command` (with `core.hooksPath=/dev/null`, `commit.gpgsign=false`, `core.editor=true` hardening — newcomers should read the comment at `:42-45`), `iso8601`, `humanize_age`, `groups_cache_path`, `sanitize_for_commit`, `decode_totp_qr`. Has its own `#[cfg(test)] mod tests` covering `humanize_age`, `sanitize_for_commit`, `iso8601`. `find_vault_dir_from` walks parents. Plaintext `groups.cache` is a deliberate trade-off and the doc-comment at `:90-93` is admirably explicit.
- **`src/session.rs`** — short and clear (~151 lines). `UnlockedVault { root, master_key: Zeroizing<[u8; 32]> }` plus `load_*`/`save_*` for Item, Manifest, VaultSettings. `unlock_interactive()` does passphrase prompt → image extract → KDF. `key()` accessor returns `&Zeroizing<...>` used at 4 call sites; consider passing through `encrypt_attachment`/`decrypt_attachment` methods so the key never leaves this module. `read_params` defines an inner `ParamsFile` struct that mismatches the writer in `main.rs:2287` — see P2.
- **`src/device.rs`** — ~169 lines. ed25519 keypair storage under `~/.config/relicario/devices/<name>/`. `configure_git_signing` runs four `git config` calls. Three `#[allow(dead_code)]` items (`load_signing_key`, `load_deploy_key`, `delete_device_keys`) — API completeness without callers.
- **`src/gitea.rs`** — ~117 lines. Plain blocking reqwest client. `create_deploy_key` parses the JSON response into `DeployKey { id, title, key }` (latter two `#[allow(dead_code)]`). `delete_deploy_key` treats 404 as success — sensible. Each method allocates a fresh `reqwest::blocking::Client::new()`.
- **`tests/basic_flows.rs`** — 137 lines. Tests at the right level: spawn binary via `assert_cmd`, assert on stdout/stderr/exit. `init_creates_expected_layout`, `add_login_then_list_shows_it`, `get_masks_by_default_shows_with_flag`, `rm_restore_purge_cycle`, `generate_random_and_bip39`. Solid CLI-surface coverage.
- **`tests/edit_and_history.rs`** — 191 lines. Most subtle test, interactive `edit` flow. `run_edit_with_pw_change` and `run_edit_totp` write hardcoded prompt sequences (`["", "", "", "", "", "y"]`) to stdin — fragile to prompt reordering. See P3.
- **`tests/attachments.rs`** — 106 lines. Round-trip attach → extract → detach. Has a dead variable kept "to avoid an unused warning" (P3). `attach_rejects_over_cap` exercises only the per-attachment cap, not per-item or per-vault — coverage gap.
- **`tests/settings.rs`** — 158 lines. `settings_roundtrip_trash_retention`, conflicting-flags rejection, generator-defaults end-to-end, `status` command coverage including `last_backup` round-trip. Solid.
- **`tests/backup.rs`** — 142 lines. Export/restore round-trip, `--include-image`, `--no-history`, refusal of non-empty target, wrong-passphrase failure. Excellent coverage.
- **`tests/import_lastpass.rs`** — 127 lines. Importer integration: success, single-commit guarantee, zero-items rejection, header validation, duplicate-import-IDs-are-unique invariant.
- **`tests/smart_inputs.rs`** — 210 lines. Completion-script smoke tests, groups-cache write/suppress, `rate` command (strong/weak/`-` stdin), `--totp-qr` via in-process synthesized QR PNG (`make_test_qr`) — adheres to "synthetic fixtures, no binary blobs."
- **`tests/vault_detection.rs`** — 59 lines. `list_refuses_without_vault_marker`, `get_finds_vault_in_parent_dir`, `v1_vault_is_rejected_with_clear_error` (`.idfoto/` ignored because lookup is for `.relicario/`).
- **`tests/common/mod.rs`** — 132 lines. `TestVault` harness; `init()` creates a `TempDir`, generates synthetic JPEG via `make_test_jpeg`, runs binary with `RELICARIO_TEST_PASSPHRASE` set. `run`, `run_with_input`, `run_with_backup_pass` variants. No shared global state — parallel-test safe.
### relicario-server
- **`src/main.rs`** — 189 lines, very legible. Two clap subcommands cleanly mapped. `verify_commit` reads devices.json + revoked.json from the commit's tree (`git show`), spawns `git verify-commit --raw` with a dynamically-built allowed-signers file injected via `GIT_CONFIG_*` env vars (a clever touch — chido [cool] — avoids touching user gitconfig), parses the SHA-256 fingerprint from stderr, then enforces revocation-first then registration logic. Uses committer timestamp (not author) for revocation cutoff (`:115-123`) — correct for non-rebased histories. All rejection paths use `eprintln!("REJECT: ...")` with actionable context (commit SHA, fingerprint, reason) and exit 1 — visible to the pushing client via `git push` stderr. `generate_hook` emits a clean bash script handling branch creation (`oldrev = 000...`) and branch deletion (`newrev = 000...`).
- **`tests/verify_commit.rs`** — 230 lines, four named scenarios mapping to audit S1. Each test stands up a tempdir git repo, generates real ed25519 keypairs via `relicario_core::device::generate_keypair`, signs a commit with explicit committer date, and shells out to the cargo-built binary via `assert_cmd`. Coverage gaps noted in P2.
- **`Cargo.toml`** — minimal, no surprises. Importantly: no AEAD or KDF crate, which structurally guarantees the server cannot decrypt.
### relicario-wasm
- **`Cargo.toml`** — 27 lines. Right deps. `getrandom = { features = ["js"] }` correctly enabled for browser entropy routing. `image` is dev-only — good. `relicario-core/Cargo.toml` does NOT enable the `js` getrandom feature (correct: core stays platform-agnostic), and the wasm crate "lifts" the feature flag for the dep graph.
- **`src/lib.rs`** — 591 lines, the bulk of the surface. Module doc-comment is concise. Imports are sprinkled mid-file (`:52, :138, :180, :310, :340, :469, :491`) instead of clustered at the top — historical from incremental authoring per Task 19/20/21 markers. Consolidating saves scroll. Sections are demarcated by `// ── ... ──` dividers which help.
- **`src/session.rs`** — 57 lines. `SessionData { master_key, image_secret }` stored in `thread_local! { RefCell<HashMap<u32, _>> }`, monotonic `NEXT_HANDLE` u32 (skips 0 on wraparound). `insert`, `with`, `with_image_secret`, `remove`, `clear` (test-only). Missing `impl Drop for SessionHandle` — see P1.
- **`src/device.rs`** — 71 lines. Clean. `Zeroizing<String>` for private keys (correct — `String::zeroize` wipes the heap allocation). Uses `Lazy<Mutex<DeviceState>>` (different pattern from `session.rs` — see P2).
### Build / clippy
- `cargo check -p relicario-cli` — clean
- `cargo check -p relicario-server` — clean
- `cargo check -p relicario-wasm` — clean
- `cargo build -p relicario-wasm --target wasm32-unknown-unknown` — clean, finishes in ~6s, zero warnings
- `cargo clippy --workspace` — silent (per subagent reports)
---
## Boundary notes for DEV-C
These are the items that look fine from the Rust side but DEV-C must verify on the TypeScript side:
1. **CRITICAL — every `.free()` callsite on a `SessionHandle`.** wasm-bindgen's auto-generated `.free()` does NOT remove the entry from the Rust-side `SESSIONS` registry today, because there is no `impl Drop for SessionHandle` (P1 above). Until that lands, every `.free()` callsite in TypeScript that does not first call `wasm.lock(handle)` is a master-key residency window in WASM linear memory of unbounded duration. Audit `extension/src` for every `.free()` on a `SessionHandle`. The Rust-side fix is preferred (defense in depth); DEV-C should also confirm that every TS-side lock path calls `wasm.lock(handle)` before `.free()` regardless.
2. **Verify every `.free()` callsite, full stop.** Same principle applies to `EncryptedAttachment.free()` and `TotpCode.free()`. wasm-bindgen will not call `free` automatically when the JS object is GC'd — JS GC does not trigger Rust drop. Any handle that goes out of scope without explicit `.free()` leaks in WASM linear memory.
3. **`unlock()` failure semantics.** When unlock throws (bad passphrase, bad params_json, salt wrong length, image_secret extract failure), no `SessionHandle` is created. TS callers should not wrap in `try { ... } finally { handle.free() }` because the handle var will be undefined.
4. **`manifest_decrypt`/`item_decrypt`/`settings_decrypt` return `JsValue` typed as `unknown` in TS.** Rust uses `serde_wasm_bindgen::Serializer::new().serialize_maps_as_objects(true)` (`lib.rs:65`). Verify TS doesn't assume `Map` semantics anywhere — should be plain JS objects. Binary fields decode to `Uint8Array`; confirm TS doesn't try to `JSON.stringify` a decrypted item containing binary.
5. **`*_encrypt` functions take `*_json: string`.** TS must `JSON.stringify` before calling. wasm-bindgen TS bindings will catch this at compile time, but verify no `as any` casts bypass.
6. **`totp_compute(_, now_unix_seconds: bigint)` and `attachment_encrypt(_, _, max_bytes: bigint)`** — TS must use `BigInt(...)`, not `number`. wasm-bindgen throws at runtime on mismatch.
7. **`Uint8Array` arguments are copied into WASM linear memory.** TS doesn't need defensive copies. But a `Uint8Array` view onto a `SharedArrayBuffer` may behave differently — verify TS isn't passing those.
8. **`EncryptedAttachment.aid` and `.bytes` clone on every read** (P2). TS code that does `enc.bytes` twice does double work + double copy. Cache locally.
9. **`SessionHandle.value` getter is exposed.** It's a u32 monotonic counter. If TS ever logs it (telemetry/debug), it's a session correlation identifier that survives across handles.
10. **`get_field_history` returns JS objects with snake_case keys** (`field_id`, `field_name`, `current_value`, `changed_at`). Verify TS components consume these names — easy mismatch with TS-side camelCase conventions.
11. **`register_device`/`sign_for_git`/`get_device_info`/`clear_device` are global, not per-session** — backed by `static DEVICE_STATE` (`Lazy<Mutex<>>`). Single SW = single state, fine. If TS ever instantiates multiple WASM modules (e.g. one per offscreen doc), each gets its own state — verify TS uses one shared init path.
12. **Naming inconsistency**: only `wasm_generate_recovery_qr` and `wasm_unwrap_recovery_qr` carry the `wasm_` prefix. If DEV-C maintains a name-mapping table or auto-generated wrappers, flag for a rename pass.
13. **`parse_lastpass_csv_json` returns `string` (JSON-encoded), not a `JsValue`.** TS must `JSON.parse` the result — different shape from `manifest_decrypt` which returns already-deserialized `unknown`. Verify `import_lastpass.ts` does `JSON.parse(...)` on the result.
14. **All exports are snake_case in JS.** If DEV-C ever wants idiomatic camelCase, the mechanism is `#[wasm_bindgen(js_name = "...")]` per export. Decide once, before the surface grows.
---
## Beginner-friendliness assessment
For a competent dev who's never written Rust:
- **Server is ideal.** 189 lines, one trust-model question, every dependency is plaintext metadata. A newcomer can read it end-to-end in under ten minutes and walk away understanding what the server does and does not do. The single change that would help most is a paragraph-length module-level comment near the top of `main.rs` explaining *why* the server only verifies signatures (because the vault is encrypted client-side, the server has no key, the hook's only job is to gate writes by device authenticity). That paragraph would make the trust story self-evident on first contact.
- **WASM is approachable but has one cliff.** 720 lines across 3 files; `lib.rs` reads top-to-bottom okay; the doc-comment on `SessionHandle` is clear about the opaque-handle contract; the section dividers help. The cliff is the missing `Drop` impl: a beginner reasonably assumes wasm-bindgen handles registry cleanup automatically (it does not). A short comment in `session.rs` saying "**Drop the SessionHandle ≠ remove from registry; you must call `lock()`**" would prevent that mistake — but better to fix the missing `Drop` so the comment isn't needed.
- **CLI has one big roadblock and a lot of small ones.** `helpers.rs`, `session.rs`, `device.rs`, `gitea.rs` all read like real code: small modules, focused responsibilities, doc-comment headers, sensible names. `helpers.rs`'s tests double as documentation. The clap surface in `main.rs:1-455` is itself a product tour. Past line 456, `main.rs` becomes a 2200-line flat file with no submodule boundaries. A newcomer searching "where does add work?" finds `cmd_add` calling 7 different `build_*_item` functions (each ~50-60 lines) plus 6 prompt helpers, all peers. Plus the Rust-specific tripwires — the `let _ = entry;` pattern repeated 6×, the `Zeroizing` newtype, the `Option<Foo>::map(Ok).unwrap_or_else(...)?` chain at every builder — compound the unfamiliarity.
**Single biggest change across the consumer layer:** split `crates/relicario-cli/src/main.rs` into a folder. Keep `main.rs` as clap definitions + dispatcher (~470 lines, very readable). Move every `cmd_*` to `commands/<name>.rs`, prompts to `prompt.rs`, parsers to `parse.rs`. Same LOC, completely different reading experience — and this is the precondition for fixing the duplicated git boilerplate (CLI P1 #2) and centralizing `refresh_groups_cache`.
---
## Open questions for DEV-B (escalated to PM separately)
1. Was `impl Drop for SessionHandle` deliberately omitted (e.g. to avoid double-free if JS holds two refs to the same handle)? If yes, document it; if no, this is the headline fix.
2. CLI `parse_month_year`, `base32_decode_lenient`, `guess_mime` — should they migrate to `relicario-core` for CLI/extension parity?
3. Is "missing `.relicario/devices.json` = bootstrap = accept" intended in perpetuity, or should it be tightened once a repo has any non-empty devices.json in history?
4. Is the `Lock` no-op CLI subcommand worth keeping visible in `--help` for parity with the extension, or hide behind `#[command(hide = true)]`?
5. `cmd_backup_export` still reads `devices.json` per a "Task 12 will remove" TODO. Is Task 12 landed, deferred, or abandoned?

View File

@@ -0,0 +1,343 @@
# DEV-C Architecture Review Notes — TypeScript (Extension + Relay)
**Reviewer:** dev-c (TypeScript scope) **Date:** 2026-05-04 **Branch:** main (read-only review)
## Summary
The TypeScript layer is **soundly organized but unevenly distributed**: a tight discriminated-union message contract (`shared/messages.ts`) and a clean Manifest V3 trust split (popup/setup/content/SW) anchor a codebase that earns its complexity from the two-factor unlock UX. The strongest part is the **service-worker router**: every popup/content message has a typed handler with origin-aware gating, and content scripts never touch WASM. The weakest parts are **two oversized modules that pre-empt the otherwise-clean component model**: `extension/src/setup/setup.ts` (1220 LOC) bypasses the SW and orchestrates WASM directly, and `extension/src/vault/vault.ts` (1027 LOC) inlines shell, sidebar, list, drawer, type-picker, form-wrapper and routing into one file. **CLI/extension parity is excellent overall** — every CLI subcommand has a UI path through the unified left-nav settings (v0.5.1) or vault tab — with two real gaps: explicit per-attachment detach (extension does it via `update_item`) and a vault-status surface equivalent to `relicario status`. One **broken test** in uncommitted relay code (`tools/relay/queue.test.ts:54`) blocks `bun test` from going green.
## Findings (prioritized: P1 / P2 / P3)
### Extension — service-worker
**[P1] router/popup-only.ts:687703 & router/content-callable.ts:187205 — duplicated storage helpers (`loadDeviceSettings`, `loadBlacklist`, `saveBlacklist`)**
WHY: Three identical chrome.storage.local helpers in both router files; both code paths can mutate blacklist via different definitions, so future drift will silently corrupt one path.
DIRECTION: Extract to `service-worker/storage.ts`; import from both routers.
**[P1] router/popup-only.ts:~169 & router/content-callable.ts:~169 — `itemToManifestEntry()` duplicated**
WHY: Identical 17-line manifest projection in both router files; refactors of the manifest schema will need to be made twice.
DIRECTION: Move into `service-worker/vault.ts` and import.
**[P2] service-worker/index.ts:7678 — inactivity timer reset skips content-callable messages**
WHY: Only popup/vault sends reset the timer; a user who is actively autofilling but never opens the popup will eventually be force-locked despite continuous use. Conversely, "every_time" mode is fine.
DIRECTION: Reset on all messages except known read-only content calls (`get_autofill_candidates`); document the exclusion in the timer module.
**[P2] service-worker/index.ts:5158 — session expiry clears `state.manifest` but leaves `state.gitHost`**
WHY: After expiry, the cached git-host client survives; the next unlock could mix with stale connection state if a remote was rotated.
DIRECTION: Null `state.gitHost` alongside `state.manifest` in the expiry callback; the initializer rebuilds it on demand.
**[P2] service-worker/session.ts:26 — `try { current.free() }` swallows free errors**
WHY: A double-free or invalid-handle on the WASM session would be silently ignored, masking a lifecycle bug that could leave the master key zeroed but the JS handle dangling.
DIRECTION: Let exceptions propagate (or log + counter); silent swallow is the wrong default for crypto state transitions.
**[P3] service-worker/index.ts:6165 — chrome.storage.local.get on startup swallows errors**
WHY: A typo or storage quota event leaves the timer in default state with no signal in the logs.
DIRECTION: Surface failure via console.warn with the key name.
**[P3] service-worker/git-host.ts vs github.ts/gitea.ts — deploy-key API only on Gitea side**
WHY: The interface is asymmetric; either GitHub doesn't need it (document) or it's an unimplemented feature.
DIRECTION: Add a doc comment on `GitHost` explaining the asymmetry, or add the GitHub op.
### Extension — popup + components
**[P1] settings.ts:5665 & settings-vault.ts:1522 — duplicated teardown helpers**
WHY: After the stub-restore commits (`8baef5b`, `ddfb95d`), teardown leaks are a known regression class; duplicated cleanup is exactly the shape that re-introduces them.
DIRECTION: Extract `teardownSettingsCommon()` into a single helper; have each settings section call it on unmount.
**[P2] popup.ts:178181 — teardown calls every type module unconditionally**
WHY: Cycling views runs all 7 type teardowns on each transition; harmless today, but makes the lifecycle hard to reason about and hides which module was actually mounted.
DIRECTION: Track last-mounted type and tear down only that one (or use a registry of `() => teardown` returned by mount).
**[P2] settings.ts:3334 — `pendingVaultSettings` and `sessionHandle` are module-scope singletons**
WHY: Section navigation can leave stale `pendingVaultSettings` from a previous section in scope; the current code is safe by accident, not by design.
DIRECTION: Reset to `null` on each `renderSection` entry, or scope into a closure per render.
**[P2] item-list.ts:152353 — settings-picker popover wires listeners on every render without a reuse path**
WHY: Open/close cycles attach + detach listeners cleanly today, but rapid re-opens with throttled `setTimeout` cleanup are exactly the pattern teardown bugs hide in.
DIRECTION: Cache the popover DOM and reuse, or use AbortController for the listeners with an explicit `signal` per open.
**[P2] devices.ts:4750 & trash.ts:3946 — `Promise.all` without per-promise error handling**
WHY: A single rejected RPC fails the whole render; the second response is discarded even when its data was fetched successfully.
DIRECTION: `Promise.allSettled`, then check `.ok` per response.
**[P2] generator-panel.ts:89261 — module-scope `activePanel`; `closeGeneratorPanel()` not idempotent-guarded**
WHY: The cleanup is currently a no-op when called twice, but the contract is implicit.
DIRECTION: Add `if (!activePanel) return` at top.
**[P3] form-header.ts:20 + types/login.ts — `isInTab()` checked twice (header + caller)**
WHY: Redundant coupling; the popout button visibility decision happens in two places.
DIRECTION: Pass `showPopout: boolean` into `renderFormHeader` once.
**[P3] popup.ts:3638 — `isInTab()` heuristic is `window.location.search.length > 0`**
WHY: Any query string on `popup.html` triggers tab mode; brittle if popup is ever opened with diagnostic params.
DIRECTION: Parse a specific param (`?view=tab` or check `window.location.pathname.endsWith('vault.html')`).
**[P3] item-form.ts:95104 — `renderComingSoon()` defined but unused**
WHY: Document type now has a real form (`types/document.ts`); the placeholder is dead code.
DIRECTION: Delete or leave a one-line comment explaining future use.
**[P3] types/login.ts ~700 LOC — `renderForm` mixes HTML, wiring, password-strength, TOTP-ticker, and section editor**
WHY: The reference implementation for the other 6 type modules; size makes the per-affordance lifecycle hard to follow for a learner.
DIRECTION: Extract `wireUrlField`, `wirePasswordField`, `wireTotpField` from the body of `renderForm`. Other type modules already follow simpler patterns.
### Extension — vault tab
**[P1] vault/vault.ts (1027 LOC) — single file owns shell + sidebar + list + drawer + type-picker + form-wrapper + routing + teardown**
WHY: A learner opening this file faces all responsibilities at once; teardown coupling (`teardownPaneComponents` lines 813820) makes adding a new pane view a 5-place edit.
DIRECTION: Split into `vault-shell.ts`, `vault-sidebar.ts`, `vault-list.ts`, `vault-drawer.ts`, `vault-form-wrapper.ts`, leaving `vault.ts` to own only routing and state.
**[P2] vault/vault.ts:4774 — vault tab intercepts `vault_locked` errors via the RPC layer; popup uses only the `session_expired` event**
WHY: Two different mechanisms reach the same outcome; popup-targeted components running in the vault tab don't know which channel will fire first.
DIRECTION: Lift the RPC `vault_locked` intercept into `shared/state.ts` (or a wrapper around `sendMessage`) so both surfaces use one path.
**[P2] vault/vault.ts:495536 — drawer doesn't auto-close on non-list view changes**
WHY: Selecting an item opens the drawer; navigating to Trash via the sidebar leaves `state.drawerOpen = true` even though the drawer is no longer rendered.
DIRECTION: `state.drawerOpen = false` at the start of `renderPane`, or when a non-list view becomes active.
**[P2] vault/vault.ts:648695 — sidebar categories re-render on every search keystroke without debounce**
WHY: Counts and active state must sync, but at hundreds of items the keystroke path stamps the whole sidebar.
DIRECTION: 50100ms debounce on the search input handler before re-rendering.
**[P3] vault/vault.ts:1826 — backup-panel and import-panel are `vault/components/`-only by design**
WHY: Correct call (popup has no room for these), but worth noting in the file header so a contributor doesn't try to import them into popup.
DIRECTION: Add a short comment in `vault/components/index.ts` (or top of each file) explaining the vault-tab-only scope.
### Extension — content scripts
**[P2] content/fill.ts:5064 — `fillFields()` returns silently when no password field is found**
WHY: Dynamic forms (React/SPA mounting after `document_idle`) can race the fill listener; user clicks autofill, nothing visible happens, no error reaches the popup.
DIRECTION: Send a `{type: 'fill_failed', reason: 'no_password_field'}` ack back to SW so the icon can flash an error glyph.
**[P2] content/detector.ts:96103 — MutationObserver `scan()` is not debounced**
WHY: SPA churn fires DOM mutations many times per second; the detector re-runs the full scan each time. WeakSet stops icon double-injection, but the scan cost is wasted.
DIRECTION: Wrap `scan()` in `requestIdleCallback` or a 200ms timer.
**[P2] content/icon.ts:169175 — outside-click listener registered in `setTimeout(0)` not removed on alternate close paths**
WHY: Picker can close via Escape or programmatic destroy; the doc-level listener for outside-click leaks unless `closeOverlay()` is the close path.
DIRECTION: Store the handler reference and `removeEventListener` in every close path; or `AbortController` scoped to picker open.
**[P3] capture.ts vs detector.ts vs fill.ts — username-finder logic copy-pasted three ways**
WHY: `findUsernameField`, `findUsernameForFill`, `findUsernameValue` are three forks of the same heuristic.
DIRECTION: Extract a shared `getUsernameField(pwField, scope, { valueOnly?: boolean })`.
**[P3] capture.ts:269299 — submit-button hook scope is `form ?? pwField.parentElement`**
WHY: A submit button outside the form (e.g. a custom React `<button onClick={...}>` siblings to the form) won't trigger capture; the user never sees the save prompt.
DIRECTION: Walk up to the nearest form ancestor and listen for `keydown.Enter` on the password field as a fallback.
**[Positive note] content scripts make zero direct WASM calls and re-validate origin in `fill.ts:3238`** — boundary discipline holds.
### Extension — setup
**[P1] setup/setup.ts:2837, 11181120 — WASM is loaded and called directly inside the setup wizard, bypassing the service-worker abstraction the rest of the codebase uses**
WHY: Popup, vault tab, and content all funnel WASM through the SW; setup is the only surface that imports `relicario-wasm` and orchestrates `unlock`/`embed_image_secret`/`register_device`/`manifest_encrypt` itself. This duplicates ~400 LOC of crypto orchestration that the SW already knows how to do, and it means the setup tab can't be locked by the same session-timer the rest of the extension uses.
DIRECTION: Add `create_vault` and `attach_vault` messages to the SW; turn `setup.ts` into a UI that posts those messages with the gathered config + image bytes. This collapses ~600 LOC into ~200 and unifies the crypto state machine.
**[P2] setup/setup.ts (1220 LOC) — render/attach pairs per step are inlined**
WHY: 6 steps × (renderStepN, attachStepN) = a switch statement that reads procedurally; adding a step means edits to the renderer, the attacher, and the wizard state.
DIRECTION: Step registry: `{ id: 0, render: () => string, attach: (root) => Teardown }[]` keyed by mode. The 1220 LOC drops to ~500 after the WASM extraction in [P1] above and this restructure.
**[P2] setup/setup.ts:6994 — `WizardState` is module-scope; passphrase + JPEG bytes + WASM handle persist if the user abandons mid-wizard**
WHY: A user who closes the setup tab mid-flow leaves sensitive material in JS memory until the page is unloaded. The handle isn't `free()`d.
DIRECTION: Add a `clearWizardState()` called on `beforeunload` and on returning to step 0; explicitly `Zeroize`-equivalent for the byte arrays where possible.
**[P2] setup/probe.ts:1123 vs service-worker/vault.ts — manifest path constants duplicated (`.relicario/salt`, `.relicario/params.json`, `manifest.enc`)**
WHY: Two sources of truth; if the metadata layout ever moves, probe and vault disagree about whether a remote is initialized.
DIRECTION: Define `VAULT_PATHS` in `shared/types.ts` (or a new `shared/paths.ts`) and import into both.
**[P3] setup/setup.ts:56, 8283 — passphrase score uses `-1` as "not yet scored" sentinel**
WHY: The score is conflated with strength label index 0..4; `-1` magic number is checked in two places.
DIRECTION: Use `{ scored: false } | { scored: true; score: number; guessesLog10: number }`.
**[P3] setup/setup.ts:10561062 — `recovery_qr_generated_at` written directly to `chrome.storage.local`, bypassing `WizardState`**
WHY: One piece of setup result state lives outside the wizard's own state model.
DIRECTION: Add it to `WizardState`, persist on a single `finishSetup` storage write.
**[P3] setup/setup.ts:17 — header comment says "5-step flow"; code is 6 steps (0..5)**
WHY: Cosmetic but actively misleading; line 42 even has a comment about the renumber.
DIRECTION: Update header.
### Extension — shared utilities
**[P1] shared/state.ts:1035 — entire `StateHost` contract is `any`-typed**
WHY: This is the bridge that lets popup components run in the vault tab; it's also the bridge most likely to drift between the two render targets, and TS gives no signal when it does.
DIRECTION: Define a concrete `StateHost` interface with `state: PopupState`, `navigate: (view: View) => void`, `popOutToTab(): void`, `isInTab(): boolean`, `openVaultTab(hash?: string): void`. Make `getState`/`setState` generic over `keyof PopupState`.
**[P1] shared/state.ts:20 — module-scope `host` singleton with no double-registration guard**
WHY: A second `registerHost()` call silently overwrites; in tests, the leak from a previous test breaks isolation if `beforeEach` forgets a reset.
DIRECTION: Throw on re-register; export a `__resetHostForTests()` helper for vitest.
**[P2] shared/messages.ts:8587 — base `Response` is `{ data?: unknown }`; every consumer casts via `Extract<Response, { ok: true }>` plus `data: ...`**
WHY: The pattern works but every call site has a hand-written `as ListItemsResponse` cast; the discriminated-union narrowing the rest of the file relies on stops at `ok: true`.
DIRECTION: Generic `Response<TKind extends Request['type']>` mapped from a single `MessageMap` table, so the response type is inferred at the send site.
**[P2] shared/form-affordances/group-autocomplete.ts:26 — `list.innerHTML = ...map(g => …).join('')` with only `replace(/"/g, '&quot;')` sanitization**
WHY: Group names come from user-entered item data. `<` and `>` are not escaped; a malicious / mistaken group name could inject markup into the autocomplete list.
DIRECTION: `document.createElement('option')` per group with `option.value = g; list.appendChild(option)`.
**[P2] shared/messages.ts:5666 — `restore_backup` flattens `newRemote` inline; other multi-field messages factor out a payload type**
WHY: Inconsistent shape vs sibling messages; harder to reuse the type for the form that posts it.
DIRECTION: Extract `RestoreBackupPayload` and intersect with `{ type: 'restore_backup' }`.
**[P3] glyphs.ts vs popup/components — raw glyph literals (`⧉`, `↻`, `▸`, `▾`, `≡`, `⤓`) appear inline in `item-form.ts`, `item-list.ts`, `settings.ts`, `generator-panel.ts`, `attachments-disclosure.ts`, `fields.ts`**
WHY: The whole point of `glyphs.ts` is the no-inline-emoji rule; partial adoption defeats the audit story.
DIRECTION: Add the missing constants (`GLYPH_COLLAPSE`, `GLYPH_EXPAND`, `GLYPH_ATTACHMENT`, `GLYPH_REGENERATE`, `GLYPH_OVERFLOW`, `GLYPH_DOWNLOAD`) and migrate the call sites. Pair with `extension/src/shared/__tests__/glyphs.test.ts` to lock it in.
**[P3] shared/types.ts:82 — `TotpKind = 'totp' | 'steam' | { hotp: { counter: number } }`**
WHY: Mixed string/object union forces every consumer to do `typeof k === 'string' ? k : k.hotp`; a flat discriminated union is cheaper to read.
DIRECTION: `{ kind: 'totp' } | { kind: 'steam' } | { kind: 'hotp'; counter: number }` with serde rename if the wire format must stay.
**[P3] shared/form-affordances/totp-tools.ts:3946 — `void tick()` swallows promise rejections**
WHY: TOTP preview RPC can fail (e.g., decrypt error); the user sees a frozen ticker with no signal.
DIRECTION: `try { await tick() } catch (e) { renderError(row, e) }`.
### WASM boundary (JS side)
**[P2] extension/src/wasm.d.ts — declarations are hand-written and explicitly require manual sync with `crates/relicario-wasm/src/lib.rs`**
WHY: Comment at top of the file says so; this is exactly the kind of contract that drifts when one side adds a new export. (Today, declarations and the Rust signatures are aligned.)
DIRECTION: Add a CI check that compares `crates/relicario-wasm/pkg/relicario_wasm.d.ts` (wasm-pack output) against `extension/src/wasm.d.ts`, or import the generated `.d.ts` directly via `wasm-pack build --target web` and the runtime loader. Note for DEV-B in boundary section.
**[P2] extension/src/__stubs__/relicario_wasm.stub.ts — only 7 of ~25 exports are stubbed; the rest will throw `wasm stub: X not mocked`**
WHY: Adding a vitest test that touches a new WASM call needs an ad-hoc mock per test; central stub is incomplete.
DIRECTION: Either round out the stub with throwing stubs for the full surface, or provide a `mockWasm({ unlock, item_decrypt, ... })` test helper.
### Relay tooling
**[P1] tools/relay/queue.test.ts:54 — `assert.ok(!isRole("dev-c"))` fails (verified: `bun test` → 1 fail)**
WHY: Uncommitted change added `dev-c` to the `Role` union and `KNOWN_ROLES` set in `queue.ts`, but the test still asserts the old enum. This is a P1 because it's a real test failure on uncommitted code, blocking a green test run.
DIRECTION: Update the test assertion to `assert.ok(isRole("dev-c"))` and add a negative case (`assert.ok(!isRole("dev-d"))`).
**[P1] tools/relay/start.sh — kitty mode launches Dev-C window? Verify against the script**
WHY: The 4-role refactor (pm/dev-a/dev-b/dev-c) needs a fourth window in the launcher. Per the subagent's read, `start.sh:80` still hardcodes "Dev-B" in user-facing output.
DIRECTION: Update the launcher prints to mention all 4 roles and confirm a 4th `--type=tab --hold` window is opened with the dev-c prompt.
**[P2] tools/relay/call.py and tools/relay/call.ts — untracked, no .gitignore policy**
WHY: Coordination prompts (including this one's "Fallback" section) reference `call.py` by path — it's load-bearing for the multi-agent flow, not an experiment. Untracked load-bearing files are a coordination footgun (a fresh checkout breaks the fallback).
DIRECTION: Track both with a one-line header explaining "MCP-fallback shim for the relay; see docs/superpowers/coordination/...". If either is genuinely scratch, add to `.gitignore` instead — but pick one.
**[P2] tools/relay/queue.ts:2127 — in-memory queue with no TTL, persistence, or cap**
WHY: A long-running relay accumulates messages indefinitely; if a session sleeps for a day, the queue is the only audit trail and could grow unbounded.
DIRECTION: Document the dev-only ephemeral contract at the top of `queue.ts`, or add a per-role cap (e.g., last 1000 messages).
**[P3] tools/relay/server.ts:115127 — `makeServer()` factory pattern is correct but unexplained**
WHY: The diff swap from a global `mcpServer` to per-connection factories is load-bearing for concurrent client isolation, but a future contributor might revert it as "unnecessary complexity".
DIRECTION: One-line comment above `makeServer`: "Per-connection MCP server prevents routing collisions across concurrent SSE clients."
### CLI/extension parity gaps
**[P2] No equivalent to `relicario status`** — CLI shows pending sync state, ahead/behind, dirty-tree summary; extension surfaces nothing comparable. The vault-tab footer or a sidebar badge would be the natural home.
DIRECTION: Add a `get_vault_status` message returning `{ ahead: n, behind: n, lastSyncAt: ts, pendingItems: n }` and a small status indicator in the vault sidebar.
**[P3] No explicit per-attachment detach message** — CLI has `relicario detach <item> <aid>`; extension forces a roundtrip through `update_item` with the `attachments[]` mutated client-side. Functional, but it's racy if two devices edit at once.
DIRECTION: Add a `delete_attachment` message that does the surgical remove on the SW side.
**[P3] CLI `list --tag X` filter** — extension does tag filtering client-side after `list_items`; that's fine for the family-vault scale spec but may surprise a learner who reads the CLI side first.
DIRECTION: Document the choice in `messages.ts` near `list_items`.
### Cross-cutting
**[P2] Direct `chrome.storage.local` reads from popup components** — `settings-security.ts:112113` and `setup.ts:10561062` read storage directly while every other popup module routes via `sendMessage`. Inconsistency makes the data-flow story split across two paradigms.
DIRECTION: Pick one: either every storage read goes through `get_settings`/`get_session_config`, or document explicitly that `setup` and `settings-security` are SW-bypass code paths for stated reasons.
**[P2] `bun test` is not the project's intended runner** — `extension/package.json:13` defines `test: vitest run`, but `tools/relay/` uses `node:test` via `bun test`. A learner will hit failures running `bun test` from the repo root because `bun` doesn't load `happy-dom`.
DIRECTION: Add a top-level README note: "extension uses `cd extension && npm run test`; relay uses `cd tools/relay && bun test`."
**[P3] Manifest version 0.5.0** — both `extension/manifest.json` and `manifest.firefox.json` show `0.5.0`, while `package.json` is also `0.5.0`. The roadmap is at v0.5.1-dev; uncommitted bumps to package.json/manifest may be coming. Worth a quick PM check.
## File-by-file walk
### `extension/src/wasm.d.ts` and `__stubs__/relicario_wasm.stub.ts`
Hand-written ambient module declarations mirroring `crates/relicario-wasm/src/lib.rs`. 25+ exports cover unlock/lock, manifest+item+settings encrypt/decrypt, attachment encrypt/decrypt, ID generation, password/passphrase generation + zxcvbn rate, image-secret embed/extract, TOTP compute, device register/sign/get/clear, recovery-QR. The stub used by vitest covers only 7 exports — every other call throws "not mocked" at test time.
### `extension/src/shared/`
`types.ts` (286 LOC) is the canonical TS view of the Rust core schema; well-commented mappings to serde. `messages.ts` (207 LOC) is the discriminated-union message contract and the `POPUP_ONLY_TYPES` / `CONTENT_CALLABLE_TYPES` capability sets that the router uses for sender gating. `state.ts` (62 LOC) is the popup-vs-vault host indirection — currently `any`-typed. `glyphs.ts` (39 LOC, recently edited) is the centralized monospace glyph set. `color-scheme.ts`, `error-copy.ts`, `base32.ts`, `password-coloring.ts`, `toast.ts` are tight focused utilities. `form-affordances/` holds 5 input-behavior wirings (group autocomplete, notes mono toggle, password reveal/strength/QR, TOTP preview/QR, URL fill) plus a tiny `index.ts`.
### `extension/src/service-worker/`
`index.ts` is the entry point: WASM load on first message, RouterState construction, session-timer wiring, `chrome.runtime.onMessage` listener. `vault.ts` (397 LOC) is the encrypted I/O layer — every WASM call passes the `SessionHandle`, never a raw key. `session.ts` is the single-handle store; `session-timer.ts` implements both `inactivity` and `every_time` modes. `devices.ts` reads/writes `.relicario/devices.json` and `revoked.json`. `gitea.ts` and `github.ts` mirror each other (Gitea has deploy-key ops; GitHub doesn't); `git-host.ts` is the abstraction. `router/index.ts` classifies the sender and dispatches to `popup-only.ts` (40+ handlers) or `content-callable.ts` (5 handlers).
### `extension/src/popup/`
`popup.ts` (entry) wires the host registration, view router, and lock/unlock flow. `index.html` is a single `#popup-app` container. `styles.css` carries the popup theme. `components/` holds the visible widgets: `unlock`, `item-list`, `item-detail`, `item-form`, `form-header`, `fields`, `field-history`, `attachments-disclosure`, `generator-panel`, `devices`, `trash`, plus the new `settings.ts` / `settings-vault.ts` / `settings-security.ts` left-nav (v0.5.1). `components/types/` holds the 7 type-specific item modules — `login.ts` is the reference, the others (secure-note, identity, card, key, document, totp) are smaller variants.
### `extension/src/vault/`
`vault.html` (12 LOC) loads `vault.css` + `vault.js`. `vault.css` (~2200 LOC) carries the dark/gold theme + 3-column layout. `vault.ts` (1027 LOC) orchestrates everything: shell init, hash routing, sidebar, list, drawer, type-picker, form-wrapper, deep-link routing, teardown. `components/backup-panel.ts` and `components/import-panel.ts` are vault-tab-only (high-risk operations need fullscreen affordances). Tests: `form-wrapper.test.ts`, `sidebar-glyphs.test.ts`.
### `extension/src/content/`
`detector.ts` finds password fields and injects icons via a MutationObserver. `fill.ts` receives `fill_credentials`, re-validates the page hostname, and uses the native-setter trick to set values past React/Vue. `capture.ts` hooks form submits and shows a closed-Shadow-DOM save prompt. `icon.ts` renders the in-page icon and credentials picker. `shadow.ts` is the closed-Shadow-DOM helper. **Zero WASM calls; clean boundary.**
### `extension/src/setup/`
`setup.ts` (1220 LOC) is the 6-step (0..5) wizard: mode pick → remote config → image select / passphrase → derive+verify or create+embed → device register → recovery QR. Imports WASM directly. `setup-helpers.ts` (84 LOC) is a clean utility module (escapeHtml, debounced rate, strength labels). `probe.ts` (23 LOC) checks for an existing vault on the configured remote.
### `tools/relay/`
`server.ts` is the MCP server with HTTP/SSE transport (per-connection `makeServer()`). `queue.ts` is the in-memory FIFO with `Role` enum and `isRole()` guard. `queue.test.ts` is `node:test` based (4 pass / 1 fail on uncommitted state). `call.py` and `call.ts` are the MCP-fallback shims (untracked). `start.sh` launches manual / tmux / kitty modes. `package.json`, `tsconfig.json` keep the relay self-contained.
## CLI/extension parity table
| CLI capability | Extension equivalent | Notes |
|---|---|---|
| `init --image --output` | `save_setup` + setup wizard step 3-new | ✓ (parity via setup.ts; see [P1] about routing through SW) |
| `add login/secure_note/identity/card/key/document/totp` | `add_item` + `types/<kind>.ts` form | ✓ |
| `get <query> [--show] [--copy]` | `get_item` + popup detail; clipboard via `navigator.clipboard` | ✓ |
| `list [--type] [--group] [--tag] [--trashed]` | `list_items`, `list_trashed`, client-side filter; `list_groups` for autocomplete | ✓ |
| `edit <query> [--totp-qr]` | `update_item` + `wireTotpQr` (jsQR lazy import) | ✓ |
| `history <query>` | `get_field_history` + `field-history.ts` | ✓ |
| `rm <query>` (soft) | `delete_item` | ✓ |
| `restore <query>` | `restore_item` | ✓ |
| `purge <query>` | `purge_item` | ✓ |
| `trash list / empty` | `list_trashed` / `purge_all_trash` + `trash.ts` | ✓ |
| `backup export` | `export_backup` + `backup-panel.ts` | ✓ |
| `backup restore` | `restore_backup` + `backup-panel.ts` (restore path) | ✓ |
| `import lastpass <csv>` | `parse_lastpass_csv` + `import_lastpass_commit` + `import-panel.ts` | ✓ |
| `attach <query> <file>` | `upload_attachment` | ✓ |
| `attachments <query>` | `AttachmentRef[]` already inside `get_item` | ✓ (no separate message; same data) |
| `extract <query> <aid>` | `download_attachment` | ✓ |
| `detach <query> <aid>` | (no message) — done via `update_item` with mutated `attachments[]` | **partial / ✗** |
| `generate --length / --bip39 …` | `generate_password` / `generate_passphrase` + `generator-panel.ts` | ✓ |
| `settings trash-retention / history-retention / attachment-cap / generator-defaults` | `get_vault_settings` / `update_vault_settings` + `settings-vault.ts` | ✓ |
| `sync` | `sync` | ✓ |
| `status` | (no message) | **✗ — gap** |
| `lock` | `lock` | ✓ |
| `completions <shell>` | N/A (CLI-only) | n/a |
| `rate <passphrase>` | `rate_passphrase` (zxcvbn gate) | ✓ |
| `device add / revoke` (+ list) | `add_device` / `register_this_device` / `revoke_device` / `list_devices` / `list_revoked` + `devices.ts` | ✓ |
| `recovery-qr generate / unwrap` | `generate_recovery_qr` / `unwrap_recovery_qr` + `settings-security.ts` | ✓ |
| (browser-only autofill) | `get_autofill_candidates`, `get_credentials`, `check_credential`, `blacklist_site`, `capture_save_login`, `fill_credentials`, `ack_autofill_origin`, `get_blacklist`, `remove_blacklist`, `get_active_tab_url` | extension-only (no CLI counterpart, by design) |
| (browser-only device UX) | `get_settings` / `update_settings` (DeviceSettings: captureEnabled, captureStyle) | extension-only |
**Summary:** 22/23 CLI capabilities have a clean extension path; `status` is the one true gap, and `detach` is partial. No "CLI first, extension follow-up" violations under this lens.
## Boundary notes for DEV-B
1. **`extension/src/wasm.d.ts` is hand-maintained** — every change to `crates/relicario-wasm/src/lib.rs`'s `#[wasm_bindgen]` surface must be mirrored manually. Worth a CI guardrail comparing the wasm-packgenerated `.d.ts` against this file. (See [P2] in WASM boundary.)
2. **The session handle is opaque on the JS side** (`SessionHandle.value: number`, `free()`). DEV-B owns the Rust-side lifecycle: confirm that double-`free()` from JS is a no-op rather than a panic, since `service-worker/session.ts:26` currently swallows free errors silently. If the Rust side could panic on double-free, the silent swallow becomes a crash mask.
3. **`attachment_encrypt(handle, plaintext, max_bytes: bigint)`** — the JS side passes `BigInt` for `max_bytes`. Confirm the Rust binding accepts `u64` and that the conversion is loss-free. The extension is going to push this with γ₁ enforcement (per project memory).
4. **`register_device(name)` returns `{ signing_public_key, deploy_public_key }`** as a plain object (not a class). Setup wizard relies on both being hex strings (`device.public_key` lookup paths). Confirm the device key types stay strings on the Rust side rather than ever moving to `Uint8Array`.
5. **`generate_recovery_qr(handle, passphrase) → string`** and `unwrap_recovery_qr(payload_b64, passphrase) → Uint8Array` — the QR payload format is a contract between this surface and the CLI's `recovery-qr` subcommand. If DEV-B is reviewing recovery-QR end-to-end, confirm that re-deriving from a recovery QR produces the same `master_key` regardless of which side (CLI vs extension) generated it.
6. **`extract_image_secret(image_bytes) → Uint8Array` and `embed_image_secret(carrier, secret) → Uint8Array`** — the central-embed DCT scheme has `MAX_DIMENSION` and `QUANT_STEP = 50.0` constants on the Rust side. The setup wizard does no client-side dimension check before passing the image; if a >MAX_DIMENSION JPEG is selected, the failure message bubbles up generically. DEV-B may want a more specific Rust-side error variant the extension can re-render.
## Beginner-friendliness assessment (TS side)
**Reading order recommendation:** start with `extension/src/shared/messages.ts` and `types.ts` — they tell you the entire vocabulary in 500 lines. Then `service-worker/router/index.ts` to see how messages get routed. Then pick **one** vertical to follow end-to-end: `popup/components/types/login.ts``service-worker/router/popup-only.ts` (the `add_item`/`update_item` handlers) → `service-worker/vault.ts`. After that, `content/detector.ts` + `content/fill.ts` + the corresponding `content-callable.ts` handlers cover the autofill story.
**Trip wires:** the two oversized files (`vault.ts` 1027 LOC, `setup.ts` 1220 LOC) are the steepest cliffs. A learner who opens `setup.ts` first will see WASM imported directly and conclude that's the pattern — it isn't; that file is the exception. Adding a short `EXTENSION_ARCH.md` (or a comment block at the top of `setup.ts`) noting "WASM is loaded directly here only because vault creation predates the SW; everywhere else, route through `chrome.runtime.sendMessage`" would save half a day.
**Strongest learning surfaces:** the discriminated-union message contract, the per-type item form modules (small, parallel, easy to diff), and `service-worker/router/router.test.ts`. The shared form-affordances are also a model of pure-function wiring with explicit teardown — once those patterns click, the rest of the popup is "more of the same."
**Weakest learning surface:** `shared/state.ts` — it's the bridge between popup and vault tab, but the `any` typing tells you nothing about what flows through it. Tightening this is a high-leverage change.
## Uncommitted-state read
The working tree has substantial uncommitted changes. Architecturally relevant:
- **`extension/src/vault/vault.ts` (+151/99) and `vault.css` (+238/99)** — appear to be active work on the form wrapper sticky bar, drawer field grid, and gold-rebrand color palette (Phase 2B). Treat these as **in flight, ignore for this review** — they don't change the architectural shape, only the visual surface.
- **`extension/src/shared/glyphs.ts` (+/2) and `__tests__/glyphs.test.ts` (+/2)** — small additions, likely a new glyph constant. Doesn't affect the [P3] glyph adoption finding (the inline-literal call sites would still need migration).
- **`extension/manifest.json` and `manifest.firefox.json` (+/2)** — likely a version bump in flight; PM should decide whether v0.5.1-dev gets a synced bump in `package.json` too.
- **`tools/relay/queue.ts`, `server.ts`, `start.sh` (modified) + `call.py`, `call.ts` (untracked)** — these are **architecturally relevant**: the dev-c role expansion is real, and `call.py`/`call.ts` are documented-but-untracked fallback shims. The broken test (`queue.test.ts:54`) is the P1 the rest of this review flags. **Do not ignore.**
- **`Cargo.lock`, `crates/*/Cargo.toml`** — out of scope; flag to DEV-A/DEV-B if not already on their list.
- **`.gitea_env_vars` (untracked)** — name suggests local credentials; should be `.gitignore`d if not already (PM check).
- **`docs/superpowers/coordination/v0.5.1-*` and `architecture-review-*`** — coordination prompt evolution; not architectural.
**My call:** vault/vault.css/glyphs/manifest changes are in-flight and shouldn't change findings. The relay changes ARE architecturally on-the-table because the new dev-c role + factory pattern are visible in the running review process. The relay test failure is treated as a P1 because it blocks `bun test` from going green and the kickoff prompt explicitly says "Review is not gated on green; it's gated on understanding" — but this is one cheap fix away from a green test run, and a learner running `bun test` first will assume the codebase is broken.

View File

@@ -1,5 +1,7 @@
# Relicario — Design Specification
> **Status:** historical. V1 shipped 2026-04-22; several "Post-V1 Ideas" listed below (typed items, attachments, secure documents, TOTP, Firefox extension, LastPass import, device authentication) have since shipped. See `CHANGELOG.md` and `docs/architecture/overview.md` for current state. Do not edit this spec as if it were architecture documentation — it is a time-stamped decision artifact.
A git-backed, self-hostable password manager with a Rust core, CLI, and Chrome browser extension. The reference image as a DCT-embedded secret carrier is the core differentiator.
## Overview

View 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

View File

@@ -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 `&#x2934;` 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 &#x2934;)
```
Remove the inline `&#x2934;` 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 |
| 720960px | 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.

View File

@@ -1,7 +1,7 @@
{
"manifest_version": 3,
"name": "Relicario",
"version": "0.2.0",
"version": "0.5.0",
"description": "Two-factor encrypted password manager",
"icons": {
"16": "icons/icon-16.png",

View File

@@ -1,7 +1,7 @@
{
"manifest_version": 3,
"name": "Relicario",
"version": "0.2.0",
"version": "0.5.0",
"description": "Two-factor encrypted password manager",
"icons": {
"16": "icons/icon-16.png",

View File

@@ -1,12 +1,12 @@
{
"name": "relicario-extension",
"version": "0.2.0",
"version": "0.5.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "relicario-extension",
"version": "0.2.0",
"version": "0.5.0",
"dependencies": {
"jsqr": "^1.4.0"
},

View File

@@ -1,6 +1,6 @@
{
"name": "relicario-extension",
"version": "0.2.0",
"version": "0.5.0",
"private": true,
"scripts": {
"build": "webpack --mode production",

View File

@@ -0,0 +1,22 @@
import { describe, it, expect, vi } from 'vitest';
vi.stubGlobal('chrome', {
storage: {
local: {
get: vi.fn((_keys: unknown, cb: (r: Record<string, unknown>) => void) => cb({})),
set: vi.fn((_data: unknown, cb?: () => void) => cb?.()),
},
},
});
import * as settingsMod from '../settings';
describe('settings module contract', () => {
it('exports renderSettings as a function', () => {
expect(typeof settingsMod.renderSettings).toBe('function');
});
it('exports teardownSettings as a function', () => {
expect(typeof settingsMod.teardownSettings).toBe('function');
});
});

View File

@@ -7,6 +7,7 @@
import { sendMessage, escapeHtml } from '../../shared/state';
import type { AttachmentRef, VaultSettings } from '../../shared/types';
import { GLYPH_TYPE_DOCUMENT } from '../../shared/glyphs';
export type DisclosureMode = 'edit' | 'view';
@@ -53,8 +54,8 @@ export function renderAttachmentsDisclosure(opts: AttachmentsDisclosureOpts): st
const action = opts.mode === 'edit' ? '×' : '↓';
const actionClass = opts.mode === 'edit' ? 'attachment-row__remove' : 'attachment-row__download';
const iconHtml = isImage(a.mime_type)
? `<span class="attachment-row__thumb" data-att-id="${escapeHtml(a.id)}" data-mime="${escapeHtml(a.mime_type)}">📄</span>`
: `<span class="attachment-row__icon">📄</span>`;
? `<span class="attachment-row__thumb" data-att-id="${escapeHtml(a.id)}" data-mime="${escapeHtml(a.mime_type)}">${GLYPH_TYPE_DOCUMENT}</span>`
: `<span class="attachment-row__icon">${GLYPH_TYPE_DOCUMENT}</span>`;
return `
<div class="attachment-row" data-att-id="${escapeHtml(a.id)}">
${iconHtml}

View File

@@ -3,6 +3,7 @@
import { getState, setState, sendMessage, navigate, escapeHtml } from '../../shared/state';
import { colorizePassword } from '../../shared/password-coloring';
import type { FieldHistoryView } from '../../shared/types';
import { GLYPH_COPY } from '../../shared/glyphs';
function relativeTime(unixSec: number): string {
const now = Math.floor(Date.now() / 1000);
@@ -75,7 +76,7 @@ export async function renderFieldHistory(app: HTMLElement): Promise<void> {
${isCurrent ? '<span class="history-entry__current">current</span>' : ''}
<span>${isCurrent ? 'set' : 'changed'} ${relativeTime(timestamp)}</span>
</div>
<button class="history-entry__copy" data-entry-copy="${escapeHtml(entryKey)}" title="Copy">📋</button>
<button class="history-entry__copy" data-entry-copy="${escapeHtml(entryKey)}" title="Copy">${GLYPH_COPY}</button>
</div>
`;
}
@@ -140,7 +141,7 @@ export async function renderFieldHistory(app: HTMLElement): Promise<void> {
const value = valueStore.get(key) ?? '';
await navigator.clipboard.writeText(value);
btn.textContent = '✓';
setTimeout(() => { btn.textContent = '📋'; }, 1500);
setTimeout(() => { btn.textContent = GLYPH_COPY; }, 1500);
});
});
}

View File

@@ -3,15 +3,19 @@
import { navigate, getState, setState, escapeHtml, popOutToTab, isInTab } from '../../shared/state';
import type { Item, ItemType } from '../../shared/types';
import {
GLYPH_TYPE_LOGIN, GLYPH_TYPE_SECURE_NOTE, GLYPH_TYPE_TOTP,
GLYPH_TYPE_CARD, GLYPH_TYPE_IDENTITY, GLYPH_TYPE_KEY, GLYPH_TYPE_DOCUMENT,
} from '../../shared/glyphs';
const TYPE_OPTIONS: Array<{ type: ItemType; icon: string; label: string }> = [
{ type: 'login', icon: '🔑', label: 'login' },
{ type: 'secure_note', icon: '📝', label: 'secure note' },
{ type: 'identity', icon: '👤', label: 'identity' },
{ type: 'card', icon: '💳', label: 'card' },
{ type: 'key', icon: '🔐', label: 'key' },
{ type: 'document', icon: '📄', label: 'document' },
{ type: 'totp', icon: '⏱️', label: 'totp' },
const TYPE_OPTIONS: Array<{ type: ItemType; icon: string; label: string; description: string }> = [
{ type: 'login', icon: GLYPH_TYPE_LOGIN, label: 'Login', description: 'Username + password' },
{ type: 'secure_note', icon: GLYPH_TYPE_SECURE_NOTE, label: 'Secure Note', description: 'Encrypted text note' },
{ type: 'identity', icon: GLYPH_TYPE_IDENTITY, label: 'Identity', description: 'Personal details' },
{ type: 'card', icon: GLYPH_TYPE_CARD, label: 'Card', description: 'Credit / debit card' },
{ type: 'key', icon: GLYPH_TYPE_KEY, label: 'SSH / API Key', description: 'Keys and tokens' },
{ type: 'document', icon: GLYPH_TYPE_DOCUMENT, label: 'Document', description: 'File attachment' },
{ type: 'totp', icon: GLYPH_TYPE_TOTP, label: 'TOTP', description: '2FA authenticator' },
];
import * as login from './types/login';
import * as secureNote from './types/secure-note';
@@ -54,36 +58,36 @@ export function renderItemForm(app: HTMLElement, mode: 'add' | 'edit'): void {
function renderTypeSelection(app: HTMLElement): void {
app.innerHTML = `
<div class="pad">
<div style="display:flex; align-items:center; gap:12px;">
<button class="btn" id="back-btn"> back</button>
<h3 style="margin:0;">new item</h3>
<div style="display:flex; align-items:center; gap:12px; margin-bottom:12px;">
<button class="btn" id="back-btn"> back</button>
<span style="font-size:14px; font-weight:600;">New item</span>
<span style="flex:1;"></span>
${isInTab() ? '' : '<button class="btn" id="popout-btn" title="Open in tab"></button>'}
${isInTab() ? '' : '<button class="btn" id="popout-btn" title="Open in tab"></button>'}
</div>
${isInTab() ? '<div class="form-subtitle">esc to cancel</div>' : '<div style="margin-bottom:16px;"></div>'}
<div class="type-select-list">
<div class="type-card-grid">
${TYPE_OPTIONS.map((opt) => `
<button class="type-select-row" data-type="${opt.type}">
<span class="type-select-icon">${opt.icon}</span>
<span>${escapeHtml(opt.label)}</span>
<button class="type-card" data-type="${opt.type}">
<span class="type-card__icon" aria-hidden="true">${opt.icon}</span>
<span class="type-card__label">${escapeHtml(opt.label)}</span>
<span class="type-card__desc">${escapeHtml(opt.description)}</span>
</button>
`).join('')}
</div>
<div class="keyhints"><span><kbd>Esc</kbd> back</span></div>
</div>
`;
document.getElementById('back-btn')?.addEventListener('click', () => navigate('list'));
document.getElementById('popout-btn')?.addEventListener('click', popOutToTab);
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') navigate('list');
}, { once: true });
document.querySelectorAll<HTMLButtonElement>('[data-type]').forEach((btn) => {
btn.addEventListener('click', () => {
const type = btn.dataset.type as ItemType;
setState({ newType: type });
if (type === 'login' || type === 'secure_note') {
renderItemForm(app, 'add');
} else {
popOutToTab();
}
renderItemForm(app, 'add');
});
});
}

View File

@@ -3,6 +3,13 @@
/// to the detail view.
import { getState, setState, sendMessage, navigate, escapeHtml, openVaultTab } from '../../shared/state';
import { showToast } from '../../shared/toast';
import {
GLYPH_VAULT_TAB,
GLYPH_DEVICES, GLYPH_LOCK,
GLYPH_TYPE_LOGIN, GLYPH_TYPE_SECURE_NOTE, GLYPH_TYPE_TOTP,
GLYPH_TYPE_CARD, GLYPH_TYPE_IDENTITY, GLYPH_TYPE_KEY, GLYPH_TYPE_DOCUMENT,
} from '../../shared/glyphs';
import type { ItemId, ItemType, ManifestEntry, Item } from '../../shared/types';
/// Extract the display hostname from an icon_hint or fallback to the first tag.
@@ -12,30 +19,46 @@ function metaLine(e: ManifestEntry): string {
return '';
}
/// Emoji icon per item type. Placeholder until we ship real SVG icons.
/// Glyph icon per item type.
function typeIcon(t: ItemType): string {
switch (t) {
case 'login': return '🔑';
case 'secure_note': return '📝';
case 'identity': return '🪪';
case 'card': return '💳';
case 'key': return '🗝';
case 'document': return '📄';
case 'totp': return '⏱';
case 'login': return GLYPH_TYPE_LOGIN;
case 'secure_note': return GLYPH_TYPE_SECURE_NOTE;
case 'identity': return GLYPH_TYPE_IDENTITY;
case 'card': return GLYPH_TYPE_CARD;
case 'key': return GLYPH_TYPE_KEY;
case 'document': return GLYPH_TYPE_DOCUMENT;
case 'totp': return GLYPH_TYPE_TOTP;
}
}
function buildRowsHtml(): string {
const state = getState();
const filtered = getFilteredEntries();
return filtered.length > 0
? filtered.map(([id, e], i) => `
if (filtered.length > 0) {
return filtered.map(([id, e], i) => `
<div class="entry-row ${i === state.selectedIndex ? 'selected' : ''}" data-id="${escapeHtml(id)}" data-index="${i}">
<span class="entry-name"><span class="type-icon" aria-hidden="true">${typeIcon(e.type)}</span> ${escapeHtml(e.title)}${e.attachment_summaries.length > 0 ? ' <span class="entry-row__attach-indicator" title="has attachments">📎</span>' : ''}</span>
<span class="entry-name"><span class="type-icon" aria-hidden="true">${typeIcon(e.type)}</span> ${escapeHtml(e.title)}${e.attachment_summaries.length > 0 ? ' <span class="entry-row__attach-indicator" title="has attachments"></span>' : ''}</span>
<span class="entry-meta">${escapeHtml(metaLine(e))}</span>
</div>
`).join('')
: '<div class="empty">no items</div>';
`).join('');
}
if (state.searchQuery) {
return `
<div class="empty-state">
<span class="empty-state__icon" aria-hidden="true">⊘</span>
<div class="empty-state__title">No results for "${escapeHtml(state.searchQuery)}"</div>
<div class="empty-state__hint">Try a shorter search term.</div>
</div>
`;
}
return `
<div class="empty-state">
<span class="empty-state__icon" aria-hidden="true">◈</span>
<div class="empty-state__title">No items yet</div>
<div class="empty-state__hint">Press <kbd>+</kbd> to add your first item.</div>
</div>
`;
}
function updateItemList(): void {
@@ -66,7 +89,7 @@ export function renderItemList(app: HTMLElement): void {
<button class="btn" id="new-btn" style="font-size:11px;">+ new</button>
<button class="btn" id="sync-btn" style="font-size:11px;">sync</button>
<span style="flex:1;"></span>
<button class="btn" id="vault-btn" style="font-size:11px;" title="Open vault (Shift+F)">&#x2934;</button>
<button class="btn" id="vault-btn" style="font-size:11px;" title="Open vault (Shift+F)">${GLYPH_VAULT_TAB}</button>
<button class="btn" id="settings-btn" style="font-size:11px;">settings</button>
<button class="btn" id="lock-btn" style="font-size:11px;">lock</button>
</div>
@@ -108,11 +131,14 @@ export function renderItemList(app: HTMLElement): void {
if (listResp.ok) {
const data = listResp.data as { items: Array<[ItemId, ManifestEntry]> };
setState({ entries: data.items, loading: false });
showToast('Synced', 'success');
return;
}
setState({ loading: false, error: listResp.error });
showToast(listResp.error ?? 'Sync failed', 'error');
} else {
setState({ loading: false, error: resp.error });
showToast(resp.error ?? 'Sync failed', 'error');
}
});
@@ -253,8 +279,8 @@ function handleListKeydown(e: KeyboardEvent): void {
// ----------------------------------------------------------------------
const SETTINGS_OPTIONS: Array<{ view: 'settings' | 'settings-vault'; icon: string; label: string }> = [
{ view: 'settings', icon: '🖥', label: 'device settings' },
{ view: 'settings-vault', icon: '🔐', label: 'vault settings' },
{ view: 'settings', icon: GLYPH_DEVICES, label: 'device settings' },
{ view: 'settings-vault', icon: GLYPH_LOCK, label: 'vault settings' },
];
function showSettingsPicker(anchor: HTMLElement): void {

View 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();
}

View File

@@ -1,18 +1,111 @@
/// Settings view — capture toggle, prompt style, and blacklist management.
import { sendMessage, navigate, escapeHtml } from '../../shared/state';
import type { DeviceSettings } from '../../shared/types';
import { GLYPH_TRASH, GLYPH_DEVICES } from '../../shared/glyphs';
import { sendMessage, escapeHtml, openVaultTab } from '../../shared/state';
import type { VaultSettings, DeviceSettings, TrashRetention, HistoryRetention } from '../../shared/types';
import type { ColorScheme } from '../../shared/color-scheme';
import {
loadColorScheme, saveColorScheme, resetColorScheme,
DEFAULT_DIGIT_COLOR, DEFAULT_SYMBOL_COLOR,
} from '../../shared/color-scheme';
import { colorizePassword } from '../../shared/password-coloring';
import { openGeneratorPanel, closeGeneratorPanel, isGeneratorPanelOpen } from './generator-panel';
import { renderSecuritySection, teardownSecuritySection } from './settings-security';
export async function renderSettings(app: HTMLElement): Promise<void> {
app.innerHTML = '<div class="pad" style="text-align:center; padding-top:20px;"><span class="spinner"></span></div>';
type SettingsSection =
| 'autofill'
| 'display'
| 'security'
| 'generator'
| 'retention'
| 'backup'
| 'import';
// Load settings and blacklist in parallel
const NAV_ITEMS: Array<{ id: SettingsSection; icon: string; label: string; group: 'device' | 'vault' }> = [
{ id: 'autofill', icon: '⊙', label: 'Autofill', group: 'device' },
{ id: 'display', icon: '◈', label: 'Display', group: 'device' },
{ id: 'security', icon: '◉', label: 'Security', group: 'vault' },
{ id: 'generator', icon: '↻', label: 'Generator', group: 'vault' },
{ id: 'retention', icon: '▦', label: 'Retention', group: 'vault' },
{ id: 'backup', icon: '⤓', label: 'Backup', group: 'vault' },
{ id: 'import', icon: '≡', label: 'Import', group: 'vault' },
];
let activeSection: SettingsSection = 'autofill';
let activeKeyHandler: ((e: KeyboardEvent) => void) | null = null;
let pendingVaultSettings: VaultSettings | null = null;
let sessionHandle: number | null = null;
export async function renderSettings(container: HTMLElement): Promise<void> {
container.innerHTML = `
<div class="settings-layout">
<nav class="settings-nav" id="settings-nav">
<div class="settings-nav__group-label">Device</div>
${NAV_ITEMS.filter(n => n.group === 'device').map(navItemHtml).join('')}
<div class="settings-nav__group-label">Vault</div>
${NAV_ITEMS.filter(n => n.group === 'vault').map(navItemHtml).join('')}
</nav>
<div class="settings-content" id="settings-content"></div>
</div>
`;
const unlockedResp = await sendMessage({ type: 'is_unlocked' });
sessionHandle = (unlockedResp.ok && unlockedResp.data && (unlockedResp.data as { unlocked: boolean }).unlocked) ? 1 : null;
wireNav();
await renderSection(activeSection);
}
export function teardownSettings(): void {
closeGeneratorPanel();
teardownSecuritySection();
if (activeKeyHandler) {
document.removeEventListener('keydown', activeKeyHandler);
activeKeyHandler = null;
}
pendingVaultSettings = null;
sessionHandle = null;
}
function navItemHtml(item: (typeof NAV_ITEMS)[0]): string {
const active = item.id === activeSection ? ' settings-nav__item--active' : '';
return `
<button class="settings-nav__item${active}" data-section="${item.id}">
<span class="settings-nav__icon" aria-hidden="true">${item.icon}</span>
<span class="settings-nav__label">${escapeHtml(item.label)}</span>
</button>
`;
}
function wireNav(): void {
document.getElementById('settings-nav')?.querySelectorAll<HTMLButtonElement>('[data-section]')
.forEach((btn) => {
btn.addEventListener('click', async () => {
teardownSecuritySection();
closeGeneratorPanel();
activeSection = btn.dataset.section as SettingsSection;
document.querySelectorAll('.settings-nav__item').forEach(b => b.classList.remove('settings-nav__item--active'));
btn.classList.add('settings-nav__item--active');
await renderSection(activeSection);
});
});
}
async function renderSection(section: SettingsSection): Promise<void> {
const content = document.getElementById('settings-content');
if (!content) return;
switch (section) {
case 'autofill': return renderAutofillSection(content);
case 'display': return renderDisplaySection(content);
case 'security': return renderSecuritySection(content, sessionHandle);
case 'generator': return renderGeneratorSection(content);
case 'retention': return renderRetentionSection(content);
case 'backup': return renderBackupSection(content);
case 'import': return renderImportSection(content);
}
}
// --- Section stubs (filled in by Tasks 3-9) ---
async function renderAutofillSection(content: HTMLElement): Promise<void> {
const [settingsResp, blacklistResp] = await Promise.all([
sendMessage({ type: 'get_settings' }),
sendMessage({ type: 'get_blacklist' }),
@@ -26,166 +119,314 @@ export async function renderSettings(app: HTMLElement): Promise<void> {
? (blacklistResp.data as { blacklist: string[] }).blacklist
: [];
const blacklistHtml = blacklist.length > 0
? blacklist.map((h) => `
<div style="display:flex; align-items:center; justify-content:space-between; padding:4px 0; border-bottom:1px solid #21262d;">
<span style="font-size:12px; overflow:hidden; text-overflow:ellipsis;">${escapeHtml(h)}</span>
<button class="relicario-remove-bl" data-hostname="${escapeHtml(h)}" style="
background:transparent; color:#ab2b20; border:none; cursor:pointer;
font-size:11px; padding:2px 6px;
">remove</button>
</div>
`).join('')
: '<p class="muted" style="font-size:12px;">no blacklisted sites</p>';
app.innerHTML = `
<div class="pad" style="padding-top:12px;">
<div style="display:flex; align-items:center; margin-bottom:16px;">
<button id="settings-back" class="btn" style="font-size:11px; margin-right:8px;">&larr;</button>
<span style="font-size:14px; font-weight:600;">settings</span>
content.innerHTML = `
<h3 class="settings-section-title">Capture</h3>
<div class="setting-row">
<div class="setting-row__info">
<div class="setting-row__title">Auto-detect logins</div>
<div class="setting-row__desc">Show a prompt when a login form is detected.</div>
</div>
<div style="margin-bottom:16px;">
<label style="display:flex; align-items:center; gap:8px; cursor:pointer; font-size:13px;">
<input type="checkbox" id="capture-enabled" ${settings.captureEnabled ? 'checked' : ''}>
auto-detect logins
</label>
<div class="setting-row__control">
<input type="checkbox" id="capture-enabled" ${settings.captureEnabled ? 'checked' : ''}>
</div>
<div style="margin-bottom:16px;">
<div style="font-size:12px; color:#8b949e; margin-bottom:6px;">prompt style</div>
<div style="display:flex; gap:8px;">
<button id="style-bar" class="btn" style="font-size:11px; ${settings.captureStyle === 'bar' ? 'background:#7c5719; color:#fff;' : ''}">bar</button>
<button id="style-toast" class="btn" style="font-size:11px; ${settings.captureStyle === 'toast' ? 'background:#7c5719; color:#fff;' : ''}">toast</button>
</div>
</div>
<div class="setting-row">
<div class="setting-row__info">
<div class="setting-row__title">Prompt style</div>
<div class="setting-row__desc">How to prompt when a login is detected.</div>
</div>
<div style="margin-bottom:16px;">
<button class="btn" id="trash-btn" style="width:100%;margin-bottom:8px;">${GLYPH_TRASH} trash</button>
<button class="btn" id="devices-btn" style="width:100%;margin-bottom:8px;">${GLYPH_DEVICES} devices</button>
<button class="btn" id="sync-now-btn" style="width:100%;margin-bottom:8px;">📤 Sync now</button>
<div id="sync-status" class="muted" style="font-size:12px;min-height:16px;"></div>
<div class="setting-row__control" style="display:flex; gap:6px;">
<button class="btn ${settings.captureStyle === 'bar' ? 'btn-active' : ''}" id="style-bar" style="font-size:11px;">bar</button>
<button class="btn ${settings.captureStyle === 'toast' ? 'btn-active' : ''}" id="style-toast" style="font-size:11px;">toast</button>
</div>
</div>
<div style="margin-bottom:16px;" id="display-section-container">
</div>
<div>
<div style="font-size:12px; color:#8b949e; margin-bottom:6px;">blacklisted sites</div>
<div id="blacklist-container">
${blacklistHtml}
</div>
</div>
<h3 class="settings-section-title" style="margin-top:20px;">Blocked sites</h3>
<div id="blacklist-container">
${blacklist.length > 0
? blacklist.map((h) => `
<div class="setting-row">
<div class="setting-row__info">
<div class="setting-row__title">${escapeHtml(h)}</div>
</div>
<button class="btn remove-bl" data-hostname="${escapeHtml(h)}" style="font-size:11px;">remove</button>
</div>
`).join('')
: '<p class="muted" style="font-size:12px; padding:8px 0;">No blocked sites.</p>'}
</div>
`;
// Back button
document.getElementById('settings-back')?.addEventListener('click', () => {
navigate('locked');
});
// Navigation buttons
document.getElementById('trash-btn')?.addEventListener('click', () => navigate('trash'));
document.getElementById('devices-btn')?.addEventListener('click', () => navigate('devices'));
// Sync now button
document.getElementById('sync-now-btn')?.addEventListener('click', async () => {
const btn = document.getElementById('sync-now-btn') as HTMLButtonElement | null;
const status = document.getElementById('sync-status');
if (!btn || !status) return;
btn.disabled = true;
status.textContent = 'syncing...';
const result = await sendMessage({ type: 'sync' });
btn.disabled = false;
status.textContent = result.ok ? 'synced ✓' : `sync failed: ${result.error}`;
});
// Capture enabled toggle
document.getElementById('capture-enabled')?.addEventListener('change', async (e) => {
const checked = (e.target as HTMLInputElement).checked;
await sendMessage({ type: 'update_settings', settings: { captureEnabled: checked } });
const enabled = (e.target as HTMLInputElement).checked;
await sendMessage({ type: 'update_settings', settings: { captureEnabled: enabled } });
});
// Style buttons
document.getElementById('style-bar')?.addEventListener('click', async () => {
await sendMessage({ type: 'update_settings', settings: { captureStyle: 'bar' } });
renderSettings(app);
renderAutofillSection(content);
});
document.getElementById('style-toast')?.addEventListener('click', async () => {
await sendMessage({ type: 'update_settings', settings: { captureStyle: 'toast' } });
renderSettings(app);
renderAutofillSection(content);
});
// Blacklist remove buttons
document.querySelectorAll('.relicario-remove-bl').forEach((btn) => {
content.querySelectorAll<HTMLButtonElement>('.remove-bl').forEach((btn) => {
btn.addEventListener('click', async () => {
const hostname = (btn as HTMLElement).dataset.hostname;
if (hostname) {
await sendMessage({ type: 'remove_blacklist', hostname });
renderSettings(app);
}
const host = btn.dataset.hostname;
if (!host) return;
await sendMessage({ type: 'remove_blacklist', hostname: host });
renderAutofillSection(content);
});
});
// Render Display section after the rest of the DOM is ready
await renderDisplaySection();
}
function updateSwatch(swatch: HTMLElement, digitColor: string, symbolColor: string): void {
swatch.style.setProperty('--relicario-pwd-digit-color', digitColor);
swatch.style.setProperty('--relicario-pwd-symbol-color', symbolColor);
swatch.innerHTML = '';
swatch.appendChild(colorizePassword('Abc123!@#xyz'));
}
async function renderDisplaySection(): Promise<void> {
// The Display section container must be present in the DOM before we call this
const container = document.getElementById('display-section-container');
if (!container) return;
async function renderDisplaySection(content: HTMLElement): Promise<void> {
const scheme = await loadColorScheme();
container.innerHTML = `
<div style="font-size:12px; color:#8b949e; margin-bottom:6px;">display</div>
<div style="margin-bottom:8px;">
<label style="display:flex; align-items:center; gap:8px; font-size:13px;">
<input type="color" id="display-digit-color" value="${escapeHtml(scheme.digit_color)}">
digit color
</label>
content.innerHTML = `
<h3 class="settings-section-title">Password coloring</h3>
<div class="setting-row">
<div class="setting-row__info">
<div class="setting-row__title">Digit color</div>
</div>
<div class="setting-row__control">
<input type="color" id="digit-color" value="${escapeHtml(scheme.digit_color)}">
</div>
</div>
<div style="margin-bottom:8px;">
<label style="display:flex; align-items:center; gap:8px; font-size:13px;">
<input type="color" id="display-symbol-color" value="${escapeHtml(scheme.symbol_color)}">
symbol color
</label>
<div class="setting-row">
<div class="setting-row__info">
<div class="setting-row__title">Symbol color</div>
</div>
<div class="setting-row__control">
<input type="color" id="symbol-color" value="${escapeHtml(scheme.symbol_color)}">
</div>
</div>
<div id="display-swatch" class="color-preview-swatch"></div>
<div style="margin-top:8px;">
<button id="display-reset" class="btn" style="font-size:11px;">reset to defaults</button>
<div class="setting-row">
<div class="setting-row__info">
<div class="setting-row__title">Preview</div>
</div>
<div class="setting-row__control">
<span id="color-preview" style="font-family:monospace; font-size:13px;"></span>
</div>
</div>
<div style="margin-top:12px;">
<button class="btn" id="reset-colors" style="font-size:11px;">Reset defaults</button>
</div>
`;
const digitInput = document.getElementById('display-digit-color') as HTMLInputElement;
const symbolInput = document.getElementById('display-symbol-color') as HTMLInputElement;
const swatch = document.getElementById('display-swatch') as HTMLElement;
// Render initial swatch
updateSwatch(swatch, scheme.digit_color, scheme.symbol_color);
async function onColorChange(): Promise<void> {
const newScheme = { digit_color: digitInput.value, symbol_color: symbolInput.value };
await saveColorScheme(newScheme);
updateSwatch(swatch, newScheme.digit_color, newScheme.symbol_color);
function refreshPreview(s: ColorScheme): void {
const preview = document.getElementById('color-preview');
if (!preview) return;
preview.style.setProperty('--relicario-pwd-digit-color', s.digit_color);
preview.style.setProperty('--relicario-pwd-symbol-color', s.symbol_color);
preview.innerHTML = '';
preview.appendChild(colorizePassword('Abc123!@#'));
}
digitInput.addEventListener('change', () => void onColorChange());
symbolInput.addEventListener('change', () => void onColorChange());
refreshPreview(scheme);
document.getElementById('display-reset')?.addEventListener('click', async () => {
document.getElementById('digit-color')?.addEventListener('change', async (e) => {
const color = (e.target as HTMLInputElement).value;
const current = await loadColorScheme();
await saveColorScheme({ ...current, digit_color: color });
refreshPreview({ ...current, digit_color: color });
});
document.getElementById('symbol-color')?.addEventListener('change', async (e) => {
const color = (e.target as HTMLInputElement).value;
const current = await loadColorScheme();
await saveColorScheme({ ...current, symbol_color: color });
refreshPreview({ ...current, symbol_color: color });
});
document.getElementById('reset-colors')?.addEventListener('click', async () => {
await resetColorScheme();
digitInput.value = DEFAULT_DIGIT_COLOR;
symbolInput.value = DEFAULT_SYMBOL_COLOR;
updateSwatch(swatch, DEFAULT_DIGIT_COLOR, DEFAULT_SYMBOL_COLOR);
renderDisplaySection(content);
});
}
async function renderGeneratorSection(content: HTMLElement): Promise<void> {
content.innerHTML = '<p class="muted" style="padding:20px;font-size:12px;">Loading…</p>';
const resp = await sendMessage({ type: 'get_vault_settings' });
if (!resp.ok) {
const errorMsg = (resp as { ok: false; error: string }).error;
content.innerHTML = `<p class="muted" style="padding:20px;">Failed to load: ${escapeHtml(errorMsg)}</p>`;
return;
}
const settings = (resp.data as { settings: VaultSettings }).settings;
content.innerHTML = `
<h3 class="settings-section-title">Generator defaults</h3>
<div class="setting-row">
<div class="setting-row__info">
<div class="setting-row__title">Configure generator</div>
<div class="setting-row__desc">Password length, character classes, passphrase word count.</div>
</div>
<div class="setting-row__control">
<button class="btn" id="open-generator-panel" style="font-size:11px;">Configure ▸</button>
</div>
</div>
`;
document.getElementById('open-generator-panel')?.addEventListener('click', (e) => {
const trigger = e.currentTarget as HTMLElement;
if (isGeneratorPanelOpen()) {
closeGeneratorPanel();
return;
}
openGeneratorPanel({
parent: content,
trigger,
initial: settings.generator_defaults,
context: 'configure-defaults',
});
});
}
async function renderRetentionSection(content: HTMLElement): Promise<void> {
content.innerHTML = '<p class="muted" style="padding:20px;font-size:12px;">Loading…</p>';
const resp = await sendMessage({ type: 'get_vault_settings' });
if (!resp.ok) {
content.innerHTML = `<p class="muted" style="padding:20px;">Failed to load: ${escapeHtml(resp.error ?? 'unknown')}</p>`;
return;
}
const settings = (resp.data as { settings: VaultSettings }).settings;
pendingVaultSettings = { ...settings };
content.innerHTML = `
<h3 class="settings-section-title">Trash retention</h3>
<div class="setting-row">
<div class="setting-row__info">
<div class="setting-row__title">Keep deleted items for</div>
<div class="setting-row__desc">Items in trash older than this are permanently deleted on the next sync.</div>
</div>
<div class="setting-row__control">
<select id="trash-retention" style="font-size:12px;">
<option value="days:7">7 days</option>
<option value="days:30">30 days</option>
<option value="days:90">90 days</option>
<option value="forever">Forever</option>
</select>
</div>
</div>
<h3 class="settings-section-title" style="margin-top:20px;">Field history retention</h3>
<div class="setting-row">
<div class="setting-row__info">
<div class="setting-row__title">Keep password history for</div>
<div class="setting-row__desc">History entries older than this are pruned on save.</div>
</div>
<div class="setting-row__control">
<select id="history-retention" style="font-size:12px;">
<option value="last_n:5">Last 5</option>
<option value="last_n:10">Last 10</option>
<option value="days:90">90 days</option>
<option value="days:365">1 year</option>
<option value="forever">Forever</option>
</select>
</div>
</div>
<div style="margin-top:12px;">
<button class="btn btn-primary" id="save-retention" style="font-size:11px;">Save retention settings</button>
</div>
`;
// Set current select values
(document.getElementById('trash-retention') as HTMLSelectElement).value =
trashRetentionToValue(settings.trash_retention);
(document.getElementById('history-retention') as HTMLSelectElement).value =
historyRetentionToValue(settings.field_history_retention);
document.getElementById('trash-retention')?.addEventListener('change', (e) => {
if (pendingVaultSettings) {
pendingVaultSettings.trash_retention = valueToTrashRetention((e.target as HTMLSelectElement).value);
}
});
document.getElementById('history-retention')?.addEventListener('change', (e) => {
if (pendingVaultSettings) {
pendingVaultSettings.field_history_retention = valueToHistoryRetention((e.target as HTMLSelectElement).value);
}
});
document.getElementById('save-retention')?.addEventListener('click', async () => {
if (!pendingVaultSettings) return;
const r = await sendMessage({ type: 'update_vault_settings', settings: pendingVaultSettings });
if (!r.ok) alert(`Save failed: ${r.error}`);
});
}
function trashRetentionToValue(r: TrashRetention): string {
if (r.kind === 'forever') return 'forever';
return `days:${r.value}`;
}
function valueToTrashRetention(v: string): TrashRetention {
if (v === 'forever') return { kind: 'forever' };
const m = /^days:(\d+)$/.exec(v);
if (m) return { kind: 'days', value: Number(m[1]) };
return { kind: 'forever' };
}
function historyRetentionToValue(r: HistoryRetention): string {
if (r.kind === 'forever') return 'forever';
if (r.kind === 'last_n') return `last_n:${r.value}`;
return `days:${r.value}`;
}
function valueToHistoryRetention(v: string): HistoryRetention {
if (v === 'forever') return { kind: 'forever' };
const mLast = /^last_n:(\d+)$/.exec(v);
if (mLast) return { kind: 'last_n', value: Number(mLast[1]) };
const mDays = /^days:(\d+)$/.exec(v);
if (mDays) return { kind: 'days', value: Number(mDays[1]) };
return { kind: 'forever' };
}
function renderBackupSection(content: HTMLElement): void {
content.innerHTML = `
<h3 class="settings-section-title">Backup &amp; restore</h3>
<div class="setting-row">
<div class="setting-row__info">
<div class="setting-row__title">Export &amp; restore backup</div>
<div class="setting-row__desc">Download an encrypted backup or restore from a file. Opens in the vault tab.</div>
</div>
<div class="setting-row__control">
<button class="btn" id="open-backup-tab" style="font-size:11px;">Open backup ▸</button>
</div>
</div>
`;
document.getElementById('open-backup-tab')?.addEventListener('click', () => openVaultTab('backup'));
}
function renderImportSection(content: HTMLElement): void {
content.innerHTML = `
<h3 class="settings-section-title">Import</h3>
<div class="setting-row">
<div class="setting-row__info">
<div class="setting-row__title">Import from LastPass</div>
<div class="setting-row__desc">Import a LastPass CSV export. Opens in the vault tab for review before committing.</div>
</div>
<div class="setting-row__control">
<button class="btn" id="open-import-tab" style="font-size:11px;">Open import ▸</button>
</div>
</div>
`;
document.getElementById('open-import-tab')?.addEventListener('click', () => openVaultTab('import'));
}
export { renderAutofillSection, renderDisplaySection, renderGeneratorSection,
renderRetentionSection, renderBackupSection, renderImportSection };
// Suppress unused-import warnings — these are used by Tasks 3-9
void sendMessage;
void loadColorScheme;
void saveColorScheme;
void resetColorScheme;
void DEFAULT_DIGIT_COLOR;
void DEFAULT_SYMBOL_COLOR;
void colorizePassword;
void openGeneratorPanel;
void pendingVaultSettings;
void activeKeyHandler;

View File

@@ -2,10 +2,19 @@
import { getState, setState, sendMessage, navigate, escapeHtml } from '../../shared/state';
import type { ItemId, ManifestEntry, VaultSettings } from '../../shared/types';
import {
GLYPH_TYPE_LOGIN, GLYPH_TYPE_SECURE_NOTE, GLYPH_TYPE_IDENTITY, GLYPH_TYPE_CARD,
GLYPH_TYPE_KEY, GLYPH_TYPE_DOCUMENT, GLYPH_TYPE_TOTP,
} from '../../shared/glyphs';
const TYPE_ICONS: Record<string, string> = {
login: '🔑', secure_note: '📝', identity: '👤', card: '💳',
key: '🔐', document: '📄', totp: '⏱️',
login: GLYPH_TYPE_LOGIN,
secure_note: GLYPH_TYPE_SECURE_NOTE,
identity: GLYPH_TYPE_IDENTITY,
card: GLYPH_TYPE_CARD,
key: GLYPH_TYPE_KEY,
document: GLYPH_TYPE_DOCUMENT,
totp: GLYPH_TYPE_TOTP,
};
function relativeTime(unixSec: number): string {
@@ -64,7 +73,7 @@ export async function renderTrash(app: HTMLElement): Promise<void> {
? `<p class="muted" style="text-align:center;margin-top:32px;">Trash is empty</p>`
: items.map(([id, entry]) => `
<div class="trash-row" data-id="${escapeHtml(id)}">
<span class="trash-row__icon">${TYPE_ICONS[entry.type] ?? '📦'}</span>
<span class="trash-row__icon">${TYPE_ICONS[entry.type] ?? ''}</span>
<div class="trash-row__info">
<span class="trash-row__title">${escapeHtml(entry.title)}</span>
<span class="trash-row__meta">trashed ${relativeTime(entry.trashed_at ?? 0)}</span>

View File

@@ -4,7 +4,7 @@
import { getState, setState, sendMessage, navigate, escapeHtml, popOutToTab } from '../../../shared/state';
import { renderFormHeader } from '../form-header';
import { REQUIRED_PILL_HTML } from '../../../shared/glyphs';
import { REQUIRED_PILL_HTML, GLYPH_TYPE_DOCUMENT, GLYPH_PREVIEW } from '../../../shared/glyphs';
import type { Item, ItemId, ManifestEntry, Section, AttachmentRef } from '../../../shared/types';
import {
renderSectionsEditor, wireSectionsEditor,
@@ -76,7 +76,7 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite
}
return `
<div class="document-primary-row" id="primary-picker">
<span class="document-primary-row__thumb">📄</span>
<span class="document-primary-row__thumb">${GLYPH_TYPE_DOCUMENT}</span>
<span class="document-primary-row__name">${escapeHtml(primaryRef.filename)}</span>
<span class="document-primary-row__meta">${formatBytes(primaryRef.size)}</span>
<span class="document-primary-row__action">↑ change</span>
@@ -283,13 +283,13 @@ export async function renderDetail(app: HTMLElement, item: Item): Promise<void>
<div class="detail-title" style="margin-bottom:12px;">${escapeHtml(item.title)}</div>
<div class="document-signature-block" id="doc-sigblock">
<div class="document-signature-block__thumb" data-att-id="${escapeHtml(primaryRef.id)}" data-mime="${escapeHtml(primaryRef.mime_type)}">📄</div>
<div class="document-signature-block__thumb" data-att-id="${escapeHtml(primaryRef.id)}" data-mime="${escapeHtml(primaryRef.mime_type)}">${GLYPH_TYPE_DOCUMENT}</div>
<div class="document-signature-block__info">
<div class="document-signature-block__name">${escapeHtml(primaryRef.filename)}</div>
<div class="document-signature-block__meta">${formatBytes(primaryRef.size)} · ${new Date(primaryRef.created * 1000).toISOString().slice(0, 10)}</div>
<div class="document-signature-block__actions">
<span id="doc-download" style="cursor:pointer;color:#d2ab43;">↓ download</span>
${isImageMime ? '<span id="doc-preview" style="cursor:pointer;color:#d2ab43;margin-left:10px;">🔍 preview</span>' : ''}
${isImageMime ? `<span id="doc-preview" style="cursor:pointer;color:#d2ab43;margin-left:10px;">${GLYPH_PREVIEW} preview</span>` : ''}
</div>
</div>
</div>

View File

@@ -11,7 +11,7 @@ import { renderUnlock } from './components/unlock';
import { renderItemList } from './components/item-list';
import { renderItemDetail } from './components/item-detail';
import { renderItemForm } from './components/item-form';
import { renderSettings } from './components/settings';
import { renderSettings, teardownSettings } from './components/settings';
import { renderVaultSettings } from './components/settings-vault';
import { renderTrash } from './components/trash';
import { renderDevices } from './components/devices';
@@ -178,6 +178,7 @@ function render(): void {
teardownTrash();
teardownDevices();
teardownFieldHistory();
teardownSettings();
switch (currentState.view) {
case 'locked':

View File

@@ -424,6 +424,41 @@ textarea {
background: #aa812a;
}
/* Setup wizard — Style C progress track */
.setup-progress-track {
display: flex;
gap: 4px;
width: 100%;
max-width: 560px;
margin: 8px auto 16px;
}
.setup-progress-segment {
flex: 1;
height: 4px;
border-radius: 2px;
}
.setup-progress-segment--completed { background: var(--success, #238636); }
.setup-progress-segment--active { background: var(--gold, #b8860b); }
.setup-progress-segment--pending { background: var(--border, #30363d); }
/* Setup wizard — Recovery QR banner */
.recovery-qr-banner {
padding: 12px 16px;
background: var(--bg-elevated, #161b22);
border: 1px solid var(--gold, #b8860b);
border-radius: 8px;
}
.recovery-qr-banner__header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
.recovery-qr-banner__actions {
display: flex;
gap: 8px;
}
/* Spinner */
.spinner {
display: inline-block;
@@ -1573,3 +1608,183 @@ textarea {
margin-top: 8px;
background: var(--bg-input);
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px 20px;
text-align: center;
}
.empty-state__icon {
font-size: 28px;
color: var(--text-muted, #8b949e);
margin-bottom: 12px;
display: block;
}
.empty-state__title {
font-size: 13px;
font-weight: 600;
margin-bottom: 4px;
}
.empty-state__hint {
font-size: 11px;
color: var(--text-muted, #8b949e);
}
.type-card-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin-bottom: 12px;
}
.type-card {
display: flex;
flex-direction: column;
align-items: flex-start;
padding: 10px 12px;
background: var(--bg-elevated, #161b22);
border: 1px solid var(--border-mid, #30363d);
border-radius: 6px;
cursor: pointer;
text-align: left;
transition: border-color 0.15s;
}
.type-card:hover { border-color: var(--gold-base, #a88a4a); }
.type-card__icon { font-size: 20px; margin-bottom: 4px; }
.type-card__label { font-size: 12px; font-weight: 600; }
/* Toast notifications */
.relicario-toast-container {
position: fixed;
bottom: 16px;
left: 50%;
transform: translateX(-50%);
display: flex;
flex-direction: column;
gap: 6px;
pointer-events: none;
z-index: 9999;
}
.vault-shell .relicario-toast-container {
left: auto;
right: 24px;
transform: none;
}
.relicario-toast {
padding: 8px 16px;
border-radius: 6px;
font-size: 12px;
opacity: 0;
transform: translateY(8px);
transition: opacity 0.2s, transform 0.2s;
pointer-events: none;
}
.relicario-toast--visible {
opacity: 1;
transform: translateY(0);
}
.relicario-toast--success { background: #1f4a24; color: #aff0b5; border: 1px solid #238636; }
.relicario-toast--error { background: #4a1f1f; color: #f0afaf; border: 1px solid #ab2b20; }
.relicario-toast--info { background: #1f2d4a; color: #afc8f0; border: 1px solid #1f6feb; }
.type-card__desc { font-size: 10px; color: var(--text-muted, #8b949e); margin-top: 2px; }
/* === Settings layout === */
.settings-layout {
display: flex;
height: 100%;
overflow: hidden;
}
.settings-nav {
width: 148px;
min-width: 148px;
border-right: 1px solid var(--border, #30363d);
padding: 12px 0;
overflow-y: auto;
flex-shrink: 0;
}
.settings-nav__group-label {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-muted, #8b949e);
padding: 8px 12px 4px;
}
.settings-nav__item {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 7px 12px;
background: transparent;
border: none;
cursor: pointer;
font-size: 13px;
color: inherit;
text-align: left;
}
.settings-nav__item:hover { background: var(--bg-hover, #161b22); }
.settings-nav__item--active { background: var(--bg-selected, #1c2d41); }
.settings-nav__icon { font-size: 14px; flex-shrink: 0; }
.settings-content {
flex: 1;
overflow-y: auto;
padding: 20px 24px;
min-width: 0;
}
.setting-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
padding: 12px 0;
border-bottom: 1px solid var(--border-subtle, #21262d);
}
.setting-row:last-child { border-bottom: none; }
.setting-row__info { flex: 1; }
.setting-row__title { font-size: 13px; font-weight: 500; }
.setting-row__desc { font-size: 11px; color: var(--text-muted, #8b949e); margin-top: 2px; }
.setting-row__control { flex-shrink: 0; }
.settings-section-title {
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-muted, #8b949e);
margin: 0 0 12px;
padding-bottom: 6px;
border-bottom: 1px solid var(--border, #30363d);
}
.setting-card {
padding: 12px 16px;
border-radius: 6px;
border: 1px solid var(--border, #30363d);
margin-bottom: 12px;
}
.setting-card--ok { border-color: var(--success, #238636); background: rgba(35, 134, 54, 0.06); }
.setting-card--warn { border-color: var(--gold, #b8860b); background: rgba(184, 134, 11, 0.06); }
.setting-card__status { font-size: 13px; margin-bottom: 8px; }
.setting-card__actions { display: flex; gap: 8px; }

View File

@@ -574,6 +574,26 @@ export async function handle(
}
}
case 'generate_recovery_qr': {
const handle = session.getCurrent();
if (!handle) return { ok: false, error: 'vault_locked' };
try {
const svg: string = state.wasm.wasm_generate_recovery_qr(handle, msg.passphrase);
return { ok: true, data: { svg } };
} catch (e) {
return { ok: false, error: (e as Error).message };
}
}
case 'unwrap_recovery_qr': {
try {
const imageSecretBytes: Uint8Array = state.wasm.wasm_unwrap_recovery_qr(msg.payload_b64, msg.passphrase);
return { ok: true, data: { image_secret: Array.from(imageSecretBytes) } };
} catch (e) {
return { ok: false, error: (e as Error).message };
}
}
case 'import_lastpass_commit': {
const handle = session.getCurrent();
if (!handle || !state.gitHost || !state.manifest) return { ok: false, error: 'vault_locked' };

View File

@@ -93,6 +93,17 @@ const state: WizardState = {
deviceName: '',
};
// --- Progress track ---
const SETUP_STEP_NAMES = ['mode', 'host', 'connection', 'vault', 'device', 'done'];
function renderProgressTrack(current: number): string {
return `<div class="setup-progress-track">${SETUP_STEP_NAMES.map((_, i) => {
const cls = i < current ? 'completed' : i === current ? 'active' : 'pending';
return `<div class="setup-progress-segment setup-progress-segment--${cls}" title="${SETUP_STEP_NAMES[i]}"></div>`;
}).join('')}</div>`;
}
// --- State-coupled helpers (pure helpers live in ./setup-helpers.ts) ---
/// Update just the meter DOM without a full re-render (so the input keeps
@@ -168,16 +179,7 @@ function render(): void {
const app = document.getElementById('app');
if (!app) return;
const progressHtml = `
<div class="progress-bar">
<div class="step ${state.step > 0 ? 'done' : state.step === 0 ? 'current' : ''}"></div>
<div class="step ${state.step > 1 ? 'done' : state.step === 1 ? 'current' : ''}"></div>
<div class="step ${state.step > 2 ? 'done' : state.step === 2 ? 'current' : ''}"></div>
<div class="step ${state.step > 3 ? 'done' : state.step === 3 ? 'current' : ''}"></div>
<div class="step ${state.step > 4 ? 'done' : state.step === 4 ? 'current' : ''}"></div>
<div class="step ${state.step > 5 ? 'done' : state.step === 5 ? 'current' : ''}"></div>
</div>
`;
const progressHtml = renderProgressTrack(state.step);
let stepHtml = '';
switch (state.step) {
@@ -224,6 +226,7 @@ function renderStep0(): string {
</p>
<div class="mode-cards">
<button class="mode-card glass ${isNew ? 'active' : ''}" data-mode="new">
<span class="mode-card__icon" style="font-size:28px;">◈</span>
<div class="mode-card-title">create new vault</div>
<p class="mode-card-blurb">
I'm setting up Relicario for the first time. This will create a fresh
@@ -231,6 +234,7 @@ function renderStep0(): string {
</p>
</button>
<button class="mode-card glass ${isAttach ? 'active' : ''}" data-mode="attach">
<span class="mode-card__icon" style="font-size:28px;">⌥</span>
<div class="mode-card-title">attach this device</div>
<p class="mode-card-blurb">
I already have a vault on another device. Connect this browser to it
@@ -981,6 +985,22 @@ function renderStep5(): string {
const configJson = JSON.stringify(config, null, 2);
const isAttach = state.mode === 'attach';
const qrBannerHtml = (!isAttach && state.verifiedHandle !== null) ? `
<div class="recovery-qr-banner" id="recovery-qr-banner" style="margin-bottom:16px;">
<div class="recovery-qr-banner__header">
<span style="font-size:20px;">◫</span>
<strong>Generate a recovery QR before you go</strong>
</div>
<p class="muted" style="font-size:12px;margin:4px 0 8px;">
If you lose your reference image, this QR lets you recover your vault. Print it and store it safely.
</p>
<div class="recovery-qr-banner__actions">
<button class="btn btn-primary" id="setup-gen-qr">Generate now</button>
<button class="btn" id="setup-skip-qr">Skip — I'll do this in Settings</button>
</div>
</div>
` : '';
return `
<div class="wizard-step glass" style="padding: 24px; margin-top: 16px;">
<div class="success-box">
@@ -992,6 +1012,8 @@ function renderStep5(): string {
</p>
</div>
${qrBannerHtml}
${isAttach ? '' : `
<div class="form-group">
<label class="label">reference image</label>
@@ -1026,6 +1048,48 @@ function renderStep5(): string {
}
function attachStep5(): void {
document.getElementById('setup-gen-qr')?.addEventListener('click', async () => {
if (!state.verifiedHandle) return;
const btn = document.getElementById('setup-gen-qr') as HTMLButtonElement | null;
if (btn) { btn.disabled = true; btn.textContent = 'Generating…'; }
try {
const { sendMessage } = await import('../shared/state');
const resp = await sendMessage({
type: 'generate_recovery_qr',
sessionHandle: state.verifiedHandle.value,
passphrase: state.passphrase,
} as any) as any;
if (!resp.ok || !resp.data) throw new Error(resp.error ?? 'unknown error');
const svg = (resp.data as { svg: string }).svg;
await new Promise<void>((resolve) => {
chrome.storage.local.set({ recovery_qr_generated_at: Date.now() }, resolve);
});
const banner = document.getElementById('recovery-qr-banner');
if (banner) {
banner.innerHTML = `
<div style="text-align:center;">${svg}</div>
<p style="font-size:12px;color:var(--success,#238636);margin:8px 0 0;">
◉ Recovery QR generated — save or print this now.
</p>
<div style="margin-top:8px;">
<button class="btn" id="setup-qr-done">Done</button>
</div>
`;
document.getElementById('setup-qr-done')?.addEventListener('click', () => {
banner.style.display = 'none';
});
}
} catch (err) {
if (btn) { btn.disabled = false; btn.textContent = 'Generate now'; }
alert(`Failed to generate QR: ${err instanceof Error ? err.message : String(err)}`);
}
});
document.getElementById('setup-skip-qr')?.addEventListener('click', () => {
const banner = document.getElementById('recovery-qr-banner');
if (banner) banner.style.display = 'none';
});
document.getElementById('download-ref-btn')?.addEventListener('click', () => {
if (!state.referenceImageBytes) return;
const blob = new Blob([state.referenceImageBytes.buffer as ArrayBuffer], { type: 'image/jpeg' });

View File

@@ -41,3 +41,30 @@ describe('glyph constants', () => {
expect(GLYPH_NEXT).toBe('▸');
});
});
describe('Stream A glyphs (vault tab + type icons)', () => {
it('exports GLYPH_VAULT_TAB as U+29C9', () => {
expect(glyphs.GLYPH_VAULT_TAB).toBe('⧉');
});
it('exports per-type glyph constants', () => {
expect(glyphs.GLYPH_TYPE_LOGIN).toBe('◉');
expect(glyphs.GLYPH_TYPE_SECURE_NOTE).toBe('◫');
expect(glyphs.GLYPH_TYPE_TOTP).toBe('⊡');
expect(glyphs.GLYPH_TYPE_CARD).toBe('▭');
expect(glyphs.GLYPH_TYPE_IDENTITY).toBe('◍');
expect(glyphs.GLYPH_TYPE_KEY).toBe('⊹');
expect(glyphs.GLYPH_TYPE_DOCUMENT).toBe('≡');
});
it('per-type glyphs are single codepoints (no emoji)', () => {
const typeGlyphs = [
glyphs.GLYPH_TYPE_LOGIN, glyphs.GLYPH_TYPE_SECURE_NOTE, glyphs.GLYPH_TYPE_TOTP,
glyphs.GLYPH_TYPE_CARD, glyphs.GLYPH_TYPE_IDENTITY, glyphs.GLYPH_TYPE_KEY,
glyphs.GLYPH_TYPE_DOCUMENT,
];
for (const g of typeGlyphs) {
expect([...g].length).toBe(1);
}
});
});

View File

@@ -17,6 +17,19 @@ export const GLYPH_DEVICES = '⌬'; // sidebar devices nav
export const GLYPH_SETTINGS = '⚙'; // sidebar settings nav
export const GLYPH_LOCK = '⏻'; // sidebar lock nav
export const GLYPH_NEXT = '▸'; // forward / next button (matches ▾/▸ disclosure family)
export const GLYPH_COPY = '⎘'; // copy to clipboard
export const GLYPH_SYNC = '⇅'; // sync / upload
export const GLYPH_PREVIEW = '⊕'; // preview / expand
export const GLYPH_VAULT_TAB = '⧉'; // U+29C9 pop-out to fullscreen vault tab
export const GLYPH_TYPE_LOGIN = '◉'; // login
export const GLYPH_TYPE_SECURE_NOTE = '◫'; // secure note
export const GLYPH_TYPE_TOTP = '⊡'; // totp / 2FA
export const GLYPH_TYPE_CARD = '▭'; // card
export const GLYPH_TYPE_IDENTITY = '◍'; // identity (distinct from GLYPH_DEVICES ⌬)
export const GLYPH_TYPE_KEY = '⊹'; // SSH / API key
export const GLYPH_TYPE_DOCUMENT = '≡'; // document
/// Inline HTML snippet for the required-field pill. Use after a label's text:
/// `<label class="label" for="f-title">title ${REQUIRED_PILL_HTML}</label>`

View File

@@ -61,7 +61,9 @@ export type PopupMessage =
}
| { type: 'parse_lastpass_csv'; bytes: ArrayBuffer }
| { type: 'import_lastpass_commit'; items: Item[] }
| { type: 'preview_totp_from_secret'; secret_b32: string };
| { type: 'preview_totp_from_secret'; secret_b32: string }
| { type: 'generate_recovery_qr'; passphrase: string }
| { type: 'unwrap_recovery_qr'; payload_b64: string; passphrase: string };
// --- Messages a content script may send ---
@@ -173,6 +175,7 @@ export const POPUP_ONLY_TYPES: ReadonlySet<PopupMessage['type']> = new Set([
'export_backup', 'restore_backup',
'parse_lastpass_csv', 'import_lastpass_commit',
'preview_totp_from_secret',
'generate_recovery_qr', 'unwrap_recovery_qr',
] as PopupMessage['type'][]);
export interface ExportBackupResponse extends Extract<Response, { ok: true }> {

View 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);
}

View File

@@ -438,6 +438,14 @@ textarea {
margin-top: 16px;
}
/* When the sticky save bar (fullscreen) provides external actions,
the form's inner action row sets the [hidden] attribute. The default
user-agent rule for [hidden] is display:none, but our .form-actions
display:flex above wins specificity. Re-assert hidden takes priority. */
.form-actions[hidden] {
display: none !important;
}
.inline-row {
display: flex;
gap: 8px;
@@ -626,16 +634,18 @@ textarea {
.gen-trigger {
background: #7c5719;
color: #fff3cf;
border: none;
border-radius: 4px;
padding: 0 12px;
font-size: 16px;
border: 1px solid #7c5719;
border-radius: 3px;
padding: 0 8px;
font-size: 14px;
cursor: pointer;
line-height: 1;
min-width: 38px;
min-width: 28px;
height: 28px;
display: inline-flex;
align-items: center;
justify-content: center;
vertical-align: middle;
}
.gen-trigger:hover { background: #aa812a; }
.gen-trigger[aria-expanded="true"] { background: #aa812a; }
@@ -1290,6 +1300,13 @@ textarea {
align-items: center;
gap: 8px;
}
.vault-sidebar__header .brand-logo {
width: 36px;
height: 36px;
margin: 0;
display: block;
flex-shrink: 0;
}
.vault-sidebar__search {
padding: 8px 12px;
@@ -1389,6 +1406,392 @@ textarea {
color: #484f58;
}
/* === 3-column shell === */
.vault-shell {
display: flex;
height: 100vh;
overflow: hidden;
background: var(--bg-page, #0d1117);
position: relative;
}
.vault-sidebar {
width: 200px;
min-width: 200px;
display: flex;
flex-direction: column;
border-right: 1px solid var(--border, #30363d);
background: var(--bg-page);
overflow-y: auto;
flex-shrink: 0;
position: relative;
z-index: 5;
}
.vault-list-pane {
flex: 1;
display: flex;
flex-direction: column;
overflow-y: auto;
min-width: 0;
}
/* Pane occupies the same flex slot as list-pane for non-list views */
.vault-pane {
flex: 1;
display: flex;
flex-direction: column;
overflow-y: auto;
min-width: 0;
padding: 24px 32px;
}
.vault-pane--empty {
align-items: center;
justify-content: center;
color: var(--text-dim);
font-size: 14px;
}
/* View-specific visibility: only one of list-pane / vault-pane is visible.
Default to list view if neither class is set. */
.vault-shell .vault-pane { display: none; }
.vault-shell--pane .vault-list-pane { display: none; }
.vault-shell--pane .vault-pane { display: flex; }
.vault-shell--pane .vault-drawer { display: none; }
.vault-drawer {
width: 440px;
min-width: 440px;
max-width: 440px;
border-left: 1px solid var(--border, #30363d);
background: var(--bg-elevated, #161b22);
overflow-y: auto;
transform: translateX(100%);
transition: transform 0.2s ease;
flex-shrink: 0;
}
.vault-drawer--open {
transform: translateX(0);
}
/* Empty state — centered, gold-accented icon, polished */
.empty-state {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
padding: 48px 24px;
text-align: center;
min-height: 240px;
}
.empty-state__icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 72px;
height: 72px;
border-radius: 50%;
background: var(--gold-soft);
border: 1px solid var(--gold-ring);
color: var(--gold-text);
font-size: 30px;
line-height: 1;
margin-bottom: 4px;
}
.empty-state__title {
font-size: 14px;
font-weight: 600;
color: var(--text);
letter-spacing: 0.02em;
}
.empty-state__hint {
font-size: 12px;
color: var(--text-muted);
max-width: 320px;
}
.vault-list-row {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 16px;
cursor: pointer;
border-bottom: 1px solid var(--border-subtle, #21262d);
transition: background 0.1s;
}
.vault-list-row:hover { background: var(--bg-hover, #161b22); }
.vault-list-row--selected {
background: var(--bg-selected, #1c2d41);
border-left: 2px solid var(--gold, #b8860b);
}
.vault-list-row__icon {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-elevated, #161b22);
border-radius: 6px;
border: 1px solid var(--border, #30363d);
font-size: 14px;
flex-shrink: 0;
}
.vault-list-row--selected .vault-list-row__icon { border-color: var(--gold, #b8860b); }
.vault-list-row__text { flex: 1; min-width: 0; }
.vault-list-row__title {
font-size: 13px;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.vault-list-row__subtitle {
font-size: 11px;
color: var(--text-muted, #8b949e);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-top: 1px;
}
.vault-list-row__age {
font-size: 10px;
color: var(--text-dim, #6e7681);
flex-shrink: 0;
}
/* Type picker — secondary panel that slides out from behind the left sidebar */
.vault-type-panel-scrim {
position: absolute;
inset: 0 0 0 200px;
background: rgba(0, 0, 0, 0.45);
opacity: 0;
pointer-events: none;
transition: opacity 0.2s ease;
z-index: 3;
}
.vault-type-panel-scrim--visible {
opacity: 1;
pointer-events: auto;
}
.vault-type-panel {
position: absolute;
top: 0;
bottom: 0;
left: 200px;
width: 280px;
background: var(--bg-elevated);
border-right: 1px solid var(--border-mid);
transform: translateX(-100%);
transition: transform 0.22s ease;
z-index: 4;
overflow-y: auto;
padding: 14px 12px;
box-shadow: 8px 0 24px rgba(0, 0, 0, 0.45);
}
.vault-type-panel--open { transform: translateX(0); }
.vault-type-panel__head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 4px 10px;
margin-bottom: 8px;
border-bottom: 1px solid var(--border-soft);
}
.vault-type-panel__title {
font-size: 12px;
font-weight: 600;
color: var(--gold-text);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.vault-type-panel__close {
background: transparent;
border: none;
color: var(--text-muted);
cursor: pointer;
font-size: 14px;
padding: 2px 6px;
border-radius: 3px;
}
.vault-type-panel__close:hover { color: var(--text); background: var(--bg-pane); }
.vault-type-panel__hint {
font-size: 11px;
color: var(--text-dim);
padding: 0 4px 8px;
}
.vault-type-list {
display: flex;
flex-direction: column;
gap: 2px;
}
.vault-type-item {
display: flex;
align-items: center;
gap: 12px;
padding: 9px 10px;
background: transparent;
border: 1px solid transparent;
border-radius: 5px;
cursor: pointer;
text-align: left;
color: var(--text);
font: inherit;
font-size: 13px;
transition: background 0.1s, border-color 0.1s;
}
.vault-type-item:hover {
background: var(--bg-pane);
border-color: var(--border-mid);
}
.vault-type-item:focus-visible {
outline: none;
border-color: var(--gold-base);
background: var(--gold-soft);
}
.vault-type-item__icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
flex-shrink: 0;
background: var(--gold-soft);
border: 1px solid var(--gold-ring);
border-radius: 5px;
font-size: 15px;
color: var(--gold-text);
}
.vault-type-item__name {
flex: 1;
color: var(--text);
}
/* Sidebar nav button — primary new-item variant */
.vault-sidebar__nav-item--primary {
color: var(--gold-text);
font-weight: 600;
}
.vault-sidebar__nav-item--primary:hover {
background: var(--gold-soft);
color: var(--gold-hi-end);
}
/* Drawer header and body */
.vault-drawer__header {
display: flex;
align-items: center;
padding: 12px 16px;
border-bottom: 1px solid var(--border, #30363d);
gap: 8px;
}
.vault-drawer__type-pill {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 2px 8px;
background: var(--bg-page, #0d1117);
border: 1px solid var(--border, #30363d);
border-radius: 4px;
color: var(--text-muted, #8b949e);
}
.vault-drawer__actions { display: flex; gap: 6px; margin-left: auto; }
.vault-drawer__close {
background: transparent;
border: none;
cursor: pointer;
font-size: 16px;
color: var(--text-muted, #8b949e);
padding: 4px 6px;
}
.vault-drawer__body { padding: 20px 20px 16px; }
.vault-drawer__title { font-size: 18px; font-weight: 700; margin-bottom: 4px; }
.vault-drawer__subtitle { font-size: 12px; color: var(--text-muted, #8b949e); margin-bottom: 16px; }
.vault-drawer__field-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.vault-drawer__field-grid > .vault-drawer__field--full { grid-column: 1 / -1; }
.vault-drawer__field-label {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-muted, #8b949e);
margin-bottom: 2px;
}
.vault-drawer__field-value {
font-size: 13px;
word-break: break-all;
}
/* Category nav */
.vault-category-row {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 6px 12px;
background: transparent;
border: none;
cursor: pointer;
color: inherit;
font-size: 13px;
text-align: left;
}
.vault-category-row:hover { background: var(--bg-hover, #161b22); }
.vault-category-row--active { background: var(--bg-selected, #1c2d41); }
.vault-category-row__icon { font-size: 14px; flex-shrink: 0; }
.vault-category-row__label { flex: 1; }
.vault-category-row__count { font-size: 11px; color: var(--text-muted, #8b949e); }
/* === Responsive === */
@media (max-width: 960px) {
.vault-drawer {
position: absolute;
right: 0;
top: 0;
height: 100%;
}
}
@media (max-width: 720px) {
.vault-sidebar {
width: 48px;
min-width: 48px;
}
.vault-sidebar__category-label,
.vault-sidebar__category-count,
.vault-sidebar__nav-label {
display: none;
}
.vault-sidebar__nav-item { justify-content: center; padding: 10px 0; }
}
/* --- Lock screen (vault tab) --- */
.vault-lock-screen {
@@ -1620,7 +2023,7 @@ textarea {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24px;
max-width: 960px;
max-width: 1100px;
margin: 0 auto;
}
@media (max-width: 720px) {
@@ -1630,7 +2033,7 @@ textarea {
/* P3: lower form sections constrained to the same envelope as .form-grid.
Gated on surface === 'fullscreen' in login.ts; popup unaffected. */
.form-lower {
max-width: 960px;
max-width: 1100px;
margin: 0 auto;
}
.form-lower > .form-group,
@@ -1683,6 +2086,9 @@ textarea {
flex-direction: column;
height: 100%;
overflow: hidden;
width: 100%;
max-width: 1280px;
margin: 0 auto;
}
.form-scroll {
flex: 1;
@@ -1712,3 +2118,41 @@ textarea {
background: linear-gradient(to top, rgba(17, 22, 30, 0.7), transparent);
pointer-events: none;
}
/* Toast notifications */
.relicario-toast-container {
position: fixed;
bottom: 16px;
left: 50%;
transform: translateX(-50%);
display: flex;
flex-direction: column;
gap: 6px;
pointer-events: none;
z-index: 9999;
}
.vault-shell .relicario-toast-container {
left: auto;
right: 24px;
transform: none;
}
.relicario-toast {
padding: 8px 16px;
border-radius: 6px;
font-size: 12px;
opacity: 0;
transform: translateY(8px);
transition: opacity 0.2s, transform 0.2s;
pointer-events: none;
}
.relicario-toast--visible {
opacity: 1;
transform: translateY(0);
}
.relicario-toast--success { background: #1f4a24; color: #aff0b5; border: 1px solid #238636; }
.relicario-toast--error { background: #4a1f1f; color: #f0afaf; border: 1px solid #ab2b20; }
.relicario-toast--info { background: #1f2d4a; color: #afc8f0; border: 1px solid #1f6feb; }

View File

@@ -10,18 +10,36 @@ import type {
} from '../shared/types';
import { registerHost } from '../shared/state';
import { lookupErrorCopy, type ErrorCta } from '../shared/error-copy';
import { GLYPH_TRASH, GLYPH_DEVICES, GLYPH_SETTINGS, GLYPH_LOCK } from '../shared/glyphs';
import {
GLYPH_TRASH, GLYPH_DEVICES, GLYPH_SETTINGS, GLYPH_LOCK,
GLYPH_TYPE_LOGIN, GLYPH_TYPE_SECURE_NOTE, GLYPH_TYPE_TOTP,
GLYPH_TYPE_CARD, GLYPH_TYPE_IDENTITY, GLYPH_TYPE_KEY, GLYPH_TYPE_DOCUMENT,
} from '../shared/glyphs';
import { renderItemDetail } from '../popup/components/item-detail';
import { renderItemForm } from '../popup/components/item-form';
import { renderTrash, teardown as teardownTrash } from '../popup/components/trash';
import { renderDevices, teardown as teardownDevices } from '../popup/components/devices';
import { renderSettings } from '../popup/components/settings';
import { renderSettings, teardownSettings } from '../popup/components/settings';
import { renderVaultSettings as renderVaultSettingsView } from '../popup/components/settings-vault';
import { renderFieldHistory, teardown as teardownFieldHistory } from '../popup/components/field-history';
import { renderBackupPanel, teardown as teardownBackup } from './components/backup-panel';
import { renderImportPanel, teardown as teardownImport } from './components/import-panel';
import { applyColorScheme } from '../shared/color-scheme';
// ---------------------------------------------------------------------------
// Type picker (right side panel)
// ---------------------------------------------------------------------------
const PICKER_TYPES: Array<{ type: ItemType; label: string }> = [
{ type: 'login', label: 'Login' },
{ type: 'secure_note', label: 'Secure Note' },
{ type: 'totp', label: 'TOTP' },
{ type: 'card', label: 'Card' },
{ type: 'identity', label: 'Identity' },
{ type: 'key', label: 'SSH / API Key' },
{ type: 'document', label: 'Document' },
];
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
@@ -29,6 +47,27 @@ import { applyColorScheme } from '../shared/color-scheme';
function sendMessage(request: Request): Promise<Response> {
return new Promise((resolve) => {
chrome.runtime.sendMessage(request, (response: Response) => {
// MV3 service workers are evicted after ~30s idle, which wipes the
// in-memory session/manifest/gitHost. The fullscreen tab stays open
// and has no signal that the SW restarted — the next RPC just comes
// back `vault_locked`. Treat that as "session lost" and force the
// lock screen so the user can re-enter their passphrase. Skip for
// is_unlocked / unlock themselves to avoid loops on cold start.
if (
response &&
!response.ok &&
response.error === 'vault_locked' &&
request.type !== 'is_unlocked' &&
request.type !== 'unlock' &&
state.unlocked
) {
state.unlocked = false;
state.selectedId = null;
state.selectedItem = null;
state.entries = [];
state.error = 'Session expired — please unlock again.';
render();
}
resolve(response);
});
});
@@ -60,26 +99,35 @@ function renderErrorBlock(code: string | null | undefined): string {
function typeIcon(t: ItemType): string {
switch (t) {
case 'login': return '\u{1F511}'; // key
case 'secure_note': return '\u{1F4DD}'; // memo
case 'identity': return '\u{1FAAA}'; // id card
case 'card': return '\u{1F4B3}'; // credit card
case 'key': return '\u{1F5DD}'; // old key
case 'document': return '\u{1F4C4}'; // page facing up
case 'totp': return '⏱'; // stopwatch
case 'login': return GLYPH_TYPE_LOGIN;
case 'secure_note': return GLYPH_TYPE_SECURE_NOTE;
case 'identity': return GLYPH_TYPE_IDENTITY;
case 'card': return GLYPH_TYPE_CARD;
case 'key': return GLYPH_TYPE_KEY;
case 'document': return GLYPH_TYPE_DOCUMENT;
case 'totp': return GLYPH_TYPE_TOTP;
}
}
function typeLabel(t: ItemType): string {
switch (t) {
case 'login': return 'Logins';
case 'secure_note': return 'Secure Notes';
case 'identity': return 'Identities';
case 'card': return 'Cards';
case 'key': return 'Keys';
case 'document': return 'Documents';
case 'totp': return 'TOTP';
}
const labels: Record<ItemType, string> = {
login: 'Login',
secure_note: 'Secure Note',
identity: 'Identity',
card: 'Card',
key: 'SSH / API Key',
document: 'Document',
totp: 'TOTP',
};
return labels[t];
}
function relativeTime(unixSec: number): string {
const diffS = Math.floor(Date.now() / 1000) - unixSec;
if (diffS < 60) return 'just now';
if (diffS < 3600) return `${Math.floor(diffS / 60)}m ago`;
if (diffS < 86400) return `${Math.floor(diffS / 3600)}h ago`;
return `${Math.floor(diffS / 86400)}d ago`;
}
// ---------------------------------------------------------------------------
@@ -138,6 +186,8 @@ interface VaultState {
selectedIndex: number;
searchQuery: string;
activeGroup: string | null;
drawerOpen: boolean;
typePanelOpen: boolean;
vaultSettings: VaultSettings | null;
generatorDefaults: GeneratorRequest | null;
error: string | null;
@@ -157,6 +207,8 @@ const state: VaultState = {
selectedIndex: 0,
searchQuery: '',
activeGroup: null,
drawerOpen: false,
typePanelOpen: false,
vaultSettings: null,
generatorDefaults: null,
error: null,
@@ -180,7 +232,9 @@ registerHost({
navigate: (view: string, extras?: any) => {
Object.assign(state, { view, error: null, loading: false, ...extras });
setHash(view as VaultView);
renderSidebarList();
applyShellViewClass();
renderSidebarCategories();
if (state.view === 'list') renderListPane();
renderPane();
},
sendMessage,
@@ -249,38 +303,253 @@ function renderLockScreen(app: HTMLElement): void {
}
// ---------------------------------------------------------------------------
// Shell (sidebar + pane)
// Shell (3-column: sidebar + list pane + drawer)
// ---------------------------------------------------------------------------
function renderShell(app: HTMLElement): void {
// Only create the shell structure if it's not present yet
if (!app.querySelector('.vault-sidebar')) {
if (!app.querySelector('.vault-shell')) {
app.innerHTML = `
<div class="vault-sidebar">
<div class="vault-sidebar__header">
<span class="brand">Relicario</span>
<div class="vault-shell">
<div class="vault-sidebar">
<div class="vault-sidebar__header">
<img class="brand-logo" src="icons/relicario-logo.svg" alt="">
<span class="brand">Relicario</span>
</div>
<div class="vault-sidebar__search">
<input type="text" id="vault-search" placeholder="/ search…" />
</div>
<nav class="vault-sidebar__categories" id="vault-categories" aria-label="Item types"></nav>
<div class="vault-sidebar__nav">
<button class="vault-sidebar__nav-item vault-sidebar__nav-item--primary" data-nav="add" title="New item">+ new item</button>
<button class="vault-sidebar__nav-item" data-nav="trash" title="Trash">${GLYPH_TRASH} <span class="vault-sidebar__nav-label">trash</span></button>
<button class="vault-sidebar__nav-item" data-nav="devices" title="Devices">${GLYPH_DEVICES} <span class="vault-sidebar__nav-label">devices</span></button>
<button class="vault-sidebar__nav-item" data-nav="settings" title="Settings">${GLYPH_SETTINGS} <span class="vault-sidebar__nav-label">settings</span></button>
<button class="vault-sidebar__nav-item" data-nav="lock" title="Lock">${GLYPH_LOCK} <span class="vault-sidebar__nav-label">lock</span></button>
</div>
</div>
<div class="vault-sidebar__search">
<input type="text" id="vault-search" placeholder="/ search..." />
</div>
<div class="vault-sidebar__list" id="vault-sidebar-list"></div>
<div class="vault-sidebar__nav">
<button class="vault-sidebar__nav-item" data-nav="add">+ new item</button>
<button class="vault-sidebar__nav-item" data-nav="trash">${GLYPH_TRASH} trash</button>
<button class="vault-sidebar__nav-item" data-nav="devices">${GLYPH_DEVICES} devices</button>
<button class="vault-sidebar__nav-item" data-nav="settings">${GLYPH_SETTINGS} settings</button>
<button class="vault-sidebar__nav-item" data-nav="lock">${GLYPH_LOCK} lock</button>
</div>
</div>
<div class="vault-pane vault-pane--empty" id="vault-pane">
select an item
<div class="vault-list-pane" id="vault-list-pane"></div>
<div class="vault-pane" id="vault-pane"></div>
<div class="vault-drawer" id="vault-drawer"></div>
<div class="vault-type-panel-scrim" id="vault-type-scrim"></div>
<aside class="vault-type-panel" id="vault-type-panel" aria-label="Choose item type"></aside>
</div>
`;
wireSidebar();
wireTypePanel();
}
renderSidebarList();
renderPane();
applyShellViewClass();
renderSidebarCategories();
if (state.view === 'list') {
renderListPane();
if (state.drawerOpen && state.selectedItem) {
renderDrawer(state.selectedItem);
}
} else {
renderPane();
}
}
// Toggle which middle column is visible based on the current view.
// list view → list-pane (+ optional drawer); other views → vault-pane.
function applyShellViewClass(): void {
const shell = document.querySelector('.vault-shell');
if (!shell) return;
shell.classList.toggle('vault-shell--list', state.view === 'list');
shell.classList.toggle('vault-shell--pane', state.view !== 'list');
}
// ---------------------------------------------------------------------------
// Right-side type picker panel
// ---------------------------------------------------------------------------
function wireTypePanel(): void {
document.getElementById('vault-type-scrim')?.addEventListener('click', closeTypePanel);
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && state.typePanelOpen) closeTypePanel();
});
}
function openTypePanel(): void {
const panel = document.getElementById('vault-type-panel');
const scrim = document.getElementById('vault-type-scrim');
if (!panel || !scrim) return;
panel.innerHTML = `
<div class="vault-type-panel__head">
<div class="vault-type-panel__title">New item</div>
<button class="vault-type-panel__close" id="vault-type-close" title="Close (Esc)" aria-label="Close">✕</button>
</div>
<div class="vault-type-panel__hint">Choose a type</div>
<div class="vault-type-list" role="menu">
${PICKER_TYPES.map((t) => `
<button class="vault-type-item" data-type="${t.type}" role="menuitem">
<span class="vault-type-item__icon" aria-hidden="true">${typeIcon(t.type)}</span>
<span class="vault-type-item__name">${escapeHtml(t.label)}</span>
</button>
`).join('')}
</div>
`;
panel.classList.add('vault-type-panel--open');
scrim.classList.add('vault-type-panel-scrim--visible');
state.typePanelOpen = true;
panel.querySelector('#vault-type-close')?.addEventListener('click', closeTypePanel);
panel.querySelectorAll<HTMLButtonElement>('[data-type]').forEach((btn) => {
btn.addEventListener('click', () => {
const type = btn.dataset.type as ItemType;
closeTypePanel();
// Use the host's navigate hook so view + hash + visibility all update
// together. This was the bug: bare setHash + renderPane left the
// shell stuck in list view with #vault-pane hidden.
state.newType = type;
state.selectedId = null;
state.selectedItem = null;
state.drawerOpen = false;
state.view = 'add';
setHash('add', type);
applyShellViewClass();
renderSidebarCategories();
renderPane();
});
});
// Focus first item for keyboard users
(panel.querySelector('.vault-type-item') as HTMLElement | null)?.focus();
}
function closeTypePanel(): void {
document.getElementById('vault-type-panel')?.classList.remove('vault-type-panel--open');
document.getElementById('vault-type-scrim')?.classList.remove('vault-type-panel-scrim--visible');
state.typePanelOpen = false;
}
// ---------------------------------------------------------------------------
// Drawer (implemented in Task 10)
// ---------------------------------------------------------------------------
function openDrawer(): void {
document.getElementById('vault-drawer')?.classList.add('vault-drawer--open');
}
function closeDrawer(): void {
state.drawerOpen = false;
state.selectedId = null;
state.selectedItem = null;
document.getElementById('vault-drawer')?.classList.remove('vault-drawer--open');
}
function getDrawerCoreFields(item: Item): Array<[string, string, boolean]> {
const core = item.core as unknown as Record<string, unknown>;
if (!core) return [];
const fields: Array<[string, string, boolean]> = [];
switch (item.type) {
case 'login':
if ('username' in core) fields.push(['username', String(core.username ?? ''), false]);
if ('password' in core) fields.push(['password', '••••••••', false]);
if ('url' in core) fields.push(['url', String(core.url ?? ''), true]);
break;
case 'card': {
if ('number' in core) fields.push(['number', String(core.number ?? ''), false]);
if ('holder' in core) fields.push(['holder', String(core.holder ?? ''), false]);
if ('expiry' in core && core.expiry) {
const exp = core.expiry as { month: number; year: number };
fields.push(['expiry', `${String(exp.month).padStart(2, '0')}/${exp.year}`, false]);
}
if ('cvv' in core) fields.push(['cvv', '•••', false]);
if ('pin' in core) fields.push(['pin', '••••', false]);
break;
}
case 'identity':
if ('full_name' in core) fields.push(['full name', String(core.full_name ?? ''), true]);
if ('email' in core) fields.push(['email', String(core.email ?? ''), true]);
if ('phone' in core) fields.push(['phone', String(core.phone ?? ''), false]);
if ('address' in core) fields.push(['address', String(core.address ?? ''), true]);
if ('date_of_birth' in core) fields.push(['date of birth', String(core.date_of_birth ?? ''), false]);
break;
case 'key':
if ('label' in core) fields.push(['label', String(core.label ?? ''), true]);
if ('algorithm' in core) fields.push(['algorithm', String(core.algorithm ?? ''), false]);
if ('public_key' in core) fields.push(['public key', String(core.public_key ?? ''), true]);
break;
case 'secure_note':
if ('body' in core) fields.push(['body', String(core.body ?? ''), true]);
break;
case 'totp':
if ('issuer' in core) fields.push(['issuer', String(core.issuer ?? ''), false]);
if ('label' in core) fields.push(['label', String(core.label ?? ''), false]);
break;
case 'document':
if ('filename' in core) fields.push(['filename', String(core.filename ?? ''), true]);
if ('mime_type' in core) fields.push(['type', String(core.mime_type ?? ''), false]);
break;
}
if (item.notes) fields.push(['notes', item.notes, true]);
return fields;
}
function renderDrawer(item: Item): void {
const drawer = document.getElementById('vault-drawer');
if (!drawer) return;
const coreFields = getDrawerCoreFields(item);
drawer.innerHTML = `
<div class="vault-drawer__header">
<span class="vault-drawer__type-pill">${item.type.replace('_', ' ').toUpperCase()}</span>
<div class="vault-drawer__actions">
<button class="btn" id="drawer-edit-btn" style="font-size:11px;">edit</button>
<button class="vault-drawer__close" id="drawer-close-btn" title="Close (Esc)">✕</button>
</div>
</div>
<div class="vault-drawer__body">
<div class="vault-drawer__title">${escapeHtml(item.title)}</div>
${item.type === 'login' && (item.core as { url?: string }).url
? `<div class="vault-drawer__subtitle">${escapeHtml((item.core as { url?: string }).url ?? '')}</div>`
: ''}
<div class="vault-drawer__field-grid">
${coreFields.map(([label, value, full]) => `
<div class="vault-drawer__field${full ? ' vault-drawer__field--full' : ''}">
<div class="vault-drawer__field-label">${escapeHtml(label)}</div>
<div class="vault-drawer__field-value">${escapeHtml(value)}</div>
</div>
`).join('')}
</div>
</div>
`;
document.getElementById('drawer-close-btn')?.addEventListener('click', () => {
closeDrawer();
renderListPane();
});
document.getElementById('drawer-edit-btn')?.addEventListener('click', () => {
if (state.selectedId) {
setHash('edit', state.selectedId);
renderPane();
}
});
}
// ---------------------------------------------------------------------------
// Item selection (implemented in Task 10)
// ---------------------------------------------------------------------------
async function selectItemForDrawer(id: string): Promise<void> {
const resp = await sendMessage({ type: 'get_item', id });
if (!resp.ok) return;
const data = resp.data as { item: Item };
state.selectedId = id;
state.selectedItem = data.item;
state.drawerOpen = true;
renderSidebarCategories();
renderListPane();
renderDrawer(data.item);
openDrawer();
}
// ---------------------------------------------------------------------------
@@ -292,7 +561,8 @@ function wireSidebar(): void {
const searchInput = document.getElementById('vault-search') as HTMLInputElement | null;
searchInput?.addEventListener('input', () => {
state.searchQuery = searchInput.value;
renderSidebarList();
renderSidebarCategories();
renderListPane();
});
// Nav buttons
@@ -312,26 +582,35 @@ function wireSidebar(): void {
state.selectedId = null;
state.selectedItem = null;
state.newType = null;
setHash('add');
renderPane();
state.drawerOpen = false;
closeDrawer();
openTypePanel();
return;
}
if (nav === 'trash' || nav === 'devices' || nav === 'settings') {
state.selectedId = null;
state.selectedItem = null;
state.newType = null;
state.drawerOpen = false;
state.view = nav;
setHash(nav);
applyShellViewClass();
renderPane();
return;
}
});
});
// Global "/" shortcut to focus search
// Global "/" shortcut to focus search; Esc to close drawer
document.addEventListener('keydown', (e) => {
if (e.key === '/' && !isEditableTarget(e.target)) {
e.preventDefault();
searchInput?.focus();
return;
}
if (e.key === 'Escape' && state.drawerOpen) {
closeDrawer();
renderListPane();
}
});
}
@@ -345,7 +624,7 @@ function isEditableTarget(target: EventTarget | null): boolean {
}
// ---------------------------------------------------------------------------
// Sidebar list
// Sidebar category nav
// ---------------------------------------------------------------------------
function getFilteredEntries(): Array<[ItemId, ManifestEntry]> {
@@ -366,70 +645,100 @@ function getFilteredEntries(): Array<[ItemId, ManifestEntry]> {
return filtered;
}
function renderSidebarList(): void {
const container = document.getElementById('vault-sidebar-list');
function renderSidebarCategories(): void {
const container = document.getElementById('vault-categories');
if (!container) return;
const filtered = getFilteredEntries();
// Group by type
const groups = new Map<ItemType, Array<[ItemId, ManifestEntry]>>();
for (const entry of filtered) {
const t = entry[1].type;
if (!groups.has(t)) groups.set(t, []);
groups.get(t)!.push(entry);
}
if (filtered.length === 0) {
container.innerHTML = '<div class="empty">no items</div>';
return;
}
let html = '';
// Stable type ordering
const typeOrder: ItemType[] = ['login', 'secure_note', 'identity', 'card', 'key', 'document', 'totp'];
const allCount = filtered.length;
const isAllActive = !state.activeGroup && state.view === 'list';
let html = `
<button class="vault-category-row ${isAllActive ? 'vault-category-row--active' : ''}" data-group="">
<span class="vault-category-row__icon">◈</span>
<span class="vault-category-row__label vault-sidebar__category-label">All items</span>
<span class="vault-category-row__count vault-sidebar__category-count">${allCount}</span>
</button>
`;
for (const t of typeOrder) {
const items = groups.get(t);
if (!items || items.length === 0) continue;
html += `<div class="vault-group-header">${typeIcon(t)} ${escapeHtml(typeLabel(t))}</div>`;
for (const [id, e] of items) {
const sel = id === state.selectedId ? ' selected' : '';
const meta = e.icon_hint ? escapeHtml(e.icon_hint) : '';
html += `
<div class="vault-entry${sel}" data-id="${escapeHtml(id)}">
<span class="vault-entry__title">${escapeHtml(e.title)}</span>
${meta ? `<span class="vault-entry__meta">${meta}</span>` : ''}
</div>
`;
}
const count = filtered.filter(([, e]) => e.type === t).length;
// Always show Login (staple type); hide other types when empty.
if (count === 0 && t !== 'login') continue;
const isActive = state.activeGroup === t;
html += `
<button class="vault-category-row ${isActive ? 'vault-category-row--active' : ''}" data-group="${t}">
<span class="vault-category-row__icon">${typeIcon(t)}</span>
<span class="vault-category-row__label vault-sidebar__category-label">${typeLabel(t)}</span>
<span class="vault-category-row__count vault-sidebar__category-count">${count}</span>
</button>
`;
}
container.innerHTML = html;
// Wire clicks
container.querySelectorAll('.vault-entry').forEach((el) => {
el.addEventListener('click', async () => {
const id = (el as HTMLElement).dataset.id!;
await selectItem(id);
container.querySelectorAll<HTMLButtonElement>('.vault-category-row').forEach((btn) => {
btn.addEventListener('click', () => {
state.activeGroup = btn.dataset.group || null;
state.drawerOpen = false;
state.selectedId = null;
state.selectedItem = null;
state.view = 'list';
setHash('list');
applyShellViewClass();
renderSidebarCategories();
renderListPane();
closeDrawer();
});
});
}
async function selectItem(id: ItemId): Promise<void> {
state.loading = true;
const resp = await sendMessage({ type: 'get_item', id });
if (resp.ok) {
const data = resp.data as { item: Item };
state.selectedId = id;
state.selectedItem = data.item;
state.loading = false;
setHash('detail', id);
renderSidebarList();
renderPane();
} else {
state.loading = false;
state.error = (resp as { error: string }).error;
// ---------------------------------------------------------------------------
// List pane
// ---------------------------------------------------------------------------
function renderListPane(): void {
const pane = document.getElementById('vault-list-pane');
if (!pane) return;
const group = state.activeGroup as ItemType | null;
let items = getFilteredEntries();
if (group) items = items.filter(([, e]) => e.type === group);
if (items.length === 0) {
pane.innerHTML = `
<div class="empty-state">
<span class="empty-state__icon" aria-hidden="true">${state.searchQuery ? '⊘' : '◈'}</span>
<div class="empty-state__title">${state.searchQuery ? `No results for "${escapeHtml(state.searchQuery)}"` : 'No items yet'}</div>
<div class="empty-state__hint">${state.searchQuery ? 'Try a shorter search term.' : 'Click + new item to get started.'}</div>
</div>
`;
return;
}
pane.innerHTML = items.map(([id, e]) => {
const sel = id === state.selectedId ? ' vault-list-row--selected' : '';
const subtitle = (e as any).icon_hint ?? (e.tags?.length > 0 ? e.tags.join(', ') : '');
const modifiedAgo = e.modified ? relativeTime(e.modified) : '';
return `
<div class="vault-list-row${sel}" data-id="${escapeHtml(id)}">
<div class="vault-list-row__icon" aria-hidden="true">${typeIcon(e.type)}</div>
<div class="vault-list-row__text">
<div class="vault-list-row__title">${escapeHtml(e.title)}</div>
${subtitle ? `<div class="vault-list-row__subtitle">${escapeHtml(subtitle)}</div>` : ''}
</div>
${modifiedAgo ? `<div class="vault-list-row__age">${escapeHtml(modifiedAgo)}</div>` : ''}
</div>
`;
}).join('');
pane.querySelectorAll<HTMLElement>('.vault-list-row').forEach((row) => {
row.addEventListener('click', async () => {
await selectItemForDrawer(row.dataset.id!);
});
});
}
// ---------------------------------------------------------------------------
@@ -445,8 +754,8 @@ const SAVE_HINT = isMac ? '⌘+S to save' : 'Ctrl+S to save';
function renderFormWrapped(app: HTMLElement, mode: 'add' | 'edit'): void {
const itemType = state.selectedItem?.type ?? state.newType ?? 'login';
const typeLabel = itemType.replace('_', ' ');
const titleText = mode === 'add' ? `new ${typeLabel}` : `edit ${typeLabel}`;
const typeLabelText = itemType.replace('_', ' ');
const titleText = mode === 'add' ? `new ${typeLabelText}` : `edit ${typeLabelText}`;
const wrapper = document.createElement('div');
wrapper.className = 'form-pane';
wrapper.innerHTML = `
@@ -504,6 +813,7 @@ export const __test__ = { renderFormWrapped };
function teardownPaneComponents(): void {
teardownTrash();
teardownDevices();
teardownSettings();
teardownFieldHistory();
teardownBackup();
teardownImport();
@@ -518,6 +828,7 @@ function renderPane(): void {
const route = parseHash();
// Keep state.view in sync with hash for components that read it
state.view = route.view;
applyShellViewClass();
pane.className = 'vault-pane';
@@ -553,7 +864,7 @@ function renderPane(): void {
renderDevices(pane);
break;
case 'settings':
renderSettings(pane);
void renderSettings(pane);
break;
case 'settings-vault':
renderVaultSettingsView(pane);
@@ -668,12 +979,15 @@ document.addEventListener('DOMContentLoaded', async () => {
if (!state.unlocked) return;
const route = parseHash();
state.view = route.view;
applyShellViewClass();
// If navigating to a detail/edit view for an item we already have loaded
if ((route.view === 'detail' || route.view === 'edit') && route.id) {
if (state.selectedId === route.id && state.selectedItem) {
renderPane();
renderSidebarList();
renderSidebarCategories();
if (state.view === 'list') renderListPane();
return;
}
// Need to fetch the item
@@ -684,7 +998,30 @@ document.addEventListener('DOMContentLoaded', async () => {
// For non-item views, just re-render the pane
state.selectedId = null;
state.selectedItem = null;
renderSidebarList();
renderSidebarCategories();
if (state.view === 'list') renderListPane();
renderPane();
});
});
// ---------------------------------------------------------------------------
// Legacy selectItem — used by hash-change deep linking
// ---------------------------------------------------------------------------
async function selectItem(id: ItemId): Promise<void> {
state.loading = true;
const resp = await sendMessage({ type: 'get_item', id });
if (resp.ok) {
const data = resp.data as { item: Item };
state.selectedId = id;
state.selectedItem = data.item;
state.loading = false;
setHash('detail', id);
renderSidebarCategories();
renderListPane();
renderPane();
} else {
state.loading = false;
state.error = (resp as { error: string }).error;
}
}

View File

@@ -79,6 +79,9 @@ declare module 'relicario-wasm' {
export function clear_device(): void;
export function get_field_history(item_json: string): unknown;
export function wasm_generate_recovery_qr(handle: SessionHandle, passphrase: string): string;
export function wasm_unwrap_recovery_qr(payload_b64: string, passphrase: string): Uint8Array;
export default function init(module_or_path?: unknown): Promise<void>;
export function initSync(args: { module: WebAssembly.Module }): void;
}

81
tools/relay/call.py Normal file
View File

@@ -0,0 +1,81 @@
#!/usr/bin/env python3
"""
CLI shim: call a relay MCP tool via raw SSE protocol.
Usage:
python3 call.py read_messages '{"for":"pm"}'
python3 call.py post_message '{"from":"pm","to":"dev-a","kind":"directive","body":"..."}'
python3 call.py list_pending '{"for":"pm"}'
"""
import sys, json, threading, requests, time
BASE = "http://localhost:7331"
tool_name = sys.argv[1]
args = json.loads(sys.argv[2]) if len(sys.argv) > 2 else {}
result = {"done": False, "value": None}
endpoint_url = {"url": None}
msg_id = 1
def read_sse():
with requests.get(f"{BASE}/sse", stream=True, timeout=15) as r:
for line in r.iter_lines(decode_unicode=True):
if not line:
continue
if line.startswith("data:"):
data = line[5:].strip()
if not endpoint_url["url"] and data.startswith("/message"):
endpoint_url["url"] = BASE + data
elif endpoint_url["url"]:
try:
payload = json.loads(data)
if payload.get("id") == msg_id:
result["value"] = payload
result["done"] = True
return
except json.JSONDecodeError:
pass
t = threading.Thread(target=read_sse, daemon=True)
t.start()
# Wait for endpoint
for _ in range(50):
if endpoint_url["url"]:
break
time.sleep(0.1)
if not endpoint_url["url"]:
print(json.dumps({"error": "no endpoint received"}))
sys.exit(1)
# Send initialize
init_payload = {
"jsonrpc": "2.0", "id": 0, "method": "initialize",
"params": {
"protocolVersion": "2024-11-05",
"capabilities": {},
"clientInfo": {"name": "relay-cli", "version": "0.1.0"}
}
}
requests.post(endpoint_url["url"], json=init_payload, timeout=5)
# Send tools/call
call_payload = {
"jsonrpc": "2.0", "id": msg_id, "method": "tools/call",
"params": {"name": tool_name, "arguments": args}
}
requests.post(endpoint_url["url"], json=call_payload, timeout=5)
# Wait for result
for _ in range(100):
if result["done"]:
break
time.sleep(0.1)
if result["done"]:
content = result["value"].get("result", {}).get("content", [])
for item in content:
if item.get("type") == "text":
print(item["text"])
else:
print(json.dumps({"error": "timeout waiting for response"}))
sys.exit(1)

25
tools/relay/call.ts Normal file
View File

@@ -0,0 +1,25 @@
/**
* CLI shim: call a relay MCP tool from bash.
* Usage:
* npx tsx call.ts read_messages '{"for":"pm"}'
* npx tsx call.ts post_message '{"from":"pm","to":"dev-a","kind":"directive","body":"..."}'
* npx tsx call.ts list_pending '{"for":"pm"}'
*/
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
const [, , toolName, argsJson] = process.argv;
if (!toolName) {
console.error("Usage: call.ts <tool_name> <args_json>");
process.exit(1);
}
const args = argsJson ? JSON.parse(argsJson) : {};
const url = new URL("http://localhost:7331/sse");
const transport = new SSEClientTransport(url);
const client = new Client({ name: "relay-cli", version: "0.1.0" }, { capabilities: {} });
await client.connect(transport);
const result = await client.callTool({ name: toolName, arguments: args });
console.log(JSON.stringify(result));
await client.close();

1701
tools/relay/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

17
tools/relay/package.json Normal file
View File

@@ -0,0 +1,17 @@
{
"name": "@relicario/relay",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"start": "npx tsx server.ts",
"test": "node --import=tsx/esm --test queue.test.ts"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.10.0"
},
"devDependencies": {
"tsx": "^4.19.0",
"@types/node": "^22.0.0"
}
}

59
tools/relay/queue.test.ts Normal file
View File

@@ -0,0 +1,59 @@
import { describe, it, beforeEach } from "node:test";
import assert from "node:assert/strict";
import { RelayQueue, isRole } from "./queue.ts";
describe("RelayQueue", () => {
let q: RelayQueue;
beforeEach(() => {
q = new RelayQueue();
});
it("post + read roundtrip returns the message with correct fields", () => {
q.post("dev-b", "pm", "status", "Task P4 DONE");
const msgs = q.read("pm");
assert.equal(msgs.length, 1);
assert.equal(msgs[0].from, "dev-b");
assert.equal(msgs[0].to, "pm");
assert.equal(msgs[0].kind, "status");
assert.equal(msgs[0].body, "Task P4 DONE");
assert.ok(typeof msgs[0].id === "string" && msgs[0].id.length > 0);
assert.ok(typeof msgs[0].ts === "string");
});
it("consume-once: second read returns empty", () => {
q.post("dev-a", "pm", "question", "Should I use approach A?");
q.read("pm");
const second = q.read("pm");
assert.deepEqual(second, []);
});
it("list_pending does not drain inbox", () => {
q.post("dev-b", "pm", "directive", "PROCEED");
const before = q.pending("pm");
assert.equal(before.count, 1);
const after = q.read("pm");
assert.equal(after.length, 1);
});
it("FIFO ordering across multiple senders", () => {
q.post("dev-a", "pm", "status", "first");
q.post("dev-b", "pm", "status", "second");
q.post("dev-a", "pm", "question", "third");
const msgs = q.read("pm");
assert.equal(msgs.length, 3);
assert.equal(msgs[0].body, "first");
assert.equal(msgs[1].body, "second");
assert.equal(msgs[2].body, "third");
});
it("isRole rejects unknown strings", () => {
assert.ok(isRole("pm"));
assert.ok(isRole("dev-a"));
assert.ok(isRole("dev-b"));
assert.ok(isRole("dev-c"));
assert.ok(!isRole("dev-d"));
assert.ok(!isRole(""));
assert.ok(!isRole("PM"));
});
});

56
tools/relay/queue.ts Normal file
View File

@@ -0,0 +1,56 @@
import { randomUUID } from "node:crypto";
export type Role = "pm" | "dev-a" | "dev-b" | "dev-c";
export type MessageKind = "status" | "question" | "directive" | "free";
export interface RelayMessage {
id: string;
from: Role;
to: Role;
kind: MessageKind;
body: string;
ts: string;
}
const KNOWN_ROLES = new Set<string>(["pm", "dev-a", "dev-b", "dev-c"]);
export function isRole(s: string): s is Role {
return KNOWN_ROLES.has(s);
}
export class RelayQueue {
private readonly queues = new Map<Role, RelayMessage[]>([
["pm", []],
["dev-a", []],
["dev-b", []],
["dev-c", []],
]);
post(from: Role, to: Role, kind: MessageKind, body: string): RelayMessage {
const msg: RelayMessage = {
id: randomUUID(),
from,
to,
kind,
body,
ts: new Date().toISOString(),
};
this.queues.get(to)!.push(msg);
return msg;
}
read(forRole: Role): RelayMessage[] {
const inbox = this.queues.get(forRole)!;
const messages = [...inbox];
inbox.length = 0;
return messages;
}
pending(forRole: Role): { count: number; kinds: MessageKind[] } {
const inbox = this.queues.get(forRole)!;
return {
count: inbox.length,
kinds: inbox.map((m) => m.kind),
};
}
}

163
tools/relay/server.ts Normal file
View File

@@ -0,0 +1,163 @@
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
import {
CallToolRequestSchema,
ListToolsRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
import http from "node:http";
import { RelayQueue, isRole } from "./queue.ts";
const PORT = 7331;
const queue = new RelayQueue();
const TOOLS = [
{
name: "post_message",
description:
"Push a message to a recipient's inbox. Returns the assigned message id.",
inputSchema: {
type: "object" as const,
properties: {
from: {
type: "string",
enum: ["pm", "dev-a", "dev-b", "dev-c"],
description: "Your role name",
},
to: {
type: "string",
enum: ["pm", "dev-a", "dev-b", "dev-c"],
description: "Recipient role name",
},
kind: {
type: "string",
enum: ["status", "question", "directive", "free"],
description: "Message type matching the coordination protocol",
},
body: {
type: "string",
description: "Message body — freeform markdown, typically the full formatted block",
},
},
required: ["from", "to", "kind", "body"],
},
},
{
name: "read_messages",
description:
"Pop and return all pending messages for this recipient. Inbox is empty after this call (consume-once).",
inputSchema: {
type: "object" as const,
properties: {
for: {
type: "string",
enum: ["pm", "dev-a", "dev-b", "dev-c"],
description: "Your role name",
},
},
required: ["for"],
},
},
{
name: "list_pending",
description:
"Return count and kinds of pending messages without consuming them.",
inputSchema: {
type: "object" as const,
properties: {
for: {
type: "string",
enum: ["pm", "dev-a", "dev-b", "dev-c"],
description: "Your role name",
},
},
required: ["for"],
},
},
];
function handleToolCall(name: string, args: Record<string, string>) {
if (name === "post_message") {
if (!isRole(args.from)) {
return { content: [{ type: "text" as const, text: `Error: unknown role "${args.from}"` }], isError: true };
}
if (!isRole(args.to)) {
return { content: [{ type: "text" as const, text: `Error: unknown role "${args.to}"` }], isError: true };
}
const kind = args.kind as "status" | "question" | "directive" | "free";
const msg = queue.post(args.from, args.to, kind, args.body);
const ts = new Date(msg.ts).toTimeString().slice(0, 8);
const preview = args.body.slice(0, 60).replace(/\n/g, " ");
const ellipsis = args.body.length > 60 ? "..." : "";
process.stdout.write(`[${ts}] ${args.from}${args.to} [${kind}] "${preview}${ellipsis}"\n`);
return { content: [{ type: "text" as const, text: JSON.stringify({ id: msg.id }) }] };
}
if (name === "read_messages") {
if (!isRole(args.for)) {
return { content: [{ type: "text" as const, text: `Error: unknown role "${args.for}"` }], isError: true };
}
const messages = queue.read(args.for);
return { content: [{ type: "text" as const, text: JSON.stringify(messages) }] };
}
if (name === "list_pending") {
if (!isRole(args.for)) {
return { content: [{ type: "text" as const, text: `Error: unknown role "${args.for}"` }], isError: true };
}
const result = queue.pending(args.for);
return { content: [{ type: "text" as const, text: JSON.stringify(result) }] };
}
return {
content: [{ type: "text" as const, text: `Error: unknown tool "${name}"` }],
isError: true,
};
}
function makeServer() {
const srv = new Server(
{ name: "relay", version: "0.1.0" },
{ capabilities: { tools: {} } }
);
srv.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
srv.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
return handleToolCall(name, args as Record<string, string>);
});
return srv;
}
const transports = new Map<string, SSEServerTransport>();
const httpServer = http.createServer(async (req, res) => {
try {
if (req.method === "GET" && req.url === "/sse") {
const transport = new SSEServerTransport("/message", res);
transports.set(transport.sessionId, transport);
transport.onclose = () => transports.delete(transport.sessionId);
// Each connection gets its own Server instance so multiple clients can coexist.
await makeServer().connect(transport);
} else if (req.method === "POST" && req.url?.startsWith("/message")) {
const url = new URL(req.url, `http://127.0.0.1:${PORT}`);
const sessionId = url.searchParams.get("sessionId") ?? "";
const transport = transports.get(sessionId);
if (transport) {
await transport.handlePostMessage(req, res);
} else {
res.writeHead(404, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "session not found" }));
}
} else {
res.writeHead(404).end("not found");
}
} catch (err) {
console.error("[relay] error:", err);
if (!res.headersSent) res.writeHead(500).end(String(err));
}
});
httpServer.listen(PORT, "127.0.0.1", () => {
console.log(`[relay] server ready on :${PORT}`);
console.log(`[relay] tools: post_message, read_messages, list_pending`);
console.log(`[relay] waiting for connections — Ctrl-C to stop`);
});

99
tools/relay/start.sh Executable file
View File

@@ -0,0 +1,99 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(git -C "$SCRIPT_DIR" rev-parse --show-toplevel)"
PORT=7331
MODE="manual"
for arg in "$@"; do
case "$arg" in
--tmux) MODE="tmux" ;;
--kitty) MODE="kitty" ;;
--manual) MODE="manual" ;;
*) echo "Unknown option: $arg" >&2; echo "Usage: $0 [--manual|--tmux|--kitty]" >&2; exit 1 ;;
esac
done
# Port check
if lsof -ti:"$PORT" &>/dev/null; then
echo "Error: port $PORT is already in use."
echo "Relay already running? Kill it with: kill \$(lsof -ti:$PORT)"
exit 1
fi
# Install deps (no-op if node_modules current)
cd "$SCRIPT_DIR"
npm install --silent
# Discover latest coordination prompts for instructions
COORD_DIR="$REPO_ROOT/docs/superpowers/coordination"
PM_PROMPT="$(ls -t "$COORD_DIR"/*-pm-prompt.md 2>/dev/null | head -1 || echo "(none found — run multi-agent-kickoff skill first)")"
DEV_A_PROMPT="$(ls -t "$COORD_DIR"/*-dev-a-prompt.md 2>/dev/null | head -1 || echo "(none found)")"
DEV_B_PROMPT="$(ls -t "$COORD_DIR"/*-dev-b-prompt.md 2>/dev/null | head -1 || echo "(none found)")"
print_manual_instructions() {
echo ""
echo "╔══════════════════════════════════════════════════════════════╗"
echo "║ RELAY SERVER — MULTI-AGENT LIFT LAUNCHER ║"
echo "╚══════════════════════════════════════════════════════════════╝"
echo ""
echo "Open 3 new terminals. In each, start Claude Code and paste"
echo "the content BELOW the '---' line from the corresponding file."
echo ""
echo " Terminal 1 (PM): cat '$PM_PROMPT'"
echo " Terminal 2 (Dev A): cat '$DEV_A_PROMPT'"
echo " Terminal 3 (Dev B): cat '$DEV_B_PROMPT'"
echo ""
echo "This terminal becomes the relay log. Keep it open."
echo ""
echo "══════════════════════════════════════════════════════════════"
}
launch_tmux() {
SESSION="relay-lift"
tmux new-session -d -s "$SESSION" -n "relay" \
"cd '$SCRIPT_DIR' && npx tsx server.ts"
tmux new-window -t "$SESSION:" -n "pm" "cd '$REPO_ROOT' && claude"
tmux new-window -t "$SESSION:" -n "dev-a" "cd '$REPO_ROOT' && claude"
tmux new-window -t "$SESSION:" -n "dev-b" "cd '$REPO_ROOT' && claude"
echo ""
echo "[relay] Opened tmux session '$SESSION' with 4 windows: relay, pm, dev-a, dev-b."
echo "[relay] Paste the kickoff prompt into each Claude window."
echo " Prompts:"
echo " PM: $PM_PROMPT"
echo " Dev A: $DEV_A_PROMPT"
echo " Dev B: $DEV_B_PROMPT"
echo ""
tmux attach-session -t "$SESSION"
}
launch_kitty() {
kitty @ launch --type=tab --tab-title "relay" -- \
bash -c "cd '$SCRIPT_DIR' && npx tsx server.ts"
kitty @ launch --type=tab --tab-title "PM" --hold -- \
bash -l -i -c "cd '$REPO_ROOT' && claude"
kitty @ launch --type=tab --tab-title "Dev-A" --hold -- \
bash -l -i -c "cd '$REPO_ROOT' && claude"
kitty @ launch --type=tab --tab-title "Dev-B" --hold -- \
bash -l -i -c "cd '$REPO_ROOT' && claude"
echo ""
echo "[relay] Opened kitty tab 'relay' + 3 windows (PM, Dev-A, Dev-B)."
echo " Paste the kickoff prompts into each Claude window."
echo " PM: $PM_PROMPT"
echo " Dev A: $DEV_A_PROMPT"
echo " Dev B: $DEV_B_PROMPT"
}
case "$MODE" in
manual)
print_manual_instructions
exec npx tsx "$SCRIPT_DIR/server.ts"
;;
tmux)
launch_tmux
;;
kitty)
launch_kitty
;;
esac

10
tools/relay/tsconfig.json Normal file
View File

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"noEmit": true
},
"include": ["*.ts"]
}