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 });