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