Files
relicario/docs/superpowers/plans/2026-04-20-relicario-extension-1c-alpha.md
adlee-was-taken c03a492ee3 docs: Plan 1C-α (extension foundation) implementation plan
28 tasks across 6 slices + pre-flight + acceptance, following the 1C-α
design spec (a1d733d/ad6d8af). Each task is a single commit; each step
is 2-5 minutes of work. Design choices locked in:

- Slice 1 (Tasks 1-3): WASM artifact rebuild (replace stale idfoto_wasm)
- Slice 2 (Tasks 4-6): shared TS types + message unions + base32 util
- Slice 3 (Tasks 7-10): session.ts, vault.ts, transitional index.ts
- Slice 4 (Tasks 11-15): split router + Vitest + sender-check matrix
- Slice 5 (Tasks 16-20): WAR cleanup, setup-via-tabs, closed Shadow DOM
  for capture/icon/picker/ack, popup captured-tab snapshot
- Slice 6 (Tasks 21-27): popup rename + Login-parity + zxcvbn + manual
  cross-browser verification
- Slice 7 (Task 28): acceptance checks (cargo test, build, lint greps)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 19:28:13 -04:00

112 KiB
Raw Blame History

relicario Extension 1C-α (Foundation) 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: Port the browser extension onto the typed-item core from Plans 1A+1B. Rebuild the WASM artifact, migrate the TypeScript surface to typed items, rewrite the service worker around the opaque SessionHandle, split the message router with sender checks, wire the full security architecture (WAR cleanup, origin-bound autofill, TOFU origin-ack, popup captured-tab verification, closed Shadow DOM), gate the setup wizard with zxcvbn, and achieve Login-parity on the new stack. Other six item types land in 1C-β.

Architecture: Six-slice bottom-up sequencing. Each slice leaves the branch in a cleanly-committable state; the extension doesn't have to load-and-run until slice 6. All messaging is statically split into PopupMessage (popup-callable) and ContentMessage (content-script-callable) unions; the single chrome.runtime.onMessage listener dispatches by sender.url / sender.tab / sender.frameId. Session handles are opaque u32 wrappers with Rust-side Zeroizing<[u8; 32]> storage — JS never sees master-key bytes.

Tech Stack: Rust (relicario-wasm crate, built with wasm-pack --target web), TypeScript (webpack 5, ts-loader), Vitest (new — for router unit tests), Bun (package manager per bun.lock).

Reference spec: docs/superpowers/specs/2026-04-20-relicario-extension-1c-alpha-design.md (commit ad6d8af)

Branch: Create feature/typed-items-1c-alpha off main (Plan 1B already merged).

Environment note: extension/ uses Bun (bun.lock) but package.json scripts invoke webpack directly. Use bun install for deps and bun run build/bun run build:all/bun run build:firefox/bun run build:wasm for scripts — they match npm semantics.


Pre-flight

  • Pre-flight step 1: Verify cargo workspace green on main

Run: cd /home/alee/Sources/relicario && cargo test --workspace Expected: all 151 tests pass (per Plan 1B tag plan-1b-cli-wasm-complete).

  • Pre-flight step 2: Create feature branch
cd /home/alee/Sources/relicario
git checkout main
git pull
git checkout -b feature/typed-items-1c-alpha
  • Pre-flight step 3: Install extension deps
cd extension
bun install

Expected: installs cleanly (webpack, ts-loader, copy-webpack-plugin, @types/chrome).


Slice 1 — WASM artifact rebuild

Goal: replace the stale idfoto_wasm* files in extension/wasm/ with freshly-built relicario_wasm* output. Synchronize wasm.d.ts if there's any drift from the Plan 1B surface.

Task 1: Delete stale WASM artifacts

Files:

  • Delete: extension/wasm/idfoto_wasm_bg.wasm

  • Delete: extension/wasm/idfoto_wasm_bg.wasm.d.ts

  • Delete: extension/wasm/idfoto_wasm.d.ts

  • Delete: extension/wasm/idfoto_wasm.js

  • Delete: extension/wasm/package.json

  • Step 1: Remove stale files

cd /home/alee/Sources/relicario/extension
rm -f wasm/idfoto_wasm_bg.wasm wasm/idfoto_wasm_bg.wasm.d.ts wasm/idfoto_wasm.d.ts wasm/idfoto_wasm.js wasm/package.json
  • Step 2: Verify directory is empty

Run: ls extension/wasm/ Expected: no output (empty directory).

  • Step 3: Commit
cd /home/alee/Sources/relicario
git add -A extension/wasm/
git commit -m "chore(ext): remove stale idfoto_wasm artifact"

Task 2: Build the relicario-wasm artifact for the extension

Files:

  • Modify (generated): extension/wasm/relicario_wasm.js

  • Modify (generated): extension/wasm/relicario_wasm_bg.wasm

  • Modify (generated): extension/wasm/relicario_wasm.d.ts

  • Modify (generated): extension/wasm/relicario_wasm_bg.wasm.d.ts

  • Modify (generated): extension/wasm/package.json

  • Step 1: Run the WASM build script

cd /home/alee/Sources/relicario/extension
bun run build:wasm

The script invokes wasm-pack build ../crates/relicario-wasm --target web --out-dir ../../extension/wasm. Requires wasm-pack on PATH; if missing, install with cargo install wasm-pack.

  • Step 2: Verify expected files exist
ls extension/wasm/

Expected files: relicario_wasm.js, relicario_wasm_bg.wasm, relicario_wasm.d.ts, relicario_wasm_bg.wasm.d.ts, package.json.

  • Step 3: Sanity-check the WASM binary is non-trivial
stat -c '%s' extension/wasm/relicario_wasm_bg.wasm

Expected: a multi-hundred-KB size (typical wasm-pack output with Argon2, XChaCha20, zxcvbn, bip39 is ~500KB1.5MB).

  • Step 4: Commit
git add extension/wasm/
git commit -m "build(ext): rebuild WASM artifact as relicario_wasm"

Task 3: Sync extension/src/wasm.d.ts with the generated declarations

The hand-maintained extension/src/wasm.d.ts declares the WASM surface for the TS compiler. Verify it matches crates/relicario-wasm/src/lib.rs — the rebuilt artifact may reveal drift.

Files:

  • Modify: extension/src/wasm.d.ts

  • Step 1: Compare existing declarations to Rust source

Read extension/src/wasm.d.ts and crates/relicario-wasm/src/lib.rs. Each #[wasm_bindgen] signature must have a corresponding TS declaration.

Current file should already match (Plan 1B landed it). If any function is missing, add it. As of commit 65e0d3c the expected surface is:

// Thin TypeScript declarations for the relicario-wasm bindings.
// These are hand-written to mirror the #[wasm_bindgen] signatures in
// crates/relicario-wasm/src/lib.rs; keep them in sync manually.

export class SessionHandle {
  readonly value: number;
  free(): void;
}

export class EncryptedAttachment {
  readonly aid: string;
  readonly bytes: Uint8Array;
  free(): void;
}

export class TotpCode {
  readonly code: string;
  readonly expires_at: number;
  free(): void;
}

export function unlock(
  passphrase: string,
  image_bytes: Uint8Array,
  salt: Uint8Array,
  params_json: string,
): SessionHandle;

export function lock(handle: SessionHandle): boolean;

export function manifest_decrypt(handle: SessionHandle, encrypted: Uint8Array): unknown;
export function manifest_encrypt(handle: SessionHandle, manifest_json: string): Uint8Array;
export function item_decrypt(handle: SessionHandle, encrypted: Uint8Array): unknown;
export function item_encrypt(handle: SessionHandle, item_json: string): Uint8Array;
export function settings_decrypt(handle: SessionHandle, encrypted: Uint8Array): unknown;
export function settings_encrypt(handle: SessionHandle, settings_json: string): Uint8Array;

export function attachment_encrypt(
  handle: SessionHandle,
  plaintext: Uint8Array,
  max_bytes: bigint,
): EncryptedAttachment;
export function attachment_decrypt(handle: SessionHandle, encrypted: Uint8Array): Uint8Array;

export function new_item_id(): string;
export function new_field_id(): string;

export function generate_password(request_json: string): string;
export function generate_passphrase(request_json: string): string;
export function rate_passphrase(p: string): { score: number; guesses_log10: number };

export function extract_image_secret(image_bytes: Uint8Array): Uint8Array;
export function embed_image_secret(carrier: Uint8Array, secret: Uint8Array): Uint8Array;

export function totp_compute(config_json: string, now_unix_seconds: bigint): TotpCode;

// Initializer (wasm-bindgen's default init function).
export default function init(module_or_path?: unknown): Promise<void>;
  • Step 2: Add initSync export declaration

The service worker index.ts also calls initSync (named export) for the Chrome MV3 path. Add this after the default export:

// wasm-bindgen's sync init — Chrome MV3 service workers can't use dynamic import().
export function initSync(args: { module: WebAssembly.Module }): void;
  • Step 3: Verify TS compile passes
cd /home/alee/Sources/relicario/extension
bun run build 2>&1 | head -40

Expected: compilation proceeds (may still fail on the old idfoto_wasm-era callers in service-worker/; that's fine — the types file itself should not error).

  • Step 4: Commit
git add extension/src/wasm.d.ts
git commit -m "build(ext): align wasm.d.ts with relicario-wasm surface"

Slice 2 — Shared TS types and message unions

Goal: rewrite shared/types.ts and shared/messages.ts to mirror the Rust typed-item serialization. This slice makes downstream TS fail to compile everywhere that still references Entry / ManifestEntry with password / totp_secret — that's expected; slices 36 fix the callers.

Task 4: Rewrite shared/types.ts with typed-item shapes

Files:

  • Modify: extension/src/shared/types.ts

  • Step 1: Replace the entire file

/// Typed-item shared TypeScript types.
///
/// These mirror the Rust core's serde serialization. See
/// crates/relicario-core/src/item.rs, item_types/, and settings.rs
/// for the source shapes.

// --- IDs ---

export type ItemId = string;       // 16-char hex
export type FieldId = string;      // 16-char hex
export type AttachmentId = string; // 16-char hex (sha256 of plaintext, truncated)

// --- ItemType / ItemCore ---

// snake_case from serde rename_all
export type ItemType =
  | 'login' | 'secure_note' | 'identity' | 'card' | 'key' | 'document' | 'totp';

// ItemCore is internally-tagged on "type":
//   Login → { type: 'login', username, password, url, totp }
export type ItemCore =
  | ({ type: 'login' }       & LoginCore)
  | ({ type: 'secure_note' } & SecureNoteCore)
  | ({ type: 'identity' }    & IdentityCore)
  | ({ type: 'card' }        & CardCore)
  | ({ type: 'key' }         & KeyCore)
  | ({ type: 'document' }    & DocumentCore)
  | ({ type: 'totp' }        & TotpCore);

// Optional fields use `?` because Rust #[serde(skip_serializing_if = "Option::is_none")]
// omits them from the JSON; serde_wasm_bindgen produces `undefined` on read.

export interface LoginCore {
  username?: string;
  password?: string;
  url?: string;
  totp?: TotpConfig;
}

export interface SecureNoteCore { body: string; }

export interface IdentityCore {
  full_name?: string;
  address?: string;
  phone?: string;
  email?: string;
  date_of_birth?: string;           // "YYYY-MM-DD"
}

export interface CardCore {
  number?: string;
  holder?: string;
  expiry?: { month: number; year: number };
  cvv?: string;
  pin?: string;
  kind: CardKind;
}

export type CardKind = 'credit' | 'debit' | 'gift' | 'loyalty' | 'other';

export interface KeyCore {
  key_material: string;
  label?: string;
  public_key?: string;
  algorithm?: string;
}

export interface DocumentCore {
  filename: string;
  mime_type: string;
  primary_attachment: AttachmentId;
}

export interface TotpCore {
  config: TotpConfig;
  issuer?: string;
  label?: string;
}

// --- TOTP ---

export type TotpKind = 'totp' | 'steam' | { hotp: { counter: number } };

export interface TotpConfig {
  secret: number[];                 // Vec<u8> → JSON number array
  algorithm: 'sha1' | 'sha256' | 'sha512';
  digits: number;
  period_seconds: number;
  kind: TotpKind;
}

// --- Sections + custom fields ---

export interface Section {
  name?: string;
  fields: Field[];
}

export interface Field {
  id: FieldId;
  label: string;
  kind: FieldKind;
  value: FieldValue;
  hidden_by_default: boolean;
}

export type FieldKind =
  | 'text' | 'multiline' | 'password' | 'concealed' | 'url' | 'email'
  | 'phone' | 'date' | 'month_year' | 'totp' | 'reference';

// adjacently-tagged { tag: "kind", content: "value" }
export type FieldValue =
  | { kind: 'text';       value: string }
  | { kind: 'multiline';  value: string }
  | { kind: 'password';   value: string }
  | { kind: 'concealed';  value: string }
  | { kind: 'url';        value: string }
  | { kind: 'email';      value: string }
  | { kind: 'phone';      value: string }
  | { kind: 'date';       value: string }
  | { kind: 'month_year'; value: { month: number; year: number } }
  | { kind: 'totp';       value: TotpConfig }
  | { kind: 'reference';  value: AttachmentId };

// --- Attachments + history ---

export interface AttachmentRef {
  id: AttachmentId;
  filename: string;
  mime_type: string;
  size: number;
  created: number;
}

export interface FieldHistoryEntry {
  value: string;
  replaced_at: number;
}

export interface AttachmentSummary {
  id: AttachmentId;
  filename: string;
  mime_type: string;
  size: number;
}

// --- Item envelope ---

export interface Item {
  id: ItemId;
  title: string;
  type: ItemType;                   // Rust r#type → JSON key "type"
  tags: string[];
  favorite: boolean;
  group?: string;
  notes?: string;
  created: number;
  modified: number;
  trashed_at?: number;
  core: ItemCore;
  sections: Section[];
  attachments: AttachmentRef[];
  field_history: Record<FieldId, FieldHistoryEntry[]>;
}

// --- Manifest (schema_version 2) ---

export interface Manifest {
  schema_version: number;           // 2
  items: Record<ItemId, ManifestEntry>;
}

export interface ManifestEntry {
  id: ItemId;
  type: ItemType;
  title: string;
  tags: string[];
  favorite: boolean;
  group?: string;
  icon_hint?: string;
  modified: number;
  trashed_at?: number;
  attachment_summaries: AttachmentSummary[];
}

// --- Vault settings (only the fields α touches) ---
// Full shape lives on the Rust side and in docs/superpowers/specs/2026-04-18-relicario-typed-items-design.md
// We leave retention/generator/caps opaque to α so we don't accidentally mutate them.

export interface VaultSettings {
  trash_retention: unknown;
  field_history_retention: unknown;
  generator_defaults: unknown;
  attachment_caps: unknown;
  autofill_origin_acks: Record<string, number>;
}

// --- Vault config (device-local) ---

export interface VaultConfig {
  hostType: 'gitea' | 'github';
  hostUrl: string;
  repoPath: string;
  apiToken: string;
}

export interface SetupState {
  config: VaultConfig | null;
  imageBase64: string | null;
  isConfigured: boolean;
}

// --- Device-local UX settings (chrome.storage.local — renamed from RelicarioSettings) ---

export interface DeviceSettings {
  captureEnabled: boolean;
  captureStyle: 'bar' | 'toast';
}

export const DEFAULT_DEVICE_SETTINGS: DeviceSettings = {
  captureEnabled: false,
  captureStyle: 'bar',
};

// --- Generator request (matches Rust GeneratorRequest — tag="kind") ---

export type GeneratorRequest =
  | { kind: 'bip39';  word_count: number; separator: string; capitalization: Capitalization }
  | { kind: 'random'; length: number; classes: CharClasses; symbol_charset: SymbolCharset };

export type Capitalization = 'lower' | 'upper' | 'first_of_each' | 'title' | 'mixed';
export interface CharClasses { lower: boolean; upper: boolean; digits: boolean; symbols: boolean; }
export type SymbolCharset =
  | { kind: 'safe_only' }
  | { kind: 'extended' }
  | { kind: 'custom'; value: string };

// Default used by the α popup "gen" button:
export const DEFAULT_PASSWORD_REQUEST: GeneratorRequest = {
  kind: 'random',
  length: 20,
  classes: { lower: true, upper: true, digits: true, symbols: true },
  symbol_charset: { kind: 'safe_only' },
};
  • Step 2: Commit
cd /home/alee/Sources/relicario
git add extension/src/shared/types.ts
git commit -m "feat(ext): typed-item TS types mirroring relicario-core serde"

Task 5: Rewrite shared/messages.ts with split unions

Files:

  • Modify: extension/src/shared/messages.ts

  • Step 1: Replace the entire file

import type {
  Item, ItemId, Manifest, ManifestEntry, VaultConfig, SetupState,
  DeviceSettings, GeneratorRequest,
} from './types';

// --- Messages a popup (or setup page) may send ---

export type PopupMessage =
  | { type: 'is_unlocked' }
  | { type: 'unlock'; passphrase: string }
  | { type: 'lock' }
  | { type: 'list_items'; group?: string }
  | { type: 'get_item'; id: ItemId }
  | { type: 'add_item'; item: Item }
  | { type: 'update_item'; id: ItemId; item: Item }
  | { type: 'delete_item'; id: ItemId }                 // soft-delete
  | { type: 'get_totp'; id: ItemId }
  | { type: 'sync' }
  | { type: 'get_setup_state' }
  | { type: 'save_setup'; config: VaultConfig; imageBase64: string }
  | { type: 'rate_passphrase'; passphrase: string }
  | { type: 'generate_password'; request: GeneratorRequest }
  | { type: 'fill_credentials'; id: ItemId; capturedTabId: number; capturedUrl: string }
  | { type: 'ack_autofill_origin'; hostname: string }
  | { type: 'get_settings' }
  | { type: 'update_settings'; settings: Partial<DeviceSettings> }
  | { type: 'get_blacklist' }
  | { type: 'remove_blacklist'; hostname: string };

// --- Messages a content script may send ---

// Note deliberate absence of a `url` field — the SW derives origin from sender.tab.url.

export type ContentMessage =
  | { type: 'get_autofill_candidates' }
  | { type: 'get_credentials'; id: ItemId }
  | { type: 'check_credential'; username: string; password: string }
  | { type: 'blacklist_site' };

// --- Union for chrome.runtime.sendMessage call sites ---

export type Request = PopupMessage | ContentMessage;

// --- Response ---

export type Response =
  | { ok: true; data?: unknown }
  | { ok: false; error: string };

// --- Typed response helpers ---

export interface IsUnlockedResponse extends Extract<Response, { ok: true }> {
  data: { unlocked: boolean };
}

export interface ListItemsResponse extends Extract<Response, { ok: true }> {
  data: { items: Array<[ItemId, ManifestEntry]> };
}

export interface GetItemResponse extends Extract<Response, { ok: true }> {
  data: { item: Item };
}

export interface TotpResponse extends Extract<Response, { ok: true }> {
  data: { code: string; expires_at: number };
}

export interface AutofillCandidatesResponse extends Extract<Response, { ok: true }> {
  data: { candidates: Array<[ItemId, ManifestEntry]> };
}

export interface CredentialsResponse extends Extract<Response, { ok: true }> {
  data:
    | { requires_ack: true; hostname: string }
    | { username: string; password: string };
}

export interface SetupStateResponse extends Extract<Response, { ok: true }> {
  data: SetupState;
}

export interface GeneratePasswordResponse extends Extract<Response, { ok: true }> {
  data: { password: string };
}

export interface RatePassphraseResponse extends Extract<Response, { ok: true }> {
  data: { score: number; guesses_log10: number };
}

// --- Capability sets (consumed by the router) ---

export const POPUP_ONLY_TYPES: ReadonlySet<PopupMessage['type']> = new Set([
  'is_unlocked', 'unlock', 'lock', 'list_items', 'get_item', 'add_item',
  'update_item', 'delete_item', 'get_totp', 'sync', 'get_setup_state',
  'save_setup', 'rate_passphrase', 'generate_password', 'fill_credentials',
  'ack_autofill_origin', 'get_settings', 'update_settings', 'get_blacklist',
  'remove_blacklist',
] as PopupMessage['type'][]);

export const CONTENT_CALLABLE_TYPES: ReadonlySet<ContentMessage['type']> = new Set([
  'get_autofill_candidates', 'get_credentials', 'check_credential', 'blacklist_site',
] as ContentMessage['type'][]);
  • Step 2: Commit
git add extension/src/shared/messages.ts
git commit -m "feat(ext): split PopupMessage / ContentMessage unions + capability sets"

Task 6: Add shared/base32.ts with encode/decode and tests

Files:

  • Create: extension/src/shared/base32.ts
  • Create: extension/src/shared/__tests__/base32.test.ts

This module is the minimum support the Login form needs to accept a base32-encoded TOTP secret string and format the re-encoded form for display.

  • Step 1: Write the failing test
// extension/src/shared/__tests__/base32.test.ts
import { describe, expect, it } from 'vitest';
import { base32Decode, base32Encode } from '../base32';

describe('base32', () => {
  // RFC 4648 § 10 test vectors
  it('encodes empty', () => expect(base32Encode(new Uint8Array())).toBe(''));
  it('encodes "f"',   () => expect(base32Encode(new TextEncoder().encode('f'))).toBe('MY'));
  it('encodes "fo"',  () => expect(base32Encode(new TextEncoder().encode('fo'))).toBe('MZXQ'));
  it('encodes "foo"', () => expect(base32Encode(new TextEncoder().encode('foo'))).toBe('MZXW6'));
  it('encodes "foob"',  () => expect(base32Encode(new TextEncoder().encode('foob'))).toBe('MZXW6YQ'));
  it('encodes "fooba"', () => expect(base32Encode(new TextEncoder().encode('fooba'))).toBe('MZXW6YTB'));
  it('encodes "foobar"',() => expect(base32Encode(new TextEncoder().encode('foobar'))).toBe('MZXW6YTBOI'));

  it('decodes round-trip', () => {
    const bytes = new Uint8Array([0x12, 0x34, 0x56, 0x78, 0x9a]);
    expect(base32Decode(base32Encode(bytes))).toEqual(bytes);
  });

  it('decodes case-insensitively', () => {
    expect(base32Decode('mzxw6')).toEqual(new TextEncoder().encode('foo'));
  });

  it('decodes ignoring whitespace and padding', () => {
    expect(base32Decode('JBSW Y3DP EHPK 3PXP==')).toEqual(
      base32Decode('JBSWY3DPEHPK3PXP'),
    );
  });

  it('throws on invalid characters', () => {
    expect(() => base32Decode('MZ!W6')).toThrow();
  });
});

Note: Vitest is not installed yet; this test file will be run in slice 4 Task 15 where Vitest is wired up. For this task, just create the file so slice 2 stays compilation-clean.

  • Step 2: Write base32.ts
/// Minimal RFC 4648 base32 encode/decode for TOTP secret parsing.
///
/// Mirrors the encoder in crates/relicario-core/src/item.rs:base32_encode.
/// Decode is case-insensitive, tolerates whitespace and `=` padding.

const ALPHA = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';

export function base32Encode(bytes: Uint8Array): string {
  let out = '';
  let buffer = 0;
  let bits = 0;
  for (const b of bytes) {
    buffer = (buffer << 8) | b;
    bits += 8;
    while (bits >= 5) {
      const idx = (buffer >> (bits - 5)) & 0x1f;
      out += ALPHA[idx];
      bits -= 5;
    }
  }
  if (bits > 0) {
    const idx = (buffer << (5 - bits)) & 0x1f;
    out += ALPHA[idx];
  }
  return out;
}

export function base32Decode(input: string): Uint8Array {
  const cleaned = input.replace(/\s+/g, '').replace(/=+$/g, '').toUpperCase();
  const out: number[] = [];
  let buffer = 0;
  let bits = 0;
  for (const ch of cleaned) {
    const idx = ALPHA.indexOf(ch);
    if (idx === -1) throw new Error(`base32: invalid character "${ch}"`);
    buffer = (buffer << 5) | idx;
    bits += 5;
    if (bits >= 8) {
      out.push((buffer >> (bits - 8)) & 0xff);
      bits -= 8;
    }
  }
  return new Uint8Array(out);
}
  • Step 3: Commit
git add extension/src/shared/base32.ts extension/src/shared/__tests__/base32.test.ts
git commit -m "feat(ext): base32 encode/decode for TOTP secret parse"

Slice 3 — Service-worker session, vault, index

Goal: rewrite the SW vault layer around the typed-item WASM surface using the opaque SessionHandle. index.ts temporarily keeps its single-function message handler (still flat, no router split yet — that's slice 4) but the handler's internals now call the new vault module. Login-parity is not yet in place; slice 6 wires that. This slice's success criterion is: bun run build succeeds.

Task 7: Create service-worker/session.ts

Files:

  • Create: extension/src/service-worker/session.ts

  • Step 1: Write the file

/// Single module-scope "current" SessionHandle.
///
/// α assumes one vault per extension install. The master key lives only
/// inside WASM linear memory (wrapped in Zeroizing<[u8;32]>); this module
/// just holds the opaque handle that names it.

import type { SessionHandle } from '../../wasm/relicario_wasm';

let current: SessionHandle | null = null;

export function setCurrent(h: SessionHandle): void { current = h; }

export function getCurrent(): SessionHandle | null { return current; }

export function requireCurrent(): SessionHandle {
  if (!current) throw new Error('vault_locked');
  return current;
}

export function clearCurrent(): void {
  if (!current) return;
  try { current.free(); } catch { /* already freed */ }
  current = null;
}
  • Step 2: Commit
git add extension/src/service-worker/session.ts
git commit -m "feat(ext/sw): SessionHandle lifecycle module"

Task 8: Rewrite service-worker/vault.ts for typed items

Files:

  • Modify: extension/src/service-worker/vault.ts

  • Step 1: Replace the entire file

/// Typed-item vault operations. All calls are handle-keyed — the master key
/// never crosses the WASM boundary.

import type { SessionHandle } from '../../wasm/relicario_wasm';
import type { GitHost } from './git-host';
import type { Item, ItemId, Manifest, ManifestEntry, VaultSettings } from '../shared/types';

// eslint-disable-next-line @typescript-eslint/no-explicit-any
let wasm: any = null;

// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function setWasm(w: any): void { wasm = w; }

function requireWasm(): any {
  if (!wasm) throw new Error('WASM module not initialized');
  return wasm;
}

export interface VaultMeta {
  salt: Uint8Array;
  paramsJson: string;
}

export async function fetchVaultMeta(git: GitHost): Promise<VaultMeta> {
  const saltBytes = await git.readFile('.relicario/salt');
  const paramsRaw = await git.readFile('.relicario/params.json');
  const paramsJson = new TextDecoder().decode(paramsRaw);
  return { salt: saltBytes, paramsJson };
}

// --- Manifest ---

export async function fetchAndDecryptManifest(
  git: GitHost,
  handle: SessionHandle,
): Promise<Manifest> {
  const w = requireWasm();
  const ciphertext = await git.readFile('manifest.enc');
  return w.manifest_decrypt(handle, ciphertext) as Manifest;
}

export async function encryptAndWriteManifest(
  git: GitHost,
  handle: SessionHandle,
  manifest: Manifest,
  message: string,
): Promise<void> {
  const w = requireWasm();
  const ciphertext = w.manifest_encrypt(handle, JSON.stringify(manifest));
  await git.writeFile('manifest.enc', ciphertext, message);
}

// --- Items ---

export async function fetchAndDecryptItem(
  git: GitHost,
  handle: SessionHandle,
  id: ItemId,
): Promise<Item> {
  const w = requireWasm();
  const ciphertext = await git.readFile(`items/${id}.enc`);
  return w.item_decrypt(handle, ciphertext) as Item;
}

export async function encryptAndWriteItem(
  git: GitHost,
  handle: SessionHandle,
  id: ItemId,
  item: Item,
  message: string,
): Promise<void> {
  const w = requireWasm();
  const ciphertext = w.item_encrypt(handle, JSON.stringify(item));
  await git.writeFile(`items/${id}.enc`, ciphertext, message);
}

// --- Settings (the α subset the SW reads/writes is autofill_origin_acks) ---

export async function fetchAndDecryptSettings(
  git: GitHost,
  handle: SessionHandle,
): Promise<VaultSettings> {
  const w = requireWasm();
  const ciphertext = await git.readFile('settings.enc');
  return w.settings_decrypt(handle, ciphertext) as VaultSettings;
}

export async function encryptAndWriteSettings(
  git: GitHost,
  handle: SessionHandle,
  settings: VaultSettings,
  message: string,
): Promise<void> {
  const w = requireWasm();
  const ciphertext = w.settings_encrypt(handle, JSON.stringify(settings));
  await git.writeFile('settings.enc', ciphertext, message);
}

// --- In-memory manifest helpers ---

export function listItems(
  manifest: Manifest,
  group?: string,
): Array<[ItemId, ManifestEntry]> {
  const entries = Object.entries(manifest.items) as Array<[ItemId, ManifestEntry]>;
  // Hide trashed items from the default list view.
  const live = entries.filter(([, e]) => e.trashed_at === undefined);
  if (!group) return live;
  const g = group.toLowerCase();
  return live.filter(([, e]) => e.group?.toLowerCase() === g);
}

export function searchItems(
  manifest: Manifest,
  query: string,
): Array<[ItemId, ManifestEntry]> {
  const q = query.toLowerCase();
  return (Object.entries(manifest.items) as Array<[ItemId, ManifestEntry]>)
    .filter(([, e]) => e.trashed_at === undefined)
    .filter(([, e]) => {
      if (e.title.toLowerCase().includes(q)) return true;
      if (e.tags.some((t) => t.toLowerCase().includes(q))) return true;
      return false;
    });
}

export function findByHostname(
  manifest: Manifest,
  hostname: string,
): Array<[ItemId, ManifestEntry]> {
  const h = hostname.toLowerCase();
  return (Object.entries(manifest.items) as Array<[ItemId, ManifestEntry]>)
    .filter(([, e]) => e.trashed_at === undefined)
    .filter(([, e]) => (e.icon_hint ?? '').toLowerCase() === h);
  // icon_hint is derived by Rust core from LoginCore.url's hostname,
  // so hostname equality on icon_hint is the cheapest match.
}
  • Step 2: Verify TS compile passes for vault.ts
cd /home/alee/Sources/relicario/extension
bunx tsc --noEmit src/service-worker/vault.ts 2>&1 | head -20

Expected: no errors originating inside vault.ts. Errors from consumers (like index.ts) are fine for now.

  • Step 3: Commit
git add extension/src/service-worker/vault.ts
git commit -m "feat(ext/sw): typed-item vault ops via SessionHandle"

Task 9: Rewire service-worker/index.ts for the new vault surface

This is a transitional state — we keep the flat handler from today but replumb it against the new types. The router split lands in slice 4.

Files:

  • Modify: extension/src/service-worker/index.ts

  • Step 1: Replace the file with the transitional (non-router) shape

/// Background script entry point for the relicario browser extension.
///
/// 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 { Item, ItemId, Manifest, VaultConfig, SetupState, DeviceSettings } from '../shared/types';
import { DEFAULT_DEVICE_SETTINGS } from '../shared/types';
import type { GitHost } from './git-host';
import { createGitHost, base64ToUint8Array } from './git-host';
import * as vault from './vault';
import * as session from './session';

// --- WASM module load ---

// @ts-ignore TS2307 — resolved by webpack alias / copy
import initDefault, { initSync } from '../../wasm/relicario_wasm.js';
// @ts-ignore TS2307
import * as wasmBindings from '../../wasm/relicario_wasm.js';

type WasmModule = typeof wasmBindings;
let wasm: WasmModule | null = null;

async function initWasm(): Promise<WasmModule> {
  if (wasm) return wasm;

  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  const SWGlobalScope = (globalThis as any).ServiceWorkerGlobalScope as (new () => ServiceWorker) | undefined;
  const isServiceWorker = typeof SWGlobalScope !== 'undefined'
    && self instanceof (SWGlobalScope as unknown as typeof EventTarget);

  if (isServiceWorker) {
    const wasmResponse = await fetch(chrome.runtime.getURL('relicario_wasm_bg.wasm'));
    const wasmBytes = await wasmResponse.arrayBuffer();
    initSync({ module: new WebAssembly.Module(wasmBytes) });
  } else {
    const wasmUrl = chrome.runtime.getURL('relicario_wasm_bg.wasm');
    await initDefault(wasmUrl);
  }

  vault.setWasm(wasmBindings);
  wasm = wasmBindings;
  return wasm;
}

// --- In-memory vault state (cleared on lock or SW restart) ---

let manifest: Manifest | null = null;
let gitHost: GitHost | null = null;
const totpConfigCache: Map<ItemId, unknown> = new Map();

// --- 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(
  (request: Request, _sender: chrome.runtime.MessageSender, sendResponse: (response: Response) => void) => {
    handleMessage(request)
      .then(sendResponse)
      .catch((err: Error) => sendResponse({ ok: false, error: err.message }));
    return true;
  },
);

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);
      // Clear passphrase from scope best-effort.
      // (JS strings are immutable; the message object goes out of scope after return.)
      (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.
      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}` };
    }
  }
}

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; }
}
  • Step 2: Verify the extension compiles
cd /home/alee/Sources/relicario/extension
bun run build 2>&1 | tail -20

Expected: webpack build succeeds. If there are TS errors in popup/ or content/, they're pre-existing and will be resolved in slices 56.

If bun run build fails because popup/content still references the old Entry type, that's expected — slice 3 intentionally breaks those callers. Workaround: add // @ts-nocheck at the top of the files that fail, in a separate commit labelled chore(ext): silence popup/content errors until slice 6. Remove the @ts-nocheck comments in slice 6 Task 22.

  • Step 3: Commit
git add extension/src/service-worker/index.ts
git commit -m "feat(ext/sw): rewire flat handler onto typed-item vault + SessionHandle"

Task 10: Item round-trip smoke test via CLI

Goal: prove the TS types round-trip cleanly through the WASM item_encrypt / item_decrypt against a real CLI-written vault. This is a manual verification — formal automation waits on slice 4's Vitest setup.

Files: no code changes.

  • Step 1: Create a test vault with the CLI
cd /home/alee/Sources/relicario
mkdir -p /tmp/ext-test-vault && cd /tmp/ext-test-vault
git init
RELICARIO_TEST_PASSPHRASE='correct horse battery staple parapet' \
  RELICARIO_TEST_ITEM_SECRET=$(head -c 32 /dev/urandom | base64) \
  cargo run -p relicario-cli -- init --non-interactive --output /tmp/test-image.jpg
# Use the test image for re-open:
RELICARIO_TEST_PASSPHRASE='correct horse battery staple parapet' \
  cargo run -p relicario-cli -- add login --title 'GitHub' \
    --username alice --url https://github.com --password-stdin <<< 'hunter2'
ls items/ manifest.enc settings.enc

Expected: one item file, a manifest, and a settings file.

  • Step 2: Load the extension's service worker in isolation and decrypt the item

Skip formal automation for α. Instead verify the wire format by reading the Rust-side JSON form and mentally comparing to shared/types.ts:

cd /tmp/ext-test-vault
# Rely on the fact that encrypt_item round-trips in the Rust tests;
# this step is a placeholder for slice 4's Vitest harness.
  • Step 3: Commit the pre-flight findings if any types had to change

If the round-trip reveals drift (e.g. an optional field serialized differently than predicted), update shared/types.ts in a dedicated commit now rather than later. If nothing drifted, skip this step.


Slice 4 — Split router with sender checks

Goal: split the flat handler in index.ts into three files (router/index.ts, router/popup-only.ts, router/content-callable.ts) with the sender-check dispatch from the spec. Wire up Vitest and write the router's acceptance/rejection matrix.

Task 11: Scaffold router/popup-only.ts (move existing handlers)

Files:

  • Create: extension/src/service-worker/router/popup-only.ts

  • Step 1: Write the file

/// Popup-callable message handlers.
///
/// Every export here assumes the router has already verified sender identity
/// via sender.url === popup.html (or setup.html for save_setup).

import type { PopupMessage, Response } from '../../shared/messages';
import type { Item, ItemId, Manifest, VaultConfig, SetupState, DeviceSettings } from '../../shared/types';
import { DEFAULT_DEVICE_SETTINGS } from '../../shared/types';
import type { GitHost } from '../git-host';
import { createGitHost, base64ToUint8Array } from '../git-host';
import * as vault from '../vault';
import * as session from '../session';

// --- Shared ambient state owned by the SW module ---
//
// The router keeps these on a single `state` object and injects it into the
// handler so testing can mock them without reaching for globals.

export interface PopupState {
  manifest: Manifest | null;
  gitHost: GitHost | null;
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  wasm: any;
}

export async function handle(
  msg: PopupMessage,
  state: PopupState,
  sender: chrome.runtime.MessageSender,
): Promise<Response> {
  void sender; // unused in most branches; retained for symmetry with content-callable
  switch (msg.type) {
    case 'is_unlocked':
      return { ok: true, data: { unlocked: session.getCurrent() !== null } };

    case 'unlock': {
      const w = state.wasm;
      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);
      if (!state.gitHost) state.gitHost = createGitHost(config.hostType, config.hostUrl, config.repoPath, config.apiToken);
      const meta = await vault.fetchVaultMeta(state.gitHost);

      const handle = w.unlock(msg.passphrase, imageBytes, meta.salt, meta.paramsJson);
      session.setCurrent(handle);
      (msg as { passphrase: string }).passphrase = '';

      state.manifest = await vault.fetchAndDecryptManifest(state.gitHost, handle);
      return { ok: true };
    }

    case 'lock':
      session.clearCurrent();
      state.manifest = null;
      return { ok: true };

    case 'list_items': {
      if (!state.manifest) return { ok: false, error: 'vault_locked' };
      return { ok: true, data: { items: vault.listItems(state.manifest, msg.group) } };
    }

    case 'get_item': {
      const handle = session.getCurrent();
      if (!handle || !state.gitHost) return { ok: false, error: 'vault_locked' };
      const item = await vault.fetchAndDecryptItem(state.gitHost, handle, msg.id);
      return { ok: true, data: { item } };
    }

    case 'add_item': {
      const handle = session.getCurrent();
      if (!handle || !state.gitHost || !state.manifest) return { ok: false, error: 'vault_locked' };
      const id = state.wasm.new_item_id();
      const item: Item = { ...msg.item, id };
      await vault.encryptAndWriteItem(state.gitHost, handle, id, item, `add: ${item.title}`);
      state.manifest.items[id] = itemToManifestEntry(item);
      await vault.encryptAndWriteManifest(state.gitHost, handle, state.manifest, `manifest: add ${item.title}`);
      return { ok: true, data: { id } };
    }

    case 'update_item': {
      const handle = session.getCurrent();
      if (!handle || !state.gitHost || !state.manifest) return { ok: false, error: 'vault_locked' };
      await vault.encryptAndWriteItem(state.gitHost, handle, msg.id, msg.item, `update: ${msg.item.title}`);
      state.manifest.items[msg.id] = itemToManifestEntry(msg.item);
      await vault.encryptAndWriteManifest(state.gitHost, handle, state.manifest, `manifest: update ${msg.item.title}`);
      return { ok: true };
    }

    case 'delete_item': {
      const handle = session.getCurrent();
      if (!handle || !state.gitHost || !state.manifest) return { ok: false, error: 'vault_locked' };
      const entry = state.manifest.items[msg.id];
      if (!entry) return { ok: false, error: 'item_not_found' };
      const item = await vault.fetchAndDecryptItem(state.gitHost, handle, msg.id);
      const now = Math.floor(Date.now() / 1000);
      const updated: Item = { ...item, trashed_at: now, modified: now };
      await vault.encryptAndWriteItem(state.gitHost, handle, msg.id, updated, `trash: ${entry.title}`);
      state.manifest.items[msg.id] = { ...entry, trashed_at: now, modified: now };
      await vault.encryptAndWriteManifest(state.gitHost, handle, state.manifest, `manifest: trash ${entry.title}`);
      return { ok: true };
    }

    case 'get_totp': {
      const handle = session.getCurrent();
      if (!handle || !state.gitHost) return { ok: false, error: 'vault_locked' };
      const item = await vault.fetchAndDecryptItem(state.gitHost, handle, msg.id);
      if (item.core.type !== 'login' || !item.core.totp) {
        return { ok: false, error: 'no_totp' };
      }
      const now = Math.floor(Date.now() / 1000);
      const code = state.wasm.totp_compute(JSON.stringify(item.core.totp), BigInt(now));
      return { ok: true, data: { code: code.code, expires_at: code.expires_at } };
    }

    case 'sync': {
      const handle = session.getCurrent();
      if (!handle || !state.gitHost) return { ok: false, error: 'vault_locked' };
      state.manifest = await vault.fetchAndDecryptManifest(state.gitHost, handle);
      return { ok: true };
    }

    case 'get_setup_state':
      return { ok: true, data: await loadSetupState() };

    case 'save_setup': {
      await chrome.storage.local.set({
        vaultConfig: msg.config,
        imageBase64: msg.imageBase64,
      });
      state.gitHost = null;
      return { ok: true };
    }

    case 'rate_passphrase':
      return { ok: true, data: state.wasm.rate_passphrase(msg.passphrase) };

    case 'generate_password': {
      const password = state.wasm.generate_password(JSON.stringify(msg.request));
      return { ok: true, data: { password } };
    }

    case 'fill_credentials':
      return handleFillCredentials(msg, state);

    case 'ack_autofill_origin': {
      const handle = session.getCurrent();
      if (!handle || !state.gitHost) return { ok: false, error: 'vault_locked' };
      const settings = await vault.fetchAndDecryptSettings(state.gitHost, handle);
      const acks = { ...(settings.autofill_origin_acks ?? {}), [msg.hostname]: Math.floor(Date.now() / 1000) };
      const updated = { ...settings, autofill_origin_acks: acks };
      await vault.encryptAndWriteSettings(state.gitHost, handle, updated, `settings: ack origin ${msg.hostname}`);
      return { ok: true };
    }

    case 'get_settings':
      return { ok: true, data: { settings: await loadDeviceSettings() } };

    case 'update_settings': {
      const current = await loadDeviceSettings();
      await saveDeviceSettings({ ...current, ...msg.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 !== msg.hostname));
      return { ok: true };
    }
  }
}

// --- fill_credentials with captured-tab verification (audit M5) ---

async function handleFillCredentials(
  msg: Extract<PopupMessage, { type: 'fill_credentials' }>,
  state: PopupState,
): Promise<Response> {
  const handle = session.getCurrent();
  if (!handle || !state.gitHost || !state.manifest) return { ok: false, error: 'vault_locked' };

  let tab: chrome.tabs.Tab;
  try { tab = await chrome.tabs.get(msg.capturedTabId); }
  catch { return { ok: false, error: 'captured_tab_gone' }; }

  const currentHost  = safeHostname(tab.url ?? '');
  const capturedHost = safeHostname(msg.capturedUrl);
  if (!currentHost || !capturedHost || currentHost !== capturedHost) {
    return { ok: false, error: 'tab_navigated' };
  }

  const item = await vault.fetchAndDecryptItem(state.gitHost, handle, msg.id);
  if (item.core.type !== 'login') return { ok: false, error: 'not_a_login' };
  const itemHost = safeHostname(item.core.url ?? '');
  if (!itemHost || itemHost !== currentHost) return { ok: false, error: 'origin_mismatch' };

  await chrome.tabs.sendMessage(msg.capturedTabId, {
    type: 'fill_credentials',
    username: item.core.username ?? '',
    password: item.core.password ?? '',
  });
  return { ok: true };
}

// --- chrome.storage.local helpers (module-scoped so all handlers share) ---

async function loadConfig(): Promise<VaultConfig | null> {
  const r = await chrome.storage.local.get('vaultConfig');
  return (r.vaultConfig as VaultConfig) ?? null;
}

async function loadImageBase64(): Promise<string | null> {
  const r = await chrome.storage.local.get('imageBase64');
  return (r.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 loadDeviceSettings(): Promise<DeviceSettings> {
  const r = await chrome.storage.local.get('relicarioSettings');
  return (r.relicarioSettings as DeviceSettings) ?? { ...DEFAULT_DEVICE_SETTINGS };
}

async function saveDeviceSettings(s: DeviceSettings): Promise<void> {
  await chrome.storage.local.set({ relicarioSettings: s });
}

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

async function saveBlacklist(list: string[]): Promise<void> {
  await chrome.storage.local.set({ captureBlacklist: list });
}

// --- Manifest entry derivation (duplicated from index; kept here to keep handler self-contained) ---

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; }
}
  • Step 2: Commit
cd /home/alee/Sources/relicario
git add extension/src/service-worker/router/popup-only.ts
git commit -m "feat(ext/sw): router/popup-only handlers"

Task 12: Create router/content-callable.ts

Files:

  • Create: extension/src/service-worker/router/content-callable.ts

  • Step 1: Write the file

/// Content-script-callable message handlers.
///
/// Origin is always derived from sender.tab.url — never trust fields on msg.
/// Router has already verified sender.frameId === 0 (top-frame only) and
/// sender.tab !== undefined.

import type { ContentMessage, Response } from '../../shared/messages';
import type { Manifest } from '../../shared/types';
import type { GitHost } from '../git-host';
import * as vault from '../vault';
import * as session from '../session';

export interface ContentState {
  manifest: Manifest | null;
  gitHost: GitHost | null;
}

export async function handle(
  msg: ContentMessage,
  state: ContentState,
  sender: chrome.runtime.MessageSender,
): Promise<Response> {
  const senderHost = safeHostname(sender.tab?.url ?? '');
  if (!senderHost) return { ok: false, error: 'invalid_sender_url' };

  switch (msg.type) {
    case 'get_autofill_candidates': {
      if (!state.manifest) return { ok: false, error: 'vault_locked' };
      return {
        ok: true,
        data: { candidates: vault.findByHostname(state.manifest, senderHost) },
      };
    }

    case 'get_credentials': {
      const handle = session.getCurrent();
      if (!handle || !state.gitHost || !state.manifest) return { ok: false, error: 'vault_locked' };

      const item = await vault.fetchAndDecryptItem(state.gitHost, handle, msg.id);
      if (item.core.type !== 'login') return { ok: false, error: 'not_a_login' };
      const itemHost = safeHostname(item.core.url ?? '');
      if (!itemHost || itemHost !== senderHost) return { ok: false, error: 'origin_mismatch' };

      // TOFU origin-ack check (VaultSettings.autofill_origin_acks):
      const settings = await vault.fetchAndDecryptSettings(state.gitHost, handle);
      const acks = settings.autofill_origin_acks ?? {};
      if (!(senderHost in acks)) {
        return { ok: true, data: { requires_ack: true, hostname: senderHost } };
      }

      return {
        ok: true,
        data: {
          username: item.core.username ?? '',
          password: item.core.password ?? '',
        },
      };
    }

    case 'check_credential': {
      const handle = session.getCurrent();
      if (!handle || !state.gitHost || !state.manifest) {
        return { ok: true, data: { action: 'skip' } };
      }

      // Settings-gating: capture off or site blacklisted → skip.
      const captureSettings = await loadDeviceSettings();
      if (!captureSettings.captureEnabled) return { ok: true, data: { action: 'skip' } };

      const blacklist = await loadBlacklist();
      if (blacklist.includes(senderHost)) return { ok: true, data: { action: 'skip' } };

      const candidates = vault.findByHostname(state.manifest, senderHost);
      if (candidates.length === 0) return { ok: true, data: { action: 'save' } };

      for (const [itemId, entry] of candidates) {
        if (entry.type !== 'login') continue;
        const full = await vault.fetchAndDecryptItem(state.gitHost, handle, itemId);
        if (full.core.type !== 'login') continue;
        if (full.core.username === msg.username) {
          if (full.core.password === msg.password) return { ok: true, data: { action: 'skip' } };
          return { ok: true, data: { action: 'update', entryId: itemId, entryName: entry.title } };
        }
      }
      return { ok: true, data: { action: 'save' } };
    }

    case 'blacklist_site': {
      const bl = await loadBlacklist();
      if (!bl.includes(senderHost)) {
        bl.push(senderHost);
        await saveBlacklist(bl);
      }
      return { ok: true };
    }
  }
}

async function loadDeviceSettings(): Promise<{ captureEnabled: boolean; captureStyle: 'bar' | 'toast' }> {
  const r = await chrome.storage.local.get('relicarioSettings');
  return (r.relicarioSettings as { captureEnabled: boolean; captureStyle: 'bar' | 'toast' })
      ?? { captureEnabled: false, captureStyle: 'bar' };
}

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

async function saveBlacklist(list: string[]): Promise<void> {
  await chrome.storage.local.set({ captureBlacklist: list });
}

function safeHostname(url: string): string | undefined {
  try { return new URL(url).hostname; } catch { return undefined; }
}
  • Step 2: Commit
git add extension/src/service-worker/router/content-callable.ts
git commit -m "feat(ext/sw): router/content-callable handlers with origin derivation"

Task 13: Create router/index.ts dispatcher

Files:

  • Create: extension/src/service-worker/router/index.ts

  • Step 1: Write the file

/// Single chrome.runtime.onMessage entry. Classifies the sender and dispatches
/// to popup-only or content-callable handlers. Unauthorized senders are
/// rejected with { ok: false, error: 'unauthorized_sender' }.

import type { Request, Response } from '../../shared/messages';
import { POPUP_ONLY_TYPES, CONTENT_CALLABLE_TYPES } from '../../shared/messages';
import type { Manifest } from '../../shared/types';
import type { GitHost } from '../git-host';
import * as popupOnly from './popup-only';
import * as contentCallable from './content-callable';

export interface RouterState {
  manifest: Manifest | null;
  gitHost: GitHost | null;
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  wasm: any;
}

export async function route(
  msg: Request,
  state: RouterState,
  sender: chrome.runtime.MessageSender,
): Promise<Response> {
  const popupUrl = chrome.runtime.getURL('popup.html');
  const setupUrl = chrome.runtime.getURL('setup.html');
  const senderUrl = sender.url ?? '';

  const isPopup   = senderUrl === popupUrl;
  const isSetup   = senderUrl.startsWith(setupUrl);
  const isContent = sender.tab !== undefined
                  && sender.frameId === 0
                  && sender.id === chrome.runtime.id;

  if (POPUP_ONLY_TYPES.has(msg.type as never)) {
    // save_setup gets one exception: allowed from the setup tab too.
    if (!(isPopup || (msg.type === 'save_setup' && isSetup))) {
      return { ok: false, error: 'unauthorized_sender' };
    }
    return popupOnly.handle(msg as never, state, sender);
  }

  if (CONTENT_CALLABLE_TYPES.has(msg.type as never)) {
    if (!isContent) return { ok: false, error: 'unauthorized_sender' };
    return contentCallable.handle(msg as never, state, sender);
  }

  return { ok: false, error: 'unknown_message_type' };
}
  • Step 2: Commit
git add extension/src/service-worker/router/index.ts
git commit -m "feat(ext/sw): router index with sender-based dispatch"

Task 14: Collapse service-worker/index.ts onto the router

Files:

  • Modify: extension/src/service-worker/index.ts

  • Step 1: Replace the file

/// Thin service-worker entry: loads WASM, constructs the router state, and
/// forwards every message into router/index.route().

import type { Request, Response } from '../shared/messages';
import type { RouterState } from './router/index';
import { route } from './router/index';
import * as vault from './vault';

// @ts-ignore TS2307 — resolved by webpack alias / copy
import initDefault, { initSync } from '../../wasm/relicario_wasm.js';
// @ts-ignore TS2307
import * as wasmBindings from '../../wasm/relicario_wasm.js';

type WasmModule = typeof wasmBindings;
let wasm: WasmModule | null = null;

async function initWasm(): Promise<WasmModule> {
  if (wasm) return wasm;

  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  const SWGlobalScope = (globalThis as any).ServiceWorkerGlobalScope as (new () => ServiceWorker) | undefined;
  const isServiceWorker = typeof SWGlobalScope !== 'undefined'
    && self instanceof (SWGlobalScope as unknown as typeof EventTarget);

  if (isServiceWorker) {
    const wasmResponse = await fetch(chrome.runtime.getURL('relicario_wasm_bg.wasm'));
    const wasmBytes = await wasmResponse.arrayBuffer();
    initSync({ module: new WebAssembly.Module(wasmBytes) });
  } else {
    const wasmUrl = chrome.runtime.getURL('relicario_wasm_bg.wasm');
    await initDefault(wasmUrl);
  }

  vault.setWasm(wasmBindings);
  wasm = wasmBindings;
  return wasm;
}

// Single router-state object shared by all messages for this SW instance.
const state: RouterState = {
  manifest: null,
  gitHost: null,
  wasm: null,
};

chrome.runtime.onMessage.addListener(
  (request: Request, sender: chrome.runtime.MessageSender, sendResponse: (r: Response) => void) => {
    (async () => {
      if (!state.wasm) state.wasm = await initWasm();
      return route(request, state, sender);
    })()
      .then(sendResponse)
      .catch((err: Error) => sendResponse({ ok: false, error: err.message }));
    return true; // async response
  },
);
  • Step 2: Verify both builds pass
cd /home/alee/Sources/relicario/extension
bun run build 2>&1 | tail -15
bun run build:firefox 2>&1 | tail -15

Expected: both succeed. The popup/content callers compiled in slice 3 remain under their @ts-nocheck shields until slice 6.

  • Step 3: Commit
git add extension/src/service-worker/index.ts
git commit -m "feat(ext/sw): collapse flat index onto router"

Task 15: Wire Vitest + router test suite

Files:

  • Modify: extension/package.json

  • Create: extension/vitest.config.ts

  • Create: extension/src/service-worker/router/__tests__/router.test.ts

  • Step 1: Add Vitest to devDependencies

cd /home/alee/Sources/relicario/extension
bun add -d vitest@^2.0 happy-dom@^15
  • Step 2: Add test script to package.json

In extension/package.json, add under scripts:

"test": "vitest run",
"test:watch": "vitest"
  • Step 3: Create vitest.config.ts
import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    environment: 'happy-dom',
    include: ['src/**/__tests__/**/*.test.ts'],
  },
});
  • Step 4: Write router.test.ts
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { route, type RouterState } from '../index';
import type { Request } from '../../../shared/messages';

// --- chrome.* shim ---

// @ts-expect-error test harness
globalThis.chrome = {
  runtime: {
    id: 'relicario-test-id',
    getURL: (p: string) => `chrome-extension://relicario-test-id/${p}`,
  },
  storage: { local: { get: vi.fn().mockResolvedValue({}), set: vi.fn().mockResolvedValue(undefined) } },
  tabs: { get: vi.fn(), sendMessage: vi.fn() },
};

function makePopupSender(): chrome.runtime.MessageSender {
  return { url: `chrome-extension://relicario-test-id/popup.html`, id: 'relicario-test-id' };
}

function makeSetupSender(): chrome.runtime.MessageSender {
  return { url: `chrome-extension://relicario-test-id/setup.html`, id: 'relicario-test-id' };
}

function makeContentSender(pageUrl = 'https://example.com/'): chrome.runtime.MessageSender {
  return {
    tab: { id: 42, url: pageUrl } as chrome.tabs.Tab,
    frameId: 0,
    id: 'relicario-test-id',
  };
}

function makeExternalSender(): chrome.runtime.MessageSender {
  return { url: 'https://evil.example/', id: 'some-other-extension' };
}

function makeState(): RouterState {
  return {
    manifest: { schema_version: 2, items: {} },
    gitHost: null,
    wasm: {
      // Stubs sufficient for the message types exercised by tests:
      new_item_id: () => 'fakeitemid0000ab',
      generate_password: () => 'PASSWORD',
      rate_passphrase: () => ({ score: 4, guesses_log10: 15 }),
    },
  };
}

// --- Sender-check matrix ---

describe('router sender dispatch', () => {
  let state: RouterState;
  beforeEach(() => { state = makeState(); });

  const popupOnlyMsgs: Request[] = [
    { type: 'is_unlocked' },
    { type: 'lock' },
    { type: 'list_items' },
    { type: 'generate_password', request: { kind: 'random', length: 20, classes: { lower: true, upper: true, digits: true, symbols: true }, symbol_charset: { kind: 'safe_only' } } },
    { type: 'rate_passphrase', passphrase: 'hunter2hunter2hunter2' },
    { type: 'get_blacklist' },
  ];

  for (const msg of popupOnlyMsgs) {
    it(`accepts popup-only "${msg.type}" from popup`, async () => {
      const res = await route(msg, state, makePopupSender());
      expect(res).toMatchObject({ ok: true });
    });
    it(`rejects popup-only "${msg.type}" from content`, async () => {
      const res = await route(msg, state, makeContentSender());
      expect(res).toEqual({ ok: false, error: 'unauthorized_sender' });
    });
    it(`rejects popup-only "${msg.type}" from external`, async () => {
      const res = await route(msg, state, makeExternalSender());
      expect(res).toEqual({ ok: false, error: 'unauthorized_sender' });
    });
  }

  it('accepts save_setup from popup', async () => {
    const msg: Request = { type: 'save_setup', config: { hostType: 'github', hostUrl: '', repoPath: '', apiToken: '' }, imageBase64: '' };
    const res = await route(msg, state, makePopupSender());
    expect(res).toMatchObject({ ok: true });
  });

  it('accepts save_setup from setup tab', async () => {
    const msg: Request = { type: 'save_setup', config: { hostType: 'github', hostUrl: '', repoPath: '', apiToken: '' }, imageBase64: '' };
    const res = await route(msg, state, makeSetupSender());
    expect(res).toMatchObject({ ok: true });
  });

  it('rejects save_setup from content', async () => {
    const msg: Request = { type: 'save_setup', config: { hostType: 'github', hostUrl: '', repoPath: '', apiToken: '' }, imageBase64: '' };
    const res = await route(msg, state, makeContentSender());
    expect(res).toEqual({ ok: false, error: 'unauthorized_sender' });
  });

  const contentMsgs: Request[] = [
    { type: 'get_autofill_candidates' },
    { type: 'blacklist_site' },
  ];

  for (const msg of contentMsgs) {
    it(`accepts content "${msg.type}" from top-frame content`, async () => {
      const res = await route(msg, state, makeContentSender());
      expect(res.ok).toBe(true);
    });
    it(`rejects content "${msg.type}" from popup`, async () => {
      const res = await route(msg, state, makePopupSender());
      expect(res).toEqual({ ok: false, error: 'unauthorized_sender' });
    });
    it(`rejects content "${msg.type}" from subframe`, async () => {
      const sender: chrome.runtime.MessageSender = { ...makeContentSender(), frameId: 3 };
      const res = await route(msg, state, sender);
      expect(res).toEqual({ ok: false, error: 'unauthorized_sender' });
    });
    it(`rejects content "${msg.type}" from external`, async () => {
      const res = await route(msg, state, makeExternalSender());
      expect(res).toEqual({ ok: false, error: 'unauthorized_sender' });
    });
  }

  it('rejects unknown message type', async () => {
    // @ts-expect-error intentional invalid type
    const res = await route({ type: 'nonsense' }, state, makePopupSender());
    expect(res).toEqual({ ok: false, error: 'unknown_message_type' });
  });
});

// --- Origin-bound autofill ---

describe('get_autofill_candidates uses sender.tab.url', () => {
  it('derives hostname from sender, not message', async () => {
    const state: RouterState = makeState();
    state.manifest = {
      schema_version: 2,
      items: {
        'aaaaaaaaaaaaaaaa': {
          id: 'aaaaaaaaaaaaaaaa', type: 'login', title: 'GitHub',
          tags: [], favorite: false, icon_hint: 'github.com',
          modified: 0, attachment_summaries: [],
        },
        'bbbbbbbbbbbbbbbb': {
          id: 'bbbbbbbbbbbbbbbb', type: 'login', title: 'Example',
          tags: [], favorite: false, icon_hint: 'example.com',
          modified: 0, attachment_summaries: [],
        },
      },
    };
    const res = await route(
      { type: 'get_autofill_candidates' },
      state,
      makeContentSender('https://example.com/login'),
    );
    expect(res.ok).toBe(true);
    if (res.ok) {
      const data = res.data as { candidates: Array<[string, { title: string }]> };
      expect(data.candidates).toHaveLength(1);
      expect(data.candidates[0][1].title).toBe('Example');
    }
  });
});
  • Step 5: Run the router tests
cd /home/alee/Sources/relicario/extension
bun run test 2>&1 | tail -30

Expected: all tests pass. base32 tests (Task 6) also run in this pass.

  • Step 6: Commit
git add extension/package.json extension/bun.lock extension/vitest.config.ts extension/src/service-worker/router/__tests__/router.test.ts
git commit -m "test(ext): vitest + router sender-check + origin-bound autofill"

Slice 5 — Security hardening

Goal: the remaining security items — manifest WAR cleanup, setup.html opened via chrome.tabs.create (not WAR), closed Shadow DOM in content scripts, popup captured-tab snapshot on init.

Task 16: Manifest WAR cleanup for Chrome + Firefox

Files:

  • Modify: extension/manifest.json

  • Modify: extension/manifest.firefox.json

  • Step 1: Edit Chrome manifest

Replace web_accessible_resources with the minimal set. If styles.css isn't used from pages (check content/*.ts for any chrome.runtime.getURL('styles.css')), drop it entirely.

cd /home/alee/Sources/relicario
grep -rn "styles.css" extension/src/

If no hits from content scripts, target shape:

"web_accessible_resources": []

Or omit the field. For Chrome MV3 an empty array is permitted but the field can be removed entirely. Test both — some versions of chrome prefer absence.

Edit extension/manifest.json: change the web_accessible_resources block to:

"web_accessible_resources": []
  • Step 2: Edit Firefox manifest the same way

In extension/manifest.firefox.json:

"web_accessible_resources": []
  • Step 3: Commit
git add extension/manifest.json extension/manifest.firefox.json
git commit -m "feat(ext): drop setup.html / wasm from web_accessible_resources (audit C1)"

Task 17: Popup + setup open via chrome.tabs.create

Files:

  • Modify: extension/src/popup/popup.ts

This is only relevant if the popup currently navigates to chrome.runtime.getURL('setup.html'). Today it does via setup-wizard.ts. Change it to open a new tab instead.

  • Step 1: Locate the current setup navigation
cd /home/alee/Sources/relicario
grep -rn "setup.html\|setup-wizard" extension/src/popup/ | head -10
  • Step 2: Update popup.ts init to open setup in a tab when not configured

Find the init() function in extension/src/popup/popup.ts. Replace the branch if (!data.isConfigured) { navigate('setup'); return; } with:

if (!data.isConfigured) {
  await chrome.tabs.create({ url: chrome.runtime.getURL('setup.html') });
  window.close();
  return;
}
  • Step 3: Remove the setup view from the popup state machine

Search for all navigate('setup') calls in extension/src/popup/components/*.ts and replace each with:

await chrome.tabs.create({ url: chrome.runtime.getURL('setup.html') });
window.close();

Remove 'setup' from the View union in popup.ts:

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

Remove the case 'setup': branch from render().

Delete the file extension/src/popup/components/setup-wizard.ts (it referenced the old Entry type; the standalone setup flow in src/setup/setup.ts handles all setup UX now).

  • Step 4: Rebuild
cd extension
bun run build 2>&1 | tail -10

Expected: build passes. If setup-wizard.ts is still referenced anywhere, remove the import.

  • Step 5: Commit
cd /home/alee/Sources/relicario
git add -A extension/src/popup/
git commit -m "feat(ext/popup): open setup via chrome.tabs.create, drop setup view from popup"

Task 18: Closed Shadow DOM helper + content-script rewrite (capture)

Files:

  • Create: extension/src/content/shadow.ts

  • Modify: extension/src/content/capture.ts

  • Step 1: Create the shadow host helper

// extension/src/content/shadow.ts
/// Creates a closed Shadow DOM host attached to document.body.
/// Page JS cannot read host.shadowRoot (it's null from outside) and our
/// rendered DOM has no stable IDs.

export interface ShadowSurface {
  host: HTMLElement;
  root: ShadowRoot;
  destroy: () => void;
}

export function createShadowHost(): ShadowSurface {
  const host = document.createElement('div');
  host.dataset.rel = '';                    // no identifying class/id
  document.body.appendChild(host);
  const root = host.attachShadow({ mode: 'closed' });
  return { host, root, destroy: () => host.remove() };
}
  • Step 2: Rewrite content/capture.ts to use the shadow host + textContent

Replace the entire file:

/// Credential capture: hook form submissions; if the submitted credentials
/// aren't already in the vault (or differ), prompt the user to save/update.
///
/// All page UI lives inside a closed Shadow DOM with textContent-only DOM
/// construction. No stable IDs, no innerHTML.

import type { Request, Response } from '../shared/messages';
import type { DeviceSettings } from '../shared/types';
import { createShadowHost } from './shadow';

const hookedForms = new WeakSet<HTMLFormElement>();
const hookedButtons = new WeakSet<HTMLElement>();

function sendMessage(request: Request): Promise<Response> {
  return new Promise((resolve) => chrome.runtime.sendMessage(request, (r: Response) => resolve(r)));
}

/// Bounded, control-char-free username scrape.
function findUsernameValue(pwField: HTMLInputElement): string {
  const form = pwField.closest('form');
  const scope = form ?? document;
  const inputs = scope.querySelectorAll<HTMLInputElement>('input');

  for (const input of inputs) {
    if (input === pwField) continue;
    if (input.autocomplete === 'username' && input.value) return sanitize(input.value);
  }
  for (const input of inputs) {
    if (input === pwField) continue;
    if (input.autocomplete === 'email' && input.value) return sanitize(input.value);
  }
  for (const input of inputs) {
    if (input === pwField) continue;
    if (input.type === 'email' && input.value) return sanitize(input.value);
  }
  const pattern = /user|email|login|account/i;
  for (const input of inputs) {
    if (input === pwField) continue;
    if (input.type === 'hidden' || input.type === 'password') continue;
    if ((pattern.test(input.name) || pattern.test(input.id)) && input.value) return sanitize(input.value);
  }
  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 sanitize(input.value);
  }
  return '';
}

function sanitize(s: string): string {
  return s.replace(/\p{Cc}/gu, '').slice(0, 256);
}

let currentPrompt: ShadowSurface | null = null;

async function onFormSubmit(pwField: HTMLInputElement): Promise<void> {
  const password = pwField.value;
  if (!password) return;

  const username = findUsernameValue(pwField);
  const resp = await sendMessage({ type: 'check_credential', username, password });
  if (!resp.ok) return;

  const data = resp.data as { action: 'skip' | 'save' | 'update'; entryId?: string; entryName?: string };
  if (data.action === 'skip') return;

  const settingsResp = await sendMessage({ type: 'get_settings' });
  const settings: DeviceSettings = settingsResp.ok
    ? (settingsResp.data as { settings: DeviceSettings }).settings
    : { captureEnabled: true, captureStyle: 'bar' };

  showPrompt(settings.captureStyle, data.action, username, password, data.entryId);
}

type ShadowSurface = ReturnType<typeof createShadowHost>;

function removeExistingPrompt(): void {
  currentPrompt?.destroy();
  currentPrompt = null;
}

function showPrompt(
  style: 'bar' | 'toast',
  action: 'save' | 'update',
  username: string,
  password: string,
  entryId?: string,
): void {
  removeExistingPrompt();
  currentPrompt = createShadowHost();
  const { root, host } = currentPrompt;

  // Style the host itself (positioning) so page CSS can't override via specificity.
  const positioning = style === 'bar'
    ? 'position:fixed;top:0;left:0;right:0;z-index:2147483647;transform:translateY(-100%);transition:transform .3s;'
    : 'position:fixed;bottom:16px;right:16px;z-index:2147483647;opacity:0;transition:opacity .3s;';
  host.setAttribute('style', positioning);

  // Shadow root style sheet — single static literal, no injected values.
  const style0 = document.createElement('style');
  style0.textContent = `
    .box { font-family: system-ui, sans-serif; font-size: 13px; color: #c9d1d9;
           background: #161b22; padding: 10px 16px; display: flex; align-items: center;
           gap: 12px; border: 1px solid #30363d; border-radius: 4px;
           box-shadow: 0 4px 12px rgba(0,0,0,0.4); line-height: 1.4; }
    .host { color: #58a6ff; font-weight: 600; }
    .btn-primary { background: #1f6feb; color: #fff; border: none; padding: 5px 14px;
                   border-radius: 3px; cursor: pointer; font: inherit; }
    .btn-secondary { background: transparent; color: #8b949e; border: 1px solid #30363d;
                     padding: 5px 10px; border-radius: 3px; cursor: pointer; font: inherit; }
    .btn-close { background: transparent; color: #8b949e; border: none; cursor: pointer;
                 padding: 2px 6px; font: inherit; }
    .msg { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
  `;
  root.appendChild(style0);

  const box = document.createElement('div');
  box.className = 'box';

  const msg = document.createElement('span');
  msg.className = 'msg';
  const actionLabel = action === 'update' ? 'Update' : 'Save';
  const hostname = window.location.hostname;
  msg.appendChild(document.createTextNode(`${actionLabel} login for `));
  const hostSpan = document.createElement('span');
  hostSpan.className = 'host';
  hostSpan.textContent = hostname;
  msg.appendChild(hostSpan);
  if (username) msg.appendChild(document.createTextNode(` (${username})?`));
  else msg.appendChild(document.createTextNode('?'));

  const saveBtn = document.createElement('button');
  saveBtn.className = 'btn-primary';
  saveBtn.textContent = actionLabel;

  const neverBtn = document.createElement('button');
  neverBtn.className = 'btn-secondary';
  neverBtn.textContent = 'Never';

  const closeBtn = document.createElement('button');
  closeBtn.className = 'btn-close';
  closeBtn.textContent = '✕';

  box.appendChild(msg);
  box.appendChild(saveBtn);
  box.appendChild(neverBtn);
  box.appendChild(closeBtn);
  root.appendChild(box);

  // Animate in
  requestAnimationFrame(() => {
    if (style === 'bar') host.style.transform = 'translateY(0)';
    else                  host.style.opacity = '1';
  });

  let autoDismissTimer: ReturnType<typeof setTimeout> | null = null;
  if (style === 'toast') {
    autoDismissTimer = setTimeout(() => removeExistingPrompt(), 15_000);
  }
  const clearAutoDismiss = (): void => { if (autoDismissTimer) clearTimeout(autoDismissTimer); };

  saveBtn.addEventListener('click', async () => {
    clearAutoDismiss();
    const now = Math.floor(Date.now() / 1000);
    const item = {
      id: '', title: hostname, type: 'login' as const, tags: [], favorite: false,
      created: now, modified: now,
      core: { type: 'login' as const, username, password, url: window.location.origin },
      sections: [], attachments: [], field_history: {},
    };
    if (action === 'update' && entryId) {
      await sendMessage({ type: 'update_item', id: entryId, item });
    } else {
      await sendMessage({ type: 'add_item', item });
    }
    msg.textContent = '✓ Saved';
    saveBtn.style.display = 'none';
    neverBtn.style.display = 'none';
    setTimeout(() => removeExistingPrompt(), 1500);
  });

  neverBtn.addEventListener('click', async () => {
    clearAutoDismiss();
    await sendMessage({ type: 'blacklist_site' });
    removeExistingPrompt();
  });

  closeBtn.addEventListener('click', () => {
    clearAutoDismiss();
    removeExistingPrompt();
  });
}

export function hookForms(): void {
  const passwordFields = document.querySelectorAll<HTMLInputElement>('input[type="password"]');
  for (const pwField of passwordFields) {
    if (pwField.offsetWidth < 20 || pwField.offsetHeight < 10) continue;
    const form = pwField.closest('form');
    if (form && !hookedForms.has(form)) {
      hookedForms.add(form);
      form.addEventListener('submit', () => { onFormSubmit(pwField); });
    }
    const scope = form ?? pwField.parentElement;
    if (!scope) continue;
    const buttons = scope.querySelectorAll<HTMLElement>('button[type="submit"], input[type="submit"], button:not([type])');
    for (const btn of buttons) {
      if (hookedButtons.has(btn)) continue;
      hookedButtons.add(btn);
      btn.addEventListener('click', () => { onFormSubmit(pwField); });
    }
  }
}
  • Step 3: Commit
cd /home/alee/Sources/relicario
git add extension/src/content/shadow.ts extension/src/content/capture.ts
git commit -m "feat(ext/content): closed Shadow DOM + textContent for capture prompt"

Task 19: Closed Shadow DOM for icon + picker

Files:

  • Modify: extension/src/content/icon.ts

  • Step 1: Replace the file

/// Inject a small "id" icon next to password fields. Clicking it queries for
/// autofill candidates and either fills or shows a picker — both rendered in
/// closed Shadow DOMs attached to document.body (not as page-DOM children).

import type { ManifestEntry } from '../shared/types';
import { createShadowHost } from './shadow';

const injected = new WeakSet<HTMLInputElement>();

export function injectFieldIcons(
  passwordField: HTMLInputElement,
  _usernameField: HTMLInputElement | null,
): void {
  if (injected.has(passwordField)) return;
  injected.add(passwordField);

  const iconSurface = createShadowHost();
  // Position the host over the password field.
  const rect = passwordField.getBoundingClientRect();
  Object.assign(iconSurface.host.style, {
    position: 'fixed',
    top: `${rect.top + rect.height / 2 - 10}px`,
    left: `${rect.right - 28}px`,
    width: '20px',
    height: '20px',
    zIndex: '2147483647',
  });

  const style = document.createElement('style');
  style.textContent = `
    .i { width: 20px; height: 20px; line-height: 20px; text-align: center;
         font: 700 10px monospace; color: #fff; background: #1f6feb;
         border-radius: 3px; cursor: pointer; user-select: none; }
  `;
  iconSurface.root.appendChild(style);

  const icon = document.createElement('div');
  icon.className = 'i';
  icon.textContent = 'id';
  iconSurface.root.appendChild(icon);

  // Keep icon positioned across scroll/resize.
  const reposition = (): void => {
    const r = passwordField.getBoundingClientRect();
    iconSurface.host.style.top  = `${r.top + r.height / 2 - 10}px`;
    iconSurface.host.style.left = `${r.right - 28}px`;
  };
  window.addEventListener('scroll', reposition, true);
  window.addEventListener('resize', reposition);

  icon.addEventListener('click', async (e) => {
    e.preventDefault();
    e.stopPropagation();
    const resp = await chrome.runtime.sendMessage({ type: 'get_autofill_candidates' });
    if (!resp || !resp.ok) return;
    const candidates = resp.data.candidates as Array<[string, ManifestEntry]>;
    if (candidates.length === 0) return;
    if (candidates.length === 1) {
      const [id] = candidates[0];
      // Popup-captured tab logic doesn't apply here — this is a direct in-page click;
      // the SW enforces origin check via sender.tab.url on get_credentials.
      const credResp = await chrome.runtime.sendMessage({ type: 'get_credentials', id });
      if (credResp?.ok) {
        const d = credResp.data as { requires_ack?: true; hostname?: string; username?: string; password?: string };
        if (d.requires_ack) {
          showAckMessage(iconSurface.host, d.hostname ?? window.location.hostname);
          return;
        }
        chrome.runtime.sendMessage({
          type: 'fill_credentials',
          username: d.username,
          password: d.password,
        });
      }
    } else {
      showPicker(iconSurface.host, candidates);
    }
  });
}

function showPicker(anchor: HTMLElement, candidates: Array<[string, ManifestEntry]>): void {
  // Reuse the anchor's shadow host by mounting a dropdown below it.
  const pickerSurface = createShadowHost();
  const r = anchor.getBoundingClientRect();
  Object.assign(pickerSurface.host.style, {
    position: 'fixed', top: `${r.bottom + 4}px`, left: `${r.right - 200}px`,
    zIndex: '2147483647',
  });

  const style = document.createElement('style');
  style.textContent = `
    .p { background:#161b22;border:1px solid #30363d;border-radius:6px;
         box-shadow:0 4px 12px rgba(0,0,0,.4);min-width:200px;max-height:200px;
         overflow-y:auto;font-family:system-ui,sans-serif;font-size:12px;color:#c9d1d9; }
    .row { padding:8px 12px;cursor:pointer;border-bottom:1px solid #21262d; }
    .row:hover { background:#21262d; }
  `;
  pickerSurface.root.appendChild(style);

  const container = document.createElement('div');
  container.className = 'p';
  pickerSurface.root.appendChild(container);

  for (const [id, entry] of candidates) {
    const row = document.createElement('div');
    row.className = 'row';
    row.textContent = entry.title;   // title only; could append group/tags later
    row.addEventListener('click', async (e) => {
      e.stopPropagation();
      pickerSurface.destroy();
      const credResp = await chrome.runtime.sendMessage({ type: 'get_credentials', id });
      if (credResp?.ok) {
        const d = credResp.data as { requires_ack?: true; hostname?: string; username?: string; password?: string };
        if (d.requires_ack) {
          showAckMessage(anchor, d.hostname ?? window.location.hostname);
          return;
        }
        chrome.runtime.sendMessage({ type: 'fill_credentials', username: d.username, password: d.password });
      }
    });
    container.appendChild(row);
  }

  const closeOnOutside = (): void => {
    pickerSurface.destroy();
    document.removeEventListener('click', closeOnOutside, true);
  };
  setTimeout(() => document.addEventListener('click', closeOnOutside, true), 0);
}

function showAckMessage(anchor: HTMLElement, hostname: string): void {
  const surface = createShadowHost();
  const r = anchor.getBoundingClientRect();
  Object.assign(surface.host.style, {
    position: 'fixed', top: `${r.bottom + 4}px`, left: `${r.right - 240}px`,
    zIndex: '2147483647',
  });
  const style = document.createElement('style');
  style.textContent = `
    .box { background:#161b22;border:1px solid #30363d;border-radius:6px;
           padding:10px 12px;color:#c9d1d9;font-family:system-ui,sans-serif;
           font-size:12px;max-width:240px;box-shadow:0 4px 12px rgba(0,0,0,.4); }
    .host { color:#58a6ff; }
  `;
  surface.root.appendChild(style);
  const box = document.createElement('div');
  box.className = 'box';
  const line1 = document.createElement('div');
  line1.appendChild(document.createTextNode('First autofill on '));
  const hostSpan = document.createElement('span');
  hostSpan.className = 'host';
  hostSpan.textContent = hostname;
  line1.appendChild(hostSpan);
  const line2 = document.createElement('div');
  line2.textContent = 'Open the relicario popup to confirm, then click again.';
  box.appendChild(line1);
  box.appendChild(line2);
  surface.root.appendChild(box);
  setTimeout(() => surface.destroy(), 5000);
}

Note this task edits icon.ts to include the TOFU requires_ack check surface. It also removes the old dependence on url passed from content → SW for get_autofill_candidates (the SW now derives it).

  • Step 2: Rebuild
cd /home/alee/Sources/relicario/extension
bun run build 2>&1 | tail -10

Expected: build passes. Content/detector.ts and content/fill.ts are untouched.

  • Step 3: Commit
cd /home/alee/Sources/relicario
git add extension/src/content/icon.ts
git commit -m "feat(ext/content): closed Shadow DOM for autofill icon + picker + ack hint"

Task 20: Popup captured-tab snapshot on init

Files:

  • Modify: extension/src/popup/popup.ts

  • Step 1: Add captured-tab fields to PopupState

At the top of popup.ts, extend the state type:

export interface PopupState {
  view: View;
  entries: Array<[string, import('../shared/types').ManifestEntry]>;
  selectedId: string | null;
  selectedItem: import('../shared/types').Item | null;
  selectedIndex: number;
  searchQuery: string;
  activeGroup: string | null;
  error: string | null;
  loading: boolean;
  capturedTabId: number | null;
  capturedUrl: string;
}

Update the initializer and setState/navigate to preserve these.

  • Step 2: Snapshot the active tab at init

In init() after the setup-state check, before the unlock branch, add:

const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
currentState.capturedTabId = tab?.id ?? null;
currentState.capturedUrl   = tab?.url ?? '';
  • Step 3: Commit
git add extension/src/popup/popup.ts
git commit -m "feat(ext/popup): snapshot activeTab at popup-open for fill_credentials (audit M5)"

Slice 6 — Login-parity popup + zxcvbn setup + Firefox verification

Goal: delete the @ts-nocheck shields, rewire the popup components onto Item::Login, add the zxcvbn strength meter to the setup wizard, verify both Chrome and Firefox.

Task 21: Rename popup components entry-*item-*

Files:

  • Move: extension/src/popup/components/entry-list.tsitem-list.ts

  • Move: extension/src/popup/components/entry-detail.tsitem-detail.ts

  • Move: extension/src/popup/components/entry-form.tsitem-form.ts

  • Step 1: Rename the files (preserving git history)

cd /home/alee/Sources/relicario
git mv extension/src/popup/components/entry-list.ts   extension/src/popup/components/item-list.ts
git mv extension/src/popup/components/entry-detail.ts extension/src/popup/components/item-detail.ts
git mv extension/src/popup/components/entry-form.ts   extension/src/popup/components/item-form.ts
  • Step 2: Update imports in popup.ts

Replace the three imports:

import { renderItemList }   from './components/item-list';
import { renderItemDetail } from './components/item-detail';
import { renderItemForm }   from './components/item-form';

And the three case 'list' / 'detail' / 'add'|'edit' render calls.

  • Step 3: Commit the rename only (no content changes yet)
git commit -m "refactor(ext/popup): rename entry-* → item-* components"

Task 22: Rewrite item-list.ts for ManifestEntry v2

Files:

  • Modify: extension/src/popup/components/item-list.ts

  • Step 1: Replace the file body with the v2 list renderer

Locate the file and replace its content. The new list view should:

  • Read state.entries: Array<[ItemId, ManifestEntry]> (already populated in popup.ts:init() via list_items).
  • Render each row with: type-icon glyph, title, group (if set), tags inline, a ★ for favorites.
  • Filter by state.searchQuery (case-insensitive against title + tags).
  • Clicking a row dispatches sendMessage({ type: 'get_item', id }) and navigates to detail.
  • Add a "New…" button that navigates to add (defaulting to login type for α).

Target structure (abbreviated — fill in HTML/CSS matching existing styles.css classes):

import { getState, setState, sendMessage, navigate, escapeHtml } from '../popup';
import type { ManifestEntry } from '../../shared/types';

export async function renderItemList(app: HTMLElement): Promise<void> {
  const state = getState();
  const q = state.searchQuery.toLowerCase();
  const filtered = state.entries.filter(([, e]) =>
    e.title.toLowerCase().includes(q) || e.tags.some((t) => t.toLowerCase().includes(q))
  );

  app.innerHTML = `
    <div class="pad">
      <div class="toolbar">
        <input id="search" class="input" placeholder="search..." value="${escapeHtml(state.searchQuery)}" />
        <button id="new-btn" class="btn">+ New</button>
        <button id="sync-btn" class="btn">sync</button>
        <button id="lock-btn" class="btn">lock</button>
        <button id="settings-btn" class="btn">⚙</button>
      </div>
      <ul class="entry-list">
        ${filtered.map(([id, e]) => row(id, e)).join('')}
      </ul>
    </div>
  `;

  document.getElementById('search')?.addEventListener('input', (ev) => {
    setState({ searchQuery: (ev.target as HTMLInputElement).value });
  });
  document.getElementById('new-btn')?.addEventListener('click', () => navigate('add'));
  document.getElementById('sync-btn')?.addEventListener('click', async () => {
    await sendMessage({ type: 'sync' });
    const res = await sendMessage({ type: 'list_items' });
    if (res.ok) setState({ entries: (res.data as { items: Array<[string, ManifestEntry]> }).items });
  });
  document.getElementById('lock-btn')?.addEventListener('click', async () => {
    await sendMessage({ type: 'lock' });
    navigate('locked');
  });
  document.getElementById('settings-btn')?.addEventListener('click', () => navigate('settings'));

  for (const [id] of filtered) {
    document.querySelector(`[data-id="${id}"]`)?.addEventListener('click', async () => {
      const res = await sendMessage({ type: 'get_item', id });
      if (res.ok) {
        const item = (res.data as { item: import('../../shared/types').Item }).item;
        setState({ selectedId: id, selectedItem: item });
        navigate('detail');
      }
    });
  }
}

function row(id: string, e: ManifestEntry): string {
  const icon = iconFor(e.type);
  const star = e.favorite ? '★' : '';
  const tags = e.tags.length > 0 ? `<span class="tags">${e.tags.map(escapeHtml).join(' ')}</span>` : '';
  return `
    <li class="entry-row" data-id="${escapeHtml(id)}">
      <span class="icon">${icon}</span>
      <span class="title">${escapeHtml(e.title)}</span>
      ${e.group ? `<span class="group">${escapeHtml(e.group)}</span>` : ''}
      ${tags}
      <span class="star">${star}</span>
    </li>
  `;
}

function iconFor(t: string): string {
  switch (t) {
    case 'login':       return '🔑';
    case 'secure_note': return '📝';
    case 'identity':    return '🪪';
    case 'card':        return '💳';
    case 'key':         return '🗝';
    case 'document':    return '📄';
    case 'totp':        return '⏱';
    default:            return '◇';
  }
}

Remove any @ts-nocheck comment that was added earlier.

  • Step 2: Commit
git add extension/src/popup/components/item-list.ts extension/src/popup/popup.ts
git commit -m "feat(ext/popup): typed-item list view"

Task 23: Rewrite item-detail.ts — Login detail + coming-soon for others

Files:

  • Modify: extension/src/popup/components/item-detail.ts

  • Step 1: Replace the file

import { getState, setState, sendMessage, navigate, escapeHtml } from '../popup';
import type { Item } from '../../shared/types';
import { base32Encode } from '../../shared/base32';

export async function renderItemDetail(app: HTMLElement): Promise<void> {
  const state = getState();
  const item = state.selectedItem;
  if (!item) { navigate('list'); return; }

  switch (item.type) {
    case 'login':       renderLogin(app, item); return;
    case 'secure_note':
    case 'identity':
    case 'card':
    case 'key':
    case 'document':
    case 'totp':        renderComingSoon(app, item); return;
  }
}

function renderLogin(app: HTMLElement, item: Item): void {
  if (item.core.type !== 'login') return;
  const { username, password, url, totp } = item.core;

  app.innerHTML = `
    <div class="pad">
      <div class="detail-title">${escapeHtml(item.title)}</div>
      <div class="detail-row"><span class="label">username</span> <span id="d-user">${escapeHtml(username ?? '')}</span> <button class="btn" id="copy-user">copy</button></div>
      <div class="detail-row"><span class="label">password</span> <span id="d-pass">••••••</span> <button class="btn" id="reveal-pass">show</button> <button class="btn" id="copy-pass">copy</button></div>
      ${url ? `<div class="detail-row"><span class="label">url</span> <a href="${escapeHtml(url)}" target="_blank" rel="noopener noreferrer">${escapeHtml(url)}</a></div>` : ''}
      ${totp ? `<div class="detail-row"><span class="label">totp</span> <span id="d-totp">…</span> <span id="d-totp-exp">…</span></div>` : ''}
      ${item.notes ? `<div class="detail-row"><span class="label">notes</span> <span>${escapeHtml(item.notes)}</span></div>` : ''}
      <div class="toolbar">
        <button class="btn" id="fill-btn">autofill</button>
        <button class="btn" id="edit-btn">edit</button>
        <button class="btn danger" id="del-btn">trash</button>
        <button class="btn" id="back-btn">back</button>
      </div>
    </div>
  `;

  let revealed = false;
  document.getElementById('reveal-pass')?.addEventListener('click', () => {
    revealed = !revealed;
    const d = document.getElementById('d-pass')!;
    d.textContent = revealed ? (password ?? '') : '••••••';
    (document.getElementById('reveal-pass') as HTMLButtonElement).textContent = revealed ? 'hide' : 'show';
  });
  document.getElementById('copy-user')?.addEventListener('click', () => navigator.clipboard.writeText(username ?? ''));
  document.getElementById('copy-pass')?.addEventListener('click', () => navigator.clipboard.writeText(password ?? ''));
  document.getElementById('back-btn')?.addEventListener('click', () => navigate('list'));
  document.getElementById('edit-btn')?.addEventListener('click', () => navigate('edit'));
  document.getElementById('del-btn')?.addEventListener('click', async () => {
    if (!confirm(`Move "${item.title}" to trash?`)) return;
    await sendMessage({ type: 'delete_item', id: item.id });
    const res = await sendMessage({ type: 'list_items' });
    if (res.ok) setState({ entries: (res.data as any).items, selectedItem: null, selectedId: null });
    navigate('list');
  });
  document.getElementById('fill-btn')?.addEventListener('click', async () => {
    const { capturedTabId, capturedUrl } = getState();
    if (capturedTabId === null) return;
    await sendMessage({ type: 'fill_credentials', id: item.id, capturedTabId, capturedUrl });
    window.close();
  });

  if (totp) {
    const tick = async (): Promise<void> => {
      const r = await sendMessage({ type: 'get_totp', id: item.id });
      if (!r.ok) return;
      const { code, expires_at } = r.data as { code: string; expires_at: number };
      const codeSpan = document.getElementById('d-totp');
      const expSpan  = document.getElementById('d-totp-exp');
      if (codeSpan) codeSpan.textContent = code;
      if (expSpan)  expSpan.textContent  = `(${Math.max(0, expires_at - Math.floor(Date.now() / 1000))}s)`;
    };
    tick();
    const id = setInterval(tick, 1000);
    window.addEventListener('hashchange', () => clearInterval(id));
  }
  void base32Encode;   // silence unused — used in item-form.ts
}

function renderComingSoon(app: HTMLElement, item: Item): void {
  app.innerHTML = `
    <div class="pad">
      <div class="detail-title">${escapeHtml(item.title)}</div>
      <p class="muted">The <strong>${escapeHtml(item.type)}</strong> item type is coming in Plan 1C-β.</p>
      <p class="muted">Use the CLI for now: <code>relicario get ${escapeHtml(item.id)} --show</code></p>
      <button class="btn" id="back-btn">back</button>
    </div>
  `;
  document.getElementById('back-btn')?.addEventListener('click', () => navigate('list'));
}
  • Step 2: Commit
git add extension/src/popup/components/item-detail.ts
git commit -m "feat(ext/popup): Login detail view + coming-soon for other types"

Task 24: Rewrite item-form.ts — Login add/edit

Files:

  • Modify: extension/src/popup/components/item-form.ts

  • Step 1: Replace the file

import { getState, setState, sendMessage, navigate, escapeHtml } from '../popup';
import type { Item, ItemType } from '../../shared/types';
import { DEFAULT_PASSWORD_REQUEST } from '../../shared/types';
import { base32Decode, base32Encode } from '../../shared/base32';

export async function renderItemForm(app: HTMLElement, mode: 'add' | 'edit'): Promise<void> {
  const state = getState();
  const item = mode === 'edit' ? state.selectedItem : null;
  const type: ItemType = item?.type ?? 'login';

  if (type !== 'login') {
    app.innerHTML = `
      <div class="pad">
        <div class="detail-title">${mode === 'add' ? 'new item' : 'edit item'}</div>
        <p class="muted">Editing <strong>${escapeHtml(type)}</strong> items is coming in 1C-β.</p>
        <button class="btn" id="back-btn">back</button>
      </div>
    `;
    document.getElementById('back-btn')?.addEventListener('click', () => navigate('list'));
    return;
  }

  const existing = item && item.core.type === 'login' ? item.core : null;
  const totpB32 = existing?.totp ? base32Encode(new Uint8Array(existing.totp.secret)) : '';

  app.innerHTML = `
    <div class="pad">
      <div class="detail-title">${mode === 'add' ? 'new login' : 'edit login'}</div>
      ${state.error ? `<div class="error">${escapeHtml(state.error)}</div>` : ''}
      <div class="form-group">
        <label for="f-title">title *</label>
        <input id="f-title" class="input" value="${escapeHtml(item?.title ?? '')}" placeholder="GitHub" />
      </div>
      <div class="form-group">
        <label for="f-url">url</label>
        <input id="f-url" class="input" value="${escapeHtml(existing?.url ?? '')}" placeholder="https://github.com/login" />
      </div>
      <div class="form-group">
        <label for="f-user">username</label>
        <input id="f-user" class="input" value="${escapeHtml(existing?.username ?? '')}" placeholder="alice@example.com" />
      </div>
      <div class="form-group">
        <label for="f-pass">password</label>
        <div class="inline-row">
          <input id="f-pass" class="input" type="password" value="${escapeHtml(existing?.password ?? '')}" />
          <button class="btn" id="gen-btn" title="generate">gen</button>
        </div>
      </div>
      <div class="form-group">
        <label for="f-totp">totp secret (base32)</label>
        <input id="f-totp" class="input" value="${escapeHtml(totpB32)}" placeholder="JBSWY3DPEHPK3PXP" />
      </div>
      <div class="form-group">
        <label for="f-group">group</label>
        <input id="f-group" class="input" value="${escapeHtml(item?.group ?? '')}" placeholder="work" />
      </div>
      <div class="form-group">
        <label for="f-notes">notes</label>
        <textarea id="f-notes" class="input">${escapeHtml(item?.notes ?? '')}</textarea>
      </div>
      <div class="toolbar">
        <button class="btn primary" id="save-btn">save</button>
        <button class="btn" id="cancel-btn">cancel</button>
      </div>
    </div>
  `;

  document.getElementById('gen-btn')?.addEventListener('click', async () => {
    const resp = await sendMessage({ type: 'generate_password', request: DEFAULT_PASSWORD_REQUEST });
    if (resp.ok) {
      const pw = (resp.data as { password: string }).password;
      (document.getElementById('f-pass') as HTMLInputElement).value = pw;
    }
  });

  document.getElementById('cancel-btn')?.addEventListener('click', () => {
    setState({ error: null });
    navigate(mode === 'edit' ? 'detail' : 'list');
  });

  document.getElementById('save-btn')?.addEventListener('click', async () => {
    const title = (document.getElementById('f-title') as HTMLInputElement).value.trim();
    if (!title) { setState({ error: 'title is required' }); return; }
    const url = (document.getElementById('f-url') as HTMLInputElement).value.trim() || undefined;
    const username = (document.getElementById('f-user') as HTMLInputElement).value.trim() || undefined;
    const password = (document.getElementById('f-pass') as HTMLInputElement).value || undefined;
    const totpStr  = (document.getElementById('f-totp') as HTMLInputElement).value.trim();
    const group    = (document.getElementById('f-group') as HTMLInputElement).value.trim() || undefined;
    const notes    = (document.getElementById('f-notes') as HTMLTextAreaElement).value.trim() || undefined;

    let totpConfig: import('../../shared/types').TotpConfig | undefined;
    if (totpStr) {
      try {
        const secretBytes = base32Decode(totpStr);
        totpConfig = {
          secret: Array.from(secretBytes),
          algorithm: 'sha1',
          digits: 6,
          period_seconds: 30,
          kind: 'totp',
        };
      } catch (e) { setState({ error: `invalid TOTP secret: ${(e as Error).message}` }); return; }
    }

    const now = Math.floor(Date.now() / 1000);
    const payload: Item = {
      id: item?.id ?? '',
      title,
      type: 'login',
      tags: item?.tags ?? [],
      favorite: item?.favorite ?? false,
      group,
      notes,
      created: item?.created ?? now,
      modified: now,
      core: { type: 'login', username, password, url, totp: totpConfig },
      sections: item?.sections ?? [],
      attachments: item?.attachments ?? [],
      field_history: item?.field_history ?? {},
    };

    const res = mode === 'edit' && item
      ? await sendMessage({ type: 'update_item', id: item.id, item: payload })
      : await sendMessage({ type: 'add_item', item: payload });

    if (!res.ok) { setState({ error: res.error }); return; }

    const listRes = await sendMessage({ type: 'list_items' });
    if (listRes.ok) {
      setState({
        entries: (listRes.data as any).items,
        error: null,
      });
    }
    navigate('list');
  });
}
  • Step 2: Rebuild and commit
cd /home/alee/Sources/relicario/extension
bun run build 2>&1 | tail -10
cd ..
git add extension/src/popup/components/item-form.ts
git commit -m "feat(ext/popup): Login add/edit form on typed-item API"

Task 25: Setup wizard zxcvbn meter

Files:

  • Modify: extension/src/setup/setup.ts

  • Step 1: Locate the passphrase input in setup.ts

Read extension/src/setup/setup.ts to find where the passphrase <input> is rendered and where the submit button is wired.

  • Step 2: Wire the strength meter

Add next to the passphrase input (in the HTML template):

<div class="form-group">
  <label for="passphrase">passphrase</label>
  <input id="passphrase" class="input" type="password" />
  <div class="strength-row">
    <div class="strength-bar" id="strength-bar"></div>
    <span class="strength-label" id="strength-label"></span>
  </div>
  <p class="muted small" id="strength-feedback"></p>
</div>

Add CSS for the bar (5 segments, color-coded):

.strength-bar { display:flex;gap:2px;height:6px;width:100%;margin-top:4px; }
.strength-bar > span { flex:1;background:#30363d;border-radius:1px; }
.strength-bar.s0 > span:nth-child(1) { background:#f85149; }
.strength-bar.s1 > span:nth-child(-n+2) { background:#f0883e; }
.strength-bar.s2 > span:nth-child(-n+3) { background:#d29922; }
.strength-bar.s3 > span:nth-child(-n+4) { background:#3fb950; }
.strength-bar.s4 > span { background:#3fb950; }
.strength-row { display:flex;align-items:center;gap:8px; }

Add the meter rendering to the setup wizard (5 segments as child spans):

function renderStrengthBar(score: number): string {
  return `<div class="strength-bar s${score}" id="strength-bar">
    <span></span><span></span><span></span><span></span><span></span>
  </div>`;
}

Add the input-change handler:

let rateDebounce: ReturnType<typeof setTimeout> | null = null;
const pwInput = document.getElementById('passphrase') as HTMLInputElement;
pwInput.addEventListener('input', () => {
  if (rateDebounce) clearTimeout(rateDebounce);
  rateDebounce = setTimeout(async () => {
    const pw = pwInput.value;
    if (!pw) { setMeter(0, '—', 'type a passphrase'); return; }
    const resp = await new Promise<Response>((resolve) =>
      chrome.runtime.sendMessage({ type: 'rate_passphrase', passphrase: pw }, resolve),
    );
    if (resp.ok) {
      const { score } = resp.data as { score: number };
      const feedback = score >= 3
        ? 'Strong enough.'
        : 'Too weak — try a longer phrase or add unpredictability.';
      setMeter(score, `score ${score}/4`, feedback);
      (document.getElementById('submit-btn') as HTMLButtonElement).disabled = score < 3;
    }
  }, 150);
});

function setMeter(score: number, label: string, feedback: string): void {
  const bar = document.getElementById('strength-bar');
  if (bar) bar.className = `strength-bar s${score}`;
  const lab = document.getElementById('strength-label');
  if (lab) lab.textContent = label;
  const fb  = document.getElementById('strength-feedback');
  if (fb)  fb.textContent  = feedback;
}

Initial submit-button state: disabled = true until score ≥ 3.

  • Step 3: Commit
cd /home/alee/Sources/relicario
git add extension/src/setup/setup.ts
git commit -m "feat(ext/setup): zxcvbn strength meter + score>=3 gate (audit H3)"

Task 26: Restore popup settings view + smoke build

Files:

  • Modify: extension/src/popup/components/settings.ts (remove @ts-nocheck if present; align to DeviceSettings)

  • Step 1: Update the settings view to use DeviceSettings type

Open extension/src/popup/components/settings.ts and replace references to RelicarioSettings with DeviceSettings. Keep the message types the same (get_settings, update_settings, get_blacklist, remove_blacklist).

  • Step 2: Build both bundles
cd /home/alee/Sources/relicario/extension
bun run build 2>&1 | tail -10
bun run build:firefox 2>&1 | tail -10
bun run test 2>&1 | tail -20

Expected: all three succeed.

  • Step 3: Commit
cd /home/alee/Sources/relicario
git add extension/src/popup/components/settings.ts
git commit -m "refactor(ext/popup): settings view on DeviceSettings type"

Task 27: Firefox manual verification

Files: no code changes.

  • Step 1: Build both bundles
cd /home/alee/Sources/relicario/extension
bun run build:all
  • Step 2: Load Chrome build
  1. Open chrome://extensions.
  2. Enable Developer mode.
  3. Click "Load unpacked" → extension/dist/.
  4. Verify extension appears in toolbar.
  • Step 3: Load Firefox build
  1. Open about:debugging#/runtime/this-firefox.
  2. Click "Load Temporary Add-on…".
  3. Select extension/dist-firefox/manifest.json.
  4. Verify extension appears in toolbar.
  • Step 4: Walk the 11-step manual matrix on both browsers

See spec §5.4. Take notes on any anomaly. This is a long-form checklist; don't commit anything yet — the next task gathers fixes.


Slice 7 — Final acceptance

Task 28: Run all acceptance checks

Files: no code changes.

  • Step 1: Rust workspace regression
cd /home/alee/Sources/relicario
cargo test --workspace 2>&1 | tail -10

Expected: all tests pass (was 151 at plan-1b-cli-wasm-complete; may be a few more or fewer if anything shifted).

  • Step 2: WASM target builds
cargo build -p relicario-wasm --target wasm32-unknown-unknown 2>&1 | tail -10

Expected: clean build.

  • Step 3: Extension builds + router tests
cd extension
bun run build:all 2>&1 | tail -10
bun run test 2>&1 | tail -30

Expected: all green.

  • Step 4: Lint greps
cd /home/alee/Sources/relicario
git grep -n 'innerHTML\|insertAdjacentHTML' extension/src/content/ && echo "FAIL: content uses raw HTML" || echo "PASS"
git grep -n 'idfoto' extension/ && echo "FAIL: idfoto references remain" || echo "PASS"

Expected: both say PASS.

  • Step 5: WAR check
cat extension/manifest.json | grep -A2 web_accessible_resources
cat extension/manifest.firefox.json | grep -A2 web_accessible_resources

Expected: either missing or an empty [].

  • Step 6: Mark the branch complete
git tag plan-1c-alpha-complete

No commit needed. The tag marks the acceptance point for executors/reviewers.


Self-review (writing-plans housekeeping)

Spec coverage check:

  • WASM artifact rebuild — Task 1-3
  • Shared types v2 — Task 4
  • Messages split — Task 5
  • base32 utility — Task 6
  • Session handle — Task 7
  • Vault rewrite — Task 8
  • Transitional SW index — Task 9
  • Router split — Tasks 11-13
  • Collapse index onto router — Task 14
  • Vitest + router tests — Task 15
  • WAR cleanup — Task 16
  • Setup via chrome.tabs.create — Task 17
  • Shadow DOM capture — Task 18
  • Shadow DOM icon + picker + ack hint — Task 19
  • Popup captured-tab — Task 20
  • Popup component rename — Task 21
  • Login list/detail/form — Tasks 22-24
  • zxcvbn setup gate — Task 25
  • Settings view cleanup — Task 26
  • Firefox verification — Task 27
  • Final acceptance — Task 28

Known gaps (intentional — all deferred per spec):

  • Per-type forms beyond Login → 1C-β
  • Sections + custom fields → 1C-β
  • Full VaultSettings UI → 1C-β
  • Attachments → 1C-γ
  • Trash view / history view → 1C-γ
  • Device management UI → 1C-γ