Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
cfef89f
fix(storage): bind the browser workspace to the signed-in account
cevheri Oct 7, 2026
3b24cce
docs(storage): describe the browser copy of the signed-in account
cevheri Oct 7, 2026
9246498
fix(storage): clear the browser copy once the session has ended
cevheri Oct 7, 2026
1691405
fix(storage): match the browser copy to the signed-in account on ever…
cevheri Oct 7, 2026
3840e54
docs(storage): describe the owner check on every page and the sign-ou…
cevheri Oct 7, 2026
95cd504
fix(storage): record the first account as the owner of an unmigrated …
cevheri Oct 7, 2026
cab2e40
fix(storage): show no server-bound browser copy while the storage mod…
cevheri Oct 7, 2026
404c88e
fix(storage): reload an open page when another tab changes whose brow…
cevheri Oct 7, 2026
7aaaee0
fix(auth): report a refused sign-out as failed and stay on the page
cevheri Oct 7, 2026
1231f0f
fix(storage): keep the browser copy cleared when a sign-out lands dur…
cevheri Oct 7, 2026
6b1e3b2
docs(storage): name every writer of the owner key and the first sign-…
cevheri Oct 7, 2026
eb16cab
fix(storage): show a loading indicator while the owner check runs
cevheri Oct 7, 2026
7059819
fix(storage): record the signed-in account on the settings pages
cevheri Oct 7, 2026
2909473
docs(storage): name the settings pages and the copy written while the…
cevheri Oct 7, 2026
58aaa41
fix(storage): ask which account is signed in when a gated tab is show…
cevheri Oct 7, 2026
b8b2521
fix(storage): send a tab whose session has ended to sign in from the …
cevheri Oct 7, 2026
ab38f34
fix(storage): name the claimed account on every request a tab sends
cevheri Oct 7, 2026
1f5a88b
fix(storage): write the browser copy only while it is still the claim…
cevheri Oct 7, 2026
ab7d15f
fix(storage): end the session when a sign-out cannot push, and check …
cevheri Oct 7, 2026
1d8e748
fix(storage): reload a page whose owner key another tab removed, also…
cevheri Oct 7, 2026
8e23f21
refactor(storage): mark the gate and settings layout props read-only …
cevheri Oct 7, 2026
8ade56a
docs(storage): say when the browser copy stays after a sign-out and h…
cevheri Oct 7, 2026
d2a6b1d
test(storage): type the owner-header fetch mock and name the tab save…
cevheri Oct 7, 2026
bba9f97
fix(storage): push the changes a sign-out kept when the same account …
cevheri Oct 7, 2026
cdef8a3
fix(storage): write the agent thread hint only while the copy is the …
cevheri Oct 7, 2026
aa1cda2
docs(storage): scope the owner header to the routes the proxy checks …
cevheri Oct 7, 2026
15dc667
fix(storage): keep changes a sign-out kept when a page without the sy…
cevheri Oct 7, 2026
bfcfa9b
docs(storage): say what a sign-out drops and that the embedded worksp…
cevheri Oct 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/API_DOCS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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) |
Expand Down
1 change: 1 addition & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions docs/SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -582,6 +582,10 @@ 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: 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 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
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
Expand Down
70 changes: 64 additions & 6 deletions docs/STORAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---

Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -1203,6 +1206,19 @@ 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, │
│ │ │ owner = username (local-mode data, │
│ │ │ migrated below into this account only) │
│ │ └─ Otherwise → clear the browser copy, │
│ │ mark it migrated, owner = username │
│ └──────────────────────┬──────────────────────┘
│ │
│ ┌──────────────────────▼──────────────────────┐
│ │ Check libredb_server_migrated flag │
│ │ ├─ Not migrated → POST /api/storage/migrate│
│ │ │ (send all localStorage → server merge) │
Expand All @@ -1212,7 +1228,8 @@ App Mount
│ │
│ ┌──────────────────────▼──────────────────────┐
│ │ Pull: GET /api/storage │
│ │ → Write server data into localStorage │
│ │ → Write server data into localStorage, │
│ │ only while the owner is still username │
│ │ → Components re-render from localStorage │
│ └──────────────────────┬──────────────────────┘
│ │
Expand All @@ -1237,9 +1254,47 @@ 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 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 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.
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, 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 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.
- **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 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.
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.

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
- 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

Expand All @@ -1251,16 +1306,19 @@ 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), 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
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, 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).

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.
Expand Down
7 changes: 6 additions & 1 deletion src/app/admin/layout.tsx
Original file line number Diff line number Diff line change
@@ -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.
<Suspense>
<AdminDashboard>{children}</AdminDashboard>
<WorkspaceOwnerGate>
<AdminDashboard>{children}</AdminDashboard>
</WorkspaceOwnerGate>
</Suspense>
);
}
9 changes: 7 additions & 2 deletions src/app/launch/launch-client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.";
Expand Down Expand Up @@ -111,8 +112,12 @@ 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);
setState(response?.ok ? "signed-out" : "failed");
// In server storage mode the browser copy belongs to the signed-in account and is cleared once
// the session ended.
const result = await releaseAccountWorkspace(() => appFetch("/api/auth/logout", { method: "POST" })).catch(
() => null,
);
setState(result?.signedOut ? "signed-out" : "failed");
}

return (
Expand Down
Loading
Loading