How it works

The theory of operation: the bridge, the engine per session, launch tokens, the signed license grant, check-ins, seats, the Origin rule, and where credentials live.

SQLly Embedded splits SQLly into the half that needs secrets and the half that doesn't. The SQLly UI runs in your user's browser as WebAssembly. Everything that touches a database runs on your server, inside the SQLly bridge and the engines it starts. Your web app sits beside the bridge and tells it who may open SQLly with which connections. This page explains each moving part and why it's built that way.

The architecture

SQLly Embedded architecture The user's browser loads SQLly from your web app and connects to the SQLly bridge over a WebSocket. Your app drives the bridge through its loopback management API. The bridge runs one SQLly engine per session, which connects to your databases. The bridge checks its license with sqlly.app every 20 minutes. your users your server sqlly.app Browser SQLly (WebAssembly) no passwords here Your web app sign-in · who gets which profile · serves web/ sqlly-bridge embedded :8444 /proxy (browsers, wss) :8445 /manage (your app, loopback) profiles · tokens · seats · license sqlly-cli engine one per session Your databases any SQLly engine sqlly.app signs a license grant per check-in https wss bearer token stdio check-in every 20 min
Blue paths carry your users' work; the dashed path is the license check-in, which carries no queries, results or credentials. Offline licenses drop it entirely.

Why the split

  • Secrets stay server-side. A browser can't keep a database password from its own user. So the browser never gets one. It gets a connection list, sends query frames, and the bridge runs them with credentials only the server holds.
  • Your app stays in charge of identity. The bridge has no user accounts. Your app already knows who is signed in. It tells the bridge "open SQLly for this user with this profile" by minting a launch token, and the bridge trusts the token.
  • One engine per session. Each browser tab gets its own sqlly-cli process, so sessions never share connection state, transactions or temp tables, and a crash takes down one tab, not the bridge.

Inside the bridge

sqlly-bridge embedded is one process with three jobs:

JobListens onTalks to
Browser proxy: accepts a WebSocket per SQLly tab, checks its Origin and launch token, starts an engine with the token's profile, and relays frames both ways.--bind, default 0.0.0.0:8444, path /proxyBrowsers (put TLS in front)
Management API: profiles, launch tokens, seats, connection tests, license status, and the query audit trail. Reference.--manage-bind, default 127.0.0.1:8445Your app's server side only, with a bearer token
License monitor: checks in with sqlly.app at startup and every 20 minutes (connected), or re-reads the license file every minute (offline), and moves the bridge between license states.Nothingsqlly.app over HTTPS (connected only)

Its state directory (--state-dir, created owner-only on Linux and macOS) holds every profile as profiles/<id>.json, the last good license grant (license.json), a linked install's key (license-key, mode 0600), the per-session credential files it hands its engines, and the query audit trail (queries.jsonl: every query a session ran, with its SQL text). Back it up, and keep it as private as you'd keep a password file, because it is one. Where to put it, and what Windows needs.

Profiles: what a user can see

A profile is a named SQLly credential document: the same JSON format SQLly's desktop app imports and exports. It lists connections (server, database, engine, authentication, TLS, safety rules) and the client, project and environment labels that colour and group them. Your app stores profiles with PUT /manage/profiles/{id}. The bridge validates the whole document up front and reports every problem at once, so a typo like "pasword" fails loudly instead of being ignored.

Users don't belong to profiles in the bridge. Each launch names one. Give analysts a read-only profile (a label with only_allow_select, or block_all_but_select on the connection: the engine refuses anything but SELECT before it reaches the database) and support staff another, or build one profile per customer in a multi-tenant app. File-based engines (SQLite, DuckDB) are refused unless the bridge is started with --allow-file-engines, because they give users file access on the bridge host. To give someone fewer connections, make another profile. When a session opens, the browser receives the profile's catalog: connection names, engines, databases and labels, with every secret removed. Editing a profile applies to the next launch. Sessions already open keep the catalog they started with.

The life of a session

Launching SQLly for a user The browser asks your app for SQLly. Your app mints a launch token from the bridge and returns the SQLly page with the proxy URL and token. The browser opens a WebSocket to the bridge with the token. The bridge checks the Origin, token, license and seats, starts an engine with the profile's credentials, and sends the browser a hello with the connection catalog and license state. Query frames then flow from the browser through the bridge and engine to the database and back. Browser Your app Bridge Engine Database 1 open SQLly (signed in) 2 POST /manage/tokens 3 201 {token, proxyUrl} 4 SQLly page + token 5 wss /proxy?token=… (Origin checked) 6 start, with credentials 7 hello {catalog, license} - no secrets 8 query frame SQL rows 9 results stream back The token can be reused until it expires (page reloads); closing the last tab frees the seat at once.
Steps 2-4 are the only part your app implements. Everything after the WebSocket opens is between the browser and the bridge.
  1. A signed-in user opens the SQLly page in your app.
  2. Your server calls POST /manage/tokens with your id for the user and the profile they should get.
  3. The bridge checks the license and the seat count, captures the profile, and returns a random token, its expiry and the public proxy URL.
  4. Your server returns the SQLly page (web/index.html) with two globals set before boot.js: SQLLY_PROXY_URL and SQLLY_PROXY_TOKEN.
  5. SQLly opens wss://…/proxy?token=…. The bridge checks, in order: the browser's Origin (403), the token (401), the license (503), the user's open sessions (429), and its own session cap.
  6. The bridge writes the profile's credentials to a private per-session file and starts sqlly-cli in engine mode with it.
  7. The browser receives a hello carrying the connection catalog without secrets and the current license state.
  8. From here on, SQLly's requests (connect, run, cancel, fetch metadata for IntelliSense, page through results) travel as binary frames. The bridge relays them to the engine, records every query and row edit in its audit trail, and refuses them with a message if the license stops being valid.

Seats and sessions

A seat is a distinct user id that has an open session or an unexpired launch token. Seats are concurrent, not named: when a user's last tab closes and their last token expires, the seat frees immediately and someone else can use it. The bridge counts seats across every bridge on the license, because sqlly.app reports the users on other instances at each check-in.

  • Minting for a user who already holds a seat always succeeds. Minting for a new user when every seat is taken fails with 409 seat_limit, so your app can show a clear message.
  • Each seat may hold sessionsPerSeat open tabs: four on paid plans, one on the trial. An extra tab is refused at the WebSocket with HTTP 429.
  • DELETE /manage/seats/{user} revokes a user's tokens and closes their sessions. Call it when someone signs out of your app or loses access.
  • The user id is whatever you pass. Use a stable, opaque id (a database key or a hash) if you'd rather not send email addresses: the ids holding seats are reported to sqlly.app so seats can be counted across bridges.

Who may connect: the Origin rule

Each license is bound to one domain. The bridge accepts a browser WebSocket only when its Origin header is that domain or any subdomain of it (any depth, scheme or port), or localhost / 127.0.0.1 / [::1] while --allow-localhost-origins is on (the default, for development). A license with no bound domain admits localhost only. Bind the domain on your Embedded page, or start the bridge once with --domain.

The management API sends no CORS headers at all. It's server-to-server: a browser on another origin can't call it, and it should be bound to loopback anyway.

How the license is enforced

The bridge trusts one thing: a license grant, a compact JWS that sqlly.app signs with ES384 (ECDSA P-384). The verification key is compiled into the bridge. Every allowance (seats, sessions per seat, bridge instances, domain, period end, grace days, features) is read from the grant's signed claims. The unsigned JSON around it is only for display.

Connected licenses

  • At startup and every 20 minutes the bridge sends POST /api/embedded/checkin with its license key, its instance id, a fresh 32-byte nonce, its version and hostname, the domain it was started for, and the user ids currently holding seats.
  • The reply's grant must verify against the compiled-in key, echo that nonce, name this instance and the same license, and be issued within 5 minutes of the bridge's clock. A replayed or forged reply fails these checks and counts as a failed check-in. Keep the host clock right (NTP).
  • A grant is good for 40 minutes. If sqlly.app can't be reached, the bridge keeps serving on its last good grant until that runs out, then goes stale and refuses work until the next successful check-in, with no restart needed. A bridge restarted during an outage starts from its cached grant only while that grant hasn't expired.
  • Each connected license allows a number of bridge instances at once (one by default). A second bridge on the same key is recorded but not admitted while the first keeps checking in. A bridge that stops cleanly releases its slot at once. One that dies frees it 40 minutes later.

Offline licenses

An offline license is a file ({ "format": "sqlly-embedded-license", "version": 1, "token": "<grant>" }) you download from sqlly.app once its domain is set. The grant inside is long-lived: it expires at the period end plus the grace days. The bridge verifies it locally, never contacts any SQLly server, and re-reads the file every minute, so renewing means replacing the file. Offline licenses have no instance cap.

Linked installs

A bridge started with no key and no file is unlicensed: the management API works (you can set up and test profiles) but launches are refused. Your admin app can then link it without anyone copying a key: it asks sqlly.app for a link code, the license owner approves it in their browser, and the app collects a key minted for this install alone and hands it to the bridge with PUT /manage/license. The bridge verifies it, stores it, and keeps using it across restarts. Each linked install has its own key, so the owner can revoke one without touching the others. How to build it.

When the license isn't valid

In every state except ok and grace, the bridge keeps open sockets connected but answers each request frame with a failure carrying a plain sentence ("…could not confirm its license with sqlly.app for 40 minutes; ask your administrator to check in."). The SQLly client shows that sentence in its status line and as a notification, so users know why. New sessions are refused at the WebSocket with HTTP 503, and launch tokens with 503 license_not_ok. During the 15-day grace after a period ends, everything works but every user sees a countdown. Every state, explained.

What leaves your server

Sent to sqlly.app at check-inNever sent anywhere
License key · bridge instance id · bridge version · hostname · requested domain · user ids holding seats · number of open sessions Queries · results · database names and hosts · credentials · profiles · anything your users type

Offline licenses send nothing at all. The browser talks only to your app and your bridge.