diff --git a/README.md b/README.md
index a1df4f0..5240531 100644
--- a/README.md
+++ b/README.md
@@ -56,7 +56,7 @@ The Vite dev server usually listens on `http://localhost:5173`. Open the web URL
## Desktop Development
-The desktop app packages the same UI through Electron and enables local folder import. It scans audio metadata and SHA-256 content hashes locally, then sends metadata, file identity, paths and cover artifacts to the API. It does not upload audio files.
+The desktop app packages the same UI through Electron and enables local folder import. It scans audio metadata and SHA-256 content hashes locally, then sends metadata, file identity, paths and cover artifacts to the API. It does not upload audio files. The hosted desktop submission contract is documented in the sibling API repository at `cratebase-api/docs/imports/desktop-import-api-boundary.md`.
Run the API first, then start the desktop app in development mode:
@@ -88,8 +88,15 @@ Packaged builds default to `https://cratebase.example.com`, while development
builds default to `http://localhost:5094`. Set `CRATEBASE_API_BASE_URL` at
runtime when a desktop build must target another hosted origin.
+Private beta users sign in with issued credentials. The first bootstrap setup
+creates the first admin account and its default private collection.
+
The cross-repository compose and reverse proxy example lives in
`../cratebase-api/deploy`.
+Private beta data handling, hosted backup ownership, and release readiness are
+documented in
+`../cratebase-api/docs/private-beta/data-handling-and-trust.md` and
+`../cratebase-api/docs/private-beta/release-readiness.md`.
## Verification
@@ -108,17 +115,27 @@ npm run build
- Workspaces for artists, releases, tracks, owned items, labels, relations, playlists, imports, exports and settings.
- Server graph context in catalog detail panels, including credits, relations, media, collector signals and playlist backlinks.
- Persistent manual and smart playlists through `/api/playlists`.
-- Desktop local folder import with streaming SHA-256 hashes and duplicate review warnings.
+- Browser import review and desktop-only local folder scanning through the Electron preload bridge, with streaming SHA-256 hashes and duplicate review warnings.
- JSON and CSV export downloads in browser and desktop modes.
- JSON restore into an empty active collection.
+The desktop import API boundary is documented in
+`../cratebase-api/docs/imports/desktop-import-api-boundary.md`. The portable
+export v1 contract is documented in
+`../cratebase-api/docs/exports/portable-export-v1.md`. User-triggered JSON and
+CSV exports are portability tools and personal backups; hosted service backups
+are an operator-managed responsibility outside the export UI.
+Private beta data handling and release readiness are tracked in
+`../cratebase-api/docs/private-beta/data-handling-and-trust.md` and
+`../cratebase-api/docs/private-beta/release-readiness.md`.
+
See [docs/acceptance-checklist.md](docs/acceptance-checklist.md) for the shared acceptance path.
## Product Boundaries
- Smart playlist editing currently exposes simple rule text and preserves server rules when editing existing smart playlists.
- Manual playlist creation can persist ordered server catalog links; free-form draft links are kept client-side until linked to catalog IDs.
-- The browser app can review import sessions but cannot browse arbitrary local folders.
+- The browser app can review import sessions but cannot browse arbitrary local folders or show the desktop folder picker.
- There is no streaming player, social graph, marketplace, recommendation engine or external catalog integration.
- Cratebase Web is a working catalog, search and relation-navigation interface, not a public profile system or mobile-first app.
diff --git a/docs/acceptance-checklist.md b/docs/acceptance-checklist.md
index 65849e6..a97bdab 100644
--- a/docs/acceptance-checklist.md
+++ b/docs/acceptance-checklist.md
@@ -20,24 +20,29 @@ and `cratebase-web`.
- Confirm private beta desktop packages target `https://cratebase.example.com` by default, with `CRATEBASE_API_BASE_URL` available as a runtime override.
- Build the API and web Docker images, then run the example compose stack and verify `/health`, `/web-health`, web routing and authenticated `/api` calls through the reverse proxy.
- Verify staging and production do not share PostgreSQL databases, service storage, secrets, invite data or user accounts.
+- Review `cratebase-api/docs/private-beta/data-handling-and-trust.md` and `cratebase-api/docs/private-beta/release-readiness.md` before private beta evidence is collected.
## Acceptance Path
1. Bootstrap a clean database and create the first admin user.
-2. Create a release manually with artist credits, label metadata, tracklist rows, genres, tags and one owned item.
-3. Search for the created data by artist, release title, track title, label, media, ownership status, tag and credit role.
-4. Open catalog result details and verify server graph sections for credits, relations, media coverage, collector signals and workspace links.
-5. Create a manual playlist with ordered release or track references and verify the order remains stable after reload.
-6. Create a smart playlist with tag, genre, media, ownership status or year rules and verify results are computed from current catalog data.
-7. Confirm playlists appear in search, export data, catalog links and graph backlinks.
-8. Use the desktop app to scan a local audio folder and create an import review session.
-9. Confirm every supported audio file includes a SHA-256 `contentHash` in the desktop scan request.
-10. Re-import the same folder and verify fully duplicate drafts are no-ops against existing catalog data.
-11. Rename or move duplicate files and verify same-collection content hash matching still preselects existing tracks.
-12. Add a partial duplicate folder and verify existing tracks are preselected while missing catalog data can still be created.
-13. Use saved search views for `remixes`, `productions`, `labels`, `physicalWithoutDigital`, `lossyWithoutLossless`, `wantedNotOwned` and `needsDigitization`.
-14. Export JSON and CSV and verify core catalog data, import-created data, playlists and playlist entries are present.
-15. Restore a JSON export into an empty collection and verify restored search, graph context, playlists and exports.
+2. Confirm sign-in and bootstrap copy describes invited private beta access, the default private collection, and the hosted archive workflow without marketing claims.
+3. Create a release manually with artist credits, label metadata, tracklist rows, genres, tags and one owned item.
+4. Search for the created data by artist, release title, track title, label, media, ownership status, tag and credit role.
+5. Open catalog result details and verify server graph sections for credits, relations, media coverage, collector signals and workspace links.
+6. Create a manual playlist with ordered release or track references and verify the order remains stable after reload.
+7. Create a smart playlist with tag, genre, media, ownership status or year rules and verify results are computed from current catalog data.
+8. Confirm playlists appear in search, export data, catalog links and graph backlinks.
+9. Use the browser app to review existing import sessions and confirm it does not expose local folder selection.
+10. Use the desktop app to scan a local audio folder through `window.cratebaseDesktop.imports.pickAndScan()` and create an import review session.
+11. Confirm every supported audio file includes a SHA-256 `contentHash` in the desktop scan request, and confirm audio bytes are not uploaded.
+12. Confirm the native import confirmation prompt appears before catalog records are created.
+13. Re-import the same folder and verify fully duplicate drafts are no-ops against existing catalog data.
+14. Rename or move duplicate files and verify same-collection content hash matching still preselects existing tracks.
+15. Add a partial duplicate folder and verify existing tracks are preselected while missing catalog data can still be created.
+16. Use saved search views for `remixes`, `productions`, `labels`, `physicalWithoutDigital`, `lossyWithoutLossless`, `wantedNotOwned` and `needsDigitization`.
+17. Open the export workspace and confirm it explains JSON/CSV scope, known v1 limits, no audio export, and the difference between user exports and hosted service backups.
+18. Export JSON and CSV and verify core catalog data, import-created data, playlists and playlist entries are present.
+19. Restore a JSON export into an empty collection and verify restored search, graph context, playlists and exports.
## Verification Commands
@@ -62,6 +67,8 @@ npm run build
## Product Boundaries
- Smart playlists are dynamic rules, not materialized snapshots.
-- Browser import review is supported, but local folder scanning is desktop-only.
+- Browser import review is supported, but local folder scanning is desktop-only through the Electron preload bridge. The API boundary is documented in `cratebase-api/docs/imports/desktop-import-api-boundary.md`.
- Audio files are not uploaded to the API.
+- User-triggered JSON and CSV exports are portability tools and personal backups. Hosted service backups are separate operator-managed recovery work, and the export v1 contract is documented in `cratebase-api/docs/exports/portable-export-v1.md`.
+- Private beta data-handling and release-readiness expectations are documented in `cratebase-api/docs/private-beta/data-handling-and-trust.md` and `cratebase-api/docs/private-beta/release-readiness.md`.
- External catalog integrations, streaming, marketplace, social, and recommendation features are outside the product boundary.
diff --git a/electron/backend-config.cjs b/electron/backend-config.cjs
new file mode 100644
index 0000000..b5c5c26
--- /dev/null
+++ b/electron/backend-config.cjs
@@ -0,0 +1,10 @@
+const localApiBaseUrl = 'http://localhost:5094'
+
+function resolveBackendBaseUrl() {
+ return process.env.CRATEBASE_API_BASE_URL || localApiBaseUrl
+}
+
+module.exports = {
+ localApiBaseUrl,
+ resolveBackendBaseUrl,
+}
diff --git a/electron/backend-config.test.cjs b/electron/backend-config.test.cjs
new file mode 100644
index 0000000..e711afc
--- /dev/null
+++ b/electron/backend-config.test.cjs
@@ -0,0 +1,31 @@
+// @vitest-environment node
+
+const { resolveBackendBaseUrl } = require('./backend-config.cjs')
+
+describe('desktop backend configuration', () => {
+ const originalApiBaseUrl = process.env.CRATEBASE_API_BASE_URL
+
+ afterEach(() => {
+ if (originalApiBaseUrl === undefined) {
+ delete process.env.CRATEBASE_API_BASE_URL
+ } else {
+ process.env.CRATEBASE_API_BASE_URL = originalApiBaseUrl
+ }
+ })
+
+ it('uses the local API by default for packaged desktop builds', () => {
+ delete process.env.CRATEBASE_API_BASE_URL
+
+ expect(resolveBackendBaseUrl({ isPackaged: true })).toBe(
+ 'http://localhost:5094',
+ )
+ })
+
+ it('allows the backend URL to be overridden for hosted deployments', () => {
+ process.env.CRATEBASE_API_BASE_URL = 'https://cratebase.example.test'
+
+ expect(resolveBackendBaseUrl({ isPackaged: true })).toBe(
+ 'https://cratebase.example.test',
+ )
+ })
+})
diff --git a/electron/main.cjs b/electron/main.cjs
index 074a4ff..ccdc7bb 100644
--- a/electron/main.cjs
+++ b/electron/main.cjs
@@ -2,13 +2,10 @@ const { app, BrowserWindow, dialog, ipcMain, shell } = require('electron')
const fsp = require('node:fs/promises')
const http = require('node:http')
const path = require('node:path')
+const { resolveBackendBaseUrl } = require('./backend-config.cjs')
const { scanFolder } = require('./scanner.cjs')
-const localApiBaseUrl = 'http://localhost:5094'
-const hostedApiBaseUrl = 'https://cratebase.example.com'
-const backendBaseUrl =
- process.env.CRATEBASE_API_BASE_URL ||
- (app.isPackaged ? hostedApiBaseUrl : localApiBaseUrl)
+const backendBaseUrl = resolveBackendBaseUrl()
const devServerUrl = process.env.CRATEBASE_DESKTOP_DEV_SERVER
const cookieJar = new Map()
const strippedProxyResponseHeaders = new Set([
diff --git a/electron/preload-contract.test.cjs b/electron/preload-contract.test.cjs
new file mode 100644
index 0000000..08d86af
--- /dev/null
+++ b/electron/preload-contract.test.cjs
@@ -0,0 +1,60 @@
+// @vitest-environment node
+
+const Module = require('node:module')
+const path = require('node:path')
+
+const preloadPath = path.join(__dirname, 'preload.cjs')
+const originalLoad = Module._load
+
+describe('desktop preload contract', () => {
+ afterEach(() => {
+ Module._load = originalLoad
+ delete require.cache[preloadPath]
+ })
+
+ it('exposes only the cratebaseDesktop bridge and routes imports and exports over IPC', async () => {
+ const exposeInMainWorld = vi.fn()
+ const invoke = vi
+ .fn()
+ .mockResolvedValueOnce({ cancelled: true })
+ .mockResolvedValueOnce({ cancelled: false, path: '/tmp/export.json' })
+
+ Module._load = function load(request, parent, isMain) {
+ if (request === 'electron') {
+ return {
+ contextBridge: { exposeInMainWorld },
+ ipcRenderer: { invoke },
+ }
+ }
+
+ return originalLoad.call(this, request, parent, isMain)
+ }
+
+ require(preloadPath)
+
+ expect(exposeInMainWorld).toHaveBeenCalledTimes(1)
+ const [bridgeName, bridge] = exposeInMainWorld.mock.calls[0]
+ expect(bridgeName).toBe('cratebaseDesktop')
+ expect(Object.keys(bridge).sort()).toEqual([
+ 'exports',
+ 'imports',
+ 'isDesktop',
+ ])
+ expect(Object.keys(bridge.imports)).toEqual(['pickAndScan'])
+ expect(Object.keys(bridge.exports)).toEqual(['download'])
+
+ await expect(bridge.imports.pickAndScan()).resolves.toEqual({
+ cancelled: true,
+ })
+ await expect(bridge.exports.download('json')).resolves.toEqual({
+ cancelled: false,
+ path: '/tmp/export.json',
+ })
+ expect(invoke).toHaveBeenNthCalledWith(1, 'cratebase:imports:pick-and-scan')
+ expect(invoke).toHaveBeenNthCalledWith(
+ 2,
+ 'cratebase:exports:download',
+ 'json',
+ )
+ })
+})
diff --git a/electron/scanner.test.cjs b/electron/scanner.test.cjs
new file mode 100644
index 0000000..592e034
--- /dev/null
+++ b/electron/scanner.test.cjs
@@ -0,0 +1,90 @@
+// @vitest-environment node
+
+const crypto = require('node:crypto')
+const fs = require('node:fs/promises')
+const os = require('node:os')
+const path = require('node:path')
+const { scanFolder } = require('./scanner.cjs')
+
+const tempRoots = []
+
+async function createTempRoot() {
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), 'cratebase-scan-'))
+ tempRoots.push(root)
+ return root
+}
+
+describe('desktop folder scanner', () => {
+ afterEach(async () => {
+ await Promise.all(
+ tempRoots.splice(0).map((root) =>
+ fs.rm(root, {
+ force: true,
+ recursive: true,
+ }),
+ ),
+ )
+ })
+
+ it('hashes audio files, includes metadata shape, and never attaches audio bytes', async () => {
+ const root = await createTempRoot()
+ const releaseDir = path.join(root, 'Release')
+ const audioPath = path.join(releaseDir, '01 Track.flac')
+ const coverPath = path.join(releaseDir, 'cover.jpg')
+ const audioBytes = Buffer.from('fake flac bytes')
+ const coverBytes = Buffer.from('cover bytes')
+ const mtime = new Date('2026-05-16T12:00:00Z')
+
+ await fs.mkdir(releaseDir, { recursive: true })
+ await fs.writeFile(audioPath, audioBytes)
+ await fs.writeFile(coverPath, coverBytes)
+ await fs.writeFile(path.join(releaseDir, 'notes.txt'), 'ignored')
+ await fs.writeFile(path.join(releaseDir, '.DS_Store'), 'ignored')
+ await fs.utimes(audioPath, mtime, mtime)
+ await fs.utimes(coverPath, mtime, mtime)
+
+ const scan = await scanFolder(root)
+
+ const audio = scan.files.find((file) => file.relativePath.endsWith('.flac'))
+ const cover = scan.files.find((file) => file.relativePath.endsWith('.jpg'))
+ expect(scan.sourceRoot).toBe(root)
+ expect(scan.ignoredFileCount).toBe(2)
+ expect(audio).toMatchObject({
+ filePath: audioPath,
+ relativePath: path.join('Release', '01 Track.flac'),
+ format: 'flac',
+ sizeBytes: audioBytes.length,
+ lastModifiedAt: '2026-05-16T12:00:00.000Z',
+ contentHash: crypto.createHash('sha256').update(audioBytes).digest('hex'),
+ audioMetadata: {
+ title: null,
+ artists: [],
+ albumTitle: null,
+ albumArtists: [],
+ catalogNumber: null,
+ releaseDate: null,
+ year: null,
+ durationSeconds: null,
+ trackNumber: null,
+ },
+ coverArtifact: null,
+ })
+ expect(audio).not.toHaveProperty('contentBase64')
+ expect(JSON.stringify(audio)).not.toContain(audioBytes.toString('base64'))
+ expect(cover).toMatchObject({
+ filePath: coverPath,
+ relativePath: path.join('Release', 'cover.jpg'),
+ format: null,
+ sizeBytes: coverBytes.length,
+ lastModifiedAt: '2026-05-16T12:00:00.000Z',
+ audioMetadata: null,
+ coverArtifact: {
+ fileName: 'cover.jpg',
+ extension: '.jpg',
+ contentType: 'image/jpeg',
+ sizeBytes: coverBytes.length,
+ contentBase64: coverBytes.toString('base64'),
+ },
+ })
+ })
+})
diff --git a/src/App.auth.test.tsx b/src/App.auth.test.tsx
index 79da6c3..39bd273 100644
--- a/src/App.auth.test.tsx
+++ b/src/App.auth.test.tsx
@@ -20,6 +20,11 @@ describe('App auth', () => {
expect(
await h.screen.findByRole('form', { name: 'Sign in' }),
).toBeInTheDocument()
+ expect(
+ h.screen.getByText(
+ /Invited private beta users sign in with the credentials issued for their collection/i,
+ ),
+ ).toBeVisible()
})
it('shows bootstrap setup for first user state', async () => {
@@ -38,6 +43,11 @@ describe('App auth', () => {
expect(
await h.screen.findByRole('form', { name: 'Bootstrap setup' }),
).toBeInTheDocument()
+ expect(
+ h.screen.getByText(
+ /Bootstrap creates the first admin account and its default private collection/i,
+ ),
+ ).toBeVisible()
})
it('logs out back to sign in', async () => {
diff --git a/src/App.exports.test.tsx b/src/App.exports.test.tsx
new file mode 100644
index 0000000..d8ced2a
--- /dev/null
+++ b/src/App.exports.test.tsx
@@ -0,0 +1,178 @@
+import { describe, expect, it, vi } from 'vitest'
+import * as h from './test/appTestHarness'
+
+h.setupAppTestHooks()
+
+describe('App exports', () => {
+ it('shows portable export downloads for the active collection', () => {
+ window.history.pushState({}, '', '/exports')
+
+ h.render()
+
+ expect(
+ h.screen.getByRole('region', { name: 'Exports workspace' }),
+ ).toBeInTheDocument()
+ expect(
+ h.screen.getByText(`${h.releaseRecords.length} releases`),
+ ).toBeVisible()
+ expect(h.screen.getByText(`${h.trackRecords.length} tracks`)).toBeVisible()
+ expect(
+ h.screen.getByText(`${h.ownedItemRecords.length} owned items`),
+ ).toBeVisible()
+ expect(
+ h.screen.getByRole('button', { name: /download json/i }),
+ ).toBeEnabled()
+ expect(
+ h.screen.getByRole('button', { name: /download csv/i }),
+ ).toBeEnabled()
+ expect(
+ h.screen.getByText(
+ /User exports are portable snapshots for personal backup and spreadsheet work/i,
+ ),
+ ).toBeVisible()
+ expect(
+ h.screen.getByText(
+ /Hosted service backups are operated separately from these JSON and CSV downloads/i,
+ ),
+ ).toBeVisible()
+ expect(
+ h.screen.getByText(
+ /Export v1 includes confirmed catalog data and omits audio bytes, raw cover bytes, import review drafts and account data/i,
+ ),
+ ).toBeVisible()
+ expect(
+ h.screen.getByText(
+ /JSON restore is available only for an empty active collection/i,
+ ),
+ ).toBeVisible()
+ })
+
+ it('starts JSON exports through authenticated direct browser downloads', async () => {
+ window.history.pushState({}, '', '/exports')
+ const { click, createObjectURL, download, revokeObjectURL } =
+ h.stubBrowserExportDownload()
+ const fetchMock = h.mockFetch(
+ new Response(null, {
+ headers: {
+ 'Content-Disposition': 'attachment; filename="cratebase.json"',
+ },
+ status: 200,
+ }),
+ )
+ const user = h.userEvent.setup()
+ h.render()
+
+ await user.click(h.screen.getByRole('button', { name: /download json/i }))
+
+ expect(fetchMock).toHaveBeenCalledWith('/api/exports/json', {
+ credentials: 'include',
+ method: 'HEAD',
+ })
+ expect(click).toHaveBeenCalledOnce()
+ expect(download.href).toBe('/api/exports/json')
+ expect(download.fileName).toBe('cratebase.json')
+ expect(createObjectURL).not.toHaveBeenCalled()
+ expect(revokeObjectURL).not.toHaveBeenCalled()
+ expect(await h.screen.findByText('JSON export started')).toBeInTheDocument()
+ })
+
+ it('starts CSV exports through authenticated direct browser downloads', async () => {
+ window.history.pushState({}, '', '/exports')
+ const { click, createObjectURL, download, revokeObjectURL } =
+ h.stubBrowserExportDownload()
+ const fetchMock = h.mockFetch(
+ new Response(null, {
+ headers: {
+ 'Content-Disposition': 'attachment; filename="cratebase.zip"',
+ },
+ status: 200,
+ }),
+ )
+ const user = h.userEvent.setup()
+ h.render()
+
+ await user.click(h.screen.getByRole('button', { name: /download csv/i }))
+
+ expect(fetchMock).toHaveBeenCalledWith('/api/exports/csv', {
+ credentials: 'include',
+ method: 'HEAD',
+ })
+ expect(click).toHaveBeenCalledOnce()
+ expect(download.href).toBe('/api/exports/csv')
+ expect(download.fileName).toBe('cratebase.zip')
+ expect(createObjectURL).not.toHaveBeenCalled()
+ expect(revokeObjectURL).not.toHaveBeenCalled()
+ expect(await h.screen.findByText('CSV export started')).toBeInTheDocument()
+ })
+
+ it('shows export server failures accessibly and resets pending state', async () => {
+ window.history.pushState({}, '', '/exports')
+ h.mockFetch(
+ h.jsonResponse(
+ { code: 'exports.server_error', message: 'Export failed' },
+ 500,
+ ),
+ )
+ const user = h.userEvent.setup()
+ h.render()
+
+ const downloadJson = h.screen.getByRole('button', {
+ name: /download json/i,
+ })
+ await user.click(downloadJson)
+
+ expect(await h.screen.findByRole('alert')).toHaveTextContent(
+ 'Export failed',
+ )
+ expect(downloadJson).toBeEnabled()
+ })
+
+ it('returns to sign in when export download expires the session', async () => {
+ window.history.pushState({}, '', '/exports')
+ h.mockFetch(
+ h.jsonResponse({ code: 'auth.unauthenticated', message: 'Expired' }, 401),
+ new Response(null, { status: 204 }),
+ )
+ const user = h.userEvent.setup()
+ h.render()
+
+ await user.click(h.screen.getByRole('button', { name: /download json/i }))
+
+ expect(
+ await h.screen.findByRole('form', { name: 'Sign in' }),
+ ).toBeInTheDocument()
+ })
+
+ it('routes export downloads through the desktop bridge in desktop mode', async () => {
+ window.history.pushState({}, '', '/exports')
+ const downloadExport = vi.fn().mockResolvedValue({
+ cancelled: false,
+ path: '/tmp/cratebase-export.json',
+ })
+ const originalDesktopBridge = window.cratebaseDesktop
+ window.cratebaseDesktop = {
+ isDesktop: true,
+ imports: { pickAndScan: vi.fn() },
+ exports: { download: downloadExport },
+ }
+
+ try {
+ const user = h.userEvent.setup()
+ h.render()
+
+ await user.click(h.screen.getByRole('button', { name: /download json/i }))
+
+ expect(downloadExport).toHaveBeenCalledWith('json')
+ expect(await h.screen.findByText('JSON export saved')).toBeInTheDocument()
+ await user.click(h.screen.getByRole('button', { name: /download csv/i }))
+
+ expect(downloadExport).toHaveBeenCalledWith('csv')
+ expect(await h.screen.findByText('CSV export saved')).toBeInTheDocument()
+ expect(
+ h.screen.queryByRole('link', { name: /download json/i }),
+ ).not.toBeInTheDocument()
+ } finally {
+ window.cratebaseDesktop = originalDesktopBridge
+ }
+ })
+})
diff --git a/src/App.imports-exports.test.tsx b/src/App.imports-exports.test.tsx
index 611dd27..840aa5e 100644
--- a/src/App.imports-exports.test.tsx
+++ b/src/App.imports-exports.test.tsx
@@ -3,6 +3,89 @@ import * as h from './test/appTestHarness'
h.setupAppTestHooks()
+const desktopAudioContentHash =
+ '70bc8f4b72a86921468bf8e8441dce51d8c6cb7d792fa7bbcb0d4d9eba328b75'
+
+function importSessionDetailResponse(status: 'needsReview' | 'confirmed') {
+ return h.jsonResponse({
+ id: 'import-session-1',
+ sourceRoot: '/Users/example/Music',
+ status: status === 'confirmed' ? 'confirmed' : 'readyForReview',
+ draftCount: 1,
+ trackCount: 1,
+ ignoredFileCount: 0,
+ createdAt: '2026-05-16T12:00:00Z',
+ updatedAt: '2026-05-16T12:00:00Z',
+ drafts: [
+ {
+ id: 'draft-1',
+ sourcePath: '/Users/example/Music/Release',
+ relativePath: 'Release',
+ status,
+ title: 'Imported Release',
+ type: 'album',
+ catalogNumber: null,
+ labelName: null,
+ releaseDate: null,
+ year: 1992,
+ isVariousArtists: false,
+ notOnLabel: true,
+ artistNames: ['Aphex Twin'],
+ artistCredits: [],
+ selectedArtistIds: [],
+ artistSuggestions: [],
+ labels: [],
+ genres: [],
+ tags: ['local-import'],
+ coverPath: 'Release/cover.jpg',
+ issues: [],
+ tracks: [
+ {
+ id: 'draft-track-1',
+ filePath: '/Users/example/Music/Release/01 Track.flac',
+ relativePath: 'Release/01 Track.flac',
+ format: 'flac',
+ sizeBytes: 12,
+ lastModifiedAt: '2026-05-16T12:00:00Z',
+ durationSeconds: null,
+ position: 1,
+ title: 'Track',
+ artistNames: ['Aphex Twin'],
+ artistCredits: [],
+ artistSuggestions: [],
+ trackSuggestions: [],
+ isSkipped: false,
+ selectedTrackId: null,
+ selectedArtistIds: [],
+ issues: [],
+ },
+ ],
+ },
+ ],
+ })
+}
+
+function importSessionListResponse() {
+ return h.jsonResponse({
+ items: [
+ {
+ id: 'import-session-1',
+ sourceRoot: '/Users/example/Music',
+ status: 'readyForReview',
+ draftCount: 1,
+ trackCount: 1,
+ ignoredFileCount: 0,
+ createdAt: '2026-05-16T12:00:00Z',
+ updatedAt: '2026-05-16T12:00:00Z',
+ drafts: [],
+ },
+ ],
+ limit: 100,
+ offset: 0,
+ total: 1,
+ })
+}
+
describe('App imports and exports', () => {
it('shows the desktop download CTA for local imports in web mode', () => {
window.history.pushState({}, '', '/imports')
@@ -18,6 +101,11 @@ describe('App imports and exports', () => {
expect(
h.screen.queryByRole('button', { name: /choose local folder/i }),
).not.toBeInTheDocument()
+ expect(
+ h.screen.getByText(
+ /Desktop import sends metadata, hashes, paths and cover artifacts, not audio files/i,
+ ),
+ ).toBeVisible()
})
it('loads import review sessions from the authenticated API', async () => {
@@ -122,125 +210,144 @@ describe('App imports and exports', () => {
}
})
- it('shows portable export downloads for the active collection', () => {
- window.history.pushState({}, '', '/exports')
-
- h.render()
-
- expect(
- h.screen.getByRole('region', { name: 'Exports workspace' }),
- ).toBeInTheDocument()
- expect(
- h.screen.getByText(`${h.releaseRecords.length} releases`),
- ).toBeVisible()
- expect(h.screen.getByText(`${h.trackRecords.length} tracks`)).toBeVisible()
- expect(
- h.screen.getByText(`${h.ownedItemRecords.length} owned items`),
- ).toBeVisible()
- expect(
- h.screen.getByRole('button', { name: /download json/i }),
- ).toBeEnabled()
- expect(
- h.screen.getByRole('button', { name: /download csv/i }),
- ).toBeEnabled()
- })
-
- it('starts JSON exports through authenticated direct browser downloads', async () => {
- window.history.pushState({}, '', '/exports')
- const { click, createObjectURL, download, revokeObjectURL } =
- h.stubBrowserExportDownload()
- const fetchMock = h.mockFetch(
- new Response(null, {
- headers: {
- 'Content-Disposition': 'attachment; filename="cratebase.json"',
- },
- status: 200,
- }),
- )
- const user = h.userEvent.setup()
- h.render()
-
- await user.click(h.screen.getByRole('button', { name: /download json/i }))
-
- expect(fetchMock).toHaveBeenCalledWith('/api/exports/json', {
- credentials: 'include',
- method: 'HEAD',
- })
- expect(click).toHaveBeenCalledOnce()
- expect(download.href).toBe('/api/exports/json')
- expect(download.fileName).toBe('cratebase.json')
- expect(createObjectURL).not.toHaveBeenCalled()
- expect(revokeObjectURL).not.toHaveBeenCalled()
- expect(await h.screen.findByText('JSON export started')).toBeInTheDocument()
- })
-
- it('shows export server failures accessibly and resets pending state', async () => {
- window.history.pushState({}, '', '/exports')
- h.mockFetch(
- h.jsonResponse(
- { code: 'exports.server_error', message: 'Export failed' },
- 500,
- ),
- )
- const user = h.userEvent.setup()
- h.render()
-
- const downloadJson = h.screen.getByRole('button', {
- name: /download json/i,
- })
- await user.click(downloadJson)
-
- expect(await h.screen.findByRole('alert')).toHaveTextContent(
- 'Export failed',
- )
- expect(downloadJson).toBeEnabled()
- })
-
- it('returns to sign in when export download expires the session', async () => {
- window.history.pushState({}, '', '/exports')
- h.mockFetch(
- h.jsonResponse({ code: 'auth.unauthenticated', message: 'Expired' }, 401),
- new Response(null, { status: 204 }),
- )
- const user = h.userEvent.setup()
- h.render()
-
- await user.click(h.screen.getByRole('button', { name: /download json/i }))
-
- expect(
- await h.screen.findByRole('form', { name: 'Sign in' }),
- ).toBeInTheDocument()
- })
-
- it('routes export downloads through the desktop bridge in desktop mode', async () => {
- window.history.pushState({}, '', '/exports')
- const downloadExport = vi.fn().mockResolvedValue({
+ it('posts desktop scan results, selects the first draft, and sends no audio bytes', async () => {
+ vi.stubGlobal('__cratebaseUseRealCatalogApi', true)
+ window.history.pushState({}, '', '/imports')
+ const pickAndScan = vi.fn().mockResolvedValue({
cancelled: false,
- path: '/tmp/cratebase-export.json',
+ scan: {
+ sourceRoot: '/Users/example/Music',
+ ignoredFileCount: 1,
+ files: [
+ {
+ filePath: '/Users/example/Music/Release/01 Track.flac',
+ relativePath: 'Release/01 Track.flac',
+ format: 'flac',
+ sizeBytes: 12,
+ lastModifiedAt: '2026-05-16T12:00:00Z',
+ contentHash: desktopAudioContentHash,
+ audioMetadata: {
+ title: 'Track',
+ artists: ['Aphex Twin'],
+ albumTitle: 'Imported Release',
+ albumArtists: ['Aphex Twin'],
+ catalogNumber: null,
+ releaseDate: null,
+ year: 1992,
+ durationSeconds: null,
+ trackNumber: 1,
+ },
+ coverArtifact: null,
+ },
+ {
+ filePath: '/Users/example/Music/Release/cover.jpg',
+ relativePath: 'Release/cover.jpg',
+ format: null,
+ sizeBytes: 11,
+ lastModifiedAt: '2026-05-16T12:00:00Z',
+ audioMetadata: null,
+ coverArtifact: {
+ fileName: 'cover.jpg',
+ extension: '.jpg',
+ contentType: 'image/jpeg',
+ sizeBytes: 11,
+ contentBase64: 'Y292ZXIgYnl0ZXM=',
+ },
+ },
+ ],
+ },
})
const originalDesktopBridge = window.cratebaseDesktop
window.cratebaseDesktop = {
isDesktop: true,
- imports: { pickAndScan: vi.fn() },
- exports: { download: downloadExport },
+ exports: { download: vi.fn() },
+ imports: { pickAndScan },
}
+ const fetchMock = h.mockFetch(
+ h.emptyImportSessionsResponse(),
+ importSessionDetailResponse('needsReview'),
+ importSessionListResponse(),
+ )
try {
const user = h.userEvent.setup()
h.render()
- await user.click(h.screen.getByRole('button', { name: /download json/i }))
+ await user.click(
+ await h.screen.findByRole('button', { name: /choose local folder/i }),
+ )
- expect(downloadExport).toHaveBeenCalledWith('json')
- expect(await h.screen.findByText('JSON export saved')).toBeInTheDocument()
- expect(
- h.screen.queryByRole('link', { name: /download json/i }),
- ).not.toBeInTheDocument()
+ expect(await h.screen.findByText('Scan saved')).toBeInTheDocument()
+ expect(h.screen.getByDisplayValue('Imported Release')).toBeVisible()
+ expect(h.screen.getByText('Ready to confirm.')).toBeInTheDocument()
+ const scanCall = fetchMock.mock.calls.find(
+ ([url]) => url === '/api/imports/desktop-folder-scans',
+ )
+ expect(scanCall).toBeDefined()
+ const requestBody = JSON.parse(
+ ((scanCall?.[1] as RequestInit).body as string) ?? '{}',
+ ) as { files: Array> }
+ const requestJson = JSON.stringify(requestBody)
+ expect(requestJson).not.toContain('collectionId')
+ expect(requestBody.files[0]).toMatchObject({
+ relativePath: 'Release/01 Track.flac',
+ contentHash: desktopAudioContentHash,
+ coverArtifact: null,
+ })
+ expect(requestBody.files[0]).not.toHaveProperty('contentBase64')
+ expect(requestBody.files[0]).not.toHaveProperty('audioContentBase64')
+ expect(JSON.stringify(requestBody.files[0])).not.toContain(
+ 'ZmFrZSBmbGFjIGJ5dGVz',
+ )
+ expect(requestBody.files[1]).toMatchObject({
+ relativePath: 'Release/cover.jpg',
+ coverArtifact: {
+ contentBase64: 'Y292ZXIgYnl0ZXM=',
+ },
+ })
} finally {
window.cratebaseDesktop = originalDesktopBridge
}
})
+ it('cancels import confirmation before save or catalog writes when not confirmed', async () => {
+ vi.stubGlobal('__cratebaseUseRealCatalogApi', true)
+ window.history.pushState({}, '', '/imports')
+ const confirm = vi.spyOn(window, 'confirm').mockReturnValue(false)
+ const fetchMock = h.mockFetch(
+ importSessionListResponse(),
+ importSessionDetailResponse('needsReview'),
+ importSessionDetailResponse('needsReview'),
+ importSessionDetailResponse('confirmed'),
+ importSessionListResponse(),
+ )
+ const user = h.userEvent.setup()
+ h.render()
+
+ await user.click(
+ await h.screen.findByRole('button', { name: /\/Users\/example\/Music/i }),
+ )
+ await h.screen.findByText('Ready to confirm.')
+ await user.click(h.screen.getByRole('button', { name: /^confirm$/i }))
+
+ await h.waitFor(() =>
+ expect(confirm).toHaveBeenCalledWith(
+ 'Confirm this import draft and create catalog records?',
+ ),
+ )
+ expect(await h.screen.findByText('Confirmation cancelled')).toBeVisible()
+ expect(
+ fetchMock.mock.calls.some(
+ ([url]) => typeof url === 'string' && url.includes('/drafts/'),
+ ),
+ ).toBe(false)
+ expect(
+ fetchMock.mock.calls.some(
+ ([url]) => typeof url === 'string' && url.includes('/confirm'),
+ ),
+ ).toBe(false)
+ })
+
it('restores JSON backups without a full catalog reload', async () => {
window.history.pushState({}, '', '/imports')
h.clearCatalogForTests()
diff --git a/src/app/AuthBoundary.tsx b/src/app/AuthBoundary.tsx
index b6772be..e800ae2 100644
--- a/src/app/AuthBoundary.tsx
+++ b/src/app/AuthBoundary.tsx
@@ -242,8 +242,16 @@ function AuthForm({
Cratebase
Personal music archive.
{mode === 'bootstrap' ? (
- Create the first local admin and default collection.
- ) : null}
+
+ Bootstrap creates the first admin account and its default private
+ collection.
+
+ ) : (
+
+ Invited private beta users sign in with the credentials issued for
+ their collection.
+
+ )}
)}
+
+ Private beta export boundary
+
+ User exports are portable snapshots for personal backup and
+ spreadsheet work.
+
+
+ Hosted service backups are operated separately from these JSON and
+ CSV downloads.
+
+
+
JSON, CSV
+
+
Export v1 limits
+
+ -
+ Export v1 includes confirmed catalog data and omits audio bytes,
+ raw cover bytes, import review drafts and account data.
+
+ -
+ JSON restore is available only for an empty active collection.
+
+ -
+ Confirmed desktop imports appear as ordinary catalog records in
+ future exports.
+
+
+
)
diff --git a/src/features/exports/exports.css b/src/features/exports/exports.css
index 07f317f..99ce897 100644
--- a/src/features/exports/exports.css
+++ b/src/features/exports/exports.css
@@ -16,6 +16,28 @@
gap: 10px;
}
+.exports-guidance {
+ display: grid;
+ gap: 8px;
+ border-top: 1px solid var(--color-border);
+ padding-top: 12px;
+}
+
+.exports-guidance h3,
+.exports-boundary-list h3 {
+ margin: 0;
+ color: var(--color-heading);
+ font-size: 14px;
+ line-height: 1.3;
+}
+
+.exports-guidance p {
+ margin: 0;
+ color: var(--color-muted);
+ font-size: 13px;
+ line-height: 1.45;
+}
+
.exports-download-row {
display: grid;
grid-template-columns: 36px minmax(0, 1fr) auto;
@@ -110,3 +132,20 @@
font-size: 13px;
font-weight: 760;
}
+
+.exports-boundary-list {
+ display: grid;
+ gap: 8px;
+ border-top: 1px solid var(--color-border);
+ padding-top: 12px;
+}
+
+.exports-boundary-list ul {
+ display: grid;
+ gap: 8px;
+ margin: 0;
+ padding-left: 18px;
+ color: var(--color-muted);
+ font-size: 13px;
+ line-height: 1.45;
+}
diff --git a/src/features/imports/ImportReviewPanels.tsx b/src/features/imports/ImportReviewPanels.tsx
index 7843224..5c15fa4 100644
--- a/src/features/imports/ImportReviewPanels.tsx
+++ b/src/features/imports/ImportReviewPanels.tsx
@@ -11,7 +11,9 @@ export function ImportSourcePanel({ isDesktop }: { isDesktop: boolean }) {
Desktop app
Local import enabled
- Choose a folder on this Mac and review parsed drafts here.
+ Choose a folder on this Mac and review parsed drafts here. Desktop
+ import sends metadata, hashes, paths and cover artifacts, not audio
+ files.
@@ -25,7 +27,8 @@ export function ImportSourcePanel({ isDesktop }: { isDesktop: boolean }) {
Local folder import is desktop-only
Web review remains available; local folder selection runs in the macOS
- app.
+ app. Desktop import sends metadata, hashes, paths and cover artifacts,
+ not audio files.
diff --git a/src/features/imports/ImportsWorkspace.tsx b/src/features/imports/ImportsWorkspace.tsx
index 744d03e..ca364b2 100644
--- a/src/features/imports/ImportsWorkspace.tsx
+++ b/src/features/imports/ImportsWorkspace.tsx
@@ -41,6 +41,8 @@ type ImportsWorkspaceProps = {
}
const macOsDownloadUrl = '/api/imports/desktop-downloads/macos'
+const confirmImportDraftMessage =
+ 'Confirm this import draft and create catalog records?'
export function ImportsWorkspace({
artists,
@@ -222,6 +224,12 @@ export function ImportsWorkspace({
return
}
+ if (!window.confirm(confirmImportDraftMessage)) {
+ setStatus('Confirmation cancelled')
+ setError(null)
+ return
+ }
+
setStatus('Confirming')
setPendingAction('confirm')
try {
diff --git a/src/features/imports/imports.css b/src/features/imports/imports.css
index 9ca6185..d878d0f 100644
--- a/src/features/imports/imports.css
+++ b/src/features/imports/imports.css
@@ -1,5 +1,14 @@
.imports-layout {
grid-template-columns: minmax(360px, 0.72fr) minmax(0, 1.28fr);
+ min-width: 0;
+}
+
+.imports-layout > .catalog-main,
+.imports-layout > .detail-panel,
+.imports-layout .catalog-main > .panel,
+.imports-layout .panel-heading > div,
+.imports-agent-card > div {
+ min-width: 0;
}
.imports-session-table td:first-child {
@@ -98,9 +107,7 @@
color: var(--color-muted);
font-size: 12px;
line-height: 1.35;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
+ overflow-wrap: anywhere;
}
.imports-agent-card {
@@ -399,3 +406,58 @@
align-items: center;
gap: 6px;
}
+
+@media (max-width: 1100px) {
+ .imports-layout {
+ grid-template-columns: minmax(0, 1fr);
+ }
+
+ .imports-layout > .detail-panel {
+ position: static;
+ }
+
+ .imports-restore-row {
+ grid-template-columns: 36px minmax(0, 1fr);
+ }
+
+ .imports-restore-row .button {
+ grid-column: 1 / -1;
+ }
+}
+
+@media (max-width: 760px) {
+ .imports-scan-panel .panel-heading,
+ .imports-agent-card,
+ .imports-folder-selection,
+ .imports-folder-picker-header,
+ .imports-folder-current {
+ align-items: stretch;
+ flex-direction: column;
+ }
+
+ .imports-scan-panel .panel-heading .button,
+ .imports-folder-selection .button,
+ .imports-folder-picker-actions .button {
+ width: 100%;
+ justify-content: center;
+ }
+
+ .imports-folder-picker-actions {
+ justify-content: stretch;
+ }
+
+ .imports-release-grid,
+ .imports-track-detail-grid,
+ .imports-tracklist-layout {
+ grid-template-columns: minmax(0, 1fr);
+ }
+
+ .imports-tracklist-master {
+ max-height: 260px;
+ }
+
+ .imports-tracklist-toolbar {
+ align-items: stretch;
+ flex-direction: column;
+ }
+}