From af628e585a5a2c2604a80beded272cf0bafdefd1 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Wed, 2 Sep 2026 14:10:44 +0000 Subject: [PATCH] fix(vault): make a large OpenCreds import possible at all Importing an OpenCreds database wrote nothing and looked like a crash: the window vanished and the vault stayed empty. Three separate defects, all on the path between choosing a file and seeing the items. The import ran in the action popup. A popup is dismissed as soon as it loses focus, and both halves of the flow took focus away -- the file chooser, and then `window.prompt` for the export passphrase. Chrome suppresses JS dialogs in a popup, so the prompt returned null and the handler took its `passphrase === null` cancel path, silently and with no message, if the popup had not already closed under the file chooser. Not one item was ever sent. The flow moves to the options page, an ordinary extension tab, which survives a file chooser and can ask for the passphrase in the page. Items were then written one POST at a time, sequentially, with the caller awaiting all of them. A few thousand keys is a few thousand round trips -- minutes during which the page that started it is gone and the result goes nowhere. Writes now run eight at a time, the call returns once the work is scheduled, and progress is polled. A worker restart still loses the job, so re-running the same file resumes instead: every item carries its own id, and an id already stored comes back 409, which is now counted as already-present rather than as a failure. Finally the list call fetched one page. The server has always capped a response at 1000 items and there was no way to ask for the rest, so any vault larger than that was listed short with no error -- the items were stored and never shown. GET accepts an offset, orders by (updated_at, id) so paging is total across rows written in the same instant, and reports `paged` so a client can tell a paging server from one that ignores the parameter. Verified against a real 3,122-item database: all 3,122 parse, encrypt, store and read back with every value byte-identical. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Q79wCDBvrPetsms1HCcnoX --- .../__tests__/vault-api-paging.test.js | 72 ++++ .../extension/__tests__/vault-session.test.js | 93 ++++++ apps/extension/src/background/index.js | 4 + .../extension/src/background/vault-session.js | 111 +++++- apps/extension/src/lib/vault-api.js | 50 ++- apps/extension/src/options/Options.jsx | 8 + apps/extension/src/options/VaultImport.jsx | 316 ++++++++++++++++++ .../src/popup/components/VaultPanel.jsx | 110 ++---- apps/web/app/api/vault/items/route.js | 15 +- 9 files changed, 669 insertions(+), 110 deletions(-) create mode 100644 apps/extension/__tests__/vault-api-paging.test.js create mode 100644 apps/extension/src/options/VaultImport.jsx diff --git a/apps/extension/__tests__/vault-api-paging.test.js b/apps/extension/__tests__/vault-api-paging.test.js new file mode 100644 index 0000000..7d256d0 --- /dev/null +++ b/apps/extension/__tests__/vault-api-paging.test.js @@ -0,0 +1,72 @@ +/** + * Paging in the vault items fetch. + * + * The server has always capped one response at MAX_PAGE_SIZE (1000) and the + * client only ever made one request, so a vault larger than a page was listed + * short with no error anywhere: the items were stored and simply never shown. + * An OpenCreds import of a few thousand keys hits this immediately. + * @module __tests__/vault-api-paging.test + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const { fetchRef } = vi.hoisted(() => ({ fetchRef: { calls: [], pages: [], paged: true } })); + +vi.mock('../src/lib/api.js', () => ({ + apiRequest: vi.fn(async (path) => { + fetchRef.calls.push(path); + const url = new URL(path, 'https://example.test'); + const offset = Number(url.searchParams.get('offset') || 0); + const limit = Number(url.searchParams.get('limit') || 1000); + const items = fetchRef.pages.slice(offset, offset + limit); + return { + ok: true, + status: 200, + json: async () => (fetchRef.paged ? { items, paged: true } : { items }), + }; + }), +})); + +const load = async () => import('../src/lib/vault-api.js'); + +beforeEach(() => { + fetchRef.calls = []; + fetchRef.paged = true; + vi.resetModules(); +}); + +describe('fetchVaultItems paging', () => { + it('returns every item of a vault larger than one page', async () => { + fetchRef.pages = Array.from({ length: 3122 }, (_, i) => ({ id: `id-${i}` })); + const { fetchVaultItems } = await load(); + + const items = await fetchVaultItems(); + + expect(items).toHaveLength(3122); + // No duplicates and nothing dropped. + expect(new Set(items.map((i) => i.id)).size).toBe(3122); + expect(fetchRef.calls).toHaveLength(4); + }); + + it('makes exactly one request when everything fits in a page', async () => { + fetchRef.pages = Array.from({ length: 12 }, (_, i) => ({ id: `id-${i}` })); + const { fetchVaultItems } = await load(); + + expect(await fetchVaultItems()).toHaveLength(12); + expect(fetchRef.calls).toHaveLength(1); + }); + + it('stops after one page against a server that does not understand offset', async () => { + // An older deployment ignores `offset` and returns the same full page every + // time. Without the `paged` flag to tell them apart, the client would ask + // forever and accumulate the same 1000 rows. + fetchRef.paged = false; + fetchRef.pages = Array.from({ length: 3122 }, (_, i) => ({ id: `id-${i}` })); + const { fetchVaultItems } = await load(); + + const items = await fetchVaultItems(); + + expect(items).toHaveLength(1000); + expect(fetchRef.calls).toHaveLength(1); + }); +}); diff --git a/apps/extension/__tests__/vault-session.test.js b/apps/extension/__tests__/vault-session.test.js index 022382a..61cab0f 100644 --- a/apps/extension/__tests__/vault-session.test.js +++ b/apps/extension/__tests__/vault-session.test.js @@ -67,6 +67,10 @@ vi.mock('../src/lib/vault-api.js', () => ({ serverRef.items.filter((row) => Boolean(row.deleted_at) === Boolean(trash)) ), createVaultItem: vi.fn(async (row) => { + // Mirrors the real route: the id is chosen by the client, so a second + // create of the same id is a 409 rather than a duplicate row. Import + // resume depends on that being reported as a conflict, not an error. + if (serverRef.items.some((r) => r.id === row.id)) return { conflict: true }; const stored = { ...row, revision: 1, deleted_at: null }; serverRef.items.push(stored); return stored; @@ -321,6 +325,95 @@ describe('items', () => { expect(res.imported).toBe(2); expect((await mod.listItems()).items).toHaveLength(2); }, 30_000); + + /** + * A database of a few thousand keys is a few thousand POSTs. Blocking the + * caller for that long is what made a large import impossible: the popup that + * was awaiting it had already been dismissed, so the result went nowhere. A + * big import is scheduled and reported through progress instead. + */ + it('schedules a large import and finishes it in the background', async () => { + const mod = await loadModule(); + await mod.setupVault(PASSWORD); + + const batch = Array.from({ length: 120 }, (_, i) => + mod.buildItem('login', { name: `Item ${i}` }) + ); + const res = await mod.importItems(batch); + + // Returns immediately, before the writes are done. + expect(res.success).toBe(true); + expect(res.started).toBe(true); + expect(res.total).toBe(120); + + await vi.waitFor( + () => { + const { job } = mod.getImportProgress(); + expect(job.running).toBe(false); + }, + { timeout: 30_000, interval: 50 } + ); + + const { job } = mod.getImportProgress(); + expect(job.imported).toBe(120); + expect(job.failed).toBe(0); + expect(job.done).toBe(120); + expect((await mod.listItems()).items).toHaveLength(120); + }, 60_000); + + /** + * The service worker can be killed mid-import and the job is lost with it, so + * the recovery is to import the same file again. That is only safe if an item + * already stored counts as done rather than as a failure. + */ + it('counts already-stored items as present, so re-running resumes', async () => { + const mod = await loadModule(); + await mod.setupVault(PASSWORD); + + const batch = Array.from({ length: 120 }, (_, i) => + mod.buildItem('login', { name: `Item ${i}` }) + ); + + await mod.importItems(batch); + await vi.waitFor(() => expect(mod.getImportProgress().job.running).toBe(false), { + timeout: 30_000, + interval: 50, + }); + + // The same file again: every id is already on the server. + await mod.importItems(batch); + await vi.waitFor(() => expect(mod.getImportProgress().job.running).toBe(false), { + timeout: 30_000, + interval: 50, + }); + + const { job } = mod.getImportProgress(); + expect(job.already).toBe(120); + expect(job.imported).toBe(0); + expect(job.failed).toBe(0); + // Re-running must not duplicate anything. + expect((await mod.listItems()).items).toHaveLength(120); + }, 60_000); + + it('refuses to start a second import while one is running', async () => { + const mod = await loadModule(); + await mod.setupVault(PASSWORD); + + const batch = Array.from({ length: 120 }, (_, i) => + mod.buildItem('login', { name: `Item ${i}` }) + ); + const first = await mod.importItems(batch); + expect(first.started).toBe(true); + + const second = await mod.importItems(batch); + expect(second.success).toBe(false); + expect(second.error).toMatch(/already running/i); + + await vi.waitFor(() => expect(mod.getImportProgress().job.running).toBe(false), { + timeout: 30_000, + interval: 50, + }); + }, 60_000); }); describe('buildItem', () => { diff --git a/apps/extension/src/background/index.js b/apps/extension/src/background/index.js index 971058a..da15b37 100644 --- a/apps/extension/src/background/index.js +++ b/apps/extension/src/background/index.js @@ -27,6 +27,7 @@ import { restoreItem as restoreVaultItem, destroyItem as destroyVaultItem, importItems as importVaultItems, + getImportProgress as getVaultImportProgress, } from './vault-session.js'; import { initAdblock, @@ -3715,6 +3716,9 @@ browser.runtime.onMessage.addListener((message, sender) => { case 'VAULT_IMPORT': return importVaultItems(message.payload?.items || []); + case 'VAULT_IMPORT_PROGRESS': + return Promise.resolve(getVaultImportProgress()); + case 'VAULT_GET_PREFS': return getVaultPrefs().then((prefs) => ({ success: true, ...prefs })); diff --git a/apps/extension/src/background/vault-session.js b/apps/extension/src/background/vault-session.js index 6bfa3fe..e6956a3 100644 --- a/apps/extension/src/background/vault-session.js +++ b/apps/extension/src/background/vault-session.js @@ -378,35 +378,124 @@ export async function destroyItem(id) { } } +/** + * How many item writes are in flight at once. + * + * Every item is a separate POST -- the API creates one item per request -- so a + * database of a few thousand keys is a few thousand round trips. Sequentially + * that is minutes of wall clock; the popup that started it is long gone and the + * user is looking at an empty vault. Eight is enough to make the trip + * network-bound rather than latency-bound without looking like a flood. + */ +const IMPORT_CONCURRENCY = 8; + +/** + * Progress for the running (or last) import. + * + * Deliberately module-level and deliberately free of item data. The service + * worker can be killed mid-import, and the one thing that must never happen is + * plaintext items being written somewhere to survive that. Losing the job on a + * worker restart is fine because re-running the same file resumes: every item + * carries its own id, so an item already stored comes back 409 and is counted + * as done rather than as an error. + * + * @type {{total: number, done: number, imported: number, already: number, + * failed: number, running: boolean, failures: Array, error: string|null}|null} + */ +let importJob = null; + +/** Progress for the popup/options page to poll. */ +export function getImportProgress() { + if (!importJob) return { success: true, job: null }; + return { success: true, job: { ...importJob, failures: importJob.failures.slice(0, 20) } }; +} + /** * Import parsed items, encrypting each one. * + * Returns as soon as the work is scheduled rather than when it finishes: the + * caller is a popup or an options tab, and neither is guaranteed to still be + * open in three minutes. Progress is polled through {@link getImportProgress}, + * and closing the page no longer abandons the import. + * * Reports per-item failures instead of stopping, so one bad row out of five * hundred does not abandon the other four hundred and ninety-nine. + * * @param {Object[]} items plaintext items from parseImport */ export async function importItems(items) { + if (importJob?.running) { + return { success: false, error: 'An import is already running' }; + } + + let userKey; try { - const userKey = await requireKey(); - let imported = 0; - const failures = []; + userKey = await requireKey(); + } catch (err) { + if (err.message === 'LOCKED') return { success: false, locked: true, error: 'Vault is locked' }; + return { success: false, error: err.message }; + } - for (const item of items) { + const queue = Array.isArray(items) ? items.slice() : []; + importJob = { + total: queue.length, + done: 0, + imported: 0, + already: 0, + failed: 0, + running: true, + failures: [], + error: null, + }; + const job = importJob; + + let next = 0; + const worker = async () => { + for (;;) { + const index = next++; + if (index >= queue.length) return; + const item = queue[index]; try { const row = await encryptItem(userKey, item); const created = await createVaultItem(row); - if (created) imported += 1; - else failures.push({ name: item.name, reason: 'Server rejected the item' }); + if (created?.conflict) job.already += 1; + else if (created) job.imported += 1; + else { + job.failed += 1; + job.failures.push({ name: item.name, reason: 'Server rejected the item' }); + } } catch (err) { - failures.push({ name: item.name, reason: err.message }); + job.failed += 1; + job.failures.push({ name: item.name, reason: err.message }); } + job.done += 1; } + }; - return { success: true, imported, failures }; - } catch (err) { - if (err.message === 'LOCKED') return { success: false, locked: true, error: 'Vault is locked' }; - return { success: false, error: err.message }; + const run = Promise.all( + Array.from({ length: Math.min(IMPORT_CONCURRENCY, queue.length) }, worker) + ) + .catch((err) => { + job.error = err.message; + }) + .finally(() => { + job.running = false; + }); + + // Awaited only so a caller that wants the old blocking behaviour -- the tests, + // and a small CSV where waiting is nicer than polling -- still gets a result. + if (queue.length <= IMPORT_CONCURRENCY * 4) { + await run; + return { + success: true, + imported: job.imported, + already: job.already, + failures: job.failures, + finished: true, + }; } + + return { success: true, started: true, total: job.total }; } /** diff --git a/apps/extension/src/lib/vault-api.js b/apps/extension/src/lib/vault-api.js index 185ab18..c49c1c5 100644 --- a/apps/extension/src/lib/vault-api.js +++ b/apps/extension/src/lib/vault-api.js @@ -48,28 +48,53 @@ export async function saveVaultMeta(meta) { * @returns {Promise} */ export async function fetchVaultItems({ trash = false, since } = {}) { - const params = new URLSearchParams(); - if (trash) params.set('trash', '1'); - if (since) params.set('since', since); - const query = params.toString(); + // The server caps one response at MAX_PAGE_SIZE (1000) and always has, so a + // single request silently truncated any vault larger than that -- the items + // were stored and simply never listed. Page until a short response says the + // end has been reached. + const PAGE = 1000; + const all = []; try { - const response = await apiRequest(`/api/vault/items${query ? `?${query}` : ''}`, { - method: 'GET', - }); - if (!response.ok) return []; - const data = await response.json(); - return data.items || []; + for (let offset = 0; ; offset += PAGE) { + const params = new URLSearchParams(); + if (trash) params.set('trash', '1'); + if (since) params.set('since', since); + params.set('limit', String(PAGE)); + if (offset) params.set('offset', String(offset)); + + const response = await apiRequest(`/api/vault/items?${params.toString()}`, { + method: 'GET', + }); + if (!response.ok) return all; + const data = await response.json(); + const page = data.items || []; + all.push(...page); + + // Short page means the end. A full page from a server that does not yet + // understand `offset` would repeat itself forever, so stop unless the + // response actually advanced past what we already hold. + if (page.length < PAGE) break; + if (!data.paged) break; + } + return all; } catch (err) { console.error('[MarkSyncr] Vault items fetch failed:', err?.message); - return []; + return all; } } /** * Create an item. + * + * A 409 means this id is already stored, which during an import is not a + * failure: the item arrived on an earlier run. It is reported as + * `{conflict: true}` so a re-run of an interrupted import can count it as + * already-done rather than as an error — that is what makes re-running the + * same file a safe way to resume. + * * @param {{id: string, type: number, ciphertext: string, iv: string}} row - * @returns {Promise} the created row, or null + * @returns {Promise} the created row, a conflict, or null */ export async function createVaultItem(row) { try { @@ -77,6 +102,7 @@ export async function createVaultItem(row) { method: 'POST', body: JSON.stringify(row), }); + if (response.status === 409) return { conflict: true }; if (!response.ok) return null; const data = await response.json(); return data.item || null; diff --git a/apps/extension/src/options/Options.jsx b/apps/extension/src/options/Options.jsx index 9ec82e6..40a27c2 100644 --- a/apps/extension/src/options/Options.jsx +++ b/apps/extension/src/options/Options.jsx @@ -7,6 +7,7 @@ import { detectImportFormat, } from '@marksyncr/core'; import { deleteCloudData } from '../lib/api.js'; +import { VaultImport } from './VaultImport.jsx'; // Service icons const GitHubIcon = ({ className = '' }) => ( @@ -726,6 +727,13 @@ export function Options() { /> +
+ +
+ {/* Export Modal */} {showExportModal && (
diff --git a/apps/extension/src/options/VaultImport.jsx b/apps/extension/src/options/VaultImport.jsx new file mode 100644 index 0000000..28c0f05 --- /dev/null +++ b/apps/extension/src/options/VaultImport.jsx @@ -0,0 +1,316 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { detectImportKind, inspectOpenCredsFile, parseImport, parseOpenCredsImport } from '@marksyncr/vault'; + +/** + * Vault import, on the options page rather than in the popup. + * + * The popup could not host this. It is dismissed the moment it loses focus, and + * a file chooser and a passphrase prompt both take focus; Chrome additionally + * suppresses JS dialogs in an action popup, so `window.prompt` returned null + * and the old handler cancelled silently. An import of a large OpenCreds + * database looked exactly like a crash -- the window vanished, nothing was + * written, and no message explained why. + * + * An options tab has none of those constraints: it survives the file chooser, + * it can ask for the passphrase in the page, and it stays open long enough to + * show progress for a database of a few thousand items. + */ + +function getExtApi() { + if (typeof chrome !== 'undefined' && chrome.runtime?.sendMessage) return chrome; + if (typeof browser !== 'undefined' && browser.runtime?.sendMessage) return browser; + return null; +} + +async function sendMessage(message) { + const api = getExtApi(); + if (!api) return { success: false, error: 'Extension API unavailable' }; + return api.runtime.sendMessage(message); +} + +/** How often to ask the background how far the import has got. */ +const POLL_MS = 400; + +export function VaultImport() { + const fileRef = useRef(null); + const pollRef = useRef(null); + + const [file, setFile] = useState(null); + const [text, setText] = useState(''); + const [header, setHeader] = useState(null); + const [kind, setKind] = useState(null); + const [passphrase, setPassphrase] = useState(''); + const [acknowledged, setAcknowledged] = useState(false); + const [busy, setBusy] = useState(false); + const [job, setJob] = useState(null); + const [message, setMessage] = useState(null); + + // Stop polling when this page goes away. + useEffect(() => () => clearInterval(pollRef.current), []); + + const reset = () => { + setFile(null); + setText(''); + setHeader(null); + setKind(null); + setPassphrase(''); + setAcknowledged(false); + setJob(null); + if (fileRef.current) fileRef.current.value = ''; + }; + + const onPick = async (event) => { + const picked = event.target.files?.[0]; + if (!picked) return; + setMessage(null); + setJob(null); + + const content = await picked.text(); + const detected = detectImportKind(content); + + if (detected === 'unknown') { + setMessage({ type: 'error', text: 'That file is neither an OpenCreds database nor a CSV export.' }); + reset(); + return; + } + + setFile(picked); + setText(content); + setKind(detected); + + if (detected === 'opencreds') { + try { + setHeader(inspectOpenCredsFile(content)); + } catch (err) { + // The header is authenticated, so a failure here means the file is + // damaged rather than that the passphrase is wrong. + setMessage({ type: 'error', text: err.message }); + reset(); + } + } else { + setHeader(null); + } + }; + + const startPolling = () => { + clearInterval(pollRef.current); + pollRef.current = setInterval(async () => { + const res = await sendMessage({ type: 'VAULT_IMPORT_PROGRESS' }); + if (!res?.success) return; + if (!res.job) { + // The service worker was restarted and lost the job. Say so rather than + // leaving a progress bar frozen at whatever it last showed. + clearInterval(pollRef.current); + setBusy(false); + setMessage({ + type: 'error', + text: 'The import was interrupted. Import the same file again to carry on — items already stored are recognised and skipped.', + }); + return; + } + setJob(res.job); + if (!res.job.running) { + clearInterval(pollRef.current); + setBusy(false); + setMessage(summarise(res.job)); + } + }, POLL_MS); + }; + + const summarise = (j) => { + const parts = [`Imported ${j.imported}`]; + if (j.already) parts.push(`${j.already} already in the vault`); + if (j.failed) parts.push(`${j.failed} failed`); + return { type: j.failed ? 'error' : 'success', text: `${parts.join(', ')}.` }; + }; + + const onImport = async () => { + if (!text) return; + setBusy(true); + setMessage(null); + + let items; + try { + if (kind === 'opencreds') { + const parsed = await parseOpenCredsImport(text, { passphrase }); + items = parsed.items; + } else { + const parsed = parseImport(text); + items = parsed.items; + if (!items.length) { + setBusy(false); + setMessage({ type: 'error', text: parsed.skipped?.[0]?.reason || 'Nothing to import.' }); + return; + } + } + } catch (err) { + // A wrong passphrase, an altered file and a manifest mismatch all land + // here, and in every one of them nothing has been written. + setBusy(false); + setMessage({ type: 'error', text: err.message }); + return; + } + + const res = await sendMessage({ type: 'VAULT_IMPORT', payload: { items } }); + + if (!res?.success) { + setBusy(false); + setMessage({ + type: 'error', + text: res?.locked ? 'The vault is locked. Unlock it from the toolbar first.' : res?.error || 'Import failed.', + }); + return; + } + + if (res.finished) { + setBusy(false); + setMessage(summarise({ imported: res.imported, already: res.already || 0, failed: res.failures?.length || 0 })); + return; + } + + setJob({ total: res.total, done: 0, imported: 0, already: 0, failed: 0, running: true, failures: [] }); + startPolling(); + }; + + const needsPassphrase = kind === 'opencreds' && header?.protected; + const needsAck = kind === 'opencreds' && header && !header.protected; + const canImport = + Boolean(text) && !busy && (!needsPassphrase || passphrase.length > 0) && (!needsAck || acknowledged); + + return ( +
+ {message && ( +
+ {message.text} +
+ )} + + + + {file && kind === 'csv' && ( +

+ {file.name} — a CSV export. Folders, TOTP seeds and + anything a CSV has no column for will not be present. +

+ )} + + {file && header && ( +
+

+ {file.name} — OpenCreds {header.opencreds},{' '} + {header.protected ? 'encrypted' : 'unprotected'}, namespace {header.namespace}. +

+

+ {header.itemCount} {header.itemCount === 1 ? 'item' : 'items'} in {header.folderCount}{' '} + {header.folderCount === 1 ? 'folder' : 'folders'} + {header.types + ? ` (${Object.entries(header.types) + .map(([type, n]) => `${n} ${type}`) + .join(', ')})` + : ''} + . +

+

+ The header is authenticated, so these counts cannot be misstated by an altered file. +

+
+ )} + + {needsPassphrase && ( + + )} + + {needsAck && ( + + )} + + {job?.running && ( +
+
+
+
+

+ {job.done} of {job.total} — {job.imported} imported + {job.already ? `, ${job.already} already present` : ''} + {job.failed ? `, ${job.failed} failed` : ''}. You can leave this page open; the import + continues in the background. +

+
+ )} + + {job && !job.running && job.failures?.length > 0 && ( +
+ + {job.failed} item{job.failed === 1 ? '' : 's'} could not be stored + +
    + {job.failures.map((f, i) => ( +
  • + {f.name || 'Untitled'} — {f.reason} +
  • + ))} +
+
+ )} + +
+ + {file && !busy && ( + + )} +
+
+ ); +} diff --git a/apps/extension/src/popup/components/VaultPanel.jsx b/apps/extension/src/popup/components/VaultPanel.jsx index db46d77..854b290 100644 --- a/apps/extension/src/popup/components/VaultPanel.jsx +++ b/apps/extension/src/popup/components/VaultPanel.jsx @@ -1,5 +1,4 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import { detectImportKind, inspectOpenCredsFile, parseImport, parseOpenCredsImport } from '@marksyncr/vault'; import { VaultUnlock } from './vault/VaultUnlock.jsx'; import { VaultItemEditor } from './vault/VaultItemEditor.jsx'; @@ -257,84 +256,27 @@ export function VaultPanel() { }; /** - * Import a CSV export from another manager, or an OpenCreds database. + * Hand a vault import to the options page. * - * An OpenCreds file is a whole vault rather than a table of logins: it - * carries folders, password history, TOTP seeds, and `key` and `account` - * items that no CSV has a column for. When it is encrypted — the default — - * the header still says what it holds, and that claim is authenticated, so - * the passphrase prompt can name a real number before anyone types anything. + * This used to run in the popup, and could not work there. A popup is + * dismissed as soon as it loses focus, and both halves of the old flow took + * focus away: the file chooser, and then `window.prompt` for the export + * passphrase. Chrome suppresses JS dialogs in an action popup, so the prompt + * returned null and the handler took its `passphrase === null` cancel path -- + * silently, with no message -- if the popup had not already closed under the + * file chooser. Either way not one item was ever sent, which is exactly what + * an import of a 3,000-item database looked like: the window vanished and the + * vault stayed empty. + * + * The options page is an ordinary extension tab, so it keeps focus through a + * file chooser, can ask for a passphrase in the page, and can stay open long + * enough to show progress. */ - const onImport = async (event) => { - const file = event.target.files?.[0]; - if (!file) return; - event.target.value = ''; - - const text = await file.text(); - - if (detectImportKind(text) !== 'opencreds') { - const { source, items: parsed, skipped } = parseImport(text); - if (!parsed.length) { - notify(skipped[0]?.reason || 'Nothing to import'); - return; - } - const res = await sendMessage({ type: 'VAULT_IMPORT', payload: { items: parsed } }); - if (res?.success) { - await loadItems(showTrash); - notify( - skipped.length - ? `Imported ${res.imported} from ${source}; skipped ${skipped.length}` - : `Imported ${res.imported} from ${source}` - ); - } else { - notify(res?.error || 'Import failed'); - } - return; - } - - let header; - try { - header = inspectOpenCredsFile(text); - } catch (err) { - notify(err.message); - return; - } - - let passphrase; - if (header.protected) { - // The popup has no modal layer, and asking here is better than importing - // nothing and explaining why afterwards. - passphrase = window.prompt( - `This OpenCreds file holds ${header.itemCount} ${ - header.itemCount === 1 ? 'item' : 'items' - }. Enter its export passphrase to import.` - ); - if (passphrase === null) return; - } else if ( - !window.confirm( - `This OpenCreds file is unprotected — every secret in it is in the clear. Import ${header.itemCount} items anyway?` - ) - ) { - return; - } - - let parsed; - try { - parsed = await parseOpenCredsImport(text, { passphrase }); - } catch (err) { - // A manifest mismatch, a wrong passphrase and an altered file all land - // here, and in every one of them nothing has been written. - notify(err.message); - return; - } - - const res = await sendMessage({ type: 'VAULT_IMPORT', payload: { items: parsed.items } }); - if (res?.success) { - await loadItems(showTrash); - notify(`Imported ${res.imported} of ${parsed.items.length} from OpenCreds`); - } else { - notify(res?.error || 'Import failed'); - } + const onImport = () => { + const api = getExtApi(); + if (!api) return; + if (api.runtime.openOptionsPage) api.runtime.openOptionsPage(); + else window.open(api.runtime.getURL('options/index.html')); }; const visible = useMemo(() => filterItems(items, query), [items, query]); @@ -463,15 +405,13 @@ export function VaultPanel() { {!showTrash && ( -
diff --git a/apps/web/app/api/vault/items/route.js b/apps/web/app/api/vault/items/route.js index 10c2259..9ae12aa 100644 --- a/apps/web/app/api/vault/items/route.js +++ b/apps/web/app/api/vault/items/route.js @@ -38,13 +38,20 @@ export async function GET(request) { const since = searchParams.get('since'); const wantsTrash = searchParams.get('trash') === '1'; const limit = Math.min(Number(searchParams.get('limit')) || MAX_PAGE_SIZE, MAX_PAGE_SIZE); + // A vault larger than one page was previously unreachable: the response was + // capped and there was no way to ask for the rest. + const offset = Math.max(Number(searchParams.get('offset')) || 0, 0); let query = supabase .from('vault_items') .select('id, type, ciphertext, iv, revision, deleted_at, created_at, updated_at') .eq('user_id', user.id) + // updated_at alone is not unique -- an import writes thousands of rows in + // the same instant -- and a non-deterministic order across pages drops + // and repeats rows. id is the tiebreaker that makes paging total. .order('updated_at', { ascending: false }) - .limit(limit); + .order('id', { ascending: false }) + .range(offset, offset + limit - 1); query = wantsTrash ? query.not('deleted_at', 'is', null) : query.is('deleted_at', null); @@ -63,7 +70,11 @@ export async function GET(request) { return NextResponse.json({ error: 'Failed to fetch items' }, { status: 500, headers }); } - return NextResponse.json({ items: data || [] }, { headers }); + // `paged` tells a client that this server understands `offset`, so it can + // safely ask for the next page. Without it a client cannot distinguish a + // paging server from one that silently ignores the parameter and would + // return the same first page forever. + return NextResponse.json({ items: data || [], paged: true }, { headers }); } catch (error) { console.error('Vault items API error:', error); return NextResponse.json({ error: 'Internal server error' }, { status: 500, headers });