peach_vault_integrity_spec.md

30.7 KB · raw

rendered · source

1# Peach Vault Integrity Spec2## Import · Export · Settings · Canary · Duress34*For implementation by Opus. Read every section before writing a single line of code.*56---78## 0. Context and Stakes910Peach is a paid, zero-knowledge password manager. Every person who uses it has trusted it with credentials that cannot be recovered if lost. The consequences of silent data loss in the import/export pipeline are catastrophic and irreversible.1112This spec fixes confirmed bugs, fills gaps in the data pipeline, redesigns the Settings page into a coherent product surface, and moves canary configuration to a per-entry settings drawer. Every change here is either a bug fix or a direct user-facing reliability improvement.1314**Do not skip items. Do not defer items to a later pass. Ship this as one coherent version bump.**1516---1718## 1. Confirmed Bugs — Fix These First1920### 1.1 SolidJS Early-Return Anti-Pattern (Critical — Blocks All CJK Export/Import)2122**Files:** `src/popup/views/Export.tsx`, `src/popup/views/Import.tsx`2324In SolidJS, component functions run **once** at setup time, not on every state change. Using an `if` statement with a signal at the top of a function body to conditionally return JSX is broken — the condition is only evaluated at mount time and never again.2526**Current broken code in `ExportView`:**27```tsx28// This runs ONCE at mount (when showCjkExport() === false). Never re-runs.29if (showCjkExport()) {30  return <CJKExportView onBack={() => setShowCjkExport(false)} />;31}32```3334**Current broken code in `ImportView`:**35```tsx36// Same anti-pattern — showCjkImport() is always false at mount37if (showCjkImport()) {38  return <CJKImportView ... />;39}40```4142**Fix:** Remove the early-return `if` blocks entirely. Use `<Show>` inside the returned JSX to conditionally render one view or the other:4344```tsx45// ExportView — correct SolidJS pattern46return (47  <Show when={showCjkExport()} fallback={<MainExportView ... />}>48    <CJKExportView onBack={() => setShowCjkExport(false)} />49  </Show>50);5152// ImportView — correct SolidJS pattern  53return (54  <Show when={showCjkImport()} fallback={<MainImportView ... />}>55    <CJKImportView56      mode="existing"57      onBack={() => setShowCjkImport(false)}58      onDone={async () => { setShowCjkImport(false); await props.onImport(); }}59    />60  </Show>61);62```6364Extract the existing main-screen JSX into an inner component or a `fallback={}` prop so the structure stays clean.6566---6768### 1.2 Toggle CSS Double-Moves the Knob (Broken Visual State)6970**File:** `src/popup/styles/popup.css`7172The toggle has **two** competing layout rules for the `is-on` state applied simultaneously:7374```css75.peach-toggle.is-on {76  justify-content: flex-end;   /* moves knob via flexbox */77}78.peach-toggle.is-on .peach-toggle-knob {79  transform: translateX(12px); /* ALSO moves knob via transform */80}81```8283Both rules fire at once. The knob is positioned to the right by `justify-content: flex-end` AND then pushed another 12px right by the transform — resulting in the knob visibly escaping the toggle container or sitting at an incorrect position.8485**Fix:** Choose **one** approach. The transform is correct and self-contained. Remove `justify-content: flex-end` from `.peach-toggle.is-on`:8687```css88.peach-toggle {89  display: flex;90  align-items: center;91  justify-content: flex-start; /* knob sits at left by default */92  padding: 2px;93}94.peach-toggle.is-on {95  background: rgba(217, 104, 48, 0.22);96  border-color: rgba(217, 104, 48, 0.4);97  /* No justify-content change — knob position handled by transform only */98}99.peach-toggle.is-on .peach-toggle-knob {100  background: var(--peach-accent);101  transform: translateX(12px);102}103```104105---106107### 1.3 Notes Lost on Peach JSON Round-Trip (Data Loss Bug)108109**File:** `src/shared/backup.ts`, function `normalizeImportedEntry`110111The current code unconditionally requires a `password` field and returns `null` for anything without one. This silently drops **all note entries** (`type: "note"`) from the imported data. A user who exports their vault and re-imports it loses every note they have.112113**Current broken code:**114```ts115const password = typeof candidate.password === "string" ? candidate.password : "";116if (!password) return null;  // ← drops notes117```118119**Fix:** Add a `type === "note"` branch **before** the password check:120121```ts122function normalizeImportedEntry(value: unknown): ImportableEntry | null {123  if (!value || typeof value !== "object") return null;124  const candidate = value as Partial<VaultEntry> & { name?: string; note?: string; email?: string };125126  // Handle passkeys127  if (candidate.type === "passkey") { /* ... existing passkey branch ... */ }128129  // Handle notes — no password required130  if (candidate.type === "note") {131    const title = firstNonEmptyString(candidate.title, candidate.name, "Untitled");132    const notes = firstNonEmptyString(candidate.notes, candidate.note) || undefined;133    if (!title) return null;134    return {135      type: "note",136      title,137      notes,138    } as ImportableEntry;139  }140141  // Handle logins — password required142  const password = typeof candidate.password === "string" ? candidate.password : "";143  if (!password) return null;144145  const title = firstNonEmptyString(candidate.title, candidate.name, candidate.url, candidate.username, candidate.email, "Untitled");146  return {147    type: "login",148    title,149    username: firstNonEmptyString(candidate.username, candidate.email, ""),150    password,151    url: firstNonEmptyString(candidate.url, ""),152    notes: firstNonEmptyString(candidate.notes, candidate.note) || undefined,153    totp: firstNonEmptyString(candidate.totp) || undefined,154    breach: typeof candidate.breach === "number" && candidate.breach > 0 ? candidate.breach : undefined,155    canary: candidate.canary ?? undefined,156    tags: Array.isArray(candidate.tags) ? candidate.tags : undefined,157  };158}159```160161Also update the CSV/Bitwarden/Proton parsers to explicitly set `type: "login"` on every entry they produce, so the `type` field is always explicit rather than relying on implicit defaults.162163---164165### 1.4 Import Entry-by-Entry Is Slow and Error-Prone166167**File:** `src/popup/views/Import.tsx`, `handleImport()`168169The current import loop sends one `ADD_ENTRY` message per entry. A 200-entry vault does 200 round-trips to the service worker, which is slow, and a single message failure (transient service worker timeout) can leave the vault partially imported with no indication of which entries made it.170171**Fix:** Introduce a `BATCH_ADD_ENTRIES` message handler in `messages.ts` + `vault.ts` that accepts an array of entry drafts and adds them transactionally in a single lock-hold. Fall back to sequential if the batch message is unavailable.172173```ts174// messages.ts175case "BATCH_ADD_ENTRIES": {176  const { entries } = msg.payload as { entries: any[] };177  const added: VaultEntry[] = [];178  for (const draft of entries) {179    added.push(await vault.addEntry(draft));180  }181  return { ok: true, data: added };182}183184// Import.tsx185const batchRes = await sendMessage({ type: "BATCH_ADD_ENTRIES", payload: { entries: currentPreview.entries } });186const imported = (batchRes as { ok: boolean; data?: VaultEntry[] }).data?.length ?? 0;187```188189Also add `BATCH_ADD_ENTRIES` to the `MessageType` union in `shared/types.ts`.190191---192193### 1.5 Encrypted Backup "Unlock Backup" Requires Separate Button Click194195**File:** `src/popup/views/Import.tsx`196197When a user selects a `peach-json` encrypted backup, they must: (1) type their password, (2) click "Unlock backup", (3) wait, (4) click "Import". Steps 1–2 can be merged.198199**Fix:** Auto-trigger decryption on password field blur or when the user presses Enter in the backup password input:200201```tsx202<input203  class="peach-restore-input"204  type="password"205  placeholder="backup password"206  value={backupPassword()}207  onInput={(e) => setBackupPassword(e.currentTarget.value)}208  onBlur={() => { if (backupPassword().trim()) void handleUnlockBackup(); }}209  onKeyDown={(e) => { if (e.key === "Enter") void handleUnlockBackup(); }}210/>211```212213Keep the explicit "Unlock backup" button as a fallback, but change its label to "Decrypt →" to clarify intent.214215---216217### 1.6 Import: Unknown Format Shows Empty State with No Explanation218219**File:** `src/popup/views/Import.tsx`220221When `detectImportFormat` returns `"unknown"`, the view shows 0 entries and no message to the user explaining that the format was not recognised. This looks like a silent crash.222223**Fix:** When `format === "unknown"`, set an error state that reads:224> "We couldn't recognise this file format. Peach supports: Peach JSON backups, Bitwarden JSON/CSV, Proton Pass JSON/CSV, 1Password JSON, and generic browser CSV exports."225226---227228## 2. Export Flow Hardening229230### 2.1 Encrypted JSON Export — Pre-flight Checks231232Before calling `exportEntriesToPeachJson`, validate:233234- Vault must have at least 1 non-deleted entry. Show error if empty: *"Your vault has no entries to export."*235- Export password must be at least 12 characters. Show inline feedback if shorter.236- Confirm the export password with a second input to prevent typos that lock users out of their own backup.237238**Updated Export UI for encrypted JSON section:**239```240[export password input]  241[confirm password input]  242strength indicator (reuse peach-strength-hairline)  243[warning: minimum 12 characters]  244[Download encrypted backup] ← disabled until both fields match + length ok245```246247### 2.2 Post-Export Confirmation248249After any successful export, show a non-dismissable success strip:250251```252✓  Exported [N] entries.  Filename: peach-backup-2026-04-21.json253   Store this file and your export password somewhere safe — neither can be recovered.254```255256Do not auto-dismiss this. The user should acknowledge it.257258### 2.3 CSV Export — Tags, Notes, Type259260The current CSV export drops `tags`, `notes`, and `type` for note entries. While CSV is limited, at minimum:261262- Include a `notes` column (already present, verify it's populated from both `e.notes` and note-type entries).263- Include a `type` column with value `"login"` or `"note"`.264- Note entries: title → `name` column, content → `notes` column, other columns blank.265- Exclude deleted entries (`e.deletedAt !== undefined`). Currently only `!e.deletedAt` is checked — verify this is falsy-correct (0 is valid `deletedAt` — use `e.deletedAt !== undefined`).266267### 2.4 CJK Export — No Changes to Crypto Pipeline268269The CJK crypto pipeline (`cjkExport.ts`, `cjkPassphrase.ts`, etc.) is correct and must not be altered. The only change is the SolidJS render bug in §1.1.270271---272273## 3. Import Flow Hardening274275### 3.1 Merge vs. Replace Decision276277When importing into an **existing unlocked vault**, present a clear choice before committing:278279```280[ ] Add to existing vault  ← append new entries alongside existing ones (default)281[ ] Replace vault          ← wipe all current entries, replace with import  282```283284Show the count: *"This will add 47 entries to your existing 12."* or *"This will replace your 12 entries with 47 imported ones."*285286The replace path must:2871. Show a danger confirmation step: *"Replace forever? This cannot be undone."*2882. Delete all current entries (send `DELETE_ENTRY` for each, or add a `CLEAR_VAULT_ENTRIES` background message).2893. Import the new entries.2904. Reload the entry list in App.tsx.291292### 3.2 Import Progress Feedback293294Large vaults (100+ entries) can take 3–5 seconds with sequential adds. Show progress:295296```297Importing... [37 / 200]298```299300If using `BATCH_ADD_ENTRIES` (§1.4), show a spinner with entry count on completion.301302### 3.3 Duplicate Detection303304After import, if any entries have the same `title + url + username` combination as an existing entry, surface a warning (not an error):305306> *"3 entries appear to be duplicates of existing ones. They were still imported — you can review and delete them in your vault."*307308### 3.4 Import Source Robustness309310**LastPass CSV** — Add LastPass column aliases to `parseCSV`:311- `"url"` or `"url"` → already covered312- `"grouping"` → map to tags313- `"extra"` → notes column (already in list)314315**1Password**: The current `parse1PasswordJSON` expects the exact `accounts[0].vaults[*].items` shape. 1Password 8 exports use a slightly different structure — add `data?.items` as a top-level array fallback in `has1PasswordShape` and `parse1PasswordJSON`.316317---318319## 4. Settings Page Redesign320321### 4.1 Visual Hierarchy322323The current Settings page feels cobbled together because sections have inconsistent spacing, the duress form uses unstyled inputs, and security-critical sections have no visual weight. The redesign groups settings into four clear sections with consistent chrome:324325```326┌─ settings ──────────────────────────────────────┐327│                                                  │328│  APPEARANCE                                      │329│  ● Theme                         [dark / light]  │330│  ● Accent color                  [swatch row]    │331│                                                  │332│  SECURITY                                        │333│  ● Lock on close                 [toggle]        │334│  ● Auto-lock after               [select: 1m/5m/15m/30m/never] │335│  ● Breach monitoring             [toggle]        │336│  ○ Check all passwords now       [run]           │337│    └─ Last result: X passwords found in breaches │338│  ● Duress password               [on/off + set up button] │339│                                                  │340│  PASSKEYS                                        │341│  ● Intercept passkey requests    [toggle]        │342│    └─ Status / browser info                      │343│                                                  │344│  VAULT                                           │345│  ○ Entries                       47              │346│  ○ Import passwords              [open →]        │347│  ○ Export backup                 [open →]        │348│  ○ Vault health                  [open →]        │349│  ○ Replace vault from backup     [CJK] [file]    │350│                                                  │351│  SYNC  (Pro only gate)                           │352│  ● Status / devices / networks                   │353│                                                  │354│  LICENSE                                         │355│  ○ Enter key / show plan                         │356│                                                  │357│  Peach 1.4.20 · Browser extension               │358└──────────────────────────────────────────────────┘359```360361### 4.2 Section Visual Treatment362363Each settings group uses consistent chrome:364365```css366.peach-settings-group {367  display: grid;368  gap: 0;369  border-bottom: 1px solid var(--peach-t3);370  padding-bottom: 14px;371  margin-bottom: 14px;372}373.peach-settings-group:last-child {374  border-bottom: none;375  margin-bottom: 0;376}377378.peach-settings-label {379  font-family: Inter, system-ui, sans-serif;380  text-transform: uppercase;381  letter-spacing: 0.2em;382  font-size: 8px;383  color: var(--peach-t2);384  margin-bottom: 10px;385}386387.peach-settings-row {388  display: flex;389  justify-content: space-between;390  align-items: center;391  min-height: 36px;392  gap: 12px;393  font-size: 13px;394  color: var(--peach-t1);395  padding: 2px 0;396}397398.peach-settings-row--meta {399  min-height: 28px;400  font-size: 11px;401  color: var(--peach-t2);402}403404.peach-settings-row--top {405  align-items: flex-start;406  padding-top: 4px;407}408409.peach-settings-row-main {410  display: flex;411  flex-direction: column;412  gap: 2px;413  flex: 1;414  min-width: 0;415}416```417418### 4.3 Auto-Lock Setting419420Add `autoLockMinutes` to the VaultSettings shape (already defined, just not exposed in UI). Add a row under "Lock on close":421422```tsx423<div class="peach-settings-row">424  <span>Auto-lock after</span>425  <select426    class="peach-settings-select"427    value={String(autoLockMinutes())}428    onChange={(e) => void applyAutoLock(Number(e.currentTarget.value))}429  >430    <option value="1">1 minute</option>431    <option value="5">5 minutes</option>432    <option value="15">15 minutes</option>433    <option value="30">30 minutes</option>434    <option value="0">Never</option>435  </select>436</div>437```438439Wire `applyAutoLock` → `UPDATE_SETTINGS { autoLockMinutes }` → in `vault.ts` `updateSettings` → persist. In the alarms handler, read `settings.autoLockMinutes` (default: 5) to set the idle detection interval.440441```css442.peach-settings-select {443  font-family: "IBM Plex Mono", monospace;444  font-size: 11px;445  background: transparent;446  border: 1px solid var(--peach-t3);447  border-radius: 4px;448  color: var(--peach-t1);449  padding: 3px 6px;450  cursor: pointer;451  outline: none;452}453.peach-settings-select:focus {454  border-color: var(--peach-accent);455}456```457458### 4.4 Duress Password Section (Polish)459460The duress form already works functionally. Visual polish:4614621. Wrap the form in `.peach-duress-form` with consistent `gap: 12px` between fields.4632. Use `.peach-settings-input` (not `.peach-underline-input`) for the password inputs — consistent with the rest of the settings page.4643. The status indicator (on/off) should be a styled pill, not a div with inline styles:465466```css467.peach-duress-status-pill {468  font-family: "IBM Plex Mono", monospace;469  font-size: 9px;470  text-transform: uppercase;471  letter-spacing: 0.12em;472  padding: 2px 8px;473  border-radius: 12px;474  background: var(--peach-t3);475  color: var(--peach-t2);476  flex-shrink: 0;477}478.peach-duress-status-pill.is-active {479  background: rgba(217, 104, 48, 0.15);480  color: var(--peach-accent);481}482```4834844. When duress is enabled, show a brief reminder on what it does rather than hiding it. The "Change / disable" button collapses to a single expand toggle.4854865. The `handleDisableDuress` path currently only asks for the main password. After disabling, clear all duress form fields and show a 4-second success strip, then reset.487488### 4.5 Remove "Last sync: local only" Hardcoded Text489490In the Vault section:491```tsx492<div class="peach-settings-row peach-settings-row--meta">493  <span>Last sync</span>494  <span class="peach-settings-value">local only</span>  {/* ← hardcoded, confusing */}495</div>496```497498Remove this row. The sync section already shows the actual sync status. Two contradictory "last sync" indicators are worse than one.499500### 4.6 Consistent Button Sizing501502All action buttons in settings should use `peach-btn--subtle peach-btn--sm` as the base. The theme toggle currently uses `peach-btn--subtle` (correct) but displays `"dark"` or `"light"` as the label — it should say `"Switch to light"` / `"Switch to dark"` to be unambiguous.503504---505506## 5. Per-Entry Canary Settings507508The canary honeypot feature should be configured from the **Entry detail view**, inside a collapsed advanced section — not in the Add form (already removed) and not as a top-level settings item.509510### 5.1 Location511512In `Entry.tsx`, after the notes section, before the delete row, add a collapsed "Advanced" disclosure:513514```tsx515{/* ── Advanced (canary) ──────────────────────────────── */}516<Show when={entry().type !== "note"}>517  <div class="peach-entry-advanced">518    <button519      class="peach-entry-advanced-toggle"520      onClick={() => setShowAdvanced(!showAdvanced())}521    >522      <span class="peach-label">advanced</span>523      <ChevronGlyph open={showAdvanced()} />524    </button>525    <Show when={showAdvanced()}>526      <CanarySection entry={entry()} onSaved={props.onFieldSaved} />527    </Show>528  </div>529</Show>530```531532### 5.2 CanarySection Component533534A self-contained sub-component that shows:5355361. **Toggle row**: "Canary alert" + toggle. When toggled on, reveals the webhook URL input.5372. **Webhook URL input** (inline edit style — same `.peach-inline-input` pattern): `https://hooks.zapier.com/...`5383. **Label input** (optional): human-readable name for this canary.5394. **"Send test ping"** button: fires `FIRE_CANARY_PING` with `trigger: "test"`, shows "sent ✓" or "failed — [reason]".5405. **Ping history**: if `entry.canary.lastPinged` is set, show *"Last accessed: [relative time]"* and *"Total alerts: [pingCount]"*.541542```tsx543function CanarySection(props: { entry: VaultEntry; onSaved: () => void | Promise<void> }) {544  const [enabled, setEnabled] = createSignal(Boolean(props.entry.canary));545  const [webhookUrl, setWebhookUrl] = createSignal(props.entry.canary?.webhookUrl ?? "");546  const [label, setLabel] = createSignal(props.entry.canary?.label ?? "");547  const [saving, setSaving] = createSignal(false);548  const [testResult, setTestResult] = createSignal<string | null>(null);549  const [testing, setTesting] = createSignal(false);550551  async function save() {552    setSaving(true);553    const canary = enabled() && webhookUrl().trim()554      ? { webhookUrl: webhookUrl().trim(), label: label().trim() || undefined,555          lastPinged: props.entry.canary?.lastPinged, pingCount: props.entry.canary?.pingCount }556      : undefined;557    await sendMessage({ type: "UPDATE_ENTRY", payload: { id: props.entry.id, patch: { canary } } });558    setSaving(false);559    await props.onSaved();560  }561562  async function testPing() {563    const url = webhookUrl().trim();564    if (!url) return;565    setTesting(true);566    setTestResult(null);567    const res = await sendMessage({568      type: "FIRE_CANARY_PING",569      payload: { webhookUrl: url, entryTitle: props.entry.title, trigger: "test" },570    }) as { ok: boolean; error?: string };571    setTestResult(res.ok ? "sent ✓" : `failed — ${res.error ?? "check URL"}`);572    setTesting(false);573  }574575  return (576    <div class="peach-canary-section">577      <div class="peach-field-value-row">578        <span class="peach-field-value" style="font-size:12px;">canary alert</span>579        <button580          class={`peach-toggle ${enabled() ? "is-on" : ""}`}581          onClick={() => { setEnabled(!enabled()); void save(); }}582          aria-label="Toggle canary"583        >584          <span class="peach-toggle-knob" />585        </button>586      </div>587      <Show when={enabled()}>588        <div class="peach-inline-field-wrap" style="margin-top:8px;">589          <input590            class="peach-inline-input"591            placeholder="https://hooks.zapier.com/..."592            value={webhookUrl()}593            onInput={(e) => setWebhookUrl(e.currentTarget.value)}594            onBlur={() => void save()}595          />596          <input597            class="peach-inline-input"598            placeholder="label (optional, e.g. AWS decoy)"599            value={label()}600            onInput={(e) => setLabel(e.currentTarget.value)}601            onBlur={() => void save()}602          />603          <div style="display:flex;align-items:center;gap:8px;margin-top:4px;">604            <button605              class="peach-btn peach-btn--ghost peach-btn--sm"606              onClick={() => void testPing()}607              disabled={testing() || !webhookUrl().trim()}608            >609              {testing() ? "sending..." : "test ping"}610            </button>611            <Show when={testResult()}>612              <span style="font-size:10px;color:var(--peach-t2);font-family:var(--font-mono);">{testResult()}</span>613            </Show>614          </div>615          <Show when={props.entry.canary?.lastPinged}>616            <p style="font-size:10px;color:var(--peach-t2);margin-top:4px;">617              Last triggered: {new Date(props.entry.canary!.lastPinged!).toLocaleDateString()}618              {" · "}{props.entry.canary?.pingCount ?? 0} total alerts619            </p>620          </Show>621        </div>622      </Show>623    </div>624  );625}626```627628### 5.3 Canary CSS Additions629630```css631.peach-entry-advanced {632  margin-top: 12px;633  border-top: 1px solid var(--peach-t3);634  padding-top: 10px;635  position: relative;636  z-index: 1;637}638.peach-entry-advanced-toggle {639  display: flex;640  align-items: center;641  justify-content: space-between;642  width: 100%;643  background: transparent;644  border: none;645  cursor: pointer;646  padding: 0;647  color: var(--peach-t2);648}649.peach-canary-section {650  margin-top: 10px;651  display: grid;652  gap: 6px;653}654```655656### 5.4 Canary Pings Still Fire Silently657658When a user **copies a password or username** from Entry.tsx that has `entry.canary`, Peach should fire the webhook silently. Wire this in `copyField()`:659660```tsx661async function copyField(text: string, label: string, button?: HTMLButtonElement) {662  const response = await sendMessage({ type: "COPY", payload: { text, label } });663  if ((response as { ok: boolean }).ok) {664    // ... existing copy feedback ...665666    // Canary: fire silently on copy if this entry has a webhook667    const entry = props.entry;668    if (entry?.canary?.webhookUrl) {669      const trigger = label === "password" ? "password_copied" : "username_copied";670      void sendMessage({671        type: "FIRE_CANARY_PING",672        payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger },673      });674    }675  }676}677```678679---680681## 6. CJK Phrase Import — QR Scan Path682683Currently, the QR code is only generated for export (show-as-QR button). The import side has no QR scanner. This is out of scope for this spec (requires camera access in extension popup). Document this as a known limitation:684685> *QR scan import requires camera access in the extension popup, which Chromium does not support. Use paste-from-clipboard instead. The QR code is useful for viewing on a trusted secondary screen and typing the phrase manually.*686687The current QR display path on export is correct as-is. No changes needed.688689---690691## 7. Entry Detail — Canary Badge (Remove from Header, Keep in Advanced)692693Currently (after v1.4.20) `Entry.tsx` has no canary badge visible. That's correct. The advanced section in §5.1 is the only entry point. Do not add a badge back to the entry header. Canary is a hidden feature.694695---696697## 8. `shared/types.ts` — MessageType Union Update698699Add `BATCH_ADD_ENTRIES` to the `MessageType` union so TypeScript doesn't warn on the new message:700701```ts702export type MessageType =703  | "CREATE_VAULT"704  | "UNLOCK"705  | "LOCK"706  | "IS_UNLOCKED"707  | "HAS_VAULT"708  | "GET_ENTRIES"709  | "ADD_ENTRY"710  | "BATCH_ADD_ENTRIES"  // ← add this711  | "UPDATE_ENTRY"712  | "DELETE_ENTRY"713  // ... rest of union unchanged714```715716---717718## 9. File Download in Extension Context719720The `downloadBlob` helper in `Export.tsx` uses `URL.createObjectURL` + anchor click. This works correctly in Chrome extension popups under MV3. **Do not replace it with `chrome.downloads.download()`** — that API requires the `downloads` permission and creates an additional permission prompt that would reduce install conversion. The current approach is correct.721722If the download silently fails (which can happen if the service worker is sleeping when the popup opens), add a `.catch()` guard and surface the error:723724```ts725function downloadBlob(content: string, mimeType: string, filename: string): void {726  try {727    const url = URL.createObjectURL(new Blob([content], { type: mimeType }));728    const a = document.createElement("a");729    a.href = url;730    a.download = filename;731    document.body.appendChild(a);  // Required in some browsers — ensure it's in the DOM732    a.click();733    document.body.removeChild(a);734    URL.revokeObjectURL(url);735  } catch (err) {736    console.error("[Peach] Download failed:", err);737    throw err; // Caller should catch and surface to UI738  }739}740```741742Note: `a.click()` without `document.body.appendChild(a)` can fail in some Firefox/Chromium extension contexts. Adding the append/remove step is a safe guard.743744---745746## 10. CSS Classes Still Missing747748Check that all of these exist in `popup.css` before declaring the settings page done. Add any that are absent:749750| Class | Purpose |751|---|---|752| `.peach-settings-input` | Full-width underlined input used in Export/import password fields |753| `.peach-settings-select` | Dropdown select element (§4.3) |754| `.peach-duress-status-pill` / `.is-active` | Duress on/off pill (§4.4) |755| `.peach-entry-advanced` | Wrapper for the advanced disclosure in Entry |756| `.peach-entry-advanced-toggle` | Button row that expands advanced section |757| `.peach-canary-section` | Canary sub-form inside advanced |758| `.peach-restore-warning` | Red warning box in Import |759| `.peach-provider-chips` / `.peach-provider-chip` | Supported format chips in Import |760| `.peach-import-summary` | "Found N entries, M skipped" text |761| `.peach-import-preview` / `.peach-import-preview-row` | 8-row preview table |762763Run `npx tsc --noEmit` and check browser devtools for CSS missing-class warnings after implementation.764765---766767## 11. Implementation Order768769Implement in this exact order. Each step should compile cleanly before proceeding.7707711. **Fix toggle CSS** (§1.2) — no logic changes, safe7722. **Fix SolidJS early-return bugs** (§1.1) — enables CJK export/import to work7733. **Fix notes data loss** (§1.3) — critical data integrity fix7744. **Fix download helper** (§9) — defensive guard7755. **Add BATCH_ADD_ENTRIES** (§1.4 + §8) — background + messages + Import.tsx7766. **Add auto-unlock-on-enter for encrypted import** (§1.5)7777. **Fix unknown-format error message** (§1.6)7788. **Export pre-flight and confirmation** (§2.1, §2.2, §2.3)7799. **Import merge/replace choice** (§3.1)78010. **Settings page redesign** (§4) — visual + auto-lock wiring78111. **Per-entry canary section** (§5)78212. **Typecheck + build + version bump to 1.4.21**78313. **Smoke test** — export encrypted JSON, re-import it, verify all entries round-trip including notes784785---786787## 12. Smoke Test Protocol788789Before shipping, verify this exact flow manually:790791**Export round-trip:**7921. Open Peach. Confirm vault has at least: 1 login, 1 note, 1 entry with TOTP, 1 entry with tags.7932. Go to Settings → Export → Encrypted file backup.7943. Enter a 14-character password + confirm. Click "Download encrypted backup".7954. Verify file downloads. Open it in a text editor — confirm it's JSON with `"type":"peach-export"`.7965. Go to Settings → Import. Select the downloaded file. Enter the same password. Click "Decrypt →".7976. Verify preview shows correct entry count. Click Import.7987. Verify vault now contains the expected entries including the note and TOTP entry.799800**CJK phrase round-trip:**8011. Go to Settings → Export → CJK phrase backup.8022. Enter a 5-word diceware passphrase (use the "Generate" button). Confirm it. Click "Export →".8033. After generation, click "Copy phrase". Open a text editor and paste.8044. Go to Settings → Import → Restore from CJK phrase.8055. Paste the phrase. Verify "✓ Phrase recognised" appears.8066. Enter the same passphrase. Click "Restore".8077. Verify entries are restored.808809**Toggle persistence:**8101. Open Settings. Toggle "Lock on close" off.8112. Close the popup completely. Re-open.8123. Verify toggle is still off. If it reverts to on, the `UPDATE_SETTINGS` persistence is broken.813814**Canary config:**8151. Open an entry. Scroll to "advanced". Expand.8162. Toggle canary on. Enter a real webhook URL. Click elsewhere (blur).8173. Close popup. Re-open. Open same entry. Verify canary is still on with URL intact.8184. Click "test ping". Verify webhook receives the payload.819820---821822## 13. Version Bump823824After all changes: bump to `1.4.21` in both `manifest.json` and `manifest.firefox.json`. Update `CONTINUITY.md` with a `[DECISIONS]` entry and a `[PROGRESS]` entry covering all changes from this spec.