The unit suite concatenates + parses every embedded app.*.js (plus argon2)
in a vm, so a syntax error already fails it. Run the ~10 cold `node --check`
spawns (~3-4s) only when tests are bypassed (PM_SKIP_TESTS) or absent.
Cuts the pre-compile freeze from ~7s to ~4s.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add --disable-renderer-backgrounding / -backgrounding-occluded-windows /
-background-timer-throttling to the WebView2 args. Without them Chromium
freezes a hidden renderer, so the first autofill hotkey after a tray-only
start (Start with Windows) waited 3-5s for onAutofillRequest to run.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Auto-backup no longer wipes the stored password when disabled, so
re-enabling reuses it silently. A dedicated "Set/Change backup password"
button (mirrors sync) owns the password, with a warning status when
unset. Corrected the stale hint that claimed the backup pwd was derived
from the master password. Added icons to each settings tab.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Extracted the autofill toggle + hotkey combos out of the Security section into
their own .slideover-field labelled "Autofill", added an Autofill tab + map
entry. 5 tabs now.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Side-mode settings panel (420px) clipped its content (Clear cache button) now
that the vertical tab column eats ~100px. Bumped #settingsPanel to 520px (id
beats .slideover's 420; entry #slideover unchanged). Nudged centered height
620→660.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Centered settings modal gets a fixed height (min(88vh,620px)) so switching
between short/long tabs no longer resizes + re-centers it. Entry #slideover
stays content-sized (edit/new unaffected).
- PIN unlock / Quick unlock / Recovery key moved from Sécurité to the Account
tab (unlock methods live with the account). Autofill stays in Sécurité.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Wrapped the tab bar + body in a .settings-main flex row; tabs now stack
vertically on the left with a right border, body scrolls on the right. JS
unchanged (toggles .is-tab-hidden on sections regardless of layout).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Long settings panel → 4 tabs (Général / Sécurité / Account / Sync & Backup).
Each section (.slideover-field) is keyed by its label text to a tab via
SETTINGS_TAB_OF; applySettingsTab toggles .is-tab-hidden on the rest. No HTML
restructure (sections were already .slideover-field siblings), no dep. The
existing settings search composes: a live query suspends the tab filter so
cross-tab matches show, clearing it restores the active tab.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Pressing Esc during a sync/backup closed Settings under the busy overlay.
The Esc handler already bails for open modals — added the same bail when
#busyOverlay is visible.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The full-screen #busyOverlay (shown during sync/backup) sits outside
#settingsPanel, so a mousedown on it fired the click-outside handler and
closed Settings mid-sync. Exempted #busyOverlay like .modal/.toast already are.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Clicking Sync twice started a second concurrent runSyncNow, and the click
event was passed as runSyncNow's `_attempt` retry counter (so the "Syncing…"
toast and 412-retry bound were both broken). Wrapped the handler: disable the
button while a sync runs, and call runSyncNow() with no arg. Internal retries
are unaffected.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The entry modal + register already show a strength bar; the slideover
password field didn't. Added one in soPasswordField reusing the existing
.strength-bar/--strength CSS and computeStrength (no zxcvbn). Vault health
already scores weakness via the same function.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The sync password is the only thing protecting the remote snapshot, but it
accepted 8 chars. Gate raised to 12+ chars AND computeStrength >= 50 (reused
from app.js — no zxcvbn dependency).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Authenticate/RequireCSRF write a 401 then raise ESessionRejected; when it
reached the dispatcher catch-all, the generic `on E: Exception` overwrote it
with a 500. Added `on ESessionRejected do Exit` before the generic clause in
both dispatchers (GET + Other) — one place, covers every handler whether or
not it wraps Authenticate. Root cause, not per-handler patch.
ponytail: runtime check only (expired token → 401) — no Delphi unit harness.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Rebuilt assets.res embedding the current JS (metadata-at-rest encryption,
dropped-column code, slideover focus fix). Runtime-validated: create/edit/
import/reload all OK.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
User dropped the title + tags cleartext columns too — all four searchable
metadata columns are now gone. Removed their refs from GET emission and the
POST/PUT/bulk INSERT/UPDATE (columns + params + binds). Only *_enc columns
remain; the client reads everything via decryptEntryMeta.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
User dropped the now-empty cleartext `site` + `username` columns after the
§1.3 migration completed. Removed every reference so the code matches the
schema: GET emission, POST/PUT/bulk INSERT/UPDATE (columns + params + binds).
title/tags cleartext columns still exist and are untouched.
decryptEntryMeta defaults e[f]='' for rows without *_enc (notes w/o site),
since GET no longer returns the dropped columns.
ponytail: contract phase of expand→migrate→contract; only safe because the
migration is proven complete (0 cleartext, both accounts).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
soSave re-opens the slideover on the just-saved entry to keep it visible, but
openSlideOver's edit-mode path focuses + selects the Title input — so every
Save jarringly jumped focus to the title with its text highlighted. Added an
opts.noFocus flag to openSlideOver and pass it from the post-save re-open;
normal opens (click / Enter from j/k nav) still auto-focus the title.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Account 'test' shows 0 cleartext across username/site/title/tags after
rebuild + unlock; _enc columns populated, render/search/favicons OK. Migration
is per-user (runs at unlock for the logged-in account), so a not-logged-in
account keeps cleartext until its next login — expected, not a regression.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Extends the username-at-rest scheme to site, title and tags — the last
searchable metadata still stored cleartext. Same design: dedicated
<f>_enc/<f>_iv columns (AES-GCM under the vault key), decrypted at load into
e.<f>, so client-side search/sort/render/favicon/autofill-match are unchanged.
Full-strength random-IV AES-GCM (no searchable encryption) because search is
client-side.
Generalized the helpers over ENCRYPTED_META_FIELDS = [username, site, title,
tags]:
- withEncryptedUsername → withEncryptedMeta (encrypts all four, blanks
cleartext) — wraps every POST/PUT body.
- decryptEntryUsernames → decryptEntryMeta (decrypts all four at load).
- migrateUsernamesAtRest → migrateMetadataAtRest (sweeps any field still
cleartext, live + trash).
- doChangeMasterPassword re-encrypts all four under the new key.
Server (Entries + Auth + Database):
- Columns site_enc/iv, title_enc/iv, tags_enc/iv; GET emits them (new
AddNullableField helper); POST/PUT/bulk read+persist (BindNullable helper);
rotation UPDATE re-encrypts them.
- Removed the server "Site required" validation (site='' when encrypted — the
client enforces it) at POST/PUT/bulk.
- ?q= server search neutralized (site+username ciphertext → LIKE useless; the
frontend never sends ?search=).
Tests: merge assertions updated to decrypt site (encrypted on import). 65/65.
username was runtime-validated earlier; site/title/tags NOT yet compiled/
runtime-tested (Delphi) — large multi-handler change. Rebuild BuildAssets +
PMServer, then create/edit/dup/move/tag/import/rotate and verify the DB shows
no cleartext site/title/tags (and the app still renders/searches).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
migrateUsernamesAtRest only swept state.entries (live rows), so a soft-deleted
entry kept its cleartext username in the DB until purge. Now it also fetches
+ decrypts the trash (GET /entries?deleted=1) and includes those rows in the
sweep. The PUT updates the row's fields without touching `deleted`, so the
entry stays in the trash; trashed rows aren't in the sync snapshot, so no
churn. Surfaced by a lingering cleartext username on a trashed test entry.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
After rebuild + unlock, vault.db shows 0 cleartext usernames (54 entries,
43 username_enc); the migrateUsernamesAtRest sweep completed on its own.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
username is no longer stored cleartext. New columns username_enc/username_iv
(AES-GCM under the vault key, same as encrypted_password). Search/sort/render
stay client-side, so the field is decrypted at loadEntries into e.username in
memory — everything downstream is unchanged. Full-strength random-IV AES-GCM
(no searchable/deterministic encryption) precisely because search is
client-side.
Server (PM.Handler.Entries / .Auth / PM.Database):
- Schema: vault_entries.username_enc, username_iv.
- GET returns them; POST/PUT/bulk-import read + persist them; master-pw
rotation re-encrypts them under the new key (UPDATE + loop).
- ?q= server search drops `username LIKE` (ciphertext won't match; frontend
searches client-side anyway).
Client (app.js / app.import.js):
- loadEntries/loadTrash decrypt username_enc → e.username (fallback to
cleartext for un-migrated rows).
- withEncryptedUsername(obj): write choke point — encrypts obj.username into
username_enc/username_iv and blanks the cleartext. Wraps every POST/PUT
body: saveEntry, soSave, duplicateEntry, moveEntryToFolder, addTagToEntry,
batchMove/AddTag, encryptImportEntry (import + sync-apply).
- doChangeMasterPassword re-encrypts username under the new key.
- migrateUsernamesAtRest(): one-time sweep at enterApp, PUT-re-ships rows that
still carry cleartext username so the DB gets scrubbed (bumps updated_at
once; plaintext unchanged so devices converge).
site/title/tags stay cleartext (same pattern later — see memory note). +1
merge test (username encrypted on import). 65/65.
NOT compiled/tested at runtime (Delphi) — large multi-handler change; rebuild
BuildAssets + PMServer and test create/edit/rotate/import/sync + verify the
DB shows no cleartext username.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Restoring from the tray popped a "Welcome back" toast every time — noise for
a routine action. Removed it from onTrayRestore; the auto-lock reset stays.
The login-success "Welcome back, <user>" toast is unaffected.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The profile picture (users.avatar_b64, cosmetic/unencrypted) travelled only
in the manual export. Now it's also in buildSyncSnapshot and the auto-backup
container, so a new device / a restore picks it up.
- Restore is ADDITIVE (mirrors the import path): applyRemoteSnapshot adopts
remote.avatar_b64 only when the local device has no avatar — never clobbers
a locally-set picture. No per-avatar timestamp to arbitrate, so changing an
existing avatar doesn't propagate (cosmetic, accepted).
- +2 merge tests (adopt-when-empty, don't-clobber-when-set). 65/65 green.
- Server /avatar endpoint unchanged (already accepts {avatar_b64}).
Closes the avatar item of CODE_AUDIT §4.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
deriveKeyBytes now calls NobleArgon2.argon2idAsync instead of the sync
argon2id, so it yields to the event loop periodically and the busy/unlock
spinner keeps animating instead of freezing ~0.65 s during login, register,
and master-pw rotation. Same result (both RFC-9106-verified); all callers
already await deriveKeyBytes so no call-site changes.
- Re-vendored js/argon2.js to export argon2idAsync alongside argon2id
(re-bundled from @noble/hashes@2.2.0; both variants pass the RFC 9106 §5.3
vector). 27KB → 29KB.
- Added a sync/async parity test. 63/63 green.
- Closes the last open item of CODE_AUDIT §1.2.
NOTE: argon2.js grew — run BuildAssets to re-embed it before the next
Delphi build.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Eighth slice. Quick search wasn't contiguous — its functions sat on both
sides of the cheatsheet and password-history modals (lines 725-1104). Rather
than a fiddly non-contiguous cut, the whole overlay cluster is extracted as
one byte-identical block: js/app.overlays.js (quick search + cheatsheet +
password history). Pure declarations, no top-level side effects → loads
before app.js; all state/api/Bridge/render/decryptPwd refs resolve via shared
global scope at call time.
- Byte-for-byte identical; syntax OK on all nine app parts; 62/62 tests green.
- index.html + BuildAssets whitelist + harness APP_PARTS updated.
app.js: 11936 → 9170 lines (8 modules extracted, ~2770 lines). Load order:
argon2 → crypto → totp → favicon → import → backup → health → overlays →
app → sync.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Seventh slice of the app.js split. Moves the vault-health dashboard
(computeHealthCache, healthScoreBand, renderHealthDashboard/Section,
openEntryForFix, entryAgeDays + scoring consts) to js/app.health.js. Pure
declarations, no top-level side effects → loads before app.js. Uses
computeStrength/decryptPwd/state/api via shared global scope at call time.
- Byte-for-byte identical extraction; syntax OK on all eight app parts.
- auditCache/auditFilter sit in this var block but drive the separate
Audit-log viewer in app.js — they ride along and resolve cross-file via
shared scope (documented).
- index.html + BuildAssets whitelist + harness APP_PARTS updated.
- 62/62 tests green.
app.js: 11936 → 9545 lines (7 modules extracted).
NOTE: quick search is NOT contiguous (interleaved with cheatsheet +
history-modal code, lines 886-1063), so a clean byte-identical extraction
isn't trivial — deferred.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Per user request, swap the quick-search click/key mapping so the common case
(fill just the password) is the plain left-click / Enter:
left click / Enter → password only (was: full user+Tab+pwd)
right click / Shift+Enter → full user+pwd (was: username only)
Ctrl+click / Ctrl+Enter → username only (was: password only)
Keyboard mirrors the mouse. Copy-mode (tray/palette, no HWND target) shares
the same `mode`, so it shifts too: click/Enter copies password, Ctrl+click/
Ctrl+Enter copies username (right-click's 'full' has no copy meaning → pwd).
Updated the dynamic hint, the static index.html hint, and CLAUDE.md.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sixth slice of the app.js split. Moves the favicon fetch/cache section
(faviconHost, saveEntryIcon, ensureEntryFavicon, backfillFavicons,
clearAllFavicons) to js/app.favicon.js. Pure declarations, no top-level
side effects → loads before app.js.
- Code moved byte-for-byte; no duplicate const; syntax OK on all 7 app parts.
- NEW: js/tests/favicon.test.js — 7 tests for faviconHost, the pure
URL→validated-hostname function that decides which domain is sent to the
DuckDuckGo proxy (a bug there leaks the wrong host). Covers scheme/www/
path/port stripping, non-hostname rejection, malformed dotting, unsafe
chars, and the 253-char DNS cap.
- Fixed an inaccurate source comment surfaced by the tests: it claimed raw
IPs "stay valid", but the TLD rule /\.[a-z]{2,}$/ rejects a numeric final
label, so IPs get no favicon lookup (fine). Test pins the real behaviour.
- Suite: 55 → 62 tests, all green. Assets regenerated (8 ordered JS files).
app.js: 11936 → 9790 lines (6 modules extracted).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Fifth slice of the app.js split. Moves the scheduled encrypted-backup
feature (config, retention, runAutoBackupNow/runAutoBackupIfDue) to
js/app.backup.js. Pure declarations + two consts, no top-level side effects
→ loads before app.js; uses encryptExportPayload (app.import.js), Bridge,
api, state via shared global scope at call time.
- Byte-for-byte identical extraction; no duplicate const; no top-level
backup reference left in app.js; syntax OK on all six app parts.
- index.html + BuildAssets whitelist + harness APP_PARTS updated; assets
regenerated (manifest embeds all 7 ordered JS files).
- 55/55 tests green.
app.js: 11936 → 9900 lines — now under 10k. Five modules extracted
(~2000 lines): argon2 → crypto → totp → import → backup → app → sync.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Fourth slice of the app.js split. Moves TOTP (base32Decode, generateTOTP,
parseOtpAuthUri) plus the TOTP-secret and custom-field AES-GCM wrappers to
js/app.totp.js. Loads before app.js (pure declarations), after app.crypto.js
(uses encryptPwd/decryptPwd). Also called by app.import.js and app.sync.js
via shared global scope.
- Byte-for-byte identical extraction; no duplicate const; syntax OK on all
five app parts.
- NEW: js/tests/totp.test.js — 13 tests including the 5 RFC 6238 Appendix B
reference vectors (generateTOTP reads Date.now(), so each case stubs the
sandbox clock to the vector's fixed time), base32 decode edge cases, and
parseOtpAuthUri. Extraction AND new coverage in one slice.
- Suite: 42 → 55 tests, all green.
- Assets regenerated (manifest now embeds all 6 ordered JS files:
argon2 → crypto → totp → import → app → sync); also fixes the previous
import commit's not-yet-rebuilt manifest.
- Delphi build artifacts (*.vrc, *.$manifest) gitignored.
app.js: 11936 → 10138 lines (4 modules extracted, ~1800 lines).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Third slice of the app.js split. Moves the encrypted export container,
CSV/JSON import parsing (parseCSV, findColumn, parseEntriesFromCSV,
parseEntriesFromJSON), and the doImport/doExport/doExportCSV flows to
js/app.import.js. encryptImportEntry moves here too (also called by
app.sync.js — resolved via shared global scope at call time).
- Byte-for-byte identical to the extracted block; no duplicate const;
no top-level import ref left in app.js.
- Load order: BEFORE app.js (pure declarations, no top-level side effects),
alongside app.crypto.js. Full order: argon2 → crypto → import → app → sync.
- index.html + BuildAssets whitelist + harness APP_PARTS updated.
- Safety net: the 14 CSV tests exercise parseCSV/parseEntriesFromCSV from
the extracted file and stay green (42/42).
app.js: 11936 → 10253 lines (crypto + sync + import now separate, ~1700
lines moved into 3 modules).
NOTE: assets.res not regenerated here (needs brcc32/Delphi) — run
BuildAssets before the next Delphi build to embed js/app.import.js.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Second slice of the app.js split (after crypto). Moves the WebDAV sync
section to js/app.sync.js: transport (_webdavCall), buildSyncSnapshot,
applyRemoteSnapshot (merge + tombstone arbitration), runSyncNow, and the
sync settings UI.
- Byte-for-byte identical to the extracted block (verified before removal);
no duplicate const; no top-level sync reference left in app.js.
- Load order: AFTER app.js (unlike crypto, which loads before) because this
module has a top-level side effect — `Bridge.onWebdavResult = …` — that
needs Bridge/state/api already declared. Rule documented in CLAUDE.md.
- index.html + BuildAssets whitelist + harness APP_PARTS updated; assets
rebuilt to embed the new file.
- Safety net: the existing merge tests exercise applyRemoteSnapshot /
buildSyncSnapshot from the extracted file and stay green (42/42).
app.js: 11936 → 11256 lines (crypto + sync now separate).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Both UPDATEs mutated a synced column without touching updated_at, so the
change rode in the sync snapshot but other devices skipped it (last-write-
wins saw "not newer"). Now both SET updated_at = datetime('now') (UTC).
- POST /entries/{id}/icon (PM.Handler.Entries)
- folder delete → entries reassigned to 'All' (PM.Handler.Folders)
accessed_at stays exempt (read timestamp, not synced); bulk "clear all
icons" stays exempt (device-local favicon cache purge). Invariant documented
in CLAUDE.md.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
First slice of the app.js split. Approach: ordered classic-script files
loaded via separate <script> tags (argon2.js → app.crypto.js → app.js),
NOT ES modules / a bundler. Classic scripts share one global lexical
environment, so consts/functions cross-reference across files exactly as
in the monofile — zero call-site rewrites, near-zero risk. Chosen over the
audit's esbuild/ES-module suggestion because the code is written entirely
in global scope (functions call each other by bare name everywhere).
- js/app.crypto.js: KDF (PBKDF2 + Argon2id), verifier, AES-GCM encrypt/
decrypt, key persist/restore. Verified byte-for-byte identical to the
original block before removal; no duplicate const across the two scripts.
- index.html + BuildAssets whitelist + test harness updated for the load
order. Harness CONCATENATES app.crypto.js + app.js (node:vm doesn't share
top-level const across separate runInContext calls the way browsers share
it across <script> tags); argon2.js stays a separate IIFE.
- Runtime-validated: rebuilt exe unlocks via quick-unlock and loads/decrypts
entries — the extracted crypto (restoreCryptoKey, verifierFromKeyHex,
decryptPwd) works from the separate file. 42/42 tests green.
- Docs: CLAUDE.md "Découpage frontend" (pattern + rules), file map, tests
README, CODE_AUDIT §3.1.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>