@lympid/embed 0.0.0-stage → 1.0.0-rc.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +18 -0
- package/README.md +209 -2
- package/dist/browser/lympid-embed.js +319 -0
- package/dist/browser/lympid-embed.js.map +7 -0
- package/dist/browser/lympid-embed.min.js +2 -0
- package/dist/browser/lympid-embed.min.js.map +7 -0
- package/dist/cjs/bridge.d.ts +31 -0
- package/dist/cjs/bridge.js +45 -0
- package/dist/cjs/embed.d.ts +8 -0
- package/dist/cjs/embed.js +154 -0
- package/dist/cjs/index.d.ts +25 -0
- package/dist/cjs/index.js +28 -0
- package/dist/cjs/modal.d.ts +30 -0
- package/dist/cjs/modal.js +159 -0
- package/dist/cjs/protocol.d.ts +71 -0
- package/dist/cjs/protocol.js +30 -0
- package/dist/cjs/types.d.ts +150 -0
- package/dist/cjs/types.js +19 -0
- package/dist/esm/bridge.js +42 -0
- package/dist/esm/embed.js +151 -0
- package/dist/esm/index.js +24 -0
- package/dist/esm/modal.js +156 -0
- package/dist/esm/package.json +3 -0
- package/dist/esm/protocol.js +26 -0
- package/dist/esm/types.js +18 -0
- package/package.json +59 -4
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../cjs/protocol.js", "../cjs/bridge.js", "../cjs/modal.js", "../cjs/embed.js", "../cjs/index.js"],
|
|
4
|
+
"sourcesContent": ["\"use strict\";\nObject.defineProperty(exports, \"__esModule\", { value: true });\nexports.WIDGET_TO_HOST_SOURCE = exports.HOST_TO_WIDGET_SOURCE = void 0;\nexports.isWidgetToHostMessage = isWidgetToHostMessage;\n/**\n * `postMessage` wire protocol between the host page (this SDK) and the\n * widget iframe (the whitelabel app running in embed mode \u2014 see\n * `whitelabel-embed-mode` in the Phase 1 plan).\n *\n * Both directions are only ever accepted/sent against the exact origin\n * configured via `InitOptions.baseUrl` \u2014 see `createBridge()` in\n * `./bridge.ts`. This file only defines the message *shapes*; it has\n * zero DOM/`window` dependencies so it can be unit-tested directly.\n */\n/** Discriminator so a `postMessage` listener can cheaply ignore every\n * unrelated message landing on the same `window` (browser extensions,\n * other SDKs, etc.) before even checking the origin. */\nexports.HOST_TO_WIDGET_SOURCE = 'lympid-embed-host';\nexports.WIDGET_TO_HOST_SOURCE = 'lympid-embed-widget';\n/** Narrows an arbitrary `MessageEvent.data` payload to a\n * `WidgetToHostMessage`, without throwing on malformed input \u2014 a\n * `postMessage` listener must never crash the host page just because\n * some unrelated script on the same window posted something shaped\n * differently. */\nfunction isWidgetToHostMessage(data) {\n return (typeof data === 'object' &&\n data !== null &&\n data.source === exports.WIDGET_TO_HOST_SOURCE &&\n typeof data.type === 'string');\n}\n", "\"use strict\";\nObject.defineProperty(exports, \"__esModule\", { value: true });\nexports.createBridge = createBridge;\nconst protocol_1 = require(\"./protocol\");\n/**\n * @param iframe The widget iframe this bridge talks to.\n * @param targetOrigin The exact origin the iframe was loaded from\n * (derived from `InitOptions.baseUrl`, never inferred from the\n * iframe's `src` string, which a compromised/misconfigured iframe\n * could have redirected away from).\n * @param onMessage Called for every well-formed `WidgetToHostMessage`\n * received from exactly `targetOrigin`. Messages from any other\n * origin, or that don't match the `WidgetToHostMessage` shape, are\n * silently dropped.\n */\nfunction createBridge(iframe, targetOrigin, onMessage) {\n const listener = (domEvent) => {\n // Origin check FIRST, before touching `data` at all \u2014 this is the\n // entire security boundary of the bridge. A mismatched origin is\n // dropped unconditionally, regardless of how well-formed the\n // payload looks.\n if (domEvent.origin !== targetOrigin) {\n return;\n }\n if (domEvent.source !== iframe.contentWindow) {\n return;\n }\n if (!(0, protocol_1.isWidgetToHostMessage)(domEvent.data)) {\n return;\n }\n onMessage(domEvent.data);\n };\n window.addEventListener('message', listener);\n return {\n send: (message) => {\n if (!iframe.contentWindow) {\n return;\n }\n iframe.contentWindow.postMessage({ ...message, source: protocol_1.HOST_TO_WIDGET_SOURCE }, targetOrigin);\n },\n teardown: () => {\n window.removeEventListener('message', listener);\n },\n };\n}\n", "\"use strict\";\n/**\n * Modal overlay + iframe DOM construction for the widget.\n *\n * Deliberately framework-agnostic (no React) and dependency-free \u2014 a\n * handful of inline-styled `div`/`iframe` nodes is simpler, smaller, and\n * safer than reaching for a UI library inside a script a partner embeds\n * on their own page (no risk of CSS/class-name collisions with the\n * partner's own stylesheet).\n */\nObject.defineProperty(exports, \"__esModule\", { value: true });\nexports.createModal = createModal;\nconst Z_INDEX = 2147483000; // Comfortably above virtually any host-page UI.\nconst DEFAULT_HEIGHT_PX = 600;\n/** Light/dark panel background, matching the whitelabel app's own\n * `LEDGER_PAPER` surface token (`packages/shared-types/src/\n * design-tokens.ts`) so the modal's own chrome doesn't flash a white\n * box around a dark-themed widget. The host page has no visibility\n * into the iframe's actual in-app theme setting (cross-origin, and the\n * whitelabel app can toggle independently of OS preference), so this\n * follows the OS-level `prefers-color-scheme` media query as the best\n * available signal without a cross-origin theme-sync protocol. */\nconst PANEL_BACKGROUND_LIGHT = '#ffffff';\nconst PANEL_BACKGROUND_DARK = '#161B22';\nfunction prefersDarkColorScheme() {\n return (typeof window !== 'undefined' &&\n typeof window.matchMedia === 'function' &&\n window.matchMedia('(prefers-color-scheme: dark)').matches);\n}\n/** Close glyph centered in the 32px button. Path matches\n * `apps/whitelabel/src/assets/images/svg/24/close.svg`. */\nfunction createCloseIcon() {\n const icon = document.createElementNS('http://www.w3.org/2000/svg', 'svg');\n icon.setAttribute('viewBox', '0 0 24 24');\n icon.setAttribute('width', '20');\n icon.setAttribute('height', '20');\n icon.setAttribute('fill', 'none');\n icon.setAttribute('aria-hidden', 'true');\n const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');\n path.setAttribute('d', 'M17 7 7 17M7 7l10 10');\n path.setAttribute('stroke', 'currentColor');\n path.setAttribute('stroke-width', '1.3');\n path.setAttribute('stroke-linecap', 'round');\n path.setAttribute('stroke-linejoin', 'round');\n icon.appendChild(path);\n return icon;\n}\n/**\n * Builds and appends the backdrop + iframe modal to `container`.\n *\n * @param container Parent node to append the overlay to.\n * @param src The widget iframe's `src` URL (the partner's whitelabel\n * origin + `/embed` route + flow query param).\n * @param onBackdropClick Called when the end user clicks the backdrop\n * or the close button \u2014 the caller decides whether that closes the\n * modal (it always should; kept as a callback so `embed.ts` stays\n * the single owner of close/`onClose` semantics).\n */\nfunction createModal(container, src, onBackdropClick) {\n const overlay = document.createElement('div');\n overlay.setAttribute('data-lympid-embed', 'overlay');\n overlay.style.position = 'fixed';\n overlay.style.inset = '0';\n overlay.style.zIndex = String(Z_INDEX);\n overlay.style.display = 'flex';\n overlay.style.alignItems = 'center';\n overlay.style.justifyContent = 'center';\n overlay.style.backgroundColor = 'rgba(15, 23, 42, 0.6)';\n const panel = document.createElement('div');\n panel.setAttribute('data-lympid-embed', 'panel');\n panel.style.position = 'relative';\n panel.style.width = 'min(420px, calc(100vw - 32px))';\n panel.style.height = `min(${DEFAULT_HEIGHT_PX}px, calc(100vh - 32px))`;\n panel.style.maxHeight = 'calc(100vh - 32px)';\n panel.style.borderRadius = '16px';\n panel.style.overflow = 'hidden';\n panel.style.boxShadow = '0 20px 60px rgba(0, 0, 0, 0.35)';\n panel.style.backgroundColor = prefersDarkColorScheme()\n ? PANEL_BACKGROUND_DARK\n : PANEL_BACKGROUND_LIGHT;\n panel.style.transition = 'height 0.2s ease';\n // Keep the panel's background in step if the OS-level preference\n // changes while the modal is open (e.g. a scheduled light/dark\n // switch, or the user flips it in their OS settings mid-session).\n let colorSchemeMedia = null;\n let handleColorSchemeChange = null;\n if (typeof window !== 'undefined' && typeof window.matchMedia === 'function') {\n colorSchemeMedia = window.matchMedia('(prefers-color-scheme: dark)');\n handleColorSchemeChange = (event) => {\n panel.style.backgroundColor = event.matches\n ? PANEL_BACKGROUND_DARK\n : PANEL_BACKGROUND_LIGHT;\n };\n colorSchemeMedia.addEventListener('change', handleColorSchemeChange);\n }\n const closeButton = document.createElement('button');\n closeButton.setAttribute('data-lympid-embed', 'close');\n closeButton.setAttribute('aria-label', 'Close');\n closeButton.type = 'button';\n closeButton.style.position = 'absolute';\n closeButton.style.top = '8px';\n closeButton.style.right = '8px';\n closeButton.style.zIndex = String(Z_INDEX + 1);\n closeButton.style.display = 'flex';\n closeButton.style.alignItems = 'center';\n closeButton.style.justifyContent = 'center';\n closeButton.style.width = '32px';\n closeButton.style.height = '32px';\n closeButton.style.margin = '0';\n closeButton.style.padding = '0';\n closeButton.style.border = 'none';\n closeButton.style.borderRadius = '50%';\n closeButton.style.backgroundColor = 'rgba(15, 23, 42, 0.06)';\n closeButton.style.color = '#585858';\n closeButton.style.cursor = 'pointer';\n closeButton.style.appearance = 'none';\n closeButton.style.setProperty('-webkit-appearance', 'none');\n // Same stroke as the whitelabel `Icon` named `close`\n // (`src/assets/images/svg/24/close.svg`), which every other modal\n // uses. A text \"\u00D7\" sits on the font baseline, so it looks high, and\n // a host `button { padding }` rule shifts it further. Flex + a reset\n // padding keeps the glyph centered on the partner page.\n closeButton.appendChild(createCloseIcon());\n const iframe = document.createElement('iframe');\n iframe.setAttribute('data-lympid-embed', 'iframe');\n iframe.src = src;\n iframe.style.width = '100%';\n iframe.style.height = '100%';\n iframe.style.border = 'none';\n iframe.title = 'Lympid';\n // Phase 1 flows (connect/sign-in, KYC) need neither top-level\n // navigation nor same-origin escalation beyond what the whitelabel\n // app itself needs to run (forms, scripts, camera for KYC).\n iframe.setAttribute('allow', 'camera; microphone; clipboard-write');\n iframe.setAttribute('sandbox', 'allow-scripts allow-same-origin allow-forms allow-popups');\n closeButton.addEventListener('click', onBackdropClick);\n overlay.addEventListener('click', (domEvent) => {\n if (domEvent.target === overlay) {\n onBackdropClick();\n }\n });\n panel.appendChild(closeButton);\n panel.appendChild(iframe);\n overlay.appendChild(panel);\n container.appendChild(overlay);\n return {\n iframe,\n setHeight: (height) => {\n const clamped = Math.max(240, Math.min(height, (typeof window !== 'undefined' ? window.innerHeight : height) - 32));\n panel.style.height = `${clamped}px`;\n },\n remove: () => {\n if (colorSchemeMedia && handleColorSchemeChange) {\n colorSchemeMedia.removeEventListener('change', handleColorSchemeChange);\n }\n overlay.remove();\n },\n };\n}\n", "\"use strict\";\nObject.defineProperty(exports, \"__esModule\", { value: true });\nexports.init = init;\nconst bridge_1 = require(\"./bridge\");\nconst modal_1 = require(\"./modal\");\nconst protocol_1 = require(\"./protocol\");\n/** Normalises a configured `baseUrl` into a bare origin string\n * (`\"https://host:port\"`, no path/trailing slash) suitable both for\n * building the iframe `src` and for `postMessage` origin checks. Throws\n * early \u2014 at `init()` time, not at `open()` time \u2014 on a malformed URL,\n * since a bad `baseUrl` is a partner-integration mistake that should\n * fail loudly immediately rather than silently drop every widget\n * message later. */\nfunction normalizeOrigin(baseUrl) {\n const url = new URL(baseUrl);\n return url.origin;\n}\nfunction buildIframeSrc(origin, flow, partnerId, rwaId) {\n const params = new URLSearchParams({ flow });\n if (partnerId) {\n params.set('partnerId', partnerId);\n }\n if (rwaId) {\n params.set('rwaId', rwaId);\n }\n return `${origin}/embed?${params.toString()}`;\n}\n/**\n * Creates a `LympidEmbedInstance` bound to one partner's whitelabel\n * origin. See `InitOptions`/`OpenOptions` in `./types.ts` for the full\n * contract, and `apps/whitelabel/src/app/embed/` (whitelabel-embed-mode)\n * for the counterpart that runs inside the iframe.\n */\nfunction init(options) {\n const origin = normalizeOrigin(options.baseUrl);\n const container = options.container ?? document.body;\n let current = null;\n let destroyed = false;\n /** Tears down the currently-open modal/bridge, if any, optionally\n * firing the `onClose` callback that was passed to the `open()` call\n * being torn down. */\n function teardownCurrent(fireOnClose) {\n if (!current) {\n return;\n }\n const { modal, bridge, onClose } = current;\n bridge.teardown();\n modal.remove();\n current = null;\n if (fireOnClose && onClose) {\n onClose();\n }\n }\n function open(openOptions) {\n if (destroyed) {\n throw new Error('This LympidEmbedInstance has been destroyed and cannot be reused \u2014 call init() again.');\n }\n // Replace any already-open modal from this instance first \u2014 only\n // one widget flow can sensibly be open at a time per instance.\n teardownCurrent(true);\n const src = buildIframeSrc(origin, openOptions.flow, options.partnerId, openOptions.rwaId);\n const modal = (0, modal_1.createModal)(container, src, () => teardownCurrent(true));\n const bridge = (0, bridge_1.createBridge)(modal.iframe, origin, (message) => {\n switch (message.type) {\n case 'ready': {\n bridge.send({\n source: protocol_1.HOST_TO_WIDGET_SOURCE,\n type: 'init',\n flow: openOptions.flow,\n hasSessionCodeProvider: !!openOptions.getSessionCode,\n partnerId: options.partnerId,\n rwaId: openOptions.rwaId,\n });\n break;\n }\n case 'request-session-code': {\n void handleSessionCodeRequest(bridge, openOptions, message.requestId);\n break;\n }\n case 'resize': {\n modal.setHeight(message.height);\n break;\n }\n case 'event': {\n openOptions.onEvent?.(message.event);\n break;\n }\n case 'close': {\n teardownCurrent(true);\n break;\n }\n default:\n break;\n }\n });\n current = { modal, bridge, onClose: openOptions.onClose };\n }\n function close() {\n teardownCurrent(true);\n }\n function destroy() {\n teardownCurrent(true);\n destroyed = true;\n }\n return { open, close, destroy };\n}\n/**\n * Resolves `openOptions.getSessionCode()` (if configured) and relays\n * the result back to the widget as `session-code`/`session-code-error`.\n * Extracted so `init()`'s `open()` switch stays a flat dispatch table.\n *\n * @param requestId Correlation id the widget attached to its\n * `request-session-code` message \u2014 echoed back unchanged so the\n * widget can match the response to the right in-flight request even\n * if several overlap.\n */\nasync function handleSessionCodeRequest(bridge, openOptions, requestId) {\n const provider = openOptions.getSessionCode;\n if (!provider) {\n // The widget asked for a session code on a partner that never\n // configured `getSessionCode` \u2014 this is an integration mistake\n // (likely a `connected`-model partner whose host page forgot the\n // callback). Surface it both as an event (for the host page's own\n // logging/monitoring) and back to the widget (so it can show the\n // end user a retry/contact-support state instead of hanging).\n const error = 'getSessionCode was not provided to open()';\n openOptions.onEvent?.({ type: 'session-code-error', requestId, error });\n bridge.send({\n source: protocol_1.HOST_TO_WIDGET_SOURCE,\n type: 'session-code-error',\n requestId,\n error,\n });\n return;\n }\n try {\n const code = await provider();\n bridge.send({\n source: protocol_1.HOST_TO_WIDGET_SOURCE,\n type: 'session-code',\n requestId,\n code,\n });\n }\n catch (error) {\n const errorMessage = error instanceof Error ? error.message : String(error);\n bridge.send({\n source: protocol_1.HOST_TO_WIDGET_SOURCE,\n type: 'session-code-error',\n requestId,\n error: errorMessage,\n });\n }\n}\n", "\"use strict\";\nObject.defineProperty(exports, \"__esModule\", { value: true });\nexports.init = void 0;\n/**\n * `@lympid/embed` \u2014 public entry point.\n *\n * ```ts\n * import { init } from '@lympid/embed';\n *\n * const widget = init({ baseUrl: 'https://invest.yourpartnerdomain.com' });\n *\n * widget.open({\n * flow: 'connect',\n * // Only needed for partners using the \"connected\" login model \u2014\n * // call YOUR OWN backend here, which in turn calls\n * // POST /api/v1/embed-sessions with your x-api-key.\n * getSessionCode: async () => {\n * const res = await fetch('/api/lympid/session-code', { method: 'POST' });\n * const { handoffCode } = await res.json();\n * return handoffCode;\n * },\n * onEvent: (event) => console.log('lympid event', event),\n * onClose: () => console.log('lympid widget closed'),\n * });\n * ```\n */\nvar embed_1 = require(\"./embed\");\nObject.defineProperty(exports, \"init\", { enumerable: true, get: function () { return embed_1.init; } });\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
|
+
"names": ["require_protocol", "__commonJSMin", "exports", "isWidgetToHostMessage", "data", "require_bridge", "__commonJSMin", "exports", "createBridge", "protocol_1", "iframe", "targetOrigin", "onMessage", "listener", "domEvent", "message", "require_modal", "__commonJSMin", "exports", "createModal", "Z_INDEX", "DEFAULT_HEIGHT_PX", "PANEL_BACKGROUND_LIGHT", "PANEL_BACKGROUND_DARK", "prefersDarkColorScheme", "createCloseIcon", "icon", "path", "container", "src", "onBackdropClick", "overlay", "panel", "colorSchemeMedia", "handleColorSchemeChange", "event", "closeButton", "iframe", "domEvent", "height", "clamped", "require_embed", "__commonJSMin", "exports", "init", "bridge_1", "modal_1", "protocol_1", "normalizeOrigin", "baseUrl", "buildIframeSrc", "origin", "flow", "partnerId", "rwaId", "params", "options", "container", "current", "destroyed", "teardownCurrent", "fireOnClose", "modal", "bridge", "onClose", "open", "openOptions", "src", "message", "handleSessionCodeRequest", "close", "destroy", "requestId", "provider", "error", "code", "errorMessage", "require_index", "__commonJSMin", "exports", "embed_1"]
|
|
7
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { type HostToWidgetMessage, type WidgetToHostMessage } from './protocol';
|
|
2
|
+
/**
|
|
3
|
+
* The `postMessage` bridge between the host page and one open widget
|
|
4
|
+
* iframe, restricted in both directions to the exact origin configured
|
|
5
|
+
* via `InitOptions.baseUrl`.
|
|
6
|
+
*
|
|
7
|
+
* This is the only place in the SDK that touches `window.postMessage`
|
|
8
|
+
* or `window.addEventListener('message', ...)` — keeping it isolated
|
|
9
|
+
* makes the origin-restriction invariant easy to audit in one file.
|
|
10
|
+
*/
|
|
11
|
+
export interface Bridge {
|
|
12
|
+
/** Sends a message to the widget iframe, targeted at `targetOrigin`
|
|
13
|
+
* only (never `'*'`). */
|
|
14
|
+
send: (message: HostToWidgetMessage) => void;
|
|
15
|
+
/** Tears down the `message` listener. Call once when the modal
|
|
16
|
+
* closes — a leaked listener would otherwise keep reacting to
|
|
17
|
+
* messages from a since-removed iframe. */
|
|
18
|
+
teardown: () => void;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* @param iframe The widget iframe this bridge talks to.
|
|
22
|
+
* @param targetOrigin The exact origin the iframe was loaded from
|
|
23
|
+
* (derived from `InitOptions.baseUrl`, never inferred from the
|
|
24
|
+
* iframe's `src` string, which a compromised/misconfigured iframe
|
|
25
|
+
* could have redirected away from).
|
|
26
|
+
* @param onMessage Called for every well-formed `WidgetToHostMessage`
|
|
27
|
+
* received from exactly `targetOrigin`. Messages from any other
|
|
28
|
+
* origin, or that don't match the `WidgetToHostMessage` shape, are
|
|
29
|
+
* silently dropped.
|
|
30
|
+
*/
|
|
31
|
+
export declare function createBridge(iframe: HTMLIFrameElement, targetOrigin: string, onMessage: (message: WidgetToHostMessage) => void): Bridge;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.createBridge = createBridge;
|
|
4
|
+
const protocol_1 = require("./protocol");
|
|
5
|
+
/**
|
|
6
|
+
* @param iframe The widget iframe this bridge talks to.
|
|
7
|
+
* @param targetOrigin The exact origin the iframe was loaded from
|
|
8
|
+
* (derived from `InitOptions.baseUrl`, never inferred from the
|
|
9
|
+
* iframe's `src` string, which a compromised/misconfigured iframe
|
|
10
|
+
* could have redirected away from).
|
|
11
|
+
* @param onMessage Called for every well-formed `WidgetToHostMessage`
|
|
12
|
+
* received from exactly `targetOrigin`. Messages from any other
|
|
13
|
+
* origin, or that don't match the `WidgetToHostMessage` shape, are
|
|
14
|
+
* silently dropped.
|
|
15
|
+
*/
|
|
16
|
+
function createBridge(iframe, targetOrigin, onMessage) {
|
|
17
|
+
const listener = (domEvent) => {
|
|
18
|
+
// Origin check FIRST, before touching `data` at all — this is the
|
|
19
|
+
// entire security boundary of the bridge. A mismatched origin is
|
|
20
|
+
// dropped unconditionally, regardless of how well-formed the
|
|
21
|
+
// payload looks.
|
|
22
|
+
if (domEvent.origin !== targetOrigin) {
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
if (domEvent.source !== iframe.contentWindow) {
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
if (!(0, protocol_1.isWidgetToHostMessage)(domEvent.data)) {
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
onMessage(domEvent.data);
|
|
32
|
+
};
|
|
33
|
+
window.addEventListener('message', listener);
|
|
34
|
+
return {
|
|
35
|
+
send: (message) => {
|
|
36
|
+
if (!iframe.contentWindow) {
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
iframe.contentWindow.postMessage({ ...message, source: protocol_1.HOST_TO_WIDGET_SOURCE }, targetOrigin);
|
|
40
|
+
},
|
|
41
|
+
teardown: () => {
|
|
42
|
+
window.removeEventListener('message', listener);
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { InitOptions, LympidEmbedInstance } from './types';
|
|
2
|
+
/**
|
|
3
|
+
* Creates a `LympidEmbedInstance` bound to one partner's whitelabel
|
|
4
|
+
* origin. See `InitOptions`/`OpenOptions` in `./types.ts` for the full
|
|
5
|
+
* contract, and `apps/whitelabel/src/app/embed/` (whitelabel-embed-mode)
|
|
6
|
+
* for the counterpart that runs inside the iframe.
|
|
7
|
+
*/
|
|
8
|
+
export declare function init(options: InitOptions): LympidEmbedInstance;
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.init = init;
|
|
4
|
+
const bridge_1 = require("./bridge");
|
|
5
|
+
const modal_1 = require("./modal");
|
|
6
|
+
const protocol_1 = require("./protocol");
|
|
7
|
+
/** Normalises a configured `baseUrl` into a bare origin string
|
|
8
|
+
* (`"https://host:port"`, no path/trailing slash) suitable both for
|
|
9
|
+
* building the iframe `src` and for `postMessage` origin checks. Throws
|
|
10
|
+
* early — at `init()` time, not at `open()` time — on a malformed URL,
|
|
11
|
+
* since a bad `baseUrl` is a partner-integration mistake that should
|
|
12
|
+
* fail loudly immediately rather than silently drop every widget
|
|
13
|
+
* message later. */
|
|
14
|
+
function normalizeOrigin(baseUrl) {
|
|
15
|
+
const url = new URL(baseUrl);
|
|
16
|
+
return url.origin;
|
|
17
|
+
}
|
|
18
|
+
function buildIframeSrc(origin, flow, partnerId, rwaId) {
|
|
19
|
+
const params = new URLSearchParams({ flow });
|
|
20
|
+
if (partnerId) {
|
|
21
|
+
params.set('partnerId', partnerId);
|
|
22
|
+
}
|
|
23
|
+
if (rwaId) {
|
|
24
|
+
params.set('rwaId', rwaId);
|
|
25
|
+
}
|
|
26
|
+
return `${origin}/embed?${params.toString()}`;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Creates a `LympidEmbedInstance` bound to one partner's whitelabel
|
|
30
|
+
* origin. See `InitOptions`/`OpenOptions` in `./types.ts` for the full
|
|
31
|
+
* contract, and `apps/whitelabel/src/app/embed/` (whitelabel-embed-mode)
|
|
32
|
+
* for the counterpart that runs inside the iframe.
|
|
33
|
+
*/
|
|
34
|
+
function init(options) {
|
|
35
|
+
const origin = normalizeOrigin(options.baseUrl);
|
|
36
|
+
const container = options.container ?? document.body;
|
|
37
|
+
let current = null;
|
|
38
|
+
let destroyed = false;
|
|
39
|
+
/** Tears down the currently-open modal/bridge, if any, optionally
|
|
40
|
+
* firing the `onClose` callback that was passed to the `open()` call
|
|
41
|
+
* being torn down. */
|
|
42
|
+
function teardownCurrent(fireOnClose) {
|
|
43
|
+
if (!current) {
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
const { modal, bridge, onClose } = current;
|
|
47
|
+
bridge.teardown();
|
|
48
|
+
modal.remove();
|
|
49
|
+
current = null;
|
|
50
|
+
if (fireOnClose && onClose) {
|
|
51
|
+
onClose();
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
function open(openOptions) {
|
|
55
|
+
if (destroyed) {
|
|
56
|
+
throw new Error('This LympidEmbedInstance has been destroyed and cannot be reused — call init() again.');
|
|
57
|
+
}
|
|
58
|
+
// Replace any already-open modal from this instance first — only
|
|
59
|
+
// one widget flow can sensibly be open at a time per instance.
|
|
60
|
+
teardownCurrent(true);
|
|
61
|
+
const src = buildIframeSrc(origin, openOptions.flow, options.partnerId, openOptions.rwaId);
|
|
62
|
+
const modal = (0, modal_1.createModal)(container, src, () => teardownCurrent(true));
|
|
63
|
+
const bridge = (0, bridge_1.createBridge)(modal.iframe, origin, (message) => {
|
|
64
|
+
switch (message.type) {
|
|
65
|
+
case 'ready': {
|
|
66
|
+
bridge.send({
|
|
67
|
+
source: protocol_1.HOST_TO_WIDGET_SOURCE,
|
|
68
|
+
type: 'init',
|
|
69
|
+
flow: openOptions.flow,
|
|
70
|
+
hasSessionCodeProvider: !!openOptions.getSessionCode,
|
|
71
|
+
partnerId: options.partnerId,
|
|
72
|
+
rwaId: openOptions.rwaId,
|
|
73
|
+
});
|
|
74
|
+
break;
|
|
75
|
+
}
|
|
76
|
+
case 'request-session-code': {
|
|
77
|
+
void handleSessionCodeRequest(bridge, openOptions, message.requestId);
|
|
78
|
+
break;
|
|
79
|
+
}
|
|
80
|
+
case 'resize': {
|
|
81
|
+
modal.setHeight(message.height);
|
|
82
|
+
break;
|
|
83
|
+
}
|
|
84
|
+
case 'event': {
|
|
85
|
+
openOptions.onEvent?.(message.event);
|
|
86
|
+
break;
|
|
87
|
+
}
|
|
88
|
+
case 'close': {
|
|
89
|
+
teardownCurrent(true);
|
|
90
|
+
break;
|
|
91
|
+
}
|
|
92
|
+
default:
|
|
93
|
+
break;
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
current = { modal, bridge, onClose: openOptions.onClose };
|
|
97
|
+
}
|
|
98
|
+
function close() {
|
|
99
|
+
teardownCurrent(true);
|
|
100
|
+
}
|
|
101
|
+
function destroy() {
|
|
102
|
+
teardownCurrent(true);
|
|
103
|
+
destroyed = true;
|
|
104
|
+
}
|
|
105
|
+
return { open, close, destroy };
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Resolves `openOptions.getSessionCode()` (if configured) and relays
|
|
109
|
+
* the result back to the widget as `session-code`/`session-code-error`.
|
|
110
|
+
* Extracted so `init()`'s `open()` switch stays a flat dispatch table.
|
|
111
|
+
*
|
|
112
|
+
* @param requestId Correlation id the widget attached to its
|
|
113
|
+
* `request-session-code` message — echoed back unchanged so the
|
|
114
|
+
* widget can match the response to the right in-flight request even
|
|
115
|
+
* if several overlap.
|
|
116
|
+
*/
|
|
117
|
+
async function handleSessionCodeRequest(bridge, openOptions, requestId) {
|
|
118
|
+
const provider = openOptions.getSessionCode;
|
|
119
|
+
if (!provider) {
|
|
120
|
+
// The widget asked for a session code on a partner that never
|
|
121
|
+
// configured `getSessionCode` — this is an integration mistake
|
|
122
|
+
// (likely a `connected`-model partner whose host page forgot the
|
|
123
|
+
// callback). Surface it both as an event (for the host page's own
|
|
124
|
+
// logging/monitoring) and back to the widget (so it can show the
|
|
125
|
+
// end user a retry/contact-support state instead of hanging).
|
|
126
|
+
const error = 'getSessionCode was not provided to open()';
|
|
127
|
+
openOptions.onEvent?.({ type: 'session-code-error', requestId, error });
|
|
128
|
+
bridge.send({
|
|
129
|
+
source: protocol_1.HOST_TO_WIDGET_SOURCE,
|
|
130
|
+
type: 'session-code-error',
|
|
131
|
+
requestId,
|
|
132
|
+
error,
|
|
133
|
+
});
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
try {
|
|
137
|
+
const code = await provider();
|
|
138
|
+
bridge.send({
|
|
139
|
+
source: protocol_1.HOST_TO_WIDGET_SOURCE,
|
|
140
|
+
type: 'session-code',
|
|
141
|
+
requestId,
|
|
142
|
+
code,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
catch (error) {
|
|
146
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
147
|
+
bridge.send({
|
|
148
|
+
source: protocol_1.HOST_TO_WIDGET_SOURCE,
|
|
149
|
+
type: 'session-code-error',
|
|
150
|
+
requestId,
|
|
151
|
+
error: errorMessage,
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@lympid/embed` — public entry point.
|
|
3
|
+
*
|
|
4
|
+
* ```ts
|
|
5
|
+
* import { init } from '@lympid/embed';
|
|
6
|
+
*
|
|
7
|
+
* const widget = init({ baseUrl: 'https://invest.yourpartnerdomain.com' });
|
|
8
|
+
*
|
|
9
|
+
* widget.open({
|
|
10
|
+
* flow: 'connect',
|
|
11
|
+
* // Only needed for partners using the "connected" login model —
|
|
12
|
+
* // call YOUR OWN backend here, which in turn calls
|
|
13
|
+
* // POST /api/v1/embed-sessions with your x-api-key.
|
|
14
|
+
* getSessionCode: async () => {
|
|
15
|
+
* const res = await fetch('/api/lympid/session-code', { method: 'POST' });
|
|
16
|
+
* const { handoffCode } = await res.json();
|
|
17
|
+
* return handoffCode;
|
|
18
|
+
* },
|
|
19
|
+
* onEvent: (event) => console.log('lympid event', event),
|
|
20
|
+
* onClose: () => console.log('lympid widget closed'),
|
|
21
|
+
* });
|
|
22
|
+
* ```
|
|
23
|
+
*/
|
|
24
|
+
export { init } from './embed';
|
|
25
|
+
export type { EmbedFlow, InitOptions, LympidEmbedEvent, LympidEmbedInstance, OpenOptions, } from './types';
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.init = void 0;
|
|
4
|
+
/**
|
|
5
|
+
* `@lympid/embed` — public entry point.
|
|
6
|
+
*
|
|
7
|
+
* ```ts
|
|
8
|
+
* import { init } from '@lympid/embed';
|
|
9
|
+
*
|
|
10
|
+
* const widget = init({ baseUrl: 'https://invest.yourpartnerdomain.com' });
|
|
11
|
+
*
|
|
12
|
+
* widget.open({
|
|
13
|
+
* flow: 'connect',
|
|
14
|
+
* // Only needed for partners using the "connected" login model —
|
|
15
|
+
* // call YOUR OWN backend here, which in turn calls
|
|
16
|
+
* // POST /api/v1/embed-sessions with your x-api-key.
|
|
17
|
+
* getSessionCode: async () => {
|
|
18
|
+
* const res = await fetch('/api/lympid/session-code', { method: 'POST' });
|
|
19
|
+
* const { handoffCode } = await res.json();
|
|
20
|
+
* return handoffCode;
|
|
21
|
+
* },
|
|
22
|
+
* onEvent: (event) => console.log('lympid event', event),
|
|
23
|
+
* onClose: () => console.log('lympid widget closed'),
|
|
24
|
+
* });
|
|
25
|
+
* ```
|
|
26
|
+
*/
|
|
27
|
+
var embed_1 = require("./embed");
|
|
28
|
+
Object.defineProperty(exports, "init", { enumerable: true, get: function () { return embed_1.init; } });
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Modal overlay + iframe DOM construction for the widget.
|
|
3
|
+
*
|
|
4
|
+
* Deliberately framework-agnostic (no React) and dependency-free — a
|
|
5
|
+
* handful of inline-styled `div`/`iframe` nodes is simpler, smaller, and
|
|
6
|
+
* safer than reaching for a UI library inside a script a partner embeds
|
|
7
|
+
* on their own page (no risk of CSS/class-name collisions with the
|
|
8
|
+
* partner's own stylesheet).
|
|
9
|
+
*/
|
|
10
|
+
export interface ModalHandle {
|
|
11
|
+
/** The widget's iframe element. */
|
|
12
|
+
iframe: HTMLIFrameElement;
|
|
13
|
+
/** Resizes the iframe (and its backdrop) to the widget's reported
|
|
14
|
+
* content height, clamped to the viewport. */
|
|
15
|
+
setHeight: (height: number) => void;
|
|
16
|
+
/** Removes every DOM node this modal created. */
|
|
17
|
+
remove: () => void;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Builds and appends the backdrop + iframe modal to `container`.
|
|
21
|
+
*
|
|
22
|
+
* @param container Parent node to append the overlay to.
|
|
23
|
+
* @param src The widget iframe's `src` URL (the partner's whitelabel
|
|
24
|
+
* origin + `/embed` route + flow query param).
|
|
25
|
+
* @param onBackdropClick Called when the end user clicks the backdrop
|
|
26
|
+
* or the close button — the caller decides whether that closes the
|
|
27
|
+
* modal (it always should; kept as a callback so `embed.ts` stays
|
|
28
|
+
* the single owner of close/`onClose` semantics).
|
|
29
|
+
*/
|
|
30
|
+
export declare function createModal(container: HTMLElement, src: string, onBackdropClick: () => void): ModalHandle;
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Modal overlay + iframe DOM construction for the widget.
|
|
4
|
+
*
|
|
5
|
+
* Deliberately framework-agnostic (no React) and dependency-free — a
|
|
6
|
+
* handful of inline-styled `div`/`iframe` nodes is simpler, smaller, and
|
|
7
|
+
* safer than reaching for a UI library inside a script a partner embeds
|
|
8
|
+
* on their own page (no risk of CSS/class-name collisions with the
|
|
9
|
+
* partner's own stylesheet).
|
|
10
|
+
*/
|
|
11
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
|
+
exports.createModal = createModal;
|
|
13
|
+
const Z_INDEX = 2147483000; // Comfortably above virtually any host-page UI.
|
|
14
|
+
const DEFAULT_HEIGHT_PX = 600;
|
|
15
|
+
/** Light/dark panel background, matching the whitelabel app's own
|
|
16
|
+
* `LEDGER_PAPER` surface token (`packages/shared-types/src/
|
|
17
|
+
* design-tokens.ts`) so the modal's own chrome doesn't flash a white
|
|
18
|
+
* box around a dark-themed widget. The host page has no visibility
|
|
19
|
+
* into the iframe's actual in-app theme setting (cross-origin, and the
|
|
20
|
+
* whitelabel app can toggle independently of OS preference), so this
|
|
21
|
+
* follows the OS-level `prefers-color-scheme` media query as the best
|
|
22
|
+
* available signal without a cross-origin theme-sync protocol. */
|
|
23
|
+
const PANEL_BACKGROUND_LIGHT = '#ffffff';
|
|
24
|
+
const PANEL_BACKGROUND_DARK = '#161B22';
|
|
25
|
+
function prefersDarkColorScheme() {
|
|
26
|
+
return (typeof window !== 'undefined' &&
|
|
27
|
+
typeof window.matchMedia === 'function' &&
|
|
28
|
+
window.matchMedia('(prefers-color-scheme: dark)').matches);
|
|
29
|
+
}
|
|
30
|
+
/** Close glyph centered in the 32px button. Path matches
|
|
31
|
+
* `apps/whitelabel/src/assets/images/svg/24/close.svg`. */
|
|
32
|
+
function createCloseIcon() {
|
|
33
|
+
const icon = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
|
34
|
+
icon.setAttribute('viewBox', '0 0 24 24');
|
|
35
|
+
icon.setAttribute('width', '20');
|
|
36
|
+
icon.setAttribute('height', '20');
|
|
37
|
+
icon.setAttribute('fill', 'none');
|
|
38
|
+
icon.setAttribute('aria-hidden', 'true');
|
|
39
|
+
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
|
40
|
+
path.setAttribute('d', 'M17 7 7 17M7 7l10 10');
|
|
41
|
+
path.setAttribute('stroke', 'currentColor');
|
|
42
|
+
path.setAttribute('stroke-width', '1.3');
|
|
43
|
+
path.setAttribute('stroke-linecap', 'round');
|
|
44
|
+
path.setAttribute('stroke-linejoin', 'round');
|
|
45
|
+
icon.appendChild(path);
|
|
46
|
+
return icon;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Builds and appends the backdrop + iframe modal to `container`.
|
|
50
|
+
*
|
|
51
|
+
* @param container Parent node to append the overlay to.
|
|
52
|
+
* @param src The widget iframe's `src` URL (the partner's whitelabel
|
|
53
|
+
* origin + `/embed` route + flow query param).
|
|
54
|
+
* @param onBackdropClick Called when the end user clicks the backdrop
|
|
55
|
+
* or the close button — the caller decides whether that closes the
|
|
56
|
+
* modal (it always should; kept as a callback so `embed.ts` stays
|
|
57
|
+
* the single owner of close/`onClose` semantics).
|
|
58
|
+
*/
|
|
59
|
+
function createModal(container, src, onBackdropClick) {
|
|
60
|
+
const overlay = document.createElement('div');
|
|
61
|
+
overlay.setAttribute('data-lympid-embed', 'overlay');
|
|
62
|
+
overlay.style.position = 'fixed';
|
|
63
|
+
overlay.style.inset = '0';
|
|
64
|
+
overlay.style.zIndex = String(Z_INDEX);
|
|
65
|
+
overlay.style.display = 'flex';
|
|
66
|
+
overlay.style.alignItems = 'center';
|
|
67
|
+
overlay.style.justifyContent = 'center';
|
|
68
|
+
overlay.style.backgroundColor = 'rgba(15, 23, 42, 0.6)';
|
|
69
|
+
const panel = document.createElement('div');
|
|
70
|
+
panel.setAttribute('data-lympid-embed', 'panel');
|
|
71
|
+
panel.style.position = 'relative';
|
|
72
|
+
panel.style.width = 'min(420px, calc(100vw - 32px))';
|
|
73
|
+
panel.style.height = `min(${DEFAULT_HEIGHT_PX}px, calc(100vh - 32px))`;
|
|
74
|
+
panel.style.maxHeight = 'calc(100vh - 32px)';
|
|
75
|
+
panel.style.borderRadius = '16px';
|
|
76
|
+
panel.style.overflow = 'hidden';
|
|
77
|
+
panel.style.boxShadow = '0 20px 60px rgba(0, 0, 0, 0.35)';
|
|
78
|
+
panel.style.backgroundColor = prefersDarkColorScheme()
|
|
79
|
+
? PANEL_BACKGROUND_DARK
|
|
80
|
+
: PANEL_BACKGROUND_LIGHT;
|
|
81
|
+
panel.style.transition = 'height 0.2s ease';
|
|
82
|
+
// Keep the panel's background in step if the OS-level preference
|
|
83
|
+
// changes while the modal is open (e.g. a scheduled light/dark
|
|
84
|
+
// switch, or the user flips it in their OS settings mid-session).
|
|
85
|
+
let colorSchemeMedia = null;
|
|
86
|
+
let handleColorSchemeChange = null;
|
|
87
|
+
if (typeof window !== 'undefined' && typeof window.matchMedia === 'function') {
|
|
88
|
+
colorSchemeMedia = window.matchMedia('(prefers-color-scheme: dark)');
|
|
89
|
+
handleColorSchemeChange = (event) => {
|
|
90
|
+
panel.style.backgroundColor = event.matches
|
|
91
|
+
? PANEL_BACKGROUND_DARK
|
|
92
|
+
: PANEL_BACKGROUND_LIGHT;
|
|
93
|
+
};
|
|
94
|
+
colorSchemeMedia.addEventListener('change', handleColorSchemeChange);
|
|
95
|
+
}
|
|
96
|
+
const closeButton = document.createElement('button');
|
|
97
|
+
closeButton.setAttribute('data-lympid-embed', 'close');
|
|
98
|
+
closeButton.setAttribute('aria-label', 'Close');
|
|
99
|
+
closeButton.type = 'button';
|
|
100
|
+
closeButton.style.position = 'absolute';
|
|
101
|
+
closeButton.style.top = '8px';
|
|
102
|
+
closeButton.style.right = '8px';
|
|
103
|
+
closeButton.style.zIndex = String(Z_INDEX + 1);
|
|
104
|
+
closeButton.style.display = 'flex';
|
|
105
|
+
closeButton.style.alignItems = 'center';
|
|
106
|
+
closeButton.style.justifyContent = 'center';
|
|
107
|
+
closeButton.style.width = '32px';
|
|
108
|
+
closeButton.style.height = '32px';
|
|
109
|
+
closeButton.style.margin = '0';
|
|
110
|
+
closeButton.style.padding = '0';
|
|
111
|
+
closeButton.style.border = 'none';
|
|
112
|
+
closeButton.style.borderRadius = '50%';
|
|
113
|
+
closeButton.style.backgroundColor = 'rgba(15, 23, 42, 0.06)';
|
|
114
|
+
closeButton.style.color = '#585858';
|
|
115
|
+
closeButton.style.cursor = 'pointer';
|
|
116
|
+
closeButton.style.appearance = 'none';
|
|
117
|
+
closeButton.style.setProperty('-webkit-appearance', 'none');
|
|
118
|
+
// Same stroke as the whitelabel `Icon` named `close`
|
|
119
|
+
// (`src/assets/images/svg/24/close.svg`), which every other modal
|
|
120
|
+
// uses. A text "×" sits on the font baseline, so it looks high, and
|
|
121
|
+
// a host `button { padding }` rule shifts it further. Flex + a reset
|
|
122
|
+
// padding keeps the glyph centered on the partner page.
|
|
123
|
+
closeButton.appendChild(createCloseIcon());
|
|
124
|
+
const iframe = document.createElement('iframe');
|
|
125
|
+
iframe.setAttribute('data-lympid-embed', 'iframe');
|
|
126
|
+
iframe.src = src;
|
|
127
|
+
iframe.style.width = '100%';
|
|
128
|
+
iframe.style.height = '100%';
|
|
129
|
+
iframe.style.border = 'none';
|
|
130
|
+
iframe.title = 'Lympid';
|
|
131
|
+
// Phase 1 flows (connect/sign-in, KYC) need neither top-level
|
|
132
|
+
// navigation nor same-origin escalation beyond what the whitelabel
|
|
133
|
+
// app itself needs to run (forms, scripts, camera for KYC).
|
|
134
|
+
iframe.setAttribute('allow', 'camera; microphone; clipboard-write');
|
|
135
|
+
iframe.setAttribute('sandbox', 'allow-scripts allow-same-origin allow-forms allow-popups');
|
|
136
|
+
closeButton.addEventListener('click', onBackdropClick);
|
|
137
|
+
overlay.addEventListener('click', (domEvent) => {
|
|
138
|
+
if (domEvent.target === overlay) {
|
|
139
|
+
onBackdropClick();
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
panel.appendChild(closeButton);
|
|
143
|
+
panel.appendChild(iframe);
|
|
144
|
+
overlay.appendChild(panel);
|
|
145
|
+
container.appendChild(overlay);
|
|
146
|
+
return {
|
|
147
|
+
iframe,
|
|
148
|
+
setHeight: (height) => {
|
|
149
|
+
const clamped = Math.max(240, Math.min(height, (typeof window !== 'undefined' ? window.innerHeight : height) - 32));
|
|
150
|
+
panel.style.height = `${clamped}px`;
|
|
151
|
+
},
|
|
152
|
+
remove: () => {
|
|
153
|
+
if (colorSchemeMedia && handleColorSchemeChange) {
|
|
154
|
+
colorSchemeMedia.removeEventListener('change', handleColorSchemeChange);
|
|
155
|
+
}
|
|
156
|
+
overlay.remove();
|
|
157
|
+
},
|
|
158
|
+
};
|
|
159
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { EmbedFlow, LympidEmbedEvent } from './types';
|
|
2
|
+
/**
|
|
3
|
+
* `postMessage` wire protocol between the host page (this SDK) and the
|
|
4
|
+
* widget iframe (the whitelabel app running in embed mode — see
|
|
5
|
+
* `whitelabel-embed-mode` in the Phase 1 plan).
|
|
6
|
+
*
|
|
7
|
+
* Both directions are only ever accepted/sent against the exact origin
|
|
8
|
+
* configured via `InitOptions.baseUrl` — see `createBridge()` in
|
|
9
|
+
* `./bridge.ts`. This file only defines the message *shapes*; it has
|
|
10
|
+
* zero DOM/`window` dependencies so it can be unit-tested directly.
|
|
11
|
+
*/
|
|
12
|
+
/** Discriminator so a `postMessage` listener can cheaply ignore every
|
|
13
|
+
* unrelated message landing on the same `window` (browser extensions,
|
|
14
|
+
* other SDKs, etc.) before even checking the origin. */
|
|
15
|
+
export declare const HOST_TO_WIDGET_SOURCE = "lympid-embed-host";
|
|
16
|
+
export declare const WIDGET_TO_HOST_SOURCE = "lympid-embed-widget";
|
|
17
|
+
export type HostToWidgetMessage = {
|
|
18
|
+
source: typeof HOST_TO_WIDGET_SOURCE;
|
|
19
|
+
type: 'init';
|
|
20
|
+
flow: EmbedFlow;
|
|
21
|
+
/** True when the host page supplied a `getSessionCode` callback,
|
|
22
|
+
* i.e. this partner uses the `connected` login model. The widget
|
|
23
|
+
* uses this to decide whether to request a session code or run
|
|
24
|
+
* its own `lympid-code` email/phone + OTP UI. */
|
|
25
|
+
hasSessionCodeProvider: boolean;
|
|
26
|
+
/** `InitOptions.partnerId`, if the host page supplied one. Only
|
|
27
|
+
* required for `flow: 'register'` — see that field's own doc
|
|
28
|
+
* comment in `types.ts` for why registration needs this and the
|
|
29
|
+
* other flows don't. An untrusted hint, like the iframe URL's own
|
|
30
|
+
* `partnerId` query param; the widget's registration calls carry
|
|
31
|
+
* it to the API, which re-validates it server-side. */
|
|
32
|
+
partnerId?: string;
|
|
33
|
+
/** `OpenOptions.rwaId`, if the host page supplied one. Only
|
|
34
|
+
* meaningful for `flow: 'invest'`. */
|
|
35
|
+
rwaId?: string;
|
|
36
|
+
} | {
|
|
37
|
+
source: typeof HOST_TO_WIDGET_SOURCE;
|
|
38
|
+
type: 'session-code';
|
|
39
|
+
requestId: string;
|
|
40
|
+
code: string;
|
|
41
|
+
} | {
|
|
42
|
+
source: typeof HOST_TO_WIDGET_SOURCE;
|
|
43
|
+
type: 'session-code-error';
|
|
44
|
+
requestId: string;
|
|
45
|
+
error: string;
|
|
46
|
+
};
|
|
47
|
+
export type WidgetToHostMessage = {
|
|
48
|
+
source: typeof WIDGET_TO_HOST_SOURCE;
|
|
49
|
+
type: 'ready';
|
|
50
|
+
} | {
|
|
51
|
+
source: typeof WIDGET_TO_HOST_SOURCE;
|
|
52
|
+
type: 'request-session-code';
|
|
53
|
+
requestId: string;
|
|
54
|
+
} | {
|
|
55
|
+
source: typeof WIDGET_TO_HOST_SOURCE;
|
|
56
|
+
type: 'resize';
|
|
57
|
+
height: number;
|
|
58
|
+
} | {
|
|
59
|
+
source: typeof WIDGET_TO_HOST_SOURCE;
|
|
60
|
+
type: 'event';
|
|
61
|
+
event: LympidEmbedEvent;
|
|
62
|
+
} | {
|
|
63
|
+
source: typeof WIDGET_TO_HOST_SOURCE;
|
|
64
|
+
type: 'close';
|
|
65
|
+
};
|
|
66
|
+
/** Narrows an arbitrary `MessageEvent.data` payload to a
|
|
67
|
+
* `WidgetToHostMessage`, without throwing on malformed input — a
|
|
68
|
+
* `postMessage` listener must never crash the host page just because
|
|
69
|
+
* some unrelated script on the same window posted something shaped
|
|
70
|
+
* differently. */
|
|
71
|
+
export declare function isWidgetToHostMessage(data: unknown): data is WidgetToHostMessage;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.WIDGET_TO_HOST_SOURCE = exports.HOST_TO_WIDGET_SOURCE = void 0;
|
|
4
|
+
exports.isWidgetToHostMessage = isWidgetToHostMessage;
|
|
5
|
+
/**
|
|
6
|
+
* `postMessage` wire protocol between the host page (this SDK) and the
|
|
7
|
+
* widget iframe (the whitelabel app running in embed mode — see
|
|
8
|
+
* `whitelabel-embed-mode` in the Phase 1 plan).
|
|
9
|
+
*
|
|
10
|
+
* Both directions are only ever accepted/sent against the exact origin
|
|
11
|
+
* configured via `InitOptions.baseUrl` — see `createBridge()` in
|
|
12
|
+
* `./bridge.ts`. This file only defines the message *shapes*; it has
|
|
13
|
+
* zero DOM/`window` dependencies so it can be unit-tested directly.
|
|
14
|
+
*/
|
|
15
|
+
/** Discriminator so a `postMessage` listener can cheaply ignore every
|
|
16
|
+
* unrelated message landing on the same `window` (browser extensions,
|
|
17
|
+
* other SDKs, etc.) before even checking the origin. */
|
|
18
|
+
exports.HOST_TO_WIDGET_SOURCE = 'lympid-embed-host';
|
|
19
|
+
exports.WIDGET_TO_HOST_SOURCE = 'lympid-embed-widget';
|
|
20
|
+
/** Narrows an arbitrary `MessageEvent.data` payload to a
|
|
21
|
+
* `WidgetToHostMessage`, without throwing on malformed input — a
|
|
22
|
+
* `postMessage` listener must never crash the host page just because
|
|
23
|
+
* some unrelated script on the same window posted something shaped
|
|
24
|
+
* differently. */
|
|
25
|
+
function isWidgetToHostMessage(data) {
|
|
26
|
+
return (typeof data === 'object' &&
|
|
27
|
+
data !== null &&
|
|
28
|
+
data.source === exports.WIDGET_TO_HOST_SOURCE &&
|
|
29
|
+
typeof data.type === 'string');
|
|
30
|
+
}
|