Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
13 changes: 11 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -215,8 +215,17 @@ DEEPSQL_BACKEND_PORT=8080
DEEPSQL_POSTGRES_PORT=5432
DEEPSQL_VALKEY_PORT=6379

# Browser origins allowed to call the backend
CORS_ALLOWED_ORIGINS=http://localhost:3000
# Browser origins allowed to call the backend.
#
# This REPLACES the built-in list — it does not add to it. When you put your own
# hostname here, keep the loopback patterns too. The desktop client reaches a VM
# over an SSH tunnel and therefore serves the app from http://127.0.0.1:<port>,
# with a port picked at runtime; if that origin is not allowed the app loads
# normally and then every login fails with a bare "403 Invalid CORS request".
# The `*` is a port wildcard (SecurityConfig uses setAllowedOriginPatterns).
#
# CORS_ALLOWED_ORIGINS=https://deepsql.example.com,http://127.0.0.1:*,http://localhost:*
CORS_ALLOWED_ORIGINS=http://localhost:3000,http://127.0.0.1:*,http://localhost:*

# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# OPTIONAL — Email / SMTP
Expand Down
93 changes: 93 additions & 0 deletions .github/workflows/desktop-release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
name: desktop-release

# Builds the DeepSQL desktop client for every platform. Each OS builds its own
# targets on its own runner: cross-building Windows needs Wine and Linux targets
# need a matching glibc, and both are far less reliable than just using the
# native runner.
#
# Tag-triggered runs attach the installers to the GitHub release. Manual runs
# upload them as workflow artifacts, which is the easy way to hand a build to a
# colleague before there is a release to cut.

on:
push:
tags:
- 'desktop-v*'
workflow_dispatch:

permissions:
contents: read

jobs:
build:
name: ${{ matrix.name }}
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
- { os: macos-latest, name: macOS, target: '--mac' }
- { os: windows-latest, name: Windows, target: '--win' }
- { os: ubuntu-latest, name: Linux, target: '--linux' }

steps:
- uses: actions/checkout@v7

- uses: actions/setup-node@v7
with:
node-version: 22
cache: npm
cache-dependency-path: desktop/package-lock.json

- name: Install dependencies
working-directory: desktop
run: npm ci

- name: Self-test the SSH tunnel transport
# Runs a real SSH server in-process, so it needs a display-free Electron.
# xvfb is only required on Linux; the other runners have a window server.
working-directory: desktop
run: ${{ matrix.os == 'ubuntu-latest' && 'xvfb-run --auto-servernum npm run selftest:tunnel' || 'npm run selftest:tunnel' }}
shell: bash

- name: Build installers
working-directory: desktop
env:
# Signing is skipped when these are unset; electron-builder warns and
# produces unsigned artifacts rather than failing the build.
CSC_LINK: ${{ secrets.DESKTOP_CSC_LINK }}
CSC_KEY_PASSWORD: ${{ secrets.DESKTOP_CSC_KEY_PASSWORD }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: npx electron-builder ${{ matrix.target }} --publish never

- uses: actions/upload-artifact@v4
with:
name: deepsql-desktop-${{ matrix.name }}
if-no-files-found: error
path: |
desktop/release/*.dmg
desktop/release/*.zip
desktop/release/*.exe
desktop/release/*.AppImage
desktop/release/*.deb
desktop/release/*.rpm

release:
name: attach to release
needs: build
if: startsWith(github.ref, 'refs/tags/desktop-v')
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/download-artifact@v4
with:
path: artifacts
merge-multiple: true

- uses: softprops/action-gh-release@v2
with:
files: artifacts/*
fail_on_unmatched_files: true
21 changes: 21 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,27 @@ User Message → ChatController → SpringAIChatService
- **UI state**: Zustand stores with selector hooks (`useActiveTab`, `useDashboardActions`)
- **Independent chat threads**: Per-tab, per-connection, stored in localStorage

## Desktop Client (Electron)

`desktop/` is a standalone Electron app (its own `package.json`, not part of the
root npm project). It is a **thin client**: it never bundles the React frontend,
it navigates a `WebContentsView` at the real DeepSQL origin, so the UI is always
the version the server runs. Two transports resolve to that origin — direct TLS,
or an in-process SSH local forward (`ssh2`, no `ssh` binary needed).

| Path | Purpose |
|------|---------|
| `desktop/src/main/transport.js` | Transport manager: connect/disconnect/health per profile |
| `desktop/src/main/tunnel.js` | SSH local forward, host-key TOFU-then-strict, auto-reconnect |
| `desktop/src/main/tls.js` | Cert policy (system / pinned / custom CA / TOFU) for Node **and** Chromium |
| `desktop/src/main/profiles.js` | Connection profiles; secrets only as `safeStorage` ciphertext |
| `desktop/src/main/windows/workspace.js` | Frameless shell: native chrome + embedded DeepSQL view |
| `desktop/src/renderer/shared/theme.css` | Mirrors `src/index.css` tokens — keep in step |

No backend change was needed: `docker/nginx/default.conf` already serves the SPA,
`/api` and `/agent-api` from one origin, which is what makes the thin-client
model work without CORS or cookie special-casing.

## Performance & Safety Guardrails

- Log size cap (500MB) via stream wrappers
Expand Down
82 changes: 82 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,88 @@ mcp/ # DeepSQL Phase 1 MCP server (Node stdio wrapper around back
agent/ # DeepSQL Agent customization (persona, skills, skins; customized Hermes runtime)
```

## Desktop Client (`desktop/`)

Cross-platform Electron client for a self-hosted DeepSQL VM. **Separate npm
project** — `cd desktop && npm install`, not part of the root `package.json`.

```bash
cd desktop
npm start # run npm run dev # run with DevTools
npm run dist:mac # dmg + zip (arm64 + x64), also :win / :linux
npm run smoke -- --url https://deepsql.example.com # headless connection check
npm run selftest:tunnel # end-to-end SSH tunnel test (in-process SSH server)
```

**It is a thin client and deliberately does not bundle the React frontend.** It
navigates a `WebContentsView` at the real DeepSQL origin, so the UI is always the
version the VM is running — no bundle/backend skew, and no second copy of 40+
tabs to maintain. `docker/nginx/default.conf` already serves the SPA, `/api` and
`/agent-api` from one origin, so cookies and SSE behave exactly as in a browser.
Do not "improve" this by bundling `dist/` — that reintroduces `SameSite` and
version-skew problems the current design does not have.

**It needs exactly one piece of backend configuration, and CORS is it.** The
"zero backend changes" claim that used to sit here was wrong, and cost a long
debugging session. Over a tunnel the origin is `http://127.0.0.1:<sticky port>`,
not the VM's hostname, so a deployment whose `CORS_ALLOWED_ORIGINS` names only
its public hostname rejects the desktop client. The failure is maximally
misleading: Chromium omits `Origin` on same-origin GETs, so the health probe,
the SPA and every read succeed, and the *first POST* — the login — comes back
`403` with the plain-text body `Invalid CORS request`. That body has no
`message` field, so `client.js`'s axios interceptor falls through to axios's own
wording and the user sees **"Request failed with status code 403"**, which names
neither CORS nor the origin. Fix: keep loopback patterns in the allowlist —
`CORS_ALLOWED_ORIGINS=https://your-host,http://127.0.0.1:*,http://localhost:*`.
Port wildcards work only because `SecurityConfig` uses
`setAllowedOriginPatterns`; `setAllowedOrigins` would reject `*` alongside
`allowCredentials(true)`. `probe.js` now sends an `Origin` header for exactly
this reason, so the rejection is caught at connect time and named.

**Two transports, one abstraction.** Both resolve to an *origin*, so nothing
downstream of `desktop/src/main/transport.js` knows which is in use:

- **Direct TLS** — the VM's HTTPS origin. Four certificate modes (`system`,
`pinned`, `custom-ca`, `insecure`/TOFU), applied to **both** the Node health
probe and the Chromium session (`tls.applyToSession`). Applying it to only one
gives a connection that tests green but renders a certificate error.
- **SSH tunnel** — `ssh2` local forward, loopback-bound, no `ssh` binary needed.
The local port is *sticky* across launches on purpose: the origin includes the
port, and a fresh random port would silently reset the web app's
`localStorage`. `http://127.0.0.1:*` is a Chromium secure context, so the
backend's `Secure` cookies still work over the tunnel. **Forward to the
frontend container (3000), not a host reverse proxy on :80** — that proxy
matches on `server_name`, a tunnel arrives with `Host: 127.0.0.1:<port>`,
and the request lands on the default vhost as a 404 that reads like a broken
backend. The container's nginx uses `server_name _` and answers any Host.

Three non-obvious things, all found the hard way:

1. **`Client.connect({ privateKey })` must get the raw key material, not the
object `sshUtils.parseKey` returns.** Handed a parsed key, ssh2 silently
never offers the publickey method and the server replies with a bare
authentication failure — a symptom that points at the VM's `authorized_keys`
rather than at a type mismatch on our side. `loadPrivateKey` parses only to
produce good error messages and returns the buffer.
2. **Authentication succeeding says nothing about forwarding being allowed.**
A hardened sshd (`AllowTcpForwarding no`) accepts the login and refuses every
`direct-tcpip` channel; the failure otherwise surfaces as "socket hang up" on
the first browser request, pointing nowhere near sshd. `verifyForwarding()`
opens and closes one channel right after auth and classifies the refusal by
SSH reason code — 1 (`ADMINISTRATIVELY_PROHIBITED`, verified against real
OpenSSH) names `AllowTcpForwarding`, 2 (`CONNECT_FAILED`) means nothing is
listening on the remote port.
3. **Only a session that once reached `ready` may be reconnected.** Gating
reconnects on `everReady` is what stops a connect that fails on
authentication from retrying forever behind a caller that already surfaced
the error.

Secrets (key passphrases, SSH passwords) are stored as `safeStorage` ciphertext;
where no OS keychain exists nothing is written to disk and the launcher says so.
Each profile gets its own session partition, so two DeepSQL servers never share
cookies. `.github/workflows/desktop-release.yml` builds all three platforms on
their native runners. See `desktop/README.md` for the full picture.

## MCP Server

- `mcp/deepsql-phase1-server.js` implements a Phase 1 stdio MCP server for internal rollout.
Expand Down
7 changes: 5 additions & 2 deletions backend/src/main/resources/application-prod.properties
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,11 @@ app.base-url=${APP_BASE_URL:http://localhost:3000}

# CORS Configuration
# Override CORS_ALLOWED_ORIGINS with the origin(s) your frontend is
# actually served from. The default below covers local dev only.
cors.allowed.origins=${CORS_ALLOWED_ORIGINS:http://localhost:3000,http://localhost:3001}
# actually served from, and keep the loopback patterns: the desktop
# client's SSH tunnel serves the app from http://127.0.0.1:<sticky port>,
# so dropping them rejects every tunnel login with 403 "Invalid CORS
# request". See the longer note in application.properties.
cors.allowed.origins=${CORS_ALLOWED_ORIGINS:http://localhost:3000,http://localhost:3001,http://127.0.0.1:*,http://localhost:*}

# File upload limits (slow query logs can be large)
spring.servlet.multipart.max-file-size=2048MB
Expand Down
18 changes: 15 additions & 3 deletions backend/src/main/resources/application.properties
Original file line number Diff line number Diff line change
Expand Up @@ -81,10 +81,22 @@ db-scheduler.shutdown-max-wait=45m
db-scheduler.immediate-execution-enabled=true

# CORS Configuration
# Localhost only by default so `mvn spring-boot:run` + `npm run dev` works out of the box.
# Loopback only by default so `mvn spring-boot:run` + `npm run dev` works out of the box.
# Any deployment serving a browser from another host must set CORS_ALLOWED_ORIGINS.
# Note: exact origins (no patterns) for reliability.
cors.allowed.origins=${CORS_ALLOWED_ORIGINS:http://localhost:3000,http://localhost:3001,http://localhost:3002,http://127.0.0.1:3000,http://127.0.0.1:3001,http://127.0.0.1:3002}
#
# The loopback entries carry a port wildcard because the desktop client's SSH tunnel
# serves the app from http://127.0.0.1:<sticky port>, and that port is chosen at runtime.
# Enumerating ports here is what made every tunnel connection fail with a bare
# "403 Invalid CORS request": Spring treats any request carrying Origin as cross-origin
# (the same-origin short-circuit went away in 5.3), so an unlisted loopback port is
# rejected. SecurityConfig uses setAllowedOriginPatterns, so `*` is legal in the port
# position and stays compatible with allowCredentials(true) — plain setAllowedOrigins
# would not be.
#
# IMPORTANT: overriding CORS_ALLOWED_ORIGINS replaces this list wholesale. A deployment
# that sets it to its public hostname alone drops the loopback entries and breaks the
# desktop client. Keep the loopback patterns alongside your hostname.
cors.allowed.origins=${CORS_ALLOWED_ORIGINS:http://localhost:3000,http://localhost:3001,http://localhost:3002,http://127.0.0.1:3000,http://127.0.0.1:3001,http://127.0.0.1:3002,http://127.0.0.1:*,http://localhost:*}

# File upload limits (slow query logs can be large)
spring.servlet.multipart.max-file-size=2048MB
Expand Down
4 changes: 4 additions & 0 deletions desktop/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
node_modules/
release/
build/icon.iconset/
*.log
Loading
Loading