@tanqory/app-bridge
v0.7.1
Published
Typed postMessage bridge between the Tanqory dashboard (host) and an embedded app rendered in an iframe (guest). Handles origin validation, request/response correlation, token handoff, and route mirroring.
Readme
@tanqory/app-bridge
Typed postMessage bridge between the Tanqory dashboard (host — owns the iframe and the session) and an embedded app (guest — served from its own origin).
Both halves validate event.origin against an explicit allow-list and post with an explicit target origin. Never '*'.
Why a code, not a token, in the iframe URL
The host mints a 30-second single-use authorization code (POST /auth/issue-code with an audience) and puts that in the iframe URL. The guest exchanges it once for an access token plus a refresh cookie scoped to its own origin, then strips ?code= from its URL.
An access token in a URL persists in browser history, Referer headers, and nginx access logs for its full lifetime. A code is dead in 30 seconds and after one use.
The exchange also leaves the guest self-sufficient: it can refresh off its own cookie, and it can be opened directly in a tab for debugging. A guest handed a raw token has neither.
Host
import { createBridgeHost, jwtExpiresAtMs } from '@tanqory/app-bridge'
const host = createBridgeHost({
getIframe: () => iframeRef.current,
allowedOrigins: [EMAIL_APP_ORIGIN, /^https:\/\/dev-email\.tanqory\.com$/],
handlers: {
onInit: () => ({ storeId, storeName, permissions, locale, currency, apiBaseUrl, ... }),
onTokenRequest: () => {
const token = getAccessToken()
return { token, expiresAt: jwtExpiresAtMs(token) ?? Date.now() + 60_000 }
},
onRouteChanged: ({ path }) => window.history.replaceState({}, '', `${base}${path}`),
onFullscreen: ({ enabled }) => setFullscreen(enabled),
onMediaPicker: (options, respond) => openPicker(options, respond),
onAuthExpired: () => recoverAuth(),
},
})
useEffect(() => host.start(), [host])
host.post('THEME_CHANGED', { theme })The host is the token authority. It holds the in-memory access token and rotates it off the httpOnly refresh cookie, so the guest pulls a fresh token per use rather than reusing whatever it got at boot. Derive expiresAt from the JWT's own exp — a hardcoded lifetime means the guest keeps using a token past its real expiry and every call 401s.
Guest
import { createBridgeGuest } from '@tanqory/app-bridge'
const guest = createBridgeGuest({ allowedOrigins: [DASHBOARD_ORIGIN] })
const stop = guest.start()
const ctx = await guest.init() // storeId, permissions, locale, apiBaseUrl, …
guest.ready() // host drops its skeleton
const { token } = await guest.requestToken()
guest.post('ROUTE_CHANGED', { path: '/builder' })
guest.on('THEME_CHANGED', ({ theme }) => setTheme(theme))guest.isEmbedded is false when the app is opened in a top-level tab; every method degrades cleanly so one build serves both.
apiBaseUrl must come from INIT. A store lives in one commerce cell, and the guest is served from a single central origin — it cannot derive the cell from its own hostname the way the dashboard does.
A guest must never navigate itself to a login page. That renders login inside a box. On auth failure: ask the host for a token, fall back to its own refresh cookie, then report AUTH_EXPIRED and let the host decide.
Embedding permission
An embeddable app must not send X-Frame-Options — it has no allow-list form, so SAMEORIGIN vetoes the CSP in any browser that still reads it. Pass frameAncestors to @tanqory/next-config for local dev; in production the ingress sets the header and strips the upstream one.
Messages
| Message | Direction | Purpose |
| ---------------------------------- | ------------ | --------------------------------------------- |
| READY | guest → host | Guest mounted; host drops the skeleton |
| INIT / INIT_RESPONSE | ↔ | Store, user, locale, currency, apiBaseUrl |
| TOKEN_REQUEST / TOKEN_RESPONSE | ↔ | Live access token + absolute expiry |
| AUTH_EXPIRED | guest → host | Guest exhausted every token route |
| ROUTE_CHANGED | guest → host | Host mirrors it into the address bar |
| POPSTATE | host → guest | Browser back/forward moved the host URL |
| NAVIGATE | guest → host | Leave the embedded app entirely |
| RESIZE | guest → host | Content height changed |
| FULLSCREEN | guest → host | Host portals the iframe to <body> |
| TOAST | guest → host | Rendered by the host so it isn't clipped |
| MEDIA_PICKER / _RESPONSE | ↔ | Host's picker; resolves when the user is done |
| UNSAVED_CHANGES | guest → host | Arms the host's navigation guard |
| THEME_CHANGED / LOCALE_CHANGED | host → guest | Keep the guest in sync |
