Files
relicario/extension/ARCHITECTURE.md
adlee-was-taken 7c7efa7c43 release: v0.7.0 — extension restructure complete (Plan C Phases 3/4/6)
Completes the extension restructure begun in v0.6.0. Phases 3 (setup
wizard SW migration + step registry), 4 (vault.ts split + vault_locked
lift), and 6 (get_vault_status + sidebar status indicator) all merged to
main (9df2fee, 3b8368d, 397cc78) via three parallel worktree streams.

This commit is the release-prep wrap-up:
- Version bump to v0.7.0 across the three relicario crates + Cargo.lock,
  extension/package.json, and both extension manifests (the manifests had
  lagged at 0.5.0 — corrected here).
- CHANGELOG.md v0.7.0 entry.
- STATUS.md: extension restructure moved to shipped; Phases 3/4/6 landing
  section added.
- ROADMAP.md: v0.7.0 row added; Up-next now command palette.
- extension/ARCHITECTURE.md: all three phases integrated (new vault-*
  modules, setup-steps.ts, get_vault_status protocol + status indicator,
  vault_locked lift, git-host sync cache).
- Plan completion checkboxes ticked.

Task 7.1 verification: done-criteria sweep all green; 423/423 vitest;
build:all clean (only the pre-existing 4MB WASM size warning).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 22:50:17 -04:00

947 lines
51 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Architecture: relicario extension
> **Audience:** contributors editing the browser extension. This doc owns the bundle structure (popup, vault tab, background SW, content scripts), the SW ↔ popup message contract, the component / pane architecture, routing, and the build pipeline. **Does NOT own:** WASM crypto internals (see [../crates/relicario-core/ARCHITECTURE.md](../crates/relicario-core/ARCHITECTURE.md)), wire formats (see [../docs/FORMATS.md](../docs/FORMATS.md)), or threat model (see [../docs/SECURITY.md](../docs/SECURITY.md)).
## What this codebase is for
The extension is the browser-resident face of relicario: the same vault the
`relicario` CLI manages, but rendered as Chrome MV3 / Firefox WebExtension
UI plus a content-script autofill surface. It does not invent its own data
model or crypto — `crates/relicario-core` compiled to WASM
(`extension/wasm/relicario_wasm.js` + `relicario_wasm_bg.wasm`) holds the
KDF, AEAD, manifest/item/settings (de)serialization, password generators,
TOTP, steganography, and field-history routines. The extension is, above
that core, three things: a message router and crypto fortress (the service
worker), a small UI shell that runs in the popup and a fullscreen vault
tab, and a content script that detects login forms and shuttles
already-resolved credentials into them.
Design intent is CLI parity. Every capability in the CLI is reachable from
the extension; the popup is the everyday surface (unlock, search, fill,
TOTP, generator, capture); heavy workflows (setup wizard, vault-level
settings, trash, devices, future backup/restore and importer) live in the
fullscreen vault tab so they have screen real estate without the popup's
600px constraint. Both Chrome MV3 and Firefox WebExtension are first-class
build targets — `manifest.json` (Chrome) and `manifest.firefox.json`
(Firefox) differ only in the manifest envelope; the same TypeScript bundles
back both.
## Bundle structure
Webpack produces five entry points in the Chrome build, four in the
Firefox build (the vault tab is Chrome-only for the moment). Verify in
`extension/webpack.config.js` and `extension/webpack.firefox.config.js`.
| Bundle | Entry | Sandbox | Has WASM access? |
| ------------------ | -------------------------------------- | ------------------ | --------------------- |
| `service-worker` | `src/service-worker/index.ts` | extension SW / bg | yes — initialized lazily on first message |
| `popup` | `src/popup/popup.ts` | popup.html | no — goes through SW |
| `vault` | `src/vault/vault.ts` (Chrome only) | vault.html (tab) | no — goes through SW |
| `setup` | `src/setup/setup.ts` | setup.html (tab) | no — goes through SW (`create_vault`/`attach_vault`) |
| `content` | `src/content/detector.ts` | host page (top frame only by router check) | no |
### What each bundle owns
- **service-worker** — the only place a vault `SessionHandle` and
decrypted `Manifest` ever live. Initializes WASM lazily on the first
message (`service-worker/index.ts:20`). Every other bundle goes through
this bundle for crypto. It also implements both `GitHost`s, owns the
inactivity timer (`session-timer.ts`), and reads/writes
`chrome.storage.local` for device-local state.
- **popup** — small MV3 popup at `popup.html`. Locked-or-list state
machine, search/sort/edit, attachments + TOTP. Cannot access
`SessionHandle` directly — every operation is a `chrome.runtime.sendMessage`
to the SW.
- **vault** — fullscreen "desktop-like" sidebar+pane shell. Imports the
same component renderers as the popup via the `StateHost` service
locator (see Cross-cutting). The vault tab is Chrome-only because
Firefox MV3 still treats `chrome.tabs.create` to extension pages
differently and the popup pop-out wasn't worth the cost yet.
- **setup** — first-run wizard. Lives in its own page (`setup.html`)
rather than the popup so the carrier-image upload + zxcvbn meter +
remote-host probing all have room. Loads WASM directly because it must
do crypto before any extension config exists for the SW to read
(`setup.ts:27`).
- **content** — injected into every page (`<all_urls>`) at
`document_idle`. Detects login forms, paints a small "id" icon, runs
the autofill picker / TOFU hint inside closed Shadow DOMs, and prompts
on form submit to save or update credentials. Cannot decrypt — the
SW always returns already-resolved `{ username, password }` payloads.
### Output trees
`webpack.config.js` writes to `dist/` and copies both
`relicario_wasm_bg.wasm` and `relicario_wasm.js` next to the bundles so
the SW's `chrome.runtime.getURL('relicario_wasm_bg.wasm')` resolves and
the setup page's dynamic `import('../relicario_wasm.js')` works. The
Firefox config writes to `dist-firefox/`, swaps in the Firefox manifest
under the name `manifest.json`, and skips the vault entry. Both pin
`experiments.asyncWebAssembly: true`. The Chrome content_security_policy
keeps `'wasm-unsafe-eval'` for extension pages (necessary for the WASM
init in setup.ts and the SW).
### WASM module
The wasm-pack output lives at `extension/wasm/`. Built from
`crates/relicario-wasm` (see project-root `CLAUDE.md`). The exported
surface — `unlock`, `lock`, `manifest_encrypt/decrypt`, `item_encrypt/decrypt`,
`settings_encrypt/decrypt`, `attachment_encrypt/decrypt`,
`embed_image_secret`, `extract_image_secret`, `totp_compute`, the
generators, `rate_passphrase`, `generate_device_keypair`, and the opaque
`SessionHandle` class — is enumerated in
`extension/wasm/relicario_wasm.d.ts`. Two patterns matter:
1. The SW initializes via `initSync(new WebAssembly.Module(bytes))` when
running as a real service worker (no top-level await), and the
default async `initDefault(url)` path otherwise (jest-style harness or
fallback). See `service-worker/index.ts:24-35`.
2. Setup uses `import(/* webpackIgnore: true */ '../relicario_wasm.js')`
so webpack doesn't try to inline the runtime — it's served as a flat
sibling file (`setup.ts:30-33`).
## Module map
### `src/popup/`
- `popup.ts` — entry. Owns the popup state machine (`View` enum:
`locked | list | detail | add | edit | settings | settings-vault | trash
| devices | field-history`), captures the active tab at popup-open for
TOCTOU-safe fill (`popup.ts:230-233`), translates cryptic backend errors
to user-readable strings (`humanizeError`, `popup.ts:135-160`), and
registers itself as the shared `StateHost`.
- `index.html` / `styles.css` — markup + dark monospace theme.
### `src/popup/components/`
The popup UI. Each module exports a `renderXxx(app: HTMLElement)` and,
where it owns disposable resources (timers, DOM listeners), a
`teardown()` that the dispatcher in `popup.ts` and `vault.ts` calls
before any new render.
- `unlock.ts` — passphrase input + Enter-to-submit. Calls `unlock` SW
message; on success, fetches `list_items` and navigates to `list`.
- `item-list.ts` — toolbar (search/new/sync/lock/settings) + virtualized-ish
row list. Owns the keyboard navigation handler (`/`, `+`, arrow keys,
Enter, Esc) and the settings-picker popover that splits "device
settings" from "vault settings".
- `item-detail.ts` / `item-form.ts` — type dispatchers; each delegates to
one of `components/types/{login,secure-note,identity,card,key,document,totp}.ts`.
- `components/types/*.ts` — per-item-type detail+form pairs. Each exports
`renderDetail`, `renderForm`, and `teardown`. Uses the shared `fields.ts`
primitives (concealed rows, signature blocks, sections editor) and the
`attachments-disclosure.ts` widget.
- `fields.ts` — pure HTML-string primitives (`renderRow`,
`renderConcealedRow`, `renderSignatureBlock`, `renderSections*`)
consumed by every type. Mounting is the caller's job; after mount,
`wireFieldHandlers(scope)` binds the reveal/copy click handlers once.
- `form-header.ts` — extracted `renderFormHeader({ title, subtitle, ...})`
helper used by every type's `renderForm` (shared `.form-header` CSS,
static "esc to cancel" subtitle in fullscreen mode). Takes an options
object so callers don't need to remember positional argument order.
- `generator-panel.ts` — inline password / passphrase generator. Mounts
inside any host element; round-trips knob changes through the SW's
`generate_password` / `generate_passphrase` (debounced 150ms). Has two
action-row modes: fill-field (cancel + use) and configure-defaults
(save-as-default).
- `attachments-disclosure.ts` — the per-item attachment list (edit/view
modes). Image-MIME rows lazy-load thumbnails as object URLs; teardown
revokes them. Per-item-count and per-vault soft/hard size caps are
enforced here client-side; the SW also enforces per-attachment max
bytes via WASM (defense in depth — see
`router/popup-only.ts:223-228`).
- `settings.ts` — device-local UX settings (capture toggle, prompt
style), trash/devices/sync-now buttons, blacklist editor. Revamped in
commit `299e7db` to split synced (vault) vs local (device) sections
and surface the per-device session-timeout UI (radio + minutes input).
- `settings-vault.ts` — vault-wide settings (retention, generator
defaults, autofill origin acks). Reads/writes via the SW's
`get_vault_settings` / `update_vault_settings`.
- `settings-security.ts` — security sub-pane of the vault-tab settings
shell: three-state recovery QR display (hidden → revealed → printed)
and an inline devices summary. Mounted from the settings left-nav.
Restored from main in commit `8baef5b` after the Stream C real
implementation landed.
- `trash.ts` — soft-delete listing with per-item purge countdown
(via `shared/relative-time.ts::daysUntilPurge`), glyph restore (`⤺`),
and a bottom-right destructive "empty trash" button.
- `devices.ts` — device list. Three-line rhythm per row: name + revoke
glyph (`⊘` with inline two-step confirm — no browser modal), full
SHA256 fingerprint (computed in-popup via `shared/ssh-fingerprint.ts`
— no SW round-trip), `added X ago · by Y` meta. Inline "register this
device" banner shown when current device is not in the list.
- `field-history.ts` — audit-log of value changes on a single item;
driven by the SW's `get_field_history` which calls into WASM
`get_field_history(item_json)`. Section header per field
(`PASSWORD · N entries`); reveal/copy via explicit glyph buttons
(decoupled from row click); revealed values colorized via
`shared/password-coloring.ts`.
- `item-history-index.ts` — top-level history pane: iterates the
manifest and fans out one `get_field_history` per item, lists those
with ≥1 entry sorted by recency. Click drills into `field-history.ts`
for the per-item view. Reachable via `#history` (the sidebar slot)
and from the URL.
### `src/vault/`
- `vault.ts` (194 lines) — fullscreen tab entry, now a thin
routing + state shell after the Phase 4 split. Registers itself as
the StateHost so all `popup/components/*` renderers run unchanged,
maintains its own `selectedItem` cache so hash navigation between
already-loaded items doesn't refetch, and delegates DOM scaffolding,
navigation, list/drawer/form rendering, and route dispatch to the
sibling modules below. The hash-route set is
`#detail/<id>`, `#add/<type>`, `#trash`, `#devices`, `#settings`,
`#settings-vault`, `#history`, `#history/<id>`, `#backup`, `#import`.
- `vault-context.ts` — the `VaultController` contract plus the shared
types and pure helpers the split modules depend on. Added so the
split is acyclic: the rendering modules import the controller
interface from here rather than from `vault.ts`.
- `vault-router.ts` — hash routing + pane dispatch + data loading,
extracted to keep `vault.ts` ≤250 LOC. Owns `parseHash`; legacy
`#field-history/<id>` URLs are normalized to `#history/<id>` here, but
the internal view value stays `'field-history'` so the per-item pane
renders unchanged.
- `vault-shell.ts` — DOM scaffolding, color-scheme apply, and the
`onMessage` wiring for the tab.
- `vault-sidebar.ts` — sidebar categories nav, 80ms-debounced search
(`SEARCH_DEBOUNCE_MS`), and the bottom-nav
(`+ new item · ▦ trash · ⌬ devices · ⚙ settings · ◷ history · ⏻ lock`).
Also owns the footer: a `#vault-status-slot` plus a manual `↻` refresh
button (`GLYPH_REFRESH`). `wireSidebar` calls `refreshStatus()` once on
mount and again on the button's click — sending `get_vault_status` via
`ctx.sendMessage` and rendering the result into the slot through
`vault-status.ts`. There is **no timer polling**: the indicator only
refreshes on mount + explicit button press, matching the spec's
no-network-without-user-intent discipline (sync is user-initiated).
- `vault-status.ts` — sidebar-footer sync indicator renderer.
`renderStatusIndicator(el, status)` is pure DOM: it renders, by
priority, `N pending` / `N ahead` / `N behind`, falling back to
`in sync`, plus a `last sync <relativeTime>` / `never synced` line.
Reuses `shared/glyphs.ts` (`GLYPH_PENDING`/`AHEAD`/`BEHIND`/`SYNCED`)
and `shared/relative-time.ts`. `VaultStatus` is an alias of
`GetVaultStatusResponse['data']`, so the renderer's input shape is
single-sourced from the message contract and can't drift from the SW
handler.
- `vault-list.ts` — the list pane and its row rendering.
- `vault-drawer.ts` — drawer open/close/render plus
`ensureDrawerClosedForRoute`, which closes the drawer on any
non-list navigation.
- `vault-form-wrapper.ts``renderFormWrapped` plus the sticky bar and
header that wrap form panes.
- `vault.html` / `vault.css` — sidebar + pane layout.
### `src/vault/components/`
Vault-tab-only panes (popup is too small for these workflows). Each
exports `render…(app)` and a `teardown()`, same convention as
`popup/components/*`.
- `backup-panel.ts``.relbak` export / restore UI. Routable as
`#backup` (vault.ts case at :167). Drives the SW's backup handlers;
the actual tar packing happens in `relicario-core` via WASM exports.
- `import-panel.ts` — LastPass CSV importer surface. Routable as
`#import` (vault.ts case at :168). Parses CSV client-side and pipes
parsed rows through `add_item` SW messages.
### `src/setup/`
- `setup.ts` (58 lines) — a thin UI-only shell after the Phase 3
split: the render loop + progress track + boot + re-exports. No longer
imports `relicario-wasm`; the wizard now drives vault creation/attach
through the SW. Binds `clearWizardState` to
`window.addEventListener('beforeunload', clearWizardState)`
(`setup.ts:53`) and also calls it on `goto('mode')` (`setup.ts:44`).
- `setup-steps.ts` (extracted in Phase 3) — the setup step registry +
wizard state + `clearWizardState` + `finishSetup`. One-directional
import (`setup.ts``setup-steps.ts`, no cycle). Crypto orchestration
no longer lives in the wizard: the device step (where `deviceName`
exists) fires `create_vault` and `attach_vault` SW messages instead of
calling WASM directly. State-coupled `updateStrengthUi` stays here
because it walks the live wizard state.
- `setup-helpers.ts` (84 lines, extracted in commit `f79a67b`) — pure
helpers: `escapeHtml`, `ratePassphrase`, `scheduleRate` (150ms
debounced zxcvbn round-trip), `STRENGTH_LABELS`, `entropyText`, the
`Strength` interface.
- `probe.ts` — best-effort detection of an existing vault on the remote
(any of `.relicario/salt`, `.relicario/params.json`, or `manifest.enc`
`exists: true`). Drives the warning banner that disambiguates "new
vault" vs "attach this device".
### `src/content/`
- `detector.ts` — entry. Finds password fields (skipping <20×10px
honeypots), associates each with a username field via a five-priority
cascade (autocomplete=username → autocomplete=email → type=email →
name/id pattern → preceding visible text input), injects the
`id`-icon, and starts a MutationObserver to rescan on SPA navigation.
- `icon.ts` — the in-page autofill icon and candidate picker /
TOFU-ack hint. Each overlay mounts in its own closed Shadow DOM
(`shadow.ts`). On icon click → `get_autofill_candidates`; one
candidate auto-fills (if origin is acked), multiple candidates show
the picker.
- `fill.ts` — listener for the SW-forwarded `fill_credentials` message.
Re-checks `location.href`'s hostname against the SW-provided
`expectedHost` (the second of two TOCTOU gates) and writes values
using the native HTMLInputElement setter trick so React/Vue pick up
the change.
- `capture.ts` — submit handler. Runs `check_credential` to ask whether
the (host, username, password) tuple is already in the vault; if not,
shows a save-or-update prompt in a closed Shadow DOM. The "Save"
button issues `capture_save_login` (content-callable); the SW figures
out add-vs-update and binds the new item to the sender's origin.
- `shadow.ts` — closed-mode `attachShadow` host helper. Comments here
enforce the "never innerHTML, never insertAdjacentHTML" rule —
page-supplied strings (hostname, username) only ever land via
`textContent`.
### `src/service-worker/`
- `index.ts` — thin entry. Wires the WASM init, owns the shared
`RouterState`, plumbs `chrome.runtime.onMessage` and
`chrome.commands.onCommand` (the `open-vault` keyboard command),
resets the inactivity timer on every popup-class message, and
broadcasts a `session_expired` notification when the timer fires.
- `router/index.ts` — single classify-and-dispatch function. Determines
whether a sender is popup/vault tab, setup tab, content top-frame, or
none-of-the-above (`router/index.ts:39-43`); routes to
`popup-only.ts` or `content-callable.ts`; rejects everything else
with `unauthorized_sender`. Setup tab is allowed exactly three
popup-only messages (`SETUP_ALLOWED`, `router/index.ts:23-27`):
`save_setup`, `rate_passphrase`, `is_unlocked`.
- `router/popup-only.ts` — handler match arms for every
`POPUP_ONLY_TYPES` message. The mutation-heavy ones (`add_item`,
`update_item`, `delete_item`) pull `SessionHandle` from
`session.getCurrent()`, load via `vault.fetchAndDecrypt*`, mutate,
re-encrypt, and `gitHost.writeFile`. `fill_credentials` lives here
with its own captured-tab verification (see Key flows). New in
commit `a7dbf35`: `register_this_device`. Phase 3 added
`create_vault` and `attach_vault` (full SW-side vault
creation/attach: embed/unlock, encrypt+push, `register_device` +
`addDevice`, persist config+image, `session.setCurrent`; the failure
path locks and frees the handle). The `lock` handler now also nulls
`state.gitHost` (symmetric with session-expiry) so the status cache
can't go stale across a lock→unlock. Phase 6 added `get_vault_status`
(popup-only, read-only) — returns the cached sync summary
`{ ahead, behind, lastSyncAt, pendingItems }` with **no network
call**. `ahead`/`behind`/`lastSyncAt` are read straight off
`state.gitHost` (populated by the `sync` handler, which records
`lastSyncAt = Math.floor(Date.now()/1000)` — unix **seconds** — after
a successful manifest fetch). `pendingItems` is a live count of active
(non-trashed) manifest entries via `vault.listItems(manifest).length`.
`ahead`/`behind` are structurally always `0` in the extension (it
writes straight to the host via the Contents REST API; there is no
local commit graph) and exist for parity with `relicario status`.
- `router/content-callable.ts` — handler match arms for every
`CONTENT_CALLABLE_TYPES` message. Origin always derived from
`sender.tab.url`, never from message fields. `capture_save_login`
has a defense-in-depth check that the existing item's `core.url`
hostname matches the sender's hostname before mutating, in case
manifest `icon_hint` has drifted from the underlying URL.
- `vault.ts` — typed-item vault operations. Crypto goes through the
ambient `wasm` module set at SW init by `setWasm`; nothing here
touches the master key directly. Includes
`findByHostname(manifest, hostname)` (the autofill matcher — coarse:
no www-stripping, no public-suffix), trash helpers
(`listTrashed`, `restoreItem`, `purgeItem`, `purgeAllTrash`), and
attachment helpers (`addAttachmentToItem`, `removeAttachmentsFromItem`,
with manifest summary sync). Now also includes the
`create_vault`/`attach_vault` orchestration handlers (Phase 3) and
`handleGetVaultStatus(state)` (Phase 6) — synchronous, no network;
returns the cached `{ ahead, behind, lastSyncAt, pendingItems }`. Its
`Pick<GitHost,'lastSyncAt'|'ahead'|'behind'>`-typed param both breaks
the `PopupState` import cycle and structurally forbids it from making
a network call.
- `session.ts` — single module-scope `SessionHandle | null`. α assumes
one vault per install. Multi-vault would replace this with a `Map`
keyed by vault id.
- `session-timer.ts` — inactivity timer. Modes: `inactivity` (N
minutes since last popup-class message) and `every_time` (no timer;
rely on popup-close to clear). The router resets the timer for every
message that is NOT in `CONTENT_CALLABLE_TYPES`
(`service-worker/index.ts:76-78`).
- `git-host.ts` — abstract interface (`readFile`, `writeFile`,
`writeFileCreateOnly`, `deleteFile`, `listDir`, `lastCommit`,
`putBlob`, `getBlob`, `deleteBlob`) and the `createGitHost` factory.
`BLOB_THRESHOLD_BYTES = 900*1024` is the cutover point at which
attachment writes switch from the Contents API to the Git Data API.
The `GitHost` interface also carries cached sync metadata —
`lastSyncAt: number | null` (unix seconds), `ahead: number`,
`behind: number` — initialized to `null`/`0`/`0` in both `GiteaHost`
and `GitHubHost`. The cache rides the gitHost lifecycle: created on
unlock and cleared whenever `state.gitHost` is nulled — on
session-timer expiry (`index.ts`) **and** on the explicit `lock`
message handler (`popup-only.ts`), which now nulls `state.gitHost`
symmetrically so a lock→unlock cycle can't surface a stale
`lastSyncAt`.
- `gitea.ts` / `github.ts` — the two GitHost implementations. Both use
the host's Contents API for files under threshold, and Git Data API
(blobs + tree + commit) for large attachment uploads. Auth differs
(Gitea: `token X`, GitHub: `Bearer X`). Both pre-check existence on
write to decide between create vs update; `writeFileCreateOnly`
refuses to clobber.
- `devices.ts` — read-modify-write helpers around
`.relicario/devices.json`. `addDevice` rejects duplicates by name;
`revokeDevice` rejects unknown names.
### `src/shared/`
- `messages.ts` — every `Request` and `Response` shape, plus the
capability sets `POPUP_ONLY_TYPES` and `CONTENT_CALLABLE_TYPES` the
router consults. Adding a new SW message requires (a) adding it to
the `PopupMessage` or `ContentMessage` union, AND (b) adding it to
the matching capability set, AND (c) adding a handler arm. Forget any
one of these and you get a silent rejection at runtime.
- `state.ts``StateHost` interface + module-scope singleton. Both
`popup.ts` and `vault.ts` register themselves on boot. All
`popup/components/*` import from here, never from popup.ts directly,
so the same render code runs in both bundles. Its `sendMessage`
wrapper intercepts `vault_locked` responses (lifted out of `vault.ts`
in Phase 4, so the intercept now applies uniformly to both bundles).
- `types.ts` — TypeScript mirrors of the Rust core's serde shapes:
`Item`, `ItemCore` (internally-tagged on `type`), `Field` and
`FieldValue` (adjacently-tagged on `kind` / `value`), `Manifest`,
`ManifestEntry`, `VaultSettings`, `GeneratorRequest`, etc. Hand-kept
in sync with `crates/relicario-core/src/{item.rs,item_types/,settings.rs}`.
- `base32.ts` — RFC 4648 base32 encode/decode for TOTP secrets. (Pure
TS; secrets never leave WASM after unlock anyway, but we store user
input as bytes via `base32Decode`.)
## Invariants & contracts
These are load-bearing rules. Some are enforced by code, some are
enforced by code-review and convention; both are listed.
- **Master key never crosses the WASM boundary.** It lives inside WASM
linear memory wrapped in `Zeroizing<[u8;32]>` (Rust side); JS holds
only the opaque `SessionHandle` (a `u32` index). `wasm.lock(handle)`
zeroes the slot; `session.clearCurrent()` calls it
(`session.ts:24-28`). No popup, vault, content, or setup code can
observe the key bytes.
- **Single SessionHandle per SW instance.** `session.ts` is module-scope.
α assumes one vault per install (deliberate; not an oversight).
- **Sender check on every SW message.** `router/index.ts:39-66` builds
`isPopup | isSetup | isContent` from `sender.url` and `sender.tab` /
`sender.frameId` / `sender.id`, then dispatches:
- popup-only types accept `popup.html` OR `vault.html` senders
(commit `a7dbf35` added `vault.html`).
- popup-only types ALSO accept `setup.html` for exactly three
messages: `save_setup`, `rate_passphrase`, `is_unlocked`
(`router/index.ts:23-27`).
- content-callable types require `sender.tab` defined,
`sender.frameId === 0` (top frame), AND
`sender.id === chrome.runtime.id` (same extension —
`router.test.ts:373-384` covers the third clause). Subframes and
other extensions are rejected.
- everything else: `unauthorized_sender`.
- **Capability sets are exhaustive.** Every message must appear in
exactly one of `POPUP_ONLY_TYPES` or `CONTENT_CALLABLE_TYPES`
(`shared/messages.ts:144-161`). A message in the union but in
neither set falls through to `unknown_message_type` and is silently
rejected. This is the easy mistake to make when adding a new
message type.
- **Content scripts cannot decrypt.** All paths from content end with
the SW returning either an opaque manifest projection (titles,
hostnames) or a fully-resolved `{ username, password }`. There is no
WASM in the content bundle and no pathway for content to obtain
ciphertext.
- **Origin TOFU on autofill.** Before returning credentials to a
content script, the SW checks
`VaultSettings.autofill_origin_acks[hostname]`
(`router/content-callable.ts:46-51`). Missing → return
`{ requires_ack: true, hostname }` so the icon shows the TOFU hint
and the user must open the popup to ack. The ack is recorded in
vault settings (encrypted, syncs across devices), keyed by hostname,
to a unix timestamp.
- **Two-stage TOCTOU close on `fill_credentials`.** The popup snapshots
`(capturedTabId, capturedUrl)` at popup-open (`popup.ts:230-233`).
The SW re-fetches the tab on fill, compares hostnames against the
snapshot AND against the item's own `core.url` hostname
(`router/popup-only.ts:397-410`), and forwards `expectedHost` along
with the credentials. The content script's fill listener
(`content/fill.ts:32-43`) re-checks `location.href`'s hostname
against `expectedHost` before typing — covering the gap between
`chrome.tabs.get` and `chrome.tabs.sendMessage`.
- **Origin binding on capture.** `capture_save_login` derives the
hostname from `sender.tab.url` only — never from message fields.
When updating an existing entry, the SW re-checks the entry's
`core.url` hostname against the sender's hostname; mismatch →
`origin_mismatch` (`router/content-callable.ts:113-117`). Otherwise a
drifted manifest `icon_hint` could rebind a password to the wrong
origin.
- **`writeFileCreateOnly` cannot clobber.** Setup uses it for the four
init artifacts (`.relicario/salt`, `.relicario/params.json`,
`manifest.enc`, `settings.enc`). If any exists, it throws — the
wizard catches and tells the user to switch to attach mode
(`setup.ts:888-893`).
- **AEAD failure surfaces as "wrong passphrase".** The setup attach
flow stages errors and rewrites failures during `derive session
handle` or `decrypt manifest` to the deliberately-ambiguous
"Could not decrypt vault — wrong passphrase or reference image."
(`setup.ts:396-401`). The popup `humanizeError` does the same for
`vault_locked`, `origin_mismatch`, `unauthorized_sender`, and
URL parse errors.
- **Inactivity timer modes.** `inactivity` resets on every
popup/vault/setup message (NOT on content messages —
`service-worker/index.ts:76-78`); fires after `minutes` of idle.
`every_time` has no timer; the popup-close handler is expected to
clear (handled implicitly because the popup re-checks `is_unlocked`
on each open).
- **Manifest mutation requires both writes.** Any item-changing handler
(`add_item`, `update_item`, `delete_item`, `restore_item`,
`purge_item`, `capture_save_login`, the attachment paths) writes
BOTH `items/<id>.enc` AND `manifest.enc` (the manifest entry is
derived via the local `itemToManifestEntry`). Forgetting the second
write breaks list/search/autofill until the next sync round-trip.
- **Both manifests stay in sync.** `manifest.json` (Chrome) and
`manifest.firefox.json` declare the same permissions, host
permissions, content scripts, and CSP. Drift is a portability bug.
## Key flows
### First-run setup (new vault)
`setup.ts`, six steps. WASM is loaded at the top of step 3.
1. **Step 0** — mode picker. `state.mode``{ 'new', 'attach' }`.
2. **Step 1** — host type (Gitea / GitHub) + per-host instructions.
3. **Step 2** — host URL + repo path + API token. Click "test
connection" → `gitHost.listDir('')` succeeds → `probeVault(host)`
detects existing vault. Banner disambiguates: empty repo + new
mode = OK; populated repo + new mode = warn (would clobber);
empty repo + attach mode = warn (no vault to attach to).
4. **Step 3 (new branch)** — carrier JPEG + passphrase + confirm.
zxcvbn meter via SW `rate_passphrase` on a 150ms debounce
(`setup-helpers.ts:54-63`). Submit gate requires score ≥ 3 AND
passphrases match.
1. `crypto.getRandomValues(imageSecret)` — fresh 32-byte secret.
2. `wasm.embed_image_secret(carrierBytes, imageSecret)` → reference
JPEG bytes (DCT-embedded via central-embed; see core spec).
3. `crypto.getRandomValues(salt)` — fresh 32-byte vault salt.
4. `wasm.unlock(passphrase, referenceJpeg, salt, paramsJson)`
Argon2id derives master key inside WASM; returns `SessionHandle`.
Note: `unlock` takes JPEG bytes, not the raw 32-byte secret —
the WASM side extracts internally.
5. Encrypt empty manifest + default settings. `writeFileCreateOnly`
pushes salt, params, manifest.enc, settings.enc — refuses to
clobber.
6. `wasm.lock(handle)` — release. Advance to step 4.
5. **Step 3 (attach branch)** — reference JPEG + passphrase. Fetches
salt + params + ciphertext, runs `wasm.unlock` and
`wasm.manifest_decrypt`. AEAD failure → "wrong passphrase or
reference image". Success → save handle in
`state.verifiedHandle`, advance.
6. **Step 4** — device name (default `${browser} on ${os}`).
7. **Step 5** — finish. If `chrome.runtime.sendMessage` reaches the
extension, "register this device" pushes everything in one go
(`setup.ts:1039-1112`):
1. `wasm.generate_device_keypair()` → `{ public_key_hex,
private_key_base64 }`.
2. `chrome.storage.local.set({ device_name, device_private_key })`.
3. `save_setup` SW message → `chrome.storage.local.set({ vaultConfig,
imageBase64 })`.
4. `addDevice(host, ...)` → read-modify-write
`.relicario/devices.json`.
5. `wasm.lock(verifiedHandle)` — release the attach-mode handle.
If the extension is NOT detected, the wizard offers to download the
reference JPEG and copy a JSON config blob to paste into the
extension manually.
### Unlock from popup
1. Popup opens → `chrome.tabs.query` snapshots active tab into
`state.capturedTabId` / `state.capturedUrl` (`popup.ts:231-233`).
Used later by `fill_credentials`.
2. `get_setup_state` → if not configured, opens setup tab and closes
popup.
3. `is_unlocked` → if unlocked, `list_items` + `get_vault_settings`,
navigate to `list`. Otherwise, navigate to `locked`.
4. User types passphrase → `unlock` SW message
(`router/popup-only.ts:38-55`):
1. Load `vaultConfig` + `imageBase64` from `chrome.storage.local`.
2. `createGitHost` if not already present.
3. `gitHost.readFile('.relicario/salt')` + `params.json` (cached on
`state.gitHost` for the SW lifetime).
4. `wasm.unlock(passphrase, imageBytes, salt, paramsJson)` →
`SessionHandle`.
5. Wipe `msg.passphrase` (best-effort — JS strings are immutable, but
we drop the reference).
6. `fetchAndDecryptManifest` and cache on `state.manifest`.
### Item create from popup
1. Form component (`components/types/login.ts` etc.) collects fields
and emits `add_item` with the full Item.
2. `router/popup-only.ts:74-83`:
1. `wasm.new_item_id()` — 16-char hex.
2. `wasm.item_encrypt(handle, JSON.stringify(item))` →
ciphertext.
3. `gitHost.writeFile('items/<id>.enc', ciphertext, "add: <title>")`.
4. Update `state.manifest.items[id]`; re-encrypt + write
`manifest.enc`.
3. Popup re-renders list with the new entry.
### Autofill (content-script flow)
1. `detector.ts` finds password fields, `icon.ts` injects an icon
inside a closed Shadow DOM near each.
2. User clicks icon → `get_autofill_candidates` (content-callable, no
`url` field — router derives hostname from `sender.tab.url`).
3. SW: `vault.findByHostname(manifest, senderHost)` matches
`manifest.items[i].icon_hint === hostname.toLowerCase()` (note: no
www-stripping, no PSL — coarse on purpose for α).
4. One candidate → content calls `get_credentials`. SW resolves origin
match (`router/content-callable.ts:42-44`) and TOFU
(`router/content-callable.ts:46-51`).
- First time on this hostname → `{ requires_ack: true, hostname }`.
`icon.ts` shows the in-page hint instructing the user to open
relicario; user opens popup, picks the item, and the SW path that
writes the credential calls `ack_autofill_origin`.
- Acked → `{ username, password }`. `fill.ts.fillFields` types
directly without a SW round-trip (content script IS the page
origin; no need to go through the SW just to write to its own
DOM). This is the only flow where credentials reach the page,
and the request was originated by the user via the icon click.
5. Multiple candidates → picker (also closed Shadow DOM).
Selection → same `get_credentials` path.
### Capture-save-login
1. `capture.ts` hooks `<form>` submit and any submit-shaped button.
2. On submit: `findUsernameValue(pwField)` + `password` →
`check_credential` (content-callable). SW returns one of:
`skip` (already match), `save` (no match), or
`update` (same username, different password).
3. If not skip, `capture.ts` shows a save-or-update prompt in a closed
Shadow DOM. Settings (capture style: bar/toast) fetched directly
from `chrome.storage.local` to avoid round-tripping through the SW
(which would also fail the router's content→popup-only check for
`get_settings`).
4. "Save" → `capture_save_login`. SW (`router/content-callable.ts:99-163`):
- Update path: existing `(host, username)` match → defense-in-depth
check that the item's `core.url` hostname matches sender hostname
→ re-encrypt only the password + modified, push.
- Add path: build a new Login bound to the sender's origin
(`title = senderHost`, `core.url = senderOrigin`), encrypt + push,
update manifest.
5. "Never" → `blacklist_site`. SW pushes hostname into
`chrome.storage.local.captureBlacklist`. Future submits on this
host short-circuit at step 2.
### Sync (manual, post-`a7dbf35`)
1. Settings view → "Sync now" (`components/settings.ts:83-92`) or
item-list toolbar "sync" (`item-list.ts:103-117`).
2. `sync` SW message → `vault.fetchAndDecryptManifest` re-pulls
`manifest.enc` from the host and re-decrypts. No git-side push or
merge — git host is the source of truth, and writes are immediate.
Sync is essentially "refresh the in-memory manifest cache".
3. Status text on the popup updates to "synced ✓" or
"sync failed: <error>".
### Device register from popup (post-`a7dbf35`)
1. Devices view detects `chrome.storage.local.device_name` is missing
from the remote device list → shows banner.
2. User clicks "Register this device" → inline name input
(`devices.ts:81-119`).
3. On confirm → `register_this_device` SW message
(`router/popup-only.ts:313-329`):
1. `wasm.generate_device_keypair()` →
`{ public_key_hex, private_key_base64 }`.
2. `chrome.storage.local.set({ device_name, device_private_key })`.
3. `devices.addDevice(host, ...)` → read-modify-write
`.relicario/devices.json`.
4. Devices view re-renders; banner gone.
### Session lock (timer-driven)
1. `service-worker/index.ts:51-58` registers `onExpired` callback at
SW boot.
2. Every popup-class message resets the timer (every content-callable
message does NOT — page-side traffic shouldn't keep the vault
unlocked; `service-worker/index.ts:76-78`).
3. After the configured idle window: callback fires →
`session.clearCurrent()` (zeroes WASM key) → `state.manifest = null`
→ broadcast `{ type: 'session_expired' }`.
4. Popup and vault tab listen for that broadcast and snap back to the
locked view (`popup.ts:299-307`, `vault.ts:521-531`).
### Trash + purge
1. `delete_item` is a soft-delete: the item gets a `trashed_at` and is
re-encrypted; the manifest entry mirrors that. List views filter
`trashed_at !== undefined`.
2. `list_trashed` returns trashed entries sorted newest-first.
3. `restore_item` clears `trashed_at` and bumps `modified`.
4. `purge_item` deletes the encrypted item + every attachment blob in
its `attachment_summaries`, removes the manifest entry, and rewrites
`manifest.enc`.
5. `purge_all_trash` purges every trashed item AND scans
`attachments/` for orphan blobs (not referenced by any remaining
manifest entry) and deletes them. Returns
`{ itemCount, orphanCount }`.
## Cross-cutting concerns
### State sharing across bundles
`shared/state.ts` is a service-locator for the popup component layer.
It defines a `StateHost` interface (`getState`, `setState`, `navigate`,
`sendMessage`, `escapeHtml`, `popOutToTab`, `isInTab`, `openVaultTab`)
and a single module-scope `host` slot. `popup.ts` and `vault.ts` each
call `registerHost({...})` at boot with their own implementations of
those methods. The `popup/components/*` files only know the locator;
they never import from `popup.ts` or `vault.ts`.
This is why every component renderer takes `app: HTMLElement`: the
host gives the component the mount point, and the locator gives the
component everything else (current state, message channel, navigation).
The same `renderItemDetail` runs unchanged in the 360px popup and the
fullscreen vault tab — the host's `getState()` projects different state
shapes that happen to share field names.
### Error surface
All SW handlers return `{ ok: true, data?: ... } | { ok: false, error: string }`.
Conventions:
- Vault-state errors (`vault_locked`, `item_not_found`, `not_a_login`,
`no_totp`, `attachment_not_found`) are bare snake_case strings the
popup can pattern-match in `humanizeError` (`popup.ts:135-160`).
- Origin / sender errors (`origin_mismatch`, `tab_navigated`,
`captured_tab_gone`, `unauthorized_sender`, `origin_changed`) are
also bare strings; they're the security-sensitive ones and must
remain testable by handler-level tests
(`router.test.ts:237-285`).
- Crypto failures bubble up as Rust error strings via wasm-bindgen.
AEAD authentication failures are deliberately conflated with
"wrong passphrase" (no oracle for "right passphrase, wrong image").
- Network / git-host failures bubble up as native `Error` instances
that the SW catches in `service-worker/index.ts:93-97` and flattens
to `{ ok: false, error: err.message }`.
### TS ↔ Rust type sync
`shared/types.ts` mirrors the Rust core's serde shapes. Internally-tagged
enums (`ItemCore`) match `#[serde(tag = "type")]`; adjacently-tagged
enums (`FieldValue`) match `#[serde(tag = "kind", content = "value")]`.
Optional fields use `?` because Rust's
`#[serde(skip_serializing_if = "Option::is_none")]` omits them and
`serde_wasm_bindgen` produces `undefined`. `r#type` Rust → `type` JSON
key. The mirror is hand-kept; if a Rust field changes, the TS shape
must be updated explicitly. Drift = silent runtime crash on first
encounter with a value the TS type says is impossible.
### Storage layout
**Local** (`chrome.storage.local`):
| Key | Set by | Holds |
| -------------------- | ------------------- | ----------------------------------------------- |
| `vaultConfig` | setup `save_setup` | `{ hostType, hostUrl, repoPath, apiToken }` |
| `imageBase64` | setup `save_setup` | reference JPEG bytes (base64). Re-read on every unlock. |
| `device_name` | setup / register | This device's name (must match a remote device record) |
| `device_private_key` | setup / register | base64 ed25519 private key. **Highest-value device-local secret.** |
| `relicarioSettings` | popup settings | `DeviceSettings` (capture toggle + style) |
| `captureBlacklist` | content `blacklist_site` / popup `remove_blacklist` | `string[]` of hostnames |
| `session_timeout` | popup `update_session_config` | `SessionTimeoutConfig` — restored on SW boot |
**Remote** (the git repo):
- `.relicario/salt` — 32-byte vault salt (KDF input).
- `.relicario/params.json` — Argon2id parameters (`m`, `t`, `p`).
- `.relicario/devices.json` — `{ devices: Device[] }`.
- `manifest.enc` — XChaCha20-Poly1305 ciphertext of the manifest.
- `items/<id>.enc` — per-item ciphertext.
- `attachments/<aid>.bin` — content-addressed encrypted attachment
blobs.
- `settings.enc` — vault settings (retention + caps + generator
defaults + `autofill_origin_acks`) ciphertext.
The remote is end-to-end encrypted; the host (Gitea/GitHub) sees only
opaque ciphertext. `chrome.storage.local` is NOT encrypted, so
`device_private_key` is the user's "this device" credential — losing
the local profile means revoking the device server-side and creating a
new keypair, but a non-zero local-attacker model. Documented in the
design spec.
### Two GitHosts
`gitea.ts` and `github.ts` implement the `GitHost` interface
(`git-host.ts:7-44`). They diverge on:
- Auth header (`token X` vs `Bearer X`).
- Read response shape (both base64-content; GitHub adds `\n` line
breaks the Gitea endpoint sometimes also adds — both implementations
strip).
- Update semantics (Gitea has separate POST-create / PUT-update;
GitHub's PUT is create-or-update, so the SHA presence is what
decides).
- Large-blob path. Both switch from Contents API to Git Data API
above `BLOB_THRESHOLD_BYTES`; the API shapes differ but both
produce a commit on the default branch.
Adding a third host (Codeberg, Gitlab) = implement `GitHost`, add a
case to `createGitHost` (`git-host.ts:74-84`), and surface the option
in `setup.ts` step 1.
## Test architecture
Tests run under `vitest` with `happy-dom`
(`extension/vitest.config.ts`). There is no real browser in CI; the
tests cover logic that is browser-API-shaped but doesn't actually
touch a real Chrome.
Patterns:
- **`globalThis.chrome` shim** at the top of each test
(`router/__tests__/router.test.ts:36-45`). Stubs only what the
test needs: `chrome.runtime.id`, `chrome.runtime.getURL`,
`chrome.storage.local.{get,set}`, `chrome.tabs.{get,sendMessage}`.
- **Module mocks via `vi.mock`** for the SW's `vault` and `session`
modules (`router/__tests__/router.test.ts:10-27`) so router tests
don't pull in WASM. The `vi.mock(..., importOriginal)` form keeps
the real `findByHostname`/`listItems` while overriding the
encrypt/decrypt boundary.
- **Component tests** (`popup/components/__tests__/*.test.ts`) mock
`shared/state` so `sendMessage` / `navigate` / etc. become
spies, and assert that the rendered DOM has the right shape and
that user actions emit the right SW messages.
Coverage highlights:
- `service-worker/router/__tests__/router.test.ts` — exhaustive sender
matrix: each popup-only and content-callable type tested from
popup, vault tab, setup tab, top-frame content, and an
"external"/wrong-extension-id sender. The vault-tab-as-popup
acceptance was added in commit `a7dbf35`. Setup-tab exception
scope (`save_setup`, `rate_passphrase`, `is_unlocked` allowed;
`unlock`, `fill_credentials` rejected) verified explicitly. Also
covers the `fill_credentials` TOCTOU verification, capture
add/update/origin-mismatch paths, get_totp on both Login.totp and
standalone Totp.config, and vault-settings get/set.
- `service-worker/__tests__/devices.test.ts` — devices.json
read/modify/write semantics (add/revoke).
- `service-worker/__tests__/git-host*.test.ts` — Contents API vs
Git Data API switching, SHA-on-update behavior.
- `service-worker/__tests__/session-timer.test.ts` — `inactivity`
vs `every_time` modes; reset/stop semantics.
- `service-worker/__tests__/trash.test.ts` — soft-delete, restore,
purge, orphan-blob cleanup.
- `popup/components/__tests__/devices.test.ts` — devices view including
the new register-this-device inline flow.
- `popup/components/__tests__/settings.test.ts` — sync button +
feedback (added in commit `a7dbf35`).
- `popup/components/__tests__/{attachments-disclosure,field-history,
fields,generator-panel,sections-{editor,render},settings-vault,trash}.test.ts`
— per-component coverage.
- `popup/components/types/__tests__/*.save.test.ts` — each item type's
form-to-Item serialization.
- `setup/__tests__/probe.test.ts` — vault-detection probe.
- `shared/__tests__/base32.test.ts` — RFC 4648 vectors.
**Test-vs-build gap**: tests run with happy-dom and stub crypto.
Browser-API semantics that depend on a real engine — service-worker
restart behavior, real `chrome.tabs.sendMessage` delivery timing,
`chrome.runtime.lastError` paths, MV3 cold-start bundle execution —
are NOT exercised. Treat tests as a logic-bug net, not a
browser-bug net; manual smoke-testing in both Chrome and Firefox is
still required before shipping.
## Gotchas & non-obvious decisions
- **Why the popup never loads WASM directly.** Crypto in one place
(the SW) means one set of bundle-size and CSP concerns. The popup
message round-trips are cheap enough; the architectural win is
worth more than the latency.
- **Why setup loads WASM directly anyway.** Setup needs to derive a
master key, encrypt an empty manifest, and push it to the remote
BEFORE `chrome.storage.local.vaultConfig` exists for the SW to read.
There's no `SessionHandle` to pass to the SW yet, and the SW's
`unlock` handler reads config from local storage — chicken-and-egg.
Setup's WASM module is independent of the SW's; both share the same
bytes but each has its own linear memory.
- **Why `vault.html` is treated as popup-class.** The audit flagged
that fullscreen workflows (settings-vault editor, future
backup/restore, future LastPass importer, devices) need more space
than the popup gives. Rather than introducing a third class of
sender, the router was extended to accept `vault.html` as a
popup-equivalent — the message vocabulary is identical, just the
surface is bigger. Commit `a7dbf35`.
- **Why setup.ts is huge but not split per-step.** A previous audit
recommended one-module-per-step; that risked introducing flow bugs
in a hand-tested wizard. Instead, only the pure helpers (no wizard
state) were extracted (`setup-helpers.ts`, commit `f79a67b`). The
step renderers and their event handlers stay inline because they
share `state` heavily and re-render on almost every input.
- **Why every "view" is just a render-into-`#app` function.** No
framework. The popup is small enough that a 50-line state machine
in `popup.ts` plus per-view render functions is shorter and faster
than React. The `StateHost` indirection lets the same components
render in the vault tab without changes — the price of "no
framework" is paid by `shared/state.ts`, which is 62 lines.
- **Why the SW caches `manifest` and `gitHost` in module memory.**
Service workers in MV3 are restartable but persistent during
activity; caching avoids re-decrypting the manifest on every
popup-open (which is constant) and re-fetching `salt` + `params`
on every unlock would be wasteful. On `lock`, `state.manifest` is
cleared (`router/popup-only.ts:60`) and on `session_expired` too
(`service-worker/index.ts:55-56`).
- **Why content scripts have direct `chrome.storage.local` access.**
The `storage` permission applies to all extension contexts. Content
uses it for capture style settings (`capture.ts:101-103`) because
routing through the SW would fail the router's
content→popup-only check for `get_settings`, and adding a
content-callable variant would expand the attack surface.
- **Why `device_private_key` lives in `chrome.storage.local` even
though it's a long-term secret.** The "device" IS the local
machine; the user is implicitly trusting whatever can read
`chrome.storage.local` (the same threat model as the SW's session
state). Promoting the key into the SW's WASM linear memory
wouldn't help — a local attacker capable of reading
`chrome.storage.local` is also capable of attaching a debugger to
the SW. The correct mitigation is OS-level (full-disk encryption)
and remote-side (revoke on loss).
- **Why `capture_save_login` is a single message with internal
add-vs-update branching.** Two messages (`capture_add` /
`capture_update`) would let a malicious page guess which one was
expected and craft a request to mutate an existing entry's password
on a sibling host. Funneling through one handler that derives
origin server-side and chooses the path itself eliminates that
class of bug.
- **Why `findByHostname` is intentionally coarse.** No
www.-stripping, no public-suffix matching: in α, `github.com` and
`www.github.com` saved logins are independent. Smarter matching
has UX failure modes (filling subdomain credentials cross-site)
that need design before code; tracked for 1C-β/γ. See
`service-worker/vault.ts:127-142`.
- **Why the inactivity timer ignores content-callable messages.**
A page making periodic background fetches (e.g. SSE, polling)
shouldn't keep the vault unlocked indefinitely. Only popup/vault
tab activity counts as "user is at the keyboard"
(`service-worker/index.ts:76-78`).
- **Why `is_unlocked` is in the setup-tab allowlist.** Setup's
step-5 detects whether the extension is reachable; pinging
`is_unlocked` is the cheapest available probe, and the response
is non-sensitive (a boolean). The two other allowed messages
(`save_setup`, `rate_passphrase`) are unavoidable.
- **Why fill goes through the SW for the credential resolution but
the actual DOM write happens in content.** The SW knows which
hostname the active tab is on and can match the right item; but
once the credentials are resolved and bound to `expectedHost`,
the content script is the only context with DOM access. The SW
could `chrome.tabs.executeScript` to inject a one-shot writer,
but that doubles the attack surface for no benefit — the
content script already has DOM access by the time the page is
loaded.
- **Why setup uses `webpackIgnore` to load WASM.** Webpack would
otherwise try to chunk-split or inline `relicario_wasm.js`, breaking
the wasm-pack runtime expectation that it lives at a stable URL
next to `relicario_wasm_bg.wasm`. The runtime calls
`WebAssembly.instantiateStreaming(fetch(URL))` against a
hardcoded path; we just hand it that path.
---
**End of tour.** For roadmap and in-flight work see [../STATUS.md](../STATUS.md) and [../ROADMAP.md](../ROADMAP.md).