# 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 shapes use a mix of `snake_case`, internal tagging (`#[serde(tag = "type")]` for `ItemCore`, `tag = "kind"` for `GeneratorRequest`), adjacent tagging (`tag = "kind", content = "value"` for `FieldValue`, `TrashRetention`, `HistoryRetention`, `SymbolCharset`), and default external tagging (`TotpKind`). The TS types must match exactly:

```ts
export type ItemId = string;       // 16-char hex
export type FieldId = string;
export type AttachmentId = string;

// snake_case strings, matches serde rename_all = "snake_case"
export type ItemType = 'login' | 'secure_note' | '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;                  // omitted when None (#[serde(skip_serializing_if)])
  notes?: string;
  created: number;
  modified: number;
  trashed_at?: number;
  core: ItemCore;                  // internally-tagged on `"type"` — see below
  sections: Section[];
  attachments: AttachmentRef[];
  field_history: Record<FieldId, FieldHistoryEntry[]>;
}

// Internally-tagged: ItemCore variant's fields get merged with `"type"` discriminant.
// Example wire format for Login: { "type": "login", "username": "...", ... }
export type ItemCore =
  | ({ type: 'login' }       & LoginCore)
  | ({ type: 'secure_note' } & SecureNoteCore)
  | ({ type: 'identity' }    & IdentityCore)
  | ({ type: 'card' }        & CardCore)
  | ({ type: 'key' }         & KeyCore)
  | ({ type: 'document' }    & DocumentCore)
  | ({ type: 'totp' }        & TotpCore);

export interface LoginCore {
  username?: string;
  password?: string;
  url?: string;                    // Rust serializes `Url` as its string form
  totp?: TotpConfig;
}

// TotpKind is externally-tagged (default for enums without #[serde(tag)]):
//   Totp         → "totp"       (unit variant serializes as bare string)
//   Hotp{counter}→ { "hotp": { "counter": 42 } }
//   Steam        → "steam"
export type TotpKind = 'totp' | 'steam' | { hotp: { counter: number } };

export interface TotpConfig {
  secret: number[];                // Vec<u8> → JSON number array
  algorithm: 'sha1' | 'sha256' | 'sha512';
  digits: number;
  period_seconds: number;
  kind: TotpKind;
}

// 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';
}

// GeneratorRequest is internally-tagged on "kind", struct variants:
export type GeneratorRequest =
  | { kind: 'bip39';  word_count: number; separator: string; capitalization: Capitalization }
  | { kind: 'random'; length: number; classes: CharClasses; symbol_charset: SymbolCharset };

export type Capitalization = 'lower' | 'upper' | 'first_of_each' | 'title' | 'mixed';
export interface CharClasses { lower: boolean; upper: boolean; digits: boolean; symbols: boolean; }

// SymbolCharset is adjacently-tagged { tag: "kind", content: "value" }:
export type SymbolCharset =
  | { kind: 'safe_only' }
  | { kind: 'extended' }
  | { kind: 'custom'; value: string };

// TrashRetention / HistoryRetention use the same adjacent tagging:
export type TrashRetention =
  | { kind: 'forever' }
  | { kind: 'days'; value: number };

export type HistoryRetention =
  | { kind: 'forever' }
  | { kind: 'last_n'; value: number }
  | { kind: 'days';   value: number };

// FieldValue adjacently-tagged { tag: "kind", content: "value" }, snake_case:
export type FieldValue =
  | { kind: 'text';       value: string }
  | { kind: 'multiline';  value: string }
  | { kind: 'password';   value: string }
  | { kind: 'concealed';  value: string }
  | { kind: 'url';        value: string }    // Url → string
  | { kind: 'email';      value: string }
  | { kind: 'phone';      value: string }
  | { kind: 'date';       value: string }    // chrono NaiveDate → "YYYY-MM-DD"
  | { kind: 'month_year'; value: { month: number; year: number } }
  | { kind: 'totp';       value: TotpConfig }
  | { kind: 'reference';  value: AttachmentId };

export type FieldKind =
  | 'text' | 'multiline' | 'password' | 'concealed' | 'url' | 'email'
  | 'phone' | 'date' | 'month_year' | 'totp' | 'reference';
```

Plus `Section`, `Field`, `AttachmentRef`, `AttachmentSummary`, `FieldHistoryEntry` as declared. Most are unused by α's UI but present so the type-check catches drift with the Rust side.

The serialization shapes above are verified in slice 3's smoke test: `item_encrypt(handle, JSON.stringify(loginItem))` round-trips through `item_decrypt` with structural equality against a Rust-side item written by the CLI.

## 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 'secure_note':
  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 a `number[]` (the `secret: Vec<u8>` on the Rust side) and emits `TotpConfig { secret, algorithm: 'sha1', digits: 6, period_seconds: 30, kind: 'totp' }`. Display is base32 re-encoded with a reveal toggle.

"gen" button sends:

```ts
{ type: 'generate_password',
  request: { kind: 'random',
             length: 20,
             classes: { lower: true, upper: true, digits: true, symbols: true },
             symbol_charset: { kind: 'safe_only' } } }
```

### 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.
