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 0000000..f821a03 Binary files /dev/null and b/apps/web/src/components/vault/item-fields/descriptors.test.ts differ 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);