feat(ext/sw): collapse flat index onto router

This commit is contained in:
adlee-was-taken
2026-04-20 20:11:59 -04:00
parent 56ab58cbe9
commit 2d4dcb5f6b

View File

@@ -1,18 +1,10 @@
/// Background script entry point for the relicario browser extension. /// Thin service-worker entry: loads WASM, constructs the router state, and
/// /// forwards every message into router/index.route().
/// Transitional slice-3 shape: keeps the flat onMessage listener but uses
/// the new typed-item vault + SessionHandle. The router split lands in
/// slice 4.
import type { Request, Response } from '../shared/messages'; import type { Request, Response } from '../shared/messages';
import type { Item, ItemId, Manifest, VaultConfig, SetupState, DeviceSettings } from '../shared/types'; import type { RouterState } from './router/index';
import { DEFAULT_DEVICE_SETTINGS } from '../shared/types'; import { route } from './router/index';
import type { GitHost } from './git-host';
import { createGitHost, base64ToUint8Array } from './git-host';
import * as vault from './vault'; import * as vault from './vault';
import * as session from './session';
// --- WASM module load ---
// @ts-ignore TS2307 — resolved by webpack alias / copy // @ts-ignore TS2307 — resolved by webpack alias / copy
import initDefault, { initSync } from '../../wasm/relicario_wasm.js'; import initDefault, { initSync } from '../../wasm/relicario_wasm.js';
@@ -44,247 +36,21 @@ async function initWasm(): Promise<WasmModule> {
return wasm; return wasm;
} }
// --- In-memory vault state (cleared on lock or SW restart) --- // Single router-state object shared by all messages for this SW instance.
const state: RouterState = {
let manifest: Manifest | null = null; manifest: null,
let gitHost: GitHost | null = null; gitHost: null,
const totpConfigCache: Map<ItemId, unknown> = new Map(); wasm: null,
};
// --- chrome.storage.local helpers ---
async function loadConfig(): Promise<VaultConfig | null> {
const result = await chrome.storage.local.get('vaultConfig');
return (result.vaultConfig as VaultConfig) ?? null;
}
async function loadImageBase64(): Promise<string | null> {
const result = await chrome.storage.local.get('imageBase64');
return (result.imageBase64 as string) ?? null;
}
async function loadSetupState(): Promise<SetupState> {
const config = await loadConfig();
const imageBase64 = await loadImageBase64();
return { config, imageBase64, isConfigured: config !== null && imageBase64 !== null };
}
async function loadSettings(): Promise<DeviceSettings> {
const result = await chrome.storage.local.get('relicarioSettings');
return (result.relicarioSettings as DeviceSettings) ?? { ...DEFAULT_DEVICE_SETTINGS };
}
async function saveSettings(settings: DeviceSettings): Promise<void> {
await chrome.storage.local.set({ relicarioSettings: settings });
}
async function loadBlacklist(): Promise<string[]> {
const result = await chrome.storage.local.get('captureBlacklist');
return (result.captureBlacklist as string[]) ?? [];
}
async function saveBlacklist(list: string[]): Promise<void> {
await chrome.storage.local.set({ captureBlacklist: list });
}
function ensureGitHost(config: VaultConfig): GitHost {
if (!gitHost) {
gitHost = createGitHost(config.hostType, config.hostUrl, config.repoPath, config.apiToken);
}
return gitHost;
}
// --- Message handler (flat; router split in slice 4) ---
chrome.runtime.onMessage.addListener( chrome.runtime.onMessage.addListener(
(request: Request, _sender: chrome.runtime.MessageSender, sendResponse: (response: Response) => void) => { (request: Request, sender: chrome.runtime.MessageSender, sendResponse: (r: Response) => void) => {
handleMessage(request) (async () => {
if (!state.wasm) state.wasm = await initWasm();
return route(request, state, sender);
})()
.then(sendResponse) .then(sendResponse)
.catch((err: Error) => sendResponse({ ok: false, error: err.message })); .catch((err: Error) => sendResponse({ ok: false, error: err.message }));
return true; return true; // async response
}, },
); );
async function handleMessage(req: Request): Promise<Response> {
switch (req.type) {
case 'is_unlocked':
return { ok: true, data: { unlocked: session.getCurrent() !== null } };
case 'unlock': {
const w = await initWasm();
const config = await loadConfig();
if (!config) return { ok: false, error: 'Extension not configured. Run setup first.' };
const imageB64 = await loadImageBase64();
if (!imageB64) return { ok: false, error: 'Reference image not set. Run setup first.' };
const imageBytes = base64ToUint8Array(imageB64);
const git = ensureGitHost(config);
const meta = await vault.fetchVaultMeta(git);
const handle = w.unlock(req.passphrase, imageBytes, meta.salt, meta.paramsJson);
session.setCurrent(handle);
// Best-effort scope clearing. JS strings are immutable so this only
// nulls the one reference on `req`; wasm-bindgen already copied the
// string into WASM linear memory before `unlock` returned. Left in
// as a marker — future work may consider a direct-stream KDF that
// never materializes the string, or a `Zeroizing` wrapper on the
// WASM-side incoming buffer.
(req as { passphrase: string }).passphrase = '';
manifest = await vault.fetchAndDecryptManifest(git, handle);
return { ok: true };
}
case 'lock':
session.clearCurrent();
manifest = null;
totpConfigCache.clear();
return { ok: true };
case 'list_items': {
if (!manifest) return { ok: false, error: 'vault_locked' };
const items = vault.listItems(manifest, req.group);
return { ok: true, data: { items } };
}
case 'get_item': {
const handle = session.getCurrent();
if (!handle || !gitHost) return { ok: false, error: 'vault_locked' };
const item = await vault.fetchAndDecryptItem(gitHost, handle, req.id);
return { ok: true, data: { item } };
}
case 'add_item': {
const handle = session.getCurrent();
if (!handle || !gitHost || !manifest) return { ok: false, error: 'vault_locked' };
const w = await initWasm();
const id = w.new_item_id();
const item: Item = { ...req.item, id };
await vault.encryptAndWriteItem(gitHost, handle, id, item, `add: ${item.title}`);
manifest.items[id] = itemToManifestEntry(item);
await vault.encryptAndWriteManifest(gitHost, handle, manifest, `manifest: add ${item.title}`);
return { ok: true, data: { id } };
}
case 'update_item': {
const handle = session.getCurrent();
if (!handle || !gitHost || !manifest) return { ok: false, error: 'vault_locked' };
await vault.encryptAndWriteItem(gitHost, handle, req.id, req.item, `update: ${req.item.title}`);
manifest.items[req.id] = itemToManifestEntry(req.item);
await vault.encryptAndWriteManifest(gitHost, handle, manifest, `manifest: update ${req.item.title}`);
totpConfigCache.delete(req.id);
return { ok: true };
}
case 'delete_item': {
const handle = session.getCurrent();
if (!handle || !gitHost || !manifest) return { ok: false, error: 'vault_locked' };
const entry = manifest.items[req.id];
if (!entry) return { ok: false, error: 'item_not_found' };
// Soft-delete: fetch the item, set trashed_at, write it back.
// TODO(slice-4): not atomic across the two git writes. If the manifest
// write fails after the item write, next sync restores the live manifest
// and the trashed item re-appears. Consider manifest-first write order
// or a retry/rollback pass when router-splitting this handler.
const item = await vault.fetchAndDecryptItem(gitHost, handle, req.id);
const now = Math.floor(Date.now() / 1000);
const updated: Item = { ...item, trashed_at: now, modified: now };
await vault.encryptAndWriteItem(gitHost, handle, req.id, updated, `trash: ${entry.title}`);
manifest.items[req.id] = { ...entry, trashed_at: now, modified: now };
await vault.encryptAndWriteManifest(gitHost, handle, manifest, `manifest: trash ${entry.title}`);
return { ok: true };
}
case 'sync': {
const handle = session.getCurrent();
if (!handle || !gitHost) return { ok: false, error: 'vault_locked' };
manifest = await vault.fetchAndDecryptManifest(gitHost, handle);
return { ok: true };
}
case 'get_setup_state': {
return { ok: true, data: await loadSetupState() };
}
case 'save_setup': {
await chrome.storage.local.set({
vaultConfig: req.config,
imageBase64: req.imageBase64,
});
gitHost = null;
return { ok: true };
}
case 'rate_passphrase': {
const w = await initWasm();
return { ok: true, data: w.rate_passphrase(req.passphrase) };
}
case 'generate_password': {
const w = await initWasm();
const password = w.generate_password(JSON.stringify(req.request));
return { ok: true, data: { password } };
}
case 'get_settings':
return { ok: true, data: { settings: await loadSettings() } };
case 'update_settings': {
const current = await loadSettings();
await saveSettings({ ...current, ...req.settings });
return { ok: true };
}
case 'get_blacklist':
return { ok: true, data: { blacklist: await loadBlacklist() } };
case 'remove_blacklist': {
const bl = await loadBlacklist();
await saveBlacklist(bl.filter((h) => h !== req.hostname));
return { ok: true };
}
// Slice 4 / 5 will wire these up properly (currently placeholders so the build passes):
case 'get_totp':
case 'fill_credentials':
case 'ack_autofill_origin':
case 'get_autofill_candidates':
case 'get_credentials':
case 'check_credential':
case 'blacklist_site':
return { ok: false, error: 'not_implemented_yet' };
default: {
const exhaustive: never = req;
return { ok: false, error: `unknown_message_type: ${(exhaustive as { type: string }).type}` };
}
}
}
/// TS mirror of the Rust core's `ManifestEntry::from_item`
/// (see crates/relicario-core/src/manifest.rs). These two derivations must
/// stay in sync — if the Rust side learns to handle e.g. trailing-whitespace
/// URL parsing differently, this function drifts and the manifest diverges
/// between CLI-written and extension-written items. A future WASM helper
/// `item_to_manifest_entry(handle, item_json)` would eliminate the duplication.
function itemToManifestEntry(item: Item) {
return {
id: item.id,
type: item.type,
title: item.title,
tags: item.tags,
favorite: item.favorite,
group: item.group,
icon_hint: (item.core.type === 'login' && item.core.url)
? safeHostname(item.core.url) : undefined,
modified: item.modified,
trashed_at: item.trashed_at,
attachment_summaries: item.attachments.map((a) => ({
id: a.id, filename: a.filename, mime_type: a.mime_type, size: a.size,
})),
};
}
function safeHostname(url: string): string | undefined {
try { return new URL(url).hostname; } catch { return undefined; }
}