# Relicario Extension 1C-α (Foundation) Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Port the browser extension onto the typed-item core from Plans 1A+1B. Rebuild the WASM artifact, migrate the TypeScript surface to typed items, rewrite the service worker around the opaque `SessionHandle`, split the message router with sender checks, wire the full security architecture (WAR cleanup, origin-bound autofill, TOFU origin-ack, popup captured-tab verification, closed Shadow DOM), gate the setup wizard with zxcvbn, and achieve Login-parity on the new stack. Other six item types land in 1C-β.

**Architecture:** Six-slice bottom-up sequencing. Each slice leaves the branch in a cleanly-committable state; the extension doesn't have to load-and-run until slice 6. All messaging is statically split into `PopupMessage` (popup-callable) and `ContentMessage` (content-script-callable) unions; the single `chrome.runtime.onMessage` listener dispatches by `sender.url` / `sender.tab` / `sender.frameId`. Session handles are opaque u32 wrappers with Rust-side `Zeroizing<[u8; 32]>` storage — JS never sees master-key bytes.

**Tech Stack:** Rust (`relicario-wasm` crate, built with `wasm-pack --target web`), TypeScript (webpack 5, ts-loader), Vitest (new — for router unit tests), Bun (package manager per `bun.lock`).

**Reference spec:** `docs/superpowers/specs/2026-04-20-relicario-extension-1c-alpha-design.md` (commit `ad6d8af`)

**Branch:** Create `feature/typed-items-1c-alpha` off `main` (Plan 1B already merged).

**Environment note:** `extension/` uses Bun (`bun.lock`) but `package.json` scripts invoke `webpack` directly. Use `bun install` for deps and `bun run build`/`bun run build:all`/`bun run build:firefox`/`bun run build:wasm` for scripts — they match npm semantics.

---

## Pre-flight

- [ ] **Pre-flight step 1: Verify cargo workspace green on `main`**

Run: `cd /home/alee/Sources/relicario && cargo test --workspace`
Expected: all 151 tests pass (per Plan 1B tag `plan-1b-cli-wasm-complete`).

- [ ] **Pre-flight step 2: Create feature branch**

```bash
cd /home/alee/Sources/relicario
git checkout main
git pull
git checkout -b feature/typed-items-1c-alpha
```

- [ ] **Pre-flight step 3: Install extension deps**

```bash
cd extension
bun install
```

Expected: installs cleanly (webpack, ts-loader, copy-webpack-plugin, @types/chrome).

---

## Slice 1 — WASM artifact rebuild

Goal: replace the stale `idfoto_wasm*` files in `extension/wasm/` with freshly-built `relicario_wasm*` output. Synchronize `wasm.d.ts` if there's any drift from the Plan 1B surface.

### Task 1: Delete stale WASM artifacts

**Files:**
- Delete: `extension/wasm/idfoto_wasm_bg.wasm`
- Delete: `extension/wasm/idfoto_wasm_bg.wasm.d.ts`
- Delete: `extension/wasm/idfoto_wasm.d.ts`
- Delete: `extension/wasm/idfoto_wasm.js`
- Delete: `extension/wasm/package.json`

- [ ] **Step 1: Remove stale files**

```bash
cd /home/alee/Sources/relicario/extension
rm -f wasm/idfoto_wasm_bg.wasm wasm/idfoto_wasm_bg.wasm.d.ts wasm/idfoto_wasm.d.ts wasm/idfoto_wasm.js wasm/package.json
```

- [ ] **Step 2: Verify directory is empty**

Run: `ls extension/wasm/`
Expected: no output (empty directory).

- [ ] **Step 3: Commit**

```bash
cd /home/alee/Sources/relicario
git add -A extension/wasm/
git commit -m "chore(ext): remove stale idfoto_wasm artifact"
```

### Task 2: Build the relicario-wasm artifact for the extension

**Files:**
- Modify (generated): `extension/wasm/relicario_wasm.js`
- Modify (generated): `extension/wasm/relicario_wasm_bg.wasm`
- Modify (generated): `extension/wasm/relicario_wasm.d.ts`
- Modify (generated): `extension/wasm/relicario_wasm_bg.wasm.d.ts`
- Modify (generated): `extension/wasm/package.json`

- [ ] **Step 1: Run the WASM build script**

```bash
cd /home/alee/Sources/relicario/extension
bun run build:wasm
```

The script invokes `wasm-pack build ../crates/relicario-wasm --target web --out-dir ../../extension/wasm`. Requires `wasm-pack` on PATH; if missing, install with `cargo install wasm-pack`.

- [ ] **Step 2: Verify expected files exist**

```bash
ls extension/wasm/
```

Expected files: `relicario_wasm.js`, `relicario_wasm_bg.wasm`, `relicario_wasm.d.ts`, `relicario_wasm_bg.wasm.d.ts`, `package.json`.

- [ ] **Step 3: Sanity-check the WASM binary is non-trivial**

```bash
stat -c '%s' extension/wasm/relicario_wasm_bg.wasm
```

Expected: a multi-hundred-KB size (typical wasm-pack output with Argon2, XChaCha20, zxcvbn, bip39 is ~500KB–1.5MB).

- [ ] **Step 4: Commit**

```bash
git add extension/wasm/
git commit -m "build(ext): rebuild WASM artifact as relicario_wasm"
```

### Task 3: Sync `extension/src/wasm.d.ts` with the generated declarations

The hand-maintained `extension/src/wasm.d.ts` declares the WASM surface for the TS compiler. Verify it matches `crates/relicario-wasm/src/lib.rs` — the rebuilt artifact may reveal drift.

**Files:**
- Modify: `extension/src/wasm.d.ts`

- [ ] **Step 1: Compare existing declarations to Rust source**

Read `extension/src/wasm.d.ts` and `crates/relicario-wasm/src/lib.rs`. Each `#[wasm_bindgen]` signature must have a corresponding TS declaration.

Current file should already match (Plan 1B landed it). If any function is missing, add it. As of commit `65e0d3c` the expected surface is:

```ts
// Thin TypeScript declarations for the relicario-wasm bindings.
// These are hand-written to mirror the #[wasm_bindgen] signatures in
// crates/relicario-wasm/src/lib.rs; keep them in sync manually.

export class SessionHandle {
  readonly value: number;
  free(): void;
}

export class EncryptedAttachment {
  readonly aid: string;
  readonly bytes: Uint8Array;
  free(): void;
}

export class TotpCode {
  readonly code: string;
  readonly expires_at: number;
  free(): void;
}

export function unlock(
  passphrase: string,
  image_bytes: Uint8Array,
  salt: Uint8Array,
  params_json: string,
): SessionHandle;

export function lock(handle: SessionHandle): boolean;

export function manifest_decrypt(handle: SessionHandle, encrypted: Uint8Array): unknown;
export function manifest_encrypt(handle: SessionHandle, manifest_json: string): Uint8Array;
export function item_decrypt(handle: SessionHandle, encrypted: Uint8Array): unknown;
export function item_encrypt(handle: SessionHandle, item_json: string): Uint8Array;
export function settings_decrypt(handle: SessionHandle, encrypted: Uint8Array): unknown;
export function settings_encrypt(handle: SessionHandle, settings_json: string): Uint8Array;

export function attachment_encrypt(
  handle: SessionHandle,
  plaintext: Uint8Array,
  max_bytes: bigint,
): EncryptedAttachment;
export function attachment_decrypt(handle: SessionHandle, encrypted: Uint8Array): Uint8Array;

export function new_item_id(): string;
export function new_field_id(): string;

export function generate_password(request_json: string): string;
export function generate_passphrase(request_json: string): string;
export function rate_passphrase(p: string): { score: number; guesses_log10: number };

export function extract_image_secret(image_bytes: Uint8Array): Uint8Array;
export function embed_image_secret(carrier: Uint8Array, secret: Uint8Array): Uint8Array;

export function totp_compute(config_json: string, now_unix_seconds: bigint): TotpCode;

// Initializer (wasm-bindgen's default init function).
export default function init(module_or_path?: unknown): Promise<void>;
```

- [ ] **Step 2: Add `initSync` export declaration**

The service worker `index.ts` also calls `initSync` (named export) for the Chrome MV3 path. Add this after the default export:

```ts
// wasm-bindgen's sync init — Chrome MV3 service workers can't use dynamic import().
export function initSync(args: { module: WebAssembly.Module }): void;
```

- [ ] **Step 3: Verify TS compile passes**

```bash
cd /home/alee/Sources/relicario/extension
bun run build 2>&1 | head -40
```

Expected: compilation proceeds (may still fail on the old `idfoto_wasm`-era callers in `service-worker/`; that's fine — the types file itself should not error).

- [ ] **Step 4: Commit**

```bash
git add extension/src/wasm.d.ts
git commit -m "build(ext): align wasm.d.ts with relicario-wasm surface"
```

---

## Slice 2 — Shared TS types and message unions

Goal: rewrite `shared/types.ts` and `shared/messages.ts` to mirror the Rust typed-item serialization. This slice makes downstream TS fail to compile everywhere that still references `Entry` / `ManifestEntry` with `password` / `totp_secret` — that's expected; slices 3–6 fix the callers.

### Task 4: Rewrite `shared/types.ts` with typed-item shapes

**Files:**
- Modify: `extension/src/shared/types.ts`

- [ ] **Step 1: Replace the entire file**

```ts
/// Typed-item shared TypeScript types.
///
/// These mirror the Rust core's serde serialization. See
/// crates/relicario-core/src/item.rs, item_types/, and settings.rs
/// for the source shapes.

// --- IDs ---

export type ItemId = string;       // 16-char hex
export type FieldId = string;      // 16-char hex
export type AttachmentId = string; // 16-char hex (sha256 of plaintext, truncated)

// --- ItemType / ItemCore ---

// snake_case from serde rename_all
export type ItemType =
  | 'login' | 'secure_note' | 'identity' | 'card' | 'key' | 'document' | 'totp';

// ItemCore is internally-tagged on "type":
//   Login → { type: 'login', username, password, url, totp }
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);

// Optional fields use `?` because Rust #[serde(skip_serializing_if = "Option::is_none")]
// omits them from the JSON; serde_wasm_bindgen produces `undefined` on read.

export interface LoginCore {
  username?: string;
  password?: string;
  url?: string;
  totp?: TotpConfig;
}

export interface SecureNoteCore { body: string; }

export interface IdentityCore {
  full_name?: string;
  address?: string;
  phone?: string;
  email?: string;
  date_of_birth?: string;           // "YYYY-MM-DD"
}

export interface CardCore {
  number?: string;
  holder?: string;
  expiry?: { month: number; year: number };
  cvv?: string;
  pin?: string;
  kind: CardKind;
}

export type CardKind = 'credit' | 'debit' | 'gift' | 'loyalty' | 'other';

export interface KeyCore {
  key_material: string;
  label?: string;
  public_key?: string;
  algorithm?: string;
}

export interface DocumentCore {
  filename: string;
  mime_type: string;
  primary_attachment: AttachmentId;
}

export interface TotpCore {
  config: TotpConfig;
  issuer?: string;
  label?: string;
}

// --- TOTP ---

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

// --- Sections + custom fields ---

export interface Section {
  name?: string;
  fields: Field[];
}

export interface Field {
  id: FieldId;
  label: string;
  kind: FieldKind;
  value: FieldValue;
  hidden_by_default: boolean;
}

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

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

// --- Attachments + history ---

export interface AttachmentRef {
  id: AttachmentId;
  filename: string;
  mime_type: string;
  size: number;
  created: number;
}

export interface FieldHistoryEntry {
  value: string;
  replaced_at: number;
}

export interface AttachmentSummary {
  id: AttachmentId;
  filename: string;
  mime_type: string;
  size: number;
}

// --- Item envelope ---

export interface Item {
  id: ItemId;
  title: string;
  type: ItemType;                   // Rust r#type → JSON key "type"
  tags: string[];
  favorite: boolean;
  group?: string;
  notes?: string;
  created: number;
  modified: number;
  trashed_at?: number;
  core: ItemCore;
  sections: Section[];
  attachments: AttachmentRef[];
  field_history: Record<FieldId, FieldHistoryEntry[]>;
}

// --- Manifest (schema_version 2) ---

export interface Manifest {
  schema_version: number;           // 2
  items: Record<ItemId, ManifestEntry>;
}

export interface ManifestEntry {
  id: ItemId;
  type: ItemType;
  title: string;
  tags: string[];
  favorite: boolean;
  group?: string;
  icon_hint?: string;
  modified: number;
  trashed_at?: number;
  attachment_summaries: AttachmentSummary[];
}

// --- Vault settings (only the fields α touches) ---
// Full shape lives on the Rust side and in docs/superpowers/specs/2026-04-18-relicario-typed-items-design.md
// We leave retention/generator/caps opaque to α so we don't accidentally mutate them.

export interface VaultSettings {
  trash_retention: unknown;
  field_history_retention: unknown;
  generator_defaults: unknown;
  attachment_caps: unknown;
  autofill_origin_acks: Record<string, number>;
}

// --- Vault config (device-local) ---

export interface VaultConfig {
  hostType: 'gitea' | 'github';
  hostUrl: string;
  repoPath: string;
  apiToken: string;
}

export interface SetupState {
  config: VaultConfig | null;
  imageBase64: string | null;
  isConfigured: boolean;
}

// --- Device-local UX settings (chrome.storage.local — renamed from RelicarioSettings) ---

export interface DeviceSettings {
  captureEnabled: boolean;
  captureStyle: 'bar' | 'toast';
}

export const DEFAULT_DEVICE_SETTINGS: DeviceSettings = {
  captureEnabled: false,
  captureStyle: 'bar',
};

// --- Generator request (matches Rust GeneratorRequest — tag="kind") ---

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; }
export type SymbolCharset =
  | { kind: 'safe_only' }
  | { kind: 'extended' }
  | { kind: 'custom'; value: string };

// Default used by the α popup "gen" button:
export const DEFAULT_PASSWORD_REQUEST: GeneratorRequest = {
  kind: 'random',
  length: 20,
  classes: { lower: true, upper: true, digits: true, symbols: true },
  symbol_charset: { kind: 'safe_only' },
};
```

- [ ] **Step 2: Commit**

```bash
cd /home/alee/Sources/relicario
git add extension/src/shared/types.ts
git commit -m "feat(ext): typed-item TS types mirroring relicario-core serde"
```

### Task 5: Rewrite `shared/messages.ts` with split unions

**Files:**
- Modify: `extension/src/shared/messages.ts`

- [ ] **Step 1: Replace the entire file**

```ts
import type {
  Item, ItemId, Manifest, ManifestEntry, VaultConfig, SetupState,
  DeviceSettings, GeneratorRequest,
} from './types';

// --- Messages a popup (or setup page) may send ---

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
  | { 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' }
  | { type: 'update_settings'; settings: Partial<DeviceSettings> }
  | { type: 'get_blacklist' }
  | { type: 'remove_blacklist'; hostname: string };

// --- Messages a content script may send ---

// Note deliberate absence of a `url` field — the SW derives origin from sender.tab.url.

export type ContentMessage =
  | { type: 'get_autofill_candidates' }
  | { type: 'get_credentials'; id: ItemId }
  | { type: 'check_credential'; username: string; password: string }
  | { type: 'blacklist_site' };

// --- Union for chrome.runtime.sendMessage call sites ---

export type Request = PopupMessage | ContentMessage;

// --- Response ---

export type Response =
  | { ok: true; data?: unknown }
  | { ok: false; error: string };

// --- Typed response helpers ---

export interface IsUnlockedResponse extends Extract<Response, { ok: true }> {
  data: { unlocked: boolean };
}

export interface ListItemsResponse extends Extract<Response, { ok: true }> {
  data: { items: Array<[ItemId, ManifestEntry]> };
}

export interface GetItemResponse extends Extract<Response, { ok: true }> {
  data: { item: Item };
}

export interface TotpResponse extends Extract<Response, { ok: true }> {
  data: { code: string; expires_at: number };
}

export interface AutofillCandidatesResponse extends Extract<Response, { ok: true }> {
  data: { candidates: Array<[ItemId, ManifestEntry]> };
}

export interface CredentialsResponse extends Extract<Response, { ok: true }> {
  data:
    | { requires_ack: true; hostname: string }
    | { username: string; password: string };
}

export interface SetupStateResponse extends Extract<Response, { ok: true }> {
  data: SetupState;
}

export interface GeneratePasswordResponse extends Extract<Response, { ok: true }> {
  data: { password: string };
}

export interface RatePassphraseResponse extends Extract<Response, { ok: true }> {
  data: { score: number; guesses_log10: number };
}

// --- Capability sets (consumed by the router) ---

export const POPUP_ONLY_TYPES: ReadonlySet<PopupMessage['type']> = new Set([
  'is_unlocked', 'unlock', 'lock', 'list_items', 'get_item', 'add_item',
  'update_item', 'delete_item', 'get_totp', 'sync', 'get_setup_state',
  'save_setup', 'rate_passphrase', 'generate_password', 'fill_credentials',
  'ack_autofill_origin', 'get_settings', 'update_settings', 'get_blacklist',
  'remove_blacklist',
] as PopupMessage['type'][]);

export const CONTENT_CALLABLE_TYPES: ReadonlySet<ContentMessage['type']> = new Set([
  'get_autofill_candidates', 'get_credentials', 'check_credential', 'blacklist_site',
] as ContentMessage['type'][]);
```

- [ ] **Step 2: Commit**

```bash
git add extension/src/shared/messages.ts
git commit -m "feat(ext): split PopupMessage / ContentMessage unions + capability sets"
```

### Task 6: Add `shared/base32.ts` with encode/decode and tests

**Files:**
- Create: `extension/src/shared/base32.ts`
- Create: `extension/src/shared/__tests__/base32.test.ts`

This module is the minimum support the Login form needs to accept a base32-encoded TOTP secret string and format the re-encoded form for display.

- [ ] **Step 1: Write the failing test**

```ts
// extension/src/shared/__tests__/base32.test.ts
import { describe, expect, it } from 'vitest';
import { base32Decode, base32Encode } from '../base32';

describe('base32', () => {
  // RFC 4648 § 10 test vectors
  it('encodes empty', () => expect(base32Encode(new Uint8Array())).toBe(''));
  it('encodes "f"',   () => expect(base32Encode(new TextEncoder().encode('f'))).toBe('MY'));
  it('encodes "fo"',  () => expect(base32Encode(new TextEncoder().encode('fo'))).toBe('MZXQ'));
  it('encodes "foo"', () => expect(base32Encode(new TextEncoder().encode('foo'))).toBe('MZXW6'));
  it('encodes "foob"',  () => expect(base32Encode(new TextEncoder().encode('foob'))).toBe('MZXW6YQ'));
  it('encodes "fooba"', () => expect(base32Encode(new TextEncoder().encode('fooba'))).toBe('MZXW6YTB'));
  it('encodes "foobar"',() => expect(base32Encode(new TextEncoder().encode('foobar'))).toBe('MZXW6YTBOI'));

  it('decodes round-trip', () => {
    const bytes = new Uint8Array([0x12, 0x34, 0x56, 0x78, 0x9a]);
    expect(base32Decode(base32Encode(bytes))).toEqual(bytes);
  });

  it('decodes case-insensitively', () => {
    expect(base32Decode('mzxw6')).toEqual(new TextEncoder().encode('foo'));
  });

  it('decodes ignoring whitespace and padding', () => {
    expect(base32Decode('JBSW Y3DP EHPK 3PXP==')).toEqual(
      base32Decode('JBSWY3DPEHPK3PXP'),
    );
  });

  it('throws on invalid characters', () => {
    expect(() => base32Decode('MZ!W6')).toThrow();
  });
});
```

Note: Vitest is not installed yet; this test file will be run in slice 4 Task 15 where Vitest is wired up. For this task, just create the file so slice 2 stays compilation-clean.

- [ ] **Step 2: Write `base32.ts`**

```ts
/// Minimal RFC 4648 base32 encode/decode for TOTP secret parsing.
///
/// Mirrors the encoder in crates/relicario-core/src/item.rs:base32_encode.
/// Decode is case-insensitive, tolerates whitespace and `=` padding.

const ALPHA = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';

export function base32Encode(bytes: Uint8Array): string {
  let out = '';
  let buffer = 0;
  let bits = 0;
  for (const b of bytes) {
    buffer = (buffer << 8) | b;
    bits += 8;
    while (bits >= 5) {
      const idx = (buffer >> (bits - 5)) & 0x1f;
      out += ALPHA[idx];
      bits -= 5;
    }
  }
  if (bits > 0) {
    const idx = (buffer << (5 - bits)) & 0x1f;
    out += ALPHA[idx];
  }
  return out;
}

export function base32Decode(input: string): Uint8Array {
  const cleaned = input.replace(/\s+/g, '').replace(/=+$/g, '').toUpperCase();
  const out: number[] = [];
  let buffer = 0;
  let bits = 0;
  for (const ch of cleaned) {
    const idx = ALPHA.indexOf(ch);
    if (idx === -1) throw new Error(`base32: invalid character "${ch}"`);
    buffer = (buffer << 5) | idx;
    bits += 5;
    if (bits >= 8) {
      out.push((buffer >> (bits - 8)) & 0xff);
      bits -= 8;
    }
  }
  return new Uint8Array(out);
}
```

- [ ] **Step 3: Commit**

```bash
git add extension/src/shared/base32.ts extension/src/shared/__tests__/base32.test.ts
git commit -m "feat(ext): base32 encode/decode for TOTP secret parse"
```

---

## Slice 3 — Service-worker session, vault, index

Goal: rewrite the SW vault layer around the typed-item WASM surface using the opaque `SessionHandle`. `index.ts` temporarily keeps its single-function message handler (still flat, no router split yet — that's slice 4) but the handler's internals now call the new vault module. Login-parity is *not* yet in place; slice 6 wires that. This slice's success criterion is: `bun run build` succeeds.

### Task 7: Create `service-worker/session.ts`

**Files:**
- Create: `extension/src/service-worker/session.ts`

- [ ] **Step 1: Write the file**

```ts
/// Single module-scope "current" SessionHandle.
///
/// α assumes one vault per extension install. The master key lives only
/// inside WASM linear memory (wrapped in Zeroizing<[u8;32]>); this module
/// just holds the opaque handle that names it.

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 requireCurrent(): SessionHandle {
  if (!current) throw new Error('vault_locked');
  return current;
}

export function clearCurrent(): void {
  if (!current) return;
  try { current.free(); } catch { /* already freed */ }
  current = null;
}
```

- [ ] **Step 2: Commit**

```bash
git add extension/src/service-worker/session.ts
git commit -m "feat(ext/sw): SessionHandle lifecycle module"
```

### Task 8: Rewrite `service-worker/vault.ts` for typed items

**Files:**
- Modify: `extension/src/service-worker/vault.ts`

- [ ] **Step 1: Replace the entire file**

```ts
/// Typed-item vault operations. All calls are handle-keyed — the master key
/// never crosses the WASM boundary.

import type { SessionHandle } from '../../wasm/relicario_wasm';
import type { GitHost } from './git-host';
import type { Item, ItemId, Manifest, ManifestEntry, VaultSettings } from '../shared/types';

// eslint-disable-next-line @typescript-eslint/no-explicit-any
let wasm: any = null;

// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function setWasm(w: any): void { wasm = w; }

function requireWasm(): any {
  if (!wasm) throw new Error('WASM module not initialized');
  return wasm;
}

export interface VaultMeta {
  salt: Uint8Array;
  paramsJson: string;
}

export async function fetchVaultMeta(git: GitHost): Promise<VaultMeta> {
  const saltBytes = await git.readFile('.relicario/salt');
  const paramsRaw = await git.readFile('.relicario/params.json');
  const paramsJson = new TextDecoder().decode(paramsRaw);
  return { salt: saltBytes, paramsJson };
}

// --- Manifest ---

export async function fetchAndDecryptManifest(
  git: GitHost,
  handle: SessionHandle,
): Promise<Manifest> {
  const w = requireWasm();
  const ciphertext = await git.readFile('manifest.enc');
  return w.manifest_decrypt(handle, ciphertext) as Manifest;
}

export async function encryptAndWriteManifest(
  git: GitHost,
  handle: SessionHandle,
  manifest: Manifest,
  message: string,
): Promise<void> {
  const w = requireWasm();
  const ciphertext = w.manifest_encrypt(handle, JSON.stringify(manifest));
  await git.writeFile('manifest.enc', ciphertext, message);
}

// --- Items ---

export async function fetchAndDecryptItem(
  git: GitHost,
  handle: SessionHandle,
  id: ItemId,
): Promise<Item> {
  const w = requireWasm();
  const ciphertext = await git.readFile(`items/${id}.enc`);
  return w.item_decrypt(handle, ciphertext) as Item;
}

export async function encryptAndWriteItem(
  git: GitHost,
  handle: SessionHandle,
  id: ItemId,
  item: Item,
  message: string,
): Promise<void> {
  const w = requireWasm();
  const ciphertext = w.item_encrypt(handle, JSON.stringify(item));
  await git.writeFile(`items/${id}.enc`, ciphertext, message);
}

// --- Settings (the α subset the SW reads/writes is autofill_origin_acks) ---

export async function fetchAndDecryptSettings(
  git: GitHost,
  handle: SessionHandle,
): Promise<VaultSettings> {
  const w = requireWasm();
  const ciphertext = await git.readFile('settings.enc');
  return w.settings_decrypt(handle, ciphertext) as VaultSettings;
}

export async function encryptAndWriteSettings(
  git: GitHost,
  handle: SessionHandle,
  settings: VaultSettings,
  message: string,
): Promise<void> {
  const w = requireWasm();
  const ciphertext = w.settings_encrypt(handle, JSON.stringify(settings));
  await git.writeFile('settings.enc', ciphertext, message);
}

// --- In-memory manifest helpers ---

export function listItems(
  manifest: Manifest,
  group?: string,
): Array<[ItemId, ManifestEntry]> {
  const entries = Object.entries(manifest.items) as Array<[ItemId, ManifestEntry]>;
  // Hide trashed items from the default list view.
  const live = entries.filter(([, e]) => e.trashed_at === undefined);
  if (!group) return live;
  const g = group.toLowerCase();
  return live.filter(([, e]) => e.group?.toLowerCase() === g);
}

export function searchItems(
  manifest: Manifest,
  query: string,
): Array<[ItemId, ManifestEntry]> {
  const q = query.toLowerCase();
  return (Object.entries(manifest.items) as Array<[ItemId, ManifestEntry]>)
    .filter(([, e]) => e.trashed_at === undefined)
    .filter(([, e]) => {
      if (e.title.toLowerCase().includes(q)) return true;
      if (e.tags.some((t) => t.toLowerCase().includes(q))) return true;
      return false;
    });
}

export function findByHostname(
  manifest: Manifest,
  hostname: string,
): Array<[ItemId, ManifestEntry]> {
  const h = hostname.toLowerCase();
  return (Object.entries(manifest.items) as Array<[ItemId, ManifestEntry]>)
    .filter(([, e]) => e.trashed_at === undefined)
    .filter(([, e]) => (e.icon_hint ?? '').toLowerCase() === h);
  // icon_hint is derived by Rust core from LoginCore.url's hostname,
  // so hostname equality on icon_hint is the cheapest match.
}
```

- [ ] **Step 2: Verify TS compile passes for vault.ts**

```bash
cd /home/alee/Sources/relicario/extension
bunx tsc --noEmit src/service-worker/vault.ts 2>&1 | head -20
```

Expected: no errors originating inside `vault.ts`. Errors from consumers (like `index.ts`) are fine for now.

- [ ] **Step 3: Commit**

```bash
git add extension/src/service-worker/vault.ts
git commit -m "feat(ext/sw): typed-item vault ops via SessionHandle"
```

### Task 9: Rewire `service-worker/index.ts` for the new vault surface

This is a transitional state — we keep the flat handler from today but replumb it against the new types. The router split lands in slice 4.

**Files:**
- Modify: `extension/src/service-worker/index.ts`

- [ ] **Step 1: Replace the file with the transitional (non-router) shape**

```ts
/// Background script entry point for the relicario browser extension.
///
/// Transitional slice-3 shape: keeps the flat onMessage listener but uses
/// the new typed-item vault + SessionHandle. The router split lands in
/// slice 4.

import type { Request, Response } from '../shared/messages';
import type { Item, ItemId, Manifest, VaultConfig, SetupState, DeviceSettings } from '../shared/types';
import { DEFAULT_DEVICE_SETTINGS } from '../shared/types';
import type { GitHost } from './git-host';
import { createGitHost, base64ToUint8Array } from './git-host';
import * as vault from './vault';
import * as session from './session';

// --- WASM module load ---

// @ts-ignore TS2307 — resolved by webpack alias / copy
import initDefault, { initSync } from '../../wasm/relicario_wasm.js';
// @ts-ignore TS2307
import * as wasmBindings from '../../wasm/relicario_wasm.js';

type WasmModule = typeof wasmBindings;
let wasm: WasmModule | null = null;

async function initWasm(): Promise<WasmModule> {
  if (wasm) return wasm;

  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  const SWGlobalScope = (globalThis as any).ServiceWorkerGlobalScope as (new () => ServiceWorker) | undefined;
  const isServiceWorker = typeof SWGlobalScope !== 'undefined'
    && self instanceof (SWGlobalScope as unknown as typeof EventTarget);

  if (isServiceWorker) {
    const wasmResponse = await fetch(chrome.runtime.getURL('relicario_wasm_bg.wasm'));
    const wasmBytes = await wasmResponse.arrayBuffer();
    initSync({ module: new WebAssembly.Module(wasmBytes) });
  } else {
    const wasmUrl = chrome.runtime.getURL('relicario_wasm_bg.wasm');
    await initDefault(wasmUrl);
  }

  vault.setWasm(wasmBindings);
  wasm = wasmBindings;
  return wasm;
}

// --- In-memory vault state (cleared on lock or SW restart) ---

let manifest: Manifest | null = null;
let gitHost: GitHost | null = null;
const totpConfigCache: Map<ItemId, unknown> = new Map();

// --- chrome.storage.local helpers ---

async function loadConfig(): Promise<VaultConfig | null> {
  const result = await chrome.storage.local.get('vaultConfig');
  return (result.vaultConfig as VaultConfig) ?? null;
}

async function loadImageBase64(): Promise<string | null> {
  const result = await chrome.storage.local.get('imageBase64');
  return (result.imageBase64 as string) ?? null;
}

async function loadSetupState(): Promise<SetupState> {
  const config = await loadConfig();
  const imageBase64 = await loadImageBase64();
  return { config, imageBase64, isConfigured: config !== null && imageBase64 !== null };
}

async function loadSettings(): Promise<DeviceSettings> {
  const result = await chrome.storage.local.get('relicarioSettings');
  return (result.relicarioSettings as DeviceSettings) ?? { ...DEFAULT_DEVICE_SETTINGS };
}

async function saveSettings(settings: DeviceSettings): Promise<void> {
  await chrome.storage.local.set({ relicarioSettings: settings });
}

async function loadBlacklist(): Promise<string[]> {
  const result = await chrome.storage.local.get('captureBlacklist');
  return (result.captureBlacklist as string[]) ?? [];
}

async function saveBlacklist(list: string[]): Promise<void> {
  await chrome.storage.local.set({ captureBlacklist: list });
}

function ensureGitHost(config: VaultConfig): GitHost {
  if (!gitHost) {
    gitHost = createGitHost(config.hostType, config.hostUrl, config.repoPath, config.apiToken);
  }
  return gitHost;
}

// --- Message handler (flat; router split in slice 4) ---

chrome.runtime.onMessage.addListener(
  (request: Request, _sender: chrome.runtime.MessageSender, sendResponse: (response: Response) => void) => {
    handleMessage(request)
      .then(sendResponse)
      .catch((err: Error) => sendResponse({ ok: false, error: err.message }));
    return true;
  },
);

async function handleMessage(req: Request): Promise<Response> {
  switch (req.type) {
    case 'is_unlocked':
      return { ok: true, data: { unlocked: session.getCurrent() !== null } };

    case 'unlock': {
      const w = await initWasm();
      const config = await loadConfig();
      if (!config) return { ok: false, error: 'Extension not configured. Run setup first.' };
      const imageB64 = await loadImageBase64();
      if (!imageB64) return { ok: false, error: 'Reference image not set. Run setup first.' };

      const imageBytes = base64ToUint8Array(imageB64);
      const git = ensureGitHost(config);
      const meta = await vault.fetchVaultMeta(git);

      const handle = w.unlock(req.passphrase, imageBytes, meta.salt, meta.paramsJson);
      session.setCurrent(handle);
      // Clear passphrase from scope best-effort.
      // (JS strings are immutable; the message object goes out of scope after return.)
      (req as { passphrase: string }).passphrase = '';

      manifest = await vault.fetchAndDecryptManifest(git, handle);
      return { ok: true };
    }

    case 'lock':
      session.clearCurrent();
      manifest = null;
      totpConfigCache.clear();
      return { ok: true };

    case 'list_items': {
      if (!manifest) return { ok: false, error: 'vault_locked' };
      const items = vault.listItems(manifest, req.group);
      return { ok: true, data: { items } };
    }

    case 'get_item': {
      const handle = session.getCurrent();
      if (!handle || !gitHost) return { ok: false, error: 'vault_locked' };
      const item = await vault.fetchAndDecryptItem(gitHost, handle, req.id);
      return { ok: true, data: { item } };
    }

    case 'add_item': {
      const handle = session.getCurrent();
      if (!handle || !gitHost || !manifest) return { ok: false, error: 'vault_locked' };
      const w = await initWasm();
      const id = w.new_item_id();
      const item: Item = { ...req.item, id };

      await vault.encryptAndWriteItem(gitHost, handle, id, item, `add: ${item.title}`);
      manifest.items[id] = itemToManifestEntry(item);
      await vault.encryptAndWriteManifest(gitHost, handle, manifest, `manifest: add ${item.title}`);
      return { ok: true, data: { id } };
    }

    case 'update_item': {
      const handle = session.getCurrent();
      if (!handle || !gitHost || !manifest) return { ok: false, error: 'vault_locked' };
      await vault.encryptAndWriteItem(gitHost, handle, req.id, req.item, `update: ${req.item.title}`);
      manifest.items[req.id] = itemToManifestEntry(req.item);
      await vault.encryptAndWriteManifest(gitHost, handle, manifest, `manifest: update ${req.item.title}`);
      totpConfigCache.delete(req.id);
      return { ok: true };
    }

    case 'delete_item': {
      const handle = session.getCurrent();
      if (!handle || !gitHost || !manifest) return { ok: false, error: 'vault_locked' };
      const entry = manifest.items[req.id];
      if (!entry) return { ok: false, error: 'item_not_found' };
      // Soft-delete: fetch the item, set trashed_at, write it back.
      const item = await vault.fetchAndDecryptItem(gitHost, handle, req.id);
      const now = Math.floor(Date.now() / 1000);
      const updated: Item = { ...item, trashed_at: now, modified: now };
      await vault.encryptAndWriteItem(gitHost, handle, req.id, updated, `trash: ${entry.title}`);
      manifest.items[req.id] = { ...entry, trashed_at: now, modified: now };
      await vault.encryptAndWriteManifest(gitHost, handle, manifest, `manifest: trash ${entry.title}`);
      return { ok: true };
    }

    case 'sync': {
      const handle = session.getCurrent();
      if (!handle || !gitHost) return { ok: false, error: 'vault_locked' };
      manifest = await vault.fetchAndDecryptManifest(gitHost, handle);
      return { ok: true };
    }

    case 'get_setup_state': {
      return { ok: true, data: await loadSetupState() };
    }

    case 'save_setup': {
      await chrome.storage.local.set({
        vaultConfig: req.config,
        imageBase64: req.imageBase64,
      });
      gitHost = null;
      return { ok: true };
    }

    case 'rate_passphrase': {
      const w = await initWasm();
      return { ok: true, data: w.rate_passphrase(req.passphrase) };
    }

    case 'generate_password': {
      const w = await initWasm();
      const password = w.generate_password(JSON.stringify(req.request));
      return { ok: true, data: { password } };
    }

    case 'get_settings':
      return { ok: true, data: { settings: await loadSettings() } };

    case 'update_settings': {
      const current = await loadSettings();
      await saveSettings({ ...current, ...req.settings });
      return { ok: true };
    }

    case 'get_blacklist':
      return { ok: true, data: { blacklist: await loadBlacklist() } };

    case 'remove_blacklist': {
      const bl = await loadBlacklist();
      await saveBlacklist(bl.filter((h) => h !== req.hostname));
      return { ok: true };
    }

    // Slice 4 / 5 will wire these up properly (currently placeholders so the build passes):
    case 'get_totp':
    case 'fill_credentials':
    case 'ack_autofill_origin':
    case 'get_autofill_candidates':
    case 'get_credentials':
    case 'check_credential':
    case 'blacklist_site':
      return { ok: false, error: 'not_implemented_yet' };

    default: {
      const exhaustive: never = req;
      return { ok: false, error: `unknown_message_type: ${(exhaustive as { type: string }).type}` };
    }
  }
}

function itemToManifestEntry(item: Item) {
  return {
    id: item.id,
    type: item.type,
    title: item.title,
    tags: item.tags,
    favorite: item.favorite,
    group: item.group,
    icon_hint: (item.core.type === 'login' && item.core.url)
      ? safeHostname(item.core.url) : undefined,
    modified: item.modified,
    trashed_at: item.trashed_at,
    attachment_summaries: item.attachments.map((a) => ({
      id: a.id, filename: a.filename, mime_type: a.mime_type, size: a.size,
    })),
  };
}

function safeHostname(url: string): string | undefined {
  try { return new URL(url).hostname; } catch { return undefined; }
}
```

- [ ] **Step 2: Verify the extension compiles**

```bash
cd /home/alee/Sources/relicario/extension
bun run build 2>&1 | tail -20
```

Expected: webpack build succeeds. If there are TS errors in `popup/` or `content/`, they're pre-existing and will be resolved in slices 5–6.

**If `bun run build` fails because popup/content still references the old `Entry` type**, that's expected — slice 3 intentionally breaks those callers. Workaround: add `// @ts-nocheck` at the top of the files that fail, in a separate commit labelled `chore(ext): silence popup/content errors until slice 6`. Remove the `@ts-nocheck` comments in slice 6 Task 22.

- [ ] **Step 3: Commit**

```bash
git add extension/src/service-worker/index.ts
git commit -m "feat(ext/sw): rewire flat handler onto typed-item vault + SessionHandle"
```

### Task 10: Item round-trip smoke test via CLI

Goal: prove the TS types round-trip cleanly through the WASM `item_encrypt` / `item_decrypt` against a real CLI-written vault. This is a manual verification — formal automation waits on slice 4's Vitest setup.

**Files:** no code changes.

- [ ] **Step 1: Create a test vault with the CLI**

```bash
cd /home/alee/Sources/relicario
mkdir -p /tmp/ext-test-vault && cd /tmp/ext-test-vault
git init
RELICARIO_TEST_PASSPHRASE='correct horse battery staple parapet' \
  RELICARIO_TEST_ITEM_SECRET=$(head -c 32 /dev/urandom | base64) \
  cargo run -p relicario-cli -- init --non-interactive --output /tmp/test-image.jpg
# Use the test image for re-open:
RELICARIO_TEST_PASSPHRASE='correct horse battery staple parapet' \
  cargo run -p relicario-cli -- add login --title 'GitHub' \
    --username alice --url https://github.com --password-stdin <<< 'hunter2'
ls items/ manifest.enc settings.enc
```

Expected: one item file, a manifest, and a settings file.

- [ ] **Step 2: Load the extension's service worker in isolation and decrypt the item**

Skip formal automation for α. Instead verify the wire format by reading the Rust-side JSON form and mentally comparing to `shared/types.ts`:

```bash
cd /tmp/ext-test-vault
# Rely on the fact that encrypt_item round-trips in the Rust tests;
# this step is a placeholder for slice 4's Vitest harness.
```

- [ ] **Step 3: Commit the pre-flight findings if any types had to change**

If the round-trip reveals drift (e.g. an optional field serialized differently than predicted), update `shared/types.ts` in a dedicated commit now rather than later. If nothing drifted, skip this step.

---

## Slice 4 — Split router with sender checks

Goal: split the flat handler in `index.ts` into three files (`router/index.ts`, `router/popup-only.ts`, `router/content-callable.ts`) with the sender-check dispatch from the spec. Wire up Vitest and write the router's acceptance/rejection matrix.

### Task 11: Scaffold `router/popup-only.ts` (move existing handlers)

**Files:**
- Create: `extension/src/service-worker/router/popup-only.ts`

- [ ] **Step 1: Write the file**

```ts
/// Popup-callable message handlers.
///
/// Every export here assumes the router has already verified sender identity
/// via sender.url === popup.html (or setup.html for save_setup).

import type { PopupMessage, Response } from '../../shared/messages';
import type { Item, ItemId, Manifest, VaultConfig, SetupState, DeviceSettings } from '../../shared/types';
import { DEFAULT_DEVICE_SETTINGS } from '../../shared/types';
import type { GitHost } from '../git-host';
import { createGitHost, base64ToUint8Array } from '../git-host';
import * as vault from '../vault';
import * as session from '../session';

// --- Shared ambient state owned by the SW module ---
//
// The router keeps these on a single `state` object and injects it into the
// handler so testing can mock them without reaching for globals.

export interface PopupState {
  manifest: Manifest | null;
  gitHost: GitHost | null;
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  wasm: any;
}

export async function handle(
  msg: PopupMessage,
  state: PopupState,
  sender: chrome.runtime.MessageSender,
): Promise<Response> {
  void sender; // unused in most branches; retained for symmetry with content-callable
  switch (msg.type) {
    case 'is_unlocked':
      return { ok: true, data: { unlocked: session.getCurrent() !== null } };

    case 'unlock': {
      const w = state.wasm;
      const config = await loadConfig();
      if (!config) return { ok: false, error: 'Extension not configured. Run setup first.' };
      const imageB64 = await loadImageBase64();
      if (!imageB64) return { ok: false, error: 'Reference image not set. Run setup first.' };

      const imageBytes = base64ToUint8Array(imageB64);
      if (!state.gitHost) state.gitHost = createGitHost(config.hostType, config.hostUrl, config.repoPath, config.apiToken);
      const meta = await vault.fetchVaultMeta(state.gitHost);

      const handle = w.unlock(msg.passphrase, imageBytes, meta.salt, meta.paramsJson);
      session.setCurrent(handle);
      (msg as { passphrase: string }).passphrase = '';

      state.manifest = await vault.fetchAndDecryptManifest(state.gitHost, handle);
      return { ok: true };
    }

    case 'lock':
      session.clearCurrent();
      state.manifest = null;
      return { ok: true };

    case 'list_items': {
      if (!state.manifest) return { ok: false, error: 'vault_locked' };
      return { ok: true, data: { items: vault.listItems(state.manifest, msg.group) } };
    }

    case 'get_item': {
      const handle = session.getCurrent();
      if (!handle || !state.gitHost) return { ok: false, error: 'vault_locked' };
      const item = await vault.fetchAndDecryptItem(state.gitHost, handle, msg.id);
      return { ok: true, data: { item } };
    }

    case 'add_item': {
      const handle = session.getCurrent();
      if (!handle || !state.gitHost || !state.manifest) return { ok: false, error: 'vault_locked' };
      const id = state.wasm.new_item_id();
      const item: Item = { ...msg.item, id };
      await vault.encryptAndWriteItem(state.gitHost, handle, id, item, `add: ${item.title}`);
      state.manifest.items[id] = itemToManifestEntry(item);
      await vault.encryptAndWriteManifest(state.gitHost, handle, state.manifest, `manifest: add ${item.title}`);
      return { ok: true, data: { id } };
    }

    case 'update_item': {
      const handle = session.getCurrent();
      if (!handle || !state.gitHost || !state.manifest) return { ok: false, error: 'vault_locked' };
      await vault.encryptAndWriteItem(state.gitHost, handle, msg.id, msg.item, `update: ${msg.item.title}`);
      state.manifest.items[msg.id] = itemToManifestEntry(msg.item);
      await vault.encryptAndWriteManifest(state.gitHost, handle, state.manifest, `manifest: update ${msg.item.title}`);
      return { ok: true };
    }

    case 'delete_item': {
      const handle = session.getCurrent();
      if (!handle || !state.gitHost || !state.manifest) return { ok: false, error: 'vault_locked' };
      const entry = state.manifest.items[msg.id];
      if (!entry) return { ok: false, error: 'item_not_found' };
      const item = await vault.fetchAndDecryptItem(state.gitHost, handle, msg.id);
      const now = Math.floor(Date.now() / 1000);
      const updated: Item = { ...item, trashed_at: now, modified: now };
      await vault.encryptAndWriteItem(state.gitHost, handle, msg.id, updated, `trash: ${entry.title}`);
      state.manifest.items[msg.id] = { ...entry, trashed_at: now, modified: now };
      await vault.encryptAndWriteManifest(state.gitHost, handle, state.manifest, `manifest: trash ${entry.title}`);
      return { ok: true };
    }

    case 'get_totp': {
      const handle = session.getCurrent();
      if (!handle || !state.gitHost) return { ok: false, error: 'vault_locked' };
      const item = await vault.fetchAndDecryptItem(state.gitHost, handle, msg.id);
      if (item.core.type !== 'login' || !item.core.totp) {
        return { ok: false, error: 'no_totp' };
      }
      const now = Math.floor(Date.now() / 1000);
      const code = state.wasm.totp_compute(JSON.stringify(item.core.totp), BigInt(now));
      return { ok: true, data: { code: code.code, expires_at: code.expires_at } };
    }

    case 'sync': {
      const handle = session.getCurrent();
      if (!handle || !state.gitHost) return { ok: false, error: 'vault_locked' };
      state.manifest = await vault.fetchAndDecryptManifest(state.gitHost, handle);
      return { ok: true };
    }

    case 'get_setup_state':
      return { ok: true, data: await loadSetupState() };

    case 'save_setup': {
      await chrome.storage.local.set({
        vaultConfig: msg.config,
        imageBase64: msg.imageBase64,
      });
      state.gitHost = null;
      return { ok: true };
    }

    case 'rate_passphrase':
      return { ok: true, data: state.wasm.rate_passphrase(msg.passphrase) };

    case 'generate_password': {
      const password = state.wasm.generate_password(JSON.stringify(msg.request));
      return { ok: true, data: { password } };
    }

    case 'fill_credentials':
      return handleFillCredentials(msg, state);

    case 'ack_autofill_origin': {
      const handle = session.getCurrent();
      if (!handle || !state.gitHost) return { ok: false, error: 'vault_locked' };
      const settings = await vault.fetchAndDecryptSettings(state.gitHost, handle);
      const acks = { ...(settings.autofill_origin_acks ?? {}), [msg.hostname]: Math.floor(Date.now() / 1000) };
      const updated = { ...settings, autofill_origin_acks: acks };
      await vault.encryptAndWriteSettings(state.gitHost, handle, updated, `settings: ack origin ${msg.hostname}`);
      return { ok: true };
    }

    case 'get_settings':
      return { ok: true, data: { settings: await loadDeviceSettings() } };

    case 'update_settings': {
      const current = await loadDeviceSettings();
      await saveDeviceSettings({ ...current, ...msg.settings });
      return { ok: true };
    }

    case 'get_blacklist':
      return { ok: true, data: { blacklist: await loadBlacklist() } };

    case 'remove_blacklist': {
      const bl = await loadBlacklist();
      await saveBlacklist(bl.filter((h) => h !== msg.hostname));
      return { ok: true };
    }
  }
}

// --- fill_credentials with captured-tab verification (audit M5) ---

async function handleFillCredentials(
  msg: Extract<PopupMessage, { type: 'fill_credentials' }>,
  state: PopupState,
): Promise<Response> {
  const handle = session.getCurrent();
  if (!handle || !state.gitHost || !state.manifest) return { ok: false, error: 'vault_locked' };

  let tab: chrome.tabs.Tab;
  try { tab = await chrome.tabs.get(msg.capturedTabId); }
  catch { return { ok: false, error: 'captured_tab_gone' }; }

  const currentHost  = safeHostname(tab.url ?? '');
  const capturedHost = safeHostname(msg.capturedUrl);
  if (!currentHost || !capturedHost || currentHost !== capturedHost) {
    return { ok: false, error: 'tab_navigated' };
  }

  const item = await vault.fetchAndDecryptItem(state.gitHost, handle, msg.id);
  if (item.core.type !== 'login') return { ok: false, error: 'not_a_login' };
  const itemHost = safeHostname(item.core.url ?? '');
  if (!itemHost || itemHost !== currentHost) return { ok: false, error: 'origin_mismatch' };

  await chrome.tabs.sendMessage(msg.capturedTabId, {
    type: 'fill_credentials',
    username: item.core.username ?? '',
    password: item.core.password ?? '',
  });
  return { ok: true };
}

// --- chrome.storage.local helpers (module-scoped so all handlers share) ---

async function loadConfig(): Promise<VaultConfig | null> {
  const r = await chrome.storage.local.get('vaultConfig');
  return (r.vaultConfig as VaultConfig) ?? null;
}

async function loadImageBase64(): Promise<string | null> {
  const r = await chrome.storage.local.get('imageBase64');
  return (r.imageBase64 as string) ?? null;
}

async function loadSetupState(): Promise<SetupState> {
  const config = await loadConfig();
  const imageBase64 = await loadImageBase64();
  return { config, imageBase64, isConfigured: config !== null && imageBase64 !== null };
}

async function loadDeviceSettings(): Promise<DeviceSettings> {
  const r = await chrome.storage.local.get('relicarioSettings');
  return (r.relicarioSettings as DeviceSettings) ?? { ...DEFAULT_DEVICE_SETTINGS };
}

async function saveDeviceSettings(s: DeviceSettings): Promise<void> {
  await chrome.storage.local.set({ relicarioSettings: s });
}

async function loadBlacklist(): Promise<string[]> {
  const r = await chrome.storage.local.get('captureBlacklist');
  return (r.captureBlacklist as string[]) ?? [];
}

async function saveBlacklist(list: string[]): Promise<void> {
  await chrome.storage.local.set({ captureBlacklist: list });
}

// --- Manifest entry derivation (duplicated from index; kept here to keep handler self-contained) ---

function itemToManifestEntry(item: Item) {
  return {
    id: item.id,
    type: item.type,
    title: item.title,
    tags: item.tags,
    favorite: item.favorite,
    group: item.group,
    icon_hint: (item.core.type === 'login' && item.core.url)
      ? safeHostname(item.core.url) : undefined,
    modified: item.modified,
    trashed_at: item.trashed_at,
    attachment_summaries: item.attachments.map((a) => ({
      id: a.id, filename: a.filename, mime_type: a.mime_type, size: a.size,
    })),
  };
}

function safeHostname(url: string): string | undefined {
  try { return new URL(url).hostname; } catch { return undefined; }
}
```

- [ ] **Step 2: Commit**

```bash
cd /home/alee/Sources/relicario
git add extension/src/service-worker/router/popup-only.ts
git commit -m "feat(ext/sw): router/popup-only handlers"
```

### Task 12: Create `router/content-callable.ts`

**Files:**
- Create: `extension/src/service-worker/router/content-callable.ts`

- [ ] **Step 1: Write the file**

```ts
/// Content-script-callable message handlers.
///
/// Origin is always derived from sender.tab.url — never trust fields on msg.
/// Router has already verified sender.frameId === 0 (top-frame only) and
/// sender.tab !== undefined.

import type { ContentMessage, Response } from '../../shared/messages';
import type { Manifest } from '../../shared/types';
import type { GitHost } from '../git-host';
import * as vault from '../vault';
import * as session from '../session';

export interface ContentState {
  manifest: Manifest | null;
  gitHost: GitHost | null;
}

export async function handle(
  msg: ContentMessage,
  state: ContentState,
  sender: chrome.runtime.MessageSender,
): Promise<Response> {
  const senderHost = safeHostname(sender.tab?.url ?? '');
  if (!senderHost) return { ok: false, error: 'invalid_sender_url' };

  switch (msg.type) {
    case 'get_autofill_candidates': {
      if (!state.manifest) return { ok: false, error: 'vault_locked' };
      return {
        ok: true,
        data: { candidates: vault.findByHostname(state.manifest, senderHost) },
      };
    }

    case 'get_credentials': {
      const handle = session.getCurrent();
      if (!handle || !state.gitHost || !state.manifest) return { ok: false, error: 'vault_locked' };

      const item = await vault.fetchAndDecryptItem(state.gitHost, handle, msg.id);
      if (item.core.type !== 'login') return { ok: false, error: 'not_a_login' };
      const itemHost = safeHostname(item.core.url ?? '');
      if (!itemHost || itemHost !== senderHost) return { ok: false, error: 'origin_mismatch' };

      // TOFU origin-ack check (VaultSettings.autofill_origin_acks):
      const settings = await vault.fetchAndDecryptSettings(state.gitHost, handle);
      const acks = settings.autofill_origin_acks ?? {};
      if (!(senderHost in acks)) {
        return { ok: true, data: { requires_ack: true, hostname: senderHost } };
      }

      return {
        ok: true,
        data: {
          username: item.core.username ?? '',
          password: item.core.password ?? '',
        },
      };
    }

    case 'check_credential': {
      const handle = session.getCurrent();
      if (!handle || !state.gitHost || !state.manifest) {
        return { ok: true, data: { action: 'skip' } };
      }

      // Settings-gating: capture off or site blacklisted → skip.
      const captureSettings = await loadDeviceSettings();
      if (!captureSettings.captureEnabled) return { ok: true, data: { action: 'skip' } };

      const blacklist = await loadBlacklist();
      if (blacklist.includes(senderHost)) return { ok: true, data: { action: 'skip' } };

      const candidates = vault.findByHostname(state.manifest, senderHost);
      if (candidates.length === 0) return { ok: true, data: { action: 'save' } };

      for (const [itemId, entry] of candidates) {
        if (entry.type !== 'login') continue;
        const full = await vault.fetchAndDecryptItem(state.gitHost, handle, itemId);
        if (full.core.type !== 'login') continue;
        if (full.core.username === msg.username) {
          if (full.core.password === msg.password) return { ok: true, data: { action: 'skip' } };
          return { ok: true, data: { action: 'update', entryId: itemId, entryName: entry.title } };
        }
      }
      return { ok: true, data: { action: 'save' } };
    }

    case 'blacklist_site': {
      const bl = await loadBlacklist();
      if (!bl.includes(senderHost)) {
        bl.push(senderHost);
        await saveBlacklist(bl);
      }
      return { ok: true };
    }
  }
}

async function loadDeviceSettings(): Promise<{ captureEnabled: boolean; captureStyle: 'bar' | 'toast' }> {
  const r = await chrome.storage.local.get('relicarioSettings');
  return (r.relicarioSettings as { captureEnabled: boolean; captureStyle: 'bar' | 'toast' })
      ?? { captureEnabled: false, captureStyle: 'bar' };
}

async function loadBlacklist(): Promise<string[]> {
  const r = await chrome.storage.local.get('captureBlacklist');
  return (r.captureBlacklist as string[]) ?? [];
}

async function saveBlacklist(list: string[]): Promise<void> {
  await chrome.storage.local.set({ captureBlacklist: list });
}

function safeHostname(url: string): string | undefined {
  try { return new URL(url).hostname; } catch { return undefined; }
}
```

- [ ] **Step 2: Commit**

```bash
git add extension/src/service-worker/router/content-callable.ts
git commit -m "feat(ext/sw): router/content-callable handlers with origin derivation"
```

### Task 13: Create `router/index.ts` dispatcher

**Files:**
- Create: `extension/src/service-worker/router/index.ts`

- [ ] **Step 1: Write the file**

```ts
/// Single chrome.runtime.onMessage entry. Classifies the sender and dispatches
/// to popup-only or content-callable handlers. Unauthorized senders are
/// rejected with { ok: false, error: 'unauthorized_sender' }.

import type { Request, Response } from '../../shared/messages';
import { POPUP_ONLY_TYPES, CONTENT_CALLABLE_TYPES } from '../../shared/messages';
import type { Manifest } from '../../shared/types';
import type { GitHost } from '../git-host';
import * as popupOnly from './popup-only';
import * as contentCallable from './content-callable';

export interface RouterState {
  manifest: Manifest | null;
  gitHost: GitHost | null;
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  wasm: any;
}

export async function route(
  msg: Request,
  state: RouterState,
  sender: chrome.runtime.MessageSender,
): Promise<Response> {
  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;

  if (POPUP_ONLY_TYPES.has(msg.type as never)) {
    // save_setup gets one exception: allowed from the setup tab too.
    if (!(isPopup || (msg.type === 'save_setup' && isSetup))) {
      return { ok: false, error: 'unauthorized_sender' };
    }
    return popupOnly.handle(msg as never, state, sender);
  }

  if (CONTENT_CALLABLE_TYPES.has(msg.type as never)) {
    if (!isContent) return { ok: false, error: 'unauthorized_sender' };
    return contentCallable.handle(msg as never, state, sender);
  }

  return { ok: false, error: 'unknown_message_type' };
}
```

- [ ] **Step 2: Commit**

```bash
git add extension/src/service-worker/router/index.ts
git commit -m "feat(ext/sw): router index with sender-based dispatch"
```

### Task 14: Collapse `service-worker/index.ts` onto the router

**Files:**
- Modify: `extension/src/service-worker/index.ts`

- [ ] **Step 1: Replace the file**

```ts
/// Thin service-worker entry: loads WASM, constructs the router state, and
/// forwards every message into router/index.route().

import type { Request, Response } from '../shared/messages';
import type { RouterState } from './router/index';
import { route } from './router/index';
import * as vault from './vault';

// @ts-ignore TS2307 — resolved by webpack alias / copy
import initDefault, { initSync } from '../../wasm/relicario_wasm.js';
// @ts-ignore TS2307
import * as wasmBindings from '../../wasm/relicario_wasm.js';

type WasmModule = typeof wasmBindings;
let wasm: WasmModule | null = null;

async function initWasm(): Promise<WasmModule> {
  if (wasm) return wasm;

  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  const SWGlobalScope = (globalThis as any).ServiceWorkerGlobalScope as (new () => ServiceWorker) | undefined;
  const isServiceWorker = typeof SWGlobalScope !== 'undefined'
    && self instanceof (SWGlobalScope as unknown as typeof EventTarget);

  if (isServiceWorker) {
    const wasmResponse = await fetch(chrome.runtime.getURL('relicario_wasm_bg.wasm'));
    const wasmBytes = await wasmResponse.arrayBuffer();
    initSync({ module: new WebAssembly.Module(wasmBytes) });
  } else {
    const wasmUrl = chrome.runtime.getURL('relicario_wasm_bg.wasm');
    await initDefault(wasmUrl);
  }

  vault.setWasm(wasmBindings);
  wasm = wasmBindings;
  return wasm;
}

// Single router-state object shared by all messages for this SW instance.
const state: RouterState = {
  manifest: null,
  gitHost: null,
  wasm: null,
};

chrome.runtime.onMessage.addListener(
  (request: Request, sender: chrome.runtime.MessageSender, sendResponse: (r: Response) => void) => {
    (async () => {
      if (!state.wasm) state.wasm = await initWasm();
      return route(request, state, sender);
    })()
      .then(sendResponse)
      .catch((err: Error) => sendResponse({ ok: false, error: err.message }));
    return true; // async response
  },
);
```

- [ ] **Step 2: Verify both builds pass**

```bash
cd /home/alee/Sources/relicario/extension
bun run build 2>&1 | tail -15
bun run build:firefox 2>&1 | tail -15
```

Expected: both succeed. The popup/content callers compiled in slice 3 remain under their `@ts-nocheck` shields until slice 6.

- [ ] **Step 3: Commit**

```bash
git add extension/src/service-worker/index.ts
git commit -m "feat(ext/sw): collapse flat index onto router"
```

### Task 15: Wire Vitest + router test suite

**Files:**
- Modify: `extension/package.json`
- Create: `extension/vitest.config.ts`
- Create: `extension/src/service-worker/router/__tests__/router.test.ts`

- [ ] **Step 1: Add Vitest to devDependencies**

```bash
cd /home/alee/Sources/relicario/extension
bun add -d vitest@^2.0 happy-dom@^15
```

- [ ] **Step 2: Add `test` script to `package.json`**

In `extension/package.json`, add under `scripts`:

```json
"test": "vitest run",
"test:watch": "vitest"
```

- [ ] **Step 3: Create `vitest.config.ts`**

```ts
import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    environment: 'happy-dom',
    include: ['src/**/__tests__/**/*.test.ts'],
  },
});
```

- [ ] **Step 4: Write `router.test.ts`**

```ts
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { route, type RouterState } from '../index';
import type { Request } from '../../../shared/messages';

// --- chrome.* shim ---

// @ts-expect-error test harness
globalThis.chrome = {
  runtime: {
    id: 'relicario-test-id',
    getURL: (p: string) => `chrome-extension://relicario-test-id/${p}`,
  },
  storage: { local: { get: vi.fn().mockResolvedValue({}), set: vi.fn().mockResolvedValue(undefined) } },
  tabs: { get: vi.fn(), sendMessage: vi.fn() },
};

function makePopupSender(): chrome.runtime.MessageSender {
  return { url: `chrome-extension://relicario-test-id/popup.html`, id: 'relicario-test-id' };
}

function makeSetupSender(): chrome.runtime.MessageSender {
  return { url: `chrome-extension://relicario-test-id/setup.html`, id: 'relicario-test-id' };
}

function makeContentSender(pageUrl = 'https://example.com/'): chrome.runtime.MessageSender {
  return {
    tab: { id: 42, url: pageUrl } as chrome.tabs.Tab,
    frameId: 0,
    id: 'relicario-test-id',
  };
}

function makeExternalSender(): chrome.runtime.MessageSender {
  return { url: 'https://evil.example/', id: 'some-other-extension' };
}

function makeState(): RouterState {
  return {
    manifest: { schema_version: 2, items: {} },
    gitHost: null,
    wasm: {
      // Stubs sufficient for the message types exercised by tests:
      new_item_id: () => 'fakeitemid0000ab',
      generate_password: () => 'PASSWORD',
      rate_passphrase: () => ({ score: 4, guesses_log10: 15 }),
    },
  };
}

// --- Sender-check matrix ---

describe('router sender dispatch', () => {
  let state: RouterState;
  beforeEach(() => { state = makeState(); });

  const popupOnlyMsgs: Request[] = [
    { type: 'is_unlocked' },
    { type: 'lock' },
    { type: 'list_items' },
    { type: 'generate_password', request: { kind: 'random', length: 20, classes: { lower: true, upper: true, digits: true, symbols: true }, symbol_charset: { kind: 'safe_only' } } },
    { type: 'rate_passphrase', passphrase: 'hunter2hunter2hunter2' },
    { type: 'get_blacklist' },
  ];

  for (const msg of popupOnlyMsgs) {
    it(`accepts popup-only "${msg.type}" from popup`, async () => {
      const res = await route(msg, state, makePopupSender());
      expect(res).toMatchObject({ ok: true });
    });
    it(`rejects popup-only "${msg.type}" from content`, async () => {
      const res = await route(msg, state, makeContentSender());
      expect(res).toEqual({ ok: false, error: 'unauthorized_sender' });
    });
    it(`rejects popup-only "${msg.type}" from external`, async () => {
      const res = await route(msg, state, makeExternalSender());
      expect(res).toEqual({ ok: false, error: 'unauthorized_sender' });
    });
  }

  it('accepts save_setup from popup', async () => {
    const msg: Request = { type: 'save_setup', config: { hostType: 'github', hostUrl: '', repoPath: '', apiToken: '' }, imageBase64: '' };
    const res = await route(msg, state, makePopupSender());
    expect(res).toMatchObject({ ok: true });
  });

  it('accepts save_setup from setup tab', async () => {
    const msg: Request = { type: 'save_setup', config: { hostType: 'github', hostUrl: '', repoPath: '', apiToken: '' }, imageBase64: '' };
    const res = await route(msg, state, makeSetupSender());
    expect(res).toMatchObject({ ok: true });
  });

  it('rejects save_setup from content', async () => {
    const msg: Request = { type: 'save_setup', config: { hostType: 'github', hostUrl: '', repoPath: '', apiToken: '' }, imageBase64: '' };
    const res = await route(msg, state, makeContentSender());
    expect(res).toEqual({ ok: false, error: 'unauthorized_sender' });
  });

  const contentMsgs: Request[] = [
    { type: 'get_autofill_candidates' },
    { type: 'blacklist_site' },
  ];

  for (const msg of contentMsgs) {
    it(`accepts content "${msg.type}" from top-frame content`, async () => {
      const res = await route(msg, state, makeContentSender());
      expect(res.ok).toBe(true);
    });
    it(`rejects content "${msg.type}" from popup`, async () => {
      const res = await route(msg, state, makePopupSender());
      expect(res).toEqual({ ok: false, error: 'unauthorized_sender' });
    });
    it(`rejects content "${msg.type}" from subframe`, async () => {
      const sender: chrome.runtime.MessageSender = { ...makeContentSender(), frameId: 3 };
      const res = await route(msg, state, sender);
      expect(res).toEqual({ ok: false, error: 'unauthorized_sender' });
    });
    it(`rejects content "${msg.type}" from external`, async () => {
      const res = await route(msg, state, makeExternalSender());
      expect(res).toEqual({ ok: false, error: 'unauthorized_sender' });
    });
  }

  it('rejects unknown message type', async () => {
    // @ts-expect-error intentional invalid type
    const res = await route({ type: 'nonsense' }, state, makePopupSender());
    expect(res).toEqual({ ok: false, error: 'unknown_message_type' });
  });
});

// --- Origin-bound autofill ---

describe('get_autofill_candidates uses sender.tab.url', () => {
  it('derives hostname from sender, not message', async () => {
    const state: RouterState = makeState();
    state.manifest = {
      schema_version: 2,
      items: {
        'aaaaaaaaaaaaaaaa': {
          id: 'aaaaaaaaaaaaaaaa', type: 'login', title: 'GitHub',
          tags: [], favorite: false, icon_hint: 'github.com',
          modified: 0, attachment_summaries: [],
        },
        'bbbbbbbbbbbbbbbb': {
          id: 'bbbbbbbbbbbbbbbb', type: 'login', title: 'Example',
          tags: [], favorite: false, icon_hint: 'example.com',
          modified: 0, attachment_summaries: [],
        },
      },
    };
    const res = await route(
      { type: 'get_autofill_candidates' },
      state,
      makeContentSender('https://example.com/login'),
    );
    expect(res.ok).toBe(true);
    if (res.ok) {
      const data = res.data as { candidates: Array<[string, { title: string }]> };
      expect(data.candidates).toHaveLength(1);
      expect(data.candidates[0][1].title).toBe('Example');
    }
  });
});
```

- [ ] **Step 5: Run the router tests**

```bash
cd /home/alee/Sources/relicario/extension
bun run test 2>&1 | tail -30
```

Expected: all tests pass. base32 tests (Task 6) also run in this pass.

- [ ] **Step 6: Commit**

```bash
git add extension/package.json extension/bun.lock extension/vitest.config.ts extension/src/service-worker/router/__tests__/router.test.ts
git commit -m "test(ext): vitest + router sender-check + origin-bound autofill"
```

---

## Slice 5 — Security hardening

Goal: the remaining security items — manifest WAR cleanup, setup.html opened via `chrome.tabs.create` (not WAR), closed Shadow DOM in content scripts, popup captured-tab snapshot on init.

### Task 16: Manifest WAR cleanup for Chrome + Firefox

**Files:**
- Modify: `extension/manifest.json`
- Modify: `extension/manifest.firefox.json`

- [ ] **Step 1: Edit Chrome manifest**

Replace `web_accessible_resources` with the minimal set. If `styles.css` isn't used from pages (check `content/*.ts` for any `chrome.runtime.getURL('styles.css')`), drop it entirely.

```bash
cd /home/alee/Sources/relicario
grep -rn "styles.css" extension/src/
```

If no hits from content scripts, target shape:

```json
"web_accessible_resources": []
```

Or omit the field. For Chrome MV3 an empty array is permitted but the field can be removed entirely. Test both — some versions of chrome prefer absence.

Edit `extension/manifest.json`: change the `web_accessible_resources` block to:

```json
"web_accessible_resources": []
```

- [ ] **Step 2: Edit Firefox manifest the same way**

In `extension/manifest.firefox.json`:

```json
"web_accessible_resources": []
```

- [ ] **Step 3: Commit**

```bash
git add extension/manifest.json extension/manifest.firefox.json
git commit -m "feat(ext): drop setup.html / wasm from web_accessible_resources (audit C1)"
```

### Task 17: Popup + setup open via `chrome.tabs.create`

**Files:**
- Modify: `extension/src/popup/popup.ts`

This is only relevant if the popup currently navigates to `chrome.runtime.getURL('setup.html')`. Today it does via `setup-wizard.ts`. Change it to open a new tab instead.

- [ ] **Step 1: Locate the current setup navigation**

```bash
cd /home/alee/Sources/relicario
grep -rn "setup.html\|setup-wizard" extension/src/popup/ | head -10
```

- [ ] **Step 2: Update popup.ts init to open setup in a tab when not configured**

Find the `init()` function in `extension/src/popup/popup.ts`. Replace the branch `if (!data.isConfigured) { navigate('setup'); return; }` with:

```ts
if (!data.isConfigured) {
  await chrome.tabs.create({ url: chrome.runtime.getURL('setup.html') });
  window.close();
  return;
}
```

- [ ] **Step 3: Remove the `setup` view from the popup state machine**

Search for all `navigate('setup')` calls in `extension/src/popup/components/*.ts` and replace each with:

```ts
await chrome.tabs.create({ url: chrome.runtime.getURL('setup.html') });
window.close();
```

Remove `'setup'` from the `View` union in `popup.ts`:

```ts
export type View = 'locked' | 'list' | 'detail' | 'add' | 'edit' | 'settings';
```

Remove the `case 'setup':` branch from `render()`.

Delete the file `extension/src/popup/components/setup-wizard.ts` (it referenced the old Entry type; the standalone setup flow in `src/setup/setup.ts` handles all setup UX now).

- [ ] **Step 4: Rebuild**

```bash
cd extension
bun run build 2>&1 | tail -10
```

Expected: build passes. If setup-wizard.ts is still referenced anywhere, remove the import.

- [ ] **Step 5: Commit**

```bash
cd /home/alee/Sources/relicario
git add -A extension/src/popup/
git commit -m "feat(ext/popup): open setup via chrome.tabs.create, drop setup view from popup"
```

### Task 18: Closed Shadow DOM helper + content-script rewrite (capture)

**Files:**
- Create: `extension/src/content/shadow.ts`
- Modify: `extension/src/content/capture.ts`

- [ ] **Step 1: Create the shadow host helper**

```ts
// extension/src/content/shadow.ts
/// Creates a closed Shadow DOM host attached to document.body.
/// Page JS cannot read host.shadowRoot (it's null from outside) and our
/// rendered DOM has no stable IDs.

export interface ShadowSurface {
  host: HTMLElement;
  root: ShadowRoot;
  destroy: () => void;
}

export function createShadowHost(): ShadowSurface {
  const host = document.createElement('div');
  host.dataset.rel = '';                    // no identifying class/id
  document.body.appendChild(host);
  const root = host.attachShadow({ mode: 'closed' });
  return { host, root, destroy: () => host.remove() };
}
```

- [ ] **Step 2: Rewrite `content/capture.ts` to use the shadow host + textContent**

Replace the entire file:

```ts
/// Credential capture: hook form submissions; if the submitted credentials
/// aren't already in the vault (or differ), prompt the user to save/update.
///
/// All page UI lives inside a closed Shadow DOM with textContent-only DOM
/// construction. No stable IDs, no innerHTML.

import type { Request, Response } from '../shared/messages';
import type { DeviceSettings } from '../shared/types';
import { createShadowHost } from './shadow';

const hookedForms = new WeakSet<HTMLFormElement>();
const hookedButtons = new WeakSet<HTMLElement>();

function sendMessage(request: Request): Promise<Response> {
  return new Promise((resolve) => chrome.runtime.sendMessage(request, (r: Response) => resolve(r)));
}

/// Bounded, control-char-free username scrape.
function findUsernameValue(pwField: HTMLInputElement): string {
  const form = pwField.closest('form');
  const scope = form ?? document;
  const inputs = scope.querySelectorAll<HTMLInputElement>('input');

  for (const input of inputs) {
    if (input === pwField) continue;
    if (input.autocomplete === 'username' && input.value) return sanitize(input.value);
  }
  for (const input of inputs) {
    if (input === pwField) continue;
    if (input.autocomplete === 'email' && input.value) return sanitize(input.value);
  }
  for (const input of inputs) {
    if (input === pwField) continue;
    if (input.type === 'email' && input.value) return sanitize(input.value);
  }
  const pattern = /user|email|login|account/i;
  for (const input of inputs) {
    if (input === pwField) continue;
    if (input.type === 'hidden' || input.type === 'password') continue;
    if ((pattern.test(input.name) || pattern.test(input.id)) && input.value) return sanitize(input.value);
  }
  const allInputs = Array.from(inputs);
  const pwIndex = allInputs.indexOf(pwField);
  for (let i = pwIndex - 1; i >= 0; i--) {
    const input = allInputs[i];
    if (input.type === 'hidden' || input.type === 'password' || input.type === 'submit') continue;
    if (input.offsetWidth > 0 && input.offsetHeight > 0 && input.value) return sanitize(input.value);
  }
  return '';
}

function sanitize(s: string): string {
  return s.replace(/\p{Cc}/gu, '').slice(0, 256);
}

let currentPrompt: ShadowSurface | null = null;

async function onFormSubmit(pwField: HTMLInputElement): Promise<void> {
  const password = pwField.value;
  if (!password) return;

  const username = findUsernameValue(pwField);
  const resp = await sendMessage({ type: 'check_credential', username, password });
  if (!resp.ok) return;

  const data = resp.data as { action: 'skip' | 'save' | 'update'; entryId?: string; entryName?: string };
  if (data.action === 'skip') return;

  const settingsResp = await sendMessage({ type: 'get_settings' });
  const settings: DeviceSettings = settingsResp.ok
    ? (settingsResp.data as { settings: DeviceSettings }).settings
    : { captureEnabled: true, captureStyle: 'bar' };

  showPrompt(settings.captureStyle, data.action, username, password, data.entryId);
}

type ShadowSurface = ReturnType<typeof createShadowHost>;

function removeExistingPrompt(): void {
  currentPrompt?.destroy();
  currentPrompt = null;
}

function showPrompt(
  style: 'bar' | 'toast',
  action: 'save' | 'update',
  username: string,
  password: string,
  entryId?: string,
): void {
  removeExistingPrompt();
  currentPrompt = createShadowHost();
  const { root, host } = currentPrompt;

  // Style the host itself (positioning) so page CSS can't override via specificity.
  const positioning = style === 'bar'
    ? 'position:fixed;top:0;left:0;right:0;z-index:2147483647;transform:translateY(-100%);transition:transform .3s;'
    : 'position:fixed;bottom:16px;right:16px;z-index:2147483647;opacity:0;transition:opacity .3s;';
  host.setAttribute('style', positioning);

  // Shadow root style sheet — single static literal, no injected values.
  const style0 = document.createElement('style');
  style0.textContent = `
    .box { font-family: system-ui, sans-serif; font-size: 13px; color: #c9d1d9;
           background: #161b22; padding: 10px 16px; display: flex; align-items: center;
           gap: 12px; border: 1px solid #30363d; border-radius: 4px;
           box-shadow: 0 4px 12px rgba(0,0,0,0.4); line-height: 1.4; }
    .host { color: #58a6ff; font-weight: 600; }
    .btn-primary { background: #1f6feb; color: #fff; border: none; padding: 5px 14px;
                   border-radius: 3px; cursor: pointer; font: inherit; }
    .btn-secondary { background: transparent; color: #8b949e; border: 1px solid #30363d;
                     padding: 5px 10px; border-radius: 3px; cursor: pointer; font: inherit; }
    .btn-close { background: transparent; color: #8b949e; border: none; cursor: pointer;
                 padding: 2px 6px; font: inherit; }
    .msg { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
  `;
  root.appendChild(style0);

  const box = document.createElement('div');
  box.className = 'box';

  const msg = document.createElement('span');
  msg.className = 'msg';
  const actionLabel = action === 'update' ? 'Update' : 'Save';
  const hostname = window.location.hostname;
  msg.appendChild(document.createTextNode(`${actionLabel} login for `));
  const hostSpan = document.createElement('span');
  hostSpan.className = 'host';
  hostSpan.textContent = hostname;
  msg.appendChild(hostSpan);
  if (username) msg.appendChild(document.createTextNode(` (${username})?`));
  else msg.appendChild(document.createTextNode('?'));

  const saveBtn = document.createElement('button');
  saveBtn.className = 'btn-primary';
  saveBtn.textContent = actionLabel;

  const neverBtn = document.createElement('button');
  neverBtn.className = 'btn-secondary';
  neverBtn.textContent = 'Never';

  const closeBtn = document.createElement('button');
  closeBtn.className = 'btn-close';
  closeBtn.textContent = '✕';

  box.appendChild(msg);
  box.appendChild(saveBtn);
  box.appendChild(neverBtn);
  box.appendChild(closeBtn);
  root.appendChild(box);

  // Animate in
  requestAnimationFrame(() => {
    if (style === 'bar') host.style.transform = 'translateY(0)';
    else                  host.style.opacity = '1';
  });

  let autoDismissTimer: ReturnType<typeof setTimeout> | null = null;
  if (style === 'toast') {
    autoDismissTimer = setTimeout(() => removeExistingPrompt(), 15_000);
  }
  const clearAutoDismiss = (): void => { if (autoDismissTimer) clearTimeout(autoDismissTimer); };

  saveBtn.addEventListener('click', async () => {
    clearAutoDismiss();
    const now = Math.floor(Date.now() / 1000);
    const item = {
      id: '', title: hostname, type: 'login' as const, tags: [], favorite: false,
      created: now, modified: now,
      core: { type: 'login' as const, username, password, url: window.location.origin },
      sections: [], attachments: [], field_history: {},
    };
    if (action === 'update' && entryId) {
      await sendMessage({ type: 'update_item', id: entryId, item });
    } else {
      await sendMessage({ type: 'add_item', item });
    }
    msg.textContent = '✓ Saved';
    saveBtn.style.display = 'none';
    neverBtn.style.display = 'none';
    setTimeout(() => removeExistingPrompt(), 1500);
  });

  neverBtn.addEventListener('click', async () => {
    clearAutoDismiss();
    await sendMessage({ type: 'blacklist_site' });
    removeExistingPrompt();
  });

  closeBtn.addEventListener('click', () => {
    clearAutoDismiss();
    removeExistingPrompt();
  });
}

export function hookForms(): void {
  const passwordFields = document.querySelectorAll<HTMLInputElement>('input[type="password"]');
  for (const pwField of passwordFields) {
    if (pwField.offsetWidth < 20 || pwField.offsetHeight < 10) continue;
    const form = pwField.closest('form');
    if (form && !hookedForms.has(form)) {
      hookedForms.add(form);
      form.addEventListener('submit', () => { onFormSubmit(pwField); });
    }
    const scope = form ?? pwField.parentElement;
    if (!scope) continue;
    const buttons = scope.querySelectorAll<HTMLElement>('button[type="submit"], input[type="submit"], button:not([type])');
    for (const btn of buttons) {
      if (hookedButtons.has(btn)) continue;
      hookedButtons.add(btn);
      btn.addEventListener('click', () => { onFormSubmit(pwField); });
    }
  }
}
```

- [ ] **Step 3: Commit**

```bash
cd /home/alee/Sources/relicario
git add extension/src/content/shadow.ts extension/src/content/capture.ts
git commit -m "feat(ext/content): closed Shadow DOM + textContent for capture prompt"
```

### Task 19: Closed Shadow DOM for icon + picker

**Files:**
- Modify: `extension/src/content/icon.ts`

- [ ] **Step 1: Replace the file**

```ts
/// Inject a small "id" icon next to password fields. Clicking it queries for
/// autofill candidates and either fills or shows a picker — both rendered in
/// closed Shadow DOMs attached to document.body (not as page-DOM children).

import type { ManifestEntry } from '../shared/types';
import { createShadowHost } from './shadow';

const injected = new WeakSet<HTMLInputElement>();

export function injectFieldIcons(
  passwordField: HTMLInputElement,
  _usernameField: HTMLInputElement | null,
): void {
  if (injected.has(passwordField)) return;
  injected.add(passwordField);

  const iconSurface = createShadowHost();
  // Position the host over the password field.
  const rect = passwordField.getBoundingClientRect();
  Object.assign(iconSurface.host.style, {
    position: 'fixed',
    top: `${rect.top + rect.height / 2 - 10}px`,
    left: `${rect.right - 28}px`,
    width: '20px',
    height: '20px',
    zIndex: '2147483647',
  });

  const style = document.createElement('style');
  style.textContent = `
    .i { width: 20px; height: 20px; line-height: 20px; text-align: center;
         font: 700 10px monospace; color: #fff; background: #1f6feb;
         border-radius: 3px; cursor: pointer; user-select: none; }
  `;
  iconSurface.root.appendChild(style);

  const icon = document.createElement('div');
  icon.className = 'i';
  icon.textContent = 'id';
  iconSurface.root.appendChild(icon);

  // Keep icon positioned across scroll/resize.
  const reposition = (): void => {
    const r = passwordField.getBoundingClientRect();
    iconSurface.host.style.top  = `${r.top + r.height / 2 - 10}px`;
    iconSurface.host.style.left = `${r.right - 28}px`;
  };
  window.addEventListener('scroll', reposition, true);
  window.addEventListener('resize', reposition);

  icon.addEventListener('click', async (e) => {
    e.preventDefault();
    e.stopPropagation();
    const resp = await chrome.runtime.sendMessage({ type: 'get_autofill_candidates' });
    if (!resp || !resp.ok) return;
    const candidates = resp.data.candidates as Array<[string, ManifestEntry]>;
    if (candidates.length === 0) return;
    if (candidates.length === 1) {
      const [id] = candidates[0];
      // Popup-captured tab logic doesn't apply here — this is a direct in-page click;
      // the SW enforces origin check via sender.tab.url on get_credentials.
      const credResp = await chrome.runtime.sendMessage({ type: 'get_credentials', id });
      if (credResp?.ok) {
        const d = credResp.data as { requires_ack?: true; hostname?: string; username?: string; password?: string };
        if (d.requires_ack) {
          showAckMessage(iconSurface.host, d.hostname ?? window.location.hostname);
          return;
        }
        chrome.runtime.sendMessage({
          type: 'fill_credentials',
          username: d.username,
          password: d.password,
        });
      }
    } else {
      showPicker(iconSurface.host, candidates);
    }
  });
}

function showPicker(anchor: HTMLElement, candidates: Array<[string, ManifestEntry]>): void {
  // Reuse the anchor's shadow host by mounting a dropdown below it.
  const pickerSurface = createShadowHost();
  const r = anchor.getBoundingClientRect();
  Object.assign(pickerSurface.host.style, {
    position: 'fixed', top: `${r.bottom + 4}px`, left: `${r.right - 200}px`,
    zIndex: '2147483647',
  });

  const style = document.createElement('style');
  style.textContent = `
    .p { background:#161b22;border:1px solid #30363d;border-radius:6px;
         box-shadow:0 4px 12px rgba(0,0,0,.4);min-width:200px;max-height:200px;
         overflow-y:auto;font-family:system-ui,sans-serif;font-size:12px;color:#c9d1d9; }
    .row { padding:8px 12px;cursor:pointer;border-bottom:1px solid #21262d; }
    .row:hover { background:#21262d; }
  `;
  pickerSurface.root.appendChild(style);

  const container = document.createElement('div');
  container.className = 'p';
  pickerSurface.root.appendChild(container);

  for (const [id, entry] of candidates) {
    const row = document.createElement('div');
    row.className = 'row';
    row.textContent = entry.title;   // title only; could append group/tags later
    row.addEventListener('click', async (e) => {
      e.stopPropagation();
      pickerSurface.destroy();
      const credResp = await chrome.runtime.sendMessage({ type: 'get_credentials', id });
      if (credResp?.ok) {
        const d = credResp.data as { requires_ack?: true; hostname?: string; username?: string; password?: string };
        if (d.requires_ack) {
          showAckMessage(anchor, d.hostname ?? window.location.hostname);
          return;
        }
        chrome.runtime.sendMessage({ type: 'fill_credentials', username: d.username, password: d.password });
      }
    });
    container.appendChild(row);
  }

  const closeOnOutside = (): void => {
    pickerSurface.destroy();
    document.removeEventListener('click', closeOnOutside, true);
  };
  setTimeout(() => document.addEventListener('click', closeOnOutside, true), 0);
}

function showAckMessage(anchor: HTMLElement, hostname: string): void {
  const surface = createShadowHost();
  const r = anchor.getBoundingClientRect();
  Object.assign(surface.host.style, {
    position: 'fixed', top: `${r.bottom + 4}px`, left: `${r.right - 240}px`,
    zIndex: '2147483647',
  });
  const style = document.createElement('style');
  style.textContent = `
    .box { background:#161b22;border:1px solid #30363d;border-radius:6px;
           padding:10px 12px;color:#c9d1d9;font-family:system-ui,sans-serif;
           font-size:12px;max-width:240px;box-shadow:0 4px 12px rgba(0,0,0,.4); }
    .host { color:#58a6ff; }
  `;
  surface.root.appendChild(style);
  const box = document.createElement('div');
  box.className = 'box';
  const line1 = document.createElement('div');
  line1.appendChild(document.createTextNode('First autofill on '));
  const hostSpan = document.createElement('span');
  hostSpan.className = 'host';
  hostSpan.textContent = hostname;
  line1.appendChild(hostSpan);
  const line2 = document.createElement('div');
  line2.textContent = 'Open the relicario popup to confirm, then click again.';
  box.appendChild(line1);
  box.appendChild(line2);
  surface.root.appendChild(box);
  setTimeout(() => surface.destroy(), 5000);
}
```

Note this task edits `icon.ts` to include the TOFU `requires_ack` check surface. It also removes the old dependence on `url` passed from content → SW for `get_autofill_candidates` (the SW now derives it).

- [ ] **Step 2: Rebuild**

```bash
cd /home/alee/Sources/relicario/extension
bun run build 2>&1 | tail -10
```

Expected: build passes. Content/detector.ts and content/fill.ts are untouched.

- [ ] **Step 3: Commit**

```bash
cd /home/alee/Sources/relicario
git add extension/src/content/icon.ts
git commit -m "feat(ext/content): closed Shadow DOM for autofill icon + picker + ack hint"
```

### Task 20: Popup captured-tab snapshot on init

**Files:**
- Modify: `extension/src/popup/popup.ts`

- [ ] **Step 1: Add captured-tab fields to PopupState**

At the top of `popup.ts`, extend the state type:

```ts
export interface PopupState {
  view: View;
  entries: Array<[string, import('../shared/types').ManifestEntry]>;
  selectedId: string | null;
  selectedItem: import('../shared/types').Item | null;
  selectedIndex: number;
  searchQuery: string;
  activeGroup: string | null;
  error: string | null;
  loading: boolean;
  capturedTabId: number | null;
  capturedUrl: string;
}
```

Update the initializer and `setState`/`navigate` to preserve these.

- [ ] **Step 2: Snapshot the active tab at init**

In `init()` after the setup-state check, before the unlock branch, add:

```ts
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
currentState.capturedTabId = tab?.id ?? null;
currentState.capturedUrl   = tab?.url ?? '';
```

- [ ] **Step 3: Commit**

```bash
git add extension/src/popup/popup.ts
git commit -m "feat(ext/popup): snapshot activeTab at popup-open for fill_credentials (audit M5)"
```

---

## Slice 6 — Login-parity popup + zxcvbn setup + Firefox verification

Goal: delete the `@ts-nocheck` shields, rewire the popup components onto `Item::Login`, add the zxcvbn strength meter to the setup wizard, verify both Chrome and Firefox.

### Task 21: Rename popup components `entry-*` → `item-*`

**Files:**
- Move: `extension/src/popup/components/entry-list.ts` → `item-list.ts`
- Move: `extension/src/popup/components/entry-detail.ts` → `item-detail.ts`
- Move: `extension/src/popup/components/entry-form.ts` → `item-form.ts`

- [ ] **Step 1: Rename the files (preserving git history)**

```bash
cd /home/alee/Sources/relicario
git mv extension/src/popup/components/entry-list.ts   extension/src/popup/components/item-list.ts
git mv extension/src/popup/components/entry-detail.ts extension/src/popup/components/item-detail.ts
git mv extension/src/popup/components/entry-form.ts   extension/src/popup/components/item-form.ts
```

- [ ] **Step 2: Update imports in `popup.ts`**

Replace the three imports:

```ts
import { renderItemList }   from './components/item-list';
import { renderItemDetail } from './components/item-detail';
import { renderItemForm }   from './components/item-form';
```

And the three `case 'list' / 'detail' / 'add'|'edit'` render calls.

- [ ] **Step 3: Commit the rename only (no content changes yet)**

```bash
git commit -m "refactor(ext/popup): rename entry-* → item-* components"
```

### Task 22: Rewrite `item-list.ts` for ManifestEntry v2

**Files:**
- Modify: `extension/src/popup/components/item-list.ts`

- [ ] **Step 1: Replace the file body with the v2 list renderer**

Locate the file and replace its content. The new list view should:

- Read `state.entries: Array<[ItemId, ManifestEntry]>` (already populated in `popup.ts:init()` via `list_items`).
- Render each row with: type-icon glyph, title, group (if set), tags inline, a ★ for favorites.
- Filter by `state.searchQuery` (case-insensitive against title + tags).
- Clicking a row dispatches `sendMessage({ type: 'get_item', id })` and navigates to `detail`.
- Add a "New…" button that navigates to `add` (defaulting to `login` type for α).

Target structure (abbreviated — fill in HTML/CSS matching existing `styles.css` classes):

```ts
import { getState, setState, sendMessage, navigate, escapeHtml } from '../popup';
import type { ManifestEntry } from '../../shared/types';

export async function renderItemList(app: HTMLElement): Promise<void> {
  const state = getState();
  const q = state.searchQuery.toLowerCase();
  const filtered = state.entries.filter(([, e]) =>
    e.title.toLowerCase().includes(q) || e.tags.some((t) => t.toLowerCase().includes(q))
  );

  app.innerHTML = `
    <div class="pad">
      <div class="toolbar">
        <input id="search" class="input" placeholder="search..." value="${escapeHtml(state.searchQuery)}" />
        <button id="new-btn" class="btn">+ New</button>
        <button id="sync-btn" class="btn">sync</button>
        <button id="lock-btn" class="btn">lock</button>
        <button id="settings-btn" class="btn">⚙</button>
      </div>
      <ul class="entry-list">
        ${filtered.map(([id, e]) => row(id, e)).join('')}
      </ul>
    </div>
  `;

  document.getElementById('search')?.addEventListener('input', (ev) => {
    setState({ searchQuery: (ev.target as HTMLInputElement).value });
  });
  document.getElementById('new-btn')?.addEventListener('click', () => navigate('add'));
  document.getElementById('sync-btn')?.addEventListener('click', async () => {
    await sendMessage({ type: 'sync' });
    const res = await sendMessage({ type: 'list_items' });
    if (res.ok) setState({ entries: (res.data as { items: Array<[string, ManifestEntry]> }).items });
  });
  document.getElementById('lock-btn')?.addEventListener('click', async () => {
    await sendMessage({ type: 'lock' });
    navigate('locked');
  });
  document.getElementById('settings-btn')?.addEventListener('click', () => navigate('settings'));

  for (const [id] of filtered) {
    document.querySelector(`[data-id="${id}"]`)?.addEventListener('click', async () => {
      const res = await sendMessage({ type: 'get_item', id });
      if (res.ok) {
        const item = (res.data as { item: import('../../shared/types').Item }).item;
        setState({ selectedId: id, selectedItem: item });
        navigate('detail');
      }
    });
  }
}

function row(id: string, e: ManifestEntry): string {
  const icon = iconFor(e.type);
  const star = e.favorite ? '★' : '';
  const tags = e.tags.length > 0 ? `<span class="tags">${e.tags.map(escapeHtml).join(' ')}</span>` : '';
  return `
    <li class="entry-row" data-id="${escapeHtml(id)}">
      <span class="icon">${icon}</span>
      <span class="title">${escapeHtml(e.title)}</span>
      ${e.group ? `<span class="group">${escapeHtml(e.group)}</span>` : ''}
      ${tags}
      <span class="star">${star}</span>
    </li>
  `;
}

function iconFor(t: string): string {
  switch (t) {
    case 'login':       return '🔑';
    case 'secure_note': return '📝';
    case 'identity':    return '🪪';
    case 'card':        return '💳';
    case 'key':         return '🗝';
    case 'document':    return '📄';
    case 'totp':        return '⏱';
    default:            return '◇';
  }
}
```

Remove any `@ts-nocheck` comment that was added earlier.

- [ ] **Step 2: Commit**

```bash
git add extension/src/popup/components/item-list.ts extension/src/popup/popup.ts
git commit -m "feat(ext/popup): typed-item list view"
```

### Task 23: Rewrite `item-detail.ts` — Login detail + coming-soon for others

**Files:**
- Modify: `extension/src/popup/components/item-detail.ts`

- [ ] **Step 1: Replace the file**

```ts
import { getState, setState, sendMessage, navigate, escapeHtml } from '../popup';
import type { Item } from '../../shared/types';
import { base32Encode } from '../../shared/base32';

export async function renderItemDetail(app: HTMLElement): Promise<void> {
  const state = getState();
  const item = state.selectedItem;
  if (!item) { navigate('list'); return; }

  switch (item.type) {
    case 'login':       renderLogin(app, item); return;
    case 'secure_note':
    case 'identity':
    case 'card':
    case 'key':
    case 'document':
    case 'totp':        renderComingSoon(app, item); return;
  }
}

function renderLogin(app: HTMLElement, item: Item): void {
  if (item.core.type !== 'login') return;
  const { username, password, url, totp } = item.core;

  app.innerHTML = `
    <div class="pad">
      <div class="detail-title">${escapeHtml(item.title)}</div>
      <div class="detail-row"><span class="label">username</span> <span id="d-user">${escapeHtml(username ?? '')}</span> <button class="btn" id="copy-user">copy</button></div>
      <div class="detail-row"><span class="label">password</span> <span id="d-pass">••••••</span> <button class="btn" id="reveal-pass">show</button> <button class="btn" id="copy-pass">copy</button></div>
      ${url ? `<div class="detail-row"><span class="label">url</span> <a href="${escapeHtml(url)}" target="_blank" rel="noopener noreferrer">${escapeHtml(url)}</a></div>` : ''}
      ${totp ? `<div class="detail-row"><span class="label">totp</span> <span id="d-totp">…</span> <span id="d-totp-exp">…</span></div>` : ''}
      ${item.notes ? `<div class="detail-row"><span class="label">notes</span> <span>${escapeHtml(item.notes)}</span></div>` : ''}
      <div class="toolbar">
        <button class="btn" id="fill-btn">autofill</button>
        <button class="btn" id="edit-btn">edit</button>
        <button class="btn danger" id="del-btn">trash</button>
        <button class="btn" id="back-btn">back</button>
      </div>
    </div>
  `;

  let revealed = false;
  document.getElementById('reveal-pass')?.addEventListener('click', () => {
    revealed = !revealed;
    const d = document.getElementById('d-pass')!;
    d.textContent = revealed ? (password ?? '') : '••••••';
    (document.getElementById('reveal-pass') as HTMLButtonElement).textContent = revealed ? 'hide' : 'show';
  });
  document.getElementById('copy-user')?.addEventListener('click', () => navigator.clipboard.writeText(username ?? ''));
  document.getElementById('copy-pass')?.addEventListener('click', () => navigator.clipboard.writeText(password ?? ''));
  document.getElementById('back-btn')?.addEventListener('click', () => navigate('list'));
  document.getElementById('edit-btn')?.addEventListener('click', () => navigate('edit'));
  document.getElementById('del-btn')?.addEventListener('click', async () => {
    if (!confirm(`Move "${item.title}" to trash?`)) return;
    await sendMessage({ type: 'delete_item', id: item.id });
    const res = await sendMessage({ type: 'list_items' });
    if (res.ok) setState({ entries: (res.data as any).items, selectedItem: null, selectedId: null });
    navigate('list');
  });
  document.getElementById('fill-btn')?.addEventListener('click', async () => {
    const { capturedTabId, capturedUrl } = getState();
    if (capturedTabId === null) return;
    await sendMessage({ type: 'fill_credentials', id: item.id, capturedTabId, capturedUrl });
    window.close();
  });

  if (totp) {
    const tick = async (): Promise<void> => {
      const r = await sendMessage({ type: 'get_totp', id: item.id });
      if (!r.ok) return;
      const { code, expires_at } = r.data as { code: string; expires_at: number };
      const codeSpan = document.getElementById('d-totp');
      const expSpan  = document.getElementById('d-totp-exp');
      if (codeSpan) codeSpan.textContent = code;
      if (expSpan)  expSpan.textContent  = `(${Math.max(0, expires_at - Math.floor(Date.now() / 1000))}s)`;
    };
    tick();
    const id = setInterval(tick, 1000);
    window.addEventListener('hashchange', () => clearInterval(id));
  }
  void base32Encode;   // silence unused — used in item-form.ts
}

function renderComingSoon(app: HTMLElement, item: Item): void {
  app.innerHTML = `
    <div class="pad">
      <div class="detail-title">${escapeHtml(item.title)}</div>
      <p class="muted">The <strong>${escapeHtml(item.type)}</strong> item type is coming in Plan 1C-β.</p>
      <p class="muted">Use the CLI for now: <code>relicario get ${escapeHtml(item.id)} --show</code></p>
      <button class="btn" id="back-btn">back</button>
    </div>
  `;
  document.getElementById('back-btn')?.addEventListener('click', () => navigate('list'));
}
```

- [ ] **Step 2: Commit**

```bash
git add extension/src/popup/components/item-detail.ts
git commit -m "feat(ext/popup): Login detail view + coming-soon for other types"
```

### Task 24: Rewrite `item-form.ts` — Login add/edit

**Files:**
- Modify: `extension/src/popup/components/item-form.ts`

- [ ] **Step 1: Replace the file**

```ts
import { getState, setState, sendMessage, navigate, escapeHtml } from '../popup';
import type { Item, ItemType } from '../../shared/types';
import { DEFAULT_PASSWORD_REQUEST } from '../../shared/types';
import { base32Decode, base32Encode } from '../../shared/base32';

export async function renderItemForm(app: HTMLElement, mode: 'add' | 'edit'): Promise<void> {
  const state = getState();
  const item = mode === 'edit' ? state.selectedItem : null;
  const type: ItemType = item?.type ?? 'login';

  if (type !== 'login') {
    app.innerHTML = `
      <div class="pad">
        <div class="detail-title">${mode === 'add' ? 'new item' : 'edit item'}</div>
        <p class="muted">Editing <strong>${escapeHtml(type)}</strong> items is coming in 1C-β.</p>
        <button class="btn" id="back-btn">back</button>
      </div>
    `;
    document.getElementById('back-btn')?.addEventListener('click', () => navigate('list'));
    return;
  }

  const existing = item && item.core.type === 'login' ? item.core : null;
  const totpB32 = existing?.totp ? base32Encode(new Uint8Array(existing.totp.secret)) : '';

  app.innerHTML = `
    <div class="pad">
      <div class="detail-title">${mode === 'add' ? 'new login' : 'edit login'}</div>
      ${state.error ? `<div class="error">${escapeHtml(state.error)}</div>` : ''}
      <div class="form-group">
        <label for="f-title">title *</label>
        <input id="f-title" class="input" value="${escapeHtml(item?.title ?? '')}" placeholder="GitHub" />
      </div>
      <div class="form-group">
        <label for="f-url">url</label>
        <input id="f-url" class="input" value="${escapeHtml(existing?.url ?? '')}" placeholder="https://github.com/login" />
      </div>
      <div class="form-group">
        <label for="f-user">username</label>
        <input id="f-user" class="input" value="${escapeHtml(existing?.username ?? '')}" placeholder="alice@example.com" />
      </div>
      <div class="form-group">
        <label for="f-pass">password</label>
        <div class="inline-row">
          <input id="f-pass" class="input" type="password" value="${escapeHtml(existing?.password ?? '')}" />
          <button class="btn" id="gen-btn" title="generate">gen</button>
        </div>
      </div>
      <div class="form-group">
        <label for="f-totp">totp secret (base32)</label>
        <input id="f-totp" class="input" value="${escapeHtml(totpB32)}" placeholder="JBSWY3DPEHPK3PXP" />
      </div>
      <div class="form-group">
        <label for="f-group">group</label>
        <input id="f-group" class="input" value="${escapeHtml(item?.group ?? '')}" placeholder="work" />
      </div>
      <div class="form-group">
        <label for="f-notes">notes</label>
        <textarea id="f-notes" class="input">${escapeHtml(item?.notes ?? '')}</textarea>
      </div>
      <div class="toolbar">
        <button class="btn primary" id="save-btn">save</button>
        <button class="btn" id="cancel-btn">cancel</button>
      </div>
    </div>
  `;

  document.getElementById('gen-btn')?.addEventListener('click', async () => {
    const resp = await sendMessage({ type: 'generate_password', request: DEFAULT_PASSWORD_REQUEST });
    if (resp.ok) {
      const pw = (resp.data as { password: string }).password;
      (document.getElementById('f-pass') as HTMLInputElement).value = pw;
    }
  });

  document.getElementById('cancel-btn')?.addEventListener('click', () => {
    setState({ error: null });
    navigate(mode === 'edit' ? 'detail' : 'list');
  });

  document.getElementById('save-btn')?.addEventListener('click', async () => {
    const title = (document.getElementById('f-title') as HTMLInputElement).value.trim();
    if (!title) { setState({ error: 'title is required' }); return; }
    const url = (document.getElementById('f-url') as HTMLInputElement).value.trim() || undefined;
    const username = (document.getElementById('f-user') as HTMLInputElement).value.trim() || undefined;
    const password = (document.getElementById('f-pass') as HTMLInputElement).value || undefined;
    const totpStr  = (document.getElementById('f-totp') as HTMLInputElement).value.trim();
    const group    = (document.getElementById('f-group') as HTMLInputElement).value.trim() || undefined;
    const notes    = (document.getElementById('f-notes') as HTMLTextAreaElement).value.trim() || undefined;

    let totpConfig: import('../../shared/types').TotpConfig | undefined;
    if (totpStr) {
      try {
        const secretBytes = base32Decode(totpStr);
        totpConfig = {
          secret: Array.from(secretBytes),
          algorithm: 'sha1',
          digits: 6,
          period_seconds: 30,
          kind: 'totp',
        };
      } catch (e) { setState({ error: `invalid TOTP secret: ${(e as Error).message}` }); return; }
    }

    const now = Math.floor(Date.now() / 1000);
    const payload: Item = {
      id: item?.id ?? '',
      title,
      type: 'login',
      tags: item?.tags ?? [],
      favorite: item?.favorite ?? false,
      group,
      notes,
      created: item?.created ?? now,
      modified: now,
      core: { type: 'login', username, password, url, totp: totpConfig },
      sections: item?.sections ?? [],
      attachments: item?.attachments ?? [],
      field_history: item?.field_history ?? {},
    };

    const res = mode === 'edit' && item
      ? await sendMessage({ type: 'update_item', id: item.id, item: payload })
      : await sendMessage({ type: 'add_item', item: payload });

    if (!res.ok) { setState({ error: res.error }); return; }

    const listRes = await sendMessage({ type: 'list_items' });
    if (listRes.ok) {
      setState({
        entries: (listRes.data as any).items,
        error: null,
      });
    }
    navigate('list');
  });
}
```

- [ ] **Step 2: Rebuild and commit**

```bash
cd /home/alee/Sources/relicario/extension
bun run build 2>&1 | tail -10
cd ..
git add extension/src/popup/components/item-form.ts
git commit -m "feat(ext/popup): Login add/edit form on typed-item API"
```

### Task 25: Setup wizard zxcvbn meter

**Files:**
- Modify: `extension/src/setup/setup.ts`

- [ ] **Step 1: Locate the passphrase input in `setup.ts`**

Read `extension/src/setup/setup.ts` to find where the passphrase `<input>` is rendered and where the submit button is wired.

- [ ] **Step 2: Wire the strength meter**

Add next to the passphrase input (in the HTML template):

```html
<div class="form-group">
  <label for="passphrase">passphrase</label>
  <input id="passphrase" class="input" type="password" />
  <div class="strength-row">
    <div class="strength-bar" id="strength-bar"></div>
    <span class="strength-label" id="strength-label">—</span>
  </div>
  <p class="muted small" id="strength-feedback"></p>
</div>
```

Add CSS for the bar (5 segments, color-coded):

```css
.strength-bar { display:flex;gap:2px;height:6px;width:100%;margin-top:4px; }
.strength-bar > span { flex:1;background:#30363d;border-radius:1px; }
.strength-bar.s0 > span:nth-child(1) { background:#f85149; }
.strength-bar.s1 > span:nth-child(-n+2) { background:#f0883e; }
.strength-bar.s2 > span:nth-child(-n+3) { background:#d29922; }
.strength-bar.s3 > span:nth-child(-n+4) { background:#3fb950; }
.strength-bar.s4 > span { background:#3fb950; }
.strength-row { display:flex;align-items:center;gap:8px; }
```

Add the meter rendering to the setup wizard (5 segments as child spans):

```ts
function renderStrengthBar(score: number): string {
  return `<div class="strength-bar s${score}" id="strength-bar">
    <span></span><span></span><span></span><span></span><span></span>
  </div>`;
}
```

Add the input-change handler:

```ts
let rateDebounce: ReturnType<typeof setTimeout> | null = null;
const pwInput = document.getElementById('passphrase') as HTMLInputElement;
pwInput.addEventListener('input', () => {
  if (rateDebounce) clearTimeout(rateDebounce);
  rateDebounce = setTimeout(async () => {
    const pw = pwInput.value;
    if (!pw) { setMeter(0, '—', 'type a passphrase'); return; }
    const resp = await new Promise<Response>((resolve) =>
      chrome.runtime.sendMessage({ type: 'rate_passphrase', passphrase: pw }, resolve),
    );
    if (resp.ok) {
      const { score } = resp.data as { score: number };
      const feedback = score >= 3
        ? 'Strong enough.'
        : 'Too weak — try a longer phrase or add unpredictability.';
      setMeter(score, `score ${score}/4`, feedback);
      (document.getElementById('submit-btn') as HTMLButtonElement).disabled = score < 3;
    }
  }, 150);
});

function setMeter(score: number, label: string, feedback: string): void {
  const bar = document.getElementById('strength-bar');
  if (bar) bar.className = `strength-bar s${score}`;
  const lab = document.getElementById('strength-label');
  if (lab) lab.textContent = label;
  const fb  = document.getElementById('strength-feedback');
  if (fb)  fb.textContent  = feedback;
}
```

Initial submit-button state: `disabled = true` until score ≥ 3.

- [ ] **Step 3: Commit**

```bash
cd /home/alee/Sources/relicario
git add extension/src/setup/setup.ts
git commit -m "feat(ext/setup): zxcvbn strength meter + score>=3 gate (audit H3)"
```

### Task 26: Restore popup settings view + smoke build

**Files:**
- Modify: `extension/src/popup/components/settings.ts` (remove `@ts-nocheck` if present; align to `DeviceSettings`)

- [ ] **Step 1: Update the settings view to use DeviceSettings type**

Open `extension/src/popup/components/settings.ts` and replace references to `RelicarioSettings` with `DeviceSettings`. Keep the message types the same (`get_settings`, `update_settings`, `get_blacklist`, `remove_blacklist`).

- [ ] **Step 2: Build both bundles**

```bash
cd /home/alee/Sources/relicario/extension
bun run build 2>&1 | tail -10
bun run build:firefox 2>&1 | tail -10
bun run test 2>&1 | tail -20
```

Expected: all three succeed.

- [ ] **Step 3: Commit**

```bash
cd /home/alee/Sources/relicario
git add extension/src/popup/components/settings.ts
git commit -m "refactor(ext/popup): settings view on DeviceSettings type"
```

### Task 27: Firefox manual verification

**Files:** no code changes.

- [ ] **Step 1: Build both bundles**

```bash
cd /home/alee/Sources/relicario/extension
bun run build:all
```

- [ ] **Step 2: Load Chrome build**

1. Open `chrome://extensions`.
2. Enable Developer mode.
3. Click "Load unpacked" → `extension/dist/`.
4. Verify extension appears in toolbar.

- [ ] **Step 3: Load Firefox build**

1. Open `about:debugging#/runtime/this-firefox`.
2. Click "Load Temporary Add-on…".
3. Select `extension/dist-firefox/manifest.json`.
4. Verify extension appears in toolbar.

- [ ] **Step 4: Walk the 11-step manual matrix on both browsers**

See spec §5.4. Take notes on any anomaly. This is a long-form checklist; don't commit anything yet — the next task gathers fixes.

---

## Slice 7 — Final acceptance

### Task 28: Run all acceptance checks

**Files:** no code changes.

- [ ] **Step 1: Rust workspace regression**

```bash
cd /home/alee/Sources/relicario
cargo test --workspace 2>&1 | tail -10
```

Expected: all tests pass (was 151 at plan-1b-cli-wasm-complete; may be a few more or fewer if anything shifted).

- [ ] **Step 2: WASM target builds**

```bash
cargo build -p relicario-wasm --target wasm32-unknown-unknown 2>&1 | tail -10
```

Expected: clean build.

- [ ] **Step 3: Extension builds + router tests**

```bash
cd extension
bun run build:all 2>&1 | tail -10
bun run test 2>&1 | tail -30
```

Expected: all green.

- [ ] **Step 4: Lint greps**

```bash
cd /home/alee/Sources/relicario
git grep -n 'innerHTML\|insertAdjacentHTML' extension/src/content/ && echo "FAIL: content uses raw HTML" || echo "PASS"
git grep -n 'idfoto' extension/ && echo "FAIL: idfoto references remain" || echo "PASS"
```

Expected: both say `PASS`.

- [ ] **Step 5: WAR check**

```bash
cat extension/manifest.json | grep -A2 web_accessible_resources
cat extension/manifest.firefox.json | grep -A2 web_accessible_resources
```

Expected: either missing or an empty `[]`.

- [ ] **Step 6: Mark the branch complete**

```bash
git tag plan-1c-alpha-complete
```

No commit needed. The tag marks the acceptance point for executors/reviewers.

---

## Self-review (writing-plans housekeeping)

Spec coverage check:
- WASM artifact rebuild — Task 1-3
- Shared types v2 — Task 4
- Messages split — Task 5
- base32 utility — Task 6
- Session handle — Task 7
- Vault rewrite — Task 8
- Transitional SW index — Task 9
- Router split — Tasks 11-13
- Collapse index onto router — Task 14
- Vitest + router tests — Task 15
- WAR cleanup — Task 16
- Setup via chrome.tabs.create — Task 17
- Shadow DOM capture — Task 18
- Shadow DOM icon + picker + ack hint — Task 19
- Popup captured-tab — Task 20
- Popup component rename — Task 21
- Login list/detail/form — Tasks 22-24
- zxcvbn setup gate — Task 25
- Settings view cleanup — Task 26
- Firefox verification — Task 27
- Final acceptance — Task 28

Known gaps (intentional — all deferred per spec):
- Per-type forms beyond Login → 1C-β
- Sections + custom fields → 1C-β
- Full VaultSettings UI → 1C-β
- Attachments → 1C-γ
- Trash view / history view → 1C-γ
- Device management UI → 1C-γ
