Data App Embedding
A data app is a React app compiled to a single HTML file and stored in
Postgres (definite.data_apps). Inside the Definite UI you open it from the
Apps list. External embedding lets you drop that same app into a page on
your own website — a customer dashboard, an internal wiki, a partner portal —
by pasting an <iframe>.
For the general app lifecycle, manifest fields, browser cache TTL, rebuilds, and permissions, see Data apps.
An embed is a named, revocable credential scoped to exactly one app. You can have several embeds per app (one per site, one per audience) and revoke any of them independently.
When to use it
- You want a Definite-built dashboard to show up on a site that is not the Definite UI.
- You want to hand a fixed, read-only view to people who do not have Definite logins.
- You need to revoke that access later without touching the app itself.
If the viewers are Definite users, just send them the in-app link instead — embedding adds a public credential you then have to manage.
Creating an embed
In the Definite UI:
- Open the app: Apps → your app.
- Go to the Embed tab on the app detail page.
- Enter a name (how you'll recognize this credential later, e.g.
marketing-site) and the allowed hosts — the sites permitted to frame the app (see Security model below). - Click Create.
The UI then shows you a ready-to-paste <iframe> snippet. The embed token is
generated server-side; you do not choose it. The full token and snippet are
shown only once, when the embed is created. After you leave or reload the page,
Definite lists the embed metadata but redacts the token. To rotate or recover a
lost token, create a new embed and revoke the old one.
The iframe snippet
The generated snippet looks like this:
<iframe
src="https://definite.your-company.com/api/v1/data-apps/revenue-explorer/embed?t=emb_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
style="width: 100%; height: 600px; border: 0;"
title="Revenue Explorer"
></iframe>
Paste it into the HTML of the page where the app should appear. The t query
parameter carries the embed token — keep the snippet as issued.
When the iframe loads, the API serves the app's compiled HTML directly from the Definite origin and injects a small bootstrap script ahead of the app's own module script. That script sets three globals the app reads to talk back to the API:
window.__DEFINITE_API_BASE— the API base path (default/api/v1)window.__DEFINITE_APP_SLUG— the app's slugwindow.__DEFINITE_TOKEN— the embed token
This external-embed path serves the app HTML directly from the Definite origin. It is deliberately different from the in-UI preview, which renders the app from a
blob:URL inside the Definite app. The embed path needs a real origin so the browser can apply the framing and header policy below.
Data scoping
By default an embed shows every row the app's SQL returns. Data scoping lets you narrow that down — so a customer sees only their own rows, or each signed-in end user sees only theirs — without forking the app or writing a per-tenant query.
The filter model
A filter is a single row-level condition:
{ "column": "tenant_id", "operator": "equals", "value": 42 }
column— the column to filter on. It must be declaredfilterablein the app manifest (see below).operator— one ofequals,not_equals,in,gte, orlte.value— a scalar forequals/not_equals/gte/lte; a non-empty list forin.
Multiple filters are ANDed together. There is no OR, no comparison
operator beyond the typed gte / lte bounds, and no free-text SQL — the
model is deliberately small so every filter is easy to reason about and safe
to compile. Range operators are available only for number and date
columns.
Declaring filterable columns
A resource can only be filtered on columns it explicitly opts in. Add a
filterable array to the resource in the app manifest, listing each column and
its type (string, number, boolean, or date):
{
"version": 2,
"resources": {
"orders": {
"kind": "dataset",
"source": { "type": "sql", "sql": "SELECT * FROM orders" },
"filterable": [
{ "column": "tenant_id", "type": "number" },
{ "column": "region", "type": "string" },
{ "column": "is_paid", "type": "boolean" },
{ "column": "created_on", "type": "date" }
]
}
}
}
A resource with no filterable array cannot be safely queried by a scoped
embed. When an embed or view token has filters, every resource the app queries
must declare every filtered column in its own filterable list. If it does not,
the query fails closed instead of running unfiltered. The declared type is used
to validate filter values up front: a number column rejects a non-numeric
value, a boolean column rejects anything that isn't a real boolean.
Date values must use the strict YYYY-MM-DD form.
Fixed-filter embeds
When you create an embed in the UI you can attach a fixed set of filters to it. Every viewer of that embed sees the same scoped data — useful when one embed belongs to one customer. On the Embed tab, after entering the name and allowed hosts, add one or more filters (column, operator, value). The filters are stored on the embed and applied to every query the embed token authorizes; they cannot be changed by the page hosting the iframe.
Per-end-user minted tokens
When a single embed serves many end users — each of whom should see only their own rows — your backend mints a short-lived per-user token from the durable embed token. Call:
POST /api/v1/data-apps/{slug}/embed/token
from your server (never the browser), authorized by the emb_ embed token,
with the filters for the user you're rendering the page for:
curl -X POST https://definite.your-company.com/api/v1/data-apps/revenue-explorer/embed/token \
-H "Authorization: Bearer emb_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"filters": [
{ "column": "tenant_id", "operator": "equals", "value": 42 }
]
}'
The response carries a short-lived emv_ token. Hand that token to the iframe
(in place of the emb_ token) — it expires in minutes, so mint a fresh one per
page render and never embed the durable emb_ token in a public page.
A minted token can only narrow. The effective filters for an emv_ token
are the parent embed's fixed filters AND the filters supplied at mint time
— never a replacement. A view cannot widen access beyond what its parent embed
already allows; passing a filter on a column the embed already constrains can
only further restrict the rows, never reveal more.
Security note
Filter values are never concatenated raw into SQL. Each value is compiled to a
safe SQL literal — strings are single-quoted with every inner quote
doubled, numbers are validated as numeric, booleans render as TRUE/FALSE —
so a hostile value like '); DROP TABLE users;-- stays fully contained inside
one quoted literal and cannot break out. Columns are allowlisted to the
manifest's filterable set, so a filter can never reference a column the app
author didn't expose. A scoped query also fails if the specific resource being
queried does not declare every filtered column. The scoped query the lakehouse
runs is the resource SQL wrapped as:
SELECT * FROM ( <resource sql> ) AS _embed_scoped WHERE <compiled predicate>
Endpoints
| Method & path | Purpose |
|---|---|
POST /api/v1/data-apps/{slug}/embeds | Create a named embed for an app |
GET /api/v1/data-apps/{slug}/embeds | List an app's embeds |
DELETE /api/v1/data-apps/{slug}/embeds/{embed_id} | Revoke an embed |
GET /api/v1/data-apps/{slug}/embed?t=<token> | Public — serve the embeddable HTML |
POST /api/v1/data-apps/{slug}/embed/token | Mint a short-lived per-end-user (emv_) token from an embed token |
POST /api/v1/data-apps/{slug}/query | Read-only query, authorized by the embed or minted token |
The first three are management endpoints for editors/admins who have edit access
to the app. GET .../embeds returns metadata only; full tokens and iframe
snippets are redacted after creation. The rest are the runtime path the iframe,
the app inside it, and your token-minting backend use.
Security model
Allowed hosts and frame-ancestors
Each embed carries an allowed_hosts list. The public embed response sets a
Content-Security-Policy header whose frame-ancestors directive is built
from that list:
- Empty list →
frame-ancestors *. Any site may frame the app. Convenient for testing; not recommended for production. - Non-empty list → the hosts space-joined, e.g.
frame-ancestors https://app.example.com *.example.com. Only those origins may frame the app; every other site gets a blank frame.
Accepted host forms: *, a hostname (example.com, app.example.com), a
wildcard subdomain (*.example.com), an optional scheme
(https://example.com), and an optional port (example.com:8443). Entries
containing whitespace, ;, ,, quotes, backticks, or newlines are rejected so
a bad value cannot break out of the CSP header.
Response headers
The public GET .../embed response is locked down:
| Header | Value |
|---|---|
Content-Security-Policy | frame-ancestors <allowed hosts or *> |
Referrer-Policy | no-referrer |
Cache-Control | private, no-store |
X-Content-Type-Options | nosniff |
Token scope
The embed token authorizes exactly one thing beyond loading the HTML:
read-only POST /api/v1/data-apps/{slug}/query for the one app the
embed belongs to. It cannot query other apps, cannot write, and cannot reach
any other API surface. It is not a user session.
Durable and view tokens are stored hashed in the Definite application database.
The plaintext token is only available to the creator response or the mint-token
response, so treat that response like a secret and store it in your own secret
manager if your backend needs to mint emv_ tokens later.
Capacity and backpressure at embed scale
Embedded apps run their resource queries on the deployment's shared lakehouse
engine — the same engine serving syncs, transformations, and in-UI queries.
Admission to that engine is bounded: when a query can't get a slot within
lakehouse.maxConcurrencyWaitSeconds, POST .../query fast-fails with
HTTP 503 + Retry-After instead of queueing unboundedly. A 503 here means
transient backpressure, not a broken query — the bundled app runtime retries
it automatically (up to 3 attempts, honoring Retry-After, capped at 10s per
wait) before surfacing an error. Brief overlaps between a transform window and
dashboard traffic self-heal; persistent errors under embed load mean the
deployment needs more capacity. Custom clients that call /query directly
should implement the same retry-on-503 behavior.
Each response is also bounded by the workspace client-side row and byte limits
(200,000 rows and 100 MiB by default), with a hard 100 MiB data-app Arrow
ceiling. An over-limit resource returns HTTP 413 with structured
data_app_query_result_too_large detail. This is not transient backpressure:
clients should not retry the same query unchanged. Apply narrower scoped
filters or redesign the app so export-sized data is requested on demand.
The runtime is compiled into each app's HTML bundle, so apps built before the
retry shipped keep their old behavior until rebuilt
(POST /api/v1/data-apps/{slug}/rebuild, or re-save the app from source).
App metadata includes the runtime version/fingerprint recorded at build time
and runtime_stale when it differs from the deployment's current template.
Use GET /api/v1/data-apps?stale=true to list stale apps, or have an admin
call POST /api/v1/admin/data-apps/rebuild-all to rebuild every app with
stored source files against the current runtime. Apps without stored source are
reported as skipped and must be re-uploaded from source.
Migration or port tooling should target the deployment's current server-side
template runtime (DATA_APP_TEMPLATE_DIR, /opt/data-apps-template by
default) instead of copying a local runtime/ snapshot that can drift behind
the installed deployment.
Several server-side effects soften the load before sizing matters:
- Query results are cached for ~60 s keyed by
(app, resource, filters), so N viewers of the same embed with the same scoping share one lakehouse query per resource per minute. Per-end-user minted tokens with distinct filters each miss that cache. - Concurrent misses for the same cache key share one in-flight materialization. The server cache is capped at 128 entries and 128 MiB, and expired entries are evicted during lookup.
- Only cache misses consume lakehouse slots; an embed page-load fires one
/queryper resource, concurrently. - Data-app response delivery has a separate memory semaphore. Requests wait up
to
api.dataApp.deliveryAdmissionWaitSeconds(60 s by default) behind that bounded queue before returning a retryable 503. The wait is asynchronous, so queued resources do not occupy FastAPI worker threads. This is distinct from the lakehouse admission wait below.
This server-side embed cache is separate from the in-UI browser cache. Data apps
opened inside Definite cache resource payloads in IndexedDB for 24 hours by
default; app authors can set top-level cache_ttl_hours in app.json, or a
per-resource resources.<key>.cache_ttl_hours, to tune that browser TTL.
External embeds do not use that browser IndexedDB cache.
Sizing the lakehouse for embed traffic
The knobs live under lakehouse: in the Helm values (defaults in
parentheses):
| Knob | Default | What it bounds |
|---|---|---|
lakehouse.readerPoolSize | 4 | Pooled DuckDB reader connections — the real read-parallelism ceiling |
lakehouse.maxConcurrency | 8 | Outer admission bound on in-flight lakehouse ops |
lakehouse.maxConcurrencyWaitSeconds | 5 | How long an over-limit query waits for a slot before the 503 |
lakehouse.busyRetryAfterSeconds | 2 | The Retry-After value advertised on that 503 |
api.dataApp.deliveryMaxConcurrency | 2 | In-flight Arrow responses retained through ASGI delivery |
api.dataApp.deliveryAdmissionWaitSeconds | 60 | How long a board resource waits for a delivery-memory slot before a 503 |
Rules of thumb:
- Estimate peak concurrent cache-missing queries: roughly
(dashboards opened per minute) × (resources per app)for fixed-filter embeds, higher when per-end-user filters defeat the result cache. - A handful of concurrently open dashboards fits the defaults. For ~10+
production embeds with steady viewer traffic, raise
readerPoolSizeto 6–8 andmaxConcurrencyproportionally (it should cover the reader pool plus a writer permit; the client enforces that floor automatically). - Keep
maxConcurrencywell under the API's worker threadpool size (~40).
Prerequisite — catalog Postgres max_connections. Each pooled reader
holds one direct connection to the DuckLake catalog Postgres for its lifetime
(plus the writer, sync workers, the app-DB pool, and any worker-tier pods on a
shared instance). The default budget is sized for a small catalog tier
(max_connections=50): 4 readers is safe there, but raising the pool without
raising max_connections trades 503s for hard too many clients 500s. Bump
the catalog tier first (e.g. Cloud SQL db-custom-1-3840 →
max_connections=150 gives headroom for 8 readers), then raise the knobs. The
comments above each knob in helm/values.yaml carry the full connection
budget.
Per-class admission (interactive embed traffic vs background transform/sync work) is not implemented today: all lakehouse work shares one admission bound, so a heavy transform window can briefly consume every slot. The retry path above is what absorbs that overlap.
Revoking an embed
On the Embed tab, delete the embed (or call
DELETE /api/v1/data-apps/{slug}/embeds/{embed_id}). Revocation is immediate
and per-credential — other embeds for the same app keep working.
After revocation:
- New iframe loads fail:
GET .../embed?t=<token>no longer returns the app. - The revoked token can no longer authorize
/query, so an already-loaded iframe stops being able to fetch data and shows whatever error state the app renders for a failed query. - Already-rendered pixels in a live iframe are not magically erased, but the app is dead the moment it next calls the API. To force every viewer off immediately, revoke the embed; to also blank the frame, take the app down or rotate to a fresh embed.
Revoking an embed never deletes or changes the underlying app.