Non-functional tightening flagged in the slice-3 code review: - session.ts: document future multi-vault refactor (β+) so the module- scope singleton is explicitly "deliberately simple," not an oversight. - vault.ts: move findByHostname doc comment above the function; note α's intentionally-coarse hostname match (no www-stripping, no public-suffix matching) and that tighter matching is a β/γ concern. - index.ts: expand the passphrase scope-clearing comment to make the theatre explicit rather than leaving it looking like real defense. - index.ts: TODO(slice-4) marker on delete_item's non-atomic two-write path — consider manifest-first ordering or retry/rollback at router- split time. - index.ts: cross-reference comment on itemToManifestEntry pointing at the Rust-side ManifestEntry::from_item derivation it must mirror. No behavior change; build still compiles with 2 bundle-size warnings. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
143 lines
4.4 KiB
TypeScript
143 lines
4.4 KiB
TypeScript
/// 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 { 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<VaultMeta> {
|
||
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<Manifest> {
|
||
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<void> {
|
||
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<Item> {
|
||
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<void> {
|
||
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<VaultSettings> {
|
||
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<void> {
|
||
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);
|
||
}
|