Compare commits
38 Commits
plan-1b-cl
...
plan-1c-al
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
da3c3893bb | ||
|
|
9139dd78a0 | ||
|
|
357455d979 | ||
|
|
69bb58c977 | ||
|
|
4341124d38 | ||
|
|
3238ef4dd4 | ||
|
|
f3b915a635 | ||
|
|
76bb61aa10 | ||
|
|
bc95b047a2 | ||
|
|
dc8097589e | ||
|
|
d090fc421e | ||
|
|
856ceb2d93 | ||
|
|
1d5ad5e59e | ||
|
|
eed11acba2 | ||
|
|
14397b33f0 | ||
|
|
8cc1e777be | ||
|
|
fbb64729ce | ||
|
|
2ff3ab1d7f | ||
|
|
0cef607859 | ||
|
|
3d2b021cb2 | ||
|
|
2d4dcb5f6b | ||
|
|
56ab58cbe9 | ||
|
|
be32ea13c6 | ||
|
|
533bfd5bea | ||
|
|
2fd6daad8e | ||
|
|
c0fba2a8dc | ||
|
|
20144e8e02 | ||
|
|
bd9dd206ac | ||
|
|
7781a51848 | ||
|
|
dc8afcb634 | ||
|
|
b4da5bffcf | ||
|
|
04c9503036 | ||
|
|
14aaac672c | ||
|
|
c03a492ee3 | ||
|
|
ad6d8af2f6 | ||
|
|
a1d733ddeb | ||
|
|
76f34bfcf5 | ||
|
|
e0c511e320 |
8
.gitignore
vendored
@@ -1 +1,9 @@
|
||||
target/
|
||||
.superpowers/
|
||||
.worktrees/
|
||||
extension/node_modules/
|
||||
extension/dist/
|
||||
extension/dist-firefox/
|
||||
extension/wasm/
|
||||
reference.jpg
|
||||
ref.jpg
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
[]
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"format_version": 2,
|
||||
"kdf": {
|
||||
"algorithm": "argon2id-v0x13",
|
||||
"argon2_m": 65536,
|
||||
"argon2_t": 3,
|
||||
"argon2_p": 4
|
||||
},
|
||||
"aead": "xchacha20poly1305",
|
||||
"salt_path": ".relicario/salt"
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
ο½48^ΖΥ<CE96>\Χ<>ο<EFBFBD>Ιl$$Η.ώφrΥΩΛ
|
||||
3319
docs/superpowers/plans/2026-04-20-relicario-extension-1c-alpha.md
Normal file
@@ -0,0 +1,584 @@
|
||||
# relicario — Extension Plan 1C-α (Foundation) Design
|
||||
|
||||
First of three sub-plans that port the browser extension from the v1 single-`Entry` data model to the typed-item model landed in Plans 1A + 1B. 1C-α is the **foundation slice**: rebuild the WASM artifact, migrate shared types, rewrite the service worker against the opaque `SessionHandle` surface, split the message router with sender checks, wire the full security architecture from the typed-items spec, and achieve Login-parity on the new stack. Other six item types show "Coming in 1C-β" placeholders.
|
||||
|
||||
This spec references the broader design at `docs/superpowers/specs/2026-04-18-relicario-typed-items-design.md` — read that for the cryptographic envelope, data-model rationale, and threat model. This document is the extension-side implementation design for the first of the three 1C sub-plans.
|
||||
|
||||
## Plan 1C decomposition
|
||||
|
||||
| Sub-plan | Scope |
|
||||
|---|---|
|
||||
| **1C-α (this spec)** | WASM rebuild, shared types, service-worker rewrite, router split, security architecture, Login-parity popup, setup-wizard zxcvbn, Firefox parity |
|
||||
| 1C-β | Per-type forms for the other six types, sections + custom fields, full vault-settings UI, generator-request UI |
|
||||
| 1C-γ | Attachments (with `putBlob` Git-Data-API fallback), trash view, field-history view, device management |
|
||||
|
||||
Each sub-plan gets its own spec → plan → implementation cycle.
|
||||
|
||||
## Design Decisions
|
||||
|
||||
Captured during brainstorming:
|
||||
|
||||
| Question | Decision | Why |
|
||||
|---|---|---|
|
||||
| How many sub-plans? | Three (α/β/γ) | Single plan is too large to review or execute without drift; three sub-plans give natural checkpoints |
|
||||
| What's "done" for 1C-α? | **Login-parity on the new stack** — all existing single-`Entry` flows re-expressed as `Item::Login`; other six types show "coming soon" | Validates the full pipeline end-to-end (item + manifest + git commit) before β's UI sprawl; keeps extension usable during β |
|
||||
| Firefox in scope for α? | Yes, concurrent with Chrome | Shared TS source; marginal extra cost; avoids mid-β surprises from silent rot |
|
||||
| Where do capture UX prefs + blacklist live? | `chrome.storage.local` (device-local) | TOFU origin-ack is a security posture (vault-level); capture prompt style is a UX preference that genuinely differs per device; blacklist churn pollutes git log |
|
||||
| zxcvbn strength gate in α or β? | **α** | Audit H3 (security); leaving a weak-passphrase window open during β is the same shape of mistake as leaving autofill origin-unbound during α |
|
||||
| Sequencing | **Bottom-up six-slice** (WASM artifact → types → SW vault/session → router → security → popup rewire + zxcvbn) | Matches Plan 1B's small-task cadence; gives the plan-executor clean checkpoints |
|
||||
|
||||
## Scope
|
||||
|
||||
### In
|
||||
|
||||
- **WASM artifact** rebuilt from `relicario-wasm` crate (replacing stale `idfoto_wasm*` files).
|
||||
- **Shared TypeScript types / messages** migrated to the typed-item surface (`Item`, `ItemCore`, `Manifest` v2, `AttachmentRef`, plus the minimal `VaultSettings` subset needed for origin-ack).
|
||||
- **Service-worker rewrite**: `SessionHandle`-based `session.ts`, rewritten `vault.ts`, split `router/{index,popup-only,content-callable}.ts` with sender checks.
|
||||
- **Security architecture**:
|
||||
- WAR cleanup — `setup.html`, `setup.js`, wasm artifacts dropped.
|
||||
- Setup opened via `chrome.tabs.create`.
|
||||
- Origin-bound autofill (`sender.tab.url` only, hostname equality, top-frame only).
|
||||
- TOFU origin-ack via `VaultSettings.autofill_origin_acks`.
|
||||
- Popup captured-tab verification for `fill_credentials` (audit M5).
|
||||
- Closed Shadow DOM for all content-script UI.
|
||||
- `textContent`-only DOM construction; randomized-per-prompt element references; bounded page-derived strings.
|
||||
- **Login-parity popup**: view, add, edit, delete, autofill, capture for `Item::Login`. Other six types appear in the "New…" menu but open a "Coming in 1C-β" placeholder.
|
||||
- **Setup wizard**: zxcvbn strength meter + `score >= 3` gate (via new `rate_passphrase` message).
|
||||
- **Firefox build** re-verified with the new manifest + webpack config.
|
||||
|
||||
### Out (→ 1C-β / 1C-γ)
|
||||
|
||||
- Per-type forms for SecureNote / Identity / Card / Key / Document / Totp (β).
|
||||
- Sections + custom fields UI (β).
|
||||
- Full vault-settings view (retention policies, generator defaults, attachment caps) — α touches `settings.enc` only for origin-ack (β).
|
||||
- Attachments and `putBlob` Git-Data-API fallback — Login items fit Contents API (γ).
|
||||
- Trash view, field-history view (γ).
|
||||
- Device management UI — CLI already handles it (γ).
|
||||
- BIP39 / advanced generator-request UI — α uses a default `Random { length: 20, classes: lower+upper+digits+symbols, symbol_charset: SafeOnly }` for the "gen" button (β).
|
||||
|
||||
## File Map
|
||||
|
||||
### New files
|
||||
|
||||
```
|
||||
extension/src/service-worker/session.ts # SessionHandle lifecycle
|
||||
extension/src/service-worker/router/index.ts # single onMessage entry + sender dispatch
|
||||
extension/src/service-worker/router/popup-only.ts # popup-callable handlers
|
||||
extension/src/service-worker/router/content-callable.ts # content-script-callable handlers
|
||||
extension/src/service-worker/router/__tests__/router.test.ts # sender-check + origin-bound autofill tests
|
||||
extension/src/content/shadow.ts # closed Shadow DOM host helper
|
||||
extension/src/shared/base32.ts # base32 encode/decode for TOTP field parse
|
||||
```
|
||||
|
||||
### Rewritten
|
||||
|
||||
```
|
||||
extension/src/shared/types.ts # Item, ItemCore, FieldKind, VaultSettings, ManifestEntry v2
|
||||
extension/src/shared/messages.ts # PopupMessage + ContentMessage unions
|
||||
extension/src/service-worker/vault.ts # typed-item ops via SessionHandle
|
||||
extension/src/service-worker/index.ts # thin init + WASM load, delegates to router
|
||||
extension/src/content/capture.ts # closed Shadow DOM, textContent
|
||||
extension/src/content/icon.ts # closed Shadow DOM, textContent
|
||||
extension/src/popup/popup.ts # item dispatch, captured-tab snapshot on init
|
||||
extension/src/popup/components/entry-list.ts # → item-list.ts
|
||||
extension/src/popup/components/entry-detail.ts # → item-detail.ts (Login dispatcher + "coming soon")
|
||||
extension/src/popup/components/entry-form.ts # → item-form.ts (Login dispatcher + "coming soon")
|
||||
extension/src/popup/components/setup-wizard.ts # zxcvbn meter + gate
|
||||
extension/src/setup/setup.ts # zxcvbn meter + gate (mirror)
|
||||
extension/src/wasm.d.ts # mirror crates/relicario-wasm/src/lib.rs
|
||||
extension/manifest.json # WAR cleanup
|
||||
extension/manifest.firefox.json # WAR cleanup
|
||||
```
|
||||
|
||||
### Deleted
|
||||
|
||||
```
|
||||
extension/wasm/idfoto_wasm* # stale pre-rename artifact
|
||||
```
|
||||
|
||||
## WASM Artifact
|
||||
|
||||
`npm run build:wasm` already targets `../crates/relicario-wasm --out-dir ../../extension/wasm`. Running it produces `relicario_wasm.js`, `relicario_wasm_bg.wasm`, and `relicario_wasm.d.ts` in the output directory. Delete the stale `idfoto_wasm*` files. Both webpack configs already import from `../../wasm/relicario_wasm.js` — no config edits required.
|
||||
|
||||
`wasm.d.ts` currently mirrors the Plan 1B surface; skim it for drift against `crates/relicario-wasm/src/lib.rs` after the rebuild (particularly the `attachment_encrypt` third argument `max_bytes: bigint`).
|
||||
|
||||
## Shared Types (`shared/types.ts`)
|
||||
|
||||
Mirror the Rust core verbatim through serde serialization. The Rust-side shapes use a mix of `snake_case`, internal tagging (`#[serde(tag = "type")]` for `ItemCore`, `tag = "kind"` for `GeneratorRequest`), adjacent tagging (`tag = "kind", content = "value"` for `FieldValue`, `TrashRetention`, `HistoryRetention`, `SymbolCharset`), and default external tagging (`TotpKind`). The TS types must match exactly:
|
||||
|
||||
```ts
|
||||
export type ItemId = string; // 16-char hex
|
||||
export type FieldId = string;
|
||||
export type AttachmentId = string;
|
||||
|
||||
// snake_case strings, matches serde rename_all = "snake_case"
|
||||
export type ItemType = 'login' | 'secure_note' | 'identity' | 'card' | 'key' | 'document' | 'totp';
|
||||
|
||||
export interface Item {
|
||||
id: ItemId;
|
||||
title: string;
|
||||
type: ItemType; // Rust's `r#type` serializes as `type`
|
||||
tags: string[];
|
||||
favorite: boolean;
|
||||
group?: string; // omitted when None (#[serde(skip_serializing_if)])
|
||||
notes?: string;
|
||||
created: number;
|
||||
modified: number;
|
||||
trashed_at?: number;
|
||||
core: ItemCore; // internally-tagged on `"type"` — see below
|
||||
sections: Section[];
|
||||
attachments: AttachmentRef[];
|
||||
field_history: Record<FieldId, FieldHistoryEntry[]>;
|
||||
}
|
||||
|
||||
// Internally-tagged: ItemCore variant's fields get merged with `"type"` discriminant.
|
||||
// Example wire format for Login: { "type": "login", "username": "...", ... }
|
||||
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);
|
||||
|
||||
export interface LoginCore {
|
||||
username?: string;
|
||||
password?: string;
|
||||
url?: string; // Rust serializes `Url` as its string form
|
||||
totp?: TotpConfig;
|
||||
}
|
||||
|
||||
// TotpKind is externally-tagged (default for enums without #[serde(tag)]):
|
||||
// Totp → "totp" (unit variant serializes as bare string)
|
||||
// Hotp{counter}→ { "hotp": { "counter": 42 } }
|
||||
// Steam → "steam"
|
||||
export type TotpKind = 'totp' | 'steam' | { hotp: { counter: number } };
|
||||
|
||||
export interface TotpConfig {
|
||||
secret: number[]; // Vec<u8> → JSON number array
|
||||
algorithm: 'sha1' | 'sha256' | 'sha512';
|
||||
digits: number;
|
||||
period_seconds: number;
|
||||
kind: TotpKind;
|
||||
}
|
||||
|
||||
// Populated minimally for α (structural shape only, no UI); β fills in:
|
||||
export interface SecureNoteCore { body: string; }
|
||||
export interface IdentityCore { /* ... */ }
|
||||
export interface CardCore { /* ... */ }
|
||||
export interface KeyCore { /* ... */ }
|
||||
export interface DocumentCore { filename: string; mime_type: string; primary_attachment: AttachmentId; }
|
||||
export interface TotpCore { config: TotpConfig; issuer: string | null; label: string | null; }
|
||||
|
||||
export interface Manifest {
|
||||
schema_version: number;
|
||||
items: Record<ItemId, ManifestEntry>;
|
||||
}
|
||||
|
||||
export interface ManifestEntry {
|
||||
id: ItemId;
|
||||
type: ItemType;
|
||||
title: string;
|
||||
tags: string[];
|
||||
favorite: boolean;
|
||||
group: string | null;
|
||||
icon_hint: string | null;
|
||||
modified: number;
|
||||
trashed_at: number | null;
|
||||
attachment_summaries: AttachmentSummary[];
|
||||
}
|
||||
|
||||
export interface VaultSettings {
|
||||
trash_retention: unknown; // opaque in α; full shape in β
|
||||
field_history_retention: unknown;
|
||||
generator_defaults: unknown;
|
||||
attachment_caps: unknown;
|
||||
autofill_origin_acks: Record<string, number>;
|
||||
}
|
||||
|
||||
export interface DeviceSettings { // chrome.storage.local shape (was RelicarioSettings)
|
||||
captureEnabled: boolean;
|
||||
captureStyle: 'bar' | 'toast';
|
||||
}
|
||||
|
||||
// GeneratorRequest is internally-tagged on "kind", struct variants:
|
||||
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; }
|
||||
|
||||
// SymbolCharset is adjacently-tagged { tag: "kind", content: "value" }:
|
||||
export type SymbolCharset =
|
||||
| { kind: 'safe_only' }
|
||||
| { kind: 'extended' }
|
||||
| { kind: 'custom'; value: string };
|
||||
|
||||
// TrashRetention / HistoryRetention use the same adjacent tagging:
|
||||
export type TrashRetention =
|
||||
| { kind: 'forever' }
|
||||
| { kind: 'days'; value: number };
|
||||
|
||||
export type HistoryRetention =
|
||||
| { kind: 'forever' }
|
||||
| { kind: 'last_n'; value: number }
|
||||
| { kind: 'days'; value: number };
|
||||
|
||||
// FieldValue adjacently-tagged { tag: "kind", content: "value" }, snake_case:
|
||||
export type FieldValue =
|
||||
| { kind: 'text'; value: string }
|
||||
| { kind: 'multiline'; value: string }
|
||||
| { kind: 'password'; value: string }
|
||||
| { kind: 'concealed'; value: string }
|
||||
| { kind: 'url'; value: string } // Url → string
|
||||
| { kind: 'email'; value: string }
|
||||
| { kind: 'phone'; value: string }
|
||||
| { kind: 'date'; value: string } // chrono NaiveDate → "YYYY-MM-DD"
|
||||
| { kind: 'month_year'; value: { month: number; year: number } }
|
||||
| { kind: 'totp'; value: TotpConfig }
|
||||
| { kind: 'reference'; value: AttachmentId };
|
||||
|
||||
export type FieldKind =
|
||||
| 'text' | 'multiline' | 'password' | 'concealed' | 'url' | 'email'
|
||||
| 'phone' | 'date' | 'month_year' | 'totp' | 'reference';
|
||||
```
|
||||
|
||||
Plus `Section`, `Field`, `AttachmentRef`, `AttachmentSummary`, `FieldHistoryEntry` as declared. Most are unused by α's UI but present so the type-check catches drift with the Rust side.
|
||||
|
||||
The serialization shapes above are verified in slice 3's smoke test: `item_encrypt(handle, JSON.stringify(loginItem))` round-trips through `item_decrypt` with structural equality against a Rust-side item written by the CLI.
|
||||
|
||||
## Messages (`shared/messages.ts`)
|
||||
|
||||
Two unions, so TypeScript itself enforces the router boundary:
|
||||
|
||||
```ts
|
||||
export type PopupMessage =
|
||||
| { type: 'is_unlocked' }
|
||||
| { type: 'unlock'; passphrase: string }
|
||||
| { type: 'lock' }
|
||||
| { type: 'list_items'; group?: string }
|
||||
| { type: 'get_item'; id: ItemId }
|
||||
| { type: 'add_item'; item: Item }
|
||||
| { type: 'update_item'; id: ItemId; item: Item }
|
||||
| { type: 'delete_item'; id: ItemId } // soft-delete (sets trashed_at)
|
||||
| { type: 'get_totp'; id: ItemId }
|
||||
| { type: 'sync' }
|
||||
| { type: 'get_setup_state' }
|
||||
| { type: 'save_setup'; config: VaultConfig; imageBase64: string }
|
||||
| { type: 'rate_passphrase'; passphrase: string }
|
||||
| { type: 'generate_password'; request: GeneratorRequest }
|
||||
| { type: 'fill_credentials'; id: ItemId; capturedTabId: number; capturedUrl: string }
|
||||
| { type: 'ack_autofill_origin'; hostname: string }
|
||||
| { type: 'get_settings' } // DeviceSettings (local)
|
||||
| { type: 'update_settings'; settings: Partial<DeviceSettings> }
|
||||
| { type: 'get_blacklist' }
|
||||
| { type: 'remove_blacklist'; hostname: string };
|
||||
|
||||
export type ContentMessage =
|
||||
| { type: 'get_autofill_candidates' } // url comes from sender.tab.url
|
||||
| { type: 'get_credentials'; id: ItemId } // origin-checked against sender.tab.url
|
||||
| { type: 'check_credential'; username: string; password: string } // url from sender
|
||||
| { type: 'blacklist_site' }; // hostname from sender
|
||||
|
||||
export type Request = PopupMessage | ContentMessage;
|
||||
```
|
||||
|
||||
Deliberate omissions: `get_autofill_candidates`, `check_credential`, `blacklist_site` no longer carry a `url` — the SW derives it from `sender.tab.url`. `fill_credentials` now takes an item id + captured tab state instead of raw credentials, so the SW can re-verify origin on the forwarding hop.
|
||||
|
||||
## Service Worker
|
||||
|
||||
### `service-worker/session.ts`
|
||||
|
||||
Single module-scope "current" `SessionHandle` (single-vault assumption per the broader spec).
|
||||
|
||||
```ts
|
||||
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 clearCurrent(): void {
|
||||
if (!current) return;
|
||||
try { current.free(); } catch { /* already freed */ }
|
||||
current = null;
|
||||
}
|
||||
export function requireCurrent(): SessionHandle {
|
||||
if (!current) throw new Error('vault_locked');
|
||||
return current;
|
||||
}
|
||||
```
|
||||
|
||||
SW idle-suspend (Chrome) or explicit `lock` message clears the handle. Firefox's persistent background script retains it until explicit lock — consistent with the spec.
|
||||
|
||||
### `service-worker/vault.ts`
|
||||
|
||||
Public surface, all handle-keyed:
|
||||
|
||||
```ts
|
||||
fetchVaultMeta(git): Promise<{ salt: Uint8Array; paramsJson: string }>
|
||||
fetchAndDecryptManifest(git, handle): Promise<Manifest>
|
||||
encryptAndWriteManifest(git, handle, manifest, message): Promise<void>
|
||||
fetchAndDecryptItem(git, handle, id): Promise<Item>
|
||||
encryptAndWriteItem(git, handle, id, item, message): Promise<void>
|
||||
fetchAndDecryptSettings(git, handle): Promise<VaultSettings>
|
||||
encryptAndWriteSettings(git, handle, settings, message): Promise<void>
|
||||
listItems(manifest, filter?): Array<[ItemId, ManifestEntry]>
|
||||
searchItems(manifest, query): Array<[ItemId, ManifestEntry]>
|
||||
findByHostname(manifest, hostname): Array<[ItemId, ManifestEntry]> // hostname from caller
|
||||
```
|
||||
|
||||
No `masterKey: Uint8Array` anywhere in the module surface.
|
||||
|
||||
### `service-worker/router/index.ts`
|
||||
|
||||
Single `chrome.runtime.onMessage.addListener`. Sender predicates:
|
||||
|
||||
```ts
|
||||
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;
|
||||
```
|
||||
|
||||
`POPUP_ONLY` capability set = every `PopupMessage` type. `CONTENT_CALLABLE` = every `ContentMessage` type. `save_setup` is the one exception: accepted from `isPopup || isSetup`.
|
||||
|
||||
Unauthorized sender → `{ ok: false, error: 'unauthorized_sender' }` synchronously. Unknown type → `{ ok: false, error: 'unknown_message_type' }`. The two handler files stay thin — pure `switch (msg.type)` with no sender logic (the router already verified).
|
||||
|
||||
## Security Architecture
|
||||
|
||||
### WAR cleanup (audit C1)
|
||||
|
||||
Both manifests drop `setup.html`, `setup.js`, `relicario_wasm.js`, `relicario_wasm_bg.wasm` from `web_accessible_resources`. The WAR array becomes `[{ resources: ["styles.css"], matches: ["<all_urls>"] }]` if styles are still needed, else disappears.
|
||||
|
||||
The popup opens setup via:
|
||||
|
||||
```ts
|
||||
chrome.tabs.create({ url: chrome.runtime.getURL('setup.html') });
|
||||
```
|
||||
|
||||
Own-origin extension tabs work without WAR. The SW loads WASM via `chrome.runtime.getURL(...)` from the extension origin — no WAR either.
|
||||
|
||||
### Sender dispatch (audit C1, C2)
|
||||
|
||||
Implemented in `router/index.ts` as described above. The `save_setup` exception is the one place this deviates from "pure set-membership" — it accepts either `isPopup` or `isSetup`.
|
||||
|
||||
### Origin-bound autofill (audit C4)
|
||||
|
||||
Content-callable handlers derive origin exclusively from `sender.tab.url`. Flow:
|
||||
|
||||
- **`get_autofill_candidates`**: parse `sender.tab.url` → hostname. Return manifest entries whose `LoginCore.url` hostname equals page hostname. Top-frame only (`sender.frameId === 0` already enforced at router).
|
||||
- **`get_credentials(id)`**: fetch item. Parse `LoginCore.url` hostname. Compare to `sender.tab.url` hostname. Mismatch → `{ ok: false, error: 'origin_mismatch' }`. No item data leaked on mismatch.
|
||||
- **`check_credential`**: same origin derivation; `username`/`password` compared against manifest + decrypted item.
|
||||
- **`blacklist_site`**: hostname derived from `sender.tab.url`.
|
||||
|
||||
### TOFU origin-ack
|
||||
|
||||
When `get_credentials` succeeds on an origin not present in `VaultSettings.autofill_origin_acks`:
|
||||
|
||||
1. SW returns `{ ok: true, data: { requires_ack: true, hostname } }` — no credentials.
|
||||
2. Content script surfaces a "confirm autofill" dialog inside its closed Shadow DOM (lightweight — just a re-use of the capture prompt layout).
|
||||
3. User clicks "Confirm in relicario" → focuses the extension popup (the content-script dialog shows a prompt to open the popup; `chrome.action.openPopup()` is Chrome-only and unreliable, so α uses an instructional "open relicario to confirm" message instead).
|
||||
4. User opens popup → popup detects a pending ack via `VaultSettings.autofill_origin_acks` diff, shows "Confirm autofill on `<hostname>`?" → user acks → popup sends `ack_autofill_origin { hostname }` (popup-only, writes `settings.enc`).
|
||||
5. Next autofill attempt on the same origin succeeds.
|
||||
|
||||
The ack is popup-only because it's a vault write; the content-script dialog is purely instructional. Full in-page ack UI (including a tighter retry loop) is β-polish territory.
|
||||
|
||||
### Popup captured-tab verification (audit M5)
|
||||
|
||||
On popup open:
|
||||
|
||||
```ts
|
||||
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
|
||||
const captured = { tabId: tab.id!, url: tab.url ?? '' };
|
||||
```
|
||||
|
||||
Stashed on `PopupState`. `fill_credentials` messages carry `capturedTabId` + `capturedUrl`. SW handler:
|
||||
|
||||
1. Look up item by `id`.
|
||||
2. `chrome.tabs.get(capturedTabId)` — if gone or navigated, reject.
|
||||
3. Compare `new URL(tab.url).hostname` to `new URL(capturedUrl).hostname` — mismatch rejects.
|
||||
4. Compare captured hostname to `LoginCore.url`'s hostname — mismatch rejects.
|
||||
5. Forward via `chrome.tabs.sendMessage(capturedTabId, { type: 'fill_credentials', username, password })`.
|
||||
|
||||
Content-script fill listener stays as-is (native-setter trick is already correct).
|
||||
|
||||
### Closed Shadow DOM (audit C3)
|
||||
|
||||
`content/shadow.ts`:
|
||||
|
||||
```ts
|
||||
export function createShadowHost(): { host: HTMLElement; root: ShadowRoot; destroy: () => void } {
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = host.attachShadow({ mode: 'closed' });
|
||||
return { host, root, destroy: () => host.remove() };
|
||||
}
|
||||
```
|
||||
|
||||
Used by `icon.ts` (per-password-field host for the icon + picker) and `capture.ts` (submit-prompt). Strict rules enforced by review + a lint rule (`no-restricted-syntax` against `MemberExpression[property.name=/^(innerHTML|outerHTML)$/]` inside `extension/src/content/`):
|
||||
|
||||
1. **No `innerHTML` / `insertAdjacentHTML` / `document.write`** anywhere in `content/`. All DOM via `createElement` + `textContent` + `appendChild`.
|
||||
2. **No stable element IDs or classes inside shadow trees**. Wire handlers via local references.
|
||||
3. **Page-derived strings bounded**: `findUsernameValue` result capped at 256 chars, control characters stripped via `replace(/\p{Cc}/gu, '')`, assigned only via `.textContent`.
|
||||
4. **Disposal**: removing the host element drops the shadow root, detaches handlers.
|
||||
|
||||
Styles inside the shadow tree via `style.setProperty(...)` calls, or a single `<style>` element whose text content is a static literal.
|
||||
|
||||
### JS-side passphrase hygiene
|
||||
|
||||
Best-effort only (JS strings are immutable). In the `unlock` handler: receive passphrase, pass directly to `wasm.unlock(...)`, then `req.passphrase = ''` and let the message object go out of scope. Never log passphrase content. WASM-side zeroization is the primary defense (already handled Rust-side).
|
||||
|
||||
## Popup
|
||||
|
||||
### Entry flow
|
||||
|
||||
`popup.ts` unchanged in shape. Init sequence:
|
||||
|
||||
1. `get_setup_state` → if `!isConfigured`, `chrome.tabs.create(setup.html)` and close the popup.
|
||||
2. `is_unlocked` → if unlocked, `list_items` + render list.
|
||||
3. Otherwise render `unlock`.
|
||||
4. Snapshot `(activeTabId, activeTabUrl)` at init; stash on `PopupState` for later `fill_credentials` calls.
|
||||
|
||||
### Item list / detail / form — Login-only
|
||||
|
||||
Rename `entry-*.ts` → `item-*.ts`. List view renders from `ManifestEntry`, shows type-icon + title + group + favorite + tags. Detail and form dispatch on `item.type`:
|
||||
|
||||
```ts
|
||||
switch (item.type) {
|
||||
case 'login': return renderLoginDetail(app, item);
|
||||
case 'secure_note':
|
||||
case 'identity':
|
||||
case 'card':
|
||||
case 'key':
|
||||
case 'document':
|
||||
case 'totp': return renderComingSoonPlaceholder(app, item.type);
|
||||
}
|
||||
```
|
||||
|
||||
Add flow: "New…" menu lists all seven types; picking Login opens the form, picking any other type shows "Coming in 1C-β".
|
||||
|
||||
Existing Login form (username/url/password/totp/group/notes) maps 1:1 to `LoginCore` + `Item` envelope. TOTP field takes a base32 string; `shared/base32.ts` parses it to a `number[]` (the `secret: Vec<u8>` on the Rust side) and emits `TotpConfig { secret, algorithm: 'sha1', digits: 6, period_seconds: 30, kind: 'totp' }`. Display is base32 re-encoded with a reveal toggle.
|
||||
|
||||
"gen" button sends:
|
||||
|
||||
```ts
|
||||
{ type: 'generate_password',
|
||||
request: { kind: 'random',
|
||||
length: 20,
|
||||
classes: { lower: true, upper: true, digits: true, symbols: true },
|
||||
symbol_charset: { kind: 'safe_only' } } }
|
||||
```
|
||||
|
||||
### Setup wizard + zxcvbn
|
||||
|
||||
Both `popup/components/setup-wizard.ts` and `setup/setup.ts`:
|
||||
|
||||
- Passphrase input with a 5-bar strength indicator (color-coded per zxcvbn `score`).
|
||||
- On input (150ms debounce): `rate_passphrase { passphrase }` → `{ score, guesses_log10 }`.
|
||||
- Submit disabled unless `score >= 3`.
|
||||
- Copy: `score < 3` → "Too weak — try a longer phrase or add unpredictability."; `score >= 3` → "Strong enough."
|
||||
|
||||
Reference-image upload and vault-config fields stay as-is. Final step gains the spec's H8 warning copy: "factor 2 + git push token are readable from this browser profile's disk. Your remaining defense is the passphrase."
|
||||
|
||||
## Storage Split
|
||||
|
||||
| Data | Location | Rationale |
|
||||
|---|---|---|
|
||||
| `vaultConfig` (host/repo/token) | `chrome.storage.local` | Device-local; needed pre-unlock |
|
||||
| `imageBase64` (reference JPEG) | `chrome.storage.local` | Device-local factor-2 material |
|
||||
| `DeviceSettings { captureEnabled, captureStyle }` | `chrome.storage.local` (key: `relicarioSettings`) | Per-device UX preference |
|
||||
| `captureBlacklist: string[]` | `chrome.storage.local` | Per-device; avoids git churn |
|
||||
| `VaultSettings.autofill_origin_acks` | `settings.enc` in the repo | Per-vault security posture |
|
||||
| Items / manifest / settings | git repo (AEAD'd) | Core vault state |
|
||||
|
||||
Existing `relicarioSettings` + `captureBlacklist` keys keep their names — no migration step.
|
||||
|
||||
## Firefox Parity
|
||||
|
||||
- `manifest.firefox.json` gets the same WAR cleanup as Chrome.
|
||||
- `background.scripts: ["service-worker.js"]` stays (not an SW in Firefox — persistent background script).
|
||||
- `initWasm()` in `service-worker/index.ts` already branches on `ServiceWorkerGlobalScope` presence; the branch survives the rewrite.
|
||||
- Load via `about:debugging` → "Load Temporary Add-on" → `dist-firefox/manifest.json`.
|
||||
- Final manual test matrix at the end of α runs on both browsers concurrently (see below).
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Rust-side regression guard
|
||||
|
||||
Plan 1B's 151 tests must stay green. Every slice runs `cargo test --workspace` before commit. The WASM rebuild slice additionally runs `cargo build -p relicario-wasm --target wasm32-unknown-unknown`.
|
||||
|
||||
### Extension unit tests (new)
|
||||
|
||||
New harness in `extension/src/service-worker/router/__tests__/router.test.ts` using Vitest (runs TS natively, no webpack dependency). Tests:
|
||||
|
||||
- **Sender-check matrix**: for each message type, accepted from the right sender and rejected (`unauthorized_sender`) from every wrong sender. Mocks `chrome.runtime.onMessage` by calling the dispatcher directly with fabricated `sender` objects.
|
||||
- **Origin-bound autofill**: `get_autofill_candidates` ignores any stray `url` field; only `sender.tab.url`'s hostname drives matching.
|
||||
- **`get_credentials` origin equality**: mismatched `LoginCore.url` hostname → `origin_mismatch`, no item data in response.
|
||||
- **`fill_credentials` captured-tab verification**: mismatched captured vs current tab → reject.
|
||||
- **`generate_password` wiring**: calls through to WASM with the expected request shape (smoke only; generator itself is Rust-tested).
|
||||
|
||||
Harness scope: SW only. Popup/content-script DOM tests are β.
|
||||
|
||||
### Shadow DOM probe (dev-build only)
|
||||
|
||||
Runtime assertion in a dev-mode content-script path: after rendering the capture prompt, `console.warn` if `document.querySelector('#relicario-save-btn') !== null` or if the host's `shadowRoot` getter returns non-null from page JS. Stripped in production.
|
||||
|
||||
### Manual test matrix — end of slice 6, on both Chrome and Firefox
|
||||
|
||||
1. Fresh install → setup wizard opens in a tab (not popup-embedded); zxcvbn slider responds; weak passphrase blocks submit.
|
||||
2. Unlock → list renders from manifest.
|
||||
3. Add Login with TOTP → sync → item appears on a second browser profile.
|
||||
4. Autofill icon in password field → click → fills (single-candidate path).
|
||||
5. Multiple candidates → picker → pick → fills.
|
||||
6. First autofill on a new hostname → origin-ack prompt → confirm → credentials arrive.
|
||||
7. Capture prompt on submitting a new login; "Save" adds item, "Never" blacklists, "×" dismisses.
|
||||
8. Edit Login → password rotates → field history captured (CLI cross-check: `relicario get --show` on second machine shows new password; item file's `field_history` populated).
|
||||
9. Delete Login → moves to trash (not in list; CLI `relicario list --trashed` shows it).
|
||||
10. Soft-lock via popup "lock" → list clears, re-unlock required.
|
||||
11. Cross-origin autofill attempt via devtools: craft a `get_credentials` from a page whose hostname differs from item's `LoginCore.url` → `origin_mismatch`.
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- `cargo test --workspace` green.
|
||||
- `npm run build:all` green in `extension/` (Chrome + Firefox bundles).
|
||||
- Router unit tests green.
|
||||
- All 11 manual-matrix steps pass on both Chrome and Firefox.
|
||||
- `git grep -n 'innerHTML\|insertAdjacentHTML' extension/src/content/` → zero hits.
|
||||
- `git grep -n 'idfoto' extension/` → zero hits.
|
||||
- WAR in both manifests contains no HTML/JS/WASM artifacts.
|
||||
- `fetch(chrome.runtime.getURL('setup.html'))` from a content script fails (smoke test — confirms WAR removal took effect).
|
||||
|
||||
### Non-goals for α testing
|
||||
|
||||
- Automated browser integration (Playwright against a built extension) — γ.
|
||||
- Heap-snapshot verification that master_key bytes aren't visible from JS — manual-only per broader spec; formalized in γ.
|
||||
- Fuzz / property tests for the router — the message-type matrix is exhaustive.
|
||||
|
||||
## Audit Findings Addressed
|
||||
|
||||
| ID | Severity | How α addresses it |
|
||||
|---|---|---|
|
||||
| C1 | Critical | Setup wizard + WASM removed from WAR; sender check on `save_setup` |
|
||||
| C2 | Critical | Split message router with sender-based dispatch |
|
||||
| C3 | Critical | Closed Shadow DOM + textContent for all content-script UI |
|
||||
| C4 | Critical | Origin-bound autofill (`sender.tab.url` only, hostname match) |
|
||||
| H2 | High | `SessionHandle` opaque to JS; passphrase cleared from scope ASAP |
|
||||
| H3 | High | zxcvbn strength gate in setup wizard |
|
||||
| M5 | Medium | Popup captures `(tab.id, tab.url)` at open; verifies on `fill_credentials` |
|
||||
| L11 | Low | `textContent` rule subsumes escaping concerns in the content-script surface |
|
||||
|
||||
Remaining audit items from the typed-items spec (retention UI, attachment caps UI, device-management UI, full vault-settings view) land in β/γ.
|
||||
|
||||
## Open Questions Deferred to the Plan
|
||||
|
||||
- Exact Vitest configuration — first router-test slice surfaces the final shape (`vitest.config.ts`, `tsconfig` overrides, jsdom vs happy-dom for the origin-parsing paths).
|
||||
- Precise "pending ack" detection mechanism in the popup: poll `VaultSettings.autofill_origin_acks` on popup open vs a background-synthesized flag. Slice 6 decides based on latency feel.
|
||||
- Whether the TOTP parse helper belongs in `shared/base32.ts` or gets added to the WASM surface as `totp_config_from_base32`. First approach is simpler; the second is more robust to charset edge cases. Slice 2 decides when wiring the Login form.
|
||||
244
docs/superpowers/test-runs/2026-04-20-1c-alpha-manual-matrix.md
Normal file
@@ -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: '<the-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://<your-extension-id>/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. `<all_urls>` 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.*
|
||||
@@ -3,12 +3,14 @@
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "idfoto-extension",
|
||||
"name": "relicario-extension",
|
||||
"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=="],
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 886 B |
|
Before Width: | Height: | Size: 4.7 KiB After Width: | Height: | Size: 4.2 KiB |
24
extension/icons/relicario-logo-16.svg
Normal file
@@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="none">
|
||||
<!-- 16x16-optimized: bolder strokes, simplified details, single gem
|
||||
facet for crisp pixels at toolbar size. -->
|
||||
|
||||
<!-- Base plate -->
|
||||
<rect x="1" y="13" width="14" height="2" rx="0.5" fill="#58a6ff"/>
|
||||
|
||||
<!-- Arched reliquary body -->
|
||||
<path d="M 3 13
|
||||
L 3 6
|
||||
C 3 3.5, 5 2, 8 2
|
||||
C 11 2, 13 3.5, 13 6
|
||||
L 13 13 Z"
|
||||
fill="#161b22"
|
||||
stroke="#58a6ff"
|
||||
stroke-width="1"
|
||||
stroke-linejoin="round"/>
|
||||
|
||||
<!-- Seal band -->
|
||||
<rect x="3" y="6" width="10" height="1" fill="#58a6ff"/>
|
||||
|
||||
<!-- Central gem — a simple filled diamond -->
|
||||
<path d="M 8 8 L 10 10 L 8 12 L 6 10 Z" fill="#58a6ff"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 745 B |
@@ -1,30 +1,38 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" fill="none">
|
||||
<!-- ID card outer body -->
|
||||
<rect x="16" y="20" width="96" height="88" rx="6" fill="#0d1117" stroke="#58a6ff" stroke-width="3"/>
|
||||
<!-- relicario: a reliquary — a vessel that holds precious things.
|
||||
Arched container with a horizontal seal band, a central gem
|
||||
(the "relic"), standing on a base plate.
|
||||
Palette: gh-dark #0d1117/#161b22 background, #58a6ff primary,
|
||||
#79c0ff / #1f6feb gem facets. -->
|
||||
|
||||
<!-- Photo rectangle (left side, dominant) -->
|
||||
<rect x="26" y="32" width="44" height="64" rx="2" fill="#58a6ff"/>
|
||||
<!-- Base plate / pedestal — extends slightly beyond the body. -->
|
||||
<rect x="18" y="104" width="92" height="10" rx="2" fill="#58a6ff"/>
|
||||
<rect x="18" y="112" width="92" height="2" fill="#1f6feb"/>
|
||||
|
||||
<!-- Silhouette in photo (cartoony connected portrait) -->
|
||||
<path d="M 28 96
|
||||
L 28 92
|
||||
C 28 84, 34 79, 42 77
|
||||
L 42 73
|
||||
C 38 71, 36 66, 36 60
|
||||
C 36 52, 41 46, 48 46
|
||||
C 55 46, 60 52, 60 60
|
||||
C 60 66, 58 71, 54 73
|
||||
L 54 77
|
||||
C 62 79, 68 84, 68 92
|
||||
L 68 96 Z" fill="#0d1117"/>
|
||||
<!-- Reliquary body: rounded arch over a rectangular casket. -->
|
||||
<path d="M 28 104
|
||||
L 28 54
|
||||
C 28 34, 44 20, 64 20
|
||||
C 84 20, 100 34, 100 54
|
||||
L 100 104 Z"
|
||||
fill="#161b22"
|
||||
stroke="#58a6ff"
|
||||
stroke-width="4"
|
||||
stroke-linejoin="round"/>
|
||||
|
||||
<!-- Info lines (right side, suggest text without being text) -->
|
||||
<rect x="78" y="36" width="24" height="4" rx="1" fill="#58a6ff"/>
|
||||
<rect x="78" y="48" width="20" height="3" rx="1" fill="#30363d"/>
|
||||
<rect x="78" y="56" width="24" height="3" rx="1" fill="#30363d"/>
|
||||
<rect x="78" y="64" width="18" height="3" rx="1" fill="#30363d"/>
|
||||
<!-- Lock icon (security indicator) -->
|
||||
<rect x="82" y="84" width="16" height="12" rx="1.5" fill="#3fb950"/>
|
||||
<path d="M85 84 V79 A5 5 0 0 1 95 79 V84" fill="none" stroke="#3fb950" stroke-width="2.5" stroke-linecap="round"/>
|
||||
<circle cx="90" cy="90" r="1.5" fill="#0d1117"/>
|
||||
<!-- Horizontal seal band across the arch-to-body transition. -->
|
||||
<rect x="26" y="56" width="76" height="5" fill="#58a6ff"/>
|
||||
|
||||
<!-- Small rivets at each end of the seal band. -->
|
||||
<circle cx="32" cy="58.5" r="2" fill="#0d1117"/>
|
||||
<circle cx="96" cy="58.5" r="2" fill="#0d1117"/>
|
||||
|
||||
<!-- The relic: a faceted diamond/gem centered in the casket chamber.
|
||||
Three tones suggest light hitting facets. -->
|
||||
<g transform="translate(64, 80)">
|
||||
<path d="M 0 -18 L 16 0 L 0 22 L -16 0 Z" fill="#58a6ff"/>
|
||||
<path d="M 0 -18 L 16 0 L 0 0 Z" fill="#79c0ff"/>
|
||||
<path d="M -16 0 L 0 -18 L 0 0 Z" fill="#1f6feb"/>
|
||||
<path d="M 0 22 L 16 0 L 0 0 Z" fill="#1f6feb" opacity="0.7"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.5 KiB |
@@ -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": []
|
||||
}
|
||||
|
||||
@@ -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": ["<all_urls>"]
|
||||
}]
|
||||
"web_accessible_resources": []
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -49,23 +49,125 @@
|
||||
}
|
||||
|
||||
.strength-bar {
|
||||
height: 4px;
|
||||
display: flex;
|
||||
gap: 3px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.strength-bar .seg {
|
||||
flex: 1;
|
||||
height: 5px;
|
||||
background: #21262d;
|
||||
border-radius: 2px;
|
||||
margin-top: 6px;
|
||||
overflow: hidden;
|
||||
border-radius: 3px;
|
||||
transition: background 0.25s ease, box-shadow 0.25s ease;
|
||||
}
|
||||
|
||||
.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: #f08d49; }
|
||||
.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: #56d364;
|
||||
box-shadow: 0 0 4px rgba(86, 211, 100, 0.4);
|
||||
}
|
||||
|
||||
.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-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
margin-top: 5px;
|
||||
}
|
||||
.strength-label {
|
||||
font-size: 11px;
|
||||
margin: 0;
|
||||
text-transform: lowercase;
|
||||
letter-spacing: 0.03em;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
.strength-label.s-very-weak { color: #f85149; }
|
||||
.strength-label.s-weak { color: #f08d49; }
|
||||
.strength-label.s-fair { color: #d29922; }
|
||||
.strength-label.s-good { color: #3fb950; }
|
||||
.strength-label.s-strong { color: #56d364; font-weight: 600; }
|
||||
|
||||
.char-counter {
|
||||
font-size: 10px;
|
||||
color: #6e7681;
|
||||
margin: 0;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.entropy-line {
|
||||
font-size: 10px;
|
||||
color: #8b949e;
|
||||
margin-top: 2px;
|
||||
font-family: "SF Mono", "JetBrains Mono", monospace;
|
||||
min-height: 1em;
|
||||
}
|
||||
|
||||
.pass-help {
|
||||
background: #0d1117;
|
||||
border: 1px solid #21262d;
|
||||
border-left: 2px solid #1f6feb;
|
||||
border-radius: 4px;
|
||||
padding: 8px 12px;
|
||||
font-size: 11px;
|
||||
color: #8b949e;
|
||||
line-height: 1.55;
|
||||
margin: 10px 0;
|
||||
}
|
||||
.pass-help strong { color: #c9d1d9; }
|
||||
|
||||
.passphrase-field {
|
||||
position: relative;
|
||||
}
|
||||
.passphrase-field input {
|
||||
padding-right: 76px; /* room for match indicator + eye button */
|
||||
}
|
||||
.eye-btn {
|
||||
position: absolute;
|
||||
right: 6px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
height: 24px;
|
||||
padding: 0 8px;
|
||||
background: transparent;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 3px;
|
||||
color: #8b949e;
|
||||
cursor: pointer;
|
||||
font-size: 10px;
|
||||
font-family: inherit;
|
||||
text-transform: lowercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
.eye-btn:hover { color: #c9d1d9; border-color: #484f58; }
|
||||
|
||||
.match-indicator {
|
||||
position: absolute;
|
||||
right: 50px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
pointer-events: none;
|
||||
transition: color 0.15s ease, opacity 0.15s ease;
|
||||
}
|
||||
.match-indicator.ok { color: #3fb950; }
|
||||
.match-indicator.bad { color: #f85149; }
|
||||
|
||||
/* Primary button explicitly dims when disabled so the gate is obvious. */
|
||||
.btn-primary:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.success-box {
|
||||
background: #0d1b0e;
|
||||
|
||||
@@ -2,14 +2,21 @@
|
||||
///
|
||||
/// 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 } from '../shared/types';
|
||||
import { createShadowHost, type ShadowSurface } from './shadow';
|
||||
|
||||
// --- State ---
|
||||
|
||||
const hookedForms = new WeakSet<HTMLFormElement>();
|
||||
const hookedButtons = new WeakSet<HTMLElement>();
|
||||
let currentPrompt: ShadowSurface | null = null;
|
||||
|
||||
// --- Messaging ---
|
||||
|
||||
@@ -73,11 +80,10 @@ async function onFormSubmit(pwField: HTMLInputElement): Promise<void> {
|
||||
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,
|
||||
});
|
||||
@@ -87,60 +93,65 @@ async function onFormSubmit(pwField: HTMLInputElement): Promise<void> {
|
||||
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 settings: RelicarioSettings = settingsResp.ok
|
||||
? (settingsResp.data as { settings: RelicarioSettings }).settings
|
||||
: { captureEnabled: true, captureStyle: 'bar' };
|
||||
// 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, url, username, password, data.entryId);
|
||||
showPrompt(settings.captureStyle, data.action, username, password);
|
||||
}
|
||||
|
||||
// --- 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 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',
|
||||
@@ -152,10 +163,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',
|
||||
@@ -167,30 +175,46 @@ function showPrompt(
|
||||
}
|
||||
|
||||
const actionLabel = action === 'update' ? 'Update' : 'Save';
|
||||
const displayUser = username ? ` (${username})` : '';
|
||||
|
||||
container.innerHTML = `
|
||||
<span style="flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">
|
||||
${actionLabel} login for <strong style="color:#58a6ff">${escapeForHtml(hostname)}</strong>${escapeForHtml(displayUser)}?
|
||||
</span>
|
||||
<button id="relicario-save-btn" style="
|
||||
background:#1f6feb; color:#fff; border:none; padding:5px 14px;
|
||||
border-radius:3px; cursor:pointer; font-family:inherit; font-size:12px;
|
||||
white-space:nowrap;
|
||||
">${actionLabel}</button>
|
||||
<button id="relicario-never-btn" style="
|
||||
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;
|
||||
">Never</button>
|
||||
<button id="relicario-close-btn" style="
|
||||
background:transparent; color:#8b949e; border:none;
|
||||
cursor:pointer; font-size:16px; padding:2px 6px;
|
||||
font-family:inherit; line-height:1;
|
||||
">\u2715</button>
|
||||
`;
|
||||
// Message span: "<actionLabel> login for <hostname>(<username>)?"
|
||||
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(() => {
|
||||
@@ -211,68 +235,35 @@ function showPrompt(
|
||||
if (autoDismissTimer) clearTimeout(autoDismissTimer);
|
||||
};
|
||||
|
||||
// Save button
|
||||
container.querySelector('#relicario-save-btn')?.addEventListener('click', async () => {
|
||||
// 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 = new Date().toISOString();
|
||||
if (action === 'update' && entryId) {
|
||||
await sendMessage({
|
||||
type: 'update_entry',
|
||||
id: entryId,
|
||||
entry: {
|
||||
name: hostname,
|
||||
url,
|
||||
username,
|
||||
password,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await sendMessage({
|
||||
type: 'add_entry',
|
||||
entry: {
|
||||
name: hostname,
|
||||
url,
|
||||
username,
|
||||
password,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
},
|
||||
});
|
||||
const resp = await sendMessage({ type: 'capture_save_login', username, password });
|
||||
if (!resp.ok) {
|
||||
msgSpan.textContent = `✗ ${resp.error}`;
|
||||
return;
|
||||
}
|
||||
|
||||
// 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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,15 +1,40 @@
|
||||
/// 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<HTMLInputElement>();
|
||||
|
||||
/// 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,
|
||||
@@ -17,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<void> {
|
||||
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);
|
||||
}
|
||||
|
||||
37
extension/src/content/shadow.ts
Normal file
@@ -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 <div> 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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,255 +0,0 @@
|
||||
/// Entry detail view — shows fields, TOTP countdown, copy/fill shortcuts.
|
||||
|
||||
import { getState, setState, sendMessage, navigate, escapeHtml } from '../popup';
|
||||
import type { ManifestEntry } from '../../shared/types';
|
||||
|
||||
let totpInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
function stopTotpTimer(): void {
|
||||
if (totpInterval !== null) {
|
||||
clearInterval(totpInterval);
|
||||
totpInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function copyToClipboard(text: string): Promise<void> {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
} catch {
|
||||
// Fallback for older browsers.
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = text;
|
||||
ta.style.position = 'fixed';
|
||||
ta.style.left = '-9999px';
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(ta);
|
||||
}
|
||||
}
|
||||
|
||||
export function renderEntryDetail(app: HTMLElement): void {
|
||||
const state = getState();
|
||||
const entry = state.selectedEntry;
|
||||
const id = state.selectedId;
|
||||
if (!entry || !id) {
|
||||
navigate('list');
|
||||
return;
|
||||
}
|
||||
|
||||
stopTotpTimer();
|
||||
|
||||
let html = `
|
||||
<div class="detail-header">
|
||||
<span class="detail-title">${escapeHtml(entry.name)}</span>
|
||||
<button class="btn" id="back-btn" style="font-size:11px;">esc</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// URL
|
||||
if (entry.url) {
|
||||
html += `
|
||||
<div class="field">
|
||||
<div class="label">url</div>
|
||||
<div class="field-value">${escapeHtml(entry.url)}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Username
|
||||
if (entry.username) {
|
||||
html += `
|
||||
<div class="field">
|
||||
<div class="label">username</div>
|
||||
<div class="field-value" id="username-val">${escapeHtml(entry.username)}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Password (masked by default)
|
||||
html += `
|
||||
<div class="field">
|
||||
<div class="label">password</div>
|
||||
<div class="field-value" id="password-val" style="cursor:pointer;">
|
||||
<span id="password-display">********</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// TOTP
|
||||
if (entry.totp_secret) {
|
||||
html += `
|
||||
<div class="field">
|
||||
<div class="label">totp</div>
|
||||
<div class="totp-code" id="totp-code">------</div>
|
||||
<div class="totp-bar"><div class="totp-bar-fill" id="totp-bar-fill" style="width:100%;"></div></div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Notes
|
||||
if (entry.notes) {
|
||||
html += `
|
||||
<div class="field">
|
||||
<div class="label">notes</div>
|
||||
<div class="field-value">${escapeHtml(entry.notes)}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Group
|
||||
if (entry.group) {
|
||||
html += `
|
||||
<div class="field">
|
||||
<div class="label">group</div>
|
||||
<div class="field-value">${escapeHtml(entry.group)}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Metadata
|
||||
html += `
|
||||
<div class="field">
|
||||
<div class="muted">updated ${escapeHtml(entry.updated_at)}</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Key hints
|
||||
html += `
|
||||
<div class="keyhints">
|
||||
<span><kbd>c</kbd> copy user</span>
|
||||
<span><kbd>p</kbd> copy pass</span>
|
||||
${entry.totp_secret ? '<span><kbd>t</kbd> copy totp</span>' : ''}
|
||||
<span><kbd>f</kbd> autofill</span>
|
||||
<span><kbd>e</kbd> edit</span>
|
||||
<span><kbd>d</kbd> delete</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
app.innerHTML = html;
|
||||
|
||||
// --- Password toggle ---
|
||||
let passwordVisible = false;
|
||||
const passwordDisplay = document.getElementById('password-display')!;
|
||||
const passwordVal = document.getElementById('password-val')!;
|
||||
passwordVal?.addEventListener('click', () => {
|
||||
passwordVisible = !passwordVisible;
|
||||
passwordDisplay.textContent = passwordVisible ? entry.password : '********';
|
||||
});
|
||||
|
||||
// --- Back button ---
|
||||
document.getElementById('back-btn')?.addEventListener('click', goBack);
|
||||
|
||||
// --- TOTP timer ---
|
||||
if (entry.totp_secret) {
|
||||
refreshTotp(id);
|
||||
totpInterval = setInterval(() => refreshTotp(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) {
|
||||
case 'Escape':
|
||||
document.removeEventListener('keydown', handler);
|
||||
goBack();
|
||||
break;
|
||||
|
||||
case 'c':
|
||||
if (entry.username) await copyToClipboard(entry.username);
|
||||
break;
|
||||
|
||||
case 'p':
|
||||
await copyToClipboard(entry.password);
|
||||
break;
|
||||
|
||||
case 't':
|
||||
if (entry.totp_secret) {
|
||||
const codeEl = document.getElementById('totp-code');
|
||||
if (codeEl) await copyToClipboard(codeEl.textContent ?? '');
|
||||
}
|
||||
break;
|
||||
|
||||
case 'f': {
|
||||
const resp = await sendMessage({
|
||||
type: 'fill_credentials',
|
||||
username: entry.username ?? '',
|
||||
password: entry.password,
|
||||
});
|
||||
if (!resp.ok) setState({ error: resp.error });
|
||||
break;
|
||||
}
|
||||
|
||||
case 'e':
|
||||
document.removeEventListener('keydown', handler);
|
||||
stopTotpTimer();
|
||||
navigate('edit');
|
||||
break;
|
||||
|
||||
case 'd':
|
||||
e.preventDefault();
|
||||
showDeleteConfirm(id, entry.name, handler);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handler);
|
||||
}
|
||||
|
||||
async function refreshTotp(id: string): Promise<void> {
|
||||
const resp = await sendMessage({ type: 'get_totp', id });
|
||||
if (resp.ok) {
|
||||
const data = resp.data as { code: string; remaining_seconds: 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}%`;
|
||||
}
|
||||
}
|
||||
|
||||
function goBack(): void {
|
||||
stopTotpTimer();
|
||||
// Reload the entry list.
|
||||
sendMessage({ type: 'list_entries' }).then(resp => {
|
||||
if (resp.ok) {
|
||||
const data = resp.data as { entries: Array<[string, ManifestEntry]> };
|
||||
navigate('list', {
|
||||
entries: data.entries,
|
||||
selectedId: null,
|
||||
selectedEntry: null,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function showDeleteConfirm(id: string, name: string, parentHandler: (e: KeyboardEvent) => void): void {
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'confirm-overlay';
|
||||
overlay.innerHTML = `
|
||||
<div class="confirm-box">
|
||||
<p>Delete <strong>${escapeHtml(name)}</strong>?</p>
|
||||
<button class="btn" id="cancel-delete">cancel</button>
|
||||
<button class="btn btn-danger" id="confirm-delete">delete</button>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
document.getElementById('cancel-delete')?.addEventListener('click', () => {
|
||||
overlay.remove();
|
||||
});
|
||||
|
||||
document.getElementById('confirm-delete')?.addEventListener('click', async () => {
|
||||
overlay.remove();
|
||||
setState({ loading: true });
|
||||
const resp = await sendMessage({ type: 'delete_entry', id });
|
||||
if (resp.ok) {
|
||||
document.removeEventListener('keydown', parentHandler);
|
||||
stopTotpTimer();
|
||||
goBack();
|
||||
} else {
|
||||
setState({ loading: false, error: resp.error });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,142 +0,0 @@
|
||||
/// Entry form — add or edit an entry.
|
||||
|
||||
import { getState, setState, sendMessage, navigate, escapeHtml } from '../popup';
|
||||
import type { Entry, ManifestEntry } from '../../shared/types';
|
||||
|
||||
export function renderEntryForm(app: HTMLElement, mode: 'add' | 'edit'): void {
|
||||
const state = getState();
|
||||
const existing = mode === 'edit' ? state.selectedEntry : null;
|
||||
|
||||
app.innerHTML = `
|
||||
<div class="pad">
|
||||
<div class="detail-title" style="margin-bottom:16px;">${mode === 'add' ? 'new entry' : 'edit entry'}</div>
|
||||
${state.error ? `<div class="error">${escapeHtml(state.error)}</div>` : ''}
|
||||
<div class="form-group">
|
||||
<label class="label" for="f-name">name *</label>
|
||||
<input id="f-name" type="text" value="${escapeHtml(existing?.name ?? '')}" placeholder="GitHub">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="label" for="f-url">url</label>
|
||||
<input id="f-url" type="text" value="${escapeHtml(existing?.url ?? '')}" placeholder="https://github.com/login">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="label" for="f-username">username</label>
|
||||
<input id="f-username" type="text" value="${escapeHtml(existing?.username ?? '')}" placeholder="alice@example.com">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="label" for="f-password">password</label>
|
||||
<div class="inline-row">
|
||||
<input id="f-password" type="password" value="${escapeHtml(existing?.password ?? '')}">
|
||||
<button class="btn" id="gen-btn" title="generate">gen</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="label" for="f-totp">totp secret</label>
|
||||
<input id="f-totp" type="text" value="${escapeHtml(existing?.totp_secret ?? '')}" placeholder="JBSWY3DPEHPK3PXP">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="label" for="f-group">group</label>
|
||||
<input id="f-group" type="text" value="${escapeHtml(existing?.group ?? '')}" placeholder="work">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="label" for="f-notes">notes</label>
|
||||
<textarea id="f-notes" placeholder="recovery codes, security questions...">${escapeHtml(existing?.notes ?? '')}</textarea>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button class="btn" id="cancel-btn">cancel</button>
|
||||
<button class="btn btn-primary" id="save-btn">${state.loading ? '<span class="spinner"></span>' : 'save'}</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// --- Generate password ---
|
||||
document.getElementById('gen-btn')?.addEventListener('click', async () => {
|
||||
const resp = await sendMessage({ type: 'generate_password', length: 24 });
|
||||
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.
|
||||
}
|
||||
});
|
||||
|
||||
// --- Cancel ---
|
||||
document.getElementById('cancel-btn')?.addEventListener('click', () => {
|
||||
if (mode === 'edit' && state.selectedId && state.selectedEntry) {
|
||||
navigate('detail');
|
||||
} else {
|
||||
navigate('list');
|
||||
}
|
||||
});
|
||||
|
||||
// --- 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 });
|
||||
}
|
||||
});
|
||||
|
||||
// --- 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');
|
||||
}
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', escHandler);
|
||||
|
||||
// Focus the name field.
|
||||
(document.getElementById('f-name') as HTMLInputElement)?.focus();
|
||||
}
|
||||
@@ -1,176 +0,0 @@
|
||||
/// Entry list view — search bar, group tabs, scrollable entry list with keyboard nav.
|
||||
|
||||
import { getState, setState, sendMessage, navigate, escapeHtml } from '../popup';
|
||||
import type { ManifestEntry } 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 '';
|
||||
}
|
||||
}
|
||||
|
||||
/// Derive unique group names from the current entries.
|
||||
function getGroups(entries: Array<[string, ManifestEntry]>): string[] {
|
||||
const groups = new Set<string>();
|
||||
for (const [, e] of entries) {
|
||||
if (e.group) groups.add(e.group);
|
||||
}
|
||||
return Array.from(groups).sort();
|
||||
}
|
||||
|
||||
export function renderEntryList(app: HTMLElement): void {
|
||||
const state = getState();
|
||||
const groups = getGroups(state.entries);
|
||||
const filtered = getFilteredEntries();
|
||||
|
||||
const groupTabsHtml = groups.length > 0
|
||||
? `<div class="group-tabs">
|
||||
<button class="group-tab ${!state.activeGroup ? 'active' : ''}" data-group="">all</button>
|
||||
${groups.map(g =>
|
||||
`<button class="group-tab ${state.activeGroup === g ? 'active' : ''}" data-group="${escapeHtml(g)}">${escapeHtml(g)}</button>`
|
||||
).join('')}
|
||||
</div>`
|
||||
: '';
|
||||
|
||||
const entriesHtml = filtered.length > 0
|
||||
? filtered.map(([id, e], i) => `
|
||||
<div class="entry-row ${i === state.selectedIndex ? 'selected' : ''}" data-id="${escapeHtml(id)}" data-index="${i}">
|
||||
<span class="entry-name">${escapeHtml(e.name)}</span>
|
||||
<span class="entry-meta">${escapeHtml(e.username ?? '')}${e.username && e.url ? ' · ' : ''}${escapeHtml(domainOf(e.url))}</span>
|
||||
</div>
|
||||
`).join('')
|
||||
: '<div class="empty">no entries</div>';
|
||||
|
||||
app.innerHTML = `
|
||||
<div class="search-bar">
|
||||
<input type="text" id="search-input" placeholder="/ search..." value="${escapeHtml(state.searchQuery)}">
|
||||
</div>
|
||||
${groupTabsHtml}
|
||||
<div class="entry-list" id="entry-list">
|
||||
${entriesHtml}
|
||||
</div>
|
||||
<div class="keyhints">
|
||||
<span><kbd>/</kbd> search</span>
|
||||
<span><kbd>+</kbd> add</span>
|
||||
<span><kbd>↑↓</kbd> nav</span>
|
||||
<span><kbd>Enter</kbd> open</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// --- Event listeners ---
|
||||
|
||||
const searchInput = document.getElementById('search-input') as HTMLInputElement;
|
||||
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 });
|
||||
});
|
||||
});
|
||||
|
||||
// Entry 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);
|
||||
});
|
||||
});
|
||||
|
||||
// Keyboard navigation.
|
||||
document.addEventListener('keydown', handleListKeydown);
|
||||
|
||||
// Focus search on / key (unless already focused).
|
||||
searchInput?.focus();
|
||||
}
|
||||
|
||||
async function openEntry(id: string): Promise<void> {
|
||||
setState({ loading: true });
|
||||
const resp = await sendMessage({ type: 'get_entry', id });
|
||||
if (resp.ok) {
|
||||
const data = resp.data as { entry: import('../../shared/types').Entry };
|
||||
navigate('detail', {
|
||||
selectedId: id,
|
||||
selectedEntry: data.entry,
|
||||
});
|
||||
} else {
|
||||
setState({ loading: false, error: resp.error });
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute the visible (filtered) entry list from current state.
|
||||
function getFilteredEntries(): Array<[string, ManifestEntry]> {
|
||||
const state = getState();
|
||||
let filtered = state.entries;
|
||||
if (state.activeGroup) {
|
||||
const g = state.activeGroup.toLowerCase();
|
||||
filtered = filtered.filter(([, e]) => e.group?.toLowerCase() === g);
|
||||
}
|
||||
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;
|
||||
return false;
|
||||
});
|
||||
}
|
||||
filtered.sort((a, b) => a[1].name.localeCompare(b[1].name));
|
||||
return filtered;
|
||||
}
|
||||
|
||||
function handleListKeydown(e: KeyboardEvent): void {
|
||||
const state = getState();
|
||||
const target = e.target as HTMLElement;
|
||||
const isSearch = target.id === 'search-input';
|
||||
|
||||
if (e.key === '/' && !isSearch) {
|
||||
e.preventDefault();
|
||||
(document.getElementById('search-input') as HTMLInputElement)?.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === '+' && !isSearch) {
|
||||
e.preventDefault();
|
||||
navigate('add');
|
||||
return;
|
||||
}
|
||||
|
||||
const filtered = getFilteredEntries();
|
||||
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
const max = Math.max(filtered.length - 1, 0);
|
||||
setState({ selectedIndex: Math.min(state.selectedIndex + 1, max) });
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setState({ selectedIndex: Math.max(state.selectedIndex - 1, 0) });
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'Enter' && !isSearch) {
|
||||
e.preventDefault();
|
||||
if (filtered[state.selectedIndex]) {
|
||||
openEntry(filtered[state.selectedIndex][0]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
document.removeEventListener('keydown', handleListKeydown);
|
||||
window.close();
|
||||
return;
|
||||
}
|
||||
}
|
||||
374
extension/src/popup/components/item-detail.ts
Normal file
@@ -0,0 +1,374 @@
|
||||
/// 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 { Item, ItemId, ManifestEntry, LoginCore, TotpConfig } from '../../shared/types';
|
||||
|
||||
let totpInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
function stopTotpTimer(): void {
|
||||
if (totpInterval !== null) {
|
||||
clearInterval(totpInterval);
|
||||
totpInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function copyToClipboard(text: string): Promise<void> {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
} catch {
|
||||
// Fallback for older browsers.
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = text;
|
||||
ta.style.position = 'fixed';
|
||||
ta.style.left = '-9999px';
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(ta);
|
||||
}
|
||||
}
|
||||
|
||||
export function renderItemDetail(app: HTMLElement): void {
|
||||
const state = getState();
|
||||
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 = `
|
||||
<div class="detail-header">
|
||||
<span class="detail-title">${escapeHtml(item.title)}</span>
|
||||
<button class="btn" id="back-btn" style="font-size:11px;">esc</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
if (core.url) {
|
||||
html += `
|
||||
<div class="field">
|
||||
<div class="label">url</div>
|
||||
<div class="field-value"><a href="${escapeHtml(core.url)}" target="_blank" rel="noopener noreferrer" style="color:#58a6ff; text-decoration:none;">${escapeHtml(core.url)}</a></div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
if (core.username) {
|
||||
html += `
|
||||
<div class="field">
|
||||
<div class="label">username</div>
|
||||
<div class="field-value" id="username-val" style="cursor:pointer;" title="click to copy">${escapeHtml(core.username)}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
html += `
|
||||
<div class="field">
|
||||
<div class="label">password</div>
|
||||
<div class="field-value" id="password-val" style="cursor:pointer;" title="click to toggle">
|
||||
<span id="password-display">********</span>
|
||||
<button class="btn" id="password-copy" style="font-size:10px; margin-left:8px; padding:2px 6px;">copy</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
if (hasTotp) {
|
||||
html += `
|
||||
<div class="field">
|
||||
<div class="label">totp</div>
|
||||
<div class="totp-code" id="totp-code">------</div>
|
||||
<div class="totp-bar"><div class="totp-bar-fill" id="totp-bar-fill" style="width:100%;"></div></div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
if (item.notes) {
|
||||
html += `
|
||||
<div class="field">
|
||||
<div class="label">notes</div>
|
||||
<div class="field-value" style="white-space:pre-wrap;">${escapeHtml(item.notes)}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
if (item.group) {
|
||||
html += `
|
||||
<div class="field">
|
||||
<div class="label">group</div>
|
||||
<div class="field-value">${escapeHtml(item.group)}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
html += `
|
||||
<div class="field">
|
||||
<div class="muted">modified ${escapeHtml(new Date(item.modified * 1000).toISOString())}</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
html += `
|
||||
<div class="form-actions" style="padding:8px 12px;">
|
||||
<button class="btn btn-primary" id="fill-btn">autofill</button>
|
||||
<button class="btn" id="edit-btn">edit</button>
|
||||
<button class="btn btn-danger" id="trash-btn" style="margin-left:auto;">trash</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
html += `
|
||||
<div class="keyhints">
|
||||
<span><kbd>c</kbd> copy user</span>
|
||||
<span><kbd>p</kbd> copy pass</span>
|
||||
${hasTotp ? '<span><kbd>t</kbd> copy totp</span>' : ''}
|
||||
<span><kbd>f</kbd> autofill</span>
|
||||
<span><kbd>e</kbd> edit</span>
|
||||
<span><kbd>d</kbd> trash</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
app.innerHTML = html;
|
||||
|
||||
// --- Password toggle ---
|
||||
let passwordVisible = false;
|
||||
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;
|
||||
if (passwordDisplay) passwordDisplay.textContent = passwordVisible ? password : '********';
|
||||
});
|
||||
document.getElementById('password-copy')?.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
await copyToClipboard(password);
|
||||
});
|
||||
|
||||
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 (hasTotp) {
|
||||
void refreshTotp(item.id);
|
||||
totpInterval = setInterval(() => { void refreshTotp(item.id); }, 1000);
|
||||
}
|
||||
|
||||
// --- Keyboard shortcuts ---
|
||||
const handler = async (e: KeyboardEvent) => {
|
||||
// Bail if the user is typing into any editable field — don't steal
|
||||
// printable keystrokes meant for an input/textarea/contenteditable element.
|
||||
const t = e.target;
|
||||
if (t instanceof HTMLElement) {
|
||||
const tag = t.tagName;
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || t.isContentEditable) return;
|
||||
}
|
||||
|
||||
switch (e.key) {
|
||||
case 'Escape':
|
||||
document.removeEventListener('keydown', handler);
|
||||
goBack();
|
||||
break;
|
||||
|
||||
case 'c':
|
||||
if (core.username) await copyToClipboard(core.username);
|
||||
break;
|
||||
|
||||
case 'p':
|
||||
await copyToClipboard(password);
|
||||
break;
|
||||
|
||||
case 't':
|
||||
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',
|
||||
id: item.id,
|
||||
capturedTabId,
|
||||
capturedUrl,
|
||||
});
|
||||
if (!resp.ok) setState({ error: resp.error });
|
||||
else window.close();
|
||||
break;
|
||||
}
|
||||
|
||||
case 'e':
|
||||
document.removeEventListener('keydown', handler);
|
||||
stopTotpTimer();
|
||||
navigate('edit');
|
||||
break;
|
||||
|
||||
case 'd':
|
||||
e.preventDefault();
|
||||
showDeleteConfirm(item.id, item.title, handler);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handler);
|
||||
}
|
||||
|
||||
async function refreshTotp(id: ItemId): Promise<void> {
|
||||
const resp = await sendMessage({ type: 'get_totp', id });
|
||||
if (resp.ok) {
|
||||
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) {
|
||||
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 = `
|
||||
<div class="detail-header">
|
||||
<span class="detail-title">${escapeHtml(item.title)}</span>
|
||||
<button class="btn" id="back-btn" style="font-size:11px;">esc</button>
|
||||
</div>
|
||||
<div class="pad" style="text-align:center; padding:32px 16px;">
|
||||
<div style="font-size:32px; margin-bottom:12px;">${typeEmoji(item.type)}</div>
|
||||
<div style="font-size:14px; color:#c9d1d9; margin-bottom:4px;">${escapeHtml(item.type.replace('_', ' '))}</div>
|
||||
<p class="muted">read/write for this type is coming in a later slice.</p>
|
||||
<p class="muted" style="margin-top:8px;">use the CLI for now.</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
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 item list.
|
||||
void sendMessage({ type: 'list_items' }).then(resp => {
|
||||
if (resp.ok) {
|
||||
const data = resp.data as { items: Array<[ItemId, ManifestEntry]> };
|
||||
navigate('list', {
|
||||
entries: data.items,
|
||||
selectedId: null,
|
||||
selectedItem: null,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function showDeleteConfirm(id: ItemId, title: string, parentHandler: (e: KeyboardEvent) => void): void {
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'confirm-overlay';
|
||||
overlay.innerHTML = `
|
||||
<div class="confirm-box">
|
||||
<p>Trash <strong>${escapeHtml(title)}</strong>?</p>
|
||||
<button class="btn" id="cancel-delete">cancel</button>
|
||||
<button class="btn btn-danger" id="confirm-delete">trash</button>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
document.getElementById('cancel-delete')?.addEventListener('click', () => {
|
||||
overlay.remove();
|
||||
});
|
||||
|
||||
document.getElementById('confirm-delete')?.addEventListener('click', async () => {
|
||||
overlay.remove();
|
||||
setState({ loading: true });
|
||||
const resp = await sendMessage({ type: 'delete_item', id });
|
||||
if (resp.ok) {
|
||||
document.removeEventListener('keydown', parentHandler);
|
||||
stopTotpTimer();
|
||||
goBack();
|
||||
} else {
|
||||
setState({ loading: false, error: resp.error });
|
||||
}
|
||||
});
|
||||
}
|
||||
281
extension/src/popup/components/item-form.ts
Normal file
@@ -0,0 +1,281 @@
|
||||
/// 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 {
|
||||
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.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 = `
|
||||
<div class="pad">
|
||||
<div class="detail-title" style="margin-bottom:16px;">${escapeHtml(type.replace('_', ' '))}</div>
|
||||
<p class="muted">editing ${escapeHtml(type)} items is coming in a later slice.</p>
|
||||
<p class="muted" style="margin-top:8px;">use the CLI for now.</p>
|
||||
<div class="form-actions">
|
||||
<button class="btn" id="back-btn">back</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
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 = `
|
||||
<div class="pad">
|
||||
<div class="detail-title" style="margin-bottom:16px;">${mode === 'add' ? 'new login' : 'edit login'}</div>
|
||||
${state.error ? `<div class="error">${escapeHtml(state.error)}</div>` : ''}
|
||||
<div class="form-group">
|
||||
<label class="label" for="f-title">title *</label>
|
||||
<input id="f-title" type="text" value="${escapeHtml(title)}" placeholder="GitHub">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="label" for="f-url">url</label>
|
||||
<input id="f-url" type="text" value="${escapeHtml(url)}" placeholder="https://github.com/login">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="label" for="f-username">username</label>
|
||||
<input id="f-username" type="text" value="${escapeHtml(username)}" placeholder="alice@example.com">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="label" for="f-password">password</label>
|
||||
<div class="inline-row">
|
||||
<input id="f-password" type="password" value="${escapeHtml(password)}">
|
||||
<button class="btn" id="gen-btn" title="generate">gen</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="label" for="f-totp">totp secret (base32)</label>
|
||||
<input id="f-totp" type="text" value="${escapeHtml(totpStr)}" placeholder="JBSWY3DPEHPK3PXP">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="label" for="f-group">group</label>
|
||||
<input id="f-group" type="text" value="${escapeHtml(group)}" placeholder="work">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="label" for="f-notes">notes</label>
|
||||
<textarea id="f-notes" placeholder="recovery codes, security questions...">${escapeHtml(notes)}</textarea>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button class="btn" id="cancel-btn">cancel</button>
|
||||
<button class="btn btn-primary" id="save-btn">${state.loading ? '<span class="spinner"></span>' : 'save'}</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// --- Generate password ---
|
||||
document.getElementById('gen-btn')?.addEventListener('click', async () => {
|
||||
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', () => goBack(mode));
|
||||
|
||||
// --- Save ---
|
||||
document.getElementById('save-btn')?.addEventListener('click', async () => {
|
||||
await saveLogin(mode, existing);
|
||||
});
|
||||
|
||||
// --- Escape to cancel ---
|
||||
const escHandler = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
document.removeEventListener('keydown', escHandler);
|
||||
goBack(mode);
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', escHandler);
|
||||
|
||||
// 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');
|
||||
}
|
||||
}
|
||||
|
||||
/// Normalize a URL input so the Rust-side `url::Url::parse` accepts it.
|
||||
///
|
||||
/// Prepends `https://` when the input looks like a bare host (no scheme),
|
||||
/// then validates via the JS URL constructor. Returns { ok, value, error }.
|
||||
function normalizeUrl(raw: string): { ok: true; value: string } | { ok: false; error: string } {
|
||||
if (!raw) return { ok: true, value: '' };
|
||||
const trimmed = raw.trim();
|
||||
// If it already has a scheme, pass through. Otherwise assume https://.
|
||||
const candidate = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(trimmed)
|
||||
? trimmed
|
||||
: `https://${trimmed}`;
|
||||
try {
|
||||
const u = new URL(candidate);
|
||||
// url::Url rejects schemes without an authority (host). Require a host.
|
||||
if (!u.host) return { ok: false, error: 'URL must include a host (e.g. https://example.com)' };
|
||||
return { ok: true, value: u.toString() };
|
||||
} catch {
|
||||
return { ok: false, error: 'URL is not valid — try something like https://example.com' };
|
||||
}
|
||||
}
|
||||
|
||||
async function saveLogin(mode: 'add' | 'edit', existing: Item | null): Promise<void> {
|
||||
const state = getState();
|
||||
|
||||
const title = (document.getElementById('f-title') as HTMLInputElement).value.trim();
|
||||
const rawUrl = (document.getElementById('f-url') as HTMLInputElement).value;
|
||||
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;
|
||||
}
|
||||
|
||||
const urlResult = normalizeUrl(rawUrl);
|
||||
if (!urlResult.ok) {
|
||||
setState({ error: urlResult.error });
|
||||
return;
|
||||
}
|
||||
const url = urlResult.value;
|
||||
|
||||
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 });
|
||||
}
|
||||
}
|
||||
217
extension/src/popup/components/item-list.ts
Normal file
@@ -0,0 +1,217 @@
|
||||
/// 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 { ItemId, ItemType, ManifestEntry, Item } from '../../shared/types';
|
||||
|
||||
/// 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 '';
|
||||
}
|
||||
|
||||
/// 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 '⏱';
|
||||
}
|
||||
}
|
||||
|
||||
export function renderItemList(app: HTMLElement): void {
|
||||
const state = getState();
|
||||
const filtered = getFilteredEntries();
|
||||
|
||||
const rowsHtml = filtered.length > 0
|
||||
? filtered.map(([id, e], i) => `
|
||||
<div class="entry-row ${i === state.selectedIndex ? 'selected' : ''}" data-id="${escapeHtml(id)}" data-index="${i}">
|
||||
<span class="entry-name"><span class="type-icon" aria-hidden="true">${typeIcon(e.type)}</span> ${escapeHtml(e.title)}</span>
|
||||
<span class="entry-meta">${escapeHtml(metaLine(e))}</span>
|
||||
</div>
|
||||
`).join('')
|
||||
: '<div class="empty">no items</div>';
|
||||
|
||||
app.innerHTML = `
|
||||
<div class="search-bar">
|
||||
<input type="text" id="search-input" placeholder="/ search..." value="${escapeHtml(state.searchQuery)}">
|
||||
</div>
|
||||
<div class="toolbar" style="display:flex; gap:4px; padding:6px 12px; border-bottom:1px solid #21262d;">
|
||||
<button class="btn" id="new-btn" style="font-size:11px;">+ new</button>
|
||||
<button class="btn" id="sync-btn" style="font-size:11px;">sync</button>
|
||||
<span style="flex:1;"></span>
|
||||
<button class="btn" id="settings-btn" style="font-size:11px;">settings</button>
|
||||
<button class="btn" id="lock-btn" style="font-size:11px;">lock</button>
|
||||
</div>
|
||||
<div class="entry-list" id="item-list">
|
||||
${rowsHtml}
|
||||
</div>
|
||||
<div class="keyhints">
|
||||
<span><kbd>/</kbd> search</span>
|
||||
<span><kbd>+</kbd> new</span>
|
||||
<span><kbd>↑↓</kbd> nav</span>
|
||||
<span><kbd>Enter</kbd> open</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// --- Event listeners ---
|
||||
|
||||
const searchInput = document.getElementById('search-input') as HTMLInputElement | null;
|
||||
searchInput?.addEventListener('input', () => {
|
||||
setState({ searchQuery: searchInput.value, 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 });
|
||||
}
|
||||
});
|
||||
|
||||
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!;
|
||||
document.removeEventListener('keydown', handleListKeydown);
|
||||
await openItem(id);
|
||||
});
|
||||
});
|
||||
|
||||
// Keyboard navigation.
|
||||
document.addEventListener('keydown', handleListKeydown);
|
||||
|
||||
// Focus search on open.
|
||||
searchInput?.focus();
|
||||
}
|
||||
|
||||
async function openItem(id: ItemId): Promise<void> {
|
||||
setState({ loading: true });
|
||||
const resp = await sendMessage({ type: 'get_item', id });
|
||||
if (resp.ok) {
|
||||
const data = resp.data as { item: Item };
|
||||
navigate('detail', {
|
||||
selectedId: id,
|
||||
selectedItem: data.item,
|
||||
});
|
||||
} else {
|
||||
setState({ loading: false, error: resp.error });
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute the visible (filtered) entry list from current state.
|
||||
function getFilteredEntries(): Array<[ItemId, ManifestEntry]> {
|
||||
const state = getState();
|
||||
// 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.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].title.localeCompare(b[1].title));
|
||||
return filtered;
|
||||
}
|
||||
|
||||
/// True if the event target is an editable field (input/textarea/contenteditable).
|
||||
/// Global shortcut handlers should bail when the user is typing into a field —
|
||||
/// otherwise printable characters like "/" and "+" get eaten by the shortcut
|
||||
/// routing and never reach the input.
|
||||
function isEditableTarget(target: EventTarget | null): boolean {
|
||||
if (!(target instanceof HTMLElement)) return false;
|
||||
const tag = target.tagName;
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true;
|
||||
if (target.isContentEditable) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function handleListKeydown(e: KeyboardEvent): void {
|
||||
const state = getState();
|
||||
const target = e.target as HTMLElement;
|
||||
const isSearch = target.id === 'search-input';
|
||||
|
||||
// If the user is typing into any input/textarea (other than the list's own
|
||||
// search field, which we want to focus on "/" even from outside it), let the
|
||||
// keystroke through. The "/" shortcut below is specifically "jump to search
|
||||
// from the list," not "steal printable characters while typing."
|
||||
if (isEditableTarget(target) && !isSearch) {
|
||||
if (e.key === 'Escape') {
|
||||
document.removeEventListener('keydown', handleListKeydown);
|
||||
window.close();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === '/' && !isSearch) {
|
||||
e.preventDefault();
|
||||
(document.getElementById('search-input') as HTMLInputElement | null)?.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === '+' && !isSearch) {
|
||||
e.preventDefault();
|
||||
document.removeEventListener('keydown', handleListKeydown);
|
||||
navigate('add');
|
||||
return;
|
||||
}
|
||||
|
||||
const filtered = getFilteredEntries();
|
||||
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
const max = Math.max(filtered.length - 1, 0);
|
||||
setState({ selectedIndex: Math.min(state.selectedIndex + 1, max) });
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setState({ selectedIndex: Math.max(state.selectedIndex - 1, 0) });
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'Enter' && !isSearch) {
|
||||
e.preventDefault();
|
||||
const selected = filtered[state.selectedIndex];
|
||||
if (selected) {
|
||||
document.removeEventListener('keydown', handleListKeydown);
|
||||
void openItem(selected[0]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
document.removeEventListener('keydown', handleListKeydown);
|
||||
window.close();
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
/// 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<void> {
|
||||
app.innerHTML = '<div class="pad" style="text-align:center; padding-top:20px;"><span class="spinner"></span></div>';
|
||||
@@ -12,8 +12,8 @@ export async function renderSettings(app: HTMLElement): Promise<void> {
|
||||
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
|
||||
|
||||
@@ -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 = `
|
||||
<div class="pad" style="padding-top:24px;text-align:center;">
|
||||
<img class="brand-logo" src="icons/relicario-logo.svg" alt="">
|
||||
<div class="brand" style="font-size:16px;margin-bottom:4px;">relicario</div>
|
||||
<p class="secondary" style="margin-bottom:20px;">two-factor vault</p>
|
||||
|
||||
<p class="muted" style="margin-bottom:16px;font-size:11px;line-height:1.6;">
|
||||
No vault configured yet. Open the setup wizard to
|
||||
create a new vault or connect to an existing one.
|
||||
</p>
|
||||
|
||||
<button class="btn btn-primary" id="open-setup-btn" style="width:100%;">
|
||||
open setup wizard
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.getElementById('open-setup-btn')?.addEventListener('click', () => {
|
||||
chrome.tabs.create({ url: chrome.runtime.getURL('setup.html') });
|
||||
window.close();
|
||||
});
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
/// 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();
|
||||
@@ -38,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 });
|
||||
}
|
||||
|
||||
@@ -4,12 +4,11 @@
|
||||
/// 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 { renderEntryList } from './components/entry-list';
|
||||
import { renderEntryDetail } from './components/entry-detail';
|
||||
import { renderEntryForm } from './components/entry-form';
|
||||
import { renderSetupWizard } from './components/setup-wizard';
|
||||
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 ---
|
||||
@@ -21,30 +20,38 @@ 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;
|
||||
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;
|
||||
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 = {
|
||||
view: 'locked',
|
||||
entries: [],
|
||||
selectedId: null,
|
||||
selectedEntry: null,
|
||||
selectedItem: null,
|
||||
selectedIndex: 0,
|
||||
searchQuery: '',
|
||||
activeGroup: null,
|
||||
error: null,
|
||||
loading: false,
|
||||
capturedTabId: null,
|
||||
capturedUrl: '',
|
||||
};
|
||||
|
||||
export function getState(): PopupState {
|
||||
@@ -61,11 +68,44 @@ export function setState(partial: Partial<PopupState>): void {
|
||||
export function sendMessage(request: Request): Promise<Response> {
|
||||
return new Promise((resolve) => {
|
||||
chrome.runtime.sendMessage(request, (response: Response) => {
|
||||
if (response && !response.ok && response.error) {
|
||||
// Replace cryptic low-level errors with user-readable messages.
|
||||
response = { ok: false, error: humanizeError(response.error) };
|
||||
}
|
||||
resolve(response);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Translate cryptic Rust/serde/WASM error strings into messages a user
|
||||
/// can act on. Unknown errors pass through unchanged.
|
||||
export function humanizeError(err: string): string {
|
||||
// URL parse failures (Rust `url::Url::parse`) bubble up through serde
|
||||
// as `item json: ...`. Match the core phrasing.
|
||||
if (/relative URL without a base/i.test(err)) {
|
||||
return 'URL must start with https:// or http:// (e.g. https://example.com)';
|
||||
}
|
||||
if (/item json:/i.test(err)) {
|
||||
return 'Could not save item — one of the fields is in an invalid format.';
|
||||
}
|
||||
if (/settings json:/i.test(err)) {
|
||||
return 'Settings are in an invalid format — try reloading the extension.';
|
||||
}
|
||||
if (/vault_locked/i.test(err)) {
|
||||
return 'Vault is locked. Unlock and try again.';
|
||||
}
|
||||
if (/origin_mismatch/i.test(err)) {
|
||||
return 'This login belongs to a different site — refusing to leak credentials cross-origin.';
|
||||
}
|
||||
if (/unauthorized_sender/i.test(err)) {
|
||||
return 'This action is not allowed from here.';
|
||||
}
|
||||
if (/tab_navigated|captured_tab_gone/i.test(err)) {
|
||||
return 'The browser tab changed before the fill could complete — try again.';
|
||||
}
|
||||
return err;
|
||||
}
|
||||
|
||||
// --- Navigation ---
|
||||
|
||||
export function navigate(view: View, extras?: Partial<PopupState>): void {
|
||||
@@ -79,23 +119,20 @@ function render(): void {
|
||||
if (!app) return;
|
||||
|
||||
switch (currentState.view) {
|
||||
case 'setup':
|
||||
renderSetupWizard(app);
|
||||
break;
|
||||
case 'locked':
|
||||
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);
|
||||
@@ -106,12 +143,20 @@ function render(): void {
|
||||
// --- Init ---
|
||||
|
||||
async function init(): Promise<void> {
|
||||
// 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) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -122,10 +167,10 @@ async function init(): Promise<void> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,34 +1,11 @@
|
||||
/// 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.
|
||||
/// 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 { Manifest, VaultConfig, SetupState, RelicarioSettings } from '../shared/types';
|
||||
import { DEFAULT_SETTINGS } from '../shared/types';
|
||||
import type { GitHost } from './git-host';
|
||||
import { createGitHost } from './git-host';
|
||||
import { base64ToUint8Array } from './git-host';
|
||||
import type { RouterState } from './router/index';
|
||||
import { route } from './router/index';
|
||||
import * as vault from './vault';
|
||||
|
||||
// --- 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<string, string> = 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.
|
||||
|
||||
// @ts-ignore TS2307 — resolved by webpack alias / copy
|
||||
import initDefault, { initSync } from '../../wasm/relicario_wasm.js';
|
||||
// @ts-ignore TS2307
|
||||
@@ -46,396 +23,48 @@ async function initWasm(): Promise<WasmModule> {
|
||||
&& 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 ---
|
||||
|
||||
async function loadConfig(): Promise<VaultConfig | null> {
|
||||
const result = await chrome.storage.local.get('vaultConfig');
|
||||
return (result.vaultConfig as VaultConfig) ?? null;
|
||||
}
|
||||
|
||||
async function loadImageBase64(): Promise<string | null> {
|
||||
const result = await chrome.storage.local.get('imageBase64');
|
||||
return (result.imageBase64 as string) ?? null;
|
||||
}
|
||||
|
||||
async function loadSetupState(): Promise<SetupState> {
|
||||
const config = await loadConfig();
|
||||
const imageBase64 = await loadImageBase64();
|
||||
return {
|
||||
config,
|
||||
imageBase64,
|
||||
isConfigured: config !== null && imageBase64 !== null,
|
||||
};
|
||||
}
|
||||
|
||||
// --- Settings & blacklist helpers ---
|
||||
|
||||
async function loadSettings(): Promise<RelicarioSettings> {
|
||||
const result = await chrome.storage.local.get('relicarioSettings');
|
||||
return (result.relicarioSettings as RelicarioSettings) ?? { ...DEFAULT_SETTINGS };
|
||||
}
|
||||
|
||||
async function saveSettings(settings: RelicarioSettings): Promise<void> {
|
||||
await chrome.storage.local.set({ relicarioSettings: settings });
|
||||
}
|
||||
|
||||
async function loadBlacklist(): Promise<string[]> {
|
||||
const result = await chrome.storage.local.get('captureBlacklist');
|
||||
return (result.captureBlacklist as string[]) ?? [];
|
||||
}
|
||||
|
||||
async function saveBlacklist(list: string[]): Promise<void> {
|
||||
await chrome.storage.local.set({ captureBlacklist: list });
|
||||
}
|
||||
|
||||
function ensureGitHost(config: VaultConfig): GitHost {
|
||||
if (!gitHost) {
|
||||
gitHost = createGitHost(config.hostType, config.hostUrl, config.repoPath, config.apiToken);
|
||||
}
|
||||
return gitHost;
|
||||
}
|
||||
|
||||
// --- Message handler ---
|
||||
// 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)
|
||||
.then(sendResponse)
|
||||
.catch((err: Error) => sendResponse({ ok: false, error: err.message }));
|
||||
// Return true to indicate async response.
|
||||
return true;
|
||||
(request: Request, sender: chrome.runtime.MessageSender, sendResponse: (r: Response) => void) => {
|
||||
(async () => {
|
||||
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((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
|
||||
},
|
||||
);
|
||||
|
||||
async function handleMessage(req: Request): Promise<Response> {
|
||||
switch (req.type) {
|
||||
// --- Auth ---
|
||||
|
||||
case 'is_unlocked':
|
||||
return { ok: true, data: { unlocked: masterKey !== 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);
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
case 'lock':
|
||||
masterKey = null;
|
||||
manifest = null;
|
||||
totpSecretCache.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 '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 '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' };
|
||||
}
|
||||
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}`,
|
||||
);
|
||||
|
||||
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}`,
|
||||
);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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 } };
|
||||
}
|
||||
|
||||
// --- 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);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
// --- Setup ---
|
||||
|
||||
case 'get_setup_state': {
|
||||
const state = await loadSetupState();
|
||||
return { ok: true, data: state };
|
||||
}
|
||||
|
||||
case 'save_setup': {
|
||||
await chrome.storage.local.set({
|
||||
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 'generate_password': {
|
||||
const w = await initWasm();
|
||||
const password = w.generate_password(req.length);
|
||||
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 'update_settings': {
|
||||
const current = await loadSettings();
|
||||
const updated = { ...current, ...req.settings };
|
||||
await saveSettings(updated);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
case 'get_blacklist': {
|
||||
const blacklist = await loadBlacklist();
|
||||
return { ok: true, data: { blacklist } };
|
||||
}
|
||||
|
||||
case 'remove_blacklist': {
|
||||
const bl = await loadBlacklist();
|
||||
await saveBlacklist(bl.filter((h) => h !== req.hostname));
|
||||
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 };
|
||||
}
|
||||
|
||||
// --- 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}` };
|
||||
}
|
||||
}
|
||||
|
||||
524
extension/src/service-worker/router/__tests__/router.test.ts
Normal file
@@ -0,0 +1,524 @@
|
||||
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<typeof import('../../vault')>();
|
||||
return {
|
||||
...actual,
|
||||
fetchAndDecryptItem: vi.fn(),
|
||||
fetchAndDecryptSettings: vi.fn(),
|
||||
encryptAndWriteSettings: vi.fn(),
|
||||
encryptAndWriteItem: vi.fn(),
|
||||
encryptAndWriteManifest: 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 ---
|
||||
|
||||
// @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');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// --- 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<typeof vi.fn>).mockReset();
|
||||
(chrome.tabs.sendMessage as ReturnType<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).mockResolvedValue({
|
||||
id: 42,
|
||||
url: 'https://example.com/login',
|
||||
});
|
||||
vi.mocked(vault.fetchAndDecryptItem).mockResolvedValue(
|
||||
loginItem('https://example.com/login'),
|
||||
);
|
||||
(chrome.tabs.sendMessage as ReturnType<typeof vi.fn>).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',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// --- 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('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(
|
||||
{
|
||||
type: 'fill_credentials',
|
||||
id: 'cccccccccccccccc',
|
||||
capturedTabId: 42,
|
||||
capturedUrl: 'https://example.com/',
|
||||
},
|
||||
state,
|
||||
makeSetupSender(),
|
||||
);
|
||||
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 ---
|
||||
|
||||
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' });
|
||||
});
|
||||
});
|
||||
|
||||
// --- 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();
|
||||
});
|
||||
});
|
||||
204
extension/src/service-worker/router/content-callable.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
/// 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 { Item, 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;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
wasm: any;
|
||||
}
|
||||
|
||||
export async function handle(
|
||||
msg: ContentMessage,
|
||||
state: ContentState,
|
||||
sender: chrome.runtime.MessageSender,
|
||||
): Promise<Response> {
|
||||
const senderHost = safeHostname(sender.tab?.url ?? '');
|
||||
if (!senderHost) return { ok: false, error: 'invalid_sender_url' };
|
||||
|
||||
switch (msg.type) {
|
||||
case 'get_autofill_candidates': {
|
||||
if (!state.manifest) return { ok: false, error: 'vault_locked' };
|
||||
return {
|
||||
ok: true,
|
||||
data: { candidates: vault.findByHostname(state.manifest, senderHost) },
|
||||
};
|
||||
}
|
||||
|
||||
case 'get_credentials': {
|
||||
const handle = session.getCurrent();
|
||||
if (!handle || !state.gitHost || !state.manifest) return { ok: false, error: 'vault_locked' };
|
||||
|
||||
const item = await vault.fetchAndDecryptItem(state.gitHost, handle, msg.id);
|
||||
if (item.core.type !== 'login') return { ok: false, error: 'not_a_login' };
|
||||
const itemHost = safeHostname(item.core.url ?? '');
|
||||
if (!itemHost || itemHost !== senderHost) return { ok: false, error: 'origin_mismatch' };
|
||||
|
||||
// TOFU origin-ack check (VaultSettings.autofill_origin_acks):
|
||||
const settings = await vault.fetchAndDecryptSettings(state.gitHost, handle);
|
||||
const acks = settings.autofill_origin_acks ?? {};
|
||||
if (!(senderHost in acks)) {
|
||||
return { ok: true, data: { requires_ack: true, hostname: senderHost } };
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
data: {
|
||||
username: item.core.username ?? '',
|
||||
password: item.core.password ?? '',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
case 'check_credential': {
|
||||
const handle = session.getCurrent();
|
||||
if (!handle || !state.gitHost || !state.manifest) {
|
||||
return { ok: true, data: { action: 'skip' } };
|
||||
}
|
||||
|
||||
// Settings-gating: capture off or site blacklisted → skip.
|
||||
const captureSettings = await loadDeviceSettings();
|
||||
if (!captureSettings.captureEnabled) return { ok: true, data: { action: 'skip' } };
|
||||
|
||||
const blacklist = await loadBlacklist();
|
||||
if (blacklist.includes(senderHost)) return { ok: true, data: { action: 'skip' } };
|
||||
|
||||
const candidates = vault.findByHostname(state.manifest, senderHost);
|
||||
if (candidates.length === 0) return { ok: true, data: { action: 'save' } };
|
||||
|
||||
for (const [itemId, entry] of candidates) {
|
||||
if (entry.type !== 'login') continue;
|
||||
const full = await vault.fetchAndDecryptItem(state.gitHost, handle, itemId);
|
||||
if (full.core.type !== 'login') continue;
|
||||
if (full.core.username === msg.username) {
|
||||
if (full.core.password === msg.password) return { ok: true, data: { action: 'skip' } };
|
||||
return { ok: true, data: { action: 'update', entryId: itemId, entryName: entry.title } };
|
||||
}
|
||||
}
|
||||
return { ok: true, data: { action: 'save' } };
|
||||
}
|
||||
|
||||
case 'blacklist_site': {
|
||||
const bl = await loadBlacklist();
|
||||
if (!bl.includes(senderHost)) {
|
||||
bl.push(senderHost);
|
||||
await saveBlacklist(bl);
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
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' })
|
||||
?? { captureEnabled: false, captureStyle: 'bar' };
|
||||
}
|
||||
|
||||
async function loadBlacklist(): Promise<string[]> {
|
||||
const r = await chrome.storage.local.get('captureBlacklist');
|
||||
return (r.captureBlacklist as string[]) ?? [];
|
||||
}
|
||||
|
||||
async function saveBlacklist(list: string[]): Promise<void> {
|
||||
await chrome.storage.local.set({ captureBlacklist: list });
|
||||
}
|
||||
|
||||
function safeHostname(url: string): string | undefined {
|
||||
try { return new URL(url).hostname; } catch { return undefined; }
|
||||
}
|
||||
70
extension/src/service-worker/router/index.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
/// 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 { 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';
|
||||
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;
|
||||
}
|
||||
|
||||
/// 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<PopupMessage['type']> = new Set<PopupMessage['type']>([
|
||||
'save_setup',
|
||||
'rate_passphrase',
|
||||
'is_unlocked',
|
||||
]);
|
||||
|
||||
export async function route(
|
||||
msg: Request,
|
||||
state: RouterState,
|
||||
sender: chrome.runtime.MessageSender,
|
||||
): Promise<Response> {
|
||||
const popupUrl = chrome.runtime.getURL('popup.html');
|
||||
const setupUrl = chrome.runtime.getURL('setup.html');
|
||||
const senderUrl = sender.url ?? '';
|
||||
|
||||
const isPopup = senderUrl === popupUrl;
|
||||
const isSetup = senderUrl.startsWith(setupUrl);
|
||||
const isContent = sender.tab !== undefined
|
||||
&& sender.frameId === 0
|
||||
&& sender.id === chrome.runtime.id;
|
||||
|
||||
if (POPUP_ONLY_TYPES.has(msg.type as never)) {
|
||||
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) {
|
||||
// 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' };
|
||||
}
|
||||
273
extension/src/service-worker/router/popup-only.ts
Normal file
@@ -0,0 +1,273 @@
|
||||
/// Popup-callable message handlers.
|
||||
///
|
||||
/// Every export here assumes the router has already verified sender identity
|
||||
/// via sender.url === popup.html (or setup.html for save_setup).
|
||||
|
||||
import type { PopupMessage, Response } from '../../shared/messages';
|
||||
import type { Item, ItemId, Manifest, VaultConfig, SetupState, DeviceSettings } from '../../shared/types';
|
||||
import { DEFAULT_DEVICE_SETTINGS } from '../../shared/types';
|
||||
import type { GitHost } from '../git-host';
|
||||
import { createGitHost, base64ToUint8Array } from '../git-host';
|
||||
import * as vault from '../vault';
|
||||
import * as session from '../session';
|
||||
|
||||
// --- Shared ambient state owned by the SW module ---
|
||||
//
|
||||
// The router keeps these on a single `state` object and injects it into the
|
||||
// handler so testing can mock them without reaching for globals.
|
||||
|
||||
export interface PopupState {
|
||||
manifest: Manifest | null;
|
||||
gitHost: GitHost | null;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
wasm: any;
|
||||
}
|
||||
|
||||
export async function handle(
|
||||
msg: PopupMessage,
|
||||
state: PopupState,
|
||||
sender: chrome.runtime.MessageSender,
|
||||
): Promise<Response> {
|
||||
void sender; // unused in most branches; retained for symmetry with content-callable
|
||||
switch (msg.type) {
|
||||
case 'is_unlocked':
|
||||
return { ok: true, data: { unlocked: session.getCurrent() !== null } };
|
||||
|
||||
case 'unlock': {
|
||||
const w = state.wasm;
|
||||
const config = await loadConfig();
|
||||
if (!config) return { ok: false, error: 'Extension not configured. Run setup first.' };
|
||||
const imageB64 = await loadImageBase64();
|
||||
if (!imageB64) return { ok: false, error: 'Reference image not set. Run setup first.' };
|
||||
|
||||
const imageBytes = base64ToUint8Array(imageB64);
|
||||
if (!state.gitHost) state.gitHost = createGitHost(config.hostType, config.hostUrl, config.repoPath, config.apiToken);
|
||||
const meta = await vault.fetchVaultMeta(state.gitHost);
|
||||
|
||||
const handle = w.unlock(msg.passphrase, imageBytes, meta.salt, meta.paramsJson);
|
||||
session.setCurrent(handle);
|
||||
(msg as { passphrase: string }).passphrase = '';
|
||||
|
||||
state.manifest = await vault.fetchAndDecryptManifest(state.gitHost, handle);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
case 'lock':
|
||||
session.clearCurrent();
|
||||
state.manifest = null;
|
||||
return { ok: true };
|
||||
|
||||
case 'list_items': {
|
||||
if (!state.manifest) return { ok: false, error: 'vault_locked' };
|
||||
return { ok: true, data: { items: vault.listItems(state.manifest, msg.group) } };
|
||||
}
|
||||
|
||||
case 'get_item': {
|
||||
const handle = session.getCurrent();
|
||||
if (!handle || !state.gitHost) return { ok: false, error: 'vault_locked' };
|
||||
const item = await vault.fetchAndDecryptItem(state.gitHost, handle, msg.id);
|
||||
return { ok: true, data: { item } };
|
||||
}
|
||||
|
||||
case 'add_item': {
|
||||
const handle = session.getCurrent();
|
||||
if (!handle || !state.gitHost || !state.manifest) return { ok: false, error: 'vault_locked' };
|
||||
const id = state.wasm.new_item_id();
|
||||
const item: Item = { ...msg.item, id };
|
||||
await vault.encryptAndWriteItem(state.gitHost, handle, id, item, `add: ${item.title}`);
|
||||
state.manifest.items[id] = itemToManifestEntry(item);
|
||||
await vault.encryptAndWriteManifest(state.gitHost, handle, state.manifest, `manifest: add ${item.title}`);
|
||||
return { ok: true, data: { id } };
|
||||
}
|
||||
|
||||
case 'update_item': {
|
||||
const handle = session.getCurrent();
|
||||
if (!handle || !state.gitHost || !state.manifest) return { ok: false, error: 'vault_locked' };
|
||||
await vault.encryptAndWriteItem(state.gitHost, handle, msg.id, msg.item, `update: ${msg.item.title}`);
|
||||
state.manifest.items[msg.id] = itemToManifestEntry(msg.item);
|
||||
await vault.encryptAndWriteManifest(state.gitHost, handle, state.manifest, `manifest: update ${msg.item.title}`);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
case 'delete_item': {
|
||||
const handle = session.getCurrent();
|
||||
if (!handle || !state.gitHost || !state.manifest) return { ok: false, error: 'vault_locked' };
|
||||
const entry = state.manifest.items[msg.id];
|
||||
if (!entry) return { ok: false, error: 'item_not_found' };
|
||||
const item = await vault.fetchAndDecryptItem(state.gitHost, handle, msg.id);
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const updated: Item = { ...item, trashed_at: now, modified: now };
|
||||
await vault.encryptAndWriteItem(state.gitHost, handle, msg.id, updated, `trash: ${entry.title}`);
|
||||
state.manifest.items[msg.id] = { ...entry, trashed_at: now, modified: now };
|
||||
await vault.encryptAndWriteManifest(state.gitHost, handle, state.manifest, `manifest: trash ${entry.title}`);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
case 'get_totp': {
|
||||
const handle = session.getCurrent();
|
||||
if (!handle || !state.gitHost) return { ok: false, error: 'vault_locked' };
|
||||
const item = await vault.fetchAndDecryptItem(state.gitHost, handle, msg.id);
|
||||
if (item.core.type !== 'login' || !item.core.totp) {
|
||||
return { ok: false, error: 'no_totp' };
|
||||
}
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const code = state.wasm.totp_compute(JSON.stringify(item.core.totp), BigInt(now));
|
||||
return { ok: true, data: { code: code.code, expires_at: code.expires_at } };
|
||||
}
|
||||
|
||||
case 'sync': {
|
||||
const handle = session.getCurrent();
|
||||
if (!handle || !state.gitHost) return { ok: false, error: 'vault_locked' };
|
||||
state.manifest = await vault.fetchAndDecryptManifest(state.gitHost, handle);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
case 'get_setup_state':
|
||||
return { ok: true, data: await loadSetupState() };
|
||||
|
||||
case 'save_setup': {
|
||||
await chrome.storage.local.set({
|
||||
vaultConfig: msg.config,
|
||||
imageBase64: msg.imageBase64,
|
||||
});
|
||||
state.gitHost = null;
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
case 'rate_passphrase':
|
||||
return { ok: true, data: state.wasm.rate_passphrase(msg.passphrase) };
|
||||
|
||||
case 'generate_password': {
|
||||
const password = state.wasm.generate_password(JSON.stringify(msg.request));
|
||||
return { ok: true, data: { password } };
|
||||
}
|
||||
|
||||
case 'fill_credentials':
|
||||
return handleFillCredentials(msg, state);
|
||||
|
||||
case 'ack_autofill_origin': {
|
||||
const handle = session.getCurrent();
|
||||
if (!handle || !state.gitHost) return { ok: false, error: 'vault_locked' };
|
||||
const settings = await vault.fetchAndDecryptSettings(state.gitHost, handle);
|
||||
const acks = { ...(settings.autofill_origin_acks ?? {}), [msg.hostname]: Math.floor(Date.now() / 1000) };
|
||||
const updated = { ...settings, autofill_origin_acks: acks };
|
||||
await vault.encryptAndWriteSettings(state.gitHost, handle, updated, `settings: ack origin ${msg.hostname}`);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
case 'get_settings':
|
||||
return { ok: true, data: { settings: await loadDeviceSettings() } };
|
||||
|
||||
case 'update_settings': {
|
||||
const current = await loadDeviceSettings();
|
||||
await saveDeviceSettings({ ...current, ...msg.settings });
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
case 'get_blacklist':
|
||||
return { ok: true, data: { blacklist: await loadBlacklist() } };
|
||||
|
||||
case 'remove_blacklist': {
|
||||
const bl = await loadBlacklist();
|
||||
await saveBlacklist(bl.filter((h) => h !== msg.hostname));
|
||||
return { ok: true };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- fill_credentials with captured-tab verification (audit M5) ---
|
||||
|
||||
async function handleFillCredentials(
|
||||
msg: Extract<PopupMessage, { type: 'fill_credentials' }>,
|
||||
state: PopupState,
|
||||
): Promise<Response> {
|
||||
const handle = session.getCurrent();
|
||||
if (!handle || !state.gitHost || !state.manifest) return { ok: false, error: 'vault_locked' };
|
||||
|
||||
let tab: chrome.tabs.Tab;
|
||||
try { tab = await chrome.tabs.get(msg.capturedTabId); }
|
||||
catch { return { ok: false, error: 'captured_tab_gone' }; }
|
||||
|
||||
const currentHost = safeHostname(tab.url ?? '');
|
||||
const capturedHost = safeHostname(msg.capturedUrl);
|
||||
if (!currentHost || !capturedHost || currentHost !== capturedHost) {
|
||||
return { ok: false, error: 'tab_navigated' };
|
||||
}
|
||||
|
||||
const item = await vault.fetchAndDecryptItem(state.gitHost, handle, msg.id);
|
||||
if (item.core.type !== 'login') return { ok: false, error: 'not_a_login' };
|
||||
const itemHost = safeHostname(item.core.url ?? '');
|
||||
if (!itemHost || itemHost !== currentHost) return { ok: false, error: 'origin_mismatch' };
|
||||
|
||||
// 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 };
|
||||
}
|
||||
|
||||
// --- chrome.storage.local helpers (module-scoped so all handlers share) ---
|
||||
|
||||
async function loadConfig(): Promise<VaultConfig | null> {
|
||||
const r = await chrome.storage.local.get('vaultConfig');
|
||||
return (r.vaultConfig as VaultConfig) ?? null;
|
||||
}
|
||||
|
||||
async function loadImageBase64(): Promise<string | null> {
|
||||
const r = await chrome.storage.local.get('imageBase64');
|
||||
return (r.imageBase64 as string) ?? null;
|
||||
}
|
||||
|
||||
async function loadSetupState(): Promise<SetupState> {
|
||||
const config = await loadConfig();
|
||||
const imageBase64 = await loadImageBase64();
|
||||
return { config, imageBase64, isConfigured: config !== null && imageBase64 !== null };
|
||||
}
|
||||
|
||||
async function loadDeviceSettings(): Promise<DeviceSettings> {
|
||||
const r = await chrome.storage.local.get('relicarioSettings');
|
||||
return (r.relicarioSettings as DeviceSettings) ?? { ...DEFAULT_DEVICE_SETTINGS };
|
||||
}
|
||||
|
||||
async function saveDeviceSettings(s: DeviceSettings): Promise<void> {
|
||||
await chrome.storage.local.set({ relicarioSettings: s });
|
||||
}
|
||||
|
||||
async function loadBlacklist(): Promise<string[]> {
|
||||
const r = await chrome.storage.local.get('captureBlacklist');
|
||||
return (r.captureBlacklist as string[]) ?? [];
|
||||
}
|
||||
|
||||
async function saveBlacklist(list: string[]): Promise<void> {
|
||||
await chrome.storage.local.set({ captureBlacklist: list });
|
||||
}
|
||||
|
||||
// --- Manifest entry derivation (duplicated from index; kept here to keep handler self-contained) ---
|
||||
|
||||
function itemToManifestEntry(item: Item) {
|
||||
return {
|
||||
id: item.id,
|
||||
type: item.type,
|
||||
title: item.title,
|
||||
tags: item.tags,
|
||||
favorite: item.favorite,
|
||||
group: item.group,
|
||||
icon_hint: (item.core.type === 'login' && item.core.url)
|
||||
? safeHostname(item.core.url) : undefined,
|
||||
modified: item.modified,
|
||||
trashed_at: item.trashed_at,
|
||||
attachment_summaries: item.attachments.map((a) => ({
|
||||
id: a.id, filename: a.filename, mime_type: a.mime_type, size: a.size,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function safeHostname(url: string): string | undefined {
|
||||
try { return new URL(url).hostname; } catch { return undefined; }
|
||||
}
|
||||
28
extension/src/service-worker/session.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
/// 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.
|
||||
///
|
||||
/// Future multi-vault (β+) would replace `current` with
|
||||
/// `Map<vaultId, SessionHandle>` and thread `vaultId` through every
|
||||
/// handler. Deliberate α simplicity — not an oversight.
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -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<VaultMeta> {
|
||||
const saltBytes = await git.readFile('.relicario/salt');
|
||||
const paramsRaw = await git.readFile('.relicario/params.json');
|
||||
@@ -36,102 +28,115 @@ export async function fetchVaultMeta(git: GitHost): Promise<VaultMeta> {
|
||||
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<Manifest> {
|
||||
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<Entry> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<Item> {
|
||||
const w = requireWasm();
|
||||
const ciphertext = await git.readFile(`items/${id}.enc`);
|
||||
return w.item_decrypt(handle, ciphertext) as Item;
|
||||
}
|
||||
|
||||
export async function encryptAndWriteItem(
|
||||
git: GitHost,
|
||||
handle: SessionHandle,
|
||||
id: ItemId,
|
||||
item: Item,
|
||||
message: string,
|
||||
): Promise<void> {
|
||||
const w = requireWasm();
|
||||
const ciphertext = w.item_encrypt(handle, JSON.stringify(item));
|
||||
await git.writeFile(`items/${id}.enc`, ciphertext, message);
|
||||
}
|
||||
|
||||
// --- Settings (the α subset the SW reads/writes is autofill_origin_acks) ---
|
||||
|
||||
export async function fetchAndDecryptSettings(
|
||||
git: GitHost,
|
||||
handle: SessionHandle,
|
||||
): Promise<VaultSettings> {
|
||||
const w = requireWasm();
|
||||
const ciphertext = await git.readFile('settings.enc');
|
||||
return w.settings_decrypt(handle, ciphertext) as VaultSettings;
|
||||
}
|
||||
|
||||
export async function encryptAndWriteSettings(
|
||||
git: GitHost,
|
||||
handle: SessionHandle,
|
||||
settings: VaultSettings,
|
||||
message: string,
|
||||
): Promise<void> {
|
||||
const w = requireWasm();
|
||||
const ciphertext = w.settings_encrypt(handle, JSON.stringify(settings));
|
||||
await git.writeFile('settings.enc', ciphertext, message);
|
||||
}
|
||||
|
||||
// --- In-memory manifest helpers ---
|
||||
|
||||
export function listItems(
|
||||
manifest: Manifest,
|
||||
group?: string,
|
||||
): Array<[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;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// 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,
|
||||
): 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);
|
||||
}
|
||||
|
||||
@@ -6,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) ---
|
||||
@@ -37,6 +36,11 @@ interface WizardState {
|
||||
carrierImageBytes: Uint8Array | null;
|
||||
passphrase: string;
|
||||
passphraseConfirm: string;
|
||||
// zxcvbn meter state — -1 means "not yet scored" (empty passphrase).
|
||||
passphraseScore: number;
|
||||
passphraseGuessesLog10: number; // -1 before first rating
|
||||
passphraseVisible: boolean;
|
||||
confirmVisible: boolean;
|
||||
referenceImageBytes: Uint8Array | null;
|
||||
creating: boolean;
|
||||
error: string | null;
|
||||
@@ -54,6 +58,10 @@ const state: WizardState = {
|
||||
carrierImageBytes: null,
|
||||
passphrase: '',
|
||||
passphraseConfirm: '',
|
||||
passphraseScore: -1,
|
||||
passphraseGuessesLog10: -1,
|
||||
passphraseVisible: false,
|
||||
confirmVisible: false,
|
||||
referenceImageBytes: null,
|
||||
creating: false,
|
||||
error: null,
|
||||
@@ -71,17 +79,138 @@ 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';
|
||||
interface Strength { score: number; guessesLog10: number }
|
||||
|
||||
/// Call the SW to score a passphrase with zxcvbn. Returns score in [0, 4]
|
||||
/// and guesses_log10, or -1 on both if the round-trip failed.
|
||||
function ratePassphrase(passphrase: string): Promise<Strength> {
|
||||
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) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[relicario setup] rate_passphrase lastError:', chrome.runtime.lastError);
|
||||
resolve({ score: -1, guessesLog10: -1 }); return;
|
||||
}
|
||||
if (!response?.ok) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[relicario setup] rate_passphrase rejected by SW:', response);
|
||||
resolve({ score: -1, guessesLog10: -1 }); return;
|
||||
}
|
||||
resolve({
|
||||
score: response.data?.score ?? -1,
|
||||
guessesLog10: response.data?.guesses_log10 ?? -1,
|
||||
});
|
||||
},
|
||||
);
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[relicario setup] rate_passphrase threw:', err);
|
||||
resolve({ score: -1, guessesLog10: -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<typeof setTimeout> | null = null;
|
||||
function scheduleRate(passphrase: string, onResult: (s: Strength) => void): void {
|
||||
if (rateDebounceTimer !== null) clearTimeout(rateDebounceTimer);
|
||||
rateDebounceTimer = setTimeout(async () => {
|
||||
rateDebounceTimer = null;
|
||||
if (!passphrase) { onResult({ score: -1, guessesLog10: -1 }); return; }
|
||||
onResult(await ratePassphrase(passphrase));
|
||||
}, 150);
|
||||
}
|
||||
|
||||
const STRENGTH_LABELS: Record<number, { text: string; cls: string }> = {
|
||||
0: { text: 'very weak', cls: 's-very-weak' },
|
||||
1: { text: 'weak', cls: 's-weak' },
|
||||
2: { text: 'fair', cls: 's-fair' },
|
||||
3: { text: 'good', cls: 's-good' },
|
||||
4: { text: 'strong', cls: 's-strong' },
|
||||
};
|
||||
|
||||
/// Render the entropy readout as "~10^N guesses to crack" or a friendlier
|
||||
/// shorthand for large values. Returns empty string when no data.
|
||||
function entropyText(guessesLog10: number): string {
|
||||
if (guessesLog10 < 0) return '';
|
||||
const rounded = Math.round(guessesLog10);
|
||||
if (rounded < 6) return `~10^${rounded} guesses — trivially crackable`;
|
||||
if (rounded < 9) return `~10^${rounded} guesses — minutes on a single GPU`;
|
||||
if (rounded < 12) return `~10^${rounded} guesses — hours to days on a GPU`;
|
||||
if (rounded < 15) return `~10^${rounded} guesses — years on consumer hardware`;
|
||||
if (rounded < 20) return `~10^${rounded} guesses — beyond consumer-hardware reach`;
|
||||
return `~10^${rounded} guesses — effectively uncrackable`;
|
||||
}
|
||||
|
||||
/// Update just the meter DOM without a full re-render (so the input keeps
|
||||
/// focus and the user's cursor position is preserved). Also updates the
|
||||
/// char counter and confirm-match indicator live.
|
||||
function updateStrengthUi(): void {
|
||||
const bar = document.getElementById('strength-bar');
|
||||
const label = document.getElementById('strength-label');
|
||||
const entropy = document.getElementById('entropy-line');
|
||||
const counter = document.getElementById('passphrase-counter');
|
||||
const matchInd = document.getElementById('match-indicator');
|
||||
const create = document.getElementById('create-btn') as HTMLButtonElement | null;
|
||||
|
||||
const score = state.passphraseScore;
|
||||
const guessesLog10 = state.passphraseGuessesLog10;
|
||||
|
||||
if (bar) bar.className = `strength-bar${score >= 0 ? ` s${score}` : ''}`;
|
||||
|
||||
if (label) {
|
||||
if (score < 0) {
|
||||
label.className = 'strength-label';
|
||||
label.innerHTML = ' ';
|
||||
} else {
|
||||
const meta = STRENGTH_LABELS[score] ?? STRENGTH_LABELS[0];
|
||||
label.className = `strength-label ${meta.cls}`;
|
||||
label.textContent = meta.text;
|
||||
}
|
||||
}
|
||||
|
||||
if (entropy) {
|
||||
const txt = entropyText(guessesLog10);
|
||||
entropy.textContent = txt;
|
||||
entropy.style.visibility = txt ? 'visible' : 'hidden';
|
||||
}
|
||||
|
||||
if (counter) {
|
||||
const n = state.passphrase.length;
|
||||
counter.textContent = n === 0 ? '' : `${n} character${n === 1 ? '' : 's'}`;
|
||||
}
|
||||
|
||||
if (matchInd) {
|
||||
const p = state.passphrase;
|
||||
const c = state.passphraseConfirm;
|
||||
if (!p || !c) {
|
||||
matchInd.className = 'match-indicator';
|
||||
matchInd.textContent = '';
|
||||
} else if (p === c) {
|
||||
matchInd.className = 'match-indicator ok';
|
||||
matchInd.textContent = '✓';
|
||||
} else {
|
||||
matchInd.className = 'match-indicator bad';
|
||||
matchInd.textContent = '✗';
|
||||
}
|
||||
}
|
||||
|
||||
const matchOk = !state.passphraseConfirm || state.passphrase === state.passphraseConfirm;
|
||||
if (create) {
|
||||
const disabled = state.creating || score < 3 || !state.passphraseConfirm || !matchOk;
|
||||
create.disabled = disabled;
|
||||
create.title = disabled
|
||||
? (score < 3
|
||||
? 'passphrase must score "good" or better'
|
||||
: !state.passphraseConfirm ? 'confirm your passphrase'
|
||||
: !matchOk ? 'passphrases do not match'
|
||||
: '')
|
||||
: '';
|
||||
}
|
||||
}
|
||||
|
||||
// --- Render ---
|
||||
@@ -266,11 +395,35 @@ function attachStep2(): void {
|
||||
// --- Step 3: Create Vault ---
|
||||
|
||||
function renderStep3(): string {
|
||||
const strength = state.passphrase ? passphraseStrength(state.passphrase) : null;
|
||||
const score = state.passphraseScore;
|
||||
const guessesLog10 = state.passphraseGuessesLog10;
|
||||
const hasScore = score >= 0;
|
||||
const meterClass = hasScore ? `s${score}` : '';
|
||||
const labelMeta = hasScore ? STRENGTH_LABELS[score] : null;
|
||||
const labelClass = labelMeta?.cls ?? '';
|
||||
const labelText = labelMeta?.text ?? ' ';
|
||||
const entropy = entropyText(guessesLog10);
|
||||
|
||||
const p = state.passphrase;
|
||||
const c = state.passphraseConfirm;
|
||||
const matchState = !p || !c ? '' : p === c ? 'ok' : 'bad';
|
||||
const matchGlyph = matchState === 'ok' ? '✓' : matchState === 'bad' ? '✗' : '';
|
||||
|
||||
const pType = state.passphraseVisible ? 'text' : 'password';
|
||||
const cType = state.confirmVisible ? 'text' : 'password';
|
||||
const pToggle = state.passphraseVisible ? 'hide' : 'show';
|
||||
const cToggle = state.confirmVisible ? 'hide' : 'show';
|
||||
|
||||
const matchOk = !c || p === c;
|
||||
const gateDisabled = state.creating || score < 3 || !c || !matchOk;
|
||||
|
||||
const nChars = p.length;
|
||||
const counterText = nChars === 0 ? '' : `${nChars} character${nChars === 1 ? '' : 's'}`;
|
||||
|
||||
return `
|
||||
<div class="wizard-step">
|
||||
<h3>create vault</h3>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="label">carrier image (JPEG)</label>
|
||||
<div class="file-drop ${state.carrierImageBytes ? 'has-file' : ''}" id="file-drop">
|
||||
@@ -281,23 +434,44 @@ function renderStep3(): string {
|
||||
</div>
|
||||
<p class="muted" style="margin-top:4px;">A 256-bit secret will be steganographically embedded in this image.</p>
|
||||
</div>
|
||||
|
||||
<div class="pass-help">
|
||||
A long phrase of unrelated words is stronger than a short complex password.
|
||||
Your vault needs <strong>good</strong> (score ≥ 3) to continue.
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="label" for="passphrase">passphrase</label>
|
||||
<input id="passphrase" type="password" value="${escapeHtml(state.passphrase)}" placeholder="enter a strong passphrase">
|
||||
${strength ? `
|
||||
<div class="strength-bar">
|
||||
<div class="strength-bar-fill ${strength}"></div>
|
||||
</div>
|
||||
<p class="muted" style="margin-top:2px;">strength: ${strength}</p>
|
||||
` : ''}
|
||||
<div class="passphrase-field">
|
||||
<input id="passphrase" type="${pType}" value="${escapeHtml(p)}" placeholder="enter a strong passphrase" autocomplete="new-password">
|
||||
<button type="button" class="eye-btn" id="eye-btn" aria-label="toggle passphrase visibility">${pToggle}</button>
|
||||
</div>
|
||||
<div class="strength-bar ${meterClass}" id="strength-bar" aria-hidden="true">
|
||||
<div class="seg i0"></div>
|
||||
<div class="seg i1"></div>
|
||||
<div class="seg i2"></div>
|
||||
<div class="seg i3"></div>
|
||||
<div class="seg i4"></div>
|
||||
</div>
|
||||
<div class="strength-row">
|
||||
<p class="strength-label ${labelClass}" id="strength-label">${labelText}</p>
|
||||
<p class="char-counter" id="passphrase-counter">${escapeHtml(counterText)}</p>
|
||||
</div>
|
||||
<p class="entropy-line" id="entropy-line" style="visibility:${entropy ? 'visible' : 'hidden'};">${escapeHtml(entropy || ' ')}</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="label" for="passphrase-confirm">confirm passphrase</label>
|
||||
<input id="passphrase-confirm" type="password" value="${escapeHtml(state.passphraseConfirm)}" placeholder="re-enter passphrase">
|
||||
<div class="passphrase-field">
|
||||
<input id="passphrase-confirm" type="${cType}" value="${escapeHtml(c)}" placeholder="re-enter passphrase" autocomplete="new-password">
|
||||
<span class="match-indicator ${matchState}" id="match-indicator">${matchGlyph}</span>
|
||||
<button type="button" class="eye-btn" id="confirm-eye-btn" aria-label="toggle confirm visibility">${cToggle}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button class="btn" id="back-btn">back</button>
|
||||
<button class="btn btn-primary" id="create-btn" ${state.creating ? 'disabled' : ''}>
|
||||
<button class="btn btn-primary" id="create-btn" ${gateDisabled ? 'disabled' : ''}>
|
||||
${state.creating ? '<span class="spinner"></span> creating...' : 'create vault'}
|
||||
</button>
|
||||
</div>
|
||||
@@ -323,26 +497,43 @@ function attachStep3(): void {
|
||||
reader.readAsArrayBuffer(file);
|
||||
});
|
||||
|
||||
// Track passphrase changes without full re-render
|
||||
document.getElementById('passphrase')?.addEventListener('input', (e) => {
|
||||
// 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.
|
||||
const passInput = document.getElementById('passphrase') as HTMLInputElement | null;
|
||||
passInput?.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();
|
||||
}
|
||||
// Update char counter + match indicator + button gate immediately on every keystroke.
|
||||
updateStrengthUi();
|
||||
// Score updates on the 150ms debounce to avoid SW hammering.
|
||||
scheduleRate(state.passphrase, (s) => {
|
||||
state.passphraseScore = s.score;
|
||||
state.passphraseGuessesLog10 = s.guessesLog10;
|
||||
updateStrengthUi();
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById('passphrase-confirm')?.addEventListener('input', (e) => {
|
||||
const confirmInput = document.getElementById('passphrase-confirm') as HTMLInputElement | null;
|
||||
confirmInput?.addEventListener('input', (e) => {
|
||||
state.passphraseConfirm = (e.target as HTMLInputElement).value;
|
||||
updateStrengthUi();
|
||||
});
|
||||
|
||||
// Eye toggles — flip the input type and label without a full re-render so
|
||||
// focus + cursor position survive the click.
|
||||
document.getElementById('eye-btn')?.addEventListener('click', () => {
|
||||
state.passphraseVisible = !state.passphraseVisible;
|
||||
if (passInput) passInput.type = state.passphraseVisible ? 'text' : 'password';
|
||||
const btn = document.getElementById('eye-btn');
|
||||
if (btn) btn.textContent = state.passphraseVisible ? 'hide' : 'show';
|
||||
passInput?.focus();
|
||||
});
|
||||
|
||||
document.getElementById('confirm-eye-btn')?.addEventListener('click', () => {
|
||||
state.confirmVisible = !state.confirmVisible;
|
||||
if (confirmInput) confirmInput.type = state.confirmVisible ? 'text' : 'password';
|
||||
const btn = document.getElementById('confirm-eye-btn');
|
||||
if (btn) btn.textContent = state.confirmVisible ? 'hide' : 'show';
|
||||
confirmInput?.focus();
|
||||
});
|
||||
|
||||
document.getElementById('back-btn')?.addEventListener('click', () => {
|
||||
@@ -366,6 +557,17 @@ 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).
|
||||
const strength = await ratePassphrase(state.passphrase);
|
||||
state.passphraseScore = strength.score;
|
||||
state.passphraseGuessesLog10 = strength.guessesLog10;
|
||||
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();
|
||||
@@ -376,80 +578,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)
|
||||
w.embed_image_secret(state.carrierImageBytes, imageSecret),
|
||||
);
|
||||
log('embedded', { referenceBytes: state.referenceImageBytes.byteLength });
|
||||
|
||||
// 3. Generate 32-byte salt
|
||||
stage = 'generate salt';
|
||||
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,
|
||||
);
|
||||
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');
|
||||
|
||||
// 6. Encrypt empty manifest
|
||||
const manifestJson = '{"entries":{},"version":1}';
|
||||
const encryptedManifest = w.encrypt_manifest(manifestJson, masterKey);
|
||||
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 });
|
||||
|
||||
// 7. 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);
|
||||
|
||||
await host.writeFile(
|
||||
'.relicario/salt',
|
||||
salt,
|
||||
'init: vault salt',
|
||||
);
|
||||
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',
|
||||
);
|
||||
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',
|
||||
);
|
||||
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',
|
||||
);
|
||||
|
||||
// 8. Advance to step 4
|
||||
stage = 'release handle';
|
||||
w.lock(handle);
|
||||
|
||||
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();
|
||||
}
|
||||
});
|
||||
|
||||
33
extension/src/shared/__tests__/base32.test.ts
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
44
extension/src/shared/base32.ts
Normal file
@@ -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);
|
||||
}
|
||||
@@ -1,68 +1,79 @@
|
||||
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<import('./types').RelicarioSettings> }
|
||||
| { type: 'update_settings'; settings: Partial<DeviceSettings> }
|
||||
| { 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' }
|
||||
| { type: 'capture_save_login'; username: string; password: string };
|
||||
|
||||
// --- 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<Response, { ok: true }> {
|
||||
data: undefined;
|
||||
}
|
||||
// --- Typed response helpers ---
|
||||
|
||||
export interface IsUnlockedResponse extends Extract<Response, { ok: true }> {
|
||||
data: { unlocked: boolean };
|
||||
}
|
||||
|
||||
export interface ListEntriesResponse extends Extract<Response, { ok: true }> {
|
||||
data: { entries: Array<[string, ManifestEntry]> };
|
||||
export interface ListItemsResponse extends Extract<Response, { ok: true }> {
|
||||
data: { items: Array<[ItemId, ManifestEntry]> };
|
||||
}
|
||||
|
||||
export interface GetEntryResponse extends Extract<Response, { ok: true }> {
|
||||
data: { entry: Entry };
|
||||
}
|
||||
|
||||
export interface SearchEntriesResponse extends Extract<Response, { ok: true }> {
|
||||
data: { entries: Array<[string, ManifestEntry]> };
|
||||
export interface GetItemResponse extends Extract<Response, { ok: true }> {
|
||||
data: { item: Item };
|
||||
}
|
||||
|
||||
export interface TotpResponse extends Extract<Response, { ok: true }> {
|
||||
data: { code: string; remaining_seconds: number };
|
||||
data: { code: string; expires_at: number };
|
||||
}
|
||||
|
||||
export interface AutofillCandidatesResponse extends Extract<Response, { ok: true }> {
|
||||
data: { candidates: Array<[string, ManifestEntry]> };
|
||||
data: { candidates: Array<[ItemId, ManifestEntry]> };
|
||||
}
|
||||
|
||||
export interface CredentialsResponse extends Extract<Response, { ok: true }> {
|
||||
data: { username: string; password: string };
|
||||
data:
|
||||
| { requires_ack: true; hostname: string }
|
||||
| { username: string; password: string };
|
||||
}
|
||||
|
||||
export interface SetupStateResponse extends Extract<Response, { ok: true }> {
|
||||
@@ -72,3 +83,22 @@ export interface SetupStateResponse extends Extract<Response, { ok: true }> {
|
||||
export interface GeneratePasswordResponse extends Extract<Response, { ok: true }> {
|
||||
data: { password: string };
|
||||
}
|
||||
|
||||
export interface RatePassphraseResponse extends Extract<Response, { ok: true }> {
|
||||
data: { score: number; guesses_log10: number };
|
||||
}
|
||||
|
||||
// --- Capability sets (consumed by the router) ---
|
||||
|
||||
export const POPUP_ONLY_TYPES: ReadonlySet<PopupMessage['type']> = new Set([
|
||||
'is_unlocked', 'unlock', 'lock', 'list_items', 'get_item', 'add_item',
|
||||
'update_item', 'delete_item', 'get_totp', 'sync', 'get_setup_state',
|
||||
'save_setup', 'rate_passphrase', 'generate_password', 'fill_credentials',
|
||||
'ack_autofill_origin', 'get_settings', 'update_settings', 'get_blacklist',
|
||||
'remove_blacklist',
|
||||
] as PopupMessage['type'][]);
|
||||
|
||||
export const CONTENT_CALLABLE_TYPES: ReadonlySet<ContentMessage['type']> = new Set([
|
||||
'get_autofill_candidates', 'get_credentials', 'check_credential', 'blacklist_site',
|
||||
'capture_save_login',
|
||||
] as ContentMessage['type'][]);
|
||||
|
||||
@@ -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<u8> → JSON number array
|
||||
algorithm: 'sha1' | 'sha256' | 'sha512';
|
||||
digits: number;
|
||||
period_seconds: number;
|
||||
kind: TotpKind;
|
||||
}
|
||||
|
||||
// --- Sections + custom fields ---
|
||||
|
||||
export interface Section {
|
||||
name?: string;
|
||||
fields: Field[];
|
||||
}
|
||||
|
||||
export interface Field {
|
||||
id: FieldId;
|
||||
label: string;
|
||||
kind: FieldKind;
|
||||
value: FieldValue;
|
||||
hidden_by_default: boolean;
|
||||
}
|
||||
|
||||
export type FieldKind =
|
||||
| 'text' | 'multiline' | 'password' | 'concealed' | 'url' | 'email'
|
||||
| 'phone' | 'date' | 'month_year' | 'totp' | 'reference';
|
||||
|
||||
// adjacently-tagged { tag: "kind", content: "value" }
|
||||
export type FieldValue =
|
||||
| { kind: 'text'; value: string }
|
||||
| { kind: 'multiline'; value: string }
|
||||
| { kind: 'password'; value: string }
|
||||
| { kind: 'concealed'; value: string }
|
||||
| { kind: 'url'; value: string }
|
||||
| { kind: 'email'; value: string }
|
||||
| { kind: 'phone'; value: string }
|
||||
| { kind: 'date'; value: string }
|
||||
| { kind: 'month_year'; value: { month: number; year: number } }
|
||||
| { kind: 'totp'; value: TotpConfig }
|
||||
| { kind: 'reference'; value: AttachmentId };
|
||||
|
||||
// --- Attachments + history ---
|
||||
|
||||
export interface AttachmentRef {
|
||||
id: AttachmentId;
|
||||
filename: string;
|
||||
mime_type: string;
|
||||
size: number;
|
||||
created: number;
|
||||
}
|
||||
|
||||
export interface FieldHistoryEntry {
|
||||
value: string;
|
||||
replaced_at: number;
|
||||
}
|
||||
|
||||
export interface AttachmentSummary {
|
||||
id: AttachmentId;
|
||||
filename: string;
|
||||
mime_type: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
// --- Item envelope ---
|
||||
|
||||
export interface Item {
|
||||
id: ItemId;
|
||||
title: string;
|
||||
type: ItemType; // Rust r#type → JSON key "type"
|
||||
tags: string[];
|
||||
favorite: boolean;
|
||||
group?: string;
|
||||
notes?: string;
|
||||
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<FieldId, FieldHistoryEntry[]>;
|
||||
}
|
||||
|
||||
/// 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<string, ManifestEntry>;
|
||||
version: number;
|
||||
schema_version: number; // 2
|
||||
items: Record<ItemId, ManifestEntry>;
|
||||
}
|
||||
|
||||
/// 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<string, number>;
|
||||
}
|
||||
|
||||
// --- 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' },
|
||||
};
|
||||
|
||||
113
extension/src/wasm.d.ts
vendored
@@ -1,58 +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<void>;
|
||||
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: number;
|
||||
free(): void;
|
||||
}
|
||||
|
||||
export function unlock(
|
||||
passphrase: string,
|
||||
image_bytes: Uint8Array,
|
||||
salt: Uint8Array,
|
||||
params_json: string,
|
||||
): SessionHandle;
|
||||
|
||||
export function lock(handle: SessionHandle): boolean;
|
||||
|
||||
export function manifest_decrypt(handle: SessionHandle, encrypted: Uint8Array): unknown;
|
||||
export function manifest_encrypt(handle: SessionHandle, manifest_json: string): Uint8Array;
|
||||
export function item_decrypt(handle: SessionHandle, encrypted: Uint8Array): unknown;
|
||||
export function item_encrypt(handle: SessionHandle, item_json: string): Uint8Array;
|
||||
export function settings_decrypt(handle: SessionHandle, encrypted: Uint8Array): unknown;
|
||||
export function settings_encrypt(handle: SessionHandle, settings_json: string): Uint8Array;
|
||||
|
||||
export function attachment_encrypt(
|
||||
handle: SessionHandle,
|
||||
plaintext: Uint8Array,
|
||||
max_bytes: bigint,
|
||||
): EncryptedAttachment;
|
||||
export function attachment_decrypt(handle: SessionHandle, encrypted: Uint8Array): Uint8Array;
|
||||
|
||||
export function new_item_id(): string;
|
||||
export function new_field_id(): string;
|
||||
|
||||
export function generate_password(request_json: string): string;
|
||||
export function generate_passphrase(request_json: string): string;
|
||||
export function rate_passphrase(p: string): { score: number; guesses_log10: number };
|
||||
|
||||
export function extract_image_secret(image_bytes: Uint8Array): Uint8Array;
|
||||
export function embed_image_secret(carrier: Uint8Array, secret: Uint8Array): Uint8Array;
|
||||
|
||||
export function totp_compute(config_json: string, now_unix_seconds: bigint): TotpCode;
|
||||
|
||||
// Initializer (wasm-bindgen's default init function).
|
||||
export default function init(module_or_path?: unknown): Promise<void>;
|
||||
|
||||
@@ -9,11 +9,8 @@
|
||||
"rootDir": "./src",
|
||||
"sourceMap": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"paths": {
|
||||
"relicario-wasm": ["./wasm/relicario_wasm.js"]
|
||||
},
|
||||
"baseUrl": "."
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "wasm"]
|
||||
"exclude": ["node_modules", "dist", "wasm", "src/**/__tests__/**"]
|
||||
}
|
||||
|
||||
8
extension/vitest.config.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'happy-dom',
|
||||
include: ['src/**/__tests__/**/*.test.ts'],
|
||||
},
|
||||
});
|
||||