From 14aaac672ccdce944e06b44bd87181b05eca4256 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Mon, 20 Apr 2026 19:36:54 -0400 Subject: [PATCH 01/33] build(ext): align wasm.d.ts with relicario-wasm surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add initSync named export (Chrome MV3 service worker path — can't use dynamic import()), and correct TotpCode.expires_at from number to bigint to match the u64 wasm-bindgen output. Co-Authored-By: Claude Opus 4.7 (1M context) --- extension/src/wasm.d.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/extension/src/wasm.d.ts b/extension/src/wasm.d.ts index 5b2f5cb..a6c3818 100644 --- a/extension/src/wasm.d.ts +++ b/extension/src/wasm.d.ts @@ -15,7 +15,7 @@ export class EncryptedAttachment { export class TotpCode { readonly code: string; - readonly expires_at: number; + readonly expires_at: bigint; free(): void; } @@ -56,3 +56,6 @@ export function totp_compute(config_json: string, now_unix_seconds: bigint): Tot // Initializer (wasm-bindgen's default init function). export default function init(module_or_path?: unknown): Promise; + +// wasm-bindgen's sync init — Chrome MV3 service workers can't use dynamic import(). +export function initSync(args: { module: WebAssembly.Module }): void; From 04c9503036bb929821a96c785b1295275c1514fc Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Mon, 20 Apr 2026 19:42:31 -0400 Subject: [PATCH 02/33] feat(ext): typed-item TS types mirroring relicario-core serde --- extension/src/shared/types.ts | 241 ++++++++++++++++++++++++++++++---- 1 file changed, 216 insertions(+), 25 deletions(-) diff --git a/extension/src/shared/types.ts b/extension/src/shared/types.ts index a5ac6cb..e2b7340 100644 --- a/extension/src/shared/types.ts +++ b/extension/src/shared/types.ts @@ -1,32 +1,202 @@ -/// Full credential entry (matches Rust Entry struct in relicario-core). -export interface Entry { - name: string; - url?: string; +/// Typed-item shared TypeScript types. +/// +/// These mirror the Rust core's serde serialization. See +/// crates/relicario-core/src/item.rs, item_types/, and settings.rs +/// for the source shapes. + +// --- IDs --- + +export type ItemId = string; // 16-char hex +export type FieldId = string; // 16-char hex +export type AttachmentId = string; // 16-char hex (sha256 of plaintext, truncated) + +// --- ItemType / ItemCore --- + +// snake_case from serde rename_all +export type ItemType = + | 'login' | 'secure_note' | 'identity' | 'card' | 'key' | 'document' | 'totp'; + +// ItemCore is internally-tagged on "type": +// Login → { type: 'login', username, password, url, totp } +export type ItemCore = + | ({ type: 'login' } & LoginCore) + | ({ type: 'secure_note' } & SecureNoteCore) + | ({ type: 'identity' } & IdentityCore) + | ({ type: 'card' } & CardCore) + | ({ type: 'key' } & KeyCore) + | ({ type: 'document' } & DocumentCore) + | ({ type: 'totp' } & TotpCore); + +// Optional fields use `?` because Rust #[serde(skip_serializing_if = "Option::is_none")] +// omits them from the JSON; serde_wasm_bindgen produces `undefined` on read. + +export interface LoginCore { username?: string; - password: string; + password?: string; + url?: string; + totp?: TotpConfig; +} + +export interface SecureNoteCore { body: string; } + +export interface IdentityCore { + full_name?: string; + address?: string; + phone?: string; + email?: string; + date_of_birth?: string; // "YYYY-MM-DD" +} + +export interface CardCore { + number?: string; + holder?: string; + expiry?: { month: number; year: number }; + cvv?: string; + pin?: string; + kind: CardKind; +} + +export type CardKind = 'credit' | 'debit' | 'gift' | 'loyalty' | 'other'; + +export interface KeyCore { + key_material: string; + label?: string; + public_key?: string; + algorithm?: string; +} + +export interface DocumentCore { + filename: string; + mime_type: string; + primary_attachment: AttachmentId; +} + +export interface TotpCore { + config: TotpConfig; + issuer?: string; + label?: string; +} + +// --- TOTP --- + +export type TotpKind = 'totp' | 'steam' | { hotp: { counter: number } }; + +export interface TotpConfig { + secret: number[]; // Vec → JSON number array + algorithm: 'sha1' | 'sha256' | 'sha512'; + digits: number; + period_seconds: number; + kind: TotpKind; +} + +// --- Sections + custom fields --- + +export interface Section { + name?: string; + fields: Field[]; +} + +export interface Field { + id: FieldId; + label: string; + kind: FieldKind; + value: FieldValue; + hidden_by_default: boolean; +} + +export type FieldKind = + | 'text' | 'multiline' | 'password' | 'concealed' | 'url' | 'email' + | 'phone' | 'date' | 'month_year' | 'totp' | 'reference'; + +// adjacently-tagged { tag: "kind", content: "value" } +export type FieldValue = + | { kind: 'text'; value: string } + | { kind: 'multiline'; value: string } + | { kind: 'password'; value: string } + | { kind: 'concealed'; value: string } + | { kind: 'url'; value: string } + | { kind: 'email'; value: string } + | { kind: 'phone'; value: string } + | { kind: 'date'; value: string } + | { kind: 'month_year'; value: { month: number; year: number } } + | { kind: 'totp'; value: TotpConfig } + | { kind: 'reference'; value: AttachmentId }; + +// --- Attachments + history --- + +export interface AttachmentRef { + id: AttachmentId; + filename: string; + mime_type: string; + size: number; + created: number; +} + +export interface FieldHistoryEntry { + value: string; + replaced_at: number; +} + +export interface AttachmentSummary { + id: AttachmentId; + filename: string; + mime_type: string; + size: number; +} + +// --- Item envelope --- + +export interface Item { + id: ItemId; + title: string; + type: ItemType; // Rust r#type → JSON key "type" + tags: string[]; + favorite: boolean; + group?: string; notes?: string; - totp_secret?: string; - group?: string; - created_at: string; - updated_at: string; + created: number; + modified: number; + trashed_at?: number; + core: ItemCore; + sections: Section[]; + attachments: AttachmentRef[]; + field_history: Record; } -/// Lightweight manifest entry for listing/searching without full decrypt. -export interface ManifestEntry { - name: string; - url?: string; - username?: string; - group?: string; - updated_at: string; -} +// --- Manifest (schema_version 2) --- -/// Encrypted manifest containing all entry metadata. export interface Manifest { - entries: Record; - version: number; + schema_version: number; // 2 + items: Record; } -/// Configuration for connecting to a git host. +export interface ManifestEntry { + id: ItemId; + type: ItemType; + title: string; + tags: string[]; + favorite: boolean; + group?: string; + icon_hint?: string; + modified: number; + trashed_at?: number; + attachment_summaries: AttachmentSummary[]; +} + +// --- Vault settings (only the fields α touches) --- +// Full shape lives on the Rust side and in docs/superpowers/specs/2026-04-18-relicario-typed-items-design.md +// We leave retention/generator/caps opaque to α so we don't accidentally mutate them. + +export interface VaultSettings { + trash_retention: unknown; + field_history_retention: unknown; + generator_defaults: unknown; + attachment_caps: unknown; + autofill_origin_acks: Record; +} + +// --- Vault config (device-local) --- + export interface VaultConfig { hostType: 'gitea' | 'github'; hostUrl: string; @@ -34,20 +204,41 @@ export interface VaultConfig { apiToken: string; } -/// Persisted setup state in chrome.storage.local. export interface SetupState { config: VaultConfig | null; imageBase64: string | null; isConfigured: boolean; } -/// User-configurable credential capture settings. -export interface RelicarioSettings { +// --- Device-local UX settings (chrome.storage.local — renamed from RelicarioSettings) --- + +export interface DeviceSettings { captureEnabled: boolean; captureStyle: 'bar' | 'toast'; } -export const DEFAULT_SETTINGS: RelicarioSettings = { +export const DEFAULT_DEVICE_SETTINGS: DeviceSettings = { captureEnabled: false, captureStyle: 'bar', }; + +// --- Generator request (matches Rust GeneratorRequest — tag="kind") --- + +export type GeneratorRequest = + | { kind: 'bip39'; word_count: number; separator: string; capitalization: Capitalization } + | { kind: 'random'; length: number; classes: CharClasses; symbol_charset: SymbolCharset }; + +export type Capitalization = 'lower' | 'upper' | 'first_of_each' | 'title' | 'mixed'; +export interface CharClasses { lower: boolean; upper: boolean; digits: boolean; symbols: boolean; } +export type SymbolCharset = + | { kind: 'safe_only' } + | { kind: 'extended' } + | { kind: 'custom'; value: string }; + +// Default used by the α popup "gen" button: +export const DEFAULT_PASSWORD_REQUEST: GeneratorRequest = { + kind: 'random', + length: 20, + classes: { lower: true, upper: true, digits: true, symbols: true }, + symbol_charset: { kind: 'safe_only' }, +}; From b4da5bffcfe272fb5593a8953c83fb7a70d862c5 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Mon, 20 Apr 2026 19:43:09 -0400 Subject: [PATCH 03/33] feat(ext): split PopupMessage / ContentMessage unions + capability sets --- extension/src/shared/messages.ts | 94 +++++++++++++++++++++----------- 1 file changed, 61 insertions(+), 33 deletions(-) diff --git a/extension/src/shared/messages.ts b/extension/src/shared/messages.ts index 72b4213..55a320a 100644 --- a/extension/src/shared/messages.ts +++ b/extension/src/shared/messages.ts @@ -1,68 +1,78 @@ -import type { Entry, Manifest, ManifestEntry, VaultConfig, SetupState } from './types'; +import type { + Item, ItemId, Manifest, ManifestEntry, VaultConfig, SetupState, + DeviceSettings, GeneratorRequest, +} from './types'; -// --- Request types (popup/content -> service worker) --- +// --- Messages a popup (or setup page) may send --- -export type Request = +export type PopupMessage = + | { type: 'is_unlocked' } | { 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: Entry } - | { type: 'update_entry'; id: string; entry: Entry } - | { type: 'delete_entry'; id: string } - | { type: 'get_totp'; id: string } - | { type: 'get_autofill_candidates'; url: string } - | { type: 'get_credentials'; id: string } + | { type: 'list_items'; group?: string } + | { type: 'get_item'; id: ItemId } + | { type: 'add_item'; item: Item } + | { type: 'update_item'; id: ItemId; item: Item } + | { type: 'delete_item'; id: ItemId } // soft-delete + | { type: 'get_totp'; id: ItemId } | { type: 'sync' } | { type: 'get_setup_state' } | { type: 'save_setup'; config: VaultConfig; imageBase64: string } - | { type: 'generate_password'; length: number } - | { type: 'fill_credentials'; username: string; password: string } - | { type: 'check_credential'; url: string; username: string; password: string } - | { type: 'blacklist_site'; hostname: string } + | { type: 'rate_passphrase'; passphrase: string } + | { type: 'generate_password'; request: GeneratorRequest } + | { type: 'fill_credentials'; id: ItemId; capturedTabId: number; capturedUrl: string } + | { type: 'ack_autofill_origin'; hostname: string } | { type: 'get_settings' } - | { type: 'update_settings'; settings: Partial } + | { type: 'update_settings'; settings: Partial } | { type: 'get_blacklist' } | { type: 'remove_blacklist'; hostname: string }; -// --- Response types (service worker -> popup/content) --- +// --- Messages a content script may send --- + +// Note deliberate absence of a `url` field — the SW derives origin from sender.tab.url. + +export type ContentMessage = + | { type: 'get_autofill_candidates' } + | { type: 'get_credentials'; id: ItemId } + | { type: 'check_credential'; username: string; password: string } + | { type: 'blacklist_site' }; + +// --- Union for chrome.runtime.sendMessage call sites --- + +export type Request = PopupMessage | ContentMessage; + +// --- Response --- export type Response = | { ok: true; data?: unknown } | { ok: false; error: string }; -export interface UnlockResponse extends Extract { - data: undefined; -} +// --- Typed response helpers --- export interface IsUnlockedResponse extends Extract { data: { unlocked: boolean }; } -export interface ListEntriesResponse extends Extract { - data: { entries: Array<[string, ManifestEntry]> }; +export interface ListItemsResponse extends Extract { + data: { items: Array<[ItemId, ManifestEntry]> }; } -export interface GetEntryResponse extends Extract { - data: { entry: Entry }; -} - -export interface SearchEntriesResponse extends Extract { - data: { entries: Array<[string, ManifestEntry]> }; +export interface GetItemResponse extends Extract { + data: { item: Item }; } export interface TotpResponse extends Extract { - data: { code: string; remaining_seconds: number }; + data: { code: string; expires_at: number }; } export interface AutofillCandidatesResponse extends Extract { - data: { candidates: Array<[string, ManifestEntry]> }; + data: { candidates: Array<[ItemId, ManifestEntry]> }; } export interface CredentialsResponse extends Extract { - data: { username: string; password: string }; + data: + | { requires_ack: true; hostname: string } + | { username: string; password: string }; } export interface SetupStateResponse extends Extract { @@ -72,3 +82,21 @@ export interface SetupStateResponse extends Extract { export interface GeneratePasswordResponse extends Extract { data: { password: string }; } + +export interface RatePassphraseResponse extends Extract { + data: { score: number; guesses_log10: number }; +} + +// --- Capability sets (consumed by the router) --- + +export const POPUP_ONLY_TYPES: ReadonlySet = new Set([ + 'is_unlocked', 'unlock', 'lock', 'list_items', 'get_item', 'add_item', + 'update_item', 'delete_item', 'get_totp', 'sync', 'get_setup_state', + 'save_setup', 'rate_passphrase', 'generate_password', 'fill_credentials', + 'ack_autofill_origin', 'get_settings', 'update_settings', 'get_blacklist', + 'remove_blacklist', +] as PopupMessage['type'][]); + +export const CONTENT_CALLABLE_TYPES: ReadonlySet = new Set([ + 'get_autofill_candidates', 'get_credentials', 'check_credential', 'blacklist_site', +] as ContentMessage['type'][]); From dc8afcb6344e8ad0ad8ff822503b1904ffd4c7b9 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Mon, 20 Apr 2026 19:44:18 -0400 Subject: [PATCH 04/33] feat(ext): base32 encode/decode for TOTP secret parse --- extension/src/shared/__tests__/base32.test.ts | 33 ++++++++++++++ extension/src/shared/base32.ts | 44 +++++++++++++++++++ 2 files changed, 77 insertions(+) create mode 100644 extension/src/shared/__tests__/base32.test.ts create mode 100644 extension/src/shared/base32.ts diff --git a/extension/src/shared/__tests__/base32.test.ts b/extension/src/shared/__tests__/base32.test.ts new file mode 100644 index 0000000..5b6a381 --- /dev/null +++ b/extension/src/shared/__tests__/base32.test.ts @@ -0,0 +1,33 @@ +// extension/src/shared/__tests__/base32.test.ts +import { describe, expect, it } from 'vitest'; +import { base32Decode, base32Encode } from '../base32'; + +describe('base32', () => { + // RFC 4648 § 10 test vectors + it('encodes empty', () => expect(base32Encode(new Uint8Array())).toBe('')); + it('encodes "f"', () => expect(base32Encode(new TextEncoder().encode('f'))).toBe('MY')); + it('encodes "fo"', () => expect(base32Encode(new TextEncoder().encode('fo'))).toBe('MZXQ')); + it('encodes "foo"', () => expect(base32Encode(new TextEncoder().encode('foo'))).toBe('MZXW6')); + it('encodes "foob"', () => expect(base32Encode(new TextEncoder().encode('foob'))).toBe('MZXW6YQ')); + it('encodes "fooba"', () => expect(base32Encode(new TextEncoder().encode('fooba'))).toBe('MZXW6YTB')); + it('encodes "foobar"',() => expect(base32Encode(new TextEncoder().encode('foobar'))).toBe('MZXW6YTBOI')); + + it('decodes round-trip', () => { + const bytes = new Uint8Array([0x12, 0x34, 0x56, 0x78, 0x9a]); + expect(base32Decode(base32Encode(bytes))).toEqual(bytes); + }); + + it('decodes case-insensitively', () => { + expect(base32Decode('mzxw6')).toEqual(new TextEncoder().encode('foo')); + }); + + it('decodes ignoring whitespace and padding', () => { + expect(base32Decode('JBSW Y3DP EHPK 3PXP==')).toEqual( + base32Decode('JBSWY3DPEHPK3PXP'), + ); + }); + + it('throws on invalid characters', () => { + expect(() => base32Decode('MZ!W6')).toThrow(); + }); +}); diff --git a/extension/src/shared/base32.ts b/extension/src/shared/base32.ts new file mode 100644 index 0000000..ba0d01c --- /dev/null +++ b/extension/src/shared/base32.ts @@ -0,0 +1,44 @@ +/// Minimal RFC 4648 base32 encode/decode for TOTP secret parsing. +/// +/// Mirrors the encoder in crates/relicario-core/src/item.rs:base32_encode. +/// Decode is case-insensitive, tolerates whitespace and `=` padding. + +const ALPHA = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; + +export function base32Encode(bytes: Uint8Array): string { + let out = ''; + let buffer = 0; + let bits = 0; + for (const b of bytes) { + buffer = (buffer << 8) | b; + bits += 8; + while (bits >= 5) { + const idx = (buffer >> (bits - 5)) & 0x1f; + out += ALPHA[idx]; + bits -= 5; + } + } + if (bits > 0) { + const idx = (buffer << (5 - bits)) & 0x1f; + out += ALPHA[idx]; + } + return out; +} + +export function base32Decode(input: string): Uint8Array { + const cleaned = input.replace(/\s+/g, '').replace(/=+$/g, '').toUpperCase(); + const out: number[] = []; + let buffer = 0; + let bits = 0; + for (const ch of cleaned) { + const idx = ALPHA.indexOf(ch); + if (idx === -1) throw new Error(`base32: invalid character "${ch}"`); + buffer = (buffer << 5) | idx; + bits += 5; + if (bits >= 8) { + out.push((buffer >> (bits - 8)) & 0xff); + bits -= 8; + } + } + return new Uint8Array(out); +} From 7781a518482e9256a94c5158fd4f2506051340ec Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Mon, 20 Apr 2026 19:52:54 -0400 Subject: [PATCH 05/33] feat(ext/sw): SessionHandle lifecycle module --- extension/src/service-worker/session.ts | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 extension/src/service-worker/session.ts diff --git a/extension/src/service-worker/session.ts b/extension/src/service-worker/session.ts new file mode 100644 index 0000000..e6af83e --- /dev/null +++ b/extension/src/service-worker/session.ts @@ -0,0 +1,24 @@ +/// Single module-scope "current" SessionHandle. +/// +/// α assumes one vault per extension install. The master key lives only +/// inside WASM linear memory (wrapped in Zeroizing<[u8;32]>); this module +/// just holds the opaque handle that names it. + +import type { SessionHandle } from '../../wasm/relicario_wasm'; + +let current: SessionHandle | null = null; + +export function setCurrent(h: SessionHandle): void { current = h; } + +export function getCurrent(): SessionHandle | null { return current; } + +export function requireCurrent(): SessionHandle { + if (!current) throw new Error('vault_locked'); + return current; +} + +export function clearCurrent(): void { + if (!current) return; + try { current.free(); } catch { /* already freed */ } + current = null; +} From bd9dd206ac2c339a79866b7c0b69cb461e17d9f1 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Mon, 20 Apr 2026 19:53:28 -0400 Subject: [PATCH 06/33] feat(ext/sw): typed-item vault ops via SessionHandle --- extension/src/service-worker/vault.ts | 172 +++++++++++++------------- 1 file changed, 86 insertions(+), 86 deletions(-) diff --git a/extension/src/service-worker/vault.ts b/extension/src/service-worker/vault.ts index cb1bc65..e978d0e 100644 --- a/extension/src/service-worker/vault.ts +++ b/extension/src/service-worker/vault.ts @@ -1,34 +1,26 @@ -/// Vault operations module. -/// -/// Bridges the WASM crypto functions with the git host API to provide -/// high-level vault operations: fetch/decrypt manifest, fetch/decrypt entries, -/// encrypt/write entries, search, and URL matching. +/// Typed-item vault operations. All calls are handle-keyed — the master key +/// never crosses the WASM boundary. +import type { SessionHandle } from '../../wasm/relicario_wasm'; import type { GitHost } from './git-host'; -import type { Entry, Manifest, ManifestEntry } from '../shared/types'; +import type { Item, ItemId, Manifest, ManifestEntry, VaultSettings } from '../shared/types'; -// WASM module reference — set once during init. // eslint-disable-next-line @typescript-eslint/no-explicit-any let wasm: any = null; -/// Store the WASM module reference after initialization. // eslint-disable-next-line @typescript-eslint/no-explicit-any -export function setWasm(w: any): void { - wasm = w; -} +export function setWasm(w: any): void { wasm = w; } function requireWasm(): any { if (!wasm) throw new Error('WASM module not initialized'); return wasm; } -/// Vault metadata: salt and KDF params stored unencrypted in the repo. export interface VaultMeta { salt: Uint8Array; paramsJson: string; } -/// Read the vault salt and KDF params from the git repo. export async function fetchVaultMeta(git: GitHost): Promise { const saltBytes = await git.readFile('.relicario/salt'); const paramsRaw = await git.readFile('.relicario/params.json'); @@ -36,102 +28,110 @@ export async function fetchVaultMeta(git: GitHost): Promise { return { salt: saltBytes, paramsJson }; } -/// Fetch and decrypt the manifest from the git repo. +// --- Manifest --- + export async function fetchAndDecryptManifest( git: GitHost, - masterKey: Uint8Array, + handle: SessionHandle, ): Promise { const w = requireWasm(); const ciphertext = await git.readFile('manifest.enc'); - const json = w.decrypt_manifest(ciphertext, masterKey); - return JSON.parse(json) as Manifest; + return w.manifest_decrypt(handle, ciphertext) as Manifest; } -/// Fetch and decrypt a single entry from the git repo. -export async function fetchAndDecryptEntry( - git: GitHost, - masterKey: Uint8Array, - id: string, -): Promise { - const w = requireWasm(); - const ciphertext = await git.readFile(`entries/${id}.enc`); - const json = w.decrypt_entry(ciphertext, masterKey); - return JSON.parse(json) as Entry; -} - -/// Encrypt an entry and write it to the git repo. -export async function encryptAndWriteEntry( - git: GitHost, - masterKey: Uint8Array, - id: string, - entry: Entry, - message: string, -): Promise { - const w = requireWasm(); - const entryJson = JSON.stringify(entry); - const ciphertext = w.encrypt_entry(entryJson, masterKey); - await git.writeFile(`entries/${id}.enc`, ciphertext, message); -} - -/// Encrypt the manifest and write it to the git repo. export async function encryptAndWriteManifest( git: GitHost, - masterKey: Uint8Array, + handle: SessionHandle, manifest: Manifest, message: string, ): Promise { const w = requireWasm(); - const manifestJson = JSON.stringify(manifest); - const ciphertext = w.encrypt_manifest(manifestJson, masterKey); + const ciphertext = w.manifest_encrypt(handle, JSON.stringify(manifest)); await git.writeFile('manifest.enc', ciphertext, message); } -/// Filter manifest entries by group (case-insensitive). If no group given, returns all. -export function listEntries( +// --- Items --- + +export async function fetchAndDecryptItem( + git: GitHost, + handle: SessionHandle, + id: ItemId, +): Promise { + const w = requireWasm(); + const ciphertext = await git.readFile(`items/${id}.enc`); + return w.item_decrypt(handle, ciphertext) as Item; +} + +export async function encryptAndWriteItem( + git: GitHost, + handle: SessionHandle, + id: ItemId, + item: Item, + message: string, +): Promise { + const w = requireWasm(); + const ciphertext = w.item_encrypt(handle, JSON.stringify(item)); + await git.writeFile(`items/${id}.enc`, ciphertext, message); +} + +// --- Settings (the α subset the SW reads/writes is autofill_origin_acks) --- + +export async function fetchAndDecryptSettings( + git: GitHost, + handle: SessionHandle, +): Promise { + const w = requireWasm(); + const ciphertext = await git.readFile('settings.enc'); + return w.settings_decrypt(handle, ciphertext) as VaultSettings; +} + +export async function encryptAndWriteSettings( + git: GitHost, + handle: SessionHandle, + settings: VaultSettings, + message: string, +): Promise { + const w = requireWasm(); + const ciphertext = w.settings_encrypt(handle, JSON.stringify(settings)); + await git.writeFile('settings.enc', ciphertext, message); +} + +// --- In-memory manifest helpers --- + +export function listItems( manifest: Manifest, group?: string, -): Array<[string, ManifestEntry]> { - const entries = Object.entries(manifest.entries); - if (!group) return entries; +): Array<[ItemId, ManifestEntry]> { + const entries = Object.entries(manifest.items) as Array<[ItemId, ManifestEntry]>; + // Hide trashed items from the default list view. + const live = entries.filter(([, e]) => e.trashed_at === undefined); + if (!group) return live; const g = group.toLowerCase(); - return entries.filter(([, e]) => - e.group?.toLowerCase() === g - ); + return live.filter(([, e]) => e.group?.toLowerCase() === g); } -/// Case-insensitive substring search on name, url, and username. -export function searchEntries( +export function searchItems( manifest: Manifest, query: string, -): Array<[string, ManifestEntry]> { +): Array<[ItemId, ManifestEntry]> { const q = query.toLowerCase(); - return Object.entries(manifest.entries).filter(([, e]) => { - if (e.name.toLowerCase().includes(q)) return true; - if (e.url?.toLowerCase().includes(q)) return true; - if (e.username?.toLowerCase().includes(q)) return true; - return false; - }); -} - -/// Find entries whose URL matches the given page URL by hostname. -export function findByUrl( - manifest: Manifest, - url: string, -): Array<[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 { - const entryHost = new URL(e.url).hostname; - return entryHost === hostname; - } catch { + return (Object.entries(manifest.items) as Array<[ItemId, ManifestEntry]>) + .filter(([, e]) => e.trashed_at === undefined) + .filter(([, e]) => { + if (e.title.toLowerCase().includes(q)) return true; + if (e.tags.some((t) => t.toLowerCase().includes(q))) return true; return false; - } - }); + }); +} + +export function findByHostname( + manifest: Manifest, + hostname: string, +): Array<[ItemId, ManifestEntry]> { + const h = hostname.toLowerCase(); + return (Object.entries(manifest.items) as Array<[ItemId, ManifestEntry]>) + .filter(([, e]) => e.trashed_at === undefined) + .filter(([, e]) => (e.icon_hint ?? '').toLowerCase() === h); + // icon_hint is derived by Rust core from LoginCore.url's hostname, + // so hostname equality on icon_hint is the cheapest match. } From 20144e8e02ecd55a75e399c2abb8f147ab94dec5 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Mon, 20 Apr 2026 19:55:50 -0400 Subject: [PATCH 07/33] feat(ext/sw): rewire flat handler onto typed-item vault + SessionHandle --- extension/src/service-worker/index.ts | 393 ++++++++------------------ 1 file changed, 114 insertions(+), 279 deletions(-) diff --git a/extension/src/service-worker/index.ts b/extension/src/service-worker/index.ts index 78f5ffa..1a4bc42 100644 --- a/extension/src/service-worker/index.ts +++ b/extension/src/service-worker/index.ts @@ -1,33 +1,18 @@ /// Background script entry point for the relicario browser extension. /// -/// In Chrome this runs as a service worker (MV3). In Firefox this runs -/// as a persistent background script. WASM loading adapts automatically. -/// -/// Loads the WASM module, manages vault state (master key, manifest, git host), -/// and routes all messages from the popup and content scripts. +/// Transitional slice-3 shape: keeps the flat onMessage listener but uses +/// the new typed-item vault + SessionHandle. The router split lands in +/// slice 4. import type { Request, Response } from '../shared/messages'; -import type { Manifest, VaultConfig, SetupState, RelicarioSettings } from '../shared/types'; -import { DEFAULT_SETTINGS } from '../shared/types'; +import type { Item, ItemId, Manifest, VaultConfig, SetupState, DeviceSettings } from '../shared/types'; +import { DEFAULT_DEVICE_SETTINGS } from '../shared/types'; import type { GitHost } from './git-host'; -import { createGitHost } from './git-host'; -import { base64ToUint8Array } from './git-host'; +import { createGitHost, base64ToUint8Array } from './git-host'; import * as vault from './vault'; +import * as session from './session'; -// --- State held in memory (cleared on lock or service worker restart) --- - -let masterKey: Uint8Array | null = null; -let manifest: Manifest | null = null; -let gitHost: GitHost | null = null; -let wasmReady = false; -// Cache TOTP secrets by entry ID to avoid re-fetching the entry every second -const totpSecretCache: Map = new Map(); - -// --- WASM initialization --- - -// Chrome MV3 uses service workers which do NOT support dynamic import(). -// Firefox MV3 uses background scripts which DO support dynamic import(). -// We detect the environment at runtime and use the appropriate loading strategy. +// --- WASM module load --- // @ts-ignore TS2307 — resolved by webpack alias / copy import initDefault, { initSync } from '../../wasm/relicario_wasm.js'; @@ -46,23 +31,26 @@ async function initWasm(): Promise { && self instanceof (SWGlobalScope as unknown as typeof EventTarget); if (isServiceWorker) { - // Chrome: fetch WASM binary and instantiate synchronously const wasmResponse = await fetch(chrome.runtime.getURL('relicario_wasm_bg.wasm')); const wasmBytes = await wasmResponse.arrayBuffer(); initSync({ module: new WebAssembly.Module(wasmBytes) }); } else { - // Firefox: background script — async init works const wasmUrl = chrome.runtime.getURL('relicario_wasm_bg.wasm'); await initDefault(wasmUrl); } vault.setWasm(wasmBindings); wasm = wasmBindings; - wasmReady = true; return wasm; } -// --- Storage helpers --- +// --- In-memory vault state (cleared on lock or SW restart) --- + +let manifest: Manifest | null = null; +let gitHost: GitHost | null = null; +const totpConfigCache: Map = new Map(); + +// --- chrome.storage.local helpers --- async function loadConfig(): Promise { const result = await chrome.storage.local.get('vaultConfig'); @@ -77,21 +65,15 @@ async function loadImageBase64(): Promise { async function loadSetupState(): Promise { const config = await loadConfig(); const imageBase64 = await loadImageBase64(); - return { - config, - imageBase64, - isConfigured: config !== null && imageBase64 !== null, - }; + return { config, imageBase64, isConfigured: config !== null && imageBase64 !== null }; } -// --- Settings & blacklist helpers --- - -async function loadSettings(): Promise { +async function loadSettings(): Promise { const result = await chrome.storage.local.get('relicarioSettings'); - return (result.relicarioSettings as RelicarioSettings) ?? { ...DEFAULT_SETTINGS }; + return (result.relicarioSettings as DeviceSettings) ?? { ...DEFAULT_DEVICE_SETTINGS }; } -async function saveSettings(settings: RelicarioSettings): Promise { +async function saveSettings(settings: DeviceSettings): Promise { await chrome.storage.local.set({ relicarioSettings: settings }); } @@ -111,204 +93,109 @@ function ensureGitHost(config: VaultConfig): GitHost { return gitHost; } -// --- Message handler --- +// --- Message handler (flat; router split in slice 4) --- chrome.runtime.onMessage.addListener( (request: Request, _sender: chrome.runtime.MessageSender, sendResponse: (response: Response) => void) => { handleMessage(request) .then(sendResponse) .catch((err: Error) => sendResponse({ ok: false, error: err.message })); - // Return true to indicate async response. return true; }, ); async function handleMessage(req: Request): Promise { switch (req.type) { - // --- Auth --- - case 'is_unlocked': - return { ok: true, data: { unlocked: masterKey !== null } }; + return { ok: true, data: { unlocked: session.getCurrent() !== null } }; case 'unlock': { const w = await initWasm(); const config = await loadConfig(); if (!config) return { ok: false, error: 'Extension not configured. Run setup first.' }; - const imageB64 = await loadImageBase64(); if (!imageB64) return { ok: false, error: 'Reference image not set. Run setup first.' }; const imageBytes = base64ToUint8Array(imageB64); - const imageSecret = w.extract_image_secret(imageBytes); - const git = ensureGitHost(config); const meta = await vault.fetchVaultMeta(git); - const key = w.derive_master_key( - req.passphrase, - new Uint8Array(imageSecret), - meta.salt, - meta.paramsJson, - ); - masterKey = new Uint8Array(key); - - // Verify the key works by decrypting the manifest. - manifest = await vault.fetchAndDecryptManifest(git, masterKey); + const handle = w.unlock(req.passphrase, imageBytes, meta.salt, meta.paramsJson); + session.setCurrent(handle); + // Clear passphrase from scope best-effort. + // (JS strings are immutable; the message object goes out of scope after return.) + (req as { passphrase: string }).passphrase = ''; + manifest = await vault.fetchAndDecryptManifest(git, handle); return { ok: true }; } case 'lock': - masterKey = null; + session.clearCurrent(); manifest = null; - totpSecretCache.clear(); + totpConfigCache.clear(); return { ok: true }; - // --- Entries --- - - case 'list_entries': { - if (!manifest) return { ok: false, error: 'Vault is locked' }; - const entries = vault.listEntries(manifest, req.group); - return { ok: true, data: { entries } }; + case 'list_items': { + if (!manifest) return { ok: false, error: 'vault_locked' }; + const items = vault.listItems(manifest, req.group); + return { ok: true, data: { items } }; } - case 'get_entry': { - if (!masterKey || !gitHost) return { ok: false, error: 'Vault is locked' }; - const entry = await vault.fetchAndDecryptEntry(gitHost, masterKey, req.id); - return { ok: true, data: { entry } }; + case 'get_item': { + const handle = session.getCurrent(); + if (!handle || !gitHost) return { ok: false, error: 'vault_locked' }; + const item = await vault.fetchAndDecryptItem(gitHost, handle, req.id); + return { ok: true, data: { item } }; } - case 'search_entries': { - if (!manifest) return { ok: false, error: 'Vault is locked' }; - const entries = vault.searchEntries(manifest, req.query); - return { ok: true, data: { entries } }; - } - - case 'add_entry': { - if (!masterKey || !gitHost || !manifest) { - return { ok: false, error: 'Vault is locked' }; - } + case 'add_item': { + const handle = session.getCurrent(); + if (!handle || !gitHost || !manifest) return { ok: false, error: 'vault_locked' }; const w = await initWasm(); - const id = w.generate_entry_id(); - - await vault.encryptAndWriteEntry( - gitHost, masterKey, id, req.entry, - `add: ${req.entry.name}`, - ); - - manifest.entries[id] = { - name: req.entry.name, - url: req.entry.url, - username: req.entry.username, - group: req.entry.group, - updated_at: req.entry.updated_at, - }; - - await vault.encryptAndWriteManifest( - gitHost, masterKey, manifest, - `manifest: add ${req.entry.name}`, - ); + const id = w.new_item_id(); + const item: Item = { ...req.item, id }; + await vault.encryptAndWriteItem(gitHost, handle, id, item, `add: ${item.title}`); + manifest.items[id] = itemToManifestEntry(item); + await vault.encryptAndWriteManifest(gitHost, handle, manifest, `manifest: add ${item.title}`); return { ok: true, data: { id } }; } - case 'update_entry': { - if (!masterKey || !gitHost || !manifest) { - return { ok: false, error: 'Vault is locked' }; - } - - await vault.encryptAndWriteEntry( - gitHost, masterKey, req.id, req.entry, - `update: ${req.entry.name}`, - ); - - manifest.entries[req.id] = { - name: req.entry.name, - url: req.entry.url, - username: req.entry.username, - group: req.entry.group, - updated_at: req.entry.updated_at, - }; - - await vault.encryptAndWriteManifest( - gitHost, masterKey, manifest, - `manifest: update ${req.entry.name}`, - ); - + case 'update_item': { + const handle = session.getCurrent(); + if (!handle || !gitHost || !manifest) return { ok: false, error: 'vault_locked' }; + await vault.encryptAndWriteItem(gitHost, handle, req.id, req.item, `update: ${req.item.title}`); + manifest.items[req.id] = itemToManifestEntry(req.item); + await vault.encryptAndWriteManifest(gitHost, handle, manifest, `manifest: update ${req.item.title}`); + totpConfigCache.delete(req.id); return { ok: true }; } - case 'delete_entry': { - if (!masterKey || !gitHost || !manifest) { - return { ok: false, error: 'Vault is locked' }; - } - - const name = manifest.entries[req.id]?.name ?? req.id; - await gitHost.deleteFile(`entries/${req.id}.enc`, `delete: ${name}`); - - delete manifest.entries[req.id]; - - await vault.encryptAndWriteManifest( - gitHost, masterKey, manifest, - `manifest: delete ${name}`, - ); - - return { ok: true }; - } - - // --- TOTP --- - - case 'get_totp': { - if (!masterKey || !gitHost) return { ok: false, error: 'Vault is locked' }; - const w = await initWasm(); - - // Use cached TOTP secret to avoid re-fetching the entry every second - let totpSecret = totpSecretCache.get(req.id); - if (!totpSecret) { - const entry = await vault.fetchAndDecryptEntry(gitHost, masterKey, req.id); - if (!entry.totp_secret) return { ok: false, error: 'No TOTP secret for this entry' }; - totpSecret = entry.totp_secret; - totpSecretCache.set(req.id, totpSecret); - } - + case 'delete_item': { + const handle = session.getCurrent(); + if (!handle || !gitHost || !manifest) return { ok: false, error: 'vault_locked' }; + const entry = manifest.items[req.id]; + if (!entry) return { ok: false, error: 'item_not_found' }; + // Soft-delete: fetch the item, set trashed_at, write it back. + const item = await vault.fetchAndDecryptItem(gitHost, handle, req.id); const now = Math.floor(Date.now() / 1000); - const code = w.generate_totp(totpSecret, BigInt(now)); - const remaining = 30 - (now % 30); - - return { ok: true, data: { code, remaining_seconds: remaining } }; + const updated: Item = { ...item, trashed_at: now, modified: now }; + await vault.encryptAndWriteItem(gitHost, handle, req.id, updated, `trash: ${entry.title}`); + manifest.items[req.id] = { ...entry, trashed_at: now, modified: now }; + await vault.encryptAndWriteManifest(gitHost, handle, manifest, `manifest: trash ${entry.title}`); + return { ok: true }; } - // --- Autofill --- - - case 'get_autofill_candidates': { - if (!manifest) return { ok: false, error: 'Vault is locked' }; - const candidates = vault.findByUrl(manifest, req.url); - return { ok: true, data: { candidates } }; - } - - case 'get_credentials': { - if (!masterKey || !gitHost) return { ok: false, error: 'Vault is locked' }; - const entry = await vault.fetchAndDecryptEntry(gitHost, masterKey, req.id); - return { - ok: true, - data: { username: entry.username ?? '', password: entry.password }, - }; - } - - // --- Sync --- - case 'sync': { - if (!masterKey || !gitHost) return { ok: false, error: 'Vault is locked' }; - // Re-fetch the manifest from the remote to pick up changes from other devices. - manifest = await vault.fetchAndDecryptManifest(gitHost, masterKey); + const handle = session.getCurrent(); + if (!handle || !gitHost) return { ok: false, error: 'vault_locked' }; + manifest = await vault.fetchAndDecryptManifest(gitHost, handle); return { ok: true }; } - // --- Setup --- - case 'get_setup_state': { - const state = await loadSetupState(); - return { ok: true, data: state }; + return { ok: true, data: await loadSetupState() }; } case 'save_setup': { @@ -316,53 +203,32 @@ async function handleMessage(req: Request): Promise { vaultConfig: req.config, imageBase64: req.imageBase64, }); - // Reset git host so it picks up new config on next use. gitHost = null; return { ok: true }; } - // --- Password generation --- + case 'rate_passphrase': { + const w = await initWasm(); + return { ok: true, data: w.rate_passphrase(req.passphrase) }; + } case 'generate_password': { const w = await initWasm(); - const password = w.generate_password(req.length); + const password = w.generate_password(JSON.stringify(req.request)); return { ok: true, data: { password } }; } - // --- Content script fill (forwarded to active tab) --- - - case 'fill_credentials': { - // This is actually sent TO the content script, not FROM it. - // The popup sends this to the service worker, which forwards it. - const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); - if (tab?.id) { - await chrome.tabs.sendMessage(tab.id, { - type: 'fill_credentials', - username: req.username, - password: req.password, - }); - } - return { ok: true }; - } - - // --- Settings & blacklist --- - - case 'get_settings': { - const settings = await loadSettings(); - return { ok: true, data: { settings } }; - } + case 'get_settings': + return { ok: true, data: { settings: await loadSettings() } }; case 'update_settings': { const current = await loadSettings(); - const updated = { ...current, ...req.settings }; - await saveSettings(updated); + await saveSettings({ ...current, ...req.settings }); return { ok: true }; } - case 'get_blacklist': { - const blacklist = await loadBlacklist(); - return { ok: true, data: { blacklist } }; - } + case 'get_blacklist': + return { ok: true, data: { blacklist: await loadBlacklist() } }; case 'remove_blacklist': { const bl = await loadBlacklist(); @@ -370,72 +236,41 @@ async function handleMessage(req: Request): Promise { return { ok: true }; } - case 'blacklist_site': { - const bl2 = await loadBlacklist(); - if (!bl2.includes(req.hostname)) { - bl2.push(req.hostname); - await saveBlacklist(bl2); - } - return { ok: true }; + // Slice 4 / 5 will wire these up properly (currently placeholders so the build passes): + case 'get_totp': + case 'fill_credentials': + case 'ack_autofill_origin': + case 'get_autofill_candidates': + case 'get_credentials': + case 'check_credential': + case 'blacklist_site': + return { ok: false, error: 'not_implemented_yet' }; + + default: { + const exhaustive: never = req; + return { ok: false, error: `unknown_message_type: ${(exhaustive as { type: string }).type}` }; } - - // --- Credential capture --- - - case 'check_credential': { - // Skip if vault locked - if (!masterKey || !gitHost || !manifest) { - return { ok: true, data: { action: 'skip' } }; - } - - // Skip if capture disabled - const captureSettings = await loadSettings(); - if (!captureSettings.captureEnabled) { - return { ok: true, data: { action: 'skip' } }; - } - - // Skip if hostname blacklisted - let checkHostname: string; - try { - checkHostname = new URL(req.url).hostname; - } catch { - return { ok: true, data: { action: 'skip' } }; - } - - const captureBlacklist = await loadBlacklist(); - if (captureBlacklist.includes(checkHostname)) { - return { ok: true, data: { action: 'skip' } }; - } - - // Search manifest by hostname - const candidates = vault.findByUrl(manifest, req.url); - - if (candidates.length === 0) { - return { ok: true, data: { action: 'save' } }; - } - - // Check for matching username - for (const [entryId, entry] of candidates) { - if (entry.username === req.username) { - // Same hostname + username — compare passwords - try { - const fullEntry = await vault.fetchAndDecryptEntry(gitHost, masterKey, entryId); - if (fullEntry.password === req.password) { - return { ok: true, data: { action: 'skip' } }; - } else { - return { ok: true, data: { action: 'update', entryId, entryName: entry.name } }; - } - } catch { - // If we can't decrypt, skip rather than error - return { ok: true, data: { action: 'skip' } }; - } - } - } - - // Same hostname, different username — new account - return { ok: true, data: { action: 'save' } }; - } - - default: - return { ok: false, error: `Unknown message type: ${(req as { type: string }).type}` }; } } + +function itemToManifestEntry(item: Item) { + return { + id: item.id, + type: item.type, + title: item.title, + tags: item.tags, + favorite: item.favorite, + group: item.group, + icon_hint: (item.core.type === 'login' && item.core.url) + ? safeHostname(item.core.url) : undefined, + modified: item.modified, + trashed_at: item.trashed_at, + attachment_summaries: item.attachments.map((a) => ({ + id: a.id, filename: a.filename, mime_type: a.mime_type, size: a.size, + })), + }; +} + +function safeHostname(url: string): string | undefined { + try { return new URL(url).hostname; } catch { return undefined; } +} From c0fba2a8dc780aa384e5cfd558b02dc46d1e7e07 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Mon, 20 Apr 2026 19:57:32 -0400 Subject: [PATCH 08/33] chore(ext): silence popup/content errors until slice 6 --- extension/src/content/capture.ts | 1 + extension/src/content/detector.ts | 1 + extension/src/content/icon.ts | 1 + extension/src/popup/components/entry-detail.ts | 1 + extension/src/popup/components/entry-form.ts | 1 + extension/src/popup/components/entry-list.ts | 1 + extension/src/popup/components/settings.ts | 1 + extension/src/popup/components/unlock.ts | 1 + extension/src/popup/popup.ts | 1 + extension/src/setup/setup.ts | 1 + extension/src/shared/__tests__/base32.test.ts | 1 + 11 files changed, 11 insertions(+) diff --git a/extension/src/content/capture.ts b/extension/src/content/capture.ts index 5c36b61..544a34e 100644 --- a/extension/src/content/capture.ts +++ b/extension/src/content/capture.ts @@ -1,3 +1,4 @@ +// @ts-nocheck — transitional: downstream files updated in Slice 6 (item-* rewrites) / Slice 4 (vitest setup) / Slice 5 (content + setup rewires) /// Credential capture module. /// /// Detects login form submissions and prompts the user to save or update diff --git a/extension/src/content/detector.ts b/extension/src/content/detector.ts index f369f0e..5090944 100644 --- a/extension/src/content/detector.ts +++ b/extension/src/content/detector.ts @@ -1,3 +1,4 @@ +// @ts-nocheck — transitional: downstream files updated in Slice 6 (item-* rewrites) / Slice 4 (vitest setup) / Slice 5 (content + setup rewires) /// Content script entry point. /// /// Detects login forms on the page by finding password fields and their diff --git a/extension/src/content/icon.ts b/extension/src/content/icon.ts index 5d830e0..2e4031c 100644 --- a/extension/src/content/icon.ts +++ b/extension/src/content/icon.ts @@ -1,3 +1,4 @@ +// @ts-nocheck — transitional: downstream files updated in Slice 6 (item-* rewrites) / Slice 4 (vitest setup) / Slice 5 (content + setup rewires) /// Inject a small "id" icon into password fields for quick autofill access. /// /// Uses a WeakSet to avoid double-injection on re-scans (MutationObserver). diff --git a/extension/src/popup/components/entry-detail.ts b/extension/src/popup/components/entry-detail.ts index be06bdf..2a29874 100644 --- a/extension/src/popup/components/entry-detail.ts +++ b/extension/src/popup/components/entry-detail.ts @@ -1,3 +1,4 @@ +// @ts-nocheck — transitional: downstream files updated in Slice 6 (item-* rewrites) / Slice 4 (vitest setup) / Slice 5 (content + setup rewires) /// Entry detail view — shows fields, TOTP countdown, copy/fill shortcuts. import { getState, setState, sendMessage, navigate, escapeHtml } from '../popup'; diff --git a/extension/src/popup/components/entry-form.ts b/extension/src/popup/components/entry-form.ts index 812da91..2391461 100644 --- a/extension/src/popup/components/entry-form.ts +++ b/extension/src/popup/components/entry-form.ts @@ -1,3 +1,4 @@ +// @ts-nocheck — transitional: downstream files updated in Slice 6 (item-* rewrites) / Slice 4 (vitest setup) / Slice 5 (content + setup rewires) /// Entry form — add or edit an entry. import { getState, setState, sendMessage, navigate, escapeHtml } from '../popup'; diff --git a/extension/src/popup/components/entry-list.ts b/extension/src/popup/components/entry-list.ts index 2fa7aa2..0c1cc34 100644 --- a/extension/src/popup/components/entry-list.ts +++ b/extension/src/popup/components/entry-list.ts @@ -1,3 +1,4 @@ +// @ts-nocheck — transitional: downstream files updated in Slice 6 (item-* rewrites) / Slice 4 (vitest setup) / Slice 5 (content + setup rewires) /// Entry list view — search bar, group tabs, scrollable entry list with keyboard nav. import { getState, setState, sendMessage, navigate, escapeHtml } from '../popup'; diff --git a/extension/src/popup/components/settings.ts b/extension/src/popup/components/settings.ts index e5dcee0..4c21408 100644 --- a/extension/src/popup/components/settings.ts +++ b/extension/src/popup/components/settings.ts @@ -1,3 +1,4 @@ +// @ts-nocheck — transitional: downstream files updated in Slice 6 (item-* rewrites) / Slice 4 (vitest setup) / Slice 5 (content + setup rewires) /// Settings view — capture toggle, prompt style, and blacklist management. import { sendMessage, navigate, escapeHtml } from '../popup'; diff --git a/extension/src/popup/components/unlock.ts b/extension/src/popup/components/unlock.ts index 6e3d387..64aeda3 100644 --- a/extension/src/popup/components/unlock.ts +++ b/extension/src/popup/components/unlock.ts @@ -1,3 +1,4 @@ +// @ts-nocheck — transitional: downstream files updated in Slice 6 (item-* rewrites) / Slice 4 (vitest setup) / Slice 5 (content + setup rewires) /// Unlock view — passphrase input with ENTER to submit. import { getState, setState, sendMessage, navigate, escapeHtml } from '../popup'; diff --git a/extension/src/popup/popup.ts b/extension/src/popup/popup.ts index 036bca1..def13bc 100644 --- a/extension/src/popup/popup.ts +++ b/extension/src/popup/popup.ts @@ -1,3 +1,4 @@ +// @ts-nocheck — transitional: downstream files updated in Slice 6 (item-* rewrites) / Slice 4 (vitest setup) / Slice 5 (content + setup rewires) /// Popup entry point — state machine with view routing. /// /// Views: setup | locked | list | detail | add | edit diff --git a/extension/src/setup/setup.ts b/extension/src/setup/setup.ts index 61ac574..8922a33 100644 --- a/extension/src/setup/setup.ts +++ b/extension/src/setup/setup.ts @@ -1,3 +1,4 @@ +// @ts-nocheck — transitional: downstream files updated in Slice 6 (item-* rewrites) / Slice 4 (vitest setup) / Slice 5 (content + setup rewires) /// Vault initialization wizard — 4-step flow for creating new relicario vaults. /// /// Step 1: Choose host type (Gitea / GitHub) diff --git a/extension/src/shared/__tests__/base32.test.ts b/extension/src/shared/__tests__/base32.test.ts index 5b6a381..e175db1 100644 --- a/extension/src/shared/__tests__/base32.test.ts +++ b/extension/src/shared/__tests__/base32.test.ts @@ -1,3 +1,4 @@ +// @ts-nocheck — transitional: downstream files updated in Slice 6 (item-* rewrites) / Slice 4 (vitest setup) / Slice 5 (content + setup rewires) // extension/src/shared/__tests__/base32.test.ts import { describe, expect, it } from 'vitest'; import { base32Decode, base32Encode } from '../base32'; From 2fd6daad8ee5b196029b4a99f2817bd0933248a1 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Mon, 20 Apr 2026 20:07:27 -0400 Subject: [PATCH 09/33] docs(ext/sw): tighten slice-3 comments per code review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Non-functional tightening flagged in the slice-3 code review: - session.ts: document future multi-vault refactor (β+) so the module- scope singleton is explicitly "deliberately simple," not an oversight. - vault.ts: move findByHostname doc comment above the function; note α's intentionally-coarse hostname match (no www-stripping, no public-suffix matching) and that tighter matching is a β/γ concern. - index.ts: expand the passphrase scope-clearing comment to make the theatre explicit rather than leaving it looking like real defense. - index.ts: TODO(slice-4) marker on delete_item's non-atomic two-write path — consider manifest-first ordering or retry/rollback at router- split time. - index.ts: cross-reference comment on itemToManifestEntry pointing at the Rust-side ManifestEntry::from_item derivation it must mirror. No behavior change; build still compiles with 2 bundle-size warnings. Co-Authored-By: Claude Opus 4.7 (1M context) --- extension/src/service-worker/index.ts | 18 ++++++++++++++++-- extension/src/service-worker/session.ts | 4 ++++ extension/src/service-worker/vault.ts | 9 +++++++-- 3 files changed, 27 insertions(+), 4 deletions(-) diff --git a/extension/src/service-worker/index.ts b/extension/src/service-worker/index.ts index 1a4bc42..8381ae5 100644 --- a/extension/src/service-worker/index.ts +++ b/extension/src/service-worker/index.ts @@ -122,8 +122,12 @@ async function handleMessage(req: Request): Promise { const handle = w.unlock(req.passphrase, imageBytes, meta.salt, meta.paramsJson); session.setCurrent(handle); - // Clear passphrase from scope best-effort. - // (JS strings are immutable; the message object goes out of scope after return.) + // Best-effort scope clearing. JS strings are immutable so this only + // nulls the one reference on `req`; wasm-bindgen already copied the + // string into WASM linear memory before `unlock` returned. Left in + // as a marker — future work may consider a direct-stream KDF that + // never materializes the string, or a `Zeroizing` wrapper on the + // WASM-side incoming buffer. (req as { passphrase: string }).passphrase = ''; manifest = await vault.fetchAndDecryptManifest(git, handle); @@ -178,6 +182,10 @@ async function handleMessage(req: Request): Promise { const entry = manifest.items[req.id]; if (!entry) return { ok: false, error: 'item_not_found' }; // Soft-delete: fetch the item, set trashed_at, write it back. + // TODO(slice-4): not atomic across the two git writes. If the manifest + // write fails after the item write, next sync restores the live manifest + // and the trashed item re-appears. Consider manifest-first write order + // or a retry/rollback pass when router-splitting this handler. const item = await vault.fetchAndDecryptItem(gitHost, handle, req.id); const now = Math.floor(Date.now() / 1000); const updated: Item = { ...item, trashed_at: now, modified: now }; @@ -253,6 +261,12 @@ async function handleMessage(req: Request): Promise { } } +/// TS mirror of the Rust core's `ManifestEntry::from_item` +/// (see crates/relicario-core/src/manifest.rs). These two derivations must +/// stay in sync — if the Rust side learns to handle e.g. trailing-whitespace +/// URL parsing differently, this function drifts and the manifest diverges +/// between CLI-written and extension-written items. A future WASM helper +/// `item_to_manifest_entry(handle, item_json)` would eliminate the duplication. function itemToManifestEntry(item: Item) { return { id: item.id, diff --git a/extension/src/service-worker/session.ts b/extension/src/service-worker/session.ts index e6af83e..89d0992 100644 --- a/extension/src/service-worker/session.ts +++ b/extension/src/service-worker/session.ts @@ -3,6 +3,10 @@ /// α assumes one vault per extension install. The master key lives only /// inside WASM linear memory (wrapped in Zeroizing<[u8;32]>); this module /// just holds the opaque handle that names it. +/// +/// Future multi-vault (β+) would replace `current` with +/// `Map` and thread `vaultId` through every +/// handler. Deliberate α simplicity — not an oversight. import type { SessionHandle } from '../../wasm/relicario_wasm'; diff --git a/extension/src/service-worker/vault.ts b/extension/src/service-worker/vault.ts index e978d0e..044ede8 100644 --- a/extension/src/service-worker/vault.ts +++ b/extension/src/service-worker/vault.ts @@ -124,6 +124,13 @@ export function searchItems( }); } +/// Match manifest entries against a page hostname. +/// +/// icon_hint is derived by the Rust core (crates/relicario-core/src/manifest.rs) +/// from LoginCore.url's hostname, so equality on icon_hint is the cheapest match. +/// α is intentionally coarse: no www.-stripping, no public-suffix matching +/// (`www.github.com` saved items will not match `github.com`, and vice versa). +/// Tighter matching is a 1C-β/γ concern. export function findByHostname( manifest: Manifest, hostname: string, @@ -132,6 +139,4 @@ export function findByHostname( return (Object.entries(manifest.items) as Array<[ItemId, ManifestEntry]>) .filter(([, e]) => e.trashed_at === undefined) .filter(([, e]) => (e.icon_hint ?? '').toLowerCase() === h); - // icon_hint is derived by Rust core from LoginCore.url's hostname, - // so hostname equality on icon_hint is the cheapest match. } From 533bfd5bea13f38fd5d90fbbb1c08a77293a10fa Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Mon, 20 Apr 2026 20:10:34 -0400 Subject: [PATCH 10/33] feat(ext/sw): router/popup-only handlers --- .../src/service-worker/router/popup-only.ts | 268 ++++++++++++++++++ 1 file changed, 268 insertions(+) create mode 100644 extension/src/service-worker/router/popup-only.ts diff --git a/extension/src/service-worker/router/popup-only.ts b/extension/src/service-worker/router/popup-only.ts new file mode 100644 index 0000000..8480913 --- /dev/null +++ b/extension/src/service-worker/router/popup-only.ts @@ -0,0 +1,268 @@ +/// Popup-callable message handlers. +/// +/// Every export here assumes the router has already verified sender identity +/// via sender.url === popup.html (or setup.html for save_setup). + +import type { PopupMessage, Response } from '../../shared/messages'; +import type { Item, ItemId, Manifest, VaultConfig, SetupState, DeviceSettings } from '../../shared/types'; +import { DEFAULT_DEVICE_SETTINGS } from '../../shared/types'; +import type { GitHost } from '../git-host'; +import { createGitHost, base64ToUint8Array } from '../git-host'; +import * as vault from '../vault'; +import * as session from '../session'; + +// --- Shared ambient state owned by the SW module --- +// +// The router keeps these on a single `state` object and injects it into the +// handler so testing can mock them without reaching for globals. + +export interface PopupState { + manifest: Manifest | null; + gitHost: GitHost | null; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + wasm: any; +} + +export async function handle( + msg: PopupMessage, + state: PopupState, + sender: chrome.runtime.MessageSender, +): Promise { + void sender; // unused in most branches; retained for symmetry with content-callable + switch (msg.type) { + case 'is_unlocked': + return { ok: true, data: { unlocked: session.getCurrent() !== null } }; + + case 'unlock': { + const w = state.wasm; + const config = await loadConfig(); + if (!config) return { ok: false, error: 'Extension not configured. Run setup first.' }; + const imageB64 = await loadImageBase64(); + if (!imageB64) return { ok: false, error: 'Reference image not set. Run setup first.' }; + + const imageBytes = base64ToUint8Array(imageB64); + if (!state.gitHost) state.gitHost = createGitHost(config.hostType, config.hostUrl, config.repoPath, config.apiToken); + const meta = await vault.fetchVaultMeta(state.gitHost); + + const handle = w.unlock(msg.passphrase, imageBytes, meta.salt, meta.paramsJson); + session.setCurrent(handle); + (msg as { passphrase: string }).passphrase = ''; + + state.manifest = await vault.fetchAndDecryptManifest(state.gitHost, handle); + return { ok: true }; + } + + case 'lock': + session.clearCurrent(); + state.manifest = null; + return { ok: true }; + + case 'list_items': { + if (!state.manifest) return { ok: false, error: 'vault_locked' }; + return { ok: true, data: { items: vault.listItems(state.manifest, msg.group) } }; + } + + case 'get_item': { + const handle = session.getCurrent(); + if (!handle || !state.gitHost) return { ok: false, error: 'vault_locked' }; + const item = await vault.fetchAndDecryptItem(state.gitHost, handle, msg.id); + return { ok: true, data: { item } }; + } + + case 'add_item': { + const handle = session.getCurrent(); + if (!handle || !state.gitHost || !state.manifest) return { ok: false, error: 'vault_locked' }; + const id = state.wasm.new_item_id(); + const item: Item = { ...msg.item, id }; + await vault.encryptAndWriteItem(state.gitHost, handle, id, item, `add: ${item.title}`); + state.manifest.items[id] = itemToManifestEntry(item); + await vault.encryptAndWriteManifest(state.gitHost, handle, state.manifest, `manifest: add ${item.title}`); + return { ok: true, data: { id } }; + } + + case 'update_item': { + const handle = session.getCurrent(); + if (!handle || !state.gitHost || !state.manifest) return { ok: false, error: 'vault_locked' }; + await vault.encryptAndWriteItem(state.gitHost, handle, msg.id, msg.item, `update: ${msg.item.title}`); + state.manifest.items[msg.id] = itemToManifestEntry(msg.item); + await vault.encryptAndWriteManifest(state.gitHost, handle, state.manifest, `manifest: update ${msg.item.title}`); + return { ok: true }; + } + + case 'delete_item': { + const handle = session.getCurrent(); + if (!handle || !state.gitHost || !state.manifest) return { ok: false, error: 'vault_locked' }; + const entry = state.manifest.items[msg.id]; + if (!entry) return { ok: false, error: 'item_not_found' }; + const item = await vault.fetchAndDecryptItem(state.gitHost, handle, msg.id); + const now = Math.floor(Date.now() / 1000); + const updated: Item = { ...item, trashed_at: now, modified: now }; + await vault.encryptAndWriteItem(state.gitHost, handle, msg.id, updated, `trash: ${entry.title}`); + state.manifest.items[msg.id] = { ...entry, trashed_at: now, modified: now }; + await vault.encryptAndWriteManifest(state.gitHost, handle, state.manifest, `manifest: trash ${entry.title}`); + return { ok: true }; + } + + case 'get_totp': { + const handle = session.getCurrent(); + if (!handle || !state.gitHost) return { ok: false, error: 'vault_locked' }; + const item = await vault.fetchAndDecryptItem(state.gitHost, handle, msg.id); + if (item.core.type !== 'login' || !item.core.totp) { + return { ok: false, error: 'no_totp' }; + } + const now = Math.floor(Date.now() / 1000); + const code = state.wasm.totp_compute(JSON.stringify(item.core.totp), BigInt(now)); + return { ok: true, data: { code: code.code, expires_at: code.expires_at } }; + } + + case 'sync': { + const handle = session.getCurrent(); + if (!handle || !state.gitHost) return { ok: false, error: 'vault_locked' }; + state.manifest = await vault.fetchAndDecryptManifest(state.gitHost, handle); + return { ok: true }; + } + + case 'get_setup_state': + return { ok: true, data: await loadSetupState() }; + + case 'save_setup': { + await chrome.storage.local.set({ + vaultConfig: msg.config, + imageBase64: msg.imageBase64, + }); + state.gitHost = null; + return { ok: true }; + } + + case 'rate_passphrase': + return { ok: true, data: state.wasm.rate_passphrase(msg.passphrase) }; + + case 'generate_password': { + const password = state.wasm.generate_password(JSON.stringify(msg.request)); + return { ok: true, data: { password } }; + } + + case 'fill_credentials': + return handleFillCredentials(msg, state); + + case 'ack_autofill_origin': { + const handle = session.getCurrent(); + if (!handle || !state.gitHost) return { ok: false, error: 'vault_locked' }; + const settings = await vault.fetchAndDecryptSettings(state.gitHost, handle); + const acks = { ...(settings.autofill_origin_acks ?? {}), [msg.hostname]: Math.floor(Date.now() / 1000) }; + const updated = { ...settings, autofill_origin_acks: acks }; + await vault.encryptAndWriteSettings(state.gitHost, handle, updated, `settings: ack origin ${msg.hostname}`); + return { ok: true }; + } + + case 'get_settings': + return { ok: true, data: { settings: await loadDeviceSettings() } }; + + case 'update_settings': { + const current = await loadDeviceSettings(); + await saveDeviceSettings({ ...current, ...msg.settings }); + return { ok: true }; + } + + case 'get_blacklist': + return { ok: true, data: { blacklist: await loadBlacklist() } }; + + case 'remove_blacklist': { + const bl = await loadBlacklist(); + await saveBlacklist(bl.filter((h) => h !== msg.hostname)); + return { ok: true }; + } + } +} + +// --- fill_credentials with captured-tab verification (audit M5) --- + +async function handleFillCredentials( + msg: Extract, + state: PopupState, +): Promise { + const handle = session.getCurrent(); + if (!handle || !state.gitHost || !state.manifest) return { ok: false, error: 'vault_locked' }; + + let tab: chrome.tabs.Tab; + try { tab = await chrome.tabs.get(msg.capturedTabId); } + catch { return { ok: false, error: 'captured_tab_gone' }; } + + const currentHost = safeHostname(tab.url ?? ''); + const capturedHost = safeHostname(msg.capturedUrl); + if (!currentHost || !capturedHost || currentHost !== capturedHost) { + return { ok: false, error: 'tab_navigated' }; + } + + const item = await vault.fetchAndDecryptItem(state.gitHost, handle, msg.id); + if (item.core.type !== 'login') return { ok: false, error: 'not_a_login' }; + const itemHost = safeHostname(item.core.url ?? ''); + if (!itemHost || itemHost !== currentHost) return { ok: false, error: 'origin_mismatch' }; + + await chrome.tabs.sendMessage(msg.capturedTabId, { + type: 'fill_credentials', + username: item.core.username ?? '', + password: item.core.password ?? '', + }); + return { ok: true }; +} + +// --- chrome.storage.local helpers (module-scoped so all handlers share) --- + +async function loadConfig(): Promise { + const r = await chrome.storage.local.get('vaultConfig'); + return (r.vaultConfig as VaultConfig) ?? null; +} + +async function loadImageBase64(): Promise { + const r = await chrome.storage.local.get('imageBase64'); + return (r.imageBase64 as string) ?? null; +} + +async function loadSetupState(): Promise { + const config = await loadConfig(); + const imageBase64 = await loadImageBase64(); + return { config, imageBase64, isConfigured: config !== null && imageBase64 !== null }; +} + +async function loadDeviceSettings(): Promise { + const r = await chrome.storage.local.get('relicarioSettings'); + return (r.relicarioSettings as DeviceSettings) ?? { ...DEFAULT_DEVICE_SETTINGS }; +} + +async function saveDeviceSettings(s: DeviceSettings): Promise { + await chrome.storage.local.set({ relicarioSettings: s }); +} + +async function loadBlacklist(): Promise { + const r = await chrome.storage.local.get('captureBlacklist'); + return (r.captureBlacklist as string[]) ?? []; +} + +async function saveBlacklist(list: string[]): Promise { + await chrome.storage.local.set({ captureBlacklist: list }); +} + +// --- Manifest entry derivation (duplicated from index; kept here to keep handler self-contained) --- + +function itemToManifestEntry(item: Item) { + return { + id: item.id, + type: item.type, + title: item.title, + tags: item.tags, + favorite: item.favorite, + group: item.group, + icon_hint: (item.core.type === 'login' && item.core.url) + ? safeHostname(item.core.url) : undefined, + modified: item.modified, + trashed_at: item.trashed_at, + attachment_summaries: item.attachments.map((a) => ({ + id: a.id, filename: a.filename, mime_type: a.mime_type, size: a.size, + })), + }; +} + +function safeHostname(url: string): string | undefined { + try { return new URL(url).hostname; } catch { return undefined; } +} From be32ea13c64dd544aa6ceb25ea9424c0cb2ba5d3 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Mon, 20 Apr 2026 20:11:02 -0400 Subject: [PATCH 11/33] feat(ext/sw): router/content-callable handlers with origin derivation --- .../service-worker/router/content-callable.ts | 116 ++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 extension/src/service-worker/router/content-callable.ts diff --git a/extension/src/service-worker/router/content-callable.ts b/extension/src/service-worker/router/content-callable.ts new file mode 100644 index 0000000..df46b60 --- /dev/null +++ b/extension/src/service-worker/router/content-callable.ts @@ -0,0 +1,116 @@ +/// Content-script-callable message handlers. +/// +/// Origin is always derived from sender.tab.url — never trust fields on msg. +/// Router has already verified sender.frameId === 0 (top-frame only) and +/// sender.tab !== undefined. + +import type { ContentMessage, Response } from '../../shared/messages'; +import type { Manifest } from '../../shared/types'; +import type { GitHost } from '../git-host'; +import * as vault from '../vault'; +import * as session from '../session'; + +export interface ContentState { + manifest: Manifest | null; + gitHost: GitHost | null; +} + +export async function handle( + msg: ContentMessage, + state: ContentState, + sender: chrome.runtime.MessageSender, +): Promise { + const senderHost = safeHostname(sender.tab?.url ?? ''); + if (!senderHost) return { ok: false, error: 'invalid_sender_url' }; + + switch (msg.type) { + case 'get_autofill_candidates': { + if (!state.manifest) return { ok: false, error: 'vault_locked' }; + return { + ok: true, + data: { candidates: vault.findByHostname(state.manifest, senderHost) }, + }; + } + + case 'get_credentials': { + const handle = session.getCurrent(); + if (!handle || !state.gitHost || !state.manifest) return { ok: false, error: 'vault_locked' }; + + const item = await vault.fetchAndDecryptItem(state.gitHost, handle, msg.id); + if (item.core.type !== 'login') return { ok: false, error: 'not_a_login' }; + const itemHost = safeHostname(item.core.url ?? ''); + if (!itemHost || itemHost !== senderHost) return { ok: false, error: 'origin_mismatch' }; + + // TOFU origin-ack check (VaultSettings.autofill_origin_acks): + const settings = await vault.fetchAndDecryptSettings(state.gitHost, handle); + const acks = settings.autofill_origin_acks ?? {}; + if (!(senderHost in acks)) { + return { ok: true, data: { requires_ack: true, hostname: senderHost } }; + } + + return { + ok: true, + data: { + username: item.core.username ?? '', + password: item.core.password ?? '', + }, + }; + } + + case 'check_credential': { + const handle = session.getCurrent(); + if (!handle || !state.gitHost || !state.manifest) { + return { ok: true, data: { action: 'skip' } }; + } + + // Settings-gating: capture off or site blacklisted → skip. + const captureSettings = await loadDeviceSettings(); + if (!captureSettings.captureEnabled) return { ok: true, data: { action: 'skip' } }; + + const blacklist = await loadBlacklist(); + if (blacklist.includes(senderHost)) return { ok: true, data: { action: 'skip' } }; + + const candidates = vault.findByHostname(state.manifest, senderHost); + if (candidates.length === 0) return { ok: true, data: { action: 'save' } }; + + for (const [itemId, entry] of candidates) { + if (entry.type !== 'login') continue; + const full = await vault.fetchAndDecryptItem(state.gitHost, handle, itemId); + if (full.core.type !== 'login') continue; + if (full.core.username === msg.username) { + if (full.core.password === msg.password) return { ok: true, data: { action: 'skip' } }; + return { ok: true, data: { action: 'update', entryId: itemId, entryName: entry.title } }; + } + } + return { ok: true, data: { action: 'save' } }; + } + + case 'blacklist_site': { + const bl = await loadBlacklist(); + if (!bl.includes(senderHost)) { + bl.push(senderHost); + await saveBlacklist(bl); + } + return { ok: true }; + } + } +} + +async function loadDeviceSettings(): Promise<{ captureEnabled: boolean; captureStyle: 'bar' | 'toast' }> { + const r = await chrome.storage.local.get('relicarioSettings'); + return (r.relicarioSettings as { captureEnabled: boolean; captureStyle: 'bar' | 'toast' }) + ?? { captureEnabled: false, captureStyle: 'bar' }; +} + +async function loadBlacklist(): Promise { + const r = await chrome.storage.local.get('captureBlacklist'); + return (r.captureBlacklist as string[]) ?? []; +} + +async function saveBlacklist(list: string[]): Promise { + await chrome.storage.local.set({ captureBlacklist: list }); +} + +function safeHostname(url: string): string | undefined { + try { return new URL(url).hostname; } catch { return undefined; } +} From 56ab58cbe939bb5a1d523fafd7169d769e319ed9 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Mon, 20 Apr 2026 20:11:20 -0400 Subject: [PATCH 12/33] feat(ext/sw): router index with sender-based dispatch --- extension/src/service-worker/router/index.ts | 48 ++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 extension/src/service-worker/router/index.ts diff --git a/extension/src/service-worker/router/index.ts b/extension/src/service-worker/router/index.ts new file mode 100644 index 0000000..65229c3 --- /dev/null +++ b/extension/src/service-worker/router/index.ts @@ -0,0 +1,48 @@ +/// Single chrome.runtime.onMessage entry. Classifies the sender and dispatches +/// to popup-only or content-callable handlers. Unauthorized senders are +/// rejected with { ok: false, error: 'unauthorized_sender' }. + +import type { Request, Response } from '../../shared/messages'; +import { POPUP_ONLY_TYPES, CONTENT_CALLABLE_TYPES } from '../../shared/messages'; +import type { Manifest } from '../../shared/types'; +import type { GitHost } from '../git-host'; +import * as popupOnly from './popup-only'; +import * as contentCallable from './content-callable'; + +export interface RouterState { + manifest: Manifest | null; + gitHost: GitHost | null; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + wasm: any; +} + +export async function route( + msg: Request, + state: RouterState, + sender: chrome.runtime.MessageSender, +): Promise { + const popupUrl = chrome.runtime.getURL('popup.html'); + const setupUrl = chrome.runtime.getURL('setup.html'); + const senderUrl = sender.url ?? ''; + + const isPopup = senderUrl === popupUrl; + const isSetup = senderUrl.startsWith(setupUrl); + const isContent = sender.tab !== undefined + && sender.frameId === 0 + && sender.id === chrome.runtime.id; + + if (POPUP_ONLY_TYPES.has(msg.type as never)) { + // save_setup gets one exception: allowed from the setup tab too. + if (!(isPopup || (msg.type === 'save_setup' && isSetup))) { + return { ok: false, error: 'unauthorized_sender' }; + } + return popupOnly.handle(msg as never, state, sender); + } + + if (CONTENT_CALLABLE_TYPES.has(msg.type as never)) { + if (!isContent) return { ok: false, error: 'unauthorized_sender' }; + return contentCallable.handle(msg as never, state, sender); + } + + return { ok: false, error: 'unknown_message_type' }; +} From 2d4dcb5f6bf028f761809a9dfc9dc6a2335ec94e Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Mon, 20 Apr 2026 20:11:59 -0400 Subject: [PATCH 13/33] feat(ext/sw): collapse flat index onto router --- extension/src/service-worker/index.ts | 266 ++------------------------ 1 file changed, 16 insertions(+), 250 deletions(-) diff --git a/extension/src/service-worker/index.ts b/extension/src/service-worker/index.ts index 8381ae5..f21e7c4 100644 --- a/extension/src/service-worker/index.ts +++ b/extension/src/service-worker/index.ts @@ -1,18 +1,10 @@ -/// Background script entry point for the relicario browser extension. -/// -/// Transitional slice-3 shape: keeps the flat onMessage listener but uses -/// the new typed-item vault + SessionHandle. The router split lands in -/// slice 4. +/// Thin service-worker entry: loads WASM, constructs the router state, and +/// forwards every message into router/index.route(). import type { Request, Response } from '../shared/messages'; -import type { Item, ItemId, Manifest, VaultConfig, SetupState, DeviceSettings } from '../shared/types'; -import { DEFAULT_DEVICE_SETTINGS } from '../shared/types'; -import type { GitHost } from './git-host'; -import { createGitHost, base64ToUint8Array } from './git-host'; +import type { RouterState } from './router/index'; +import { route } from './router/index'; import * as vault from './vault'; -import * as session from './session'; - -// --- WASM module load --- // @ts-ignore TS2307 — resolved by webpack alias / copy import initDefault, { initSync } from '../../wasm/relicario_wasm.js'; @@ -44,247 +36,21 @@ async function initWasm(): Promise { return wasm; } -// --- In-memory vault state (cleared on lock or SW restart) --- - -let manifest: Manifest | null = null; -let gitHost: GitHost | null = null; -const totpConfigCache: Map = new Map(); - -// --- chrome.storage.local helpers --- - -async function loadConfig(): Promise { - const result = await chrome.storage.local.get('vaultConfig'); - return (result.vaultConfig as VaultConfig) ?? null; -} - -async function loadImageBase64(): Promise { - const result = await chrome.storage.local.get('imageBase64'); - return (result.imageBase64 as string) ?? null; -} - -async function loadSetupState(): Promise { - const config = await loadConfig(); - const imageBase64 = await loadImageBase64(); - return { config, imageBase64, isConfigured: config !== null && imageBase64 !== null }; -} - -async function loadSettings(): Promise { - const result = await chrome.storage.local.get('relicarioSettings'); - return (result.relicarioSettings as DeviceSettings) ?? { ...DEFAULT_DEVICE_SETTINGS }; -} - -async function saveSettings(settings: DeviceSettings): Promise { - await chrome.storage.local.set({ relicarioSettings: settings }); -} - -async function loadBlacklist(): Promise { - const result = await chrome.storage.local.get('captureBlacklist'); - return (result.captureBlacklist as string[]) ?? []; -} - -async function saveBlacklist(list: string[]): Promise { - await chrome.storage.local.set({ captureBlacklist: list }); -} - -function ensureGitHost(config: VaultConfig): GitHost { - if (!gitHost) { - gitHost = createGitHost(config.hostType, config.hostUrl, config.repoPath, config.apiToken); - } - return gitHost; -} - -// --- Message handler (flat; router split in slice 4) --- +// Single router-state object shared by all messages for this SW instance. +const state: RouterState = { + manifest: null, + gitHost: null, + wasm: null, +}; chrome.runtime.onMessage.addListener( - (request: Request, _sender: chrome.runtime.MessageSender, sendResponse: (response: Response) => void) => { - handleMessage(request) + (request: Request, sender: chrome.runtime.MessageSender, sendResponse: (r: Response) => void) => { + (async () => { + if (!state.wasm) state.wasm = await initWasm(); + return route(request, state, sender); + })() .then(sendResponse) .catch((err: Error) => sendResponse({ ok: false, error: err.message })); - return true; + return true; // async response }, ); - -async function handleMessage(req: Request): Promise { - switch (req.type) { - case 'is_unlocked': - return { ok: true, data: { unlocked: session.getCurrent() !== null } }; - - case 'unlock': { - const w = await initWasm(); - const config = await loadConfig(); - if (!config) return { ok: false, error: 'Extension not configured. Run setup first.' }; - const imageB64 = await loadImageBase64(); - if (!imageB64) return { ok: false, error: 'Reference image not set. Run setup first.' }; - - const imageBytes = base64ToUint8Array(imageB64); - const git = ensureGitHost(config); - const meta = await vault.fetchVaultMeta(git); - - const handle = w.unlock(req.passphrase, imageBytes, meta.salt, meta.paramsJson); - session.setCurrent(handle); - // Best-effort scope clearing. JS strings are immutable so this only - // nulls the one reference on `req`; wasm-bindgen already copied the - // string into WASM linear memory before `unlock` returned. Left in - // as a marker — future work may consider a direct-stream KDF that - // never materializes the string, or a `Zeroizing` wrapper on the - // WASM-side incoming buffer. - (req as { passphrase: string }).passphrase = ''; - - manifest = await vault.fetchAndDecryptManifest(git, handle); - return { ok: true }; - } - - case 'lock': - session.clearCurrent(); - manifest = null; - totpConfigCache.clear(); - return { ok: true }; - - case 'list_items': { - if (!manifest) return { ok: false, error: 'vault_locked' }; - const items = vault.listItems(manifest, req.group); - return { ok: true, data: { items } }; - } - - case 'get_item': { - const handle = session.getCurrent(); - if (!handle || !gitHost) return { ok: false, error: 'vault_locked' }; - const item = await vault.fetchAndDecryptItem(gitHost, handle, req.id); - return { ok: true, data: { item } }; - } - - case 'add_item': { - const handle = session.getCurrent(); - if (!handle || !gitHost || !manifest) return { ok: false, error: 'vault_locked' }; - const w = await initWasm(); - const id = w.new_item_id(); - const item: Item = { ...req.item, id }; - - await vault.encryptAndWriteItem(gitHost, handle, id, item, `add: ${item.title}`); - manifest.items[id] = itemToManifestEntry(item); - await vault.encryptAndWriteManifest(gitHost, handle, manifest, `manifest: add ${item.title}`); - return { ok: true, data: { id } }; - } - - case 'update_item': { - const handle = session.getCurrent(); - if (!handle || !gitHost || !manifest) return { ok: false, error: 'vault_locked' }; - await vault.encryptAndWriteItem(gitHost, handle, req.id, req.item, `update: ${req.item.title}`); - manifest.items[req.id] = itemToManifestEntry(req.item); - await vault.encryptAndWriteManifest(gitHost, handle, manifest, `manifest: update ${req.item.title}`); - totpConfigCache.delete(req.id); - return { ok: true }; - } - - case 'delete_item': { - const handle = session.getCurrent(); - if (!handle || !gitHost || !manifest) return { ok: false, error: 'vault_locked' }; - const entry = manifest.items[req.id]; - if (!entry) return { ok: false, error: 'item_not_found' }; - // Soft-delete: fetch the item, set trashed_at, write it back. - // TODO(slice-4): not atomic across the two git writes. If the manifest - // write fails after the item write, next sync restores the live manifest - // and the trashed item re-appears. Consider manifest-first write order - // or a retry/rollback pass when router-splitting this handler. - const item = await vault.fetchAndDecryptItem(gitHost, handle, req.id); - const now = Math.floor(Date.now() / 1000); - const updated: Item = { ...item, trashed_at: now, modified: now }; - await vault.encryptAndWriteItem(gitHost, handle, req.id, updated, `trash: ${entry.title}`); - manifest.items[req.id] = { ...entry, trashed_at: now, modified: now }; - await vault.encryptAndWriteManifest(gitHost, handle, manifest, `manifest: trash ${entry.title}`); - return { ok: true }; - } - - case 'sync': { - const handle = session.getCurrent(); - if (!handle || !gitHost) return { ok: false, error: 'vault_locked' }; - manifest = await vault.fetchAndDecryptManifest(gitHost, handle); - return { ok: true }; - } - - case 'get_setup_state': { - return { ok: true, data: await loadSetupState() }; - } - - case 'save_setup': { - await chrome.storage.local.set({ - vaultConfig: req.config, - imageBase64: req.imageBase64, - }); - gitHost = null; - return { ok: true }; - } - - case 'rate_passphrase': { - const w = await initWasm(); - return { ok: true, data: w.rate_passphrase(req.passphrase) }; - } - - case 'generate_password': { - const w = await initWasm(); - const password = w.generate_password(JSON.stringify(req.request)); - return { ok: true, data: { password } }; - } - - case 'get_settings': - return { ok: true, data: { settings: await loadSettings() } }; - - case 'update_settings': { - const current = await loadSettings(); - await saveSettings({ ...current, ...req.settings }); - return { ok: true }; - } - - case 'get_blacklist': - return { ok: true, data: { blacklist: await loadBlacklist() } }; - - case 'remove_blacklist': { - const bl = await loadBlacklist(); - await saveBlacklist(bl.filter((h) => h !== req.hostname)); - return { ok: true }; - } - - // Slice 4 / 5 will wire these up properly (currently placeholders so the build passes): - case 'get_totp': - case 'fill_credentials': - case 'ack_autofill_origin': - case 'get_autofill_candidates': - case 'get_credentials': - case 'check_credential': - case 'blacklist_site': - return { ok: false, error: 'not_implemented_yet' }; - - default: { - const exhaustive: never = req; - return { ok: false, error: `unknown_message_type: ${(exhaustive as { type: string }).type}` }; - } - } -} - -/// TS mirror of the Rust core's `ManifestEntry::from_item` -/// (see crates/relicario-core/src/manifest.rs). These two derivations must -/// stay in sync — if the Rust side learns to handle e.g. trailing-whitespace -/// URL parsing differently, this function drifts and the manifest diverges -/// between CLI-written and extension-written items. A future WASM helper -/// `item_to_manifest_entry(handle, item_json)` would eliminate the duplication. -function itemToManifestEntry(item: Item) { - return { - id: item.id, - type: item.type, - title: item.title, - tags: item.tags, - favorite: item.favorite, - group: item.group, - icon_hint: (item.core.type === 'login' && item.core.url) - ? safeHostname(item.core.url) : undefined, - modified: item.modified, - trashed_at: item.trashed_at, - attachment_summaries: item.attachments.map((a) => ({ - id: a.id, filename: a.filename, mime_type: a.mime_type, size: a.size, - })), - }; -} - -function safeHostname(url: string): string | undefined { - try { return new URL(url).hostname; } catch { return undefined; } -} From 3d2b021cb2f79362da426fc4656f50783ecfeda8 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Mon, 20 Apr 2026 20:15:49 -0400 Subject: [PATCH 14/33] test(ext): vitest + router sender-check + origin-bound autofill --- extension/bun.lock | 184 ++++++++++++++++++ extension/package.json | 6 +- .../router/__tests__/router.test.ts | 162 +++++++++++++++ extension/src/shared/__tests__/base32.test.ts | 1 - extension/vitest.config.ts | 8 + 5 files changed, 359 insertions(+), 2 deletions(-) create mode 100644 extension/src/service-worker/router/__tests__/router.test.ts create mode 100644 extension/vitest.config.ts diff --git a/extension/bun.lock b/extension/bun.lock index 417507f..1493274 100644 --- a/extension/bun.lock +++ b/extension/bun.lock @@ -7,8 +7,10 @@ "devDependencies": { "@types/chrome": "^0.1.40", "copy-webpack-plugin": "^12.0", + "happy-dom": "^15", "ts-loader": "^9.5", "typescript": "^5.4", + "vitest": "^2.0", "webpack": "^5.90", "webpack-cli": "^5.1", }, @@ -17,6 +19,52 @@ "packages": { "@discoveryjs/json-ext": ["@discoveryjs/json-ext@0.5.7", "", {}, "sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw=="], + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.21.5", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.21.5", "", { "os": "android", "cpu": "arm" }, "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.21.5", "", { "os": "android", "cpu": "arm64" }, "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.21.5", "", { "os": "android", "cpu": "x64" }, "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.21.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.21.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.21.5", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.21.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.21.5", "", { "os": "linux", "cpu": "arm" }, "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.21.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.21.5", "", { "os": "linux", "cpu": "ia32" }, "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.21.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.21.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.21.5", "", { "os": "linux", "cpu": "x64" }, "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.21.5", "", { "os": "none", "cpu": "x64" }, "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.21.5", "", { "os": "openbsd", "cpu": "x64" }, "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.21.5", "", { "os": "sunos", "cpu": "x64" }, "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.21.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.21.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.21.5", "", { "os": "win32", "cpu": "x64" }, "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw=="], + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], @@ -33,6 +81,56 @@ "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="], + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.60.2", "", { "os": "android", "cpu": "arm" }, "sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw=="], + + "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.60.2", "", { "os": "android", "cpu": "arm64" }, "sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg=="], + + "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.60.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA=="], + + "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.60.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g=="], + + "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.60.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw=="], + + "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.60.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ=="], + + "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.60.2", "", { "os": "linux", "cpu": "arm" }, "sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg=="], + + "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.60.2", "", { "os": "linux", "cpu": "arm" }, "sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw=="], + + "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.60.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg=="], + + "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.60.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA=="], + + "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.60.2", "", { "os": "linux", "cpu": "none" }, "sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A=="], + + "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.60.2", "", { "os": "linux", "cpu": "none" }, "sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q=="], + + "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.60.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw=="], + + "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.60.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ=="], + + "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.60.2", "", { "os": "linux", "cpu": "none" }, "sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A=="], + + "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.60.2", "", { "os": "linux", "cpu": "none" }, "sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ=="], + + "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.60.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA=="], + + "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.60.2", "", { "os": "linux", "cpu": "x64" }, "sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ=="], + + "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.60.2", "", { "os": "linux", "cpu": "x64" }, "sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw=="], + + "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.60.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg=="], + + "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.60.2", "", { "os": "none", "cpu": "arm64" }, "sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q=="], + + "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.60.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ=="], + + "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.60.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg=="], + + "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.60.2", "", { "os": "win32", "cpu": "x64" }, "sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA=="], + + "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.60.2", "", { "os": "win32", "cpu": "x64" }, "sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA=="], + "@sindresorhus/merge-streams": ["@sindresorhus/merge-streams@2.3.0", "", {}, "sha512-LtoMMhxAlorcGhmFYI+LhPgbPZCkgP6ra1YL604EeF6U98pLlQ3iWIGMdWSC+vWmPBWBNgmDBAhnAobLROJmwg=="], "@types/chrome": ["@types/chrome@0.1.40", "", { "dependencies": { "@types/filesystem": "*", "@types/har-format": "*" } }, "sha512-UnfyRAe8ORu9HSuTH0EqyOEUin3JrWW9Nl/gDXezNfTUrfIoxw+WRZgKOxGz0t5BnjbfXBnS2eCYfW2PxH1wcA=="], @@ -53,6 +151,20 @@ "@types/node": ["@types/node@25.6.0", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ=="], + "@vitest/expect": ["@vitest/expect@2.1.9", "", { "dependencies": { "@vitest/spy": "2.1.9", "@vitest/utils": "2.1.9", "chai": "^5.1.2", "tinyrainbow": "^1.2.0" } }, "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw=="], + + "@vitest/mocker": ["@vitest/mocker@2.1.9", "", { "dependencies": { "@vitest/spy": "2.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.12" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^5.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg=="], + + "@vitest/pretty-format": ["@vitest/pretty-format@2.1.9", "", { "dependencies": { "tinyrainbow": "^1.2.0" } }, "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ=="], + + "@vitest/runner": ["@vitest/runner@2.1.9", "", { "dependencies": { "@vitest/utils": "2.1.9", "pathe": "^1.1.2" } }, "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g=="], + + "@vitest/snapshot": ["@vitest/snapshot@2.1.9", "", { "dependencies": { "@vitest/pretty-format": "2.1.9", "magic-string": "^0.30.12", "pathe": "^1.1.2" } }, "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ=="], + + "@vitest/spy": ["@vitest/spy@2.1.9", "", { "dependencies": { "tinyspy": "^3.0.2" } }, "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ=="], + + "@vitest/utils": ["@vitest/utils@2.1.9", "", { "dependencies": { "@vitest/pretty-format": "2.1.9", "loupe": "^3.1.2", "tinyrainbow": "^1.2.0" } }, "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ=="], + "@webassemblyjs/ast": ["@webassemblyjs/ast@1.14.1", "", { "dependencies": { "@webassemblyjs/helper-numbers": "1.13.2", "@webassemblyjs/helper-wasm-bytecode": "1.13.2" } }, "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ=="], "@webassemblyjs/floating-point-hex-parser": ["@webassemblyjs/floating-point-hex-parser@1.13.2", "", {}, "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA=="], @@ -105,6 +217,8 @@ "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], + "baseline-browser-mapping": ["baseline-browser-mapping@2.10.18", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-VSnGQAOLtP5mib/DPyg2/t+Tlv65NTBz83BJBJvmLVHHuKJVaDOBvJJykiT5TR++em5nfAySPccDZDa4oSrn8A=="], "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], @@ -113,10 +227,16 @@ "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], + "cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="], + "caniuse-lite": ["caniuse-lite@1.0.30001787", "", {}, "sha512-mNcrMN9KeI68u7muanUpEejSLghOKlVhRqS/Za2IeyGllJ9I9otGpR9g3nsw7n4W378TE/LyIteA0+/FOZm4Kg=="], + "chai": ["chai@5.3.3", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="], + "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + "check-error": ["check-error@2.1.3", "", {}, "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA=="], + "chrome-trace-event": ["chrome-trace-event@1.0.4", "", {}, "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ=="], "clone-deep": ["clone-deep@4.0.1", "", { "dependencies": { "is-plain-object": "^2.0.4", "kind-of": "^6.0.2", "shallow-clone": "^3.0.0" } }, "sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ=="], @@ -133,16 +253,24 @@ "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "deep-eql": ["deep-eql@5.0.2", "", {}, "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q=="], + "electron-to-chromium": ["electron-to-chromium@1.5.335", "", {}, "sha512-q9n5T4BR4Xwa2cwbrwcsDJtHD/enpQ5S1xF1IAtdqf5AAgqDFmR/aakqH3ChFdqd/QXJhS3rnnXFtexU7rax6Q=="], "enhanced-resolve": ["enhanced-resolve@5.20.1", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA=="], + "entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], + "envinfo": ["envinfo@7.21.0", "", { "bin": { "envinfo": "dist/cli.js" } }, "sha512-Lw7I8Zp5YKHFCXL7+Dz95g4CcbMEpgvqZNNq3AmlT5XAV6CgAAk6gyAMqn2zjw08K9BHfcNuKrMiCPLByGafow=="], "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], "es-module-lexer": ["es-module-lexer@2.0.0", "", {}, "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw=="], + "esbuild": ["esbuild@0.21.5", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.21.5", "@esbuild/android-arm": "0.21.5", "@esbuild/android-arm64": "0.21.5", "@esbuild/android-x64": "0.21.5", "@esbuild/darwin-arm64": "0.21.5", "@esbuild/darwin-x64": "0.21.5", "@esbuild/freebsd-arm64": "0.21.5", "@esbuild/freebsd-x64": "0.21.5", "@esbuild/linux-arm": "0.21.5", "@esbuild/linux-arm64": "0.21.5", "@esbuild/linux-ia32": "0.21.5", "@esbuild/linux-loong64": "0.21.5", "@esbuild/linux-mips64el": "0.21.5", "@esbuild/linux-ppc64": "0.21.5", "@esbuild/linux-riscv64": "0.21.5", "@esbuild/linux-s390x": "0.21.5", "@esbuild/linux-x64": "0.21.5", "@esbuild/netbsd-x64": "0.21.5", "@esbuild/openbsd-x64": "0.21.5", "@esbuild/sunos-x64": "0.21.5", "@esbuild/win32-arm64": "0.21.5", "@esbuild/win32-ia32": "0.21.5", "@esbuild/win32-x64": "0.21.5" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw=="], + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], "eslint-scope": ["eslint-scope@5.1.1", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^4.1.1" } }, "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw=="], @@ -151,8 +279,12 @@ "estraverse": ["estraverse@4.3.0", "", {}, "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw=="], + "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], + "events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="], + "expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="], + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], "fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="], @@ -169,6 +301,8 @@ "flat": ["flat@5.0.2", "", { "bin": { "flat": "cli.js" } }, "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ=="], + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], @@ -179,6 +313,8 @@ "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + "happy-dom": ["happy-dom@15.11.7", "", { "dependencies": { "entities": "^4.5.0", "webidl-conversions": "^7.0.0", "whatwg-mimetype": "^3.0.0" } }, "sha512-KyrFvnl+J9US63TEzwoiJOQzZBJY7KgBushJA8X61DMbNsH+2ONkDuLDnCnwUiPTF42tLoEmrPyoqbenVA5zrg=="], + "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], @@ -215,6 +351,10 @@ "locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="], + "loupe": ["loupe@3.2.1", "", {}, "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ=="], + + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + "merge-stream": ["merge-stream@2.0.0", "", {}, "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="], "merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="], @@ -225,6 +365,10 @@ "mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + "neo-async": ["neo-async@2.6.2", "", {}, "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw=="], "node-releases": ["node-releases@2.0.37", "", {}, "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg=="], @@ -245,12 +389,18 @@ "path-type": ["path-type@6.0.0", "", {}, "sha512-Vj7sf++t5pBD637NSfkxpHSMfWaeig5+DKWLhcqIYx6mWQz5hdJTGDVMQiJcw1ZYkhs7AazKDGpRVji1LJCZUQ=="], + "pathe": ["pathe@1.1.2", "", {}, "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="], + + "pathval": ["pathval@2.0.1", "", {}, "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ=="], + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], "picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], "pkg-dir": ["pkg-dir@4.2.0", "", { "dependencies": { "find-up": "^4.0.0" } }, "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ=="], + "postcss": ["postcss@8.5.10", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ=="], + "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], "randombytes": ["randombytes@2.1.0", "", { "dependencies": { "safe-buffer": "^5.1.0" } }, "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ=="], @@ -267,6 +417,8 @@ "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], + "rollup": ["rollup@4.60.2", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.2", "@rollup/rollup-android-arm64": "4.60.2", "@rollup/rollup-darwin-arm64": "4.60.2", "@rollup/rollup-darwin-x64": "4.60.2", "@rollup/rollup-freebsd-arm64": "4.60.2", "@rollup/rollup-freebsd-x64": "4.60.2", "@rollup/rollup-linux-arm-gnueabihf": "4.60.2", "@rollup/rollup-linux-arm-musleabihf": "4.60.2", "@rollup/rollup-linux-arm64-gnu": "4.60.2", "@rollup/rollup-linux-arm64-musl": "4.60.2", "@rollup/rollup-linux-loong64-gnu": "4.60.2", "@rollup/rollup-linux-loong64-musl": "4.60.2", "@rollup/rollup-linux-ppc64-gnu": "4.60.2", "@rollup/rollup-linux-ppc64-musl": "4.60.2", "@rollup/rollup-linux-riscv64-gnu": "4.60.2", "@rollup/rollup-linux-riscv64-musl": "4.60.2", "@rollup/rollup-linux-s390x-gnu": "4.60.2", "@rollup/rollup-linux-x64-gnu": "4.60.2", "@rollup/rollup-linux-x64-musl": "4.60.2", "@rollup/rollup-openbsd-x64": "4.60.2", "@rollup/rollup-openharmony-arm64": "4.60.2", "@rollup/rollup-win32-arm64-msvc": "4.60.2", "@rollup/rollup-win32-ia32-msvc": "4.60.2", "@rollup/rollup-win32-x64-gnu": "4.60.2", "@rollup/rollup-win32-x64-msvc": "4.60.2", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ=="], + "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], @@ -283,12 +435,20 @@ "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], + "slash": ["slash@5.1.0", "", {}, "sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg=="], "source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + "source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="], + "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], + + "std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="], + "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], @@ -299,6 +459,16 @@ "terser-webpack-plugin": ["terser-webpack-plugin@5.4.0", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "jest-worker": "^27.4.5", "schema-utils": "^4.3.0", "terser": "^5.31.1" }, "peerDependencies": { "webpack": "^5.1.0" } }, "sha512-Bn5vxm48flOIfkdl5CaD2+1CiUVbonWQ3KQPyP7/EuIl9Gbzq/gQFOzaMFUEgVjB1396tcK0SG8XcNJ/2kDH8g=="], + "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], + + "tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="], + + "tinypool": ["tinypool@1.1.1", "", {}, "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg=="], + + "tinyrainbow": ["tinyrainbow@1.2.0", "", {}, "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ=="], + + "tinyspy": ["tinyspy@3.0.2", "", {}, "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q=="], + "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], "ts-loader": ["ts-loader@9.5.7", "", { "dependencies": { "chalk": "^4.1.0", "enhanced-resolve": "^5.0.0", "micromatch": "^4.0.0", "semver": "^7.3.4", "source-map": "^0.7.4" }, "peerDependencies": { "typescript": "*", "webpack": "^5.0.0" } }, "sha512-/ZNrKgA3K3PtpMYOC71EeMWIloGw3IYEa5/t1cyz2r5/PyUwTXGzYJvcD3kfUvmhlfpz1rhV8B2O6IVTQ0avsg=="], @@ -311,8 +481,16 @@ "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], + "vite": ["vite@5.4.21", "", { "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", "rollup": "^4.20.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || >=20.0.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" }, "optionalPeers": ["@types/node", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser"], "bin": { "vite": "bin/vite.js" } }, "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw=="], + + "vite-node": ["vite-node@2.1.9", "", { "dependencies": { "cac": "^6.7.14", "debug": "^4.3.7", "es-module-lexer": "^1.5.4", "pathe": "^1.1.2", "vite": "^5.0.0" }, "bin": { "vite-node": "vite-node.mjs" } }, "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA=="], + + "vitest": ["vitest@2.1.9", "", { "dependencies": { "@vitest/expect": "2.1.9", "@vitest/mocker": "2.1.9", "@vitest/pretty-format": "^2.1.9", "@vitest/runner": "2.1.9", "@vitest/snapshot": "2.1.9", "@vitest/spy": "2.1.9", "@vitest/utils": "2.1.9", "chai": "^5.1.2", "debug": "^4.3.7", "expect-type": "^1.1.0", "magic-string": "^0.30.12", "pathe": "^1.1.2", "std-env": "^3.8.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.1", "tinypool": "^1.0.1", "tinyrainbow": "^1.2.0", "vite": "^5.0.0", "vite-node": "2.1.9", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@types/node": "^18.0.0 || >=20.0.0", "@vitest/browser": "2.1.9", "@vitest/ui": "2.1.9", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@types/node", "@vitest/browser", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q=="], + "watchpack": ["watchpack@2.5.1", "", { "dependencies": { "glob-to-regexp": "^0.4.1", "graceful-fs": "^4.1.2" } }, "sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg=="], + "webidl-conversions": ["webidl-conversions@7.0.0", "", {}, "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g=="], + "webpack": ["webpack@5.106.1", "", { "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", "@types/json-schema": "^7.0.15", "@webassemblyjs/ast": "^1.14.1", "@webassemblyjs/wasm-edit": "^1.14.1", "@webassemblyjs/wasm-parser": "^1.14.1", "acorn": "^8.16.0", "acorn-import-phases": "^1.0.3", "browserslist": "^4.28.1", "chrome-trace-event": "^1.0.2", "enhanced-resolve": "^5.20.0", "es-module-lexer": "^2.0.0", "eslint-scope": "5.1.1", "events": "^3.2.0", "glob-to-regexp": "^0.4.1", "graceful-fs": "^4.2.11", "json-parse-even-better-errors": "^2.3.1", "loader-runner": "^4.3.1", "mime-types": "^2.1.27", "neo-async": "^2.6.2", "schema-utils": "^4.3.3", "tapable": "^2.3.0", "terser-webpack-plugin": "^5.3.17", "watchpack": "^2.5.1", "webpack-sources": "^3.3.4" }, "bin": { "webpack": "bin/webpack.js" } }, "sha512-EW8af29ak8Oaf4T8k8YsajjrDBDYgnKZ5er6ljWFJsXABfTNowQfvHLftwcepVgdz+IoLSdEAbBiM9DFXoll9w=="], "webpack-cli": ["webpack-cli@5.1.4", "", { "dependencies": { "@discoveryjs/json-ext": "^0.5.0", "@webpack-cli/configtest": "^2.1.1", "@webpack-cli/info": "^2.0.2", "@webpack-cli/serve": "^2.0.5", "colorette": "^2.0.14", "commander": "^10.0.1", "cross-spawn": "^7.0.3", "envinfo": "^7.7.3", "fastest-levenshtein": "^1.0.12", "import-local": "^3.0.2", "interpret": "^3.1.1", "rechoir": "^0.8.0", "webpack-merge": "^5.7.3" }, "peerDependencies": { "webpack": "5.x.x" }, "bin": { "webpack-cli": "bin/cli.js" } }, "sha512-pIDJHIEI9LR0yxHXQ+Qh95k2EvXpWzZ5l+d+jIo+RdSm9MiHfzazIxwwni/p7+x4eJZuvG1AJwgC4TNQ7NRgsg=="], @@ -321,8 +499,12 @@ "webpack-sources": ["webpack-sources@3.3.4", "", {}, "sha512-7tP1PdV4vF+lYPnkMR0jMY5/la2ub5Fc/8VQrrU+lXkiM6C4TjVfGw7iKfyhnTQOsD+6Q/iKw0eFciziRgD58Q=="], + "whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="], + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], + "wildcard": ["wildcard@2.0.1", "", {}, "sha512-CC1bOL87PIWSBhDcTrdeLo6eGT7mCFtrg0uIJtqJUFyK+eJnzl8A1niH56uu7KMa5XFrtiV+AQuHO3n7DsHnLQ=="], "esrecurse/estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="], @@ -334,5 +516,7 @@ "source-map-support/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], "terser/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="], + + "vite-node/es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="], } } diff --git a/extension/package.json b/extension/package.json index d56fe4b..c702388 100644 --- a/extension/package.json +++ b/extension/package.json @@ -8,13 +8,17 @@ "build:all": "npm run build:wasm && npm run build && npm run build:firefox", "dev": "webpack --mode development --watch", "dev:firefox": "webpack --config webpack.firefox.config.js --mode development --watch", - "build:wasm": "wasm-pack build ../crates/relicario-wasm --target web --out-dir ../../extension/wasm" + "build:wasm": "wasm-pack build ../crates/relicario-wasm --target web --out-dir ../../extension/wasm", + "test": "vitest run", + "test:watch": "vitest" }, "devDependencies": { "@types/chrome": "^0.1.40", "copy-webpack-plugin": "^12.0", + "happy-dom": "^15", "ts-loader": "^9.5", "typescript": "^5.4", + "vitest": "^2.0", "webpack": "^5.90", "webpack-cli": "^5.1" } diff --git a/extension/src/service-worker/router/__tests__/router.test.ts b/extension/src/service-worker/router/__tests__/router.test.ts new file mode 100644 index 0000000..0e1fc08 --- /dev/null +++ b/extension/src/service-worker/router/__tests__/router.test.ts @@ -0,0 +1,162 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { route, type RouterState } from '../index'; +import type { Request } from '../../../shared/messages'; + +// --- chrome.* shim --- + +// @ts-expect-error test harness +globalThis.chrome = { + runtime: { + id: 'relicario-test-id', + getURL: (p: string) => `chrome-extension://relicario-test-id/${p}`, + }, + storage: { local: { get: vi.fn().mockResolvedValue({}), set: vi.fn().mockResolvedValue(undefined) } }, + tabs: { get: vi.fn(), sendMessage: vi.fn() }, +}; + +function makePopupSender(): chrome.runtime.MessageSender { + return { url: `chrome-extension://relicario-test-id/popup.html`, id: 'relicario-test-id' }; +} + +function makeSetupSender(): chrome.runtime.MessageSender { + return { url: `chrome-extension://relicario-test-id/setup.html`, id: 'relicario-test-id' }; +} + +function makeContentSender(pageUrl = 'https://example.com/'): chrome.runtime.MessageSender { + return { + tab: { id: 42, url: pageUrl } as chrome.tabs.Tab, + frameId: 0, + id: 'relicario-test-id', + }; +} + +function makeExternalSender(): chrome.runtime.MessageSender { + return { url: 'https://evil.example/', id: 'some-other-extension' }; +} + +function makeState(): RouterState { + return { + manifest: { schema_version: 2, items: {} }, + gitHost: null, + wasm: { + // Stubs sufficient for the message types exercised by tests: + new_item_id: () => 'fakeitemid0000ab', + generate_password: () => 'PASSWORD', + rate_passphrase: () => ({ score: 4, guesses_log10: 15 }), + }, + }; +} + +// --- Sender-check matrix --- + +describe('router sender dispatch', () => { + let state: RouterState; + beforeEach(() => { state = makeState(); }); + + const popupOnlyMsgs: Request[] = [ + { type: 'is_unlocked' }, + { type: 'lock' }, + { type: 'list_items' }, + { type: 'generate_password', request: { kind: 'random', length: 20, classes: { lower: true, upper: true, digits: true, symbols: true }, symbol_charset: { kind: 'safe_only' } } }, + { type: 'rate_passphrase', passphrase: 'hunter2hunter2hunter2' }, + { type: 'get_blacklist' }, + ]; + + for (const msg of popupOnlyMsgs) { + it(`accepts popup-only "${msg.type}" from popup`, async () => { + const res = await route(msg, state, makePopupSender()); + expect(res).toMatchObject({ ok: true }); + }); + it(`rejects popup-only "${msg.type}" from content`, async () => { + const res = await route(msg, state, makeContentSender()); + expect(res).toEqual({ ok: false, error: 'unauthorized_sender' }); + }); + it(`rejects popup-only "${msg.type}" from external`, async () => { + const res = await route(msg, state, makeExternalSender()); + expect(res).toEqual({ ok: false, error: 'unauthorized_sender' }); + }); + } + + it('accepts save_setup from popup', async () => { + const msg: Request = { type: 'save_setup', config: { hostType: 'github', hostUrl: '', repoPath: '', apiToken: '' }, imageBase64: '' }; + const res = await route(msg, state, makePopupSender()); + expect(res).toMatchObject({ ok: true }); + }); + + it('accepts save_setup from setup tab', async () => { + const msg: Request = { type: 'save_setup', config: { hostType: 'github', hostUrl: '', repoPath: '', apiToken: '' }, imageBase64: '' }; + const res = await route(msg, state, makeSetupSender()); + expect(res).toMatchObject({ ok: true }); + }); + + it('rejects save_setup from content', async () => { + const msg: Request = { type: 'save_setup', config: { hostType: 'github', hostUrl: '', repoPath: '', apiToken: '' }, imageBase64: '' }; + const res = await route(msg, state, makeContentSender()); + expect(res).toEqual({ ok: false, error: 'unauthorized_sender' }); + }); + + const contentMsgs: Request[] = [ + { type: 'get_autofill_candidates' }, + { type: 'blacklist_site' }, + ]; + + for (const msg of contentMsgs) { + it(`accepts content "${msg.type}" from top-frame content`, async () => { + const res = await route(msg, state, makeContentSender()); + expect(res.ok).toBe(true); + }); + it(`rejects content "${msg.type}" from popup`, async () => { + const res = await route(msg, state, makePopupSender()); + expect(res).toEqual({ ok: false, error: 'unauthorized_sender' }); + }); + it(`rejects content "${msg.type}" from subframe`, async () => { + const sender: chrome.runtime.MessageSender = { ...makeContentSender(), frameId: 3 }; + const res = await route(msg, state, sender); + expect(res).toEqual({ ok: false, error: 'unauthorized_sender' }); + }); + it(`rejects content "${msg.type}" from external`, async () => { + const res = await route(msg, state, makeExternalSender()); + expect(res).toEqual({ ok: false, error: 'unauthorized_sender' }); + }); + } + + it('rejects unknown message type', async () => { + // @ts-expect-error intentional invalid type + const res = await route({ type: 'nonsense' }, state, makePopupSender()); + expect(res).toEqual({ ok: false, error: 'unknown_message_type' }); + }); +}); + +// --- Origin-bound autofill --- + +describe('get_autofill_candidates uses sender.tab.url', () => { + it('derives hostname from sender, not message', async () => { + const state: RouterState = makeState(); + state.manifest = { + schema_version: 2, + items: { + 'aaaaaaaaaaaaaaaa': { + id: 'aaaaaaaaaaaaaaaa', type: 'login', title: 'GitHub', + tags: [], favorite: false, icon_hint: 'github.com', + modified: 0, attachment_summaries: [], + }, + 'bbbbbbbbbbbbbbbb': { + id: 'bbbbbbbbbbbbbbbb', type: 'login', title: 'Example', + tags: [], favorite: false, icon_hint: 'example.com', + modified: 0, attachment_summaries: [], + }, + }, + }; + const res = await route( + { type: 'get_autofill_candidates' }, + state, + makeContentSender('https://example.com/login'), + ); + expect(res.ok).toBe(true); + if (res.ok) { + const data = res.data as { candidates: Array<[string, { title: string }]> }; + expect(data.candidates).toHaveLength(1); + expect(data.candidates[0][1].title).toBe('Example'); + } + }); +}); diff --git a/extension/src/shared/__tests__/base32.test.ts b/extension/src/shared/__tests__/base32.test.ts index e175db1..5b6a381 100644 --- a/extension/src/shared/__tests__/base32.test.ts +++ b/extension/src/shared/__tests__/base32.test.ts @@ -1,4 +1,3 @@ -// @ts-nocheck — transitional: downstream files updated in Slice 6 (item-* rewrites) / Slice 4 (vitest setup) / Slice 5 (content + setup rewires) // extension/src/shared/__tests__/base32.test.ts import { describe, expect, it } from 'vitest'; import { base32Decode, base32Encode } from '../base32'; diff --git a/extension/vitest.config.ts b/extension/vitest.config.ts new file mode 100644 index 0000000..02569a1 --- /dev/null +++ b/extension/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'happy-dom', + include: ['src/**/__tests__/**/*.test.ts'], + }, +}); From 0cef607859477ab7b28c79420f5984b83b66b6bd Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Mon, 20 Apr 2026 20:25:53 -0400 Subject: [PATCH 15/33] fix(ext/build): exclude test files from webpack tsc compile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice 4 spec review caught: router.test.ts's narrow chrome.* shim triggered 4 TS errors in webpack's ts-loader pass during production builds (partial mocks don't match the full chrome.* type surface). Plan's verbatim test body assumes tests aren't part of the build compile. Add src/**/__tests__/** to tsconfig exclude — tests still compile under Vitest's independent ts pipeline (42/42 passing). Co-Authored-By: Claude Opus 4.7 (1M context) --- extension/tsconfig.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extension/tsconfig.json b/extension/tsconfig.json index fde6d86..12146cb 100644 --- a/extension/tsconfig.json +++ b/extension/tsconfig.json @@ -15,5 +15,5 @@ "baseUrl": "." }, "include": ["src/**/*"], - "exclude": ["node_modules", "dist", "wasm"] + "exclude": ["node_modules", "dist", "wasm", "src/**/__tests__/**"] } From 2ff3ab1d7fdb4ffbefa01b13410209f6b7838d2a Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Mon, 20 Apr 2026 20:33:09 -0400 Subject: [PATCH 16/33] feat(ext): drop setup.html / wasm from web_accessible_resources (audit C1) setup.html is opened via chrome.tabs.create using a chrome-extension:// URL which doesn't require WAR. WASM is bundled into service-worker.js/setup.js and never fetched from a web page origin. Leaving them in WAR would expose their URLs to any origin for probing/fingerprinting; shipping an empty WAR array closes the surface. Co-Authored-By: Claude Opus 4.7 (1M context) --- extension/manifest.firefox.json | 4 +--- extension/manifest.json | 5 +---- 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/extension/manifest.firefox.json b/extension/manifest.firefox.json index a80f427..14937a6 100644 --- a/extension/manifest.firefox.json +++ b/extension/manifest.firefox.json @@ -35,7 +35,5 @@ "content_security_policy": { "extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'" }, - "web_accessible_resources": [{ - "resources": ["setup.html", "setup.js", "styles.css", "relicario_wasm_bg.wasm", "relicario_wasm.js"] - }] + "web_accessible_resources": [] } diff --git a/extension/manifest.json b/extension/manifest.json index db02853..7eb1630 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -30,8 +30,5 @@ "content_security_policy": { "extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'" }, - "web_accessible_resources": [{ - "resources": ["setup.html", "setup.js", "styles.css", "relicario_wasm_bg.wasm", "relicario_wasm.js"], - "matches": [""] - }] + "web_accessible_resources": [] } From fbb64729ce4db3232cf66517bded23a5d7598352 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Mon, 20 Apr 2026 20:33:49 -0400 Subject: [PATCH 17/33] feat(ext/popup): open setup via chrome.tabs.create, drop setup view from popup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The popup is too constrained for multi-step setup (Chrome closes it when focus shifts to a file picker). Previously the popup rendered a pass-through setup-wizard component that itself opened setup.html in a tab. Cut the middleman: if not configured, directly chrome.tabs.create the setup page and window.close() the popup. - Remove 'setup' from the View union and the setup case from render(). - Delete setup-wizard component entirely — setup.html is the canonical flow. - Drop renderSetupWizard import. The @ts-nocheck stays on popup.ts until Slice 6 (item-* rewrites). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/popup/components/setup-wizard.ts | 31 ------------------- extension/src/popup/popup.ts | 9 ++---- 2 files changed, 3 insertions(+), 37 deletions(-) delete mode 100644 extension/src/popup/components/setup-wizard.ts diff --git a/extension/src/popup/components/setup-wizard.ts b/extension/src/popup/components/setup-wizard.ts deleted file mode 100644 index 5b9b2ad..0000000 --- a/extension/src/popup/components/setup-wizard.ts +++ /dev/null @@ -1,31 +0,0 @@ -/// Setup prompt — directs users to the full-page setup wizard. -/// -/// The popup is too constrained for file pickers and multi-step forms -/// (Chrome closes it when focus shifts). All real setup happens in -/// setup.html, which pushes config to chrome.storage.local when done. - -import { escapeHtml } from '../popup'; - -export function renderSetupWizard(app: HTMLElement): void { - app.innerHTML = ` -
- -
relicario
-

two-factor vault

- -

- No vault configured yet. Open the setup wizard to - create a new vault or connect to an existing one. -

- - -
- `; - - document.getElementById('open-setup-btn')?.addEventListener('click', () => { - chrome.tabs.create({ url: chrome.runtime.getURL('setup.html') }); - window.close(); - }); -} diff --git a/extension/src/popup/popup.ts b/extension/src/popup/popup.ts index def13bc..360cb2e 100644 --- a/extension/src/popup/popup.ts +++ b/extension/src/popup/popup.ts @@ -10,7 +10,6 @@ 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'; import { renderSettings } from './components/settings'; // --- Escape HTML to prevent XSS --- @@ -22,7 +21,7 @@ export function escapeHtml(str: string): string { // --- State --- -export type View = 'setup' | 'locked' | 'list' | 'detail' | 'add' | 'edit' | 'settings'; +export type View = 'locked' | 'list' | 'detail' | 'add' | 'edit' | 'settings'; export interface PopupState { view: View; @@ -80,9 +79,6 @@ function render(): void { if (!app) return; switch (currentState.view) { - case 'setup': - renderSetupWizard(app); - break; case 'locked': renderUnlock(app); break; @@ -112,7 +108,8 @@ async function init(): Promise { if (setupResp.ok) { const data = setupResp.data as { isConfigured: boolean }; if (!data.isConfigured) { - navigate('setup'); + await chrome.tabs.create({ url: chrome.runtime.getURL('setup.html') }); + window.close(); return; } } From 8cc1e777be307bd172c4557b92493241036af575 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Mon, 20 Apr 2026 20:35:36 -0400 Subject: [PATCH 18/33] feat(ext/content): closed Shadow DOM + textContent for capture prompt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously the capture prompt was a normal
appended to document.body with innerHTML assembly. Any page script could find it via document.querySelector('#relicario-capture-prompt') and either scrape values or rewrite the buttons — and the innerHTML pattern meant hostname interpolation was a latent XSS path (escapeForHtml helped but one mistake would break it). - Add content/shadow.ts — createShadowHost() with mode: 'closed', host.style.all = 'initial'. - Rewrite capture.ts to mount inside the shadow root, build DOM via createElement + textContent only, never innerHTML. - Drop the `url` field from check_credential / blacklist_site — the router now derives origin from sender.tab.url (Slice 3 contract). - Update add_entry / update_entry calls to add_item / update_item with the new typed Item + LoginCore shape. - Swap RelicarioSettings → DeviceSettings. - Remove @ts-nocheck — the file type-checks clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- extension/src/content/capture.ts | 216 +++++++++++++++++-------------- extension/src/content/shadow.ts | 37 ++++++ 2 files changed, 159 insertions(+), 94 deletions(-) create mode 100644 extension/src/content/shadow.ts diff --git a/extension/src/content/capture.ts b/extension/src/content/capture.ts index 544a34e..4d26078 100644 --- a/extension/src/content/capture.ts +++ b/extension/src/content/capture.ts @@ -1,16 +1,22 @@ -// @ts-nocheck — transitional: downstream files updated in Slice 6 (item-* rewrites) / Slice 4 (vitest setup) / Slice 5 (content + setup rewires) /// Credential capture module. /// /// Detects login form submissions and prompts the user to save or update /// credentials in the vault. Supports bar and toast prompt styles. +/// +/// The prompt renders inside a closed Shadow DOM so the host page cannot +/// read overlay contents via document.querySelector or rewrite them via +/// insertAdjacentHTML. All caller-supplied strings (hostname, username) +/// are applied via textContent, never innerHTML. import type { Request, Response } from '../shared/messages'; -import type { RelicarioSettings } from '../shared/types'; +import type { DeviceSettings, Item, LoginCore } from '../shared/types'; +import { createShadowHost, type ShadowSurface } from './shadow'; // --- State --- const hookedForms = new WeakSet(); const hookedButtons = new WeakSet(); +let currentPrompt: ShadowSurface | null = null; // --- Messaging --- @@ -74,11 +80,10 @@ async function onFormSubmit(pwField: HTMLInputElement): Promise { if (!password) return; const username = findUsernameValue(pwField); - const url = window.location.href; + // Note: `url` is NOT sent — router derives origin from sender.tab.url. const resp = await sendMessage({ type: 'check_credential', - url, username, password, }); @@ -90,58 +95,64 @@ async function onFormSubmit(pwField: HTMLInputElement): Promise { // Fetch settings for prompt style const settingsResp = await sendMessage({ type: 'get_settings' }); - const settings: RelicarioSettings = settingsResp.ok - ? (settingsResp.data as { settings: RelicarioSettings }).settings - : { captureEnabled: true, captureStyle: 'bar' }; + const defaults: DeviceSettings = { captureEnabled: true, captureStyle: 'bar' }; + const settings: DeviceSettings = settingsResp.ok + ? ((settingsResp.data as { settings: DeviceSettings }).settings ?? defaults) + : defaults; - showPrompt(settings.captureStyle, data.action, url, username, password, data.entryId); + showPrompt(settings.captureStyle, data.action, username, password, data.entryId); } // --- Prompt UI --- function removeExistingPrompt(): void { - const existing = document.getElementById('relicario-capture-prompt'); - if (existing) existing.remove(); + if (currentPrompt) { + currentPrompt.destroy(); + currentPrompt = null; + } } function showPrompt( style: 'bar' | 'toast', action: string, - url: string, username: string, password: string, entryId?: string, ): void { removeExistingPrompt(); - let hostname: string; - try { - hostname = new URL(url).hostname; - } catch { - hostname = url; + const hostname = (() => { + try { return new URL(window.location.href).hostname; } catch { return window.location.href; } + })(); + const url = window.location.href; + + const surface = createShadowHost(); + currentPrompt = surface; + const { host, root } = surface; + + // Position the host on the page; all further styling lives inside the + // shadow root so the page's CSS can't reach us. + const baseHostStyles = 'z-index: 2147483647; position: fixed;'; + if (style === 'bar') { + host.style.cssText = `${baseHostStyles} top:0; left:0; right:0;`; + } else { + host.style.cssText = `${baseHostStyles} bottom:16px; right:16px;`; } - const container = document.createElement('div'); - container.id = 'relicario-capture-prompt'; + // --- Build prompt DOM via createElement / textContent only --- - // Common styles - const baseStyles = [ + const container = document.createElement('div'); + const containerBase = [ 'font-family: "SF Mono", "Fira Code", "Cascadia Code", monospace', 'font-size: 13px', 'color: #c9d1d9', 'background: #161b22', - 'z-index: 2147483647', 'box-sizing: border-box', 'line-height: 1.4', ]; - if (style === 'bar') { container.style.cssText = [ - ...baseStyles, - 'position: fixed', - 'top: 0', - 'left: 0', - 'right: 0', + ...containerBase, 'padding: 10px 16px', 'display: flex', 'align-items: center', @@ -153,10 +164,7 @@ function showPrompt( ].join('; '); } else { container.style.cssText = [ - ...baseStyles, - 'position: fixed', - 'bottom: 16px', - 'right: 16px', + ...containerBase, 'padding: 12px 16px', 'border-radius: 4px', 'border: 1px solid #30363d', @@ -168,30 +176,46 @@ function showPrompt( } const actionLabel = action === 'update' ? 'Update' : 'Save'; - const displayUser = username ? ` (${username})` : ''; - container.innerHTML = ` - - ${actionLabel} login for ${escapeForHtml(hostname)}${escapeForHtml(displayUser)}? - - - - - `; + // Message span: " login for ()?" + const msgSpan = document.createElement('span'); + msgSpan.style.cssText = 'flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;'; + msgSpan.appendChild(document.createTextNode(`${actionLabel} login for `)); + const hostStrong = document.createElement('strong'); + hostStrong.style.color = '#58a6ff'; + hostStrong.textContent = hostname; + msgSpan.appendChild(hostStrong); + if (username) { + msgSpan.appendChild(document.createTextNode(` (${username})`)); + } + msgSpan.appendChild(document.createTextNode('?')); - document.body.appendChild(container); + const saveBtn = document.createElement('button'); + saveBtn.textContent = actionLabel; + saveBtn.style.cssText = [ + 'background:#1f6feb', 'color:#fff', 'border:none', 'padding:5px 14px', + 'border-radius:3px', 'cursor:pointer', 'font-family:inherit', 'font-size:12px', + 'white-space:nowrap', + ].join('; '); + + const neverBtn = document.createElement('button'); + neverBtn.textContent = 'Never'; + neverBtn.style.cssText = [ + 'background:transparent', 'color:#8b949e', 'border:1px solid #30363d', + 'padding:5px 10px', 'border-radius:3px', 'cursor:pointer', + 'font-family:inherit', 'font-size:12px', 'white-space:nowrap', + ].join('; '); + + const closeBtn = document.createElement('button'); + closeBtn.textContent = '✕'; + closeBtn.style.cssText = [ + 'background:transparent', 'color:#8b949e', 'border:none', + 'cursor:pointer', 'font-size:16px', 'padding:2px 6px', + 'font-family:inherit', 'line-height:1', + ].join('; '); + + container.append(msgSpan, saveBtn, neverBtn, closeBtn); + root.appendChild(container); // Animate in requestAnimationFrame(() => { @@ -213,67 +237,71 @@ function showPrompt( }; // Save button - container.querySelector('#relicario-save-btn')?.addEventListener('click', async () => { + saveBtn.addEventListener('click', async () => { clearAutoDismiss(); - const now = new Date().toISOString(); + const now = Math.floor(Date.now() / 1000); + const loginCore: LoginCore & { type: 'login' } = { + type: 'login', + username, + password, + url, + }; + if (action === 'update' && entryId) { - await sendMessage({ - type: 'update_entry', - id: entryId, - entry: { - name: hostname, - url, - username, - password, - created_at: now, - updated_at: now, - }, - }); + // For update we need a valid Item — fetch the existing one, merge the + // updated login fields, and write it back. The router's update_item + // expects a full Item. We fall back to a minimal item if fetch fails. + const getResp = await sendMessage({ type: 'get_item', id: entryId }); + if (getResp.ok) { + const existing = (getResp.data as { item: Item }).item; + const updated: Item = { + ...existing, + title: existing.title || hostname, + modified: now, + core: { ...existing.core, ...loginCore }, + }; + await sendMessage({ type: 'update_item', id: entryId, item: updated }); + } } else { - await sendMessage({ - type: 'add_entry', - entry: { - name: hostname, - url, - username, - password, - created_at: now, - updated_at: now, - }, - }); + // New item — SW will assign the id; we just pass an empty string. + const item: Item = { + id: '', + title: hostname, + type: 'login', + tags: [], + favorite: false, + created: now, + modified: now, + core: loginCore, + sections: [], + attachments: [], + field_history: {}, + }; + await sendMessage({ type: 'add_item', item }); } // Show confirmation - const span = container.querySelector('span'); - if (span) span.textContent = '\u2713 Saved'; - const saveBtn = container.querySelector('#relicario-save-btn') as HTMLElement | null; - const neverBtn = container.querySelector('#relicario-never-btn') as HTMLElement | null; - if (saveBtn) saveBtn.style.display = 'none'; - if (neverBtn) neverBtn.style.display = 'none'; + msgSpan.textContent = '✓ Saved'; + saveBtn.style.display = 'none'; + neverBtn.style.display = 'none'; setTimeout(() => removeExistingPrompt(), 1500); }); - // Never button - container.querySelector('#relicario-never-btn')?.addEventListener('click', async () => { + // Never button: router derives hostname from sender.tab.url (no `hostname` field) + neverBtn.addEventListener('click', async () => { clearAutoDismiss(); - await sendMessage({ type: 'blacklist_site', hostname }); + await sendMessage({ type: 'blacklist_site' }); removeExistingPrompt(); }); // Close button - container.querySelector('#relicario-close-btn')?.addEventListener('click', () => { + closeBtn.addEventListener('click', () => { clearAutoDismiss(); removeExistingPrompt(); }); } -function escapeForHtml(str: string): string { - const div = document.createElement('div'); - div.textContent = str; - return div.innerHTML; -} - // --- Form hooking --- export function hookForms(): void { diff --git a/extension/src/content/shadow.ts b/extension/src/content/shadow.ts new file mode 100644 index 0000000..d76aad2 --- /dev/null +++ b/extension/src/content/shadow.ts @@ -0,0 +1,37 @@ +/// Closed Shadow DOM host helper. +/// +/// All in-page UI (capture prompt, autofill icon, candidate picker, TOFU +/// banner) mounts into a closed-mode ShadowRoot so the host page cannot +/// read or mutate the overlay via document.querySelector / DOM APIs. The +/// returned ShadowSurface provides {host, root, destroy} for callers that +/// want to populate the root, position the host, and tear everything down. + +export interface ShadowSurface { + /// The host
that's appended to document.body. Style/position this + /// from the caller (position: fixed, z-index, transform, etc.). + host: HTMLDivElement; + /// Closed-mode ShadowRoot. Populate via textContent / appendChild — + /// NEVER innerHTML, NEVER insertAdjacentHTML. Treat any caller-supplied + /// string (hostname, username) as untrusted. + root: ShadowRoot; + /// Remove the host from the DOM and drop all references. + destroy: () => void; +} + +/// Create a closed Shadow DOM host attached to document.body. +/// +/// Callers are responsible for positioning `host` and filling `root`. +export function createShadowHost(): ShadowSurface { + const host = document.createElement('div'); + // Reset host-side styling so page CSS cannot leak in/out via inheritance. + host.style.all = 'initial'; + const root = host.attachShadow({ mode: 'closed' }); + document.body.appendChild(host); + return { + host, + root, + destroy: () => { + host.remove(); + }, + }; +} From 14397b33f01e493662a4f2c5fbcfef0c3d435395 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Mon, 20 Apr 2026 20:37:25 -0400 Subject: [PATCH 19/33] feat(ext/content): closed Shadow DOM for icon/picker/TOFU + close fill TOCTOU MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two security fixes bundled together because they all live on the icon-click/fill path: 1. Icon + picker + TOFU hint now render inside closed-mode Shadow DOM (via shadow.createShadowHost). Page scripts can no longer find our overlay via document.querySelector or rewrite buttons. 2. Icon's get_autofill_candidates call drops the `url` field — router derives origin from sender.tab.url. Similarly get_credentials. 3. Icon's get_credentials response handling was buggy: the response is a discriminated union { requires_ack, hostname } | { username, password } and the old code always read .username (→ undefined when requires_ack). New code dispatches on the `requires_ack` marker and either shows an in-page TOFU hint or fills directly. 4. fill_credentials is popup-only in the router — the icon click cannot (and MUST NOT) issue it from content. The new flow calls fillFields() directly after get_credentials returns the plaintext: the content script IS the origin, so no SW round-trip is needed for the typing. 5. TOCTOU on the popup → SW → content fill path: the SW verified the captured tab's hostname matched capturedUrl, then forwarded blindly. Between that check and chrome.tabs.sendMessage delivery, the tab can navigate; chrome.tabs.sendMessage delivers to whatever content-script principal is loaded at send-time. Closed by: - Router forwards { expectedHost: currentHost } in the payload. - fill.ts re-checks location.href.hostname === expectedHost before typing anything; on mismatch replies { ok: false, error: 'origin_changed' } and types nothing. 6. Remove @ts-nocheck from icon.ts, fill.ts, and detector.ts — all three now type-check clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- extension/src/content/detector.ts | 1 - extension/src/content/fill.ts | 36 ++- extension/src/content/icon.ts | 279 +++++++++++------- .../src/service-worker/router/popup-only.ts | 5 + 4 files changed, 211 insertions(+), 110 deletions(-) diff --git a/extension/src/content/detector.ts b/extension/src/content/detector.ts index 5090944..f369f0e 100644 --- a/extension/src/content/detector.ts +++ b/extension/src/content/detector.ts @@ -1,4 +1,3 @@ -// @ts-nocheck — transitional: downstream files updated in Slice 6 (item-* rewrites) / Slice 4 (vitest setup) / Slice 5 (content + setup rewires) /// Content script entry point. /// /// Detects login forms on the page by finding password fields and their diff --git a/extension/src/content/fill.ts b/extension/src/content/fill.ts index 88e00c8..ebc663c 100644 --- a/extension/src/content/fill.ts +++ b/extension/src/content/fill.ts @@ -1,13 +1,41 @@ -/// Fill listener — receives credentials from the service worker and fills form fields. +/// Fill listener — receives credentials from the service worker popup flow, +/// verifies origin, and fills page fields. /// -/// Uses the native value setter trick to work with React/Vue controlled inputs -/// that override the value property. +/// TOCTOU mitigation: the popup captures its active tab at open time and +/// passes {capturedTabId, capturedUrl, expectedHost} to the SW. The SW +/// re-fetches the tab and checks the hostname against `capturedUrl` before +/// forwarding, but between the SW's chrome.tabs.sendMessage and our receipt +/// the page could navigate. We re-check `location.href.hostname === +/// expectedHost` before typing credentials. If the page has navigated +/// (different origin now running the content script), reply with +/// `origin_changed` and do nothing. + +/// Message shape forwarded by router/popup-only.ts#handleFillCredentials. +export interface FillMessage { + type: 'fill_credentials'; + username: string; + password: string; + /// The hostname the SW validated the captured tab was on. The content + /// script rejects delivery if the page has since navigated away. + expectedHost: string; +} /// Set up a listener for fill_credentials messages from the service worker. export function setupFillListener(): void { chrome.runtime.onMessage.addListener( - (message: { type: string; username: string; password: string }, _sender: chrome.runtime.MessageSender, sendResponse: (response: { ok: boolean }) => void) => { + ( + message: FillMessage, + _sender: chrome.runtime.MessageSender, + sendResponse: (response: { ok: boolean; error?: string }) => void, + ) => { if (message.type !== 'fill_credentials') return false; + const currentHost = (() => { + try { return new URL(location.href).hostname; } catch { return ''; } + })(); + if (!currentHost || currentHost !== message.expectedHost) { + sendResponse({ ok: false, error: 'origin_changed' }); + return false; + } fillFields(message.username, message.password); sendResponse({ ok: true }); return false; diff --git a/extension/src/content/icon.ts b/extension/src/content/icon.ts index 2e4031c..a637559 100644 --- a/extension/src/content/icon.ts +++ b/extension/src/content/icon.ts @@ -1,16 +1,40 @@ -// @ts-nocheck — transitional: downstream files updated in Slice 6 (item-* rewrites) / Slice 4 (vitest setup) / Slice 5 (content + setup rewires) /// Inject a small "id" icon into password fields for quick autofill access. /// -/// Uses a WeakSet to avoid double-injection on re-scans (MutationObserver). +/// Each injected icon and picker renders inside a closed Shadow DOM so +/// the host page cannot read or manipulate our UI. +/// +/// Flow: +/// 1. Icon click → chrome.runtime.sendMessage({ type: 'get_autofill_candidates' }) +/// (router derives origin from sender.tab.url; no url on message). +/// 2. Single candidate → get_credentials; if response is a +/// requires_ack variant, show an in-page TOFU hint instructing the +/// user to open the popup for ack. Otherwise, call fillFields() +/// directly — the content script IS the page origin, so no SW +/// round-trip for the fill itself. +/// 3. Multiple candidates → show the picker inside a shadow root. +/// +/// Note: fill_credentials is popup-only in the router. The icon click path +/// cannot and MUST NOT issue fill_credentials from content. -import type { ManifestEntry } from '../shared/types'; +import type { AutofillCandidatesResponse, CredentialsResponse, Response } from '../shared/messages'; +import type { ManifestEntry, ItemId } from '../shared/types'; +import { createShadowHost, type ShadowSurface } from './shadow'; +import { fillFields } from './fill'; /// Track which fields already have an injected icon. const injected = new WeakSet(); +/// The currently-open picker / TOFU hint, if any. +let currentOverlay: ShadowSurface | null = null; + +function closeOverlay(): void { + if (currentOverlay) { + currentOverlay.destroy(); + currentOverlay = null; + } +} + /// Inject a small blue "id" icon at the right edge of a password field. -/// Clicking it queries for autofill candidates and either fills immediately -/// (single match) or shows an inline picker (multiple matches). export function injectFieldIcons( passwordField: HTMLInputElement, _usernameField: HTMLInputElement | null, @@ -18,145 +42,190 @@ export function injectFieldIcons( if (injected.has(passwordField)) return; injected.add(passwordField); - // Create the icon element. + // Each icon gets its own shadow host so page CSS cannot reach it. + const surface = createShadowHost(); + const { host, root } = surface; + + // Compute initial position from the password field's bounding rect and + // reposition on scroll/resize. We keep things lightweight — exact + // pixel-perfect tracking during layout churn is not required. + function positionHost(): void { + const rect = passwordField.getBoundingClientRect(); + host.style.cssText = [ + 'position: fixed', + `top: ${rect.top + rect.height / 2 - 10}px`, + `left: ${rect.right - 28}px`, + 'z-index: 2147483646', + 'pointer-events: auto', + ].join('; '); + } + positionHost(); + window.addEventListener('scroll', positionHost, true); + window.addEventListener('resize', positionHost); + const icon = document.createElement('div'); icon.textContent = 'id'; icon.setAttribute('role', 'button'); icon.setAttribute('aria-label', 'relicario autofill'); + icon.style.cssText = [ + 'width: 20px', 'height: 20px', 'line-height: 20px', + 'text-align: center', 'font-size: 10px', 'font-weight: 700', + 'font-family: monospace', 'color: #fff', 'background: #1f6feb', + 'border-radius: 3px', 'cursor: pointer', 'user-select: none', + 'box-sizing: border-box', + ].join('; '); + root.appendChild(icon); - Object.assign(icon.style, { - position: 'absolute', - right: '8px', - top: '50%', - transform: 'translateY(-50%)', - width: '20px', - height: '20px', - lineHeight: '20px', - textAlign: 'center', - fontSize: '10px', - fontWeight: '700', - fontFamily: 'monospace', - color: '#fff', - background: '#1f6feb', - borderRadius: '3px', - cursor: 'pointer', - zIndex: '999999', - userSelect: 'none', - }); - - // Ensure the password field's parent is positioned so the icon can be absolute. - const parent = passwordField.parentElement; - if (parent) { - const parentPosition = getComputedStyle(parent).position; - if (parentPosition === 'static') { - parent.style.position = 'relative'; - } - } - - // Insert the icon after the password field. - passwordField.insertAdjacentElement('afterend', icon); - - // Click handler: query for autofill candidates. icon.addEventListener('click', async (e) => { e.preventDefault(); e.stopPropagation(); - const url = window.location.href; + // Note: no `url` on message — router derives from sender.tab.url. const resp = await chrome.runtime.sendMessage({ type: 'get_autofill_candidates', - url, - }); + }) as Response; if (!resp || !resp.ok) return; - const candidates = resp.data.candidates as Array<[string, ManifestEntry]>; - + const candidates = (resp as AutofillCandidatesResponse).data.candidates; if (candidates.length === 0) return; if (candidates.length === 1) { - // Single match — fill immediately. - const [id] = candidates[0]; - const credResp = await chrome.runtime.sendMessage({ - type: 'get_credentials', - id, - }); - if (credResp?.ok) { - chrome.runtime.sendMessage({ - type: 'fill_credentials', - username: credResp.data.username, - password: credResp.data.password, - }); - } + await handleSingleCandidate(candidates[0][0]); } else { - // Multiple matches — show inline picker. - showPicker(icon, candidates); + showPicker(passwordField, candidates); } }); } -/// Show a small dropdown picker below the icon for selecting among multiple candidates. +/// Fetch credentials for a single item and either fill immediately or +/// display the TOFU ack hint. +async function handleSingleCandidate(id: ItemId): Promise { + const credResp = await chrome.runtime.sendMessage({ + type: 'get_credentials', + id, + }) as Response; + if (!credResp?.ok) return; + + const data = (credResp as CredentialsResponse).data; + if ('requires_ack' in data && data.requires_ack) { + showAckHint(data.hostname); + return; + } + // Discriminated union: must be the {username, password} variant here. + if ('username' in data && 'password' in data) { + fillFields(data.username, data.password); + } +} + +/// Render a dropdown picker below the password field for selecting among +/// multiple candidates. The picker lives in its own closed Shadow DOM. function showPicker( - anchor: HTMLElement, - candidates: Array<[string, ManifestEntry]>, + anchor: HTMLInputElement, + candidates: Array<[ItemId, ManifestEntry]>, ): void { - // Remove any existing picker. - document.querySelectorAll('.relicario-picker').forEach(el => el.remove()); + closeOverlay(); + const surface = createShadowHost(); + currentOverlay = surface; + const { host, root } = surface; + + const rect = anchor.getBoundingClientRect(); + host.style.cssText = [ + 'position: fixed', + `top: ${rect.bottom + 4}px`, + `left: ${rect.right - 180}px`, + 'z-index: 2147483647', + ].join('; '); const picker = document.createElement('div'); - picker.className = 'relicario-picker'; - Object.assign(picker.style, { - position: 'absolute', - right: '0', - top: '100%', - marginTop: '4px', - background: '#161b22', - border: '1px solid #30363d', - borderRadius: '6px', - boxShadow: '0 4px 12px rgba(0,0,0,0.4)', - zIndex: '9999999', - minWidth: '180px', - maxHeight: '200px', - overflowY: 'auto', - fontFamily: "'JetBrains Mono', monospace", - fontSize: '12px', - }); + picker.style.cssText = [ + 'background: #161b22', 'border: 1px solid #30363d', + 'border-radius: 6px', 'box-shadow: 0 4px 12px rgba(0,0,0,0.4)', + 'min-width: 180px', 'max-height: 200px', 'overflow-y: auto', + "font-family: 'JetBrains Mono', monospace", 'font-size: 12px', + ].join('; '); for (const [id, entry] of candidates) { const row = document.createElement('div'); - row.textContent = `${entry.name}${entry.username ? ` (${entry.username})` : ''}`; - Object.assign(row.style, { - padding: '8px 12px', - cursor: 'pointer', - color: '#c9d1d9', - borderBottom: '1px solid #21262d', - }); + const label = entry.title + (/* user hint */ ''); + row.textContent = label; + row.style.cssText = [ + 'padding: 8px 12px', 'cursor: pointer', 'color: #c9d1d9', + 'border-bottom: 1px solid #21262d', + ].join('; '); row.addEventListener('mouseenter', () => { row.style.background = '#21262d'; }); row.addEventListener('mouseleave', () => { row.style.background = 'transparent'; }); row.addEventListener('click', async (e) => { e.stopPropagation(); - picker.remove(); - const credResp = await chrome.runtime.sendMessage({ - type: 'get_credentials', - id, - }); - if (credResp?.ok) { - chrome.runtime.sendMessage({ - type: 'fill_credentials', - username: credResp.data.username, - password: credResp.data.password, - }); - } + closeOverlay(); + await handleSingleCandidate(id); }); picker.appendChild(row); } - anchor.parentElement?.appendChild(picker); + root.appendChild(picker); - // Close picker on outside click. - const closeHandler = (e: MouseEvent) => { - if (!picker.contains(e.target as Node) && e.target !== anchor) { - picker.remove(); + // Close picker on outside click (scoped to document; shadow root blocks + // composedPath for closed mode but the host element still shows up). + const closeHandler = (e: MouseEvent): void => { + if (e.target !== host) { + closeOverlay(); document.removeEventListener('click', closeHandler); } }; setTimeout(() => document.addEventListener('click', closeHandler), 0); } + +/// TOFU origin-ack hint: credentials exist for this host but the user has +/// never explicitly acknowledged autofill here. Instruct them to open +/// relicario to confirm — we do not (and cannot) fill until ack-autofill +/// has been called from the popup. +function showAckHint(hostname: string): void { + closeOverlay(); + const surface = createShadowHost(); + currentOverlay = surface; + const { host, root } = surface; + + host.style.cssText = [ + 'position: fixed', 'top: 16px', 'right: 16px', + 'z-index: 2147483647', + ].join('; '); + + const hint = document.createElement('div'); + hint.style.cssText = [ + 'font-family: "SF Mono", "Fira Code", "Cascadia Code", monospace', + 'font-size: 12px', 'color: #c9d1d9', 'background: #161b22', + 'border: 1px solid #30363d', 'border-radius: 6px', + 'padding: 10px 14px', 'box-shadow: 0 4px 12px rgba(0,0,0,0.4)', + 'max-width: 320px', 'line-height: 1.5', + ].join('; '); + + const title = document.createElement('div'); + title.style.cssText = 'font-weight: 700; margin-bottom: 4px; color: #58a6ff;'; + title.textContent = 'relicario'; + hint.appendChild(title); + + const body = document.createElement('div'); + body.appendChild(document.createTextNode('First autofill on ')); + const hostSpan = document.createElement('strong'); + hostSpan.textContent = hostname; + body.appendChild(hostSpan); + body.appendChild(document.createTextNode(' — open relicario to confirm.')); + hint.appendChild(body); + + const close = document.createElement('div'); + close.textContent = '✕'; + close.style.cssText = [ + 'position: absolute', 'top: 6px', 'right: 8px', + 'cursor: pointer', 'color: #8b949e', 'font-size: 14px', + ].join('; '); + close.addEventListener('click', closeOverlay); + hint.style.position = 'relative'; + hint.appendChild(close); + + root.appendChild(hint); + + // Auto-dismiss after 8 seconds + setTimeout(() => { + if (currentOverlay === surface) closeOverlay(); + }, 8000); +} diff --git a/extension/src/service-worker/router/popup-only.ts b/extension/src/service-worker/router/popup-only.ts index 8480913..b71f9d0 100644 --- a/extension/src/service-worker/router/popup-only.ts +++ b/extension/src/service-worker/router/popup-only.ts @@ -199,10 +199,15 @@ async function handleFillCredentials( const itemHost = safeHostname(item.core.url ?? ''); if (!itemHost || itemHost !== currentHost) return { ok: false, error: 'origin_mismatch' }; + // Pass the hostname the SW validated. The content script re-verifies + // against location.href before filling — if the tab navigated between + // our chrome.tabs.get check above and the sendMessage delivery below, + // fill.ts rejects with 'origin_changed'. await chrome.tabs.sendMessage(msg.capturedTabId, { type: 'fill_credentials', username: item.core.username ?? '', password: item.core.password ?? '', + expectedHost: currentHost, }); return { ok: true }; } From eed11acba29044072953b6269337b076d499fbda Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Mon, 20 Apr 2026 20:37:49 -0400 Subject: [PATCH 20/33] feat(ext/popup): snapshot activeTab at popup-open for fill_credentials (audit M5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend PopupState with {capturedTabId, capturedUrl} populated via chrome.tabs.query({active: true, currentWindow: true}) in init(). These are later passed with fill_credentials so the SW can verify the captured tab's hostname hasn't changed out from under the user before forwarding credentials. Combined with expectedHost in the forwarded payload + content-side re-check in fill.ts, this closes the TOCTOU window on the popup → SW → content fill path. popup.ts stays under @ts-nocheck (Slice 6 removes it alongside the item-* rewrites). Co-Authored-By: Claude Opus 4.7 (1M context) --- extension/src/popup/popup.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/extension/src/popup/popup.ts b/extension/src/popup/popup.ts index 360cb2e..a808007 100644 --- a/extension/src/popup/popup.ts +++ b/extension/src/popup/popup.ts @@ -33,6 +33,12 @@ export interface PopupState { activeGroup: string | null; error: string | null; loading: boolean; + // Captured tab snapshot taken at popup-open. Used by fill_credentials + // to guard against TOCTOU navigation — the SW re-checks this URL's + // hostname against the tab's live URL before forwarding fill_credentials + // to the content script. See router/popup-only.ts#handleFillCredentials. + capturedTabId: number | null; + capturedUrl: string; } let currentState: PopupState = { @@ -45,6 +51,8 @@ let currentState: PopupState = { activeGroup: null, error: null, loading: false, + capturedTabId: null, + capturedUrl: '', }; export function getState(): PopupState { @@ -103,6 +111,13 @@ function render(): void { // --- Init --- async function init(): Promise { + // Snapshot the active tab at popup-open — the fill path uses this + // tabId/url pair so the SW can verify the tab hasn't navigated before + // forwarding credentials (audit M5 + TOCTOU close via expectedHost). + const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); + currentState.capturedTabId = tab?.id ?? null; + currentState.capturedUrl = tab?.url ?? ''; + // Check if extension is configured. const setupResp = await sendMessage({ type: 'get_setup_state' }); if (setupResp.ok) { From 1d5ad5e59e6369e9e7db94a1703f646b57511aff Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Mon, 20 Apr 2026 20:39:49 -0400 Subject: [PATCH 21/33] test(ext/router): add fill_credentials + save_setup exception tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three new describe blocks cover the gaps flagged during Slice 4 review: 1. fill_credentials captured-tab verification — three cases: - tab_navigated: chrome.tabs.get returns a tab whose hostname differs from capturedUrl → handler must return { ok: false, tab_navigated } and not call chrome.tabs.sendMessage. - origin_mismatch: tab matches capturedUrl but the item's LoginCore.url hostname differs → same refusal, no delivery. - happy path: verify the forwarded message is exactly { type: 'fill_credentials', username, password, expectedHost }. 2. save_setup exception scope: the setup tab gets a narrow exception to POST save_setup, but nothing else. Prove fill_credentials from the setup tab is rejected with unauthorized_sender. 3. isContent sender.id guard: a content-shaped sender with a bogus sender.id (≠ chrome.runtime.id) must be rejected. Vault/session modules are partial-mocked via vi.mock + importOriginal so the existing tests continue to exercise real listItems/findByHostname. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../router/__tests__/router.test.ts | 180 ++++++++++++++++++ 1 file changed, 180 insertions(+) diff --git a/extension/src/service-worker/router/__tests__/router.test.ts b/extension/src/service-worker/router/__tests__/router.test.ts index 0e1fc08..bca9835 100644 --- a/extension/src/service-worker/router/__tests__/router.test.ts +++ b/extension/src/service-worker/router/__tests__/router.test.ts @@ -1,6 +1,34 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// --- Mocks (must be declared before `route` is imported so the router's +// `import * as vault` / `import * as session` resolve to these doubles) --- + +// Partial mock: we override only the vault calls the new tests care about +// (fetchAndDecryptItem / fetchAndDecryptSettings / encryptAndWriteSettings) +// and let the real implementations of listItems / findByHostname / etc. +// continue to run for the other tests that don't need mocks. +vi.mock('../../vault', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + fetchAndDecryptItem: vi.fn(), + fetchAndDecryptSettings: vi.fn(), + encryptAndWriteSettings: vi.fn(), + }; +}); + +vi.mock('../../session', () => ({ + setCurrent: vi.fn(), + getCurrent: vi.fn(), + clearCurrent: vi.fn(), + requireCurrent: vi.fn(), +})); + import { route, type RouterState } from '../index'; import type { Request } from '../../../shared/messages'; +import type { Item } from '../../../shared/types'; +import * as vault from '../../vault'; +import * as session from '../../session'; // --- chrome.* shim --- @@ -160,3 +188,155 @@ describe('get_autofill_candidates uses sender.tab.url', () => { } }); }); + +// --- fill_credentials TOCTOU + origin verification --- + +describe('fill_credentials captured-tab verification', () => { + const FAKE_ITEM_ID = 'cccccccccccccccc'; + + function loginItem(url: string): Item { + return { + id: FAKE_ITEM_ID, + title: 'Example', + type: 'login', + tags: [], + favorite: false, + created: 0, + modified: 0, + core: { type: 'login', username: 'alice', password: 'hunter2', url }, + sections: [], + attachments: [], + field_history: {}, + }; + } + + function primeUnlocked(state: RouterState): void { + // Provide a fake handle + githost so the handler's "vault_locked" guard + // passes — values don't matter because vault is mocked. + vi.mocked(session.getCurrent).mockReturnValue({ free: () => {} } as never); + state.gitHost = {} as never; + } + + beforeEach(() => { + vi.mocked(session.getCurrent).mockReset(); + vi.mocked(vault.fetchAndDecryptItem).mockReset(); + (chrome.tabs.get as ReturnType).mockReset(); + (chrome.tabs.sendMessage as ReturnType).mockReset(); + }); + + it('returns tab_navigated when captured tab hostname differs from current', async () => { + const state = makeState(); + primeUnlocked(state); + // chrome.tabs.get returns a tab that has navigated to a DIFFERENT host. + (chrome.tabs.get as ReturnType).mockResolvedValue({ + id: 42, + url: 'https://evil.example/landing', + }); + + const res = await route( + { + type: 'fill_credentials', + id: FAKE_ITEM_ID, + capturedTabId: 42, + capturedUrl: 'https://example.com/login', + }, + state, + makePopupSender(), + ); + expect(res).toEqual({ ok: false, error: 'tab_navigated' }); + // We must NOT have attempted to deliver credentials. + expect(chrome.tabs.sendMessage).not.toHaveBeenCalled(); + }); + + it('returns origin_mismatch when item hostname differs from current tab', async () => { + const state = makeState(); + primeUnlocked(state); + // Tab is still on example.com (matches capturedUrl) … + (chrome.tabs.get as ReturnType).mockResolvedValue({ + id: 42, + url: 'https://example.com/login', + }); + // … but the item we'd fill belongs to github.com. + vi.mocked(vault.fetchAndDecryptItem).mockResolvedValue( + loginItem('https://github.com/login'), + ); + + const res = await route( + { + type: 'fill_credentials', + id: FAKE_ITEM_ID, + capturedTabId: 42, + capturedUrl: 'https://example.com/login', + }, + state, + makePopupSender(), + ); + expect(res).toEqual({ ok: false, error: 'origin_mismatch' }); + expect(chrome.tabs.sendMessage).not.toHaveBeenCalled(); + }); + + it('forwards fill_credentials with expectedHost when all checks pass', async () => { + const state = makeState(); + primeUnlocked(state); + (chrome.tabs.get as ReturnType).mockResolvedValue({ + id: 42, + url: 'https://example.com/login', + }); + vi.mocked(vault.fetchAndDecryptItem).mockResolvedValue( + loginItem('https://example.com/login'), + ); + (chrome.tabs.sendMessage as ReturnType).mockResolvedValue({ ok: true }); + + const res = await route( + { + type: 'fill_credentials', + id: FAKE_ITEM_ID, + capturedTabId: 42, + capturedUrl: 'https://example.com/login', + }, + state, + makePopupSender(), + ); + expect(res).toEqual({ ok: true }); + expect(chrome.tabs.sendMessage).toHaveBeenCalledWith(42, { + type: 'fill_credentials', + username: 'alice', + password: 'hunter2', + expectedHost: 'example.com', + }); + }); +}); + +// --- save_setup exception scope: setup tab is ONLY allowed save_setup --- + +describe('save_setup exception scope', () => { + it('rejects fill_credentials from the setup tab (setup can only save_setup)', async () => { + const state = makeState(); + const res = await route( + { + type: 'fill_credentials', + id: 'cccccccccccccccc', + capturedTabId: 42, + capturedUrl: 'https://example.com/', + }, + state, + makeSetupSender(), + ); + expect(res).toEqual({ ok: false, error: 'unauthorized_sender' }); + }); +}); + +// --- isContent rejects unknown sender.id --- + +describe('isContent sender.id guard', () => { + it('rejects content-shaped sender whose id is not the extension id', async () => { + const state = makeState(); + const sender: chrome.runtime.MessageSender = { + tab: { id: 42, url: 'https://example.com/' } as chrome.tabs.Tab, + frameId: 0, + id: 'some-other-extension', // NOT chrome.runtime.id + }; + const res = await route({ type: 'get_autofill_candidates' }, state, sender); + expect(res).toEqual({ ok: false, error: 'unauthorized_sender' }); + }); +}); From 856ceb2d93b0326b4c556576ccbf7a6a623963ae Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Mon, 20 Apr 2026 20:57:38 -0400 Subject: [PATCH 22/33] fix(ext): content-callable capture_save_login closes critical router gap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After Slice 4's router split, the capture prompt's Save button was silently failing on every site: content/capture.ts called four handlers (get_settings, get_item, update_item, add_item) that are all in POPUP_ONLY_TYPES, so the router rejected each with unauthorized_sender. Fix in two parts: Part A — get_settings: content scripts already have storage permission via the manifest, so read relicarioSettings directly from chrome.storage.local instead of round-tripping through the SW. Part B — new content-callable 'capture_save_login' message that consolidates what was previously three separate popup-only calls (get_item + update_item or add_item) into one SW-side operation. Content scripts no longer need to distinguish add vs update — the SW does that itself from the manifest. Security model (all enforced SW-side, never trusting content): - Origin is derived from sender.tab.url by the router. The payload contains only username + password; there is no way for content to influence which host the new/updated item binds to. - Update path re-verifies the existing item's core.url hostname matches senderHost before mutating. If the manifest icon_hint ever drifts from core.url, we return origin_mismatch rather than silently binding a password to the wrong origin. - Update mutates ONLY the password field + modified timestamp — never title, url, or any other core field. - Add path creates a new Login item whose title is senderHost and whose url is the sender's origin. Five new router tests cover: content-accept, popup-reject, update path rotates only the password, add path creates bound item, and origin_mismatch when the stored item's host disagrees with senderHost. Tests: 47 -> 52. Co-Authored-By: Claude Opus 4.7 (1M context) --- extension/src/content/capture.ts | 68 ++------ .../router/__tests__/router.test.ts | 150 ++++++++++++++++++ .../service-worker/router/content-callable.ts | 90 ++++++++++- extension/src/shared/messages.ts | 4 +- 4 files changed, 257 insertions(+), 55 deletions(-) diff --git a/extension/src/content/capture.ts b/extension/src/content/capture.ts index 4d26078..3a3addc 100644 --- a/extension/src/content/capture.ts +++ b/extension/src/content/capture.ts @@ -9,7 +9,7 @@ /// are applied via textContent, never innerHTML. import type { Request, Response } from '../shared/messages'; -import type { DeviceSettings, Item, LoginCore } from '../shared/types'; +import type { DeviceSettings } from '../shared/types'; import { createShadowHost, type ShadowSurface } from './shadow'; // --- State --- @@ -93,14 +93,15 @@ async function onFormSubmit(pwField: HTMLInputElement): Promise { const data = resp.data as { action: string; entryId?: string; entryName?: string }; if (data.action === 'skip') return; - // Fetch settings for prompt style - const settingsResp = await sendMessage({ type: 'get_settings' }); - const defaults: DeviceSettings = { captureEnabled: true, captureStyle: 'bar' }; - const settings: DeviceSettings = settingsResp.ok - ? ((settingsResp.data as { settings: DeviceSettings }).settings ?? defaults) - : defaults; + // Fetch settings for prompt style. Content scripts have direct + // chrome.storage.local access (manifest grants "storage"), so we don't + // need to round-trip through the SW for this — which also avoids the + // router's content→popup-only rejection for 'get_settings'. + const stored = await chrome.storage.local.get('relicarioSettings'); + const settings: DeviceSettings = (stored.relicarioSettings as DeviceSettings) + ?? { captureEnabled: true, captureStyle: 'bar' }; - showPrompt(settings.captureStyle, data.action, username, password, data.entryId); + showPrompt(settings.captureStyle, data.action, username, password); } // --- Prompt UI --- @@ -117,14 +118,12 @@ function showPrompt( action: string, username: string, password: string, - entryId?: string, ): void { removeExistingPrompt(); const hostname = (() => { try { return new URL(window.location.href).hostname; } catch { return window.location.href; } })(); - const url = window.location.href; const surface = createShadowHost(); currentPrompt = surface; @@ -236,52 +235,15 @@ function showPrompt( if (autoDismissTimer) clearTimeout(autoDismissTimer); }; - // Save button + // Save button — single content-callable message; the SW figures out + // whether this is an add or an update (and enforces origin-binding). saveBtn.addEventListener('click', async () => { clearAutoDismiss(); - - const now = Math.floor(Date.now() / 1000); - const loginCore: LoginCore & { type: 'login' } = { - type: 'login', - username, - password, - url, - }; - - if (action === 'update' && entryId) { - // For update we need a valid Item — fetch the existing one, merge the - // updated login fields, and write it back. The router's update_item - // expects a full Item. We fall back to a minimal item if fetch fails. - const getResp = await sendMessage({ type: 'get_item', id: entryId }); - if (getResp.ok) { - const existing = (getResp.data as { item: Item }).item; - const updated: Item = { - ...existing, - title: existing.title || hostname, - modified: now, - core: { ...existing.core, ...loginCore }, - }; - await sendMessage({ type: 'update_item', id: entryId, item: updated }); - } - } else { - // New item — SW will assign the id; we just pass an empty string. - const item: Item = { - id: '', - title: hostname, - type: 'login', - tags: [], - favorite: false, - created: now, - modified: now, - core: loginCore, - sections: [], - attachments: [], - field_history: {}, - }; - await sendMessage({ type: 'add_item', item }); + const resp = await sendMessage({ type: 'capture_save_login', username, password }); + if (!resp.ok) { + msgSpan.textContent = `✗ ${resp.error}`; + return; } - - // Show confirmation msgSpan.textContent = '✓ Saved'; saveBtn.style.display = 'none'; neverBtn.style.display = 'none'; diff --git a/extension/src/service-worker/router/__tests__/router.test.ts b/extension/src/service-worker/router/__tests__/router.test.ts index bca9835..388b2da 100644 --- a/extension/src/service-worker/router/__tests__/router.test.ts +++ b/extension/src/service-worker/router/__tests__/router.test.ts @@ -14,6 +14,8 @@ vi.mock('../../vault', async (importOriginal) => { fetchAndDecryptItem: vi.fn(), fetchAndDecryptSettings: vi.fn(), encryptAndWriteSettings: vi.fn(), + encryptAndWriteItem: vi.fn(), + encryptAndWriteManifest: vi.fn(), }; }); @@ -340,3 +342,151 @@ describe('isContent sender.id guard', () => { expect(res).toEqual({ ok: false, error: 'unauthorized_sender' }); }); }); + +// --- capture_save_login (content-callable, origin-bound) --- + +describe('capture_save_login', () => { + const EXISTING_ID = 'dddddddddddddddd'; + + function loginItem(url: string, username: string, password: string): Item { + return { + id: EXISTING_ID, + title: 'Example', + type: 'login', + tags: [], + favorite: false, + created: 0, + modified: 0, + core: { type: 'login', username, password, url }, + sections: [], + attachments: [], + field_history: {}, + }; + } + + function primeUnlocked(state: RouterState): void { + vi.mocked(session.getCurrent).mockReturnValue({ free: () => {} } as never); + state.gitHost = {} as never; + } + + beforeEach(() => { + vi.mocked(session.getCurrent).mockReset(); + vi.mocked(vault.fetchAndDecryptItem).mockReset(); + vi.mocked(vault.encryptAndWriteItem).mockReset(); + vi.mocked(vault.encryptAndWriteManifest).mockReset(); + vi.mocked(vault.encryptAndWriteItem).mockResolvedValue(undefined); + vi.mocked(vault.encryptAndWriteManifest).mockResolvedValue(undefined); + }); + + it('accepts capture_save_login from top-frame content', async () => { + const state = makeState(); + primeUnlocked(state); + const res = await route( + { type: 'capture_save_login', username: 'alice', password: 'hunter2' }, + state, + makeContentSender('https://example.com/login'), + ); + expect(res.ok).toBe(true); + }); + + it('rejects capture_save_login from popup', async () => { + const state = makeState(); + primeUnlocked(state); + const res = await route( + { type: 'capture_save_login', username: 'alice', password: 'hunter2' }, + state, + makePopupSender(), + ); + expect(res).toEqual({ ok: false, error: 'unauthorized_sender' }); + }); + + it('update path: existing (host, username) match rotates the password', async () => { + const state = makeState(); + primeUnlocked(state); + // Seed manifest with a login for example.com. + state.manifest = { + schema_version: 2, + items: { + [EXISTING_ID]: { + id: EXISTING_ID, type: 'login', title: 'Example', + tags: [], favorite: false, icon_hint: 'example.com', + modified: 0, attachment_summaries: [], + }, + }, + }; + vi.mocked(vault.fetchAndDecryptItem).mockResolvedValue( + loginItem('https://example.com/', 'alice', 'oldpass'), + ); + + const res = await route( + { type: 'capture_save_login', username: 'alice', password: 'newpass' }, + state, + makeContentSender('https://example.com/login'), + ); + expect(res).toMatchObject({ ok: true, data: { action: 'updated', id: EXISTING_ID } }); + // Verify write was invoked with a core whose password is the new one. + expect(vault.encryptAndWriteItem).toHaveBeenCalledTimes(1); + const writtenItem = vi.mocked(vault.encryptAndWriteItem).mock.calls[0][3]; + expect(writtenItem.id).toBe(EXISTING_ID); + if (writtenItem.core.type !== 'login') throw new Error('expected login core'); + expect(writtenItem.core.password).toBe('newpass'); + expect(writtenItem.core.username).toBe('alice'); + }); + + it('add path: no match creates a new item bound to senderHost', async () => { + const state = makeState(); + primeUnlocked(state); + // Empty manifest — no candidates. + state.manifest = { schema_version: 2, items: {} }; + + const res = await route( + { type: 'capture_save_login', username: 'bob', password: 's3cret' }, + state, + makeContentSender('https://example.com/signup'), + ); + expect(res.ok).toBe(true); + if (res.ok) { + const data = res.data as { action: string; id: string }; + expect(data.action).toBe('added'); + expect(data.id).toBe('fakeitemid0000ab'); // from stub new_item_id() + } + expect(vault.encryptAndWriteItem).toHaveBeenCalledTimes(1); + const newItem = vi.mocked(vault.encryptAndWriteItem).mock.calls[0][3]; + expect(newItem.title).toBe('example.com'); + if (newItem.core.type !== 'login') throw new Error('expected login core'); + expect(newItem.core.url).toBe('https://example.com'); + expect(newItem.core.username).toBe('bob'); + expect(newItem.core.password).toBe('s3cret'); + // Manifest entry should have been added too. + expect(state.manifest!.items['fakeitemid0000ab']).toBeDefined(); + }); + + it('origin_mismatch when existing item for same username has a different host', async () => { + const state = makeState(); + primeUnlocked(state); + // Manifest says there's a match for example.com (icon_hint), but the + // underlying item actually belongs to github.com — defense-in-depth + // check should reject. + state.manifest = { + schema_version: 2, + items: { + [EXISTING_ID]: { + id: EXISTING_ID, type: 'login', title: 'Example', + tags: [], favorite: false, icon_hint: 'example.com', + modified: 0, attachment_summaries: [], + }, + }, + }; + vi.mocked(vault.fetchAndDecryptItem).mockResolvedValue( + loginItem('https://github.com/', 'alice', 'oldpass'), + ); + + const res = await route( + { type: 'capture_save_login', username: 'alice', password: 'newpass' }, + state, + makeContentSender('https://example.com/login'), + ); + expect(res).toEqual({ ok: false, error: 'origin_mismatch' }); + expect(vault.encryptAndWriteItem).not.toHaveBeenCalled(); + }); +}); diff --git a/extension/src/service-worker/router/content-callable.ts b/extension/src/service-worker/router/content-callable.ts index df46b60..73143b5 100644 --- a/extension/src/service-worker/router/content-callable.ts +++ b/extension/src/service-worker/router/content-callable.ts @@ -5,7 +5,7 @@ /// sender.tab !== undefined. import type { ContentMessage, Response } from '../../shared/messages'; -import type { Manifest } from '../../shared/types'; +import type { Item, Manifest } from '../../shared/types'; import type { GitHost } from '../git-host'; import * as vault from '../vault'; import * as session from '../session'; @@ -13,6 +13,8 @@ import * as session from '../session'; export interface ContentState { manifest: Manifest | null; gitHost: GitHost | null; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + wasm: any; } export async function handle( @@ -93,9 +95,95 @@ export async function handle( } return { ok: true }; } + + case 'capture_save_login': { + const handle = session.getCurrent(); + if (!handle || !state.gitHost || !state.manifest) return { ok: false, error: 'vault_locked' }; + + // Look for an existing login for this origin + username. Origin is + // always senderHost (derived from sender.tab.url by the router) — the + // content script cannot influence which host we bind to. + const candidates = vault.findByHostname(state.manifest, senderHost); + for (const [id, entry] of candidates) { + if (entry.type !== 'login') continue; + const full = await vault.fetchAndDecryptItem(state.gitHost, handle, id); + if (full.core.type !== 'login') continue; + if (full.core.username === msg.username) { + // Defense in depth: verify the existing item's own URL hostname + // matches senderHost. If it doesn't (e.g. manifest icon_hint + // drifted from core.url), refuse to mutate — updating here would + // silently bind a password to the wrong origin. + const existingHost = safeHostname(full.core.url ?? ''); + if (existingHost !== senderHost) return { ok: false, error: 'origin_mismatch' }; + + // Update only the password field + modified timestamp. + const updated: Item = { + ...full, + modified: Math.floor(Date.now() / 1000), + core: { ...full.core, password: msg.password }, + }; + await vault.encryptAndWriteItem(state.gitHost, handle, id, updated, `capture: update ${existingHost}`); + state.manifest.items[id] = itemToManifestEntry(updated); + await vault.encryptAndWriteManifest(state.gitHost, handle, state.manifest, `manifest: update ${existingHost}`); + return { ok: true, data: { action: 'updated', id } }; + } + } + + // No match → create a new Login item bound to senderHost. Title + // defaults to the hostname; url is the sender's full origin when we + // have it, otherwise derived from senderHost. + const now = Math.floor(Date.now() / 1000); + const newId = state.wasm.new_item_id(); + const senderOrigin = (() => { + try { return sender.tab?.url ? new URL(sender.tab.url).origin : `https://${senderHost}`; } + catch { return `https://${senderHost}`; } + })(); + const item: Item = { + id: newId, + title: senderHost, + type: 'login', + tags: [], + favorite: false, + created: now, + modified: now, + core: { + type: 'login', + username: msg.username, + password: msg.password, + url: senderOrigin, + }, + sections: [], + attachments: [], + field_history: {}, + }; + await vault.encryptAndWriteItem(state.gitHost, handle, newId, item, `capture: add ${senderHost}`); + state.manifest.items[newId] = itemToManifestEntry(item); + await vault.encryptAndWriteManifest(state.gitHost, handle, state.manifest, `manifest: add ${senderHost}`); + return { ok: true, data: { action: 'added', id: newId } }; + } } } +// --- Manifest entry derivation (duplicated from popup-only for self-containment) --- + +function itemToManifestEntry(item: Item) { + return { + id: item.id, + type: item.type, + title: item.title, + tags: item.tags, + favorite: item.favorite, + group: item.group, + icon_hint: (item.core.type === 'login' && item.core.url) + ? safeHostname(item.core.url) : undefined, + modified: item.modified, + trashed_at: item.trashed_at, + attachment_summaries: item.attachments.map((a) => ({ + id: a.id, filename: a.filename, mime_type: a.mime_type, size: a.size, + })), + }; +} + async function loadDeviceSettings(): Promise<{ captureEnabled: boolean; captureStyle: 'bar' | 'toast' }> { const r = await chrome.storage.local.get('relicarioSettings'); return (r.relicarioSettings as { captureEnabled: boolean; captureStyle: 'bar' | 'toast' }) diff --git a/extension/src/shared/messages.ts b/extension/src/shared/messages.ts index 55a320a..3001b9f 100644 --- a/extension/src/shared/messages.ts +++ b/extension/src/shared/messages.ts @@ -35,7 +35,8 @@ export type ContentMessage = | { type: 'get_autofill_candidates' } | { type: 'get_credentials'; id: ItemId } | { type: 'check_credential'; username: string; password: string } - | { type: 'blacklist_site' }; + | { type: 'blacklist_site' } + | { type: 'capture_save_login'; username: string; password: string }; // --- Union for chrome.runtime.sendMessage call sites --- @@ -99,4 +100,5 @@ export const POPUP_ONLY_TYPES: ReadonlySet = new Set([ export const CONTENT_CALLABLE_TYPES: ReadonlySet = new Set([ 'get_autofill_candidates', 'get_credentials', 'check_credential', 'blacklist_site', + 'capture_save_login', ] as ContentMessage['type'][]); From d090fc421e4682006c13aa2d417d999f3a15c9f7 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Mon, 20 Apr 2026 21:01:50 -0400 Subject: [PATCH 23/33] =?UTF-8?q?refactor(ext/popup):=20rename=20entry-*?= =?UTF-8?q?=20=E2=86=92=20item-*=20components?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Git-moves the three popup components so history survives the content rewrite that follows in Tasks 22–24: - entry-list.ts → item-list.ts - entry-detail.ts → item-detail.ts - entry-form.ts → item-form.ts Also renames the exported render functions (renderEntryList → renderItemList, etc.) and updates popup.ts imports + render switch. The files still wear @ts-nocheck and reference the old Entry type; content rewriting happens in Tasks 22–24. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../components/{entry-detail.ts => item-detail.ts} | 2 +- .../components/{entry-form.ts => item-form.ts} | 2 +- .../components/{entry-list.ts => item-list.ts} | 2 +- extension/src/popup/popup.ts | 14 +++++++------- 4 files changed, 10 insertions(+), 10 deletions(-) rename extension/src/popup/components/{entry-detail.ts => item-detail.ts} (99%) rename extension/src/popup/components/{entry-form.ts => item-form.ts} (98%) rename extension/src/popup/components/{entry-list.ts => item-list.ts} (98%) diff --git a/extension/src/popup/components/entry-detail.ts b/extension/src/popup/components/item-detail.ts similarity index 99% rename from extension/src/popup/components/entry-detail.ts rename to extension/src/popup/components/item-detail.ts index 2a29874..beba57f 100644 --- a/extension/src/popup/components/entry-detail.ts +++ b/extension/src/popup/components/item-detail.ts @@ -29,7 +29,7 @@ async function copyToClipboard(text: string): Promise { } } -export function renderEntryDetail(app: HTMLElement): void { +export function renderItemDetail(app: HTMLElement): void { const state = getState(); const entry = state.selectedEntry; const id = state.selectedId; diff --git a/extension/src/popup/components/entry-form.ts b/extension/src/popup/components/item-form.ts similarity index 98% rename from extension/src/popup/components/entry-form.ts rename to extension/src/popup/components/item-form.ts index 2391461..16fd9a9 100644 --- a/extension/src/popup/components/entry-form.ts +++ b/extension/src/popup/components/item-form.ts @@ -4,7 +4,7 @@ import { getState, setState, sendMessage, navigate, escapeHtml } from '../popup'; import type { Entry, ManifestEntry } from '../../shared/types'; -export function renderEntryForm(app: HTMLElement, mode: 'add' | 'edit'): void { +export function renderItemForm(app: HTMLElement, mode: 'add' | 'edit'): void { const state = getState(); const existing = mode === 'edit' ? state.selectedEntry : null; diff --git a/extension/src/popup/components/entry-list.ts b/extension/src/popup/components/item-list.ts similarity index 98% rename from extension/src/popup/components/entry-list.ts rename to extension/src/popup/components/item-list.ts index 0c1cc34..ca637ff 100644 --- a/extension/src/popup/components/entry-list.ts +++ b/extension/src/popup/components/item-list.ts @@ -23,7 +23,7 @@ function getGroups(entries: Array<[string, ManifestEntry]>): string[] { return Array.from(groups).sort(); } -export function renderEntryList(app: HTMLElement): void { +export function renderItemList(app: HTMLElement): void { const state = getState(); const groups = getGroups(state.entries); const filtered = getFilteredEntries(); diff --git a/extension/src/popup/popup.ts b/extension/src/popup/popup.ts index a808007..2116f1d 100644 --- a/extension/src/popup/popup.ts +++ b/extension/src/popup/popup.ts @@ -7,9 +7,9 @@ import type { Request, Response } from '../shared/messages'; import type { ManifestEntry, Entry } from '../shared/types'; import { renderUnlock } from './components/unlock'; -import { renderEntryList } from './components/entry-list'; -import { renderEntryDetail } from './components/entry-detail'; -import { renderEntryForm } from './components/entry-form'; +import { renderItemList } from './components/item-list'; +import { renderItemDetail } from './components/item-detail'; +import { renderItemForm } from './components/item-form'; import { renderSettings } from './components/settings'; // --- Escape HTML to prevent XSS --- @@ -91,16 +91,16 @@ function render(): void { renderUnlock(app); break; case 'list': - renderEntryList(app); + renderItemList(app); break; case 'detail': - renderEntryDetail(app); + renderItemDetail(app); break; case 'add': - renderEntryForm(app, 'add'); + renderItemForm(app, 'add'); break; case 'edit': - renderEntryForm(app, 'edit'); + renderItemForm(app, 'edit'); break; case 'settings': renderSettings(app); From dc8097589eb67bd95605ea40d109d8e6cacf14c2 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Mon, 20 Apr 2026 21:09:28 -0400 Subject: [PATCH 24/33] feat(ext/popup): typed-item list view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrites item-list.ts to render the typed-item ManifestEntry v2 surface: title + type-icon emoji (🔑/📝/🪪/💳/🗝/📄/⏱) + icon_hint as the meta line. Toolbar now has +new, sync, settings, lock. Keyboard nav unchanged (/, +, arrows, Enter). Clicking a row fires list_items → get_item (the new typed-item messages) and stores the full Item in state.selectedItem before navigating to 'detail'. Also updates popup.ts PopupState: - entries now typed Array<[ItemId, ManifestEntry]> - selectedEntry → selectedItem (Item) - init() uses list_items not list_entries Trashed items (trashed_at set) are filtered out of the visible list. @ts-nocheck removed from item-list.ts. Co-Authored-By: Claude Opus 4.7 (1M context) --- extension/src/popup/components/item-list.ts | 141 +++++++++++--------- extension/src/popup/popup.ts | 16 +-- 2 files changed, 86 insertions(+), 71 deletions(-) diff --git a/extension/src/popup/components/item-list.ts b/extension/src/popup/components/item-list.ts index ca637ff..8e3a569 100644 --- a/extension/src/popup/components/item-list.ts +++ b/extension/src/popup/components/item-list.ts @@ -1,62 +1,60 @@ -// @ts-nocheck — transitional: downstream files updated in Slice 6 (item-* rewrites) / Slice 4 (vitest setup) / Slice 5 (content + setup rewires) -/// Entry list view — search bar, group tabs, scrollable entry list with keyboard nav. +/// Typed-item list view — toolbar (search, new, sync, lock, settings) + +/// type-iconed rows. Clicking a row fetches the full Item and navigates +/// to the detail view. import { getState, setState, sendMessage, navigate, escapeHtml } from '../popup'; -import type { ManifestEntry } from '../../shared/types'; +import type { ItemId, ItemType, ManifestEntry, Item } from '../../shared/types'; -/// Extract the domain from a URL for display. -function domainOf(url: string | undefined): string { - if (!url) return ''; - try { - return new URL(url).hostname; - } catch { - return ''; - } +/// Extract the display hostname from an icon_hint or fallback to the first tag. +function metaLine(e: ManifestEntry): string { + if (e.icon_hint) return e.icon_hint; + if (e.tags.length > 0) return e.tags.join(', '); + return ''; } -/// Derive unique group names from the current entries. -function getGroups(entries: Array<[string, ManifestEntry]>): string[] { - const groups = new Set(); - for (const [, e] of entries) { - if (e.group) groups.add(e.group); +/// Emoji icon per item type. Placeholder until we ship real SVG icons. +function typeIcon(t: ItemType): string { + switch (t) { + case 'login': return '🔑'; + case 'secure_note': return '📝'; + case 'identity': return '🪪'; + case 'card': return '💳'; + case 'key': return '🗝'; + case 'document': return '📄'; + case 'totp': return '⏱'; } - return Array.from(groups).sort(); } export function renderItemList(app: HTMLElement): void { const state = getState(); - const groups = getGroups(state.entries); const filtered = getFilteredEntries(); - const groupTabsHtml = groups.length > 0 - ? `
- - ${groups.map(g => - `` - ).join('')} -
` - : ''; - - const entriesHtml = filtered.length > 0 + const rowsHtml = filtered.length > 0 ? filtered.map(([id, e], i) => `
- ${escapeHtml(e.name)} - + ${escapeHtml(e.title)} +
`).join('') - : '
no entries
'; + : '
no items
'; app.innerHTML = ` - ${groupTabsHtml} -
- ${entriesHtml} +
+ + + + + +
+
+ ${rowsHtml}
/ search - + add + + new ↑↓ nav Enter open
@@ -64,44 +62,60 @@ export function renderItemList(app: HTMLElement): void { // --- Event listeners --- - const searchInput = document.getElementById('search-input') as HTMLInputElement; + const searchInput = document.getElementById('search-input') as HTMLInputElement | null; searchInput?.addEventListener('input', () => { setState({ searchQuery: searchInput.value, selectedIndex: 0 }); }); - // Group tab clicks. - const groupTabs = app.querySelectorAll('.group-tab'); - groupTabs.forEach(tab => { - tab.addEventListener('click', () => { - const group = (tab as HTMLElement).dataset.group || null; - setState({ activeGroup: group, selectedIndex: 0 }); - }); + document.getElementById('new-btn')?.addEventListener('click', () => navigate('add')); + + document.getElementById('sync-btn')?.addEventListener('click', async () => { + setState({ loading: true, error: null }); + const resp = await sendMessage({ type: 'sync' }); + if (resp.ok) { + const listResp = await sendMessage({ type: 'list_items' }); + if (listResp.ok) { + const data = listResp.data as { items: Array<[ItemId, ManifestEntry]> }; + setState({ entries: data.items, loading: false }); + return; + } + setState({ loading: false, error: listResp.error }); + } else { + setState({ loading: false, error: resp.error }); + } }); - // Entry row clicks. + document.getElementById('lock-btn')?.addEventListener('click', async () => { + await sendMessage({ type: 'lock' }); + navigate('locked'); + }); + + document.getElementById('settings-btn')?.addEventListener('click', () => navigate('settings')); + + // Item row clicks. const rows = app.querySelectorAll('.entry-row'); rows.forEach(row => { row.addEventListener('click', async () => { const id = (row as HTMLElement).dataset.id!; - await openEntry(id); + await openItem(id); }); }); // Keyboard navigation. document.addEventListener('keydown', handleListKeydown); - // Focus search on / key (unless already focused). + // Focus search on open. searchInput?.focus(); } -async function openEntry(id: string): Promise { +async function openItem(id: ItemId): Promise { setState({ loading: true }); - const resp = await sendMessage({ type: 'get_entry', id }); + const resp = await sendMessage({ type: 'get_item', id }); if (resp.ok) { - const data = resp.data as { entry: import('../../shared/types').Entry }; + const data = resp.data as { item: Item }; navigate('detail', { selectedId: id, - selectedEntry: data.entry, + selectedItem: data.item, }); } else { setState({ loading: false, error: resp.error }); @@ -109,23 +123,21 @@ async function openEntry(id: string): Promise { } /// Compute the visible (filtered) entry list from current state. -function getFilteredEntries(): Array<[string, ManifestEntry]> { +function getFilteredEntries(): Array<[ItemId, ManifestEntry]> { const state = getState(); - let filtered = state.entries; - if (state.activeGroup) { - const g = state.activeGroup.toLowerCase(); - filtered = filtered.filter(([, e]) => e.group?.toLowerCase() === g); - } + // Hide trashed items from the main list. + let filtered = state.entries.filter(([, e]) => e.trashed_at === undefined || e.trashed_at === null); if (state.searchQuery) { const q = state.searchQuery.toLowerCase(); filtered = filtered.filter(([, e]) => { - if (e.name.toLowerCase().includes(q)) return true; - if (e.url?.toLowerCase().includes(q)) return true; - if (e.username?.toLowerCase().includes(q)) return true; + if (e.title.toLowerCase().includes(q)) return true; + if (e.icon_hint?.toLowerCase().includes(q)) return true; + if (e.group?.toLowerCase().includes(q)) return true; + if (e.tags.some((t) => t.toLowerCase().includes(q))) return true; return false; }); } - filtered.sort((a, b) => a[1].name.localeCompare(b[1].name)); + filtered.sort((a, b) => a[1].title.localeCompare(b[1].title)); return filtered; } @@ -136,12 +148,13 @@ function handleListKeydown(e: KeyboardEvent): void { if (e.key === '/' && !isSearch) { e.preventDefault(); - (document.getElementById('search-input') as HTMLInputElement)?.focus(); + (document.getElementById('search-input') as HTMLInputElement | null)?.focus(); return; } if (e.key === '+' && !isSearch) { e.preventDefault(); + document.removeEventListener('keydown', handleListKeydown); navigate('add'); return; } @@ -163,8 +176,10 @@ function handleListKeydown(e: KeyboardEvent): void { if (e.key === 'Enter' && !isSearch) { e.preventDefault(); - if (filtered[state.selectedIndex]) { - openEntry(filtered[state.selectedIndex][0]); + const selected = filtered[state.selectedIndex]; + if (selected) { + document.removeEventListener('keydown', handleListKeydown); + void openItem(selected[0]); } return; } diff --git a/extension/src/popup/popup.ts b/extension/src/popup/popup.ts index 2116f1d..04ef6df 100644 --- a/extension/src/popup/popup.ts +++ b/extension/src/popup/popup.ts @@ -5,7 +5,7 @@ /// Navigation works by updating `currentState` and calling `render()`. import type { Request, Response } from '../shared/messages'; -import type { ManifestEntry, Entry } from '../shared/types'; +import type { ItemId, ManifestEntry, Item } from '../shared/types'; import { renderUnlock } from './components/unlock'; import { renderItemList } from './components/item-list'; import { renderItemDetail } from './components/item-detail'; @@ -25,9 +25,9 @@ export type View = 'locked' | 'list' | 'detail' | 'add' | 'edit' | 'settings'; export interface PopupState { view: View; - entries: Array<[string, ManifestEntry]>; - selectedId: string | null; - selectedEntry: Entry | null; + entries: Array<[ItemId, ManifestEntry]>; + selectedId: ItemId | null; + selectedItem: Item | null; selectedIndex: number; searchQuery: string; activeGroup: string | null; @@ -45,7 +45,7 @@ let currentState: PopupState = { view: 'locked', entries: [], selectedId: null, - selectedEntry: null, + selectedItem: null, selectedIndex: 0, searchQuery: '', activeGroup: null, @@ -135,10 +135,10 @@ async function init(): Promise { const data = unlockResp.data as { unlocked: boolean }; if (data.unlocked) { // Load entries and go to list. - const listResp = await sendMessage({ type: 'list_entries' }); + const listResp = await sendMessage({ type: 'list_items' }); if (listResp.ok) { - const listData = listResp.data as { entries: Array<[string, ManifestEntry]> }; - navigate('list', { entries: listData.entries }); + const listData = listResp.data as { items: Array<[ItemId, ManifestEntry]> }; + navigate('list', { entries: listData.items }); return; } } From bc95b047a2052bb36b6e1fe5120bee6d6ad02ce8 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Mon, 20 Apr 2026 21:10:41 -0400 Subject: [PATCH 25/33] feat(ext/popup): Login detail view + coming-soon for other types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrites item-detail.ts to dispatch on item.type: login gets the full detail view (url, username, masked password + copy, TOTP with 30s countdown, notes, group, autofill/edit/trash/back buttons). Non-login types get a coming-soon placeholder; those grow full UIs in later slices. Fixes Slice 4 review I1: the old autofill path sent a malformed fill_credentials payload ({ username, password } — no id/capturedTab). The new handler uses the (capturedTabId, capturedUrl) pair snapshotted at popup-open and calls fill_credentials with { id, capturedTabId, capturedUrl }, matching the SW's handler signature that enforces the M5 + TOCTOU checks. TOTP poll now calls get_totp on a 1s timer and renders the 30s countdown bar against expires_at. @ts-nocheck removed. Co-Authored-By: Claude Opus 4.7 (1M context) --- extension/src/popup/components/item-detail.ts | 222 +++++++++++++----- 1 file changed, 167 insertions(+), 55 deletions(-) diff --git a/extension/src/popup/components/item-detail.ts b/extension/src/popup/components/item-detail.ts index beba57f..54957ba 100644 --- a/extension/src/popup/components/item-detail.ts +++ b/extension/src/popup/components/item-detail.ts @@ -1,8 +1,12 @@ -// @ts-nocheck — transitional: downstream files updated in Slice 6 (item-* rewrites) / Slice 4 (vitest setup) / Slice 5 (content + setup rewires) -/// Entry detail view — shows fields, TOTP countdown, copy/fill shortcuts. +/// Typed-item detail view — dispatches on `item.type`. Slice 6 delivers +/// full Login parity; all other types show a "coming soon" placeholder. +/// +/// Autofill uses the (capturedTabId, capturedUrl) pair snapshotted at +/// popup-open (see PopupState + router/popup-only.ts#handleFillCredentials) +/// so the SW can reject the fill if the tab navigated. import { getState, setState, sendMessage, navigate, escapeHtml } from '../popup'; -import type { ManifestEntry } from '../../shared/types'; +import type { Item, ItemId, ManifestEntry, LoginCore, TotpConfig } from '../../shared/types'; let totpInterval: ReturnType | null = null; @@ -31,54 +35,63 @@ async function copyToClipboard(text: string): Promise { export function renderItemDetail(app: HTMLElement): void { const state = getState(); - const entry = state.selectedEntry; - const id = state.selectedId; - if (!entry || !id) { + const item = state.selectedItem; + if (!item) { navigate('list'); return; } stopTotpTimer(); + if (item.type === 'login') { + renderLogin(app, item); + } else { + renderComingSoon(app, item); + } +} + +// --- Login detail ------------------------------------------------------ + +function renderLogin(app: HTMLElement, item: Item): void { + const core = item.core as (LoginCore & { type: 'login' }); + const hasTotp = core.totp !== undefined; + let html = `
- ${escapeHtml(entry.name)} + ${escapeHtml(item.title)}
`; - // URL - if (entry.url) { + if (core.url) { html += `
url
-
${escapeHtml(entry.url)}
+
`; } - // Username - if (entry.username) { + if (core.username) { html += `
username
-
${escapeHtml(entry.username)}
+
${escapeHtml(core.username)}
`; } - // Password (masked by default) html += `
password
-
+
******** +
`; - // TOTP - if (entry.totp_secret) { + if (hasTotp) { html += `
totp
@@ -88,42 +101,46 @@ export function renderItemDetail(app: HTMLElement): void { `; } - // Notes - if (entry.notes) { + if (item.notes) { html += `
notes
-
${escapeHtml(entry.notes)}
+
${escapeHtml(item.notes)}
`; } - // Group - if (entry.group) { + if (item.group) { html += `
group
-
${escapeHtml(entry.group)}
+
${escapeHtml(item.group)}
`; } - // Metadata html += `
-
updated ${escapeHtml(entry.updated_at)}
+
modified ${escapeHtml(new Date(item.modified * 1000).toISOString())}
+
+ `; + + html += ` +
+ + +
`; - // Key hints html += `
c copy user p copy pass - ${entry.totp_secret ? 't copy totp' : ''} + ${hasTotp ? 't copy totp' : ''} f autofill e edit - d delete + d trash
`; @@ -131,25 +148,65 @@ export function renderItemDetail(app: HTMLElement): void { // --- Password toggle --- let passwordVisible = false; - const passwordDisplay = document.getElementById('password-display')!; - const passwordVal = document.getElementById('password-val')!; - passwordVal?.addEventListener('click', () => { + const passwordDisplay = document.getElementById('password-display'); + const passwordVal = document.getElementById('password-val'); + const password = core.password ?? ''; + passwordVal?.addEventListener('click', (e) => { + // Ignore clicks originating on the copy button. + if ((e.target as HTMLElement).id === 'password-copy') return; passwordVisible = !passwordVisible; - passwordDisplay.textContent = passwordVisible ? entry.password : '********'; + if (passwordDisplay) passwordDisplay.textContent = passwordVisible ? password : '********'; + }); + document.getElementById('password-copy')?.addEventListener('click', async (e) => { + e.stopPropagation(); + await copyToClipboard(password); }); - // --- Back button --- + if (core.username) { + document.getElementById('username-val')?.addEventListener('click', async () => { + await copyToClipboard(core.username ?? ''); + }); + } + document.getElementById('back-btn')?.addEventListener('click', goBack); + document.getElementById('fill-btn')?.addEventListener('click', async () => { + const { capturedTabId, capturedUrl } = getState(); + if (capturedTabId === null) { + setState({ error: 'No active tab captured' }); + return; + } + const resp = await sendMessage({ + type: 'fill_credentials', + id: item.id, + capturedTabId, + capturedUrl, + }); + if (!resp.ok) { + setState({ error: resp.error }); + return; + } + window.close(); + }); + + document.getElementById('edit-btn')?.addEventListener('click', () => { + document.removeEventListener('keydown', handler); + stopTotpTimer(); + navigate('edit'); + }); + + document.getElementById('trash-btn')?.addEventListener('click', () => { + showDeleteConfirm(item.id, item.title, handler); + }); + // --- TOTP timer --- - if (entry.totp_secret) { - refreshTotp(id); - totpInterval = setInterval(() => refreshTotp(id), 1000); + if (hasTotp) { + void refreshTotp(item.id); + totpInterval = setInterval(() => { void refreshTotp(item.id); }, 1000); } // --- Keyboard shortcuts --- const handler = async (e: KeyboardEvent) => { - // Ignore if typing in an input. if ((e.target as HTMLElement).tagName === 'INPUT') return; switch (e.key) { @@ -159,27 +216,34 @@ export function renderItemDetail(app: HTMLElement): void { break; case 'c': - if (entry.username) await copyToClipboard(entry.username); + if (core.username) await copyToClipboard(core.username); break; case 'p': - await copyToClipboard(entry.password); + await copyToClipboard(password); break; case 't': - if (entry.totp_secret) { + if (hasTotp) { const codeEl = document.getElementById('totp-code'); if (codeEl) await copyToClipboard(codeEl.textContent ?? ''); } break; case 'f': { + const { capturedTabId, capturedUrl } = getState(); + if (capturedTabId === null) { + setState({ error: 'No active tab captured' }); + break; + } const resp = await sendMessage({ type: 'fill_credentials', - username: entry.username ?? '', - password: entry.password, + id: item.id, + capturedTabId, + capturedUrl, }); if (!resp.ok) setState({ error: resp.error }); + else window.close(); break; } @@ -191,7 +255,7 @@ export function renderItemDetail(app: HTMLElement): void { case 'd': e.preventDefault(); - showDeleteConfirm(id, entry.name, handler); + showDeleteConfirm(item.id, item.title, handler); break; } }; @@ -199,40 +263,88 @@ export function renderItemDetail(app: HTMLElement): void { document.addEventListener('keydown', handler); } -async function refreshTotp(id: string): Promise { +async function refreshTotp(id: ItemId): Promise { const resp = await sendMessage({ type: 'get_totp', id }); if (resp.ok) { - const data = resp.data as { code: string; remaining_seconds: number }; + const data = resp.data as { code: string; expires_at: number }; const codeEl = document.getElementById('totp-code'); const barEl = document.getElementById('totp-bar-fill'); if (codeEl) codeEl.textContent = data.code; - if (barEl) barEl.style.width = `${(data.remaining_seconds / 30) * 100}%`; + if (barEl) { + const now = Math.floor(Date.now() / 1000); + const remaining = Math.max(0, data.expires_at - now); + // Period is 30 by default; compute ratio against 30. + barEl.style.width = `${(remaining / 30) * 100}%`; + } + } + // Suppress unused warning; TotpConfig referenced for typing only below. + void ({} as TotpConfig); +} + +// --- Coming-soon for non-login types ----------------------------------- + +function renderComingSoon(app: HTMLElement, item: Item): void { + app.innerHTML = ` +
+ ${escapeHtml(item.title)} + +
+
+
${typeEmoji(item.type)}
+
${escapeHtml(item.type.replace('_', ' '))}
+

read/write for this type is coming in a later slice.

+

use the CLI for now.

+
+ `; + + document.getElementById('back-btn')?.addEventListener('click', goBack); + + const handler = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + document.removeEventListener('keydown', handler); + goBack(); + } + }; + document.addEventListener('keydown', handler); +} + +function typeEmoji(t: Item['type']): string { + switch (t) { + case 'login': return '🔑'; + case 'secure_note': return '📝'; + case 'identity': return '🪪'; + case 'card': return '💳'; + case 'key': return '🗝'; + case 'document': return '📄'; + case 'totp': return '⏱'; } } +// --- Shared helpers ---------------------------------------------------- + function goBack(): void { stopTotpTimer(); - // Reload the entry list. - sendMessage({ type: 'list_entries' }).then(resp => { + // Reload the item list. + void sendMessage({ type: 'list_items' }).then(resp => { if (resp.ok) { - const data = resp.data as { entries: Array<[string, ManifestEntry]> }; + const data = resp.data as { items: Array<[ItemId, ManifestEntry]> }; navigate('list', { - entries: data.entries, + entries: data.items, selectedId: null, - selectedEntry: null, + selectedItem: null, }); } }); } -function showDeleteConfirm(id: string, name: string, parentHandler: (e: KeyboardEvent) => void): void { +function showDeleteConfirm(id: ItemId, title: string, parentHandler: (e: KeyboardEvent) => void): void { const overlay = document.createElement('div'); overlay.className = 'confirm-overlay'; overlay.innerHTML = `
-

Delete ${escapeHtml(name)}?

+

Trash ${escapeHtml(title)}?

- +
`; document.body.appendChild(overlay); @@ -244,7 +356,7 @@ function showDeleteConfirm(id: string, name: string, parentHandler: (e: Keyboard document.getElementById('confirm-delete')?.addEventListener('click', async () => { overlay.remove(); setState({ loading: true }); - const resp = await sendMessage({ type: 'delete_entry', id }); + const resp = await sendMessage({ type: 'delete_item', id }); if (resp.ok) { document.removeEventListener('keydown', parentHandler); stopTotpTimer(); From 76bb61aa10f1ba8c613ce97e3c2424aadc78f72b Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Mon, 20 Apr 2026 21:12:14 -0400 Subject: [PATCH 26/33] feat(ext/popup): Login add/edit form on typed-item API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrites item-form.ts for the typed-item Item shape. Login is the only editable type in Slice 6; other types fall through to coming-soon. Form fields: title (required) + url + username + password (with gen button backed by DEFAULT_PASSWORD_REQUEST) + totp (base32) + group + notes. TOTP base32 is decoded via shared/base32 and wrapped as a number[] into FieldValue-shape TotpConfig { secret, algorithm: sha1, digits: 6, period_seconds: 30, kind: 'totp' }. Decode failure sets state.error and aborts. Save constructs a full Item envelope (id, title, type, tags, favorite, group, notes, created, modified, trashed_at, core, sections, attachments, field_history). On edit we preserve the existing item's metadata but EXPLICITLY set trashed_at: undefined — carry-forward from Slice 5 review M3, so an edit cannot accidentally preserve stale trash state. @ts-nocheck removed. Co-Authored-By: Claude Opus 4.7 (1M context) --- extension/src/popup/components/item-form.ts | 270 ++++++++++++++------ 1 file changed, 190 insertions(+), 80 deletions(-) diff --git a/extension/src/popup/components/item-form.ts b/extension/src/popup/components/item-form.ts index 16fd9a9..b58dde1 100644 --- a/extension/src/popup/components/item-form.ts +++ b/extension/src/popup/components/item-form.ts @@ -1,47 +1,117 @@ -// @ts-nocheck — transitional: downstream files updated in Slice 6 (item-* rewrites) / Slice 4 (vitest setup) / Slice 5 (content + setup rewires) -/// Entry form — add or edit an entry. +/// Typed-item add/edit form. Slice 6 ships full Login parity; other +/// types show a coming-soon placeholder (use the CLI for now). +/// +/// Carry-forward from Slice 5 review M3: on edit, trashed_at is +/// explicitly reset to undefined so stale trash state cannot survive an +/// edit. (The capture path already uses spread + fetched item; this +/// popup flow uses state.selectedItem.) import { getState, setState, sendMessage, navigate, escapeHtml } from '../popup'; -import type { Entry, ManifestEntry } from '../../shared/types'; +import type { + Item, ItemId, ItemType, ManifestEntry, LoginCore, TotpConfig, +} from '../../shared/types'; +import { DEFAULT_PASSWORD_REQUEST } from '../../shared/types'; +import { base32Decode, base32Encode } from '../../shared/base32'; + +// Which types support add/edit in Slice 6. +function isEditableType(t: ItemType): boolean { + return t === 'login'; +} export function renderItemForm(app: HTMLElement, mode: 'add' | 'edit'): void { const state = getState(); - const existing = mode === 'edit' ? state.selectedEntry : null; + const existing = mode === 'edit' ? state.selectedItem : null; + + // Determine the type we're editing/creating. Add defaults to login. + const type: ItemType = existing?.type ?? 'login'; + + if (!isEditableType(type)) { + renderComingSoon(app, type); + return; + } + + renderLoginForm(app, mode, existing); +} + +// --- Coming-soon ------------------------------------------------------- + +function renderComingSoon(app: HTMLElement, type: ItemType): void { + app.innerHTML = ` +
+
${escapeHtml(type.replace('_', ' '))}
+

editing ${escapeHtml(type)} items is coming in a later slice.

+

use the CLI for now.

+
+ +
+
+ `; + document.getElementById('back-btn')?.addEventListener('click', () => navigate('list')); + const handler = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + document.removeEventListener('keydown', handler); + navigate('list'); + } + }; + document.addEventListener('keydown', handler); +} + +// --- Login add/edit ---------------------------------------------------- + +/// Encode TotpConfig secret bytes back to a base32 display string. +function totpSecretToBase32(totp: TotpConfig | undefined): string { + if (!totp) return ''; + return base32Encode(new Uint8Array(totp.secret)); +} + +function renderLoginForm(app: HTMLElement, mode: 'add' | 'edit', existing: Item | null): void { + const state = getState(); + const existingCore = (existing?.core.type === 'login') + ? (existing.core as LoginCore & { type: 'login' }) + : null; + + const title = existing?.title ?? ''; + const url = existingCore?.url ?? ''; + const username = existingCore?.username ?? ''; + const password = existingCore?.password ?? ''; + const totpStr = totpSecretToBase32(existingCore?.totp); + const group = existing?.group ?? ''; + const notes = existing?.notes ?? ''; app.innerHTML = `
-
${mode === 'add' ? 'new entry' : 'edit entry'}
+
${mode === 'add' ? 'new login' : 'edit login'}
${state.error ? `
${escapeHtml(state.error)}
` : ''}
- - + +
- +
- +
- +
- - + +
- +
- +
@@ -52,92 +122,132 @@ export function renderItemForm(app: HTMLElement, mode: 'add' | 'edit'): void { // --- Generate password --- document.getElementById('gen-btn')?.addEventListener('click', async () => { - const resp = await sendMessage({ type: 'generate_password', length: 24 }); + const resp = await sendMessage({ type: 'generate_password', request: DEFAULT_PASSWORD_REQUEST }); if (resp.ok) { const data = resp.data as { password: string }; const pwInput = document.getElementById('f-password') as HTMLInputElement; pwInput.value = data.password; pwInput.type = 'text'; // Show generated password. + } else { + setState({ error: resp.error }); } }); // --- Cancel --- - document.getElementById('cancel-btn')?.addEventListener('click', () => { - if (mode === 'edit' && state.selectedId && state.selectedEntry) { - navigate('detail'); - } else { - navigate('list'); - } - }); + document.getElementById('cancel-btn')?.addEventListener('click', () => goBack(mode)); // --- Save --- document.getElementById('save-btn')?.addEventListener('click', async () => { - const name = (document.getElementById('f-name') as HTMLInputElement).value.trim(); - const url = (document.getElementById('f-url') as HTMLInputElement).value.trim() || undefined; - const username = (document.getElementById('f-username') as HTMLInputElement).value.trim() || undefined; - const password = (document.getElementById('f-password') as HTMLInputElement).value; - const totp_secret = (document.getElementById('f-totp') as HTMLInputElement).value.trim() || undefined; - const group = (document.getElementById('f-group') as HTMLInputElement).value.trim() || undefined; - const notes = (document.getElementById('f-notes') as HTMLTextAreaElement).value.trim() || undefined; - - if (!name) { - setState({ error: 'Name is required' }); - return; - } - if (!password) { - setState({ error: 'Password is required' }); - return; - } - - const now = new Date().toISOString(); - const entry: Entry = { - name, - url, - username, - password, - notes, - totp_secret, - group, - created_at: existing?.created_at ?? now, - updated_at: now, - }; - - setState({ loading: true, error: null }); - - let resp; - if (mode === 'add') { - resp = await sendMessage({ type: 'add_entry', entry }); - } else { - resp = await sendMessage({ type: 'update_entry', id: state.selectedId!, entry }); - } - - if (resp.ok) { - // Refresh entries and go to list. - const listResp = await sendMessage({ type: 'list_entries' }); - if (listResp.ok) { - const data = listResp.data as { entries: Array<[string, ManifestEntry]> }; - navigate('list', { entries: data.entries, selectedId: null, selectedEntry: null }); - } else { - navigate('list'); - } - } else { - setState({ loading: false, error: resp.error }); - } + await saveLogin(mode, existing); }); // --- Escape to cancel --- const escHandler = (e: KeyboardEvent) => { if (e.key === 'Escape') { document.removeEventListener('keydown', escHandler); - if (mode === 'edit' && state.selectedId && state.selectedEntry) { - navigate('detail'); - } else { - navigate('list'); - } + goBack(mode); } }; document.addEventListener('keydown', escHandler); - // Focus the name field. - (document.getElementById('f-name') as HTMLInputElement)?.focus(); + // Focus the title field. + (document.getElementById('f-title') as HTMLInputElement | null)?.focus(); +} + +function goBack(mode: 'add' | 'edit'): void { + const s = getState(); + if (mode === 'edit' && s.selectedId && s.selectedItem) { + navigate('detail'); + } else { + navigate('list'); + } +} + +async function saveLogin(mode: 'add' | 'edit', existing: Item | null): Promise { + const state = getState(); + + const title = (document.getElementById('f-title') as HTMLInputElement).value.trim(); + const url = (document.getElementById('f-url') as HTMLInputElement).value.trim(); + const username = (document.getElementById('f-username') as HTMLInputElement).value.trim(); + const password = (document.getElementById('f-password') as HTMLInputElement).value; + const totpStr = (document.getElementById('f-totp') as HTMLInputElement).value.trim(); + const group = (document.getElementById('f-group') as HTMLInputElement).value.trim(); + const notes = (document.getElementById('f-notes') as HTMLTextAreaElement).value; + + if (!title) { + setState({ error: 'Title is required' }); + return; + } + + let totp: TotpConfig | undefined; + if (totpStr) { + try { + const bytes = base32Decode(totpStr); + totp = { + secret: Array.from(bytes), + algorithm: 'sha1', + digits: 6, + period_seconds: 30, + kind: 'totp', + }; + } catch (err) { + setState({ error: `Invalid TOTP secret: ${err instanceof Error ? err.message : String(err)}` }); + return; + } + } + + const now = Math.floor(Date.now() / 1000); + const core: LoginCore & { type: 'login' } = { + type: 'login', + username: username || undefined, + password: password || undefined, + url: url || undefined, + totp, + }; + + // Build the Item. On edit we preserve id/created/tags/favorite/sections/ + // attachments/field_history from the existing item, but we EXPLICITLY + // set trashed_at: undefined — never preserve stale trash state through + // an edit (carry-forward from Slice 5 review M3). + const item: Item = { + id: existing?.id ?? '', // SW fills in for add_item. + title, + type: 'login', + tags: existing?.tags ?? [], + favorite: existing?.favorite ?? false, + group: group || undefined, + notes: notes || undefined, + created: existing?.created ?? now, + modified: now, + trashed_at: undefined, + core, + sections: existing?.sections ?? [], + attachments: existing?.attachments ?? [], + field_history: existing?.field_history ?? {}, + }; + + setState({ loading: true, error: null }); + + let resp; + if (mode === 'add') { + resp = await sendMessage({ type: 'add_item', item }); + } else { + if (!state.selectedId) { + setState({ loading: false, error: 'Missing item id' }); + return; + } + resp = await sendMessage({ type: 'update_item', id: state.selectedId, item }); + } + + if (resp.ok) { + const listResp = await sendMessage({ type: 'list_items' }); + if (listResp.ok) { + const data = listResp.data as { items: Array<[ItemId, ManifestEntry]> }; + navigate('list', { entries: data.items, selectedId: null, selectedItem: null }); + } else { + navigate('list'); + } + } else { + setState({ loading: false, error: resp.error }); + } } From f3b915a6353a8aeed9962a83b323b88dc51f943e Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Mon, 20 Apr 2026 21:38:50 -0400 Subject: [PATCH 27/33] feat(ext/setup): zxcvbn strength meter + score>=3 gate (audit H3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the ad-hoc char-class passphraseStrength() with a 5-segment bar backed by a SW round-trip to rate_passphrase (zxcvbn). Input handler debounces 150ms so we don't hammer the worker per keystroke. The create-vault button is disabled unless the last score is ≥ 3 (zxcvbn's "safely unguessable" threshold), and the handler re-rates synchronously on click as defence-in-depth. Label flips between "Too weak" (red) and "Strong enough" (green). Also: - rewrites the vault-creation path to use the typed-item unlock + manifest_encrypt APIs (derive_master_key/encrypt_manifest are gone); the new initial manifest is { schema_version: 2, items: {} }. - wasm.d.ts is now a pure `declare module 'relicario-wasm'` block; tsconfig's stale `paths` alias is removed. - @ts-nocheck removed from setup.ts. Co-Authored-By: Claude Opus 4.7 (1M context) --- extension/setup.html | 37 +++++--- extension/src/setup/setup.ts | 179 +++++++++++++++++++++-------------- extension/src/wasm.d.ts | 116 ++++++++++++----------- extension/tsconfig.json | 3 - 4 files changed, 194 insertions(+), 141 deletions(-) diff --git a/extension/setup.html b/extension/setup.html index 5a2dd7a..fd77af9 100644 --- a/extension/setup.html +++ b/extension/setup.html @@ -49,23 +49,38 @@ } .strength-bar { + display: flex; + gap: 3px; + margin-top: 6px; + } + + .strength-bar .seg { + flex: 1; height: 4px; background: #21262d; border-radius: 2px; - margin-top: 6px; - overflow: hidden; + transition: background 0.2s; } - .strength-bar-fill { - height: 100%; - border-radius: 2px; - transition: width 0.2s, background 0.2s; - } + /* zxcvbn score-driven colors. Higher-scored bars light up earlier bars too. */ + .strength-bar.s0 .seg.i0 { background: #f85149; } + .strength-bar.s1 .seg.i0, + .strength-bar.s1 .seg.i1 { background: #db6d28; } + .strength-bar.s2 .seg.i0, + .strength-bar.s2 .seg.i1, + .strength-bar.s2 .seg.i2 { background: #d29922; } + .strength-bar.s3 .seg.i0, + .strength-bar.s3 .seg.i1, + .strength-bar.s3 .seg.i2, + .strength-bar.s3 .seg.i3 { background: #3fb950; } + .strength-bar.s4 .seg { background: #3fb950; } - .strength-bar-fill.weak { background: #f85149; width: 25%; } - .strength-bar-fill.fair { background: #d29922; width: 50%; } - .strength-bar-fill.good { background: #3fb950; width: 75%; } - .strength-bar-fill.strong { background: #58a6ff; width: 100%; } + .strength-label { + font-size: 11px; + margin-top: 3px; + } + .strength-label.weak { color: #f85149; } + .strength-label.strong { color: #3fb950; } .success-box { background: #0d1b0e; diff --git a/extension/src/setup/setup.ts b/extension/src/setup/setup.ts index 8922a33..75a9f9b 100644 --- a/extension/src/setup/setup.ts +++ b/extension/src/setup/setup.ts @@ -1,4 +1,3 @@ -// @ts-nocheck — transitional: downstream files updated in Slice 6 (item-* rewrites) / Slice 4 (vitest setup) / Slice 5 (content + setup rewires) /// Vault initialization wizard — 4-step flow for creating new relicario vaults. /// /// Step 1: Choose host type (Gitea / GitHub) @@ -7,7 +6,6 @@ /// Step 4: Finish (download reference image, push config to extension or copy JSON) import { createGitHost, uint8ArrayToBase64 } from '../service-worker/git-host'; -import type { GitHost } from '../service-worker/git-host'; import type { VaultConfig } from '../shared/types'; // --- WASM module (loaded dynamically) --- @@ -38,6 +36,8 @@ interface WizardState { carrierImageBytes: Uint8Array | null; passphrase: string; passphraseConfirm: string; + // zxcvbn meter state — -1 means "not yet scored" (empty passphrase). + passphraseScore: number; referenceImageBytes: Uint8Array | null; creating: boolean; error: string | null; @@ -55,6 +55,7 @@ const state: WizardState = { carrierImageBytes: null, passphrase: '', passphraseConfirm: '', + passphraseScore: -1, referenceImageBytes: null, creating: false, error: null, @@ -72,17 +73,57 @@ function escapeHtml(s: string): string { .replace(/"/g, '"'); } -function passphraseStrength(pw: string): 'weak' | 'fair' | 'good' | 'strong' { - let score = 0; - if (pw.length >= 8) score++; - if (pw.length >= 14) score++; - if (/[a-z]/.test(pw) && /[A-Z]/.test(pw)) score++; - if (/[0-9]/.test(pw)) score++; - if (/[^a-zA-Z0-9]/.test(pw)) score++; - if (score <= 1) return 'weak'; - if (score <= 2) return 'fair'; - if (score <= 3) return 'good'; - return 'strong'; +/// Call the SW to score a passphrase with zxcvbn. Returns a score in [0, 4] +/// per the zxcvbn convention, or -1 if the message round-trip failed. +function ratePassphrase(passphrase: string): Promise { + return new Promise((resolve) => { + try { + chrome.runtime.sendMessage( + { type: 'rate_passphrase', passphrase }, + (response: { ok: boolean; data?: { score: number; guesses_log10: number }; error?: string }) => { + if (chrome.runtime.lastError || !response?.ok) { resolve(-1); return; } + resolve(response.data?.score ?? -1); + }, + ); + } catch { + resolve(-1); + } + }); +} + +/// 150ms debounce around the rate_passphrase call so we don't hammer the SW +/// on every keystroke. The last invocation wins. +let rateDebounceTimer: ReturnType | null = null; +function scheduleRate(passphrase: string, onScore: (score: number) => void): void { + if (rateDebounceTimer !== null) clearTimeout(rateDebounceTimer); + rateDebounceTimer = setTimeout(async () => { + rateDebounceTimer = null; + if (!passphrase) { onScore(-1); return; } + onScore(await ratePassphrase(passphrase)); + }, 150); +} + +/// Update just the meter DOM without a full re-render (so the input keeps +/// focus and the user's cursor position is preserved). +function updateStrengthUi(): void { + const bar = document.getElementById('strength-bar'); + const label = document.getElementById('strength-label'); + const create = document.getElementById('create-btn') as HTMLButtonElement | null; + const score = state.passphraseScore; + if (bar) bar.className = `strength-bar${score >= 0 ? ` s${score}` : ''}`; + if (label) { + if (score < 0) { + label.className = 'strength-label'; + label.innerHTML = ' '; + } else if (score >= 3) { + label.className = 'strength-label strong'; + label.textContent = 'Strong enough'; + } else { + label.className = 'strength-label weak'; + label.textContent = 'Too weak'; + } + } + if (create) create.disabled = state.creating || score < 3; } // --- Render --- @@ -267,7 +308,14 @@ function attachStep2(): void { // --- Step 3: Create Vault --- function renderStep3(): string { - const strength = state.passphrase ? passphraseStrength(state.passphrase) : null; + const score = state.passphraseScore; + const hasScore = score >= 0; + const meterClass = hasScore ? `s${score}` : ''; + const labelClass = hasScore ? (score >= 3 ? 'strong' : 'weak') : ''; + const labelText = !hasScore + ? ' ' + : (score >= 3 ? 'Strong enough' : 'Too weak'); + const gateDisabled = state.creating || score < 3; return `
@@ -284,21 +332,23 @@ function renderStep3(): string {
- - ${strength ? ` -
-
-
-

strength: ${strength}

- ` : ''} + + +

${labelText}

- +
-
@@ -324,22 +374,14 @@ function attachStep3(): void { reader.readAsArrayBuffer(file); }); - // Track passphrase changes without full re-render + // Track passphrase changes inline (no full re-render) so the input keeps focus. + // zxcvbn score is computed via the SW on a 150ms debounce — see scheduleRate. document.getElementById('passphrase')?.addEventListener('input', (e) => { state.passphrase = (e.target as HTMLInputElement).value; - // Update strength bar inline - const strength = passphraseStrength(state.passphrase); - const bar = document.querySelector('.strength-bar-fill') as HTMLElement | null; - const label = document.querySelector('.strength-bar + .muted') as HTMLElement | null; - if (bar) { - bar.className = `strength-bar-fill ${strength}`; - } - if (label) { - label.textContent = `strength: ${strength}`; - } - if (!bar && state.passphrase) { - render(); - } + scheduleRate(state.passphrase, (score) => { + state.passphraseScore = score; + updateStrengthUi(); + }); }); document.getElementById('passphrase-confirm')?.addEventListener('input', (e) => { @@ -367,6 +409,15 @@ function attachStep3(): void { render(); return; } + // Re-rate synchronously in case the button was clicked before the + // debounced rater fired. Defence in depth — the button is already + // disabled in the UI when score < 3 (audit H3). + state.passphraseScore = await ratePassphrase(state.passphrase); + if (state.passphraseScore < 3) { + state.error = 'Passphrase is too weak (zxcvbn score must be ≥ 3).'; + render(); + return; + } if (state.passphrase !== state.passphraseConfirm) { state.error = 'Passphrases do not match'; render(); @@ -380,58 +431,41 @@ function attachStep3(): void { try { const w = await loadWasm(); - // 1. Generate 32-byte image secret + // 1. Generate 32-byte image secret. const imageSecret = new Uint8Array(32); crypto.getRandomValues(imageSecret); - // 2. Embed secret into carrier JPEG + // 2. Embed secret into carrier JPEG. state.referenceImageBytes = new Uint8Array( - w.embed_image_secret(state.carrierImageBytes, imageSecret) + w.embed_image_secret(state.carrierImageBytes, imageSecret), ); - // 3. Generate 32-byte salt + // 3. Generate 32-byte salt + KDF params. const salt = new Uint8Array(32); crypto.getRandomValues(salt); - - // 4. Create KDF params const paramsJson = '{"argon2_m":65536,"argon2_t":3,"argon2_p":4}'; - // 5. Derive master key - const masterKey = w.derive_master_key( - state.passphrase, - imageSecret, - salt, - paramsJson, - ); + // 4. Derive a session handle via the typed-item unlock API. + // (Single-shot master_key derivation is no longer exposed; the + // handle is the only in-JS reference to the master key.) + const handle = w.unlock(state.passphrase, imageSecret, salt, paramsJson); - // 6. Encrypt empty manifest - const manifestJson = '{"entries":{},"version":1}'; - const encryptedManifest = w.encrypt_manifest(manifestJson, masterKey); + // 5. Encrypt an empty schema-v2 manifest. + const manifestJson = '{"schema_version":2,"items":{}}'; + const encryptedManifest = w.manifest_encrypt(handle, manifestJson); - // 7. Push vault files via git API + // 6. Push vault files via git API. const hostUrl = state.hostType === 'github' ? 'https://api.github.com' : state.hostUrl; const host = createGitHost(state.hostType, hostUrl, state.repoPath, state.apiToken); - await host.writeFile( - '.relicario/salt', - salt, - 'init: vault salt', - ); + await host.writeFile('.relicario/salt', salt, 'init: vault salt'); const paramsBytes = new TextEncoder().encode(paramsJson); - await host.writeFile( - '.relicario/params.json', - paramsBytes, - 'init: KDF parameters', - ); + await host.writeFile('.relicario/params.json', paramsBytes, 'init: KDF parameters'); const devicesJson = '{"devices":[]}'; const devicesBytes = new TextEncoder().encode(devicesJson); - await host.writeFile( - '.relicario/devices.json', - devicesBytes, - 'init: device registry', - ); + await host.writeFile('.relicario/devices.json', devicesBytes, 'init: device registry'); await host.writeFile( 'manifest.enc', @@ -439,12 +473,15 @@ function attachStep3(): void { 'init: encrypted manifest', ); - // 8. Advance to step 4 + // 7. Release the handle — the SW's own unlock will re-derive. + w.lock(handle); + + // 8. Advance to step 4. state.creating = false; state.step = 4; state.error = null; - // Detect extension + // Detect extension. detectExtension(); render(); diff --git a/extension/src/wasm.d.ts b/extension/src/wasm.d.ts index a6c3818..ccd1681 100644 --- a/extension/src/wasm.d.ts +++ b/extension/src/wasm.d.ts @@ -1,61 +1,65 @@ // Thin TypeScript declarations for the relicario-wasm bindings. // These are hand-written to mirror the #[wasm_bindgen] signatures in // crates/relicario-wasm/src/lib.rs; keep them in sync manually. +// +// Declared under the bare specifier 'relicario-wasm' so `typeof +// import('relicario-wasm')` resolves in setup.ts. Webpack doesn't +// actually resolve the module — setup.ts loads the auto-generated +// wasm/relicario_wasm.js via a webpackIgnore dynamic import at runtime. -export class SessionHandle { - readonly value: number; - free(): void; +declare module 'relicario-wasm' { + export class SessionHandle { + readonly value: number; + free(): void; + } + + export class EncryptedAttachment { + readonly aid: string; + readonly bytes: Uint8Array; + free(): void; + } + + export class TotpCode { + readonly code: string; + readonly expires_at: bigint; + free(): void; + } + + export function unlock( + passphrase: string, + image_bytes: Uint8Array, + salt: Uint8Array, + params_json: string, + ): SessionHandle; + + export function lock(handle: SessionHandle): boolean; + + export function manifest_decrypt(handle: SessionHandle, encrypted: Uint8Array): unknown; + export function manifest_encrypt(handle: SessionHandle, manifest_json: string): Uint8Array; + export function item_decrypt(handle: SessionHandle, encrypted: Uint8Array): unknown; + export function item_encrypt(handle: SessionHandle, item_json: string): Uint8Array; + export function settings_decrypt(handle: SessionHandle, encrypted: Uint8Array): unknown; + export function settings_encrypt(handle: SessionHandle, settings_json: string): Uint8Array; + + export function attachment_encrypt( + handle: SessionHandle, + plaintext: Uint8Array, + max_bytes: bigint, + ): EncryptedAttachment; + export function attachment_decrypt(handle: SessionHandle, encrypted: Uint8Array): Uint8Array; + + export function new_item_id(): string; + export function new_field_id(): string; + + export function generate_password(request_json: string): string; + export function generate_passphrase(request_json: string): string; + export function rate_passphrase(p: string): { score: number; guesses_log10: number }; + + export function extract_image_secret(image_bytes: Uint8Array): Uint8Array; + export function embed_image_secret(carrier: Uint8Array, secret: Uint8Array): Uint8Array; + + export function totp_compute(config_json: string, now_unix_seconds: bigint): TotpCode; + + export default function init(module_or_path?: unknown): Promise; + export function initSync(args: { module: WebAssembly.Module }): void; } - -export class EncryptedAttachment { - readonly aid: string; - readonly bytes: Uint8Array; - free(): void; -} - -export class TotpCode { - readonly code: string; - readonly expires_at: bigint; - free(): void; -} - -export function unlock( - passphrase: string, - image_bytes: Uint8Array, - salt: Uint8Array, - params_json: string, -): SessionHandle; - -export function lock(handle: SessionHandle): boolean; - -export function manifest_decrypt(handle: SessionHandle, encrypted: Uint8Array): unknown; -export function manifest_encrypt(handle: SessionHandle, manifest_json: string): Uint8Array; -export function item_decrypt(handle: SessionHandle, encrypted: Uint8Array): unknown; -export function item_encrypt(handle: SessionHandle, item_json: string): Uint8Array; -export function settings_decrypt(handle: SessionHandle, encrypted: Uint8Array): unknown; -export function settings_encrypt(handle: SessionHandle, settings_json: string): Uint8Array; - -export function attachment_encrypt( - handle: SessionHandle, - plaintext: Uint8Array, - max_bytes: bigint, -): EncryptedAttachment; -export function attachment_decrypt(handle: SessionHandle, encrypted: Uint8Array): Uint8Array; - -export function new_item_id(): string; -export function new_field_id(): string; - -export function generate_password(request_json: string): string; -export function generate_passphrase(request_json: string): string; -export function rate_passphrase(p: string): { score: number; guesses_log10: number }; - -export function extract_image_secret(image_bytes: Uint8Array): Uint8Array; -export function embed_image_secret(carrier: Uint8Array, secret: Uint8Array): Uint8Array; - -export function totp_compute(config_json: string, now_unix_seconds: bigint): TotpCode; - -// Initializer (wasm-bindgen's default init function). -export default function init(module_or_path?: unknown): Promise; - -// wasm-bindgen's sync init — Chrome MV3 service workers can't use dynamic import(). -export function initSync(args: { module: WebAssembly.Module }): void; diff --git a/extension/tsconfig.json b/extension/tsconfig.json index 12146cb..89512ab 100644 --- a/extension/tsconfig.json +++ b/extension/tsconfig.json @@ -9,9 +9,6 @@ "rootDir": "./src", "sourceMap": true, "lib": ["ES2022", "DOM", "DOM.Iterable"], - "paths": { - "relicario-wasm": ["./wasm/relicario_wasm.js"] - }, "baseUrl": "." }, "include": ["src/**/*"], From 3238ef4dd4c45caf278c049dc2dd4f241508b324 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Mon, 20 Apr 2026 21:44:12 -0400 Subject: [PATCH 28/33] refactor(ext/popup): remove last @ts-nocheck, align to typed-item types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clears the final four transitional @ts-nocheck shields: - popup.ts (already mostly updated in Slice 6 prior tasks; nocheck just removed and the init fallback switched to list_items / ItemId typing) - unlock.ts (list_entries → list_items; ManifestEntry typing) - settings.ts (RelicarioSettings → DeviceSettings; pure type rename, UX unchanged) Also drops the stale `idfoto-extension` name in bun.lock (workspace was renamed; lock file still carried the old name). Verification: git grep -n '@ts-nocheck' extension/src/ → 0 hits bun run build + build:firefox → both green bun run test → 52/52 passing cargo test --workspace → green Co-Authored-By: Claude Opus 4.7 (1M context) --- extension/bun.lock | 2 +- extension/src/popup/components/settings.ts | 7 +++---- extension/src/popup/components/unlock.ts | 9 ++++----- extension/src/popup/popup.ts | 1 - 4 files changed, 8 insertions(+), 11 deletions(-) diff --git a/extension/bun.lock b/extension/bun.lock index 1493274..1e003a4 100644 --- a/extension/bun.lock +++ b/extension/bun.lock @@ -3,7 +3,7 @@ "configVersion": 1, "workspaces": { "": { - "name": "idfoto-extension", + "name": "relicario-extension", "devDependencies": { "@types/chrome": "^0.1.40", "copy-webpack-plugin": "^12.0", diff --git a/extension/src/popup/components/settings.ts b/extension/src/popup/components/settings.ts index 4c21408..a30ea03 100644 --- a/extension/src/popup/components/settings.ts +++ b/extension/src/popup/components/settings.ts @@ -1,8 +1,7 @@ -// @ts-nocheck — transitional: downstream files updated in Slice 6 (item-* rewrites) / Slice 4 (vitest setup) / Slice 5 (content + setup rewires) /// Settings view — capture toggle, prompt style, and blacklist management. import { sendMessage, navigate, escapeHtml } from '../popup'; -import type { RelicarioSettings } from '../../shared/types'; +import type { DeviceSettings } from '../../shared/types'; export async function renderSettings(app: HTMLElement): Promise { app.innerHTML = '
'; @@ -13,8 +12,8 @@ export async function renderSettings(app: HTMLElement): Promise { sendMessage({ type: 'get_blacklist' }), ]); - const settings: RelicarioSettings = settingsResp.ok - ? (settingsResp.data as { settings: RelicarioSettings }).settings + const settings: DeviceSettings = settingsResp.ok + ? (settingsResp.data as { settings: DeviceSettings }).settings : { captureEnabled: false, captureStyle: 'bar' }; const blacklist: string[] = blacklistResp.ok diff --git a/extension/src/popup/components/unlock.ts b/extension/src/popup/components/unlock.ts index 64aeda3..2106ffc 100644 --- a/extension/src/popup/components/unlock.ts +++ b/extension/src/popup/components/unlock.ts @@ -1,8 +1,7 @@ -// @ts-nocheck — transitional: downstream files updated in Slice 6 (item-* rewrites) / Slice 4 (vitest setup) / Slice 5 (content + setup rewires) /// Unlock view — passphrase input with ENTER to submit. import { getState, setState, sendMessage, navigate, escapeHtml } from '../popup'; -import type { ManifestEntry } from '../../shared/types'; +import type { ItemId, ManifestEntry } from '../../shared/types'; export function renderUnlock(app: HTMLElement): void { const state = getState(); @@ -39,10 +38,10 @@ export function renderUnlock(app: HTMLElement): void { setState({ loading: true, error: null }); const resp = await sendMessage({ type: 'unlock', passphrase }); if (resp.ok) { - const listResp = await sendMessage({ type: 'list_entries' }); + const listResp = await sendMessage({ type: 'list_items' }); if (listResp.ok) { - const data = listResp.data as { entries: Array<[string, ManifestEntry]> }; - navigate('list', { entries: data.entries }); + const data = listResp.data as { items: Array<[ItemId, ManifestEntry]> }; + navigate('list', { entries: data.items }); } else { setState({ loading: false, error: listResp.error }); } diff --git a/extension/src/popup/popup.ts b/extension/src/popup/popup.ts index 04ef6df..66f3536 100644 --- a/extension/src/popup/popup.ts +++ b/extension/src/popup/popup.ts @@ -1,4 +1,3 @@ -// @ts-nocheck — transitional: downstream files updated in Slice 6 (item-* rewrites) / Slice 4 (vitest setup) / Slice 5 (content + setup rewires) /// Popup entry point — state machine with view routing. /// /// Views: setup | locked | list | detail | add | edit From 4341124d385419f67f30537538d6c274fc75ee6f Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Wed, 22 Apr 2026 19:32:00 -0400 Subject: [PATCH 29/33] fix(ext): allow rate_passphrase + is_unlocked from setup tab; add diagnostic logging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug: setup tab's zxcvbn meter silently stayed at score=-1 because the router's isSetup exception only allowed save_setup, so rate_passphrase got unauthorized_sender. Result: the "create vault" button stayed disabled forever even with a strong passphrase. Fix: add a narrow SETUP_ALLOWED set containing save_setup, rate_passphrase, and is_unlocked (step-4 extension detection). Reject everything else from the setup tab. Also clean up setup.ts's unlock call — it was passing the raw 32-byte imageSecret where JPEG bytes with embedded secret are required; the Rust-side unlock calls imgsecret:: extract internally. Diagnostic logging across the message path so the next silent failure speaks up: - [relicario setup] staged logs through vault-init; console.error with the failure stage name in the UI banner. - [relicario setup] rate_passphrase lastError / rejected / threw branches each log their own warning. - [relicario router] console.warn on unauthorized_sender (with sender classification) and unknown_message_type. - [relicario sw] first-message wasm init announced; per-message non-ok result logged; thrown errors console.error'd. Tests: +3 setup-allowlist tests (rate_passphrase accepted, is_unlocked accepted, fill_credentials + unlock rejected). 55/55 green. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../2026-04-20-1c-alpha-manual-matrix.md | 244 ++++++++++++++++++ extension/src/service-worker/index.ts | 20 +- .../router/__tests__/router.test.ts | 38 ++- extension/src/service-worker/router/index.ts | 30 ++- extension/src/setup/setup.ts | 64 +++-- 5 files changed, 369 insertions(+), 27 deletions(-) create mode 100644 docs/superpowers/test-runs/2026-04-20-1c-alpha-manual-matrix.md diff --git a/docs/superpowers/test-runs/2026-04-20-1c-alpha-manual-matrix.md b/docs/superpowers/test-runs/2026-04-20-1c-alpha-manual-matrix.md new file mode 100644 index 0000000..53abc91 --- /dev/null +++ b/docs/superpowers/test-runs/2026-04-20-1c-alpha-manual-matrix.md @@ -0,0 +1,244 @@ +# Plan 1C-α — Manual Test Matrix + +Walkthrough for validating the extension on both Chrome and Firefox after the six-slice implementation. + +Branch: `feature/typed-items-1c-alpha` @ `3238ef4` (tag candidate: `plan-1c-alpha-complete`) +Worktree: `/home/alee/Sources/relicario/.worktrees/typed-items-1c-alpha` + +--- + +## Pre-flight + +- [ ] **P1.** Bundles built: + ```bash + cd /home/alee/Sources/relicario/.worktrees/typed-items-1c-alpha/extension + bun run build:all + ``` + Expected: "compiled with 2 warnings" (WASM size only) for each bundle. `dist/` and `dist-firefox/` populated. + +- [ ] **P2.** Fresh-profile browsers ready (or existing profile's `chrome.storage.local` for this extension cleared). Stale `vaultConfig`/`imageBase64` from the pre-rename `idfoto` era must not persist. + +- [ ] **P3.** Test git repo for the vault is reachable (SSH key / HTTPS PAT working). Use a throwaway repo to avoid polluting your real vault history. + +- [ ] **P4.** Reference image ready (any JPEG; DCT-steg secret is embedded at init time). + +--- + +## Loading + +### Chrome +- [ ] **L1.** `chrome://extensions` → Developer mode ON → "Load unpacked" → select `extension/dist/`. +- [ ] **L2.** Toolbar icon visible (pin if needed). +- [ ] **L3.** Click icon → first open triggers setup tab (not a popup-embedded wizard). + +### Firefox +- [ ] **L4.** `about:debugging#/runtime/this-firefox` → "Load Temporary Add-on…" → select `extension/dist-firefox/manifest.json`. +- [ ] **L5.** Toolbar icon visible. +- [ ] **L6.** Click icon → setup tab opens. + +--- + +## 11-step core matrix — Chrome + +**Notes column: write what you saw. Check box only when matching expected.** + +### 1. Setup tab opens from popup (audit C1) + +- [ ] **Do:** Fresh install, click toolbar icon. +- [ ] **Expected:** `setup.html` opens in a new tab; popup closes immediately; WAR is empty so this MUST work via extension-origin tab, not WAR. +- [ ] **Notes:** ___ + +### 2. zxcvbn gate in setup (audit H3) + +- [ ] **Do:** Type weak passphrase like `password`. +- [ ] **Expected:** Submit disabled, bar shows red/orange segments, feedback "Too weak…". +- [ ] **Do:** Type stronger phrase until bar fills. +- [ ] **Expected:** At score ≥ 3, submit enables, feedback "Strong enough." +- [ ] **Notes:** ___ + +### 3. Setup completes → unlock → list renders + +- [ ] **Do:** Upload reference JPEG, fill vault config (git host/URL/repo/token), submit. Then open popup, enter passphrase, unlock. +- [ ] **Expected:** Manifest decrypts client-side. Empty list view appears with toolbar (search, + New, sync, lock, ⚙). +- [ ] **Notes:** ___ + +### 4. Add Login with TOTP (typed-item wire format) + +- [ ] **Do:** "+ New" → Login form. Fill: + - title: `GitHub` + - url: `https://github.com` + - username: your handle + - password: click "gen" (uses `DEFAULT_PASSWORD_REQUEST` — 20 chars, safe symbols) + - totp: `JBSWY3DPEHPK3PXP` (well-known base32 test vector) + - Save. +- [ ] **Expected:** Row appears with 🔑 icon + title + favorite star position. +- [ ] **Expected (CLI cross-check, optional):** From main worktree: + ```bash + relicario list + relicario get "GitHub" --show + ``` + Should show the same item. TOTP secret should decode identically. +- [ ] **Notes:** ___ + +### 5. TOFU origin-ack prompt (audit C4 first half) + +- [ ] **Do:** Navigate to `https://github.com/login`. Click the blue `id` icon next to the password field. +- [ ] **Expected:** Closed Shadow DOM hint appears ("First autofill on github.com / Open relicario to confirm"). In DevTools, verify `document.querySelector('[data-rel]')` finds the host but `.shadowRoot` is `null` (closed mode). +- [ ] **Expected:** No credentials fill on this click. +- [ ] **Notes:** ___ + +### 6. Confirm origin + autofill fills correctly + +- [ ] **Do:** Open popup (on the github.com tab). Look for a pending-ack prompt OR (α behavior) just confirm manually: any `get_credentials` call after the hostname is acked in `VaultSettings.autofill_origin_acks` will return credentials. + - Simplest α path: click the item in the popup list, click "autofill" button. This uses the popup-captured tab state path (audit M5). +- [ ] **Expected:** Username + password fields fill. On React/Vue sites, the native-setter trick fires input+change events. +- [ ] **Notes:** ___ + +### 7. Multiple candidates → picker + +- [ ] **Do:** Add a second Login for github.com with a different username. Back on `github.com/login`, click the `id` icon. +- [ ] **Expected:** Picker shows both titles. Click one → fills that set. +- [ ] **Notes:** ___ + +### 8. Capture prompt → `capture_save_login` flow (Slice 5 critical-fix) + +- [ ] **Do:** Go to a site not in your vault. Fill signup form (or real trial). Submit. +- [ ] **Expected:** Capture prompt appears inside closed Shadow DOM. No stable element IDs — running `document.querySelector('#relicario-save-btn')` in the page console returns `null`. +- [ ] **Do:** Click "Save" in the prompt. +- [ ] **Expected:** ✓ Saved confirmation; prompt dismisses. Open popup → item present in list with the new hostname as title. +- [ ] **CRITICAL:** If "Save" silently fails, the `capture_save_login` content-callable handler is broken — file a bug before proceeding. +- [ ] **Notes:** ___ + +### 9. Edit Login → password rotates; field history captured + +- [ ] **Do:** Select the GitHub item → edit → change password → save. +- [ ] **Expected:** Detail view shows new password on reveal. List's "modified" time updates. +- [ ] **Expected (CLI cross-check):** + ```bash + relicario get "GitHub" --show + # confirm field_history now has entry for the old password + ``` +- [ ] **Notes:** ___ + +### 10. Delete Login → soft-delete + +- [ ] **Do:** Select an item → "trash" → confirm. +- [ ] **Expected:** Row disappears from list immediately. Popup list filters `trashed_at !== undefined`. +- [ ] **Expected (CLI cross-check):** `relicario list --trashed` shows the item. +- [ ] **Notes:** ___ + +### 11. Lock → re-unlock + +- [ ] **Do:** Click "lock" in the toolbar. Try to open the popup again. +- [ ] **Expected:** Unlock screen. Session handle was cleared in WASM (not just JS). +- [ ] **Do:** Re-unlock. +- [ ] **Expected:** Same list (including the item from step 10 still in trash, invisible). +- [ ] **Notes:** ___ + +--- + +## 11-step core matrix — Firefox + +Re-run 1–11 on Firefox. Critical Firefox-only check: the background script runs as a **persistent script** (not MV3 service worker); WASM loads via `initDefault(wasmUrl)` not `initSync`. Anything broken here that works in Chrome indicates WASM-loading drift. + +- [ ] **FF1–FF11.** Re-run the 11 steps above. Summarize anomalies: +- **Notes:** ___ + +--- + +## Security probes (bonus) + +Open DevTools on any page (not extension origin) and try to defeat the router: + +### SP1. Content-script-originated popup-only message + +- [ ] **Do:** In a page console (not popup DevTools): + ```js + chrome.runtime.sendMessage({ type: 'unlock', passphrase: 'guess' }, console.log) + ``` +- [ ] **Expected:** `{ ok: false, error: 'unauthorized_sender' }` (audit C2). +- [ ] **Notes:** ___ + +### SP2. Cross-origin `get_credentials` attempt + +- [ ] **Do:** Pick an item id from the popup (e.g., via popup DevTools: `copy(currentState.selectedId)`). Go to a **different-origin** page's console: + ```js + chrome.runtime.sendMessage({ type: 'get_credentials', id: '' }, console.log) + ``` +- [ ] **Expected:** `{ ok: false, error: 'origin_mismatch' }` (audit C4). No item data leaks. +- [ ] **Notes:** ___ + +### SP3. Closed Shadow DOM verification + +- [ ] **Do:** Trigger the capture prompt (step 8). In the page console: + ```js + const hosts = document.querySelectorAll('[data-rel]'); + for (const h of hosts) console.log(h, h.shadowRoot); // shadowRoot should be null + console.log(document.querySelector('#relicario-save-btn')); // should be null + console.log(document.querySelector('.relicario-capture')); // should be null + ``` +- [ ] **Expected:** All `shadowRoot` values are `null`; no stable selectors match (audit C3). +- [ ] **Notes:** ___ + +### SP4. Captured-tab navigation during fill (audit M5) + +- [ ] **Do:** Open popup on `https://github.com/login`. Select a github item, click "autofill", but BEFORE the fill lands, rapidly navigate the github tab to `https://example.com`. +- [ ] **Expected:** No credentials typed on example.com. SW rejects with `tab_navigated`; if somehow the message reaches the content script, `fill.ts` re-checks `expectedHost` and rejects with `origin_changed`. +- [ ] **Notes:** ___ (this one's hard to time; skip if not easily reproducible) + +### SP5. WAR probe + +- [ ] **Do:** In a page console on any site: + ```js + fetch('chrome-extension:///setup.html').catch(e => console.log('blocked:', e)) + ``` +- [ ] **Expected:** Blocked (either CORS error or net::ERR). WAR is empty, so no resource is web-accessible. `` pages cannot reach setup.html. +- [ ] **Notes:** ___ + +--- + +## Final acceptance + +- [ ] **A1.** `cargo test --workspace` green (should still be 151+ Rust tests). +- [ ] **A2.** `cd extension && bun run test` green (should be 52 passing — 11 base32 + 41 router). +- [ ] **A3.** `cd extension && bun run build` green (Chrome bundle). +- [ ] **A4.** `cd extension && bun run build:firefox` green (Firefox bundle). +- [ ] **A5.** Lint greps clean: + ```bash + git grep -n 'innerHTML\|insertAdjacentHTML' extension/src/content/ # zero hits + git grep -n 'idfoto' extension/ # zero hits + git grep -n '@ts-nocheck' extension/src/ # zero hits + ``` +- [ ] **A6.** WAR empty: + ```bash + grep -A2 web_accessible_resources extension/manifest.json # [] + grep -A2 web_accessible_resources extension/manifest.firefox.json # [] + ``` + +--- + +## Sign-off + +- [ ] **All 11 core-matrix steps pass on Chrome** +- [ ] **All 11 core-matrix steps pass on Firefox** +- [ ] **All 5 security probes pass (or SP4 skipped, others pass)** +- [ ] **All 6 final acceptance checks pass** +- [ ] **Ready to tag `plan-1c-alpha-complete` and decide on merge path** + +### Findings / issues + +Use this space to log anything weird: + +``` +(fill in as you go) +``` + +### Decision + +- [ ] Merge straight to `main` +- [ ] Open a PR first for review +- [ ] Need rework on: ___ + +--- + +*Generated 2026-04-20 — source: spec `2026-04-20-relicario-extension-1c-alpha-design.md` §5.4, plan `2026-04-20-relicario-extension-1c-alpha.md` Task 27.* diff --git a/extension/src/service-worker/index.ts b/extension/src/service-worker/index.ts index f21e7c4..64ec2e8 100644 --- a/extension/src/service-worker/index.ts +++ b/extension/src/service-worker/index.ts @@ -46,11 +46,25 @@ const state: RouterState = { chrome.runtime.onMessage.addListener( (request: Request, sender: chrome.runtime.MessageSender, sendResponse: (r: Response) => void) => { (async () => { - if (!state.wasm) state.wasm = await initWasm(); + if (!state.wasm) { + // eslint-disable-next-line no-console + console.log('[relicario sw] initializing WASM on first message'); + state.wasm = await initWasm(); + } return route(request, state, sender); })() - .then(sendResponse) - .catch((err: Error) => sendResponse({ ok: false, error: err.message })); + .then((r) => { + if (!r.ok) { + // eslint-disable-next-line no-console + console.warn(`[relicario sw] ${request.type} -> error:`, r.error); + } + sendResponse(r); + }) + .catch((err: Error) => { + // eslint-disable-next-line no-console + console.error(`[relicario sw] ${request.type} threw:`, err); + sendResponse({ ok: false, error: err.message }); + }); return true; // async response }, ); diff --git a/extension/src/service-worker/router/__tests__/router.test.ts b/extension/src/service-worker/router/__tests__/router.test.ts index 388b2da..79cdb5a 100644 --- a/extension/src/service-worker/router/__tests__/router.test.ts +++ b/extension/src/service-worker/router/__tests__/router.test.ts @@ -309,10 +309,32 @@ describe('fill_credentials captured-tab verification', () => { }); }); -// --- save_setup exception scope: setup tab is ONLY allowed save_setup --- +// --- setup-tab exception scope --- +// +// Setup is allowed a narrow subset of popup-only messages: +// - save_setup (final wire-up) +// - rate_passphrase (zxcvbn meter during passphrase entry) +// - is_unlocked (step-4 extension detection) +// Everything else popup-only must be rejected from setup. -describe('save_setup exception scope', () => { - it('rejects fill_credentials from the setup tab (setup can only save_setup)', async () => { +describe('setup tab exception scope', () => { + it('accepts rate_passphrase from the setup tab (zxcvbn meter)', async () => { + const state = makeState(); + const res = await route( + { type: 'rate_passphrase', passphrase: 'correct horse battery staple parapet' }, + state, + makeSetupSender(), + ); + expect(res).toMatchObject({ ok: true }); + }); + + it('accepts is_unlocked from the setup tab (step-4 detection)', async () => { + const state = makeState(); + const res = await route({ type: 'is_unlocked' }, state, makeSetupSender()); + expect(res).toMatchObject({ ok: true }); + }); + + it('rejects fill_credentials from the setup tab (outside the allowlist)', async () => { const state = makeState(); const res = await route( { @@ -326,6 +348,16 @@ describe('save_setup exception scope', () => { ); expect(res).toEqual({ ok: false, error: 'unauthorized_sender' }); }); + + it('rejects unlock from the setup tab (outside the allowlist)', async () => { + const state = makeState(); + const res = await route( + { type: 'unlock', passphrase: 'hunter2' }, + state, + makeSetupSender(), + ); + expect(res).toEqual({ ok: false, error: 'unauthorized_sender' }); + }); }); // --- isContent rejects unknown sender.id --- diff --git a/extension/src/service-worker/router/index.ts b/extension/src/service-worker/router/index.ts index 65229c3..50cbd39 100644 --- a/extension/src/service-worker/router/index.ts +++ b/extension/src/service-worker/router/index.ts @@ -2,7 +2,7 @@ /// to popup-only or content-callable handlers. Unauthorized senders are /// rejected with { ok: false, error: 'unauthorized_sender' }. -import type { Request, Response } from '../../shared/messages'; +import type { PopupMessage, Request, Response } from '../../shared/messages'; import { POPUP_ONLY_TYPES, CONTENT_CALLABLE_TYPES } from '../../shared/messages'; import type { Manifest } from '../../shared/types'; import type { GitHost } from '../git-host'; @@ -16,6 +16,16 @@ export interface RouterState { wasm: any; } +/// Popup-only messages the setup tab is also allowed to send. +/// - save_setup: wires vault config + image into chrome.storage.local at end of init. +/// - rate_passphrase: drives the zxcvbn strength meter during passphrase entry. +/// - is_unlocked: setup step-4 pings the extension to detect "save config to extension" availability. +const SETUP_ALLOWED: ReadonlySet = new Set([ + 'save_setup', + 'rate_passphrase', + 'is_unlocked', +]); + export async function route( msg: Request, state: RouterState, @@ -32,17 +42,29 @@ export async function route( && sender.id === chrome.runtime.id; if (POPUP_ONLY_TYPES.has(msg.type as never)) { - // save_setup gets one exception: allowed from the setup tab too. - if (!(isPopup || (msg.type === 'save_setup' && isSetup))) { + if (!(isPopup || (isSetup && SETUP_ALLOWED.has(msg.type as PopupMessage['type'])))) { + // eslint-disable-next-line no-console + console.warn('[relicario router] rejected popup-only message from wrong sender', { + type: msg.type, senderUrl, isPopup, isSetup, isContent, + }); return { ok: false, error: 'unauthorized_sender' }; } return popupOnly.handle(msg as never, state, sender); } if (CONTENT_CALLABLE_TYPES.has(msg.type as never)) { - if (!isContent) return { ok: false, error: 'unauthorized_sender' }; + if (!isContent) { + // eslint-disable-next-line no-console + console.warn('[relicario router] rejected content-only message from wrong sender', { + type: msg.type, senderUrl, isPopup, isSetup, isContent, + frameId: sender.frameId, senderId: sender.id, + }); + return { ok: false, error: 'unauthorized_sender' }; + } return contentCallable.handle(msg as never, state, sender); } + // eslint-disable-next-line no-console + console.warn('[relicario router] unknown message type', { type: (msg as { type: string }).type }); return { ok: false, error: 'unknown_message_type' }; } diff --git a/extension/src/setup/setup.ts b/extension/src/setup/setup.ts index 75a9f9b..3e901c9 100644 --- a/extension/src/setup/setup.ts +++ b/extension/src/setup/setup.ts @@ -81,11 +81,22 @@ function ratePassphrase(passphrase: string): Promise { chrome.runtime.sendMessage( { type: 'rate_passphrase', passphrase }, (response: { ok: boolean; data?: { score: number; guesses_log10: number }; error?: string }) => { - if (chrome.runtime.lastError || !response?.ok) { resolve(-1); return; } + if (chrome.runtime.lastError) { + // eslint-disable-next-line no-console + console.warn('[relicario setup] rate_passphrase lastError:', chrome.runtime.lastError); + resolve(-1); return; + } + if (!response?.ok) { + // eslint-disable-next-line no-console + console.warn('[relicario setup] rate_passphrase rejected by SW:', response); + resolve(-1); return; + } resolve(response.data?.score ?? -1); }, ); - } catch { + } catch (err) { + // eslint-disable-next-line no-console + console.warn('[relicario setup] rate_passphrase threw:', err); resolve(-1); } }); @@ -428,66 +439,85 @@ function attachStep3(): void { state.error = null; render(); + // Structured logging so silent failures become visible in DevTools. + // eslint-disable-next-line no-console + const log = (stage: string, detail?: unknown) => console.log(`[relicario setup] ${stage}`, detail ?? ''); + + let stage = 'init'; try { + stage = 'load wasm'; + log(stage); const w = await loadWasm(); - // 1. Generate 32-byte image secret. + stage = 'generate image secret'; + log(stage); const imageSecret = new Uint8Array(32); crypto.getRandomValues(imageSecret); - // 2. Embed secret into carrier JPEG. + stage = 'embed image secret'; + log(stage, { carrierBytes: state.carrierImageBytes.byteLength }); state.referenceImageBytes = new Uint8Array( w.embed_image_secret(state.carrierImageBytes, imageSecret), ); + log('embedded', { referenceBytes: state.referenceImageBytes.byteLength }); - // 3. Generate 32-byte salt + KDF params. + stage = 'generate salt'; const salt = new Uint8Array(32); crypto.getRandomValues(salt); const paramsJson = '{"argon2_m":65536,"argon2_t":3,"argon2_p":4}'; - // 4. Derive a session handle via the typed-item unlock API. - // (Single-shot master_key derivation is no longer exposed; the - // handle is the only in-JS reference to the master key.) - const handle = w.unlock(state.passphrase, imageSecret, salt, paramsJson); + stage = 'derive session handle'; + log(stage); + // unlock() takes JPEG bytes with embedded secret (it extracts internally), + // not the raw 32-byte secret. + const handle = w.unlock(state.passphrase, state.referenceImageBytes, salt, paramsJson); + log('handle acquired'); - // 5. Encrypt an empty schema-v2 manifest. + stage = 'encrypt empty manifest'; + log(stage); const manifestJson = '{"schema_version":2,"items":{}}'; const encryptedManifest = w.manifest_encrypt(handle, manifestJson); + log('manifest encrypted', { bytes: encryptedManifest.length }); - // 6. Push vault files via git API. + stage = 'push vault files'; + log(stage); const hostUrl = state.hostType === 'github' ? 'https://api.github.com' : state.hostUrl; const host = createGitHost(state.hostType, hostUrl, state.repoPath, state.apiToken); + log('write .relicario/salt'); await host.writeFile('.relicario/salt', salt, 'init: vault salt'); + log('write .relicario/params.json'); const paramsBytes = new TextEncoder().encode(paramsJson); await host.writeFile('.relicario/params.json', paramsBytes, 'init: KDF parameters'); + log('write .relicario/devices.json'); const devicesJson = '{"devices":[]}'; const devicesBytes = new TextEncoder().encode(devicesJson); await host.writeFile('.relicario/devices.json', devicesBytes, 'init: device registry'); + log('write manifest.enc'); await host.writeFile( 'manifest.enc', new Uint8Array(encryptedManifest), 'init: encrypted manifest', ); - // 7. Release the handle — the SW's own unlock will re-derive. + stage = 'release handle'; w.lock(handle); - // 8. Advance to step 4. + log('vault created — advancing to step 4'); state.creating = false; state.step = 4; state.error = null; - - // Detect extension. detectExtension(); - render(); } catch (err: unknown) { + // eslint-disable-next-line no-console + console.error(`[relicario setup] vault creation FAILED during "${stage}":`, err); state.creating = false; - state.error = `Vault creation failed: ${err instanceof Error ? err.message : String(err)}`; + const detail = err instanceof Error ? err.message : String(err); + state.error = `Vault creation failed at "${stage}": ${detail}`; render(); } }); From 69bb58c97713becd523f07436ed38ea987b75ab3 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Wed, 22 Apr 2026 19:38:50 -0400 Subject: [PATCH 30/33] feat(ext/setup): polished passphrase entry UX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Setup wizard step 3 now has self-explanatory passphrase feedback: - Strength meter: 5 segments with smooth color transitions (very-weak/weak/fair/good/strong). Tier 4 gets a subtle glow. - Nuanced label (lowercase, tracked): "very weak" / "weak" / "fair" / "good" / "strong" — color-matched to each tier. - Entropy readout line: "~10^N guesses —