Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
7231920
fix(editor): refuse an inline edit whose key would write to more than…
yusuf-gundogdu Sep 18, 2026
108e518
fix(schema-diff): read the database when a snapshot is taken and when…
yusuf-gundogdu Sep 18, 2026
3d35a92
fix(readme): stop publishing credentials that work
yusuf-gundogdu Sep 18, 2026
8eed757
fix(mysql): read a BIGINT past 2^53 without rounding it into the next…
yusuf-gundogdu Sep 18, 2026
190e4b0
fix(sqlite): carry a 64-bit id out and back without losing a digit
yusuf-gundogdu Sep 18, 2026
0381717
fix(libsql): send a 64-bit id back as an integer, not as text
yusuf-gundogdu Sep 18, 2026
485e20b
fix(env): take the working length out of the example key and widen th…
yusuf-gundogdu Sep 18, 2026
0ea9ea1
fix(editor): stop telling the user something untrue about the row the…
yusuf-gundogdu Sep 18, 2026
7b67912
fix(schema-diff): keep the panel showing what the user last asked for
yusuf-gundogdu Sep 18, 2026
f6f68d1
fix(schema-diff): show the user when a remote schema could not be fet…
yusuf-gundogdu Sep 18, 2026
648b8a3
docs: put SQL Server back in the agent-mode engine list
yusuf-gundogdu Sep 18, 2026
f431c55
test(sqlite): cover the two record guards at the row seam
yusuf-gundogdu Sep 18, 2026
d4ecfa5
fix(docs): stop handing a working login to the API examples, and clos…
yusuf-gundogdu Sep 18, 2026
d6867b2
fix(sqlite): report declared column types, so an exported table keeps…
yusuf-gundogdu Sep 18, 2026
b1df6bc
fix(editor): let a SQLite float key through, because REAL is 64 bits …
yusuf-gundogdu Sep 18, 2026
86d975e
fix(test): decide a table cell by its column header, and read a login…
yusuf-gundogdu Sep 18, 2026
03b2b2d
docs(providers): bring three provider documents up to what their code…
yusuf-gundogdu Sep 18, 2026
02a9e2c
test: name the credential fixtures for what they are
yusuf-gundogdu Sep 18, 2026
f989a85
style: rewrap one assertion after the fixture rename
yusuf-gundogdu Sep 18, 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
23 changes: 19 additions & 4 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -52,13 +52,18 @@
# required only when AUTH_BOOTSTRAP=off; otherwise it is auto-generated on
# first start (see ZERO-CONFIG BOOTSTRAP section below).
ADMIN_EMAIL=admin@libredb.org
ADMIN_PASSWORD=your_secure_admin_password
# Left empty on purpose: a value here is a password anyone reading this file knows, and
# this file is copied to .env and run as it stands. Unset, one is generated on first start
# and printed to the log once. Fill it in only to choose your own.
ADMIN_PASSWORD=

# User credentials (query execution only) — OPTIONAL.
# The lower-privilege user account exists only when USER_PASSWORD is set.
# Leave USER_PASSWORD unset to run admin-only (no default user password is ever assumed).
USER_EMAIL=user@libredb.org
USER_PASSWORD=your_secure_user_password
# Empty means the account does not exist at all - it is never generated. That is the safer
# default for anything reachable from outside.
USER_PASSWORD=

# TWO-FACTOR AUTHENTICATION (TOTP) — optional, local provider only
# ============================================
Expand Down Expand Up @@ -91,7 +96,11 @@ USER_PASSWORD=your_secure_user_password
# AUTH_BOOTSTRAP=off, which turns that generation off too: unset in production with
# bootstrap off, the server stops at startup for the same reason, because nothing would
# produce a secret and every login would be 503.
JWT_SECRET=your_32_character_random_string_here
# Empty on purpose. A placeholder long enough to clear the 32-character minimum is a
# working secret published in this file, and with STORAGE_ENCRYPTION_KEY unset it is also
# what saved connection passwords are sealed with. Generate one:
# openssl rand -base64 32
JWT_SECRET=

# ============================================
# ZERO-CONFIG BOOTSTRAP (Optional)
Expand Down Expand Up @@ -190,7 +199,13 @@ STORAGE_PROVIDER=local
# you re-enter the password once. Restore the previous key BEFORE the app writes again if you
# want the old values back.
# Browser localStorage is NOT encrypted; this variable does not change that.
# STORAGE_ENCRYPTION_KEY=your_32_character_random_string_here
# Short on purpose. A placeholder long enough to clear the 32-character minimum is a
# working key published in this file, and it is what the passwords inside saved
# connections are sealed with. Uncomment the line as it stands and, with server-side
# storage on, the server stops at startup (exit code 1) and says the key is too short -
# which is the point: nothing comes up on a key anyone can read here. Generate your own:
# openssl rand -base64 32
# STORAGE_ENCRYPTION_KEY=too-short-generate-your-own

# ===========================================
# SQLite DB Provider Driver (advanced)
Expand Down
6 changes: 3 additions & 3 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -285,9 +285,9 @@ the password once to the dev-server output. Set them to pin known values instead
(`USER_PASSWORD` additionally creates the optional non-admin account, which is
never generated):
```env
ADMIN_PASSWORD=admin123
USER_PASSWORD=user123
JWT_SECRET=your_32_character_random_string_here
ADMIN_PASSWORD=
USER_PASSWORD=
JWT_SECRET=
```

Optional (for AI features):
Expand Down
10 changes: 1 addition & 9 deletions DOCKERHUB.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,14 +31,10 @@ docker run \
--name libredb-studio \
-p 3000:3000 \
-e ADMIN_EMAIL=admin@libredb.org \
-e ADMIN_PASSWORD=change-me-admin \
-e USER_EMAIL=user@libredb.org \
-e USER_PASSWORD=change-me-user \
-e JWT_SECRET=change-me-to-a-random-32-char-string \
libredb/libredb-studio:latest
```

Open <http://localhost:3000> and log in with the `ADMIN_EMAIL` / `ADMIN_PASSWORD` you set above. **Use your own strong passwords and a random `JWT_SECRET`** — the values here are placeholders.
Open <http://localhost:3000>. No password is set above, so the first start generates one and prints it with `docker logs libredb-studio`. To choose your own instead, add `-e ADMIN_PASSWORD=...` and `-e JWT_SECRET=...` — the secret has to be at least 32 characters, and a value short enough to read as a placeholder is what stops a container coming up on a published one. `USER_EMAIL` / `USER_PASSWORD` are optional and create a second, lower-privilege account; without them there is no such account.

> **None of these auth variables are mandatory.** With the local provider, `ADMIN_PASSWORD` and `JWT_SECRET` are required only when you opt into strict mode (`AUTH_BOOTSTRAP=off`); otherwise both are generated on first start and the admin password is printed once to the container log. `USER_EMAIL` / `USER_PASSWORD` are always optional — omit them to run admin-only, since no default user password is ever assumed. None of them are used when `NEXT_PUBLIC_AUTH_PROVIDER=oidc`.

Expand All @@ -54,10 +50,6 @@ services:
- "3000:3000"
environment:
ADMIN_EMAIL: admin@libredb.org
ADMIN_PASSWORD: change-me
USER_EMAIL: user@libredb.org
USER_PASSWORD: change-me
JWT_SECRET: change-me-to-a-random-32-char-string
STORAGE_PROVIDER: sqlite # persist on the volume below
STORAGE_SQLITE_PATH: /app/data/libredb-storage.db
volumes:
Expand Down
12 changes: 3 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -349,18 +349,14 @@ docker run \
--name libredb-studio \
-p 3000:3000 \
-e ADMIN_EMAIL=admin@libredb.org \
-e ADMIN_PASSWORD=LibreDB.2026 \
-e USER_EMAIL=user@libredb.org \
-e USER_PASSWORD=LibreDB.2026 \
-e JWT_SECRET=change-me-to-a-random-32-char-string \
ghcr.io/libredb/libredb-studio:latest
```

> **Registry**: `ghcr.io/libredb/libredb-studio` is the primary image (no pull rate limits — preferred for Kubernetes/CI). The same image is also mirrored to Docker Hub as [`libredb/libredb-studio`](https://hub.docker.com/r/libredb/libredb-studio?tag=latest) for convenience.

> **IPv6**: the container picks its own bind address at startup and prefers `::`, which serves IPv4 and IPv6 through one socket — so an IPv6-only host needs no flags. It falls back to `0.0.0.0` where the namespace has no usable IPv6, and logs which it chose. Add `-e HOSTNAME=0.0.0.0` to pin it to IPv4 — details, and the Kubernetes equivalent, in [`docs/DISTRIBUTION.md`](docs/DISTRIBUTION.md#network-exposure-bind-address).

Open [http://localhost:3000](http://localhost:3000) and login with `admin@libredb.org` / `LibreDB.2026`.
Open [http://localhost:3000](http://localhost:3000). The command above sets no password, so the first start generates one and prints it to the container log with `docker logs libredb-studio` — sign in as `admin@libredb.org` with the password it printed, or set `ADMIN_PASSWORD` yourself.

> **Auth env vars (local provider):** `ADMIN_PASSWORD` and `JWT_SECRET` are only required when `AUTH_BOOTSTRAP=off`; otherwise both are generated on first start (see [Zero-config first run](#zero-config-first-run) below). `USER_EMAIL` / `USER_PASSWORD` are optional; omit them to run admin-only (no default user password is ever assumed). `ADMIN_EMAIL` defaults to `admin@libredb.org`. Using OIDC (`NEXT_PUBLIC_AUTH_PROVIDER=oidc`)? None of these are needed.

Expand Down Expand Up @@ -419,9 +415,7 @@ journalctl -u libredb-studio
```env
# Authentication (email/password)
ADMIN_EMAIL=admin@libredb.org
ADMIN_PASSWORD=your_admin_password
USER_EMAIL=user@libredb.org
USER_PASSWORD=your_user_password
JWT_SECRET=your_32_character_random_string

# Optional: OIDC Single Sign-On (Auth0, Keycloak, Okta, Azure AD, etc.)
Expand Down Expand Up @@ -616,7 +610,7 @@ The nineteenth spec in `e2e/`, `base-path.spec.ts`, is not in that 18: it needs

Deploy your own instance of LibreDB Studio with a single click on DigitalOcean, Koyeb, Render, Railway, Sealos, CapRover, or Dokploy:

[![Deploy to Koyeb](https://www.koyeb.com/static/images/deploy/button.svg)](https://app.koyeb.com/deploy?name=libredb-studio&type=docker&image=ghcr.io%2Flibredb%2Flibredb-studio%3Alatest&instance_type=free&regions=fra&instances_min=0&autoscaling_sleep_idle_delay=3900&env%5BADMIN_EMAIL%5D=admin%40libredb.org&env%5BADMIN_PASSWORD%5D=set_a_real_password&env%5BJWT_SECRET%5D=set_a_real_secret&env%5BLLM_API_KEY%5D=your_GEMINI_API_KEY&env%5BLLM_MODEL%5D=gemini-2.5-flash&env%5BLLM_PROVIDER%5D=gemini&env%5BNEXT_PUBLIC_AUTH_PROVIDER%5D=local&env%5BSTORAGE_PROVIDER%5D=local&env%5BUSER_EMAIL%5D=user%40libredb.org&env%5BUSER_PASSWORD%5D=set_a_real_password&ports=3000%3Bhttp%3B%2F&hc_protocol%5B3000%5D=tcp&hc_grace_period%5B3000%5D=5&hc_interval%5B3000%5D=30&hc_restart_limit%5B3000%5D=3&hc_timeout%5B3000%5D=5&hc_path%5B3000%5D=%2F&hc_method%5B3000%5D=get)
[![Deploy to Koyeb](https://www.koyeb.com/static/images/deploy/button.svg)](https://app.koyeb.com/deploy?name=libredb-studio&type=docker&image=ghcr.io%2Flibredb%2Flibredb-studio%3Alatest&instance_type=free&regions=fra&instances_min=0&autoscaling_sleep_idle_delay=3900&env%5BADMIN_EMAIL%5D=admin%40libredb.org&env%5BJWT_SECRET%5D=set_a_real_secret&env%5BLLM_API_KEY%5D=your_GEMINI_API_KEY&env%5BLLM_MODEL%5D=gemini-2.5-flash&env%5BLLM_PROVIDER%5D=gemini&env%5BNEXT_PUBLIC_AUTH_PROVIDER%5D=local&env%5BSTORAGE_PROVIDER%5D=local&ports=3000%3Bhttp%3B%2F&hc_protocol%5B3000%5D=tcp&hc_grace_period%5B3000%5D=5&hc_interval%5B3000%5D=30&hc_restart_limit%5B3000%5D=3&hc_timeout%5B3000%5D=5&hc_path%5B3000%5D=%2F&hc_method%5B3000%5D=get)
[![Deploy to Render](https://render.com/images/deploy-to-render-button.svg)](https://render.com/deploy?repo=https://github.com/libredb/libredb-studio)
[![Deploy on Railway](https://railway.com/button.svg)](https://railway.com/deploy/libredb-studio?referralCode=libredb&utm_medium=integration&utm_source=template&utm_campaign=generic)
[![Deploy on Sealos](https://sealos.io/Deploy-on-Sealos.svg)](https://sealos.io/products/app-store/libredb-studio)
Expand Down Expand Up @@ -683,7 +677,7 @@ For a reverse-proxy path such as `/tools/libredb`, build with `BASE_PATH` and fo
### Koyeb

1. Use the **Deploy to Koyeb** button under [One-Click Deploy](#one-click-deploy) to run the prebuilt `ghcr.io/libredb/libredb-studio:latest` image.
2. Set a strong `JWT_SECRET` (32+ characters) and real `ADMIN_PASSWORD` / `USER_PASSWORD` in the deploy form before launching. Koyeb cannot auto-generate secrets; the prefilled values are placeholders.
2. Set a strong `JWT_SECRET` (32+ characters) in the deploy form before launching. Koyeb cannot auto-generate secrets, and the prefilled one is shorter than the 32-character minimum on purpose, so a deployment left as it stands stops at boot and says why. No password is prefilled: leave `ADMIN_PASSWORD` unset and the app generates one on first run and prints it to the Koyeb runtime log, or set your own. `USER_PASSWORD` is not generated — without it the lower-privilege account does not exist at all, which is the safer default for a public URL.
3. For connections to survive redeploys, set `STORAGE_PROVIDER=postgres` and `STORAGE_POSTGRES_URL` to a Koyeb managed Postgres or Neon connection string. The button defaults to `STORAGE_PROVIDER=local`, which keeps connection metadata in the browser.

See [`deploy/koyeb/`](deploy/koyeb/) for the complete setup and storage options.
Expand Down
6 changes: 5 additions & 1 deletion SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,11 @@ When using LibreDB Studio, please follow these security best practices:
that fetched the rows on screen, read under the connection's own dialect, and is refused
whenever that statement's rows have no single table or the reader cannot settle the name. An
unquoted name is validated as a bare identifier rather than quoted, because quoting changes its
case semantics; a quoted one is copied exactly as the query spells it
case semantics; a quoted one is copied exactly as the query spells it. The key column the
`WHERE` is built on is inferred as well, from the result's own field names, so the apply asks
the engine whether that column addresses one row per value and refuses the whole apply when it
does not: a result carrying a foreign key instead of the table's own key made one cell edit
rewrite every row sharing that value
- Login attempts, the AI endpoints and every database-reaching route (query execution, schema
browsing, maintenance operations, and the admin fleet-health check) are rate limited in the
application. The counters live in the application process, so with more than one replica the
Expand Down
25 changes: 16 additions & 9 deletions deploy/koyeb/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,20 @@ by hand. URL-encode every special character (`@` → `%40`, `:` → `%3A`,
## Important Koyeb specifics

- **No secret generation.** Unlike Railway's `${{ secret(48) }}`, Koyeb cannot
auto-generate values. The user **must** set a strong `JWT_SECRET` (32+ chars)
and real `ADMIN_PASSWORD` / `USER_PASSWORD` in the deploy form before
launching. The prefilled values are deliberately unusable rather than merely
nominal: the secret is **shorter than the 32-character minimum**, so a deploy
left as-is stops at boot with `JWT_SECRET is too short` instead of coming up
on a secret that is printed in a public README. Keep it that way — a
placeholder that clears the minimum is a published working secret.
auto-generate values, so the user **must** set a strong `JWT_SECRET` (32+
chars) in the deploy form before launching. The prefilled secret is
deliberately unusable rather than merely nominal: it is **shorter than the
32-character minimum**, so a deploy left as-is stops at boot with
`JWT_SECRET is too short` instead of coming up on a secret that is printed in
a public README. Keep it that way — a placeholder that clears the minimum is a
published working secret.
- **No prefilled passwords.** The button carries none. The two
fields behave differently when unset, and both answers are safe ones:
`ADMIN_PASSWORD` is generated on first run and printed to the Koyeb runtime
log, the same as a bare `docker run`; `USER_PASSWORD` is never generated, and
without it the lower-privilege account does not exist at all. Set your own in
the deploy form if you want to choose them, or if you want that second account;
do not put a placeholder back.
- **Ephemeral filesystem.** Koyeb instances do not have a persistent disk in the
button flow, so SQLite-on-disk storage (`STORAGE_PROVIDER=sqlite`) will reset
on every redeploy/sleep. The button therefore defaults to
Expand All @@ -56,8 +63,8 @@ for the full list. Minimum required for a working Koyeb deploy:
| Variable | Notes |
|----------|-------|
| `JWT_SECRET` | 32+ chars, set your own |
| `ADMIN_EMAIL` / `ADMIN_PASSWORD` | admin login |
| `USER_EMAIL` / `USER_PASSWORD` | standard user login |
| `ADMIN_EMAIL` / `ADMIN_PASSWORD` | admin login; password not prefilled, generated and logged when unset |
| `USER_EMAIL` / `USER_PASSWORD` | optional second account; no password means no account |
| `NEXT_PUBLIC_AUTH_PROVIDER` | `local` (default) or `oidc` |
| `STORAGE_PROVIDER` | `local` (default); `postgres` for persistence |

Expand Down
8 changes: 6 additions & 2 deletions docs/API_DOCS.md
Original file line number Diff line number Diff line change
Expand Up @@ -1679,10 +1679,14 @@ including login - is refused this way.
### cURL Examples

#### Login

The admin password is generated on first run and printed to the server log, or set
through `ADMIN_PASSWORD`. Put yours in place of the placeholder below.

```bash
curl -X POST http://localhost:3000/api/auth/login \
-H "Content-Type: application/json" \
-d '{"email": "admin@libredb.org", "password": "admin123"}' \
-d '{"email": "admin@libredb.org", "password": "<your admin password>"}' \
-c cookies.txt
```

Expand Down Expand Up @@ -1766,7 +1770,7 @@ async function executeQuery(sql: string) {
await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: 'admin@libredb.org', password: 'admin123' }),
body: JSON.stringify({ email: 'admin@libredb.org', password: process.env.ADMIN_PASSWORD }),
credentials: 'include'
});

Expand Down
3 changes: 2 additions & 1 deletion docs/BACKLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -1437,7 +1437,8 @@ it was not mixed into a correctness PR.

### X9. What `columnTypes` still cannot name, measured

The four string-returning drivers fill `QueryResult.columnTypes` since 2026-08-23. Four bounds were
The four string-returning drivers fill `QueryResult.columnTypes` since 2026-08-23, and
SQLite joined them on 2026-09-18 by reading its own declarations through the driver bridge. Four bounds were
measured while doing it, and each is a small residue rather than a defect:

- **A user-defined type has no name.** Postgres's built-in OIDs are a generated static table (they are
Expand Down
5 changes: 2 additions & 3 deletions docs/DISTRIBUTION.md
Original file line number Diff line number Diff line change
Expand Up @@ -263,9 +263,8 @@ Production (strict mode, explicit secrets):
```bash
docker run --name libredb-studio -p 3000:3000 \
-e AUTH_BOOTSTRAP=off \
-e JWT_SECRET=change-me-to-a-random-32-char-string \
-e JWT_SECRET=change-me \
-e ADMIN_EMAIL=admin@libredb.org \
-e ADMIN_PASSWORD=your_secure_admin_password \
ghcr.io/libredb/libredb-studio:latest
```

Expand Down Expand Up @@ -809,7 +808,7 @@ Example drop-in (uncomment and fill what you need):

# Auth (optional; omit to keep zero-config bootstrap)
#Environment=AUTH_BOOTSTRAP=off
#Environment=JWT_SECRET=change-me-to-a-random-32-char-string
#Environment=JWT_SECRET=change-me
#Environment=ADMIN_EMAIL=admin@libredb.org
#Environment=ADMIN_PASSWORD=

Expand Down
2 changes: 1 addition & 1 deletion docs/FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@

### 3. Pro Data Grid (Excel-Style)
* **High Performance:** Virtualized rendering using TanStack Virtual for smooth scrolling through millions of rows.
* **Inline Editing:** Double-click any cell to edit data directly; apply pending cell changes as one `UPDATE` per edited row or discard them. The table written to is the one the *statement that fetched the rows* names, not the tab's title. A query whose rows have no single table - a join, a comma-separated `FROM`, a subquery, a CTE, a set operation - is refused with a reason rather than guessed at. Offered only where the provider declares `supportsInlineRowEdit`. ClickHouse, Druid, Elasticsearch, OpenSearch, Trino, Cassandra, MongoDB, Redis and LibreDB show no editing control at all because they have no single-table row update - on Cassandra because CQL requires the WHOLE primary key restricted by equality while the editor names one column it guessed from the result fields, so a clustered table answers "Some partition key parts are missing" (measured) — on Trino because it declares no primary key for any table in any catalog, so the generated `WHERE` could not identify one row — on the two search engines `UPDATE` is absent from the SQL grammar itself, measured on both; Couchbase shows none because the document key reaches the grid as a projection alias the generated `WHERE` cannot address.
* **Inline Editing:** Double-click any cell to edit data directly; apply pending cell changes as one `UPDATE` per edited row or discard them. The table written to is the one the *statement that fetched the rows* names, not the tab's title. A query whose rows have no single table - a join, a comma-separated `FROM`, a subquery in `FROM` or in the select list, a CTE, a set operation - is refused with a reason rather than guessed at. The key the `WHERE` is built on is a guess off the result's own fields, so before anything is written the apply asks the engine whether that column addresses one row per value and refuses the whole apply when it does not: on a result carrying a foreign key rather than the table's own key, one cell edit used to rewrite every row sharing that value. Offered only where the provider declares `supportsInlineRowEdit`. ClickHouse, Druid, Elasticsearch, OpenSearch, Trino, Cassandra, MongoDB, Redis and LibreDB show no editing control at all because they have no single-table row update - on Cassandra because CQL requires the WHOLE primary key restricted by equality while the editor names one column it guessed from the result fields, so a clustered table answers "Some partition key parts are missing" (measured) — on Trino because it declares no primary key for any table in any catalog, so the generated `WHERE` could not identify one row — on the two search engines `UPDATE` is absent from the SQL grammar itself, measured on both; Couchbase shows none because the document key reaches the grid as a projection alias the generated `WHERE` cannot address.
* **Data-Type Formatting:** Specialized rendering for Numbers, Booleans, and Nulls.
* **Column Management:** Resizable columns and advanced sorting.
* **Row Detail:** A control at the left edge of every row opens that row field by field, values beside field names, with per-field copy and the same masking the grid applies.
Expand Down
Loading
Loading