Files
locqr/extension/src/background/index.ts
T

228 lines
8.7 KiB
TypeScript

// 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<Exclude<CertInvalidReason, 'domain_mismatch' | 'signature_invalid' | 'malformed'>, 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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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);
});