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>
28 KiB
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-wasmcrate (replacing staleidfoto_wasm*files). - Shared TypeScript types / messages migrated to the typed-item surface (
Item,ItemCore,Manifestv2,AttachmentRef, plus the minimalVaultSettingssubset needed for origin-ack). - Service-worker rewrite:
SessionHandle-basedsession.ts, rewrittenvault.ts, splitrouter/{index,popup-only,content-callable}.tswith sender checks. - Security architecture:
- WAR cleanup —
setup.html,setup.js, wasm artifacts dropped. - Setup opened via
chrome.tabs.create. - Origin-bound autofill (
sender.tab.urlonly, 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.
- WAR cleanup —
- 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 >= 3gate (via newrate_passphrasemessage). - 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.enconly for origin-ack (β). - Attachments and
putBlobGit-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 ItemCore enum is externally-tagged by default, producing { "Login": {...} } style JSON:
export type ItemId = string; // 16-char hex
export type FieldId = string;
export type AttachmentId = string;
export type ItemType = 'Login' | 'SecureNote' | '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 | null;
notes: string | null;
created: number;
modified: number;
trashed_at: number | null;
core: ItemCore;
sections: Section[];
attachments: AttachmentRef[];
field_history: Record<FieldId, FieldHistoryEntry[]>;
}
export type ItemCore =
| { Login: LoginCore }
| { SecureNote: SecureNoteCore }
| { Identity: IdentityCore }
| { Card: CardCore }
| { Key: KeyCore }
| { Document: DocumentCore }
| { Totp: TotpCore };
export interface LoginCore {
username: string | null;
password: string | null;
url: string | null;
totp: TotpConfig | null;
}
export interface TotpConfig {
secret: number[]; // raw bytes, serde → JSON array
algorithm: 'Sha1' | 'Sha256' | 'Sha512';
digits: number;
period_seconds: number;
kind: { Totp: null } | { Hotp: number } | { Steam: null };
}
// 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';
}
export type GeneratorRequest =
| { Bip39: { word_count: number; separator: string; capitalization: Capitalization } }
| { Random: { length: number; classes: CharClasses; symbol_charset: SymbolCharset } };
export type Capitalization = 'Lower' | 'Upper' | 'FirstOfEach' | 'Title' | 'Mixed';
export interface CharClasses { lower: boolean; upper: boolean; digits: boolean; symbols: boolean; }
export type SymbolCharset = 'SafeOnly' | 'Extended' | { Custom: string };
Plus Section, Field, FieldKind, FieldValue, AttachmentRef, AttachmentSummary, FieldHistoryEntry as declared. Most are unused by α's UI but present so the type-check catches drift with the Rust side.
The ItemCore external-tagging assumption gets verified in the first SW slice's smoke test: item_encrypt(handle, JSON.stringify(loginItem)) round-trips through item_decrypt with structural equality.
Messages (shared/messages.ts)
Two unions, so TypeScript itself enforces the router boundary:
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).
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:
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:
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:
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: parsesender.tab.url→ hostname. Return manifest entries whoseLoginCore.urlhostname equals page hostname. Top-frame only (sender.frameId === 0already enforced at router).get_credentials(id): fetch item. ParseLoginCore.urlhostname. Compare tosender.tab.urlhostname. Mismatch →{ ok: false, error: 'origin_mismatch' }. No item data leaked on mismatch.check_credential: same origin derivation;username/passwordcompared against manifest + decrypted item.blacklist_site: hostname derived fromsender.tab.url.
TOFU origin-ack
When get_credentials succeeds on an origin not present in VaultSettings.autofill_origin_acks:
- SW returns
{ ok: true, data: { requires_ack: true, hostname } }— no credentials. - Content script surfaces a "confirm autofill" dialog inside its closed Shadow DOM (lightweight — just a re-use of the capture prompt layout).
- 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). - User opens popup → popup detects a pending ack via
VaultSettings.autofill_origin_acksdiff, shows "Confirm autofill on<hostname>?" → user acks → popup sendsack_autofill_origin { hostname }(popup-only, writessettings.enc). - 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:
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:
- Look up item by
id. chrome.tabs.get(capturedTabId)— if gone or navigated, reject.- Compare
new URL(tab.url).hostnametonew URL(capturedUrl).hostname— mismatch rejects. - Compare captured hostname to
LoginCore.url's hostname — mismatch rejects. - 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:
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/):
- No
innerHTML/insertAdjacentHTML/document.writeanywhere incontent/. All DOM viacreateElement+textContent+appendChild. - No stable element IDs or classes inside shadow trees. Wire handlers via local references.
- Page-derived strings bounded:
findUsernameValueresult capped at 256 chars, control characters stripped viareplace(/\p{Cc}/gu, ''), assigned only via.textContent. - 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:
get_setup_state→ if!isConfigured,chrome.tabs.create(setup.html)and close the popup.is_unlocked→ if unlocked,list_items+ render list.- Otherwise render
unlock. - Snapshot
(activeTabId, activeTabUrl)at init; stash onPopupStatefor laterfill_credentialscalls.
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:
switch (item.type) {
case 'Login': return renderLoginDetail(app, item);
case 'SecureNote':
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 Uint8Array → bytes in TotpConfig { secret, algorithm: Sha1, digits: 6, period_seconds: 30, kind: { Totp: null } }. Display is base32 re-encoded with a reveal toggle.
"gen" button sends:
{ type: 'generate_password',
request: { Random: { length: 20,
classes: { lower: true, upper: true, digits: true, symbols: true },
symbol_charset: 'SafeOnly' } } }
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.jsongets the same WAR cleanup as Chrome.background.scripts: ["service-worker.js"]stays (not an SW in Firefox — persistent background script).initWasm()inservice-worker/index.tsalready branches onServiceWorkerGlobalScopepresence; 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. Mockschrome.runtime.onMessageby calling the dispatcher directly with fabricatedsenderobjects. - Origin-bound autofill:
get_autofill_candidatesignores any strayurlfield; onlysender.tab.url's hostname drives matching. get_credentialsorigin equality: mismatchedLoginCore.urlhostname →origin_mismatch, no item data in response.fill_credentialscaptured-tab verification: mismatched captured vs current tab → reject.generate_passwordwiring: 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
- Fresh install → setup wizard opens in a tab (not popup-embedded); zxcvbn slider responds; weak passphrase blocks submit.
- Unlock → list renders from manifest.
- Add Login with TOTP → sync → item appears on a second browser profile.
- Autofill icon in password field → click → fills (single-candidate path).
- Multiple candidates → picker → pick → fills.
- First autofill on a new hostname → origin-ack prompt → confirm → credentials arrive.
- Capture prompt on submitting a new login; "Save" adds item, "Never" blacklists, "×" dismisses.
- Edit Login → password rotates → field history captured (CLI cross-check:
relicario get --showon second machine shows new password; item file'sfield_historypopulated). - Delete Login → moves to trash (not in list; CLI
relicario list --trashedshows it). - Soft-lock via popup "lock" → list clears, re-unlock required.
- Cross-origin autofill attempt via devtools: craft a
get_credentialsfrom a page whose hostname differs from item'sLoginCore.url→origin_mismatch.
Acceptance criteria
cargo test --workspacegreen.npm run build:allgreen inextension/(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,tsconfigoverrides, jsdom vs happy-dom for the origin-parsing paths). - Precise "pending ack" detection mechanism in the popup: poll
VaultSettings.autofill_origin_ackson popup open vs a background-synthesized flag. Slice 6 decides based on latency feel. - Whether the TOTP parse helper belongs in
shared/base32.tsor gets added to the WASM surface astotp_config_from_base32. First approach is simpler; the second is more robust to charset edge cases. Slice 2 decides when wiring the Login form.