a1c947

chore: codebase audit and commentary pass (v1.7.1)

time
2026-04-23 23:20 UTC · 6 months ago
machine
koh2
parent
1a7d31
22 files .gitignore+3 −0manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/aes.ts+17 −0src/background/messages.ts+16 −1src/background/sync.ts+9 −0src/background/vault.ts+15 −0src/content/detect.ts+4 −1src/content/fill.ts+5 −0src/content/index.ts+7 −0src/content/inject.ts+15 −0src/crypto/argon2.ts+8 −0src/crypto/index.ts+0 −3src/popup/components/Generator.tsx+0 −78src/popup/views/Export.tsx+5 −0src/popup/views/Generator.tsx+0 −190src/popup/views/Import.tsx+4 −0src/popup/views/NoteEditor.tsx+0 −64src/popup/views/Vault.tsx+4 −0src/shared/types.ts+11 −0vite.config.ts+4 −0
.gitignore+3 −0
@@ -5,3 +5,6 @@55 .DS_Store66 .playwright-mcp/77 .gstack/8+.koh/9+test-results/10+tsconfig.tsbuildinfo

view file

manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach",4-  "version": "1.7.0",4+  "version": "1.7.1",55   "description": "Zero-knowledge password manager",66   "browser_specific_settings": {77     "gecko": {

view file

manifest.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach",4-  "version": "1.7.0",4+  "version": "1.7.1",55   "description": "Zero-knowledge password manager",66   "action": {77     "default_popup": "src/popup/index.html",

view file

package.json+1 −1
@@ -1,6 +1,6 @@11 {22   "name": "peach",3-  "version": "1.7.0",3+  "version": "1.7.1",44   "description": "Zero-knowledge password manager",55   "type": "module",66   "scripts": {

view file

src/background/aes.ts+17 −0
@@ -1,3 +1,13 @@1+/**2+ * aes.ts — AES-256-GCM encrypt/decrypt primitives for vault storage.3+ *4+ * Canonical AES module. `src/crypto/aes.ts` was a duplicate and has been5+ * removed (Projects.md §4.10); if it reappears, delete it again and6+ * redirect its imports here. All ciphertext in the vault slots is produced7+ * by `encrypt`/`decrypt` below with a fresh random 96-bit nonce and the8+ * 1-byte VERSION_BYTE as AAD.9+ */10+111 const VERSION_BYTE = new Uint8Array([1]);212 313 function hex(buf: ArrayBuffer | Uint8Array): string {@@ -27,6 +37,11 @@2737     ["encrypt"]2838   );2939 40+  // Fresh 96-bit nonce per encryption. Reusing a nonce with the same key under41+  // AES-GCM catastrophically breaks both confidentiality and authenticity —42+  // random 96-bit nonces make collision probability negligible (~N²/2⁹⁷) across43+  // any realistic vault lifetime. tagLength: 128 is the maximum and must not44+  // be reduced; the tag covers ciphertext + AAD (the version byte).3045   const nonce = crypto.getRandomValues(new Uint8Array(12));3146   const encoded = new TextEncoder().encode(plaintext);3247   const raw = await crypto.subtle.encrypt(@@ -40,6 +55,8 @@4055     encoded4156   );4257 58+  // WebCrypto returns ciphertext || 128-bit auth tag as one buffer. Split at59+  // (length - 16) so the two parts can be stored separately.4360   const full = new Uint8Array(raw);4461   const ciphertext = full.slice(0, full.length - 16);4562   const tag = full.slice(full.length - 16);

view file

src/background/messages.ts+16 −1
@@ -1,3 +1,14 @@1+/**2+ * messages.ts — the only background message handler.3+ *4+ * All `chrome.runtime.onMessage` handling for the service worker lives here.5+ * Do not handle messages inline in entry.ts, alarms.ts, or any other6+ * background file. Every background-bound MessageType in shared/types.ts7+ * must have a case below. Content-script-bound message types (PING,8+ * SUMMON_AUTOFILL, VAULT_LOCKED) are documented in shared/types.ts and9+ * intentionally have no case here — they are sent *to* content scripts.10+ */11+112 import { copyToClipboard } from "./clipboard";213 import { setupAutoLock, touchAutoLock } from "./alarms";314 import * as vault from "./vault";@@ -281,7 +292,11 @@281292           source: "peach-extension",282293         });283294         if (trigger === "test") {284-          // Test pings are awaited so the UI can show success/failure295+          // Permitted outbound fetch: user-configured canary alert endpoint296+          // (validated by isAllowedCanaryWebhookUrl above). This is the297+          // only non-HIBP outbound call in the extension — see298+          // Projects.md §3.7. Test pings are awaited so the UI can show299+          // success/failure.285300           try {286301             const res = await fetch(webhookUrl, {287302               method: "POST",

view file

src/background/sync.ts+9 −0
@@ -1,3 +1,12 @@1+/**2+ * DORMANT — peach-sync native host client.3+ *4+ * Not wired to any live code path. The native host (native/peach-sync-host)5+ * has an open critical bug (SYN-001, Projects.md §3.9). Do not import from6+ * this module. Do not re-enable sync without completing the SYN-001 fix7+ * sprint and a full re-audit of the native host first.8+ */9+110 type HostMessage =211   | { type: "ADVERTISE"; port: number; x25519Pubkey: string; ed25519Pubkey: string; name: string; pairing: boolean }312   | { type: "STOP_ADVERTISING" }

view file

src/background/vault.ts+15 −0
@@ -1,3 +1,14 @@1+/**2+ * vault.ts — sole owner of vault state and chrome.storage vault keys.3+ *4+ * Every read/write of `peach_vault_a`, `peach_vault_b`, and `peach_settings`5+ * goes through this module. No other file accesses those keys directly.6+ * External callers reach vault operations via `messages.ts` — never by7+ * importing from here cross-boundary (popup/content must not). Invariants8+ * for the two-slot schema, unlock timing, and key/disk atomicity are in9+ * Projects.md §3.2–§3.6.10+ */11+112 import { deriveKey, generateSalt } from "../crypto/argon2";213 import { extractDomain } from "../shared/domain";314 import type {@@ -657,6 +668,10 @@657668   };658669 }659670 671+// Soft-deletes every non-deleted entry in one call. The literal string672+// argument is a tripwire against accidental calls — any caller that would673+// wipe the vault has to consciously type "REPLACE_ALL". Do not relax this674+// to a boolean.660675 export async function clearVaultEntries(661676   confirmation: "REPLACE_ALL"662677 ): Promise<void> {

view file

src/content/detect.ts+4 −1
@@ -9,7 +9,10 @@99 const USERNAME_PATTERNS = /\b(user(name)?|e?mail(box)?|login|account|identifier|signin|sign.?in|credential)\b/i;1010 const PASSWORD_PATTERNS = /pass|passwd|pwd/i;1111 12-// Input types that are never text-entry fields and must never get autofill icons12+// Input types that are never text-entry fields and must never get autofill13+// icons. submit/button/reset/image are interactive controls — putting the14+// icon over them blocks the click. The rest are structurally incapable of15+// holding a username/password.1316 const SKIP_INPUT_TYPES = new Set([1417   "submit", "button", "reset", "image",1518   "checkbox", "radio", "hidden", "file",

view file

src/content/fill.ts+5 −0
@@ -38,6 +38,11 @@3838   }3939 }4040 41+// React tracks input values via a synthetic event system that does not see42+// direct `.value =` assignment. We must call the native HTMLInputElement43+// value setter (pulled off the prototype) and then dispatch the real input44+// and change events so React re-reads the new value. Without this, the45+// field reads as empty to the form's controlled-input state.4146 function setNativeValue(el: HTMLInputElement, value: string): void {4247   const nativeInputValueSetter = Object.getOwnPropertyDescriptor(4348     window.HTMLInputElement.prototype,

view file

src/content/index.ts+7 −0
@@ -20,6 +20,10 @@2020 import { generateTOTP } from "../crypto/totp";2121 import type { PasswordPolicy, VaultEntry } from "../shared/types";2222 23+// WeakMap (not WeakSet) — we keep the per-input cleanup callback as the value24+// so we can tear down listeners if we ever need to re-process the same field.25+// When the input is GC'd (SPA nav, React re-render), the map entry goes with26+// it and we can re-inject without leaking the old handler.2327 const processedInputs = new WeakMap<HTMLInputElement, () => void>();2428 const AUTOFILL_FIELD_TYPES = new Set(["password", "username", "email"]);2529 @@ -552,6 +556,9 @@552556   for (const f of newFields) {553557     reattachListeners(f.el, f.type, allFields);554558   }559+  // 200ms debounce — below this, Peach and the host page's reactive layer560+  // fight over the same layout properties every frame on React/Vue/Angular561+  // login forms (v1.4.14 regression, Projects.md §4.7). Do not lower.555562 }, 200);556563 557564 function setupMutationObserver(): void {

view file

src/content/inject.ts+15 −0
@@ -1,3 +1,14 @@1+/**2+ * inject.ts — autofill icon + selector overlay, injected into pages.3+ *4+ * Runs as a content script. Injects the Peach icon next to detected login5+ * fields, anchors it via event-driven positioning (no perpetual rAF — see6+ * Projects.md §4.7), and renders the inline autofill selector in a shadow7+ * DOM. Never holds decrypted vault data directly: all entry lookups go8+ * through `chrome.runtime.sendMessage`, and only the minimum fields needed9+ * to fill come back across the boundary.10+ */11+112 import { ACCENT_COLORS } from "../shared/types";213 314 let accentHex = "#E8956D";@@ -208,6 +219,10 @@208219   button.setAttribute("aria-label", "Peach autofill");209220   button.title = "Fill with Peach";210221   button.innerHTML = peachIconSvg();222+  // position: fixed is load-bearing — not absolute. Absolute positioning223+  // inherits the nearest positioned ancestor's box, which on sites with224+  // overflow:hidden or sticky parents clips or offsets the icon. Fixed225+  // anchors to the viewport and escapes all parent layout.211226   button.style.cssText = `212227     position: fixed; z-index: 2147483646;213228     width: 26px; height: 26px; cursor: pointer;

view file

src/crypto/argon2.ts+8 −0
@@ -1,5 +1,10 @@11 import { argon2id } from "hash-wasm";22 3+// Argon2id params: m=65536 KiB (64 MiB), t=3, p=4, 32-byte output.4+// Meets the OWASP "password manager" tier (m=65536, t=3, p=1) and exceeds it5+// on parallelism. Changing any of these changes the derived key — existing6+// vaults on disk can only be unlocked with the params they were sealed under.7+// Raising them tightens attacker cost; lowering them is a regression.38 export async function deriveKey(49   password: string,510   salt: Uint8Array@@ -16,6 +21,9 @@1621   return result;1722 }1823 24+// Per-vault salt: 16 bytes from the OS CSPRNG. A fresh salt is generated at25+// vault creation and on every changePassword. Prevents cross-user26+// precomputation attacks against the same master passphrase.1927 export function generateSalt(): Uint8Array {2028   return crypto.getRandomValues(new Uint8Array(16));2129 }

view file

src/crypto/index.ts+0 −3
@@ -1,3 +0,0 @@1-export { deriveKey, generateSalt } from "./argon2";2-export { encrypt, decrypt, zeroKey } from "../background/aes";3-export { generateTOTP, parseOtpauthURI } from "./totp";

view file at the parent

src/popup/components/Generator.tsx+0 −78
@@ -1,78 +0,0 @@1-import { createSignal } from "solid-js";2-import { generatePassword } from "../../shared/generate";3-4-interface GeneratorProps {5-  onSelect?: (password: string) => void;6-  onClose?: () => void;7-}8-9-export function Generator(props: GeneratorProps) {10-  const [password, setPassword] = createSignal(generatePassword({ length: 20 }));11-  const [length, setLength] = createSignal(20);12-  const [upper, setUpper] = createSignal(true);13-  const [lower, setLower] = createSignal(true);14-  const [numbers, setNumbers] = createSignal(true);15-  const [symbols, setSymbols] = createSignal(true);16-17-  function regenerate() {18-    setPassword(19-      generatePassword({20-        length: length(),21-        includeUppercase: upper(),22-        includeLowercase: lower(),23-        includeNumbers: numbers(),24-        includeSymbols: symbols(),25-      })26-    );27-  }28-29-  function handleUse() {30-    if (props.onSelect) {31-      props.onSelect(password());32-    }33-    if (props.onClose) {34-      props.onClose();35-    }36-  }37-38-  return (39-    <div>40-      <div class="generator-preview">{password()}</div>41-      <div class="generator-controls">42-        <span class="generator-label">Length: {length()}</span>43-        <input44-          type="range"45-          class="gen-slider"46-          min="8"47-          max="64"48-          value={length()}49-          onInput={(e) => {50-            setLength(parseInt((e.target as HTMLInputElement).value));51-            regenerate();52-          }}53-        />54-        <label style={{ display: "flex", "align-items": "center", gap: "4px", color: "var(--t2)", "font-size": "11px" }}>55-          <input type="checkbox" checked={upper()} onChange={(e) => { setUpper((e.target as HTMLInputElement).checked); regenerate(); }} />56-          ABC57-        </label>58-        <label style={{ display: "flex", "align-items": "center", gap: "4px", color: "var(--t2)", "font-size": "11px" }}>59-          <input type="checkbox" checked={lower()} onChange={(e) => { setLower((e.target as HTMLInputElement).checked); regenerate(); }} />60-          abc61-        </label>62-        <label style={{ display: "flex", "align-items": "center", gap: "4px", color: "var(--t2)", "font-size": "11px" }}>63-          <input type="checkbox" checked={numbers()} onChange={(e) => { setNumbers((e.target as HTMLInputElement).checked); regenerate(); }} />64-          12365-        </label>66-        <label style={{ display: "flex", "align-items": "center", gap: "4px", color: "var(--t2)", "font-size": "11px" }}>67-          <input type="checkbox" checked={symbols()} onChange={(e) => { setSymbols((e.target as HTMLInputElement).checked); regenerate(); }} />68-          !@#69-        </label>70-        <button class="gen-btn" onClick={regenerate}>Regenerate</button>71-      </div>72-      <div style={{ "margin-top": "12px", display: "flex", gap: "8px" }}>73-        <button class="btn-primary" onClick={handleUse} style={{ flex: 1 }}>Use Password</button>74-        <button class="btn-secondary" onClick={props.onClose} style={{ flex: 1 }}>Cancel</button>75-      </div>76-    </div>77-  );78-}

view file at the parent

src/popup/views/Export.tsx+5 −0
@@ -1,3 +1,8 @@1+// Signal-conditioned views in this file must use <Show>, never a2+// signal-conditioned early return (`if (signal()) return <X/>`). Early3+// returns in SolidJS run once at mount and never re-execute when signals4+// change — the v1.4.21 CJK export regression silently stopped re-rendering5+// because of this. See Projects.md §4.1.16 import { createMemo, createSignal, Show } from "solid-js";27 import { sendMessage } from "../App";38 import type { VaultEntry } from "../../shared/types";

view file

src/popup/views/Generator.tsx+0 −190
@@ -1,190 +0,0 @@1-import { createSignal, onMount, Show } from "solid-js";2-import { generatePassword } from "../../shared/generate";3-import type { PasswordPolicy } from "../../shared/types";4-import { sendMessage, triggerPipPulse, triggerRing } from "../App";5-6-interface GeneratorProps {7-  onBack: () => void;8-  onUse: (password: string) => void;9-  policy?: PasswordPolicy;10-}11-12-export function GeneratorView(props: GeneratorProps) {13-  const [password, setPassword] = createSignal("");14-  const [length, setLength] = createSignal(24);15-  const [upper, setUpper] = createSignal(true);16-  const [lower, setLower] = createSignal(true);17-  const [numbers, setNumbers] = createSignal(true);18-  const [symbols, setSymbols] = createSignal(true);19-  const [copied, setCopied] = createSignal(false);20-  const [detectedPolicy, setDetectedPolicy] = createSignal<PasswordPolicy | null>(null);21-22-  function initFromPolicy(policy: PasswordPolicy) {23-    if (policy.minLength) setLength(Math.max(length(), policy.minLength));24-    if (policy.maxLength) setLength(Math.min(length(), policy.maxLength));25-    if (policy.forbidsSymbols) setSymbols(false);26-    if (policy.requiresSymbols) setSymbols(true);27-  }28-29-  onMount(async () => {30-    if (props.policy) {31-      setDetectedPolicy(props.policy);32-      initFromPolicy(props.policy);33-    } else {34-      const res = await sendMessage({ type: "GET_SITE_PASSWORD_POLICY" });35-      const data = (res as { ok: boolean; data?: PasswordPolicy }).data;36-      if (data && Object.keys(data).length > 0) {37-        setDetectedPolicy(data);38-        initFromPolicy(data);39-      }40-    }41-    regenerate();42-  });43-44-  function regenerate(nextLength = length()) {45-    setPassword(46-      generatePassword({47-        length: nextLength,48-        includeUppercase: upper(),49-        includeLowercase: lower(),50-        includeNumbers: numbers(),51-        includeSymbols: symbols(),52-      })53-    );54-  }55-56-  async function copyPassword(button?: HTMLButtonElement) {57-    const response = await sendMessage({58-      type: "COPY",59-      payload: { text: password(), label: "password" },60-    });61-    if ((response as { ok: boolean }).ok) {62-      triggerRing();63-      triggerPipPulse();64-      setCopied(true);65-      if (button) {66-        button.classList.remove("is-retreat");67-        const drain = document.createElement("span");68-        drain.className = "peach-copy-drain";69-        const bar = document.createElement("span");70-        bar.className = "peach-copy-drain-bar";71-        drain.appendChild(bar);72-        button.appendChild(drain);73-        window.setTimeout(() => {74-          setCopied(false);75-          button.classList.remove("is-copied");76-          button.classList.add("is-retreat");77-          drain.remove();78-          window.setTimeout(() => button.classList.remove("is-retreat"), 160);79-        }, 1400);80-        return;81-      }82-      window.setTimeout(() => setCopied(false), 1400);83-    }84-  }85-86-  return (87-    <div class="peach-overlay-view ov-enter-right" style="display:flex">88-      <div class="peach-overlay-header">89-        <button90-          class="peach-btn peach-btn--subtle peach-btn--sm peach-overlay-action"91-          onClick={props.onBack}92-        >93-          cancel94-        </button>95-        <span class="peach-overlay-title">password generator</span>96-        <button97-          class="peach-btn peach-btn--accent peach-btn--sm peach-overlay-action"98-          onClick={() => props.onUse(password())}99-        >100-          use101-        </button>102-      </div>103-104-      <div class="peach-pwgen-body">105-        <Show when={detectedPolicy()}>106-          <div class="peach-policy-badge">107-            <svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="1" style="margin-right:4px;opacity:0.7;">108-              <circle cx="5" cy="5" r="4" />109-              <path d="M5 3v3M5 7h.01" />110-            </svg>111-            site requirements detected112-          </div>113-        </Show>114-        <div class="peach-pwgen-display">115-          <p class="peach-pwgen-output">{password()}</p>116-          <div class="peach-pwgen-display-actions">117-            <button118-              class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"119-              onClick={() => regenerate()}120-              aria-label="Regenerate"121-            >122-              <svg width="13" height="13" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">123-                <path d="M11 2.5A5.5 5.5 0 1 0 12.5 7" />124-                <path d="M9.5 2.5H11V1" />125-              </svg>126-            </button>127-            <button128-              class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() ? "is-copied" : ""}`}129-              onClick={(e) =>130-                void copyPassword(e.currentTarget as HTMLButtonElement)131-              }132-              aria-label="Copy password"133-            >134-              <span class="peach-copy-icon">135-                <svg class="peach-copy-icon-svg" width="12" height="12" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">136-                  <rect x="4" y="4" width="8" height="9" rx="1.5" />137-                  <path d="M10 4V2.5A1.5 1.5 0 0 0 8.5 1h-6A1.5 1.5 0 0 0 1 2.5v7A1.5 1.5 0 0 0 2.5 11H4" />138-                </svg>139-              </span>140-              <span class="peach-copy-sheen"></span>141-            </button>142-          </div>143-        </div>144-145-        <div class="peach-pwgen-settings">146-          <div class="peach-pwgen-length-row">147-            <div class="peach-pwgen-length-labels">148-              <span class="peach-label">length</span>149-              <span class="peach-pwgen-len-num">{length()}</span>150-            </div>151-            <input152-              class="peach-pwgen-slider"153-              type="range"154-              min="8"155-              max="128"156-              value={length()}157-              onInput={(e) => {158-                const nextLength = parseInt((e.currentTarget as HTMLInputElement).value, 10);159-                setLength(nextLength);160-                regenerate(nextLength);161-              }}162-            />163-          </div>164-165-          <div class="peach-pwgen-toggles">166-            {[167-              ["Uppercase A-Z", upper, setUpper],168-              ["Lowercase a-z", lower, setLower],169-              ["Numbers 0-9", numbers, setNumbers],170-              ["Symbols !@#$", symbols, setSymbols],171-            ].map(([label, getter, setter]) => (172-              <div class="peach-pwgen-toggle-row">173-                <span class="peach-pwgen-toggle-label">{label as string}</span>174-                <button175-                  class={`peach-toggle ${(getter as () => boolean)() ? "is-on" : ""}`}176-                  onClick={() => {177-                    (setter as (value: boolean) => void)(!(getter as () => boolean)());178-                    regenerate();179-                  }}180-                >181-                  <span class="peach-toggle-knob"></span>182-                </button>183-              </div>184-            ))}185-          </div>186-        </div>187-      </div>188-    </div>189-  );190-}

view file at the parent

src/popup/views/Import.tsx+4 −0
@@ -1,3 +1,7 @@1+// Signal-conditioned views in this file must use <Show>, never a2+// signal-conditioned early return (`if (signal()) return <X/>`). Early3+// returns in SolidJS run once at mount and never re-execute when signals4+// change — see Projects.md §4.1.15 import { createMemo, createSignal, Show } from "solid-js";26 37 import { sendMessage } from "../App";

view file

src/popup/views/NoteEditor.tsx+0 −64
@@ -69,70 +69,6 @@6969   );7070 }7171 72-/* ── Full-popup overlay editor (Entry.tsx) ── */73-74-interface NoteEditorProps {75-  initialContent: string;76-  saving: boolean;77-  onSave: (html: string) => Promise<void>;78-  onCancel: () => void;79-}80-81-export function NoteEditor(props: NoteEditorProps) {82-  let editorRef: HTMLDivElement | undefined;83-84-  onMount(() => {85-    if (!editorRef) return;86-    editorRef.innerHTML = toHtml(props.initialContent);87-    editorRef.focus();88-    const sel = window.getSelection();89-    if (sel) {90-      const range = document.createRange();91-      range.selectNodeContents(editorRef);92-      range.collapse(false);93-      sel.removeAllRanges();94-      sel.addRange(range);95-    }96-  });97-98-  function exec(cmd: string, value?: string) {99-    document.execCommand(cmd, false, value);100-    editorRef?.focus();101-  }102-103-  function handleKeyDown(e: KeyboardEvent) {104-    const mod = e.metaKey || e.ctrlKey;105-    if (mod && e.key === "b") { e.preventDefault(); exec("bold"); return; }106-    if (mod && e.key === "i") { e.preventDefault(); exec("italic"); return; }107-    if (mod && e.key === "u") { e.preventDefault(); exec("underline"); return; }108-    if (e.key === "Escape") { e.preventDefault(); props.onCancel(); }109-  }110-111-  return (112-    <div class="peach-note-editor">113-      <div class="peach-note-editor-header">114-        <button class="peach-note-editor-back" onClick={props.onCancel}115-          disabled={props.saving} aria-label="Cancel">116-          <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor"117-            stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">118-            <path d="M6 1L1 6L6 11" />119-          </svg>120-        </button>121-        <span class="peach-note-editor-label">note</span>122-        <button class="peach-note-editor-save-btn"123-          onClick={() => void props.onSave(editorRef?.innerHTML ?? "")}124-          disabled={props.saving}>125-          {props.saving ? "saving…" : "save"}126-        </button>127-      </div>128-      <Toolbar exec={exec} />129-      <div ref={editorRef} class="peach-note-editor-body"130-        contenteditable="true" onKeyDown={handleKeyDown}131-        aria-multiline="true" aria-label="Note content" />132-    </div>133-  );134-}135-13672 /* ── Inline editor (Add.tsx, Entry.tsx note view) ── */13773 13874 interface NoteEditorInlineProps {

view file

src/popup/views/Vault.tsx+4 −0
@@ -204,6 +204,10 @@204204     return result;205205   });206206 207+  // Declaration order matters: `allVisibleIds` and `allSelected` read208+  // `filteredEntries` eagerly on component mount via createMemo. Moving209+  // `filteredEntries` below them throws ReferenceError under the production210+  // minifier and freezes the vault list on mount (v1.4.34 regression).207211   const allVisibleIds = createMemo(() => filteredEntries().map(e => e.id));208212   const allSelected = createMemo(() =>209213     allVisibleIds().length > 0 && allVisibleIds().every(id => selectedIds().has(id))

view file

src/shared/types.ts+11 −0
@@ -1,3 +1,14 @@1+/**2+ * types.ts — cross-boundary types and the `MessageType` union.3+ *4+ * `MessageType` mixes background-bound and content-script-bound messages.5+ * Background-bound types have a case in `src/background/messages.ts`.6+ * Content-script-bound types (PING, SUMMON_AUTOFILL, VAULT_LOCKED,7+ * RESTART_AUTO_LOCK, etc.) are sent *to* content scripts or the popup and8+ * have no handler in messages.ts by design — that absence is not a missing9+ * handler. Add new cross-boundary types here, not inline in call sites.10+ */11+112 export interface ManualSummonRecord {213   firstSummonedAt: number;314   summonCount: number;

view file

vite.config.ts+4 −0
@@ -16,6 +16,10 @@1616   },1717   build: {1818     outDir: target === "firefox" ? "dist-firefox" : "dist-chrome",19+    // emptyOutDir must stay false. When true, Vite tries to remove the20+    // contents of the outDir before each build, which throws EPERM on FUSE21+    // and some mounted filesystems and aborts the whole build. Overwriting22+    // in place works everywhere.1923     emptyOutDir: false,2024     target: "esnext",2125     rollupOptions: {

view file