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