Drop vault: data minimisation (ADR-002)#8
Conversation
Per ADR-002, remove the encrypted-account vault and replace it with a
data-minimisation model. This commit lays the type/store/domain
foundation for the cleanup.
types.ts
- Drop AccountDetails interface
- Drop Account.details and Account.isEncrypted fields
- Add Account.note?: string as the unencrypted freeform note
- Simplify UserCloudSettings to { schemaVersion?, maskMode?, lastSyncAt?, lastUpdatedAt? }
src/stores/mask.store.ts (new, replaces privacy.store.ts)
- Rename usePrivacyStore -> useMaskStore
- Drop vault fields (isVaultEnabled, isVaultCreated, isVaultUnlocked, securityUnlocked, masterKey)
- Keep maskMode boolean; add setMaskMode/reset actions
- Rename VaultState/PrivacyActions -> MaskState/MaskActions
src/lib/domain/migration.ts (new, TEMPORARY)
- migrateSchemaV1toV2 strips legacy fields from accounts and profile
- needsV1Migration predicate: returns true if schemaVersion != 2 OR
any legacy field is present (defends against v1 client re-introducing them)
- isPresent helper treats empty arrays/strings as absent
- CURRENT_SCHEMA_VERSION = 2
- Removal criterion in CONTEXT.md / docs/adrs/002
Tests: 110/110 passing (33 new migration tests).
Wire the v1->v2 schema migration into the data flow and drop every
read/write path that touched the removed vault fields.
services/sheets.services.ts
- findUser: required headers are now {maskMode, schemaVersion, showAIAssistant,
syncChatToSheets, lastUpdatedAt, lastSyncAt}
- createUser: header row is now 9 columns; legacy aliases
isVaultEnabled/isVaultCreated/vaultSalt removed
- updateUser: drop the biometricCredIds/devices array-merge branch; simple
key-by-key update (no array fields remain in profile)
- saveToSheet: keep the defensive sensitive-field filter (data no longer
carries those keys, but the guard stays)
services/storage.services.ts
- Remove KEYS.SECURITY (zenfinance_security_settings_v1)
- Drop getStoredSecuritySettings / saveSecuritySettings
- getStoredProfile: no longer merges a security file; defaults for
syncChatToSheets/showAIAssistant/maskMode/schemaVersion only
- saveProfile: single-key write; no more vault-field destructure
services/security.services.ts (deleted, 306 lines)
services/twofa.services.ts (deleted, 109 lines)
services/biometric.services.ts (deleted, 112 lines)
services/auth.services.tsx
- Import useMaskStore (not usePrivacyStore)
- handleGoogleSuccess / emailLogin / loginOffline: new profiles set
schemaVersion: 2
- Drop LegacyVaultProfile and the entire vault branch in
handleGoogleSuccess / emailLogin
- Logout: remove vault_password_session cleanup
- Error messages: 'secure vault' -> 'data'/'account'
src/lib/application/commands/privacy.ts (deleted, 479 lines)
src/lib/application/commands/migration.ts (new, TEMPORARY)
- runVaultSchemaMigration(): reads stored profile, runs
needsV1Migration, executes migrateSchemaV1toV2, persists accounts +
profile to localStorage, updates the in-memory store, and best-effort
pushes the cleaned profile to Sheets (skipped if offline or gapi not
ready). Header comment marks it temporary; removal criterion in
CONTEXT.md / ADR-002.
src/lib/application/commands/sync.ts
- Drop usePrivacyStore, decryptAccount/normalizeAccount/encryptAccount,
isBiometricAvailable, verifyWithBiometrics, registerBiometrics
- loadData: no decrypt pass; profile merge keeps only name/maskMode/
showAIAssistant/syncChatToSheets
- saveAccountSyncPost: no encrypt; just saveProfile
- Drop encrypted_vault_key from the keep set
- _forceUnlock param replaces the removed force-unlock concept
src/lib/application/commands/accounts.ts
- saveAccount: no encryptAccount call; _profile param
- deleteAccount: no decrypt/clear-key side effects
src/lib/application/commands.ts
- Drop privacy re-exports
- Add runVaultSchemaMigration export
Strip every UI surface that interacted with the removed vault. The
Mask Mode toggle stays as a UI-only flag.
helpers/useAppInit.tsx
- Remove the vault-lock effect (no more isVaultUnlocked state to enforce)
- Wire runVaultSchemaMigration into the post-loadData .then(); failures
are logged but never block the app
- Toast on successful migration
helpers/useMask.tsx
- Read from useMaskStore (was usePrivacyStore)
layouts/MainLayout.tsx
- 'Privacy Mode' label renamed to 'Mask Mode' in sidebar + mobile header
- useMaskStore wired in
pages/AccountPage.tsx (981 -> 693 lines, -288)
- Remove AccountDetails import + all vault unlock state/handlers
- Remove the entire Identity Details block (card number, holder, expiry, CVV)
- Remove the Unlock Vault modal
- Replace with a small account.note display in the account header
pages/ProfilePage.tsx
- handleExportData no longer strips { details, ...rest }; no _note warning
- Updated to also write a clean, vault-free backup
components/Profile.tsx (1441 -> 603 lines, -838)
- Drop SecurityService, TwoFAService, LegacyVaultProfile, usePrivacyStore
- Remove the Vault, TOTP Setup, and Biometric Management modals
- Remove the Security & Biometrics section (2FA, Vault, Passkey items)
- Drop unused icons (LockClosedIcon, FingerPrintIcon, ShieldCheckIcon)
- Rename 'Privacy Mode' toggle -> 'Mask Mode' (now backed by maskMode)
components/AccountForm.tsx (774 -> 412 lines, -362)
- Drop AccountDetails, SecurityService, usePrivacyStore,
unlockVaultWithTOTP, unlockVaultWithBiometrics, Modal, LockClosedIcon,
FingerPrintIcon imports
- Remove isVaultEnabled, isVaultUnlocked, vaultTOTPCode state and all
vault unlock/biometric handlers
- Remove the 5 sensitive-field inputs (accountNumber, cardNumber,
holderName, expiry, cvv)
- loadAccountData: no sensitive-field init; no reload on vault unlock
- handleSubmit: no finalDetails wrap; note passes through directly
- Remove the entire Unlock Vault modal
- Add a single Notes textarea in its place
components/History.tsx
- Drop the stale usePrivacyStore import (useMask was already wired)
Final tallies: 19 files changed, 525 insertions, 3471 deletions across
the three commits of this branch. Tests: 110/110 passing.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Organization UI Review profile: ASSERTIVE Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
Wires the existing runVaultSchemaMigration() to a manual button in
Profile > Cloud & Data. Visible only when needsV1Migration() returns
true (i.e. local profile is still on schemaVersion < 2 or carries
legacy vault fields). Disappears after a successful run because the
predicate returns false once the profile is bumped to v2.
Confirmation modal explains the multi-device caveat. Local cleanup
runs first; cloud push is best-effort and may be skipped if offline.
Per CONTEXT.md / ADR-002, this button + runVaultSchemaMigration() +
the migration domain are all marked temporary. When the v1 -> v2
cutover is complete:
1. Delete the SettingItem block in components/Profile.tsx
2. Delete the showV1Migration hook (2 lines)
3. Delete the runVaultSchemaMigration + needsV1Migration imports (2 lines)
4. Later: delete src/lib/application/commands/migration.ts and
src/lib/domain/migration.ts (when no v1 clients are reachable)
Tests: 110/110 passing.
…om sheets Adds 10 application tests for runVaultSchemaMigration covering: early-return on missing profile, no-op on clean v2, full v1 cleanup, self-healing, offline/no-client skips cloud push, push-fail doesn't throw, in-memory store update, store-vs-storage source authority. Brings total to 120/120 tests. Removes the defensive 'sensitiveFields' filter from saveToSheet that excluded accountNumber/cardNumber/cvv/expiry/holderName from sheet headers. This is dead code per ADR-002 (all such fields deleted from Account type and the v1 codebase), and per the PRD's 'Code deleted' section. 120/120 tests still pass.
There was a problem hiding this comment.
Actionable comments posted: 8
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (4)
services/sheets.services.ts (2)
661-680:⚠️ Potential issue | 🟠 Major | ⚡ Quick winStrip legacy sensitive account keys before regenerating sheet headers.
This still derives headers from
combinedDataverbatim. If any legacyAccountsrow in Sheets carries removed fields likecardNumber,cvv,expiry,holderName,accountNumber, ordetails, those columns will be written straight back on every save and the minimisation migration never actually removes them.🧹 Proposed fix
+ const LEGACY_ACCOUNT_FIELDS = new Set([ + "cardNumber", + "cvv", + "expiry", + "holderName", + "accountNumber", + "details", + ]); + + const sanitizeRow = (item: any) => { + if (sheetName !== "Accounts") return item; + return Object.fromEntries( + Object.entries(item).filter( + ([key]) => !LEGACY_ACCOUNT_FIELDS.has(key), + ), + ); + }; + - const combinedData = Array.from(mergedMap.values()); + const combinedData = Array.from(mergedMap.values()).map(sanitizeRow); @@ - combinedData.forEach((item) => { + combinedData.forEach((item) => { Object.keys(item).forEach((key) => { headerSet.add(key); }); });🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@services/sheets.services.ts` around lines 661 - 680, The current header/row generation uses combinedData verbatim and will resurrect legacy sensitive Account fields; before building headerSet and mapping rows (symbols: combinedData, headerSet, headers, rowsToUpdate) filter out any legacy sensitive keys (cardNumber, cvv, expiry, holderName, accountNumber, details — and any other configured legacy sensitive names) from each item: create a sanitizedItems = combinedData.map(item => sanitized copy without those keys), then compute headerSet and rowsToUpdate from sanitizedItems (keeping the existing hasId logic to force 'id' first); this ensures removed sensitive columns are not reintroduced when regenerating the sheet.
388-405:⚠️ Potential issue | 🟡 Minor | ⚡ Quick winInitialize
lastSyncAtwhen creating the Profile sheet.
findUser()now treatslastSyncAtas required, but Lines 391-405 still create a 9-column header without it. The first lookup on a fresh spreadsheet will immediately rewrite the header row and recurse.🛠 Proposed fix
await window.gapi.client.sheets.spreadsheets.values.update({ spreadsheetId: fileId, - range: `'${sheetName}'!A1:I1`, + range: `'${sheetName}'!A1:J1`, valueInputOption: "RAW", resource: { values: [ [ "email", "password", "name", "createdAt", "maskMode", "schemaVersion", "showAIAssistant", "syncChatToSheets", "lastUpdatedAt", + "lastSyncAt", ], ], }, }); @@ if (h === "schemaVersion") return userData.schemaVersion ?? 2; if (h === "showAIAssistant") return userData.showAIAssistant !== false; if (h === "syncChatToSheets") return userData.syncChatToSheets !== false; if (h === "lastUpdatedAt") return new Date().toISOString(); + if (h === "lastSyncAt") return ""; return ""; });Also applies to: 417-426
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@services/sheets.services.ts` around lines 388 - 405, The header creation for the Profile sheet omits the required "lastSyncAt" column so findUser() treats it as missing and rewrites the header; update the headers passed to window.gapi.client.sheets.spreadsheets.values.update (the resource.values array used when creating the Profile sheet) to include "lastSyncAt" and expand the range (e.g., A1:J1) accordingly, and make the same change in the other header-creation call later in the file (the second spreadsheets.values.update block that builds profile/cloud headers) so both initial header rows include the "lastSyncAt" column.services/auth.services.tsx (1)
246-258:⚠️ Potential issue | 🟠 Major | ⚡ Quick winClear the refresh token during logout.
Lines 247-248 only clear the active access-token path.
google_refresh_tokenis still left inlocalStorage, andinitAuth()will use it on the next app start to mint a fresh Sheets token even after logout.🔒 Proposed fix
const logout = () => { googleLogout(); SheetService.clearGapiAccessToken(); + localStorage.removeItem("google_refresh_token"); const emptyProfile: UserProfile = { name: "", email: "", isLoggedIn: false, };🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@services/auth.services.tsx` around lines 246 - 258, The logout function currently clears the access token but not the persisted refresh token; update the logout implementation (function logout) to also remove the Google refresh token (key "google_refresh_token") from storage so initAuth() cannot reuse it after logout—either call an existing StorageService.remove or add StorageService.clearRefreshToken(), or call localStorage.removeItem("google_refresh_token") before setting the empty profile and resetting stores (after SheetService.clearGapiAccessToken() and before StorageService.saveProfile()).layouts/MainLayout.tsx (1)
303-315:⚠️ Potential issue | 🟠 Major | ⚡ Quick winGive the mobile Mask Mode toggle an accessible name and state.
This is an icon-only custom toggle, so assistive tech currently gets an unnamed button with no on/off state. Add an explicit label plus
aria-pressedor switch semantics.♿ Suggested fix
<button onClick={() => setMaskMode(!maskMode)} + aria-label={maskMode ? "Disable mask mode" : "Enable mask mode"} + aria-pressed={maskMode} className={`p-2 rounded-xl transition-all ${ maskMode ? "bg-indigo-600 text-white shadow-[0_0_15px_rgba(79,70,229,0.4)]"🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@layouts/MainLayout.tsx` around lines 303 - 315, The mobile Mask Mode icon-only toggle in MainLayout.tsx lacks an accessible name and state; update the button (the element using setMaskMode and maskMode) to include an explicit accessible label (e.g., aria-label or a visually hidden span text) and expose state via aria-pressed="true|false" or role="switch" with aria-checked={maskMode}; ensure the toggle still calls setMaskMode(!maskMode) and that the label updates or is descriptive (e.g., "Mask mode" / "Toggle mask mode") so screen readers receive both name and on/off state.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@components/AccountForm.tsx`:
- Around line 366-372: The placeholder in the Notes textarea currently
encourages storing account identifiers ("last 4 digits") which is disallowed;
update the placeholder text in the textarea (the element using value={note} and
onChange={(e) => setNote(e.target.value)}) to a non-identifying example (e.g.,
"Optional notes (e.g. payment reference, internal memo)") and add a simple
client-side guard in the onChange handler or a validator used by setNote to
strip or reject obvious account identifiers (sequences of 3+ consecutive digits
or patterns like last XXXX) before saving to state so the field no longer
accepts or suggests storing sensitive account identifiers.
In `@components/Profile.tsx`:
- Around line 289-304: The toggle in Profile.tsx is mutating profile.maskMode
directly, causing divergence from the global mask state used by
helpers/useMask.tsx and layouts/MainLayout.tsx; switch the toggle to use the
centralized store instead: import the useMaskStore hook used elsewhere, read
maskMode from useMaskStore (instead of profile.maskMode) and onClick call the
store's updater (e.g., a provided toggle/setMaskMode function or
useMaskStore.setState({ maskMode: !maskMode })) so both controls share the same
source of truth; you may still call onUpdate to persist the profile if needed,
but the UI toggle must drive useMaskStore.maskMode as the primary updater.
In `@helpers/useAppInit.tsx`:
- Around line 29-32: The init chain currently calls loadData(profile).then(() =>
{ runVaultSchemaMigration(); }) which fires runVaultSchemaMigration() without
returning its promise; change this so the migration promise is returned/awaited
(e.g., return runVaultSchemaMigration() from the .then or use async/await) so
that any rejection flows into the same .catch() and preserves ordering between
loadData and runVaultSchemaMigration; update the call site in useAppInit.tsx
where loadData(profile).then(...) is used to return the migration promise.
In `@pages/AccountPage.tsx`:
- Around line 617-620: The account note is rendered raw (account.note) and
therefore bypasses Mask Mode; update the rendering to pass account.note through
the masking utility (e.g., maskText(account.note) or a note-specific masker that
preserves whitespace/pre-wrap), or use a masked renderer component when Mask
Mode is active so wrapped formatting is preserved; locate this in the JSX that
conditionally renders account.note and replace the raw insertion with the masked
output while keeping the existing className and whitespace-pre-wrap behavior.
In `@pages/ProfilePage.tsx`:
- Around line 41-43: The exported `data` object currently includes `accounts`
which may still carry legacy runtime keys (`details`, `isEncrypted`) even if TS
types were updated; before building `data` (or before JSON.stringify/export),
map over `accounts` and create a sanitized array that removes those legacy keys
(e.g., strip `details` and `isEncrypted` from each account) and use that
sanitized array in place of `accounts` so the export will not contain legacy
fields; update the code that constructs `data` (the `data` const) to use this
sanitized accounts array.
In `@src/lib/application/commands/sync.ts`:
- Around line 448-455: The mergedAccounts array must be sanitized for legacy v1
fields before persisting or re-uploading: after computing mergedAccounts
(variable mergedAccounts) run needsV1Migration on each account and, if needed,
pass them through migrateSchemaV1toV2 to produce a cleanedAccounts array, then
call store.setAccounts(cleanedAccounts) and await
StorageService.saveAccounts(cleanedAccounts) (and use cleanedAccounts for any
subsequent syncWithGoogleSheets upload). Apply the same sanitize-then-save/sync
change to the other merge/save block referenced (around the 521-526 region) so
no raw v1 fields are reintroduced.
In `@src/lib/domain/__tests__/migration.test.ts`:
- Line 44: Replace the PAN-like test fixture used when creating the account in
migration.test.ts: locate the asAccount call that assigns acc (asAccount({
details: { cardNumber: "4111111111111111", cvv: "123" } })) and change the
cardNumber value to a clearly non-card placeholder (for example
"NON_PAN_PLACEHOLDER" or "placeholder-card-number"); leave the asAccount call
and acc variable unchanged otherwise.
In `@src/lib/domain/migration.ts`:
- Around line 47-50: The migration currently drops the v1 preference
`privacyMode` and forces `maskMode` to false; update the logic around `cleaned`
(the result of stripFields(profile, VAULT_PROFILE_FIELDS)) so that when
`cleaned.maskMode` is undefined you fall back to the original
`profile.privacyMode` before defaulting to false. In other words, use
`profile.privacyMode` as a fallback to preserve existing user settings
(referencing `profile`, `cleaned`, `maskMode`, and `privacyMode`) and only set
`maskMode` to false if neither value exists.
---
Outside diff comments:
In `@layouts/MainLayout.tsx`:
- Around line 303-315: The mobile Mask Mode icon-only toggle in MainLayout.tsx
lacks an accessible name and state; update the button (the element using
setMaskMode and maskMode) to include an explicit accessible label (e.g.,
aria-label or a visually hidden span text) and expose state via
aria-pressed="true|false" or role="switch" with aria-checked={maskMode}; ensure
the toggle still calls setMaskMode(!maskMode) and that the label updates or is
descriptive (e.g., "Mask mode" / "Toggle mask mode") so screen readers receive
both name and on/off state.
In `@services/auth.services.tsx`:
- Around line 246-258: The logout function currently clears the access token but
not the persisted refresh token; update the logout implementation (function
logout) to also remove the Google refresh token (key "google_refresh_token")
from storage so initAuth() cannot reuse it after logout—either call an existing
StorageService.remove or add StorageService.clearRefreshToken(), or call
localStorage.removeItem("google_refresh_token") before setting the empty profile
and resetting stores (after SheetService.clearGapiAccessToken() and before
StorageService.saveProfile()).
In `@services/sheets.services.ts`:
- Around line 661-680: The current header/row generation uses combinedData
verbatim and will resurrect legacy sensitive Account fields; before building
headerSet and mapping rows (symbols: combinedData, headerSet, headers,
rowsToUpdate) filter out any legacy sensitive keys (cardNumber, cvv, expiry,
holderName, accountNumber, details — and any other configured legacy sensitive
names) from each item: create a sanitizedItems = combinedData.map(item =>
sanitized copy without those keys), then compute headerSet and rowsToUpdate from
sanitizedItems (keeping the existing hasId logic to force 'id' first); this
ensures removed sensitive columns are not reintroduced when regenerating the
sheet.
- Around line 388-405: The header creation for the Profile sheet omits the
required "lastSyncAt" column so findUser() treats it as missing and rewrites the
header; update the headers passed to
window.gapi.client.sheets.spreadsheets.values.update (the resource.values array
used when creating the Profile sheet) to include "lastSyncAt" and expand the
range (e.g., A1:J1) accordingly, and make the same change in the other
header-creation call later in the file (the second spreadsheets.values.update
block that builds profile/cloud headers) so both initial header rows include the
"lastSyncAt" column.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: ASSERTIVE
Plan: Pro Plus
Run ID: 98aa2e37-7909-4d8b-9bec-3c3f9a4db4c8
📒 Files selected for processing (28)
components/AccountForm.tsxcomponents/History.tsxcomponents/Profile.tsxhelpers/useAppInit.tsxhelpers/useMask.tsxlayouts/MainLayout.tsxpages/AccountPage.tsxpages/ProfilePage.tsxservices/auth.services.tsxservices/biometric.services.tsservices/security.services.tsservices/sheets.services.tsservices/storage.services.tsservices/twofa.services.tssrc/lib/application/commands.tssrc/lib/application/commands/__tests__/migration.test.tssrc/lib/application/commands/accounts.tssrc/lib/application/commands/migration.tssrc/lib/application/commands/privacy.tssrc/lib/application/commands/sync.tssrc/lib/domain/__tests__/migration.test.tssrc/lib/domain/migration.tssrc/stores/__tests__/mask.store.test.tssrc/stores/__tests__/privacy.store.test.tssrc/stores/index.tssrc/stores/mask.store.tssrc/stores/privacy.store.tstypes.ts
💤 Files with no reviewable changes (7)
- services/twofa.services.ts
- src/stores/privacy.store.ts
- src/stores/tests/privacy.store.test.ts
- services/biometric.services.ts
- components/History.tsx
- services/security.services.ts
- src/lib/application/commands/privacy.ts
📜 Review details
🧰 Additional context used
🪛 OpenGrep (1.22.0)
src/lib/domain/__tests__/migration.test.ts
[ERROR] 44-44: Possible credit card number (PAN) detected in source code. Credit card numbers should never be hardcoded or stored in source files. Use a secrets manager or tokenization service instead.
(coderabbit.pii.credit-card-number)
🔇 Additional comments (9)
types.ts (1)
35-35: LGTM!Also applies to: 86-87
src/stores/mask.store.ts (1)
3-21: LGTM!src/stores/__tests__/mask.store.test.ts (1)
4-20: LGTM!src/stores/index.ts (1)
4-5: LGTM!src/lib/application/commands.ts (1)
12-12: LGTM!src/lib/application/commands/migration.ts (1)
1-43: LGTM!src/lib/application/commands/__tests__/migration.test.ts (1)
1-247: LGTM!src/lib/application/commands/accounts.ts (1)
11-11: LGTM!Also applies to: 27-27, 47-47
src/lib/application/commands/sync.ts (1)
104-105: LGTM!Also applies to: 121-121, 368-372
10 findings fixed (2 skipped — see body):
Domain layer (src/lib/domain/migration.ts):
- migration.ts: profile.privacyMode is now used as a fallback for maskMode when v2 maskMode is absent. Preserves v1 user preference instead of forcing maskMode to false. Added 2 regression tests.
- migration.test.ts: replaced PAN-like fixture (4111111111111111) with NON_PAN_PLACEHOLDER. Updated existing 'cleans in one pass' assertion to reflect the new privacyMode-fallback behavior.
Application layer (src/lib/application/commands/sync.ts):
- Sanitizes mergedAccounts with stripVaultFromAccount before setAccounts/saveAccounts (defense in depth: cloud might still carry legacy v1 fields even after local migration).
- Sanitizes postSubAccounts with stripVaultFromAccount before saveAccounts and the syncWithGoogleSheets upload.
Infrastructure layer:
- services/sheets.services.ts: saveToSheet now filters cardNumber/cvv/expiry/holderName/accountNumber/details from combinedData before generating headers/rows, so removed v1 columns are not reintroduced when re-serializing.
- services/sheets.services.ts: initial Profile/Users sheet header row now includes 'lastSyncAt' (was missing, causing findUser to treat it as absent and rewrite headers). Range expanded from A1:I1 to A1:J1.
- services/auth.services.tsx: logout now removes the persisted 'google_refresh_token' from localStorage so initAuth() cannot reuse it after logout.
UI layer:
- components/AccountForm.tsx: Notes placeholder no longer suggests 'last 4 digits'; new sanitizeNote() strips 3+ consecutive digit runs and 'last XXXX' patterns from the onChange handler.
- components/Profile.tsx: Mask Mode toggle now reads from useMaskStore (source of truth) and calls setMaskMode; profile.maskMode is only persisted via onUpdate. Was using profile.maskMode for UI state, causing divergence from helpers/useMask + MainLayout.
- pages/AccountPage.tsx: account.note is masked when Mask Mode is active (• chars, preserves whitespace-pre-wrap). Uses reactive maskMode from useMaskStore so toggling re-renders.
- pages/ProfilePage.tsx: data export strips legacy 'details' and 'isEncrypted' from each account (defense in depth: localStorage may still have v1 keys even after migration).
- layouts/MainLayout.tsx: mobile Mask Mode button now has aria-label='Toggle mask mode' and aria-pressed={maskMode} for screen reader state.
Application bootstrap (helpers/useAppInit.tsx):
- runVaultSchemaMigration() promise is now returned from the .then() callback so any rejection flows into the same .catch() and ordering with loadData is preserved.
Skipped findings (1 additional user finding):
- 'Cloud sync complete toast does not go away on staging': not a regression. Toast component JSX (layouts/MainLayout.tsx:487) is byte-identical on main and feature/drop-vault, and the sync flow's showToast('Cloud sync complete', 'success') is also identical. The behavior is pre-existing on main too. The fix would be a UX improvement (auto-dismiss), but is out of scope for this PR.
Validation: npx tsc --noEmit clean, npx vitest run 11/11 files 122/122 tests pass.
Before PR #6 (refactor/layered-architecture), main's DataProvider.tsx had a local showToast that called setTimeout(() => setToast(null), 3000) for auto-dismiss. When toast state was extracted into useSyncStore in PR #6, the auto-dismiss was dropped — the new showToast only set state, never cleared it. Restores the 3-second auto-dismiss in useSyncStore.showToast, matching main's behavior. Tracks the timer ID inside the store so: - Subsequent showToast calls cancel the previous timer (no premature dismissal of a fresh toast) - dismissToast() and reset() cancel the pending timer Adds 5 regression tests covering: 3s auto-dismiss, no-dismiss before 3s, timer reset on new toast, manual dismiss clears timer, reset clears timer. 127/127 tests pass.
Summary
Removes the encrypted-account vault and replaces it with a data-minimisation model. Per ADR-002, sensitive account fields (card number, CVV, expiry, holder name, account number) are dropped from the data model entirely. TOTP-derived encryption and WebAuthn-based biometric unlock for the vault are removed. The "Mask Mode" toggle stays as a UI-only flag (renamed from "Privacy Mode").
Why
The vault held a card number in the same trust boundary as the data it was supposed to protect. Even encrypted, the encryption key was derived from a TOTP secret stored in the user's Google Sheet, so the data was effectively plaintext to anyone with sheet access. ADR-002 captures the full reasoning and consequences.
What changes
Domain + types
types.ts— dropAccountDetails; dropAccount.details/isEncrypted; addAccount.note?: string(unencrypted freeform); simplifyUserCloudSettingsto 4 fieldssrc/lib/domain/migration.ts(new, temporary) — puremigrateSchemaV1toV2+needsV1MigrationpredicateStore rename
usePrivacyStore→useMaskStore(file, types, barrel, tests)maskModeboolean keptServices + I/O
services/sheets.services.ts—findUser/createUserno longer read/writetotpSecret,isSecurityEnabled,isVaultLocked,vaultSalt,biometricCredId(s),devices,privacyMode;updateUserdrops the array-merge branchservices/storage.services.ts—KEYS.SECURITYremoved;getStoredSecuritySettings/saveSecuritySettingsdeleted; profile defaults simplifiedservices/auth.services.tsx— new profiles setschemaVersion: 2;useMaskStorewired;LegacyVaultProfileand vault branch removedservices/security.services.ts,services/twofa.services.ts,services/biometric.services.ts— deleted (527 lines)Application
src/lib/application/commands/migration.ts(new, temporary) —runVaultSchemaMigration()orchestratorsrc/lib/application/commands/sync.ts—loadDatano-decrypt;saveAccountSyncPostno-encrypt;_forceUnlockparamsrc/lib/application/commands/accounts.ts— noencryptAccountcallsrc/lib/application/commands/privacy.ts— deleted (479 lines)src/lib/application/commands.ts— privacy re-exports removed,runVaultSchemaMigrationaddedUI
helpers/useAppInit.tsx— vault-lock effect removed;runVaultSchemaMigrationwired into post-loadData.then()components/Profile.tsx(1441 → 603, −838) — 3 modals + Security & Biometrics section + vault handlers all removedpages/AccountPage.tsx(981 → 693, −288) — Identity Details block + Unlock Vault modal removedcomponents/AccountForm.tsx(774 → 412, −362) — 5 sensitive inputs + Unlock Vault modal removedcomponents/History.tsx,helpers/useMask.tsx,layouts/MainLayout.tsx,pages/ProfilePage.tsx— minor wiring updatesMigration strategy
runVaultSchemaMigration()(idempotent, gated byprofile.schemaVersion === 2)needsV1Migrationpredicate is checked on every sync to defend against a v1 client re-introducing legacy fields — no UI, no prompt, silent self-healingThe migration code carries a TEMPORARY header comment and is marked for removal in
CONTEXT.mdanddocs/adrs/002-drop-vault-data-minimization.md(criterion: when no v1 clients are reachable andschemaVersionis no longer needed).Validation
npx tsc --noEmit— cleannpx vitest run— 110/110 passing across 10 test filesmigration.test.tsNet diff
19 files changed, 525 insertions, 3471 deletions across 3 commits.
Refs: ADR-002, CONTEXT.md, issue #7