@declarion/embed
v0.12.4
Published
Host integration SDK for embedding Declarion screens as white-label iframes.
Readme
@declarion/embed
Host integration SDK for embedding Declarion screens as white-label iframes in a third-party app.
A Declarion screen route renders without Declarion shell chrome when loaded
with ?embed=1. The host app owns the outer shell, navigation, and identity;
Declarion owns screen rendering, data access, and tenant isolation. This
package is the typed integration layer for both sides:
- Browser core (
@declarion/embed) - dependency-free. Creates the iframe, runs the auth handshake, auto-resizes, mirrors navigation. - React binding (
@declarion/embed/react) -<DeclarionEmbed />. - Server helper (
@declarion/embed/server) -createEmbedSession, the Node-side mint call. Keeps thedk:API key out of browser code.
The host installs only @declarion/embed; it does not depend on
@declarion/react (the full Declarion UI SDK).
Install
npm install @declarion/embedreact and react-dom are optional peer dependencies, required only by the
@declarion/embed/react entry.
How embedding works
- The host backend mints a short-lived, scoped token for a target tenant and
user with
createEmbedSession(@declarion/embed/server), which calls theauth.create_embed_sessionaction with a server-helddk:API key. - The host frontend embeds a screen with
createDeclarionEmbedor<DeclarionEmbed />, passing agetTokencallback that fetches a token from the host backend. - The SDK runs a
postMessagehandshake: the iframe asks for a token, the SDK delivers it, and refreshes it whenever the iframe reports expiry.
The dk: API key never leaves the host backend. For multi-tenant hosts, use a
least-privilege dk:_global key whose service-account owner holds only
action:auth.create_embed_session; tenant-scoped keys can mint only in their
own tenant.
Token model and the persistent iframe
The embed token is minted per (user, tenant) and carries that user's full
effective permissions. ONE token serves every screen the user can open - it is
NOT per screen. Mint it once per identity and reuse it; getToken should be
bound to the identity, not the current screen.
Switching screens is fast because the iframe is persistent: changing the
route (the route prop, or handle.navigate(route)) posts a soft
SPA-navigation to the existing iframe. The app keeps its loaded schema and
session - it does NOT reload or re-fetch the schema per screen. The first load
fetches the schema once; later screen switches are frontend-speed.
Changing the embedded identity (a different user or tenant) MUST rebuild
the iframe, because the in-iframe schema cache is authority-shaped. With the
React binding, pass a stable identityKey (e.g. `${tenant}|${user}`);
changing it rebuilds the iframe and re-mints. With the core, create a new embed
for the new identity. Per-screen access is enforced inside the iframe at
navigate time, never by narrowing the token.
Quickstart: browser core
import { createDeclarionEmbed } from "@declarion/embed";
const embed = createDeclarionEmbed({
container: document.getElementById("declarion")!,
declarionOrigin: "https://app.example.com",
route: "/cases",
getToken: async () => {
const res = await fetch("/host-api/embed-token");
return res.json(); // { token, expires_at }
},
onError: (err) => console.error(err.code, err.message),
});
// Later: embed.navigate("/cases/42"); embed.setTheme("dark"); embed.destroy();Quickstart: React
import { DeclarionEmbed } from "@declarion/embed/react";
export function CasesPanel({ tenant, user, route }) {
return (
<DeclarionEmbed
declarionOrigin="https://app.example.com"
route={route}
// Stable per identity. Changing `route` soft-navigates the SAME iframe;
// changing `identityKey` rebuilds it (a different user must not reuse the
// prior user's authority-shaped schema).
identityKey={`${tenant}|${user}`}
getToken={async () => (await fetch("/host-api/embed-token")).json()}
onError={(err) => console.error(err.code, err.message)}
/>
);
}Quickstart: server helper (Node)
import { createEmbedSession } from "@declarion/embed/server";
// In a host backend route. The dk: API key is read from server config.
const session = await createEmbedSession({
declarionOrigin: "https://app.example.com",
// Prefer a least-privilege dk:_global key whose owner holds only
// action:auth.create_embed_session. Keep it server-side only.
apiKey: process.env.DECLARION_EMBED_API_KEY!,
tenantCode: "acme", // target tenant
userEmail: "[email protected]",
screenCode: "cases_list",
});
// Return `session` ({ token, expires_at }) to the host frontend's getToken.Hosts on a non-Node backend call the
POST /api/actions/auth.create_embed_session action over HTTP directly with
the same dk: API key in the Authorization header.
Script tag (no build step)
The package also ships an IIFE bundle of the core. Load it and use
window.DeclarionEmbed.createDeclarionEmbed:
<script src="https://unpkg.com/@declarion/embed/dist/declarion-embed.iife.js"></script>
<script>
DeclarionEmbed.createDeclarionEmbed({ /* options */ });
</script>Navigation modes
navigation: "self"(default) - the iframe routes EVERY in-frame link internally (the token is identity-scoped, so the iframe is already authorized to open anywhere the user can go) and emitsonNavigate({ mode: "self", route, screenCode, ... })so the host can mirror its URL.navigation: "delegated"- the iframe never moves on an in-frame link; it emitsonNavigate({ mode: "delegated", route, screenCode, ... })and the host decides what to open (drive it back via therouteprop /handle.navigate). Use this to pin the embed to a single screen.
The onNavigate event carries { mode, route, screenCode?, entity?, recordId? }.
A host-driven navigate (the route prop changing, or handle.navigate) is a
deep-link command: it MOVES the iframe in BOTH modes (it is the host asking),
as a soft SPA navigation - only in-frame LINKS are intercepted/delegated. The
same token authorizes every screen, so no re-mint is needed to switch.
Navigation failures
When an in-frame navigation cannot be handled - an unknown route, or a 404 -
the iframe stays put and emits onNavigationFailed({ route, reason })
(reason is not_found or unresolved). The host owns recovery: show its own
not-found, or reset its sidebar selection. Never break the frame.
createDeclarionEmbed({
// ...
onNavigationFailed({ route, reason }) {
hostRouter.showNotFound(route, reason);
},
});Iframe sandbox
The SDK sets a sandbox on the iframe by default that runs the full app but
omits the top-navigation tokens, so the framed app cannot navigate the host's
top window. Widen it with the extraSandbox option if needed - the
top-navigation tokens are stripped from it and can never be re-added. Keep your
CSP frame-ancestors configured to control who may frame the app.
Unsaved changes
A Declarion screen can hold unsaved edits. When the host navigates the iframe
away from such a screen - via its own menu, or handle.navigate() - it should
confirm with the user first. The iframe reports its edit state through the
onEvent callback:
let embedDirty = false;
createDeclarionEmbed({
// ...
onEvent(event) {
if (event.type === "dirty-changed") embedDirty = event.payload.dirty;
},
});
// Before the host moves the iframe away:
if (embedDirty && !confirm("Discard unsaved changes?")) return;Navigation inside the iframe (a row click, a Back button) AND a host-driven
route change / handle.navigate are both already guarded by Declarion: the
iframe shows its own confirm dialog before discarding edits. The dirty-changed
event is for host actions the iframe CANNOT intercept - the host leaving the
embed via its own page navigation, or replacing the embed on an identity
switch. Use it to guard those.
Staying current while embedded
When the Declarion deployment is redeployed while a screen is embedded, the
iframe does NOT show Declarion's own reload chrome (that would be wrong inside a
white-label host). Instead it asks the host to reload it via a reload-required
event (surfaced through onError with code reload-required and through
onEvent). The host decides: reload the iframe, show its own banner, or ignore.
The persistent iframe otherwise keeps its loaded schema across screen switches
and only re-validates it on a real version change or SSE session refresh.
Diagnostics
A misconfiguration is surfaced loudly through onError (a typed
EmbedError with a stable code) and console.error. Untrusted
cross-origin postMessage frames are dropped silently. The SDK detects:
invalid-options- a required option is missing or malformed.get-token-failed-getTokenrejected or returned a malformed result.handshake-timeout- no handshake within the post-load window, usually adeclarionOriginmismatch or framing denied by the Declarion CSP (DECLARION_FRAME_ANCESTORSdoes not list the host origin).reload-required- the iframe asked the host to reload it.
A protocol version mismatch between this SDK and the Declarion deployment
produces a clear console.warn naming both versions.
Putting it together
A real integration is two halves, and the snippets above are the whole contract:
- Host frontend - renders
<DeclarionEmbed />(React) or callscreateDeclarionEmbed(any framework) and supplies the asyncgetTokencallback. The SDK owns the iframe, theready->set-tokenhandshake, resize, navigation, and token refresh. - Host backend - holds the
dk:API key (server-side only) and mints short-lived, scoped embed tokens withcreateEmbedSessionfrom@declarion/embed/server. YourgetTokencallback fetches one from your own backend endpoint.
The dk: key never leaves your server; the browser only ever holds a
short-lived, scoped embed token. That split is the entire security model -
keep @declarion/embed/server out of any browser bundle.
License
MIT
