diff --git a/docs/superpowers/reviews/2026-05-04-architecture-review.md b/docs/superpowers/reviews/2026-05-04-architecture-review.md new file mode 100644 index 0000000..a268181 --- /dev/null +++ b/docs/superpowers/reviews/2026-05-04-architecture-review.md @@ -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` 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 ` ` 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` 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>` 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` mapped from a single `MessageMap` table. (DEV-C) +- **`group-autocomplete.ts:26` builds list HTML via `innerHTML` with only `"` escaping.** Group names are user-entered. `<` and `>` aren't escaped — markup-injection risk. Use `document.createElement('option')`. (DEV-C) +- **`restore_backup` flattens `newRemote` inline** (`shared/messages.ts:56-66`). Inconsistent with sibling messages. Extract `RestoreBackupPayload`. (DEV-C) + +### WASM boundary (JS side) +- **`__stubs__/relicario_wasm.stub.ts` only stubs 7 of ~25 exports.** Adding a vitest test that touches a new WASM call needs an ad-hoc per-test mock. Round out the stub or provide a `mockWasm({...})` test helper. (DEV-C) + +### Relay tooling +- **`tools/relay/start.sh:80` may still reference "Dev-B"** in user-facing output despite the dev-c expansion. Confirm a fourth window opens for dev-c. (DEV-C) +- **`tools/relay/call.py` and `call.ts` are untracked but load-bearing** for the multi-agent fallback path (kickoff prompts reference `call.py` by path). Either track them with a one-line header explaining "MCP-fallback shim" or add to `.gitignore`. (DEV-C) +- **In-memory queue has no TTL, persistence, or cap** (`queue.ts:21-27`). Document the dev-only ephemeral contract or add a per-role cap. (DEV-C) + +### Cross-cutting +- **`#[allow(dead_code)]` without explanation** appears in `cli/device.rs`, `cli/gitea.rs`, and `wasm/device.rs`. Each is either "API completeness" or "scar tissue." Annotate with `// TODO():` 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 `. 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 `. 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) diff --git a/docs/superpowers/reviews/2026-05-04-dev-a-notes.md b/docs/superpowers/reviews/2026-05-04-dev-a-notes.md new file mode 100644 index 0000000..b1601fe --- /dev/null +++ b/docs/superpowers/reviews/2026-05-04-dev-a-notes.md @@ -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` 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` 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)` 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. diff --git a/docs/superpowers/reviews/2026-05-04-dev-b-notes.md b/docs/superpowers/reviews/2026-05-04-dev-b-notes.md new file mode 100644 index 0000000..2d59081 --- /dev/null +++ b/docs/superpowers/reviews/2026-05-04-dev-b-notes.md @@ -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` 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():` 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 ` ` 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` 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>`) vs `crates/relicario-wasm/src/session.rs:14-17` (`thread_local! { RefCell> }`) +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, 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():`. + +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//`. `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> }`, 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` for private keys (correct — `String::zeroize` wipes the heap allocation). Uses `Lazy>` (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>`). 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::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/.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? diff --git a/docs/superpowers/reviews/2026-05-04-dev-c-notes.md b/docs/superpowers/reviews/2026-05-04-dev-c-notes.md new file mode 100644 index 0000000..d95da22 --- /dev/null +++ b/docs/superpowers/reviews/2026-05-04-dev-c-notes.md @@ -0,0 +1,343 @@ +# DEV-C Architecture Review Notes — TypeScript (Extension + Relay) + +**Reviewer:** dev-c (TypeScript scope) **Date:** 2026-05-04 **Branch:** main (read-only review) + +## Summary + +The TypeScript layer is **soundly organized but unevenly distributed**: a tight discriminated-union message contract (`shared/messages.ts`) and a clean Manifest V3 trust split (popup/setup/content/SW) anchor a codebase that earns its complexity from the two-factor unlock UX. The strongest part is the **service-worker router**: every popup/content message has a typed handler with origin-aware gating, and content scripts never touch WASM. The weakest parts are **two oversized modules that pre-empt the otherwise-clean component model**: `extension/src/setup/setup.ts` (1220 LOC) bypasses the SW and orchestrates WASM directly, and `extension/src/vault/vault.ts` (1027 LOC) inlines shell, sidebar, list, drawer, type-picker, form-wrapper and routing into one file. **CLI/extension parity is excellent overall** — every CLI subcommand has a UI path through the unified left-nav settings (v0.5.1) or vault tab — with two real gaps: explicit per-attachment detach (extension does it via `update_item`) and a vault-status surface equivalent to `relicario status`. One **broken test** in uncommitted relay code (`tools/relay/queue.test.ts:54`) blocks `bun test` from going green. + +## Findings (prioritized: P1 / P2 / P3) + +### Extension — service-worker + +**[P1] router/popup-only.ts:687–703 & router/content-callable.ts:187–205 — duplicated storage helpers (`loadDeviceSettings`, `loadBlacklist`, `saveBlacklist`)** +WHY: Three identical chrome.storage.local helpers in both router files; both code paths can mutate blacklist via different definitions, so future drift will silently corrupt one path. +DIRECTION: Extract to `service-worker/storage.ts`; import from both routers. + +**[P1] router/popup-only.ts:~169 & router/content-callable.ts:~169 — `itemToManifestEntry()` duplicated** +WHY: Identical 17-line manifest projection in both router files; refactors of the manifest schema will need to be made twice. +DIRECTION: Move into `service-worker/vault.ts` and import. + +**[P2] service-worker/index.ts:76–78 — inactivity timer reset skips content-callable messages** +WHY: Only popup/vault sends reset the timer; a user who is actively autofilling but never opens the popup will eventually be force-locked despite continuous use. Conversely, "every_time" mode is fine. +DIRECTION: Reset on all messages except known read-only content calls (`get_autofill_candidates`); document the exclusion in the timer module. + +**[P2] service-worker/index.ts:51–58 — session expiry clears `state.manifest` but leaves `state.gitHost`** +WHY: After expiry, the cached git-host client survives; the next unlock could mix with stale connection state if a remote was rotated. +DIRECTION: Null `state.gitHost` alongside `state.manifest` in the expiry callback; the initializer rebuilds it on demand. + +**[P2] service-worker/session.ts:26 — `try { current.free() }` swallows free errors** +WHY: A double-free or invalid-handle on the WASM session would be silently ignored, masking a lifecycle bug that could leave the master key zeroed but the JS handle dangling. +DIRECTION: Let exceptions propagate (or log + counter); silent swallow is the wrong default for crypto state transitions. + +**[P3] service-worker/index.ts:61–65 — chrome.storage.local.get on startup swallows errors** +WHY: A typo or storage quota event leaves the timer in default state with no signal in the logs. +DIRECTION: Surface failure via console.warn with the key name. + +**[P3] service-worker/git-host.ts vs github.ts/gitea.ts — deploy-key API only on Gitea side** +WHY: The interface is asymmetric; either GitHub doesn't need it (document) or it's an unimplemented feature. +DIRECTION: Add a doc comment on `GitHost` explaining the asymmetry, or add the GitHub op. + +### Extension — popup + components + +**[P1] settings.ts:56–65 & settings-vault.ts:15–22 — duplicated teardown helpers** +WHY: After the stub-restore commits (`8baef5b`, `ddfb95d`), teardown leaks are a known regression class; duplicated cleanup is exactly the shape that re-introduces them. +DIRECTION: Extract `teardownSettingsCommon()` into a single helper; have each settings section call it on unmount. + +**[P2] popup.ts:178–181 — teardown calls every type module unconditionally** +WHY: Cycling views runs all 7 type teardowns on each transition; harmless today, but makes the lifecycle hard to reason about and hides which module was actually mounted. +DIRECTION: Track last-mounted type and tear down only that one (or use a registry of `() => teardown` returned by mount). + +**[P2] settings.ts:33–34 — `pendingVaultSettings` and `sessionHandle` are module-scope singletons** +WHY: Section navigation can leave stale `pendingVaultSettings` from a previous section in scope; the current code is safe by accident, not by design. +DIRECTION: Reset to `null` on each `renderSection` entry, or scope into a closure per render. + +**[P2] item-list.ts:152–353 — settings-picker popover wires listeners on every render without a reuse path** +WHY: Open/close cycles attach + detach listeners cleanly today, but rapid re-opens with throttled `setTimeout` cleanup are exactly the pattern teardown bugs hide in. +DIRECTION: Cache the popover DOM and reuse, or use AbortController for the listeners with an explicit `signal` per open. + +**[P2] devices.ts:47–50 & trash.ts:39–46 — `Promise.all` without per-promise error handling** +WHY: A single rejected RPC fails the whole render; the second response is discarded even when its data was fetched successfully. +DIRECTION: `Promise.allSettled`, then check `.ok` per response. + +**[P2] generator-panel.ts:89–261 — module-scope `activePanel`; `closeGeneratorPanel()` not idempotent-guarded** +WHY: The cleanup is currently a no-op when called twice, but the contract is implicit. +DIRECTION: Add `if (!activePanel) return` at top. + +**[P3] form-header.ts:20 + types/login.ts — `isInTab()` checked twice (header + caller)** +WHY: Redundant coupling; the popout button visibility decision happens in two places. +DIRECTION: Pass `showPopout: boolean` into `renderFormHeader` once. + +**[P3] popup.ts:36–38 — `isInTab()` heuristic is `window.location.search.length > 0`** +WHY: Any query string on `popup.html` triggers tab mode; brittle if popup is ever opened with diagnostic params. +DIRECTION: Parse a specific param (`?view=tab` or check `window.location.pathname.endsWith('vault.html')`). + +**[P3] item-form.ts:95–104 — `renderComingSoon()` defined but unused** +WHY: Document type now has a real form (`types/document.ts`); the placeholder is dead code. +DIRECTION: Delete or leave a one-line comment explaining future use. + +**[P3] types/login.ts ~700 LOC — `renderForm` mixes HTML, wiring, password-strength, TOTP-ticker, and section editor** +WHY: The reference implementation for the other 6 type modules; size makes the per-affordance lifecycle hard to follow for a learner. +DIRECTION: Extract `wireUrlField`, `wirePasswordField`, `wireTotpField` from the body of `renderForm`. Other type modules already follow simpler patterns. + +### Extension — vault tab + +**[P1] vault/vault.ts (1027 LOC) — single file owns shell + sidebar + list + drawer + type-picker + form-wrapper + routing + teardown** +WHY: A learner opening this file faces all responsibilities at once; teardown coupling (`teardownPaneComponents` lines 813–820) makes adding a new pane view a 5-place edit. +DIRECTION: Split into `vault-shell.ts`, `vault-sidebar.ts`, `vault-list.ts`, `vault-drawer.ts`, `vault-form-wrapper.ts`, leaving `vault.ts` to own only routing and state. + +**[P2] vault/vault.ts:47–74 — vault tab intercepts `vault_locked` errors via the RPC layer; popup uses only the `session_expired` event** +WHY: Two different mechanisms reach the same outcome; popup-targeted components running in the vault tab don't know which channel will fire first. +DIRECTION: Lift the RPC `vault_locked` intercept into `shared/state.ts` (or a wrapper around `sendMessage`) so both surfaces use one path. + +**[P2] vault/vault.ts:495–536 — drawer doesn't auto-close on non-list view changes** +WHY: Selecting an item opens the drawer; navigating to Trash via the sidebar leaves `state.drawerOpen = true` even though the drawer is no longer rendered. +DIRECTION: `state.drawerOpen = false` at the start of `renderPane`, or when a non-list view becomes active. + +**[P2] vault/vault.ts:648–695 — sidebar categories re-render on every search keystroke without debounce** +WHY: Counts and active state must sync, but at hundreds of items the keystroke path stamps the whole sidebar. +DIRECTION: 50–100ms debounce on the search input handler before re-rendering. + +**[P3] vault/vault.ts:18–26 — backup-panel and import-panel are `vault/components/`-only by design** +WHY: Correct call (popup has no room for these), but worth noting in the file header so a contributor doesn't try to import them into popup. +DIRECTION: Add a short comment in `vault/components/index.ts` (or top of each file) explaining the vault-tab-only scope. + +### Extension — content scripts + +**[P2] content/fill.ts:50–64 — `fillFields()` returns silently when no password field is found** +WHY: Dynamic forms (React/SPA mounting after `document_idle`) can race the fill listener; user clicks autofill, nothing visible happens, no error reaches the popup. +DIRECTION: Send a `{type: 'fill_failed', reason: 'no_password_field'}` ack back to SW so the icon can flash an error glyph. + +**[P2] content/detector.ts:96–103 — MutationObserver `scan()` is not debounced** +WHY: SPA churn fires DOM mutations many times per second; the detector re-runs the full scan each time. WeakSet stops icon double-injection, but the scan cost is wasted. +DIRECTION: Wrap `scan()` in `requestIdleCallback` or a 200ms timer. + +**[P2] content/icon.ts:169–175 — outside-click listener registered in `setTimeout(0)` not removed on alternate close paths** +WHY: Picker can close via Escape or programmatic destroy; the doc-level listener for outside-click leaks unless `closeOverlay()` is the close path. +DIRECTION: Store the handler reference and `removeEventListener` in every close path; or `AbortController` scoped to picker open. + +**[P3] capture.ts vs detector.ts vs fill.ts — username-finder logic copy-pasted three ways** +WHY: `findUsernameField`, `findUsernameForFill`, `findUsernameValue` are three forks of the same heuristic. +DIRECTION: Extract a shared `getUsernameField(pwField, scope, { valueOnly?: boolean })`. + +**[P3] capture.ts:269–299 — submit-button hook scope is `form ?? pwField.parentElement`** +WHY: A submit button outside the form (e.g. a custom React `