Files
relicario/docs/superpowers/reviews/2026-05-04-dev-c-notes.md
adlee-was-taken 061facd5a9 docs(reviews): whole-codebase architecture audit 2026-05-04
Three-reviewer architecture audit (DEV-A: core, DEV-B: cli/server/wasm,
DEV-C: extension/relay) plus PM synthesis. Lens: make the codebase
readable for a smart developer who doesn't know Rust but wants to learn
by tinkering.

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

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

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

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-04 23:27:26 -04:00

35 KiB
Raw Blame History

DEV-C Architecture Review Notes — TypeScript (Extension + Relay)

Reviewer: dev-c (TypeScript scope) Date: 2026-05-04 Branch: main (read-only review)

Summary

The TypeScript layer is soundly organized but unevenly distributed: a tight discriminated-union message contract (shared/messages.ts) and a clean Manifest V3 trust split (popup/setup/content/SW) anchor a codebase that earns its complexity from the two-factor unlock UX. The strongest part is the service-worker router: every popup/content message has a typed handler with origin-aware gating, and content scripts never touch WASM. The weakest parts are two oversized modules that pre-empt the otherwise-clean component model: extension/src/setup/setup.ts (1220 LOC) bypasses the SW and orchestrates WASM directly, and extension/src/vault/vault.ts (1027 LOC) inlines shell, sidebar, list, drawer, type-picker, form-wrapper and routing into one file. CLI/extension parity is excellent overall — every CLI subcommand has a UI path through the unified left-nav settings (v0.5.1) or vault tab — with two real gaps: explicit per-attachment detach (extension does it via update_item) and a vault-status surface equivalent to relicario status. One broken test in uncommitted relay code (tools/relay/queue.test.ts:54) blocks bun test from going green.

Findings (prioritized: P1 / P2 / P3)

Extension — service-worker

[P1] router/popup-only.ts:687703 & router/content-callable.ts:187205 — duplicated storage helpers (loadDeviceSettings, loadBlacklist, saveBlacklist) WHY: Three identical chrome.storage.local helpers in both router files; both code paths can mutate blacklist via different definitions, so future drift will silently corrupt one path. DIRECTION: Extract to service-worker/storage.ts; import from both routers.

[P1] router/popup-only.ts:~169 & router/content-callable.ts:~169 — itemToManifestEntry() duplicated WHY: Identical 17-line manifest projection in both router files; refactors of the manifest schema will need to be made twice. DIRECTION: Move into service-worker/vault.ts and import.

[P2] service-worker/index.ts:7678 — inactivity timer reset skips content-callable messages WHY: Only popup/vault sends reset the timer; a user who is actively autofilling but never opens the popup will eventually be force-locked despite continuous use. Conversely, "every_time" mode is fine. DIRECTION: Reset on all messages except known read-only content calls (get_autofill_candidates); document the exclusion in the timer module.

[P2] service-worker/index.ts:5158 — session expiry clears state.manifest but leaves state.gitHost WHY: After expiry, the cached git-host client survives; the next unlock could mix with stale connection state if a remote was rotated. DIRECTION: Null state.gitHost alongside state.manifest in the expiry callback; the initializer rebuilds it on demand.

[P2] service-worker/session.ts:26 — try { current.free() } swallows free errors WHY: A double-free or invalid-handle on the WASM session would be silently ignored, masking a lifecycle bug that could leave the master key zeroed but the JS handle dangling. DIRECTION: Let exceptions propagate (or log + counter); silent swallow is the wrong default for crypto state transitions.

[P3] service-worker/index.ts:6165 — chrome.storage.local.get on startup swallows errors WHY: A typo or storage quota event leaves the timer in default state with no signal in the logs. DIRECTION: Surface failure via console.warn with the key name.

[P3] service-worker/git-host.ts vs github.ts/gitea.ts — deploy-key API only on Gitea side WHY: The interface is asymmetric; either GitHub doesn't need it (document) or it's an unimplemented feature. DIRECTION: Add a doc comment on GitHost explaining the asymmetry, or add the GitHub op.

Extension — popup + components

[P1] settings.ts:5665 & settings-vault.ts:1522 — duplicated teardown helpers WHY: After the stub-restore commits (8baef5b, ddfb95d), teardown leaks are a known regression class; duplicated cleanup is exactly the shape that re-introduces them. DIRECTION: Extract teardownSettingsCommon() into a single helper; have each settings section call it on unmount.

[P2] popup.ts:178181 — teardown calls every type module unconditionally WHY: Cycling views runs all 7 type teardowns on each transition; harmless today, but makes the lifecycle hard to reason about and hides which module was actually mounted. DIRECTION: Track last-mounted type and tear down only that one (or use a registry of () => teardown returned by mount).

[P2] settings.ts:3334 — pendingVaultSettings and sessionHandle are module-scope singletons WHY: Section navigation can leave stale pendingVaultSettings from a previous section in scope; the current code is safe by accident, not by design. DIRECTION: Reset to null on each renderSection entry, or scope into a closure per render.

[P2] item-list.ts:152353 — settings-picker popover wires listeners on every render without a reuse path WHY: Open/close cycles attach + detach listeners cleanly today, but rapid re-opens with throttled setTimeout cleanup are exactly the pattern teardown bugs hide in. DIRECTION: Cache the popover DOM and reuse, or use AbortController for the listeners with an explicit signal per open.

[P2] devices.ts:4750 & trash.ts:3946 — Promise.all without per-promise error handling WHY: A single rejected RPC fails the whole render; the second response is discarded even when its data was fetched successfully. DIRECTION: Promise.allSettled, then check .ok per response.

[P2] generator-panel.ts:89261 — module-scope activePanel; closeGeneratorPanel() not idempotent-guarded WHY: The cleanup is currently a no-op when called twice, but the contract is implicit. DIRECTION: Add if (!activePanel) return at top.

[P3] form-header.ts:20 + types/login.ts — isInTab() checked twice (header + caller) WHY: Redundant coupling; the popout button visibility decision happens in two places. DIRECTION: Pass showPopout: boolean into renderFormHeader once.

[P3] popup.ts:3638 — isInTab() heuristic is window.location.search.length > 0 WHY: Any query string on popup.html triggers tab mode; brittle if popup is ever opened with diagnostic params. DIRECTION: Parse a specific param (?view=tab or check window.location.pathname.endsWith('vault.html')).

[P3] item-form.ts:95104 — renderComingSoon() defined but unused WHY: Document type now has a real form (types/document.ts); the placeholder is dead code. DIRECTION: Delete or leave a one-line comment explaining future use.

[P3] types/login.ts ~700 LOC — renderForm mixes HTML, wiring, password-strength, TOTP-ticker, and section editor WHY: The reference implementation for the other 6 type modules; size makes the per-affordance lifecycle hard to follow for a learner. DIRECTION: Extract wireUrlField, wirePasswordField, wireTotpField from the body of renderForm. Other type modules already follow simpler patterns.

Extension — vault tab

[P1] vault/vault.ts (1027 LOC) — single file owns shell + sidebar + list + drawer + type-picker + form-wrapper + routing + teardown WHY: A learner opening this file faces all responsibilities at once; teardown coupling (teardownPaneComponents lines 813820) makes adding a new pane view a 5-place edit. DIRECTION: Split into vault-shell.ts, vault-sidebar.ts, vault-list.ts, vault-drawer.ts, vault-form-wrapper.ts, leaving vault.ts to own only routing and state.

[P2] vault/vault.ts:4774 — vault tab intercepts vault_locked errors via the RPC layer; popup uses only the session_expired event WHY: Two different mechanisms reach the same outcome; popup-targeted components running in the vault tab don't know which channel will fire first. DIRECTION: Lift the RPC vault_locked intercept into shared/state.ts (or a wrapper around sendMessage) so both surfaces use one path.

[P2] vault/vault.ts:495536 — drawer doesn't auto-close on non-list view changes WHY: Selecting an item opens the drawer; navigating to Trash via the sidebar leaves state.drawerOpen = true even though the drawer is no longer rendered. DIRECTION: state.drawerOpen = false at the start of renderPane, or when a non-list view becomes active.

[P2] vault/vault.ts:648695 — sidebar categories re-render on every search keystroke without debounce WHY: Counts and active state must sync, but at hundreds of items the keystroke path stamps the whole sidebar. DIRECTION: 50100ms debounce on the search input handler before re-rendering.

[P3] vault/vault.ts:1826 — backup-panel and import-panel are vault/components/-only by design WHY: Correct call (popup has no room for these), but worth noting in the file header so a contributor doesn't try to import them into popup. DIRECTION: Add a short comment in vault/components/index.ts (or top of each file) explaining the vault-tab-only scope.

Extension — content scripts

[P2] content/fill.ts:5064 — fillFields() returns silently when no password field is found WHY: Dynamic forms (React/SPA mounting after document_idle) can race the fill listener; user clicks autofill, nothing visible happens, no error reaches the popup. DIRECTION: Send a {type: 'fill_failed', reason: 'no_password_field'} ack back to SW so the icon can flash an error glyph.

[P2] content/detector.ts:96103 — MutationObserver scan() is not debounced WHY: SPA churn fires DOM mutations many times per second; the detector re-runs the full scan each time. WeakSet stops icon double-injection, but the scan cost is wasted. DIRECTION: Wrap scan() in requestIdleCallback or a 200ms timer.

[P2] content/icon.ts:169175 — outside-click listener registered in setTimeout(0) not removed on alternate close paths WHY: Picker can close via Escape or programmatic destroy; the doc-level listener for outside-click leaks unless closeOverlay() is the close path. DIRECTION: Store the handler reference and removeEventListener in every close path; or AbortController scoped to picker open.

[P3] capture.ts vs detector.ts vs fill.ts — username-finder logic copy-pasted three ways WHY: findUsernameField, findUsernameForFill, findUsernameValue are three forks of the same heuristic. DIRECTION: Extract a shared getUsernameField(pwField, scope, { valueOnly?: boolean }).

[P3] capture.ts:269299 — submit-button hook scope is form ?? pwField.parentElement WHY: A submit button outside the form (e.g. a custom React <button onClick={...}> siblings to the form) won't trigger capture; the user never sees the save prompt. DIRECTION: Walk up to the nearest form ancestor and listen for keydown.Enter on the password field as a fallback.

[Positive note] content scripts make zero direct WASM calls and re-validate origin in fill.ts:3238 — boundary discipline holds.

Extension — setup

[P1] setup/setup.ts:2837, 11181120 — WASM is loaded and called directly inside the setup wizard, bypassing the service-worker abstraction the rest of the codebase uses WHY: Popup, vault tab, and content all funnel WASM through the SW; setup is the only surface that imports relicario-wasm and orchestrates unlock/embed_image_secret/register_device/manifest_encrypt itself. This duplicates ~400 LOC of crypto orchestration that the SW already knows how to do, and it means the setup tab can't be locked by the same session-timer the rest of the extension uses. DIRECTION: Add create_vault and attach_vault messages to the SW; turn setup.ts into a UI that posts those messages with the gathered config + image bytes. This collapses ~600 LOC into ~200 and unifies the crypto state machine.

[P2] setup/setup.ts (1220 LOC) — render/attach pairs per step are inlined WHY: 6 steps × (renderStepN, attachStepN) = a switch statement that reads procedurally; adding a step means edits to the renderer, the attacher, and the wizard state. DIRECTION: Step registry: { id: 0, render: () => string, attach: (root) => Teardown }[] keyed by mode. The 1220 LOC drops to ~500 after the WASM extraction in [P1] above and this restructure.

[P2] setup/setup.ts:6994 — WizardState is module-scope; passphrase + JPEG bytes + WASM handle persist if the user abandons mid-wizard WHY: A user who closes the setup tab mid-flow leaves sensitive material in JS memory until the page is unloaded. The handle isn't free()d. DIRECTION: Add a clearWizardState() called on beforeunload and on returning to step 0; explicitly Zeroize-equivalent for the byte arrays where possible.

[P2] setup/probe.ts:1123 vs service-worker/vault.ts — manifest path constants duplicated (.relicario/salt, .relicario/params.json, manifest.enc) WHY: Two sources of truth; if the metadata layout ever moves, probe and vault disagree about whether a remote is initialized. DIRECTION: Define VAULT_PATHS in shared/types.ts (or a new shared/paths.ts) and import into both.

[P3] setup/setup.ts:56, 8283 — passphrase score uses -1 as "not yet scored" sentinel WHY: The score is conflated with strength label index 0..4; -1 magic number is checked in two places. DIRECTION: Use { scored: false } | { scored: true; score: number; guessesLog10: number }.

[P3] setup/setup.ts:10561062 — recovery_qr_generated_at written directly to chrome.storage.local, bypassing WizardState WHY: One piece of setup result state lives outside the wizard's own state model. DIRECTION: Add it to WizardState, persist on a single finishSetup storage write.

[P3] setup/setup.ts:17 — header comment says "5-step flow"; code is 6 steps (0..5) WHY: Cosmetic but actively misleading; line 42 even has a comment about the renumber. DIRECTION: Update header.

Extension — shared utilities

[P1] shared/state.ts:1035 — entire StateHost contract is any-typed WHY: This is the bridge that lets popup components run in the vault tab; it's also the bridge most likely to drift between the two render targets, and TS gives no signal when it does. DIRECTION: Define a concrete StateHost interface with state: PopupState, navigate: (view: View) => void, popOutToTab(): void, isInTab(): boolean, openVaultTab(hash?: string): void. Make getState/setState generic over keyof PopupState.

[P1] shared/state.ts:20 — module-scope host singleton with no double-registration guard WHY: A second registerHost() call silently overwrites; in tests, the leak from a previous test breaks isolation if beforeEach forgets a reset. DIRECTION: Throw on re-register; export a __resetHostForTests() helper for vitest.

[P2] shared/messages.ts:8587 — base Response is { data?: unknown }; every consumer casts via Extract<Response, { ok: true }> plus data: ... WHY: The pattern works but every call site has a hand-written as ListItemsResponse cast; the discriminated-union narrowing the rest of the file relies on stops at ok: true. DIRECTION: Generic Response<TKind extends Request['type']> mapped from a single MessageMap table, so the response type is inferred at the send site.

[P2] shared/form-affordances/group-autocomplete.ts:26 — list.innerHTML = ...map(g => …).join('') with only replace(/"/g, '&quot;') sanitization WHY: Group names come from user-entered item data. < and > are not escaped; a malicious / mistaken group name could inject markup into the autocomplete list. DIRECTION: document.createElement('option') per group with option.value = g; list.appendChild(option).

[P2] shared/messages.ts:5666 — restore_backup flattens newRemote inline; other multi-field messages factor out a payload type WHY: Inconsistent shape vs sibling messages; harder to reuse the type for the form that posts it. DIRECTION: Extract RestoreBackupPayload and intersect with { type: 'restore_backup' }.

[P3] glyphs.ts vs popup/components — raw glyph literals (, , , , , ) appear inline in item-form.ts, item-list.ts, settings.ts, generator-panel.ts, attachments-disclosure.ts, fields.ts WHY: The whole point of glyphs.ts is the no-inline-emoji rule; partial adoption defeats the audit story. DIRECTION: Add the missing constants (GLYPH_COLLAPSE, GLYPH_EXPAND, GLYPH_ATTACHMENT, GLYPH_REGENERATE, GLYPH_OVERFLOW, GLYPH_DOWNLOAD) and migrate the call sites. Pair with extension/src/shared/__tests__/glyphs.test.ts to lock it in.

[P3] shared/types.ts:82 — TotpKind = 'totp' | 'steam' | { hotp: { counter: number } } WHY: Mixed string/object union forces every consumer to do typeof k === 'string' ? k : k.hotp; a flat discriminated union is cheaper to read. DIRECTION: { kind: 'totp' } | { kind: 'steam' } | { kind: 'hotp'; counter: number } with serde rename if the wire format must stay.

[P3] shared/form-affordances/totp-tools.ts:3946 — void tick() swallows promise rejections WHY: TOTP preview RPC can fail (e.g., decrypt error); the user sees a frozen ticker with no signal. DIRECTION: try { await tick() } catch (e) { renderError(row, e) }.

WASM boundary (JS side)

[P2] extension/src/wasm.d.ts — declarations are hand-written and explicitly require manual sync with crates/relicario-wasm/src/lib.rs WHY: Comment at top of the file says so; this is exactly the kind of contract that drifts when one side adds a new export. (Today, declarations and the Rust signatures are aligned.) DIRECTION: Add a CI check that compares crates/relicario-wasm/pkg/relicario_wasm.d.ts (wasm-pack output) against extension/src/wasm.d.ts, or import the generated .d.ts directly via wasm-pack build --target web and the runtime loader. Note for DEV-B in boundary section.

[P2] extension/src/stubs/relicario_wasm.stub.ts — only 7 of ~25 exports are stubbed; the rest will throw wasm stub: X not mocked WHY: Adding a vitest test that touches a new WASM call needs an ad-hoc mock per test; central stub is incomplete. DIRECTION: Either round out the stub with throwing stubs for the full surface, or provide a mockWasm({ unlock, item_decrypt, ... }) test helper.

Relay tooling

[P1] tools/relay/queue.test.ts:54 — assert.ok(!isRole("dev-c")) fails (verified: bun test → 1 fail) WHY: Uncommitted change added dev-c to the Role union and KNOWN_ROLES set in queue.ts, but the test still asserts the old enum. This is a P1 because it's a real test failure on uncommitted code, blocking a green test run. DIRECTION: Update the test assertion to assert.ok(isRole("dev-c")) and add a negative case (assert.ok(!isRole("dev-d"))).

[P1] tools/relay/start.sh — kitty mode launches Dev-C window? Verify against the script WHY: The 4-role refactor (pm/dev-a/dev-b/dev-c) needs a fourth window in the launcher. Per the subagent's read, start.sh:80 still hardcodes "Dev-B" in user-facing output. DIRECTION: Update the launcher prints to mention all 4 roles and confirm a 4th --type=tab --hold window is opened with the dev-c prompt.

[P2] tools/relay/call.py and tools/relay/call.ts — untracked, no .gitignore policy WHY: Coordination prompts (including this one's "Fallback" section) reference call.py by path — it's load-bearing for the multi-agent flow, not an experiment. Untracked load-bearing files are a coordination footgun (a fresh checkout breaks the fallback). DIRECTION: Track both with a one-line header explaining "MCP-fallback shim for the relay; see docs/superpowers/coordination/...". If either is genuinely scratch, add to .gitignore instead — but pick one.

[P2] tools/relay/queue.ts:2127 — in-memory queue with no TTL, persistence, or cap WHY: A long-running relay accumulates messages indefinitely; if a session sleeps for a day, the queue is the only audit trail and could grow unbounded. DIRECTION: Document the dev-only ephemeral contract at the top of queue.ts, or add a per-role cap (e.g., last 1000 messages).

[P3] tools/relay/server.ts:115127 — makeServer() factory pattern is correct but unexplained WHY: The diff swap from a global mcpServer to per-connection factories is load-bearing for concurrent client isolation, but a future contributor might revert it as "unnecessary complexity". DIRECTION: One-line comment above makeServer: "Per-connection MCP server prevents routing collisions across concurrent SSE clients."

CLI/extension parity gaps

[P2] No equivalent to relicario status — CLI shows pending sync state, ahead/behind, dirty-tree summary; extension surfaces nothing comparable. The vault-tab footer or a sidebar badge would be the natural home. DIRECTION: Add a get_vault_status message returning { ahead: n, behind: n, lastSyncAt: ts, pendingItems: n } and a small status indicator in the vault sidebar.

[P3] No explicit per-attachment detach message — CLI has relicario detach <item> <aid>; extension forces a roundtrip through update_item with the attachments[] mutated client-side. Functional, but it's racy if two devices edit at once. DIRECTION: Add a delete_attachment message that does the surgical remove on the SW side.

[P3] CLI list --tag X filter — extension does tag filtering client-side after list_items; that's fine for the family-vault scale spec but may surprise a learner who reads the CLI side first. DIRECTION: Document the choice in messages.ts near list_items.

Cross-cutting

[P2] Direct chrome.storage.local reads from popup componentssettings-security.ts:112113 and setup.ts:10561062 read storage directly while every other popup module routes via sendMessage. Inconsistency makes the data-flow story split across two paradigms. DIRECTION: Pick one: either every storage read goes through get_settings/get_session_config, or document explicitly that setup and settings-security are SW-bypass code paths for stated reasons.

[P2] bun test is not the project's intended runnerextension/package.json:13 defines test: vitest run, but tools/relay/ uses node:test via bun test. A learner will hit failures running bun test from the repo root because bun doesn't load happy-dom. DIRECTION: Add a top-level README note: "extension uses cd extension && npm run test; relay uses cd tools/relay && bun test."

[P3] Manifest version 0.5.0 — both extension/manifest.json and manifest.firefox.json show 0.5.0, while package.json is also 0.5.0. The roadmap is at v0.5.1-dev; uncommitted bumps to package.json/manifest may be coming. Worth a quick PM check.

File-by-file walk

extension/src/wasm.d.ts and __stubs__/relicario_wasm.stub.ts

Hand-written ambient module declarations mirroring crates/relicario-wasm/src/lib.rs. 25+ exports cover unlock/lock, manifest+item+settings encrypt/decrypt, attachment encrypt/decrypt, ID generation, password/passphrase generation + zxcvbn rate, image-secret embed/extract, TOTP compute, device register/sign/get/clear, recovery-QR. The stub used by vitest covers only 7 exports — every other call throws "not mocked" at test time.

extension/src/shared/

types.ts (286 LOC) is the canonical TS view of the Rust core schema; well-commented mappings to serde. messages.ts (207 LOC) is the discriminated-union message contract and the POPUP_ONLY_TYPES / CONTENT_CALLABLE_TYPES capability sets that the router uses for sender gating. state.ts (62 LOC) is the popup-vs-vault host indirection — currently any-typed. glyphs.ts (39 LOC, recently edited) is the centralized monospace glyph set. color-scheme.ts, error-copy.ts, base32.ts, password-coloring.ts, toast.ts are tight focused utilities. form-affordances/ holds 5 input-behavior wirings (group autocomplete, notes mono toggle, password reveal/strength/QR, TOTP preview/QR, URL fill) plus a tiny index.ts.

extension/src/service-worker/

index.ts is the entry point: WASM load on first message, RouterState construction, session-timer wiring, chrome.runtime.onMessage listener. vault.ts (397 LOC) is the encrypted I/O layer — every WASM call passes the SessionHandle, never a raw key. session.ts is the single-handle store; session-timer.ts implements both inactivity and every_time modes. devices.ts reads/writes .relicario/devices.json and revoked.json. gitea.ts and github.ts mirror each other (Gitea has deploy-key ops; GitHub doesn't); git-host.ts is the abstraction. router/index.ts classifies the sender and dispatches to popup-only.ts (40+ handlers) or content-callable.ts (5 handlers).

extension/src/popup/

popup.ts (entry) wires the host registration, view router, and lock/unlock flow. index.html is a single #popup-app container. styles.css carries the popup theme. components/ holds the visible widgets: unlock, item-list, item-detail, item-form, form-header, fields, field-history, attachments-disclosure, generator-panel, devices, trash, plus the new settings.ts / settings-vault.ts / settings-security.ts left-nav (v0.5.1). components/types/ holds the 7 type-specific item modules — login.ts is the reference, the others (secure-note, identity, card, key, document, totp) are smaller variants.

extension/src/vault/

vault.html (12 LOC) loads vault.css + vault.js. vault.css (~2200 LOC) carries the dark/gold theme + 3-column layout. vault.ts (1027 LOC) orchestrates everything: shell init, hash routing, sidebar, list, drawer, type-picker, form-wrapper, deep-link routing, teardown. components/backup-panel.ts and components/import-panel.ts are vault-tab-only (high-risk operations need fullscreen affordances). Tests: form-wrapper.test.ts, sidebar-glyphs.test.ts.

extension/src/content/

detector.ts finds password fields and injects icons via a MutationObserver. fill.ts receives fill_credentials, re-validates the page hostname, and uses the native-setter trick to set values past React/Vue. capture.ts hooks form submits and shows a closed-Shadow-DOM save prompt. icon.ts renders the in-page icon and credentials picker. shadow.ts is the closed-Shadow-DOM helper. Zero WASM calls; clean boundary.

extension/src/setup/

setup.ts (1220 LOC) is the 6-step (0..5) wizard: mode pick → remote config → image select / passphrase → derive+verify or create+embed → device register → recovery QR. Imports WASM directly. setup-helpers.ts (84 LOC) is a clean utility module (escapeHtml, debounced rate, strength labels). probe.ts (23 LOC) checks for an existing vault on the configured remote.

tools/relay/

server.ts is the MCP server with HTTP/SSE transport (per-connection makeServer()). queue.ts is the in-memory FIFO with Role enum and isRole() guard. queue.test.ts is node:test based (4 pass / 1 fail on uncommitted state). call.py and call.ts are the MCP-fallback shims (untracked). start.sh launches manual / tmux / kitty modes. package.json, tsconfig.json keep the relay self-contained.

CLI/extension parity table

CLI capability Extension equivalent Notes
init --image --output save_setup + setup wizard step 3-new ✓ (parity via setup.ts; see [P1] about routing through SW)
add login/secure_note/identity/card/key/document/totp add_item + types/<kind>.ts form
get <query> [--show] [--copy] get_item + popup detail; clipboard via navigator.clipboard
list [--type] [--group] [--tag] [--trashed] list_items, list_trashed, client-side filter; list_groups for autocomplete
edit <query> [--totp-qr] update_item + wireTotpQr (jsQR lazy import)
history <query> get_field_history + field-history.ts
rm <query> (soft) delete_item
restore <query> restore_item
purge <query> purge_item
trash list / empty list_trashed / purge_all_trash + trash.ts
backup export export_backup + backup-panel.ts
backup restore restore_backup + backup-panel.ts (restore path)
import lastpass <csv> parse_lastpass_csv + import_lastpass_commit + import-panel.ts
attach <query> <file> upload_attachment
attachments <query> AttachmentRef[] already inside get_item ✓ (no separate message; same data)
extract <query> <aid> download_attachment
detach <query> <aid> (no message) — done via update_item with mutated attachments[] partial / ✗
generate --length / --bip39 … generate_password / generate_passphrase + generator-panel.ts
settings trash-retention / history-retention / attachment-cap / generator-defaults get_vault_settings / update_vault_settings + settings-vault.ts
sync sync
status (no message) ✗ — gap
lock lock
completions <shell> N/A (CLI-only) n/a
rate <passphrase> rate_passphrase (zxcvbn gate)
device add / revoke (+ list) add_device / register_this_device / revoke_device / list_devices / list_revoked + devices.ts
recovery-qr generate / unwrap generate_recovery_qr / unwrap_recovery_qr + settings-security.ts
(browser-only autofill) get_autofill_candidates, get_credentials, check_credential, blacklist_site, capture_save_login, fill_credentials, ack_autofill_origin, get_blacklist, remove_blacklist, get_active_tab_url extension-only (no CLI counterpart, by design)
(browser-only device UX) get_settings / update_settings (DeviceSettings: captureEnabled, captureStyle) extension-only

Summary: 22/23 CLI capabilities have a clean extension path; status is the one true gap, and detach is partial. No "CLI first, extension follow-up" violations under this lens.

Boundary notes for DEV-B

  1. extension/src/wasm.d.ts is hand-maintained — every change to crates/relicario-wasm/src/lib.rs's #[wasm_bindgen] surface must be mirrored manually. Worth a CI guardrail comparing the wasm-packgenerated .d.ts against this file. (See [P2] in WASM boundary.)

  2. The session handle is opaque on the JS side (SessionHandle.value: number, free()). DEV-B owns the Rust-side lifecycle: confirm that double-free() from JS is a no-op rather than a panic, since service-worker/session.ts:26 currently swallows free errors silently. If the Rust side could panic on double-free, the silent swallow becomes a crash mask.

  3. attachment_encrypt(handle, plaintext, max_bytes: bigint) — the JS side passes BigInt for max_bytes. Confirm the Rust binding accepts u64 and that the conversion is loss-free. The extension is going to push this with γ₁ enforcement (per project memory).

  4. register_device(name) returns { signing_public_key, deploy_public_key } as a plain object (not a class). Setup wizard relies on both being hex strings (device.public_key lookup paths). Confirm the device key types stay strings on the Rust side rather than ever moving to Uint8Array.

  5. generate_recovery_qr(handle, passphrase) → string and unwrap_recovery_qr(payload_b64, passphrase) → Uint8Array — the QR payload format is a contract between this surface and the CLI's recovery-qr subcommand. If DEV-B is reviewing recovery-QR end-to-end, confirm that re-deriving from a recovery QR produces the same master_key regardless of which side (CLI vs extension) generated it.

  6. extract_image_secret(image_bytes) → Uint8Array and embed_image_secret(carrier, secret) → Uint8Array — the central-embed DCT scheme has MAX_DIMENSION and QUANT_STEP = 50.0 constants on the Rust side. The setup wizard does no client-side dimension check before passing the image; if a >MAX_DIMENSION JPEG is selected, the failure message bubbles up generically. DEV-B may want a more specific Rust-side error variant the extension can re-render.

Beginner-friendliness assessment (TS side)

Reading order recommendation: start with extension/src/shared/messages.ts and types.ts — they tell you the entire vocabulary in 500 lines. Then service-worker/router/index.ts to see how messages get routed. Then pick one vertical to follow end-to-end: popup/components/types/login.tsservice-worker/router/popup-only.ts (the add_item/update_item handlers) → service-worker/vault.ts. After that, content/detector.ts + content/fill.ts + the corresponding content-callable.ts handlers cover the autofill story.

Trip wires: the two oversized files (vault.ts 1027 LOC, setup.ts 1220 LOC) are the steepest cliffs. A learner who opens setup.ts first will see WASM imported directly and conclude that's the pattern — it isn't; that file is the exception. Adding a short EXTENSION_ARCH.md (or a comment block at the top of setup.ts) noting "WASM is loaded directly here only because vault creation predates the SW; everywhere else, route through chrome.runtime.sendMessage" would save half a day.

Strongest learning surfaces: the discriminated-union message contract, the per-type item form modules (small, parallel, easy to diff), and service-worker/router/router.test.ts. The shared form-affordances are also a model of pure-function wiring with explicit teardown — once those patterns click, the rest of the popup is "more of the same."

Weakest learning surface: shared/state.ts — it's the bridge between popup and vault tab, but the any typing tells you nothing about what flows through it. Tightening this is a high-leverage change.

Uncommitted-state read

The working tree has substantial uncommitted changes. Architecturally relevant:

  • extension/src/vault/vault.ts (+151/99) and vault.css (+238/99) — appear to be active work on the form wrapper sticky bar, drawer field grid, and gold-rebrand color palette (Phase 2B). Treat these as in flight, ignore for this review — they don't change the architectural shape, only the visual surface.
  • extension/src/shared/glyphs.ts (+/2) and __tests__/glyphs.test.ts (+/2) — small additions, likely a new glyph constant. Doesn't affect the [P3] glyph adoption finding (the inline-literal call sites would still need migration).
  • extension/manifest.json and manifest.firefox.json (+/2) — likely a version bump in flight; PM should decide whether v0.5.1-dev gets a synced bump in package.json too.
  • tools/relay/queue.ts, server.ts, start.sh (modified) + call.py, call.ts (untracked) — these are architecturally relevant: the dev-c role expansion is real, and call.py/call.ts are documented-but-untracked fallback shims. The broken test (queue.test.ts:54) is the P1 the rest of this review flags. Do not ignore.
  • Cargo.lock, crates/*/Cargo.toml — out of scope; flag to DEV-A/DEV-B if not already on their list.
  • .gitea_env_vars (untracked) — name suggests local credentials; should be .gitignored if not already (PM check).
  • docs/superpowers/coordination/v0.5.1-* and architecture-review-* — coordination prompt evolution; not architectural.

My call: vault/vault.css/glyphs/manifest changes are in-flight and shouldn't change findings. The relay changes ARE architecturally on-the-table because the new dev-c role + factory pattern are visible in the running review process. The relay test failure is treated as a P1 because it blocks bun test from going green and the kickoff prompt explicitly says "Review is not gated on green; it's gated on understanding" — but this is one cheap fix away from a green test run, and a learner running bun test first will assume the codebase is broken.