53 Commits

Author SHA1 Message Date
adlee-was-taken
706051530e fix(ext/popup): bind form escHandlers to teardown to stop listener leak 2026-04-23 23:09:52 -04:00
adlee-was-taken
23759dc163 feat(ext/popup): + New picker with all 7 item types (Document disabled) 2026-04-23 23:07:33 -04:00
adlee-was-taken
3c0b4c1589 fix(ext): get_totp handles Totp items, not just Login
Critical bug caught in T8 code review: the SW's get_totp handler
gated on core.type === 'login' and referenced core.totp, so the
standalone Totp item type (which lands in T8 with core.type === 'totp'
and core.config) had its detail-view ticker silently rejected with
'no_totp' every second. Ticker swallowed the error; rotating code
display stayed at placeholder forever.

Fix: extend the handler to resolve TotpConfig from either carrier:
- Login items: item.core.totp (optional subfield)
- Totp items:  item.core.config (required)

Also:
- Add 3 router tests covering both paths + the empty case
- Remove stale '……' placeholder check in types/totp.ts's \`t\`
  keyboard shortcut (dead code — the placeholder is '·····' or
  '······', never horizontal ellipses)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 23:04:27 -04:00
adlee-was-taken
673981379e feat(ext/popup): Totp view + form (countdown ring, Steam toggle)
Detail view renders a signature block with a large monospace rotating code and
a thin SVG countdown ring that sweeps via CSS transition. The ticker polls
get_totp every second and is stopped on teardown (back/edit/trash/Escape/e/d/t).

Form has a two-button kind toggle (TOTP / Steam Guard) that re-renders in place
while preserving entered values. TOTP uses digits=6 kind='totp'; Steam uses
digits=5 kind='steam'. Both default to algorithm='sha1' period_seconds=30.

Keyboard shortcuts on detail: Escape=back, e=edit, d=trash, t=copy-code.
Guarded against stealing keystrokes from editable targets.

Wires totp.renderDetail / totp.renderForm into both dispatchers and calls
totp.teardown() alongside the other types so tickers can't leak across views.

Closes T8 of the extension 1C-β1 plan (5/5 typed-item modules in place;
only T9 picker and T10 acceptance remain).
2026-04-23 22:54:49 -04:00
adlee-was-taken
e084790756 feat(ext/popup): Key view + form (concealed monospace signature block) 2026-04-23 22:42:48 -04:00
adlee-was-taken
560a3c63c4 feat(ext/popup): Card view + form (card-silhouette signature, MM/YY selects) 2026-04-23 22:39:21 -04:00
adlee-was-taken
113b0b690a feat(ext/popup): Identity view + form (profile-card signature block) 2026-04-23 22:29:04 -04:00
adlee-was-taken
99d689b9b0 feat(ext/popup): SecureNote view + form on shared helpers 2026-04-23 22:26:49 -04:00
adlee-was-taken
23d4f736e1 fix(ext/popup): close 3 critical regressions from slice-2 code review
- C1: escapeHtml now escapes " and ' so values stored in data-field-value
  attributes (concealed rows, copyable rows) round-trip correctly. Prior
  impl silently truncated passwords containing quotes. +3 regression tests.
- C2: centralize view-teardown. login.ts exports teardown() that stops
  the TOTP ticker and removes the active keydown handler; item-detail.ts
  and item-form.ts dispatchers call it before rendering the next view;
  each button handler also calls teardown() locally for belt-and-suspenders.
- C3: restore alpha's keyboard shortcuts on login detail view: c
  (copy username), p (copy password), t (copy TOTP), f (autofill), e
  (edit), d (trash), plus Escape (back). All gated by the
  is-editable-target guard so they don't eat keystrokes inside form fields.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 22:21:40 -04:00
adlee-was-taken
11c274053b refactor(ext/popup): extract Login to types/login.ts on shared helpers 2026-04-23 21:57:53 -04:00
adlee-was-taken
24a99ba07a feat(ext/popup): field-row + concealed-row + signature-block helpers 2026-04-23 21:55:36 -04:00
adlee-was-taken
beac303a77 feat(core/totp): emit Steam Guard alphabet for kind=Steam 2026-04-23 20:04:41 -04:00
adlee-was-taken
b80b322853 docs: Plan 1C-β₁ (typed-item forms) implementation plan
10 tasks across 5 slices + pre-flight + acceptance, mirroring the
α plan's cadence. Each task is a single commit; each step 2-5 min.

Slice 1 — Rust Steam encoding fix (Task 1, 4 tests).
Slice 2 — Shared field helpers + Login refactor (Tasks 2-3).
Slice 3 — SecureNote + Identity (Tasks 4-5).
Slice 4 — Card + Key (Tasks 6-7).
Slice 5 — Totp incl. Steam toggle (Task 8).
Slice 6 — "+ New" picker + final acceptance (Tasks 9-10).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 18:47:32 -04:00
adlee-was-taken
1b51b7dbab docs: Plan 1C-β₁ (typed-item forms) design spec
Second sub-plan after 1C-α. Adds the 5 remaining typed-item forms
(SecureNote, Identity, Card, Key, Totp) so the extension can daily-
drive every typed item the Rust core supports — Document deferred
to γ for attachment dependencies.

Form style: muted "signature block + uniform rows" pattern
(per-type accent panel + plain rows for the rest). Login is
refactored onto a shared field-helper module as the reference
implementation.

Totp covers `kind: 'totp'` and `kind: 'steam'`. The latter requires
a Rust-core fix (Slice 1) — `compute_totp_code` currently produces
decimal output for Steam but Steam Guard uses a 5-char alphabet
(`23456789BCDFGHJKMNPQRTVWXY`). Plan ships the alphabet patch and
RFC-style test vectors.

Five-slice sequencing: Rust Steam → shared helpers + Login
refactor → SecureNote+Identity → Card+Key → Totp.

Custom fields editor, vault-settings view, advanced generator UI
all moved to β₂. Hotp counter UI deferred. Document type stays in γ.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 18:08:43 -04:00
adlee-was-taken
2b83105149 Merge Plan 1C-α: extension foundation
Ports the browser extension onto the typed-item core from Plans 1A/1B.
Six-slice implementation: WASM artifact rebuild, shared TS types + messages,
SessionHandle-based service worker, split message router with sender checks,
closed Shadow DOM content scripts, Login-parity popup, zxcvbn setup gate.

Audit items closed: C1 (WAR cleanup), C2 (split router + sender dispatch),
C3 (closed Shadow DOM + textContent), C4 (origin-bound autofill), H2
(opaque SessionHandle), H3 (zxcvbn ≥3 gate), M5 (popup captured-tab
TOCTOU defense — 3-layer: popup snapshot, SW re-check, content-side
expectedHost re-check).

Tests: 55/55 Vitest (router sender-check matrix, fill_credentials TOCTOU,
capture_save_login origin-bound add/update, base32 round-trip). Rust
workspace unchanged. Both Chrome and Firefox bundles compile clean.

Tag plan-1c-alpha-complete points at da3c389 (branch tip).
2026-04-22 19:51:41 -04:00
adlee-was-taken
da3c3893bb feat(ext/icons): replace idfoto ID-card icon with reliquary design
The prior icon was a holdover from the pre-rename idfoto project — a
stylized ID card with a portrait silhouette. Replaced with a proper
reliquary: an arched vessel with a horizontal seal band, small rivets,
standing on a blue pedestal, with a faceted gem at center representing
the protected relic.

- relicario-logo.svg: full 128-px-native design used by the setup
  wizard header and rasterized to icon-48.png and icon-128.png.
- relicario-logo-16.svg: 16-px-optimized variant (bolder strokes, no
  rivets, single-facet gem) for crisp toolbar rendering.
- Palette matches the gh-dark aesthetic used across the extension
  (#0d1117 / #161b22 background, #58a6ff / #79c0ff / #1f6feb accents).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 19:50:02 -04:00
adlee-was-taken
9139dd78a0 fix(ext/popup): normalize url field + humanize cryptic error messages
Bug: typing "Test" into an add-login form's URL field produced
"item json: relative URL without a base: "Test" at line 1 column 227"
in the UI banner — a serde-internal error message that no user should
ever see.

Two fixes:

1. Client-side URL normalization in the add/edit Login form
   (item-form.ts:normalizeUrl):
   - Empty string stays empty (URL is optional).
   - Scheme-less inputs get "https://" prepended so "github.com"
     becomes "https://github.com".
   - The result is run through the JS URL constructor. If that rejects
     OR if the result has no host, show a targeted message like
     "URL must include a host (e.g. https://example.com)".
   - Prevents the Rust-side url::Url::parse failure from ever firing
     for a form-shaped error.

2. Popup-side error humanizer (popup.ts:humanizeError):
   - Applied inside sendMessage so every UI-visible error passes
     through it before the state banner gets the string.
   - Translates: "relative URL without a base" → "URL must start with
     https://...", generic "item json:" / "settings json:" → form-
     field or corruption messages, and the sender/origin gates
     (vault_locked, origin_mismatch, unauthorized_sender,
     tab_navigated, captured_tab_gone) to user-action prompts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 19:45:55 -04:00
adlee-was-taken
357455d979 fix(ext/popup): don't eat '/' and other keystrokes while typing in inputs
Bug: item-list's global "/" shortcut (focus search) and "+" shortcut
(new item) fired even when focus was inside any input/textarea other
than the list's own search field. This ate forward-slashes typed into
the setup wizard's host-url field and the add/edit form's notes area,
and would have done the same for any printable shortcut in a future
text field.

Root cause: the handler was attached to `document`, stays attached
when the user opens an item (and its click-handler navigated without
removing the listener), and only excluded the search field by id.

Fix:
- Add isEditableTarget() helper — returns true for
  INPUT/TEXTAREA/SELECT and contenteditable elements. Global shortcut
  handlers bail early when this fires, passing the keystroke through
  to the field.
- Apply the same guard in item-detail.ts (previously only guarded
  against INPUT, missing TEXTAREA + contenteditable).
- Remove handleListKeydown on row-click so it doesn't linger on
  detail/edit views even before the route-transition keydown
  listeners install.
- Escape in the list view still works from inside an editable
  field — only the printable-character interceptions are gated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 19:43:43 -04:00
adlee-was-taken
69bb58c977 feat(ext/setup): polished passphrase entry UX
Setup wizard step 3 now has self-explanatory passphrase feedback:

- Strength meter: 5 segments with smooth color transitions
  (very-weak/weak/fair/good/strong). Tier 4 gets a subtle glow.
- Nuanced label (lowercase, tracked): "very weak" / "weak" / "fair" /
  "good" / "strong" — color-matched to each tier.
- Entropy readout line: "~10^N guesses — <time to crack>" with
  tiered shorthand (trivial / minutes-on-GPU / hours-to-days /
  years-on-consumer / beyond consumer / uncrackable).
- Live char counter in the strength row.
- Eye toggle buttons on both passphrase fields. Flip type="password"
  <-> type="text" without re-render, preserving focus + cursor.
- Live match indicator (✓ / ✗) between the confirm field and its eye
  toggle. Updates per keystroke.
- Create button gate widened: now requires score >= 3 AND confirm
  field filled AND confirm matches. Disabled button carries a
  tooltip saying which condition failed.
- Contextual help box above the passphrase field explaining the
  "long phrase > complex password" idea + the score >= 3 threshold.

All live-update paths (counter, label, entropy, match indicator,
button gate) go through updateStrengthUi() which targets the DOM
directly — no full re-render, so focus/cursor survive every keystroke.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 19:38:50 -04:00
adlee-was-taken
4341124d38 fix(ext): allow rate_passphrase + is_unlocked from setup tab; add diagnostic logging
Bug: setup tab's zxcvbn meter silently stayed at score=-1 because the
router's isSetup exception only allowed save_setup, so rate_passphrase
got unauthorized_sender. Result: the "create vault" button stayed
disabled forever even with a strong passphrase.

Fix: add a narrow SETUP_ALLOWED set containing save_setup,
rate_passphrase, and is_unlocked (step-4 extension detection). Reject
everything else from the setup tab. Also clean up setup.ts's unlock
call — it was passing the raw 32-byte imageSecret where JPEG bytes with
embedded secret are required; the Rust-side unlock calls imgsecret::
extract internally.

Diagnostic logging across the message path so the next silent failure
speaks up:
- [relicario setup]    staged logs through vault-init; console.error
                       with the failure stage name in the UI banner.
- [relicario setup]    rate_passphrase lastError / rejected / threw
                       branches each log their own warning.
- [relicario router]   console.warn on unauthorized_sender (with sender
                       classification) and unknown_message_type.
- [relicario sw]       first-message wasm init announced; per-message
                       non-ok result logged; thrown errors console.error'd.

Tests: +3 setup-allowlist tests (rate_passphrase accepted, is_unlocked
accepted, fill_credentials + unlock rejected). 55/55 green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 19:32:00 -04:00
adlee-was-taken
3238ef4dd4 refactor(ext/popup): remove last @ts-nocheck, align to typed-item types
Clears the final four transitional @ts-nocheck shields:
- popup.ts (already mostly updated in Slice 6 prior tasks; nocheck just
  removed and the init fallback switched to list_items / ItemId typing)
- unlock.ts (list_entries → list_items; ManifestEntry typing)
- settings.ts (RelicarioSettings → DeviceSettings; pure type rename, UX
  unchanged)

Also drops the stale `idfoto-extension` name in bun.lock (workspace was
renamed; lock file still carried the old name).

Verification:
  git grep -n '@ts-nocheck' extension/src/  → 0 hits
  bun run build + build:firefox             → both green
  bun run test                              → 52/52 passing
  cargo test --workspace                    → green

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 21:44:12 -04:00
adlee-was-taken
f3b915a635 feat(ext/setup): zxcvbn strength meter + score>=3 gate (audit H3)
Replaces the ad-hoc char-class passphraseStrength() with a 5-segment
bar backed by a SW round-trip to rate_passphrase (zxcvbn). Input
handler debounces 150ms so we don't hammer the worker per keystroke.

The create-vault button is disabled unless the last score is ≥ 3
(zxcvbn's "safely unguessable" threshold), and the handler re-rates
synchronously on click as defence-in-depth. Label flips between "Too
weak" (red) and "Strong enough" (green).

Also:
- rewrites the vault-creation path to use the typed-item unlock +
  manifest_encrypt APIs (derive_master_key/encrypt_manifest are gone);
  the new initial manifest is { schema_version: 2, items: {} }.
- wasm.d.ts is now a pure `declare module 'relicario-wasm'` block;
  tsconfig's stale `paths` alias is removed.
- @ts-nocheck removed from setup.ts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 21:38:50 -04:00
adlee-was-taken
76bb61aa10 feat(ext/popup): Login add/edit form on typed-item API
Rewrites item-form.ts for the typed-item Item shape. Login is the only
editable type in Slice 6; other types fall through to coming-soon.

Form fields: title (required) + url + username + password (with gen
button backed by DEFAULT_PASSWORD_REQUEST) + totp (base32) + group +
notes. TOTP base32 is decoded via shared/base32 and wrapped as a
number[] into FieldValue-shape TotpConfig { secret, algorithm: sha1,
digits: 6, period_seconds: 30, kind: 'totp' }. Decode failure sets
state.error and aborts.

Save constructs a full Item envelope (id, title, type, tags, favorite,
group, notes, created, modified, trashed_at, core, sections,
attachments, field_history). On edit we preserve the existing item's
metadata but EXPLICITLY set trashed_at: undefined — carry-forward
from Slice 5 review M3, so an edit cannot accidentally preserve stale
trash state.

@ts-nocheck removed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 21:12:14 -04:00
adlee-was-taken
bc95b047a2 feat(ext/popup): Login detail view + coming-soon for other types
Rewrites item-detail.ts to dispatch on item.type: login gets the full
detail view (url, username, masked password + copy, TOTP with 30s
countdown, notes, group, autofill/edit/trash/back buttons). Non-login
types get a coming-soon placeholder; those grow full UIs in later slices.

Fixes Slice 4 review I1: the old autofill path sent a malformed
fill_credentials payload ({ username, password } — no id/capturedTab).
The new handler uses the (capturedTabId, capturedUrl) pair snapshotted
at popup-open and calls fill_credentials with { id, capturedTabId,
capturedUrl }, matching the SW's handler signature that enforces the
M5 + TOCTOU checks.

TOTP poll now calls get_totp on a 1s timer and renders the 30s countdown
bar against expires_at. @ts-nocheck removed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 21:10:41 -04:00
adlee-was-taken
dc8097589e feat(ext/popup): typed-item list view
Rewrites item-list.ts to render the typed-item ManifestEntry v2
surface: title + type-icon emoji (🔑/📝/🪪/💳/🗝/📄/⏱) + icon_hint
as the meta line. Toolbar now has +new, sync, settings, lock. Keyboard
nav unchanged (/, +, arrows, Enter).

Clicking a row fires list_items → get_item (the new typed-item
messages) and stores the full Item in state.selectedItem before
navigating to 'detail'.

Also updates popup.ts PopupState:
- entries now typed Array<[ItemId, ManifestEntry]>
- selectedEntry → selectedItem (Item)
- init() uses list_items not list_entries

Trashed items (trashed_at set) are filtered out of the visible list.
@ts-nocheck removed from item-list.ts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 21:09:28 -04:00
adlee-was-taken
d090fc421e refactor(ext/popup): rename entry-* → item-* components
Git-moves the three popup components so history survives the content
rewrite that follows in Tasks 22–24:
- entry-list.ts   → item-list.ts
- entry-detail.ts → item-detail.ts
- entry-form.ts   → item-form.ts

Also renames the exported render functions (renderEntryList →
renderItemList, etc.) and updates popup.ts imports + render switch.
The files still wear @ts-nocheck and reference the old Entry type;
content rewriting happens in Tasks 22–24.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 21:01:50 -04:00
adlee-was-taken
856ceb2d93 fix(ext): content-callable capture_save_login closes critical router gap
After Slice 4's router split, the capture prompt's Save button was
silently failing on every site: content/capture.ts called four handlers
(get_settings, get_item, update_item, add_item) that are all in
POPUP_ONLY_TYPES, so the router rejected each with unauthorized_sender.

Fix in two parts:

Part A — get_settings: content scripts already have storage permission
via the manifest, so read relicarioSettings directly from
chrome.storage.local instead of round-tripping through the SW.

Part B — new content-callable 'capture_save_login' message that
consolidates what was previously three separate popup-only calls
(get_item + update_item or add_item) into one SW-side operation.
Content scripts no longer need to distinguish add vs update — the SW
does that itself from the manifest.

Security model (all enforced SW-side, never trusting content):

- Origin is derived from sender.tab.url by the router. The payload
  contains only username + password; there is no way for content to
  influence which host the new/updated item binds to.
- Update path re-verifies the existing item's core.url hostname
  matches senderHost before mutating. If the manifest icon_hint ever
  drifts from core.url, we return origin_mismatch rather than
  silently binding a password to the wrong origin.
- Update mutates ONLY the password field + modified timestamp —
  never title, url, or any other core field.
- Add path creates a new Login item whose title is senderHost and
  whose url is the sender's origin.

Five new router tests cover: content-accept, popup-reject, update
path rotates only the password, add path creates bound item, and
origin_mismatch when the stored item's host disagrees with senderHost.
Tests: 47 -> 52.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 20:57:38 -04:00
adlee-was-taken
1d5ad5e59e test(ext/router): add fill_credentials + save_setup exception tests
Three new describe blocks cover the gaps flagged during Slice 4 review:

1. fill_credentials captured-tab verification — three cases:
   - tab_navigated: chrome.tabs.get returns a tab whose hostname differs
     from capturedUrl → handler must return { ok: false, tab_navigated }
     and not call chrome.tabs.sendMessage.
   - origin_mismatch: tab matches capturedUrl but the item's
     LoginCore.url hostname differs → same refusal, no delivery.
   - happy path: verify the forwarded message is exactly
     { type: 'fill_credentials', username, password, expectedHost }.

2. save_setup exception scope: the setup tab gets a narrow exception
   to POST save_setup, but nothing else. Prove fill_credentials from
   the setup tab is rejected with unauthorized_sender.

3. isContent sender.id guard: a content-shaped sender with a bogus
   sender.id (≠ chrome.runtime.id) must be rejected.

Vault/session modules are partial-mocked via vi.mock + importOriginal so
the existing tests continue to exercise real listItems/findByHostname.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 20:39:49 -04:00
adlee-was-taken
eed11acba2 feat(ext/popup): snapshot activeTab at popup-open for fill_credentials (audit M5)
Extend PopupState with {capturedTabId, capturedUrl} populated via
chrome.tabs.query({active: true, currentWindow: true}) in init().
These are later passed with fill_credentials so the SW can verify
the captured tab's hostname hasn't changed out from under the user
before forwarding credentials. Combined with expectedHost in the
forwarded payload + content-side re-check in fill.ts, this closes
the TOCTOU window on the popup → SW → content fill path.

popup.ts stays under @ts-nocheck (Slice 6 removes it alongside the
item-* rewrites).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 20:37:49 -04:00
adlee-was-taken
14397b33f0 feat(ext/content): closed Shadow DOM for icon/picker/TOFU + close fill TOCTOU
Two security fixes bundled together because they all live on the
icon-click/fill path:

1. Icon + picker + TOFU hint now render inside closed-mode Shadow DOM
   (via shadow.createShadowHost). Page scripts can no longer find our
   overlay via document.querySelector or rewrite buttons.

2. Icon's get_autofill_candidates call drops the `url` field — router
   derives origin from sender.tab.url. Similarly get_credentials.

3. Icon's get_credentials response handling was buggy: the response is a
   discriminated union { requires_ack, hostname } | { username, password }
   and the old code always read .username (→ undefined when requires_ack).
   New code dispatches on the `requires_ack` marker and either shows an
   in-page TOFU hint or fills directly.

4. fill_credentials is popup-only in the router — the icon click cannot
   (and MUST NOT) issue it from content. The new flow calls fillFields()
   directly after get_credentials returns the plaintext: the content
   script IS the origin, so no SW round-trip is needed for the typing.

5. TOCTOU on the popup → SW → content fill path: the SW verified the
   captured tab's hostname matched capturedUrl, then forwarded blindly.
   Between that check and chrome.tabs.sendMessage delivery, the tab can
   navigate; chrome.tabs.sendMessage delivers to whatever content-script
   principal is loaded at send-time. Closed by:
   - Router forwards { expectedHost: currentHost } in the payload.
   - fill.ts re-checks location.href.hostname === expectedHost before
     typing anything; on mismatch replies { ok: false, error: 'origin_changed' }
     and types nothing.

6. Remove @ts-nocheck from icon.ts, fill.ts, and detector.ts — all three
   now type-check clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 20:37:25 -04:00
adlee-was-taken
8cc1e777be feat(ext/content): closed Shadow DOM + textContent for capture prompt
Previously the capture prompt was a normal <div> appended to document.body
with innerHTML assembly. Any page script could find it via
document.querySelector('#relicario-capture-prompt') and either scrape
values or rewrite the buttons — and the innerHTML pattern meant hostname
interpolation was a latent XSS path (escapeForHtml helped but one mistake
would break it).

- Add content/shadow.ts — createShadowHost() with mode: 'closed', host.style.all = 'initial'.
- Rewrite capture.ts to mount inside the shadow root, build DOM via
  createElement + textContent only, never innerHTML.
- Drop the `url` field from check_credential / blacklist_site — the router
  now derives origin from sender.tab.url (Slice 3 contract).
- Update add_entry / update_entry calls to add_item / update_item with the
  new typed Item + LoginCore shape.
- Swap RelicarioSettings → DeviceSettings.
- Remove @ts-nocheck — the file type-checks clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 20:35:36 -04:00
adlee-was-taken
fbb64729ce feat(ext/popup): open setup via chrome.tabs.create, drop setup view from popup
The popup is too constrained for multi-step setup (Chrome closes it when
focus shifts to a file picker). Previously the popup rendered a pass-through
setup-wizard component that itself opened setup.html in a tab. Cut the
middleman: if not configured, directly chrome.tabs.create the setup page
and window.close() the popup.

- Remove 'setup' from the View union and the setup case from render().
- Delete setup-wizard component entirely — setup.html is the canonical flow.
- Drop renderSetupWizard import.

The @ts-nocheck stays on popup.ts until Slice 6 (item-* rewrites).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 20:33:49 -04:00
adlee-was-taken
2ff3ab1d7f feat(ext): drop setup.html / wasm from web_accessible_resources (audit C1)
setup.html is opened via chrome.tabs.create using a chrome-extension:// URL
which doesn't require WAR. WASM is bundled into service-worker.js/setup.js
and never fetched from a web page origin. Leaving them in WAR would expose
their URLs to any origin for probing/fingerprinting; shipping an empty WAR
array closes the surface.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 20:33:09 -04:00
adlee-was-taken
0cef607859 fix(ext/build): exclude test files from webpack tsc compile
Slice 4 spec review caught: router.test.ts's narrow chrome.* shim
triggered 4 TS errors in webpack's ts-loader pass during production
builds (partial mocks don't match the full chrome.* type surface).

Plan's verbatim test body assumes tests aren't part of the build
compile. Add src/**/__tests__/** to tsconfig exclude — tests still
compile under Vitest's independent ts pipeline (42/42 passing).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 20:25:53 -04:00
adlee-was-taken
3d2b021cb2 test(ext): vitest + router sender-check + origin-bound autofill 2026-04-20 20:15:49 -04:00
adlee-was-taken
2d4dcb5f6b feat(ext/sw): collapse flat index onto router 2026-04-20 20:11:59 -04:00
adlee-was-taken
56ab58cbe9 feat(ext/sw): router index with sender-based dispatch 2026-04-20 20:11:20 -04:00
adlee-was-taken
be32ea13c6 feat(ext/sw): router/content-callable handlers with origin derivation 2026-04-20 20:11:02 -04:00
adlee-was-taken
533bfd5bea feat(ext/sw): router/popup-only handlers 2026-04-20 20:10:34 -04:00
adlee-was-taken
2fd6daad8e docs(ext/sw): tighten slice-3 comments per code review
Non-functional tightening flagged in the slice-3 code review:

- session.ts: document future multi-vault refactor (β+) so the module-
  scope singleton is explicitly "deliberately simple," not an oversight.
- vault.ts: move findByHostname doc comment above the function; note
  α's intentionally-coarse hostname match (no www-stripping, no
  public-suffix matching) and that tighter matching is a β/γ concern.
- index.ts: expand the passphrase scope-clearing comment to make
  the theatre explicit rather than leaving it looking like real defense.
- index.ts: TODO(slice-4) marker on delete_item's non-atomic two-write
  path — consider manifest-first ordering or retry/rollback at router-
  split time.
- index.ts: cross-reference comment on itemToManifestEntry pointing at
  the Rust-side ManifestEntry::from_item derivation it must mirror.

No behavior change; build still compiles with 2 bundle-size warnings.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 20:07:27 -04:00
adlee-was-taken
c0fba2a8dc chore(ext): silence popup/content errors until slice 6 2026-04-20 19:57:32 -04:00
adlee-was-taken
20144e8e02 feat(ext/sw): rewire flat handler onto typed-item vault + SessionHandle 2026-04-20 19:55:50 -04:00
adlee-was-taken
bd9dd206ac feat(ext/sw): typed-item vault ops via SessionHandle 2026-04-20 19:53:28 -04:00
adlee-was-taken
7781a51848 feat(ext/sw): SessionHandle lifecycle module 2026-04-20 19:52:54 -04:00
adlee-was-taken
dc8afcb634 feat(ext): base32 encode/decode for TOTP secret parse 2026-04-20 19:44:18 -04:00
adlee-was-taken
b4da5bffcf feat(ext): split PopupMessage / ContentMessage unions + capability sets 2026-04-20 19:43:09 -04:00
adlee-was-taken
04c9503036 feat(ext): typed-item TS types mirroring relicario-core serde 2026-04-20 19:42:31 -04:00
adlee-was-taken
14aaac672c build(ext): align wasm.d.ts with relicario-wasm surface
Add initSync named export (Chrome MV3 service worker path — can't use
dynamic import()), and correct TotpCode.expires_at from number to bigint
to match the u64 wasm-bindgen output.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 19:36:54 -04:00
adlee-was-taken
c03a492ee3 docs: Plan 1C-α (extension foundation) implementation plan
28 tasks across 6 slices + pre-flight + acceptance, following the 1C-α
design spec (a1d733d/ad6d8af). Each task is a single commit; each step
is 2-5 minutes of work. Design choices locked in:

- Slice 1 (Tasks 1-3): WASM artifact rebuild (replace stale idfoto_wasm)
- Slice 2 (Tasks 4-6): shared TS types + message unions + base32 util
- Slice 3 (Tasks 7-10): session.ts, vault.ts, transitional index.ts
- Slice 4 (Tasks 11-15): split router + Vitest + sender-check matrix
- Slice 5 (Tasks 16-20): WAR cleanup, setup-via-tabs, closed Shadow DOM
  for capture/icon/picker/ack, popup captured-tab snapshot
- Slice 6 (Tasks 21-27): popup rename + Login-parity + zxcvbn + manual
  cross-browser verification
- Slice 7 (Task 28): acceptance checks (cargo test, build, lint greps)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 19:28:13 -04:00
adlee-was-taken
ad6d8af2f6 docs(1c-alpha): correct TS type definitions to match actual serde shapes
Verified against the Plan 1A Rust sources:
- ItemType / ItemCore use snake_case with tag="type" internal tagging
  (not the external tagging I initially wrote)
- TotpKind is default-externally-tagged (no tag attr), so it serializes
  as bare "totp"/"steam" for unit variants and { hotp: { counter } }
- GeneratorRequest uses tag="kind" internal tagging
- FieldValue / TrashRetention / HistoryRetention / SymbolCharset use
  adjacent tagging { tag: "kind", content: "value" }
- Fix Login form TOTP parse example and "gen" button payload

No scope change — this is a bookkeeping correction so the plan
author references the correct wire shapes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 19:19:44 -04:00
adlee-was-taken
a1d733ddeb docs: Plan 1C-α (extension foundation) design spec
Foundation slice of the browser-extension migration onto the typed-item
core from Plans 1A+1B. Scope: WASM artifact rebuild, typed-item shared
types, SessionHandle-based service worker, split router with sender
checks, full security architecture (origin-bound autofill, TOFU ack,
closed Shadow DOM, popup captured-tab verification), zxcvbn setup gate,
Login-parity popup. Other 6 item types land in 1C-β; attachments/trash/
history/device UI in 1C-γ.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 19:14:25 -04:00
adlee-was-taken
76f34bfcf5 chore: remove stray vault files from Plan 1B + add plan doc
A Task 6 implementer subagent ran `relicario init` inside the worktree
root during manual testing and committed the resulting vault skeleton
(.relicario/, manifest.enc, settings.enc) plus overwrote .gitignore.
None of these should be in the source repo.

Restores the original .gitignore (adds reference.jpg and ref.jpg to it)
and checks in the Plan 1B design doc that describes the work just merged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 18:50:37 -04:00
adlee-was-taken
e0c511e320 Merge Plan 1B: typed-item CLI + WASM bridge
34 commits from plan-1a-rust-core-complete landing:
- Rename reconciliation (Task 1)
- Core imgsecret MAX_DIMENSION cap (Task 2, audit M3)
- CLI rewrite against typed-item core API (Tasks 3-17)
- WASM opaque SessionHandle bridge (Tasks 18-21)
- CLI integration test harness + tests (Tasks 22-24)
- CLAUDE.md typed-item layout refresh (Task 25)

Audit fixes: H4 H5 H6 H7 M3 M6 M7 M11 L8.
Tests: 151 passing (core + CLI + WASM native), WASM target builds clean.
Tag: plan-1b-cli-wasm-complete
2026-04-20 18:48:56 -04:00
66 changed files with 16618 additions and 1583 deletions

8
.gitignore vendored
View File

@@ -1 +1,9 @@
target/
.superpowers/
.worktrees/
extension/node_modules/
extension/dist/
extension/dist-firefox/
extension/wasm/
reference.jpg
ref.jpg

View File

@@ -1 +0,0 @@
[]

View File

@@ -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"
}

View File

@@ -1 +0,0 @@
ο½48^ΖΥ<CE96>\Χ<>ο<EFBFBD>Ιl$$Η. ώφrΥΩΛ

View File

@@ -8,6 +8,10 @@ use zeroize::Zeroizing;
use crate::error::{RelicarioError, Result};
/// Steam Mobile Authenticator's 5-character output alphabet.
/// Deliberately excludes ambiguous glyphs (0/O, 1/I/L, S/5, A/Z).
const STEAM_ALPHABET: &[u8] = b"23456789BCDFGHJKMNPQRTVWXY";
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct TotpCore {
pub config: TotpConfig,
@@ -96,6 +100,16 @@ pub fn compute_totp_code(config: &TotpConfig, now_unix_seconds: u64) -> Result<S
| ((hmac_out[offset + 1] as u32) << 16)
| ((hmac_out[offset + 2] as u32) << 8)
| (hmac_out[offset + 3] as u32);
if matches!(config.kind, TotpKind::Steam) {
let mut t = truncated;
let mut out = String::with_capacity(5);
for _ in 0..5 {
out.push(STEAM_ALPHABET[(t % 26) as usize] as char);
t /= 26;
}
return Ok(out);
}
let modulus = 10u32.pow(config.digits as u32);
Ok(format!("{:0width$}", truncated % modulus, width = config.digits as usize))
}
@@ -168,3 +182,103 @@ mod tests {
assert!(json.contains("steam"));
}
}
#[cfg(test)]
mod steam_tests {
use super::*;
/// Reference implementation of the Steam 5-character output, per the
/// Steam Mobile Authenticator (and WinAuth's Steam-Guard adapter).
/// Used by tests below to cross-check the production impl without
/// requiring a third-party vector. The algorithm is short enough to
/// be reproduced here in isolation.
fn steam_output_reference(truncated: u32) -> String {
const ALPHA: &[u8] = b"23456789BCDFGHJKMNPQRTVWXY";
let mut t = truncated;
let mut out = String::with_capacity(5);
for _ in 0..5 {
out.push(ALPHA[(t % 26) as usize] as char);
t /= 26;
}
out
}
/// Compute the dynamic-truncated u32 the same way `compute_totp_code`
/// does internally — used to drive the reference impl.
fn truncated_for(secret: &[u8], counter: u64) -> u32 {
use hmac::{Hmac, Mac};
use sha1::Sha1;
let mut mac = Hmac::<Sha1>::new_from_slice(secret).unwrap();
mac.update(&counter.to_be_bytes());
let bytes = mac.finalize().into_bytes();
let offset = (bytes[bytes.len() - 1] & 0x0F) as usize;
((bytes[offset] as u32 & 0x7F) << 24)
| ((bytes[offset + 1] as u32) << 16)
| ((bytes[offset + 2] as u32) << 8)
| (bytes[offset + 3] as u32)
}
#[test]
fn steam_output_matches_reference_impl() {
let secret = b"12345678901234567890".to_vec();
let cfg = TotpConfig {
secret: Zeroizing::new(secret.clone()),
algorithm: TotpAlgorithm::Sha1,
digits: 5,
period_seconds: 30,
kind: TotpKind::Steam,
};
let code_at_30 = compute_totp_code(&cfg, 30).unwrap();
let code_at_60 = compute_totp_code(&cfg, 60).unwrap();
let code_at_120 = compute_totp_code(&cfg, 120).unwrap();
assert_eq!(code_at_30, steam_output_reference(truncated_for(&secret, 1)));
assert_eq!(code_at_60, steam_output_reference(truncated_for(&secret, 2)));
assert_eq!(code_at_120, steam_output_reference(truncated_for(&secret, 4)));
}
#[test]
fn steam_output_is_exactly_5_chars_regardless_of_digits() {
let secret = b"hello world!".to_vec();
for digits in [4u8, 5, 6, 7, 8] {
let cfg = TotpConfig {
secret: Zeroizing::new(secret.clone()),
algorithm: TotpAlgorithm::Sha1,
digits,
period_seconds: 30,
kind: TotpKind::Steam,
};
let code = compute_totp_code(&cfg, 0).unwrap();
assert_eq!(code.len(), 5, "Steam output must be 5 chars (digits={})", digits);
}
}
#[test]
fn steam_output_uses_only_alphabet_chars() {
const ALPHA: &str = "23456789BCDFGHJKMNPQRTVWXY";
let secret = b"hello world!".to_vec();
let cfg = TotpConfig {
secret: Zeroizing::new(secret),
algorithm: TotpAlgorithm::Sha1,
digits: 5,
period_seconds: 30,
kind: TotpKind::Steam,
};
for t in 0u64..1000 {
let code = compute_totp_code(&cfg, t * 30).unwrap();
for ch in code.chars() {
assert!(ALPHA.contains(ch), "char {ch:?} not in Steam alphabet (t={t})");
}
}
}
#[test]
fn steam_alphabet_excludes_ambiguous_glyphs() {
// Authoritative Steam Guard alphabet from Valve's Steam Mobile
// Authenticator: 26 chars, excludes 0/O, 1/I/L, S, A, E, U, Z.
// (Note: '5' IS in the alphabet — S is excluded, so 5 is unambiguous.)
const ALPHA: &str = "23456789BCDFGHJKMNPQRTVWXY";
for ch in ['0', 'O', '1', 'I', 'L', 'S', 'A', 'Z'] {
assert!(!ALPHA.contains(ch), "ambiguous glyph {ch:?} must not be in alphabet");
}
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View 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.

View File

@@ -0,0 +1,401 @@
# relicario — Extension Plan 1C-β₁ (Typed-Item Forms) Design
Second of three sub-plans porting the extension to the typed-item core. 1C-α (foundation) shipped Login-parity; 1C-β₁ adds the **other 5 typed-item forms** so the extension can daily-drive every typed item the Rust core knows about (except Document, deferred to γ for attachment dependencies). Custom-fields editor, vault-settings view, and advanced generator UI move to **β₂**.
Reference: 1C-α design `docs/superpowers/specs/2026-04-20-relicario-extension-1c-alpha-design.md` (commits `a1d733d`, `ad6d8af`); 1C-α implementation merged 2026-04-22 (`2b83105`, tag `plan-1c-alpha-complete`).
## Plan 1C decomposition (post-α refinement)
| Sub-plan | Status | Scope |
|---|---|---|
| 1C-α | shipped 2026-04-22 | WASM rebuild, shared TS types, SessionHandle SW, split router with sender checks, full security architecture, Login-parity popup, zxcvbn setup gate |
| **1C-β₁ (this spec)** | proposed | 5 typed-item forms (SecureNote / Identity / Card / Key / Totp) + Rust Steam encoding fix |
| 1C-β₂ | proposed | Custom fields editor, full vault-settings view, advanced generator-request UI |
| 1C-γ | proposed | Attachments (with `putBlob` Git-Data-API fallback), Document type, trash view, field-history view, device management |
## Design Decisions (from brainstorming)
| Question | Decision | Why |
|---|---|---|
| Does β stay one plan or split? | **β₁ + β₂** | Settings view + custom-fields editor are heavy independently; splitting unlocks daily-driver typed items as soon as β₁ ships |
| Document type in β₁? | **Defer to γ** | `DocumentCore.primary_attachment` is required; without attachment upload there's nothing to attach |
| Form visual style? | **Type-flavored, muted** | "Signature block + uniform rows" pattern: each type gets one accent panel + plain rows for the rest. Lower contrast than vivid v1 mockup, sits with the dark-terminal aesthetic |
| Totp variants in β₁? | **TOTP + Steam** (Hotp deferred) | Steam Guard is widely used; Hotp is rare and needs counter-persistence UX |
| Steam encoding in Rust core? | **Yes — fix as Slice 1** | Existing `compute_totp_code` returns decimal output for `kind: 'steam'`, which doesn't match Steam Guard. ~30 line patch + test vectors |
| Sequencing? | **5 slices: Rust Steam → shared helpers + Login refactor → SecureNote+Identity → Card+Key → Totp** | Helper extraction pays off across 5 forms; pairing trivial types together; Totp last because it depends on Steam fix |
| Custom fields in β₁? | **No — β₂** | Custom fields are the single hardest UI in β; deserves its own focused cycle |
## Scope
### In
- 5 typed-item forms wired end-to-end (view + add + edit + delete): SecureNote, Identity, Card, Key, Totp.
- Form style: muted "signature block + uniform rows" with thin left-border accent per type.
- **Steam Guard** support on Totp items: `kind: 'totp'` and `kind: 'steam'` selectable in the form; UI toggle (no dropdown).
- **Rust core fix**: `compute_totp_code` learns the Steam alphabet (`23456789BCDFGHJKMNPQRTVWXY`, 5-char output).
- Concealed-with-reveal+copy pattern applied to: `Card.number`, `Card.cvv`, `Card.pin`, `Key.key_material`, `Totp.secret` (rendered as base32). Re-uses Login's existing convention via a new shared helper.
- Shared helper module `extension/src/popup/components/fields.ts` for row / concealed-row / signature-block primitives. **Login refactored onto it** as the reference implementation (net code reduction even before adding 5 new types).
- `item-detail.ts` and `item-form.ts` collapse to thin dispatchers calling `types/<x>.renderDetail()` / `renderForm()`.
- "New…" picker on the toolbar's `+ New` button, listing all 7 types (Document greyed/disabled with "coming in γ" tooltip).
- Per-type Vitest unit tests for the form→Item transform.
### Out (→ β₂ / γ)
- Custom fields editor (sections + per-field add/rename/remove/reorder). β₂.
- Vault-settings view (retention, generator defaults, attachment caps). β₂.
- Advanced generator-request UI (BIP39 vs Random, charset toggles, length slider). β₂.
- Hotp counter UI. β₂ or later.
- Per-type form custom defaults (e.g. exposing `Totp.digits` / `Totp.period_seconds`). β₂ via the custom-fields editor.
- Document type. γ.
- Attachment upload, trash view, field-history view, device-management UI. γ.
## File map
### New
```
crates/relicario-core/src/item_types/totp.rs # Steam alphabet output (modified)
extension/src/popup/components/fields.ts # row / concealed-row / signature-block helpers
extension/src/popup/components/types/login.ts # extracted from existing item-detail/form Login branches
extension/src/popup/components/types/secure-note.ts
extension/src/popup/components/types/identity.ts
extension/src/popup/components/types/card.ts
extension/src/popup/components/types/key.ts
extension/src/popup/components/types/totp.ts
extension/src/popup/components/__tests__/fields.test.ts
extension/src/popup/components/types/__tests__/save-shape.test.ts
```
### Modified
```
extension/src/popup/components/item-detail.ts # dispatch on item.type → types/<x>.renderDetail
extension/src/popup/components/item-form.ts # dispatch on item.type → types/<x>.renderForm
extension/src/popup/components/item-list.ts # "+ New" button opens type picker
extension/src/popup/styles.css # signature-block + field-row classes
crates/relicario-core/src/item_types/totp.rs # see above
```
### Deleted
None.
## Slice 1 — Rust Steam encoding
**File**: `crates/relicario-core/src/item_types/totp.rs`
Patch shape:
```rust
const STEAM_ALPHABET: &[u8] = b"23456789BCDFGHJKMNPQRTVWXY";
pub fn compute_totp_code(config: &TotpConfig, now_unix_seconds: u64) -> Result<String> {
let counter = match config.kind {
TotpKind::Totp => now_unix_seconds / config.period_seconds as u64,
TotpKind::Hotp { counter } => counter,
TotpKind::Steam => now_unix_seconds / config.period_seconds as u64,
};
// ... existing HMAC + dynamic-truncation logic produces `truncated: u32` ...
if matches!(config.kind, TotpKind::Steam) {
let mut t = truncated;
let mut out = String::with_capacity(5);
for _ in 0..5 {
out.push(STEAM_ALPHABET[(t % 26) as usize] as char);
t /= 26;
}
return Ok(out);
}
let modulus = 10u32.pow(config.digits as u32);
Ok(format!("{:0width$}", truncated % modulus, width = config.digits as usize))
}
```
`STEAM_ALPHABET` deliberately excludes `0`, `O`, `1`, `I`, `L`, `S`, `5`, `A`, `Z`. Same alphabet used by Steam Mobile Authenticator and WinAuth.
### Tests (in the same file)
- `steam_known_vector`: pin a `(secret, counter)` to its known Steam output. If a citeable third-party vector is available, prefer it; otherwise pin the value our impl computes today (regression test against accidental future change).
- `steam_alphabet_no_ambiguous_chars`: `assert!(!STEAM_ALPHABET.contains(&b'0' / &b'O' / &b'1' / &b'I' / &b'L' / &b'S' / &b'5' / &b'A' / &b'Z'))`.
- `steam_output_is_5_chars`: regardless of `config.digits`, Steam output is exactly 5 characters.
- `totp_kind_decimal_unaffected`: existing RFC 6238 vectors for `kind: 'totp'` still pass byte-for-byte.
### WASM impact
`totp_compute` in `crates/relicario-wasm/src/lib.rs` doesn't change — it forwards `kind` through serde. The TS `TotpKind` shape in `extension/src/shared/types.ts` is already correct. Only the Rust-side compute body changes.
## Slice 2 — Shared field helpers + Login refactor
### `extension/src/popup/components/fields.ts`
Pure functions returning HTML strings + a small mount-time event-binding helper. No DOM ownership, no state.
```ts
import { escapeHtml } from '../popup';
export interface RowOpts {
label: string;
value: string;
copyable?: boolean;
href?: string; // wraps value in <a target="_blank" rel="noopener">
monospace?: boolean;
multiline?: boolean; // renders as <pre> instead of inline
}
export function renderRow(opts: RowOpts): string;
export interface ConcealedRowOpts {
id: string; // unique within the rendered detail view
label: string;
value: string; // plaintext; rendered hidden until user reveals
monospace?: boolean;
multiline?: boolean; // <pre> when revealed; "•••• (N chars)" when hidden
}
export function renderConcealedRow(opts: ConcealedRowOpts): string;
export interface SignatureBlockOpts {
accent?: 'blue' | 'green' | 'amber' | 'red'; // default 'blue'
children: string; // HTML, caller's responsibility to escape
}
export function renderSignatureBlock(opts: SignatureBlockOpts): string;
/// Wire reveal-toggle + copy handlers for all rows rendered above.
/// Call once after the parent's innerHTML lands.
export function wireFieldHandlers(scope: HTMLElement): void;
```
`wireFieldHandlers` looks for `data-field-action="reveal"` and `data-field-action="copy"` attributes inside `scope` and binds click handlers. Reveal toggles a `data-revealed` attribute on the row's value `<span>`/`<pre>`; copy uses `navigator.clipboard.writeText` and flashes a 1.5s "copied" badge.
### CSS additions in `extension/src/popup/styles.css`
```css
.field-row {
display: grid;
grid-template-columns: 90px 1fr auto;
gap: 8px 10px;
align-items: baseline;
padding: 4px 0;
font-size: 12px;
}
.field-row__label { color: #8b949e; }
.field-row__value { color: #c9d1d9; }
.field-row__value.monospace { font-family: "SF Mono", "JetBrains Mono", monospace; }
.field-row__value pre { margin: 0; white-space: pre-wrap; word-break: break-word; }
.field-row__actions { display: flex; gap: 6px; font-size: 11px; color: #8b949e; }
.field-row__actions button {
background: transparent; border: 0; color: inherit;
cursor: pointer; padding: 0; font: inherit;
}
.field-row__actions button:hover { color: #c9d1d9; }
.sig-block {
background: #161b22;
border: 1px solid #30363d;
border-left: 3px solid #1f6feb;
border-radius: 5px;
padding: 14px;
margin-bottom: 10px;
}
.sig-block--blue { border-left-color: #1f6feb; }
.sig-block--green { border-left-color: #3fb950; }
.sig-block--amber { border-left-color: #d29922; }
.sig-block--red { border-left-color: #f85149; }
```
### Login refactor (same slice)
Extract `popup/components/types/login.ts` exporting `renderDetail(app, item)` / `renderForm(app, mode, existing)` / private `saveLogin(...)`. The bodies are the existing Login-branch code from `item-detail.ts` / `item-form.ts`, ported to use `renderRow` / `renderConcealedRow` / `renderSignatureBlock` instead of inline string concatenation.
Net-line check: this slice should reduce total LOC slightly (helper consolidation) before adding any new types.
### Helper unit tests (`fields.test.ts`)
- `renderRow` produces expected HTML for plain / copyable / linked / monospace / multiline cases.
- `renderConcealedRow` produces the hidden initial state, includes the unique id in `data-field-id`, has show + copy buttons, hides multiline value as `"•••• (N chars)"`.
- `renderSignatureBlock` wraps children correctly with each accent class.
- `wireFieldHandlers`: with a happy-dom `<div>` containing rendered rows, clicking the show button toggles `data-revealed`; clicking copy calls `navigator.clipboard.writeText` (mock).
## Slices 35 — Per-type designs
### SecureNote (Slice 3a)
**Data**: `SecureNoteCore { body: Zeroizing<String> }`.
**Detail view**: title at top, then a single signature block (accent `green`) containing the body rendered as a concealed `<pre>` block (multiline concealed row). Copy button copies the whole body verbatim. No other rows.
**Form view**: a single `<textarea>` (10-row default) for the body. Title at the top (always required on the Item envelope, not on the body field). No signature-block visual on the form — the textarea is the content.
### Identity (Slice 3b)
**Data**: `IdentityCore { full_name?, address? (multiline), phone?, email?, date_of_birth? }`.
**Detail view**: title at top; signature block (accent `amber`) with a monogram "avatar" (initials extracted from `full_name`, or `?`) + the name in larger type. Below the block, plain rows in this order: phone, email, address (multiline), date_of_birth (formatted as the user's locale via `toLocaleDateString`). Email and phone are copyable.
**Form view**: plain rows:
- `full_name`: `<input type="text">`
- `address`: `<textarea>` (3 rows)
- `phone`: `<input type="tel">`
- `email`: `<input type="email">` (browser-native validation surfaces on submit)
- `date_of_birth`: `<input type="date">` — wire format matches Rust `NaiveDate`'s `"YYYY-MM-DD"` serialization
Empty strings → `undefined` per the established convention.
### Card (Slice 4a)
**Data**: `CardCore { number?, holder?, expiry?: MonthYear, cvv?, pin?, kind: CardKind }`. `MonthYear = { month, year }`. `CardKind = 'credit' | 'debit' | 'gift' | 'loyalty' | 'other'`.
**Detail view**: title at top; signature block (accent `blue`) matching the v2 mockup:
- Top label band: `"<BRAND> · <KIND>"` uppercased (brand derived from card BIN; see below)
- Masked card number with reveal toggle, monospace, letter-spaced
- Footer: HOLDER (left) and EXPIRES (right)
Below the signature block: concealed rows for `cvv` and `pin`.
Brand derivation (display-only, not stored):
```ts
function brandFromNumber(num: string): string {
if (/^3[47]/.test(num)) return 'AMEX';
if (/^4/.test(num)) return 'VISA';
if (/^5[1-5]/.test(num)) return 'MASTERCARD';
if (/^6/.test(num)) return 'DISCOVER';
return '';
}
```
**Form view**: plain rows:
- `number`: `<input type="text" inputmode="numeric">`, no formatting on the form (paste-friendly)
- `holder`: `<input type="text">`
- `expiry`: two side-by-side `<select>`s — month (`01``12`) + year (current ± 25). Saves as `{ month: number, year: number }`. Empty selection → `undefined` for the whole `expiry`.
- `cvv`: `<input type="password" inputmode="numeric" maxlength="4">`
- `pin`: `<input type="password" inputmode="numeric" maxlength="8">`
- `kind`: `<select>` with the 5 enum values, default `credit`
### Key (Slice 4b)
**Data**: `KeyCore { key_material: Zeroizing<String>, label?, public_key?, algorithm? }`. `key_material` is required.
**Detail view**: title at top; signature block (accent `green`) showing the `key_material` as a concealed monospace `<pre>` block. Below: plain rows for `label`, `algorithm` (free-form text), `public_key` (multiline monospace, **not concealed** — public keys are public).
**Form view**: plain rows:
- `key_material`: `<textarea>` (8 rows, monospace) with a sibling `[show]` toggle button (since `<textarea>` doesn't honor `type="password"`). Default state: a CSS rule sets `-webkit-text-security: disc` to mask characters; clicking the toggle removes the rule.
- `label`: `<input type="text">`
- `public_key`: `<textarea>` (4 rows, monospace, no masking)
- `algorithm`: `<input type="text">` placeholder `"ed25519"`
### Totp (Slice 5)
**Data**: `TotpCore { config: TotpConfig, issuer?, label? }`. `TotpConfig = { secret: number[], algorithm: 'sha1'|'sha256'|'sha512', digits: number, period_seconds: number, kind: TotpKind }`. β₁ supports `kind: 'totp'` and `kind: 'steam'`.
**Detail view**: title at top (uses `issuer / label` to construct a default if title is empty: `"<issuer>: <label>"`). Signature block (accent `blue`) shows:
- Large monospace rotating code (centered, 28pt)
- Thin SVG countdown ring at the right side, sized 32×32
Below the block: plain rows for `issuer`, `label`, and a concealed row for `secret` (rendered as base32 via `shared/base32.ts` `base32Encode`).
The ring re-tick interval is 1000ms; on each tick it calls `chrome.runtime.sendMessage({ type: 'get_totp', id })` (the existing α handler in `router/popup-only.ts` — no new message type). The countdown value is `(expires_at - now)` per the existing `TotpResponse`.
**Form view**: a kind toggle at the top, then plain rows:
```
┌─ kind ──────────────────────────┐
│ [● TOTP] [○ Steam Guard] │
└─────────────────────────────────┘
secret (base32): [_______________]
issuer: [_______________]
label: [_______________]
```
Toggle is a two-button group; click switches `state.kind` and re-renders the small subtitle below ("Standard time-based codes" vs "Steam Mobile Authenticator (5-char alphanumeric)"). For both kinds, `digits` / `period_seconds` / `algorithm` are written with their defaults (`6`/`30`/`sha1` for TOTP; `5`/`30`/`sha1` for Steam — Steam's compute uses the alphabet, ignoring the digits field). Power users who need non-default values use the CLI; β₂ may add a `[more options ▾]` disclosure on the Totp form if this turns out to bite real users.
`secret` parsed via `base32Decode` from `shared/base32.ts` (already exists). Empty string is rejected with a friendly error from the popup's `humanizeError` path.
### Dispatcher updates
`item-detail.ts` after β₁:
```ts
import * as login from './types/login';
import * as secureNote from './types/secure-note';
import * as identity from './types/identity';
import * as card from './types/card';
import * as key from './types/key';
import * as totp from './types/totp';
export async function renderItemDetail(app: HTMLElement): Promise<void> {
const item = getState().selectedItem;
if (!item) { navigate('list'); return; }
switch (item.type) {
case 'login': return login.renderDetail(app, item);
case 'secure_note': return secureNote.renderDetail(app, item);
case 'identity': return identity.renderDetail(app, item);
case 'card': return card.renderDetail(app, item);
case 'key': return key.renderDetail(app, item);
case 'totp': return totp.renderDetail(app, item);
case 'document': return renderComingSoonPlaceholder(app, item.type);
}
}
```
`item-form.ts` follows the same shape with `renderForm(app, mode, existing)`.
### "New…" picker
`item-list.ts`'s `+ New` button opens a small picker (popover anchored to the button):
```
new item
🔑 login
📝 secure note
🪪 identity
💳 card
🗝 key
⏱ totp
📄 document ← greyed; tooltip "coming in γ — needs attachment upload"
```
Selecting a type stores `state.newType` (transient — added to PopupState with `'login' | 'secure_note' | …`) and navigates to `'add'`. The form dispatcher reads `state.newType` for add-mode and `state.selectedItem.type` for edit-mode.
The popover lives in the popup's own DOM (no closed Shadow DOM needed — the popup is its own origin and not subject to page-injection threats). Standard `<div>` with `position: absolute` anchored to the button.
## Testing
### Rust
`cargo test --workspace` stays green. New tests in `crates/relicario-core/src/item_types/totp.rs` listed in §Slice 1.
### Vitest
Existing 55 tests stay green. New:
- `extension/src/popup/components/__tests__/fields.test.ts` (helper unit tests, ~12 cases).
- `extension/src/popup/components/types/__tests__/save-shape.test.ts` (per-type form→Item transform, ~5 cases × ~3 sub-assertions = ~15 cases).
The save-shape tests use happy-dom to render each form's HTML, populate inputs, fire the save handler, and intercept the `add_item` message via a `vi.fn()` shim of `chrome.runtime.sendMessage`. Asserts cover:
- SecureNote: `core.body === '<input value>'`, `core.type === 'secure_note'`.
- Identity: each present field in JS shape matches the wire format; absent fields are `undefined` (not empty string).
- Card: `expiry === { month: 8, year: 2029 }`; concealed fields (`number`/`cvv`/`pin`) round-trip through the form values; `kind` matches the select.
- Key: `key_material` always present; `algorithm` free-form.
- Totp: `config.secret === Array.from(base32Decode('JBSWY3DPEHPK3PXP'))`; `config.kind === 'totp'` or `'steam'` depending on toggle; for Steam, `config.digits === 5`.
### Manual matrix
Re-run the α matrix's 11 steps (§5.4 of α spec) plus, per type:
1. Add a new item of the type → it appears in the list with the right icon.
2. Open the item → detail view renders correctly (signature block + rows; no console errors).
3. For types with concealed fields: click reveal → value appears; click copy → clipboard contains the value.
4. Edit → save → list updates with new modified time; detail reflects changes.
5. Trash → moves out of the live list; CLI `relicario list --trashed` shows it.
6. For Totp: code rotates every 30s; Steam Guard kind produces 5-char alphanumeric; TOTP kind produces 6-digit decimal; switching kinds in the edit form re-renders the detail view's compute output correctly after save.
### Acceptance
- `cargo test --workspace` green.
- `bun run test` green.
- `bun run build:all` green for both Chrome and Firefox.
- `git grep -n 'coming-soon\|Coming in' extension/src/popup/components/` returns hits ONLY for `'document'`.
- All 5 type matrices pass on Chrome and Firefox.
- No new lint regressions; `git grep -n '@ts-nocheck' extension/src/` returns zero.
## Open questions deferred to the plan
- Exact CSS sizing of the Totp signature block's countdown ring (32px or 40px). Picked at implementation time.
- Whether the Card brand-from-BIN is comprehensive enough (currently 4 brands). Likely fine for α/β₁ — extending the table is a one-line change.
- For Steam toggle UX: a two-button group or a dropdown. Brainstorming locked in two-button; implementation may push back if it's awkward at popup width.
- Whether to expose `Totp.algorithm` / `digits` / `period_seconds` to power users via a `[more options ▾]` disclosure on the form. β₁ defaults them; β₂ revisits if the CLI workaround friction is real.

View 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 111 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.
- [ ] **FF1FF11.** 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.*

View File

@@ -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=="],
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 886 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

View 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

View File

@@ -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

View File

@@ -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": []
}

View File

@@ -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": []
}

View File

@@ -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"
}

View File

@@ -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;

View File

@@ -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 {

View File

@@ -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;

View File

@@ -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];
await handleSingleCandidate(candidates[0][0]);
} else {
showPicker(passwordField, 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,
});
if (credResp?.ok) {
chrome.runtime.sendMessage({
type: 'fill_credentials',
username: credResp.data.username,
password: credResp.data.password,
});
}) as Response;
if (!credResp?.ok) return;
const data = (credResp as CredentialsResponse).data;
if ('requires_ack' in data && data.requires_ack) {
showAckHint(data.hostname);
return;
}
} else {
// Multiple matches — show inline picker.
showPicker(icon, candidates);
// Discriminated union: must be the {username, password} variant here.
if ('username' in data && 'password' in data) {
fillFields(data.username, data.password);
}
});
}
/// Show a small dropdown picker below the icon for selecting among multiple candidates.
/// 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);
}

View 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();
},
};
}

View File

@@ -0,0 +1,149 @@
import { describe, expect, it, vi } from 'vitest';
import {
renderRow,
renderConcealedRow,
renderSignatureBlock,
wireFieldHandlers,
} from '../fields';
describe('renderRow', () => {
it('plain row contains label + value', () => {
const html = renderRow({ label: 'username', value: 'alice' });
expect(html).toContain('username');
expect(html).toContain('alice');
expect(html).toContain('field-row');
});
it('copyable row exposes a copy action', () => {
const html = renderRow({ label: 'email', value: 'alice@example.com', copyable: true });
expect(html).toContain('data-field-action="copy"');
});
it('href row wraps value in an external anchor', () => {
const html = renderRow({ label: 'url', value: 'https://example.com', href: 'https://example.com' });
expect(html).toContain('href="https://example.com"');
expect(html).toContain('target="_blank"');
expect(html).toContain('rel="noopener noreferrer"');
});
it('monospace flag toggles the monospace class', () => {
const html = renderRow({ label: 'fingerprint', value: 'AB:CD', monospace: true });
expect(html).toContain('monospace');
});
it('multiline value renders inside a <pre>', () => {
const html = renderRow({ label: 'address', value: '1 Main\n2 Main', multiline: true });
expect(html).toContain('<pre');
});
it('escapes HTML in value and label', () => {
const html = renderRow({ label: '<script>x</script>', value: '"&<>' });
expect(html).not.toContain('<script>');
expect(html).toContain('&amp;');
expect(html).toContain('&lt;');
});
});
describe('renderConcealedRow', () => {
it('initial state hides the value behind a placeholder', () => {
const html = renderConcealedRow({ id: 'pw1', label: 'password', value: 'hunter2' });
expect(html).toContain('data-field-id="pw1"');
expect(html).toContain('data-revealed="false"');
expect(html).toContain('••••');
// Plaintext is in a data attribute on the row, NOT in the visible textContent.
expect(html).not.toMatch(/>hunter2</);
});
it('exposes show + copy actions', () => {
const html = renderConcealedRow({ id: 'pw1', label: 'password', value: 'hunter2' });
expect(html).toContain('data-field-action="reveal"');
expect(html).toContain('data-field-action="copy"');
});
it('multiline concealed shows char count when hidden', () => {
const html = renderConcealedRow({ id: 'k1', label: 'key', value: 'abcdefghij', multiline: true });
expect(html).toContain('•••• (10 chars)');
});
});
describe('renderSignatureBlock', () => {
it('default accent is blue', () => {
const html = renderSignatureBlock({ children: '<p>hi</p>' });
expect(html).toContain('sig-block--blue');
expect(html).toContain('<p>hi</p>');
});
it('honors accent prop', () => {
expect(renderSignatureBlock({ accent: 'green', children: '' })).toContain('sig-block--green');
expect(renderSignatureBlock({ accent: 'amber', children: '' })).toContain('sig-block--amber');
expect(renderSignatureBlock({ accent: 'red', children: '' })).toContain('sig-block--red');
});
});
describe('wireFieldHandlers', () => {
it('reveal toggle flips data-revealed and swaps placeholder for plaintext', () => {
document.body.innerHTML = renderConcealedRow({
id: 'pw1',
label: 'password',
value: 'hunter2',
});
wireFieldHandlers(document.body);
const row = document.querySelector('[data-field-id="pw1"]') as HTMLElement;
const revealBtn = row.querySelector('[data-field-action="reveal"]') as HTMLButtonElement;
const valueEl = row.querySelector('[data-field-role="value"]') as HTMLElement;
expect(row.getAttribute('data-revealed')).toBe('false');
expect(valueEl.textContent).toContain('••••');
revealBtn.click();
expect(row.getAttribute('data-revealed')).toBe('true');
expect(valueEl.textContent).toBe('hunter2');
});
it('copy button writes the row value to the clipboard', async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText },
});
document.body.innerHTML = renderRow({
label: 'email',
value: 'alice@example.com',
copyable: true,
});
wireFieldHandlers(document.body);
const copyBtn = document.querySelector('[data-field-action="copy"]') as HTMLButtonElement;
copyBtn.click();
expect(writeText).toHaveBeenCalledWith('alice@example.com');
});
});
describe('concealed-row round-trip with special characters', () => {
it('reveals a value containing double quotes correctly', () => {
document.body.innerHTML = renderConcealedRow({ id: 'pw', label: 'p', value: 'a"b"c' });
wireFieldHandlers(document.body);
const btn = document.querySelector('[data-field-action="reveal"]') as HTMLButtonElement;
btn.click();
const valueEl = document.querySelector('[data-field-role="value"]') as HTMLElement;
expect(valueEl.textContent).toBe('a"b"c');
});
it('reveals a value containing single quotes correctly', () => {
document.body.innerHTML = renderConcealedRow({ id: 'pw', label: 'p', value: "it's & ok" });
wireFieldHandlers(document.body);
const btn = document.querySelector('[data-field-action="reveal"]') as HTMLButtonElement;
btn.click();
const valueEl = document.querySelector('[data-field-role="value"]') as HTMLElement;
expect(valueEl.textContent).toBe("it's & ok");
});
it('copies a value containing double quotes correctly', async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText },
});
document.body.innerHTML = renderRow({ label: 'p', value: 'a"b"c', copyable: true });
wireFieldHandlers(document.body);
(document.querySelector('[data-field-action="copy"]') as HTMLButtonElement).click();
expect(writeText).toHaveBeenCalledWith('a"b"c');
});
});

View File

@@ -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 });
}
});
}

View File

@@ -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();
}

View File

@@ -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>&uarr;&darr;</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;
}
}

View File

@@ -0,0 +1,119 @@
/// Field rendering primitives used by every typed-item detail view.
///
/// Pure functions that return HTML strings. Caller is responsible for
/// mounting the strings into the DOM (typically via `app.innerHTML = ...`).
/// After mounting, call `wireFieldHandlers(scope)` once to bind reveal +
/// copy click handlers on any rendered rows.
import { escapeHtml } from '../popup';
export interface RowOpts {
label: string;
value: string;
copyable?: boolean;
href?: string;
monospace?: boolean;
multiline?: boolean;
}
/// Plain label/value row. Optional copy button, optional anchor wrap,
/// optional monospace styling, optional multiline (renders in a <pre>).
export function renderRow(opts: RowOpts): string {
const { label, value, copyable, href, monospace, multiline } = opts;
const valueClass = `field-row__value${monospace ? ' monospace' : ''}`;
let valueHtml: string;
if (multiline) {
valueHtml = `<pre>${escapeHtml(value)}</pre>`;
} else if (href) {
valueHtml = `<a href="${escapeHtml(href)}" target="_blank" rel="noopener noreferrer">${escapeHtml(value)}</a>`;
} else {
valueHtml = escapeHtml(value);
}
const actions = copyable
? `<button type="button" data-field-action="copy" data-field-value="${escapeHtml(value)}">copy</button>`
: '';
return `
<div class="field-row">
<span class="field-row__label">${escapeHtml(label)}</span>
<span class="${valueClass}" data-field-role="value">${valueHtml}</span>
<span class="field-row__actions">${actions}</span>
</div>
`;
}
export interface ConcealedRowOpts {
id: string;
label: string;
value: string;
monospace?: boolean;
multiline?: boolean;
}
/// Concealed row — value rendered hidden until the user clicks "show".
/// Plaintext is stored in `data-field-value` on the row element and copied
/// to the visible value span on reveal. Copy button always copies plaintext.
export function renderConcealedRow(opts: ConcealedRowOpts): string {
const { id, label, value, monospace, multiline } = opts;
const placeholder = multiline ? `•••• (${value.length} chars)` : '••••';
const valueClass = `field-row__value${monospace ? ' monospace' : ''}`;
return `
<div class="field-row" data-field-id="${escapeHtml(id)}" data-revealed="false" data-field-value="${escapeHtml(value)}" data-field-multiline="${multiline ? 'true' : 'false'}">
<span class="field-row__label">${escapeHtml(label)}</span>
<span class="${valueClass}" data-field-role="value">${escapeHtml(placeholder)}</span>
<span class="field-row__actions">
<button type="button" data-field-action="reveal">show</button>
<button type="button" data-field-action="copy" data-field-value="${escapeHtml(value)}">copy</button>
</span>
</div>
`;
}
export interface SignatureBlockOpts {
accent?: 'blue' | 'green' | 'amber' | 'red';
children: string;
}
/// Container for the type-specific signature panel. `children` is HTML
/// the caller has already produced (and escaped where needed).
export function renderSignatureBlock(opts: SignatureBlockOpts): string {
const accent = opts.accent ?? 'blue';
return `
<div class="sig-block sig-block--${accent}">${opts.children}</div>
`;
}
/// Wire reveal-toggle + copy click handlers within `scope`. Idempotent —
/// safe to call multiple times against the same scope.
export function wireFieldHandlers(scope: HTMLElement): void {
scope.querySelectorAll<HTMLButtonElement>('[data-field-action="reveal"]').forEach((btn) => {
btn.addEventListener('click', () => {
const row = btn.closest('[data-field-id]') as HTMLElement | null;
if (!row) return;
const valueEl = row.querySelector('[data-field-role="value"]') as HTMLElement | null;
if (!valueEl) return;
const revealed = row.getAttribute('data-revealed') === 'true';
const plaintext = row.getAttribute('data-field-value') ?? '';
const multiline = row.getAttribute('data-field-multiline') === 'true';
if (revealed) {
const placeholder = multiline ? `•••• (${plaintext.length} chars)` : '••••';
valueEl.textContent = placeholder;
row.setAttribute('data-revealed', 'false');
btn.textContent = 'show';
} else {
valueEl.textContent = plaintext;
row.setAttribute('data-revealed', 'true');
btn.textContent = 'hide';
}
});
});
scope.querySelectorAll<HTMLButtonElement>('[data-field-action="copy"]').forEach((btn) => {
btn.addEventListener('click', async () => {
const value = btn.getAttribute('data-field-value') ?? '';
try { await navigator.clipboard.writeText(value); } catch { /* swallow — UX is the visual flash below */ }
const original = btn.textContent;
btn.textContent = 'copied';
setTimeout(() => { if (btn.textContent === 'copied') btn.textContent = original; }, 1500);
});
});
}

View File

@@ -0,0 +1,48 @@
/// Typed-item detail view dispatcher. Each type's renderDetail lives in
/// its own module under ./types/. Document stays "coming soon" until γ.
import { navigate } from '../popup';
import type { Item } from '../../shared/types';
import { getState } from '../popup';
import * as login from './types/login';
import * as secureNote from './types/secure-note';
import * as identity from './types/identity';
import * as card from './types/card';
import * as key from './types/key';
import * as totp from './types/totp';
export async function renderItemDetail(app: HTMLElement): Promise<void> {
// Tear down any tickers/handlers from a previous detail render before
// the next one boots up. Each type module owns its own teardown; we
// call all of them since the dispatcher doesn't know which was active.
login.teardown();
secureNote.teardown();
identity.teardown();
card.teardown();
key.teardown();
totp.teardown();
const item = getState().selectedItem;
if (!item) { navigate('list'); return; }
switch (item.type) {
case 'login': return login.renderDetail(app, item);
case 'secure_note': return secureNote.renderDetail(app, item);
case 'identity': return identity.renderDetail(app, item);
case 'card': return card.renderDetail(app, item);
case 'key': return key.renderDetail(app, item);
case 'totp': return totp.renderDetail(app, item);
case 'document': return renderComingSoon(app, item);
}
}
function renderComingSoon(app: HTMLElement, item: Item): void {
app.innerHTML = `
<div class="pad">
<div class="detail-title" style="margin-bottom:16px;">${item.title}</div>
<p class="muted">The <strong>${item.type}</strong> item type is not editable in the extension yet.</p>
<div class="form-actions"><button class="btn" id="back-btn">back</button></div>
</div>
`;
document.getElementById('back-btn')?.addEventListener('click', () => navigate('list'));
}

View File

@@ -0,0 +1,44 @@
/// Typed-item add/edit form dispatcher. Each type's renderForm lives in
/// its own module under ./types/. Document stays "coming soon" until γ.
import { navigate, getState } from '../popup';
import type { Item, ItemType } from '../../shared/types';
import * as login from './types/login';
import * as secureNote from './types/secure-note';
import * as identity from './types/identity';
import * as card from './types/card';
import * as key from './types/key';
import * as totp from './types/totp';
export function renderItemForm(app: HTMLElement, mode: 'add' | 'edit'): void {
login.teardown(); // detail-view's ticker/listener don't leak into form
secureNote.teardown();
identity.teardown();
card.teardown();
key.teardown();
totp.teardown();
const state = getState();
const existing = mode === 'edit' ? state.selectedItem : null;
const type: ItemType = existing?.type ?? state.newType ?? 'login';
switch (type) {
case 'login': return login.renderForm(app, mode, existing);
case 'secure_note': return secureNote.renderForm(app, mode, existing);
case 'identity': return identity.renderForm(app, mode, existing);
case 'card': return card.renderForm(app, mode, existing);
case 'key': return key.renderForm(app, mode, existing);
case 'totp': return totp.renderForm(app, mode, existing);
case 'document': return renderComingSoon(app, type);
}
}
function renderComingSoon(app: HTMLElement, type: ItemType): void {
app.innerHTML = `
<div class="pad">
<div class="detail-title" style="margin-bottom:16px;">${type.replace('_', ' ')}</div>
<p class="muted">Editing <strong>${type}</strong> items is not available yet.</p>
<div class="form-actions"><button class="btn" id="back-btn">back</button></div>
</div>
`;
document.getElementById('back-btn')?.addEventListener('click', () => navigate('list'));
}

View File

@@ -0,0 +1,309 @@
/// 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>&uarr;&darr;</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', (e) => {
e.stopPropagation();
showNewTypePicker(e.currentTarget as HTMLElement);
});
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;
}
}
// ----------------------------------------------------------------------
// New-item type picker popover
// ----------------------------------------------------------------------
const NEW_TYPE_OPTIONS: Array<{ type: ItemType; icon: string; label: string; disabled?: boolean; tooltip?: string }> = [
{ type: 'login', icon: '🔑', label: 'login' },
{ type: 'secure_note', icon: '📝', label: 'secure note' },
{ type: 'identity', icon: '🪪', label: 'identity' },
{ type: 'card', icon: '💳', label: 'card' },
{ type: 'key', icon: '🗝', label: 'key' },
{ type: 'totp', icon: '⏱', label: 'totp' },
{ type: 'document', icon: '📄', label: 'document', disabled: true, tooltip: 'coming in γ — needs attachment upload' },
];
function showNewTypePicker(anchor: HTMLElement): void {
document.querySelectorAll('.new-type-picker').forEach((el) => el.remove());
const picker = document.createElement('div');
picker.className = 'new-type-picker';
Object.assign(picker.style, {
position: 'absolute',
background: '#161b22',
border: '1px solid #30363d',
borderRadius: '6px',
boxShadow: '0 4px 12px rgba(0,0,0,0.4)',
padding: '4px',
minWidth: '160px',
zIndex: '999999',
fontSize: '12px',
});
const rect = anchor.getBoundingClientRect();
picker.style.top = `${rect.bottom + 4}px`;
picker.style.left = `${rect.left}px`;
for (const opt of NEW_TYPE_OPTIONS) {
const row = document.createElement('div');
Object.assign(row.style, {
padding: '6px 10px',
cursor: opt.disabled ? 'not-allowed' : 'pointer',
color: opt.disabled ? '#484f58' : '#c9d1d9',
borderRadius: '4px',
display: 'flex', alignItems: 'center', gap: '8px',
});
if (opt.tooltip) row.title = opt.tooltip;
const iconSpan = document.createElement('span');
Object.assign(iconSpan.style, { fontSize: '14px', width: '16px', display: 'inline-block', textAlign: 'center' });
iconSpan.textContent = opt.icon;
const labelSpan = document.createElement('span');
labelSpan.textContent = opt.label;
row.appendChild(iconSpan);
row.appendChild(labelSpan);
if (!opt.disabled) {
row.addEventListener('mouseenter', () => { row.style.background = '#21262d'; });
row.addEventListener('mouseleave', () => { row.style.background = 'transparent'; });
row.addEventListener('click', (ev) => {
ev.stopPropagation();
picker.remove();
document.removeEventListener('click', closeOnOutside);
document.removeEventListener('keydown', closeOnEsc);
setState({ newType: opt.type });
navigate('add');
});
}
picker.appendChild(row);
}
document.body.appendChild(picker);
const closeOnOutside = (ev: MouseEvent) => {
if (!picker.contains(ev.target as Node)) {
picker.remove();
document.removeEventListener('click', closeOnOutside);
document.removeEventListener('keydown', closeOnEsc);
}
};
const closeOnEsc = (ev: KeyboardEvent) => {
if (ev.key === 'Escape') {
picker.remove();
document.removeEventListener('click', closeOnOutside);
document.removeEventListener('keydown', closeOnEsc);
}
};
setTimeout(() => {
document.addEventListener('click', closeOnOutside);
document.addEventListener('keydown', closeOnEsc);
}, 0);
}

View File

@@ -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

View File

@@ -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();
});
}

View File

@@ -0,0 +1,75 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('../../../popup', async () => {
const navigate = vi.fn();
const setState = vi.fn();
const sendMessage = vi.fn();
const getState = vi.fn(() => ({
view: 'add', entries: [], selectedId: null, selectedItem: null, selectedIndex: 0,
searchQuery: '', activeGroup: null, error: null, loading: false,
capturedTabId: null, capturedUrl: '', newType: 'card',
}));
const escapeHtml = (s: string) => s
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
return { navigate, setState, sendMessage, getState, escapeHtml };
});
import { renderForm } from '../card';
import { sendMessage } from '../../../popup';
describe('Card save shape', () => {
beforeEach(() => {
document.body.innerHTML = '<div id="app"></div>';
vi.mocked(sendMessage).mockReset();
vi.mocked(sendMessage).mockResolvedValue({ ok: true, data: { id: 'fakeid0000000000', items: [] } });
});
it('builds an Item with expiry as { month, year } and kind from select', async () => {
const app = document.getElementById('app')!;
renderForm(app, 'add', null);
(document.getElementById('f-title') as HTMLInputElement).value = 'Amex Gold';
(document.getElementById('f-number') as HTMLInputElement).value = '378282246310005';
(document.getElementById('f-holder') as HTMLInputElement).value = 'AARON LEE';
(document.getElementById('f-expiry-month') as HTMLSelectElement).value = '08';
(document.getElementById('f-expiry-year') as HTMLSelectElement).value = '2029';
(document.getElementById('f-cvv') as HTMLInputElement).value = '1234';
(document.getElementById('f-pin') as HTMLInputElement).value = '5678';
(document.getElementById('f-kind') as HTMLSelectElement).value = 'credit';
document.getElementById('save-btn')!.click();
await new Promise(r => setTimeout(r, 5));
const calls = vi.mocked(sendMessage).mock.calls;
const addCall = calls.find(([msg]) => (msg as { type: string }).type === 'add_item');
const msg = addCall![0] as { type: 'add_item'; item: any };
expect(msg.item.type).toBe('card');
expect(msg.item.core).toMatchObject({
type: 'card',
number: '378282246310005',
holder: 'AARON LEE',
expiry: { month: 8, year: 2029 },
cvv: '1234',
pin: '5678',
kind: 'credit',
});
});
it('omits expiry entirely when month or year is empty', async () => {
const app = document.getElementById('app')!;
renderForm(app, 'add', null);
(document.getElementById('f-title') as HTMLInputElement).value = 'Loyalty card';
(document.getElementById('f-kind') as HTMLSelectElement).value = 'loyalty';
// expiry-month + expiry-year left empty.
document.getElementById('save-btn')!.click();
await new Promise(r => setTimeout(r, 5));
const calls = vi.mocked(sendMessage).mock.calls;
const addCall = calls.find(([msg]) => (msg as { type: string }).type === 'add_item');
const msg = addCall![0] as { type: 'add_item'; item: any };
expect(msg.item.core.expiry).toBeUndefined();
expect(msg.item.core.kind).toBe('loyalty');
});
});

View File

@@ -0,0 +1,76 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('../../../popup', async () => {
const navigate = vi.fn();
const setState = vi.fn();
const sendMessage = vi.fn();
const getState = vi.fn(() => ({
view: 'add', entries: [], selectedId: null, selectedItem: null, selectedIndex: 0,
searchQuery: '', activeGroup: null, error: null, loading: false,
capturedTabId: null, capturedUrl: '', newType: 'identity',
}));
const escapeHtml = (s: string) => s
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
return { navigate, setState, sendMessage, getState, escapeHtml };
});
import { renderForm } from '../identity';
import { sendMessage } from '../../../popup';
describe('Identity save shape', () => {
beforeEach(() => {
document.body.innerHTML = '<div id="app"></div>';
vi.mocked(sendMessage).mockReset();
vi.mocked(sendMessage).mockResolvedValue({ ok: true, data: { id: 'fakeid0000000000', items: [] } });
});
it('builds an Item with all populated fields and undefined for blanks', async () => {
const app = document.getElementById('app')!;
renderForm(app, 'add', null);
(document.getElementById('f-title') as HTMLInputElement).value = 'Aaron Lee · personal';
(document.getElementById('f-full-name') as HTMLInputElement).value = 'Aaron Lee';
(document.getElementById('f-email') as HTMLInputElement).value = 'aaron@example.com';
(document.getElementById('f-phone') as HTMLInputElement).value = '+1 555 0100';
(document.getElementById('f-address') as HTMLTextAreaElement).value = '1 Main St\nSpringfield';
(document.getElementById('f-dob') as HTMLInputElement).value = '1985-05-23';
document.getElementById('save-btn')!.click();
await new Promise(r => setTimeout(r, 5));
const calls = vi.mocked(sendMessage).mock.calls;
const addCall = calls.find(([msg]) => (msg as { type: string }).type === 'add_item');
const msg = addCall![0] as { type: 'add_item'; item: any };
expect(msg.item.type).toBe('identity');
expect(msg.item.core).toEqual({
type: 'identity',
full_name: 'Aaron Lee',
email: 'aaron@example.com',
phone: '+1 555 0100',
address: '1 Main St\nSpringfield',
date_of_birth: '1985-05-23',
});
});
it('leaves empty fields out of core entirely', async () => {
const app = document.getElementById('app')!;
renderForm(app, 'add', null);
(document.getElementById('f-title') as HTMLInputElement).value = 'name only';
(document.getElementById('f-full-name') as HTMLInputElement).value = 'Bob';
// Other fields left blank.
document.getElementById('save-btn')!.click();
await new Promise(r => setTimeout(r, 5));
const calls = vi.mocked(sendMessage).mock.calls;
const addCall = calls.find(([msg]) => (msg as { type: string }).type === 'add_item');
const msg = addCall![0] as { type: 'add_item'; item: any };
expect(msg.item.core.full_name).toBe('Bob');
expect(msg.item.core.email).toBeUndefined();
expect(msg.item.core.phone).toBeUndefined();
expect(msg.item.core.address).toBeUndefined();
expect(msg.item.core.date_of_birth).toBeUndefined();
});
});

View File

@@ -0,0 +1,64 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('../../../popup', async () => {
const navigate = vi.fn();
const setState = vi.fn();
const sendMessage = vi.fn();
const getState = vi.fn(() => ({
view: 'add', entries: [], selectedId: null, selectedItem: null, selectedIndex: 0,
searchQuery: '', activeGroup: null, error: null, loading: false,
capturedTabId: null, capturedUrl: '', newType: 'key',
}));
const escapeHtml = (s: string) => s
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
return { navigate, setState, sendMessage, getState, escapeHtml };
});
import { renderForm } from '../key';
import { sendMessage } from '../../../popup';
describe('Key save shape', () => {
beforeEach(() => {
document.body.innerHTML = '<div id="app"></div>';
vi.mocked(sendMessage).mockReset();
vi.mocked(sendMessage).mockResolvedValue({ ok: true, data: { id: 'fakeid0000000000', items: [] } });
});
it('requires key_material and emits all populated fields', async () => {
const app = document.getElementById('app')!;
renderForm(app, 'add', null);
(document.getElementById('f-title') as HTMLInputElement).value = 'github ssh';
(document.getElementById('f-key-material') as HTMLTextAreaElement).value = '-----BEGIN OPENSSH PRIVATE KEY-----\nfoo\n-----END...';
(document.getElementById('f-label') as HTMLInputElement).value = 'work laptop';
(document.getElementById('f-public-key') as HTMLTextAreaElement).value = 'ssh-ed25519 AAAA...';
(document.getElementById('f-algorithm') as HTMLInputElement).value = 'ed25519';
document.getElementById('save-btn')!.click();
await new Promise(r => setTimeout(r, 5));
const calls = vi.mocked(sendMessage).mock.calls;
const addCall = calls.find(([msg]) => (msg as { type: string }).type === 'add_item');
const msg = addCall![0] as { type: 'add_item'; item: any };
expect(msg.item.type).toBe('key');
expect(msg.item.core).toEqual({
type: 'key',
key_material: '-----BEGIN OPENSSH PRIVATE KEY-----\nfoo\n-----END...',
label: 'work laptop',
public_key: 'ssh-ed25519 AAAA...',
algorithm: 'ed25519',
});
});
it('rejects empty key_material', async () => {
const app = document.getElementById('app')!;
renderForm(app, 'add', null);
(document.getElementById('f-title') as HTMLInputElement).value = 'no key';
document.getElementById('save-btn')!.click();
await new Promise(r => setTimeout(r, 5));
const calls = vi.mocked(sendMessage).mock.calls;
const addCall = calls.find(([msg]) => (msg as { type: string }).type === 'add_item');
expect(addCall).toBeUndefined();
});
});

View File

@@ -0,0 +1,49 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('../../../popup', async () => {
const navigate = vi.fn();
const setState = vi.fn();
const sendMessage = vi.fn();
const getState = vi.fn(() => ({
view: 'add', entries: [], selectedId: null, selectedItem: null, selectedIndex: 0,
searchQuery: '', activeGroup: null, error: null, loading: false,
capturedTabId: null, capturedUrl: '', newType: 'secure_note',
}));
const escapeHtml = (s: string) => s
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
return { navigate, setState, sendMessage, getState, escapeHtml };
});
import { renderForm } from '../secure-note';
import { sendMessage } from '../../../popup';
describe('SecureNote save shape', () => {
beforeEach(() => {
document.body.innerHTML = '<div id="app"></div>';
vi.mocked(sendMessage).mockReset();
vi.mocked(sendMessage).mockResolvedValue({ ok: true, data: { id: 'fakeid0000000000', items: [] } });
});
it('builds an Item with type=secure_note and the body in core', async () => {
const app = document.getElementById('app')!;
renderForm(app, 'add', null);
(document.getElementById('f-title') as HTMLInputElement).value = 'My Secret Note';
(document.getElementById('f-body') as HTMLTextAreaElement).value = 'hello\nworld';
document.getElementById('save-btn')!.click();
await new Promise(r => setTimeout(r, 5));
const calls = vi.mocked(sendMessage).mock.calls;
const addCall = calls.find(([msg]) => (msg as { type: string }).type === 'add_item');
expect(addCall).toBeDefined();
const msg = addCall![0] as { type: 'add_item'; item: any };
expect(msg.item.title).toBe('My Secret Note');
expect(msg.item.type).toBe('secure_note');
expect(msg.item.core).toEqual({ type: 'secure_note', body: 'hello\nworld' });
expect(msg.item.trashed_at).toBeUndefined();
expect(msg.item.sections).toEqual([]);
expect(msg.item.attachments).toEqual([]);
});
});

View File

@@ -0,0 +1,95 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('../../../popup', async () => {
const navigate = vi.fn();
const setState = vi.fn();
const sendMessage = vi.fn();
const getState = vi.fn(() => ({
view: 'add', entries: [], selectedId: null, selectedItem: null, selectedIndex: 0,
searchQuery: '', activeGroup: null, error: null, loading: false,
capturedTabId: null, capturedUrl: '', newType: 'totp',
}));
const escapeHtml = (s: string) => s
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
return { navigate, setState, sendMessage, getState, escapeHtml };
});
import { renderForm } from '../totp';
import { sendMessage } from '../../../popup';
import { base32Decode } from '../../../../shared/base32';
describe('Totp save shape', () => {
beforeEach(() => {
document.body.innerHTML = '<div id="app"></div>';
vi.mocked(sendMessage).mockReset();
vi.mocked(sendMessage).mockResolvedValue({ ok: true, data: { id: 'fakeid0000000000', items: [] } });
});
it('TOTP kind: secret round-trips via base32, defaults applied', async () => {
const app = document.getElementById('app')!;
renderForm(app, 'add', null);
(document.getElementById('f-title') as HTMLInputElement).value = 'GitHub';
(document.getElementById('f-secret') as HTMLInputElement).value = 'JBSWY3DPEHPK3PXP';
(document.getElementById('f-issuer') as HTMLInputElement).value = 'GitHub';
(document.getElementById('f-label') as HTMLInputElement).value = 'alice';
document.getElementById('save-btn')!.click();
await new Promise(r => setTimeout(r, 5));
const calls = vi.mocked(sendMessage).mock.calls;
const addCall = calls.find(([msg]) => (msg as { type: string }).type === 'add_item');
const msg = addCall![0] as { type: 'add_item'; item: any };
expect(msg.item.type).toBe('totp');
expect(msg.item.core).toMatchObject({
type: 'totp',
issuer: 'GitHub',
label: 'alice',
config: {
secret: Array.from(base32Decode('JBSWY3DPEHPK3PXP')),
algorithm: 'sha1',
digits: 6,
period_seconds: 30,
kind: 'totp',
},
});
});
it('Steam kind: digits set to 5, kind set to steam', async () => {
const app = document.getElementById('app')!;
renderForm(app, 'add', null);
(document.getElementById('f-title') as HTMLInputElement).value = 'Steam';
(document.getElementById('f-secret') as HTMLInputElement).value = 'JBSWY3DPEHPK3PXP';
(document.getElementById('kind-steam') as HTMLButtonElement).click();
// After the click, the form re-renders; re-query the secret field and re-populate.
(document.getElementById('f-secret') as HTMLInputElement).value = 'JBSWY3DPEHPK3PXP';
(document.getElementById('f-title') as HTMLInputElement).value = 'Steam';
document.getElementById('save-btn')!.click();
await new Promise(r => setTimeout(r, 5));
const calls = vi.mocked(sendMessage).mock.calls;
const addCall = calls.find(([msg]) => (msg as { type: string }).type === 'add_item');
const msg = addCall![0] as { type: 'add_item'; item: any };
expect(msg.item.core.config).toMatchObject({
digits: 5,
kind: 'steam',
algorithm: 'sha1',
period_seconds: 30,
});
});
it('rejects empty secret', async () => {
const app = document.getElementById('app')!;
renderForm(app, 'add', null);
(document.getElementById('f-title') as HTMLInputElement).value = 'no secret';
document.getElementById('save-btn')!.click();
await new Promise(r => setTimeout(r, 5));
const calls = vi.mocked(sendMessage).mock.calls;
const addCall = calls.find(([msg]) => (msg as { type: string }).type === 'add_item');
expect(addCall).toBeUndefined();
});
});

View File

@@ -0,0 +1,262 @@
/// Card: number / holder / expiry MonthYear / cvv / pin / kind.
/// Detail view has a styled card-silhouette signature block.
import { getState, setState, sendMessage, navigate, escapeHtml } from '../../popup';
import type { Item, ItemId, ManifestEntry, CardKind } from '../../../shared/types';
import {
renderConcealedRow, renderSignatureBlock, wireFieldHandlers,
} from '../fields';
const CARD_KINDS: CardKind[] = ['credit', 'debit', 'gift', 'loyalty', 'other'];
let activeKeyHandler: ((e: KeyboardEvent) => void) | null = null;
let activeFormEscHandler: ((e: KeyboardEvent) => void) | null = null;
export function teardown(): void {
if (activeKeyHandler) {
document.removeEventListener('keydown', activeKeyHandler);
activeKeyHandler = null;
}
if (activeFormEscHandler) {
document.removeEventListener('keydown', activeFormEscHandler);
activeFormEscHandler = null;
}
}
function brandFromNumber(num: string): string {
if (/^3[47]/.test(num)) return 'AMEX';
if (/^4/.test(num)) return 'VISA';
if (/^5[1-5]/.test(num)) return 'MASTERCARD';
if (/^6/.test(num)) return 'DISCOVER';
return '';
}
function maskedNumber(num: string): string {
if (!num) return '';
const last4 = num.slice(-4);
const groups = num.length > 4 ? '•••• •••• •••• ' : '';
return `${groups}${last4}`;
}
function formatExpiry(e: { month: number; year: number } | undefined): string {
if (!e) return '';
const mm = String(e.month).padStart(2, '0');
const yy = String(e.year).slice(-2);
return `${mm}/${yy}`;
}
export async function renderDetail(app: HTMLElement, item: Item): Promise<void> {
if (item.core.type !== 'card') return;
const c = item.core;
const number = c.number ?? '';
const brand = brandFromNumber(number);
const kindLabel = (c.kind ?? 'other').toUpperCase();
const bandLabel = brand ? `${brand} · ${kindLabel}` : kindLabel;
const sigInner = `
<div style="font-size:9px;letter-spacing:0.1em;color:#6e7681;margin-bottom:6px;">${escapeHtml(bandLabel)}</div>
<div style="font-family:monospace;font-size:14px;letter-spacing:0.08em;color:#c9d1d9;margin-bottom:12px;display:flex;justify-content:space-between;align-items:center;" data-field-id="card-number" data-revealed="false" data-field-value="${escapeHtml(number)}" data-field-multiline="false">
<span data-field-role="value">${escapeHtml(maskedNumber(number))}</span>
${number ? '<button type="button" data-field-action="reveal" style="font-size:10px;color:#8b949e;cursor:pointer;font-family:system-ui,sans-serif;letter-spacing:0;background:transparent;border:0;">show</button>' : ''}
</div>
<div style="display:flex;justify-content:space-between;align-items:flex-end;">
<div>
<div style="font-size:9px;color:#6e7681;letter-spacing:0.08em;">HOLDER</div>
<div style="font-size:11px;color:#c9d1d9;">${escapeHtml(c.holder ?? '')}</div>
</div>
<div style="text-align:right;">
<div style="font-size:9px;color:#6e7681;letter-spacing:0.08em;">EXPIRES</div>
<div style="font-family:monospace;font-size:11px;color:#c9d1d9;">${escapeHtml(formatExpiry(c.expiry))}</div>
</div>
</div>
`;
app.innerHTML = `
<div class="pad">
<div style="margin-bottom:12px;">
<div class="detail-title" style="margin-bottom:8px;">${escapeHtml(item.title)}</div>
${renderSignatureBlock({ accent: 'blue', children: sigInner })}
</div>
${c.cvv ? renderConcealedRow({ id: 'card-cvv', label: 'cvv', value: c.cvv, monospace: true }) : ''}
${c.pin ? renderConcealedRow({ id: 'card-pin', label: 'pin', value: c.pin, monospace: true }) : ''}
<div class="form-actions" style="margin-top:14px;">
<button class="btn" id="back-btn">back</button>
<button class="btn" id="edit-btn">edit</button>
<button class="btn danger" id="trash-btn">trash</button>
</div>
</div>
`;
// The card-number reveal lives inside the signature block, so wireFieldHandlers
// picks it up alongside the cvv/pin rows.
wireFieldHandlers(app);
document.getElementById('back-btn')?.addEventListener('click', () => { teardown(); navigate('list'); });
document.getElementById('edit-btn')?.addEventListener('click', () => { teardown(); navigate('edit'); });
document.getElementById('trash-btn')?.addEventListener('click', async () => {
if (!confirm(`Move "${item.title}" to trash?`)) return;
teardown();
const resp = await sendMessage({ type: 'delete_item', id: item.id });
if (!resp.ok) { setState({ error: resp.error }); return; }
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');
});
const handler = async (e: KeyboardEvent) => {
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': teardown(); navigate('list'); break;
case 'e': teardown(); navigate('edit'); break;
case 'd':
e.preventDefault();
if (confirm(`Move "${item.title}" to trash?`)) {
teardown();
const resp = await sendMessage({ type: 'delete_item', id: item.id });
if (!resp.ok) { setState({ error: resp.error }); return; }
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');
}
break;
}
};
activeKeyHandler = handler;
document.addEventListener('keydown', handler);
}
export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Item | null): void {
const state = getState();
const title = existing?.title ?? '';
const c = (existing?.core.type === 'card') ? existing.core : null;
const currentYear = new Date().getFullYear();
const monthOptions = Array.from({ length: 12 }, (_, i) => {
const m = String(i + 1).padStart(2, '0');
const sel = c?.expiry?.month === i + 1 ? 'selected' : '';
return `<option value="${m}" ${sel}>${m}</option>`;
}).join('');
const yearOptions = Array.from({ length: 51 }, (_, i) => {
const y = currentYear - 25 + i;
const sel = c?.expiry?.year === y ? 'selected' : '';
return `<option value="${y}" ${sel}>${y}</option>`;
}).join('');
const kindOptions = CARD_KINDS.map((k) => {
const sel = (c?.kind ?? 'credit') === k ? 'selected' : '';
return `<option value="${k}" ${sel}>${k}</option>`;
}).join('');
app.innerHTML = `
<div class="pad">
<div class="detail-title" style="margin-bottom:16px;">${mode === 'add' ? 'new card' : 'edit card'}</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="Amex Gold"></div>
<div class="form-group"><label class="label" for="f-number">number</label>
<input id="f-number" type="text" inputmode="numeric" value="${escapeHtml(c?.number ?? '')}" placeholder="378282246310005"></div>
<div class="form-group"><label class="label" for="f-holder">holder</label>
<input id="f-holder" type="text" value="${escapeHtml(c?.holder ?? '')}" placeholder="AARON LEE"></div>
<div class="form-group"><label class="label">expiry</label>
<div class="inline-row">
<select id="f-expiry-month"><option value="">mm</option>${monthOptions}</select>
<select id="f-expiry-year"><option value="">yyyy</option>${yearOptions}</select>
</div></div>
<div class="form-group"><label class="label" for="f-cvv">cvv</label>
<input id="f-cvv" type="password" inputmode="numeric" maxlength="4" value="${escapeHtml(c?.cvv ?? '')}"></div>
<div class="form-group"><label class="label" for="f-pin">pin</label>
<input id="f-pin" type="password" inputmode="numeric" maxlength="8" value="${escapeHtml(c?.pin ?? '')}"></div>
<div class="form-group"><label class="label" for="f-kind">kind</label>
<select id="f-kind">${kindOptions}</select></div>
<div class="form-actions">
<button class="btn" id="cancel-btn">cancel</button>
<button class="btn btn-primary" id="save-btn">save</button>
</div>
</div>
`;
document.getElementById('cancel-btn')?.addEventListener('click', () => {
setState({ error: null });
navigate(mode === 'edit' ? 'detail' : 'list');
});
document.getElementById('save-btn')?.addEventListener('click', async () => {
await saveCard(mode, existing);
});
const escHandler = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
setState({ error: null });
navigate(mode === 'edit' ? 'detail' : 'list');
}
};
activeFormEscHandler = escHandler;
document.addEventListener('keydown', escHandler);
(document.getElementById('f-title') as HTMLInputElement | null)?.focus();
}
async function saveCard(mode: 'add' | 'edit', existing: Item | null): Promise<void> {
const state = getState();
const title = (document.getElementById('f-title') as HTMLInputElement).value.trim();
if (!title) { setState({ error: 'Title is required' }); return; }
const get = (id: string) => (document.getElementById(id) as HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement).value.trim();
const number = get('f-number');
const holder = get('f-holder');
const expMonth = get('f-expiry-month');
const expYear = get('f-expiry-year');
const cvv = get('f-cvv');
const pin = get('f-pin');
const kindRaw = get('f-kind');
const kind: CardKind = (CARD_KINDS as string[]).includes(kindRaw) ? (kindRaw as CardKind) : 'credit';
const expiry = (expMonth && expYear)
? { month: Number(expMonth), year: Number(expYear) }
: undefined;
const core = {
type: 'card' as const,
number: number || undefined,
holder: holder || undefined,
expiry,
cvv: cvv || undefined,
pin: pin || undefined,
kind,
};
const now = Math.floor(Date.now() / 1000);
const item: Item = {
id: existing?.id ?? '',
title, type: 'card',
tags: existing?.tags ?? [],
favorite: existing?.favorite ?? false,
group: existing?.group, notes: existing?.notes,
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 });
const resp = mode === 'add'
? await sendMessage({ type: 'add_item', item })
: 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 });
}
}

View File

@@ -0,0 +1,204 @@
/// Identity: full_name, address (multiline), phone, email, date_of_birth.
/// Detail view shows a "profile card" signature block + plain rows.
import { getState, setState, sendMessage, navigate, escapeHtml } from '../../popup';
import type { Item, ItemId, ManifestEntry } from '../../../shared/types';
import {
renderRow, renderSignatureBlock, wireFieldHandlers,
} from '../fields';
let activeKeyHandler: ((e: KeyboardEvent) => void) | null = null;
let activeFormEscHandler: ((e: KeyboardEvent) => void) | null = null;
export function teardown(): void {
if (activeKeyHandler) {
document.removeEventListener('keydown', activeKeyHandler);
activeKeyHandler = null;
}
if (activeFormEscHandler) {
document.removeEventListener('keydown', activeFormEscHandler);
activeFormEscHandler = null;
}
}
function initials(name: string | undefined): string {
if (!name) return '?';
const parts = name.trim().split(/\s+/).slice(0, 2);
return parts.map((p) => p.charAt(0).toUpperCase()).join('') || '?';
}
function formatDate(iso: string | undefined): string {
if (!iso) return '';
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso);
if (!m) return iso;
const d = new Date(Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3])));
return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric', timeZone: 'UTC' });
}
export async function renderDetail(app: HTMLElement, item: Item): Promise<void> {
if (item.core.type !== 'identity') return;
const c = item.core;
const sigInner = `
<div style="display:flex;align-items:center;gap:12px;">
<div style="width:36px;height:36px;border-radius:50%;background:#d29922;color:#0d1117;display:flex;align-items:center;justify-content:center;font-weight:700;font-size:13px;">${escapeHtml(initials(c.full_name))}</div>
<div>
<div style="font-size:14px;font-weight:600;color:#c9d1d9;">${escapeHtml(c.full_name ?? item.title)}</div>
${c.email ? `<div style="font-size:11px;color:#8b949e;">${escapeHtml(c.email)}</div>` : ''}
</div>
</div>
`;
app.innerHTML = `
<div class="pad">
<div style="margin-bottom:12px;">
${renderSignatureBlock({ accent: 'amber', children: sigInner })}
</div>
${c.phone ? renderRow({ label: 'phone', value: c.phone, copyable: true }) : ''}
${c.email ? renderRow({ label: 'email', value: c.email, copyable: true }) : ''}
${c.address ? renderRow({ label: 'address', value: c.address, multiline: true }) : ''}
${c.date_of_birth ? renderRow({ label: 'born', value: formatDate(c.date_of_birth) }) : ''}
<div class="form-actions" style="margin-top:14px;">
<button class="btn" id="back-btn">back</button>
<button class="btn" id="edit-btn">edit</button>
<button class="btn danger" id="trash-btn">trash</button>
</div>
</div>
`;
wireFieldHandlers(app);
document.getElementById('back-btn')?.addEventListener('click', () => { teardown(); navigate('list'); });
document.getElementById('edit-btn')?.addEventListener('click', () => { teardown(); navigate('edit'); });
document.getElementById('trash-btn')?.addEventListener('click', async () => {
if (!confirm(`Move "${item.title}" to trash?`)) return;
teardown();
const resp = await sendMessage({ type: 'delete_item', id: item.id });
if (!resp.ok) { setState({ error: resp.error }); return; }
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');
});
const handler = async (e: KeyboardEvent) => {
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': teardown(); navigate('list'); break;
case 'e': teardown(); navigate('edit'); break;
case 'd':
e.preventDefault();
if (confirm(`Move "${item.title}" to trash?`)) {
teardown();
const resp = await sendMessage({ type: 'delete_item', id: item.id });
if (!resp.ok) { setState({ error: resp.error }); return; }
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');
}
break;
}
};
activeKeyHandler = handler;
document.addEventListener('keydown', handler);
}
export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Item | null): void {
const state = getState();
const title = existing?.title ?? '';
const c = (existing?.core.type === 'identity') ? existing.core : null;
app.innerHTML = `
<div class="pad">
<div class="detail-title" style="margin-bottom:16px;">${mode === 'add' ? 'new identity' : 'edit identity'}</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="Aaron Lee · personal"></div>
<div class="form-group"><label class="label" for="f-full-name">full name</label>
<input id="f-full-name" type="text" value="${escapeHtml(c?.full_name ?? '')}" placeholder="Aaron Lee"></div>
<div class="form-group"><label class="label" for="f-email">email</label>
<input id="f-email" type="email" value="${escapeHtml(c?.email ?? '')}" placeholder="aaron@example.com"></div>
<div class="form-group"><label class="label" for="f-phone">phone</label>
<input id="f-phone" type="tel" value="${escapeHtml(c?.phone ?? '')}" placeholder="+1 555 0100"></div>
<div class="form-group"><label class="label" for="f-address">address</label>
<textarea id="f-address" rows="3" placeholder="street, city, postcode...">${escapeHtml(c?.address ?? '')}</textarea></div>
<div class="form-group"><label class="label" for="f-dob">date of birth</label>
<input id="f-dob" type="date" value="${escapeHtml(c?.date_of_birth ?? '')}"></div>
<div class="form-actions">
<button class="btn" id="cancel-btn">cancel</button>
<button class="btn btn-primary" id="save-btn">save</button>
</div>
</div>
`;
document.getElementById('cancel-btn')?.addEventListener('click', () => {
setState({ error: null });
navigate(mode === 'edit' ? 'detail' : 'list');
});
document.getElementById('save-btn')?.addEventListener('click', async () => {
await saveIdentity(mode, existing);
});
const escHandler = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
setState({ error: null });
navigate(mode === 'edit' ? 'detail' : 'list');
}
};
activeFormEscHandler = escHandler;
document.addEventListener('keydown', escHandler);
(document.getElementById('f-title') as HTMLInputElement | null)?.focus();
}
async function saveIdentity(mode: 'add' | 'edit', existing: Item | null): Promise<void> {
const state = getState();
const title = (document.getElementById('f-title') as HTMLInputElement).value.trim();
if (!title) { setState({ error: 'Title is required' }); return; }
const get = (id: string) => (document.getElementById(id) as HTMLInputElement | HTMLTextAreaElement).value.trim();
const core = {
type: 'identity' as const,
full_name: get('f-full-name') || undefined,
email: get('f-email') || undefined,
phone: get('f-phone') || undefined,
address: get('f-address') || undefined,
date_of_birth: get('f-dob') || undefined,
};
const now = Math.floor(Date.now() / 1000);
const item: Item = {
id: existing?.id ?? '',
title, type: 'identity',
tags: existing?.tags ?? [],
favorite: existing?.favorite ?? false,
group: existing?.group, notes: existing?.notes,
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 });
const resp = mode === 'add'
? await sendMessage({ type: 'add_item', item })
: 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 });
}
}

View File

@@ -0,0 +1,205 @@
/// Key: key_material (required, concealed multiline) + label/algorithm/public_key.
/// Form's key_material textarea uses CSS text-security to mask characters
/// since <textarea type="password"> isn't a thing.
import { getState, setState, sendMessage, navigate, escapeHtml } from '../../popup';
import type { Item, ItemId, ManifestEntry } from '../../../shared/types';
import {
renderRow, renderConcealedRow, renderSignatureBlock, wireFieldHandlers,
} from '../fields';
let activeKeyHandler: ((e: KeyboardEvent) => void) | null = null;
let activeFormEscHandler: ((e: KeyboardEvent) => void) | null = null;
export function teardown(): void {
if (activeKeyHandler) {
document.removeEventListener('keydown', activeKeyHandler);
activeKeyHandler = null;
}
if (activeFormEscHandler) {
document.removeEventListener('keydown', activeFormEscHandler);
activeFormEscHandler = null;
}
}
export async function renderDetail(app: HTMLElement, item: Item): Promise<void> {
if (item.core.type !== 'key') return;
const c = item.core;
const sigInner = `
<div style="display:flex;justify-content:space-between;align-items:center;">
<div style="font-size:14px;font-weight:600;color:#c9d1d9;">${escapeHtml(item.title)}</div>
${c.algorithm ? `<div style="font-size:10px;color:#8b949e;font-family:monospace;">${escapeHtml(c.algorithm)}</div>` : ''}
</div>
`;
app.innerHTML = `
<div class="pad">
<div style="margin-bottom:12px;">
${renderSignatureBlock({ accent: 'green', children: sigInner })}
</div>
${renderConcealedRow({ id: 'key-material', label: 'private', value: c.key_material, multiline: true, monospace: true })}
${c.label ? renderRow({ label: 'label', value: c.label }) : ''}
${c.algorithm ? renderRow({ label: 'algorithm', value: c.algorithm }) : ''}
${c.public_key ? renderRow({ label: 'public', value: c.public_key, multiline: true, monospace: true, copyable: true }) : ''}
<div class="form-actions" style="margin-top:14px;">
<button class="btn" id="back-btn">back</button>
<button class="btn" id="edit-btn">edit</button>
<button class="btn danger" id="trash-btn">trash</button>
</div>
</div>
`;
wireFieldHandlers(app);
document.getElementById('back-btn')?.addEventListener('click', () => { teardown(); navigate('list'); });
document.getElementById('edit-btn')?.addEventListener('click', () => { teardown(); navigate('edit'); });
document.getElementById('trash-btn')?.addEventListener('click', async () => {
if (!confirm(`Move "${item.title}" to trash?`)) return;
teardown();
const resp = await sendMessage({ type: 'delete_item', id: item.id });
if (!resp.ok) { setState({ error: resp.error }); return; }
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');
});
const handler = async (e: KeyboardEvent) => {
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': teardown(); navigate('list'); break;
case 'e': teardown(); navigate('edit'); break;
case 'c':
e.preventDefault();
try { await navigator.clipboard.writeText(c.key_material); } catch { /* swallow */ }
break;
case 'd':
e.preventDefault();
if (confirm(`Move "${item.title}" to trash?`)) {
teardown();
const resp = await sendMessage({ type: 'delete_item', id: item.id });
if (!resp.ok) { setState({ error: resp.error }); return; }
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');
}
break;
}
};
activeKeyHandler = handler;
document.addEventListener('keydown', handler);
}
export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Item | null): void {
const state = getState();
const title = existing?.title ?? '';
const c = (existing?.core.type === 'key') ? existing.core : null;
app.innerHTML = `
<div class="pad">
<div class="detail-title" style="margin-bottom:16px;">${mode === 'add' ? 'new key' : 'edit key'}</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 ssh"></div>
<div class="form-group"><label class="label" for="f-key-material">key material *</label>
<div style="position:relative;">
<textarea id="f-key-material" rows="8" style="font-family:monospace;-webkit-text-security:disc;" placeholder="paste key here">${escapeHtml(c?.key_material ?? '')}</textarea>
<button type="button" id="key-show-btn" class="btn" style="position:absolute;right:6px;top:6px;font-size:10px;padding:2px 8px;">show</button>
</div></div>
<div class="form-group"><label class="label" for="f-label">label</label>
<input id="f-label" type="text" value="${escapeHtml(c?.label ?? '')}" placeholder="work laptop"></div>
<div class="form-group"><label class="label" for="f-algorithm">algorithm</label>
<input id="f-algorithm" type="text" value="${escapeHtml(c?.algorithm ?? '')}" placeholder="ed25519"></div>
<div class="form-group"><label class="label" for="f-public-key">public key</label>
<textarea id="f-public-key" rows="4" style="font-family:monospace;" placeholder="ssh-ed25519 AAAA...">${escapeHtml(c?.public_key ?? '')}</textarea></div>
<div class="form-actions">
<button class="btn" id="cancel-btn">cancel</button>
<button class="btn btn-primary" id="save-btn">save</button>
</div>
</div>
`;
// Show/hide toggle for the key_material textarea.
let revealed = false;
document.getElementById('key-show-btn')?.addEventListener('click', () => {
revealed = !revealed;
const ta = document.getElementById('f-key-material') as HTMLTextAreaElement;
(ta.style as CSSStyleDeclaration & { webkitTextSecurity?: string }).webkitTextSecurity = revealed ? 'none' : 'disc';
(document.getElementById('key-show-btn') as HTMLButtonElement).textContent = revealed ? 'hide' : 'show';
});
document.getElementById('cancel-btn')?.addEventListener('click', () => {
setState({ error: null });
navigate(mode === 'edit' ? 'detail' : 'list');
});
document.getElementById('save-btn')?.addEventListener('click', async () => {
await saveKey(mode, existing);
});
const escHandler = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
setState({ error: null });
navigate(mode === 'edit' ? 'detail' : 'list');
}
};
activeFormEscHandler = escHandler;
document.addEventListener('keydown', escHandler);
(document.getElementById('f-title') as HTMLInputElement | null)?.focus();
}
async function saveKey(mode: 'add' | 'edit', existing: Item | null): Promise<void> {
const state = getState();
const title = (document.getElementById('f-title') as HTMLInputElement).value.trim();
if (!title) { setState({ error: 'Title is required' }); return; }
const keyMaterial = (document.getElementById('f-key-material') as HTMLTextAreaElement).value;
if (!keyMaterial) { setState({ error: 'Key material is required' }); return; }
const get = (id: string) => (document.getElementById(id) as HTMLInputElement | HTMLTextAreaElement).value.trim();
const core = {
type: 'key' as const,
key_material: keyMaterial,
label: get('f-label') || undefined,
public_key: get('f-public-key') || undefined,
algorithm: get('f-algorithm') || undefined,
};
const now = Math.floor(Date.now() / 1000);
const item: Item = {
id: existing?.id ?? '',
title, type: 'key',
tags: existing?.tags ?? [],
favorite: existing?.favorite ?? false,
group: existing?.group, notes: existing?.notes,
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 });
const resp = mode === 'add'
? await sendMessage({ type: 'add_item', item })
: 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 });
}
}

View File

@@ -0,0 +1,360 @@
/// Login type detail + form. Reference implementation for the shared
/// field helpers introduced in Slice 2.
import { getState, setState, sendMessage, navigate, escapeHtml } from '../../popup';
import type { Item, ItemId, LoginCore, ManifestEntry, TotpConfig } from '../../../shared/types';
import { DEFAULT_PASSWORD_REQUEST } from '../../../shared/types';
import { base32Decode, base32Encode } from '../../../shared/base32';
import {
renderRow,
renderConcealedRow,
renderSignatureBlock,
wireFieldHandlers,
} from '../fields';
/// Called by the dispatcher before each render. Stops any in-flight
/// tickers / intervals / listeners the previous view may have attached.
export function teardown(): void {
stopTotpTicker();
if (activeKeyHandler) {
document.removeEventListener('keydown', activeKeyHandler);
activeKeyHandler = null;
}
if (activeFormEscHandler) {
document.removeEventListener('keydown', activeFormEscHandler);
activeFormEscHandler = null;
}
}
// ----------------------------------------------------------------------
// Detail view
// ----------------------------------------------------------------------
export async function renderDetail(app: HTMLElement, item: Item): Promise<void> {
if (item.core.type !== 'login') return;
const core = item.core as LoginCore & { type: 'login' };
const password = core.password ?? '';
const username = core.username ?? '';
const url = core.url ?? '';
const hasTotp = core.totp !== undefined;
const sigInner = `
<div style="display:flex;justify-content:space-between;align-items:center;">
<div style="font-size:14px;font-weight:600;color:#c9d1d9;">${escapeHtml(item.title)}</div>
${url ? `<a href="${escapeHtml(url)}" target="_blank" rel="noopener noreferrer" style="font-size:11px;color:#58a6ff;">open ↗</a>` : ''}
</div>
`;
app.innerHTML = `
<div class="pad">
<div style="margin-bottom:12px;">
${renderSignatureBlock({ accent: 'blue', children: sigInner })}
</div>
${username ? renderRow({ label: 'username', value: username, copyable: true }) : ''}
${renderConcealedRow({ id: 'login-password', label: 'password', value: password })}
${url ? renderRow({ label: 'url', value: url, href: url }) : ''}
${hasTotp ? `
<div class="field-row">
<span class="field-row__label">totp</span>
<span class="field-row__value monospace" id="totp-code">…</span>
<span class="field-row__actions"><span id="totp-countdown" style="font-variant-numeric:tabular-nums;">…</span></span>
</div>
` : ''}
${item.notes ? renderRow({ label: 'notes', value: item.notes, multiline: true }) : ''}
<div class="form-actions" style="margin-top:14px;">
<button class="btn" id="back-btn">back</button>
<button class="btn" id="edit-btn">edit</button>
<button class="btn" id="fill-btn">autofill</button>
<button class="btn danger" id="trash-btn">trash</button>
</div>
</div>
`;
wireFieldHandlers(app);
document.getElementById('back-btn')?.addEventListener('click', () => {
teardown();
navigate('list');
});
document.getElementById('edit-btn')?.addEventListener('click', () => {
teardown();
navigate('edit');
});
document.getElementById('trash-btn')?.addEventListener('click', async () => {
if (!confirm(`Move "${item.title}" to trash?`)) return;
teardown();
const resp = await sendMessage({ type: 'delete_item', id: item.id });
if (!resp.ok) { setState({ error: resp.error }); return; }
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');
});
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 });
else { teardown(); window.close(); }
});
if (hasTotp) startTotpTicker(item.id);
const handler = async (e: KeyboardEvent) => {
// Bail if the user is typing in an editable field — don't steal printable keystrokes.
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':
teardown();
navigate('list');
break;
case 'c':
if (username) {
try { await navigator.clipboard.writeText(username); } catch { /* no-op */ }
}
break;
case 'p':
try { await navigator.clipboard.writeText(password); } catch { /* no-op */ }
break;
case 't':
if (hasTotp) {
const codeEl = document.getElementById('totp-code');
const code = codeEl?.textContent?.trim();
if (code && code !== '…') {
try { await navigator.clipboard.writeText(code); } catch { /* no-op */ }
}
}
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 { teardown(); window.close(); }
break;
}
case 'e':
teardown();
navigate('edit');
break;
case 'd':
e.preventDefault();
if (confirm(`Move "${item.title}" to trash?`)) {
teardown();
const resp = await sendMessage({ type: 'delete_item', id: item.id });
if (!resp.ok) { setState({ error: resp.error }); return; }
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');
}
break;
}
};
activeKeyHandler = handler;
document.addEventListener('keydown', handler);
}
// ----------------------------------------------------------------------
// TOTP ticker
// ----------------------------------------------------------------------
let totpTickerId: ReturnType<typeof setInterval> | null = null;
let activeKeyHandler: ((e: KeyboardEvent) => void) | null = null;
let activeFormEscHandler: ((e: KeyboardEvent) => void) | null = null;
function stopTotpTicker(): void {
if (totpTickerId !== null) { clearInterval(totpTickerId); totpTickerId = null; }
}
function startTotpTicker(id: ItemId): void {
stopTotpTicker();
const tick = async () => {
const r = await sendMessage({ type: 'get_totp', id });
if (!r.ok) return;
const { code, expires_at } = r.data as { code: string; expires_at: number };
const codeEl = document.getElementById('totp-code');
const cdEl = document.getElementById('totp-countdown');
if (codeEl) codeEl.textContent = code;
if (cdEl) cdEl.textContent = `${Math.max(0, expires_at - Math.floor(Date.now() / 1000))}s`;
};
void tick();
totpTickerId = setInterval(() => void tick(), 1000);
}
// ----------------------------------------------------------------------
// Form (add / edit)
// ----------------------------------------------------------------------
export function renderForm(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 = existingCore?.totp ? base32Encode(new Uint8Array(existingCore.totp.secret)) : '';
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>
`;
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 pw = document.getElementById('f-password') as HTMLInputElement;
pw.value = data.password;
pw.type = 'text';
} else setState({ error: resp.error });
});
document.getElementById('cancel-btn')?.addEventListener('click', () => {
setState({ error: null });
navigate(mode === 'edit' ? 'detail' : 'list');
});
document.getElementById('save-btn')?.addEventListener('click', async () => {
await saveLogin(mode, existing);
});
const escHandler = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
setState({ error: null });
navigate(mode === 'edit' ? 'detail' : 'list');
}
};
activeFormEscHandler = escHandler;
document.addEventListener('keydown', escHandler);
(document.getElementById('f-title') as HTMLInputElement | null)?.focus();
}
function normalizeUrl(raw: string): { ok: true; value: string } | { ok: false; error: string } {
if (!raw) return { ok: true, value: '' };
const trimmed = raw.trim();
const candidate = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(trimmed) ? trimmed : `https://${trimmed}`;
try {
const u = new URL(candidate);
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,
};
const item: Item = {
id: existing?.id ?? '',
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 });
const resp = mode === 'add'
? await sendMessage({ type: 'add_item', item })
: 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 });
}
}

View File

@@ -0,0 +1,166 @@
/// SecureNote: a single multiline body field. Concealed by default in the
/// detail view; the form is just a big <textarea>.
import { getState, setState, sendMessage, navigate, escapeHtml } from '../../popup';
import type { Item, ItemId, ManifestEntry } from '../../../shared/types';
import {
renderConcealedRow, renderSignatureBlock, wireFieldHandlers,
} from '../fields';
let activeKeyHandler: ((e: KeyboardEvent) => void) | null = null;
let activeFormEscHandler: ((e: KeyboardEvent) => void) | null = null;
export function teardown(): void {
if (activeKeyHandler) {
document.removeEventListener('keydown', activeKeyHandler);
activeKeyHandler = null;
}
if (activeFormEscHandler) {
document.removeEventListener('keydown', activeFormEscHandler);
activeFormEscHandler = null;
}
}
export async function renderDetail(app: HTMLElement, item: Item): Promise<void> {
if (item.core.type !== 'secure_note') return;
const body = item.core.body ?? '';
const sigInner = `
<div style="font-size:14px;font-weight:600;color:#c9d1d9;">${escapeHtml(item.title)}</div>
`;
app.innerHTML = `
<div class="pad">
<div style="margin-bottom:12px;">
${renderSignatureBlock({ accent: 'green', children: sigInner })}
</div>
${renderConcealedRow({ id: 'note-body', label: 'body', value: body, multiline: true })}
<div class="form-actions" style="margin-top:14px;">
<button class="btn" id="back-btn">back</button>
<button class="btn" id="edit-btn">edit</button>
<button class="btn danger" id="trash-btn">trash</button>
</div>
</div>
`;
wireFieldHandlers(app);
document.getElementById('back-btn')?.addEventListener('click', () => { teardown(); navigate('list'); });
document.getElementById('edit-btn')?.addEventListener('click', () => { teardown(); navigate('edit'); });
document.getElementById('trash-btn')?.addEventListener('click', async () => {
if (!confirm(`Move "${item.title}" to trash?`)) return;
teardown();
const resp = await sendMessage({ type: 'delete_item', id: item.id });
if (!resp.ok) { setState({ error: resp.error }); return; }
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');
});
const handler = async (e: KeyboardEvent) => {
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': teardown(); navigate('list'); break;
case 'e': teardown(); navigate('edit'); break;
case 'd':
e.preventDefault();
if (confirm(`Move "${item.title}" to trash?`)) {
teardown();
const resp = await sendMessage({ type: 'delete_item', id: item.id });
if (!resp.ok) { setState({ error: resp.error }); return; }
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');
}
break;
}
};
activeKeyHandler = handler;
document.addEventListener('keydown', handler);
}
export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Item | null): void {
const state = getState();
const title = existing?.title ?? '';
const body = (existing?.core.type === 'secure_note') ? existing.core.body ?? '' : '';
app.innerHTML = `
<div class="pad">
<div class="detail-title" style="margin-bottom:16px;">${mode === 'add' ? 'new secure note' : 'edit secure note'}</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="My recovery codes"></div>
<div class="form-group"><label class="label" for="f-body">body</label>
<textarea id="f-body" rows="10" placeholder="paste secrets here">${escapeHtml(body)}</textarea></div>
<div class="form-actions">
<button class="btn" id="cancel-btn">cancel</button>
<button class="btn btn-primary" id="save-btn">save</button>
</div>
</div>
`;
document.getElementById('cancel-btn')?.addEventListener('click', () => {
setState({ error: null });
navigate(mode === 'edit' ? 'detail' : 'list');
});
document.getElementById('save-btn')?.addEventListener('click', async () => {
await saveSecureNote(mode, existing);
});
const escHandler = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
setState({ error: null });
navigate(mode === 'edit' ? 'detail' : 'list');
}
};
activeFormEscHandler = escHandler;
document.addEventListener('keydown', escHandler);
(document.getElementById('f-title') as HTMLInputElement | null)?.focus();
}
async function saveSecureNote(mode: 'add' | 'edit', existing: Item | null): Promise<void> {
const state = getState();
const title = (document.getElementById('f-title') as HTMLInputElement).value.trim();
const body = (document.getElementById('f-body') as HTMLTextAreaElement).value;
if (!title) { setState({ error: 'Title is required' }); return; }
const now = Math.floor(Date.now() / 1000);
const item: Item = {
id: existing?.id ?? '',
title, type: 'secure_note',
tags: existing?.tags ?? [],
favorite: existing?.favorite ?? false,
group: existing?.group,
notes: existing?.notes,
created: existing?.created ?? now,
modified: now,
trashed_at: undefined,
core: { type: 'secure_note', body },
sections: existing?.sections ?? [],
attachments: existing?.attachments ?? [],
field_history: existing?.field_history ?? {},
};
setState({ loading: true, error: null });
const resp = mode === 'add'
? await sendMessage({ type: 'add_item', item })
: 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 });
}
}

View File

@@ -0,0 +1,337 @@
/// Totp standalone item type. Detail view shows the rotating code in a
/// signature block with a thin SVG countdown ring; form has a kind toggle
/// (TOTP vs Steam Guard) and a single secret input.
import { getState, setState, sendMessage, navigate, escapeHtml } from '../../popup';
import type { Item, ItemId, ManifestEntry, TotpKind } from '../../../shared/types';
import { base32Decode, base32Encode } from '../../../shared/base32';
import {
renderRow, renderConcealedRow, renderSignatureBlock, wireFieldHandlers,
} from '../fields';
// ----------------------------------------------------------------------
// Module-scope lifecycle state
// ----------------------------------------------------------------------
let totpTickerId: ReturnType<typeof setInterval> | null = null;
let activeKeyHandler: ((e: KeyboardEvent) => void) | null = null;
let activeFormEscHandler: ((e: KeyboardEvent) => void) | null = null;
function stopTotpTicker(): void {
if (totpTickerId !== null) { clearInterval(totpTickerId); totpTickerId = null; }
}
/// Called by the dispatcher before each render. Stops the countdown ticker
/// AND removes the detail-view's keyboard handler so they don't leak.
export function teardown(): void {
stopTotpTicker();
if (activeKeyHandler) {
document.removeEventListener('keydown', activeKeyHandler);
activeKeyHandler = null;
}
if (activeFormEscHandler) {
document.removeEventListener('keydown', activeFormEscHandler);
activeFormEscHandler = null;
}
}
// ----------------------------------------------------------------------
// Detail view
// ----------------------------------------------------------------------
export async function renderDetail(app: HTMLElement, item: Item): Promise<void> {
if (item.core.type !== 'totp') return;
const c = item.core;
const secretB32 = base32Encode(new Uint8Array(c.config.secret));
const isSteam = c.config.kind === 'steam';
const headerLine = c.issuer
? `${escapeHtml(c.issuer)}${c.label ? ` · ${escapeHtml(c.label)}` : ''}`
: escapeHtml(item.title);
// Countdown ring SVG. Stroke-dashoffset animates per tick (CSS transition
// gives the smooth sweep between seconds).
const ringSvg = `
<svg width="32" height="32" viewBox="0 0 32 32" style="display:block;">
<circle cx="16" cy="16" r="14" stroke="#30363d" stroke-width="2" fill="none"/>
<circle id="totp-ring-arc" cx="16" cy="16" r="14" stroke="#58a6ff" stroke-width="2" fill="none"
stroke-linecap="round" stroke-dasharray="87.96"
transform="rotate(-90 16 16)" style="transition:stroke-dashoffset 1s linear;"/>
</svg>
`;
const sigInner = `
<div style="display:flex;justify-content:space-between;align-items:center;">
<div>
<div style="font-size:11px;color:#8b949e;letter-spacing:0.04em;">${headerLine}</div>
<div id="totp-code" style="font-family:monospace;font-size:28px;letter-spacing:0.12em;color:#c9d1d9;margin-top:4px;">${isSteam ? '·····' : '······'}</div>
</div>
<div style="display:flex;flex-direction:column;align-items:center;gap:4px;">
${ringSvg}
<span id="totp-countdown" style="font-size:10px;color:#8b949e;font-variant-numeric:tabular-nums;">…</span>
</div>
</div>
`;
app.innerHTML = `
<div class="pad">
<div style="margin-bottom:12px;">
<div class="detail-title" style="margin-bottom:8px;">${escapeHtml(item.title)}</div>
${renderSignatureBlock({ accent: 'blue', children: sigInner })}
</div>
${c.issuer ? renderRow({ label: 'issuer', value: c.issuer }) : ''}
${c.label ? renderRow({ label: 'label', value: c.label }) : ''}
${renderRow({ label: 'kind', value: isSteam ? 'Steam Guard' : 'TOTP' })}
${renderConcealedRow({ id: 'totp-secret', label: 'secret', value: secretB32, monospace: true })}
<div class="form-actions" style="margin-top:14px;">
<button class="btn" id="back-btn">back</button>
<button class="btn" id="edit-btn">edit</button>
<button class="btn danger" id="trash-btn">trash</button>
</div>
</div>
`;
wireFieldHandlers(app);
// Start the ticker — re-fetches code + countdown every second from the SW.
startTotpTicker(item.id, c.config.period_seconds || 30);
document.getElementById('back-btn')?.addEventListener('click', () => {
teardown();
navigate('list');
});
document.getElementById('edit-btn')?.addEventListener('click', () => {
teardown();
navigate('edit');
});
document.getElementById('trash-btn')?.addEventListener('click', async () => {
if (!confirm(`Move "${item.title}" to trash?`)) return;
teardown();
const resp = await sendMessage({ type: 'delete_item', id: item.id });
if (!resp.ok) { setState({ error: resp.error }); return; }
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');
});
const handler = async (e: KeyboardEvent) => {
// Don't steal printable keystrokes from editable fields.
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': teardown(); navigate('list'); break;
case 'e': teardown(); navigate('edit'); break;
case 't': {
// Copy the currently displayed rotating code.
const codeEl = document.getElementById('totp-code');
const code = codeEl?.textContent?.trim();
if (code && code !== '·····' && code !== '······') {
try { await navigator.clipboard.writeText(code); } catch { /* swallow */ }
}
break;
}
case 'd':
e.preventDefault();
if (confirm(`Move "${item.title}" to trash?`)) {
teardown();
const resp = await sendMessage({ type: 'delete_item', id: item.id });
if (!resp.ok) { setState({ error: resp.error }); return; }
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');
}
break;
}
};
activeKeyHandler = handler;
document.addEventListener('keydown', handler);
}
// ----------------------------------------------------------------------
// Countdown ticker
// ----------------------------------------------------------------------
function startTotpTicker(id: ItemId, period: number): void {
stopTotpTicker();
const circumference = 2 * Math.PI * 14;
const tick = async () => {
const r = await sendMessage({ type: 'get_totp', id });
if (!r.ok) return;
const { code, expires_at } = r.data as { code: string; expires_at: number };
const codeEl = document.getElementById('totp-code');
const cdEl = document.getElementById('totp-countdown');
const ring = document.getElementById('totp-ring-arc') as SVGCircleElement | null;
if (codeEl) codeEl.textContent = code;
const remaining = Math.max(0, expires_at - Math.floor(Date.now() / 1000));
if (cdEl) cdEl.textContent = `${remaining}s`;
if (ring) {
const offset = circumference * (1 - remaining / period);
ring.style.strokeDashoffset = String(offset);
}
};
void tick();
totpTickerId = setInterval(() => void tick(), 1000);
}
// ----------------------------------------------------------------------
// Form (add / edit) with TOTP/Steam kind toggle
// ----------------------------------------------------------------------
let formKind: TotpKind = 'totp';
export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Item | null): void {
const state = getState();
const title = existing?.title ?? '';
const c = (existing?.core.type === 'totp') ? existing.core : null;
formKind = c?.config.kind === 'steam' ? 'steam' : 'totp';
const secretB32 = c?.config.secret ? base32Encode(new Uint8Array(c.config.secret)) : '';
const renderInner = (): string => `
<div class="pad">
<div class="detail-title" style="margin-bottom:16px;">${mode === 'add' ? 'new totp' : 'edit totp'}</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">kind</label>
<div class="inline-row">
<button type="button" id="kind-totp" class="btn ${formKind === 'totp' ? 'btn-primary' : ''}" style="flex:1;">TOTP</button>
<button type="button" id="kind-steam" class="btn ${formKind === 'steam' ? 'btn-primary' : ''}" style="flex:1;">Steam Guard</button>
</div>
<p class="muted" style="font-size:11px;margin-top:4px;" id="kind-blurb">${formKind === 'steam' ? 'Steam Mobile Authenticator (5-char alphanumeric)' : 'Standard time-based codes (6 digits)'}</p>
</div>
<div class="form-group"><label class="label" for="f-secret">secret (base32) *</label>
<input id="f-secret" type="text" value="${escapeHtml(secretB32)}" placeholder="JBSWY3DPEHPK3PXP"></div>
<div class="form-group"><label class="label" for="f-issuer">issuer</label>
<input id="f-issuer" type="text" value="${escapeHtml(c?.issuer ?? '')}" placeholder="GitHub"></div>
<div class="form-group"><label class="label" for="f-label">label</label>
<input id="f-label" type="text" value="${escapeHtml(c?.label ?? '')}" placeholder="alice@github.com"></div>
<div class="form-actions">
<button class="btn" id="cancel-btn">cancel</button>
<button class="btn btn-primary" id="save-btn">save</button>
</div>
</div>
`;
app.innerHTML = renderInner();
// In-place re-render on kind toggle. Preserves current input values so
// the user doesn't lose what they've typed.
const reRender = (): void => {
const titleVal = (document.getElementById('f-title') as HTMLInputElement).value;
const secretVal = (document.getElementById('f-secret') as HTMLInputElement).value;
const issuerVal = (document.getElementById('f-issuer') as HTMLInputElement).value;
const labelVal = (document.getElementById('f-label') as HTMLInputElement).value;
app.innerHTML = renderInner();
(document.getElementById('f-title') as HTMLInputElement).value = titleVal;
(document.getElementById('f-secret') as HTMLInputElement).value = secretVal;
(document.getElementById('f-issuer') as HTMLInputElement).value = issuerVal;
(document.getElementById('f-label') as HTMLInputElement).value = labelVal;
wireKindToggle();
wireFormButtons(mode, existing);
};
const wireKindToggle = (): void => {
document.getElementById('kind-totp')?.addEventListener('click', () => {
formKind = 'totp';
reRender();
});
document.getElementById('kind-steam')?.addEventListener('click', () => {
formKind = 'steam';
reRender();
});
};
wireKindToggle();
wireFormButtons(mode, existing);
const escHandler = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
setState({ error: null });
navigate(mode === 'edit' ? 'detail' : 'list');
}
};
activeFormEscHandler = escHandler;
document.addEventListener('keydown', escHandler);
(document.getElementById('f-title') as HTMLInputElement | null)?.focus();
}
function wireFormButtons(mode: 'add' | 'edit', existing: Item | null): void {
document.getElementById('cancel-btn')?.addEventListener('click', () => {
setState({ error: null });
navigate(mode === 'edit' ? 'detail' : 'list');
});
document.getElementById('save-btn')?.addEventListener('click', async () => {
await saveTotp(mode, existing);
});
}
async function saveTotp(mode: 'add' | 'edit', existing: Item | null): Promise<void> {
const state = getState();
const title = (document.getElementById('f-title') as HTMLInputElement).value.trim();
if (!title) { setState({ error: 'Title is required' }); return; }
const secretStr = (document.getElementById('f-secret') as HTMLInputElement).value.trim();
if (!secretStr) { setState({ error: 'Secret is required' }); return; }
let secretBytes: Uint8Array;
try {
secretBytes = base32Decode(secretStr);
} catch (err) {
setState({ error: `Invalid TOTP secret: ${err instanceof Error ? err.message : String(err)}` });
return;
}
if (secretBytes.length === 0) { setState({ error: 'Secret decoded to zero bytes' }); return; }
const get = (id: string) => (document.getElementById(id) as HTMLInputElement).value.trim();
const isSteam = formKind === 'steam';
const core = {
type: 'totp' as const,
config: {
secret: Array.from(secretBytes),
algorithm: 'sha1' as const,
digits: isSteam ? 5 : 6,
period_seconds: 30,
kind: (isSteam ? 'steam' : 'totp') as TotpKind,
},
issuer: get('f-issuer') || undefined,
label: get('f-label') || undefined,
};
const now = Math.floor(Date.now() / 1000);
const item: Item = {
id: existing?.id ?? '',
title, type: 'totp',
tags: existing?.tags ?? [],
favorite: existing?.favorite ?? false,
group: existing?.group, notes: existing?.notes,
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 });
const resp = mode === 'add'
? await sendMessage({ type: 'add_item', item })
: 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 });
}
}

View File

@@ -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 });
}

View File

@@ -4,47 +4,59 @@
/// 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 ---
export function escapeHtml(str: string): string {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
// --- 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;
newType: import('../shared/types').ItemType | null;
}
let currentState: PopupState = {
view: 'locked',
entries: [],
selectedId: null,
selectedEntry: null,
selectedItem: null,
selectedIndex: 0,
searchQuery: '',
activeGroup: null,
error: null,
loading: false,
capturedTabId: null,
capturedUrl: '',
newType: null,
};
export function getState(): PopupState {
@@ -61,11 +73,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 +124,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 +148,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 +172,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;
}
}

View File

@@ -459,3 +459,52 @@ textarea {
border-color: #3fb950;
border-style: solid;
}
/* --- field-row + signature-block helpers (β₁) --- */
.field-row {
display: grid;
grid-template-columns: 90px 1fr auto;
gap: 8px 10px;
align-items: baseline;
padding: 4px 0;
font-size: 12px;
}
.field-row__label { color: #8b949e; }
.field-row__value { color: #c9d1d9; word-break: break-word; }
.field-row__value.monospace { font-family: "SF Mono", "JetBrains Mono", monospace; }
.field-row__value pre {
margin: 0;
white-space: pre-wrap;
word-break: break-word;
font-family: "SF Mono", "JetBrains Mono", monospace;
}
.field-row__actions {
display: flex;
gap: 6px;
font-size: 11px;
color: #8b949e;
}
.field-row__actions button {
background: transparent;
border: 0;
color: inherit;
cursor: pointer;
padding: 0;
font: inherit;
}
.field-row__actions button:hover { color: #c9d1d9; }
.sig-block {
background: #161b22;
border: 1px solid #30363d;
border-left: 3px solid #1f6feb;
border-radius: 5px;
padding: 14px;
margin-bottom: 10px;
}
.sig-block--blue { border-left-color: #1f6feb; }
.sig-block--green { border-left-color: #3fb950; }
.sig-block--amber { border-left-color: #d29922; }
.sig-block--red { border-left-color: #f85149; }

View File

@@ -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,
// Single router-state object shared by all messages for this SW instance.
const state: RouterState = {
manifest: null,
gitHost: null,
wasm: 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 ---
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}` };
}
}

View File

@@ -0,0 +1,656 @@
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();
});
});
// --- get_totp covers both Login.totp and Totp.config ---
describe('get_totp handler covers both Login.totp and Totp.config', () => {
function primeUnlocked(state: RouterState): void {
vi.mocked(session.getCurrent).mockReturnValue({ free: () => {} } as never);
state.gitHost = {} as never;
}
function withTotpWasm(state: RouterState): void {
state.wasm = {
...state.wasm,
totp_compute: (_json: string, _now: bigint) => ({
code: '123456',
expires_at: 1_700_000_030,
}),
};
}
beforeEach(() => {
vi.mocked(session.getCurrent).mockReset();
vi.mocked(vault.fetchAndDecryptItem).mockReset();
});
it('returns a code for an item with core.type === "totp"', async () => {
const state = makeState();
primeUnlocked(state);
withTotpWasm(state);
const totpItem: Item = {
id: 'totp0000000000aa',
title: 'GitHub TOTP',
type: 'totp',
tags: [],
favorite: false,
created: 0,
modified: 0,
core: {
type: 'totp',
config: {
secret: [0x48, 0x65, 0x6c, 0x6c, 0x6f], // "Hello" in bytes
algorithm: 'sha1',
digits: 6,
period_seconds: 30,
kind: 'totp',
},
issuer: 'GitHub',
},
sections: [],
attachments: [],
field_history: {},
};
vi.mocked(vault.fetchAndDecryptItem).mockResolvedValue(totpItem);
const res = await route(
{ type: 'get_totp', id: 'totp0000000000aa' },
state,
makePopupSender(),
);
expect(res.ok).toBe(true);
if (res.ok) {
const d = res.data as { code: string; expires_at: number };
expect(d.code).toMatch(/^\d{6}$/);
expect(d.expires_at).toBeGreaterThan(0);
}
});
it('still returns a code for Login items with a totp subfield', async () => {
const state = makeState();
primeUnlocked(state);
withTotpWasm(state);
const loginItem: Item = {
id: 'login0000000000a',
title: 'Example',
type: 'login',
tags: [],
favorite: false,
created: 0,
modified: 0,
core: {
type: 'login',
username: 'alice',
password: 'hunter2',
url: 'https://example.com',
totp: {
secret: [0x48, 0x65, 0x6c, 0x6c, 0x6f],
algorithm: 'sha1',
digits: 6,
period_seconds: 30,
kind: 'totp',
},
},
sections: [],
attachments: [],
field_history: {},
};
vi.mocked(vault.fetchAndDecryptItem).mockResolvedValue(loginItem);
const res = await route(
{ type: 'get_totp', id: 'login0000000000a' },
state,
makePopupSender(),
);
expect(res.ok).toBe(true);
});
it('rejects items without any TOTP config', async () => {
const state = makeState();
primeUnlocked(state);
withTotpWasm(state);
const identityItem: Item = {
id: 'id0000000000aaaa',
title: 'Identity',
type: 'identity',
tags: [],
favorite: false,
created: 0,
modified: 0,
core: { type: 'identity' },
sections: [],
attachments: [],
field_history: {},
};
vi.mocked(vault.fetchAndDecryptItem).mockResolvedValue(identityItem);
const res = await route(
{ type: 'get_totp', id: 'id0000000000aaaa' },
state,
makePopupSender(),
);
expect(res).toEqual({ ok: false, error: 'no_totp' });
});
});

View 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; }
}

View 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' };
}

View File

@@ -0,0 +1,280 @@
/// 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, TotpConfig } 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);
// Resolve the TotpConfig from whichever carrier the item type uses.
// Login items hold TOTP as an optional subfield on LoginCore; the standalone
// Totp item type carries it as TotpCore.config (required).
let cfg: TotpConfig | null = null;
if (item.core.type === 'login' && item.core.totp) {
cfg = item.core.totp;
} else if (item.core.type === 'totp') {
cfg = item.core.config;
}
if (!cfg) return { ok: false, error: 'no_totp' };
const now = Math.floor(Date.now() / 1000);
const code = state.wasm.totp_compute(JSON.stringify(cfg), 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; }
}

View 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;
}

View File

@@ -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 (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;
});
}
/// Find entries whose URL matches the given page URL by hostname.
export function findByUrl(
/// 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,
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 false;
}
});
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);
}

View File

@@ -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, '&quot;');
}
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 = '&nbsp;';
} 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 ?? '&nbsp;';
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&nbsp;≥&nbsp;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 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>
<p class="muted" style="margin-top:2px;">strength: ${strength}</p>
` : ''}
<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();
}
});

View 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();
});
});

View 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);
}

View File

@@ -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'][]);

View File

@@ -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' },
};

View File

@@ -1,7 +1,13 @@
// 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.
declare module 'relicario-wasm' {
export class SessionHandle {
readonly value: number;
free(): void;
@@ -15,7 +21,7 @@ export class EncryptedAttachment {
export class TotpCode {
readonly code: string;
readonly expires_at: number;
readonly expires_at: bigint;
free(): void;
}
@@ -54,5 +60,6 @@ export function embed_image_secret(carrier: Uint8Array, secret: Uint8Array): Uin
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>;
export function initSync(args: { module: WebAssembly.Module }): void;
}

View File

@@ -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__/**"]
}

View File

@@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'happy-dom',
include: ['src/**/__tests__/**/*.test.ts'],
},
});

Binary file not shown.

Binary file not shown.