// LOCQR extension — background service worker. // // Covers both the verification-state machine (extension/claude.md: cert // check, idle/error popup states) and, as of this slice, the run flow // (idle -> phase_1..4 -> delivered) defined in run.ts. This file wires // external SDK Port messages and internal popup actions to those two // state machines; the actual run logic lives in run.ts. import { queryDomainStatus, reportSecurityError } from './api.js'; import { abortRun, cleanupRun, confirmPin, dismissRunError, startRun } from './run.js'; import { clearSessionActive, getSessionActive, setSessionActive } from './session-status.js'; import { registerSdkPort, unregisterSdkPort } from './sdk-ports.js'; import { clearTabState, getTabState, setTabState } from './state.js'; import { verifyRegistrationCert } from './verify.js'; import type { CertInvalidReason, PopupActionMessage, SdkMessage, TabState, VerificationMessage } from '../types.js'; /** Every state past initial verification carries the origin/features an active or just-ended run needs. */ function originAndFeatures(state: TabState): { origin: string; features: string[] } | undefined { switch (state.kind) { case 'unverified': case 'not_registered': case 'cert_invalid': case 'account_error': return undefined; default: return { origin: state.origin, features: state.features }; } } const OPERATIONAL_ERROR_TO_REASON: Record, string> = { insecure_origin: 'insecure_origin', expired: 'expired', not_yet_valid: 'not_yet_valid', }; const SECURITY_ERROR_TYPE: Record<'domain_mismatch' | 'signature_invalid' | 'malformed', string> = { domain_mismatch: 'domain_mismatch', signature_invalid: 'cert_signature_invalid', malformed: 'cert_malformed', }; function tabIdOf(port: chrome.runtime.Port): number | undefined { return port.sender?.tab?.id; } async function handleInit(port: chrome.runtime.Port, tabId: number, originUrl: string, cert: string | undefined): Promise { if (!cert) { await setTabState(tabId, { kind: 'not_registered' }); respond(port, { type: 'verification', status: 'error', error: { code: 'NOT_REGISTERED' } }); return; } const result = verifyRegistrationCert(cert, originUrl); if (!result.ok) { const state: TabState = { kind: 'cert_invalid', reason: result.reason, security: result.security }; await setTabState(tabId, state); if (result.security) { const errorType = SECURITY_ERROR_TYPE[result.reason as keyof typeof SECURITY_ERROR_TYPE]; await reportSecurityError(errorType); } respond(port, { type: 'verification', status: 'error', error: { code: 'CERT_INVALID', reason: result.reason, security: result.security }, }); return; } const domainStatus = await queryDomainStatus(result.domain); if (!domainStatus.reachable) { await setTabState(tabId, { kind: 'account_error', reason: 'unreachable' }); respond(port, { type: 'verification', status: 'error', error: { code: 'ACCOUNT_ERROR', reason: 'unreachable' }, }); return; } if (domainStatus.status !== 'valid') { await setTabState(tabId, { kind: 'account_error', reason: 'rejected' }); respond(port, { type: 'verification', status: 'error', error: { code: 'ACCOUNT_ERROR', reason: 'rejected' }, }); return; } await setTabState(tabId, { kind: 'idle', origin: result.domain, features: result.features, sessionActive: getSessionActive(tabId), }); respond(port, { type: 'verification', status: 'valid', features: result.features }); } /** * SDK `session_status` — advisory only (types.ts's SessionStatusMessage). * Remembered even outside `idle` (e.g. reported before verification * finishes, or during an unrelated run) so the *next* time `idle` is * entered it reflects the latest report, not just whichever one happened * to arrive while already idle. */ async function handleSessionStatus(tabId: number, active: boolean): Promise { setSessionActive(tabId, active); const state = await getTabState(tabId); if (state.kind === 'idle') { await setTabState(tabId, { ...state, sessionActive: active }); } } function respond(port: chrome.runtime.Port, message: VerificationMessage): void { try { port.postMessage(message); } catch { // Port may already be disconnected (page navigated away mid-verification). } } /** SDK `request_credential` — only valid from `idle` per claude.md's transition table. */ async function handleRequestCredential(tabId: number, windowId: number | undefined): Promise { const state = await getTabState(tabId); if (state.kind !== 'idle') return; // Nothing else makes the popup actually appear for an SDK-triggered run // (unlike the popup's own start_run action, where it's already open). // Best-effort: openPopup() can reject if the window isn't focused: the // run still proceeds either way, the user just has to open it manually. try { await chrome.action.openPopup(windowId === undefined ? undefined : { windowId }); } catch { // ignored — see above } await startRun(tabId, state.origin, state.features); } /** SDK `abort` — valid from any active run phase; a no-op outside one. */ async function handleAbort(tabId: number): Promise { const oaf = originAndFeatures(await getTabState(tabId)); if (!oaf) return; await abortRun(tabId, oaf.origin, oaf.features); } chrome.runtime.onConnectExternal.addListener((port) => { const tabId = tabIdOf(port); const originUrl = port.sender?.tab?.url; const windowId = port.sender?.tab?.windowId; if (tabId === undefined || !originUrl) { port.disconnect(); return; } registerSdkPort(tabId, port); port.onDisconnect.addListener(() => unregisterSdkPort(tabId, port)); // A fresh connection is a fresh page load — any session hint from a // previous page at this tab is stale until this page re-reports it. clearSessionActive(tabId); void setTabState(tabId, { kind: 'unverified' }); port.onMessage.addListener((raw: unknown) => { const message = raw as SdkMessage; if (message.type === 'init') { void handleInit(port, tabId, originUrl, message.cert); } else if (message.type === 'request_credential') { void handleRequestCredential(tabId, windowId); } else if (message.type === 'abort') { void handleAbort(tabId); } else if (message.type === 'session_status') { void handleSessionStatus(tabId, message.active); } }); }); /** * Internal popup actions (extension/claude.md's UI action column) — a * same-extension chrome.runtime.sendMessage channel, distinct from the * external SDK Port above. `retry` re-enters phase_1 from a dismissable * run_error (claude.md: security-class errors don't offer retry, but this * slice only wires user_abort, which does). */ chrome.runtime.onMessage.addListener((raw: unknown) => { const message = raw as PopupActionMessage; if (message.type !== 'popup_action') return; void handlePopupAction(message); }); async function handlePopupAction(message: PopupActionMessage): Promise { const { tabId, action } = message; const state = await getTabState(tabId); // Diagnostic: distinguishes "message never arrived" from "arrived but // no-opped because state didn't match" — both looked identical before. console.log(`[locqr] popup_action received: action=${action} tabId=${tabId} currentState=${state.kind}`); switch (action) { case 'start_run': if (state.kind === 'idle') await startRun(tabId, state.origin, state.features); return; case 'confirm_pin': if (state.kind === 'phase_3') await confirmPin(tabId, state.origin, state.features); return; case 'abort': { const oaf = originAndFeatures(state); if (oaf) await abortRun(tabId, oaf.origin, oaf.features); return; } case 'retry': if (state.kind === 'run_error') await startRun(tabId, state.origin, state.features); return; case 'dismiss': if (state.kind === 'run_error') await dismissRunError(tabId, state.origin, state.features); return; } } chrome.tabs.onRemoved.addListener((tabId) => { cleanupRun(tabId); void clearTabState(tabId); });