diff --git a/docs/superpowers/plans/2026-04-20-relicario-extension-1c-alpha.md b/docs/superpowers/plans/2026-04-20-relicario-extension-1c-alpha.md new file mode 100644 index 0000000..7a27e10 --- /dev/null +++ b/docs/superpowers/plans/2026-04-20-relicario-extension-1c-alpha.md @@ -0,0 +1,3319 @@ +# 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-γ