diff --git a/apps/web/src/lib/api.test.mjs b/apps/web/src/lib/api.test.mjs index e38b51b4..3f41611f 100644 --- a/apps/web/src/lib/api.test.mjs +++ b/apps/web/src/lib/api.test.mjs @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"; const storage = new Map(); const calls = []; +const events = []; let completeSave; globalThis.window = { @@ -23,10 +24,23 @@ globalThis.window = { calls.push(["storage", value]); storage.set(key, value); }, + removeItem: (key) => { + storage.delete(key); + }, + }, + dispatchEvent: (event) => { + events.push(event.type); + return true; }, }; -const { DESKTOP_API_BASE_URL_STORAGE_KEY, saveDesktopApiBaseUrl } = await import("./api.ts"); +const { + DESKTOP_API_BASE_URL_STORAGE_KEY, + api, + cacheDesktopSession, + getCachedDesktopSession, + saveDesktopApiBaseUrl, +} = await import("./api.ts"); describe("desktop instance setup", () => { test("can retry with a valid URL after invalid input", async () => { @@ -46,4 +60,55 @@ describe("desktop instance setup", () => { ]); expect(storage.get(DESKTOP_API_BASE_URL_STORAGE_KEY)).toBe("https://notes.example.com"); }); + + test("uses the desktop session token and stops network retries after a 401", async () => { + calls.length = 0; + events.length = 0; + cacheDesktopSession({ + authRequired: true, + authenticated: true, + demoMode: false, + sessionToken: "desktop-session-token", + user: { id: "user-1", username: "admin", displayName: null, role: "owner" }, + }); + + const requests = []; + globalThis.fetch = async (url, init) => { + requests.push({ url, authorization: new Headers(init?.headers).get("Authorization") }); + return new Response(JSON.stringify({ error: { code: "unauthorized", message: "Authentication required" } }), { + status: 401, + headers: { "Content-Type": "application/json" }, + }); + }; + + await expect(api.syncBootstrap({ limit: 200 })).rejects.toMatchObject({ status: 401 }); + await expect(api.syncBootstrap({ limit: 200 })).rejects.toMatchObject({ status: 401 }); + + expect(requests).toEqual([{ + url: "https://notes.example.com/api/v1/sync/bootstrap?limit=200", + authorization: "Bearer desktop-session-token", + }]); + expect(events).toEqual(["edgeever:unauthorized"]); + expect(getCachedDesktopSession()).toBeNull(); + + globalThis.fetch = async (url, init) => { + requests.push({ url, authorization: new Headers(init?.headers).get("Authorization") }); + return Response.json({ + authRequired: true, + authenticated: true, + demoMode: false, + sessionToken: "replacement-session-token", + user: { id: "user-1", username: "admin", displayName: null, role: "owner" }, + }); + }; + + const replacement = await api.login({ username: "admin", password: "secret" }); + cacheDesktopSession(replacement); + await api.syncBootstrap({ limit: 200 }); + + expect(requests.at(-1)).toEqual({ + url: "https://notes.example.com/api/v1/sync/bootstrap?limit=200", + authorization: "Bearer replacement-session-token", + }); + }); }); diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 69c77029..8a1782c5 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -186,8 +186,23 @@ export class ApiRequestError extends Error { } } +let desktopSessionRejected = false; + +const isDesktopAuthenticationRequest = (path: string) => + path === "/api/v1/auth/login" || path === "/api/v1/auth/session"; + const request = async (path: string, init?: RequestInit): Promise => { const headers = new Headers(init?.headers); + const isDesktop = Boolean(window.edgeeverDesktop?.isAvailable); + const sessionToken = isDesktop ? getCachedDesktopSession()?.sessionToken : undefined; + + if (isDesktop && desktopSessionRejected && !isDesktopAuthenticationRequest(path)) { + throw new ApiRequestError("Authentication required", 401, "unauthorized"); + } + + if (sessionToken && !headers.has("Authorization") && path !== "/api/v1/auth/login") { + headers.set("Authorization", `Bearer ${sessionToken}`); + } if (!(init?.body instanceof FormData) && !headers.has("Content-Type")) { headers.set("Content-Type", "application/json"); @@ -209,13 +224,25 @@ const request = async (path: string, init?: RequestInit): Promise => { : response.statusText; if (response.status === 401) { - window.dispatchEvent(new CustomEvent("edgeever:unauthorized")); + if (isDesktop) { + clearCachedDesktopSession(); + if (!desktopSessionRejected) { + desktopSessionRejected = true; + window.dispatchEvent(new CustomEvent("edgeever:unauthorized")); + } + } else { + window.dispatchEvent(new CustomEvent("edgeever:unauthorized")); + } } throw new ApiRequestError(message || "Request failed", response.status, error?.code); } - return response.json() as Promise; + const body = await response.json() as T; + if (path === "/api/v1/auth/login") { + desktopSessionRejected = false; + } + return body; }; export const api = { diff --git a/apps/web/src/lib/desktop-sync.test.mjs b/apps/web/src/lib/desktop-sync.test.mjs index 5c1ec49a..7296292a 100644 --- a/apps/web/src/lib/desktop-sync.test.mjs +++ b/apps/web/src/lib/desktop-sync.test.mjs @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; -const { rewriteStagedResource } = await import("./desktop-sync.ts"); +const { orderBootstrapNotebooks, rewriteStagedResource } = await import("./desktop-sync.ts"); describe("desktop staged resource sync", () => { test("rewrites placeholders in memo JSON and markdown", () => { @@ -16,3 +16,17 @@ describe("desktop staged resource sync", () => { }); }); }); + +describe("desktop bootstrap sync", () => { + test("orders parent notebooks before their children", () => { + const child = { id: "child", parentId: "parent", name: "Child" }; + const parent = { id: "parent", parentId: null, name: "Parent" }; + const grandchild = { id: "grandchild", parentId: "child", name: "Grandchild" }; + + expect(orderBootstrapNotebooks([grandchild, child, parent]).map((notebook) => notebook.id)).toEqual([ + "parent", + "child", + "grandchild", + ]); + }); +}); diff --git a/apps/web/src/lib/desktop-sync.ts b/apps/web/src/lib/desktop-sync.ts index e4f993a5..5a9419c1 100644 --- a/apps/web/src/lib/desktop-sync.ts +++ b/apps/web/src/lib/desktop-sync.ts @@ -3,6 +3,7 @@ import { api, ApiRequestError } from "@/lib/api"; import { isDesktopResourceRuntime } from "@/lib/desktop-resources"; type StagedResourceRewrite = { memoId: string; placeholder: string; url: string }; +let lastSyncFailed = false; const syncStagedResources = async (memoIdMappings: Map) => { if (!isDesktopResourceRuntime() || (typeof navigator !== "undefined" && !navigator.onLine)) return { attempted: 0, synced: 0, failed: 0, rewrites: [] as StagedResourceRewrite[], stagedIds: [] as string[] }; @@ -313,15 +314,37 @@ const syncOutbox = async (stagedRewrites: StagedResourceRewrite[], onlyKinds?: S const applyBootstrap = async (page: SyncBootstrapResponse) => { const changes = [ - ...page.notebooks.map((notebook) => ({ entityType: "notebook" as const, operation: "upsert" as const, entityId: notebook.id, notebook, memo: null })), + ...orderBootstrapNotebooks(page.notebooks).map((notebook) => ({ entityType: "notebook" as const, operation: "upsert" as const, entityId: notebook.id, notebook, memo: null })), ...page.memos.map((memo) => ({ entityType: "memo" as const, operation: "upsert" as const, entityId: memo.id, memo, notebook: null })), ]; if (changes.length > 0) await request("sync.apply", { changes }); }; +export const orderBootstrapNotebooks = (notebooks: SyncBootstrapResponse["notebooks"]) => { + const remaining = new Map(notebooks.map((notebook) => [notebook.id, notebook])); + const ordered: SyncBootstrapResponse["notebooks"] = []; + + while (remaining.size > 0) { + let added = 0; + for (const [id, notebook] of remaining) { + if (notebook.parentId && remaining.has(notebook.parentId)) continue; + ordered.push(notebook); + remaining.delete(id); + added += 1; + } + if (added === 0) { + ordered.push(...remaining.values()); + break; + } + } + + return ordered; +}; + const pullRemoteChanges = async () => { const status = await request("sync.status", {}); if (!status.syncIdentity) { + await request("sync.bootstrap.prepare", {}); let afterId: string | null = null; let snapshotCursor = 0; let syncIdentity = ""; @@ -363,8 +386,11 @@ export const syncDesktopData = () => { await removeSyncedStagedResources(stagedResources.stagedIds); } if (outbox.conflicted === 0 && creates.conflicted === 0 && (typeof navigator === "undefined" || navigator.onLine)) await pullRemoteChanges(); + lastSyncFailed = false; return { ...outbox, attempted: creates.attempted + outbox.attempted + stagedResources.attempted, synced: creates.synced + outbox.synced + stagedResources.synced, failed: creates.failed + outbox.failed + stagedResources.failed, conflicted: creates.conflicted + outbox.conflicted }; - } catch { + } catch (error) { + lastSyncFailed = true; + console.error("[desktop-sync] Sync failed", error); return { attempted: 0, synced: 0, failed: 1, conflicted: 0 }; } })().finally(() => { activeSync = null; }); @@ -373,7 +399,8 @@ export const syncDesktopData = () => { export const getDesktopSyncSummary = async () => { const status = await request("sync.status", {}); - return { total: status.pending + status.syncing + status.conflict + status.error, pending: status.pending, syncing: status.syncing, conflict: status.conflict, error: status.error }; + const error = status.error + (lastSyncFailed ? 1 : 0); + return { total: status.pending + status.syncing + status.conflict + error, pending: status.pending, syncing: status.syncing, conflict: status.conflict, error }; }; export const discardDesktopConflicts = async () => { diff --git a/crates/desktop-sidecar/src/main.rs b/crates/desktop-sidecar/src/main.rs index a7a5fd20..6d0accf4 100644 --- a/crates/desktop-sidecar/src/main.rs +++ b/crates/desktop-sidecar/src/main.rs @@ -1350,6 +1350,53 @@ fn sync_status(database: &Connection) -> Result { })) } +fn prepare_sync_bootstrap(database: &Connection) -> Result { + if meta_value(database, "sync.identity").is_some_and(|identity| !identity.is_empty()) { + return Ok(json!({ "clearedSeedData": false })); + } + + let outbox_count = database + .query_row("SELECT COUNT(*) FROM _edgeever_sidecar_outbox", [], |row| { + row.get::<_, i64>(0) + }) + .map_err(|e| e.to_string())?; + let non_seed_memos = database + .query_row( + "SELECT COUNT(*) FROM memos WHERE id <> 'memo_welcome'", + [], + |row| row.get::<_, i64>(0), + ) + .map_err(|e| e.to_string())?; + let non_seed_notebooks = database + .query_row( + "SELECT COUNT(*) FROM notebooks + WHERE id NOT IN ('nb_inbox', 'nb_projects', 'nb_learning', 'nb_creative', 'nb_personal')", + [], + |row| row.get::<_, i64>(0), + ) + .map_err(|e| e.to_string())?; + + if outbox_count > 0 || non_seed_memos > 0 || non_seed_notebooks > 0 { + return Ok(json!({ "clearedSeedData": false })); + } + + let tx = database + .unchecked_transaction() + .map_err(|e| e.to_string())?; + tx.execute("DELETE FROM memos_fts WHERE memo_id = 'memo_welcome'", []) + .map_err(|e| e.to_string())?; + tx.execute("DELETE FROM memos WHERE id = 'memo_welcome'", []) + .map_err(|e| e.to_string())?; + tx.execute( + "DELETE FROM notebooks + WHERE id IN ('nb_inbox', 'nb_projects', 'nb_learning', 'nb_creative', 'nb_personal')", + [], + ) + .map_err(|e| e.to_string())?; + tx.commit().map_err(|e| e.to_string())?; + Ok(json!({ "clearedSeedData": true })) +} + fn sync_outbox_list(database: &Connection, params: &Value) -> Result { let limit = params .get("limit") @@ -1648,6 +1695,7 @@ fn handle( "storage.backups" => list_backups(root).map_err(|error| error.to_string()), "storage.restore" => restore_database(database, root, migrations, &request.params), "sync.status" => sync_status(database), + "sync.bootstrap.prepare" => prepare_sync_bootstrap(database), "sync.outbox.list" => sync_outbox_list(database, &request.params), "sync.outbox.ack" => sync_outbox_ack(database, &request.params), "sync.outbox.fail" => sync_outbox_fail(database, &request.params), diff --git a/packages/shared/src/desktop-rpc.ts b/packages/shared/src/desktop-rpc.ts index 93544911..19283c30 100644 --- a/packages/shared/src/desktop-rpc.ts +++ b/packages/shared/src/desktop-rpc.ts @@ -8,6 +8,7 @@ export type DesktopRpcMethod = | "storage.backups" | "storage.restore" | "sync.status" + | "sync.bootstrap.prepare" | "notebook.list" | "notebook.create" | "notebook.update" @@ -103,6 +104,7 @@ export type DesktopRpcResponses = { "memo.restoreRevision": { memo: MemoDetail }; "memo.revision.cache": { ok: true }; "sync.status": { pending: number; syncing: number; conflict: number; error: number; cursor: number; syncIdentity: string | null; lastSyncedAt: string | null }; + "sync.bootstrap.prepare": { clearedSeedData: boolean }; "sync.outbox.list": { items: DesktopOutboxItem[] }; "sync.outbox.ack": { ok: true; memo: MemoDetail | null; notebook: Notebook | null; template: MemoTemplate | null }; "sync.outbox.fail": { ok: true }; @@ -147,6 +149,7 @@ export type DesktopRpcParams = { "memo.restoreRevision": { memoId: string; revisionId: string }; "memo.revision.cache": { revision: import("./types").MemoRevision }; "sync.status": Record; + "sync.bootstrap.prepare": Record; "sync.outbox.list": { limit?: number }; "sync.outbox.ack": { id: number; remoteMemo?: MemoDetail; remoteNotebook?: Notebook; remoteTemplate?: MemoTemplate }; "sync.outbox.fail": { id: number; error: string; conflict?: boolean }; diff --git a/scripts/test-desktop-sidecar.mjs b/scripts/test-desktop-sidecar.mjs index e614a84a..012ace27 100644 --- a/scripts/test-desktop-sidecar.mjs +++ b/scripts/test-desktop-sidecar.mjs @@ -36,14 +36,20 @@ const request = async (method, params = {}) => { await waitFor((message) => message.event === "ready"); const notebooks = await request("notebook.list"); -const inbox = notebooks.notebooks.find((notebook) => notebook.slug === "inbox"); -assert.ok(inbox, "seed inbox notebook should exist"); +const seedInbox = notebooks.notebooks.find((notebook) => notebook.slug === "inbox"); +assert.ok(seedInbox, "seed inbox notebook should exist"); +assert.deepEqual(await request("sync.bootstrap.prepare"), { clearedSeedData: true }); +assert.equal((await request("memo.list", { limit: 20 })).totalCount, 0, "bootstrap preparation should remove only pristine seed data"); +assert.equal((await request("notebook.list")).notebooks.length, 0, "bootstrap preparation should remove pristine seed notebooks"); +const inbox = (await request("notebook.create", { name: "Inbox" })).notebook; if (process.platform !== "win32") { assert.equal(statSync(dataDir).mode & 0o777, 0o700, "sidecar data directory should be private"); assert.equal(statSync(join(dataDir, "edgeever.sqlite")).mode & 0o777, 0o600, "sidecar database should be private"); } const first = await request("memo.create", { notebookId: inbox.id, title: "Local first", contentMarkdown: "searchable body", tags: ["local"] }); +assert.deepEqual(await request("sync.bootstrap.prepare"), { clearedSeedData: false }); +assert.equal((await request("memo.get", { memoId: first.memo.id })).memo.id, first.memo.id, "bootstrap preparation must preserve local user data"); const second = await request("memo.create", { notebookId: inbox.id, title: "Second memo", contentMarkdown: "another body", tags: [] }); const search = await request("memo.list", { q: "searchable", limit: 20 }); assert.deepEqual(search.memos.map((memo) => memo.id), [first.memo.id]);