705a2fcd8d9a
L5/QUA-004: standardize error codes to snake_case across messages.ts, vault.ts and add ErrorCode type union
- time
- 2026-05-16 05:14 UTC · 5 months ago
- machine
- koh2
- parent
- 214299cfc6f6
↓ peach-browser-705a2fcd.tar.gzatlas at this save
files changed11 files
src/background/messages.adversarial.test.ts+2 −2src/background/messages.test.ts+9 −9src/background/messages.ts+36 −36src/background/pairing-completed-gate.test.ts+1 −1src/background/passkey.ts+2 −2src/background/vault.ts+16 −16src/popup/App.tsx+1 −1src/popup/views/Add.tsx+1 −1src/popup/views/Import.tsx+1 −1src/popup/views/RecoveryWordEntry.tsx+1 −1src/shared/types.ts+39 −0src/background/messages.adversarial.test.ts+2 −2
@@ -297,7 +297,7 @@297297 );298298 });299299 expect(r.ok).toBe(false);300- expect(r.error).toBe("invalid sender");300+ expect(r.error).toBe("invalid_sender");301301 });302302 303303 it("sender with no id (extension UI) is accepted", async () => {@@ -797,7 +797,7 @@797797 );798798 });799799 expect(r.ok).toBe(false);800- expect(r.error).toBe("invalid sender");800+ expect(r.error).toBe("invalid_sender");801801 });802802 }803803 });src/background/messages.test.ts+9 −9
@@ -204,7 +204,7 @@204204 url: "https://github.com",205205 },206206 })207- ).resolves.toMatchObject({ ok: false, error: "vault locked" });207+ ).resolves.toMatchObject({ ok: false, error: "vault_locked" });208208 });209209 210210 it("ADD_ENTRY with a missing password returns a validation error", async () => {@@ -292,7 +292,7 @@292292 type: "DELETE_ENTRY",293293 payload: { id: "missing" },294294 })295- ).resolves.toMatchObject({ ok: false, error: "entry not found" });295+ ).resolves.toMatchObject({ ok: false, error: "entry_not_found" });296296 });297297 298298 it("GET_ENTRY_FOR_FILL returns a safe login entry without privateKeyJwk", async () => {@@ -331,7 +331,7 @@331331 type: "GET_ENTRY_FOR_FILL",332332 payload: { id: "missing" },333333 })334- ).resolves.toMatchObject({ ok: false, error: "vault locked" });334+ ).resolves.toMatchObject({ ok: false, error: "vault_locked" });335335 });336336 337337 it("GET_ENTRY_FOR_FILL rejects passkey-only entries", async () => {@@ -364,7 +364,7 @@364364 type: "GET_ENTRY_FOR_FILL",365365 payload: { id: entry.id },366366 })367- ).resolves.toMatchObject({ ok: false, error: "passkey not fillable" });367+ ).resolves.toMatchObject({ ok: false, error: "passkey_not_fillable" });368368 });369369 370370 it("AUTOFILL_QUERY returns matched domain metadata without secrets", async () => {@@ -491,7 +491,7 @@491491 newPassword: "another-pass",492492 },493493 })494- ).resolves.toMatchObject({ ok: false, error: "incorrect current password" });494+ ).resolves.toMatchObject({ ok: false, error: "incorrect_current_password" });495495 });496496 497497 it("UPDATE_SETTINGS reschedules auto-lock using the new minutes value", async () => {@@ -546,7 +546,7 @@546546 547547 await expect(dispatch({ type: "GET_ENTRIES" }, { id: "other-extension" })).resolves.toMatchObject({548548 ok: false,549- error: "invalid sender",549+ error: "invalid_sender",550550 });551551 552552 await expect(dispatch({ type: "GET_ENTRIES" }, {})).resolves.toMatchObject({@@ -614,7 +614,7 @@614614 trigger: "password_copied",615615 },616616 })617- ).resolves.toMatchObject({ ok: false, error: "invalid webhook url" });617+ ).resolves.toMatchObject({ ok: false, error: "invalid_webhook_url" });618618 619619 expect(fetchMock).not.toHaveBeenCalled();620620 });@@ -645,7 +645,7 @@645645 },646646 });647647 648- expect(response).toMatchObject({ ok: false, error: "message is empty" });648+ expect(response).toMatchObject({ ok: false, error: "message_empty" });649649 expect(fetchMock).not.toHaveBeenCalled();650650 });651651 @@ -655,7 +655,7 @@655655 vault.lock();656656 await expect(dispatch({ type: "GET_IDENTITIES" })).resolves.toMatchObject({657657 ok: false,658- error: "vault locked",658+ error: "vault_locked",659659 });660660 });661661 src/background/messages.ts+36 −36
@@ -306,7 +306,7 @@306306 chrome.runtime.onMessage.addListener(307307 (msg: unknown, sender, sendResponse) => {308308 if (!isMessage(msg)) {309- sendResponse({ ok: false, error: "invalid message" });309+ sendResponse({ ok: false, error: "invalid_message" });310310 return true;311311 }312312 handleMessage(msg, sender)@@ -327,7 +327,7 @@327327 const reg = (globalThis as any).__peachRegisterMessageHandler;328328 if (typeof reg === "function") {329329 reg(async (m: unknown) => {330- if (!isMessage(m)) return { ok: false, error: "invalid message" };330+ if (!isMessage(m)) return { ok: false, error: "invalid_message" };331331 try {332332 return await handleMessage(m);333333 } catch (err) {@@ -407,7 +407,7 @@407407 sender.id !== undefined &&408408 sender.id !== chrome.runtime.id409409 ) {410- return { ok: false, error: "invalid sender" };410+ return { ok: false, error: "invalid_sender" };411411 }412412 413413 if (shouldRefreshAutoLock(msg.type) && vault.isUnlocked()) {@@ -453,7 +453,7 @@453453 }454454 case "GET_RECOVERY_WORDS": {455455 // Must be unlocked and have recovery set up (vault must be v2.12+).456- if (!vault.isUnlocked()) return { ok: false, error: "vault locked" };456+ if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };457457 try {458458 const words = await vault.getRecoveryWords();459459 return { ok: true, data: words };@@ -470,7 +470,7 @@470470 newPassword: string;471471 };472472 const stored = await vault.loadSlotA();473- if (!stored) return { ok: false, error: "no vault" };473+ if (!stored) return { ok: false, error: "no_vault" };474474 if (!stored.recovery_salt || !stored.recovery_wrapped_root_key) {475475 return { ok: false, error: "recovery_not_setup" };476476 }@@ -512,7 +512,7 @@512512 if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };513513 return { ok: true, data: vault.getEntries() };514514 case "ADD_ENTRY": {515- if (!vault.isUnlocked()) return { ok: false, error: "vault locked" };515+ if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };516516 const payload = msg.payload;517517 if (!payload || typeof payload !== "object" || Array.isArray(payload)) {518518 return { ok: false, error: "invalid_payload" };@@ -591,10 +591,10 @@591591 case "ADD_ADDITIONAL_URL": {592592 const { id, url } = msg.payload as { id: string; url: string };593593 const trimmed = typeof url === "string" ? url.trim() : "";594- if (!trimmed) return { ok: false, error: "url required" };594+ if (!trimmed) return { ok: false, error: "url_required" };595595 const existing = vault.getEntryById(id);596- if (!existing) return { ok: false, error: "entry not found" };597- if (existing.type !== "login") return { ok: false, error: "login entries only" };596+ if (!existing) return { ok: false, error: "entry_not_found" };597+ if (existing.type !== "login") return { ok: false, error: "login_entries_only" };598598 const merged = [...(existing.additionalUrls ?? []), trimmed];599599 await vault.updateEntry(id, { additionalUrls: merged });600600 return { ok: true };@@ -673,7 +673,7 @@673673 publicKeyHex?: string;674674 deviceIp?: string;675675 };676- if (!publicKeyHex) return { ok: false, error: "publicKeyHex required" };676+ if (!publicKeyHex) return { ok: false, error: "publickeyhex_required" };677677 // Only accept publicKeyHex values that just completed a real Noise NK678678 // handshake (initiator or responder side). Without this gate, anyone679679 // who can sendMessage to the background can pair an arbitrary key@@ -707,7 +707,7 @@707707 }708708 case "AUTOFILL_QUERY": {709709 if (!vault.isUnlocked()) {710- return { ok: false, error: "VAULT_LOCKED" };710+ return { ok: false, error: "vault_locked" };711711 }712712 const { domain, includeAll, kind } = msg.payload as {713713 domain: string;@@ -767,14 +767,14 @@767767 };768768 }769769 case "GET_ENTRY_FOR_FILL": {770- if (!vault.isUnlocked()) return { ok: false, error: "vault locked" };770+ if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };771771 const { id } = msg.payload as { id: string };772772 const entry = vault.getEntryById(id);773- if (!entry) return { ok: false, error: "entry not found" };773+ if (!entry) return { ok: false, error: "entry_not_found" };774774 // Passkey-only entries (passkey credential, no password) cannot be775775 // filled into a form — sign-in goes through the WebAuthn path. A776776 // login that ALSO has a passkey is still fillable for its password.777- if (isPasskeyOnly(entry)) return { ok: false, error: "passkey not fillable" };777+ if (isPasskeyOnly(entry)) return { ok: false, error: "passkey_not_fillable" };778778 // Strip the passkey credential from the response — content scripts779779 // never need the private key, and there's no reason to expose the780780 // public credential metadata over the boundary either.@@ -854,7 +854,7 @@854854 trigger: "password_copied" | "username_copied" | "autofilled" | "submitted" | "test";855855 };856856 if (!isAllowedCanaryWebhookUrl(webhookUrl)) {857- return { ok: false, error: "invalid webhook url" };857+ return { ok: false, error: "invalid_webhook_url" };858858 }859859 const body = JSON.stringify({860860 event: trigger === "test" ? "peach_canary_test" : "peach_canary_accessed",@@ -877,7 +877,7 @@877877 });878878 return { ok: res.ok, error: res.ok ? undefined : `HTTP ${res.status}` };879879 } catch (err) {880- return { ok: false, error: err instanceof Error ? err.message : "network error" };880+ return { ok: false, error: err instanceof Error ? err.message : "network_error" };881881 }882882 }883883 // Non-test triggers are fire-and-forget@@ -942,7 +942,7 @@942942 return { ok: true };943943 }944944 case "GET_IDENTITIES": {945- if (!vault.isUnlocked()) return { ok: false, error: "vault locked" };945+ if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };946946 return { ok: true, data: vault.getIdentities() };947947 }948948 case "TOUCH_AUTO_LOCK": {@@ -953,26 +953,26 @@953953 case "GET_ADVANCED_HEALTH_REPORT": {954954 const gate = await requirePro("advanced_health");955955 if (!gate.allowed) return { ok: false, error: "pro_required" };956- if (!vault.isUnlocked()) return { ok: false, error: "vault locked" };956+ if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };957957 const report = await buildAdvancedHealthReport(vault.getEntries());958958 return { ok: true, data: report };959959 }960960 case "INITIATE_PASSWORD_CHANGE": {961961 const gate = await requirePro("smart_password_change");962962 if (!gate.allowed) return { ok: false, error: "pro_required" };963- if (!vault.isUnlocked()) return { ok: false, error: "vault locked" };963+ if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };964964 const { id } = msg.payload as { id: string };965965 const entry = vault.getEntryById(id);966- if (!entry) return { ok: false, error: "entry not found" };966+ if (!entry) return { ok: false, error: "entry_not_found" };967967 const url = resolveResetUrl(entry);968- if (!url) return { ok: false, error: "no reset url" };968+ if (!url) return { ok: false, error: "no_reset_url" };969969 try {970970 await chrome.tabs.create({ url });971971 return { ok: true, data: { url } };972972 } catch (err) {973973 return {974974 ok: false,975- error: err instanceof Error ? err.message : "failed to open tab",975+ error: err instanceof Error ? err.message : "failed_to_open_tab",976976 };977977 }978978 }@@ -1002,7 +1002,7 @@10021002 const changed = await vault.changePassword(currentPassword, newPassword);10031003 return changed10041004 ? { ok: true }1005- : { ok: false, error: "incorrect current password" };1005+ : { ok: false, error: "incorrect_current_password" };10061006 }10071007 case "GET_SITE_PASSWORD_POLICY": {10081008 const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });@@ -1202,7 +1202,7 @@12021202 userName?: string;12031203 userDisplayName?: string;12041204 };1205- if (!rpId || !userName) return { ok: false, error: "rpId and userName required" };1205+ if (!rpId || !userName) return { ok: false, error: "rpid_and_username_required" };12061206 return await initiateFirefoxPasskeyCreate({12071207 rpId,12081208 rpName: rpName ?? rpId,@@ -1213,20 +1213,20 @@12131213 12141214 case "COMPLETE_FIREFOX_PASSKEY_CREATE": {12151215 const payload = msg.payload as Parameters<typeof completeFirefoxPasskeyCreate>[0] | undefined;1216- if (!payload) return { ok: false, error: "missing payload" };1216+ if (!payload) return { ok: false, error: "missing_payload" };12171217 return await completeFirefoxPasskeyCreate(payload);12181218 }12191219 12201220 case "PASSKEY_OFFER_DECLINE": {12211221 const { domain } = (msg.payload ?? {}) as { domain?: string };1222- if (!domain) return { ok: false, error: "domain required" };1222+ if (!domain) return { ok: false, error: "domain_required" };12231223 await recordPasskeyOfferDecline(domain);12241224 return { ok: true };12251225 }12261226 12271227 case "GET_PASSKEY_OFFER_DECLINED": {12281228 const { domain } = (msg.payload ?? {}) as { domain?: string };1229- if (!domain) return { ok: false, error: "domain required" };1229+ if (!domain) return { ok: false, error: "domain_required" };12301230 return { ok: true, data: await isPasskeyOfferDeclined(domain) };12311231 }12321232 @@ -1235,7 +1235,7 @@12351235 rpId?: string;12361236 allowCredentials?: Array<{ id: string; type?: string }>;12371237 } | undefined;1238- if (!payload?.rpId) return { ok: false, error: "rpId required" };1238+ if (!payload?.rpId) return { ok: false, error: "rpid_required" };12391239 return await handlePasskeyHasCredentials({12401240 rpId: payload.rpId,12411241 allowCredentials: payload.allowCredentials ?? [],@@ -1245,14 +1245,14 @@12451245 case "PASSKEY_CREATE": {12461246 const payload = msg.payload as Parameters<typeof handleFirefoxPasskeyCreate>[0] | undefined;12471247 if (!payload?.rpId || !payload?.userName) {1248- return { ok: false, error: "rpId and userName required" };1248+ return { ok: false, error: "rpid_and_username_required" };12491249 }12501250 return await handleFirefoxPasskeyCreate(payload);12511251 }12521252 12531253 case "PASSKEY_GET": {12541254 const payload = msg.payload as Parameters<typeof handleFirefoxPasskeyGet>[0] | undefined;1255- if (!payload?.rpId) return { ok: false, error: "rpId required" };1255+ if (!payload?.rpId) return { ok: false, error: "rpid_required" };12561256 return await handleFirefoxPasskeyGet(payload);12571257 }12581258 @@ -1270,7 +1270,7 @@12701270 12711271 case "GET_PASSKEY_FOR_DOMAIN": {12721272 const { domain } = (msg.payload ?? {}) as { domain?: string };1273- if (!domain) return { ok: false, error: "domain required" };1273+ if (!domain) return { ok: false, error: "domain_required" };12741274 if (!vault.isUnlocked()) return { ok: true, data: null };12751275 const match = vault12761276 .getEntries()@@ -1310,10 +1310,10 @@13101310 version: string;13111311 browser: string;13121312 };1313- if (!message.trim()) return { ok: false, error: "message is empty" };1313+ if (!message.trim()) return { ok: false, error: "message_empty" };13141314 if (!FEEDBACK_ENDPOINT) return { ok: true };13151315 if (!isAllowedCanaryWebhookUrl(FEEDBACK_ENDPOINT)) {1316- return { ok: false, error: "invalid feedback endpoint" };1316+ return { ok: false, error: "invalid_feedback_endpoint" };13171317 }13181318 try {13191319 const res = await fetch(FEEDBACK_ENDPOINT, {@@ -1323,7 +1323,7 @@13231323 });13241324 return { ok: res.ok, error: res.ok ? undefined : `HTTP ${res.status}` };13251325 } catch (err) {1326- return { ok: false, error: err instanceof Error ? err.message : "network error" };1326+ return { ok: false, error: err instanceof Error ? err.message : "network_error" };13271327 }13281328 }13291329 case "TRIGGER_SYNC": {@@ -1504,12 +1504,12 @@15041504 }15051505 15061506 default:1507- return { ok: false, error: "unknown message type" };1507+ return { ok: false, error: "unknown_message_type" };15081508 }15091509 } catch (err) {15101510 return {15111511 ok: false,1512- error: err instanceof Error ? err.message : "operation failed",1512+ error: err instanceof Error ? err.message : "operation_failed",15131513 };15141514 }15151515 }src/background/pairing-completed-gate.test.ts+1 −1
@@ -148,6 +148,6 @@148148 payload: { deviceName: "X" },149149 });150150 expect(r.ok).toBe(false);151- expect(r.error).toBe("publicKeyHex required");151+ expect(r.error).toBe("publickeyhex_required");152152 });153153 });src/background/passkey.ts+2 −2
@@ -631,7 +631,7 @@631631 }632632 if (!vault.isUnlocked()) {633633 await openApprovalPopup();634- return { ok: false, error: "vault locked" };634+ return { ok: false, error: "vault_locked" };635635 }636636 if (!chrome.storage.session) {637637 return { ok: false, error: "session storage unavailable" };@@ -676,7 +676,7 @@676676 backupEligible?: boolean;677677 backupState?: boolean;678678 }): Promise<{ ok: boolean; error?: string }> {679- if (!vault.isUnlocked()) return { ok: false, error: "vault locked" };679+ if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };680680 681681 const passkey: PasskeyData = {682682 rpId: payload.rpId,src/background/vault.ts+16 −16
@@ -1254,14 +1254,14 @@12541254 }12551255 12561256 export function getEntries(): VaultEntry[] {1257- if (!derivedKey) throw new Error("vault locked");1257+ if (!derivedKey) throw new Error("vault_locked");12581258 return entries.filter((entry) => !entry.deletedAt);12591259 }12601260 12611261 const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;12621262 12631263 export function getIdentities(): SavedIdentity[] {1264- if (!derivedKey) throw new Error("vault locked");1264+ if (!derivedKey) throw new Error("vault_locked");12651265 const active = entries.filter((e) => !e.deletedAt && e.type === "login");12661266 12671267 const emailMap = new Map<string, { lastUsed: number; count: number; domain: string }>();@@ -1293,7 +1293,7 @@12931293 }12941294 12951295 export async function batchAddEntries(drafts: EntryDraft[]): Promise<VaultEntry[]> {1296- if (!derivedKey) throw new Error("vault locked");1296+ if (!derivedKey) throw new Error("vault_locked");12971297 12981298 const now = Date.now();12991299 const added: VaultEntry[] = [];@@ -1331,7 +1331,7 @@13311331 incoming: VaultEntry[],13321332 mode: "replace" | "merge"13331333 ): Promise<{ added: number; updated: number; skipped: number }> {1334- if (!derivedKey) throw new Error("vault locked");1334+ if (!derivedKey) throw new Error("vault_locked");13351335 13361336 // A replace with zero incoming entries is almost certainly a caller bug13371337 // (e.g. an import that silently dropped everything). Refuse rather than@@ -1440,7 +1440,7 @@14401440 if (confirmation !== "REPLACE_ALL") {14411441 throw new Error("clearVaultEntries requires explicit confirmation");14421442 }1443- if (!derivedKey) throw new Error("vault locked");1443+ if (!derivedKey) throw new Error("vault_locked");14441444 for (const entry of entries) {14451445 if (!entry.deletedAt) {14461446 entry.deletedAt = Date.now();@@ -1451,7 +1451,7 @@14511451 }14521452 14531453 export async function addEntry(entry: EntryDraft): Promise<VaultEntry> {1454- if (!derivedKey) throw new Error("vault locked");1454+ if (!derivedKey) throw new Error("vault_locked");14551455 14561456 const sanitized = sanitizeEntryDraft(entry);14571457 const now = Date.now();@@ -1488,11 +1488,11 @@14881488 id: string,14891489 patch: Partial<VaultEntry>14901490 ): Promise<void> {1491- if (!derivedKey) throw new Error("vault locked");1491+ if (!derivedKey) throw new Error("vault_locked");14921492 14931493 const idx = entries.findIndex((entry) => entry.id === id);14941494 if (idx === -1 || entries[idx].deletedAt) {1495- throw new Error("entry not found");1495+ throw new Error("entry_not_found");14961496 }14971497 14981498 const current = entries[idx];@@ -1551,11 +1551,11 @@15511551 }15521552 15531553 export async function deleteEntry(id: string): Promise<void> {1554- if (!derivedKey) throw new Error("vault locked");1554+ if (!derivedKey) throw new Error("vault_locked");15551555 15561556 const entry = entries.find((item) => item.id === id);15571557 if (!entry || entry.deletedAt) {1558- throw new Error("entry not found");1558+ throw new Error("entry_not_found");15591559 }15601560 15611561 entry.deletedAt = Date.now();@@ -1569,7 +1569,7 @@15691569 }15701570 15711571 export async function restoreEntry(id: string): Promise<void> {1572- if (!derivedKey) throw new Error("vault locked");1572+ if (!derivedKey) throw new Error("vault_locked");15731573 const entry = entries.find((e) => e.id === id && e.deletedAt);15741574 if (!entry) throw new Error("deleted entry not found");15751575 delete entry.deletedAt;@@ -1579,7 +1579,7 @@15791579 }15801580 15811581 export async function purgeEntry(id: string): Promise<void> {1582- if (!derivedKey) throw new Error("vault locked");1582+ if (!derivedKey) throw new Error("vault_locked");15831583 const idx = entries.findIndex((e) => e.id === id && e.deletedAt);15841584 if (idx === -1) throw new Error("deleted entry not found");15851585 entries.splice(idx, 1);@@ -1608,7 +1608,7 @@16081608 async function persistSlotA(16091609 reason: VaultSnapshot["reason"] = "local_save",16101610 ): Promise<StoredVault> {1611- if (!derivedKey) throw new Error("vault locked");1611+ if (!derivedKey) throw new Error("vault_locked");16121612 16131613 const slotA = await loadSlot(SLOT_A);16141614 if (!slotA) throw new Error("missing slot A");@@ -1642,7 +1642,7 @@16421642 }16431643 16441644 async function persistSlotB(): Promise<void> {1645- if (!derivedKey) throw new Error("vault locked");1645+ if (!derivedKey) throw new Error("vault_locked");16461646 16471647 const slotB = await loadSlot(SLOT_B);16481648 if (!slotB) throw new Error("missing slot B");@@ -1659,7 +1659,7 @@16591659 async function persistEntries(16601660 reason: VaultSnapshot["reason"] = "local_save",16611661 ): Promise<void> {1662- if (!derivedKey) throw new Error("vault locked");1662+ if (!derivedKey) throw new Error("vault_locked");16631663 16641664 if (vaultMode === "decoy") {16651665 await persistSlotB();@@ -1971,7 +1971,7 @@19711971 * each group. Returns the number of entries moved to trash.19721972 */19731973 export async function deduplicateEntries(): Promise<number> {1974- if (!derivedKey) throw new Error("vault locked");1974+ if (!derivedKey) throw new Error("vault_locked");19751975 // Dedup reshapes slot B permanently under the decoy key, which is19761976 // forensically distinguishable from a freshly-seeded decoy vault. Refuse.19771977 if (vaultMode === "decoy") {src/popup/App.tsx+1 −1
@@ -890,7 +890,7 @@890890 // showing an empty vault (which looks like data loss).891891 if (!res.ok) {892892 if (893- res.error === "vault locked" ||893+ res.error === "vault_locked" ||894894 res.error === "SERVICE_WORKER_UNAVAILABLE" ||895895 (typeof res.error === "string" && res.error.includes("locked"))896896 ) {src/popup/views/Add.tsx+1 −1
@@ -256,7 +256,7 @@256256 const res = await sendMessage({ type: "ADD_ENTRY", payload });257257 const result = res as { ok: boolean; data?: VaultEntry; error?: string };258258 if (!result.ok) {259- if (result.error === "vault locked") {259+ if (result.error === "vault_locked") {260260 setError("vault is locked — tap the lock icon to re-enter your master password");261261 } else {262262 setError(result.error ?? "could not save entry");src/popup/views/Import.tsx+1 −1
@@ -857,7 +857,7 @@857857 return "Choose a master password for your new vault first.";858858 case "RESTORE_FAILED":859859 return "Restore failed. Your existing vault is unchanged.";860- case "vault locked":860+ case "vault_locked":861861 return "Peach is locked. Open Peach with your master password and try again.";862862 case "refuse to replace with empty entry set":863863 return "This PeachScript decoded to zero entries — refusing to wipe the vault.";src/popup/views/RecoveryWordEntry.tsx+1 −1
@@ -330,7 +330,7 @@330330 function getDisplayError(error: string | undefined, fallback: string): string {331331 if (!error) return fallback;332332 switch (error) {333- case "no vault":333+ case "no_vault":334334 return "no vault found";335335 case "recovery_not_setup":336336 return "recovery not set up for this vault";src/shared/types.ts+39 −0
@@ -456,6 +456,45 @@456456 payload?: unknown;457457 }458458 459+// Canonical snake_case error codes returned by message handlers. Keep this460+// union the authoritative catalogue — new handlers must reuse an entry or461+// add one here. Free-form strings still appear (network/operation failures462+// surface their raw .message), so error stays string at runtime; the union463+// is the documented set callers should switch on.464+export type ErrorCode =465+ | "vault_locked"466+ | "vault_corrupted"467+ | "no_vault"468+ | "entry_not_found"469+ | "invalid_payload"470+ | "invalid_message"471+ | "invalid_sender"472+ | "wrong_passphrase"473+ | "incorrect_current_password"474+ | "recovery_not_setup"475+ | "android_only"476+ | "not_available"477+ | "pro_required"478+ | "pairing_not_verified"479+ | "sync_rejected_unpaired_initiator"480+ | "vault_not_applied"481+ | "url_required"482+ | "domain_required"483+ | "rpid_required"484+ | "rpid_and_username_required"485+ | "publickeyhex_required"486+ | "missing_payload"487+ | "login_entries_only"488+ | "passkey_not_fillable"489+ | "invalid_webhook_url"490+ | "invalid_feedback_endpoint"491+ | "message_empty"492+ | "network_error"493+ | "no_reset_url"494+ | "failed_to_open_tab"495+ | "operation_failed"496+ | "unknown_message_type";497+459498 export interface MessageResponse {460499 ok: boolean;461500 data?: unknown;