51b129be8db6
feat: Incoming pairing request overlay banner with 60s expiry (v2.13.29)
- time
- 2026-04-30 15:13 UTC · 6 months ago
- machine
- koh2
- parent
- f400fcc5c4c3
↓ peach-browser-51b129be.tar.gzatlas at this save
files changed10 files
manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/messages.test.ts+67 −0src/background/messages.ts+47 −0src/background/sync-storage.ts+39 −1src/popup/App.tsx+73 −0src/popup/styles/popup.css+56 −0src/shared/sync-types.ts+15 −0src/shared/types.ts+3 −0manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22 "manifest_version": 3,33 "name": "Peach",4- "version": "2.13.28",4+ "version": "2.13.29",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": "2.13.28",4+ "version": "2.13.29",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": "2.13.28",3+ "version": "2.13.29",44 "description": "Zero-knowledge password manager",55 "type": "module",66 "scripts": {src/background/messages.test.ts+67 −0
@@ -12,6 +12,7 @@1212 1313 describe("background messages", () => {1414 let storage: StorageShape;15+ let sessionStorage: StorageShape;1516 let listener: MessageListener;1617 let fetchMock: ReturnType<typeof vi.fn>;1718 @@ -21,6 +22,7 @@2122 storage = {2223 peach_settings: { breachMonitoring: false },2324 };25+ sessionStorage = {};2426 2527 fetchMock = vi.fn(async () => ({2628 ok: true,@@ -74,6 +76,30 @@7476 }7577 }),7678 },79+ session: {80+ get: vi.fn(async (key?: string | string[]) => {81+ if (!key) return sessionStorage;82+ if (typeof key === "string") {83+ return { [key]: sessionStorage[key] };84+ }85+ return key.reduce<Record<string, unknown>>((result, entry) => {86+ result[entry] = sessionStorage[entry];87+ return result;88+ }, {});89+ }),90+ set: vi.fn(async (next: Record<string, unknown>) => {91+ Object.assign(sessionStorage, next);92+ }),93+ remove: vi.fn(async (keys: string | string[]) => {94+ const list = Array.isArray(keys) ? keys : [keys];95+ for (const key of list) {96+ delete sessionStorage[key];97+ }98+ }),99+ clear: vi.fn(async () => {100+ for (const key of Object.keys(sessionStorage)) delete sessionStorage[key];101+ }),102+ },77103 },78104 alarms: {79105 clear: vi.fn(),@@ -669,6 +695,47 @@669695 const hits = data.filter((d) => d.value === "[email protected]");670696 expect(hits).toHaveLength(1);671697 expect(hits[0]!.useCount).toBe(3);698+ });699+700+ it("DEBUG_INJECT_INCOMING_PAIRING + GET returns the request and 60s expiry window", async () => {701+ const inject = await dispatch({702+ type: "DEBUG_INJECT_INCOMING_PAIRING",703+ payload: { deviceName: "iPhone", publicKeyHex: "deadbeef" },704+ });705+ expect(inject.ok).toBe(true);706+707+ const get = await dispatch({ type: "GET_INCOMING_PAIRING_REQUEST" });708+ expect(get.ok).toBe(true);709+ const data = (get as { data?: { deviceName?: string; expiresAt?: number; receivedAt?: number } }).data;710+ expect(data?.deviceName).toBe("iPhone");711+ expect((data?.expiresAt ?? 0) - (data?.receivedAt ?? 0)).toBe(60_000);712+ });713+714+ it("RESPOND_INCOMING_PAIRING accept clears the request and reports the device name", async () => {715+ await dispatch({716+ type: "DEBUG_INJECT_INCOMING_PAIRING",717+ payload: { deviceName: "MacBook", publicKeyHex: "cafe" },718+ });719+ const respond = await dispatch({720+ type: "RESPOND_INCOMING_PAIRING",721+ payload: { accept: true },722+ });723+ expect(respond.ok).toBe(true);724+ expect((respond as { data?: { accepted?: boolean; deviceName?: string } }).data).toEqual({725+ accepted: true,726+ deviceName: "MacBook",727+ });728+ const after = await dispatch({ type: "GET_INCOMING_PAIRING_REQUEST" });729+ expect((after as { data?: unknown }).data).toBeNull();730+ });731+732+ it("RESPOND_INCOMING_PAIRING with no pending request returns no_pending_request", async () => {733+ const res = await dispatch({734+ type: "RESPOND_INCOMING_PAIRING",735+ payload: { accept: false },736+ });737+ expect(res.ok).toBe(false);738+ expect((res as { error?: string }).error).toBe("no_pending_request");672739 });673740 674741 it("RESET_SYNC_IDENTITY is Pro-gated and rejects free users", async () => {src/background/messages.ts+47 −0
@@ -618,6 +618,53 @@618618 });619619 return { ok: true };620620 }621+ case "GET_INCOMING_PAIRING_REQUEST": {622+ const { getIncomingPairingRequest } = await import('./sync-storage');623+ const request = await getIncomingPairingRequest();624+ return { ok: true, data: request };625+ }626+ case "RESPOND_INCOMING_PAIRING": {627+ const { accept } = msg.payload as { accept: boolean };628+ const { getIncomingPairingRequest, clearIncomingPairingRequest } = await import('./sync-storage');629+ const request = await getIncomingPairingRequest();630+ if (!request) return { ok: false, error: "no_pending_request" };631+ await clearIncomingPairingRequest();632+ if (accept) {633+ // Trust-store wiring lives in src/background/sync.ts (`trustDevice`).634+ // It is dormant until the native messaging host installer (Phase 9)635+ // ships and runtime trigger paths come online; the user-side accept636+ // here clears the banner and records intent for Phase 10 E2E.637+ try {638+ const { trustDevice } = await import('./sync');639+ await trustDevice(request.publicKeyHex, request.deviceName);640+ } catch {641+ // Native host not installed yet — accept still succeeds locally.642+ }643+ }644+ return { ok: true, data: { accepted: accept, deviceName: request.deviceName } };645+ }646+ case "DEBUG_INJECT_INCOMING_PAIRING": {647+ // Manual E2E rehearsal hook. Inserts a synthetic incoming pairing648+ // request into session storage so Phase 7 banner UX can be exercised649+ // without a live native host. No Pro gate — this is a no-op in650+ // production unless explicitly invoked from a Settings debug action.651+ const { deviceName, publicKeyHex } = (msg.payload ?? {}) as {652+ deviceName?: string;653+ publicKeyHex?: string;654+ };655+ if (!deviceName || !publicKeyHex) {656+ return { ok: false, error: "missing_payload" };657+ }658+ const { setIncomingPairingRequest } = await import('./sync-storage');659+ const now = Date.now();660+ await setIncomingPairingRequest({661+ deviceName,662+ publicKeyHex,663+ receivedAt: now,664+ expiresAt: now + 60_000,665+ });666+ return { ok: true };667+ }621668 case "RESET_SYNC_IDENTITY": {622669 const gate = await requirePro("sync");623670 if (!gate.allowed) return { ok: false, error: "pro_required" };src/background/sync-storage.ts+39 −1
@@ -1,7 +1,8 @@1-import type { TrustedNetwork, PairedDevice } from '../shared/sync-types';1+import type { TrustedNetwork, PairedDevice, IncomingPairingRequest } from '../shared/sync-types';22 33 const TRUSTED_NETWORKS_KEY = 'peach_trusted_networks';44 const PAIRED_DEVICES_KEY = 'peach_paired_devices';5+const INCOMING_PAIR_KEY = 'peach_incoming_pair';56 67 // ---------------------------------------------------------------------------78 // Trusted Networks@@ -118,5 +119,42 @@118119 return devices.find((d) => d.deviceId === deviceId);119120 } catch {120121 return undefined;122+ }123+}124+125+// ---------------------------------------------------------------------------126+// Incoming Pairing Request127+//128+// Stored in session storage so the request disappears when the browser closes129+// and never lands on disk. The 60-second expiry is enforced by the consumer130+// (`getIncomingPairingRequest` returns null past expiry and clears storage).131+// ---------------------------------------------------------------------------132+133+export async function setIncomingPairingRequest(134+ request: IncomingPairingRequest135+): Promise<void> {136+ await chrome.storage.session.set({ [INCOMING_PAIR_KEY]: request });137+}138+139+export async function getIncomingPairingRequest(): Promise<IncomingPairingRequest | null> {140+ try {141+ const result = await chrome.storage.session.get(INCOMING_PAIR_KEY);142+ const request = (result as { peach_incoming_pair?: IncomingPairingRequest })[INCOMING_PAIR_KEY];143+ if (!request) return null;144+ if (Date.now() >= request.expiresAt) {145+ await clearIncomingPairingRequest();146+ return null;147+ }148+ return request;149+ } catch {150+ return null;151+ }152+}153+154+export async function clearIncomingPairingRequest(): Promise<void> {155+ try {156+ await chrome.storage.session.remove(INCOMING_PAIR_KEY);157+ } catch {158+ // session storage may be unavailable in some contexts; safe to ignore.121159 }122160 }src/popup/App.tsx+73 −0
@@ -8,6 +8,7 @@88 import { clearInMemoryVault, publishInMemoryVault } from "../shared/in-memory-vault";99 import { ACCENT_COLORS } from "../shared/types";1010 import type { AccentColorName, PendingPasskeyRequest, ProFeature, VaultEntry } from "../shared/types";11+import type { IncomingPairingRequest } from "../shared/sync-types";1112 import { isPasskeyOnly } from "../shared/types";1213 import { LockView } from "./views/Lock";1314 import { Onboarding } from "./views/Onboarding";@@ -163,6 +164,15 @@163164 password: string;164165 } | null>(null);165166 const [backExitNotice, setBackExitNotice] = createSignal(false);167+ const [incomingPair, setIncomingPair] = createSignal<IncomingPairingRequest | null>(null);168+ const [incomingPairResponding, setIncomingPairResponding] = createSignal(false);169+ const [incomingPairTick, setIncomingPairTick] = createSignal(0);170+ const incomingPairSecondsLeft = () => {171+ void incomingPairTick();172+ const req = incomingPair();173+ if (!req) return 0;174+ return Math.max(0, Math.ceil((req.expiresAt - Date.now()) / 1000));175+ };166176 // True while Panic Step 1 has bounced the user out to Export. When Export's167177 // onBack fires we return to Panic instead of Settings so the user keeps the168178 // destruction context they came from.@@ -447,6 +457,7 @@447457 }448458 449459 await refreshPendingPasskey();460+ await refreshIncomingPairing();450461 451462 // Track tab changes so smart sort stays current452463 if (!Capacitor.isNativePlatform()) {@@ -537,6 +548,9 @@537548 setSettingsInitialSection("license");538549 setView("settings");539550 }551+ if (msg?.type === "INCOMING_PAIRING_REQUEST") {552+ void refreshIncomingPairing();553+ }540554 };541555 chrome.runtime.onMessage.addListener(handleRuntimeMessage);542556 onCleanup(() => chrome.runtime.onMessage.removeListener(handleRuntimeMessage));@@ -680,6 +694,36 @@680694 setPendingPasskey((res as { ok: boolean; data?: PendingPasskeyRequest | null }).data ?? null);681695 }682696 697+ async function refreshIncomingPairing() {698+ const res = await sendMessage({ type: "GET_INCOMING_PAIRING_REQUEST" });699+ const data = (res as { ok: boolean; data?: IncomingPairingRequest | null }).data ?? null;700+ setIncomingPair(data);701+ }702+703+ async function respondIncomingPairing(accept: boolean) {704+ if (incomingPairResponding()) return;705+ setIncomingPairResponding(true);706+ await sendMessage({ type: "RESPOND_INCOMING_PAIRING", payload: { accept } });707+ setIncomingPair(null);708+ setIncomingPairResponding(false);709+ }710+711+ // 60-second expiry ticker for the incoming pairing banner. Re-runs when a712+ // request appears, ticks once per second, and clears the banner if the713+ // request expires while the popup is open.714+ createEffect(() => {715+ const req = incomingPair();716+ if (!req) return;717+ const handle = window.setInterval(() => {718+ setIncomingPairTick((tick) => tick + 1);719+ if (Date.now() >= req.expiresAt) {720+ setIncomingPair(null);721+ window.clearInterval(handle);722+ }723+ }, 1000);724+ onCleanup(() => window.clearInterval(handle));725+ });726+683727 async function refreshProStatus() {684728 const res = await sendMessage({ type: "GET_LICENSE" });685729 const data = (res as { ok: boolean; data?: { valid?: boolean; plan?: string } }).data;@@ -1073,6 +1117,35 @@10731117 </button>10741118 </div>10751119 </header>1120+1121+ <Show when={incomingPair() !== null}>1122+ <div class="peach-pair-request-banner" role="dialog" aria-label="Pairing request">1123+ <div class="peach-pair-request-header">1124+ <span class="peach-pair-request-mark">🍑</span>1125+ <span class="peach-pair-request-title">Pairing request</span>1126+ <span class="peach-pair-request-countdown">{incomingPairSecondsLeft()}s</span>1127+ </div>1128+ <p class="peach-pair-request-body">1129+ <strong>{incomingPair()!.deviceName}</strong> wants to sync with this Peach.1130+ </p>1131+ <div class="peach-pair-request-actions">1132+ <button1133+ class="peach-btn peach-btn--ghost peach-btn--sm"1134+ onClick={() => void respondIncomingPairing(false)}1135+ disabled={incomingPairResponding()}1136+ >1137+ Reject1138+ </button>1139+ <button1140+ class="peach-btn peach-btn--accent peach-btn--sm"1141+ onClick={() => void respondIncomingPairing(true)}1142+ disabled={incomingPairResponding()}1143+ >1144+ Accept1145+ </button>1146+ </div>1147+ </div>1148+ </Show>10761149 10771150 <Show when={pendingProNotice() !== null}>10781151 <div class="peach-pro-notice-banner">src/popup/styles/popup.css+56 −0
@@ -6122,6 +6122,62 @@61226122 line-height: 1;61236123 }61246124 6125+/* ── Incoming pairing request overlay banner (Phase 7) ──────────────────6126+ Renders over whatever view is showing. 60-second expiry; the popup ticks6127+ the countdown each second and clears the banner when it hits zero. */6128+.peach-pair-request-banner {6129+ display: flex;6130+ flex-direction: column;6131+ gap: 8px;6132+ padding: 10px 12px;6133+ background: color-mix(in srgb, var(--peach-accent) 14%, var(--peach-bg-card));6134+ border-bottom: 1px solid color-mix(in srgb, var(--peach-accent) 32%, transparent);6135+ font-family: "IBM Plex Mono", monospace;6136+ flex-shrink: 0;6137+}6138+6139+.peach-pair-request-header {6140+ display: flex;6141+ align-items: center;6142+ gap: 8px;6143+ font-size: 12px;6144+ color: var(--peach-t1);6145+}6146+6147+.peach-pair-request-mark {6148+ font-size: 14px;6149+}6150+6151+.peach-pair-request-title {6152+ font-weight: 600;6153+ color: var(--peach-t0);6154+}6155+6156+.peach-pair-request-countdown {6157+ margin-left: auto;6158+ font-variant-numeric: tabular-nums;6159+ color: var(--peach-t2);6160+ font-size: 11px;6161+}6162+6163+.peach-pair-request-body {6164+ margin: 0;6165+ font-size: 12px;6166+ color: var(--peach-t1);6167+ line-height: 1.4;6168+}6169+6170+.peach-pair-request-body strong {6171+ color: var(--peach-t0);6172+ font-weight: 600;6173+}6174+6175+.peach-pair-request-actions {6176+ display: flex;6177+ gap: 8px;6178+ justify-content: flex-end;6179+}6180+61256181 /* ─── First-run onboarding ──────────────────────────────────────────────61266182 Three-card welcome shown once before vault creation. The PeachScript61276183 rune background drifts behind everything at very low opacity — it'ssrc/shared/sync-types.ts+15 −0
@@ -71,3 +71,18 @@7171 receiveNonce: number;7272 isInitiator: boolean;7373 }74+75+/**76+ * Pairing request received from a peer that needs user confirmation.77+ *78+ * The banner expires after 60 seconds (`expiresAt - receivedAt = 60_000`);79+ * after that the popup hides the banner and the background drops the80+ * request. The publicKeyHex is the peer's static X25519 identity — it81+ * gets written to trusted devices on accept and discarded on reject.82+ */83+export interface IncomingPairingRequest {84+ deviceName: string;85+ publicKeyHex: string;86+ receivedAt: number;87+ expiresAt: number;88+}src/shared/types.ts+3 −0
@@ -247,6 +247,9 @@247247 | "REMOVE_TRUSTED_NETWORK"248248 | "MARK_NETWORK_TRUSTED"249249 | "RESET_SYNC_IDENTITY"250+ | "GET_INCOMING_PAIRING_REQUEST"251+ | "RESPOND_INCOMING_PAIRING"252+ | "DEBUG_INJECT_INCOMING_PAIRING"250253 | "GET_PENDING_PASSKEY"251254 | "APPROVE_PASSKEY"252255 | "DENY_PASSKEY"