work in progress
This commit is contained in:
@@ -0,0 +1,198 @@
|
||||
// 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 { 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 });
|
||||
respond(port, { type: 'verification', status: 'valid', features: result.features });
|
||||
}
|
||||
|
||||
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));
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 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);
|
||||
|
||||
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);
|
||||
});
|
||||
Reference in New Issue
Block a user