peach_vault_integrity_spec.md

30.7 KB · raw

rendered · source

Peach Vault Integrity Spec

Import · Export · Settings · Canary · Duress

For implementation by Opus. Read every section before writing a single line of code.


0. Context and Stakes

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

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

Do not skip items. Do not defer items to a later pass. Ship this as one coherent version bump.


1. Confirmed Bugs — Fix These First

1.1 SolidJS Early-Return Anti-Pattern (Critical — Blocks All CJK Export/Import)

Files: src/popup/views/Export.tsx, src/popup/views/Import.tsx

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

Current broken code in ExportView:

// This runs ONCE at mount (when showCjkExport() === false). Never re-runs.
if (showCjkExport()) {
  return <CJKExportView onBack={() => setShowCjkExport(false)} />;
}

Current broken code in ImportView:

// Same anti-pattern — showCjkImport() is always false at mount
if (showCjkImport()) {
  return <CJKImportView ... />;
}

Fix: Remove the early-return if blocks entirely. Use <Show> inside the returned JSX to conditionally render one view or the other:

// ExportView — correct SolidJS pattern
return (
  <Show when={showCjkExport()} fallback={<MainExportView ... />}>
    <CJKExportView onBack={() => setShowCjkExport(false)} />
  </Show>
);

// ImportView — correct SolidJS pattern  
return (
  <Show when={showCjkImport()} fallback={<MainImportView ... />}>
    <CJKImportView
      mode="existing"
      onBack={() => setShowCjkImport(false)}
      onDone={async () => { setShowCjkImport(false); await props.onImport(); }}
    />
  </Show>
);

Extract the existing main-screen JSX into an inner component or a fallback={} prop so the structure stays clean.


1.2 Toggle CSS Double-Moves the Knob (Broken Visual State)

File: src/popup/styles/popup.css

The toggle has two competing layout rules for the is-on state applied simultaneously:

.peach-toggle.is-on {
  justify-content: flex-end;   /* moves knob via flexbox */
}
.peach-toggle.is-on .peach-toggle-knob {
  transform: translateX(12px); /* ALSO moves knob via transform */
}

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

Fix: Choose one approach. The transform is correct and self-contained. Remove justify-content: flex-end from .peach-toggle.is-on:

.peach-toggle {
  display: flex;
  align-items: center;
  justify-content: flex-start; /* knob sits at left by default */
  padding: 2px;
}
.peach-toggle.is-on {
  background: rgba(217, 104, 48, 0.22);
  border-color: rgba(217, 104, 48, 0.4);
  /* No justify-content change — knob position handled by transform only */
}
.peach-toggle.is-on .peach-toggle-knob {
  background: var(--peach-accent);
  transform: translateX(12px);
}

1.3 Notes Lost on Peach JSON Round-Trip (Data Loss Bug)

File: src/shared/backup.ts, function normalizeImportedEntry

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

Current broken code:

const password = typeof candidate.password === "string" ? candidate.password : "";
if (!password) return null;  // ← drops notes

Fix: Add a type === "note" branch before the password check:

function normalizeImportedEntry(value: unknown): ImportableEntry | null {
  if (!value || typeof value !== "object") return null;
  const candidate = value as Partial<VaultEntry> & { name?: string; note?: string; email?: string };

  // Handle passkeys
  if (candidate.type === "passkey") { /* ... existing passkey branch ... */ }

  // Handle notes — no password required
  if (candidate.type === "note") {
    const title = firstNonEmptyString(candidate.title, candidate.name, "Untitled");
    const notes = firstNonEmptyString(candidate.notes, candidate.note) || undefined;
    if (!title) return null;
    return {
      type: "note",
      title,
      notes,
    } as ImportableEntry;
  }

  // Handle logins — password required
  const password = typeof candidate.password === "string" ? candidate.password : "";
  if (!password) return null;

  const title = firstNonEmptyString(candidate.title, candidate.name, candidate.url, candidate.username, candidate.email, "Untitled");
  return {
    type: "login",
    title,
    username: firstNonEmptyString(candidate.username, candidate.email, ""),
    password,
    url: firstNonEmptyString(candidate.url, ""),
    notes: firstNonEmptyString(candidate.notes, candidate.note) || undefined,
    totp: firstNonEmptyString(candidate.totp) || undefined,
    breach: typeof candidate.breach === "number" && candidate.breach > 0 ? candidate.breach : undefined,
    canary: candidate.canary ?? undefined,
    tags: Array.isArray(candidate.tags) ? candidate.tags : undefined,
  };
}

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


1.4 Import Entry-by-Entry Is Slow and Error-Prone

File: src/popup/views/Import.tsx, handleImport()

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

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.

// messages.ts
case "BATCH_ADD_ENTRIES": {
  const { entries } = msg.payload as { entries: any[] };
  const added: VaultEntry[] = [];
  for (const draft of entries) {
    added.push(await vault.addEntry(draft));
  }
  return { ok: true, data: added };
}

// Import.tsx
const batchRes = await sendMessage({ type: "BATCH_ADD_ENTRIES", payload: { entries: currentPreview.entries } });
const imported = (batchRes as { ok: boolean; data?: VaultEntry[] }).data?.length ?? 0;

Also add BATCH_ADD_ENTRIES to the MessageType union in shared/types.ts.


1.5 Encrypted Backup "Unlock Backup" Requires Separate Button Click

File: src/popup/views/Import.tsx

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

Fix: Auto-trigger decryption on password field blur or when the user presses Enter in the backup password input:

<input
  class="peach-restore-input"
  type="password"
  placeholder="backup password"
  value={backupPassword()}
  onInput={(e) => setBackupPassword(e.currentTarget.value)}
  onBlur={() => { if (backupPassword().trim()) void handleUnlockBackup(); }}
  onKeyDown={(e) => { if (e.key === "Enter") void handleUnlockBackup(); }}
/>

Keep the explicit "Unlock backup" button as a fallback, but change its label to "Decrypt →" to clarify intent.


1.6 Import: Unknown Format Shows Empty State with No Explanation

File: src/popup/views/Import.tsx

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

Fix: When format === "unknown", set an error state that reads:

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


2. Export Flow Hardening

2.1 Encrypted JSON Export — Pre-flight Checks

Before calling exportEntriesToPeachJson, validate:

Updated Export UI for encrypted JSON section:

[export password input]  
[confirm password input]  
strength indicator (reuse peach-strength-hairline)  
[warning: minimum 12 characters]  
[Download encrypted backup] ← disabled until both fields match + length ok

2.2 Post-Export Confirmation

After any successful export, show a non-dismissable success strip:

✓  Exported [N] entries.  Filename: peach-backup-2026-04-21.json
   Store this file and your export password somewhere safe — neither can be recovered.

Do not auto-dismiss this. The user should acknowledge it.

2.3 CSV Export — Tags, Notes, Type

The current CSV export drops tags, notes, and type for note entries. While CSV is limited, at minimum:

2.4 CJK Export — No Changes to Crypto Pipeline

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


3. Import Flow Hardening

3.1 Merge vs. Replace Decision

When importing into an existing unlocked vault, present a clear choice before committing:

[ ] Add to existing vault  ← append new entries alongside existing ones (default)
[ ] Replace vault          ← wipe all current entries, replace with import  

Show the count: "This will add 47 entries to your existing 12." or "This will replace your 12 entries with 47 imported ones."

The replace path must:

  1. Show a danger confirmation step: "Replace forever? This cannot be undone."
  2. Delete all current entries (send DELETE_ENTRY for each, or add a CLEAR_VAULT_ENTRIES background message).
  3. Import the new entries.
  4. Reload the entry list in App.tsx.

3.2 Import Progress Feedback

Large vaults (100+ entries) can take 3–5 seconds with sequential adds. Show progress:

Importing... [37 / 200]

If using BATCH_ADD_ENTRIES (§1.4), show a spinner with entry count on completion.

3.3 Duplicate Detection

After import, if any entries have the same title + url + username combination as an existing entry, surface a warning (not an error):

"3 entries appear to be duplicates of existing ones. They were still imported — you can review and delete them in your vault."

3.4 Import Source Robustness

LastPass CSV — Add LastPass column aliases to parseCSV:

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.


4. Settings Page Redesign

4.1 Visual Hierarchy

The 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:

┌─ settings ──────────────────────────────────────┐
│                                                  │
│  APPEARANCE                                      │
│  ● Theme                         [dark / light]  │
│  ● Accent color                  [swatch row]    │
│                                                  │
│  SECURITY                                        │
│  ● Lock on close                 [toggle]        │
│  ● Auto-lock after               [select: 1m/5m/15m/30m/never] │
│  ● Breach monitoring             [toggle]        │
│  ○ Check all passwords now       [run]           │
│    └─ Last result: X passwords found in breaches │
│  ● Duress password               [on/off + set up button] │
│                                                  │
│  PASSKEYS                                        │
│  ● Intercept passkey requests    [toggle]        │
│    └─ Status / browser info                      │
│                                                  │
│  VAULT                                           │
│  ○ Entries                       47              │
│  ○ Import passwords              [open →]        │
│  ○ Export backup                 [open →]        │
│  ○ Vault health                  [open →]        │
│  ○ Replace vault from backup     [CJK] [file]    │
│                                                  │
│  SYNC  (Pro only gate)                           │
│  ● Status / devices / networks                   │
│                                                  │
│  LICENSE                                         │
│  ○ Enter key / show plan                         │
│                                                  │
│  Peach 1.4.20 · Browser extension               │
└──────────────────────────────────────────────────┘

4.2 Section Visual Treatment

Each settings group uses consistent chrome:

.peach-settings-group {
  display: grid;
  gap: 0;
  border-bottom: 1px solid var(--peach-t3);
  padding-bottom: 14px;
  margin-bottom: 14px;
}
.peach-settings-group:last-child {
  border-bottom: none;
  margin-bottom: 0;
}

.peach-settings-label {
  font-family: Inter, system-ui, sans-serif;
  text-transform: uppercase;
  letter-spacing: 0.2em;
  font-size: 8px;
  color: var(--peach-t2);
  margin-bottom: 10px;
}

.peach-settings-row {
  display: flex;
  justify-content: space-between;
  align-items: center;
  min-height: 36px;
  gap: 12px;
  font-size: 13px;
  color: var(--peach-t1);
  padding: 2px 0;
}

.peach-settings-row--meta {
  min-height: 28px;
  font-size: 11px;
  color: var(--peach-t2);
}

.peach-settings-row--top {
  align-items: flex-start;
  padding-top: 4px;
}

.peach-settings-row-main {
  display: flex;
  flex-direction: column;
  gap: 2px;
  flex: 1;
  min-width: 0;
}

4.3 Auto-Lock Setting

Add autoLockMinutes to the VaultSettings shape (already defined, just not exposed in UI). Add a row under "Lock on close":

<div class="peach-settings-row">
  <span>Auto-lock after</span>
  <select
    class="peach-settings-select"
    value={String(autoLockMinutes())}
    onChange={(e) => void applyAutoLock(Number(e.currentTarget.value))}
  >
    <option value="1">1 minute</option>
    <option value="5">5 minutes</option>
    <option value="15">15 minutes</option>
    <option value="30">30 minutes</option>
    <option value="0">Never</option>
  </select>
</div>

Wire applyAutoLock → UPDATE_SETTINGS { autoLockMinutes } → in vault.ts updateSettings → persist. In the alarms handler, read settings.autoLockMinutes (default: 5) to set the idle detection interval.

.peach-settings-select {
  font-family: "IBM Plex Mono", monospace;
  font-size: 11px;
  background: transparent;
  border: 1px solid var(--peach-t3);
  border-radius: 4px;
  color: var(--peach-t1);
  padding: 3px 6px;
  cursor: pointer;
  outline: none;
}
.peach-settings-select:focus {
  border-color: var(--peach-accent);
}

4.4 Duress Password Section (Polish)

The duress form already works functionally. Visual polish:

  1. Wrap the form in .peach-duress-form with consistent gap: 12px between fields.
  2. Use .peach-settings-input (not .peach-underline-input) for the password inputs — consistent with the rest of the settings page.
  3. The status indicator (on/off) should be a styled pill, not a div with inline styles:
.peach-duress-status-pill {
  font-family: "IBM Plex Mono", monospace;
  font-size: 9px;
  text-transform: uppercase;
  letter-spacing: 0.12em;
  padding: 2px 8px;
  border-radius: 12px;
  background: var(--peach-t3);
  color: var(--peach-t2);
  flex-shrink: 0;
}
.peach-duress-status-pill.is-active {
  background: rgba(217, 104, 48, 0.15);
  color: var(--peach-accent);
}
  1. 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.

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

4.5 Remove "Last sync: local only" Hardcoded Text

In the Vault section:

<div class="peach-settings-row peach-settings-row--meta">
  <span>Last sync</span>
  <span class="peach-settings-value">local only</span>  {/* ← hardcoded, confusing */}
</div>

Remove this row. The sync section already shows the actual sync status. Two contradictory "last sync" indicators are worse than one.

4.6 Consistent Button Sizing

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


5. Per-Entry Canary Settings

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

5.1 Location

In Entry.tsx, after the notes section, before the delete row, add a collapsed "Advanced" disclosure:

{/* ── Advanced (canary) ──────────────────────────────── */}
<Show when={entry().type !== "note"}>
  <div class="peach-entry-advanced">
    <button
      class="peach-entry-advanced-toggle"
      onClick={() => setShowAdvanced(!showAdvanced())}
    >
      <span class="peach-label">advanced</span>
      <ChevronGlyph open={showAdvanced()} />
    </button>
    <Show when={showAdvanced()}>
      <CanarySection entry={entry()} onSaved={props.onFieldSaved} />
    </Show>
  </div>
</Show>

5.2 CanarySection Component

A self-contained sub-component that shows:

  1. Toggle row: "Canary alert" + toggle. When toggled on, reveals the webhook URL input.
  2. Webhook URL input (inline edit style — same .peach-inline-input pattern): https://hooks.zapier.com/...
  3. Label input (optional): human-readable name for this canary.
  4. "Send test ping" button: fires FIRE_CANARY_PING with trigger: "test", shows "sent ✓" or "failed — [reason]".
  5. Ping history: if entry.canary.lastPinged is set, show "Last accessed: [relative time]" and "Total alerts: [pingCount]".
function CanarySection(props: { entry: VaultEntry; onSaved: () => void | Promise<void> }) {
  const [enabled, setEnabled] = createSignal(Boolean(props.entry.canary));
  const [webhookUrl, setWebhookUrl] = createSignal(props.entry.canary?.webhookUrl ?? "");
  const [label, setLabel] = createSignal(props.entry.canary?.label ?? "");
  const [saving, setSaving] = createSignal(false);
  const [testResult, setTestResult] = createSignal<string | null>(null);
  const [testing, setTesting] = createSignal(false);

  async function save() {
    setSaving(true);
    const canary = enabled() && webhookUrl().trim()
      ? { webhookUrl: webhookUrl().trim(), label: label().trim() || undefined,
          lastPinged: props.entry.canary?.lastPinged, pingCount: props.entry.canary?.pingCount }
      : undefined;
    await sendMessage({ type: "UPDATE_ENTRY", payload: { id: props.entry.id, patch: { canary } } });
    setSaving(false);
    await props.onSaved();
  }

  async function testPing() {
    const url = webhookUrl().trim();
    if (!url) return;
    setTesting(true);
    setTestResult(null);
    const res = await sendMessage({
      type: "FIRE_CANARY_PING",
      payload: { webhookUrl: url, entryTitle: props.entry.title, trigger: "test" },
    }) as { ok: boolean; error?: string };
    setTestResult(res.ok ? "sent ✓" : `failed — ${res.error ?? "check URL"}`);
    setTesting(false);
  }

  return (
    <div class="peach-canary-section">
      <div class="peach-field-value-row">
        <span class="peach-field-value" style="font-size:12px;">canary alert</span>
        <button
          class={`peach-toggle ${enabled() ? "is-on" : ""}`}
          onClick={() => { setEnabled(!enabled()); void save(); }}
          aria-label="Toggle canary"
        >
          <span class="peach-toggle-knob" />
        </button>
      </div>
      <Show when={enabled()}>
        <div class="peach-inline-field-wrap" style="margin-top:8px;">
          <input
            class="peach-inline-input"
            placeholder="https://hooks.zapier.com/..."
            value={webhookUrl()}
            onInput={(e) => setWebhookUrl(e.currentTarget.value)}
            onBlur={() => void save()}
          />
          <input
            class="peach-inline-input"
            placeholder="label (optional, e.g. AWS decoy)"
            value={label()}
            onInput={(e) => setLabel(e.currentTarget.value)}
            onBlur={() => void save()}
          />
          <div style="display:flex;align-items:center;gap:8px;margin-top:4px;">
            <button
              class="peach-btn peach-btn--ghost peach-btn--sm"
              onClick={() => void testPing()}
              disabled={testing() || !webhookUrl().trim()}
            >
              {testing() ? "sending..." : "test ping"}
            </button>
            <Show when={testResult()}>
              <span style="font-size:10px;color:var(--peach-t2);font-family:var(--font-mono);">{testResult()}</span>
            </Show>
          </div>
          <Show when={props.entry.canary?.lastPinged}>
            <p style="font-size:10px;color:var(--peach-t2);margin-top:4px;">
              Last triggered: {new Date(props.entry.canary!.lastPinged!).toLocaleDateString()}
              {" · "}{props.entry.canary?.pingCount ?? 0} total alerts
            </p>
          </Show>
        </div>
      </Show>
    </div>
  );
}

5.3 Canary CSS Additions

.peach-entry-advanced {
  margin-top: 12px;
  border-top: 1px solid var(--peach-t3);
  padding-top: 10px;
  position: relative;
  z-index: 1;
}
.peach-entry-advanced-toggle {
  display: flex;
  align-items: center;
  justify-content: space-between;
  width: 100%;
  background: transparent;
  border: none;
  cursor: pointer;
  padding: 0;
  color: var(--peach-t2);
}
.peach-canary-section {
  margin-top: 10px;
  display: grid;
  gap: 6px;
}

5.4 Canary Pings Still Fire Silently

When a user copies a password or username from Entry.tsx that has entry.canary, Peach should fire the webhook silently. Wire this in copyField():

async function copyField(text: string, label: string, button?: HTMLButtonElement) {
  const response = await sendMessage({ type: "COPY", payload: { text, label } });
  if ((response as { ok: boolean }).ok) {
    // ... existing copy feedback ...

    // Canary: fire silently on copy if this entry has a webhook
    const entry = props.entry;
    if (entry?.canary?.webhookUrl) {
      const trigger = label === "password" ? "password_copied" : "username_copied";
      void sendMessage({
        type: "FIRE_CANARY_PING",
        payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger },
      });
    }
  }
}

6. CJK Phrase Import — QR Scan Path

Currently, 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:

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.

The current QR display path on export is correct as-is. No changes needed.


7. Entry Detail — Canary Badge (Remove from Header, Keep in Advanced)

Currently (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.


8. shared/types.ts — MessageType Union Update

Add BATCH_ADD_ENTRIES to the MessageType union so TypeScript doesn't warn on the new message:

export type MessageType =
  | "CREATE_VAULT"
  | "UNLOCK"
  | "LOCK"
  | "IS_UNLOCKED"
  | "HAS_VAULT"
  | "GET_ENTRIES"
  | "ADD_ENTRY"
  | "BATCH_ADD_ENTRIES"  // ← add this
  | "UPDATE_ENTRY"
  | "DELETE_ENTRY"
  // ... rest of union unchanged

9. File Download in Extension Context

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

If 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:

function downloadBlob(content: string, mimeType: string, filename: string): void {
  try {
    const url = URL.createObjectURL(new Blob([content], { type: mimeType }));
    const a = document.createElement("a");
    a.href = url;
    a.download = filename;
    document.body.appendChild(a);  // Required in some browsers — ensure it's in the DOM
    a.click();
    document.body.removeChild(a);
    URL.revokeObjectURL(url);
  } catch (err) {
    console.error("[Peach] Download failed:", err);
    throw err; // Caller should catch and surface to UI
  }
}

Note: a.click() without document.body.appendChild(a) can fail in some Firefox/Chromium extension contexts. Adding the append/remove step is a safe guard.


10. CSS Classes Still Missing

Check that all of these exist in popup.css before declaring the settings page done. Add any that are absent:

Class Purpose
.peach-settings-input Full-width underlined input used in Export/import password fields
.peach-settings-select Dropdown select element (§4.3)
.peach-duress-status-pill / .is-active Duress on/off pill (§4.4)
.peach-entry-advanced Wrapper for the advanced disclosure in Entry
.peach-entry-advanced-toggle Button row that expands advanced section
.peach-canary-section Canary sub-form inside advanced
.peach-restore-warning Red warning box in Import
.peach-provider-chips / .peach-provider-chip Supported format chips in Import
.peach-import-summary "Found N entries, M skipped" text
.peach-import-preview / .peach-import-preview-row 8-row preview table

Run npx tsc --noEmit and check browser devtools for CSS missing-class warnings after implementation.


11. Implementation Order

Implement in this exact order. Each step should compile cleanly before proceeding.

  1. Fix toggle CSS (§1.2) — no logic changes, safe
  2. Fix SolidJS early-return bugs (§1.1) — enables CJK export/import to work
  3. Fix notes data loss (§1.3) — critical data integrity fix
  4. Fix download helper (§9) — defensive guard
  5. Add BATCH_ADD_ENTRIES (§1.4 + §8) — background + messages + Import.tsx
  6. Add auto-unlock-on-enter for encrypted import (§1.5)
  7. Fix unknown-format error message (§1.6)
  8. Export pre-flight and confirmation (§2.1, §2.2, §2.3)
  9. Import merge/replace choice (§3.1)
  10. Settings page redesign (§4) — visual + auto-lock wiring
  11. Per-entry canary section (§5)
  12. Typecheck + build + version bump to 1.4.21
  13. Smoke test — export encrypted JSON, re-import it, verify all entries round-trip including notes

12. Smoke Test Protocol

Before shipping, verify this exact flow manually:

Export round-trip:

  1. Open Peach. Confirm vault has at least: 1 login, 1 note, 1 entry with TOTP, 1 entry with tags.
  2. Go to Settings → Export → Encrypted file backup.
  3. Enter a 14-character password + confirm. Click "Download encrypted backup".
  4. Verify file downloads. Open it in a text editor — confirm it's JSON with "type":"peach-export".
  5. Go to Settings → Import. Select the downloaded file. Enter the same password. Click "Decrypt →".
  6. Verify preview shows correct entry count. Click Import.
  7. Verify vault now contains the expected entries including the note and TOTP entry.

CJK phrase round-trip:

  1. Go to Settings → Export → CJK phrase backup.
  2. Enter a 5-word diceware passphrase (use the "Generate" button). Confirm it. Click "Export →".
  3. After generation, click "Copy phrase". Open a text editor and paste.
  4. Go to Settings → Import → Restore from CJK phrase.
  5. Paste the phrase. Verify "✓ Phrase recognised" appears.
  6. Enter the same passphrase. Click "Restore".
  7. Verify entries are restored.

Toggle persistence:

  1. Open Settings. Toggle "Lock on close" off.
  2. Close the popup completely. Re-open.
  3. Verify toggle is still off. If it reverts to on, the UPDATE_SETTINGS persistence is broken.

Canary config:

  1. Open an entry. Scroll to "advanced". Expand.
  2. Toggle canary on. Enter a real webhook URL. Click elsewhere (blur).
  3. Close popup. Re-open. Open same entry. Verify canary is still on with URL intact.
  4. Click "test ping". Verify webhook receives the payload.

13. Version Bump

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