diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 32a11de..6c859b4 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -238,6 +238,36 @@ The form is **protocol-aware**: Empty fields are stripped before encryption, so the ciphertext stays small and forward-compatible. +### 5a. Store on this device only + +For credentials you don't want stored remotely **even encrypted**, the +form has a **Store on this device only** toggle below the URL field. +When ticked: + +- The encrypted ciphertext is written to **IndexedDB** in this browser + (using the same vault key, so locking the vault still locks the local + items). +- Nothing about the credential — not even the ciphertext — touches + the server. +- Items appear in the grid with a green **DEVICE** badge next to the + name, so you can tell at a glance which credentials are local-only. + +> ⚠️ **The trade-offs are real.** A local-only credential: +> +> - Does **not sync** to other devices or browsers. +> - Is **wiped** if you clear site data, "Reset to defaults", or the OS +> user profile is deleted. +> - Has **no off-device backup** — if this disk dies, the credential +> dies with it. +> +> Use device-only storage for items where the security benefit +> ("never on a server, even encrypted") outweighs the convenience cost +> ("only on this machine"). Most credentials should stay on the +> server-default for the sync + backup guarantees. + +The toggle defaults to off, so existing zero-knowledge guarantees apply +to everyone who doesn't opt in. + --- ## 6. Chrome extension diff --git a/docs/img/vault-add.png b/docs/img/vault-add.png index c020af0..7b3e5fb 100644 Binary files a/docs/img/vault-add.png and b/docs/img/vault-add.png differ diff --git a/docs/img/vault-connect.png b/docs/img/vault-connect.png index edda1eb..a4a2322 100644 Binary files a/docs/img/vault-connect.png and b/docs/img/vault-connect.png differ diff --git a/docs/img/vault.png b/docs/img/vault.png index 36298b0..9f3440f 100644 Binary files a/docs/img/vault.png and b/docs/img/vault.png differ diff --git a/docs/preview/vault-add.html b/docs/preview/vault-add.html index 0bc0840..8c5b1c7 100644 --- a/docs/preview/vault-add.html +++ b/docs/preview/vault-add.html @@ -103,6 +103,19 @@

New credential

+ +
diff --git a/docs/preview/vault.html b/docs/preview/vault.html index 6059499..2a7c70f 100644 --- a/docs/preview/vault.html +++ b/docs/preview/vault.html @@ -125,7 +125,7 @@

All credentials

PG
-
prod-pg-primary
+
prod-pg-primary
postgres://db-prod-01:5432/app
@@ -149,7 +149,7 @@

All credentials

SSH
-
prod-pg-replica
+
prod-pg-replica
ssh://postgres@db-prod-02:22
@@ -173,7 +173,10 @@

All credentials

RDP
-
oracle-erp-bastion
+
+ oracle-erp-bastion + Device +
rdp://erp-db-01.example.az:3389
@@ -197,7 +200,7 @@

All credentials

MY
-
mysql-reporting
+
mysql-reporting
mysql://rpt-db-01:3306/reports
@@ -221,7 +224,7 @@

All credentials

RD
-
redis-cache
+
redis-cache
rediss://cache-01:6379
@@ -245,7 +248,7 @@

All credentials

MG
-
mongo-shard-a
+
mongo-shard-a
mongodb://mongo-01:27017
diff --git a/package-lock.json b/package-lock.json index abe1336..a33dada 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1634,6 +1634,16 @@ "node": ">=12.0.0" } }, + "node_modules/fake-indexeddb": { + "version": "6.2.5", + "resolved": "https://registry.npmjs.org/fake-indexeddb/-/fake-indexeddb-6.2.5.tgz", + "integrity": "sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -2402,6 +2412,7 @@ "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", "@vitejs/plugin-react": "^4.3.4", + "fake-indexeddb": "^6.2.5", "typescript": "^5.7.0", "vite": "^6.0.3", "vitest": "^4.1.5" diff --git a/packages/web/package.json b/packages/web/package.json index 9670f08..1f49cd6 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -22,6 +22,7 @@ "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", "@vitejs/plugin-react": "^4.3.4", + "fake-indexeddb": "^6.2.5", "typescript": "^5.7.0", "vite": "^6.0.3", "vitest": "^4.1.5" diff --git a/packages/web/src/pages/VaultPage.tsx b/packages/web/src/pages/VaultPage.tsx index aaa3e14..70447ac 100644 --- a/packages/web/src/pages/VaultPage.tsx +++ b/packages/web/src/pages/VaultPage.tsx @@ -12,6 +12,12 @@ import { effectiveProtocol, protocolLabel, } from "../connect/index.js"; +import { + createItem as createStored, + deleteItem as deleteStored, + listAll as listAllStored, + type StorageLocation, +} from "../storage/index.js"; import { useSession } from "../stores/session.js"; import { AddCredentialForm } from "./vault/AddCredentialForm.js"; import { ConnectDialog } from "./vault/ConnectDialog.js"; @@ -104,13 +110,13 @@ export function VaultPage() { } async function loadItems() { - if (!accessToken || !vault) return; + if (!accessToken || !vault || !email) return; setLoading(true); setError(null); try { - const { items: encrypted } = await api.listItems(accessToken); + const located = await listAllStored(accessToken, email); const decoded = await Promise.all( - encrypted.map(async (it) => ({ + located.map(async (it) => ({ ...it, plaintext: await decryptVaultLogin(it.encrypted_data, vault), })), @@ -123,16 +129,26 @@ export function VaultPage() { } } - async function onAdd(plaintext: VaultLoginPlaintext) { - if (!accessToken || !vault) return; + async function onAdd( + plaintext: VaultLoginPlaintext, + location: StorageLocation, + ) { + if (!accessToken || !vault || !email) return; const encoded = await encryptVaultLogin(plaintext, vault); - await api.createItem(accessToken, { + await createStored({ + accessToken, + vault: email, item_type: encoded.itemType, encrypted_data: encoded.encryptedData, + location, }); setAdding(false); await loadItems(); - showToast("Credential added"); + showToast( + location === "local" + ? "Credential added — stored on this device only" + : "Credential added", + ); } async function onLogout() { @@ -149,9 +165,11 @@ export function VaultPage() { async function onDelete(id: string) { if (!accessToken) return; + const target = items.find((it) => it.id === id); + if (!target) return; if (!confirm("Delete this credential?")) return; try { - await api.deleteItem(accessToken, id); + await deleteStored(accessToken, id, target.location); setSelected((s) => { const next = new Set(s); next.delete(id); @@ -168,13 +186,20 @@ export function VaultPage() { if (!accessToken) return; if (selected.size === 0) return; if (!confirm(`Delete ${selected.size} credential(s)?`)) return; + // Map each id to its location so the facade routes to the right store. + const byId = new Map(items.map((it) => [it.id, it.location])); try { await Promise.all( - [...selected].map((id) => api.deleteItem(accessToken, id)), + [...selected].map((id) => { + const loc = byId.get(id); + if (!loc) return Promise.resolve(); + return deleteStored(accessToken, id, loc); + }), ); + const count = selected.size; setSelected(new Set()); await loadItems(); - showToast(`${selected.size} credential(s) deleted`); + showToast(`${count} credential(s) deleted`); } catch (e) { setError(e instanceof Error ? e.message : "Failed to delete"); } diff --git a/packages/web/src/pages/vault/AddCredentialForm.tsx b/packages/web/src/pages/vault/AddCredentialForm.tsx index 42c3fbb..161580b 100644 --- a/packages/web/src/pages/vault/AddCredentialForm.tsx +++ b/packages/web/src/pages/vault/AddCredentialForm.tsx @@ -3,6 +3,7 @@ import { useEffect, useState } from "react"; import type { Protocol, VaultLoginPlaintext } from "@passman/core"; import { defaultPort, inferProtocolFromPort } from "../../connect/index.js"; +import type { StorageLocation } from "../../storage/index.js"; interface DraftState { name: string; @@ -50,12 +51,13 @@ const PROTOCOL_OPTIONS: { value: Protocol | ""; label: string }[] = [ ]; interface Props { - onSubmit: (item: VaultLoginPlaintext) => Promise; + onSubmit: (item: VaultLoginPlaintext, location: StorageLocation) => Promise; onCancel: () => void; } export function AddCredentialForm({ onSubmit, onCancel }: Props) { const [draft, setDraft] = useState(EMPTY_DRAFT); + const [storeLocally, setStoreLocally] = useState(false); const [busy, setBusy] = useState(false); const [err, setErr] = useState(null); @@ -96,8 +98,9 @@ export function AddCredentialForm({ onSubmit, onCancel }: Props) { ...(draft.environment ? { environment: draft.environment } : {}), ...(draft.url ? { url: draft.url } : {}), }; - await onSubmit(cleaned); + await onSubmit(cleaned, storeLocally ? "local" : "server"); setDraft(EMPTY_DRAFT); + setStoreLocally(false); } catch (e) { setErr(e instanceof Error ? e.message : "Failed to add credential"); } finally { @@ -239,6 +242,26 @@ export function AddCredentialForm({ onSubmit, onCancel }: Props) {
+ + {err &&

{err}

}
diff --git a/packages/web/src/pages/vault/CredentialsGrid.tsx b/packages/web/src/pages/vault/CredentialsGrid.tsx index 2ea88a2..88b60e3 100644 --- a/packages/web/src/pages/vault/CredentialsGrid.tsx +++ b/packages/web/src/pages/vault/CredentialsGrid.tsx @@ -140,8 +140,16 @@ function Row({ {engineCode(protocol)}
-
- {p.name} +
+ {p.name} + {item.location === "local" && ( + + Device + + )}
{p.url && ( diff --git a/packages/web/src/pages/vault/types.ts b/packages/web/src/pages/vault/types.ts index 616cddd..5b50a0d 100644 --- a/packages/web/src/pages/vault/types.ts +++ b/packages/web/src/pages/vault/types.ts @@ -1,8 +1,10 @@ import type { VaultLoginPlaintext } from "@passman/core"; -import type { VaultItem } from "../../api/client.js"; +import type { LocatedItem, StorageLocation } from "../../storage/index.js"; -export interface DecryptedItem extends VaultItem { +export type { StorageLocation }; + +export interface DecryptedItem extends LocatedItem { plaintext: VaultLoginPlaintext; } diff --git a/packages/web/src/storage/index.ts b/packages/web/src/storage/index.ts new file mode 100644 index 0000000..700afbc --- /dev/null +++ b/packages/web/src/storage/index.ts @@ -0,0 +1,9 @@ +export { createItem, deleteItem, listAll, type LocatedItem, type StorageLocation } from "./unified.js"; +export { + clearLocalStore, + countLocalItems, + createLocalItem, + deleteLocalItem, + listLocalItems, + type LocalItemRecord, +} from "./local.js"; diff --git a/packages/web/src/storage/local.ts b/packages/web/src/storage/local.ts new file mode 100644 index 0000000..c2a5fdd --- /dev/null +++ b/packages/web/src/storage/local.ts @@ -0,0 +1,162 @@ +/** + * IndexedDB-backed store for credentials the user opted to keep on-device. + * + * Important properties: + * - The blob persisted here is the SAME ciphertext shape that the server + * would store — we encrypt with the same vault key. Locking the vault + * makes both server-fetched and local-only items unreadable. + * - Records are addressable by a UUIDv4 generated client-side (so callers + * don't need a round-trip to mint an id). + * - The store is keyed per vault by `email` (lowercased). Two accounts + * sharing a browser see disjoint local items. + * - Failures are returned as rejected promises with descriptive errors — + * callers never silently lose a write. + * + * Persistence caveat the UI must surface to users: + * IndexedDB is wiped if the user clears site data, "Reset to defaults", + * or the OS user profile is deleted. There is no off-device backup. + */ + +const DB_NAME = "passman-local-vault"; +const DB_VERSION = 1; +const STORE = "items"; + +/** A row as persisted in IndexedDB. */ +export interface LocalItemRecord { + id: string; + /** Lowercased email of the vault owner — segregates rows per account. */ + vault: string; + /** Item type, currently always "login" for parity with the server schema. */ + item_type: string; + /** Same ciphertext envelope the server would store. */ + encrypted_data: string; + /** ISO-8601 timestamp set on creation. */ + created_at: string; + /** ISO-8601 timestamp updated on each write. */ + updated_at: string; +} + +function open(): Promise { + return new Promise((resolve, reject) => { + const req = indexedDB.open(DB_NAME, DB_VERSION); + req.onupgradeneeded = () => { + const db = req.result; + if (!db.objectStoreNames.contains(STORE)) { + const store = db.createObjectStore(STORE, { keyPath: "id" }); + // Index on `vault` so we can list per-account in O(matching rows). + store.createIndex("by_vault", "vault", { unique: false }); + } + }; + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error ?? new Error("IndexedDB open failed")); + req.onblocked = () => + reject(new Error("IndexedDB open blocked — close other Passman tabs")); + }); +} + +function tx( + mode: IDBTransactionMode, + fn: (store: IDBObjectStore) => Promise | T, +): Promise { + return open().then( + (db) => + new Promise((resolve, reject) => { + const transaction = db.transaction(STORE, mode); + const store = transaction.objectStore(STORE); + let result: T | undefined; + Promise.resolve(fn(store)) + .then((v) => { + result = v; + }) + .catch((e) => reject(e)); + transaction.oncomplete = () => { + db.close(); + resolve(result as T); + }; + transaction.onabort = () => { + db.close(); + reject(transaction.error ?? new Error("Local store transaction aborted")); + }; + transaction.onerror = () => { + db.close(); + reject(transaction.error ?? new Error("Local store transaction errored")); + }; + }), + ); +} + +/** Generate a UUIDv4. Uses crypto.randomUUID where available. */ +function newId(): string { + if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { + return crypto.randomUUID(); + } + // Fallback for very old contexts — RFC 4122 §4.4 random UUIDv4. + const bytes = crypto.getRandomValues(new Uint8Array(16)); + bytes[6] = (bytes[6]! & 0x0f) | 0x40; + bytes[8] = (bytes[8]! & 0x3f) | 0x80; + const hex = [...bytes].map((b) => b.toString(16).padStart(2, "0")).join(""); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; +} + +function reqAsPromise(req: IDBRequest): Promise { + return new Promise((resolve, reject) => { + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); +} + +export interface CreateLocalParams { + vault: string; + item_type: string; + encrypted_data: string; +} + +/** Persist a new ciphertext blob; returns the full record including id + timestamps. */ +export async function createLocalItem( + params: CreateLocalParams, +): Promise { + const now = new Date().toISOString(); + const record: LocalItemRecord = { + id: newId(), + vault: params.vault.toLowerCase(), + item_type: params.item_type, + encrypted_data: params.encrypted_data, + created_at: now, + updated_at: now, + }; + await tx("readwrite", (store) => reqAsPromise(store.add(record))); + return record; +} + +/** Return every record for the given vault email, oldest first. */ +export async function listLocalItems(vault: string): Promise { + return tx("readonly", (store) => { + const idx = store.index("by_vault"); + return reqAsPromise(idx.getAll(vault.toLowerCase())); + }).then((rows) => + rows.sort((a, b) => a.created_at.localeCompare(b.created_at)), + ); +} + +/** Delete by id. Returns true if a record was removed, false if it didn't exist. */ +export async function deleteLocalItem(id: string): Promise { + return tx("readwrite", async (store) => { + const existing = await reqAsPromise(store.get(id)); + if (!existing) return false; + await reqAsPromise(store.delete(id)); + return true; + }); +} + +/** Wipe every record in every vault — used by tests and "Forget all local data" flows. */ +export async function clearLocalStore(): Promise { + await tx("readwrite", (store) => reqAsPromise(store.clear())); +} + +/** Count records belonging to a vault — cheap, used to gate UI hints. */ +export async function countLocalItems(vault: string): Promise { + return tx("readonly", (store) => { + const idx = store.index("by_vault"); + return reqAsPromise(idx.count(vault.toLowerCase())); + }); +} diff --git a/packages/web/src/storage/unified.ts b/packages/web/src/storage/unified.ts new file mode 100644 index 0000000..ac4c13b --- /dev/null +++ b/packages/web/src/storage/unified.ts @@ -0,0 +1,110 @@ +/** + * Storage facade — fan-out over the server vault and the local IndexedDB + * store. The rest of the app talks to this module instead of the API + * client + local store directly, so individual call sites don't have to + * branch on storage location. + * + * Items returned from `listAll` carry a `location` tag set by THIS layer + * — that tag is metadata about *where the ciphertext came from*, NOT a + * field inside the encrypted plaintext (so existing items don't need + * migration). + */ + +import { type VaultItem, api } from "../api/client.js"; +import { + createLocalItem, + deleteLocalItem, + listLocalItems, + type LocalItemRecord, +} from "./local.js"; + +export type StorageLocation = "server" | "local"; + +/** A `VaultItem` plus the storage tag added by the facade. */ +export interface LocatedItem extends VaultItem { + location: StorageLocation; +} + +/** + * Fetch every item the user has access to, both from the server and the + * local IndexedDB store, tagged with `location`. Local items keep the same + * shape as server items — same id, same encrypted_data envelope — they + * differ only in the tag. + * + * Errors from the server are surfaced; local-store failures fall back to + * an empty list with a warning logged. Rationale: a corrupt local store + * shouldn't block the user from seeing their server-stored credentials. + */ +export async function listAll( + accessToken: string, + vault: string, +): Promise { + const [serverItems, localItems] = await Promise.all([ + api + .listItems(accessToken) + .then((r) => + r.items.map((it) => ({ ...it, location: "server" })), + ), + listLocalItems(vault) + .then((rows) => rows.map(localToVaultItem)) + .catch((e) => { + // eslint-disable-next-line no-console + console.warn("[passman] local store unreadable:", e); + return [] as LocatedItem[]; + }), + ]); + return [...serverItems, ...localItems]; +} + +function localToVaultItem(row: LocalItemRecord): LocatedItem { + return { + id: row.id, + item_type: row.item_type, + encrypted_data: row.encrypted_data, + created_at: row.created_at, + updated_at: row.updated_at, + location: "local", + }; +} + +export interface CreateParams { + accessToken: string; + vault: string; + item_type: string; + encrypted_data: string; + location: StorageLocation; +} + +/** Create a new item in the chosen store. Returns the persisted record. */ +export async function createItem(params: CreateParams): Promise { + if (params.location === "local") { + const row = await createLocalItem({ + vault: params.vault, + item_type: params.item_type, + encrypted_data: params.encrypted_data, + }); + return localToVaultItem(row); + } + const created = await api.createItem(params.accessToken, { + item_type: params.item_type, + encrypted_data: params.encrypted_data, + }); + return { ...created, location: "server" }; +} + +/** + * Delete an item from the store it lives in. The caller is expected to + * pass the location tag they got from `listAll` — we don't go fishing + * across stores by id, since server and local id-spaces don't overlap. + */ +export async function deleteItem( + accessToken: string, + id: string, + location: StorageLocation, +): Promise { + if (location === "local") { + await deleteLocalItem(id); + return; + } + await api.deleteItem(accessToken, id); +} diff --git a/packages/web/src/styles.css b/packages/web/src/styles.css index 17db700..442e7bf 100644 --- a/packages/web/src/styles.css +++ b/packages/web/src/styles.css @@ -738,12 +738,19 @@ main.vault-main { min-width: 0; line-height: 1.25; } +.name-cell .name-row { + display: flex; + align-items: center; + gap: 0.45rem; + min-width: 0; +} .name-cell .name { font-weight: 600; font-size: 0.86rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + min-width: 0; } .name-cell .url { display: block; @@ -894,6 +901,91 @@ main.vault-main { font-family: inherit; } +/* Storage-location toggle in the Add form */ +.storage-toggle { + display: grid; + grid-template-columns: auto 1fr; + gap: 0.65rem; + align-items: flex-start; + padding: 0.65rem 0.85rem; + border: 1px solid var(--line); + border-radius: 8px; + background: rgba(255, 255, 255, 0.012); + margin-top: 0.6rem; + cursor: pointer; + font-size: 0.82rem; + color: var(--fg); + flex-direction: row; + transition: border-color 0.12s ease, background 0.12s ease; +} +.storage-toggle:hover { border-color: var(--line-strong); } +.storage-toggle.on { + border-color: var(--brand-line); + background: var(--brand-soft); +} +.storage-toggle input[type="checkbox"] { + appearance: none; + -webkit-appearance: none; + width: 16px; + height: 16px; + margin: 0.2rem 0 0 0; + border: 1px solid var(--line-strong); + border-radius: 4px; + background: var(--bg-deep); + cursor: pointer; + display: grid; + place-items: center; + padding: 0; + flex-shrink: 0; +} +.storage-toggle input[type="checkbox"]:checked { + background: var(--brand); + border-color: var(--brand); +} +.storage-toggle input[type="checkbox"]:checked::after { + content: "✓"; + color: #062a18; + font-size: 0.7rem; + font-weight: 700; +} +.storage-toggle-body { + display: flex; + flex-direction: column; + gap: 0.2rem; + min-width: 0; +} +.storage-toggle-title { + font-weight: 600; + font-size: 0.85rem; +} +.storage-toggle-hint { + color: var(--muted); + font-size: 0.74rem; + line-height: 1.4; +} +.storage-toggle-hint strong { + color: var(--fg-2); + font-weight: 500; +} + +/* Per-row "Device" badge in the grid Name cell. Sized to slot inside the + flex `.name-row`; flex-shrink:0 prevents it from being squeezed by the + ellipsis-truncated name span. */ +.storage-badge { + display: inline-block; + padding: 0.05rem 0.35rem; + border-radius: 3px; + font-size: 0.62rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--brand); + background: var(--brand-soft); + border: 1px solid var(--brand-line); + font-family: var(--mono); + flex-shrink: 0; +} + /* ============================================================================= Connect dialog (modal overlay) ============================================================================ */ diff --git a/packages/web/tests/local-storage.test.ts b/packages/web/tests/local-storage.test.ts new file mode 100644 index 0000000..9120283 --- /dev/null +++ b/packages/web/tests/local-storage.test.ts @@ -0,0 +1,143 @@ +/** + * Tests for the IndexedDB-backed local store. + * + * `fake-indexeddb/auto` polyfills `globalThis.indexedDB` and `IDBKeyRange` + * with an in-memory implementation that matches the real spec, so the + * production `local.ts` runs unmodified under Node + vitest. + */ +import "fake-indexeddb/auto"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { + clearLocalStore, + countLocalItems, + createLocalItem, + deleteLocalItem, + listLocalItems, +} from "../src/storage/local.js"; + +describe("local IndexedDB store", () => { + beforeEach(async () => { + await clearLocalStore(); + }); + afterEach(async () => { + await clearLocalStore(); + }); + + it("round-trips a single item", async () => { + const created = await createLocalItem({ + vault: "alice@example.az", + item_type: "login", + encrypted_data: "v1:iv:ciphertext", + }); + expect(created.id).toMatch(/^[0-9a-f-]{36}$/i); + expect(created.vault).toBe("alice@example.az"); + expect(created.created_at).toMatch(/^\d{4}-\d{2}-\d{2}T/); + + const list = await listLocalItems("alice@example.az"); + expect(list).toHaveLength(1); + expect(list[0]!.id).toBe(created.id); + expect(list[0]!.encrypted_data).toBe("v1:iv:ciphertext"); + }); + + it("lowercases the vault key so case differences don't fork the store", async () => { + await createLocalItem({ + vault: "Alice@Example.AZ", + item_type: "login", + encrypted_data: "blob", + }); + const list = await listLocalItems("alice@example.az"); + expect(list).toHaveLength(1); + }); + + it("isolates items between vaults", async () => { + await createLocalItem({ + vault: "alice@example.az", + item_type: "login", + encrypted_data: "alice-blob", + }); + await createLocalItem({ + vault: "bob@example.az", + item_type: "login", + encrypted_data: "bob-blob", + }); + const aliceItems = await listLocalItems("alice@example.az"); + const bobItems = await listLocalItems("bob@example.az"); + expect(aliceItems.map((i) => i.encrypted_data)).toEqual(["alice-blob"]); + expect(bobItems.map((i) => i.encrypted_data)).toEqual(["bob-blob"]); + }); + + it("returns the records sorted by creation time, oldest first", async () => { + const a = await createLocalItem({ + vault: "v", + item_type: "login", + encrypted_data: "a", + }); + // Force a millisecond gap so ISO timestamps differ. + await new Promise((r) => setTimeout(r, 5)); + const b = await createLocalItem({ + vault: "v", + item_type: "login", + encrypted_data: "b", + }); + const list = await listLocalItems("v"); + expect(list.map((i) => i.id)).toEqual([a.id, b.id]); + }); + + it("deletes by id and reports whether anything was removed", async () => { + const item = await createLocalItem({ + vault: "v", + item_type: "login", + encrypted_data: "blob", + }); + expect(await deleteLocalItem(item.id)).toBe(true); + expect(await deleteLocalItem(item.id)).toBe(false); + expect(await listLocalItems("v")).toHaveLength(0); + }); + + it("counts items per vault", async () => { + await createLocalItem({ vault: "v", item_type: "login", encrypted_data: "a" }); + await createLocalItem({ vault: "v", item_type: "login", encrypted_data: "b" }); + await createLocalItem({ vault: "other", item_type: "login", encrypted_data: "c" }); + expect(await countLocalItems("v")).toBe(2); + expect(await countLocalItems("other")).toBe(1); + expect(await countLocalItems("nobody")).toBe(0); + }); + + it("never persists the plaintext password — only the encrypted_data envelope", async () => { + // Sanity check: the store accepts whatever ciphertext blob the caller + // provides and never tries to inspect or split it. The blob shape + // matches what the server endpoint receives. + const created = await createLocalItem({ + vault: "v", + item_type: "login", + encrypted_data: "v1:nonce:ciphertext-with-tag", + }); + expect(Object.keys(created).sort()).toEqual([ + "created_at", + "encrypted_data", + "id", + "item_type", + "updated_at", + "vault", + ]); + }); + + it("accepts a vault that contains an empty string but not records that share an id", async () => { + const a = await createLocalItem({ + vault: "v", + item_type: "login", + encrypted_data: "a", + }); + // Re-creating with the same id would violate the uniqueness constraint; + // there's no public API to set the id, but verifying that the schema is + // correctly keyed on `id` keeps this contract from regressing silently. + expect(a.id).toBeTruthy(); + const second = await createLocalItem({ + vault: "v", + item_type: "login", + encrypted_data: "b", + }); + expect(second.id).not.toBe(a.id); + }); +});