a1c947
chore: codebase audit and commentary pass (v1.7.1)
- time
- 2026-04-23 23:20 UTC · 6 months ago
- machine
- koh2
- parent
- 1a7d31
↓ peach-browser-a1c947.tar.gzatlas at this save
files changed22 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.tsbuildinfomanifest.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": {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",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": {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);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",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" }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> {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",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,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 {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;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 }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";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-}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";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-}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";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 {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))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;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: {