# 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** ```bash 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** ```bash 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** ```bash 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** ```bash 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** ```bash 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** ```bash 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** ```bash 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 ~500KB–1.5MB). - [ ] **Step 4: Commit** ```bash 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: ```ts // 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; ``` - [ ] **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: ```ts // 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** ```bash 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** ```bash 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 3–6 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** ```ts /// 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 → 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; } // --- Manifest (schema_version 2) --- export interface Manifest { schema_version: number; // 2 items: Record; } 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; } // --- 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** ```bash 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** ```ts 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 } | { 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 { data: { unlocked: boolean }; } export interface ListItemsResponse extends Extract { data: { items: Array<[ItemId, ManifestEntry]> }; } export interface GetItemResponse extends Extract { data: { item: Item }; } export interface TotpResponse extends Extract { data: { code: string; expires_at: number }; } export interface AutofillCandidatesResponse extends Extract { data: { candidates: Array<[ItemId, ManifestEntry]> }; } export interface CredentialsResponse extends Extract { data: | { requires_ack: true; hostname: string } | { username: string; password: string }; } export interface SetupStateResponse extends Extract { data: SetupState; } export interface GeneratePasswordResponse extends Extract { data: { password: string }; } export interface RatePassphraseResponse extends Extract { data: { score: number; guesses_log10: number }; } // --- Capability sets (consumed by the router) --- export const POPUP_ONLY_TYPES: ReadonlySet = 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 = new Set([ 'get_autofill_candidates', 'get_credentials', 'check_credential', 'blacklist_site', ] as ContentMessage['type'][]); ``` - [ ] **Step 2: Commit** ```bash 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** ```ts // 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`** ```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** ```bash 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** ```ts /// 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** ```bash 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** ```ts /// 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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** ```bash 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** ```bash 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** ```ts /// 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 { 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 = new Map(); // --- chrome.storage.local helpers --- async function loadConfig(): Promise { const result = await chrome.storage.local.get('vaultConfig'); return (result.vaultConfig as VaultConfig) ?? null; } async function loadImageBase64(): Promise { const result = await chrome.storage.local.get('imageBase64'); return (result.imageBase64 as string) ?? null; } async function loadSetupState(): Promise { const config = await loadConfig(); const imageBase64 = await loadImageBase64(); return { config, imageBase64, isConfigured: config !== null && imageBase64 !== null }; } async function loadSettings(): Promise { const result = await chrome.storage.local.get('relicarioSettings'); return (result.relicarioSettings as DeviceSettings) ?? { ...DEFAULT_DEVICE_SETTINGS }; } async function saveSettings(settings: DeviceSettings): Promise { await chrome.storage.local.set({ relicarioSettings: settings }); } async function loadBlacklist(): Promise { const result = await chrome.storage.local.get('captureBlacklist'); return (result.captureBlacklist as string[]) ?? []; } async function saveBlacklist(list: string[]): Promise { 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 { 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** ```bash 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 5–6. **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** ```bash 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** ```bash 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`: ```bash 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** ```ts /// 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 { 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, state: PopupState, ): Promise { 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 { const r = await chrome.storage.local.get('vaultConfig'); return (r.vaultConfig as VaultConfig) ?? null; } async function loadImageBase64(): Promise { const r = await chrome.storage.local.get('imageBase64'); return (r.imageBase64 as string) ?? null; } async function loadSetupState(): Promise { const config = await loadConfig(); const imageBase64 = await loadImageBase64(); return { config, imageBase64, isConfigured: config !== null && imageBase64 !== null }; } async function loadDeviceSettings(): Promise { const r = await chrome.storage.local.get('relicarioSettings'); return (r.relicarioSettings as DeviceSettings) ?? { ...DEFAULT_DEVICE_SETTINGS }; } async function saveDeviceSettings(s: DeviceSettings): Promise { await chrome.storage.local.set({ relicarioSettings: s }); } async function loadBlacklist(): Promise { const r = await chrome.storage.local.get('captureBlacklist'); return (r.captureBlacklist as string[]) ?? []; } async function saveBlacklist(list: string[]): Promise { 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** ```bash 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** ```ts /// 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 { 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 { const r = await chrome.storage.local.get('captureBlacklist'); return (r.captureBlacklist as string[]) ?? []; } async function saveBlacklist(list: string[]): Promise { 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** ```bash 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** ```ts /// 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 { 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** ```bash 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** ```ts /// 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 { 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** ```bash 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** ```bash 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** ```bash 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`: ```json "test": "vitest run", "test:watch": "vitest" ``` - [ ] **Step 3: Create `vitest.config.ts`** ```ts import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { environment: 'happy-dom', include: ['src/**/__tests__/**/*.test.ts'], }, }); ``` - [ ] **Step 4: Write `router.test.ts`** ```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** ```bash 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** ```bash 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. ```bash cd /home/alee/Sources/relicario grep -rn "styles.css" extension/src/ ``` If no hits from content scripts, target shape: ```json "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: ```json "web_accessible_resources": [] ``` - [ ] **Step 2: Edit Firefox manifest the same way** In `extension/manifest.firefox.json`: ```json "web_accessible_resources": [] ``` - [ ] **Step 3: Commit** ```bash 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** ```bash 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: ```ts 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: ```ts await chrome.tabs.create({ url: chrome.runtime.getURL('setup.html') }); window.close(); ``` Remove `'setup'` from the `View` union in `popup.ts`: ```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** ```bash 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** ```bash 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** ```ts // 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: ```ts /// 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(); const hookedButtons = new WeakSet(); function sendMessage(request: Request): Promise { 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('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 { 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; 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 | 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('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('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** ```bash 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** ```ts /// 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(); 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** ```bash 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** ```bash 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: ```ts 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: ```ts const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); currentState.capturedTabId = tab?.id ?? null; currentState.capturedUrl = tab?.url ?? ''; ``` - [ ] **Step 3: Commit** ```bash 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.ts` → `item-list.ts` - Move: `extension/src/popup/components/entry-detail.ts` → `item-detail.ts` - Move: `extension/src/popup/components/entry-form.ts` → `item-form.ts` - [ ] **Step 1: Rename the files (preserving git history)** ```bash 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: ```ts 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)** ```bash 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): ```ts import { getState, setState, sendMessage, navigate, escapeHtml } from '../popup'; import type { ManifestEntry } from '../../shared/types'; export async function renderItemList(app: HTMLElement): Promise { 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 = `
    ${filtered.map(([id, e]) => row(id, e)).join('')}
`; 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 ? `${e.tags.map(escapeHtml).join(' ')}` : ''; return `
  • ${icon} ${escapeHtml(e.title)} ${e.group ? `${escapeHtml(e.group)}` : ''} ${tags} ${star}
  • `; } 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** ```bash 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** ```ts 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 { 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 = `
    ${escapeHtml(item.title)}
    username ${escapeHtml(username ?? '')}
    password ••••••
    ${url ? `` : ''} ${totp ? `
    totp
    ` : ''} ${item.notes ? `
    notes ${escapeHtml(item.notes)}
    ` : ''}
    `; 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 => { 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 = `
    ${escapeHtml(item.title)}

    The ${escapeHtml(item.type)} item type is coming in Plan 1C-β.

    Use the CLI for now: relicario get ${escapeHtml(item.id)} --show

    `; document.getElementById('back-btn')?.addEventListener('click', () => navigate('list')); } ``` - [ ] **Step 2: Commit** ```bash 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** ```ts 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 { const state = getState(); const item = mode === 'edit' ? state.selectedItem : null; const type: ItemType = item?.type ?? 'login'; if (type !== 'login') { app.innerHTML = `
    ${mode === 'add' ? 'new item' : 'edit item'}

    Editing ${escapeHtml(type)} items is coming in 1C-β.

    `; 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 = `
    ${mode === 'add' ? 'new login' : 'edit login'}
    ${state.error ? `
    ${escapeHtml(state.error)}
    ` : ''}
    `; 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** ```bash 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 `` 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): ```html

    ``` Add CSS for the bar (5 segments, color-coded): ```css .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): ```ts function renderStrengthBar(score: number): string { return `
    `; } ``` Add the input-change handler: ```ts let rateDebounce: ReturnType | 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((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** ```bash 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** ```bash 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** ```bash 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** ```bash 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** ```bash 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** ```bash cargo build -p relicario-wasm --target wasm32-unknown-unknown 2>&1 | tail -10 ``` Expected: clean build. - [ ] **Step 3: Extension builds + router tests** ```bash cd extension bun run build:all 2>&1 | tail -10 bun run test 2>&1 | tail -30 ``` Expected: all green. - [ ] **Step 4: Lint greps** ```bash 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** ```bash 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** ```bash 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-γ