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

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

103 KiB

Relicario WASM + Chrome MV3 Extension 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: Compile relicario-core to WASM and wrap it in a Chrome MV3 browser extension with a terminal-aesthetic popup, conservative autofill, and direct Gitea/GitHub API access.

Architecture: Monolith service worker loads the WASM module and holds all state (master_key, cached manifest). Popup and content script are thin UI layers communicating via chrome.runtime.sendMessage. Vault data is fetched/committed directly via Gitea/GitHub REST APIs — no local clone, no CLI dependency.

Tech Stack: Rust + wasm-bindgen (WASM crate), TypeScript + webpack (extension), Chrome MV3 APIs

Spec: docs/superpowers/specs/2026-04-12-relicario-wasm-extension-design.md


File Structure

Rust (new crate)

crates/relicario-wasm/
├── Cargo.toml
└── src/
    └── lib.rs          # wasm-bindgen wrappers + TOTP implementation

Rust (modified)

crates/relicario-core/src/entry.rs   # Add group field to Entry and ManifestEntry
Cargo.toml                         # Add relicario-wasm to workspace members

Extension (all new)

extension/
├── manifest.json
├── package.json
├── tsconfig.json
├── webpack.config.js
├── src/
│   ├── service-worker/
│   │   ├── index.ts        # WASM init, message router, state
│   │   ├── vault.ts        # vault CRUD operations
│   │   ├── git-host.ts     # GitHost interface + factory
│   │   ├── gitea.ts        # Gitea API implementation
│   │   └── github.ts       # GitHub API implementation
│   ├── popup/
│   │   ├── index.html      # popup HTML shell
│   │   ├── popup.ts        # state machine: setup → locked → list → detail → edit
│   │   ├── styles.css      # terminal dark theme
│   │   └── components/
│   │       ├── unlock.ts       # passphrase prompt
│   │       ├── entry-list.ts   # search + group filter + entry rows
│   │       ├── entry-detail.ts # field display + TOTP countdown
│   │       ├── entry-form.ts   # add/edit form
│   │       └── setup-wizard.ts # 3-step setup flow
│   ├── content/
│   │   ├── detector.ts     # login form detection
│   │   ├── fill.ts         # credential injection
│   │   └── icon.ts         # field icon injection + inline picker
│   └── shared/
│       ├── messages.ts     # typed message definitions
│       └── types.ts        # Entry, ManifestEntry, VaultConfig types
└── icons/
    ├── icon-16.png
    ├── icon-48.png
    └── icon-128.png

Task 0: Add Heavy Comments to Existing Rust Code

Files:

  • Modify: crates/relicario-core/src/lib.rs
  • Modify: crates/relicario-core/src/error.rs
  • Modify: crates/relicario-core/src/crypto.rs
  • Modify: crates/relicario-core/src/entry.rs
  • Modify: crates/relicario-core/src/vault.rs
  • Modify: crates/relicario-core/src/imgsecret.rs
  • Modify: crates/relicario-cli/src/main.rs

Add thorough documentation comments to all existing Rust code. Every public function, struct, field, constant, and non-trivial private function should have doc comments explaining what it does, why it exists, and any important constraints. Module-level docs should explain the module's role in the overall architecture.

Guidelines:

  • Use /// for public items and // for inline explanations of non-obvious logic

  • Explain the "why" not just the "what" — e.g., why XChaCha20 over AES-GCM, why mid-frequency DCT coefficients, why majority voting

  • Reference the crypto pipeline and threat model from the design spec where relevant

  • Document parameters, return values, and error conditions

  • For imgsecret.rs, explain the steganography algorithm step by step — this is the novel component and should read like a tutorial

  • For crypto.rs, document the binary format and why each field exists

  • For entry.rs, document the vault data model and serialization strategy

  • For main.rs, document the CLI flow and unlock sequence

  • Step 1: Add module-level docs and comments to lib.rs

//! # relicario-core
//!
//! Platform-agnostic core library for the relicario password manager.
//!
//! This crate is deliberately bytes-in/bytes-out — no filesystem, no network,
//! no git operations. This makes it portable to WASM (browser extension),
//! Android (JNI), and iOS (Swift bridge) without modification.
//!
//! ## Modules
//!
//! - [`crypto`] — Argon2id key derivation + XChaCha20-Poly1305 authenticated encryption
//! - [`entry`] — Entry, ManifestEntry, and Manifest data structures
//! - [`vault`] — High-level encrypt/decrypt operations for entries and manifests
//! - [`imgsecret`] — DCT-based 256-bit secret embedding in JPEG images
//! - [`error`] — Error types for all operations
  • Step 2: Add comments to error.rs

Document each error variant with when it occurs and what the caller should do about it.

  • Step 3: Add comments to crypto.rs

Document:

  • The binary ciphertext format (version byte + nonce + ciphertext + tag) and why each field exists

  • Why XChaCha20-Poly1305 was chosen over AES-GCM (192-bit nonce eliminates collision risk, fast in WASM/ARM without AES-NI)

  • The KDF pipeline: how passphrase + image_secret are concatenated as the Argon2id password input

  • KdfParams and why they're configurable (future parameter upgrades, different hardware profiles)

  • Constants and their significance (VERSION_BYTE, NONCE_LEN, TAG_LEN, HEADER_LEN)

  • Step 4: Add comments to entry.rs

Document:

  • The vault data model: Entry (full credential), ManifestEntry (index metadata), Manifest (entry index)

  • Why the manifest exists (decrypt one file to list/search entries without decrypting every entry)

  • skip_serializing_if strategy for optional fields (backwards compatibility, compact JSON)

  • Entry ID format (random 8-char hex, 32 bits — sufficient for family vault sizes)

  • The search implementation and its case-insensitive substring matching

  • Step 5: Add comments to vault.rs

Document:

  • The relationship between vault.rs and crypto.rs (vault = typed wrappers around raw encrypt/decrypt)

  • The serialization path: struct → JSON → encrypt → ciphertext bytes (and reverse)

  • Why a single master_key is used for both entries and manifest (simpler, sufficient for family vault sizes)

  • Step 6: Add comments to imgsecret.rs

This is the technically novel component. Document heavily:

  • Module-level doc explaining the DCT steganography approach and why it was chosen

  • Constants: BLOCK_SIZE, QUANT_STEP (why 50.0 — higher than typical 25 to survive JPEG recompression), MIN_DIMENSION, SECRET_BITS, MIN_COPIES, BLOCKS_PER_COPY, EMBED_POSITIONS (mid-frequency DCT coefficients in zig-zag order)

  • YChannel: what it represents, why only luminance (survives re-encoding best, Cb/Cr often subsampled)

  • EmbedRegion: the central 70% concept, the 15% crumple zone for crop tolerance

  • DCT functions: what DCT is, why 8x8 blocks (matches JPEG's own block size), the 2D DCT as row-then-column 1D DCTs

  • QIM (Quantization Index Modulation): how it encodes bits by rounding to quantization grids, why it's robust to re-quantization

  • The embedding process step by step: decode → extract Y → define region → block DCT → select blocks → encode with redundancy → QIM embed → reconstruct

  • The extraction process: canonical try → crop recovery search → majority voting with confidence threshold

  • Bit conversion helpers

  • Block selection strategy (evenly spaced, fixed geometric pattern)

  • The reconstruct_jpeg function and the YCbCr ↔ RGB color space conversion

  • Step 7: Add comments to main.rs (CLI)

Document:

  • Module-level doc explaining this is the platform layer (filesystem, git, terminal I/O)

  • The unlock flow: passphrase prompt → read reference image → extract image_secret → read salt/params → derive master_key

  • Each CLI command's purpose and flow

  • Helper functions: why git_commit uses git add -A, why generate_password uses OsRng, why now_iso8601 uses UNIX epoch seconds

  • Device management: ed25519 key generation, why device keys are separate from the KDF

  • Step 8: Run tests to verify comments didn't break anything

Run: cargo test Expected: All tests pass unchanged.

  • Step 9: Commit
git add crates/relicario-core/src/ crates/relicario-cli/src/main.rs
git commit -m "docs: add heavy documentation comments to all Rust code"

Task 1: Add group Field to Core Data Model

Files:

  • Modify: crates/relicario-core/src/entry.rs

  • Modify: crates/relicario-core/src/vault.rs (test helpers)

  • Modify: crates/relicario-cli/src/main.rs (Entry construction sites)

  • Test: crates/relicario-core/src/entry.rs (inline tests)

  • Step 1: Add group field to Entry struct

In crates/relicario-core/src/entry.rs, add the field after totp_secret:

pub struct Entry {
    pub name: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub url: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub username: Option<String>,
    pub password: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub notes: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub totp_secret: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub group: Option<String>,
    pub created_at: String,
    pub updated_at: String,
}
  • Step 2: Add group field to ManifestEntry struct

In the same file:

pub struct ManifestEntry {
    pub name: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub url: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub username: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub group: Option<String>,
    pub updated_at: String,
}
  • Step 3: Fix all Entry construction sites in tests and CLI

Update every place that constructs Entry or ManifestEntry to include group: None. These are:

In crates/relicario-core/src/entry.rs tests — entry_serialization_round_trip, manifest_add_and_lookup, manifest_serialization_round_trip, manifest_search_case_insensitive:

// Every Entry construction gets:
group: None,

// Every ManifestEntry construction gets:
group: None,

In crates/relicario-core/src/vault.rs tests — sample_entry() helper and manifest_encrypt_decrypt_round_trip:

// sample_entry() gets:
group: None,

// ManifestEntry in manifest_encrypt_decrypt_round_trip gets:
group: None,

In crates/relicario-core/tests/integration.rsfull_vault_workflow() Entry construction (line ~55) and ManifestEntry (line ~101):

// Entry construction gets:
group: None,

// ManifestEntry construction gets:
group: None,

In crates/relicario-cli/src/main.rscmd_add() Entry construction (line ~328), cmd_add() ManifestEntry (line ~349), cmd_edit() Entry construction (line ~513), cmd_edit() ManifestEntry (line ~536):

// Every Entry construction gets:
group: None,

// Every ManifestEntry construction gets:
group: None,
  • Step 4: Add a test for backwards compatibility (deserialize without group)

In crates/relicario-core/src/entry.rs tests:

#[test]
fn entry_deserializes_without_group_field() {
    let json = r#"{
        "name": "Legacy",
        "password": "old-pw",
        "created_at": "2024-01-01T00:00:00Z",
        "updated_at": "2024-01-01T00:00:00Z"
    }"#;
    let entry: Entry = serde_json::from_str(json).unwrap();
    assert_eq!(entry.name, "Legacy");
    assert_eq!(entry.group, None);
}

#[test]
fn manifest_entry_deserializes_without_group_field() {
    let json = r#"{
        "name": "Legacy",
        "updated_at": "2024-01-01T00:00:00Z"
    }"#;
    let entry: ManifestEntry = serde_json::from_str(json).unwrap();
    assert_eq!(entry.name, "Legacy");
    assert_eq!(entry.group, None);
}

#[test]
fn entry_with_group_round_trips() {
    let entry = Entry {
        name: "Work GitHub".to_string(),
        url: Some("https://github.com".to_string()),
        username: Some("alee-work".to_string()),
        password: "s3cr3t".to_string(),
        notes: None,
        totp_secret: None,
        group: Some("work".to_string()),
        created_at: "2024-01-01T00:00:00Z".to_string(),
        updated_at: "2024-01-01T00:00:00Z".to_string(),
    };
    let json = serde_json::to_string(&entry).unwrap();
    assert!(json.contains("\"group\":\"work\""));
    let decoded: Entry = serde_json::from_str(&json).unwrap();
    assert_eq!(decoded.group, Some("work".to_string()));
}
  • Step 5: Run all tests

Run: cargo test Expected: All tests pass, including new backwards-compatibility tests.

  • Step 6: Commit
git add crates/relicario-core/src/entry.rs crates/relicario-core/src/vault.rs crates/relicario-core/tests/integration.rs crates/relicario-cli/src/main.rs
git commit -m "feat: add group field to Entry and ManifestEntry"

Task 2: Create relicario-wasm Crate

Files:

  • Create: crates/relicario-wasm/Cargo.toml

  • Create: crates/relicario-wasm/src/lib.rs

  • Modify: Cargo.toml (workspace members)

  • Step 1: Create Cargo.toml

Create crates/relicario-wasm/Cargo.toml:

[package]
name = "relicario-wasm"
version = "0.1.0"
edition = "2021"
description = "WASM bindings for relicario password manager"

[lib]
crate-type = ["cdylib", "rlib"]

[dependencies]
relicario-core = { path = "../relicario-core" }
wasm-bindgen = "0.2"
js-sys = "0.3"
serde_json = "1"
hmac = "0.12"
sha1 = "0.10"
data-encoding = "2"

[dev-dependencies]
wasm-bindgen-test = "0.3"
  • Step 2: Add to workspace

In root Cargo.toml, add "crates/relicario-wasm" to the members list:

[workspace]
resolver = "2"
members = [
    "crates/relicario-core",
    "crates/relicario-cli",
    "crates/relicario-wasm",
]
  • Step 3: Write the WASM wrapper

Create crates/relicario-wasm/src/lib.rs:

use wasm_bindgen::prelude::*;

/// Derive a 32-byte master key from passphrase + image_secret + salt.
/// `params_json` is a JSON string like: {"argon2_m":65536,"argon2_t":3,"argon2_p":4}
#[wasm_bindgen]
pub fn derive_master_key(
    passphrase: &str,
    image_secret: &[u8],
    salt: &[u8],
    params_json: &str,
) -> Result<Vec<u8>, JsValue> {
    let params: relicario_core::KdfParams =
        serde_json::from_str(params_json).map_err(|e| JsValue::from_str(&e.to_string()))?;

    let image_secret: [u8; 32] = image_secret
        .try_into()
        .map_err(|_| JsValue::from_str("image_secret must be exactly 32 bytes"))?;
    let salt: [u8; 32] = salt
        .try_into()
        .map_err(|_| JsValue::from_str("salt must be exactly 32 bytes"))?;

    let key = relicario_core::derive_master_key(passphrase.as_bytes(), &image_secret, &salt, &params)
        .map_err(|e| JsValue::from_str(&e.to_string()))?;

    Ok(key.to_vec())
}

/// Encrypt plaintext bytes with a 32-byte key. Returns version+nonce+ciphertext+tag.
#[wasm_bindgen]
pub fn encrypt(plaintext: &[u8], key: &[u8]) -> Result<Vec<u8>, JsValue> {
    let key: [u8; 32] = key
        .try_into()
        .map_err(|_| JsValue::from_str("key must be exactly 32 bytes"))?;
    relicario_core::crypto::encrypt(&key, plaintext).map_err(|e| JsValue::from_str(&e.to_string()))
}

/// Decrypt ciphertext with a 32-byte key. Returns plaintext bytes.
#[wasm_bindgen]
pub fn decrypt(ciphertext: &[u8], key: &[u8]) -> Result<Vec<u8>, JsValue> {
    let key: [u8; 32] = key
        .try_into()
        .map_err(|_| JsValue::from_str("key must be exactly 32 bytes"))?;
    relicario_core::crypto::decrypt(&key, ciphertext).map_err(|e| JsValue::from_str(&e.to_string()))
}

/// Extract a 256-bit secret from a JPEG with an embedded secret.
#[wasm_bindgen]
pub fn extract_image_secret(jpeg_bytes: &[u8]) -> Result<Vec<u8>, JsValue> {
    let secret =
        relicario_core::imgsecret::extract(jpeg_bytes).map_err(|e| JsValue::from_str(&e.to_string()))?;
    Ok(secret.to_vec())
}

/// Encrypt an entry (JSON string) with a 32-byte key. Returns encrypted bytes.
#[wasm_bindgen]
pub fn encrypt_entry(entry_json: &str, key: &[u8]) -> Result<Vec<u8>, JsValue> {
    let key: [u8; 32] = key
        .try_into()
        .map_err(|_| JsValue::from_str("key must be exactly 32 bytes"))?;
    let entry: relicario_core::Entry =
        serde_json::from_str(entry_json).map_err(|e| JsValue::from_str(&e.to_string()))?;
    relicario_core::encrypt_entry(&key, &entry).map_err(|e| JsValue::from_str(&e.to_string()))
}

/// Decrypt an entry from encrypted bytes. Returns JSON string.
#[wasm_bindgen]
pub fn decrypt_entry(ciphertext: &[u8], key: &[u8]) -> Result<String, JsValue> {
    let key: [u8; 32] = key
        .try_into()
        .map_err(|_| JsValue::from_str("key must be exactly 32 bytes"))?;
    let entry =
        relicario_core::decrypt_entry(&key, ciphertext).map_err(|e| JsValue::from_str(&e.to_string()))?;
    serde_json::to_string(&entry).map_err(|e| JsValue::from_str(&e.to_string()))
}

/// Encrypt a manifest (JSON string) with a 32-byte key. Returns encrypted bytes.
#[wasm_bindgen]
pub fn encrypt_manifest(manifest_json: &str, key: &[u8]) -> Result<Vec<u8>, JsValue> {
    let key: [u8; 32] = key
        .try_into()
        .map_err(|_| JsValue::from_str("key must be exactly 32 bytes"))?;
    let manifest: relicario_core::Manifest =
        serde_json::from_str(manifest_json).map_err(|e| JsValue::from_str(&e.to_string()))?;
    relicario_core::encrypt_manifest(&key, &manifest).map_err(|e| JsValue::from_str(&e.to_string()))
}

/// Decrypt a manifest from encrypted bytes. Returns JSON string.
#[wasm_bindgen]
pub fn decrypt_manifest(ciphertext: &[u8], key: &[u8]) -> Result<String, JsValue> {
    let key: [u8; 32] = key
        .try_into()
        .map_err(|_| JsValue::from_str("key must be exactly 32 bytes"))?;
    let manifest = relicario_core::decrypt_manifest(&key, ciphertext)
        .map_err(|e| JsValue::from_str(&e.to_string()))?;
    serde_json::to_string(&manifest).map_err(|e| JsValue::from_str(&e.to_string()))
}

/// Generate a TOTP code for the given base32-encoded secret and unix timestamp.
/// Returns a 6-digit zero-padded string.
#[wasm_bindgen]
pub fn generate_totp(secret_base32: &str, timestamp_secs: u64) -> Result<String, JsValue> {
    use hmac::{Hmac, Mac};
    use sha1::Sha1;

    let secret = data_encoding::BASE32_NOPAD
        .decode(secret_base32.to_uppercase().as_bytes())
        .or_else(|_| data_encoding::BASE32.decode(secret_base32.to_uppercase().as_bytes()))
        .map_err(|e| JsValue::from_str(&format!("invalid base32 secret: {e}")))?;

    let counter = timestamp_secs / 30;
    let counter_bytes = counter.to_be_bytes();

    let mut mac = Hmac::<Sha1>::new_from_slice(&secret)
        .map_err(|e| JsValue::from_str(&format!("HMAC error: {e}")))?;
    mac.update(&counter_bytes);
    let result = mac.finalize().into_bytes();

    let offset = (result[19] & 0x0f) as usize;
    let code = ((result[offset] as u32 & 0x7f) << 24)
        | ((result[offset + 1] as u32) << 16)
        | ((result[offset + 2] as u32) << 8)
        | (result[offset + 3] as u32);
    let code = code % 1_000_000;

    Ok(format!("{:06}", code))
}

/// Generate a random password of the given length.
#[wasm_bindgen]
pub fn generate_password(length: u32) -> String {
    use js_sys::Math;

    const CHARSET: &[u8] =
        b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()-_=+";

    (0..length)
        .map(|_| {
            let idx = (Math::random() * CHARSET.len() as f64).floor() as usize;
            CHARSET[idx % CHARSET.len()] as char
        })
        .collect()
}

/// Generate a random 8-character hex entry ID.
#[wasm_bindgen]
pub fn generate_entry_id() -> String {
    use js_sys::Math;

    let mut bytes = [0u8; 4];
    for b in &mut bytes {
        *b = (Math::random() * 256.0).floor() as u8;
    }
    bytes.iter().map(|b| format!("{:02x}", b)).collect()
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn totp_rfc6238_test_vector() {
        // RFC 6238 test vector: SHA1, secret = "12345678901234567890" (ASCII),
        // time = 59, expected code = 287082
        let secret_ascii = b"12345678901234567890";
        let secret_b32 = data_encoding::BASE32.encode(secret_ascii);
        let result = generate_totp(&secret_b32, 59).unwrap();
        assert_eq!(result, "287082");
    }

    #[test]
    fn totp_rfc6238_test_vector_2() {
        // time = 1111111109, expected = 081804
        let secret_ascii = b"12345678901234567890";
        let secret_b32 = data_encoding::BASE32.encode(secret_ascii);
        let result = generate_totp(&secret_b32, 1111111109).unwrap();
        assert_eq!(result, "081804");
    }

    #[test]
    fn totp_rfc6238_test_vector_3() {
        // time = 1234567890, expected = 005924
        let secret_ascii = b"12345678901234567890";
        let secret_b32 = data_encoding::BASE32.encode(secret_ascii);
        let result = generate_totp(&secret_b32, 1234567890).unwrap();
        assert_eq!(result, "005924");
    }

    #[test]
    fn totp_invalid_base32_fails() {
        let result = generate_totp("not-valid-base32!!!", 1000);
        assert!(result.is_err());
    }

    #[test]
    fn derive_key_via_wasm_wrapper() {
        let params = r#"{"argon2_m":256,"argon2_t":1,"argon2_p":1}"#;
        let image_secret = [0x42u8; 32];
        let salt = [0x01u8; 32];
        let key = derive_master_key("test-passphrase", &image_secret, &salt, params).unwrap();
        assert_eq!(key.len(), 32);

        // Same inputs should produce same output
        let key2 = derive_master_key("test-passphrase", &image_secret, &salt, params).unwrap();
        assert_eq!(key, key2);
    }

    #[test]
    fn encrypt_decrypt_via_wasm_wrapper() {
        let key = [0xABu8; 32];
        let plaintext = b"hello wasm";
        let ciphertext = encrypt(plaintext, &key).unwrap();
        let decrypted = decrypt(&ciphertext, &key).unwrap();
        assert_eq!(decrypted, plaintext);
    }

    #[test]
    fn encrypt_entry_decrypt_entry_round_trip() {
        let key = [0xABu8; 32];
        let entry_json = r#"{"name":"Test","password":"secret","created_at":"2024-01-01T00:00:00Z","updated_at":"2024-01-01T00:00:00Z"}"#;
        let ciphertext = encrypt_entry(entry_json, &key).unwrap();
        let result = decrypt_entry(&ciphertext, &key).unwrap();
        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
        assert_eq!(parsed["name"], "Test");
        assert_eq!(parsed["password"], "secret");
    }
}
  • Step 4: Verify it compiles

Run: cargo build -p relicario-wasm Expected: Compiles successfully.

  • Step 5: Run tests

Run: cargo test -p relicario-wasm Expected: All tests pass, including TOTP RFC 6238 test vectors.

  • Step 6: Test WASM compilation

Run: cargo install wasm-pack (if not already installed), then:

wasm-pack build crates/relicario-wasm --target web --out-dir ../../extension/wasm

Expected: Produces extension/wasm/relicario_wasm.js and extension/wasm/relicario_wasm_bg.wasm. Note the WASM binary size for later reference.

  • Step 7: Commit
git add crates/relicario-wasm/ Cargo.toml extension/wasm/
git commit -m "feat: add relicario-wasm crate with wasm-bindgen wrappers and TOTP"

Task 3: Extension Scaffolding

Files:

  • Create: extension/manifest.json

  • Create: extension/package.json

  • Create: extension/tsconfig.json

  • Create: extension/webpack.config.js

  • Create: extension/src/popup/index.html

  • Create: extension/icons/ (placeholder icons)

  • Modify: .gitignore (add extension build artifacts)

  • Step 1: Create extension/package.json

{
  "name": "relicario-extension",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "build": "webpack --mode production",
    "dev": "webpack --mode development --watch",
    "build:wasm": "wasm-pack build ../crates/relicario-wasm --target web --out-dir ../../extension/wasm",
    "build:all": "npm run build:wasm && npm run build"
  },
  "devDependencies": {
    "typescript": "^5.4",
    "ts-loader": "^9.5",
    "webpack": "^5.90",
    "webpack-cli": "^5.1",
    "copy-webpack-plugin": "^12.0",
    "@anthropic-ai/sdk": "^0.39"
  }
}

Note: @anthropic-ai/sdk is NOT needed — remove that. The devDependencies should be:

{
  "name": "relicario-extension",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "build": "webpack --mode production",
    "dev": "webpack --mode development --watch",
    "build:wasm": "wasm-pack build ../crates/relicario-wasm --target web --out-dir ../../extension/wasm",
    "build:all": "npm run build:wasm && npm run build"
  },
  "devDependencies": {
    "typescript": "^5.4",
    "ts-loader": "^9.5",
    "webpack": "^5.90",
    "webpack-cli": "^5.1",
    "copy-webpack-plugin": "^12.0",
    "@anthropic-ai/sdk": "^0.39.0"
  }
}

Actually, strike that — no anthropic SDK. Final version:

{
  "name": "relicario-extension",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "build": "webpack --mode production",
    "dev": "webpack --mode development --watch",
    "build:wasm": "wasm-pack build ../crates/relicario-wasm --target web --out-dir ../../extension/wasm",
    "build:all": "npm run build:wasm && npm run build"
  },
  "devDependencies": {
    "typescript": "^5.4",
    "ts-loader": "^9.5",
    "webpack": "^5.90",
    "webpack-cli": "^5.1",
    "copy-webpack-plugin": "^12.0"
  }
}
  • Step 2: Create extension/tsconfig.json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ES2022",
    "moduleResolution": "bundler",
    "strict": true,
    "esModuleInterop": true,
    "outDir": "./dist",
    "rootDir": "./src",
    "sourceMap": true,
    "lib": ["ES2022", "DOM"]
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist", "wasm"]
}
  • Step 3: Create extension/webpack.config.js
const path = require('path');
const CopyPlugin = require('copy-webpack-plugin');

module.exports = {
  entry: {
    'service-worker': './src/service-worker/index.ts',
    popup: './src/popup/popup.ts',
    content: './src/content/detector.ts',
  },
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: '[name].js',
    clean: true,
  },
  resolve: {
    extensions: ['.ts', '.js'],
  },
  module: {
    rules: [
      {
        test: /\.ts$/,
        use: 'ts-loader',
        exclude: /node_modules/,
      },
    ],
  },
  plugins: [
    new CopyPlugin({
      patterns: [
        { from: 'manifest.json', to: '.' },
        { from: 'src/popup/index.html', to: 'popup.html' },
        { from: 'src/popup/styles.css', to: 'styles.css' },
        { from: 'icons', to: 'icons' },
        { from: 'wasm/relicario_wasm_bg.wasm', to: '.' },
        { from: 'wasm/relicario_wasm.js', to: '.' },
      ],
    }),
  ],
  experiments: {
    asyncWebAssembly: true,
  },
};
  • Step 4: Create extension/manifest.json
{
  "manifest_version": 3,
  "name": "relicario",
  "version": "0.1.0",
  "description": "Two-factor encrypted password manager",
  "permissions": ["storage", "activeTab", "clipboardWrite"],
  "host_permissions": ["<all_urls>"],
  "background": {
    "service_worker": "service-worker.js",
    "type": "module"
  },
  "action": {
    "default_popup": "popup.html",
    "default_icon": {
      "16": "icons/icon-16.png",
      "48": "icons/icon-48.png",
      "128": "icons/icon-128.png"
    }
  },
  "content_scripts": [
    {
      "matches": ["<all_urls>"],
      "js": ["content.js"],
      "run_at": "document_idle"
    }
  ],
  "content_security_policy": {
    "extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'"
  }
}
  • Step 5: Create popup HTML shell

Create extension/src/popup/index.html:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=360">
  <link rel="stylesheet" href="styles.css">
  <title>relicario</title>
</head>
<body>
  <div id="app"></div>
  <script src="popup.js"></script>
</body>
</html>
  • Step 6: Create placeholder icons

Generate simple placeholder PNGs for extension/icons/. These are 1x1 blue pixels scaled to 16, 48, 128 — functional placeholders to avoid Chrome errors. Use a simple script or create them manually.

cd extension && mkdir -p icons
# Create minimal valid PNGs (can replace with real icons later)
python3 -c "
import struct, zlib
def make_png(size, path):
    raw = b''
    for y in range(size):
        raw += b'\x00' + b'\x1f\x6f\xeb\xff' * size
    def chunk(ctype, data):
        c = ctype + data
        return struct.pack('>I', len(data)) + c + struct.pack('>I', zlib.crc32(c) & 0xffffffff)
    ihdr = struct.pack('>IIBBBBB', size, size, 8, 6, 0, 0, 0)
    with open(path, 'wb') as f:
        f.write(b'\x89PNG\r\n\x1a\n')
        f.write(chunk(b'IHDR', ihdr))
        f.write(chunk(b'IDAT', zlib.compress(raw)))
        f.write(chunk(b'IEND', b''))
make_png(16, 'icons/icon-16.png')
make_png(48, 'icons/icon-48.png')
make_png(128, 'icons/icon-128.png')
"
  • Step 7: Add to .gitignore

Append to the root .gitignore:

extension/node_modules/
extension/dist/
.superpowers/
  • Step 8: Commit
git add extension/manifest.json extension/package.json extension/tsconfig.json extension/webpack.config.js extension/src/popup/index.html extension/icons/ .gitignore
git commit -m "feat: add extension scaffolding (manifest, webpack, tsconfig)"

Task 4: Shared Types and Message Definitions

Files:

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

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

  • Step 1: Create shared types

Create extension/src/shared/types.ts:

export interface Entry {
  name: string;
  url?: string;
  username?: string;
  password: string;
  notes?: string;
  totp_secret?: string;
  group?: string;
  created_at: string;
  updated_at: string;
}

export interface ManifestEntry {
  name: string;
  url?: string;
  username?: string;
  group?: string;
  updated_at: string;
}

export interface Manifest {
  entries: Record<string, ManifestEntry>;
  version: number;
}

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

export interface SetupState {
  config: VaultConfig | null;
  imageBase64: string | null; // reference JPEG stored as base64
  isConfigured: boolean;
}
  • Step 2: Create message definitions

Create extension/src/shared/messages.ts:

import type { Entry, ManifestEntry, VaultConfig } from './types';

// ─── Requests (popup/content → service worker) ─────────────────────────────

export type Request =
  | { type: 'unlock'; passphrase: string }
  | { type: 'lock' }
  | { type: 'is_unlocked' }
  | { type: 'list_entries'; group?: string }
  | { type: 'get_entry'; id: string }
  | { type: 'search_entries'; query: string }
  | { type: 'add_entry'; entry: Omit<Entry, 'created_at' | 'updated_at'> }
  | { type: 'update_entry'; id: string; entry: Omit<Entry, 'created_at' | 'updated_at'> }
  | { type: 'delete_entry'; id: string }
  | { type: 'get_totp'; id: string }
  | { type: 'get_autofill_candidates'; url: string }
  | { type: 'get_credentials'; id: string }
  | { type: 'sync' }
  | { type: 'get_setup_state' }
  | { type: 'save_setup'; config: VaultConfig; imageBase64: string }
  | { type: 'fill_credentials'; username: string; password: string };

// ─── Responses (service worker → popup/content) ────────────────────────────

export type Response =
  | { ok: true }
  | { ok: true; id: string }
  | { ok: true; unlocked: boolean }
  | { ok: true; entries: Array<{ id: string } & ManifestEntry> }
  | { ok: true; entry: Entry }
  | { ok: true; code: string; remaining_seconds: number }
  | { ok: true; candidates: Array<{ id: string } & ManifestEntry> }
  | { ok: true; username: string; password: string }
  | { ok: true; state: import('./types').SetupState }
  | { error: string };
  • Step 3: Commit
git add extension/src/shared/
git commit -m "feat: add shared types and message definitions"

Task 5: Git API Layer

Files:

  • Create: extension/src/service-worker/git-host.ts

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

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

  • Step 1: Create the GitHost interface

Create extension/src/service-worker/git-host.ts:

export interface GitHost {
  readFile(path: string): Promise<Uint8Array>;
  writeFile(path: string, content: Uint8Array, message: string): Promise<void>;
  deleteFile(path: string, message: string): Promise<void>;
  listDir(path: string): Promise<string[]>;
}

export function createGitHost(
  hostType: 'gitea' | 'github',
  hostUrl: string,
  repoPath: string,
  apiToken: string
): GitHost {
  switch (hostType) {
    case 'gitea':
      return new (require('./gitea').GiteaHost)(hostUrl, repoPath, apiToken);
    case 'github':
      return new (require('./github').GitHubHost)(hostUrl, repoPath, apiToken);
  }
}

Actually, since we're using ES modules and webpack, use dynamic imports or just import both. Simpler approach:

import { GiteaHost } from './gitea';
import { GitHubHost } from './github';

export interface GitHost {
  readFile(path: string): Promise<Uint8Array>;
  writeFile(path: string, content: Uint8Array, message: string): Promise<void>;
  deleteFile(path: string, message: string): Promise<void>;
  listDir(path: string): Promise<string[]>;
}

export function createGitHost(
  hostType: 'gitea' | 'github',
  hostUrl: string,
  repoPath: string,
  apiToken: string
): GitHost {
  if (hostType === 'gitea') {
    return new GiteaHost(hostUrl, repoPath, apiToken);
  }
  return new GitHubHost(hostUrl, repoPath, apiToken);
}
  • Step 2: Create Gitea implementation

Create extension/src/service-worker/gitea.ts:

import type { GitHost } from './git-host';

export class GiteaHost implements GitHost {
  private baseUrl: string;
  private headers: HeadersInit;

  constructor(hostUrl: string, repoPath: string, apiToken: string) {
    // Remove trailing slash from hostUrl
    const host = hostUrl.replace(/\/+$/, '');
    this.baseUrl = `${host}/api/v1/repos/${repoPath}/contents`;
    this.headers = {
      Authorization: `token ${apiToken}`,
      'Content-Type': 'application/json',
    };
  }

  async readFile(path: string): Promise<Uint8Array> {
    const resp = await fetch(`${this.baseUrl}/${path}`, { headers: this.headers });
    if (!resp.ok) {
      throw new Error(`Failed to read ${path}: ${resp.status} ${resp.statusText}`);
    }
    const data = await resp.json();
    const decoded = atob(data.content);
    const bytes = new Uint8Array(decoded.length);
    for (let i = 0; i < decoded.length; i++) {
      bytes[i] = decoded.charCodeAt(i);
    }
    return bytes;
  }

  async writeFile(path: string, content: Uint8Array, message: string): Promise<void> {
    // Check if file exists to get its SHA
    let sha: string | undefined;
    try {
      const resp = await fetch(`${this.baseUrl}/${path}`, { headers: this.headers });
      if (resp.ok) {
        const data = await resp.json();
        sha = data.sha;
      }
    } catch {
      // File doesn't exist, that's fine
    }

    const body: Record<string, string> = {
      message,
      content: uint8ArrayToBase64(content),
    };
    if (sha) {
      body.sha = sha;
    }

    const method = sha ? 'PUT' : 'POST';
    const resp = await fetch(`${this.baseUrl}/${path}`, {
      method,
      headers: this.headers,
      body: JSON.stringify(body),
    });
    if (!resp.ok) {
      throw new Error(`Failed to write ${path}: ${resp.status} ${resp.statusText}`);
    }
  }

  async deleteFile(path: string, message: string): Promise<void> {
    // Get file SHA first
    const getResp = await fetch(`${this.baseUrl}/${path}`, { headers: this.headers });
    if (!getResp.ok) {
      throw new Error(`Failed to read ${path} for deletion: ${getResp.status}`);
    }
    const data = await getResp.json();

    const resp = await fetch(`${this.baseUrl}/${path}`, {
      method: 'DELETE',
      headers: this.headers,
      body: JSON.stringify({ message, sha: data.sha }),
    });
    if (!resp.ok) {
      throw new Error(`Failed to delete ${path}: ${resp.status} ${resp.statusText}`);
    }
  }

  async listDir(path: string): Promise<string[]> {
    const resp = await fetch(`${this.baseUrl}/${path}`, { headers: this.headers });
    if (!resp.ok) {
      if (resp.status === 404) return [];
      throw new Error(`Failed to list ${path}: ${resp.status} ${resp.statusText}`);
    }
    const data = await resp.json();
    if (!Array.isArray(data)) return [];
    return data.map((item: { name: string }) => item.name);
  }
}

function uint8ArrayToBase64(bytes: Uint8Array): string {
  let binary = '';
  for (let i = 0; i < bytes.length; i++) {
    binary += String.fromCharCode(bytes[i]);
  }
  return btoa(binary);
}
  • Step 3: Create GitHub implementation

Create extension/src/service-worker/github.ts:

import type { GitHost } from './git-host';

export class GitHubHost implements GitHost {
  private baseUrl: string;
  private headers: HeadersInit;

  constructor(_hostUrl: string, repoPath: string, apiToken: string) {
    this.baseUrl = `https://api.github.com/repos/${repoPath}/contents`;
    this.headers = {
      Authorization: `Bearer ${apiToken}`,
      Accept: 'application/vnd.github.v3+json',
      'Content-Type': 'application/json',
    };
  }

  async readFile(path: string): Promise<Uint8Array> {
    const resp = await fetch(`${this.baseUrl}/${path}`, { headers: this.headers });
    if (!resp.ok) {
      throw new Error(`Failed to read ${path}: ${resp.status} ${resp.statusText}`);
    }
    const data = await resp.json();
    const decoded = atob(data.content.replace(/\n/g, ''));
    const bytes = new Uint8Array(decoded.length);
    for (let i = 0; i < decoded.length; i++) {
      bytes[i] = decoded.charCodeAt(i);
    }
    return bytes;
  }

  async writeFile(path: string, content: Uint8Array, message: string): Promise<void> {
    let sha: string | undefined;
    try {
      const resp = await fetch(`${this.baseUrl}/${path}`, { headers: this.headers });
      if (resp.ok) {
        const data = await resp.json();
        sha = data.sha;
      }
    } catch {
      // File doesn't exist
    }

    const body: Record<string, string> = {
      message,
      content: uint8ArrayToBase64(content),
    };
    if (sha) {
      body.sha = sha;
    }

    const resp = await fetch(`${this.baseUrl}/${path}`, {
      method: 'PUT',
      headers: this.headers,
      body: JSON.stringify(body),
    });
    if (!resp.ok) {
      throw new Error(`Failed to write ${path}: ${resp.status} ${resp.statusText}`);
    }
  }

  async deleteFile(path: string, message: string): Promise<void> {
    const getResp = await fetch(`${this.baseUrl}/${path}`, { headers: this.headers });
    if (!getResp.ok) {
      throw new Error(`Failed to read ${path} for deletion: ${getResp.status}`);
    }
    const data = await getResp.json();

    const resp = await fetch(`${this.baseUrl}/${path}`, {
      method: 'DELETE',
      headers: this.headers,
      body: JSON.stringify({ message, sha: data.sha }),
    });
    if (!resp.ok) {
      throw new Error(`Failed to delete ${path}: ${resp.status} ${resp.statusText}`);
    }
  }

  async listDir(path: string): Promise<string[]> {
    const resp = await fetch(`${this.baseUrl}/${path}`, { headers: this.headers });
    if (!resp.ok) {
      if (resp.status === 404) return [];
      throw new Error(`Failed to list ${path}: ${resp.status} ${resp.statusText}`);
    }
    const data = await resp.json();
    if (!Array.isArray(data)) return [];
    return data.map((item: { name: string }) => item.name);
  }
}

function uint8ArrayToBase64(bytes: Uint8Array): string {
  let binary = '';
  for (let i = 0; i < bytes.length; i++) {
    binary += String.fromCharCode(bytes[i]);
  }
  return btoa(binary);
}
  • Step 4: Commit
git add extension/src/service-worker/git-host.ts extension/src/service-worker/gitea.ts extension/src/service-worker/github.ts
git commit -m "feat: add git API layer with Gitea and GitHub implementations"

Task 6: Service Worker — Vault Operations

Files:

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

  • Step 1: Create vault operations module

Create extension/src/service-worker/vault.ts:

import type { GitHost } from './git-host';
import type { Entry, Manifest, ManifestEntry } from '../shared/types';

// These will be set by the service worker index after WASM init
let wasm: typeof import('../../wasm/relicario_wasm');

export function setWasm(w: typeof wasm) {
  wasm = w;
}

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

export async function fetchAndDecryptManifest(git: GitHost, masterKey: Uint8Array): Promise<Manifest> {
  const encBytes = await git.readFile('manifest.enc');
  const json = wasm.decrypt_manifest(encBytes, masterKey);
  return JSON.parse(json);
}

export async function fetchAndDecryptEntry(git: GitHost, masterKey: Uint8Array, id: string): Promise<Entry> {
  const encBytes = await git.readFile(`entries/${id}.enc`);
  const json = wasm.decrypt_entry(encBytes, masterKey);
  return JSON.parse(json);
}

export async function encryptAndWriteEntry(
  git: GitHost,
  masterKey: Uint8Array,
  id: string,
  entry: Entry,
  commitMsg: string
): Promise<void> {
  const json = JSON.stringify(entry);
  const encBytes = wasm.encrypt_entry(json, masterKey);
  await git.writeFile(`entries/${id}.enc`, encBytes, commitMsg);
}

export async function encryptAndWriteManifest(
  git: GitHost,
  masterKey: Uint8Array,
  manifest: Manifest,
  commitMsg: string
): Promise<void> {
  const json = JSON.stringify(manifest);
  const encBytes = wasm.encrypt_manifest(json, masterKey);
  await git.writeFile('manifest.enc', encBytes, commitMsg);
}

export function listEntries(manifest: Manifest, group?: string): Array<{ id: string } & ManifestEntry> {
  const entries = Object.entries(manifest.entries).map(([id, entry]) => ({ id, ...entry }));
  if (group) {
    return entries.filter((e) => e.group === group);
  }
  return entries;
}

export function searchEntries(manifest: Manifest, query: string): Array<{ id: string } & ManifestEntry> {
  const q = query.toLowerCase();
  return Object.entries(manifest.entries)
    .filter(([, e]) => {
      return (
        e.name.toLowerCase().includes(q) ||
        (e.url && e.url.toLowerCase().includes(q)) ||
        (e.username && e.username.toLowerCase().includes(q))
      );
    })
    .map(([id, entry]) => ({ id, ...entry }));
}

export function findByUrl(manifest: Manifest, url: string): Array<{ id: string } & ManifestEntry> {
  let hostname: string;
  try {
    hostname = new URL(url).hostname;
  } catch {
    return [];
  }
  return Object.entries(manifest.entries)
    .filter(([, e]) => {
      if (!e.url) return false;
      try {
        return new URL(e.url).hostname === hostname;
      } catch {
        return false;
      }
    })
    .map(([id, entry]) => ({ id, ...entry }));
}
  • Step 2: Commit
git add extension/src/service-worker/vault.ts
git commit -m "feat: add vault operations module for service worker"

Task 7: Service Worker — Main Entry Point

Files:

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

  • Step 1: Create the service worker entry point

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

import type { Manifest, VaultConfig, SetupState } from '../shared/types';
import type { Request, Response } from '../shared/messages';
import { createGitHost, type GitHost } from './git-host';
import {
  setWasm,
  fetchVaultMeta,
  fetchAndDecryptManifest,
  fetchAndDecryptEntry,
  encryptAndWriteEntry,
  encryptAndWriteManifest,
  listEntries,
  searchEntries,
  findByUrl,
} from './vault';

// ─── State ──────────────────────────────────────────────────────────────────

let masterKey: Uint8Array | null = null;
let manifest: Manifest | null = null;
let gitHost: GitHost | null = null;
let wasm: typeof import('../../wasm/relicario_wasm') | null = null;

// ─── WASM initialization ───────────────────────────────────────────────────

async function initWasm(): Promise<typeof import('../../wasm/relicario_wasm')> {
  if (wasm) return wasm;
  const mod = await import(/* webpackIgnore: true */ './relicario_wasm.js');
  await mod.default();
  wasm = mod;
  setWasm(mod);
  return mod;
}

// ─── Config helpers ─────────────────────────────────────────────────────────

async function loadConfig(): Promise<{ config: VaultConfig; imageBase64: string } | null> {
  const data = await chrome.storage.local.get(['vaultConfig', 'imageBase64']);
  if (!data.vaultConfig || !data.imageBase64) return null;
  return { config: data.vaultConfig, imageBase64: data.imageBase64 };
}

async function saveConfig(config: VaultConfig, imageBase64: string): Promise<void> {
  await chrome.storage.local.set({ vaultConfig: config, imageBase64 });
}

function base64ToUint8Array(base64: string): Uint8Array {
  const binary = atob(base64);
  const bytes = new Uint8Array(binary.length);
  for (let i = 0; i < binary.length; i++) {
    bytes[i] = binary.charCodeAt(i);
  }
  return bytes;
}

function isoNow(): string {
  return new Date().toISOString();
}

// ─── Message handler ────────────────────────────────────────────────────────

chrome.runtime.onMessage.addListener(
  (request: Request, _sender, sendResponse: (response: Response) => void) => {
    handleMessage(request)
      .then(sendResponse)
      .catch((err) => sendResponse({ error: String(err) }));
    return true; // async response
  }
);

async function handleMessage(req: Request): Promise<Response> {
  const w = await initWasm();

  switch (req.type) {
    case 'get_setup_state': {
      const stored = await loadConfig();
      const state: SetupState = {
        config: stored?.config ?? null,
        imageBase64: stored?.imageBase64 ?? null,
        isConfigured: stored !== null,
      };
      return { ok: true, state } as Response;
    }

    case 'save_setup': {
      await saveConfig(req.config, req.imageBase64);
      return { ok: true };
    }

    case 'is_unlocked': {
      return { ok: true, unlocked: masterKey !== null } as Response;
    }

    case 'unlock': {
      const stored = await loadConfig();
      if (!stored) return { error: 'Extension not configured. Run setup first.' };

      const imageBytes = base64ToUint8Array(stored.imageBase64);
      const imageSecret = w.extract_image_secret(imageBytes);

      gitHost = createGitHost(
        stored.config.hostType,
        stored.config.hostUrl,
        stored.config.repoPath,
        stored.config.apiToken
      );

      const { salt, paramsJson } = await fetchVaultMeta(gitHost);
      const key = w.derive_master_key(req.passphrase, imageSecret, salt, paramsJson);
      masterKey = new Uint8Array(key);

      manifest = await fetchAndDecryptManifest(gitHost, masterKey);
      return { ok: true };
    }

    case 'lock': {
      masterKey = null;
      manifest = null;
      return { ok: true };
    }

    case 'list_entries': {
      if (!manifest) return { error: 'Vault is locked' };
      const entries = listEntries(manifest, req.group);
      return { ok: true, entries } as Response;
    }

    case 'search_entries': {
      if (!manifest) return { error: 'Vault is locked' };
      const entries = searchEntries(manifest, req.query);
      return { ok: true, entries } as Response;
    }

    case 'get_entry': {
      if (!masterKey || !gitHost) return { error: 'Vault is locked' };
      const entry = await fetchAndDecryptEntry(gitHost, masterKey, req.id);
      return { ok: true, entry } as Response;
    }

    case 'add_entry': {
      if (!masterKey || !gitHost || !manifest) return { error: 'Vault is locked' };
      const now = isoNow();
      const entry = { ...req.entry, created_at: now, updated_at: now };
      const id = w.generate_entry_id();

      await encryptAndWriteEntry(gitHost, masterKey, id, entry, `add entry '${entry.name}'`);

      manifest.entries[id] = {
        name: entry.name,
        url: entry.url,
        username: entry.username,
        group: entry.group,
        updated_at: now,
      };
      await encryptAndWriteManifest(gitHost, masterKey, manifest, `update manifest: add '${entry.name}'`);

      return { ok: true, id } as Response;
    }

    case 'update_entry': {
      if (!masterKey || !gitHost || !manifest) return { error: 'Vault is locked' };
      const existing = manifest.entries[req.id];
      if (!existing) return { error: `Entry ${req.id} not found` };

      const now = isoNow();
      const oldEntry = await fetchAndDecryptEntry(gitHost, masterKey, req.id);
      const updated = { ...req.entry, created_at: oldEntry.created_at, updated_at: now };

      await encryptAndWriteEntry(gitHost, masterKey, req.id, updated, `edit entry '${updated.name}'`);

      manifest.entries[req.id] = {
        name: updated.name,
        url: updated.url,
        username: updated.username,
        group: updated.group,
        updated_at: now,
      };
      await encryptAndWriteManifest(gitHost, masterKey, manifest, `update manifest: edit '${updated.name}'`);

      return { ok: true };
    }

    case 'delete_entry': {
      if (!masterKey || !gitHost || !manifest) return { error: 'Vault is locked' };
      const entry = manifest.entries[req.id];
      if (!entry) return { error: `Entry ${req.id} not found` };

      await gitHost.deleteFile(`entries/${req.id}.enc`, `remove entry '${entry.name}'`);
      delete manifest.entries[req.id];
      await encryptAndWriteManifest(gitHost, masterKey, manifest, `update manifest: remove '${entry.name}'`);

      return { ok: true };
    }

    case 'get_totp': {
      if (!masterKey || !gitHost) return { error: 'Vault is locked' };
      const entry = await fetchAndDecryptEntry(gitHost, masterKey, req.id);
      if (!entry.totp_secret) return { error: 'No TOTP secret for this entry' };

      const now = Math.floor(Date.now() / 1000);
      const code = w.generate_totp(entry.totp_secret, BigInt(now));
      const remaining = 30 - (now % 30);
      return { ok: true, code, remaining_seconds: remaining } as Response;
    }

    case 'get_autofill_candidates': {
      if (!manifest) return { error: 'Vault is locked' };
      const candidates = findByUrl(manifest, req.url);
      return { ok: true, candidates } as Response;
    }

    case 'get_credentials': {
      if (!masterKey || !gitHost) return { error: 'Vault is locked' };
      const entry = await fetchAndDecryptEntry(gitHost, masterKey, req.id);
      return { ok: true, username: entry.username ?? '', password: entry.password } as Response;
    }

    case 'sync': {
      if (!masterKey || !gitHost) return { error: 'Vault is locked' };
      manifest = await fetchAndDecryptManifest(gitHost, masterKey);
      return { ok: true };
    }

    case 'fill_credentials': {
      // This is handled by the content script directly, not the service worker
      return { ok: true };
    }

    default:
      return { error: `Unknown message type: ${(req as Request).type}` };
  }
}
  • Step 2: Commit
git add extension/src/service-worker/index.ts
git commit -m "feat: add service worker with WASM init, state management, and message router"

Task 8: Popup — Styles

Files:

  • Create: extension/src/popup/styles.css

  • Step 1: Create the terminal dark theme

Create extension/src/popup/styles.css:

/* ─── Reset & Base ───────────────────────────────────────────────────────── */

* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

body {
  width: 360px;
  min-height: 200px;
  max-height: 500px;
  overflow-y: auto;
  background: #0d1117;
  color: #c9d1d9;
  font-family: 'JetBrains Mono', 'Cascadia Code', 'Fira Code', 'SF Mono', 'Menlo', monospace;
  font-size: 12px;
  line-height: 1.5;
}

#app {
  padding: 16px;
}

/* ─── Typography ─────────────────────────────────────────────────────────── */

.brand {
  color: #58a6ff;
  font-size: 14px;
  letter-spacing: 2px;
  font-weight: normal;
}

.label {
  color: #484f58;
  font-size: 10px;
  text-transform: uppercase;
  margin-bottom: 2px;
}

.secondary {
  color: #8b949e;
}

.muted {
  color: #484f58;
}

.error {
  color: #f85149;
  font-size: 11px;
}

/* ─── Inputs ─────────────────────────────────────────────────────────────── */

input, textarea {
  background: #161b22;
  border: 1px solid #30363d;
  color: #c9d1d9;
  font-family: inherit;
  font-size: 12px;
  padding: 6px 10px;
  border-radius: 2px;
  width: 100%;
  outline: none;
}

input:focus, textarea:focus {
  border-color: #58a6ff;
}

input::placeholder, textarea::placeholder {
  color: #484f58;
}

/* ─── Buttons ────────────────────────────────────────────────────────────── */

.btn {
  background: #21262d;
  border: 1px solid #30363d;
  color: #c9d1d9;
  font-family: inherit;
  font-size: 11px;
  padding: 4px 12px;
  border-radius: 2px;
  cursor: pointer;
}

.btn:hover {
  background: #30363d;
}

.btn-primary {
  background: #1f6feb;
  border-color: #1f6feb;
  color: #fff;
}

.btn-primary:hover {
  background: #388bfd;
}

.btn-danger {
  background: #da3633;
  border-color: #da3633;
  color: #fff;
}

.btn-danger:hover {
  background: #f85149;
}

/* ─── Header ─────────────────────────────────────────────────────────────── */

.header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 12px;
}

.status {
  color: #484f58;
  font-size: 10px;
}

/* ─── Search ─────────────────────────────────────────────────────────────── */

.search-bar {
  margin-bottom: 8px;
}

/* ─── Group Tabs ─────────────────────────────────────────────────────────── */

.group-tabs {
  display: flex;
  gap: 6px;
  margin-bottom: 10px;
  flex-wrap: wrap;
}

.group-tab {
  background: #21262d;
  border: 1px solid #30363d;
  padding: 2px 8px;
  border-radius: 2px;
  font-size: 10px;
  color: #8b949e;
  cursor: pointer;
  font-family: inherit;
}

.group-tab:hover {
  background: #30363d;
}

.group-tab.active {
  background: #1f6feb;
  border-color: #1f6feb;
  color: #fff;
}

/* ─── Entry List ─────────────────────────────────────────────────────────── */

.entry-list {
  max-height: 300px;
  overflow-y: auto;
}

.entry-row {
  padding: 6px 10px;
  border-left: 2px solid transparent;
  cursor: pointer;
}

.entry-row:hover {
  background: #161b22;
}

.entry-row.selected {
  border-left-color: #58a6ff;
  background: #161b22;
}

.entry-name {
  color: #c9d1d9;
  font-size: 12px;
}

.entry-meta {
  color: #484f58;
  font-size: 10px;
}

/* ─── Entry Detail ───────────────────────────────────────────────────────── */

.detail-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 12px;
}

.detail-title {
  color: #58a6ff;
  font-size: 14px;
}

.field {
  margin-bottom: 8px;
}

.field-value {
  display: flex;
  justify-content: space-between;
  align-items: center;
}

.field-value .hint {
  color: #484f58;
  font-size: 10px;
}

/* ─── TOTP ───────────────────────────────────────────────────────────────── */

.totp-code {
  color: #3fb950;
  font-size: 16px;
  letter-spacing: 4px;
}

.totp-bar {
  height: 2px;
  background: #21262d;
  margin-top: 4px;
  border-radius: 1px;
}

.totp-bar-fill {
  height: 2px;
  background: #3fb950;
  border-radius: 1px;
  transition: width 1s linear;
}

/* ─── Footer / Keybinding Hints ──────────────────────────────────────────── */

.keyhints {
  color: #484f58;
  font-size: 10px;
  margin-top: 8px;
  padding-top: 8px;
  border-top: 1px solid #21262d;
}

/* ─── Setup Wizard ───────────────────────────────────────────────────────── */

.wizard-step {
  color: #484f58;
  font-size: 10px;
  margin-bottom: 4px;
}

.progress-bar {
  height: 2px;
  background: #21262d;
  margin-bottom: 16px;
  border-radius: 1px;
}

.progress-bar-fill {
  height: 2px;
  background: #1f6feb;
  border-radius: 1px;
  transition: width 0.3s;
}

.form-group {
  margin-bottom: 12px;
}

.host-toggle {
  display: flex;
  gap: 8px;
  margin-bottom: 12px;
}

/* ─── Spinner ────────────────────────────────────────────────────────────── */

.spinner {
  display: inline-block;
  width: 12px;
  height: 12px;
  border: 2px solid #30363d;
  border-top-color: #58a6ff;
  border-radius: 50%;
  animation: spin 0.6s linear infinite;
}

@keyframes spin {
  to { transform: rotate(360deg); }
}

/* ─── Actions Bar ────────────────────────────────────────────────────────── */

.actions {
  display: flex;
  gap: 8px;
  justify-content: flex-end;
  margin-top: 12px;
}

/* ─── Confirm Dialog ─────────────────────────────────────────────────────── */

.confirm-overlay {
  position: fixed;
  inset: 0;
  background: rgba(0, 0, 0, 0.6);
  display: flex;
  align-items: center;
  justify-content: center;
  z-index: 100;
}

.confirm-box {
  background: #161b22;
  border: 1px solid #30363d;
  padding: 16px;
  border-radius: 4px;
  max-width: 300px;
}

/* ─── Empty State ────────────────────────────────────────────────────────── */

.empty {
  color: #484f58;
  text-align: center;
  padding: 24px 0;
  font-size: 11px;
}

/* ─── Scrollbar ──────────────────────────────────────────────────────────── */

::-webkit-scrollbar {
  width: 4px;
}

::-webkit-scrollbar-track {
  background: transparent;
}

::-webkit-scrollbar-thumb {
  background: #30363d;
  border-radius: 2px;
}
  • Step 2: Commit
git add extension/src/popup/styles.css
git commit -m "feat: add terminal dark theme for popup"

Task 9: Popup — State Machine and Components

Files:

  • Create: extension/src/popup/popup.ts
  • Create: extension/src/popup/components/unlock.ts
  • Create: extension/src/popup/components/entry-list.ts
  • Create: extension/src/popup/components/entry-detail.ts
  • Create: extension/src/popup/components/entry-form.ts
  • Create: extension/src/popup/components/setup-wizard.ts

This is the largest task. Each component is a function that renders into a container element and attaches its own event listeners. The popup state machine orchestrates transitions.

  • Step 1: Create the popup state machine

Create extension/src/popup/popup.ts:

import type { Request, Response } from '../shared/messages';
import { renderUnlock } from './components/unlock';
import { renderEntryList } from './components/entry-list';
import { renderEntryDetail } from './components/entry-detail';
import { renderEntryForm } from './components/entry-form';
import { renderSetupWizard } from './components/setup-wizard';

export type PopupState =
  | { view: 'setup' }
  | { view: 'locked' }
  | { view: 'list' }
  | { view: 'detail'; entryId: string }
  | { view: 'add' }
  | { view: 'edit'; entryId: string };

let currentState: PopupState = { view: 'locked' };

export function sendMessage(request: Request): Promise<Response> {
  return chrome.runtime.sendMessage(request);
}

export function navigate(state: PopupState) {
  currentState = state;
  render();
}

function render() {
  const app = document.getElementById('app')!;
  app.innerHTML = '';

  switch (currentState.view) {
    case 'setup':
      renderSetupWizard(app);
      break;
    case 'locked':
      renderUnlock(app);
      break;
    case 'list':
      renderEntryList(app);
      break;
    case 'detail':
      renderEntryDetail(app, currentState.entryId);
      break;
    case 'add':
      renderEntryForm(app, null);
      break;
    case 'edit':
      renderEntryForm(app, currentState.entryId);
      break;
  }
}

// ─── Init ───────────────────────────────────────────────────────────────────

async function init() {
  const resp = await sendMessage({ type: 'get_setup_state' });
  if ('error' in resp) {
    navigate({ view: 'setup' });
    return;
  }

  const state = (resp as { ok: true; state: import('../shared/types').SetupState }).state;
  if (!state.isConfigured) {
    navigate({ view: 'setup' });
    return;
  }

  const unlockResp = await sendMessage({ type: 'is_unlocked' });
  if ('error' in unlockResp) {
    navigate({ view: 'locked' });
    return;
  }

  const unlocked = (unlockResp as { ok: true; unlocked: boolean }).unlocked;
  navigate(unlocked ? { view: 'list' } : { view: 'locked' });
}

document.addEventListener('DOMContentLoaded', init);
  • Step 2: Create unlock component

Create extension/src/popup/components/unlock.ts:

import { sendMessage, navigate } from '../popup';

export function renderUnlock(container: HTMLElement) {
  container.innerHTML = `
    <div class="brand">relicario</div>
    <div style="margin-top: 16px">
      <div class="label">PASSPHRASE</div>
      <input type="password" id="passphrase" placeholder="Enter passphrase..." autofocus>
      <div id="unlock-error" class="error" style="margin-top: 4px"></div>
      <div id="unlock-spinner" style="margin-top: 8px; display: none">
        <span class="spinner"></span> <span class="secondary">Deriving key...</span>
      </div>
    </div>
    <div class="actions" style="margin-top: 16px">
      <button class="btn muted" id="btn-settings">settings</button>
      <button class="btn-primary btn" id="btn-unlock">ENTER unlock</button>
    </div>
  `;

  const input = container.querySelector('#passphrase') as HTMLInputElement;
  const errorEl = container.querySelector('#unlock-error') as HTMLElement;
  const spinnerEl = container.querySelector('#unlock-spinner') as HTMLElement;
  const btnUnlock = container.querySelector('#btn-unlock') as HTMLButtonElement;
  const btnSettings = container.querySelector('#btn-settings') as HTMLButtonElement;

  async function doUnlock() {
    const passphrase = input.value;
    if (!passphrase) return;

    errorEl.textContent = '';
    spinnerEl.style.display = 'block';
    btnUnlock.disabled = true;

    const resp = await sendMessage({ type: 'unlock', passphrase });
    spinnerEl.style.display = 'none';
    btnUnlock.disabled = false;

    if ('error' in resp) {
      errorEl.textContent = resp.error;
      input.value = '';
      input.focus();
    } else {
      navigate({ view: 'list' });
    }
  }

  input.addEventListener('keydown', (e) => {
    if (e.key === 'Enter') doUnlock();
    if (e.key === 'Escape') window.close();
  });

  btnUnlock.addEventListener('click', doUnlock);
  btnSettings.addEventListener('click', () => navigate({ view: 'setup' }));
}
  • Step 3: Create entry list component

Create extension/src/popup/components/entry-list.ts:

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

export async function renderEntryList(container: HTMLElement) {
  container.innerHTML = `
    <div class="header">
      <div class="brand">relicario</div>
      <div class="status">🔓 unlocked</div>
    </div>
    <div class="search-bar">
      <input type="text" id="search" placeholder="/ search...">
    </div>
    <div class="group-tabs" id="group-tabs"></div>
    <div class="entry-list" id="entry-list">
      <div class="empty">Loading...</div>
    </div>
    <div class="keyhints">↑↓ navigate · ENTER open · / search · + add</div>
  `;

  const searchInput = container.querySelector('#search') as HTMLInputElement;
  const groupTabsEl = container.querySelector('#group-tabs') as HTMLElement;
  const listEl = container.querySelector('#entry-list') as HTMLElement;

  let entries: Array<{ id: string } & ManifestEntry> = [];
  let selectedIndex = 0;
  let activeGroup: string | undefined;

  async function loadEntries() {
    const resp = await sendMessage({ type: 'list_entries', group: activeGroup });
    if ('error' in resp) return;
    entries = (resp as { ok: true; entries: Array<{ id: string } & ManifestEntry> }).entries;
    entries.sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()));
    selectedIndex = 0;
    renderList();
    renderGroupTabs();
  }

  function renderGroupTabs() {
    // Get all groups from entries (fetch all, not filtered)
    sendMessage({ type: 'list_entries' }).then((resp) => {
      if ('error' in resp) return;
      const allEntries = (resp as { ok: true; entries: Array<{ id: string } & ManifestEntry> }).entries;
      const groups = new Set<string>();
      allEntries.forEach((e) => { if (e.group) groups.add(e.group); });

      let html = `<button class="group-tab ${!activeGroup ? 'active' : ''}" data-group="">all</button>`;
      for (const g of Array.from(groups).sort()) {
        html += `<button class="group-tab ${activeGroup === g ? 'active' : ''}" data-group="${g}">${g}</button>`;
      }
      groupTabsEl.innerHTML = html;

      groupTabsEl.querySelectorAll('.group-tab').forEach((btn) => {
        btn.addEventListener('click', () => {
          const g = (btn as HTMLElement).dataset.group;
          activeGroup = g || undefined;
          loadEntries();
        });
      });
    });
  }

  function renderList() {
    if (entries.length === 0) {
      listEl.innerHTML = '<div class="empty">No entries found.</div>';
      return;
    }

    listEl.innerHTML = entries
      .map((e, i) => {
        const domain = e.url ? extractDomain(e.url) : '';
        const meta = [e.username, domain].filter(Boolean).join(' · ');
        return `<div class="entry-row ${i === selectedIndex ? 'selected' : ''}" data-index="${i}" data-id="${e.id}">
          <div class="entry-name">${escapeHtml(e.name)}</div>
          <div class="entry-meta">${escapeHtml(meta)}</div>
        </div>`;
      })
      .join('');

    listEl.querySelectorAll('.entry-row').forEach((row) => {
      row.addEventListener('click', () => {
        const id = (row as HTMLElement).dataset.id!;
        navigate({ view: 'detail', entryId: id });
      });
    });
  }

  function handleSearch() {
    const query = searchInput.value.trim();
    if (!query) {
      loadEntries();
      return;
    }
    sendMessage({ type: 'search_entries', query }).then((resp) => {
      if ('error' in resp) return;
      entries = (resp as { ok: true; entries: Array<{ id: string } & ManifestEntry> }).entries;
      entries.sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()));
      selectedIndex = 0;
      renderList();
    });
  }

  searchInput.addEventListener('input', handleSearch);

  document.addEventListener('keydown', function handler(e) {
    // Clean up when we navigate away
    if (!container.isConnected) {
      document.removeEventListener('keydown', handler);
      return;
    }

    if (e.key === 'ArrowDown') {
      e.preventDefault();
      if (selectedIndex < entries.length - 1) {
        selectedIndex++;
        renderList();
      }
    } else if (e.key === 'ArrowUp') {
      e.preventDefault();
      if (selectedIndex > 0) {
        selectedIndex--;
        renderList();
      }
    } else if (e.key === 'Enter' && document.activeElement !== searchInput) {
      if (entries[selectedIndex]) {
        navigate({ view: 'detail', entryId: entries[selectedIndex].id });
      }
    } else if (e.key === '/' && document.activeElement !== searchInput) {
      e.preventDefault();
      searchInput.focus();
    } else if (e.key === '+' && document.activeElement !== searchInput) {
      e.preventDefault();
      navigate({ view: 'add' });
    } else if (e.key === 'Escape') {
      if (document.activeElement === searchInput) {
        searchInput.value = '';
        searchInput.blur();
        loadEntries();
      } else {
        window.close();
      }
    }
  });

  await loadEntries();
}

function extractDomain(url: string): string {
  try {
    return new URL(url).hostname;
  } catch {
    return url;
  }
}

function escapeHtml(s: string): string {
  const div = document.createElement('div');
  div.textContent = s;
  return div.innerHTML;
}
  • Step 4: Create entry detail component

Create extension/src/popup/components/entry-detail.ts:

import { sendMessage, navigate } from '../popup';
import type { Entry } from '../../shared/types';

export async function renderEntryDetail(container: HTMLElement, entryId: string) {
  container.innerHTML = `
    <div class="detail-header">
      <span class="secondary" style="font-size:11px; cursor:pointer" id="back">← ESC back</span>
      <span class="muted" id="entry-group"></span>
    </div>
    <div class="detail-title" id="entry-name">Loading...</div>
    <div id="entry-fields" style="margin-top: 12px"></div>
    <div class="keyhints">f fill · e edit · d delete</div>
  `;

  const backBtn = container.querySelector('#back') as HTMLElement;
  const nameEl = container.querySelector('#entry-name') as HTMLElement;
  const groupEl = container.querySelector('#entry-group') as HTMLElement;
  const fieldsEl = container.querySelector('#entry-fields') as HTMLElement;

  backBtn.addEventListener('click', () => navigate({ view: 'list' }));

  const resp = await sendMessage({ type: 'get_entry', id: entryId });
  if ('error' in resp) {
    nameEl.textContent = 'Error loading entry';
    return;
  }

  const entry = (resp as { ok: true; entry: Entry }).entry;
  nameEl.textContent = entry.name;
  groupEl.textContent = entry.group ?? '';

  let fieldsHtml = '';

  if (entry.url) {
    fieldsHtml += `
      <div class="field">
        <div class="label">URL</div>
        <div class="secondary">${escapeHtml(entry.url)}</div>
      </div>`;
  }

  if (entry.username) {
    fieldsHtml += `
      <div class="field">
        <div class="label">USERNAME</div>
        <div class="field-value">
          <span>${escapeHtml(entry.username)}</span>
          <span class="hint" data-copy="username">c copy</span>
        </div>
      </div>`;
  }

  fieldsHtml += `
    <div class="field">
      <div class="label">PASSWORD</div>
      <div class="field-value">
        <span id="pw-display">••••••••••</span>
        <span class="hint" data-copy="password">p copy</span>
      </div>
    </div>`;

  if (entry.totp_secret) {
    fieldsHtml += `
      <div class="field">
        <div class="label">TOTP</div>
        <div class="field-value">
          <span class="totp-code" id="totp-code">------</span>
          <span class="hint"><span id="totp-remaining">--</span>s · <span data-copy="totp">t copy</span></span>
        </div>
        <div class="totp-bar"><div class="totp-bar-fill" id="totp-bar-fill"></div></div>
      </div>`;
  }

  if (entry.notes) {
    fieldsHtml += `
      <div class="field">
        <div class="label">NOTES</div>
        <div class="secondary" style="white-space: pre-wrap">${escapeHtml(entry.notes)}</div>
      </div>`;
  }

  fieldsEl.innerHTML = fieldsHtml;

  // ─── TOTP countdown ────────────────────────────────────────────────────

  let totpInterval: ReturnType<typeof setInterval> | null = null;

  if (entry.totp_secret) {
    async function updateTotp() {
      const totpResp = await sendMessage({ type: 'get_totp', id: entryId });
      if ('error' in totpResp) return;
      const { code, remaining_seconds } = totpResp as { ok: true; code: string; remaining_seconds: number };

      const codeEl = container.querySelector('#totp-code');
      const remainEl = container.querySelector('#totp-remaining');
      const barEl = container.querySelector('#totp-bar-fill') as HTMLElement | null;

      if (codeEl) codeEl.textContent = code.slice(0, 3) + ' ' + code.slice(3);
      if (remainEl) remainEl.textContent = String(remaining_seconds);
      if (barEl) barEl.style.width = `${(remaining_seconds / 30) * 100}%`;
    }

    await updateTotp();
    totpInterval = setInterval(updateTotp, 1000);
  }

  // ─── Copy handlers ────────────────────────────────────────────────────

  async function copyToClipboard(text: string) {
    await navigator.clipboard.writeText(text);
  }

  container.querySelectorAll('[data-copy]').forEach((el) => {
    el.addEventListener('click', async () => {
      const field = (el as HTMLElement).dataset.copy;
      if (field === 'username' && entry.username) {
        await copyToClipboard(entry.username);
      } else if (field === 'password') {
        await copyToClipboard(entry.password);
      } else if (field === 'totp') {
        const totpResp = await sendMessage({ type: 'get_totp', id: entryId });
        if (!('error' in totpResp)) {
          await copyToClipboard((totpResp as { ok: true; code: string }).code);
        }
      }
    });
  });

  // ─── Keyboard shortcuts ───────────────────────────────────────────────

  document.addEventListener('keydown', function handler(e) {
    if (!container.isConnected) {
      if (totpInterval) clearInterval(totpInterval);
      document.removeEventListener('keydown', handler);
      return;
    }

    if (e.key === 'Escape') {
      navigate({ view: 'list' });
    } else if (e.key === 'c') {
      if (entry.username) copyToClipboard(entry.username);
    } else if (e.key === 'p') {
      copyToClipboard(entry.password);
    } else if (e.key === 't' && entry.totp_secret) {
      sendMessage({ type: 'get_totp', id: entryId }).then((resp) => {
        if (!('error' in resp)) {
          copyToClipboard((resp as { ok: true; code: string }).code);
        }
      });
    } else if (e.key === 'e') {
      navigate({ view: 'edit', entryId });
    } else if (e.key === 'f') {
      // Autofill active tab
      chrome.tabs.query({ active: true, currentWindow: true }, async (tabs) => {
        if (!tabs[0]?.id) return;
        const creds = await sendMessage({ type: 'get_credentials', id: entryId });
        if ('error' in creds) return;
        const { username, password } = creds as { ok: true; username: string; password: string };
        chrome.tabs.sendMessage(tabs[0].id, { type: 'fill_credentials', username, password });
      });
    } else if (e.key === 'd') {
      showDeleteConfirm(container, entryId, entry.name);
    }
  });
}

function showDeleteConfirm(container: HTMLElement, entryId: string, name: string) {
  const overlay = document.createElement('div');
  overlay.className = 'confirm-overlay';
  overlay.innerHTML = `
    <div class="confirm-box">
      <p>Delete <strong>${escapeHtml(name)}</strong>?</p>
      <p class="secondary" style="margin-top: 4px; font-size: 11px">This commits a removal to the vault.</p>
      <div class="actions" style="margin-top: 12px">
        <button class="btn" id="confirm-no">No</button>
        <button class="btn btn-danger" id="confirm-yes">Yes, delete</button>
      </div>
    </div>
  `;
  container.appendChild(overlay);

  overlay.querySelector('#confirm-no')!.addEventListener('click', () => overlay.remove());
  overlay.querySelector('#confirm-yes')!.addEventListener('click', async () => {
    await sendMessage({ type: 'delete_entry', id: entryId });
    navigate({ view: 'list' });
  });
}

function escapeHtml(s: string): string {
  const div = document.createElement('div');
  div.textContent = s;
  return div.innerHTML;
}
  • Step 5: Create entry form component (add/edit)

Create extension/src/popup/components/entry-form.ts:

import { sendMessage, navigate } from '../popup';
import type { Entry } from '../../shared/types';

export async function renderEntryForm(container: HTMLElement, entryId: string | null) {
  const isEdit = entryId !== null;
  let existing: Entry | null = null;

  if (isEdit) {
    const resp = await sendMessage({ type: 'get_entry', id: entryId });
    if ('error' in resp) {
      container.innerHTML = '<div class="error">Failed to load entry</div>';
      return;
    }
    existing = (resp as { ok: true; entry: Entry }).entry;
  }

  container.innerHTML = `
    <div class="detail-header">
      <span class="secondary" style="font-size:11px; cursor:pointer" id="back">← ESC back</span>
      <span class="brand">${isEdit ? 'edit' : 'add'}</span>
    </div>
    <div class="form-group">
      <div class="label">NAME *</div>
      <input type="text" id="f-name" value="${escapeAttr(existing?.name ?? '')}" autofocus>
    </div>
    <div class="form-group">
      <div class="label">URL</div>
      <input type="text" id="f-url" value="${escapeAttr(existing?.url ?? '')}" placeholder="https://...">
    </div>
    <div class="form-group">
      <div class="label">USERNAME</div>
      <input type="text" id="f-username" value="${escapeAttr(existing?.username ?? '')}">
    </div>
    <div class="form-group">
      <div class="label">PASSWORD</div>
      <div style="display:flex;gap:4px">
        <input type="text" id="f-password" value="${escapeAttr(existing?.password ?? '')}" style="flex:1">
        <button class="btn" id="btn-generate" title="Generate">gen</button>
      </div>
    </div>
    <div class="form-group">
      <div class="label">TOTP SECRET</div>
      <input type="text" id="f-totp" value="${escapeAttr(existing?.totp_secret ?? '')}" placeholder="Base32 encoded">
    </div>
    <div class="form-group">
      <div class="label">GROUP</div>
      <input type="text" id="f-group" value="${escapeAttr(existing?.group ?? '')}" placeholder="personal, work, ...">
    </div>
    <div class="form-group">
      <div class="label">NOTES</div>
      <textarea id="f-notes" rows="3">${escapeHtml(existing?.notes ?? '')}</textarea>
    </div>
    <div id="form-error" class="error"></div>
    <div class="actions">
      <button class="btn" id="btn-cancel">Cancel</button>
      <button class="btn btn-primary" id="btn-save">Save</button>
    </div>
  `;

  const backBtn = container.querySelector('#back') as HTMLElement;
  const btnCancel = container.querySelector('#btn-cancel') as HTMLButtonElement;
  const btnSave = container.querySelector('#btn-save') as HTMLButtonElement;
  const btnGenerate = container.querySelector('#btn-generate') as HTMLButtonElement;
  const errorEl = container.querySelector('#form-error') as HTMLElement;

  function goBack() {
    if (isEdit) {
      navigate({ view: 'detail', entryId: entryId! });
    } else {
      navigate({ view: 'list' });
    }
  }

  backBtn.addEventListener('click', goBack);
  btnCancel.addEventListener('click', goBack);

  btnGenerate.addEventListener('click', async () => {
    const resp = await sendMessage({ type: 'is_unlocked' }); // Just to trigger WASM load
    // Generate locally via service worker — but we can just use crypto.getRandomValues
    const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()-_=+';
    const array = new Uint8Array(24);
    crypto.getRandomValues(array);
    const password = Array.from(array, (b) => chars[b % chars.length]).join('');
    (container.querySelector('#f-password') as HTMLInputElement).value = password;
  });

  btnSave.addEventListener('click', async () => {
    const name = (container.querySelector('#f-name') as HTMLInputElement).value.trim();
    if (!name) {
      errorEl.textContent = 'Name is required';
      return;
    }

    const entry = {
      name,
      url: (container.querySelector('#f-url') as HTMLInputElement).value.trim() || undefined,
      username: (container.querySelector('#f-username') as HTMLInputElement).value.trim() || undefined,
      password: (container.querySelector('#f-password') as HTMLInputElement).value,
      totp_secret: (container.querySelector('#f-totp') as HTMLInputElement).value.trim() || undefined,
      group: (container.querySelector('#f-group') as HTMLInputElement).value.trim() || undefined,
      notes: (container.querySelector('#f-notes') as HTMLTextAreaElement).value.trim() || undefined,
    };

    btnSave.disabled = true;
    errorEl.textContent = '';

    let resp;
    if (isEdit) {
      resp = await sendMessage({ type: 'update_entry', id: entryId!, entry });
    } else {
      resp = await sendMessage({ type: 'add_entry', entry });
    }

    if ('error' in resp) {
      errorEl.textContent = resp.error;
      btnSave.disabled = false;
      return;
    }

    if (isEdit) {
      navigate({ view: 'detail', entryId: entryId! });
    } else {
      navigate({ view: 'list' });
    }
  });

  document.addEventListener('keydown', function handler(e) {
    if (!container.isConnected) {
      document.removeEventListener('keydown', handler);
      return;
    }
    if (e.key === 'Escape') goBack();
  });
}

function escapeHtml(s: string): string {
  const div = document.createElement('div');
  div.textContent = s;
  return div.innerHTML;
}

function escapeAttr(s: string): string {
  return s.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
  • Step 6: Create setup wizard component

Create extension/src/popup/components/setup-wizard.ts:

import { sendMessage, navigate } from '../popup';
import type { VaultConfig } from '../../shared/types';

export function renderSetupWizard(container: HTMLElement) {
  let step = 1;
  let config: Partial<VaultConfig> = { hostType: 'gitea' };
  let imageBase64: string | null = null;

  function render() {
    container.innerHTML = `
      <div class="brand">relicario setup</div>
      <div class="wizard-step">step ${step} of 3 — ${['repository', 'reference image', 'test unlock'][step - 1]}</div>
      <div class="progress-bar"><div class="progress-bar-fill" style="width: ${(step / 3) * 100}%"></div></div>
      <div id="wizard-content"></div>
      <div id="wizard-error" class="error" style="margin-top: 8px"></div>
      <div class="actions" id="wizard-actions"></div>
    `;
    const content = container.querySelector('#wizard-content') as HTMLElement;
    const actions = container.querySelector('#wizard-actions') as HTMLElement;

    if (step === 1) renderStep1(content, actions);
    else if (step === 2) renderStep2(content, actions);
    else if (step === 3) renderStep3(content, actions);
  }

  function renderStep1(content: HTMLElement, actions: HTMLElement) {
    content.innerHTML = `
      <div class="form-group">
        <div class="label">HOST TYPE</div>
        <div class="host-toggle">
          <button class="group-tab ${config.hostType === 'gitea' ? 'active' : ''}" data-host="gitea">gitea</button>
          <button class="group-tab ${config.hostType === 'github' ? 'active' : ''}" data-host="github">github</button>
        </div>
      </div>
      <div class="form-group">
        <div class="label">HOST URL</div>
        <input type="text" id="w-host" value="${config.hostUrl ?? ''}" placeholder="https://git.example.com">
      </div>
      <div class="form-group">
        <div class="label">REPO PATH</div>
        <input type="text" id="w-repo" value="${config.repoPath ?? ''}" placeholder="owner/repo">
      </div>
      <div class="form-group">
        <div class="label">API TOKEN</div>
        <input type="password" id="w-token" value="${config.apiToken ?? ''}" placeholder="Paste token here">
      </div>
    `;
    actions.innerHTML = `<button class="btn btn-primary" id="btn-next">Next →</button>`;

    content.querySelectorAll('[data-host]').forEach((btn) => {
      btn.addEventListener('click', () => {
        config.hostType = (btn as HTMLElement).dataset.host as 'gitea' | 'github';
        render();
      });
    });

    actions.querySelector('#btn-next')!.addEventListener('click', () => {
      config.hostUrl = (content.querySelector('#w-host') as HTMLInputElement).value.trim();
      config.repoPath = (content.querySelector('#w-repo') as HTMLInputElement).value.trim();
      config.apiToken = (content.querySelector('#w-token') as HTMLInputElement).value.trim();

      const errorEl = container.querySelector('#wizard-error') as HTMLElement;
      if (!config.hostUrl || !config.repoPath || !config.apiToken) {
        errorEl.textContent = 'All fields are required';
        return;
      }
      step = 2;
      render();
    });
  }

  function renderStep2(content: HTMLElement, actions: HTMLElement) {
    content.innerHTML = `
      <div class="form-group">
        <div class="label">REFERENCE IMAGE</div>
        <p class="secondary" style="margin-bottom: 8px; font-size: 11px">
          Upload the JPEG with your embedded secret. Stored locally, never sent to the server.
        </p>
        <input type="file" id="w-image" accept="image/jpeg">
        <div id="image-status" class="secondary" style="margin-top: 4px; font-size: 11px">
          ${imageBase64 ? '✓ Image loaded' : 'No image selected'}
        </div>
      </div>
    `;
    actions.innerHTML = `
      <button class="btn" id="btn-back">← Back</button>
      <button class="btn btn-primary" id="btn-next">Next →</button>
    `;

    const fileInput = content.querySelector('#w-image') as HTMLInputElement;
    const statusEl = content.querySelector('#image-status') as HTMLElement;

    fileInput.addEventListener('change', () => {
      const file = fileInput.files?.[0];
      if (!file) return;
      const reader = new FileReader();
      reader.onload = () => {
        const dataUrl = reader.result as string;
        // Strip the data:image/jpeg;base64, prefix
        imageBase64 = dataUrl.split(',')[1];
        statusEl.textContent = `✓ ${file.name} (${(file.size / 1024).toFixed(0)} KB)`;
      };
      reader.readAsDataURL(file);
    });

    actions.querySelector('#btn-back')!.addEventListener('click', () => { step = 1; render(); });
    actions.querySelector('#btn-next')!.addEventListener('click', () => {
      const errorEl = container.querySelector('#wizard-error') as HTMLElement;
      if (!imageBase64) {
        errorEl.textContent = 'Please select a reference image';
        return;
      }
      step = 3;
      render();
    });
  }

  function renderStep3(content: HTMLElement, actions: HTMLElement) {
    content.innerHTML = `
      <div class="form-group">
        <div class="label">TEST UNLOCK</div>
        <p class="secondary" style="margin-bottom: 8px; font-size: 11px">
          Enter your passphrase to verify everything works.
        </p>
        <input type="password" id="w-passphrase" placeholder="Passphrase" autofocus>
        <div id="test-spinner" style="margin-top: 8px; display: none">
          <span class="spinner"></span> <span class="secondary">Testing...</span>
        </div>
      </div>
    `;
    actions.innerHTML = `
      <button class="btn" id="btn-back">← Back</button>
      <button class="btn btn-primary" id="btn-finish">Test & Save</button>
    `;

    actions.querySelector('#btn-back')!.addEventListener('click', () => { step = 2; render(); });
    actions.querySelector('#btn-finish')!.addEventListener('click', async () => {
      const passphrase = (content.querySelector('#w-passphrase') as HTMLInputElement).value;
      const errorEl = container.querySelector('#wizard-error') as HTMLElement;
      const spinnerEl = content.querySelector('#test-spinner') as HTMLElement;

      if (!passphrase) {
        errorEl.textContent = 'Enter your passphrase';
        return;
      }

      errorEl.textContent = '';
      spinnerEl.style.display = 'block';

      // Save config first
      await sendMessage({
        type: 'save_setup',
        config: config as VaultConfig,
        imageBase64: imageBase64!,
      });

      // Try to unlock
      const resp = await sendMessage({ type: 'unlock', passphrase });
      spinnerEl.style.display = 'none';

      if ('error' in resp) {
        errorEl.textContent = `Test failed: ${resp.error}`;
        return;
      }

      navigate({ view: 'list' });
    });
  }

  render();
}
  • Step 7: Install npm dependencies
cd extension && npm install

Expected: node_modules/ created with TypeScript, webpack, etc.

  • Step 8: Build the extension
cd extension && npm run build

Expected: dist/ directory with service-worker.js, popup.js, content.js, popup.html, styles.css, manifest.json, WASM files, and icons.

If build fails, fix any TypeScript errors and retry.

  • Step 9: Commit
git add extension/src/popup/ extension/src/service-worker/index.ts
git commit -m "feat: add popup UI with state machine, all components, and setup wizard"

Task 10: Content Script — Form Detection and Autofill

Files:

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

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

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

  • Step 1: Create the form detector and content script entry point

Create extension/src/content/detector.ts:

import { injectFieldIcons } from './icon';
import { setupFillListener } from './fill';

function detectLoginForms(): { passwordField: HTMLInputElement; usernameField: HTMLInputElement | null }[] {
  const passwordFields = document.querySelectorAll<HTMLInputElement>('input[type="password"]');
  const results: { passwordField: HTMLInputElement; usernameField: HTMLInputElement | null }[] = [];

  for (const pwField of passwordFields) {
    if (!pwField.offsetParent) continue; // skip hidden fields

    const usernameField = findUsernameField(pwField);
    results.push({ passwordField: pwField, usernameField });
  }

  return results;
}

function findUsernameField(passwordField: HTMLInputElement): HTMLInputElement | null {
  const form = passwordField.closest('form');
  const scope = form ?? document;

  // Priority 1: autocomplete="username"
  const byAutocompleteUser = scope.querySelector<HTMLInputElement>('input[autocomplete="username"]');
  if (byAutocompleteUser) return byAutocompleteUser;

  // Priority 2: autocomplete="email"
  const byAutocompleteEmail = scope.querySelector<HTMLInputElement>('input[autocomplete="email"]');
  if (byAutocompleteEmail) return byAutocompleteEmail;

  // Priority 3: type="email"
  const byTypeEmail = scope.querySelector<HTMLInputElement>('input[type="email"]');
  if (byTypeEmail) return byTypeEmail;

  // Priority 4: name matching pattern
  const byNamePattern = scope.querySelector<HTMLInputElement>(
    'input[name*="user" i], input[name*="email" i], input[name*="login" i], input[name*="account" i]'
  );
  if (byNamePattern && byNamePattern !== passwordField) return byNamePattern;

  // Priority 5: nearest preceding text input in same form
  if (form) {
    const inputs = Array.from(form.querySelectorAll<HTMLInputElement>('input[type="text"], input[type="email"], input:not([type])'));
    const pwIndex = Array.from(form.querySelectorAll('input')).indexOf(passwordField);
    for (let i = inputs.length - 1; i >= 0; i--) {
      const inp = inputs[i];
      const inpIndex = Array.from(form.querySelectorAll('input')).indexOf(inp);
      if (inpIndex < pwIndex) return inp;
    }
  }

  return null;
}

// ─── Init ───────────────────────────────────────────────────────────────────

function init() {
  const forms = detectLoginForms();
  if (forms.length > 0) {
    injectFieldIcons(forms);
  }
  setupFillListener();
}

// Run detection after DOM is ready
if (document.readyState === 'loading') {
  document.addEventListener('DOMContentLoaded', init);
} else {
  init();
}

// Re-detect on DOM changes (for SPAs that load forms dynamically)
const observer = new MutationObserver(() => {
  const forms = detectLoginForms();
  if (forms.length > 0) {
    injectFieldIcons(forms);
  }
});
observer.observe(document.body, { childList: true, subtree: true });
  • Step 2: Create the fill module

Create extension/src/content/fill.ts:

export function setupFillListener() {
  chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
    if (message.type === 'fill_credentials') {
      fillFields(message.username, message.password);
      sendResponse({ ok: true });
    }
    return false;
  });
}

export function fillFields(username: string, password: string) {
  const passwordField = document.querySelector<HTMLInputElement>('input[type="password"]');
  if (!passwordField) return;

  // Fill password
  setInputValue(passwordField, password);

  // Find and fill username
  const form = passwordField.closest('form');
  const scope = form ?? document;

  const usernameField =
    scope.querySelector<HTMLInputElement>('input[autocomplete="username"]') ??
    scope.querySelector<HTMLInputElement>('input[autocomplete="email"]') ??
    scope.querySelector<HTMLInputElement>('input[type="email"]') ??
    scope.querySelector<HTMLInputElement>(
      'input[name*="user" i], input[name*="email" i], input[name*="login" i]'
    );

  if (usernameField && username) {
    setInputValue(usernameField, username);
  }

  // Focus submit button or password field
  const submitBtn = form?.querySelector<HTMLButtonElement>('button[type="submit"], input[type="submit"]');
  if (submitBtn) {
    submitBtn.focus();
  } else {
    passwordField.focus();
  }
}

function setInputValue(input: HTMLInputElement, value: string) {
  // Use native setter to bypass React/Vue/Angular controlled component checks
  const nativeSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set;
  if (nativeSetter) {
    nativeSetter.call(input, value);
  } else {
    input.value = value;
  }

  // Dispatch events that frameworks listen for
  input.dispatchEvent(new Event('input', { bubbles: true }));
  input.dispatchEvent(new Event('change', { bubbles: true }));
}
  • Step 3: Create the field icon module

Create extension/src/content/icon.ts:

import type { ManifestEntry } from '../shared/types';
import { fillFields } from './fill';

const ICON_SVG = `<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
  <rect x="1" y="1" width="14" height="14" rx="2" fill="#1f6feb" fill-opacity="0.9"/>
  <text x="8" y="11.5" text-anchor="middle" fill="white" font-size="9" font-family="monospace" font-weight="bold">id</text>
</svg>`;

const injectedFields = new WeakSet<HTMLInputElement>();

export function injectFieldIcons(
  forms: { passwordField: HTMLInputElement; usernameField: HTMLInputElement | null }[]
) {
  for (const { passwordField, usernameField } of forms) {
    if (injectedFields.has(passwordField)) continue;
    injectedFields.add(passwordField);

    const icon = document.createElement('div');
    icon.innerHTML = ICON_SVG;
    icon.style.cssText = `
      position: absolute;
      right: 8px;
      top: 50%;
      transform: translateY(-50%);
      cursor: pointer;
      z-index: 10000;
      opacity: 0.8;
      transition: opacity 0.2s;
    `;
    icon.addEventListener('mouseenter', () => { icon.style.opacity = '1'; });
    icon.addEventListener('mouseleave', () => { icon.style.opacity = '0.8'; });

    // Wrap the password field in a relative container if needed
    const parent = passwordField.parentElement;
    if (parent) {
      const computed = getComputedStyle(parent);
      if (computed.position === 'static') {
        parent.style.position = 'relative';
      }
      parent.appendChild(icon);
    }

    icon.addEventListener('click', async (e) => {
      e.preventDefault();
      e.stopPropagation();

      const url = window.location.href;
      const resp = await chrome.runtime.sendMessage({ type: 'get_autofill_candidates', url });

      if ('error' in resp || !resp.candidates || resp.candidates.length === 0) {
        // No matches — open popup
        return;
      }

      const candidates = resp.candidates as Array<{ id: string } & ManifestEntry>;

      if (candidates.length === 1) {
        // Single match — fill immediately
        const creds = await chrome.runtime.sendMessage({ type: 'get_credentials', id: candidates[0].id });
        if (!('error' in creds)) {
          fillFields(creds.username, creds.password);
        }
      } else {
        // Multiple matches — show picker
        showPicker(icon, candidates, usernameField, passwordField);
      }
    });
  }
}

function showPicker(
  anchor: HTMLElement,
  candidates: Array<{ id: string } & ManifestEntry>,
  usernameField: HTMLInputElement | null,
  passwordField: HTMLInputElement
) {
  // Remove any existing picker
  document.querySelectorAll('.relicario-picker').forEach((el) => el.remove());

  const picker = document.createElement('div');
  picker.className = 'relicario-picker';
  picker.style.cssText = `
    position: absolute;
    right: 0;
    top: 100%;
    margin-top: 4px;
    background: #161b22;
    border: 1px solid #30363d;
    border-radius: 4px;
    min-width: 200px;
    z-index: 10001;
    font-family: monospace;
    font-size: 12px;
    box-shadow: 0 4px 12px rgba(0,0,0,0.4);
  `;

  for (const candidate of candidates) {
    const row = document.createElement('div');
    row.style.cssText = `
      padding: 6px 10px;
      cursor: pointer;
      color: #c9d1d9;
      border-bottom: 1px solid #21262d;
    `;
    row.addEventListener('mouseenter', () => { row.style.background = '#21262d'; });
    row.addEventListener('mouseleave', () => { row.style.background = 'transparent'; });
    row.innerHTML = `
      <div>${escapeHtml(candidate.name)}</div>
      <div style="color:#484f58;font-size:10px">${escapeHtml(candidate.username ?? '')}</div>
    `;
    row.addEventListener('click', async () => {
      const creds = await chrome.runtime.sendMessage({ type: 'get_credentials', id: candidate.id });
      if (!('error' in creds)) {
        fillFields(creds.username, creds.password);
      }
      picker.remove();
    });
    picker.appendChild(row);
  }

  anchor.parentElement?.appendChild(picker);

  // Close picker on outside click
  document.addEventListener('click', function handler(e) {
    if (!picker.contains(e.target as Node) && e.target !== anchor) {
      picker.remove();
      document.removeEventListener('click', handler);
    }
  });
}

function escapeHtml(s: string): string {
  const div = document.createElement('div');
  div.textContent = s;
  return div.innerHTML;
}
  • Step 4: Update webpack config entry for content script

The webpack config should bundle all three content script files together. Update the content entry in extension/webpack.config.js:

The entry content: './src/content/detector.ts' already imports icon.ts and fill.ts, so webpack will bundle them together. No change needed.

  • Step 5: Build and verify
cd extension && npm run build

Expected: dist/content.js contains the bundled content script.

  • Step 6: Commit
git add extension/src/content/
git commit -m "feat: add content script with form detection, field icon, and autofill"

Task 11: Build Integration and Manual Test

Files:

  • Modify: extension/webpack.config.js (if needed)

  • Create: Makefile (optional convenience)

  • Step 1: Full build from scratch

# Build WASM
wasm-pack build crates/relicario-wasm --target web --out-dir ../../extension/wasm

# Install deps and build extension
cd extension && npm install && npm run build

Expected: extension/dist/ contains all files needed to load as an unpacked Chrome extension.

  • Step 2: Note the WASM binary size
ls -lh extension/wasm/relicario_wasm_bg.wasm
ls -lh extension/dist/relicario_wasm_bg.wasm

Record the size for reference. If >2 MB uncompressed, consider optimizing later.

  • Step 3: Load in Chrome

Open chrome://extensions/, enable Developer Mode, click "Load unpacked", select extension/dist/. Verify:

  • Extension appears in the toolbar

  • Clicking the icon opens the popup

  • Setup wizard renders correctly

  • No console errors in the service worker (inspect via "Service Worker" link on the extensions page)

  • Step 4: Test the full flow manually

  1. Setup wizard: enter Gitea/GitHub host, repo, token, upload reference image
  2. Test unlock: enter passphrase, verify spinner + successful unlock
  3. Entry list: verify entries load from remote vault
  4. Add entry: create a new entry with a group
  5. Entry detail: verify TOTP countdown (if entry has TOTP), copy shortcuts
  6. Autofill: navigate to a login page, verify field icon appears, click to fill
  7. Lock/re-unlock: close popup, wait, reopen — verify re-unlock is required
  • Step 5: Fix any issues found during manual testing

Address any bugs or UX issues discovered during manual testing. Each fix gets its own commit.

  • Step 6: Final commit
git add -A
git commit -m "feat: complete WASM + Chrome MV3 extension build"

Task Summary

Task Description Dependencies
0 Add heavy comments to existing Rust code None
1 Add group field to core data model Task 0
2 Create relicario-wasm crate Task 1
3 Extension scaffolding Task 2
4 Shared types and messages Task 3
5 Git API layer Task 4
6 Service worker — vault operations Task 4, 5
7 Service worker — main entry point Task 5, 6
8 Popup styles Task 3
9 Popup state machine and components Task 4, 7, 8
10 Content script Task 4
11 Build integration and manual test All

Tasks 3-4, 5, 8 can be parallelized. Tasks 6-7 are sequential. Task 9 depends on 7+8. Task 10 is independent of 9. Task 11 is the final integration.