228 lines
8.7 KiB
TypeScript
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);
|
|
});
|