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

JobIn the sample appIn your app
Sign users inIts own users file with scrypt passwords (lib/auth.js)Your existing sign-in, SSO, sessions
Decide who sees which connectionsEach user is assigned one profileYour roles, teams, tenants or plans pick the profile id
Define connectionsProfile 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 SQLlyGET /app/ mints a token and injects it into index.htmlOne route that does the same (step 4)
Serve the web bundle/app/* with isolation headersYour static file server or CDN path with the same headers (step 5)
Free seatsUsers page, Release seatCall it on sign-out and when access is removed (step 6)
Show license healthStatus pageA health check or admin widget (step 7)
Link to a licenseConnect to sqlly.appOptional: 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_profile with problems listing 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-PUT profiles 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_select on a Production environment, or confirm_data_changes on 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:

javascript (express-style)
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 ttlSeconds between 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.js resolves to your copy of web/.

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-origin and Cross-Origin-Embedder-Policy: require-corp on the page and every bundle file. SQLly needs SharedArrayBuffer, which browsers only enable on isolated pages.
  • application/wasm as 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' in script-src, the bridge's wss:// origin in connect-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_limit on mint means every seat is in use. Tell the user plainly and, for admins, offer to free one. GET /manage/seats lists 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 sessionsPerSeat is 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:

  1. POST https://sqlly.app/api/embedded/links with a display name (e.g. "SQLly Embedded on " + hostname). Keep the returned secret server-side. Show the admin the code and send them to linkUrl.
  2. The admin signs in to sqlly.app, checks the code matches, picks a license and approves.
  3. Meanwhile, poll POST https://sqlly.app/api/embedded/links/token with { linkId, secret } every pollIntervalSeconds. It answers 202 while pending, then 200 once with licenseKey. 410 means start over (denied, expired or already used).
  4. 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 shows license.source: "linked". Don't store the key yourself.
  5. 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_limit and license_not_ok show a human message, not a stack trace.
  • Something watches GET /manage/status.
  • You've read Going to production.