Build your own app
Step by step: drive the SQLly Embedded bridge from your own web app - store profiles, mint launch tokens, serve the browser build, release seats and link installs.
The sample app is a stand-in for your product. Everything it does goes through the bridge's management API, so any server-side stack can replace it: Node, ASP.NET, Django, Rails, Go, PHP. This page walks through each job the sample app does and how to do it in your own app. Examples are in JavaScript (Node 20+) and C# (ASP.NET Core), and the same calls work from anything that speaks HTTP and JSON.
Generate a client instead of writing one. swagger.json is an OpenAPI 3.0 description of the whole management API. Feed it to openapi-generator, NSwag, Kiota or your HTTP tool of choice. The sample app's sample-app/lib/bridge.js is also a complete hand-written client in about 120 lines.
What your app takes over
| Job | In the sample app | In your app |
|---|---|---|
| Sign users in | Its own users file with scrypt passwords (lib/auth.js) | Your existing sign-in, SSO, sessions |
| Decide who sees which connections | Each user is assigned one profile | Your roles, teams, tenants or plans pick the profile id |
| Define connections | Profile editor, Labels tab (lib/credentials.js builds the document) | Your admin UI, a config file, or code that builds documents from your tenant data |
| Open SQLly | GET /app/ mints a token and injects it into index.html | One route that does the same (step 4) |
| Serve the web bundle | /app/* with isolation headers | Your static file server or CDN path with the same headers (step 5) |
| Free seats | Users page, Release seat | Call it on sign-out and when access is removed (step 6) |
| Show license health | Status page | A health check or admin widget (step 7) |
| Link to a license | Connect to sqlly.app | Optional: or configure a key or license file (step 8) |
1. Run the bridge next to your app
Without the start script, the bridge is one command. Generate the manage token once and give the same value to the bridge and your app (an environment variable or secret store, never your repository):
export SQLLY_EMBEDDED_MANAGE_TOKEN="$(openssl rand -base64 24)"
export SQLLY_EMBEDDED_LICENSE_KEY=sqe_… # or SQLLY_EMBEDDED_LICENSE_FILE, or neither and link later
./bin/sqlly-bridge embedded \
--domain portal.example.com \
--public-url wss://portal.example.com/sqlly/proxy \
--manage-bind 127.0.0.1:8445 \
--state-dir /var/lib/sqlly-embedded
Your app talks to http://127.0.0.1:8445 with Authorization: Bearer $SQLLY_EMBEDDED_MANAGE_TOKEN. Browsers reach the proxy listener (:8444) through your reverse proxy. Production details.
2. A tiny management client
javascript (node 20+)const BRIDGE = process.env.SQLLY_BRIDGE_URL ?? 'http://127.0.0.1:8445';
const TOKEN = process.env.SQLLY_EMBEDDED_MANAGE_TOKEN;
export async function bridge(method, path, body) {
const res = await fetch(BRIDGE + path, {
method,
headers: {
authorization: `Bearer ${TOKEN}`,
...(body ? { 'content-type': 'application/json' } : {}),
},
body: body ? JSON.stringify(body) : undefined,
});
if (res.status === 204) return null;
const json = await res.json().catch(() => null);
if (!res.ok) {
// Every error is { error, message } (+ problems for invalid documents).
throw Object.assign(new Error(json?.message ?? `bridge HTTP ${res.status}`),
{ code: json?.error, status: res.status, problems: json?.problems });
}
return json;
}
c# (asp.net core)
builder.Services.AddHttpClient("sqlly-bridge", c =>
{
c.BaseAddress = new Uri(builder.Configuration["Sqlly:BridgeUrl"] ?? "http://127.0.0.1:8445");
c.DefaultRequestHeaders.Authorization =
new("Bearer", builder.Configuration["Sqlly:ManageToken"]);
});
public sealed record BridgeError(string Error, string Message, string[]? Problems);
public sealed record MintedToken(string Token, DateTimeOffset ExpiresAt, string ProxyUrl,
string User, string Profile, int SeatsUsed, int Seats);
3. Store profiles
A profile is a SQLly credential document plus a display name. Build it however suits you: from rows in your database, from a per-tenant template, or from an admin form. GET /manage/engines lists every engine id, its default port, and which sign-in types it accepts, so a form never hard-codes them.
await bridge('PUT', '/manage/profiles/acme-analysts', {
name: 'Acme analysts',
credentials: {
format: 'sqlly-credentials',
version: 1,
clients: [{ name: 'Acme', color: '#1E88E5' }],
environments: [{ name: 'Production', safety: { only_allow_select: true } }],
sql_auth_accounts: [{ name: 'Reader', username: 'acme_ro', password: secrets.acmeReader }],
connections: [
{ name: 'Orders', engine: 'postgres', server: 'pg1.internal', database: 'orders',
environment: 'production', client: 'Acme',
authentication: { type: 'sql-login', account: 'Reader' }, tls: { mode: 'verify-full' } },
],
},
});
- Profile ids are 1-64 of
A-Z a-z 0-9 _ -. Use your own stable key (a tenant id, a role name). - The bridge validates the whole document before saving. A bad one comes back as
400 invalid_profilewithproblemslisting every mistake. Unknown keys are errors, so typos never slip through. - The bridge is the store of record for secrets. Keep the source of truth wherever you like, but re-
PUTprofiles when it changes. The sample app re-sends every profile at startup, so a fresh state directory repopulates itself. - Use labels and safety flags.
only_allow_selecton a Production environment, orconfirm_data_changeson a connection, are enforced by SQLly for everyone who opens that profile.
Before saving, you can prove a connection works with POST /manage/test-connection and a document holding just that one connection. Failures come back as { "ok": false, "message": "<the engine's own error>" } with HTTP 200, ready to show next to the form.
4. Launch SQLly for a signed-in user
This is the one route every integration needs. When a signed-in user opens SQLly, mint a token for them and return the bundle's index.html with two globals set before the boot.js module tag:
import { readFile } from 'node:fs/promises';
const ISOLATION = {
'Cross-Origin-Opener-Policy': 'same-origin',
'Cross-Origin-Embedder-Policy': 'require-corp',
};
const BOOT_TAG = '<script type="module" src="./boot.js"></script>';
// JSON for an inline script: never let a value close the tag.
const js = (v) => JSON.stringify(v).replace(/</g, '\\u003c');
app.get('/sqlly/', requireSignIn, async (req, res) => {
const profile = profileFor(req.user); // your rule: role, team, tenant…
let minted;
try {
minted = await bridge('POST', '/manage/tokens', { user: req.user.id, profile });
} catch (err) {
if (err.code === 'seat_limit') return res.status(409).send('All SQLly seats are in use right now.');
if (err.code === 'license_not_ok') return res.status(503).send(err.message);
throw err;
}
const html = (await readFile('web/index.html', 'utf8')).replace(BOOT_TAG,
`<script>globalThis.SQLLY_PROXY_URL=${js(minted.proxyUrl)};` +
`globalThis.SQLLY_PROXY_TOKEN=${js(minted.token)};</script>` + BOOT_TAG);
res.set(ISOLATION).set('Cache-Control', 'no-store').type('html').send(html);
});
c# (asp.net core minimal api)
app.MapGet("/sqlly/", async (HttpContext http, IHttpClientFactory clients, IWebHostEnvironment env) =>
{
var user = http.User.FindFirst("sub")!.Value;
var bridge = clients.CreateClient("sqlly-bridge");
var reply = await bridge.PostAsJsonAsync("/manage/tokens", new { user, profile = ProfileFor(http.User) });
if (!reply.IsSuccessStatusCode)
{
var error = await reply.Content.ReadFromJsonAsync<BridgeError>();
return Results.Text(error?.Message ?? "SQLly is unavailable.", statusCode: (int)reply.StatusCode);
}
var minted = (await reply.Content.ReadFromJsonAsync<MintedToken>())!;
const string bootTag = "<script type=\"module\" src=\"./boot.js\"></script>";
// JsonSerializer escapes < > & by default, so the values can't close the tag.
var globals = $"<script>globalThis.SQLLY_PROXY_URL={JsonSerializer.Serialize(minted.ProxyUrl)};" +
$"globalThis.SQLLY_PROXY_TOKEN={JsonSerializer.Serialize(minted.Token)};</script>";
var html = (await File.ReadAllTextAsync(Path.Combine(env.ContentRootPath, "sqlly-web", "index.html")))
.Replace(bootTag, globals + bootTag);
http.Response.Headers["Cross-Origin-Opener-Policy"] = "same-origin";
http.Response.Headers["Cross-Origin-Embedder-Policy"] = "require-corp";
http.Response.Headers.CacheControl = "no-store";
return Results.Content(html, "text/html");
}).RequireAuthorization();
- Mint on the server, per request. Never put the manage token in a page. The launch token is safe to hand to the user it was minted for: it opens SQLly with that profile for that user only, and expires (10 minutes by default; pass
ttlSecondsbetween 10 and 86,400). - Reuse is fine. A token works until it expires, so page reloads don't need a new one. The sample app caches each user's token and mints again a minute before expiry. Minting again for the same user never costs another seat.
- The user id is yours. Pass a stable, opaque id. Seats count distinct ids, and the ids holding seats are reported to sqlly.app at check-in.
- If you can't edit the HTML, pass
?proxy=<url>&token=<token>on the page URL instead. Injecting is better, because the token stays out of history and logs. - Serve the page at a URL ending in
/(here/sqlly/) so the bundle's relative./boot.jsresolves to your copy ofweb/.
5. Serve the web bundle
Serve the files in web/ under the same path as the launch page (/sqlly/boot.js, /sqlly/sqlly_browser_bg.wasm, …), with:
Cross-Origin-Opener-Policy: same-originandCross-Origin-Embedder-Policy: require-corpon the page and every bundle file. SQLly needsSharedArrayBuffer, which browsers only enable on isolated pages.application/wasmas the type for.wasm. Compression helps a lot here: the wasm file shrinks several times with Brotli or gzip.- If your pages send a Content-Security-Policy:
'wasm-unsafe-eval'inscript-src, the bridge'swss://origin inconnect-src, and a nonce or hash on the inline globals script (or the query-parameter form above). - Cross-origin isolation applies to everything the page loads, so serve the bundle from your own origin rather than a third-party CDN without CORP headers.
The bundle's files are versioned with the package. When you upgrade, replace web/ and the bridge together.
6. Handle seats
409 seat_limiton mint means every seat is in use. Tell the user plainly and, for admins, offer to free one.GET /manage/seatslists who holds seats and since when.- Release on sign-out:
DELETE /manage/seats/{user}revokes the user's tokens and closes their SQLly tabs. Do the same when you remove someone's access. Otherwise their token keeps working until it expires. - An extra tab beyond
sessionsPerSeatis refused by the bridge itself (HTTP 429 on the WebSocket), and SQLly shows why.
7. Show license health
GET /manage/status has everything for an admin widget or a health check:
const s = await bridge('GET', '/manage/status');
// s.license.state: ok | grace | stale | not_admitted | expired | revoked | domain_mismatch | unlicensed
// s.license.message: the sentence your users see when something is wrong
// s.license.daysRemaining, s.license.planName, s.seatsUsed / s.license.seats
const healthy = ['ok', 'grace'].includes(s.license.state);
Alert on anything other than ok, and treat grace as "renew this week". POST /manage/license/checkin forces a check-in (or re-reads an offline file), which is useful for a Check in now button after fixing network access. What each state means.
8. Link to a license from your admin UI (optional)
You can configure the bridge with a key or license file and skip this. Linking is for products that install SQLly Embedded for many customers: each customer's admin connects their install to their own sqlly.app license with a few clicks, and no key is ever copied. Start the bridge with neither --license-key nor --license-file, then:
POST https://sqlly.app/api/embedded/linkswith a display name (e.g."SQLly Embedded on " + hostname). Keep the returnedsecretserver-side. Show the admin thecodeand send them tolinkUrl.- The admin signs in to sqlly.app, checks the code matches, picks a license and approves.
- Meanwhile, poll
POST https://sqlly.app/api/embedded/links/tokenwith{ linkId, secret }everypollIntervalSeconds. It answers202while pending, then200once withlicenseKey.410means start over (denied, expired or already used). - Hand the key straight to the bridge:
PUT /manage/license { "licenseKey": "…" }. The bridge verifies it with sqlly.app before storing it, keeps it across restarts, and the status showslicense.source: "linked". Don't store the key yourself. - To disconnect,
DELETE /manage/license: the bridge releases its slot, revokes the key on sqlly.app and goes back to unlicensed.
The sample app's lib/sqllyapp.js and its /admin/link routes implement exactly this.
Before you ship
- The manage token lives in server-side configuration and the management API is bound to loopback or a private network.
- Every launch is minted server-side for an authenticated user, with a profile your authorization rules chose.
- The SQLly page and bundle carry the isolation headers, and your CSP allows the bridge's
wss://origin. - Sign-out and access removal release the user's seat.
seat_limitandlicense_not_okshow a human message, not a stack trace.- Something watches
GET /manage/status. - You've read Going to production.