Files
relicario/docs/superpowers/plans/2026-04-12-relicario-credential-capture.md
adlee-was-taken 39ae2ecbf3 style: capitalize "Relicario" in prose / UI / CLI help
Brand name uses capital R in user-facing text — extension UI strings,
CLI clap help / descriptions / error prose, markdown docs. Lowercase
preserved for the binary command, crate names, npm package, file
paths, env vars, and code identifiers.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-01 17:29:10 -04:00

24 KiB

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:

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:

| { type: 'check_credential'; url: string; username: string; password: string }
| { type: 'blacklist_site'; hostname: string }
| { type: 'get_settings' }
| { type: 'update_settings'; settings: Partial<import('./types').RelicarioSettings> }
| { type: 'get_blacklist' }
| { type: 'remove_blacklist'; hostname: string }
  • Step 3: Commit
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:

import type { RelicarioSettings } from '../shared/types';
import { DEFAULT_SETTINGS } from '../shared/types';

async function loadSettings(): Promise<RelicarioSettings> {
  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<void> {
  await chrome.storage.local.set({ settings });
}

async function loadBlacklist(): Promise<string[]> {
  const data = await chrome.storage.local.get(['captureBlacklist']);
  return data.captureBlacklist ?? [];
}

async function saveBlacklist(list: string[]): Promise<void> {
  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:

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
cd extension && bun run build

Expected: Compiles with no errors.

  • Step 4: Commit
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:

/// 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<HTMLFormElement>();
const hookedButtons = new WeakSet<HTMLElement>();

// --- 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<HTMLInputElement>('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<HTMLInputElement>('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<void> {
  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<HTMLFormElement>('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<HTMLElement>(
      '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)}<br><span style="color:#8b949e;font-size:11px;">${escapeHtml(username)}</span>`;
  }

  // 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<string, unknown> }).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:

import { hookForms } from './capture';

Add hookForms() calls in two places:

After the scan() call near the bottom (line ~91):

// Initial scan.
scan();
hookForms();

Inside the MutationObserver callback (line ~95):

const observer = new MutationObserver(() => {
  scan();
  hookForms();
});
  • Step 3: Build and verify
cd extension && bun run build

Expected: Compiles with no errors.

  • Step 4: Commit
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:

/// 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<void> {
  // 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 = `
    <div class="pad" style="padding-top:12px;">
      <div style="margin-bottom:16px;">
        <span class="secondary" style="cursor:pointer;font-size:11px;" id="back-btn">← back</span>
      </div>

      <div class="brand" style="margin-bottom:16px;">settings</div>

      <div class="form-group" style="margin-bottom:16px;">
        <div class="label">CREDENTIAL CAPTURE <span class="muted">(experimental)</span></div>
        <div style="display:flex;align-items:center;gap:8px;margin-top:6px;">
          <label style="display:flex;align-items:center;gap:6px;cursor:pointer;font-size:12px;">
            <input type="checkbox" id="capture-toggle" ${settings.captureEnabled ? 'checked' : ''}>
            auto-detect logins
          </label>
        </div>
      </div>

      <div class="form-group" style="margin-bottom:16px;">
        <div class="label">PROMPT STYLE</div>
        <div style="display:flex;gap:8px;margin-top:6px;">
          <button class="group-tab ${settings.captureStyle === 'bar' ? 'active' : ''}" data-style="bar">bar</button>
          <button class="group-tab ${settings.captureStyle === 'toast' ? 'active' : ''}" data-style="toast">toast</button>
        </div>
      </div>

      ${blacklist.length > 0 ? `
        <div class="form-group">
          <div class="label">BLACKLISTED SITES</div>
          <div style="margin-top:6px;">
            ${blacklist.map(h => `
              <div style="display:flex;justify-content:space-between;align-items:center;padding:3px 0;font-size:11px;">
                <span class="secondary">${escapeHtml(h)}</span>
                <span class="muted" style="cursor:pointer;" data-remove-host="${escapeHtml(h)}">✕</span>
              </div>
            `).join('')}
          </div>
        </div>
      ` : ''}
    </div>
  `;

  // --- 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:

import { renderSettings } from './components/settings';

Update the View type:

export type View = 'setup' | 'locked' | 'list' | 'detail' | 'add' | 'edit' | 'settings';

Add the case to the render() switch:

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:

settingsBtn?.addEventListener('click', () => navigate('setup'));

To:

settingsBtn?.addEventListener('click', () => navigate('settings'));
  • Step 4: Build and verify
cd extension && bun run build

Expected: Compiles with no errors.

  • Step 5: Commit
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
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

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.