# Relicario Credential Capture Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Add experimental credential capture that detects login form submissions and prompts the user to save or update credentials, with configurable bar/toast prompt style and per-site blacklist. **Architecture:** Content script hooks form submissions, captures credentials, asks the service worker to check against the manifest, then injects a prompt (bar or toast) into the page. New settings view in the popup for configuration. Feature is off by default. **Tech Stack:** TypeScript, Chrome extension APIs, DOM injection **Spec:** `docs/superpowers/specs/2026-04-12-relicario-credential-capture-design.md` --- ## File Structure ### New files ``` extension/src/content/capture.ts # Form submission detection + prompt injection extension/src/popup/components/settings.ts # Settings view ``` ### Modified files ``` extension/src/shared/types.ts # Add RelicarioSettings interface extension/src/shared/messages.ts # Add new message types extension/src/service-worker/index.ts # Handle new messages extension/src/content/detector.ts # Import and init capture extension/src/popup/popup.ts # Add 'settings' view extension/src/popup/components/unlock.ts # Wire settings button to settings view ``` --- ## Task 1: Add Types and Message Definitions **Files:** - Modify: `extension/src/shared/types.ts` - Modify: `extension/src/shared/messages.ts` - [ ] **Step 1: Add RelicarioSettings to types.ts** Add at the end of `extension/src/shared/types.ts`: ```typescript export interface RelicarioSettings { captureEnabled: boolean; captureStyle: 'bar' | 'toast'; } export const DEFAULT_SETTINGS: RelicarioSettings = { captureEnabled: false, captureStyle: 'bar', }; ``` - [ ] **Step 2: Add new message types to messages.ts** Add these to the `Request` union in `extension/src/shared/messages.ts`: ```typescript | { type: 'check_credential'; url: string; username: string; password: string } | { type: 'blacklist_site'; hostname: string } | { type: 'get_settings' } | { type: 'update_settings'; settings: Partial } | { type: 'get_blacklist' } | { type: 'remove_blacklist'; hostname: string } ``` - [ ] **Step 3: Commit** ```bash git add extension/src/shared/types.ts extension/src/shared/messages.ts git commit -m "feat: add settings and credential capture message types" ``` --- ## Task 2: Service Worker Message Handlers **Files:** - Modify: `extension/src/service-worker/index.ts` - [ ] **Step 1: Add settings and blacklist storage helpers** Add these helper functions to `extension/src/service-worker/index.ts`, after the existing storage helpers: ```typescript import type { RelicarioSettings } from '../shared/types'; import { DEFAULT_SETTINGS } from '../shared/types'; async function loadSettings(): Promise { const data = await chrome.storage.local.get(['settings']); if (!data.settings) return { ...DEFAULT_SETTINGS }; return { ...DEFAULT_SETTINGS, ...data.settings }; } async function saveSettings(settings: RelicarioSettings): Promise { await chrome.storage.local.set({ settings }); } async function loadBlacklist(): Promise { const data = await chrome.storage.local.get(['captureBlacklist']); return data.captureBlacklist ?? []; } async function saveBlacklist(list: string[]): Promise { await chrome.storage.local.set({ captureBlacklist: list }); } ``` - [ ] **Step 2: Add message handlers** Add these cases to the `switch` statement in the message handler, before the `default` case: ```typescript case 'get_settings': { const settings = await loadSettings(); return { ok: true, data: settings }; } case 'update_settings': { const current = await loadSettings(); const updated = { ...current, ...req.settings }; await saveSettings(updated); return { ok: true }; } case 'get_blacklist': { const list = await loadBlacklist(); return { ok: true, data: { blacklist: list } }; } case 'remove_blacklist': { const list = await loadBlacklist(); await saveBlacklist(list.filter(h => h !== req.hostname)); return { ok: true }; } case 'blacklist_site': { const list = await loadBlacklist(); if (!list.includes(req.hostname)) { list.push(req.hostname); await saveBlacklist(list); } return { ok: true }; } case 'check_credential': { // If vault is locked, skip if (!masterKey || !gitHost || !manifest) { return { ok: true, data: { action: 'skip' } }; } // Check settings const settings = await loadSettings(); if (!settings.captureEnabled) { return { ok: true, data: { action: 'skip' } }; } // Check blacklist let hostname: string; try { hostname = new URL(req.url).hostname; } catch { return { ok: true, data: { action: 'skip' } }; } const blacklist = await loadBlacklist(); if (blacklist.includes(hostname)) { return { ok: true, data: { action: 'skip' } }; } // Find matching entries by hostname const matches = vault.findByUrl(manifest, req.url); if (matches.length === 0) { return { ok: true, data: { action: 'save' } }; } // Check if any match has the same username for (const [id, entry] of matches) { if (entry.username === req.username) { // Same username — check if password changed const fullEntry = await vault.fetchAndDecryptEntry(gitHost, masterKey, id); if (fullEntry.password === req.password) { // Exact match, already saved return { ok: true, data: { action: 'skip' } }; } else { // Password changed return { ok: true, data: { action: 'update', entryId: id, entryName: entry.name } }; } } } // Different username on same site — new account return { ok: true, data: { action: 'save' } }; } ``` - [ ] **Step 3: Verify build** ```bash cd extension && bun run build ``` Expected: Compiles with no errors. - [ ] **Step 4: Commit** ```bash git add extension/src/service-worker/index.ts git commit -m "feat: add settings, blacklist, and credential check handlers" ``` --- ## Task 3: Credential Capture Content Script **Files:** - Create: `extension/src/content/capture.ts` - Modify: `extension/src/content/detector.ts` - [ ] **Step 1: Create capture.ts** Create `extension/src/content/capture.ts`: ```typescript /// Credential capture — detects login form submissions and prompts /// the user to save or update credentials in the vault. /// /// This module hooks form submit events and submit button clicks, /// captures username + password, asks the service worker whether to /// save/update/skip, and injects a prompt (bar or toast) into the page. // --- Types --- interface CaptureResult { action: 'save' | 'update' | 'skip'; entryId?: string; entryName?: string; } type PromptStyle = 'bar' | 'toast'; // Track forms we've already hooked to avoid duplicates. const hookedForms = new WeakSet(); const hookedButtons = new WeakSet(); // --- Form Submission Detection --- /// Find the username field associated with a password field. /// Same priority as detector.ts but inlined to avoid circular deps. function findUsername(pwField: HTMLInputElement): string { const form = pwField.closest('form'); const scope = form ?? document; const inputs = scope.querySelectorAll('input'); // autocomplete="username" for (const input of inputs) { if (input !== pwField && input.autocomplete === 'username' && input.value) return input.value; } // autocomplete="email" for (const input of inputs) { if (input !== pwField && input.autocomplete === 'email' && input.value) return input.value; } // type="email" for (const input of inputs) { if (input !== pwField && input.type === 'email' && input.value) return input.value; } // name/id pattern const pattern = /user|email|login|account/i; for (const input of inputs) { if (input === pwField || input.type === 'hidden' || input.type === 'password') continue; if ((pattern.test(input.name) || pattern.test(input.id)) && input.value) return input.value; } // Nearest preceding visible text input const allInputs = Array.from(inputs); const pwIndex = allInputs.indexOf(pwField); for (let i = pwIndex - 1; i >= 0; i--) { const input = allInputs[i]; if (input.type === 'hidden' || input.type === 'password' || input.type === 'submit') continue; if (input.offsetWidth > 0 && input.offsetHeight > 0 && input.value) return input.value; } return ''; } /// Capture credentials from a form that contains a password field. function captureFromForm(form: HTMLFormElement): { username: string; password: string } | null { const pwField = form.querySelector('input[type="password"]'); if (!pwField || !pwField.value) return null; const username = findUsername(pwField); return { username, password: pwField.value }; } /// Handle a form submission — capture credentials and check with service worker. async function handleSubmission(form: HTMLFormElement): Promise { const creds = captureFromForm(form); if (!creds || !creds.password) return; const url = window.location.href; try { const response = await chrome.runtime.sendMessage({ type: 'check_credential', url, username: creds.username, password: creds.password, }); if (!response?.ok) return; const result = response.data as CaptureResult; if (result.action === 'skip') return; // Get the prompt style from settings const settingsResp = await chrome.runtime.sendMessage({ type: 'get_settings' }); const style: PromptStyle = settingsResp?.ok ? (settingsResp.data as { captureStyle: PromptStyle }).captureStyle : 'bar'; showPrompt(style, result, url, creds.username, creds.password); } catch { // Extension not available or vault locked — silently skip } } /// Hook form submit events and submit button clicks. export function hookForms(): void { const forms = document.querySelectorAll('form'); for (const form of forms) { // Only hook forms that contain a password field. if (!form.querySelector('input[type="password"]')) continue; if (hookedForms.has(form)) continue; hookedForms.add(form); // Hook form submit event. form.addEventListener('submit', () => { handleSubmission(form); }); // Hook submit button clicks (some sites don't use form submit). const submitBtns = form.querySelectorAll( 'button[type="submit"], input[type="submit"], button:not([type])' ); for (const btn of submitBtns) { if (hookedButtons.has(btn)) continue; hookedButtons.add(btn); btn.addEventListener('click', () => { handleSubmission(form); }); } } } // --- Prompt UI --- /// Remove any existing relicario prompt from the page. function removePrompt(): void { document.getElementById('relicario-capture-prompt')?.remove(); } /// Show a save/update prompt. function showPrompt( style: PromptStyle, result: CaptureResult, url: string, username: string, password: string, ): void { removePrompt(); let hostname: string; try { hostname = new URL(url).hostname; } catch { hostname = url; } const isUpdate = result.action === 'update'; const actionLabel = isUpdate ? 'Update' : 'Save'; const message = isUpdate ? `Update password for ${hostname}?` : `Save login for ${hostname}?`; const container = document.createElement('div'); container.id = 'relicario-capture-prompt'; // Common styles const baseStyles = ` font-family: 'JetBrains Mono', 'Cascadia Code', 'Fira Code', monospace; font-size: 13px; color: #c9d1d9; background: #161b22; border: 1px solid #30363d; z-index: 2147483647; box-sizing: border-box; `; if (style === 'bar') { container.style.cssText = ` ${baseStyles} position: fixed; top: 0; left: 0; right: 0; padding: 10px 16px; display: flex; align-items: center; gap: 12px; border-top: none; border-left: none; border-right: none; box-shadow: 0 2px 8px rgba(0,0,0,0.4); transform: translateY(-100%); transition: transform 0.2s ease-out; `; // Slide in after a frame requestAnimationFrame(() => { requestAnimationFrame(() => { container.style.transform = 'translateY(0)'; }); }); } else { container.style.cssText = ` ${baseStyles} position: fixed; bottom: 16px; right: 16px; padding: 12px 16px; border-radius: 4px; min-width: 260px; max-width: 340px; box-shadow: 0 4px 12px rgba(0,0,0,0.4); opacity: 0; transition: opacity 0.2s ease-out; `; requestAnimationFrame(() => { container.style.opacity = '1'; }); // Auto-dismiss after 15 seconds. setTimeout(() => { if (container.isConnected) { container.style.opacity = '0'; setTimeout(removePrompt, 200); } }, 15000); } // Brand label const brand = document.createElement('span'); brand.textContent = 'relicario'; brand.style.cssText = 'color: #58a6ff; font-weight: normal; letter-spacing: 1px;'; // Message text const msg = document.createElement('span'); msg.style.cssText = 'flex: 1;'; if (style === 'bar') { msg.textContent = `${message} ${username ? `(${username})` : ''}`; } else { msg.innerHTML = `${escapeHtml(message)}
${escapeHtml(username)}`; } // Buttons const btnStyle = ` font-family: inherit; font-size: 11px; padding: 4px 12px; border-radius: 2px; cursor: pointer; border: none; `; const saveBtn = document.createElement('button'); saveBtn.textContent = actionLabel; saveBtn.style.cssText = `${btnStyle} background: #1f6feb; color: #fff;`; const neverBtn = document.createElement('button'); neverBtn.textContent = 'Never'; neverBtn.style.cssText = `${btnStyle} background: #21262d; color: #8b949e; border: 1px solid #30363d;`; const closeBtn = document.createElement('button'); closeBtn.textContent = '✕'; closeBtn.style.cssText = `${btnStyle} background: transparent; color: #484f58; font-size: 14px; padding: 4px 8px;`; // Event handlers saveBtn.addEventListener('click', async () => { saveBtn.disabled = true; saveBtn.textContent = '...'; try { if (isUpdate && result.entryId) { // Fetch existing entry, update password const getResp = await chrome.runtime.sendMessage({ type: 'get_entry', id: result.entryId }); if (getResp?.ok) { const existing = (getResp.data as { entry: Record }).entry; await chrome.runtime.sendMessage({ type: 'update_entry', id: result.entryId, entry: { ...existing, password }, }); } } else { await chrome.runtime.sendMessage({ type: 'add_entry', entry: { name: hostname, url, username: username || undefined, password, created_at: new Date().toISOString(), updated_at: new Date().toISOString(), }, }); } // Brief confirmation msg.textContent = isUpdate ? '✓ Updated' : '✓ Saved'; saveBtn.remove(); neverBtn.remove(); setTimeout(removePrompt, 1500); } catch { saveBtn.textContent = 'Error'; setTimeout(removePrompt, 2000); } }); neverBtn.addEventListener('click', async () => { await chrome.runtime.sendMessage({ type: 'blacklist_site', hostname }); removePrompt(); }); closeBtn.addEventListener('click', removePrompt); // Assemble if (style === 'bar') { container.appendChild(brand); container.appendChild(msg); container.appendChild(saveBtn); container.appendChild(neverBtn); container.appendChild(closeBtn); } else { const header = document.createElement('div'); header.style.cssText = 'display:flex; justify-content:space-between; align-items:center; margin-bottom:8px;'; header.appendChild(brand); header.appendChild(closeBtn); const body = document.createElement('div'); body.style.cssText = 'margin-bottom:10px;'; body.appendChild(msg); const actions = document.createElement('div'); actions.style.cssText = 'display:flex; gap:8px; justify-content:flex-end;'; actions.appendChild(neverBtn); actions.appendChild(saveBtn); container.appendChild(header); container.appendChild(body); container.appendChild(actions); } document.body.appendChild(container); } function escapeHtml(s: string): string { const div = document.createElement('div'); div.textContent = s; return div.innerHTML; } ``` - [ ] **Step 2: Import and init capture in detector.ts** Add to the top of `extension/src/content/detector.ts`, after the existing imports: ```typescript import { hookForms } from './capture'; ``` Add `hookForms()` calls in two places: After the `scan()` call near the bottom (line ~91): ```typescript // Initial scan. scan(); hookForms(); ``` Inside the MutationObserver callback (line ~95): ```typescript const observer = new MutationObserver(() => { scan(); hookForms(); }); ``` - [ ] **Step 3: Build and verify** ```bash cd extension && bun run build ``` Expected: Compiles with no errors. - [ ] **Step 4: Commit** ```bash git add extension/src/content/capture.ts extension/src/content/detector.ts git commit -m "feat: add credential capture with bar/toast prompts" ``` --- ## Task 4: Settings View in Popup **Files:** - Create: `extension/src/popup/components/settings.ts` - Modify: `extension/src/popup/popup.ts` - Modify: `extension/src/popup/components/unlock.ts` - [ ] **Step 1: Create settings.ts** Create `extension/src/popup/components/settings.ts`: ```typescript /// Settings view — configure credential capture and manage blacklist. import { setState, sendMessage, navigate, escapeHtml } from '../popup'; import type { RelicarioSettings } from '../../shared/types'; export async function renderSettings(app: HTMLElement): Promise { // Load current settings and blacklist in parallel. const [settingsResp, blacklistResp] = await Promise.all([ sendMessage({ type: 'get_settings' }), sendMessage({ type: 'get_blacklist' }), ]); const settings: RelicarioSettings = settingsResp.ok ? settingsResp.data as RelicarioSettings : { captureEnabled: false, captureStyle: 'bar' }; const blacklist: string[] = blacklistResp.ok ? (blacklistResp.data as { blacklist: string[] }).blacklist : []; app.innerHTML = `
← back
settings
CREDENTIAL CAPTURE (experimental)
PROMPT STYLE
${blacklist.length > 0 ? `
BLACKLISTED SITES
${blacklist.map(h => `
${escapeHtml(h)}
`).join('')}
` : ''}
`; // --- Event listeners --- document.getElementById('back-btn')?.addEventListener('click', () => { navigate('locked'); }); document.getElementById('capture-toggle')?.addEventListener('change', async (e) => { const enabled = (e.target as HTMLInputElement).checked; await sendMessage({ type: 'update_settings', settings: { captureEnabled: enabled } }); }); document.querySelectorAll('[data-style]').forEach(btn => { btn.addEventListener('click', async () => { const style = (btn as HTMLElement).dataset.style as 'bar' | 'toast'; await sendMessage({ type: 'update_settings', settings: { captureStyle: style } }); // Re-render to update active state. renderSettings(app); }); }); document.querySelectorAll('[data-remove-host]').forEach(btn => { btn.addEventListener('click', async () => { const hostname = (btn as HTMLElement).dataset.removeHost!; await sendMessage({ type: 'remove_blacklist', hostname }); // Re-render to remove from list. renderSettings(app); }); }); } ``` - [ ] **Step 2: Add 'settings' view to popup.ts** In `extension/src/popup/popup.ts`: Add the import at the top with the other component imports: ```typescript import { renderSettings } from './components/settings'; ``` Update the `View` type: ```typescript export type View = 'setup' | 'locked' | 'list' | 'detail' | 'add' | 'edit' | 'settings'; ``` Add the case to the `render()` switch: ```typescript case 'settings': renderSettings(app); break; ``` - [ ] **Step 3: Wire settings button in unlock.ts** In `extension/src/popup/components/unlock.ts`, change the settings button handler (line ~55): From: ```typescript settingsBtn?.addEventListener('click', () => navigate('setup')); ``` To: ```typescript settingsBtn?.addEventListener('click', () => navigate('settings')); ``` - [ ] **Step 4: Build and verify** ```bash cd extension && bun run build ``` Expected: Compiles with no errors. - [ ] **Step 5: Commit** ```bash git add extension/src/popup/components/settings.ts extension/src/popup/popup.ts extension/src/popup/components/unlock.ts git commit -m "feat: add settings view with capture toggle and blacklist management" ``` --- ## Task 5: Build and Manual Test **Files:** None (integration testing) - [ ] **Step 1: Full build** ```bash cd extension && bun run build ``` Expected: Compiles with no errors (warnings about WASM size are fine). - [ ] **Step 2: Reload extension in Chrome** Open `chrome://extensions/`, reload the unpacked extension. - [ ] **Step 3: Test settings view** 1. Open popup → click "settings" from unlock screen 2. Verify toggle for "auto-detect logins" (should be off by default) 3. Toggle it on 4. Verify bar/toast style selector works 5. Go back to unlock screen - [ ] **Step 4: Test credential capture (bar mode)** 1. Enable capture in settings, set style to "bar" 2. Unlock the vault 3. Navigate to a login page (e.g. GitHub) 4. Enter credentials and submit the form 5. Verify: notification bar slides down from top with "Save login for github.com?" 6. Click "Save" — verify entry appears in vault 7. Submit same credentials again — verify no prompt (already saved) 8. Change password and submit — verify "Update password?" prompt - [ ] **Step 5: Test credential capture (toast mode)** 1. Change style to "toast" in settings 2. Submit a login form on a new site 3. Verify: floating toast appears in bottom-right 4. Verify: auto-dismisses after ~15 seconds if ignored - [ ] **Step 6: Test blacklist** 1. Click "Never" on a capture prompt 2. Submit login on same site again — verify no prompt 3. Open settings — verify site appears in blacklist 4. Remove site from blacklist — verify it's gone - [ ] **Step 7: Fix any issues found** - [ ] **Step 8: Final commit** ```bash git add -A git commit -m "feat: complete credential capture feature" ``` --- ## Task Summary | Task | Description | Dependencies | |------|-------------|--------------| | 1 | Add types and message definitions | None | | 2 | Service worker message handlers | Task 1 | | 3 | Capture content script + detector integration | Task 1 | | 4 | Settings view in popup | Task 1 | | 5 | Build and manual test | All | Tasks 2, 3, and 4 can run in parallel after Task 1. Task 5 is final integration.