From 8d712e780c25973f50d999d08a11d955f74ea99a Mon Sep 17 00:00:00 2001 From: Allisson Azevedo Date: Sat, 18 Jul 2026 16:38:26 -0300 Subject: [PATCH] refactor(web): unify per-type item field selection behind ItemDescriptor The "which fields matter for type X" decision was re-encoded in four places: the vault-list search switch (route.tsx), getItemSubtitle (ItemCard.tsx), the read-only detail renderer, and the form field registry. Search and subtitle carried `as string` casts because the item type was unknown at the call site, and neither was directly tested. Deepen the existing form registry with a pure, React-free sibling: ITEM_DESCRIPTORS in item-fields/descriptors.ts. Each entry is keyed by VaultItem['type'] and typed against Extract, so field access needs no casts; the two dispatchers (getItemSearchText, getItemSubtitle) carry a single contained assertion and return '' for unknown types. Kept separate from index.ts so the hot list-filter path does not pull the field-editor component graph into its bundle. getItemSubtitle is re-exported from ItemCard.tsx so existing import paths and the trash test mock keep working. Invariant, locked by a table-driven test: the search haystack emits no secret material (password, number, cvv, secret, clientSecret, privateKey, passphrase, mnemonic) and excludes the generic title. Detail renderer and TYPE_OPTIONS are left for a follow-up. Co-Authored-By: Claude Opus 4.8 (1M context) --- CONTEXT.md | 6 +- apps/web/src/components/vault/ItemCard.tsx | 35 +------ .../vault/item-fields/descriptors.test.ts | Bin 0 -> 4829 bytes .../vault/item-fields/descriptors.ts | 90 ++++++++++++++++++ apps/web/src/routes/_vault/route.tsx | 49 +--------- 5 files changed, 104 insertions(+), 76 deletions(-) create mode 100644 apps/web/src/components/vault/item-fields/descriptors.test.ts create mode 100644 apps/web/src/components/vault/item-fields/descriptors.ts diff --git a/CONTEXT.md b/CONTEXT.md index 5bf8013..bb82df0 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -103,7 +103,11 @@ The re-wrap ceremony. Given a held **MasterKey** and a new password, derives a n The per-type editor module for one **VaultItem** discriminator (login, secure_note, payment_card, identity, totp, developer_credential, crypto_wallet). Lives under `components/vault/item-fields/Fields.tsx`. Owns its type-specific fields, validation, and any local reveal/mode-switch UI. Reads RHF methods through `useFormContext()` so the outer shell doesn't prop-drill. **VaultItemFieldsRegistry**: -The `Record` map exported from `components/vault/item-fields/index.ts`. The outer **ItemForm** shell looks up the active type and renders the registered component inside a `FormProvider`. Schemas are imported from `@blindpass/vault` so the wire schema stays the source of truth. +The `Record` map exported from `components/vault/item-fields/index.ts`. The outer **ItemForm** shell looks up the active type and renders the registered component inside a `FormProvider`. Schemas are imported from `@blindpass/vault` so the wire schema stays the source of truth. Its read-only sibling is the **ItemDescriptor** map. + +**ItemDescriptor**: +The per-type read-selection map `ITEM_DESCRIPTORS: { [K in VaultItem['type']]: { toSearchText, subtitle } }` in `components/vault/item-fields/descriptors.ts` — a pure, React-free module kept separate from the form registry so the hot list-filter path doesn't pull the field-editor component graph into its bundle. Owns "which fields matter for type X" for every non-form read: `getItemSearchText(item)` (the vault-list search haystack, `route.tsx`) and `getItemSubtitle(item)` (the **ItemCard** / trash / health subtitle, re-exported from `ItemCard.tsx` for path stability). The single audit point that replaced the four scattered copies of per-type field knowledge. _Invariant_: `toSearchText` emits no secret material (`password`, `number`, `cvv`, `secret`, `clientSecret`, `privateKey`, `passphrase`, `mnemonic`) — the search haystack is a plaintext index — and excludes `title` (generic; callers match it separately). Each entry is typed against `Extract`, so field access needs no casts; the two dispatchers carry the one contained `as never` and return `''` for unknown types. The read-only detail renderer (`$itemId.tsx`) and the TypeFilter's `TYPE_OPTIONS` are not yet folded in — the obvious next descriptor fields. +_Avoid_: item metadata, type config, field map. ### Browser ceremony plumbing diff --git a/apps/web/src/components/vault/ItemCard.tsx b/apps/web/src/components/vault/ItemCard.tsx index e3671f9..388e2bb 100644 --- a/apps/web/src/components/vault/ItemCard.tsx +++ b/apps/web/src/components/vault/ItemCard.tsx @@ -8,6 +8,10 @@ import type { DecryptedItem } from '@/hooks/useVault'; import { useSyncBoundary } from '@/components/sync/SyncBoundary'; import { useTotpCode, formatTotpCode } from '@/hooks/useTotpCode'; import { ItemAvatar } from './ItemAvatar'; +import { getItemSubtitle } from './item-fields/descriptors'; + +// Re-exported so existing importers of `@/components/vault/ItemCard` keep working. +export { getItemSubtitle }; function TotpRowTail({ item, @@ -60,37 +64,6 @@ interface Props { vaultLabel?: { color: string; name: string }; } -export function getItemSubtitle(item: { type: string; [key: string]: unknown }): string { - switch (item.type) { - case 'login': - return String(item.username ?? ''); - case 'secure_note': { - const c = String(item.content ?? ''); - return c.slice(0, 40) + (c.length > 40 ? '…' : ''); - } - case 'payment_card': - return String(item.cardholderName ?? ''); - case 'identity': - return `${item.firstName ?? ''} ${item.lastName ?? ''}`.trim(); - case 'totp': - return [item.issuer, item.accountName].filter(Boolean).join(' · ') || 'Authenticator'; - case 'developer_credential': - return item.credentialMode === 'ssh_key' - ? [item.username, item.host].filter(Boolean).join(' @ ') - : [item.provider, item.environment].filter(Boolean).join(' · '); - case 'crypto_wallet': { - if (item.walletMode !== 'bip39') return ''; - const wordCount = String(item.mnemonic ?? '') - .trim() - .split(/\s+/).length; - const primary = String(item.walletName ?? item.addressHint ?? `${wordCount}-word seed`); - return item.network ? `${item.network} · ${primary}` : primary; - } - default: - return ''; - } -} - export const ItemCard = memo(function ItemCard({ item, isWeak, isReused, vaultLabel }: Props) { const [copied, setCopied] = useState(false); const { pendingItemIds } = useSyncBoundary(); diff --git a/apps/web/src/components/vault/item-fields/descriptors.test.ts b/apps/web/src/components/vault/item-fields/descriptors.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..f821a034571bcaf8dcc7f445012f584204d90cad GIT binary patch literal 4829 zcmb_gYi=7i5bkfEVtxdWgxc~uKy9S3lNz?0#1X1AO<)8HwM$Eccb8j|D_bxG0*`OYTcrxk*t0wrp~q-wcN{6mcq*p$7`N4wM-4P2%?{56mVBLkqf5GN#T< z7_Lp{ctbdHGfz2q-mxq)mxjkB;mJ4>NtiONw`X>Uk(qK6HlgvI>GvmN7qKo}oYkl7`jLs2Kv9*M?U?jfW) z6RtlVZ!E4q(lfe-2&<{k)rsUpB%RHC$*FVOL^fbY)F8m3#T={W!dzry&qtI2Cue*| z21W+Vlv_5icT$C5nr4aNYH!QoUs7Z$vYW=F`e=K5BFr=!_X8Pwf`v1#aJ*1JYZ)p) z3R*A8=>;m+%cgv^I&wt~aKMl$K1!tFUPLQIt7H{WSK!s9tBAPaiGjPU(^nj6VY-Jq zJ?G)Fqb(#g*Q(uvogRHpoiCsNT=8>_Un!f%Fk^&65kBTv3ti>eJE2+S7$i+)6moTy z=WypVWb{hTc*KGNJLFszkGX>P2fNKrDF=n*H87fjW@op)`T=>5#r>UfF__J;u)DYK zSC-*xv7iQD+ir1au3PMLn%3YcLZB(k+*{I`Bug|Ej;||xZiLdt19-0L$VIH&xR59( zs)T{ZOxW`?vGgYio6Y;Nh}K||3#EpH8YXSKB_?ZJH`1h!xn0|@Tt3arv~<9oN_g<| z^WkqF_J10jT?}6C58e+-3ZZqDr_E^)S75OqkSHh~u9%-N?DmB$*;q7@3e^N9v@^Uo zt{4Y{1)@4s4TO8i#cTZ3rGacU$?8sV18 z6N^Oz*+$;-M=Nz3V+lr4xK9U{bjyoOC8~71mZ7cebu~an$r&=&9=#!Mv$TSrCo`dB zV(SPtL041~vhpu&y~p?HV6QY`jC=UkMy+S5=-V4A<+XZm?>KuuLx8>=MPkNBZovy$ zKIhIZYiVdTLt}Y0w92oA&9egBn*B~$eoHLr|Hgm?k+4P9$s46i6o_IJ+7SFKF<~<-60jXY}&yL%v%vX6W_< z#|J02yklF>HKMe%G+ng&OSk`v$)Vl2}ADt<+=e}RsS99_v4Cp?yk2mq~Z z%c<-`clUaI)Z)17+fnkI{(^nQL*zs$CgBF`w~%s%VMp<-XxEWhVW_y)7cP5t?olib zJm2!kc0#ETK@H+soJs<~)i9(Mr`#L+tMj$MYmTAs25}f;9P<|bzX2Fp4Kj(U`*tn- zdk0PUm!b0Z)j-)h*vfsjZWOy!yRixfhQN(tWbbyYqke*SBl9M8d-Uwbd^{}3`kV#R zt{tFtuiJTIGqnS{hkP>K^us8={RnDT?A5JlQHrutY&Y)@vHRJrrG<0So4$&X V=PweY)A9B%F%-zPlUw)f&p+2$qzwQ7 literal 0 HcmV?d00001 diff --git a/apps/web/src/components/vault/item-fields/descriptors.ts b/apps/web/src/components/vault/item-fields/descriptors.ts new file mode 100644 index 0000000..51d6c0e --- /dev/null +++ b/apps/web/src/components/vault/item-fields/descriptors.ts @@ -0,0 +1,90 @@ +import type { VaultItem } from '@blindpass/vault'; + +export type VaultItemType = VaultItem['type']; + +type ItemOf = Extract; + +/** + * Per-type field-selection for one VaultItem discriminator. The single audit + * point for "which fields matter for type X" outside of the form registry. + * + * Invariants (locked by descriptors.test.ts): + * - `toSearchText` MUST NOT emit secret material (password, number, cvv, + * secret, clientSecret, privateKey, passphrase, mnemonic). The search + * haystack is a plaintext index — leaking a secret here is a disclosure bug. + * - `toSearchText` returns raw case and excludes `title` (generic across all + * types); callers lowercase once and match title separately. + * - `subtitle` is display copy (separators, truncation, fallbacks) and is + * likewise secret-free. + */ +export interface ItemDescriptor { + toSearchText: (item: ItemOf) => string; + subtitle: (item: ItemOf) => string; +} + +type DescriptorMap = { [K in VaultItemType]: ItemDescriptor }; + +const join = (parts: (string | undefined)[], sep = ' ') => parts.filter(Boolean).join(sep); + +export const ITEM_DESCRIPTORS: DescriptorMap = { + login: { + toSearchText: (item) => join([item.username, item.url]), + subtitle: (item) => item.username, + }, + secure_note: { + toSearchText: (item) => item.content, + subtitle: (item) => item.content.slice(0, 40) + (item.content.length > 40 ? '…' : ''), + }, + payment_card: { + toSearchText: (item) => item.cardholderName, + subtitle: (item) => item.cardholderName, + }, + identity: { + toSearchText: (item) => join([item.firstName, item.lastName]), + subtitle: (item) => `${item.firstName} ${item.lastName}`.trim(), + }, + totp: { + toSearchText: (item) => join([item.issuer, item.accountName]), + subtitle: (item) => join([item.issuer, item.accountName], ' · ') || 'Authenticator', + }, + developer_credential: { + toSearchText: (item) => + join( + item.credentialMode === 'token' + ? [item.provider, item.environment, item.keyId, item.baseUrl] + : item.credentialMode === 'client_secret_pair' + ? [item.provider, item.environment, item.clientId, item.baseUrl] + : [item.username, item.host, item.algorithm, item.fingerprint], + ), + subtitle: (item) => + item.credentialMode === 'ssh_key' + ? join([item.username, item.host], ' @ ') + : join([item.provider, item.environment], ' · '), + }, + crypto_wallet: { + toSearchText: (item) => join([item.walletName, item.network, item.addressHint]), + subtitle: (item) => { + const wordCount = item.mnemonic.trim().split(/\s+/).length; + const primary = item.walletName ?? item.addressHint ?? `${wordCount}-word seed`; + return item.network ? `${item.network} · ${primary}` : primary; + }, + }, +}; + +type LooseItem = { type: string }; + +/** + * Space-joined, non-secret search haystack for an item. Excludes `title` (the + * caller matches that separately) and returns raw case. Unknown types yield '' + * so no legacy/future discriminator can throw here. + */ +export function getItemSearchText(item: LooseItem): string { + const descriptor = ITEM_DESCRIPTORS[item.type as VaultItemType]; + return descriptor ? descriptor.toSearchText(item as never) : ''; +} + +/** Single-line display subtitle for an item. Secret-free; '' for unknown types. */ +export function getItemSubtitle(item: LooseItem): string { + const descriptor = ITEM_DESCRIPTORS[item.type as VaultItemType]; + return descriptor ? descriptor.subtitle(item as never) : ''; +} diff --git a/apps/web/src/routes/_vault/route.tsx b/apps/web/src/routes/_vault/route.tsx index e039020..c7f0734 100644 --- a/apps/web/src/routes/_vault/route.tsx +++ b/apps/web/src/routes/_vault/route.tsx @@ -37,6 +37,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { Drawer } from 'vaul'; import { vaultColor } from '@/lib/vaultColor'; import { ItemCard } from '@/components/vault/ItemCard'; +import { getItemSearchText } from '@/components/vault/item-fields/descriptors'; import { ItemCardSkeleton } from '@/components/vault/ItemCardSkeleton'; import { OnboardingEmpty } from '@/components/vault/OnboardingEmpty'; import { RecentlyViewedSection } from '@/components/vault/RecentlyViewedSection'; @@ -599,50 +600,10 @@ function VaultListPanel({ const result = typeFiltered; if (!search.trim()) return result; const q = search.toLowerCase(); - return result.filter((item) => { - if (item.title.toLowerCase().includes(q)) return true; - switch (item.type) { - case 'login': - return ( - (item.username as string).toLowerCase().includes(q) || - ((item.url as string | undefined)?.toLowerCase().includes(q) ?? false) - ); - case 'secure_note': - return ((item.content as string) ?? '').toLowerCase().includes(q); - case 'payment_card': - return ((item.cardholderName as string) ?? '').toLowerCase().includes(q); - case 'identity': - return `${(item.firstName as string) ?? ''} ${(item.lastName as string) ?? ''}` - .toLowerCase() - .includes(q); - case 'totp': - return [item.issuer, item.accountName] - .filter(Boolean) - .join(' ') - .toLowerCase() - .includes(q); - case 'developer_credential': - return ( - item.credentialMode === 'token' - ? [item.provider, item.environment, item.keyId, item.baseUrl] - : item.credentialMode === 'client_secret_pair' - ? [item.provider, item.environment, item.clientId, item.baseUrl] - : [item.username, item.host, item.algorithm, item.fingerprint] - ) - .filter(Boolean) - .join(' ') - .toLowerCase() - .includes(q); - case 'crypto_wallet': - return [item.walletName, item.network, item.addressHint] - .filter(Boolean) - .join(' ') - .toLowerCase() - .includes(q); - default: - return false; - } - }); + return result.filter( + (item) => + item.title.toLowerCase().includes(q) || getItemSearchText(item).toLowerCase().includes(q), + ); }, [typeFiltered, search]); const [recentTick, setRecentTick] = useState(0);