From cfef89f40d61407ec892449cadb0bb983fa6cf19 Mon Sep 17 00:00:00 2001
From: cevheri
Date: Wed, 7 Oct 2026 11:17:31 +0300
Subject: [PATCH 01/28] fix(storage): bind the browser workspace to the
signed-in account
In server storage mode the browser copy belongs to the signed-in account.
useStorageSync reads the username from /api/auth/me and records it in libredb_workspace_owner.
Signing in as a different account starts from that account's server data: a copy with another owner, or with no owner that was already migrated, is cleared before the migration and the pull.
A copy with no owner that was never migrated is local-mode data and is migrated as before.
When the username cannot be read, the copy is neither used nor pushed and syncError says why.
Signing out from the editor, the admin dashboard or the launch page pushes pending collections while the session is still valid, then clears this browser's copy, editor tabs included, then ends the session.
clearAccountWorkspace() in local-storage.ts is the one list of the account's keys; per-browser preferences stay.
Local mode is unchanged.
---
src/app/launch/launch-client.tsx | 6 +-
src/components/admin/AdminDashboard.tsx | 3 +
src/components/agent/use-agent-run.ts | 10 +-
src/components/object-source/source-drafts.ts | 6 +-
src/hooks/use-auth.ts | 5 +
src/hooks/use-storage-sync.ts | 77 ++++-
src/lib/storage/local-storage.ts | 46 +++
src/lib/storage/sign-out.ts | 42 +++
tests/components/LaunchPage.test.tsx | 48 ++-
.../components/admin/AdminDashboard.test.tsx | 54 ++-
tests/hooks/use-auth.test.ts | 102 ++++++
tests/isolated/use-storage-sync.test.ts | 326 ++++++++++++++++++
tests/unit/lib/storage/local-storage.test.ts | 60 +++-
tests/unit/lib/storage/sign-out.test.ts | 102 ++++++
14 files changed, 873 insertions(+), 14 deletions(-)
create mode 100644 src/lib/storage/sign-out.ts
create mode 100644 tests/unit/lib/storage/sign-out.test.ts
diff --git a/src/app/launch/launch-client.tsx b/src/app/launch/launch-client.tsx
index 45d844937..6e41f25ab 100644
--- a/src/app/launch/launch-client.tsx
+++ b/src/app/launch/launch-client.tsx
@@ -3,6 +3,7 @@
import Link from "next/link";
import { useEffect, useRef, useState } from "react";
import { appFetch, withBasePath } from "@/lib/config/base-path";
+import { releaseAccountWorkspace } from "@/lib/storage/sign-out";
const NO_TOKEN =
"This address carries no launch token. Open Studio again from the platform that sent you, or sign in with your password.";
@@ -111,7 +112,10 @@ function SessionConflict({ signedInAs, launchFor }: Conflict) {
const [state, setState] = useState<"asking" | "signed-out" | "failed">("asking");
async function signOut() {
- const response = await appFetch("/api/auth/logout", { method: "POST" }).catch(() => null);
+ // In server storage mode the browser copy belongs to the signed-in account and is cleared first.
+ const response = await releaseAccountWorkspace()
+ .then(() => appFetch("/api/auth/logout", { method: "POST" }))
+ .catch(() => null);
setState(response?.ok ? "signed-out" : "failed");
}
diff --git a/src/components/admin/AdminDashboard.tsx b/src/components/admin/AdminDashboard.tsx
index 896a6154a..2a84aab8a 100644
--- a/src/components/admin/AdminDashboard.tsx
+++ b/src/components/admin/AdminDashboard.tsx
@@ -1,6 +1,7 @@
"use client";
import { appFetch } from "@/lib/config/base-path";
+import { releaseAccountWorkspace } from "@/lib/storage/sign-out";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import type { ReactNode } from "react";
@@ -29,6 +30,8 @@ export default function AdminDashboard({ children }: AdminDashboardProps) {
const activeSection = adminSectionFromPathname(pathname);
const handleLogout = async () => {
+ // In server storage mode the browser copy belongs to the signed-in account and is cleared first.
+ await releaseAccountWorkspace();
const res = await appFetch("/api/auth/logout", { method: "POST" });
const data = await res.json();
toast.success("Logged out successfully");
diff --git a/src/components/agent/use-agent-run.ts b/src/components/agent/use-agent-run.ts
index 647b5d1e3..3f016cf08 100644
--- a/src/components/agent/use-agent-run.ts
+++ b/src/components/agent/use-agent-run.ts
@@ -3,6 +3,7 @@
import { appFetch } from "@/lib/config/base-path";
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
import { isAgentModelCapability } from "@/lib/agent/capability-labels";
+import { AGENT_THREAD_KEY } from "@/lib/storage/local-storage";
// Type-only, so nothing of the probe — or of the AI SDK it runs — reaches this bundle.
import type { AgentModelCapability } from "@/lib/agent/capability-probe";
import type { AgentLedgerEntry } from "@/lib/agent/run-store";
@@ -202,14 +203,15 @@ const isStartRefusalCode = (value: unknown): value is AgentStartRefusalCode => v
/**
* Where this browser remembers which conversation it was in.
*
- * localStorage only, and per browser rather than per user: it is a resumption hint, not
- * user data, so it must never reach the storage layer or a server — the rule
- * `lib/community/star-prompt.ts` states for the same reason. Every access is wrapped, and
+ * localStorage only: it is a resumption hint, not user data, so it must never reach the
+ * storage layer or a server: the rule `lib/community/star-prompt.ts` states for the same
+ * reason. It is part of the signed-in account's browser copy all the same, so in server
+ * storage mode signing out clears it (`clearAccountWorkspace`). Every access is wrapped, and
* every failure degrades to "nothing was interrupted", which is the behaviour before this
* existed: a rail that cannot say a conversation ended is strictly better than one that
* cannot open a run (#518).
*/
-const THREAD_STORAGE_KEY = "libredb_agent_thread";
+const THREAD_STORAGE_KEY = AGENT_THREAD_KEY;
/** A conversation this browser was in and is no longer following. */
export interface AgentInterruptedThread {
diff --git a/src/components/object-source/source-drafts.ts b/src/components/object-source/source-drafts.ts
index 940d9270b..9d6d5da9f 100644
--- a/src/components/object-source/source-drafts.ts
+++ b/src/components/object-source/source-drafts.ts
@@ -1,5 +1,6 @@
import { SOURCE_CHARACTER_LIMIT } from "@/lib/db/object-kinds";
import type { ObjectEditRevision } from "@/lib/db/types";
+import { SOURCE_DRAFTS_KEY } from "@/lib/storage/local-storage";
/**
* The unsaved edit of ONE part of an object's definition, held in this browser (#789 Phase 3,
@@ -59,9 +60,10 @@ export interface SourceDraft {
/**
* The one key. Versioned, so a later shape change is a new key rather than a migration over a
- * record a previous release wrote.
+ * record a previous release wrote. Spelled in `lib/storage/local-storage.ts`, which lists it among
+ * the keys of the signed-in account's browser copy.
*/
-export const DRAFT_KEY = "libredb_source_drafts_v1";
+export const DRAFT_KEY = SOURCE_DRAFTS_KEY;
/**
* 1,048,576 characters, which is 20 percent of the measured origin ceiling, and it is counted as
diff --git a/src/hooks/use-auth.ts b/src/hooks/use-auth.ts
index 0f46deef4..a8b7c2bc7 100644
--- a/src/hooks/use-auth.ts
+++ b/src/hooks/use-auth.ts
@@ -6,6 +6,7 @@ import { useState, useEffect, useCallback } from "react";
import { useRouter } from "next/navigation";
import { useToast } from "@/hooks/use-toast";
import { logger } from "@/lib/logger";
+import { releaseAccountWorkspace } from "@/lib/storage/sign-out";
interface AuthUser {
role?: string;
@@ -47,6 +48,10 @@ export function useAuth() {
const handleLogout = useCallback(async () => {
try {
+ // In server storage mode the browser copy belongs to the signed-in account: it is pushed
+ // and cleared while the session cookie is still valid. The session-ended path above clears
+ // nothing; the next sign-in's owner check in useStorageSync covers a different account.
+ await releaseAccountWorkspace();
const res = await appFetch("/api/auth/logout", { method: "POST" });
const data = await res.json();
toast({ title: "Logged out", description: "You have been successfully logged out." });
diff --git a/src/hooks/use-storage-sync.ts b/src/hooks/use-storage-sync.ts
index 5fb3b0e19..3ddbde500 100644
--- a/src/hooks/use-storage-sync.ts
+++ b/src/hooks/use-storage-sync.ts
@@ -9,9 +9,14 @@ import {
type StorageData,
STORAGE_COLLECTIONS,
} from "@/lib/storage";
+import {
+ clearAccountWorkspace,
+ SERVER_MIGRATED_KEY as MIGRATION_FLAG,
+ WORKSPACE_OWNER_KEY,
+} from "@/lib/storage/local-storage";
+import { registerPendingPush } from "@/lib/storage/sign-out";
import { logger } from "@/lib/logger";
-const MIGRATION_FLAG = "libredb_server_migrated";
const DEBOUNCE_MS = 500;
/** First retry delay after a failed push; doubles per consecutive failure. */
const RETRY_BASE_MS = 1000;
@@ -33,6 +38,8 @@ export interface StorageSyncState {
* - Discovers storage mode via GET /api/storage/config
* - In server mode: pulls data on mount, pushes mutations (debounced)
* - Handles first-login migration from localStorage to server
+ * - Binds the browser copy to the signed-in account (server mode): a copy another account
+ * left behind is cleared before anything is migrated, pulled or pushed
* - Graceful degradation: if server unreachable, localStorage continues
*/
export function useStorageSync(): StorageSyncState {
@@ -57,6 +64,10 @@ export function useStorageSync(): StorageSyncState {
const pendingCollectionsRef = useRef>(new Set());
const retryDelayRef = useRef(RETRY_BASE_MS);
const serverModeRef = useRef(false);
+ /** The push in flight, so a sign-out can wait for it while the session cookie is still valid. */
+ const inFlightRef = useRef | null>(null);
+ /** Set once a sign-out cleared the browser copy: nothing may push the empty copy after that. */
+ const signedOutRef = useRef(false);
/**
* Whether this hook is still mounted. A push that is in flight when the user
* navigates away resolves AFTER teardown, and the failure branch would arm a
@@ -104,9 +115,11 @@ export function useStorageSync(): StorageSyncState {
setIsSyncing(true);
try {
- const outcomes = await Promise.all(
+ const pushes = Promise.all(
collections.map(async (col) => [col, await pushToServer(col, getCollectionData(col))] as const),
);
+ inFlightRef.current = pushes;
+ const outcomes = await pushes;
// A collection that failed to push is still only in localStorage. Putting
// it back in the queue is what keeps the write recoverable — without this
@@ -145,6 +158,7 @@ export function useStorageSync(): StorageSyncState {
// ── Schedule debounced push ──
const schedulePush = useCallback(
(collection: string) => {
+ if (signedOutRef.current) return;
pendingCollectionsRef.current.add(collection);
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
@@ -258,26 +272,43 @@ export function useStorageSync(): StorageSyncState {
let cancelled = false;
async function init() {
+ let ready = true;
try {
const res = await appFetch("/api/storage/config");
if (!res.ok || cancelled) return;
const config = (await res.json()) as StorageConfigResponse;
if (config.serverMode && !cancelled) {
+ // The browser copy belongs to the signed-in account. Without the account's name the
+ // copy cannot be matched to it, so it is neither used nor pushed: isReady stays false.
+ let username: string;
+ try {
+ username = await readSignedInUsername();
+ } catch (err) {
+ ready = false;
+ const message = err instanceof Error ? err.message : String(err);
+ logger.warn("StorageSync could not read the signed-in account", { error: message });
+ setSyncError(message);
+ return;
+ }
+ if (cancelled) return;
+
setIsServerMode(true);
serverModeRef.current = true;
+ claimBrowserCopy(username);
// Migration first, then pull
await migrateToServer();
if (!cancelled) {
await pullFromServer();
}
+ localStorage.setItem(WORKSPACE_OWNER_KEY, username);
}
} catch {
// Server unreachable — stay in local mode
logger.debug("Storage server unreachable, staying in local mode");
} finally {
- if (!cancelled) {
+ if (!cancelled && ready) {
setIsReady(true);
}
}
@@ -289,6 +320,21 @@ export function useStorageSync(): StorageSyncState {
};
}, [migrateToServer, pullFromServer]);
+ // ── Sign-out: push what is pending while the session cookie is still valid ──
+ useEffect(() => {
+ if (!isServerMode) return;
+ return registerPendingPush(async () => {
+ if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current);
+ if (retryTimerRef.current) clearTimeout(retryTimerRef.current);
+ await inFlightRef.current;
+ await flushPending();
+ if (pendingCollectionsRef.current.size > 0) {
+ throw new Error("Unsaved changes could not be saved to server storage");
+ }
+ signedOutRef.current = true;
+ });
+ }, [isServerMode, flushPending]);
+
// ── Listen for storage mutations ──
useEffect(() => {
if (!isServerMode) return;
@@ -311,6 +357,31 @@ export function useStorageSync(): StorageSyncState {
// ── Helpers ──
+/** The signed-in account's username, from GET /api/auth/me. Throws when it cannot be read. */
+async function readSignedInUsername(): Promise {
+ const res = await appFetch("/api/auth/me");
+ if (!res.ok) throw new Error(`Could not read the signed-in account: HTTP ${res.status}`);
+ const body = (await res.json()) as { user?: { username?: unknown } };
+ const username = body.user?.username;
+ if (typeof username !== "string" || username === "") {
+ throw new Error("Could not read the signed-in account: no username");
+ }
+ return username;
+}
+
+/**
+ * Keep the browser copy only for the account it belongs to. The same owner keeps it; a copy
+ * with no owner that was never handed to a server account is local-mode data, migrated into
+ * this account as before (docs/STORAGE.md). Anything else starts from this account's server
+ * data, so the copy is cleared before the migration and the pull run.
+ */
+function claimBrowserCopy(username: string): void {
+ const owner = localStorage.getItem(WORKSPACE_OWNER_KEY);
+ if (owner === username) return;
+ if (owner === null && localStorage.getItem(MIGRATION_FLAG) === null) return;
+ clearAccountWorkspace();
+}
+
/** Read a collection's current data from the storage facade */
function getCollectionData(collection: string): unknown {
switch (collection) {
diff --git a/src/lib/storage/local-storage.ts b/src/lib/storage/local-storage.ts
index 964453e18..3d786d915 100644
--- a/src/lib/storage/local-storage.ts
+++ b/src/lib/storage/local-storage.ts
@@ -5,6 +5,7 @@
*/
import { logger } from "@/lib/logger";
+import { STORAGE_COLLECTIONS } from "./types";
const KEY_PREFIX = "libredb_";
@@ -115,3 +116,48 @@ export function removeWorkspaceTabs(connectionId: string): void {
if (!isClient()) return;
localStorage.removeItem(workspaceTabsKey(connectionId));
}
+
+/**
+ * The signed-in username the browser copy belongs to. Written in server storage mode only, by
+ * `useStorageSync`, which compares it with the signed-in account before it uses the copy.
+ */
+export const WORKSPACE_OWNER_KEY = `${KEY_PREFIX}workspace_owner`;
+
+/** Set once this browser's copy has been handed to a server account (`useStorageSync`). */
+export const SERVER_MIGRATED_KEY = `${KEY_PREFIX}server_migrated`;
+
+/** Unsaved object-source edits (`components/object-source/source-drafts.ts`). */
+export const SOURCE_DRAFTS_KEY = `${KEY_PREFIX}source_drafts_v1`;
+
+/** The agent conversation this browser was last in (`components/agent/use-agent-run.ts`). */
+export const AGENT_THREAD_KEY = `${KEY_PREFIX}agent_thread`;
+
+/**
+ * Every key that holds the signed-in account's browser copy: the synced collections, the
+ * unsynced drafts and hints, and the two keys that say whose copy it is. Per-browser
+ * preferences (theme, line numbers, the star prompt counters) are not on it.
+ */
+const ACCOUNT_KEYS: readonly string[] = [
+ ...STORAGE_COLLECTIONS.map(getKey),
+ SOURCE_DRAFTS_KEY,
+ AGENT_THREAD_KEY,
+ SERVER_MIGRATED_KEY,
+ WORKSPACE_OWNER_KEY,
+];
+
+/**
+ * Remove the signed-in account's browser copy, editor tabs included, and nothing else. In
+ * server storage mode the browser copy belongs to the signed-in account: signing out clears
+ * it, and signing in as a different account starts from that account's server data.
+ */
+export function clearAccountWorkspace(): void {
+ if (!isClient()) return;
+ for (const key of ACCOUNT_KEYS) localStorage.removeItem(key);
+ // Collected first: removing while walking by index would skip the key after each removal.
+ const tabKeys: string[] = [];
+ for (let index = 0; index < localStorage.length; index++) {
+ const key = localStorage.key(index);
+ if (key?.startsWith(`${WORKSPACE_TABS_KEY_PREFIX}:`)) tabKeys.push(key);
+ }
+ for (const key of tabKeys) localStorage.removeItem(key);
+}
diff --git a/src/lib/storage/sign-out.ts b/src/lib/storage/sign-out.ts
new file mode 100644
index 000000000..08d6e39e4
--- /dev/null
+++ b/src/lib/storage/sign-out.ts
@@ -0,0 +1,42 @@
+/**
+ * What an explicit sign-out does to this browser's copy of the workspace.
+ *
+ * In server storage mode the browser copy belongs to the signed-in account, so signing out
+ * pushes whatever is still waiting to be pushed, while the session cookie is still valid, and
+ * then clears the copy. In local mode the browser copy is the only one and stays.
+ *
+ * Every sign-out path calls `releaseAccountWorkspace()` before POST /api/auth/logout. Only the
+ * Studio page mounts `useStorageSync`, which registers its pending push here while it is
+ * mounted; the admin dashboard and the launch page have nothing pending and only clear.
+ */
+
+import { appFetch } from "@/lib/config/base-path";
+import { clearAccountWorkspace } from "./local-storage";
+import type { StorageConfigResponse } from "./types";
+
+/** Pushes every pending collection; rejects when one of them did not reach the server. */
+type PendingPush = () => Promise;
+
+let pendingPush: PendingPush | null = null;
+
+/** Register the mounted sync's pending push. Returns the unregister function. */
+export function registerPendingPush(push: PendingPush): () => void {
+ pendingPush = push;
+ return () => {
+ if (pendingPush === push) pendingPush = null;
+ };
+}
+
+/**
+ * Server mode: push what is pending, then clear this browser's copy. Local mode: nothing.
+ * Rejects, and clears nothing, when the storage mode cannot be read or a pending push does not
+ * land, so the caller reports a failed sign-out instead of dropping unsaved changes.
+ */
+export async function releaseAccountWorkspace(): Promise {
+ const res = await appFetch("/api/storage/config");
+ if (!res.ok) throw new Error(`Storage mode unavailable: HTTP ${res.status}`);
+ const config = (await res.json()) as StorageConfigResponse;
+ if (!config.serverMode) return;
+ if (pendingPush) await pendingPush();
+ clearAccountWorkspace();
+}
diff --git a/tests/components/LaunchPage.test.tsx b/tests/components/LaunchPage.test.tsx
index 8a4093e03..aaac5a24a 100644
--- a/tests/components/LaunchPage.test.tsx
+++ b/tests/components/LaunchPage.test.tsx
@@ -285,9 +285,12 @@ describe("the /launch page in a browser signed in as someone else", () => {
launchFor: "bob@example.com",
};
- function serveConflict(logout: () => Promise): ReturnType {
+ function serveConflict(logout: () => Promise, serverMode = false): ReturnType {
const fetchMock = mock(async (url: string) => {
if (String(url).endsWith("/api/auth/me")) return SIGNED_IN();
+ if (String(url).endsWith("/api/storage/config")) {
+ return answer(200, { provider: serverMode ? "postgres" : "local", serverMode });
+ }
return String(url).endsWith("/api/auth/logout") ? logout() : answer(409, CONFLICT);
});
globalThis.fetch = fetchMock as never;
@@ -318,7 +321,7 @@ describe("the /launch page in a browser signed in as someone else", () => {
expect(
await view.findByText("You are signed out. Open Studio again from the platform to continue as bob@example.com."),
).not.toBeNull();
- const [url, init] = fetchMock.mock.calls[2] as unknown as [string, RequestInit];
+ const [url, init] = fetchMock.mock.calls[3] as unknown as [string, RequestInit];
expect(url).toBe("/api/auth/logout");
expect(init.method).toBe("POST");
});
@@ -336,4 +339,45 @@ describe("the /launch page in a browser signed in as someone else", () => {
).not.toBeNull();
expect(view.getByRole("link", { name: "Stay signed in as ada@example.com" })).not.toBeNull();
});
+
+ describe("this browser's copy of the workspace", () => {
+ beforeEach(() => {
+ localStorage.clear();
+ localStorage.setItem("libredb_connections", JSON.stringify([{ id: "c1" }]));
+ localStorage.setItem("libredb_workspace_owner", "ada@example.com");
+ });
+
+ afterEach(() => {
+ localStorage.clear();
+ });
+
+ test("server mode: the copy is cleared before the session ends", async () => {
+ let atLogout: string | null = "not called";
+ serveConflict(async () => {
+ atLogout = localStorage.getItem("libredb_connections");
+ return answer(200, { success: true });
+ }, true);
+ const view = render();
+ fireEvent.click(await view.findByRole("button", { name: "Sign out" }));
+ expect(
+ await view.findByText(
+ "You are signed out. Open Studio again from the platform to continue as bob@example.com.",
+ ),
+ ).not.toBeNull();
+ expect(atLogout).toBeNull();
+ expect(localStorage.getItem("libredb_workspace_owner")).toBeNull();
+ });
+
+ test("local mode: the copy stays", async () => {
+ serveConflict(async () => answer(200, { success: true }));
+ const view = render();
+ fireEvent.click(await view.findByRole("button", { name: "Sign out" }));
+ expect(
+ await view.findByText(
+ "You are signed out. Open Studio again from the platform to continue as bob@example.com.",
+ ),
+ ).not.toBeNull();
+ expect(localStorage.getItem("libredb_connections")).not.toBeNull();
+ });
+ });
});
diff --git a/tests/components/admin/AdminDashboard.test.tsx b/tests/components/admin/AdminDashboard.test.tsx
index 1b5145041..9a3f844da 100644
--- a/tests/components/admin/AdminDashboard.test.tsx
+++ b/tests/components/admin/AdminDashboard.test.tsx
@@ -45,7 +45,10 @@ describe("AdminDashboard", () => {
mockRouterRefresh.mockClear();
mockToastSuccess.mockClear();
setMockPathname("/admin/overview");
- mockGlobalFetch({ "/api/auth/logout": { json: { success: true } } });
+ mockGlobalFetch({
+ "/api/auth/logout": { json: { success: true } },
+ "/api/storage/config": { json: { provider: "local", serverMode: false } },
+ });
});
test("renders admin dashboard title", async () => {
@@ -148,6 +151,7 @@ describe("AdminDashboard", () => {
"/api/auth/logout": {
json: { success: true, redirectUrl: "https://idp.example.com/logout?client_id=abc" },
},
+ "/api/storage/config": { json: { provider: "local", serverMode: false } },
});
let renderResult: ReturnType;
@@ -173,4 +177,52 @@ describe("AdminDashboard", () => {
Object.defineProperty(window, "location", savedDescriptor);
}
});
+
+ describe("this browser's copy of the workspace", () => {
+ beforeEach(() => {
+ localStorage.clear();
+ localStorage.setItem("libredb_connections", JSON.stringify([{ id: "c1" }]));
+ localStorage.setItem("libredb_workspace_owner", "admin@libredb.org");
+ });
+
+ afterEach(() => {
+ localStorage.clear();
+ });
+
+ async function clickLogout() {
+ let renderResult: ReturnType;
+ await act(async () => {
+ renderResult = render(content);
+ });
+ await act(async () => {
+ fireEvent.click(renderResult!.getByText("Logout").closest("button")!);
+ });
+ await waitFor(() => {
+ expect(mockRouterPush).toHaveBeenCalledWith("/login");
+ });
+ }
+
+ test("server mode: the copy is cleared before the session ends", async () => {
+ let atLogout: string | null = "not called";
+ mockGlobalFetch({
+ "/api/auth/logout": () => {
+ atLogout = localStorage.getItem("libredb_connections");
+ return { json: { success: true } };
+ },
+ "/api/storage/config": { json: { provider: "postgres", serverMode: true } },
+ });
+
+ await clickLogout();
+
+ expect(atLogout).toBeNull();
+ expect(localStorage.getItem("libredb_workspace_owner")).toBeNull();
+ });
+
+ test("local mode: the copy stays", async () => {
+ await clickLogout();
+
+ expect(localStorage.getItem("libredb_connections")).not.toBeNull();
+ expect(localStorage.getItem("libredb_workspace_owner")).toBe("admin@libredb.org");
+ });
+ });
});
diff --git a/tests/hooks/use-auth.test.ts b/tests/hooks/use-auth.test.ts
index 3c121885c..70469274f 100644
--- a/tests/hooks/use-auth.test.ts
+++ b/tests/hooks/use-auth.test.ts
@@ -8,6 +8,7 @@ import { renderHook, act, waitFor } from "@testing-library/react";
import { mockGlobalFetch, restoreGlobalFetch } from "../helpers/mock-fetch";
import { useAuth } from "@/hooks/use-auth";
+import { registerPendingPush } from "@/lib/storage/sign-out";
// =============================================================================
// useAuth Tests
@@ -88,6 +89,7 @@ describe("useAuth", () => {
test("handleLogout calls /api/auth/logout with POST method", async () => {
const fetchMock = mockGlobalFetch({
"/api/auth/me": { ok: true, json: { user: { role: "user" } } },
+ "/api/storage/config": { ok: true, json: { provider: "local", serverMode: false } },
"/api/auth/logout": { ok: true, json: { success: true } },
});
@@ -112,6 +114,7 @@ describe("useAuth", () => {
test('handleLogout calls router.push("/login") and router.refresh()', async () => {
mockGlobalFetch({
"/api/auth/me": { ok: true, json: { user: { role: "user" } } },
+ "/api/storage/config": { ok: true, json: { provider: "local", serverMode: false } },
"/api/auth/logout": { ok: true, json: { success: true } },
});
@@ -132,6 +135,7 @@ describe("useAuth", () => {
test("handleLogout shows success toast on success", async () => {
mockGlobalFetch({
"/api/auth/me": { ok: true, json: { user: { role: "user" } } },
+ "/api/storage/config": { ok: true, json: { provider: "local", serverMode: false } },
"/api/auth/logout": { ok: true, json: { success: true } },
});
@@ -154,6 +158,7 @@ describe("useAuth", () => {
test("handleLogout shows destructive toast on error", async () => {
mockGlobalFetch({
"/api/auth/me": { ok: true, json: { user: { role: "user" } } },
+ "/api/storage/config": { ok: true, json: { provider: "local", serverMode: false } },
});
// Override fetch so logout throws a network error
@@ -296,6 +301,12 @@ describe("useAuth", () => {
globalThis.fetch = (async (input: RequestInfo | URL) => {
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
if (url.includes("/api/auth/me")) return mePromise;
+ if (url.includes("/api/storage/config")) {
+ return new Response(JSON.stringify({ provider: "local", serverMode: false }), {
+ status: 200,
+ headers: { "content-type": "application/json" },
+ });
+ }
if (url.includes("/api/auth/logout")) {
return new Response(JSON.stringify({ success: true }), {
status: 200,
@@ -341,6 +352,7 @@ describe("useAuth", () => {
mockGlobalFetch({
"/api/auth/me": { ok: true, json: { user: { role: "user" } } },
+ "/api/storage/config": { ok: true, json: { provider: "local", serverMode: false } },
"/api/auth/logout": {
ok: true,
json: { success: true, redirectUrl: "https://auth0.com/v2/logout?client_id=abc" },
@@ -372,6 +384,7 @@ describe("useAuth", () => {
test("handleLogout navigates even if logout API returns non-ok", async () => {
mockGlobalFetch({
"/api/auth/me": { ok: true, json: { user: { role: "user" } } },
+ "/api/storage/config": { ok: true, json: { provider: "local", serverMode: false } },
"/api/auth/logout": { ok: false, status: 500, json: { error: "Server error" } },
});
@@ -393,6 +406,7 @@ describe("useAuth", () => {
const fetchMock = mockGlobalFetch({
"/~/libredb/api/auth/me": { json: { user: { role: "user" } } },
"/~/libredb/api/auth/logout": { json: { success: true } },
+ "/~/libredb/api/storage/config": { json: { provider: "local", serverMode: false } },
});
const { result, unmount } = renderHook(() => useAuth());
try {
@@ -407,4 +421,92 @@ describe("useAuth", () => {
}
});
});
+
+ // ── Sign-out and this browser's copy of the workspace ─────────────────────
+
+ describe("sign-out and the browser copy", () => {
+ beforeEach(() => {
+ localStorage.clear();
+ localStorage.setItem("libredb_connections", JSON.stringify([{ id: "c1" }]));
+ localStorage.setItem("libredb_workspace_tabs_v1:c1", "[]");
+ localStorage.setItem("libredb_workspace_owner", "user@libredb.org");
+ });
+
+ afterEach(() => {
+ localStorage.clear();
+ });
+
+ test("server mode: pending pushes go out, then the copy is cleared, then the session ends", async () => {
+ const order: string[] = [];
+ mockGlobalFetch({
+ "/api/auth/me": { ok: true, json: { user: { role: "user" } } },
+ "/api/storage/config": { ok: true, json: { provider: "postgres", serverMode: true } },
+ "/api/auth/logout": () => {
+ order.push(`logout:${localStorage.getItem("libredb_connections")}`);
+ return { ok: true, json: { success: true } };
+ },
+ });
+ const unregister = registerPendingPush(async () => {
+ order.push(`push:${localStorage.getItem("libredb_connections")}`);
+ });
+
+ const { result } = renderHook(() => useAuth());
+ await waitFor(() => {
+ expect(result.current.user).not.toBeNull();
+ });
+ await act(async () => {
+ await result.current.handleLogout();
+ });
+ unregister();
+
+ expect(order).toEqual([`push:${JSON.stringify([{ id: "c1" }])}`, "logout:null"]);
+ expect(localStorage.getItem("libredb_workspace_tabs_v1:c1")).toBeNull();
+ expect(localStorage.getItem("libredb_workspace_owner")).toBeNull();
+ expect(mockRouterPush).toHaveBeenCalledWith("/login");
+ });
+
+ test("server mode: a push that does not land keeps the session and the copy", async () => {
+ const fetchMock = mockGlobalFetch({
+ "/api/auth/me": { ok: true, json: { user: { role: "user" } } },
+ "/api/storage/config": { ok: true, json: { provider: "postgres", serverMode: true } },
+ "/api/auth/logout": { ok: true, json: { success: true } },
+ });
+ const unregister = registerPendingPush(async () => {
+ throw new Error("Unsaved changes could not be saved to server storage");
+ });
+
+ const { result } = renderHook(() => useAuth());
+ await waitFor(() => {
+ expect(result.current.user).not.toBeNull();
+ });
+ await act(async () => {
+ await result.current.handleLogout();
+ });
+ unregister();
+
+ expect(fetchMock.mock.calls.some((call) => String(call[0]).includes("/api/auth/logout"))).toBe(false);
+ expect(localStorage.getItem("libredb_connections")).not.toBeNull();
+ expect(mockToastError).toHaveBeenCalledWith("Error", { description: "Failed to logout." });
+ });
+
+ test("local mode: the copy stays", async () => {
+ mockGlobalFetch({
+ "/api/auth/me": { ok: true, json: { user: { role: "user" } } },
+ "/api/storage/config": { ok: true, json: { provider: "local", serverMode: false } },
+ "/api/auth/logout": { ok: true, json: { success: true } },
+ });
+
+ const { result } = renderHook(() => useAuth());
+ await waitFor(() => {
+ expect(result.current.user).not.toBeNull();
+ });
+ await act(async () => {
+ await result.current.handleLogout();
+ });
+
+ expect(localStorage.getItem("libredb_connections")).not.toBeNull();
+ expect(localStorage.getItem("libredb_workspace_tabs_v1:c1")).toBe("[]");
+ expect(mockRouterPush).toHaveBeenCalledWith("/login");
+ });
+ });
});
diff --git a/tests/isolated/use-storage-sync.test.ts b/tests/isolated/use-storage-sync.test.ts
index f5b8b7494..e183fca93 100644
--- a/tests/isolated/use-storage-sync.test.ts
+++ b/tests/isolated/use-storage-sync.test.ts
@@ -48,9 +48,18 @@ mock.module("@/lib/storage", () => ({
}));
import { useStorageSync } from "@/hooks/use-storage-sync";
+import { releaseAccountWorkspace } from "@/lib/storage/sign-out";
+import { workspaceTabsKey } from "@/lib/storage/local-storage";
// ── Helpers ─────────────────────────────────────────────────────────────────
+const USERNAME = "user@libredb.org";
+
+/** GET /api/auth/me for the signed-in account; every server-mode route map carries it. */
+const SIGNED_IN = {
+ "/api/auth/me": { ok: true, status: 200, json: { authenticated: true, user: { username: USERNAME, role: "user" } } },
+};
+
function setupLocalMode() {
return mockGlobalFetch({
"/api/storage/config": { ok: true, status: 200, json: { provider: "local", serverMode: false } },
@@ -60,6 +69,7 @@ function setupLocalMode() {
function setupServerMode(extraRoutes: Record = {}) {
return mockGlobalFetch({
"/api/storage/config": { ok: true, status: 200, json: { provider: "postgres", serverMode: true } },
+ ...SIGNED_IN,
"/api/storage/migrate": { ok: true, status: 200, json: { ok: true, migrated: ["connections"] } },
"/api/storage": { ok: true, status: 200, json: { connections: [{ id: "server-c1" }] } },
...extraRoutes,
@@ -252,6 +262,7 @@ describe("useStorageSync", () => {
mockStorage.getConnections.mockReturnValue([{ id: "test" }]);
mockGlobalFetch({
"/api/storage/config": { ok: true, status: 200, json: { provider: "postgres", serverMode: true } },
+ ...SIGNED_IN,
"/api/storage/migrate": () => {
throw new Error("migrate down");
},
@@ -342,6 +353,7 @@ describe("useStorageSync", () => {
localStorage.setItem("libredb_server_migrated", "true");
mockGlobalFetch({
"/api/storage/config": { ok: true, status: 200, json: { provider: "postgres", serverMode: true } },
+ ...SIGNED_IN,
"/api/storage": { ok: false, status: 500, json: { error: "DB error" } },
});
@@ -360,6 +372,7 @@ describe("useStorageSync", () => {
localStorage.setItem("libredb_server_migrated", "true");
mockGlobalFetch({
"/api/storage/config": { ok: true, status: 200, json: { provider: "postgres", serverMode: true } },
+ ...SIGNED_IN,
"/api/storage": () => {
throw new Error("pull down");
},
@@ -465,6 +478,7 @@ describe("useStorageSync", () => {
localStorage.setItem("libredb_server_migrated", "true");
const fetchMock = mockGlobalFetch({
"/api/storage/config": { ok: true, status: 200, json: { provider: "postgres", serverMode: true } },
+ ...SIGNED_IN,
"/api/storage/migrate": { ok: true, status: 200, json: { ok: true, migrated: [] } },
"/api/storage": { ok: true, status: 200, json: {} },
"/api/storage/connections": { ok: true, status: 200, json: { ok: true } },
@@ -509,6 +523,7 @@ describe("useStorageSync", () => {
let attempts = 0;
const fetchMock = mockGlobalFetch({
"/api/storage/config": { ok: true, status: 200, json: { provider: "postgres", serverMode: true } },
+ ...SIGNED_IN,
"/api/storage/migrate": { ok: true, status: 200, json: { ok: true, migrated: [] } },
"/api/storage/connections": () => {
attempts += 1;
@@ -556,6 +571,7 @@ describe("useStorageSync", () => {
let attempts = 0;
mockGlobalFetch({
"/api/storage/config": { ok: true, status: 200, json: { provider: "postgres", serverMode: true } },
+ ...SIGNED_IN,
"/api/storage/migrate": { ok: true, status: 200, json: { ok: true, migrated: [] } },
"/api/storage/connections": () => {
attempts += 1;
@@ -636,6 +652,7 @@ describe("useStorageSync", () => {
mockGlobalFetch({
"/api/storage/config": { ok: true, status: 200, json: { provider: "postgres", serverMode: true } },
+ ...SIGNED_IN,
"/api/storage/migrate": { ok: true, status: 200, json: { ok: true, migrated: [] } },
"/api/storage/connections": async () => {
connectionsAttempts += 1;
@@ -703,6 +720,7 @@ describe("useStorageSync", () => {
mockGlobalFetch({
"/api/storage/config": { ok: true, status: 200, json: { provider: "postgres", serverMode: true } },
+ ...SIGNED_IN,
"/api/storage/migrate": { ok: true, status: 200, json: { ok: true, migrated: [] } },
"/api/storage/connections": async () => {
attempts += 1;
@@ -738,6 +756,7 @@ describe("useStorageSync", () => {
// Use a request handler that returns 500 specifically for PUT /connections
const fetchMock = mockGlobalFetch({
"/api/storage/config": { ok: true, status: 200, json: { provider: "postgres", serverMode: true } },
+ ...SIGNED_IN,
"/api/storage/migrate": { ok: true, status: 200, json: { ok: true, migrated: [] } },
"/api/storage/connections": { ok: false, status: 500, json: { error: "Write failed" } },
"/api/storage": { ok: true, status: 200, json: {} },
@@ -910,4 +929,311 @@ describe("useStorageSync", () => {
expect(result.current.lastSyncedAt).toBeInstanceOf(Date);
});
});
+
+ // ── The browser copy belongs to the signed-in account ─────────────────
+
+ describe("the browser copy belongs to the signed-in account", () => {
+ const PREVIOUS = "admin@libredb.org";
+ const FORM_CONNECTION = { id: "form-1", name: "Warehouse", password: "previous-account-password" };
+
+ /** The previous account's browser copy, as a sign-in on this browser finds it. */
+ function seedPreviousCopy(owner: string | null, migrated: boolean) {
+ localStorage.setItem("libredb_connections", JSON.stringify([FORM_CONNECTION]));
+ localStorage.setItem("libredb_history", JSON.stringify([{ id: "h1", query: "SELECT 1" }]));
+ localStorage.setItem("libredb_active_connection_id", FORM_CONNECTION.id);
+ localStorage.setItem(workspaceTabsKey(FORM_CONNECTION.id), JSON.stringify([{ id: "t1", query: "SELECT 2" }]));
+ if (owner !== null) localStorage.setItem("libredb_workspace_owner", owner);
+ if (migrated) localStorage.setItem("libredb_server_migrated", "2026-10-01");
+ }
+
+ /** Every request body the hook sent: PUT /api/storage/* and POST /api/storage/migrate. */
+ function sentBodies(fetchMock: FetchMock): string[] {
+ return (fetchMock.mock.calls as unknown[][])
+ .map((c) => (c[1] as RequestInit | undefined)?.body)
+ .filter((body): body is string => typeof body === "string");
+ }
+
+ beforeEach(() => {
+ // The facade reads the browser copy itself, so a push carries exactly what is stored.
+ mockStorage.getConnections.mockImplementation(() =>
+ JSON.parse(localStorage.getItem("libredb_connections") ?? "[]"),
+ );
+ });
+
+ afterEach(() => {
+ mockStorage.getConnections.mockImplementation(() => [{ id: "c1" }]);
+ });
+
+ test("signing in as a different account starts from that account's server data", async () => {
+ seedPreviousCopy(PREVIOUS, true);
+ const fetchMock = setupServerMode({ "/api/storage": { ok: true, status: 200, json: {} } });
+
+ const { result } = renderHook(() => useStorageSync());
+ await waitFor(() => {
+ expect(result.current.isReady).toBe(true);
+ });
+
+ expect(localStorage.getItem("libredb_connections")).toBeNull();
+ expect(localStorage.getItem("libredb_history")).toBeNull();
+ expect(localStorage.getItem("libredb_active_connection_id")).toBeNull();
+ expect(localStorage.getItem(workspaceTabsKey(FORM_CONNECTION.id))).toBeNull();
+ expect(localStorage.getItem("libredb_workspace_owner")).toBe(USERNAME);
+ expect(calledPaths(fetchMock)).not.toContain("/api/storage/migrate");
+
+ // A change after the sign-in pushes this account's copy, never the previous one.
+ act(() => {
+ window.dispatchEvent(new CustomEvent("libredb-storage-change", { detail: { collection: "connections" } }));
+ });
+ await waitFor(
+ () => {
+ expect(calledPaths(fetchMock)).toContain("/api/storage/connections");
+ },
+ { timeout: 3000 },
+ );
+ expect(sentBodies(fetchMock).some((body) => body.includes(FORM_CONNECTION.password))).toBe(false);
+ });
+
+ test("a copy with no owner that was already handed to a server account is cleared", async () => {
+ seedPreviousCopy(null, true);
+ const fetchMock = setupServerMode({ "/api/storage": { ok: true, status: 200, json: {} } });
+
+ const { result } = renderHook(() => useStorageSync());
+ await waitFor(() => {
+ expect(result.current.isReady).toBe(true);
+ });
+
+ expect(localStorage.getItem("libredb_connections")).toBeNull();
+ expect(localStorage.getItem("libredb_history")).toBeNull();
+ expect(localStorage.getItem("libredb_active_connection_id")).toBeNull();
+ expect(localStorage.getItem(workspaceTabsKey(FORM_CONNECTION.id))).toBeNull();
+ expect(localStorage.getItem("libredb_workspace_owner")).toBe(USERNAME);
+ expect(sentBodies(fetchMock).some((body) => body.includes(FORM_CONNECTION.password))).toBe(false);
+ });
+
+ test("a local-mode copy never handed to a server account is migrated into the signed-in account", async () => {
+ seedPreviousCopy(null, false);
+ const fetchMock = setupServerMode({ "/api/storage": { ok: true, status: 200, json: {} } });
+
+ const { result } = renderHook(() => useStorageSync());
+ await waitFor(() => {
+ expect(result.current.isReady).toBe(true);
+ });
+
+ expect(calledPaths(fetchMock)).toContain("/api/storage/migrate");
+ expect(sentBodies(fetchMock).some((body) => body.includes(FORM_CONNECTION.id))).toBe(true);
+ expect(JSON.parse(localStorage.getItem("libredb_connections")!)).toEqual([FORM_CONNECTION]);
+ expect(localStorage.getItem(workspaceTabsKey(FORM_CONNECTION.id))).not.toBeNull();
+ expect(localStorage.getItem("libredb_workspace_owner")).toBe(USERNAME);
+ });
+
+ test("the same account keeps its copy, and a collection the server lacks stays local", async () => {
+ seedPreviousCopy(USERNAME, true);
+ const fetchMock = setupServerMode({
+ "/api/storage": { ok: true, status: 200, json: { history: [{ id: "server-h1", query: "SELECT 3" }] } },
+ });
+
+ const { result } = renderHook(() => useStorageSync());
+ await waitFor(() => {
+ expect(result.current.isReady).toBe(true);
+ });
+
+ expect(JSON.parse(localStorage.getItem("libredb_connections")!)).toEqual([FORM_CONNECTION]);
+ expect(JSON.parse(localStorage.getItem("libredb_history")!)).toEqual([{ id: "server-h1", query: "SELECT 3" }]);
+ expect(localStorage.getItem("libredb_active_connection_id")).toBe(FORM_CONNECTION.id);
+ expect(localStorage.getItem(workspaceTabsKey(FORM_CONNECTION.id))).not.toBeNull();
+ expect(localStorage.getItem("libredb_workspace_owner")).toBe(USERNAME);
+ expect(calledPaths(fetchMock)).not.toContain("/api/storage/migrate");
+ });
+
+ test("an unreadable signed-in account leaves the copy unused and unpushed", async () => {
+ seedPreviousCopy(PREVIOUS, true);
+ const fetchMock = mockGlobalFetch({
+ "/api/storage/config": { ok: true, status: 200, json: { provider: "postgres", serverMode: true } },
+ "/api/auth/me": { ok: false, status: 503, json: { error: "down" } },
+ "/api/storage": { ok: true, status: 200, json: {} },
+ });
+
+ const { result } = renderHook(() => useStorageSync());
+ await waitFor(() => {
+ expect(result.current.syncError).not.toBeNull();
+ });
+
+ act(() => {
+ window.dispatchEvent(new CustomEvent("libredb-storage-change", { detail: { collection: "connections" } }));
+ });
+ await act(async () => await new Promise((r) => setTimeout(r, 700)));
+
+ expect(result.current.isReady).toBe(false);
+ expect(result.current.isServerMode).toBe(false);
+ expect(
+ calledPaths(fetchMock).filter((p) => p.startsWith("/api/storage/") && p !== "/api/storage/config"),
+ ).toEqual([]);
+ expect(calledPaths(fetchMock)).not.toContain("/api/storage");
+ expect(localStorage.getItem("libredb_workspace_owner")).toBe(PREVIOUS);
+ });
+
+ test("a signed-in answer without a username counts as unreadable", async () => {
+ seedPreviousCopy(PREVIOUS, true);
+ mockGlobalFetch({
+ "/api/storage/config": { ok: true, status: 200, json: { provider: "postgres", serverMode: true } },
+ "/api/auth/me": { ok: true, status: 200, json: { authenticated: true, user: {} } },
+ "/api/storage": { ok: true, status: 200, json: {} },
+ });
+
+ const { result } = renderHook(() => useStorageSync());
+ await waitFor(() => {
+ expect(result.current.syncError).not.toBeNull();
+ });
+ expect(result.current.isReady).toBe(false);
+ });
+
+ test("local mode clears nothing and records no owner", async () => {
+ seedPreviousCopy(PREVIOUS, true);
+ setupLocalMode();
+
+ const { result } = renderHook(() => useStorageSync());
+ await waitFor(() => {
+ expect(result.current.isReady).toBe(true);
+ });
+
+ expect(JSON.parse(localStorage.getItem("libredb_connections")!)).toEqual([FORM_CONNECTION]);
+ expect(localStorage.getItem(workspaceTabsKey(FORM_CONNECTION.id))).not.toBeNull();
+ expect(localStorage.getItem("libredb_workspace_owner")).toBe(PREVIOUS);
+ });
+
+ test("local mode on a fresh browser writes no owner", async () => {
+ setupLocalMode();
+
+ const { result } = renderHook(() => useStorageSync());
+ await waitFor(() => {
+ expect(result.current.isReady).toBe(true);
+ });
+
+ expect(localStorage.getItem("libredb_workspace_owner")).toBeNull();
+ });
+ });
+
+ // ── Sign-out ──────────────────────────────────────────────────────────
+
+ describe("sign-out", () => {
+ /** Server mode with the collection routes ahead of the catch-all `/api/storage` pull. */
+ function setupSignOut(collectionRoutes: Parameters[0]) {
+ return mockGlobalFetch({
+ "/api/storage/config": { ok: true, status: 200, json: { provider: "postgres", serverMode: true } },
+ ...SIGNED_IN,
+ ...collectionRoutes,
+ });
+ }
+
+ test("pushes a pending change before the browser copy is cleared", async () => {
+ localStorage.setItem("libredb_workspace_owner", USERNAME);
+ localStorage.setItem("libredb_server_migrated", "2026-10-01");
+ const order: string[] = [];
+ setupSignOut({
+ "/api/storage/history": () => {
+ order.push(`push:${localStorage.getItem("libredb_workspace_owner")}`);
+ return { ok: true, status: 200, json: { ok: true } };
+ },
+ "/api/storage": { ok: true, status: 200, json: {} },
+ });
+
+ const { result } = renderHook(() => useStorageSync());
+ await waitFor(() => {
+ expect(result.current.isReady).toBe(true);
+ });
+
+ act(() => {
+ window.dispatchEvent(new CustomEvent("libredb-storage-change", { detail: { collection: "history" } }));
+ });
+ // Inside the debounce window: the push is still pending when the sign-out starts.
+ await act(async () => {
+ await releaseAccountWorkspace();
+ });
+ order.push(`cleared:${localStorage.getItem("libredb_workspace_owner")}`);
+
+ expect(order).toEqual([`push:${USERNAME}`, "cleared:null"]);
+ });
+
+ test("waits for a push already in flight before the browser copy is cleared", async () => {
+ localStorage.setItem("libredb_workspace_owner", USERNAME);
+ localStorage.setItem("libredb_server_migrated", "2026-10-01");
+ let started = false;
+ let landed = false;
+ setupSignOut({
+ "/api/storage/history": async () => {
+ started = true;
+ await new Promise((r) => setTimeout(r, 150));
+ landed = true;
+ return { ok: true, status: 200, json: { ok: true } };
+ },
+ "/api/storage": { ok: true, status: 200, json: {} },
+ });
+
+ const { result } = renderHook(() => useStorageSync());
+ await waitFor(() => {
+ expect(result.current.isReady).toBe(true);
+ });
+
+ act(() => {
+ window.dispatchEvent(new CustomEvent("libredb-storage-change", { detail: { collection: "history" } }));
+ });
+ await waitFor(() => {
+ expect(started).toBe(true);
+ });
+ await act(async () => {
+ await releaseAccountWorkspace();
+ });
+
+ expect(landed).toBe(true);
+ expect(localStorage.getItem("libredb_workspace_owner")).toBeNull();
+ });
+
+ test("a pending push that does not land fails the sign-out and keeps the copy", async () => {
+ localStorage.setItem("libredb_workspace_owner", USERNAME);
+ localStorage.setItem("libredb_server_migrated", "2026-10-01");
+ setupSignOut({
+ "/api/storage/history": { ok: false, status: 500, json: { error: "Write failed" } },
+ "/api/storage": { ok: true, status: 200, json: {} },
+ });
+
+ const { result } = renderHook(() => useStorageSync());
+ await waitFor(() => {
+ expect(result.current.isReady).toBe(true);
+ });
+
+ act(() => {
+ window.dispatchEvent(new CustomEvent("libredb-storage-change", { detail: { collection: "history" } }));
+ });
+ let failure: unknown = null;
+ await act(async () => {
+ await releaseAccountWorkspace().catch((err: unknown) => {
+ failure = err;
+ });
+ });
+
+ expect(failure).toBeInstanceOf(Error);
+ expect(localStorage.getItem("libredb_workspace_owner")).toBe(USERNAME);
+ });
+
+ test("pushes nothing after the browser copy is cleared", async () => {
+ localStorage.setItem("libredb_workspace_owner", USERNAME);
+ localStorage.setItem("libredb_server_migrated", "2026-10-01");
+ const fetchMock = setupServerMode({ "/api/storage": { ok: true, status: 200, json: {} } });
+
+ const { result } = renderHook(() => useStorageSync());
+ await waitFor(() => {
+ expect(result.current.isReady).toBe(true);
+ });
+
+ await act(async () => {
+ await releaseAccountWorkspace();
+ });
+ act(() => {
+ window.dispatchEvent(new CustomEvent("libredb-storage-change", { detail: { collection: "connections" } }));
+ });
+ await act(async () => await new Promise((r) => setTimeout(r, 700)));
+
+ expect(calledPaths(fetchMock)).not.toContain("/api/storage/connections");
+ });
+ });
});
diff --git a/tests/unit/lib/storage/local-storage.test.ts b/tests/unit/lib/storage/local-storage.test.ts
index 829358040..b479a5d77 100644
--- a/tests/unit/lib/storage/local-storage.test.ts
+++ b/tests/unit/lib/storage/local-storage.test.ts
@@ -5,7 +5,21 @@ if (typeof globalThis.window === "undefined") {
globalThis.window = globalThis;
}
-import { readJSON, writeJSON, readString, writeString, remove, getKey } from "@/lib/storage/local-storage";
+import {
+ readJSON,
+ writeJSON,
+ readString,
+ writeString,
+ remove,
+ getKey,
+ clearAccountWorkspace,
+ workspaceTabsKey,
+ WORKSPACE_OWNER_KEY,
+ SERVER_MIGRATED_KEY,
+ SOURCE_DRAFTS_KEY,
+ AGENT_THREAD_KEY,
+} from "@/lib/storage/local-storage";
+import { STORAGE_COLLECTIONS } from "@/lib/storage/types";
describe("local-storage: getKey", () => {
test("maps known collection names to libredb_ prefix keys", () => {
@@ -116,3 +130,47 @@ describe("local-storage: remove", () => {
expect(readString("active_connection_id")).toBeNull();
});
});
+
+describe("local-storage: clearAccountWorkspace", () => {
+ beforeEach(() => {
+ localStorage.clear();
+ });
+
+ test("the account keys are named once, with their stored spelling", () => {
+ expect(WORKSPACE_OWNER_KEY).toBe("libredb_workspace_owner");
+ expect(SERVER_MIGRATED_KEY).toBe("libredb_server_migrated");
+ expect(SOURCE_DRAFTS_KEY).toBe("libredb_source_drafts_v1");
+ expect(AGENT_THREAD_KEY).toBe("libredb_agent_thread");
+ });
+
+ test("removes every key the signed-in account's browser copy is held under", () => {
+ for (const collection of STORAGE_COLLECTIONS) localStorage.setItem(getKey(collection), "x");
+ localStorage.setItem(workspaceTabsKey("c1"), "[]");
+ localStorage.setItem(workspaceTabsKey("seed:sample"), "[]");
+ localStorage.setItem(SOURCE_DRAFTS_KEY, "{}");
+ localStorage.setItem(AGENT_THREAD_KEY, "{}");
+ localStorage.setItem(SERVER_MIGRATED_KEY, "2026-10-07");
+ localStorage.setItem(WORKSPACE_OWNER_KEY, "admin@libredb.org");
+
+ clearAccountWorkspace();
+
+ expect(localStorage.length).toBe(0);
+ });
+
+ test("keeps the per-browser preferences", () => {
+ const preferences = {
+ "editor-line-numbers": "false",
+ "libredb-theme": "dark",
+ libredb_star_prompt_query_count: "3",
+ libredb_star_prompt_handled: "1",
+ "another-app": "kept",
+ };
+ for (const [key, value] of Object.entries(preferences)) localStorage.setItem(key, value);
+ localStorage.setItem(getKey("connections"), "[]");
+
+ clearAccountWorkspace();
+
+ expect(localStorage.getItem(getKey("connections"))).toBeNull();
+ for (const [key, value] of Object.entries(preferences)) expect(localStorage.getItem(key)).toBe(value);
+ });
+});
diff --git a/tests/unit/lib/storage/sign-out.test.ts b/tests/unit/lib/storage/sign-out.test.ts
new file mode 100644
index 000000000..356dcda35
--- /dev/null
+++ b/tests/unit/lib/storage/sign-out.test.ts
@@ -0,0 +1,102 @@
+import { describe, test, expect, beforeEach, afterEach } from "bun:test";
+
+if (typeof globalThis.window === "undefined") {
+ // @ts-expect-error — minimal window stub
+ globalThis.window = globalThis;
+}
+
+import { mockGlobalFetch, restoreGlobalFetch } from "../../../helpers/mock-fetch";
+import { registerPendingPush, releaseAccountWorkspace } from "@/lib/storage/sign-out";
+import { WORKSPACE_OWNER_KEY } from "@/lib/storage/local-storage";
+
+function serverMode(serverModeOn: boolean) {
+ return mockGlobalFetch({
+ "/api/storage/config": {
+ ok: true,
+ status: 200,
+ json: { provider: serverModeOn ? "postgres" : "local", serverMode: serverModeOn },
+ },
+ });
+}
+
+describe("releaseAccountWorkspace", () => {
+ beforeEach(() => {
+ localStorage.clear();
+ localStorage.setItem("libredb_connections", JSON.stringify([{ id: "c1", password: "pw" }]));
+ localStorage.setItem(WORKSPACE_OWNER_KEY, "admin@libredb.org");
+ });
+
+ afterEach(() => {
+ restoreGlobalFetch();
+ });
+
+ test("server mode: pushes the pending collections first, then clears this browser's copy", async () => {
+ serverMode(true);
+ const seen: (string | null)[] = [];
+ const unregister = registerPendingPush(async () => {
+ seen.push(localStorage.getItem("libredb_connections"));
+ });
+
+ await releaseAccountWorkspace();
+ unregister();
+
+ expect(seen).toEqual([JSON.stringify([{ id: "c1", password: "pw" }])]);
+ expect(localStorage.getItem("libredb_connections")).toBeNull();
+ expect(localStorage.getItem(WORKSPACE_OWNER_KEY)).toBeNull();
+ });
+
+ test("server mode with no sync mounted: clears this browser's copy", async () => {
+ serverMode(true);
+ await releaseAccountWorkspace();
+ expect(localStorage.getItem("libredb_connections")).toBeNull();
+ });
+
+ test("server mode: a push that does not land keeps the copy and fails the sign-out", async () => {
+ serverMode(true);
+ const unregister = registerPendingPush(async () => {
+ throw new Error("Unsaved changes could not be saved");
+ });
+
+ await expect(releaseAccountWorkspace()).rejects.toThrow("Unsaved changes could not be saved");
+ unregister();
+
+ expect(localStorage.getItem("libredb_connections")).not.toBeNull();
+ });
+
+ test("local mode: nothing is pushed and nothing is cleared", async () => {
+ serverMode(false);
+ let pushed = false;
+ const unregister = registerPendingPush(async () => {
+ pushed = true;
+ });
+
+ await releaseAccountWorkspace();
+ unregister();
+
+ expect(pushed).toBe(false);
+ expect(localStorage.getItem("libredb_connections")).not.toBeNull();
+ expect(localStorage.getItem(WORKSPACE_OWNER_KEY)).toBe("admin@libredb.org");
+ });
+
+ test("an unreadable storage mode fails the sign-out and clears nothing", async () => {
+ mockGlobalFetch({ "/api/storage/config": { ok: false, status: 500, json: { error: "down" } } });
+
+ await expect(releaseAccountWorkspace()).rejects.toThrow("HTTP 500");
+ expect(localStorage.getItem("libredb_connections")).not.toBeNull();
+ });
+
+ test("unregistering a push that was replaced leaves the newer one in place", async () => {
+ serverMode(true);
+ let newer = 0;
+ const unregisterOlder = registerPendingPush(async () => {});
+ const unregisterNewer = registerPendingPush(async () => {
+ newer += 1;
+ });
+ unregisterOlder();
+
+ await releaseAccountWorkspace();
+ unregisterNewer();
+
+ expect(newer).toBe(1);
+ });
+});
From 3b24cce57ba45b43505d6f7e5a5eb48e0cb793dc Mon Sep 17 00:00:00 2001
From: cevheri
Date: Wed, 7 Oct 2026 11:17:31 +0300
Subject: [PATCH 02/28] docs(storage): describe the browser copy of the
signed-in account
STORAGE.md and SECURITY.md state that in server mode the browser copy belongs to the signed-in account and sign-out clears it, and that in local mode the next account on the same browser profile sees and can open the connections saved there.
---
docs/SECURITY.md | 2 ++
docs/STORAGE.md | 42 ++++++++++++++++++++++++++++++++++++++----
2 files changed, 40 insertions(+), 4 deletions(-)
diff --git a/docs/SECURITY.md b/docs/SECURITY.md
index 510260d77..9a31007de 100644
--- a/docs/SECURITY.md
+++ b/docs/SECURITY.md
@@ -582,6 +582,8 @@ These are real, current, and not oversights. Each is a decision with a reason.
- **Browser `localStorage` holds your credentials in plaintext.** It is the rendering source, and
encrypting it would require a master password and a recovery flow, changing what the product is.
This is why 0.1 and 1.1 matter as much as they do.
+ In server storage mode the browser copy belongs to the signed-in account and is cleared on sign-out.
+ In local mode it stays, and the next account to sign in on that browser profile sees and can open the connections saved there.
- **Anyone who can read the server's environment can read the stored credentials.** 3.1 protects a
stolen database file or dump on its own; it is not a vault. For `STORAGE_PROVIDER=sqlite` with no
`STORAGE_ENCRYPTION_KEY` configured, that protection does not extend to a backup or volume
diff --git a/docs/STORAGE.md b/docs/STORAGE.md
index 14ab92d4e..91d6de6a7 100644
--- a/docs/STORAGE.md
+++ b/docs/STORAGE.md
@@ -57,7 +57,9 @@ LibreDB Studio supports three storage modes. Pick the one that fits your use cas
Server storage (`sqlite` or `postgres`) gives each person their own private workspace by partitioning connections, saved queries, and settings by the login username.
For a team of three or more, pair server storage with Single Sign-On via [OIDC](OIDC.md) to provide individual logins, personal workspaces, and an audit trail that attributes actions to each user.
+In server storage the browser copy belongs to the signed-in account: signing out clears this browser's copy, open editor tabs included, and signing in as a different account starts from that account's server data.
The default `local` mode stores data in the browser and does not isolate workspaces between people using the same browser profile.
+In `local` mode the next account to sign in on that browser profile sees, and can open, the connections saved there before it, saved credentials included.
---
@@ -449,6 +451,7 @@ When you switch from local mode to SQLite or PostgreSQL, **existing browser data
**No manual steps required.** Just change the env var and restart.
> If multiple users were sharing a browser in local mode, only the data from the user who migrates first will be sent. Each user's server storage is isolated by their login email.
+> From then on the browser copy belongs to that account (`libredb_workspace_owner`), and a different account signing in on the same browser starts from its own server data.
For the full migration lifecycle and the underlying merge semantics, see [Migration Flow](#10-migration-flow) in Part 2.
@@ -1203,6 +1206,17 @@ App Mount
│ └─ serverMode: true ──┐
│ │
│ ┌──────────────────────▼──────────────────────┐
+ │ │ Owner: GET /api/auth/me → username │
+ │ │ ├─ Unreadable → syncError, isReady stays │
+ │ │ │ false, nothing is pulled or pushed │
+ │ │ ├─ libredb_workspace_owner = username │
+ │ │ │ → keep the browser copy │
+ │ │ ├─ No owner, never migrated → keep it │
+ │ │ │ (local-mode data, migrated below) │
+ │ │ └─ Otherwise → clear the browser copy │
+ │ └──────────────────────┬──────────────────────┘
+ │ │
+ │ ┌──────────────────────▼──────────────────────┐
│ │ Check libredb_server_migrated flag │
│ │ ├─ Not migrated → POST /api/storage/migrate│
│ │ │ (send all localStorage → server merge) │
@@ -1213,6 +1227,7 @@ App Mount
│ ┌──────────────────────▼──────────────────────┐
│ │ Pull: GET /api/storage │
│ │ → Write server data into localStorage │
+ │ │ → Set libredb_workspace_owner = username │
│ │ → Components re-render from localStorage │
│ └──────────────────────┬──────────────────────┘
│ │
@@ -1237,6 +1252,21 @@ When any `storage.*` mutation fires:
- Reads each pending collection from `localStorage`
- Sends `PUT /api/storage/[collection]` for each
+### The Browser Copy Belongs to the Signed-In Account
+
+In server mode the browser copy is bound to the account that signed in: `libredb_workspace_owner` holds its username, and the lifecycle above compares it with `GET /api/auth/me` before anything is migrated, pulled or pushed.
+The copy is every synced collection, the editor tabs (never stored on the server), the object-source drafts, the agent thread hint, the migration flag and the owner key itself; per-browser preferences such as the theme and line numbers are not part of it.
+`clearAccountWorkspace()` in `src/lib/storage/local-storage.ts` holds that list.
+
+- **Same account:** the copy is kept, and the pull overwrites each collection the server holds; a collection the server does not hold yet stays local.
+- **A different account, or a copy with no owner that was already migrated:** the copy is cleared first, so the account starts from its own server data.
+- **A copy with no owner that was never migrated:** local-mode data, migrated into the signed-in account as described in [Migration Flow](#10-migration-flow).
+- **Sign-out** (the editor, the admin dashboard and the launch page): pending collections are pushed while the session is still valid, then the copy is cleared, then `POST /api/auth/logout` ends the session.
+ When a pending push does not land, the sign-out fails and the copy stays, so no unsaved change is dropped.
+- **A session that ended on its own** (expiry, a disabled account) clears nothing; the owner check at the next sign-in covers a different account.
+
+In local mode none of this runs: the browser copy is the only copy, it stays on sign-out, and no owner is recorded.
+
### Graceful Degradation
- If `/api/storage/config` fails → stays in localStorage-only mode
@@ -1251,16 +1281,20 @@ When a user first enables server mode (or a new user logs in for the first time)
```
1. Hook detects serverMode = true
-2. Checks localStorage('libredb_server_migrated') flag
-3. If not migrated:
+2. Reads the signed-in username (GET /api/auth/me) and clears a browser copy that belongs to another account
+3. Checks localStorage('libredb_server_migrated') flag
+4. If not migrated:
a. Reads whichever of the 13 collections exist in localStorage (a fresh browser with none simply sets the flag and skips)
b. POST /api/storage/migrate with the collected payload
c. Server calls provider.mergeData() — upserts each collection as a whole blob in one transaction
d. Sets 'libredb_server_migrated' flag in localStorage
-4. Pull: GET /api/storage → overwrite localStorage with server data
-5. Subsequent mutations sync normally via push
+5. Pull: GET /api/storage → overwrite localStorage with server data
+6. Sets 'libredb_workspace_owner' to the username
+7. Subsequent mutations sync normally via push
```
+Step 2 is described in [The Browser Copy Belongs to the Signed-In Account](#the-browser-copy-belongs-to-the-signed-in-account).
+
This ensures existing localStorage data is preserved when transitioning to server mode.
> For the operator-facing summary of this behavior, see [Migration: Local to Server](#migration-local-to-server) in Part 1.
From 9246498a661027985b7b2a425e94b299fc6dcf99 Mon Sep 17 00:00:00 2001
From: cevheri
Date: Wed, 7 Oct 2026 11:34:03 +0300
Subject: [PATCH 03/28] fix(storage): clear the browser copy once the session
has ended
Signing out pushes what is pending, ends the session, and only then clears this browser's copy, so a sign-out that does not happen keeps the signed-in account's copy and resumes the sync. The cleared copy stays marked as handed to a server account, so anything written to it afterwards is cleared at the next sign-in instead of being migrated into that account. The admin dashboard reports a sign-out that cannot complete.
---
src/app/launch/launch-client.tsx | 9 +-
src/components/admin/AdminDashboard.tsx | 14 +-
src/hooks/use-auth.ts | 8 +-
src/hooks/use-storage-sync.ts | 68 ++++-----
src/lib/storage/local-storage.ts | 27 ++++
src/lib/storage/sign-out.ts | 62 +++++---
src/lib/storage/workspace-owner.ts | 40 +++++
tests/components/LaunchPage.test.tsx | 5 +-
.../components/admin/AdminDashboard.test.tsx | 29 +++-
tests/hooks/use-auth.test.ts | 41 ++++-
tests/isolated/use-storage-sync.test.ts | 140 +++++++++++++++++-
tests/unit/lib/storage/local-storage.test.ts | 76 ++++++++++
tests/unit/lib/storage/sign-out.test.ts | 138 +++++++++++++----
.../unit/lib/storage/workspace-owner.test.ts | 86 +++++++++++
14 files changed, 621 insertions(+), 122 deletions(-)
create mode 100644 src/lib/storage/workspace-owner.ts
create mode 100644 tests/unit/lib/storage/workspace-owner.test.ts
diff --git a/src/app/launch/launch-client.tsx b/src/app/launch/launch-client.tsx
index 6e41f25ab..29b794c20 100644
--- a/src/app/launch/launch-client.tsx
+++ b/src/app/launch/launch-client.tsx
@@ -112,10 +112,11 @@ function SessionConflict({ signedInAs, launchFor }: Conflict) {
const [state, setState] = useState<"asking" | "signed-out" | "failed">("asking");
async function signOut() {
- // In server storage mode the browser copy belongs to the signed-in account and is cleared first.
- const response = await releaseAccountWorkspace()
- .then(() => appFetch("/api/auth/logout", { method: "POST" }))
- .catch(() => null);
+ // In server storage mode the browser copy belongs to the signed-in account and is cleared once
+ // the session ended.
+ const response = await releaseAccountWorkspace(() => appFetch("/api/auth/logout", { method: "POST" })).catch(
+ () => null,
+ );
setState(response?.ok ? "signed-out" : "failed");
}
diff --git a/src/components/admin/AdminDashboard.tsx b/src/components/admin/AdminDashboard.tsx
index 2a84aab8a..c0006fac2 100644
--- a/src/components/admin/AdminDashboard.tsx
+++ b/src/components/admin/AdminDashboard.tsx
@@ -30,10 +30,16 @@ export default function AdminDashboard({ children }: AdminDashboardProps) {
const activeSection = adminSectionFromPathname(pathname);
const handleLogout = async () => {
- // In server storage mode the browser copy belongs to the signed-in account and is cleared first.
- await releaseAccountWorkspace();
- const res = await appFetch("/api/auth/logout", { method: "POST" });
- const data = await res.json();
+ // In server storage mode the browser copy belongs to the signed-in account and is cleared once
+ // the session ended.
+ let data: { redirectUrl?: string };
+ try {
+ const res = await releaseAccountWorkspace(() => appFetch("/api/auth/logout", { method: "POST" }));
+ data = await res.json();
+ } catch {
+ toast.error("Failed to logout");
+ return;
+ }
toast.success("Logged out successfully");
// In OIDC mode the route answers with the provider's end_session URL. The local cookie is gone
diff --git a/src/hooks/use-auth.ts b/src/hooks/use-auth.ts
index a8b7c2bc7..e6e31ba34 100644
--- a/src/hooks/use-auth.ts
+++ b/src/hooks/use-auth.ts
@@ -49,10 +49,10 @@ export function useAuth() {
const handleLogout = useCallback(async () => {
try {
// In server storage mode the browser copy belongs to the signed-in account: it is pushed
- // and cleared while the session cookie is still valid. The session-ended path above clears
- // nothing; the next sign-in's owner check in useStorageSync covers a different account.
- await releaseAccountWorkspace();
- const res = await appFetch("/api/auth/logout", { method: "POST" });
+ // while the session cookie is still valid and cleared once the session ended. The
+ // session-ended path above clears nothing; the owner check every page that reads the copy
+ // runs at the next sign-in covers a different account.
+ const res = await releaseAccountWorkspace(() => appFetch("/api/auth/logout", { method: "POST" }));
const data = await res.json();
toast({ title: "Logged out", description: "You have been successfully logged out." });
diff --git a/src/hooks/use-storage-sync.ts b/src/hooks/use-storage-sync.ts
index 3ddbde500..075dde1de 100644
--- a/src/hooks/use-storage-sync.ts
+++ b/src/hooks/use-storage-sync.ts
@@ -10,11 +10,12 @@ import {
STORAGE_COLLECTIONS,
} from "@/lib/storage";
import {
- clearAccountWorkspace,
+ claimAccountWorkspace,
SERVER_MIGRATED_KEY as MIGRATION_FLAG,
WORKSPACE_OWNER_KEY,
} from "@/lib/storage/local-storage";
-import { registerPendingPush } from "@/lib/storage/sign-out";
+import { registerWorkspaceSync } from "@/lib/storage/sign-out";
+import { readSignedInUsername } from "@/lib/storage/workspace-owner";
import { logger } from "@/lib/logger";
const DEBOUNCE_MS = 500;
@@ -66,8 +67,11 @@ export function useStorageSync(): StorageSyncState {
const serverModeRef = useRef(false);
/** The push in flight, so a sign-out can wait for it while the session cookie is still valid. */
const inFlightRef = useRef | null>(null);
- /** Set once a sign-out cleared the browser copy: nothing may push the empty copy after that. */
- const signedOutRef = useRef(false);
+ /**
+ * Set while a sign-out is under way and after it: changes are queued but not pushed, so nothing
+ * pushes the copy once it is cleared. A sign-out that did not happen resets it (`resume`).
+ */
+ const signingOutRef = useRef(false);
/**
* Whether this hook is still mounted. A push that is in flight when the user
* navigates away resolves AFTER teardown, and the failure branch would arm a
@@ -158,8 +162,8 @@ export function useStorageSync(): StorageSyncState {
// ── Schedule debounced push ──
const schedulePush = useCallback(
(collection: string) => {
- if (signedOutRef.current) return;
pendingCollectionsRef.current.add(collection);
+ if (signingOutRef.current) return;
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
@@ -296,7 +300,7 @@ export function useStorageSync(): StorageSyncState {
setIsServerMode(true);
serverModeRef.current = true;
- claimBrowserCopy(username);
+ claimAccountWorkspace(username);
// Migration first, then pull
await migrateToServer();
if (!cancelled) {
@@ -323,17 +327,24 @@ export function useStorageSync(): StorageSyncState {
// ── Sign-out: push what is pending while the session cookie is still valid ──
useEffect(() => {
if (!isServerMode) return;
- return registerPendingPush(async () => {
- if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current);
- if (retryTimerRef.current) clearTimeout(retryTimerRef.current);
- await inFlightRef.current;
- await flushPending();
- if (pendingCollectionsRef.current.size > 0) {
- throw new Error("Unsaved changes could not be saved to server storage");
- }
- signedOutRef.current = true;
+ return registerWorkspaceSync({
+ flush: async () => {
+ if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current);
+ if (retryTimerRef.current) clearTimeout(retryTimerRef.current);
+ await inFlightRef.current;
+ await flushPending();
+ if (pendingCollectionsRef.current.size > 0) {
+ throw new Error("Unsaved changes could not be saved to server storage");
+ }
+ signingOutRef.current = true;
+ },
+ resume: () => {
+ signingOutRef.current = false;
+ const held = Array.from(pendingCollectionsRef.current);
+ for (const collection of held) schedulePush(collection);
+ },
});
- }, [isServerMode, flushPending]);
+ }, [isServerMode, flushPending, schedulePush]);
// ── Listen for storage mutations ──
useEffect(() => {
@@ -357,31 +368,6 @@ export function useStorageSync(): StorageSyncState {
// ── Helpers ──
-/** The signed-in account's username, from GET /api/auth/me. Throws when it cannot be read. */
-async function readSignedInUsername(): Promise {
- const res = await appFetch("/api/auth/me");
- if (!res.ok) throw new Error(`Could not read the signed-in account: HTTP ${res.status}`);
- const body = (await res.json()) as { user?: { username?: unknown } };
- const username = body.user?.username;
- if (typeof username !== "string" || username === "") {
- throw new Error("Could not read the signed-in account: no username");
- }
- return username;
-}
-
-/**
- * Keep the browser copy only for the account it belongs to. The same owner keeps it; a copy
- * with no owner that was never handed to a server account is local-mode data, migrated into
- * this account as before (docs/STORAGE.md). Anything else starts from this account's server
- * data, so the copy is cleared before the migration and the pull run.
- */
-function claimBrowserCopy(username: string): void {
- const owner = localStorage.getItem(WORKSPACE_OWNER_KEY);
- if (owner === username) return;
- if (owner === null && localStorage.getItem(MIGRATION_FLAG) === null) return;
- clearAccountWorkspace();
-}
-
/** Read a collection's current data from the storage facade */
function getCollectionData(collection: string): unknown {
switch (collection) {
diff --git a/src/lib/storage/local-storage.ts b/src/lib/storage/local-storage.ts
index 3d786d915..7561fae70 100644
--- a/src/lib/storage/local-storage.ts
+++ b/src/lib/storage/local-storage.ts
@@ -161,3 +161,30 @@ export function clearAccountWorkspace(): void {
}
for (const key of tabKeys) localStorage.removeItem(key);
}
+
+/**
+ * Clear the browser copy and mark it as handed to a server account, so `claimAccountWorkspace`
+ * never takes what is written to it afterwards for local-mode data. `owner` is the account the
+ * emptied copy now belongs to, or null after a sign-out.
+ */
+export function resetAccountWorkspace(owner: string | null): void {
+ if (!isClient()) return;
+ clearAccountWorkspace();
+ localStorage.setItem(SERVER_MIGRATED_KEY, new Date().toISOString());
+ if (owner !== null) localStorage.setItem(WORKSPACE_OWNER_KEY, owner);
+}
+
+/**
+ * Server storage mode: keep the browser copy only for the account it belongs to, before anything
+ * reads it. The same owner keeps it. A copy with no owner that was never handed to a server
+ * account is local-mode data, kept for `useStorageSync` to migrate into this account
+ * (docs/STORAGE.md). Anything else is cleared, and the signed-in account starts from its own
+ * server data.
+ */
+export function claimAccountWorkspace(username: string): void {
+ if (!isClient()) return;
+ const owner = localStorage.getItem(WORKSPACE_OWNER_KEY);
+ if (owner === username) return;
+ if (owner === null && localStorage.getItem(SERVER_MIGRATED_KEY) === null) return;
+ resetAccountWorkspace(username);
+}
diff --git a/src/lib/storage/sign-out.ts b/src/lib/storage/sign-out.ts
index 08d6e39e4..c169c2fc2 100644
--- a/src/lib/storage/sign-out.ts
+++ b/src/lib/storage/sign-out.ts
@@ -2,41 +2,63 @@
* What an explicit sign-out does to this browser's copy of the workspace.
*
* In server storage mode the browser copy belongs to the signed-in account, so signing out
- * pushes whatever is still waiting to be pushed, while the session cookie is still valid, and
- * then clears the copy. In local mode the browser copy is the only one and stays.
+ * pushes whatever is still waiting to be pushed while the session cookie is still valid, ends
+ * the session, and only then clears the copy: a sign-out that does not happen leaves the
+ * signed-in account's copy in place. In local mode the browser copy is the only one and stays.
*
- * Every sign-out path calls `releaseAccountWorkspace()` before POST /api/auth/logout. Only the
- * Studio page mounts `useStorageSync`, which registers its pending push here while it is
- * mounted; the admin dashboard and the launch page have nothing pending and only clear.
+ * Every sign-out path goes through `releaseAccountWorkspace()` with its POST /api/auth/logout.
+ * Only the Studio page mounts `useStorageSync`, which registers itself here while it is mounted;
+ * the admin dashboard and the launch page have nothing pending and only clear.
*/
import { appFetch } from "@/lib/config/base-path";
-import { clearAccountWorkspace } from "./local-storage";
+import { resetAccountWorkspace } from "./local-storage";
import type { StorageConfigResponse } from "./types";
-/** Pushes every pending collection; rejects when one of them did not reach the server. */
-type PendingPush = () => Promise;
+/** The mounted sync, as a sign-out sees it. */
+export interface WorkspaceSync {
+ /** Pushes every pending collection and holds later changes; rejects when one did not land. */
+ flush(): Promise;
+ /** Pushes again: the sign-out did not happen and the session goes on. */
+ resume(): void;
+}
-let pendingPush: PendingPush | null = null;
+let mountedSync: WorkspaceSync | null = null;
-/** Register the mounted sync's pending push. Returns the unregister function. */
-export function registerPendingPush(push: PendingPush): () => void {
- pendingPush = push;
+/** Register the mounted sync. Returns the unregister function. */
+export function registerWorkspaceSync(sync: WorkspaceSync): () => void {
+ mountedSync = sync;
return () => {
- if (pendingPush === push) pendingPush = null;
+ if (mountedSync === sync) mountedSync = null;
};
}
/**
- * Server mode: push what is pending, then clear this browser's copy. Local mode: nothing.
- * Rejects, and clears nothing, when the storage mode cannot be read or a pending push does not
- * land, so the caller reports a failed sign-out instead of dropping unsaved changes.
+ * Server mode: push what is pending, run `signOut`, and once it succeeded clear this browser's
+ * copy, marked so nothing written to it afterwards is migrated into the next account. Local
+ * mode: only `signOut`. Rejects, before `signOut` runs, when the storage mode cannot be read or a
+ * pending push does not land, so the caller reports a failed sign-out instead of dropping unsaved
+ * changes. A refused or failed `signOut` keeps the copy and resumes the sync.
*/
-export async function releaseAccountWorkspace(): Promise {
+export async function releaseAccountWorkspace(signOut: () => Promise): Promise {
const res = await appFetch("/api/storage/config");
if (!res.ok) throw new Error(`Storage mode unavailable: HTTP ${res.status}`);
const config = (await res.json()) as StorageConfigResponse;
- if (!config.serverMode) return;
- if (pendingPush) await pendingPush();
- clearAccountWorkspace();
+ if (!config.serverMode) return signOut();
+
+ const sync = mountedSync;
+ if (sync) await sync.flush();
+ let response: Response;
+ try {
+ response = await signOut();
+ } catch (err) {
+ sync?.resume();
+ throw err;
+ }
+ if (!response.ok) {
+ sync?.resume();
+ return response;
+ }
+ resetAccountWorkspace(null);
+ return response;
}
diff --git a/src/lib/storage/workspace-owner.ts b/src/lib/storage/workspace-owner.ts
new file mode 100644
index 000000000..d00f68b78
--- /dev/null
+++ b/src/lib/storage/workspace-owner.ts
@@ -0,0 +1,40 @@
+/**
+ * Whose browser copy this is, in server storage mode. The copy belongs to the signed-in account:
+ * every page that reads it (the editor, the admin dashboard, monitoring) first makes it the
+ * signed-in account's (`claimAccountWorkspace`), so a different account starts from its own
+ * server data.
+ */
+
+import { appFetch } from "@/lib/config/base-path";
+import { claimAccountWorkspace } from "./local-storage";
+import type { StorageConfigResponse } from "./types";
+
+/** The signed-in account's username, from GET /api/auth/me. Throws when it cannot be read. */
+export async function readSignedInUsername(): Promise {
+ const res = await appFetch("/api/auth/me");
+ if (!res.ok) throw new Error(`Could not read the signed-in account: HTTP ${res.status}`);
+ const body = (await res.json()) as { user?: { username?: unknown } };
+ const username = body.user?.username;
+ if (typeof username !== "string" || username === "") {
+ throw new Error("Could not read the signed-in account: no username");
+ }
+ return username;
+}
+
+/**
+ * Server mode: make the browser copy the signed-in account's before a page reads it. Local mode:
+ * nothing. A storage mode that cannot be read counts as local mode, as `useStorageSync` counts
+ * it. Throws in server mode when the signed-in account cannot be read: the copy must not be used.
+ */
+export async function claimWorkspaceForSignedInAccount(): Promise {
+ let config: StorageConfigResponse;
+ try {
+ const res = await appFetch("/api/storage/config");
+ if (!res.ok) return;
+ config = (await res.json()) as StorageConfigResponse;
+ } catch {
+ return;
+ }
+ if (!config.serverMode) return;
+ claimAccountWorkspace(await readSignedInUsername());
+}
diff --git a/tests/components/LaunchPage.test.tsx b/tests/components/LaunchPage.test.tsx
index aaac5a24a..85229f1c0 100644
--- a/tests/components/LaunchPage.test.tsx
+++ b/tests/components/LaunchPage.test.tsx
@@ -351,7 +351,7 @@ describe("the /launch page in a browser signed in as someone else", () => {
localStorage.clear();
});
- test("server mode: the copy is cleared before the session ends", async () => {
+ test("server mode: the session ends, then the copy is cleared", async () => {
let atLogout: string | null = "not called";
serveConflict(async () => {
atLogout = localStorage.getItem("libredb_connections");
@@ -364,7 +364,8 @@ describe("the /launch page in a browser signed in as someone else", () => {
"You are signed out. Open Studio again from the platform to continue as bob@example.com.",
),
).not.toBeNull();
- expect(atLogout).toBeNull();
+ expect(atLogout).toBe(JSON.stringify([{ id: "c1" }]));
+ expect(localStorage.getItem("libredb_connections")).toBeNull();
expect(localStorage.getItem("libredb_workspace_owner")).toBeNull();
});
diff --git a/tests/components/admin/AdminDashboard.test.tsx b/tests/components/admin/AdminDashboard.test.tsx
index 9a3f844da..7a325f5e6 100644
--- a/tests/components/admin/AdminDashboard.test.tsx
+++ b/tests/components/admin/AdminDashboard.test.tsx
@@ -26,7 +26,7 @@ import { render, fireEvent, waitFor, act, cleanup } from "@testing-library/react
import { mockGlobalFetch, restoreGlobalFetch } from "../../helpers/mock-fetch";
import { mockRouterPush, mockRouterRefresh, setMockPathname, resetMockPathname } from "../../helpers/mock-navigation";
-import { mockToastSuccess } from "../../helpers/mock-sonner";
+import { mockToastError, mockToastSuccess } from "../../helpers/mock-sonner";
const { default: AdminDashboard } = await import("@/components/admin/AdminDashboard");
@@ -202,7 +202,7 @@ describe("AdminDashboard", () => {
});
}
- test("server mode: the copy is cleared before the session ends", async () => {
+ test("server mode: the session ends, then the copy is cleared", async () => {
let atLogout: string | null = "not called";
mockGlobalFetch({
"/api/auth/logout": () => {
@@ -214,10 +214,33 @@ describe("AdminDashboard", () => {
await clickLogout();
- expect(atLogout).toBeNull();
+ expect(atLogout).toBe(JSON.stringify([{ id: "c1" }]));
+ expect(localStorage.getItem("libredb_connections")).toBeNull();
expect(localStorage.getItem("libredb_workspace_owner")).toBeNull();
});
+ test("a sign-out that cannot complete says so and keeps the copy", async () => {
+ mockToastError.mockClear();
+ mockGlobalFetch({
+ "/api/auth/logout": { json: { success: true } },
+ "/api/storage/config": { ok: false, status: 503, json: { error: "down" } },
+ });
+
+ let renderResult: ReturnType;
+ await act(async () => {
+ renderResult = render(content);
+ });
+ await act(async () => {
+ fireEvent.click(renderResult!.getByText("Logout").closest("button")!);
+ });
+ await waitFor(() => {
+ expect(mockToastError).toHaveBeenCalledWith("Failed to logout");
+ });
+
+ expect(mockRouterPush).not.toHaveBeenCalled();
+ expect(localStorage.getItem("libredb_connections")).not.toBeNull();
+ });
+
test("local mode: the copy stays", async () => {
await clickLogout();
diff --git a/tests/hooks/use-auth.test.ts b/tests/hooks/use-auth.test.ts
index 70469274f..33542dbac 100644
--- a/tests/hooks/use-auth.test.ts
+++ b/tests/hooks/use-auth.test.ts
@@ -8,7 +8,7 @@ import { renderHook, act, waitFor } from "@testing-library/react";
import { mockGlobalFetch, restoreGlobalFetch } from "../helpers/mock-fetch";
import { useAuth } from "@/hooks/use-auth";
-import { registerPendingPush } from "@/lib/storage/sign-out";
+import { registerWorkspaceSync } from "@/lib/storage/sign-out";
// =============================================================================
// useAuth Tests
@@ -436,7 +436,7 @@ describe("useAuth", () => {
localStorage.clear();
});
- test("server mode: pending pushes go out, then the copy is cleared, then the session ends", async () => {
+ test("server mode: pending pushes go out, then the session ends, then the copy is cleared", async () => {
const order: string[] = [];
mockGlobalFetch({
"/api/auth/me": { ok: true, json: { user: { role: "user" } } },
@@ -446,8 +446,11 @@ describe("useAuth", () => {
return { ok: true, json: { success: true } };
},
});
- const unregister = registerPendingPush(async () => {
- order.push(`push:${localStorage.getItem("libredb_connections")}`);
+ const unregister = registerWorkspaceSync({
+ flush: async () => {
+ order.push(`push:${localStorage.getItem("libredb_connections")}`);
+ },
+ resume: () => {},
});
const { result } = renderHook(() => useAuth());
@@ -459,7 +462,9 @@ describe("useAuth", () => {
});
unregister();
- expect(order).toEqual([`push:${JSON.stringify([{ id: "c1" }])}`, "logout:null"]);
+ const copy = JSON.stringify([{ id: "c1" }]);
+ expect(order).toEqual([`push:${copy}`, `logout:${copy}`]);
+ expect(localStorage.getItem("libredb_connections")).toBeNull();
expect(localStorage.getItem("libredb_workspace_tabs_v1:c1")).toBeNull();
expect(localStorage.getItem("libredb_workspace_owner")).toBeNull();
expect(mockRouterPush).toHaveBeenCalledWith("/login");
@@ -471,8 +476,11 @@ describe("useAuth", () => {
"/api/storage/config": { ok: true, json: { provider: "postgres", serverMode: true } },
"/api/auth/logout": { ok: true, json: { success: true } },
});
- const unregister = registerPendingPush(async () => {
- throw new Error("Unsaved changes could not be saved to server storage");
+ const unregister = registerWorkspaceSync({
+ flush: async () => {
+ throw new Error("Unsaved changes could not be saved to server storage");
+ },
+ resume: () => {},
});
const { result } = renderHook(() => useAuth());
@@ -489,6 +497,25 @@ describe("useAuth", () => {
expect(mockToastError).toHaveBeenCalledWith("Error", { description: "Failed to logout." });
});
+ test("server mode: a sign-out the server refused keeps the copy", async () => {
+ mockGlobalFetch({
+ "/api/auth/me": { ok: true, json: { user: { role: "user" } } },
+ "/api/storage/config": { ok: true, json: { provider: "postgres", serverMode: true } },
+ "/api/auth/logout": { ok: false, status: 500, json: { error: "Server error" } },
+ });
+
+ const { result } = renderHook(() => useAuth());
+ await waitFor(() => {
+ expect(result.current.user).not.toBeNull();
+ });
+ await act(async () => {
+ await result.current.handleLogout();
+ });
+
+ expect(localStorage.getItem("libredb_connections")).not.toBeNull();
+ expect(localStorage.getItem("libredb_workspace_owner")).toBe("user@libredb.org");
+ });
+
test("local mode: the copy stays", async () => {
mockGlobalFetch({
"/api/auth/me": { ok: true, json: { user: { role: "user" } } },
diff --git a/tests/isolated/use-storage-sync.test.ts b/tests/isolated/use-storage-sync.test.ts
index e183fca93..319100a2b 100644
--- a/tests/isolated/use-storage-sync.test.ts
+++ b/tests/isolated/use-storage-sync.test.ts
@@ -1125,7 +1125,15 @@ describe("useStorageSync", () => {
});
}
- test("pushes a pending change before the browser copy is cleared", async () => {
+ /** POST /api/auth/logout as the sign-out paths send it; `status` is what the server answers. */
+ function signOutAnswering(status: number, during: () => void = () => {}) {
+ return async () => {
+ during();
+ return new Response(JSON.stringify({ success: status === 200 }), { status });
+ };
+ }
+
+ test("pushes a pending change, then ends the session, then clears the browser copy", async () => {
localStorage.setItem("libredb_workspace_owner", USERNAME);
localStorage.setItem("libredb_server_migrated", "2026-10-01");
const order: string[] = [];
@@ -1147,18 +1155,21 @@ describe("useStorageSync", () => {
});
// Inside the debounce window: the push is still pending when the sign-out starts.
await act(async () => {
- await releaseAccountWorkspace();
+ await releaseAccountWorkspace(
+ signOutAnswering(200, () => order.push(`sign-out:${localStorage.getItem("libredb_workspace_owner")}`)),
+ );
});
order.push(`cleared:${localStorage.getItem("libredb_workspace_owner")}`);
- expect(order).toEqual([`push:${USERNAME}`, "cleared:null"]);
+ expect(order).toEqual([`push:${USERNAME}`, `sign-out:${USERNAME}`, "cleared:null"]);
});
- test("waits for a push already in flight before the browser copy is cleared", async () => {
+ test("waits for a push already in flight before the session ends", async () => {
localStorage.setItem("libredb_workspace_owner", USERNAME);
localStorage.setItem("libredb_server_migrated", "2026-10-01");
let started = false;
let landed = false;
+ let landedAtSignOut = null as boolean | null;
setupSignOut({
"/api/storage/history": async () => {
started = true;
@@ -1181,16 +1192,17 @@ describe("useStorageSync", () => {
expect(started).toBe(true);
});
await act(async () => {
- await releaseAccountWorkspace();
+ await releaseAccountWorkspace(signOutAnswering(200, () => (landedAtSignOut = landed)));
});
- expect(landed).toBe(true);
+ expect(landedAtSignOut).toBe(true);
expect(localStorage.getItem("libredb_workspace_owner")).toBeNull();
});
test("a pending push that does not land fails the sign-out and keeps the copy", async () => {
localStorage.setItem("libredb_workspace_owner", USERNAME);
localStorage.setItem("libredb_server_migrated", "2026-10-01");
+ let signedOut = false;
setupSignOut({
"/api/storage/history": { ok: false, status: 500, json: { error: "Write failed" } },
"/api/storage": { ok: true, status: 200, json: {} },
@@ -1206,12 +1218,13 @@ describe("useStorageSync", () => {
});
let failure: unknown = null;
await act(async () => {
- await releaseAccountWorkspace().catch((err: unknown) => {
+ await releaseAccountWorkspace(signOutAnswering(200, () => (signedOut = true))).catch((err: unknown) => {
failure = err;
});
});
expect(failure).toBeInstanceOf(Error);
+ expect(signedOut).toBe(false);
expect(localStorage.getItem("libredb_workspace_owner")).toBe(USERNAME);
});
@@ -1226,7 +1239,7 @@ describe("useStorageSync", () => {
});
await act(async () => {
- await releaseAccountWorkspace();
+ await releaseAccountWorkspace(signOutAnswering(200));
});
act(() => {
window.dispatchEvent(new CustomEvent("libredb-storage-change", { detail: { collection: "connections" } }));
@@ -1235,5 +1248,116 @@ describe("useStorageSync", () => {
expect(calledPaths(fetchMock)).not.toContain("/api/storage/connections");
});
+
+ test("a sign-out the server refused keeps the copy and pushes the changes made meanwhile", async () => {
+ localStorage.setItem("libredb_workspace_owner", USERNAME);
+ localStorage.setItem("libredb_server_migrated", "2026-10-01");
+ const pushed: string[] = [];
+ setupSignOut({
+ "/api/storage/history": () => {
+ pushed.push("history");
+ return { ok: true, status: 200, json: { ok: true } };
+ },
+ "/api/storage/connections": () => {
+ pushed.push("connections");
+ return { ok: true, status: 200, json: { ok: true } };
+ },
+ "/api/storage": { ok: true, status: 200, json: {} },
+ });
+
+ const { result } = renderHook(() => useStorageSync());
+ await waitFor(() => {
+ expect(result.current.isReady).toBe(true);
+ });
+
+ // A change made while the sign-out request is out is held, not pushed and not dropped.
+ await act(async () => {
+ await releaseAccountWorkspace(
+ signOutAnswering(500, () => {
+ window.dispatchEvent(new CustomEvent("libredb-storage-change", { detail: { collection: "history" } }));
+ }),
+ );
+ });
+ expect(localStorage.getItem("libredb_workspace_owner")).toBe(USERNAME);
+ await waitFor(
+ () => {
+ expect(pushed).toContain("history");
+ },
+ { timeout: 3000 },
+ );
+
+ // And the session goes on syncing as before.
+ act(() => {
+ window.dispatchEvent(new CustomEvent("libredb-storage-change", { detail: { collection: "connections" } }));
+ });
+ await waitFor(
+ () => {
+ expect(pushed).toContain("connections");
+ },
+ { timeout: 3000 },
+ );
+ });
+
+ test("a sign-out the server refused, with nothing held, pushes nothing", async () => {
+ localStorage.setItem("libredb_workspace_owner", USERNAME);
+ localStorage.setItem("libredb_server_migrated", "2026-10-01");
+ const fetchMock = setupServerMode({ "/api/storage": { ok: true, status: 200, json: {} } });
+
+ const { result } = renderHook(() => useStorageSync());
+ await waitFor(() => {
+ expect(result.current.isReady).toBe(true);
+ });
+
+ await act(async () => {
+ await releaseAccountWorkspace(signOutAnswering(500));
+ });
+ await act(async () => await new Promise((r) => setTimeout(r, 700)));
+
+ expect(
+ calledPaths(fetchMock).filter((p) => p.startsWith("/api/storage/") && p !== "/api/storage/config"),
+ ).toEqual([]);
+ });
+
+ test("whatever is written after a sign-out never reaches the next account", async () => {
+ const PREVIOUS = "admin@libredb.org";
+ localStorage.setItem("libredb_workspace_owner", PREVIOUS);
+ localStorage.setItem("libredb_server_migrated", "2026-10-01");
+ mockGlobalFetch({
+ "/api/storage/config": { ok: true, status: 200, json: { provider: "postgres", serverMode: true } },
+ "/api/auth/me": { ok: true, status: 200, json: { user: { username: PREVIOUS } } },
+ "/api/storage": { ok: true, status: 200, json: {} },
+ });
+ const first = renderHook(() => useStorageSync());
+ await waitFor(() => {
+ expect(first.result.current.isReady).toBe(true);
+ });
+ await act(async () => {
+ await releaseAccountWorkspace(signOutAnswering(200));
+ });
+
+ // A query that finishes after the sign-out, in this tab or one still open, writes the copy.
+ localStorage.setItem("libredb_history", JSON.stringify([{ id: "h1", query: "SELECT * FROM prior_account" }]));
+ first.unmount();
+
+ const bodies: string[] = [];
+ const fetchMock = mockGlobalFetch({
+ "/api/storage/config": { ok: true, status: 200, json: { provider: "postgres", serverMode: true } },
+ ...SIGNED_IN,
+ "/api/storage/": async (req: Request) => {
+ bodies.push(await req.text());
+ return { ok: true, status: 200, json: { ok: true } };
+ },
+ "/api/storage": { ok: true, status: 200, json: {} },
+ });
+ const second = renderHook(() => useStorageSync());
+ await waitFor(() => {
+ expect(second.result.current.isReady).toBe(true);
+ });
+
+ expect(calledPaths(fetchMock)).not.toContain("/api/storage/migrate");
+ expect(bodies.some((body) => body.includes("prior_account"))).toBe(false);
+ expect(localStorage.getItem("libredb_history")).toBeNull();
+ expect(localStorage.getItem("libredb_workspace_owner")).toBe(USERNAME);
+ });
});
});
diff --git a/tests/unit/lib/storage/local-storage.test.ts b/tests/unit/lib/storage/local-storage.test.ts
index b479a5d77..6cd27a30d 100644
--- a/tests/unit/lib/storage/local-storage.test.ts
+++ b/tests/unit/lib/storage/local-storage.test.ts
@@ -13,6 +13,8 @@ import {
remove,
getKey,
clearAccountWorkspace,
+ claimAccountWorkspace,
+ resetAccountWorkspace,
workspaceTabsKey,
WORKSPACE_OWNER_KEY,
SERVER_MIGRATED_KEY,
@@ -174,3 +176,77 @@ describe("local-storage: clearAccountWorkspace", () => {
for (const [key, value] of Object.entries(preferences)) expect(localStorage.getItem(key)).toBe(value);
});
});
+
+describe("local-storage: resetAccountWorkspace", () => {
+ beforeEach(() => {
+ localStorage.clear();
+ });
+
+ test("clears the copy and leaves it marked as handed to a server account, with no owner", () => {
+ localStorage.setItem(getKey("history"), "[]");
+ localStorage.setItem(workspaceTabsKey("c1"), "[]");
+ localStorage.setItem(WORKSPACE_OWNER_KEY, "admin@libredb.org");
+
+ resetAccountWorkspace(null);
+
+ expect(localStorage.getItem(getKey("history"))).toBeNull();
+ expect(localStorage.getItem(workspaceTabsKey("c1"))).toBeNull();
+ expect(localStorage.getItem(SERVER_MIGRATED_KEY)).not.toBeNull();
+ expect(localStorage.getItem(WORKSPACE_OWNER_KEY)).toBeNull();
+ });
+
+ test("records the given owner", () => {
+ resetAccountWorkspace("user@libredb.org");
+
+ expect(localStorage.getItem(SERVER_MIGRATED_KEY)).not.toBeNull();
+ expect(localStorage.getItem(WORKSPACE_OWNER_KEY)).toBe("user@libredb.org");
+ });
+});
+
+describe("local-storage: claimAccountWorkspace", () => {
+ beforeEach(() => {
+ localStorage.clear();
+ localStorage.setItem(getKey("connections"), '[{"id":"c1"}]');
+ localStorage.setItem(workspaceTabsKey("default"), "[]");
+ });
+
+ test("the same owner keeps the copy", () => {
+ localStorage.setItem(WORKSPACE_OWNER_KEY, "user@libredb.org");
+ localStorage.setItem(SERVER_MIGRATED_KEY, "2026-10-07");
+
+ claimAccountWorkspace("user@libredb.org");
+
+ expect(localStorage.getItem(getKey("connections"))).not.toBeNull();
+ expect(localStorage.getItem(workspaceTabsKey("default"))).toBe("[]");
+ });
+
+ test("a copy with no owner that was never handed to a server account is kept, still unowned", () => {
+ claimAccountWorkspace("user@libredb.org");
+
+ expect(localStorage.getItem(getKey("connections"))).not.toBeNull();
+ expect(localStorage.getItem(WORKSPACE_OWNER_KEY)).toBeNull();
+ expect(localStorage.getItem(SERVER_MIGRATED_KEY)).toBeNull();
+ });
+
+ test("another owner's copy is cleared and the signed-in account becomes its owner", () => {
+ localStorage.setItem(WORKSPACE_OWNER_KEY, "admin@libredb.org");
+ localStorage.setItem(SERVER_MIGRATED_KEY, "2026-10-07");
+
+ claimAccountWorkspace("user@libredb.org");
+
+ expect(localStorage.getItem(getKey("connections"))).toBeNull();
+ expect(localStorage.getItem(workspaceTabsKey("default"))).toBeNull();
+ expect(localStorage.getItem(WORKSPACE_OWNER_KEY)).toBe("user@libredb.org");
+ expect(localStorage.getItem(SERVER_MIGRATED_KEY)).not.toBeNull();
+ });
+
+ test("a copy left after a sign-out is cleared, whatever was written to it since", () => {
+ resetAccountWorkspace(null);
+ localStorage.setItem(getKey("history"), '[{"id":"h1"}]');
+
+ claimAccountWorkspace("user@libredb.org");
+
+ expect(localStorage.getItem(getKey("history"))).toBeNull();
+ expect(localStorage.getItem(WORKSPACE_OWNER_KEY)).toBe("user@libredb.org");
+ });
+});
diff --git a/tests/unit/lib/storage/sign-out.test.ts b/tests/unit/lib/storage/sign-out.test.ts
index 356dcda35..ef5c378ae 100644
--- a/tests/unit/lib/storage/sign-out.test.ts
+++ b/tests/unit/lib/storage/sign-out.test.ts
@@ -1,13 +1,15 @@
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
if (typeof globalThis.window === "undefined") {
- // @ts-expect-error — minimal window stub
+ // @ts-expect-error: minimal window stub
globalThis.window = globalThis;
}
import { mockGlobalFetch, restoreGlobalFetch } from "../../../helpers/mock-fetch";
-import { registerPendingPush, releaseAccountWorkspace } from "@/lib/storage/sign-out";
-import { WORKSPACE_OWNER_KEY } from "@/lib/storage/local-storage";
+import { registerWorkspaceSync, releaseAccountWorkspace } from "@/lib/storage/sign-out";
+import { SERVER_MIGRATED_KEY, WORKSPACE_OWNER_KEY } from "@/lib/storage/local-storage";
+
+const CONNECTIONS = JSON.stringify([{ id: "c1", password: "pw" }]);
function serverMode(serverModeOn: boolean) {
return mockGlobalFetch({
@@ -19,10 +21,18 @@ function serverMode(serverModeOn: boolean) {
});
}
+/** The POST /api/auth/logout stand-in: records what the copy held when the session ended. */
+function signOutAnswering(status: number, seen: (string | null)[] = []) {
+ return async () => {
+ seen.push(localStorage.getItem("libredb_connections"));
+ return new Response(JSON.stringify({ success: status === 200 }), { status });
+ };
+}
+
describe("releaseAccountWorkspace", () => {
beforeEach(() => {
localStorage.clear();
- localStorage.setItem("libredb_connections", JSON.stringify([{ id: "c1", password: "pw" }]));
+ localStorage.setItem("libredb_connections", CONNECTIONS);
localStorage.setItem(WORKSPACE_OWNER_KEY, "admin@libredb.org");
});
@@ -30,71 +40,141 @@ describe("releaseAccountWorkspace", () => {
restoreGlobalFetch();
});
- test("server mode: pushes the pending collections first, then clears this browser's copy", async () => {
+ test("server mode: pushes what is pending, ends the session, then clears this browser's copy", async () => {
serverMode(true);
- const seen: (string | null)[] = [];
- const unregister = registerPendingPush(async () => {
- seen.push(localStorage.getItem("libredb_connections"));
+ const order: string[] = [];
+ const unregister = registerWorkspaceSync({
+ flush: async () => {
+ order.push(`flush:${localStorage.getItem("libredb_connections")}`);
+ },
+ resume: () => order.push("resume"),
});
- await releaseAccountWorkspace();
+ const response = await releaseAccountWorkspace(async () => {
+ order.push(`sign-out:${localStorage.getItem("libredb_connections")}`);
+ return new Response("{}", { status: 200 });
+ });
unregister();
- expect(seen).toEqual([JSON.stringify([{ id: "c1", password: "pw" }])]);
+ expect(response.ok).toBe(true);
+ expect(order).toEqual([`flush:${CONNECTIONS}`, `sign-out:${CONNECTIONS}`]);
expect(localStorage.getItem("libredb_connections")).toBeNull();
expect(localStorage.getItem(WORKSPACE_OWNER_KEY)).toBeNull();
+ // Marked, so whatever is written to the emptied copy later is never taken for local-mode data.
+ expect(localStorage.getItem(SERVER_MIGRATED_KEY)).not.toBeNull();
});
- test("server mode with no sync mounted: clears this browser's copy", async () => {
+ test("server mode with no sync mounted: ends the session, then clears this browser's copy", async () => {
serverMode(true);
- await releaseAccountWorkspace();
+ const seen: (string | null)[] = [];
+ await releaseAccountWorkspace(signOutAnswering(200, seen));
+ expect(seen).toEqual([CONNECTIONS]);
expect(localStorage.getItem("libredb_connections")).toBeNull();
});
- test("server mode: a push that does not land keeps the copy and fails the sign-out", async () => {
+ test("server mode: a push that does not land keeps the session and the copy", async () => {
serverMode(true);
- const unregister = registerPendingPush(async () => {
- throw new Error("Unsaved changes could not be saved");
+ const seen: (string | null)[] = [];
+ const unregister = registerWorkspaceSync({
+ flush: async () => {
+ throw new Error("Unsaved changes could not be saved");
+ },
+ resume: () => {},
});
- await expect(releaseAccountWorkspace()).rejects.toThrow("Unsaved changes could not be saved");
+ await expect(releaseAccountWorkspace(signOutAnswering(200, seen))).rejects.toThrow(
+ "Unsaved changes could not be saved",
+ );
+ unregister();
+
+ expect(seen).toEqual([]);
+ expect(localStorage.getItem("libredb_connections")).toBe(CONNECTIONS);
+ });
+
+ test("server mode: a sign-out the server refused keeps the copy and resumes the sync", async () => {
+ serverMode(true);
+ let resumed = 0;
+ const unregister = registerWorkspaceSync({ flush: async () => {}, resume: () => (resumed += 1) });
+
+ const response = await releaseAccountWorkspace(signOutAnswering(500));
+ unregister();
+
+ expect(response.status).toBe(500);
+ expect(resumed).toBe(1);
+ expect(localStorage.getItem("libredb_connections")).toBe(CONNECTIONS);
+ expect(localStorage.getItem(WORKSPACE_OWNER_KEY)).toBe("admin@libredb.org");
+ });
+
+ test("server mode: a sign-out request that fails keeps the copy and resumes the sync", async () => {
+ serverMode(true);
+ let resumed = 0;
+ const unregister = registerWorkspaceSync({ flush: async () => {}, resume: () => (resumed += 1) });
+
+ await expect(
+ releaseAccountWorkspace(async () => {
+ throw new Error("network down");
+ }),
+ ).rejects.toThrow("network down");
unregister();
- expect(localStorage.getItem("libredb_connections")).not.toBeNull();
+ expect(resumed).toBe(1);
+ expect(localStorage.getItem("libredb_connections")).toBe(CONNECTIONS);
+ });
+
+ test("server mode with no sync mounted: a refused sign-out keeps the copy", async () => {
+ serverMode(true);
+ await releaseAccountWorkspace(signOutAnswering(500));
+ await expect(
+ releaseAccountWorkspace(async () => {
+ throw new Error("network down");
+ }),
+ ).rejects.toThrow("network down");
+ expect(localStorage.getItem("libredb_connections")).toBe(CONNECTIONS);
});
- test("local mode: nothing is pushed and nothing is cleared", async () => {
+ test("local mode: the session ends, nothing is pushed and nothing is cleared", async () => {
serverMode(false);
let pushed = false;
- const unregister = registerPendingPush(async () => {
- pushed = true;
+ const seen: (string | null)[] = [];
+ const unregister = registerWorkspaceSync({
+ flush: async () => {
+ pushed = true;
+ },
+ resume: () => {},
});
- await releaseAccountWorkspace();
+ await releaseAccountWorkspace(signOutAnswering(200, seen));
unregister();
expect(pushed).toBe(false);
- expect(localStorage.getItem("libredb_connections")).not.toBeNull();
+ expect(seen).toEqual([CONNECTIONS]);
+ expect(localStorage.getItem("libredb_connections")).toBe(CONNECTIONS);
expect(localStorage.getItem(WORKSPACE_OWNER_KEY)).toBe("admin@libredb.org");
+ expect(localStorage.getItem(SERVER_MIGRATED_KEY)).toBeNull();
});
test("an unreadable storage mode fails the sign-out and clears nothing", async () => {
mockGlobalFetch({ "/api/storage/config": { ok: false, status: 500, json: { error: "down" } } });
+ const seen: (string | null)[] = [];
- await expect(releaseAccountWorkspace()).rejects.toThrow("HTTP 500");
- expect(localStorage.getItem("libredb_connections")).not.toBeNull();
+ await expect(releaseAccountWorkspace(signOutAnswering(200, seen))).rejects.toThrow("HTTP 500");
+ expect(seen).toEqual([]);
+ expect(localStorage.getItem("libredb_connections")).toBe(CONNECTIONS);
});
- test("unregistering a push that was replaced leaves the newer one in place", async () => {
+ test("unregistering a sync that was replaced leaves the newer one in place", async () => {
serverMode(true);
let newer = 0;
- const unregisterOlder = registerPendingPush(async () => {});
- const unregisterNewer = registerPendingPush(async () => {
- newer += 1;
+ const unregisterOlder = registerWorkspaceSync({ flush: async () => {}, resume: () => {} });
+ const unregisterNewer = registerWorkspaceSync({
+ flush: async () => {
+ newer += 1;
+ },
+ resume: () => {},
});
unregisterOlder();
- await releaseAccountWorkspace();
+ await releaseAccountWorkspace(signOutAnswering(200));
unregisterNewer();
expect(newer).toBe(1);
diff --git a/tests/unit/lib/storage/workspace-owner.test.ts b/tests/unit/lib/storage/workspace-owner.test.ts
new file mode 100644
index 000000000..a0160f7bf
--- /dev/null
+++ b/tests/unit/lib/storage/workspace-owner.test.ts
@@ -0,0 +1,86 @@
+import { describe, test, expect, beforeEach, afterEach } from "bun:test";
+
+if (typeof globalThis.window === "undefined") {
+ // @ts-expect-error: minimal window stub
+ globalThis.window = globalThis;
+}
+
+import { mockGlobalFetch, restoreGlobalFetch } from "../../../helpers/mock-fetch";
+import { claimWorkspaceForSignedInAccount, readSignedInUsername } from "@/lib/storage/workspace-owner";
+import { SERVER_MIGRATED_KEY, WORKSPACE_OWNER_KEY } from "@/lib/storage/local-storage";
+
+const SERVER_MODE = { "/api/storage/config": { json: { provider: "postgres", serverMode: true } } };
+
+describe("claimWorkspaceForSignedInAccount", () => {
+ beforeEach(() => {
+ localStorage.clear();
+ localStorage.setItem("libredb_connections", JSON.stringify([{ id: "c1" }]));
+ localStorage.setItem(WORKSPACE_OWNER_KEY, "admin@libredb.org");
+ localStorage.setItem(SERVER_MIGRATED_KEY, "2026-10-07");
+ });
+
+ afterEach(() => {
+ restoreGlobalFetch();
+ });
+
+ test("server mode: a different account's copy is cleared and the signed-in account owns it", async () => {
+ mockGlobalFetch({ ...SERVER_MODE, "/api/auth/me": { json: { user: { username: "user@libredb.org" } } } });
+
+ await claimWorkspaceForSignedInAccount();
+
+ expect(localStorage.getItem("libredb_connections")).toBeNull();
+ expect(localStorage.getItem(WORKSPACE_OWNER_KEY)).toBe("user@libredb.org");
+ });
+
+ test("server mode: the same account keeps its copy", async () => {
+ mockGlobalFetch({ ...SERVER_MODE, "/api/auth/me": { json: { user: { username: "admin@libredb.org" } } } });
+
+ await claimWorkspaceForSignedInAccount();
+
+ expect(localStorage.getItem("libredb_connections")).not.toBeNull();
+ });
+
+ test("server mode: an unreadable signed-in account throws and keeps the copy unclaimed", async () => {
+ mockGlobalFetch({ ...SERVER_MODE, "/api/auth/me": { ok: false, status: 503, json: { error: "down" } } });
+
+ await expect(claimWorkspaceForSignedInAccount()).rejects.toThrow("HTTP 503");
+ expect(localStorage.getItem(WORKSPACE_OWNER_KEY)).toBe("admin@libredb.org");
+ });
+
+ test("local mode: nothing changes and the signed-in account is not asked for", async () => {
+ const fetchMock = mockGlobalFetch({ "/api/storage/config": { json: { provider: "local", serverMode: false } } });
+
+ await claimWorkspaceForSignedInAccount();
+
+ expect(localStorage.getItem("libredb_connections")).not.toBeNull();
+ expect(fetchMock.mock.calls.length).toBe(1);
+ });
+
+ test("a storage mode that cannot be read counts as local mode, as the sync counts it", async () => {
+ mockGlobalFetch({ "/api/storage/config": { ok: false, status: 500, json: { error: "down" } } });
+ await claimWorkspaceForSignedInAccount();
+ expect(localStorage.getItem("libredb_connections")).not.toBeNull();
+
+ globalThis.fetch = (async () => {
+ throw new Error("offline");
+ }) as unknown as typeof fetch;
+ await claimWorkspaceForSignedInAccount();
+ expect(localStorage.getItem("libredb_connections")).not.toBeNull();
+ });
+});
+
+describe("readSignedInUsername", () => {
+ afterEach(() => {
+ restoreGlobalFetch();
+ });
+
+ test("answers the username of GET /api/auth/me", async () => {
+ mockGlobalFetch({ "/api/auth/me": { json: { user: { username: "user@libredb.org" } } } });
+ expect(await readSignedInUsername()).toBe("user@libredb.org");
+ });
+
+ test("an answer without a username throws", async () => {
+ mockGlobalFetch({ "/api/auth/me": { json: { user: { username: "" } } } });
+ await expect(readSignedInUsername()).rejects.toThrow("no username");
+ });
+});
From 16914053f95b21e81e1e792017fd3e4dfd4a99d1 Mon Sep 17 00:00:00 2001
From: cevheri
Date: Wed, 7 Oct 2026 11:37:02 +0300
Subject: [PATCH 04/28] fix(storage): match the browser copy to the signed-in
account on every page that reads it
The editor, the admin dashboard and the monitoring page render once this browser's copy is the signed-in account's: in server storage mode a copy another account left behind is cleared before the page reads it, so signing in as a different account starts from that account's server data wherever it lands. When the signed-in account cannot be read the page says so instead of showing the copy.
---
src/app/admin/layout.tsx | 7 +-
src/app/monitoring/page.tsx | 7 +-
src/app/page.tsx | 7 +-
src/components/auth/WorkspaceOwnerGate.tsx | 45 ++++++
tests/components/MonitoringPage.test.tsx | 25 +++-
tests/components/Page.test.tsx | 25 +++-
tests/components/admin/AdminLayout.test.tsx | 23 ++-
.../auth/WorkspaceOwnerGate.test.tsx | 133 ++++++++++++++++++
8 files changed, 251 insertions(+), 21 deletions(-)
create mode 100644 src/components/auth/WorkspaceOwnerGate.tsx
create mode 100644 tests/components/auth/WorkspaceOwnerGate.test.tsx
diff --git a/src/app/admin/layout.tsx b/src/app/admin/layout.tsx
index 57d8413a7..293cb1e01 100644
--- a/src/app/admin/layout.tsx
+++ b/src/app/admin/layout.tsx
@@ -1,13 +1,18 @@
import { Suspense } from "react";
import AdminDashboard from "@/components/admin/AdminDashboard";
+import { WorkspaceOwnerGate } from "@/components/auth/WorkspaceOwnerGate";
export default function AdminLayout({ children }: { children: React.ReactNode }) {
return (
// The boundary is no longer required by the shell itself (AdminDashboard moved
// from useSearchParams to usePathname, which needs no Suspense). It stays as the
// streaming boundary for the section pages rendered into {children}.
+ // The gate: every admin section reads this browser's copy of the workspace, which must be the
+ // signed-in account's first.
- {children}
+
+ {children}
+
);
}
diff --git a/src/app/monitoring/page.tsx b/src/app/monitoring/page.tsx
index aaec87ac1..35a47bb7d 100644
--- a/src/app/monitoring/page.tsx
+++ b/src/app/monitoring/page.tsx
@@ -1,8 +1,13 @@
"use client";
+import { WorkspaceOwnerGate } from "@/components/auth/WorkspaceOwnerGate";
import { MonitoringDashboard } from "@/components/monitoring/MonitoringDashboard";
export default function MonitoringPage() {
// Middleware handles authentication, no need for client-side check
- return ;
+ return (
+
+
+
+ );
}
diff --git a/src/app/page.tsx b/src/app/page.tsx
index bb4ebb746..6a3f0f2e3 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -1,5 +1,10 @@
+import { WorkspaceOwnerGate } from "@/components/auth/WorkspaceOwnerGate";
import Studio from "@/components/Studio";
export default function Page() {
- return ;
+ return (
+
+
+
+ );
}
diff --git a/src/components/auth/WorkspaceOwnerGate.tsx b/src/components/auth/WorkspaceOwnerGate.tsx
new file mode 100644
index 000000000..cd22442cb
--- /dev/null
+++ b/src/components/auth/WorkspaceOwnerGate.tsx
@@ -0,0 +1,45 @@
+"use client";
+
+import { type ReactNode, useEffect, useState } from "react";
+import { logger } from "@/lib/logger";
+import { claimWorkspaceForSignedInAccount } from "@/lib/storage/workspace-owner";
+
+/**
+ * Renders a page that reads this browser's copy of the workspace only once the copy is the
+ * signed-in account's. In server storage mode the browser copy belongs to the signed-in account,
+ * so a copy another account left behind is cleared before the page reads it, and signing in as
+ * a different account starts from that account's server data on every such page, not only the
+ * editor. In local mode the page renders after the storage mode is known, with the copy as it is.
+ */
+export function WorkspaceOwnerGate({ children }: { children: ReactNode }) {
+ const [state, setState] = useState<"checking" | "ready" | "failed">("checking");
+
+ useEffect(() => {
+ let cancelled = false;
+ claimWorkspaceForSignedInAccount().then(
+ () => {
+ if (!cancelled) setState("ready");
+ },
+ (err: unknown) => {
+ logger.warn("Could not match this browser's workspace to the signed-in account", {
+ error: err instanceof Error ? err.message : String(err),
+ });
+ if (!cancelled) setState("failed");
+ },
+ );
+ return () => {
+ cancelled = true;
+ };
+ }, []);
+
+ if (state === "ready") return children;
+ if (state === "failed") {
+ return (
+
+ Studio could not confirm the signed-in account, so this browser's workspace is not shown. Reload the page
+ to try again.
+
+ );
+ }
+ return null;
+}
diff --git a/tests/components/MonitoringPage.test.tsx b/tests/components/MonitoringPage.test.tsx
index 2257870e8..d671f4554 100644
--- a/tests/components/MonitoringPage.test.tsx
+++ b/tests/components/MonitoringPage.test.tsx
@@ -10,22 +10,33 @@ mock.module("@/components/monitoring/MonitoringDashboard", () => ({
const { default: MonitoringPage } = await import("@/app/monitoring/page");
-import { afterEach, describe, expect, test } from "bun:test";
+import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { cleanup, render } from "@testing-library/react";
+import { mockGlobalFetch, restoreGlobalFetch } from "../helpers/mock-fetch";
describe("MonitoringPage", () => {
+ beforeEach(() => {
+ mockGlobalFetch({ "/api/storage/config": { json: { provider: "local", serverMode: false } } });
+ });
+
afterEach(() => {
cleanup();
+ restoreGlobalFetch();
+ });
+
+ test("renders MonitoringDashboard component", async () => {
+ const { findByTestId } = render();
+ expect(await findByTestId("monitoring-dashboard")).not.toBeNull();
});
- test("renders MonitoringDashboard component", () => {
- const { getByTestId } = render();
- expect(getByTestId("monitoring-dashboard")).not.toBeNull();
+ test("renders MonitoringDashboard content", async () => {
+ const { findByText } = render();
+ expect(await findByText("MonitoringDashboard Mock")).not.toBeNull();
});
- test("renders MonitoringDashboard content", () => {
- const { getByText } = render();
- expect(getByText("MonitoringDashboard Mock")).not.toBeNull();
+ test("renders the dashboard only once this browser's workspace is the signed-in account's", () => {
+ const { queryByTestId } = render();
+ expect(queryByTestId("monitoring-dashboard")).toBeNull();
});
test("is a client component that renders directly", () => {
diff --git a/tests/components/Page.test.tsx b/tests/components/Page.test.tsx
index 6075c0731..a04f029aa 100644
--- a/tests/components/Page.test.tsx
+++ b/tests/components/Page.test.tsx
@@ -9,22 +9,33 @@ mock.module("@/components/Studio", () => ({
const { default: Page } = await import("@/app/page");
-import { afterEach, describe, expect, test } from "bun:test";
+import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { cleanup, render } from "@testing-library/react";
+import { mockGlobalFetch, restoreGlobalFetch } from "../helpers/mock-fetch";
describe("Page", () => {
+ beforeEach(() => {
+ mockGlobalFetch({ "/api/storage/config": { json: { provider: "local", serverMode: false } } });
+ });
+
afterEach(() => {
cleanup();
+ restoreGlobalFetch();
+ });
+
+ test("renders Studio component", async () => {
+ const { findByTestId } = render();
+ expect(await findByTestId("studio")).not.toBeNull();
});
- test("renders Studio component", () => {
- const { getByTestId } = render();
- expect(getByTestId("studio")).not.toBeNull();
+ test("renders Studio content", async () => {
+ const { findByText } = render();
+ expect(await findByText("Studio Mock")).not.toBeNull();
});
- test("renders Studio content", () => {
- const { getByText } = render();
- expect(getByText("Studio Mock")).not.toBeNull();
+ test("renders Studio only once this browser's workspace is the signed-in account's", () => {
+ const { queryByTestId } = render();
+ expect(queryByTestId("studio")).toBeNull();
});
test("is a valid React component (returns JSX)", () => {
diff --git a/tests/components/admin/AdminLayout.test.tsx b/tests/components/admin/AdminLayout.test.tsx
index fd966cede..08fb12c55 100644
--- a/tests/components/admin/AdminLayout.test.tsx
+++ b/tests/components/admin/AdminLayout.test.tsx
@@ -9,24 +9,39 @@ mock.module("@/components/admin/AdminDashboard", () => ({
const { default: AdminLayout } = await import("@/app/admin/layout");
-import { afterEach, describe, expect, test } from "bun:test";
+import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { cleanup, render } from "@testing-library/react";
+import { mockGlobalFetch, restoreGlobalFetch } from "../../helpers/mock-fetch";
describe("AdminLayout", () => {
+ beforeEach(() => {
+ mockGlobalFetch({ "/api/storage/config": { json: { provider: "local", serverMode: false } } });
+ });
+
afterEach(() => {
cleanup();
+ restoreGlobalFetch();
});
- test("wraps children in AdminDashboard shell", () => {
- const { getByTestId, getByText } = render(
+ test("wraps children in AdminDashboard shell", async () => {
+ const { findByTestId, getByText } = render(
section content
,
);
- expect(getByTestId("admin-shell")).not.toBeNull();
+ expect(await findByTestId("admin-shell")).not.toBeNull();
expect(getByText("section content")).not.toBeNull();
});
+ test("renders the shell only once this browser's workspace is the signed-in account's", () => {
+ const { queryByTestId } = render(
+
+ section content
+ ,
+ );
+ expect(queryByTestId("admin-shell")).toBeNull();
+ });
+
test("returns a Suspense boundary", () => {
const element = AdminLayout({ children: React.createElement("div") });
expect(element.type).toBe(React.Suspense);
diff --git a/tests/components/auth/WorkspaceOwnerGate.test.tsx b/tests/components/auth/WorkspaceOwnerGate.test.tsx
new file mode 100644
index 000000000..f7f605464
--- /dev/null
+++ b/tests/components/auth/WorkspaceOwnerGate.test.tsx
@@ -0,0 +1,133 @@
+import "../../setup-dom";
+
+import { afterEach, beforeEach, describe, expect, test } from "bun:test";
+import { cleanup, render, waitFor } from "@testing-library/react";
+import { mockGlobalFetch, restoreGlobalFetch } from "../../helpers/mock-fetch";
+
+import { WorkspaceOwnerGate } from "@/components/auth/WorkspaceOwnerGate";
+
+const SERVER_MODE = { "/api/storage/config": { json: { provider: "postgres", serverMode: true } } };
+
+/** A page that reads the browser copy the moment it renders, as the admin and editor pages do. */
+function CopyReader({ seen }: { seen: (string | null)[] }) {
+ seen.push(localStorage.getItem("libredb_connections"), localStorage.getItem("libredb_workspace_tabs_v1:default"));
+ return workspace page
;
+}
+
+describe("WorkspaceOwnerGate", () => {
+ beforeEach(() => {
+ localStorage.clear();
+ localStorage.setItem("libredb_connections", JSON.stringify([{ id: "c1", name: "Warehouse" }]));
+ localStorage.setItem("libredb_workspace_tabs_v1:default", JSON.stringify({ tabs: [{ query: "SELECT 1" }] }));
+ localStorage.setItem("libredb_workspace_owner", "admin@libredb.org");
+ localStorage.setItem("libredb_server_migrated", "2026-10-07");
+ });
+
+ afterEach(() => {
+ cleanup();
+ restoreGlobalFetch();
+ localStorage.clear();
+ });
+
+ test("server mode: a page never reads a copy that belongs to a different account", async () => {
+ mockGlobalFetch({ ...SERVER_MODE, "/api/auth/me": { json: { user: { username: "user@libredb.org" } } } });
+ const seen: (string | null)[] = [];
+
+ const view = render(
+
+
+ ,
+ );
+
+ expect(await view.findByText("workspace page")).not.toBeNull();
+ expect(seen.every((value) => value === null)).toBe(true);
+ expect(localStorage.getItem("libredb_workspace_tabs_v1:default")).toBeNull();
+ expect(localStorage.getItem("libredb_workspace_owner")).toBe("user@libredb.org");
+ });
+
+ test("server mode: the same account's page reads its copy", async () => {
+ mockGlobalFetch({ ...SERVER_MODE, "/api/auth/me": { json: { user: { username: "admin@libredb.org" } } } });
+ const seen: (string | null)[] = [];
+
+ const view = render(
+
+
+ ,
+ );
+
+ expect(await view.findByText("workspace page")).not.toBeNull();
+ expect(seen.at(-2)).toBe(JSON.stringify([{ id: "c1", name: "Warehouse" }]));
+ });
+
+ test("server mode: an unreadable signed-in account shows why and renders no page", async () => {
+ mockGlobalFetch({ ...SERVER_MODE, "/api/auth/me": { ok: false, status: 503, json: { error: "down" } } });
+ const seen: (string | null)[] = [];
+
+ const view = render(
+
+
+ ,
+ );
+
+ const alert = await view.findByRole("alert");
+ expect(alert.textContent).toContain("could not confirm the signed-in account");
+ expect(seen).toEqual([]);
+ expect(localStorage.getItem("libredb_workspace_owner")).toBe("admin@libredb.org");
+ });
+
+ test("local mode: the page reads the browser copy as before", async () => {
+ mockGlobalFetch({ "/api/storage/config": { json: { provider: "local", serverMode: false } } });
+ const seen: (string | null)[] = [];
+
+ const view = render(
+
+
+ ,
+ );
+
+ expect(await view.findByText("workspace page")).not.toBeNull();
+ expect(seen.at(-2)).toBe(JSON.stringify([{ id: "c1", name: "Warehouse" }]));
+ expect(localStorage.getItem("libredb_workspace_owner")).toBe("admin@libredb.org");
+ });
+
+ test("nothing renders while the check is out, and an answer after unmount changes nothing", async () => {
+ let answer: (value: Response) => void = () => {};
+ globalThis.fetch = (() => new Promise((resolve) => (answer = resolve))) as unknown as typeof fetch;
+ const seen: (string | null)[] = [];
+
+ const view = render(
+
+
+ ,
+ );
+ expect(view.queryByText("workspace page")).toBeNull();
+ view.unmount();
+ answer(new Response(JSON.stringify({ provider: "local", serverMode: false }), { status: 200 }));
+ await new Promise((resolve) => setTimeout(resolve, 10));
+
+ expect(seen).toEqual([]);
+ });
+
+ test("an unreadable account answered after unmount changes nothing", async () => {
+ let answer: (value: Response) => void = () => {};
+ mockGlobalFetch({
+ ...SERVER_MODE,
+ "/api/auth/me": () =>
+ new Promise((resolve) => {
+ answer = () => resolve({ ok: false, status: 503, json: { error: "down" } });
+ }),
+ });
+
+ const view = render(
+
+ workspace page
+ ,
+ );
+ await waitFor(() => expect(answer).not.toBeNull());
+ await new Promise((resolve) => setTimeout(resolve, 10));
+ view.unmount();
+ answer(new Response(""));
+ await new Promise((resolve) => setTimeout(resolve, 10));
+ expect(view.queryByRole("alert")).toBeNull();
+ });
+});
From 3840e5453c27808298c0402564d8a47d1e4d14de Mon Sep 17 00:00:00 2001
From: cevheri
Date: Wed, 7 Oct 2026 11:37:32 +0300
Subject: [PATCH 05/28] docs(storage): describe the owner check on every page
and the sign-out order
---
docs/SECURITY.md | 2 +-
docs/STORAGE.md | 19 ++++++++++++-------
2 files changed, 13 insertions(+), 8 deletions(-)
diff --git a/docs/SECURITY.md b/docs/SECURITY.md
index 9a31007de..de82835c1 100644
--- a/docs/SECURITY.md
+++ b/docs/SECURITY.md
@@ -582,7 +582,7 @@ These are real, current, and not oversights. Each is a decision with a reason.
- **Browser `localStorage` holds your credentials in plaintext.** It is the rendering source, and
encrypting it would require a master password and a recovery flow, changing what the product is.
This is why 0.1 and 1.1 matter as much as they do.
- In server storage mode the browser copy belongs to the signed-in account and is cleared on sign-out.
+ In server storage mode the browser copy belongs to the signed-in account: it is cleared on sign-out, and a different account signing in starts from its own server data.
In local mode it stays, and the next account to sign in on that browser profile sees and can open the connections saved there.
- **Anyone who can read the server's environment can read the stored credentials.** 3.1 protects a
stolen database file or dump on its own; it is not a vault. For `STORAGE_PROVIDER=sqlite` with no
diff --git a/docs/STORAGE.md b/docs/STORAGE.md
index 91d6de6a7..b7f41d0d4 100644
--- a/docs/STORAGE.md
+++ b/docs/STORAGE.md
@@ -59,7 +59,7 @@ Server storage (`sqlite` or `postgres`) gives each person their own private work
For a team of three or more, pair server storage with Single Sign-On via [OIDC](OIDC.md) to provide individual logins, personal workspaces, and an audit trail that attributes actions to each user.
In server storage the browser copy belongs to the signed-in account: signing out clears this browser's copy, open editor tabs included, and signing in as a different account starts from that account's server data.
The default `local` mode stores data in the browser and does not isolate workspaces between people using the same browser profile.
-In `local` mode the next account to sign in on that browser profile sees, and can open, the connections saved there before it, saved credentials included.
+In `local` mode the next account to sign in on that browser profile sees, and can open, the connections saved there before it.
---
@@ -1213,7 +1213,8 @@ App Mount
│ │ │ → keep the browser copy │
│ │ ├─ No owner, never migrated → keep it │
│ │ │ (local-mode data, migrated below) │
- │ │ └─ Otherwise → clear the browser copy │
+ │ │ └─ Otherwise → clear the browser copy, │
+ │ │ mark it migrated, owner = username │
│ └──────────────────────┬──────────────────────┘
│ │
│ ┌──────────────────────▼──────────────────────┐
@@ -1254,16 +1255,20 @@ When any `storage.*` mutation fires:
### The Browser Copy Belongs to the Signed-In Account
-In server mode the browser copy is bound to the account that signed in: `libredb_workspace_owner` holds its username, and the lifecycle above compares it with `GET /api/auth/me` before anything is migrated, pulled or pushed.
+In server mode the browser copy is bound to the account that signed in: `libredb_workspace_owner` holds its username, and it is compared with `GET /api/auth/me` before any page reads the copy.
+Every page that reads it (the editor, the admin dashboard and the monitoring page) renders inside `WorkspaceOwnerGate`, which runs that check first; the lifecycle above runs it again before anything is migrated, pulled or pushed.
+When the signed-in account cannot be read, those pages show a message instead of the copy.
The copy is every synced collection, the editor tabs (never stored on the server), the object-source drafts, the agent thread hint, the migration flag and the owner key itself; per-browser preferences such as the theme and line numbers are not part of it.
`clearAccountWorkspace()` in `src/lib/storage/local-storage.ts` holds that list.
- **Same account:** the copy is kept, and the pull overwrites each collection the server holds; a collection the server does not hold yet stays local.
-- **A different account, or a copy with no owner that was already migrated:** the copy is cleared first, so the account starts from its own server data.
+- **A different account, or a copy with no owner that was already migrated:** the copy is cleared first and recorded as this account's, so the account starts from its own server data.
- **A copy with no owner that was never migrated:** local-mode data, migrated into the signed-in account as described in [Migration Flow](#10-migration-flow).
-- **Sign-out** (the editor, the admin dashboard and the launch page): pending collections are pushed while the session is still valid, then the copy is cleared, then `POST /api/auth/logout` ends the session.
- When a pending push does not land, the sign-out fails and the copy stays, so no unsaved change is dropped.
-- **A session that ended on its own** (expiry, a disabled account) clears nothing; the owner check at the next sign-in covers a different account.
+- **Sign-out** (the editor, the admin dashboard and the launch page): pending collections are pushed while the session is still valid, then `POST /api/auth/logout` ends the session, and only then is the copy cleared.
+ The cleared copy keeps the migration flag and no owner, so whatever is written to it afterwards is cleared at the next sign-in rather than migrated.
+ When the storage mode cannot be read or a pending push does not land, the sign-out fails and the copy stays, so no unsaved change is dropped; when the server refuses the sign-out, the copy stays and the sync goes on.
+- **A session that ended on its own** (expiry, a disabled account) clears nothing; the owner check at the next sign-in, on whichever of these pages it lands, covers a different account.
+- **The first sign-in after upgrading to a release that records the owner:** a browser that was already migrated has no owner yet, so its copy is cleared once, for the same account too; open editor tabs, object-source drafts and any change that had not reached the server start over.
In local mode none of this runs: the browser copy is the only copy, it stays on sign-out, and no owner is recorded.
From 95cd50404656dc28ad57c0e0bf7b9093e97c91dd Mon Sep 17 00:00:00 2001
From: cevheri
Date: Wed, 7 Oct 2026 11:58:17 +0300
Subject: [PATCH 06/28] fix(storage): record the first account as the owner of
an unmigrated browser copy
A browser copy with no owner that was never migrated is local-mode data. The first account that signs in now becomes its owner, so only that account migrates it, and a different account that signs in later starts from its own server data.
---
docs/STORAGE.md | 8 +++++---
src/lib/storage/local-storage.ts | 11 +++++++----
tests/unit/lib/storage/local-storage.test.ts | 15 +++++++++++++--
3 files changed, 25 insertions(+), 9 deletions(-)
diff --git a/docs/STORAGE.md b/docs/STORAGE.md
index b7f41d0d4..81d98ded8 100644
--- a/docs/STORAGE.md
+++ b/docs/STORAGE.md
@@ -1211,8 +1211,9 @@ App Mount
│ │ │ false, nothing is pulled or pushed │
│ │ ├─ libredb_workspace_owner = username │
│ │ │ → keep the browser copy │
- │ │ ├─ No owner, never migrated → keep it │
- │ │ │ (local-mode data, migrated below) │
+ │ │ ├─ No owner, never migrated → keep it, │
+ │ │ │ owner = username (local-mode data, │
+ │ │ │ migrated below into this account only) │
│ │ └─ Otherwise → clear the browser copy, │
│ │ mark it migrated, owner = username │
│ └──────────────────────┬──────────────────────┘
@@ -1263,7 +1264,8 @@ The copy is every synced collection, the editor tabs (never stored on the server
- **Same account:** the copy is kept, and the pull overwrites each collection the server holds; a collection the server does not hold yet stays local.
- **A different account, or a copy with no owner that was already migrated:** the copy is cleared first and recorded as this account's, so the account starts from its own server data.
-- **A copy with no owner that was never migrated:** local-mode data, migrated into the signed-in account as described in [Migration Flow](#10-migration-flow).
+- **A copy with no owner that was never migrated:** local-mode data, recorded as the signed-in account's and migrated into that account as described in [Migration Flow](#10-migration-flow).
+ A different account that signs in before the migration ran finds it owned by the first account, so the copy is cleared for it.
- **Sign-out** (the editor, the admin dashboard and the launch page): pending collections are pushed while the session is still valid, then `POST /api/auth/logout` ends the session, and only then is the copy cleared.
The cleared copy keeps the migration flag and no owner, so whatever is written to it afterwards is cleared at the next sign-in rather than migrated.
When the storage mode cannot be read or a pending push does not land, the sign-out fails and the copy stays, so no unsaved change is dropped; when the server refuses the sign-out, the copy stays and the sync goes on.
diff --git a/src/lib/storage/local-storage.ts b/src/lib/storage/local-storage.ts
index 7561fae70..ffaa04f0e 100644
--- a/src/lib/storage/local-storage.ts
+++ b/src/lib/storage/local-storage.ts
@@ -177,14 +177,17 @@ export function resetAccountWorkspace(owner: string | null): void {
/**
* Server storage mode: keep the browser copy only for the account it belongs to, before anything
* reads it. The same owner keeps it. A copy with no owner that was never handed to a server
- * account is local-mode data, kept for `useStorageSync` to migrate into this account
- * (docs/STORAGE.md). Anything else is cleared, and the signed-in account starts from its own
- * server data.
+ * account is local-mode data: it now belongs to the signed-in account and is kept for
+ * `useStorageSync` to migrate into that account alone (docs/STORAGE.md). Anything else is
+ * cleared, and the signed-in account starts from its own server data.
*/
export function claimAccountWorkspace(username: string): void {
if (!isClient()) return;
const owner = localStorage.getItem(WORKSPACE_OWNER_KEY);
if (owner === username) return;
- if (owner === null && localStorage.getItem(SERVER_MIGRATED_KEY) === null) return;
+ if (owner === null && localStorage.getItem(SERVER_MIGRATED_KEY) === null) {
+ localStorage.setItem(WORKSPACE_OWNER_KEY, username);
+ return;
+ }
resetAccountWorkspace(username);
}
diff --git a/tests/unit/lib/storage/local-storage.test.ts b/tests/unit/lib/storage/local-storage.test.ts
index 6cd27a30d..a29b02fe7 100644
--- a/tests/unit/lib/storage/local-storage.test.ts
+++ b/tests/unit/lib/storage/local-storage.test.ts
@@ -220,14 +220,25 @@ describe("local-storage: claimAccountWorkspace", () => {
expect(localStorage.getItem(workspaceTabsKey("default"))).toBe("[]");
});
- test("a copy with no owner that was never handed to a server account is kept, still unowned", () => {
+ test("a copy with no owner that was never handed to a server account is kept for the signed-in account", () => {
claimAccountWorkspace("user@libredb.org");
expect(localStorage.getItem(getKey("connections"))).not.toBeNull();
- expect(localStorage.getItem(WORKSPACE_OWNER_KEY)).toBeNull();
+ expect(localStorage.getItem(WORKSPACE_OWNER_KEY)).toBe("user@libredb.org");
expect(localStorage.getItem(SERVER_MIGRATED_KEY)).toBeNull();
});
+ test("a copy the first account kept and wrote to belongs to it, so a different account starts empty", () => {
+ localStorage.clear();
+ claimAccountWorkspace("admin@libredb.org");
+ localStorage.setItem(getKey("threshold_config"), '{"slowQueryMs":10}');
+
+ claimAccountWorkspace("user@libredb.org");
+
+ expect(localStorage.getItem(getKey("threshold_config"))).toBeNull();
+ expect(localStorage.getItem(WORKSPACE_OWNER_KEY)).toBe("user@libredb.org");
+ });
+
test("another owner's copy is cleared and the signed-in account becomes its owner", () => {
localStorage.setItem(WORKSPACE_OWNER_KEY, "admin@libredb.org");
localStorage.setItem(SERVER_MIGRATED_KEY, "2026-10-07");
From cab2e409a7658b33cb46e9dc3b92a6813fde798c Mon Sep 17 00:00:00 2001
From: cevheri
Date: Wed, 7 Oct 2026 11:58:48 +0300
Subject: [PATCH 07/28] fix(storage): show no server-bound browser copy while
the storage mode cannot be read
When GET /api/storage/config cannot be read and the browser copy carries an owner or the migration flag, both written only in server mode, the pages that read the copy show their message instead of it. A copy with neither renders as before, so local mode does not change.
---
docs/STORAGE.md | 3 +-
src/lib/storage/workspace-owner.ts | 17 +++++++----
.../auth/WorkspaceOwnerGate.test.tsx | 30 +++++++++++++++++++
.../unit/lib/storage/workspace-owner.test.ts | 21 ++++++++++++-
4 files changed, 64 insertions(+), 7 deletions(-)
diff --git a/docs/STORAGE.md b/docs/STORAGE.md
index 81d98ded8..4ae01b09c 100644
--- a/docs/STORAGE.md
+++ b/docs/STORAGE.md
@@ -1259,6 +1259,7 @@ When any `storage.*` mutation fires:
In server mode the browser copy is bound to the account that signed in: `libredb_workspace_owner` holds its username, and it is compared with `GET /api/auth/me` before any page reads the copy.
Every page that reads it (the editor, the admin dashboard and the monitoring page) renders inside `WorkspaceOwnerGate`, which runs that check first; the lifecycle above runs it again before anything is migrated, pulled or pushed.
When the signed-in account cannot be read, those pages show a message instead of the copy.
+They show it too when `GET /api/storage/config` cannot be read and the copy carries an owner or the migration flag, both written only in server mode; a copy with neither renders as it is, so local mode does not change.
The copy is every synced collection, the editor tabs (never stored on the server), the object-source drafts, the agent thread hint, the migration flag and the owner key itself; per-browser preferences such as the theme and line numbers are not part of it.
`clearAccountWorkspace()` in `src/lib/storage/local-storage.ts` holds that list.
@@ -1276,7 +1277,7 @@ In local mode none of this runs: the browser copy is the only copy, it stays on
### Graceful Degradation
-- If `/api/storage/config` fails → stays in localStorage-only mode
+- If `/api/storage/config` fails → stays in localStorage-only mode, unless the browser copy carries `libredb_workspace_owner` or `libredb_server_migrated`: then the pages that read the copy show a message instead of it, because the copy may belong to a different account
- If push fails → logs warning, sets `syncError`, does **not** block the UI
- Components always read from `localStorage` — no loading states for storage
diff --git a/src/lib/storage/workspace-owner.ts b/src/lib/storage/workspace-owner.ts
index d00f68b78..b88450484 100644
--- a/src/lib/storage/workspace-owner.ts
+++ b/src/lib/storage/workspace-owner.ts
@@ -6,7 +6,7 @@
*/
import { appFetch } from "@/lib/config/base-path";
-import { claimAccountWorkspace } from "./local-storage";
+import { claimAccountWorkspace, SERVER_MIGRATED_KEY, WORKSPACE_OWNER_KEY } from "./local-storage";
import type { StorageConfigResponse } from "./types";
/** The signed-in account's username, from GET /api/auth/me. Throws when it cannot be read. */
@@ -23,18 +23,25 @@ export async function readSignedInUsername(): Promise {
/**
* Server mode: make the browser copy the signed-in account's before a page reads it. Local mode:
- * nothing. A storage mode that cannot be read counts as local mode, as `useStorageSync` counts
- * it. Throws in server mode when the signed-in account cannot be read: the copy must not be used.
+ * nothing. Throws in server mode when the signed-in account cannot be read: the copy must not be
+ * used. A storage mode that cannot be read throws too when the copy carries an owner or the
+ * migration flag, which only server mode writes, so such a copy is never shown unchecked; a copy
+ * with neither counts as local mode, as `useStorageSync` counts it.
*/
export async function claimWorkspaceForSignedInAccount(): Promise {
let config: StorageConfigResponse;
try {
const res = await appFetch("/api/storage/config");
- if (!res.ok) return;
+ if (!res.ok) throw new Error(`Storage mode unavailable: HTTP ${res.status}`);
config = (await res.json()) as StorageConfigResponse;
- } catch {
+ } catch (err) {
+ if (isServerBoundCopy()) throw err;
return;
}
if (!config.serverMode) return;
claimAccountWorkspace(await readSignedInUsername());
}
+
+function isServerBoundCopy(): boolean {
+ return localStorage.getItem(WORKSPACE_OWNER_KEY) !== null || localStorage.getItem(SERVER_MIGRATED_KEY) !== null;
+}
diff --git a/tests/components/auth/WorkspaceOwnerGate.test.tsx b/tests/components/auth/WorkspaceOwnerGate.test.tsx
index f7f605464..72111d3a2 100644
--- a/tests/components/auth/WorkspaceOwnerGate.test.tsx
+++ b/tests/components/auth/WorkspaceOwnerGate.test.tsx
@@ -75,6 +75,36 @@ describe("WorkspaceOwnerGate", () => {
expect(localStorage.getItem("libredb_workspace_owner")).toBe("admin@libredb.org");
});
+ test("a storage mode that cannot be read shows why and renders no page for a copy bound to a server account", async () => {
+ mockGlobalFetch({ "/api/storage/config": { ok: false, status: 502, json: { error: "down" } } });
+ const seen: (string | null)[] = [];
+
+ const view = render(
+
+
+ ,
+ );
+
+ expect((await view.findByRole("alert")).textContent).toContain("could not confirm the signed-in account");
+ expect(seen).toEqual([]);
+ });
+
+ test("a storage mode that cannot be read renders the page for a copy never bound to a server account", async () => {
+ localStorage.removeItem("libredb_workspace_owner");
+ localStorage.removeItem("libredb_server_migrated");
+ mockGlobalFetch({ "/api/storage/config": { ok: false, status: 502, json: { error: "down" } } });
+ const seen: (string | null)[] = [];
+
+ const view = render(
+
+
+ ,
+ );
+
+ expect(await view.findByText("workspace page")).not.toBeNull();
+ expect(seen.at(-2)).toBe(JSON.stringify([{ id: "c1", name: "Warehouse" }]));
+ });
+
test("local mode: the page reads the browser copy as before", async () => {
mockGlobalFetch({ "/api/storage/config": { json: { provider: "local", serverMode: false } } });
const seen: (string | null)[] = [];
diff --git a/tests/unit/lib/storage/workspace-owner.test.ts b/tests/unit/lib/storage/workspace-owner.test.ts
index a0160f7bf..b5351b151 100644
--- a/tests/unit/lib/storage/workspace-owner.test.ts
+++ b/tests/unit/lib/storage/workspace-owner.test.ts
@@ -56,7 +56,26 @@ describe("claimWorkspaceForSignedInAccount", () => {
expect(fetchMock.mock.calls.length).toBe(1);
});
- test("a storage mode that cannot be read counts as local mode, as the sync counts it", async () => {
+ test("a storage mode that cannot be read throws for a copy that was bound to a server account", async () => {
+ mockGlobalFetch({ "/api/storage/config": { ok: false, status: 500, json: { error: "down" } } });
+ await expect(claimWorkspaceForSignedInAccount()).rejects.toThrow("HTTP 500");
+
+ localStorage.removeItem(WORKSPACE_OWNER_KEY);
+ globalThis.fetch = (async () => {
+ throw new Error("offline");
+ }) as unknown as typeof fetch;
+ await expect(claimWorkspaceForSignedInAccount()).rejects.toThrow("offline");
+
+ localStorage.setItem(WORKSPACE_OWNER_KEY, "admin@libredb.org");
+ localStorage.removeItem(SERVER_MIGRATED_KEY);
+ await expect(claimWorkspaceForSignedInAccount()).rejects.toThrow("offline");
+ expect(localStorage.getItem("libredb_connections")).not.toBeNull();
+ });
+
+ test("a storage mode that cannot be read counts as local mode for a copy never bound to a server account", async () => {
+ localStorage.removeItem(WORKSPACE_OWNER_KEY);
+ localStorage.removeItem(SERVER_MIGRATED_KEY);
+
mockGlobalFetch({ "/api/storage/config": { ok: false, status: 500, json: { error: "down" } } });
await claimWorkspaceForSignedInAccount();
expect(localStorage.getItem("libredb_connections")).not.toBeNull();
From 404c88e1f0489e76b36d86f9899fe6708b8bea33 Mon Sep 17 00:00:00 2001
From: cevheri
Date: Wed, 7 Oct 2026 11:59:53 +0300
Subject: [PATCH 08/28] fix(storage): reload an open page when another tab
changes whose browser copy it is
Every tab of a browser profile shares the copy. When another tab records a different owner or clears it, a page inside WorkspaceOwnerGate stops rendering and reloads, so the owner check runs again for the account signed in now.
---
docs/STORAGE.md | 1 +
src/components/auth/WorkspaceOwnerGate.tsx | 23 +++-
src/lib/storage/workspace-owner.ts | 13 ++-
.../auth/WorkspaceOwnerGate.test.tsx | 109 +++++++++++++++++-
4 files changed, 136 insertions(+), 10 deletions(-)
diff --git a/docs/STORAGE.md b/docs/STORAGE.md
index 4ae01b09c..5112d5694 100644
--- a/docs/STORAGE.md
+++ b/docs/STORAGE.md
@@ -1270,6 +1270,7 @@ The copy is every synced collection, the editor tabs (never stored on the server
- **Sign-out** (the editor, the admin dashboard and the launch page): pending collections are pushed while the session is still valid, then `POST /api/auth/logout` ends the session, and only then is the copy cleared.
The cleared copy keeps the migration flag and no owner, so whatever is written to it afterwards is cleared at the next sign-in rather than migrated.
When the storage mode cannot be read or a pending push does not land, the sign-out fails and the copy stays, so no unsaved change is dropped; when the server refuses the sign-out, the copy stays and the sync goes on.
+- **Other open tabs:** every tab of the browser profile shares the copy, so when another tab records a different owner or clears the copy (a sign-out, a different account signing in), each page inside `WorkspaceOwnerGate` stops rendering and reloads, and the owner check runs again for the account signed in now.
- **A session that ended on its own** (expiry, a disabled account) clears nothing; the owner check at the next sign-in, on whichever of these pages it lands, covers a different account.
- **The first sign-in after upgrading to a release that records the owner:** a browser that was already migrated has no owner yet, so its copy is cleared once, for the same account too; open editor tabs, object-source drafts and any change that had not reached the server start over.
diff --git a/src/components/auth/WorkspaceOwnerGate.tsx b/src/components/auth/WorkspaceOwnerGate.tsx
index cd22442cb..473e19401 100644
--- a/src/components/auth/WorkspaceOwnerGate.tsx
+++ b/src/components/auth/WorkspaceOwnerGate.tsx
@@ -2,6 +2,7 @@
import { type ReactNode, useEffect, useState } from "react";
import { logger } from "@/lib/logger";
+import { WORKSPACE_OWNER_KEY } from "@/lib/storage/local-storage";
import { claimWorkspaceForSignedInAccount } from "@/lib/storage/workspace-owner";
/**
@@ -9,16 +10,31 @@ import { claimWorkspaceForSignedInAccount } from "@/lib/storage/workspace-owner"
* signed-in account's. In server storage mode the browser copy belongs to the signed-in account,
* so a copy another account left behind is cleared before the page reads it, and signing in as
* a different account starts from that account's server data on every such page, not only the
- * editor. In local mode the page renders after the storage mode is known, with the copy as it is.
+ * editor. The copy is shared by every tab of the browser profile: when another tab records a
+ * different owner or clears it (a sign-out, a different account signing in), this page stops
+ * rendering and reloads, so the check runs again for the account signed in now. In local mode
+ * the page renders after the storage mode is known, with the copy as it is.
*/
export function WorkspaceOwnerGate({ children }: { children: ReactNode }) {
const [state, setState] = useState<"checking" | "ready" | "failed">("checking");
useEffect(() => {
let cancelled = false;
+ let stopListening = () => {};
claimWorkspaceForSignedInAccount().then(
- () => {
- if (!cancelled) setState("ready");
+ (owner) => {
+ if (cancelled) return;
+ setState("ready");
+ if (owner === null) return;
+ const onStorage = (event: StorageEvent) => {
+ if (event.key !== null && event.key !== WORKSPACE_OWNER_KEY) return;
+ if (localStorage.getItem(WORKSPACE_OWNER_KEY) === owner) return;
+ stopListening();
+ setState("checking");
+ window.location.reload();
+ };
+ window.addEventListener("storage", onStorage);
+ stopListening = () => window.removeEventListener("storage", onStorage);
},
(err: unknown) => {
logger.warn("Could not match this browser's workspace to the signed-in account", {
@@ -29,6 +45,7 @@ export function WorkspaceOwnerGate({ children }: { children: ReactNode }) {
);
return () => {
cancelled = true;
+ stopListening();
};
}, []);
diff --git a/src/lib/storage/workspace-owner.ts b/src/lib/storage/workspace-owner.ts
index b88450484..64a8ba136 100644
--- a/src/lib/storage/workspace-owner.ts
+++ b/src/lib/storage/workspace-owner.ts
@@ -26,9 +26,10 @@ export async function readSignedInUsername(): Promise {
* nothing. Throws in server mode when the signed-in account cannot be read: the copy must not be
* used. A storage mode that cannot be read throws too when the copy carries an owner or the
* migration flag, which only server mode writes, so such a copy is never shown unchecked; a copy
- * with neither counts as local mode, as `useStorageSync` counts it.
+ * with neither counts as local mode, as `useStorageSync` counts it. Answers the account the copy
+ * now belongs to, or null when no account was matched (local mode).
*/
-export async function claimWorkspaceForSignedInAccount(): Promise {
+export async function claimWorkspaceForSignedInAccount(): Promise {
let config: StorageConfigResponse;
try {
const res = await appFetch("/api/storage/config");
@@ -36,10 +37,12 @@ export async function claimWorkspaceForSignedInAccount(): Promise {
config = (await res.json()) as StorageConfigResponse;
} catch (err) {
if (isServerBoundCopy()) throw err;
- return;
+ return null;
}
- if (!config.serverMode) return;
- claimAccountWorkspace(await readSignedInUsername());
+ if (!config.serverMode) return null;
+ const username = await readSignedInUsername();
+ claimAccountWorkspace(username);
+ return username;
}
function isServerBoundCopy(): boolean {
diff --git a/tests/components/auth/WorkspaceOwnerGate.test.tsx b/tests/components/auth/WorkspaceOwnerGate.test.tsx
index 72111d3a2..4bc0596d3 100644
--- a/tests/components/auth/WorkspaceOwnerGate.test.tsx
+++ b/tests/components/auth/WorkspaceOwnerGate.test.tsx
@@ -1,7 +1,7 @@
import "../../setup-dom";
-import { afterEach, beforeEach, describe, expect, test } from "bun:test";
-import { cleanup, render, waitFor } from "@testing-library/react";
+import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
+import { act, cleanup, render, waitFor } from "@testing-library/react";
import { mockGlobalFetch, restoreGlobalFetch } from "../../helpers/mock-fetch";
import { WorkspaceOwnerGate } from "@/components/auth/WorkspaceOwnerGate";
@@ -160,4 +160,109 @@ describe("WorkspaceOwnerGate", () => {
await new Promise((resolve) => setTimeout(resolve, 10));
expect(view.queryByRole("alert")).toBeNull();
});
+
+ describe("another tab changes whose copy this is", () => {
+ const original = window.location.reload;
+ let reload: ReturnType;
+
+ beforeEach(() => {
+ reload = mock(() => {});
+ Object.defineProperty(window.location, "reload", { value: reload, configurable: true });
+ });
+
+ afterEach(() => {
+ Object.defineProperty(window.location, "reload", { value: original, configurable: true });
+ });
+
+ function ownerChanged(key: string | null, owner: string | null) {
+ if (owner === null) localStorage.removeItem("libredb_workspace_owner");
+ else localStorage.setItem("libredb_workspace_owner", owner);
+ act(() => {
+ window.dispatchEvent(new window.StorageEvent("storage", { key, newValue: owner }));
+ });
+ }
+
+ test("server mode: a different account signing in elsewhere drops the page and reloads", async () => {
+ mockGlobalFetch({ ...SERVER_MODE, "/api/auth/me": { json: { user: { username: "admin@libredb.org" } } } });
+ const view = render(
+
+ workspace page
+ ,
+ );
+ expect(await view.findByText("workspace page")).not.toBeNull();
+
+ ownerChanged("libredb_workspace_owner", "user@libredb.org");
+
+ expect(view.queryByText("workspace page")).toBeNull();
+ expect(reload).toHaveBeenCalledTimes(1);
+ });
+
+ for (const [what, key] of [
+ ["a sign-out elsewhere", "libredb_workspace_owner"],
+ ["a browser copy cleared elsewhere", null],
+ ] as const) {
+ test(`server mode: ${what} drops the page and reloads`, async () => {
+ mockGlobalFetch({ ...SERVER_MODE, "/api/auth/me": { json: { user: { username: "admin@libredb.org" } } } });
+ const view = render(
+
+ workspace page
+ ,
+ );
+ expect(await view.findByText("workspace page")).not.toBeNull();
+
+ ownerChanged(key, null);
+
+ expect(view.queryByText("workspace page")).toBeNull();
+ expect(reload).toHaveBeenCalledTimes(1);
+ });
+ }
+
+ test("server mode: other keys, and the same owner written again, keep the page", async () => {
+ mockGlobalFetch({ ...SERVER_MODE, "/api/auth/me": { json: { user: { username: "admin@libredb.org" } } } });
+ const view = render(
+
+ workspace page
+ ,
+ );
+ expect(await view.findByText("workspace page")).not.toBeNull();
+
+ act(() => {
+ window.dispatchEvent(new window.StorageEvent("storage", { key: "libredb_history", newValue: "[]" }));
+ });
+ ownerChanged("libredb_workspace_owner", "admin@libredb.org");
+
+ expect(view.queryByText("workspace page")).not.toBeNull();
+ expect(reload).not.toHaveBeenCalled();
+ });
+
+ test("local mode: an owner key written elsewhere changes nothing", async () => {
+ mockGlobalFetch({ "/api/storage/config": { json: { provider: "local", serverMode: false } } });
+ const view = render(
+
+ workspace page
+ ,
+ );
+ expect(await view.findByText("workspace page")).not.toBeNull();
+
+ ownerChanged("libredb_workspace_owner", "user@libredb.org");
+
+ expect(view.queryByText("workspace page")).not.toBeNull();
+ expect(reload).not.toHaveBeenCalled();
+ });
+
+ test("the page stops listening once it is gone", async () => {
+ mockGlobalFetch({ ...SERVER_MODE, "/api/auth/me": { json: { user: { username: "admin@libredb.org" } } } });
+ const view = render(
+
+ workspace page
+ ,
+ );
+ expect(await view.findByText("workspace page")).not.toBeNull();
+ view.unmount();
+
+ ownerChanged("libredb_workspace_owner", "user@libredb.org");
+
+ expect(reload).not.toHaveBeenCalled();
+ });
+ });
});
From 7aaaee0ac82f43117242344eeca4b8e340a51f70 Mon Sep 17 00:00:00 2001
From: cevheri
Date: Wed, 7 Oct 2026 12:00:32 +0300
Subject: [PATCH 09/28] fix(auth): report a refused sign-out as failed and stay
on the page
The editor and the admin dashboard now check the sign-out response: a refused sign-out shows the failure toast and does not go to /login, matching the launch page, which already said so.
---
src/components/admin/AdminDashboard.tsx | 1 +
src/hooks/use-auth.ts | 1 +
tests/components/LaunchPage.test.tsx | 14 +++++++++++
.../components/admin/AdminDashboard.test.tsx | 23 +++++++++++++++++++
tests/hooks/use-auth.test.ts | 7 +++---
5 files changed, 43 insertions(+), 3 deletions(-)
diff --git a/src/components/admin/AdminDashboard.tsx b/src/components/admin/AdminDashboard.tsx
index c0006fac2..dc48a298a 100644
--- a/src/components/admin/AdminDashboard.tsx
+++ b/src/components/admin/AdminDashboard.tsx
@@ -35,6 +35,7 @@ export default function AdminDashboard({ children }: AdminDashboardProps) {
let data: { redirectUrl?: string };
try {
const res = await releaseAccountWorkspace(() => appFetch("/api/auth/logout", { method: "POST" }));
+ if (!res.ok) throw new Error(`Sign-out refused: HTTP ${res.status}`);
data = await res.json();
} catch {
toast.error("Failed to logout");
diff --git a/src/hooks/use-auth.ts b/src/hooks/use-auth.ts
index e6e31ba34..76503fbe5 100644
--- a/src/hooks/use-auth.ts
+++ b/src/hooks/use-auth.ts
@@ -53,6 +53,7 @@ export function useAuth() {
// session-ended path above clears nothing; the owner check every page that reads the copy
// runs at the next sign-in covers a different account.
const res = await releaseAccountWorkspace(() => appFetch("/api/auth/logout", { method: "POST" }));
+ if (!res.ok) throw new Error(`Sign-out refused: HTTP ${res.status}`);
const data = await res.json();
toast({ title: "Logged out", description: "You have been successfully logged out." });
diff --git a/tests/components/LaunchPage.test.tsx b/tests/components/LaunchPage.test.tsx
index 85229f1c0..c44df670f 100644
--- a/tests/components/LaunchPage.test.tsx
+++ b/tests/components/LaunchPage.test.tsx
@@ -369,6 +369,20 @@ describe("the /launch page in a browser signed in as someone else", () => {
expect(localStorage.getItem("libredb_workspace_owner")).toBeNull();
});
+ test("server mode: a sign-out the server refused says so and keeps the copy", async () => {
+ serveConflict(async () => answer(500, { error: "Server error" }), true);
+ const view = render();
+ fireEvent.click(await view.findByRole("button", { name: "Sign out" }));
+ expect(
+ await view.findByText(
+ "Studio could not sign you out. Sign out from the editor, then open Studio again from the platform.",
+ ),
+ ).not.toBeNull();
+ expect(view.queryByText(/You are signed out/)).toBeNull();
+ expect(localStorage.getItem("libredb_connections")).not.toBeNull();
+ expect(localStorage.getItem("libredb_workspace_owner")).toBe("ada@example.com");
+ });
+
test("local mode: the copy stays", async () => {
serveConflict(async () => answer(200, { success: true }));
const view = render();
diff --git a/tests/components/admin/AdminDashboard.test.tsx b/tests/components/admin/AdminDashboard.test.tsx
index 7a325f5e6..5835e2900 100644
--- a/tests/components/admin/AdminDashboard.test.tsx
+++ b/tests/components/admin/AdminDashboard.test.tsx
@@ -241,6 +241,29 @@ describe("AdminDashboard", () => {
expect(localStorage.getItem("libredb_connections")).not.toBeNull();
});
+ test("a sign-out the server refused says so, stays, and keeps the copy", async () => {
+ mockToastError.mockClear();
+ mockGlobalFetch({
+ "/api/auth/logout": { ok: false, status: 500, json: { error: "Server error" } },
+ "/api/storage/config": { json: { provider: "postgres", serverMode: true } },
+ });
+
+ let renderResult: ReturnType;
+ await act(async () => {
+ renderResult = render(content);
+ });
+ await act(async () => {
+ fireEvent.click(renderResult!.getByText("Logout").closest("button")!);
+ });
+ await waitFor(() => {
+ expect(mockToastError).toHaveBeenCalledWith("Failed to logout");
+ });
+
+ expect(mockToastSuccess).not.toHaveBeenCalled();
+ expect(mockRouterPush).not.toHaveBeenCalled();
+ expect(localStorage.getItem("libredb_connections")).not.toBeNull();
+ });
+
test("local mode: the copy stays", async () => {
await clickLogout();
diff --git a/tests/hooks/use-auth.test.ts b/tests/hooks/use-auth.test.ts
index 33542dbac..ddac8d9c9 100644
--- a/tests/hooks/use-auth.test.ts
+++ b/tests/hooks/use-auth.test.ts
@@ -381,7 +381,7 @@ describe("useAuth", () => {
// ── Logout with non-ok response still navigates ───────────────────────────
- test("handleLogout navigates even if logout API returns non-ok", async () => {
+ test("handleLogout says a refused sign-out did not complete and stays on the page", async () => {
mockGlobalFetch({
"/api/auth/me": { ok: true, json: { user: { role: "user" } } },
"/api/storage/config": { ok: true, json: { provider: "local", serverMode: false } },
@@ -398,8 +398,9 @@ describe("useAuth", () => {
await result.current.handleLogout();
});
- // The fetch didn't throw, so logout path should succeed
- expect(mockRouterPush).toHaveBeenCalledWith("/login");
+ expect(mockRouterPush).not.toHaveBeenCalled();
+ expect(mockToastSuccess).not.toHaveBeenCalled();
+ expect(mockToastError).toHaveBeenCalledWith("Error", { description: "Failed to logout." });
});
test("auth hook sends requests under basePath and leaves Next router navigation logical", async () => {
await withBasePathEnv("/~/libredb", async () => {
From 1231f0fd2bb763f13608950725a3bdc7f0683cd7 Mon Sep 17 00:00:00 2001
From: cevheri
Date: Wed, 7 Oct 2026 12:02:09 +0300
Subject: [PATCH 10/28] fix(storage): keep the browser copy cleared when a
sign-out lands during the first pull
The pull writes the server answer only while the copy still belongs to the account it was claimed for, and the owner is recorded by the claim alone. A sign-out that cleared the copy while the pull was out keeps it cleared.
---
docs/STORAGE.md | 11 ++++-----
src/hooks/use-storage-sync.ts | 12 +++++++---
tests/isolated/use-storage-sync.test.ts | 31 +++++++++++++++++++++++++
3 files changed, 45 insertions(+), 9 deletions(-)
diff --git a/docs/STORAGE.md b/docs/STORAGE.md
index 5112d5694..9fc62f6e7 100644
--- a/docs/STORAGE.md
+++ b/docs/STORAGE.md
@@ -1228,8 +1228,8 @@ App Mount
│ │
│ ┌──────────────────────▼──────────────────────┐
│ │ Pull: GET /api/storage │
- │ │ → Write server data into localStorage │
- │ │ → Set libredb_workspace_owner = username │
+ │ │ → Write server data into localStorage, │
+ │ │ only while the owner is still username │
│ │ → Components re-render from localStorage │
│ └──────────────────────┬──────────────────────┘
│ │
@@ -1290,16 +1290,15 @@ When a user first enables server mode (or a new user logs in for the first time)
```
1. Hook detects serverMode = true
-2. Reads the signed-in username (GET /api/auth/me) and clears a browser copy that belongs to another account
+2. Reads the signed-in username (GET /api/auth/me), clears a browser copy that belongs to another account, and sets 'libredb_workspace_owner' to the username
3. Checks localStorage('libredb_server_migrated') flag
4. If not migrated:
a. Reads whichever of the 13 collections exist in localStorage (a fresh browser with none simply sets the flag and skips)
b. POST /api/storage/migrate with the collected payload
c. Server calls provider.mergeData() — upserts each collection as a whole blob in one transaction
d. Sets 'libredb_server_migrated' flag in localStorage
-5. Pull: GET /api/storage → overwrite localStorage with server data
-6. Sets 'libredb_workspace_owner' to the username
-7. Subsequent mutations sync normally via push
+5. Pull: GET /api/storage → overwrite localStorage with server data, unless a sign-out cleared the copy while the pull was out
+6. Subsequent mutations sync normally via push
```
Step 2 is described in [The Browser Copy Belongs to the Signed-In Account](#the-browser-copy-belongs-to-the-signed-in-account).
diff --git a/src/hooks/use-storage-sync.ts b/src/hooks/use-storage-sync.ts
index 075dde1de..9d0f4a2a3 100644
--- a/src/hooks/use-storage-sync.ts
+++ b/src/hooks/use-storage-sync.ts
@@ -175,12 +175,18 @@ export function useStorageSync(): StorageSyncState {
);
// ── Pull all data from server → localStorage ──
- const pullFromServer = useCallback(async () => {
+ /**
+ * `owner` is the account the copy was claimed for. The answer is written only while the copy
+ * still belongs to it: a sign-out (or another tab) that cleared the copy while the pull was out
+ * keeps it cleared.
+ */
+ const pullFromServer = useCallback(async (owner: string) => {
setIsSyncing(true);
try {
const res = await appFetch("/api/storage");
if (!res.ok) return;
const data = (await res.json()) as Partial;
+ if (localStorage.getItem(WORKSPACE_OWNER_KEY) !== owner) return;
// Write server data to localStorage (overwrite)
if (data.connections) writeCollectionToLocal("connections", data.connections);
@@ -300,13 +306,13 @@ export function useStorageSync(): StorageSyncState {
setIsServerMode(true);
serverModeRef.current = true;
+ // Records `username` as the owner of the copy it keeps or clears.
claimAccountWorkspace(username);
// Migration first, then pull
await migrateToServer();
if (!cancelled) {
- await pullFromServer();
+ await pullFromServer(username);
}
- localStorage.setItem(WORKSPACE_OWNER_KEY, username);
}
} catch {
// Server unreachable — stay in local mode
diff --git a/tests/isolated/use-storage-sync.test.ts b/tests/isolated/use-storage-sync.test.ts
index 319100a2b..b716d3328 100644
--- a/tests/isolated/use-storage-sync.test.ts
+++ b/tests/isolated/use-storage-sync.test.ts
@@ -1318,6 +1318,37 @@ describe("useStorageSync", () => {
).toEqual([]);
});
+ test("a sign-out while the first pull is out leaves the browser copy cleared", async () => {
+ localStorage.setItem("libredb_workspace_owner", USERNAME);
+ localStorage.setItem("libredb_server_migrated", "2026-10-01");
+ let pullStarted = false;
+ let answerPull: () => void = () => {};
+ setupSignOut({
+ "/api/storage": () =>
+ new Promise((resolve) => {
+ pullStarted = true;
+ answerPull = () => resolve({ ok: true, status: 200, json: { connections: [{ id: "server-c1" }] } });
+ }),
+ });
+
+ const { result } = renderHook(() => useStorageSync());
+ await waitFor(() => {
+ expect(result.current.isServerMode).toBe(true);
+ expect(pullStarted).toBe(true);
+ });
+ await act(async () => {
+ await releaseAccountWorkspace(signOutAnswering(200));
+ });
+ await act(async () => {
+ answerPull();
+ await new Promise((r) => setTimeout(r, 10));
+ });
+
+ expect(result.current.isReady).toBe(true);
+ expect(localStorage.getItem("libredb_connections")).toBeNull();
+ expect(localStorage.getItem("libredb_workspace_owner")).toBeNull();
+ });
+
test("whatever is written after a sign-out never reaches the next account", async () => {
const PREVIOUS = "admin@libredb.org";
localStorage.setItem("libredb_workspace_owner", PREVIOUS);
From 6b1e3b2d21ef1c78e05f58db2b39d31239aedec6 Mon Sep 17 00:00:00 2001
From: cevheri
Date: Wed, 7 Oct 2026 12:02:28 +0300
Subject: [PATCH 11/28] docs(storage): name every writer of the owner key and
the first sign-in after upgrading
The owner key and the migration flag comments name every function that writes them. ARCHITECTURE.md points to the owner rule in STORAGE.md, and STORAGE.md says that the first sign-in after upgrading in server storage mode starts from the account's server data, so open editor tabs from before are not kept.
---
docs/ARCHITECTURE.md | 1 +
docs/STORAGE.md | 3 ++-
src/lib/storage/local-storage.ts | 8 ++++++--
3 files changed, 9 insertions(+), 3 deletions(-)
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index c1246272c..905e12b5c 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -256,6 +256,7 @@ Multi-statement queries execute sequentially via `POST /api/db/multi-query`, one
- `postgres`: Server-side PostgreSQL via `pg`
- **`useStorageSync` hook** in Studio.tsx: discovers mode at runtime via `/api/storage/config`, pulls on mount, pushes mutations (debounced 500ms)
- **Migration**: First login auto-migrates localStorage to server; `libredb_server_migrated` flag prevents re-migration
+- **Browser copy owner** (server mode): `libredb_workspace_owner` binds the browser copy to the signed-in account; a different account starts from its own server data and sign-out clears the copy (see [STORAGE.md](STORAGE.md#the-browser-copy-belongs-to-the-signed-in-account))
- **Graceful degradation**: If server unreachable, localStorage continues working
### 4.5. Client State Management
diff --git a/docs/STORAGE.md b/docs/STORAGE.md
index 9fc62f6e7..db743aee2 100644
--- a/docs/STORAGE.md
+++ b/docs/STORAGE.md
@@ -1272,7 +1272,8 @@ The copy is every synced collection, the editor tabs (never stored on the server
When the storage mode cannot be read or a pending push does not land, the sign-out fails and the copy stays, so no unsaved change is dropped; when the server refuses the sign-out, the copy stays and the sync goes on.
- **Other open tabs:** every tab of the browser profile shares the copy, so when another tab records a different owner or clears the copy (a sign-out, a different account signing in), each page inside `WorkspaceOwnerGate` stops rendering and reloads, and the owner check runs again for the account signed in now.
- **A session that ended on its own** (expiry, a disabled account) clears nothing; the owner check at the next sign-in, on whichever of these pages it lands, covers a different account.
-- **The first sign-in after upgrading to a release that records the owner:** a browser that was already migrated has no owner yet, so its copy is cleared once, for the same account too; open editor tabs, object-source drafts and any change that had not reached the server start over.
+- **The first sign-in after upgrading to a release that records the owner:** a browser that was already migrated has no owner yet, so its copy is cleared once, for the same account too, and that sign-in starts from the account's server data.
+ Open editor tabs from before the upgrade are not kept, since they are never stored on the server, and neither are object-source drafts or any change that had not reached the server.
In local mode none of this runs: the browser copy is the only copy, it stays on sign-out, and no owner is recorded.
diff --git a/src/lib/storage/local-storage.ts b/src/lib/storage/local-storage.ts
index ffaa04f0e..eaee8d0ce 100644
--- a/src/lib/storage/local-storage.ts
+++ b/src/lib/storage/local-storage.ts
@@ -119,11 +119,15 @@ export function removeWorkspaceTabs(connectionId: string): void {
/**
* The signed-in username the browser copy belongs to. Written in server storage mode only, by
- * `useStorageSync`, which compares it with the signed-in account before it uses the copy.
+ * `claimAccountWorkspace` and `resetAccountWorkspace` (from `WorkspaceOwnerGate`, `useStorageSync`
+ * and the sign-out), which compare it with the signed-in account before the copy is used.
*/
export const WORKSPACE_OWNER_KEY = `${KEY_PREFIX}workspace_owner`;
-/** Set once this browser's copy has been handed to a server account (`useStorageSync`). */
+/**
+ * Set once this browser's copy has been handed to a server account: by `useStorageSync` after
+ * the migration, and by `resetAccountWorkspace` when the copy is cleared.
+ */
export const SERVER_MIGRATED_KEY = `${KEY_PREFIX}server_migrated`;
/** Unsaved object-source edits (`components/object-source/source-drafts.ts`). */
From eb16cab535e17b02707dc21c156c56459d63f7e1 Mon Sep 17 00:00:00 2001
From: cevheri
Date: Wed, 7 Oct 2026 12:02:50 +0300
Subject: [PATCH 12/28] fix(storage): show a loading indicator while the owner
check runs
The pages inside WorkspaceOwnerGate rendered an empty body until the check answered. They now show the same loading indicator the code-split views use, labelled for assistive technology.
---
docs/STORAGE.md | 2 +-
src/components/auth/WorkspaceOwnerGate.tsx | 3 ++-
tests/components/auth/WorkspaceOwnerGate.test.tsx | 4 +++-
3 files changed, 6 insertions(+), 3 deletions(-)
diff --git a/docs/STORAGE.md b/docs/STORAGE.md
index db743aee2..f07f35311 100644
--- a/docs/STORAGE.md
+++ b/docs/STORAGE.md
@@ -1258,7 +1258,7 @@ When any `storage.*` mutation fires:
In server mode the browser copy is bound to the account that signed in: `libredb_workspace_owner` holds its username, and it is compared with `GET /api/auth/me` before any page reads the copy.
Every page that reads it (the editor, the admin dashboard and the monitoring page) renders inside `WorkspaceOwnerGate`, which runs that check first; the lifecycle above runs it again before anything is migrated, pulled or pushed.
-When the signed-in account cannot be read, those pages show a message instead of the copy.
+While the check runs, those pages show a loading indicator; when the signed-in account cannot be read, they show a message instead of the copy.
They show it too when `GET /api/storage/config` cannot be read and the copy carries an owner or the migration flag, both written only in server mode; a copy with neither renders as it is, so local mode does not change.
The copy is every synced collection, the editor tabs (never stored on the server), the object-source drafts, the agent thread hint, the migration flag and the owner key itself; per-browser preferences such as the theme and line numbers are not part of it.
`clearAccountWorkspace()` in `src/lib/storage/local-storage.ts` holds that list.
diff --git a/src/components/auth/WorkspaceOwnerGate.tsx b/src/components/auth/WorkspaceOwnerGate.tsx
index 473e19401..9738644fe 100644
--- a/src/components/auth/WorkspaceOwnerGate.tsx
+++ b/src/components/auth/WorkspaceOwnerGate.tsx
@@ -1,6 +1,7 @@
"use client";
import { type ReactNode, useEffect, useState } from "react";
+import { ViewLoading } from "@/components/LazyView";
import { logger } from "@/lib/logger";
import { WORKSPACE_OWNER_KEY } from "@/lib/storage/local-storage";
import { claimWorkspaceForSignedInAccount } from "@/lib/storage/workspace-owner";
@@ -58,5 +59,5 @@ export function WorkspaceOwnerGate({ children }: { children: ReactNode }) {
);
}
- return null;
+ return ;
}
diff --git a/tests/components/auth/WorkspaceOwnerGate.test.tsx b/tests/components/auth/WorkspaceOwnerGate.test.tsx
index 4bc0596d3..1fd624238 100644
--- a/tests/components/auth/WorkspaceOwnerGate.test.tsx
+++ b/tests/components/auth/WorkspaceOwnerGate.test.tsx
@@ -120,7 +120,7 @@ describe("WorkspaceOwnerGate", () => {
expect(localStorage.getItem("libredb_workspace_owner")).toBe("admin@libredb.org");
});
- test("nothing renders while the check is out, and an answer after unmount changes nothing", async () => {
+ test("the page waits behind a loading indicator while the check is out, and an answer after unmount changes nothing", async () => {
let answer: (value: Response) => void = () => {};
globalThis.fetch = (() => new Promise((resolve) => (answer = resolve))) as unknown as typeof fetch;
const seen: (string | null)[] = [];
@@ -131,6 +131,8 @@ describe("WorkspaceOwnerGate", () => {
,
);
expect(view.queryByText("workspace page")).toBeNull();
+ const loading = view.getByTestId("view-loading");
+ expect(loading.getAttribute("aria-label")).toBe("Checking the signed-in account");
view.unmount();
answer(new Response(JSON.stringify({ provider: "local", serverMode: false }), { status: 200 }));
await new Promise((resolve) => setTimeout(resolve, 10));
From 70598198a6a0069ab02ac1813452d4c65452bde4 Mon Sep 17 00:00:00 2001
From: cevheri
Date: Wed, 7 Oct 2026 12:16:44 +0300
Subject: [PATCH 13/28] fix(storage): record the signed-in account on the
settings pages
A sign-in can land on /settings/mcp or /settings/authenticator, so both now render inside WorkspaceOwnerGate through a settings layout. The browser copy is recorded as the signed-in account's there too, and every other open tab of the browser profile follows that account.
---
src/app/settings/layout.tsx | 7 +++
src/lib/storage/workspace-owner.ts | 6 +--
tests/components/SettingsLayout.test.tsx | 65 ++++++++++++++++++++++++
3 files changed, 75 insertions(+), 3 deletions(-)
create mode 100644 src/app/settings/layout.tsx
create mode 100644 tests/components/SettingsLayout.test.tsx
diff --git a/src/app/settings/layout.tsx b/src/app/settings/layout.tsx
new file mode 100644
index 000000000..9a58368ce
--- /dev/null
+++ b/src/app/settings/layout.tsx
@@ -0,0 +1,7 @@
+import { WorkspaceOwnerGate } from "@/components/auth/WorkspaceOwnerGate";
+
+export default function SettingsLayout({ children }: { children: React.ReactNode }) {
+ // The gate: a sign-in can land on a settings page, so the browser copy is recorded as the
+ // signed-in account's here too, and every other tab of the browser profile follows that account.
+ return {children};
+}
diff --git a/src/lib/storage/workspace-owner.ts b/src/lib/storage/workspace-owner.ts
index 64a8ba136..5ad51075b 100644
--- a/src/lib/storage/workspace-owner.ts
+++ b/src/lib/storage/workspace-owner.ts
@@ -1,8 +1,8 @@
/**
* Whose browser copy this is, in server storage mode. The copy belongs to the signed-in account:
- * every page that reads it (the editor, the admin dashboard, monitoring) first makes it the
- * signed-in account's (`claimAccountWorkspace`), so a different account starts from its own
- * server data.
+ * every page that reads it (the editor, the admin dashboard, monitoring), and every settings page a
+ * sign-in can land on, first makes it the signed-in account's (`claimAccountWorkspace`), so a
+ * different account starts from its own server data.
*/
import { appFetch } from "@/lib/config/base-path";
diff --git a/tests/components/SettingsLayout.test.tsx b/tests/components/SettingsLayout.test.tsx
new file mode 100644
index 000000000..22033e7f2
--- /dev/null
+++ b/tests/components/SettingsLayout.test.tsx
@@ -0,0 +1,65 @@
+import "../setup-dom";
+import { afterEach, beforeEach, describe, expect, test } from "bun:test";
+import { cleanup, render } from "@testing-library/react";
+import { mockGlobalFetch, restoreGlobalFetch } from "../helpers/mock-fetch";
+
+import SettingsLayout from "@/app/settings/layout";
+import McpSettingsPage from "@/app/settings/mcp/page";
+
+const MCP_STATUS = {
+ state: "ready",
+ problems: [],
+ url: "https://studio.example.com/api/mcp",
+ tokenTtlDays: 30,
+ visibleConnections: 1,
+};
+
+describe("/settings layout", () => {
+ beforeEach(() => {
+ localStorage.clear();
+ localStorage.setItem("libredb_connections", JSON.stringify([{ id: "c1", name: "Warehouse" }]));
+ localStorage.setItem("libredb_workspace_owner", "admin@libredb.org");
+ localStorage.setItem("libredb_server_migrated", "2026-10-07");
+ });
+
+ afterEach(() => {
+ cleanup();
+ restoreGlobalFetch();
+ localStorage.clear();
+ });
+
+ test("server mode: a settings page records the signed-in account as the owner of the browser copy", async () => {
+ mockGlobalFetch({
+ "/api/storage/config": { json: { provider: "postgres", serverMode: true } },
+ "/api/auth/me": { json: { user: { username: "user@libredb.org" } } },
+ "/api/mcp/token": { json: MCP_STATUS },
+ });
+
+ const view = render(
+
+
+ ,
+ );
+
+ expect(await view.findByRole("link", { name: /Back to the editor/i })).not.toBeNull();
+ expect(localStorage.getItem("libredb_workspace_owner")).toBe("user@libredb.org");
+ expect(localStorage.getItem("libredb_connections")).toBeNull();
+ });
+
+ test("local mode: a settings page renders and leaves the browser copy as it is", async () => {
+ mockGlobalFetch({
+ "/api/storage/config": { json: { provider: "local", serverMode: false } },
+ "/api/mcp/token": { json: MCP_STATUS },
+ });
+
+ const view = render(
+
+
+ ,
+ );
+
+ expect(await view.findByRole("link", { name: /Back to the editor/i })).not.toBeNull();
+ expect(localStorage.getItem("libredb_workspace_owner")).toBe("admin@libredb.org");
+ expect(localStorage.getItem("libredb_connections")).toBe(JSON.stringify([{ id: "c1", name: "Warehouse" }]));
+ });
+});
From 2909473083e15280daee53479a783590ccc15db1 Mon Sep 17 00:00:00 2001
From: cevheri
Date: Wed, 7 Oct 2026 12:16:44 +0300
Subject: [PATCH 14/28] docs(storage): name the settings pages and the copy
written while the storage mode was unread
The settings pages render inside the owner gate, and the owner check at the next sign-in does not cover a copy written while the storage mode could not be read on a browser profile with no owner yet.
---
docs/STORAGE.md | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/docs/STORAGE.md b/docs/STORAGE.md
index f07f35311..c306bc9cf 100644
--- a/docs/STORAGE.md
+++ b/docs/STORAGE.md
@@ -1258,6 +1258,7 @@ When any `storage.*` mutation fires:
In server mode the browser copy is bound to the account that signed in: `libredb_workspace_owner` holds its username, and it is compared with `GET /api/auth/me` before any page reads the copy.
Every page that reads it (the editor, the admin dashboard and the monitoring page) renders inside `WorkspaceOwnerGate`, which runs that check first; the lifecycle above runs it again before anything is migrated, pulled or pushed.
+The settings pages (`/settings/mcp`, `/settings/authenticator`) do not read the copy but render inside the gate too, because a sign-in can land on them, and the gate records the owner there.
While the check runs, those pages show a loading indicator; when the signed-in account cannot be read, they show a message instead of the copy.
They show it too when `GET /api/storage/config` cannot be read and the copy carries an owner or the migration flag, both written only in server mode; a copy with neither renders as it is, so local mode does not change.
The copy is every synced collection, the editor tabs (never stored on the server), the object-source drafts, the agent thread hint, the migration flag and the owner key itself; per-browser preferences such as the theme and line numbers are not part of it.
@@ -1270,8 +1271,9 @@ The copy is every synced collection, the editor tabs (never stored on the server
- **Sign-out** (the editor, the admin dashboard and the launch page): pending collections are pushed while the session is still valid, then `POST /api/auth/logout` ends the session, and only then is the copy cleared.
The cleared copy keeps the migration flag and no owner, so whatever is written to it afterwards is cleared at the next sign-in rather than migrated.
When the storage mode cannot be read or a pending push does not land, the sign-out fails and the copy stays, so no unsaved change is dropped; when the server refuses the sign-out, the copy stays and the sync goes on.
-- **Other open tabs:** every tab of the browser profile shares the copy, so when another tab records a different owner or clears the copy (a sign-out, a different account signing in), each page inside `WorkspaceOwnerGate` stops rendering and reloads, and the owner check runs again for the account signed in now.
+- **Other open tabs:** every tab of the browser profile shares the copy, so when another tab records a different owner or clears the copy (a sign-out, or a different account signing in and landing on the editor, an admin page, the monitoring page or a settings page), each page inside `WorkspaceOwnerGate` stops rendering and reloads, and the owner check runs again for the account signed in now.
- **A session that ended on its own** (expiry, a disabled account) clears nothing; the owner check at the next sign-in, on whichever of these pages it lands, covers a different account.
+ It does not cover a copy written while `GET /api/storage/config` could not be read on a browser profile that had no owner and no migration flag yet: such a copy is treated as local-mode data, so the next account to sign in records it as its own and migrates it.
- **The first sign-in after upgrading to a release that records the owner:** a browser that was already migrated has no owner yet, so its copy is cleared once, for the same account too, and that sign-in starts from the account's server data.
Open editor tabs from before the upgrade are not kept, since they are never stored on the server, and neither are object-source drafts or any change that had not reached the server.
From 58aaa411a4da8145b1e7a293acee943bde347425 Mon Sep 17 00:00:00 2001
From: cevheri
Date: Wed, 7 Oct 2026 12:24:54 +0300
Subject: [PATCH 15/28] fix(storage): ask which account is signed in when a
gated tab is shown again
A sign-in can land where no owner is recorded, such as an unknown address,
so the storage event never reaches an open tab. The gate now also asks
GET /api/auth/me whenever its tab becomes visible and reloads when the
account is no longer the one it rendered for. STORAGE.md names this and
says sign-out pushes only the signing-out tab's pending collections.
---
docs/STORAGE.md | 6 +-
src/components/auth/WorkspaceOwnerGate.tsx | 42 +++++-
.../auth/WorkspaceOwnerGate.test.tsx | 124 ++++++++++++++++++
3 files changed, 163 insertions(+), 9 deletions(-)
diff --git a/docs/STORAGE.md b/docs/STORAGE.md
index c306bc9cf..e5a878f78 100644
--- a/docs/STORAGE.md
+++ b/docs/STORAGE.md
@@ -1268,11 +1268,13 @@ The copy is every synced collection, the editor tabs (never stored on the server
- **A different account, or a copy with no owner that was already migrated:** the copy is cleared first and recorded as this account's, so the account starts from its own server data.
- **A copy with no owner that was never migrated:** local-mode data, recorded as the signed-in account's and migrated into that account as described in [Migration Flow](#10-migration-flow).
A different account that signs in before the migration ran finds it owned by the first account, so the copy is cleared for it.
-- **Sign-out** (the editor, the admin dashboard and the launch page): pending collections are pushed while the session is still valid, then `POST /api/auth/logout` ends the session, and only then is the copy cleared.
+- **Sign-out** (the editor, the admin dashboard and the launch page): the signing-out tab's pending collections are pushed while the session is still valid, then `POST /api/auth/logout` ends the session, and only then is the copy cleared.
+ A change still waiting in another open tab is not pushed; it does not reach the server.
The cleared copy keeps the migration flag and no owner, so whatever is written to it afterwards is cleared at the next sign-in rather than migrated.
When the storage mode cannot be read or a pending push does not land, the sign-out fails and the copy stays, so no unsaved change is dropped; when the server refuses the sign-out, the copy stays and the sync goes on.
- **Other open tabs:** every tab of the browser profile shares the copy, so when another tab records a different owner or clears the copy (a sign-out, or a different account signing in and landing on the editor, an admin page, the monitoring page or a settings page), each page inside `WorkspaceOwnerGate` stops rendering and reloads, and the owner check runs again for the account signed in now.
-- **A session that ended on its own** (expiry, a disabled account) clears nothing; the owner check at the next sign-in, on whichever of these pages it lands, covers a different account.
+ A sign-in can also land where no owner is recorded (an unknown address), so a page inside `WorkspaceOwnerGate` also asks which account is signed in whenever its tab is shown again, and reloads the same way when that is no longer the account it rendered for.
+- **A session that ended on its own** (expiry, a disabled account) clears nothing; the owner check covers a different account on the first of these pages it opens, and an open tab when it is shown again.
It does not cover a copy written while `GET /api/storage/config` could not be read on a browser profile that had no owner and no migration flag yet: such a copy is treated as local-mode data, so the next account to sign in records it as its own and migrates it.
- **The first sign-in after upgrading to a release that records the owner:** a browser that was already migrated has no owner yet, so its copy is cleared once, for the same account too, and that sign-in starts from the account's server data.
Open editor tabs from before the upgrade are not kept, since they are never stored on the server, and neither are object-source drafts or any change that had not reached the server.
diff --git a/src/components/auth/WorkspaceOwnerGate.tsx b/src/components/auth/WorkspaceOwnerGate.tsx
index 9738644fe..f47362eda 100644
--- a/src/components/auth/WorkspaceOwnerGate.tsx
+++ b/src/components/auth/WorkspaceOwnerGate.tsx
@@ -4,7 +4,7 @@ import { type ReactNode, useEffect, useState } from "react";
import { ViewLoading } from "@/components/LazyView";
import { logger } from "@/lib/logger";
import { WORKSPACE_OWNER_KEY } from "@/lib/storage/local-storage";
-import { claimWorkspaceForSignedInAccount } from "@/lib/storage/workspace-owner";
+import { claimWorkspaceForSignedInAccount, readSignedInUsername } from "@/lib/storage/workspace-owner";
/**
* Renders a page that reads this browser's copy of the workspace only once the copy is the
@@ -13,8 +13,11 @@ import { claimWorkspaceForSignedInAccount } from "@/lib/storage/workspace-owner"
* a different account starts from that account's server data on every such page, not only the
* editor. The copy is shared by every tab of the browser profile: when another tab records a
* different owner or clears it (a sign-out, a different account signing in), this page stops
- * rendering and reloads, so the check runs again for the account signed in now. In local mode
- * the page renders after the storage mode is known, with the copy as it is.
+ * rendering and reloads, so the check runs again for the account signed in now. A sign-in can
+ * also land where nothing records an owner (an unknown address), so whenever the tab is shown
+ * again it asks which account is signed in, and reloads the same way when that is no longer the
+ * account it rendered for. In local mode the page renders after the storage mode is known, with
+ * the copy as it is.
*/
export function WorkspaceOwnerGate({ children }: { children: ReactNode }) {
const [state, setState] = useState<"checking" | "ready" | "failed">("checking");
@@ -27,15 +30,40 @@ export function WorkspaceOwnerGate({ children }: { children: ReactNode }) {
if (cancelled) return;
setState("ready");
if (owner === null) return;
- const onStorage = (event: StorageEvent) => {
- if (event.key !== null && event.key !== WORKSPACE_OWNER_KEY) return;
- if (localStorage.getItem(WORKSPACE_OWNER_KEY) === owner) return;
+ let left = false;
+ const leave = () => {
+ if (left || cancelled) return;
+ left = true;
stopListening();
setState("checking");
window.location.reload();
};
+ const onStorage = (event: StorageEvent) => {
+ if (event.key !== null && event.key !== WORKSPACE_OWNER_KEY) return;
+ if (localStorage.getItem(WORKSPACE_OWNER_KEY) === owner) return;
+ leave();
+ };
+ const onShown = () => {
+ if (document.visibilityState !== "visible") return;
+ readSignedInUsername().then(
+ (username) => {
+ if (username !== owner) leave();
+ },
+ // Not knowing the account now says nothing about a switch: this page stays as it
+ // is, and the next request a page sends meets the session check as usual.
+ (err: unknown) => {
+ logger.warn("Could not ask which account is signed in when the tab was shown again", {
+ error: err instanceof Error ? err.message : String(err),
+ });
+ },
+ );
+ };
window.addEventListener("storage", onStorage);
- stopListening = () => window.removeEventListener("storage", onStorage);
+ document.addEventListener("visibilitychange", onShown);
+ stopListening = () => {
+ window.removeEventListener("storage", onStorage);
+ document.removeEventListener("visibilitychange", onShown);
+ };
},
(err: unknown) => {
logger.warn("Could not match this browser's workspace to the signed-in account", {
diff --git a/tests/components/auth/WorkspaceOwnerGate.test.tsx b/tests/components/auth/WorkspaceOwnerGate.test.tsx
index 1fd624238..ecd14ba7f 100644
--- a/tests/components/auth/WorkspaceOwnerGate.test.tsx
+++ b/tests/components/auth/WorkspaceOwnerGate.test.tsx
@@ -267,4 +267,128 @@ describe("WorkspaceOwnerGate", () => {
expect(reload).not.toHaveBeenCalled();
});
});
+
+ describe("the tab is shown again", () => {
+ const original = window.location.reload;
+ let reload: ReturnType;
+ /** The account GET /api/auth/me names; null answers 503. */
+ let signedIn: string | null;
+
+ beforeEach(() => {
+ reload = mock(() => {});
+ Object.defineProperty(window.location, "reload", { value: reload, configurable: true });
+ signedIn = "admin@libredb.org";
+ });
+
+ afterEach(() => {
+ Object.defineProperty(window.location, "reload", { value: original, configurable: true });
+ Object.defineProperty(document, "visibilityState", { value: "visible", configurable: true });
+ });
+
+ function shown(state: "visible" | "hidden" = "visible") {
+ Object.defineProperty(document, "visibilityState", { value: state, configurable: true });
+ act(() => {
+ document.dispatchEvent(new window.Event("visibilitychange"));
+ });
+ }
+
+ async function renderPage(routes: Parameters[0]) {
+ const fetchMock = mockGlobalFetch(routes);
+ const view = render(
+
+ workspace page
+ ,
+ );
+ expect(await view.findByText("workspace page")).not.toBeNull();
+ return { view, fetchMock, asked: () => fetchMock.mock.calls.length };
+ }
+
+ const SERVER_ROUTES = {
+ ...SERVER_MODE,
+ "/api/auth/me": () =>
+ signedIn === null ? { status: 503, json: { error: "down" } } : { json: { user: { username: signedIn } } },
+ };
+
+ test("server mode: a different account signed in since then drops the page and reloads", async () => {
+ // A sign-in can land on a page that records no owner (an unknown address), so no storage
+ // event reaches this tab; the account is asked again when the tab is shown.
+ const { view } = await renderPage(SERVER_ROUTES);
+ signedIn = "user@libredb.org";
+
+ shown();
+
+ await waitFor(() => expect(reload).toHaveBeenCalledTimes(1));
+ expect(view.queryByText("workspace page")).toBeNull();
+ });
+
+ test("server mode: the same account keeps the page", async () => {
+ const { view, asked } = await renderPage(SERVER_ROUTES);
+ const before = asked();
+
+ shown();
+
+ await waitFor(() => expect(asked()).toBe(before + 1));
+ await new Promise((resolve) => setTimeout(resolve, 10));
+ expect(reload).not.toHaveBeenCalled();
+ expect(view.queryByText("workspace page")).not.toBeNull();
+ });
+
+ test("server mode: an account that cannot be read now keeps the page as it is", async () => {
+ const { view, asked } = await renderPage(SERVER_ROUTES);
+ const before = asked();
+ signedIn = null;
+
+ shown();
+
+ await waitFor(() => expect(asked()).toBe(before + 1));
+ await new Promise((resolve) => setTimeout(resolve, 10));
+ expect(reload).not.toHaveBeenCalled();
+ expect(view.queryByText("workspace page")).not.toBeNull();
+ });
+
+ test("server mode: a tab that is hidden asks nothing", async () => {
+ const { asked } = await renderPage(SERVER_ROUTES);
+ const before = asked();
+
+ shown("hidden");
+
+ await new Promise((resolve) => setTimeout(resolve, 10));
+ expect(asked()).toBe(before);
+ expect(reload).not.toHaveBeenCalled();
+ });
+
+ test("local mode: a tab shown again asks nothing", async () => {
+ const { asked } = await renderPage({ "/api/storage/config": { json: { provider: "local", serverMode: false } } });
+ const before = asked();
+
+ shown();
+
+ await new Promise((resolve) => setTimeout(resolve, 10));
+ expect(asked()).toBe(before);
+ expect(reload).not.toHaveBeenCalled();
+ });
+
+ test("an answer that arrives after the page is gone, or after it already left, reloads once at most", async () => {
+ const { view } = await renderPage(SERVER_ROUTES);
+ signedIn = "user@libredb.org";
+
+ shown();
+ ownerChanged();
+ await waitFor(() => expect(reload).toHaveBeenCalledTimes(1));
+ view.unmount();
+ shown();
+ await new Promise((resolve) => setTimeout(resolve, 10));
+
+ expect(reload).toHaveBeenCalledTimes(1);
+
+ function ownerChanged() {
+ localStorage.setItem("libredb_workspace_owner", "user@libredb.org");
+ act(() => {
+ window.dispatchEvent(
+ new window.StorageEvent("storage", { key: "libredb_workspace_owner", newValue: "user@libredb.org" }),
+ );
+ });
+ }
+ });
+ });
});
From b8b252163593ea9b264675186d8404c7957b80d6 Mon Sep 17 00:00:00 2001
From: cevheri
Date: Wed, 7 Oct 2026 13:45:37 +0300
Subject: [PATCH 16/28] fix(storage): send a tab whose session has ended to
sign in from the owner check
GET /api/auth/me answering 401 means no account is signed in. The owner check, at the first claim and when the tab is shown again, now shows neither the page nor its message for it and reports the ended session, so the tab goes to sign in as every page does.
---
src/components/auth/WorkspaceOwnerGate.tsx | 35 ++++++++++++----
src/lib/config/base-path.ts | 9 ++++
src/lib/storage/workspace-owner.ts | 14 ++++++-
.../auth/WorkspaceOwnerGate.test.tsx | 41 +++++++++++++++++++
tests/unit/config/base-path.test.ts | 9 ++++
.../unit/lib/storage/workspace-owner.test.ts | 18 +++++++-
6 files changed, 117 insertions(+), 9 deletions(-)
diff --git a/src/components/auth/WorkspaceOwnerGate.tsx b/src/components/auth/WorkspaceOwnerGate.tsx
index f47362eda..13e307cf9 100644
--- a/src/components/auth/WorkspaceOwnerGate.tsx
+++ b/src/components/auth/WorkspaceOwnerGate.tsx
@@ -2,9 +2,14 @@
import { type ReactNode, useEffect, useState } from "react";
import { ViewLoading } from "@/components/LazyView";
+import { reportSessionEnded } from "@/lib/config/base-path";
import { logger } from "@/lib/logger";
import { WORKSPACE_OWNER_KEY } from "@/lib/storage/local-storage";
-import { claimWorkspaceForSignedInAccount, readSignedInUsername } from "@/lib/storage/workspace-owner";
+import {
+ claimWorkspaceForSignedInAccount,
+ readSignedInUsername,
+ SessionEndedError,
+} from "@/lib/storage/workspace-owner";
/**
* Renders a page that reads this browser's copy of the workspace only once the copy is the
@@ -16,7 +21,9 @@ import { claimWorkspaceForSignedInAccount, readSignedInUsername } from "@/lib/st
* rendering and reloads, so the check runs again for the account signed in now. A sign-in can
* also land where nothing records an owner (an unknown address), so whenever the tab is shown
* again it asks which account is signed in, and reloads the same way when that is no longer the
- * account it rendered for. In local mode the page renders after the storage mode is known, with
+ * account it rendered for. A session that has ended, at the first check or when the tab is shown
+ * again, shows no page and no message: the tab goes to sign in, as every page does when the server
+ * reports an ended session. In local mode the page renders after the storage mode is known, with
* the copy as it is.
*/
export function WorkspaceOwnerGate({ children }: { children: ReactNode }) {
@@ -31,12 +38,16 @@ export function WorkspaceOwnerGate({ children }: { children: ReactNode }) {
setState("ready");
if (owner === null) return;
let left = false;
- const leave = () => {
- if (left || cancelled) return;
+ /** Stops rendering the page, once; answers whether this call is the one that did. */
+ const drop = () => {
+ if (left || cancelled) return false;
left = true;
stopListening();
setState("checking");
- window.location.reload();
+ return true;
+ };
+ const leave = () => {
+ if (drop()) window.location.reload();
};
const onStorage = (event: StorageEvent) => {
if (event.key !== null && event.key !== WORKSPACE_OWNER_KEY) return;
@@ -50,8 +61,13 @@ export function WorkspaceOwnerGate({ children }: { children: ReactNode }) {
if (username !== owner) leave();
},
// Not knowing the account now says nothing about a switch: this page stays as it
- // is, and the next request a page sends meets the session check as usual.
+ // is, and the next request a page sends meets the session check as usual. A session
+ // that has ended is known: the page goes, and the tab goes to sign in.
(err: unknown) => {
+ if (err instanceof SessionEndedError) {
+ if (drop()) reportSessionEnded();
+ return;
+ }
logger.warn("Could not ask which account is signed in when the tab was shown again", {
error: err instanceof Error ? err.message : String(err),
});
@@ -66,10 +82,15 @@ export function WorkspaceOwnerGate({ children }: { children: ReactNode }) {
};
},
(err: unknown) => {
+ if (cancelled) return;
+ if (err instanceof SessionEndedError) {
+ reportSessionEnded();
+ return;
+ }
logger.warn("Could not match this browser's workspace to the signed-in account", {
error: err instanceof Error ? err.message : String(err),
});
- if (!cancelled) setState("failed");
+ setState("failed");
},
);
return () => {
diff --git a/src/lib/config/base-path.ts b/src/lib/config/base-path.ts
index 06e663fd2..650cbc55f 100644
--- a/src/lib/config/base-path.ts
+++ b/src/lib/config/base-path.ts
@@ -55,6 +55,15 @@ export function onSessionEnded(handler: () => void): () => void {
};
}
+/**
+ * Reports that the session has ended, from a check that is not an appFetch answer (GET
+ * /api/auth/me answering 401): the registered handler sends the tab to sign in. Nothing happens
+ * without one.
+ */
+export function reportSessionEnded(): void {
+ sessionEndedHandler?.();
+}
+
/**
* Calls the registered handler when `response` is the session-required answer. Keyed on the code,
* not the status: 401 also reports a database refusing its credentials and a model provider
diff --git a/src/lib/storage/workspace-owner.ts b/src/lib/storage/workspace-owner.ts
index 5ad51075b..7ca60809d 100644
--- a/src/lib/storage/workspace-owner.ts
+++ b/src/lib/storage/workspace-owner.ts
@@ -9,9 +9,21 @@ import { appFetch } from "@/lib/config/base-path";
import { claimAccountWorkspace, SERVER_MIGRATED_KEY, WORKSPACE_OWNER_KEY } from "./local-storage";
import type { StorageConfigResponse } from "./types";
-/** The signed-in account's username, from GET /api/auth/me. Throws when it cannot be read. */
+/** GET /api/auth/me answered 401: there is no session any more, so no account is signed in. */
+export class SessionEndedError extends Error {
+ constructor() {
+ super("The session has ended");
+ this.name = "SessionEndedError";
+ }
+}
+
+/**
+ * The signed-in account's username, from GET /api/auth/me. Throws when it cannot be read:
+ * `SessionEndedError` when the session has ended, an ordinary error otherwise.
+ */
export async function readSignedInUsername(): Promise {
const res = await appFetch("/api/auth/me");
+ if (res.status === 401) throw new SessionEndedError();
if (!res.ok) throw new Error(`Could not read the signed-in account: HTTP ${res.status}`);
const body = (await res.json()) as { user?: { username?: unknown } };
const username = body.user?.username;
diff --git a/tests/components/auth/WorkspaceOwnerGate.test.tsx b/tests/components/auth/WorkspaceOwnerGate.test.tsx
index ecd14ba7f..00a81b83d 100644
--- a/tests/components/auth/WorkspaceOwnerGate.test.tsx
+++ b/tests/components/auth/WorkspaceOwnerGate.test.tsx
@@ -3,6 +3,7 @@ import "../../setup-dom";
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
import { act, cleanup, render, waitFor } from "@testing-library/react";
import { mockGlobalFetch, restoreGlobalFetch } from "../../helpers/mock-fetch";
+import { onSessionEnded } from "@/lib/config/base-path";
import { WorkspaceOwnerGate } from "@/components/auth/WorkspaceOwnerGate";
@@ -75,6 +76,25 @@ describe("WorkspaceOwnerGate", () => {
expect(localStorage.getItem("libredb_workspace_owner")).toBe("admin@libredb.org");
});
+ test("server mode: a session that has ended sends the tab to sign in, with neither the page nor an alert", async () => {
+ mockGlobalFetch({ ...SERVER_MODE, "/api/auth/me": { status: 401, json: { authenticated: false } } });
+ const signIn = mock(() => {});
+ const unregister = onSessionEnded(signIn);
+ const seen: (string | null)[] = [];
+
+ const view = render(
+
+
+ ,
+ );
+
+ await waitFor(() => expect(signIn).toHaveBeenCalledTimes(1));
+ unregister();
+ expect(view.queryByRole("alert")).toBeNull();
+ expect(view.queryByText("workspace page")).toBeNull();
+ expect(seen).toEqual([]);
+ });
+
test("a storage mode that cannot be read shows why and renders no page for a copy bound to a server account", async () => {
mockGlobalFetch({ "/api/storage/config": { ok: false, status: 502, json: { error: "down" } } });
const seen: (string | null)[] = [];
@@ -333,6 +353,27 @@ describe("WorkspaceOwnerGate", () => {
expect(view.queryByText("workspace page")).not.toBeNull();
});
+ test("server mode: a session that has ended since then drops the page and sends the tab to sign in", async () => {
+ const signIn = mock(() => {});
+ const unregister = onSessionEnded(signIn);
+ const { view } = await renderPage({
+ ...SERVER_ROUTES,
+ "/api/auth/me": () =>
+ signedIn === null
+ ? { status: 401, json: { authenticated: false } }
+ : { json: { user: { username: signedIn } } },
+ });
+ signedIn = null;
+
+ shown();
+
+ await waitFor(() => expect(signIn).toHaveBeenCalledTimes(1));
+ unregister();
+ expect(view.queryByText("workspace page")).toBeNull();
+ expect(view.queryByRole("alert")).toBeNull();
+ expect(reload).not.toHaveBeenCalled();
+ });
+
test("server mode: an account that cannot be read now keeps the page as it is", async () => {
const { view, asked } = await renderPage(SERVER_ROUTES);
const before = asked();
diff --git a/tests/unit/config/base-path.test.ts b/tests/unit/config/base-path.test.ts
index da4523a8b..f233ffc4f 100644
--- a/tests/unit/config/base-path.test.ts
+++ b/tests/unit/config/base-path.test.ts
@@ -7,6 +7,7 @@ import {
appFetch,
currentAppPath,
onSessionEnded,
+ reportSessionEnded,
} from "@/lib/config/base-path";
import { sessionRequiredBody } from "@/lib/api/session-ended";
@@ -201,6 +202,14 @@ describe("appFetch session-ended notice", () => {
expect(response.bodyUsed).toBe(false);
});
+ test("reportSessionEnded calls the registered handler, and does nothing without one", () => {
+ reportSessionEnded();
+ const handler = mock(() => {});
+ unregister = onSessionEnded(handler);
+ reportSessionEnded();
+ expect(handler).toHaveBeenCalledTimes(1);
+ });
+
test("unregistering removes only the handler it registered", async () => {
const first = mock(() => {});
const second = mock(() => {});
diff --git a/tests/unit/lib/storage/workspace-owner.test.ts b/tests/unit/lib/storage/workspace-owner.test.ts
index b5351b151..5f653202a 100644
--- a/tests/unit/lib/storage/workspace-owner.test.ts
+++ b/tests/unit/lib/storage/workspace-owner.test.ts
@@ -6,7 +6,11 @@ if (typeof globalThis.window === "undefined") {
}
import { mockGlobalFetch, restoreGlobalFetch } from "../../../helpers/mock-fetch";
-import { claimWorkspaceForSignedInAccount, readSignedInUsername } from "@/lib/storage/workspace-owner";
+import {
+ claimWorkspaceForSignedInAccount,
+ readSignedInUsername,
+ SessionEndedError,
+} from "@/lib/storage/workspace-owner";
import { SERVER_MIGRATED_KEY, WORKSPACE_OWNER_KEY } from "@/lib/storage/local-storage";
const SERVER_MODE = { "/api/storage/config": { json: { provider: "postgres", serverMode: true } } };
@@ -98,6 +102,18 @@ describe("readSignedInUsername", () => {
expect(await readSignedInUsername()).toBe("user@libredb.org");
});
+ test("a 401 throws SessionEndedError: the session has ended", async () => {
+ mockGlobalFetch({ "/api/auth/me": { status: 401, json: { authenticated: false } } });
+ const failure = await readSignedInUsername().catch((err: unknown) => err);
+ expect(failure).toBeInstanceOf(SessionEndedError);
+ });
+
+ test("any other failure is not SessionEndedError", async () => {
+ mockGlobalFetch({ "/api/auth/me": { status: 503, json: { error: "down" } } });
+ const failure = await readSignedInUsername().catch((err: unknown) => err);
+ expect(failure).not.toBeInstanceOf(SessionEndedError);
+ });
+
test("an answer without a username throws", async () => {
mockGlobalFetch({ "/api/auth/me": { json: { user: { username: "" } } } });
await expect(readSignedInUsername()).rejects.toThrow("no username");
From ab38f34a14c1dce053e217acccef43373a7a8e59 Mon Sep 17 00:00:00 2001
From: cevheri
Date: Wed, 7 Oct 2026 13:48:41 +0300
Subject: [PATCH 17/28] fix(storage): name the claimed account on every request
a tab sends
After a server-mode claim, appFetch sends the claimed username in X-LibreDB-Workspace-Owner. The proxy answers 409 WORKSPACE_OWNER_MISMATCH when it is not the session's username, before any route runs, and the tab reloads so the owner check runs again. A request without the header, and the public routes, behave as before.
---
docs/API_DOCS.md | 6 ++
docs/SECURITY.md | 1 +
docs/STORAGE.md | 2 +
src/lib/api/error-codes.ts | 7 ++
src/lib/config/base-path.ts | 61 ++++++++++++-
src/lib/storage/local-storage.ts | 6 +-
src/proxy.ts | 26 +++++-
tests/api/proxy.test.ts | 87 +++++++++++++++++++
tests/unit/config/base-path.test.ts | 90 ++++++++++++++++++++
tests/unit/lib/api/error-codes.test.ts | 7 +-
tests/unit/lib/api/session-ended.test.ts | 6 +-
tests/unit/lib/storage/local-storage.test.ts | 16 +++-
12 files changed, 308 insertions(+), 7 deletions(-)
diff --git a/docs/API_DOCS.md b/docs/API_DOCS.md
index 950b342ba..b66b171d1 100644
--- a/docs/API_DOCS.md
+++ b/docs/API_DOCS.md
@@ -1884,6 +1884,11 @@ Mints a token for the signed-in user and role; it reads no body field and spends
The write-through storage sync layer (see [`docs/STORAGE.md`](STORAGE.md)). Data is per-user, keyed by the session username.
+In server storage mode a browser tab that has matched its copy of the workspace to the signed-in account sends that account's username, URI-encoded, in the `X-LibreDB-Workspace-Owner` header on every request it makes through `appFetch`, not only on the storage routes.
+When the header is present and does not name the session's username, the middleware (`src/proxy.ts`) answers `409 { "error": "...", "code": "WORKSPACE_OWNER_MISMATCH" }` before any route runs, and the browser reloads the tab so the owner check runs again for the account signed in now.
+A request without the header is not checked: MCP clients, the agent drive callback, external clients and local mode behave as before.
+The public routes (`/api/auth/*`, `/api/storage/config`, `/api/db/health` and the others listed above) are not checked either, so a tab can still ask which account is signed in.
+
#### GET /api/storage/config
Public. Returns the active storage configuration so the client can discover whether server-side storage is enabled.
@@ -2267,6 +2272,7 @@ These are the values of the `code` field emitted by `createErrorResponse` (`src/
| `AUTH_ERROR` | Authentication failed (401) |
| `CUSTOM_CONNECTIONS_DISABLED` | `ALLOW_CUSTOM_CONNECTIONS` is off and the request supplied a connection that is not a seed (403); see `GET /api/connections/policy`. A seed the caller's role may not open is refused with `AUTH_ERROR` (403) instead |
| `AUTH_REQUIRED` | No Studio session, or one that no longer verifies (401). Answered by the middleware and the route-level session checks rather than `createErrorResponse`; the only 401 the browser answers by sending the user to sign in |
+| `WORKSPACE_OWNER_MISMATCH` | The request's `X-LibreDB-Workspace-Owner` header names an account other than the signed-in one (409). Answered by the middleware rather than `createErrorResponse`; the browser reloads the tab for it (see [Storage API](#storage-api)) |
| `TIMEOUT_ERROR` | Query exceeded time limit (408); `POST /api/ai/query-safety` answers it with 504 when the model does not answer in time |
| `CONNECTION_ERROR` | Database connection failed (503) |
| `POOL_EXHAUSTED` | Connection pool exhausted (503) |
diff --git a/docs/SECURITY.md b/docs/SECURITY.md
index de82835c1..146fdac2d 100644
--- a/docs/SECURITY.md
+++ b/docs/SECURITY.md
@@ -583,6 +583,7 @@ These are real, current, and not oversights. Each is a decision with a reason.
encrypting it would require a master password and a recovery flow, changing what the product is.
This is why 0.1 and 1.1 matter as much as they do.
In server storage mode the browser copy belongs to the signed-in account: it is cleared on sign-out, and a different account signing in starts from its own server data.
+ Every request a tab sends after matching its copy names that account, and the server refuses one that names an account other than the signed-in one, so a tab still open for one account never writes into another account's server data.
In local mode it stays, and the next account to sign in on that browser profile sees and can open the connections saved there.
- **Anyone who can read the server's environment can read the stored credentials.** 3.1 protects a
stolen database file or dump on its own; it is not a vault. For `STORAGE_PROVIDER=sqlite` with no
diff --git a/docs/STORAGE.md b/docs/STORAGE.md
index e5a878f78..0dbe0bbed 100644
--- a/docs/STORAGE.md
+++ b/docs/STORAGE.md
@@ -1274,6 +1274,8 @@ The copy is every synced collection, the editor tabs (never stored on the server
When the storage mode cannot be read or a pending push does not land, the sign-out fails and the copy stays, so no unsaved change is dropped; when the server refuses the sign-out, the copy stays and the sync goes on.
- **Other open tabs:** every tab of the browser profile shares the copy, so when another tab records a different owner or clears the copy (a sign-out, or a different account signing in and landing on the editor, an admin page, the monitoring page or a settings page), each page inside `WorkspaceOwnerGate` stops rendering and reloads, and the owner check runs again for the account signed in now.
A sign-in can also land where no owner is recorded (an unknown address), so a page inside `WorkspaceOwnerGate` also asks which account is signed in whenever its tab is shown again, and reloads the same way when that is no longer the account it rendered for.
+- **Every request names the account:** once a page has matched the copy, every request it sends names that account in the `X-LibreDB-Workspace-Owner` header, and the server refuses one that names an account other than the signed-in one with `409 WORKSPACE_OWNER_MISMATCH` before any route runs ([API_DOCS.md](API_DOCS.md#storage-api)).
+ The page then reloads, and the owner check runs again for the account signed in now, so a push, a pull or a migration a page sends after another tab signed in as a different account never reaches that account's server data, also in a tab that stays visible.
- **A session that ended on its own** (expiry, a disabled account) clears nothing; the owner check covers a different account on the first of these pages it opens, and an open tab when it is shown again.
It does not cover a copy written while `GET /api/storage/config` could not be read on a browser profile that had no owner and no migration flag yet: such a copy is treated as local-mode data, so the next account to sign in records it as its own and migrates it.
- **The first sign-in after upgrading to a release that records the owner:** a browser that was already migrated has no owner yet, so its copy is cleared once, for the same account too, and that sign-in starts from the account's server data.
diff --git a/src/lib/api/error-codes.ts b/src/lib/api/error-codes.ts
index 6d50e3116..b1a709d62 100644
--- a/src/lib/api/error-codes.ts
+++ b/src/lib/api/error-codes.ts
@@ -45,6 +45,13 @@ export const ApiErrorCode = {
*/
AUTH_REQUIRED: "AUTH_REQUIRED",
+ /**
+ * The request names, in X-LibreDB-Workspace-Owner, an account other than the signed-in one
+ * (409): the tab claimed its browser copy for that account, and the session is now another
+ * account's. The browser reloads the tab for this code, so the owner check runs again.
+ */
+ WORKSPACE_OWNER_MISMATCH: "WORKSPACE_OWNER_MISMATCH",
+
/**
* The operator switched custom connections off (ALLOW_CUSTOM_CONNECTIONS) and the request supplied a
* connection of its own (403). Distinct from AUTH_ERROR, which the same 403 carries when the role
diff --git a/src/lib/config/base-path.ts b/src/lib/config/base-path.ts
index 650cbc55f..e22924941 100644
--- a/src/lib/config/base-path.ts
+++ b/src/lib/config/base-path.ts
@@ -81,13 +81,70 @@ async function noticeSessionEnded(response: Response): Promise {
if ((body as { code?: unknown } | null)?.code === SESSION_REQUIRED_CODE) sessionEndedHandler?.();
}
+/**
+ * The request header that names the account this tab claimed the browser copy for, URI-encoded
+ * so any username fits a header value. The proxy (src/proxy.ts) answers 409 with
+ * WORKSPACE_OWNER_MISMATCH_CODE when it is not the signed-in account.
+ */
+export const WORKSPACE_OWNER_HEADER = "X-LibreDB-Workspace-Owner";
+
+/**
+ * ApiErrorCode.WORKSPACE_OWNER_MISMATCH (src/lib/api/error-codes.ts), written out for the reason
+ * SESSION_REQUIRED_CODE is. A test pins the two together.
+ */
+export const WORKSPACE_OWNER_MISMATCH_CODE = "WORKSPACE_OWNER_MISMATCH";
+
+let workspaceOwner: string | null = null;
+
+/**
+ * Records the account this tab claimed the browser copy for, in server storage mode
+ * (`claimAccountWorkspace`), so every request it sends from then on names it; null sends none.
+ * Nothing is held in local mode or before a claim.
+ */
+export function holdWorkspaceOwner(username: string | null): void {
+ workspaceOwner = username;
+}
+
+/** The account this tab claimed the browser copy for, or null when it holds no claim. */
+export function heldWorkspaceOwner(): string | null {
+ return workspaceOwner;
+}
+
+/** Adds the owner header to an app request; any other request, or no claim, passes as it came. */
+function withWorkspaceOwner(path: string, init: [RequestInit?], owner: string | null): [RequestInit?] {
+ if (owner === null || !path.startsWith("/") || path.startsWith("//")) return init;
+ const headers = new Headers(init[0]?.headers);
+ headers.set(WORKSPACE_OWNER_HEADER, encodeURIComponent(owner));
+ return [{ ...init[0], headers }];
+}
+
+/**
+ * The browser copy this tab claimed belongs to a different account than the one signed in now
+ * (another tab signed in as someone else). The tab reloads, so the owner check runs again for the
+ * account signed in now. Reads a clone and never throws, as noticeSessionEnded does.
+ */
+async function noticeOwnerMismatch(response: Response, owner: string | null): Promise {
+ if (response.status !== 409 || owner === null) return;
+ let body: unknown;
+ try {
+ body = await response.clone().json();
+ } catch {
+ return;
+ }
+ if ((body as { code?: unknown } | null)?.code === WORKSPACE_OWNER_MISMATCH_CODE) window.location.reload();
+}
+
/**
* Preserve fetch's arguments and cancellation while applying the application's prefix. A
* session-required 401 is also reported to the page's session-ended handler, when it has one; the
- * response itself is returned unchanged either way.
+ * response itself is returned unchanged either way. While this tab holds a claim on the browser
+ * copy, every app request names its account (WORKSPACE_OWNER_HEADER), and an answer that it is not
+ * the signed-in account reloads the tab.
*/
export async function appFetch(path: string, ...init: [RequestInit?]): Promise {
- const response = await fetch(withBasePath(path), ...init);
+ const owner = workspaceOwner;
+ const response = await fetch(withBasePath(path), ...withWorkspaceOwner(path, init, owner));
await noticeSessionEnded(response);
+ await noticeOwnerMismatch(response, owner);
return response;
}
diff --git a/src/lib/storage/local-storage.ts b/src/lib/storage/local-storage.ts
index eaee8d0ce..acf8ea4a6 100644
--- a/src/lib/storage/local-storage.ts
+++ b/src/lib/storage/local-storage.ts
@@ -4,6 +4,7 @@
* No event dispatching — that's the facade's responsibility.
*/
+import { holdWorkspaceOwner } from "@/lib/config/base-path";
import { logger } from "@/lib/logger";
import { STORAGE_COLLECTIONS } from "./types";
@@ -180,13 +181,16 @@ export function resetAccountWorkspace(owner: string | null): void {
/**
* Server storage mode: keep the browser copy only for the account it belongs to, before anything
- * reads it. The same owner keeps it. A copy with no owner that was never handed to a server
+ * reads it, and hold that account for the requests this tab sends (`holdWorkspaceOwner`). The
+ * same owner keeps it. A copy with no owner that was never handed to a server
* account is local-mode data: it now belongs to the signed-in account and is kept for
* `useStorageSync` to migrate into that account alone (docs/STORAGE.md). Anything else is
* cleared, and the signed-in account starts from its own server data.
*/
export function claimAccountWorkspace(username: string): void {
if (!isClient()) return;
+ // Every request this tab sends from now on names the account it claimed the copy for.
+ holdWorkspaceOwner(username);
const owner = localStorage.getItem(WORKSPACE_OWNER_KEY);
if (owner === username) return;
if (owner === null && localStorage.getItem(SERVER_MIGRATED_KEY) === null) {
diff --git a/src/proxy.ts b/src/proxy.ts
index cf88cf78b..25696b21a 100644
--- a/src/proxy.ts
+++ b/src/proxy.ts
@@ -1,4 +1,5 @@
-import { withBasePath } from "@/lib/config/base-path";
+import { WORKSPACE_OWNER_HEADER, withBasePath } from "@/lib/config/base-path";
+import { ApiErrorCode } from "@/lib/api/error-codes";
import { NextRequest, NextResponse } from "next/server";
import { jwtVerify } from "jose";
import { AGENT_DRIVE_HEADER, AGENT_DRIVE_PATH, verifyAgentDriveToken } from "@/lib/agent/drive-token";
@@ -36,6 +37,20 @@ const ORIGIN_MISMATCH_BODY = {
retryable: false,
};
+const WORKSPACE_OWNER_MISMATCH_BODY = {
+ error: "This page's workspace belongs to a different account than the one signed in. Reload the page.",
+ code: ApiErrorCode.WORKSPACE_OWNER_MISMATCH,
+};
+
+/** The account a workspace owner header names, or null when it is not a valid encoding. */
+function decodedOwner(value: string): string | null {
+ try {
+ return decodeURIComponent(value);
+ } catch {
+ return null;
+ }
+}
+
export async function proxy(request: NextRequest) {
// NextURL removes the configured basePath before exposing pathname; Next also
// prefixes config.matcher at build time. Keep authorization checks app-relative.
@@ -175,6 +190,15 @@ export async function proxy(request: NextRequest) {
const { payload } = await jwtVerify(token, jwtSecret());
const role = payload.role as string;
+ // A tab that claimed the browser copy names that account on every request (docs/STORAGE.md).
+ // One that names a different account than the session's is answered here, before any route
+ // runs, and the tab reloads to claim the copy again. A request without the header (MCP, the
+ // agent drive callback, an external client, local mode) is not checked.
+ const claimed = request.headers.get(WORKSPACE_OWNER_HEADER);
+ if (claimed !== null && decodedOwner(claimed) !== payload.username) {
+ return withSecurityHeaders(NextResponse.json(WORKSPACE_OWNER_MISMATCH_BODY, { status: 409 }));
+ }
+
// RBAC: /admin only for admin
if (pathname.startsWith("/admin") && role !== "admin") {
// METERED through the anon bucket, exactly as the origin_mismatch line above is. Holding a
diff --git a/tests/api/proxy.test.ts b/tests/api/proxy.test.ts
index e717c32b8..6139f129b 100644
--- a/tests/api/proxy.test.ts
+++ b/tests/api/proxy.test.ts
@@ -396,6 +396,93 @@ describe("proxy", () => {
});
});
+ // ───────────────────────────────────────────────────────────────────────────
+ // The account a tab claimed the browser copy for (X-LibreDB-Workspace-Owner)
+ // ───────────────────────────────────────────────────────────────────────────
+
+ describe("workspace owner header", () => {
+ async function tokenFor(username: string) {
+ return await new SignJWT({ role: "user", username })
+ .setProtectedHeader({ alg: "HS256" })
+ .setIssuedAt()
+ .setExpirationTime("1h")
+ .sign(JWT_SECRET);
+ }
+
+ function request(pathname: string, token: string, owner: string | null, method = "GET"): NextRequest {
+ const headers = new Headers({
+ cookie: `auth-token=${token}`,
+ host: "localhost:3000",
+ origin: "http://localhost:3000",
+ });
+ if (owner !== null) headers.set("X-LibreDB-Workspace-Owner", owner);
+ return new NextRequest(`http://localhost:3000${pathname}`, { method, headers });
+ }
+
+ /** NextResponse.next() marks the response so the route runs; a refusal never carries it. */
+ const reachesRoute = (response: Response) => response.headers.get("x-middleware-next") === "1";
+
+ async function expectOwnerMismatch(response: Response) {
+ expect(response.status).toBe(409);
+ expect(reachesRoute(response)).toBe(false);
+ expect(response.headers.get("x-content-type-options")).toBe("nosniff");
+ const body = (await response.json()) as { code?: string };
+ expect(body.code).toBe("WORKSPACE_OWNER_MISMATCH");
+ }
+
+ test("a request whose owner is the signed-in account reaches the route", async () => {
+ const token = await tokenFor("ana@libredb.org");
+ const res = await proxy(request("/api/storage", token, encodeURIComponent("ana@libredb.org")));
+
+ expect(reachesRoute(res)).toBe(true);
+ });
+
+ test("a request whose owner is a different account answers 409 with the code", async () => {
+ const token = await tokenFor("bob@libredb.org");
+
+ await expectOwnerMismatch(await proxy(request("/api/storage", token, encodeURIComponent("ana@libredb.org"))));
+ });
+
+ test("an owner that does not decode is not the signed-in account", async () => {
+ const token = await tokenFor("ana@libredb.org");
+
+ await expectOwnerMismatch(await proxy(request("/api/storage", token, "%E0%A4%A")));
+ });
+
+ test("a request without the header behaves as before", async () => {
+ const token = await tokenFor("ana@libredb.org");
+
+ expect(reachesRoute(await proxy(request("/api/storage", token, null)))).toBe(true);
+ expect(reachesRoute(await proxy(request("/", token, null)))).toBe(true);
+ });
+
+ test("a storage write carrying another account's owner is refused before the route runs", async () => {
+ const token = await tokenFor("bob@libredb.org");
+ const put = request("/api/storage/connections", token, encodeURIComponent("ana@libredb.org"), "PUT");
+
+ await expectOwnerMismatch(await proxy(put));
+ const own = request("/api/storage/connections", token, encodeURIComponent("bob@libredb.org"), "PUT");
+ expect(reachesRoute(await proxy(own))).toBe(true);
+ });
+
+ test("public routes are not checked, so a tab can still ask which account is signed in", async () => {
+ const token = await tokenFor("bob@libredb.org");
+ const stale = encodeURIComponent("ana@libredb.org");
+
+ expect(reachesRoute(await proxy(request("/api/auth/me", token, stale)))).toBe(true);
+ expect(reachesRoute(await proxy(request("/api/auth/logout", token, stale, "POST")))).toBe(true);
+ expect(reachesRoute(await proxy(request("/api/db/health", token, stale)))).toBe(true);
+ });
+
+ test("without a session the answer is still the session-required 401", async () => {
+ const req = new NextRequest("http://localhost:3000/api/storage", {
+ headers: { "X-LibreDB-Workspace-Owner": "ana%40libredb.org" },
+ });
+
+ await expectSessionRequired(await proxy(req), "Authentication required");
+ });
+ });
+
// ───────────────────────────────────────────────────────────────────────────
// The agent drive path (#329 T9)
//
diff --git a/tests/unit/config/base-path.test.ts b/tests/unit/config/base-path.test.ts
index f233ffc4f..735980dc4 100644
--- a/tests/unit/config/base-path.test.ts
+++ b/tests/unit/config/base-path.test.ts
@@ -8,6 +8,9 @@ import {
currentAppPath,
onSessionEnded,
reportSessionEnded,
+ heldWorkspaceOwner,
+ holdWorkspaceOwner,
+ WORKSPACE_OWNER_HEADER,
} from "@/lib/config/base-path";
import { sessionRequiredBody } from "@/lib/api/session-ended";
@@ -228,6 +231,93 @@ describe("appFetch session-ended notice", () => {
});
});
+describe("appFetch and the account this tab claimed the browser copy for", () => {
+ const originalReload = window.location.reload;
+ let reload: ReturnType;
+
+ afterEach(() => {
+ holdWorkspaceOwner(null);
+ Object.defineProperty(window.location, "reload", { value: originalReload, configurable: true });
+ });
+
+ function sentHeaders(fetchMock: ReturnType): Headers {
+ const init = fetchMock.mock.calls.at(-1)?.[1] as RequestInit | undefined;
+ return new Headers(init?.headers);
+ }
+
+ function answering(response: () => Response) {
+ const fetchMock = mock(async () => response());
+ globalThis.fetch = fetchMock as unknown as typeof fetch;
+ return fetchMock;
+ }
+
+ function watchReload() {
+ reload = mock(() => {});
+ Object.defineProperty(window.location, "reload", { value: reload, configurable: true });
+ }
+
+ test("before a claim no owner header is sent, and the arguments pass through untouched", async () => {
+ const fetchMock = answering(() => json(200, {}));
+ const init = { method: "PUT", headers: { "Content-Type": "application/json" } };
+
+ await appFetch("/api/storage/connections", init);
+
+ expect(heldWorkspaceOwner()).toBeNull();
+ expect(fetchMock.mock.calls[0]).toEqual(["/api/storage/connections", init]);
+ });
+
+ test("while a claim is held every app request names the claimed account, keeping the caller's headers", async () => {
+ const fetchMock = answering(() => json(200, {}));
+ holdWorkspaceOwner("ana@libredb.org");
+
+ await appFetch("/api/storage/connections", { method: "PUT", headers: { "Content-Type": "application/json" } });
+ const headers = sentHeaders(fetchMock);
+ expect(headers.get(WORKSPACE_OWNER_HEADER)).toBe("ana%40libredb.org");
+ expect(headers.get("Content-Type")).toBe("application/json");
+ expect((fetchMock.mock.calls[0][1] as RequestInit).method).toBe("PUT");
+
+ await appFetch("/api/auth/me");
+ expect(sentHeaders(fetchMock).get(WORKSPACE_OWNER_HEADER)).toBe("ana%40libredb.org");
+ });
+
+ test("a request that leaves the application never carries it", async () => {
+ const fetchMock = answering(() => json(200, {}));
+ holdWorkspaceOwner("ana@libredb.org");
+
+ await appFetch("https://example.com/x");
+ await appFetch("//example.com/x");
+
+ expect(fetchMock.mock.calls.map((call) => call.length)).toEqual([1, 1]);
+ });
+
+ test("an owner mismatch while a claim is held reloads the tab, so the owner check runs again", async () => {
+ watchReload();
+ holdWorkspaceOwner("ana@libredb.org");
+ answering(() => json(409, { error: "x", code: "WORKSPACE_OWNER_MISMATCH" }));
+
+ const response = await appFetch("/api/storage/connections");
+
+ expect(reload).toHaveBeenCalledTimes(1);
+ expect(response.status).toBe(409);
+ expect(response.bodyUsed).toBe(false);
+ });
+
+ test("any other 409, a body that is not JSON, or no claim held does not reload", async () => {
+ watchReload();
+ holdWorkspaceOwner("ana@libredb.org");
+ answering(() => json(409, { error: "conflict", code: "QUERY_ERROR" }));
+ await appFetch("/api/storage/connections");
+ answering(() => new Response("", { status: 409 }));
+ await appFetch("/api/storage/connections");
+
+ holdWorkspaceOwner(null);
+ answering(() => json(409, { error: "x", code: "WORKSPACE_OWNER_MISMATCH" }));
+ await appFetch("/api/storage/connections");
+
+ expect(reload).not.toHaveBeenCalled();
+ });
+});
+
// next.config.ts reads this file, and a config file cannot resolve the alias an import would use:
// an import here fails `next build` before it compiles anything ("Cannot find module").
test("base-path.ts stays import-free, so next.config.ts can load it", () => {
diff --git a/tests/unit/lib/api/error-codes.test.ts b/tests/unit/lib/api/error-codes.test.ts
index 3b8ee887b..45bc490c6 100644
--- a/tests/unit/lib/api/error-codes.test.ts
+++ b/tests/unit/lib/api/error-codes.test.ts
@@ -31,7 +31,7 @@ const GROUPS = {
],
llm: ["LLM_SAFETY", "LLM_AUTH", "LLM_RATE_LIMIT", "LLM_CONFIG", "LLM_UNCONFIGURED", "LLM_STREAM", "LLM_ERROR"],
rateLimit: ["RATE_LIMITED"],
- session: ["AUTH_REQUIRED"],
+ session: ["AUTH_REQUIRED", "WORKSPACE_OWNER_MISMATCH"],
connectionPolicy: ["CUSTOM_CONNECTIONS_DISABLED"],
generic: ["INTERNAL_ERROR", "NETWORK_ERROR"],
} as const;
@@ -66,6 +66,11 @@ describe("ApiErrorCode", () => {
expect(ApiErrorCode.AUTH_REQUIRED).not.toBe(ApiErrorCode.AUTH_ERROR);
});
+ test("contains the workspace owner mismatch code, distinct from the session-required code", () => {
+ expect(ApiErrorCode.WORKSPACE_OWNER_MISMATCH).toBe("WORKSPACE_OWNER_MISMATCH");
+ expect(ApiErrorCode.WORKSPACE_OWNER_MISMATCH).not.toBe(ApiErrorCode.AUTH_REQUIRED);
+ });
+
test("contains the custom-connections refusal code, distinct from a role refusal's AUTH_ERROR", () => {
expect(ApiErrorCode.CUSTOM_CONNECTIONS_DISABLED).toBe("CUSTOM_CONNECTIONS_DISABLED");
expect(ApiErrorCode.CUSTOM_CONNECTIONS_DISABLED).not.toBe(ApiErrorCode.AUTH_ERROR);
diff --git a/tests/unit/lib/api/session-ended.test.ts b/tests/unit/lib/api/session-ended.test.ts
index 31709cf3f..52079d9ad 100644
--- a/tests/unit/lib/api/session-ended.test.ts
+++ b/tests/unit/lib/api/session-ended.test.ts
@@ -1,7 +1,7 @@
import { afterEach, describe, expect, test } from "bun:test";
import { ApiErrorCode } from "@/lib/api/error-codes";
import { claimSignInRedirect, safeReturnPath, sessionRequiredBody, signInPath } from "@/lib/api/session-ended";
-import { SESSION_REQUIRED_CODE } from "@/lib/config/base-path";
+import { SESSION_REQUIRED_CODE, WORKSPACE_OWNER_MISMATCH_CODE } from "@/lib/config/base-path";
describe("sessionRequiredBody", () => {
// base-path.ts writes the code out because next.config.ts reads that file and cannot resolve
@@ -10,6 +10,10 @@ describe("sessionRequiredBody", () => {
expect(SESSION_REQUIRED_CODE).toBe(ApiErrorCode.AUTH_REQUIRED);
});
+ test("the owner mismatch code appFetch keys on is the server's", () => {
+ expect(WORKSPACE_OWNER_MISMATCH_CODE).toBe(ApiErrorCode.WORKSPACE_OWNER_MISMATCH);
+ });
+
test("names the AUTH_REQUIRED code the browser keys on", () => {
expect(sessionRequiredBody("Authentication required")).toEqual({
error: "Authentication required",
diff --git a/tests/unit/lib/storage/local-storage.test.ts b/tests/unit/lib/storage/local-storage.test.ts
index a29b02fe7..e0663fded 100644
--- a/tests/unit/lib/storage/local-storage.test.ts
+++ b/tests/unit/lib/storage/local-storage.test.ts
@@ -1,4 +1,4 @@
-import { describe, test, expect, beforeEach } from "bun:test";
+import { describe, test, expect, beforeEach, afterEach } from "bun:test";
if (typeof globalThis.window === "undefined") {
// @ts-expect-error — minimal window stub
@@ -22,6 +22,7 @@ import {
AGENT_THREAD_KEY,
} from "@/lib/storage/local-storage";
import { STORAGE_COLLECTIONS } from "@/lib/storage/types";
+import { heldWorkspaceOwner, holdWorkspaceOwner } from "@/lib/config/base-path";
describe("local-storage: getKey", () => {
test("maps known collection names to libredb_ prefix keys", () => {
@@ -210,6 +211,19 @@ describe("local-storage: claimAccountWorkspace", () => {
localStorage.setItem(workspaceTabsKey("default"), "[]");
});
+ afterEach(() => {
+ holdWorkspaceOwner(null);
+ });
+
+ test("every outcome holds the signed-in account for the requests this tab sends", () => {
+ claimAccountWorkspace("admin@libredb.org");
+ expect(heldWorkspaceOwner()).toBe("admin@libredb.org");
+ claimAccountWorkspace("admin@libredb.org");
+ expect(heldWorkspaceOwner()).toBe("admin@libredb.org");
+ claimAccountWorkspace("user@libredb.org");
+ expect(heldWorkspaceOwner()).toBe("user@libredb.org");
+ });
+
test("the same owner keeps the copy", () => {
localStorage.setItem(WORKSPACE_OWNER_KEY, "user@libredb.org");
localStorage.setItem(SERVER_MIGRATED_KEY, "2026-10-07");
From 1f5a88b477b04b1a7f633c693a760d35610c4f03 Mon Sep 17 00:00:00 2001
From: cevheri
Date: Wed, 7 Oct 2026 13:52:54 +0300
Subject: [PATCH 18/28] fix(storage): write the browser copy only while it is
still the claimed account's
In server mode a page writes the copy (the storage facade, the editor tabs, the object-source drafts) only while the owner key still names the account it claimed. The pull checks the owner again before each collection it writes, over STORAGE_COLLECTIONS, which also brings its complexity under the limit.
---
docs/STORAGE.md | 1 +
src/components/object-source/source-drafts.ts | 7 ++-
src/hooks/use-storage-sync.ts | 36 ++++++------
src/hooks/use-tab-manager.ts | 4 +-
src/lib/storage/local-storage.ts | 28 +++++++---
tests/hooks/use-tab-manager.test.ts | 35 ++++++++++++
tests/isolated/use-storage-sync.test.ts | 33 ++++++++++-
.../components/object-source-drafts.test.ts | 24 +++++++-
tests/unit/lib/storage/local-storage.test.ts | 55 +++++++++++++++++++
9 files changed, 192 insertions(+), 31 deletions(-)
diff --git a/docs/STORAGE.md b/docs/STORAGE.md
index 0dbe0bbed..1d8ced0a7 100644
--- a/docs/STORAGE.md
+++ b/docs/STORAGE.md
@@ -1276,6 +1276,7 @@ The copy is every synced collection, the editor tabs (never stored on the server
A sign-in can also land where no owner is recorded (an unknown address), so a page inside `WorkspaceOwnerGate` also asks which account is signed in whenever its tab is shown again, and reloads the same way when that is no longer the account it rendered for.
- **Every request names the account:** once a page has matched the copy, every request it sends names that account in the `X-LibreDB-Workspace-Owner` header, and the server refuses one that names an account other than the signed-in one with `409 WORKSPACE_OWNER_MISMATCH` before any route runs ([API_DOCS.md](API_DOCS.md#storage-api)).
The page then reloads, and the owner check runs again for the account signed in now, so a push, a pull or a migration a page sends after another tab signed in as a different account never reaches that account's server data, also in a tab that stays visible.
+ In the browser the same rule holds: a page writes the copy (the storage facade, its editor tabs, its object-source drafts, and each collection of a pull) only while the owner key still names the account it claimed, so a page that has not reloaded yet writes nothing into the copy another tab claimed.
- **A session that ended on its own** (expiry, a disabled account) clears nothing; the owner check covers a different account on the first of these pages it opens, and an open tab when it is shown again.
It does not cover a copy written while `GET /api/storage/config` could not be read on a browser profile that had no owner and no migration flag yet: such a copy is treated as local-mode data, so the next account to sign in records it as its own and migrates it.
- **The first sign-in after upgrading to a release that records the owner:** a browser that was already migrated has no owner yet, so its copy is cleared once, for the same account too, and that sign-in starts from the account's server data.
diff --git a/src/components/object-source/source-drafts.ts b/src/components/object-source/source-drafts.ts
index 9d6d5da9f..6271e265b 100644
--- a/src/components/object-source/source-drafts.ts
+++ b/src/components/object-source/source-drafts.ts
@@ -1,6 +1,6 @@
import { SOURCE_CHARACTER_LIMIT } from "@/lib/db/object-kinds";
import type { ObjectEditRevision } from "@/lib/db/types";
-import { SOURCE_DRAFTS_KEY } from "@/lib/storage/local-storage";
+import { holdsAccountWorkspace, SOURCE_DRAFTS_KEY } from "@/lib/storage/local-storage";
/**
* The unsaved edit of ONE part of an object's definition, held in this browser (#789 Phase 3,
@@ -196,7 +196,8 @@ export function readDraft(storage: Storage | null, key: string): SourceDraft | u
* the last thing that happens.
*/
export function writeDraft(storage: Storage | null, key: string, draft: SourceDraft): DraftWrite {
- if (storage === null) return { ok: false, reason: "unavailable" };
+ // A store another tab has handed to a different account is not this page's to write.
+ if (storage === null || !holdsAccountWorkspace(storage)) return { ok: false, reason: "unavailable" };
if (draft.text.length > SOURCE_CHARACTER_LIMIT) return { ok: false, reason: "too-long" };
const next: Record = { ...readStore(storage), [key]: draft };
@@ -234,7 +235,7 @@ export function writeDraft(storage: Storage | null, key: string, draft: SourceDr
* "another tab" grammar. Nothing in this module builds that banner (#789).
*/
export function dropDraft(storage: Storage | null, key: string): void {
- if (storage === null) return;
+ if (storage === null || !holdsAccountWorkspace(storage)) return;
const next = readStore(storage);
delete next[key];
try {
diff --git a/src/hooks/use-storage-sync.ts b/src/hooks/use-storage-sync.ts
index 9d0f4a2a3..2cffb1324 100644
--- a/src/hooks/use-storage-sync.ts
+++ b/src/hooks/use-storage-sync.ts
@@ -176,9 +176,10 @@ export function useStorageSync(): StorageSyncState {
// ── Pull all data from server → localStorage ──
/**
- * `owner` is the account the copy was claimed for. The answer is written only while the copy
- * still belongs to it: a sign-out (or another tab) that cleared the copy while the pull was out
- * keeps it cleared.
+ * `owner` is the account the copy was claimed for. Each collection of the answer is written
+ * only while the copy still belongs to it, checked again before every write: a sign-out, or
+ * another tab claiming the copy for a different account, while the pull was out or between two
+ * of its writes, keeps the copy as that left it.
*/
const pullFromServer = useCallback(async (owner: string) => {
setIsSyncing(true);
@@ -186,23 +187,14 @@ export function useStorageSync(): StorageSyncState {
const res = await appFetch("/api/storage");
if (!res.ok) return;
const data = (await res.json()) as Partial;
- if (localStorage.getItem(WORKSPACE_OWNER_KEY) !== owner) return;
// Write server data to localStorage (overwrite)
- if (data.connections) writeCollectionToLocal("connections", data.connections);
- if (data.history) writeCollectionToLocal("history", data.history);
- if (data.saved_queries) writeCollectionToLocal("saved_queries", data.saved_queries);
- if (data.schema_snapshots) writeCollectionToLocal("schema_snapshots", data.schema_snapshots);
- if (data.saved_charts) writeCollectionToLocal("saved_charts", data.saved_charts);
- if (data.active_connection_id !== undefined)
- writeCollectionToLocal("active_connection_id", data.active_connection_id);
- if (data.audit_log) writeCollectionToLocal("audit_log", data.audit_log);
- if (data.masking_config) writeCollectionToLocal("masking_config", data.masking_config);
- if (data.threshold_config) writeCollectionToLocal("threshold_config", data.threshold_config);
- if (data.dismissed_seeds) writeCollectionToLocal("dismissed_seeds", data.dismissed_seeds);
- if (data.favorite_connections) writeCollectionToLocal("favorite_connections", data.favorite_connections);
- if (data.connection_order) writeCollectionToLocal("connection_order", data.connection_order);
- if (data.connection_groups) writeCollectionToLocal("connection_groups", data.connection_groups);
+ for (const collection of STORAGE_COLLECTIONS) {
+ const value = data[collection];
+ if (!isPulled(collection, value)) continue;
+ if (localStorage.getItem(WORKSPACE_OWNER_KEY) !== owner) return;
+ writeCollectionToLocal(collection, value);
+ }
setLastSyncedAt(new Date());
setSyncError(null);
@@ -408,6 +400,14 @@ function getCollectionData(collection: string): unknown {
}
}
+/**
+ * Whether the pull writes a collection the server answered: one it holds a value for, and
+ * `active_connection_id` also when that value is null (the account has no active connection).
+ */
+function isPulled(collection: string, value: unknown): boolean {
+ return collection === "active_connection_id" ? value !== undefined : Boolean(value);
+}
+
/** Write server data directly to localStorage via storage key */
function writeCollectionToLocal(collection: string, data: unknown): void {
const key = `libredb_${collection}`;
diff --git a/src/hooks/use-tab-manager.ts b/src/hooks/use-tab-manager.ts
index 2896f0795..391019fa3 100644
--- a/src/hooks/use-tab-manager.ts
+++ b/src/hooks/use-tab-manager.ts
@@ -12,7 +12,7 @@ import { objectPathLabel, pathKey } from "@/lib/db/object-path";
import { resolveTabType } from "@/lib/editor/tab-language";
import { logger } from "@/lib/logger";
import { newLocalId } from "@/lib/ids";
-import { workspaceTabsKey } from "@/lib/storage/local-storage";
+import { holdsAccountWorkspace, workspaceTabsKey } from "@/lib/storage/local-storage";
import { useStableCallback } from "@/hooks/use-stable-callback";
/**
@@ -282,6 +282,8 @@ export function useTabManager({ activeConnection, metadata, schema, persistWorks
if (!isWorkspaceHydrated || !storage) return;
const timer = setTimeout(() => {
+ // A copy another tab has handed to a different account is not this page's to write.
+ if (!holdsAccountWorkspace(storage)) return;
const serialized: PersistedWorkspaceState = {
activeTabId,
tabs: tabs.map((tab) => ({
diff --git a/src/lib/storage/local-storage.ts b/src/lib/storage/local-storage.ts
index acf8ea4a6..e2ffeb53a 100644
--- a/src/lib/storage/local-storage.ts
+++ b/src/lib/storage/local-storage.ts
@@ -4,7 +4,7 @@
* No event dispatching — that's the facade's responsibility.
*/
-import { holdWorkspaceOwner } from "@/lib/config/base-path";
+import { heldWorkspaceOwner, holdWorkspaceOwner } from "@/lib/config/base-path";
import { logger } from "@/lib/logger";
import { STORAGE_COLLECTIONS } from "./types";
@@ -58,10 +58,11 @@ export function readString(collection: string): string | null {
/**
* Write JSON to localStorage.
- * Returns true on success, false on failure (e.g. QuotaExceededError).
+ * Returns true on success, false on failure (e.g. QuotaExceededError, or a copy this tab no
+ * longer holds: `holdsAccountWorkspace`).
*/
export function writeJSON(collection: string, data: unknown): boolean {
- if (!isClient()) return false;
+ if (!isClient() || !holdsAccountWorkspace()) return false;
try {
localStorage.setItem(getKey(collection), JSON.stringify(data));
return true;
@@ -76,10 +77,11 @@ export function writeJSON(collection: string, data: unknown): boolean {
/**
* Write raw string to localStorage.
- * Returns true on success, false on failure (e.g. QuotaExceededError).
+ * Returns true on success, false on failure (e.g. QuotaExceededError, or a copy this tab no
+ * longer holds: `holdsAccountWorkspace`).
*/
export function writeString(collection: string, value: string): boolean {
- if (!isClient()) return false;
+ if (!isClient() || !holdsAccountWorkspace()) return false;
try {
localStorage.setItem(getKey(collection), value);
return true;
@@ -96,7 +98,7 @@ export function writeString(collection: string, value: string): boolean {
* Remove a key from localStorage.
*/
export function remove(collection: string): void {
- if (!isClient()) return;
+ if (!isClient() || !holdsAccountWorkspace()) return;
localStorage.removeItem(getKey(collection));
}
@@ -114,7 +116,7 @@ export function workspaceTabsKey(connectionId: string): string {
/** Drop a connection's saved editor tabs; the connection they belonged to is gone. */
export function removeWorkspaceTabs(connectionId: string): void {
- if (!isClient()) return;
+ if (!isClient() || !holdsAccountWorkspace()) return;
localStorage.removeItem(workspaceTabsKey(connectionId));
}
@@ -137,6 +139,18 @@ export const SOURCE_DRAFTS_KEY = `${KEY_PREFIX}source_drafts_v1`;
/** The agent conversation this browser was last in (`components/agent/use-agent-run.ts`). */
export const AGENT_THREAD_KEY = `${KEY_PREFIX}agent_thread`;
+/**
+ * Whether this tab may write the browser copy. Before a claim, and so always in local mode, it
+ * may. In server storage mode it may only while the copy still belongs to the account this tab
+ * claimed it for: once another tab of the browser profile has cleared it or recorded a different
+ * owner, this tab writes nothing more and reloads (`WorkspaceOwnerGate`). `storage` is the store
+ * the owner key is read from, for a writer that is handed its store.
+ */
+export function holdsAccountWorkspace(storage: Pick = localStorage): boolean {
+ const claimed = heldWorkspaceOwner();
+ return claimed === null || storage.getItem(WORKSPACE_OWNER_KEY) === claimed;
+}
+
/**
* Every key that holds the signed-in account's browser copy: the synced collections, the
* unsynced drafts and hints, and the two keys that say whose copy it is. Per-browser
diff --git a/tests/hooks/use-tab-manager.test.ts b/tests/hooks/use-tab-manager.test.ts
index a71928867..2a3345eb1 100644
--- a/tests/hooks/use-tab-manager.test.ts
+++ b/tests/hooks/use-tab-manager.test.ts
@@ -8,6 +8,7 @@ import { mockToastDefault, mockToastDismiss, mockToastError } from "../helpers/m
import "../helpers/mock-navigation";
import { useTabManager, PREVIEW_PAGE_SIZE } from "@/hooks/use-tab-manager";
+import { holdWorkspaceOwner } from "@/lib/config/base-path";
import type { DatabaseConnection } from "@/lib/types";
import type { DetailedObject } from "@/lib/db/detailed-object";
import type { DatabaseObject } from "@/lib/db/types";
@@ -767,6 +768,40 @@ describe("useTabManager", () => {
// ─── Persistence: Load Effect ───
+ test("save — server mode: tabs are not written once another tab hands the copy to a different account", async () => {
+ holdWorkspaceOwner("ana@libredb.org");
+ localStorage.setItem("libredb_workspace_owner", "ana@libredb.org");
+ try {
+ const { result } = renderHook(() =>
+ useTabManager({ activeConnection: null, metadata: null, schema: [], persistWorkspace: true }),
+ );
+ await waitFor(() => {
+ expect(result.current.tabs).toHaveLength(1);
+ });
+ act(() => {
+ result.current.addTab();
+ });
+ await waitFor(
+ () => {
+ expect(localStorage.getItem("libredb_workspace_tabs_v1:default")).not.toBeNull();
+ },
+ { timeout: 2000 },
+ );
+
+ localStorage.removeItem("libredb_workspace_tabs_v1:default");
+ localStorage.setItem("libredb_workspace_owner", "bob@libredb.org");
+ act(() => {
+ result.current.addTab();
+ });
+ await new Promise((r) => setTimeout(r, 700));
+
+ expect(localStorage.getItem("libredb_workspace_tabs_v1:default")).toBeNull();
+ } finally {
+ holdWorkspaceOwner(null);
+ localStorage.removeItem("libredb_workspace_owner");
+ }
+ });
+
test("load — empty storage with persistWorkspace defaults to DEFAULT_TAB", async () => {
// No data in localStorage for this key
const { result } = renderHook(() =>
diff --git a/tests/isolated/use-storage-sync.test.ts b/tests/isolated/use-storage-sync.test.ts
index b716d3328..1827da23b 100644
--- a/tests/isolated/use-storage-sync.test.ts
+++ b/tests/isolated/use-storage-sync.test.ts
@@ -1,6 +1,6 @@
import "../setup-dom";
-import { describe, test, expect, mock, beforeEach, afterEach } from "bun:test";
+import { describe, test, expect, mock, spyOn, beforeEach, afterEach } from "bun:test";
import { renderHook, waitFor, act, cleanup } from "@testing-library/react";
import { mockGlobalFetch, restoreGlobalFetch } from "../helpers/mock-fetch";
@@ -50,6 +50,7 @@ mock.module("@/lib/storage", () => ({
import { useStorageSync } from "@/hooks/use-storage-sync";
import { releaseAccountWorkspace } from "@/lib/storage/sign-out";
import { workspaceTabsKey } from "@/lib/storage/local-storage";
+import { holdWorkspaceOwner } from "@/lib/config/base-path";
// ── Helpers ─────────────────────────────────────────────────────────────────
@@ -96,6 +97,7 @@ describe("useStorageSync", () => {
afterEach(() => {
restoreGlobalFetch();
cleanup();
+ holdWorkspaceOwner(null);
});
// ── Mode discovery ──────────────────────────────────────────────────────
@@ -454,6 +456,35 @@ describe("useStorageSync", () => {
expect(JSON.parse(stored!)).toEqual(groups);
});
+ test("stops writing once another tab hands the copy to a different account mid-pull", async () => {
+ const write = localStorage.setItem.bind(localStorage);
+ // Another tab's claim lands between two of this pull's writes.
+ const setItem = spyOn(localStorage, "setItem").mockImplementation((key: string, value: string) => {
+ write(key, value);
+ if (key === "libredb_connections") write("libredb_workspace_owner", "admin@libredb.org");
+ });
+ try {
+ setupServerMode({
+ "/api/storage": {
+ ok: true,
+ status: 200,
+ json: { connections: [{ id: "server-c1" }], history: [{ id: "server-h1" }] },
+ },
+ });
+
+ const { result } = renderHook(() => useStorageSync());
+ await waitFor(() => {
+ expect(result.current.isReady).toBe(true);
+ });
+ } finally {
+ setItem.mockRestore();
+ }
+
+ expect(localStorage.getItem("libredb_connections")).toBe(JSON.stringify([{ id: "server-c1" }]));
+ expect(localStorage.getItem("libredb_history")).toBeNull();
+ expect(localStorage.getItem("libredb_workspace_owner")).toBe("admin@libredb.org");
+ });
+
test("removes active_connection_id from localStorage when server returns null", async () => {
localStorage.setItem("libredb_server_migrated", "true");
localStorage.setItem("libredb_active_connection_id", "stale");
diff --git a/tests/unit/components/object-source-drafts.test.ts b/tests/unit/components/object-source-drafts.test.ts
index 96384b816..d3d4b1a88 100644
--- a/tests/unit/components/object-source-drafts.test.ts
+++ b/tests/unit/components/object-source-drafts.test.ts
@@ -1,4 +1,4 @@
-import { describe, expect, test } from "bun:test";
+import { afterEach, describe, expect, test } from "bun:test";
import {
DRAFT_BUDGET_CHARACTERS,
DRAFT_KEY,
@@ -10,6 +10,8 @@ import {
} from "@/components/object-source/source-drafts";
import { SOURCE_CHARACTER_LIMIT } from "@/lib/db/object-kinds";
import type { ObjectEditRevision } from "@/lib/db/types";
+import { holdWorkspaceOwner } from "@/lib/config/base-path";
+import { WORKSPACE_OWNER_KEY } from "@/lib/storage/local-storage";
const BASE: ObjectEditRevision = { check: "guarded", token: "t", basis: "b", scope: "server" };
@@ -289,3 +291,23 @@ describe("the populations the eviction policy is actually for", () => {
expect(readDraft(storage, "new")).toBeDefined();
});
});
+
+describe("server storage mode: a store that another tab has handed to a different account", () => {
+ afterEach(() => {
+ holdWorkspaceOwner(null);
+ });
+
+ test("is neither written nor dropped from, and the write says it was unavailable", () => {
+ const storage = fakeStorage();
+ holdWorkspaceOwner("ana@libredb.org");
+ storage.setItem(WORKSPACE_OWNER_KEY, "ana@libredb.org");
+ expect(writeDraft(storage, "a", draft("SELECT 1", 1))).toEqual({ ok: true, evicted: [] });
+
+ storage.setItem(WORKSPACE_OWNER_KEY, "bob@libredb.org");
+
+ expect(writeDraft(storage, "b", draft("SELECT 2", 2))).toEqual({ ok: false, reason: "unavailable" });
+ dropDraft(storage, "a");
+ expect(readDraft(storage, "a")?.text).toBe("SELECT 1");
+ expect(readDraft(storage, "b")).toBeUndefined();
+ });
+});
diff --git a/tests/unit/lib/storage/local-storage.test.ts b/tests/unit/lib/storage/local-storage.test.ts
index e0663fded..d844960eb 100644
--- a/tests/unit/lib/storage/local-storage.test.ts
+++ b/tests/unit/lib/storage/local-storage.test.ts
@@ -16,6 +16,8 @@ import {
claimAccountWorkspace,
resetAccountWorkspace,
workspaceTabsKey,
+ removeWorkspaceTabs,
+ holdsAccountWorkspace,
WORKSPACE_OWNER_KEY,
SERVER_MIGRATED_KEY,
SOURCE_DRAFTS_KEY,
@@ -134,6 +136,59 @@ describe("local-storage: remove", () => {
});
});
+describe("local-storage: writes while another tab changed whose copy this is", () => {
+ beforeEach(() => {
+ localStorage.clear();
+ });
+
+ afterEach(() => {
+ holdWorkspaceOwner(null);
+ });
+
+ test("before a claim, as in local mode, every write lands whatever the owner key says", () => {
+ localStorage.setItem(WORKSPACE_OWNER_KEY, "bob@libredb.org");
+
+ expect(holdsAccountWorkspace()).toBe(true);
+ expect(writeJSON("history", [])).toBe(true);
+ expect(writeString("active_connection_id", "c1")).toBe(true);
+ });
+
+ test("server mode: the browser copy is written only while it still belongs to the claimed account", () => {
+ holdWorkspaceOwner("ana@libredb.org");
+ localStorage.setItem(WORKSPACE_OWNER_KEY, "ana@libredb.org");
+ expect(holdsAccountWorkspace()).toBe(true);
+ expect(writeJSON("history", [{ id: "h1" }])).toBe(true);
+ localStorage.setItem(workspaceTabsKey("c1"), "[]");
+
+ localStorage.setItem(WORKSPACE_OWNER_KEY, "bob@libredb.org");
+
+ expect(holdsAccountWorkspace()).toBe(false);
+ expect(writeJSON("history", [{ id: "h2" }])).toBe(false);
+ expect(writeString("active_connection_id", "c2")).toBe(false);
+ remove("history");
+ removeWorkspaceTabs("c1");
+ expect(localStorage.getItem(getKey("history"))).toBe(JSON.stringify([{ id: "h1" }]));
+ expect(localStorage.getItem(getKey("active_connection_id"))).toBeNull();
+ expect(localStorage.getItem(workspaceTabsKey("c1"))).toBe("[]");
+ });
+
+ test("server mode: a copy cleared by a sign-out elsewhere is not written either", () => {
+ holdWorkspaceOwner("ana@libredb.org");
+ resetAccountWorkspace(null);
+
+ expect(writeJSON("connections", [{ id: "c1" }])).toBe(false);
+ expect(localStorage.getItem(getKey("connections"))).toBeNull();
+ });
+
+ test("the owner is read from the storage it is given", () => {
+ holdWorkspaceOwner("ana@libredb.org");
+ const other = { getItem: (key: string) => (key === WORKSPACE_OWNER_KEY ? "ana@libredb.org" : null) };
+
+ expect(holdsAccountWorkspace(other)).toBe(true);
+ expect(holdsAccountWorkspace()).toBe(false);
+ });
+});
+
describe("local-storage: clearAccountWorkspace", () => {
beforeEach(() => {
localStorage.clear();
From ab7d15fdcd5e375c1fadf533034407f3ac61d6f5 Mon Sep 17 00:00:00 2001
From: cevheri
Date: Wed, 7 Oct 2026 13:56:17 +0300
Subject: [PATCH 19/28] fix(storage): end the session when a sign-out cannot
push, and check for a session left after a refused one
A push that fails with the session already ended clears the copy and finishes to sign in. One that fails otherwise still ends the session and keeps the copy for this account, and the sign-out says so. After a refused or failed sign-out, GET /api/auth/me decides: with no session left the copy is cleared as for a sign-out. The flush clears its timers again after the push it waited for, and no timer pushes while a sign-out is under way. An unreadable storage mode on a copy with neither the owner key nor the migration flag signs out as local mode.
---
docs/STORAGE.md | 4 +-
src/app/launch/launch-client.tsx | 4 +-
src/components/admin/AdminDashboard.tsx | 8 +-
src/hooks/use-auth.ts | 24 ++-
src/hooks/use-storage-sync.ts | 34 +++-
src/lib/storage/sign-out.ts | 92 +++++++---
src/lib/storage/workspace-owner.ts | 28 +--
.../components/admin/AdminDashboard.test.tsx | 13 ++
tests/hooks/use-auth.test.ts | 41 ++++-
tests/isolated/use-storage-sync.test.ts | 83 ++++++++-
tests/unit/lib/storage/sign-out.test.ts | 164 ++++++++++++++----
11 files changed, 392 insertions(+), 103 deletions(-)
diff --git a/docs/STORAGE.md b/docs/STORAGE.md
index 1d8ced0a7..dae49c1c1 100644
--- a/docs/STORAGE.md
+++ b/docs/STORAGE.md
@@ -1271,7 +1271,9 @@ The copy is every synced collection, the editor tabs (never stored on the server
- **Sign-out** (the editor, the admin dashboard and the launch page): the signing-out tab's pending collections are pushed while the session is still valid, then `POST /api/auth/logout` ends the session, and only then is the copy cleared.
A change still waiting in another open tab is not pushed; it does not reach the server.
The cleared copy keeps the migration flag and no owner, so whatever is written to it afterwards is cleared at the next sign-in rather than migrated.
- When the storage mode cannot be read or a pending push does not land, the sign-out fails and the copy stays, so no unsaved change is dropped; when the server refuses the sign-out, the copy stays and the sync goes on.
+ A pending push that does not land does not stop the sign-out: when it failed because the session had already ended, the copy is cleared and the tab goes to sign in; otherwise (server storage unreachable) the session still ends, the copy stays for this account, protected by its owner key, and the sign-out says so; the next sign-in of a different account clears it.
+ When the server refuses the sign-out, or its answer is lost, the tab asks `GET /api/auth/me`: with no session left the sign-out counts as done and the copy is cleared, and while the session goes on the copy stays, the sync goes on, and the sign-out says it did not complete.
+ When the storage mode cannot be read, a copy that carries the owner key or the migration flag fails the sign-out and stays; a copy with neither is local-mode data, and the session just ends.
- **Other open tabs:** every tab of the browser profile shares the copy, so when another tab records a different owner or clears the copy (a sign-out, or a different account signing in and landing on the editor, an admin page, the monitoring page or a settings page), each page inside `WorkspaceOwnerGate` stops rendering and reloads, and the owner check runs again for the account signed in now.
A sign-in can also land where no owner is recorded (an unknown address), so a page inside `WorkspaceOwnerGate` also asks which account is signed in whenever its tab is shown again, and reloads the same way when that is no longer the account it rendered for.
- **Every request names the account:** once a page has matched the copy, every request it sends names that account in the `X-LibreDB-Workspace-Owner` header, and the server refuses one that names an account other than the signed-in one with `409 WORKSPACE_OWNER_MISMATCH` before any route runs ([API_DOCS.md](API_DOCS.md#storage-api)).
diff --git a/src/app/launch/launch-client.tsx b/src/app/launch/launch-client.tsx
index 29b794c20..a5b202b7e 100644
--- a/src/app/launch/launch-client.tsx
+++ b/src/app/launch/launch-client.tsx
@@ -114,10 +114,10 @@ function SessionConflict({ signedInAs, launchFor }: Conflict) {
async function signOut() {
// In server storage mode the browser copy belongs to the signed-in account and is cleared once
// the session ended.
- const response = await releaseAccountWorkspace(() => appFetch("/api/auth/logout", { method: "POST" })).catch(
+ const result = await releaseAccountWorkspace(() => appFetch("/api/auth/logout", { method: "POST" })).catch(
() => null,
);
- setState(response?.ok ? "signed-out" : "failed");
+ setState(result?.signedOut ? "signed-out" : "failed");
}
return (
diff --git a/src/components/admin/AdminDashboard.tsx b/src/components/admin/AdminDashboard.tsx
index dc48a298a..72736b962 100644
--- a/src/components/admin/AdminDashboard.tsx
+++ b/src/components/admin/AdminDashboard.tsx
@@ -34,9 +34,11 @@ export default function AdminDashboard({ children }: AdminDashboardProps) {
// the session ended.
let data: { redirectUrl?: string };
try {
- const res = await releaseAccountWorkspace(() => appFetch("/api/auth/logout", { method: "POST" }));
- if (!res.ok) throw new Error(`Sign-out refused: HTTP ${res.status}`);
- data = await res.json();
+ const { signedOut, response } = await releaseAccountWorkspace(() =>
+ appFetch("/api/auth/logout", { method: "POST" }),
+ );
+ if (!signedOut) throw new Error("Sign-out did not complete");
+ data = response ? await response.json() : {};
} catch {
toast.error("Failed to logout");
return;
diff --git a/src/hooks/use-auth.ts b/src/hooks/use-auth.ts
index 76503fbe5..8938c26da 100644
--- a/src/hooks/use-auth.ts
+++ b/src/hooks/use-auth.ts
@@ -8,6 +8,10 @@ import { useToast } from "@/hooks/use-toast";
import { logger } from "@/lib/logger";
import { releaseAccountWorkspace } from "@/lib/storage/sign-out";
+/** What a sign-out says when changes that could not be pushed stay in this browser's copy. */
+const CHANGES_KEPT =
+ "Some changes could not be saved to server storage. They stay in this browser for this account until a different account signs in here.";
+
interface AuthUser {
role?: string;
}
@@ -49,13 +53,19 @@ export function useAuth() {
const handleLogout = useCallback(async () => {
try {
// In server storage mode the browser copy belongs to the signed-in account: it is pushed
- // while the session cookie is still valid and cleared once the session ended. The
- // session-ended path above clears nothing; the owner check every page that reads the copy
- // runs at the next sign-in covers a different account.
- const res = await releaseAccountWorkspace(() => appFetch("/api/auth/logout", { method: "POST" }));
- if (!res.ok) throw new Error(`Sign-out refused: HTTP ${res.status}`);
- const data = await res.json();
- toast({ title: "Logged out", description: "You have been successfully logged out." });
+ // while the session cookie is still valid and cleared once the session ended, or kept for
+ // this account when a push did not land. The session-ended path above clears nothing; the
+ // owner check every page that reads the copy runs at the next sign-in covers a different
+ // account.
+ const { signedOut, response, changesKept } = await releaseAccountWorkspace(() =>
+ appFetch("/api/auth/logout", { method: "POST" }),
+ );
+ if (!signedOut) throw new Error("Sign-out did not complete");
+ const data: { redirectUrl?: string } = response ? await response.json() : {};
+ toast({
+ title: "Logged out",
+ description: changesKept ? CHANGES_KEPT : "You have been successfully logged out.",
+ });
if (data.redirectUrl) {
window.location.href = data.redirectUrl;
diff --git a/src/hooks/use-storage-sync.ts b/src/hooks/use-storage-sync.ts
index 2cffb1324..f66159aaa 100644
--- a/src/hooks/use-storage-sync.ts
+++ b/src/hooks/use-storage-sync.ts
@@ -68,8 +68,9 @@ export function useStorageSync(): StorageSyncState {
/** The push in flight, so a sign-out can wait for it while the session cookie is still valid. */
const inFlightRef = useRef | null>(null);
/**
- * Set while a sign-out is under way and after it: changes are queued but not pushed, so nothing
- * pushes the copy once it is cleared. A sign-out that did not happen resets it (`resume`).
+ * Set while a sign-out is under way and after it: changes are queued but not pushed, and no
+ * timer pushes, so nothing pushes the copy once it is cleared. A sign-out that did not happen
+ * resets it (`resume`).
*/
const signingOutRef = useRef(false);
/**
@@ -84,8 +85,8 @@ export function useStorageSync(): StorageSyncState {
// `flushPending` and `schedulePush` depending on each other.
const flushPendingRef = useRef<() => void>(() => {});
- // ── Flush pending collections ──
- const flushPending = useCallback(async () => {
+ // ── Push pending collections ──
+ const pushPending = useCallback(async () => {
// Declared inside the flush rather than as its own `useCallback`: it has
// exactly one caller, and hoisting it only bought this memo a dependency
// that could never change. It closes over nothing but refs, module-level
@@ -150,6 +151,14 @@ export function useStorageSync(): StorageSyncState {
}
}, []);
+ // ── Flush pending collections, unless a sign-out is under way ──
+ // Every timer goes through here, so a debounce or retry that fires during or after a sign-out
+ // sends nothing; the sign-out's own flush pushes through `pushPending` directly.
+ const flushPending = useCallback(async () => {
+ if (signingOutRef.current) return;
+ await pushPending();
+ }, [pushPending]);
+
// Filled after commit, never during render: React forbids writing `ref.current`
// while rendering, and the ref's only reader — the retry timer armed inside
// `flushPending` — cannot fire before the first commit, so it never sees the
@@ -327,14 +336,21 @@ export function useStorageSync(): StorageSyncState {
if (!isServerMode) return;
return registerWorkspaceSync({
flush: async () => {
- if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current);
- if (retryTimerRef.current) clearTimeout(retryTimerRef.current);
+ signingOutRef.current = true;
+ const clearTimers = () => {
+ if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current);
+ if (retryTimerRef.current) clearTimeout(retryTimerRef.current);
+ };
+ clearTimers();
await inFlightRef.current;
- await flushPending();
+ // The push awaited above arms a retry when it failed: cleared again, and after this
+ // flush's own push for the same reason.
+ clearTimers();
+ await pushPending();
+ clearTimers();
if (pendingCollectionsRef.current.size > 0) {
throw new Error("Unsaved changes could not be saved to server storage");
}
- signingOutRef.current = true;
},
resume: () => {
signingOutRef.current = false;
@@ -342,7 +358,7 @@ export function useStorageSync(): StorageSyncState {
for (const collection of held) schedulePush(collection);
},
});
- }, [isServerMode, flushPending, schedulePush]);
+ }, [isServerMode, pushPending, schedulePush]);
// ── Listen for storage mutations ──
useEffect(() => {
diff --git a/src/lib/storage/sign-out.ts b/src/lib/storage/sign-out.ts
index c169c2fc2..3fe392043 100644
--- a/src/lib/storage/sign-out.ts
+++ b/src/lib/storage/sign-out.ts
@@ -3,17 +3,19 @@
*
* In server storage mode the browser copy belongs to the signed-in account, so signing out
* pushes whatever is still waiting to be pushed while the session cookie is still valid, ends
- * the session, and only then clears the copy: a sign-out that does not happen leaves the
- * signed-in account's copy in place. In local mode the browser copy is the only one and stays.
+ * the session, and only then clears the copy. A push that did not land does not stop the sign-out:
+ * when the session had already ended the copy is cleared, and otherwise the session ends and the
+ * copy is kept for this account, whose owner key the next sign-in checks. A sign-out the server
+ * refused while the session goes on leaves the copy in place. In local mode the browser copy is
+ * the only one and stays.
*
* Every sign-out path goes through `releaseAccountWorkspace()` with its POST /api/auth/logout.
* Only the Studio page mounts `useStorageSync`, which registers itself here while it is mounted;
* the admin dashboard and the launch page have nothing pending and only clear.
*/
-import { appFetch } from "@/lib/config/base-path";
import { resetAccountWorkspace } from "./local-storage";
-import type { StorageConfigResponse } from "./types";
+import { readServerMode, readSignedInUsername, SessionEndedError } from "./workspace-owner";
/** The mounted sync, as a sign-out sees it. */
export interface WorkspaceSync {
@@ -23,6 +25,16 @@ export interface WorkspaceSync {
resume(): void;
}
+/** How a sign-out ended. */
+export interface SignOutResult {
+ /** The session is over: the sign-out succeeded, or no session was left after it. */
+ signedOut: boolean;
+ /** The successful POST /api/auth/logout answer, whose body may name the provider's sign-out address. */
+ response: Response | null;
+ /** Changes that could not be pushed stay in this browser's copy for the account that signed out. */
+ changesKept: boolean;
+}
+
let mountedSync: WorkspaceSync | null = null;
/** Register the mounted sync. Returns the unregister function. */
@@ -33,32 +45,58 @@ export function registerWorkspaceSync(sync: WorkspaceSync): () => void {
};
}
+/** Whether GET /api/auth/me says no session is left. Not knowing counts as a session that goes on. */
+async function sessionHasEnded(): Promise {
+ try {
+ await readSignedInUsername();
+ return false;
+ } catch (err) {
+ return err instanceof SessionEndedError;
+ }
+}
+
/**
- * Server mode: push what is pending, run `signOut`, and once it succeeded clear this browser's
- * copy, marked so nothing written to it afterwards is migrated into the next account. Local
- * mode: only `signOut`. Rejects, before `signOut` runs, when the storage mode cannot be read or a
- * pending push does not land, so the caller reports a failed sign-out instead of dropping unsaved
- * changes. A refused or failed `signOut` keeps the copy and resumes the sync.
+ * Runs `signOut`. A refused or failed one is still a sign-out when no session is left after it (the
+ * server ended the session and the answer was an error or was lost); otherwise the sync resumes.
*/
-export async function releaseAccountWorkspace(signOut: () => Promise): Promise {
- const res = await appFetch("/api/storage/config");
- if (!res.ok) throw new Error(`Storage mode unavailable: HTTP ${res.status}`);
- const config = (await res.json()) as StorageConfigResponse;
- if (!config.serverMode) return signOut();
+async function endSession(
+ signOut: () => Promise,
+ sync: WorkspaceSync | null,
+ changesKept: boolean,
+): Promise {
+ const response = await signOut().catch(() => null);
+ if (response?.ok) return { signedOut: true, response, changesKept };
+ if (await sessionHasEnded()) return { signedOut: true, response: null, changesKept };
+ sync?.resume();
+ return { signedOut: false, response: null, changesKept: false };
+}
+
+/**
+ * Server mode: push what is pending, run `signOut`, and once the session is over clear this
+ * browser's copy, marked so nothing written to it afterwards is migrated into the next account.
+ * A pending push that did not land clears the copy without `signOut` when the session had already
+ * ended, and otherwise keeps it (`changesKept`) while the session still ends. Local mode, or a
+ * storage mode that cannot be read for a copy never bound to a server account: only `signOut`.
+ * Rejects, before `signOut` runs, when the storage mode cannot be read for a copy bound to a server
+ * account, so the caller reports a failed sign-out.
+ */
+export async function releaseAccountWorkspace(signOut: () => Promise): Promise {
+ if (!(await readServerMode())) return endSession(signOut, null, false);
const sync = mountedSync;
- if (sync) await sync.flush();
- let response: Response;
- try {
- response = await signOut();
- } catch (err) {
- sync?.resume();
- throw err;
- }
- if (!response.ok) {
- sync?.resume();
- return response;
+ let changesKept = false;
+ if (sync) {
+ try {
+ await sync.flush();
+ } catch {
+ if (await sessionHasEnded()) {
+ resetAccountWorkspace(null);
+ return { signedOut: true, response: null, changesKept: false };
+ }
+ changesKept = true;
+ }
}
- resetAccountWorkspace(null);
- return response;
+ const result = await endSession(signOut, sync, changesKept);
+ if (result.signedOut && !changesKept) resetAccountWorkspace(null);
+ return result;
}
diff --git a/src/lib/storage/workspace-owner.ts b/src/lib/storage/workspace-owner.ts
index 7ca60809d..8247695ba 100644
--- a/src/lib/storage/workspace-owner.ts
+++ b/src/lib/storage/workspace-owner.ts
@@ -34,24 +34,30 @@ export async function readSignedInUsername(): Promise {
}
/**
- * Server mode: make the browser copy the signed-in account's before a page reads it. Local mode:
- * nothing. Throws in server mode when the signed-in account cannot be read: the copy must not be
- * used. A storage mode that cannot be read throws too when the copy carries an owner or the
- * migration flag, which only server mode writes, so such a copy is never shown unchecked; a copy
- * with neither counts as local mode, as `useStorageSync` counts it. Answers the account the copy
- * now belongs to, or null when no account was matched (local mode).
+ * Whether server storage mode is on, from GET /api/storage/config. A storage mode that cannot be
+ * read throws when the copy carries an owner or the migration flag, which only server mode
+ * writes, so such a copy is never handled unchecked; a copy with neither counts as local mode, as
+ * `useStorageSync` counts it.
*/
-export async function claimWorkspaceForSignedInAccount(): Promise {
- let config: StorageConfigResponse;
+export async function readServerMode(): Promise {
try {
const res = await appFetch("/api/storage/config");
if (!res.ok) throw new Error(`Storage mode unavailable: HTTP ${res.status}`);
- config = (await res.json()) as StorageConfigResponse;
+ return ((await res.json()) as StorageConfigResponse).serverMode;
} catch (err) {
if (isServerBoundCopy()) throw err;
- return null;
+ return false;
}
- if (!config.serverMode) return null;
+}
+
+/**
+ * Server mode: make the browser copy the signed-in account's before a page reads it. Local mode:
+ * nothing. Throws in server mode when the signed-in account cannot be read: the copy must not be
+ * used. A storage mode that cannot be read is judged as `readServerMode` judges it. Answers the
+ * account the copy now belongs to, or null when no account was matched (local mode).
+ */
+export async function claimWorkspaceForSignedInAccount(): Promise {
+ if (!(await readServerMode())) return null;
const username = await readSignedInUsername();
claimAccountWorkspace(username);
return username;
diff --git a/tests/components/admin/AdminDashboard.test.tsx b/tests/components/admin/AdminDashboard.test.tsx
index 5835e2900..e09c76fe8 100644
--- a/tests/components/admin/AdminDashboard.test.tsx
+++ b/tests/components/admin/AdminDashboard.test.tsx
@@ -264,6 +264,19 @@ describe("AdminDashboard", () => {
expect(localStorage.getItem("libredb_connections")).not.toBeNull();
});
+ test("server mode: a refused sign-out after which no session is left clears the copy and goes to sign in", async () => {
+ mockGlobalFetch({
+ "/api/auth/logout": { ok: false, status: 500, json: { error: "Server error" } },
+ "/api/auth/me": { ok: false, status: 401, json: { authenticated: false } },
+ "/api/storage/config": { json: { provider: "postgres", serverMode: true } },
+ });
+
+ await clickLogout();
+
+ expect(localStorage.getItem("libredb_connections")).toBeNull();
+ expect(localStorage.getItem("libredb_workspace_owner")).toBeNull();
+ });
+
test("local mode: the copy stays", async () => {
await clickLogout();
diff --git a/tests/hooks/use-auth.test.ts b/tests/hooks/use-auth.test.ts
index ddac8d9c9..a8c82fb99 100644
--- a/tests/hooks/use-auth.test.ts
+++ b/tests/hooks/use-auth.test.ts
@@ -471,9 +471,9 @@ describe("useAuth", () => {
expect(mockRouterPush).toHaveBeenCalledWith("/login");
});
- test("server mode: a push that does not land keeps the session and the copy", async () => {
+ test("server mode: a push that does not land ends the session, keeps the copy for this account and says so", async () => {
const fetchMock = mockGlobalFetch({
- "/api/auth/me": { ok: true, json: { user: { role: "user" } } },
+ "/api/auth/me": { ok: true, json: { user: { role: "user", username: "user@libredb.org" } } },
"/api/storage/config": { ok: true, json: { provider: "postgres", serverMode: true } },
"/api/auth/logout": { ok: true, json: { success: true } },
});
@@ -493,9 +493,42 @@ describe("useAuth", () => {
});
unregister();
- expect(fetchMock.mock.calls.some((call) => String(call[0]).includes("/api/auth/logout"))).toBe(false);
+ expect(fetchMock.mock.calls.some((call) => String(call[0]).includes("/api/auth/logout"))).toBe(true);
expect(localStorage.getItem("libredb_connections")).not.toBeNull();
- expect(mockToastError).toHaveBeenCalledWith("Error", { description: "Failed to logout." });
+ expect(localStorage.getItem("libredb_workspace_owner")).toBe("user@libredb.org");
+ expect(mockToastSuccess).toHaveBeenCalledWith("Logged out", {
+ description:
+ "Some changes could not be saved to server storage. They stay in this browser for this account until a different account signs in here.",
+ });
+ expect(mockRouterPush).toHaveBeenCalledWith("/login");
+ });
+
+ test("server mode: a sign-out refused after which no session is left clears the copy and goes to sign in", async () => {
+ let signedIn = true;
+ mockGlobalFetch({
+ "/api/auth/me": () =>
+ signedIn
+ ? { ok: true, json: { user: { role: "user", username: "user@libredb.org" } } }
+ : { ok: false, status: 401, json: { authenticated: false } },
+ "/api/storage/config": { ok: true, json: { provider: "postgres", serverMode: true } },
+ "/api/auth/logout": () => {
+ signedIn = false;
+ return { ok: false, status: 500, json: { error: "Server error" } };
+ },
+ });
+
+ const { result } = renderHook(() => useAuth());
+ await waitFor(() => {
+ expect(result.current.user).not.toBeNull();
+ });
+ await act(async () => {
+ await result.current.handleLogout();
+ });
+
+ expect(localStorage.getItem("libredb_connections")).toBeNull();
+ expect(localStorage.getItem("libredb_workspace_owner")).toBeNull();
+ expect(mockToastError).not.toHaveBeenCalled();
+ expect(mockRouterPush).toHaveBeenCalledWith("/login");
});
test("server mode: a sign-out the server refused keeps the copy", async () => {
diff --git a/tests/isolated/use-storage-sync.test.ts b/tests/isolated/use-storage-sync.test.ts
index 1827da23b..caf3e84d5 100644
--- a/tests/isolated/use-storage-sync.test.ts
+++ b/tests/isolated/use-storage-sync.test.ts
@@ -1230,7 +1230,7 @@ describe("useStorageSync", () => {
expect(localStorage.getItem("libredb_workspace_owner")).toBeNull();
});
- test("a pending push that does not land fails the sign-out and keeps the copy", async () => {
+ test("a pending push that does not land still ends the session and keeps the copy for this account", async () => {
localStorage.setItem("libredb_workspace_owner", USERNAME);
localStorage.setItem("libredb_server_migrated", "2026-10-01");
let signedOut = false;
@@ -1247,18 +1247,87 @@ describe("useStorageSync", () => {
act(() => {
window.dispatchEvent(new CustomEvent("libredb-storage-change", { detail: { collection: "history" } }));
});
- let failure: unknown = null;
+ let outcome: Awaited> | null = null;
await act(async () => {
- await releaseAccountWorkspace(signOutAnswering(200, () => (signedOut = true))).catch((err: unknown) => {
- failure = err;
- });
+ outcome = await releaseAccountWorkspace(signOutAnswering(200, () => (signedOut = true)));
});
- expect(failure).toBeInstanceOf(Error);
- expect(signedOut).toBe(false);
+ expect(outcome).toMatchObject({ signedOut: true, changesKept: true });
+ expect(signedOut).toBe(true);
expect(localStorage.getItem("libredb_workspace_owner")).toBe(USERNAME);
});
+ test("a pending push refused because the session had ended clears the copy", async () => {
+ localStorage.setItem("libredb_workspace_owner", USERNAME);
+ localStorage.setItem("libredb_server_migrated", "2026-10-01");
+ localStorage.setItem("libredb_connections", JSON.stringify([{ id: "c1", password: "pw" }]));
+ let sessionEnded = false;
+ let signedOut = false;
+ setupSignOut({
+ "/api/auth/me": () =>
+ sessionEnded ? { ok: false, status: 401, json: { authenticated: false } } : SIGNED_IN["/api/auth/me"],
+ "/api/storage/history": () => {
+ sessionEnded = true;
+ return { ok: false, status: 401, json: { error: "Session expired. Sign in again.", code: "AUTH_REQUIRED" } };
+ },
+ "/api/storage": { ok: true, status: 200, json: {} },
+ });
+
+ const { result } = renderHook(() => useStorageSync());
+ await waitFor(() => {
+ expect(result.current.isReady).toBe(true);
+ });
+
+ act(() => {
+ window.dispatchEvent(new CustomEvent("libredb-storage-change", { detail: { collection: "history" } }));
+ });
+ let outcome: Awaited> | null = null;
+ await act(async () => {
+ outcome = await releaseAccountWorkspace(signOutAnswering(200, () => (signedOut = true)));
+ });
+
+ expect(outcome).toMatchObject({ signedOut: true, changesKept: false });
+ expect(signedOut).toBe(false);
+ expect(localStorage.getItem("libredb_connections")).toBeNull();
+ expect(localStorage.getItem("libredb_workspace_owner")).toBeNull();
+ });
+
+ test("a retry armed while the sign-out waited for a failing push sends nothing afterwards", async () => {
+ localStorage.setItem("libredb_workspace_owner", USERNAME);
+ localStorage.setItem("libredb_server_migrated", "2026-10-01");
+ let pushes = 0;
+ let started = false;
+ setupSignOut({
+ "/api/storage/history": async () => {
+ pushes += 1;
+ started = true;
+ await new Promise((r) => setTimeout(r, 100));
+ return { ok: false, status: 503, json: { error: "Storage unreachable" } };
+ },
+ "/api/storage": { ok: true, status: 200, json: {} },
+ });
+
+ const { result } = renderHook(() => useStorageSync());
+ await waitFor(() => {
+ expect(result.current.isReady).toBe(true);
+ });
+
+ act(() => {
+ window.dispatchEvent(new CustomEvent("libredb-storage-change", { detail: { collection: "history" } }));
+ });
+ await waitFor(() => {
+ expect(started).toBe(true);
+ });
+ await act(async () => {
+ await releaseAccountWorkspace(signOutAnswering(200));
+ });
+ const atSignOut = pushes;
+ await act(async () => await new Promise((r) => setTimeout(r, 2500)));
+
+ expect(atSignOut).toBe(2);
+ expect(pushes).toBe(atSignOut);
+ });
+
test("pushes nothing after the browser copy is cleared", async () => {
localStorage.setItem("libredb_workspace_owner", USERNAME);
localStorage.setItem("libredb_server_migrated", "2026-10-01");
diff --git a/tests/unit/lib/storage/sign-out.test.ts b/tests/unit/lib/storage/sign-out.test.ts
index ef5c378ae..8b17def6e 100644
--- a/tests/unit/lib/storage/sign-out.test.ts
+++ b/tests/unit/lib/storage/sign-out.test.ts
@@ -11,13 +11,20 @@ import { SERVER_MIGRATED_KEY, WORKSPACE_OWNER_KEY } from "@/lib/storage/local-st
const CONNECTIONS = JSON.stringify([{ id: "c1", password: "pw" }]);
-function serverMode(serverModeOn: boolean) {
+/** GET /api/auth/me after the sign-out: the session goes on (200) or has ended (401). */
+type Session = "alive" | "ended";
+
+function serverMode(serverModeOn: boolean, session: Session = "alive") {
return mockGlobalFetch({
"/api/storage/config": {
ok: true,
status: 200,
json: { provider: serverModeOn ? "postgres" : "local", serverMode: serverModeOn },
},
+ "/api/auth/me":
+ session === "alive"
+ ? { json: { authenticated: true, user: { username: "admin@libredb.org" } } }
+ : { status: 401, json: { authenticated: false } },
});
}
@@ -29,6 +36,22 @@ function signOutAnswering(status: number, seen: (string | null)[] = []) {
};
}
+async function networkDown(): Promise {
+ throw new Error("network down");
+}
+
+/** A mounted sync whose flush rejects, as one does when a pending collection did not land. */
+function failingSync() {
+ const calls = { resumed: 0 };
+ const unregister = registerWorkspaceSync({
+ flush: async () => {
+ throw new Error("Unsaved changes could not be saved");
+ },
+ resume: () => (calls.resumed += 1),
+ });
+ return { calls, unregister };
+}
+
describe("releaseAccountWorkspace", () => {
beforeEach(() => {
localStorage.clear();
@@ -50,13 +73,15 @@ describe("releaseAccountWorkspace", () => {
resume: () => order.push("resume"),
});
- const response = await releaseAccountWorkspace(async () => {
+ const result = await releaseAccountWorkspace(async () => {
order.push(`sign-out:${localStorage.getItem("libredb_connections")}`);
return new Response("{}", { status: 200 });
});
unregister();
- expect(response.ok).toBe(true);
+ expect(result.signedOut).toBe(true);
+ expect(result.changesKept).toBe(false);
+ expect(result.response?.ok).toBe(true);
expect(order).toEqual([`flush:${CONNECTIONS}`, `sign-out:${CONNECTIONS}`]);
expect(localStorage.getItem("libredb_connections")).toBeNull();
expect(localStorage.getItem(WORKSPACE_OWNER_KEY)).toBeNull();
@@ -67,68 +92,119 @@ describe("releaseAccountWorkspace", () => {
test("server mode with no sync mounted: ends the session, then clears this browser's copy", async () => {
serverMode(true);
const seen: (string | null)[] = [];
- await releaseAccountWorkspace(signOutAnswering(200, seen));
+ const result = await releaseAccountWorkspace(signOutAnswering(200, seen));
+ expect(result.signedOut).toBe(true);
expect(seen).toEqual([CONNECTIONS]);
expect(localStorage.getItem("libredb_connections")).toBeNull();
});
- test("server mode: a push that does not land keeps the session and the copy", async () => {
- serverMode(true);
+ test("server mode: a push that did not land because the session had ended clears the copy", async () => {
+ serverMode(true, "ended");
const seen: (string | null)[] = [];
- const unregister = registerWorkspaceSync({
- flush: async () => {
- throw new Error("Unsaved changes could not be saved");
- },
- resume: () => {},
- });
+ const { calls, unregister } = failingSync();
- await expect(releaseAccountWorkspace(signOutAnswering(200, seen))).rejects.toThrow(
- "Unsaved changes could not be saved",
- );
+ const result = await releaseAccountWorkspace(signOutAnswering(200, seen));
unregister();
+ expect(result).toEqual({ signedOut: true, response: null, changesKept: false });
expect(seen).toEqual([]);
+ expect(calls.resumed).toBe(0);
+ expect(localStorage.getItem("libredb_connections")).toBeNull();
+ expect(localStorage.getItem(WORKSPACE_OWNER_KEY)).toBeNull();
+ });
+
+ test("server mode: a push that did not land otherwise still ends the session and keeps the copy for this account", async () => {
+ serverMode(true);
+ const seen: (string | null)[] = [];
+ const { calls, unregister } = failingSync();
+
+ const result = await releaseAccountWorkspace(signOutAnswering(200, seen));
+ unregister();
+
+ expect(result.signedOut).toBe(true);
+ expect(result.changesKept).toBe(true);
+ expect(seen).toEqual([CONNECTIONS]);
+ expect(calls.resumed).toBe(0);
+ expect(localStorage.getItem("libredb_connections")).toBe(CONNECTIONS);
+ expect(localStorage.getItem(WORKSPACE_OWNER_KEY)).toBe("admin@libredb.org");
+ });
+
+ test("server mode: a push that did not land, then a sign-out refused while the session goes on, resumes", async () => {
+ serverMode(true);
+ const { calls, unregister } = failingSync();
+
+ const result = await releaseAccountWorkspace(signOutAnswering(500));
+ unregister();
+
+ expect(result).toEqual({ signedOut: false, response: null, changesKept: false });
+ expect(calls.resumed).toBe(1);
+ expect(localStorage.getItem("libredb_connections")).toBe(CONNECTIONS);
+ });
+
+ test("server mode: a push that did not land, then a sign-out that ended the session anyway, keeps the copy", async () => {
+ serverMode(true, "alive");
+ const { unregister } = failingSync();
+ const result = await releaseAccountWorkspace(async () => {
+ // The answer is lost after the server ended the session.
+ serverMode(true, "ended");
+ throw new Error("connection reset");
+ });
+ unregister();
+
+ expect(result).toEqual({ signedOut: true, response: null, changesKept: true });
expect(localStorage.getItem("libredb_connections")).toBe(CONNECTIONS);
});
- test("server mode: a sign-out the server refused keeps the copy and resumes the sync", async () => {
+ test("server mode: a sign-out the server refused while the session goes on keeps the copy and resumes the sync", async () => {
serverMode(true);
let resumed = 0;
const unregister = registerWorkspaceSync({ flush: async () => {}, resume: () => (resumed += 1) });
- const response = await releaseAccountWorkspace(signOutAnswering(500));
+ const result = await releaseAccountWorkspace(signOutAnswering(500));
unregister();
- expect(response.status).toBe(500);
+ expect(result.signedOut).toBe(false);
expect(resumed).toBe(1);
expect(localStorage.getItem("libredb_connections")).toBe(CONNECTIONS);
expect(localStorage.getItem(WORKSPACE_OWNER_KEY)).toBe("admin@libredb.org");
});
- test("server mode: a sign-out request that fails keeps the copy and resumes the sync", async () => {
+ test("server mode: a sign-out request that fails while the session goes on keeps the copy and resumes the sync", async () => {
serverMode(true);
let resumed = 0;
const unregister = registerWorkspaceSync({ flush: async () => {}, resume: () => (resumed += 1) });
- await expect(
- releaseAccountWorkspace(async () => {
- throw new Error("network down");
- }),
- ).rejects.toThrow("network down");
+ const result = await releaseAccountWorkspace(networkDown);
unregister();
+ expect(result.signedOut).toBe(false);
expect(resumed).toBe(1);
expect(localStorage.getItem("libredb_connections")).toBe(CONNECTIONS);
});
+ test("server mode: a refused or failed sign-out after which no session is left clears the copy", async () => {
+ serverMode(true, "ended");
+ let resumed = 0;
+ const unregister = registerWorkspaceSync({ flush: async () => {}, resume: () => (resumed += 1) });
+
+ const refused = await releaseAccountWorkspace(signOutAnswering(500));
+ expect(refused).toEqual({ signedOut: true, response: null, changesKept: false });
+ expect(localStorage.getItem("libredb_connections")).toBeNull();
+
+ localStorage.setItem("libredb_connections", CONNECTIONS);
+ localStorage.setItem(WORKSPACE_OWNER_KEY, "admin@libredb.org");
+ const failed = await releaseAccountWorkspace(networkDown);
+ unregister();
+
+ expect(failed.signedOut).toBe(true);
+ expect(resumed).toBe(0);
+ expect(localStorage.getItem("libredb_connections")).toBeNull();
+ });
+
test("server mode with no sync mounted: a refused sign-out keeps the copy", async () => {
serverMode(true);
- await releaseAccountWorkspace(signOutAnswering(500));
- await expect(
- releaseAccountWorkspace(async () => {
- throw new Error("network down");
- }),
- ).rejects.toThrow("network down");
+ expect((await releaseAccountWorkspace(signOutAnswering(500))).signedOut).toBe(false);
+ expect((await releaseAccountWorkspace(networkDown)).signedOut).toBe(false);
expect(localStorage.getItem("libredb_connections")).toBe(CONNECTIONS);
});
@@ -143,9 +219,10 @@ describe("releaseAccountWorkspace", () => {
resume: () => {},
});
- await releaseAccountWorkspace(signOutAnswering(200, seen));
+ const result = await releaseAccountWorkspace(signOutAnswering(200, seen));
unregister();
+ expect(result.signedOut).toBe(true);
expect(pushed).toBe(false);
expect(seen).toEqual([CONNECTIONS]);
expect(localStorage.getItem("libredb_connections")).toBe(CONNECTIONS);
@@ -153,7 +230,17 @@ describe("releaseAccountWorkspace", () => {
expect(localStorage.getItem(SERVER_MIGRATED_KEY)).toBeNull();
});
- test("an unreadable storage mode fails the sign-out and clears nothing", async () => {
+ test("local mode: a refused sign-out says so while the session goes on, and clears nothing when it has ended", async () => {
+ serverMode(false);
+ expect((await releaseAccountWorkspace(signOutAnswering(500))).signedOut).toBe(false);
+
+ serverMode(false, "ended");
+ expect((await releaseAccountWorkspace(signOutAnswering(500))).signedOut).toBe(true);
+ expect(localStorage.getItem("libredb_connections")).toBe(CONNECTIONS);
+ expect(localStorage.getItem(SERVER_MIGRATED_KEY)).toBeNull();
+ });
+
+ test("an unreadable storage mode fails the sign-out and clears nothing for a copy bound to a server account", async () => {
mockGlobalFetch({ "/api/storage/config": { ok: false, status: 500, json: { error: "down" } } });
const seen: (string | null)[] = [];
@@ -162,6 +249,19 @@ describe("releaseAccountWorkspace", () => {
expect(localStorage.getItem("libredb_connections")).toBe(CONNECTIONS);
});
+ test("an unreadable storage mode counts as local mode for a copy never bound to a server account", async () => {
+ localStorage.removeItem(WORKSPACE_OWNER_KEY);
+ mockGlobalFetch({ "/api/storage/config": { ok: false, status: 500, json: { error: "down" } } });
+ const seen: (string | null)[] = [];
+
+ const result = await releaseAccountWorkspace(signOutAnswering(200, seen));
+
+ expect(result.signedOut).toBe(true);
+ expect(seen).toEqual([CONNECTIONS]);
+ expect(localStorage.getItem("libredb_connections")).toBe(CONNECTIONS);
+ expect(localStorage.getItem(SERVER_MIGRATED_KEY)).toBeNull();
+ });
+
test("unregistering a sync that was replaced leaves the newer one in place", async () => {
serverMode(true);
let newer = 0;
From 1d8e748721646e142b67ebd6eb3d049fb6689b44 Mon Sep 17 00:00:00 2001
From: cevheri
Date: Wed, 7 Oct 2026 13:56:38 +0300
Subject: [PATCH 20/28] fix(storage): reload a page whose owner key another tab
removed, also when the same name follows
A removed owner key means the copy was cleared, so a page inside WorkspaceOwnerGate reloads even when another tab claiming for the same account writes the name again right after.
---
src/components/auth/WorkspaceOwnerGate.tsx | 9 ++++---
.../auth/WorkspaceOwnerGate.test.tsx | 25 +++++++++++++++++++
2 files changed, 31 insertions(+), 3 deletions(-)
diff --git a/src/components/auth/WorkspaceOwnerGate.tsx b/src/components/auth/WorkspaceOwnerGate.tsx
index 13e307cf9..83b8d664a 100644
--- a/src/components/auth/WorkspaceOwnerGate.tsx
+++ b/src/components/auth/WorkspaceOwnerGate.tsx
@@ -17,8 +17,8 @@ import {
* so a copy another account left behind is cleared before the page reads it, and signing in as
* a different account starts from that account's server data on every such page, not only the
* editor. The copy is shared by every tab of the browser profile: when another tab records a
- * different owner or clears it (a sign-out, a different account signing in), this page stops
- * rendering and reloads, so the check runs again for the account signed in now. A sign-in can
+ * different owner or clears it (a sign-out, a different account signing in, or a claim for this
+ * same account that cleared it first), this page stops rendering and reloads, so the check runs again for the account signed in now. A sign-in can
* also land where nothing records an owner (an unknown address), so whenever the tab is shown
* again it asks which account is signed in, and reloads the same way when that is no longer the
* account it rendered for. A session that has ended, at the first check or when the tab is shown
@@ -51,7 +51,10 @@ export function WorkspaceOwnerGate({ children }: { children: ReactNode }) {
};
const onStorage = (event: StorageEvent) => {
if (event.key !== null && event.key !== WORKSPACE_OWNER_KEY) return;
- if (localStorage.getItem(WORKSPACE_OWNER_KEY) === owner) return;
+ // A removed owner key means the copy was cleared, also when another tab claiming for
+ // this same account writes the name again right after: this page's data is gone.
+ const removed = event.key === WORKSPACE_OWNER_KEY && event.newValue === null;
+ if (!removed && localStorage.getItem(WORKSPACE_OWNER_KEY) === owner) return;
leave();
};
const onShown = () => {
diff --git a/tests/components/auth/WorkspaceOwnerGate.test.tsx b/tests/components/auth/WorkspaceOwnerGate.test.tsx
index 00a81b83d..93b25c218 100644
--- a/tests/components/auth/WorkspaceOwnerGate.test.tsx
+++ b/tests/components/auth/WorkspaceOwnerGate.test.tsx
@@ -257,6 +257,31 @@ describe("WorkspaceOwnerGate", () => {
expect(reload).not.toHaveBeenCalled();
});
+ test("server mode: the owner key removed elsewhere drops the page, even when the same name is written right after", async () => {
+ // Another tab claiming for the same account clears the copy and records the name again; this
+ // page's pulled data is gone from the copy all the same.
+ mockGlobalFetch({ ...SERVER_MODE, "/api/auth/me": { json: { user: { username: "admin@libredb.org" } } } });
+ const view = render(
+
+ workspace page
+ ,
+ );
+ expect(await view.findByText("workspace page")).not.toBeNull();
+
+ act(() => {
+ window.dispatchEvent(
+ new window.StorageEvent("storage", {
+ key: "libredb_workspace_owner",
+ oldValue: "admin@libredb.org",
+ newValue: null,
+ }),
+ );
+ });
+
+ expect(view.queryByText("workspace page")).toBeNull();
+ expect(reload).toHaveBeenCalledTimes(1);
+ });
+
test("local mode: an owner key written elsewhere changes nothing", async () => {
mockGlobalFetch({ "/api/storage/config": { json: { provider: "local", serverMode: false } } });
const view = render(
From 8e23f210a72b9876a999f79be9e1c01e039aa876 Mon Sep 17 00:00:00 2001
From: cevheri
Date: Wed, 7 Oct 2026 13:56:51 +0300
Subject: [PATCH 21/28] refactor(storage): mark the gate and settings layout
props read-only and assert lengths with toHaveLength
---
src/app/settings/layout.tsx | 2 +-
src/components/auth/WorkspaceOwnerGate.tsx | 2 +-
tests/unit/lib/storage/local-storage.test.ts | 2 +-
tests/unit/lib/storage/workspace-owner.test.ts | 2 +-
4 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/src/app/settings/layout.tsx b/src/app/settings/layout.tsx
index 9a58368ce..718e76b47 100644
--- a/src/app/settings/layout.tsx
+++ b/src/app/settings/layout.tsx
@@ -1,6 +1,6 @@
import { WorkspaceOwnerGate } from "@/components/auth/WorkspaceOwnerGate";
-export default function SettingsLayout({ children }: { children: React.ReactNode }) {
+export default function SettingsLayout({ children }: Readonly<{ children: React.ReactNode }>) {
// The gate: a sign-in can land on a settings page, so the browser copy is recorded as the
// signed-in account's here too, and every other tab of the browser profile follows that account.
return {children};
diff --git a/src/components/auth/WorkspaceOwnerGate.tsx b/src/components/auth/WorkspaceOwnerGate.tsx
index 83b8d664a..ab3ae13ac 100644
--- a/src/components/auth/WorkspaceOwnerGate.tsx
+++ b/src/components/auth/WorkspaceOwnerGate.tsx
@@ -26,7 +26,7 @@ import {
* reports an ended session. In local mode the page renders after the storage mode is known, with
* the copy as it is.
*/
-export function WorkspaceOwnerGate({ children }: { children: ReactNode }) {
+export function WorkspaceOwnerGate({ children }: Readonly<{ children: ReactNode }>) {
const [state, setState] = useState<"checking" | "ready" | "failed">("checking");
useEffect(() => {
diff --git a/tests/unit/lib/storage/local-storage.test.ts b/tests/unit/lib/storage/local-storage.test.ts
index d844960eb..292f91dfc 100644
--- a/tests/unit/lib/storage/local-storage.test.ts
+++ b/tests/unit/lib/storage/local-storage.test.ts
@@ -212,7 +212,7 @@ describe("local-storage: clearAccountWorkspace", () => {
clearAccountWorkspace();
- expect(localStorage.length).toBe(0);
+ expect(localStorage).toHaveLength(0);
});
test("keeps the per-browser preferences", () => {
diff --git a/tests/unit/lib/storage/workspace-owner.test.ts b/tests/unit/lib/storage/workspace-owner.test.ts
index 5f653202a..0fe542f51 100644
--- a/tests/unit/lib/storage/workspace-owner.test.ts
+++ b/tests/unit/lib/storage/workspace-owner.test.ts
@@ -57,7 +57,7 @@ describe("claimWorkspaceForSignedInAccount", () => {
await claimWorkspaceForSignedInAccount();
expect(localStorage.getItem("libredb_connections")).not.toBeNull();
- expect(fetchMock.mock.calls.length).toBe(1);
+ expect(fetchMock.mock.calls).toHaveLength(1);
});
test("a storage mode that cannot be read throws for a copy that was bound to a server account", async () => {
From 8ade56adfefe95bf13123f0928c171ea421eba5f Mon Sep 17 00:00:00 2001
From: cevheri
Date: Wed, 7 Oct 2026 13:57:14 +0300
Subject: [PATCH 22/28] docs(storage): say when the browser copy stays after a
sign-out and how the owner is matched
SECURITY.md names the sign-out that could not complete and the session that ended without one. STORAGE.md keeps only claims the code holds: the pending collections pushed first are the signing-out tab's, and the owner is matched when the sync starts and then on every request through the header.
---
docs/SECURITY.md | 1 +
docs/STORAGE.md | 7 ++++---
2 files changed, 5 insertions(+), 3 deletions(-)
diff --git a/docs/SECURITY.md b/docs/SECURITY.md
index 146fdac2d..2c08bda8d 100644
--- a/docs/SECURITY.md
+++ b/docs/SECURITY.md
@@ -584,6 +584,7 @@ These are real, current, and not oversights. Each is a decision with a reason.
This is why 0.1 and 1.1 matter as much as they do.
In server storage mode the browser copy belongs to the signed-in account: it is cleared on sign-out, and a different account signing in starts from its own server data.
Every request a tab sends after matching its copy names that account, and the server refuses one that names an account other than the signed-in one, so a tab still open for one account never writes into another account's server data.
+ A sign-out that could not complete, or a session that ended without one (expiry, a disabled account), leaves the copy in that browser until the next sign-in's owner check, which clears it for a different account.
In local mode it stays, and the next account to sign in on that browser profile sees and can open the connections saved there.
- **Anyone who can read the server's environment can read the stored credentials.** 3.1 protects a
stolen database file or dump on its own; it is not a vault. For `STORAGE_PROVIDER=sqlite` with no
diff --git a/docs/STORAGE.md b/docs/STORAGE.md
index dae49c1c1..1f49ff248 100644
--- a/docs/STORAGE.md
+++ b/docs/STORAGE.md
@@ -1257,7 +1257,7 @@ When any `storage.*` mutation fires:
### The Browser Copy Belongs to the Signed-In Account
In server mode the browser copy is bound to the account that signed in: `libredb_workspace_owner` holds its username, and it is compared with `GET /api/auth/me` before any page reads the copy.
-Every page that reads it (the editor, the admin dashboard and the monitoring page) renders inside `WorkspaceOwnerGate`, which runs that check first; the lifecycle above runs it again before anything is migrated, pulled or pushed.
+Every page that reads it (the editor, the admin dashboard and the monitoring page) renders inside `WorkspaceOwnerGate`, which runs that check first; the lifecycle above runs it again when it starts, before anything is migrated or pulled, and from then on every request names the owner (see below).
The settings pages (`/settings/mcp`, `/settings/authenticator`) do not read the copy but render inside the gate too, because a sign-in can land on them, and the gate records the owner there.
While the check runs, those pages show a loading indicator; when the signed-in account cannot be read, they show a message instead of the copy.
They show it too when `GET /api/storage/config` cannot be read and the copy carries an owner or the migration flag, both written only in server mode; a copy with neither renders as it is, so local mode does not change.
@@ -1269,7 +1269,7 @@ The copy is every synced collection, the editor tabs (never stored on the server
- **A copy with no owner that was never migrated:** local-mode data, recorded as the signed-in account's and migrated into that account as described in [Migration Flow](#10-migration-flow).
A different account that signs in before the migration ran finds it owned by the first account, so the copy is cleared for it.
- **Sign-out** (the editor, the admin dashboard and the launch page): the signing-out tab's pending collections are pushed while the session is still valid, then `POST /api/auth/logout` ends the session, and only then is the copy cleared.
- A change still waiting in another open tab is not pushed; it does not reach the server.
+ Only the signing-out tab's pending collections are pushed first: a change waiting in another open tab reaches the server only if that tab pushes it before the session ends, and is refused after.
The cleared copy keeps the migration flag and no owner, so whatever is written to it afterwards is cleared at the next sign-in rather than migrated.
A pending push that does not land does not stop the sign-out: when it failed because the session had already ended, the copy is cleared and the tab goes to sign in; otherwise (server storage unreachable) the session still ends, the copy stays for this account, protected by its owner key, and the sign-out says so; the next sign-in of a different account clears it.
When the server refuses the sign-out, or its answer is lost, the tab asks `GET /api/auth/me`: with no session left the sign-out counts as done and the copy is cleared, and while the session goes on the copy stays, the sync goes on, and the sign-out says it did not complete.
@@ -1279,7 +1279,8 @@ The copy is every synced collection, the editor tabs (never stored on the server
- **Every request names the account:** once a page has matched the copy, every request it sends names that account in the `X-LibreDB-Workspace-Owner` header, and the server refuses one that names an account other than the signed-in one with `409 WORKSPACE_OWNER_MISMATCH` before any route runs ([API_DOCS.md](API_DOCS.md#storage-api)).
The page then reloads, and the owner check runs again for the account signed in now, so a push, a pull or a migration a page sends after another tab signed in as a different account never reaches that account's server data, also in a tab that stays visible.
In the browser the same rule holds: a page writes the copy (the storage facade, its editor tabs, its object-source drafts, and each collection of a pull) only while the owner key still names the account it claimed, so a page that has not reloaded yet writes nothing into the copy another tab claimed.
-- **A session that ended on its own** (expiry, a disabled account) clears nothing; the owner check covers a different account on the first of these pages it opens, and an open tab when it is shown again.
+- **A session that ended on its own** (expiry, a disabled account) clears nothing: the copy stays until the next sign-in's owner check, which covers a different account on the first of these pages it opens.
+ A page inside `WorkspaceOwnerGate` that finds no session when it opens, or when its tab is shown again, shows neither the copy nor a message and goes to sign in.
It does not cover a copy written while `GET /api/storage/config` could not be read on a browser profile that had no owner and no migration flag yet: such a copy is treated as local-mode data, so the next account to sign in records it as its own and migrates it.
- **The first sign-in after upgrading to a release that records the owner:** a browser that was already migrated has no owner yet, so its copy is cleared once, for the same account too, and that sign-in starts from the account's server data.
Open editor tabs from before the upgrade are not kept, since they are never stored on the server, and neither are object-source drafts or any change that had not reached the server.
From d2a6b1d4d0e8a222484c90d600564fced3a88236 Mon Sep 17 00:00:00 2001
From: cevheri
Date: Wed, 7 Oct 2026 14:03:53 +0300
Subject: [PATCH 23/28] test(storage): type the owner-header fetch mock and
name the tab save test without a dash
---
tests/hooks/use-tab-manager.test.ts | 2 +-
tests/unit/config/base-path.test.ts | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/tests/hooks/use-tab-manager.test.ts b/tests/hooks/use-tab-manager.test.ts
index 2a3345eb1..d9d128a9f 100644
--- a/tests/hooks/use-tab-manager.test.ts
+++ b/tests/hooks/use-tab-manager.test.ts
@@ -768,7 +768,7 @@ describe("useTabManager", () => {
// ─── Persistence: Load Effect ───
- test("save — server mode: tabs are not written once another tab hands the copy to a different account", async () => {
+ test("save in server mode: tabs are not written once another tab hands the copy to a different account", async () => {
holdWorkspaceOwner("ana@libredb.org");
localStorage.setItem("libredb_workspace_owner", "ana@libredb.org");
try {
diff --git a/tests/unit/config/base-path.test.ts b/tests/unit/config/base-path.test.ts
index 735980dc4..f6b1f70db 100644
--- a/tests/unit/config/base-path.test.ts
+++ b/tests/unit/config/base-path.test.ts
@@ -246,7 +246,7 @@ describe("appFetch and the account this tab claimed the browser copy for", () =>
}
function answering(response: () => Response) {
- const fetchMock = mock(async () => response());
+ const fetchMock = mock<(input: string, init?: RequestInit) => Promise>(async () => response());
globalThis.fetch = fetchMock as unknown as typeof fetch;
return fetchMock;
}
From bba9f97005c1f04d0920a889cbd4e161d6860331 Mon Sep 17 00:00:00 2001
From: cevheri
Date: Wed, 7 Oct 2026 14:26:11 +0300
Subject: [PATCH 24/28] fix(storage): push the changes a sign-out kept when the
same account signs in again
A sign-out whose push did not land records the collections it could not push in the account's browser copy. The same account's next sign-in on this browser pushes them before it pulls, and the pull keeps a collection still waiting to be pushed. The sync also reloads when the page's owner check matched another account than the one signed in now, instead of claiming the copy again.
---
src/hooks/use-auth.ts | 2 +-
src/hooks/use-storage-sync.ts | 28 +++-
src/lib/storage/local-storage.ts | 21 +++
src/lib/storage/sign-out.ts | 3 +-
tests/hooks/use-auth.test.ts | 2 +-
tests/isolated/use-storage-sync.test.ts | 146 ++++++++++++++++++-
tests/unit/lib/storage/local-storage.test.ts | 39 +++++
7 files changed, 232 insertions(+), 9 deletions(-)
diff --git a/src/hooks/use-auth.ts b/src/hooks/use-auth.ts
index 8938c26da..44b865112 100644
--- a/src/hooks/use-auth.ts
+++ b/src/hooks/use-auth.ts
@@ -10,7 +10,7 @@ import { releaseAccountWorkspace } from "@/lib/storage/sign-out";
/** What a sign-out says when changes that could not be pushed stay in this browser's copy. */
const CHANGES_KEPT =
- "Some changes could not be saved to server storage. They stay in this browser for this account until a different account signs in here.";
+ "Some changes could not be saved to server storage. This browser keeps them and saves them when this account signs in here again; a different account signing in here first discards them.";
interface AuthUser {
role?: string;
diff --git a/src/hooks/use-storage-sync.ts b/src/hooks/use-storage-sync.ts
index f66159aaa..b63d74b2c 100644
--- a/src/hooks/use-storage-sync.ts
+++ b/src/hooks/use-storage-sync.ts
@@ -1,6 +1,6 @@
"use client";
-import { appFetch } from "@/lib/config/base-path";
+import { appFetch, heldWorkspaceOwner } from "@/lib/config/base-path";
import { useState, useEffect, useRef, useCallback } from "react";
import {
storage,
@@ -11,7 +11,9 @@ import {
} from "@/lib/storage";
import {
claimAccountWorkspace,
+ keepUnsavedCollections,
SERVER_MIGRATED_KEY as MIGRATION_FLAG,
+ takeUnsavedCollections,
WORKSPACE_OWNER_KEY,
} from "@/lib/storage/local-storage";
import { registerWorkspaceSync } from "@/lib/storage/sign-out";
@@ -188,7 +190,8 @@ export function useStorageSync(): StorageSyncState {
* `owner` is the account the copy was claimed for. Each collection of the answer is written
* only while the copy still belongs to it, checked again before every write: a sign-out, or
* another tab claiming the copy for a different account, while the pull was out or between two
- * of its writes, keeps the copy as that left it.
+ * of its writes, keeps the copy as that left it. A collection still waiting to be pushed keeps
+ * its local value.
*/
const pullFromServer = useCallback(async (owner: string) => {
setIsSyncing(true);
@@ -200,7 +203,7 @@ export function useStorageSync(): StorageSyncState {
// Write server data to localStorage (overwrite)
for (const collection of STORAGE_COLLECTIONS) {
const value = data[collection];
- if (!isPulled(collection, value)) continue;
+ if (!isPulled(collection, value) || pendingCollectionsRef.current.has(collection)) continue;
if (localStorage.getItem(WORKSPACE_OWNER_KEY) !== owner) return;
writeCollectionToLocal(collection, value);
}
@@ -303,14 +306,25 @@ export function useStorageSync(): StorageSyncState {
return;
}
if (cancelled) return;
+ // The page's owner check claimed the copy for another account than the one signed in
+ // now: the page reloads, so the check runs again for this one.
+ const held = heldWorkspaceOwner();
+ if (held !== null && held !== username) {
+ ready = false;
+ window.location.reload();
+ return;
+ }
setIsServerMode(true);
serverModeRef.current = true;
// Records `username` as the owner of the copy it keeps or clears.
claimAccountWorkspace(username);
- // Migration first, then pull
+ // Migration first, then the changes a sign-out of this account could not push, then pull
await migrateToServer();
+ const unsaved = takeUnsavedCollections();
+ for (const collection of unsaved) pendingCollectionsRef.current.add(collection);
+ if (unsaved.length > 0) await pushPending();
if (!cancelled) {
await pullFromServer(username);
}
@@ -329,7 +343,7 @@ export function useStorageSync(): StorageSyncState {
return () => {
cancelled = true;
};
- }, [migrateToServer, pullFromServer]);
+ }, [migrateToServer, pullFromServer, pushPending]);
// ── Sign-out: push what is pending while the session cookie is still valid ──
useEffect(() => {
@@ -349,11 +363,15 @@ export function useStorageSync(): StorageSyncState {
await pushPending();
clearTimers();
if (pendingCollectionsRef.current.size > 0) {
+ // Kept in the copy for this account's next sign-in here, which pushes them first.
+ keepUnsavedCollections(Array.from(pendingCollectionsRef.current));
throw new Error("Unsaved changes could not be saved to server storage");
}
},
resume: () => {
signingOutRef.current = false;
+ // Held here again, so nothing is left for a later sign-in to push.
+ takeUnsavedCollections();
const held = Array.from(pendingCollectionsRef.current);
for (const collection of held) schedulePush(collection);
},
diff --git a/src/lib/storage/local-storage.ts b/src/lib/storage/local-storage.ts
index e2ffeb53a..c1a217269 100644
--- a/src/lib/storage/local-storage.ts
+++ b/src/lib/storage/local-storage.ts
@@ -139,6 +139,26 @@ export const SOURCE_DRAFTS_KEY = `${KEY_PREFIX}source_drafts_v1`;
/** The agent conversation this browser was last in (`components/agent/use-agent-run.ts`). */
export const AGENT_THREAD_KEY = `${KEY_PREFIX}agent_thread`;
+/**
+ * The collections a sign-out could not push (`useStorageSync`). Their changes stay in the copy,
+ * and the same account's next sign-in on this browser pushes them before it pulls.
+ */
+export const UNSAVED_COLLECTIONS_KEY = `${KEY_PREFIX}unsaved_collections`;
+
+/** Record the collections a sign-out could not push, while the copy is still this tab's. */
+export function keepUnsavedCollections(collections: readonly string[]): void {
+ if (!isClient() || !holdsAccountWorkspace()) return;
+ localStorage.setItem(UNSAVED_COLLECTIONS_KEY, JSON.stringify(collections));
+}
+
+/** The collections a sign-out could not push, removed from the copy: the caller pushes them now. */
+export function takeUnsavedCollections(): string[] {
+ if (!isClient() || !holdsAccountWorkspace()) return [];
+ const raw = localStorage.getItem(UNSAVED_COLLECTIONS_KEY);
+ localStorage.removeItem(UNSAVED_COLLECTIONS_KEY);
+ return raw === null ? [] : (JSON.parse(raw) as string[]);
+}
+
/**
* Whether this tab may write the browser copy. Before a claim, and so always in local mode, it
* may. In server storage mode it may only while the copy still belongs to the account this tab
@@ -160,6 +180,7 @@ const ACCOUNT_KEYS: readonly string[] = [
...STORAGE_COLLECTIONS.map(getKey),
SOURCE_DRAFTS_KEY,
AGENT_THREAD_KEY,
+ UNSAVED_COLLECTIONS_KEY,
SERVER_MIGRATED_KEY,
WORKSPACE_OWNER_KEY,
];
diff --git a/src/lib/storage/sign-out.ts b/src/lib/storage/sign-out.ts
index 3fe392043..bba5d3a1a 100644
--- a/src/lib/storage/sign-out.ts
+++ b/src/lib/storage/sign-out.ts
@@ -5,7 +5,8 @@
* pushes whatever is still waiting to be pushed while the session cookie is still valid, ends
* the session, and only then clears the copy. A push that did not land does not stop the sign-out:
* when the session had already ended the copy is cleared, and otherwise the session ends and the
- * copy is kept for this account, whose owner key the next sign-in checks. A sign-out the server
+ * copy is kept for this account with the collections that were not pushed, which the same account's
+ * next sign-in here pushes before it pulls (`useStorageSync`). A sign-out the server
* refused while the session goes on leaves the copy in place. In local mode the browser copy is
* the only one and stays.
*
diff --git a/tests/hooks/use-auth.test.ts b/tests/hooks/use-auth.test.ts
index a8c82fb99..f71055bfb 100644
--- a/tests/hooks/use-auth.test.ts
+++ b/tests/hooks/use-auth.test.ts
@@ -498,7 +498,7 @@ describe("useAuth", () => {
expect(localStorage.getItem("libredb_workspace_owner")).toBe("user@libredb.org");
expect(mockToastSuccess).toHaveBeenCalledWith("Logged out", {
description:
- "Some changes could not be saved to server storage. They stay in this browser for this account until a different account signs in here.",
+ "Some changes could not be saved to server storage. This browser keeps them and saves them when this account signs in here again; a different account signing in here first discards them.",
});
expect(mockRouterPush).toHaveBeenCalledWith("/login");
});
diff --git a/tests/isolated/use-storage-sync.test.ts b/tests/isolated/use-storage-sync.test.ts
index caf3e84d5..e03701212 100644
--- a/tests/isolated/use-storage-sync.test.ts
+++ b/tests/isolated/use-storage-sync.test.ts
@@ -50,7 +50,7 @@ mock.module("@/lib/storage", () => ({
import { useStorageSync } from "@/hooks/use-storage-sync";
import { releaseAccountWorkspace } from "@/lib/storage/sign-out";
import { workspaceTabsKey } from "@/lib/storage/local-storage";
-import { holdWorkspaceOwner } from "@/lib/config/base-path";
+import { heldWorkspaceOwner, holdWorkspaceOwner } from "@/lib/config/base-path";
// ── Helpers ─────────────────────────────────────────────────────────────────
@@ -1118,6 +1118,31 @@ describe("useStorageSync", () => {
expect(result.current.isReady).toBe(false);
});
+ test("a page whose owner check matched another account reloads instead of claiming the copy again", async () => {
+ const original = window.location.reload;
+ const reload = mock(() => {});
+ Object.defineProperty(window.location, "reload", { value: reload, configurable: true });
+ try {
+ seedPreviousCopy(PREVIOUS, true);
+ // The page's owner check claimed the copy for PREVIOUS; another tab has since signed in.
+ holdWorkspaceOwner(PREVIOUS);
+ const fetchMock = setupServerMode();
+
+ const { result } = renderHook(() => useStorageSync());
+ await waitFor(() => {
+ expect(reload).toHaveBeenCalledTimes(1);
+ });
+
+ expect(result.current.isReady).toBe(false);
+ expect(localStorage.getItem("libredb_workspace_owner")).toBe(PREVIOUS);
+ expect(heldWorkspaceOwner()).toBe(PREVIOUS);
+ expect(calledPaths(fetchMock)).not.toContain("/api/storage/migrate");
+ expect(calledPaths(fetchMock).filter((path) => path === "/api/storage")).toHaveLength(0);
+ } finally {
+ Object.defineProperty(window.location, "reload", { value: original, configurable: true });
+ }
+ });
+
test("local mode clears nothing and records no owner", async () => {
seedPreviousCopy(PREVIOUS, true);
setupLocalMode();
@@ -1257,6 +1282,123 @@ describe("useStorageSync", () => {
expect(localStorage.getItem("libredb_workspace_owner")).toBe(USERNAME);
});
+ /** Signs out of a mounted sync whose pending `history` push does not land; the copy is kept. */
+ async function signOutKeepingHistory() {
+ localStorage.setItem("libredb_workspace_owner", USERNAME);
+ localStorage.setItem("libredb_server_migrated", "2026-10-01");
+ localStorage.setItem("libredb_history", JSON.stringify([{ id: "kept", query: "SELECT 'kept'" }]));
+ setupSignOut({
+ "/api/storage/history": { ok: false, status: 503, json: { error: "Storage unreachable" } },
+ "/api/storage": { ok: true, status: 200, json: {} },
+ });
+ const first = renderHook(() => useStorageSync());
+ await waitFor(() => {
+ expect(first.result.current.isReady).toBe(true);
+ });
+ act(() => {
+ window.dispatchEvent(new CustomEvent("libredb-storage-change", { detail: { collection: "history" } }));
+ });
+ let outcome: Awaited> | null = null;
+ await act(async () => {
+ outcome = await releaseAccountWorkspace(signOutAnswering(200));
+ });
+ expect(outcome).toMatchObject({ signedOut: true, changesKept: true });
+ first.unmount();
+ restoreGlobalFetch();
+ // The next sign-in loads a new page, which holds no claim yet.
+ holdWorkspaceOwner(null);
+ }
+
+ test("changes a sign-out could not push are pushed at the same account's next sign-in, before the pull", async () => {
+ mockStorage.getHistory.mockImplementation(() => JSON.parse(localStorage.getItem("libredb_history") ?? "[]"));
+ try {
+ await signOutKeepingHistory();
+
+ const order: string[] = [];
+ const fetchMock = setupSignOut({
+ "/api/storage/history": async (req: Request) => {
+ order.push(`push:${await req.text()}`);
+ return { ok: true, status: 200, json: { ok: true } };
+ },
+ "/api/storage": () => {
+ order.push("pull");
+ return { ok: true, status: 200, json: { history: [{ id: "server", query: "SELECT 'server'" }] } };
+ },
+ });
+ const second = renderHook(() => useStorageSync());
+ await waitFor(() => {
+ expect(second.result.current.isReady).toBe(true);
+ });
+
+ expect(order).toHaveLength(2);
+ expect(order[0]).toContain("kept");
+ expect(order[1]).toBe("pull");
+ expect(calledPaths(fetchMock)).not.toContain("/api/storage/migrate");
+ // Pushed once: nothing is left to push at a later sign-in.
+ expect(localStorage.getItem("libredb_unsaved_collections")).toBeNull();
+ } finally {
+ mockStorage.getHistory.mockImplementation(() => []);
+ }
+ });
+
+ test("kept changes that still cannot be pushed at the next sign-in are not replaced by the pull", async () => {
+ await signOutKeepingHistory();
+
+ setupSignOut({
+ "/api/storage/history": { ok: false, status: 503, json: { error: "Storage unreachable" } },
+ "/api/storage": { ok: true, status: 200, json: { history: [{ id: "server", query: "SELECT 'server'" }] } },
+ });
+ const second = renderHook(() => useStorageSync());
+ await waitFor(() => {
+ expect(second.result.current.isReady).toBe(true);
+ });
+
+ expect(localStorage.getItem("libredb_history")).toContain("kept");
+ });
+
+ test("a different account signing in drops the kept changes with the rest of the copy", async () => {
+ await signOutKeepingHistory();
+
+ const fetchMock = mockGlobalFetch({
+ "/api/storage/config": { ok: true, status: 200, json: { provider: "postgres", serverMode: true } },
+ "/api/auth/me": { ok: true, status: 200, json: { user: { username: "other@libredb.org" } } },
+ "/api/storage": { ok: true, status: 200, json: {} },
+ });
+ const second = renderHook(() => useStorageSync());
+ await waitFor(() => {
+ expect(second.result.current.isReady).toBe(true);
+ });
+
+ expect(calledPaths(fetchMock)).not.toContain("/api/storage/history");
+ expect(localStorage.getItem("libredb_history")).toBeNull();
+ expect(localStorage.getItem("libredb_unsaved_collections")).toBeNull();
+ });
+
+ test("a sign-out the server refused after a push that did not land leaves nothing for a later sign-in to push", async () => {
+ localStorage.setItem("libredb_workspace_owner", USERNAME);
+ localStorage.setItem("libredb_server_migrated", "2026-10-01");
+ setupSignOut({
+ "/api/storage/history": { ok: false, status: 503, json: { error: "Storage unreachable" } },
+ "/api/storage": { ok: true, status: 200, json: {} },
+ });
+ const { result } = renderHook(() => useStorageSync());
+ await waitFor(() => {
+ expect(result.current.isReady).toBe(true);
+ });
+
+ act(() => {
+ window.dispatchEvent(new CustomEvent("libredb-storage-change", { detail: { collection: "history" } }));
+ });
+ let outcome: Awaited> | null = null;
+ await act(async () => {
+ outcome = await releaseAccountWorkspace(signOutAnswering(500));
+ });
+
+ // The session goes on, and this page pushes the change itself.
+ expect(outcome).toMatchObject({ signedOut: false });
+ expect(localStorage.getItem("libredb_unsaved_collections")).toBeNull();
+ });
+
test("a pending push refused because the session had ended clears the copy", async () => {
localStorage.setItem("libredb_workspace_owner", USERNAME);
localStorage.setItem("libredb_server_migrated", "2026-10-01");
@@ -1469,6 +1611,8 @@ describe("useStorageSync", () => {
// A query that finishes after the sign-out, in this tab or one still open, writes the copy.
localStorage.setItem("libredb_history", JSON.stringify([{ id: "h1", query: "SELECT * FROM prior_account" }]));
first.unmount();
+ // The next sign-in loads a new page, which holds no claim yet.
+ holdWorkspaceOwner(null);
const bodies: string[] = [];
const fetchMock = mockGlobalFetch({
diff --git a/tests/unit/lib/storage/local-storage.test.ts b/tests/unit/lib/storage/local-storage.test.ts
index 292f91dfc..d6a501bf7 100644
--- a/tests/unit/lib/storage/local-storage.test.ts
+++ b/tests/unit/lib/storage/local-storage.test.ts
@@ -22,6 +22,9 @@ import {
SERVER_MIGRATED_KEY,
SOURCE_DRAFTS_KEY,
AGENT_THREAD_KEY,
+ UNSAVED_COLLECTIONS_KEY,
+ keepUnsavedCollections,
+ takeUnsavedCollections,
} from "@/lib/storage/local-storage";
import { STORAGE_COLLECTIONS } from "@/lib/storage/types";
import { heldWorkspaceOwner, holdWorkspaceOwner } from "@/lib/config/base-path";
@@ -189,6 +192,40 @@ describe("local-storage: writes while another tab changed whose copy this is", (
});
});
+describe("local-storage: the collections a sign-out could not push", () => {
+ beforeEach(() => {
+ localStorage.clear();
+ });
+
+ afterEach(() => {
+ holdWorkspaceOwner(null);
+ });
+
+ test("are kept in the copy and handed back once", () => {
+ holdWorkspaceOwner("ana@libredb.org");
+ localStorage.setItem(WORKSPACE_OWNER_KEY, "ana@libredb.org");
+
+ expect(takeUnsavedCollections()).toEqual([]);
+ keepUnsavedCollections(["history", "connections"]);
+
+ expect(takeUnsavedCollections()).toEqual(["history", "connections"]);
+ expect(localStorage.getItem(UNSAVED_COLLECTIONS_KEY)).toBeNull();
+ expect(takeUnsavedCollections()).toEqual([]);
+ });
+
+ test("are neither kept nor taken once the copy belongs to a different account", () => {
+ holdWorkspaceOwner("ana@libredb.org");
+ localStorage.setItem(WORKSPACE_OWNER_KEY, "bob@libredb.org");
+
+ keepUnsavedCollections(["history"]);
+ expect(localStorage.getItem(UNSAVED_COLLECTIONS_KEY)).toBeNull();
+
+ localStorage.setItem(UNSAVED_COLLECTIONS_KEY, '["connections"]');
+ expect(takeUnsavedCollections()).toEqual([]);
+ expect(localStorage.getItem(UNSAVED_COLLECTIONS_KEY)).toBe('["connections"]');
+ });
+});
+
describe("local-storage: clearAccountWorkspace", () => {
beforeEach(() => {
localStorage.clear();
@@ -199,6 +236,7 @@ describe("local-storage: clearAccountWorkspace", () => {
expect(SERVER_MIGRATED_KEY).toBe("libredb_server_migrated");
expect(SOURCE_DRAFTS_KEY).toBe("libredb_source_drafts_v1");
expect(AGENT_THREAD_KEY).toBe("libredb_agent_thread");
+ expect(UNSAVED_COLLECTIONS_KEY).toBe("libredb_unsaved_collections");
});
test("removes every key the signed-in account's browser copy is held under", () => {
@@ -207,6 +245,7 @@ describe("local-storage: clearAccountWorkspace", () => {
localStorage.setItem(workspaceTabsKey("seed:sample"), "[]");
localStorage.setItem(SOURCE_DRAFTS_KEY, "{}");
localStorage.setItem(AGENT_THREAD_KEY, "{}");
+ localStorage.setItem(UNSAVED_COLLECTIONS_KEY, '["history"]');
localStorage.setItem(SERVER_MIGRATED_KEY, "2026-10-07");
localStorage.setItem(WORKSPACE_OWNER_KEY, "admin@libredb.org");
From cdef8a3bc3594c4172faa7a6c9c1b1255b16d421 Mon Sep 17 00:00:00 2001
From: cevheri
Date: Wed, 7 Oct 2026 14:26:12 +0300
Subject: [PATCH 25/28] fix(storage): write the agent thread hint only while
the copy is the claimed account's
---
src/components/agent/use-agent-run.ts | 8 ++++--
tests/components/agent/AgentRail.test.tsx | 35 +++++++++++++++++++++++
2 files changed, 41 insertions(+), 2 deletions(-)
diff --git a/src/components/agent/use-agent-run.ts b/src/components/agent/use-agent-run.ts
index 3f016cf08..5007f7cb2 100644
--- a/src/components/agent/use-agent-run.ts
+++ b/src/components/agent/use-agent-run.ts
@@ -3,7 +3,7 @@
import { appFetch } from "@/lib/config/base-path";
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
import { isAgentModelCapability } from "@/lib/agent/capability-labels";
-import { AGENT_THREAD_KEY } from "@/lib/storage/local-storage";
+import { AGENT_THREAD_KEY, holdsAccountWorkspace } from "@/lib/storage/local-storage";
// Type-only, so nothing of the probe — or of the AI SDK it runs — reaches this bundle.
import type { AgentModelCapability } from "@/lib/agent/capability-probe";
import type { AgentLedgerEntry } from "@/lib/agent/run-store";
@@ -290,9 +290,13 @@ function parseStoredThread(raw: string | null): AgentInterruptedThread | null {
return { threadId: candidate.threadId, steps: candidate.steps };
}
-/** Remember the conversation this run belongs to, or forget the one that no longer applies. */
+/**
+ * Remember the conversation this run belongs to, or forget the one that no longer applies. Only
+ * while the browser copy is still the account this tab claimed it for (`holdsAccountWorkspace`).
+ */
function rememberThread(thread: AgentThreadContext | null): void {
try {
+ if (!holdsAccountWorkspace()) return;
// Removed rather than left standing when a run belongs to no conversation: a stale
// entry would tell the next mount a conversation was interrupted that had already ended.
if (thread === null) localStorage.removeItem(THREAD_STORAGE_KEY);
diff --git a/tests/components/agent/AgentRail.test.tsx b/tests/components/agent/AgentRail.test.tsx
index bf0d3e14a..b7d0a0f10 100644
--- a/tests/components/agent/AgentRail.test.tsx
+++ b/tests/components/agent/AgentRail.test.tsx
@@ -21,6 +21,8 @@ import type { AgentRunWorkflowType, AgentThreadContext } from "@/lib/agent/types
import { getDBConfig } from "@/lib/db-ui-config";
import type { DatabaseConnection } from "@/lib/types";
import { mockGlobalFetch, restoreGlobalFetch } from "../../helpers/mock-fetch";
+import { holdWorkspaceOwner } from "@/lib/config/base-path";
+import { WORKSPACE_OWNER_KEY } from "@/lib/storage/local-storage";
/**
* The standalone agent rail (#329 T10a): the gated surface, its two modes and the
@@ -686,6 +688,39 @@ describe("AgentRail", () => {
expect(localStorage.getItem("libredb_agent_thread")).toBeNull();
});
+ test("a start neither records nor forgets a conversation once the copy is a different account's", async () => {
+ // Server storage mode: this tab claimed the copy for ana, and another tab has since
+ // recorded bob as its owner. The copy is bob's, so this tab writes nothing more to it.
+ const stored = JSON.stringify({ threadId: "arun_bob", steps: 2 });
+ holdWorkspaceOwner("ana@libredb.org");
+ localStorage.setItem(WORKSPACE_OWNER_KEY, "bob@libredb.org");
+ localStorage.setItem("libredb_agent_thread", stored);
+ try {
+ for (const thread of [
+ { threadId: "arun_a", steps: [{ runId: "arun_a", objective: "count by department" }], text: "Step 1" },
+ null,
+ ]) {
+ mockAgentFetch([OPENED_LINE, STARTED_LINE, FINISHED_LINE], {
+ runId: "arun_b",
+ status: "queued",
+ mode: "planning",
+ thread,
+ });
+ const view = render();
+ fireEvent.change(view.getByTestId("agent-objective"), { target: { value: "chart those" } });
+ await act(async () => {
+ fireEvent.click(view.getByTestId("agent-start"));
+ });
+ await view.findByTestId("agent-run-id");
+ cleanup();
+
+ expect(localStorage.getItem("libredb_agent_thread")).toBe(stored);
+ }
+ } finally {
+ holdWorkspaceOwner(null);
+ }
+ });
+
test("a store that refuses the write costs the notice, never the run", async () => {
// Safari private mode and a full quota both land here. The bookkeeping is a nudge; the
// run is the work, and the same policy is written into `star-prompt.ts`.
From aa1cda26a0dc3e773fe1b330eeed052b56d26df8 Mon Sep 17 00:00:00 2001
From: cevheri
Date: Wed, 7 Oct 2026 14:26:12 +0300
Subject: [PATCH 26/28] docs(storage): scope the owner header to the routes the
proxy checks and describe the kept changes
---
docs/SECURITY.md | 2 +-
docs/STORAGE.md | 9 ++++++---
src/components/auth/WorkspaceOwnerGate.tsx | 14 +++++++-------
3 files changed, 14 insertions(+), 11 deletions(-)
diff --git a/docs/SECURITY.md b/docs/SECURITY.md
index 2c08bda8d..5cd367d55 100644
--- a/docs/SECURITY.md
+++ b/docs/SECURITY.md
@@ -583,7 +583,7 @@ These are real, current, and not oversights. Each is a decision with a reason.
encrypting it would require a master password and a recovery flow, changing what the product is.
This is why 0.1 and 1.1 matter as much as they do.
In server storage mode the browser copy belongs to the signed-in account: it is cleared on sign-out, and a different account signing in starts from its own server data.
- Every request a tab sends after matching its copy names that account, and the server refuses one that names an account other than the signed-in one, so a tab still open for one account never writes into another account's server data.
+ Every request a tab sends after matching its copy names that account, and outside the public routes (`/api/auth/*`, `/api/db/health` and the rest of the public set in `src/proxy.ts`) the server refuses one that names an account other than the signed-in one, so a tab still open for one account never writes its workspace into another account's server storage.
A sign-out that could not complete, or a session that ended without one (expiry, a disabled account), leaves the copy in that browser until the next sign-in's owner check, which clears it for a different account.
In local mode it stays, and the next account to sign in on that browser profile sees and can open the connections saved there.
- **Anyone who can read the server's environment can read the stored credentials.** 3.1 protects a
diff --git a/docs/STORAGE.md b/docs/STORAGE.md
index 1f49ff248..3e4ce9abf 100644
--- a/docs/STORAGE.md
+++ b/docs/STORAGE.md
@@ -1261,21 +1261,24 @@ Every page that reads it (the editor, the admin dashboard and the monitoring pag
The settings pages (`/settings/mcp`, `/settings/authenticator`) do not read the copy but render inside the gate too, because a sign-in can land on them, and the gate records the owner there.
While the check runs, those pages show a loading indicator; when the signed-in account cannot be read, they show a message instead of the copy.
They show it too when `GET /api/storage/config` cannot be read and the copy carries an owner or the migration flag, both written only in server mode; a copy with neither renders as it is, so local mode does not change.
-The copy is every synced collection, the editor tabs (never stored on the server), the object-source drafts, the agent thread hint, the migration flag and the owner key itself; per-browser preferences such as the theme and line numbers are not part of it.
+The copy is every synced collection, the editor tabs (never stored on the server), the object-source drafts, the agent thread hint, the names of the collections a sign-out could not push, the migration flag and the owner key itself; per-browser preferences such as the theme and line numbers are not part of it.
`clearAccountWorkspace()` in `src/lib/storage/local-storage.ts` holds that list.
-- **Same account:** the copy is kept, and the pull overwrites each collection the server holds; a collection the server does not hold yet stays local.
+- **Same account:** the copy is kept, the collections a sign-out could not push are pushed, and the pull overwrites each collection the server holds, except one still waiting to be pushed; a collection the server does not hold yet stays local.
- **A different account, or a copy with no owner that was already migrated:** the copy is cleared first and recorded as this account's, so the account starts from its own server data.
- **A copy with no owner that was never migrated:** local-mode data, recorded as the signed-in account's and migrated into that account as described in [Migration Flow](#10-migration-flow).
A different account that signs in before the migration ran finds it owned by the first account, so the copy is cleared for it.
- **Sign-out** (the editor, the admin dashboard and the launch page): the signing-out tab's pending collections are pushed while the session is still valid, then `POST /api/auth/logout` ends the session, and only then is the copy cleared.
Only the signing-out tab's pending collections are pushed first: a change waiting in another open tab reaches the server only if that tab pushes it before the session ends, and is refused after.
The cleared copy keeps the migration flag and no owner, so whatever is written to it afterwards is cleared at the next sign-in rather than migrated.
- A pending push that does not land does not stop the sign-out: when it failed because the session had already ended, the copy is cleared and the tab goes to sign in; otherwise (server storage unreachable) the session still ends, the copy stays for this account, protected by its owner key, and the sign-out says so; the next sign-in of a different account clears it.
+ A pending push that does not land does not stop the sign-out: when it failed because the session had already ended, the copy is cleared and the tab goes to sign in; otherwise (server storage unreachable) the session still ends, the copy stays for this account with the names of the collections that were not pushed, and the sign-out says so.
+ The next sign-in of the same account on this browser pushes those collections before it pulls, and a pull does not replace a collection still waiting to be pushed; the next sign-in of a different account clears the copy, those changes with it.
When the server refuses the sign-out, or its answer is lost, the tab asks `GET /api/auth/me`: with no session left the sign-out counts as done and the copy is cleared, and while the session goes on the copy stays, the sync goes on, and the sign-out says it did not complete.
When the storage mode cannot be read, a copy that carries the owner key or the migration flag fails the sign-out and stays; a copy with neither is local-mode data, and the session just ends.
+ A sign-out from a tab whose session another tab has meanwhile replaced with a different account's gets `409 WORKSPACE_OWNER_MISMATCH` on its push and reloads; a logout request it sends before the reload lands ends the session the browser holds, which is that other account's.
- **Other open tabs:** every tab of the browser profile shares the copy, so when another tab records a different owner or clears the copy (a sign-out, or a different account signing in and landing on the editor, an admin page, the monitoring page or a settings page), each page inside `WorkspaceOwnerGate` stops rendering and reloads, and the owner check runs again for the account signed in now.
A sign-in can also land where no owner is recorded (an unknown address), so a page inside `WorkspaceOwnerGate` also asks which account is signed in whenever its tab is shown again, and reloads the same way when that is no longer the account it rendered for.
+ The editor's sync asks which account is signed in once more when it starts, and reloads the same way when that is no longer the account the page matched the copy for.
- **Every request names the account:** once a page has matched the copy, every request it sends names that account in the `X-LibreDB-Workspace-Owner` header, and the server refuses one that names an account other than the signed-in one with `409 WORKSPACE_OWNER_MISMATCH` before any route runs ([API_DOCS.md](API_DOCS.md#storage-api)).
The page then reloads, and the owner check runs again for the account signed in now, so a push, a pull or a migration a page sends after another tab signed in as a different account never reaches that account's server data, also in a tab that stays visible.
In the browser the same rule holds: a page writes the copy (the storage facade, its editor tabs, its object-source drafts, and each collection of a pull) only while the owner key still names the account it claimed, so a page that has not reloaded yet writes nothing into the copy another tab claimed.
diff --git a/src/components/auth/WorkspaceOwnerGate.tsx b/src/components/auth/WorkspaceOwnerGate.tsx
index ab3ae13ac..6404e9a3c 100644
--- a/src/components/auth/WorkspaceOwnerGate.tsx
+++ b/src/components/auth/WorkspaceOwnerGate.tsx
@@ -18,13 +18,13 @@ import {
* a different account starts from that account's server data on every such page, not only the
* editor. The copy is shared by every tab of the browser profile: when another tab records a
* different owner or clears it (a sign-out, a different account signing in, or a claim for this
- * same account that cleared it first), this page stops rendering and reloads, so the check runs again for the account signed in now. A sign-in can
- * also land where nothing records an owner (an unknown address), so whenever the tab is shown
- * again it asks which account is signed in, and reloads the same way when that is no longer the
- * account it rendered for. A session that has ended, at the first check or when the tab is shown
- * again, shows no page and no message: the tab goes to sign in, as every page does when the server
- * reports an ended session. In local mode the page renders after the storage mode is known, with
- * the copy as it is.
+ * same account that cleared it first), this page stops rendering and reloads, so the check runs
+ * again for the account signed in now. A sign-in can also land where nothing records an owner (an
+ * unknown address), so whenever the tab is shown again it asks which account is signed in, and
+ * reloads the same way when that is no longer the account it rendered for. A session that has
+ * ended, at the first check or when the tab is shown again, shows no page and no message: the tab
+ * goes to sign in, as every page does when the server reports an ended session. In local mode the
+ * page renders after the storage mode is known, with the copy as it is.
*/
export function WorkspaceOwnerGate({ children }: Readonly<{ children: ReactNode }>) {
const [state, setState] = useState<"checking" | "ready" | "failed">("checking");
From 15dc6672a29fb142a6f57c84c9dc3203a1ca5526 Mon Sep 17 00:00:00 2001
From: cevheri
Date: Wed, 7 Oct 2026 14:41:24 +0300
Subject: [PATCH 27/28] fix(storage): keep changes a sign-out kept when a page
without the sync signs out
A sign-out from the admin dashboard, which mounts no sync, now keeps the
copy while it holds changes an earlier sign-out could not push, and the
record of those collections is read as a list of known names only.
STORAGE.md names the routes the owner header is checked on.
---
docs/STORAGE.md | 2 +-
src/lib/storage/local-storage.ts | 25 ++++++++++++++++++--
src/lib/storage/sign-out.ts | 6 +++--
tests/unit/lib/storage/local-storage.test.ts | 14 +++++++++++
tests/unit/lib/storage/sign-out.test.ts | 16 ++++++++++++-
5 files changed, 57 insertions(+), 6 deletions(-)
diff --git a/docs/STORAGE.md b/docs/STORAGE.md
index 3e4ce9abf..158eab641 100644
--- a/docs/STORAGE.md
+++ b/docs/STORAGE.md
@@ -1279,7 +1279,7 @@ The copy is every synced collection, the editor tabs (never stored on the server
- **Other open tabs:** every tab of the browser profile shares the copy, so when another tab records a different owner or clears the copy (a sign-out, or a different account signing in and landing on the editor, an admin page, the monitoring page or a settings page), each page inside `WorkspaceOwnerGate` stops rendering and reloads, and the owner check runs again for the account signed in now.
A sign-in can also land where no owner is recorded (an unknown address), so a page inside `WorkspaceOwnerGate` also asks which account is signed in whenever its tab is shown again, and reloads the same way when that is no longer the account it rendered for.
The editor's sync asks which account is signed in once more when it starts, and reloads the same way when that is no longer the account the page matched the copy for.
-- **Every request names the account:** once a page has matched the copy, every request it sends names that account in the `X-LibreDB-Workspace-Owner` header, and the server refuses one that names an account other than the signed-in one with `409 WORKSPACE_OWNER_MISMATCH` before any route runs ([API_DOCS.md](API_DOCS.md#storage-api)).
+- **Every request names the account:** once a page has matched the copy, every request it sends names that account in the `X-LibreDB-Workspace-Owner` header, and the server refuses one that names an account other than the signed-in one with `409 WORKSPACE_OWNER_MISMATCH` before the route runs, on every route that needs a session; the public routes (`src/proxy.ts`) are not checked ([API_DOCS.md](API_DOCS.md#storage-api)).
The page then reloads, and the owner check runs again for the account signed in now, so a push, a pull or a migration a page sends after another tab signed in as a different account never reaches that account's server data, also in a tab that stays visible.
In the browser the same rule holds: a page writes the copy (the storage facade, its editor tabs, its object-source drafts, and each collection of a pull) only while the owner key still names the account it claimed, so a page that has not reloaded yet writes nothing into the copy another tab claimed.
- **A session that ended on its own** (expiry, a disabled account) clears nothing: the copy stays until the next sign-in's owner check, which covers a different account on the first of these pages it opens.
diff --git a/src/lib/storage/local-storage.ts b/src/lib/storage/local-storage.ts
index c1a217269..1f39b3144 100644
--- a/src/lib/storage/local-storage.ts
+++ b/src/lib/storage/local-storage.ts
@@ -151,12 +151,33 @@ export function keepUnsavedCollections(collections: readonly string[]): void {
localStorage.setItem(UNSAVED_COLLECTIONS_KEY, JSON.stringify(collections));
}
-/** The collections a sign-out could not push, removed from the copy: the caller pushes them now. */
+/**
+ * The collections a sign-out could not push, removed from the copy: the caller pushes them now.
+ * Only names of collections the sync knows come back; a record that is not a list of names (a copy
+ * edited by hand) is dropped with a warning rather than failing the sync that reads it.
+ */
export function takeUnsavedCollections(): string[] {
if (!isClient() || !holdsAccountWorkspace()) return [];
const raw = localStorage.getItem(UNSAVED_COLLECTIONS_KEY);
localStorage.removeItem(UNSAVED_COLLECTIONS_KEY);
- return raw === null ? [] : (JSON.parse(raw) as string[]);
+ if (raw === null) return [];
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(raw);
+ } catch {
+ parsed = null;
+ }
+ if (!Array.isArray(parsed)) {
+ logger.warn("Dropped a record of unsaved collections that is not a list", { key: UNSAVED_COLLECTIONS_KEY });
+ return [];
+ }
+ const known: readonly string[] = STORAGE_COLLECTIONS;
+ return parsed.filter((name): name is string => typeof name === "string" && known.includes(name));
+}
+
+/** Whether the copy holds changes an earlier sign-out could not push. */
+export function hasUnsavedCollections(): boolean {
+ return isClient() && localStorage.getItem(UNSAVED_COLLECTIONS_KEY) !== null;
}
/**
diff --git a/src/lib/storage/sign-out.ts b/src/lib/storage/sign-out.ts
index bba5d3a1a..c8269dd92 100644
--- a/src/lib/storage/sign-out.ts
+++ b/src/lib/storage/sign-out.ts
@@ -15,7 +15,7 @@
* the admin dashboard and the launch page have nothing pending and only clear.
*/
-import { resetAccountWorkspace } from "./local-storage";
+import { hasUnsavedCollections, resetAccountWorkspace } from "./local-storage";
import { readServerMode, readSignedInUsername, SessionEndedError } from "./workspace-owner";
/** The mounted sync, as a sign-out sees it. */
@@ -85,7 +85,9 @@ export async function releaseAccountWorkspace(signOut: () => Promise):
if (!(await readServerMode())) return endSession(signOut, null, false);
const sync = mountedSync;
- let changesKept = false;
+ // A page with no sync (the admin dashboard) cannot push what an earlier sign-out kept, so it
+ // keeps the copy for this account rather than clear those changes.
+ let changesKept = !sync && hasUnsavedCollections();
if (sync) {
try {
await sync.flush();
diff --git a/tests/unit/lib/storage/local-storage.test.ts b/tests/unit/lib/storage/local-storage.test.ts
index d6a501bf7..9a8d5d2e9 100644
--- a/tests/unit/lib/storage/local-storage.test.ts
+++ b/tests/unit/lib/storage/local-storage.test.ts
@@ -213,6 +213,20 @@ describe("local-storage: the collections a sign-out could not push", () => {
expect(takeUnsavedCollections()).toEqual([]);
});
+ test("hand back only the collection names they know, and nothing from a record that is not a list", () => {
+ holdWorkspaceOwner("ana@libredb.org");
+ localStorage.setItem(WORKSPACE_OWNER_KEY, "ana@libredb.org");
+
+ localStorage.setItem(UNSAVED_COLLECTIONS_KEY, JSON.stringify(["history", "not_a_collection", 7]));
+ expect(takeUnsavedCollections()).toEqual(["history"]);
+
+ for (const corrupt of ["{not json", JSON.stringify({ history: true }), JSON.stringify("history")]) {
+ localStorage.setItem(UNSAVED_COLLECTIONS_KEY, corrupt);
+ expect(takeUnsavedCollections()).toEqual([]);
+ expect(localStorage.getItem(UNSAVED_COLLECTIONS_KEY)).toBeNull();
+ }
+ });
+
test("are neither kept nor taken once the copy belongs to a different account", () => {
holdWorkspaceOwner("ana@libredb.org");
localStorage.setItem(WORKSPACE_OWNER_KEY, "bob@libredb.org");
diff --git a/tests/unit/lib/storage/sign-out.test.ts b/tests/unit/lib/storage/sign-out.test.ts
index 8b17def6e..490385c76 100644
--- a/tests/unit/lib/storage/sign-out.test.ts
+++ b/tests/unit/lib/storage/sign-out.test.ts
@@ -7,7 +7,7 @@ if (typeof globalThis.window === "undefined") {
import { mockGlobalFetch, restoreGlobalFetch } from "../../../helpers/mock-fetch";
import { registerWorkspaceSync, releaseAccountWorkspace } from "@/lib/storage/sign-out";
-import { SERVER_MIGRATED_KEY, WORKSPACE_OWNER_KEY } from "@/lib/storage/local-storage";
+import { SERVER_MIGRATED_KEY, UNSAVED_COLLECTIONS_KEY, WORKSPACE_OWNER_KEY } from "@/lib/storage/local-storage";
const CONNECTIONS = JSON.stringify([{ id: "c1", password: "pw" }]);
@@ -98,6 +98,20 @@ describe("releaseAccountWorkspace", () => {
expect(localStorage.getItem("libredb_connections")).toBeNull();
});
+ test("server mode with no sync mounted: changes an earlier sign-out kept stay with the copy", async () => {
+ // An editor sign-out could not push them, and this page (the admin dashboard) has no sync to push
+ // them now: clearing the copy here would lose what that sign-out promised to keep.
+ serverMode(true);
+ localStorage.setItem(UNSAVED_COLLECTIONS_KEY, JSON.stringify(["connections"]));
+
+ const result = await releaseAccountWorkspace(signOutAnswering(200));
+
+ expect(result).toMatchObject({ signedOut: true, changesKept: true });
+ expect(localStorage.getItem("libredb_connections")).toBe(CONNECTIONS);
+ expect(localStorage.getItem(UNSAVED_COLLECTIONS_KEY)).toBe(JSON.stringify(["connections"]));
+ expect(localStorage.getItem(WORKSPACE_OWNER_KEY)).toBe("admin@libredb.org");
+ });
+
test("server mode: a push that did not land because the session had ended clears the copy", async () => {
serverMode(true, "ended");
const seen: (string | null)[] = [];
From bfcfa9b4317e086991267582e1cb58db5e031e0f Mon Sep 17 00:00:00 2001
From: cevheri
Date: Wed, 7 Oct 2026 14:41:53 +0300
Subject: [PATCH 28/28] docs(storage): say what a sign-out drops and that the
embedded workspace keeps no owner
---
docs/STORAGE.md | 2 ++
1 file changed, 2 insertions(+)
diff --git a/docs/STORAGE.md b/docs/STORAGE.md
index 158eab641..c7e62029a 100644
--- a/docs/STORAGE.md
+++ b/docs/STORAGE.md
@@ -1273,6 +1273,7 @@ The copy is every synced collection, the editor tabs (never stored on the server
The cleared copy keeps the migration flag and no owner, so whatever is written to it afterwards is cleared at the next sign-in rather than migrated.
A pending push that does not land does not stop the sign-out: when it failed because the session had already ended, the copy is cleared and the tab goes to sign in; otherwise (server storage unreachable) the session still ends, the copy stays for this account with the names of the collections that were not pushed, and the sign-out says so.
The next sign-in of the same account on this browser pushes those collections before it pulls, and a pull does not replace a collection still waiting to be pushed; the next sign-in of a different account clears the copy, those changes with it.
+ A change made in the signing-out tab while the sign-out request is out is not pushed; it goes with the cleared copy.
When the server refuses the sign-out, or its answer is lost, the tab asks `GET /api/auth/me`: with no session left the sign-out counts as done and the copy is cleared, and while the session goes on the copy stays, the sync goes on, and the sign-out says it did not complete.
When the storage mode cannot be read, a copy that carries the owner key or the migration flag fails the sign-out and stays; a copy with neither is local-mode data, and the session just ends.
A sign-out from a tab whose session another tab has meanwhile replaced with a different account's gets `409 WORKSPACE_OWNER_MISMATCH` on its push and reloads; a logout request it sends before the reload lands ends the session the browser holds, which is that other account's.
@@ -1280,6 +1281,7 @@ The copy is every synced collection, the editor tabs (never stored on the server
A sign-in can also land where no owner is recorded (an unknown address), so a page inside `WorkspaceOwnerGate` also asks which account is signed in whenever its tab is shown again, and reloads the same way when that is no longer the account it rendered for.
The editor's sync asks which account is signed in once more when it starts, and reloads the same way when that is no longer the account the page matched the copy for.
- **Every request names the account:** once a page has matched the copy, every request it sends names that account in the `X-LibreDB-Workspace-Owner` header, and the server refuses one that names an account other than the signed-in one with `409 WORKSPACE_OWNER_MISMATCH` before the route runs, on every route that needs a session; the public routes (`src/proxy.ts`) are not checked ([API_DOCS.md](API_DOCS.md#storage-api)).
+- **The embedded workspace:** none of this applies to `StudioWorkspace` from the npm package, which mounts no sync, gate or owner check: its host supplies the connections, and the editor tabs it keeps in the host page's storage are the host's to scope to its own accounts.
The page then reloads, and the owner check runs again for the account signed in now, so a push, a pull or a migration a page sends after another tab signed in as a different account never reaches that account's server data, also in a tab that stays visible.
In the browser the same rule holds: a page writes the copy (the storage facade, its editor tabs, its object-source drafts, and each collection of a pull) only while the owner key still names the account it claimed, so a page that has not reloaded yet writes nothing into the copy another tab claimed.
- **A session that ended on its own** (expiry, a disabled account) clears nothing: the copy stays until the next sign-in's owner check, which covers a different account on the first of these pages it opens.