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
+
+
+
+ Store on this device only
+
+ Encrypted ciphertext stays in this browser's IndexedDB · never sent to the server.
+ No cross-device sync · cleared if you clear site data ·
+ single point of failure if this disk dies. Use for credentials you never
+ want stored remotely, even encrypted.
+
+
+
+
Save
Cancel
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) {
+
+ setStoreLocally(e.target.checked)}
+ />
+
+
+ Store on this device only
+
+
+ Encrypted ciphertext stays in this browser's IndexedDB · never sent to the server.
+
+ No cross-device sync · cleared if you clear site data ·
+ single point of failure if this disk dies. Use for credentials you never
+ want stored remotely, even encrypted.
+
+
+
+
{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);
+ });
+});