docs: Plan 1C-α (extension foundation) design spec

Foundation slice of the browser-extension migration onto the typed-item
core from Plans 1A+1B. Scope: WASM artifact rebuild, typed-item shared
types, SessionHandle-based service worker, split router with sender
checks, full security architecture (origin-bound autofill, TOFU ack,
closed Shadow DOM, popup captured-tab verification), zxcvbn setup gate,
Login-parity popup. Other 6 item types land in 1C-β; attachments/trash/
history/device UI in 1C-γ.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
adlee-was-taken
2026-04-20 19:14:25 -04:00
parent 76f34bfcf5
commit a1d733ddeb

View File

@@ -0,0 +1,540 @@
# relicario — Extension Plan 1C-α (Foundation) Design
First of three sub-plans that port the browser extension from the v1 single-`Entry` data model to the typed-item model landed in Plans 1A + 1B. 1C-α is the **foundation slice**: rebuild the WASM artifact, migrate shared types, rewrite the service worker against the opaque `SessionHandle` surface, split the message router with sender checks, wire the full security architecture from the typed-items spec, and achieve Login-parity on the new stack. Other six item types show "Coming in 1C-β" placeholders.
This spec references the broader design at `docs/superpowers/specs/2026-04-18-relicario-typed-items-design.md` — read that for the cryptographic envelope, data-model rationale, and threat model. This document is the extension-side implementation design for the first of the three 1C sub-plans.
## Plan 1C decomposition
| Sub-plan | Scope |
|---|---|
| **1C-α (this spec)** | WASM rebuild, shared types, service-worker rewrite, router split, security architecture, Login-parity popup, setup-wizard zxcvbn, Firefox parity |
| 1C-β | Per-type forms for the other six types, sections + custom fields, full vault-settings UI, generator-request UI |
| 1C-γ | Attachments (with `putBlob` Git-Data-API fallback), trash view, field-history view, device management |
Each sub-plan gets its own spec → plan → implementation cycle.
## Design Decisions
Captured during brainstorming:
| Question | Decision | Why |
|---|---|---|
| How many sub-plans? | Three (α/β/γ) | Single plan is too large to review or execute without drift; three sub-plans give natural checkpoints |
| What's "done" for 1C-α? | **Login-parity on the new stack** — all existing single-`Entry` flows re-expressed as `Item::Login`; other six types show "coming soon" | Validates the full pipeline end-to-end (item + manifest + git commit) before β's UI sprawl; keeps extension usable during β |
| Firefox in scope for α? | Yes, concurrent with Chrome | Shared TS source; marginal extra cost; avoids mid-β surprises from silent rot |
| Where do capture UX prefs + blacklist live? | `chrome.storage.local` (device-local) | TOFU origin-ack is a security posture (vault-level); capture prompt style is a UX preference that genuinely differs per device; blacklist churn pollutes git log |
| zxcvbn strength gate in α or β? | **α** | Audit H3 (security); leaving a weak-passphrase window open during β is the same shape of mistake as leaving autofill origin-unbound during α |
| Sequencing | **Bottom-up six-slice** (WASM artifact → types → SW vault/session → router → security → popup rewire + zxcvbn) | Matches Plan 1B's small-task cadence; gives the plan-executor clean checkpoints |
## Scope
### In
- **WASM artifact** rebuilt from `relicario-wasm` crate (replacing stale `idfoto_wasm*` files).
- **Shared TypeScript types / messages** migrated to the typed-item surface (`Item`, `ItemCore`, `Manifest` v2, `AttachmentRef`, plus the minimal `VaultSettings` subset needed for origin-ack).
- **Service-worker rewrite**: `SessionHandle`-based `session.ts`, rewritten `vault.ts`, split `router/{index,popup-only,content-callable}.ts` with sender checks.
- **Security architecture**:
- WAR cleanup — `setup.html`, `setup.js`, wasm artifacts dropped.
- Setup opened via `chrome.tabs.create`.
- Origin-bound autofill (`sender.tab.url` only, hostname equality, top-frame only).
- TOFU origin-ack via `VaultSettings.autofill_origin_acks`.
- Popup captured-tab verification for `fill_credentials` (audit M5).
- Closed Shadow DOM for all content-script UI.
- `textContent`-only DOM construction; randomized-per-prompt element references; bounded page-derived strings.
- **Login-parity popup**: view, add, edit, delete, autofill, capture for `Item::Login`. Other six types appear in the "New…" menu but open a "Coming in 1C-β" placeholder.
- **Setup wizard**: zxcvbn strength meter + `score >= 3` gate (via new `rate_passphrase` message).
- **Firefox build** re-verified with the new manifest + webpack config.
### Out (→ 1C-β / 1C-γ)
- Per-type forms for SecureNote / Identity / Card / Key / Document / Totp (β).
- Sections + custom fields UI (β).
- Full vault-settings view (retention policies, generator defaults, attachment caps) — α touches `settings.enc` only for origin-ack (β).
- Attachments and `putBlob` Git-Data-API fallback — Login items fit Contents API (γ).
- Trash view, field-history view (γ).
- Device management UI — CLI already handles it (γ).
- BIP39 / advanced generator-request UI — α uses a default `Random { length: 20, classes: lower+upper+digits+symbols, symbol_charset: SafeOnly }` for the "gen" button (β).
## File Map
### New files
```
extension/src/service-worker/session.ts # SessionHandle lifecycle
extension/src/service-worker/router/index.ts # single onMessage entry + sender dispatch
extension/src/service-worker/router/popup-only.ts # popup-callable handlers
extension/src/service-worker/router/content-callable.ts # content-script-callable handlers
extension/src/service-worker/router/__tests__/router.test.ts # sender-check + origin-bound autofill tests
extension/src/content/shadow.ts # closed Shadow DOM host helper
extension/src/shared/base32.ts # base32 encode/decode for TOTP field parse
```
### Rewritten
```
extension/src/shared/types.ts # Item, ItemCore, FieldKind, VaultSettings, ManifestEntry v2
extension/src/shared/messages.ts # PopupMessage + ContentMessage unions
extension/src/service-worker/vault.ts # typed-item ops via SessionHandle
extension/src/service-worker/index.ts # thin init + WASM load, delegates to router
extension/src/content/capture.ts # closed Shadow DOM, textContent
extension/src/content/icon.ts # closed Shadow DOM, textContent
extension/src/popup/popup.ts # item dispatch, captured-tab snapshot on init
extension/src/popup/components/entry-list.ts # → item-list.ts
extension/src/popup/components/entry-detail.ts # → item-detail.ts (Login dispatcher + "coming soon")
extension/src/popup/components/entry-form.ts # → item-form.ts (Login dispatcher + "coming soon")
extension/src/popup/components/setup-wizard.ts # zxcvbn meter + gate
extension/src/setup/setup.ts # zxcvbn meter + gate (mirror)
extension/src/wasm.d.ts # mirror crates/relicario-wasm/src/lib.rs
extension/manifest.json # WAR cleanup
extension/manifest.firefox.json # WAR cleanup
```
### Deleted
```
extension/wasm/idfoto_wasm* # stale pre-rename artifact
```
## WASM Artifact
`npm run build:wasm` already targets `../crates/relicario-wasm --out-dir ../../extension/wasm`. Running it produces `relicario_wasm.js`, `relicario_wasm_bg.wasm`, and `relicario_wasm.d.ts` in the output directory. Delete the stale `idfoto_wasm*` files. Both webpack configs already import from `../../wasm/relicario_wasm.js` — no config edits required.
`wasm.d.ts` currently mirrors the Plan 1B surface; skim it for drift against `crates/relicario-wasm/src/lib.rs` after the rebuild (particularly the `attachment_encrypt` third argument `max_bytes: bigint`).
## Shared Types (`shared/types.ts`)
Mirror the Rust core verbatim through serde serialization. The Rust-side `ItemCore` enum is externally-tagged by default, producing `{ "Login": {...} }` style JSON:
```ts
export type ItemId = string; // 16-char hex
export type FieldId = string;
export type AttachmentId = string;
export type ItemType = 'Login' | 'SecureNote' | 'Identity' | 'Card' | 'Key' | 'Document' | 'Totp';
export interface Item {
id: ItemId;
title: string;
type: ItemType; // Rust's `r#type` serializes as `type`
tags: string[];
favorite: boolean;
group: string | null;
notes: string | null;
created: number;
modified: number;
trashed_at: number | null;
core: ItemCore;
sections: Section[];
attachments: AttachmentRef[];
field_history: Record<FieldId, FieldHistoryEntry[]>;
}
export type ItemCore =
| { Login: LoginCore }
| { SecureNote: SecureNoteCore }
| { Identity: IdentityCore }
| { Card: CardCore }
| { Key: KeyCore }
| { Document: DocumentCore }
| { Totp: TotpCore };
export interface LoginCore {
username: string | null;
password: string | null;
url: string | null;
totp: TotpConfig | null;
}
export interface TotpConfig {
secret: number[]; // raw bytes, serde → JSON array
algorithm: 'Sha1' | 'Sha256' | 'Sha512';
digits: number;
period_seconds: number;
kind: { Totp: null } | { Hotp: number } | { Steam: null };
}
// Populated minimally for α (structural shape only, no UI); β fills in:
export interface SecureNoteCore { body: string; }
export interface IdentityCore { /* ... */ }
export interface CardCore { /* ... */ }
export interface KeyCore { /* ... */ }
export interface DocumentCore { filename: string; mime_type: string; primary_attachment: AttachmentId; }
export interface TotpCore { config: TotpConfig; issuer: string | null; label: string | null; }
export interface Manifest {
schema_version: number;
items: Record<ItemId, ManifestEntry>;
}
export interface ManifestEntry {
id: ItemId;
type: ItemType;
title: string;
tags: string[];
favorite: boolean;
group: string | null;
icon_hint: string | null;
modified: number;
trashed_at: number | null;
attachment_summaries: AttachmentSummary[];
}
export interface VaultSettings {
trash_retention: unknown; // opaque in α; full shape in β
field_history_retention: unknown;
generator_defaults: unknown;
attachment_caps: unknown;
autofill_origin_acks: Record<string, number>;
}
export interface DeviceSettings { // chrome.storage.local shape (was RelicarioSettings)
captureEnabled: boolean;
captureStyle: 'bar' | 'toast';
}
export type GeneratorRequest =
| { Bip39: { word_count: number; separator: string; capitalization: Capitalization } }
| { Random: { length: number; classes: CharClasses; symbol_charset: SymbolCharset } };
export type Capitalization = 'Lower' | 'Upper' | 'FirstOfEach' | 'Title' | 'Mixed';
export interface CharClasses { lower: boolean; upper: boolean; digits: boolean; symbols: boolean; }
export type SymbolCharset = 'SafeOnly' | 'Extended' | { Custom: string };
```
Plus `Section`, `Field`, `FieldKind`, `FieldValue`, `AttachmentRef`, `AttachmentSummary`, `FieldHistoryEntry` as declared. Most are unused by α's UI but present so the type-check catches drift with the Rust side.
The ItemCore external-tagging assumption gets verified in the first SW slice's smoke test: `item_encrypt(handle, JSON.stringify(loginItem))` round-trips through `item_decrypt` with structural equality.
## Messages (`shared/messages.ts`)
Two unions, so TypeScript itself enforces the router boundary:
```ts
export type PopupMessage =
| { type: 'is_unlocked' }
| { type: 'unlock'; passphrase: string }
| { type: 'lock' }
| { type: 'list_items'; group?: string }
| { type: 'get_item'; id: ItemId }
| { type: 'add_item'; item: Item }
| { type: 'update_item'; id: ItemId; item: Item }
| { type: 'delete_item'; id: ItemId } // soft-delete (sets trashed_at)
| { type: 'get_totp'; id: ItemId }
| { type: 'sync' }
| { type: 'get_setup_state' }
| { type: 'save_setup'; config: VaultConfig; imageBase64: string }
| { type: 'rate_passphrase'; passphrase: string }
| { type: 'generate_password'; request: GeneratorRequest }
| { type: 'fill_credentials'; id: ItemId; capturedTabId: number; capturedUrl: string }
| { type: 'ack_autofill_origin'; hostname: string }
| { type: 'get_settings' } // DeviceSettings (local)
| { type: 'update_settings'; settings: Partial<DeviceSettings> }
| { type: 'get_blacklist' }
| { type: 'remove_blacklist'; hostname: string };
export type ContentMessage =
| { type: 'get_autofill_candidates' } // url comes from sender.tab.url
| { type: 'get_credentials'; id: ItemId } // origin-checked against sender.tab.url
| { type: 'check_credential'; username: string; password: string } // url from sender
| { type: 'blacklist_site' }; // hostname from sender
export type Request = PopupMessage | ContentMessage;
```
Deliberate omissions: `get_autofill_candidates`, `check_credential`, `blacklist_site` no longer carry a `url` — the SW derives it from `sender.tab.url`. `fill_credentials` now takes an item id + captured tab state instead of raw credentials, so the SW can re-verify origin on the forwarding hop.
## Service Worker
### `service-worker/session.ts`
Single module-scope "current" `SessionHandle` (single-vault assumption per the broader spec).
```ts
import type { SessionHandle } from '../../wasm/relicario_wasm';
let current: SessionHandle | null = null;
export function setCurrent(h: SessionHandle): void { current = h; }
export function getCurrent(): SessionHandle | null { return current; }
export function clearCurrent(): void {
if (!current) return;
try { current.free(); } catch { /* already freed */ }
current = null;
}
export function requireCurrent(): SessionHandle {
if (!current) throw new Error('vault_locked');
return current;
}
```
SW idle-suspend (Chrome) or explicit `lock` message clears the handle. Firefox's persistent background script retains it until explicit lock — consistent with the spec.
### `service-worker/vault.ts`
Public surface, all handle-keyed:
```ts
fetchVaultMeta(git): Promise<{ salt: Uint8Array; paramsJson: string }>
fetchAndDecryptManifest(git, handle): Promise<Manifest>
encryptAndWriteManifest(git, handle, manifest, message): Promise<void>
fetchAndDecryptItem(git, handle, id): Promise<Item>
encryptAndWriteItem(git, handle, id, item, message): Promise<void>
fetchAndDecryptSettings(git, handle): Promise<VaultSettings>
encryptAndWriteSettings(git, handle, settings, message): Promise<void>
listItems(manifest, filter?): Array<[ItemId, ManifestEntry]>
searchItems(manifest, query): Array<[ItemId, ManifestEntry]>
findByHostname(manifest, hostname): Array<[ItemId, ManifestEntry]> // hostname from caller
```
No `masterKey: Uint8Array` anywhere in the module surface.
### `service-worker/router/index.ts`
Single `chrome.runtime.onMessage.addListener`. Sender predicates:
```ts
const popupUrl = chrome.runtime.getURL('popup.html');
const setupUrl = chrome.runtime.getURL('setup.html');
const senderUrl = sender.url ?? '';
const isPopup = senderUrl === popupUrl;
const isSetup = senderUrl.startsWith(setupUrl);
const isContent = sender.tab !== undefined
&& sender.frameId === 0
&& sender.id === chrome.runtime.id;
```
`POPUP_ONLY` capability set = every `PopupMessage` type. `CONTENT_CALLABLE` = every `ContentMessage` type. `save_setup` is the one exception: accepted from `isPopup || isSetup`.
Unauthorized sender → `{ ok: false, error: 'unauthorized_sender' }` synchronously. Unknown type → `{ ok: false, error: 'unknown_message_type' }`. The two handler files stay thin — pure `switch (msg.type)` with no sender logic (the router already verified).
## Security Architecture
### WAR cleanup (audit C1)
Both manifests drop `setup.html`, `setup.js`, `relicario_wasm.js`, `relicario_wasm_bg.wasm` from `web_accessible_resources`. The WAR array becomes `[{ resources: ["styles.css"], matches: ["<all_urls>"] }]` if styles are still needed, else disappears.
The popup opens setup via:
```ts
chrome.tabs.create({ url: chrome.runtime.getURL('setup.html') });
```
Own-origin extension tabs work without WAR. The SW loads WASM via `chrome.runtime.getURL(...)` from the extension origin — no WAR either.
### Sender dispatch (audit C1, C2)
Implemented in `router/index.ts` as described above. The `save_setup` exception is the one place this deviates from "pure set-membership" — it accepts either `isPopup` or `isSetup`.
### Origin-bound autofill (audit C4)
Content-callable handlers derive origin exclusively from `sender.tab.url`. Flow:
- **`get_autofill_candidates`**: parse `sender.tab.url` → hostname. Return manifest entries whose `LoginCore.url` hostname equals page hostname. Top-frame only (`sender.frameId === 0` already enforced at router).
- **`get_credentials(id)`**: fetch item. Parse `LoginCore.url` hostname. Compare to `sender.tab.url` hostname. Mismatch → `{ ok: false, error: 'origin_mismatch' }`. No item data leaked on mismatch.
- **`check_credential`**: same origin derivation; `username`/`password` compared against manifest + decrypted item.
- **`blacklist_site`**: hostname derived from `sender.tab.url`.
### TOFU origin-ack
When `get_credentials` succeeds on an origin not present in `VaultSettings.autofill_origin_acks`:
1. SW returns `{ ok: true, data: { requires_ack: true, hostname } }` — no credentials.
2. Content script surfaces a "confirm autofill" dialog inside its closed Shadow DOM (lightweight — just a re-use of the capture prompt layout).
3. User clicks "Confirm in relicario" → focuses the extension popup (the content-script dialog shows a prompt to open the popup; `chrome.action.openPopup()` is Chrome-only and unreliable, so α uses an instructional "open relicario to confirm" message instead).
4. User opens popup → popup detects a pending ack via `VaultSettings.autofill_origin_acks` diff, shows "Confirm autofill on `<hostname>`?" → user acks → popup sends `ack_autofill_origin { hostname }` (popup-only, writes `settings.enc`).
5. Next autofill attempt on the same origin succeeds.
The ack is popup-only because it's a vault write; the content-script dialog is purely instructional. Full in-page ack UI (including a tighter retry loop) is β-polish territory.
### Popup captured-tab verification (audit M5)
On popup open:
```ts
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
const captured = { tabId: tab.id!, url: tab.url ?? '' };
```
Stashed on `PopupState`. `fill_credentials` messages carry `capturedTabId` + `capturedUrl`. SW handler:
1. Look up item by `id`.
2. `chrome.tabs.get(capturedTabId)` — if gone or navigated, reject.
3. Compare `new URL(tab.url).hostname` to `new URL(capturedUrl).hostname` — mismatch rejects.
4. Compare captured hostname to `LoginCore.url`'s hostname — mismatch rejects.
5. Forward via `chrome.tabs.sendMessage(capturedTabId, { type: 'fill_credentials', username, password })`.
Content-script fill listener stays as-is (native-setter trick is already correct).
### Closed Shadow DOM (audit C3)
`content/shadow.ts`:
```ts
export function createShadowHost(): { host: HTMLElement; root: ShadowRoot; destroy: () => void } {
const host = document.createElement('div');
document.body.appendChild(host);
const root = host.attachShadow({ mode: 'closed' });
return { host, root, destroy: () => host.remove() };
}
```
Used by `icon.ts` (per-password-field host for the icon + picker) and `capture.ts` (submit-prompt). Strict rules enforced by review + a lint rule (`no-restricted-syntax` against `MemberExpression[property.name=/^(innerHTML|outerHTML)$/]` inside `extension/src/content/`):
1. **No `innerHTML` / `insertAdjacentHTML` / `document.write`** anywhere in `content/`. All DOM via `createElement` + `textContent` + `appendChild`.
2. **No stable element IDs or classes inside shadow trees**. Wire handlers via local references.
3. **Page-derived strings bounded**: `findUsernameValue` result capped at 256 chars, control characters stripped via `replace(/\p{Cc}/gu, '')`, assigned only via `.textContent`.
4. **Disposal**: removing the host element drops the shadow root, detaches handlers.
Styles inside the shadow tree via `style.setProperty(...)` calls, or a single `<style>` element whose text content is a static literal.
### JS-side passphrase hygiene
Best-effort only (JS strings are immutable). In the `unlock` handler: receive passphrase, pass directly to `wasm.unlock(...)`, then `req.passphrase = ''` and let the message object go out of scope. Never log passphrase content. WASM-side zeroization is the primary defense (already handled Rust-side).
## Popup
### Entry flow
`popup.ts` unchanged in shape. Init sequence:
1. `get_setup_state` → if `!isConfigured`, `chrome.tabs.create(setup.html)` and close the popup.
2. `is_unlocked` → if unlocked, `list_items` + render list.
3. Otherwise render `unlock`.
4. Snapshot `(activeTabId, activeTabUrl)` at init; stash on `PopupState` for later `fill_credentials` calls.
### Item list / detail / form — Login-only
Rename `entry-*.ts``item-*.ts`. List view renders from `ManifestEntry`, shows type-icon + title + group + favorite + tags. Detail and form dispatch on `item.type`:
```ts
switch (item.type) {
case 'Login': return renderLoginDetail(app, item);
case 'SecureNote':
case 'Identity':
case 'Card':
case 'Key':
case 'Document':
case 'Totp': return renderComingSoonPlaceholder(app, item.type);
}
```
Add flow: "New…" menu lists all seven types; picking Login opens the form, picking any other type shows "Coming in 1C-β".
Existing Login form (username/url/password/totp/group/notes) maps 1:1 to `LoginCore` + `Item` envelope. TOTP field takes a base32 string; `shared/base32.ts` parses it to `Uint8Array` → bytes in `TotpConfig { secret, algorithm: Sha1, digits: 6, period_seconds: 30, kind: { Totp: null } }`. Display is base32 re-encoded with a reveal toggle.
"gen" button sends:
```ts
{ type: 'generate_password',
request: { Random: { length: 20,
classes: { lower: true, upper: true, digits: true, symbols: true },
symbol_charset: 'SafeOnly' } } }
```
### Setup wizard + zxcvbn
Both `popup/components/setup-wizard.ts` and `setup/setup.ts`:
- Passphrase input with a 5-bar strength indicator (color-coded per zxcvbn `score`).
- On input (150ms debounce): `rate_passphrase { passphrase }``{ score, guesses_log10 }`.
- Submit disabled unless `score >= 3`.
- Copy: `score < 3` → "Too weak — try a longer phrase or add unpredictability."; `score >= 3` → "Strong enough."
Reference-image upload and vault-config fields stay as-is. Final step gains the spec's H8 warning copy: "factor 2 + git push token are readable from this browser profile's disk. Your remaining defense is the passphrase."
## Storage Split
| Data | Location | Rationale |
|---|---|---|
| `vaultConfig` (host/repo/token) | `chrome.storage.local` | Device-local; needed pre-unlock |
| `imageBase64` (reference JPEG) | `chrome.storage.local` | Device-local factor-2 material |
| `DeviceSettings { captureEnabled, captureStyle }` | `chrome.storage.local` (key: `relicarioSettings`) | Per-device UX preference |
| `captureBlacklist: string[]` | `chrome.storage.local` | Per-device; avoids git churn |
| `VaultSettings.autofill_origin_acks` | `settings.enc` in the repo | Per-vault security posture |
| Items / manifest / settings | git repo (AEAD'd) | Core vault state |
Existing `relicarioSettings` + `captureBlacklist` keys keep their names — no migration step.
## Firefox Parity
- `manifest.firefox.json` gets the same WAR cleanup as Chrome.
- `background.scripts: ["service-worker.js"]` stays (not an SW in Firefox — persistent background script).
- `initWasm()` in `service-worker/index.ts` already branches on `ServiceWorkerGlobalScope` presence; the branch survives the rewrite.
- Load via `about:debugging` → "Load Temporary Add-on" → `dist-firefox/manifest.json`.
- Final manual test matrix at the end of α runs on both browsers concurrently (see below).
## Testing Strategy
### Rust-side regression guard
Plan 1B's 151 tests must stay green. Every slice runs `cargo test --workspace` before commit. The WASM rebuild slice additionally runs `cargo build -p relicario-wasm --target wasm32-unknown-unknown`.
### Extension unit tests (new)
New harness in `extension/src/service-worker/router/__tests__/router.test.ts` using Vitest (runs TS natively, no webpack dependency). Tests:
- **Sender-check matrix**: for each message type, accepted from the right sender and rejected (`unauthorized_sender`) from every wrong sender. Mocks `chrome.runtime.onMessage` by calling the dispatcher directly with fabricated `sender` objects.
- **Origin-bound autofill**: `get_autofill_candidates` ignores any stray `url` field; only `sender.tab.url`'s hostname drives matching.
- **`get_credentials` origin equality**: mismatched `LoginCore.url` hostname → `origin_mismatch`, no item data in response.
- **`fill_credentials` captured-tab verification**: mismatched captured vs current tab → reject.
- **`generate_password` wiring**: calls through to WASM with the expected request shape (smoke only; generator itself is Rust-tested).
Harness scope: SW only. Popup/content-script DOM tests are β.
### Shadow DOM probe (dev-build only)
Runtime assertion in a dev-mode content-script path: after rendering the capture prompt, `console.warn` if `document.querySelector('#relicario-save-btn') !== null` or if the host's `shadowRoot` getter returns non-null from page JS. Stripped in production.
### Manual test matrix — end of slice 6, on both Chrome and Firefox
1. Fresh install → setup wizard opens in a tab (not popup-embedded); zxcvbn slider responds; weak passphrase blocks submit.
2. Unlock → list renders from manifest.
3. Add Login with TOTP → sync → item appears on a second browser profile.
4. Autofill icon in password field → click → fills (single-candidate path).
5. Multiple candidates → picker → pick → fills.
6. First autofill on a new hostname → origin-ack prompt → confirm → credentials arrive.
7. Capture prompt on submitting a new login; "Save" adds item, "Never" blacklists, "×" dismisses.
8. Edit Login → password rotates → field history captured (CLI cross-check: `relicario get --show` on second machine shows new password; item file's `field_history` populated).
9. Delete Login → moves to trash (not in list; CLI `relicario list --trashed` shows it).
10. Soft-lock via popup "lock" → list clears, re-unlock required.
11. Cross-origin autofill attempt via devtools: craft a `get_credentials` from a page whose hostname differs from item's `LoginCore.url``origin_mismatch`.
### Acceptance criteria
- `cargo test --workspace` green.
- `npm run build:all` green in `extension/` (Chrome + Firefox bundles).
- Router unit tests green.
- All 11 manual-matrix steps pass on both Chrome and Firefox.
- `git grep -n 'innerHTML\|insertAdjacentHTML' extension/src/content/` → zero hits.
- `git grep -n 'idfoto' extension/` → zero hits.
- WAR in both manifests contains no HTML/JS/WASM artifacts.
- `fetch(chrome.runtime.getURL('setup.html'))` from a content script fails (smoke test — confirms WAR removal took effect).
### Non-goals for α testing
- Automated browser integration (Playwright against a built extension) — γ.
- Heap-snapshot verification that master_key bytes aren't visible from JS — manual-only per broader spec; formalized in γ.
- Fuzz / property tests for the router — the message-type matrix is exhaustive.
## Audit Findings Addressed
| ID | Severity | How α addresses it |
|---|---|---|
| C1 | Critical | Setup wizard + WASM removed from WAR; sender check on `save_setup` |
| C2 | Critical | Split message router with sender-based dispatch |
| C3 | Critical | Closed Shadow DOM + textContent for all content-script UI |
| C4 | Critical | Origin-bound autofill (`sender.tab.url` only, hostname match) |
| H2 | High | `SessionHandle` opaque to JS; passphrase cleared from scope ASAP |
| H3 | High | zxcvbn strength gate in setup wizard |
| M5 | Medium | Popup captures `(tab.id, tab.url)` at open; verifies on `fill_credentials` |
| L11 | Low | `textContent` rule subsumes escaping concerns in the content-script surface |
Remaining audit items from the typed-items spec (retention UI, attachment caps UI, device-management UI, full vault-settings view) land in β/γ.
## Open Questions Deferred to the Plan
- Exact Vitest configuration — first router-test slice surfaces the final shape (`vitest.config.ts`, `tsconfig` overrides, jsdom vs happy-dom for the origin-parsing paths).
- Precise "pending ack" detection mechanism in the popup: poll `VaultSettings.autofill_origin_acks` on popup open vs a background-synthesized flag. Slice 6 decides based on latency feel.
- Whether the TOTP parse helper belongs in `shared/base32.ts` or gets added to the WASM surface as `totp_config_from_base32`. First approach is simpler; the second is more robust to charset edge cases. Slice 2 decides when wiring the Login form.