/// Typed-item vault operations. All calls are handle-keyed — the master key /// never crosses the WASM boundary. import type { SessionHandle } from '../../wasm/relicario_wasm'; import type { GitHost } from './git-host'; import type { AttachmentRef, Item, ItemId, Manifest, ManifestEntry, VaultSettings } from '../shared/types'; // eslint-disable-next-line @typescript-eslint/no-explicit-any let wasm: any = null; // eslint-disable-next-line @typescript-eslint/no-explicit-any export function setWasm(w: any): void { wasm = w; } function requireWasm(): any { if (!wasm) throw new Error('WASM module not initialized'); return wasm; } export interface VaultMeta { salt: Uint8Array; paramsJson: string; } export async function fetchVaultMeta(git: GitHost): Promise { const saltBytes = await git.readFile('.relicario/salt'); const paramsRaw = await git.readFile('.relicario/params.json'); const paramsJson = new TextDecoder().decode(paramsRaw); return { salt: saltBytes, paramsJson }; } // --- Manifest --- export async function fetchAndDecryptManifest( git: GitHost, handle: SessionHandle, ): Promise { const w = requireWasm(); const ciphertext = await git.readFile('manifest.enc'); return w.manifest_decrypt(handle, ciphertext) as Manifest; } export async function encryptAndWriteManifest( git: GitHost, handle: SessionHandle, manifest: Manifest, message: string, ): Promise { const w = requireWasm(); const ciphertext = w.manifest_encrypt(handle, JSON.stringify(manifest)); await git.writeFile('manifest.enc', ciphertext, message); } // --- Items --- export async function fetchAndDecryptItem( git: GitHost, handle: SessionHandle, id: ItemId, ): Promise { const w = requireWasm(); const ciphertext = await git.readFile(`items/${id}.enc`); return w.item_decrypt(handle, ciphertext) as Item; } export async function encryptAndWriteItem( git: GitHost, handle: SessionHandle, id: ItemId, item: Item, message: string, ): Promise { const w = requireWasm(); const ciphertext = w.item_encrypt(handle, JSON.stringify(item)); await git.writeFile(`items/${id}.enc`, ciphertext, message); } // --- Settings (the α subset the SW reads/writes is autofill_origin_acks) --- export async function fetchAndDecryptSettings( git: GitHost, handle: SessionHandle, ): Promise { const w = requireWasm(); const ciphertext = await git.readFile('settings.enc'); return w.settings_decrypt(handle, ciphertext) as VaultSettings; } export async function encryptAndWriteSettings( git: GitHost, handle: SessionHandle, settings: VaultSettings, message: string, ): Promise { const w = requireWasm(); const ciphertext = w.settings_encrypt(handle, JSON.stringify(settings)); await git.writeFile('settings.enc', ciphertext, message); } // --- In-memory manifest helpers --- export function listItems( manifest: Manifest, group?: string, ): Array<[ItemId, ManifestEntry]> { const entries = Object.entries(manifest.items) as Array<[ItemId, ManifestEntry]>; // Hide trashed items from the default list view. const live = entries.filter(([, e]) => e.trashed_at === undefined); if (!group) return live; const g = group.toLowerCase(); return live.filter(([, e]) => e.group?.toLowerCase() === g); } export function searchItems( manifest: Manifest, query: string, ): Array<[ItemId, ManifestEntry]> { const q = query.toLowerCase(); return (Object.entries(manifest.items) as Array<[ItemId, ManifestEntry]>) .filter(([, e]) => e.trashed_at === undefined) .filter(([, e]) => { if (e.title.toLowerCase().includes(q)) return true; if (e.tags.some((t) => t.toLowerCase().includes(q))) return true; return false; }); } /// Match manifest entries against a page hostname. /// /// icon_hint is derived by the Rust core (crates/relicario-core/src/manifest.rs) /// from LoginCore.url's hostname, so equality on icon_hint is the cheapest match. /// α is intentionally coarse: no www.-stripping, no public-suffix matching /// (`www.github.com` saved items will not match `github.com`, and vice versa). /// Tighter matching is a 1C-β/γ concern. export function findByHostname( manifest: Manifest, hostname: string, ): Array<[ItemId, ManifestEntry]> { const h = hostname.toLowerCase(); return (Object.entries(manifest.items) as Array<[ItemId, ManifestEntry]>) .filter(([, e]) => e.trashed_at === undefined) .filter(([, e]) => (e.icon_hint ?? '').toLowerCase() === h); } // --- Attachment helpers --- /// Sync the manifest entry's attachment_summaries for a single item. /// Reads the current manifest + item; if either is missing, does nothing. async function syncManifestAttachments( git: GitHost, handle: SessionHandle, itemId: ItemId, item: Item, message: string, ): Promise { const manifest = await fetchAndDecryptManifest(git, handle); const entry = manifest.items[itemId]; if (!entry) return; entry.attachment_summaries = item.attachments.map((a) => ({ id: a.id, filename: a.filename, mime_type: a.mime_type, size: a.size, })); await encryptAndWriteManifest(git, handle, manifest, message); } /// Add an AttachmentRef to an existing item. /// Reads + decrypts the item, appends to attachments[], re-encrypts and /// writes both items/.enc and the manifest entry's attachment_summaries. /// Throws if the item is not found. export async function addAttachmentToItem( git: GitHost, handle: SessionHandle, itemId: ItemId, ref: AttachmentRef, ): Promise { let item: Item; try { item = await fetchAndDecryptItem(git, handle, itemId); } catch { throw new Error(`addAttachmentToItem: item ${itemId} not found`); } item.attachments = [...item.attachments, ref]; item.modified = Math.floor(Date.now() / 1000); const commitMsg = `attach ${ref.filename} to item ${itemId}`; await encryptAndWriteItem(git, handle, itemId, item, commitMsg); await syncManifestAttachments(git, handle, itemId, item, `manifest: sync attachments for ${itemId}`); } // --- Trash operations --- export function listTrashed(manifest: Manifest): Array<[ItemId, ManifestEntry]> { return Object.entries(manifest.items) .filter(([, entry]) => entry.trashed_at != null) .sort(([, a], [, b]) => (b.trashed_at ?? 0) - (a.trashed_at ?? 0)); } export async function restoreItem( git: GitHost, handle: SessionHandle, manifest: Manifest, itemId: ItemId, ): Promise { const item = await fetchAndDecryptItem(git, handle, itemId); const now = Math.floor(Date.now() / 1000); const restored: Item = { ...item, trashed_at: undefined, modified: now }; await encryptAndWriteItem(git, handle, itemId, restored, `restore: ${item.title}`); manifest.items[itemId] = { ...manifest.items[itemId], trashed_at: undefined, modified: now }; await encryptAndWriteManifest(git, handle, manifest, `manifest: restore ${item.title}`); } export async function purgeItem( git: GitHost, itemId: ItemId, manifest: Manifest, ): Promise { const entry = manifest.items[itemId]; const deletedBlobs: string[] = []; // Delete attachments for (const att of entry?.attachment_summaries ?? []) { try { await git.deleteBlob(`attachments/${att.id}.bin`, `purge attachment: ${att.filename}`); deletedBlobs.push(att.id); } catch { /* blob may not exist */ } } // Delete item file await git.deleteFile(`items/${itemId}.enc`, `purge: ${entry?.title ?? itemId}`); // Update manifest delete manifest.items[itemId]; return deletedBlobs; } export async function purgeAllTrash( git: GitHost, handle: SessionHandle, manifest: Manifest, ): Promise<{ itemCount: number; orphanCount: number }> { const trashed = listTrashed(manifest); const allDeletedBlobs = new Set(); // Purge each trashed item for (const [id] of trashed) { const deleted = await purgeItem(git, id, manifest); deleted.forEach((b) => allDeletedBlobs.add(b)); } // Collect all referenced attachment IDs from remaining items const referenced = new Set(); for (const entry of Object.values(manifest.items)) { for (const att of entry.attachment_summaries ?? []) { referenced.add(att.id); } } // Scan for orphan blobs let orphanCount = 0; try { const blobFiles = await git.listDir('attachments'); for (const filename of blobFiles) { const id = filename.replace(/\.bin$/, ''); if (!referenced.has(id) && !allDeletedBlobs.has(id)) { try { await git.deleteBlob(`attachments/${filename}`, `purge orphan: ${id}`); orphanCount++; } catch { /* ignore */ } } } } catch { /* attachments dir may not exist */ } // Write manifest once if (trashed.length > 0 || orphanCount > 0) { await encryptAndWriteManifest( git, handle, manifest, `trash: purge ${trashed.length} items + ${orphanCount} orphan blobs`, ); } return { itemCount: trashed.length, orphanCount }; } /// Remove attachments matching the given IDs from the item. /// Returns the removed AttachmentRefs (so the caller can deleteBlob the /// underlying bytes). IDs not present in the item's attachments are /// silently ignored. export async function removeAttachmentsFromItem( git: GitHost, handle: SessionHandle, itemId: ItemId, idsToRemove: string[], ): Promise { let item: Item; try { item = await fetchAndDecryptItem(git, handle, itemId); } catch { throw new Error(`removeAttachmentsFromItem: item ${itemId} not found`); } const removeSet = new Set(idsToRemove); const removed: AttachmentRef[] = []; const kept: AttachmentRef[] = []; for (const att of item.attachments) { if (removeSet.has(att.id)) { removed.push(att); } else { kept.push(att); } } item.attachments = kept; item.modified = Math.floor(Date.now() / 1000); const commitMsg = `remove ${removed.length} attachment(s) from item ${itemId}`; await encryptAndWriteItem(git, handle, itemId, item, commitMsg); await syncManifestAttachments(git, handle, itemId, item, `manifest: sync attachments for ${itemId}`); return removed; }