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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
Maps serialize as JS objects, not Maps — what the extension popup
expects. Also ships hand-written TS declarations for the bridge
(consumed by Plan 1C).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The popup is too constrained for multi-step setup (file pickers
close it, fields duplicate the init wizard). Now it just shows
a single button that opens the full-page setup wizard.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Chrome closes popups when file pickers steal focus. Instead, check
chrome.storage.local for an existing image (pushed by init wizard),
and redirect to the full-page setup.html if no image is found.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Calling setState() after FileReader.onload triggered a full popup
re-render which could crash or close the popup with large images.
Update DOM elements in place instead, and add error handling.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Chrome MV3 service workers do not support dynamic import().
Switch to static import of the wasm-pack JS glue and use
initSync() with fetch() to load the WASM binary at runtime.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Fix .idfoto/ prefix for salt and params.json in vault.ts
- Cache TOTP secrets by entry ID to avoid re-fetching every second
- Fix keyboard navigation to use filtered entries, not unfiltered
- Add window.close() on Escape from entry list
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>